Главная/Development workflow/Урок

10.3. Отладка в container

Цели

После этого материала вы сможете:

  • подключить отладчик IDE к процессу внутри container через debugpy;
  • объяснить, почему breakpoint() не работает под docker compose up и как это исправить;
  • отлаживать приложение с несколькими worker-процессами;
  • снять трассировку стека работающего процесса без остановки;
  • исследовать container, в образе которого нет оболочки;
  • понимать, почему логирование остаётся основным инструментом.

Предварительные знания

Ключевые термины

ТерминОбъяснение
debugpyРеализация Debug Adapter Protocol для Python
DAPПротокол, по которому IDE общается с отладчиком
attachПодключение отладчика к уже работающему процессу
stdin_open, ttyКлючи Compose, дающие интерактивный ввод
SYS_PTRACECapability, нужная для чтения памяти чужого процесса
faulthandlerМодуль Python: дамп стека по сигналу

Теория

Три способа отладки и когда какой

СпособКогда применятьТребует
ЛогиВсегда, первое действиеНичего
debugpy плюс IDEНужны точки останова и переменныеПорт, пакет в dev-образе
pdb через attachБыстрая проверка без IDEstdin_open, tty
Дамп стека по сигналуПроцесс зависfaulthandler
ПрофилировщикМедленно, но не падаетSYS_PTRACE или общий PID namespace

Логи остаются основным инструментом. В контейнеризованной среде процесс может жить секунды, перезапускаться и работать в нескольких экземплярах — подключиться отладчиком к нужному не всегда возможно. Структурированные логи работают всегда (урок 6.6).

Почему breakpoint() не работает под up

pdb требует интерактивного ввода. При docker compose up процесс запущен без терминала, stdin закрыт, и отладчик немедленно получает EOF:

text
Traceback (most recent call last):
  ...
bdb.BdbQuit

Или зависает, не реагируя на ввод.

Исправление — два ключа плюс отдельная команда подключения:

yaml
services:
  api:
    stdin_open: true      # эквивалент docker run -i
    tty: true             # эквивалент docker run -t
bash
docker compose up -d
docker attach <проект>-api-1        # теперь ввод доходит до pdb

Выход из attach без остановки container'а — Ctrl-P, Ctrl-Q.

Способ рабочий, но неудобный: он занимает терминал и не переживает перезапуск процесса. Для регулярной отладки лучше debugpy.

debugpy: подключение из IDE

python
import debugpy

debugpy.listen(("0.0.0.0", 5678))
debugpy.wait_for_client()          # блокирует до подключения IDE
ФункцияНазначение
listen(адрес)Открыть порт для отладчика
wait_for_client()Ждать подключения; без него процесс продолжит работу
breakpoint()Точка останова в коде (после listen)
debugpy.breakpoint()То же, явно через debugpy

Запуск без правки кода:

bash
python -m debugpy --listen 0.0.0.0:5678 --wait-for-client -m uvicorn app.main:app

Три обязательных условия в container:

УсловиеПочему
--listen 0.0.0.0, не 127.0.0.1Иначе порт недоступен снаружи (урок 8.3)
Публикация порта 5678Иначе IDE не подключится
debugpy только в dev-образеВ production он не нужен и опасен

Открытый порт отладчика — это удалённое выполнение кода. В production его быть не должно (урок 10.1).

Отладка при нескольких worker-процессах

Точка останова срабатывает в том процессе, который обработал запрос. При нескольких worker'ах предсказать, в каком именно, невозможно — а debugpy.listen на одном порту во втором процессе упадёт с «адрес занят».

СитуацияРешение
Gunicorn или Uvicorn с --workers NДля отладки поставить --workers 1
Compose с deploy.replicasОтлаживать один экземпляр: --scale api=1
Нужно отладить конкретный workerСлушать разные порты по PID — сложно и редко оправдано

Практическое правило: на время отладки — один процесс.

Дамп стека без остановки

Приложение зависло. Отладчик не подключён. Нужно узнать, где именно.

python
import faulthandler
import signal

faulthandler.register(signal.SIGUSR1)     # дамп по сигналу
bash
docker compose exec api kill -USR1 1
docker compose logs api --tail 30

Стек всех потоков попадёт в stderr. Приложение при этом продолжает работать.

Модуль входит в стандартную библиотеку — устанавливать ничего не нужно. Переменная PYTHONFAULTHANDLER=1 дополнительно включает дамп при аварийном завершении (урок 6.4).

Профилирование работающего процесса

py-spy читает память чужого процесса, поэтому требует SYS_PTRACE:

bash
docker run --rm --pid container:myapp --cap-add SYS_PTRACE \
    python:3.13-slim sh -c 'pip install -q py-spy && py-spy dump --pid 1'

Ключевые флаги:

ФлагЗачем
--pid container:myappОбщий PID namespace: процессы видны
--cap-add SYS_PTRACEРазрешение читать память

Такой подход не требует ничего устанавливать в исследуемый образ — инструменты приходят из другого (урок 8.6).

Когда в образе нет оболочки

Минимальные образы (distroless, scratch) не содержат sh. docker exec -it ... sh не работает.

ЗадачаРешение
Посмотреть файлыdocker cp container:/path ./local
Посмотреть процессы--pid container:X с образом-инструментом
Посмотреть сеть--network container:X или nsenter
Выполнить командуНевозможно; исследовать снаружи
Посмотреть переменныеdocker inspect

Отсутствие оболочки — осознанное решение в пользу безопасности, и отладка таких образов ведётся снаружи. Для разработки при этом используют обычный образ с оболочкой (урок 10.1).


Внутренний механизм

Как IDE находит файлы

Отладчик сообщает IDE пути внутри container'а: /app/app/main.py. У вас на диске это ./app/main.py. Без сопоставления IDE не покажет исходник.

json
{
  "pathMappings": [
    {"localRoot": "${workspaceFolder}/app", "remoteRoot": "/app/app"}
  ]
}

Симптом отсутствующего сопоставления: отладчик останавливается, но окно с кодом пустое или показывает не тот файл.

Почему wait_for_client иногда обязателен

Без него процесс стартует немедленно, и код, выполняющийся при импорте, отработает до подключения IDE. Точки останова в нём не сработают.

С ним процесс ждёт — но и healthcheck не проходит, и depends_on: service_healthy у соседей не выполняется. Поэтому включают его только когда нужно отладить именно инициализацию.


Команды и примеры

breakpoint() под up не работает

bash
mkdir -p /tmp/dbg/app && cd /tmp/dbg

cat > app/__init__.py <<'PY'
"""Приложение для демонстрации отладки."""
PY

cat > app/main.py <<'PY'
"""Приложение с намеренной ошибкой в вычислении."""
from __future__ import annotations

import os

from fastapi import FastAPI

app = FastAPI()


def compute_discount(price: float, percent: float) -> float:
    """Здесь ошибка: делится на 10 вместо 100."""
    factor = percent / 10          # должно быть 100
    return round(price * (1 - factor), 2)


@app.get("/price")
async def price(base: float = 100.0, discount: float = 15.0) -> dict[str, object]:
    return {"base": base, "discount": discount,
            "final": compute_discount(base, discount), "pid": os.getpid()}


@app.get("/healthz")
async def healthz() -> dict[str, str]:
    return {"status": "ok"}
PY

cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF

cat > requirements-dev.txt <<'EOF'
-r requirements.txt
debugpy==1.8.19
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONFAULTHANDLER=1 PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv
WORKDIR /app

FROM base AS builder
COPY requirements.txt .
RUN pip install -r requirements.txt

FROM builder AS dev
COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"]

FROM base AS runtime
COPY --from=builder /opt/venv /opt/venv
COPY app/ ./app/
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF

cat > compose.yaml <<'EOF'
name: dbg

services:
  api:
    build:
      context: .
      target: dev
    ports:
      - "127.0.0.1:8200:8000"
    volumes:
      - ./app:/app/app
EOF

docker compose up -d --build > /dev/null 2>&1
sleep 8

echo "═══ ошибка видна в результате ═══"
curl -s "localhost:8200/price?base=100&discount=15" | python3 -m json.tool --compact | sed 's/^/  /'
echo "  ожидалось final=85.0, получено 98.5 — где-то ошибка"

Ожидаемый вывод:

text
═══ ошибка видна в результате ═══
  {"base":100.0,"discount":15.0,"final":98.5,"pid":12}
  ожидалось final=85.0, получено 98.5 — где-то ошибка

Теперь попробуем поставить точку останова обычным способом:

bash
cd /tmp/dbg
python3 - <<'PY'
import pathlib
p = pathlib.Path("app/main.py")
t = p.read_text()
t = t.replace("    factor = percent / 10          # должно быть 100",
              "    breakpoint()                   # обычный pdb\n"
              "    factor = percent / 10          # должно быть 100")
p.write_text(t)
PY
sleep 4

echo "═══ запрос с breakpoint() под docker compose up ═══"
timeout 8 curl -s "localhost:8200/price" > /dev/null 2>&1
sleep 1
docker compose logs api --no-log-prefix --tail 8 2>/dev/null | tail -5 | sed 's/^/  /'

Ожидаемый вывод:

text
═══ запрос с breakpoint() под docker compose up ═══
  > /app/app/main.py(14)compute_discount()
  -> factor = percent / 10          # должно быть 100
  (Pdb) 
  *** NameError: name 'ping' is not defined
  bdb.BdbQuit

pdb запустился, но ввода не получил: stdin закрыт. Он прочитал мусор из потока и завершился с BdbQuit.

Запрос при этом завис или вернул ошибку — приложение фактически сломано.

Как заставить pdb работать

bash
cd /tmp/dbg
cat > compose.pdb.yaml <<'EOF'
services:
  api:
    stdin_open: true      # docker run -i
    tty: true             # docker run -t
EOF

docker compose -f compose.yaml -f compose.pdb.yaml up -d > /dev/null 2>&1
sleep 8

echo "═══ теперь stdin открыт ═══"
docker inspect "$(docker compose ps -q api)" \
    --format '  OpenStdin={{.Config.OpenStdin}} Tty={{.Config.Tty}}'

echo "═══ подключаемся и отвечаем pdb ═══"
curl -s -m 20 "localhost:8200/price?base=100&discount=15" > /tmp/dbg/result.txt 2>&1 &
sleep 2
# Отправляем команды pdb: печать переменной и продолжение
printf 'p percent\np percent / 100\nc\n' | timeout 10 docker attach "$(docker compose ps -q api)" > /tmp/dbg/pdb.txt 2>&1 || true
wait
echo "  что показал pdb:"
grep -E '^[0-9.]+$' /tmp/dbg/pdb.txt | head -2 | sed 's/^/    /'
echo "  ответ приложения:"
cat /tmp/dbg/result.txt | sed 's/^/    /'

Ожидаемый вывод:

text
═══ теперь stdin открыт ═══
  OpenStdin=true Tty=true
═══ подключаемся и отвечаем pdb ═══
  что показал pdb:
    15.0
    0.15
  ответ приложения:
    {"base":100.0,"discount":15.0,"final":98.5,"pid":12}

Отладчик заработал: p percent / 100 дал 0.15, а в коде используется percent / 10 — ошибка найдена.

Способ рабочий, но неудобен: занимает терминал, ломается при перезапуске от --reload и не даёт просмотра кода.

bash
cd /tmp/dbg
python3 - <<'PY'
import pathlib
p = pathlib.Path("app/main.py")
t = p.read_text().replace("    breakpoint()                   # обычный pdb\n", "")
p.write_text(t)
PY
docker compose down > /dev/null 2>&1

debugpy: подключение отладчика

bash
cd /tmp/dbg
cat > compose.debug.yaml <<'EOF'
services:
  api:
    command:
      - python
      - -m
      - debugpy
      - --listen
      - 0.0.0.0:5678          # 0.0.0.0, иначе порт недоступен снаружи
      - -m
      - uvicorn
      - app.main:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
      # --reload несовместим с отладчиком: перезапуск рвёт сессию
    ports:
      - "127.0.0.1:5678:5678"
EOF

docker compose -f compose.yaml -f compose.debug.yaml up -d --build > /dev/null 2>&1
sleep 8

echo "═══ порт отладчика открыт ═══"
docker compose -f compose.yaml -f compose.debug.yaml port api 5678 | sed 's/^/  /'
python3 - <<'PY'
import socket
s = socket.socket(); s.settimeout(3)
try:
    s.connect(("127.0.0.1", 5678))
    print("  отладчик принимает подключения")
except OSError as e:
    print(f"  недоступен: {type(e).__name__}")
finally:
    s.close()
PY

echo "═══ приложение при этом работает ═══"
curl -s "localhost:8200/price?base=100&discount=15" | python3 -m json.tool --compact | sed 's/^/  /'

Ожидаемый вывод:

text
═══ порт отладчика открыт ═══
  127.0.0.1:5678
  отладчик принимает подключения
═══ приложение при этом работает ═══
  {"base":100.0,"discount":15.0,"final":98.5,"pid":1}

Без --wait-for-client приложение стартовало сразу и обслуживает запросы. IDE может подключиться в любой момент.

Конфигурация для VS Code:

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Docker: attach",
      "type": "debugpy",
      "request": "attach",
      "connect": {"host": "127.0.0.1", "port": 5678},
      "pathMappings": [
        {"localRoot": "${workspaceFolder}/app", "remoteRoot": "/app/app"}
      ],
      "justMyCode": true
    }
  ]
}

Раздел pathMappings обязателен: без него отладчик остановится, но IDE не найдёт исходник.

wait_for_client для отладки инициализации

bash
cd /tmp/dbg
cat > compose.waitdebug.yaml <<'EOF'
services:
  api:
    command:
      - python
      - -m
      - debugpy
      - --listen
      - 0.0.0.0:5678
      - --wait-for-client        # процесс НЕ стартует до подключения IDE
      - -m
      - uvicorn
      - app.main:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
    ports:
      - "127.0.0.1:5678:5678"
EOF

docker compose -f compose.yaml -f compose.waitdebug.yaml up -d > /dev/null 2>&1
sleep 6

echo "═══ приложение ждёт отладчик ═══"
printf '  HTTP на 8200: %s\n' \
    "$(curl -s -m 3 -o /dev/null -w '%{http_code}' localhost:8200/healthz 2>/dev/null || echo '000 (не отвечает)')"
docker compose -f compose.yaml -f compose.waitdebug.yaml logs api --no-log-prefix --tail 3 2>/dev/null | sed 's/^/  /'

echo "═══ последствие: healthcheck не пройдёт ═══"
echo "  Поэтому --wait-for-client включают только для отладки инициализации,"
echo "  и никогда — в конфигурации, где есть depends_on: service_healthy."

docker compose -f compose.yaml -f compose.waitdebug.yaml down > /dev/null 2>&1

Ожидаемый вывод:

text
═══ приложение ждёт отладчик ═══
  HTTP на 8200: 000 (не отвечает)
═══ последствие: healthcheck не пройдёт ═══
  Поэтому --wait-for-client включают только для отладки инициализации,
  и никогда — в конфигурации, где есть depends_on: service_healthy.

Отладка при нескольких worker'ах

bash
cd /tmp/dbg
cat > compose.workers.yaml <<'EOF'
services:
  api:
    command:
      - python
      - -m
      - debugpy
      - --listen
      - 0.0.0.0:5678
      - -m
      - uvicorn
      - app.main:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
      - --workers
      - "3"
    ports:
      - "127.0.0.1:5678:5678"
EOF

docker compose -f compose.yaml -f compose.workers.yaml up -d > /dev/null 2>&1
sleep 8

echo "═══ запросы попадают в разные процессы ═══"
for _ in 1 2 3 4; do
    curl -s "localhost:8200/price" | python3 -c 'import json,sys; print("  pid:", json.load(sys.stdin)["pid"])'
done

echo "═══ что в логах отладчика ═══"
docker compose -f compose.yaml -f compose.workers.yaml logs api --no-log-prefix 2>/dev/null \
    | grep -icE 'address already in use|error' | xargs printf '  ошибок привязки порта: %s\n'

docker compose -f compose.yaml -f compose.workers.yaml down > /dev/null 2>&1

Ожидаемый вывод:

text
═══ запросы попадают в разные процессы ═══
  pid: 9
  pid: 10
  pid: 11
  pid: 9
═══ что в логах отладчика ═══
  ошибок привязки порта: 0

Отладчик привязан к процессу-родителю, а запросы обрабатывают три потомка. Точка останова в обработчике либо не сработает, либо сработает непредсказуемо.

Правило простое: на время отладки --workers 1.

Дамп стека зависшего процесса

bash
cd /tmp/dbg
cat > app/hang.py <<'PY'
"""Приложение с намеренным зависанием."""
import faulthandler
import signal
import threading
import time

from fastapi import FastAPI

# Дамп стека всех потоков по SIGUSR1 — процесс продолжит работу
faulthandler.register(signal.SIGUSR1)

app = FastAPI()
_lock = threading.Lock()


def blocked_operation() -> None:
    """Захватывает блокировку и не отпускает."""
    _lock.acquire()
    time.sleep(3600)


@app.get("/healthz")
async def healthz() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/hang")
def hang() -> dict[str, str]:
    """Синхронный обработчик: заблокирует поток пула."""
    with _lock:
        time.sleep(3600)
    return {"never": "reached"}


@app.on_event("startup")
def start_blocker() -> None:
    threading.Thread(target=blocked_operation, daemon=True, name="blocker").start()
PY

cat > compose.hang.yaml <<'EOF'
services:
  api:
    command:
      - uvicorn
      - app.hang:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
EOF

docker compose -f compose.yaml -f compose.hang.yaml up -d > /dev/null 2>&1
sleep 8

echo "═══ запрос зависает ═══"
timeout 4 curl -s "localhost:8200/hang" > /dev/null 2>&1 &
sleep 3
printf '  /healthz отвечает: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' localhost:8200/healthz)"

echo "═══ снимаем дамп стека без остановки ═══"
docker compose -f compose.yaml -f compose.hang.yaml exec -T api kill -USR1 1
sleep 2
docker compose -f compose.yaml -f compose.hang.yaml logs api --no-log-prefix --tail 25 2>/dev/null \
    | grep -A4 -E 'Thread.*blocker|Current thread' | head -12 | sed 's/^/  /'

echo "═══ приложение продолжает работать ═══"
printf '  /healthz после дампа: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' localhost:8200/healthz)"

docker compose -f compose.yaml -f compose.hang.yaml down > /dev/null 2>&1

Ожидаемый вывод:

text
═══ запрос зависает ═══
  /healthz отвечает: 200
═══ снимаем дамп стека без остановки ═══
  Thread 0x00007f2a1c3d5640 (most recent call first):
    File "/app/app/hang.py", line 19 in blocked_operation
    File "/usr/local/lib/python3.13/threading.py", line 1012 in run
  Thread 0x00007f2a1d4e6640 (most recent call first):
    File "/app/app/hang.py", line 32 in hang
═══ приложение продолжает работать ═══
  /healthz после дампа: 200

Дамп прямо указывает на строки: поток blocker стоит в blocked_operation, обработчик /hang — на строке 32, где ждёт ту же блокировку.

Ключевое свойство: приложение не останавливалось. /healthz отвечает до и после — это делает приём применимым и в production.

Профилирование через общий PID namespace

bash
cd /tmp/dbg
docker compose up -d > /dev/null 2>&1
sleep 8
cid="$(docker compose ps -q api)"

echo "═══ в образе приложения py-spy нет ═══"
docker compose exec -T api sh -c 'command -v py-spy || echo "  py-spy отсутствует"'

echo "═══ подключаем инструмент из другого образа ═══"
docker run --rm --pid "container:$cid" --cap-add SYS_PTRACE \
    python:3.13-slim sh -c '
      pip install -q py-spy 2>/dev/null
      echo "  процессы, видимые из общего namespace:"
      ps -o pid,comm | head -4 | sed "s/^/    /"
      echo "  дамп стека PID 1:"
      py-spy dump --pid 1 2>/dev/null | head -12 | sed "s/^/    /"
    '

echo "═══ образ приложения не изменился ═══"
docker compose exec -T api sh -c 'command -v py-spy || echo "  py-spy по-прежнему отсутствует"'

Ожидаемый вывод:

text
═══ в образе приложения py-spy нет ═══
  py-spy отсутствует
═══ подключаем инструмент из другого образа ═══
  процессы, видимые из общего namespace:
    PID COMMAND
      1 python
     14 sh
  дамп стека PID 1:
    Process 1: /opt/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0
    Python v3.13.9

    Thread 1 (idle): "MainThread"
        select (selectors.py:398)
        _run_once (asyncio/base_events.py:1922)
        run_forever (asyncio/base_events.py:641)
═══ образ приложения не изменился ═══
  py-spy по-прежнему отсутствует

Инструмент пришёл из другого образа, увидел процессы через общий PID namespace и снял стек. Исследуемый container при этом не менялся — важно, если то же нужно сделать в production.

Флаг --cap-add SYS_PTRACE обязателен: без него чтение памяти чужого процесса запрещено (раздел 12).

Container без оболочки

bash
cd /tmp/dbg
cat > Dockerfile.noshell <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN /opt/venv/bin/pip install -q -r requirements.txt

FROM gcr.io/distroless/python3-debian12
COPY --from=builder /opt/venv /opt/venv
COPY app/ /app/app/
WORKDIR /app
ENV PYTHONPATH=/opt/venv/lib/python3.11/site-packages
CMD ["/app/app/main.py"]
EOF

echo "═══ (образ distroless может быть недоступен — проверяем) ═══"
if docker pull -q gcr.io/distroless/python3-debian12 > /dev/null 2>&1; then
    echo "  образ получен"
    docker run --rm --entrypoint sh gcr.io/distroless/python3-debian12 -c 'echo привет' 2>&1 \
        | tail -1 | sed 's/^/  exec sh: /'
else
    echo "  образ недоступен в этой среде; поведение показано ниже на примере scratch"
fi

echo "═══ приёмы, работающие без оболочки ═══"
cid="$(docker compose ps -q api)"
echo "  1. Копирование файлов наружу:"
docker cp "$cid:/app/app/main.py" /tmp/dbg/copied.py && \
    printf '     скопировано %s байт\n' "$(wc -c < /tmp/dbg/copied.py)"

echo "  2. Просмотр процессов через общий PID namespace:"
docker run --rm --pid "container:$cid" alpine:3.21 ps -o pid,args | head -3 | sed 's/^/     /'

echo "  3. Просмотр сети через общий network namespace:"
docker run --rm --network "container:$cid" alpine:3.21 sh -c \
    'apk add --no-cache iproute2 > /dev/null 2>&1; ss -tln | tail -2' | sed 's/^/     /'

echo "  4. Метаданные без входа в container:"
docker inspect "$cid" --format '     CMD={{json .Config.Cmd}}' 
docker inspect "$cid" --format '     User={{if .Config.User}}{{.Config.User}}{{else}}root{{end}}'

rm -f /tmp/dbg/copied.py
docker compose down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/dbg

Ожидаемый вывод:

text
═══ (образ distroless может быть недоступен — проверяем) ═══
  образ получен
  exec sh: exec: "sh": executable file not found in $PATH
═══ приёмы, работающие без оболочки ═══
  1. Копирование файлов наружу:
     скопировано 612 байт
  2. Просмотр процессов через общий PID namespace:
     PID   COMMAND
       1   /opt/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
  3. Просмотр сети через общий network namespace:
     LISTEN 0  2048  0.0.0.0:8000  0.0.0.0:*
  4. Метаданные без входа в container:
     CMD=["uvicorn","app.main:app","--host","0.0.0.0","--port","8000","--reload","--reload-dir","app"]
     User=root

Четыре приёма покрывают большинство задач диагностики без единой команды внутри container'а.


Практическое упражнение

Задание. Настройте отладку приложения в container и подтвердите шесть утверждений.

  1. breakpoint() под docker compose up не работает — воспроизвести и объяснить.
  2. С stdin_open и tty плюс docker attach он работает.
  3. debugpy открывает порт, приложение при этом обслуживает запросы.
  4. --wait-for-client блокирует старт — показать последствие для healthcheck.
  5. При нескольких worker'ах запросы попадают в разные процессы — измерить.
  6. Дамп стека по сигналу снимается без остановки приложения.

Дополнительно: найдите с помощью отладки ошибку в вычислении и исправьте её.

Подсказки

Подсказка 1

Для пункта 2 команды pdb можно подать в docker attach через конвейер, не занимая терминал вручную.

Подсказка 2

Пункт 6 требует faulthandler.register(signal.SIGUSR1) в коде и kill -USR1 1 внутри container'а.

Подсказка 3

Для пункта 5 endpoint должен возвращать os.getpid().

Решение

Показать решение
bash
mkdir -p /tmp/dbgfull/app && cd /tmp/dbgfull

cat > app/__init__.py <<'PY'
"""Приложение для практикума по отладке."""
PY

cat > app/main.py <<'PY'
"""Приложение с ошибкой в вычислении скидки и средствами отладки."""
from __future__ import annotations

import faulthandler
import os
import signal
import threading
import time

from fastapi import FastAPI

# Пункт 6: дамп стека по сигналу, без остановки процесса
faulthandler.register(signal.SIGUSR1)

app = FastAPI()
_lock = threading.Lock()

DIVISOR = 10          # ОШИБКА: должно быть 100


def compute_discount(price: float, percent: float) -> float:
    factor = percent / DIVISOR
    return round(price * (1 - factor), 2)


@app.get("/healthz")
async def healthz() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/price")
async def price(base: float = 100.0, discount: float = 15.0) -> dict[str, object]:
    return {
        "base": base,
        "discount": discount,
        "final": compute_discount(base, discount),
        "divisor": DIVISOR,
        "pid": os.getpid(),
    }


@app.get("/hang")
def hang() -> dict[str, str]:
    """Синхронный обработчик: занимает поток пула надолго."""
    with _lock:
        time.sleep(600)
    return {"never": "reached"}


@app.on_event("startup")
def start_blocker() -> None:
    def blocked() -> None:
        _lock.acquire()
        time.sleep(600)
    threading.Thread(target=blocked, daemon=True, name="blocker").start()
PY

cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF

cat > requirements-dev.txt <<'EOF'
-r requirements.txt
debugpy==1.8.19
EOF

cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
compose*.yaml
Dockerfile
.dockerignore
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONFAULTHANDLER=1 \
    PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv
WORKDIR /app

FROM base AS builder
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# debugpy только в dev: в production открытый порт отладчика — уязвимость
FROM builder AS dev
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

FROM base AS runtime
COPY --from=builder /opt/venv /opt/venv
COPY app/ ./app/
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF

cat > compose.yaml <<'EOF'
name: dbgfull

services:
  api:
    build:
      context: .
      target: dev
    ports:
      - "127.0.0.1:8300:8000"
    volumes:
      - ./app:/app/app
EOF

cat > compose.pdb.yaml <<'EOF'
services:
  api:
    stdin_open: true
    tty: true
EOF

cat > compose.debugpy.yaml <<'EOF'
services:
  api:
    command:
      - python
      - -m
      - debugpy
      - --listen
      - 0.0.0.0:5678
      - -m
      - uvicorn
      - app.main:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
    ports:
      - "127.0.0.1:5678:5678"
EOF

cat > compose.wait.yaml <<'EOF'
services:
  api:
    command:
      - python
      - -m
      - debugpy
      - --listen
      - 0.0.0.0:5678
      - --wait-for-client
      - -m
      - uvicorn
      - app.main:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
    ports:
      - "127.0.0.1:5678:5678"
EOF

cat > compose.workers.yaml <<'EOF'
services:
  api:
    command:
      - uvicorn
      - app.main:app
      - --host
      - 0.0.0.0
      - --port
      - "8000"
      - --workers
      - "3"
EOF

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
code() { curl -s -m 4 -o /dev/null -w '%{http_code}' "http://127.0.0.1:8300$1" 2>/dev/null || echo 000; }
field() { curl -s -m 5 "http://127.0.0.1:8300/price?base=100&discount=15" \
          | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])" 2>/dev/null; }

printf '\n═══ Ошибка, которую предстоит найти ═══\n'
docker compose up -d --build > /dev/null 2>&1
for _ in $(seq 40); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
printf '    base=100 discount=15 → final=%s (ожидалось 85.0)\n' "$(field final)"

printf '\n═══ Пункт 1: breakpoint() под up не работает ═══\n'
python3 - <<'PY'
import pathlib
p = pathlib.Path("app/main.py")
t = p.read_text().replace("    factor = percent / DIVISOR",
                          "    breakpoint()\n    factor = percent / DIVISOR")
p.write_text(t)
PY
docker compose restart api > /dev/null 2>&1
for _ in $(seq 30); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
timeout 8 curl -s -m 6 "http://127.0.0.1:8300/price" > /dev/null 2>&1 || true
sleep 2
if docker compose logs api --no-log-prefix 2>/dev/null | grep -qiE 'BdbQuit|Pdb.*EOF|bdb'; then
    docker compose logs api --no-log-prefix 2>/dev/null | grep -iE 'BdbQuit|bdb' | tail -1 | sed 's/^/    /'
    ok "pdb получил EOF — stdin закрыт (пункт 1)"
else
    docker compose logs api --no-log-prefix --tail 3 2>/dev/null | sed 's/^/    /'
    ok "pdb не смог работать без stdin (пункт 1)"
fi

printf '\n═══ Пункт 2: stdin_open + tty + attach ═══\n'
docker compose -f compose.yaml -f compose.pdb.yaml up -d > /dev/null 2>&1
for _ in $(seq 30); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
cid="$(docker compose -f compose.yaml -f compose.pdb.yaml ps -q api)"
printf '    OpenStdin=%s Tty=%s\n' \
    "$(docker inspect "$cid" --format '{{.Config.OpenStdin}}')" \
    "$(docker inspect "$cid" --format '{{.Config.Tty}}')"

curl -s -m 25 "http://127.0.0.1:8300/price?base=100&discount=15" > /tmp/dbgfull/resp.txt 2>&1 &
curl_pid=$!
sleep 3
printf 'p percent\np DIVISOR\np percent / 100\nc\n' \
    | timeout 15 docker attach "$cid" > /tmp/dbgfull/pdb.txt 2>&1 || true
wait $curl_pid 2>/dev/null
printf '    вывод pdb:\n'
grep -E '^[0-9.]+$' /tmp/dbgfull/pdb.txt | head -3 | sed 's/^/      /'
if grep -qE '^15\.0$' /tmp/dbgfull/pdb.txt && grep -qE '^10$' /tmp/dbgfull/pdb.txt; then
    ok "отладчик ответил: percent=15.0, DIVISOR=10 — ошибка найдена (пункт 2)"
else
    bad "pdb не дал ожидаемых значений"
fi

# Исправляем найденную ошибку
python3 - <<'PY'
import pathlib
p = pathlib.Path("app/main.py")
t = p.read_text()
t = t.replace("    breakpoint()\n", "")
t = t.replace("DIVISOR = 10          # ОШИБКА: должно быть 100",
              "DIVISOR = 100         # исправлено по результатам отладки")
p.write_text(t)
PY
docker compose -f compose.yaml -f compose.pdb.yaml restart api > /dev/null 2>&1
for _ in $(seq 30); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
printf '    после исправления: final=%s\n' "$(field final)"
[ "$(field final)" = "85.0" ] && ok "ошибка исправлена" || bad "final=$(field final)"

printf '\n═══ Пункт 3: debugpy без блокировки ═══\n'
docker compose -f compose.yaml -f compose.debugpy.yaml up -d > /dev/null 2>&1
for _ in $(seq 40); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
reachable="$(python3 -c "
import socket
s = socket.socket(); s.settimeout(3)
try:
    s.connect(('127.0.0.1', 5678)); print('да')
except OSError:
    print('нет')
finally:
    s.close()
")"
printf '    порт 5678 принимает: %s, /healthz=%s, /price final=%s\n' \
    "$reachable" "$(code /healthz)" "$(field final)"
[ "$reachable" = "да" ] && [ "$(code /healthz)" = "200" ] \
    && ok "отладчик слушает, приложение работает (пункт 3)" || bad "отладчик недоступен"

printf '\n═══ Пункт 4: --wait-for-client блокирует старт ═══\n'
docker compose -f compose.yaml -f compose.wait.yaml up -d > /dev/null 2>&1
sleep 8
printf '    /healthz: %s\n' "$(code /healthz)"
[ "$(code /healthz)" = "000" ] \
    && ok "приложение ждёт подключения — healthcheck не пройдёт (пункт 4)" \
    || bad "приложение стартовало: $(code /healthz)"

printf '\n═══ Пункт 5: несколько worker-процессов ═══\n'
docker compose -f compose.yaml -f compose.workers.yaml up -d > /dev/null 2>&1
for _ in $(seq 40); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
pids=""
for _ in $(seq 8); do pids="$pids $(field pid)"; done
uniq_n="$(echo "$pids" | tr ' ' '\n' | grep -v '^$' | sort -u | wc -l)"
printf '    PID обработчиков:%s\n' "$pids"
printf '    различных процессов: %s\n' "$uniq_n"
[ "$uniq_n" -gt 1 ] && ok "запросы распределяются — точка останова непредсказуема (пункт 5)" \
    || bad "все запросы в одном процессе"

printf '\n═══ Пункт 6: дамп стека без остановки ═══\n'
docker compose up -d > /dev/null 2>&1
for _ in $(seq 40); do [ "$(code /healthz)" = "200" ] && break; sleep 1; done
timeout 5 curl -s -m 4 "http://127.0.0.1:8300/hang" > /dev/null 2>&1 &
sleep 3
before="$(code /healthz)"
docker compose exec -T api kill -USR1 1
sleep 2
after="$(code /healthz)"
printf '    /healthz до дампа=%s, после=%s\n' "$before" "$after"
dump_lines="$(docker compose logs api --no-log-prefix 2>/dev/null | grep -cE 'File "/app/app/main.py"' || true)"
printf '    строк исходника в дампе: %s\n' "$dump_lines"
docker compose logs api --no-log-prefix 2>/dev/null | grep -E 'File "/app/app/main.py"' | head -3 | sed 's/^/      /'
[ "$dump_lines" -ge 1 ] && [ "$after" = "200" ] \
    && ok "стек снят, приложение продолжает работать (пункт 6)" \
    || bad "дамп=$dump_lines healthz=$after"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все шесть утверждений подтверждены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/dbgfull
exit "$fail"

Ожидаемый вывод:

text
═══ Ошибка, которую предстоит найти ═══
    base=100 discount=15 → final=98.5 (ожидалось 85.0)

═══ Пункт 1: breakpoint() под up не работает ═══
    bdb.BdbQuit
  ✓ pdb получил EOF — stdin закрыт (пункт 1)

═══ Пункт 2: stdin_open + tty + attach ═══
    OpenStdin=true Tty=true
    вывод pdb:
      15.0
      10
      0.15
  ✓ отладчик ответил: percent=15.0, DIVISOR=10 — ошибка найдена (пункт 2)
    после исправления: final=85.0
  ✓ ошибка исправлена

═══ Пункт 3: debugpy без блокировки ═══
    порт 5678 принимает: да, /healthz=200, /price final=85.0
  ✓ отладчик слушает, приложение работает (пункт 3)

═══ Пункт 4: --wait-for-client блокирует старт ═══
    /healthz: 000
  ✓ приложение ждёт подключения — healthcheck не пройдёт (пункт 4)

═══ Пункт 5: несколько worker-процессов ═══
    PID обработчиков: 9 10 11 9 10 11 9 10
    различных процессов: 3
  ✓ запросы распределяются — точка останова непредсказуема (пункт 5)

═══ Пункт 6: дамп стека без остановки ═══
    /healthz до дампа=200, после=200
    строк исходника в дампе: 4
      File "/app/app/main.py", line 52 in blocked
      File "/app/app/main.py", line 45 in hang
  ✓ стек снят, приложение продолжает работать (пункт 6)

═══ ИТОГ ═══
  все шесть утверждений подтверждены

Все шесть утверждений подтверждены, ошибка найдена и исправлена.

Три решения, определяющие качество.

Команды pdb подаются в docker attach через конвейер. Интерактивная сессия не поддаётся автоматической проверке — скрипт зависнет, ожидая ввода человека. Конвейер printf ... | docker attach делает проверку воспроизводимой и при этом использует ровно тот механизм, что и ручная отладка. Команда c в конце обязательна: без неё процесс останется в отладчике и curl не дождётся ответа.

Ошибка ищется отладчиком, а не читается из кода. Скрипт печатает percent=15.0 и DIVISOR=10 — то, что увидел бы человек в сессии. Исправление вносится после этого. Порядок важен: он показывает отладку как способ получить знание, а не как ритуал вокруг уже известного ответа.

Пункт 6 проверяет /healthz до и после дампа. Само наличие стека в логах доказывает лишь, что сигнал сработал. Два одинаковых 200 доказывают главное свойство приёма — приложение не остановилось, а значит, им можно пользоваться и в production.

Чего решение не делает. Подключение IDE к debugpy не проверяется: это требует запущенного редактора, чего нет в автоматической проверке. Проверяется лишь то, что порт принимает соединения, — необходимое условие, но не достаточное. Не покрыт и вопрос сопоставления путей (pathMappings): его отсутствие проявляется именно в IDE, когда отладчик останавливается, а исходник не открывается.

Проверка результата

bash
mkdir -p /tmp/dc && cd /tmp/dc
cat > app.py <<'PY'
import faulthandler, signal, time
faulthandler.register(signal.SIGUSR1)
print("запущен, PID 1", flush=True)
while True:
    time.sleep(1)
PY
docker run -d --name dc -v "$PWD/app.py:/app.py:ro" python:3.13-slim python -u /app.py > /dev/null
sleep 2
docker exec dc kill -USR1 1
sleep 1
docker logs dc 2>&1 | tail -4
docker rm -f dc > /dev/null; cd /tmp && rm -rf /tmp/dc

Ожидается дамп стека с указанием строки в /app.py и продолжение работы процесса.

Типичные ошибки

ОшибкаПричинаИсправление
breakpoint() под docker compose upПривычка с локального запускаНет stdin; нужны stdin_open и tty
debugpy --listen 127.0.0.1Скопировали из локального примераПорт недоступен снаружи container'а
Забыли опубликовать порт отладчикаНастроили только --listenIDE не подключится
debugpy в production-образеОдин образ на всёОткрытый порт — удалённое выполнение кода
Отладка при нескольких worker'ахНе задумывалисьТочка останова срабатывает непредсказуемо
--wait-for-client в общей конфигурацииСкопировали примерHealthcheck не проходит, зависимости ждут
Нет pathMappings в конфигурации IDEНе знали о необходимостиОтладчик останавливается, исходник не виден
--reload вместе с debugpyОбе настройки для разработкиПерезапуск рвёт сессию отладки
Устанавливают py-spy в образ приложенияНужен профилировщик--pid container: с образом-инструментом
Ищут sh в distroless-образеПривычкаОболочки нет; исследовать снаружи

Контрольные вопросы

На понимание:

  1. Почему breakpoint() не работает под docker compose up?
  2. Что делают ключи stdin_open и tty?
  3. Почему debugpy должен слушать 0.0.0.0, а не 127.0.0.1?
  4. Что происходит при --wait-for-client и почему это ломает healthcheck?
  5. Почему при нескольких worker'ах отладка ненадёжна?

На применение:

  1. Как снять стек зависшего процесса, не останавливая его?
  2. Как запустить профилировщик, не устанавливая его в образ приложения?
  3. Как исследовать container без оболочки? Назовите четыре приёма.

На диагностику:

  1. Отладчик останавливается, но IDE показывает пустое окно. Причина?
  2. Приложение не отвечает после запуска с отладчиком. Что проверить?

Краткое резюме

  1. Логи остаются основным инструментом: процесс может жить секунды и работать в нескольких экземплярах.
  2. pdb требует stdin; под docker compose up его нет — нужны stdin_open и tty плюс docker attach.
  3. Выход из attach без остановки container'а — Ctrl-P, Ctrl-Q.
  4. debugpy должен слушать 0.0.0.0, и его порт нужно опубликовать.
  5. debugpy устанавливают только в dev-образ: открытый порт отладчика — уязвимость.
  6. Без --wait-for-client приложение стартует сразу; с ним — ждёт IDE и не проходит healthcheck.
  7. pathMappings в конфигурации IDE обязателен: пути в container и на host различаются.
  8. На время отладки число worker-процессов сводят к одному.
  9. faulthandler.register(signal.SIGUSR1) даёт дамп стека без остановки процесса.
  10. --pid container:X с --cap-add SYS_PTRACE позволяет профилировать чужой процесс.
  11. Инструменты приходят из отдельного образа — исследуемый container не меняется.
  12. Container без оболочки исследуют через docker cp, общие namespace и docker inspect.

Официальные источники

ИсточникСсылкаЧто подтверждает
debugpyhttps://github.com/microsoft/debugpy--listen, --wait-for-client, API
VS Code: Python debugginghttps://code.visualstudio.com/docs/python/debuggingattach, pathMappings
Compose: serviceshttps://docs.docker.com/reference/compose-file/services/#stdin_openstdin_open, tty
Docker: docker attachhttps://docs.docker.com/reference/cli/docker/container/attach/Подключение к запущенному container
Docker: docker exechttps://docs.docker.com/reference/cli/docker/container/exec/Выполнение команд, флаги -it
Docker: docker cphttps://docs.docker.com/reference/cli/docker/container/cp/Копирование без входа в container
Python: faulthandlerhttps://docs.python.org/3/library/faulthandler.htmlregister, дамп по сигналу
Python: pdbhttps://docs.python.org/3/library/pdb.htmlbreakpoint(), команды отладчика
py-spyhttps://github.com/benfred/py-spydump, требования к правам

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Database migrations
Главное оглавление

Markdown на GitHub ↗