K8s CRD Operator開発実践:CRD設計からController実装まで6つのプロダクションパターン

云原生

2000行のYAMLを手書きしてやっと1つのアプリをデプロイする時

このような苦痛を経験したことはありませんか——マイクロサービスをデプロイするためにDeployment、Service、ConfigMap、Secret、Ingress、HPA、PDBなど7種類のリソースを手書きし、環境ごとにイメージバージョン、レプリカ数、環境変数を変更する必要がある?新入社員がデプロイフローを理解するのに1週間かかる?さらに恐ろしいのは、深夜3時に本番障害が発生し、ConfigMapのデータベース接続文字列が間違っていることに気づいたが、YAMLファイルが5つのGitリポジトリに散在していて、どれを変更すべきか分からない?

これがK8sネイティブAPIの「組み合わせ爆発」問題です:低レベルリソースが細かすぎ、ビジネスセマンティクスの抽象化が欠如;YAML運用にはバリデーションがなく、インデントエラー1つでデプロイ全体が失敗;マルチ環境設定が収束されず、dev/staging/prodのYAML差異はすべて手動比較に依存。

CRD + Operatorがこれをすべて変えます。K8sで独自のビジネスAPIを定義し、Controllerですべてのデプロイロジックを自動化できます——MyAppリソースを1つ書くだけで、Operatorが7つの子リソースを自動作成し、バージョンアップグレードを処理し、設定変更を管理します。本記事では、ゼロから6つのCRD Operator開発のプロダクショングレードパターンを習得します。

コア概念リファレンステーブル

概念 フルネーム 説明
CRD Custom Resource Definition K8sでのカスタムリソースタイプの定義、APIパス、フィールド、バリデーションルールを宣言
CR Custom Resource CRDで定義されたリソースインスタンス、ユーザーが作成する具体的なカスタムリソースオブジェクト
Operator Operator Pattern CRD + Controllerを通じてK8sアプリの自動化管理を実現するパターン
Controller Controller クラスタ状態を継続的に監視し、実際の状態を期望状態に収束させる制御ループ
Reconcile Reconciliation Loop Controllerのコア調整ロジック、期望状態と実際の状態の差異を比較し操作を実行
Kubebuilder Kubebuilder controller-runtimeベースのOperator SDK、プロジェクトスキャフォールディングとコード生成を提供
controller-runtime controller-runtime K8s Controllerを実装するGoライブラリ、Watch/Reconcile/Event等のコアメカニズムを提供
Finalizer Finalizer リソース削除前のインターセプトメカニズム、Controllerがクリーンアップを完了してから削除を許可
Status Subresource Status Subresource SpecとStatusを2つの独立した更新パスに分離、更新競合を回避
Owner Reference Owner Reference リソース間の所有関係、カスケード削除とガベージコレクションを実現
Event Event K8sイベントレコード、Controllerの操作状態とエラー情報をユーザーに表示
Webhook Admission Webhook APIリクエストのインターセプトバリデーションメカニズム、MutatingとValidatingの2種類

6つの課題:なぜCRD Operator開発は「CRDを書けば終わり」ではないのか

  1. CRD Schema設計の罠:フィールドタイプの定義が不正確、OpenAPI v3バリデーションが欠如し、ユーザーが不正なデータを送信可能;バージョン互換性の事前計画がなく、v1からv2への移行が悪夢に

  2. Reconcile Loopの冪等性:ControllerのReconcileは繰り返しトリガーされる可能性があり、ロジックが冪等でない場合、重複リソースの作成や重複操作の実行を引き起こす——これが最も一般的かつ致命的なバグ

  3. Status更新の競合:複数のControllerが同じリソースのStatusを同時に更新、またはControllerがStatusを更新中にSpecが変更され、楽観的ロックの競合と更新の損失を引き起こす

  4. Finalizerのデッドロック:Finalizer追加後にControllerがクラッシュまたは削除されると、リソースは永遠にTerminating状態でスタックし、削除も再作成もできない

  5. イベントストーム:大規模クラスタでは、1つのCRD変更が数百の子リソースの作成/更新をトリガーし、Controllerが処理しきれずWorkqueueが蓄積

  6. プロダクショングレードの欠落:Leader Electionの欠如によりマルチレプリカControllerが重複実行、Graceful Shutdownの欠如によりReconcileが中断、Metricsの欠如によりControllerの健全状態を監視不可能

6ステップ実践:CRD設計からController実装まで

ステップ1:CRD定義とOpenAPI v3 Schema

CRD定義(フルバリデーション付き):

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: webapps.apps.toolsku.dev
spec:
  group: apps.toolsku.dev
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required:
                - image
                - replicas
              properties:
                image:
                  type: string
                  pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*:[a-zA-Z0-9][a-zA-Z0-9._-]*$'
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 100
                port:
                  type: integer
                  minimum: 1
                  maximum: 65535
                  default: 8080
                resources:
                  type: object
                  properties:
                    requests:
                      type: object
                      properties:
                        cpu:
                          type: string
                          pattern: '^([0-9]+m|[0-9]+(\.[0-9]+)?)$'
                        memory:
                          type: string
                          pattern: '^[0-9]+(Ki|Mi|Gi|Ti)$'
                    limits:
                      type: object
                      properties:
                        cpu:
                          type: string
                        memory:
                          type: string
                env:
                  type: array
                  items:
                    type: object
                    required:
                      - name
                    properties:
                      name:
                        type: string
                        maxLength: 256
                      value:
                        type: string
                      valueFrom:
                        type: object
                        properties:
                          secretKeyRef:
                            type: object
                            required:
                              - name
                              - key
                            properties:
                              name:
                                type: string
                              key:
                                type: string
            status:
              type: object
              properties:
                availableReplicas:
                  type: integer
                conditions:
                  type: array
                  items:
                    type: object
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum:
                          - "True"
                          - "False"
                          - Unknown
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string
      subresources:
        status: {}
        scale:
          specReplicasPath: .spec.replicas
          statusReplicasPath: .status.availableReplicas
      additionalPrinterColumns:
        - name: Image
          type: string
          jsonPath: .spec.image
        - name: Replicas
          type: integer
          jsonPath: .spec.replicas
        - name: Available
          type: integer
          jsonPath: .status.availableReplicas
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
  scope: Namespaced
  names:
    plural: webapps
    singular: webapp
    kind: WebApp
    shortNames:
      - wa

ステップ2:Kubebuilderプロジェクトスキャフォールディング

# Kubebuilderプロジェクトの初期化
mkdir webapp-operator && cd webapp-operator
kubebuilder init --domain toolsku.dev --repo github.com/toolsku/webapp-operator

# APIの作成(CRD + Controller)
kubebuilder create api --group apps --version v1alpha1 --kind WebApp

# Webhookの作成
kubebuilder create webhook --group apps --version v1alpha1 --kind WebApp --defaulting --programmatic-validation

# プロジェクト構造
# ├── api/v1alpha1/
# │   ├── webapp_types.go      # CRDタイプ定義
# │   ├── webapp_webhook.go    # Webhookロジック
# │   ├── webhook_suite_test.go
# │   └── groupversion_info.go
# ├── internal/controller/
# │   ├── webapp_controller.go # Controller調整ロジック
# │   └── suite_test.go
# ├── cmd/
# │   └── main.go              # エントリポイント
# ├── config/
# │   ├── crd/                 # CRD YAML
# │   ├── rbac/                # RBAC設定
# │   ├── manager/             # Controller Managerデプロイ
# │   └── samples/             # サンプルCR
# └── Dockerfile

CRDタイプ定義(Go):

package v1alpha1

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

type WebAppSpec struct {
	Image     string                      `json:"image"`
	Replicas  *int32                      `json:"replicas"`
	Port      *int32                      `json:"port,omitempty"`
	Resources *WebAppResourceRequirements `json:"resources,omitempty"`
	Env       []WebAppEnvVar              `json:"env,omitempty"`
}

type WebAppResourceRequirements struct {
	Requests *ResourceList `json:"requests,omitempty"`
	Limits   *ResourceList `json:"limits,omitempty"`
}

type ResourceList struct {
	CPU    string `json:"cpu,omitempty"`
	Memory string `json:"memory,omitempty"`
}

type WebAppEnvVar struct {
	Name      string          `json:"name"`
	Value     string          `json:"value,omitempty"`
	ValueFrom *EnvVarSource   `json:"valueFrom,omitempty"`
}

type EnvVarSource struct {
	SecretKeyRef *SecretKeyRef `json:"secretKeyRef,omitempty"`
}

type SecretKeyRef struct {
	Name string `json:"name"`
	Key  string `json:"key"`
}

type WebAppStatus struct {
	AvailableReplicas int32              `json:"availableReplicas,omitempty"`
	Conditions       []metav1.Condition `json:"conditions,omitempty"`
}

type WebApp struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   WebAppSpec   `json:"spec,omitempty"`
	Status WebAppStatus `json:"status,omitempty"`
}

type WebAppList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []WebApp `json:"items"`
}

func init() {
	SchemeBuilder.Register(&WebApp{}, &WebAppList{})
}

ステップ3:Controller Reconcile Loop実装

package controller

import (
	"context"
	"fmt"

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

	appsv1alpha1 "github.com/toolsku/webapp-operator/api/v1alpha1"
)

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

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

	var webapp appsv1alpha1.WebApp
	if err := r.Get(ctx, req.NamespacedName, &webapp); err != nil {
		if errors.IsNotFound(err) {
			logger.Info("WebApp resource not found, ignoring")
			return ctrl.Result{}, nil
		}
		logger.Error(err, "Failed to get WebApp")
		return ctrl.Result{}, err
	}

	if webapp.DeletionTimestamp != nil {
		return r.handleDeletion(ctx, &webapp)
	}

	if err := r.ensureFinalizer(ctx, &webapp); err != nil {
		return ctrl.Result{}, err
	}

	deployment, err := r.reconcileDeployment(ctx, &webapp)
	if err != nil {
		r.updateCondition(ctx, &webapp, "Available", metav1.ConditionFalse, "ReconcileError", err.Error())
		return ctrl.Result{}, err
	}

	service, err := r.reconcileService(ctx, &webapp)
	if err != nil {
		r.updateCondition(ctx, &webapp, "Available", metav1.ConditionFalse, "ReconcileError", err.Error())
		return ctrl.Result{}, err
	}

	_ = deployment
	_ = service

	if err := r.updateStatus(ctx, &webapp); err != nil {
		return ctrl.Result{}, err
	}

	r.updateCondition(ctx, &webapp, "Available", metav1.ConditionTrue, "ReconcileSuccess", "WebApp is available")

	return ctrl.Result{}, nil
}

func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *appsv1alpha1.WebApp) (*appsv1.Deployment, error) {
	logger := log.FromContext(ctx)

	var deployment appsv1.Deployment
	err := r.Get(ctx, types.NamespacedName{Name: webapp.Name, Namespace: webapp.Namespace}, &deployment)

	if err != nil && errors.IsNotFound(err) {
		desired := r.buildDeployment(webapp)
		if err := ctrl.SetControllerReference(webapp, desired, r.Scheme); err != nil {
			return nil, fmt.Errorf("OwnerReferenceの設定に失敗: %w", err)
		}
		logger.Info("Deploymentを作成", "name", desired.Name)
		if err := r.Create(ctx, desired); err != nil {
			return nil, fmt.Errorf("Deploymentの作成に失敗: %w", err)
		}
		return desired, nil
	} else if err != nil {
		return nil, fmt.Errorf("Deploymentの照会に失敗: %w", err)
	}

	desired := r.buildDeployment(webapp)
	if r.deploymentNeedsUpdate(&deployment, desired) {
		deployment.Spec = desired.Spec
		logger.Info("Deploymentを更新", "name", deployment.Name)
		if err := r.Update(ctx, &deployment); err != nil {
			return nil, fmt.Errorf("Deploymentの更新に失敗: %w", err)
		}
	}

	return &deployment, nil
}

func (r *WebAppReconciler) buildDeployment(webapp *appsv1alpha1.WebApp) *appsv1.Deployment {
	replicas := int32(1)
	if webapp.Spec.Replicas != nil {
		replicas = *webapp.Spec.Replicas
	}
	port := int32(8080)
	if webapp.Spec.Port != nil {
		port = *webapp.Spec.Port
	}

	envVars := make([]corev1.EnvVar, 0, len(webapp.Spec.Env))
	for _, e := range webapp.Spec.Env {
		ev := corev1.EnvVar{Name: e.Name, Value: e.Value}
		if e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
			ev.ValueFrom = &corev1.EnvVarSource{
				SecretKeyRef: &corev1.SecretKeySelector{
					LocalObjectReference: corev1.LocalObjectReference{Name: e.ValueFrom.SecretKeyRef.Name},
					Key:                  e.ValueFrom.SecretKeyRef.Key,
				},
			}
		}
		envVars = append(envVars, ev)
	}

	return &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      webapp.Name,
			Namespace: webapp.Namespace,
			Labels: map[string]string{
				"app.kubernetes.io/name":       "webapp",
				"app.kubernetes.io/instance":   webapp.Name,
				"app.kubernetes.io/managed-by": "webapp-operator",
			},
		},
		Spec: appsv1.DeploymentSpec{
			Replicas: &replicas,
			Selector: &metav1.LabelSelector{
				MatchLabels: map[string]string{
					"app.kubernetes.io/instance": webapp.Name,
				},
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: map[string]string{
						"app.kubernetes.io/name":     "webapp",
						"app.kubernetes.io/instance": webapp.Name,
					},
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{
						{
							Name:  "webapp",
							Image: webapp.Spec.Image,
							Ports: []corev1.ContainerPort{{ContainerPort: port}},
							Env:   envVars,
						},
					},
				},
			},
		},
	}
}

func (r *WebAppReconciler) reconcileService(ctx context.Context, webapp *appsv1alpha1.WebApp) (*corev1.Service, error) {
	logger := log.FromContext(ctx)
	port := int32(8080)
	if webapp.Spec.Port != nil {
		port = *webapp.Spec.Port
	}

	var svc corev1.Service
	err := r.Get(ctx, types.NamespacedName{Name: webapp.Name, Namespace: webapp.Namespace}, &svc)

	if err != nil && errors.IsNotFound(err) {
		desired := &corev1.Service{
			ObjectMeta: metav1.ObjectMeta{
				Name:      webapp.Name,
				Namespace: webapp.Namespace,
				Labels: map[string]string{
					"app.kubernetes.io/instance":   webapp.Name,
					"app.kubernetes.io/managed-by": "webapp-operator",
				},
			},
			Spec: corev1.ServiceSpec{
				Selector: map[string]string{
					"app.kubernetes.io/instance": webapp.Name,
				},
				Ports: []corev1.ServicePort{
					{Port: port, TargetPort: intstr.FromInt32(port)},
				},
			},
		}
		if err := ctrl.SetControllerReference(webapp, desired, r.Scheme); err != nil {
			return nil, err
		}
		logger.Info("Serviceを作成", "name", desired.Name)
		if err := r.Create(ctx, desired); err != nil {
			return nil, err
		}
		return desired, nil
	} else if err != nil {
		return nil, err
	}

	return &svc, nil
}

func (r *WebAppReconciler) deploymentNeedsUpdate(current, desired *appsv1.Deployment) bool {
	if *current.Spec.Replicas != *desired.Spec.Replicas {
		return true
	}
	if current.Spec.Template.Spec.Containers[0].Image != desired.Spec.Template.Spec.Containers[0].Image {
		return true
	}
	return false
}

func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&appsv1alpha1.WebApp{}).
		Owns(&appsv1.Deployment{}).
		Owns(&corev1.Service{}).
		Complete(r)
}

ステップ4:Status Subresource管理とCondition更新

package controller

import (
	"context"

	appsv1 "k8s.io/api/apps/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/log"

	appsv1alpha1 "github.com/toolsku/webapp-operator/api/v1alpha1"
)

const (
	finalizerName = "apps.toolsku.dev/finalizer"
)

func (r *WebAppReconciler) updateStatus(ctx context.Context, webapp *appsv1alpha1.WebApp) error {
	var deployment appsv1.Deployment
	err := r.Get(ctx, types.NamespacedName{Name: webapp.Name, Namespace: webapp.Namespace}, &deployment)
	if err != nil {
		return err
	}

	availableReplicas := int32(0)
	if deployment.Status.AvailableReplicas > 0 {
		availableReplicas = deployment.Status.AvailableReplicas
	}

	if webapp.Status.AvailableReplicas == availableReplicas {
		return nil
	}

	webapp.Status.AvailableReplicas = availableReplicas
	return r.Status().Update(ctx, webapp)
}

func (r *WebAppReconciler) updateCondition(ctx context.Context, webapp *appsv1alpha1.WebApp, condType string, status metav1.ConditionStatus, reason, message string) {
	condition := metav1.Condition{
		Type:               condType,
		Status:             status,
		Reason:             reason,
		Message:            message,
		ObservedGeneration: webapp.Generation,
	}

	meta.SetStatusCondition(&webapp.Status.Conditions, condition)

	if err := r.Status().Update(ctx, webapp); err != nil {
		log.FromContext(ctx).Error(err, "Conditionの更新に失敗")
	}
}

func (r *WebAppReconciler) ensureFinalizer(ctx context.Context, webapp *appsv1alpha1.WebApp) error {
	if !containsString(webapp.Finalizers, finalizerName) {
		webapp.Finalizers = append(webapp.Finalizers, finalizerName)
		if err := r.Update(ctx, webapp); err != nil {
			return err
		}
	}
	return nil
}

func (r *WebAppReconciler) handleDeletion(ctx context.Context, webapp *appsv1alpha1.WebApp) (ctrl.Result, error) {
	logger := log.FromContext(ctx)

	if !containsString(webapp.Finalizers, finalizerName) {
		return ctrl.Result{}, nil
	}

	logger.Info("クリーンアップロジックを実行", "webapp", webapp.Name)

	if err := r.cleanupExternalResources(ctx, webapp); err != nil {
		return ctrl.Result{}, err
	}

	webapp.Finalizers = removeString(webapp.Finalizers, finalizerName)
	if err := r.Update(ctx, webapp); err != nil {
		return ctrl.Result{}, err
	}

	logger.Info("Finalizerクリーンアップ完了", "webapp", webapp.Name)
	return ctrl.Result{}, nil
}

func (r *WebAppReconciler) cleanupExternalResources(ctx context.Context, webapp *appsv1alpha1.WebApp) error {
	logger := log.FromContext(ctx)
	logger.Info("外部リソースをクリーンアップ", "webapp", webapp.Name)
	return nil
}

func containsString(slice []string, s string) bool {
	for _, item := range slice {
		if item == s {
			return true
		}
	}
	return false
}

func removeString(slice []string, s string) []string {
	var result []string
	for _, item := range slice {
		if item == s {
			continue
		}
		result = append(result, item)
	}
	return result
}

ステップ5:Admission Webhookバリデーション

package v1alpha1

import (
	"fmt"

	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/webhook"
	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

func (w *WebApp) SetupWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).
		For(w).
		Complete()
}

var _ webhook.Defaulter = &WebApp{}

func (w *WebApp) Default() {
	if w.Spec.Port == nil {
		port := int32(8080)
		w.Spec.Port = &port
	}
	if w.Spec.Replicas == nil {
		replicas := int32(1)
		w.Spec.Replicas = &replicas
	}
}

var _ webhook.Validator = &WebApp{}

func (w *WebApp) ValidateCreate() (admission.Warnings, error) {
	if err := validateWebApp(w); err != nil {
		return nil, err
	}
	return admission.Warnings{"WebApp作成後、DeploymentとServiceが自動的にデプロイされます"}, nil
}

func (w *WebApp) ValidateUpdate(old runtime.Object) error {
	oldWA := old.(*WebApp)
	if *oldWA.Spec.Replicas > 0 && *w.Spec.Replicas == 0 {
		return fmt.Errorf("replicasを0にスケールダウンすることはできません。WebAppリソースを直接削除してください")
	}
	return validateWebApp(w)
}

func (w *WebApp) ValidateDelete() error {
	return nil
}

func validateWebApp(w *WebApp) error {
	if w.Spec.Image == "" {
		return fmt.Errorf("imageは空にできません")
	}
	if w.Spec.Replicas != nil && *w.Spec.Replicas > 100 {
		return fmt.Errorf("replicasは100を超えることはできません。現在の値: %d", *w.Spec.Replicas)
	}
	if w.Spec.Port != nil {
		if *w.Spec.Port < 1 || *w.Spec.Port > 65535 {
			return fmt.Errorf("portは1-65535の間である必要があります。現在の値: %d", *w.Spec.Port)
		}
	}
	for i, env := range w.Spec.Env {
		if env.Name == "" {
			return fmt.Errorf("env[%d].nameは空にできません", i)
		}
		if env.Value == "" && env.ValueFrom == nil {
			return fmt.Errorf("env[%d]にはvalueまたはvalueFromを指定する必要があります", i)
		}
	}
	return nil
}

ステップ6:プロダクション設定—Leader Election、Graceful Shutdown、Metrics

package main

import (
	"context"
	"flag"
	"fmt"
	"os"

	"k8s.io/apimachinery/pkg/runtime"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/healthz"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
	"sigs.k8s.io/controller-runtime/pkg/webhook"

	appsv1alpha1 "github.com/toolsku/webapp-operator/api/v1alpha1"
	"github.com/toolsku/webapp-operator/internal/controller"
)

var (
	scheme   = runtime.NewScheme()
	setupLog = ctrl.Log.WithName("setup")
)

func init() {
	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
	utilruntime.Must(appsv1alpha1.AddToScheme(scheme))
}

func main() {
	var metricsAddr string
	var enableLeaderElection bool
	var probeAddr string
	var webhookPort int

	flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Metricsサーバーアドレス")
	flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "ヘルスプローブアドレス")
	flag.BoolVar(&enableLeaderElection, "leader-elect", true, "Leader Electionを有効化")
	flag.IntVar(&webhookPort, "webhook-port", 9443, "Webhookポート")
	opts := zap.Options{Development: true}
	opts.BindFlags(flag.CommandLine)
	flag.Parse()

	ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
		Scheme: scheme,
		Metrics: server.Options{
			BindAddress: metricsAddr,
		},
		WebhookServer: webhook.NewServer(webhook.Options{
			Port: webhookPort,
		}),
		HealthProbeBindAddress: probeAddr,
		LeaderElection:         enableLeaderElection,
		LeaderElectionID:       "webapp-operator.toolsku.dev",
		LeaderElectionNamespace: os.Getenv("POD_NAMESPACE"),
	})
	if err != nil {
		setupLog.Error(err, "Managerの作成に失敗")
		os.Exit(1)
	}

	if err := (&controller.WebAppReconciler{
		Client: mgr.GetClient(),
		Scheme: mgr.GetScheme(),
	}).SetupWithManager(mgr); err != nil {
		setupLog.Error(err, "Controllerのセットアップに失敗")
		os.Exit(1)
	}

	if err := (&appsv1alpha1.WebApp{}).SetupWebhookWithManager(mgr); err != nil {
		setupLog.Error(err, "Webhookのセットアップに失敗")
		os.Exit(1)
	}

	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
		setupLog.Error(err, "ヘルスチェックのセットアップに失敗")
		os.Exit(1)
	}
	if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
		setupLog.Error(err, "レディネスチェックのセットアップに失敗")
		os.Exit(1)
	}

	setupLog.Info("Operatorを起動",
		"metrics", metricsAddr,
		"probe", probeAddr,
		"leader-election", enableLeaderElection,
		"webhook-port", webhookPort,
	)

	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		setupLog.Error(err, "Managerの実行に失敗")
		os.Exit(1)
	}
}

Controller ManagerデプロイYAML:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-operator-controller-manager
  namespace: webapp-operator-system
spec:
  replicas: 2
  selector:
    matchLabels:
      control-plane: controller-manager
  template:
    metadata:
      labels:
        control-plane: controller-manager
      annotations:
        kubectl.kubernetes.io/default-container: manager
    spec:
      serviceAccountName: controller-manager
      containers:
        - name: manager
          image: toolsku/webapp-operator:v1.0.0
          args:
            - --leader-elect
            - --metrics-bind-address=:8080
            - --health-probe-bind-address=:8081
            - --webhook-port=9443
          ports:
            - containerPort: 8080
              name: metrics
            - containerPort: 8081
              name: health
            - containerPort: 9443
              name: webhook
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8081
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8081
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            limits:
              cpu: 500m
              memory: 256Mi
            requests:
              cpu: 100m
              memory: 128Mi
          env:
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          volumeMounts:
            - name: cert
              mountPath: /tmp/k8s-webhook-server/serving-certs
              readOnly: true
      volumes:
        - name: cert
          secret:
            defaultMode: 420
            secretName: webhook-server-cert
      terminationGracePeriodSeconds: 60

6つの落とし穴ガイド

落とし穴1:非冪等なReconcileによる重複リソース作成

間違ったアプローチ:

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var webapp appsv1alpha1.WebApp
	r.Get(ctx, req.NamespacedName, &webapp)

	deploy := r.buildDeployment(&webapp)
	r.Create(ctx, deploy)
	return ctrl.Result{}, nil
}

正しいアプローチ:

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var webapp appsv1alpha1.WebApp
	if err := r.Get(ctx, req.NamespacedName, &webapp); err != nil {
		if errors.IsNotFound(err) {
			return ctrl.Result{}, nil
		}
		return ctrl.Result{}, err
	}

	var existing appsv1.Deployment
	err := r.Get(ctx, types.NamespacedName{Name: webapp.Name, Namespace: webapp.Namespace}, &existing)
	if err != nil && errors.IsNotFound(err) {
		deploy := r.buildDeployment(&webapp)
		if err := ctrl.SetControllerReference(&webapp, deploy, r.Scheme); err != nil {
			return ctrl.Result{}, err
		}
		if err := r.Create(ctx, deploy); err != nil {
			return ctrl.Result{}, err
		}
	} else if err != nil {
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}

落とし穴2:Status更新とSpec更新の競合

間違ったアプローチ:

webapp.Status.AvailableReplicas = 3
if err := r.Update(ctx, webapp); err != nil {
	return ctrl.Result{}, err
}

正しいアプローチ:

webapp.Status.AvailableReplicas = 3
if err := r.Status().Update(ctx, webapp); err != nil {
	if errors.IsConflict(err) {
		return ctrl.Result{Requeue: true}, nil
	}
	return ctrl.Result{}, err
}

落とし穴3:Finalizerの不適切な処理によるTerminatingスタック

間違ったアプローチ:

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var webapp appsv1alpha1.WebApp
	r.Get(ctx, req.NamespacedName, &webapp)
	if webapp.DeletionTimestamp != nil {
		return ctrl.Result{}, nil
	}
	webapp.Finalizers = append(webapp.Finalizers, "my-finalizer")
	r.Update(ctx, &webapp)
	return ctrl.Result{}, nil
}

正しいアプローチ:

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var webapp appsv1alpha1.WebApp
	if err := r.Get(ctx, req.NamespacedName, &webapp); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	if webapp.DeletionTimestamp != nil {
		if containsString(webapp.Finalizers, finalizerName) {
			if err := r.cleanupExternalResources(ctx, &webapp); err != nil {
				return ctrl.Result{}, err
			}
			webapp.Finalizers = removeString(webapp.Finalizers, finalizerName)
			if err := r.Update(ctx, &webapp); err != nil {
				return ctrl.Result{}, err
			}
		}
		return ctrl.Result{}, nil
	}

	if !containsString(webapp.Finalizers, finalizerName) {
		webapp.Finalizers = append(webapp.Finalizers, finalizerName)
		if err := r.Update(ctx, &webapp); err != nil {
			return ctrl.Result{}, err
		}
	}

	return r.reconcileNormal(ctx, &webapp)
}

落とし穴4:OwnerReferenceの欠落による子リソースリーク

間違ったアプローチ:

deploy := &appsv1.Deployment{
	ObjectMeta: metav1.ObjectMeta{
		Name:      webapp.Name,
		Namespace: webapp.Namespace,
	},
}
r.Create(ctx, deploy)

正しいアプローチ:

deploy := &appsv1.Deployment{
	ObjectMeta: metav1.ObjectMeta{
		Name:      webapp.Name,
		Namespace: webapp.Namespace,
	},
}
if err := ctrl.SetControllerReference(&webapp, deploy, r.Scheme); err != nil {
	return ctrl.Result{}, err
}
if err := r.Create(ctx, deploy); err != nil && !errors.IsAlreadyExists(err) {
	return ctrl.Result{}, err
}

落とし穴5:ReconcileでのConflict無視によるエラー返却

間違ったアプローチ:

if err := r.Update(ctx, &webapp); err != nil {
	return ctrl.Result{}, err
}

正しいアプローチ:

if err := r.Update(ctx, &webapp); err != nil {
	if errors.IsConflict(err) {
		return ctrl.Result{Requeue: true}, nil
	}
	return ctrl.Result{}, err
}

落とし穴6:Webhookの証明書未設定によるTLSハンドシェイク失敗

間違ったアプローチ:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: webapp-validator
webhooks:
  - name: webapp.apps.toolsku.dev
    clientConfig:
      service:
        name: webhook-service
        namespace: webapp-operator-system
        path: /validate-apps-toolsku-dev-v1alpha1-webapp
    sideEffects: None
    admissionReviewVersions: ["v1"]

正しいアプローチ:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: webapp-validator
  annotations:
    cert-manager.io/inject-ca-from: webapp-operator-system/webapp-operator-serving-cert
webhooks:
  - name: webapp.apps.toolsku.dev
    clientConfig:
      service:
        name: webhook-service
        namespace: webapp-operator-system
        path: /validate-apps-toolsku-dev-v1alpha1-webapp
        port: 443
    sideEffects: None
    admissionReviewVersions: ["v1"]
    failurePolicy: Fail
    timeoutSeconds: 10
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: webapp-operator-serving-cert
  namespace: webapp-operator-system
spec:
  dnsNames:
    - webhook-service.webapp-operator-system.svc
    - webhook-service.webapp-operator-system.svc.cluster.local
  issuerRef:
    kind: Issuer
    name: webapp-operator-selfsigned-issuer
  secretName: webhook-server-cert

エラートラブルシューティングリファレンス

# エラー 原因 解決策
1 the server could not find the requested resource CRDがインストールされていない、またはAPIバージョンの不一致 kubectl apply -f config/crd/bases/を実行、apiVersionがCRDと一致するか確認
2 Operation cannot be fulfilled on webapps.apps.toolsku.dev: the object has been modified 楽観的ロックの競合、Reconcile中にオブジェクトが他のプロセスで変更された IsConflictエラーをキャッチし、ctrl.Result{Requeue: true}を返してReconcileを再実行
3 webhook server: TLS handshake error Webhook証明書が正しく設定されていない、または期限切れ cert-managerをインストールし、Certificateリソースを設定して自動発行
4 resource stuck in Terminating state Finalizerが正しく削除されていない、Controllerが停止している kubectl patch <cr> -p '{"metadata":{"finalizers":[]}}' --type=mergeで強制削除
5 no matches for kind "WebApp" in version "apps.toolsku.dev/v1alpha1" CRDのversionsserved: falseまたはstorage: false CRD定義を確認、ターゲットバージョンがserved: trueであることを確認
6 failed to call webhook: Post "https://...": x509: certificate signed by unknown authority Webhook CA証明書がValidatingWebhookConfigurationに注入されていない cert-managerのinject-ca-fromアノテーションを設定、または手動でcaBundleを設定
7 controller: Reconciler error: failed to get deployment: deployments is forbidden RBAC権限不足、ServiceAccountにAPI操作権限がない config/rbac/のRoleとRoleBindingを確認、appsグループのdeploymentsリソース権限を追加
8 leader-election: failed to renew lease Leader ElectionのLeaseオブジェクトの更新失敗、通常はネットワークまたは権限の問題 coordination.k8s.ioleasesリソース権限を確認、ネットワーク接続性を確認
9 too many open files Controller Watchの接続数がシステムのファイルディスクリプタ制限を超過 ulimit -nを増加、またはWatchリソースタイプを削減、WithEventFilterでイベントをフィルタ
10 webhook: admission webhook denied the request: replicas cannot be 0 Validating Webhookが不正なリクエストを拒否 Webhookバリデーションロジックを確認、リクエストパラメータがビジネス制約を満たすか確認

3つの高度な最適化テクニック

テクニック1:EventFilterで不要なReconcileを削減

デフォルトでは、Statusの更新がすべてReconcileをトリガーします。EventFilterを使用して不要なイベントをフィルタします:

import (
	"fmt"
	"reflect"

	"sigs.k8s.io/controller-runtime/pkg/event"
	"sigs.k8s.io/controller-runtime/pkg/predicate"
)

func WebAppPredicate() predicate.Predicate {
	return predicate.Funcs{
		UpdateFunc: func(e event.UpdateEvent) bool {
			oldObj := e.ObjectOld.(*appsv1alpha1.WebApp)
			newObj := e.ObjectNew.(*appsv1alpha1.WebApp)

			if oldObj.Generation != newObj.Generation {
				return true
			}

			if oldObj.DeletionTimestamp != newObj.DeletionTimestamp {
				return true
			}

			if !reflect.DeepEqual(oldObj.Finalizers, newObj.Finalizers) {
				return true
			}

			return false
		},
		CreateFunc: func(e event.CreateEvent) bool {
			return true
		},
		DeleteFunc: func(e event.DeleteEvent) bool {
			return true
		},
		GenericFunc: func(e event.GenericEvent) bool {
			return false
		},
	}
}

func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&appsv1alpha1.WebApp{}).
		WithEventFilter(WebAppPredicate()).
		Owns(&appsv1.Deployment{}).
		Owns(&corev1.Service{}).
		Complete(r)
}

テクニック2:Reconcile結果とRequeue戦略

RequeueAfterを適切に使用して定期的な調整を実現し、ポーリングを回避します:

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var webapp appsv1alpha1.WebApp
	if err := r.Get(ctx, req.NamespacedName, &webapp); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	if webapp.DeletionTimestamp != nil {
		return r.handleDeletion(ctx, &webapp)
	}

	if err := r.reconcileResources(ctx, &webapp); err != nil {
		return ctrl.Result{}, err
	}

	if !r.isWebAppReady(ctx, &webapp) {
		return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
	}

	return ctrl.Result{}, nil
}

func (r *WebAppReconciler) isWebAppReady(ctx context.Context, webapp *appsv1alpha1.WebApp) bool {
	var deployment appsv1.Deployment
	if err := r.Get(ctx, types.NamespacedName{Name: webapp.Name, Namespace: webapp.Namespace}, &deployment); err != nil {
		return false
	}
	return deployment.Status.ReadyReplicas == *webapp.Spec.Replicas
}

テクニック3:カスタムMetricsでControllerランタイム指標を公開

import (
	"github.com/prometheus/client_golang/prometheus"
	"sigs.k8s.io/controller-runtime/pkg/metrics"
)

var (
	reconcileTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "webapp_operator_reconcile_total",
			Help: "WebApp Operator調整総回数",
		},
		[]string{"name", "namespace", "result"},
	)

	reconcileDuration = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "webapp_operator_reconcile_duration_seconds",
			Help:    "WebApp Operator調整所要時間分布",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"name", "namespace"},
	)

	managedResources = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "webapp_operator_managed_resources",
			Help: "現在管理中のWebAppリソース数",
		},
		[]string{"namespace"},
	)
)

func init() {
	metrics.Registry.MustRegister(reconcileTotal, reconcileDuration, managedResources)
}

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	start := time.Now()
	defer func() {
		reconcileDuration.WithLabelValues(req.Name, req.Namespace).Observe(time.Since(start).Seconds())
	}()

	var webapp appsv1alpha1.WebApp
	if err := r.Get(ctx, req.NamespacedName, &webapp); err != nil {
		if errors.IsNotFound(err) {
			reconcileTotal.WithLabelValues(req.Name, req.Namespace, "not_found").Inc()
			return ctrl.Result{}, nil
		}
		reconcileTotal.WithLabelValues(req.Name, req.Namespace, "error").Inc()
		return ctrl.Result{}, err
	}

	if err := r.reconcileResources(ctx, &webapp); err != nil {
		reconcileTotal.WithLabelValues(req.Name, req.Namespace, "error").Inc()
		return ctrl.Result{}, err
	}

	reconcileTotal.WithLabelValues(req.Name, req.Namespace, "success").Inc()
	return ctrl.Result{}, nil
}

Operator開発フレームワーク比較分析

次元 raw client-go Kubebuilder Operator SDK (Go) Helm Operator
開発言語 Go Go Go/Ansible/Helm Helm
スキャフォールディング なし 完全なCLI 完全なCLI SDK内蔵
学習曲線 急峻 中程度 中程度 緩やか
コード生成 なし CRDタイプ+Webhook CRDタイプ+Webhook なし
柔軟性 最高 高い 中程度 低い
Controller複雑度 手動実装 controller-runtime controller-runtime 自動生成
Webhookサポート 手動実装 自動生成 自動生成 非対応
Leader Election 手動実装 内蔵 内蔵 内蔵
Metrics 手動実装 内蔵 内蔵 内蔵
マルチバージョンCRD 手動実装 Conversion Webhook Conversion Webhook 非対応
コミュニティエコシステム K8s公式 CNCF CNCF CNCF
ユースケース 高度なカスタマイズ 汎用Operator 迅速な開発 シンプルなアプリ
プロダクション対応 大量のラッピングが必要 すぐに使用可能 すぐに使用可能 すぐに使用可能

まとめ

Summary: CRD Operatorは「CRDを書いてControllerを実行する」ほど単純ではありません。プロダクショングレードのOperatorは6つの重要な次元に注力する必要があります:CRD Schema設計はすべてのフィールドのバリデーションルールまで正確に;Reconcile Loopは冪等でありConflictを正しく処理;Status SubresourceはSpecとStatusの更新パスを分離;Finalizerはリソースクリーンアップのデッドロックを防止;Webhookは証明書と障害ポリシーを適切に設定;プロダクション設定にはLeader Election、Graceful Shutdown、Metricsが必須。Kubebuilderは現在、柔軟性と開発効率の最適なバランスを達成する、最も推奨されるOperator開発フレームワークです。

おすすめツール

ブラウザローカルツールを無料で試す →

#Kubernetes#Operator#CRD#Controller#云原生#2026#Kubebuilder