☰
容器资源限制与调度机制:从cgroups到Kubernetes的实战解析
2026/10/5 13:43:51 网站建设 项目流程

1. 先说清楚:容器资源限制到底在限制什么

做容器逃不过两个词:资源限制、调度。很多人把这两个词混在一起谈,觉得"给容器设了 CPU 和内存上限,调度器就能好好安排它了"。实际上这是两套完全独立的机制——资源限制解决的是"单机上这个进程能从内核要多少资源",调度解决的是"这一堆容器应该各自落在哪台机器上"。但它们又紧密咬合:没有资源限制,调度器算出来的"剩余资源"就是笔糊涂账;没有调度,资源限制只能保证单机不被打爆,保证不了全局的合理分配。我把这套东西彻底搞明白,是在一次线上事故之后。

那是一个深夜告警:一台物理机上跑了 12 个容器,其中一个是内部压测服务,代码里有个内存泄漏,跑了三天把内存吃到了 30GB。我当时想的是"反正是物理机 64GB,还能扛",就没给容器设内存上限。结果半夜另一个核心业务容器申请内存被内核 OOM Killer 盯上,直接被 kill 掉,流量切到备用节点,备用节点也跟着抖动,最后整条链路雪崩。复盘的时候大家说"加个内存限制就好了",但问题没有这么简单——限制加多少?虚拟机里跑容器和物理机跑容器,限制方式一样吗?容器内看到的可用内存和宿主机的剩余内存为什么总是对不上?调度器到底靠什么判断"这台机器还能不能再塞一个容器"?

如果你也是一路踩坑走过来的,这篇内容应该能帮你把这些概念串起来。我会把容器资源限制的底层机制、调度器的工作逻辑,以及两者结合时最容易踩的坑通通拆开讲,最后补上我实际排查事故的思路链。

先说基础概念。容器资源限制,本质上是通过 Linux 内核的cgroups 机制(Control Groups,控制组)实现的。它不是一个"虚拟机管理软件"给容器设的配置文件,而是内核本身提供的一种资源记账和隔离能力。每创建一个容器,容器运行时(比如 Docker 或 containerd)就会在宿主机上创建一组 cgroup 目录,然后把容器里所有进程的 PID 写进去,之后这个 cgroup 下所有进程消耗的 CPU、内存、磁盘 IO、网络带宽,都会被内核统一记账,并且可以被配额机制卡上限。

这里有个特别容易误解的点:限制(limit)不等于隔离(isolation)。命名空间(namespace)隔离的是"你能看到什么"——容器里的进程看不到宿主机的其他进程,文件系统、网络栈也都是独立的视图。而 cgroups 管的是"你能用多少"——即使你可以看到全部 CPU,你也只能用配额范围内的那部分。两者缺一不可:只有 namespace 没有 cgroups,容器就是一群能互相看见但不受约束的进程;只有 cgroups 没有 namespace,那只是普通的进程限速工具。Docker 之所以能提供"像虚拟机一样"的体验,正是因为这两套内核机制被同时用上了。

资源限制需要覆盖哪些维度?实际生产中,我至少会关注以下四项:

  • CPU:限制容器能占用的 CPU 核心数与时间片比例。决定了服务是能抢占整台物理机的所有核,还是只能老老实实待在配额内。
  • 内存:限制容器的物理内存使用量,这是最"硬"的限制,超了就会被 OOM Killer 杀掉。
  • 磁盘 IO:限制容器的读写带宽和 IOPS。被忽视的重灾区,尤其是日志量大的服务。
  • 进程数(PID)限制:防止某个容器 fork 出海量进程,把宿主机的 PID 空间耗尽。

磁盘空间也是常见限制维度(比如 Docker 的--storage-opt或 K8s 里的 emptyDir 大小限制),不过它更偏存储层面,和 cgroup 的记账逻辑不太一样。

我见过很多团队,上了 Kubernetes 之后只管给容器写个resources.limits,底层细节完全不看。结果一遇到 CPU 节流(throttling)问题就懵了:明明 Pod 的 CPU 用到了 limit 上限,但容器内的应用性能就是上不去,查监控还看不出什么异常。原因往往出在他们根本不理解 CPU 限制是靠"时间片配额"实现的,而不是靠"绑核"实现的。这一点我会在下一节详细展开。

2. CPU 与内存限制的底层机制和参数细抠

2.1 CPU 限制:配额时间片和绑核是两回事

先做一个随手实验。在一台 8 核的 Linux 机器上用 Docker 跑一个容器:

指令示例:

docker run --rm --cpus=1 -it ubuntu:22.04 bash

进入容器后我们执行nproc,输出很可能是 8,而不是 1。为什么?因为--cpus=1并没有让容器"看到"1 个 CPU——nproc读的是 CPU 亲和性掩码,而 Docker 默认不会给容器设置 CPU 亲和性(除非你用了--cpuset-cpus)。这个参数真正做的事,是通过 CPU 带宽(bandwidth)控制来限制容器内的进程最多只能使用1 个 CPU 核心等效的计算时间。

这里引入两个关键内核参数:

  • cpu.cfs_period_us:默认值 100000(微秒),也就是 100ms,表示一个调度周期。
  • cpu.cfs_quota_us:默认值 -1,表示不限制。如果设置为 100000,就表示每个周期内这个 cgroup 最多能跑满 100ms 的 CPU 时间,也就是 1 核。

--cpus=1在 Docker 里实际做的事,是把cpu.cfs_period_us设成 100000,cpu.cfs_quota_us设成 100000。这样内核在每 100ms 的窗口内,会统计这个 cgroup 里所有线程的实际运行时间,超过 100ms 之后,剩余的线程就会被强制休眠,直到下一个周期重新获得配额。如果--cpus=4,quota 就是 400000,容器最多能用满 4 个核心的等效时间。

这个机制决定了两个事情:

第一,容器内的进程不会死锁在某个核上,它会跑到哪个核,由内核 CFS 调度器决定;第二,如果你的服务是延迟敏感型,且 CPU 使用率经常逼近 limit,你会看到 CPU 节流带来的 "毛刺"——不是没资源可用,时间是分片给的,拼凑不出整段的计算能力。典型场景是 Java 服务:GC 线程、业务线程都要 CPU,一旦被节流,GC 停顿时间会明显拉长,接口耗时跟着上涨,而你从top里看到的进程 CPU 占用率却并不高,因为统计窗口被拉长了。

另外一个常见参数是--cpu-shares(在 Kubernetes 里对应requests.cpu),它设定的是相对权重,而不是硬性上限。假设两个容器 A、B 分别设置 shares 为 1024 和 512,当宿主机 CPU 空闲时,两者都可以自由使用空闲 CPU;但当 CPU 资源紧张时,内核会按 2:1 的比例分配时间片给它们。这个参数是"软限制"性质的,主要用于保证服务的基线资源,而不是卡死上限。

如果你的服务对 CPU 缓存有强依赖(比如搜索类、数据库类),可以再加上--cpuset-cpus把容器绑定到固定的物理核上,这样能减少上下文切换和 CPU 缓存失效。例如:

docker run --cpuset-cpus=0,1 --cpus=2 -it ubuntu:22.04 bash

这样既绑定了 0、1 号物理核,又限制了总 CPU 时间为 2 核。生产环境我建议除非明确知道服务需要,否则不要轻易绑核,因为绑核会让调度器在资源碎片化的机器上很难做"装箱",反而降低集群整体的资源利用率。

2.2 内存限制:为什么超限就会被直接杀死

内存限制比 CPU 限制要硬得多。只要这个 cgroup 内所有进程使用的物理内存(包括 page cache 中不可回收的部分)超过限制,内核就会触发回收,如果回收不掉,就会按照 cgroup 内进程的 oom_score 挑一个进程杀掉。在 Docker 里设置--memory=4g,容器最多使用 4GB 物理内存。这个限制包括匿名内存(堆、栈)和部分文件缓存(page cache),但在多数发行版上,内核会尽量回收 page cache 而不是直接杀进程,所以实际遇到最多的还是堆内存和元数据把内存打爆。

这里有一个非常值得注意的细节:swap 和内存限制的关系。如果你在宿主机上开了 swap,且没有给容器单独设置--memory-swap,Docker 默认的 swap 上限会是内存上限的两倍。也就是说,--memory=4g不额外设置 swap 时,容器实际上最多用 4GB 内存 + 4GB swap。这会导致一个现象:容器内存到了 4GB 后不会立刻被杀,而是开始用 swap,整体性能断崖式下降,但进程活着。很多人在排查时只盯着 "RSS 没涨,为什么不响应",完全没意识到 swap 已经被吃满了。

处理建议分两种场景:

  • 生产环境我几乎都会关闭容器 swap,--memory-swap=4g让它和内存上限一致,迫使超过 4GB 时立刻触发 OOM 机制,尽早暴露问题,而不是让服务在 swap 里"假死"。
  • 但如果你是靠内存换性能的批处理任务(比如大量读文件),可以保留少量 swap 作为缓冲。

再补一个 cgroup v2 的注意点。现在 Debian/Ubuntu 新版本和主流云厂商的内核都已经切到 cgroup v2,v2 里内存限制的写法变了,memory.max直接对应原来的memory.limit_in_bytes,而且 v2 默认会把 swap 单列,不再默认给"两倍 swap"的规则。在容器平台层做监控采集的时候,要注意把 cgroup v1 和 v2 的路径区分开,否则图表上会出现"内存使用率超过 100%"的怪事。

2.3 为什么容器内看到的 CPU/内存和宿主机对不上

这个问题至少碰到过十次以上。你进到容器里执行free -h,发现总内存是宿主机的大小;执行top,发现所有 CPU 核都在列表里。但宿主机明明通过 cgroup 限制了这个容器最多只能用 2 核 4GB。这会让很多人误以为限制没生效。

原因我刚才零散提过:/proc/meminfo、/proc/cpuinfo这些文件反映的是内核的全局视图,而不是 cgroup 的视图。它们不受 namespace 隔离影响。除非容器平台特意做了一层虚拟化或者通过 lxcfs 这类工具把 cgroup 信息映射到/proc里,否则你在容器里看宿主机,是看不到"自己这台容器当前被限制了多少"的。

所以排障时不要依赖容器内free、top的绝对值,要看 cgroup 目录里的统计文件。举例:

# cgroup v1 cat /sys/fs/cgroup/memory/memory.usage_in_bytes cat /sys/fs/cgroup/cpu/cpu.stat # cgroup v2 cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/cpu.stat

这也是为什么我强烈建议监控系统直接采集 cgroup 数据,而不是依赖容器内部 agent 上报。否则资源限制有没有生效、有没有触发节流,你根本看不到。

3. 从单机到集群:调度器如何理解"还有多少可用资源"

3.1 调度本质:一个带约束条件的装箱问题

如果说资源限制是"单个容器在一台机器上能用多少"的规则表,那调度就是"这张规则表贴在哪台机器上最合适"的决策过程。把调度抽象成一句话:有一批待调度的 Pod/容器,有一批候选节点,每个节点初始可用资源已知,每个 Pod 声明了自己需要的资源(requests)和最多能用的资源(limits),调度器要在满足各种约束(资源够不够、端口冲不冲突、数据盘在不在、亲和性满不满足)的前提下,为每个 Pod 选一个节点。

这里最重要的是理解 Kubernetes 调度器(kube-scheduler)是怎么看待"资源够不够"的。它判断的依据不是top看到的节点实时负载,而是申请值(requests)的累加。

举个例子,一个节点有 8 核 16GB,上面已经运行了三个 Pod:

Podrequests.cpurequests.memory
A1核2GB
B2核4GB
C1核1GB

kube-scheduler 在调度一个新的 Pod D 时,会先计算"节点已分配(allocated)的 requests" = 4核 7GB,然后看看 D 如果申请 2核 4GB,加上去是 6核 11GB,没有超过节点的总量 8核 16GB,于是认为这个节点可以调度。

注意,它完全不管 A、B、C 是不是真的在用这么多资源,甚至不管节点是否因为其他原因接近崩溃。这意味着什么?意味着如果一个 Pod 只设了非常大的 limits、但 requests 设得很小,调度器会认为它"不占地方",一股脑往同节点塞。等它们真正跑起来,实际使用量远超 requests,节点就可能会被击穿。

所以在做容量规划时,requests 和 limits 必须一起看。

3.2 requests 和 limits 是两套不同的语义

Kubernetes 的资源模型区分得很清楚:

  • requests:调度依据。告诉调度器"这个容器至少需要这么多资源",调度和节点容量校验基于它。
  • limits:运行时限制。告诉节点上的 kubelet"这个容器最多只能用这么多资源",一旦超过就会被限流或杀掉。CPU 超限会限流,内存超限会 OOM Kill。

很多人刚上手时喜欢把 requests 和 limits 写得一样大,理由是"这样服务质量有保证"。这在资源充足时没问题,但如果你关心集群利用率,就会发现在绝大多数服务不是时刻打满的情况下,requests == limits 等于主动放弃了超卖红利——你为每一份空闲资源付了"预定费用",但并没有真正使用它。

反过来,如果 requests 设得太小而 limits 很大,调度器会认为这个容器"很省",把大量高 limits 容器叠在同一节点上,一旦洪峰到来,整节点直接被打爆。这里需要找到一个平衡点。

我常用的策略是:

  • 在线业务(延迟敏感,如 API 服务):requests 设为基线负载的 120% 或 P50 的 1.5 倍,limits 设为能承受的峰值,尽量控制在 requests 的 2~4 倍以内。
  • 离线业务(大数据计算、批量任务):requests 可以设小一点,limits 设大,利用超卖。但必须搭配优先级和抢占机制,保证在线业务需要资源时能把离线任务挤走。
  • 关键中间件(etcd、数据库、消息队列):requests == limits,不做任何超卖。这是我的底线,因为它们的内存和 CPU 抖动会直接拖垮下游。

我还碰到过一个容易忽略的场景:GPU 节点的调度。GPU 在 Kubernetes 里通常是通过设备插件以 "Extended Resource" 的形式暴露的(比如nvidia.com/gpu),调度器在计算这类资源时走的是"整数资源计数"逻辑,不是 CPU 时间片那种连续数值。你必须在 Pod 里显式声明resources.limits["nvidia.com/gpu"]: 1,节点上这个资源够数才调度,否则即使节点有 GPU,也轮不到你的 Pod 用。这里 requests 和 limits 的语义近似等价,因为 GPU 分配是独占式的。很多人在混用 CPU 和 GPU 节点时踩过坑:所有 Pod 都声明了 CPU requests,但只有带 GPU 的 Pod 才声明 GPU 数量,调度器在 GPU 节点上还要单独考虑 CPU 是否够分,不然 GPU 资源满了 CPU 没满,或者反过来,都会导致调度失败。

3.3 资源抢占与调度失败的真实场景

资源不足时的表现不一。我先说最常见的 Pending 场景。你创建一个 Deployment,Pod 一直卡在 Pending,kubectl describe pod里写着0/4 nodes are available: 1 Insufficient cpu, 2 Insufficient memory, 1 node(s) had untolerated taint。Insufficient cpu和Insufficient memory不用解释——候选节点上,当前已分配 requests + 新 Pod requests 超出节点容量。had untolerated taint是节点上有污点(taint),而你的 Pod 没有对应的容忍(toleration),调度器直接排除节点。

另一个更微妙的场景:节点的实际负载很高,但所有已分配 Pod 的 requests 加起来还没超过节点容量。调度器认为这是个"轻载"节点,继续往里塞 Pod,结果新 Pod 的 CPU 时间片不够用,应用卡成幻灯片。这在自建 Kubernetes 集群里特别常见,因为 kube-scheduler 默认根本不看节点的真实负载指标(比如节点 CPU 使用率、内存压力)。

怎么解决?两个方向:

  1. 安装descheduler定期把"过载"节点上的 Pod 重新调度,或者给节点设置较高的资源余量。
  2. 在调度阶段就用动态资源感知的调度器扩展,比如基于 Prometheus 采集的节点实时负载做过滤和打分。如果你的集群规模不大,最省事的做法是把新节点的 requests 总和保持在节点容量的 70% 以内,给自己留出突发余量。

抢占地盘的机制也要理解。当一个高优先级 Pod 调度失败且节点上存在低优先级 Pod 时,kube-scheduler 会尝试"抢占"——驱逐低优先级 Pod,给高优先级 Pod 腾位置。这里比较坑的是,被抢占的低优先级 Pod 如果不是 Deployment/StatefulSet 管理的,它被删掉之后就不会自动重建,造成服务长时间中断。所以生产环境建议所有工作负载都通过控制器管理,不要用裸 Pod。

4. 调度的实际组合:把"限制"变成集群级的策略

4.1 节点选择:从硬性过滤到软性打分

当容器数量少、节点只有三五台时,调度可以完全依赖资源 requests 是否超出容量。但集群规模到上百节点后,"该把 Pod 放哪"就不只是资源问题,还涉及可用性、亲和性、甚至成本。

kube-scheduler 的调度流程简单概括是:预选(Filter)→ 优选(Score)→ 选定(Select)。

  • 预选阶段把所有不满足硬性条件的节点过滤掉。比如资源不足、端口冲突、节点 taint 不匹配、Pod 有nodeSelector而节点没有对应 label 等。
  • 优选阶段对通过预选的节点打分。打分维度包括资源富余程度、Pod 与节点之间的亲和性/反亲和性、Pod 分布的分散程度等。
  • 选定阶段选最高分节点执行绑定。

其中资源分数的细节影响很大。默认的LeastRequestedPriority偏向选择资源富余度高的节点,让集群负载趋于分散;而MostRequestedPriority偏向把 Pod 集中到少量节点,提升装箱效率。Kubernetes 推荐配置通常会把节点原有资源利用率、新 Pod requests 考虑进去,但你生产中可能需要根据场景调整权重。比如批处理集群可以用装箱策略提高吞吐,在线交易系统则用分散策略提高容错。

4.2 亲和性、反亲和性和拓扑分布

只用资源大小决定调度,会有几个问题:

  • 两个互相调用的服务被调度到不同交换机下的节点,跨机房的网络延迟把接口耗时拉高一倍。
  • 某个服务的多个副本落到同一台物理机,机器一挂,这个服务的所有副本同时不可用。
  • GPU 节点上排满了一个服务的副本,其他服务反而抢不到 GPU。

亲和性解决了第一和第三个问题:你可以用nodeAffinity指定 Pod 必须或尽量调度到打了特定 label 的节点(比如gpu=true的节点、zone=az1的节点)。Pod 间亲和性解决第二个问题的另一面:让 frontend 和 backend 尽量落在同一个拓扑域里,减少跨节点网络开销。

反亲和性则是解决"多副本不能挤在一起"。典型配置:

affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: my-service topologyKey: kubernetes.io/hostname

这段的意思是在调度时,尽量避免让app: my-service的多个副本落在同一个 hostname(物理节点)上。注意这里用了preferredDuringScheduling...,它是"尽量满足但非强制",因为如果你强制每个节点只能一个副本,而节点总数少于副本数时,多余副本永远 Pending。

还有一个和资源限制强相关的点:反亲和性必须配合资源 requests 合理设置才能发挥作用。你想把一个 stateful 服务的三个副本分散到三台机器,但每台机器上其他 Pod 已经占了大量 requests,三台机器都只剩不到一个副本所需的资源配额,那调度器要么把三个副本都塞到一台机器,要么让部分副本 Pending。所以做高可用部署时,节点空闲资源必须留出至少一个副本容量。

4.3 污点与容忍:给节点设置"准入规则"

污点(taint)和容忍(toleration)是调度里的准入控制。它不像资源限制那样直接约束容器能消耗多少资源,而是决定"容器能不能进这台节点"。

举个例子,集群里有几台机器是高性能型(比如带 NVMe 磁盘、大内存),你希望只有关键数据库类 Pod 能上去,普通业务不要占用。可以给高性能节点打一个污点:

kubectl taint nodes perf-node dedicated=db:NoSchedule

然后在数据库 Pod 上配置容忍:

tolerations: - key: "dedicated" operator: "Equal" value: "db" effect: "NoSchedule"

这样普通 Pod 上没有对应的容忍,调度器根本不会把它们放到 perf-node 上;数据库 Pod 带有容忍,可以进入。这里面有个易混淆的效应:NoSchedule只影响未来调度,不会驱逐已经运行在该节点上的 Pod;NoExecute会立即驱逐所有不容忍的 Pod。如果你要对存量 Pod 生效,只能用NoExecute或者先排空节点再重新调度。

污点与容忍和资源限制的配合在混部场景尤为重要。混部(在线业务和离线任务跑在同一批机器上)的核心是:在线业务用污点把离线任务挡住,离线任务靠容忍进入;然后离线任务设置较低的 requests 来充分利用空闲资源,但它的 limits 必须受到严格约束,否则内存泄漏或 CPU 毛刺会反过来影响在线业务。

我在生产环境见过一个很典型的组合:在线服务设置requests.cpu=2、limits.cpu=4;离线批量任务设置requests.cpu=0.5、limits.cpu=8,同时给离线任务一个低优先级 PriorityClass。这样调度器把离线任务塞满节点,但一旦在线服务扩展副本,节点资源紧张,高优先级在线服务的抢占机制会把低优先级离线任务挤掉。这套逻辑跑得很稳,但前提是:离线任务必须能容忍被抢占和重启,不然数据一致性会出大问题。

5. 实战排查:资源限制和调度叠加时最容易踩的坑

5.1 现象一:容器 OOMKilled 到底是谁杀的

收到告警说某个 Pod 被 OOMKilled,第一反应往往是"去容器里看日志,增加内存 limit"。但同样的现象,背后有三种可能:

  • cgroup 内存超限,容器进程被内核作为 cgroup 超额的"首犯"杀掉。
  • 宿主机物理内存本身耗尽,全局 OOM Killer 启动,挑了一个进程杀。这个进程可能不是使用内存最多的,而是 oom_score 最高的。
  • 容器内应用自己崩溃、panic,被 kubelet 判定为内存超限重启。

判断方法是看内核日志。cgroup v1 时用dmesg -T | grep -i oom,v2 时可以查 BPF 或 events。如果日志里出现Memory cgroup out of memory,那是容器自身超限;如果是全局 OOM,会看到Out of memory: Kill process ...,且受害进程的 cgroup 路径通常不是规规矩矩的容器路径。

经验之谈:当 Pod 的内存 limit 是 4GB,但容器内 Java 进程的堆设了 5GB,或者堆没设 Xmx,JVM 默认按宿主机内存比例分配,那必然反复 OOMKilled。我在排查过几次后,凡是容器化 Java 应用,第一件事就是确认 JVM 参数是否感知到了 cgroup 限制。JDK 8u191+ 和 JDK 10+ 已经支持容器感知,默认-XX:+UseContainerSupport,会自动根据容器内存限制调整默认堆大小;老版本 JDK 会把宿主机内存当成可用堆大小,直接打爆限制。这类问题不用改代码,换基础镜像就能解,纯属踩坑经验。

5.2 现象二:CPU Limit 设了,但容器还是把节点打满

还有一个高频现象:给容器设了limits.cpu=2,按理说不可能超过 2 核,但节点整体 CPU 使用率还是 100%,而且top显示某个容器进程 CPU 占用很高。这不是限制没生效,而是你限制的是一个容器内的所有进程总和,如果这个容器里起了 8 个线程,它们加在一起不能超过 2 核;但如果容器的 PID namespace 里有多个进程,其中一个进程自身就能跑满 2 核,另一个就完全没 CPU 可用。看起来就是"某个进程把节点打满"。

更隐蔽的是 CPU 管理器(CPU Manager)的静态策略。如果你在 kubelet 开启了--cpu-manager-policy=static,并且 Pod 是 Guaranteed QoS(requests == limits 且都是整数核),kubelet 会把该 Pod 的容器绑定到独占的 CPU 核心上。这种模式下,其他容器无法使用这些核心,即使它们是空闲的。节点上其他 Pod 会"看起来明明有 CPU 余量,但调度就是失败"。排查时请先看这个 Pod 是否处于 Guaranteed QoS,再检查 kubelet 的 CPU manager 策略。

5.3 现象三:节点明明有剩余资源,Pod 却调度不上

我之前遇到过最让人头疼的一类问题是:kubectl describe node显示剩余资源还够,但新 Pod 始终 Pending,提示资源不足。排查后发现两个原因叠加:

  1. kube-scheduler 看在节点的allocatable资源时,扣掉的是所有已存在 Pod 的 requests,而不是实际使用量。如果一个节点上有一堆未知来源的静态 Pod(static pod),或者之前残留的裸 Pod 已经消失但 sandbox 没清干净,它们的 requests 会一直占坑。
  2. 节点被打了污点,或者有node.kubernetes.io/unschedulable: NoSchedule的标记没有去掉。很多人排障时只看kubectl get node的状态是 Ready,没注意到SchedulingDisabled。

实操中我一般按这个顺序排查:

# 查看节点状态和污点 kubectl describe node <node-name> | grep -A5 Taints # 查看节点已分配的资源 kubectl describe node <node-name> | grep -A7 "Allocated resources" # 查看节点上的 Pod 是否都是活跃的 kubectl get pods --all-namespaces --field-selector spec.nodeName=<node-name>

如果你发现节点上残留大量 Terminating 状态的 Pod,它们占用的 requests 会被继续计入,直到真正删除。这时需要确认容器运行时挂掉了或 kubelet 卡死,把 sandbox 清掉,问题立刻缓解。

5.4 现象四:GPU 节点上的资源竞争

烫过几次之后我专门把 GPU 调度单独拎出来。GPU 资源不像 CPU 那样可无限分片,它默认是整卡分配。如果你给一个 Pod 声明nvidia.com/gpu: 1,那这张卡就整张归它,其他 Pod 不能用,哪怕它在上面只做很轻的推理。

于是出现了两种典型问题:

  • 节点有 8 张卡,但其中一张因显存问题被系统做成了nvidia.com/gpu: 7,调度器按 7 张卡分配,剩下 1 张卡的资源永远不会被调度,因为设备插件上报的计数就是 7。
  • 多个 Pod 共享同一张卡时(通过 MIG 切片或 vGPU 方案),你需要额外声明每个 Pod 的显存和算力需求,否则调度器依旧按整卡粒度决策,资源利用率不高。

GPU 限制这块,本质上调度器只管"分配到的整数资源是否够",而不管"显存会不会 OOM"。运行起来后如果显存超了,驱动会直接把进程杀掉,表现形式和普通 OOMKilled 很像,但原因完全不同——你查 cgroup 内存的指标可能一切正常。所以给 GPU 工作负载做监控时,一定要把 NVIDIA 的nvidia-smi显存指标采集进去,不能用系统内存指标代替。

6. 我的资源限制与调度策略清单

讲了这么多机制和事故,最后给一份可以直接抄作业的配置思路。不同团队的基础设施不一样,但以下几条通用原则,我几乎所有项目里都会贯彻。

6.1 资源限制设置原则

  1. 在线服务 requests 不要小于峰值期间实际使用量的中位数,否则高峰段 CPU 时间片分配不足,请求延迟指数级上升。
  2. 内存的 limits 必须大于 JVM 堆 + 堆外内存 + 线程栈 + 直接内存。无论是 Java 还是 Go,运行时都有不少非堆内存。Java 服务我通常把-Xmx设置为容器内存 limit 的 60% 到 70%,给 MetaSpace 和直接内存留足空间。
  3. CPU limits 不要设成"永远打不满"的值。我见过有人给所有服务设limits.cpu=8,跑在 2 核的节点上——首先调度器会直接拒绝,因为 limits 不影响调度,但 kubelet 的 cgroup 配置会按 8 核设 quota;如果节点上其他 Pod 不停增加,这个服务不会被限流吗?会,但实际被限的节点层面却在和其他 Pod 争抢。设 limits 前一定要确认节点物理 CPU 总量。
  4. 磁盘 IO 限制能设就设,尤其日志型容器和临时文件密集型服务(比如代理缓存)。--device-read-bps、--device-write-bps在 Docker 下可以直接设,Kubernetes 里可以通过 device plugin 和 CSI 实现,复杂度略高,但值得做。

6.2 调度配置原则

  1. 集群节点分组是基础中的基础。用 label 区分通用节点、CPU 密集型节点、内存型节点、GPU 节点,并在工作负载上通过 nodeAffinity 或污点容忍控制流向。
  2. 核心工作负载用PodDisruptionBudget(PDB)保护起来。PDB 的作用不是调度,而是防止 Kubernetes 在节点维护时把过多副本同时下线。配合反亲和性,可以让"一个节点挂了"这件事的影响面最小化。
  3. 定期用descheduler或手工巡检处理资源碎片。节点被大量小 requests 的 Pod 占满后,可能每个 Pod 都只用了不到 20% 的 CPU,但调度器再也塞不进一个需要 1 核资源的 Pod。这时候需要重新调度,把 Pod 往其他节点上迁移。
  4. 启用PriorityClass并设计好优先级分类。不要只给在线、离线各一个优先级,建议按"系统关键组件 > 有状态核心业务 > 无状态在线服务 > 批处理离线任务 > 可丢弃的临时任务"划分至少五个档位,抢占行为才可控。

6.3 可观测性:没有数据,一切策略都是瞎猜

资源限制和调度如果没有可观测性支撑,基本等于盲飞。我最小化的一套监控配置包含:

  • 节点维度:CPU 使用率、CPU steal、内存使用率、内存压力、磁盘 IO 延迟、网络丢包率。
  • 容器维度:cgroup 内的 CPU 使用量、限流时间(throttled time)、内存使用量、OOM 事件次数。
  • 调度维度:调度耗时分位数、Pending Pod 数量、调度失败原因分布(resource insufficient / taints / affinity 不满足)、节点资源碎片率。

有一组关键指标让我无数次在故障发生前发现问题:container_cpu_cfs_throttled_periods和container_memory_working_set_bytes。前者一旦显著增长,说明 CPU limit 即将成为瓶颈;后者持续逼近 limits,则预示 OOM 风险。如果你还在裸跑 Docker 而没有这些指标,强烈建议接上 cAdvisor 或 Prometheus 的容器指标采集。

最后再说一个个人经验:资源限制和调度策略的每一次调整,都要在灰度环境里先观察至少一个业务周期。CPU limits 调小哪怕 0.5 核,都可能让一批延迟敏感型服务的 P99 从 50ms 涨到 500ms。不要在大版本发布当天顺手调配置,我吃过这个亏——线上做了一次"听起来无害"的 limits 收敛,中午流量高峰直接触发大面积 CPU 节流,接口超时告警刷了一下午。改配置和改代码一样,要有变更流程、有回滚预案、有灰度验证。这套思路放到今天依然实用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询