你有没有遇到过这种情况:明明只是部署了一个 agent 服务,结果它在高峰期把整台机器的 CPU 吃满,内存一路涨到 swap 都救不回来,最后把同机上的数据库、网关、日志采集全部拖垮。我在生产环境里踩过这个坑,而且不止一次。后来我把所有 agent 的启动方式统一收编到 systemd 下,用 cgroups v2 对 CPU、内存、IO 逐项加锁,才算真正把资源失控这件事摁死。这篇文章就把整套思路、配置细节、验证方法和排查心得完整写出来,给同样被 agent 资源问题折磨过的人一个可以直接抄作业的方案。
1. 先想清楚:agent 到底该怎么约束
1.1 你跑的 agent 属于哪一类
"agent"这个词现在确实太宽泛了。我在实际接触中至少见过三类:
- AI agent 服务:比如跑在服务器上的多智能体协作框架、定时触发的推理任务,特点是并发高、计算密集、上下文缓存吃内存,高峰期 CPU 和内存曲线像过山车。
- 监控采集 agent:比如负责抓指标、采集日志、做健康检查的常驻进程,特点是平时负载低,但遇到大量文件扫描或网络轮询时,会把 CPU 和 IO 突然拉高。
- 自动化任务 agent:比如定时脚本、消息处理 worker、爬虫任务,特点是线程池容易膨胀,任务堆积时可能创建出几千个线程。
不同 agent 的资源模型不一样,但约束手段是同一套:在操作系统层面给进程组划一条清晰的资源边界。我的思路很简单——不指望 agent 自己写得足够收敛,而是假设它有失控的可能,从外部给它套上笼子。
1.2 为什么是 systemd 加 cgroups v2,而不是改 agent 代码
很多人第一反应是优化 agent 代码,比如加限流、控制并发数、调整缓存大小。这当然能做,但有几个现实问题:agent 可能是第三方开源的,你改不到核心代码;即使能改,你不可能预测到所有极端场景;而且代码里的自控逻辑一旦写错,反而引入新的 bug。
相比之下,systemd 加 cgroups v2 这套组合有几个别人比不了的优势:
- systemd 是几乎所有现代发行版的 init 系统,天然管理服务生命周期。把 agent 写成 systemd unit,开机自启、崩溃重启、日志管理全都顺带解决。
- 资源限制直接在 unit 配置里声明,不需要额外装工具,不需要改业务代码。
- cgroups v2 是内核级约束,agent 内部的垃圾回收、线程池、缓存模块完全感知不到,也无法绕过。
- 配置即代码,把 unit 文件放进 Git 仓库,环境之间迁移非常干净。
1.3 cgroups v2 相比 v1 到底改了什么
cgroups v2 不是简单换个挂载点,它有几处设计变化直接影响使用方式:
- 统一层级树:v1 时代每个子系统一个挂载点,cpu、memory、blkio 各自为政;v2 统一挂载在 /sys/fs/cgroup,所有控制器都在同一棵树上,管理逻辑清爽很多。
- 内部进程约束:一个 cgroup 目录要么直接包含进程,要么包含子目录,不能既跑进程又建子目录。这个约束让资源统计更干净,不会出现父子混在一起算不清账的情况。
- 控制器合并:原来独立的 cpu 和 cpuacct 合并成 cpu 控制器,内存控制器也做了大量重构,统计口径更统一。
- systemd 深度集成:每个 unit 服务自动落在 /sys/fs/cgroup/system.slice/xxx.service 目录下,你不需要手动创建 cgroup,改 unit 配置加参数就行。
这里面最需要注意的是"内部进程约束"。在 v2 里,你的 agent 进程如果直接跑在一个 cgroup 目录里,你就不能往它下面再挂子目录;反过来,如果有子目录,你就不能直接往里放进程。实际操作中 systemd 会帮你处理好,但如果你手动建 cgroup 调试,很容易被这个规则绊一下。
2. 动手前的地基检查:cgroup 版本与 systemd 环境
2.1 确认当前系统挂载的是 v2 还是 v1
很多教程上来就写配置,结果读者配置完发现参数没生效,十有八九是系统还跑在 cgroups v1 上。所以第一步永远是确认版本。
最简单的命令:
mount | grep cgroup如果你看到的是:
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)说明已经启用 cgroups v2,可以直接继续。如果你看到一堆 cgroup 挂在 /sys/fs/cgroup/cpu、/sys/fs/cgroup/memory 这种子目录,那就是 v1。
还有一种方式,看 /sys/fs/cgroup 目录里有没有 cgroup.controllers 这个文件:
cat /sys/fs/cgroup/cgroup.controllers有输出类似 cpu memory io 这样的列表,就是 v2。这个文件在 v1 里不存在。
2.2 还在 v1 的老系统如何切换
如果你的系统还在 v1,并且内核支持 v2,可以通过内核参数切换。编辑 /etc/default/grub,在 GRUB_CMDLINE_LINUX 里加上:
systemd.unified_cgroup_hierarchy=1 cgroup_no_v1=all然后重新生成引导配置:
# Debian/Ubuntu update-grub # CentOS/RHEL grub2-mkconfig -o /boot/grub2/grub.cfg重启后确认挂载情况。切换前一定要评估一下:机器上是否有老程序硬编码依赖 v1 的路径,比如 /sys/fs/cgroup/memory/memory.limit_in_bytes。这类老工具切到 v2 后可能不工作,需要一并升级。
2.3 systemd 版本和 agent 服务的 unit 归属
systemd 对 cgroups v2 的完整支持在 246 版本之后才比较稳,建议至少 250 以上。查看版本:
systemd --version另外,你要确认 agent 进程到底落在哪个 cgroup 下面:
cat /proc/<pid>/cgroup如果输出是:
0::/system.slice/agent.service说明它由 systemd 的 system 用户管理,资源限制可以直接通过 unit 参数生效。
如果输出是 user.slice 之类,说明 agent 是用手动 nohup 或者用户会话方式启动的。这种情况下你想用 systemd 限制,最好先把启动方式改成 systemd service,否则后面配置都是空谈。这也是我坚持"先收编再限制"的原因——运行方式不规范,限制就无从谈起。
3. 核心配置实操:从 CPU 到内存再到 IO,逐层加锁
3.1 CPU 限制:CPUQuota 与 CPUWeight 怎么选
CPU 限制有两个核心参数,很多人搞混。
- CPUQuota:硬性配额,格式是百分数。CPUQuota=100% 代表最多使用 1 个核,CPUQuota=300% 代表最多使用 3 个核,CPUQuota=50% 代表最多使用半个核。它是真正意义上的上限,不管系统多空闲,agent 都不能超过这条线。
- CPUWeight:相对权重,范围 1 到 10000,默认 100。它管的是"系统繁忙时大家怎么分 CPU",权重越高,竞争时分配到的份额越大。系统空闲时,权重高的进程照样能跑满。
对 agent 来说,我推荐优先用 CPUQuota。原因是防的就是它高峰时吃满 CPU,不是想让它在低负载时让位。给 AI agent 类服务我一般这样配:
CPUQuota=300% CPUAffinity=0-3CPUQuota 是上限,CPUAffinity 是把进程绑定到指定 CPU 核心。绑定核心的好处是减少缓存抖动,尤其适合计算密集的 agent;坏处是核心不足时可能浪费算力,所以要不要绑得看机器情况。
你可能会问:那 CPUWeight 完全没用吗?也不是。如果你的机器上同时跑着多个同等重要的 agent,你希望它们大致公平地分享 CPU,而不想给某个硬上限,那 CPUWeight 更合适。实际项目里我是两者都写:CPUQuota 定死天花板,CPUWeight 控制竞争优先级。
3.2 内存限制:MemoryHigh 和 MemoryMax 的配合
内存参数里最常用的是 MemoryMax 和 MemoryHigh,含义差别很大。
MemoryMax 是硬上限,比如 MemoryMax=2G。agent 使用的内存一旦超过这个值,内核就会触发 OOM,在对应 cgroup 里挑进程杀掉。这是保命线,不能没有。
MemoryHigh 是软上限,比如 MemoryHigh=1.5G。超过之后内核不会立刻杀进程,而是加大内存回收力度,把这个 cgroup 的页缓存、可回收内存先压出来。如果 agent 自身有缓存清理或 GC 机制,它就会在接近 MemoryHigh 的地方开始自我调节。我把这一层比喻成"财务预算"——超过预算后开始节省开支,但不会直接冻结账户。
生产环境里我建议两个配合使用:
MemoryHigh=1.5G MemoryMax=2G只设 MemoryHigh 不设 MemoryMax 的问题在于:如果 agent 存在真正的内存泄漏,回收也兜不住,最终会把宿主机内存吃光。只设 MemoryMax 不设 MemoryHigh 的问题在于:agent 会毫无预警地撞墙被 OOM,频繁重启反而更不稳定。
另外还要关注 Swap。默认情况下 cgroup 的交换空间没有单独限制,agent 可以把 swap 也吃满。可以用 MemorySwapMax 单独限制:
MemorySwapMax=512M这里有一个比较容易踩的认知误区:cgroup 的 memory 统计是包含页缓存(page cache)的。agent 如果大量读文件,它的 memory.current 会很高,但那些页缓存是可以在内存压力下回收的。所以别一看到内存占用超过 MemoryMax 就以为泄漏了,先看 memory.events 文件里的 oom 字段是否在涨。
3.3 IO 限制:防止 agent 打满磁盘带宽
CPU 和内存是大家最先想到的,IO 往往被忽略,但 agent 打满磁盘的问题一点都不少见。典型场景是:多个 agent 实例同时启动,各自读取模型文件、写日志、写缓存目录,瞬间把磁盘 IO 拉满,整个宿主机上的服务全部卡顿。
cgroups v2 的 IO 控制通过 systemd 配置时,核心是两个方向:
IOWeight=20 IOReadBandwidthMax=/dev/sda 100M IOWriteBandwidthMax=/dev/sda 50MIOWeight 是权重,默认 100,范围 1 到 10000。性能敏感型服务可以给到 200 以上,不重要的 agent 给 10 到 50。IOReadBandwidthMax 和 IOWriteBandwidthMax 是按设备名限制读写带宽,属于硬上限。
注意一点:设备名要写对。用 lsblk 查看实际设备名,有些云主机上是 /dev/vda,有些是 /dev/nvme0n1,别照抄我这边的 /dev/sda。
还有一点需要提醒:cgroups v2 的 IO 控制器不是在所有文件系统上都生效,而且某些内核配置下 io 控制器可能没有启用。排查方式就是看 /sys/fs/cgroup/cgroup.controllers 里有没有 io 这个关键字。没有的话,你的 IOWeight 和带宽限制参数会被 systemd 接受,但实际不会起作用。
3.4 额外防线:TasksMax 与进程数量控制
agent 用线程池时特别容易疯狂创建线程。我见过一个 Python agent 在任务积压时线程数涨到 5000 多,直接把整个机器拖死。这个用 TasksMax 就能兜底:
TasksMax=512TasksMax 限制的是 cgroup 里的任务数,也就是线程和进程的总数。考虑到 agent 本身的线程池、异步任务、子进程,512 是个比较稳的起步值;如果实际业务并发需求高,可以放宽到 1024。
除了 TasksMax,systemd 还有一批安全加固参数,虽然不是资源限制,但对 agent 这类不可信服务非常有用。我在这类 unit 里一般会带上去:
PrivateTmp=true ProtectSystem=strict ProtectHome=true NoNewPrivileges=true这些参数主要解决 agent 的安全问题,同时也有副带好处:防止 agent 把自己或者临时文件写到系统的不可控位置,间接避免 IO 污染。
3.5 一份可以直接抄的 agent.service 完整配置
把前面的参数合成一个完整 unit 文件。我目前在生产环境用的模板是这样的:
[Unit] Description=My AI Agent Service After=network-online.target Wants=network-online.target [Service] Type=simple User=agent-runner Group=agent-runner ExecStart=/opt/agent/run-agent Restart=on-failure RestartSec=5 TimeoutStopSec=30 # CPU CPUQuota=300% CPUWeight=20 # Memory MemoryHigh=1.5G MemoryMax=2G MemorySwapMax=512M # IO IOWeight=20 IOReadBandwidthMax=/dev/sda 100M IOWriteBandwidthMax=/dev/sda 50M # Tasks TasksMax=512 # Security PrivateTmp=true ProtectSystem=strict ProtectHome=true NoNewPrivileges=true [Install] WantedBy=multi-user.target把这个文件放到 /etc/systemd/system/agent.service,然后执行:
systemctl daemon-reload systemctl enable --now agent注意两个细节。一是 User=agent-runner 必须提前创建好,而且建议用独立账号跑 agent,不要直接用 root。这样即使 agent 内部出了问题,也没有权限去修改 cgroup 限制,等于把资源锁和权限锁一起上。二是 CPUAffinity 我没有写进模板,因为不是所有机器都适合绑核,如果你确定要绑定,自己加一行即可。
4. 验证、压测与动态调优
4.1 实时观测:systemd-cgtop 和 systemctl show
配置改完了,第一件事是确认它真的生效。看状态最常用的两个工具:
systemd-cgtop它会实时显示每个 slice/service 的 CPU、内存、IO 占用,效果类似 top,但是站在 cgroup 视角汇总数据。你一眼就能看到 agent.service 当前吃了多少资源,以及它离你设置的上限还有多远。
另一个命令是查询 unit 实际生效的参数:
systemctl show agent.service -p CPUQuota -p MemoryMax -p MemoryHigh -p TasksMax -p IOWeight输出里的值会告诉你 systemd 到底给这个服务下了什么配置。注意,如果某些参数显示为空,说明 systemd 认为这个参数不适用,或者值没有配置成功。
4.2 用压力工具验证限制是否真的生效
配置生效与否,不要光看参数,要实际压一把。我常用的压测方法是:
# 在 agent 服务里跑一段压力的临时命令 systemctl stop agent systemd-run --unit=agent-pressure-test --service-type=simple \ --property=CPUQuota=200% --property=MemoryMax=512M \ --property=IOWriteBandwidthMax="/dev/sda 10M" \ bash -c "stress --cpu 4 --timeout 60 & dd if=/dev/zero of=/tmp/testfile bs=1M count=200 oflag=direct"当然更简单的方式是直接重启 agent,让它跑真实业务,然后盯着 systemd-cgtop 看曲线。如果 CPU 曲线精确卡在配额上,说明限制生效;如果明显超过配额,那就要排查是不是运行方式和预期不一致,或者配置没加载。
对于内存限制,可以这样验证:写一个小脚本读取大文件并把内容放进内存,观察到达上限后是否被 OOM。
4.3 读懂 cgroup 状态文件:memory.events 与 cpu.stat
这是排查资源问题最硬核的手段,也是常规教程基本不会讲的部分。systemd 创建的每个 unit 在 /sys/fs/cgroup/system.slice/agent.service/ 下都有状态文件。重点看两个:
cat /sys/fs/cgroup/system.slice/agent.service/memory.events输出类似:
low 0 high 137 max 5 oom 2 oom_kill 1这里的 high 表示 MemoryHigh 被触发的次数,max 表示 MemoryMax 被触发的次数,oom 和 oom_kill 表示发生过 OOM 及实际杀进程的次数。如果你的 high 增长很快,说明 MemoryHigh 设置得太紧;如果 max 有增长,说明内存已经撞硬墙了;如果 oom_kill 有增长,说明已经有进程被杀,这在生产环境是重大事故信号。
CPU 要看 cpu.stat:
cat /sys/fs/cgroup/system.slice/agent.service/cpu.stat重点关注 usage_usec,它表示该 cgroup 累计使用 CPU 的微秒数。你可以在一个压测周期前后各读一次,差值除以时间,就能算出实际 CPU 占用百分比,用来验证 CPUQuota 是否真的拦住。
4.4 线上动态调整:systemctl set-property
资源限制不一定要停服才能改。systemd 支持热修改资源参数:
systemctl set-property agent.service MemoryMax=3G systemctl set-property agent.service CPUQuota=400%这会即时生效,并且会把配置持久化写到 /etc/systemd/system.agent.service.d/ 下。我踩过一个小坑:用 set-property 调整之后,如果后来直接编辑主 unit 文件,两者可能产生覆盖关系,行为不好预测。所以我的建议是:主 unit 文件保存稳定基线,临时调优统一用 set-property,或者统一用 drop-in override 文件管理,避免两套配置混在一起打架。
5. 踩坑记录与排查速查表
5.1 限制不生效的五个常见原因
我排查过不少"配置了没用"的案例,最常见的原因排序如下:
- 系统还跑在 cgroups v1 上。这是最大概率的原因,很多人直接跳过环境检查。
- agent 不是由 systemd 启动的。如果你用 crontab 或者 nohup 起进程,systemd unit 里的所有资源参数都和它无关。
- 修改配置后没有 daemon-reload,或者没有重启服务。systemd 资源参数在启动服务时固化到 cgroup 里,修改后只 reload 不 restart 是不行的。
- 参数写错了位置。比如把 MemoryMax 写到了 [Unit] 段而不是 [Service] 段,systemd 解析时会忽略。
- 被 drop-in 配置覆盖。用 systemctl cat agent.service 查看最终生效的配置,如果和预期不一致,多半是 /etc/systemd/system/agent.service.d/ 下有覆盖文件。
5.2 no internal process 约束导致的奇怪现象
cgroups v2 的层级规则比较死板:一个 cgroup 目录如果已经有进程在里面,就不能再创建子目录;如果已经有子目录,就不能直接往里面放进程。我第一次手动调试时,想给 agent 的 cgroup 下创建一个子 cgroup 来隔离子进程,结果 mkdir 直接返回 -ENOMEM。
后来才明白这是 v2 的设计约束,目的是保证资源统计没有歧义。实际使用 systemd 时,这条规则一般不会影响你;但如果你有"在一个服务里再分 cgroup"的需求,需要换思路,用 systemd-run 在现有服务的 pids 层级下再创建 scope,而不是手动去改 cgroup 目录。
5.3 agent 频繁 OOM 怎么办
如果 memory.events 里 oom_kill 一直在涨,先别急着加大 MemoryMax。正确排查顺序是:
- 看日志:journalctl -u agent.service 会输出内核 OOM 的具体记录,确认是不是 agent 主进程被杀。
- 看内存构成:进入 cgroup 目录执行 cat memory.stat,区分 anon(匿名页,真正的进程数据)和 file(页缓存,可回收)。如果 file 占比很高,说明不是泄漏,而是缓存占用太多,可以适当降 MemoryHigh,或者调低读取缓存行为。
- 确认 agent 是否存在内存泄漏:连续观察 memory.current 和 memory.events 的 high 次数,如果每轮任务后内存不下降,那就是泄漏,这种情况加不加 MemoryMax 都治标不治本,该修的还得修。
5.4 agent 线程/进程数失控
TasksMax 能拦住线程数,但如果你没设这个参数,systemd 默认值是很大的。我的习惯是任何 agent unit 必须显式设置 TasksMax,不能依赖默认。
不过要提醒一点:进程数限制对你的排查工作也有影响。如果 agent 控制的子进程很多,而且你怀疑某个子进程异常,可以这样查:
systemd-cgls -u agent.service for pid in $(cat /sys/fs/cgroup/system.slice/agent.service/cgroup.procs); do echo "PID $pid: $(cat /proc/$pid/comm)" donecgroup.procs 文件会列出这个 cgroup 里所有进程,排查线程和子进程特别方便。
5.5 排查速查表
| 症状 | 排查命令 | 常见原因 |
|---|---|---|
| 所有限制不生效 | mount | grep cgroup | 系统仍在 cgroups v1 |
| CPU 上限未拦住 | cat cpu.stat 前后差值 | 进程不属于该 cgroup |
| 内存频繁被杀 | cat memory.events | 页面缓存占比过高或内存泄漏 |
| IO 限制无效 | cat cgroup.controllers | io 控制器未启用 |
| 线程数失控 | wc -l cgroup.procs | 未设置 TasksMax |
| 配置显示生效但行为不对 | systemctl cat agent.service | 存在 drop-in 覆盖文件 |
| 手动建子目录报错 | mkdir 返回 ENOMEM | v2 的 no internal process 约束 |
5.6 关于"agent 自己改配置"的防御思路
cgroups v2 的 cgroup.procs 和 cgroup.subtree_control 等文件,写入权限受严格限制。非 root 用户的 agent 无法修改自己所在 cgroup 的资源配置,所以让 agent 以独立非 root 用户运行,本身就是一道有效的资源安全锁。
如果你管理的是不可信 agent,我建议再把 cgroup 相关目录的写权限再收一收,同时配合 systemd 的 ProtectSystem=strict 和 NoNewPrivileges=true。这套组合打下来,agent 既不能改自己的资源上限,也不能通过 sudo 之类的方式提权,属于比较稳妥的防护姿势。
写在最后的经验
我从第一次被 agent 搞挂生产环境到现在,已经形成一套固定习惯:任何 agent 类服务,落地第一步就是写 systemd unit,CPU、内存、IO、任务数四项限制全部显式声明,不允许裸奔。你可能会觉得加限制会影响 agent 性能,实际情况是,一个被资源边界约束的 agent 只是不能无限膨胀,正常业务流量下它该跑多快还是多快。
另外,限制参数不是写完就完事的。我每次升级 agent 版本或调整业务规模后,都会重新看一眼 systemd-cgtop,如果 memory.events 的 high 次数持续上升,说明资源水位贴近了,提前调参数比线上 OOM 再救火舒服得多。最后分享一个小技巧:把 systemd-cgtop 的输出做成定时采集,丢到现有监控系统里画曲线,资源趋势有了数据支撑,调参时就不用靠猜,这套流程跑顺之后,agent 的资源问题基本可以不用再操心了。