Checkpoints
Четыре контрольные точки после логических блоков курса. Каждая состоит из трёх частей: диагностическая задача, самостоятельное задание и критерии приёмки.
Checkpoint отличается от exercises.md раздела тем, что не подсказывает, какой материал применить. В упражнении раздела 08 понятно, что задача про сеть. В checkpoint дан симптом, и часть работы — определить, где искать.
Правило приёмки: checkpoint не пройден, пока не выполнены все критерии. Частичное выполнение не засчитывается — частично работающий сервис в эксплуатации равен неработающему.
Правило честности: диагностическая задача решается до чтения разбора. Прочитанный разбор даёт знание конкретной поломки, но не навык расследования, а проверяется именно он.
Docker на машине, где готовился курс, не установлен. Конфигурации ниже написаны так, чтобы воспроизводить описанный симптом, но фактически не запускались (README). Разборы опираются на механизмы, разобранные в соответствующих уроках.
Checkpoint 1: Container как процесс
После разделов: 01–04
Время: 2 часа
Проверяет: понимание того, что container — это процесс, а не машина
Часть A. Диагностика
Дан образ, собранный из такого Dockerfile:
FROM python:3.13-slim
WORKDIR /app
COPY worker.py .
CMD python worker.py
worker.py:
import signal
import sys
import time
def on_term(signum, frame):
print("получен SIGTERM, завершаюсь", flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, on_term)
print("работаю", flush=True)
while True:
time.sleep(1)
Симптомы:
$ docker run -d --name w worker
a1b2c3d4e5f6
$ docker logs w
работаю
$ time docker stop w
w
real 0m10.412s
Остановка занимает ровно десять секунд. Строка «получен SIGTERM» в логах не появляется — при том, что обработчик написан и заведомо работает при запуске скрипта напрямую.
Задача. Назвать причину, подтвердить её командой и исправить.
Подсказка (открывать после 20 минут)
Вопрос, с которого стоит начать: какой процесс имеет PID 1 внутри container'а?
docker exec w ps -eo pid,ppid,cmd
Разбор
Причина. CMD записан в shell-форме. Docker разворачивает её в /bin/sh -c "python worker.py", поэтому PID 1 — оболочка, а Python работает её дочерним процессом.
docker stop посылает SIGTERM процессу с PID 1, то есть оболочке. sh не пересылает сигнал потомкам и сам по SIGTERM не завершается, пока ждёт дочерний процесс. Через grace period (по умолчанию 10 секунд) приходит SIGKILL, который убивает всё разом — отсюда ровно десять секунд.
Подтверждение:
docker exec w ps -eo pid,ppid,cmd
PID PPID CMD
1 0 /bin/sh -c python worker.py
7 1 python worker.py
PID 1 — оболочка. Это и есть ответ.
Исправление — exec-форма:
CMD ["python", "worker.py"]
PID PPID CMD
1 0 python worker.py
Проверка исправления:
time docker stop w
Меньше секунды, и в логах появилась строка обработчика.
Что здесь легко перепутать. Симптом «десять секунд» иногда приписывают отсутствию обработчика сигнала. Обработчик есть — проблема в том, что сигнал до него не доходит. Различить два случая позволяет только проверка PID 1: при отсутствии обработчика PID 1 был бы самим Python.
Часть B. Задание
Собрать образ инструмента, который:
- принимает аргументы через
docker run; - читает стандартный ввод;
- возвращает три различимых кода возврата;
- корректно завершается по
docker stopменее чем за секунду; - печатает результат в
stdout, диагностику вstderr.
Приложение может быть любым — достаточно тридцати строк Python.
Критерии приёмки
| № | Критерий | Проверка |
|---|---|---|
| 1 | PID 1 — само приложение | docker exec + ps, первая строка не sh |
| 2 | Аргументы доходят | docker run образ --флаг меняет поведение |
| 3 | Стандартный ввод работает | echo данные | docker run -i образ |
| 4 | Три кода возврата различимы | Три запуска, три разных $? |
| 5 | Остановка быстрее секунды | time docker stop |
| 6 | Потоки разделены | 2>/dev/null и 2>&1 >/dev/null дают разное |
Шесть из шести — checkpoint пройден. Пять из шести — не пройден.
Checkpoint 2: Сборка и многокомпонентная система
После разделов: 05–09
Время: 3 часа
Проверяет: оптимизацию сборки, сети, тома, зависимости по готовности
Часть A. Диагностика
Дан compose.yaml:
services:
api:
build: .
ports:
- "8080:8000"
environment:
DATABASE_URL: postgresql://app:secret@localhost:5432/appdb
depends_on:
- db
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
Симптомы. docker compose up запускает оба сервиса, db работает, а api перезапускается с ошибкой:
api-1 | connection to server at "localhost" (127.0.0.1), port 5432 failed:
api-1 | Connection refused
api-1 | Is the server running on that host and accepting connections?
При этом docker compose exec api ping db отвечает.
Задача. Найти две независимые ошибки. Симптом виден один, причин две, и исправление только одной оставит проблему.
Подсказка (открывать после 20 минут)
Первая ошибка видна в самой строке подключения. Вторая проявится, когда первая будет исправлена, — и только на медленной машине или при холодном старте.
Разбор
Ошибка 1: localhost вместо имени сервиса.
Каждый container имеет собственный network namespace (урок 17.3). localhost внутри api указывает на сам api, а не на хост и не на db.
Подтверждение — ping db работает, а подключение к localhost:5432 нет: значит, разрешение имён исправно, а адрес указан неверный.
Исправление:
DATABASE_URL: postgresql://app:secret@db:5432/appdb
Ошибка 2: depends_on без условия готовности.
depends_on в форме списка ждёт запуска container'а, а не готовности процесса внутри. PostgreSQL после старта container'а ещё несколько секунд инициализирует кластер и не принимает соединения.
После исправления первой ошибки симптом станет плавающим: на прогретой машине успевает, на холодной — нет. Плавающие ошибки дороже воспроизводимых.
Исправление:
services:
api:
depends_on:
db:
condition: service_healthy
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
Что остаётся неисправленным и это правильно заметить. Даже с condition: service_healthy приложение обязано переживать отказ базы в работе, а не только при старте. depends_on решает задачу первой минуты; повторы подключения в коде решают её всегда (урок 18.3).
Третья проблема, не входящая в задачу, но заметная: пароль записан в compose.yaml открытым текстом. Симптомов не даёт, найтись должна (урок 12.6).
Часть B. Задание
Собрать стек из трёх сервисов: приложение, база данных, кэш.
Требования:
- Multi-stage сборка приложения; инструментов сборки в итоговом образе нет.
- Изменение кода приложения не пересобирает зависимости.
- Две сети: база и кэш недоступны снаружи.
- Именованный том для данных базы.
- Healthcheck у каждого сервиса; зависимости по готовности.
- Приложение переживает перезапуск базы без перезапуска себя.
Критерии приёмки
| № | Критерий | Проверка |
|---|---|---|
| 1 | Кэш сборки работает | touch файла в src/, пересборка занимает секунды |
| 2 | Компилятора нет в образе | docker run --rm образ which gcc не находит |
| 3 | .dockerignore исключает .git и кэши | Размер контекста сборки |
| 4 | База недоступна с хоста | Подключение к порту базы снаружи не проходит |
| 5 | Приложение доступно с хоста | curl localhost:порт отвечает |
| 6 | Данные переживают docker compose down | Запись, down, up, чтение |
| 7 | Порядок старта соблюдается | docker compose up из чистого состояния десять раз подряд |
| 8 | Приложение переживает docker compose restart db | Запрос после перезапуска проходит |
Восемь из восьми — checkpoint пройден.
Про критерий 7. Один успешный запуск ничего не доказывает: гонка проявляется не всегда. Десять запусков подряд — минимальная проверка воспроизводимости.
Checkpoint 3: Production и диагностика
После разделов: 10–13
Время: 3 часа
Проверяет: готовность к эксплуатации и умение расследовать
Часть A. Диагностика
Симптом. Сервис работает часами, затем исчезает. В docker logs — ничего необычного: последняя строка выглядит как обычный обработанный запрос. Перезапуск помогает на несколько часов.
$ docker inspect api --format '{{.State.Status}} {{.State.ExitCode}} {{.State.OOMKilled}}'
exited 137 true
Задача. Объяснить, что произошло, почему в логах приложения ничего нет, и назвать, какие данные нужны, чтобы предсказать это заранее.
Подсказка (открывать после 20 минут)
Код 137 — это 128 + 9. Кто послал сигнал 9 и почему приложение не могло об этом сообщить?
Разбор
Что произошло. Процесс превысил ограничение памяти cgroup, и ядро завершило его через OOM killer. SIGKILL не перехватывается — обработчика для него не существует в принципе, поэтому приложение физически не могло записать ничего в лог.
Код 137 = 128 + 9, где 9 — номер SIGKILL. Поле OOMKilled: true подтверждает причину.
Почему в логах приложения пусто. Запись делает ядро, а не процесс. Она видна:
dmesg | grep -i 'killed process'
docker events --filter 'event=oom' --since 24h
Отсюда общее правило: docker logs показывает то, что сказал процесс. То, что сделали с процессом, приходится узнавать в другом месте (урок 13.5).
Как предсказать заранее. Нужны данные о давлении памяти до отказа:
docker exec api cat /sys/fs/cgroup/memory.events
low 0
high 0
max 4213
oom 12
oom_kill 3
Ненулевой max означает, что процесс уже упирался в предел и его тормозили; oom_kill — сколько раз убивали. Эти счётчики растут задолго до того, как отказ станет заметным (урок 17.4).
Docker задаёт только memory.max, но не memory.high — то есть торможения перед отказом нет: процесс работает нормально и убивается мгновенно. У systemd для этого есть MemoryHigh (урок 19.1).
Что делать дальше — три разных ответа, и выбор между ними требует данных:
| Гипотеза | Как отличить |
|---|---|
| Утечка памяти в приложении | Потребление растёт монотонно между перезапусками |
| Лимит занижен | Потребление выходит на плато выше лимита |
| Всплеск на редком запросе | Потребление стабильно, скачок перед отказом |
Поднять лимит без различения этих случаев — обычная ошибка: при утечке это отодвигает отказ, не устраняя его.
Часть B. Задание
Привести сервис из checkpoint 2 в состояние, пригодное для эксплуатации:
- Три пробы с разной логикой: жив, готов, инициализирован.
- Структурированные логи в
stdoutс идентификатором запроса. - Ограничения памяти и процессора, подобранные по измерению, а не наугад.
- Ротация логов.
- Мягкое завершение: активные запросы дорабатываются.
- Раздел
READMEс методикой диагностики трёх типовых отказов.
Критерии приёмки
| № | Критерий | Проверка |
|---|---|---|
| 1 | /healthz не зависит от базы | Остановить базу — отвечает 200 |
| 2 | /readyz зависит от базы | Остановить базу — отвечает 503 |
| 3 | Логи разбираются как JSON | docker logs пропустить через json.tool |
| 4 | Идентификатор запроса проходит через все записи | Один запрос — один идентификатор во всех строках |
| 5 | Лимиты заданы и обоснованы измерением | Число в README со ссылкой на замер |
| 6 | Ротация логов настроена | docker inspect показывает max-size |
| 7 | Активный запрос доживает до конца при остановке | Запрос на 3 секунды + docker stop |
| 8 | memory.events читается изнутри | Команда в README работает |
Восемь из восьми — checkpoint пройден.
Про критерии 1 и 2. Это единственная пара, которую проверяют вместе: если обе пробы ведут себя одинаково, разделения нет, сколько бы эндпоинтов ни было объявлено.
Checkpoint 4: Доставка
После разделов: 14–16
Время: 3 часа
Проверяет: воспроизводимость доставки от коммита до образа
Часть A. Диагностика
Симптом. В эксплуатации работает не тот код, который ожидали. Развёртывание выполнено командой:
docker pull registry.example.com/team/api:prod
docker compose up -d
docker compose ps показывает container запущенным, образ — team/api:prod. Разработчик утверждает, что исправление в prod собрано и опубликовано час назад.
$ docker image inspect team/api:prod --format '{{.Id}}'
sha256:9f8e7d6c...
$ docker image inspect team/api:prod --format '{{index .RepoDigests 0}}'
registry.example.com/team/api@sha256:1a2b3c4d...
Задача. Назвать не менее трёх мест, где могло разойтись, и предложить изменение, устраняющее весь класс.
Подсказка (открывать после 20 минут)
Тег — изменяемая ссылка. Сколько шагов между «собрали» и «запущено», и на каком из них тег мог указывать на разное?
Разбор
Три места расхождения.
Первое: pull выполнен, но container не пересоздан. docker compose up -d пересоздаёт container, только если изменилась конфигурация сервиса. Само по себе обновление образа с тем же тегом изменением конфигурации не является — работает старый container со старым образом.
Подтверждение:
docker inspect container --format '{{.Image}}'
docker image inspect team/api:prod --format '{{.Id}}'
Разные значения означают, что container работает не на том образе, который сейчас помечен тегом.
Второе: тег перезаписан после pull. Между pull и up прошло время. Если сборка опубликовала prod повторно, локальная копия устарела мгновенно.
Третье: опубликовано не то, что собрано. Сборка пометила образ тегом prod до прохождения тестов, или два конвейера опубликовали prod одновременно — побеждает последний.
Изменение, устраняющее класс. Развёртывание по digest, а не по тегу:
services:
api:
image: registry.example.com/team/api@sha256:1a2b3c4d...
Digest вычисляется из содержимого: одно значение — один набор байтов, всегда (урок 14.3). Подменить его нельзя, устареть он не может.
Дополнительно: неизменяемые теги по SHA коммита (api:g1a2b3c4) вместо перезаписываемого prod. Тогда откат означает «развернуть предыдущий тег», а не «надеяться, что старый образ ещё не вытеснен из кэша».
Что при этом теряется. Развёртывание по digest требует шага, обновляющего digest в конфигурации, — это работа. Обычное возражение: «неудобно». Правильный ответ: неудобно ровно настолько, насколько удобно было не знать, что запущено.
Часть B. Задание
Построить конвейер доставки для стека из checkpoint 3:
- Проверки, тесты и сборка — по возрастанию времени и убыванию вероятности отказа.
- Логика в скриптах, а не в шагах YAML.
- Кэш сборки, переносимый между запусками.
- Сканирование с тремя кодами возврата: чисто, находки, не проверено.
- SBOM, приложенный к образу.
- Теги: SHA коммита и digest; перезаписываемых тегов нет.
- План отката с указанием, что именно откатывается.
Критерии приёмки
| № | Критерий | Проверка |
|---|---|---|
| 1 | Быстрые проверки идут первыми | Сломать линтер — конвейер падает за минуту |
| 2 | Скрипты запускаются локально | ./ci/проверка.sh работает без CI |
| 3 | Кэш переносится между запусками | Второй запуск быстрее первого |
| 4 | Отсутствие сканера отличимо от чистого результата | Убрать сканер — код возврата 3, не 0 |
| 5 | SBOM создан и содержит зависимости | Число записей больше нуля |
| 6 | Образ помечен SHA коммита | Тег соответствует git rev-parse --short HEAD |
| 7 | Развёртывание по digest | В конфигурации @sha256: |
| 8 | Откат описан командой | Команда, а не «развернуть предыдущую версию» |
Восемь из восьми — checkpoint пройден.
Про критерий 4. Он проверяет то, что обычно не проверяют: поведение конвейера при отсутствии инструмента. Сканер, которого нет, не должен выглядеть как сканер, не нашедший проблем (урок 16.4).
Сводка
| № | Тема | После разделов | Диагностика | Задание | Критериев |
|---|---|---|---|---|---|
| 1 | Container как процесс | 01–04 | Shell-форма и PID 1 | Инструмент командной строки | 6 |
| 2 | Сборка и система | 05–09 | localhost и порядок старта | Стек из трёх сервисов | 8 |
| 3 | Production и диагностика | 10–13 | OOM без записи в логе | Готовность к эксплуатации | 8 |
| 4 | Доставка | 14–16 | Тег указывает не туда | Конвейер доставки | 8 |
Разделы 17–19 контрольных точек не имеют: они не добавляют новых навыков сборки и эксплуатации, а объясняют механизмы и границы применимости. Проверяются quiz'ами и заданиями самих разделов.
Что checkpoints проверяют, чего не проверяет остальное
Диагностические части всех четырёх устроены одинаково: симптом не указывает на раздел. Ошибка Connection refused в checkpoint 2 может быть про сеть, про порядок старта, про конфигурацию или про сам процесс — и половина работы состоит в том, чтобы это сузить.
Упражнения разделов такого дать не могут: они лежат внутри раздела, и область поиска известна заранее. В работе она неизвестна почти никогда.
Второе, общее для всех четырёх: в каждой диагностической задаче больше одной причины или больше одного правильного ответа. Checkpoint 2 содержит две независимые ошибки и третью, не дающую симптомов. Checkpoint 3 заканчивается тремя гипотезами, различить которые можно только измерением. Это ближе к тому, как выглядит расследование, чем задача с единственным ответом.
Навигация
Вернуться к системе проверки
Quizzes по разделам
Debugging challenges
Grading rubric
Главное оглавление