如何使用 systemd 限制處理程序的 CPU 與記憶體使用量
透過 systemd 的 MemoryMax 與 CPUQuota 設定,有效控管 VPS 資源。本文說明如何避免因記憶體抖動導致的系統凍結,並教您透過 PSI 監控處理程序停滯狀況,確保服務穩定執行。
使用 systemd drop-in 限制處理程序的記憶體與 CPU
您可以透過在執行處理程序的單元(unit)中加入幾行設定,來限制 Linux VPS 上的處理程序記憶體與 CPU 使用量。MemoryMax= 是記憶體的硬性上限。CPUQuota= 則是處理器時間的上限。兩者皆由 cgroup v2(control groups, version 2)強制執行,這是 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] 標頭;若設定行上方沒有標頭,systemd 會記錄 Assignment outside of section. Ignoring. 並在無任何限制的情況下啟動服務。
本指南的其餘部分將說明如何選擇這些數值,以及設定後可能發生的問題。
為何失控的處理程序會凍結未滿載的 VPS
當處理程序達到記憶體上限時,通常會在約一秒內終止並重啟服務,這是理想情況。糟糕的情況是系統並未終止任何程序:主機對 ping 有回應,SSH 也能連線,但卻無法進入 shell 提示字元。此時機器處於運作中且忙碌,但所有工作皆無效。
其機制並不直觀。當可用記憶體不足時,核心會回收頁面而非分配新頁面。最容易回收的是檔案備份頁面(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/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 代表在過去 10 秒內,主機上 48% 的時間裡,所有可執行的任務都因等待記憶體作業而停滯,導致系統無法執行任何工作。健康的伺服器在 full 的數值應接近 0。數值超過 10 時,使用者會感到系統變慢;達到 40 或以上時,即為一般所稱的「凍結」狀態。
這也是為何單純設定限制並非保證。受限於 MemoryHigh= 的單元會被節流(throttled)而非終止,因此它會保持執行但速度緩慢,且由於 systemd 認為它並未失敗,因此不會觸發重啟。一個被限制但仍允許使用 swap 的單元,會產生由共享裝置處理的讀寫請求,這會導致主機上所有其他服務的 /proc/pressure/io 數值上升。限制僅能決定由誰承擔資源短缺的代價,無法創造額外的容量。
檢查您的 VPS 是否執行 cgroup v2
stat -fc %T /sys/fs/cgroupstat -fc %T /sys/fs/cgroup/
cgroup2fs 是統一階層(unified hierarchy),這是下方所有設定的必要條件。tmpfs 代表系統以舊版 v1 配置啟動,在該模式下 MemoryHigh= 與 MemorySwapMax= 不存在,且各單位的 OOM 行為亦有所不同。Ubuntu 22.04 及更新版本、Debian 11 及更新版本預設皆使用 v2。舊版映像檔或以 systemd.unified_cgroup_hierarchy=0 參數啟動的核心則不支援。
在 cgroup v2 中,systemd 預設會為每個單位啟用記憶體統計,因此相關數據已就緒:
systemd-cgtop -msystemd-cgtop
此指令會依記憶體使用量排序顯示 cgroup,當伺服器尚能回應時,這是找出「何者耗盡系統資源」最快的方法。若伺服器為全新部署,請先完成 新 VPS 的前十分鐘 中關於帳號與防火牆的設定。
MemoryHigh 節流與 MemoryMax 終止
這兩個記憶體設定的差異,決定了故障發生時的表現形式。
MemoryHigh=是一個軟性上限(soft cap)。當用量超過此值時,核心會積極地從該 cgroup 回收記憶體,並刻意減緩其配置速度。記憶體用量仍可超過此數值,且不會有任何程序被終止。MemoryMax=是一個硬性上限(hard cap)。當配置需求無法在此限制內滿足時,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 的影響。
設定上限時必須搭配重啟策略(restart policy),否則終止後服務將保持停止狀態。
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* 應放置於 [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 並為其命名。該任務隨後會以暫時性服務的形式在背景執行,並將日誌寫入 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 的誠實解答
Swap 改變了故障的形式,而非防止故障發生。
若無 Swap,記憶體洩漏達到上限時,系統會在幾秒內崩潰。這種中斷發生得既明顯又短暫,事後查看 journal 很容易判讀。若有 Swap,核心會將冷門的匿名頁面寫入磁碟以爭取時間。如果該行程的記憶體用量會趨於平穩,Swap 能救你一命;但如果是失控的行程,Swap 會將五秒的中斷變成二十分鐘的停滯。後者更糟,因為行程崩潰後你至少還有可用的 shell,但系統陷入 thrashing 時則完全無法操作。
swapon --show
free -h在小型 VPS 上的一個可行折衷方案是:保留一個適度的 swap file 來存放那些配置後便不再存取的頁面,並對你願意犧牲的單元設定 MemorySwapMax=0。重要的服務保留 Swap,不可預測的服務則讓它們快速觸發記憶體上限並重啟。
調低 vm.swappiness 的效果有限,原因在於它僅是調整「清除頁面快取」與「交換匿名頁面」之間的權重,兩者最終都會導致磁碟讀取。它改變的是哪些頁面會發生 thrashing,而不是系統是否會陷入 thrashing。
早期 OOM daemon 在系統停滯前執行終止
核心會等待記憶體回收完全失敗,但在小型 VPS 上,該等待時間正是導致機器失去回應的關鍵窗口。兩個使用者空間的 daemon 透過自行監控記憶體並提早執行終止,可解決此問題。
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 會列印目前監控的對象,在伺服器映像檔中通常為空,因為該設定需針對每個單元手動啟用。請選擇一個 daemon 並僅使用該工具。同時執行兩者會導致兩個程式競爭選擇終止對象,且難以釐清任何終止事件的原因。
哪個單元需負責?
請從核心(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:0anon-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 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 不存在,日誌將僅存於 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,因為每個單元(unit)都有可能同時達到峰值。請將最重要的服務分配到最大份額,再限制其餘非必要服務的資源。
[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 作為犧牲品的機率,這決定了你是能直接修復伺服器,還是必須從控制面板重啟。此設定僅改變核心選擇犧牲品的邏輯,並不會縮短系統停滯的時間。
容器運行在各自的 cgroups 中,這些 cgroups 是由容器執行時期(container runtime)而非你的 unit files 所建立,因此對 docker.service 的限制不會直接套用到單一容器上。關於每個容器對應的 MemoryMax= 與 CPUQuota= 設定,請參閱 在 Docker Compose 中設定記憶體與 CPU 上限。
FAQ
為什麼我的 VPS 凍結了,而不是終止失控的處理程序?
因為核心是根據記憶體回收(reclaim)是否歸還頁面來判斷進度,而非耗費的時間。當記憶體不足時,系統會驅逐頁面快取(page cache),包含執行中程式的可執行頁面,並在下一次指令執行時重新讀取。此時所有程序都在等待儲存裝置,且技術上並未發生配置失敗,因此 OOM killer 不會被觸發。在凍結發生時檢查 /proc/pressure/memory:若 full avg10 超過 40,代表過去 10 秒內幾乎沒有任務能執行。使用如 earlyoom 這類使用者空間的守護行程,可在系統達到該狀態前先行終止程序。
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 空間。
我可以在不編寫單元檔案的情況下限制指令嗎?
可以。sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh 會在終端機中以暫時性範圍(transient scope)執行該指令並套用限制,指令結束後限制即消失。systemd.resource-control 中的所有屬性在 -p 之後皆可使用,因此 MemorySwapMax=、TasksMax= 與 CPUWeight= 在此同樣適用。移除 --scope 並加入 --unit=name,即可在背景執行該任務,並將輸出記錄至日誌中。