ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kubernetes Pod进阶指南:探针、资源模型、调度与安全上下文

Kubernetes Pod进阶指南:探针、资源模型、调度与安全上下文 说实话做 Kubernetes 运维这几年我最大的感触是Pod 才是整个集群里最值得抠细节的对象而不是那些五花八门的网络插件或者存储方案。很多人会用 PodYAML 复制粘贴得飞起但等到线上告警响起来才发现自己对 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 相关改了之后不需要重建 Podkubelet 会动态更新探针配置。这个认知的实用价值在于排障定位遇到问题时先想清楚这个配置是谁在消费再去对应的组件日志里找线索能省掉大半瞎猜的时间。比如 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. 资源模型与QoSRequest和Limit背后是一套驱逐哲学4.1 CPU和内存的语义完全不是一回事这是 Pod 进阶里我认为最值得花时间理解的部分因为它直接决定了你的容器在节点压力下的生死顺序。CPU 是可压缩资源。你给容器设置了limits.cpu2kubelet 会通过 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.maxcgroup 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 很难覆盖所有新增工作负载所以集群层面要有统一标准。旧版的 PodSecurityPolicyPSP因为设计复杂、启用方式反人类已经被废弃。现在用的是 PodSecurity AdmissionPSA它定义了三个级别级别含义privileged不设任何限制最宽松baseline默认禁用明显危险的配置如 privileged 容器、hostPID、hostNetwork 等restricted在 baseline 之上继续收紧强制非 root、只读根文件系统、限制 capabilities 等启用方式很直观直接给命名空间打标签无需部署额外的 Admission Controllerlabels: 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和事件输出观察行为变化这种亲手验证得来的体感比看十篇文档都管用。
返回列表