Главная/Проекты/Чеклист

Проект 2. Список проверок

Проходить после реализации и до чтения SOLUTION.md.

Весь список прогнан против собранного образа: Docker Engine 29.7.1, 2026-08-04, 27 подтверждений из 27. Скрипт прогона — verify/61-project2.sh; он собирает образ из блоков SOLUTION.md, то есть проверяет ровно то, что напечатано в курсе. Прогон нашёл в эталонном решении три дефекта — они исправлены и разобраны в SOLUTION.md.

Подготовка

bash
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. Валидация

bash
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}'
text
201
422
422
422
422
422

Сообщение должно называть поле:

bash
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
text
{
    "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. Пробы

bash
curl -s localhost:8000/healthz
curl -s localhost:8000/readyz
curl -s localhost:8000/startupz
text
{"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}

Главная проверка — пробы должны расходиться:

bash
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
text
healthz: 200
readyz:  503

☐ Три пробы отвечают
/healthz даёт 200, когда /readyz даёт 503
/readyz называет причину неготовности
/healthz не обращается к зависимостям

Если обе пробы отвечают одинаково — разделения нет, сколько бы эндпоинтов ни было объявлено. Это ровно та ошибка, ради которой требование существует: отказ зависимости приведёт к перезапуску всех экземпляров.


3. Конфигурация

bash
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 "опечатка в имени: $?"
text
неверный порт: 1
неверный уровень: 1
опечатка в имени: 1

Проверка того, что сообщение полезно:

bash
docker run --rm -e EVENTAPI_LOG_LEVE=info eventapi 2>&1 | tail -2
text
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. Журналирование

bash
docker logs api 2>&1 | head -3
text
{"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}

Главная проверка — все строки, а не первые:

bash
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}")'
text
строк: 13, не JSON: 0

Идентификатор запроса:

bash
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
text
x-request-id: trace-42

☐ Все строки — JSON, включая строки uvicorn
☐ ANSI-кодов раскраски в выводе нет
☐ Идентификатор возвращается в заголовке ответа
☐ Тот же идентификатор есть в записях журнала

Самая частая ошибка здесь — настроить логирование в lifespan. Uvicorn печатает первые строки до входа в lifespan, и они уходят в его собственном формате. Поток получается смешанным, и сборщик логов разберёт только половину. Проверяется предпоследней командой: она смотрит все строки, а не первые.


5. Завершение

bash
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
text
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

Проверка дорабатывания запросов:

bash
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. Безопасность

bash
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 "секретов в истории нет"
text
10001:10001
секретов в истории нет

User задан и числовой
☐ Не 0 и не пустое
☐ Секретов в слоях нет


7. Образ

bash
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 перестала собираться совсем:

console
$ 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).

bash
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. Тесты

bash
docker build --target test -t eventapi:test . 2>&1 | tail -12
text
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%

Провал должен останавливать сборку:

bash
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
text
код сборки: 1

☐ Тесты проходят, покрытие не ниже 85 %
☐ Сломанный тест останавливает сборку
☐ Есть тесты на запущенном процессе, а не только через TestClient

Последний пункт — то, что отличает проект от упражнения. TestClient не поднимает uvicorn. Он не покажет ни формата строк, которые печатает сам сервер, ни реакции на сигнал. Обе проверки требуют настоящего процесса.


9. Доступность

bash
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
text
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.

bash
docker rm -f api 2>/dev/null

Навигация

← Техническое задание
Эталонное решение →
Вернуться к проектам
Главное оглавление

Markdown на GitHub ↗