引言 — 好端端的 Pod 每隔几分钟就重启一次
应用运行正常,只有 RESTARTS 在悄悄上涨。
kubectl get pod -n payments
NAME READY STATUS RESTARTS AGE
checkout-7d9f6c5b4d-2xk9p 1/1 Running 11 (4m2s ago) 3h18m
checkout-7d9f6c5b4d-lm4vz 1/1 Running 9 (12m ago) 3h18m
日志里直到终止前一刻都只有正常处理请求的记录。答案在事件里。
kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | tail -5
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 4m12s (x33 over 3h17m) kubelet Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Normal Killing 4m2s (x11 over 3h17m) kubelet Container checkout failed liveness probe, will be restarted
是 Kubernetes 在杀这个容器。更准确地说,是我们自己下达了这样的指令。
三种探针回答的是不同的问题
三种探针的写法完全一样,所以容易混淆,但它们失败时做的事情截然不同。
- startupProbe — 「启动完成了吗」。在成功之前会把 liveness 和 readiness 完全暂停。一旦成功,在该容器的整个生命周期内都不会再执行。把 failureThreshold 耗尽就杀掉容器
- readinessProbe — 「现在可以接收流量了吗」。失败时 Pod 地址会从服务端点里摘除。不会重启容器。成功后会重新加回来
- livenessProbe — 「这个进程需要重启吗」。失败时 kubelet 会杀掉容器,并按 restartPolicy 重新拉起
先把这里最常见的误解讲清楚。readiness 失败绝不会触发重启。很多人看到 Pod 一直停在 0/1 却不重启就慌了,其实那正是设计如此。重启只属于 liveness 和 startup 的权限。
readiness 失败要在端点上确认。
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 10.42.3.17,10.42.1.9 3h20m
三个 Pod 里只进来了两个。剩下那一个就是 readiness 失败的 Pod。
kubectl get pod -n payments -l app=checkout \
-o custom-columns='POD:.metadata.name,IP:.status.podIP,READY:.status.containerStatuses[*].ready'
POD IP READY
checkout-7d9f6c5b4d-2xk9p 10.42.3.17 true
checkout-7d9f6c5b4d-lm4vz 10.42.1.9 true
checkout-7d9f6c5b4d-q8w2n 10.42.2.31 false
用错会引发的三类故障
| 症状 | 原因 | 诊断 | 解决 |
|---|---|---|---|
| 正常 Pod 周期性重启 | liveness 过于敏感,尤其是 timeoutSeconds 默认 1 秒 | Events 里的 Unhealthy 与 Killing | 调高 timeoutSeconds,给健康端点减负 |
| 发布后立刻出现 502 和连接重置 | 没有 readiness,或者它没有反映真实的就绪状态 | Pod 什么时候进入 EndpointSlice | 添加 readiness,maxUnavailable 设为 0 |
| 启动慢的应用到不了 Ready | 没有 startup,liveness 在启动过程中杀掉容器 | Started 与 Killing 事件的间隔 | 添加 startupProbe |
| 数据库故障时全部 Pod 同时重启 | liveness 里包含了依赖检查 | 健康处理器调用了什么 | 从 liveness 里去掉依赖 |
| 端点归零导致服务全面中断 | readiness 里包含了所有 Pod 共享的依赖 | 端点数量的时间序列 | 允许部分降级,拆分依赖 |
| 终止过程中请求失败 | 端点传播完成之前 SIGTERM 就到了 | 对照 Pod 终止时刻与失败时刻 | 插入 preStop 延迟 |
| 终止时出现 Exit Code 137 | 没能在宽限期内退出 | Reason Error 带 137 | 缩短排空时间,调高宽限期 |
| kubelet CPU 上升 | exec 探针过多 | 统计探针种类 | 换成 httpGet 或 grpc |
故障 1 — 过于敏感的 liveness 造成的自我毁灭
这是最危险的组合。负载一上来,应用响应就变慢。探针也跟着变慢。一超时 Kubernetes 就杀掉容器。剩下的 Pod 承担更多负载。它们也开始超时。于是形成了负载越高死得越多的结构,系统亲手把自己压垮。
在这个场景里 liveness 不是稳定性装置而是放大器。诊断方法是把重启时刻和流量曲线叠在一起看,立刻就能看出来。如果重启只集中在高峰时段,那就可以确定了。
故障 2 — 不带 readiness 就发布
没有 readiness 时,容器进程一起来 Pod 就被视为 Ready,马上进入端点。哪怕应用还在建连接池、填缓存,请求也会打进来。
kubectl get events -n payments --field-selector reason=Started --sort-by=.lastTimestamp | tail -2
如果只在发布后的几秒里 5xx 抖一下、随后很快恢复正常,那几乎总是这个问题。它在每次滚动更新时重复出现,所以在一天发布好几次的团队里会固化成常态错误率。
故障 3 — liveness 屠杀缓慢启动的情况
给一个需要 90 秒才能启动的应用挂上 initialDelaySeconds 30 的 liveness,从第 30 秒起探针就开始失败,容器被杀掉、重新起来、又被杀掉。它永远到不了 Ready。
kubectl describe pod ledger-0 -n payments | grep -E "Started|Killing"
Normal Started 2m11s (x5 over 7m32s) kubelet Started container ledger
Normal Killing 91s (x4 over 6m52s) kubelet Container ledger failed liveness probe, will be restarted
Started 与 Killing 的间隔稳定在 40 秒左右。这是启动完成之前被反复杀死的明确信号。
常见误答:把 initialDelaySeconds 调到 200。这要付出两份代价。第一,真正死掉的容器在最初 200 秒里也不会被发现。第二,这个值必须迁就最慢的那次启动,所以在节点繁忙的日子里依然不够。正解是 startupProbe。
参数计算 — 实际上几秒之后才会被杀
先把五个参数的默认值和含义确定下来。
- initialDelaySeconds(默认 0)— 容器启动后到第一次探测之间的等待
- periodSeconds(默认 10)— 探针执行周期
- timeoutSeconds(默认 1)— 单次探测等待响应的时间
- failureThreshold(默认 3)— 连续失败几次才算失败
- successThreshold(默认 1)— 连续成功几次才算成功。liveness 和 startup 只允许填 1
timeoutSeconds 默认 1 秒是故障最常见的种子。GC 停顿、瞬时的线程池饱和、磁盘延迟,任何一样都能让它超过 1 秒。健康端点再轻量,只要整个进程卡住,响应就会变慢。
现在来算。假设在下面的配置下,容器在第 t 秒彻底停住。
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
- 最坏情况下第一次失败的探测不是紧接着 t,而是在下一个周期,也就是最晚 t 加 10 秒时开始
- 必须连续失败三次,所以按 10 秒间隔探三次,最后一次失败落在 t 加 30 秒附近
- 每次探测都会耗掉 3 秒超时,因此实际判定还要再晚几秒
也就是说,检测延迟的最坏值大约是 periodSeconds 乘以 failureThreshold 再加 timeoutSeconds。上面这套配置约为 33 秒。这个值必须和 SLO 对齐。如果服务要求 30 秒内恢复,就得把 periodSeconds 降到 5。
startupProbe 的预算用乘法确定。
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
5 秒乘以 60 次,允许最长 300 秒的启动时间。在这 300 秒里 liveness 根本不会执行。启动结束后 startup 被永久停用,由 liveness 以较短的周期接手。同时获得宽松的启动与敏捷的检测,唯一的办法就是这个。
三种探针一起用的完整形态是这样。
containers:
- name: checkout
image: registry.example.com/checkout:1.14.2
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
successThreshold: 1
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
这里包含三层设计意图。readiness 的周期和阈值都取得很小,让它快速摘除、快速回归。liveness 相反,取得很宽容,只有确实死了 60 秒以上才重启。路径也不同,liveness 用 /healthz,readiness 用 /readyz,分开处理。
把依赖放进 liveness 会让故障全面化
第一次做健康检查时,看起来最自然的实现就是这个。
GET /healthz
→ 对数据库执行 SELECT 1
→ 对 Redis 执行 PING
→ 对支付网关发 HEAD 请求
→ 全部成功就返回 200
看着很诚实,在生产环境却是灾难。数据库抖动 3 分钟会发生什么,按顺序看一遍。
- 所有 Pod 的 liveness 同时失败。因为它们盯着同一个依赖,无一例外
- kubelet 杀掉所有容器
- Pod 同时重启,重新建立连接池,缓存被全部清空
- 数据库恢复的瞬间,所有 Pod 同时倾泻连接和查询
- 数据库再次被打垮
- 循环往复
一次 3 分钟的数据库故障,扩大成 30 分钟的服务故障。而且因为重启,进行中的请求、内存里的队列、预热好的缓存全部消失,恢复反而更慢。
liveness 需要回答的问题只有一个:重启这个进程能让情况变好吗。数据库挂了的时候重启进程不会让任何事情变好。所以数据库不该进 liveness。
可以放进 liveness 的,只有那些重启真能解决的状态。事件循环停摆、死锁、不可恢复的内部状态损坏,也就这些。
GET /healthz
→ 只确认进程能不能接收请求并产生响应
→ 永远返回 200,只是事件循环被堵住时响应根本发不出去
这个简单的处理器在大多数情况下就是最优解。而且从这里还能再往前推一步。如果没有任何靠重启就能修好的故障模式,那么干脆不设 liveness 探针更安全。没有探针时,容器只在进程死掉时才重启,而那通常是正确的行为。
readiness 里可以放到什么程度
readiness 不会触发重启,所以有放依赖检查的余地。判断标准是这个。没有那个依赖,这个 Pod 还能不能处理哪怕一个请求。
- 如果所有请求都需要数据库,放进 readiness 是合理的。反正都会失败的请求,不接更好
- 如果只有一部分需要,就不该放。连静态响应或者靠缓存就能处理的请求也会被挡掉
而且一旦决定要放,就必须确认一件事。所有 Pod 同时变成 NotReady 时,服务的端点会归零,客户端连连接都会被拒绝。这可能是比 5xx 更糟的体验,并且恢复时刻完全没有健康信号,诊断也会更困难。
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout -o wide
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 <unset> 3h44m
ENDPOINTS 是空的就是这个状态。把依赖检查放进 readiness 时,要一并考虑至少保留一台的策略,或者把依赖失败按部分降级来处理的设计。
探针类型的选择与 exec 的代价
有四种方式,代价和可信度各不相同。
httpGet — 默认选项。kubelet 直接向 Pod IP 发请求。把 200 到 399 都视为成功。这里有一个陷阱。如果认证代理把健康路径 302 重定向到登录页,探针会被判定为成功。应用死了也照样成功。健康路径必须排除在认证之外,并直接返回 200。
tcpSocket — 只看端口是否打开。监听套接字由内核维护,所以哪怕应用线程全都停了也会成功。可信度最低,只在没有别的选择时才用。
grpc — 如果是 gRPC 服务,kubelet 会直接调用标准健康协议。不必再像以前那样把健康检查二进制放进镜像,用 exec 去调。
readinessProbe:
grpc:
port: 9000
service: readiness
periodSeconds: 5
exec — 最贵。每次探测都会在容器里新建一个进程。容器运行时创建任务、设置命名空间、回收进程的开销每次都要付。把周期 10 秒的 exec 探针挂在 500 个 Pod 上,整个集群里就会常态化地每秒创建 50 次进程。
代价体现在两处。节点上 kubelet 和运行时的 CPU 会上升,而且探针进程属于容器的 cgroup,因此会蚕食应用的 CPU 与内存预算。如果是调用 shell 脚本的 exec 探针,shell 本身的内存也算在里面。在内存上限本就紧张的容器里,探针有时会成为 OOM 的扳机。
数一数现状,通常会让人吃惊。
kubectl get pods -A -o json | \
jq -r '.items[].spec.containers[].livenessProbe | select(.!=null) | keys[]' | \
grep -v Seconds | grep -v Threshold | sort | uniq -c | sort -rn
412 httpGet
87 exec
23 tcpSocket
87 个 exec 里相当一部分是调用 curl 的脚本,那是换成 httpGet 就会直接消失的开销。
如果用了服务网格,还有一点。kubelet 是直接向 Pod IP 发请求的,所以探针绕过了 sidecar 的 mTLS 策略。网格要求严格双向 TLS 时探针就会失败,因此大多数网格都提供把探针路径改写为由 sidecar 代收的选项。如果探针突然全部开始失败而应用却好好的,就往这边怀疑。
终止路径 — 端点摘除与 SIGTERM 的竞态条件
探针调得再好,如果每次滚动更新都冒错误,原因就在终止路径上。
Pod 被删除时,有两件事会同时、彼此之间毫无协调地启动。
- kubelet 路径 — 执行 preStop 钩子,向容器主进程发送 SIGTERM,宽限期结束后发送 SIGKILL
- 端点路径 — 控制器把 Pod 从 EndpointSlice 里移除,这个变更再传播到所有节点的 kube-proxy、Ingress 控制器和服务网格 sidecar
问题在于第二条很慢。从控制器察觉、更新 EndpointSlice,到所有节点接收并重写规则,要花几百毫秒到几秒。集群大或者中间夹了外部负载均衡器,还会更久。
在这期间容器已经收到 SIGTERM 并关闭了监听。仍然路由到这个 Pod 的请求会碰上连接被拒。发布过程中出现少量 502 和连接重置,原因正是这个。
解决办法是故意把 SIGTERM 推迟。preStop 钩子先于 SIGTERM 执行,并且在它完成之前 SIGTERM 会被挂起,所以在这里放一段等待传播的延迟。
spec:
terminationGracePeriodSeconds: 60
containers:
- name: checkout
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 15']
新版本里可以使用不调用 shell 的专用处理器。在镜像里没有 shell 的 distroless 环境下尤其有用。
lifecycle:
preStop:
sleep:
seconds: 15
这里必须把宽限期预算算清楚。terminationGracePeriodSeconds 是连 preStop 执行时间一起算进去的总预算。
- preStop 延迟 15 秒
- 进行中请求排空最多 30 秒
- 余量 15 秒
- 合计 60 秒
不做这个计算,preStop 一结束宽限期就耗尽,SIGKILL 随即飞来。退出码 137 而 Reason 打成 Error 的情况就是这个,它和 OOMKilled 篇里讲的内存问题退出码相同,很容易误诊。
应用这一侧也必须正确处理 SIGTERM。拒绝新连接但把进行中的请求做完,后台工作线程收尾当前任务后再退出。没有 SIGTERM 处理器的应用会按默认行为立刻死掉,所以 preStop 拉得再长,进行中的请求也会被截断。
kubectl exec -it checkout-7d9f6c5b4d-2xk9p -n payments -- \
sh -c 'grep SigCgt /proc/1/status'
SigCgt: 0000000000014002
通过这个位掩码里第 15 位是否置起,可以确认是否注册了 SIGTERM 处理器。对于经由 shell 启动的容器,还要一并确认那个经典问题:shell 成了 PID 1 而不把信号转发给子进程。
标准模板与检查清单
新建服务时可以把下面这份当作起点,只按服务特性调整数值。
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
sleep:
seconds: 15
startupProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
发布之前确认五件事。
- liveness 处理器有没有调用外部依赖
- timeoutSeconds 是不是还留在 1
- 启动时间的最坏值是否落在 startupProbe 的预算之内
- terminationGracePeriodSeconds 是否大于 preStop 延迟加排空时间之和
- 应用是否会在收到 SIGTERM 后开始排空
作为防止复发的指标,跟踪探针失败本身很有用。因为它能在演变成重启之前的阶段被抓住。
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: probe-alerts
namespace: monitoring
spec:
groups:
- name: probes
rules:
- alert: LivenessProbeFailing
expr: increase(prober_probe_total{probe_type="Liveness",result="failure"}[15m]) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} liveness failing without restart yet"
- alert: ServiceHasNoEndpoints
expr: kube_endpoint_address_available == 0
for: 3m
labels:
severity: critical
结语 — 探针不是安全装置,而是权限委托
配置探针,意味着把切断流量的权限和杀死进程的权限交给 Kubernetes。至于依据什么来行使这份权限,由我们来定。依据不牢靠,毫无问题的系统就会被自己的防护装置压垮。
要记住的一句话是这个。liveness 只应回答「重启能不能让情况变好」,凡是答案为否的条件,都要交给 readiness,或者干脆从探针里拿掉。
현재 단락 (1/242)
应用运行正常,只有 RESTARTS 在悄悄上涨。