systemd 限制程序記憶體與 CPU 的設定方法
程序觸及 MemoryMax 後不一定立即終止,VPS 仍可能因 thrashing 凍結。本文示範設定 MemoryHigh、MemoryMax、CPUQuota 與 TasksMax,並讀取 OOM kill 記錄。
使用 systemd drop-in 限制程序記憶體與 CPU
在 Linux VPS 上,您可以在執行該程序的 unit 中加入幾行設定,以限制程序使用的記憶體與 CPU。MemoryMax= 是記憶體的硬上限。CPUQuota= 是處理器時間的上限。這兩項限制都由 cgroup v2(control groups,版本 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] 標頭,因為設定行上方沒有 section 時,systemd 會記錄 Assignment outside of section. Ignoring.,並在完全沒有限制的情況下啟動服務。
本指南的其餘內容將說明如何選擇這些數值,以及設定完成後仍可能發生哪些問題。
為何失控的程序未耗盡記憶體,卻會凍結 VPS
程序觸及硬性記憶體上限後,通常會在約 1 秒內終止,服務也會重新啟動。這是理想情況。較糟的情況是沒有任何程序終止:主機仍會回應 ping,SSH 也接受連線,但 shell 提示字元始終不會出現。機器仍在運作且十分忙碌,但這些工作都沒有實際效益。
以下說明其運作機制,因為這並不直觀。可用記憶體不足時,kernel 會回收頁面,而不是配置新頁面。最容易回收的是由檔案支援的頁面;page cache 會保存所有執行中程式的可執行程式碼。因此,kernel 會逐出 sshd 的文字頁面,而 sshd 執行的下一個指令會觸發 page fault,必須從儲存裝置讀回這些位元組。最後每個程序都在等待磁碟,而不是執行。相同的頁面反覆被逐出又讀回,這種現象稱為 thrashing。
與筆記型電腦相比,VPS 上有兩項因素會使情況惡化。儲存裝置通常透過網路連接或由多個租戶共用,因此每次 page fault 所需的毫秒數,往往高於本機 NVMe 裝置。其次,kernel 衡量的不是耗時,而是回收是否失敗:只要回收程序仍能交出頁面,即使速度非常慢,kernel 就會認為仍有進展,不會呼叫 out of memory (OOM) killer。主機可能維持這種狀態數分鐘,之後才終止任何程序。
你可以觀察這個現象。Linux 4.20 及更新版本會透過 PSI 匯出 pressure stall information:
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= 約束的 unit 會被限速,而不是終止,因此它會持續存在且持續緩慢;從 systemd 的角度看,它從未失敗,所以不會重新啟動。仍允許使用 swap 的受限 unit 會產生由該 unit 計費、但由同一個共用裝置處理的讀寫操作,因此可能推高 /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 開機的 kernel 則不使用 v2。
在 cgroup v2 上,systemd 預設會為每個單元啟用記憶體 accounting,因此相關數值已經存在:
systemd-cgtop -m這會依記憶體用量排序列出 cgroup。伺服器尚能回應時,這是最快確認「什麼正在耗用這台伺服器資源」的方法。如果伺服器是新建的,請先完成新 VPS 的前 10 分鐘中的帳號與防火牆設定。
MemoryHigh 會進行節流。MemoryMax 會終止程序。
這兩個記憶體設定的差異,決定了故障的表現方式。
MemoryHigh=是軟性上限。超過此上限後,kernel 會積極回收該 cgroup 的資源,並刻意降低其記憶體配置速度。使用量仍可超過此數值,且不會終止任何程序。MemoryMax=是硬性上限。當記憶體配置無法在此上限內滿足時,OOM killer 會在該 cgroup 內執行,並終止該 unit 自有的其中一個程序。
後半段才是應在不完全信任的服務上設定 MemoryMax= 的真正原因。未設定上限時,資源不足會成為整台主機的問題,而全域 OOM killer 會依 oom_score 選擇受害程序,通常代表選擇使用量最大的程序。使用量最大的程序通常是資料庫,而不是發生記憶體洩漏的 script。設定上限後,終止動作會落在造成問題的 unit 內。
請同時設定兩者,讓 MemoryHigh= 約比 MemoryMax= 低 20 到 30 percent。兩者之間的差距是警告區:緩慢的記憶體洩漏會先越過 High,使服務變慢;突然的用量尖峰則會直接超過 Max 並終止程序。
百分比值是以已安裝的實體記憶體為基準計算。因此,在 4 GB 的方案上,MemoryMax=25% 代表 1 GB;即使之後調整方案大小,它仍會維持整台主機記憶體的四分之一。MemorySwapMax=0 會讓該 unit 完全不使用 swap,將長時間的效能衰退轉變為快速且明確的終止。
設定上限時,也必須同時設定 restart policy,否則程序終止後只會留下停止的服務。
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* 應放在 [Unit] 中,而 Restart= 應放在 [Service] 中。任一設定放錯 section,systemd 都會忽略它。五分鐘內重新啟動 5 次代表這是記憶體洩漏,而不是短暫異常;此後 systemd 會放棄重試,並讓 unit 保持 failed 狀態。這樣日後才能找到問題,而不是讓 crash loop 掩蓋問題。
使用 CPUQuota 限制 CPU,或使用 CPUWeight 分享 CPU
CPUQuota= 會占用單一 CPU 可用時間的百分比。CPUQuota=50% 等於半個核心。CPUQuota=200% 相當於 2 個核心,unit 可依需求分散到任意數量的執行緒。在 2 vCPU 方案中,CPUQuota=200% 等於整台機器的 CPU 資源。
對大多數服務而言,CPUWeight= 是較好的預設值。它是介於 1 到 10000 的相對權重,而 kernel 的預設值為 100。只有在程序彼此競爭時,這項設定才會產生影響:負載升高時,CPUWeight=20 的備份工作會讓出 CPU 給權重為 100 的 Web server;機器閒置時,備份工作仍可使用整台機器。設定硬性配額會浪費這些閒置資源。
請正確理解 CPU 限制的作用。CPU-bound process 很少會讓 Linux 完全停止,因為 scheduler 會持續將 CPU 時間分配給所有程序。真正可能讓伺服器停止運作的是記憶體不足。若需要可預測的上限,請使用 CPUQuota=,例如限制 build 工作或原本會連續滿載執行 1 小時的 agent。這類工作負載的規模評估是另一個問題,請參閱 coding agent VPS 需要多少 RAM 和 CPU。
如果 CPU 顯示忙碌,但沒有任何程序使用大量 CPU,原因可能在 hypervisor 的另一側。這是 來自 noisy neighbour 的 CPU steal time,而你設定的任何 quota 都無法改變這種情況。
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 file。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 使用相同選項,但 user manager 只有獲委派的 controllers,因此某項屬性可能會在其中遭拒。發生這種情況時,請搭配 sudo 執行。當工作需要永久保存時,將設定原封不動移至正式 unit:請參閱將指令碼作為 systemd 服務與計時器執行。
誠實回答 swap 問題
swap 會改變故障的形式,而不是避免故障。
沒有 swap 時,記憶體洩漏會在幾秒內觸及上限,接著某個程序就會終止。服務中斷明顯、持續時間短,事後也容易從 journal 判讀原因。有 swap 時,kernel 會將不常使用的匿名頁面寫入磁碟,爭取更多時間。如果程序原本會穩定在某個用量,swap 可以避免故障。如果程序持續失控,swap 會把 5 秒的中斷變成 20 分鐘的停滯;而且停滯更糟,因為程序終止時仍會留下可用的 shell,但持續 thrashing 的主機不會。
swapon --show
free -h對小型 VPS 而言,一種可行的折衷方式是保留容量適中的 swap file,供只配置一次且之後不再存取的頁面使用;對於可以容許終止的 units,則設定 MemorySwapMax=0。重要服務保留使用 swap 的能力。不穩定的服務則快速觸及限制並重新啟動。
降低 vm.swappiness 的效果有限,但有必要了解原因。它只會在驅逐 page cache 與將匿名頁面換出至 swap 之間調整平衡,而兩者之後都需要從磁碟讀取資料。它改變的是哪些頁面發生 thrashing,而不是主機是否發生 thrashing。
OOM daemon 會在系統停滯前終止程序
核心必須等到回收記憶體完全失敗才會處理 OOM;在小型 VPS 上,這段等待時間正是主機失去回應的時機。兩個 userspace 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,而不是單一程序,因此終止的是一個 unit,而非失控的子程序。unit 可使用 ManagedOOMMemoryPressure=kill 或 ManagedOOMSwap=kill 選擇加入,門檻位於 /etc/systemd/oomd.conf。
systemctl status systemd-oomd
oomctloomctl 會列出目前正在監控的項目。伺服器映像通常不會列出任何項目,因為設定必須由各 unit 個別選擇加入。請選擇一個 daemon 使用即可。兩者同時執行時,會有兩個元件競相選擇要終止的程序,也會使日後更難還原終止程序的原因。
哪個 unit 負責?
先從 kernel 開始,因為它會記錄每次 kill。
journalctl -k --grep "Killed process" --since "2 hours ago"global 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 是該 process 終止時仍保留在 RAM 中的記憶體,這裡約為 1.8 GB。請審慎解讀方括號中的名稱。那是 kernel 選出的受害 process,但 kernel 會選擇最大的 process,未必是造成記憶體不足的 process。
cgroup limit 觸發的 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 表示某個 unit 達到你設定的 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 會計算該 unit 超過 MemoryHigh= 並遭到 throttling 的次數。max 會計算它達到 hard cap 的次數,oom_kill 則會計算實際遭 kill 的 process 數量。high 很大但搭配 oom_kill 0,就是前面提到的無聲案例:服務仍在執行,但速度慢到幾乎停滯,也沒有向任何人回報失敗。memory.peak(Linux 5.19 及更新版本)會保存 cgroup 達到的最高使用量,這是設定 MemoryMax= 時應參考的數值。這兩個檔案會在 unit 重新啟動時重設,因為 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 顯示的數量大於目前 boot,表示歷史資料現在會保留,因此 journalctl -k -b -1 可以顯示導致主機停止運作之 boot 的 kernel 訊息。
小型 VPS 的起點
在 2 GB 方案中,保留 300 到 400 MB 給 kernel 和 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 的機率。這可能決定你能修復主機,還是必須從控制面板重新開機。它只會影響 kernel 選擇終止對象的方式,不會縮短停滯時間。
容器使用由 container runtime 建立的專屬 cgroup,而不是由你的 unit file 建立。因此,對 docker.service 設定的限制不會套用成單一容器的限制。MemoryMax= 和 CPUQuota= 的每個容器對應設定,請參閱 在 Docker Compose 中設定 memory 和 CPU 限制。
FAQ
為什麼我的 VPS 會凍結,而不是終止失控的程序?
因為 kernel 判斷是否有進展,是看回收作業是否能釋放 page,而不是看回收花了多久。記憶體不足時,kernel 會驅逐 page cache,其中也包括執行中程式的 executable page,然後在下一個指令執行時再讀回。所有作業都在等待儲存裝置,但配置作業在技術上尚未失敗,因此不會呼叫 OOM killer。問題發生時,請檢查 /proc/pressure/memory:full avg10 高於 40,表示過去 10 秒幾乎沒有 task 得以執行。使用 earlyoom 之類的 userspace daemon,可在主機進入這種狀態前終止程序。
MemoryHigh 和 MemoryMax 有何差異?
MemoryHigh= 是會限制資源的軟上限。kernel 會強制從該 unit 回收記憶體,並降低其配置速度,但使用量可以超過此數值,且不會終止任何程序。MemoryMax= 是硬上限:在此上限下無法滿足的配置要求,會在該 unit 自己的 cgroup 內呼叫 OOM killer,因此造成問題的程序會被終止,而不是主機上使用量最大的程序。請將 MemoryHigh= 設定低於 MemoryMax=,並將兩者之間的差距視為警告區。
如何找出 OOM killer 終止了哪個服務?
執行 journalctl -k --grep "Killed process" --since "2 hours ago"。以 Memory cgroup out of memory 開頭的行表示某個 unit 觸發了自己的 MemoryMax=;單獨出現 Out of memory 則表示整台機器記憶體耗盡。接著執行 journalctl -u <unit> -n 50,並尋找 Failed with result 'oom-kill'。如果伺服器上不存在 /var/log/journal,表示 journal 儲存在 RAM 中,相關證據已隨重新開機而遺失。因此,請在下次事故前建立該目錄。
小型 VPS 應該加入 swap 嗎?
小型 swap 檔案有助於處理只配置一次、之後不再存取的 cold page。它無法解決失控程序的問題:swap 只會延後終止時間,並將短暫中斷變成無法登入處理的長時間停滯。請將 swap 維持在適度大小,並在可以承受終止的 unit 上設定 MemorySwapMax=0,讓這些 unit 達到上限後快速重新啟動,同時讓重要服務繼續使用自己的 swap。
不建立 unit file,也能限制命令嗎?
可以。sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh 會在終端機中,以指定的限制於 transient scope 內執行命令;命令結束後,這些限制也會消失。systemd.resource-control 中的每個 property,在 -p 之後都可使用,因此 MemorySwapMax=、TasksMax= 和 CPUWeight= 也能在此使用。移除 --scope,並加入 --unit=name,即可在背景執行工作,且將輸出寫入 journal。