Kubernetes Pod IP耗尽排查与Node Pool分层改造实践
事情发生在一次例行的版本升级之后。凌晨两点多告警群里突然开始刷某业务线的Pod调度失败kubectl get pods一看一大批副本卡在Pending状态kubectl describe pod给出的原因是failed to allocate ip address: no available IP。当时第一反应是节点资源不够结果查了一圈CPU、内存、磁盘都充足节点池里的机器个个正常就是调不上去。折腾到天亮才发现问题出在Pod IP上——集群在演进的过程中Node Pool的划分方式一直没有跟着业务增长调整IPAM池子早就悄悄逼近上限了。这篇内容就是记录这次排查和改造的完整过程。目标读者是那些正在维护Kubernetes集群的运维、SRE或者平台工程师尤其是集群规模到了一定程度、开始频繁出现无法分配IP这类报错的人。文章会从问题现象、根因分析、方案设计、存量迁移再到后续容量规划完整走一遍文中涉及的集群名称、业务线名称都已做过脱敏处理但命令、配置和排查思路都是可以直接落地的。1. 一场调度风暴如何让我盯上了Pod IP池完整排查链路1.1 从调度事件到CNI报错先分清是资源不足还是IP不足我遇到的情况典型的不能再典型。某支付业务线的统计任务在凌晨扩容新拉的20个副本全部Pending调度器没有任何Insufficient cpu或者Insufficient memory这类事件Pod事件里只有一条反复出现的报错Failed to allocate ip address: no available IP。先说我当时的反应很多人包括我自己第一次遇到这个报错时容易走弯路。第一直觉是看节点资源第二直觉是看CNI网络插件是不是挂了。但这两个方向都错了。正确的打开方式是先去看事件本身——如果调度器已经完成了调度Pod被绑定到具体节点但Sandbox创建失败那问题大概率出在CNI插件这一层。可以用下面的命令快速验证# 查看Pod调度到了哪个节点 kubectl get pod -n your-namespace -o wide | grep Pending # 如果已经调度但创建失败去kubelet日志里看CNI报错 journalctl -u kubelet -f --since 2024-11-20 02:00:00 | grep -i cni\|ipam\|failed我见过更隐蔽的情况所有Pod都显示ContainerCreating但因为CNI插件卡在IP分配上容器一直起不来从Pod状态上看会误以为是容器运行时问题。所以我又用crictl ps -a扫了一遍Sandbox状态发现沙箱容器状态一直是CONTAINER_CREATED最终定位到CNI调用栈里。1.2 用calicoctl把IP池家底翻出来定位到分配率确认是CNI层的问题之后就要看IPAM池的具体分配情况了。我的集群用的是Calico所以排查链路很清晰# 查看集群所有IPPool规划 calicoctl get ippool -o yaml # 查看某个IPPool的分配量 calicoctl ipam show --ippool10.90.0.0/18这一步能看到两个关键信息一是集群里到底规划了几个池子二是每个池子已经分配了多少地址。我当时的输出很吓人默认池10.90.0.0/16下面已经分配了6万多个地址而池子的可用空间本来是按网段大小固定的加上节点路由、系统预留占掉一部分实际可分配给Pod的数量已经到顶了。更麻烦的是所有业务线都在用同一个默认池任何一个业务的小幅扩容都可能把池子打爆。1.3 排查中容易忽略的两类隐形消耗排查到这里我又多留了个心眼。因为单纯看到分配率高还不够IP不足往往不只是用完了还有两类消耗很容易忽略。第一类是被删除的PodIP并没有立刻归还。Calico会有一个短暂的TTL回收窗口具体时长跟版本配置有关。在大量滚动发布的时候一分钟内可能几千个Pod被删掉重建此时IPAM里正在回收的地址数量会瞬间冲高出现明明刚释放了一堆IP但新Pod就是分不到地址的怪现象。第二类是集群中的DaemonSet、系统组件它们的Pod散落在所有节点上也会默默吃掉池子里的IP。我在统计时一开始只算了业务Pod后来把kube-system、网络插件Pod、日志采集Pod全算进去缺口比想象的大得多。这轮排查让我确认了一个事实这不是一次偶发故障是集群演进过程中IP规划没跟上业务节奏的必然结果。2. 从CIDR分配机制看IP不足的三个真实成因2.1 先搞懂CNI分配Pod IP的两种模式要理解这个故障必须回到IPAM机制本身。Kubernetes本身不负责给Pod分IP这个动作由CNI插件的IPAM模块来完成。不同CNI的分配逻辑差异很大但大体可以归成两类。一类是每节点一网段模式Flannel的默认方式就是这样。集群初始化时规划一个大的Pod网段比如10.244.0.0/16每个节点在这个大网段里蹭一块子网比如10.244.1.0/24。这带来的直接限制是单节点的Pod数量上限取决于这个子网的可用地址数一个标准/24网段去掉网络地址、网关地址、广播地址后可用地址大约253个。如果你一个节点跑满了253个Pod下一个Pod就分不到IP了。另一类是全局池内动态分配模式Calico的默认方式。它不按节点静态切分网段而是把Pod地址从IPPool里动态分配节点通过路由方式知道哪些IP落在这个节点上。这种方式灵活单节点不会被网段大小卡死但代价是全局池容量成了瓶颈——当整个池子的地址都快分完的时候任何一个节点上的新Pod都没法获得IP。我这次踩的就是这个坑整个集群共用一个大池子业务一多池子直接见底。2.2 日常踩中Pod IP不足的三种典型场景根据这次的排查经历结合平时处理过的类似问题我把IP不足的根因归纳成三种。先列成一张表方便对照自查场景特征根因排查入口单个节点Pod数接近253新Pod调度到该节点失败Flannel每节点固定子网/24容量见底查看kubelet日志中的CNI报错统计节点实际Pod数所有节点都能调度但全集群新Pod批量创建失败Calico全局IPPool地址耗尽calicoctl ipam show --ippool池子检查分配率刚开始正常大量滚动发布后突然分配失败已删除Pod的IP处于回收TTL窗口出现瞬时耗尽观察IPAM分配记录中的回收中状态等待短暂时间再重试这三种场景需要不同的处理方式。第一种是改节点网段大小或者调低单节点Pod密度第二种是扩容IPPool或者做池子拆分第三种是控制并发发布节奏或者按业务拆分避免大家都挤在一个池子里。2.3 为什么把单节点网段调大不是个聪明办法我遇到过不少同事第一反应是既然节点网段不够那就把每个节点的子网从/24调成/23不就行了理论上当然可以但实际操作中我基本不推荐。原因有两个。第一Pod IP是整个集群网络规划的一部分节点子网改大意味着路由表、防火墙规则、网络策略的覆盖范围都要跟着变涉及面太广。第二单节点网段调大之后本机Pod数量的上限提高了但kubelet、容器运行时、内核网络参数能不能撑住就不好说了。单节点跑到300个以上的Pod首先cni调用延迟会变高其次kube-proxy的iptables规则规模会膨胀再往后是句柄数、PID限制都会成为新的瓶颈。真正的根因不是单节点容量太小而是整个集群的Pod IP没有一个合理的分层规划。所以接下来我决定用一个更大胆的方案从架构层面做Node Pool分层把IP资源隔离成多个专门池子。3. Node Pool分层按业务角色隔离IP资源的改造方案3.1 方案核心把物理节点池和IP资源池绑定起来Kubernetes里的节点池通常情况下是按机器规格、污点来划分的。比如我有三个区域通用计算池跑常规业务4C8G到16C32G的机器混合高性能池跑实时计算、模型推理32C128G的大规格机器离线任务池跑批处理、数据清洗用的是低配但数量多的机器。但之前的网络规划完全没有利用这个分层——所有池子里的节点都用同一个Calico默认IPPool。这就相当于把三个不同业务部门的仓库全放在同一块空地上任何一个部门囤货多了其他人连门都进不去。改造的思路很简单每个节点池绑定一个独立的Calico IPPool再通过命名空间的nodeSelector把业务流量约束到对应的节点池上。这样不同业务线的Pod IP天然隔离一个池子满了只会影响该池子对应的业务不会拖垮整个集群。3.2 具体落地配置Calico IPPool、节点标签、命名空间三层联动第一步是为节点池打标签。对我这个集群来说三个池子的标签规划是# 通用计算池 kubectl label node --selectorpoolgeneral pool.kubernetes.io/namegeneral # 高性能池 kubectl label node --selectorpoolhigh-mem pool.kubernetes.io/namehigh-mem # 离线任务池 kubectl label node --selectorpooloffline pool.kubernetes.io/nameoffline第二步是创建对应的Calico IPPool。这里有个细节我要特别说明Calico IPPool的nodeSelector字段决定了这个池子的地址可以被哪些节点使用。配置如下apiVersion: projectcalico.org/v3 kind: IPPool metadata: name: general-pool spec: cidr: 10.91.0.0/16 natOutgoing: true nodeSelector: pool.kubernetes.io/name general --- apiVersion: projectcalico.org/v3 kind: IPPool metadata: name: high-mem-pool spec: cidr: 10.92.0.0/16 natOutgoing: true nodeSelector: pool.kubernetes.io/name high-mem --- apiVersion: projectcalico.org/v3 kind: IPPool metadata: name: offline-pool spec: cidr: 10.93.0.0/16 natOutgoing: true nodeSelector: pool.kubernetes.io/name offline第三步是把命名空间绑定到特定节点池。这一步很容易被忽略——如果你只建了IPPool但业务Pod依然可以通过调度器落在任意节点上CNI分配的时候就会去找该节点匹配的IPPool结果发现池子对不上Pod还是拿不到正确网段的IP。所以命名空间必须同时约束调度目标apiVersion: v1 kind: Namespace metadata: name: online-payment labels: pool.kubernetes.io/name: general然后再给每个业务Deployment加上对应节点池的nodeSelector或者nodeAffinity。这一步完成之后业务跑在哪个池子和IP从哪个池子分就完全对应上了。3.3 为什么这个方案值得做故障域隔离带来的从容改完之后回头看这次改造最值钱的地方不是IP变多了而是故障域变小了。以前IP耗尽的时候是全集群一起遭殃所有业务线、所有命名空间一起Pending现在如果某个池子分配率到90%我只要通知对应业务线调整扩容节奏就好了其他池子的Pod完全不受影响。从容量规划角度也更清晰了。以后每个业务线要扩容我需要问的问题从集群总IP还剩多少变成了你这个池子还剩多少和你的业务峰值匹配吗。这个转变在产品化运维、多租户交付场景下尤其重要它让容量这个东西从全局模糊变成了局部可度量、可追溯。同时我也在表格里简单对比过几个候选方案最后选分池不是偶然候选方案改动范围故障隔离效果长期容量弹性是否采用默认池整体扩大CIDR整个集群网络重新规划刷路由无治标不治本否每个节点增大子网所有节点CNI配置弱单节点密度的上限成为新瓶颈否Calico IPPool分池节点池绑定仅新增池子默认池保持不变强可按池独立扩容是4. 从旧池到新池存量Pod迁移的节奏与踩坑4.1 先确认新池生效再谈迁移改造方案敲定后最怕的就是一把梭直接在全部节点上改标签。我当时的操作分了两步走。第一步先加了一批全新的节点打上新标签启动后确认它们的Calico agent已经能识别对应的IPPool。我在这批新节点上起了几个测试Pod用kubectl get pod -o wide检查Pod IP确认拿到的是10.91.x.x这个网段的地址。这一步快慢不重要重要的是验证整条链路是通的调度器把Pod调度到新池节点CNI从匹配的IPPool里成功分配IP。第二步才考虑迁移存量Pod。老的Pod还在旧节点上它们的IP是旧池子里的不会因为标签变化而自动变。迁移的办法是驱逐节点让Pod重新调度到新池子。这里有个节奏问题不能一次把所有旧节点全驱逐因为新池的容量是逐步接管的。4.2 滚动驱逐时踩到的三个坑迁移过程中我吃了不少亏挑三个最典型的说。第一个坑是驱逐顺序没想清楚。一开始我按机器名倒序驱逐结果先驱逐了一个跑核心实时交易的节点瞬间触发了大规模Pod重建。新池的容量虽然够但大量Pod同时创建Calico的IPAM在同一时间窗口内要处理几万个分配请求响应延迟明显上升。后来改成先离线任务、再一般业务、最后核心交易的顺序并且每次最多同时驱逐3个节点等上一批Pod全部Running、IP分配完全稳定再动下一批。第二个坑是PDBPodDisruptionBudget卡住了驱逐。有些核心服务的PDB配置成maxUnavailable: 0这本意是保护业务可用性但在滚动迁移时反而把驱逐卡死了。驱逐命令kubectl drain会一直等待PDB允许的Pod数量变化如果PDB不允许任何Pod离线drain就永远进行不下去。后来我梳理了所有PDB把核心服务的maxUnavailable调整成了1迁移速度才恢复正常。第三个坑是IP回收的假象。驱逐完一批节点后我看旧池的已分配地址数并没有立刻下降多少反而是一会儿下去、一会儿上来。后来查清楚这是IPAM里还有一种待回收状态的地址在TTL窗口内尚未归还。不要一看到分配的IP数量没降就觉得有问题给它一些时间。实际经验是等5到10分钟再通过calicoctl ipam show对比前后两次的输出趋势下降就说明回收是正常的。4.3 用一段巡检脚本盯住迁移进度因为迁移窗口要持续挺长时间我写了个简单的巡检脚本每天定时把各个池子的分配率、各业务线的Pod数量变化推送到工作群。脚本逻辑不复杂就是把calicoctl ipam show的结果解析一下加上kube_pod_info的统计再通过企业微信机器人推出来。这里的关键不是脚本本身而是每天都能看到分配率的变化趋势。当连续三天所有池子的分配率涨幅都低于5%我才认为这次迁移真正完成了。5. 扩容后的容量规划与巡检指标5.1 怎么判断一个池子划多大按业务峰值而不是按当前用量池子规划完成后紧接着的问题就是每个池子给多大CIDR才够用。这个问题如果等满了再考虑就晚了应该在迁移的时候就想清楚。我的办法是按峰值Pod数量×冗余系数来反推网段大小。举个例子某个池子要承载的业务线历史峰值Pod数量是4000个考虑到发布高峰会额外多出20%的副本再考虑未来半年业务增长30%我就按4000×1.2×1.3≈6240来规划。分配地址时一个/19网段有8190个可用地址但我不想撑得太满实际选择/18那是16380个可用地址冗余非常充足。这里还要考虑每个节点有没有额外的地址消耗。每个节点上至少会有几个DaemonSet Pod如果这个池子有500个节点光DaemonSet就要吃掉一两千个地址。所以我在规划时是预留节点数×15个地址的余量。5.2 三个监控指标与告警阈值避免再次半夜被叫醒经历过这次凌晨告警之后我把IPAM相关监控彻底补上了。现在最关注的三个指标分别对应网络层的三个风险点。单节点Pod数量是最容易突增的指标对应的是节点子网容量问题。我用Prometheus的kube_pod_info聚合出每个节点的Pod总数告警阈值设在节点最大Pod数的80%。如果用的是Flannel这个指标非常关键。IPPool分配率是这次事故的元凶指标。Calico官方没有直接暴露这个指标我的做法是把calicoctl ipam show的输出定时推到Prometheus或者直接落库。告警阈值我设置在池子容量的80%因为一旦到90%以上发布高峰稍微冲一下就到100%了。早期发现、提前扩容比满了再救火舒服得多。最后是IP回收效率。我会统计连续5分钟内有大量Pod删除的场景下IPAM里待回收地址数是否超过阈值。如果回收慢可能是API Server压力大也可能是CNI插件版本有已知的回收问题。这个指标平时用不上但它能帮你在发布洪峰的时候提前预警瞬时IP不足。5.3 我个人的后续扩展思路这次Node Pool分层改造完成之后我还在观察几个扩展方向。第一是IP池的动态缩扩容脚本目前池子的CIDR还是静态的未来业务线如果出现剧烈波动我希望有一套自动化的池子扩容请求→人工审批→自动拾取空闲地址段流程。第二是在应用层规范 requests 和 limits让Pod密度更加可预期——很多节点的IP耗尽实际上是内存资源大量空闲、Pod数量却被规格撑爆造成的。第三是把这套分层能力标准化成一套网络规划模板以后每次新集群上线不用再从零开始设计直接套用池子划分、标签规范和容量计算公式。这次改造给我印象最深的一点是Kubernetes集群的资源规划不能只盯着CPU、内存IP地址一样是会被耗尽的稀缺资源。它不像CPU那样可以超卖也没有内存那样的回收机制一旦规划错了改动的代价只会越来越大。在集群还在小规模的时候就把Node Pool和IP池的分层关系定下来比出了问题再救火要便宜得多。如果读这篇文章的你也遇到过类似的IP不足问题不妨先把集群的池子结构和IPAM分配率统计一遍再决定是不是要往分层的方向走。