チェシャ猫の消滅定理

数学にプログラミング、素敵なもの何もかも。

猫でもわかる Vertical Pod Autoscaler

先日行われた Kubernetes Meetup Tokyo #13 で、Vertical Pod Autoscaler (VPA) について発表してきました。

VPA は、各コンテナの Resource Request の値を自動的に調整してくれるコンポーネント群です。必要とするリソース(CPU、メモリ)量があらかじめ推測しにくいアプリケーションに対して、実績に基づいてそれらしい値を決めたい場合に効果を発揮します。

本記事ではスライドの補足として、VPA が動作する流れをクラスタ上での実際の挙動を通じて確認し、また内部実装についても踏み込んで解説します。

なお、本記事中で引用している仕様やソースコードは執筆時点で最新のコミット ab9c27e を基準にしています。

github.com

はじめに

Kubernetes の Pod には、コンテナごとにリソース使用量を指定する機能があります。指定できる項目は Request と Limit の二種類がありますが、今回のテーマである VPA に関係するのは Request の方です。

Kubernetes Scheduler は、Node の利用可能リソースと Pod の Resource Request を比較してどの Node に Pod を配置するか決定します。

Managing Compute Resources for Containers - Kubernetes

重要なのは、Request の値はあくまでもユーザが指定した値であり、実際のリソース使用量とは無関係であるという点です。そのため、Request が小さすぎると実際の動作時にリソースが不足してアプリケーションの挙動に悪影響を与える可能性がある一方、大きすぎるとリソースが余っているのにもかかわらず Pod が配置されないスペースが増えるため、集積度が下がり無駄なコストを抱えることになります。

この Request と実績値の乖離という問題を解決するのが VPA です。VPA は、リソース使用量の実績値をモニタして、過去の履歴から算出した推奨値を自動で Request として設定してくれます。結果として、Scheduler はその Pod をより実態に合った Node に配置することができ、クラスタ全体のリソースを有効活用することができます。

コンポーネント群とその役割

ここからは、実際にクラスタ上に VPA をデプロイしtて、Resource Request が変更される際の各コンポーネントの挙動を確認してみましょう。手元のクラスタ必要な条件を満たしていることを確認しておいてください。

なお、VPA は amd64 用のバイナリしか提供していません。Raspberry Pi で試したい場合、ARM 用の差分をフォークしたので参考にしてください。対応した Docker イメージも Docker Hub に push してあります。

起動の準備

VPA は、次に挙げた三つのコンポーネントがそれぞれの役割を分担することで成り立っています。

  • Recommender : 実際のリソース使用量データから Request の推奨値を算出する
  • Updater : 推奨値に合致しない Pod を見つけて evict する
  • Admission Controller : evict された Pod が再作成される際に Request の値を差し替える

これらのコンポーネントを作成するための YAML deploy ディレクトリ以下にありますが、証明書の作成などの作業とひとまとめにしたスクリプト vpa-up.sh が提供されているのでこれを使用します。

$ mkdir -p $GOPATH/src/k8s.io
$ cd $GOPATH/src/k8s.io
$ git clone https://github.com/kubernetes/autoscaler.git
$ cd autoscaler/vertical-pod-autoscaler
$ git checkout -b ab9c27e

ただし今回は、起動スクリプト実行前にちょっとした工夫を加えます。コンポーネント群を生成する *-deployment.yamlreplicas を 0 に書き換えておきましょう。

$ find deploy -name *-deployment.yaml | xargs sed -i.bak "s/replicas: 1/replicas: 0/"
$ ./hack/vpa-up.sh

こうすることで、必要な準備は整えた上で、コンポーネント群はまだ起動していない状態をつくることができます。以下、各コンポーネントをひとつづつ起動し、それぞれのコンポーネントの役割を確認します。

VPA CustomResource

VPA の設定および現在の状態は CustomResource として保持されます。

VPA の作成

先ほど実行した vpa-up.sh によって VPA 用の CustomResourceDefinition が作成されているはずなので確認してみましょう。

$ kubectl get crd | grep verticalpodautoscaler
verticalpodautoscalercheckpoints.poc.autoscaling.k8s.io   2018-10-01T08:17:20Z
verticalpodautoscalers.poc.autoscaling.k8s.io             2018-10-01T08:17:20Z

二種類の CustomResource が作成されていますが、VPA の状態を保持するのは verticalpodautoscalers です。

提供されているサンプル hamster.yaml から Deployment とそれを管理する VPA を作成します。

$ kubectl create -f examples/hamster.yaml
verticalpodautoscaler.poc.autoscaling.k8s.io/hamster-vpa created
deployment.extensions/hamster created

Pod が 2 個作成されますが、この段階では Pod の Resource Request は hamster.yaml で定義されている通り CPU 100m、メモリ 50 Mi です。

$ kubectl get pods | grep hamster
hamster-6db596f5b4-46g4g   1/1       Running   0          6s
hamster-6db596f5b4-9hlw2   1/1       Running   0          7s

$ kubectl describe pods/hamster-6db596f5b4-46g4g
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

同時に作成された VPA についても確認しておきましょう。出力に Status の項目がないことに注意してください。Status を埋める役割を担っているのは Recommender ですが、この段階ではまだ起動していないためです。

$ kubectl describe vpa/hamster-vpa
...

以上の状態はいわば「初期状態」です。必要なコンポーネント群がまだ起動していないので、このまま待っても状態は変化しません。ここから一つずつコンポーネントを立ち上げていって、VPA の変化を観察します。

VPA の設定項目

今回のサンプルでは最低限の項目のみ定義されていますが、可能な設定をすべて明示すると以下のようになります。

apiVersion: "poc.autoscaling.k8s.io/v1alpha1"
kind: VerticalPodAutoscaler
metadata:
  name: my-app-vpa
spec:
  selector:
    matchLabels:
      app: my-app
  updatePolicy:
    updateMode: Auto
  resourcePolicy:
    containerPolicies:
    - containerName: my-app
      mode: Auto
      minAllowed:
        cpu: 200m
        memory: 100Mi
      maxAllowed:
        cpu: 1000m
        memory: 500Mi

各項目はおおむね見た通りですが、updatePolicy は少し説明が必要かもしれません。可能な値は AutoRecreateInitialOff で、それぞれ次のような挙動になります。

Auto はデフォルト値です。現時点では Kubernetes が Pod を起動させたまま Resource Request の値を変更する機能を提供していないため、暫定的に Recreate と同じ挙動になります。

Recreate の場合、Resouce Request が推奨値のレンジに収まらない Pod が存在したとき、その Pod は evict されます。

Initial の場合、Pod が最初に作成されるときのみ Resource Request の変更を行いますが、すでに起動状態の Pod を能動的に evict しようとはしません。

Off の場合、Resource Request の推奨値の算出だけを行うのみで Pod には影響を与えません。

なお resourcePolicies[].mode には Auto もしくは Off のみが指定できます。また resourcePolicies[].containerName* とすると、その Pod に属するコンテナの内 containerName が指定されていないものすべてに適用されるデフォルト値になります。

Recommender

Recommender は Metrics Server 経由で Pod のリソース使用実績を取得し、Resource Request の推奨値を算出して VPA に保存します。

Recommender の起動

まず、先ほど 0 に差し替えていた replicas を 1 に戻して Recommender を起動させます。

$ mv deploy/recommender-deployment.yaml.bak deploy/recommender-deployment.yaml
$ kubectl apply -f deploy/recommender-deployment.yaml
serviceaccount/vpa-recommender configured
deployment.extensions/vpa-recommender configured

$ kubectl -n kube-system get pods | grep recommender
vpa-recommender-6d8cddc856-rw7m2   1/1       Running   0          6s

これで Recommender が立ち上がりました。数分待つと、算出された推奨値が VPA の Status として書き込まれているのが確認できます。

$ kubectl describe vpa/hamster-vpa
...
Status:
  Conditions:
    Last Transition Time:  2018-10-01T08:52:56Z
    Status:                True
    Type:                  RecommendationProvided
  Recommendation:
    Container Recommendations:
      Container Name:  hamster
      Lower Bound:
        Cpu:     115m
        Memory:  53677237
      Target:
        Cpu:     688m
        Memory:  319572800
      Upper Bound:
        Cpu:     991408m
        Memory:  460504404800
...

Recommender は Target、Lower Bound、Upper Bound の三種類の値を提示します。もし現に起動している Pod の Resource Request がこの Lower Bound を割っている場合、その Pod が実際に動作するためには想定よりも多くのリソースが必要です。また Upper Bound を超えている場合、Node の選択時に必要以上に余裕を見ていることでクラスタ全体のリソースに無駄が生じていることになります。

ただし、Recommender は推奨値を算出して VPA の Status に記録するだけで、実際の調整作業は行いません。例えば先ほど作成した Pod の Request は CPU 100m、メモリ 50 Mi だったので推奨値の範囲からは外れていますが、しばらく放置しても再作成されたりはしないはずです。

推奨値を算出するアルゴリズム

アルゴリズムについて解説する前に、もう一度 VPA の状態を確認してみましょう。

$ kubectl describe vpa/hamster-vpa
...
Status:
  Conditions:
    Last Transition Time:  2018-10-01T08:52:56Z
    Status:                True
    Type:                  RecommendationProvided
  Recommendation:
    Container Recommendations:
      Container Name:  hamster
      Lower Bound:
        Cpu:     529m
        Memory:  237494649
      Target:
        Cpu:     712m
        Memory:  319572800
      Upper Bound:
        Cpu:     114632m
        Memory:  51451220800
...

先ほどと値が変わっています。もちろん、Recommender はリアルタイムの実績値を使用しているのでタイミングによって多少のブレはありますが、Lower Bound と Upper Bound が Target に近づいている、言い換えれば許容される値の幅が狭くなっているのが特徴的です。

推奨値の算出ロジックは、estimator.go に定義された ResourceEstimator インタフェースとして抽象化されています。

type ResourceEstimator interface {
    GetResourceEstimation(s *model.AggregateContainerState) model.Resources

内部で使用されている ResourceEstimator の実装はいわゆる Decorator パターンになっていて、ベースとなる Estimator を次々にラップすることで算出ロジックを組み立てていきます。

例として、実装の一つである minResourceEstimator を見てみましょう。

type minResourcesEstimator struct {
    minResources  model.Resources
    baseEstimator ResourceEstimator
}

func (e *minResourcesEstimator) GetResourceEstimation(s *model.AggregateContainerState) model.Resources {
    originalResources := e.baseEstimator.GetResourceEstimation(s)
    newResources := make(model.Resources)
    for resource, resourceAmount := range originalResources {
        if resourceAmount < e.minResources[resource] {
            resourceAmount = e.minResources[resource]
        }
        newResources[resource] = resourceAmount
    }
    return newResources
}

構造体の内側にベースとなる baseEstimator を保持していて、ベースが返す値がもし許容される最小値 minResources より大きい場合はそのまま、小さい場合には切り上げた値を返すようになっています。

実際に Lower Bound、Target、Upper Bound を算出している Estimator は、recommender.go 内の CreatePodResourceRecommender 関数で構築されています。過去データのパーセンタイル値をベースとして、最小値と安全係数を加味した Estimator です。

推奨値 リソース 基準パーセンタイル 最小値 安全係数
Lower Bound CPU 50% 25m *1.15
memory 50% 250Mi *1.15
Target CPU 90% 25m *1.15
memory 90% 250Mi *1.15
Upper Bound CPU 95% 25m *1.15
memory 95% 250Mi *1.15

Lower Bound と Upper Bound については、ここからさらに「信頼区間」を加味するためのスケーリング操作が入ります。

Lower Bound の計算式は

 \displaystyle
  scaledResource = originalResource \left(1 +\frac{0.001}{N}\right)^{-2}

Upper Bound の計算式は

 \displaystyle
  scaledResource = originalResource \left(1 +\frac{1}{N}\right)

で与えられます。N は算出に使用する過去データのサンプルサイズを表すパラメータで、デフォルトのサンプリング間隔(1 回/分)の場合はデータを使用する期間の日数に一致します。

計算式から、使用する過去データの期間が長ければ長いほど scaledResource は originalResource に近くなり、また Upper Bound よりも Lower Bound の方が急激に収束することがわかります。このことは、先ほど VPA をしばらく放置した際、値の幅が狭くなっていた観察結果とも一致します。

なお、このサンプリング間隔は Recommender 起動の際に --recommender-interval オプションで指定できますが、なぜか無駄に細かくナノ秒単位で指定する仕様になっています。

Updater

Updater は、Recommender が VPA に書き込んだ推奨値と実際に起動中の Pod に指定されている Resource Request を比較し、推奨値のレンジから外れている場合はその Pod を evict します。

Updater の起動

Recommender に引き続き、Updater も起動させてみましょう。

$ mv deploy/updater-deployment.yaml.bak deploy/updater-deployment.yaml
$ kubectl apply -f deploy/updater-deployment.yaml
serviceaccount/vpa-updater configured
deployment.extensions/vpa-updater configured

$ kubectl -n kube-system get pods | grep updater
vpa-updater-664996d6dd-sjrz4       1/1       Running   0          6s

これで Recommender + Updater が立ち上がった状態になりました。数分待つと Pod が evict され、Deployment の作用で再作成されます。しかし、この新しい Pod の Resource Request は元の Pod と同様、CPU 100m、メモリ 50Mi のままです。

$ kubectl get pods | grep hamster
hamster-6db596f5b4-9hlw2   1/1       Running   0          12m
hamster-6db596f5b4-wls85   1/1       Running   0          4s

$ kubectl describe pods/hamster-6db596f5b4-wls85
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

Updater の担当はあくまでも Pod を evict することまでであり、Resource Request の値を変更することはありません。したがって当然ながら Pod は Deployment で定義されている通りの設定で再作成されます。すると新しい Pod もやはり推奨値のレンジからは外れているので、結果として evict と再作成が繰り返されるはずです。

ちなみに、Updater が delete ではなく evict API を使用するという事実は地味ですが有用です。というのも、あらかじめ Pod Disruption Budget を指定しておくことで、すべての Pod が同時に再作成されて一時的にサービス不能になる事態を避けることができるからです。

evict される Pod の割合

ところで、今回は特に Pod Disruption Budget が設定されていないにもかかわらず、evict される Pod は必ず一個ずつであることに気づいたかもしれません。これは、Updater 自身が evict する Pod を制限する仕組みを持っているためです。

Pod を evict できるかどうかの判定は、pods_eviction_restriction.go 内で定義されたインタフェース PodsEvictionRestriction によって行われます。インタフェースは切られていますが実装は事実上podsEvictionRestrictionImpl のみで、判定を行う関数 canEvict は以下のような内容になっています。

func (e *podsEvictionRestrictionImpl) CanEvict(pod *apiv1.Pod) bool {
...
    shouldBeAlive := singleGroupStats.configured - singleGroupStats.evictionTolerance
    if singleGroupStats.running-singleGroupStats.evicted > shouldBeAlive {
        return true
    }
...
}

ここで、configured は設定上存在する Pod の個数、例えば今回の場合なら Deployment に指定された replicas の値が格納されています。条件判定の内容から、evictionTolerance は evict したくない Pod の個数であるらしいことが見て取れます。

evictionTolerance の値を実際に計算しているのは、同じファイル内の NewPodsEvictionRestriction 関数です。

func (f *podsEvictionRestrictionFactoryImpl) NewPodsEvictionRestriction(pods []*apiv1.Pod) PodsEvictionRestriction {
...
    singleGroup.evictionTolerance = int(float64(configured) * f.evictionToleranceFraction)
...
}

この evictionToleranceFraction のデフォルト値は 0.5 なので、 Updater は一度に replicas の半分まで evict しようとします。今回、2 個ある Pod が 1 個ずつ evict されていたのはこのロジックが原因です。

なお evictionToleranceFraction の値は Updaer 起動の際に --eviction-tolerance オプションで指定できますが、VPA ごとに Pod の用途に合わせて設定することはできないため注意しましょう。

Admission Controller

Admission Controller は、Updater により evict された Pod が再作成される際、API Server への Pod 作成リクエストに割り込んで Resource Request の値を書き換えます。

Admission Controller の起動

最後のコンポーネントとして、Admission Controller を起動させます。

$ mv deploy/admission-controller-deployment.yaml.bak deploy/admission-controller-deployment.yaml
$ kubectl apply -f deploy/admission-controller-deployment.yaml
deployment.extensions/vpa-admission-controller configured
service/vpa-webhook configured

$ kubectl -n kube-system get pods | grep admission-controller
vpa-admission-controller-76744566cc-t6m9d   1/1       Running   0          9s

これで Recommender + Updater + Admission Controller の全コンポーネントが揃った状態になりました。先ほど Updater のみ起動した状態では元と同じ設定で Pod が再作成されるだけでしたが、Admission Controller の起動によって状況が変わったことを確認しましょう。

$ kubectl get pods | grep hamster
hamster-6db596f5b4-7xm84   1/1       Running   0          1m
hamster-6db596f5b4-9dn55   1/1       Running   0          22s

$ kubectl describe pods/hamster-6db596f5b4-9dn55
...
    Requests:
      cpu:        712m
      memory:     319572800...

ここで hamster-6db596f5b4-9dn55 は Admission Controller 起動後に作成された Pod です。Resource Request の値が VPA に保持された Target と同じ値に差し替えられていることがわかります。

以上で VPA が推奨値を算出して Pod に適用する一連の流れが確認できました。

Request を差し替える仕組み

ところで、なぜ Pod の Resource Request が差し替えられたのでしょうか? Pod を再作成している Deployment と、そこから作られる ReplicaSet の状況を確認してみましょう。

$ kubectl describe deploy/hamster
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

$ kubectl get rs | hamster
hamster-78568d5fbc   2         2         2         29m

$ kubectl describe rs/hamster-78568d5fbc
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

Deployment と ReplicaSet はいずれも元の設定である CPU 100m、メモリ 50 Mi から変化していません。すなわち他には影響を与えず、Pod を作成するリクエストだけが差し替えられていることがわかります。

ここまで VPA の動作に必要な特定のコンポーネントを Admission Controller と呼んできましたが、実はこれは固有名詞ではありません。もっと広く Kubernetes API へのリクエストに割り込んで特殊な操作を行う機能の総称です。

Using Admission Controllers - Kubernetes

KubernetesAPI にはいわゆる認証・認可の後にこの Admission Control のフェイズがあり、リクエストの内容が特定の条件を満たさない場合にエラーを返したり、あるいはリクエストの一部を書き換えたりすることができます。

Admission Contoller はあらかじめ API Server に組み込まれているものの他に、JSON Webhook の形でユーザが定義することもできます。

Dynamic Admission Control - Kubernetes

VPA の Admission Controller は、その名の通りこの仕組みを利用しています。先ほど立ち上げた VPA Admission Controller の実体は Webhook を提供する HTTP サーバで、API Server は Pod 作成リクエストを受け取った際、この Webhook サーバに問い合わせることでリクエスト内容の書き換えを行います。

それでは VPA Admission Controller の実装を確認してみましょう。

まず Admission Controller は、API Server に対して自分自身を Webhook サーバとして登録し、Pod の作成リクエストがあったとき自分に問い合わせが来るようにします。ソースコード上では config.go 内の selfRegistration 関数が相当します。

func selfRegistration(clientset *kubernetes.Clientset, caCert []byte) {
...
        Webhooks: []v1beta1.Webhook{
            {
                Name: "vpa.k8s.io",
                Rules: []v1beta1.RuleWithOperations{
                    {
                        Operations: []v1beta1.OperationType{v1beta1.Create},
                        Rule: v1beta1.Rule{
                            APIGroups:   []string{""},
                            APIVersions: []string{"v1"},
                            Resources:   []string{"pods"},
                        },
                    },
...
                ClientConfig: v1beta1.WebhookClientConfig{
                    Service: &v1beta1.ServiceReference{
                        Namespace: "kube-system",
                        Name:      "vpa-webhook",
                    },
                    CABundle: caCert,
                },
            },
        },
    }
}

VAP Admission Controller がこの設定を API Server に登録しておくことで、API Server は Pod に対する Create リクエストを受け取った際、Namespace kube-system 内の Service vpa-webhook にアクセスするようになります。

そしてアクセスされた VAP Admission Controller 側では、recommendation_provider.go 内の getContainersResources 関数によって、VPA が保持している Target が Resource Request の値として設定されます。

func getContainersResources(pod *v1.Pod, podRecommendation vpa_types.RecommendedPodResources) []ContainerResources {
    resources := make([]ContainerResources, len(pod.Spec.Containers))
    for i, container := range pod.Spec.Containers {
        resources[i] = newContainerResources()
...
        resources[i].Requests = recommendation.Target
    }
    return resources
}

ここで取得した resources を元にして、最終的には server.go 内の getPatchesForPodResourceRequest 関数が JSON Patch を組み立てて API Server に送り返す流れになっています。

まとめ

本記事では、Kubernetes Pod の Resource Request を実績値から判断し、自動で変更する仕組みである Vertical Pod Autoscaler (VPA) の実装について解説しました。

VPA を使用することで、リソースの使用実績が Pod の配置にフィードバックされるため、クラスタのリソースを有効に活用することができます。

より具体的には、実績から推奨値を算出する Recommender、推奨値に合わない Pod を evict する Updater、Pod の再作成時にリクエストを書き換える Admission Controller の三つのコンポーネントがそれぞれの役割を果たすことで、最終的に Pod の Resource Request が更新されます。現時点では稼働中 Pod の Resource Request を動かしたまま(In-Place で)変更することができないという制限のため、一度 evict する手法が取られています。

ところで、この稼働中の In-Place な変更という問題も深入りすると実はなかなか興味深いのですが、それはまた別の話。