LXCで学ぶコンテナ入門 -軽量仮想化環境を実現する技術

第58回Linuxカーネルのコンテナ機能 ―cgroup v2から使うメモリコントローラ(3)

前回はメモリ保護の動きを確認しました。今回は、ここまで紹介したメモリ制限やメモリ保護において、階層構造が考慮されることを、実際に試しながら確認します。

今回の実行例はUbuntu 24.04で実行しています。

階層構造とメモリコントローラ

cgroupは、この連載の第37回で説明したように階層構造を取ります。今回は、メモリコントローラの階層構造を考慮したカーネルデフォルトの動きを紹介します。

階層のそれぞれでコントローラを有効にし、制限や保護を設定した場合、基本的には階層構造が考慮されます。

階層構造が考慮されるというのは、あるcgroupで設定する値が、そのcgroupの子孫が使うリソースにも影響するということです。祖先で設定した値を超過するような値を子孫で設定しても、祖先で設定した値に制限されます。

複数のツリーがある場合、あるサブツリー全体のリソースを制御するには、上位のcgroupで設定しておけば、それ以下のツリー全体で設定が効きます。

図1のようなcgroup階層がある場合、test011 cgroupは、test01 cgroupで設定したメモリ制限や保護の影響を受けます。

test01 cgroupで設定した制限値より大きな値でtest011 cgroupでメモリ制限を設定しても、test01 cgroupの設定値でメモリ制限が働きます。同様に、test01 cgroupで設定した設定値より大きな値で、test011 cgroupにメモリ保護を設定しても、test01 cgroupで設定した値でメモリ保護が働きます。

ただし、メモリ保護においては、上位のcgroupで設定した値が、その子孫では効かないことがあります。先に「基本的には」と書いたのはこれが理由です。今回はこのような動作も確認します。

図1 実行例で使うcgroup階層
このあとの実行例で使うcgroup階層

ここで、このあとの実行例を試す前に、cgroup v2のカーネルデフォルトの動きを見るために必要な操作をします。

Ubuntu 24.04では、cgroup v2をマウントする際にオプションとして、memory_recursiveprotが指定されています。マウント情報を見ると、次のようにmemory_recursiveprotが指定されていることがわかります[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で設定した制限値より小さな値を、子cgrouptest011に設定します。

表1 子cgroupのメモリ制限値より、親cgroupのメモリ制限値が大きい場合の動きを確認するための設定とプログラムの消費メモリ
cgroup memory.maxの値 プログラムの消費メモリ
test01 384M -
test011 128M 256M

次のように子cgrouptest011内で、メモリを256MB消費するように指定して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の設定値で制限されるという、予想どおりの結果です。

図2 親cgroupのメモリ制限値より、子cgroupのメモリ制限値が小さい場合
子cgroupのメモリ制限値より、親cgroupのメモリ制限値が大きい場合

親cgroupのメモリ制限値より⁠子cgroupのメモリ制限値が大きい場合

次に、親cgroupで設定したメモリ制限値より、子cgroupで設定したメモリ制限値が大きい場合にどうなるかを見てみましょう。

表2のように親cgrouptest01で設定した制限値より、子cgrouptest011の制限値を大きく設定した状態で、test011 cgroupでメモリを消費させます。

表2 親cgroupのメモリ制限値より、子cgroupのメモリ制限値が大きい場合の動きを確認するための設定とプログラムの消費メモリ
cgroup memory.maxの値 プログラムの消費メモリ
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のメモリ制限値が大きい場合

ここまでで見た、メモリ制限の階層構造での動きをまとめると表3のようになります。

表3 cgroup v2の階層構造でのメモリ制限の動き
親子のメモリ制限値の設定状況 子cgroupでのメモリ保護の動き
親cgroupのメモリ制限値 < 子cgroupのメモリ制限値 親cgroupのメモリ制限値が効く
親cgroupのメモリ制限値 ≧ 子cgroupのメモリ制限値 子cgroupのメモリ制限値が効く

階層構造が深くなっても親で設定される制限値で制限されることは変わりません。メモリコントローラのメモリ制限は、ツリーをroot cgroupまでたどった際に、ツリーの祖先で設定されている最小の制限値が適用されます。

つまり、祖先で設定された制限値を子孫に分配するという考え方です。

階層構造とメモリ保護

次に、メモリ保護において、メモリコントローラが階層構造が考慮する動きを確認してみましょう。

シンプルで理解しやすかったメモリ制限に比べて、メモリ保護での動きはやや複雑です。

親cgroupでメモリ保護を設定し⁠子cgroupではメモリ保護を設定しない場合

まずは親cgroupでメモリ保護を設定し、子cgroupでは何も設定しないデフォルトの状態で試してみましょう。

表4のように、親cgroupでのみ保証値を設定し、その子cgroupでは何も設定せず、memory.minはデフォルト値のままとします。

表4 親cgroupでのみメモリ保護を設定した場合の動きを確認するための設定とプログラムの消費メモリ
cgroup memory.minの値 プログラムの消費メモリ
test01 256M -
test011 0 256M
test02 0 1700M

親cgroupであるtest01にだけ保証値として256MBを設定し、子cgroupであるtest011でメモリを256MB消費するプログラムを実行します。

そして、test01 cgroupとは別のツリーであるtest02 cgroupでメモリを大量に消費させるプログラムを実行します。この実行例の環境はメモリを 2GB しか積んでいませんので、メモリを 1700MB 消費させるとメモリ回収が始まるはずです。

この場合、test011 cgroupに所属するプロセスのメモリは保護されるでしょうか?

親cgrouptest01memory.minを256MBに設定し、子cgrouptest011内で256MBメモリを消費するように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ではメモリは保護されません。

図4 親cgroupでメモリ保護を設定し、子cgroupでは設定しない場合
親cgroupでメモリ保護を設定し、子cgroupでは設定しない場合

親cgroupのメモリ保証値より⁠子cgroupのメモリ保証値が小さい場合

次は、親cgroupで設定した保証値より、子cgroupで設定した保証値のほうが小さい場合の動きを見てみましょう。

この設定は、プロセスが所属するcgroupの設定どおりにメモリ保護が効くという、当然の結果が予想できます。

表5のように、親cgrouptest01で設定した保証値より小さな値を、子cgrouptest011に設定します。

表5 親cgroupのメモリ保証値より、子cgroupのメモリ保証値が小さい場合
cgroup memory.minの値 プログラムの消費メモリ
test01 128M -
test011 64M 256M
test02 0 1700M

ここまでと同様に、子cgrouptest011内で256MBのメモリを消費するように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.currentを観測すると、次のようになりました。

$ 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で設定したメモリ保護が効いていることがわかります。

図5 親cgroupのメモリ保証値より、子cgroupのメモリ保証値が小さい場合
親cgroupよりも子cgroupで小さい値のメモリ保護を設定する場合

プロセスが消費するメモリは、所属するcgroupの設定値で保護されるという予想どおりの結果になりました。

親cgroupのメモリ保証値より⁠子cgroupのメモリ保証値が大きい場合

それでは、親cgroupの保証値よりも、子cgroupの保証値を大きく設定した場合の動きを見てみましょう。

表6のように、親cgrouptest01で設定した保証値より大きな値を、子cgrouptest011に設定します。

表6 子cgroupのメモリ保証値が親cgroupのメモリ保証値より大きな場合
cgroup memory.minの値 プログラムの消費メモリ
test01 128M -
test011 256M 256M
test02 0 1700M

表6のとおりに各cgroupを設定し、親cgrouptest01で設定した保証値を超える256MBを消費するように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.currentを観測すると、次のようになりました。

$ 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.minで指定した256MBよりも低い128MB付近までメモリが回収されています。

test01 cgroupでmemory.currentを観測すると、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で設定した保証値です。つまり、階層構造が考慮されていることがわかります。

図6 子cgroupのメモリ保証値が親cgroupのメモリ保証値より大きな場合
親cgroupと子cgroupで同じメモリ保護を設定する場合

ここまでのメモリ保護の階層構造での動きをまとめると表7のようになります。

表7 cgroup v2の階層構造でのメモリ保護のデフォルトの動き
親子のメモリ保証値の設定状況 子cgroupでのメモリ保護の動き
親cgroupでメモリ保証値を設定、子cgroupは設定しない(=0) 子cgroupではメモリ保護は働かない
親cgroupのメモリ保証値 ≧ 子cgroupのメモリ保証値 子cgroupのメモリ保証値が効く
親cgroupのメモリ保証値 < 子cgroupのメモリ保証値 親cgroupのメモリ保証値が効く

メモリ保護のオーバーコミット

ここまでの例で見た、子cgroupで設定した保証値が親cgroupで設定した保証値を超えるような設定は、少し不自然に思えるかもしれません。

しかし、コンテナの管理権限が委譲され、祖先で設定されているの設定値を確認できない場合、自身が権限を持つcgroupで、祖先で設定されている値を超える値を設定してしまう可能性があります。そのような場合でも、祖先cgroupで設定されたメモリ保護は有効に働き、ツリー全体が適切に保護されます。

また、階層が深くなり子孫のcgroupが多数存在する場合、すべての子孫で設定されているcgroupのメモリ保証値の和が、祖先のcgroupで設定されている保証値を超えるような設定は考えられます。

単一のcgroupではメモリを消費しても、同じ階層にあるcgroupに所属するタスクが、同時には親の保証値を超えて消費をしない場合を考えてみましょう。このような場合、図7のように子cgroupぞれぞれに設定されている保証値は親cgroupで設定されている値を超えないものの、子cgroupで設定されている値の合計は親cgroupで設定されている値を超えるような、オーバーコミットとなる設定をすることが考えられます。

図7 子cgroupのメモリ保証値がオーバーコミットとなる設定
子のcgroupの保証値がオーバーコミットとなる場合

この場合、子cgroupで消費されているメモリの合計が親cgroupで設定されている保証値によって保護されます。子cgroupはそれぞれ、自身に設定している保証値を超えない範囲で、メモリ消費量に比例して親cgroupから保護されたメモリを受け取ります。

図7のように設定されている場合で、test012 cgroupに所属するタスクがほとんどメモリを消費していないような場合は、test011 cgroupに所属するタスクは256MB近くまでメモリが保護されるでしょう。

しかし、test012 cgroupに所属するタスクのメモリ消費が増えた場合は、test011test012両方に所属するタスクは、それぞれ256MBまでメモリが保護されない可能性があります。しかし、親cgroupであるtest01 cgroupで設定されているメモリ保護はツリー全体で有効です。

まとめ

今回は、メモリコントローラーでメモリ制限やメモリ保護を設定した場合に、階層構造が考慮される動きを紹介しました。

メモリ制限では、階層構造が考慮され、祖先のcgroupで設定した値が子孫まで影響することがわかりました。

一方、表7のメモリ保護の動きを見て、違和感を覚えた方もいらっしゃるかもしれません。メモリ保護の場合では階層構造が考慮されていないケースがあるためです。

デフォルト値である"0"が設定されている場合、親cgroupでメモリ保護が設定されていても、子cgroupではメモリ保護が効きません。

メモリ保護を設定した場合は階層構造が考慮されるのに、明示的に設定しない場合は保護が効かないのは、不自然に感じるかもしれません。

このような動きになる原因は実装にあります。歴史的な経緯と実装の容易さのため、cgroupのメモリ保証値は、ツリーをroot cgroupまでたどったときの最小値が適用されるように実装されていました。

つまり、"0"が設定されているときは"0"が有効になり、メモリが保護されない状態になります。

このメモリ保護の動きは、メモリ制限や他のコントローラーのように、ツリーをroot cgroupまでたどった祖先の値を考慮するという動きと異なっています。

このため、ツリー内のタスクを保護するには、ツリー内のすべてのcgroupに保証値を設定する必要があるという問題がありました。

また、コンテナのネストなどでcgroupで権限委譲が発生すると、委譲されたサブツリー配下でメモリ保護を設定することが非常に難しくなるという問題もありました。

そこで、5.7カーネルでメモリ保護に対する改良が加えられました。次回はこの改良について説明します。

おすすめ記事

記事・ニュース一覧