GKEでnodeAffinityを用いたコントローラブルなPod配置の検証

## 概要

- nodeAffinityの説明と使い方
- Cluster AutoScalerとの併用に関して
- プリエンプティブインスタンスを利用したユースケース

## ノードプールごとに狙った割合でPodを配置したい！

GKEを運用していると、Podを「各ノードプールに対して指定した割合で配置したい」と思うことが少なからずあると思います。
例えばレプリカ数が10のdeploymentを定義した際に、**node-pool-1にはPodを4個配置して、node-pool-2には6個配置する**、といった感じですね。マシンタイプの異なるノードや、プリエンプティブインスタンスを利用している場合は、Podの配置をコントロールできれば嬉しいことがあるかもしれません。

## nodeAffinityを使えばノードプールごとの配置をコントロールできる

nodeAffinityを使えば、Podの配置を細かくコントロールすることができます。
https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#node-affinity-beta-feature

nodeAffinityで指定できる設定は大きく分けて二つです。

一つは`requiredDuringSchedulingIgnoredDuringExecution`で、Podの配置先ノードを固定するものです。指定したノードに配置する余剰リソースがない場合は、pendingとなり配置されません。`nodeSelector`に近いですが、こちらはより細かい設定が可能です。ノードの選択は、ノードラベルを用います。ノードラベルはダッシュボードなら、**クラスタ->ノードの詳細**の詳細タブから確認することができます。

以下はノードプールを`pool-1`と`pool-2`に限定する場合の設定例です。

```yaml
affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
          - key: cloud.google.com/gke-nodepool
            operator: In
            values:
              - pool-1
              - pool-2
```

もう一つは`preferredDuringSchedulingIgnoredDuringExecution`で、こちらは指定したノードへの配置を**優先する**、というものです。優先するだけなので、指定したノードに配置するリソースがない場合は指定外のノードにスケジューリングされます。

また、ノードごとにスケジューリングする割合を指定することもできます。

以下はノードプール`pool-1`と`pool-2`に対して、6対4の割合でPodを配置する設定例です。

```yaml
affinity:
  nodeAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 6
        preference:
          matchExpressions:
            - key: cloud.google.com/gke-nodepool
              operator: In
              values:
                - pool-1
      - weight: 4
        preference:
          matchExpressions:
            - key: cloud.google.com/gke-nodepool
              operator: In
              values:
                - pool-2
```

なるほど、これを利用すれば当初の目的が達成できそうですね。と、一瞬思ったのですが・・・

## クラスタオートスケーラーとの併用は難しい

ノード数が固定の場合は狙った通りにPodが配分されるのですが、残念ながらクラスタオートスケーラーを考慮した分配は無理そうでした。
`requiredDuringSchedulingIgnoredDuringExecution`は、**今存在するノードが優先**されるので、将来追加されるノードに対してスケジューリングを分配することは出来ません。

例えば、オートスケールを有効にしたノードプール`pool-1`を用意した上で以下の様なnodeAffinityを設定します。

```yaml
affinity:
  nodeAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
            - key: cloud.google.com/gke-nodepool
              operator: In
              values:
                - pool-1
```

このaffinityを設定したリソースは`pool-1`にスケジューリングが優先されているので、`pool-1`のノードが不足した場合は`pool-1`がオートスケールして新しいノードにスケジューリングされることを期待したのですが、残念ながらそうはなりませんでした。`pool-1`以外のノードプールに余裕があった場合は、そちらにPodがスケジューリングされてしまいます。

また、オートスケールを有効にしたノードプール`pool-1`と`pool-2`を設定した上で以下の様なaffinityを設定し、Podを150台まで段階的にスケールさせてみました。

```yaml
preferredDuringSchedulingIgnoredDuringExecution:
  - weight: 1
    preference:
      matchExpressions:
        - key: cloud.google.com/gke-nodepool
          operator: In
          values:
            - pool-1
  - weight: 2
    preference:
      matchExpressions:
        - key: cloud.google.com/gke-nodepool
          operator: In
          values:
            - pool-2
```

`pool-1`と`pool-2`がノード数1対2の割合でスケールされることを期待したのですが、`pool-2`だけがどんどん増えて、`pool-1`は一台も増えませんでした。


## プリエンプティブインスタンスを利用した活用を考える

これまでの検証を踏まえて、有効活用方法を考えてみます。やっぱりパッと思い浮かぶのはプリエンプティブインスタンスを利用したノード利用料金の節約でしょうか。

https://cloud.google.com/kubernetes-engine/docs/preemptible-vm?hl=ja

### 条件の定義

実現したいスケジューリングの動作に関して語るために、まず条件を定義します。

- それなりに大規模なトラフィックを持つWebサービスを想定
- ノードのマシンタイプはn1-standard-2
- トラフィックがピークになる際にはノードが30台必要
- トラフィックがもっとも少ない時間帯ではノードが10台で十分
- ノードプールは二つで、通常のノードプールである`pool-1`と、プリエンプティブインスタンスによるノードプールである`pool-pe`が存在
- HPAがいい感じに設定されている

これを踏まえて、クラスタの構成を考えてみました。

### 条件1: pool-1の台数

`pool-1`は5〜30台のオートスケール設定とします。とりあえずこれだけあればクラスタが維持できます。

### 条件2: pool-peの台数

`pool-pe`は5〜30台のオートスケール設定とします。プリエンプティブインスタンスが払い出せる場合は、基本的にこちらを活用する想定です。

### 条件3: affinityの設定

以下がnodeAffinityの設定です。

まず、`requiredDuringSchedulingIgnoredDuringExecution`でノードプールを`pool-1`と`pool-pe`に固定します。
次に`preferredDuringSchedulingIgnoredDuringExecution`で、`pool-1`と`pool-pe`が1対3の配置になる様に設定します。正直ここは差があることが重要で、1対2でも1対100でも良いと思われます。

```yaml
affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
          - key: cloud.google.com/gke-nodepool
            operator: In
            values:
              - pool-1
              - pool-pe
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
            - key: cloud.google.com/gke-nodepool
              operator: In
              values:
                - pool-1
      - weight: 3
        preference:
          matchExpressions:
            - key: cloud.google.com/gke-nodepool
              operator: In
              values:
                - pool-pe
```

### どの様なスケジューリングの動作になるのか？

おそらく、以下の様な動作になると想定されます(願望)。まあ、プリエンプティブインスタンスが払い出せない際の挙動が検証できないので、確証はないですが・・・

1. まず、`pool-pe`の最小ノード(5台)が埋まる
2. `pool-1`の最小ノード(5台)が埋まる
3. プリエンプティブインスタンスが払い出せる場合は、負荷に応じて`pool-pe`がスケールアウトする
4. プリエンプティブインスタンスが払い出せない場合は、`pool-1`がスケールアウトする

問題は、プリエンプティブインスタンスが払い出せずに`pool-1`が増えた場合、スケールイン時に`pool-1`の台数が下がりきらない可能性がある点でしょうか。まあ、この辺りは実際に運用してみなければわかりませんね・・・

### 最後に

プリエンプティブインスタンスを利用した活用法を考えてみましたが、本番環境でプリエンプティブインスタンスを使うのは、サービスの性質にもよりますが金銭的なメリットよりもリスクの方が大きいと感じます(バッチとか良いか)。

GKEでは最近、ノードの自動プロビジョニングや垂直ポッド自動スケーリングがサポートされる様になったので、この辺りも活用していきたいですね。