Контейнеризация Python: slim и multi-stage сборки с uv, слои под кеш и сигналы, доходящие до PID 1
python:3.x-slim вместо alpine (musl ломает manylinux-колёса); multi-stage: uv sync собирает venv в билдере, копия уходит в slim — ~1 ГБ против 80 МБ. COPY lock-файла раньше кода держит зависимости в кеше. PYTHONUNBUFFERED=1, иначе логи молчат; exec-форма CMD, иначе PID 1 — шелл.
Месяцами каждый деплой «случайно» ронял горсть запросов, и все винили балансировщик. Графики сводили с ума: всплеск 502-х, ровно в момент раскатки, никогда не воспроизводится в staging. Настоящий баг был длиной в одну строку и возрастом в три года: CMD gunicorn app.main:app — shell-форма. Docker оборачивает эту строку в /bin/sh -c, так что PID 1 внутри контейнера был шеллом, а gunicorn — его потомком. Когда Kubernetes начинал раскатку, он посылал SIGTERM в PID 1 — а sh не пересылает сигналы детям. Gunicorn не слышал ничего, продолжал обслуживать, и через десять секунд ядро доставляло SIGKILL всей группе процессов: воркеры умирали посреди запроса, соединения рвались, 502-е сыпались. Фикс — пунктуация: CMD ["gunicorn", "app.main:app"] — exec-форма, gunicorn и есть PID 1, SIGTERM доходит, воркеры дренируются, деплои затихли. Образ контейнера — это маленькое решение уровня операционной системы: кто PID 1, флашится ли stdout, какие слои пересобираются, что едет в финальную стадию. У Python по всем четырём пунктам острые особенности, и этот урок — чеклист.
База образа — честно
По умолчанию — python:3.12-slim: на Debian, glibc, около 150 МБ. Полный python:3.12 — почти гигабайт компиляторов и dev-заголовков, которые нельзя везти в прод. Соблазнительный вариант — alpine, и предупреждение здесь важно: Alpine использует musl libc, а большинство готовых колёс на PyPI — manylinux, собранные под glibc. На musl pip часто не может их использовать и откатывается к сборке из исходников: сборки за секунды превращаются в минуты, приходится ставить gcc и заголовки (прощай, выигрыш в размере), и вы наследуете тонкие отличия musl — меньший стек потоков по умолчанию, другое разрешение DNS, — всплывающие как баги только-в-проде. Тег колёс musllinux существует, но покрытие по вашему дереву зависимостей — исключение, а не правило. Distroless-образы Python (без шелла, без пакетного менеджера) — финальная стадия харднинга: минимальная поверхность атаки, но отладка — только через эфемерные контейнеры; внедряйте осознанно, а не по умолчанию.
Честные порядки размеров: полный python:3.12 ≈ 1 ГБ, наивный одностадийный slim ≈ 300–400 МБ со сборочными зависимостями, multi-stage с финальным slim ≈ 80–150 МБ в зависимости от колёс. manylinux — стандарт совместимости Python-колёс (wheel) с большинством glibc-дистрибутивов Linux; пакет, собранный под manylinux, ставится бинарём без компилятора.
Multi-stage с uv и механика кеша слоёв
Две стадии: билдер, которому можно компиляторы, и финальная стадия, получающая только готовый virtualenv. uv делает билдер быстрым и детерминированным — uv sync --frozen ставит ровно lock-файл:
# --- builder: может содержать компиляторы; никогда не едет в прод ---
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project # слой зависимостей: в кеше, пока lock не изменится
COPY src/ src/
RUN uv sync --frozen --no-dev # слой проекта: дёшево
# --- final: slim, без компиляторов, nonroot ---
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH"
RUN useradd --create-home appuser
WORKDIR /app
COPY --from=builder /app/.venv .venv/
COPY src/ src/
USER appuser
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app.main:app"]Порядок COPY — это и есть механика кеша: каждая инструкция — слой, его ключ кеша включает чексуммы копируемых файлов, и всё после первого изменившегося слоя пересобирается. Lock-файл первым, uv sync вторым, код приложения последним — коммит с кодом перезапускает только дешёвый финальный COPY; установка зависимостей (медленный слой) остаётся в кеше, пока uv.lock реально не изменится. Переверните порядок — COPY . . до установки — и каждая однострочная правка переустанавливает все зависимости в CI. Файл-компаньон — .dockerignore: без него COPY . . затаскивает .git (нередко сотни МБ), хостовый .venv (который может затенить собранный) и __pycache__ — классика раздувания образа по умолчанию.
В вашем Dockerfile сначала COPY . . и затем RUN uv sync --frozen. CI пересобирает образ на каждый коммит. Во что обходится изменение одной строки кода?
Рантайм-флаги, nonroot и настоящий healthcheck
PYTHONUNBUFFERED=1 не обсуждается. Когда stdout — не TTY, а в контейнере он не TTY никогда, CPython буферизует его блоками: строки логов лежат в локальном буфере процесса, пока не накопятся килобайты. Симптом — классика молчащего контейнера: сервис работает, kubectl logs минутами не показывает ничего, а при падении пода буферизованный хвост — те самые строки, объясняющие падение, — теряется навсегда. PYTHONDONTWRITEBYTECODE=1 честнее назвать флагом аккуратности: он пропускает запись .pyc (дружелюбнее к read-only файловым системам, нет мусора __pycache__ в слоях) ценой перекомпиляции байткода при каждом старте — измеримо, но мало; прекомпиляция в билдере — вариант «и то и другое».
Работайте под nonroot USER — побеги из контейнера и эксплойты записываемой файловой системы дешевеют под рутом, а admission-политики в харднутых кластерах рутовые образы просто отвергнут. Подключите пробу оркестратора к настоящему readiness-эндпоинту — тому из вашей работы с lifespan в python/06, который проверяет пул БД и downstream-клиентов, — а не к «процесс существует». Учтите: Kubernetes полностью игнорирует инструкцию HEALTHCHECK из Dockerfile; пробы конфигурируются в спеке пода, так что контракт — эндпоинт, а не инструкция.
PID 1 и путь сигнала
Баг из Хука обобщается. CMD gunicorn app:app (shell-форма) делает PID 1 из /bin/sh -c; CMD ["gunicorn", "app:app"] (exec-форма) делает PID 1 из gunicorn. Сигналы доставляются в PID 1 — а неинтерактивный sh не пересылает SIGTERM и сам по нему не выходит, так что graceful shutdown никогда не начинается, и период ожидания всегда истекает в SIGKILL: паттерн «десять секунд — и труп» на каждом деплое. Exec-форма — правило; если в команде действительно нужны возможности шелла, завершайте скрипт через exec gunicorn ..., чтобы сервер заместил шелл. PID 1 наследует и обязанность подбирать сирот: gunicorn управляется со своими детьми, но если ваш entrypoint порождает сайдкары, init-прослойка (tini или init-флаг рантайма) предотвращает накопление зомби.
Деплои дают всплеск 502-х: k8s шлёт SIGTERM, ждёт период ожидания, затем SIGKILL. Dockerfile заканчивается CMD gunicorn app:app (shell-форма). Что на самом деле пережил gunicorn?
- 01Обоснуйте выбор базового образа и multi-stage раскладку: почему slim, а не alpine и не полный образ, и что именно вносит каждая стадия?
- 02Назовите три рантайм-ловушки — буферизация, порядок слоёв, PID 1 — с механизмом и фиксом для каждой.
Python-контейнер — это четыре решения уровня операционной системы, замаскированные под Dockerfile. База: python:3.x-slim, потому что колёса PyPI — manylinux, то есть glibc, а musl в alpine превращает установку колёс в сборку из исходников со своим раздуванием тулчейна и дрейфом поведения; полный образ везёт гигабайт компиляторов, которым нечего делать в проде. Сборка: две стадии — uv разворачивает uv.lock в venv там, где компиляторы разрешены, а финальный slim получает только .venv и исходники, приземляясь на 80–150 МБ вместо гигабайта. Кеш: слои — это префикс; COPY lock-файла и uv sync стоят выше COPY кода, так что коммит пересобирает дешёвый слой, а не установку зависимостей, и .dockerignore вообще не пускает .git и хостовый .venv в контекст. Рантайм: PYTHONUNBUFFERED=1, потому что блочно буферизованный stdout — это молчащий контейнер, чьи предсмертные слова исчезают вместе с падением; nonroot, потому что рутовые контейнеры не проходят харднутый admission и удешевляют эксплойты; readiness подключён к lifespan-эндпоинту, который реально проверяет зависимости, потому что Kubernetes игнорирует Dockerfile HEALTHCHECK. И PID 1: exec-форма CMD, чтобы SIGTERM дошёл до gunicorn, а не умер в шелле, который ничего не пересылает, — месяцы «случайных» 502-х на раскатках из Хука отделяла от небытия одна пара квадратных скобок. Теперь, когда вы увидите молчащий контейнер, раздутый образ или 502-е на деплое, — вы будете знать, какое из этих четырёх решений проверить первым.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.