systemd如何限制进程内存和CPU使用量
进程触及MemoryMax后仍可能拖慢整个VPS。本文配置MemoryHigh、MemoryMax、CPUQuota和TasksMax,并检查OOM终止及内存抖动。
使用 systemd drop-in 限制进程内存和 CPU
您可以通过向运行该进程的 unit 添加几行配置,限制 Linux VPS 上进程使用的内存和 CPU。MemoryMax= 是内存硬上限。CPUQuota= 是处理器时间上限。两者都由 cgroup v2(控制组,第 2 版)强制执行。cgroup v2 是 systemd 已用于统计服务器上每个服务资源使用情况的内核功能。
sudo systemctl edit myapp.service这会打开一个带有注释说明的 drop-in 文件。在这些注释上方添加以下内容:
[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMaxsystemctl show 必须使用内核自身的单位回显您设置的数值:MemoryMax=805306368 和 CPUQuotaPerSecUSec=800ms。如果输出 MemoryMax=infinity,说明 drop-in 从未加载。请检查文件是否已写入 /etc/systemd/system/myapp.service.d/override.conf,并确认文件以 [Service] 标头开头。因为设置行上方没有 section 时,systemd 会记录 Assignment outside of section. Ignoring.,并在完全不设限制的情况下启动服务。
本指南的其余部分将介绍如何选择这些数值,以及设置限制后仍可能出现哪些问题。
为什么失控进程不会耗尽内存,却会冻结 VPS
达到硬内存上限的进程通常会在约 1 秒内退出,服务随后重新启动。这是理想情况。糟糕的情况是没有任何进程退出:服务器仍能响应 ping,SSH 也接受连接,但始终不会出现 shell 提示符。机器仍在运行并忙于处理任务,但这些工作没有实际作用。
其机制并不直观。可用内存不足时,内核会回收页面,而不是分配新页面。最容易回收的是文件支持的页面,页面缓存中保存着所有运行中程序的可执行代码。因此,内核会驱逐 sshd 的代码页,而它接下来要执行的指令 sshd 会触发缺页异常,必须从存储设备中重新读取这些字节。最终,每个进程都在等待磁盘,而不是执行代码。同一批页面不断被换出又换入,这种情况称为抖动。
相比笔记本电脑,VPS 上有两点会使问题更加严重。VPS 的存储通常通过网络连接或由多个租户共享,因此每次缺页所需的时间都比本地 NVMe 设备更长。内核衡量的不是耗时,而是是否发生失败:只要页面回收仍能返回页面,即使速度很慢,内核就会认为系统仍在取得进展,不会调用内存不足(OOM)终止程序。服务器可能在这种状态下持续数分钟,之后才有进程被终止。
您可以观察这一过程。Linux 4.20 及更高版本会提供压力停滞信息(PSI):
cat /proc/pressure/memory
cat /proc/pressure/iosome avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233其中 full 行最重要。full avg10=48.15 表示在过去 10 秒内,服务器上所有可运行任务有 48% 的时间都在等待内存相关操作,因此没有任务实际运行。健康服务器在 full 上的数值应接近 0。超过 10 时,人可以明显感觉到系统变慢;达到 40 或更高时,就是通常所说的冻结状态。
这也是为什么单独设置限制并不能构成保证。受 MemoryHigh= 约束的单元会被限速,而不是被终止,因此它会继续运行但持续缓慢;从 systemd 的角度看,它从未失败,所以也不会重新启动。仍允许交换的受限单元会产生由该单元计费、但由同一个共享设备处理的读写操作,因此可能使服务器上其他所有服务的 /proc/pressure/io 都升高。限制只能决定由谁承担资源短缺的代价,不能增加系统容量。
确认 VPS 运行的是 cgroup v2
stat -fc %T /sys/fs/cgroupcgroup2fs 是统一层次结构,下面的所有设置都需要它。tmpfs 表示系统以较旧的 v1 布局启动,此时不存在 MemoryHigh= 和 MemorySwapMax=,每个单元的 OOM 行为也不同。Ubuntu 22.04 及更高版本,以及 Debian 11 及更高版本,默认使用 v2。旧镜像或使用 systemd.unified_cgroup_hierarchy=0 启动的内核则不会使用 v2。
在 cgroup v2 系统中,systemd 默认会为每个单元启用内存统计,因此这些数值已经存在:
systemd-cgtop -m该命令会按内存使用量对 cgroup 排序。这是服务器仍能响应时,最快确定“是什么占用了这台服务器”的方法。如果服务器刚创建不久,应先完成 新 VPS 的前十分钟中的账户和防火墙配置。
MemoryHigh 会限流。MemoryMax 会终止进程。
这两个内存设置的区别决定了故障的表现形式。
MemoryHigh=是软上限。超过该值后,内核会积极回收该 cgroup 的资源,并有意降低其内存分配速度。使用量仍可超过该值,但不会终止任何进程。MemoryMax=是硬上限。当无法在该上限内满足内存分配请求时,OOM killer 会在该 cgroup 内运行,并终止该单元自己的某个进程。
第二点正是应在任何不完全信任的服务上设置 MemoryMax= 的真正原因。未设置上限时,内存不足会影响整台主机,全局 OOM killer 会根据 oom_score 选择终止对象,这通常意味着选择占用内存最多的进程。占用内存最多的通常是数据库,而不是发生内存泄漏的脚本。设置上限后,终止操作会发生在导致问题的单元内。
同时设置两者,并让 MemoryHigh= 比 MemoryMax= 低约 20 到 30%。两者之间的差值是警告区间:缓慢的内存泄漏会先超过 High,表现为服务运行变慢;突然的峰值则会直接超过 Max,使进程被终止。
百分比值以已安装的物理内存为基准计算。因此,在 4 GB 方案上,MemoryMax=25% 表示 1 GB;扩容方案后,它仍然占整台主机的四分之一。MemorySwapMax=0 会让该单元完全不使用 swap,将长时间卡顿变成快速且明显的进程终止。
设置上限时,还应同时配置重启策略,否则进程被终止后只会留下一个已停止的服务。
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* 应放在 [Unit] 中,Restart= 应放在 [Service] 中。将任一设置放在错误的部分,systemd 都会忽略它。5 分钟内重启 5 次说明存在内存泄漏,而不是暂时波动。因此,达到该次数后,systemd 会放弃重启并将单元标记为 failed。这样便于之后发现问题,而不是让崩溃循环掩盖问题。
使用 CPUQuota 限制 CPU,或使用 CPUWeight 共享 CPU
CPUQuota= 表示占用单个 CPU 可用时间的百分比。CPUQuota=50% 相当于半个核心。CPUQuota=200% 相当于两个核心,单元可以将其分配到任意数量的线程上。在 2 vCPU 方案中,CPUQuota=200% 相当于整台机器。
对大多数服务来说,CPUWeight= 是更合适的默认设置。它表示 1 到 10000 之间的相对权重,内核默认值为 100。只有在存在竞争时,该设置才会生效:负载较高时,CPUWeight=20 的备份任务会让出 CPU 给权重为 100 的 Web 服务器;机器空闲时,备份任务仍可使用整台机器的 CPU。硬配额会浪费这部分空闲容量。
应准确理解 CPU 限制的作用。CPU 密集型进程通常不会让 Linux 卡死,因为调度器会继续为其他进程分配时间。导致机器宕机的通常是内存不足。需要可预测的上限时,使用 CPUQuota=,例如限制构建任务,或限制原本会连续满负载运行一小时的代理程序。这类工作负载的资源规划需要单独考虑,详见编码代理 VPS 需要多少 RAM 和 CPU。
如果 CPU 显示繁忙,但您的进程都没有明显占用资源,原因可能在虚拟机监控程序的另一侧。这称为来自繁忙邻居的 CPU steal time,您设置的任何配额都无法改变这种情况。
TasksMax 可阻止 fork 循环
TasksMax= 表示一个单元可以持有的进程和线程数。线程也计入其中,因此 Java 或 Go 服务需要的余量会比进程列表显示的更多。这是防止脚本循环 fork 的最低成本措施,因为 fork 会在该单元内失败,而不是耗尽整台服务器的进程 ID。
TasksMax=128当单元达到限制时,内核会记录一行包含该 cgroup 名称的日志:
cgroup: fork rejected by pids controller in /system.slice/myapp.service程序本身通常会报告 fork: retry: Resource temporarily unavailable。使用 systemctl show -p DefaultTasksMax 检查管理器默认应用的值。
使用 systemd-run 限制一次性任务
使用这些功能不需要 unit 文件。systemd-run 会围绕单个命令创建一个临时 unit。
sudo systemd-run --scope -p MemoryMax=1G -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=64 ./import-data.sh--scope 会在打印 Running scope as unit: run-r7c1a....scope 后,在当前终端中运行命令。输出会显示在屏幕上,命令退出后限制也会消失。systemd.resource-control 中的任何属性都可以在 -p 后使用。
对于长时间运行的任务,去掉 --scope 并为其指定名称。任务随后会作为临时服务在后台运行,日志会写入 journal:
sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -f以非 root 身份运行时,--user 也支持相同的选项。但您的用户管理器只能使用分配给它的控制器,因此某个属性可能会在那里被拒绝。发生这种情况时,请使用 sudo 运行。任务需要永久配置时,可将这些设置原样移入正式 unit:参见将脚本作为 systemd 服务和计时器运行。
Swap 问题:如实回答
Swap 会改变故障的表现形式,但不会阻止故障。
没有 Swap 时,内存泄漏会触及上限,并在数秒内导致某个进程退出。服务中断会很明显,持续时间也很短,事后查看 journal 时通常容易判断原因。启用 Swap 后,内核会将不常访问的匿名页写入磁盘,从而争取时间。如果进程最终会趋于稳定,Swap 可以避免故障。如果进程会持续失控,Swap 会把 5 秒的中断变成 20 分钟的卡顿,而且卡顿更糟,因为进程退出后,您仍然可以使用 shell;但发生颠簸的系统连 shell 都无法正常使用。
swapon --show
free -h对于小型 VPS,一种可行的折中方案是:保留一个适度大小的 Swap 文件,用于存放只分配一次且之后不再访问的页面;同时在您可以接受其退出的单元上设置 MemorySwapMax=0。重要服务继续使用 Swap。不稳定的服务则快速触及限制并重启。
降低 vm.swappiness 的作用有限,值得了解原因。它只会调整回收页缓存和交换匿名页之间的平衡,而这两种方式之后都需要读取磁盘。它改变的是发生颠簸的页面,而不是系统是否会发生颠簸。
早期 OOM 守护进程可在系统卡顿前终止进程
内核会一直等待内存回收彻底失败。对于小型 VPS,这段等待时间正是主机失去响应的窗口。两个用户空间守护进程会自行监控内存,并更早终止进程,从而缩短这个窗口。
earlyoom监控可用内存和可用 swap,并在任一指标低于阈值时终止评分最高的进程。
sudo apt install earlyoom
systemctl status earlyoomDebian 和 Ubuntu 软件包会在安装时启动该服务。其选项位于 /etc/default/earlyoom:
EARLYOOM_ARGS="-m 5,2 -s 5,2 --avoid '^(sshd|systemd)$' --prefer '^(node|python3)$'"-m PERCENT设置最低可用内存,-s PERCENT设置最低可用 swap,默认值均为 10%。每组中的第二个数值是 SIGKILL 阈值:可用资源低于第一个数值后,earlyoom 会发送 SIGTERM;低于第二个数值后会发送 SIGKILL。第二个数值默认是第一个数值的一半。使用 sudo systemctl restart earlyoom 应用更改,并查看 journalctl -u earlyoom,确认它终止了哪个进程,以及该进程当时占用的内存量。
systemd-oomd是另一个选项。其手册页将其描述为“使用 cgroups-v2 和压力停滞信息(PSI)进行监控,并在内核空间发生 OOM 前采取纠正措施的系统服务”。它针对整个 cgroup,而不是单个进程,因此终止的是一个单元,而不是某个脱离管理的子进程。单元可通过 ManagedOOMMemoryPressure=kill 或 ManagedOOMSwap=kill选择启用,阈值位于 /etc/systemd/oomd.conf。
systemctl status systemd-oomd
oomctloomctl输出当前正在监控的内容。在服务器镜像上,这通常为空,因为该设置需要按单元选择启用。请选择一个守护进程,不要同时运行两个。并行运行时,两个守护进程会竞争选择终止对象,之后也更难还原每次终止的原因。
哪个单元负责?
先从内核开始,因为内核会记录它执行的每次 kill 操作。
journalctl -k --grep "Killed process" --since "2 hours ago"全局 OOM killer 执行的 kill 通常如下所示:
Out of memory: Killed process 4127 (node) total-vm:2731084kB, anon-rss:1874232kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:4212kB oom_score_adj:0anon-rss 是进程退出时占用的 RAM,在此处约为 1.8 GB。请谨慎看待方括号中的名称。那是内核选中的受害进程。内核会选择占用最大的进程,但它不一定是导致内存不足的进程。
cgroup 限制触发的 kill 使用不同的前缀,其上方的报告会显示达到自身上限的 cgroup:
Memory cgroup out of memory: Killed process 8811 (python3) total-vm:1044320kB, anon-rss:769112kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:1720kB oom_score_adj:0这个前缀基本就能说明原因。Memory cgroup out of memory 表示某个单元达到了为其设置的 MemoryMax=,而服务器的其他部分运行正常。单独出现 Out of memory 表示整台机器内存耗尽,因此设置的限制要么缺失,要么总和过大。
然后询问 systemd 记录到的情况:
systemctl status myapp.service
journalctl -u myapp.service -n 50myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Main process exited, code=killed, status=9/KILL
myapp.service: Failed with result 'oom-kill'.systemctl status 用一行说明了相同的情况,即 Active: failed (Result: oom-kill)。
cgroup 计数器是第三个信息来源,也是唯一会记录 throttling 的来源。throttling 根本不会生成日志行:
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peaklow 0
high 4213
max 118
oom 12
oom_kill 12high 统计该单元超过 MemoryHigh= 并受到 throttling 的次数。max 统计它达到硬上限的次数,oom_kill 统计实际被 kill 的进程数。较大的 high 配合 oom_kill 0,就是前面所说的静默情况:服务仍在运行,但速度极慢,而且没有向任何人报告故障。memory.peak(Linux 5.19 及更高版本)记录 cgroup 达到的最高用量,这是设置 MemoryMax= 时应参考的数值。单元重启时,两个文件都会重置,因为 systemd 会重新创建 cgroup。
所有这些机制都依赖一个前提。如果 /var/log/journal 不存在,journal 就保存在 RAM 中,而你为恢复服务器而执行的那次重启会让所有日志行消失。
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-bootsjournalctl --list-boots 显示的启动次数大于当前启动次数,表示历史记录现在可以保留,因此 journalctl -k -b -1 可以显示导致服务器停止运行的那次启动中的内核消息。
小型 VPS 的起点
在 2 GB 方案中,为内核和页面缓存预留 300 到 400 MB。不要让所有上限之和达到完整的 2 GB,因为每个单元都可能同时达到峰值。将最大份额分配给最重要的服务,然后限制其周围所有非关键服务的资源。
[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5s保留一种进入服务器的方式,值得再增加一项设置。在 ssh.service 的 drop-in 配置中设置 OOMScoreAdjust=-500,可以大幅降低全局 OOM killer 将 SSH daemon 选为终止目标的可能性。这可能决定您是修复服务器,还是必须通过控制面板重启服务器。它只会改变内核选择终止目标的方式,不会缩短停顿时间。
容器由容器运行时创建在各自的 cgroup 中,而不是由您的 unit 文件创建。因此,对 docker.service 设置的限制不会成为某个容器的限制。MemoryMax= 和 CPUQuota= 针对单个容器的等效设置,详见 在 Docker Compose 中设置内存和 CPU 限制。
FAQ
为什么我的 VPS 会卡死,而不是终止失控进程?
因为内核根据回收操作是否返回页面来判断系统是否仍在推进,而不是根据回收耗时来判断。内存不足时,内核会驱逐页面缓存,其中包括正在运行的程序的可执行文件页面,然后在执行下一条指令时将它们重新读回。所有任务都在等待存储设备,且从技术上看没有任何内存分配失败,因此不会调用 OOM killer。问题发生时检查 /proc/pressure/memory:如果 full avg10 高于 40,表示过去十秒内几乎没有任务获得运行机会。使用 earlyoom 之类的用户空间守护进程,可以在服务器达到该状态前终止进程。
MemoryHigh 和 MemoryMax 有什么区别?
MemoryHigh= 是用于限流的软上限。内核会积极回收该单元的内存并减慢其分配速度,但使用量可以超过此数值,且不会终止任何进程。MemoryMax= 是硬上限:如果在该上限内无法满足内存分配请求,就会在该单元自己的 cgroup 中调用 OOM killer。因此,导致问题的进程会被终止,而不是服务器上占用内存最多的进程。将 MemoryHigh= 设置为低于 MemoryMax=,并将两者之间的差值作为警告区间。
如何找出 OOM killer 终止了哪个服务?
运行 journalctl -k --grep "Killed process" --since "2 hours ago"。以 Memory cgroup out of memory 开头的行表示某个单元触发了自己的 MemoryMax=,而普通的 Out of memory 表示整台机器耗尽了内存。然后运行 journalctl -u <unit> -n 50,查找 Failed with result 'oom-kill'。如果服务器上不存在 /var/log/journal,说明 journal 保存在 RAM 中,相关证据已在重启时丢失。因此,请在下次事故发生前创建该目录。
我应该为小型 VPS 添加 swap 吗?
小型 swap 文件有助于处理只分配一次、之后不再访问的冷页面。它无法解决失控进程问题:swap 只会延迟终止操作,把短暂中断变成长时间卡顿,让您无法登录修复。将 swap 保持在适当大小,并在您愿意终止的单元上设置 MemorySwapMax=0。这样这些单元会先达到上限并快速重启,同时重要服务仍可使用自己的 swap。
不编写 unit 文件也可以限制命令吗?
可以。sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh 会在您的终端中启动一个临时 scope,并在其中按指定限制运行命令;命令退出后,这些限制也会消失。systemd.resource-control 中的每个属性都可在 -p 之后使用,因此 MemorySwapMax=、TasksMax= 和 CPUWeight= 也适用于此处。删除 --scope 并添加 --unit=name,即可在后台运行任务,并将其输出写入 journal。