Проект 2. Список проверок
Проходить после реализации и до чтения SOLUTION.md.
Весь список прогнан против собранного образа: Docker Engine 29.7.1, 2026-08-04, 27 подтверждений из 27. Скрипт прогона —
verify/61-project2.sh; он собирает образ из блоковSOLUTION.md, то есть проверяет ровно то, что напечатано в курсе. Прогон нашёл в эталонном решении три дефекта — они исправлены и разобраны в SOLUTION.md.
Подготовка
docker build -t eventapi .
docker run -d --name api -p 8000:8000 eventapi
sleep 3
post() {
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8000/events \
-H 'content-type: application/json' -d "$1"
}
1. Валидация
post '{"path":"/api/items","method":"GET","status":200,"duration_ms":12}'
post '{"path":"без слеша","method":"GET","status":200,"duration_ms":1}'
post '{"path":"/a","method":"FETCH","status":200,"duration_ms":1}'
post '{"path":"/a","method":"GET","status":99,"duration_ms":1}'
post '{"path":"/a","method":"GET","status":200,"duration_ms":-1}'
post '{"path":"/a","method":"GET","status":200,"duration_ms":1,"durationms":2}'
201
422
422
422
422
422
Сообщение должно называть поле:
curl -s -X POST localhost:8000/events -H 'content-type: application/json' \
-d '{"path":"/a","method":"GET","status":99,"duration_ms":1}' | python3 -m json.tool
{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "status"],
"msg": "Input should be greater than or equal to 100",
...
}
]
}
☐ Корректное событие — 201
☐ Каждый вид ошибки — 422
☐ Лишнее поле — 422, а не молчаливое игнорирование
☐ В ответе назван конкретный путь до поля
Про лишнее поле. durationms вместо duration_ms — опечатка клиента. Молчаливое игнорирование означает, что событие сохранится без длительности и никто об этом не узнает.
2. Пробы
curl -s localhost:8000/healthz
curl -s localhost:8000/readyz
curl -s localhost:8000/startupz
{"status":"alive","version":"1.0.0"}
{"ready":true,"checks":{"storage":true,"warmed_up":true,"not_shutting_down":true},"reason":null}
{"initialized":true,"uptime_seconds":12.481}
Главная проверка — пробы должны расходиться:
docker run -d --name slow -p 8001:8000 -e EVENTAPI_READY_AFTER_SECONDS=3600 eventapi
sleep 3
curl -s -o /dev/null -w 'healthz: %{http_code}\n' localhost:8001/healthz
curl -s -o /dev/null -w 'readyz: %{http_code}\n' localhost:8001/readyz
docker rm -f slow
healthz: 200
readyz: 503
☐ Три пробы отвечают
☐ /healthz даёт 200, когда /readyz даёт 503
☐ /readyz называет причину неготовности
☐ /healthz не обращается к зависимостям
Если обе пробы отвечают одинаково — разделения нет, сколько бы эндпоинтов ни было объявлено. Это ровно та ошибка, ради которой требование существует: отказ зависимости приведёт к перезапуску всех экземпляров.
3. Конфигурация
docker run --rm -e EVENTAPI_PORT=0 eventapi; echo "неверный порт: $?"
docker run --rm -e EVENTAPI_LOG_LEVEL=громко eventapi; echo "неверный уровень: $?"
docker run --rm -e EVENTAPI_LOG_LEVE=info eventapi; echo "опечатка в имени: $?"
неверный порт: 1
неверный уровень: 1
опечатка в имени: 1
Проверка того, что сообщение полезно:
docker run --rm -e EVENTAPI_LOG_LEVE=info eventapi 2>&1 | tail -2
app.settings.UnknownSettingError: неизвестные переменные окружения:
EVENTAPI_LOG_LEVE; известны: host, log_level, max_body_bytes, max_events,
port, ready_after_seconds, shutdown_grace_seconds
☐ Неверное значение останавливает старт
☐ Неверное имя переменной останавливает старт
☐ Сообщение называет и опечатку, и список известных имён
☐ Отказ происходит при старте, а не при первом запросе
Третья строка — отдельное требование, и оно не следует из первых двух. Опечатка в имени не даёт неверного значения — она даёт тихое умолчание. Проверьте, что ваша библиотека настроек действительно её ловит: у pydantic-settings параметр extra="forbid" этого не делает, потому что переменную, которой не соответствует поле, он просто не видит.
4. Журналирование
docker logs api 2>&1 | head -3
{"ts": "2026-08-04T05:58:17.128+00:00", "level": "info", "logger": "uvicorn.error", "message": "Started server process [1]", "request_id": "-"}
{"ts": "2026-08-04T05:58:17.129+00:00", "level": "info", "logger": "uvicorn.error", "message": "Waiting for application startup.", "request_id": "-"}
{"ts": "2026-08-04T05:58:17.129+00:00", "level": "info", "logger": "eventapi", "message": "сервис запущен", "request_id": "-", "event": "startup", "version": "1.0.0", "max_events": 10000}
Главная проверка — все строки, а не первые:
docker logs api 2>&1 | python3 -c '
import json, sys
bad = 0
lines = [l for l in sys.stdin if l.strip()]
for i, line in enumerate(lines, 1):
try:
json.loads(line)
except Exception:
bad += 1
print(f" строка {i} не JSON: {line[:60]}")
print(f"строк: {len(lines)}, не JSON: {bad}")'
строк: 13, не JSON: 0
Идентификатор запроса:
curl -s -H 'x-request-id: trace-42' localhost:8000/healthz -o /dev/null -D - | grep -i x-request-id
docker logs api 2>&1 | grep trace-42 | python3 -m json.tool | head -8
x-request-id: trace-42
☐ Все строки — JSON, включая строки uvicorn
☐ ANSI-кодов раскраски в выводе нет
☐ Идентификатор возвращается в заголовке ответа
☐ Тот же идентификатор есть в записях журнала
Самая частая ошибка здесь — настроить логирование в lifespan. Uvicorn печатает первые строки до входа в lifespan, и они уходят в его собственном формате. Поток получается смешанным, и сборщик логов разберёт только половину. Проверяется предпоследней командой: она смотрит все строки, а не первые.
5. Завершение
docker run -d --name t eventapi && sleep 3
time docker stop t
docker inspect t --format '{{.State.ExitCode}}'
docker logs t 2>&1 | grep shutdown
real 0m0.9s
0
{"ts": "...", "level": "info", "logger": "eventapi", "message": "получен сигнал завершения, дожидаюсь активных запросов", "event": "shutdown_begin", "inflight": 0}
{"ts": "...", "level": "info", "logger": "eventapi", "message": "завершение", "event": "shutdown_done", "inflight": 0, "drained": true}
☐ Остановка занимает менее 3 секунд
☐ В журнале есть shutdown_begin и shutdown_done
☐ drained: true
Про код возврата. Ожидается 0, а не 143. Uvicorn после мягкого завершения повторно посылает себе SIGTERM — но внутри container'а он является PID 1, а для PID 1 ядро не применяет действие по умолчанию, поэтому повторный сигнал ничего не делает и процесс выходит штатно. Проверено запуском: код 0, остановка за секунду (verify/FACTS.md).
Код 143 в container'е означает --init; код 137 — что сигнал не был обработан и истёк grace period.
Отсюда важное следствие: код возврата не годится как признак успешного завершения. Отличать мягкое от жёсткого нужно по двум другим приметам:
| Признак | Мягкое завершение | Жёсткое (SIGKILL) |
|---|---|---|
| Время | Доли секунды | Ровно grace period |
| Записи в журнале | shutdown_begin, shutdown_done | Ничего |
| Код возврата | 0 (или 143 при --init) | 137 |
Проверка дорабатывания запросов:
docker run -d --name t2 -p 8002:8000 eventapi && sleep 3
curl -s -m 30 'localhost:8002/events' >/dev/null & # долгий запрос
sleep 0.2 && docker stop -t 30 t2
docker logs t2 2>&1 | grep shutdown_done
☐ inflight в shutdown_done равен нулю
6. Безопасность
docker image inspect eventapi --format '{{.Config.User}}'
docker run --rm --read-only --tmpfs /tmp eventapi --help >/dev/null 2>&1; echo "read-only: $?"
docker history --no-trunc eventapi | grep -iE 'password|secret|token' || echo "секретов в истории нет"
10001:10001
секретов в истории нет
☐ User задан и числовой
☐ Не 0 и не пустое
☐ Секретов в слоях нет
7. Образ
docker run --rm eventapi which gcc || echo "компилятора нет"
docker image inspect eventapi --format '{{.Size}}' | numfmt --to=iec
docker build -t eventapi . && touch app/main.py && time docker build -t eventapi .
☐ Инструментов сборки в образе нет
☐ Изменение кода не переустанавливает зависимости
☐ .dockerignore исключает .git, кэши, .venv — но не tests
☐ docker build --target test собирается
☐ Тестов в итоговом образе всё равно нет
☐ Размер измерен и записан в README
Про tests в .dockerignore. Соблазн исключить велик, и первая редакция эталонного решения так и делала — после чего стадия test перестала собираться совсем:
$ docker build --target test -t eventapi:test .
CopyIgnoredFile: Attempting to Copy file "tests" that is excluded by .dockerignore
ERROR: failed to compute cache key: "/tests": not found
.dockerignore — один фильтр на всю сборку, и стадии его не переопределяют (урок 5.1). В итоговый образ тесты не попадают по другой причине: стадия runtime копирует только app/. Проверьте оба факта — предпоследним пунктом списка.
Про размер — и про то, каким числом его мерить. Под Docker 29 docker images и docker image inspect возвращают разное: DISK USAGE и CONTENT SIZE. Эталонное решение — 293 MB на диске при 69 MB контента (Docker 29.7.1, измерено 2026-08-04).
docker images eventapi --format '{{.Size}}'
docker image inspect eventapi --format '{{.Size}}' | numfmt --to=si
Ориентир этого проекта — меньше 350 MB по DISK USAGE. Порог «меньше 200 MB», привычный по старым руководствам, на диске для FastAPI недостижим: один python:3.13-slim занимает 178 MB (урок 3.4).
Если заметно больше — ищите причину: полный python:3.13 вместо slim, оставшийся build-essential, отсутствующий .dockerignore.
Записать измерение важнее, чем уложиться в число.
8. Тесты
docker build --target test -t eventapi:test . 2>&1 | tail -12
64 passed, 1 warning in 14.01s
Name Stmts Miss Cover Missing
-----------------------------------------------------
app/__init__.py 1 0 100%
app/logging_config.py 36 0 100%
app/main.py 109 8 93% 52, 99, 115-118, 163, 204, 208
app/models.py 49 1 98% 34
app/settings.py 36 0 100%
app/storage.py 44 2 95% 93-94
-----------------------------------------------------
TOTAL 275 11 96%
Required test coverage of 85% reached. Total coverage: 96.00%
Провал должен останавливать сборку:
sed -i 's/assert r.status_code == 201/assert r.status_code == 200/' tests/test_api.py
docker build --target test . ; echo "код сборки: $?"
git checkout tests/test_api.py
код сборки: 1
☐ Тесты проходят, покрытие не ниже 85 %
☐ Сломанный тест останавливает сборку
☐ Есть тесты на запущенном процессе, а не только через TestClient
Последний пункт — то, что отличает проект от упражнения. TestClient не поднимает uvicorn. Он не покажет ни формата строк, которые печатает сам сервер, ни реакции на сигнал. Обе проверки требуют настоящего процесса.
9. Доступность
docker run -d --name h -p 8003:8000 eventapi && sleep 3
curl -s -o /dev/null -w '%{http_code}\n' localhost:8003/healthz
docker exec h python -c "
import socket
s = socket.socket(); s.settimeout(1)
print('слушает 0.0.0.0:8000' if s.connect_ex(('0.0.0.0', 8000)) == 0 else 'НЕ слушает')"
docker rm -f h
200
слушает 0.0.0.0:8000
☐ Сервис отвечает с хоста
☐ Привязка к 0.0.0.0, а не к 127.0.0.1
Если отвечает изнутри и молчит снаружи — приложение слушает 127.0.0.1. Внутри container'а это адрес самого container'а, и опубликованный порт ведёт в пустоту (урок 8.3).
Сводка
| № | Проверка | Требования | Отметка |
|---|---|---|---|
| 1 | Валидация | 1 | ☐ |
| 2 | Пробы | 2 | ☐ |
| 3 | Конфигурация | 3, 4 | ☐ |
| 4 | Журналирование | 5, 6 | ☐ |
| 5 | Завершение | 7 | ☐ |
| 6 | Безопасность | 8 | ☐ |
| 7 | Образ | 9 | ☐ |
| 8 | Тесты | 10 | ☐ |
| 9 | Доступность | 11 | ☐ |
Девять из девяти — можно открывать SOLUTION.md.
docker rm -f api 2>/dev/null
Навигация
← Техническое задание
Эталонное решение →
Вернуться к проектам
Главное оглавление