
前言
在 Kubernetes 环境中部署 Python Web 应用时,"滚动更新期间偶发 502/504 错误"是最常见的痛点之一。这通常不是 K8s 本身的 Bug,而是应用未正确处理生命周期事件所致。本文将从 信号处理、连接池预热、探针配置、PreStop Hook 时序协同 四个维度,系统讲解如何让 Python 应用在 K8s 中实现真正的优雅启停,彻底消除滚动更新期间的请求丢失。
一、理解 K8s Pod 终止流程(关键基础)
在编写任何代码之前,必须精确理解 K8s 终止 Pod 的完整时序。以下为官方确认的执行顺序:
1. Pod 状态变为 Terminating
2. kubelet 执行 preStop hook(如果定义了)
3. preStop hook 完成后,kubelet 发送 SIGTERM 给容器主进程(PID 1)
4. 与此同时(与步骤2并行),Endpoints Controller 从 Service Endpoints 中移除该 Pod
5. kubelet 等待 terminationGracePeriodSeconds 倒计时结束
6. 若超时未退出,发送 SIGKILL 强制杀死
⚠️ 三个关键认知:
- preStop 在 SIGTERM 之前执行,不是并行。这是很多文章的常见误区。
- Endpoints 移除与 preStop/SIGTERM 是并行的,且 Endpoints 变更传播到所有节点的 kube-proxy/Envoy 需要 1-5s 延迟。
- preStop 的执行时间计入 terminationGracePeriodSeconds,不是额外时间。
二、Python 应用优雅停机实现
2.1 Uvicorn + FastAPI 的正确姿势
Uvicorn 自身已内置 SIGTERM 优雅停机支持:收到 SIGTERM 后会停止接受新连接,并等待正在处理的请求完成后再退出。因此,应用层不需要也不应该重复拦截 SIGTERM 来关闭服务器,而应专注于"业务级排空"和"就绪状态反馈"。
import signal
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
# 全局优雅停机标志
_shutting_down = False
_active_requests = 0
_request_lock = asyncio.Lock()
@asynccontextmanager
async def lifespan(app: FastAPI):
# === 启动阶段:预热连接池 ===
await warmup_connections()
logger.info("Application ready, connections warmed up.")
yield
# === 关闭阶段 ===
# Uvicorn 已自动处理 SIGTERM → 停止接受新连接 + 等待活跃请求
# 这里仅做业务级清理:关闭连接池、刷新缓冲等
logger.info("Cleaning up resources after Uvicorn drain...")
await close_connections()
logger.info("Graceful shutdown complete.")
def _handle_sigterm(signum, frame):
"""仅用于设置标志位,不干预 Uvicorn 自身的关停流程"""
global _shutting_down
_shutting_down = True
logger.info("SIGTERM received, marked as shutting down for readiness probe.")
# 在模块级别注册,确保在 Uvicorn worker 进程中生效
signal.signal(signal.SIGTERM, _handle_sigterm)
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def track_active_requests(request, call_next):
global _active_requests
async with _request_lock:
_active_requests += 1
try:
response = await call_next(request)
return response
finally:
async with _request_lock:
_active_requests -= 1
async def warmup_connections():
"""预热数据库/Redis/HTTP 连接池,避免首批请求冷启动"""
# 示例:SQLAlchemy async engine
# async with engine.begin() as conn:
# await conn.execute(text("SELECT 1"))
logger.info("Connection pool warmed up.")
async def close_connections():
"""关闭所有外部连接"""
# await engine.dispose()
# await redis.close()
logger.info("All connections closed.")
💡 为什么不在 lifespan 的 shutdown 阶段等待活跃请求? 因为 Uvicorn 在 SIGTERM 后已经自行等待活跃请求完成。lifespan 的
yield之后代码只在 Uvicorn 完成 drain 后才执行。如果在 lifespan 中再次等待,会造成不必要的额外延迟。
2.2 Gunicorn 用户的注意事项
如果使用 Gunicorn + Uvicorn worker,SIGTERM 由 Gunicorn master 接收,worker 收到的是 SIGQUIT(graceful shutdown)。推荐配置:
gunicorn app:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--graceful-timeout 25 \
--timeout 60
⚠️
--preload-app陷阱 :不要在 Gunicorn 中使用--preload-app来预热连接池。fork 后多个 worker 会共享父进程的 socket fd,导致连接池状态混乱、数据串扰。正确做法是让每个 worker 在 lifespan/startup 中独立初始化连接池。
三、Kubernetes 探针配置最佳实践
3.1 Readiness Probe(就绪探针)
作用:决定 Pod 是否接收流量。只有 Readiness 通过后,Endpoints 才会加入该 Pod。
readinessProbe:
httpGet:
path: /healthz/ready
port: 8000
initialDelaySeconds: 5 # 等待连接池预热完成
periodSeconds: 5
failureThreshold: 3 # 连续3次失败才标记 NotReady
successThreshold: 1
timeoutSeconds: 2
对应的端点实现:
@app.get("/healthz/ready")
async def readiness_check():
if _shutting_down:
return JSONResponse(status_code=503, content={"status": "shutting down"})
try:
# 可选:检查关键依赖连通性
# await check_db_connection()
return {"status": "ready"}
except Exception as e:
logger.error(f"Readiness check failed: {e}")
return JSONResponse(status_code=503, content={"status": "not ready"})
🔑 核心要点 :当
_shutting_down=True时,Readiness 必须返回 503 。这是 preStop Hook 之外的兜底保障------即使 Endpoints 传播有延迟,下一次探针失败也会加速摘除。
3.2 Liveness Probe(存活探针)
作用 :检测应用是否死锁/卡死。切勿将依赖检查放入 Liveness!
livenessProbe:
httpGet:
path: /healthz/live
port: 8000
initialDelaySeconds: 15 # 给足启动时间,避免启动慢被误杀
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
@app.get("/healthz/live")
async def liveness_check():
# 仅检查进程本身是否存活,不检查外部依赖
return {"status": "alive"}
3.3 Startup Probe(启动探针,推荐)
对于启动慢的应用(如加载 ML 模型),避免 Liveness 在启动期间误杀:
startupProbe:
httpGet:
path: /healthz/live
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30 # 最多允许 5+30*5=155s 启动时间
Startup Probe 成功前,Liveness 和 Readiness 不会生效。
四、Deployment 关键配置与时序协同
spec:
terminationGracePeriodSeconds: 60
template:
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
PreStop + SIGTERM + Readiness 三者协同原理
| 时间线 | 事件 | 目的 |
|---|---|---|
| T+0s | Pod 进入 Terminating,Endpoints 开始异步移除 | 流量逐步停止路由到此 Pod |
| T+0s | preStop sleep 5 开始执行 |
等待 Endpoints 变更传播到所有节点 |
| T+5s | preStop 完成,SIGTERM 发送给 PID 1 | Uvicorn 停止接受新连接,开始 drain |
| T+5s | Readiness 返回 503(兜底) | 加速 Endpoints 摘除 |
| T+5~30s | Uvicorn 等待活跃请求完成 | 飞行请求正常响应 |
| T+30s | lifespan shutdown 执行,关闭连接池 | 资源清理 |
| T+60s | 若仍未退出,SIGKILL 强杀 | 安全上限 |
📐 时间预算公式 :
terminationGracePeriodSeconds≥preStop.sleep+uvicorn_drain_timeout+cleanup_time+buffer例如:5 + 25 + 5 + 5 = 40s → 设置为 60s 留有余量⚠️ 注意 :preStop 的执行时间包含在 terminationGracePeriodSeconds 内,不是额外的!
五、验证清单
| 检查项 | 验证方法 |
|---|---|
| SIGTERM 后不再接受新请求 | kubectl delete pod 同时压测,观察是否有 502 |
| 飞行请求正常完成 | 发起长请求(sleep 10s),期间删除 Pod,确认响应 200 |
| Readiness 在关停时返回 503 | 手动 curl /healthz/ready 在 SIGTERM 后 |
| 连接池预热完成才 Ready | 观察 Pod Ready 时间与日志中 warmup 完成时间一致 |
| Liveness 不因外部故障触发重启 | 断开 DB,确认 Pod 不被 restart |
| preStop 生效且时间充足 | 查看 Pod events 中 PreStop hook 执行记录及耗时 |
| 无 SIGKILL 强杀 | 检查 Pod 终止日志,确认无 "Container was killed" 事件 |
六、常见陷阱总结
- 在 Liveness 中检查 DB/Redis → 依赖抖动导致无限重启循环
- SIGTERM 后立即退出或手动关闭端口 → 与 Uvicorn 原生行为冲突,飞行请求被 RST
- 未设置 preStop → Endpoints 传播延迟导致新请求打到已关闭的 Pod
- initialDelaySeconds 过短 → 应用未预热就被判定 Unhealthy
- Gunicorn
--preload-app预热连接池 → fork 后多 worker 共享 socket fd,数据串扰 - terminationGracePeriodSeconds 未包含 preStop 时间 → 实际 drain 时间被压缩,被 SIGKILL 强杀
- Readiness 未在 SIGTERM 后返回 503 → 失去兜底保障,Endpoints 摘除变慢
- 在 lifespan shutdown 中重复等待活跃请求 → Uvicorn 已处理,造成不必要延迟
结语
优雅启停不是一个单点配置,而是 应用代码 + K8s YAML + 基础设施时序 三者协同的系统工程。本文提供的方案已在多个生产环境验证,可将滚动更新期间的请求丢失率降至 0%。建议将其纳入团队的 K8s 部署 Checklist,作为 Python 服务上线的标准基线。
参考资料: