cgroup v2と、制限が守られていないように見えるとき
一言でいうと
ネームスペースは何を見られるか、cgroupはどれだけ使えるかを担当します。そしてこのコースで、最もよく人をだます事実は、cgroupは制限をかけますが、/procを隠しはしないという点です。
なぜ必要なのか
cgroupは2006年にGoogleで始まり、2007年にカーネル2.6.24にマージされました。元の名前は「process container」でしたが、当時すでに広く使われていたcontainerという言葉と紛らわしいという理由で、control group、略してcgroupに改名されました。名前の歴史が、そのまま役割の説明です。分離ではなく、リソースの統制です。
1つのホストにコンテナを30個載せていて、そのうち1つがメモリを使い切ると、残りの29個も一緒に死にます。ネームスペースは、この問題をまったく解決できません。お互いが見えないだけで、同じ物理リソースを共有しているからです。
どう動くのか
まずバージョンを確認します。stat -fc %T /sys/fs/cgroupがcgroup2fsならv2、tmpfsならv1です。v2の主なファイルは、次のとおりです。
| ファイル | 意味 |
|---|---|
cpu.max |
quota periodの2つの値。50000 100000なら0.5 CPU |
cpu.weight |
1–10000の範囲の相対的な重み、デフォルトは100 |
cpu.stat |
nr_periods、nr_throttled、throttled_usec |
memory.min/low/high/max |
保護ラインと上限 |
memory.current |
現在の使用量 |
memory.events |
上限に達した回数 |
pids.max |
プロセス数の上限 |
v1では、それぞれcpu.cfs_quota_us、memory.limit_in_bytesでした。
memory.highとmemory.maxを区別することが重要です。highを超えると、回収の圧力がかかって割り当てが遅くなりますが、死ぬことはありません。maxを超えると、OOM Killです。そのため、highは「ここから痛くしろ」、maxは「ここが壁だ」です。
CPUは、殺さずに眠らせます。cpu.statでnr_throttled / nr_periodsが5%を超えたら問題と見ます。1000周期のうち350回スロットリングされたなら35%で、このとき、CPU使用率のグラフは暇そうに見えるのに、応答時間だけが跳ねるという、典型的な絵が出ます。
よく使うバイト値は、覚えておくと便利です。64MiB = 67108864、192MiB = 201326592、256MiB = 268435456、512MiB = 536870912。
終了コード137 = 128 + 9 = SIGKILLです。OOM Killは、アプリにどんな例外も与えません。try/exceptで捕まえられず、ログも残りません。カーネルログのMemory cgroup out of memory: Killed process ...を見て初めて確定でき、ランタイム側ではState.OOMKilledで切り分ける必要があります。
現場での姿
最も重要な落とし穴です。--memory=256m --cpus=0.5で起動したコンテナでfree -mを打つと64228MBが、nprocは32が出ます。cgroupは制限をかけますが、/procを隠さないからです。JVMはUseContainerSupportでcgroupのファイルを直接読んでこの問題を解決しましたが、Nodeのos.cpus()とGoのruntime.NumCPU()は、相変わらずホストの値を見ます。そのためGoのサービスにはGOMAXPROCSを明示する必要があります。32スレッドでワーカーを起動しておいて、0.5コアを割り当てると、スロットリングがたまるだけです。
KubernetesのQoSクラスも、ここから出てきます。GuaranteedはOOM score -997、Burstableは2–999、BestEffortは1000です。BestEffortが先に死ぬのは、ポリシーの文言ではなく、cgroupとOOMスコアの直接の結果です。
もう1つあります。rootless環境では、制限をかけても実際には強制されないことがあります。systemd delegation(Delegate=cpu memory pids io)がなければ、リクエストは記録されますが、カーネルには反映されません。そのため次のラボは、docker inspectのHostConfigのリクエスト値と、コンテナが実際に見る値を、両方確認します。2つが違っていれば、それ自体が学ぶべきことです。
v1とv2で違って見える場所
cgroupはv1とv2が共存していて、ファイル名と意味が違います。どちらなのかを先に確認しないと、存在しないファイルを探すことになります。
stat -fc %T /sys/fs/cgroup # cgroup2fs 면 v2, tmpfs 면 v1
| 何を | v1 | v2 |
|---|---|---|
| メモリの上限 | memory/memory.limit_in_bytes |
memory.max |
| 現在の使用量 | memory.usage_in_bytes |
memory.current |
| CPUの上限 | cpu.cfs_quota_us / cpu.cfs_period_us |
cpu.max(2つの値が1行) |
| スロットリングの統計 | cpu.stat |
cpu.stat |
| OOMの記録 | (なし) | memory.events |
v2のmemory.eventsが特に役に立ちます。v1にはないので、「このコンテナがOOMで死んだことがあるか」を、カーネルログでしか知れませんでした。
上限がなければmaxと出ます。数字ではなくmaxという文字列なので、パースするコードで例外が出ます。ツールを作るときによく引っかかります。
メモリの上限は、ページキャッシュも数えます。ファイルをたくさん読むコンテナは、キャッシュがたまって上限に達しますが、カーネルがそのキャッシュを回収するので、たいていは死にません。そのため、memory.currentが上限に張り付いていること自体は問題ではありません。memory.eventsのoom_killが増えたら、本物の問題です。
CPUの上限は、使用率には見えません。100msごとに割り当て量を使い切って、残りを待つので、平均の使用率は低いのに、応答だけが遅くなります。cpu.statのnr_throttledとthrottled_usecが、その証拠です。
小さすぎる上限は、かえって有害です。CPUの上限を0.1コアにすると、100msのうち10msしか動けないので、短い作業も複数の周期にまたがって終わります。遅延が目に見えて大きくなるので、リクエストは低く、上限は余裕をもって、または設定しないほうが、応答時間には有利です。
次のラボですること
cgroupのバージョンとコントローラーの一覧を確認し、メモリ・CPU・PIDの上限をそれぞれかけてみたあと、リクエスト値と観測値を突き合わせます。SIGKILLで137を自分で作ってみて、OOM Killとどう区別するかを整理します。