前回はメモリ保護の動きを確認しました。今回は、ここまで紹介したメモリ制限やメモリ保護において、階層構造が考慮されることを、実際に試しながら確認します。
今回の実行例はUbuntu 24.
階層構造とメモリコントローラ
cgroupは、この連載の第37回で説明したように階層構造を取ります。今回は、メモリコントローラの階層構造を考慮したカーネルデフォルトの動きを紹介します。
階層のそれぞれでコントローラを有効にし、制限や保護を設定した場合、基本的には階層構造が考慮されます。
階層構造が考慮されるというのは、あるcgroupで設定する値が、そのcgroupの子孫が使うリソースにも影響するということです。祖先で設定した値を超過するような値を子孫で設定しても、祖先で設定した値に制限されます。
複数のツリーがある場合、あるサブツリー全体のリソースを制御するには、上位のcgroupで設定しておけば、それ以下のツリー全体で設定が効きます。
図1のようなcgroup階層がある場合、test011
cgroupは、test01
cgroupで設定したメモリ制限や保護の影響を受けます。
test01
cgroupで設定した制限値より大きな値でtest011
cgroupでメモリ制限を設定しても、test01
cgroupの設定値でメモリ制限が働きます。同様に、test01
cgroupで設定した設定値より大きな値で、test011
cgroupにメモリ保護を設定しても、test01
cgroupで設定した値でメモリ保護が働きます。
ただし、メモリ保護においては、上位のcgroupで設定した値が、その子孫では効かないことがあります。先に

ここで、このあとの実行例を試す前に、cgroup v2のカーネルデフォルトの動きを見るために必要な操作をします。
Ubuntu 24.memory_
が指定されています。マウント情報を見ると、次のようにmemory_
が指定されていることがわかります[1]。
$ grep cgroup /proc/self/mountinfo 34 25 0:29 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime shared:9 - cgroup2 cgroup2 rw,nsdelegate,memory_recursiveprot
このオプションが指定されていると、デフォルトと動きが異なってしまうので、まずはこのオプションを外した状態で再マウントします。
$ sudo mount -t cgroup2 -o remount,rw,nosuid,nodev,noexec,relatime,nsdelegate cgroup2 /sys/fs/cgroup/ (memory_recursiveprotを削除して再マウント) $ grep cgroup /proc/self/mountinfo 35 25 0:30 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime shared:9 - cgroup2 cgroup2 rw,nsdelegate (memory_recursiveprotが指定されていない状態でマウントされた)
図1のcgroup階層を作ります。そして、test011
cgroupでメモリコントローラを有効にします。子cgroupでコントローラを有効にする方法については第38回をご覧ください。
$ sudo mkdir -p /sys/fs/cgroup/test01/test011 (test01,test011 cgroupを作成) $ sudo mkdir /sys/fs/cgroup/test02 (test02 cgroupを作成) $ echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control +memory (子cgroupでメモリコントローラが使えるようにroot cgroupで設定) $ echo "+memory" | sudo tee /sys/fs/cgroup/test01/cgroup.subtree_control (test01の子cgroupであるtest011でメモリコントローラが使えるように設定) +memory
これで準備ができました。次のようなcgroupを作って階層構造が考慮された動きを見ていきましょう。
階層構造とメモリ制限
最初に、階層構造でメモリ制限がどのように効くのかを確認していきます。
親cgroupのメモリ制限値より、子cgroupのメモリ制限値が小さい場合
まずは、親cgroupのメモリ制限値より小さい値を子cgroupに設定した場合を試してみましょう。
この設定は、プロセスが所属するcgroupの設定通りにメモリ消費が制限されるという当然の結果が予想できます。
表1のように、親cgrouptest01
)test011
)
cgroup | memory. の値 |
プログラムの消費メモリ |
---|---|---|
test01 |
384M | - |
test011 |
128M | 256M |
次のように子cgrouptest011
)stress-ng
コマンドを実行します。
$ echo $$ | sudo tee /sys/fs/cgroup/test01/test011/cgroup.procs (シェルをtest011 cgroupに登録) 1266 $ echo 384M | sudo tee /sys/fs/cgroup/test01/memory.max (親であるtest01のmemory.maxを384MBに設定) 384M $ echo 128M | sudo tee /sys/fs/cgroup/test01/test011/memory.max (子であるtest011のmemory.maxを128MBに設定) 128M $ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v
このとき、別シェルでtest011
cgroupのメモリ使用量を確認すると、ほぼ設定値どおりであることがわかります。
$ while :; do sleep 2; cat /sys/fs/cgroup/test01/test011/memory.current ; done 134205440 134152192 134152192 :(略)
この動きは図2のとおり、プロセス消費するメモリは所属するcgroupの設定値で制限されるという、予想どおりの結果です。

親cgroupのメモリ制限値より、子cgroupのメモリ制限値が大きい場合
次に、親cgroupで設定したメモリ制限値より、子cgroupで設定したメモリ制限値が大きい場合にどうなるかを見てみましょう。
表2のように親cgrouptest01
)test011
)test011
cgroupでメモリを消費させます。
cgroup | memory. の値 |
プログラムの消費メモリ |
---|---|---|
test01 |
128M | - |
test011 |
384M | 256M |
次のようにstress-ng
コマンドを、子cgrouptest011
)test01
cgroupで設定した制限値より大きい256MBのメモリを消費するように起動します。
$ echo $$ | sudo tee /sys/fs/cgroup/test01/test011/cgroup.procs (シェルのプロセスをtest011に登録) 1015 $ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.max (親であるtest01のmemory.maxを128MBに設定) 128M $ echo 384M | sudo tee /sys/fs/cgroup/test01/test011/memory.max (子であるtest01のmemory.maxを384MBに設定) 384M $ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v :(略)
このとき、別シェルからtest011
cgroupのメモリ使用量を確認します。test011
cgroupで設定した制限値は384MBで、プログラムは256MBを消費するように指定して起動していますので、256MBを消費しそうです。しかし、親cgroupであるtest01
cgroupで設定した制限値である128MBに近い値を消費にとどまっており、親cgroupで設定した制限値が効いていることがわかります。
$ while :; do sleep 2; cat /sys/fs/cgroup/test01/test011/memory.current ; done (test011のメモリ使用量を確認) 134184960 134184960 134184960 :(略)
このとき、同時にtest01
cgroupのメモリ使用量を確認すると、test01
cgroupで設定したとおり、128MBに近い値になっています。
$ while :; do sleep 2; cat /sys/fs/cgroup/test01/memory.current ; done (親であるtest01のメモリ使用量を確認) 134217728 134217728 134217728 :(略)
つまり図3のように、親cgroupで設定した制限値によってメモリ消費が制限されていることが確認できました。

ここまでで見た、メモリ制限の階層構造での動きをまとめると表3のようになります。
親子のメモリ制限値の設定状況 | 子cgroupでのメモリ保護の動き |
---|---|
親cgroupのメモリ制限値 < 子cgroupのメモリ制限値 | 親cgroupのメモリ制限値が効く |
親cgroupのメモリ制限値 ≧ 子cgroupのメモリ制限値 | 子cgroupのメモリ制限値が効く |
階層構造が深くなっても親で設定される制限値で制限されることは変わりません。メモリコントローラのメモリ制限は、ツリーをroot cgroupまでたどった際に、ツリーの祖先で設定されている最小の制限値が適用されます。
つまり、祖先で設定された制限値を子孫に分配するという考え方です。
階層構造とメモリ保護
次に、メモリ保護において、メモリコントローラが階層構造が考慮する動きを確認してみましょう。
シンプルで理解しやすかったメモリ制限に比べて、メモリ保護での動きはやや複雑です。
親cgroupでメモリ保護を設定し、子cgroupではメモリ保護を設定しない場合
まずは親cgroupでメモリ保護を設定し、子cgroupでは何も設定しないデフォルトの状態で試してみましょう。
表4のように、親cgroupでのみ保証値を設定し、その子cgroupでは何も設定せず、memory.
はデフォルト値のままとします。
cgroup | memory. の値 |
プログラムの消費メモリ |
---|---|---|
test01 |
256M | - |
test011 |
0 | 256M |
test02 |
0 | 1700M |
親cgroupであるtest01
にだけ保証値として256MBを設定し、子cgroupであるtest011
でメモリを256MB消費するプログラムを実行します。
そして、test01
cgroupとは別のツリーであるtest02
cgroupでメモリを大量に消費させるプログラムを実行します。この実行例の環境はメモリを 2GB しか積んでいませんので、メモリを 1700MB 消費させるとメモリ回収が始まるはずです。
この場合、test011
cgroupに所属するプロセスのメモリは保護されるでしょうか?
親cgrouptest01
)memory.
を256MBに設定し、子cgrouptest011
)stress-ng
コマンドを実行します。
$ echo $$ | sudo tee /sys/fs/cgroup/test01/test011/cgroup.procs (シェルをtest011に登録) 1011 $ echo 256M | sudo tee /sys/fs/cgroup/test01/memory.min (test01のmemory.minに256MBを設定) 256M $ cat /sys/fs/cgroup/test01/test011/memory.min 0 (test011のmemory.minは0) $ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v (メモリを256MB消費するようにstress-ngを実行)
そしてtest02
cgroupで、大量にメモリを消費させるようにstress-ng
を実行します。
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs (シェルをtest02に登録) 1058 $ stress-ng --vm 1 --vm-bytes 1700M --page-in --vm-hang 0 -v :(略)
このとき、test011
cgroupのメモリ使用量を確認すると、次のようになりました。
while :; do sleep 2; cat /sys/fs/cgroup/test01/test011/memory.current ; done (test011のメモリ使用量を確認) :(略) 295792640 295792640 295792640 208896000 17625088 12591104 4509696 4509696 4509696 :(略)
最初はstress-ngへの指定どおり256MB程度を消費していましたが、test02
cgroupで大量にメモリが消費されるとメモリが回収され、最終的にメモリ消費は約4MBにまで減少しました。
つまり図4のように、親cgroupでメモリ保護を設定しても、子cgroupで設定していなければ、子cgroupではメモリは保護されません。

親cgroupのメモリ保証値より、子cgroupのメモリ保証値が小さい場合
次は、親cgroupで設定した保証値より、子cgroupで設定した保証値のほうが小さい場合の動きを見てみましょう。
この設定は、プロセスが所属するcgroupの設定どおりにメモリ保護が効くという、当然の結果が予想できます。
表5のように、親cgrouptest01
)test011
)
cgroup | memory. の値 |
プログラムの消費メモリ |
---|---|---|
test01 |
128M | - |
test011 |
64M | 256M |
test02 |
0 | 1700M |
ここまでと同様に、子cgrouptest011
)stress-ng
コマンドを実行します。
$ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.min (親であるtest01のmemory.minを128MBに設定) 128M $ echo 64M | sudo tee /sys/fs/cgroup/test01/test011/memory.min (子であるtest011のmemory.minを64MBに設定) 64M $ echo $$ | sudo tee /sys/fs/cgroup/test01/test011/cgroup.procs (シェルをtest011に登録) 1028 $ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v :(略)
そして、test02
cgroupに所属する別シェルから大量にメモリを消費させます。
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs (シェルをtest02に登録) 1153 $ stress-ng --vm 1 --vm-bytes 1700M --page-in --vm-hang 0 -v :(略)
test01
cgroupのmemory.
を観測すると、次のようになりました。
$ while :; do sleep 2; cat /sys/fs/cgroup/test01/test011/memory.current ; done (test011のメモリ使用量を確認) :(略) 295780352 295780352 254341120 66863104 66863104 66863104 66863104 66863104 :(略)
メモリは回収されましたが、メモリ消費は子cgroupであるtest011
cgroupで設定した64MBに近いところで落ち着き、同cgroupで設定したメモリ保護が効いていることがわかります。

プロセスが消費するメモリは、所属するcgroupの設定値で保護されるという予想どおりの結果になりました。
親cgroupのメモリ保証値より、子cgroupのメモリ保証値が大きい場合
それでは、親cgroupの保証値よりも、子cgroupの保証値を大きく設定した場合の動きを見てみましょう。
表6のように、親cgrouptest01
)test011
)
cgroup | memory. の値 |
プログラムの消費メモリ |
---|---|---|
test01 |
128M | - |
test011 |
256M | 256M |
test02 |
0 | 1700M |
表6のとおりに各cgroupを設定し、親cgrouptest01
)stress-ng
コマンドを起動します。
$ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.min (親であるtest01のmemory.minを128MBに設定) 128M $ echo 256M | sudo tee /sys/fs/cgroup/test01/test011/memory.min (子であるtest011のmemory.minを256MBに設定) 256M $ echo $$ | sudo tee /sys/fs/cgroup/test01/test011/cgroup.procs (シェルをtest011に登録) 1008 $ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v :(略)
そして、test02
cgroupで大量にメモリを消費させるようにstress-ng
を実行します。
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs (シェルをtest02に登録) 1058 $ stress-ng --vm 1 --vm-bytes 1700M --page-in --vm-hang 0 -v :(略)
test011
cgroupでmemory.
を観測すると、次のようになりました。
$ while :; do sleep 2; cat /sys/fs/cgroup/test01/test011/memory.current ; done (test011のメモリ使用量を確認) :(略) 295747584 295747584 286318592 134135808 134168576 134168576 :(略)
実行直後はstress-ng
で指定した256MB付近でメモリ消費が推移していました。その後、test02
cgroupでメモリの大量消費が始まると、test011
cgroupのmemory.
で指定した256MBよりも低い128MB付近までメモリが回収されています。
test01
cgroupでmemory.
を観測すると、test011
cgroupと同様の動きになっています。
$ while :; do sleep 2; cat /sys/fs/cgroup/test01/memory.current ; done (test01のメモリ使用量を確認) :(略) 295755776 295755776 295608320 134070272 134176768 134176768 :(略)
図6のように、子cgroupに親cgroupの保証値より大きな値を設定しても、有効なのは親cgroupで設定した保証値です。つまり、階層構造が考慮されていることがわかります。

ここまでのメモリ保護の階層構造での動きをまとめると表7のようになります。
親子のメモリ保証値の設定状況 | 子cgroupでのメモリ保護の動き |
---|---|
親cgroupでメモリ保証値を設定、子cgroupは設定しない |
子cgroupではメモリ保護は働かない |
親cgroupのメモリ保証値 ≧ 子cgroupのメモリ保証値 | 子cgroupのメモリ保証値が効く |
親cgroupのメモリ保証値 < 子cgroupのメモリ保証値 | 親cgroupのメモリ保証値が効く |
メモリ保護のオーバーコミット
ここまでの例で見た、子cgroupで設定した保証値が親cgroupで設定した保証値を超えるような設定は、少し不自然に思えるかもしれません。
しかし、コンテナの管理権限が委譲され、祖先で設定されているの設定値を確認できない場合、自身が権限を持つcgroupで、祖先で設定されている値を超える値を設定してしまう可能性があります。そのような場合でも、祖先cgroupで設定されたメモリ保護は有効に働き、ツリー全体が適切に保護されます。
また、階層が深くなり子孫のcgroupが多数存在する場合、すべての子孫で設定されているcgroupのメモリ保証値の和が、祖先のcgroupで設定されている保証値を超えるような設定は考えられます。
単一のcgroupではメモリを消費しても、同じ階層にあるcgroupに所属するタスクが、同時には親の保証値を超えて消費をしない場合を考えてみましょう。このような場合、図7のように子cgroupぞれぞれに設定されている保証値は親cgroupで設定されている値を超えないものの、子cgroupで設定されている値の合計は親cgroupで設定されている値を超えるような、オーバーコミットとなる設定をすることが考えられます。

この場合、子cgroupで消費されているメモリの合計が親cgroupで設定されている保証値によって保護されます。子cgroupはそれぞれ、自身に設定している保証値を超えない範囲で、メモリ消費量に比例して親cgroupから保護されたメモリを受け取ります。
図7のように設定されている場合で、test012
cgroupに所属するタスクがほとんどメモリを消費していないような場合は、test011
cgroupに所属するタスクは256MB近くまでメモリが保護されるでしょう。
しかし、test012
cgroupに所属するタスクのメモリ消費が増えた場合は、test011
、test012
両方に所属するタスクは、それぞれ256MBまでメモリが保護されない可能性があります。しかし、親cgroupであるtest01
cgroupで設定されているメモリ保護はツリー全体で有効です。
まとめ
今回は、メモリコントローラーでメモリ制限やメモリ保護を設定した場合に、階層構造が考慮される動きを紹介しました。
メモリ制限では、階層構造が考慮され、祖先のcgroupで設定した値が子孫まで影響することがわかりました。
一方、表7のメモリ保護の動きを見て、違和感を覚えた方もいらっしゃるかもしれません。メモリ保護の場合では階層構造が考慮されていないケースがあるためです。
デフォルト値である"0"が設定されている場合、親cgroupでメモリ保護が設定されていても、子cgroupではメモリ保護が効きません。
メモリ保護を設定した場合は階層構造が考慮されるのに、明示的に設定しない場合は保護が効かないのは、不自然に感じるかもしれません。
このような動きになる原因は実装にあります。歴史的な経緯と実装の容易さのため、cgroupのメモリ保証値は、ツリーをroot cgroupまでたどったときの最小値が適用されるように実装されていました。
つまり、"0"が設定されているときは"0"が有効になり、メモリが保護されない状態になります。
このメモリ保護の動きは、メモリ制限や他のコントローラーのように、ツリーをroot cgroupまでたどった祖先の値を考慮するという動きと異なっています。
このため、ツリー内のタスクを保護するには、ツリー内のすべてのcgroupに保証値を設定する必要があるという問題がありました。
また、コンテナのネストなどでcgroupで権限委譲が発生すると、委譲されたサブツリー配下でメモリ保護を設定することが非常に難しくなるという問題もありました。
そこで、5.