ノードのサイジングで知っておきたいOpenShiftのsystem-reserved

こんにちは、Red Hatでソリューションアーキテクトをしている石川です。
OpenShiftを含むKubernetesのクラスタを設計する際に悩まれるポイントの一つとして、ノードのサイジングがあると思います。 実際に必要となるノードのサイズは、その上で動かすコンテナアプリに依存するため、その要件を確認した上で決める必要がありますが、一つ抑えておきたいポイントとしてノードの保有するCPU、メモリを全てコンテナアプリで使用することができるわけではないということです。

この時関係してくるのがkubeletのsystem-reservedという設定であり、今回はOpenShiftにおいてこのsystem-reservedがどのように設定されるのかを紹介したいと思います。

Kubernetesのノードでコンテナに割り当て可能なリソース

Kubernetesの公式ドキュメントを見ると、ノードのリソースのうち、実際にPodに割り当て可能な領域は以下のallocatableで表される領域であると書かれています。

式として表すと、
割り当て可能なリソース(allocatable) = ノードのリソース - kube-reserved - system-reserved - eviction-threshold
ですね。

ここで登場する、kube-reservedsystem-reservedeviction-thresholdはノードで動かしているkubeletの設定オプションになります。(正確にはeviction-threshold--eviction-hardというオプションで指定します。)

Kubernetesクラスタの一部としてノードを動かす場合、kube-api-serverと通信するためのkubeletと、実際にコンテナの操作を担うコンテナランタイム(OpenShiftの場合cri-o)が必要となります。 仮にノードのリソースを全てコンテナ向けに割り当てると考えると、kubeletやcri-oを動かすためのリソースが圧迫されてしまい、結果としてそのノード上で動作するコンテナ全体に影響を及ぼしてしまう(最悪、全てのコンテナが停止する)ことが考えられます。
そのためkubeletやcri-oなどのプロセスが安定的に稼働できるよう、ノード上であらかじめ必要なリソースを確保する仕組みとしてkube-reservedsystem-reservedeviction-thresholdが存在しています。

kube-reserved、system-reserved、eviction-thresholdについて

再び公式ドキュメントを見るとそれぞれの設定について説明があります。

kube-reservedは、kubeletやコンテナランタイムなど、
system-reservedはsshdやudevなどのリソース確保を目的とした設定項目となっています。
それぞれでCPU、Memory、ephemeral-storageの値を入力可能です。

kube-reservedとsystem-reservedはそれぞれkube-reserved-cgroupsystem-reserved-cgroupというオプションと一緒に使用することができ、ここでcgroupを指定するとそのための占有リソースが確保されるようになります。

もしかするとここで、おや、と思われる方もいるかも知れません。というのもkube-reservedもsystem-reservedも、結局はkube-reserved-cgroupsystem-reserved-cgroupで指定したcgroupを見て、それらのためのリソースを確保するため、設定項目の名前は違うもののできることは同じになるからです。

またこのkube-reserved-cgroupsystem-reserved-cgroupは設定しないことも可能です。 その場合特定のcgroup向けのリソースを確保するのではなく、単純にPodに割り当て可能なリソースにkube-reservedとsystem-reservedで設定した値が含まれなくなるという形になります。

eviction-thresholdはこれらとはまた違う役割を持っています。 あるノードのMemoryが、その上で動くPodもしくはkubelet等のプロセスにより全て使われてしまうとSystem OOMsの状態となり、ノード上で動いている全てのPodの動作に影響を与えることが考えられます。
そのためkubeletのeviction-hardという設定では、現在利用可能なリソースの値がeviction-hardを下回ったら、そのノード上で動いているPodを停止させることで、リソース枯渇を防ぐという動作を取るようになります。この時どのPodが停止するかはPodのPriority等により決定されます。気をつけたい点として、この時のPodの停止は通常のGraceful Shutdownではないため注意が必要です。

kubeletではデフォルトでこのeviction-hardが設定されています。 kubernetes.io

OpenShiftでの設定

ここまでご紹介した各設定項目をOpenShiftでどのように設定しているかについては、以下のドキュメントにまとまっています。 docs.openshift.com

OpenShiftではkube-reservedについては設定をしておらず、system-reservedのみを設定しています。これは先ほど書いたように、実際にはこの二つの項目でできることは同じであるため、system-reservedのみを使用しているのだと考えられます。

system-reservedで具体的にどれだけのリソースが確保されるかは、利用しているOpenShiftの環境により異なってきます。
OpenShiftではバージョン4.8より、system-reservedの値を使用しているノードのリソースから自動計算するという機能が追加されています。 docs.openshift.com

こちらはSelf ManagedなOpenShiftではデフォルトでオフとなっているのですが、例えばManged OpenShiftのROSAなどではデフォルトでこの機能が有効化されており、自動で計算された値がsystem-reservedに設定されています。
この時の計算方法は以下のナレッジベースに記載があるため、気になる方はご確認下さい。 access.redhat.com

ここからは実際の設定値を確認していきましょう。今回はsystem-reservedの自動計算がオンになっている環境を使っています。
まずoc proxyコマンドを実行しておきます。

oc proxy

そうしたら別のターミナルを立ち上げ、oc get nodeでノードの情報を取得します。

oc get node
---
NAME                                         STATUS   ROLES    AGE   VERSION
ip-10-0-155-114.us-east-2.compute.internal   Ready    worker   16h   v1.22.8+9e95cb9
ip-10-0-158-172.us-east-2.compute.internal   Ready    master   16h   v1.22.8+9e95cb9
ip-10-0-162-108.us-east-2.compute.internal   Ready    master   16h   v1.22.8+9e95cb9
ip-10-0-168-39.us-east-2.compute.internal    Ready    worker   16h   v1.22.8+9e95cb9
ip-10-0-207-222.us-east-2.compute.internal   Ready    master   16h   v1.22.8+9e95cb9

あとは以下のcurlコマンドを実行することでノード上で動いているkubeletの設定を確認することができます。

curl -sSL "http://localhost:8001/api/v1/nodes/${NODE_NAME}/proxy/configz" | jq .

実行結果 (クリックで展開)

{
  "kubeletconfig": {
    "enableServer": true,
    "staticPodPath": "/etc/kubernetes/manifests",
    "syncFrequency": "1m0s",
    "fileCheckFrequency": "20s",
    "httpCheckFrequency": "20s",
    "address": "0.0.0.0",
    "port": 10250,
    "tlsCipherSuites": [
      "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
      "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
      "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
      "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
      "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
      "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
    ],
    "tlsMinVersion": "VersionTLS12",
    "rotateCertificates": true,
    "serverTLSBootstrap": true,
    "authentication": {
      "x509": {
        "clientCAFile": "/etc/kubernetes/kubelet-ca.crt"
      },
      "webhook": {
        "enabled": true,
        "cacheTTL": "2m0s"
      },
      "anonymous": {
        "enabled": false
      }
    },
    "authorization": {
      "mode": "Webhook",
      "webhook": {
        "cacheAuthorizedTTL": "5m0s",
        "cacheUnauthorizedTTL": "30s"
      }
    },
    "registryPullQPS": 5,
    "registryBurst": 10,
    "eventRecordQPS": 5,
    "eventBurst": 10,
    "enableDebuggingHandlers": true,
    "healthzPort": 10248,
    "healthzBindAddress": "127.0.0.1",
    "oomScoreAdj": -999,
    "clusterDomain": "cluster.local",
    "clusterDNS": [
      "172.30.0.10"
    ],
    "streamingConnectionIdleTimeout": "4h0m0s",
    "nodeStatusUpdateFrequency": "10s",
    "nodeStatusReportFrequency": "5m0s",
    "nodeLeaseDurationSeconds": 40,
    "imageMinimumGCAge": "2m0s",
    "imageGCHighThresholdPercent": 85,
    "imageGCLowThresholdPercent": 80,
    "volumeStatsAggPeriod": "1m0s",
    "systemCgroups": "/system.slice",
    "cgroupRoot": "/",
    "cgroupsPerQOS": true,
    "cgroupDriver": "systemd",
    "cpuManagerPolicy": "none",
    "cpuManagerReconcilePeriod": "10s",
    "memoryManagerPolicy": "None",
    "topologyManagerPolicy": "none",
    "topologyManagerScope": "container",
    "runtimeRequestTimeout": "2m0s",
    "hairpinMode": "promiscuous-bridge",
    "maxPods": 250,
    "podPidsLimit": -1,
    "resolvConf": "/etc/resolv.conf",
    "cpuCFSQuota": true,
    "cpuCFSQuotaPeriod": "100ms",
    "nodeStatusMaxImages": 50,
    "maxOpenFiles": 1000000,
    "contentType": "application/vnd.kubernetes.protobuf",
    "kubeAPIQPS": 50,
    "kubeAPIBurst": 100,
    "serializeImagePulls": false,
    "evictionHard": {
      "imagefs.available": "15%",
      "memory.available": "100Mi",
      "nodefs.available": "10%",
      "nodefs.inodesFree": "5%"
    },
    "evictionPressureTransitionPeriod": "5m0s",
    "enableControllerAttachDetach": true,
    "makeIPTablesUtilChains": true,
    "iptablesMasqueradeBit": 14,
    "iptablesDropBit": 15,
    "featureGates": {
      "APIPriorityAndFairness": true,
      "DownwardAPIHugePages": true,
      "LegacyNodeRoleBehavior": false,
      "NodeDisruptionExclusion": true,
      "RotateKubeletServerCertificate": true,
      "ServiceNodeExclusion": true,
      "SupportPodPidsLimit": true
    },
    "failSwapOn": true,
    "memorySwap": {},
    "containerLogMaxSize": "50Mi",
    "containerLogMaxFiles": 5,
    "configMapAndSecretChangeDetectionStrategy": "Watch",
    "systemReserved": {
      "cpu": "0.11",
      "memory": "5.3Gi"
    },
    "enforceNodeAllocatable": [
      "pods"
    ],
    "volumePluginDir": "/etc/kubernetes/kubelet-plugins/volume/exec",
    "logging": {
      "format": "text"
    },
    "enableSystemLogHandler": true,
    "shutdownGracePeriod": "0s",
    "shutdownGracePeriodCriticalPods": "0s",
    "enableProfilingHandler": true,
    "enableDebugFlagsHandler": true,
    "seccompDefault": false,
    "memoryThrottlingFactor": 0.8
  }
}

この設定の中で"evictionHard"や"systemReserved"として値が設定されているのを確認できます。
"evictionHard"を見るとkubeletのデフォルトの設定値がOpenShiftでも適用されているのがわかります。

    "evictionHard": {
      "imagefs.available": "15%",
      "memory.available": "100Mi",
      "nodefs.available": "10%",
      "nodefs.inodesFree": "5%"
    },

"systemReserved"には自動計算された値が設定されています。
今回はノードのメモリが約62GiBなので比較的大きな値がここに設定されているのがわかります。

    "systemReserved": {
      "cpu": "0.11",
      "memory": "5.3Gi"
    },

また設定値を確認すると"systemReservedCgroup"が設定されていません。 つまり、OpenShiftではkubeletに--system-reserved-cgroupを設定しておらず、特定のcgroupにリソースを割り振る動きをしていないということがわかります。

自分で割り当て可能なリソースを計算してみる

ここまでで実際の設定値も含めてsystem-reserved等について確認することができました。
折角なので、メモリを対象としてPodに割り当て可能なリソースを自分で計算したいと思います。

まずノード全体のメモリの値を以下のコマンドで取得します。

oc get node ${NODE_NAME} -o jsonpath="{.status.capacity.memory}"
---
64790784Ki

この値から先ほど確認したsystem-reservedのメモリの値とevicition-hardの値を引けば割り当て可能リソースになるはずです。

allocatable = 64790784Ki - (5.3Gi + 100Mi)

単位をKiBに合わせると、
allocatable = 64790784 - (5557452.8 + 102400) = 59130931.2
となります。

答え合わせのため、以下のコマンドを実行しましょう。
割り当て可能なリソースはノードの.status.allocatableから確認できます。

oc get node ${NODE_NAME} -o jsonpath="{.status.allocatable.memory}"
---
60550073548800m

単位をKiBに合わせると
60550073548800 \div (1024 \times 1000) = 59130931.2

ということで無事正しい値を計算できたことが確認できました。

まとめ

今回はOpenShiftのsystem-reservedの設定についてご紹介しました。
system-reserved等の設定はノードで実際にどれだけのリソースを使うことができるのかということを理解する上で大事なポイントになります。 またOpenShiftにはノードを安定して運用するためのsystem-reservedの自動計算機能もありますので、本記事を見てご興味持たれたらぜひこちらも試してみて下さい。

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。