SSD Nodes Learn 🎉 VPS $5.50/月〜
ガイド Matt Connor著者 Matt Connor

systemdでプロセスのメモリとCPUを制限する方法

systemdのdrop-inでMemoryHigh、MemoryMax、CPUQuota、TasksMaxを設定する手順です。上限後もVPS全体が停止するスラッシングとOOM killの確認方法を解説します。

systemd の drop-in でプロセスのメモリと CPU を制限する

プロセスを実行する unit に数行追加すると、Linux VPS 上のプロセスが使用できるメモリと CPU を制限できます。MemoryMax= はメモリのハード上限です。CPUQuota= は CPU 時間の上限です。どちらも cgroup v2(control groups、version 2)によって適用されます。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=805306368CPUQuotaPerSecUSec=800ms で表示させる必要があります。MemoryMax=infinity と表示された場合、drop-in は読み込まれていません。ファイルが /etc/systemd/system/myapp.service.d/override.conf に作成されていることと、[Service] ヘッダーで始まっていることを確認してください。設定行の前にセクションがないと、systemd は Assignment outside of section. Ignoring. をログに記録し、制限なしでサービスを起動します。

このガイドの残りでは、これらの数値の選び方と、設定後にも発生する問題について説明します。

暴走したプロセスが VPS を停止させる理由

厳しいメモリ上限に達したプロセスが約 1 秒で終了し、サービスが再起動するなら、それは良いケースです。悪いケースは、何も終了しない状態です。サーバーは ping に応答し、SSH は接続を受け付けますが、シェルプロンプトは表示されません。マシンは稼働していて処理も続いていますが、その処理は何の役にも立ちません。

その仕組みを説明します。これは直感的には分かりにくい動作です。空きメモリが少なくなると、カーネルは新しいメモリを割り当てる代わりに、ページを回収します。回収しやすいのはファイルに裏付けられたページです。ページキャッシュには、実行中のすべてのプログラムの実行コードが保持されています。そのためカーネルは sshd のコードページを追い出します。すると次に実行される sshd 命令でページフォールトが発生し、そのバイト列をストレージから読み戻す必要が生じます。すべてのプロセスが実行される代わりにディスクの処理を待つ状態になります。同じページが追い出されては戻されるループが発生します。これをスラッシングと呼びます。

VPS では、ノート PC よりもこの状態が悪化しやすい理由が 2 つあります。ストレージがネットワーク接続または共有の場合が多く、ページフォールト 1 回あたりの待ち時間がローカルの NVMe デバイスより長くなります。また、カーネルが測定するのは時間ではなく失敗です。回収処理によって、どれほど遅くてもページが 1 つ返され続ける限り、カーネルは処理が進んでいると判断し、out of memory (OOM) killer を呼び出しません。何かが kill されるまで、サーバーがその状態で何分も留まることがあります。

この状態は監視できます。Linux 4.20 以降では、カーネルが pressure stall information (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 は、直近 10 秒間のうち 48% の時間で、サーバー上の実行可能なタスクが すべて メモリ処理の待機によって停止し、何も実行されなかったことを意味します。正常なサーバーでは、full はほぼ 0 です。10 を超えると人間にも遅延を感じられ、40 以上になると、一般にフリーズと表現される状態になります。

このため、上限を設定するだけでは保証になりません。MemoryHigh= の制限を受ける unit は kill されずにスロットリングされるため、稼働したまま遅い状態になります。systemd から見ると失敗していないため、再起動も行われません。swap の使用を許可された制限付き unit では、その unit に課金される読み書きが発生しますが、処理は 1 つの共有デバイスによって提供されます。その結果、サーバー上の他のすべてのサービスでも /proc/pressure/io が上昇する可能性があります。制限は不足分を誰が負担するかを決めるだけで、容量を増やすことはできません。

VPS が cgroup v2 で動作していることを確認する

stat -fc %T /sys/fs/cgroup

cgroup2fs は統合階層です。以下のすべての設定で必要になります。tmpfs の場合、サーバーは古い v1 レイアウトで起動しています。この構成には MemoryHigh=MemorySwapMax= がなく、unit ごとの OOM 動作も異なります。Ubuntu 22.04 以降と Debian 11 以降では、デフォルトで v2 が使用されます。古いイメージや systemd.unified_cgroup_hierarchy=0 で起動したカーネルでは使用されません。

cgroup v2 では、systemd がデフォルトですべての unit のメモリーアカウンティングを有効にします。そのため、値はすでに取得されています。

systemd-cgtop -m

このコマンドは、メモリー使用量の順に cgroup を一覧表示します。サーバーがまだ応答できる間に、「このサーバーで何がメモリーを消費しているのか」を確認する最も速い方法です。サーバーを新規に構築した場合は、新しい VPS で最初の 10 分間に行う作業でのアカウント設定とファイアウォール設定を先に実施します。

MemoryHigh はスロットリングを発生させます。MemoryMax はプロセスを強制終了します。

2 つのメモリー設定の違いによって、障害時の動作が決まります。

  • MemoryHigh= はソフト上限です。この値を超えると、カーネルはその cgroup から積極的にメモリーを回収し、意図的に割り当てを遅くします。使用量はこの値を超えることがありますが、プロセスは強制終了されません。
  • MemoryMax= はハード上限です。この上限内でメモリー割り当てを満たせない場合、OOM killer がその cgroup 内で動作し、その unit 自身のプロセスを 1 つ強制終了します。

2 つ目の動作が、完全には信頼できないものに MemoryMax= を設定する本当の理由です。上限がない場合、メモリー不足はシステム全体の問題になり、グローバル OOM killer は oom_score に基づいて強制終了する対象を選びます。多くの場合、これは最大のプロセスです。最大のプロセスは通常、メモリーリークを起こしたスクリプトではなくデータベースです。上限を設定すると、原因となった unit 内でプロセスが強制終了されます。

両方を設定し、MemoryHigh=MemoryMax= より 20 から 30 パーセント程度低くします。この差が警告領域になります。緩やかなメモリーリークが High を超えると、サービスの動作が遅くなって現れます。一方、急激なスパイクは Max を一気に超えて、サービスが停止します。

パーセント値は搭載された物理メモリーを基準に解釈されます。そのため、4 GB プランでの MemoryMax=25% は 1 GB であり、プランを変更してもシステム全体の 4 分の 1 のままです。MemorySwapMax=0 を設定すると、その unit は swap を完全に使用しなくなります。長時間の低速化が、すぐに判別できる強制終了に変わります。

上限を設定する場合は、その近くに再起動ポリシーも設定する必要があります。そうしないと、強制終了後に停止したサービスだけが残ります。

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

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

StartLimit*[Unit] に、Restart=[Service] に記述します。どちらかを誤ったセクションに記述すると、systemd は無視します。5 分間に 5 回再起動する状態は一時的な障害ではなく、メモリーリークです。そのため、systemd はそこで再起動を諦め、unit を failed 状態のままにします。後から問題を発見するには、この状態の方が適しています。問題を隠してしまうクラッシュループを避けられるためです。

CPUQuota で CPU 使用率を制限するか、CPUWeight で CPU 時間を共有する

CPUQuota= は、1 つの CPU で利用可能な時間のうち、指定した割合を使用します。CPUQuota=50% は 1 コアの半分です。CPUQuota=200% は 2 コア相当で、unit は必要に応じて複数のスレッドに分散できます。2 vCPU プランでは、CPUQuota=200% がマシン全体に相当します。

ほとんどのサービスでは、CPUWeight= をデフォルトにするのが適切です。これは 1 から 10000 までの相対的な配分で、カーネルのデフォルト値は 100 です。CPU の競合がある場合にだけ効きます。負荷が高いときは、CPUWeight=20 のバックアップジョブが 100 の Web サーバーに CPU 時間を譲ります。一方、マシンがアイドル状態なら、バックアップジョブは CPU 全体を使用できます。固定クォータでは、このアイドル状態の余力を無駄にします。

CPU 制限で得られる効果を正しく理解してください。CPU バウンドのプロセスが Linux を停止させることは、通常ありません。スケジューラーがすべてのプロセスに CPU 時間を割り当て続けるためです。マシンを停止させる原因になるのは、メモリであることが多いです。ビルドや、設定しなければ 1 時間にわたって CPU を使い切るエージェントなど、予測可能な上限が必要な場合は、CPUQuota= を使用します。この種のワークロードに必要なサイズの判断は別の問題であり、コーディングエージェントの VPS に必要な RAM と CPU の量 で説明しています。

プロセスの使用量が少ないのに CPU がビジーと表示される場合、原因はハイパーバイザーの反対側にある可能性があります。これは 近隣の高負荷なテナントによる CPU steal time であり、設定したクォータでは変えられません。

TasksMax で fork ループを停止する

TasksMax= は、unit が保持できるプロセスとスレッドの数です。スレッドも数えられるため、Java や Go のサービスには、プロセス一覧から想定される数より大きな余裕が必要です。これは、ループ内で fork するスクリプトに対する最も低コストな保護策です。unit 内で fork が失敗するため、ホスト全体のプロセス ID を使い果たさずに済みます。

TasksMax=128

unit が上限に達すると、kernel は cgroup 名を含む行をログに記録します。

cgroup: fork rejected by pids controller in /system.slice/myapp.service

プログラム自体は通常、fork: retry: Resource temporarily unavailable を報告します。manager がデフォルトで適用する設定は、systemctl show -p DefaultTasksMax で確認します。

systemd-run で単発ジョブを制限する

これらの機能を使用するために、unit file は必要ありません。systemd-run は、1 つのコマンド専用の一時的な 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 で同じオプションを使用できます。ただし、ユーザーマネージャーで使用できるのは委譲された controller だけなので、プロパティが拒否される場合があります。その場合は sudo で実行してください。ジョブを永続的に管理する必要が生じたら、設定を変更せずに実際の unit へ移せます。スクリプトを systemd のサービスおよびタイマーとして実行する を参照してください。

スワップについて、正直に答えます

スワップは障害を防ぐのではなく、障害の現れ方を変えます。

スワップがない場合、メモリリークが上限に達すると、数秒以内に何かのプロセスが停止します。障害は明確で短時間に収束し、その後 journal を確認すれば原因を読み取りやすくなります。スワップがある場合、カーネルは使用頻度の低い匿名ページをディスクへ書き出し、時間を稼ぎます。プロセスが最終的に安定するのであれば、スワップが有効です。しかし暴走するプロセスの場合、スワップによって 5 秒の障害が 20 分間の停止状態に変わります。この停止状態のほうが深刻です。プロセスが停止してもシェルは使えますが、スラッシングしているサーバーでは使えません。

swapon --show
free -h

小規模な VPS では、現実的な構成として、1 度だけ割り当てられて以後ほとんど使われないページに備えて、適度なサイズのスワップファイルを用意します。そのうえで、失ってもよい unit に MemorySwapMax=0 を設定します。重要なサービスではスワップを利用できるようにします。予測できないサービスは早く上限に達するようにし、再起動させます。

vm.swappiness を下げる方法は効果が限定的です。その理由を理解しておく必要があります。これは、ページキャッシュを追い出すか、匿名ページをスワップするかのバランスを変えるだけで、どちらの場合も後でディスク読み取りが発生します。どのページがスラッシングするかは変わりますが、サーバーがスラッシングするかどうかは変わりません。

停止前に OOM デーモンでプロセスを終了させる

カーネルは reclaim が完全に失敗するまで待機します。小規模な VPS では、その待機時間がマシンを失う正確な時間帯になります。ユーザー空間の 2 つのデーモンは、自身でメモリを監視し、より早くプロセスを終了させることで、この時間帯を埋めます。

earlyoom は使用可能なメモリと空き 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 percent です。各ペアの 2 番目の値は SIGKILL の実行点です。earlyoom は、最初の値を下回ると SIGTERM を送信し、2 番目の値を下回ると SIGKILL を送信します。2 番目の値は、デフォルトでは最初の値の半分です。sudo systemctl restart earlyoom で変更を適用し、journalctl -u earlyoom で終了させたプロセスと、そのプロセスが保持していたメモリ量を確認します。

systemd-oomd はもう 1 つの選択肢です。マニュアルページでは、これを「cgroups-v2 と pressure stall information (PSI) を使用して監視し、kernel space で OOM が発生する前に是正措置を取る system service」と説明しています。これは単一のプロセスではなく cgroup 全体を対象にするため、単独の子プロセスではなく unit を終了させます。unit は ManagedOOMMemoryPressure=kill または ManagedOOMSwap=kill で明示的に対象に含めます。しきい値は /etc/systemd/oomd.conf にあります。

systemctl status systemd-oomd
oomctl

oomctl は現在監視している対象を表示します。unit ごとの opt-in 設定であるため、サーバーイメージでは何も表示されないことがよくあります。デーモンは 1 つだけ選んでください。両方を実行すると、終了させる対象を 2 つのデーモンが競合して選ぶため、終了の理由を後から特定しにくくなります。

どの unit が原因だったのか

まず kernel を確認します。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:0

anon-rss は、プロセスが終了した時点で RAM 上に保持していたメモリ量です。ここでは約 1.8 GB です。角括弧内の名前はそのまま信用しないでください。そこに表示されるのは kernel が選んだ被害プロセスです。kernel は最大のプロセスを選ぶため、メモリ不足を引き起こしたプロセスとは限りません。

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 は、1 つの unit が指定された 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 は、同じ内容を 1 行で Active: failed (Result: oom-kill) として示します。

3 つ目の情報源は cgroup のカウンターです。スロットリングを記録する唯一の情報源でもあります。スロットリングはログ行を一切生成しません。

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 は、unit が MemoryHigh= を超えてスロットリングされた回数です。max はハード上限に達した回数、oom_kill は実際に kill されたプロセス数です。oom_kill 0 を伴う大きな high は、前述した検出しにくいケースです。サービスは実行中ですが、極端に遅くなっており、どこにも障害を報告していません。memory.peak(Linux 5.19 以降)には、cgroup が到達した最大使用量が記録されます。MemoryMax= のサイズを決める際は、この値を基準にします。unit が再起動すると、両方のファイルはリセットされます。systemd が cgroup を再作成するためです。

これらすべての前提となる条件が 1 つあります。/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-boots

journalctl --list-boots に現在の boot より多くの boot が表示されるなら、履歴はすでに保持されています。そのため journalctl -k -b -1 で、停止した boot の kernel メッセージを表示できます。

小規模 VPS の出発点

2 GB プランでは、カーネルとページキャッシュ用に 300〜400 MB を残します。各 unit が同時にピークに達する可能性があるため、上限の合計を 2 GB 全体に設定しないでください。重要なサービスに最も大きな割り当てを与え、その周囲にある予測的なサービスには上限を設定します。

[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5s

侵入経路を確保するために、設定を 1 つ追加する価値があります。OOMScoreAdjust=-500ssh.service の drop-in に設定すると、グローバル OOM killer が SSH daemon を強制終了対象に選ぶ可能性を大幅に下げられます。これは、マシンを修復できるか、control panel から再起動するしかないかの違いになります。この設定が変更するのは、カーネルが選ぶ強制終了対象だけです。停止時間を短縮するものではありません。

コンテナは独自の cgroup で実行されます。これらは unit ファイルではなく、コンテナランタイムが作成します。そのため、docker.service の上限は個々のコンテナの上限にはなりません。MemoryMax=CPUQuota= に相当するコンテナ単位の設定については、Docker Compose でメモリと CPU の上限を設定するで説明しています。

FAQ

暴走したプロセスを終了させる代わりに、VPS がフリーズしたのはなぜですか?

カーネルは、処理にかかった時間ではなく、メモリ回収によってページを解放できたかどうかで進行状況を判断するためです。メモリが不足すると、実行中のプログラムの実行可能ページを含むページキャッシュを追い出し、次の命令で再び読み込みます。すべての処理がストレージ待ちになりますが、メモリ割り当ては技術的には失敗していないため、OOM killer は呼び出されません。発生中に /proc/pressure/memory を確認してください。full avg10 が 40 を超えている場合、直近の 10 秒間にほとんどのタスクが実行できていません。earlyoom のようなユーザー空間デーモンを使うと、システムがその状態に達する前にプロセスを終了できます。

MemoryHigh と MemoryMax の違いは何ですか?

MemoryHigh= は、処理を抑制するソフト上限です。カーネルは 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 ファイルは、1 回だけ割り当てられ、その後ほとんど使用されないコールドページには有効です。ただし、暴走したプロセスには効果がありません。プロセスの終了を遅らせ、ログインして修正できない短時間の障害を、長時間の停止に置き換えるだけです。swap は控えめなサイズにし、失ってもよい unit には MemorySwapMax=0 を設定してください。これらの unit は上限に達すると速やかに再起動し、重要なサービスは 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 に記録できます。