はじめに
前の記事で Operator パターンの原理を見ました。今回は実際に手を動かす番です。Kubebuilder を使って、シンプルですが本当に動く Operator を最初から作ってみます。作る対象は `Guestbook` という CRD で、ユーザーが望む replica 数とイメージを宣言すると、それに合った Deployment と Service を自動で作り調整する Operator です。
本記事の目標は「魔法なしで」全体の流れを理解することです。スキャフォールディングが何を作るのか、reconcile 関数に何を埋めるべきか、RBAC と CRD はどう生成されるのか、そしてローカルとクラスターでそれぞれどう動かすのかを最後まで追っていきます。controller-runtime v0.24.x が基準です。
事前準備
作業を始める前に、次のツールが必要です。
- **Go 1.26 以上**: Kubebuilder が生成するモジュールは最新の Go ツールチェーンを前提とします。
- **Kubebuilder CLI**: プロジェクトのスキャフォールディングとコード生成を担います。
- **kubectl**: クラスターと通信する標準 CLI です。
- **ローカルまたはリモートのクラスター**: kind や minikube で作ったローカルクラスターで十分です。
- **Docker または同等のコンテナビルダー**: コントローラーイメージをビルドしてデプロイするときに必要です。
インストールが終わったらバージョンを確認します。
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.go`、`Makefile`、`Dockerfile`、`config/` ディレクトリ(マニフェスト)、そして依存関係が埋められた `go.mod` を生成します。この時点でプロジェクトは何の API もない空のマネージャー(manager)に過ぎません。
kubebuilder create api
次に API とコントローラーを追加します。
kubebuilder create api --group webapp --version v1 --kind Guestbook
プロンプトで Resource(型定義)と Controller(reconcile ロジック)の両方を生成するか尋ねられます。両方 yes を選びます。すると次が作られます。
- `api/v1/guestbook_types.go` — Spec/Status などの API 型定義
- `internal/controller/guestbook_controller.go` — reconcile ロジックが入るコントローラー
- `config/crd/`、`config/rbac/`、`config/samples/` — 関連マニフェスト
プロジェクト構造を見てみる
スキャフォールディングが作ったディレクトリ構造を一目で理解すると、その後の作業がずっと楽になります。核心ディレクトリだけまとめると次のとおりです。
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/**: 私たちが直接手を入れる場所です。型とマーカーをここで定義します。
- **internal/controller/**: 同じく reconcile を埋める場所です。
- **config/**: ほとんど自動生成されるので手で直すことは少ないです。マーカーを変えて make manifests を回す方式で管理します。
- **cmd/main.go**: マネージャーを作り、スキームに型を登録し、コントローラーをマネージャーにつなぐブートストラップコードです。新しいコントローラーを追加すると、通常自動でここに登録コードが入ります。
この構造の核心的な哲学は「自分が書くコード(api、controller)」と「生成される成果物(config)」を明確に分けることです。生成物は直接直さず、常にソース(型とマーカー)を直したあと再生成する流れを守れば、一貫性が保たれます。
API 型の定義: Spec と Status
`api/v1/guestbook_types.go` を開いて、欲しいフィールドを定義します。Spec はユーザーが宣言する「望ましい状態」、Status はコントローラーが埋める「観測された状態」です。
package v1
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 サブリソースを有効化し、`printcolumn` は `kubectl get` に追加列を表示します。
reconcile 実装の全コード
いよいよ核心の reconcile 関数を埋めます。`internal/controller/guestbook_controller.go` を次のように書きます。このコントローラーは Guestbook を読み、望ましい Deployment と Service を冪等に作り、最後に status を更新します。
package controller
"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)
}
`SetupWithManager` の `Owns` が重要です。こう登録すると、コントローラーが作った 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
- `make manifests` はマーカーを読んで `config/crd/` の CRD と `config/rbac/` の RBAC を生成します。
- `make generate` は `DeepCopy` のようなボイラープレートの Go コードを生成します。
生成された 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 ループを性能とエラー処理の観点からさらに深く掘り下げます。
参考資料
- [Kubebuilder Book — Quick Start](https://book.kubebuilder.io/quick-start.html)
- [Kubebuilder Book — Tutorial](https://book.kubebuilder.io/cronjob-tutorial/cronjob-tutorial.html)
- [Kubebuilder マーカーリファレンス](https://book.kubebuilder.io/reference/markers.html)
- [controller-runtime (pkg.go.dev)](https://pkg.go.dev/sigs.k8s.io/controller-runtime)
- [Operator SDK ドキュメント](https://sdk.operatorframework.io/)
- [kubernetes-sigs/kubebuilder (GitHub)](https://github.com/kubernetes-sigs/kubebuilder)
- [kubernetes-sigs/controller-runtime (GitHub)](https://github.com/kubernetes-sigs/controller-runtime)
- [envtest 利用ガイド](https://book.kubebuilder.io/reference/envtest.html)
- [Operator パターン(Kubernetes 公式ドキュメント)](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
현재 단락 (1/256)
前の記事で Operator パターンの原理を見ました。今回は実際に手を動かす番です。Kubebuilder を使って、シンプルですが本当に動く Operator を最初から作ってみます。作る対象は ...