はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

ここ 1〜2年の傾向として、弊社でも Kubernetes を扱う案件が増えてきております。
設計から構築、運用まで一貫してお任せいただくことも多いのですが、安定運用するうえでオートスケールの活用は欠かせません。
(というか、これを活用しないと Kubernetes の価値は半減です。)

ところが、Kubernetes にはオートスケールの仕組みが何種類もあり、さらにプラットフォーム独自のスケーリング方式も含めると、初見者にはなかなか全貌が把握しづらいところでございます。

そこで、今回は Google Kubernetes Engine (GKE) を対象に、利用可能なすべてのスケーリング方式の概観をまとめてみました。

あくまで概観を眺めるのみに留めており、各方式の具体的な設定方法は説明しませんので、必要に応じてググっていただければ。:)

なお、弊社では普段、水平方向 (= 台数) のスケーリングを「スケールアウト/イン」、垂直方向 (= 1台のスペック) のスケーリングを「スケールアップ/ダウン」と呼んでいますが、この記事では Kubernetes の流儀に合わせて、水平/垂直にかかわらず「スケールアップ/ダウン」と呼ぶことにします。

オートスケールの種類

GKE には 5種類のスケーリング方式があります。

方式動作略称
水平PodオートスケーラーPod の数を増減する。HPA (Horizontal Pod Autoscaler)
垂直PodオートスケーラーPod の Resource request を適正値に補正する。VPA (Vertical Pod Autoscaler)
多次元PodオートスケーラーHPA と VPA を同時に実現する。MPA (Multidimensional Pod Autoscaler)
クラスタ・オートスケーラーノードプール内のノードの数を増減する。CA (Cluster Autoscaler)
ノード自動プロビジョニングノードプールを作成&削除する。NAP (Node Auto-provisioning)

これらはそれぞれスケーリング対象の守備範囲が違っているため、システムの要件に応じて組み合わせて使います。

各方式の詳細

続いて、発動のトリガーやスケールダウン時の挙動など、各方式の詳細を見ていきます。

水平Podオートスケーラー (HPA)

概要

Pod の負荷に応じて Pod を増減させます。

Kubernetes で単にオートスケールと言えば、皆さんこれを想像するはずです。

Kubernetes自体 に実装されており、GKE の独自機能ではありません。

トリガー

Pod の負荷 (= 指標値) が指定した水準に合致するように、稼働する Pod の数が増減されます。

GKE では、CPU やメモリのような基本的な指標だけではなく、Cloud Monitoring の指標 に基づいた判定もできます。

ターゲットとする Pod数は以下の計算式で算出されます。

ceil[ <現在の Pod数> * ( <現在の指標値 / <ターゲットとする指標値> ) ]

詳細は Algorithm Details を参照してください。

設定方法

HorizontalPodAutoscaler という Kubernetesリソース、または、そのショートカットである $ kubectl autoscale でスケーリング・ポリシーを定義します。

HorizontalPodAutoscaler は任意の指標に基づいてポリシーを設定できますが、$ kubectl autoscale には制限があり、「全Pod の平均CPU使用率」のみ指定可能です。

HorizontalPodAutoscaler.spec.metrics[].resource.target.type の種類は以下の 3つで、

  • Utilization: 使用率 (= Resource request の値に対する割合)
  • Value: 絶対値
  • AverageValue: 絶対値を Pod の数で割ったもの (= Podあたりの絶対値)

このうち、Utilization は Resource request を基準として計算されるため、使用する場合は Pod に Resource request をセットする必要があります。

https://cloud.google.com/kubernetes-engine/docs/how-to/horizontal-pod-autoscaling#create_the_example_deployment

If you want to autoscale based on a resource’s utilization as a percentage, you must specify requests for that resource. If you do not specify requests, you can autoscale based only on the absolute value of the resource’s utilization, such as milliCPUs for CPU utilization.

デフォルトのスケーリング・ポリシー

ポリシーを指定しない場合はデフォルトのポリシーが使われます。

ここ で定義されているとおり、「平均CPU使用率: 80%」がデフォルト値です。

スケール時の振る舞い

v1.18 から スケール時の振る舞い (spec.behavior) を定義できるようになりました。

具体的には、時間あたりの増減数に上限を設けたり、フラッピング (= 短時間での急激な増減の繰り返し) を緩和できます。

(こちらの資料 が非常にわかりやすいです。)

ちなみに、デフォルト では以下のように定義されており、

behavior:
  scaleDown:
    stabilizationWindowSeconds: 300
    policies:
    - type: Percent
      value: 100
      periodSeconds: 15
  scaleUp:
    stabilizationWindowSeconds: 0
    policies:
    - type: Percent
      value: 100
      periodSeconds: 15
    - type: Pods
      value: 4
      periodSeconds: 15
    selectPolicy: Max

これをわかりやすくまとめると以下の表のように整理できます。

スケール方向単位時間あたりの増減数の最大単位時間要求Pod数が変化した場合の待機時間
ダウン現在の Pod数と同数15秒300秒だけ待ってから追随を開始する。
アップ現在の Pod数と同数 or 4つのどちらか多いほう15秒待ち時間なし。すぐに追随を開始する。

垂直Podオートスケーラー (VPA)

概要

HPA が水平方向 (= Pod の数) へのスケールであるのに対して、VPA は垂直方向 (= 1つの Pod の大きさ) にスケールします。

Pod の大きさというのはつまり Resource request (spec.containers[].resources.requests) の大きさのことで、VPA は Pod の利用状況に基づいて CPU とメモリのリクエスト推奨値を算出し、自動で Pod に適用してくれます。
(UpdateMode を指定することで、自動適用せず推奨値の提示のみに留めることもできます。)

性能向上のためのスケーリングというよりは、リソース使用率の最適化の性格が強い印象です。

GKE の VPA

GKE の VPA は、Kubernetes のスケーラー を独自拡張したもののようで、以下の利点が挙げられています。

https://cloud.google.com/kubernetes-engine/docs/concepts/verticalpodautoscaler#overview

Google Kubernetes Engine (GKE) vertical Pod autoscaling provides these benefits over the Kubernetes open source autoscaler:

  • Takes maximum node size and resource quotas into account when determining the recommendation target.
  • Notifies the Cluster autoscaler to adjust cluster capacity.
  • Uses historical data, providing metrics collected prior to vertical Pod autoscaler being enabled.
  • Runs vertical Pod autoscaler pods as control plane processes, instead of deployments on your worker nodes.

利点と言いつつも、最後の「VPA の Pod群はノードではなくコントロール・プレーンに乗る」というのは、ちょっと一長一短な気がします。

というのも、この記事を書くために VPA を改めて検証していたところ、急に推奨値を取得できない状態に陥ってしまい、

$ kubectl describe vpa vpa-nginx
...
Status:
  Conditions:
    Last Transition Time:  2021-01-14T02:28:48Z
    Message:               No recommendation provided from the recommendation engine.
    Reason:                No recommendation provided from the recommendation engine.
    Status:                False
    Type:                  RecommendationProvided
  Recommendation:
Events:  <none>

どれだけ待ってもダメで、原因もわからず、VPA をいったん無効化してから再び有効化しても直らず…。

結局、コントロール・プレーンを再起動して、やっと推奨値を取得できるようになりました。
(再起動をトリガーするために今回は Kubernetes をバージョンダウンしたのですが、他にコントロール・プレーンを再起動する手段はあるのでしょうか?)

ノードのリソースを消費しないのは利点なのですが、異常があった際に手も足も出せないコントロール・プレーンに乗っているのは少し怖いな、と思いました。

推奨値

推奨値として、Lower Bound、Target、Uncapped Target、Upper Bound の 4つの数値が提示されます。

$ kubectl describe vpa vpa-nginx
...
Status:
  ...
  Recommendation:
    Container Recommendations:
      Container Name:  nginx
      Lower Bound:
        Cpu:     2m
        Memory:  3145728
      Target:
        Cpu:     10m
        Memory:  4194304
      Uncapped Target:
        Cpu:     10m
        Memory:  4194304
      Upper Bound:
        Cpu:     685m
        Memory:  232783872
...

各数値の説明は ここ に書かれており、このうち、Pod の Resource request には Target の値がセットされます。
(他の 3つはただの参考値です。)

Lower Bound、Target、Upper Boundの 3つは、許容範囲 (VerticalPodAutoscalerリソースの spec.resourcePolicy.containerPolicies[] で指定する範囲) に収まるように調整されます。
一方、Uncapped Target は許容範囲の制限を加味しない本来の推奨値を示します。

例: 許容範囲を指定した場合

$ kubectl describe vpa vpa-nginx
...
Spec:
  Resource Policy:
    Container Policies:
      Container Name:  *
      Max Allowed:
        Cpu:     8m
        Memory:  3Mi
      Min Allowed:
        Cpu:     5m
        Memory:  1Mi
      Mode:      Auto
...
Status:
  ...
  Recommendation:
    Container Recommendations:
      Container Name:  nginx
      Lower Bound:
        Cpu:     5m
        Memory:  3145728
      Target:
        Cpu:     8m
        Memory:  3Mi
      Uncapped Target:
        Cpu:     10m
        Memory:  4194304
      Upper Bound:
        Cpu:     8m
        Memory:  3Mi
...

推奨値の適用

公式の設計ドキュメント によると、今の Kubernetes では Pod が稼働したまま Resource request の値を変更できないらしく、代替として「推奨値から外れた Pod を停止して、Reconciliation loop により再作成される際に Admission controller が推奨値に書き換える」という手段を採用しています。

一度に多くの Pod が停止するとシステムの可用性に影響するので、PodDisruptionBudget (PDB) で入れ替え速度を制御するとよいでしょう。

また、クラスタ・オートスケーラー (CA)ノード自動プロビジョニング (NAP) と併用することで、「Pod を停止したけど、新しい Resource request で配置できるだけの空き容量がノードに残っておらず Pod が起動してこない!」という状況を回避できます。

制約

いろいろありますが、特に以下の制約に注意が必要です。

https://cloud.google.com/kubernetes-engine/docs/concepts/verticalpodautoscaler#limitations_for_vertical_pod_autoscaling

To use vertical Pod autoscaling with horizontal Pod autoscaling, use multidimensional Pod autoscaling. You can also use vertical Pod autoscaling with horizontal Pod autoscaling on custom and external metrics.

つまり、「Cloud Monitoring の指標を用いた HPA ならば VPA と併用可能だが、CPU やメモリを指標とする場合は HPA ではなく多次元Podオートスケーラー (MPA) を使用せよ。」ということです。
(MPA は次のセクションで解説します。)

多次元Podオートスケーラー (MPA)

概要

前セクションで述べた HPA と VPA を併用する際の制約を克服するための、水平と垂直、両方向のスケーリングを一度に実現する方式です。

GKE の独自機能のようで、v1.19.4-gke.1700 から利用可能 です。

現時点でサポートしているスケーリング・ポリシーは「CPU使用率に基づいた水平スケーリング + メモリの Resource request の補正」の 1パターンのみですが、それ以外のポリシーが必要になる場面を想像できないので、実用としてはほぼこのポリシーだけで事足りるでしょう。
(例えば「メモリで水平 + CPU で垂直)」みたいなポリシーが必要になるケースはどれだけあるのかしら?)

実態は HPA と VPA なので、詳しい説明は省略します。

$ kubectl get mpa
NAME        AGE
mpa-nginx   3s

$ kubectl get hpa
NAME        REFERENCE          TARGETS         MINPODS   MAXPODS   REPLICAS   AGE
mpa-nginx   Deployment/nginx   <unknown>/60%   1         4         0          7s

$ kubectl get vpa
NAME        AGE
mpa-nginx   5s

クラスタ・オートスケーラー (CA)

概要

Pod のデプロイ要求に応じてノードを増やしたり、また逆に、遊んでいるノードを削除します。

ノードプールごとにスケーリングの範囲 (= 最小〜最大ノード数) を設定できます。

GKE の独自機能というわけではなく、Kubernetes のコア + 各プロバイダ用の実装 という設計のようです。

トリガー

既存のノードに Pod を配置できるだけの空きリソースが残っていないとスケールアップが発動し、新たにノードが追加されます。
具体的には、Pod の Resource request に応じられるだけのキャパシティがあるかどうかで判断されます。

反対に、ノードのリソースが余っている状態が続くと、スケールダウンが走って当該ノードは削除されます。
基本的に「Resource request の合計がノードのアロケータブル領域の 50% を下回る状態」が 10分を超えて継続し、かつ、引越し先のノードに充分な空きがある場合にトリガーされますが、いろいろ細かい条件があるので、詳しくは公式ドキュメントを確認してください。

なお、マシン・タイプが小さすぎると、基盤系 (kube-system名前空間) の Pod のオーバヘッドが大きすぎて消費リソースが閾値を下回らず、スケールダウンしません。
(e2-micro のノードプールでは発動することはありませんでした。)

スケール性能

公式ドキュメントに 性能試験の結果 がまとまっています。

テスト・シナリオにもよりますが、どうやら 1,000ノード x 30 Pods くらいの規模なら普通に扱えるようです。

GKE はさらに太っ腹で、5,000 ノード x 30 Pods までサポートしていました。

https://cloud.google.com/kubernetes-engine/docs/concepts/cluster-autoscaler#limitations

The cluster autoscaler supports up to 5000 nodes running 30 Pods each.

スケールダウン時の挙動

スケールダウンの際、停止対象のノードを空にするために稼働中の Pod を別ノードに引越すので、(Kubernetes を使うなら当然なので改めて言うまでもないことですが) コンテナはステートレスにしておく必要があります。

GKE の場合、Pod を停止するための猶予時間は 10分以内です。
それを超えるとノードは強制停止されます。

https://cloud.google.com/kubernetes-engine/docs/concepts/cluster-autoscaler#limitations

When scaling down, the cluster autoscaler honors a graceful termination period of 10 minutes for rescheduling the node’s Pods onto a different node before forcibly terminating the node.

停止対象のノードが複数ある場合、一斉に停止したものの引越し先が早い者勝ちで埋まってしまって Pod の行き先がなくなる事態を避けるために、ノードは 1つずつ停止されます。

https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/FAQ.md#how-does-scale-down-work

Cluster Autoscaler terminates one non-empty node at a time to reduce the risk of creating new unschedulable pods.

一方、Pod が稼働していない空のノードは Pod を引越す必要がない (= 引越し先が埋まってしまう危険がない) ため、一度に複数台まとめて停止されます。

Empty nodes, on the other hand, can be terminated in bulk, up to 10 nodes at a time (configurable by --max-empty-bulk-delete flag.)

クラスタ・レベルのスケール上限

NAP の設定項目の 1つに クラスタ全体での CPU/メモリ合計の上限 がありますが、この上限は CA にも適用されます。

CA はこの上限を超えてノードを増やすことはできず、上限に達した場合、以下のようなログを吐いてスケールアップに失敗します。

  • resource.type: k8s_cluster
  • log_name: projects/{{ プロジェクト }}/logs/container.googleapis.com%2Fcluster-autoscaler-visibility
{
  "jsonPayload": {
    "noDecisionStatus": {
      "measureTime": "1610004170",
      "noScaleUp": {
        "unhandledPodGroupsTotalCount": 1,
        "skippedMigs": [
          {
            "mig": {
              "nodepool": "default-pool",
              "name": "gke-scale-test-default-pool-8df654b8-grp",
              "zone": "asia-northeast1-a"
            },
            "reason": {
              "parameters": [
                "max cluster cpu limit reached"
              ],
              "messageId": "no.scale.up.mig.skipped"
            }
          }
        ],
        ...

この例ではクラスタの CPU上限に達しており、max cluster cpu limit reached と怒られてしまっています。

ドキュメントにも「この上限の適用範囲は自動プロビジョニングされたノードプールだけではない」という旨の記述が 2回も重ねて書かれているのですが、GKE の Webコンソール上では設定欄が NAP の 1項目として配置されているので CA との関連に気づきにくく、注意が必要です。

https://cloud.google.com/kubernetes-engine/docs/how-to/node-auto-provisioning#limits_for_clusters

The limits you define are enforced based on the total CPU and memory resources used across your cluster, not just auto-provisioned pools.

★ Note: Resource Limits are enforced for all autoscaled node pools, not just auto-provisioned pools.

ノード自動プロビジョニング (NAP)

概要

CA の拡張版 (Proposal) で、Pod の要求する CPU やメモリ、GPU、Taint に基づいてノードプールを用意してくれます。
前セクションの純粋な CA がノードを増減するのに対し、NAP はノードプール自体を作ったり消したりします。

なお、NAP を有効化すると CA も連動して有効化されます。

https://cloud.google.com/kubernetes-engine/docs/how-to/node-auto-provisioning#operation

★ Note: The cluster autoscaler is automatically enabled when using node auto-provisioning.

トリガー

既存ノードのスペックが Pod の要求に合わず、同じノードをいくら増やしても Pod を配置できない場合にスケールアップが発動します。

例:

  1. マシン・タイプが小さすぎる。
  2. Taint されている。

1 のケースでは、リクエストされた Pod を配置するに足る大きめのノードが作成されるので、クラスタのスケール上限 に注意しましょう。

特に、マシン・タイプ本来の容量 (= Capacity) と Pod が利用可能な領域 (= Allocatable) は異なるため、その差異を意識して余裕をもった上限を設定することをオススメします。
(参考: 上限が低すぎるため NAP が発動しない例)

2 のケースでは、Affinity (= nodeSelector または nodeAffinity) と Toleration の組み合わせで挙動が変わってきます。
詳しくは次のセクションで説明します。

Affinity と Toleration

Affinity と Toleration の組み合わせでスケールアップが走るかどうかが決まります。

Affinity + Toleration:

GKE 公式ドキュメントの例のとおり、Affinity と Toleration の両方を指定した場合 は、その指定を満たすように新しいノードプールが作成されます。

例えば、ドキュメントに沿ってこんな Pod を要求すると、

spec:
  template:
    spec:
      tolerations:
      - key: dedicated
        operator: Equal
        value: ui-team
        effect: NoSchedule
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: dedicated
                operator: In
                values:
                - ui-team

NAP が発動し、ラベルと Taint が貼られた新しいノードプールが作成されます。

$ kubectl get nodes '{{ 作成されたノード }}' -o json | jq '.metadata.labels | { dedicated }'
{
  "dedicated": "ui-team"
}

$ kubectl get nodes '{{ 作成されたノード }}' -o json | jq '.spec.taints'
[
  {
    "effect": "NoSchedule",
    "key": "dedicated",
    "value": "ui-team"
  }
]

Affinity のみ:

一方、Toleration を指定せずに Affinity のみ要求した場合は、意外なことにスケールアップが走りません。

ラベルの貼られたノードプールが作成されてもよいと思うのですが… 直感に反する意外な挙動です。:(

Toleration のみ:

使う機会は多くないと思いますが、Toleration のみの場合はスケールアップが発動します。

まず、既存のノードプールに別の Taint (= その Toleration では許容できない Taint) を貼って、既存のノードプールに Pod を配置できない状態を作ります。
続いて Toleration のみの Pod を要求すると、NAP により新ノードプールが作成されます。

ただし、この場合は新ノードにラベルも Taint は貼られません。

Affinity も Toleration もなし:

こちらもあまりないケースだと思いますが、Affinity、Toleration の両方とも指定しない場合でも、スケールアップが走ります。
(Toleration のみの場合と同じように、あらかじめ既存ノードプールに Taint を貼っておきます。)

まとめ:

以上、NAP発動の有無を表にまとめます。

Toleration ありToleration なし
Affinity あり
Affinity なし

Affinity のみの場合にスケールアップが走らないのはかなり意外でした。

ノードのマシン・タイプ

v1.19.7-gke.800 の前後でマシン・タイプの 選択ルール が異なります。

なお、ノードのイメージは Container-Optimized OS です。

当該バージョン以降:

以下の例外を除き、E2系です。

(と、ドキュメントには書いてあるのですが、現時点ではまだ v1.19.7-gke.800 を選択できなかったので、実際の挙動は未検証です。)

当該バージョン未満:

64 vCPU までの N1系のうち、要求された Pod に適したサイズが選択されます。

スケールダウン

NAP で作成されたノードプールは CA が自動で有効になっており、スケールの範囲が 0〜1000ノードなので、Pod を消したあとしばらく待てばノードが 0 になります。

ノードが 0 になれば、ノードプール自体も削除 されます。

まとめ

GKE で利用可能なオートスケールの方式 5種類の概観をまとめました。

それぞれ特徴があり、カバーするスケーリング対象が違うので、要件に応じてうまく組み合わせながら使っていきたいですね。:)

今後ともよろしくお願い申し上げます。


当記事の図表には Kubernetes Icons Set を使用しています。