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

第37回Linuxカーネルのコンテナ機能 ― cgroupの改良版cgroup v2[1]

この連載をはじめてから、毎年この連載の記事でAdvent Calendarに参加してきました。昨年までは、この連載の記事で"Linux Advent Calendar"に参加してきました。

今年は参加するカレンダーを少し変えて、まずはこの記事で私が所属する会社のAdvent Calendarであるファーストサーバ Advent Calendar 2017の5日目に参加してみます。あまりたくさんの人が興味を持たなさそうな話題なので、会社のAdvent Calendarにマッチするのか心配です (^_^;)。

さて、Linuxでコンテナでリソース制限を行うための機能として、この連載では第3回から第5回まで3回に渡ってcgroupについて解説しました。

cgroupは、多くのLinuxディストリビューションでinitとして採用されているsystemdが使っていますので、今やLinuxをお使いであれば意識をせずにcgroupを使っている人が多いでしょう。

このときに紹介したcgroupは、執筆時点の最新バージョンである4.14カーネルではcgroup v1と呼ばれています。一方、第5回の最後で紹介した、cgroupの再設計と改良の動きは4.5カーネルで正式機能となり、cgroup v2と名付けられました。

今回は、そのcgroup v2について紹介していきたいと思います。

cgroup v1の特徴のおさらい

cgroup v2の説明に入る前に、以前説明したcgroupの機能や特徴を軽くおさらいしておきましょう。詳しくは第3回から第5回の記事をご覧ください。

cgroupfs

cgroupを使うには、cgroupfsという特別なファイルシステムをマウントして使います。cgroupfsは通常のファイルシステム風の見た目を持ち、通常のファイルシステム風に操作が行えます。

リソースを制御する単位となるcgroupはディレクトリで表され、cgroupを作成するときはmkdirコマンドを使うなど、通常のファイルシステムと同様の操作でcgroupを操作できます。

$ sudo mount -t cgroup -o pids cgroup /sys/fs/cgroup/pids
$ sudo mkdir /sys/fs/cgroup/pids/test01 (cgroup "test01"の作成)
$ ls /sys/fs/cgroup/pids/test01 (作成したcgroupには制御用のファイルが自動で作成される)
cgroup.clone_children  notify_on_release  pids.events  tasks
cgroup.procs           pids.current       pids.max

階層構造

先に述べたようにcgroupfsは通常のファイルシステムと同様の操作によりグループ操作を行います。つまり通常のファイルシステムのようなツリー構造を取ります。

複数階層構造

そして、cgroupのこの階層構造は複数持てます。複数回cgroupfsをマウントして階層を複数作った場合、システム上の各プロセスはすべての階層構造それぞれの中で、どこかのcgroupに属します。

サブシステム

cgroupは実装面から見るとふたつに分かれています。

階層構造の管理など、cgroup自身の機能を管理するためのコア部分と、実際にリソース制御を行うサブシステム(コントローラ)です。

実際に制御したいリソースがある場合は、そのリソース用のサブシステムを実装する必要があり、4.14カーネル時点では13のサブシステムが準備されています。サブシステムは、マウントの際にオプションでサブシステムの指定を行い、階層構造に紐付けます。ひとつの階層にひとつだけサブシステムを紐付けることも、複数のサブシステムを紐付けることもできます。

$ sudo mount -t cgroup -o cpu cpu /sys/fs/cgroup/cpu
(cpuサブシステムを使う指定をしてcgroupfsをマウント)

cgroupへのタスクの登録

cgroupへはスレッド単位でタスクが登録できます。

cgroupへタスクを登録するには、各ディレクトリ以下にできるファイルにプロセス、もしくはスレッドのIDを書き込みます。

$ echo $$ | sudo tee /sys/fs/cgroup/pids/test01/tasks ("test01"グループへのプロセスの登録)
13077

cgroup v1の問題点

以上のような特徴を持つcgroup v1は、さまざまなサブシステムが徐々に追加され、広く利用されるようになってきました。それと共に色々な問題点が指摘されるようになってきました。

複数階層構造

複数の階層を自由に作成でき、それぞれの階層に自由にサブシステムが所属できるという cgroup v1の特徴は、非常に柔軟で自由度が高く見えます。しかし、実際にはcgroup v1にはさまざまな制限があり、実はそれほど自由度がありません。

まず、サブシステムはひとつの階層にしか所属できません。cgroupのサブシステムのうち、ユーティリティ的な機能を持つfreezerサブシステムは、所属するプロセスすべてに対して同時に複数の階層で使えると便利です。しかし、複数の階層を持った場合には特定の階層でしか使えません。また、一度どこかの階層にサブシステムを所属させると、他の階層に移動ができません。

上の問題を回避するために、複数またはすべてのサブシステムをひとつの階層に所属させることもできます。そうした場合、たとえばCPUとメモリは別の階層構造(=ディレクトリ構造)を持たせて管理を行いたい、というような要求に応えられません。

つまり階層を複数持てて、それぞれの階層に任意の数のサブシステムを所属させられると言っても、意外に柔軟性がないのです。

また、複数の階層を持って、それぞれの階層内で異なる構造を作成しても管理が複雑になるだけで、実際にそのように使うようなケースはほとんどありませんでした。

結局は、サブシステムごとに別の階層を作り、密接に関係するサブシステムのみ同じ階層に属させることがほとんどになりました。また、複数の階層を持っても、各階層内の構造は同じような形の階層構造を取る運用がほとんどです。

実際、LXCコンテナでcgroupを使う場合は、コンテナ名のcgroupを各階層に作成しますので、全階層が同じ構造になります。

$ ls -F /sys/fs/cgroup/ (Ubuntu 16.04でのcgroup構造)
blkio/    cpu,cpuacct/  freezer/  net_cls@           perf_event/
cpu@      cpuset/       hugetlb/  net_cls,net_prio/  pids/
cpuacct@  devices/      memory/   net_prio@          systemd/
$ tree -L 2 -d /sys/fs/cgroup/ (各階層で同じような構造になっている様子)
/sys/fs/cgroup/
├── blkio
│   ├── init.scope
│   ├── lxc
│   ├── system.slice
│   └── user.slice
  :(略)
├── cpu,cpuacct
│   ├── init.scope
│   ├── lxc
│   ├── system.slice
│   └── user.slice
  :(略)
├── devices
│   ├── init.scope
│   ├── lxc
│   ├── system.slice
│   └── user.slice
  :(略)

サブシステム間の連携

cgroup v1では、サブシステムは実装に関わる規則に沿っていれば、特に他のサブシステムのことを気にせず実装できました。つまり各サブシステムの機能は独立しているということです。これは色々なサブシステムを徐々に実装していくという面ではメリットだったように思います。実装時も自身の機能のみ考えて実装すれば良いので実装がしやすいでしょう。

当然、このようにバラバラに実装が進むと当然サブシステム間で連携する機能はありません。また、複数階層構造を取り、どの階層にどのサブシステムが属するかわからない状態では、サブシステム間で連携するのは難しいでしょう。cgroupにさまざまなサブシステムが実装されていくにつれ、これが問題になるケースがでてきました。

たとえば、ファイルのI/Oでは、通常はメモリ上に確保されるページキャッシュを通した入出力が行われます。しかし、memoryサブシステムとblkioサブシステムは別々に実装されているため、cgroup v1ではページキャッシュを通したBuffered I/Oを制限できません。cgroup v1のblkioサブシステムを使って、きちんと制限が設定できるのはダイレクトI/Oの場合のみでした。

サブシステム間の一貫性

上に書いたように、各サブシステムは別々に実装が進められたため、動きに一貫性がありません。

たとえば、cpusetサブシステムを使えるようにcgroupfsをマウントし、mkdirコマンドでcgroupを作成します。そのままの状態で、このcgroupにタスクを登録しようとするとエラーになります。

$ sudo mount -t cgroup -o cpuset cpuset /sys/fs/cgroup/cpuset
$ echo $$ | sudo tee /sys/fs/cgroup/cpuset/test01/tasks 
3046
tee: /sys/fs/cgroup/cpuset/test01/tasks: No space left on device

これは、cpusetでcgroupを作成した直後は、cpuset.cpuscpuset.memsというふたつのファイルが空だからです。

タスクを登録する前にこれらのファイルの中身をきちんと設定するか、親cgroupのcgroup.clone_childrenファイルに1を書き込んでおけば、親cgroupの値が子にコピーされますので、エラーにはなりません。

しかし、このようにcgroup作成後にできるファイルの中身がデフォルトで空で、cgroup.clone_childrenファイルで動きが変わるサブシステムは、cpusetサブシステムのみです。

他にも、memoryサブシステムは、階層構造になっているツリーで祖先(上位)のcgroupの制限の影響を受けるか受けないかをmemory.use_hierarchyというファイルで制御できます。このような設定ができるのはmemoryサブシステムだけです。他のサブシステムでは、上位に割り当てられたリソースを子に分配していきます。

つまり、使用するサブシステムごとに、そのサブシステム独自の設定を行う必要があり、操作面から見ると一貫性が欠けていると言われても仕方がないでしょう。

どのノードにもタスクが所属できる

cgroupは、ディレクトリによって表されるcgroupでツリー構造を形成します。cgroup v1では、ツリー構造のどの部分のノードにもタスクが所属できます。

図1 cgroup-v1の階層構造
図1 cgroup-v1の階層構造

cpuサブシステム用のツリーで、図のように途中の階層にタスクが複数所属し、その子cgroupにもタスクが所属している場合を考えてみましょう。ツリーの階層が深くなるごとにふたつに分岐し、それぞれに50%ずつCPU時間を割り当てます。

cgroup Aにタスクが所属していなければ、BとCに25%ずつCPU時間を割り当てれば良いのですが、図ではAにも3つタスクが所属しています。このときA、B、Cに所属するタスクそれぞれにどれだけCPU時間が割り当たるのか、非常にわかりづらいです。

この問題は、子cgroupのタスクと親cgroupの内部タスクの競合と呼ばれ、cgroup v1の大きな問題点とされていました。この問題を解決するための方法はコントローラに任され、コントローラ間の一貫性を欠いていました。

スレッド単位での制御

cgroup v1では、cgroupへの所属するタスクの最小単位はスレッドでした。しかし、サブシステムが扱うリソースの中にはスレッド単位で制御できるリソースもあれば、スレッド単位では扱わないリソースもあります。たとえば前者はCPU、後者はメモリです。

元々プロセス単位でリソースを割り当てたのに、同じリソースを共有している複数のスレッドを別々にコントロールできるのが果たして正しいのか、どのように処理すべきなのかという問題があります。


以上のように、cgroup v1の特徴である自由度の高さが原因で、逆に色々と問題視されるようになってきました。筆者にとって、カーネルのcgroup関連の構造やコードは非常に複雑で理解しづらいです。この複雑さは、この自由度の高さが原因ではないかと思っています。

cgroup v2の開発

先に紹介したような問題点を解決するため、cgroupメンテナのTejun Heo氏によりcgroup v2の開発が始まりました。

この機能は、開発時点では"Unified hierarchy"と呼ばれていました。この機能は3.16カーネル(2014年8月)でマージされました。この時点では開発中の機能ということで、マウントオプションとして__DEVEL__sane_behaviorというオプションを与える必要がありました。

# mount -n -t cgroup -o __DEVEL__sane_behavior cgroup /sys/fs/cgroup/

"sane_behavior"(まともなふるまい)という名前に、cgroupを改良したいという強い思いが感じられますね(笑⁠⁠。

ちなみにcgroupのコード内で使われている変数でも、v1用の変数には"_legacy_"という文字列が、v2用の変数には"default"を表していると思われる"_dfl_"という文字列が入っており、ここにも同じような強い思いを感じます(笑⁠⁠。

3.16でマージされたあとも開発は続き、正式な機能としてマージされたのは4.5カーネル(2016年3月)の時点でした。この時点で機能が"stable"であるとされ、名前も"cgroup v2"となりました。

4.5時点でcgroup v2で使えるサブシステムはio、memory、pidsでした。カーネル付属文書にはcpuサブシステムの記載もあるのですが、執筆時点の最新カーネルである4.14の時点でもcpuサブシステムの機能はcgroup v2にはマージされていません(カーネル付属文書cgroup-v2.txtには注意書きがあります⁠⁠。

4.14カーネルの時点でcgroup v2で使えるサブシステムはio、memory、pids、rdma、perf_eventです。

cgroup v2の特徴

単一階層構造

開発時点から"Unified hierarchy"と呼ばれていた通り、cgroup v2は階層はひとつです。つまりマウントした時点でそのマウントポイント以下に作られるcgroupツリーがシステム上の唯一のcgroup v2のcgroupfsとなります。

サブシステム間の連携

単一階層構造になりましたので、かならずすべてのサブシステムが同じcgroupツリー内で管理されます。これにより、サブシステム間の連携ができるようになりました。

プロセス単位の管理

cgroupでコントロールする単位はプロセス単位です。これにより、メモリのようにリソースをメモリ単位で管理するサブシステムでも矛盾なくリソースの分配ができるようになり、全サブシステムで統一したポリシーでコントロールができるようなりました。

しかし、リソース管理がプロセス単位となったことが、cpuサブシステムがマージされない理由になっています。このため、cgroup v2でスレッド単位の管理ができるような機能が4.14カーネルでサポートされました。この機能については、筆者もまだちゃんと評価できていませんので、連載の後の回で紹介したいと思います。

スレッド単位の管理をサポートしたとはいえ、デフォルトではプロセス単位の管理です。スレッド単位で管理するためには、そのための機能を有効にする必要があります。

プロセスが所属できるのは末端のcgroupのみ

先にcgroup v1の問題点として、ツリー構造の途中のノードにもタスクが存在できることを挙げました。

この問題を解決するため、子cgroupにリソースを分配できるのは、自身にプロセスが所属していないときだけとなりました。つまり、コントローラがひとつでも有効になっている階層では、常にツリーの末端にあるcgroupにのみプロセスが所属しているということです。

ただし、root cgroupはこの制約を受けません。

サブシステムの制御はcgroupごと

cgroup v1はマウント時にその階層で使うサブシステムを指定しました。すると、その階層全体でそのサブシステムが使えました。サブシステムごとに階層の構造を変えたい場合は、別にマウントし、別の階層で管理しました。

cgroup v2では階層はひとつですが、cgroupごとに自身の子cgroupでどのサブシステムを有効化するかを選択できます。

図2 cgroup-v2のサブシステム制御
図2 cgroup-v2のサブシステム制御

図のように、cgroup v2全体ではcpu、io、memory、pidsのサブシステムが利用できるようになっているとします。

ここでroot cgroupで、子cgroupではcpu、memory、pidsサブシステムのみ使えるように設定すると、"cgroup A"と"cgroup D"にはcgroup自身の制御を行うファイルのほか、cpu、memory、pidsサブシステムに関係するファイルのみ表れます。

ここで"cgroup A"で、子cgroupではmemoryサブシステムのみ有効とする設定を行うと、"cgroup B"と"cgroup C"では、"cgroup A"からメモリリソースのみ分配を受けられ、cpuについては"cgroup A"で使えるCPU時間を特に制限を受けずに競争しながら使うことになります。

また、リソースはトップダウンで分配されます。つまり、親cgroupで有効に設定されているサブシステムだけを子cgroupに対して有効化できます。

図で説明すると、"cgroup B"と"cgroup C"では、"cgroup A"で有効なサブシステムだけしかリソース制御ができません。"cgroup A"はroot cgroupでcpu、memory、pidsのみ有効となるように設定されていますので、"cgroup B"、"cgroup C"に対していきなりioサブシステムを使えるように設定できません。

cgroup操作はcgroup v1と同じ

cgroupの操作はv1と同じです。

cgroupfsをマウントして、通常のファイルシステムの操作と同様に、ディレクトリ操作で階層の管理を行います。リソースの分配はファイルへの書き込みで行い、リソース分配の設定値や統計値の取得はファイルに対する読み取りで行います。

実はcgroup v1の問題点として、リソース分配をcgroupfsの持つファイル入出力で行うことも挙げられていました。しかし、このインターフェース部分についてはcgroup v1との互換性が保たれましたので、cgroupを利用する側のプログラムの基本的な動きを変更する必要はなく、影響が小さくて済みました。

cgroup v1との共存

cgroup v2は徐々に実装が進んでいますので、cgroup v2だけを使用した状態では、v1を使って実現していた機能をすべて実現できません。

そこで、cgroup v1とv2を同時に使用できます。これにより徐々にcgroup v2へ移行できます。systemdでもcgroup v1とv2の両方を使った"hybrid mode"という機能が実装されています。

cgroup v1とv2を同時に使った場合は、cgroup v2をマウントする時点で、cgroup v1で使われていないサブシステムのみがcgroup v2に現れます。

cgroup状態通知

自身または自分の子孫のcgroupにプロセスが参加した場合と、プロセスがなくなった場合の通知をpoll()inotify()dnotify()で受け取れます。

規約の整備

cgroup v1では、cgroupを作成したときに作成されるファイルに関する決まりを全くもっていませんでした。cgroup v2では、リソース分配のモデルが定義され、そのモデルに従ってファイル名やファイルのフォーマットなどの、cgroup操作に関わるインターフェースがきちんと定義されました。cgroupやサブシステムに関わる操作を行う場合に曖昧な点はなくなりましたし、ファイル名で機能がわかるようになりました。

まとめ

今回はcgroup v1で指摘されてきた問題点と、その問題点を解決するためにcgroup v2が持つことになった特徴について説明しました。

この連載では珍しく、今回は実行例がほとんどない記事になってしまったので、cgroup v2の特徴は少しわかりづらかったかもしれません。

次回は、実際にcgroup v2の操作を紹介しながら、今回説明したcgroup v2の特徴をおさらいしたいと思います。

おすすめ記事

記事・ニュース一覧