刚开始用Kubernetes那阵子,我最大的感受就是:Pod这玩意儿看着就是个"集装箱",可真到生产环境里一跑,各种状态卡住、资源争抢、调度不按套路出牌的问题全冒出来了。前面两篇我们聊了Pod的基础定义和常用操作,这一篇我打算换个角度,专门把Pod管理里最容易让人栽跟头的几个深水区捋一遍,包括生命周期里那些"说不清道不明"的状态细节、资源配置和CPU throttling的恩怨、Pod到底会被调度到哪台节点、怎么给Pod配只读权限,以及一大堆让人头疼的故障到底怎么查。
整个系列我已经写到第51篇了,说实话,"Pod管理"这个主题越往深挖越有意思,因为它几乎是所有Kubernetes问题的汇聚点——网络、存储、调度、安全、可观测性,最后都会落到Pod上。这篇内容同样不挑环境,你在哪套Kubernetes集群上都适用,自己用minikube搭的也罢,生产环境也罢,思路是通用的。我看后台很多朋友留言都在问"Pod卡在Terminating怎么办""明明设置了requests为什么还是被杀",这些问题这篇都会聊到。
1. 重新理解Pod的生命周期:状态机里藏着所有故障线索
1.1 Phase只是表象,Conditions才是内幕
很多新手看Pod状态,眼睛只盯着kubectl get pod那一列的Running、Pending、CrashLoopBackOff,但这些其实只是Pod的phase,是一个高度概括的快照。真正能反映Pod内部发生了什么的是status.conditions,它是Pod当前真实处境的明细账。
每条condition都有type、status、reason和message四个字段。type一共就那么几个:PodScheduled、Initialized、ContainersReady、Ready。我习惯用一条命令把Pod的所有condition一次性拉出来看:
kubectl get pod <pod-name> -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}) {.message}{"\n"}{end}'实操里我见过太多这样的情况——kubectl get pod显示Running,但业务就是访问不通,一查condition,Ready=False,message里写着Readiness probe failed: HTTP probe failed with statuscode: 503。如果只看phase,你根本无从下手,但condition的message直接告诉你探针返回了503。排查Pod问题的第一步永远是看condition,不是看phase。
1.2 三种探针怎么配,直接决定Pod的生死
探针是Pod生命周期管理的核心机制,也是区分"容器进程活着"和"服务真正可用"的关键。很多人把三个探针搞混,这里我用一个生活化的类比拆开讲:
startupProbe(启动探针):就像你叫一个刚睡醒的人起床,先确认他意识清醒了没有。它专门用来处理"启动特别慢"的容器,比如Java应用要加载一堆类、冷启动要连数据库的应用,给它一个单独的宽限期,免得liveness探针在启动阶段就误杀。livenessProbe(存活探针):检测容器是否还活着。如果失败,kubelet会杀掉容器并按照restartPolicy重启。这个探针最忌讳的是把外部依赖(比如数据库、下游服务)的可用性也绑进来,否则外部抖动会导致你的Pod被反复杀掉。readinessProbe(就绪探针):检测容器是否准备好接收流量。失败时Pod会被从Service的Endpoints中摘掉,但不会重启容器。这个探针最适合用来做优雅上线和优雅下线。
我目前生产环境里的通用模板大致长这样:
startupProbe: httpGet: path: /healthz port: 8080 periodSeconds: 5 failureThreshold: 30 livenessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /readyz port: 8080 periodSeconds: 5 failureThreshold: 3这段配置的思路是:给启动探针一个大阈值,比如30 * 5s = 150s,容忍应用慢慢热身;一旦启动探针成功,liveness才开始接管,用10s * 3 = 30s的窗口判断容器是否卡死;readiness则用更短的周期(5秒)去实时反映服务是否可用。这是我调过无数轮之后沉淀下来的相对稳妥的配置,你在落地时一定要根据应用的实际启动耗时调整failureThreshold和periodSeconds,不要照抄。
1.3 restartPolicy:只有三种值,但很多人理解错了
Pod的restartPolicy只有Always、OnFailure、Never三个值。需要特别注意:这个字段只在Pod所在节点上的kubelet生效,你的Pod如果所在的Node挂了,restartPolicy是不负责把Pod拉起来的,那是控制器(Deployment/StatefulSet)的职责。
在实际使用中,restartPolicy: Always适用于Deployment这类长期运行的服务;OnFailure和Never更适合Job和CronJob这种跑完就退出的短任务。还有一个易错点:restartPolicy: Never不是说容器永远不重启,而是Pod内的容器退出后,kubelet不会自动重建容器,但整个Pod仍会进入Failed状态,最终由控制器决定是否重建Pod。我在用Job跑一次性数据任务时经常遇到这个情况,特此提醒一下。
2. 资源管理实操:requests/limits和CPU throttling的新仇旧恨
2.1 requests和limits的区别,一句话讲透
一句话:requests是你告诉调度器"这个Pod至少要这么多资源才能跑",limits是你告诉kubelet"这个Pod最多可以用这么多资源,超过就得管管"。requests是调度依据,limits是运行时约束。
CPU资源在Linux内核里是靠CFS带宽控制的,Pod的CPU limits超过后会触发CPU throttling。而内存不太一样,内存limits是硬限制,超过就会触发OOMKilled,直接杀容器。所以配置的时候要心里有数:
| 维度 | CPU | 内存 |
|---|---|---|
| requests 超额 | 调度器按requests找节点,不满足则Pending | 同上 |
| requests 设置过高 | 节点资源碎片化,明明还有空闲却调度不上去 | 同上 |
| limits 超额 | 容器被throttling,限流不杀死 | 容器被OOMKilled杀死 |
| 未设置requests | 调度不管,可能堆到同一节点 | 同上 |
我踩过的坑是:内存全都给了limits,但requests故意不给,想着"让调度器随便调度,反正内存够用"。结果节点内存被TopPod占满,其他Pod直接被OOMKilled,查问题查了半天。后来我养成了习惯:requests和limits永远一起写,即使相等也写,用这个习惯倒逼自己思考每个服务的资源画像。
2.2 CPU throttling:明明limits没超,服务却变慢的元凶
这是热搜词里提到的,也是我见过生产环境最隐蔽的问题之一。现象是:服务看起来没死,CPU使用率也不算高,但延迟飙升、毛刺特别多。查了监控发现CPU使用确实没有到达limits,但内核里cpu.stat的nr_throttled特别大。
这个问题的根源在于CFS的带宽控制机制。Kubernetes给容器设置的cpu.limits被转换成CFS的quota和period,默认period=100ms。假设你设置limits: cpu=2,那内核就给容器在每个100ms周期内分配200ms的CPU时间。重点来了:即使你在这个周期内只用了150ms,但这150ms聚在一起(比如某毫秒内突然把CPU打满),内核照样会在该周期的剩余时间对容器进行throttling,把它冻结到下一个周期。
举个例子,一个线程用burst方式干活,每100ms周期里抢到50ms连续CPU,然后又歇50ms,平均使用率只有50%,按理说离limits的2核还远着,但因为CFS是按"周期内累计"而不是"瞬时速率"来仲裁的,这50ms如果集中在同一窗口,照样会触发throttling。这类问题在低limits+高burst型工作负载下特别典型。
排查方法:
# 进入容器后查看cpu.stat cat /sys/fs/cgroup/cpu/cpu.stat如果nr_throttled数值不停上涨,且throttled_time很大,基本可以断定存在CPU throttling。缓解手段有几个:
- 调大
limits,给足额度(最直接但成本高); - 修改kubelet的
--cpu-cfs-quota-period,比如改成10ms,让仲裁窗口变小,burst的bias会减小; - 让应用本身学会"限速",避免瞬时burst太猛;
- 把CPU类型的服务从多线程改为更均匀的消费模式。
我的实际经验是:先看监控确认是throttling还是真的CPU不足,不要一上来就加limits。加limits是最贵的解法,能用软件层解决burst问题就优先软件层。
2.3 QoS等级:Kubernetes驱逐Pod的优先级
Pod的QoS等级完全由requests和limits的设置方式决定,分三档:
Guaranteed:每个容器都设置了requests和limits,且两者相等。这类Pod优先级最高,节点资源不足时最后被驱逐。Burstable:至少一个容器设置了requests或limits,但并非所有容器都满足Guaranteed条件。比较"中间层",资源不够时比Guaranteed先被考虑驱逐。BestEffort:所有容器都没设置requests和limits。节点资源告急时最先被驱逐。
这个机制在节点内存压力(memory pressure)来临时特别关键。kubelet根据QoS等级和实际内存使用量决定驱逐顺序:
- 首先驱逐
BestEffort中内存使用超过requests的; - 然后驱逐
Burstable中使用内存超过requests的; - 最后才考虑
Guaranteed(而且只在系统进程濒临OOM时才动)。
所以,如果你有一个跑核心数据库的Pod却把它配成了BestEffort,那节点一紧张,第一个被请走的就是它。我一直建议,核心业务至少配成Guaranteed,非核心批处理可以降级为Burstable,但至少要有requests兜底。用一句话总结:requests和limits不只是性能参数,更是你在节点资源竞争中的"优先级筹码"。
3. 调度控制进阶:让Pod去它该去的节点
3.1 nodeSelector、nodeAffinity怎么选
nodeSelector是最简单的调度约束,本质上是"硬匹配"。它的局限在于只能做等值匹配,比如disktype=ssd,不支持In、NotIn这种集合操作,也没法表达"尽量往这个节点放"的软性偏好。
nodeAffinity才是真正的进阶玩法,支持requiredDuringSchedulingIgnoredDuringExecution(硬约束)和preferredDuringSchedulingIgnoredDuringExecution(软偏好)。我日常最常用的写法是软偏好,把Pod尽量调度到某些节点,但不强制:
affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: node-role.kubernetes.io/ingress operator: In values: - "true"这里weight: 80表示这是一个权重为80的软约束,调度器在计算节点得分时会给满足条件的节点加80分。实战中,我会把强要求放required,比如"必须调度到有GPU的节点"nvidia.com/gpu=true;而把"最好在同一个可用区"这种偏好放preferred。这样做的好处是:硬约束保证基本盘,软偏好给调度器留优化空间,集群资源利用率更高。
3.2 Taints和Tolerations:谁才能坐上这趟专车
与nodeAffinity的"吸引"逻辑相反,Taints是站在节点一侧的"排斥"机制。节点打了taint,Pod如果没有对应的toleration,就不会被调度到这个节点。
这一点和nodeAffinity是互补的:nodeAffinity让Pod主动选择节点,Taints让节点主动拒绝Pod。最典型的场景是给专用节点(如GPU节点、数据库专用节点)打上taint,确保只有特殊Pod才能上去。比如:
kubectl taint nodes node-gpu-01 gpu=true:NoSchedule对应的Pod配置:
tolerations: - key: "gpu" operator: "Equal" value: "true" effect: "NoSchedule"注意effect还有两个兄弟:NoExecute和PreferNoSchedule。NoExecute更狠,不仅阻止新Pod调度上来,还会把节点上已有的没有对应toleration的Pod全部驱逐;PreferNoSchedule是软版本,表示"尽量别调度上来,但不是强制"。
3.3 调度失败,从Pending里看出线索
Pod卡在Pending是所有调度问题的最终归宿。排查的时候不要靠猜,直接看事件的输出:
kubectl describe pod <pod-name> | tail -n 20最常见的几个事件和原因:
0/4 nodes are available: 1 node(s) had untolerated taint——节点的taint你没容忍,把nodeAffinity要求组合起来想想为什么;0/4 nodes are available: 2 Insufficient cpu, 2 Insufficient memory——requests加起来放不下了,要么扩容节点,要么降低requests;0/4 nodes are available: 1 node(s) didn't match node selector——nodeSelector/affinity匹配不上,去检查节点标签;pod has unbound immediate PersistentVolumeClaims——PVC没绑上PV,存储出了问题。
我个人的排查顺序是:先看condition和events,再看PVC/PV状态,最后检查调度器日志。千万别一看Pending就急着删Pod,很多问题删了还会再来,因为根源没有解决。
4. 权限与安全模型:给Pod开只读权限的实操笔记
4.1 ServiceAccount是Pod的"身份证"
Pod中的进程要和Kubernetes API Server交互,必须有一段身份凭证,这就是ServiceAccount。默认情况下,每个命名空间都有一个defaultServiceAccount,容器里挂着它的token。但默认token权限很大,实际操作中我强烈建议大家为不同业务创建独立的ServiceAccount,然后用RBAC精确授权。
创建一个只读ServiceAccount的完整姿势:
- 创建ServiceAccount:
apiVersion: v1 kind: ServiceAccount metadata: name: readonly-sa namespace: default- 创建只读Role,只授予
get、list、watch三种权限:
apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: default name: readonly-role rules: - apiGroups: [""] resources: ["pods", "pods/log", "services", "endpoints", "configmaps", "secrets"] verbs: ["get", "list", "watch"] - apiGroups: ["apps"] resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] verbs: ["get", "list", "watch"]- 绑定:
apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: readonly-binding namespace: default subjects: - kind: ServiceAccount name: readonly-sa namespace: default roleRef: kind: Role name: readonly-role apiGroup: rbac.authorization.k8s.io- 在Pod中指定:
spec: serviceAccountName: readonly-sa这里有个细节:Role和RoleBinding是命名空间级别的,只能控制某个命名空间内的资源。如果要跨命名空间只读,得用ClusterRole和ClusterRoleBinding。我在给运维同事开只读权限时,通常会在每个项目命名空间里各建一套Role和RoleBinding,这样权限范围清晰,不会不小心看到其他项目的Secret。
4.2 securityContext:让容器里的进程守规矩
ServiceAccount解决的是"Pod以什么身份访问K8s API",而securityContext解决的是"容器里跑进程时能干什么"。
最常见的安全加固配置:
securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 3000 fsGroup: 2000其中runAsNonRoot: true会在启动时校验镜像里的用户是否为root,如果是root就直接拒绝启动。很多镜像的基础层默认就是root用户,你把这个字段设成true之前,先确认镜像里的应用可以用非root方式运行。我用一个简单的runAsUser: 1000就成功把一些基础镜像的Pod从root降级到普通用户,配合readOnlyRootFilesystem还能进一步收紧。
readOnlyRootFilesystem: true是最容易被人忽略但价值极高的一个配置。它把容器的根文件系统设为只读,但应用通常需要写临时文件,所以同时必须挂一个emptyDir作为临时目录:
volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}这种"只读根文件系统+专用临时目录"的组合是我在生产环境做安全加固的首选方案,既保证了容器内文件系统的不可篡改,又不影响应用的正常启动。
4.3 只读权限用户的完整使用场景
之前有个同事做故障排查,需要查看各命名空间的Pod状态和日志,但又要防止他误删东西。我给他创建了一个只读ServiceAccount,配合kubectl使用:
kubectl --kubeconfig=readonly.config get pods -Akubeconfig里把token换成readonly-sa挂载的token,然后users.users.token字段指向这个token。他执行任何delete、edit、apply操作都会被API Server拒绝,返回Forbidden。这个方案比手动给他发一个admin kubeconfig安全得多,出了事故也能明确追踪到操作者。
这个方案我只给了"读"权限,但注意:pods/log的读取也属于"读"的范畴,如果你不想让同事看日志,那就别在resources里加pods/log。同理,secrets虽然也是"读"操作,但secret内容本身非常敏感,我一般默认不给只读用户加secrets的list权限,除非确有需要。
5. 故障排查实录:Pod管理里最常见的五个问题
5.1 CrashLoopBackOff的排查思路
CrashLoopBackOff表示容器反复启动失败,kubelet在退避指数增加重启间隔。常见的根因包括:
- 启动命令错误或环境变量缺失,进程启动即退出;
- 探针配置太严格,应用还在启动就被liveness杀掉;
- 内存OOM导致进程被杀;
- 镜像里的入口脚本依赖某个不存在的配置或服务。
排查步骤我建议按这个顺序来:
# 1. 看当前容器的退出码和原因 kubectl describe pod <pod-name> | grep -A 20 "Last State:" # 2. 看日志,注意要带--previous看上一轮的 kubectl logs <pod-name> --previous # 3. 如果日志没输出,看容器是否启动后立即退出 kubectl get Events --field-selector involvedObject.name=<pod-name> -A # 4. 看阶段性的退出码 # 137 = SIGKILL,通常OOM # 143 = SIGTERM,通常是优雅退出或探针杀了它 # 1 = 应用自身崩溃退出码是很有用的线索:137说明进程是被强杀的(大概率OOM),143说明收到SIGTERM后退出(通常是探针触发了重启),1说明是程序自己的逻辑错误。我曾经碰到一个CrashLoopBackOff,describe里一片模糊,后来打开kubectl logs --previous才看到是环境变量里密码格式错了,应用启动时直接panic。所以日志永远是最优先看的。
5.2 ImagePullBackOff的坑
ImagePullBackOff表示镜像拉取失败。原因不外乎:镜像不存在、私有仓库认证失败、tag不对、registry网络不通。
排查顺序:
# 1. 描述Pod查看具体错误 kubectl describe pod <pod-name> | grep -A 20 "Events:" # 2. 如果是私有仓库认证失败,先看看imagePullSecrets kubectl get secrets # 3. 手动拉一下镜像验证是不是网络问题 docker pull <image-name>经验之谈:很多ImagePullBackOff源于你改了镜像tag但没推送到registry,或者镜像名写错了。先确认镜像仓库里真的有这个tag,再去查网络和认证。另外,如果你的集群节点没有外网,得确保镜像已经提前推到了内网harbor,并在Pod里写内网镜像地址。
5.3 OOMKilled:不仅仅是limits的问题
OOMKilled说明容器内存达到limits,被内核OOM killer干掉。很多人第一反应是加limits,但有一个细节值得注意:如果你的业务是Java应用,JVM堆内内存默认可能只占容器内存的一部分,但堆外内存泄漏照样会触发OOM。给JVM配置容器感知的堆大小,用-XX:MaxRAMPercentage=75.0这类参数,让JVM和容器limits保持一致。
另一个坑是:Kubernetes的OOMKilled不一定是因为超过limits,还有可能是节点本身内存不够,触发了系统级别的OOM。这两者要区分,看describe时,如果是超过limits,Events里一般会明确写Killed container due to memory usage is greater than the limit;如果是节点级OOM,事件里会写SystemOOM。排查时先用kubectl describe pod确认归属,再动手调整,不要盲目改limits。
5.4 Pod一直Terminating怎么办
Pod删除后一直停在Terminating状态,是各大K8s讨论群里出现频率最高的求助帖。原因通常有几个:
- 容器进程对SIGTERM无响应,kubelet等待
terminationGracePeriodSeconds(默认30秒)后发SIGKILL强制杀; - Pod里挂载了NFS或者其他无法卸载的卷,导致kubelet卡在卸载卷的步骤;
- 有finalizer在Pod上,删除流程被阻塞。
最有效的强制删除方式,我分两步走:
# 第一步:如果只是等得太久,先确认没有finalizer kubectl get pod <pod-name> -o json | jq '.metadata.finalizers' # 第二步:确认是挂载问题或进程卡死,强制删 kubectl delete pod <pod-name> --force --grace-period=0--force会让kubelet直接从APIServer删除记录,不走正常的优雅终止流程。但注意:强力删除可能让容器对应的日志、网络规则残留,但不至于让集群整体失控。真正常见的最后一步是去节点上df或mount确认能不能卸载挂载点,如果挂载卡死了,清掉相关进程再删Pod才能根治。
5.5 节点NotReady后Pod去哪了
节点宕机或NotReady后,很多人误以为Pod会自动迁移到其他节点。实际上,Pod不会自己迁移,而是等待控制器(如Deployment)创建新的Pod替换。kubectl get pod会看到Pod还挂在那个不健康节点上,状态可能是Unknown或Terminating。
关键时间点是tolerationSeconds(如果节点有node.kubernetes.io/not-ready:NoExecute的toleration且设置了秒数)和Deployment的progressDeadlineSeconds。K8s会等一段时间(由控制器根据自己的逻辑决定),然后把Pod放回Pending去重新调度。如果你希望挂掉节点上的Pod尽快被取代,可以把Deployment的minReadySeconds和探针参数调整一下,但最直接的办法还是把节点尽快恢复或摘除,让控制器更快感知节点异常。
6. 我的Pod管理经验沉淀与几个实用习惯
写了十几篇K8s的内容,沉淀下来的Pod管理心法,无非这么几条。
第一,永远把"可观测性"放在第一位。每个Pod一定配上readiness和liveness探针,探针端点务必输出有意义的状态,不要永远返回200。探针是Pod生命周期管理的第一道防线,不配探针的Pod等于在裸奔。
第二,给每个Pod定义资源画像。无论是新上线还是已有服务,花时间测出CPU、内存的日常基线和峰值,然后把requests设为基线值、limits设为峰值或稍高于峰值。我在公司的规范里把这些值做成标准模板,新服务都要按模板填,绝不裸奔。
第三,慎用--force删除Pod。这条命令是双刃剑,在排查故障时会省很多时间,但在生产环境里频繁使用会让APIServer和节点之间的状态越来越脏。遇到Terminating,先查finalizer和挂载,再决定是否强删。
第四,日志就是一切。排查Pod问题,我几乎每次都从kubectl logs --previous开始。很多故障表面上是K8s层面的问题,实际是应用自己启动失败、连接超时、配置错误。K8s只是忠实地反映了应用的"生老病死",并没有添油加醋。
第五,权限最小化原则。给同事、给CI/CD系统、给监控系统创建的ServiceAccount,一律按需授权,能只读就只读,能限定命名空间就限定命名空间。K8s的RBAC很灵活,多花五分钟写清楚权限,省掉未来无数个"误删了怎么办"的夜晚。
最后说一个我自己的小习惯:我会在每个集群里创建一个troubleshoot专用命名空间,里面放一些带诊断工具镜像的测试Pod,比如带有curl、dig、tcpdump的镜像。遇到网络类问题,直接起一个临时诊断Pod去curl Service或者Pod IP,比在业务容器里装工具安全得多,也快得多。这个习惯帮我解决过不少怪问题,分享给你们,希望也能帮上忙。