Python container checklist
Специфика Python в container'е: базовый образ, зависимости, окружение, логи, сигналы, число процессов, ограничения.
Общие требования — в production checklist. Здесь только то, что относится именно к Python.
1. Базовый образ
| ☐ | Требование | Проверка |
|---|---|---|
| ☐ | Версия зафиксирована | FROM python:3.13-slim, не python:3 |
| ☐ | Вариант slim, а не полный | Полный больше примерно на 700 MB |
| ☐ | alpine выбран осознанно или не выбран | См. ниже |
| ☐ | Для воспроизводимости — digest | FROM python:3.13-slim@sha256:… |
slim против alpine
slim | alpine | |
|---|---|---|
| Реализация libc | glibc | musl |
| Готовые колёса 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 — копируется одной инструкцией |
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 даёт тихое умолчание.
Проверка пишется отдельно:
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. Настройка формата там даёт смешанный поток, и сборщик логов разберёт половину.
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 — доли секунды |
| ☐ | Активные запросы дорабатываются | Долгий запрос плюс остановка |
| ☐ | Ожидание завершения ограничено по времени | Иначе зависший запрос блокирует выход |
| ☐ | Скрипт-обёртка использует exec | exec python -m app "$@" |
| ☐ | BrokenPipeError обработан у CLI | команда | head не даёт трассировки |
Важная тонкость при отладке. Локально послав SIGTERM, вы не увидите задержки даже без обработчика: обычный процесс умирает мгновенно по действию ядра по умолчанию.
Симптом «десять секунд» существует только там, где приложение имеет PID 1 — то есть внутри container'а: для PID 1 действия по умолчанию не применяются (урок 4.5).
Проверять нужно time docker stop, а не локальным kill.
Образец для ASGI
@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 или оркестратора |
| ☐ | Число рабочих процессов задаётся снаружи | Не вычисляется от числа ядер хоста |
| ☐ | Вычисление от ядер учитывает лимит cgroup | os.cpu_count() видит ядра хоста |
Главная ловушка. os.cpu_count() и multiprocessing.cpu_count() возвращают число ядер хоста, а не выделенных container'у. На машине с 64 ядрами и лимитом cpus: 0.5 приложение запустит десятки процессов.
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 |
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
Главное оглавление