写这个系列写到第五篇,绕不开一个东西:Pod。身边不少刚上手Kubernetes的朋友,对Pod的理解停在一个很模糊的位置——知道它是"最小的调度单元",写YAML的时候照模板抄一份,跑起来就算完事。等真到线上出问题:Pod卡在Pending半天不动、容器反复CrashLoopBackOff、节点资源看着还挺空却调度不上去、日志打到一半突然断——回头一查,八成是对Pod这一层的东西没吃透。
这一篇就正面拆Pod。上篇只聊"静态的Pod":它到底是个什么东西、清单怎么写才不埋雷、生命周期怎么走、多容器怎么协作、资源怎么算、探针怎么配。下篇再讲控制器怎么管它、亲和性怎么排、污点容忍怎么绕、拓扑分布怎么散这些"动态"的部分。
这里说的Pod,是Kubernetes里被调度、被扩缩、被回收的最小单位,一个Pod里可以只跑一个容器,也可以跑多个共享网络与存储的容器。它解决的核心问题是:一组关系紧密、必须同生共死的进程,如何作为一个整体被安置到某台机器上。适合谁看?如果你能照着文档跑通Deployment但说不清Pod内部机制,或者你正在维护集群、被各种Pod状态和驱逐事件搞得有点懵,这篇应该能给你一些能直接拿去用的东西。
1. 先把Pod这个概念掰开揉碎
1.1 Pod不是"高级容器",它更像一套合租房
我见过太多人把Pod当成"容器的另一种写法",这个认知偏差是所有后续踩坑的源头。Pod是一个逻辑主机,不是容器的别名。你可以把它理解成一套合租房:里面的容器是合租的室友,大家共享同一个门牌号(Pod IP)、同一个客厅(网络命名空间)、同一个储物间(Volume),但各自有独立的卧室(根文件系统)。
落到内核层面看,同一个Pod内的所有容器共享 Network Namespace、IPC Namespace、UTS Namespace,可选共享 PID Namespace。网络命名空间共享意味着什么?意味着它们看到的是同一张网卡、同一个IP、同一个端口空间。这就是为什么同一个Pod里两个容器不能同时监听8080——不是Kubernetes拦着你,是内核层面就冲突了。UTS共享意味着它们看到同一个主机名,容器里执行 hostname 拿到的是Pod的名字而不是容器名。
但文件系统是真不共享的。每个容器有自己的rootfs,镜像里有什么就是什么。想让两个容器看到同一份文件,必须显式声明一个Volume挂到两边。这个区别在实际排查时特别关键:很多人发现日志容器读不到应用日志,第一反应是"共享没生效",其实是根本没挂Volume,两个容器各自在各自的rootfs里找文件,当然找不到。
1.2 为什么Kubernetes不直接调度容器
这是个值得停下来想清楚的设计取舍。既然容器是运行进程的实体,为什么不让调度器直接管容器,非要加一层Pod?
答案藏在"超亲密关系"这个词里。设想一个典型的Web服务:主进程处理请求,旁边挂一个日志转发进程把写盘的日志实时推走。如果这两个东西分到两台机器上,日志进程就得跨网络去读远端磁盘,延迟高、丢数据的概率大、还得处理网络分区。把它们绑在一起,同一个节点、同一个IP、同一个Volume、同一个生命周期——这就是Pod存在的意义。
代价是灵活性。一个Pod里的容器没法单独扩缩,不能一个跑在A节点一个跑在B节点,资源是打包计算的。Kubernetes的设计者认为这个代价可以接受,因为真正需要"超亲密"的场景本来就不该拆开。我在实际项目里见过有人把一个服务的三个副本拆成三个单容器Pod,又想共享缓存目录,最后只能上网络存储,性能和复杂度都很难看。这种时候老老实实承认"它们是一个整体",用多容器Pod或者干脆合并成一个容器,反而更省事。
1.3 Pod的几种典型形态,先建立整体地图
在深入细节之前,先把Pod能长成什么样摆出来,后面所有的配置细节才有归属。
| 形态 | 结构 | 典型场景 | 生命周期特点 |
|---|---|---|---|
| 单容器Pod | 一个容器 | 绝大多数无状态服务 | 最简单,Deployment的标准形态 |
| 多容器Pod(Sidecar) | 主容器 + 旁路容器 | 日志采集、配置热加载、流量转发 | 同生共死,一起创建一起销毁 |
| 多容器Pod(Ambassador) | 主容器 + 出站转发 | 主容器只连本地端口,由旁路容器连外部 | 主容器代码不需要知道外部地址 |
| 多容器Pod(Adapter) | 主容器 + 格式转换 | 把主容器暴露的指标转成统一格式 | 对上层监控系统屏蔽差异 |
| Init容器形态 | 若干一次性容器 + 主容器 | 等待依赖就绪、初始化目录权限、拉取配置 | Init按顺序跑完才启动主容器 |
这张表里,单容器占实际使用的九成以上。多容器不是"高级玩法",它是为特定耦合场景准备的工具,用错了比不用更糟——比如把两个可以独立扩缩的服务塞进一个Pod,扩容时只能整体扩,资源利用率会很难看。
2. Pod的配置骨架:从最小清单到生产模板
2.1 一份最小可用的Pod清单,逐行拆
先看最短能跑起来的样子:
apiVersion: v1 kind: Pod metadata: name: demo-web namespace: default labels: app: demo-web tier: frontend spec: containers: - name: web image: nginx:1.25-alpine ports: - containerPort: 80 name: httpapiVersion 写 v1,因为Pod属于核心组,没有组名前缀。kind 是 Pod。metadata 下面的 name 在同一个命名空间内必须唯一,namespace 不写就落到 default。labels 是键值对,后面Service选Pod、Deployment管Pod全靠它。
真正容易误解的是 ports 这一段。containerPort 这个字段不改变任何运行时行为,它纯粹是文档性质的声明。容器实际监听哪个端口,完全由镜像里的进程决定。我见过有人把 containerPort 从80改成8080,然后疑惑为什么nginx还在80上响应——因为改的只是一行注释。真正要改端口,得改镜像里的配置或者用 args 覆盖启动参数。
只有当你想给端口起名字、然后在Service里用名字引用时,这个字段才有实际价值。比如上面写的 name: http,Service里就可以写 targetPort: http,将来端口号变了只要名字不变,Service不用动。这是个小技巧,但迁移的时候能省不少事。
2.2 生产环境里我常备的Pod清单模板
最小清单只能证明"能跑",生产环境要考虑的东西多得多。下面这份是我自己项目里反复用的骨架,字段不算全,但覆盖了最容易出事的那些点:
apiVersion: v1 kind: Pod metadata: name: demo-api namespace: prod labels: app.kubernetes.io/name: demo-api app.kubernetes.io/version: "1.4.2" app.kubernetes.io/component: backend annotations: owner: team-backend change-ticket: "OPS-2317" spec: terminationGracePeriodSeconds: 30 serviceAccountName: demo-api-sa securityContext: runAsNonRoot: true runAsUser: 10001 fsGroup: 10001 containers: - name: api image: registry.internal.example/demo-api:1.4.2 imagePullPolicy: IfNotPresent command: ["/app/server"] args: ["--port=8080", "--config=/etc/demo/config.yaml"] env: - name: APP_ENV value: "prod" - name: DB_PASSWORD valueFrom: secretKeyRef: name: demo-db key: password resources: requests: cpu: "500m" memory: "512Mi" limits: cpu: "2000m" memory: "1Gi" readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /livez port: 8080 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 3 volumeMounts: - name: config mountPath: /etc/demo readOnly: true - name: logs mountPath: /var/log/app volumes: - name: config configMap: name: demo-api-config - name: logs emptyDir: sizeLimit: 2Gi几个字段值得单独说说。imagePullPolicy 设成 IfNotPresent 配合明确版本号,是为了避免每次启动都去拉镜像,也避免 latest 标签带来的版本漂移。真正上线我建议把镜像摘要(digest)也考虑进去,标签可以被人覆盖,摘要是不可变的。
terminationGracePeriodSeconds 默认30秒,很多人不改。但如果你的应用处理长连接或者要做数据落盘,30秒可能不够,进程会被SIGKILL直接砍掉,正在处理的事务就丢了。我的经验是设成比应用最大请求超时时间再多10到15秒比较稳妥。
securityContext 这几行看着琐碎,其实是过安全审计的必答题。runAsNonRoot 让容器不以root启动,runAsUser 指定UID,fsGroup 决定挂载卷的组归属。这三行不加,很多集群的准入策略会直接拒掉你的Pod。
2.3 标签和注解不是一回事,别混着用
labels 和 annotations 都是键值对,但用途完全不同。labels 会被索引、会被选择器查询,所以它必须短、必须是有限的枚举值。annotations 不进索引,用来存一些描述性信息,比如负责人、变更单号、构建时间、配置校验和。
我踩过的坑是把一大段JSON配置塞进label,结果超过长度限制,提交时直接报错;还有个更隐蔽的问题,label一旦变动,如果Deployment的selector和它绑定了,会导致Pod被认为不属于这个Deployment,引发一轮意外的重建。所以记住一条:能放annotations的,绝不放labels。
3. Pod的生命周期:状态机其实没那么玄
3.1 五个阶段状态,各自意味着什么
kubectl get pod 输出的 STATUS 那一列,反映的是Pod的 phase。这个字段只有五个合法值,理解它们的边界能省掉很多猜测。
| Phase | 含义 | 常见触发原因 |
|---|---|---|
| Pending | Pod已被API Server接收,但还没完成调度或镜像未就绪 | 资源不足、节点亲和性不满足、镜像拉取中 |
| Running | Pod已绑定到节点,至少一个容器在运行或正在启动 | 正常状态,但不代表业务可用 |
| Succeeded | 所有容器正常退出且不会重启 | 一次性任务完成,Job的终点 |
| Failed | 所有容器都已终止,至少一个异常退出 | 启动失败、退出码非0、被驱逐 |
| Unknown | 无法获取Pod状态 | 节点失联、kubelet通信中断 |
这里有个特别容易误解的点:Running 不等于业务可用。一个Java应用可能容器进程起来了,但Spring上下文还在初始化,端口还没监听,这时候 phase 就是 Running,可流量打进来全是连接拒绝。判断业务可用要看 readinessProbe 的结果,或者看 READY 列的 x/x 是不是全绿。把 phase 和 ready 这两个概念分开,是排查问题的第一课。
3.2 容器状态比Pod状态更细,排查要往下钻一层
Pod的phase是粗粒度的汇总,真正的细节在容器状态里。kubectl get pod 加 -o yaml,看 status.containerStatuses,每个容器有 state 字段,取值是 waiting、running、terminated 三选一。
waiting 里最有信息量的是 reason。ImagePullBackOff 说明镜像拉不到,可能是仓库地址错、凭证没配、网络不通;CrashLoopBackOff 说明容器起来了又挂了,反复重试;CreateContainerConfigError 通常是ConfigMap或Secret引用了不存在的键。这几个词看着像黑话,其实每个都对应一条明确的排查路径。
terminated 里的 exitCode 是金矿。退出码137意味着被SIGKILL,最常见的原因是内存超限被OOMKiller干掉,或者liveness探针失败触发重启。退出码143是SIGTERM,说明是正常终止流程。退出码1是应用自己报错退出。记住137和143这两个数,能少走很多弯路。
restartPolicy 这个字段作用在整个Pod上,不是单个容器。取值 Always、OnFailure、Never。Deployment管的Pod必须是 Always,Job一般用 OnFailure 或 Never。有人想在Deployment里设 OnFailure 让失败容器不重启,这是无效的——控制器层面会覆盖这个行为。
3.3 用describe和events把卡点定位到具体环节
排查Pod问题,我的固定顺序是四步,几乎能覆盖八成场景。
第一步看概览:
kubectl get pod demo-api -n prod -o wide-o wide 会多给节点名和Pod IP,能立刻判断是不是调度到了预期节点。如果 STATUS 是 Pending 且 NODE 列是 none,问题就在调度环节,跟应用代码一点关系没有。
第二步看事件,这是信息密度最高的地方:
kubectl describe pod demo-api -n prod输出末尾的 Events 段落会按时间倒序列出这个Pod经历的所有事件。FailedScheduling 会告诉你调度器为什么拒绝,比如 "0/5 nodes are available: 3 Insufficient cpu, 2 node(s) had taint"。FailedMount 会告诉你卷挂不上。Unhealthy 会告诉你探针失败了。我个人的习惯是直接看 Events,比翻 YAML 快得多。
第三步,如果事件里提到节点层面的问题,把视角切到节点:
kubectl describe node <node-name> | grep -A 20 "Non-terminated Pods"能看到这个节点上所有Pod的资源请求总和,判断是不是真的被占满了。
第四步看日志,尤其是上一个容器实例的日志:
kubectl logs demo-api -n prod --previous--previous 这个参数是排查 CrashLoopBackOff 的关键。容器重启后,kubectl logs 默认给的是新实例的日志,可能一片空白,而真正的死因在上一个实例里。这个参数我用得极频繁,值得记牢。
4. 多容器Pod的实战:共享边界在哪
4.1 一张表说清共享与隔离
多容器Pod出问题,绝大多数是因为没搞清楚哪些东西共享、哪些不共享。
| 资源类型 | 是否共享 | 说明 |
|---|---|---|
| 网络命名空间 | 共享 | 同一Pod IP,同一端口空间,可用localhost互访 |
| IPC命名空间 | 共享 | SysV IPC、POSIX消息队列可跨容器使用 |
| UTS命名空间 | 共享 | 同一主机名,容器内hostname返回Pod名 |
| PID命名空间 | 默认隔离,可开启 | 设 shareProcessNamespace: true 后可见彼此进程 |
| 根文件系统 | 隔离 | 每个容器独立rootfs |
| Volume | 按需挂载 | 必须显式在两个容器里都声明volumeMounts |
| 环境变量 | 隔离 | 各自独立,不互相传递 |
用生活类比:合租房的室友共用一个门牌号和客厅,但各自卧室是私密的,想共用冰箱就得一起出钱买一台放客厅——这个"一起出钱买"就是显式声明Volume。不声明,谁都别想用。
localhost互访这一条特别实用。主容器监听127.0.0.1:8080,旁路容器直接连localhost:8080就能通,不需要知道Pod IP。这让配置变得非常简单,也让主容器完全不需要感知自己在Kubernetes里跑。
4.2 Sidecar落地:日志旁路转发的完整写法
日志收集是最经典的多容器场景。应用只管往文件写,旁路容器负责读取并推走,应用代码零侵入。
apiVersion: v1 kind: Pod metadata: name: demo-order labels: app: demo-order spec: containers: - name: app image: registry.internal.example/demo-order:2.1.0 volumeMounts: - name: app-logs mountPath: /var/log/app - name: log-shipper image: registry.internal.example/log-shipper:0.9.3 args: ["--source=/var/log/app", "--target=collector.internal:5044"] volumeMounts: - name: app-logs mountPath: /var/log/app readOnly: true resources: requests: cpu: "50m" memory: "64Mi" limits: cpu: "200m" memory: "128Mi" volumes: - name: app-logs emptyDir: sizeLimit: 1Gi这段配置里有三个细节值得强调。第一,旁路容器挂载时加了 readOnly: true,防止它误删应用日志,这是个顺手就做的小防护。第二,旁路容器给了很小的 resources,因为它的活儿很轻,不设的话会按BestEffort处理,节点压力大时最先被驱逐,反而在最需要日志的时候丢日志。第三,emptyDir 设了 sizeLimit。
第三点特别重要,我踩过坑。emptyDir 默认落在节点的磁盘上,不设上限的话,如果应用日志写得疯,或者转发端出故障导致文件堆积,会一路把节点磁盘写满,然后节点上所有Pod一起遭殃,触发节点级驱逐。设了 sizeLimit 之后,超过限制时kubelet会驱逐这个Pod,影响范围限制在单个Pod内。这个参数不写,等于给了应用一个把整台机器拖垮的能力。
4.3 Init容器:把"等依赖"这件事从应用代码里拿出去
Init容器是一类特殊容器,它在所有主容器启动之前按顺序执行,全部成功后才启动主容器。失败就按restartPolicy重试,主容器一直等着。
spec: initContainers: - name: wait-for-db image: busybox:1.36 command: - sh - -c - | until nc -z demo-db 3306; do echo "waiting for demo-db..." sleep 2 done - name: fix-permissions image: busybox:1.36 command: ["sh", "-c", "chown -R 10001:10001 /data"] volumeMounts: - name: data mountPath: /data containers: - name: app image: registry.internal.example/demo-order:2.1.0 volumeMounts: - name: data mountPath: /data volumes: - name: data emptyDir: {}这个用法解决了两个长期困扰应用开发的问题:一是启动时的依赖等待,以前要在代码里写重试循环,现在交给Init容器,应用代码干净了;二是挂载卷的权限问题,emptyDir挂载后默认属主是root,非root容器写不进去,用一个Init容器chown一下就解决了。
几个硬约束要记牢。Init容器严格串行,第一个跑完才跑第二个,没有并行。Init容器不支持livenessProbe、readinessProbe、startupProbe,因为它们本来就是一次性任务,成功即退出。Init容器失败时会按Pod的restartPolicy重试,如果设的是Never,Pod就直接进入Failed。还有一个容易忽略的点:Pod的资源请求计算里,Init容器的资源是按"取所有Init容器中最大的那个"来算的,不是累加,因为它跑完就释放了。这个规则在做资源规划时很关键。
5. 资源请求与限制:Pod规格里最容易埋雷的地方
5.1 requests和limits,一个是订座一个是饭量
这两个概念经常被混着说,但它们作用在完全不同的环节。
requests 是给调度器看的。调度器在选节点时,会把节点上所有Pod的requests加起来,看还剩多少。你写 cpu: "500m",意思是"我至少要半个核",调度器就按这个给你找位置。这个数字跟实际用了多少没关系,写大了浪费,写小了容易把节点塞爆。
limits 是给内核看的。cgroup按这个值设上限,超了就动手。CPU和内存的处理方式完全不同,这是最关键的一点:
- CPU是压缩型资源。超过limit不会被杀,只会被限流(throttle),表现是响应变慢。cgroup的cpu quota机制会让进程在每个周期内跑满配额后被强制挂起,等到下个周期。短期看只是慢,长期看P99延迟会明显恶化。
- 内存是非压缩型资源。超过limit没有"慢一点"这种中间态,内核的OOMKiller直接杀进程,容器退出码137,然后被重启。
这个差异决定了配置策略。CPU的limit可以适当放宽,因为最坏情况只是变慢;内存的limit必须精确,设小了就是定时炸弹,设大了浪费不说,还容易掩盖内存泄漏。我的习惯是内存的requests和limits设成相等,让QoS等级至少落在Burstable偏上的位置,减少被驱逐的概率。
5.2 QoS等级:决定驱逐顺序的隐形标签
Kubernetes根据requests和limits的配置,自动给每个Pod打一个QoS等级,这个等级直接决定节点资源紧张时谁先被赶走。
| QoS等级 | 判定条件 | 驱逐优先级 | 适用场景 |
|---|---|---|---|
| Guaranteed | 所有容器都设了requests和limits,且CPU和内存的requests等于limits | 最低(最后被驱逐) | 核心服务、数据库类 |
| Burstable | 至少一个容器设了requests,但不满足Guaranteed条件 | 中等 | 大多数业务服务 |
| BestEffort | 所有容器都没设requests和limits | 最高(最先被驱逐) | 临时任务、批处理 |
判定规则比想象中严格。要拿到Guaranteed,必须每个容器、每个资源维度(CPU和内存)都设了值,而且requests和limits完全相等。少设一个内存limit,哪怕CPU配得再完美,也只能是Burstable。
这个等级不需要你手写,但你必须知道自己的Pod落在哪一档。生产环境里我看到过一批核心服务因为漏配内存limit掉到Burstable,节点一紧张就被优先驱逐,业务方还以为是平台不稳定。查了半天,问题出在一行没写的limit上。
5.3 参数怎么算:一个Java服务的完整估算过程
抽象讲原理没什么用,拿个具体例子走一遍。
假设一个Java服务,压测下来QPS峰值500,单请求平均处理时间20毫秒。先算并发度:500 × 0.02 = 10,也就是平均有10个请求同时在处理。考虑到突发和GC停顿,线程池开到20比较稳妥。
CPU估算:每个请求是IO密集型(查库+调下游),CPU时间大概占处理时间的20%,也就是4毫秒。500 QPS × 4ms = 2秒CPU时间,需要2个核才能打平。留50%余量,requests设 2000m,limits设 4000m 允许突发。
内存估算:JVM堆设2Gi,堆外部分包括元空间(约256Mi)、直接内存(Netty的DirectBuffer,约256Mi)、线程栈(20线程 × 1Mi = 20Mi)、JIT代码缓存(约240Mi)、GC自身开销。加起来大约 2Gi + 0.8Gi ≈ 2.8Gi。再留20%给系统调用和页缓存,最终 requests 和 limits 都设 3.5Gi。
| 资源 | requests | limits | 计算依据 |
|---|---|---|---|
| CPU | 2000m | 4000m | 500 QPS × 4ms = 2核,留50%余量,limit允许突发 |
| 内存 | 3584Mi | 3584Mi | 堆2Gi + 堆外0.8Gi + 20%余量,两者相等保证Guaranteed |
这里有个JVM特有的坑必须提。容器里的JVM在早期版本默认读的是宿主机的内存而不是cgroup限制,导致堆开得过大,一启动就被OOMKilled。现在的JDK版本基本都支持容器感知了,但如果你跑的是老版本,务必显式加 -XX:MaxRAMPercentage=75 或者 -Xmx 固定堆大小。我见过一个服务limit设4Gi,JVM却按64Gi的宿主机内存算,堆直接开到16Gi,容器起来20秒必挂,排查了两天才定位到。
6. 探针体系:三个探针各管一段
6.1 职责边界,配错一个就够喝一壶
三种探针名字很像,作用完全不同,配错是新手最常见的翻车点。
livenessProbe 回答"这个容器还活着吗"。失败达到阈值,kubelet会杀掉容器并按restartPolicy重启。注意,它不会新建Pod,只是重启容器。
readinessProbe 回答"这个容器能接流量吗"。失败时不会重启,只是把这个Pod从Service的Endpoints里摘掉,流量不再打进来。等探针恢复,自动加回去。
startupProbe 回答"这个容器启动完了吗"。它在启动阶段生效,成功之前liveness和readiness都不执行。这是给慢启动应用准备的保护伞。
三者的关系可以用一句话概括:startup管开头,readiness管过程,liveness管兜底。一个应用启动要40秒、运行中偶尔因为连不上数据库短暂不可用,正确配法是startup给足60秒,readiness灵敏一点快速摘流量,liveness放宽避免误杀。如果只配了liveness且initialDelaySeconds给10秒,那必然陷入启动超时被杀、重启、再超时的死循环,日志里什么都看不到,因为容器还没跑到打日志的地方就被砍了。
6.2 参数配置的口诀和一组可用值
参数有五个:initialDelaySeconds、periodSeconds、timeoutSeconds、successThreshold、failureThreshold。
我的经验口诀是:liveness宽松、readiness灵敏、startup兜底。
| 探针 | initialDelay | period | timeout | failureThreshold | 说明 |
|---|---|---|---|---|---|
| startupProbe | 0 | 5 | 3 | 24 | 最多给120秒启动窗口 |
| readinessProbe | 5 | 5 | 2 | 3 | 15秒内连续失败就摘流量 |
| livenessProbe | 0(由startup保护) | 10 | 3 | 6 | 60秒持续异常才重启 |
这套值在我维护的几个服务上跑了很久,基本不用调。要注意的是readiness的timeout不能设得太小,如果探针接口本身要查数据库,2秒可能不够,会误判。探针接口的设计原则是"轻量、不依赖外部组件",最好是纯内存判断,别在探针里做重操作。我见过有人在 /healthz 里查一次全表统计,结果探针把数据库打挂了,这个教训值得记。
还有一个反直觉的点:探针的failureThreshold次数乘以periodSeconds,才是真正的容忍时长。liveness设period 10秒、failureThreshold 6,意味着要连续60秒失败才重启。这个乘积决定了你的服务对瞬时抖动的容忍度,比单看任何一个参数都有意义。
7. 常见问题与排查技巧实录
7.1 高频问题速查表
下面这些是我这几年反复遇到的,按现象归类,方便直接对号入座。
| 现象 | 最可能的原因 | 第一条命令 | 处理方向 |
|---|---|---|---|
| Pending,节点列为none | 资源不足或亲和性不满足 | kubectl describe pod | 看FailedScheduling事件的具体原因 |
| ImagePullBackOff | 镜像地址错、私有仓库凭证缺失 | kubectl describe pod | 检查imagePullSecrets和镜像tag是否存在 |
| CrashLoopBackOff | 应用启动报错或探针误杀 | kubectl logs --previous | 先看上一个实例日志,再核对探针参数 |
| Running但READY 0/1 | readinessProbe失败 | kubectl describe pod | 看Unhealthy事件,exec进去手动curl探针路径 |
| 退出码137 | 内存超limit被OOMKilled | kubectl describe pod | 看Last State的Reason是否为OOMKilled,调大内存limit |
| 退出码143 | 收到SIGTERM正常终止 | 看终止原因 | 通常是缩容或滚动更新,检查preStop钩子是否够用 |
| Pod频繁被驱逐 | QoS等级低或节点压力 | kubectl get events | 补全requests/limits,提升QoS等级 |
| 多容器读不到文件 | 未挂载共享Volume | kubectl exec 进入查看 | 两个容器都要声明volumeMounts |
7.2 我自己的排障顺序,和一些不那么常规的技巧
遇到Pod异常,我的动作基本是固定的:先 get 看状态和节点,再 describe 看事件,然后 logs --previous 看死因,最后 exec 进去手动验证。这四步能解决八成问题,剩下两成再往节点层和网络层钻。
分享几个不太写进文档的技巧。第一个,describe 输出很长,我习惯用 grep 直接抽事件段:
kubectl describe pod demo-api -n prod | sed -n '/Events:/,$p'第二个,事件有时效性,默认只保留一小时。如果问题发生得早,用这个查历史:
kubectl get events -n prod \ --field-selector involvedObject.name=demo-api \ --sort-by=.lastTimestamp第三个,判断是不是探针误杀,最直接的办法是临时把liveness去掉重跑一次。如果去掉后应用能正常起来,那百分之百是探针参数问题,接着调 initialDelaySeconds 或者换成 startupProbe 就行。这个手法虽然粗暴,但定位效率极高。
第四个,CrashLoopBackOff 的时候容器活得太短,exec 根本来不及进去。这时候可以临时把 command 覆盖成 sleep,让容器挂在那儿,再exec进去手工执行启动脚本,一步步看卡在哪:
kubectl run debug-pod --image=registry.internal.example/demo-api:1.4.2 \ --restart=Never --command -- sleep 3600这个调试Pod的思路是从原Pod的清单里扒下镜像、环境变量、Volume挂载,去掉探针,改成一个不会退出的命令。我一般会直接 copy 原YAML改,比手敲靠谱。
第五个心得,关于 emptyDir 的 sizeLimit。前面提过一次,这里再强调:凡是用 emptyDir 的地方,一律显式设 sizeLimit。这不是可选项,是防护栏。没设的Pod在大流量或者下游故障时会把节点磁盘写满,然后整个节点上的Pod一起遭殃,故障半径从"一个Pod"扩大到"一台机器"。这个参数就一行,加不加的差别可能是半小时故障和半天故障。
7.3 关于资源超卖的一个实测观察
最后说个实测出来的体会。很多团队为了提升集群利用率,会把 requests 设得很低、limits 设得很高,靠超卖来塞更多Pod。短期看资源利用率上去了,长期看会积累两个隐患。
一是CPU throttling会在高峰期集中爆发。节点上所有Pod都以为自己能用高limit,实际物理核就那么多,cgroup调度器只能轮流掐,结果每个Pod都在被throttle,延迟全线恶化。这个现象很隐蔽,因为CPU使用率指标看着不高,但容器的nr_throttled计数在飙升。查这个要看容器的cpu.stat文件,throttled_time 持续增长就是信号。
二是内存的OOM风险在节点层面会被放大。单个Pod超限只是自己被杀,但如果多个Pod同时冲高,节点的可用内存会被迅速吃光,触发节点级驱逐,一口气赶走一批Pod。这种连锁反应排查起来很痛苦,因为每个Pod的单点日志都看不出问题。
我现在的做法是:CPU的requests按实际P50用量设,limits给到requests的1.5到2倍,允许合理突发;内存的requests和limits全部相等,杜绝超卖。这么配之后,集群利用率大概在60%左右,看着不极致,但线上抖动和驱逐事件基本消失了。资源利用率这个数字好看不好看,和业务体验之间没有必然联系,别被它牵着走。
至于Pod的调度约束、亲和性、污点容忍、拓扑分布这些让Pod"落到哪台机器"的规则,牵扯到节点标签体系和集群拓扑设计,展开讲篇幅不小,留到下一篇单独拆。这一篇把Pod本身的静态部分吃透,后面那些动态规则才有落脚点。