Graceful shutdown trong container: đừng để SIGTERM làm rớt request — hoatq.dev

cat blog/.md

Graceful shutdown trong container: đừng để SIGTERM làm rớt request

date: tags: backend, kubernetes, docker, devops, reliability

Có một kiểu bug rất khó chịu: nó không xảy ra lúc code, mà xảy ra lúc deploy. Mỗi lần rolling update là một nhóm user nhận 502, vài order bị double-charge vì client retry, vài background job chết giữa chừng. Log thì sạch, healthcheck thì xanh. Nhưng cứ deploy là rụng request.

Hồi đó tôi mất gần một tuần để truy nguyên — và gốc rễ đơn giản đến mức khó tin: container không xử lý SIGTERM đúng cách. Bài này là những gì tôi rút ra sau vài lần bị khách phản ánh “deploy là em bị lỗi”.

Chuyện gì xảy ra khi Kubernetes kill pod

Khi bạn rolling update một Deployment, K8s thực hiện một chuỗi bước cho pod cũ:

  1. Endpoint bị xóa khỏi Service — nhưng việc này bất đồng bộ với bước 2.
  2. preStop hook được chạy (nếu có).
  3. SIGTERM được gửi vào PID 1 của container.
  4. Đợi terminationGracePeriodSeconds (mặc định 30s).
  5. Nếu vẫn còn chạy, SIGKILL — không thương tiếc.

Vấn đề nằm ở bước 1 và 3 chạy gần như song song. Kube-proxy trên từng node cần vài trăm ms đến vài giây để cập nhật iptables/IPVS, trong khi SIGTERM đã được gửi ngay. Tức là trong window đó, vẫn có request mới đang bay vào pod “sắp chết”.

Nếu app của bạn nhận SIGTERM xong là exit(0) ngay, những request đang dở sẽ bị cắt, request mới không kịp xử lý.

Ba lỗi phổ biến

Lỗi 1: PID 1 không phải app của bạn. Khi Dockerfile dùng CMD ["sh", "-c", "python main.py"], PID 1 là sh, mà sh không forward signal cho child. SIGTERM rơi vào khoảng không. K8s đợi đủ 30s rồi SIGKILL. Fix: dùng exec form CMD ["python", "main.py"], hoặc thêm tini làm init.

Lỗi 2: App thoát ngay khi nhận SIGTERM. Không đợi in-flight request xong, không đóng DB connection, không flush log. Request mất, dữ liệu mồ côi.

Lỗi 3: Không có preStop delay. App shutdown đúng chuẩn nhưng vẫn nhận request mới trong lúc endpoint chưa được xóa. Cần một khoảng delay nhỏ để kube-proxy kịp cập nhật.

Làm đúng với FastAPI + Uvicorn

Uvicorn hỗ trợ graceful shutdown sẵn: khi nhận SIGTERM, nó đóng listener (không nhận connection mới), đợi các request đang xử lý xong, rồi exit. Nhưng bạn cần chắc PID 1 đúng là Uvicorn.

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Exec form — Uvicorn là PID 1, nhận trực tiếp SIGTERM
CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--timeout-graceful-shutdown", "25"]

--timeout-graceful-shutdown 25 cho Uvicorn tối đa 25 giây đợi request xong trước khi force close. Con số này phải nhỏ hơn terminationGracePeriodSeconds của K8s (30s), chừa buffer cho cleanup cuối.

Nếu bạn có logic cleanup riêng (flush metrics, close Redis pool…), dùng lifespan event:

from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    app.state.redis = await create_redis_pool()
    yield
    # Shutdown — chạy khi Uvicorn nhận SIGTERM
    await app.state.redis.close()
    await flush_pending_metrics()

app = FastAPI(lifespan=lifespan)

Thêm preStop delay ở K8s

Đây là mảnh ghép nhiều team bỏ sót. Thêm vào Deployment:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "5"]

5 giây sleep cho kube-proxy trên mọi node cập nhật endpoint xong. Trong 5s này, pod vẫn nhận request bình thường (endpoint mới bị xóa dần). Sau preStop, SIGTERM mới được gửi, Uvicorn bắt đầu shutdown trong lúc không còn request mới nữa.

Sequence hoàn chỉnh:

t=0s   : K8s remove endpoint + run preStop
t=0s   : preStop "sleep 5" bắt đầu
t=~2s  : kube-proxy đã cập nhật xong, không còn traffic mới
t=5s   : preStop kết thúc, K8s gửi SIGTERM
t=5s   : Uvicorn đóng listener, đợi in-flight xong
t=~10s : request cuối cùng xong, Uvicorn exit(0)

Không mất request. Không 502. Không alert 3h sáng.

Worker và background job

Với worker (Celery, RQ, hay custom loop), pattern khác chút: bạn cần chủ động handle signal.

import signal
import asyncio

class Worker:
    def __init__(self):
        self._shutdown = asyncio.Event()

    async def run(self):
        loop = asyncio.get_running_loop()
        for sig in (signal.SIGTERM, signal.SIGINT):
            loop.add_signal_handler(sig, self._shutdown.set)

        while not self._shutdown.is_set():
            job = await self.fetch_job(timeout=1)
            if job is None:
                continue
            await self.process(job)  # process xong mới loop tiếp

        await self.cleanup()

Điểm then chốt: sau khi _shutdown.set(), worker vẫn xử lý cho xong job đang chạy rồi mới thoát. Không giết job giữa chừng.

Nếu job chạy lâu hơn grace period (ví dụ 5 phút mà K8s chỉ cho 30s), bạn có hai lựa chọn:

  • Tăng terminationGracePeriodSeconds lên đủ dài (K8s cho phép đến vài phút).
  • Thiết kế job idempotent + có checkpoint, để lần retry tiếp theo tiếp tục từ đúng chỗ.

Cá nhân tôi luôn ưu tiên option 2 — vì kể cả K8s không kill, node vẫn có thể crash bất kỳ lúc nào.

Kiểm tra xem có đúng không

Cách đơn giản nhất: mở hai terminal, một cái bắn load test liên tục, một cái kubectl rollout restart. Đếm 5xx trong khoảng rolling.

# Terminal 1
hey -z 60s -c 50 https://api.example.com/healthz

# Terminal 2
kubectl rollout restart deployment/api

Với setup đúng, tỉ lệ 5xx phải là 0. Nếu có, kiểm tra theo checklist:

  • PID 1 có đúng là process app không? (kubectl exec pod -- ps -1)
  • App có log “shutdown starting” khi nhận SIGTERM không?
  • preStop hook có tồn tại?
  • timeout-graceful-shutdown của server < terminationGracePeriodSeconds?

Những thứ dễ quên

Database connection. Nếu bạn dùng pool (SQLAlchemy, asyncpg), đóng pool ở shutdown event. Không đóng thì connection treo ở DB server đến khi timeout — vài phút sau mới free, ảnh hưởng pod mới.

Health check vs readiness. Readiness probe nên fail ngay khi app bắt đầu shutdown (một số team set một flag shutting_down=True). Liveness probe thì vẫn OK — vì app vẫn sống, chỉ là không nhận request mới. Lẫn hai cái là K8s sẽ restart pod giữa chừng shutdown.

Load balancer bên ngoài. Nếu bạn có ALB/NLB phía trước, nó cũng có connection draining riêng. Config cả hai tầng phải khớp nhau, không thì LB vẫn route request vào pod đã chết.

Tóm lại

Graceful shutdown không phải là “tính năng” mà là một chuỗi hợp đồng giữa app, container runtime, và orchestrator. Chỉ thiếu một mắt xích là rớt request. Checklist ngắn tôi luôn kiểm lúc review deployment mới:

  • CMD exec form, PID 1 là app (hoặc tini)
  • Server có graceful shutdown timeout nhỏ hơn grace period
  • preStop có sleep 3-5s để kube-proxy kịp cập nhật
  • Shutdown hook đóng DB pool, Redis, flush log
  • Worker có signal handler chủ động, job idempotent
  • Load test trong lúc rolling deploy → 0 lỗi 5xx

Deploy mà không rớt request là chuyện nhỏ, nhưng thiếu nó thì mọi retry-budget và SLA đều vô nghĩa.

// reactions


cat comments.log


hoatq@dev : ~/blog $