私たちはKubernetes SchedulerにWasm拡張の夢を見るか

この記事はMercari Advent Calendar 2022の21日目の記事です。

こんにちは。メルカリのPlatform Infraチームで働いている @sanposhiho といいます。

個人的にKubernetesやその周辺のOSSにコントリビュートをしていて、特にKubernetesのコントロールプレーンのコンポーネントのうちの一つであるスケジューラー周りを触ってることが多いです。

この記事では、現状のスケジューラーにおける拡張性の課題と、スケジューラーに現代の汎用バイナリとも言えるWasm(WebAssembly)ランタイムを通した拡張性を持たせることができるのかどうかを検証してみます。

Kubernetes Scheduler とは

スケジューラーはPodをどのNodeで実行するかを決定しているコンポーネントです。
その時の様々なリソースの状況を見たり、ユーザーが指定したPodのスケジュールの制約を鑑みたりしつつ、Podに最適なNodeを決定しています。NodeAffinityや比較的新しいものだとPod Topology Spread Constraintsなど、Podのスケジュールの制約を指定できる機能も基本的にスケジューラーに実装されています。

また、スケジューラーは以下のScheduling Frameworkというものに沿ってスケジュールを行なっており、様々な役割を持つ拡張点に分かれています。

scheduling framework

(公式のドキュメントから引用)​​
Scheduling Framework | Kubernetes

この内、Nodeの決定には大きく二つの拡張点が関わっています。
不適当なNodeを除外するフェーズ(Filter)と、残ったNodeから最も好ましいNodeを選択するフェーズ(Score)です。

例えば、Filterでは、Podのリソース要求(request)に対して、リソースの空き容量が足りないNodeや、PodのrequiredなNodeAffinity(requiredDuringSchedulingIgnoredDuringExecution) に対して適していないNodeなどが除外されます。
その後、Scoreにて、全体のNodeのリソースの使用量のバランスがちょうど良くなるNode、PodのrequiredではないNodeAffinity(preferredDuringSchedulingIgnoredDuringExecution) に対して適しているNode、など様々な項目でスコアリングされた上で、最適なNodeが決定されます。

また、スケジューラーの各機能はScheduling Framework上でそれぞれプラグインという形で実装されています。
上記の図でExtensible APIとして列挙されているのが、プラグインが実行可能な拡張点で、プラグインはそれぞれ一つ以上の拡張点で動作します。
例えばPod Topology Spread Constraintsはv1.26現在、PreFilter、Filter、PreScore、Scoreの拡張点で動作することで機能を提供しています。

ここでは、この記事をある程度理解するために必要な概要のみを紹介しました。
さらに詳しくスケジューラーに関して学びたい方は、昨年僕が書いた 自作して学ぶKubernetes Scheduler という記事を参照してみてください。
かなり長い記事になっていますが、最初の「kube-schedulerとは」の章を読むだけでもう少し理解が深まると思います。

また、スケジューラー周りの最新事情を知りたいという方は、SIG-Scheduling Deep Dive (Kubecon Recap)を参照してください。

現状のスケジューラーの拡張性と課題

現状スケジューラーには二つの方法で独自のロジックを組み込むことができるようになっています。

  • Extender
  • Plugin

Extenderはつまるところwebhookで、Nodeのフィルタリングやスコアリングの際に呼び出されるので、そこで独自のスケジューリングロジックを組み込めます。

しかし、Extenderを使うと毎回Webhookを呼び出す必要があるため、パフォーマンスの観点から懸念があります。それ以外の懸念もこちらに記載されています。
そこで開発されたのが「Kubernetes Scheduler とは」の章で紹介した、Scheduling Frameworkと呼ばれるPluginを通してスケジューリングのロジックを定義するアーキテクチャです。ユーザーはinterfaceを満たすように新たなプラグインをGoで実装し、それを組み込んでスケジューラーをビルドし直すことでスケジューラーを拡張することが出来ます。現状はスケジューラーの拡張にはこの方法が推奨されています。

ただし、このPluginを通した拡張自体も、いくつか面倒な点が示されています。例えば、スケジューラーをビルドし直す必要があること。またGKEなどのmanagedなKubernetes Clusterを使用している場合、カスタムのスケジューラーを利用するように、SchedulerNameと呼ばれるフィールドを全てのPodに付与する必要があることです。(後者に関してはプラットフォームがデフォルトのスケジューラーを無効にする術を提供しているならそれで十分です。)
メルカリはまさにGKEを使用しているため、Pluginを通した気軽に独自のロジックの追加などは行えない現状があります。

これらのことから、Goの公式パッケージであるpluginパッケージの使用が検討されています。

Goではgo build -buildmode=pluginを通してpluginパッケージ用ののバイナリをビルドすることができます。(ここではpluginバイナリと呼ぶことにします) pluginバイナリをpluginパッケージを通して読み込むことで、pluginバイナリ内に定義されている関数などを呼び出すことができます。

しかし、pluginバイナリ自体がポータブルであるとは言い難く、その現状の仕様からくるいくつかの制約もあることから、現状はこれをコミュニティ共通の運用手法で使用するのは難しそうというのが議論の大筋の流れになっています。

Wasmとは

Wasmは色々な言語からコンパイル可能なバイナリフォーマットで、以下の概要のように、主にWebの文脈で聞いたことのある方が多いかもしれません。

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.
https://webassembly.org/

しかし、その利用はWebブラウザから離れたところにも広がってきています。
ブラウザの外でWasmを使用するためのWASIと呼ばれる標準規格も策定が進んでいます。

実際に、EnvoyではWasmを通した拡張がサポートされています。

メルカリではistioを使用しており、ネットワークチームではこのWebAssemblyによる拡張の検証を始めています。
https://engineering.mercari.com/en/blog/entry/20221214-exploring-the-possibility-of-istio-ingress-gateway/

Wasmはホスト側から動的に読み込むことが可能です。そのため、再コンパイルなどを必要とせずに、色々な言語への拡張性を提供することが出来ます。

…おっと、これはまさに、スケジューラーにおいてやりたいことと合致していますね。

ここからがこの記事の本題となります。EnvoyのようなWasm拡張をKubernetes Schedulerにも導入できるのかを検証していきます。

Upstreamに私が立てたissueはこちらです。
https://github.com/kubernetes/kubernetes/issues/112851
ただし、この記事の結論がどうなろうと、それがこの先に取られるKubernetesコミュニティとしての結論ではないことに注意してください。

実装方針

本記事でKubernetes SchedulerにWasmランタイムを導入するために白羽を立てたのはtetratelabs/wazeroです。

https://github.com/tetratelabs/wazero

また、knqyf263/go-pluginも使用します。protobufを通して何をWasm上でやりとりするかさえを定義すれば、色々生成してくれるのでとても助かりました。
https://github.com/knqyf263/go-plugin

また、執筆時点でGoはWASIに対応したバイナリの生成ができません。この記事ではTinyGoを使用します。
https://github.com/golang/go/issues/31105

Scheduling Framework上のプラグインとして、「WasmとしてコンパイルされたXXX.wasmを読み込み、Filterの結果を問い合わせるプラグイン」を実装するという方針で進めていきます。この記事は検証なので、特定のPathに存在するplugin.wasmを読み込むようになっていますが、Pathを外部から設定可能にすることで、スケジューラーをビルドし直さずに独自のロジックを導入するという目標を達成できます。

The architecture

この記事で用いるブランチ

僕のKubernetesフォーク上の以下のブランチがこの検証のために使用したブランチになります。
https://github.com/sanposhiho/kubernetes/tree/sanposhiho/poc-wasm-scheduler-plugin

Podの名前とNodeの名前だけを渡してみる

最初に最も簡単なものとして、Podの名前とNodeの名前だけをFilter plugin(Wasm)に渡すものを作ってみます。

以下のようなprotobuf定義を使用します。

// The wasmscheduling service definition.
// go:plugin type=plugin version=1
service WasmScheduling {
  rpc Filter(FilterRequest) returns (FilterReply) {}
}

message FilterRequest {
  string pod_name = 1;
  string node_name = 2;
}

message FilterReply {
  // It corresponds to framework.Code.
  int32  status_code = 1;
  string reason = 2;
}

Wasmを通して、プラグインにpod_namenode_nameを渡し、プラグイン側からはstatus_codereasonを貰います。

func (p *Plugin) Filter(ctx context.Context, req proto.FilterRequest) (proto.FilterReply, error) {
    if req.NodeName == "good-node" {
        return proto.FilterReply{
            StatusCode: int32(0), // Pass this Node. (framework.Success)
        }, nil
    }

    return proto.FilterReply{
        StatusCode: int32(2), // mark this Node as unschedulable. (framwork.Unschedulable)
    }, nil
}

Filter plugin(Wasm)では上のように受け渡されたNodeの名前をみて、そのNodeを通すかどうかを決定します。

/pkg/scheduler/framework/plugins/wasm1/plugin_test.go を実行することで、うまく動作することが見てとれます。

しかし、Filterプラグインは本来NodeとPodの名前だけではなく、オブジェクト全体を受け取ります。(ref)

同様にオブジェクト全体を渡すようにできるか検証してみます。

Podの情報全てを渡してみる

とりあえず、Podの情報全てを渡すように変更を試みましょう。

Kubernetesでは各オブジェクトのprotobuf定義を提供しています。
k8s.io/api/core/v1/generated.protoimportしてやれば十分でしょう。

import "k8s.io/api/core/v1/generated.proto";

option go_package = "github.com/kubernetes/kubernetes/pkg/scheduler/framework/plugins/wasm/proto";

// The wasmscheduling service definition.
// go:plugin type=plugin version=1
service WasmScheduling {
  rpc Filter(FilterRequest) returns (FilterReply) {}
}

// The request message containing the user's name.
message FilterRequest {
  k8s.io.api.core.v1.Pod pod = 1;
  string node_name = 2;
}

// The reply message containing the greetings
message FilterReply {
  // It corresponds to framework.Code.
  int32  status_code = 1;
  string reason = 2;
}

ところがどっこい、このprotobufを通して生成したプラグインはTinyGoでコンパイルできません。

TinyGoにはサポートされていないパッケージがいくつかあり、標準のGoでコンパイルできるソースコードが全てコンパイルできるわけではないためです。
https://tinygo.org/docs/reference/lang-support/stdlib/

結果として、k8s.io/apiに依存した時点で、TinyGoではコンパイルができなくなります。かなり苦しいですね。

少し考え直してみましょう。僕たちが使用したいのはk8s.io/apiではなく、PodやNodeの定義のみです。それに付随する便利なメソッドなどはあれば便利ですが、必須ではありません。
例えばPodSpec内のNodeAffinityなどの内容を覗くことができれば、NodeAffinityを実装することは可能なわけです。

KubernetesにはOpenAPI定義が存在するので、そこからprotobuf定義を生成し、knqyf263/go-plugin に一緒に食わせるという方法を取りました。

以下のようなprotobuf定義になっています。

service WasmScheduling {
  rpc Filter(FilterRequest) returns (FilterReply) {}
}

// The request message containing the user's name.
message FilterRequest {
  IoK8sApiCoreV1Pod pod = 1;
  IoK8sApiCoreV1Node node = 2;
}

// The reply message containing the greetings
message FilterReply {
  // It corresponds to framework.Code.
  int32  status_code = 1;
  string reason = 2;
}

一つ目のものと違ってPodとNodeのオブジェクト全体を渡しています。

func (p *Plugin) Filter(ctx context.Context, req proto.FilterRequest) (proto.FilterReply, error) {
    pod := req.GetPod()

    if len(pod.Spec.NodeName) == 0 || pod.Spec.NodeName == req.Node.Metadata.Name {
        return proto.FilterReply{
            StatusCode: int32(0), // mark this Node as schedulable. (framwork.Success)
        }, nil
    }

    return proto.FilterReply{
        StatusCode: int32(2), // mark this Node as unschedulable. (framwork.Unschedulable)
    }, nil
}

Filter plugin(Wasm)では上のように受け渡されたNodeの名前を見て、PodのSpec.NodeNameと比較し、そのNodeを通すかどうかを決定しています。

/pkg/scheduler/framework/plugins/wasm3/plugin_test.go を実行することで、うまく動作することを見てとれます。

パフォーマンスに関して

上記までを見てもかなり苦しい道のりであることはわかりました。
しかし、せっかくここまできたのでパフォーマンスも検証してみましょう。

Benchmark_Filter/success:_node_is_match_with_spec.NodeName-16                 75      14111228 ns/op     2807885 B/op      66532 allocs/op

https://github.com/sanposhiho/kubernetes/blob/sanposhiho/poc-wasm-scheduler-plugin/pkg/scheduler/framework/plugins/wasm3/plugin_perf_test.go

Benchmark_Filter/success:_node_is_match_with_spec.NodeName-16           89364028            12.49 ns/op        0 B/op          0 allocs/op

https://github.com/sanposhiho/kubernetes/blob/sanposhiho/poc-wasm-scheduler-plugin/pkg/scheduler/framework/plugins/nodename/node_name_perf_test.go

このテストでは、先ほど実装したwasm3と、全く同じ振る舞いをする既存のプラグイン”nodename”のベンチマーク結果を比較しています。

もちろんといった感じですが、オーバーヘッドがありますね。スケジューラーは一つのPodのスケジュールをするたびに最大で全てのNodeの数だけFilterを実行するため、Nodeの数が多いクラスターだと影響がかなり大きくなってしまいます。

その他: host functions

Host functionsはホスト側で用意した関数を呼び出せる機能があります。
プラグインにはCycleStateという各スケジューリング中に参照できる構造体が存在します。また、framework.Handleと呼ばれる、プラグイン側から呼び出せるフレームワークのメソッド郡も存在します。
これらは、上手くHost functionsを使えばWasmを通して、プラグイン側から呼び出したりすることができるようにすることができそうです。
https://github.com/knqyf263/go-plugin#host-functions

結論

この記事では、現状のスケジューラーの拡張性における課題点と、スケジューラーに対してWasmを通した拡張性を導入することを検証してみました。
結論としては、夢見ることくらいはできそうですが、少なくともWasm側をGoで実装するには、現状TinyGoでKubernetes関連のモジュールがコンパイルできないという制限が厳しすぎるようです。
しかし、Wasmの扱いが上手い言語(Rust等)であればそこは問題になりません。また、GoのWASIサポートやTinyGoが成長すればKubernetesのモジュール等のエコシステムが揃っているGoに向けて提供をすることも可能そうです。残りの懸念はパフォーマンスくらいになるでしょうか。

関連して何かいいアイデアがある人はぜひ Issue を通して教えてください。

明日のMercari Advent Calendarはkuuさんで「Droidkaigiスポンサーブース運営の裏側と振り返り2022版」です。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加