つくって学ぶkubebuilder を読んだ。

簡潔によく纏まっている資料で勉強になった。 読んだ感想としてCustom Resource(CR)は自由度が高いので作りたくなるけど、 迂闊にその数を増やすと管理しきれなくなると思う。 おそらく本質的にCRでないと実現できない課題に対してのみ採用して、 かつミニマムに作ったり使ったりするのが良いんだろうと感じた。 例えばここで題材としたMarkdownViewに関しては、 現実にはCRじゃなく標準リソースの組み合わせで実現した方が良いんだろう。 もちろん題材としてはとっつきやすく良かった。

性能面はどうだろうか。 Reconcileループは1ループを軽量にしないと収束時間の予測が難しく運用しづらそう。 リトライはその状態を次のループにわたすなど、おそらく実装上のテクニックがいくつかあるんだろう。 標準のコントローラ、etcd、kube-apiserverへ与える性能影響も気になる。

Kubebuilerに相当するフレームワークは他にあるんだろうか? Kubebuilderが標準でメジャーなのかな。 ざっくり調べたところKubernetes Way(client-goとcode-generator)とOperator SDKがありそうだ。


ここからメモ。私は Kubebuilder を今回初めて触るので誤解も含まれているはず。

序章

KubebuilderはCustom ControllerやOperatorを開発するためのフレームワークで、 controller-toolsとcontroller-runtimeが含まれる。 Custom Resource(CR)のコントローラをCustom Controllerと呼ぶ。 CRの仕様はCRD(Custom Resource Definition)で定義され、これはGoの構造体から自動生成される。 Kubernetesのリソースは宣言的、冪等、レベルドリブントリガーといった特徴があるので、 それに則った形で実装する。 MarkdownViewというCustom Resource/Controllerを題材にして手を動かしながら学べる。 ここ にコードが公開されているのであわせて読むと良い。

MarkdownView Custom Resourceを使うと以下を実現できる。

  • ConfigMapにMarkdownを1ファイルずつ保存する
  • MarkdownのレンダラーとしてmdBookイメージを使ったDeploymentを作成する
  • コンテナイメージとレプリカ数をCustom Resourceで指定する
  • mdBookのDeploymentに外部からHTTPでアクセスできるようSerivceを作成する

kubebuilder

init/edit サブコマンド

まずは空のディレクトリ上でinitサブコマンドを発行してプロジェクトの雛形を作る。 途中で変更する場合にはeditサブコマンドを使う。 重要なオプションは以下の2つ。

  • --domainでCRDのグループ名を指定する
  • --repoにGoモジュール名を指定

makeコマンドを頻繁に使うのでmake helpでmakeターゲット一覧を確認しておくと良い。 ファイル中の//+kubebuilderは重要なマーカーなので削除しないよう注意する。 cmd/main.goがCustom Controllerのエントリポイントとなる。 config/配下にマニフェストが集約されている。 これらマニフェストは kustomization.yaml でまとめて管理されている。

create サブコマンド

createサブコマンドで新たなAPIやWebhookを追加することができる。 例えばAPIの場合には、 create apiサブコマンドでCustom ResourceやCustom Controllerの雛形を生成できる。

  • api/v1/markdown_view_types.go:CRDの生成元
  • api/v1/generated.deepcopy.go:自動生成されたものなので触らないよう注意
  • internal/controllers:主要なcuostom controllerの実装本体

create webhookサブコマンドでAdmission Webhookを追加できる。 リソースの作成・更新時にデフォルト値を埋め込んだりバリデーションしたりできる。 api/v1/markdownview_webhook.goが実装本体となる。 Webhookを有効にするためにconfig/default/kustomization.yamlのコメントを外す。

動かす

多くの操作はmake docker-build/manifests/generate/install/uninstall/deployコマンドで行う。 その他に kind load docker-image controller:latest でCustom Controller のイメージを Kubernetes に読み込んだり、kubectl rollout restart でCustom Controller Podを リスタートしたり、といった操作が必要になる。 今回は試さなかったが、Tiltを使うと効率よく開発ができるようだ。

今回 https://github.com/zoetrope/kubebuilder-training/tree/main を参考にしながら、 https://github.com/bobuhiro11/markdown-view が私の作業リポジトリを作った。

bobuhiro11/markdown-view - GitHub

手元にあるツールのバージョンは以下のとおり。

Tool Version
Go 1.20.3
Docker 24.0.5
Kind 0.20.0
Kubebuilder 3.11.1
controller-gen 0.12.0
Helm 3.13.0

ざっくりと使い方をメモするとこんな感じ。 まずプロジェクトと初期化し、依存ツールをインストール。

mkdir markdown-view
cd markdown-view

# Init project.
kubebuilder init --domain bobuhiro11.net \
  --repo github.com/bobuhiro11/markdown-view
kubebuilder create api --group view --version v1 --kind MarkdownView
kubebuilder create webhook --group view --version v1 --kind MarkdownView
  --programmatic-validation --defaulting
vim config/default/kustomization.yaml

# Start kubernetes cluster.
kind create cluster
kubectl cluster-info --context kind-kind

# Deploy cert-manager.
kubectl apply --validate=false \
  -f https://github.com/jetstack/cert-manager/releases/latest/download/cert-manager.yaml

続いてCustom Controllerのイメージを生成してkubernetesクラスタにロード。

make docker-build
kind load docker-image controller:latest
kubectl rollout restart -n markdown-view-system \
  deployment markdown-view-controller-manager

CRDなどのマニフェストを適用。

make install
make deploy

kubectl get crd markdownviews.view.bobuhiro11.net
# NAME                                CREATED AT
# markdownviews.view.bobuhiro11.net   2023-09-28T04:57:48Z

最後にCustom Resource のマニフェストを適用。

kubectl apply -f config/samples/view_v1_markdownview.yaml
kubectl port-forward svc/viewer-markdownview-sample \
  3000:80 --address 0.0.0.0

controller-tools

controller-toolsは補助ツール群で、重要なツールとしてcontroller-genが含まれている。 Goコードからマニフェストなどを自動生成するもので、 Go構造体としてSpecとStatusを定義すると自動的にCRDが生成される。 自動生成にあたっていくつかマーカーを使うことができ、 一例として+kubebuilder:printcolumn マーカーを使うとkubectlなどの出力形式を改造できる。 Specはユーザが記載するもの、Statusはコントローラが記載するものと役割分担できる。 そこで、Statusに+kubebuilder:subresource:status マーカーをつけてサブリソースとすると良い。 サブリソースは単体で独立したAPIエンドポイントを持ち、単独で更新できるようだ。 RBACやAdmission Webhookのマニフェストもこれで生成される。

ちなみに+kubebuilderマーカーの一覧とそれらの説明を確認できる。

./bin/controller-gen crd -w # or -www
./bin/controller-gen webhook -w # or -www

type-scaffoldとhelpgenといったツールも含まれているようだが、よく分からなかったので後で調べる。

controller-runtime

client.Clientの埋め込み

MarkdownViewReconcilerがCustom Controllerの中心的な実装に相当する。 MarkdownViewReconcilerは client.Client を埋め込んでおり、 r.Get()r.Create()のような操作をいくつかのリソースに対して発行しながら Reconile処理を実現する。

リソースの更新

リソースの更新については競合が発生する可能性があるので注意して実装する。 まずUpdate()CreateOrUpdate()はリソースの取得と書き換えがアトミックでないので、 TOCTTOU(Time of check to time of use)な操作となる。 対策としてPatch()を用いる。Patch()にはいくつかバリエーションがあるようだ。

  • client.MergeFrom:リストの更新は上書きに相当
  • client.StrategicMergeFrom:patchStrategyによってリストの更新が上書きになったり追加になったりする
  • Servcer-Side Apply:kubernetes v1.14+

注意点としてStatusはサブリソースとしたので、 client.Status().Update()client.Status().Patch()で更新する。 また、リソースの取得と削除の間で、同名のリソースが作られる可能性があるので、 PreconditionsオプションでUIDとResourceVersionの一致をチェックすると安全に削除できる。

Reconciler

ReconcilerインターフェイスでReconile処理の肝となる処理を実装する。 Reconcile()メソッドはCRのNemespaceとNameを受け取り、 Requeueするかどうかを返す。 時間がかかる処理の場合にはRequeueすることでRecondile()メソッドからは即座に抜けるよう設計する。 Reconile()は冪等な実装でなければならない。

Custom Controllerは後述するManagerに登録されるが、 登録時にNewControllerManagedBy()において ForでReconcile対象のCR、Ownsで関連するリソースを登録する。 Owns対象にownerReferencesを設定しガベージコレクションが動作するように設定する。

DeletionTimestampがnon-zeroなら削除が開始されたことを意味するので抜ける。

Admission Webhook

下記の2種がある。

  • MutatingWebhook:リソースの作成・更新・削除の直前にその内容を書き換える。Defaulterインターフェイス。
  • ValidatingWebhook:値を検証をする。Validatorインターフェイス。

CRのマニフェストを書き換えて適用しようとすると、バリデーションが動くことを確認できる。

kubectl apply -f config/samples/view_v1_markdownview_noimage.yaml
# The MarkdownView "markdownview-sample-noimage" is invalid:
# * spec.replicas: Invalid value: 1000: replicas must be in the
#                  range of 1 to 5.
# * spec.markdowns: Required value: markdowns must have SUMMARY.md.

Envtest

Envtestでコントローラをテストできる。 make testコマンドで実行でき、Custom ControllerやAdmission Webhookをテストできる。 内部でetcdとkube-apiserverが稼働する。

リソースの削除

リソースの削除はCreateやUpdateと比べ難易度が高い。 同一のNamespaceのリソースに対しては.metadata.ownerReferences によって親リソースの削除によって子リソースが削除されるようガベージコレクションを設定すれば良い。

  • controllerutil.SetControllerReference()
  • あるいはServer-Side Applyの時には WithOwnerReferences(owner)

異なるNamespaceやKubernetesの外部にあるリソースの削除に対してはFinalizerを使う。

  • .metadata.finalizersが指定されたリソースは削除される代わりにdeletionTimestampが付与される
  • Custom ControllerはdeletionTimestampが付与されていれば関連リソースを削除し.metadata.finalizersを削除する
  • 削除イベントを取りこぼしても何度でもReconcileが呼ばれる
  • controllerutil.Contains/Add/RemoveFinalizer()を使う

Manager

ManagerはCustom Controllerの上位の概念で、 コントローラの管理・リーダ選出・メトリクス集計・ヘルスチェックを担当する。

可用性向上のためにCustom Controller Podを複数立ち上げた時、 同リソースに対してReconcile処理が競合するのを防げる。 これはNewManager()LeaderElection=trueとしてLeaderElectionIDを設定すれば良い。 同じIDをもつ複数のPodから唯一つがリーダーとして選出される。 以下のようにログからもリーダー選出の状況を確認できる。

kubectl -n markdown-view-system logs markdown-view-controller-manager...
# ... leaderelection.go:255] successfully acquired lease ...

Reconcile Loop以外にgoroutineを立ち上げて何かを実行するときはRunnableを使えば良い。

CRのStatusは現在の状態だけだか、過去のStatus変更の履歴を残すにはEventRecorderを使う。 controller.Recorder.Event()でログを記録でき、kubectl get eventsで取得できる。

HealthProbeBindAddress でヘルスチェック用のエンドポイントを設定できる。 mgr.AddHealthzCheckmgr.AddReadyzCheckのハンドラがlivenessProbeとreadinessProbeに相当する。

mgr.GetFieldIndexer() と使うとリソースの取得にインデックスを使うことができる

  • インデックスはリソースの任意の複数のフィールドの組み合わせて実現できる
  • インデックスはGVK(Group Version Kind)ごとなのでリソース間で重複しても良い
  • インデックスにnamespaceは暗黙的?に含まれるので明示する必要はない
  • IndexFiled()の第3引数はフィールド名と一致させておくと便利

メトリクス

Kubebuilderの生成コードによって、 自動的にCPU, Memory, Reconcile時間などのメトリクスが取得される。 ManagerのMetricsBindAddressで設定できる。

  • Prometheus互換のフォーマット
  • controller-runtimeのmetrics.Registryで任意のメトリクスを追加できる
  • kube-rbac-proxyを利用するとメトリクスエンドポイントAPIにRBACを設定できる。今回は自動で有効化されていた

手元でメトリクスが取得されていることを確認できる。

kubectl -n markdown-view-system port-forward \
  deploy/markdown-view-controller-manager 8080:8080 --address 0.0.0.0

curl -s localhost:8080/metrics | grep ^markdown
# markdownview_available{name="markdownview-sample",namespace="default"} 0
# markdownview_healthy{name="markdownview-sample",namespace="default"} 1
# markdownview_notready{name="markdownview-sample",namespace="default"} 0

もちろんメトリクスをPrometheusに送り、Grafanaから読むことができる。

helm repo add prometheus-community \
  https://prometheus-community.github.io/helm-charts
helm repo update
kubectl create ns prometheus
helm install prometheus prometheus-community/kube-prometheus-stack \
  --namespace=prometheus \
  --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false
kubectl apply -f ./config/rbac/prometheus_role_binding.yaml

# Forward port for Grafana Web UI
kubectl -n markdown-view-system port-forward \
  deploy/markdown-view-controller-manager \
  8080:8080 --address 0.0.0.0