RollingUpdateやBlue/Green DeployをスムーズにするKEP-3633のバックポート実装を作った

## この記事は何?

[KEP-3633][kep-3633]のバックポート実装を以下のリポジトリで開発しました。

https://github.com/10hin/kep-3633-alt

筆者がKEP-3633に注目した動機と、上記バックポート実装の使い方を簡単に解説します。

[kep-3633]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/3633-matchlabelkeys-to-podaffinity

## 免責事項

記事全体を通して自身の業務を踏まえたモデルストーリーに沿って説明しますが、実際の業務そのものではなく、説明のしやすさのため一部改変しています。
また、注意は払っていますが、改変により矛盾点を含んでいる可能性もあるので実業務への応用はご自身の責任で十分な検証を行ってください。

## KEP-3633とは

:::note warn
この節の内容は現在実装作業中の提案内容にもとづいています。今後の実装作業や議論の結果仕様が変更され変わることがあります。最新の内容は[KEP-3633][kep-3633]や関連するissue/pull-requestを確認してください。
:::

[kep-3633-alpha-issue]: https://github.com/kubernetes/enhancements/issues/3633

そもそもKEPとは Kubernetes Enhancement Proposals の略で、Kubernetesプロジェクトでの取り組みについて提案とその後の議論、方針決定などを行うための方法[^1]です。

[^1]: https://github.com/kubernetes/enhancements/tree/45f1297fa1c2d9d5495689a7ac182f9cbf9b3d23/keps#kubernetes-enhancement-proposals-keps

KubernetesプロジェクトはSIG(Special Interest Groups)に分かれて作業を行っており、KEPも多くがそれぞれのSIGに分かれいます。KEP-3633はその内容からsig-schedulingに割り当てられています。[^2]

[^2]: あまりこれ以降でSIGの割り当てを気にすることはないのですが、KEPのディレクトリが主にSIGで分けられているので紹介しました。

KEP-3633では `podAffinity` や `podAntiAffinity` において `matchLabelKeys`/`mismatchLabelKeys` というフィールドで代表される機能を実装しようと提案しています。

`podAffinity` は、Podに設定され、「設定されたPod」と「すでに存在しているPodの集団」を「特定のNodeのグループ分けについて同じグループのNode」にスケジュールするための機能です。 `podAntiAffinity` は「同じグループのNode」を「異なるグループのNode」に変えたものです。

`podAffinity` において、前述の「すでに存在しているPodの集団」を特定するために `labelSelector` というフィールドがあります[^3]。このフィールドは汎用の `LabelSelector` が設定できますが、ラベルキーと値が既知である必要があります。しかし、Podには動的に割り当てられるラベルもあります。例えばDeploymentによって管理されるPodには `pod-template-hash` というキーを持つラベルが付与され、Deploymentの世代をラベルで区別できるようになっています。こうったラベルが自身と共通の値を持つPodの集団との間に `podAffinity` / `podAntiAffinity` を設定したい状況は後述のユースケースなどの場合に存在していました。そこでKEP-3633では `matchLabelKeys` という文字列配列フィールドを追加してそのようなラベルキーを設定できるようにしようとしています。これは実は `topologySpreadConstraints` にKEP-3243で導入されています。ただしKEP-3633では、「値が一致するPodの集団」のみならず「値が一致しないPodの集団」を設定できるように `mismatchLabelKeys` というフィールドも併せて実装することが検討されています。

[^3]: 別のNamespaceのPodも選択できるように `namespaces`/`namespaceSelector` フィールドもありますが、話を簡単にするために触れないことにします。


## 筆者が注目したきっかけ

筆者が注目したきっかけは、Argo Rolloutsの導入の検討でした。もともと既存のKubernetesクラスタでアプリケーションPodに `podAntiAffinity` を利用したavailability zone分散構成を行っているところにArgo Rolloutsを導入すると、肝心のCanaryReleaseやBlue/Green Deploymentを行う際にPodが起動できない問題がありました。

もう少し詳しく説明します。クラウド環境では、一つの地域(Region)でサービスを開始するとその中でも電源やインターネット接続回線などの低レベルインフラを独立させた区画を複数設けて、仮想マシンなどの計算リソースをそれらの区画に分散させることで可用性を向上します。AWSではavailability zone、Azureでは可用性ゾーンなどと呼ばれているようです[^4]。

[^4]: 筆者はAWSを利用することが多いので以下ではavailability zone、短くAZなどと呼びます。

クラウドベンダーが提供するサービスは何も設定しなくても複数のAZに分散されていることが多いですが、利用者が計算リソースを管理する際は複数のAZに分散する責任も利用者にあります。Kubernetesでアプリケーションを稼働する場合、複数のAZにNodeを作成してクラスタに追加しておくだけでは不十分な場合[^5]もあります。そこでPodにも `podAntiAffinity` を設定して複数のAZに配置されたNodeの異なるAZにあるNodeでPodを起動するようにします[^6]。

[^5]: これは詳しく言うと求められるサービスレベルによります。複数のAZにまたがって十分なNodeがあれば、単一AZに障害が発生した場合でも、ほかのAZのNodeにKubernetesがPodをスケジューリングしてくれます。しかし、AZ障害発生から「(当該AZの)Nodeに問題があるのでPodを削除しよう」とKubernetesが判断するまでには時間がかかります。AZ障害の影響でロードバランサから当該AZで起動したPodに到達できなくなると仮定し、あるアプリケーションのPodがすべて1つのAZで起動していたとすると、そのAZの障害ではすべてのリクエストが何らかのエラーになってしまうことになります。そのエラーは前述のPodの再作成(別ノードでの起動)により自動的に復旧することが見込まれますが、その間はサービス停止となります。このような状況が許容できないようなサービスレベルを設定している場合は、事前にPodが複数のAZに分散起動していることを確実にしておきたいところです。
[^6]: この目的で `podAntiAffinity` を使うとスケールアウトと相性が悪いことは知られており、その問題を解消するのが `topologySpreadConstraints` なのですが、オートスケールなどをしない場合は `podAntiAffinity` でも十分に対応できます。筆者が管理する環境の場合、Javaアプリケーションが多く、Javaはその設計上、インスタンスごとのオーバーヘッドが大きいため、小さなリソースで複数のPodに分散するのはリソース効率が悪化するため避けています。

以下にDeploymentマニフェストを例示します。

```yaml:deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: default
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1  # 伏線
  template:
    metadata:
      labels:
        app: nginx
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - # AWS環境の一般的な構成では以下キーのラベルにAZ名を値として持つ
            topologyKey: topology.kubernetes.io/zone
            # 同じDeployment管理下のPodを対象とする
            labelSelector:
              matchLabels:
                app: nginx
      containers:
      - name: nginx
        image: nginx:mainline-alpine
        imagePullPolicy: Always
```

3つのAZに十分なリソースのNodeを持つKubernetesクラスタであれば上記Deploymentで作成されるPodは3つとも異なるAZに配置されます。

さて、上記のような構成でアプリケーションPodのAZ分散を実現しているところに、Argo Rolloutsの導入を検討しました。Argo Rollouts導入の目的はBlue/Green Deployです。

Blue/Green Deployは筆者の環境では以下のような流れで新しいアプリをデプロイすることとしました。

1. 新しい設定(典型的にはコンテナイメージの更新)のPod(以下、新Pod)を最終的に起動する数だけ起動する。この時はまだ古い設定のPod(以下、旧Pod)にすべてのトラフィックが送信されている。
2. 新Podに新規トラフィックを送信するようLoadBalancerの設定を変更する。
3. 動作確認などが終わり、旧Podへの切り戻しが不要になったら旧Podを削除する。

ここで問題になったのが手順の1.です。旧Podを起動したまま新Podを起動する必要がありますが、 `podAntiAffinity` の設定は同時に起動している旧Podと新Podの両方が対象となるため、あとから起動する新Podは旧Podと異なるAZに配置せねばならず、そんなAZのNodeはないため、新PodはどのNodeにも割り当てることができないままになってしまいます。実は似たような現象はDeploymentでも起こりえますが、先ほど例示したマニフェストには回避策が打ってありました。それは `# 伏線` とコメントがつけられたフィールド、 `spec.strategy.rollingUpdate.maxUnavailable` であり、値は1に設定されています。これは、RollingUpdate時に「正常稼働しているPodの数を `replicas` の値からいくつ減らしてもよいか？」という設定で、これを1に設定しているのは、「`(replicas) - 1` までだったらPodが利用不能でもよい」と指示しています。これは実はAZ分散の副作用を回避してRollingUpdateができるようにするための設定です。これがないと、デフォルト値の `25%` が使われますが、3 Podの25%は `0.75` で1に満たず、小数点以下は切り捨てなので0を設定するのと同じになります。この場合、RollingUpdateにおいても旧Podを一つも消さずに新しいPodを起動する必要があり、そのPodは割り当てるNodeが見つかりません。Deploymentの場合は1つまでなら先にPodを消すことを許容することでRollingUpdateを進めることができましたが、Blue/Green Deployでは「すべての新Podを旧Podからトラフィックを移動する前に起動する」という要件上、そういった回避はできません。

ここでインターネット上の情報を調べたり、 `topologySpreadConstraints` への移行なども検討しましたが、その際に `topologySpreadConstraints` に `matchLabelKeys` というフィールドの実装が進められており、現在Beta(デフォルトenable)であること[^7]を知りました。できれば変更を少なくしたいため、 `podAntiAffinity` で同様の新機能はないか探したところ、 [KEP-3633][kep-3633]を発見した、というわけです。

[kep-3243]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/3243-respect-pod-topology-spread-after-rolling-upgrades
[^7]: [KEP-3243][kep-3243]

## 何を作ったの

KEP-3633では、現在[^8]、`matchLabelKeys` / `mismatchLabelKeys` の挙動として、「Podを作成する前に当該ラベルキーと値をLabelSelectorに追加する」という動作で実装する方向になっています。ところで「Podを作成する前に変更を加える」といえばMutatingAdmissionWebhookですよね。上記の動作であればMutatingAdmissionWebhookを作成して同じ動作を行うことができます。 `matchLabelKeys` / `mismatchLabelKeys` のようなフィールドを追加できるわけではないので、 `podAffinity` / `podAntiAffinity` フィールドに書いている内容を丸ごとアノテーションに書いてもらい、 `matchLabelKeys` / `mismatchLabelKeys` についてはPod自身のラベルの値を参照して本物の `podAffinity` / `podAntiAffinity` フィールドに設定することにしました。

[^8]: 2023年7月ごろ

作成したものが以下のリポジトリにまとまっています:

https://github.com/10hin/kep-3633-alt

Helmチャートを用意していますので以下のコマンドでインストールできます[^9]。

[^9]: 今後のリリースも利用する予定があれば(とてもうれしいです)Helmリポジトリを登録することもできます。

```shell
helm upgrade -i -n kube-system kep3633alt kep3633alt --repo https://10hin.github.io/kep-3633-alt
```

インストールができたらPodマニフェストやPodTemplateを以下のように書くことで:

```yaml
apiVersion: v1
kind: Pod
metadata:
  annotations:
    kep-3633-alt.10h.in/podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution: |
      [
        {
          "labelSelector": {
            "matchLabels": {
              "app": "nginx"
            }
          },
          "topologyKey": "topology.kubernetes.io/zone",
          "matchLabelKeys": [
            "pod-template-hash"
          ]
        }
      ]
  name: nginx
  labels:
    app: nginx
    pod-template-hash: UNEXPECTABLEVALUE
spec:
  # ...
```

次のようなPodが起動します。

```yaml
apiVersion: v1
kind: Pod
metadata:
  annotations:
    kep-3633-alt.10h.in/podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution: |
      # reduced
  name: nginx
  labels:
    app: nginx
    pod-template-hash: UNEXPECTABLEVALUE
spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app: nginx
              pod-template-hash: UNEXPECTABLEVALUE
          topologyKey: topology.kubernetes.io/zone
  # ...
```

インストール方法、使い方の詳細は[README.md][kep-3633-alt-readme]に書いています。

[kep-3633-alt-readme]: https://github.com/10hin/kep-3633-alt/blob/main/README.md

## 最後に

興味があればぜひkep-3633-altを使ってみてください。
kep-3633-altを利用するユースケースがKEP-3633に書かれていないなどの場合はメンテナに相談の上KEP-3633への追加を検討してください。
KEPにユースケースがあること、実装が望まれているものであることを伝えることもOSSへの貢献だと思います。また、KEP-3633の実装・GA昇格を後押しすることになるので、GA昇格を待ち望んでいる筆者にとってもうれしいです。

もしkep-3633-altに問題があればissue/prでお知らせください。