У 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-timeout30 с длиннееdocker stop: выровнять с таймаутами платформы; exec-форма точки входа.- Квоту читают из
/sys/fs/cgroup/cpu.maxиmemory.max(v2);memory.currentвключает файловый кэш. - Старт ускоряют заранее скомпилированный байткод, ленивый импорт и
--preload; готовность — по базе, а не по процессу.
Что почитать дальше
- Dockerfile для FastAPI — как собрать образ, в который попадут эти настройки.
- Запуск контейнеров — флаги
--memory,--cpusи что происходит без лимитов. - Kubernetes: деплой и конфигурация — requests и limits, из которых берутся те же cgroup.
- Наблюдаемость на Python: метрики — как вывести память воркеров и задержки на график.