K8s 探针避坑:Next.js 不同部署模式下的健康检查实践

线上一个 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 的部署形态直接影响「健康端点从哪来」。三种主流模式对比:

部署模式 健康端点从哪来 探针怎么配
standaloneoutput: '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

一张图概括决策路径:

flowchart TD A[Pod 偶发重启] --> B[kubectl describe 看终止原因] B --> C{Last State Reason} C -->|OOMKilled exit 137| D[内存路径 查堆与 cgroup limit 与泄漏] C -->|probe failed| E[探针路径 调容忍度参数] E --> F[宿主机持续过载 则平台扩容或反亲和调度]

修复方式是放宽探针容忍度:调大 timeoutSeconds(容忍高负载下的延迟)、调整 periodSeconds、调大 failureThreshold(别让偶发抖动一越阈值就重启)。

但有一句必须说清楚:调参是缓解,不是万能。 如果宿主机是持续过载,放宽阈值只是把误杀推迟发生,真正的根因得靠平台扩容、反亲和调度去解决,不能无限放宽阈值兜底。

实战避坑清单

把这套实践浓缩成可执行的几条:

  1. 偶发 Pod 重启,先 kubectl describe 看终止原因,区分 OOMKilled / 探针失败 / 进程崩溃,别一上来就默认 OOM。
  2. liveness 保持极简,别加内存 / 依赖检查------那会扩大误杀面,高负载下尤其危险。
  3. readiness 做关闭期摘流SIGTERM 后返 503),配合优雅关闭,避免滚动更新时把流量打到正在退出的 Pod。
  4. degraded 检测进监控,不进探针。探针管重启 / 摘流,监控管观测 / 告警。
  5. 容忍度调参是缓解,节点持续过载要靠扩容 / 调度,不是一味放宽阈值。
  6. standalone 下用 Route Handler 实现探针端点,不要为健康检查退回自定义 server。
  7. 探针失败 ≠ 应用不健康------这是整篇最想传达的一句:宿主机抖动会让一个完全正常的应用探针超时,别让它背 OOM 的锅。

参考与数据源

相关推荐
Plastic garden2 小时前
K8s(10)NFS 的动态 PV 创建数据库给k8s的mysql和redis
docker·容器·kubernetes
Plastic garden2 小时前
k8s(11) Pod 控制器,服务发现与存储管理
kubernetes
武子康3 小时前
调查研究-167 Docker Compose 详解:从单容器到多服务编排的工程化入口
运维·docker·云原生·容器·kubernetes·k8s·docker-compose
我登哥MVP4 小时前
VS Code 安装 Claude Code 并接入 DeepSeek V4 Model
人工智能·python·node.js·agent·codex·deepseek·claude code
Shacoray5 小时前
K8s 中 Ingress 的 HTTPS 证书 如何生成?
容器·https·kubernetes
开发者联盟league5 小时前
使用Jenkins整合Sonarqube/Gitlab/Harbor/Kubernetes的Demo工程
kubernetes·gitlab·jenkins
Patrick_Wilson6 小时前
Node.js SSR 内存治理:为什么 --max-old-space-size 不等于进程内存
kubernetes·node.js·v8
开发者联盟league6 小时前
使用k8s安装Jenkins
容器·kubernetes·jenkins