Раздел 11. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
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.
Требуется:
- Проверить минимум восемь пунктов автоматически.
- Показать образ, проваливающий проверки, и исправленный.
- Для каждого исправления назвать закрытый пункт.
- Один пункт сознательно не выполнить и записать обоснование с условием пересмотра.
Ожидаемый результат. Скрипт проверки, два образа и файл решений.
Проверка:
./readiness-check.sh <образ>; echo "код: $?"
Разбор — в уроке 11.1.
Задание 2. Правило «один процесс» [обяз.]
Постановка. Соберите три варианта запуска и покажите для каждого, видит ли Docker падение приложения:
- скрипт запускает приложение в фоне и живёт сам;
- скрипт завершается
exec; - Gunicorn с тремя worker'ами.
Для варианта 3 дополнительно покажите, что гибель worker'а не роняет container, а гибель master — роняет.
Ожидаемый результат. Таблица «статус container и код выхода» для трёх вариантов.
Проверка:
docker inspect <container> --format '{{.State.Status}} {{.State.ExitCode}}'
Разбор — в уроке 11.1.
Задание 3. Runtime hardening [обяз.]
Постановка. Проведите hardening приложения и подтвердите каждую меру:
- non-root, UID задан в образе;
--cap-drop=ALLбез единого--cap-add;no-new-privileges— показать наsetuid-программе;- read-only корень, список
tmpfsполучен черезdocker diff; --pids-limitс обоснованием значения;- seccomp не отключён.
Ожидаемый результат. Сравнительная таблица hardened и обычного варианта.
Проверка:
docker exec <c> sh -c 'grep -E "CapEff|NoNewPrivs|Seccomp:" /proc/self/status; id -u'
Разбор — в уроке 11.2.
Задание 4. Слив трафика без потерь [обяз.]
Постановка. Настройте остановку так, чтобы под непрерывной нагрузкой не потерялся ни один запрос:
- при
SIGTERMreadiness отказывает сразу; - приложение продолжает принимать запросы в течение периода слива;
- после слива дозавершает текущие и выходит с кодом
0; stop_grace_periodрассчитан по формуле, расчёт записан.
Сравните с наивным вариантом, закрывающим приём сразу.
Ожидаемый результат. Два измерения «успешных и неудачных запросов» с ненулевой разницей.
Проверка:
# нагрузка идёт всё время остановки, считаются коды ответов
docker inspect <c> --format '{{.State.ExitCode}}'
Разбор — в уроке 11.3.
Задание 5. Подбор лимитов по измерению [обяз.]
Постановка. Пройдите полный цикл:
- измерение без лимита под нагрузкой;
- вывод лимита по записанной формуле;
- проверка под лимитом:
oom_kill = 0; - контроль: заниженный лимит даёт код
137; - расчёт числа worker'ов по обоим ограничениям.
Ожидаемый результат. Файл с расчётом и подтверждения всех пяти пунктов.
Проверка:
docker exec <c> sh -c 'cat /sys/fs/cgroup/memory.peak; awk "/oom_kill /" /sys/fs/cgroup/memory.events'
Разбор — в уроке 11.4.
Задание 6. Секрет при сборке [доп.]
Постановка. Установите «приватный пакет» с использованием токена так, чтобы токен не попал в образ.
Требуется:
- использовать
RUN --mount=type=secret; - доказать отсутствие токена в
docker history,image inspectи файловой системе; - показать контрольный вариант с
--build-arg, где токен находится; - объяснить, почему наличие
--mountне гарантирует отсутствия утечки.
Ожидаемый результат. Два образа и поиск значения токена в обоих.
Проверка:
docker history --no-trunc <образ> | grep -c "<значение токена>"
Разбор — в уроке 11.5.
Задание 7. SBOM и поиск компонента [доп.]
Постановка. Получите SBOM образа и решите практическую задачу: «объявлена уязвимость в библиотеке X, есть ли она у нас».
Требуется:
- сгенерировать SBOM в формате SPDX или CycloneDX;
- посчитать компоненты по типам;
- найти конкретный компонент и его версию;
- показать компонент, которого в образе нет.
Ожидаемый результат. Скрипт поиска, дающий однозначный ответ за секунды.
Проверка:
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. Схема тегирования и откат [доп.]
Постановка. Организуйте теги так, чтобы откат был точным:
- неизменяемый тег по коммиту;
- неизменяемый релизный тег;
- изменяемый указатель
latest; - два последовательных выпуска;
- откат к первому выпуску и доказательство, что по
latestон невозможен.
Ожидаемый результат. Демонстрация того, что latest переехал, а неизменяемые теги — нет.
Проверка:
docker image inspect <образ>:v1.0.0 --format '{{.Id}}'
docker image inspect <образ>:latest --format '{{.Id}}'
Разбор — в уроке 11.6.
Задание 9. Диагностика: сервис «работает», но не обслуживает [диаг.]
Постановка. Дана конфигурация:
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:
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).
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
Ожидаемый вывод:
база работает: healthy
база остановлена: unhealthy
Исправление: отдельный /healthz, знающий только о процессе.
Дефект 2. Параметры healthcheck по умолчанию.
interval: 30s, retries: 3, timeout: 30s дают до двух минут на обнаружение отказа.
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'
Ожидаемый вывод:
interval=30s retries=3 timeout=30s
Исправление: interval: 5s, timeout: 2s, retries: 3, плюс start_period.
Дефект 3. stop_grace_period по умолчанию.
Десять секунд. При сливе трафика и запросах любой длительности этого не хватает — отсюда «при обновлении теряются запросы» (урок 11.3).
cd ~/docker-course/11-production/diag9
docker inspect "$(docker compose ps -q api)" --format ' StopTimeout: {{.HostConfig.StopTimeout}}'
Ожидаемый вывод:
StopTimeout: <no value>
Пустое значение означает умолчание — 10 секунд. Исправление: рассчитать по формуле detection + max_request + margin.
Дефект 4. CMD в shell form.
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}}'
Ожидаемый вывод:
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.
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
Ожидаемый вывод:
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.
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)"
Ожидаемый вывод:
UID процесса: 0
строк в docker logs: 0
Жалоба «в логах пусто» объясняется тем, что Gunicorn по умолчанию не пишет access-логи в stdout: нужны --access-logfile - и --error-logfile - (урок 6.9).
Работа от root — нарушение пункта 2 чек-листа (урок 11.1).
Исправленная конфигурация:
# 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"]
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
# 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
Сводка дефектов:
| № | Дефект | Жалоба, которую объясняет | Исправление |
|---|---|---|---|
| 1 | Healthcheck на /readyz | Все реплики перезапускаются разом | Отдельный /healthz |
| 2 | Умолчания healthcheck | Долгая недоступность незамеченной | interval: 5s, start_period |
| 3 | stop_grace_period 10 с | Потеря запросов при обновлении | Расчёт по формуле |
| 4 | CMD в shell form | То же плюс код 137 | Exec form |
| 5 | 8 worker'ов при 128 MiB | Периодически не отвечает | Расчёт по памяти |
| 6 | restart: always | Маскирует деградацию | unless-stopped |
| 7 | Логи не в stdout, работа от root | В логах пусто | accesslog = "-", USER |
Обратите внимание: жалоб было четыре, дефектов — семь. Две жалобы («теряются запросы», «пусто в логах») объясняются каждая двумя дефектами сразу.
Задание 10. Production-ready сервис [★]
Постановка. Приведите сервис в состояние, проходящее чек-лист готовности, и подтвердите каждый пункт измерением.
Требования:
- Multi-stage: в production нет инструментов сборки и разработки.
- non-root,
--cap-drop=ALL,no-new-privileges, read-only корень с измеренным наборомtmpfs. - Лимиты памяти, CPU и PID заданы; значения выведены из измерений и записаны.
- Liveness не зависит от базы; readiness различает три состояния.
- Слив трафика: под нагрузкой остановка не теряет запросов, код выхода
0. - Секрет базы приходит файлом; в
docker inspectи в образе его нет. - Логи структурированные, в stdout, включая логи сервера приложений.
- Базовый образ закреплён по digest; метка
base.nameсодержит digest. - Скрипт проверки покрывает все пункты и возвращает ненулевой код при провале.
Подсказки
Подсказка 1
Пункт 3 требует сначала измерения без лимита — иначе значения будут заниженными.
Подсказка 2
Для пункта 5 нагрузка должна идти всё время остановки, включая период до SIGTERM.
Подсказка 3
Пункт 2 проверяйте от имени самого приложения: docker exec создаёт другой процесс с другим набором capabilities.
Подсказка 4
Проверка каждого пункта должна иметь положительный контроль — вариант, где она срабатывает.
Решение
Показать решение
Готовая основа — resources/examples/compose-stack/: в ней уже реализованы миграции, изоляция сетей, secrets и graceful shutdown. Ниже — сервис, доведённый до полного чек-листа.
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"
Ожидаемый вывод:
═══ Пункт 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 мс, а реальный профиль может содержать многосекундные операции.
Очистка после раздела
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
Главное оглавление