容器资源限制这个话题,看着像是运维或者平台工程师的专属领域,但真踩过坑的人都知道,只要你的服务上了容器,不管是 Docker 还是 Kubernetes,资源限制和调度就是你绕不过去的两道坎。我见过太多线上事故,不是因为代码写得烂,而是因为容器 CPU 飙到 100% 把整个宿主机拖垮,或者一个 Java 应用的内存没设上限,直接把节点打 OOM,最后整批 Pod 陪葬。反过来说,限制设得太死,业务高峰期流量一来,容器直接被杀或者疯狂触发 CPU 节流,接口响应时间从 50ms 变成 5s,用户投诉电话被打爆。
这个主题之所以值得单独拿出来聊,是因为很多人对“资源限制”和“调度”的理解是割裂的。总以为在 Docker 里写上--cpus和-m就算完事,或者以为调度就是 K8s 里那个 scheduler 组件在做的事情,跟自己无关。但真实的容器世界里,这两者是一套完整的逻辑闭环:资源限制决定了单个容器能“吃多少饭”,调度决定了容器应该“坐在哪张桌子上吃”,两者一旦配合不好,轻则性能抖动,重则雪崩。这篇文章不打算讲太虚的架构理论,我想从底层原理、单机实操、集群调度再到任务平台,把这条链路完整地捋一遍,把我自己踩过的坑和验证过的方案都摊开来说。
先说清楚适合看这篇文章的人:正在用 Docker Compose 跑服务的后端开发,刚把应用迁到 K8s 的运维工程师,以及做大数据或者 AI 训练平台、天天跟任务队列和资源池打交道的同学。只要你想搞清楚“为什么我的容器老是被杀”“为什么我的 Pod 调度不到新节点上”“为什么资源明明很充足任务却一直排队”,这篇文章就是给你写的。
1. 资源限制与调度的核心逻辑拆解
1.1 资源限制到底在限制什么
容器的本质是进程,但它是被 cgroup 约束住的进程。Linux 内核通过 cgroup 来对一组进程做资源隔离,包括 CPU、内存、磁盘 I/O、网络带宽等。Docker 启动容器时,本质上就是创建了一组 cgroup,并把容器主进程塞进去。所以当你设置资源限制时,表面上是 Docker 在帮你做配置,实际上是在操作内核的 cgroup 文件系统。
CPU 限制这块有两种维度:一种是 CPU 份额(shares),代表的是相对权重,比如容器 A 设置 1024,容器 B 设置 512,那么当宿主机 CPU 争抢时,A 分到的 CPU 时间是 B 的两倍。但注意,份额只在 CPU 紧张的时候才生效,如果宿主机很空闲,A 想跑满所有核心也没人能拦它。另一种是 CPU 配额(quota),也就是常说的--cpus、--cpu-period和--cpu-quota的组合。这个是真的硬限制,比如设置--cpus=2,就意味着容器最多每 100ms 周期内使用 200ms 的 CPU 时间,超过这个阈值就直接节流。这个机制保证了容器永远不会超过你设置的额度,但代价就是高负载时会有明显的性能损耗。
内存限制就更直接了,cgroup 的内存控制器会跟踪容器内所有进程的物理内存、交换内存、内核内存等使用量。一旦达到限制,内核的 OOM Killer 就会介入。这里有个很多人忽略的细节:OOM Killer 不一定会杀掉容器里的进程,它可能直接杀掉 cgroup 里的某个子进程,而这个子进程恰恰就是你服务的核心 worker。Docker 在检测到这种情况后,会把整个容器标记为 OOM killed 状态,但如果是 K8s 的 Pod,容器会被重启,然后调度器可能把它重新调度到别的节点上。
1.2 调度解决的是“放哪里”的问题
调度这个词在不同层级含义不同。在 Docker 单机环境下,调度其实就是你手动指定--cpuset,告诉容器用哪颗 CPU 核心,或者靠 runc 把容器挂在某个 NUMA 节点上。这种粒度很细,但对普通业务开发者来说太底层了,日常几乎用不到。
到了集群环境,调度就变成了分配决策问题。K8s 的 kube-scheduler 做的事情是:找到一个适合运行某个待调度 Pod 的节点。它会先过滤掉不符合硬性条件的节点(比如资源不足、端口冲突、节点不可用),然后再给剩余节点打分,选一个分数最高的。这里面的过滤和打分逻辑是可以被调度器扩展点干预的,但绝大多数场景下,默认的调度算法已经足够好用。
真正让很多人挠头的概念是“调度”和“资源限制”在 K8s 里的交互方式。K8s 里有两个关键字段:requests和limits。requests是调度器用来做决策的值,它代表这个 Pod “至少要拿到这么多资源才肯干活”。调度器只看requests,不看limits。而limits是真正被 cgroup 强制执行的值,超过就可能被节流或杀掉。很多人只设置了limits没设置requests,结果调度器认为这个 Pod 对资源的需求是 0,可能把大量 Pod 压到同一个节点上,运行时limits又互相争抢,最终大家一起卡死。这是新手最容易踩的坑之一。
高层的任务调度,比如 DolphinScheduler、Airflow 这类平台,它们管的是“任务在哪个 worker 上执行”,又是另一套逻辑。一般是有个队列或消息中间件收集任务,调度线程按 DAG 依赖关系触发任务,然后通过注册到平台的 worker 节点来领取并执行。这个时候的“调度”其实已经脱离了容器层面的资源概念,更多是业务编排层面的事情。但我在实际项目里发现,很多团队把这两层调度混为一谈,在业务调度平台上设置了并发上限,却完全没考虑底层容器的资源水位,最后任务全部堆积在容器里排队,界面看着是“等待执行”,实际是容器 CPU 已经跑满,任务执行完一个又卡住一个。
1.3 两者配合的最佳实践思路
最理想的状态是:容器层做硬隔离,通过limits和requests保证每个容器有稳定的资源水位;集群层做软调度,通过调度器感知每个节点的可分配资源,把新来的 Pod 放到最空闲的节点上;业务调度层做队列控制,根据底层容器的实际负载动态调整并发数。三层各司其职,就像一个餐饮店的厨房:容器是灶台,limits是燃气限量阀,调度器是厨师长的排菜逻辑,任务队列是门口的取餐号。
但这种理想状态需要很强的平台能力支撑,对大多数中小团队来说,先把容器层的限制和 K8s 调度这两件事做好,已经能解决 80% 的资源问题。所以后面的内容我会分成三层来展开:先用 Docker 讲清楚资源限制的实操细节,再讲 K8s 调度器的设计逻辑和配置方法,最后补充任务调度与容器资源之间互相影响的实际案例。
2. Docker 单机层的资源限制实操要点
2.1 最常用的 CPU 和内存限制参数
在 Docker 里做资源限制,核心就是docker run的几个参数。CPU 相关的有--cpus、--cpu-shares、--cpu-period、--cpu-quota,内存相关的有-m或--memory、--memory-swap、--memory-reservation。我建议普通场景下不要碰那些细粒度的--cpu-period和--cpu-quota,直接用 Docker 帮你封装好的--cpus就行,它底层会自动换算成cpu-period和cpu-quota。比如--cpus=1.5就等价于 100ms 周期内最多用 150ms CPU 时间,非常直观。
内存方面,一个特别容易出问题的组合是-m 512m但不设置--memory-swap。Docker 默认情况下,--memory-swap未设置会等于内存限制的两倍,也就是说-m 512m时 swap 是 512m,总内存加 swap 上限是 1G。如果你的应用能申请到 700M 物理内存,它不会立刻被 OOM 杀掉,而是会去用 swap,速度瞬间变慢。对于 Java 这种内存分配敏感的应用,强烈建议显式设置--memory-swap=512m,让它完全不能使用 swap,要么直接 OOM 重启,要么通过 JVM 参数把堆大小控制在安全范围内。这种“快速失败”在很多线上场景反而比“慢性卡死”好处理得多。
2.2 目录权限与容器的资源限制关系
很多人会问,为什么改了容器的读写权限,容器反而启动更慢或者性能下降了?这其实和资源限制关系不大,但容易混淆。Docker 挂载目录时常用的-v /host:/container默认以 root 权限方式绑定,容器内进程如果以非 root 用户运行,就可能出现“Permission denied”。解决方法是使用--user参数或直接修改挂载目录的属主,比如chown -R 1000:1000 /host。这本身不涉及资源限制,但如果你的容器因为权限问题不断重启,就会触发 K8s 的 CrashLoopBackOff,进而影响调度器的调度计数,甚至被判定为“这个节点状态异常”。这提醒我们,处理容器实际问题时,不能只盯着资源参数,权限、磁盘 I/O 等因素都可能和“资源”问题表现出相似的症状。
从 I/O 限制的角度看,Docker 提供了--device-read-bps、--device-write-bps等参数来限制磁盘吞吐。这个参数在共享宿主机的情况下特别有用,尤其是你跑的是日志采集类容器或大数据组件。比如多个业务容器都在写大量日志到宿主机目录,如果不限制某个容器的写盘速率,它可能把磁盘带宽全部吃掉,影响其他容器的正常读写。
我去年调过一个真实案例:K8s 节点上同一个宿主机跑了两个 Java 服务,一个做离线批量计算,一个做在线 API,两个容器挂载了同一个宿主机磁盘目录。离线服务在某天数据量暴增时疯狂写盘,节点 I/O 达到 100%,在线服务响应时间从 20ms 飙到 800ms。当时没有限制容器 I/O,后来给两个容器分别加了--device-write-bps限速,在线服务才恢复稳定。类似这种问题,只关注 CPU 和内存是永远排查不出来的。
2.3 Docker Desktop 与 Compose 场景下的配置写法
如果是用 Docker Desktop(Windows 或 macOS 环境)做开发调试,很多人会问“怎么对已部署的容器增删文件夹和文件”或者“怎么调整已运行容器的资源配置”。Docker Desktop 对正在运行的容器本身不支持直接修改--cpus和-m,因为容器创建后 cgroup 参数不能像 Pod 那样通过 update 动态修改(实际上 Kubernetes 可以动态调整,但原生 Docker 不行)。所以你只有两个选择:一是把配置写在docker-compose.yml里,然后docker-compose up -d重建容器;二是用docker update命令对运行中的容器调整部分参数。注意docker update只支持 CPU 和内存的部分参数(比如--cpus、-m),不支持磁盘 I/O 相关设置。
Compose 文件里的写法是这样的:
services: api-server: image: myapp:latest deploy: resources: limits: cpus: "1.5" memory: 512M reservations: cpus: "0.5" memory: 128Mdeploy.resources.limits对应容器限制,reservations对应预留,也就是 Docker swarm 模式下的 requests 概念。但如果你只是用docker-compose而没启用 swarm,reservations其实不会生效。这个细节坑了很多人——明明配置了预留,但容器跑起来后查看 cgroup 完全没有变化。原因是docker-compose up不是 swarm 服务部署,它只识别limits,reservations被忽略了。真正的做法是每个服务单独加cpu_count或mem_limit这种顶层字段,或者干脆在命令里显式传参。
3. Kubernetes 集群调度与资源池管理
3.1 requests 和 limits 的设计逻辑
K8s 的资源模型用很多标准教材讲过,但我更愿意用一个生活化的类比来解释:requests相当于你租房时的“最低生活需求”——你告诉房东你必须要有 2 个卧室才能住;limits则是你最多能接受的“房租封顶”——不管什么情况你最多付这么多钱。调度器这个“中介”在帮你找房时,只关注你有没有钱付最低需求,也就是只看requests。它不会因为你limits很高就认为你很有钱,也不会因为你limits很低就认为你更容易养活。
这个设计的一个直接后果是:如果你不设置requests,只设置limits,调度器看到的是你对资源的需求为零。它会把多个这样的 Pod 调度到同一个节点,但运行起来后各自的limits互相挤压,因为 cgroup 的限制是硬性的,最终可能出现一种情况:节点的requested资源很低,看起来还有很大的“可申请空间”,但实际的物理资源已经因为limits的执行而被某个容器吃满,其他容器性能下降甚至被杀。业界管这种问题叫“超卖陷阱”。所以在生产环境里,我强烈建议requests和limits都设置,而且最好是等于或者接近的比例。比如requests.cpu=1,limits.cpu=2这种 1:2 的比例是允许的,但不能只写一半。
从调度打分机制的角度说,requests会参与节点的资源分配计算。K8s 默认调度器在打分时,会优先选择“资源余量更多的节点”,这个是通过least_requested_score这类策略实现的。简单说,你设置了requests,调度器才能计算“这个节点如果放了你这个 Pod,还剩多少资源”;如果你不设置,剩余资源永远不变,调度器只能靠别的维度乱猜,调度的随机性大大增加。
3.2 节点资源池与调度约束
集群调度不是只有requests和limits,还需要配合节点选择条件来精确控制。常见的做法有nodeSelector、nodeAffinity、podAffinity和taints/tolerations。前两个决定 Pod 能去哪些节点,后两个决定哪些 Pod 能“容忍”节点的污点。
nodeSelector是最简单的:指定节点 label,比如disktype: ssd,调度器就只把 Pod 放到带这个 label 的节点上。我见过很多团队所有节点共用同一个资源池,没有做任何隔离,结果一个占资源的离线任务把整个集群的 CPU 都吃光了,线上流量全部抖动。正确的做法是把不同业务的节点打上 label,或者直接用独立的节点池,从物理层面隔离不同优先级的工作负载。
taints和tolerations的组合则更适合保护特定节点。比如 GPU 节点比较宝贵,可以给节点加一个gpu=true:NoSchedule的污点,然后只有明确写了tolerations的 Pod 才能调度上去。这样可以避免“一个 CPU 密集的小任务不小心被调度到 GPU 节点上”的尴尬局面。
但要注意,调度约束做得越细,调度失败的概率就越高。如果某个 Pod 加了非常严格的nodeAffinity,同时集群里符合条件的节点数很少,一旦这些节点资源不足,Pod 就会一直 Pending。生产环境我曾见过一个状态:Pod 卡在 Pending 超过 24 小时,原因是节点池只剩一个节点,而该节点的资源已经被其他高优先级 Pod 占满,这个 Pod 的资源请求又恰好匹配不上空余资源,调度器一直找不到合适的“座位”。排查时要学会看kubectl describe pod的事件信息,里面会明确告诉你 “0/3 nodes are available” 原因。
3.3 QoS 等级与优先级抢占
K8s 根据requests和limits的组合把 Pod 分成三类 QoS:Guaranteed、Burstable、BestEffort。这个分类决定了极端情况下谁先被驱逐。Guaranteed 要求每个容器requests和limits必须相等,这是最优先保住的 Pod;Burstable 指requests小于limits的 Pod,大部分业务都属于这一类;BestEffort 是完全没有设置requests和limits的 Pod,一旦节点资源紧张,它第一个被驱逐。
这里有个值得注意的现象:很多团队给业务 Pod 设置的requests很小、limits很大,比如requests=100m CPU / 256Mi,limits=4 CPU / 4Gi。从调度角度是良性的,因为调度器按 requests 分配,能塞进很多 Pod;但从运行稳定性角度很危险,因为当节点资源吃紧时,这个 Pod 可能无法申请到接近limits的资源(被其他同节点的 Pod 挤占),而 cgroup 对它的限制又允许它膨胀到 4CPU 和 4Gi。两个这种 Pod 在同一节点上同时膨胀,节点直接被打穿,这个时候 K8s 的驱逐机制就会介入,先杀 BestEffort,再杀 Burstable,最后实在不行可能整个节点 NotReady。
正确的设计思路是:核心业务用 Guaranteed(requests == limits),保证它的 CPU 时间片和内存不会因为其他租户的竞争而缩水;非核心业务用 Burstable,但limits不要设置得太离谱,比如 CPU 1:2、内存 1:1.5 就是比较稳妥的比例。至于 BestEffort 的 Pod,除非是那种“丢了也不心疼”的批处理任务,否则我一般禁止出现在生产集群里。
4. 任务调度平台与容器资源限制的联动
4.1 DolphinScheduler、AIRFLOW 这类平台的任务调度逻辑
DolphinScheduler 的调度流程是:工作流定义由 DAG 描述,调度器按 cron 或依赖关系触发任务实例,任务实例会被放到一个任务队列,由 worker 节点拉取执行。这里的“队列”管理非常关键,它直接决定了任务能同时跑多少、在哪些 worker 上跑。
我见过的大多数问题出在:DolphinScheduler 的 worker 节点本身运行在容器里,而容器资源被 K8s 限制了。比如 worker 容器limits.memory=2Gi,但 DolphinScheduler 的任务配置里executor 内存=4Gi,这样任务启动时就可能因为容器 cgroup 的限制导致子进程内存申请失败,表现特别诡异——任务日志显示 worker 启动成功,但拉取任务后立即退出。排查到最后才发现是容器层内存限制小于任务执行器所需内存。所以在做这类平台容器化部署时,一定要把“任务本身需要的资源 + worker 本身的开销”都算进容器limits里,不能只看 worker 进程占用的内存。
任务调度失败时还有另一个常见问题:失败告警没有和容器资源指标打通。比如某个任务执行失败多次,企微群里反复告警,但根本原因是容器被频繁 OOM kill,导致执行器丢了很多运行状态。这种情况光看任务日志是看不出来的,需要查看docker events或者 K8s 的kubectl describe pod来确认容器是否被杀、是否因为资源不足。我建议在告警规则里增加容器重启次数和 OOMKilled 字段的监控,比如 Prometheus 的kube_pod_container_status_restarts_total,这样每次容器被杀都会直接触发告警,能把根因判断的时间从数小时缩短到几分钟。
4.2 大模型调度平台与队列管理的资源思路
大模型训练和推理平台的调度,跟传统分布式任务有点不一样。训练任务通常需要占用大量 GPU 显存和 CPU 内存,而且一旦资源被抢占,训练进程的通信就可能断掉,代价极高。所以这类平台在做资源限制时,通常会为每个训练任务预留一整块专属资源,而不是用超卖的方式去共享。这种模型叫“bin-packing”,尽量把任务集中放到少数节点上,提高 GPU 利用率,同时避免资源碎片。
队列管理方面,典型的做法是分层队列:用户提交任务后,平台会把任务放入某个队列,队列有最大可占用资源量、最大任务数等指标。底层通过 yarn、Volcano 或者 Kueue 这类调度器来管理。Kueue 是 K8s 生态里做“工作负载排队”的组件,它可以把多个训练任务排在 K8s 之外,等到资源池有足够的配额时才创建对应的 Pod。这种设计能非常有效地避免“任务一提交就创建一堆 Pending 的 Pod 把调度器的压力拉满”的尴尬情况。
但大模型平台容易踩的坑是:模型权重和数据集加载时的临时内存暴涨。一个 7B 模型加载权重时可能瞬间吃几个 GB 的内存,如果你设置的容器内存恰好卡在边缘,训练一开始就被 OOM 杀掉。这里的处理办法有两个:一是显存和内存使用 CV 波动大的任务,limits要比requests高出 30%~50%;二是把权重加载前的预热阶段和实际训练阶段拆开,在预热阶段不要触发资源自动扩展。我写过很多次训练平台编排,最稳的做法是任务的主进程先做资源预检,等到确认内存足够后再真正初始化模型。
4.3 事件驱动的 actor 调度思维
热词里出现了“基于事件调度的 actor 模型”,这个方向偏底层,但也值得点一下,因为它在资源限制与调度上的作用方式不太一样。actor 模型里的调度是按消息事件触发的,每个 actor 在收到消息时才被放进调度队列去执行。对应到资源管理上,它要求“执行某个 actor 逻辑时的资源请求”是动态的,而不是像 K8s 那样提前声明一个 Pod 的资源请求。
这种模型和容器调度结合的例子是 Orleans 或 Akka Cluster。每个 actor 所在的服务节点可以是 K8s 里的一个 Pod,但由于 actor 是轻量级并发的,一个 Pod 可以承载数千个 actor。此时资源限制的粒度就不能到 Pod 了,要用更细的应用级调度,比如设置 actor 每轮消息处理的 CPU 时间配额。如果底层容器被限制了 CPU,actor 的处理吞吐量就会整体下降,但不会崩溃。这个实验我做过,结论是:对于 actor 模型,CPU 限流比内存限制温和得多,内存限制一旦触发很容易导致整个节点掉线。
5. 常见问题与排查技巧实录
5.1 容器频繁 OOM 的排查路径
容器被杀,第一件事不是看业务日志,而是先查内核日志。dmesg -T | grep -i oom或journalctl -k --since "1 hour ago"能看到 OOM Killer 的痕迹,里面会明确写出哪个 cgroup 被杀了、进程 PID 是多少、内存使用量是多少。很多人不知道,内核日志里往往会出现 “Killed process 12345 (java), total-vm:x kB, anon-rss:y kB”,这个信息能直接告诉你到底是谁吃掉了内存。
在 K8s 环境里,kubectl describe pod <pod>的 Last State 字段如果显示Reason: OOMKilled,就说明容器是被 cgroup 杀掉的。但要注意一个容易误判的情况:容器被 OOM kill 后重启,业务日志可能没有记录,因为进程是瞬间被杀掉的,用户态还没来得及写日志。这时候很多人会去翻应用日志,查不到任何异常,最后才想到看容器状态。所以正确的一线排查顺序是:看 K8s 事件 → 看容器状态 → 看内核日志 → 看应用日志。顺序搞反了,排查时间会翻好几倍。
5.2 CPU 节流导致的高延迟排查
有一种现象比较隐蔽:容器没被杀,CPU 利用率看起来也不高(比如 30%),但接口延迟时不时飙到几秒。这时候很可能是 CPU 配额被 cgroup 节流了。--cpus=1的容器跑在 8 核机器上,它最多只用 1 核的算力。如果应用内部有大量并行线程,它们全都想跑,但 cgroup 只能给 1 核的配额,于是线程被频繁切换,上下文切换开销巨大,CPU 使用率看起来不高,实际响应很慢。
查这个问题的利器是cat /sys/fs/cgroup/cpu.stat,里面有两个关键字段:nr_periods表示经过的周期数,nr_throttled表示节流过的周期数。如果nr_throttled / nr_periods的比例很高(比如超过 30%),说明你的容器经常被卡 CPU。这个时候调整limits的 CPU 配额是无效的,正确的做法是调大requests和limits,或者重新审视应用本身的线程模型,适当减少核心线程数,降低上下文切换损耗。
5.3 Pod 一直 Pending 的调度复盘
K8s 里 Pod 卡在 Pending 最让人头疼的就是资源 “看起来” 明明够,但调度器就是不给分配。一个极其常见的隐藏原因是:节点上有一些资源被allocatable扣掉了,比如 kubelet 自己预留的系统资源(system-reserved)或驱逐阈值。如果节点总内存是 16G,kubelet 预留 2G,驱逐阈值 1G,真正可调度给你的只有 13G。你在 Dashboard 上看 “16G” 觉得很宽裕,实际调度器算出来不是那么回事。
另一个隐藏原因是容器镜像拉取失败被反复重试,但这会显示ImagePullBackOff,与 Pending 的表现不同。所以排查 Pending 时,第一步永远是用kubectl describe pod看 Events,Events 里 scheduler 会写明类似 “0/4 nodes are available: 1 node(s) had untolerated taint, 3 node(s) insufficient cpu”。看到这个描述,你只需要照着原因去解:要么加 tolerations,要么减 requests。
5.4 常见问题速查表
| 症状 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 容器被杀,状态为 OOMKilled | 内存 limit 过小或应用内存泄漏 | kubectl describe pod/dmesg | 调整 limit 或优化 JVM 堆 |
| 接口延迟高,CPU 不高 | cgroup CPU 节流 | cat /sys/fs/cgroup/cpu.stat | 增大 CPU quota、优化线程数 |
| Pod Pending,提示 insufficient cpu/memory | requests 大于节点剩余可分配资源 | kubectl describe pod | 降低 requests 或扩容节点 |
| 容器启动慢但无报错 | 镜像拉取慢或挂载权限问题 | docker events/journalctl | 预热镜像、修复权限 |
| 任务执行失败但日志无异常 | 容器崩溃或网络闪断 | docker inspect/kubectl logs | 结合事件时间戳核对容器状态 |
| 同一节点容器互相抢占 | 没有设置 requests 导致超卖 | kubectl top nodes | 为所有工作负载补 requests |
| 磁盘 I/O 占满影响在线业务 | 未限制容器 I/O | docker stats/iostat | 用--device-write-bps做限速 |
6. 实操过程中总结的经验小贴士
6.1 资源限制参数要写成“套餐”,而不是单独指定某一个
我推荐在业务容器上线前,把 CPU、内存、磁盘、文件描述符等参数全部写进部署模板,不要只加一个内存限制就完事。K8s 里很多人只用limits做限制,忘了加requests;Docker 场景里很多人只设置-m,忘了调整--memory-swap。这些偏差在低负载时看不出来,但压测或者流量高峰时全部暴露。生产配置可以参考这个模板:
resources: requests: cpu: 500m memory: 512Mi limits: cpu: "2" memory: 2Gi这个配置比较适合典型的 Web 服务,requests/limits比值是 1:4(CPU)、1:4(内存),适用于业务本身有突发流量且容器内可分享进程占用内存不大的场景。如果是 Java 服务,尤其是堆内存设置了-Xmx2g的,建议内存requests和limits更接近,比如 1Gi/2Gi,因为 JVM 堆外内存、Metaspace 和线程栈常常被忽略,留太多空余也无妨,预防性足够就好。
6.2 对运行中的容器做“软变更”要谨慎
Docker 原生对运行中容器的资源限制可以做docker update,但它的行为受到一些限制。实践中最关键的一点是:如果你改小了内存限制,并且容器当前占用内存已经超过新限制,内核会立刻触发 OOM kill,容器直接被干掉。这种操作在生产环境里等于“手动自杀”。所以任何资源参数变更,哪怕只是调小一点点,都建议走“重建容器”的路线,保证新容器从启动开始就处于新的限制约束下,而不是把运行中的进程强行塞进更小的“笼子”。
K8s 的场景相对好一点,因为requests和limits可以动态调整(需要开启InPlacePodVerticalScaling特性),但这只是对纯 CPU 和内存资源的调整。如果涉及卷挂载、镜像更新等变更,依然需要重建 Pod。因此生产环境的最佳实践是把资源参数作为发布模板的一部分,走完整的 CI/CD 流程,而不是人肉登录节点去改。
6.3 监控和告警必须同时覆盖容器与应用两级
最后这点很重要。资源限制和调度的问题,单靠容器层监控很难发现真正的根因。比如一个 Java 服务的 GC 突然变得频繁,容器层只能看到 CPU 上升、内存使用增加,但看不到 JVM 堆的使用率变化。这时候就需要应用层的监控指标。反过来,应用层日志一切正常,但容器反复重启,应用层监控也发现不了,必须靠容器事件来兜底。我建议的监控组合是:Prometheus + cAdvisor 采集容器 CPU/内存/网络指标,Grafana 展示趋势,配合告警规则;应用层用 Micrometer 或者 SkyWalking 那一套,采集 JVM 的 GC 次数、堆内存、线程数等。两级指标联动,才能真正把“资源限制导致的性能问题”和“业务代码导致的性能问题”区分开。
7. 后续还可以这样扩展
写到这里,整个链路基本清楚了:容器资源限制解决单容器能用到多少资源的问题,K8s 调度解决容器放在哪里运行的问题,任务调度平台解决大批量任务如何排队执行的问题。三者环环相扣,任何一个地方配置不到位,都会在其他层露出奇怪的症状。
如果你已经对目前这套方案比较熟练了,下一步可以尝试引入自动扩缩容。K8s 的 HPA 可以根据 CPU 和内存指标自动调整副本数,但前提是你得给每个 Pod 设置合理的requests。我在实际使用中发现,很多团队把 HPA 配好了却没用起来,就是因为requests设置的过高或过低,导致扩缩容过于敏感或迟钝。没有扎实的资源限制基础,HPA 的阈值判断很容易失真。
另外,如果你在搞 AI 训练或大数据作业,可以深入研究 Volcano 或 Kueue 这类面向批处理的调度器,它们支持资源队列、抢占和公平调度,比默认的 kube-scheduler 更适合作业密集型的场景。我最近在尝试用 Kueue 做训练任务的排队管理,配合容器资源限制,整体效果相当不错,后面有机会可以专门写一篇实操记录。