LabHub

ブログ

Kubebuilder で初めての Operator を作る — プロジェクト作成からデプロイまで

한국어English日本語

はじめに

前の記事で Operator パターンの原理を見ました。今回は実際に手を動かす番です。Kubebuilder を使って、シンプルですが本当に動く Operator を最初から作ってみます。作る対象は Guestbook という CRD で、ユーザーが望む replica 数とイメージを宣言すると、それに合った Deployment と Service を自動で作り調整する Operator です。

本記事の目標は「魔法なしで」全体の流れを理解することです。スキャフォールディングが何を作るのか、reconcile 関数に何を埋めるべきか、RBAC と CRD はどう生成されるのか、そしてローカルとクラスターでそれぞれどう動かすのかを最後まで追っていきます。controller-runtime v0.24.x が基準です。

事前準備

作業を始める前に、次のツールが必要です。

インストールが終わったらバージョンを確認します。

go version
kubebuilder version
kubectl version --client

プロジェクト作成: init と create api

kubebuilder init

まず空のディレクトリでプロジェクトを初期化します。--domain は API グループの接尾辞になり、--repo は Go モジュールのパスです。

mkdir guestbook-operator && cd guestbook-operator
kubebuilder init --domain example.com --repo example.com/guestbook-operator

このコマンドは main.goMakefileDockerfileconfig/ ディレクトリ(マニフェスト)、そして依存関係が埋められた go.mod を生成します。この時点でプロジェクトは何の API もない空のマネージャー(manager)に過ぎません。

kubebuilder create api

次に API とコントローラーを追加します。

kubebuilder create api --group webapp --version v1 --kind Guestbook

プロンプトで Resource(型定義)と Controller(reconcile ロジック)の両方を生成するか尋ねられます。両方 yes を選びます。すると次が作られます。

プロジェクト構造を見てみる

スキャフォールディングが作ったディレクトリ構造を一目で理解すると、その後の作業がずっと楽になります。核心ディレクトリだけまとめると次のとおりです。

guestbook-operator/
  api/v1/                 # API 型定義 (Spec/Status、マーカー)
  internal/controller/    # reconcile ロジック
  config/
    crd/                  # 生成された CRD マニフェスト
    rbac/                 # 生成された RBAC (role.yaml など)
    manager/              # コントローラーマネージャー Deployment
    samples/              # サンプル CR
  cmd/main.go             # マネージャーの入口 (スキーム登録、マネージャー起動)
  Makefile                # build/run/deploy などの標準ターゲット
  Dockerfile              # コントローラーイメージのビルド

この構造の核心的な哲学は「自分が書くコード(api、controller)」と「生成される成果物(config)」を明確に分けることです。生成物は直接直さず、常にソース(型とマーカー)を直したあと再生成する流れを守れば、一貫性が保たれます。

API 型の定義: Spec と Status

api/v1/guestbook_types.go を開いて、欲しいフィールドを定義します。Spec はユーザーが宣言する「望ましい状態」、Status はコントローラーが埋める「観測された状態」です。

package v1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// GuestbookSpec はユーザーが望む状態を定義します。
type GuestbookSpec struct {
	// 望ましい replica 数です。
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=10
	Replicas int32 `json:"replicas"`

	// 使用するコンテナイメージです。
	// +kubebuilder:validation:MinLength=1
	Image string `json:"image"`

	// サービスが公開するポートです。
	// +kubebuilder:default=80
	Port int32 `json:"port,omitempty"`
}

// GuestbookStatus は観測された状態を保持します。
type GuestbookStatus struct {
	// 準備できた replica 数です。
	ReadyReplicas int32 `json:"readyReplicas"`

	// 状態条件の一覧です。
	Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`

// Guestbook は私たちのカスタムリソースです。
type Guestbook struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   GuestbookSpec   `json:"spec,omitempty"`
	Status GuestbookStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// GuestbookList は Guestbook の一覧です。
type GuestbookList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []Guestbook `json:"items"`
}

func init() {
	SchemeBuilder.Register(&Guestbook{}, &GuestbookList{})
}

ここで // +kubebuilder: で始まる行を マーカー(marker) と呼びます。マーカーはコード生成ツールが読む指示文です。たとえば validation:Minimum は CRD の OpenAPI スキーマに検証ルールを追加し、subresource:status は status サブリソースを有効化し、printcolumnkubectl get に追加列を表示します。

reconcile 実装の全コード

いよいよ核心の reconcile 関数を埋めます。internal/controller/guestbook_controller.go を次のように書きます。このコントローラーは Guestbook を読み、望ましい Deployment と Service を冪等に作り、最後に status を更新します。

package controller

import (
	"context"

	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/intstr"
	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/log"

	webappv1 "example.com/guestbook-operator/api/v1"
)

type GuestbookReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	logger := log.FromContext(ctx)

	// 1. 対象の Guestbook を読む。
	var gb webappv1.Guestbook
	if err := r.Get(ctx, req.NamespacedName, &gb); err != nil {
		if errors.IsNotFound(err) {
			// すでに削除済み。owner reference のおかげで子リソースは GC が片付ける。
			return ctrl.Result{}, nil
		}
		return ctrl.Result{}, err
	}

	// 2. 望ましい Deployment を作り冪等に調整する。
	desiredDeploy := r.buildDeployment(&gb)
	if err := ctrl.SetControllerReference(&gb, desiredDeploy, r.Scheme); err != nil {
		return ctrl.Result{}, err
	}

	var existingDeploy appsv1.Deployment
	err := r.Get(ctx, client.ObjectKeyFromObject(desiredDeploy), &existingDeploy)
	if errors.IsNotFound(err) {
		logger.Info("Deployment を作成", "name", desiredDeploy.Name)
		if err := r.Create(ctx, desiredDeploy); err != nil {
			return ctrl.Result{}, err
		}
	} else if err != nil {
		return ctrl.Result{}, err
	} else {
		// すでに存在するなら望ましい姿に合わせる。
		existingDeploy.Spec = desiredDeploy.Spec
		if err := r.Update(ctx, &existingDeploy); err != nil {
			return ctrl.Result{}, err
		}
	}

	// 3. 望ましい Service を調整する。
	desiredSvc := r.buildService(&gb)
	if err := ctrl.SetControllerReference(&gb, desiredSvc, r.Scheme); err != nil {
		return ctrl.Result{}, err
	}
	var existingSvc corev1.Service
	err = r.Get(ctx, client.ObjectKeyFromObject(desiredSvc), &existingSvc)
	if errors.IsNotFound(err) {
		if err := r.Create(ctx, desiredSvc); err != nil {
			return ctrl.Result{}, err
		}
	} else if err != nil {
		return ctrl.Result{}, err
	}

	// 4. status を更新する。
	gb.Status.ReadyReplicas = existingDeploy.Status.ReadyReplicas
	if err := r.Status().Update(ctx, &gb); err != nil {
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}

func (r *GuestbookReconciler) buildDeployment(gb *webappv1.Guestbook) *appsv1.Deployment {
	labels := map[string]string{"app": gb.Name}
	replicas := gb.Spec.Replicas
	return &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      gb.Name,
			Namespace: gb.Namespace,
		},
		Spec: appsv1.DeploymentSpec{
			Replicas: &replicas,
			Selector: &metav1.LabelSelector{MatchLabels: labels},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{Labels: labels},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{{
						Name:  "app",
						Image: gb.Spec.Image,
						Ports: []corev1.ContainerPort{{ContainerPort: gb.Spec.Port}},
					}},
				},
			},
		},
	}
}

func (r *GuestbookReconciler) buildService(gb *webappv1.Guestbook) *corev1.Service {
	labels := map[string]string{"app": gb.Name}
	return &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      gb.Name,
			Namespace: gb.Namespace,
		},
		Spec: corev1.ServiceSpec{
			Selector: labels,
			Ports: []corev1.ServicePort{{
				Port:       gb.Spec.Port,
				TargetPort: intstr.FromInt32(gb.Spec.Port),
			}},
		},
	}
}

func (r *GuestbookReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&webappv1.Guestbook{}).
		Owns(&appsv1.Deployment{}).
		Owns(&corev1.Service{}).
		Complete(r)
}

SetupWithManagerOwns が重要です。こう登録すると、コントローラーが作った Deployment や Service が外部から変更されたときにも reconcile がトリガーされます。つまり誰かが手で replica を変えても、コントローラーがすぐに望ましい値に戻します。これが自己修復の原理です。

RBAC マーカー

コントローラーが Deployment と Service を作るには、それに見合った権限が必要です。Kubebuilder では RBAC を マーカー で宣言します。コントローラーファイルの先頭に次のコメントを追加します。

// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete

これらのマーカーはコード生成時に config/rbac/role.yaml の ClusterRole に変換されます。RBAC の YAML を手で書く代わりに、コードの隣で権限の意図を宣言する方式なので、権限とコードが一緒に保たれます。必要以上の権限を与えないよう verbs を最小化するのが良いプラクティスです。

CRD 生成: make manifests

API 型とマーカーを書き終えたらマニフェストを生成します。

make manifests
make generate

生成された CRD をクラスターにインストールします。

make install
kubectl get crd guestbooks.webapp.example.com

ローカル実行対クラスターデプロイ

ローカル実行(開発ループに適する)

開発中はコントローラーをクラスターの外、つまり自分のノート PC で直接実行するのが最も速いです。kubeconfig を通じてクラスターに接続します。

make run

コードを直して再度 make run すれば即座に反映されるので、反復が速いです。ではサンプル CR を適用してみます。

kubectl apply -f config/samples/webapp_v1_guestbook.yaml
kubectl get guestbook
kubectl get deployment,service

サンプル CR の replica を 5 に変えて再適用すると、Deployment の replica が 5 に追従するのが見えます。手で Deployment の replica を 3 に変えると、コントローラーがすぐに元の値に戻します。

クラスターデプロイ(運用に適する)

実際の運用ではコントローラーもクラスター内のポッドとして動かします。イメージをビルドしてプッシュしたあとデプロイします。

make docker-build docker-push IMG=registry.example.com/guestbook-operator:v0.1.0
make deploy IMG=registry.example.com/guestbook-operator:v0.1.0

make deploy は RBAC、CRD、そしてコントローラー Deployment を一度に適用します。デプロイ後はコントローラーマネージャーのポッドのログで動作を確認します。

kubectl -n guestbook-operator-system get pods
kubectl -n guestbook-operator-system logs deploy/guestbook-operator-controller-manager

テスト: envtest の概念

Operator を本気で作るにはテストが必要です。Kubebuilder は envtest というツールを提供します。envtest は実際のクラスターなしで etcd と kube-apiserver のバイナリだけを立ち上げ、コントローラーが本物の API Server とやり取りするかのようにテストできるようにします。

make test

envtest の利点は fake client より現実的なことです。CRD 検証、status サブリソース、owner reference のような API Server レベルの動作を実際に検証できます。テストでは通常、CR を作ったあと、reconcile が望ましい Deployment を作ったかをポーリングで確認するパターンを使います。

よくある間違い

実際に初めて Operator を作るときによく遭遇する落とし穴をまとめます。

間違い症状解決
RBAC マーカー漏れ「forbidden」エラーでリソース作成失敗必要なグループ/リソース/verb マーカーを追加後 make manifests
owner reference 未設定CR を削除しても子リソースが残るSetControllerReference を呼ぶ
非冪等な reconcile「already exists」エラー、無限ループget 後なければ create、あれば update のパターン
status を Update で更新status 衝突または無視されるStatus().Update を使う(サブリソース)
Owns 漏れ子リソース変更に反応しないSetupWithManager に Owns を追加
make generate 忘れDeepCopy 関連のコンパイルエラー型変更後に make generate を実行

特に冪等性と owner reference は前の記事で強調した 2 つの基本です。この 2 つさえきちんと守れば、ほとんどの初歩的なバグを避けられます。

2026 年の文脈

2026 年基準で Kubebuilder は Kubernetes 1.36 と Go 1.26 をサポートし、内部的に controller-runtime v0.24.x と controller-tools v0.21.x を使います。かつてスキャフォールディングに含まれていた kube-rbac-proxy サイドカーは削除され、代わりに controller-runtime の認証・認可ミドルウェア(WithAuthenticationAndAuthorization)でメトリクスエンドポイントを保護します。新規に作ったプロジェクトならこの方式が既定なので、追加コンテナなしでも安全なメトリクス公開が可能です。

おわりに

Kubebuilder で Operator を作る流れは、結局次の 5 ステップに要約されます。init でプロジェクトを作り、create api で型とコントローラーを追加し、Spec/Status とマーカーで API を定義し、reconcile に冪等な調整ロジックを埋め、make manifests/install/run で動かしてみることです。

最初はスキャフォールディングが生み出すファイルが多くて途方に暮れて見えますが、核心は結局 reconcile 関数 1 つです。「望ましい状態を作り、実際の状態と比較して冪等に合わせる」という一文をコードに移すことが Operator 開発の本質です。次の記事では、この reconcile ループを性能とエラー処理の観点からさらに深く掘り下げます。

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます