Kubernetes 1.27 以降のバッチ処理の改善

2023/06/08に公開

Kubernetes 1.27 以降で実装済みまたは予定されているバッチ処理の改善に繋がる KEP や Kubernetes のサブプロジェクトの現状を見ていきます。

KEP-3673: Kubelet limit of Parallel Image Pulls

Pod の起動にノードのスケールアウトが必要な場合に、Pod の起動時間の短縮が期待できます。バッチ処理の Pod が一斉に起動するケースで恩恵を受けられそうです。

Kubelet はデフォルトでコンテナイメージを直列にダウンロードします。Cluster Autoscaler は、Pending な Pod を検知してノードを起動します。この場合、新しいノード上に複数の Pod が起動することになります。例えば、DaemonSet の Pod や Pending 状態な Pod が複数ある可能性もあります。Pod を起動するために Kubelet がコンテナイメージを直列でダウンロードすると、重いコンテナイメージのダウンロード時間に引きづられて他の Pod の起動が遅れます。

Kubelet にはコンテナイメージを並列でダウンロードするオプションが存在しました。しかし、これまで並列数を制限できなかったため、大量の Pod が並列でコンテナイメージをダウンロードし、ネットワーク帯域が占領されたり、ディスクのスロットリングが発生するなど不安定になる事象が確認されていました。

Kubernetes 1.27 からアルファとして入った KEP-3673 は Kubelet のコンテナイメージのダウンロードの並列数を制限する機能です。Kubelet の設定ファイル内で maxParallelImagePulls のオプションで指定できます。maxParallelImagePulls が 1 より大きい場合に自動的に serializeImagePulls が無効になります。(source) アルファ機能ですが、GKE では 1.27 から有効化されています。(comment - kubernetes/enhancements#3673) 並列数を適切に扱えば大きな影響がないこと、etcd などにデータを保存する訳ではないので切り戻しが楽なことが考えられます。実装者が Google の方なのもあるかもしれません。

GKE v1.27.1-gke.400 で確認してみます。

❯ kubectl get nodes -owide
NAME                                                  STATUS   ROLES    AGE     VERSION           INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                             KERNEL-VERSION   CONTAINER-RUNTIME
gke-ngsw-development-nap-e2-medium-r4-2736940b-vgdf   Ready    <none>   4m27s   v1.27.1-gke.400   10.0.0.5      <none>        Container-Optimized OS from Google   5.15.90+         containerd://1.7.0

適当なエフェメラルコンテナをノード上に起動します。

kubectl debug node/gke-ngsw-development-nap-e2-medium-r4-2736940b-vgdf -it --image=alpine -- ash

プロセス一覧から Kubelet を探して引数から設定ファイルの場所を確認します。

/ # ps aux | grep bin/kubelet
 1797 root      0:20 /home/kubernetes/bin/kubelet --v=2 --cloud-provider=external --experimental-mounter-path=/home/kubernetes/containerized_mounter/mounter --cert-dir=/var/lib/kubelet/pki/ --kubeconfig=/var/lib/kubelet/kubeconfig --max-pods=32 --volume-plugin-dir=/home/kubernetes/flexvolume --node-status-max-images=25 --container-runtime-endpoint=unix:///run/containerd/containerd.sock --runtime-cgroups=/system.slice/containerd.service --registry-qps=10 --registry-burst=20 --config /home/kubernetes/kubelet-config.yaml --pod-sysctls=net.core.somaxconn=1024,net.ipv4.conf.all.accept_redirects=0,net.ipv4.conf.all.forwarding=1,net.ipv4.conf.all.route_localnet=1,net.ipv4.conf.default.forwarding=1,net.ipv4.ip_forward=1,net.ipv4.tcp_fin_timeout=60,net.ipv4.tcp_keepalive_intvl=60,net.ipv4.tcp_keepalive_probes=5,net.ipv4.tcp_keepalive_time=300,net.ipv4.tcp_rmem=4096 87380 6291456,net.ipv4.tcp_syn_retries=6,net.ipv4.tcp_tw_reuse=0,net.ipv4.tcp_wmem=4096 16384 4194304,net.ipv4.udp_rmem_min=4096,net.ipv4.udp_wmem_min=4096,net.ipv6.conf.all.disable_ipv6=1,net.ipv6.conf.default.accept_ra=0,net.ipv6.conf.default.disable_ipv6=1,net.netfilter.nf_conntrack_generic_timeout=600,net.netfilter.nf_conntrack_tcp_be_liberal=1,net.netfilter.nf_conntrack_tcp_timeout_close_wait=3600,net.netfilter.nf_conntrack_tcp_timeout_established=86400 --cgroup-driver=systemd --pod-infra-container-image=gke.gcr.io/pause:3.8@sha256:880e63f94b145e46f1b1082bb71b85e21f16b99b180b9996407d61240ceb9830

Kubelet の引数は徐々に非推奨化が進んでいる (comment - k/k#115220) ので、引数として設定できないようです。デバッグコンテナの /host 配下にホストマシンの root ディレクトリがマウントされているので、確認します。serializeImagePulls が無効化され、maxParallelImagePulls が 2 に設定されていることが分かりました。

/ # cat /host/home/kubernetes/kubelet-config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
authentication:
  anonymous:
    enabled: false
  webhook:
    enabled: true
  x509:
    clientCAFile: /etc/srv/kubernetes/pki/ca-certificates.crt
authorization:
  mode: Webhook
cgroupRoot: /
clusterDNS:
- 10.0.8.10
clusterDomain: cluster.local
enableDebuggingHandlers: true
evictionHard:
  memory.available: 100Mi
  nodefs.available: 10%
  nodefs.inodesFree: 5%
  pid.available: 10%
featureGates:
  CSIMigrationGCE: true
  DisableKubeletCloudCredentialProviders: false
  ExecProbeTimeout: false
  InTreePluginAWSUnregister: true
  InTreePluginAzureDiskUnregister: true
  InTreePluginvSphereUnregister: true
  RotateKubeletServerCertificate: true
kernelMemcgNotification: true
kind: KubeletConfiguration
kubeReserved:
  cpu: 1060m
  ephemeral-storage: 41Gi
  memory: 1019Mi
maxParallelImagePulls: 2
readOnlyPort: 10255
serializeImagePulls: false
serverTLSBootstrap: true
staticPodPath: /etc/kubernetes/manifests

アルファ機能なのもあってまだ小さい値を設定しているようですが、機能が安定化していくにつれて値も最適化されていくはずです。Google 内部のテストで値を大きくすると I/O スロットリングを観測しているよう (comment - kubernetes/enhancements#3673) なので、インスタンスサイズやノードあたりの Pod 数で調整が入るかもしれないですね。

KEP-1040: Priority and Fairness for API Server Requests

Kubernetes 1.27 から Kubelet から API リクエストのレート制限のデフォルト値が緩和されました。バッチ処理の Pod が一斉に起動するケースで Pod の起動時間が早くなることが期待できます。

Pod の起動には多くのリソース (e.g. ServiceAccount, ConfigMap, Secret, Persistent Volume) が関わります。以前のデフォルト値だと Kubelet が必要なリソースの準備で API サーバに問い合わせる際にリクエストのスロットリングが発生していました。これは、API サーバが必要以上のリクエストを受けて CPU やメモリに想定以上の負荷が掛かることを避けるためです。しかし、API Priority and Faireness (APF) の安定化が進んだことで、過負荷する状況が改善できてきました。

KEP-1287: In-place Update of Pod Resources

バッチ処理の Pod のリソース割り当てを再起動なしに変更できるようになります。常にリソースを消費する訳ではないワークロードで恩恵を受けられそうです。

In-place Update of Pod Resources は Kubernetes 1.27 でアルファの機能です。Pod の resources で設定できる requests/limits が可変な値に変更され、resizePolicy で Pod の再起動が可能かどうか指定ができます。

Kind で InPlacePodVerticalScaling の Feature Gate を有効化したクラスタを起動します。

# Kind で InPlacePodVerticalScaling の Feature Gate を有効化したクラスタを起動
cat <<EOF > kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
featureGates:
  InPlacePodVerticalScaling: true
EOF

# Kind v0.19.0 のデフォルトイメージが Kubernetes 1.27.1
# https://github.com/kubernetes-sigs/kind/releases/tag/v0.19.0
kind create cluster --config kind-config.yaml

QOS=Guranteed な Pod を作成します。

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginx
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired
    - resourceName: memory
      restartPolicy: RestartContainer
    resources:
      limits:
        memory: "100Mi"
        cpu: "100m"
      requests:
        memory: "100Mi"
        cpu: "100m"
EOF

CPU の割り当てをそれぞれ変更します。Pod 作成時の QoS クラスから変更出来ないので、cpu.requests/cpu.limits の値を同じにする (Guranteed) 必要があります。

kubectl patch pod nginx --patch '{"spec":{"containers":[{"name":"nginx", "resources":{"requests":{"cpu":"200m"}, "limits":{"cpu":"200m"}}}]}}'

CPU 割り当ては resizePolicy.restartPolicy で再起動が不要と設定したので、Pod が再起動していません。

❯ kubectl get pods
NAME    READY   STATUS    RESTARTS     AGE
nginx   1/1     Running   0            74m

しばらくすると、Pod のステータスが更新されて指定したリソース割り当てに変更されていることが分かります。ステータスの更新に時間が掛かるのは実装者も把握済みで、k/k#112264 の Issue にあるように今後修正予定です。

❯ kubectl get pods nginx -ojsonpath='{.status.containerStatuses[].allocatedResources}'
{"cpu":"200m","memory":"100Mi"}

❯ kubectl get pods nginx -ojsonpath='{.status.containerStatuses[].resources}'
{"limits":{"cpu":"200m","memory":"100Mi"},"requests":{"cpu":"200m","memory":"100Mi"}}

Kind の Docker コンテナ内のシェルを取得します。

docker exec -it kind-control-plane bash

Nginx のプロセス ID を特定します。

CONTAINER_ID=$(crictl ps --name nginx -o json | jq -r .containers[].id)
CONTAINER_PID=$(crictl inspect $CONTAINER_ID | jq .info.pid)

Nginx の Pod の cgroup の設定ファイルの場所を特定します。

/# cat /proc/${CONTAINER_PID}/cgroup
0::/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-podfa9bdbd8_3962_41ab_aca3_2b9fa64ec815.slice/cri-containerd-794b7aafed255d574ec19b1b8693697f25cafe8dbada08b06093b7960164a775.scope

cgroup の設定ファイルの場所から CPU の limits の設定が 200m の 100 倍の 20000 に変更されていることが分かります。

/# cat /sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-podfa9bdbd8_3962_41ab_aca3_2b9fa64ec815.slice/cri-containerd-794b7aafed255d574ec19b1b8693697f25cafe8dbada08b06093b7960164a775.scope/cpu.max
20000 100000

メモリの割り当てをそれぞれ変更します。Pod 作成時の QoS クラスから変更出来ないので、memory.requests/memory.limits の値を同じにする (Gurantee) 必要があります。

kubectl patch pod nginx --patch '{"spec":{"containers":[{"name":"nginx", "resources":{"requests":{"memory":"200Mi"}, "limits":{"memory":"200Mi"}}}]}}'

メモリ割り当ては resizePolicy.restartPolicy で再起動が必要と設定したので、Pod が再起動しています。

❯ kubectl get pods
NAME    READY   STATUS    RESTARTS     AGE
nginx   1/1     Running   1 (5s ago)   74m

Kind の Docker コンテナ内のシェルを取得します。

docker exec -it kind-control-plane bash

Nginx のプロセス ID を特定します。

CONTAINER_ID=$(crictl ps --name nginx -o json | jq -r .containers[].id)
CONTAINER_PID=$(crictl inspect $CONTAINER_ID | jq .info.pid)

Nginx の Pod の cgroup の設定ファイルの場所を特定します。

/# cat /proc/${CONTAINER_PID}/cgroup
0::/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-podfa9bdbd8_3962_41ab_aca3_2b9fa64ec815.slice/cri-containerd-1851a7753e56b6927444329ed5ddb3516686031996d7ffcf8cf68bb11d9d20c9.scop

cgroup の設定ファイルの場所からメモリ上限の設定が 200 MiB に変わっていることが分かります。

/# cat /sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-podfa9bdbd8_3962_41ab_aca3_2b9fa64ec815.slice/cri-containerd-1851a7753e56b6927444329ed5ddb3516686031996d7ffcf8cf68bb11d9d20c9.scope/memory.max
209715200

一般的に Vertical Pod Autoscaler (VPA) と連携して使用する前提ですが、VPA はまだ対応していません。AEP for support of in-place updates for VPA で仕様を決めているところです。

  • 既存の VPA の仕組みだと、UpdateMode が Initial / Recreate (Auto) の場合、Recommender が推奨する Pod のリソース割り当てと現在の値が異なると Updater が Pod を Evict し、Mutating Admission Controller がリソース割り当てを書き換えます。
  • 新しく UpdateMode に追加予定の InPlaceOnlyInPlaceOrRecreate モードでは、Updater が in-place で Pod のリソース割り当てを変更するようになる予定です。
  • Pod が無停止で更新される
    • リソース割り当ての変更だけで、resizePolicy.restartPolicy にも再起動が不要と指定されている
    • Pod が起動してから 12 時間未満 (デフォルト値で —in-recommendation-bounds-eviction-lifetime-threshold の値による) で、Recommender の推奨値とリソース割り当ての差分が 10% 以上 (デフォルト値で —pod-update-threshold の値による) ある
    • 他の変更で再起動が必要な場合も、部分的な更新だけやる
  • Pod が再起動を伴って更新される
    • Updater は再起動を伴う更新も行うことがある
    • LowBound の値よりも低い値が要求されている
    • UpperBound の値よりも大きな値が要求されている
    • Pod が起動してから 12 時間以上中断なく稼働していて、Recommender の推奨値とリソース割り当ての差分が 10% 以上ある
      • 再起動を伴う更新が成功した場合、12 時間の間は再起動を伴う更新ができなくなる
  • InPlaceOrRecreate では、Updater が推奨値を適用するのに失敗した場合、Pod を evict して別のノードで推奨値を適用できるようにする

KEP-4017: Add Pod Index Label for StatefulSets and Indexed Jobs

Kubernetes 1.24 で GA した Indexed Job の機能追加で、特定の index の Job とやり取りが楽に行えるようになります。バッチ処理を並列化する際にヘッド (0 番目) の Job が調整役で、他の Job がヘッドの Job とやり取りしながら並列処理を進めるようなアーキテクチャ向けです。

Indexed Job には batch.kubernetes.io/job-completion-index の annotation で自分自身の Pod の index を知ることができました。この情報を Downward API を使って環境変数に埋め込むことで、入力するデータに対してどの index のデータを処理するのか判別することができます。デフォルトで JOB_COMPLETION_INDEX の環境変数に index の情報が埋め込まれています。

公式のドキュメントの例を少し変更して Downward API で埋め込んだ環境変数から index を読み取るように変更してみます。

cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
  name: indexed-job
spec:
  completions: 5
  parallelism: 3
  completionMode: Indexed
  template:
    spec:
      restartPolicy: Never
      initContainers:
      - name: 'input'
        image: 'docker.io/library/bash'
        command:
        - "bash"
        - "-c"
        - |
          items=(foo bar baz qux xyz)
          echo ${items[$JOB_INDEX]} > /input/data.txt
        env:
        - name: JOB_INDEX
          valueFrom:
            fieldRef:
              fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
        volumeMounts:
        - mountPath: /input
          name: input
      containers:
      - name: worker
        image: docker.io/library/busybox
        command:
        - rev
        - /input/data.txt
        volumeMounts:
        - mountPath: /input
          name: input
      volumes:
      - name: input
        emptyDir: {}
EOF

Downward API で埋め込んだ環境変数から自身の index を取得し、initContainer 内で入力のリストから対象の index の文字をファイルに書き込み、メインのコンテナでファイルから読み込んだ文字を反転して表示できていることが確認できます。

❯ stern --only-log-lines .
indexed-job-0-66xgc worker oof
indexed-job-1-kmlf6 worker rab
indexed-job-2-wvlb5 worker zab
indexed-job-3-wz6bn worker xuq
indexed-job-4-nndms worker zyx

Google DeepMind では、Indexed Job を使って ML モデルの学習でデータをシャーディングして計算時間を短縮しています。(video) ヘッド (index=0) の Job が調整役となり、各 Job が自身の index でシャーディングされたデータを読み込んで処理します。学習は各 Pod 間で部分的な結果を交換しつつ行われます。

ML のフレームワーク用に Indexed Job を利用したサンプルも提供されています。

HPC 向けの Flux Framework も同様に Indexed Job を活用し、ヘッドの Job が broker となって複数の worker (ヘッド以外の Job) を良い感じに管理したり、Indexed Job 間でやり取りを行なっています。(video)

1.27 以前の Indexed Job で Pod 間通信を実現するにはどうすれば良いのでしょうか。Starting a Job with Pod-to-Pod Communication にあるように Headless Service を準備し、Job の subdomain のフィールドに Headless Service の名前を指定します。これにより、<job_name>-<index>.<headless_service_name>.<namespace>.svc.cluster.local で特定の index の Job とやり取りができます。ただ、Job の名前と index、Headless Service 名を使用して接続先のホスト名を構成する必要があり若干面倒なのと、Headless Service の中に Indexed Job から生成された全ての Pod が含まれるため、index の数が大きくなればなるほどパフォーマンスに影響してきます。

KEP-4017 では、Indexed Job に新たに batch.kubernetes.io/job-completion-index のラベルを追加予定です。このラベルを使用して Service を作成を使用することで、特定の index の Pod のみを含めることができます。以下のマニフェストにあるように Service 名を使ったが通常のサービス検出が可能になります。

apiVersion: v1
kind: Service
metadata:
  name: example-job-head
spec:
  type: ClusterIP
  selector:
    job-name: example-job
    batch.kubernetes.io/job-completion-index: "0"
---
apiVersion: batch/v1
kind: Job
metadata:
  name: example-job
spec:
  completions: 3
  parallelism: 3
  completionMode: Indexed
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: example-workload
        image: bash:latest
        command:
        - bash
        - -c
        - |
          set -eux;

          # wait for iptables rule to be synced
          sleep 5

          if [ $JOB_COMPLETION_INDEX -ne 0 ]; then
            ping -c 1 example-job-head > /dev/null 2>&1
          fi

KEP-3715: Elastic Indexed Jobs

Indexed Job の Pod の並列起動数 (spec.parallelism) と Completed な Pod 数の合計 (spec.completions) が等しい場合に限り、両方のフィールドの値を別の同数に変更できます。AI/ML や HPC のワークロードで調整役 (driver) と処理役 (worker) に分かれているケースで、処理役の Pod の数を動的に増減できます。

JobSet

Kubernetes のサブプロジェクトとして WG Batch のメンバーが開発を進めています。JobSet API のデザインドキュメントによると、Deployment や StatefulSet の Job 版を AI/ML や HPC のワークロード (Job) 向けに作ることを目指しています。最終的に Kubernetes のコア API として組み込むことを目標に開発を進めています。

  • 複数の Pod テンプレートを指定したい
    • ML や HPC のワークロードは調整役 (driver) と処理役 (worker) に分かれていて、それぞれ実装が異なり、エラー終了したかの判定も異なる
    • JobSet の中の ReplicatedJobs に複数のテンプレートを指定可能
    • KEP-3715: Elastic Indexed Jobs のおかげで spec.completions と spec.parallelism を増減させることでスケールイン/アウトが可能
    • Job がリトライ可能かどうかを exit code で判別する機能を追加
  • Pod 間のネットワークを介したやり取り
    • Pod 間のやり取りを行うためのリソース (e.g. Headless Service) を管理してくれる
  • JobSet の正常終了とエラーのポリシー
    • JobSet から生成された Job の終了状態に基づいて判断される
    • JobSet の中の全ての Job か、JobSelector で指定された Job が正常終了したら JobSet が正常終了したとみなせる
      • KEP-4017 で Job の index のラベルが追加されると、調整役と処理役に分かれている場合に調整役 (index=0) が正常終了したら JobSet も正常終了みたいなことができてうれしいらしい
  • バッチ処理のワークロードの設定の簡略化
    • MPI や PyTorch などワークロード毎に大量にある設定をある程度共通化して設定する
  • scale サブリソースのサポート
    • バッチ処理のワークロードも HPA を使ってスケールアウト/インさせたい
  • 起動順の制御
    • 調整役の Pod が先に起動してから処理役の Pod が起動する必要があったり、バッチ処理でも起動順が大事
    • replicatedJobs のリストに指定された順番で起動していく
apiVersion: jobset.x-k8s.io/v1alpha1
kind: JobSet
metadata:
  name: paralleljobs
spec:
  replicatedJobs:
  - name: workers
    template:
      spec:
        parallelism: 4
        completions: 4
        backoffLimit: 0
        template:
          spec:
            containers:
            - name: sleep
              image: busybox
              command: 
                - sleep
              args:
                - 100s
  - name: driver
    template:
      spec:
        parallelism: 1
        completions: 1
        backoffLimit: 0
        template:
          spec:
            containers:
            - name: sleep
              image: busybox
              command: 
                - sleep
              args:
                - 100s

KEP-3998: Job success/completion policy

ML や HPC のワークロードで調整役 (driver) と処理役 (worker) に分かれている場合に、調整役が正常終了したらジョブが成功したとみなせます。

  • Indexed Job の特定の index の Pod が正常終了 (Complete) した場合に、他の index の Pod が失敗 (Failed) したとしても Job を成功扱いにすることができます。
  • Indexed Job の Pod に対して成功の閾値 (全ての Pod の中の 何%が正常終了したら) を満たした Job を成功扱いにすることができます。

JobSet にある機能をよりプリミティブな Job API に実装することで、JobSet で異なる replicatedJobs を定義することなしに、Job の index で調整役と処理役を分離することができます。

複数の調整役がそれぞれ分散してトポロジを形成しないといけない場合でも、KEP-4017 のラベルを利用することで実現が可能です。 (comment - kubernetes/enhancements#4062)

apiVersion: v1
kind: Job
metadata:
  name: sample-job
spec:
  completions: 10
  parallelism: 10
  completionMode: Indexed
  successfulPolicy:
    onRequiredIndexes: [0,1,2]
  template:
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: batch.kubernetes.io/job-completion-index
                operator: In
                values: ["0", "1", "2"] # leaders
            topologyKey: topology.kubernetes.io/zone

参考

Discussion