backend · advanced · 8d
Асинхронный Python-сервис: собрать и эксплуатировать
Собери асинхронный FastAPI-сервис приёма, который валидирует, прогоняет через пайплайн и выдерживает нагрузку, — а потом эксплуатируй его: упакуй, контейнеризуй с корректным поведением PID-1 и разберись с инцидентом, когда проглоченный CancelledError тихо протекает задачами, пока event loop не начинает голодать.
Результат
Работающий асинхронный API-сервис с валидируемым через Pydantic приёмом, корректным по отмене асинхронным пайплайном с потаймстейджными таймаутами, упакованной через pyproject сборкой с закоммиченным lock-файлом, контейнером с корректной обработкой сигналов, структурным логированием и вынесенной конфигурацией, а также написанным пост-мортемом инцидента голодания event loop.
Этапы
0/8 · 0%- 01Очерти сервис: профиль нагрузки, SLO, async non-goals
До кода реши, чем этот сервис является, а чем — нет. Асинхронный сервис оправдывает свою сложность только когда работа I/O-bound и конкурентна — фан-аут к другим сервисам, запросы к БД, записи в очередь, — а не CPU-bound вычисления, которые просто заблокируют loop. Набросай поток запроса (приём → валидация → стадии пайплайна → ответ), оцени конкурентность (целевой RPS, фан-аут на запрос, ожидаемое число задач в полёте) и выпиши SLO (например, p99 приёма < 100 мс, durability принятого payload) плюс явные non-goals, чтобы не тянуться к async там, где место потокам или process pool.
Критерии готовности- У тебя есть числа: целевой RPS, фан-аут на запрос и вытекающая оценка пиковых задач в полёте.
- Ты выписал 2–3 SLO и минимум два non-goal, включая один кусок работы, который ты намеренно держишь вне event loop.
- 02Построй ASGI-приём с валидацией Pydantic
Подними приложение FastAPI и сделай границу приёма строгой. Каждый payload проходит один валидационный шлюз: Pydantic-модель, которая приводит, ограничивает и отвергает, — так что некорректный вход падает на краю с 422, а не глубже в пайплайне. Держи обработчик запроса асинхронным и неблокирующим; единственный синхронный парсинг или хеширование большого тела тихо сериализует весь loop. Опиши модели запроса и ответа, чтобы контракт был явным, а форма ошибки — единообразной.
Критерии готовности- Невалидные payload отвергаются на границе с 422 и структурным телом ошибки, что подтверждено тестом.
- Обработчик приёма асинхронный, без блокирующих вызовов на горячем пути, и ты можешь указать, куда был бы вынесен любой CPU-тяжёлый шаг.
- 03Построй асинхронный пайплайн со структурной конкурентностью
Преврати провалидированный запрос в пайплайн конкурентных асинхронных стадий — обогащение, фан-аут к зависимостям, агрегация. Используй структурную конкурентность (task group / nursery), чтобы дочерние задачи принадлежали области: если одна падает, её соседи отменяются и ошибка пробрасывается, а не протекает осиротевшей задачей, которая досчитывается в темноте. Это этап, где разница между fire-and-forget и владеемой конкурентностью становится разницей между отлаживаемым сервисом и сервисом с привидениями.
Критерии готовности- Конкурентные стадии исполняются внутри task group, и принудительный сбой в одной стадии отменяет соседей, а не оставляет работающего сироту.
- Ты можешь показать тестом или счётчиком задач, что ни одна задача не переживает породивший её запрос.
- 04Сделай корректным по отмене: таймауты и shielding
Ограничь каждое внешнее ожидание и обрабатывай отмену честно. Оберни вызовы зависимостей потаймстейджными таймаутами, чтобы один медленный upstream не пришпиливал запрос — и задачу — навсегда. Главное: относись к CancelledError как к управлению потоком, а не ошибке: лови его только для очистки, затем перебрасывай, никогда не проглатывай. Там, где шаг обязан завершиться атомарно (закоммиченная запись, отпущенный lock), защити shielding ровно эту критическую секцию и ничего больше — избыточный shielding делает сервис, игнорирующий собственные дедлайны.
Критерии готовности- Каждый вызов зависимости имеет таймаут, и тест доказывает, что медленная стадия отменяется на своём дедлайне, а не зависает.
- Пути очистки при отмене перебрасывают CancelledError, и ровно одна критическая секция под shielding — ты можешь обосновать, почему именно она.
- 05Упакуй: pyproject, entry points, lock-файл
Сделай сервис устанавливаемым и воспроизводимым, а не папкой скриптов. Опиши проект в pyproject.toml с зафиксированными рантайм-зависимостями и entry point, запускающим сервер, затем разреши и закоммить lock-файл, чтобы каждая установка — твоя, CI, контейнера — получала ровно один и тот же граф зависимостей. Держи импортную поверхность чистой: ясная раскладка пакета означает отсутствие sys.path-хаков и сюрпризов от случайного модуля верхнего уровня, затеняющего зависимость.
Критерии готовности- Чистый checkout ставится из pyproject + lock-файла и стартует через объявленный entry point, без ручной возни с путями.
- Lock-файл закоммичен и пинит транзитивные зависимости, так что две установки с разницей в неделю резолвятся идентично.
- 06Контейнеризуй: лёгкий образ, PID 1, обработка сигналов
Помести сервис в контейнер, который завершается gracefully. Ловушка — PID 1: процесс Python, запущенный как PID 1, не получает дефолтных обработчиков сигналов ядра, так что наивный контейнер игнорирует SIGTERM, и оркестратор жёстко убивает его после grace-периода, теряя запросы в полёте на каждом деплое. Почини настоящим init (tini / exec-форма) или явной обработкой сигналов, затем заставь сервер дренироваться — перестать принимать, дать пайплайнам в полёте завершиться или упереться в дедлайн — до выхода. Собери лёгкий multi-stage образ от lock-файла, чтобы рантайм-слой нёс только то, что исполняет.
Критерии готовности- Отправка SIGTERM контейнеру запускает graceful-дренаж (запросы в полёте завершаются или отваливаются по таймауту), а не мгновенный жёсткий kill.
- Образ multi-stage и ставится из lock-файла, без оставленного в рантайм-слое сборочного тулчейна.
- 07Сделай читаемым: структурное логирование и конфигурация
Заставь работающий сервис сообщать тебе, что он делает. Снимай структурные (JSON) логи с request id, протянутым через каждую стадию, чтобы один медленный или упавший запрос был одним запросом, а не grep по перемешанным строкам от конкурентных задач. Вынеси конфигурацию — уровень логов, таймауты, URL зависимостей, число воркеров — через окружение, а не константы, чтобы один образ работал в dev и prod сменой входов, а не кода. Логируй события, важные для предстоящего инцидента: задача порождена, стадия истекла по таймауту, отмена замечена, дренаж начат.
Критерии готовности- Логи структурны и несут request id от и до, так что ты можешь восстановить полный путь одного запроса только по логам.
- Конфигурация приходит из окружения с разумными дефолтами, и один образ работает в двух конфигах без пересборки.
- 08Переживи голодание event loop, затем напиши пост-мортем
Под нагрузкой p99 латентности приёма растёт и не восстанавливается, хотя CPU выглядит простаивающим. Корневая причина — утечка отмены: одна стадия пайплайна ловит CancelledError и проглатывает его вместо переброса, так что задачи с истёкшим таймаутом на самом деле не умирают — они накапливаются, каждая держит соединение и слот, пока event loop не настолько забит задачами-зомби, что готовые корутины ждут своей очереди и всё голодает. Воспроизведи это нагрузочным тестом, форсирующим таймауты, посмотри, как число задач в полёте растёт без предела в твоих логах, затем почини перебросом CancelledError и подтверди, что число задач возвращается к базовой линии. Напиши пост-мортем: он обязан назвать механизм проглоченной отмены, а не просто «высокая латентность». Профилируй, если нужно доказать, куда ушло время loop.
Критерии готовности- Ты воспроизвёл утечку нагрузочным тестом, форсирующим таймауты, и зафиксировал неограниченный рост числа задач в полёте и подъём p99 в логах.
- Ты починил перебросом CancelledError (без проглатывания) и показал возврат числа задач и p99 к базовой линии под той же нагрузкой.
- Твой пост-мортем называет триггер, корневую причину проглоченной отмены, радиус поражения, фикс и одну превенцию, которая не «добавить воркеров».
Опирается наСамопроверка
Вставь корневую причину из пост-мортема и пункт превенции; senior-ревьюер проверяет, что назван механизм проглоченной отмены / утёкших задач (переброс CancelledError), а не только симптом (высокая латентность, простаивающий CPU).
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Граница валидации Pydantic и угроза блокировки loop | Payload'ы валидируются вручную через if/else или частично; тяжёлый разбор или хеширование на горячем пути выполняется синхронно и блокирует loop. | Pydantic-модель с глобальным ValidationPipe отвергает некорректный вход на краю с 422 и структурным телом ошибки; обработчик приёма асинхронный без блокирующих вызовов на горячем пути. | Граница валидации — единственная авторитетная истина о форме запроса, без повторной валидации глубже в пайплайне. Любой CPU-тяжёлый шаг (хеш большого тела, декодирование изображения) явно выгружается в thread pool через run_in_executor, и ты можешь указать единственное место для этого в коде. |
| Корректность отмены и структурная конкурентность | Задачи порождаются через asyncio.create_task без владения; упавшая стадия оставляет соседние задачи работать как сироты. CancelledError может молча поглощаться без переброса. | Конкурентные стадии выполняются внутри task group (TaskGroup или anyio nursery); упавшая стадия отменяет соседей. CancelledError ловится только для очистки и всегда перебрасывается. | Каждый вызов зависимости имеет пер-стейджный таймаут; ровно одна критическая секция защищена asyncio.shield с письменным обоснованием; нагрузочный тест, форсирующий таймауты, показывает возврат числа задач в полёте к базовой линии, а не рост без предела, что означало бы проглоченную отмену. |
| Backpressure, обработка сигналов PID-1 и корректный дренаж | Контейнер стартует с python app.py как PID 1; SIGTERM игнорируется, и оркестратор жёстко убивает процесс после grace-периода, теряя запросы в полёте. | PID-1 исправлен через tini или CMD в exec-форме; SIGTERM запускает корректный дренаж — сервер перестаёт принимать новые запросы и ждёт завершения пайплайнов в полёте или их дедлайна. | Число задач в полёте ограничено семафором, чтобы loop деградировал плавно под перегрузкой (503 + Retry-After), а не накапливал задачи-зомби, голодающие готовые корутины. Дедлайн дренажа короче grace-периода оркестратора, чтобы процесс выходил чисто до SIGKILL. |
| Диагностика голодания event loop и глубина пост-мортема | Пост-мортем описывает высокую латентность и простаивающий CPU, не называя, почему loop был заблокирован. | Пост-мортем определяет проглоченный CancelledError как корневую причину; фикс (переброс) применён и показан возврат числа задач в полёте к базовой линии. | Пост-мортем количественно оценивает радиус поражения (N задач-зомби на каждый форсированный таймаут при P RPS за T минут), называет превенцию, которая не «добавить воркеров», — например, lint-правило или тест, внедряющий CancelledError и проверяющий снижение числа задач, — и объясняет, почему симптом «CPU простаивает при растущем p99» — это отпечаток голодания loop, а не медленного обработчика. |
Эталонный разбор (спойлер)
Почему async оправдывает свою сложность только для I/O-bound работы: event loop однопоточный. Блокирующий вызов — синхронное чтение файла, CPU-тяжёлый разбор, time.sleep — стопорит каждую другую корутину на всё своё время. asyncio.to_thread / loop.run_in_executor — это выходы; использовать их намеренно — часть выбора async.
CancelledError — это управление потоком, а не исключение: проглотить его — самая частая ошибка async в Python. Стадия, ловящая CancelledError без переброса, держит задачу живой за границей отмены — task group никогда не завершается, таймауты не имеют эффекта, и задачи-зомби накапливаются, пока loop не начнёт голодать.
PID-1 важен при каждом деплое: процесс Python как PID-1 не наследует дефолтный обработчик SIGTERM ядра, поэтому сигнал корректного завершения оркестратора молча игнорируется и каждый rolling-деплой жёстко убивает запросы в полёте после grace-периода. tini или CMD в exec-форме восстанавливает дефолтное поведение сигналов без какой-либо стоимости.
Отпечаток голодания: простаивающий CPU с растущим p99 и растущим числом задач в полёте — это не медленный обработчик, а event loop, сжигающий свои кванты планирования на задачах-зомби, которые никогда не завершатся. Добавление воркеров не помогает; переброс CancelledError, чтобы задачи реально завершались, — помогает.
Сделай по-сеньорски
- Добавь backpressure: ограничь число задач в полёте семафором или очередью и сбрасывай нагрузку через 503 + Retry-After вместо неограниченной деградации loop.
- Вынеси единственную CPU-bound стадию из event loop в process pool и измерь улучшение латентности loop, доказав, что выбрал async только там, где он окупается.
- Запусти сервис в режиме multi-worker за менеджером процессов и явно рассуждай о поворкерном graceful-дренаже при rolling-деплое.
- Добавь синтетический chaos-таймаут, случайно отменяющий долю стадий в staging, чтобы будущая утечка отмены ловилась тестом, а не инцидентом.