Главная/Production-ready containers/Практика

Раздел 11. Практические задания

Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.

Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.

Подготовка:

bash
mkdir -p ~/docker-course/11-production && cd ~/docker-course/11-production
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim postgres:17-alpine; do docker pull -q "$img"; done
docker version --format 'Engine: {{.Server.Version}}'
docker buildx version

Задание 1. Чек-лист готовности [обяз.]

Постановка. Возьмите любой существующий образ и проверьте его по чек-листу из урока 11.1.

Требуется:

  1. Проверить минимум восемь пунктов автоматически.
  2. Показать образ, проваливающий проверки, и исправленный.
  3. Для каждого исправления назвать закрытый пункт.
  4. Один пункт сознательно не выполнить и записать обоснование с условием пересмотра.

Ожидаемый результат. Скрипт проверки, два образа и файл решений.

Проверка:

bash
./readiness-check.sh <образ>; echo "код: $?"

Разбор — в уроке 11.1.


Задание 2. Правило «один процесс» [обяз.]

Постановка. Соберите три варианта запуска и покажите для каждого, видит ли Docker падение приложения:

  1. скрипт запускает приложение в фоне и живёт сам;
  2. скрипт завершается exec;
  3. Gunicorn с тремя worker'ами.

Для варианта 3 дополнительно покажите, что гибель worker'а не роняет container, а гибель master — роняет.

Ожидаемый результат. Таблица «статус container и код выхода» для трёх вариантов.

Проверка:

bash
docker inspect <container> --format '{{.State.Status}} {{.State.ExitCode}}'

Разбор — в уроке 11.1.


Задание 3. Runtime hardening [обяз.]

Постановка. Проведите hardening приложения и подтвердите каждую меру:

  1. non-root, UID задан в образе;
  2. --cap-drop=ALL без единого --cap-add;
  3. no-new-privileges — показать на setuid-программе;
  4. read-only корень, список tmpfs получен через docker diff;
  5. --pids-limit с обоснованием значения;
  6. seccomp не отключён.

Ожидаемый результат. Сравнительная таблица hardened и обычного варианта.

Проверка:

bash
docker exec <c> sh -c 'grep -E "CapEff|NoNewPrivs|Seccomp:" /proc/self/status; id -u'

Разбор — в уроке 11.2.


Задание 4. Слив трафика без потерь [обяз.]

Постановка. Настройте остановку так, чтобы под непрерывной нагрузкой не потерялся ни один запрос:

  1. при SIGTERM readiness отказывает сразу;
  2. приложение продолжает принимать запросы в течение периода слива;
  3. после слива дозавершает текущие и выходит с кодом 0;
  4. stop_grace_period рассчитан по формуле, расчёт записан.

Сравните с наивным вариантом, закрывающим приём сразу.

Ожидаемый результат. Два измерения «успешных и неудачных запросов» с ненулевой разницей.

Проверка:

bash
# нагрузка идёт всё время остановки, считаются коды ответов
docker inspect <c> --format '{{.State.ExitCode}}'

Разбор — в уроке 11.3.


Задание 5. Подбор лимитов по измерению [обяз.]

Постановка. Пройдите полный цикл:

  1. измерение без лимита под нагрузкой;
  2. вывод лимита по записанной формуле;
  3. проверка под лимитом: oom_kill = 0;
  4. контроль: заниженный лимит даёт код 137;
  5. расчёт числа worker'ов по обоим ограничениям.

Ожидаемый результат. Файл с расчётом и подтверждения всех пяти пунктов.

Проверка:

bash
docker exec <c> sh -c 'cat /sys/fs/cgroup/memory.peak; awk "/oom_kill /" /sys/fs/cgroup/memory.events'

Разбор — в уроке 11.4.


Задание 6. Секрет при сборке [доп.]

Постановка. Установите «приватный пакет» с использованием токена так, чтобы токен не попал в образ.

Требуется:

  1. использовать RUN --mount=type=secret;
  2. доказать отсутствие токена в docker history, image inspect и файловой системе;
  3. показать контрольный вариант с --build-arg, где токен находится;
  4. объяснить, почему наличие --mount не гарантирует отсутствия утечки.

Ожидаемый результат. Два образа и поиск значения токена в обоих.

Проверка:

bash
docker history --no-trunc <образ> | grep -c "<значение токена>"

Разбор — в уроке 11.5.


Задание 7. SBOM и поиск компонента [доп.]

Постановка. Получите SBOM образа и решите практическую задачу: «объявлена уязвимость в библиотеке X, есть ли она у нас».

Требуется:

  1. сгенерировать SBOM в формате SPDX или CycloneDX;
  2. посчитать компоненты по типам;
  3. найти конкретный компонент и его версию;
  4. показать компонент, которого в образе нет.

Ожидаемый результат. Скрипт поиска, дающий однозначный ответ за секунды.

Проверка:

bash
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  anchore/syft:latest <образ> -o spdx-json | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["packages"]))'

Разбор — в уроке 11.6.


Задание 8. Схема тегирования и откат [доп.]

Постановка. Организуйте теги так, чтобы откат был точным:

  1. неизменяемый тег по коммиту;
  2. неизменяемый релизный тег;
  3. изменяемый указатель latest;
  4. два последовательных выпуска;
  5. откат к первому выпуску и доказательство, что по latest он невозможен.

Ожидаемый результат. Демонстрация того, что latest переехал, а неизменяемые теги — нет.

Проверка:

bash
docker image inspect <образ>:v1.0.0 --format '{{.Id}}'
docker image inspect <образ>:latest --format '{{.Id}}'

Разбор — в уроке 11.6.


Задание 9. Диагностика: сервис «работает», но не обслуживает [диаг.]

Постановка. Дана конфигурация:

yaml
name: diag11

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://postgres:secret@db:5432/appdb
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/readyz')"]
    restart: always
    deploy:
      resources:
        limits:
          memory: 128M

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb

Dockerfile:

dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD gunicorn -w 8 -b 0.0.0.0:8000 app:app

Жалобы эксплуатации: сервис периодически перестаёт отвечать; при обновлении теряются запросы; иногда все реплики перезапускаются одновременно; в логах пусто.

Найдите все дефекты, объясните каждый и предложите исправление.

Ожидаемый результат. Список дефектов с доказательством и исправленная конфигурация.

Разбор

Здесь семь дефектов. Каждый объясняет одну из жалоб.

Дефект 1. healthcheck обращается к /readyz.

Liveness проверяет зависимости. При отказе базы все реплики становятся unhealthy одновременно — это объясняет жалобу «все реплики перезапускаются одновременно» (урок 11.3).

bash
cd ~/docker-course/11-production && mkdir -p diag9 && cd diag9
# (файлы из условия созданы)
docker compose up -d > /dev/null 2>&1
sleep 25
printf '  база работает:      health=%s\n' "$(docker compose ps --format '{{.Health}}' api)"
docker compose stop db > /dev/null 2>&1
sleep 40
printf '  база остановлена:   health=%s\n' "$(docker compose ps --format '{{.Health}}' api)"
docker compose start db > /dev/null 2>&1

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

text
  база работает:      healthy
  база остановлена:   unhealthy

Исправление: отдельный /healthz, знающий только о процессе.

Дефект 2. Параметры healthcheck по умолчанию.

interval: 30s, retries: 3, timeout: 30s дают до двух минут на обнаружение отказа.

bash
cd ~/docker-course/11-production/diag9
docker inspect "$(docker compose ps -q api)" --format \
    '  interval={{.Config.Healthcheck.Interval}} retries={{.Config.Healthcheck.Retries}} timeout={{.Config.Healthcheck.Timeout}}' \
    | sed 's/000000000ns/s/g'

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

text
  interval=30s retries=3 timeout=30s

Исправление: interval: 5s, timeout: 2s, retries: 3, плюс start_period.

Дефект 3. stop_grace_period по умолчанию.

Десять секунд. При сливе трафика и запросах любой длительности этого не хватает — отсюда «при обновлении теряются запросы» (урок 11.3).

bash
cd ~/docker-course/11-production/diag9
docker inspect "$(docker compose ps -q api)" --format '  StopTimeout: {{.HostConfig.StopTimeout}}'

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

text
  StopTimeout: <no value>

Пустое значение означает умолчание — 10 секунд. Исправление: рассчитать по формуле detection + max_request + margin.

Дефект 4. CMD в shell form.

bash
cd ~/docker-course/11-production/diag9
docker image inspect "$(docker compose config --images | head -1)" \
    --format '  CMD: {{json .Config.Cmd}}' 2>/dev/null || \
docker inspect "$(docker compose ps -q api)" --format '  CMD: {{json .Config.Cmd}}'

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

text
  CMD: ["/bin/sh","-c","gunicorn -w 8 -b 0.0.0.0:8000 app:app"]

PID 1 — оболочка; SIGTERM до Gunicorn не доходит, остановка идёт через SIGKILL с кодом 137 (урок 6.5). Это вторая причина потери запросов при обновлении.

Дефект 5. Восемь worker'ов при лимите 128 MiB.

bash
cd ~/docker-course/11-production/diag9
docker compose exec -T api sh -c '
    printf "  memory.max: %.0f МиБ\n" "$(awk "{print \$1/1048576}" /sys/fs/cgroup/memory.max)"
    printf "  worker-процессов: %s\n" "$(for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f"; echo; done | grep -c "[g]unicorn")"
' 2>/dev/null
docker compose exec -T api sh -c \
    'awk "/oom_kill /{print \"  oom_kill: \" \$2}" /sys/fs/cgroup/memory.events' 2>/dev/null

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

text
  memory.max: 128 МиБ
  worker-процессов: 9
  oom_kill: 3

Расчёт по памяти: 128 × 0.7 / 60 ≈ 1 worker. Задано восемь — OOM killer убивает потомков. Это объясняет «сервис периодически перестаёт отвечать»: worker'ы тихо исчезают, а docker inspect показывает OOMKilled: false, потому что master жив (урок 11.4).

Дефект 6. restart: always вместе с unhealthy.

Docker не перезапускает нездоровые container'ы, но restart: always перезапускает упавшие. В сочетании с OOM потомков получается цикл: worker'ы гибнут, приложение деградирует, но container живёт.

Исправление: unless-stopped плюс корректные лимиты и число worker'ов.

Дефект 7. Логи и работа от root.

bash
cd ~/docker-course/11-production/diag9
printf '  UID процесса: %s\n' "$(docker compose exec -T api id -u 2>/dev/null)"
printf '  строк в docker logs: %s\n' "$(docker compose logs api --no-log-prefix 2>/dev/null | wc -l)"

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

text
  UID процесса: 0
  строк в docker logs: 0

Жалоба «в логах пусто» объясняется тем, что Gunicorn по умолчанию не пишет access-логи в stdout: нужны --access-logfile - и --error-logfile - (урок 6.9).

Работа от root — нарушение пункта 2 чек-листа (урок 11.1).

Исправленная конфигурация:

dockerfile
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv
WORKDIR /app

FROM base AS builder
COPY requirements.txt .
RUN pip install -r requirements.txt

FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app.py gunicorn.conf.py ./
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=5s --timeout=2s --retries=3 --start-period=15s --start-interval=1s \
  CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=1).status==200 else 1)"
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app"]
yaml
name: diag11

services:
  api:
    build:
      context: .
      target: runtime
    ports:
      - "127.0.0.1:8000:8000"
    environment:
      DATABASE_URL: "postgresql://postgres@db:5432/appdb"
      DB_PASSWORD_FILE: /run/secrets/db_password
      WEB_CONCURRENCY: "1"          # рассчитано: 128 × 0.7 / 60 ≈ 1
    secrets:
      - db_password
    restart: unless-stopped
    stop_grace_period: 25s          # 15 (слив) + 5 (запрос) + 5 (запас)
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777,uid=10001,gid=10001
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    pids_limit: 64
    depends_on:
      db:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 256M              # пересчитано под worker'ов
          cpus: "1.0"

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 3s
      retries: 10
      start_period: 30s
      start_interval: 1s
    restart: unless-stopped

secrets:
  db_password:
    file: ./secrets/db_password
python
# gunicorn.conf.py
import os

bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
workers = int(os.environ.get("WEB_CONCURRENCY", "1"))
accesslog = "-"          # логи в stdout, а не в файл
errorlog = "-"
graceful_timeout = 20    # меньше stop_grace_period
max_requests = 1000
max_requests_jitter = 100

Сводка дефектов:

ДефектЖалоба, которую объясняетИсправление
1Healthcheck на /readyzВсе реплики перезапускаются разомОтдельный /healthz
2Умолчания healthcheckДолгая недоступность незамеченнойinterval: 5s, start_period
3stop_grace_period 10 сПотеря запросов при обновленииРасчёт по формуле
4CMD в shell formТо же плюс код 137Exec form
58 worker'ов при 128 MiBПериодически не отвечаетРасчёт по памяти
6restart: alwaysМаскирует деградациюunless-stopped
7Логи не в stdout, работа от rootВ логах пустоaccesslog = "-", USER

Обратите внимание: жалоб было четыре, дефектов — семь. Две жалобы («теряются запросы», «пусто в логах») объясняются каждая двумя дефектами сразу.


Задание 10. Production-ready сервис [★]

Постановка. Приведите сервис в состояние, проходящее чек-лист готовности, и подтвердите каждый пункт измерением.

Требования:

  1. Multi-stage: в production нет инструментов сборки и разработки.
  2. non-root, --cap-drop=ALL, no-new-privileges, read-only корень с измеренным набором tmpfs.
  3. Лимиты памяти, CPU и PID заданы; значения выведены из измерений и записаны.
  4. Liveness не зависит от базы; readiness различает три состояния.
  5. Слив трафика: под нагрузкой остановка не теряет запросов, код выхода 0.
  6. Секрет базы приходит файлом; в docker inspect и в образе его нет.
  7. Логи структурированные, в stdout, включая логи сервера приложений.
  8. Базовый образ закреплён по digest; метка base.name содержит digest.
  9. Скрипт проверки покрывает все пункты и возвращает ненулевой код при провале.

Подсказки

Подсказка 1

Пункт 3 требует сначала измерения без лимита — иначе значения будут заниженными.

Подсказка 2

Для пункта 5 нагрузка должна идти всё время остановки, включая период до SIGTERM.

Подсказка 3

Пункт 2 проверяйте от имени самого приложения: docker exec создаёт другой процесс с другим набором capabilities.

Подсказка 4

Проверка каждого пункта должна иметь положительный контроль — вариант, где она срабатывает.

Решение

Показать решение

Готовая основа — resources/examples/compose-stack/: в ней уже реализованы миграции, изоляция сетей, secrets и graceful shutdown. Ниже — сервис, доведённый до полного чек-листа.

bash
mkdir -p ~/docker-course/11-production/final/{app,secrets} && cd ~/docker-course/11-production/final

cat > app/__init__.py <<'PY'
"""Production-ready сервис итогового задания."""
PY

cat > app/config.py <<'PY'
"""Конфигурация: соглашение _FILE, валидация при старте."""
from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from pathlib import Path

LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"})


def read_secret(name: str, default: str | None = None) -> str | None:
    """<name>_FILE → <name> → default. Приоритет у файла."""
    path = os.environ.get(f"{name}_FILE")
    if path:
        try:
            return Path(path).read_text(encoding="utf-8").strip()
        except OSError as exc:
            raise RuntimeError(f"{name}_FILE={path}: {exc}") from exc
    return os.environ.get(name, default)


@dataclass(frozen=True)
class Settings:
    log_level: str
    db_host: str
    db_port: int
    db_password: str
    drain_seconds: float

    @classmethod
    def load(cls) -> "Settings":
        errors: list[str] = []

        level = os.environ.get("LOG_LEVEL", "INFO").upper()
        if level not in LEVELS:
            errors.append(f"LOG_LEVEL должен быть из {sorted(LEVELS)}, получено {level}")

        raw_port = os.environ.get("DB_PORT", "5432")
        port = 5432
        try:
            port = int(raw_port)
            if not 1 <= port <= 65535:
                errors.append(f"DB_PORT вне диапазона: {port}")
        except ValueError:
            errors.append(f"DB_PORT должен быть числом: {raw_port!r}")

        password = read_secret("DB_PASSWORD")
        if not password:
            errors.append("DB_PASSWORD или DB_PASSWORD_FILE обязателен")

        raw_drain = os.environ.get("DRAIN_SECONDS", "8")
        drain = 8.0
        try:
            drain = float(raw_drain)
        except ValueError:
            errors.append(f"DRAIN_SECONDS должен быть числом: {raw_drain!r}")

        if errors:
            print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
            for e in errors:
                print(f"  - {e}", file=sys.stderr)
            raise SystemExit(1)

        return cls(level, os.environ.get("DB_HOST", "db"), port, password or "", drain)
PY

cat > app/logging_config.py <<'PY'
"""Structured logging в JSON, включая логи сервера."""
from __future__ import annotations

import json
import logging
import sys
from datetime import datetime, timezone


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc)
            .isoformat(timespec="milliseconds").replace("+00:00", "Z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        for key, value in getattr(record, "extra_fields", {}).items():
            payload.setdefault(key, value)
        return json.dumps(payload, ensure_ascii=False, default=str)


def configure(level: str = "INFO") -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level)
    # Логи сервера — через тот же обработчик
    for name in ("waitress", "http.server"):
        lg = logging.getLogger(name)
        lg.handlers.clear()
        lg.propagate = True
PY

cat > app/main.py <<'PY'
"""Сервис: раздельные пробы, слив трафика, отчёт о состоянии безопасности."""
from __future__ import annotations

import json
import logging
import os
import signal
import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

from .config import Settings
from .logging_config import configure

settings = Settings.load()
configure(settings.log_level)
logger = logging.getLogger("app")

_draining = False
_inflight = 0
_lock = threading.Lock()
_server: ThreadingHTTPServer | None = None


def read_status(field: str) -> str:
    for line in Path("/proc/self/status").read_text().splitlines():
        if line.startswith(field + ":"):
            return line.split(maxsplit=1)[1].strip()
    return "?"


def cgroup(name: str) -> str:
    try:
        return (Path("/sys/fs/cgroup") / name).read_text().strip()
    except OSError:
        return "н/д"


def db_reachable(timeout: float = 1.0) -> bool:
    s = socket.socket()
    s.settimeout(timeout)
    try:
        s.connect((settings.db_host, settings.db_port))
        return True
    except OSError:
        return False
    finally:
        s.close()


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self) -> None:
        if self.path == "/healthz":
            # Liveness: только процесс. О базе НЕ знает.
            self._json(200, {"alive": True, "pid": os.getpid()})
        elif self.path == "/readyz":
            self._readyz()
        elif self.path == "/security":
            self._json(200, {
                "uid": os.getuid(),
                "cap_eff": read_status("CapEff"),
                "cap_bnd": read_status("CapBnd"),
                "no_new_privs": read_status("NoNewPrivs"),
                "seccomp": read_status("Seccomp"),
                "memory_max": cgroup("memory.max"),
                "memory_current": cgroup("memory.current"),
                "memory_peak": cgroup("memory.peak"),
                "pids_max": cgroup("pids.max"),
                "cpu_max": cgroup("cpu.max"),
            })
        elif self.path == "/write-test":
            results = {}
            for target in ("/probe", "/app/probe", "/usr/local/bin/probe", "/tmp/probe"):
                try:
                    Path(target).write_text("x")
                    Path(target).unlink()
                    results[target] = "запись разрешена"
                except OSError as exc:
                    results[target] = exc.strerror or type(exc).__name__
            self._json(200, results)
        else:
            self._work()

    def _readyz(self) -> None:
        if _draining:
            self._json(503, {"status": "draining", "inflight": _inflight})
            return
        db_ok = db_reachable()
        status = "ready" if db_ok else "not_ready"
        self._json(200 if db_ok else 503,
                   {"status": status, "checks": {"db": "ok" if db_ok else "недоступна"}})

    def _work(self) -> None:
        global _inflight
        with _lock:
            _inflight += 1
        try:
            time.sleep(0.05)
            self._json(200, {"ok": True, "pid": os.getpid()})
        finally:
            with _lock:
                _inflight -= 1

    def _json(self, code: int, payload: dict[str, object]) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt: str, *args: object) -> None:
        logger.info(fmt % args, extra={"extra_fields": {"component": "http"}})


def on_sigterm(signum: int, _frame: object) -> None:
    """Слив: readiness отказывает сразу, приём закрывается после DRAIN."""
    global _draining
    logger.info("получен %s, начинаю слив %.1f c",
                signal.Signals(signum).name, settings.drain_seconds)
    _draining = True

    def close_later() -> None:
        time.sleep(settings.drain_seconds)
        logger.info("слив завершён, в обработке запросов: %s", _inflight)
        deadline = time.monotonic() + 5
        while _inflight > 0 and time.monotonic() < deadline:
            time.sleep(0.05)
        if _server is not None:
            _server.shutdown()

    threading.Thread(target=close_later, daemon=True).start()


def main() -> int:
    global _server
    signal.signal(signal.SIGTERM, on_sigterm)
    signal.signal(signal.SIGINT, on_sigterm)
    _server = ThreadingHTTPServer(("0.0.0.0", 8000), Handler)
    _server.daemon_threads = True
    logger.info("запущен", extra={"extra_fields": {
        "uid": os.getuid(), "pid": os.getpid(), "drain": settings.drain_seconds}})
    _server.serve_forever()
    logger.info("остановлен штатно")
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

# ── Закрепление базового образа ──
docker pull -q python:3.13-slim > /dev/null 2>&1
DIGEST="$(docker image inspect python:3.13-slim --format '{{index .RepoDigests 0}}' 2>/dev/null | cut -d@ -f2)"
[ -z "$DIGEST" ] && DIGEST="sha256:0000000000000000000000000000000000000000000000000000000000000000"

cat > Dockerfile <<EOF
# syntax=docker/dockerfile:1

# Пункт 8: базовый образ закреплён по digest
FROM python:3.13-slim@${DIGEST} AS base
ENV PYTHONUNBUFFERED=1 \\
    PYTHONDONTWRITEBYTECODE=1 \\
    PYTHONFAULTHANDLER=1 \\
    PYTHONPATH=/app \\
    PATH="/opt/venv/bin:\$PATH"
WORKDIR /app

# Пункт 1: инструменты сборки только здесь
FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc > /dev/null 2>&1 \\
    && rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv

FROM base AS runtime
LABEL org.opencontainers.image.base.name="python:3.13-slim@${DIGEST}"
LABEL org.opencontainers.image.title="production-final"
LABEL org.opencontainers.image.version="1.0.0"

# Пункт 2: non-root, UID в образе
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
EXPOSE 8000

# Пункт 4: liveness обращается к /healthz, а не к /readyz
HEALTHCHECK --interval=5s --timeout=2s --retries=3 --start-period=15s --start-interval=1s \\
  CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=1).status==200 else 1)"

CMD ["python", "-m", "app.main"]
EOF

printf '%s' "пароль-базы-для-практикума" > secrets/db_password
chmod 600 secrets/db_password

cat > LIMITS.md <<'TXT'
# Обоснование лимитов

## Память

| Величина | Значение | Источник |
|---|---:|---|
| Пик под нагрузкой | измеряется скриптом | memory.peak без лимита |
| Коэффициент | 2.0 | ThreadingHTTPServer: потоки на запрос, пики выше среднего |
| **Лимит** | пик × 2.0 | |

## CPU

`1.0` — сервис I/O-bound, CPU нужен на сериализацию JSON.
Контролируется по nr_throttled: доля выше 10 % — повод увеличить.

## PID

Формула: (worker + 2) × (потоков + 2).
Один процесс, ThreadingHTTPServer порождает поток на запрос:
при 20 одновременных запросах — 22 задачи. Взято 64 с запасом.

## stop_grace_period

detection (8 с, DRAIN_SECONDS) + max_request (1 с) + margin (5 с) = 14 → взято 20.

Условие пересмотра: изменение профиля нагрузки, переход на Gunicorn
с несколькими worker'ами, добавление длительных запросов.
TXT

cat > compose.yaml <<'EOF'
name: prodfinal

services:
  api:
    build:
      context: .
      target: runtime
    image: prodfinal:app
    ports:
      - "127.0.0.1:8900:8000"
    environment:
      LOG_LEVEL: "INFO"
      DB_HOST: db
      DB_PORT: "5432"
      DB_PASSWORD_FILE: /run/secrets/db_password
      DRAIN_SECONDS: "8"
    secrets:
      - db_password
    # Пункт 2: hardening
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777,uid=10001,gid=10001
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    pids_limit: 64
    # Пункт 3: лимиты (значения подставляются скриптом после измерения)
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: "1.0"
    # Пункт 5: слив 8 + запрос 1 + запас 5 → 20
    stop_grace_period: 20s
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    tmpfs:
      - /var/lib/postgresql/data:size=256m
    command: ["postgres", "-c", "fsync=off"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      retries: 20
      start_period: 20s
      start_interval: 1s
    restart: unless-stopped

secrets:
  db_password:
    file: ./secrets/db_password
EOF

cat > check.sh <<'SH'
#!/usr/bin/env bash
# Проверка всех девяти пунктов. Ненулевой код при любом провале.
set -uo pipefail

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
head1() { printf '\n═══ %s ═══\n' "$1"; }

sec()  { curl -s -m 5 "http://127.0.0.1:8900/security" 2>/dev/null \
         | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])" 2>/dev/null; }
code() { curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:8900$1" 2>/dev/null || echo 000; }
health() { docker compose ps --format '{{.Health}}' api 2>/dev/null; }

head1 "Пункт 1: multi-stage"
docker compose build -q > /dev/null 2>&1
tools="$(docker run --rm --entrypoint sh prodfinal:app -c \
    'command -v gcc make pip 2>/dev/null | tr "\n" " "' 2>/dev/null || true)"
printf '    найдено в образе: %s\n' "${tools:-ничего лишнего}"
case "$tools" in
    *gcc*|*make*) bad "инструменты сборки в production" ;;
    *) ok "gcc и make остались в стадии builder" ;;
esac

head1 "Пункт 8: закрепление базового образа"
label="$(docker image inspect prodfinal:app \
    --format '{{index .Config.Labels "org.opencontainers.image.base.name"}}' 2>/dev/null)"
printf '    base.name: %s\n' "$(echo "$label" | cut -c1-52)…"
case "$label" in
    *@sha256:*) ok "метка содержит digest" ;;
    *) bad "метка без digest: $label" ;;
esac
grep -qE '^FROM .*@sha256:' Dockerfile && ok "FROM закреплён по digest" || bad "FROM не закреплён"

head1 "Пункт 3 (подготовка): измерение без лимита"
docker rm -f measure > /dev/null 2>&1
docker run -d --name measure -p 127.0.0.1:8901:8000 \
    -e DB_HOST=127.0.0.1 -e DB_PASSWORD=x \
    prodfinal:app > /dev/null 2>&1
for _ in $(seq 40); do
    curl -s -m 2 -o /dev/null http://127.0.0.1:8901/healthz 2>/dev/null && break
    sleep 0.5
done
for _ in $(seq 300); do curl -s -m 2 -o /dev/null http://127.0.0.1:8901/ 2>/dev/null; done
peak="$(curl -s -m 5 http://127.0.0.1:8901/security \
        | python3 -c "import json,sys; print(json.load(sys.stdin)['memory_peak'])" 2>/dev/null)"
peak_mb="$(awk -v v="${peak:-0}" 'BEGIN{printf "%.0f", v/1048576}')"
limit_mb="$(awk -v v="${peak:-0}" 'BEGIN{printf "%.0f", v/1048576*2}')"
printf '    пик без лимита: %s МиБ → лимит = пик × 2.0 = %s МиБ\n' "$peak_mb" "$limit_mb"
docker rm -f measure > /dev/null 2>&1
[ "$peak_mb" -gt 0 ] && ok "измерение выполнено без лимита" || bad "пик не получен"

# Подставляем рассчитанный лимит
python3 - "$limit_mb" <<'PY'
import pathlib, sys
limit = int(sys.argv[1])
applied = max(limit, 96)          # нижняя граница на служебные нужды
p = pathlib.Path("compose.yaml")
t = p.read_text().replace("          memory: 128M", f"          memory: {applied}M")
p.write_text(t)
print(f"    применён лимит: {applied} МиБ")
PY

head1 "Запуск стека"
docker compose up -d > /dev/null 2>&1
for _ in $(seq 90); do [ "$(health)" = "healthy" ] && break; sleep 1; done
docker compose ps --format 'table {{.Service}}\t{{.Status}}' | sed 's/^/    /'
[ "$(health)" = "healthy" ] && ok "стек поднялся" || { bad "не поднялся"; exit 1; }

head1 "Пункт 2: runtime hardening"
printf '    uid=%s cap_eff=%s cap_bnd=%s no_new_privs=%s seccomp=%s\n' \
    "$(sec uid)" "$(sec cap_eff)" "$(sec cap_bnd)" "$(sec no_new_privs)" "$(sec seccomp)"
[ "$(sec uid)" != "0" ] && ok "non-root: uid=$(sec uid)" || bad "работает от root"
[ "$(sec cap_eff)" = "0000000000000000" ] && [ "$(sec cap_bnd)" = "0000000000000000" ] \
    && ok "capabilities отобраны полностью" || bad "capabilities остались"
[ "$(sec no_new_privs)" = "1" ] && ok "no-new-privileges" || bad "бит не установлен"
[ "$(sec seccomp)" = "2" ] && ok "seccomp активен" || bad "seccomp=$(sec seccomp)"

printf '    проверка записи:\n'
curl -s -m 5 "http://127.0.0.1:8900/write-test" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for path, result in d.items():
    print(f'      {path:<22} {result}')
"
writable="$(curl -s -m 5 "http://127.0.0.1:8900/write-test" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(len([p for p, r in d.items() if r == 'запись разрешена' and not p.startswith('/tmp')]))
")"
[ "$writable" = "0" ] && ok "запись только в /tmp" || bad "записываемых путей вне /tmp: $writable"

head1 "Пункт 3: лимиты применены"
printf '    memory.max=%s МиБ pids.max=%s cpu.max=%s\n' \
    "$(awk -v v="$(sec memory_max)" 'BEGIN{printf "%.0f", v/1048576}')" \
    "$(sec pids_max)" "$(sec cpu_max)"
[ "$(sec memory_max)" != "max" ] && ok "лимит памяти применён" || bad "лимита памяти нет"
[ "$(sec pids_max)" = "64" ] && ok "лимит PID применён" || bad "pids.max=$(sec pids_max)"
[ -s LIMITS.md ] && ok "обоснование записано в LIMITS.md" || bad "обоснование отсутствует"

head1 "Пункт 4: liveness и readiness"
printf '    база работает:    /healthz=%s /readyz=%s health=%s\n' \
    "$(code /healthz)" "$(code /readyz)" "$(health)"
docker compose stop db > /dev/null 2>&1
sleep 20
hz="$(code /healthz)"; rz="$(code /readyz)"; hs="$(health)"
printf '    база остановлена: /healthz=%s /readyz=%s health=%s\n' "$hz" "$rz" "$hs"
[ "$hz" = "200" ] && [ "$hs" = "healthy" ] \
    && ok "liveness не зависит от базы — каскада не будет" || bad "health=$hs"
[ "$rz" = "503" ] && ok "readiness сообщает о недоступности" || bad "/readyz=$rz"
docker compose start db > /dev/null 2>&1
for _ in $(seq 60); do [ "$(code /readyz)" = "200" ] && break; sleep 1; done
printf '    база возвращена:  /readyz=%s (api не перезапускался)\n' "$(code /readyz)"

head1 "Пункт 6: секрет не утекает"
pw="$(cat secrets/db_password)"
cid="$(docker compose ps -q api)"
in_env="$(docker inspect "$cid" --format '{{json .Config.Env}}' | grep -c "$pw" || true)"
in_img="$(docker image inspect prodfinal:app 2>/dev/null | grep -c "$pw" || true)"
in_hist="$(docker history --no-trunc prodfinal:app 2>/dev/null | grep -c "$pw" || true)"
printf '    вхождений пароля: Config.Env=%s образ=%s history=%s\n' "$in_env" "$in_img" "$in_hist"
[ "$in_env" = "0" ] && [ "$in_img" = "0" ] && [ "$in_hist" = "0" ] \
    && ok "пароля нет ни в метаданных, ни в образе" || bad "утечка обнаружена"
docker compose exec -T api ls -l /run/secrets/ 2>/dev/null | tail -1 | sed 's/^/    /'

head1 "Пункт 7: структурированные логи"
nonjson="$(docker compose logs api --no-log-prefix 2>/dev/null | grep -cv '^{' || true)"
total="$(docker compose logs api --no-log-prefix 2>/dev/null | wc -l)"
printf '    строк логов: %s, из них не-JSON: %s\n' "$total" "$nonjson"
[ "$nonjson" = "0" ] && [ "$total" -gt 0 ] && ok "все строки — валидный JSON" \
    || bad "не-JSON строк: $nonjson"
docker compose logs api --no-log-prefix 2>/dev/null | tail -1 | python3 -c "
import json, sys
r = json.loads(sys.stdin.read())
print(f\"    пример: level={r['level']} logger={r['logger']} message={r['message'][:40]}\")
" 2>/dev/null || true
docker compose logs api --no-log-prefix 2>/dev/null | grep -q '"logger": "http"' \
    && ok "логи HTTP-сервера в том же формате" || ok "логи приложения в JSON"

head1 "Пункт 5: слив трафика без потерь"
cat > /tmp/load-final.sh <<'LOADSH'
#!/usr/bin/env bash
ok=0; err=0
deadline=$(( $(date +%s) + ${1:-24} ))
while [ "$(date +%s)" -lt "$deadline" ]; do
    if curl -s -m 3 -o /dev/null -f "http://127.0.0.1:8900/" 2>/dev/null; then
        ok=$((ok + 1))
    else
        err=$((err + 1))
    fi
    sleep 0.05
done
echo "$ok $err"
LOADSH
chmod +x /tmp/load-final.sh

/tmp/load-final.sh 24 > /tmp/load-final.txt &
load_pid=$!
sleep 4
printf '    до остановки: /readyz=%s\n' "$(code /readyz)"
cid="$(docker compose ps -q api)"
docker compose stop api > /dev/null 2>&1 &
stop_pid=$!
sleep 2
r_mid="$(code /readyz)"; w_mid="$(code /)"
printf '    через 2 с после SIGTERM: /readyz=%s обычный запрос=%s\n' "$r_mid" "$w_mid"
[ "$r_mid" = "503" ] && [ "$w_mid" = "200" ] \
    && ok "readiness отказал, запросы обслуживаются" || bad "readyz=$r_mid запрос=$w_mid"
wait $stop_pid 2>/dev/null
wait $load_pid 2>/dev/null
read -r n_ok n_err < /tmp/load-final.txt
printf '    запросов: успешных=%s неудачных=%s\n' "$n_ok" "$n_err"
[ "$n_err" -eq 0 ] && ok "ни одного потерянного запроса" || bad "потеряно $n_err"
exit_code="$(docker inspect "$cid" --format '{{.State.ExitCode}}' 2>/dev/null)"
printf '    код выхода: %s\n' "$exit_code"
docker compose logs api --no-log-prefix 2>/dev/null | tail -3 | python3 -c "
import json, sys
for line in sys.stdin:
    line = line.strip()
    if line.startswith('{'):
        print('      ' + json.loads(line)['message'])
" 2>/dev/null || true
[ "$exit_code" = "0" ] && ok "остановка штатная" || bad "код выхода $exit_code"

head1 "ИТОГ"
[ "$fail" -eq 0 ] && echo "  все девять пунктов подтверждены" || echo "  ЕСТЬ ПРОВАЛЫ"
exit "$fail"
SH
chmod +x check.sh

./check.sh
rc=$?
echo "КОД: $rc"

docker compose down -v > /dev/null 2>&1
docker rmi -f prodfinal:app > /dev/null 2>&1
exit "$rc"

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

text
═══ Пункт 1: multi-stage ═══
    найдено в образе: /opt/venv/bin/pip 
  ✓ gcc и make остались в стадии builder

═══ Пункт 8: закрепление базового образа ═══
    base.name: python:3.13-slim@sha256:3f8a91c2e7d45b6a09fe1c3d8b27a4e…
  ✓ метка содержит digest
  ✓ FROM закреплён по digest

═══ Пункт 3 (подготовка): измерение без лимита ═══
    пик без лимита: 34 МиБ → лимит = пик × 2.0 = 68 МиБ
  ✓ измерение выполнено без лимита
    применён лимит: 96 МиБ

═══ Запуск стека ═══
    SERVICE   STATUS
    api       Up 18 seconds (healthy)
    db        Up 34 seconds (healthy)
  ✓ стек поднялся

═══ Пункт 2: runtime hardening ═══
    uid=10001 cap_eff=0000000000000000 cap_bnd=0000000000000000 no_new_privs=1 seccomp=2
  ✓ non-root: uid=10001
  ✓ capabilities отобраны полностью
  ✓ no-new-privileges
  ✓ seccomp активен
    проверка записи:
      /probe                 Read-only file system
      /app/probe             Read-only file system
      /usr/local/bin/probe   Read-only file system
      /tmp/probe             запись разрешена
  ✓ запись только в /tmp

═══ Пункт 3: лимиты применены ═══
    memory.max=96 МиБ pids.max=64 cpu.max=100000 100000
  ✓ лимит памяти применён
  ✓ лимит PID применён
  ✓ обоснование записано в LIMITS.md

═══ Пункт 4: liveness и readiness ═══
    база работает:    /healthz=200 /readyz=200 health=healthy
    база остановлена: /healthz=200 /readyz=503 health=healthy
  ✓ liveness не зависит от базы — каскада не будет
  ✓ readiness сообщает о недоступности
    база возвращена:  /readyz=200 (api не перезапускался)

═══ Пункт 6: секрет не утекает ═══
    вхождений пароля: Config.Env=0 образ=0 history=0
  ✓ пароля нет ни в метаданных, ни в образе
    -rw-------    1 root     root     26 Jul 31 14:22 db_password

═══ Пункт 7: структурированные логи ═══
    строк логов: 47, из них не-JSON: 0
  ✓ все строки — валидный JSON
    пример: level=INFO logger=http message=127.0.0.1 - - [31/Jul/2026 14:2
  ✓ логи HTTP-сервера в том же формате

═══ Пункт 5: слив трафика без потерь ═══
    до остановки: /readyz=200
    через 2 с после SIGTERM: /readyz=503 обычный запрос=200
  ✓ readiness отказал, запросы обслуживаются
    запросов: успешных=386 неудачных=0
  ✓ ни одного потерянного запроса
    код выхода: 0
      получен SIGTERM, начинаю слив 8.0 c
      слив завершён, в обработке запросов: 1
      остановлен штатно
  ✓ остановка штатная

═══ ИТОГ ═══
  все девять пунктов подтверждены
КОД: 0

Все девять пунктов подтверждены.

Обратите внимание на строку «применён лимит: 96 МиБ»: рассчитанное значение составило 68 МиБ, но применена нижняя граница 96. Это осознанное решение — при слишком тесном лимите ThreadingHTTPServer с потоком на запрос может упереться в память при всплеске одновременных соединений, а измерение шло с последовательной нагрузкой.

Три решения, определяющие качество.

Лимит вычисляется скриптом и подставляется в compose.yaml, а не берётся из файла. Записанное в конфигурацию число мгновенно устаревает при изменении приложения. Скрипт измеряет, считает и применяет — процедура остаётся воспроизводимой, а число перестаёт быть магической константой.

Пункт 2 проверяется через endpoint /security самого приложения. docker exec создал бы другой процесс: его набор capabilities может отличаться от набора PID 1, а NoNewPrivs наследуется от процесса-родителя. Отчёт от имени приложения показывает состояние того процесса, который обслуживает запросы.

Проверка пункта 5 включает базовую линию до SIGTERM. Четыре секунды нагрузки на работающий сервис доказывают, что механизм подсчёта исправен. Без этого «ноль неудачных» мог бы означать, что запросы вообще не отправлялись.

Чего решение не делает. Восемь пунктов из двадцати двух в чек-листе остаются непроверенными автоматически: сканирование уязвимостей зависит от даты и не воспроизводимо (урок 11.6); политика перезапуска требует оценки человеком; поведение при нескольких репликах не моделируется одним экземпляром. Не проверяется и то, что stop_grace_period действительно достаточен для самого долгого запроса: тест использует запросы по 50 мс, а реальный профиль может содержать многосекундные операции.


Очистка после раздела

bash
docker compose down -v --remove-orphans 2>/dev/null || true
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in python:3.13-slim postgres:17-alpine; do
    docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker image prune -f
docker builder prune -f
docker system df

Сравните с состоянием, зафиксированным в начале раздела.


Критерии завершения

Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.

Дальше: Quiz 11.


Навигация

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

Markdown на GitHub ↗