Главная/Справочники/Справочник

Python container checklist

Специфика Python в container'е: базовый образ, зависимости, окружение, логи, сигналы, число процессов, ограничения.

Общие требования — в production checklist. Здесь только то, что относится именно к Python.


1. Базовый образ

ТребованиеПроверка
Версия зафиксированаFROM python:3.13-slim, не python:3
Вариант slim, а не полныйПолный больше примерно на 700 MB
alpine выбран осознанно или не выбранСм. ниже
Для воспроизводимости — digestFROM python:3.13-slim@sha256:…

slim против alpine

slimalpine
Реализация libcglibcmusl
Готовые колёса PyPIПодходятЧасто нет
Время сборкиБыстроМедленно: сборка из исходников
Размер образа~130 MB~50 MB
НеожиданностиРедкоРазличия в поведении libc

Правило: slim по умолчанию. alpine — когда размер критичен и вы готовы платить временем сборки и отладкой различий.


2. Зависимости

ТребованиеПроверка
Версии зафиксированыpip install -r requirements.txt с ==
Есть файл блокировкиuv.lock, poetry.lock или requirements.lock
Файл зависимостей копируется до кодаПорядок в Dockerfile
Кэш pip в mount, а не в слое--mount=type=cache,target=/root/.cache/pip
--no-cache-dir не сочетается с cache mountВзаимоисключающие
Зависимости разработки не в итоговом образеОтдельный requirements-dev.txt
Виртуальное окружение по фиксированному пути/opt/venv — копируется одной инструкцией
dockerfile
FROM python:3.13-slim AS deps
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Про фиксированный путь. Копирование site-packages требует знать путь с версией Python (/usr/local/lib/python3.13/…) и ломается при обновлении базового образа.


3. Переменные окружения

ПеременнаяЗачем
PYTHONUNBUFFERED=1Иначе логи копятся в буфере
PYTHONDONTWRITEBYTECODE=1.pyc в слое бесполезны
PIP_DISABLE_PIP_VERSION_CHECK=1Убирает лишний запрос в сеть
PYTHONFAULTHANDLER=1Трассировка при аварийном завершении
PATH с виртуальным окружением/opt/venv/bin:$PATH

Первая — обязательная. Python буферизует stdout блоками, когда он не терминал. В container'е это означает логи, появляющиеся пачками или только при завершении.

flush=True в каждом print не заменяет её: не покрывает вывод библиотек и logging.


4. Конфигурация

ТребованиеПроверка
Читается из окруженияОдин образ для всех сред
Проверяется при стартеНеверное значение — ненулевой код возврата
Опечатка в имени переменной отвергаетсяСм. ниже
Секреты — из файла, не из значения*_FILE-переменные
Источник значения можно узнатьКлюч вроде --show-config

Про опечатки в именах. extra="forbid" в pydantic-settings этого не ловит: переменную, которой не соответствует поле, библиотека просто не видит, и APP_LOG_LEVE=info даёт тихое умолчание.

Проверка пишется отдельно:

python
def check_env(env: dict[str, str], prefix: str, fields: set[str]) -> list[str]:
    known = {f"{prefix}{name.upper()}" for name in fields}
    return sorted(n for n in env if n.startswith(prefix) and n not in known)

5. Журналирование

ТребованиеПроверка
В stdout, не в файлdocker logs не пуст
СтруктурированноеКаждая строка — объект JSON
Все строки, включая сервер приложенийПроверять весь вывод, не первые строки
Настраивается при импорте, не в lifespanИначе первые строки уйдут другим форматом
Многострочные записи не ломают разборПеревод строки внутри значения
Значения от клиента очищаютсяИдентификатор запроса из заголовка

Про четвёртый пункт. Сервер приложений печатает первые строки до входа в lifespan. Настройка формата там даёт смешанный поток, и сборщик логов разберёт половину.

bash
docker logs ИМЯ 2>&1 | python3 -c '
import json, sys
lines = [line for line in sys.stdin if line.strip()]
bad = sum(1 for line in lines if not _try(line))' 2>/dev/null || \
docker logs ИМЯ 2>&1 | head -20

Проще — командой из production checklist.


6. Сигналы и завершение

ТребованиеПроверка
Exec-форма точки входаdocker image inspect --format '{{json .Config.Entrypoint}}'
PID 1 — интерпретатор, не оболочкаdocker exec ИМЯ ps -eo pid,cmd | head -2
Обработчик SIGTERM установленtime docker stop — доли секунды
Активные запросы дорабатываютсяДолгий запрос плюс остановка
Ожидание завершения ограничено по времениИначе зависший запрос блокирует выход
Скрипт-обёртка использует execexec python -m app "$@"
BrokenPipeError обработан у CLIкоманда | head не даёт трассировки

Важная тонкость при отладке. Локально послав SIGTERM, вы не увидите задержки даже без обработчика: обычный процесс умирает мгновенно по действию ядра по умолчанию.

Симптом «десять секунд» существует только там, где приложение имеет PID 1 — то есть внутри container'а: для PID 1 действия по умолчанию не применяются (урок 4.5).

Проверять нужно time docker stop, а не локальным kill.

Образец для ASGI

python
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    deadline = time.monotonic() + settings.shutdown_grace_seconds
    while state.inflight > 0 and time.monotonic() < deadline:
        await asyncio.sleep(0.05)

7. Число процессов

ТребованиеЗаметка
Один процесс на containerРепликация — задача Compose или оркестратора
Число рабочих процессов задаётся снаружиНе вычисляется от числа ядер хоста
Вычисление от ядер учитывает лимит cgroupos.cpu_count() видит ядра хоста

Главная ловушка. os.cpu_count() и multiprocessing.cpu_count() возвращают число ядер хоста, а не выделенных container'у. На машине с 64 ядрами и лимитом cpus: 0.5 приложение запустит десятки процессов.

python
def available_cpus() -> int:
    """Число процессоров с учётом ограничения cgroup v2."""
    try:
        quota, period = Path("/sys/fs/cgroup/cpu.max").read_text().split()
        if quota != "max":
            return max(1, int(int(quota) / int(period)))
    except (OSError, ValueError):
        pass
    return os.cpu_count() or 1

Проще и надёжнее — задавать число переменной окружения.


8. Ограничения ресурсов

ТребованиеПроверка
Лимит памяти заданdocker inspect --format '{{.HostConfig.Memory}}'
Лимит применилсяdocker exec ИМЯ cat /sys/fs/cgroup/memory.max
Значение обосновано измерениемНе «256 MB на глаз»
Приложение читает лимит изнутриДля настройки пулов и кэшей
Счётчики давления наблюдаютсяmemory.events
bash
docker exec ИМЯ cat /sys/fs/cgroup/memory.max
docker exec ИМЯ cat /sys/fs/cgroup/memory.events
docker exec ИМЯ cat /sys/fs/cgroup/cpu.max

Docker задаёт только memory.max, но не memory.high. Это означает отсутствие торможения перед отказом: процесс работает нормально и убивается мгновенно (урок 17.4).

Ненулевой счётчик max в memory.events означает, что процесс уже упирался в предел — задолго до того, как отказ станет заметным.


9. Тесты

ТребованиеПроверка
Раскладка src/Исключает импорт из рабочего каталога
Тесты выполняются в образеdocker build --target test .
Провал останавливает сборкуСломать тест намеренно
stdout и stderr проверяются раздельноИначе диагностика в результате не заметится
Есть тест на запущенном процессеTestClient не поднимает сервер
Отсутствие зависимости — отказ в CIНе «пропущено»

Про раскладку src/. Без неё пакет импортируется из рабочего каталога, а не из установленного: тесты проходят локально и падают в образе, где установлен пакет.

Про последний пункт. «33 skipped» в отчёте выглядит как успех. В конвейере пропуск должен быть отказом — переключателем вроде APP_REQUIRE_DB=1.


Итог

ГруппаПунктов
1. Базовый образ4
2. Зависимости7
3. Переменные окружения5
4. Конфигурация5
5. Журналирование6
6. Сигналы7
7. Число процессов3
8. Ограничения ресурсов5
9. Тесты6
Всего48

Три ловушки, специфичные для Python

ЛовушкаСимптомМеханизм
Буферизация выводаЛоги пусты при работающем приложенииstdout не терминал
os.cpu_count() в container'еДесятки процессов при cpus: 0.5Видит ядра хоста
SIGTERM не проверяется локально«У меня работает»Локально процесс не PID 1

Все три не воспроизводятся вне container'а — и потому находятся позже всего.


Навигация

Вернуться к справочникам
Production checklist
Dockerfile cheat sheet
Раздел 06. Python внутри Container
Главное оглавление

Markdown на GitHub ↗