open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 12 · 03

Воркеры и жизненный цикл: процессная математика gunicorn, preload и fork-безопасность, graceful shutdown

Sync-воркеры — блокирующий код, uvicorn — async; ~ядра для CPU, 1–2 на ядро для I/O: GIL делает процессы единицей масштаба. preload_app экономит память, но делит дофорковые сокеты: клиенты — в post_fork. max_requests+jitter смягчает утечки; SIGTERM дренирует за graceful_timeout.

PY Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Баг-репорт читался как инцидент безопасности: «Я открыл страницу своих заказов и увидел чужие». Потом второй репорт, потом дюжина — мигающие, невоспроизводимые, исчезающие по F5. Команда два дня аудировала авторизацию, пока кто-то не заглянул в gunicorn.conf.py: preload_app = True, восемь воркеров и — в модуле, импортируемом на старте, — redis = Redis() на уровне модуля. Вот оно. С preload приложение импортируется один раз в мастере, а fork() затем дублирует процесс — включая уже открытый TCP-сокет к Redis — во все восемь воркеров. Восемь процессов, одно общее соединение. Каждый воркер писал команды и читал тот ответ, который оказывался следующим в потоке: на GET session:alice воркера A приходили байты, принадлежащие GET session:bob воркера B. Не баг авторизации — баг файлового дескриптора. Фикс — правило fork-безопасности, которое вбивает этот урок: соединения создаются после форка, в хуке post_fork или лениво при первом использовании. Решения процессной модели — сколько воркеров, какой класс, что делит preload, как дренирует SIGTERM — это тихие строки конфига с радиусом поражения, видимым пользователям.

Классы воркеров и математика количества

Gunicorn — префоркающий мастер: он биндит слушающий сокет, форкает N воркеров, которые все делают accept() с него, и супервизирует — перезапускает упавших, рециклит, транслирует сигналы. Класс воркера определяет, что такое один воркер. Sync-воркер обрабатывает ровно один запрос за раз: ваша конкурентность равна числу воркеров, и один медленный вызов наружу держит в заложниках целый процесс. Uvicorn-воркер крутит цикл событий: один процесс держит сотни конкурентных запросов в полёте — пока всё await-ится (подбирайте класс под приложение, которое вы реально построили в python/06: блокирующим драйверам БД место на sync-воркерах или в тред-экзекуторах, а не на цикле). Свежий uvicorn поставляет класс отдельным пакетом uvicorn-worker; старый путь импорта uvicorn.workers задеприкейчен, но встречается повсюду в старых конфигах.

Математика количества следует из GIL (Global Interpreter Lock — глобальная блокировка интерпретатора, не дающая нескольким потокам исполнять байткод Python одновременно): потоки внутри процесса не добавляют CPU-параллелизма, поэтому единица CPU-масштаба — процесс. CPU-bound сервисы: воркеров ≈ ядер — больше лишь добавляет переключений контекста. I/O-bound async-сервисы: 1–2 × ядра — каждый воркер уже мультиплексирует тысячи сокетов; воркеры добавляют ядра и изоляцию отказов, а не конкурентность. Классическая эвристика 2*cores+1 — правило эпохи sync-воркеров для смешанного I/O; любую формулу считайте стартовой точкой, перемеряемой под собственный p99.

preload_app и fork-безопасность

preload_app = True импортирует приложение один раз в мастере до форков. Выигрыш реален: copy-on-write означает, что тяжёлые импорты (numpy, pandas, модель) занимают физическую память один раз на все воркеры, а не N раз, и (пере)запуск воркера почти мгновенный — импорт уже случился. Цена — Хук: всё созданное на этапе импорта существует до форка и дублируется в каждого потомка — и если Python-объекты становятся copy-on-write копиями, то открытый сокет — это файловый дескриптор уровня ОС, и fork() дублирует дескриптор, а не соединение. Восемь воркеров наследуют один TCP-поток; конкурентные записи перемешиваются, ответы достаются тому процессу, который прочитал первым — классика испорченного соединения, всплывающая как чужие данные у пользователей или протокольные ошибки в глубине драйвера.

Правило: соединения создаются после форка. Две идиомы:

# gunicorn.conf.py
import multiprocessing
workers = multiprocessing.cpu_count() * 2          # форма для async I/O; ~ядра при CPU-bound
worker_class = "uvicorn_worker.UvicornWorker"
preload_app = True                                 # экономия CoW — с правилом ниже
max_requests = 5_000
max_requests_jitter = 500                          # рассинхронизировать рециклы
graceful_timeout = 25                              # < terminationGracePeriodSeconds (30)
timeout = 30

def post_fork(server, worker):
    from app import state
    state.init_clients()                           # пулы/клиенты рождаются ЗДЕСЬ, на воркер

Альтернатива — ленивая инициализация: клиент конструируется при первом использовании внутри воркера. Работает и то и другое; не работает никогда — модульный Redis() или подключённый пул на импорте при включённом preload. Та же опасность касается сидов random (fork копирует состояние — одинаковые «случайные» последовательности в воркерах) и захваченных локов.

Викторина

gunicorn с preload_app=True и 8 воркерами. Модуль создаёт redis = Redis() на этапе импорта. Пользователи изредка получают чужие данные. Каков механизм?

Жизненный цикл: рециклинг и таймауты

max_requests рециклит воркер после N запросов; max_requests_jitter рандомизирует N на воркер, чтобы восемь воркеров не перезапустились в одну и ту же минуту, синхронно просадив ёмкость. Будьте честны о том, что это: смягчение роста памяти, а не фикс — оно ограничивает RSS перезапуском процесса, пока вы ищете настоящую утечку tracemalloc-ом (следующий урок препарирует одну). Жить с этим вечно как с «фиксом» означает, что каждая следующая утечка, которую вы зашипите, будет невидимой.

timeout — контракт сердцебиения мастера: sync-воркер, переставший отмечаться у арбитра на timeout секунд, считается застрявшим (дедлок, зависание на неограниченном вызове), убивается и заменяется — грубо, но корректно для воркеров «один запрос за раз». На async-воркере тот же симптом значит другое и худшее: сердцебиение пропадает, потому что заблокирован сам цикл событий — вкрался синхронный вызов (урок о мостах python/11), — и убийство уносит не один запрос, а каждую корутину в полёте на этом воркере. Один таймаут — разный радиус поражения; диагностика тоже разная (py-spy, следующий урок).

Graceful shutdown и сигналы масштабирования

Последовательность выключения — контракт между двумя системами. Kubernetes помечает под terminating: эндпоинт уходит из Service (сначала readiness-off — новый трафик перестаёт маршрутизироваться; маленький preStop-sleep покрывает лаг распространения), затем SIGTERM идёт в PID 1. Мастер gunicorn прекращает приём, сигналит воркерам дообработать запросы в полёте и ждёт graceful_timeout, после чего добивает отстающих; под обязан выйти до terminationGracePeriodSeconds, иначе ядро шлёт SIGKILL посреди дренажа. Неравенство такое: preStop + graceful_timeout с запасом меньше периода ожидания — тот же паттерн дренажа, что вы собирали в Go-сервисах (go/12), потому что контракт принадлежит платформе, а не языку. Сигналы масштабирования, честно: CPU на воркер показывает запас по вычислениям; глубина очереди и p99 показывают насыщение раньше CPU на I/O-bound сервисах. Масштабируйте поды, а не только число воркеров — поды покупают изоляцию, бин-пакинг шедулера и контроль радиуса поражения рестартов, чего толстые поды не дают.

Викторина

У gunicorn graceful_timeout=30, но в спеке пода terminationGracePeriodSeconds=10. Что происходит с запросами в полёте на каждом деплое?

Вспомните перед уходом
  1. 01
    Изложите компромисс preload_app целиком: что он экономит, что именно ломается с модульным клиентом и каковы две безопасные идиомы?
  2. 02
    Дайте математику числа воркеров с обоснованием через GIL и полный временной контракт graceful shutdown с Kubernetes.
Итог

Процессная модель — то место, где Python-сервисы зарабатывают или теряют операционную репутацию. Мастер gunicorn форкает воркеров; класс определяет воркера: sync — один запрос на процесс и конкурентность равна числу воркеров, uvicorn-worker — цикл событий на процесс и сотни запросов в полёте; подбирайте под то, действительно ли ваше приложение await-ится. GIL фиксирует математику: процесс — единица CPU, отсюда ~ядра для CPU-bound, 1–2 на ядро для async I/O, и любая формула — гипотеза, пока её не подтвердил ваш p99. preload_app — самый острый компромисс: импорт один раз в мастере, и copy-on-write делает тяжёлые импорты почти бесплатными на воркер — но всё рождённое до форка дублируется в потомков, и Redis-клиент на импорте — это один fd сокета на восемь процессов, перемешивающий ответы пользователей, пока кто-нибудь не прочитает gunicorn.conf.py вместо кода авторизации. Соединения создаются после форка: хук post_fork или ленивая инициализация, без исключений. max_requests с джиттером рециклит воркеров, ограничивая утечку, которую вы ещё не нашли, — смягчение со сроком годности, а не фикс. А выключение — неравенство между двумя системами: сначала readiness off, SIGTERM в PID 1, дренаж за graceful_timeout, выход до terminationGracePeriodSeconds — тот же контракт, что вы собирали в go/12, потому что им владеет платформа. Когда нужна ёмкость — масштабируйте поды, глядя на CPU на воркер, глубину очереди и p99. Теперь, когда вы увидите чужие данные у пользователей, деплой, зависший на периоде ожидания, или очевидно неверное число воркеров, — вы потянетесь к нужному параметру конфига, а не в код авторизации.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.