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)によって適用されます。これは、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 をメモリいっぱいにせず停止させる理由
厳しいメモリ上限に達したプロセスが約 1 秒で終了し、サービスが再起動するなら、それは良いケースです。悪いケースでは、何も終了しません。サーバーは ping に応答し、SSH は接続を受け付けますが、シェルプロンプトが表示されません。マシンは稼働して処理を続けていますが、その処理は何の成果にもつながりません。
その仕組みは分かりにくいため、説明します。空きメモリが少なくなると、kernel は新しいメモリを割り当てる代わりにページを回収します。回収しやすいのは file-backed ページです。page cache には、実行中のすべてのプログラムの実行コードが保持されています。そのため kernel は sshd の text ページを追い出します。次に実行される sshd の命令は page fault になり、追い出されたバイト列をストレージから読み戻す必要があります。すべてのプロセスが実行する代わりにディスクの処理を待つ状態になります。同じページが追い出されては戻る処理を繰り返す状態を thrashing と呼びます。
VPS では、ノート PC よりこの状態が悪化しやすい理由が 2 つあります。ストレージがネットワーク接続または共有されていることが多く、各 page fault にかかる時間がローカルの NVMe デバイスより長くなります。また kernel は時間ではなく失敗を測定します。reclaim が遅くてもページを 1 つ返し続ける限り、kernel は処理が進んでいると判断し、out of memory (OOM) killer を呼び出しません。何かが kill されるまで、サーバーがその状態に何分も留まることがあります。
この状態は監視できます。Linux 4.20 以降では、kernel が pressure stall information (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=9114233重要なのは full の行です。full avg10=48.15 は、直近 10 秒間の 48% の時間で、サーバー上の idle ではないすべての task が同時にメモリ処理で停止していたことを示します。つまり、処理は何も進んでいませんでした。正常なサーバーの full は、ほぼ 0 です。10 を超えると人間にも遅く感じられ、40 以上になると一般にフリーズと表現される状態です。
このため、上限を設定するだけでは保証になりません。MemoryHigh= の制限下にある unit は kill されずに throttle されるため、動作し続けながら遅い状態になります。systemd から見ると失敗していないため、再起動も行われません。swap が許可された上限付き unit は、その unit に計上される読み書きを発生させますが、処理は 1 つの共有デバイスで行われます。そのため、サーバー上の他のすべてのサービスについて /proc/pressure/io を押し上げる可能性があります。制限によって不足分を負担する対象は決まりますが、容量を増やすことはできません。
VPS が cgroup v2 で動作していることを確認する
stat -fc %T /sys/fs/cgroupcgroup2fs は統合階層です。以下のメモリ設定にはこの階層が必要です。tmpfs の場合、サーバーは古い v1 レイアウトで起動しています。この場合、MemoryHigh= と MemorySwapMax= はサポートされず、unit ごとの OOM 動作も異なります。ただし、従来の cpu.cfs_quota_us ファイルを通じて CPUQuota= は引き続き動作します。Ubuntu 22.04 以降と Debian 11 以降では、デフォルトで v2 が使用されます。古いイメージでは使用されません。また、systemd 257 以前では、systemd.unified_cgroup_hierarchy=0 を指定して起動したカーネルでも v2 は使用されません。systemd 258 では cgroup v1 のサポートが削除され、起動時に常に統合階層がマウントされます。そのため、systemd 258 を採用するリリースでは、この引数を指定しても v1 に戻すことはできません。
cgroup v2 では、systemd がデフォルトで全 unit のメモリアカウンティングを有効にします。そのため、数値はすでに取得できます。
systemd-cgtop -mこのコマンドは、メモリ使用量の多い順に cgroup を一覧表示します。サーバーが応答できるうちに「このサーバーで何がメモリを消費しているか」を確認する最も速い方法です。サーバーを新規に構築した直後であれば、新しい VPS の最初の 10 分間に行うアカウント設定とファイアウォール設定を先に実施します。
MemoryHigh によるスロットリングと MemoryMax による強制終了
この2つのメモリ設定の違いによって、障害時の動作が決まります。
MemoryHigh=はソフト上限です。これを超えると、kernel はその cgroup から積極的にメモリを回収し、割り当てを意図的に遅くします。使用量はこの値を超えることがあり、プロセスは強制終了されません。MemoryMax=はハード上限です。この上限内で割り当てを満たせない場合、OOM killer がその cgroup 内で実行され、その unit 自身のプロセスを1つ強制終了します。
後半の動作が、完全には信頼できないものに MemoryMax= を設定する本当の理由です。上限がない場合、メモリ不足はマシン全体の問題になります。グローバルな OOM killer は oom_score に基づいて対象を選ぶため、通常は最大のプロセスが選ばれます。最大のプロセスは、メモリリークを起こした script ではなく、通常はデータベースです。上限を設定すれば、原因となった unit の内部で強制終了が発生します。
両方を設定し、MemoryHigh= は MemoryMax= より20〜30%程度低くします。この差が警告領域になります。緩やかなリークは High を超え、サービスの動作が遅くなった状態として現れます。一方、急激なスパイクは Max をそのまま超えて、プロセスが強制終了されます。
パーセント値は搭載された物理メモリを基準に解釈されます。そのため、4 GB のプランで MemoryMax=25% は1 GBとなり、プランを変更してもマシン全体の4分の1のままです。MemorySwapMax=0 を設定すると、その unit は swap を完全に使用しなくなります。そのため、MemoryMax= に達すると、swap を長時間使い続けた後ではなく、すぐに強制終了されます。それを下回る範囲では MemoryHigh= によってスロットリングできます。high limit を超えても OOM killer は実行されないため、2つの上限の間で推移する unit は停止せず、遅い状態が続きます。
一部のサービスでは、使用量を測定する代わりに、あらかじめ必要量を決められます。Ollama unit は、指定したコンテキストウィンドウに基づいて KV cache のサイズを決めます。そのため、上限を決める前に num_ctx を増やすと RAM がどれだけ必要になるか を確認してください。
上限には、再起動ポリシーも併せて設定する必要があります。そうしないと、強制終了後にサービスが停止したままになります。
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* は [Unit] に、Restart= は [Service] に記述します。どちらかを誤ったセクションに記述すると、systemd は無視します。5分間に5回の再起動は一時的な障害ではなく、リークの兆候です。その後 systemd は再起動を諦め、unit を failed 状態のままにします。これは、問題を隠すクラッシュループではなく、後から確認したい状態です。
CPUQuota で CPU を制限するか、CPUWeight で共有する
CPUQuota= は、1 つの CPU で利用可能な時間の割合を指定します。CPUQuota=50% は 1 コアの半分です。CPUQuota=200% は 2 コア相当で、unit は必要に応じて複数のスレッドに処理を分散できます。2 vCPU プランでは、CPUQuota=200% がマシン全体に相当します。
ほとんどのサービスでは、CPUWeight= をデフォルトにする方が適切です。これは 1 から 10000 までの相対的な配分で、kernel のデフォルトは 100 です。他の処理と競合するときだけ効果があります。負荷が高い場合、CPUWeight=20 のバックアップジョブは 100 の Web サーバーに CPU 時間を譲ります。一方、マシンがアイドル状態なら、マシン全体を使い続けられます。固定 quota では、そのアイドル中の余力を捨てることになります。
CPU 制限で得られる効果を正しく理解してください。CPU-bound process が原因で Linux 全体が停止することは、通常ほとんどありません。scheduler がすべての処理に CPU 時間を割り当て続けるためです。マシンを停止させる原因になるのは、メモリです。予測可能な上限が必要な場合は CPUQuota= を使用します。たとえば、1 時間にわたって CPU を使い切る build や agent が該当します。この種の workload のサイジングは別の問題であり、coding agent VPS に必要な RAM と CPU の量で説明しています。
自分の process がほとんど処理していないのに CPU 使用率が高い場合、原因は hypervisor の反対側にある可能性があります。これは 他の tenant による CPU steal time であり、自分で設定した quota では変えられません。
TasksMax で fork ループを停止する
TasksMax= は、unit が保持できるプロセスとスレッドの数です。スレッドも数に含まれるため、Java や Go のサービスには、プロセス一覧から分かる数よりも大きな上限が必要です。fork をループするスクリプトに対する最も低コストな保護策です。プロセス ID がサーバー全体で枯渇する前に、unit 内で fork が失敗します。
TasksMax=128unit が上限に達すると、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 は、単一のコマンド用に transient 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 を省略して名前を指定します。ジョブは transient service としてバックグラウンドで実行され、journal にログが記録されます。
sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -froot でない場合も、--user で同じオプションを使用できます。ただし、ユーザーの manager が使用できるのは委譲された controller だけなので、プロパティが拒否されることがあります。その場合は sudo で実行します。ジョブを恒久的に運用する段階になったら、設定を変更せずに実際の unit へ移します。systemd service と timer としてスクリプトを実行する を参照してください。
Swap に関する疑問への率直な回答
Swap は障害を防ぐのではなく、障害の現れ方を変えます。
Swap がない場合、メモリリークが上限に達すると、数秒以内に何かのプロセスが終了します。停止は明確で短時間に収まり、後から journal を確認すれば原因を読み取りやすくなります。Swap がある場合、kernel は使用頻度の低い anonymous page をディスクへ書き出し、時間を稼ぎます。プロセスのメモリ使用量が一定で収束するなら、Swap が有効です。暴走するプロセスの場合、Swap は5秒間の停止を20分間の遅延に変えます。この遅延のほうが深刻です。終了したプロセスなら shell は使い続けられますが、thrashing が発生したサーバーでは使えません。
swapon --show
free -h小規模な VPS では、現実的な構成として、1度だけ確保されて以降使用されないページ用に適度なサイズの swap file を用意し、失ってもよい unit に MemorySwapMax=0 を設定します。重要なサービスでは Swap を使用できる状態にします。予測できないサービスは早く上限に達し、上限の近くに設定した restart policy によって復旧します。
vm.swappiness を下げる方法の効果が弱い理由も理解しておく必要があります。この設定は、page cache を追い出すか anonymous page を swap するかのバランスを変えるだけであり、どちらの場合も後でディスク読み取りが発生します。どのページが thrashing するかは変わりますが、サーバーで thrashing が発生するかどうかは変わりません。
停止する前に動作する早期 OOM デーモン
カーネルは reclaim が完全に失敗するまで待機します。小規模な VPS では、その待機時間がマシンを失う正確な時間帯になります。ユーザー空間の 2 つのデーモンは、メモリを自分で監視して早めにプロセスを kill することで、この時間帯をなくします。
earlyoom は使用可能なメモリと free swap を毎秒最大 10 回確認し、両方がしきい値を下回った場合に限り、oom_score が最大のプロセスを kill します。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 は free swap の最小値を設定します。どちらもデフォルトでは 10 percent です。各組の 2 番目の値は SIGKILL のしきい値です。earlyoom は、メモリと swap の両方が各組の 1 番目の値を下回ると SIGTERM を送信します。その後、両方が 2 番目の値を下回ると SIGKILL を送信します。2 番目の値は、デフォルトでは 1 番目の値の半分です。--avoid と --prefer のパターンは、引用符で囲まず、空白も入れないでください。systemd は引用符をそのまま渡すため、引用符で囲んだパターンは一致しません。変更を適用するには sudo systemctl restart earlyoom を実行します。どのプロセスを kill したか、そのプロセスが保持していたメモリ量を確認するには journalctl -u earlyoom を読みます。
systemd-oomd はもう 1 つのオプションです。マニュアルページでは、これを「cgroups-v2 と pressure stall information (PSI) を使用して、kernel space で OOM が発生する前に監視し、是正措置を取るシステムサービス」と説明しています。これは単一のプロセスではなく、cgroup 全体を対象にするため、個別の子プロセスではなく unit を kill します。unit は ManagedOOMMemoryPressure=kill または ManagedOOMSwap=kill で明示的に有効化し、しきい値は /etc/systemd/oomd.conf に設定します。
systemctl status systemd-oomd
oomctloomctl は現在監視している対象を表示します。ただし、unit ごとの opt-in 設定であるため、サーバーイメージでは何も表示されないことがよくあります。使用するデーモンは 1 つ選び、それだけにしてください。両方を実行すると、どちらが kill 対象を選ぶかを 2 つのデーモンが競合します。その結果、kill の理由を後から特定しにくくなります。
どの 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:0anon-rss は、プロセスが終了した時点で RAM に保持していた heap などの匿名メモリです。ここでは約 1.8 GB です。常駐ファイルマッピングと共有メモリは、それぞれ file-rss と shmem-rss として別に数えられます。角括弧内の名前は慎重に読み取ってください。これは 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 は、指定した MemoryMax= に 1 つの unit が達し、マシンの他の部分には問題がなかったことを示します。単純な 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) として 1 行で示します。
3 つ目の情報源は cgroup のカウンターです。スロットリングを記録する唯一の情報源でもあります。スロットリングはログ行を一切生成しません。
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= を超えてスロットリングされた回数を数えます。max はハード上限に達した回数を数え、oom_kill は実際に kill されたプロセス数を数えます。oom_kill 0 を伴う大きな high は、前述した静かなケースです。cgroup が作成されてから、unit は kill されることなく何度もスロットリングされています。そのため、systemd は障害を記録していません。カウンターは増えるだけなので、1 分間隔で 2 回読み取ります。増加し続ける high は、現在もスロットリングが発生していることを示します。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 プランでは、kernel と page cache 用に 300 から 400 MB を残します。各 unit が同時にピークに達する可能性があるため、上限の合計を 2 GB 全体に設定しないでください。重要なサービスに最大の割り当てを与え、その周辺にある不確実なサービスには上限を設定します。
[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5s接続手段を確保するために、もう 1 つ設定する価値があります。ssh.service の drop-in で OOMScoreAdjust=-500 を設定すると、global OOM killer が SSH daemon を victim に選ぶ可能性を大幅に下げられます。これにより、control panel から再起動するのではなく、サーバーを修復できます。この設定が変えるのは、kernel が選ぶ victim だけです。停止時間が短くなるわけではありません。
コンテナは独自の cgroup で実行されます。これらは unit file ではなく container runtime が作成します。そのため、docker.service に設定した上限は、個々のコンテナの上限にはなりません。MemoryMax= と CPUQuota= に対応するコンテナ単位の設定については、Docker Compose でメモリと CPU の上限を設定するで説明しています。
FAQ
なぜ暴走プロセスを停止する前に VPS がフリーズしたのですか?
kernel は、reclaim によってページを回収できているかどうかで処理の進行を判断するためです。メモリが不足すると、実行中のプログラムの実行ファイルページを含む page cache を追い出し、次の命令で再び読み込みます。すべての処理がストレージ待ちになりますが、割り当て自体は技術的には失敗していないため、OOM killer は呼び出されません。発生中に /proc/pressure/memory を確認してください。full avg10 が 40 を超えている場合、直近 10 秒の 40% 超で、アイドル状態でないすべてのタスクが同時にメモリ待ちで停止していたことを示します。earlyoom のような userspace daemon を使うと、システムがその状態に達する前にプロセスを停止できます。
MemoryHigh と MemoryMax の違いは何ですか?
MemoryHigh= は処理を抑制する soft cap です。kernel は unit から積極的にメモリを回収し、割り当てを遅くしますが、使用量はその値を超えることがあり、プロセスは停止されません。MemoryMax= は hard cap です。この上限内で満たせないメモリ割り当てが発生すると、その unit 自身の cgroup 内で OOM killer が実行されます。そのため、システム上で最大のプロセスではなく、問題を起こしたプロセスが停止します。MemoryHigh= は MemoryMax= より低く設定し、その間を警告ゾーンとして扱ってください。
OOM killer が停止したサービスを特定するにはどうすればよいですか?
journalctl -k --grep "Killed process" --since "2 hours ago" を実行します。Memory cgroup out of memory で始まる行は、1 つの unit が独自の MemoryMax= に達したことを示します。一方、単独の Out of memory は、マシン全体のメモリが枯渇したことを示します。次に journalctl -u <unit> -n 50 を実行し、Failed with result 'oom-kill' を探します。サーバーに /var/log/journal が存在しない場合、journal は RAM 上に保持されており、再起動時に証拠も失われています。次の障害が発生する前に、そのディレクトリを作成してください。
小規模な VPS に swap を追加すべきですか?
小さな swap file は、1 回だけ割り当てられて、その後アクセスされない cold page には有効です。ただし、暴走プロセスには効果がありません。停止までの時間が延び、短時間の障害が、ログインして修正することもできない長時間の停止に変わります。swap は控えめなサイズにし、停止してもよい unit には MemorySwapMax=0 を設定してください。これらの unit を早く上限に到達させ、Restart=on-failure を設定しておけば自動的に復旧できます。その間、重要なサービスは swap を使い続けられます。
unit file を記述せずにコマンドを制限できますか?
はい。sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh は、指定した制限を適用し、terminal 内のコマンドを一時的な scope で実行します。コマンドが終了すると、制限もなくなります。systemd.resource-control のすべての property は -p の後で利用できるため、MemorySwapMax=、TasksMax=、CPUWeight= も使用できます。--scope を削除し、--unit=name を追加すると、ジョブをバックグラウンドで実行し、その出力を journal に記録できます。