普通 HTTP 服务可以在停止接收新流量后等待请求结束,但 LLM 的长时流式生成让这一过程复杂得多。Pod 收到终止信号时,尚未完成的内容、客户端重试和 token 计费都可能进入不一致状态。要避免更新期间集中断流,需要把请求排空、状态追踪与续传协议作为一套完整机制设计。
我们的 LLM 服务在一次 K8s 滚动更新后,用户侧出现大批"生成中断"投诉。排查日志发现:新版本 Pod 就绪后,旧 Pod 收到 SIGTERM,直接退出——所有正在 streaming 的请求瞬间断流,返回 503。
这不是偶发问题。只要你的 LLM 服务:
就一定会遇到这个问题。
这篇文章把我们踩过的坑和最终落地的方案全部写出来。
普通 HTTP 服务的 graceful shutdown 已有成熟方案:等待 in-flight 请求完成,超时后强制退出。但 LLM streaming 请求有三个特殊性:
1. 请求时长不可预测
生成 100 token 需要 3 秒,生成 4000 token 需要 2 分钟。你不知道一个请求要多久结束。设 drain window 多少合适?30 秒打断一半请求,300 秒让 K8s 等到超时。
2. 部分响应有价值,也有风险
普通 API 要么成功要么失败,没有中间态。LLM streaming 的中间态是已生成的 token 序列——它有价值(用户已看到 1000 字),但截断的文本可能语义不完整,客户端需要知道"这是意外中断"而非"正常结束"。
3. 计费状态需要持久化
LLM 调用通常按 token 计费。如果请求在生成 3000 个 output token 后中断,这 3000 个 token 已经在 provider 侧扣费,但你的计费系统可能没有记录。重试会再扣一次。
下面分 4 层讲解解法。
最基础的一层。Node.js / Python 应用默认不处理 SIGTERM,进程直接退出。
// shutdown.ts
import { Server } from 'http';
const DRAIN_TIMEOUT_MS = process.env.DRAIN_TIMEOUT_MS
? parseInt(process.env.DRAIN_TIMEOUT_MS)
: 120_000; // 2 分钟
export function setupGracefulShutdown(server: Server) {
let isShuttingDown = false;
// K8s 发 SIGTERM,给足够时间 drain
process.on('SIGTERM', async () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`[shutdown] SIGTERM received. Drain window: ${DRAIN_TIMEOUT_MS}ms`);
// 1. 停止接收新连接
server.close();
// 2. 等待 in-flight 请求完成,或超时
const drainStart = Date.now();
await waitForInflightRequests(drainStart);
console.log('[shutdown] Drain complete. Exiting.');
process.exit(0);
});
// Liveness probe:shutdown 开始后返回 503,让 K8s 不再把流量路由进来
server.on('request', (req, res) => {
if (isShuttingDown && req.url === '/healthz') {
res.writeHead(503);
res.end('shutting down');
}
});
}
# deployment.yaml
spec:
template:
spec:
terminationGracePeriodSeconds: 180 # 必须 > DRAIN_TIMEOUT_MS
containers:
- name: llm-server
lifecycle:
preStop:
exec:
# 给 kube-proxy 时间更新 iptables,避免 SIGTERM 和流量同时到达
command: ["/bin/sleep", "5"]
terminationGracePeriodSeconds 是 K8s 给 Pod 的总宽限时间,必须大于你的 drain timeout,否则 K8s 会在 drain 结束前发 SIGKILL。
光有 drain window 不够——你需要知道"现在有多少个 LLM 请求还在跑",才能决定什么时候可以退出。
// inflight-tracker.ts
export class InflightTracker {
private requests = new Map<string, {
startedAt: number;
model: string;
estimatedTokens?: number;
abortController: AbortController;
}>();
register(requestId: string, model: string, abortController: AbortController) {
this.requests.set(requestId, {
startedAt: Date.now(),
model,
abortController,
});
}
complete(requestId: string) {
this.requests.delete(requestId);
}
get count() {
return this.requests.size;
}
// 超过 maxAgeMs 的请求视为挂起,强制中止
abortStale(maxAgeMs: number) {
const now = Date.now();
for (const [id, req] of this.requests) {
if (now - req.startedAt > maxAgeMs) {
console.warn(`[tracker] Aborting stale request ${id} (age: ${now - req.startedAt}ms)`);
req.abortController.abort('shutdown-stale');
this.requests.delete(id);
}
}
}
abortAll(reason: string) {
for (const [id, req] of this.requests) {
console.log(`[tracker] Aborting in-flight request ${id} (reason: ${reason})`);
req.abortController.abort(reason);
}
this.requests.clear();
}
}
export const inflightTracker = new InflightTracker();
在 drain window 里轮询:
async function waitForInflightRequests(drainStart: number) {
while (inflightTracker.count > 0) {
const elapsed = Date.now() - drainStart;
if (elapsed >= DRAIN_TIMEOUT_MS - 10_000) {
// 最后 10 秒:中止所有剩余请求,发送 shutdown token
console.warn(`[shutdown] Drain timeout approaching. Aborting ${inflightTracker.count} requests.`);
inflightTracker.abortAll('shutdown');
break;
}
console.log(`[shutdown] Waiting for ${inflightTracker.count} in-flight requests (elapsed: ${elapsed}ms)`);
await new Promise(r => setTimeout(r, 2000));
}
}
最核心的一层,也是最容易被忽视的。
当 drain window 到期时,你不能直接关闭 SSE 连接——客户端会以为是网络故障,不知道该不该重试。你需要在 SSE 流的最后发一个特殊的 shutdown token,告诉客户端"我要停了,你可以从断点续传"。
// sse-handler.ts
import { inflightTracker } from './inflight-tracker';
export async function handleStreamRequest(req: Request, res: Response) {
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
const abortController = new AbortController();
// 注册到 tracker
inflightTracker.register(requestId, req.body.model, abortController);
// 设置 SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Request-Id', requestId);
// abort 信号(来自 drain window 超时)
abortController.signal.addEventListener('abort', () => {
const reason = abortController.signal.reason;
// 发送 shutdown token,携带断点信息
const checkpointData = {
type: 'shutdown',
reason,
requestId,
// 已生成的 token 数,用于客户端决策
generatedTokens: tokenCounter.get(requestId) || 0,
// 客户端可用此 ID 发起续传请求
resumeToken: generateResumeToken(requestId),
timestamp: Date.now(),
};
res.write(`data: ${JSON.stringify(checkpointData)}nn`);
res.end();
console.log(`[sse] Sent shutdown token for ${requestId}`);
});
try {
// 正常流式生成
for await (const chunk of llmClient.streamGenerate(req.body, {
signal: abortController.signal,
})) {
if (res.destroyed) break;
res.write(`data: ${JSON.stringify(chunk)}nn`);
tokenCounter.increment(requestId);
}
// 正常结束
res.write('data: [DONE]nn');
res.end();
} catch (e) {
if (e.name === 'AbortError') {
// abort 已通过 signal 事件处理,这里不重复发
} else {
res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}nn`);
res.end();
}
} finally {
inflightTracker.complete(requestId);
}
}
// client.ts
async function* streamGenerate(prompt: string, options: {
resumeToken?: string;
previousContent?: string;
} = {}) {
const response = await fetch('/api/generate', {
method: 'POST',
body: JSON.stringify({
prompt,
resumeToken: options.resumeToken, // 告诉服务端这是续传
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') return;
const event = JSON.parse(data);
// 关键:识别 shutdown token
if (event.type === 'shutdown') {
console.log(`[client] Server shutting down. Resume token: ${event.resumeToken}`);
// 自动重试,携带续传上下文
yield* streamGenerate(prompt, {
resumeToken: event.resumeToken,
previousContent: options.previousContent,
});
return;
}
yield event;
}
}
}
这是最容易丢数据的地方。LLM 调用按 token 计费,如果 shutdown 时没有及时记录已消耗的 token,会出现两种问题:
// token-billing.ts
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
export async function recordTokenUsage(params: {
requestId: string;
userId: string;
model: string;
promptTokens: number;
completionTokens: number;
isPartial: boolean; // shutdown 中断时为 true
resumeToken?: string;
}) {
// 幂等键:同一个 requestId 只写入一次
const idempotencyKey = `billing:${params.requestId}`;
const exists = await redis.exists(idempotencyKey);
if (exists && !params.isPartial) {
// 已有完整记录,不覆盖
return;
}
// 原子性写入,用 MULTI/EXEC 避免部分写入
await redis.multi()
.hSet(`billing:record:${params.requestId}`, {
userId: params.userId,
model: params.model,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
isPartial: params.isPartial ? '1' : '0',
resumeToken: params.resumeToken || '',
recordedAt: Date.now(),
})
.expire(`billing:record:${params.requestId}`, 86400 * 7) // 7 天 TTL
.set(idempotencyKey, '1', { EX: 86400 * 7 })
.exec();
// 续传时,需要从 resumeToken 找到原始 requestId,扣除重复的 prompt token
if (params.resumeToken) {
const originalRequestId = await getOriginalRequestId(params.resumeToken);
if (originalRequestId) {
await deductDuplicatePromptTokens(originalRequestId, params.promptTokens);
}
}
}
我们在压测环境用 100 并发模拟 K8s 滚动更新,对比了两种配置:
| 指标 | 无 Graceful Shutdown | 有 Graceful Shutdown |
|---|---|---|
| 中断请求数 / 次更新 | ~40 个 | 0 个(drain window 内) |
| 客户端 503 率 | 12.3% | 0.1%(仅极端慢请求) |
| Token 计费丢失率 | 8.7% | 0.02% |
| 滚动更新耗时 | 45s | 165s(含 drain window) |
| 用户可感知中断 | 高频 | 极低 |
耗时增加了,但用户体验和计费准确性都大幅提升。对于 LLM 服务,这个代价是值得的。
坑 1:preStop hook 不够长
K8s 的 preStop 阶段和 terminationGracePeriodSeconds 是并行计时的,不是串行的。很多人以为 preStop sleep 5 + terminationGracePeriodSeconds 180 = 185 秒,实际上 Pod 只有 180 秒,preStop sleep 5 会占用其中 5 秒。
坑 2:Nginx / Envoy 在 Pod 前面先关闭
如果你用 Nginx sidecar 做 SSL 终止,Nginx 收到 SIGTERM 可能比你的 LLM server 先关闭,导致已建立的连接也断了。需要给 Nginx 配置 worker_shutdown_timeout 大于你的 drain window。
worker_shutdown_timeout 130s; # 比 drain window 略大
坑 3:SSE 连接被 ALB/CLB 强制超时
AWS ALB 默认 idle timeout 60 秒,LLM streaming 可能超过。需要:
// 每 30 秒发一个 keep-alive
const keepAlive = setInterval(() => {
if (!res.destroyed) res.write(': keepalivenn');
}, 30_000);
// 完成时清除
onComplete(() => clearInterval(keepAlive));
坑 4:drain window 过长导致 K8s 反复驱逐
如果 drain window 设为 300 秒,K8s 在资源紧张时可能把这个 Pod 标记为"响应过慢"并强制驱逐。建议配合 PodDisruptionBudget 限制同时下线的 Pod 数量:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: llm-server-pdb
spec:
minAvailable: "50%"
selector:
matchLabels:
app: llm-server
┌─────────────────────────────────────────────────────────────┐
│ Graceful Shutdown 四层 │
├─────────────────────────────────────────────────────────────┤
│ 第 1 层 SIGTERM 处理 + Drain Window │
│ terminationGracePeriodSeconds > drainTimeout │
│ preStop sleep 5 给 kube-proxy 时间 │
├─────────────────────────────────────────────────────────────┤
│ 第 2 层 In-Flight 追踪 │
│ Map<requestId, AbortController> │
│ drain 结束前中止剩余请求 │
├─────────────────────────────────────────────────────────────┤
│ 第 3 层 Shutdown Token + 客户端续传 │
│ SSE 最后发 { type: 'shutdown', resumeToken } │
│ 客户端自动续传,对用户无感知 │
├─────────────────────────────────────────────────────────────┤
│ 第 4 层 计费状态持久化 │
│ 幂等写入,续传时扣除重复 prompt token │
└─────────────────────────────────────────────────────────────┘
LLM 应用的 graceful shutdown 难点不在于"停进程",而在于:
生产中我们配置的值:DRAIN_TIMEOUT_MS=120000,terminationGracePeriodSeconds=180,ALB idle timeout=600。这组配置在 99% 的场景下能让正在进行的请求正常结束,只有极少数 2 分钟以上的超长生成会触发 shutdown token 续传。
代码已经能直接跑,有问题欢迎在评论区讨论。