Сервис на 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 + venv | 100–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-timeout30 с длиннее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 то же делают пробы.
Что почитать дальше
- Multi-stage сборка и кэш слоёв — почему этапы и порядок инструкций решают размер и время.
- Python в контейнере: воркеры, память и сигналы — что рантайм видит внутри лимитов и как это настроить.
- Маленькие и безопасные образы — теги и дайджесты, non-root, сканеры, секреты вне слоёв.
- Запуск контейнеров — порты, переменные, логи и жизненный цикл.