Когда твой сервис работает под k8s, рано или поздно придёт SIGTERM. Или ты сам его пошлёшь при деплое. Если не подготовиться — пользователи увидят 502, а фоновые задачи просто исчезнут.
Graceful shutdown — это не про SIGKILL. Это про то, чтобы сервис перестал принимать новое, дал время доделать текущее и только потом умер.
1. Ловим сигналы ОС
Берём SIGTERM или SIGINT. Сделать это в asyncio можно так:
import asyncio, signal
async def shutdown(sig, loop):
tasks = [t for t in asyncio.all_tasks()
if t is not asyncio.current_task()]
[task.cancel() for task in tasks]
await asyncio.gather(*tasks, return_exceptions=True)
loop.stop()
loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(
sig, lambda s=sig: asyncio.create_task(shutdown(s, loop)))
Типичная ошибка — ожидать, что
loop.add_signal_handler решит всё за тебя. Для in-flight запросов этого мало: фоновые задачи с долгим циклом просто отменятся, а не завершатся.2. Health-check без блокировки
Как только пришёл сигнал, health-check должен показать "не готов". Иначе k8s продолжит слать трафик, пока не убьёт контейнер принудительно.
async def health_handler(request):
return web.Response(
text="OK" if app['is_healthy'] else "Stopping")
async def on_shutdown(app):
app['is_healthy'] = False
Только флаг — никаких блокирующих проверок. Тrade-off: быстрый ответ против точного отражения состояния. Для production-реалий это оправдано.
3. Draining in-flight запросов
Используй счётчик с
asyncio.Lock. Каждый обработчик увеличивает счётчик при старте и уменьшает после завершения. В shutdown’е жди, пока счётчик не станет 0:active_requests = 0
lock = asyncio.Lock()
async def handle_request(request):
async with lock:
active_requests += 1
try:
pass # твой код
finally:
async with lock:
active_requests -= 1
async def wait_for_drain():
while True:
async with lock:
if active_requests == 0:
break
await asyncio.sleep(0.5)
Но если запрос завис на 10 минут, сервис будет висеть. Тут нужен таймаут.
4. Таймаут на завершение
async def graceful_shutdown(timeout=30):
try:
await asyncio.wait_for(wait_for_drain(), timeout=timeout)
except asyncio.TimeoutError:
print("Drain timeout, force stop")
30 секунд — типичное значение для k8s. Если не успели, пусть оркестратор решает. Лучше потерять пару запросов, чем висеть вечно. Практический совет: настрой
terminationGracePeriodSeconds в манифесте с запасом на 5-10 секунд.Вывод: Safe-stopping — это тройной механизм: флаг health-check, ожидание дампа активных соединений и таймаут принудительного выхода, который защищает от зависания сервиса.