11.1. Принципы production
Цели
После этого материала вы сможете:
- объяснить, почему
docker execдля исправления в production — антипаттерн, и что делать вместо; - сформулировать правило «один процесс на container» точно и назвать обоснованные исключения;
- применить принципы Twelve-Factor к контейнеризованному приложению;
- пользоваться чек-листом готовности и понимать, откуда взялся каждый его пункт;
- отличать требования, обязательные всегда, от зависящих от контекста.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
immutable infrastructure | Подход: экземпляры не изменяются, а заменяются |
configuration drift | Расхождение состояния экземпляров, накопленное правками |
Twelve-Factor | Свод правил построения сервисов, применимый к container'ам |
disposability | Свойство: экземпляр можно быстро запустить и остановить |
dev/prod parity | Минимальное различие между средами |
Теория
Immutable infrastructure
Принцип: работающий экземпляр не изменяют — его заменяют новым.
Изменяемая инфраструктура Неизменяемая
───────────────────────── ────────────
сервер образ v1 ──► container
│ apt upgrade │
│ правка конфига │ новый образ v2
│ hotfix ▼
▼ образ v2 ──► новый container
состояние неизвестно старый удалён
| Что даёт | Практическое следствие |
|---|---|
| Состояние известно | Оно равно образу плюс конфигурация |
| Воспроизводимость | Тот же образ даёт тот же результат |
| Откат возможен | Достаточно запустить прежний образ |
| Нет drift | Экземпляры не расходятся со временем |
Почему docker exec для исправления — антипаттерн:
| Проблема | Пояснение |
|---|---|
| Изменение теряется | Перезапуск возвращает образ к исходному состоянию |
| Реплики расходятся | Исправлена одна из трёх |
| Нет следа | Изменение не в системе контроля версий |
| Откат неопределён | Непонятно, к чему возвращаться |
| Диагностика ломается | «У нас же исправлено» — а в образе нет |
Правильная последовательность: правка кода → сборка образа → развёртывание. Даже для однострочного исправления.
Исключение ровно одно: диагностика в режиме чтения. docker exec для просмотра логов, состояния или дампа стека изменений не вносит (урок 10.3).
«Один процесс на container»
Правило формулируют неточно, из-за чего оно порождает споры. Точная формулировка:
Один container решает одну задачу и имеет один процесс, за жизненным циклом которого следит Docker.
Что из этого следует и чего не следует:
| Утверждение | Верно |
|---|---|
| В container должен быть ровно один процесс ОС | Нет |
| Приложение не может создавать потоки | Нет |
| Приложение не может порождать worker'ов | Нет |
| Container должен решать одну задачу | Да |
| Docker должен видеть жизненный цикл главного процесса | Да |
Обоснованные исключения, где процессов несколько:
| Случай | Почему допустимо |
|---|---|
| Gunicorn с worker'ами | Master следит за потомками и транслирует сигналы |
Uvicorn с --workers | То же |
--init плюс приложение | Init нужен для сбора зомби (урок 6.5) |
| Приложение с фоновым потоком | Потоки — часть одного процесса |
Что не является исключением:
| Антипаттерн | Что ломается |
|---|---|
supervisord с приложением и nginx | Docker видит supervisord; падение приложения незаметно |
cron плюс приложение | Логи cron не попадают в docker logs (урок 6.11) |
| Приложение и база в одном container | Разный жизненный цикл, разное масштабирование |
| Скрипт, запускающий несколько сервисов | Нет контроля над каждым |
Ключевой критерий: если один из процессов упадёт, узнает ли об этом Docker? Если нет — правило нарушено.
Twelve-Factor в применении к container'ам
Свод правил создавался до Docker, но описывает ровно те свойства, которые нужны контейнеризованному сервису.
| Фактор | Что означает для container | Где разбирается |
|---|---|---|
| I. Codebase | Один репозиторий — много развёртываний | — |
| II. Dependencies | Зависимости объявлены явно и зафиксированы | 6.2 |
| III. Config | Конфигурация в окружении, не в коде | 11.5 |
| IV. Backing services | База и очередь — подключаемые ресурсы | 9.3 |
| V. Build, release, run | Три раздельные стадии | 11.6 |
| VI. Processes | Приложение не хранит состояние в памяти | 7.1 |
| VII. Port binding | Сервис сам слушает порт | 8.3 |
| VIII. Concurrency | Масштабирование числом процессов | 6.11 |
| IX. Disposability | Быстрый старт, корректная остановка | 6.5 |
| X. Dev/prod parity | Среды различаются минимально | 10.1 |
| XI. Logs | Логи — поток в stdout | 6.6 |
| XII. Admin processes | Разовые задачи — тем же кодом | 10.4 |
Три фактора нарушаются чаще остальных.
VI. Processes. Сессии в памяти приложения ломаются при второй реплике: пользователь попадает на другой экземпляр и теряет сессию. Состояние выносят в Redis или базу.
IX. Disposability. Приложение должно быть готово к остановке в любой момент. Практически: обработка SIGTERM, дозавершение текущей работы, идемпотентность операций.
XI. Logs. Запись в файл внутри container'а означает, что логи исчезнут вместе с ним. Приложение пишет в stdout, а маршрутизацией занимается платформа.
Чек-лист готовности
Из принципов выводится проверяемый список. Каждый пункт — либо выполнен, либо нет; «частично» не бывает.
Образ:
| № | Требование | Проверка |
|---|---|---|
| 1 | Multi-stage: инструментов сборки нет в финальном образе | pip list, command -v gcc |
| 2 | Работает от non-root | docker run --rm образ id -u |
| 3 | Зависимости зафиксированы по версиям | requirements.txt или lock-файл |
| 4 | Базовый образ закреплён по digest | FROM ...@sha256:... |
| 5 | Нет секретов ни в одном слое | docker history, поиск по слоям |
| 6 | .dockerignore исключает лишнее | Размер контекста сборки |
| 7 | CMD в exec form | docker inspect .Config.Cmd |
| 8 | Тесты прогнаны при сборке | Отдельная стадия |
Запуск:
| № | Требование | Проверка |
|---|---|---|
| 9 | Read-only корневая файловая система | Попытка записи |
| 10 | Capabilities отобраны | --cap-drop=ALL плюс точечные |
| 11 | no-new-privileges | docker inspect .HostConfig.SecurityOpt |
| 12 | Лимиты памяти и CPU заданы | /sys/fs/cgroup/memory.max |
| 13 | --pids-limit задан | /sys/fs/cgroup/pids.max |
| 14 | Политика перезапуска выбрана осознанно | .HostConfig.RestartPolicy |
Поведение:
| № | Требование | Проверка |
|---|---|---|
| 15 | Liveness не зависит от внешних сервисов | Остановить базу, проверить статус |
| 16 | Readiness отражает готовность принимать трафик | То же |
| 17 | SIGTERM завершает штатно, код 0 | docker stop, .State.ExitCode |
| 18 | stop_grace_period покрывает длительность работы | Остановка под нагрузкой |
| 19 | Логи в stdout, структурированные | docker logs, разбор JSON |
| 20 | Конфигурация валидируется при старте | Запуск с неверным значением |
| 21 | Секреты не видны в docker inspect | Поиск значения |
| 22 | Приложение переживает недоступность зависимости | Остановить базу, вернуть |
Двадцать два пункта — не догма, а отправная точка. Часть зависит от контекста: --pids-limit не нужен, если приложение не порождает процессов; read-only корень бессмыслен, если платформа его не поддерживает.
Но каждый пункт должен быть рассмотрен и решение записано. Разница между «мы не стали делать read-only, потому что приложение пишет в /var/lib» и «мы про это не подумали» — принципиальная.
Чего чек-лист не покрывает
| Область | Где разбирается |
|---|---|
| Сканирование уязвимостей, SBOM | 11.6 |
| Метрики и трассировка | Раздел 13 |
| Политики безопасности платформы | Раздел 12 |
| Резервное копирование данных | 7.6 |
| Стратегия развёртывания | Раздел 16 |
Внутренний механизм
Почему изменения через exec исчезают
docker exec работает в том же container'е, и запись попадает в его writable layer (урок 7.1). Слой уничтожается при docker rm — а он выполняется при любом обновлении образа, compose down, пересоздании из-за изменения конфигурации.
Отсюда наблюдение, знакомое каждому: «исправление работало, а после деплоя проблема вернулась».
Что видит Docker при нескольких процессах
Docker следит за PID 1. Если приложение запущено как потомок скрипта, падение приложения не изменит состояние container'а: скрипт продолжит работать, и docker ps покажет Up.
Проверка простая: остановить главный процесс приложения изнутри и посмотреть, изменится ли состояние container'а.
Команды и примеры
Изменение через exec не переживает пересоздание
mkdir -p /tmp/immut && cd /tmp/immut
cat > compose.yaml <<'EOF'
name: immut
services:
app:
image: python:3.13-slim
command: ["sh", "-c", "cat /etc/app.conf 2>/dev/null || echo 'конфиг из образа: version=1'; sleep 300"]
EOF
docker compose up -d > /dev/null 2>&1
sleep 2
echo "═══ исходное состояние ═══"
docker compose logs app --no-log-prefix 2>/dev/null | tail -1 | sed 's/^/ /'
echo "═══ «исправляем» через exec ═══"
docker compose exec -T app sh -c 'echo "конфиг ИСПРАВЛЕН вручную: version=2" > /etc/app.conf'
docker compose exec -T app cat /etc/app.conf | sed 's/^/ /'
echo "═══ что видит docker diff ═══"
docker diff "$(docker compose ps -q app)" | grep etc/app | sed 's/^/ /'
echo "═══ обновляем стек (обычная операция) ═══"
docker compose up -d --force-recreate > /dev/null 2>&1
sleep 2
docker compose logs app --no-log-prefix 2>/dev/null | tail -1 | sed 's/^/ /'
docker compose exec -T app cat /etc/app.conf 2>&1 | tail -1 | sed 's/^/ /'
Ожидаемый вывод:
═══ исходное состояние ═══
конфиг из образа: version=1
═══ «исправляем» через exec ═══
конфиг ИСПРАВЛЕН вручную: version=2
═══ что видит docker diff ═══
A /etc/app.conf
═══ обновляем стек (обычная операция) ═══
конфиг из образа: version=1
cat: /etc/app.conf: No such file or directory
Исправление исчезло при обычном обновлении стека. Никакой ошибки при этом не возникло — и в этом главная опасность: проблема возвращается тихо, спустя дни после «исправления».
Строка A /etc/app.conf в docker diff — единственный след, и он живёт ровно до docker rm (урок 7.1).
Реплики расходятся
cd /tmp/immut
docker compose up -d --scale app=3 > /dev/null 2>&1
sleep 2
echo "═══ исправляем одну реплику ═══"
first="$(docker compose ps -q app | head -1)"
docker exec "$first" sh -c 'echo "исправлено" > /etc/patch.txt'
echo "═══ состояние трёх реплик ═══"
for cid in $(docker compose ps -q app); do
name="$(docker inspect "$cid" --format '{{.Name}}' | tr -d '/')"
patched="$(docker exec "$cid" sh -c 'cat /etc/patch.txt 2>/dev/null || echo НЕТ')"
printf ' %-16s %s\n' "$name" "$patched"
done
docker compose down > /dev/null 2>&1
Ожидаемый вывод:
═══ исправляем одну реплику ═══
═══ состояние трёх реплик ═══
immut-app-1 исправлено
immut-app-2 НЕТ
immut-app-3 НЕТ
Треть трафика обслуживается исправленным кодом, две трети — нет. Поведение системы становится недетерминированным, а воспроизвести проблему в разработке невозможно.
Docker не видит падения потомка
cd /tmp/immut
cat > bad-entry.sh <<'EOF'
#!/bin/sh
# АНТИПАТТЕРН: скрипт запускает приложение в фоне и живёт сам по себе
python3 -c "
import time, sys
print('приложение запущено', flush=True)
time.sleep(3)
print('приложение УПАЛО', flush=True)
sys.exit(1)
" &
APP_PID=$!
echo "скрипт-обёртка работает, PID приложения: $APP_PID"
# Скрипт продолжает жить независимо от приложения
while true; do sleep 1; done
EOF
chmod +x bad-entry.sh
cat > good-entry.sh <<'EOF'
#!/bin/sh
# ПРАВИЛЬНО: exec заменяет процесс скрипта процессом приложения
exec python3 -c "
import time, sys
print('приложение запущено', flush=True)
time.sleep(3)
print('приложение УПАЛО', flush=True)
sys.exit(1)
"
EOF
chmod +x good-entry.sh
for variant in bad good; do
echo "═══ вариант $variant ═══"
docker run -d --name "entry-$variant" \
-v "$PWD/$variant-entry.sh:/entry.sh:ro" \
python:3.13-slim /entry.sh > /dev/null
sleep 6
printf ' статус container: %s\n' "$(docker inspect "entry-$variant" --format '{{.State.Status}}')"
printf ' код выхода: %s\n' "$(docker inspect "entry-$variant" --format '{{.State.ExitCode}}')"
printf ' PID 1: %s\n' \
"$(docker inspect "entry-$variant" --format '{{.State.Status}}' | grep -q running \
&& docker exec "entry-$variant" cat /proc/1/comm 2>/dev/null || echo '(завершён)')"
docker logs "entry-$variant" 2>&1 | tail -2 | sed 's/^/ лог: /'
docker rm -f "entry-$variant" > /dev/null
done
Ожидаемый вывод:
═══ вариант bad ═══
статус container: running
код выхода: 0
PID 1: sh
лог: приложение запущено
лог: приложение УПАЛО
═══ вариант good ═══
статус container: exited
код выхода: 1
PID 1: (завершён)
лог: приложение запущено
лог: приложение УПАЛО
В первом варианте приложение упало, а container остался Up с кодом 0. Ни healthcheck по умолчанию, ни политика перезапуска, ни оркестратор не отреагируют — для них всё в порядке.
Во втором exec заменил процесс скрипта, приложение стало PID 1, и его падение стало падением container'а (урок 5.4).
Это и есть точный смысл правила «один процесс»: Docker должен видеть жизненный цикл того, что важно.
Обоснованное исключение: master и worker'ы
cd /tmp/immut
cat > compose.workers.yaml <<'EOF'
name: immut-w
services:
app:
image: python:3.13-slim
command:
- sh
- -c
- |
pip install -q gunicorn==26.0.0 2>/dev/null
cat > /app.py <<'PY'
def app(environ, start_response):
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"ok\n"]
PY
exec gunicorn -w 3 -b 0.0.0.0:8000 --chdir / app:app
EOF
docker compose -f compose.workers.yaml up -d > /dev/null 2>&1
sleep 12
echo "═══ процессов в container ═══"
docker compose -f compose.workers.yaml exec -T app sh -c 'ps -o pid,ppid,args' 2>/dev/null | head -6 | sed 's/^/ /'
echo "═══ убиваем один worker ═══"
wpid="$(docker compose -f compose.workers.yaml exec -T app sh -c \
"ps -o pid,args | awk '/[g]unicorn/ && \$1 != 1 {print \$1; exit}'" | tr -d '\r')"
printf ' убиваем PID %s\n' "$wpid"
docker compose -f compose.workers.yaml exec -T app kill -9 "$wpid" 2>/dev/null
sleep 4
echo "═══ master восстановил worker ═══"
docker compose -f compose.workers.yaml exec -T app sh -c 'ps -o pid,args | grep -c "[g]unicorn"' \
| xargs printf ' процессов gunicorn: %s\n'
printf ' статус container: %s\n' \
"$(docker compose -f compose.workers.yaml ps --format '{{.Status}}' app)"
echo "═══ а падение master роняет container ═══"
docker compose -f compose.workers.yaml exec -T app kill -9 1 2>/dev/null || true
sleep 3
printf ' статус: %s, код: %s\n' \
"$(docker inspect "$(docker compose -f compose.workers.yaml ps -aq app)" --format '{{.State.Status}}')" \
"$(docker inspect "$(docker compose -f compose.workers.yaml ps -aq app)" --format '{{.State.ExitCode}}')"
docker compose -f compose.workers.yaml down > /dev/null 2>&1
Ожидаемый вывод:
═══ процессов в container ═══
PID PPID COMMAND
1 0 /usr/local/bin/python /usr/local/bin/gunicorn -w 3 -b 0.0.0.0:8000
10 1 /usr/local/bin/python /usr/local/bin/gunicorn -w 3 -b 0.0.0.0:8000
11 1 /usr/local/bin/python /usr/local/bin/gunicorn -w 3 -b 0.0.0.0:8000
12 1 /usr/local/bin/python /usr/local/bin/gunicorn -w 3 -b 0.0.0.0:8000
═══ убиваем один worker ═══
убиваем PID 10
═══ master восстановил worker ═══
процессов gunicorn: 4
статус container: Up 20 seconds
═══ а падение master роняет container ═══
статус: exited, код: 137
Четыре процесса — и это правильно. Master следит за потомками: убитый worker восстановлен, container продолжает работать.
Последний блок показывает критерий: падение главного процесса роняет container. Жизненный цикл виден Docker, правило соблюдено.
Сравните с предыдущим примером: там процессов было два, и падение важного из них осталось незамеченным.
Логи в файл теряются
cd /tmp/immut
cat > compose.logs.yaml <<'EOF'
name: immut-l
services:
to-file:
image: python:3.13-slim
command:
- python
- -c
- |
import time
with open("/var/log/app.log", "a") as f:
for i in range(3):
f.write(f"запись {i}\n")
f.flush()
time.sleep(1)
time.sleep(60)
to-stdout:
image: python:3.13-slim
command:
- python
- -u
- -c
- |
import time
for i in range(3):
print(f"запись {i}", flush=True)
time.sleep(1)
time.sleep(60)
EOF
docker compose -f compose.logs.yaml up -d > /dev/null 2>&1
sleep 5
echo "═══ docker logs ═══"
printf ' to-file: [%s]\n' "$(docker compose -f compose.logs.yaml logs to-file --no-log-prefix 2>/dev/null | tr '\n' ' ')"
printf ' to-stdout: [%s]\n' "$(docker compose -f compose.logs.yaml logs to-stdout --no-log-prefix 2>/dev/null | tr '\n' ' ')"
echo "═══ где логи первого ═══"
docker compose -f compose.logs.yaml exec -T to-file cat /var/log/app.log | sed 's/^/ /'
echo "═══ после пересоздания container ═══"
docker compose -f compose.logs.yaml up -d --force-recreate to-file > /dev/null 2>&1
sleep 3
docker compose -f compose.logs.yaml exec -T to-file sh -c 'wc -l < /var/log/app.log' \
| xargs printf ' строк в файле: %s (прежние записи потеряны)\n'
docker compose -f compose.logs.yaml down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/immut
Ожидаемый вывод:
═══ docker logs ═══
to-file: []
to-stdout: [запись 0 запись 1 запись 2 ]
═══ где логи первого ═══
запись 0
запись 1
запись 2
═══ после пересоздания container ═══
строк в файле: 3 (прежние записи потеряны)
Первый сервис пишет в файл: docker logs пуст, платформа сбора логов ничего не получит, а при пересоздании container'а история исчезнет.
Второй пишет в stdout — логи доступны штатными средствами и переживают container (урок 6.6).
Чек-лист как скрипт
mkdir -p /tmp/checklist && cd /tmp/checklist
cat > readiness-check.sh <<'SH'
#!/usr/bin/env bash
# Проверка готовности образа и container к эксплуатации.
# Каждый пункт — да или нет; «частично» не бывает.
set -uo pipefail
IMAGE="${1:?укажите образ}"
passed=0
failed=0
skipped=0
ok() { printf ' ✓ %-42s %s\n' "$1" "${2:-}"; passed=$((passed + 1)); }
no() { printf ' ✗ %-42s %s\n' "$1" "${2:-}"; failed=$((failed + 1)); }
skip() { printf ' — %-42s %s\n' "$1" "${2:-}"; skipped=$((skipped + 1)); }
printf '\nОБРАЗ: %s\n' "$IMAGE"
# 1. Инструменты сборки
tools="$(docker run --rm --entrypoint sh "$IMAGE" -c \
'command -v gcc g++ make 2>/dev/null | tr "\n" " "' 2>/dev/null || true)"
[ -z "$tools" ] && ok "1. нет инструментов сборки" || no "1. инструменты сборки" "$tools"
# 2. Пользователь
uid="$(docker run --rm --entrypoint id "$IMAGE" -u 2>/dev/null || echo '?')"
[ "$uid" != "0" ] && ok "2. работает от non-root" "uid=$uid" || no "2. работает от root" "uid=$uid"
# 4. Базовый образ по digest
base="$(docker image inspect "$IMAGE" --format '{{index .Config.Labels "org.opencontainers.image.base.name"}}' 2>/dev/null)"
case "$base" in
*@sha256:*) ok "4. базовый образ по digest" "$base" ;;
""|"<no value>") skip "4. базовый образ" "метка не задана" ;;
*) no "4. базовый образ по тегу" "$base" ;;
esac
# 5. Секреты в истории слоёв
leaks="$(docker history --no-trunc "$IMAGE" 2>/dev/null \
| grep -icE '(password|secret|token|api[_-]?key)=[^ ]{6,}' || true)"
[ "$leaks" -eq 0 ] && ok "5. секретов в истории слоёв нет" || no "5. подозрительные строки в истории" "$leaks"
# 7. exec form
cmd="$(docker image inspect "$IMAGE" --format '{{json .Config.Cmd}}' 2>/dev/null)"
case "$cmd" in
*'"/bin/sh","-c"'*) no "7. CMD в shell form" "$cmd" ;;
'null') skip "7. CMD не задан" "используется ENTRYPOINT" ;;
*) ok "7. CMD в exec form" ;;
esac
printf '\nЗАПУСК (проба)\n'
cid="$(docker run -d --rm \
--read-only --tmpfs /tmp:size=16m \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory 256m --pids-limit 100 \
--entrypoint sleep "$IMAGE" 60 2>/dev/null || true)"
if [ -z "$cid" ]; then
no "9-13. запуск с ограничениями" "container не стартовал"
else
# 9. read-only
docker exec "$cid" sh -c 'echo x > /probe' 2>/dev/null \
&& no "9. корень доступен на запись" || ok "9. read-only корень"
# 10. capabilities
caps="$(docker inspect "$cid" --format '{{json .HostConfig.CapDrop}}')"
case "$caps" in
*ALL*) ok "10. capabilities отобраны" "$caps" ;;
*) no "10. capabilities не отобраны" "$caps" ;;
esac
# 11. no-new-privileges
docker inspect "$cid" --format '{{json .HostConfig.SecurityOpt}}' 2>/dev/null \
| grep -q 'no-new-privileges' \
&& ok "11. no-new-privileges" || no "11. no-new-privileges не задан"
# 12. лимиты
mem="$(docker exec "$cid" cat /sys/fs/cgroup/memory.max 2>/dev/null || echo '?')"
[ "$mem" != "max" ] && [ "$mem" != "?" ] \
&& ok "12. лимит памяти применён" "$((mem / 1048576)) MiB" || no "12. лимит памяти"
# 13. pids
pids="$(docker exec "$cid" cat /sys/fs/cgroup/pids.max 2>/dev/null || echo '?')"
[ "$pids" != "max" ] && [ "$pids" != "?" ] \
&& ok "13. лимит PID применён" "$pids" || no "13. лимит PID"
docker rm -f "$cid" > /dev/null 2>&1
fi
printf '\nИТОГ: пройдено %s, не пройдено %s, пропущено %s\n' "$passed" "$failed" "$skipped"
exit "$( [ "$failed" -eq 0 ] && echo 0 || echo 1 )"
SH
chmod +x readiness-check.sh
echo "═══ проверяем обычный образ ═══"
./readiness-check.sh python:3.13-slim
Ожидаемый вывод:
═══ проверяем обычный образ ═══
ОБРАЗ: python:3.13-slim
✓ 1. нет инструментов сборки
✗ 2. работает от root uid=0
— 4. базовый образ метка не задана
✓ 5. секретов в истории слоёв нет
✓ 7. CMD в exec form
ЗАПУСК (проба)
✓ 9. read-only корень
✓ 10. capabilities отобраны ["ALL"]
✓ 11. no-new-privileges
✓ 12. лимит памяти применён 256 MiB
✓ 13. лимит PID применён 100
ИТОГ: пройдено 8, не пройдено 1, пропущено 1
Базовый python:3.13-slim проваливает пункт 2 — он работает от root. Это ожидаемо: базовый образ не предназначен для production как есть.
Обратите внимание на разделение: пункты 9–13 зависят от флагов запуска, а не от образа. Скрипт запускает пробный container с ограничениями и проверяет, что они применились — то есть что образ с ними совместим.
Пропущенный пункт 4 — не провал: метка не задана, и скрипт честно сообщает, что проверить нечего.
cd /tmp && rm -rf /tmp/checklist
Практическое упражнение
Задание. Возьмите произвольный образ и доведите его до соответствия чек-листу.
Требования:
- Скрипт проверяет минимум восемь пунктов чек-листа и возвращает ненулевой код при провале.
- Показан образ, проваливающий проверки, — «до».
- Показан исправленный образ, проходящий их, — «после».
- Для каждого исправления объяснено, какой пункт оно закрывает.
- Продемонстрировано, что правило «один процесс» соблюдено: падение приложения роняет container.
- Один сознательно не выполненный пункт с записанным обоснованием.
Подсказки
Подсказка 1
Пункты 9–13 зависят от флагов запуска: проверять их нужно на пробном container'е.
Подсказка 2
Пункт 5 проверяется остановкой процесса приложения изнутри container'а.
Подсказка 3
Требование 6 — не поблажка: оно проверяет, что решение принято осознанно, а не забыто.
Решение
Показать решение
mkdir -p /tmp/prodready/app && cd /tmp/prodready
cat > app/__init__.py <<'PY'
"""Приложение для проверки готовности к эксплуатации."""
PY
cat > app/main.py <<'PY'
"""Минимальный сервис с корректным поведением при остановке."""
from __future__ import annotations
import json
import logging
import os
import signal
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return json.dumps(
{"level": record.levelname, "logger": record.name,
"message": record.getMessage()},
ensure_ascii=False,
)
handler = logging.StreamHandler(sys.stdout) # пункт 19: логи в stdout
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
logger = logging.getLogger("app")
_server: HTTPServer | None = None
_crash = os.environ.get("CRASH_AFTER", "")
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
body = json.dumps({"status": "ok", "pid": os.getpid()}).encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
def shutdown(signum: int, _frame: object) -> None:
"""Пункт 17: SIGTERM завершает штатно с кодом 0."""
logger.info("получен %s, завершаюсь", signal.Signals(signum).name)
if _server is not None:
Thread(target=_server.shutdown, daemon=True).start()
def main() -> int:
global _server
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
# Пункт 5 упражнения: имитация падения приложения
if _crash:
def crash() -> None:
time.sleep(float(_crash))
logger.error("аварийное завершение (имитация)")
os._exit(1)
Thread(target=crash, daemon=True).start()
_server = HTTPServer(("0.0.0.0", 8000), Handler)
logger.info("запущен на 0.0.0.0:8000, uid=%s", os.getuid())
_server.serve_forever()
logger.info("остановлен штатно")
return 0
if __name__ == "__main__":
sys.exit(main())
PY
# ── Образ «до»: типичный, не готовый к эксплуатации ──
cat > Dockerfile.before <<'EOF'
FROM python:3.13-slim
# Инструменты сборки остаются в финальном образе
RUN apt-get update && apt-get install -y --no-install-recommends gcc make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY app/ ./app/
# Секрет в слое образа
ENV API_TOKEN=super-secret-token-12345
# shell form: приложение не будет PID 1
CMD python -m app.main
EOF
# ── Образ «после» ──
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app
WORKDIR /app
# Пункт 1: инструменты сборки только здесь, в финальный образ не попадут
FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc make \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
FROM base AS runtime
# Пункт 2: non-root
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
ENV PATH="/opt/venv/bin:$PATH"
USER 10001:10001
EXPOSE 8000
# Пункт 7: exec form — приложение становится PID 1
CMD ["python", "-m", "app.main"]
EOF
cat > readiness-check.sh <<'SH'
#!/usr/bin/env bash
# Проверка готовности: восемь пунктов чек-листа.
set -uo pipefail
IMAGE="${1:?укажите образ}"
passed=0; failed=0; skipped=0
ok() { printf ' ✓ %-38s %s\n' "$1" "${2:-}"; passed=$((passed+1)); }
no() { printf ' ✗ %-38s %s\n' "$1" "${2:-}"; failed=$((failed+1)); }
skip() { printf ' — %-38s %s\n' "$1" "${2:-}"; skipped=$((skipped+1)); }
printf ' Образ: %s\n' "$IMAGE"
# 1. Инструменты сборки
tools="$(docker run --rm --entrypoint sh "$IMAGE" -c \
'command -v gcc make 2>/dev/null | tr "\n" " "' 2>/dev/null || true)"
[ -z "$tools" ] && ok "1. нет инструментов сборки" || no "1. инструменты сборки" "${tools% }"
# 2. non-root
uid="$(docker run --rm --entrypoint id "$IMAGE" -u 2>/dev/null || echo '?')"
[ "$uid" != "0" ] && ok "2. non-root" "uid=$uid" || no "2. работает от root" "uid=$uid"
# 5. Секреты в метаданных и истории
env_leak="$(docker image inspect "$IMAGE" --format '{{json .Config.Env}}' 2>/dev/null \
| grep -icE '(TOKEN|SECRET|PASSWORD|API_KEY)=[^"]{6,}' || true)"
hist_leak="$(docker history --no-trunc "$IMAGE" 2>/dev/null \
| grep -icE '(TOKEN|SECRET|PASSWORD|API_KEY)=[^ ]{6,}' || true)"
if [ "$env_leak" -eq 0 ] && [ "$hist_leak" -eq 0 ]; then
ok "5. секретов в образе нет"
else
no "5. секрет в образе" "ENV=$env_leak history=$hist_leak"
fi
# 7. exec form
cmd="$(docker image inspect "$IMAGE" --format '{{json .Config.Cmd}}' 2>/dev/null)"
case "$cmd" in
*'"/bin/sh","-c"'*) no "7. CMD в shell form" "$cmd" ;;
*) ok "7. CMD в exec form" ;;
esac
# Пункты запуска
cid="$(docker run -d --rm \
--read-only --tmpfs /tmp:size=16m \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory 256m --pids-limit 100 \
-e CRASH_AFTER= \
"$IMAGE" 2>/dev/null || true)"
sleep 2
if [ -z "$cid" ] || ! docker inspect "$cid" > /dev/null 2>&1; then
no "9-13. запуск с ограничениями" "container не стартовал"
no "17. graceful shutdown" "нечего проверять"
else
docker exec "$cid" sh -c 'echo x > /probe' 2>/dev/null \
&& no "9. корень доступен на запись" || ok "9. read-only корень"
docker inspect "$cid" --format '{{json .HostConfig.CapDrop}}' | grep -q ALL \
&& ok "10. capabilities отобраны" || no "10. capabilities"
docker inspect "$cid" --format '{{json .HostConfig.SecurityOpt}}' \
| grep -q 'no-new-privileges' \
&& ok "11. no-new-privileges" || no "11. no-new-privileges"
mem="$(docker exec "$cid" cat /sys/fs/cgroup/memory.max 2>/dev/null || echo max)"
[ "$mem" != "max" ] && ok "12. лимит памяти" "$((mem / 1048576)) MiB" || no "12. лимит памяти"
# 17. graceful shutdown
name="$(docker inspect "$cid" --format '{{.Name}}' | tr -d '/')"
docker stop -t 15 "$name" > /dev/null 2>&1
code="$(docker inspect "$name" --format '{{.State.ExitCode}}' 2>/dev/null || echo '?')"
[ "$code" = "0" ] && ok "17. SIGTERM даёт код 0" || no "17. код выхода" "$code"
docker rm -f "$name" > /dev/null 2>&1
fi
printf ' Итог: пройдено %s, не пройдено %s, пропущено %s\n' "$passed" "$failed" "$skipped"
[ "$failed" -eq 0 ]
SH
chmod +x readiness-check.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 2: образ ДО ═══\n'
docker build -q -f Dockerfile.before -t ready:before . > /dev/null 2>&1
./readiness-check.sh ready:before
before_rc=$?
[ "$before_rc" -ne 0 ] && ok "образ «до» проверку не проходит" || bad "образ «до» неожиданно прошёл"
printf '\n═══ Требование 3: образ ПОСЛЕ ═══\n'
docker build -q -t ready:after . > /dev/null 2>&1
./readiness-check.sh ready:after
after_rc=$?
[ "$after_rc" -eq 0 ] && ok "образ «после» проходит все проверки" || bad "остались непройденные пункты"
printf '\n═══ Требование 4: что закрыло каждое исправление ═══\n'
cat <<'TXT'
пункт 1 multi-stage: gcc и make остались в стадии builder
пункт 2 USER 10001:10001 в финальной стадии
пункт 5 ENV с токеном удалён; секрет доставляется файлом при запуске
пункт 7 CMD переписан в exec form — приложение становится PID 1
пункт 9 приложение не пишет в корень; /tmp вынесен в tmpfs
пункт 17 обработчик SIGTERM останавливает сервер и выходит с кодом 0
TXT
printf '\n═══ Требование 5: падение приложения роняет container ═══\n'
docker run -d --name crash-test -e CRASH_AFTER=3 ready:after > /dev/null 2>&1
sleep 7
status="$(docker inspect crash-test --format '{{.State.Status}}')"
code="$(docker inspect crash-test --format '{{.State.ExitCode}}')"
pid1="$(docker inspect crash-test --format '{{index .Config.Cmd 0}}')"
printf ' PID 1 из образа: %s\n' "$pid1"
printf ' после падения: статус=%s код=%s\n' "$status" "$code"
docker logs crash-test 2>&1 | tail -1 | sed 's/^/ лог: /'
[ "$status" = "exited" ] && [ "$code" = "1" ] \
&& ok "падение приложения = падение container'а" || bad "статус=$status код=$code"
docker rm -f crash-test > /dev/null 2>&1
printf '\n═══ Требование 6: осознанно не выполненный пункт ═══\n'
cat > DECISIONS.md <<'TXT'
# Решения по чек-листу готовности
## Пункт 13: --pids-limit — НЕ применяется
**Решение:** лимит числа процессов не задаётся.
**Обоснование:** приложение однопоточное, не порождает потомков и не использует
worker-процессы. Единственный процесс — сам сервер. Лимит не защищает ни от чего
реального, но добавляет параметр, который придётся пересматривать при первом же
переходе на Gunicorn.
**Условие пересмотра:** появление worker-процессов или пула потоков.
При переходе на Gunicorn с N worker'ами задать pids-limit = (N + 2) * 4.
**Кто принял:** команда разработки, зафиксировано в этом файле.
## Пункт 4: базовый образ по digest — отложено
**Решение:** используется тег `python:3.13-slim`, digest не закреплён.
**Обоснование:** проект на стадии активной разработки, базовый образ обновляется
еженедельно вместе с исправлениями безопасности. Закрепление digest потребует
процесса регулярного обновления, которого пока нет.
**Условие пересмотра:** перед первым выпуском в эксплуатацию — обязательно.
TXT
sed -n '1,12p' DECISIONS.md | sed 's/^/ /'
[ -s DECISIONS.md ] && ok "решение записано с обоснованием и условием пересмотра" \
|| bad "обоснование не записано"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker rmi -f ready:before ready:after > /dev/null 2>&1
cd /tmp && rm -rf /tmp/prodready
exit "$fail"
Ожидаемый вывод:
═══ Требование 2: образ ДО ═══
Образ: ready:before
✗ 1. инструменты сборки /usr/bin/gcc /usr/bin/make
✗ 2. работает от root uid=0
✗ 5. секрет в образе ENV=1 history=1
✗ 7. CMD в shell form ["/bin/sh","-c","python -m app.main"]
✓ 9. read-only корень
✓ 10. capabilities отобраны
✓ 11. no-new-privileges
✓ 12. лимит памяти 256 MiB
✗ 17. код выхода 143
Итог: пройдено 4, не пройдено 5, пропущено 0
✓ образ «до» проверку не проходит
═══ Требование 3: образ ПОСЛЕ ═══
Образ: ready:after
✓ 1. нет инструментов сборки
✓ 2. non-root uid=10001
✓ 5. секретов в образе нет
✓ 7. CMD в exec form
✓ 9. read-only корень
✓ 10. capabilities отобраны
✓ 11. no-new-privileges
✓ 12. лимит памяти 256 MiB
✓ 17. SIGTERM даёт код 0
Итог: пройдено 9, не пройдено 0, пропущено 0
✓ образ «после» проходит все проверки
═══ Требование 4: что закрыло каждое исправление ═══
пункт 1 multi-stage: gcc и make остались в стадии builder
пункт 2 USER 10001:10001 в финальной стадии
пункт 5 ENV с токеном удалён; секрет доставляется файлом при запуске
пункт 7 CMD переписан в exec form — приложение становится PID 1
пункт 9 приложение не пишет в корень; /tmp вынесен в tmpfs
пункт 17 обработчик SIGTERM останавливает сервер и выходит с кодом 0
═══ Требование 5: падение приложения роняет container ═══
PID 1 из образа: python
после падения: статус=exited код=1
лог: {"level": "ERROR", "logger": "app", "message": "аварийное завершение (имитация)"}
✓ падение приложения = падение container'а
═══ Требование 6: осознанно не выполненный пункт ═══
# Решения по чек-листу готовности
## Пункт 13: --pids-limit — НЕ применяется
**Решение:** лимит числа процессов не задаётся.
...
✓ решение записано с обоснованием и условием пересмотра
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Обратите внимание на код 143 у образа «до» в пункте 17. Это 128 + 15, то есть SIGTERM без обработчика: CMD в shell form сделал PID 1 оболочкой, которая сигнал не транслировала и была убита по истечении grace period (урок 6.5). Один дефект — shell form — провалил сразу два пункта чек-листа.
Три решения, определяющие качество.
Скрипт разделяет пункты образа и пункты запуска. Первые проверяются через docker image inspect и разовые запуски; вторые требуют пробного container'а с флагами. Смешивать их нельзя: пункт «read-only корень» — свойство не образа, а команды запуска, и образ может быть с ним несовместим (например, если пишет в /var). Пробный запуск проверяет именно совместимость.
Требование 6 оформлено файлом, а не комментарием. DECISIONS.md с обоснованием и условием пересмотра превращает пропуск пункта из умолчания в решение. Через полгода будет видно не только что пункт не выполнен, но и почему, и когда к нему вернуться. Это и есть разница между осознанным отказом и забывчивостью.
Проверка «до» ожидает провал и падает, если его нет. Скрипт, который всегда зелёный, ничего не проверяет. Строка [ "$before_rc" -ne 0 ] фиксирует, что метод способен обнаружить проблему, — без неё «после» доказывало бы только работоспособность образа, но не работоспособность проверки.
Чего решение не делает. Чек-лист покрывает восемь пунктов из двадцати двух: остальные требуют либо запущенных зависимостей (пункты 15–16, 22), либо внешних инструментов (сканеры, SBOM — урок 11.6), либо оценки человеком (пункт 14: политика перезапуска зависит от того, что делает сервис). Автоматизировать стоит то, что автоматизируется однозначно; остальное остаётся в списке для ручного рассмотрения — но остаётся.
Проверка результата
docker run -d --name pcheck --read-only --tmpfs /tmp:size=8m \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory 128m --pids-limit 50 \
python:3.13-slim sleep 60 > /dev/null
sleep 2
docker exec pcheck sh -c 'echo x > /probe' 2>&1 | tail -1
docker exec pcheck sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/pids.max'
docker inspect pcheck --format '{{json .HostConfig.SecurityOpt}}'
docker rm -f pcheck > /dev/null
Ожидается Read-only file system, 134217728, 50 и ["no-new-privileges"].
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
docker exec для исправления в production | Быстрее развёртывания | Изменение теряется, реплики расходятся |
| Скрипт запускает приложение в фоне | Нужно «ещё что-то» рядом | Docker не видит падения; exec |
supervisord в container | Привычка с виртуальных машин | Docker следит за supervisord, не за приложением |
| Логи в файл внутри container | Привычка | Исчезают с container'ом; писать в stdout |
| Сессии в памяти приложения | Работает на одной реплике | Ломается на второй; выносить в Redis |
| Правило «один процесс» как догма | Неточная формулировка | Master с worker'ами допустим |
| Чек-лист пройден «частично» | Кажется достаточным | Пункт либо выполнен, либо нет |
| Пропуск пункта без записи | Забыли или решили пропустить | Неотличимо; записывать обоснование |
| Одинаковый образ для dev и prod | Проще | Инструменты разработки в эксплуатации |
Контрольные вопросы
На понимание:
- Почему исправление через
docker execисчезает и когда именно? - Сформулируйте правило «один процесс на container» точно.
- Назовите два обоснованных исключения и два антипаттерна.
- Какой критерий отличает допустимую многопроцессность от недопустимой?
- Почему логи пишут в stdout, а не в файл?
На применение:
- Как проверить, что падение приложения роняет container?
- Как оформить сознательный отказ от пункта чек-листа?
- Как разделить проверки образа и проверки запуска?
На диагностику:
- Container
Up, приложение не отвечает, логи обрываются. Первая версия? - Исправление, сделанное вчера, перестало действовать. Что произошло?
Краткое резюме
- Immutable infrastructure: экземпляры не изменяют, а заменяют новыми.
docker execдля исправления теряет изменение при первом же пересоздании container'а.- Правки через
execразводят реплики: часть трафика идёт по одному коду, часть по другому. - Точная формулировка правила: одна задача и один процесс, чей жизненный цикл виден Docker.
- Master с worker'ами — допустимое исключение: падение master роняет container.
supervisordиcronрядом с приложением — антипаттерн: падение остаётся незамеченным.- Скрипт-обёртка должна завершаться
exec, иначе PID 1 — оболочка. - Логи в файл исчезают вместе с container'ом и не доходят до платформы сбора.
- Состояние в памяти ломается на второй реплике — выносить во внешнее хранилище.
- Чек-лист из 22 пунктов делится на требования к образу, к запуску и к поведению.
- Пункт либо выполнен, либо нет; «частично» не бывает.
- Сознательный отказ от пункта записывают с обоснованием и условием пересмотра.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: build best practices | https://docs.docker.com/build/building/best-practices/ | Один процесс, multi-stage, exec form |
| Docker: container basics | https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/ | Модель container'а и его жизненного цикла |
| The Twelve-Factor App | https://12factor.net/ | Двенадцать факторов целиком |
| Docker: logging | https://docs.docker.com/engine/logging/ | Почему stdout, драйверы логов |
Docker: docker exec | https://docs.docker.com/reference/cli/docker/container/exec/ | Область действия изменений |
| OCI image spec | https://github.com/opencontainers/image-spec | Метки, конфигурация образа |
Навигация
← Вернуться к разделу
Следующий материал → Runtime hardening
Главное оглавление