生产环境跑了一段时间 Kubernetes 之后,你会发现节点资源调度这件事,光靠按标签分组和亲和性根本不够。比如你想把某几个节点专门留给数据库,或者让监控组件必须跑到所有节点上,再或者集群里有几台机器硬件老化需要标记出来别让新业务落上去——这种"我要拒绝某些Pod来"的需求,标签选择器做不到,亲和性也做不到。这个时候你能用的就是污点(Taints)和容忍度(Tolerations)。
Kubernetes 里这套机制的核心思路特别简单:给节点打上污点,就像在门上挂了一块"某些人请勿入内"的牌子;给 Pod 配置容忍度,就像是给特定的人发了一张"特别通行证"。有通行证的 Pod 可以无视牌子往里走,没有通行证的 Pod 会被调度器自动安排到其他节点。这套机制从设计之初就是为了解决"节点与 Pod 之间的排斥关系",和亲和性正好是一对互补的工具。这篇文章我会把污点和容忍度的原理、语法、实操以及我在排障中踩过的坑一次讲清楚,适合刚接触到调度策略的运维和开发,也适合已经在用但有些细节没搞明白的人。
1. 污点和容忍度到底解决什么问题
1.1 从一次误调度事故说起
先讲一个我实际遇到过的事情。之前管理的一个集群里,有两台机器是专门的 GPU 节点,给训练任务用的。有一天新上线了一个 Web 服务,它既没要求 GPU,也没写任何调度相关的配置。结果 Pod 一起,调度器顺手就把其中两个副本放到了 GPU 节点上。训练任务跑起来需要占满显存,Web 服务在 CPU 和内存上也不算省心,两边挤在一起,训练任务直接 OOM,Web 服务也出现大量超时。
当时整个集群没有做节点资源隔离,因为节点数不多,大家觉得没必要。但这个问题暴露了一个事实:调度器只关心节点是否满足 Pod 的资源需求,它并不知道哪些节点是"专用的",哪些节点是"共享的"。我缺一个表达"这节点不欢迎你"的机制。
后来我做了两个动作:给 GPU 节点打上污点gpu=true:NoSchedule,给训练任务的 Deployment 加上对应的容忍度。从此调度器再也不把普通业务放到 GPU 节点上,问题彻底消失。
这就是污点与容忍度最核心的价值:节点通过污点表达偏好,Pod 通过容忍度表达身份,两边各管各的,调度器按规则执行。对比一下另外两个常用工具就能看得更清楚。
1.2 语义对比:节点选择器和亲和性差在哪
Kubernetes 里控制 Pod 落在哪个节点上,主要有三套机制:nodeSelector、nodeAffinity、污点和容忍度。很多人刚接触时会混淆,觉得它们都是用来"挑节点"的,其实它们的方向完全不同。
nodeSelector和nodeAffinity表达的是Pod 对节点的需求:"我要跑在带disk=ssd标签的节点上"。这是从 Pod 视角出发的正向选择。如果没有任何节点满足条件,Pod 就一直 Pending 在那里等着。
污点表达的是节点对 Pod 的拒绝:"我不接受不带通行证的 Pod 进来"。这是从节点视角出发的反向选择。这种语义有一个天然优势:即使你集群里后面新加了节点,只要新节点没打污点,就不影响任何 Pod 的调度;如果新节点打了污点,同样能自动屏蔽掉不相关的业务。维护成本比逐个改nodeSelector低很多。
你完全可以把一套机制组合起来用:用nodeAffinity表达"我倾向于去某类节点",用污点表达"某些节点绝对不能放普通业务",两者互不冲突。实际上生产环境里很多团队就是同时用这两种方式做双保险的——首先通过亲和性把特定 Pod 引导到特定节点池,再用污点把其他不该来的 Pod 挡在外面。
打个比方:亲和性是"我自己主动想去的方向",污点是"门口保安不允许我进"。你出门前既要知道自己想去哪,也要知道哪些地方没有特别通行证就进不去。两件事都不冲突,一起考虑才完整。
2. 核心概念与语法:把定义吃透
2.1 污点的三个组成要素与四种 effect
一个污点(Taint)本质上就是一条由key、value、effect三个字段组成的信息,挂在节点上。key和value好理解,就是键值对,通常用来描述污点的类型或来源。effect才是真正决定调度行为的字段,它一共有四种取值,其中三种常用,一种是 Alpha 特性。
| effect 值 | 调度层面的影响 | 对已有 Pod 的影响 | 典型应用场景 |
|---|---|---|---|
NoSchedule | 新 Pod 如果没有匹配的容忍度,不会被调度到该节点 | 节点上已运行的 Pod 不受影响,继续运行 | 把某个节点从调度池里"摘出去",用于维护、隔离、专用节点 |
PreferNoSchedule | 调度器会尽量避免把不匹配的 Pod 放到该节点,但不保证绝对不调度 | 已运行的 Pod 不受影响 | 软性规避,比如某节点性能较差,希望尽量少跑业务 |
NoExecute | 新 Pod 若无匹配容忍度,不会被调度到该节点 | 节点上已运行的、无匹配容忍度的 Pod 会被驱逐 | 节点故障、安全隔离、立即清理该节点上的 Pod |
NoExecute的扩展字段tolerationSeconds(实际属于容忍度一侧) | 如果 Pod 有容忍度且指定了tolerationSeconds,则到期后仍被驱逐 | 控制驱逐的宽限期 | 节点维护前给 Pod 一个优雅退出窗口 |
第一种NoSchedule是最常用的,打上去之后只是不让新 Pod 进来,已经在那跑的不受影响。第二种PreferNoSchedule是软性的,调度器会尝试避开该节点,但如果确实没有更合适的节点,它还是会调过去。第三种NoExecute是最狠的:它不仅拦新 Pod,还会把节点上所有没有对应容忍度的存量 Pod 全部驱逐掉。我一般只在节点磁盘故障、内存持续飙高这类真需要清场的情况下才用。
kubeadm 初始化的集群里,master 节点上默认就带了一个node-role.kubernetes.io/master:NoSchedule(新版本是node-role.kubernetes.io/control-plane:NoSchedule),目的就是防止普通业务 Pod 被调度到控制平面节点上。这个默认行为如果你不知道,直接用kubeadm init创建的集群,你会看到业务 Pod 怎么都调度不到 master 上——不是节点有问题,而是门卫不让进。
2.2 容忍度语法:operator 与书写规则
容忍度(Toleration)配置在 Pod 的spec.tolerations字段里,语法核心是"我要匹配节点上的哪条污点"。它有两种写法,对应operator字段的两种取值。
第一种写法是operator: Equal,表示精确匹配,需要key、value、effect三个字段全部和节点上的污点一致,才能算匹配上。这也是最容易理解的写法。
tolerations: - key: "gpu" operator: "Equal" value: "true" effect: "NoSchedule"上面这段表达的意思是:这个 Pod 容忍节点上形如gpu=true:NoSchedule的污点。注意,如果节点上挂着的是gpu=false:NoSchedule,那这条容忍度是不起作用的,因为 value 不匹配。
第二种写法是operator: Exists,表示只要 key 存在即可,value 无所谓,effect 也可以不写。不写 effect 时,它匹配该 key 下的所有 effect 类型。
tolerations: - key: "gpu" operator: "Exists"这行的意思是:只要节点上存在任意一条以gpu为 key 的污点,不管 value 是什么、effect 是什么,这个 Pod 都能容忍。
还有一种比较特殊的情况:如果你写operator: Exists且不写key,那这条容忍度会匹配节点上的所有污点。这是一种"我谁都能忍"的无敌配置,通常在系统组件(比如网络插件 DaemonSet)里见得到,日常业务不建议这么写,否则等于没有门槛,任何节点都能把你调度上去。
两种 operator 的选择原则很简单:如果你想针对某一条具体污点做精准放行,用Equal;如果你只关心 key 不关心 value,或者想兼容各种 effect 类型,用Exists。
2.3 常见误区:匹配规则与容忍范围
这里有一个新手几乎必踩的误区:以为 Pod 要能调度到某节点,就必须在tolerations里把节点上的所有污点全部列出来。实际上完全不是这样。
Kubernetes 的匹配逻辑是"只要有一条容忍度能匹配上节点上的任意一条污点,这个 Pod 就可以调度到该节点"。更准确地说,调度器会逐个检查节点上的污点,检查 Pod 的容忍度列表里是否存在能匹配当前的污点的条目——只要存在匹配,就认为这个污点被容忍了。如果节点上所有污点都能被容忍度集合覆盖,就允许调度;如果有一条污点没有任何容忍度能匹配,就拒绝调度。
举个例子,节点上有两条污点:
dedicated=db:NoSchedule gpu=true:NoExecutePod 的容忍度里只写了:
tolerations: - key: "dedicated" operator: "Equal" value: "db" effect: "NoSchedule"这时候调度器检查dedicated=db:NoSchedule时发现匹配,可以容忍;检查gpu=true:NoExecute时找不到匹配的容忍度,于是判定这个 Pod 不能调度到该节点。因为你只需要匹配"每一条污点"即可,而不是匹配"所有污点"。
再有就是容忍度并不只对调度生效,NoExecute类型的污点还会触发驱逐机制。很多人只记住了它拦截新 Pod,忘了它还会清理存量 Pod,结果给节点打上NoExecute污点后整个节点上的业务瞬间清零,以为自己误操作了,其实这本来就是设计好的行为。
3. 实操全流程:从命令行到 YAML
3.1 打污点、查污点、去污点
在实际运维中,给节点打污点的操作比想象中频繁。我梳理一下最常用的几个命令,都是踩过坑之后沉淀下来的。
先查看节点现有的污点,用kubectl describe是最直观的:
kubectl describe node node1在输出里找Taints字段,如果节点没有污点,显示的是空;有污点的话会列出类似dedicated=db:NoSchedule这样的记录。有时节点多了之后,用describe一页页翻太慢,可以直接用 JSON 提取:
kubectl get node node1 -o jsonpath='{.spec.taints}'给节点打污点的标准格式是kubectl taint nodes <node-name> <key>=<value>:<effect>:
kubectl taint nodes node1 dedicated=db:NoSchedule这条命令执行后,node1 上就多了一条dedicated=db:NoSchedule污点。以后再创建的新 Pod,如果没写对应的容忍度,调度器就不会把 Pod 放上去。
如果要取消污点,在命令末尾加一个减号即可:
kubectl taint nodes node1 dedicated=db:NoSchedule-这个减号一定要写在最后,和 effect 之间没有空格。写错格式的话,kubectl 会报错提示。如果节点上有多条污点,想去掉某一条,必须把 key、value、effect 原样写上去再加减号。也有人只写 key 就去取消,比如:
kubectl taint nodes node1 dedicated-这个操作会移除所有以dedicated为 key 的污点,不管是哪种 effect。我觉得这个用法挺实用的,尤其是你想批量清掉某一类污点时,效率高很多。
还有一个小技巧:操作前最好先确认节点名称。很多生产环境里节点主机名不是node1这种好记的名字,而是类似ip-10-0-1-123这种长名称。用kubectl get nodes先看一眼,避免敲错导致提示找不到节点。我确实见过有人把节点名写错,然后开始排查为什么 Pod 不调度——其实污点根本没打上。
3.2 一节完整的容忍度 YAML 实例
光有污点没有容忍度,业务 Pod 就会被挡在外面。实际配置容忍度时,我习惯直接把整段 YAML 放在 Deployment 或 StatefulSet 的spec.template.spec下,下面给一个完整示例:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-gpu namespace: production spec: replicas: 3 selector: matchLabels: app: nginx-gpu template: metadata: labels: app: nginx-gpu spec: tolerations: - key: "gpu" operator: "Equal" value: "true" effect: "NoSchedule" - key: "gpu" operator: "Equal" value: "true" effect: "NoExecute" tolerationSeconds: 3600 containers: - name: nginx image: nginx:1.25 resources: requests: cpu: 500m memory: 512Mi limits: cpu: "1" memory: 1Gi这个配置有两段容忍度。第一段gpu=true:NoSchedule让它能够被调度到打了这个污点的 GPU 节点上。第二段gpu=true:NoExecute加了tolerationSeconds: 3600,意思是即使该节点上存在NoExecute污点,Pod 也能在节点上继续运行 3600 秒,之后如果污点还在,才被驱逐。
有一种情况需要重点说明:如果节点上同时存在NoSchedule和NoExecute两条污点,且 Pod 只写了NoSchedule的容忍度,那么新 Pod 无法调度过来,因为NoExecute那条污点没有被容忍。如果节点上只有NoExecute污点,Pod 没写对应容忍度,即使它已经在节点上运行,也会被立即驱逐。
在生产环境里,我经常把tolerationSeconds当作优雅退出的缓冲时间用。比如计划对某个节点做维护,提前给它打上maintenance=true:NoExecute污点,业务 Pod 如果带有tolerationSeconds: 300的容忍度,那么维护命令执行后,Pod 会在 300 秒内被平滑驱逐。这比直接用kubectl drain暴力驱逐要温和得多。
3.3 master 节点污点与业务部署回避
如果你用 kubeadm 初始化集群,默认情况下 master 节点上会带有node-role.kubernetes.io/master:NoSchedule或node-role.kubernetes.io/control-plane:NoSchedule污点。初始化日志里你会看到类似[init] using kubernetes version: v1.26.0、[preflight] running pre-flight checks的过程,等集群起来后,kubectl get nodes能看到 master 节点处于Ready状态,但普通业务 Pod 永远不会调度上去,就是这条污点在起作用。
网上很多教程会教你怎么把这个污点去掉,让业务 Pod 也能调度到 master 节点。我的建议是:除非你的集群实在太小、节点太少,否则千万别这么干。master 节点上跑着 etcd、kube-apiserver、controller-manager 这些核心组件,如果混入高负载业务 Pod,一旦资源被抢占,整个集群的稳定性都会受影响。
如果实在有某个组件必须跑在 master 节点上(比如有些严格的网络插件要求 master 上的 kube-proxy 必须调度成功),正确做法是给那个 Pod 加上对应的容忍度,而不是全局去掉污点。比如:
tolerations: - operator: "Exists"有人会写这种"容忍所有污点"的配置,仅仅是为了让一个 Pod 上 master。能用,但暴力,相当于把门卫的通行标准全部废掉。我自己的习惯是具体污点具体写,比如:
tolerations: - key: "node-role.kubernetes.io/control-plane" operator: "Exists" effect: "NoSchedule"这样这个 Pod 只对 control-plane 的污点放行,其他污点该卡还是卡,风险可控。
4. NoExecute 与驱逐:真正的高级用法
4.1 NoExecute 驱逐逻辑与 tolerationSeconds
NoExecute之所以特殊,是因为它同时影响"调度"和"驱逐"两层行为。节点上只要存在NoExecute污点,调度器就不会把没有对应容忍度的新 Pod 放过来;同时 kubelet 也会监视节点状态,把已经运行在该节点上的、且没有对应容忍度的 Pod 全部杀掉。
对于已经在节点上运行的 Pod,行为分成三种情况:
| Pod 情况 | kubelet 处理行为 |
|---|---|
| 没有匹配的容忍度 | 立即被驱逐 |
有匹配容忍度,但没有设置tolerationSeconds | 永久容忍,驱逐不触发 |
有匹配容忍度,且设置了tolerationSeconds | 容忍倒计时结束后被驱逐,到期时间从污点添加到节点那一刻开始计算 |
也就是说,tolerationSeconds给了你一个"临时豁免权"。我举个实际用法:某台节点需要紧急下线,但上面有数据库的 Pod,我希望给它两分钟的优雅关闭时间,让它能刷盘结束事务。我就可以这样做:
kubectl taint nodes node-db-shard-03 shutdown=true:NoExecute假设数据库 Pod 上带了:
tolerations: - key: "shutdown" operator: "Equal" value: "true" effect: "NoExecute" tolerationSeconds: 120那么从污点打上的那一瞬间起,这个 Pod 会在 120 秒后被强制驱逐。120 秒内,Pod 还在正常处理请求,应用层可以做优雅下线处理。等到 kubelet 开始驱逐时,数据已经保存完毕,风险大幅降低。
这里有个容易忽略的细节:如果节点上的NoExecute污点在两分钟之内被移除了,tolerationSeconds的倒计时也会被取消,Pod 继续正常运行。所以这相当于是给节点维护操作加上了一个"软时限",很灵活。
4.2 DaemonSet 的默认容忍与普适场景
DaemonSet 在 Kubernetes 里承担着每个节点都必须跑一个 Pod 的任务,比如kube-proxy、calico-node、fluentd这些。为了让它们在所有节点上都能运行,系统给 DaemonSet 内置了针对NoSchedule和NoExecute的无限容忍度。换句话说,就算你给某个节点打了NoSchedule污点,DaemonSet 的 Pod 依然会被调度上去;就算打了NoExecute污点,已在运行的 DaemonSet Pod 也不会被驱逐。
这个默认行为在大多数场景下是合理的:你总不希望网络插件因为节点上有污点就装不上去,导致这个节点网络不通。但也有例外,我之前在一个多租户集群里遇到过麻烦:用户数据面节点被打上了很严格的租户污点,为了避免计算节点被平台组件过多占用,我希望能把某些 DaemonSet 组件排除在特定节点之外。默认行为偏偏拦不住它。
这种情况下就得靠nodeSelector或亲和性来配合限制 DaemonSet 的调度范围。没有绝对的默认行为能适配所有场景,组合使用才能达到预期效果。
DaemonSet 默认容忍这条规则也提醒我们:当你在排查"为什么这个节点上的 DaemonSet Pod 还在"时,不要以为自己的污点没生效——先确认你查看的对象是不是 DaemonSet 控制的 Pod。
4.3 节点池隔离、告警检测等延伸用法
既然理解了污点的"拒绝"语义,就可以玩出一些比较高级的场景。第一个是节点池隔离。好多团队会按节点用途打不同的污点,比如:
dedicated=redis:NoSchedule只让 Redis 类业务调度dedicated=training:NoSchedule只让训练任务调度gpu=true:NoSchedule只让 GPU 任务调度
这样即便集群没有用复杂的节点池管理系统,也能在调度层面实现逻辑隔离。再加上对应的业务 Deployment 里写容忍度,就能非常精确地控制每个 Pod 的落点。
第二个场景是做"异常节点标记"。比如某节点的磁盘 IO 持续异常,但节点还没完全挂掉。我非常不建议直接kubectl delete node,这时候可以给它打个degraded=true:NoExecute污点,不用NoSchedule而是用带驱逐能力的NoExecute,存量业务马上被排走,保留节点本身供排查。等修复完成后再把污点去掉,节点重新进入调度池。
第三个场景是配合PriorityClass做抢占控制。重要系统组件(比如指标采集、DNS)可以同时设置高优先级和容忍度,普通业务不设置容忍度。一旦节点出现故障,调度器会优先保证这些高优先级组件的调度请求被满足,而普通 Pod 自然被挡在外面。这是一种低成本、见效快的保障手段。
5. 实战排障:Pod 为什么不调度
5.1 问题现象与分析路径
几乎每个用过污点的人都遇到过"Pod 一直 Pending"的问题。排查路径其实很固定,我总结了一套自己的方法。
第一步看事件。kubectl describe pod <pod-name>里Events部分会直接告诉你调度失败的原因。如果是污点导致,你会看到类似0/5 nodes are available: 5 node(s) had untolerated taint {dedicated: db}这样的提示。这个信息基本是终极定位,不用再瞎猜。
第二步看节点污点。kubectl describe node <node-name>拉到Taints字段,确认节点上的污点是什么。然后对比 Pod 的tolerations,看有没有漏写 key、value、effect 对不上的情况。
第三步检查容忍度语法。最常见的问题是operator: Equal情况下 value 写错。比如节点污点是gpu=true:NoSchedule,容忍度里写的是value: "True",大小写不一致,匹配失败。Kubernetes 对字符串值是严格匹配的,不是英语阅读理解,大小写任何差异都不放过。
第四步检查是否被其他机制影响。污点只是 Pending 原因之一,节点NotReady、CPU 内存资源不足、PVC 无法挂载,同样会造成 Pending。不要一看到 Pending 就说是污点问题,要结合事件信息一起判断。
5.2 一个从 Pending 到 Running 的排查实录
我在线上处理过这样一个案例,写出来分享一下完整过程。
同事反馈新上的服务一直 Pending。我看了一眼 Deployment,发现没有设置任何调度规则,按理说集群有几十个节点,不应该调度不上去。kubectl get pods -n production显示 Pod 处于Pending,再用kubectl describe pod查看事件,输出里有这样一行:
Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 30s default-scheduler 0/32 nodes are available: 12 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: }, 20 node(s) had resource cpu insufficient.这个信息非常有价值。它明确告诉我们 32 个节点里,12 个是 master 带 control-plane 污点,20 个是因为 CPU 资源不足。问题并不是污点没配好,而是业务 Pod 申请的资源太大,所有可用节点都装不下了。
我去查了这个服务的 resource requests,发现 CPU 请求写的是9000m,但整个工作节点每台只有 8 核。也就是说,一台节点最多只能塞进一个这样的 Pod,而且由于其他业务已经占了一部分资源,20 台节点全都剩不下 9 核的空闲。最后的解决方法是把 requests 降到4000m,Pod 立刻调度成功。
这次排障给我一个很重要的提醒:看到had untolerated taint时,不要立刻认为是污点的问题,也可能是多个原因叠加。处理原则是先按事件信息逐条排除,优先解决资源不足,再看污点匹配。
5.3 避坑清单与检查速查表
我把这些年遇到过的和污点相关的问题整理成一张速查表,希望对你有帮助。
| 现象 | 可能原因 | 排查命令 | 常用解法 |
|---|---|---|---|
| Pod 一直 Pending | 没有写容忍度,或 key/value 不匹配 | kubectl describe pod看事件 | 补容忍度,核对 key/value/effect |
| 节点上某 Pod 被突然驱逐 | 节点被打上NoExecute污点,且 Pod 无容忍度 | kubectl describe node看 Taints | 给 Pod 加容忍度;避免随意打NoExecute污点 |
| DaemonSet Pod 出现在所有节点 | DaemonSet 默认容忍全部污点 | kubectl get ds -A | 配合nodeSelector限制范围 |
| master 节点上出现业务 Pod | 有人手动加了容忍度或删了默认污点 | 查看节点 Taints | 删掉不必要的容忍度,恢复默认污点 |
| 去掉污点后 Pod 仍不调度 | 节点 NotReady 或资源不足 | kubectl describe node看 Conditions | 检查 kubelet 状态、资源水位 |
| 多个污点叠加导致调度失败 | Pod 只匹配了其中部分污点 | 列表对比节点污点和 Pod 容忍度 | 补齐每条污点对应的容忍度 |
还有一个比较隐蔽的坑:如果你给某个节点同时打了多段污点(比如NoSchedule和NoExecute并存),Pod 的容忍度列表需要能够覆盖所有污点才能调度。哪怕漏掉一条,事件里都会明确提示是哪条污点没被容忍。我曾经因为NoExecute后缀写错,导致一个本该被调度的 Pod 等了十几分钟,后来看事件才发现自己把tolerationSeconds放在了没用的字段上,kubectl 并没有帮你校验配置文件里的语义,只是简单跳过。
在 YAML 里写好容忍度后,可以用kubectl apply --dry-run=server -f deployment.yaml检查一遍 API 层面的校验。虽然它不会主动帮你检查业务逻辑,但至少能过滤掉拼写错误、缩进错误这类低级问题。
6. 写在最后的经验
个人体会,污点和容忍度是那种"一学会就觉得简单、但遇到问题才觉得水很深"的机制。它表面上只有两三个字段,实际上牵扯到调度、驱逐、节点生命周期管理、DaemonSet 行为等多个环节。我踩过的坑里,最典型的还是把NoSchedule和NoExecute混为一谈——以为只是"调不调得上去"的区别,忽略了驱逐这一层威力。
建议从小集群开始做练习:自己拿 kubeadm 起一个三节点集群,给 worker1 打gpu=true:NoSchedule,分别创建不带容忍度和带容忍度的 Deployment,观察它们的调度结果;再试一次打NoExecute,看存量 Pod 会发生什么。这套实验做下来,整个机制基本就刻在脑子里了。
最后分享一个小技巧:写污点 key 时尽量采用"域名前缀 + 描述性名称"的格式,比如dedicated.example.com/gpu。看到的人能一眼知道这个污点是谁定义、什么用途,排查问题的时候少很多沟通成本。毕竟 Kubernetes 集群是多人协作的环境,一个一清二楚的污点,比什么都强。