在Kubernetes里摸爬滚打这些年,我越来越确信一个规律:很多看起来玄乎的线上故障,追根溯源都绕不开两个词——Pod生命周期和Pod资源管理。要么是Pod反复重启、永远Ready不了,要么是requests和limits配得稀里糊涂,导致Pod不是Pending到天荒地老,就是被OOMKilled一刀切。这篇文章不打算讲教科书式的概念,而是把我在实际运维中怎么理解生命周期、怎么配置资源参数、怎么排查故障的经验完整捋一遍,适合刚入门K8s的开发者,也适合被线上Pod折腾到失眠的运维同学。
1. 先把Pod的“人生轨迹”理清楚:生命周期到底包含什么
1.1 从提交到回收:Pod的完整旅程
Pod的生命周期,本质上就是从你执行kubectl apply提交给API Server开始,直到Pod被彻底清理回收为止的一段完整旅程。在用户视角里,Kubernetes把它抽象成了几个Phase:Pending、Running、Succeeded、Failed、Unknown。
- Pending:Pod已经被API Server接受并写入etcd,但还没有完全就绪。这个阶段可能是调度器正在寻找合适的节点,可能是镜像还没拉完,也可能正卡在init container执行阶段。排障时需要进一步确认卡点在哪里。
- Running:Pod已经成功绑定到某个节点,至少有一个容器处于运行状态。注意,这里的“运行”不一定是健康运行,可能正在启动,也可能已经进入重启循环。
- Succeeded:Pod内所有容器都正常退出,退出码为0,且不会重启。这种状态通常出现在Job、CronJob这类一次性任务场景。普通Deployment管理的Pod极少出现这个状态,除非你手动把副本缩到0。
- Failed:所有容器都已终止,但至少有一个容器以非零退出码退出,或者被系统强制终止。这个状态意味着任务执行失败。
- Unknown:API Server无法获取Pod当前状态,绝大多数情况是节点失联、kubelet无法上报心跳导致的。
这个Phase表看起来简单,但新手特别容易踩一个坑:以为Pod进入Running就等于万事大吉。其实Phase只是一个粗粒度的指示器,它只告诉你Pod“大体上活着”,完全不代表Pod的业务逻辑是健康的。我见过太多同事看到Pod是Running就不再深究,结果流量已经断了半天。
1.2 Phase只是指示器:真正要盯的是Condition和ContainerState
线上排障时,单独看Phase远远不够,真正决定问题定位效率的,是PodConditions和ContainerState这一层细节。
PodConditions是Kubernetes通过探针和调度结果推断出来的一组状态标签,常见的有:
- PodScheduled:Pod是否已经完成调度。如果是False,下面通常会有Unschedulable相关的reason。
- Initialized:所有init container是否执行完成。
- ContainersReady:Pod里的所有容器是否都通过了readiness检查。
- Ready:Pod整体是否就绪,只有这个字段为True,Pod才会被纳入Service的Endpoints承担流量。
- DisruptionTarget(1.21以后出现):Pod是否因为节点维护、驱逐等原因即将被终止。这个Condition对理解Pod突然消失非常有帮助。
每个Condition里还藏着lastProbeTime、lastTransitionTime、reason和message。遇到问题先看kubectl describe pod,Events区和Conditions区通常会直接告诉你答案,比瞎猜快得多。
再往下拆,就是ContainerState。单个容器的状态分三种:
- Waiting:容器尚未开始运行,常见reason包括ContainerCreating、ErrImagePull、CrashLoopBackOff等。
- Running:容器正在执行,但你别忘了核对它的Ready字段。
- Terminated:容器曾运行但现在已退出。这里一定要看exitCode,137代表被杀(OOMKilled或SIGKILL),1一般是业务代码异常退出。
我习惯的排查顺序是四步:第一步kubectl get pod看Phase和RESTARTS;第二步kubectl describe pod看Conditions和Events;第三步看容器State和exitCode;第四步才去看日志。这个顺序看起来笨拙,但能保证你不被日志里的噪声带偏,直接锁定问题发生的层级。
这里顺便说下“有效生命周期”这个词。一个Pod从Ready变成True,到它开始被终止退出,这段时间对线上服务来说才是真正“有效”的。探针、readiness、preStop钩子这些设计,本质上都是在延长或保护这段有效生命周期,后面我会详细讲。
2. 生命周期里的三个关键旋钮:RestartPolicy、钩子与探针
2.1 restartPolicy:决定容器“倒下后”谁来管
restartPolicy是PodSpec里被忽视得最厉害的一个字段,但它直接决定了容器退出后的行为。它有且只有三个值:Always、OnFailure、Never。
- Always:不管容器以什么方式退出,kubelet都会在本机重启它。Deployment、StatefulSet管理的Pod默认就是Always。
- OnFailure:只有当容器以非零退出码退出时才会重启。正常退出(exitCode=0)不会触发重启,适合Job这类任务型Pod。
- Never:容器一旦退出就不再本地重启。任务型Pod如果失败,可以依靠控制器重新调度新Pod。
这里必须澄清一个大坑:restartPolicy管的是同一个节点上、同一个容器组的“原地重启”,不是把Pod重新调度到别的节点。Pod能不能切换节点,是Deployment、StatefulSet这类控制器基于副本数和调度策略决定的。很多人把restartPolicy当成“高可用机制”,这是不对的。
另外kubelet重启容器不是无限连击,而是采用指数退避策略:第一次重启等待约10秒,之后每次翻倍,最大等300秒。所以你会看到CrashLoopBackOff这个状态。注意它还带一个exponential backoff的特性,连续失败几次之后,容器的重启间隔会越来越长,这是kubelet在保护自己,避免把CPU和IO全耗在无意义的重启上。如果你在监控里看到restartCount保持在某个数字不涨,但Pod状态一直不是Ready,多半就是处于退避等待阶段。
2.2 postStart与preStop:容器的入场和退场回调
生命周期钩子提供了两个时机:postStart和preStop。
postStart在容器创建后立即执行,注意它和容器的entrypoint命令是并行的,并不保证钩子先执行完,主进程才启动。这意味着你可能以为postStart里完成了某个初始化动作,但实际上主进程已经抢跑。如果postStart本身就执行失败,kubelet会杀掉容器并按照restartPolicy决定是否重启,同时Events里会出现PostStartHookFailed。
preStop则刚好相反,它是阻塞的。当Pod收到删除请求时,kubelet会先执行preStop里定义的命令,等它执行完,再发送SIGTERM给容器主进程。这个特性非常适合做优雅退出:
- 从注册中心下线服务
- 从负载均衡摘除流量
- 等待存量请求处理完成
- 刷新缓存或持久化内存中的状态
我踩过一个非常深的坑:某次运维同学把数据库迁移脚本写进preStop,结果每次滚动更新都触发一次数据变更,最后演变成生产事故。preStop里只适合放轻量、幂等、快速的下线动作,千万别放重量级任务。另外,preStop的执行时长受terminationGracePeriodSeconds约束,如果钩子执行时间超过这个期限,SIGTERM不会来,等来的直接是SIGKILL强杀。默认值30秒,很多人没意识到这个字段的存在,导致超大流量应用在优雅退出上形同虚设。
2.3 探针:liveness/readiness/startup如何配置才不误伤
探针是生命周期与流量调度的桥梁,三种探针角色完全不同:
- livenessProbe:判断容器是否“活着”,失败后kubelet杀死容器并重启。适合检测死锁、长期卡死这类不可恢复状态。
- readinessProbe:判断容器是否有能力接收流量,失败后从Service的Endpoints中摘除,但容器本身继续运行。适合检测依赖是否就绪。
- startupProbe:专门给慢启动容器准备的。在startupProbe成功之前,其余探针都不会开始执行,避免容器启动慢导致被liveness误杀。
举一个最典型的案例。JVM应用冷启动经常要60秒甚至90秒,如果只配了livenessProbe且initialDelaySeconds只给30秒,大概率容器会在启动阶段被打死,然后陷入启动即重启的死循环。正确做法是配一个startupProbe,把periodSeconds设成10,failureThreshold设成12,这样给容器最多120秒启动时间;startup成功后,livenessProbe再接管日常健康检查。
参数上的建议也一并说了:
periodSeconds默认10,不建议小于5,否则探针本身会对容器产生额外的CPU和端口压力。timeoutSeconds默认1,如果接口本身要几百毫秒才能返回,务必调大,避免误杀。failureThreshold连乘periodSeconds就是“失败容忍时长”,配置前先算清楚。initialDelaySeconds对startupProbe无效,startupProbe从容器启动那一刻就开始计时。
探针的探测方式有exec、httpGet、tcpSocket、grpc四种。日常最常用的是httpGet和exec。grpc探针需要额外开通grpc健康检查协议支持,不是所有服务都适用。我个人的习惯是:核心业务用readiness+liveness组合,慢启动服务再加startup,绝不只依赖单一探针。
3. Pod资源管理的底层逻辑:requests/limits与QoS
3.1 CPU和内存的计量方式:为什么CPU可压缩,内存不可压缩
资源管理是Pod生命周期能够稳定运行的地基。理解requests和limits之前,必须先理解CPU和内存的本质区别。
CPU是可压缩资源。你用cpu: 500m表示半个核,100m表示0.1个核。Kubernetes通过cgroup的CPU配额机制(CFS quota)实现限制:当容器实际使用的CPU超过limits时,内核会强制限流,让容器进入throttle状态,表现为CPU使用率被“削平”,但进程不会死。所以CPU limits设低一点,最多是性能变差,不会直接导致进程退出。
内存是不可压缩资源。超过内存limit,内核OOM killer会直接找一个进程杀掉,对应到容器侧就是OOMKilled,exitCode为137。不可压缩的意思就是内存用完就是没了,没法像CPU那样排队等待。这决定了内存相关的配置必须保守,宁多勿少。
还有一个关键点经常被忽略:调度器只看requests,不看limits。也就是说,一个Pod能不能调度到某台节点上,只取决于它声明的requests是否在节点allocatable容量内。limits只对运行时生效。这导致一个经典问题:大量Pod只设了limits没设requests,节点上每个Pod的requests都很小,调度器可能把它们堆在同一台机器上,结果运行时大家一起撞limits,发生CPU争抢或内存超卖。
如果你写了limits没写requests,Kubernetes会默认把requests“顶格”到和limits一样。这本身是一种保护机制,但也意味着调度时的资源占用会比你想象的高。所以一定要显式声明requests,不要让系统悄悄替你决定。
3.2 QoS三个等级:Guaranteed、Burstable、BestEffort
根据requests和limits的组合方式,Kubernetes给每个Pod划分了QoS等级,这个等级直接决定节点资源紧张时谁先被驱逐、谁先被OOM。
- Guaranteed:Pod内每个容器都显式设置了CPU和内存的requests,且requests等于limits。这是最高优先级,平时节点资源紧张时最不容易被驱逐,进程也不容易被OOM killer盯上。
- Burstable:至少有一个容器设置了requests或limits,但存在某容器requests不等于limits,或只有requests没有limits。这是日常最常见的一类。
- BestEffort:所有容器都没有设置任何requests和limits。这类Pod在节点内存压力时是第一个被驱逐的,OOM打分也最高。
驱逐顺序很残酷:BestEffort最先死,其次是Burstable中内存使用率超过requests比例高的,最后才是Guaranteed。简单说,Guaranteed相当于给关键业务买了一张“优先存活”的保险。
我强烈建议:核心业务、有状态服务、数据库类应用,一律配成Guaranteed。这样系统行为可预期,不会因为邻居Pod的突发流量影响到你的稳定性。非关键的离线任务、测试环境Pod,可以用Burstable来提升节点利用率。
3.3 requests和limits设置不当会怎样
这里分享几个我实际处理过的案例。
requests设置过高:有一个团队把requests.cpu写成4,但实际业务平均只用0.5核。结果就是集群明明有很多节点的CPU利用率很低,新Pod却一直Pending。一看节点Allocated resources,发现CPU requests已经被“纸面资源”占满了。这不是容量不够,是requests占位不够。解决办法是统计一周实际用量,把requests压到P50甚至P30水平,给突发流量留出余量。
limits设置过低:最常见的是JVM应用。Java默认按照宿主机内存计算堆大小,如果你分配的Pod limit是1Gi,但宿主机的内存是64Gi,JVM启动时可能给自己预留了十几G堆,一跑起来直接超limit被OOMKilled。现代JDK可以加参数-XX:MaxRAMPercentage=75让JVM感知容器限制,或者直接用容器内存配堆。别小看这个细节,很多CrashLoopBackOff的根因就是它。
只设置limits不设置requests:requests会被隐式拷贝成limits,导致调度时被当成一个大块头,明明可以落两台小机器,结果一台都塞不下。这个坑的隐蔽性很高,因为YAML里你只写了limits,但kubectl describe pod会发现requests也是同样数值。
4. 资源管理的落地操作:LimitRange与ResourceQuota
4.1 LimitRange:给单个Pod戴上限
纸上谈兵说完QoS,实际落地时最常用的控制手段是LimitRange和ResourceQuota。前者管单个Pod,后者管整个命名空间。
LimitRange在命名空间级别定义Pod和容器的资源边界。它可以限制最大值、最小值、默认值,以及limits和requests的最大比例。一个典型配置长这样:
apiVersion: v1 kind: LimitRange metadata: name: example-limitrange namespace: dev spec: limits: - type: Container max: cpu: "2" memory: 2Gi min: cpu: "20m" memory: 32Mi default: cpu: "200m" memory: 256Mi defaultRequest: cpu: "100m" memory: 128Mi maxLimitRequestRatio: cpu: "10" memory: "10"default和defaultRequest的作用是:当Pod没写requests或limits时,自动补上。这个默认值非常重要,因为在企业环境里,我们没法保证每个开发都会认真写资源字段。LimitRange配合default,能让所有新Pod一出生就带上合理的资源声明,不至于跑着跑着变成BestEffort。
需要提醒一点:maxLimitRequestRatio设成10表示limits最大是requests的10倍。这个可以防止有人把requests写得很小、limits写得很大,既享受低requests带来的调度便利,又占着高limits的资源承诺。这其实是一种资源管理上的“占位套利”,值得用配额限制。
4.2 ResourceQuota:命名空间级的总量控制
ResourceQuota管的是命名空间里所有Pod加在一起的资源总量。它通常在apiserver准入控制阶段生效,Pod创建时如果发现总量超配,直接拒绝创建。示例:
apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: dev spec: hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi pods: "20" persistentvolumeclaims: "4"这个配置表示dev这个命名空间里,所有Pod的CPU requests总和不能超过4核,内存requests总和不能超过8Gi,所有Pod的limits总和不能超过8核和16Gi,同时Pod总数不能超过20个,PVC不能超过4个。
这里有一个隐藏很深的坑:ResourceQuota统计的是requests和limits字段本身,如果Pod没有设置这两个字段,而命名空间里又配置了ResourceQuota,创建Pod会直接报错,错误信息一般是“failed quota: dev-quota must specify requests.cpu, requests.memory”。所以ResourceQuota和LimitRange通常要成对出现:LimitRange负责给Pod补默认值,ResourceQuota负责在总量上卡预算。只配Quota不配LimitRange,等于是把所有没写资源字段的Pod全部拒之门外,开发会疯掉的。
另一个坑是:配额只在新Pod创建时校验,已运行Pod不受影响。也就是说,某团队忽然删掉了一个大Pod,释放出来的配额不会立刻被Pending的Pod追认,需要重新创建或者等待调度器重试。线上遇到配额不足,最稳的套路是删掉不用的Pod,再重建依赖它的Deployment。
4.3 实操配置示例:从一个命名空间看资源完整性
我建议每个命名空间都做成“LimitRange + ResourceQuota + 显式资源声明”三件套。实际配置顺序和验证方式是这样的:
- 创建命名空间:
kubectl create ns dev - 应用LimitRange,保证新Pod自动带上默认资源值。
- 应用ResourceQuota,限制总量。
- 用一个Deployment验证默认值是否生效:
kubectl apply -f https://k8s.io/examples/controllers/nginx-deployment.yaml kubectl get pod -o yaml | grep -A6 resources如果配置正确,你会看到Pod的resources字段里带上了LimitRange注入的默认值。再用kubectl describe resourcequota dev-quota查看配额使用情况,可以清晰看到当前已用和剩余。
这里还有一个实用命令:kubectl get limitrange example-limitrange -o yaml,查看LimitRange的当前生效状态。注意,修改LimitRange只会影响之后新建的Pod,已经在运行的Pod不会跟着改。所以资源规范最好在命名空间创建之初就定好,否则后面“补课”的代价很高。
5. 常见故障与排查实录
5.1 Pod一直Pending:从调度的角度排查
Pod卡在Pending是最高频的问题。排查思路一定要按层级展开:
- 第一层看Events:
kubectl describe pod <name> | grep -A 20 Events,如果看到FailedScheduling,说明是调度失败。 - 第二层看节点资源:
kubectl describe node <node>,重点看Allocated resources和Allocatable。如果CPU/内存的requests已经接近上限,说明节点容器被纸面资源占满了。 - 第三层看污点:节点如果有NoSchedule污点,而Pod没有对应容忍,必然Pending。
kubectl describe node里能直接看到Taints。 - 第四层看PVC:如果Pod挂载了PVC,而PVC处于Pending,Pod也会卡住。
kubectl get pvc一看便知。
我处理过一个比较迷惑的案例:Pod的requests只有500m,集群也大把空闲节点,但依然Pending。最后查出来是Pod设置了nodeSelector,而带指定label的节点全部处于NotReady状态。调度器的视角里,Pod在逻辑上没有可容纳它的地方,这和资源充足与否没有关系。
还有个技巧:如果Pod的Events里报的是Unschedulable,可以试试kubectl get events --sort-by=.metadata.creationTimestamp把全局事件按时间排一下,往往能看见调度器给出的更多细节。
5.2 Pod反复重启:OOMKilled与CrashLoopBackOff的现场复盘
Pod反复重启的两种最常见原因,一种是内存超限被OOMKilled,一种是业务进程启动即崩导致CrashLoopBackOff。
OOMKilled的典型特征:kubectl describe pod里ContainerState的reason显示OOMKilled,exitCode是137。这时去kubectl logs看到的日志往往很少,因为进程直接被内核杀掉了,根本没机会留下堆栈。正确排查方式是看实际内存使用:kubectl top pod <name>,再和limits对比,基本一眼就能看出是不是超limit了。
如果确认是内存问题,调整策略不是简单把limit翻倍,而是:先连续观察3到5天的内存曲线,取P90或者最大值,再乘1.2到1.5的安全系数作为新limit。如果是JVM或者Golang这类运行时,先检查是否感知容器限制,避免在宿主机视角下给堆分配了过多内存。
CrashLoopBackOff的特征更复杂一些。容器可能启动了几秒就退出,退出码可能是1、127、128等等。这时kubectl logs是主力工具,如果日志里没有有效信息,可以用kubectl logs <pod> --previous查看上一次容器实例的日志。很多情况下崩溃原因在最后一次日志里已经写明,只是你没看对容器实例。
5.3 镜像拉取失败:ImagePullBackOff排查思路
ImagePullBackOff和ErrImagePull虽然不会直接导致容器退出,但会让Pod永远无法进入Running。常见原因有四类:
- 镜像名或tag写错,最常见的是少写了镜像仓库前缀或tag不存在。
- 镜像仓库认证失败,拉取私有仓库镜像时没有配置imagePullSecret。
- Registry不存在或网络不通,尤其是自建仓库地址错误。
- 镜像被误删或tag被覆盖,导致旧的tag无法拉取。
排查第一步永远是kubectl describe pod,Events里会直接给出拉取失败的具体原因。比如Failed to pull image "xxx:latest": rpc error: code = NotFound,基本就是镜像名或tag问题。如果报unauthorized,那就是认证问题,需要在同一个命名空间创建imagePullSecret并在Pod的spec里引用。
5.4 从监控角度守护Pod的有效生命周期
最后补充一下怎么用监控盯住这些故障。我强烈建议在监控面板里至少放四个核心指标:
kube_pod_container_status_restarts_total:容器重启次数,快速发现CrashLoop。- OOMKilled事件计数:可以通过kubelet日志或Prometheus的
kube_pod_container_status_terminated_reason统计。 - 内存使用vs limits:用
container_memory_working_set_bytes除以container_spec_memory_limit_bytes,超过0.9就该告警。 - CPU throttle时间:
container_cpu_cfs_throttled_periods_total增长异常,说明CPU limit确实压住了业务。
这几个图能覆盖绝大多数资源类故障。我见过太多团队只盯CPU和内存均值,结果Pod已经被OOMKilled重启了三轮,监控面板上还是绿灯。原因就是聚合粒度太粗,异常被平均值抹平了。
6. 写在最后:从“有效生命周期”角度谈资源管理
我个人在实际操作中的体会是:Pod生命周期和Pod资源管理从来就不是两个独立的话题,它们共同决定了一个Pod的“有效生命周期”——从开始正常处理流量到被安全终止,这段时间到底有多长。
有一个很典型的场景:业务滚动发布时,老Pod要被终止。如果不做任何优雅退出处理,kubelet会直接给容器发SIGTERM,很多服务收到后立刻断开连接,正在处理的请求全部失败。我在生产环境常用的方案是给容器加一个preStop钩子,里面执行sleep 10,同时在PodSpec里把terminationGracePeriodSeconds调大到45秒。这样kubelet会在删除Pod时先等10秒,让新Pod完成启动并从Service拿到流量,再发送SIGTERM给老Pod,实现一种接近无损的切换。
这个方案在Nginx ingress和Spring Cloud服务上我都验证过,实测下来很稳,但要注意sleep时间不能太长,否则滚动更新的耗时会被无限拉长。
再补充一个实用建议:调任何资源参数之前,先用kubectl top pod导出至少一周的实际用量曲线,然后根据P90或P95来定requests,根据P99或最大值来定limits。别拍脑袋,更别照抄官方示例里的数值。资源配比这事,每家业务差异太大,只有基于自身数据做出来的配置才靠谱。
踩过几次坑之后,我现在看Pod的状态不再只盯着Phase,而是习惯性把describe里的Conditions、ContainerState、QoS等级、配额使用量全部扫一遍。把这些事当成肌肉记忆,线上的Pod才能真正做到心里有数。