1. 先搞清楚Pod的两个"生命周期":运行状态与调度状态
1.1 PodPhase不等于容器状态
我经常在答疑群里看到一种现象:有同学看到kubectl get pod返回的Running,就觉得一切正常;等业务反馈服务不可用,再查才发现容器其实一直在崩溃重启。问题出在哪?就是搞混了Pod的"阶段"和"容器状态"这两个完全不同的概念。
PodPhase(阶段)是Kubernetes对Pod整体生命周期所处位置的宏观描述,它只有五个值:Pending、Running、Succeeded、Failed、Unknown。而容器状态是kubelet通过容器运行时上报的细粒度信息,比如Waiting、Running、Terminated。一个Pod处于Running阶段时,里面的容器完全可能正在"反复崩溃→重启→再崩溃",只是重启策略掩盖了故障表象。
用一个生活类比来说:PodPhase像"一个人是否在职",容器状态像"这个人此刻是精神抖擞还是带病硬撑"。在职的人也可能状态很差,但Kubernetes认为"只要进程还在跑,重启策略还允许拉起,这个Pod就没结束"。理解这层关系,是后面排查一切生命周期问题的前提。
1.2 从调度到运行:Pod的完整诞生链
创建一个Pod时,真正的事件链路比大多数人想的要长。我见过不少同学只盯kubectl get pod就看结论,其实Pod诞生过程大致分这么几步:
- 客户端请求打到kube-apiserver,经过认证、授权、Admission Controller(比如ResourceQuota、LimitRange这些准入插件)校验后,Pod被写入etcd。
- kube-scheduler监听到未绑定的Pod,按节点资源、亲和性、污点容忍等条件选一个节点,然后通过绑定操作把Pod与节点绑定。
- 节点上的kubelet收到新Pod的分配信息,开始调用容器运行时(比如containerd)拉镜像、创建沙箱、启动容器。
- 容器启动成功后,kubelet持续上报状态,探针开始按周期执行,Pod进入
Running。
很多Pod卡在Pending,就是第一步和第二步之间的某个环节没走通。排查时别瞎猜,kubectl describe pod是第一个该敲的命令。去看Events里的调度失败信息,常见有三类:节点CPU/内存不足(资源requests加起来超出节点可分配量)、节点有taint但没有对应容忍、Pod声明了PVC但PersistentVolumeClaim没绑定成功。
我曾经有个模拟项目X,线上Pod全部卡Pending,describe后才发现是某个节点被打了NoSchedule污点,而新业务Pod没写对应容忍。这不是高深问题,但如果不按事件链排查,很容易在错误的方向上浪费半天。
1.3 重启策略与状态机的联动
PodSpec里的restartPolicy只有三个取值:Always(默认)、OnFailure、Never。它决定了Pod内部容器退出后kubelet是否帮忙拉起,以及Pod最终走向哪个阶段。
Always:任何退出都重启,最常见,适合Deployment托管的无状态服务。OnFailure:容器以非0退出码结束时才重启,适合批处理任务。Never:无论正常还是异常退出都不重启,适合明确只想跑一次的任务。
很多人忽略的一点是:重启策略影响的是"容器层面"的重启,Pod本身不会因此进入Failed。只有容器退出了、但重启策略又不允许重启时,Pod才真正走向Failed或Succeeded。换句话说,你的服务是否持续可用,一大半取决于这个字段。
我在实际运维里见过最坑的配置:一个批处理job,容器脚本偶尔返回1,开发者图省事用了默认的Always,结果任务永远"没完没了",job一直不结束,还要额外付计算成本。这类场景应该显式用OnFailure或Never。
2. 终止流程:优雅退出才是生产环境的基本素养
2.1 删除Pod时到底发生了什么
生产环境里"删Pod"是家常便饭,滚动发布、扩缩容、节点维护,都少不了它。但很少人真正关心Pod销毁时的动作序列。我一度也以为删除就是"把容器杀掉完事",直到一次灰度发布时线上连接数瞬间掉到0,才被迫把这个流程摸了个底朝天。
当你执行kubectl delete pod时,实际发生的是:
- Pod对象被标记为删除,进入Terminating状态。
- kubelet收到通知,先并行执行PreStop钩子(如果配置了)。
- PreStop执行完(或超时)后,kubelet向主容器进程发送
SIGTERM信号。 - 容器进程开始优雅收尾,比如关闭HTTP监听、等待在途请求处理完、注销服务发现。
- 如果在
terminationGracePeriodSeconds(默认30秒)内进程没有退出,kubelet会发送SIGKILL强杀。 - 完成资源清理后,Pod对象从API Server消失。
关键点是:SIGTERM发出后,系统给的"善后时间"由terminationGracePeriodSeconds控制。30秒听起来很长,但对依赖链复杂的服务来讲可能完全不够,对快速退出的进程又显太长。
2.2 PreStop钩子的正确打开方式
PreStop是一个"在容器被停止前执行的钩子",可以运行一段命令,或者发起一个HTTP请求。很多业务场景里,直接裸用SIGTERM很难优雅收尾,比如:
- 应用没实现信号处理,收到SIGTERM直接退出,在途请求就会断。
- 注册中心需要先摘除本实例,否则流量还会打过来。
- 消息队列消费者需要把正在消费的消息处理完,或者主动reject。
这种时候,PreStop就能帮你"抢时间"。比如一个Web服务可以这样配置:
lifecycle: preStop: exec: command: - /bin/sh - -c - "sleep 5"很多人的第一反应是"这不是脱裤子放屁吗?"其实不是。这个sleep 5的典型用途是给上线前的滚动更新留一点时间差:Endpoints或Service的规则已经变更,但负载均衡器可能还没完全摘掉旧Pod,先睡5秒,再进SIGTERM流程,新请求大概率已经切到别的Pod上。这是业内常见的"平滑退出"土办法,虽然粗暴,但有效。
如果你用的是服务网格或注册中心模式,PreStop里通常要写一段脚本去调注册中心的下线接口。这时候注意,PreStop的执行时间也算在terminationGracePeriodSeconds内,如果hook本身卡死,整个Pod销毁会被拖住并最终强制结束。
2.3 优雅退出的时间窗口设置
terminationGracePeriodSeconds这个字段平时没人管,出问题才想起来。我遇到过这样一个场景:某Java服务启动特别慢,但退出时需要等待线程池排空、数据库连接池关闭,实测优雅退出至少需要45秒,而默认只有30秒。每次发版都会看到一堆"断连"告警。
解决办法很直接,在Deployment里显式加长:
spec: terminationGracePeriodSeconds: 60这个值不是越大越好。设太大,kill一个还被卡着的Pod要等很久;设太小,进程来不及收尾就收到SIGKILL,可能导致状态没落盘、数据没刷完。我个人的经验是:先压测出业务最慢的优雅退出耗时,再乘以1.5或者加20%的冗余。例如压测结果50秒内必然结束,就设75秒到90秒。
还要提醒一点:如果你用kubectl delete pod --force --grace-period=0强制删除,就完全绕过了上面所有流程,Pod会立即从API Server移除,但底层容器可能还在跑,会造成"幽灵容器"。生产环境除非明确要处理卡死Pod,否则别用这招。
2.4 强制删除的代价与使用场景
说到强制删除,我得讲清楚代价。强制删除相当于告诉Kubernetes"别等了,直接抹掉这个对象"。但容器运行时的清理可能没跟上,尤其是有状态服务挂载了块存储时,可能会留下卷挂载泄漏、锁未释放之类的问题。
那么什么时候必须强制删?一般就两类:一是Pod卡在Terminating状态超过几分钟,正常删不掉;二是有状态服务的Pod节点已经失联,kubelet无法完成删除回调,需要人工接管。处理时我会先确认底层进程是否还在,再做强制删除,删完回头检查节点上的容器和volume是否清理干净。
3. 探针:生命周期里的健康信号
3.1 三种探针与各自职责
探针是Kubernetes判断容器健康状况的"体检设备",分三种:livenessProbe(存活探针)、readinessProbe(就绪探针)、startupProbe(启动探针)。
- 存活探针失败,kubelet会按重启策略处理,杀掉容器并重启。
- 就绪探针失败,Pod不会重启,而是从Service的Endpoints里摘掉,流量不再转发给它。
- 启动探针是给"启动非常慢"的容器用的,它在启动探针成功前,不会启动其他探针,避免老服务启动要两分钟、存活探针却30秒就把它杀了。
很多人问我:"为什么存活探针失败要重启,就绪探针失败却不重启?"因为两者的目标完全不同:存活探针回答的是"这个进程还活着吗,要不要急救",就绪探针回答的是"这个进程活着但能承接流量吗,不行就先别给它派活"。一个管生死,一个管流量,混用场景会出大问题。
3.2 配置探针时的常见陷阱
我见过最典型的配置错误是把探针的periodSeconds设成1或2,failureThreshold设成1。这意味着只要有一次请求超时,容器就被重启。而网络抖动、高负载下的偶发慢响应,都会成为"误杀"导火索。真要造成线上大规模重启,谁都扛不住。
我的建议参数起步线是这样:
| 参数 | 存活探针推荐值 | 就绪探针推荐值 |
|---|---|---|
| initialDelaySeconds | 根据启动耗时设定,建议 ≥ 10 | 同左 |
| periodSeconds | 10 | 10 |
| timeoutSeconds | 5 | 5 |
| failureThreshold | 3 | 3 |
| successThreshold | 1 | 1(滚动更新场景可设2) |
这里特别提醒:写HTTP探针时,httpGet的path别用根路径/,很多框架根路径会返回404或者302。探针只认2xx和3xx,404会直接把容器判死。尽量给应用单独加一个/healthz之类的健康检查接口,里面只做轻量逻辑,别去查数据库或调用第三方服务,否则数据库抖动一下,你的Pod全被重启。
另一个高频坑是探针里用了command,但命令本身有超时问题。比如probe执行pg_isready检测数据库,如果数据库连不上,命令可能卡在网络层直到timeoutSeconds超时,白白消耗周期。这种情况我会换用轻量TCP探测,或者让脚本自身加timeout命令包一层。
3.3 探针与滚动更新的配合
滚动更新(RollingUpdate)能否做到"用户无感",就绪探针起着决定性作用。新Pod的readiness探针没通过时,它不会接收流量;但Deployment默认的maxSurge和maxUnavailable会让旧Pod继续服务,直到新Pod真正就绪。
我踩过这样一次坑:某服务滚动更新时,新Pod明明已经Running,但在LoadBalancer层面还是有一瞬间的502。检查发现,就绪探针用的是initialDelaySeconds=0、periodSeconds=5,端口监听一建立就认为就绪。实际上应用内部还需要加载几百兆的配置缓存,此时HTTP监听是通的,但请求过来会超时。
修复也简单:把就绪探针指向一个"业务真正就绪后才会返回200"的接口,并把initialDelaySeconds调到20秒以上。印象最深的教训是:探针关心的不是进程活着,而是"这个实例现在可以接客了"。
4. 资源管理的起点:requests与limits不是一回事
4.1 为什么需要把资源声明落到Pod上
很多新手创建一个Pod时不写任何资源字段,照样能跑。但到了集群资源紧张的关头,这种Pod会成为最先被"开刀"的对象,而且调度器也没法合理规划节点负载。换言之,不写资源声明,等于把Pod的未来完全交给运气。
requests和limits是Pod内容器层面的两个核心字段,含义完全不同:
requests是"我保底要这么多",kube-scheduler依据它决定把Pod放到哪个节点,kubelet也用它参与节点资源计算。limits是"最多只能用这么多",超过这个上限,CPU会被限流,内存会被直接杀掉进程(OOMKilled)。
一个常见误区是只配limits不配requests。Kubernetes的规则是:如果只配limits,requests会自动继承limits的值。结果就是每个容器都按上限申请资源,调度器就会以为集群资源全被占满,后续Pod排队等不到位置,可用容量反而降了。这类"隐形超卖"问题我在某测试集群踩过一次,排查半天才从配额用量里看出端倪。
4.2 CPU与内存:可压缩与不可压缩的本质差异
CPU和内存是两种完全不同的资源,不理解这个本质,资源设计一定会踩坑。
CPU是可压缩资源。容器用满CPU limit后,不会死,只是被节流(throttled),请求变慢,但进程还活着。这就像高速公路限速,你油门踩到底也就跑那么快,并不会翻车。
内存是不可压缩资源。一旦容器使用的内存达到limit,内核的OOM Killer会介入,直接杀掉超出内存限制的进程。更麻烦的是,节点整体内存压力大时,即使单个容器没超自己的limit,也可能因节点内存不足被整体驱逐。
我用一个表格来对比,会看得更清楚:
| 维度 | CPU | 内存 |
|---|---|---|
| 资源类型 | 可压缩 | 不可压缩 |
| 达到limits后 | CPU Throttling,性能下降 | OOMKilled,进程被杀 |
| requests作用 | 参与调度与节点分配 | 参与调度与节点分配,内存压力下影响驱逐顺序 |
| 是否可回收 | 调度器静态分配,运行时动态争抢 | 不可争抢,用完就崩 |
这决定了我们在设置值时,内存的余量要留得更保守。CPU可以按业务平均负载再稍微收紧,内存却要按"业务最坏峰值"来设计。
4.3 requests与limits分配策略
这个部分我给不了"万能参数",但可以分享一套我自己验证过多次的分配流程:
- 先让Pod在没有limit的情况下跑24到48小时,观察Prometheus里的CPU和内存真实水位。
- 取CPU的P95值作为requests,取P95再乘1.2到1.3作为limits。
- 内存取P99值作为requests,再额外加20%到30%余量作为limits。
- 定期回看,避免requests长期虚高导致集群容量浪费。
举个例子,某个业务容器压测后统计:CPU均值80m,P95是180m,P99是220m;内存均值400Mi,P95是700Mi。落到YAML里大致是:
resources: requests: cpu: 180m memory: 700Mi limits: cpu: 300m memory: 1024Mi这种配置既保证调度有依据,又给极端流量留了缓冲。有人喜欢把limits和requests设成完全一样,这在追求稳定性的有状态服务里算合理选择,但对大多数无状态服务有点浪费。资源配额的本质是"交易":要更稳,就要牺牲一部分可调度容量。
5. QoS类别:资源争抢时的"阶级"划分
5.1 三类QoS的判定规则
Pod在资源紧张时谁会先被牺牲,不是看运气,而是看QoS(Quality of Service)类别。Kubernetes根据requests/limits的组合,把Pod分成三档:
| QoS类别 | 判定条件 | 说明 |
|---|---|---|
| Guaranteed | 每个容器都同时设置了requests和limits,且requests等于limits | 优先级最高 |
| Burstable | 至少有一个容器设置了requests或limits,但两值不相等 | 中间档 |
| BestEffort | 没有任何容器设置requests和limits | 优先级最低 |
很多人会误以为只要配了requests就是Guaranteed,其实必须"每个容器都要配,且数值相等"。只要有一个容器的requests小于limits,整个Pod就降级成Burstable。这个细节在混用多个容器时特别容易翻车。
5.2 被驱逐的顺序与真实场景
当节点内存压力超过阈值,kubelet的eviction manager会开始驱逐Pod。驱逐顺序不是按名字,而是按QoS和实际使用量综合判断,大体原则是:
- 先清理BestEffort。
- 再清理"使用量超过requests"的Burstable Pod。
- Guaranteed Pod只有在自身使用量都超了limits,或者节点有更严重的不可恢复压力时,才可能被驱逐。
我遇过这样一个线上事故:某节点内存告警,系统把好几个BestEffort的日志采集Pod全部驱逐,接着又把两个内存用超requests的Burstable业务Pod也驱逐了,最后整个节点上的Pod被清得七零八落,而旁边几个Guaranteed的Pod稳稳不动。事后总结时才真正理解"资源声明确实是生存保障"这句话的分量。
5.3 如何故意"混搭"QoS
混搭不是坏事,关键是要按业务重要性来定。像日志采集sidecar、监控agent这类辅助容器,就可以做成Burstable,占资源不多,真到节点压力暴增时优先被驱逐,损失也可控。而核心交易服务最好配置成Guaranteed,提高在节点内存争抢中的存活概率。
实际配置里我常用一个技巧:给关键业务的Pod统一设置limits等于requests,让它变成Guaranteed;给边缘任务Pod只设置较小的requests,不设置limits,让它变成Burstable。这样既保证了核心链路稳定,又不再给整个集群的可调度容量添负担。
6. 从单Pod到集群:配额与默认值
6.1 ResourceQuota:把命名空间管起来
单Pod配置做好只是第一步,到了集群层面,还得防止"某个团队把整个集群资源都吃光"。ResourceQuota做的就是命名空间级别的资源总量控制,比如:
apiVersion: v1 kind: ResourceQuota metadata: name: team-a-quota namespace: team-a spec: hard: requests.cpu: "20" requests.memory: 40Gi limits.cpu: "40" limits.memory: 80Gi persistentvolumeclaims: "20"设定之后,team-a这个命名空间里所有Pod的requests.cpu加起来不能超过20核,limits.cpu不能超过40核,PVC也不能超过20个。超了,API Server直接拒绝创建Pod。这能从源头防止"无节制申请资源"的情况。
引入配额有两个副作用要注意,我在实际推行配额时都遇到了:
- 已有存量Pod不自动迁移。配额只对新创建的对象生效,存量Pod如果已经超过配额,不会被驱逐,要先手工整理。
- 没写requests/limits的Pod在配了ResourceQuota的命名空间里会创建失败,因为配额校验需要每个Pod都明确资源量。这时就必须配合LimitRange给Pod填默认值,否则开发者会被一堆创建失败日志搞晕。
6.2 LimitRange:给Pod补默认值
LimitRange的作用是在命名空间级别设置Pod/容器的资源默认值和上下限。它解决的正是"开发者忘了写资源声明"的问题。典型配置如下:
apiVersion: v1 kind: LimitRange metadata: name: default-limit-range namespace: team-a spec: limits: - default: cpu: 200m memory: 512Mi defaultRequest: cpu: 100m memory: 256Mi max: cpu: "4" memory: 8Gi min: cpu: 10m memory: 64Mi type: Container这个LimitRange一上线,team-a里凡是没显式写资源的容器,都会被自动塞入defaultRequest和default限值。同时,单个容器的资源申请必须在min和max之间,避免有人申请一个Pod要32核128Gi内存,把命名空间配额瞬间打满。
我建议每个命名空间在启用ResourceQuota之前,先配好合适的LimitRange。否则Quota会暴露大量"资源字段缺失"的Pod创建问题,你要花很多时间教开发者怎么写资源声明,不如让平台自动兜底。
6.3 我实际分配资源的一点经验
最后分享一些我折腾多年资源管理后的心得体会,不一定适合所有公司,但至少能帮新同学少走弯路:
第一,不要把limits当成"反正写个大值就安全"。内存limits越大,节点风险越高。因为Pod实际用不了那么多,但调度器却按requests分配;一旦节点内存真爆了,驱逐范围会更大。正确的做法是让limits尽可能贴近真实峰值,而不是拍脑袋放大。
第二,定期用工具扫描资源声明。我习惯每个月跑一遍集群里的资源清单,找那些"无requests/limits"的Pod、长期CPU throttling严重的容器、以及内存used/requests比例过低的Pod。前两类是隐患,后一类是浪费。
第三,给"重启动机制"留一点空间。如果一个Pod的内存经常贴到limit边缘,与其不停调大内存,不如先查是不是内存泄漏。我见过一个开发团队连续三周调整OOM服务的resources,最后发现是某个SDK的全局缓存没有上限,问题完全不在Kubernetes配置层面。
第四,故障演练一定要做。不要只在文档里写"配置了合适的resources和探针"就觉得万事大吉。我建议定期挑几个非核心节点,手动触发内存压力或直接驱逐部分Pod,观察哪些服务能扛住、哪些瞬间骨折。只有真实模拟过节点内存不足、容器被杀、Pod被驱逐的链路,你才会对生命周期和资源管理产生真正的体感。
像我一直在做的模拟项目X,每次调整资源策略后都会把节点内存压力拉到告警阈值做一次演练,从Pod状态、探针告警、驱逐日志到业务可恢复时间,完整记录一遍。这种事前折腾,比线上事故后再复盘要好太多。