← назад к разделу

Сервис на Python в контейнере — это интерпретатор, пакеты из pip и ваш код, который читается прямо из исходников. Отсюда свои грабли: полный образ python:3.12 на гигабайт с компиляторами, кэш pip в слое, Alpine, на котором pip вдруг начинает собирать psycopg из исходников, логи, которые появляются через минуту после события, и uvicorn, который не узнаёт о docker stop. Разберём Dockerfile от наивного к рабочему и каждую из граблей по дороге.

Обязательно

Самый простой Dockerfile и что в нём дорого

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD uvicorn app.main:app --host 0.0.0.0

Работает и стоит дорого. python:3.12 на Debian с gcc, заголовками и инструментами весит около 1 ГБ. COPY . . до установки зависимостей сбрасывает кэш слоя при любой правке кода, и pip install идёт заново. Кэш pip остаётся в слое образа и добавляет десятки мегабайт. А CMD строкой запускает uvicorn через оболочку: первым процессом становится sh, и сигнал остановки до сервера не доходит — об этом отдельный раздел. Общий принцип разделения сборки и запуска разобран в статье про multi-stage, здесь — его версия для Python.

Многоэтапная сборка: venv в builder, slim в рантайме

# syntax=docker/dockerfile:1

# ── Этап 1: зависимости в виртуальное окружение ─────────────
FROM python:3.12 AS build
WORKDIR /app
ENV PIP_DISABLE_PIP_VERSION_CHECK=1

# зависимости отдельно: слой переживёт правку кода
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    python -m venv /venv && /venv/bin/pip install -r requirements.txt

# ── Этап 2: финальный образ ──────────────────────────────────
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH="/venv/bin:$PATH"
WORKDIR /app

RUN addgroup --system app && adduser --system --ingroup app --no-create-home app
COPY --from=build --chown=app:app /venv /venv
COPY --chown=app:app app/ app/

EXPOSE 8000
USER app
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Что здесь важно. Зависимости ставятся в виртуальное окружение /venv на полном образе, где есть компилятор для пакетов без готовых колёс, а в финальный образ переезжает только /venv: без gcc, без кэша, без исходников пакетов. Кэш-монтирование /root/.cache/pip переживает пересборки и в образ не попадает. PATH с /venv/bin впереди делает uvicorn и python из окружения командами по умолчанию. PYTHONUNBUFFERED=1 отключает буферизацию stdout: без неё Python в контейнере, где вывод идёт не в терминал, копит логи блоками, и они появляются в docker logs с опозданием, а при убийстве процесса теряются вовсе. PYTHONDONTWRITEBYTECODE=1 не пишет __pycache__ в контейнер только для чтения; если старт важнее, наоборот, компилируют заранее, об этом ниже.

Версии фиксируют файлом с точными номерами (pip freeze или pip-compile), а не диапазонами: иначе две сборки одного коммита могут получить разные пакеты. Размеры для ориентира:

ВариантРазмер
python:3.12 со сборкой внутри1,0–1,2 ГБ
python:3.12-slim + venv типичного сервиса150–250 МБ
python:3.12-alpine + venv100–200 МБ, с оговорками ниже
distroless/python3-debian12интерпретатор 3.11 из Debian, с собранным на 3.12 окружением не совместим

То же с uv: один инструмент вместо pip и venv

uv ставит зависимости в разы быстрее pip и держит файл блокировки uv.lock. В Dockerfile он занимает одну строку копирования из официального образа:

FROM python:3.12 AS build
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
COPY app/ app/

FROM python:3.12-slim
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=build /app/.venv .venv
COPY --from=build /app/app app
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0"]

--frozen запрещает менять uv.lock при сборке, --no-dev не ставит зависимости разработки, --no-install-project ставит только зависимости, чтобы слой переживал правку кода. Идея та же, что с pip и venv, команды короче и быстрее.

slim против alpine: колёса и musl

python:3.12-alpine меньше slim, но Alpine использует musl вместо glibc, а готовые колёса (wheels) большинства пакетов с C-частью собраны под glibc по стандарту manylinux. На Alpine pip не находит подходящего колеса и собирает пакет из исходников: для psycopg, pydantic-core, numpy, cryptography это минуты сборки, компилятор и заголовки в образе, а иногда и ошибка на полпути. У части пакетов появились колёса musllinux, но далеко не у всех, и каждый новый пакет — лотерея. Для обычного сервиса slim на glibc проще и предсказуемее; на Alpine переходят, когда сотня мегабайт действительно важна, и после проверки pip install на чистой машине с журналом, где Building wheel означает сборку из исходников.

Кто получает SIGTERM: exec-форма, uvicorn и gunicorn

docker stop посылает SIGTERM процессу с номером 1 и через десять секунд добивает SIGKILL. При CMD uvicorn ... строкой номер 1 — оболочка sh, и uvicorn о сигнале не узнаёт: контейнер каждый раз останавливается ровно десять секунд с кодом 137, запросы оборваны. Поэтому точка входа — exec-форма: ENTRYPOINT ["uvicorn", "app.main:app", ...].

Сам uvicorn на SIGTERM завершается мягко: перестаёт принимать соединения, дожидается текущих запросов и вызывает shutdown приложения FastAPI (контекст lifespan), где закрывают пул соединений с базой и клиентов. Время ожидания задаёт --timeout-graceful-shutdown, и оно должно быть короче таймаута docker stop и terminationGracePeriodSeconds в Kubernetes. С gunicorn и воркерами uvicorn то же делает главный процесс: он передаёт сигнал воркерам и ждёт --graceful-timeout (30 секунд по умолчанию, то есть больше docker stop, что стоит выровнять).

uvicorn как PID 1 с одним воркером зомби не копит. С gunicorn или при запуске внешних команд из приложения добавьте docker run --init или init: true в Compose.

От кого работает процесс: USER

В официальном образе Python нет готового непривилегированного пользователя, его создают: addgroup --system app && adduser --system --ingroup app --no-create-home app, затем COPY --chown=app:app и USER app до точки входа. Kubernetes с runAsNonRoot: true проверяет числовой uid, поэтому иногда пишут USER 10001:10001 и создают пользователя с этим номером явно. Приложению после этого нужно место для записи: временные файлы загрузок, кэш matplotlib или huggingface. Каталог создают заранее от root и отдают пользователю, а приложению говорят про него переменной TMPDIR; писать в /app рядом с кодом не стоит, в проде файловую систему контейнера часто делают только для чтения.

Конфигурация и секреты: pydantic-settings и файлы

В образ не зашивают ни адреса, ни пароли: один образ едет через все окружения. Настройки читают из переменных среды при старте, в FastAPI обычно через pydantic-settings, чтобы отсутствие обязательной переменной роняло процесс на старте, а не на первом запросе:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(secrets_dir="/run/secrets")

    database_url: PostgresDsn
    http_timeout: float = 5.0
    database_password: SecretStr

Поле secrets_dir читает значения из файлов /run/secrets/<имя_поля>: так приходят секреты из Docker Compose и Kubernetes, и переменные среды для паролей не нужны — переменные видны в docker inspect и в дочерних процессах. Файл .env — удобство разработки: его добавляют в .dockerignore, иначе локальные пароли уедут в слой COPY . . этапа сборки.

HEALTHCHECK без curl

В python:3.12-slim нет curl, зато есть сам python:

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=3).status == 200 else 1)"]

--start-period нужен: импорт тяжёлых пакетов и подключение к базе занимают секунды, а приложение с numpy и ORM — дольше. Без этого параметра оркестратор успеет посчитать контейнер неработоспособным ещё на старте и перезапустить его. В Kubernetes инструкция HEALTHCHECK игнорируется, те же проверки описывают пробами на ту же ручку.

Дополнительно: при первом чтении можно пропустить

Глубже: архитектура: arm64 на ноутбуке, amd64 на серверерасширенное

Образ, собранный на ноутбуке с Apple Silicon, получается linux/arm64 и на сервере amd64 не стартует: exec format error. У Python кросс-компиляции нет, а колёса с C-частью привязаны к архитектуре, поэтому образ под другую архитектуру собирают либо в конвейере на машине нужной архитектуры, либо через эмуляцию docker build --platform linux/amd64 — медленно, особенно если какой-то пакет собирается из исходников. Виртуальное окружение с хоста в образ не копируют по той же причине. Общая механика мультиплатформенных образов — в статье про реестры и CI.

Глубже: байткод и старт: PYTHONDONTWRITEBYTECODE или compileallрасширенное

PYTHONDONTWRITEBYTECODE=1 удобен для контейнера только для чтения, но у него есть цена: без __pycache__ интерпретатор компилирует каждый модуль при каждом старте, и у приложения с сотнями модулей это сотни миллисекунд. Если старт важен (частые перезапуски, масштабирование по нагрузке), байткод компилируют заранее на этапе сборки: python -m compileall -q /venv /app/app кладёт .pyc в образ один раз, а переменную не выставляют. Второй источник медленного старта — импорт на верхнем уровне тяжёлых пакетов, которые нужны не каждому запросу; их импортируют лениво внутри функций.

Коротко

  • Рабочий образ — python:3.12-slim с виртуальным окружением из этапа сборки и кодом, 150–250 МБ; компилятор и кэш pip остаются в первом этапе.
  • Зависимости до кода, кэш-монтирование /root/.cache/pip или /root/.cache/uv; версии зафиксированы точно (pip-compile, uv.lock с --frozen).
  • PYTHONUNBUFFERED=1 обязателен: иначе логи копятся блоками и теряются при убийстве процесса; PYTHONDONTWRITEBYTECODE=1 или заранее compileall — по тому, что важнее: образ только для чтения или старт.
  • Alpine меняет glibc на musl: колёса manylinux не подходят, pip собирает из исходников; для обычного сервиса slim.
  • Exec-форма ENTRYPOINT ["uvicorn", ...]: uvicorn на SIGTERM завершается мягко с --timeout-graceful-shutdown, у gunicorn --graceful-timeout 30 с длиннее docker stop.
  • Пользователя создают сами (adduser --system), COPY --chown, USER до точки входа, место для записи через TMPDIR.
  • Настройки из переменных через pydantic-settings, секреты файлами через secrets_dir="/run/secrets", .env в .dockerignore.
  • HEALTHCHECK через python -c "urllib.request...", потому что curl в slim нет; --start-period под медленный импорт; в Kubernetes то же делают пробы.

Что почитать дальше