线上一个 Next.js SSR 应用,最近一周偶发个位数 Pod 重启------低频、间歇,看起来人畜无害。第一反应几乎是条件反射:内存泄漏、OOM 吧?于是查堆、查 cgroup limit、翻 process.memoryUsage(),折腾半天,内存一切正常。
最后真相是:根本不是内存问题,是 K8s 探针在宿主机高负载时「误杀」了健康的应用。这件事逼我把一个一直没想清楚的问题理顺了------K8s 探针和应用的健康检查到底各自该管什么,以及它和 Next.js 的不同部署模式怎么配合。这篇就把这套实践讲清楚。
先用大白话讲清三种探针
K8s 有三种探针,新手最容易把它们混成一团「健康检查」,但它们的失败后果完全不同:
| 探针 | 回答的问题 | 失败后果 |
|---|---|---|
| liveness(存活) | 进程还活着吗 | 重启容器(代价最高) |
| readiness(就绪) | 现在能接流量吗 | 从 Service 摘流(不杀,温和) |
| startup(启动) | 启动完成了吗 | 没完成前不跑上面两个 |
一个好记的类比:liveness 是「还有脉搏吗」------没脉搏就抢救(重启);readiness 是「现在能上班吗」------不能就先别派活,但人还在工位。记住这个差别是后面一切的基础:liveness 失败会重启,readiness 失败只摘流。
最该先想清楚的:健康检查是「两层职责」
绝大多数踩坑,都源于把下面这两层混为一谈:
| 层 | 谁负责 | 具体内容 |
|---|---|---|
| 端点逻辑(探针返回什么) | 应用 | 只有应用知道自己「启动完没」「依赖好没」「是不是在关闭中」 |
| 调度策略(怎么探、多容忍) | K8s | periodSeconds / timeoutSeconds / failureThreshold 等 |
这两层是正交的、都需要。应用提供「我健不健康」的语义,K8s 决定「多久探一次、慢多久算超时、连续失败几次才动手」。把它们分开,后面的坑就能绕过大半。
反直觉:liveness 要「笨」,readiness 才该「聪明」
很多人给 liveness 端点塞一堆检查------内存阈值、依赖连通、数据库 ping。这是个典型错误。
因为 liveness 失败 = 重启。端点逻辑越复杂、越容易误判,就越容易把一个其实健康的应用重启掉。所以:
- liveness 要极简:纯静态返回 200,甚至 TCP 探活就够,「进程能响应」即可。
- readiness 才放真实语义 :启动完成、依赖就绪、以及是否正在关闭(关闭期返回 503,配合优雅关闭把自己摘出流量)。
- degraded 检测 (内存高、事件循环卡顿、依赖变慢)属于监控告警 (Prometheus / Sentry 这类),不进探针。一句话区分:探针是「开关」(重启 / 摘流),监控是「眼睛」(观测 / 告警),别让眼睛去按开关。
Next.js 三种部署模式,探针怎么配
Next.js 的部署形态直接影响「健康端点从哪来」。三种主流模式对比:
| 部署模式 | 健康端点从哪来 | 探针怎么配 |
|---|---|---|
standalone (output: 'standalone') |
App Router 的 Route Handler(/api/health、/api/ready) |
liveness 指 /api/health、readiness 指 /api/ready,无需自定义 server |
| 自定义 server(Express/Koa 包一层 Next) | 自己在 server 里写 /healthz、/readyz |
最灵活,但优雅关闭、摘流都得自己实现 |
静态导出 (output: 'export') |
没有 Node 运行时,纯静态文件 | 探针探的是托管层(Nginx / CDN),跟 Next 本身无关 |
这里有个关键点值得强调:用 standalone 时,不要为了「加个健康检查」就退回自定义 server。 Route Handler 就能优雅地提供 liveness + readiness,既满足探针需求,又不破坏 standalone「Node 进程只跑 Next、运维交给平台」的简洁性。示例:
ts
// app/api/health/route.ts ------ liveness:极简,进程能响应就算活着
export function GET() {
return new Response('ok');
}
ts
// app/api/ready/route.ts ------ readiness:有语义,关闭期摘流
let shuttingDown = false;
process.once('SIGTERM', () => {
shuttingDown = true;
});
export function GET() {
if (shuttingDown) {
return new Response('shutting down', { status: 503 });
}
return new Response('ready');
}
回到那次「偶发重启」:真相是高负载误杀
把上面的框架套回开头那个问题就清晰了:
- 现象:低频、个位数 Pod 重启。
- 误判:表象很像「内存泄漏缓慢累积触顶」,所以第一直觉是 OOM。
- 真相 :Pod 所在宿主机负载高 → 探针请求的响应延迟超过了
timeoutSeconds→ 连续失败越过failureThreshold→ K8s 判定容器不健康,重启。应用自始至终是正常的。
排查这类问题,第一个动作不是去翻内存,而是看容器的终止原因:
bash
kubectl describe pod <pod> -n <namespace>
# Last State: Terminated -> Reason / Exit Code
# Events 里有没有 Liveness/Readiness probe failed
一张图概括决策路径:
修复方式是放宽探针容忍度:调大 timeoutSeconds(容忍高负载下的延迟)、调整 periodSeconds、调大 failureThreshold(别让偶发抖动一越阈值就重启)。
但有一句必须说清楚:调参是缓解,不是万能。 如果宿主机是持续过载,放宽阈值只是把误杀推迟发生,真正的根因得靠平台扩容、反亲和调度去解决,不能无限放宽阈值兜底。
实战避坑清单
把这套实践浓缩成可执行的几条:
- 偶发 Pod 重启,先
kubectl describe看终止原因,区分 OOMKilled / 探针失败 / 进程崩溃,别一上来就默认 OOM。 - liveness 保持极简,别加内存 / 依赖检查------那会扩大误杀面,高负载下尤其危险。
- readiness 做关闭期摘流 (
SIGTERM后返 503),配合优雅关闭,避免滚动更新时把流量打到正在退出的 Pod。 - degraded 检测进监控,不进探针。探针管重启 / 摘流,监控管观测 / 告警。
- 容忍度调参是缓解,节点持续过载要靠扩容 / 调度,不是一味放宽阈值。
- standalone 下用 Route Handler 实现探针端点,不要为健康检查退回自定义 server。
- 探针失败 ≠ 应用不健康------这是整篇最想传达的一句:宿主机抖动会让一个完全正常的应用探针超时,别让它背 OOM 的锅。
参考与数据源
- Kubernetes 官方文档:Configure Liveness, Readiness and Startup Probes
- Kubernetes 官方文档:Pod Lifecycle --- Container probes
- Next.js 官方文档:Deploying --- Self-hosting &
output: 'standalone' - Next.js 官方文档:
next.config.js---output