Kubernetes で cgroup がどう利用されているか

執筆者 : 山下雅喜


はじめに

cgroup とは、Linux カーネルの機能の1つであり、プロセスやスレッドが利用するリソースの制限や分離を行うための機能です。 cgroup は名前空間の機能と共に、Linux コンテナの根幹を成す技術の1つでもあります。

Kubernetes において、名前空間は PID 名前空間、ネットワーク名前空間、マウント名前空間などで利用者の目に触れやすい存在ではありますが、cgroup は相対的に目に付きにくいもののように感じています。

そこで今回は、Kubernetes のいくつかの機能を例に挙げ、cgroup がどう利用されているか見ていきます。

利用した環境

利用したソフトウェアおよびバージョンは次の通りです。

  • OS : Ubuntu 20.04.1 LTS
  • Linux Kernel : 5.4.0-58-generic
  • Kubernetes : 1.20.1
  • CRI : containerd 1.4.3
  • OCI Runtime : runc 1.0.2-dev (containerd に同梱されていたもの)
  • CNI : Calico 3.17.1
  • cgroup Driver : cgroupfs

サーバは1台 (CPU16コア、メモリ32GB) だけ用意し、kubeadm を用いてシングルノードのクラスタとしました。

cgroup の階層構造

システムの cgroup の設定ファイルは /sys/fs/cgroup/<cgroup コントローラ名> ディレクトリ以下にあり、systemd-cgls コマンドを利用するとシステム上の cgroup を階層構造で確認できます。

$ sudo systemd-cgls cpu

今回利用した環境の構成では、Pod やコンテナの cgroup は Kubernetes によって、コントローラごとに次のような階層構造で作成していました。

/kubepods
    /<Guaranteed Pod の UID>
        /<コンテナID>
    /burstable
        /<Burstable Pod の UID>
            /<コンテナID>
    /besteffort
        /<BestEffort Pod の UID>
            /<コンテナID>

Pod の QoS クラス (Guaranteed / Burstable / BestEffort) によって階層が若干異なりますが、最上位に kubepods グループがあり、その子または孫として Pod のグループがあり、さらにその下にコンテナのグループがあるという構成となっています。

Pod の UID やコンテナIDは、Pod の metadata や status で確認できます。 CRI ランタイムのコマンド (crictl inspect コマンドなど) や /proc/<プロセス番号>/cgroup ファイルで、コンテナのプロセスの cgroup のパスを直接確認することもできます。

また、Kubernetes は次の11個の cgroup のコントローラに関して、Pod やコンテナごとに cgroup を作成していました。

  • blkio
  • cpu
  • cpuacct
  • cpuset
  • devices
  • freezer
  • hugetlb
  • memory
  • net_cls
  • net_prio
  • perf_event
  • pids
  • systemd

例えば、CPU に関するコンテナの cgroup の設定ファイルは、次のようなディレクトリ内にあります。

/sys/fs/cgroup/cpu/kubepods/burstable/pod47795806037f7cd456da0e7aaf65bfc9/6738405fa7f7af60af3db414ed9b1daabb639b4a13ee33c8e27dd75eeb8bbac1

これらの cgroup のコントローラに関して、Kubernetes は何らかの制御を行っている可能性があると言えます。

例:CPU やメモリの制限

Pod 内のコンテナで利用できる CPU やメモリの量を、Pod の spec で制限できる機能は皆さんご存じのことでしょう。

例えば次のような Pod を作成すると、コンテナ burstable で利用可能な CPU は1コアの10%、メモリは100MBまでに制限されます。

apiVersion: v1
kind: Pod
metadata:
  name: burstable
spec:
  containers:
  - name: burstable
    image: busybox:latest
    imagePullPolicy: IfNotPresent
    command: ["tail", "-f", "/dev/null"]
    resources:
      limits:
        cpu: 100m
        memory: 100Mi
      requests:
        cpu: 50m
        memory: 50Mi

この設定に従ってコンテナが属する cgroup の次のファイルに値が書き込まれることにより、カーネルによって制限が行われます。

  • cpu.cfs_quota_us
    • cpu.cfs_period_us で指定された時間内に利用可能な CPU リソースの時間 (単位はともにマイクロ秒)
    • cpu.cfs_period_us の値の spec.containers[].resources.limits.cpu 倍 (上記コンテナでは 0.1 倍) の値が設定される
    • specを指定していない場合、上限なしを表す値である -1 が設定される
  • memory.limit_in_bytes
    • 利用可能なメモリの量
    • spec.containers[].resources.limits.memory (上記コンテナでは 100MiB) の値がそのまま設定される
    • specを指定していない場合、非常に大きな上限が設定されていた
      • 今回の環境では 8EiB (8*260 バイト) となっていた

CPU やメモリのリクエスト量 (spec.containers[].resources.requests 以下) は kube-scheduler が Pod を割り当てるノードを選択する際に利用される値ですが、次の cgroup のファイルにも影響します。

  • cpu.shares
    • cgroup 内のタスクに割り当てられる CPU 時間の相対的配分
    • CPU を要求するタスクが複数存在する場合、この値が大きいほど多くの CPU 時間が割り当てられる
    • 1024 の spec.containers[].resources.requests.cpu 倍 (上記コンテナでは 0.05 倍) の値が設定される
    • specを指定していない場合、最小の値である 2 が設定される

spec で CPU のリクエスト量を指定すれば、ノードの CPU リソース自体が十分に存在しない場合に、タスクの優先度のような役割も期待できると言えます。

例:PID 数の制限

Kubernetes v1.20 では、次の2つの機能が stable に昇格しました。

  • SupportPodPidsLimit : 1つの Pod で利用可能な PID 数を制限する機能
  • SupportNodePidsLimit : 1つのノード上の全ての Pod で利用可能な PID の総数を制限する機能

※参考 : Process ID Limits And Reservations

システム全体で利用可能な PID 数の上限はカーネルパラメータ kernel.pid_max によって定まっており、/proc/sys/kernel/pid_max ファイルで実際の設定値を確認できます。 今回検証した Ubuntu 20.04 LTS ではデフォルト値が 4194304 ですが、Ubuntu 18.04 LTS や CentOS 7 ではデフォルト値が 32768 となっていました。

プロセスやスレッドごとに1つの PID が利用されるため、32768 という値は決して大きな値ではありません。 また、PID が枯渇すると新たなプロセスを作成できず、kill や shutdown コマンドすら実行できなくなってしまいます。 そのため、SupportPodPidsLimit や SupportNodePidsLimit のような PID 数を制限する機能は、Pod によってそういった事象が引き起こされることを防止するために用意されています。

SupportPodPidsLimit 機能は、kubelet の --pod-max-pids パラメータで指定できます。 このパラメータが指定されたノードで新しく Pod が作成されると、Pod が属する cgroup の次のファイルに値が書き込まれることにより、カーネルによって制限が行われます。

  • pids.max
    • cgroup 内のタスクで利用可能な PID 数
    • --pod-max-pids パラメータの値が設定される
    • パラメータを指定していない場合、上限なしを表す値である max が設定される

また、SupportNodePidsLimit 機能は、kubelet の --system-reserved または --kube-reserved パラメータの pid キーで指定できます。 このパラメータが指定されたノードでは、kubepods グループ (全ての Pod の親である cgroup) の次のファイルに値が書き込まれることにより、カーネルによって制限が行われます。

  • pids.max
    • cgroup 内のタスクで利用可能な PID 数
    • カーネルパラメータ kernel.pid_max から kubelet のパラメータで指定した値を減らした値が設定される
    • kubelet のパラメータを指定していない場合、カーネルパラメータの値がそのまま設定される

Pod によってシステムの PID が枯渇する事象の発生を防止するためには、SupportNodePidsLimit 機能を用いると良いでしょう。 また、特定の Pod によって使い果たされることを防止するためには、SupportPodPidsLimit 機能を用いると良いでしょう。

例:CPU コアの排他的割り当て

マルチコアのシステムにおいて、各プロセスをどの CPU コアで実行するか、カーネルのスケジューラが決定します。 特別な設定をしない限り CPU コアは複数のプロセスによって共有して利用されるため、他のプロセスによる CPU の利用状況が自プロセスに影響を及ぼすことがあります。

Kubernetes で作成した Pod のコンテナのプロセスも通常は CPU コアが共有して利用されますが、Topology Manager および CPU Manager の機能を用いれば、Pod に CPU コアを排他的に割り当てて利用させることができます。

※参考 : Control Topology Management Policies on a node
※参考 : Control CPU Management Policies on the Node

排他的に割り当てるには、事前に kubelet の次の3つのパラメータを設定しておく必要があります。

  • --topology-manager-policy : none 以外に設定する
  • --cpu-manager-policy : static に設定する
  • --system-reserved または --kube-reserved : Pod に割り当てない CPU リソースを残しておく
    • 代わりに --reserved-cpus を設定しても良い

この設定をしたうえで、CPU 要求 (spec.containers[].resources.requests.cpu の値) が整数で、QoS クラスが Guaranteed の Pod を作成すると、その Pod のコンテナのプロセスには spec で指定した数の CPU コアが排他的に割り当てられます。

apiVersion: v1
kind: Pod
metadata:
  name: guaranteed
spec:
  containers:
  - name: guaranteed
    image: busybox:latest
    imagePullPolicy: IfNotPresent
    command: ["tail", "-f", "/dev/null"]
    resources:
      limits:
        cpu: 2
        memory: 100Mi
      requests:
        cpu: 2
        memory: 100Mi

この機能は、Pod のコンテナが属する cgroup の次のファイルに値が書き込まれることにより、CPU コアの割り当てが制御されます。

  • cpuset.cpus
    • cgroup 内のタスクが利用可能な CPU コアのリスト

CPU 要求が整数で QoS クラスが Guaranteed の Pod が存在しない場合、全ての Pod のコンテナのプロセスは、全ての CPU コアを共有利用可能な状態となっています。 今回利用した CPU コアが16個あるサーバでは、全ての Pod のコンテナの cgroup の cpuset.cpus の値は 0-15 となっていました。

次に、上記 YAML のように2個の CPU コアを排他的に割り当てる Pod を作成すると、その Pod のコンテナの cgroup の cpuset.cpus の値は 1-2 となり、1番と2番の CPU コアのみを利用するよう制限されました。 それと同時に、他の Pod のコンテナの cgroup の cpuset.cpus の値は 0,3-15 に変更され、1番と2番の CPU コアを利用しないよう制限されました。

このように、cgroup の cpuset コントローラを用いて、Pod のコンテナへの CPU コアの排他的割り当てが実現されています。

ただし、Kubernetes は Pod やコンテナ以外の cgroup を制御しないため、この機能だけでは systemd 配下のサービスのプロセスなどとは排他的にならない点に注意が必要です。 systemd の CPUAffinity を設定するなどすれば、完全な排他的割り当てが実現できる可能性があります。

まとめ

Kubernetes の機能のいくつかが cgroup によって実現されていることを解説しました。 特に、起動された後のコンテナの動作に関する制限において cgroup が利用されている例を紹介しました。

cgroup を利用すれば Pod やコンテナの動作をもっと細かく制御できるでしょうし、昨年の KubeCon North America 2020 でもカスタムリソースコントローラを作って cgroup を操作するような事例もありました。

※参考 : Practice of Fine-grained Cgroups Resources Scheduling in Kubernetes

システムの要件次第ですが、cgroup を活用できるケースはまだまだありそうです。