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

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

第58回では、メモリコントローラでメモリに対する制限や保護を設定した場合に、階層構造が考慮される動作を見ました。

上位のcgroupでメモリに対する制限値や保証値を設定した場合、その子孫に対しても設定が有効になるので、サブツリー全体に対してメモリの制限をかけたり、サブツリー全体を保護したりできます。

その中で、ツリー構造を考慮したメモリ保護の動作に違和感を持った方は多いのではないでしょうか。上位cgroupで設定したメモリ保護が、下位cgroupに影響しない場合があったためです。このような動作は直感的ではなく、cgroupで制限を設定した場合のツリー構造を考慮した動作とは異なりましたし、実際の使用でも問題がありました。

このため、5.7カーネルで、この問題を解決するための改良がなされました。今回は、このメモリ保護の問題を解決するために、5.7カーネルで実装された機能を紹介します。

機能を説明するにあたって、図1のようにroot cgroup直下に「システム関係タスク」cgroupと「ワークロード」cgroupを作成した例を使って、サブツリー全体に対する制限や保護を考えます。

図1 ツリー例
ツリー例

「システム関係タスク」の配下には、システムの運用に必要なパッケージ更新、バックアップ、ロギングといったタスクが所属しています。一方で、ホストが提供するサービスに必要なコンテナやジョブなどは「ワークロード」配下に所属しています。

ここで、メモリコントローラを使い、⁠システム関係タスク」「ワークロード」配下のタスクがメモリを異常に消費して、他のサブツリーのタスクに影響が出ないように制限値が設定できます。また、⁠システム関係タスク」配下のタスクや「ワークロード」配下のタスクが正常に実行されるように、それぞれのサブツリーで保護を設定できます。

それぞれのサブツリーに適切な制限や保護を設定することで、ホストの運用に必要なタスクにも、サービス提供に必要なワークロードにも悪影響が出ないようにできます。このように、cgroupを適切に設定し、ホストの安定運用を実現することは一般的なことでしょう。

サブツリー全体に対するメモリ制限

まずは、サブツリー全体に制限をかけて、他のサブツリーに属するタスクの実行に影響が出ないようにすることを考えてみます。

ロギングなどのシステム運用に必要なタスクが必要以上にメモリを消費して、⁠ワークロード」配下に所属する、ホストがサービスを提供するために必要なタスクの実行に影響が出ないようにします。逆に、ホストで実行しているワークロードが必要以上にメモリを消費して、ロギングなどのシステム運用に影響が出ないようにすることも考えます。

図2のように、サブツリー上位の「システム関係タスク」「ワークロード」cgroupで制限をかけると、配下に存在するcgroupに所属するタスクは、その制限を超えてメモリを消費できませんので、他のサブツリーへの影響を抑えられます。

図2 サブツリー全体に対する制限
サブツリー全体に対する制限

図2の例では、⁠システム関係タスク」に制限値として8GBを設定し、⁠ワークロード」に制限値として64GBを設定しています。

すると、⁠システム関係タスク」以下に存在する全タスクのメモリ消費は合計で8GBを超えず、⁠ワークロード」以下に存在する全タスクのメモリ消費は合計で64GBを超えません。それぞれ、他のサブツリーに影響が出ないようにメモリ消費を抑えられます。

図2では、⁠システム関係タスク⁠⁠、⁠ワークロード」以外のcgroupはデフォルト値のままで、明示的には制限値を設定していません。

サブツリー全体のメモリ消費を制限するには、このように上位のcgroupで制限を設定し、それ以下のcgroupはデフォルト値のままで、必要なcgroupにのみ制限値を設定するというような運用が考えられます。

サブツリー配下のcgroupすべてに制限値を設定するということはまれでしょう。

カーネルデフォルトのメモリ保護の動作

サブツリー全体に対するメモリ保護を考える前に、メモリ保護におけるカーネルデフォルトの動作をおさらいしておきましょう。

メモリ保護に関係するファイルのデフォルト値は、第57回で説明したとおり"0"でした。つまり、デフォルトではcgroupに対してはメモリ保護が働かない状態です。

階層構造を考えた場合に適用されるメモリ保証値は、第58回で説明したとおり、歴史的な経緯があり、また実装が容易であったため、ツリーをroot cgroupまでたどったときの最小値が適用されるように実装されていました。

図3のように、サブツリー上位のcgroupでメモリ保証値が設定されていても、それ以下のcgroupでメモリ保証値を明示的に設定しない場合、デフォルト値である"0"が設定されているため、メモリ保護が効かないことになります。

図3 カーネルデフォルトのメモリ保護
サブツリー全体に対する保護(カーネルデフォルト)

これが、第58回で確認したメモリ保護におけるカーネルデフォルトの動作でした。

サブツリー全体に対するメモリ保護

このようなメモリ保護に対するカーネルデフォルトの動作を踏まえて、サブツリー全体を保護するために、メモリ保護を設定することを考えてみましょう。

メモリ制限で設定したように、上位のcgroupに保証値を設定してサブツリー全体を保護したいところです。しかし、図3のように、サブツリー最上位の「システム関係タスク⁠⁠、⁠ワークロード」cgroupで保護を設定しても、その配下のcgroupでメモリ保証値がデフォルトのままの場合、⁠システム関係タスク⁠⁠、⁠ワークロード」それぞれのcgroup自身でのみメモリが保護されます。

その他のcgroupは、メモリ保証値を設定しない場合はデフォルト値ですから、カーネルデフォルトのメモリ保護の動作で説明したようにメモリは保護されません。

サブツリー全体を保護したい場合、図4のようにサブツリーすべてのcgroupに保証値を設定しなければいけません。

そこで例えば、図4のように、すべてのcgroupにサブツリー最上位のcgroupと同じ値を設定して、ツリー全体を保護することを考えてみましょう。

図4 サブツリー全体を保護したい場合(不適切な設定例)
サブツリー全体を保護したい場合(不適切な設定例)

このように設定すれば、サブツリー全体が保護できるかもしれません。しかし、サブツリー全体を16GBで保護したいということと、サブツリー内のcgroupすべてに16GBを設定することはかなり意味が異なるでしょう。

では、適切に設定するために、サブツリー全体でcgroupそれぞれに適切なメモリ保護値を見積もった上で、設定すればよいかというとそんなことはないでしょう。すべてのcgroupで適切な設定値を見積もるのは非常に大変な作業になるでしょうし、適切に見積もれないケースも多いはずです。サブツリー全体のcgroupすべてに適切な値を設定することは、非現実的だと言えます。

またLinuxホストでは通常、cgroupfsツリー/sys/fs/cgroup以下)には多数のcgroupが存在しています。そのすべてに保証値を設定していくことは非常にコストがかかる処理です。特にコンテナホストで大量にコンテナが起動している場合は、cgroup数もかなり多いはずです。

そもそも、やりたいことは「システム関係タスク」「ワークロード」のサブツリー全体を保護することで、個々のワークロード間で保護を設定したり、優先順位をつけたりしたいわけではありません。

さらに、すべてのcgroupに値を設定したとしても、各ワークロードやタスクが必要とするメモリは、時間とともに変化する可能性があります。すべてのcgroupに固定値でメモリ保証値を設定することで、逆にリソース分配が非効率になる可能性があります。

くわえて、すべてのcgroupに値を設定する場合、図5のように、下位のcgroupに設定する保護値の見積もりができずに、上位のcgroupで不当に大きなメモリ保護を設定するようなケースが考えられます。

図5 下位cgroupで必要なメモリがわからないため上位cgroupで高い保証値を設定する
下位cgroupで必要なメモリがわからないため上位cgroupで高い保証値を設定する

図5のように設定している場合、負荷が高くなり、メモリが回収されているようなケースでは次のような動作になります。

  • 「コンテナA」では10GBのメモリが保護される
  • 「コンテナB」では5GBのメモリが保護される
  • 「ワークロード」で設定したメモリ保護のうち、10+5=15GBのメモリは保護されるが、残りの50-15=35GBのメモリは保護には使われない

このように、無駄に高く設定した保証値が、実際はメモリ保護には使用されないままの状態になります。

memory_recursiveprotオプション

ここまでで説明したとおり、メモリコントローラでメモリ保護を設定した場合の動作は、他のcgroupで制限値を設定した場合のように、上位の設定値が下位に分配される動作とは異なっており、不自然でわかりづらく、実際にメモリ保護を設定しようにも、現実には設定が困難でした。

上位のcgroupで指定した保護値を下位に分配するのであれば、cgroupの制限値を設定した場合の動作とも合っており、期待されるあるべき動作だと言えるでしょう。現実的にメモリ保護も設定しやすくなります。

このようなメモリ保護でのあるべき姿を実現するために、5.7カーネルでmemory_recursiveprotオプションが導入されました。cgroup v2をマウントする時に、マウントオプションで指定します。

memory_recursiveprotオプションを指定してcgroup v2をマウントした場合、図6のように、上位のcgroupで設定した保証値で、ツリー全体が保護されます。

図6 memory_recursiveprotオプションを指定した場合
memory_recursiveprotオプションを指定した場合

図6のように、⁠システム関係タスク」cgroupで保証値を設定すると、その配下の3つのcgroupはデフォルト値のままメモリ保護が設定されていない場合でも、⁠システム関係タスク」で設定した4GBのメモリ保証値でメモリが保護されます。

ツリー配下のcgroupにメモリ保証値を設定していなくても、各cgroupには、⁠システム関係タスク」cgroupや「ワークロード」cgroupで設定した保護値から、各cgroupのメモリ消費に応じて適切に分配されます。

また、memory_recursiveprotオプションを設定すると、図5で説明したような「メモリ保護の破棄」は起こりません。

図7 memory_recursiveprotオプションでメモリが分配される様子
memory_recursiveprotオプションでメモリが分配される様子

図7のように、メモリ保証値として「ワークロード」で50GBを設定し、その下の「コンテナA」⁠コンテナB」でそれぞれ10GB、5GBを設定したとします。負荷が高くなり、メモリが回収されているようなケースでは次のような動作になります。

  • 「コンテナA」では10GBのメモリが保護される
  • 「コンテナB」では5GBのメモリが保護される
  • 「ワークロード」で設定したメモリ保護のうち、残りの50-15=35GBのメモリは、⁠コンテナA」⁠コンテナB」のメモリ使用状況に応じて分配される

つまり上位で設定したメモリ保護は、下位で明示的に指定された保護で使われない分も、下位の使用状況に応じて分配されますので無駄がありません。メモリが有効に使えるため、ホストの安定稼働に役立つでしょう。

もちろん、上位のcgroupで設定した保証値より大きな値を下位のcgroupに設定しても、上位の保証値を超えてメモリが保護されることはありません。

memory_recursiveprotオプションの動作

それでは、memory_recursiveprotオプションの動作を実際に見ていきましょう。

まずは、memory_recursiveprotオプションが有効であることを確認します。Ubuntu 24.04環境で試しています。

shell_01$ grep memory_recursiveprot /proc/self/mountinfo 
34 25 0:29 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime shared:9 - cgroup2 cgroup2 rw,nsdelegate,memory_recursiveprot

このようにmemory_recursiveprotオプションを指定してcgroup v2をマウントしています。

cgroup v2がマウントされているものの、memory_recursiveprotオプションが指定されていない場合は、次のように再マウントします。

$ sudo mount -t cgroup2 -o remount,memory_recursiveprot,(その他のオプション) cgroup2 /sys/fs/cgroup

また、cgroup v2がマウントされておらず、cgroup v2が使えない状態の場合は次のようにマウントします。

$ sudo mount -t cgroup2 -o memory_recursiveprot cgroup2 /sys/fs/cgroup/

cgroup v2でmemory_recursiveprotオプションを指定してマウントした状態になったところで、下位のcgroupで保証値を設定しなくても、上位のcgroupで設定した保証値が下位のcgroupに分配され、上位のcgroupで設定したメモリ保護が下位のcgroupでも有効になっていることを確認してみましょう。

ここで、第58回「親cgroupでメモリ保護を設定し、子cgroupではメモリ保護を設定しない場合」と同様にcgroupを作成し、保証値を設定します。

動作がわかりやすいように、test01 cgroupのmemory.minは、第58回の設定値とは異なる128MBに設定しました。

表1 親cgroupでのみメモリ保護を設定した場合の動作を確認するための設定とプログラムの消費メモリ
cgroup memory.minの値 プログラムの消費メモリ
test01 128M -
test011 0 256M
test02 0 1700M
shell_01$ sudo mkdir -p /sys/fs/cgroup/test01/test011
(cgroupを作成)
shell_01$ echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control 
(子cgroupでmemoryコントローラーを使えるようにroot cgroupを設定)
+memory
shell_01$ echo "+memory" | sudo tee /sys/fs/cgroup/test01/cgroup.subtree_control 
(子cgroupでmemoryコントローラーを使えるようにtest01 cgroupを設定)
+memory
shell_01$ echo "128M" | sudo tee /sys/fs/cgroup/test01/memory.min 
(test01 cgroupで128MBのメモリ保護値を設定)
128M
shell_01$ cat /sys/fs/cgroup/test01/memory.min 
134217728
shell_01$ cat /sys/fs/cgroup/test01/test011/memory.min 
(test011 cgroupではメモリ保護値を設定していないのでデフォルトの0)
0
$ echo $$ | sudo tee /sys/fs/cgroup/test01/test011/cgroup.procs 
(シェルのPIDをtest011 cgroupに登録)
1021

表1のように設定し、メモリを256MB消費するプログラムをtest011 cgroupに所属するシェルから実行します。

shell_01$ stress-ng --vm 1 --vm-bytes 256M --vm-hang 0 -v
  :(略)

そして、test02 cgroupに所属するシェルから、大量にメモリを消費させるようにプログラムを実行します。

shell_02$ sudo mkdir /sys/fs/cgroup/test02
shell_02$ echo $$ | sudo tee /sys/fs/cgroup/test02/cgroup.procs
1071
shell_02$ 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のメモリ使用量を確認)
  :(略)
297463808
297463808
216137728
134082560
134111232
134111232
134111232
  :(略)

最初はstress-ngプログラムへの指定どおり256MB程度メモリを消費していました。test02 cgroupで大量にメモリが消費されるとメモリが回収されました。しかし、第58回のように大量にメモリが回収されることはなく、test01 cgroupで指定した128MB程度でメモリ消費の減少は止まっています。

つまり、プロセスが所属するtest011 cgroupでメモリ保証値を設定しなくても、その上位のtest01 cgroupで設定したメモリ保証値が効いていることがわかります。第58回の実行例と比較してみてください。

memory_recursiveprotオプションとsystemd

先に述べたように、memory_recursiveprotオプションを付与すると、メモリ保護の動作はcgroup v2でリソース制限を設定した場合の動作とも一致し、あるべき姿になったと言えるでしょう。

あるべき姿ではあるものの、memory_recursiveprotオプションはカーネルデフォルトでは有効ではありません。なぜ、カーネルデフォルトの動作は従来のままでmemory_recursiveprotオプションが新設されたのでしょう。

これは、カーネルの進化ではよくあることで、それまでの動作との互換性を保つためです。仮にmemory_recursiveprotオプションが実装される前のメモリ保護の動作を期待して、cgroup v2が設定されて動作しているシステムがあった場合、急にカーネルの動作が変わってしまうと不具合が発生する可能性があるためです。

このためカーネルでは、明示的にmemory_recursiveprotオプションを指定したときだけ、機能が有効になるようになりました。

しかしsystemdでは、オプションを指定したときの動作があるべき姿であるという理由で、247以降では、memory_recursiveprotオプションを指定してcgroup v2をマウントするようになりました[1]

今では、稼働しているLinuxホストのほとんどがsystemdを採用しているので、古いバージョンのsystemdが動いている環境でなければ、memory_recursiveprotを指定した状態でcgroup v2が動作していると言ってもよいでしょう。

まとめ

今回は、memory_recursiveprotオプションを指定すると、どのようにメモリ保護が動作するのかについて説明し、実際の動作を確認しました。

第58回や今回の最初で紹介した、階層構造を考慮したメモリ保護におけるカーネルデフォルトの動作は少し不自然でした。

そこで、5.7カーネルでmemory_recursiveprotオプションが導入され、systemdではデフォルトでmemory_recursiveprotオプションを指定してcgroup v2をマウントするようになり、今ではmemory_recursiveprotを指定した動作が事実上のデフォルトになりました。

cgroup v2の目玉の1つは、メモリ保護が設定できることでした。memory_recursiveprotオプションが実装されたことにより、メモリ保護の動作があるべき姿になり、柔軟でわかりやすく設定できるようになりました。

次回は、引き続きメモリコントローラが持つ機能について紹介する予定です。

おすすめ記事

記事・ニュース一覧