📘

[Sidecar Containers] Pod Eviction 時のメッセージの改善

2024/10/14に公開

はじめに

先日 Kubernetes で報告されていたバグを修正する PR を送りました。その時に、今後 Kubernetes へのコントリビュートを考えている方の参考になればと思い、どう取り組んだか (Issue の読み解き方やローカル環境での再現、コードの修正、テストの追加などの一通りの流れ) を脳内ダンプして言語化してみました。それを社内向けに共有していたのですが、PR も無事にマージされたので、一部加筆修正して記事として公開します。

背景

SIG-Node の Chair で WG-Sidecar のリードでもある SergeyKanzhelev が Sidecar Containers 関連の Issue を 3 つ作成していました。どういうバグかと本来どうあるべきかが説明されていたので、その中から興味のある Issue を選びました。Issue ですぐに /assign はせず、他の人に取られても良いかなくらいの気持ちで取り組み始めました。

KEP-753: Sidecar Containers は Kubernetes 1.31 時点でベータの機能です。Sidecar Containers の機能は SergeyKanzhelevmatthyxgjkim42tzneal (最近見かけない) が中心となって開発しています。Kubernetes 1.32 では Sidecar Containers の機能は GA 昇格せず、GA に向けて既知のバグや課題を消化・整理し、Sidecar Containers の KEP からスピンオフした KEP-4438: Restarting sidecar containers during Pod termination の実装も進めていく予定です。そのため、今回 Issue として上がった細かい修正に関しては人手が足りない状態のようです。

Issue を読み解く

k/k#124938 は Pod の Eviction 時のメッセージの中に Restartable Init Containers (Sidecar Containers) が考慮されていないというバグです。

  • evictionMessage() の中で Restartable Init Containers が考慮されていない
  • Pod が Evict された時に Restartable Init Containers のリソース要求も annotation の中に含めるべき

Kubernetes において Pod の Eviction (退避) が発生する要因は以下のとおりです。

  1. kube-scheduler が Node 上にこれ以上 Pod を起動できない場合に、優先度の低い Pod を Preemption して立ち退かせる (Pod Priority and Preemption)
  2. kubelet が Node のリソース逼迫を理由に Pod を立ち退かせる (Node-pressure Eviction)
  3. Pod に ephemeral-storage 上限を設定していて使用量が超過した場合に Pod を立ち退かせる(正確には 2. と同じ)
  4. kubectl drain などが内部的に呼び出している Eviction API によって Pod を立ち退かせる (API-initiated Eviction)

Kubernetes のコードベースを evictionMessage で調べる (https://github.com/search?q=repo%3Akubernetes%2Fkubernetes evictionMessage&type=code) と kubelet の処理が引っ掛かります。ということで、今回修正したい Eviction は 2. の Node のリソース逼迫を理由とした Pod の立ち退きだと分かります。

次に evictionMessage() の処理を軽く眺めます。Node のリソース逼迫による Eviction が発生した理由と Eviction の情報を含んだ annotation を返す処理です。この処理は後から詳細に見ていくので、まずはこの情報がどう利用されているのかを追いかけます。Eviction の理由と annotation を evictPod() に渡しています。evictPod() の処理を見てみるとこれらの情報を Kubernetes Event を通してユーザーに通知していることが分かります。

ここまで分かったら evictionMessage() に戻って処理を見ていきます。stats()Pod のメトリクスを取得 しています。stats()makeSignalObservations() から取得 しています。cachedStatsFunc() を覗くと kubelet の Summary API から取得したメトリクスの情報を Pod 毎 (正確には Pod UID 毎) に詰め直していることが分かります。kubelet の Summary API に関しては Node metrics data に記載があり、kubectl コマンドでも情報が取得できます。

kubectl get --raw "/api/v1/nodes/kind-worker/proxy/stats/summary"

evictionMessage() に戻って続きを見ていきます。kubelet の Summary API で取得した Pod のメトリクスの情報をコンテナ毎に見ていき、PodSpec の Regular Containers で定義しているコンテナ名と一致しているか を見ています。一致している場合は、PodSpec からコンテナのリソース要求を参照 し、超過したリソース (Ephemeral Storage / Memory) の使用量を Summary API から取得 します。で、超過したリソースの使用量とリソース要求を比較して、リソース要求よりも多くリソースを使用しているコンテナの情報を Event のメッセージに記載 します。最後に annotation にもリソース要求よりも使用量の高いコンテナ名と使用量、リソースの種類の情報を格納 しています。

ここまでの情報から以下のことが分かりました。

  • Node のリソース逼迫の際に Pod の Eviction が発生し、その原因などを Kubernetes の Event を通してユーザーに通知している
  • リソース逼迫した際に Pod 内の各コンテナのリソースの使用量は kubelet の Summary API から取得している
  • リソース逼迫が発生した際に、Pod 内のコンテナ毎にリソースの使用量とリソース要求を比較して、超過しているコンテナの情報を Kubernetes の Event のメッセージや annotation に記載している
  • Restartable Init Containers が含まれる Pod で Eviction が発生しても、Event の情報に Restartable Init Containers は含まれない
    • PodSpec の Regular Containers に関してはリソース要求の比較を行っているが、Restartable Init Containers に関しては比較を行っていないのが原因

また、以下の点が気になりました。

  • Restartable Init Containers がリソース超過した場合も Node のリソース逼迫による Eviction は発生するか?
  • Restartable Init Containers のリソース使用量の情報は Summary API に含まれているか?

Pod の Eviction 時の挙動の再現

気になる点をローカル環境で実験しながら潰していきました。まず、kind を利用して Node ののメモリ逼迫の再現を目指しました。これが一番時間が掛かりました。

Node のメモリ逼迫の挙動を確認するための E2E テストを参考に、最初は kubelet の kube-reserved の設定を増やして Node の割り当て可能なリソースを制限する方法を試しました。

cat <<EOF | kind create cluster --config -
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  image: kindest/node:latest
- role: worker
  image: kindest/node:latest
  kubeadmConfigPatches:
  - |
    kind: JoinConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        # ノードの割り当て可能な CPU 数を減らすためのハック
        kube-reserved: "memory=4000Mi"
EOF

E2E テストで利用している PodSpec を参考stress-ng コマンドでメモリ使用量を徐々に増やす Pod を作成します。E2E テストの場合、Node の割り当て可能なメモリの容量を見て動的に設定していますが、ローカル環境で再現する時には決めうちしました。

cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata: 
  generateName: alloc-
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  initContainers:
  - name: el-sidecar1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    args: ["stress", "--mem-alloc-size", "10Mi", "--mem-alloc-sleep", "1s", "--mem-total", "200Mi"]
    restartPolicy: Always
  - name: el-sidecar2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  - name: el-sidecar3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF

Pod が起動できたら kind の Node に exec してメモリの空き容量を確認します。

docker exec -it kind-worker bash
/# watch free -h
Every 2.0s: free -h                                 kind-worker: Wed May 29 02:48:09 2024

               total        used        free      shared  buff/cache   available
Mem:           5.1Gi       1.3Gi       176Mi        29Mi       4.0Gi       3.8Gi
Swap:          7.1Gi          0B       7.1Gi

ただ、この方法だと stress-ng コマンドで消費するメモリ使用量の調整が難しく、必ず Pod Eviction を発生させることができませんでした。

  • stress-ng コマンドを実行しても OOMKill が発生しない
  • stress-ng コマンドを実行する Pod が Node のメモリ逼迫が発生する前に OOMKill される
  • ローカルマシンの他のプロセスのメモリ使用量にも左右されるため安定しない

この方法だと上手くいかないので、安定して Pod Eviction を発生させる別の方法を探すことにしました。今度は kubelet の設定で Eviction の閾値を変えるという方法を取りました。Node のリソース逼迫は eviction-softeviction-hard の 2 つの設定で調整できます。eviction-softeviction-soft は追い出す Pod の停止処理に違いがあります。

  • eviction-soft : Pod の各コンテナに対して SIGTERM を送り、Pod の terminationGracePeriodSeconds か kubelet の eviction-max-pod-grace-period の設定のうち短い時間だけ待って、それでも停止しない場合に SIGKILL を送り強制的にプロセスを停止します。

  • eviction-hard : Pod の各コンテナに対して gracePeriodSeconds を 0 秒で上書きして強制的にコンテナを停止します。

eviction-soft はデフォルトで設定されておらず、eviction-hard のみ設定されています。メモリの空き容量の閾値はデフォルトで 100MiB となっています。このメモリの空き容量の閾値を高く設定します。

cat <<EOF | kind create cluster --config -
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
kubeadmConfigPatches:
- |
  kind: KubeletConfiguration
  evictionHard:
    memory.available: "1.5Gi"
nodes:
- role: control-plane
  image: kindest/node:v1.30.0@sha256:047357ac0cfea04663786a612ba1eaba9702bef25227a794b52890dd8bcd692e
- role: worker
  image: kindest/node:v1.30.0@sha256:047357ac0cfea04663786a612ba1eaba9702bef25227a794b52890dd8bcd692e
EOF

stress-ng コマンドでメモリ使用量を徐々に増やすコンテナを起動します。

cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata: 
  generateName: alloc-
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  initContainers:
  - name: el-sidecar1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    args: ["stress", "--mem-alloc-size", "100Mi", "--mem-alloc-sleep", "1s", "--mem-total", "4000Mi"]
    restartPolicy: Always
  - name: el-sidecar2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  - name: el-sidecar3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF

数十秒後に Pod Eviction が発生します。

Event の詳細
apiVersion: v1
count: 1
eventTime: null
firstTimestamp: "2024-05-29T05:35:26Z"
involvedObject:
  apiVersion: v1
  kind: Pod
  name: alloc-xndq5
  namespace: default
  resourceVersion: "501"
  uid: 17d1f6d1-b2a9-4226-bfc2-31137afd74d4
kind: Event
lastTimestamp: "2024-05-29T05:35:26Z"
message: 'The node was low on resource: memory. Threshold quantity: 1536Mi, available:
  1516448Ki. Container el1 was using 48Ki, request is 0, has larger consumption
  of memory. Container el2 was using 48Ki, request is 0, has larger consumption
  of memory. Container el3 was using 48Ki, request is 0, has larger consumption
  of memory. '
metadata:
  annotations:
    offending_containers: el1,el2,el3
    offending_containers_usage: 48Ki,48Ki,48Ki
    starved_resource: memory
  creationTimestamp: "2024-05-29T05:35:26Z"
  name: alloc-xndq5.17d3deddb8dd5016
  namespace: default
  resourceVersion: "601"
  uid: 65c745da-0cae-4c59-b37d-1e98baca2f26
reason: Evicted
reportingComponent: kubelet
reportingInstance: kind-worker
source:
  component: kubelet
  host: kind-worker
type: Warning
Pod の詳細
❯ kubectl describe pods alloc-xndq5
Name:             alloc-xndq5
Namespace:        default
Priority:         0
Service Account:  default
Node:             kind-worker/192.168.228.2
Start Time:       Wed, 29 May 2024 14:34:34 +0900
Labels:           <none>
Annotations:      <none>
Status:           Failed
Reason:           Evicted
Message:          The node was low on resource: memory. Threshold quantity: 1536Mi, available: 1516448Ki. Container el1 was using 48Ki, request is 0, has larger consumption of memory. Container el2 was using 48Ki, request is 0, has larger consumption of memory. Container el3 was using 48Ki, request is 0, has larger consumption of memory.
IP:               10.244.1.2
IPs:
  IP:  10.244.1.2
Init Containers:
  el-sidecar1:
    Container ID:
    Image:         registry.k8s.io/e2e-test-images/agnhost:2.52
    Image ID:
    Port:          <none>
    Host Port:     <none>
    Args:
      stress
      --mem-alloc-size
      100Mi
      --mem-alloc-sleep
      1s
      --mem-total
      4000Mi
    State:          Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was terminated
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Last State:     Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was deleted.  The container used to be Running
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Ready:          False
    Restart Count:  1
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cmk5f (ro)
  el-sidecar2:
    Container ID:  containerd://2fbc36327f16ffc4303d5e6ea31eca2fa1d7fe2dc2ba5147ac06092b7dc3fd50
    Image:         registry.k8s.io/e2e-test-images/agnhost:2.52
    Image ID:      registry.k8s.io/e2e-test-images/agnhost@sha256:b173c7d0ffe3d805d49f4dfe48375169b7b8d2e1feb81783efd61eb9d08042e6
    Port:          <none>
    Host Port:     <none>
    Command:
      sleep
      infinity
    State:          Terminated
      Reason:       Error
      Exit Code:    137
      Started:      Wed, 29 May 2024 14:34:45 +0900
      Finished:     Wed, 29 May 2024 14:35:29 +0900
    Ready:          False
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cmk5f (ro)
  el-sidecar3:
    Container ID:  containerd://7f605897f7eb10021accb5e879c9dc5249b37b596dc8c25d30835d8bc0f5269f
    Image:         registry.k8s.io/e2e-test-images/agnhost:2.52
    Image ID:      registry.k8s.io/e2e-test-images/agnhost@sha256:b173c7d0ffe3d805d49f4dfe48375169b7b8d2e1feb81783efd61eb9d08042e6
    Port:          <none>
    Host Port:     <none>
    Command:
      sleep
      infinity
    State:          Terminated
      Reason:       Error
      Exit Code:    137
      Started:      Wed, 29 May 2024 14:34:46 +0900
      Finished:     Wed, 29 May 2024 14:35:29 +0900
    Ready:          False
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cmk5f (ro)
Containers:
  el1:
    Container ID:
    Image:         registry.k8s.io/e2e-test-images/agnhost:2.52
    Image ID:
    Port:          <none>
    Host Port:     <none>
    Command:
      sleep
      infinity
    State:          Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was terminated
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Last State:     Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was deleted.  The container used to be Running
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Ready:          False
    Restart Count:  1
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cmk5f (ro)
  el2:
    Container ID:
    Image:         registry.k8s.io/e2e-test-images/agnhost:2.52
    Image ID:
    Port:          <none>
    Host Port:     <none>
    Command:
      sleep
      infinity
    State:          Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was terminated
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Last State:     Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was deleted.  The container used to be Running
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Ready:          False
    Restart Count:  1
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cmk5f (ro)
  el3:
    Container ID:
    Image:         registry.k8s.io/e2e-test-images/agnhost:2.52
    Image ID:
    Port:          <none>
    Host Port:     <none>
    Command:
      sleep
      infinity
    State:          Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was terminated
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Last State:     Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was deleted.  The container used to be Running
      Exit Code:    137
      Started:      Mon, 01 Jan 0001 00:00:00 +0000
      Finished:     Mon, 01 Jan 0001 00:00:00 +0000
    Ready:          False
    Restart Count:  1
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cmk5f (ro)
Conditions:
  Type                        Status
  DisruptionTarget            True
  PodReadyToStartContainers   False
  Initialized                 False
  Ready                       False
  ContainersReady             False
  PodScheduled                True
Volumes:
  kube-api-access-cmk5f:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   BestEffort
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type     Reason     Age   From               Message
  ----     ------     ----  ----               -------
  Normal   Scheduled  92s   default-scheduler  Successfully assigned default/alloc-xndq5 to kind-worker
  Normal   Pulling    92s   kubelet            Pulling image "registry.k8s.io/e2e-test-images/agnhost:2.52"
  Normal   Pulled     81s   kubelet            Successfully pulled image "registry.k8s.io/e2e-test-images/agnhost:2.52" in 10.213s (10.213s including waiting). Image size: 50208832 bytes.
  Normal   Created    81s   kubelet            Created container el-sidecar1
  Normal   Started    81s   kubelet            Started container el-sidecar1
  Normal   Pulled     81s   kubelet            Container image "registry.k8s.io/e2e-test-images/agnhost:2.52" already present on machine
  Normal   Created    81s   kubelet            Created container el-sidecar2
  Normal   Started    81s   kubelet            Started container el-sidecar2
  Normal   Pulled     80s   kubelet            Container image "registry.k8s.io/e2e-test-images/agnhost:2.52" already present on machine
  Normal   Created    80s   kubelet            Created container el-sidecar3
  Normal   Started    80s   kubelet            Started container el-sidecar3
  Normal   Pulled     79s   kubelet            Container image "registry.k8s.io/e2e-test-images/agnhost:2.52" already present on machine
  Normal   Created    79s   kubelet            Created container el1
  Normal   Started    79s   kubelet            Started container el1
  Normal   Pulled     79s   kubelet            Container image "registry.k8s.io/e2e-test-images/agnhost:2.52" already present on machine
  Normal   Created    79s   kubelet            Created container el2
  Normal   Started    79s   kubelet            Started container el2
  Normal   Pulled     79s   kubelet            Container image "registry.k8s.io/e2e-test-images/agnhost:2.52" already present on machine
  Normal   Created    78s   kubelet            Created container el3
  Normal   Started    78s   kubelet            Started container el3
  Warning  Evicted    40s   kubelet            The node was low on resource: memory. Threshold quantity: 1536Mi, available: 1516448Ki. Container el1 was using 48Ki, request is 0, has larger consumption of memory. Container el2 was using 48Ki, request is 0, has larger consumption of memory. Container el3 was using 48Ki, request is 0, has larger consumption of memory.
  Normal   Killing    40s   kubelet            Stopping container el-sidecar1
  Normal   Killing    40s   kubelet            Stopping container el3
  Normal   Killing    40s   kubelet            Stopping container el1
  Normal   Killing    40s   kubelet            Stopping container el2
  Normal   Killing    40s   kubelet            Stopping container el-sidecar2

Event のメッセージ

message: 'The node was low on resource: memory. Threshold quantity: 1536Mi, available:
  1516448Ki. Container el1 was using 48Ki, request is 0, has larger consumption
  of memory. Container el2 was using 48Ki, request is 0, has larger consumption
  of memory. Container el3 was using 48Ki, request is 0, has larger consumption
  of memory. '

Event の annotation

metadata:
  annotations:
    offending_containers: el1,el2,el3
    offending_containers_usage: 48Ki,48Ki,48Ki
    starved_resource: memory

上記から確かに Event のメッセージや annotation の情報の中に Restartable Init Containers の情報が含まれていないことが分かりました。

Summary API の挙動の実験

Pod Eviction を常に発生することができる手順を手に入れたので、実験を繰り返しながら Pod Eviction の挙動を確認します。知りたいのは以下の挙動です。

Restartable Init Containers のリソース使用量の情報は Summary API に含まれているか?

仮に Summary API に Restartable Init Containers のメトリクスが含まれているなら、コードの修正は少なくて済みます。

Restartable Init Containers を含んだ Pod を作成します。今回は stress-ng コマンドで負荷を掛ける必要がないのでコマンドを変えています。

cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata: 
  generateName: alloc-
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  initContainers:
  - name: el-sidecar1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  - name: el-sidecar2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  - name: el-sidecar3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF

Summary API を叩くと Restartable Init Containers のメトリクスも含まれていることが分かります。

kubectl get --raw "/api/v1/nodes/kind-worker/proxy/stats/summary"
Summary API の一部
{
 (...)
 "pods": [
   (...)
  {
   "podRef": {
    "name": "alloc-d4w7d",
    "namespace": "default",
    "uid": "a6e41259-1afc-4d08-8525-50f453c08f47"
   },
   "startTime": "2024-05-29T05:49:00Z",
   "containers": [
    {
     "name": "el3",
     "startTime": "2024-05-29T05:49:02Z",
     "cpu": {
      "time": "2024-05-29T05:49:13Z",
      "usageNanoCores": 54709,
      "usageCoreNanoSeconds": 41096000
     },
     "memory": {
      "time": "2024-05-29T05:49:13Z",
      "usageBytes": 49152,
      "workingSetBytes": 49152,
      "rssBytes": 49152,
      "pageFaults": 1581,
      "majorPageFaults": 0
     },
     "rootfs": {
      "time": "2024-05-29T05:49:18Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 4096,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 8
     },
     "logs": {
      "time": "2024-05-29T05:49:21Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 0,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 1
     },
     "swap": {
      "time": "2024-05-29T05:49:21Z",
      "swapAvailableBytes": 0,
      "swapUsageBytes": 0
     }
    },
    {
     "name": "el-sidecar1",
     "startTime": "2024-05-29T05:49:00Z",
     "cpu": {
      "time": "2024-05-29T05:49:14Z",
      "usageNanoCores": 570891,
      "usageCoreNanoSeconds": 43136000
     },
     "memory": {
      "time": "2024-05-29T05:49:14Z",
      "usageBytes": 2220032,
      "workingSetBytes": 1953792,
      "rssBytes": 49152,
      "pageFaults": 1584,
      "majorPageFaults": 7
     },
     "rootfs": {
      "time": "2024-05-29T05:49:18Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 4096,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 8
     },
     "logs": {
      "time": "2024-05-29T05:49:21Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 0,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 1
     },
     "swap": {
      "time": "2024-05-29T05:49:21Z",
      "swapAvailableBytes": 0,
      "swapUsageBytes": 0
     }
    },
    {
     "name": "el-sidecar2",
     "startTime": "2024-05-29T05:49:00Z",
     "cpu": {
      "time": "2024-05-29T05:49:20Z",
      "usageNanoCores": 17135,
      "usageCoreNanoSeconds": 25895000
     },
     "memory": {
      "time": "2024-05-29T05:49:20Z",
      "usageBytes": 49152,
      "workingSetBytes": 49152,
      "rssBytes": 49152,
      "pageFaults": 1692,
      "majorPageFaults": 0
     },
     "rootfs": {
      "time": "2024-05-29T05:49:18Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 4096,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 8
     },
     "logs": {
      "time": "2024-05-29T05:49:21Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 0,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 1
     },
     "swap": {
      "time": "2024-05-29T05:49:21Z",
      "swapAvailableBytes": 0,
      "swapUsageBytes": 0
     }
    },
    {
     "name": "el-sidecar3",
     "startTime": "2024-05-29T05:49:01Z",
     "cpu": {
      "time": "2024-05-29T05:49:15Z",
      "usageNanoCores": 27182,
      "usageCoreNanoSeconds": 28664000
     },
     "memory": {
      "time": "2024-05-29T05:49:15Z",
      "usageBytes": 45056,
      "workingSetBytes": 45056,
      "rssBytes": 45056,
      "pageFaults": 1571,
      "majorPageFaults": 0
     },
     "rootfs": {
      "time": "2024-05-29T05:49:18Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 4096,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 8
     },
     "logs": {
      "time": "2024-05-29T05:49:21Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 0,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 1
     },
     "swap": {
      "time": "2024-05-29T05:49:21Z",
      "swapAvailableBytes": 0,
      "swapUsageBytes": 0
     }
    },
    {
     "name": "el1",
     "startTime": "2024-05-29T05:49:02Z",
     "cpu": {
      "time": "2024-05-29T05:49:21Z",
      "usageNanoCores": 0,
      "usageCoreNanoSeconds": 22773000
     },
     "memory": {
      "time": "2024-05-29T05:49:21Z",
      "workingSetBytes": 49152
     },
     "rootfs": {
      "time": "2024-05-29T05:49:18Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 4096,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 8
     },
     "logs": {
      "time": "2024-05-29T05:49:21Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 0,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 1
     },
     "swap": {
      "time": "2024-05-29T05:49:21Z",
      "swapAvailableBytes": 0,
      "swapUsageBytes": 0
     }
    },
    {
     "name": "el2",
     "startTime": "2024-05-29T05:49:02Z",
     "cpu": {
      "time": "2024-05-29T05:49:18Z",
      "usageNanoCores": 24561,
      "usageCoreNanoSeconds": 22993000
     },
     "memory": {
      "time": "2024-05-29T05:49:18Z",
      "usageBytes": 49152,
      "workingSetBytes": 49152,
      "rssBytes": 49152,
      "pageFaults": 1685,
      "majorPageFaults": 0
     },
     "rootfs": {
      "time": "2024-05-29T05:49:18Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 4096,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 8
     },
     "logs": {
      "time": "2024-05-29T05:49:21Z",
      "availableBytes": 16804392960,
      "capacityBytes": 70218711040,
      "usedBytes": 0,
      "inodesFree": 0,
      "inodes": 0,
      "inodesUsed": 1
     },
     "swap": {
      "time": "2024-05-29T05:49:21Z",
      "swapAvailableBytes": 0,
      "swapUsageBytes": 0
     }
    }
   ],
   "cpu": {
    "time": "2024-05-29T05:49:13Z",
    "usageNanoCores": 14648961,
    "usageCoreNanoSeconds": 194774000
   },
   "memory": {
    "time": "2024-05-29T05:49:13Z",
    "usageBytes": 3014656,
    "workingSetBytes": 2732032,
    "rssBytes": 327680,
    "pageFaults": 10797,
    "majorPageFaults": 8
   },
   "network": {
    "time": "2024-05-29T05:49:19Z",
    "name": "eth0",
    "rxBytes": 0,
    "rxErrors": 0,
    "txBytes": 656,
    "txErrors": 0,
    "interfaces": [
     {
      "name": "tunl0",
      "rxBytes": 0,
      "rxErrors": 0,
      "txBytes": 0,
      "txErrors": 0
     },
     {
      "name": "eth0",
      "rxBytes": 0,
      "rxErrors": 0,
      "txBytes": 656,
      "txErrors": 0
     }
    ]
   },
   "volume": [
    {
     "time": "2024-05-29T05:49:16Z",
     "availableBytes": 3894149120,
     "capacityBytes": 3894161408,
     "usedBytes": 12288,
     "inodesFree": 671960,
     "inodes": 671969,
     "inodesUsed": 9,
     "name": "kube-api-access-7bf44"
    }
   ],
   "ephemeral-storage": {
    "time": "2024-05-29T05:49:21Z",
    "availableBytes": 16804392960,
    "capacityBytes": 70218711040,
    "usedBytes": 28672,
    "inodesFree": 0,
    "inodes": 0,
    "inodesUsed": 55
   },
   "process_stats": {
    "process_count": 0
   },
   "swap": {
    "time": "2024-05-29T05:49:13Z",
    "swapUsageBytes": 0
   }
  }
 ]
}

Summary API の実験結果からコードの修正箇所が想像できました。Summary API から取得した各コンテナのリソース使用量を Regular Containers だけでなく、InitContainers も含めて比較するように書き換えれば良さそうです。

	// 疑似コード
	for _, containerStats := range podStats.Containers {
		for _, container := range [pod.Spec.Containers, pod.Spec.InitContainers] {
			if container.Name == containerStats.Name {
     		(...)
			}
		}
	}

コードの修正

コードの修正方針が決まったので、対応していきます。今回は以下の流れでコードの修正と動作確認を行います。Kubernetes の組織に参加していて PR 上でテストを手動でトリガーできる場合は、そちらで確認した方が早いかもしれません。

  1. ローカル環境にフォークした Kubernetes のリポジトリを用意
  2. コードを修正
  3. kind でコードの修正を含んだ Node のイメージをセルフビルド
  4. 再現手順を元に手動で動作確認

Pod Eviction や Summary API の挙動の実験から Pod Eviction に Restartable Init Containers 専用の処理は不要だと分かりました。修正はシンプルですが、一つ気になる点があります。Issue には Eviction のメッセージに Restartable Init Containers が考慮されていないとしか書かれていないですが、どうやら Init Containers も考慮されていなさそうです。迷いましたが、最初は Restartable Init Containers のみを考慮する方針でコードを書きました。(ただ、後から Init Containers も含めて修正した方が良さそうと思い直しました。)

Restartable Init Containers だけを考慮するなら、Sidecar Containers の FeatureGates が有効な時にだけ InitContainers の中で定義された Restartable Init Containers を含めます。

	for _, containerStats := range podStats.Containers {
		containers := pod.Spec.Containers
		if utilfeature.DefaultFeatureGate.Enabled(features.SidecarContainers) {
			for _, initContainer := range pod.Spec.InitContainers {
				if initContainer.RestartPolicy != nil && *initContainer.RestartPolicy == v1.ContainerRestartPolicyAlways {
					containers = append(containers, initContainer)
				}
			}
		}
		for _, container := range containers {
			(...)

同様に annotation に関してもコンテナ名と使用量を考慮するようにコードを修正しました。

Kubernetes のソースコードに変更を加えたら、kind の Node のイメージを新しくビルドして動作確認します。

kindest/node:latest のタグでコンテナイメージをビルド

cd path/to/kubernetes
kind build node-image .

セルフビルドした Node のコンテナイメージを利用して kind でローカルクラスタを起動

cat <<EOF | kind create cluster --config -
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
kubeadmConfigPatches:
- |
  kind: KubeletConfiguration
  evictionHard:
    memory.available: "1.5Gi"
nodes:
- role: control-plane
  image: kindest/node:latest
- role: worker
  image: kindest/node:latest
EOF

Restartable Init Containers を起動して Pod の Eviction メッセージが期待通りに変化したか確認

cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata: 
  generateName: alloc-
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  initContainers:
  - name: el-sidecar1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    args: ["stress", "--mem-alloc-size", "100Mi", "--mem-alloc-sleep", "1s", "--mem-total", "4000Mi"]
    restartPolicy: Always
  - name: el-sidecar2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  - name: el-sidecar3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF
修正後の Event
apiVersion: v1
count: 1
eventTime: null
firstTimestamp: "2024-05-19T08:14:14Z"
involvedObject:
  apiVersion: v1
  kind: Pod
  name: alloc-lm9sq
  namespace: default
  resourceVersion: "19979"
  uid: 786af1f6-3b04-4385-b61f-acfca78ddfd6
kind: Event
lastTimestamp: "2024-05-19T08:14:14Z"
message: 'The node was low on resource: memory. Threshold quantity: 1536Mi, available:
  1208704Ki. Container el1 was using 44Ki, request is 0, has larger consumption
  of memory. Container el2 was using 48Ki, request is 0, has larger consumption
  of memory. Container el3 was using 48Ki, request is 0, has larger consumption
  of memory. Container el-sidecar1 was using 3818464Ki, request is 0, has larger
  consumption of memory. Container el-sidecar2 was using 1452Ki, request is 0, has
  larger consumption of memory. Container el-sidecar3 was using 48Ki, request is
  0, has larger consumption of memory. '
metadata:
  annotations:
    offending_containers: el1,el2,el3,el-sidecar1,el-sidecar2,el-sidecar3
    offending_containers_usage: 44Ki,48Ki,48Ki,3818464Ki,1452Ki,48Ki
    starved_resource: memory
  creationTimestamp: "2024-05-19T08:14:14Z"
  name: alloc-lm9sq.17d0d5ba642ef84c
  namespace: default
  resourceVersion: "20072"
  uid: 521c02b7-4935-4def-be2c-53a76c32f4f7
reason: Evicted
reportingComponent: kubelet
reportingInstance: kind-worker
source:
  component: kubelet
  host: kind-worker
type: Warning

期待通りの挙動に修正できていることが確認できました。

テストの追加

Kubernetes に機能を追加したり、バグ修正する場合はテストを書く必要があります。テストを書いていない変更はレビュー時に必ず指摘されます。Kubernetes では複数の種類のテストが存在します。

  • ユニットテスト
  • 結合テスト
  • E2E テスト
  • Conformance テスト
  • ファジングテスト
  • パフォーマンステスト
  • ソークテスト

変更箇所にもよるのですが、機能追加やバグ修正を行う場合、ユニットテスト、結合テスト、E2E テストを書く可能性があります。全ての挙動をテストでカバーするのが理想ですが、現実はそうではありません。ユニットテスト < 結合テスト < E2E テスト の順番でコストが高くなるので、テストケースもカバー率も減る傾向にあります。ユニットテストはほぼ必須ですが、それ以外は変更の大きさやテストのし易さによって変わってきます。

今回の変更はユニットテストだけを書くことにしました。最初は E2E テストを書く or 修正するつもりでいました。ただ、(Restartable) Init Containers 向けの Pod Eviction の E2E テストが存在せず、Pod Eviction の E2E テストは Serial (他のテストと並列実行できない)、Disruptive (Kubernetes クラスタの機能に影響する設定を注入したり、挙動を調べる)、Slow (時間の掛かるテスト?) とマークされていてコストの高いテストになります。Pod Eviction に関連する処理に変更は加えていないので、このバグ修正のために新たに E2E テストを追加するのはコスト対効果が見合わないと判断しました。

Kubernetes のテストでは Pod や Node などのオブジェクトをテストの入力や期待する結果として指定することが多いです。そのため、ユニットテストはテーブル駆動テストとして書くことが多いです。テーブル駆動テストで Pod の構造体を全て書くとテストケースが見辛くなります。Tim Hockin も k/k#116260 でテーブル駆動テストのリファクタリングを提案しているくらいです。

以下のように書くのではなく、

	restartPolicyAlways := v1.ContainerRestartPolicyAlways
	testcase := []struct {
		name                     string
		initContainers           []v1.Container
		containers               []v1.Container
		containerMemoryStats     []containerMemoryStat
		expectedContainerMessage string
		expectedAnnotations      map[string]string
	}{
		{
			name: "No container exceeds memory usage",
			initContainers: []v1.Container{
				{
					Name: "testcontainer-init",
					Resources: v1.ResourceRequirements{
						Requests: v1.ResourceList{
							v1.ResourceMemory: resource.MustParse("300Mi"),
						},
					},
				},
				{
					Name: "testcontainer-sidecar",
					Resources: v1.ResourceRequirements{
						Requests: v1.ResourceList{
							v1.ResourceMemory: resource.MustParse("200Mi"),
						},
					},
					RestartPolicy: &restartPolicyAlways,
				},
			},
			containers: []v1.Container{
				{
					Name: "testcontainer",
					Resources: v1.ResourceRequirements{
						Requests: v1.ResourceList{
							v1.ResourceMemory: resource.MustParse("200Mi"),
						},
					},
				},
			},
			containerMemoryStats: []containerMemoryStat{
				{name: "testcontainer-sidecar", usage: "150Mi"},
				{name: "testcontainer", usage: "100Mi"},
			},
			expectedContainerMessage: memoryExceededEvictionMessage(nil),
		}
	}

以下のように書けるようにしました。

	testcase := []struct {
		name                     string
		initContainers           []v1.Container
		containers               []v1.Container
		containerMemoryStats     []containerMemoryStat
		expectedContainerMessage string
		expectedAnnotations      map[string]string
	}{
		{
			name: "No container exceeds memory usage",
			initContainers: []v1.Container{
				newContainer("testcontainer-init", newResourceList("", "300Mi", ""), newResourceList("", "", "")),
				newRestartableInitContainer("testcontainer-sidecar", newResourceList("", "200Mi", ""), newResourceList("", "", "")),
			},
			containers: []v1.Container{
				newContainer("testcontainer", newResourceList("", "200Mi", ""), newResourceList("", "", "")),
			},
			containerMemoryStats: []containerMemoryStat{
				{name: "testcontainer-sidecar", usage: "150Mi"},
				{name: "testcontainer", usage: "100Mi"},
			},
			expectedContainerMessage: memoryExceededEvictionMessage(nil),
		}
	}

テストにはヘルパー関数が用意されています。自分の書きたいテストに合わせて新しいヘルパー関数を追加したり、既存のヘルパー関数を拡張するのが良いです。今回のテストでは Restartable Init Containers を作成するヘルパー関数が欲しかったので追加しました。既存の newContainer をラップした単純な関数です。

func newRestartableInitContainer(name string, requests v1.ResourceList, limits v1.ResourceList) v1.Container {
	restartAlways := v1.ContainerRestartPolicyAlways
	container := newContainer(name, requests, limits)
	container.RestartPolicy = &restartAlways
	return container
}

ユニットテストを実行します。今回追加した TestEvictionMessageWithInitContainers に対して実行します。

❯ go test -timeout 30s -count=1 -race -run ^TestEvictionMessageWithInitContainers$ k8s.io/kubernetes/pkg/kubelet/eviction -v
=== RUN   TestEvictionMessageWithInitContainers
=== RUN   TestEvictionMessageWithInitContainers/No_container_exceeds_memory_usage
=== RUN   TestEvictionMessageWithInitContainers/Init_container_exceeds_memory_usage
=== RUN   TestEvictionMessageWithInitContainers/Restartable_init_container_exceeds_memory_usage
=== RUN   TestEvictionMessageWithInitContainers/Regular_container_exceeds_memory_usage
=== RUN   TestEvictionMessageWithInitContainers/Regular_and_init_containers_exceed_memory_usage_due_to_missing_memory_requests
=== RUN   TestEvictionMessageWithInitContainers/Regular_and_restartable_init_containers_exceed_memory_usage_due_to_missing_memory_requests
=== RUN   TestEvictionMessageWithInitContainers/All_types_of_containers_exceed_memory_usage_due_to_missing_memory_requests
=== RUN   TestEvictionMessageWithInitContainers/Multiple_regular_and_(non-)restartable_init_containers_exceed_memory_usage
--- PASS: TestEvictionMessageWithInitContainers (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/No_container_exceeds_memory_usage (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/Init_container_exceeds_memory_usage (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/Restartable_init_container_exceeds_memory_usage (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/Regular_container_exceeds_memory_usage (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/Regular_and_init_containers_exceed_memory_usage_due_to_missing_memory_requests (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/Regular_and_restartable_init_containers_exceed_memory_usage_due_to_missing_memory_requests (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/All_types_of_containers_exceed_memory_usage_due_to_missing_memory_requests (0.00s)
    --- PASS: TestEvictionMessageWithInitContainers/Multiple_regular_and_(non-)restartable_init_containers_exceed_memory_usage (0.00s)
PASS
ok  	k8s.io/kubernetes/pkg/kubelet/eviction	1.865s

一部のユニットテストは連続で実行すると失敗する Flaky な要素を持っている可能性があります。テストを並列で複数回実行して Flaky なテストを検知するために、stress を利用します。

go install golang.org/x/tools/cmd/stress@latest

stress を利用しながらユニットテストを実行します。

go test k8s.io/kubernetes/pkg/kubelet/eviction -race -c
stress ./eviction.test -test.run TestEvictionMessageWithInitContainers
実行例
❯ stress ./eviction.test -test.run TestEvictionMessageWithInitContainers
5s: 24 runs so far, 0 failures
10s: 56 runs so far, 0 failures
15s: 96 runs so far, 0 failures
20s: 128 runs so far, 0 failures
25s: 160 runs so far, 0 failures
30s: 200 runs so far, 0 failures
35s: 232 runs so far, 0 failures
40s: 264 runs so far, 0 failures
45s: 304 runs so far, 0 failures
50s: 336 runs so far, 0 failures
55s: 376 runs so far, 0 failures
1m0s: 408 runs so far, 0 failures
(...)

レビュー対応

PR を作成した際に Sidecar Containers の関係者に自ら /cc してレビューを依頼していたため、最初のレビューは比較的すぐに受けられました。

/cc @SergeyKanzhelev @matthyx @gjkim42

まず、Sidecar Containers の実装者である Gunju さんから指摘を受けました。Pod Eviction で Restartable Init Containers 向けにロジックを変更したつもりがないので、Pod Eviction 時のメッセージに Restartable Init Containers を考慮しても良いのか分からないというものでした。この指摘に関しては、既にローカル環境で挙動を確認していたので、エビデンスとして再現手順を共有することでパスできました。

次に、Node E2E テストを追加すべきではないかと指摘を受けました。こちらに関しても Pod Eviction 関連の Node E2E テストが重いこと、Init Containers 向けに Pod Eviction のロジックに変更がないこと、追加すべきか迷ったけど追加しなかったことを正直に回答しました。この辺りの判断も PR の概要に記載しておく方が親切だったなと反省しました。

どちらの指摘も Node E2E テストを追加していれば問題にならなかったはずです。テストが書けるならさまざまなケースを想定してテストを書く方がレビューもすんなり進みます。レビュワーの時間を無駄に奪うこともなくなり Win-Win です。

Init Containers の考慮

Init Containers で Node のリソース逼迫が起きるケースがどの程度あるのか分かりませんが、Eviction メッセージで Restartable Init Containers のみを考慮するのはやはりおかしいという気持ちになりました。実際に Init Container で Pod Eviction を発生させてみました。

cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata: 
  generateName: alloc-
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  initContainers:
  - name: el-init1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    args: ["stress", "--mem-alloc-size", "100Mi", "--mem-alloc-sleep", "1s", "--mem-total", "4000Mi"]
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF

Init Containers でも Pod Eviction は発生しましたが、当然 Event のメッセージや annotation に Init Containers の情報はありません。

apiVersion: v1
count: 1
eventTime: null
firstTimestamp: "2024-05-29T09:20:56Z"
involvedObject:
  apiVersion: v1
  kind: Pod
  name: alloc-v972z
  namespace: default
  resourceVersion: "573"
  uid: a7e67c6e-4838-4ce8-a656-c31f9fb42831
kind: Event
lastTimestamp: "2024-05-29T09:20:56Z"
message: 'The node was low on resource: memory. Threshold quantity: 1536Mi, available:
  1453492Ki. '
metadata:
  annotations:
    offending_containers: ""
    offending_containers_usage: ""
    starved_resource: memory
  creationTimestamp: "2024-05-29T09:20:56Z"
  name: alloc-v972z.17d3eb2bd8f92aca
  namespace: default
  resourceVersion: "655"
  uid: b3aa8512-9383-4f8c-9794-89a77f1d6602
reason: Evicted
reportingComponent: kubelet
reportingInstance: kind-worker
source:
  component: kubelet
  host: kind-worker
type: Warning

そのため、Init Containers が空でない時に Regular Containers と Init Containers の全てのコンテナと Summary API の情報を突き合わせてEviction メッセージを生成する方法に変更しました。

	containers := pod.Spec.Containers
	if len(pod.Spec.InitContainers) != 0 {
		containers = append(containers, pod.Spec.InitContainers...)
	}
	for _, containerStats := range podStats.Containers {
		for _, container := range containers {
			(...)
		}
	}
修正後の Pod Eviction の Event
apiVersion: v1
count: 1
eventTime: null
firstTimestamp: "2024-05-29T09:20:56Z"
involvedObject:
  apiVersion: v1
  kind: Pod
  name: alloc-v972z
  namespace: default
  resourceVersion: "573"
  uid: a7e67c6e-4838-4ce8-a656-c31f9fb42831
kind: Event
lastTimestamp: "2024-05-29T09:20:56Z"
message: 'The node was low on resource: memory. Threshold quantity: 1536Mi, available:
  1453492Ki. '
metadata:
  annotations:
    offending_containers: ""
    offending_containers_usage: ""
    starved_resource: memory
  creationTimestamp: "2024-05-29T09:20:56Z"
  name: alloc-v972z.17d3eb2bd8f92aca
  namespace: default
  resourceVersion: "655"
  uid: b3aa8512-9383-4f8c-9794-89a77f1d6602
reason: Evicted
reportingComponent: kubelet
reportingInstance: kind-worker
source:
  component: kubelet
  host: kind-worker
type: Warning

k/k#124947 (comment) で PR のスコープを Restartable Init Containers から Init Containers に拡大しても良いか確認しつつ、コミットメッセージや PR タイトルと概要も Init Containers を考慮する形に書き換えました。

Ephemeral Containers の考慮

その後しばらく間が空いて、Sergey さんから Ephemeral Containers を考慮しなくて良いのかという指摘を受けました。また、Sergey さんから自己解決的に Ephemeral Containers が Pod のリソース割り当てを指定できないから考慮しなくても良いとコメントも貰っていました。Ephemeral Containers のことは完全に頭になかったので、リソース割り当てを指定できるかを確認するところから始めました。

Ephemeral Containers には既存の Pod の中にデバッグ用のコンテナを作る機能と、Pod を複製してデバッグ用のコンテナを差し込む機能があります。どちらの場合も、差し込む Ephemeral Containers にリソース割り当てが設定できるかが鍵になります。実際に kind のローカルクラスタで挙動を確認してみました。

kind での動作確認
❯ cat <<EOF | kind create cluster --config -
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  image: kindest/node:v1.31.0@sha256:53df588e04085fd41ae12de0c3fe4c72f7013bba32a20e7325357a1ac94ba865
- role: worker
  image: kindest/node:v1.31.0@sha256:53df588e04085fd41ae12de0c3fe4c72f7013bba32a20e7325357a1ac94ba865
EOF
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.31.0) 🖼
 ✓ Preparing nodes 📦 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌
 ✓ Installing StorageClass 💾
 ✓ Joining worker nodes 🚜
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Have a nice day! 👋

❯ kubectl version
Client Version: v1.31.1
Kustomize Version: v5.4.2
Server Version: v1.31.0

❯ cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata:
  name: alloc
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF
pod/alloc created

❯ cat <<EOF > custom.json
{
  "resources": {
    "requests": {
      "cpu": "0.2",
      "memory": "256Mi"
    }
  }
}
EOF

❯ kubectl debug alloc -it --custom custom.json --image=registry.k8s.io/e2e-test-images/agnhost:2.52 --target=el1 -- sh
Targeting container "el1". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
Defaulting debug container name to debugger-ch2t6.
The Pod "alloc" is invalid: spec.ephemeralContainers[0].resources: Forbidden: cannot be set for an Ephemeral Container

Ephemeral Containers でリソース割り当てを指定すると API サーバのバリデーションエラーとなることが分かります。実際にローカル環境にクローンした Kubernetes のソースコードの中で cannot be set for an Ephemeral Container で検索すると、該当のコード箇所に辿り着きました。allowedEphemeralContainerField の中に Resources の指定が許可されていないことが分かります。

Ephemeral Containers でリソース割り当てが指定できないということは、実際の使用量とリソース割り当てを比較する際にリソース割り当てが 0 として扱われます。そのため、Pod Eviction が発生した際に Ephemeral Containers が必ず違反者として報告されることになります。この挙動は紛らわしいため、Ephemeral Containers は考慮しない方が良いです。

考慮する必要がないことが分かったので、Sergey さんから指摘を受けていたコードコメントを追加して、調べた結果も同様に共有しました。また、その後で Ephemeral Containers を指定した Pod のテストケースも追加して欲しいと言われたので、そちらも対応しました。

停止済みの Init Containers の扱い

最後に Sergey さんから停止時の Init Containers も Pod Eviction 時のメッセージに含まれるのかという質問を受けました。stats() で取得した Pod のメトリクスの中に停止済みの Init Containers が含まれるかという問いと同義です。実際にローカル環境で挙動を確認してみました。

まず、コードを一部修正してデバッグログを書き出すようにしました。

diff --git a/pkg/kubelet/eviction/helpers.go b/pkg/kubelet/eviction/helpers.go
index 4e684a5b902..e6ed91cbabe 100644
--- a/pkg/kubelet/eviction/helpers.go
+++ b/pkg/kubelet/eviction/helpers.go
@@ -1249,8 +1249,10 @@ func evictionMessage(resourceToReclaim v1.ResourceName, pod *v1.Pod, stats stats
                containers = append(containers, pod.Spec.InitContainers...)
        }
        for _, containerStats := range podStats.Containers {
+               klog.InfoS("Eviction manager: check if the container's resource usage exceeds the resource requests", "container", containerStats.Name)
                for _, container := range containers {
                        if container.Name == containerStats.Name {
                                requests := container.Resources.Requests[resourceToReclaim]
                                if utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) &&
                                        (resourceToReclaim == v1.ResourceMemory || resourceToReclaim == v1.ResourceCPU) {

kind のコンテナイメージをセルフビルドして kind のローカルクラスタを起動します。その後で、停止した Init Containers を含む Pod が Eviction される状況を再現します。

cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: Pod
metadata: 
  generateName: alloc-
spec:
  terminationGracePeriodSeconds: 1
  restartPolicy: Never
  initContainers:
  - name: init1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "10"]
  - name: el-sidecar1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    args: ["stress", "--mem-alloc-size", "100Mi", "--mem-alloc-sleep", "1s", "--mem-total", "4000Mi"]
    restartPolicy: Always
  - name: el-sidecar2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  - name: el-sidecar3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
    restartPolicy: Always
  containers:
  - name: el1
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el2
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
  - name: el3
    image: registry.k8s.io/e2e-test-images/agnhost:2.52
    command: ["sleep", "infinity"]
EOF

kubelet のログを確認します。

kind export logs

kubelet のログの中からデバッグ用のメッセージを確認します。

Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.162521     215 eviction_manager.go:369] "Eviction manager: attempting to reclaim" resourceName="memory"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.162993     215 eviction_manager.go:380] "Eviction manager: must evict pod(s) to reclaim" resourceName="memory"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.163022     215 eviction_manager.go:398] "Eviction manager: pods ranked for eviction" pods=["default/alloc-wpt9g","kube-system/kube-proxy-mhj88","kube-system/kindnet-mvnq8"]
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.168883     215 helpers.go:1252] "Eviction manager: check if the container's resource usage exceeds the resource requests" container="el1"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.169204     215 helpers.go:1252] "Eviction manager: check if the container's resource usage exceeds the resource requests" container="el3"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.169221     215 helpers.go:1252] "Eviction manager: check if the container's resource usage exceeds the resource requests" container="el-sidecar1"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.169241     215 helpers.go:1252] "Eviction manager: check if the container's resource usage exceeds the resource requests" container="el-sidecar2"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.169257     215 helpers.go:1252] "Eviction manager: check if the container's resource usage exceeds the resource requests" container="el-sidecar3"
Sep 15 00:07:37 kind-worker kubelet[215]: I0915 00:07:37.169270     215 helpers.go:1252] "Eviction manager: check if the container's resource usage exceeds the resource requests" container="el2"

上記のログから停止済みの Init Containers は Pod Eviction で考慮されないことが分かりました。調べたことを Sergey さんに共有すると、PR を approve してくれました。そして、無事に PR がマージされました。

さいごに

大規模な OSS プロジェクトはどこも同じだと思いますが、Kubernetes も慢性的なレビュワー不足に陥っています。コントリビューターや新規の方が作成した大量の PR を数少ないメンテナがレビューする構図のためです。どうしてもレビューがボトルネックとなってしまいます。自分の場合も PR を作成してからマージされるまでに 4 ヶ月ほど掛かりました。その間、指をくわえて待つのではなく、SIG-Node 関連の PR のレビューをお手伝いしていました。コードの体裁など表面的な部分のレビューというよりは、実装に踏み込んだレビューを行うよう心掛けました。自分のレビューの後ですんなり PR がマージされると、役に立てたかなという気持ちになります。この活動のおかげで自分の PR のレビュがー早くなったかは謎ですが、楽しいし勉強にもなるので今も続けています。

この記事が、Kubernetesのコミュニティに貢献したいけれど、何から始めればいいか分からない方の助けになれば嬉しいです。

Discussion