Главная/Production-ready containers/Урок

11.1. Принципы production

Цели

После этого материала вы сможете:

  • объяснить, почему docker exec для исправления в production — антипаттерн, и что делать вместо;
  • сформулировать правило «один процесс на container» точно и назвать обоснованные исключения;
  • применить принципы Twelve-Factor к контейнеризованному приложению;
  • пользоваться чек-листом готовности и понимать, откуда взялся каждый его пункт;
  • отличать требования, обязательные всегда, от зависящих от контекста.

Предварительные знания

Ключевые термины

ТерминОбъяснение
immutable infrastructureПодход: экземпляры не изменяются, а заменяются
configuration driftРасхождение состояния экземпляров, накопленное правками
Twelve-FactorСвод правил построения сервисов, применимый к container'ам
disposabilityСвойство: экземпляр можно быстро запустить и остановить
dev/prod parityМинимальное различие между средами

Теория

Immutable infrastructure

Принцип: работающий экземпляр не изменяют — его заменяют новым.

text
Изменяемая инфраструктура        Неизменяемая
─────────────────────────        ────────────
  сервер                           образ 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 с приложением и nginxDocker видит 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Логи — поток в stdout6.6
XII. Admin processesРазовые задачи — тем же кодом10.4

Три фактора нарушаются чаще остальных.

VI. Processes. Сессии в памяти приложения ломаются при второй реплике: пользователь попадает на другой экземпляр и теряет сессию. Состояние выносят в Redis или базу.

IX. Disposability. Приложение должно быть готово к остановке в любой момент. Практически: обработка SIGTERM, дозавершение текущей работы, идемпотентность операций.

XI. Logs. Запись в файл внутри container'а означает, что логи исчезнут вместе с ним. Приложение пишет в stdout, а маршрутизацией занимается платформа.

Чек-лист готовности

Из принципов выводится проверяемый список. Каждый пункт — либо выполнен, либо нет; «частично» не бывает.

Образ:

ТребованиеПроверка
1Multi-stage: инструментов сборки нет в финальном образеpip list, command -v gcc
2Работает от non-rootdocker run --rm образ id -u
3Зависимости зафиксированы по версиямrequirements.txt или lock-файл
4Базовый образ закреплён по digestFROM ...@sha256:...
5Нет секретов ни в одном слоеdocker history, поиск по слоям
6.dockerignore исключает лишнееРазмер контекста сборки
7CMD в exec formdocker inspect .Config.Cmd
8Тесты прогнаны при сборкеОтдельная стадия

Запуск:

ТребованиеПроверка
9Read-only корневая файловая системаПопытка записи
10Capabilities отобраны--cap-drop=ALL плюс точечные
11no-new-privilegesdocker inspect .HostConfig.SecurityOpt
12Лимиты памяти и CPU заданы/sys/fs/cgroup/memory.max
13--pids-limit задан/sys/fs/cgroup/pids.max
14Политика перезапуска выбрана осознанно.HostConfig.RestartPolicy

Поведение:

ТребованиеПроверка
15Liveness не зависит от внешних сервисовОстановить базу, проверить статус
16Readiness отражает готовность принимать трафикТо же
17SIGTERM завершает штатно, код 0docker stop, .State.ExitCode
18stop_grace_period покрывает длительность работыОстановка под нагрузкой
19Логи в stdout, структурированныеdocker logs, разбор JSON
20Конфигурация валидируется при стартеЗапуск с неверным значением
21Секреты не видны в docker inspectПоиск значения
22Приложение переживает недоступность зависимостиОстановить базу, вернуть

Двадцать два пункта — не догма, а отправная точка. Часть зависит от контекста: --pids-limit не нужен, если приложение не порождает процессов; read-only корень бессмыслен, если платформа его не поддерживает.

Но каждый пункт должен быть рассмотрен и решение записано. Разница между «мы не стали делать read-only, потому что приложение пишет в /var/lib» и «мы про это не подумали» — принципиальная.

Чего чек-лист не покрывает

ОбластьГде разбирается
Сканирование уязвимостей, SBOM11.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 не переживает пересоздание

bash
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/^/  /'

Ожидаемый вывод:

text
═══ исходное состояние ═══
  конфиг из образа: 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).

Реплики расходятся

bash
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

Ожидаемый вывод:

text
═══ исправляем одну реплику ═══
═══ состояние трёх реплик ═══
  immut-app-1      исправлено
  immut-app-2      НЕТ
  immut-app-3      НЕТ

Треть трафика обслуживается исправленным кодом, две трети — нет. Поведение системы становится недетерминированным, а воспроизвести проблему в разработке невозможно.

Docker не видит падения потомка

bash
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

Ожидаемый вывод:

text
═══ вариант 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'ы

bash
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

Ожидаемый вывод:

text
═══ процессов в 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, правило соблюдено.

Сравните с предыдущим примером: там процессов было два, и падение важного из них осталось незамеченным.

Логи в файл теряются

bash
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

Ожидаемый вывод:

text
═══ docker logs ═══
  to-file:   []
  to-stdout: [запись 0 запись 1 запись 2 ]
═══ где логи первого ═══
  запись 0
  запись 1
  запись 2
═══ после пересоздания container ═══
  строк в файле: 3 (прежние записи потеряны)

Первый сервис пишет в файл: docker logs пуст, платформа сбора логов ничего не получит, а при пересоздании container'а история исчезнет.

Второй пишет в stdout — логи доступны штатными средствами и переживают container (урок 6.6).

Чек-лист как скрипт

bash
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

Ожидаемый вывод:

text
═══ проверяем обычный образ ═══

ОБРАЗ: 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 — не провал: метка не задана, и скрипт честно сообщает, что проверить нечего.

bash
cd /tmp && rm -rf /tmp/checklist

Практическое упражнение

Задание. Возьмите произвольный образ и доведите его до соответствия чек-листу.

Требования:

  1. Скрипт проверяет минимум восемь пунктов чек-листа и возвращает ненулевой код при провале.
  2. Показан образ, проваливающий проверки, — «до».
  3. Показан исправленный образ, проходящий их, — «после».
  4. Для каждого исправления объяснено, какой пункт оно закрывает.
  5. Продемонстрировано, что правило «один процесс» соблюдено: падение приложения роняет container.
  6. Один сознательно не выполненный пункт с записанным обоснованием.

Подсказки

Подсказка 1

Пункты 9–13 зависят от флагов запуска: проверять их нужно на пробном container'е.

Подсказка 2

Пункт 5 проверяется остановкой процесса приложения изнутри container'а.

Подсказка 3

Требование 6 — не поблажка: оно проверяет, что решение принято осознанно, а не забыто.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Требование 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: политика перезапуска зависит от того, что делает сервис). Автоматизировать стоит то, что автоматизируется однозначно; остальное остаётся в списке для ручного рассмотрения — но остаётся.

Проверка результата

bash
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ПрощеИнструменты разработки в эксплуатации

Контрольные вопросы

На понимание:

  1. Почему исправление через docker exec исчезает и когда именно?
  2. Сформулируйте правило «один процесс на container» точно.
  3. Назовите два обоснованных исключения и два антипаттерна.
  4. Какой критерий отличает допустимую многопроцессность от недопустимой?
  5. Почему логи пишут в stdout, а не в файл?

На применение:

  1. Как проверить, что падение приложения роняет container?
  2. Как оформить сознательный отказ от пункта чек-листа?
  3. Как разделить проверки образа и проверки запуска?

На диагностику:

  1. Container Up, приложение не отвечает, логи обрываются. Первая версия?
  2. Исправление, сделанное вчера, перестало действовать. Что произошло?

Краткое резюме

  1. Immutable infrastructure: экземпляры не изменяют, а заменяют новыми.
  2. docker exec для исправления теряет изменение при первом же пересоздании container'а.
  3. Правки через exec разводят реплики: часть трафика идёт по одному коду, часть по другому.
  4. Точная формулировка правила: одна задача и один процесс, чей жизненный цикл виден Docker.
  5. Master с worker'ами — допустимое исключение: падение master роняет container.
  6. supervisord и cron рядом с приложением — антипаттерн: падение остаётся незамеченным.
  7. Скрипт-обёртка должна завершаться exec, иначе PID 1 — оболочка.
  8. Логи в файл исчезают вместе с container'ом и не доходят до платформы сбора.
  9. Состояние в памяти ломается на второй реплике — выносить во внешнее хранилище.
  10. Чек-лист из 22 пунктов делится на требования к образу, к запуску и к поведению.
  11. Пункт либо выполнен, либо нет; «частично» не бывает.
  12. Сознательный отказ от пункта записывают с обоснованием и условием пересмотра.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: build best practiceshttps://docs.docker.com/build/building/best-practices/Один процесс, multi-stage, exec form
Docker: container basicshttps://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/Модель container'а и его жизненного цикла
The Twelve-Factor Apphttps://12factor.net/Двенадцать факторов целиком
Docker: logginghttps://docs.docker.com/engine/logging/Почему stdout, драйверы логов
Docker: docker exechttps://docs.docker.com/reference/cli/docker/container/exec/Область действия изменений
OCI image spechttps://github.com/opencontainers/image-specМетки, конфигурация образа

Навигация

← Вернуться к разделу
Следующий материал → Runtime hardening
Главное оглавление

Markdown на GitHub ↗