TT Lab
はじめる
学ぶ 学習パス コース

コンテナの内部原理

cgroup v2と、制限が守られていないように見えるとき

TT Labで続きを見る

一言でいうと

ネームスペースは何を見られるか、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とどう区別するかを整理します。