资讯动态

Kubernetes Pod下半场:健康探针、资源配额与优雅终止实战

发布时间:2026/10/26 11:39:59 来源:尧图企业网站定制
1. 为什么Pod下半场才是真正的深水区很多人学Pod上半场学的是“怎么把容器跑起来”——镜像、命令、端口、环境变量照着文档敲一遍Pod能Running就觉得自己会了。真到了生产环境才发现Pod跑起来只是起点真正让人半夜爬起来处理告警的几乎全在下半场健康检查配错了导致流量打到还没准备好的实例上、资源限制没设好引发节点级雪崩、生命周期钩子写错了让滚动更新卡死、多容器Pod里主容器和边车互相拖累。这些问题的共同点是它们都不影响Pod“启动”但严重影响Pod“可用”。这篇内容就是围绕Pod的下半场展开的。核心关键词是Pod生命周期、健康探针、资源配额、多容器协作、调度约束。我会把Pod从“创建”到“终止”这条完整链路上那些容易被跳过的细节拆开讲包括探针的三种类型到底该怎么选、resources的requests和limits为什么必须成对思考、init容器和普通容器的执行顺序差异、以及Pod被驱逐和优雅终止时到底发生了什么。适合已经能独立写出一个可运行Pod、但对其运行机制还停留在“能跑就行”阶段的读者也适合那些正在排查Pod反复重启、就绪探针失败、OOMKilled之类问题的一线运维和开发。我不打算重复上半场那些基础字段的罗列而是把每个机制背后的“为什么”讲透——为什么kubelet要这样设计探针、为什么limit设了还会被驱逐、为什么优雅终止时间设了却不生效。这些才是真正决定你写的Pod能不能在生产环境活下来的东西。2. Pod生命周期全景从Pending到Terminated的每个关键节点2.1 阶段Phase只是粗粒度状态别拿它当诊断依据很多人排查Pod问题第一反应是kubectl get pod看STATUS列看到Running就放心了看到CrashLoopBackOff就慌了。但Pod的phase只有五个值Pending、Running、Succeeded、Failed、Unknown它描述的是Pod在生命周期中的宏观位置而不是具体健康状态。一个Pod处于Running完全可能内部容器已经探针失败、正在被反复重启一个Pod处于Pending可能是调度没成功也可能是镜像还在拉取。把phase当诊断结论是新手最容易踩的认知坑。真正有诊断价值的是kubectl describe pod里的Conditions和容器状态。Conditions包含PodScheduled、Initialized、ContainersReady、Ready四个维度每个都有True/False/Unknown三态配合reason字段能精确定位卡在哪一环。比如Initialized为False说明init容器还没跑完Ready为False但ContainersReady为True说明就绪探针还没通过。我习惯在排查时先看Conditions再看容器状态最后才看Events这个顺序能把问题范围从“整个Pod”逐步收敛到“某个容器的某个环节”。2.2 容器状态机Waiting、Running、Terminated三态背后的信息量每个容器在Pod里都维护一个独立的状态机只有三种状态Waiting、Running、Terminated。Waiting状态下最关键的是reason字段常见的ContainerCreating表示正在准备拉镜像、挂卷、配网络ErrImagePull和ImagePullBackOff表示镜像问题CrashLoopBackOff表示容器启动后反复退出。这里有个细节值得说清楚CrashLoopBackOff不是容器状态而是kubelet对反复崩溃容器施加的退避重启策略退避时间从10秒开始指数增长最长5分钟。所以看到这个状态真正要查的是容器为什么退出而不是纠结退避本身。Terminated状态里的exitCode是排查崩溃的第一手证据。exitCode为0表示正常退出非0表示异常。137基本等于被SIGKILL杀掉最常见的原因是OOMKilled或者优雅终止超时后被强杀143是收到SIGTERM后正常退出1通常是应用自身报错。我见过不少人看到137就去查内存结果发现是terminationGracePeriodSeconds设得太短应用还没处理完就被强杀了。所以exitCode要结合reason一起看reason为OOMKilled才是内存问题reason为Error且exitCode为137更可能是终止超时。2.3 重启策略与Pod的“死法”restartPolicy有三个值Always、OnFailure、Never默认Always。这个字段决定了容器退出后kubelet要不要重启它但它只作用于Pod内的容器不改变Pod本身的phase。一个restartPolicy为Always的Pod即使所有容器都正常退出Pod也会被重建容器而不是进入Succeeded。这点在跑一次性任务时特别容易搞混——如果你用普通Pod跑批处理任务任务跑完了容器退出kubelet会立刻重启它形成无限循环。正确做法是用JobJob会管理Pod的完成语义。Pod的“死法”主要有几种被删除用户主动或控制器缩容、被驱逐节点资源不足或污点、节点故障导致失联后被标记Unknown再清理、以及正常完成仅对restartPolicy非Always的Pod。驱逐这块值得单独提当节点内存或磁盘压力达到阈值kubelet会按QoS等级排序驱逐PodBestEffort最先被赶走Burstable次之Guaranteed最后。这就是为什么生产环境的核心服务一定要设requests等于limits把自己钉死在Guaranteed等级上避免成为资源紧张时的第一批牺牲品。3. 健康探针三件套liveness、readiness、startup到底怎么配3.1 三种探针的职责边界配错就是给自己埋雷liveness探针回答的是“这个容器还活着吗要不要重启它”readiness探针回答的是“这个容器准备好接流量了吗要不要把它从Service端点里摘掉”startup探针回答的是“这个容器启动完成了吗在它完成前别用liveness去干扰它”。三者职责完全不同但很多人图省事只配一个liveness结果应用启动慢的时候liveness先超时把容器杀了形成“启动-被杀-再启动”的死循环。正确的组合逻辑是这样的启动慢的应用比如要加载大量数据、初始化连接池的Java服务必须配startup探针给它足够的启动窗口startup成功之前liveness不生效readiness探针几乎每个对外提供服务的容器都该配它决定了流量什么时候进来liveness探针要谨慎只有当容器真的进入不可恢复的僵死状态、重启是唯一出路时才需要配得太激进反而制造故障。我个人的经验是liveness的失败阈值至少设3次间隔至少10秒给应用留出自愈的余地。3.2 探针类型选型exec、httpGet、tcpSocket的适用场景探针支持三种检查方式。httpGet最常用向容器发一个HTTP请求返回码在200到399之间算成功适合Web服务tcpSocket只检查端口能不能连上适合数据库、消息队列这类没有HTTP接口的服务exec在容器内执行命令靠退出码判断最灵活但也最重因为每次检查都要fork一个进程。选型上有个容易忽略的点httpGet和tcpSocket是由kubelet直接发起的不经过容器内的应用逻辑所以它们只能证明“端口在监听”不能证明“业务逻辑正常”。如果你的服务端口监听着但内部依赖全挂了httpGet照样返回200。这时候要么在应用里暴露一个真正检查依赖的healthz接口要么用exec跑一个能验证业务状态的脚本。我见过一个案例服务连不上数据库但HTTP端口正常readiness一直通过流量持续打进来全部报错最后是靠把readiness改成检查数据库连接的exec脚本才解决。3.3 参数调优initialDelaySeconds、periodSeconds、failureThreshold的联动计算探针的几个时间参数不是孤立的要联动算。initialDelaySeconds是容器启动后等多久开始第一次探测periodSeconds是探测间隔failureThreshold是连续失败几次判定失败timeoutSeconds是单次探测超时。判定一个容器“不健康”的总时间是initialDelaySeconds periodSeconds × failureThreshold粗略估算实际还要加timeout。举个实际例子一个Spring Boot服务冷启动要40秒如果你配initialDelaySeconds为10、periodSeconds为10、failureThreshold为3那么在第10、20、30秒三次探测都失败后第30秒左右liveness就会判定失败并重启容器而此时应用还没启动完直接进入死循环。正确做法是配startup探针failureThreshold设到足够覆盖最坏启动时间比如periodSeconds为5、failureThreshold为20给足100秒启动窗口startup成功后再交给liveness接管。这个计算过程很多人不算拍脑袋填数字结果就是探针和应用的启动节奏对不上。提示startup探针的failureThreshold要按“最坏情况启动时间”来设不是平均启动时间。数据库迁移、缓存预热这类操作在数据量大时可能比平时慢好几倍留足余量比省那点探测开销重要得多。3.4 探针实操配置模板与验证方法下面是一个相对稳妥的三探针配置模板适用于大多数对外提供HTTP服务的容器containers: - name: app image: myapp:1.0 startupProbe: httpGet: path: /healthz port: 8080 periodSeconds: 5 failureThreshold: 24 livenessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 failureThreshold: 3 timeoutSeconds: 3 readinessProbe: httpGet: path: /readyz port: 8080 periodSeconds: 5 failureThreshold: 2 timeoutSeconds: 3这里startup给了120秒窗口5×24liveness在startup成功后才开始计时readiness比liveness更敏感失败2次就摘流量这样流量切换快、重启决策稳。验证探针是否生效除了看Pod的READY列更直接的是kubectl describe pod看Events里有没有Liveness probe failed或Readiness probe failed记录以及用kubectl get endpoints确认Pod是否真的从Service端点里被摘除和加回。光看Pod状态不够端点列表才是流量视角的真相。4. 资源管理requests、limits与QoS等级的连锁反应4.1 requests和limits不是可选项是调度和运行的分离契约requests是调度器用的它告诉调度器“这个Pod至少需要这么多资源你找节点的时候按这个数预留”limits是运行时用的它告诉kubelet和容器运行时“这个容器最多能用这么多超了就限制或杀掉”。两者分离的设计意图是调度时按保守估计预留运行时允许突发。但很多人只设limits不设requests或者两个设成一样都没理解这层分离。只设limits不设requests时requests默认等于limitsPod会被当成Guaranteed等级调度这其实是个不错的默认行为但会牺牲调度灵活性——节点上明明还有零碎资源却因为凑不出完整的limits而调度失败。只设requests不设limits时容器可以无限使用节点剩余资源一旦某个容器内存泄漏会把整个节点拖垮引发连锁驱逐。我的建议是核心服务requests等于limits钉死Guaranteed批处理、离线任务可以requests小于limits允许突发但设上限兜底。4.2 QoS三级Guaranteed、Burstable、BestEffort的判定规则与生存优先级QoS等级是kubelet自动判定的规则很明确所有容器都设了requests且等于limitsCPU和内存都要判定为Guaranteed至少一个容器设了requests或limits但不满足Guaranteed条件判定为Burstable所有容器都没设任何requests和limits判定为BestEffort。这个等级直接决定节点资源紧张时的驱逐顺序BestEffort最先死Burstable其次Guaranteed最后。这里有个反直觉的点Burstable内部的驱逐不是完全随机的kubelet会参考每个Pod的实际用量超出requests的程度来排序超出越多的越先被驱逐。所以即使同为Burstable一个requests设得很低但实际吃很多内存的Pod比一个requests设得接近实际用量的Pod更容易被赶走。这解释了为什么“把requests设小一点好调度”是个短视做法——省了调度赔了稳定性。4.3 内存超限的两种死法OOMKilled与节点驱逐的区别内存超limits和节点内存压力是两回事处理机制完全不同。容器内存超过自己的limits容器运行时比如containerd直接发SIGKILL杀掉容器kubelet重启它Pod状态里reason显示OOMKilledexitCode为137。这是容器级别的隔离影响范围仅限这个容器。而节点整体内存不足时kubelet触发驱逐按QoS排序挑Pod赶走被驱逐的Pod在整个集群层面重新调度。前者是“你自己吃太多”后者是“节点装不下了”。排查时区分这两者很关键。看到OOMKilled去查这个容器的内存实际用量和limits的差距考虑调大limits或者修内存泄漏看到Pod被驱逐Events里有The node was low on resource: memory要去查节点上哪些Pod在超用以及整体容量规划是否合理。我踩过的一个坑是一个Pod反复OOMKilled我一路调大limits结果把节点内存吃满触发驱逐把同节点上其他服务也连累了。后来才明白limits调大之前要先确认节点还有余量否则只是把容器级问题升级成节点级问题。4.4 CPU限制的隐形代价throttling与延迟毛刺CPU和内存的限制机制不一样。内存超了直接杀CPU超了不杀而是throttle——容器运行时用cgroup的CPU quota限制容器在每个调度周期内能用的CPU时间超了就等下一个周期。这个机制不致命但会制造延迟毛刺。一个CPU limits设得很紧的服务平时看着没事流量高峰时请求延迟突然飙升查日志没有任何错误查监控发现CPU throttling指标很高这就是limits在暗中使坏。CPU limits该不该设业界有争议。设了能防止单个容器吃满节点CPU影响邻居不设则能让容器在节点空闲时充分利用资源、避免throttling。我的实践是延迟敏感的服务API网关、实时计算尽量不设CPU limits或设得宽松靠requests保证基本配额批处理任务设紧一点无所谓反正不追求延迟。判断依据是看应用的throttling比例如果持续超过5%就该考虑放宽limits了。5. 多容器Pod与初始化容器协作模式与执行顺序5.1 init容器串行执行、必须成功、失败即重试init容器是在普通容器启动之前运行的专用容器可以有一个或多个按定义顺序串行执行每个都必须成功退出exitCode为0才会执行下一个全部成功后普通容器才开始启动。如果某个init容器失败kubelet会按restartPolicy重试它整个Pod卡在Initialized为False的状态。这个机制特别适合做启动前的准备工作等待依赖服务就绪、拉取配置、初始化数据目录、设置文件权限。init容器和普通容器的几个关键差异要记牢init容器不支持lifecycle钩子、不支持readiness探针它本来就不接流量、资源requests按“所有init容器中最大的那个”来计算而不是求和因为它们串行执行不会同时占用。最后这点在资源规划时容易算错一个Pod里三个init容器各要1核不代表这个Pod要3核调度时只按最大的1核算。但普通容器的requests是求和的这个区别要分清。5.2 边车模式主容器与辅助容器的生命周期耦合陷阱多容器Pod最常见的模式是边车sidecar主容器跑业务辅助容器做日志收集、监控代理、请求代理之类的事。它们共享网络命名空间同一个Pod IP端口不能冲突和存储卷生命周期绑定在一起——Pod被删除时所有容器一起终止。这个耦合是双刃剑好处是辅助容器能直接访问主容器的本地资源坏处是任何一个容器出问题都可能拖累整个Pod。最典型的陷阱是边车容器没有正确处理终止信号。Pod终止时kubelet给所有容器发SIGTERM然后等terminationGracePeriodSeconds超时后SIGKILL。如果边车容器收到SIGTERM后立刻退出而主容器还在处理最后的请求边车先死了主容器的日志就没人收集了。正确做法是边车容器要能感知主容器何时退出或者用preStop钩子做延迟退出。另一个陷阱是边车容器的资源没设limits它悄悄吃内存把节点拖垮主容器跟着遭殃。多容器Pod里每个容器的资源都要单独规划不能只管主容器。5.3 共享卷与网络命名空间的实操注意点同一个Pod内的容器共享网络命名空间意味着它们都用localhost互相访问端口必须错开。这个特性让边车代理模式很自然——边车监听localhost的某个端口主容器把请求发给它。但也带来一个坑如果两个容器都想监听80端口第二个会启动失败报端口占用。排查这类问题时看容器日志可能只看到“address already in use”要意识到是Pod内另一个容器占了端口。共享卷方面emptyDir是最常用的Pod级临时存储所有容器都能挂载同一个emptyDir实现容器间文件交换。但要注意emptyDir的生命周期跟Pod绑定Pod重建比如被驱逐后重新调度时数据就没了不能当持久化存储用。另外多个容器同时写同一个emptyDir里的同一个文件会有并发问题需要应用层自己加锁或者用不同的子目录隔离。我见过一个案例主容器和边车都往同一个日志文件写结果日志内容交错错乱最后是给每个容器分配独立的子目录才解决。6. 调度约束与优雅终止Pod落在哪、怎么走6.1 从nodeSelector到亲和性Pod落点的精细控制Pod调度默认由调度器按资源余量和打分算法决定但生产环境经常需要干预。nodeSelector是最简单的约束按节点标签硬匹配比如disktype: ssd只调度到SSD节点。但它太死板标签不满足就Pending没有回旋余地。nodeAffinity提供了更灵活的表达支持required硬约束和preferred软约束两种模式preferred模式下不满足也能调度只是打分低。podAffinity和podAntiAffinity控制的是Pod之间的相对位置。反亲和性最常用的场景是高可用——同一个服务的多个副本不要落在同一个节点或同一个可用区避免单点故障。配置时topologyKey决定“同一位置”的粒度填kubernetes.io/hostname表示按节点分散填可用区标签表示按可用区分散。这里有个性能陷阱podAntiAffinity在节点数量多、Pod数量大的集群里会显著增加调度延迟因为它要遍历检查每个候选节点上的现有Pod。大规模集群里更推荐用topologySpreadConstraints做均匀分布它的算法更高效。6.2 污点与容忍节点侧的准入控制污点是节点主动拒绝Pod的机制容忍是Pod声明“我能接受这个污点”。两者配合使用典型场景是专用节点——给GPU节点打上污点只有声明了对应容忍的Pod才能调度上去防止普通Pod占用宝贵资源。污点有三种效果NoSchedule表示新Pod不能调度上来但已有的不管PreferNoSchedule是软拒绝NoExecute最狠不仅拒绝新Pod还会驱逐节点上不满足容忍的现有Pod。NoExecute配合tolerationSeconds可以实现“节点失联后延迟驱逐”这个在节点网络抖动时很有用——节点短暂失联Pod不会立刻被赶走给它一点时间恢复。但要注意系统组件比如网络插件、监控代理通常需要容忍所有污点否则节点打上污点后这些基础组件被驱逐节点就彻底失联了。给节点打污点前先确认关键DaemonSet都配了对应的容忍。6.3 优雅终止全流程preStop、SIGTERM、宽限期的时序真相Pod被删除时完整流程是这样的API Server把Pod标记为Terminatingkubelet开始终止流程先执行preStop钩子如果配了preStop完成后给容器发SIGTERM同时从Service端点里摘除这个Pod然后等terminationGracePeriodSeconds超时后SIGKILL强杀。这里有个关键时序摘除端点和发SIGTERM是并行的不是先摘完再发信号。这意味着在端点摘除生效之前kube-proxy更新iptables规则有延迟可能还有新流量打进来而容器已经在处理终止了。这就是为什么preStop钩子几乎是必配的。一个常见的preStop是sleep 5给端点摘除留出传播时间让负载均衡器停止往这个Pod发新请求然后再让应用处理完存量请求退出。没有这个sleep应用可能在还有请求进来的时候就开始关闭造成少量5xx。terminationGracePeriodSeconds要大于preStop时间加上应用处理存量请求的最长时间否则preStop还没执行完就被SIGKILL了。我一般设30到60秒具体看应用的最长请求处理时间。注意preStop里的sleep不是偷懒是给服务发现传播留窗口。这个窗口该多长取决于你的负载均衡器和kube-proxy的更新频率太短没效果太长拖慢滚动更新。实测下来5到10秒覆盖大多数场景。6.4 滚动更新中的Pod替换节奏与maxSurge、maxUnavailableDeployment的滚动更新靠maxSurge和maxUnavailable两个参数控制节奏。maxSurge是更新过程中允许超出期望副本数的Pod数量maxUnavailable是允许不可用的Pod数量两者不能同时为0。设maxSurge为1、maxUnavailable为0意味着先起一个新Pod等它就绪后再杀一个旧Pod全程可用副本数不减适合对可用性要求高的服务代价是更新慢、峰值资源占用高。反过来maxUnavailable为1、maxSurge为0先杀旧再起新更新快但过程中会短暂少一个副本。这两个参数要和readiness探针配合看。滚动更新判断“新Pod可以继续下一步”的依据就是readiness探针通过如果readiness配得太宽松比如只检查端口新Pod还没真正准备好就被当成就绪旧Pod被杀了流量打到半成品上。所以滚动更新的可靠性一半在Deployment参数一半在readiness探针的准确性。我见过更新过程中出现短暂503的案例最后定位到是readiness只检查了HTTP端口没检查依赖新Pod端口通了但数据库连接还没建立就被切了流量。7. 常见问题排查实录与避坑清单7.1 Pod反复重启的排查路径Pod反复重启是最常见的问题排查要按证据链走。第一步看kubectl describe pod的Last State确认上一次退出的exitCode和reason。exitCode为137且reason为OOMKilled查内存exitCode为1查应用日志里的报错exitCode为143说明是收到SIGTERM正常退出那要看是不是liveness探针在误杀。第二步看Events有没有Liveness probe failed记录有的话说明是探针判定失败触发的重启要去核对探针参数和应用实际启动/响应时间是否匹配。第三步看容器日志的尾部崩溃前的最后几行往往就是根因。一个高频误判是应用启动要60秒liveness的initialDelaySeconds只设了30秒结果每次都在启动到一半时被liveness杀掉表现为无限CrashLoopBackOff。这种问题的迷惑性在于应用日志看起来“正常启动中”没有任何错误但就是起不来。解法就是加startup探针或者调大initialDelaySeconds。另一个高频问题是readiness探针指向的路径返回了非2xx比如配置错了端口或者路径Pod一直不Ready滚动更新卡住。7.2 常见问题速查表现象最可能原因排查命令/位置处理方向Pod一直Pending资源不足或亲和性不满足kubectl describe pod看Events检查节点资源、nodeSelector、污点反复CrashLoopBackOff应用报错或探针误杀看Last State的exitCode和Events修应用或调探针参数OOMKilled内存超limitskubectl describe看reason调大limits或修内存泄漏Pod Running但不Readyreadiness探针失败看Events的probe failed核对探针路径、端口、依赖滚动更新卡住新Pod一直不Readykubectl rollout status查新Pod的readiness终止很慢或被强杀宽限期太短或preStop阻塞看exitCode是否137调大grace period端口冲突启动失败Pod内容器端口重复容器日志address in use错开容器监听端口7.3 几个只有踩过才知道的细节第一个细节kubectl logs默认只显示当前容器实例的日志容器重启过的话上一次的日志要用--previous参数才能看到。排查CrashLoopBackOff时当前实例可能刚启动还没输出真正的崩溃原因在上一个实例的日志里不加--previous会以为日志是空的。第二个细节Pod的Events默认只保留一小时而且数量有限高频重启的Pod会把早期Events挤掉。排查偶发问题时要么及时抓取要么上集群级别的事件收集。我吃过亏一个隔几小时才复现一次的问题等我去看Events时早就被刷没了。第三个细节删除Pod时如果卡在Terminating很久可能是finalizer没清理也可能是节点失联kubelet无法确认终止。前者用kubectl patch去掉finalizer后者要等节点状态被清理或者手动处理。不要上来就--force --grace-period0强删强删只是从API Server删了记录节点上的容器可能还在跑造成资源泄漏。第四个细节多容器Pod里某个容器OOMKilled整个Pod会被重建因为restartPolicy是Always时容器重启但如果Pod被驱逐则是整体重建边车容器也跟着重启可能丢失边车里的状态。设计多容器Pod时要假设任何容器都可能随时重启不要把关键状态放在容器本地。8. 我个人在Pod配置上的几条硬规矩配了这么多Pod踩了这么多坑我现在写Pod模板有几条不太会妥协的规矩。第一凡是启动可能超过10秒的应用一律配startup探针不靠initialDelaySeconds硬扛因为启动时间会随数据量波动硬编码的延迟迟早不够用。第二readiness探针必须检查真实依赖不能只检查端口宁可多写一个healthz接口也不要让半成品接流量。第三核心服务的requests等于limits钉死Guaranteed宁可调度时多等一会儿也不要在资源紧张时第一个被驱逐。第四preStop的sleep和terminationGracePeriodSeconds要一起算宽限期必须大于preStop加最长请求处理时间否则优雅终止就是句空话。这些规矩不是拍脑袋定的每一条背后都是一次线上故障或者一次半夜排查。Pod这个东西上半场学的是语法下半场学的是它和调度器、kubelet、容器运行时之间的协作契约。把这些契约搞清楚了你写的Pod才不是“能跑”而是“能扛”。后面如果还要往下走可以接着看控制器怎么管理Pod副本、HPA怎么根据Pod指标扩缩容那些是Pod之上的另一层故事了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑