Linux运维14 min read次阅读

一台机器被一个进程拖垮:用 cgroup v2 和 systemd 把它关进笼子

凌晨两点被告警叫醒,一台跑着十几个服务的共用机器 load 平均 80 多,SSH 连上去敲命令都卡顿半秒。top 一开,一个批处理脚本占了 31 个核里的 28 个,内存也快吃满。它本来只是个每天跑一次的报表任务,那晚上游给了异常大的输入,脚本没做分批,直接往内存里灌,fork 了一堆子进程还没回收。

第一反应是 kill -9,但杀完它又会被调度器重启(systemd 管的,Restart=always)。更糟的是,它把邻居们——包括一个核心支付接口——的 CPU 时间也挤没了。那一刻我意识到,光会杀进程没用,得从根上给这种"失控任务"套上笼子。

ulimit -u 限制进程数、ulimit -m 限制内存,听起来能防,但其实有两道坎:第一,ulimit 是登录 shell 继承的,systemd 起的服务根本不吃你手动改的值;第二,ulimit 是"建议性"边界,而且它的判定粒度粗,挡不住一个单进程把 28 个核跑满。真正能在内核层面硬隔离的,是 cgroup。

先确认你用的是 cgroup v2 还是 v1

别急着写配置,第一步先看清这台机器跑的是哪一代。cgroup v1 是"每子系统一棵独立树",cpu、memory、blkio 各自一套层级,配置分散、语义还互相打架;cgroup v2 是"统一层级",所有控制器挂在一棵树下,这是现在主流发行版(CentOS 8+/RHEL 8+/Ubuntu 20.04+/Debian 11+)的默认。老一点的 CentOS 7 默认还是 v1 混合模式,这篇文章的命令以 v2 为准——如果你还在用 7,得先切到 unified hierarchy,或者接受 v1 那套不同的文件路径。

一句话判断:

stat -fc %T /sys/fs/cgroup/
# 输出 cgroup2fs  → 你用的是 v2(统一层级)
# 输出 tmpfs      → 你大概率在 v1(或混合)上

再看当前机器上挂了哪些控制器可用:

cat /sys/fs/cgroup/cgroup.controllers
# 例如:cpuset cpu io memory pids

只要列表里有 cpumemoryiopids,后面要用的资源限制就都能做。这几样正好对应"CPU、内存、磁盘 IO、进程数"四个最容易把机器拖垮的维度。

systemd 是 cgroup v2 的"默认管理员"

很多人以为要用 cgroup 得手写 /sys/fs/cgroup/... 下的一堆文件,那是在没有 systemd 的容器或嵌入式环境里的做法。在有 systemd 的服务器上,最省事的是直接写 unit 文件的 [Service] 段,或者更灵活一点用 systemctl set-property 在线改——后者不用重启服务立刻生效,适合止血。

假设那个报表服务叫 report-job.service,先在线把它按在半核以内、内存封顶 2G:

systemctl set-property report-job.service CPUQuota=50% MemoryMax=2G

CPUQuota=50% 的意思是"最多用满半个 CPU 核的时间",不管机器有 4 核还是 64 核,它都只能拿到 0.5 核的当量。MemoryMax=2G 是硬上限,超过就被 OOM killer 在该 cgroup 内击杀——注意,是只杀这个 cgroup 里的进程,宿主机和其他服务安然无恙。这正是我们想要的:坏任务死,好任务活。

这两个属性写下去后,systemd 会把它们落到 /etc/systemd/system.control/ 下的 drop-in 片段里,重启也保留。想看生效没有:

systemctl show -p CPUQuotaPerSecUSec report-job.service
systemctl show -p MemoryMax report-job.service
# CPUQuotaPerSecUSec=50000  → 50000 微秒/秒 = 50%
# MemoryMax=2147483648

systemctl set-property 默认是立即生效且持久化的,等价于你手动去加 drop-in 文件。如果你更习惯写进 unit 文件,效果一样:

[Service]
CPUQuota=50%
MemoryMax=2G
MemoryHigh=1.5G
TasksMax=1000

这里 MemoryHighMemoryMax 温和:它是"软水位",到了 1.5G 内核就开始 aggressively 回收它的内存(比如把它的匿名页往 swap 赶),试图把它压回线内;只有越过 MemoryMax 才会直接 OOM 杀。一般我会把 High 设成 Max 的 70%~80%,给内核一点缓冲空间去柔性节流,而不是一越线就杀。

TasksMax 防的是那晚的 fork 爆炸——它限制这个 unit 能创建的进程/线程总数。默认 systemd 给服务设的上限不低(通常是 4915 或按 pids_max 的 15% 算),但一个批处理真要失控,几千个进程也够把 pid 表填满连带影响整机。设成业务合理值,比如 1000,它想 fork 爆炸时会被直接拦下。

CPU 限制的几种力度

不是所有场景都该一刀切限死。systemd 给了三档:

  • CPUWeight(默认 100):相对权重。两个服务一个设 100、一个设 50,争抢时前者拿三分之二的 CPU 时间。它不限制绝对用量,只在资源紧张时按比例分配。轻负载时大家都满速,这档最"温柔"。
  • CPUQuota:绝对上限百分比,前面用过。适合"这个任务绝不能超 X 核"的硬约束。
  • CPUAccounting + cpu.pressure:前者开启统计,后者能看这个 cgroup 的 CPU 压力(详见后文诊断)。

我自己的习惯:常驻核心服务用 CPUWeight 保证它在争抢时优先;临时批处理、测试任务、可疑的第三方 agent 一律 CPUQuota 钉死,免得它们抢占在线流量。

还有个冷门但好用的:AllowedCPUs,直接把服务绑到指定核,和 CPUQuota 配合能把" noisy neighbor"彻底隔离到某些核上,甚至给 NUMA 绑内存(AllowedMemoryNodes)。多路服务器上做性能隔离很香。

systemctl set-property report-job.service AllowedCPUs=8-15
# 只让它用第 9~16 号核

磁盘 IO 也得限,不然还是能拖垮机器

CPU 和内存限住了,很多人就松口气,但 IO 失控同样能要命:一个服务疯狂读盘,把磁盘队列打满,其他服务连日志都写不进去,表现就是"CPU 不高、内存够、但全体卡死"。cgroup v2 的 io 控制器能按设备限速。

先看这台机器磁盘对应的设备号:

ls -l /dev/sda
# brw-rw---- 1 root disk 8, 0 ...  → 主设备号 8,次设备号 0

然后限速,比如限制这个服务对 sda 的写带宽不超过 20 MB/s:

systemctl set-property report-job.service \
  IOWriteBandwidthMax="/dev/sda 20M" \
  IOReadBandwidthMax="/dev/sda 50M"

IOWeight(默认 100)则是相对权重,争抢时按比例分 IO 带宽,和 CPUWeight 一个思路。要注意:IO 限速依赖内核的 BFQ 或 mq-deadline 调度器才能精确生效,如果用的是 none(某些云盘默认),带宽限制可能变成"尽力而为"而非硬上限。生产上我会先 cat /sys/block/sda/queue/scheduler 确认调度器,再决定靠 Weight 还是 BandwidthMax。

临时任务不想写 unit?用 systemd-run 起个 scope

有些活儿不是服务,是手工跑的一次性命令,比如 SELECT ... INTO OUTFILE 导数据、或者临时解压个大包。给这种"一次性"任务套限制,不用专门写 service 文件,用 systemd-run 起一个 transient scope 就行:

systemd-run --scope -p MemoryMax=4G -p TasksMax=500 \
  -p IOWriteBandwidthMax="/dev/sda 30M" \
  ./big_export.sh

这个 scope 会作为一个独立的 cgroup 存在,命令跑完自动销毁,期间它越界就被单独处理。我常用它跑那些"心里没底、怕它吃资源"的临时脚本,相当于给命令套个一次性笼子,比 nohup 裸跑安心太多。

出事后怎么确认是 cgroup 兜住了底

加完限制,最该验证的是:真的把坏影响圈在笼子里了吗?cgroup v2 提供了 PSI(Pressure Stall Information,压力阻塞信息),能直接看到某个 cgroup 在 CPU、内存、IO 上"等资源等到花了多少时间"。这是比 top 更底层的诊断信号。

# 看整个系统各级压力
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io

# 看某个具体 cgroup 的压力(路径按 systemd 的 slice 来)
cat /sys/fs/cgroup/system.slice/report-job.service/cpu.pressure
cat /sys/fs/cgroup/system.slice/report-job.service/memory.pressure

some avg10=... 那一行表示"过去 10 秒里,至少有一些任务在等资源的比例"。如果某个服务的 io.pressure 的 avg60 长期 90%+,说明它一直在和磁盘较劲,即使没把整机拖垮,它自己也已经半残——这时候限速值该往下调。

实时监控可以用 systemd-cgtop,类似 top 但按 cgroup 聚合,能直接看到每个 service 的 CPU、内存、IO 占用:

systemd-cgtop

它比按进程看更接近"这个服务到底吃了多少资源"的真相,特别适合多实例混部时定位哪个 unit 是祸首。

和容器的关系:Delegate=yes 是个坑

如果你用 Docker 或 Podman 跑容器,容器运行时(dockerd / podman)本身就是一个被 systemd 管理的 service。默认情况下 systemd 不会把 cgroup 的"管理权"下放给容器运行时,导致容器内部再设自己的 cgroup 限制时层层套娃、甚至失效。解决方法是给容器运行时的 unit 加 Delegate=yes

# /etc/systemd/system/docker.service.d/delegate.conf
[Service]
Delegate=yes

加了之后,dockerd 能在自己的 cgroup 下再建子树,容器里 docker run --cpus=1 --memory=1g 的限制才能真正落到 cgroup v2 上。我吃过这个亏:没加 Delegate 时,容器内的 --memory 限制形同虚设,容器照样能吃光宿主内存——因为 systemd 把容器进程的 cgroup 归到了自己的统一树里,容器运行时改不了。

顺带一句,kubernetes 的 kubelet 在 v2 环境下也是靠这套机制给每个 pod 建 cgroup,原理完全一致,只是由 kubelet 自动管理。理解了 systemd + cgroup v2,再看 k8s 的 QoS 和 Limit/Request,会觉得那层抽象其实就是给这套内核能力包了层皮。

一个常被忽略的顺序问题

set-property 在线改是立刻生效的,但如果你同时改了 unit 文件又跑了 set-property,两者会叠加还是覆盖?答案是 set-property 写的是 drop-in 片段(/etc/systemd/system/<svc>.service.d/),unit 文件本体里写的也会被读取——systemd 会合并,drop-in 优先级更高。所以干净的运维做法是:止血时用 set-property 立刻压住,事后把同样的值写进正式的 unit 文件(或 drop-in),再 systemctl daemon-reload,保证下次部署、下次装机都默认带这层限制。别只靠运行时临时设,机器一重装就回到了没有笼子的状态。

那晚之后,我把所有"非核心、可失控"的任务——报表、数据导出、第三方 agent、测试环境服务——统一加了一层 Resource Control 基线:CPUQuota 不超过 2 核、MemoryMax 按历史峰值的 1.5 倍留余量、TasksMax 设业务上限、对数据盘 IO 限速。核心在线服务反而用 CPUWeight 保优先级,而不是硬限死。半年里又出过两次上游数据异常的类似事故,那台机器 load 最高只到 12,核心接口响应没掉出 SLA。笼子这东西,平时觉得多余,真出事那天它就是那道防火墙。

分享:

相关文章

Too many open files:ulimit、LimitNOFILE 与 fd 泄漏的完整排查链路
Linux运维12 min read

Too many open files:ulimit、LimitNOFILE 与 fd 泄漏的完整排查链路

服务半夜开始报错 accept4() failed (24: Too many open files),或者 Nginx 频繁 502。这种故障十次有八次是文件句柄(fd)耗尽,但光改 ulimit -n 往往没用——因为 systemd 起的服务根本不吃 limits.conf 那套。从内核 file-max 到进程 LimitNOFILE,到定位到底是哪个进程在漏 fd,把每一层都讲透,并给出不停机临时救急和永久修复两条路。

评论区