🐙

Kubernetes マニフェストを Go で書きやすくする

2021/03/15に公開

想定読者

  • 普段から Go で Kubernetes operator を書いてる人
  • Kubernetes オブジェクトを Go で書く必要がある人

Kubernetes オブジェクトをプログラムで書く辛さ

Kubernetes Operator を書いていると Operator によっては Kubernetes オブジェクトを操作しなければいけなくなります。

Kubernetes 上にアプリをデプロイする時に書くマニフェストより Go で書くマニフェストの方が大変です。
YAML で書くマニフェストは YAML がデータ構造を記述するのに特化した言語なのでいくぶん楽にはなっています。(それでも辛いけど…)

さらにマニフェストはデータ構造を YAML か JSON で書き下せばいいのでよく使われる YAML だけではなく jsonnet を使うという方法もあり更に楽にする方法もあります。一方 Operator 内ではこのデータ構造をプログラミング言語で書き下さないといけないので結構大変です。

大きなオブジェクトだと画面の上から下までひとつの変数の定義になってしまうという時もあります。

プログラミング言語の場合は型情報があるのでエディタの補完が効きやすいというメリットはありますがエディタによっては YAML でも同レベルで補完が効くので YAML で書いた方が楽であることにはかわりありません。

ナイーブな解決方法

上記の課題についてナイーブな解決方法として YAML を文字列としてプログラム内に記述するというものがあります。
その文字列を k8s.io/apimachinery/pkg/util/yaml でパースし、 k8s.io/apimachinery/pkg/runtime/serializer でデコードすれば runtime.Object を手に入れることができます。(もしくは k8s.io/apimachinery/pkg/runtime/serializer/json
あとはこれを適当に型アサーションすればオブジェクトを手に入れることができます。

しかしこの方法は文字列として YAML を持つが故に YAML の補完が効きません。
外部ファイルとして YAML を書いて go:embed するという手もありますがプログラムとデータの定義が離れてしまうという大きなデメリットがあります。

なお自分ではこの方法をやったことはありません。
外部から提供されているマニフェストを利用するためにファイルとして埋め込むことはありますが自分で用意するマニフェストをこの方法でプログラム内に記述したことはありません。

Trait を利用したオブジェクトの定義

自分で Operator を書いていてこの問題に何か解決策がないかと考え、最近は次のような方法を使っています。

type Trait func(object interface{})

func PodFactory(base *corev1.Pod, traits ...Trait) *corev1.Pod {
	var p *corev1.Pod
	if base == nil {
		p = &corev1.Pod{}
	} else {
		p = base.DeepCopy()
	}

	if p.GetObjectKind().GroupVersionKind().Kind == "" {
		gvks, unversioned, err := scheme.Scheme.ObjectKinds(p)
		if err == nil && !unversioned && len(gvks) > 0 {
			p.GetObjectKind().SetGroupVersionKind(gvks[0])
		}
	}

	for _, v := range traits {
		v(p)
	}

	return p
}

TypeMeta は必ずしも必要ではありません。しかし用途が限定されていない場合は設定をしておいたほうがよいでしょう。

更に Trait を必要に応じてどんどん定義していきます。

func Name(v string) Trait {
	return func(object interface{}) {
		m, ok := object.(metav1.Object)
		if ok {
			m.SetName(v)
			return
		}

		// metav1.Object を実装していない型の一部もサポートできるようにする
		switch obj := object.(type) {
		case *corev1.Container:
			obj.Name = v
		}
	}
}

func PodIsReady(v interface{}) {
	p, ok := v.(*corev1.Pod)
	if !ok {
		return
	}
	if p.GenerateName != "" && p.Name == "" {
		p.Name = p.GenerateName + randomString(5)
	}
	p.CreationTimestamp = metav1.Now()
	p.Status.Phase = corev1.PodRunning
	containerStatus := make([]corev1.ContainerStatus, 0)
	for _, v := range p.Spec.Containers {
		containerStatus = append(containerStatus, corev1.ContainerStatus{
			Name:  v.Name,
			Ready: true,
		})
	}
	p.Status.ContainerStatuses = containerStatus
	p.Status.Conditions = append(p.Status.Conditions, corev1.PodCondition{Type: corev1.PodReady, Status: corev1.ConditionTrue})
}

func Container(c *corev1.Container) Trait {
	return func(v interface{}) {
		p, ok := v.(*corev1.Pod)
		if !ok {
			return
		}
		p.Spec.Containers = append(p.Spec.Containers, *c)
	}
}

ここでは PodFactory のみの例となっていますが必要であれば XXXFactory もどんどん定義していきます。
どのようなオブジェクトの Factory でも基本的なコードの構造は変わりません。

また Factory はトップレベルのオブジェクト( Go 的には runtime.Object を実装しているオブジェクト。具体的な例だと DeploymentPod Service などのマニフェストの kind に設定するもの)だけに限らずどのようなオブジェクトにでも定義します。
例えば上記の PodFactory に関連して ContainerFactory があってもよいでしょう。

ただしあまり細かくすると利用側から見た時のインターフェースが多すぎるということになるでしょう。ここは作る人のセンスでさじ加減を調整してください。

利用例

上記の Trait を使ったオブジェクト生成の例を示します。

etcdPodBase := k8sfactory.PodFactory(nil,
	k8sfactory.Container(
		k8sfactory.ContainerFactory(nil, k8sfactory.Name("etcd")),
	),
)

readyPod := k8sfactory.PodFactory(etcdPodBase, k8sfactory.PodIsReady)
failedPod := k8sfactory.PodFactory(etcdPodBase, k8sfactory.PodFailed),

このように基本となるオブジェクトを変数としておいて、更にそこに Trait を付加していくという使い方をします。

Operator のテストを書く場合などで様々なオブジェクトの状態を用意する必要があるかと思いますが、このように再利用性を上げておくとテストで記述しなければいけないコード量を大幅に減らすことができます。

メリット

大幅にコードの記述量を減らすことができます。

Trait は基本的に上書きであるため(Trait の実装に依存しますが)どんどん重ねていくことができます。
それにより、結果的に複雑で巨大なオブジェクトであっても少ないコード量と、オブジェクト自体が持っている性質を Trait の名前によって表現することができているので、そのオブジェクトを俯瞰することが可能になります。

デメリット

マニフェストはデータ構造をそのまま書き下した文字列でした。
ですのである意味、データ構造の把握が容易です。読む際に特に考える必要がなく、上から下まで順番に見ていくだけでオブジェクトを把握することが出来ます。

一方 Trait を使った方法ではそのように全体を一気に記述する部分はなくなります。
したがって、各 Trait が 1.どの順番で 2.何を 実行するかを読みながら頭の中の実行環境で実行する必要があります。
それなりに経験を積んだプログラマであれば頭の中に実行環境ができあがっていると思うので把握することはそこまで難しくはないと思いますが、いずれにしろ思考する必要はあります。

そのためデバッグ用としてこれらのオブジェクトをマニフェストとして文字列に変換する方法を知っていると良いでしょう。
それには次のようなコードを実行します。

import (
	"fmt"

	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/serializer/json"
	"k8s.io/client-go/kubernetes/scheme"
)

func DebugPrint(objs []runtime.Object) {
	s := json.NewSerializerWithOptions(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme, json.SerializerOptions{Yaml: true})
	for i, obj := range objs {
		if i != 0 {
			fmt.Println("---")
		}
		if err := s.Encode(obj, os.Stdout); err != nil {
			return err
		}
	}
}

os.Stdout に YAML を出力するので必要に応じてメモリにバッファするなりすると便利に使えるでしょう。

まとめ

主に Kubernetes Operator を書くことが業務になって1年、プライベートでもいくつか Operator を作りテストを書くなどした時に感じたペインポイントのうち一つを解消できるかもしれない方法について紹介しました。

Kubernetes Operator を実装するにあたって他にもいくつかペインポイントがあります。この方法を導入しただけで全てが解決できるようなものではありませんが、現時点で Operator 実装にあたって Ruby on Rails のようなフレームワークがないことからこういったことを積み重ねて少しずつ解決していくしかないのではないでしょうか。
(kubebuilder はありますがそこまでペインポイントが解消した気はしませんでした。むしろ client-go や apimachinery の使い方が分かっているのであればそんなに変わらないという印象です)

Discussion