Раздел 6. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
mkdir -p ~/docker-course/06-python && cd ~/docker-course/06-python
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim python:3.13-alpine redis:8-alpine; do docker pull -q "$img"; done
docker system df # зафиксируйте исходное состояние
Задание 1. Цена Alpine для Python [обяз.]
Постановка. Соберите один и тот же образ с зависимостью, имеющей C-расширение (например, pydantic), на python:3.13-slim и на python:3.13-alpine. Измерьте время установки и итоговый размер.
Объясните полученную разницу: почему образ, который «должен быть меньше», может оказаться больше и собираться в разы дольше.
Ожидаемый результат. Два измерения и объяснение через колёса manylinux и musllinux.
Проверка:
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'slim|alpine'
Разбор — в уроке 6.1.
Задание 2. Порядок слоёв и кэш зависимостей [обяз.]
Постановка. Соберите два варианта Dockerfile:
- вариант A —
COPY . .затемpip install -r requirements.txt; - вариант B —
COPY requirements.txt .,pip install, затемCOPY . ..
Измерьте время пересборки после правки одной строки в исходном коде.
Ожидаемый результат. Вариант B пересобирается за секунды, вариант A переустанавливает все пакеты.
Проверка:
# после правки исходника
time docker build -q -f Dockerfile.a -t la . > /dev/null
time docker build -q -f Dockerfile.b -t lb . > /dev/null
Разбор — в уроке 6.2 и уроке 5.5.
Задание 3. Пропавшие логи [обяз.]
Постановка. Напишите скрипт, печатающий строку раз в секунду в течение минуты. Запустите его в container без PYTHONUNBUFFERED и с ним. Покажите разницу в docker logs в первые секунды.
Затем убейте container через docker kill в обоих вариантах и сравните, сколько строк дошло до docker logs.
Ожидаемый результат. Без переменной вывод появляется блоками по 4–8 KB и теряется при SIGKILL; с переменной — построчно и без потерь.
Проверка:
docker logs <container> | wc -l
Разбор — в уроке 6.4 и уроке 6.6.
Задание 4. Таблица форм CMD [обяз.]
Постановка. Соберите четыре container'а с одним и тем же Python-приложением, различающиеся только формой запуска:
CMD python app.py(shell form);CMD ["python", "app.py"](exec form);- shell form плюс
docker run --init; - shell form с
exec python app.py.
Для каждого измерьте время docker stop и код выхода. Заполните таблицу и объясните каждую строку.
Ожидаемый результат. Варианты 2, 3 и 4 останавливаются быстро с кодом 0; вариант 1 — за 10 секунд с кодом 137.
Проверка:
time docker stop <c>; docker inspect <c> --format '{{.State.ExitCode}}'
docker exec <c> ps -o pid,args # кто PID 1
Разбор — в уроке 6.5 и уроке 5.4.
Задание 5. Non-root и запись в volume [обяз.]
Постановка. Соберите образ с USER 10001, приложение пишет в /data. Примонтируйте туда named volume и bind mount с хоста. В одном случае запись пройдёт, в другом — нет.
Объясните разницу и почините bind mount без chmod 777 и без запуска от root.
Ожидаемый результат. Запись работает в обоих случаях; проверка id -u внутри container'а возвращает 10001.
Проверка:
docker run --rm -v "$PWD/data:/data" <образ> sh -c 'touch /data/probe && echo ok'
ls -ln data/
Разбор — в уроке 6.7.
Задание 6. Multi-stage с виртуальным окружением [доп.]
Постановка. Переведите однослойный Dockerfile на multi-stage: сборочная стадия ставит зависимости в /opt/venv, финальная копирует только его. Зависимость должна требовать компиляции (например, установка из sdist).
Измерьте размеры до и после, покажите отсутствие компилятора в финальном образе.
Ожидаемый результат. Сокращение размера минимум вдвое; gcc в финальном образе отсутствует.
Проверка:
docker run --rm <образ> sh -c 'command -v gcc || echo "компилятора нет"'
Разбор — в уроке 6.3 и уроке 5.7.
Задание 7. Структурированные логи [доп.]
Постановка. Настройте вывод логов приложения в JSON: одна строка — один объект с полями ts, level, logger, message. Логи используемого сервера приложений (Gunicorn или Uvicorn) должны идти через тот же обработчик.
Дополнительно: исключение должно попадать в поле exception одной строкой, а не разбиваться на несколько записей.
Ожидаемый результат. docker logs выдаёт валидный JSON построчно, включая строки сервера.
Проверка:
docker logs <c> 2>&1 | python3 -c '
import json, sys
bad = [l for l in sys.stdin if l.strip() and not l.startswith("{")]
print("не-JSON строк:", len(bad))
'
Разбор — в уроке 6.6.
Задание 8. Диагностика: порт открыт, ответа нет [диаг.]
Постановка. Дан Dockerfile:
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir flask==3.1.3 gunicorn==26.0.0
COPY app.py .
EXPOSE 8000
CMD ["gunicorn", "-b", "127.0.0.1:8000", "app:app"]
Запуск с -p 8000:8000 проходит, container в статусе running, в логах ошибок нет. Но curl localhost:8000 возвращает Empty reply или Connection reset.
Найдите причину, докажите её измерением и исправьте.
Ожидаемый результат. Объяснение, доказательство и рабочий вариант.
Разбор
Причина. Gunicorn слушает 127.0.0.1 — loopback внутри network namespace container'а. Это отдельный интерфейс, недоступный снаружи. Публикация порта пробрасывает трафик на eth0 container'а, где никто не слушает.
Доказательство:
docker exec <c> sh -c 'apt-get update -qq && apt-get install -y -qq iproute2 curl > /dev/null'
echo "═══ где слушает процесс ═══"
docker exec <c> ss -tlnp | grep 8000
echo "═══ изнутри через loopback ═══"
docker exec <c> curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:8000/
echo "═══ изнутри через собственный IP ═══"
ip="$(docker inspect <c> --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')"
docker exec <c> curl -s -m 3 -o /dev/null -w 'HTTP %{http_code}\n' "http://$ip:8000/" \
|| echo "недоступно — вот и подтверждение"
Ожидаемый вывод:
═══ где слушает процесс ═══
LISTEN 0 2048 127.0.0.1:8000 0.0.0.0:* users:(("gunicorn",pid=1,fd=5))
═══ изнутри через loopback ═══
HTTP 200
═══ изнутри через собственный IP ═══
недоступно — вот и подтверждение
Строка 127.0.0.1:8000 вместо 0.0.0.0:8000 — прямая улика. Приложение отвечает через loopback и не отвечает через собственный сетевой интерфейс, куда и приходит проброшенный трафик.
Исправление:
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
Почему это не дыра в безопасности. Привязка к 0.0.0.0 внутри container'а не делает сервис доступным всему миру: снаружи он виден только через явно опубликованные порты. Ограничивать доступ следует на уровне публикации:
docker run -p 127.0.0.1:8000:8000 ... # только с хоста
Как узнать быстрее. Симптом «container работает, логи чистые, ответа нет» почти всегда означает одно из двух: сервис слушает loopback либо слушает не тот порт. Обе версии проверяются одной командой ss -tlnp внутри container'а.
Разбор темы — в уроке 6.9 и разделе 08.
Задание 9. Диагностика: ModuleNotFoundError [диаг.]
Постановка. Приложение работает на хосте (python -m app.main), но в container'е падает:
ModuleNotFoundError: No module named 'app'
Известно: COPY . . присутствует, WORKDIR /app задан, зависимости установлены успешно.
Найдите все возможные причины (их несколько), определите, какая действует в вашем случае, и исправьте.
Ожидаемый результат. Список причин, способ различить их и рабочее исправление.
Разбор
Четыре причины, дающие один и тот же текст ошибки.
| Причина | Как проверить |
|---|---|
Каталог исключён в .dockerignore | docker run --rm <образ> ls -la /app |
Отсутствует __init__.py при запуске через -m | docker run --rm <образ> find /app -name "__init__.py" |
Пакет установлен, но src-layout не учтён | docker run --rm <образ> python -c "import sys; print(sys.path)" |
WORKDIR задан после COPY | docker history <образ> — порядок инструкций |
Порядок диагностики — от общего к частному:
echo "═══ 1. что вообще попало в образ ═══"
docker run --rm <образ> find /app -maxdepth 2 -type f -name "*.py" | head -20
echo "═══ 2. видит ли Python каталог ═══"
docker run --rm <образ> python -c "import sys; [print(' ', p) for p in sys.path]"
echo "═══ 3. есть ли __init__.py ═══"
docker run --rm <образ> sh -c 'ls -la /app/app/__init__.py 2>&1'
echo "═══ 4. что говорит сам импорт ═══"
docker run --rm <образ> python -c "import app" 2>&1 | tail -2
Ожидаемый вывод при самой частой причине:
═══ 1. что вообще попало в образ ═══
/app/requirements.txt
═══ 2. видит ли Python каталог ═══
/app
/usr/local/lib/python313.zip
...
═══ 3. есть ли __init__.py ═══
ls: cannot access '/app/app/__init__.py': No such file or directory
═══ 4. что говорит сам импорт ═══
ModuleNotFoundError: No module named 'app'
Первая команда всё объясняет: в образе нет ни одного файла приложения. Значит, каталог исключён из контекста сборки.
Проверка .dockerignore:
grep -nE '^\s*(app|src|\*|\.)' .dockerignore
Классическая ошибка — строка * с последующими исключениями !..., где нужный каталог забыли вернуть. Вторая по частоте — . или слишком широкий шаблон вроде *.py[cod], записанный как *.py*.
Если файлы на месте, но импорт не работает — причина в sys.path. Для src-layout нужен либо установленный пакет:
COPY pyproject.toml .
COPY src/ ./src/
RUN pip install .
либо явное указание пути:
ENV PYTHONPATH=/app/src
Первый вариант предпочтительнее: он проверяет, что пакет вообще устанавливается, и работает одинаково внутри и снаружи container'а.
Почему на хосте работает. При запуске из каталога проекта Python добавляет текущий каталог в sys.path, а установленный в режиме pip install -e . пакет виден отовсюду. В container'е ни того, ни другого может не быть.
Задание 10. Диагностика: OOM под нагрузкой [диаг.]
Постановка. Сервис на Gunicorn развёрнут с --memory 512m. Число worker'ов рассчитано формулой из документации:
workers = 2 * os.cpu_count() + 1
Под нагрузкой container перезапускается, docker logs показывает Worker was sent SIGKILL! Perhaps out of memory?.
Определите причину, подтвердите её тремя независимыми измерениями и предложите корректный расчёт.
Ожидаемый результат. Диагноз, три подтверждения, исправленная конфигурация.
Разбор
Диагноз. os.cpu_count() возвращает число CPU хоста, а не квоту container'а. На 16-ядерной машине формула даёт 33 worker'а. При 512 MB это гарантированный OOM.
Подтверждение 1 — что видит Python:
docker exec <c> python -c "
import os
from pathlib import Path
print('os.cpu_count():', os.cpu_count())
print('cpu.max: ', Path('/sys/fs/cgroup/cpu.max').read_text().strip())
print('формула даёт: ', 2 * os.cpu_count() + 1, 'worker(ов)')
"
Ожидаемый вывод:
os.cpu_count(): 16
cpu.max: 150000 100000
формула даёт: 33 worker(ов)
Расхождение налицо: 33 worker'а при квоте 1.5 CPU.
Подтверждение 2 — сколько процессов запущено на самом деле:
docker exec <c> sh -c 'ps -o pid,rss,args | grep "[g]unicorn" | wc -l'
docker exec <c> sh -c 'ps -o rss= | awk "{s+=\$1} END {print s/1024 \" MiB суммарно\"}"'
Ожидаемый вывод:
34
689 MiB суммарно
689 MiB при лимите 512 — превышение подтверждено арифметически.
Подтверждение 3 — счётчик OOM в cgroup:
docker exec <c> grep oom /sys/fs/cgroup/memory.events
docker inspect <c> --format 'OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}'
Ожидаемый вывод:
oom oom_kill 7
OOMKilled=false Exit=0
Ключевая деталь: oom_kill 7 при OOMKilled=false. OOM killer убивал worker'ов, а не master — поэтому container не помечен как убитый по памяти. Полагаясь только на docker inspect, проблему пропустили бы (урок 6.13).
Корректный расчёт. Ограничение идёт по двум ресурсам сразу — берётся меньшее:
по CPU: 2 × 1.5 + 1 = 4 worker'а
по памяти: 512 MiB × 0.7 / 60 MiB ≈ 5 worker'ов
итог: 4
Исправление — задать число явно, а не вычислять:
services:
api:
environment:
WEB_CONCURRENCY: "4"
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
Проверка исправления:
sleep 60 # под нагрузкой
docker exec <c> grep oom_kill /sys/fs/cgroup/memory.events
Ожидается oom_kill 0 — счётчик не растёт.
Почему явное число лучше расчёта. Даже правильный расчёт по cgroup скрывает важное число в коде. Значение в конфигурации видно рядом с лимитами памяти и CPU, меняется без пересборки и одинаково понимается всеми, кто читает Compose-файл.
Разбор — в уроке 6.11 и уроке 6.13.
Задание 11. Диагностика: тесты «проходят», но не выполняются [диаг.]
Постановка. В Dockerfile есть стадия с RUN pytest. CI собирает образ командой docker build -t app . и сообщает об успехе. При этом в коде заведомо сломанный тест.
Объясните, почему сборка проходит, и предложите два способа исправления с оценкой каждого.
Ожидаемый результат. Объяснение, доказательство и рекомендация.
Разбор
Причина. BuildKit собирает только стадии, достижимые из цели сборки. Если финальная стадия не зависит от стадии test, последняя не попадает в граф и не выполняется.
Доказательство:
echo "═══ обычная сборка ═══"
docker build -t app . > /dev/null 2>&1
echo " код: $?"
echo "═══ явно стадия test ═══"
docker build --target test -t app:test . > /dev/null 2>&1
echo " код: $?"
Ожидаемый вывод:
═══ обычная сборка ═══
код: 0
═══ явно стадия test ═══
код: 1
Один и тот же Dockerfile, один и тот же код — разные результаты. Второй показывает правду.
Ещё нагляднее — сравнить план сборки:
docker build --progress plain -t app . 2>&1 | grep -c "RUN pytest"
docker build --progress plain --target test -t app:test . 2>&1 | grep -c "RUN pytest"
Ожидаемый вывод:
0
1
Способ 1 — отдельный шаг (рекомендуется):
docker build --target test -t app:test . # падение здесь останавливает CI
docker build --target runtime -t app . # стадии переиспользуются из кэша
Плюсы: явно, работает везде, позволяет собрать образ без тестов при необходимости. Минус: требует контроля над командой сборки.
Способ 2 — искусственная зависимость:
FROM builder AS test
COPY tests/ ./tests/
RUN pytest -q && touch /app/.tests-passed
FROM base AS runtime
COPY --from=test /app/.tests-passed /app/.tests-passed
Плюс: тесты невозможно обойти. Минусы: production-образ нельзя собрать без прогона тестов даже когда это нужно; в образе остаётся бессмысленный файл.
Рекомендация — способ 1. Способ 2 применяют, когда команда сборки задаётся чужой системой и повлиять на неё нельзя.
Дополнительная ловушка. Даже с --target test тесты могут не выполниться повторно: слой RUN pytest кэшируется. Перед релизом:
docker build --target test --no-cache-filter test -t app:test .
Разбор — в уроке 6.12.
Задание 12. Полная контейнеризация сервиса [★]
Постановка. Контейнеризируйте FastAPI-сервис, удовлетворяющий одновременно всем требованиям раздела:
- Multi-stage: сборочная стадия с зависимостями, стадия тестов, финальная без того и другого.
- Запуск через
fastapi run; приложение — PID 1. USER 10001, запись в volume работает.PYTHONUNBUFFERED=1, логи в JSON, включая логи сервера.- Конфигурация из окружения с валидацией при старте; все ошибки сообщаются сразу.
lifespanзакрывает ресурсы при остановке.- Раздельные
/healthzи/readyz;HEALTHCHECKиспользует liveness. docker stopдозавершает активный запрос, код выхода0.- Тесты выполняются в стадии сборки; в финальном образе
pytestотсутствует. - Лимиты
--memoryи--cpusзаданы по измерению;oom_killостаётся нулевым под нагрузкой.
Каждое требование подтвердите командой.
Ожидаемый результат. Образ, скрипт проверки всех десяти требований и объяснение выбранных лимитов.
Подсказки
Подсказка 1
Требования 3 и 9 конфликтуют с наивным COPY . .: тесты нужны в одной стадии и не нужны в другой. Копируйте каталоги по отдельности.
Подсказка 2
Требование 8 проверяется запросом, идущим в момент docker stop. Понадобится --timeout больше длительности запроса.
Подсказка 3
Требование 10 начинается с запуска без лимита: сначала измерьте пик, потом задавайте.
Подсказка 4
Скрипт проверки должен возвращать ненулевой код при провале — иначе он бесполезен в CI.
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p ~/docker-course/06-python/final/app ~/docker-course/06-python/final/tests
cd ~/docker-course/06-python/final
cat > app/__init__.py <<'PY'
"""Сервис итогового задания раздела 6."""
PY
cat > app/settings.py <<'PY'
"""Требование 5: конфигурация с валидацией при старте."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"})
@dataclass(frozen=True)
class Settings:
app_name: str = "final"
log_level: str = "INFO"
data_dir: str = "/data"
db_fail_after: float = 0.0
@classmethod
def from_env(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 = os.environ.get("DB_FAIL_AFTER", "0")
try:
db_fail = float(raw)
except ValueError:
errors.append(f"DB_FAIL_AFTER должен быть числом, получено: {raw!r}")
db_fail = 0.0
data_dir = os.environ.get("DATA_DIR", cls.data_dir)
# Все ошибки сразу, а не первая: иначе исправление потребует
# нескольких перезапусков
if errors:
print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
raise SystemExit(1)
return cls(
app_name=os.environ.get("APP_NAME", cls.app_name),
log_level=level,
data_dir=data_dir,
db_fail_after=db_fail,
)
PY
cat > app/logging_config.py <<'PY'
"""Требование 4: JSON-логи, включая логи Uvicorn."""
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 ("uvicorn", "uvicorn.access", "uvicorn.error"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
PY
cat > app/main.py <<'PY'
"""Требования 2, 4, 6, 7, 8."""
from __future__ import annotations
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Response
from .logging_config import configure
from .settings import Settings
settings = Settings.from_env()
configure(settings.log_level)
logger = logging.getLogger("app")
_started = time.monotonic()
_ready = False
class Store:
"""Ресурс, требующий закрытия (требование 6)."""
def __init__(self, path: Path) -> None:
self.path = path
self.closed = False
def available(self) -> bool:
if settings.db_fail_after and (time.monotonic() - _started) > settings.db_fail_after:
return False
return not self.closed
def write(self, line: str) -> None:
# Требование 3: запись в volume от непривилегированного пользователя
with (self.path / "records.log").open("a", encoding="utf-8") as f:
f.write(line + "\n")
async def close(self) -> None:
logger.info("закрываю хранилище")
await asyncio.sleep(0.2)
self.closed = True
@asynccontextmanager
async def lifespan(app: FastAPI):
global _ready
data_dir = Path(settings.data_dir)
data_dir.mkdir(parents=True, exist_ok=True)
app.state.store = Store(data_dir)
_ready = True
logger.info("startup завершён", extra={"extra_fields": {"pid": os.getpid()}})
yield
_ready = False
logger.info("shutdown начат")
await app.state.store.close()
logger.info("shutdown завершён")
app = FastAPI(title=settings.app_name, lifespan=lifespan)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
"""Liveness: только процесс, о хранилище не знает (требование 7)."""
return {"status": "ok"}
@app.get("/readyz")
async def readyz(response: Response) -> dict[str, str]:
"""Readiness: процесс и зависимости."""
if not _ready:
response.status_code = 503
return {"status": "starting"}
if not app.state.store.available():
response.status_code = 503
return {"status": "storage unavailable"}
return {"status": "ready"}
@app.get("/")
async def root() -> dict[str, object]:
return {"service": settings.app_name, "pid": os.getpid()}
@app.post("/records")
async def write_record(response: Response) -> dict[str, str]:
if not app.state.store.available():
response.status_code = 503
return {"error": "storage unavailable"}
app.state.store.write(f"{time.time():.3f}")
return {"status": "written"}
@app.get("/slow")
async def slow(seconds: float = 4.0) -> dict[str, float]:
await asyncio.sleep(min(seconds, 20.0))
return {"slept": seconds}
PY
cat > tests/test_app.py <<'PY'
"""Требование 9: тесты выполняются в стадии сборки."""
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client(tmp_path, monkeypatch):
# TestClient как контекстный менеджер — иначе lifespan не выполнится
with TestClient(app) as c:
yield c
def test_root(client):
assert client.get("/").status_code == 200
def test_healthz(client):
assert client.get("/healthz").json() == {"status": "ok"}
def test_readyz_after_startup(client):
assert client.get("/readyz").json()["status"] == "ready"
def test_healthz_independent_of_storage(client, monkeypatch):
"""Ключевой тест: liveness НЕ зависит от хранилища."""
monkeypatch.setattr(app.state.store, "closed", True)
assert client.get("/healthz").status_code == 200
def test_readyz_depends_on_storage(client, monkeypatch):
monkeypatch.setattr(app.state.store, "closed", True)
assert client.get("/readyz").status_code == 503
def test_write_record(client):
assert client.post("/records").json() == {"status": "written"}
PY
cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF
cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
httpx==0.28.1
EOF
cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
data
reports
Dockerfile
.dockerignore
check.sh
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
# ── Требование 1: общая база ──
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONFAULTHANDLER=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
# ── Требование 9: тесты в сборке, в финальный образ не попадают ──
FROM builder AS test
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
COPY app/ ./app/
COPY tests/ ./tests/
RUN pytest -q tests/
FROM base AS runtime
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
# Требование 3: каталог данных принадлежит пользователю приложения
RUN mkdir -p /data && chown 10001:10001 /data
VOLUME ["/data"]
USER 10001:10001
EXPOSE 8000
# Требование 7: HEALTHCHECK использует liveness, не readiness
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --start-interval=2s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status==200 else 1)"
# Требование 2: exec form, приложение — PID 1
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF
cat > check.sh <<'SH'
#!/usr/bin/env bash
# Проверка всех десяти требований. Ненулевой код при любом провале.
set -uo pipefail
IMAGE=final
C=final-check
PORT=8099
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
eq() { [ "$2" = "$3" ] && ok "$1: $2" || bad "$1: получено '$2', ожидалось '$3'"; }
cleanup() { docker rm -f "$C" > /dev/null 2>&1 || true; }
trap cleanup EXIT
printf '\n═══ 9. Тесты в стадии сборки ═══\n'
if docker build --target test -q -t "$IMAGE:test" . > /dev/null; then
ok "тесты прошли"
else
bad "тесты упали"; exit 1
fi
docker build --target runtime -q -t "$IMAGE" . > /dev/null
printf '\n═══ 1, 9. Финальный образ без тестового обвеса ═══\n'
docker run --rm "$IMAGE" sh -c 'command -v pytest' > /dev/null 2>&1 \
&& bad "pytest присутствует" || ok "pytest отсутствует"
docker run --rm "$IMAGE" sh -c 'test -d /app/tests' > /dev/null 2>&1 \
&& bad "tests/ в образе" || ok "tests/ отсутствует"
mkdir -p ./data
docker run -d --name "$C" -p "$PORT:8000" \
-v "$PWD/data:/data" --memory 384m --memory-swap 384m --cpus 1.0 \
"$IMAGE" > /dev/null
sleep 10
printf '\n═══ 2. PID 1 ═══\n'
eq "PID 1" "$(docker exec "$C" ps -o comm= -p 1 | tr -d ' ')" "python"
printf '\n═══ 3. Пользователь и запись в volume ═══\n'
eq "UID" "$(docker exec "$C" id -u)" "10001"
eq "запись в /data" "$(curl -s -X POST "localhost:$PORT/records" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')" "written"
[ -s ./data/records.log ] && ok "файл на хосте создан" || bad "файла на хосте нет"
printf '\n═══ 4. JSON-логи, включая Uvicorn ═══\n'
nonjson="$(docker logs "$C" 2>&1 | grep -cv '^{' || true)"
eq "не-JSON строк" "$nonjson" "0"
docker logs "$C" 2>&1 | grep -q '"logger": "uvicorn' \
&& ok "логи Uvicorn в JSON" || bad "логи Uvicorn не в JSON"
printf '\n═══ 7. Раздельные пробы ═══\n'
eq "/healthz" "$(curl -s -o /dev/null -w '%{http_code}' "localhost:$PORT/healthz")" "200"
eq "/readyz" "$(curl -s -o /dev/null -w '%{http_code}' "localhost:$PORT/readyz")" "200"
eq "healthcheck" "$(docker inspect "$C" --format '{{.State.Health.Status}}')" "healthy"
printf '\n═══ 10. Лимиты: OOM не сработал ═══\n'
for _ in $(seq 300); do curl -s -o /dev/null "localhost:$PORT/"; done
eq "oom_kill" "$(docker exec "$C" awk '/oom_kill /{print $2}' /sys/fs/cgroup/memory.events)" "0"
printf ' пик памяти: %s MiB при лимите 384\n' \
"$(docker exec "$C" awk '{printf "%.0f", $1/1048576}' /sys/fs/cgroup/memory.peak 2>/dev/null || echo '?')"
thr="$(docker exec "$C" awk '/nr_throttled/{print $2}' /sys/fs/cgroup/cpu.stat)"
per="$(docker exec "$C" awk '/nr_periods/{print $2}' /sys/fs/cgroup/cpu.stat)"
printf ' throttling: %s из %s периодов\n' "$thr" "$per"
printf '\n═══ 6, 8. Graceful shutdown с активным запросом ═══\n'
curl -s -m 25 "localhost:$PORT/slow?seconds=4" > /tmp/final-slow.txt &
cpid=$!
sleep 1
docker stop --timeout 25 "$C" > /dev/null
wait $cpid 2>/dev/null
eq "код выхода" "$(docker inspect "$C" --format '{{.State.ExitCode}}')" "0"
grep -q "slept" /tmp/final-slow.txt && ok "запрос дозавершён" || bad "запрос оборван"
docker logs "$C" 2>&1 | grep -q "закрываю хранилище" \
&& ok "ресурсы закрыты через lifespan" || bad "lifespan shutdown не отработал"
rm -f /tmp/final-slow.txt
printf '\n═══ 5. Валидация конфигурации ═══\n'
out="$(docker run --rm -e LOG_LEVEL=НЕТ -e DB_FAIL_AFTER=abc "$IMAGE" 2>&1)"
echo "$out" | grep -q "LOG_LEVEL" && echo "$out" | grep -q "DB_FAIL_AFTER" \
&& ok "обе ошибки сообщены сразу" || bad "сообщена не вся конфигурация"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЕННЫЕ ПРОВЕРКИ"
exit "$fail"
SH
chmod +x check.sh
./check.sh
echo "КОД: $?"
Ожидаемый вывод:
═══ 9. Тесты в стадии сборки ═══
✓ тесты прошли
═══ 1, 9. Финальный образ без тестового обвеса ═══
✓ pytest отсутствует
✓ tests/ отсутствует
═══ 2. PID 1 ═══
✓ PID 1: python
═══ 3. Пользователь и запись в volume ═══
✓ UID: 10001
✓ запись в /data: written
✓ файл на хосте создан
═══ 4. JSON-логи, включая Uvicorn ═══
✓ не-JSON строк: 0
✓ логи Uvicorn в JSON
═══ 7. Раздельные пробы ═══
✓ /healthz: 200
✓ /readyz: 200
✓ healthcheck: healthy
═══ 10. Лимиты: OOM не сработал ═══
✓ oom_kill: 0
пик памяти: 118 MiB при лимите 384
throttling: 12 из 640 периодов
═══ 6, 8. Graceful shutdown с активным запросом ═══
✓ код выхода: 0
✓ запрос дозавершён
✓ ресурсы закрыты через lifespan
═══ 5. Валидация конфигурации ═══
✓ обе ошибки сообщены сразу
═══ ИТОГ ═══
все требования выполнены
КОД: 0
Объяснение выбранных лимитов. Пик составил 118 MiB. Лимит 384 MiB — это пик × 3, а не × 1.3, потому что здесь один процесс, и запас оставлен на рост при добавлении worker'ов. Throttling — 12 периодов из 640 (менее 2 %), что находится в пределах нормы для --cpus 1.0.
Порядок был именно таким, как предписывает урок 6.13: сначала запуск без лимита и измерение, потом назначение. Обратный порядок — назначить лимит и надеяться — даёт либо OOM, либо бессмысленно большое число.
Три решения, определяющие качество.
chown каталога /data в стадии сборки, а не при старте. Изменение владельца при каждом запуске потребовало бы root внутри container'а — то есть отказа от требования 3. В образе это делается один раз, до USER.
Скрипт использует trap cleanup EXIT. Без него провалившаяся проверка оставила бы работающий container, и следующий запуск упал бы на конфликте имён — проверка стала бы недиагностируемой.
Проверка требования 4 ищет "logger": "uvicorn, а не просто валидный JSON. Собственные логи приложения перевести в JSON легко; логи сервера — та часть, которую забывают. Проверка нацелена именно на неё.
Чего решение не делает. Один процесс без --workers: для Compose с несколькими репликами это правильно, для одиночного docker run под нагрузкой понадобился бы расчёт из урока 6.11. Нет и integration-тестов — у сервиса нет внешних зависимостей; при появлении базы они потребовали бы отдельного сервиса Compose (урок 6.12).
Очистка после раздела
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in python:3.13-slim python:3.13-alpine redis:8-alpine; do
docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker image prune -f
docker builder prune -f
docker volume prune -f
docker system df
Сравните с состоянием, зафиксированным в начале раздела.
Критерии завершения
Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.
Дальше: Quiz 06.
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий раздел → Storage: volumes и данные
Главное оглавление