如何使用 systemd 限制进程内存与 CPU 使用
通过 systemd 单元文件配置 MemoryMax 与 CPUQuota 限制资源。本文详解如何利用 cgroup v2 避免进程抖动,并教你通过 PSI 指标监控系统压力,防止失控进程导致 VPS 冻结。
使用 systemd 覆盖文件限制进程内存和 CPU
在 Linux VPS 上,通过向运行进程的单元文件添加几行配置,即可限制其内存和 CPU 使用。MemoryMax= 是内存的硬上限。CPUQuota= 是处理器时间的上限。两者均由 cgroup v2(控制组第 2 版)强制执行,这是 systemd 已用于统计服务器上每个服务资源消耗的内核功能。
sudo systemctl edit myapp.service该命令会打开一个包含说明注释的覆盖文件。请在注释上方添加以下内容:
[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,说明覆盖文件未加载。请检查文件是否已存放在 /etc/systemd/system/myapp.service.d/override.conf,并确认其以 [Service] 标题开头;因为如果配置行上方没有标题,systemd 会记录 Assignment outside of section. Ignoring. 错误,并使服务在没有任何限制的情况下启动。
本指南的其余部分将介绍如何选择这些数值,以及设置后可能出现的问题。
为何失控进程会冻结并未占满内存的 VPS
当进程达到内存硬限制时,它通常会在一秒内终止并触发服务重启。这是理想情况。糟糕的情况是没有任何进程终止:服务器能响应 ping,SSH 也能建立连接,但始终无法进入 shell 提示符。此时机器处于繁忙状态,但所有工作均无效。
其机制并不直观。当可用内存不足时,内核会回收内存页而非分配新页。最易回收的是文件支持页(file-backed pages),而页缓存(page cache)中存放着所有正在运行程序的执行代码。因此,内核会驱逐 sshd 的代码段页,导致 sshd 执行下一条指令时触发缺页中断,必须从存储设备重新读取这些字节。最终,所有进程都在等待磁盘而非运行。这些页面在内存中反复进出,这种现象称为抖动(thrashing)。
相比笔记本电脑,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=9114233full 行至关重要。full avg10=48.15 表示在过去十秒内,系统上 48% 的时间内所有可运行任务都在等待内存操作,导致没有任何任务在执行。健康的服务器 full 数值应接近零。数值超过 10 时用户会感到卡顿,达到 40 或更高时,系统就会表现为“冻结”。
这也是为什么仅设置限制并不能保证稳定。受 MemoryHigh= 限制的单元会被限流而非终止,因此它会保持运行且速度缓慢,由于 systemd 认为它并未失败,因此不会重启该服务。如果受限单元被允许使用交换空间(swap),它产生的读写操作会记在该单元名下,但由共享设备处理,这会导致服务器上其他所有服务的 /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,这会将漫长的性能衰退转变为快速且明显的终止。
少数服务允许你预先设定其内存需求而非通过测量得出:Ollama 单元会根据你给定的上下文窗口大小来分配 KV 缓存,因此在为其设定上限前,请阅读 提高 num_ctx 对内存的消耗。
设置上限时必须配合重启策略,否则终止操作只会导致服务停止。
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* 应放在 [Unit] 中,Restart= 应放在 [Service] 中。如果放错区域,systemd 将会忽略这些配置。五分钟内重启五次通常意味着存在内存泄漏而非偶然波动,因此在此之后 systemd 会放弃重启并使单元保持 failed 状态,这正是你希望看到的,而不是让崩溃循环掩盖了问题。
使用 CPUQuota 限制 CPU,或使用 CPUWeight 分配权重
CPUQuota= 占用单核 CPU 可用时间的百分比。CPUQuota=50% 代表半个核心。CPUQuota=200% 相当于两个核心的算力,该单元可根据需要将其分配到任意数量的线程上。在 2 vCPU 的方案中,CPUQuota=200% 代表整台机器的算力。
对于大多数服务,CPUWeight= 是更优的默认设置。它是一个从 1 到 10000 的相对权重值,内核默认值为 100。它仅在存在资源竞争时生效:在负载较高时,权重为 CPUWeight=20 的备份任务会主动让位给权重为 100 的 Web 服务器,而在系统空闲时,备份任务仍可使用整台机器的资源。硬性配额(Hard quota)则会浪费这些空闲的算力。
请客观评估 CPU 限制的实际作用。CPU 密集型进程很少会导致 Linux 系统卡死,因为调度器会持续为所有进程分配时间片。导致服务器宕机的主要原因是内存不足。当你需要设定一个可预测的算力上限时,请使用 CPUQuota=,例如针对那些可能会持续满载运行一小时的构建任务或代理程序。此类工作负载的规模评估属于另一个课题,详见 编码代理 VPS 需要多少内存和 CPU。
如果 CPU 显示繁忙但你的进程并未进行大量计算,原因可能在于宿主机层面。这就是 来自邻居干扰的 CPU 窃取时间,无论你设置何种配额都无法改变这一情况。
TasksMax 可防止 fork 炸弹
TasksMax= 定义了单个单元(unit)允许持有的进程和线程总数。由于线程也计入其中,因此 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 限制一次性任务
无需编写单元文件即可使用此功能。systemd-run 会围绕单个命令构建一个临时单元。
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 运行。当任务需要永久化时,可将这些设置直接移入正式的单元文件中:请参阅 将脚本作为 systemd 服务和定时器运行。
关于交换分区的真相
交换分区(swap)改变了故障的表现形式,而非防止故障发生。
如果没有交换分区,内存泄漏达到上限时,进程会在几秒内终止。这种故障表现明显、持续时间短,且事后易于通过日志排查。如果配置了交换分区,内核会将不常用的匿名页写入磁盘,从而争取时间。如果进程的内存占用能够趋于平稳,交换分区可以救急。如果进程内存失控,交换分区会将 5 秒的故障变成 20 分钟的系统卡顿。卡顿比进程崩溃更糟糕,因为进程崩溃后你至少还能通过 shell 操作,而系统频繁抖动(thrashing)时你将无法进行任何操作。
swapon --show
free -h在小型 VPS 上,一个可行的折中方案是:保留一个适度的交换文件,用于存放那些分配后不再访问的内存页,并对你允许终止的单元设置 MemorySwapMax=0。重要的服务保留交换空间,不可控的服务则让其快速触发内存溢出并重启。
降低 vm.swappiness 是一个效果有限的手段,原因如下:它仅改变了驱逐页缓存(page cache)与交换匿名页之间的平衡,而两者在后续访问时都会产生磁盘读取开销。它改变的是哪些内存页发生抖动,而不是系统是否会发生抖动。
早期 OOM 守护进程在系统卡死前执行终止
内核会等待内存回收彻底失败,而在小型 VPS 上,这段等待时间正是导致机器失去响应的关键窗口。两个用户空间守护进程通过自行监控内存并提前终止进程来解决此问题。
earlyoom 负责监控可用内存和空闲交换空间,当两者中任意一项低于阈值时,它会终止评分最高的进程。
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 设置空闲交换空间最小值,两者默认均为 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 会打印当前监控的内容,在服务器镜像上通常为空,因为该设置需要按单元手动启用。选择一个守护进程并仅使用它即可。同时运行两者意味着两个进程会竞争选择终止对象,这会增加分析终止原因的难度。
哪个单元导致了问题?
首先检查内核,因为它会记录每一次终止进程的操作。
journalctl -k --grep "Killed process" --since "2 hours ago"全局 OOM killer(内存溢出杀手)发起的终止操作如下所示:
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 是该进程在终止时占用的内存,此处约为 1.8 GB。请仔细查看方括号内的名称。这是内核选定的受害者;内核通常选择内存占用最大的进程,但这并不一定就是导致内存短缺的元凶。
由 cgroup 限制触发的终止操作前缀不同,其上方的报告会指明触及自身上限的 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)情况的来源,因为节流操作不会产生任何日志行:
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= 以上并被节流的次数。max 记录了达到硬上限的频率,oom_kill 则记录了实际被杀死的进程数。如果 high 数值很大但 oom_kill 0 为 0,即为前文提到的静默故障:服务仍在运行,但速度极慢,且未向任何地方报告错误。memory.peak(Linux 5.19 及更高版本)记录了 cgroup 达到的最高使用量,这是设置 MemoryMax= 的参考数值。当单元重启时,这两个文件都会重置,因为 systemd 会重新创建 cgroup。
这一切的前提是:如果 /var/log/journal 不存在,日志将仅保存在内存中,重启以恢复服务器后,所有日志行都会丢失。
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 的插入式配置文件中设置 OOMScoreAdjust=-500,可以大幅降低全局 OOM Killer 将 SSH 守护进程选为清理对象的概率。这决定了你是能直接修复服务器,还是必须通过控制面板重启。该设置仅改变内核选择清理对象的优先级,不会缩短系统卡顿时间。
容器运行在各自的 cgroup 中,由容器运行时而非你的单元文件创建,因此对 docker.service 的限制不会直接作用于单个容器。关于 MemoryMax= 和 CPUQuota= 的容器级对应设置,请参阅 在 Docker Compose 中设置内存和 CPU 限制。
FAQ
为什么我的 VPS 冻结了,而不是杀掉失控进程?
因为内核通过回收页面是否成功来判断系统进展,而不是看耗时。当内存不足时,内核会清理页面缓存,包括正在运行程序的执行页面,然后在下次指令执行时重新读取。此时所有进程都在等待存储 I/O,且技术上并未发生内存分配失败,因此 OOM killer 不会被触发。在系统冻结时检查 /proc/pressure/memory:如果 full avg10 超过 40,意味着在过去 10 秒内几乎没有任务获得执行机会。使用 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,说明日志保存在内存中,重启后证据已丢失,因此请在下次故障前创建该目录。
我应该为小型 VPS 添加 swap 吗?
小型 swap 文件有助于处理那些分配后不再使用的冷页面。它对失控进程无效:它只会推迟终止时间,将短暂的宕机变成无法登录修复的长时间卡顿。请保持 swap 适量,并对愿意牺牲的服务设置 MemorySwapMax=0,这样它们在达到上限时会快速重启,而重要服务则能保留其 swap 空间。
我可以在不编写单元文件的情况下限制命令吗?
可以。sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh 会在终端内以瞬态作用域(transient scope)运行命令并应用这些限制,命令退出后限制即消失。systemd.resource-control 中的所有属性在 -p 之后均可用,因此 MemorySwapMax=、TasksMax= 和 CPUWeight= 在此处同样有效。去掉 --scope 并添加 --unit=name,即可在后台运行任务并将输出记录到 journal 中。