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

У Python нет размера кучи, который нужно подбирать, зато есть GIL: один процесс выполняет байткод на одном ядре, и «сколько ядер» превращается в «сколько процессов». А каждый процесс — копия приложения со своей памятью. Отсюда два типичных инцидента: сервис с восемью воркерами на квоте в два ядра, который работает медленнее, чем с двумя, и контейнер на 512 МБ, который умирает с кодом 137 после четвёртого воркера.

Обязательно

Ядра и GIL: сколько воркеров запускать

Один процесс uvicorn обслуживает тысячи одновременных соединений, пока обработчики ждут базу или сеть: асинхронный цикл событий переключается между ними без потоков. Но байткод Python в этом процессе исполняется на одном ядре, и когда обработчики считают (сериализация больших ответов, хеширование, обработка изображений), второе ядро процессу не поможет. Чтобы занять несколько ядер, запускают несколько процессов: uvicorn --workers N или gunicorn -k uvicorn.workers.UvicornWorker -w N.

Чему равно N. Старое правило «2 × ядра + 1» написано для синхронных воркеров на хосте и в контейнере вредно вдвойне: ядра оно берёт у хоста, а воркеры множит без нужды. os.cpu_count() в контейнере возвращает число ядер машины, а не квоту: на 32-ядерном хосте с --cpus=2 формула даст 65 процессов, которые будут толкаться за две ядро-секунды на каждые 100 мс и стоять на паузах квоты. os.process_cpu_count() в Python 3.13 учитывает привязку к ядрам, но квоту cgroup тоже не читает. Правило для контейнера: число воркеров равно квоте процессора, округлённой до целого, для асинхронных воркеров — не больше; дробную квоту (--cpus=0.5) не дают вовсе, один процесс с половиной ядра живёт на паузах. Число задают явно переменной WEB_CONCURRENCY, которую читают и uvicorn, и gunicorn, и оно едет рядом с лимитом в том же манифесте.

Память: каждый воркер — копия приложения

Память контейнера с четырьмя воркерами — это четыре процесса, у каждого свои объекты, кэши и соединения с базой. gunicorn --preload загружает приложение один раз и затем делает fork: страницы памяти поначалу общие, но Python при каждом обращении к объекту меняет его счётчик ссылок, страница копируется, и через несколько минут работы общего почти не остаётся. Приём gc.freeze() перед fork убирает объекты из кучи сборщика и сохраняет часть страниц общими, но планировать память всё равно надо как «воркеры × память одного под нагрузкой плюс запас».

Память одного воркера со временем растёт: фрагментация аллокатора glibc, кэши библиотек, редкие утечки в C-расширениях. Два предохранителя. --max-requests 1000 --max-requests-jitter 100 у gunicorn перезапускает воркера после тысячи запросов, разброс нужен, чтобы воркеры не перезапускались одновременно. Переменная MALLOC_ARENA_MAX=2 ограничивает число арен glibc: по умолчанию их по восемь на ядро хоста, и у процесса с потоками память дробится на десятки арен, которые не возвращаются системе.

OOMKilled против MemoryError: два разных конца

Контейнер завершился с кодом 137, в логе последняя строка — обычный запрос. Это OOMKilled: ядро убило процесс за превышение лимита памяти cgroup, docker inspect покажет OOMKilled: true. У gunicorn картина хитрее: ядро убивает самый толстый воркер, главный процесс поднимает новый, и снаружи это выглядит как периодические 502 и строка Worker (pid:42) was sent SIGKILL! Perhaps out of memory? в логе — её и ищут первой.

MemoryError внутри Python — исключение, которое прилетает, когда интерпретатор не смог выделить память: в контейнере встречается редко, потому что ядро обычно убивает раньше, чем отказывает; типичный источник — попытка разом прочитать файл на гигабайты или собрать список из миллионов объектов. Разбор в обоих случаях начинают с того, кто держит память: tracemalloc в стандартной библиотеке показывает, какие строки кода выделили удерживаемые объекты, memray даёт полную картину вместе с C-расширениями, а метрика rss каждого воркера на графике говорит, растёт ли память ровно или скачками.

Логи и буферизация: PYTHONUNBUFFERED

Когда stdout — не терминал, а труба к Docker, Python буферизует вывод блоками по несколько килобайт. Сервис с редкими логами показывает их в docker logs с опозданием на минуты, а при SIGKILL буфер пропадает вместе с процессом: инцидент есть, строк о нём нет. PYTHONUNBUFFERED=1 в образе или флаг python -u отключают буферизацию для всего вывода; логгер с обработчиком в sys.stderr и flush после каждой записи решает то же для своих сообщений. Это первое, что проверяют, когда «логов не было» перед падением.

Сигналы: uvicorn, gunicorn и PID 1

uvicorn на SIGTERM перестаёт принимать соединения, дожидается текущих запросов в пределах --timeout-graceful-shutdown и вызывает shutdown приложения, где закрывают пул соединений с базой. gunicorn передаёт сигнал воркерам и ждёт --graceful-timeout (30 секунд по умолчанию) — дольше десяти секунд docker stop, поэтому одно из двух значений подгоняют под другое и под terminationGracePeriodSeconds в Kubernetes. Сигнал должен дойти до процесса: exec-форма точки входа, а не строка через оболочку, об этом в статье про Dockerfile для Python. Главный процесс gunicorn как PID 1 умеет собирать завершившихся воркеров; если приложение само запускает внешние команды, добавьте --init.

Полная конфигурация: минимальный рабочий пример

FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
    WEB_CONCURRENCY=2 \
    MALLOC_ARENA_MAX=2 \
    PATH="/venv/bin:$PATH"
WORKDIR /app
COPY --from=build --chown=app:app /venv /venv
COPY --chown=app:app app/ app/
USER app
ENTRYPOINT ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", \
            "--bind", "0.0.0.0:8000", "--max-requests", "1000", "--max-requests-jitter", "100", \
            "--graceful-timeout", "8"]
services:
  app:
    image: myapp:1.4.2
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 768M
    environment:
      WEB_CONCURRENCY: "2"

Или то же для docker run: --cpus=2 --memory=768m -e WEB_CONCURRENCY=2. Лимит памяти считают от измеренного rss одного воркера под нагрузкой: два воркера по 250 МБ плюс главный процесс и запас на всплески — 768 МБ, а не 512. При старте сервис пишет в лог версию Python, число воркеров и квоту из cgroup — три числа, с которых начинается разбор любого инцидента про память и задержки.

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

Глубже: cgroup v1 и v2: как прочитать квоту из Pythonрасширенное

Лимиты контейнера лежат в /sys/fs/cgroup: в cgroup v2 квота процессора в cpu.max в виде 200000 100000 (два ядра), лимит памяти в memory.max; в v1 — cpu/cpu.cfs_quota_us, cpu/cpu.cfs_period_us и memory/memory.limit_in_bytes. Посчитать воркеров из квоты можно прямо в скрипте запуска:

def cpu_quota() -> int | None:
    try:
        quota, period = Path("/sys/fs/cgroup/cpu.max").read_text().split()
    except FileNotFoundError:
        return None
    return None if quota == "max" else math.ceil(int(quota) / int(period))

и подставить результат в WEB_CONCURRENCY, если переменную не задали снаружи. Один нюанс v2: в memory.current входит файловый кэш прочитанных файлов, поэтому с лимитом сравнивают число за вычетом inactive_file из memory.stat, как делает Kubernetes.

Глубже: холодный старт: импорт и байткодрасширенное

Интерпретатор стартует за десятки миллисекунд, приложение — за секунды: импорт сотен модулей, компиляция байткода, если __pycache__ нет, подключение к базе и прогрев. Что ускоряет: байткод, скомпилированный на этапе сборки через compileall вместо PYTHONDONTWRITEBYTECODE; ленивый импорт тяжёлых пакетов внутри функций; gunicorn --preload, чтобы приложение импортировалось один раз, а не в каждом воркере. Остальное время — инициализация, и её выносят в проверку готовности: контейнер готов, когда база ответила, а не когда процесс запустился. Для --start-period и initialDelaySeconds мерят реальный старт в проде с четырьмя воркерами, а не на ноутбуке с одним.

Коротко

  • GIL: один процесс считает на одном ядре, ядра в контейнере — это воркеры. Их число равно квоте процессора, не «2 × ядра + 1»: os.cpu_count() видит хост, задавайте WEB_CONCURRENCY явно, дробную квоту не давайте.
  • Память — воркеры × память одного под нагрузкой плюс запас; --preload и gc.freeze() экономят на общих страницах, но ненадолго.
  • Рост памяти воркера со временем лечат --max-requests с разбросом и MALLOC_ARENA_MAX=2.
  • Код 137 — OOMKilled ядром; у gunicorn это периодические 502 и строка Worker was sent SIGKILL! Perhaps out of memory?; MemoryError внутри Python — редкость. Разбор: tracemalloc, memray, rss воркеров на графике.
  • PYTHONUNBUFFERED=1 обязателен: иначе логи копятся блоками и теряются при убийстве процесса.
  • uvicorn и gunicorn завершаются мягко по SIGTERM, но --graceful-timeout 30 с длиннее docker stop: выровнять с таймаутами платформы; exec-форма точки входа.
  • Квоту читают из /sys/fs/cgroup/cpu.max и memory.max (v2); memory.current включает файловый кэш.
  • Старт ускоряют заранее скомпилированный байткод, ленивый импорт и --preload; готовность — по базе, а не по процессу.

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