Skip to content
Published on

CRD なしのコントローラ — 既存リソースを自動化するパターン

Authors

はじめに — Operator という言葉に怯えない

Kubernetes で何かを自動化したいと思うと、多くの方がすぐに「Operator を作らねば」へ直行します。そして kubebuilder でプロジェクトを生成し、CRD を設計し、API タイプを書いていて「ここまでやる必要があるのか」という疑念に陥ります。実は多くの社内自動化に CRD はまったく要りません。

核心を先に言います。コントローラは CRD と無関係です。 コントローラは単に「あるリソースの状態を観察し、あるべき状態へ収束させるループ」です。その対象リソースが必ずしもカスタムリソースである必要はありません。ConfigMap、Secret、Namespace、Node といった Kubernetes のビルトインリソースを対象にしても、同じようにコントローラを作れます。

本記事は「CRD なしでビルトインリソースだけで自動化するコントローラ」という、意外とよく必要なのにあまり扱われないパターンを深く見ていきます。

コントローラとは何か — reconcile ループの本質

Kubernetes のすべてのコントローラは同じメンタルモデルに従います。

[観察] 現在の状態を読む (API サーバー watch)
   |
   v
[比較] あるべき状態と現在の状態の差を計算する
   |
   v
[調整] 差をなくす行為をする (生成/修正/削除)
   |
   +--> イベントが再び発生したら最初へ (冪等ループ)

ここで「あるべき状態」がどこから来るかが核心です。Operator はそのあるべき状態を、ユーザーが宣言したカスタムリソース(CR)から読みます。しかしあるべき状態が必ずしも CR である必要はありません。

  • 「特定のラベルが付いた ConfigMap はすべてのネームスペースに複製されるべき」 — この規則自体があるべき状態です。CR は不要です。
  • 「新しいネームスペースができたら、デフォルトの NetworkPolicy と ResourceQuota があるべき」 — 規則として表現されます。
  • 「GPU ノードには特定のラベルと taint があるべき」 — 同様です。

このようにあるべき状態がコードに刻まれた規約(convention)で表現できるなら、CRD は不要です。

ビルトインリソースを watch するコントローラの典型例

事例1 — ラベルベースの ConfigMap/Secret 同期

最もよくある要求です。「この ConfigMap をすべてのチームネームスペースに撒いて」。例えば共通 CA 証明書、共通レジストリ資格情報、共通設定値などがそうです。コントローラはソースに特定ラベルが付いた ConfigMap を watch し、その内容を対象ネームスペースに複製・同期します。ソースが変わればすべての複製を更新し、誰かが複製を手で変えればソース基準に戻します。

事例2 — ネームスペースのブートストラップ

新しいネームスペースが作られるたびにデフォルトのリソースセットを自動注入します。デフォルトの ResourceQuota、LimitRange、デフォルト拒否の NetworkPolicy、デフォルト ServiceAccount RBAC などです。コントローラが Namespace 生成イベントを watch し、欠けているデフォルトリソースを作ります。

事例3 — ノードのラベリング/taint 管理

特定の条件(例: インスタンスタイプ、ゾーン、GPU の有無)に応じてノードに一貫したラベルや taint を保証します。クラウドが付けるラベルを社内標準ラベルへ正規化する作業が代表的です。

この3つの共通点は、すべてビルトインリソースだけを扱い、ユーザーが宣言する新しいリソースタイプがないことです。あるべき状態はすべてコントローラコード内の規則です。

controller-runtime で CRD なしに実装する

controller-runtime は kubebuilder の基盤ライブラリですが、CRD がなくてもそのまま使えます。核心は watch 対象をビルトインタイプ(corev1.ConfigMap など)に指定することだけです。

次は「特定ラベルが付いたソース ConfigMap を対象ネームスペースに同期」するコントローラの骨格です。

package main

import (
	"context"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	"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"
)

const syncLabel = "example.com/sync"

// ConfigMapSyncReconciler はラベル付き ConfigMap を対象ネームスペースに複製する。
type ConfigMapSyncReconciler struct {
	client.Client
	TargetNamespaces []string
}

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

	var src corev1.ConfigMap
	if err := r.Get(ctx, req.NamespacedName, &src); err != nil {
		// ソースが削除された場合は無視 (必要なら複製の整理ロジックを追加)
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 同期ラベルがなければ対象ではない。
	if src.Labels[syncLabel] != "true" {
		return ctrl.Result{}, nil
	}

	for _, ns := range r.TargetNamespaces {
		if ns == src.Namespace {
			continue
		}
		desired := corev1.ConfigMap{}
		desired.Name = src.Name
		desired.Namespace = ns
		desired.Data = src.Data

		var existing corev1.ConfigMap
		key := types.NamespacedName{Namespace: ns, Name: src.Name}
		err := r.Get(ctx, key, &existing)
		switch {
		case errors.IsNotFound(err):
			if err := r.Create(ctx, &desired); err != nil {
				return ctrl.Result{}, err
			}
			l.Info("複製を生成", "namespace", ns)
		case err != nil:
			return ctrl.Result{}, err
		default:
			existing.Data = src.Data
			if err := r.Update(ctx, &existing); err != nil {
				return ctrl.Result{}, err
			}
			l.Info("複製を更新", "namespace", ns)
		}
	}
	return ctrl.Result{}, nil
}

func (r *ConfigMapSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&corev1.ConfigMap{}). // ビルトインタイプを watch — CRD 不要
		Complete(r)
}

注目すべきは1行、ビルトインの ConfigMap タイプを For(...) に渡した部分です。kubebuilder で作っていれば通常その位置に自作 CR タイプが入りますが、ここではビルトインタイプを入れただけです。CRD も、API タイプ定義も、deepcopy 生成も不要です。

main 関数でマネージャを起動する部分も標準のままです。

func main() {
	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
	if err != nil {
		panic(err)
	}

	reconciler := &ConfigMapSyncReconciler{
		Client:           mgr.GetClient(),
		TargetNamespaces: []string{"team-a", "team-b", "team-c"},
	}
	if err := reconciler.SetupWithManager(mgr); err != nil {
		panic(err)
	}

	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		panic(err)
	}
}

predicate で不要な reconcile を減らす

上のコントローラはすべての ConfigMap 変更に対して Reconcile が呼ばれます。ラベルのない ConfigMap まで全部入ってくると無駄です。predicate で watch 段階で事前にフィルタリングすると効率が大きく良くなります。

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

func (r *ConfigMapSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {
	hasSyncLabel := predicate.NewPredicateFuncs(func(obj client.Object) bool {
		return obj.GetLabels()[syncLabel] == "true"
	})

	return ctrl.NewControllerManagedBy(mgr).
		For(&corev1.ConfigMap{}, builder.WithPredicates(hasSyncLabel)).
		Complete(r)
}

こうすると同期ラベルが付いた ConfigMap だけが reconcile キューに入るので、クラスタに ConfigMap が数千あっても負担は小さいです。

Operator vs 単純コントローラ — 何が違うか

用語の整理が必要です。実務ではよく混用されますが、明確に区別すると次の通りです。

区分単純コントローラOperator
CRD の有無なし(ビルトインリソースを watch)あり(固有の CR を定義)
あるべき状態の出所コード内の規則(convention)ユーザーが宣言した CR
ユーザーインターフェースラベル/アノテーション/ネームスペースkubectl で CR を作成
適した仕事社内運用規則の強制アプリケーション運用の自動化
複雑度

核心の分岐点は「ユーザーが意図を宣言する新しい語彙が必要か」です。PostgreSQL クラスタのようにユーザーが「インスタンス3個、バックアップ毎日2時」という豊かな意図を表現せねばならないなら CR が必要で、それは Operator です。逆に「このラベルが付いたリソースはこう処理する」という固定規則ならラベルだけで十分で、それは単純コントローラです。

実務の助言: 疑わしければ CRD なしで始めてください。 ラベル/アノテーションベースで先に作り、本当にユーザーが表現すべき意図がラベルで賄えないほど豊かになったときに初めて CRD を導入する方が後悔が少ないです。

kubebuilder なしで client-go で作る(概要)

もっと軽い道もあります。controller-runtime すら使わず client-go の informer で直接コントローラを書く方式です。標準的な Kubernetes コントローラ(例: kube-controller-manager 内部)が使うパターンです。

import (
	"k8s.io/client-go/informers"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/tools/cache"
	"k8s.io/client-go/util/workqueue"
)

// 核心の構成要素
// 1. SharedInformerFactory — ビルトインリソースを watch + ローカルキャッシュ
// 2. workqueue — 処理するキーを集めるキュー (rate limit, リトライ)
// 3. EventHandler — Add/Update/Delete 時にキューへキーを追加
// 4. worker ループ — キューからキーを取り出し reconcile ロジックを実行

func setupInformer(clientset *kubernetes.Clientset, queue workqueue.RateLimitingInterface) {
	factory := informers.NewSharedInformerFactory(clientset, 0)
	cmInformer := factory.Core().V1().ConfigMaps().Informer()

	cmInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			key, _ := cache.MetaNamespaceKeyFunc(obj)
			queue.Add(key)
		},
		UpdateFunc: func(old, new interface{}) {
			key, _ := cache.MetaNamespaceKeyFunc(new)
			queue.Add(key)
		},
	})
}

この方式は依存が軽く動作原理が透明だという利点がありますが、informer/workqueue/leader election をすべて手で配線せねばなりません。ほとんどの場合 controller-runtime がこのボイラープレートをうまく抽象化してくれるので、特別な理由がなければ controller-runtime を勧めます。client-go の直接利用は「ライブラリ依存を最小化せねばならない、または動作を完全に制御せねばならない」特殊な状況に適します。

admission webhook だけで行うポリシー

自動化のもう1つの形は、コントローラではなく admission webhook です。コントローラが「すでに作られたリソースを後で調整」するなら、admission webhook は「リソースが保存される前に検証または変形」します。

  • Validating webhook: 規則に反するリソースを拒否します。例: 「すべての Pod に team ラベルがなければ拒否」。
  • Mutating webhook: リソースを保存前に自動修正します。例: 「サイドカーコンテナの自動注入」「デフォルトラベルの自動追加」。

最近は Go で webhook を直接書く代わりに、ポリシーエンジンを使うのが一般的です。Kyverno(Kubernetes ネイティブ、YAML ポリシー)や OPA Gatekeeper(Rego 言語)が代表的です。次は Kyverno ポリシーの例で、ラベルを強制します。

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-team-label
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-team-label
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "すべての Pod には team ラベルが必要です。"
        pattern:
          metadata:
            labels:
              team: "?*"

webhook(ポリシー)とコントローラは補完関係です。webhook は「間違ったものが入らないよう入口で防ぎ」、コントローラは「すでにあるものを継続的に正しい状態に保ち」ます。両方を一緒に使うと強力です。

社内自動化アイデア集

CRD なしのコントローラで解くのに向いた実際の課題です。

  • 新規ネームスペースに標準 RBAC、ResourceQuota、NetworkPolicy を自動注入
  • 共通 ImagePullSecret をすべてのネームスペースに同期
  • 期限が近い証明書を持つ Secret を watch して更新をトリガー
  • 特定ラベルが付いた Deployment に標準アノテーション(監視スクレイプ設定など)を自動付加
  • 孤児となった(所有者のない)リソースを定期的に整理
  • ノードコンディションに応じて自動 cordon/uncordon を補助

これらの共通点は「社内規約を人が毎回気にかける代わりに、コントローラに保証させる」ことです。

落とし穴 — 権限と無限ループ

CRD がなく単純に見えても、ビルトインリソースを扱うコントローラには固有の落とし穴があります。

落とし穴1 — 過度な RBAC

ビルトインリソースを扱っていると、ConfigMap、Secret、Namespace などへの広範な権限が必要になります。Secret 全体への読み取り権限は、クラスタのすべての資格情報を読めるということなので慎重に。最小権限の原則を守り、可能なら対象ネームスペースへ RBAC を絞ってください。controller-runtime を使うなら RBAC マーカーで権限を明示的に生成するのが良いです。

落とし穴2 — 無限 reconcile ループ

最もよくあり危険な落とし穴です。コントローラがリソースを修正すると、その修正がまた watch イベントを起こし、また reconcile が回り、また修正し... 無限ループになります。特に自分自身が管理するリソースを Update するときによく起きます。

防止法:

  • 修正が本当に必要なときだけ Update を呼びます。現在の状態とあるべき状態を比較し、差がなければ何もしません(冪等性)。
  • predicate で status だけが変わったイベントは無視します(generation 変化基準のフィルタなど)。
  • 自分が作った変更と外部の変更を区別するアノテーション/ハッシュを活用します。

落とし穴3 — 他のコントローラとの衝突

自分が作ったコントローラがラベルを付けるのに、別のコントローラ(または GitOps エージェント)がそのラベルを消すなら、両者は無限に争います。同じフィールドを誰が所有するかを明確にし、GitOps と一緒に使うなら ignoreDifferences でコントローラ管理フィールドを除外してください。

落とし穴4 — キャッシュ一貫性

controller-runtime の client は基本的にキャッシュから読みます。作ったばかりのリソースをすぐ Get すると、まだキャッシュにないことがあります。こうした競合を前提とし、NotFound を正常フローの一部として扱い、必要なら requeue で再試行するよう設計してください。

運用 — デプロイと観測

CRD なしのコントローラも、運用観点で気にかけることは同じです。

  • 単一インスタンスの保証: leader election をオンにして、複数レプリカが同時に同じリソースを触らないようにします。controller-runtime が標準でサポートします。
  • メトリクス: reconcile 回数、エラー率、処理遅延を公開します。controller-runtime は標準メトリクスを提供します。
  • イベント記録: 重要な調整行為を Event として残し、kubectl describe で追跡可能にします。
  • ヘルスチェック: liveness/readiness プローブを付けます。
  • 認証/認可: メトリクスエンドポイントは保護されるべきです。最新の controller-runtime は WithAuthenticationAndAuthorization でこれを処理し、別途の kube-rbac-proxy サイドカーはもはや不要です。

デプロイマニフェスト — コントローラを実際にクラスタへ乗せる

コードを書いたら今度はクラスタへデプロイせねばなりません。CRD がないので必要なのは ServiceAccount、RBAC、Deployment の3つだけです。

# 1) コントローラ用 ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
  name: cm-sync-controller
  namespace: platform-system
---
# 2) 最小権限 RBAC — ConfigMap に対してのみ、しかも必要な動詞だけ
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: cm-sync-controller
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch", "create", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: cm-sync-controller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cm-sync-controller
subjects:
  - kind: ServiceAccount
    name: cm-sync-controller
    namespace: platform-system
---
# 3) コントローラ Deployment (単一インスタンス、leader election を有効にすることを推奨)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cm-sync-controller
  namespace: platform-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cm-sync-controller
  template:
    metadata:
      labels:
        app: cm-sync-controller
    spec:
      serviceAccountName: cm-sync-controller
      containers:
        - name: controller
          image: registry.example.com/cm-sync-controller:v0.1.0
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 128Mi

注目すべきは、RBAC が ConfigMap にのみ、しかも delete なしで必要な動詞だけを付与している点です。Secret を扱わないので Secret 権限はまったくありません。これが先に強調した最小権限の具体的な姿です。権限を狭めれば、たとえコントローラが侵害されても被害範囲が限定されます。

テスト — envtest で reconcile を検証する

CRD なしのコントローラもテストが必要です。controller-runtime エコシステムの envtest は、実際のクラスタなしに偽の API サーバー(etcd + kube-apiserver バイナリ)を起動して reconcile を検証させてくれます。

// テストの大きな流れ (擬似コードレベル)
// 1. envtest でテスト用 API サーバーを起動
// 2. ソース ConfigMap をラベルとともに作成
// 3. reconciler.Reconcile を呼ぶ (またはマネージャを実行)
// 4. 対象ネームスペースに複製ができたか断言(assert)
// 5. ソース Data を変えて再度 reconcile -> 複製も更新されるか確認

func TestConfigMapSync(t *testing.T) {
	// envtest 環境を準備
	// (TestEnv 起動、scheme 登録、client 生成)

	// given: ラベルが付いたソース ConfigMap
	// when: reconcile 実行
	// then: 対象ネームスペースに同一 Data の複製が存在
}

テストで特に確認すべきは 冪等性 です。同じ入力で reconcile を2回呼んだとき、2回目の呼び出しが何の変更も作らないべきです。この性質が崩れると、運用環境で無限ループや不要な API 負荷につながります。最新の setup-envtest は Kubernetes 1.36 系のバイナリまで取得でき、実際の運用バージョンに近い環境で検証できます。

おわりに

Kubernetes 自動化において「Operator を作ろう」はしばしば重すぎる出発点です。本当にユーザーが表現する新しい意図があるときにのみ CRD が正当化されます。社内規約を強制したりビルトインリソースを世話する仕事なら、CRD なしの単純コントローラ(またはポリシーエンジン)の方がはるかに軽く保守しやすいです。

核心を再びまとめると、コントローラの本質は reconcile ループであって CRD ではありません。ビルトインリソースを watch するだけで強力な自動化を作れ、その出発点はラベル1行と controller-runtime の For() 1行です。ただし権限は最小に、ループは冪等に — この2つさえ守ればよいのです。

参考資料