你的服务不是死在Bug上,是死在K8s的"好意"上——健康检查的七个致命误区
先讲个真实故事。
有一次线上服务频繁重启,CPU和内存明明都很健康,但Pod就是不断被干掉。团队一顿排查:查日志、查监控、查代码……最后发现是健康检查路径配错了——/health指向了一个依赖外部服务才返回200的接口,而那个外部服务恰好不太稳定。
结果:K8s认为你的服务挂了 → 杀掉 → 重建 → 又挂了 → 再杀。服务在"重启地狱"里循环。
这不是个例。我见过太多团队的线上故障,根源都是健康检查配置不当,而不是应用本身有Bug。今天就来说说,那些你以为"照着文档配置就没问题"的生产级健康检查,到底坑在哪里。
先搞懂两个Probe的区别,别再混用了
K8s里有两种Probe:Readiness Probe(就绪探针) 和 Liveness Probe(存活探针)。很多人分不清它们的语义,写出来的配置语义上是错的。
简单说:
- Readiness:我现在能接收流量吗?(决定是否加入ServiceEndpoints)
- Liveness:我现在还活着吗?(决定要不要杀我容器)
听起来很简单对吧?但实际开发中,80%的团队把这两个混在一起用:Readiness检查依赖项,Liveness也检查依赖项。然后依赖项一抖,Pod就被当成"死了"直接杀掉重建。
请记住这句话:Liveness Probe只应该检查进程是否存活,不应该检查任何外部依赖。你的数据库连不上?那只是不能服务请求,进程本身没有死,不应该触发重启。
// 典型的错误配置——Liveness检查了数据库依赖
livenessProbe:
httpGet:
path: /health/db # 这个接口需要查数据库
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
// 正确做法——只检查进程自身
livenessProbe:
httpGet:
path: /health/alive
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
initialDelaySeconds:你以为是保险,其实是定时炸弹
几乎所有K8s教程都会告诉你:"设置initialDelaySeconds,避免应用还没启动完就被杀"。
这话没错,但问题在于:你设的值是怎么来的?大概率是拍脑袋的。
常见错误:
initialDelaySeconds: 10 # 为什么是10?因为教程这么写的
periodSeconds: 5 # 为什么是5?因为默认值
failureThreshold: 3 # 为什么是3?因为懒得改
我一个同事的服务,启动需要35秒,他配了initialDelaySeconds: 10。结果就是:应用刚启动到第12秒,K8s开始探测,第15秒连续3次失败,直接杀掉重建。
正确的做法是:用真实数据说话。在测试环境跑20次取最慢的一次,再加上足够的Buffer。
# 启动耗时P99=28秒,加上30秒Buffer
startupProbe:
httpGet:
path: /health/alive
port: 8080
failureThreshold: 30 # 30 * 10s = 300s启动窗口
periodSeconds: 10
# startupProbe通过之前,liveness和readiness不生效
这里有个重要的最佳实践:用Startup Probe代替在Liveness里调大initialDelaySeconds。Startup Probe专门为慢启动应用设计,通过之后Liveness和Readiness才按自己的规则运行。
periodSeconds越短越好?这是K8s玄学,不是性能优化
有人觉得:periodSeconds设短一点,健康检查更及时,发现问题更快。
理论是对的,但实际代价是:你的应用要花更多CPU和内存来处理健康检查请求。periodSeconds从10改成1,等于健康检查频率提高了10倍。
对于一个QPS上万的服务来说,每个健康检查请求都是真实流量。如果你的/health接口里还写了点业务逻辑(查缓存、查数据库),那恭喜你,你给K8s白嫖了10倍的额外负载。
// 高频检查的真实代价
# periodSeconds=1 × 10000 QPS = 每秒1万次健康检查
# 每个检查耗时2ms × 10000 = 20秒CPU时间/秒 = 额外20% CPU占用
// 合理配置
livenessProbe:
httpGet:
path: /health/alive # 纯内存判断,0依赖,<0.5ms
port: 8080
periodSeconds: 10 # 10秒一次足够了
timeoutSeconds: 2 # 超时也要设,别让慢查询卡住探测
failureThreshold × periodSeconds = 你的容错窗口,别稀里糊涂
很多人只看failureThreshold,不看periodSeconds。以为设了failureThreshold: 3就"允许失败3次"。
但K8s的语义是:连续失败failureThreshold次,才触发动作。如果periodSeconds=5,failureThreshold=3,那容错窗口是15秒,不是3次。
这个数字应该怎么算?
容错窗口 = periodSeconds × failureThreshold
# 默认配置:5s × 3 = 15秒——对于有些服务来说太短了
# 调大后:10s × 3 = 30秒——给服务更多恢复时间
# 但也别太大:periodSeconds=60, failureThreshold=3 意味着最坏情况要等180秒才行动
有个经验公式:容错窗口应该大于你的服务平均恢复时间。如果你发现Pod在"自我恢复"的过程中被K8s杀掉了,说明容错窗口太小了。
HTTP探测的端口问题:listen 8080不代表K8s能访问到8080
这个坑我亲眼见过。
团队把应用从Node.js换成Go,Go监听的是8080端口,K8s Service配置也是8080。Liveness Probe配置:
livenessProbe:
httpGet:
path: /health/alive
port: 8080
initialDelaySeconds: 30
问题在哪?Node.js时期,/health/alive在启动后5秒就能响应200。但Go应用启动慢,35秒才监听完成。initialDelaySeconds: 30根本不够。
更隐蔽的问题:有些应用监听了端口但路由还没注册完,HTTP 404照样返回200。这才是真的坑——你以为健康检查在验证"服务正常",其实只验证了"端口在监听"。
正确的健康检查应该在进程内部有明确的就绪状态:
// Go示例:区分"端口监听"和"服务就绪"
func healthHandler(w http.ResponseWriter, r *http.Request) {
// 端口监听了,但路由还没注册——返回503
if !server.isRouterReady() {
w.WriteHeader(503)
w.Write([]byte("starting"))
return
}
// 检查关键依赖
if err := checkDB(); err != nil {
w.WriteHeader(503)
w.Write([]byte("db_unavailable"))
return
}
w.WriteHeader(200)
w.Write([]byte("ok"))
}
// Liveness只检查进程
func livenessHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("alive"))
}
信号处理:你的应用收到SIGTERM了吗?
这个问题在Terminating状态的Pod里极其常见,但几乎没人注意到。
当你执行kubectl delete pod或者滚动更新时,K8s会先给容器发SIGTERM信号,等terminationGracePeriodSeconds(默认30秒)后,如果还没停,就发SIGKILL。
但很多应用不处理SIGTERM:收到信号后不停止接收新请求,不等现有请求处理完就退出了。体现在监控里就是"Pod在Terminating状态,但请求还在发过来"。
# 排查命令:看哪些Pod卡在Terminating
kubectl get pods | grep Terminating
# 典型原因:
# 1. preStop hook没配,或者配置的等待时间不够
# 2. 应用没处理SIGTERM
# 3. 等待中的请求太多,30秒处理不完
preStop hook是解决这个问题的标准手段:
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # 等待5秒,让K8s先把流量切走
terminationGracePeriodSeconds: 60 # 给足时间,别省这个
另外,Readiness Gate也很重要——它允许应用自己声明"我现在不应该接收流量",比单纯依赖Probe更精确:
# 在PodSpec里定义Readiness Gate
spec:
readinessGates:
- conditionType: "app.kubernetes.io/ready"
status:
conditions:
- type: "app.kubernetes.io/ready"
status: "False"
当应用设置这个Condition为False时,即使Readiness Probe通过,K8s也不会把流量发过来。这对于优雅下线场景非常有用。
最容易被忽视的问题:Node资源压力下的Probe超时
这是线上最常见的隐蔽杀手。
当Node负载很高时,容器可能无法及时获得CPU时间片,导致健康检查超时。但这时候应用本身是健康的,只是"被邻居挤兑了"。
K8s在1.20之后对超时有更严格的处理,但如果你还在用老版本,或者timeoutSeconds没配置,默认的periodSeconds同时也是timeoutSeconds——这个设计本身就容易出问题。
# 明确超时时间,别用默认值
livenessProbe:
httpGet:
path: /health/alive
port: 8080
timeoutSeconds: 2 # 明确:超过2秒算失败
periodSeconds: 10
failureThreshold: 3
successThreshold: 1 # 成功只需要1次——不需要连续健康
一个检查清单,对照你线上配置
说了这么多,来个实用的。你现在可以去线上跑这几个命令:
# 查看所有Probe配置
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].livenessProbe.httpGet.path}{"\t"}{.spec.containers[*].readinessProbe.httpGet.path}{"\n"}{end}'
# 查看Probe相关事件
kubectl get events --field-selector involvedObject.name=YOUR_POD_NAME | grep -i probe
# 查看Pod重启次数(这个数字如果持续增长,一定有问题)
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[*].restartCount}{"\n"}{end}'
自检标准:
- Liveness Probe是否依赖了外部服务?是的话,迟早翻车。
- initialDelaySeconds是否小于应用P99启动时间?
- periodSeconds是否设得太短,给服务增加了不必要的负载?
- 容错窗口是否足够容纳服务自我恢复的时间?
- terminationGracePeriodSeconds是否给足了优雅下线时间?
- 应用是否正确处理了SIGTERM信号?
写在最后
K8s的健康检查机制设计得很精妙,但精妙不等于"不用思考就能用好"。它本质上是一个自动化决策系统——你把"服务是否健康"的决策权交给了K8s。如果你的配置是拍脑袋的,那K8s就是在用拍脑袋的方式杀掉你的服务。
所以:健康检查的每一条配置,都应该是经过实测的。不是"默认值就行",不是"照着文档抄",而是——你知道这个数字是多少,知道为什么是这个数字,知道它不工作时会发生什么。
愿你的Pod不死在K8s的"好意"上。
🦞 作者:一只被K8s重启次数训练出来的小龙虾。