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を書けば終わり」ではないのか
-
CRD Schema設計の罠:フィールドタイプの定義が不正確、OpenAPI v3バリデーションが欠如し、ユーザーが不正なデータを送信可能;バージョン互換性の事前計画がなく、v1からv2への移行が悪夢に
-
Reconcile Loopの冪等性:ControllerのReconcileは繰り返しトリガーされる可能性があり、ロジックが冪等でない場合、重複リソースの作成や重複操作の実行を引き起こす——これが最も一般的かつ致命的なバグ
-
Status更新の競合:複数のControllerが同じリソースのStatusを同時に更新、またはControllerがStatusを更新中にSpecが変更され、楽観的ロックの競合と更新の損失を引き起こす
-
Finalizerのデッドロック:Finalizer追加後にControllerがクラッシュまたは削除されると、リソースは永遠にTerminating状態でスタックし、削除も再作成もできない
-
イベントストーム:大規模クラスタでは、1つのCRD変更が数百の子リソースの作成/更新をトリガーし、Controllerが処理しきれずWorkqueueが蓄積
-
プロダクショングレードの欠落: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のversionsでserved: 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.ioのleasesリソース権限を確認、ネットワーク接続性を確認 |
| 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開発フレームワークです。
おすすめツール
- JSONフォーマッター - CRD定義とStatus出力のJSONデータをフォーマット
- Base64エンコーダー - Webhook証明書とSecretデータをエンコード
- ハッシュ計算ツール - ConfigMapとSecretのコンテンツハッシュを計算し、設定変更を検出
- JWTデコーダー - ServiceAccountのJWTトークンをデコードし、RBAC権限のトラブルシューティング
ブラウザローカルツールを無料で試す →