Docker 资源限制与 OOM 治理:cgroups 如何让一个容器不拖垮整台服务器


VPS 上跑多个容器,最怕的不是某个服务慢,而是一个容器把整台机器的 CPU 或内存耗尽,把旁边所有服务一起拖跨。Docker 给每个容器设资源配额的能力,底层依赖内核的 cgroups 机制——这套内核限制系统决定了"谁可以占多少 CPU、多少内存、多少进程"。这篇文章讲清楚配额、cgroups 与 OOM 处理的原理,以及怎么设置才不至于"一胖全盘皆输"。

一、先说机制:cgroups 决定"拿不拿得到资源"

cgroups(control groups)是内核用来限制和审计一组进程的资源用的花销的子系统。它分 v1/v2 两代,主流发行版已默认 cgroups v2。Docker 每次启动容器都会把它放进一组 cgroups 控制组里,分别设置 CPU、内存、PIDs 等限额。理解这点有个很直接的意义:限制不是"容器自己承诺不用更多",而是内核层面"硬性拦截",超出的内存申请会被拒绝,超限的 CPU 会被调度压低——这不依赖应用自觉,所以可靠。

资源限制还有一个微妙的点:在同一台机器上,配额的总和可以超过物理资源(超售)。这在自托管通常没问题,因为不是所有容器同时吃满。但反过来如果你给每个容器都设"无限",那它们就真的会互相抢,OOM Killer 只能随机找一个牺牲品,而不是按你的意愿踢掉最该踢的那个——所以"设配额"本质上是把"由内核随机裁决"变成"由你主动声明"。

二、在 compose 里怎么给配额

services:
  app:
    image: my/app:latest
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 2g
        reservations:
          cpus: "0.5"
          memory: 256m

关键地认识三者的区别:limits 是硬上限——内存超过就会被 OOM,CPU 超过就被限速;reservations 是软保证——让调度器给容器预留最小资源,不保证硬分配。单独 docker run 时对应 --cpus / --memory / --memory-reservation。多数场景建议只设 limits 与 reservations,不要给每个服务都开满,留一部分余量给突发流量和系统本身。

三、内存配额的真实语义:别只看 memory

memory: 2g 只限制内存本体,还要理解一个默认行为:Docker 会为容器分配等价大小的 swap(--memory-swap),也就是说"限 2g"时你真正留下的是 2g 内存 + 2g swap。若想禁止容器使用 swap,显式写 memory-swap: 2g(等于把 swap 上限拉到与内存一致)而不是只写 memory。判断内存还差多少,习惯不要看物理剩余,要看"cgroup 内的 usage——它才是容器真正占用的量"。

很多服务在无压力时占内存很少,但在一次尖峰请求里暴涨,所以"我平时看内存没满,就以为配额够",是很多自托管崩掉的根源。调节配额要基于"历史峰值 + 缓冲",而不是"平均值 + 猜"。配合监控(上一轮 Prometheus 的 cAdvisor),把每个容器的内存曲线先观察一周,再回填到 compose 里,比凭感觉设值可靠得多。

四、OOM:怎么让内核按你的意愿"砍人"

当整机内存不足时,内核 OOM Killer 要选进程杀掉。它的依据是 oom_score(谁内存占用高、谁更容易被选)以及每个进程的 oom_score_adj(主动调分)。Docker 相关参数你在 compose 常见到的是 memswap_limit,但更直接的是偏主观的一端:

services:
  db:
    image: postgres:16
    mem_limit: 1g
    oom_kill_disable: false   # 允许这个容器内的进程被 OOM 杀掉

偏好取舍是把"可有可无的缓存型服务"设成更容易被杀(例如给 oom_score_adj 较高的值),把"宁可慢也不能挂的数据库"设成更不容易被杀(oom_score_adj 调低)。这样即便真发生内存挤压,被优先牺牲的是可重建的,而不是核心数据服务。设置得上心,设置错很可能让"整机宕机"变成"把最重要的服务误杀"。

五、别忘了进程数限制:PIDs Limit

资源泄露不只是内存:一个失控的应用常常表现为"进程/线程疯狂 fork",把进程表填满,进而拖垮整机,哪怕它内存还好。给每个容器设进程上限 pids_limit: 512(compose 字段)非常便宜又有效。它的另一个价值是做护栏:避免 fork 炸弹式问题演进成"内核 pid_max 达到上限,所有容器一起卡死"的灾难场景。

六、排障与度量:如何确认配额真的生效

配额配没配对,不要靠"感觉应该生效"。三个落点:运行态看 docker stats(实时 CPU 内存);结构化看 docker inspect 的 Memory / NanoCpus 段;内核层看 cgroups 文件(cgroups v2 下 /sys/fs/cgroup/docker/<id>/memory.max 等)。要定位"是不是配额导致的问题",最好用变化对照法:在多压力下临时放大内存配额,若慢/死症状消失,基本能把范围锁在配额设计上而不是业务 bug,再回头修曲线。

结语

资源权配最核心的意义是把"内核随机裁决的抖动"变成"你可预期的行为":内存超限被拦截、CPU 超限被限速、进程数受控、OOM 时先牺牲可重建的服务。基于留存的实测曲线去填 compose 的 limits/reservations,你的 VPS 就不至于出现"一个容器吃满,全家一起入土"的雪崩。限额不是给系统"减配",而是给这台机器上每个应用划好各自的地界,让它们互不踩踏地共享这一亩三分地。


手机扫码阅读

微信或手机浏览器扫一扫,随时随地随心阅读与分享

Ollama 自托管本地大模型实战:模型部署、量化与 OpenAI 兼容 API 全解析

Gitea + Gitea Actions 自托管 CI/CD:代码托管与自动发布一把抓

评 论
更换验证码