1. 现场还原:节点明明有空余,TaskManager 却一直 Pending
1.1 一个典型的“灵异事件”
前几天组里的同事火急火燎找我,说 Flink 作业提交到 Kubernetes 集群后,JobManager 倒是起来了,TaskManager 的 Pod 却一直卡在 Pending。他百思不得其解,因为他执行kubectl describe node时,明明看到节点还剩不少资源。
为了还原当时的情况,我模拟了他的检查动作。节点信息先看 Allocatable:
kubectl describe node <node-name>输出里有一段:
Allocatable: cpu: 8000m memory: 16384Mi ephemeral-storage: 102400Mi再往下翻到 Allocated resources 区域:
Allocated resources: Resource Requests Limits -------- -------- -------- cpu 7000m 12000m memory 15360Mi 20960Mi ephemeral-storage 0 08000m 减去 7000m,正好剩 1000m,也就是一个 CPU 核心。再看 TaskManager 的 YAML,resources.requests.cpu正好是 1000m。CPU 尺寸完全对得上,可 Pod 就是起不来。同事说这简直是“灵异事件”,怀疑是不是调度器坏了。
这里先给结论:调度器大概率没坏。CPU 数字对得上,不代表这个 Pod 能被调度上去。因为 Kubernetes 调度器决定一个 Pod 去哪里,从来不是只看 CPU 一项,而是要同时核对一堆资源和约束条件。这个“剩余 1 个 CPU”的假象,恰恰是最经典的 Kubernetes 资源调度陷阱。
1.2 第一件事:别盯数字,先看事件
我让同事先别纠缠 describe node 的数值,直接看 Pending Pod 的事件。执行:
kubectl describe pod <taskmanager-pod> -n <namespace>Events 里果然写着:
Warning FailedScheduling 13s default-scheduler 0/5 nodes are available: 1 Insufficient memory, 2 node(s) had untolerated taint, 2 Insufficient cpu关键信息就一句:Insufficient memory。节点 CPU 还剩 1000m,但内存的剩余可调度额度已经不足以再放下一个 TaskManager 了。Flink TaskManager 的 Pod 请求里除了 1 个 CPU,还有几 GiB 的内存。CPU 够、内存不够,调度器照样拒绝,并且会在事件里告诉你具体原因。
提示:
kubectl describe node里的“剩余资源”,本质上是 Allocatable 减去所有非终止 Pod 的 Requests 总和。它只是调度器判断的一个输入项。真正做调度决策时,CPU、内存、临时存储、GPU、端口、污点、亲和性、QoS、配额……每一样都必须通过,任何一项不满足,整个 Pod 就会被卡住。
从这个小故事展开,这篇文章把背后的原理、排查动作和处理方案完整讲一遍。以后你再遇到“明明有资源,Pod 就是起不来”,大概率能一次定位到位。
2. 调度器的准入门槛:它从来没承诺过“有 CPU 就能跑”
2.1 “剩余1个CPU”是怎么算出来的?
要理解这个陷阱,先要把 Kubernetes 里的资源模型说清楚。调度器眼中,节点资源不是看top或者free -h的实时使用率,而是看三个数字:
- Capacity:节点硬件的总量,比如
cpu: 8000m、memory: 16384Mi。 - Allocatable:Capacity 减去 kubelet 为系统组件、内核驱逐阈值预留的部分,这才是调度器真正能分配的上限。
- Allocated:已经绑定到该节点的所有非终止 Pod 的 Requests 总和。
“还剩 1 个 CPU”的真实含义是:还有 1000m 的 CPU Request 额度可以继续预订,和你节点现在 CPU 利用率是 5% 还是 95% 没有任何直接关系。哪怕节点实际空闲得很,只要 Requests 总和已经顶到 Allocatable,新 Pod 就进不来。反过来,哪怕节点 CPU 已经跑满,只要 Requests 没有超额,调度器照样可能把新 Pod 放进来——至于运行时会不会卡顿,那是另一层问题。
这个设计本身是为了让 Guaranteed 等 QoS 级别的 Pod 获得确定的资源保障,但副作用就是非常容易让人产生错觉:只盯着 CPU 剩余量,issues 就来了。我见过太多排障的人第一反应是“节点不是还剩 1 个核吗”,却忽略了真正卡住的往往是内存、临时存储或者别的隐性约束。
2.2 Flink TaskManager 的资源画像:CPU 只是单项
大部分生产环境的 Flink TaskManager Pod 长这样:
resources: requests: cpu: 1000m memory: 4Gi limits: cpu: 1000m memory: 4Gicpu: 1000m确实只有一个核,但memory: 4Gi也是硬性请求。如果那个节点剩余可调度内存只剩 1Gi 左右,调度器会直接因为Insufficient memory把 Pod 拒掉,哪怕 CPU 还剩 2 个核都无济于事。开头那个案例,问题恰恰出在内存上:Allocatable 内存 16384Mi,已分配 15360Mi,剩余 1024Mi,TaskManager 要 4Gi,放不下。
更麻烦的是,Flink TaskManager 的内存请求往往比你以为的更大:
taskmanager.memory.process.size是 Flink 进程的完整内存预算,它包含了 JVM Heap、Managed Memory、网络缓冲、JVM Overhead 等一大串组成。- 在 Kubernetes 原生集成中,Pod 的内存 Requests 和 Limits 通常会直接按照
process.size来设置。 - 而实际运行时,JVM 的 Metaspace、线程栈、直接内存、容器页缓存都可能超出理论预算。生产环境如果不对 Flink 内存做冗余,节点内存很容易在不知不觉中被吃满。
Flink 这种负载和在线 Web 服务完全不同,它是吃内存大户。不要用“CPU 剩 1 个就能跑”这种直觉去判断它。
2.3 看不见的扣减项:系统预留、Pod Overhead 与 Sidecar
除了直观的 Requests 汇总,还有几个“隐形扣减”会悄悄吃掉节点配额,越大的集群越常见。
第一是 kubelet 预留。不少集群在 kubelet 启动参数里配置了--system-reserved和--kube-reserved,分别给操作系统和 Kubernetes 系统组件预留 CPU/内存。这些预留会直接从 Capacity 里扣除,反映到 Allocatable 上。小规格节点尤其吃亏:一个 2C4G 的节点,扣完系统预留、驱逐阈值之后,Allocatable 可能只剩 1800m/3700Mi,比你想象中少一大截。
第二是 Pod Overhead 和副容器。如果集群启用了 RuntimeClass 并且 Pod 指定了对应 RuntimeClass,RuntimeClass 里定义的overhead会自动累加到 Pod 的资源请求上,你写 YAML 时看不见,但它确实占额度。除此之外,平台如果给 Pod 注入了日志采集、监控采集等 Sidecar 容器,每个副容器的 Requests 都要单独计入。Flink TaskManager 要是被套了两三个 Sidecar,实际资源占用会明显超出主容器那部分请求。
第三是系统级 DaemonSet。网络插件、节点监控、日志采集这些 DaemonSet Pod 在每个节点上各有一份,全都算进 Allocated resources。所以一个 16G 内存的节点,真正留给业务的可能只有一半。很多人算完自己的业务 Pod 觉得“绰绰有余”,却忘了系统组件已经占了半壁江山。
2.4 节点压力状态会让“空余 CPU”失效
节点状态并不只有 Ready 和 NotReady 两种。kubectl get node看到STATUS是 Ready,不代表节点没有压力条件。用:
kubectl describe node <node-name> | grep -A 15 Conditions可以看到MemoryPressure、DiskPressure、PIDPressure这类 Condition。它们对调度行为的影响各不相同:
| 节点条件 | 调度器表现 | 受影响最多的 QoS |
|---|---|---|
| Ready=false 或 Unknown | 节点直接不参与调度 | 全部 |
| MemoryPressure | 拒绝没有内存请求的 BestEffort Pod;Burstable/Guaranteed 仍需满足资源余量 | BestEffort |
| DiskPressure | 拒绝所有新 Pod,直到压力恢复 | 全部 |
| PIDPressure | 拒绝非 Guaranteed QoS 的 Pod | BestEffort / Burstable |
| NetworkUnavailable | 网络不可用,不参与调度 | 全部 |
所以完全可能存在这种情况:节点 CPU 显示还剩 1 个核,但它正处在 DiskPressure 状态,调度器一条策略就把它排除在外了,根本轮不到你去比较 CPU 够不够。这类问题如果不看 Conditions,光盯着资源数字想破头也找不到原因。
2.5 命名空间配额与 LimitRange:你以为集群有,Namespace 却没有
最后一个高频坑:集群整体还有资源,但你的 Namespace 已经没配额了。
ResourceQuota限制某个 Namespace 下所有 Pod 的资源请求总和。配额一旦耗尽,新的 Pod 在调度前就会被 API Server 或调度器拦住,事件里多半是exceeded quota。LimitRange更隐蔽。如果你的 Pod 没有显式声明资源请求,LimitRange 会自动补一个默认值上去。比如你只给 TaskManager 写了cpu: 1000m,没写内存,而 Namespace 里的 LimitRange 设置了defaultRequest.memory: 2Gi,那调度器实际看到的请求就是cpu: 1000m, memory: 2Gi。你完全不知道自己多背了 2G 内存,直到发现 Pod Pending。
因此排障时别忘了查:
kubectl get resourcequota -n <namespace> kubectl get limitrange -n <namespace>很多“资源充足却调度失败”的案例,最后都栽在配额和默认值这两个不显眼的机制上。
3. 实操排查:把“资源够不够”这件事彻底算清楚
3.1 从 Pod 事件读出根因,而不是猜
遇到 Pending,我强烈建议不要凭感觉猜,第一步永远固定为:
kubectl describe pod <pod-name> -n <namespace>看 Events 里的 FailedScheduling 消息。调度器会把失败的节点和原因全部列出来,比如:
0/5 nodes are available: 1 Insufficient memory, 1 node(s) had untolerated taint, 2 Insufficient cpu, 1 node(s) didn't match node affinity.根据关键词就能分流向:Insufficient 系列看资源分配;taint 系列去看污点与容忍;affinity 系列去看节点亲和配置;剩下那些是配额或条件问题。九成以上的场景,这一步已经能定位根因,下一步只需要围绕事件里的关键词往下钻。
如果事件信息被截断,或者你想看到更细的过滤日志,可以临时调高默认调度器的日志级别。默认调度器 Pod 在kube-system命名空间下,名字通常类似kube-scheduler-<节点名>,用kubectl logs查看。生产环境不建议长期开高日志级别,只在排障窗口内临时开启,排完恢复。
3.2 把“可调度资源”一行一行算清楚
想真正搞清楚某个节点还能不能放下新 Pod,建议别只看 describe 的汇总,自己亲手验算一遍。核心公式只有一个:
可调度余量 = Allocatable - Σ(节点上所有非终止 Pod 的 Requests)
先把节点和 Allocatable 拉出来:
kubectl get node -o custom-columns='NAME:.metadata.name,CPU:.status.allocatable.cpu,MEMORY:.status.allocatable.memory,EPHEMERAL_STORAGE:.status.allocatable.ephemeral-storage'再看kubectl describe node中 Allocated resources 区域的 Requests 列。两个数字相减,就是这台节点目前还能接多少新 Pod。这个计算过程最好固化成脚本,或者直接用下面的监控指标做展示:
kube_node_status_allocatable表示节点可调度总量。kube_pod_container_resource_requests表示每个容器的资源请求。- 两个指标按节点聚合相减,就能得到实时、准确的“剩余可调度资源”。
我自己的习惯是在 Grafana 里建一张“节点装箱率”面板,横轴是节点,纵轴按资源类型展示 Requests 总额占 Allocatable 的百分比。这样不用等事故出现才手动验算,平时就能直观看到哪个节点快要打满了。
3.3 核查这个节点上到底是谁占了内存
如果事件已经明确是Insufficient memory,下一步要搞清楚内存被谁占了。先看这台节点上到底有哪些 Pod:
kubectl get pod -A --field-selector spec.nodeName=<node-name>然后按 Pod 把 Requests 汇总。这一步最容易忽略的是 DaemonSet 组件:每个节点都有一份网络插件、日志采集、监控 Agent,加起来往往占掉几 GiB。kubectl describe node的 Allocated resources 其实已经把它们的请求算进去了,所以不要自己在脑子里“减去业务 Pod”,直接相信 describe 的汇总数,再叠加 Pod Overhead、LimitRange 默认值这些隐性项目,就能得到真实结论。
顺便说一句:如果你发现某个节点上有一堆Terminating状态的 Pod 迟迟不消失,它们也是会被计入已分配资源的。这种资源“假占用”会导致明明实际可用内存不少,但调度器就是觉得不够。优先清理掉被卡住的 Terminating Pod,再回来看资源余量。
3.4 检查节点状态、配额与污点
当事件里不是单纯的资源不足,或者明明资源充足却依然调度失败时,按顺序检查下面三组信息:
# 节点条件与状态 kubectl describe node <node-name> | grep -A 12 Conditions # 节点污点 kubectl get node <node-name> -o jsonpath='{.spec.taints}' # Namespace 配额与默认值 kubectl get resourcequota,limitrange -n <namespace>污点和容忍是个大坑。比如节点上打了dedicated=flink:NoSchedule,而你的 TaskManager 没有对应的容忍,那么这些节点对 Pod 来说就是“不可见”的。哪怕它们显示剩余 10 个 CPU,也跟你没关系。事件里会出现node(s) had untolerated taint,所以只要养成先看事件的习惯,这条一般不会漏。
3.5 别忘了调度器缓存与状态的滞后
调度器内部维护一份集群状态缓存,正常情况下它跟 API Server 的同步非常快,但某些场景会出现短时间“假性不足”:
- Pod 正在 Terminating,但还没完全清理,其 Requests 仍被计入已分配资源。
- 一批 Pod 同时创建时,调度器一轮轮抢占配额,同一个节点在很短时间内被分配了多个 Pod,后面的 Pod 看到的就是资源不足。
- 使用了自定义调度器或扩展调度器插件,缓存更新逻辑存在额外延迟。
这种现象通常持续几秒到几十秒,等待重试即可恢复。但如果集群里经常出现“资源明明有余量,却卡住好几分钟”的情况,就得重点排查是否有大量 Terminating Pod 堆积,或者某个组件把 namespace 的 quota 占满了不释放。
4. 对症下药:不同根因的处理方式与容量规划
4.1 高频原因与处理对照表
把“剩余 1 个 CPU 却无法启动 Flink TaskManager”这类问题的高频原因集中整理一下,方便直接对照:
| 表面现象 | 实际根因 | 处理方式 |
|---|---|---|
| CPU 有余,事件显示 Insufficient memory | 节点剩余可调度内存不足 | 扩容节点、调整 Flink 内存参数、合并或迁移部分 Pod |
| CPU 有余,事件显示 Insufficient cpu | 你看的是使用率或旧状态,实际 Requests 已超额 | 用 Allocatable 减 Requests 重新计算;检查 Terminating Pod |
| 资源明明够,事件显示 exceeded quota | 命名空间 ResourceQuota 耗尽 | 检查 quota,调整限额或拆分命名空间 |
| Pod 没写内存请求,却仍提示内存不足 | LimitRange 自动注入默认请求 | 查看 LimitRange,显式声明资源请求 |
| 节点状态存在压力 Conditions | 磁盘、内存或 PID 压力导致拒绝 | 处理节点压力,或把工作负载移到健康节点 |
| 事件显示 untolerated taint | 节点污点与 Pod 容忍不匹配 | 为 Pod 添加容忍,或调整节点污点 |
| CPU 显示有核,但实际需要单 NUMA 节点连续核 | CPU Manager static + TopologyManager 限制 | 调整 CPU Manager 策略、申请整数独占核 |
| 调度器缓存滞后、Terminating Pod 堆积 | 删除中的 Pod 仍占用 Requests | 等待回收;排查被 finalizer 卡住的 Pod |
4.2 Flink TaskManager 侧的资源参数建议
针对 Flink 场景,我通常在flink-conf.yaml里做这样的基础配置:
kubernetes.taskmanager.cpu: 1.0 taskmanager.memory.process.size: 8g taskmanager.memory.jvm-overhead.fraction: 0.15 taskmanager.memory.managed.fraction: 0.4 taskmanager.memory.framework.off-heap.size: 64m taskmanager.memory.task.off-heap.size: 128m几个关键点:
kubernetes.taskmanager.cpu会同时作为 CPU 的 Requests 和 Limits。如果不设置,TaskManager 的 CPU 请求就是 0,调度器可能把它放到超卖严重的节点上,运行质量完全没有保障。生产环境务必显式设置。taskmanager.memory.process.size决定 Pod 的内存请求。它本身已经包含 JVM Overhead,但 JVM 在实际运行中的 Metaspace、线程栈、Direct Memory 波动仍可能超过预算。我建议在 Pod 的资源请求里再留 5% 到 10% 的 buffer,别把requests.memory顶着process.size写得严丝合缝。- 单个 TaskManager 的内存不要开太大。把大内存拆成多个较小内存的副本,调度时更容易找到“刚好放得下”的节点,也能降低资源碎片化的影响。
- 同步调整 Flink 作业的并行度与副本数。并行度翻倍但单 TM 缩一半,整体调度成功率通常更高,故障域也更小。
4.3 集群容量规划:别把装箱玩成钢丝绳
修完眼前的故障,更值得做的事是从容量规划上堵住同类问题。这里的建议就三条,但都很实在。
给节点留 headroom。不要让所有 Pod 的 Requests 总和逼近 Allocatable 的 100%。我一般建议 CPU 装箱率控制在 70% 到 80%,内存控制在 80% 到 85%。多出来的空间用来应对突发的 Pod 扩缩容、节点故障后的重调度,以及 Flink 这类作业执行过程中的内存波动。
区分资源池。Flink 是内存敏感型负载,在线 Web 服务通常是 CPU 敏感型。两者混在同一个节点池里,很容易互相“挤兑”。把 Flink 作业放到独立的 NodePool,并设置对应的节点污点和容忍,既能让调度更可控,排障时也能减少变量。
配置优先级类和自动扩缩容。给核心 Flink 作业设置更高的 PriorityClass,资源不足时低优先级 Pod 会被优先驱逐,保护关键作业。同时如果集群支持自动扩缩容,把 Pending 状态作为扩容触发条件,让节点扩容来兜底,而不是等人发现节点满了再手动加机器。
5. 避坑与心得:调度问题排查的黄金顺序
5.1 我的固定排查动作
踩过太多次坑之后,我现在遇到“Pod 起不来”都用一套固定流程,基本不会跑偏:
- 执行
kubectl describe pod查看 FailedScheduling 事件,确定被拒原因的关键词。 - 根据关键词分流:Insufficient 就查节点资源分布,taint 就查污点与容忍,quota 就查配额,pressure 就查节点 Conditions。
- 用 Allocatable 减去所有已分配 Pod 的 Requests,亲手确认“到底够不够”。
- 检查是否存在 Terminating Pod 堆挤占配额。
- 排除系统 DaemonSet、kubelet 预留、Pod Overhead 和 LimitRange 默认值这些隐性扣减。
- 最后才考虑调度器缓存或自定义调度扩展带来的暂时性异常。
这套流程走下来,绝大多数调度问题能在几分钟内定位。核心思路只有一条:让事件和数字说话,而不是凭直觉猜测。
5.2 “剩余1个CPU”本质上是个伪命题
回到标题里的问题:为什么剩余 1 个 CPU 却无法启动 Flink TaskManager?因为节点调度从来不是单资源判断题。调度器在过滤阶段要同时核对 CPU、内存、临时存储、扩展资源、端口、污点、亲和性、QoS、配额……任何一项不满足都会被拒绝。只盯着 CPU 数字,就像只看一个人的体检单上某一项指标就判断他是否健康一样,参考价值非常有限。
我见过的绝大多数此类“灵异事件”,最后都落在三个常见根因里:内存配额耗尽、节点压力状态、命名空间配额或默认值注入。三者都有非常明确的事件线索可以定位。
5.3 最后分享一个小习惯
我平时会在每个生产集群的监控面板固定放三个指标:节点可调度 CPU/内存余量、按 Pod 聚合的资源请求 Top 10、调度失败事件趋势。有了这三块看板,任何“资源够却起不来”的问题都能在第一时间被看见,而不是等业务方慌慌张张跑过来才发现。毕竟调度器是集群里最讲道理的那个角色,它拒绝 Pod 时一定会给出理由。找不到原因的时候,通常是我们自己的账没算对——把资源账算平,问题自然就浮出水面了。