SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

如何使用 systemd 限制處理程序的 CPU 與記憶體用量

透過 systemd 的 MemoryMax 與 CPUQuota 限制資源,避免處理程序導致系統發生記憶體抖動 (thrashing)。本文說明如何設定 drop-in 檔案並利用 PSI 監控記憶體壓力,防止 VPS 因資源耗盡而凍結。

使用 systemd drop-in 限制處理程序的記憶體與 CPU

您可以透過在執行處理程序的單元(unit)中加入幾行設定,來限制 Linux VPS 上的處理程序記憶體與 CPU 使用量。MemoryMax= 是記憶體的硬性上限。CPUQuota= 則是處理器時間的上限。兩者皆由 cgroup v2(控制群組第二版)強制執行,這是 systemd 已用於計算伺服器上每個服務資源用量的核心功能。

sudo systemctl edit myapp.service

這會開啟一個包含說明註解的 drop-in 檔案。請在註解上方加入以下內容:

[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax

systemctl show 必須以核心自身的單位回傳您的數值:MemoryMax=805306368 與 CPUQuotaPerSecUSec=800ms。若顯示 MemoryMax=infinity,表示 drop-in 未成功載入。請檢查檔案是否已放置於 /etc/systemd/system/myapp.service.d/override.conf,且開頭是否包含 [Service] 標頭;因為若設定行上方沒有區段標頭,systemd 會記錄 Assignment outside of section. Ignoring. 並在無任何限制的情況下啟動服務。

本指南後續內容將說明如何選擇這些數值,以及設定後可能發生的問題。

為何失控的處理程序會凍結未滿載的 VPS

當處理程序達到記憶體上限時,通常會在約一秒內終止並由服務自動重啟,這是理想情況。糟糕的情況是系統並未終止任何程序:主機對 ping 有回應,SSH 也能連線,但卻無法進入 shell 提示字元。此時機器處於運作中且負載極高,但所有運作皆無效。

其機制並不直觀,說明如下。當可用記憶體不足時,核心會回收頁面(pages)而非分配新頁面。最容易回收的是檔案備份頁面(file-backed pages),而頁面快取(page cache)中存放著所有執行中程式的執行檔程式碼。因此,核心會移除 sshd 的文字頁面,導致 sshd 下一次執行指令時觸發頁面錯誤(page fault),必須從儲存裝置重新讀取這些位元組。每個處理程序最終都在等待磁碟而非執行。相同的頁面不斷移出又讀入,形成迴圈,稱為「抖動」(thrashing)。

在 VPS 上,這種情況比筆記型電腦更嚴重,原因有二。首先,儲存裝置通常是網路附加或共享的,因此每次錯誤造成的延遲比本地 NVMe 裝置高出許多毫秒。其次,核心衡量的是失敗而非時間:只要回收機制能持續提供頁面(無論速度多慢),核心就會認為系統仍在運作,因此不會觸發 OOM killer(記憶體不足終止機制)。主機可能在這種狀態下持續數分鐘,直到有程序被終止。

您可以監控此過程。Linux 4.20 及更新版本會匯出壓力失速資訊(PSI):

cat /proc/pressure/memory
cat /proc/pressure/io
some 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 代表在過去十秒內,系統中 48% 的時間裡,所有可執行的任務都因等待記憶體作業而停滯,導致系統無法執行任何工作。健康的伺服器在 full 的數值應接近零。數值超過 10 時,使用者會感到系統緩慢;達到 40 或以上時,即為一般所稱的「凍結」狀態。

這也是為什麼單純設定限制並非保證。受限於 MemoryHigh= 的單元會被節流(throttled)而非終止,因此它會保持運作但速度緩慢,且由於 systemd 認為它並未失敗,因此不會觸發重啟。一個受限且允許使用 swap 的單元會產生讀寫請求,這些請求會計入該單元,但由共享裝置處理,進而導致系統中其他所有服務的 /proc/pressure/io 數值飆升。限制只能決定由誰承擔資源短缺的代價,無法創造額外的容量。

檢查您的 VPS 是否執行 cgroup v2

stat -fc %T /sys/fs/cgroup

cgroup2fs 是統一階層架構,這是下方所有設定的必要條件。tmpfs 代表系統以舊版的 v1 佈局開機,在該模式下 MemoryHigh= 與 MemorySwapMax= 並不存在,且各單位的 OOM 行為亦有所不同。Ubuntu 22.04 及更新版本、Debian 11 及更新版本預設皆使用 v2。舊版映像檔或以 systemd.unified_cgroup_hierarchy=0 參數開機的核心則不支援。

在 cgroup v2 系統上,systemd 預設會為每個單位啟用記憶體統計,因此相關數據已就緒:

systemd-cgtop -m

此指令會依記憶體使用量排序並列出 cgroup,這是當伺服器尚能回應時,判斷「是什麼吃掉了系統資源」最快的方法。若伺服器為全新部署,請先完成 新 VPS 的前十分鐘設定 中的帳號與防火牆作業。

MemoryHigh 與 MemoryMax 的節流與終止機制

這兩項記憶體設定的差異,決定了服務故障時的表現形式。

  • MemoryHigh= 是軟性上限。當記憶體用量超過此值,核心會積極回收該 cgroup 的資源,並刻意拖慢其記憶體分配速度。用量仍可超過此數值,且不會有任何處理程序被終止。
  • MemoryMax= 是硬性上限。當記憶體分配需求無法在此上限內滿足時,OOM killer 會在該 cgroup 內部執行,並終止該單元(unit)內部的其中一個處理程序。

後者正是針對任何非完全信任的服務設定 MemoryMax= 的主要原因。若無上限,記憶體短缺會演變成整台伺服器的問題,全域 OOM killer 會根據 oom_score 挑選受害者,這通常意味著記憶體佔用最大的處理程序會被殺掉。該處理程序往往是您的資料庫,而非造成記憶體洩漏的腳本。設定上限後,終止行為會被限制在造成問題的單元內部。

建議同時設定兩者,並將 MemoryHigh= 設定在低於 MemoryMax= 約 20% 到 30% 的位置。此間距作為緩衝區:緩慢的記憶體洩漏會跨越 High,表現為服務變慢;而突發性的記憶體暴增則會直接衝破 Max 並導致服務終止。

百分比數值是根據實體記憶體總量計算,因此在 4 GB 的方案中,MemoryMax=25% 代表 1 GB;即便日後調整方案規格,它仍會維持在總量的四分之一。MemorySwapMax=0 可讓該單元完全不使用 swap,這會將原本漫長的效能低落轉變為快速且明確的服務終止。

部分服務允許您預先決定其資源需求而非透過測量:例如 Ollama 單元會根據您給定的 context window 來調整 KV cache 大小,因此在設定上限前,請先閱讀 提高 num_ctx 對 RAM 的影響。

設定上限時必須搭配重啟策略,否則服務被終止後將維持在停止狀態。

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=5s

StartLimit* 應置於 [Unit] 區段,而 Restart= 應置於 [Service] 區段。若放置錯誤,systemd 將會忽略這些設定。五分鐘內重啟五次通常代表存在記憶體洩漏而非短暫異常,因此 systemd 在此之後會放棄重啟並將單元標記為 failed;這正是您希望看到的狀態,而非讓 crash loop 掩蓋了問題本質。

使用 CPUQuota 限制 CPU,或使用 CPUWeight 分配資源

CPUQuota= 佔用單一 CPU 可用時間的百分比。CPUQuota=50% 代表半個核心。CPUQuota=200% 相當於兩個核心的效能,該單元可視需求將負載分散至任意數量的執行緒。在 2 vCPU 的方案中,CPUQuota=200% 代表整台機器的效能。

對於大多數服務而言,CPUWeight= 是較佳的預設值。這是一個 1 到 10000 之間的相對權重,核心預設值為 100。它僅在資源競爭時生效:當負載過高時,設定為 CPUWeight=20 的備份工作會讓位給設定為 100 的網頁伺服器,但在系統閒置時,備份工作仍可使用整台機器的資源。硬性配額(Hard quota)則會浪費這些閒置的運算能力。

請務必釐清 CPU 限制的實際效益。CPU 密集型程序極少導致 Linux 當機,因為排程器會持續分配時間給所有程序。真正會導致伺服器崩潰的是記憶體不足。當您需要可預測的效能上限時,請使用 CPUQuota=,例如針對編譯任務或代理程式,否則它們可能會長時間全速運作。這類工作負載的規模評估屬於另一個議題,詳見 編碼代理程式 VPS 需要多少 RAM 與 CPU。

若您的程序並未執行繁重任務,但 CPU 讀數仍顯示忙碌,原因可能來自 Hypervisor 的另一端。這就是 來自鄰居干擾的 CPU steal time,無論您設定何種配額都無法改善此狀況。

TasksMax 可防止 fork 迴圈

TasksMax= 是單元(unit)所能持有的處理程序與執行緒總數。由於執行緒也會被計入,因此 Java 或 Go 服務所需的額度會比處理程序清單顯示的更多。這是防止腳本陷入 fork 迴圈最經濟實惠的保護機制,因為 fork 會在單元內部失敗,而不至於導致整台伺服器耗盡處理程序 ID(PID)。

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 file)即可使用這些功能。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 並為其命名。該任務隨後會以暫時性服務(transient service)在背景執行,並將日誌寫入 journal:

sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -f

當您不具備 root 權限時,相同的選項也適用於 --user,但您的使用者管理器(user manager)僅擁有委派給它的控制器,因此某些屬性可能會被拒絕。若發生此情況,請使用 sudo 執行。當任務需要永久執行時,設定可直接移至正式單元中:請參閱 將腳本作為 systemd 服務與計時器執行。

關於 Swap 的誠實解答

Swap 改變了故障的形式,而非防止故障發生。

若無 Swap,記憶體洩漏達到上限時,服務會在幾秒內終止。這種中斷發生得迅速且明顯,事後查看 journal 也很容易判讀。若有 Swap,核心會將冷門的匿名頁面寫入磁碟以爭取時間。如果該行程的記憶體用量會趨於平穩,Swap 能救你一命;但如果是失控的行程,Swap 會將五秒的中斷變成二十分鐘的系統停滯。停滯更為棘手,因為行程終止後你至少還有可用的 shell,但系統發生 thrashing 時則完全無法操作。

swapon --show
free -h

在小型 VPS 上的一個折衷方案:保留適量的 swap file 來存放那些僅配置一次且不再存取的頁面,並對你願意犧牲的單元設定 MemorySwapMax=0。重要的服務保留 Swap,不可預測的服務則讓其快速觸發記憶體上限並重啟。

調低 vm.swappiness 的效果有限,原因在於它僅是調整「清除頁面快取 (page cache)」與「置換匿名頁面 (anonymous pages)」之間的權衡,兩者最終都會導致磁碟讀取。它改變的是哪些頁面會發生 thrashing,而非系統是否會發生 thrashing。

在記憶體耗盡(OOM)發生前啟動的早期守護行程

核心會等到記憶體回收完全失敗才介入,但在小型 VPS 上,這段等待時間正是導致系統崩潰的關鍵。兩個使用者空間的守護行程透過自行監控記憶體並提早終止行程,解決了這個問題。

earlyoom 每秒檢查可用記憶體與剩餘 swap 最多 10 次,僅在兩者皆低於閾值時,才會終止 oom_score 最高的行程。若系統未配置 swap,則僅依據記憶體閾值判斷。

sudo apt install earlyoom
systemctl status earlyoom

Debian 與 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 觸發點:當記憶體與 swap 皆低於第一個數值時,earlyoom 會發送 SIGTERM;若兩者皆低於第二個數值(預設為第一個數值的一半),則會發送 SIGKILL。請勿將 --avoid 與 --prefer 的模式加上引號或空格,因為 systemd 會將引號視為字元的一部分,導致模式無法匹配。套用變更請執行 sudo systemctl restart earlyoom,並查看 journalctl -u earlyoom 以確認被終止的行程及其佔用的記憶體量。

systemd-oomd 是另一個選項。其說明文件將其描述為「一個利用 cgroups-v2 與壓力失速資訊(PSI)來監控並在核心空間發生 OOM 前採取修正行動的系統服務」。它以整個 cgroup 為操作對象而非單一行程,因此會終止整個單元(unit)而非單一子行程。單元需透過 ManagedOOMMemoryPressure=kill 或 ManagedOOMSwap=kill 啟用,閾值設定則位於 /etc/systemd/oomd.conf。

systemctl status systemd-oomd
oomctl

oomctl 會列出目前監控的對象,在伺服器映像檔中通常為空,因為該設定需針對每個單元個別啟用。請選擇一個守護行程進行監控即可。同時執行兩者會導致兩個程式競爭選擇終止對象,這會增加追蹤終止原因的難度。

是哪個單元造成的?

請從核心(kernel)開始檢查,因為它會記錄每一次的終止操作。

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:0

anon-rss 是該處理程序在死亡時佔用的 RAM 大小,此處約為 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 50
myapp.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.peak
low 0
high 4213
max 118
oom 12
oom_kill 12

high 計算該單元被推至 MemoryHigh= 並進行節流的次數。max 計算達到硬性上限的次數,而 oom_kill 則計算實際被終止的處理程序數量。若 high 數值很大但 oom_kill 0 為 0,即為前述的無聲故障案例:服務仍在執行,但速度極慢,且未向任何人回報錯誤。memory.peak(Linux 5.19 及更新版本)記錄了該 cgroup 曾達到的最高使用量,這是設定 MemoryMax= 的參考數值。當單元重新啟動時,由於 systemd 會重新建立 cgroup,這兩個檔案的數值都會重置。

上述所有檢查的前提是:如果 /var/log/journal 不存在,日誌將僅存於 RAM 中,一旦為了恢復系統而重新開機,所有日誌行都會消失。

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-boots

若 journalctl --list-boots 顯示的數值大於目前的開機次數,代表歷史紀錄已保存,因此 journalctl -k -b -1 可以顯示導致當機那次開機的核心訊息。

小型 VPS 的起點

在 2 GB 的方案中,請保留 300 至 400 MB 給核心與頁面快取(page cache),且各項限制總和不應達到 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 中,這些 cgroup 是由容器執行時期(container runtime)而非你的 unit 檔案所建立,因此對 docker.service 的限制不會直接套用到單一容器上。關於每個容器對應的 MemoryMax= 與 CPUQuota= 設定,請參閱 在 Docker Compose 中設定記憶體與 CPU 限制。

FAQ

為什麼我的 VPS 凍結了,而不是殺掉失控的行程?

因為核心判斷系統是否運作的標準是記憶體回收(reclaim)是否成功,而非回收耗時多久。當記憶體不足時,核心會釋放頁面快取(page cache),包含執行中程式的可執行頁面,並在下一次指令執行時重新讀取。此時所有行程都在等待儲存裝置回應,且技術上並未發生記憶體分配失敗,因此 OOM killer 不會被觸發。當此狀況發生時,請檢查 /proc/pressure/memory:若 full avg10 超過 40,代表過去 10 秒內幾乎沒有任務能執行。使用如 earlyoom 這類使用者空間的 daemon,可在系統達到該狀態前先行終止行程。

MemoryHigh 與 MemoryMax 有什麼區別?

MemoryHigh= 是一個會進行節流(throttle)的軟性上限。核心會強制回收該單位的記憶體並減緩其分配速度,但使用量仍可超過此數值,且不會有任何行程被殺掉。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,代表日誌僅存於 RAM 中,證據已隨重開機消失,請在下次事件發生前建立該目錄。

我應該為小型 VPS 新增 swap 嗎?

小型 swap 檔案有助於處理那些只分配一次且不再存取的冷頁面(cold pages)。但它對失控行程無效:它只會延遲終止時間,將短暫的中斷轉變為無法登入修復的長期停滯。請將 swap 維持在適度大小,並在您願意犧牲的單位上設定 MemorySwapMax=0,讓這些單位達到上限後能快速重啟,同時確保重要服務能保留其 swap 空間。

我可以在不編寫 unit file 的情況下限制指令嗎?

可以。sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh 可在終端機中以暫時性 scope 執行指令並套用限制,指令結束後限制即消失。systemd.resource-control 中的所有屬性在 -p 之後皆可使用,因此 MemorySwapMax=、TasksMax= 與 CPUWeight= 在此同樣有效。若要讓任務在背景執行並將輸出寫入日誌,請移除 --scope 並加入 --unit=name。