AI智能体服务通常依赖长连接维持对话上下文,比如SSE流式输出、WebSocket会话、gRPC双向流等。这类服务在做滚动更新时,经常出现一个尴尬的现象:Pod明明还在正常运行,客户端却源源不断地收到503 Service Unavailable。追根究底,问题往往不是服务代码有bug,而是发布流程没有处理好“旧实例退出”与“新实例就绪”之间的衔接。本文围绕这个问题展开,给出一套经过生产验证的零停机方案。

503错误在滚动更新中是怎么产生的
要解决问题,得先弄清楚503从哪里来。滚动更新的标准流程是:先起新Pod,等新Pod就绪后,再逐步杀掉旧Pod。503意味着请求到达了某个代理层(负载均衡器、Ingress Controller或API网关),但代理层找不到健康的后端可以转发。
典型场景有三类。第一种是新旧实例青黄不接:新Pod启动慢,比如AI智能体服务要加载大模型权重、预热推理引擎,动辄需要一两分钟,而滚动更新策略配置的maxUnavailable为1,导致旧Pod先被杀,新Pod还没就绪,流量无路可走。第二种是注册中心与实际状态不同步:旧Pod收到SIGTERM后立刻从注册中心摘除,但负载均衡层缓存的旧节点还没过期,请求继续打到已关闭的实例上,返回503。第三种是长连接被粗暴切断:Agent服务的SSE流式对话正在进行中,Pod被删除,连接中断,如果客户端的重试逻辑不完善,就会把中断暴露为503。
排查时可以看Ingress或网关的访问日志,重点观察503发生的时间点是否与Deployment的rollout时间重合,同时对比Pod的事件记录中“Killing container”的时间戳。如果时间吻合,基本可以确认是发布流程问题而非服务容量问题。
Kubernetes层面的优雅关闭配置
解决503的核心思路是让旧Pod在退出前“善后”:先停止接收新流量,再等存量请求处理完,最后才真正退出。Kubernetes提供了两个关键机制:preStop钩子和terminationGracePeriodSeconds。
下面是一份针对AI智能体服务的完整Deployment配置,重点看容器生命周期部分:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-agent-service
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 先多起一个新实例,保证容量不下降
maxUnavailable: 0 # 严格禁止有实例不可用,这是避免503的关键
template:
spec:
terminationGracePeriodSeconds: 120 # 给长对话流留足排空时间
containers:
- name: agent
image: registry.ippipp.com/ai-agent:v2.3.0
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
这里有几个细节值得展开。maxUnavailable设为0意味着更新过程中永远先扩容再缩容,容量零损失;sleep 15秒的preStop看起来简单,作用却很大——Pod被标记为Terminating时,Endpoint从Service的转发列表中摘除是异步的,存在短暂延迟,这15秒就是等各个负载均衡层感知到节点下线,避免请求继续打到这个即将退出的实例。
terminationGracePeriodSeconds要根据Agent业务调整。如果服务中存在平均耗时30秒以上的长对话流,这个值要大于最长对话的剩余处理时间。对于SSE场景,更优雅的做法是在应用层实现排空逻辑:收到SIGTERM后,停止接受新会话,对已有会话继续服务直到自然结束或超时,然后再关闭HTTP服务器。
应用层与网关层的配合
光靠Kubernetes配置还不够,应用代码要正确响应SIGTERM信号。很多AI智能体服务用Python的FastAPI或uvicorn开发,默认行为是收到信号后立即停止接收连接,但不会等待正在执行的推理任务完成。需要显式配置优雅关闭:
import asyncio
import signal
from contextlib import asynccontextmanager
from fastapi import FastAPI
active_streams = set() # 记录进行中的流式对话
@asynccontextmanager
async def lifespan(app: FastAPI):
# 注册信号处理
loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, handle_shutdown)
yield
# 排空阶段:等待所有活跃流结束,最长等90秒
for task in active_streams:
try:
await asyncio.wait_for(task, timeout=90)
except asyncio.TimeoutError:
task.cancel()
def handle_shutdown():
# 标记服务不再接受新请求,健康检查立即返回失败
app.state.draining = True
app = FastAPI(lifespan=lifespan)
@app.get("/health/ready")
async def ready():
# 排空期间主动报告未就绪,让LB尽快摘除本实例
if getattr(app.state, "draining", False):
from fastapi import HTTPException
raise HTTPException(status_code=503)
return {"status": "ok"}
这段代码的关键在于让就绪检查和排空状态联动:一旦开始排空,/health/ready立刻返回503,Kubernetes会自动把该Pod从Endpoints摘除,比等待preStop更精准。同时活跃的流式对话被继续服务,客户端完全无感知。
最后是网关层。如果使用Nginx Ingress,建议开启长连接相关配置,并确保proxy_next_upstream允许在连接失败时重试,这样即使个别连接被切断,请求也能被转发到其他健康实例。以下配置供参考:
upstream ai_agent {
least_conn;
server 10.0.1.11:8080 max_fails=2 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=2 fail_timeout=10s;
keepalive 64;
}
server {
listen 443 ssl;
location /v1/chat {
proxy_pass http://ai_agent;
proxy_http_version 1.1;
proxy_set_header Connection "";
# 连接失败或503时自动重试下一台后端
proxy_next_upstream error timeout http_503;
proxy_next_upstream_tries 2;
proxy_read_timeout 300s; # 长对话流需要较长的读超时
}
}
验证方案效果
配置完成后不要直接上生产,先做一次发布演练。常用做法是用压测工具持续发请求,同时触发滚动更新,统计整个过程中的错误率:
# 用vegeta以每秒50个请求持续压测,同时另开终端执行 kubectl rollout restart echo "GET https://api.ipipp.com/v1/chat" | vegeta attack -rate=50 -duration=5m | vegeta report
理想结果是整个rollout过程中非2xx响应数为零。如果仍出现零星503,按顺序检查:readinessProbe的initialDelaySeconds是否足够模型加载、preStop的sleep时间是否覆盖了Endpoints同步延迟、terminationGracePeriodSeconds是否小于最长请求耗时。另外建议在Agent服务的启动脚本中加入预热逻辑,比如启动时先用一条测试消息跑通推理链路,再通过就绪检查,这样新实例一旦接流量就是热状态,不会因为冷启动响应慢被误判为故障。
零停机不是单点配置能实现的,它需要编排层、应用层、网关层三方协同。把这套方案落地后,AI智能体服务的版本发布就从“运维事故高发时刻”变成了一个可以随时执行、用户完全无感的常规操作。