说实话,做 Kubernetes 运维这几年,我最大的感触是:Pod 才是整个集群里最值得抠细节的对象,而不是那些五花八门的网络插件或者存储方案。很多人会用 Pod,YAML 复制粘贴得飞起,但等到线上告警响起来,才发现自己对 Pod 的理解停在"一个容器"或者说"一个能跑的单元"这个层面。这篇文章就是给已经会用 Pod、但想更进一步的人写的,我会从探针与优雅退出、多容器编排、资源模型与 QoS、调度规则、安全上下文,以及我自己处理过的几个线上案例入手,把那些文档里不会写、但实战中一定会遇到的东西一次讲透。读完之后你至少能回答一个问题:一个 Pod 从创建到销毁,中间每一个字段、每一个信号、每一条调度规则,到底是谁在消费、谁在决定、出了问题去查哪里。
1. 会写Pod和懂Pod之间,隔着的不是语法而是认知
1.1 先把Pod"最小调度单元"这层窗户纸捅破
教科书喜欢把 Pod 定义为"Kubernetes 中最小的调度和部署单元",这句话本身没错,但容易让人产生一个错觉:Pod 就是一层包着容器的壳。实际上,Pod 更准确的定位是"一组容器共享运行环境的逻辑主机"。你可以把它想象成一套合租公寓:里面的容器是室友,它们共享同一个网络命名空间(也就是共用一个 IP 和端口空间)、同一个 IPC 命名空间、同一个 UTS 命名空间,还能挂载同一块存储卷。室友之间可以用 localhost 直接互相访问,这在普通的 Docker 容器组合里是做不到的。
理解了这层共享关系,很多问题就有了判断依据。比如为什么同一 Pod 里的两个容器不能用同一个端口,因为它们在同一个网络命名空间里,端口是全局唯一的。再比如为什么 Sidecar 代理能用 localhost 转发主容器的流量,也是同一个道理。还有一个常被忽略的细节:Pod 内的容器共享 PID 命名空间是可选的,默认不共享。也就是说,你在 Sidecar 容器里ps是看不到主容器进程的,除非显式设置shareProcessNamespace: true。这个字段对排查问题影响很大,我后面讲多容器编排时会再提到。
1.2 进阶的第一步:知道每个字段由谁消费
刚开始学 Pod 时,YAML 里的字段在我眼里就是一排排参数,照着模板抄就行。直到有一天排障,我发现改了resources.limits.memory之后容器并没有立刻被重新调度,才意识到一个关键问题:Pod 的不同字段是由不同组件消费的,改完之后生效的位置和时机完全不同。
举个最典型的例子:nodeSelector、nodeAffinity、toleration这些字段是给 kube-scheduler 看的,所以改了之后需要 Pod 重新调度才会生效,单纯原地更新 Pod 很多时候没用。resources.requests和limits是给 kubelet 和 kube-scheduler 共同消费的:调度器拿 requests 做节点资源评估,kubelet 拿 limits 来配置 cgroup 限制,所以改了内存 limit,需要容器重建(实际上大多数时候是 Pod 重建)才会真正落到 cgroup 上。而readinessProbe、livenessProbe这类字段只和 kubelet 相关,改了之后不需要重建 Pod,kubelet 会动态更新探针配置。
这个认知的实用价值在于排障定位:遇到问题时,先想清楚"这个配置是谁在消费",再去对应的组件日志里找线索,能省掉大半瞎猜的时间。比如 Pod 一直 Pending,你去看的应该是 scheduler 日志,而不是 kubelet 日志;Pod 被反复重启,那就该看 kubelet 和容器运行时的事件。进阶的第一步,不是背更多 YAML 字段,而是建立"字段—组件—日志"这条对应链。
2. 探针与优雅退出:Pod生死时刻的完整链路
2.1 三种探针的分工,远比你想的讲究
探针大概是 Pod 配置里最容易被"抄错方向"的部分。很多团队所有服务一律只配一个readinessProbe,还是从网上复制来的/healthz。我见过最严重的案例,是把 liveness 探针指向了一个依赖外部数据库的接口,数据库一抖动,所有副本同时触发重启,整个服务原地雪崩。所以先理清楚三种探针各自的职责:
- livenessProbe:判断容器是否"活着"。失败时 kubelet 会杀掉容器并按照
restartPolicy决定是否重启。它应该检查的是"进程还能不能提供服务",而不是"服务依赖的下游是否健康"。 - readinessProbe:判断容器是否"准备好接收流量"。失败时容器不会被杀,但会被从 Service 的 Endpoints 中摘除。它才是真正适合检查完整链路健康的地方。
- startupProbe:专门解决慢启动应用被 liveness 误杀的问题。在 startupProbe 成功之前,liveness 和 readiness 探针都不生效。
探针参数的设置也很有讲究。initialDelaySeconds给应用留启动时间,但这个值最好结合镜像实际拉起时间来测,而不是拍脑袋写 30。periodSeconds默认 10 秒,对高并发服务来说,探针请求本身也会消耗一点资源,如果接口响应慢,timeoutSeconds要放宽,否则健康检查自身超时会造成假失败。下面是一份我在生产环境常用的配置模板:
readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 2 successThreshold: 1 failureThreshold: 3记住一个原则:探针是用来发现"这台机器是否还能干活"的,不要在里面串联太多外部依赖。健康检查接口最好在本进程内就能快速回答"我活着、我准备好了"。
2.2 一次删除操作背后,其实是五步接力
很多人以为kubectl delete pod就是让容器停了,实际操作里这是一条完整的接力链路,每一步都有可能出问题:
- API Server 把 Pod 标记为 Terminating,同时更新
deletionTimestamp。 - Endpoint 控制器把该 Pod 从 Service 的 Endpoints 里摘除(这一步和 readiness 探针摘除是两套机制)。
- kubelet 收到通知,先执行
preStop钩子(如果有的话)。 - kubelet 向主容器发送 SIGTERM 信号。
- 等待
terminationGracePeriodSeconds(默认 30 秒),超时后发送 SIGKILL 强杀。
这里有几个非常容易踩的坑。第一,很多人不知道 API Server 标记 Terminating 之后,流量摘除和 SIGTERM 之间是有时间差的,但摘除动作本身是异步的,极端情况下可能出现"Pod 正在处理请求,SIGTERM 已经到了"。所以应用层面必须自己处理优雅退出,收到 SIGTERM 后停止接受新请求,等正在处理的请求完成再退出。
第二,preStop钩子里最常见的写法是sleep 5,本意是给流量摘除留时间。但如果你把preStop的执行时间加上容器退出时间算进terminationGracePeriodSeconds,这个值往往不够用。我遇到过一版 preStop 里先跑脚本再等 30 秒,而 grace period 也是 30,结果脚本还没跑完容器就被强杀——preStop 本身也在 grace period 内计时。正确做法是先把应用的抓信号退出的逻辑做对,preStop 只做少量必要的收尾动作,然后适当调大terminationGracePeriodSeconds来兜底。
lifecycle: preStop: exec: command: ["sh", "-c", "sleep 5"] terminationGracePeriodSeconds: 60提示:如果应用是消息消费者,尤其要注意 SIGTERM 后正在处理的消息怎么处理。最稳妥的方案是应用内捕获 SIGTERM,先把当前任务处理完再退出,而不是依赖 preStop 里的 sleep 来赌运气。
3. 多容器编排:Init容器和Sidecar容器的正确用法
3.1 Init容器:串行准备,干完活就退出
Init 容器的执行时机在 Pod 的主容器启动之前,多个 Init 容器按顺序执行,每个成功退出后才执行下一个。它的典型场景包括:等待外部依赖就绪(比如数据库建表)、下载数据或配置、执行权限初始化、迁移脚本等等。
我用得最多的场景是把"配置生成"从主容器里拆出来。以前应用启动时要在容器内跑一段脚本从配置中心拉配置,后来改成一个 Init 容器做这件事,写完共享卷后退出,主容器启动时直接读取。好处很明显:配置拉取失败时,Pod 会停留在 Init 阶段并不断重试,而不会出现"主容器起来了但配置不全,业务流量已经进来了"这种尴尬情况。
Init 容器有两个坑值得注意。第一是资源竞争:如果 Init 容器没有设置resources,它和主容器会共享节点资源额度,并行的资源评估逻辑比较绕。官方建议是给 Init 容器单独设置resources.requests,而且要记得它可能比主容器消耗更多 CPU。第二是重试逻辑:Init 容器失败后,Pod 会按照restartPolicy和 backoff 机制反复重启整个 Pod(不是只重启 Init 容器),所以在 Init 容器里做超时控制和幂等处理非常重要,否则一旦失败就会陷入"重启—再失败—再重启"的循环。
3.2 Sidecar容器:和主容器共生,不是简单塞一个镜像
Sidecar 容器从设计定位上和 Init 容器完全不同:它不是干完活就退出的临时工,而是陪主容器跑完整生命周期的搭档。最常见的两大类场景,一是日志采集(Filebeat、Fluent Bit 挂载 emptyDir 共享日志目录),二是流量代理(Istio Envoy 进 Pod,通过 localhost 转发)和指标采集。
Sidecar 能频繁出现在现代架构里,依赖的就是第 1 节说的共享机制:同一 Pod 里的容器共享网络命名空间和存储卷。日志 Sidecar 和主容器挂同一个 emptyDir,主容器写日志文件,Sidecar 读出并转发,互不阻塞;代理 Sidecar 监听在 localhost 的特定端口,主容器把请求指向它,由它统一做 TLS 终结或负载均衡。
这里要特别提醒一个版本差异:在 Kubernetes 1.28 之前,Sidecar 容器的生命周期没有专门的语义,它和普通容器一样,Pod 终止时所有容器一起收到 SIGTERM,谁先退不一定。如果你的日志 Sidecar 先退了,主容器最后几行日志就可能没被采集到。1.28 开始支持了"原生 Sidecar"(在容器级别设置restartPolicy: Always),能保证 Sidecar 的启动顺序(先于主容器)和终止顺序(晚于主容器退出),这对日志完整性是很关键的。如果你在 1.28 之前,就只能靠应用侧在退出前先 flush 日志或者接受少量日志丢失。
提示:多容器 Pod 排查问题时要习惯用
kubectl logs -c <容器名>指定容器,否则默认只输出第一个容器的日志。同样,kubectl exec也要用-c指定目标容器,这个操作很多人临场会忘,一查就是半天。
4. 资源模型与QoS:Request和Limit背后是一套驱逐哲学
4.1 CPU和内存的语义完全不是一回事
这是 Pod 进阶里我认为最值得花时间理解的部分,因为它直接决定了你的容器在节点压力下的"生死顺序"。
CPU 是可压缩资源。你给容器设置了limits.cpu=2,kubelet 会通过 CFS 配额把它的 CPU 使用限制在 2 核以内。当容器想用更多 CPU 时,内核会做限流(throttling),表现为 CPU 使用率被"压平",进程不会被杀,只是变慢。而内存是不可压缩资源。容器超过limits.memory后,内核会直接调用 OOM Killer 杀掉容器里最占内存的进程,然后被 kubelet 判定为失败,按策略重启。
同样要注意单位,CPU 的100m是 0.1 核,1等同于1000m;内存的1Gi是 1024Mi,不是 1000Mi。别小看这个单位换算,我在给业务方做资源规划时,真的见过有人把 2Gi 当成 2GB,结果压测一上来就被 OOM。
另一个常见误区是:容器里执行free或top看到的其实是宿主机的内存和 CPU 总量,而不是容器自己的配额,因为默认没有按 cgroup 维度展示。想确认容器实际能用多少,看limit字段最直接,或者进容器读/sys/fs/cgroup/memory.max(cgroup v2)这类文件。
4.2 QoS等级:节点内存紧张时,先牺牲谁?
Kubernetes 根据 Pod 的 request 和 limit 设置,把 Pod 划分为三个 QoS 等级,并以此决定节点资源紧张时的驱逐顺序:
| QoS 等级 | 判定条件 | 节点压力下 |
|---|---|---|
| Guaranteed | 所有容器都设置了 request 且 request == limit | 最晚被驱逐 |
| Burstable | 至少一个容器设置了 request,但存在 request != limit | 其次被驱逐 |
| BestEffort | 所有容器都没设置 request 和 limit | 最先被驱逐 |
这个机制的本意是保护关键负载,但它也催生了一种不健康的做法:为了让自己不被打死,所有 Pod 都无脑设置request == limit。我见过一个极端案例,某业务方把所有内存 limit 都设成和 request 一样大,结果容器稍微有一点内存增长就被 OOM 杀掉,应用频繁重启。原因很简单:Guaranteed 的确不容易被驱逐,但它限死了你使用资源的弹性上限,内存一旦逼近 limit 就是死路一条。
4.3 所谓"合理配置"到底怎么落地
我的实践建议分三步。第一步,先压测拿到容量的真实基线:并发多少、QPS 多少、内存峰值多少,起码运行几天收集监控数据,再用 P99 或者最大值乘以 1.2~1.5 的安全系数。第二步,给 request 设置成这个基线值,limit 在 request 基础上留出爆发余量,比如 CPU limit 是 request 的 2 倍,内存 limit 是 request 的 1.2~1.5 倍,留一点空间但不至于大到失去保护意义。第三步,结合 HPA 来动态扩缩副本,因为 HPA 的 CPU 利用率计算用的是 request 作为分母而不是 limit——这一点很多人算错了,导致 HPA 迟迟不扩。
还要记得设置ephemeral-storage的 request 和 limit。日志写满临时存储导致 Pod 被驱逐的案例太常见了,这个资源被大量团队直接忽略。
5. 调度规则的潜规则:亲和性、污点与拓扑分布
5.1 nodeSelector 到 nodeAffinity:从简单匹配到表达式匹配
调度这一块,很多团队实际用得非常粗糙。最常见的操作是在节点上打标签,然后给 Pod 写nodeSelector做精确匹配。这在小规模集群没问题,但一旦节点分类型(GPU 机型、高内存机型、ARM 机型),你需要的往往是更灵活的选择逻辑。
nodeAffinity提供了两类策略:requiredDuringSchedulingIgnoredDuringExecution(硬性要求)和preferredDuringSchedulingIgnoredDuringExecution(软性偏好)。硬性要求的意思是调度时不满足条件就不调度;软性偏好则是尽量满足,不满足也能被调度到其他节点,配合weight控制优先级。我常用的是这套组合:GPU 任务用硬性要求匹配 GPU 节点标签,普通服务用软性偏好尽量分散到多可用区。
有一点必须说清楚:Affinity 的两个阶段中,"调度时"是强制的,"运行中"是忽略的。也就是说,即使节点后来标签变了,已经跑在上面的 Pod 也不会被重新调度去校验亲和性。这体现了调度系统的设计哲学:调度是一次性决策,运行中要不要搬走是另一套驱逐机制的事。
5.2 污点与容忍:宽泛的容忍很容易养出事故
污点(Taint)是加在节点上的"排斥标记",容忍(Toleration)是加在 Pod 上的"通行证"。有三个 effect 要分清:
| Effect | 行为 |
|---|---|
| NoSchedule | 不把新的 Pod 调度到这个节点 |
| PreferNoSchedule | 尽量不调度,无匹配节点时可以调度 |
| NoExecute | 立即驱逐节点上不满足容忍的 Pod |
真正坑人的往往是 NoExecute 的tolerationSeconds。有个案例让我印象很深:团队为了防止节点维护时 Pod 被过早驱逐,给核心服务加了node.kubernetes.io/not-ready和node.kubernetes.io/unreachable的容忍,容忍时间设为 60 秒。听起来很合理——节点短暂抖动时 Pod 不会被立刻干掉。但问题是,一旦节点真的宕机超过 60 秒,所有副本会同时被标记为驱逐,控制器重建的新 Pod 又继续调度到其他节点,短时间内把整个集群的节点打满。这里的教训是:给系统级污点配容忍时,一定要考虑全局并发影响,不要只盯着单节点看。
自定义污点也有一个常见坑:很多人给"专用节点"打了污点,然后给业务 Pod 写了operator: Exists的宽泛容忍,等于把所有节点的污点全部放行,专用节点的隔离意义就完全失效了。容忍要写具体,key、value、effect 尽量都带上。
5.3 topologySpreadConstraints:让 Pod 分布真正符合直觉
有了亲和性和反亲和,大家下意识会用podAntiAffinity来做高可用分布,比如让同一服务的副本尽量不落在同一个节点上。但反亲和有一个隐藏问题:它默认基于hostname拓扑键,只能做到"每个节点一个副本",而且条件一旦满足,剩余副本就没有任何约束了。如果集群跨多个可用区,你的副本可能仍然全部集中在同一个可用区——可用的高可用在语义上是假的。
topologySpreadConstraints就是为了解决"按某个拓扑域均匀分布"而生的:
topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: my-service这段配置的含义是:以可用区为拓扑域,任意两个可用区中这个服务的副本数差值不能超过 1;如果没法满足,宁可让 Pod 停留在 Pending,也不要破坏均匀分布。maxSkew越小分布越均匀,但调度失败的概率也越高,生产环境我一般从 1 开始,确认集群规模足够再收紧。
还可以配合doNotSchedule和ScheduleAnyway两种模式理解:前者是硬约束,后者是把均匀分布当成一个软加分项,实在放不下时可以接受倾斜调度。线上高可用服务我建议至少把主工作负载的拓扑约束设为硬约束,否则一个可用区故障可能直接带走全部副本。
6. 安全上下文与PodSecurity标准:权限收敛是绕不开的必答题
6.1 securityContext:从Pod到容器,逐层收紧
很多基础镜像默认以 root 用户运行,这在本地开发没问题,到了生产环境就是重大的安全隐患。一旦容器被攻破,攻击者拿到的就是容器内 root 权限,如果再有特权容器或内核漏洞,就存在逃逸到宿主机的风险。所以安全上下文的第一原则是:能不用 root 就不用 root。
securityContext可以配置在 Pod 级别和容器级别,容器级别的配置会覆盖 Pod 级别。我常用的字段组合如下:
securityContext: runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 fsGroup: 10001 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"]这里解释几个关键点。allowPrivilegeEscalation: false用于禁止进程获得比父进程更高的权限,配合capabilities的 drop 操作,把默认持有的 Linux capabilities 全部去掉,只加上确实需要的NET_BIND_SERVICE(比如要监听 80 端口)。readOnlyRootFilesystem: true把根文件系统设为只读,应用只能写挂载的卷,能有效阻止写入型攻击。设置之后如果应用启动报权限错误,不要直接放开安全上下文,先检查是不是日志目录、临时目录没挂卷,这是更安全的解法。
还有一点容易被忽略:runAsNonRoot: true并不是"我会把进程以非 root 运行",而是"如果镜像里默认用户是 root,就拒绝启动"。它强制镜像作者必须显式声明非 root 用户,所以我建议把这项作为集群的默认策略,而不是每个 Deployment 自己决定。
6.2 PodSecurity标准:集群级的安全兜底
单靠每个 Deployment 自己写 securityContext 很难覆盖所有新增工作负载,所以集群层面要有统一标准。旧版的 PodSecurityPolicy(PSP)因为设计复杂、启用方式反人类,已经被废弃。现在用的是 PodSecurity Admission(PSA),它定义了三个级别:
| 级别 | 含义 |
|---|---|
| privileged | 不设任何限制,最宽松 |
| baseline | 默认禁用明显危险的配置(如 privileged 容器、hostPID、hostNetwork 等) |
| restricted | 在 baseline 之上继续收紧(强制非 root、只读根文件系统、限制 capabilities 等) |
启用方式很直观,直接给命名空间打标签,无需部署额外的 Admission Controller:
labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: latest除了enforce之外,还有warn和audit两种模式。warn会在创建不安全的 Pod 时给出警告但不拦截,audit则把违规事件记入审计日志。我给团队的过渡建议是:先在核心命名空间用warn跑一两周,把所有警告汇总分析,逐个修复,然后再切到enforce。直接一步到位上 restricted,往往会遇到大量存量工作负载不兼容,效果反而不好。
7. 线上复盘:几个"看着没挂,其实已经出问题"的案例
7.1 案例一:Readiness探针没有设超时,流量全打到未就绪的Pod
现象是新版本发布后,服务偶发大量 502,但看 Pod 状态全是 Running,资源占用也不高。排查到最后,发现 Readiness 探针指向的是一个偶尔会慢的统计接口,而探针的timeoutSeconds用的默认值,接口响应一旦超过 2 秒,探针就判定失败,Pod 被摘除。但摘除和接入是动态的,探针恢复后又立刻把流量切回来,造成 Pod 频繁进出 Endpoints,负载均衡器来回切换,最终表现为间歇性 502。修复很简单:探针接口改用进程内轻量实现,并把timeoutSeconds显式调大一点。这个问题的本质是探针路径选错了,它不应该承载任何可能变慢的业务逻辑。
7.2 案例二:/dev/shm只有64MB,大并发一上来就崩溃
某个 Java 应用上线后偶发崩溃,应用日志里没有任何异常,看 OOM 事件也只是显示进程被杀。后来在节点上dmesg里看到shm相关的痕迹,才想起/dev/shm默认大小只有 64MB。这个应用用到了共享内存做进程间通信,并发一高就超过 64MB。修复方案是在 Pod 里挂一个emptyDir的medium: Memory卷到/dev/shm,并设置合理的sizeLimit。这个案例的教训是:Kubernetes 的容器不等于你本地 Docker 运行时,默认限制多到你想象不到,遇到诡异崩溃先怀疑各种隐藏的 cgroup 和 tmpfs 限制。
7.3 案例三:临时存储写满,Pod 被驱逐却没人发现
有段时间监控面板上不断有 Pod 变成 Evicted 状态,业务方一直没注意,因为每秒请求还在正常返回——直到某个副本数较少的服务所有 Pod 全部被驱逐,才真正挂掉。根因是应用日志写入不滚动,把 emptyDir 里的临时空间写满了。kubelet 发现节点磁盘压力后,会优先驱逐超出ephemeral-storagelimit 的 Pod。这个案例给我们的改造是:所有写日志的容器都加日志轮转,给 emptyDir 加sizeLimit,同时给 Pod 设置resources.limits.ephemeral-storage,并且监控里专门加一条"Evicted Pod 数量"的告警。Evicted 状态不会主动消失,必须人工删除或被控制器重建,长期不处理会留下大量僵尸 Pod 占用状态存储。
7.4 案例四:配了反亲和,副本却还是集中在同一个可用区
这个案例很能说明前面第 5 节的观点。团队给服务配了podAntiAffinity,以为高可用已经做好了,结果某公有云一个可用区故障,服务直接不可用。事后看调度分布才发现,反亲和的topologyKey默认是kubernetes.io/hostname,它的语义是"同一服务不能有两个副本在同一个节点上",不是"不能集中在同一个可用区"。副本总数少、节点多的时候,反亲和很容易全部满足,副本依然可能全部落在同一个可用区。我们后来改用topologySpreadConstraints并按topology.kubernetes.io/zone分布,配合硬约束补齐了这块缺口。从那以后我养成了一个习惯:看高可用,先看副本在故障域上的分布,而不是只看副本数量。
这几轮排查下来,我对 Pod 的理解总结起来就是一句话:它是一个有完整生命周期的逻辑主机,而不是一个静态 YAML。探针决定生死,资源决定顺位,调度决定位置,安全上下文决定边界。你在 YAML 里写的每一个字段,背后都有一套运行时机制在消费它——当你开始用"机制"而不是"语法"的视角去看 Pod 时,才算真正进了进阶的门。我的建议是找一台测试集群,把上面提到的字段逐个改一遍,配合kubectl describe和事件输出观察行为变化,这种亲手验证得来的体感,比看十篇文档都管用。