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

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

少し前にSNS上で、この連載に対する10周年おめでとうメッセージをいただきました。この連載は2014年5月に第1回が掲載され、今年でちょうど10年になります。このようなメッセージをいただくとは思っていなかったので、とてもうれしかったです。

最初の頃の記事を読むと、深い知識を知らないまま書いていることが、色々なところから感じられます。しかし、書き続けることで私自身の勉強になり、最初の頃よりはより充実した記事が書けるようになった気がしています。これも、記事を読んでいただいているたくさんの方のおかげだと思っています。ありがとうございます。

まだ書きたいことはありますので、連載は続きます。引き続きよろしくお願いいたします。


前回は、メモリコントローラがcgroup v1からv2でどのように変わったのかを紹介し、制限値を設定したときの動きを説明しました。制限値は、cgroup v1のときから設定できましたので、制限値を設定したときの動きは理解しやすかったのではないでしょうか。

cgroup v2からは、メモリの制限値だけでなくメモリの保証値を設定し、cgroupに対するメモリ保護が設定できるようになりました。この機能は、cgroup v2のメモリコントローラで、v1から一番大きく変わった機能であると言えるでしょう。

今回は、このメモリ保護を設定したときの動きを紹介します。

cgroup v1時代から設定されてきたように、特定のコンテナが大量にメモリを消費しないように制限値を設定すると、コンテナホストの安定稼働につながり、ひいてはコンテナの安定稼働につながります。

一方で、コンテナホスト全体でメモリの消費量が増加して、コンテナが使っているメモリが回収されると、コンテナの安定稼働に影響します。そこで、cgroup v2では、コンテナに最低限必要なメモリ量を設定し、必要以上にメモリが回収されないよう、メモリ保護の仕組みが実装されました。

まずは、前回からの復習を兼ねて、表1にcgroup v2のメモリコントローラで使用するインターフェースファイルを載せておきます。前回載せた表と同じです。

表1 cgroup v2のメモリコントローラで使用するファイル
ファイル名 機能 操作 デフォルト
memory.current cgroupとその子孫のcgroupが現在使っているメモリの総量 読み取り
memory.min cgroupとその子孫のcgroupのメモリ消費が設定した値より少ない場合、cgroup内のプロセスのメモリは回収されない。回収可能なメモリがない場合はOOM Killerが呼ばれる 読み書き 0
memory.low cgroupとその子孫cgroupのメモリ消費が設定した値より少ない場合、回収可能なメモリがない場合をのぞいては、cgroupのメモリは回収されない 読み書き 0
memory.high cgroupとその子孫のcgroupのメモリ消費が設定値を超えた場合、メモリ回収の圧力がかかる。OOM Killerが呼ばれることはない 読み書き max
memory.max cgroupとその子孫のcgroupのメモリ消費が設定値を超えた場合で、減らせない場合はcgroupに対してOOM Killerが呼ばれる 読み書き max
memory.swap.max スワップ使用量の制限値。cgroupとその子孫のcgroupのスワップ使用量が設定値を超えた場合、それ以上はスワップアウトしない 読み書き max
memory.swap.high スワップ使用を絞る制限値。cgroupとその子孫のcgroupのスワップ使用量が設定値を超えた場合、スワップアウトを可能な限り絞る 読み書き max
memory.reclaim この書き込んだバイト数分、メモリを回収する 書き込み
memory.peak cgroup作成以降の自身とその子孫が使った最大のメモリ使用量 読み取り
memory.oom.group OOM Killerが呼ばれるとき、cgroup内と子孫のプロセスをまとめて扱うか、扱わないか 読み書き 0
memory.events cgroupとその子孫のcgroupで起こったイベント数 読み取り
memory.stat 現在のメモリ使用の状況をメモリタイプごとに表示 読み取り

それでは、メモリ保護について説明していきましょう。今回の記事の実行例は、Ubuntu 22.04で実行しています。

メモリ保護

ここまでも説明した通り、cgroup v2からはメモリ保護が設定できるようになりました。

メモリ保護は、表1のとおりmemory.minmemory.lowで設定します。memory.lowは、cgroup v2がstableになった4.5カーネルの時点で使えました。一方で、memory.minは4.18カーネルで導入されました。

この2つの違いは、memory.maxmemory.highの違いと同じで、OOM Killerが発動するかしないかです。

cgroup内のプロセスが消費するメモリは、memory.minmemory.lowで設定した値より少なければ回収されません。もし、memory.minが設定されている場合、回収できる保護されていないメモリがない場合はOOM Killerが呼ばれます。memory.lowが設定されている場合は、OOM Killerは呼ばれません。

もし、memory.minmemory.lowで設定した値より多くメモリを消費している場合で、メモリ負荷が高まり、メモリを回収する圧力がかかると、超過している分に応じて回収されます。つまり、多く超過しているほど圧力がかかって回収され、少なく超過していると、その分、圧力は軽減されます[1]

メモリ保護の設定値は、cgroupツリーの子孫にまで影響します。また、自分より上位のcgroupがある場合は、上位のcgroupに設定されている値や消費しているメモリ量によって制限を受けることに注意が必要です。

それでは、実際にメモリ保護が働くことを確認していきましょう。以下の実行例では、メモリを2GB搭載したホスト上で試しています。

設定しないとき

メモリ保護を設定する前に、何も設定しないときの動きを確認しましょう。cgroupに制限は設定せずに、表2のようにプログラムで消費するメモリを設定してstress-ngを実行します。

表2 メモリ保護を設定しない場合の動きをみるためのプログラムの消費メモリ
cgroup メモリ保護の設定 プログラムの消費メモリ
test01 設定なし 256M
test02 設定なし 1700M

まずは1つ目のシェルでtest01 cgroupを作成し、自身をtest01に追加します。

このシェル上で、メモリを256MB確保するようにstress-ngコマンドを実行します。

$ sudo mkdir /sys/fs/cgroup/test01
(test01 cgroup作成)
$ echo $$ | sudo tee /sys/fs/cgroup/test01/cgroup.procs 
972
(プロセスをtest01 cgroupに登録)
$ cat /proc/$$/cgroup 
0::/test01
(test01に登録されたことを確認)
$ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v
(256MBメモリを消費するようにstress-ngを実行)

そして、test01cgroup.currentの値を監視すると、大体指定したとおりの値のメモリを確保した状態になります。

$ while : ; do cat /sys/fs/cgroup/test01/memory.current ; sleep 1; done
0
0
0
266780672
276271104
276271104
276271104
276271104
  :(略)

ここで、もう1つシェルを開き、こちらはtest01とは別のcgroupへ所属するようにします。

この実行例で使っているホストはメモリを2GB持ったホストですので、多めのメモリを確保するように1700MBを確保するようにオプションを与えました。そして、なるべくメモリを使用するようにstress-ng--page-inオプションを与えます。このオプションは、スワップに書き出したデータをメモリに書き戻すオプションです。

$ sudo mkdir /sys/fs/cgroup/test02
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs 
1028
$ cat /proc/$$/cgroup 
0::/test02
$ stress-ng --vm 1 --vm-bytes 1700M --page-in --vm-hang 0 -v
(1700MBメモリを消費し、スワップに書き出されたメモリを書き戻す指定でstress-ngを実行)

すると、test02 cgroupに所属するstress-ngコマンドがメモリを消費しようとして、test01 cgroupに所属するプロセスが消費するメモリが減っていきます。

$ while : ; do cat /sys/fs/cgroup/test01/memory.current ; sleep 1; done
  :(略)
276332544
247586816
219856896
151453696
136445952
116088832
73453568
71491584
71491584
71491584
  :(略)

この例では、test01に所属するstress-ngコマンドは70MBほどの消費に落ち着いています。確保できていない分はスワップへ書き出されます。

ここまでの動きを図で説明しましょう。

図1 メモリ保証を設定していない場合
no_memory_protection

図1で、①のようにtest01 cgroupに所属するプロセスが起動しているところに、②のようにメモリを大量に消費するプログラムがtest01 cgroup以外で起動すると、test01 cgroupに所属するプロセスが消費しているメモリが回収され、スワップに書き出されます。

すると、③のようにtest01 cgroupに所属するプロセスが使えるメモリが少なくなり、パフォーマンスに影響が出ます。

このように、他のcgroupに所属するプロセスがメモリを消費すると、メモリが回収され、プロセスの実行に影響を与える可能性があります。このような場合、プロセスの実行に最低限必要なメモリをcgroupに設定しておくと、プロセスの安定稼働につながります。

そこで、memory.minmemory.lowを設定します。

memory.low

まずは、cgroup v2導入当初から存在していたmemory.lowから動きを見ていきましょう。

メモリが回収できる場合

まずは、memory.lowで設定したメモリ保護がきちんと効くことを確認してみましょう。

表3 メモリが回収できる場合のmemory.lowの動きを確認するための設定とプログラムの消費メモリ
cgroup memory.lowの値 プログラムの消費メモリ
test01 128M 256M
test02 設定なし 1700M

表3のように、cgroupで設定したmemory.lowに設定した値より多めにプログラムにメモリを消費させます。その状態で、別にメモリ保護を設定していないcgroupに属しているプログラムにメモリを消費させます。

test01 cgroupを作成し、memory.lowで設定した値より多くstress-ngにメモリを消費させます。

$ sudo mkdir /sys/fs/cgroup/test01
$ echo $$ | sudo tee /sys/fs/cgroup/test01/cgroup.procs
996
$ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.low
128M
$ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v

ここで、別にtest02 cgroupを作成し、メモリ保護は設定しない状態で大量にメモリを消費するプロセスを起動します。先の実行例と同様に、メモリを使うように--page-inオプションも与えます。

$ sudo mkdir /sys/fs/cgroup/test02
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs
1038
$ stress-ng --vm 1 --vm-bytes 1700M --vm-hang 0 --page-in -v

test01 cgroupのmemory.currentは、次のように変化します。

$ while : ; do cat /sys/fs/cgroup/test01/memory.current ; sleep 2; done
  :(略)
276557824
276557824
  :(略)
152498176
150106112
144891904
  :(略)
140783616
140783616
  :(略)

他にメモリを消費するプログラムが起動したため、そちらにメモリが割りあたり、メモリが回収され、test01に所属するプロセスのメモリ消費量が減っていることがわかります。

しかし、メモリは回収されたものの保証値を設定しているため、保証値以下になるほどにはメモリが回収されていないことがわかります。一方で、test02 cgroupのmemory.currentは次のように変化しています。

$ while : ; do cat /sys/fs/cgroup/test02/memory.current ; sleep 2; done
1693544448
1701314560
1702375424
  :(略)
1694265344
1694265344
  :(略)
1556148224
1556148224
  :(略)

メモリ保護がされていないcgroupに属するstress-ngは、指定した1700MBのメモリに到達することはなく、メモリ消費が抑えられていることがわかります。

ここまでの動きを図2で説明します。

図2 memory.lowを設定した場合
memory.low

図2で、①のようにmemory.lowを設定して、test01 cgroupに所属するプロセスを起動します。

その後、②でtest02 cgroupに所属する大量にメモリを消費するプロセスが起動しても、③のようにmemory.minで設定した値まではtest01 cgroupにメモリが確保された状態となります。

各cgroupに所属するプロセスが使用するメモリのうち、メモリが使えない分はスワップを使用します。

回収するメモリがない場合

次に、test01だけでなくtest02 cgroupにもmemory.lowを設定して、memory.lowに設定した値より多くメモリを消費するプログラムの動きを見てみます。

表4 回収するメモリがない場合のmemory.lowの動きを確認するための設定とプログラムの消費メモリ
cgroup memory.lowの値 プログラムの消費メモリ
test01 128M 256M
test02 1700M 1700M

表4のように、片方のcgroupでは、memory.lowに設定した値より多めにプログラムにメモリを消費させます。その状態で、別のcgroupでmemory.lowに設定した値と同じだけ、プログラムにメモリを消費させます。

ここで、実行している環境は2GBしかメモリを搭載していないので、このように実行すると搭載以上のメモリを保護しなければならないことになります[2]

test01 cgroupを作成し、memory.lowで設定した値より多くstress-ngにメモリを消費させます。また、これまでと同様になるべくメモリを使うように--page-inオプションも与えます。

$ sudo mkdir /sys/fs/cgroup/test01
$ echo $$ | sudo tee /sys/fs/cgroup/test01/cgroup.procs
957
$ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.low
128M
$ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 --page-in -v

ここで、別のtest02 cgroupを作成し、memory.lowに設定した値と同じ値をstress-ngコマンドに設定します。こちらはstress-ngコマンドに--page-inオプションを与えます。

$ sudo mkdir /sys/fs/cgroup/test02
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs
982
$ echo 1700M | sudo tee /sys/fs/cgroup/test02/memory.low
1700M
$ stress-ng --vm 1 --vm-bytes 1700M --page-in --vm-hang 0 -v

test01 cgroupのmemory.currentの値は、次のように変化していきます。

$ while : ; do cat /sys/fs/cgroup/test01/memory.current ; sleep 2; done
271757312
271757312
92090368
92090368
92098560
92098560
  :(略)

最初はtest01 cgroupに所属するstress-ngコマンドは、指定した256MB程度のメモリを消費しています。

その状態でtest02 cgroupに所属するプロセスがメモリを消費しはじめると、test01 cgroupに所属するプロセスは、memory.lowに設定した値より多くメモリを消費していたので、メモリを回収する圧力がかかり、かなりのメモリがスワップに書き出されました。

最終的にはmemory.lowへ設定した保証値以上に回収されてしまっています。

ここで、test02 cgroupのmemory.currentの値は次のようになっていました。

$ while : ; do cat /sys/fs/cgroup/test02/memory.current ; sleep 2; done
1782550528
1782550528
1782550528
1782550528
1782550528
1782550528
  :(略)

ここまでの動きを図3を使って説明しましょう。

図3 memory.lowを設定していて回収するメモリがない場合
memory.lowを設定していて回収するメモリがない場合

図3で、①のようにtest01 cgroupに所属するプロセスがmemory.low設定値以上にメモリを消費していました。

そこに、test02 cgroupでmemory.lowに設定された値以下しかメモリを消費していないプロセスを動かすと、test02 cgroupに所属するプロセスが消費するメモリは、memory.lowに設定した通りに確保された状態になります。

memory.lowに設定した値より多くメモリを消費していたtest01 cgroupにメモリ回収圧力がかかり、③のようにmemory.low設定値を満たさないレベルまでメモリが回収されたことがわかります。test01 cgroupではmemory.lowに設定したメモリ保護が働いていません。

つまり、システム全体で回収するメモリがない場合は、memory.lowを設定していても、memory.lowは機能しないことがわかります。

このように、システム全体のメモリ負荷が高い場合で、システム全体で回収するメモリがない場合、memory.lowに設定したメモリ保護は機能しません。回収可能なメモリがある場合のみ、メモリ保護が機能します。

実際に使用できるメモリより多い値をmemory.lowとして設定すると、OOM Killerは発動しないものの、メモリ保護自体効かない可能性があります。

メモリ保護に設定する値をオーバーコミットせず、適切な値を設定する必要があります。カーネルの付属文書にも「利用可能なメモリよりも多くのメモリを割り当てることは推奨されません」と書かれています。

memory.min

ここまでで説明したように、memory.lowは回収可能なメモリがある場合にのみ機能しました。

通常は、このようなメモリ保護で十分でしょう。しかし、memory.lowのメモリ保護では役に立たないケースがあります。例えばスワップを持っていないシステムのようなケースです。

このようなケースにも対応できるように、4.18カーネルでmemory.minが導入されました。

先に書いたように、memory.minmemory.lowの差は、ちょうどmemory.maxmemory.highの関係と似ています。そうです、memory.minはOOM Killerを使います。足りないメモリをOOM Killerを使って確保しようとします。

それでは、memory.minを設定して試してみましょう。

メモリが回収できる場合

まずは、memory.minを設定した状態で、回収するメモリが確保できる場合です。

memory.minの設定と、プログラムに与える値は表5のようにします。

表5 メモリが回収できる場合のmemory.minの動きを見るための設定とプログラムの消費メモリ
cgroup memory.minの値 プログラムの消費メモリ
test01 128M 256M
test02 設定なし 1700M

表5のように設定し、ここまでの実行例と同様にtest01 cgroupでstress-ngコマンドを実行します。

$ sudo mkdir /sys/fs/cgroup/test01
$ echo $$ | sudo tee /sys/fs/cgroup/test01/cgroup.procs
961
$ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.min
128M
$ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v

この状態で、ここまでの実行例と同じように、test02 cgroupに属するプロセスで大量にメモリを消費させます。ここでもメモリを使用するようにstress-ngコマンドには--page-inオプションを与えます。

$ stress-ng --vm 1 --vm-bytes 1700M --page-in --vm-hang 0 -v

するとtest01 cgroupに所属するプロセスが消費するメモリは、次のように、さきほどのmemory.minを設定しなかったときのようにメモリ消費が少なくなることはなく、ある程度、設定値と近い値へ落ち着くようになります。

memory.lowのときと同じような動きです。

$ while : ; do cat /sys/fs/cgroup/test01/memory.current ; sleep 1; done
  :(略)
276545536
276545536
274292736
273383424
254242816
223477760
  :(略)
184053760
184053760
184053760
184053760
  :(略)
図4 memory.minを設定した場合
memory.min

このように回収するメモリがある場合は、図4のようにmemory.lowのときの図2と同じになります。

つまり、回収するメモリが確保できる場合、memory.lowmemory.minで動きは変わりません。

回収するメモリがない場合

それでは、memory.minを設定した状態で、回収できるメモリがなくなった場合はどうなるのでしょう? それを見ていきましょう。

test01test02 という2つのcgroupを作成し、表6のようにそれぞれにmemory.minを設定します。

そして、搭載している量より大きなメモリを消費させるように、それぞれのcgroupに所属するstress-ngプログラムに--page-inオプションを与えて実行します。

表6 回収するメモリがない場合のmemory.minの動きを見るための設定とプログラムの消費メモリ
cgroup memory.minの値 プログラムの消費メモリ
test01 128M 256M
test02 1700M 1700M

test01 cgroupでは次のようにstress-ngコマンドを実行します。ここまでと同様、メモリを使うように--page-inオプションを与えます。

$ sudo mkdir /sys/fs/cgroup/test01
$ echo $$ | sudo tee /sys/fs/cgroup/test01/cgroup.procs
986
$ echo 128M | sudo tee /sys/fs/cgroup/test01/memory.min
128M
$ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 --page-in -v

ここで、test02 cgroupでstress-ngコマンドを実行すると、次のようにOOM Killerが発動します。

$ sudo mkdir /sys/fs/cgroup/test02
$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs
1043
$ echo 1700M | sudo tee /sys/fs/cgroup/test02/memory.min
1700M
$ stress-ng --vm 1 --vm-bytes 1700M --vm-hang 0 -v
stress-ng: debug: [3608] stress-ng-vm: child died: signal 9 'SIGKILL' (instance 0)
stress-ng: debug: [3608] stress-ng-vm: assuming killed by OOM killer, restarting again (instance 0)

test01 cgroupでは、次のようにほぼmemory.minの設定通りにメモリが確保されています。

$ while : ; do cat /sys/fs/cgroup/test01/memory.current ; sleep 2; done
  :(略)
269770752
269770752
269770752
134221824
134217728
134217728
  :(略)

一方で、test02 cgroupのほうは、OOM Killerによりメモリ消費が強制的に抑えられています。

$ while : ; do cat /sys/fs/cgroup/test02/memory.current ; sleep 2; done
  :(略)
1462009856
704868352
19591168
1182564352
78434304
  :(略)

このとき、dmesgでも次のようにOOM Killerが発動されたことが記録されています。

[  216.682968] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=test02,mems_allowed=0,global_oom,task_memcg=/test02,task=stress-ng,pid=1236,uid=1001
[  216.682979] Out of memory: Killed process 1236 (stress-ng) total-vm:1796844kB, anon-rss:1684292kB, file-rss:880kB, shmem-rss:44kB, UID:1001 pgtables:3488kB o
om_score_adj:1000

ここまでで試した動きは、図5のようになります。

図5 回収するメモリがない場合のmemory.minの動き
memory.minとoom_killer

図5でも図4と同様に、①でmemory.minを設定して、test01 cgroupでプロセスを起動しました。

その後、②でtest02 cgroupにもmemory.minを設定し、memory.minで設定した値と同じ値でメモリを消費するプロセスを起動しました。

test01 cgroupに所属するプロセスはmemory.min以上にメモリを消費していましたので、test02 cgroupでプロセスを起動すると、memory.min以上のメモリは回収され、スワップに書き出されました。

しかし、それでもtest02 cgroupでmemory.minに設定されたメモリが確保できないため、③のようにOOM Killerが発動し、システム全体でメモリを確保しようとしました。

memory.lowと違い、OOM Killerによりシステム全体のメモリ保護の設定を解決しようとするmemory.minの動きが確認できました。

dmesg出力の1行目を見てみると、global_oomという文字列があります。memory.minが起因となるOOM Killerは、cgroupに対するOOM Killerではなく、システム全体に対するOOM Kilerが発動していることがわかります。

このようにmemory.minで設定したメモリを保護できなくなったということは、システム全体でメモリが足りないということですので、システム全体のOOM Killerが発動します。dmesgの出力から、そのことがわかります。

ちなみに、前回memory.maxの実行例を見ると、dmesgは次のようになっていました。

[ 1845.108346] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=test01,mems_allowed=0,oom_memcg=/test01,task_memcg=/test01,task=stress-ng,pid=4797,uid=1000

今回、OOM Killer時に出力されたログのglobal_oomの部分に相当する文字列がoom_memcgとなっていて、対象のcgroup名が記載されています。memory.maxの場合はcgroup内のメモリ消費が設定値を超えたためにOOM Killerが発動しており、cgroup内のOOM Killerが発動していることがわかります。

ここでは、スワップがある環境を使って動きを見ましたが、スワップがないホスト上では、OOM Killerを使ってメモリ使用量を抑えるmemory.minの動きはもっと重要になるでしょう。

まとめ

今回は、cgroup v2で設定できるようになったメモリ保証の動きを紹介しました。

まとめると、メモリ負荷が高くなった場合、メモリ保護は、

  • 回収するメモリがある場合はmemory.lowmemory.minの動きは同じ
  • 回収するメモリがない場合、memory.lowで設定したメモリ保護は無効になる
  • 回収するメモリがない場合、memory.minで設定したメモリを保護するため、OOM Killerが発動する

という動きになります。

メモリ保護の設定における、memory.minmemory.lowの違いがご理解いただけたのではないでしょうか。

実際には、今回試したようにメモリ保護を設定するために、直接cgroupを操作して設定することはほとんどないでしょう。

しかし、コンテナランタイムなどでコンテナに対してメモリ保護を設定する場合でも、cgroupのメモリ保護の動きを理解した上で、コンテナの設計や見積もりをきっちりと行った上で保証値を設定しないと、OOM Killerが呼ばれてプロセスが安定して稼働できなかったり、保証値以下までメモリが回収されてしまったりして、コンテナの安定稼働に影響を及ぼすでしょう。

次回は、今回紹介できなかったメモリコントローラの階層構造を考慮した動きや、cgroup v2のマウントオプションのうち、メモリコントローラに関係するオプションを紹介する予定です。

おすすめ記事

記事・ニュース一覧