先日行われた Kubernetes Meetup Tokyo #13 で、Vertical Pod Autoscaler (VPA) について発表してきました。
VPA は、各コンテナの Resource Request の値を自動的に調整してくれるコンポーネント群です。必要とするリソース(CPU、メモリ)量があらかじめ推測しにくいアプリケーションに対して、実績に基づいてそれらしい値を決めたい場合に効果を発揮します。
本記事ではスライドの補足として、VPA が動作する流れをクラスタ上での実際の挙動を通じて確認し、また内部実装についても踏み込んで解説します。
なお、本記事中で引用している仕様やソースコードは執筆時点で最新のコミット ab9c27e
を基準にしています。
はじめに
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.yaml
の replicas
を 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
は少し説明が必要かもしれません。可能な値は Auto
、Recreate
、Initial
、Off
で、それぞれ次のような挙動になります。
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 の計算式は
Upper Bound の計算式は
で与えられます。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
Kubernetes の API にはいわゆる認証・認可の後にこの 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 な変更という問題も深入りすると実はなかなか興味深いのですが、それはまた別の話。