Проект 3. Список проверок
Проходить после реализации и до чтения SOLUTION.md.
Весь список прогнан против настоящего стека: Docker Engine 29.7.1 и Compose, 2026-08-05, 35 подтверждений из 35, включая десять запусков подряд. Скрипт прогона —
verify/62-project3.sh; он собирает стек из блоковSOLUTION.mdпроектов 2 и 3, то есть проверяет ровно то, что напечатано в курсе. Прогон нашёл в эталонном решении шесть дефектов — они исправлены и разобраны в SOLUTION.md.
Подготовка
mkdir -p secrets && head -c 32 /dev/urandom | base64 > secrets/db_password.txt
docker compose build
docker compose up -d
docker compose ps
1. Состав
docker compose ps --format 'table {{.Service}}\t{{.Status}}'
SERVICE STATUS
api Up 30 seconds (healthy)
cache Up 45 seconds (healthy)
db Up 45 seconds (healthy)
migrate Exited (0) 35 seconds ago
worker Up 30 seconds (healthy)
☐ Пять сервисов
☐ migrate в состоянии Exited (0), а не Up
☐ Остальные healthy, а не просто Up
Про migrate. Одноразовая задача не должна оставаться запущенной. Если она Up — вероятно, в конце команды стоит что-то вроде sleep infinity, чтобы «не падала». Это антипаттерн (урок 19.4): container живёт, работа не сделана, и никто не узнает.
2. Готовность
Главная проверка проекта — воспроизводимость старта:
for i in $(seq 10); do
docker compose down -v >/dev/null 2>&1
docker compose up -d >/dev/null 2>&1
sleep 15
status=$(docker compose ps api --format '{{.Status}}')
printf '%2d: %s\n' "$i" "$status"
done
1: Up 12 seconds (healthy)
2: Up 12 seconds (healthy)
...
10: Up 12 seconds (healthy)
☐ Десять запусков подряд без падения api
☐ У db и cache условие service_healthy
☐ У migrate условие service_completed_successfully
Один успешный запуск ничего не доказывает. Гонка при старте проявляется не всегда: на прогретой машине база успевает, на холодной нет. Десять запусков — минимальная проверка.
docker compose config | grep -A 3 depends_on | head -20
☐ Нигде нет depends_on в форме простого списка
3. Миграции
docker compose run --rm migrate
docker compose run --rm migrate
{"level":"INFO","message":"применяю миграцию"}
{"level":"INFO","message":"применяю миграцию"}
{"level":"INFO","message":"применяю миграцию"}
применено миграций: 3 (001_events, 002_stats_indexes, 003_processed_flag)
новых миграций нет
Схема:
docker compose exec db psql -U app -d appdb -c '\d events'
Column | Type | Nullable | Default
--------------+--------------------------+----------+------------------------------------
id | bigint | not null | nextval('events_id_seq'::regclass)
path | text | not null |
method | text | not null |
status | integer | not null |
duration_ms | double precision | not null |
ts | timestamp with time zone | not null | now()
processed_at | timestamp with time zone | |
☐ Первый запуск применяет все миграции
☐ Второй печатает «новых миграций нет» и возвращает 0
☐ Порядок применения соответствует номерам в именах
☐ Изменение уже применённого файла приводит к отказу
Последнее проверяется так:
echo "-- правка" >> migrations/002_stats_indexes.sql
docker compose run --rm migrate; echo "код: $?"
git checkout migrations/002_stats_indexes.sql
миграции: 002_stats_indexes: файл изменён после применения
(a1b2c3d4e5f6a7b8 → 9f8e7d6c5b4a3210); исправлять применённую миграцию нельзя
код: 1
Почему это важно. Правка применённой миграции безобидна на вашей машине, где схему можно пересоздать, и разрушительна в эксплуатации: там миграция уже применена в старом виде, и схемы разъедутся молча.
4. Сети
docker compose exec api python -c "
import socket
for host, port in (('db', 5432), ('cache', 6379)):
s = socket.create_connection((host, port), 2); s.close()
print(f'{host}:{port} доступен изнутри')"
db:5432 доступен изнутри
cache:6379 доступен изнутри
Снаружи — не должны:
timeout 3 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/5432' 2>/dev/null \
&& echo "ОШИБКА: база доступна с хоста" || echo "база с хоста недоступна"
curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/healthz
база с хоста недоступна
200
☐ db и cache доступны изнутри backend
☐ Порты базы и кэша не опубликованы
☐ api доступен с хоста
☐ backend объявлена internal: true
docker network inspect eventstack_backend --format '{{.Internal}}'
true
Про internal: true. Это не то же самое, что «не публиковать порты». Публикация решает доступ снаружи внутрь; internal запрещает выход изнутри наружу. Второе защищает от того, что скомпрометированная зависимость обратится в интернет.
Но защищает не всех. Проверено запуском: worker состоит только в backend и наружу не ходит, а api состоит и в frontend — и выход в интернет у него есть:
docker compose exec worker python -c "
import socket; socket.setdefaulttimeout(4)
socket.create_connection(('1.1.1.1', 443))" 2>&1 | tail -1
docker compose exec api python -c "
import socket; socket.setdefaulttimeout(4)
socket.create_connection(('1.1.1.1', 443)); print('api: выход есть')"
socket.gaierror: [Errno -3] Temporary failure in name resolution
api: выход есть
internal — свойство сети, а не сервиса. Сервис, состоящий в двух сетях, получает возможности обеих. Проверять нужно каждый сервис отдельно, и api из этой проверки не исключение, а её смысл: наружу смотрит ровно один сервис, и это видно.
5. Состояние
curl -s -X POST localhost:8000/events -H 'content-type: application/json' \
-d '{"path":"/api/x","method":"GET","status":200,"duration_ms":5}' >/dev/null
curl -s localhost:8000/stats | python3 -c 'import json,sys; print("до:", json.load(sys.stdin)["total"])'
docker compose down && docker compose up -d && sleep 15
curl -s localhost:8000/stats | python3 -c 'import json,sys; print("после down/up:", json.load(sys.stdin)["total"])'
docker compose down -v && docker compose up -d && sleep 20
curl -s localhost:8000/stats | python3 -c 'import json,sys; print("после down -v:", json.load(sys.stdin)["total"])'
до: 1
после down/up: 1
после down -v: 0
☐ Данные переживают down
☐ Данные исчезают при down -v
☐ После down -v схема создаётся заново автоматически
Третий пункт проверяет, что migrate действительно отрабатывает при каждом старте, а не был запущен один раз вручную.
6. Ресурсы
docker compose config | python3 -c '
import sys, yaml
d = yaml.safe_load(sys.stdin)
for name, svc in d["services"].items():
limits = (svc.get("deploy") or {}).get("resources", {}).get("limits", {})
print(f"{name:<8} {limits or \"НЕТ ОГРАНИЧЕНИЙ\"}")'
api {'cpus': '1.0', 'memory': '512M'}
cache {'cpus': '0.5', 'memory': '256M'}
db {'cpus': '1.0', 'memory': '1G'}
migrate {'cpus': '0.5', 'memory': '128M'}
worker {'cpus': '0.5', 'memory': '256M'}
☐ У всех пяти сервисов заданы лимиты
☐ Ротация логов настроена у всех
☐ maxmemory у Redis задан и не превышает лимит container'а
Про Redis. maxmemory внутри Redis и memory в лимитах container'а — разные вещи. Если первый больше второго, Redis не начнёт вытеснять ключи, а будет убит OOM killer — без записи в лог (checkpoint 3).
7. Тесты
docker compose -f compose.yaml -f compose.test.yaml run --rm tests
..................................... [100%]
Name Stmts Miss Cover
------------------------------------------
app/secrets.py 17 4 76%
app/storage_pg.py 67 3 96%
migrations/runner.py 86 17 80%
worker/main.py 69 19 72%
worker/queue.py 41 14 66%
------------------------------------------
TOTAL 280 57 80%
Required test coverage of 75% reached. Total coverage: 79.64%
Покрытие считается по новому коду, а не по всему app. Файлы main.py, models.py, settings.py пришли из проекта 2 вместе со своими тестами; включить их сюда — получить 40 % и провал порога на ровном месте.
☐ Тесты проходят с настоящей базой
☐ Покрытие не ниже заявленного порога
☐ Тесты используют отдельную базу, а не рабочую
Главная проверка этого раздела — что происходит без базы:
EVENTAPI_DATABASE_URL= pytest -q # локально
EVENTAPI_DATABASE_URL= EVENTAPI_REQUIRE_DB=1 pytest -q # в конвейере
...ssss.ss.sssssssssssssssssssssss... [100%]
33 skipped, 4 passed
ERROR tests/test_worker.py::test_stop_finishes_current_batch - Failed:
EVENTAPI_DATABASE_URL не задан: интеграционные тесты не выполнялись
(EVENTAPI_REQUIRE_DB=1)
☐ Локально отсутствие базы даёт пропуск
☐ В конвейере отсутствие базы даёт отказ
Почему нужны оба режима. Пропуск удобен при работе над кодом, не касающимся базы. В конвейере он опасен: «33 skipped» в отчёте выглядит как успех, и сборка проходит зелёной, ничего не проверив. Это то же различение трёх состояний, что в уроке 16.4.
8. Конфигурации
docker compose config >/dev/null && echo "основная: ок"
docker compose -f compose.yaml -f compose.test.yaml config >/dev/null && echo "тестовая: ок"
docker compose -f compose.yaml -f compose.dev.yaml config >/dev/null && echo "разработка: ок"
основная: ок
тестовая: ок
разработка: ок
☐ Три конфигурации разбираются без ошибок
☐ Отладочные настройки не попадают в основную
☐ В тестовой база не использует том
docker compose -f compose.yaml -f compose.test.yaml config | grep -A 3 'db:' | grep -E 'tmpfs|volumes'
tmpfs:
Про fsync=off в тестовой конфигурации. Он ускоряет прогон в разы и абсолютно недопустим в эксплуатации: при отказе питания база не восстановится. Если такая настройка есть — убедитесь, что она только в тестовом файле, и напишите об этом в комментарии.
9. Секреты
grep -riE 'password\s*[:=]\s*[^$/]' compose*.yaml || echo "паролей в открытом виде нет"
git check-ignore secrets/db_password.txt && echo "файл секрета исключён из репозитория"
docker compose config | grep -i password
паролей в открытом виде нет
файл секрета исключён из репозитория
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
☐ В compose*.yaml нет значений паролей
☐ Файл секрета в .gitignore
☐ Используется _FILE-переменная, а не значение
Сводка
| № | Проверка | Требования | Отметка |
|---|---|---|---|
| 1 | Состав | 1 | ☐ |
| 2 | Готовность | 2, 3 | ☐ |
| 3 | Миграции | 4, 5 | ☐ |
| 4 | Сети | 6 | ☐ |
| 5 | Состояние | 7 | ☐ |
| 6 | Ресурсы | 8 | ☐ |
| 7 | Тесты | 9 | ☐ |
| 8 | Конфигурации | 10 | ☐ |
| 9 | Секреты | 11 | ☐ |
Девять из девяти — можно открывать SOLUTION.md.
docker compose down -v
Навигация
← Техническое задание
Эталонное решение →
Вернуться к проектам
Главное оглавление