Kubernetes Pod健康探测与滚动更新零故障实践

Kubernetes Pod健康探测与滚动更新零故障实践 1. 滚动更新为什么会在“一切正常”时翻车做Kubernetes平台运维这几年让我感触最深的一个主题就是Pod健康探测。我刚开始负责这块时遇到过一次印象很深的故障一个核心服务发版本滚动更新流程跑得很顺新Pod一个个起来旧的Pod一个个退出kubectl get pods看起来一切正常没有CrashLoopBackOff没有ImagePullBackOff。但发布完不到两分钟监控告警就炸了——接口错误率直线上升大量5xx。1.1 一次没有探针的发布事故排查之后发现新Pod虽然已经显示Running容器内的Java进程也确实起来了但Spring Boot应用还在做数据库连接池初始化、Redis缓存预热、服务注册发现至少需要60秒才能真正处理请求。而Service的Endpoints在Pod进入Running的那一刻就已经把Pod IP加进去了流量随即打进来全部撞在“还没准备好”的应用上。这个故障的核心就是Pod健康探测没配置到位。准确地说是readinessProbe缺失导致Endpoints过早纳入了新Pod。很多人理解Pod健康探测觉得就是“探活”容器活着就万事大吉。但实际上健康探测在Kubernetes的滚动更新链路里扮演的角色远不止“存活检查”这么简单。它决定了流量什么时候进来、旧Pod什么时候退出、发布过程中有没有请求失败或者超时。可以说探针配置的质量直接决定了滚动更新是“零故障丝滑升级”还是“发布即事故”。1.2 Pod Running不等于应用可用Endpoints的纳入机制这里先普及一个底层机制Pod从创建到进入Runningkubelet会启动容器容器内进程起来以后Pod状态就是Running。但Pod状态变成Running不代表应用可用。Endpoints控制器会监听Pod状态一旦Pod进入Running且所属Service的selector匹配就会把Pod IP写进Endpoints。从这一刻起流量就会打到这个Pod上。说白了Kubernetes只看“容器进程活着没”并不知道“你的应用能不能处理请求”。这个信息差就是各种升级事故的温床。而Pod健康探测就是用来弥补这个信息差的机制。你通过探针告诉kubelet“容器进程活着只是第一步还要满足哪些条件才算真正可用”。有了这套机制Kubernetes才能在升级过程中做出正确的流量调度决策。1.3 健康探测在整个升级链路中的角色把视角拉到整个滚动升级的链路来看探针其实卡在三个关键节点上新Pod启动后靠startupProbe确认“启动完成”避免在慢启动阶段就被误杀新Pod就绪后靠readinessProbe决定“是否把流量切进来”运行过程中靠livenessProbe判断“进程是否还值得存活”失败则重启。这三个节点任何一个配置失误都可能让滚动更新从“零故障”变成“事故现场”。接下来我按三个层面展开三种探针的职责边界、探针参数如何影响升级、滚动更新策略与探针的配合方式最后用一个真实排查案例收尾。2. 三种探针的分工liveness管生死readiness管流量startup管启动2.1 livenessProbe容器活着的证据但不是可用的证据livenessProbe解决的是“容器假死”的问题。什么情况会用得上最常见的是死锁、内存溢出导致进程无响应或者某个线程把CPU占满了进程还挂着但已经不干活了。这时候livenessProbe发现探活失败kubelet会主动杀掉容器重建一个新的。关键点liveness失败的动作是重启容器而不是从负载均衡摘除。如果在流量高峰时一个Pod因为瞬时压力变大导致接口响应慢、探活失败kubelet直接重启容器Pod的IP从Endpoints里消失正在处理的连接全部中断这是很危险的。所以liveness的失败阈值要适当放宽不要用太敏感的检查。我见过不少团队把liveness配成了一个“全能检查”里面既查数据库连通性又查下游依赖结果数据库一抖动所有Pod一起重启整个服务雪崩。liveness应该只检查“进程还活着、基本健康”最好和外部依赖解耦。外部依赖出问题应该由readiness来摘流量而不是靠liveness重启——重启也解决不了数据库抖动的问题只会让情况更糟。2.2 readinessProbe决定流量是否进入的关键闸门readinessProbe是滚动更新中影响最大的探针。它的失败动作是把Pod从Service的Endpoints中摘除。也就是说readiness没通过流量不会进来通过了流量才进来。为什么它这么关键回到滚动更新的场景。Deployment滚动更新时新Pod创建后只有readinessProbe通过Endpoints切片才会把新Pod加入负载池。readiness没通过的新Pod不会接收流量。而在maxUnavailable0的策略下控制器会等到新Pod ready之后才会终止旧Pod。所以“新Pod什么时候算好”这件事全由readinessProbe说了算。很多人以为滚动更新时流量切换是靠Deployment自身的逻辑其实不是。真正决定“接下来流量往哪里打”的就是readinessProbe的通过与否。没有readinessProbePod一Running就进Endpoints流量就进来前面说的那个502事故就是这样发生的。readiness还承担着运行期优雅摘流量的职责。比如应用连接池满了、依赖的Redis挂了、缓存还没准备好readiness应该能感知到这些状态并主动“脱岗”让流量绕开自己。等恢复了再通过重新接入流量。这就相当于给每个Pod装了一个可编程的“流量阀门”。2.3 startupProbe慢启动应用的保护伞startupProbe是Kubernetes 1.16引入的解决的正是慢启动应用的误杀问题。很多Java系应用、大数据组件启动要几十秒甚至几分钟。如果只配livenessProbe容器启动中探活失败kubelet会把容器杀了重启陷入CrashLoopBackOff死循环。startupProbe的作用是在它成功之前liveness和readiness都不生效。相当于给了应用一个“宽限期”在这个期间只检查startupProbe成功了才开始跑liveness/readiness。配置逻辑很简单startupProbe的periodSeconds * failureThreshold应该大于应用最坏的启动时间。实际做法上periodSeconds设成2~5秒failureThreshold设成30甚至60。比如periodSeconds2、failureThreshold60就能容忍120秒的启动时间非常充裕。如果你的应用启动时间不稳定宁可把阈值调大一点也不要让它在启动阶段被打断。2.4 三种探测方式的选型exec、httpGet、tcpSocket怎么选每种探针都可以用三种方式实现选择逻辑其实很直接探测方式原理优点缺点适用场景httpGet请求HTTP端点2xx/3xx视为成功最能反映应用真实状态需要应用提供health端点HTTP/gRPC服务tcpSocket尝试建立TCP连接通用、不依赖应用实现端口通不代表应用就绪非HTTP协议、TCP服务exec容器内执行命令退出码0为成功灵活、可自定义检查逻辑每次执行有额外资源开销无网络端口的worker、脚本进程httpGet用得最多但要注意health端点应该是轻量的不要在里面查数据库、调第三方接口否则会产生连带效应。tcpSocket太粗糙只能做一个“兜底”级别的探测。exec每次探测会fork一个进程如果periodSeconds太短或者命令很重会浪费不少资源。我曾经维护过一组由shell脚本实现的worker用的是exec探针脚本里写了个复杂的健康检查逻辑包含对文件锁的检查。结果periodSeconds设了5秒太频繁脚本本身的资源消耗和锁竞争把worker拖慢了。后来把检查脚本简化periodSeconds调到10问题解决。exec探针本身的开销不容忽视。3. 探针参数不是拍脑袋填的初始延迟、周期、阈值的配置逻辑3.1 五个核心参数的完整含义探针配置里有五个参数很多人只知道大概意思但配的时候还是拍脑袋。先说清楚参数默认值含义判断逻辑initialDelaySeconds0容器启动后等多久才开始探测给应用预留启动时间periodSeconds10每隔多少秒探测一次频率太短浪费资源太长反应慢timeoutSeconds1一次探测最多等多久超时即本次失败successThreshold1连续成功几次才判为成功readiness恢复接流量时起作用liveness固定为1failureThreshold3连续失败几次才判为失败越短越敏感越长越耐错这里面容易忽略的是successThreshold。readinessProbe从失败状态恢复时需要连续多次成功才会把Pod重新加回Endpoints。如果你把successThreshold设成1那么一次成功探测后Pod就恢复接流量设成3需要连续3次成功相当于多了一个缓冲避免“刚恢复又失败”的抖动。不过也不要设太大否则恢复时间会被拉长流量长时间绕开Pod其他Pod压力会变大。3.2 结合启动时间和依赖初始化速度做参数推演参数设计的核心是先搞清楚应用的启动曲线再倒推参数。以一个典型的Java服务为例进程启动到端口监听大约10秒数据库连接池初始化大约20秒Redis缓存预热大约30秒完全可服务大约35秒如果我们直接在readinessProbe里配initialDelaySeconds0periodSeconds10failureThreshold3那么第一次探测在启动后0秒就执行应用端口还没监听探测失败。第二次10秒可能端口起来了但还没ready失败。第三次20秒仍可能失败。连续3次失败之后Pod就被摘除了——但这其实只是个误会应用35秒后就好了。而且一旦readiness失败后续的探测还在继续它会在应用ready后的下一次probe通过时恢复。问题在于中间这段“判定为失败”的时间窗口如果和其他操作叠加就会出问题。所以比较稳的做法是readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 25 # 等应用基本启动完再开始探测 periodSeconds: 5 # 每5秒探测一次 timeoutSeconds: 3 successThreshold: 1 failureThreshold: 3 # 连续3次失败才摘流量initialDelaySeconds设25秒意味着前25秒不探测Pod即使没就绪也不会被摘除因为压根还没开始检查。注意在没有readinessProbe时Pod进Endpoints的时机是Pod Running配了readiness之后Pod只有在readiness成功后才进Endpoints。所以initialDelay期间虽然不探测但Pod也不会接流量是安全的。对于启动特别慢的应用再加一个startupProbestartupProbe: httpGet: path: /health/start port: 8080 periodSeconds: 2 failureThreshold: 60 # 最长容忍120秒启动startupProbe通过之前liveness和readiness都不会执行。所以即使liveness的initialDelaySeconds很短也不会误杀启动中的应用。3.3 判定时间窗口的近似计算这里给一个可以套用的计算公式方便你在评审别人配置时快速判断有没有问题。判断Pod从不健康到被摘除的最短时间即最坏情况下需要多久才开始摘流量挂起时间 ≈ initialDelaySeconds (failureThreshold - 1) * periodSeconds timeoutSeconds举个例子initialDelaySeconds30failureThreshold3periodSeconds10timeoutSeconds3那么在超时最坏情况下第30秒开始第一次探测第33秒超时失败1次第40秒开始第二次探测第43秒超时失败2次第50秒开始第三次探测第53秒超时失败3次也就是说Pod从启动到被判定为失败并摘除大约需要53秒。反过来如果应用实际上在第45秒就ready了那它会在第50秒的探测中通过重新加入Endpoints损失的时间只有几秒。如果你追求快速摘除故障Pod可以把periodSeconds调小、failureThreshold调小。但要注意权衡太激进会把瞬时抖动误判成故障。这个平衡没有标准答案取决于你的应用对延迟的容忍度。我的经验是对于关键链路上的服务readiness的failureThreshold至少3periodSeconds控制在5~10秒既能较快摘除故障Pod又不会因为一次GC停顿或网络抖动就误摘。4. 滚动更新策略与探针联动maxSurge、maxUnavailable怎么配才能零故障4.1 滚动更新的完整决策链路Deployment滚动更新Kubernetes做决策的顺序是这样的Deployment控制器创建一个新的ReplicaSet按照maxSurge的比例额外创建新Pod新Pod启动后先经过startupProbe确认启动完成readinessProbe通过后Pod IP被加入Service的Endpoints控制器时刻检查可用Pod数量保证可用Pod数不低于replicas - maxUnavailable。在这个前提下滚动替换旧Pod终止旧Pod时会先从Endpoints中摘除再等待优雅终止期terminationGracePeriodSeconds最后发送SIGTERM。这里面有两个容易被忽略的细节。第一当maxUnavailable0时控制器必须先等到至少一个新Pod ready才会终止一个旧Pod。这就是“先扩容、后缩容”的逐个替换方式能保证任何一个时刻可用Pod数量不下降。而默认的maxUnavailable25%策略下控制器允许提前终止少量旧Pod只要总不可用数不超标。所以如果你没配readiness探针新Pod秒进Endpoints控制器也秒杀旧Pod发布过程完全失控。第二如果新Pod一直不ready比如镜像启动失败、探针配置错误滚动更新就会卡住不继续终止旧Pod。这其实是一个安全机制避免“旧的还没好、新的又不行”。很多人在发布时发现Deployment卡住不动kubectl rollout status一直等就是因为新Pod的readiness没过。这种情况不要慌先看新Pod的探针状态而不是盲目取消发布。4.2 maxSurge、maxUnavailable的选取逻辑maxSurge和maxUnavailable共同决定了滚动的速度与风险。maxSurge滚动更新过程中允许比期望副本数多出来的Pod数量。比如replicas10maxSurge25%那么更新过程中最多可以有13个Pod同时存在。maxUnavailable滚动更新过程中允许最多有多少比例的Pod不可用。默认25%。零故障升级的前提是在任何时刻至少保留足够的可用Pod来支撑流量。也就是说maxUnavailable不能太大否则旧Pod已下线、新Pod还没就绪可用Pod不足流量就会受损。举个例子replicas10maxUnavailable50%。滚动更新开始后控制器可能会一次性把5个旧Pod杀掉然后创建5个新Pod。如果新Pod的readiness比较慢在它们ready之前集群只有5个可用Pod如果这5个Pod扛不住原有流量就出事了。更稳的配置是针对关键服务把maxUnavailable设为0strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 0maxUnavailable0的意思是在滚动更新期间任何时刻都不允许减少可用Pod的数量。控制器必须等一个新Pod ready才能终止一个旧Pod。对于不能接受容量下降的服务来说这是最稳妥的策略。代价也很明显滚动速度慢。尤其是Pod数量大、readiness检查时间长的服务整个发布可能要走很久。所以实际中要结合业务容忍度来选。如果服务有多副本且单副本挂了不影响可用性maxUnavailable25%也够用但如果你只有2个副本的线上关键服务我建议maxUnavailable直接设0别赌。4.3 maxSurge与实际流量压力的关系maxSurge并不是越大越好。它决定了瞬时多出多少Pod来承接流量。如果maxSurge100%相当于先起一批完整的新Pod全部ready后再开始缩旧的。这样对流量最安全但资源消耗翻倍。我见过不少团队为了“零故障”把maxSurge设成100%但没考虑到集群资源是否足够。如果节点资源不足新Pod一直Pending滚动更新就卡在那儿。资源充足时maxSurge大一点确实更稳因为新Pod有充分的时间完成预热再逐步替代旧Pod。我的建议是资源宽松、追求速度maxSurge50%maxUnavailable25%资源较紧、追求稳定maxSurge25%maxUnavailable0只有2~3个副本的关键服务maxSurge100%全部先建好maxUnavailable04.4 readinessGates把自定义条件塞进滚动更新决策readinessProbe是内置的检查维度但有些场景需要额外的“外部就绪条件”。比如我这个Pod启动后必须等注册中心确认注册成功、必须等配置中心拉取到最新配置、必须等某个Agent上报心跳才算真正ready。这些外部条件无法用httpGet/tcpSocket/exec直接表达。Kubernetes提供了readinessGates机制可以在Pod上声明一个或多个Custom Condition只有当所有condition都为True时Pod才算ready。这个机制在Service Mesh场景里很常见比如Istio就利用readinessGates来确保Pod在Envoy代理注入并Ready之前不会被标记为就绪。spec: readinessGates: - conditionType: example.com/registered外部控制器比如自定义Operator负责在Pod满足条件后更新Pod的status.conditions里该condition为True。否则即使readinessProbe通过了Pod也不会进入ready状态。这个机制对零故障升级的意义在于它把“应用层就绪”和“平台层就绪”分离了。如果你的应用依赖外部系统同步建议用readinessGates补充避免出现“应用本身探测通过了但外部依赖还没就绪”的窗口期。5. 一次真实的探针误判排查从502告警到根因定位的全过程5.1 故障现象与初步怀疑某一个SaaS服务版本升级后发现线上偶发502。监控面板显示的Pod状态全部正常readinessProbe配置也正确滚动更新也没卡住看起来一切都好。但错误率确实在发布后的半小时内间歇性升高。我的第一反应是查最近变更。除了发版有没有改过网络策略、有没有动过Service因为502是网关侧报出来的通常意味着后端Pod不可用或连接被重置。查了一圈没发现其他变更于是把目光聚焦到探针上。5.2 排查链路从Endpoint到探针日志排查链路的第一步是看Service的Endpoints。用kubectl get endpoints检查发现Pod的IP确实都在Endpoints里。这就排除了“Pod被摘除”的原因。如果是readiness失败导致Pod被摘除Endpoints里会少IP请求就会失败。既然IP都在问题不在摘除环节。第二步看Pod事件。kubectl describe pod看最近的事件发现有个别Pod在短时间内被重启过重启原因显示是Liveness probe failed。这就奇怪了因为监控面板显示Pod状态正常。原因在于Pod重启之后状态恢复Running监控就显示正常了但期间有一小段不可用窗口正好赶上了流量高峰造成了502。第三步看探针日志和之前的监控曲线。发现探针失败其实是尾部的偶发现象并不是持续的。再深入看应用在做Full GC时有明显的停顿探针请求的响应时间飚到几秒超过了timeoutSeconds。5.3 根因liveness与readiness共用了同一个检查点根因在于我们当时把livenessProbe和readinessProbe配置成了完全相同的检查都指向/health/live这个端点。而这个端点虽然是专门为探针服务的但实现里带了一个轻量的数据库状态查询判断是否需要降级结果数据库在主库切换时短暂不可用/health/live返回5xx。liveness连续失败3次后kubelet直接重启了容器。这里有两个叠加的问题。第一liveness检查不应该依赖外部组件。数据库状态查询本质上是检查依赖不是检查“进程活着”。依赖抖动时正确的动作是让readiness把Pod摘出去让流量绕开它等依赖恢复后再接回来。而liveness的职责是判断“进程是否值得继续存活”它失败就该重启但这不适合应对依赖抖动——重启也解决不了数据库主从切换的问题。第二liveness和readiness完全共用同一个端点导致“临时不可用”和“进程死亡”被混为一谈。这是一个非常常见的错误设计。你想想这两个探针的失败动作完全不同readiness失败只是摘流量liveness失败可是会重启容器的。把同一个检查点同时交给两个动作完全不同的机制等于让一个开关同时控制电灯和消防喷淋必然出问题。5.4 修复与验证修复方案分两步。把readinessProbe和livenessProbe拆开。readinessProbe检查/health/ready这个端点包含依赖状态判断比如数据库连通性、连接池使用率livenessProbe只检查/health/live这个端点只负责确认进程活着不碰任何外部依赖。同时把liveness的failureThreshold从3提高到5timeoutSeconds从1提高到3避免因为GC停顿或瞬时负载导致误杀。livenessProbe: httpGet: path: /health/live port: 8080 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 5 readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 25 periodSeconds: 5 timeoutSeconds: 3 successThreshold: 1 failureThreshold: 3线上验证重新发布后观察了三天502彻底消失。即使数据库再发生主从切换Pod也只会在readiness层面被摘除请求会自动绕开切换完成后再重新加入Endpoints全程无感。5.5 这个坑的另一个变体探针端点本身太重顺带说一个相关案例。有团队把readinessProbe的HTTP端点写得很重里面扫数据库、统计全量缓存、调用下游健康检查接口导致每次探针请求都要几百毫秒甚至几秒。由于探针是周期性高频发起的这些请求本身就成了应用的一个额外负载源。更糟的是如果探针端点在处理中发生死锁探针超时失败Pod被摘除请求全打到其他Pod上其他Pod也可能因为过载而探针失败形成雪崩。探针端点就应该“轻、快、无副作用”。readiness端点只做三件事确认进程活着、确认关键依赖可用、确认自身状态比如连接池水位在可接受范围。超过一个阈值或依赖不可用就返回非2xx其他情况一律快速返回200。不要在探针端点里做任何需要长时间等待的操作也不要在里面写日志、洗数据。6. 零故障升级的探针设计检查清单结合前面所有踩过的坑我整理了一份可以直接拿去评审用的检查清单。每次排障或发版前拿它过一遍能挡掉大部分升级事故。6.1 探针配置自查清单[ ] 是否配置了readinessProbe如果没有Pod Running即自动进Endpoints等同于裸奔。[ ] livenessProbe和readinessProbe是否使用了同一个端点如果共用建议拆开区分“临时不可用”和“进程死亡”。[ ] livenessProbe是否依赖了外部组件数据库、下游服务、注册中心如果依赖建议去掉或单独检查避免依赖抖动引发全量重启。[ ] initialDelaySeconds是否大于应用监听端口所需的最短时间如果应用启动需要30秒别把initialDelay设成0。[ ] 启动特别慢的应用超过30秒是否配置了startupProbe如果没有liveness可能在启动阶段就误杀容器。[ ] timeoutSeconds是否足够探针端点在GC停顿或高负载下的响应时间必须明显小于timeoutSeconds。[ ] failureThreshold是否合理关键服务建议不低于3避免瞬时抖动导致误判。[ ] 探针端点是否轻量不要在探针端点内查数据库、扫描索引、调用远程接口。[ ] 探针端点在依赖不可用时返回码是否正确应该返回非2xx而不是一直卡住超时。6.2 滚动更新策略自查清单[ ] maxUnavailable是否可能让可用Pod数低于安全水位只有2~3个副本的关键服务建议设为0。[ ] maxSurge是否超出了集群可用资源超出会导致新Pod Pending滚动更新卡死。[ ] 是否配置了terminationGracePeriodSeconds优雅终止期要覆盖应用接到SIGTERM后的“摘流量清理资源退出”时间常见默认30秒不一定够。[ ] 是否验证过滚动更新在“新Pod永远不ready”时的表现预期应该卡住而不是继续发布。[ ] 如果平台支持是否对发布过程做了灰度分组建议先将新版本发布到低流量节点再逐步扩大。6.3 几个通用经验第一探针是“防御性”设计不显眼但决定了事故概率。我见过太多团队把精力花在CI/CD流程上却忽略了把readiness配好结果发布管道再顺最后一公里在Kubernetes这边翻了车。第二health endpoint应该和业务接口分离。业务接口里如果有降级逻辑、复杂处理不适合作为探针端点。探针端点最好是一个独立的轻量接口只传健康状态。第三探针的“噪音”不能忽略。如果集群规模大Pod数量多探针请求本身就是不小的流量。比如1000个Pod每5秒一次readiness探测每秒就有200次请求打到业务进程里。建议periodSeconds不要设太短常见的5~10秒对于绝大多数应用足够。第四无论配置看起来多合理都要通过实战验证。最有效的验证方式就是主动制造故障手动把某个Pod的readiness端点改为返回500观察Endpoints是否及时摘除或者把某个Pod停掉观察滚动更新是否卡住。混沌工程里最简单的两个实验Kubernetes场景下就是这两个。最后分享一个我一直在用的技巧把探针的日志和事件接入告警。kubectl describe pod里的事件平时没人看但一旦探针开始失败事件里会有Liveness probe failed或者Readiness probe failed的连续记录。把这些事件接入监控设置聚合告警很多问题能在用户感知之前就被你发现。这也是我做“零故障升级”专项以来觉得投入产出比最高的一项改进。