10.3. Отладка в container
Цели
После этого материала вы сможете:
- подключить отладчик IDE к процессу внутри container через
debugpy; - объяснить, почему
breakpoint()не работает подdocker compose upи как это исправить; - отлаживать приложение с несколькими worker-процессами;
- снять трассировку стека работающего процесса без остановки;
- исследовать container, в образе которого нет оболочки;
- понимать, почему логирование остаётся основным инструментом.
Предварительные знания
- 10.2. Hot reload;
- 8.5. Network modes — общий namespace;
- 6.6. Logging.
Ключевые термины
| Термин | Объяснение |
|---|---|
debugpy | Реализация Debug Adapter Protocol для Python |
DAP | Протокол, по которому IDE общается с отладчиком |
attach | Подключение отладчика к уже работающему процессу |
stdin_open, tty | Ключи Compose, дающие интерактивный ввод |
SYS_PTRACE | Capability, нужная для чтения памяти чужого процесса |
faulthandler | Модуль Python: дамп стека по сигналу |
Теория
Три способа отладки и когда какой
| Способ | Когда применять | Требует |
|---|---|---|
| Логи | Всегда, первое действие | Ничего |
debugpy плюс IDE | Нужны точки останова и переменные | Порт, пакет в dev-образе |
pdb через attach | Быстрая проверка без IDE | stdin_open, tty |
| Дамп стека по сигналу | Процесс завис | faulthandler |
| Профилировщик | Медленно, но не падает | SYS_PTRACE или общий PID namespace |
Логи остаются основным инструментом. В контейнеризованной среде процесс может жить секунды, перезапускаться и работать в нескольких экземплярах — подключиться отладчиком к нужному не всегда возможно. Структурированные логи работают всегда (урок 6.6).
Почему breakpoint() не работает под up
pdb требует интерактивного ввода. При docker compose up процесс запущен без терминала, stdin закрыт, и отладчик немедленно получает EOF:
Traceback (most recent call last):
...
bdb.BdbQuit
Или зависает, не реагируя на ввод.
Исправление — два ключа плюс отдельная команда подключения:
services:
api:
stdin_open: true # эквивалент docker run -i
tty: true # эквивалент docker run -t
docker compose up -d
docker attach <проект>-api-1 # теперь ввод доходит до pdb
Выход из attach без остановки container'а — Ctrl-P, Ctrl-Q.
Способ рабочий, но неудобный: он занимает терминал и не переживает перезапуск процесса. Для регулярной отладки лучше debugpy.
debugpy: подключение из IDE
import debugpy
debugpy.listen(("0.0.0.0", 5678))
debugpy.wait_for_client() # блокирует до подключения IDE
| Функция | Назначение |
|---|---|
listen(адрес) | Открыть порт для отладчика |
wait_for_client() | Ждать подключения; без него процесс продолжит работу |
breakpoint() | Точка останова в коде (после listen) |
debugpy.breakpoint() | То же, явно через debugpy |
Запуск без правки кода:
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 — сложно и редко оправдано |
Практическое правило: на время отладки — один процесс.
Дамп стека без остановки
Приложение зависло. Отладчик не подключён. Нужно узнать, где именно.
import faulthandler
import signal
faulthandler.register(signal.SIGUSR1) # дамп по сигналу
docker compose exec api kill -USR1 1
docker compose logs api --tail 30
Стек всех потоков попадёт в stderr. Приложение при этом продолжает работать.
Модуль входит в стандартную библиотеку — устанавливать ничего не нужно. Переменная PYTHONFAULTHANDLER=1 дополнительно включает дамп при аварийном завершении (урок 6.4).
Профилирование работающего процесса
py-spy читает память чужого процесса, поэтому требует SYS_PTRACE:
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 не покажет исходник.
{
"pathMappings": [
{"localRoot": "${workspaceFolder}/app", "remoteRoot": "/app/app"}
]
}
Симптом отсутствующего сопоставления: отладчик останавливается, но окно с кодом пустое или показывает не тот файл.
Почему wait_for_client иногда обязателен
Без него процесс стартует немедленно, и код, выполняющийся при импорте, отработает до подключения IDE. Точки останова в нём не сработают.
С ним процесс ждёт — но и healthcheck не проходит, и depends_on: service_healthy у соседей не выполняется. Поэтому включают его только когда нужно отладить именно инициализацию.
Команды и примеры
breakpoint() под up не работает
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 — где-то ошибка"
Ожидаемый вывод:
═══ ошибка видна в результате ═══
{"base":100.0,"discount":15.0,"final":98.5,"pid":12}
ожидалось final=85.0, получено 98.5 — где-то ошибка
Теперь попробуем поставить точку останова обычным способом:
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/^/ /'
Ожидаемый вывод:
═══ запрос с 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 работать
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/^/ /'
Ожидаемый вывод:
═══ теперь 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 и не даёт просмотра кода.
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: подключение отладчика
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/^/ /'
Ожидаемый вывод:
═══ порт отладчика открыт ═══
127.0.0.1:5678
отладчик принимает подключения
═══ приложение при этом работает ═══
{"base":100.0,"discount":15.0,"final":98.5,"pid":1}
Без --wait-for-client приложение стартовало сразу и обслуживает запросы. IDE может подключиться в любой момент.
Конфигурация для VS Code:
{
"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 для отладки инициализации
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
Ожидаемый вывод:
═══ приложение ждёт отладчик ═══
HTTP на 8200: 000 (не отвечает)
═══ последствие: healthcheck не пройдёт ═══
Поэтому --wait-for-client включают только для отладки инициализации,
и никогда — в конфигурации, где есть depends_on: service_healthy.
Отладка при нескольких worker'ах
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
Ожидаемый вывод:
═══ запросы попадают в разные процессы ═══
pid: 9
pid: 10
pid: 11
pid: 9
═══ что в логах отладчика ═══
ошибок привязки порта: 0
Отладчик привязан к процессу-родителю, а запросы обрабатывают три потомка. Точка останова в обработчике либо не сработает, либо сработает непредсказуемо.
Правило простое: на время отладки --workers 1.
Дамп стека зависшего процесса
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
Ожидаемый вывод:
═══ запрос зависает ═══
/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
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 по-прежнему отсутствует"'
Ожидаемый вывод:
═══ в образе приложения 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 без оболочки
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
Ожидаемый вывод:
═══ (образ 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 и подтвердите шесть утверждений.
breakpoint()подdocker compose upне работает — воспроизвести и объяснить.- С
stdin_openиttyплюсdocker attachон работает. debugpyоткрывает порт, приложение при этом обслуживает запросы.--wait-for-clientблокирует старт — показать последствие для healthcheck.- При нескольких worker'ах запросы попадают в разные процессы — измерить.
- Дамп стека по сигналу снимается без остановки приложения.
Дополнительно: найдите с помощью отладки ошибку в вычислении и исправьте её.
Подсказки
Подсказка 1
Для пункта 2 команды pdb можно подать в docker attach через конвейер, не занимая терминал вручную.
Подсказка 2
Пункт 6 требует faulthandler.register(signal.SIGUSR1) в коде и kill -USR1 1 внутри container'а.
Подсказка 3
Для пункта 5 endpoint должен возвращать os.getpid().
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Ошибка, которую предстоит найти ═══
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, когда отладчик останавливается, а исходник не открывается.
Проверка результата
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'а |
| Забыли опубликовать порт отладчика | Настроили только --listen | IDE не подключится |
debugpy в production-образе | Один образ на всё | Открытый порт — удалённое выполнение кода |
| Отладка при нескольких worker'ах | Не задумывались | Точка останова срабатывает непредсказуемо |
--wait-for-client в общей конфигурации | Скопировали пример | Healthcheck не проходит, зависимости ждут |
Нет pathMappings в конфигурации IDE | Не знали о необходимости | Отладчик останавливается, исходник не виден |
--reload вместе с debugpy | Обе настройки для разработки | Перезапуск рвёт сессию отладки |
Устанавливают py-spy в образ приложения | Нужен профилировщик | --pid container: с образом-инструментом |
Ищут sh в distroless-образе | Привычка | Оболочки нет; исследовать снаружи |
Контрольные вопросы
На понимание:
- Почему
breakpoint()не работает подdocker compose up? - Что делают ключи
stdin_openиtty? - Почему
debugpyдолжен слушать0.0.0.0, а не127.0.0.1? - Что происходит при
--wait-for-clientи почему это ломает healthcheck? - Почему при нескольких worker'ах отладка ненадёжна?
На применение:
- Как снять стек зависшего процесса, не останавливая его?
- Как запустить профилировщик, не устанавливая его в образ приложения?
- Как исследовать container без оболочки? Назовите четыре приёма.
На диагностику:
- Отладчик останавливается, но IDE показывает пустое окно. Причина?
- Приложение не отвечает после запуска с отладчиком. Что проверить?
Краткое резюме
- Логи остаются основным инструментом: процесс может жить секунды и работать в нескольких экземплярах.
pdbтребуетstdin; подdocker compose upего нет — нужныstdin_openиttyплюсdocker attach.- Выход из
attachбез остановки container'а —Ctrl-P,Ctrl-Q. debugpyдолжен слушать0.0.0.0, и его порт нужно опубликовать.debugpyустанавливают только в dev-образ: открытый порт отладчика — уязвимость.- Без
--wait-for-clientприложение стартует сразу; с ним — ждёт IDE и не проходит healthcheck. pathMappingsв конфигурации IDE обязателен: пути в container и на host различаются.- На время отладки число worker-процессов сводят к одному.
faulthandler.register(signal.SIGUSR1)даёт дамп стека без остановки процесса.--pid container:Xс--cap-add SYS_PTRACEпозволяет профилировать чужой процесс.- Инструменты приходят из отдельного образа — исследуемый container не меняется.
- Container без оболочки исследуют через
docker cp, общие namespace иdocker inspect.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| debugpy | https://github.com/microsoft/debugpy | --listen, --wait-for-client, API |
| VS Code: Python debugging | https://code.visualstudio.com/docs/python/debugging | attach, pathMappings |
| Compose: services | https://docs.docker.com/reference/compose-file/services/#stdin_open | stdin_open, tty |
Docker: docker attach | https://docs.docker.com/reference/cli/docker/container/attach/ | Подключение к запущенному container |
Docker: docker exec | https://docs.docker.com/reference/cli/docker/container/exec/ | Выполнение команд, флаги -it |
Docker: docker cp | https://docs.docker.com/reference/cli/docker/container/cp/ | Копирование без входа в container |
Python: faulthandler | https://docs.python.org/3/library/faulthandler.html | register, дамп по сигналу |
Python: pdb | https://docs.python.org/3/library/pdb.html | breakpoint(), команды отладчика |
| py-spy | https://github.com/benfred/py-spy | dump, требования к правам |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Database migrations
Главное оглавление