CRD と Operator パターン
K8s API をユーザードメインのオブジェクトで拡張する2つの軸を扱います。CustomResourceDefinition で新しいオブジェクト種類を定義し、controller-runtime ベースの Operator が第1章で見た reconcile loop をそのオブジェクトの上に掛けて K8s の宣言的モデルをドメインまで拡張します。ownerReference・finalizer・status subresource の3つの標準パターンと Kubebuilder・Operator SDK のビルドツールまでを一連の流れで整理します。
第17章 Admission Controller で Gatekeeper の ConstraintTemplate、Kyverno の ClusterPolicy のようなオブジェクトがどれも K8s 本体の標準リソースではなく、2つのツールが CRD で定義した新しいオブジェクト種類 であるという点を一度押さえておきました。本章のテーマはその CRD 自体です。ここまで扱ったオブジェクトはすべて K8s がビルトインで持っている標準リソースでした — Pod、Deployment、Service、ConfigMap、Secret、NetworkPolicy など。しかし K8s API の本当の魅力は その上に自分のドメインのオブジェクトを追加できる という点です。PostgreSQL クラスタ、Redis マスター-replica トポロジー、Kafka ブローカーグループのようなドメイン概念が K8s の中で1級オブジェクトになり、kubectl get postgrescluster で照会され、マニフェストで宣言され、コントローラによって自動的に運用されます。この拡張の2つの軸が CustomResourceDefinition (新しいオブジェクト種類の定義) と Operator (そのオブジェクトを運用するコントローラ) です。
本章の終わりには 第1章 Kubernetes とは の reconcile loop モデルが K8s 本体の標準リソースを越えてユーザードメインオブジェクトまで拡張された形 が手に入ります。第4章 Deployment コントローラと第18章 Operator が同じパターンの別のインスタンスであるという点がその出発点です。
K8s API 拡張の2つの道 #
K8s は自分の API を拡張する道を2つ提供します。
| 道 | 特徴 |
|---|---|
| CRD (CustomResourceDefinition) | マニフェストで新しいオブジェクト種類を登録。K8s API サーバーがそのオブジェクトを etcd に保存し標準オブジェクトのように扱う |
| Aggregation Layer | 別の API サーバーを起動して K8s API サーバーがそちらへ呼び出しを委譲。より柔軟だが運用コストが大きい |
運用クラスタで圧倒的によく使われる道は CRD です。Aggregation Layer は metrics-server のように K8s コアと非常に深く連携する場合以外にはほとんど使われません。本章のテーマは CRD です。
CRD — 新しいオブジェクト種類の定義 #
CRD はそれ自体が K8s の1つのオブジェクト種類です。CRD 1枚を適用するとその瞬間からクラスタに新しいオブジェクト種類が登録され、kubectl でそのオブジェクトを作って照会できるようになります。
最も単純な CRD の例 #
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.myteam.example.com
spec:
group: myteam.example.com
scope: Namespaced
names:
plural: widgets
singular: widget
kind: Widget
shortNames: ["wg"]
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: string
enum: ["small", "medium", "large"]
replicas:
type: integer
minimum: 1
maximum: 10
required: ["size"]
status:
type: object
properties:
phase:
type: string
subresources:
status: {}この CRD を適用したら、そのクラスタで次のマニフェストが valid になります。
apiVersion: myteam.example.com/v1
kind: Widget
metadata:
name: my-first-widget
namespace: default
spec:
size: medium
replicas: 3kubectl get widgets
kubectl get wg my-first-widget -o yamlCRD 定義の核心要素 #
上のマニフェストで運用面で重要な部分をいくつか押さえます。
scope: NamespacedvsCluster— このオブジェクトがネームスペースの中に住むのか、クラスタ全域に住むのかを定めます。一度定めると変えにくいのでドメインの肌合いを見て決定します。ConfigMap・Pod は Namespaced、Node・StorageClass は Cluster。schema.openAPIV3Schema— オブジェクトの形を OpenAPI スキーマで定義します。K8s API サーバーが admission 段階でこのスキーマでマニフェストを検証します。enum、minimum、requiredのような制約をここで表現します。subresources.status: {}—statusフィールドを別の subresource として分離します。コントローラがspecを触らずにstatusだけを更新できるようにする K8s 標準パターンです。この一行が非常に重要です — 後の §「status subresource」でもう一度押さえます。versions— CRD は複数のバージョンを同時にサポートでき、1つのバージョンがstorage: trueで実際の etcd 保存形式を決定します。バージョン変更時には storage マイグレーションが必要です。
CRD だけでは足りない — Operator の役割 #
CRD を登録しただけではオブジェクトに意味がありません。Widget オブジェクトを作ってもそれ自体では etcd に保存されたマニフェストにすぎず、どんな動作も起きません。そのオブジェクトを見て実際に何かを作って運用するコントローラ が必要です。
このコントローラを K8s コミュニティでは Operator と呼びます。Operator は本質的に次の2つの部分の束です。
- CRD — 新しいドメインオブジェクトの形の定義
- Custom Controller — そのオブジェクトを見て reconcile ループを回すコード
このモデルが強力な理由は K8s の核心パラダイム — 宣言的 desired state + コントローラの reconcile loop — をユーザードメインまで拡張するからです。私たちは「PostgreSQL マスター1個 + replica 3個のクラスタが欲しい」とマニフェストで宣言するだけで、Operator が 第8章 StatefulSet、Service、PVC、ConfigMap、Secret、backup CronJob まで自動的に作って管理します。
Reconcile loop — コントローラの本質 #
K8s コントローラの核心パターンは無限ループです。第1章 で図で見た reconcile loop の正確な形です。
loop forever:
observed_state = K8s APIから実際の状態を照会
desired_state = マニフェストから望む状態を読む
if observed_state != desired_state:
K8s API呼び出しで差を狭める
sleep until 次のトリガーこの単純なループがビルトインコントローラ (第4章 Deployment / ReplicaSet、StatefulSet、Job など) とユーザー定義 Operator の共通モデルです。Operator を書くということは 「自分のドメインオブジェクトに対してこの reconcile 関数をどう実装するか」 を定める仕事です。第4章で Deployment コントローラの reconcile が ReplicaSet と Pod の差を埋める様子を見たなら、本章の Operator は同じパターンを Widget のようなユーザー定義オブジェクトに適用します。
controller-runtime — Operator の標準骨格 #
最初から K8s API client を直接呼び出して reconcile ループを書くことは可能ですがボイラープレートが非常に大きいです。controller-runtime は Kubernetes プロジェクト自体が管理する Go ライブラリで、Operator の骨格を標準化したツールです。Kubebuilder と Operator SDK がこの controller-runtime を包んだ上位ツールです。
Reconciler の形 #
controller-runtime ベースの Operator はほぼ次の形を持ちます。
package controller
import (
"context"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
myteamv1 "myteam.example.com/api/v1"
)
type WidgetReconciler struct {
client.Client
}
func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
// 1. desired state — Widgetオブジェクトを読む
var widget myteamv1.Widget
if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}
// 2. observed state — Widgetが作るべきDeploymentを読む
var deploy appsv1.Deployment
err := r.Get(ctx, client.ObjectKey{
Namespace: widget.Namespace,
Name: widget.Name,
}, &deploy)
if err != nil && client.IgnoreNotFound(err) == nil {
// なければ作る
newDeploy := buildDeployment(&widget)
if err := r.Create(ctx, newDeploy); err != nil {
return reconcile.Result{}, err
}
} else if err == nil {
// あればdesired stateと比較後に更新
if needsUpdate(&deploy, &widget) {
updateDeployment(&deploy, &widget)
if err := r.Update(ctx, &deploy); err != nil {
return reconcile.Result{}, err
}
}
}
// 3. status更新
widget.Status.Phase = "Ready"
if err := r.Status().Update(ctx, &widget); err != nil {
return reconcile.Result{}, err
}
return reconcile.Result{}, nil
}この関数が Widget オブジェクト1個につき一度ずつ (または変更があるたびに) 呼び出されます。controller-runtime が watch / queue / retry / leader election のような基盤インフラをすべて処理してくれるので、Operator 開発者は上の reconcile ロジックにだけ集中すればよいです。
運用の3つの標準パターン #
Operator を書くときほぼ常に出会う3つのパターンがあります。K8s API の標準メカニズムなのでよく知っておくとずっと役立ちます。
1. ownerReference — 子オブジェクトの自動整理 #
Widget が Deployment を作ったなら、Widget が削除されるときその Deployment も一緒に削除されるべきです。いちいちコードで処理せずに K8s の garbage collector に自動的に処理させる メカニズムが ownerReference です。第4章 で Deployment が ReplicaSet を、ReplicaSet が Pod を ownerReference でつないだモデルがそのままユーザードメインに適用されます。
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: widget.Name,
Namespace: widget.Namespace,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(&widget, myteamv1.GroupVersion.WithKind("Widget")),
},
},
Spec: ...
}この ownerReference が付着した Deployment は親 Widget が削除される瞬間 K8s の garbage collector によって自動的に削除されます。明示的な削除ロジックを Operator コードに入れる必要がありません。
2. finalizer — 外部リソース整理の hook #
オブジェクトが削除されるとき K8s 内の子オブジェクトは ownerReference で自動整理されますが、K8s 外のリソース はそうではありません。クラウド LB、S3 バケット、RDS インスタンスのような外部リソースを作った Operator なら、オブジェクト削除時にその外部リソースも一緒に整理しなければなりません。この hook を掛けるメカニズムが finalizer です。
finalizer が登録されたオブジェクトを削除しようとすると K8s API サーバーはオブジェクトをすぐには消さず metadata.deletionTimestamp フィールドだけを埋めます。そのオブジェクトは「削除進行中」状態になり、finalizer リストが空になるまで etcd に残ります。
const widgetFinalizer = "widget.myteam.example.com/finalizer"
func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
var widget myteamv1.Widget
if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}
// 削除進行中か
if !widget.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
// 外部リソースの整理
if err := cleanupExternalResources(&widget); err != nil {
return reconcile.Result{}, err
}
// 整理が終わったらfinalizerを除去 → K8sがオブジェクトを本当に削除
controllerutil.RemoveFinalizer(&widget, widgetFinalizer)
if err := r.Update(ctx, &widget); err != nil {
return reconcile.Result{}, err
}
}
return reconcile.Result{}, nil
}
// 削除中でなければfinalizerを保証
if !controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
controllerutil.AddFinalizer(&widget, widgetFinalizer)
if err := r.Update(ctx, &widget); err != nil {
return reconcile.Result{}, err
}
}
// 一般reconcile進行
...
}finalizer のあるオブジェクトは外部リソースの整理が終わるまで本当には削除されないので、運用者がオブジェクトを強制的に消そうとしてもクラウドリソースが取り残されません。
3. status subresource — spec と status の分離 #
CRD に subresources.status: {} を書いた一行の意味がここで完成します。この subresource のある CRD は次の2つの動作が分離されます。
- ユーザー (または GitOps ツール) は
specだけを修正。statusの修正は無視される。 - コントローラは
r.Status().Update()でstatusだけを修正します。specは触りません。
この分離が重要な理由は GitOps との衝突を防ぐからです。 第20章 GitOps で扱う ArgoCD が git のマニフェストをクラスタに同期するとき、status フィールドはコントローラが埋める値なので git にはありません。status subresource が分離されていれば ArgoCD は status を見ずに spec だけを比較するので、コントローラが status を更新しても ArgoCD が「drift」と認識しません。
Operator ビルドツール — Kubebuilder vs Operator SDK #
controller-runtime を直接使うよりさらに上位の抽象化を提供するツールが2つあります。
| ツール | 特徴 |
|---|---|
| Kubebuilder | Kubernetes プロジェクト公式ツール。CRD scaffolding、Makefile、kustomize 統合。controller-runtime を最も直接的に包む |
| Operator SDK | Red Hat のツール。Kubebuilder をベースにする + Helm / Ansible ベースの Operator オプションを追加 |
骨格を作って reconcile 関数だけを埋める流れは2つのツールがほぼ同じです。Go で直接書く本格的な Operator は Kubebuilder がより標準に近く、Helm チャートから始めて Operator へ移るマイグレーションシナリオは Operator SDK が滑らかです。
kubebuilder init --domain example.com --repo github.com/myteam/widget-operator
kubebuilder create api --group myteam --version v1 --kind Widgetこの2つのコマンドで CRD 定義、controller 骨格、マニフェスト、Dockerfile、Makefile がすべて生成されます。その後 controller.go の Reconcile 関数を埋めるのが実際の作業の本体です。
運用でよく出会う Operator #
本章のモデルを知れば運用クラスタにすでに入っている様々な Operator の意図と動作が一行で読めます。よく出会う事例を短く押さえておきます。
- cert-manager — 第10章 Ingress で押さえた証明書自動発行ツール。
Certificate、Issuer、ClusterIssuerという CRD を定義し、Let’s Encrypt との ACME チャレンジの流れを reconcile で自動化します。 - AWS Load Balancer Controller — 第10章 の ALB Ingress Controller も Operator です。
Ingressオブジェクトと AWS ALB の一貫性を reconcile で維持します。 - External Secrets Operator — 第29章 シークレット運用 で扱うツール。
ExternalSecretCRD が AWS Secrets Manager・Vault・GCP SM の値を K8s Secret へ同期します。 - CloudNativePG・Zalando Postgres Operator — 第8章 StatefulSet の上に載って PostgreSQL クラスタのセットアップ・バックアップ・フェイルオーバーを自動化します。DB のような状態性ワークロードの運用自動化で Operator の価値が最も大きい領域です。
- Karpenter — 第13章 オートスケーリング の EKS ノード自動化ツール。
NodePool、NodeClassCRD の上に reconcile を回してノードを動的に起動して整理します。
運用クラスタのマニフェストディレクトリで apiVersion: postgres-operator.crunchydata.com/v1beta1 のような見慣れない API グループに出会ったら、それはほぼ常にどれかの Operator が定義した CRD です。本章のモデルで一行で読めます — 「この CRD はどの Operator が提供し、その Operator の reconcile がどんなリソースたちを作って管理するか」。
いつ Operator を書くべきか #
CRD と Operator は強力なツールですがすべてのドメインオブジェクトに必要なわけではありません。次の条件が集まるとき Operator の価値が大きいです。
- 状態のあるワークロードの運用自動化 — DB クラスタのセットアップ、バックアップ、フェイルオーバー、アップグレードのように人が定期的に回していた手順
- 複数の K8s オブジェクトの束を1つのドメインオブジェクトに抽象化 — Deployment + Service + Ingress + PDB + HPA + ServiceMonitor などを束ねて1つのオブジェクトに
- 外部リソースと K8s オブジェクトの一貫性の維持 — クラウド LB、DNS レコードのようなものを K8s オブジェクトと連携させて自動同期
- ドメイン知識のコード化 — 「PostgreSQL primary が死んだら最も同期 lag が小さい replica を promote」のような運用ノウハウ
逆に、Helm チャート1枚で終わる単純な束 なら Operator を書く必要はありません。Operator はコード保守コストが大きいツールなので、本当に自動化の価値が大きいところにだけ導入するのがよいです。
練習問題 #
- 自分のクラスタにインストールされた CRD を確認します (
kubectl get crd)。標準 K8s オブジェクト (Pod、Service など) のほかにどんな CRD たちがあるかを表に整理し、各 CRD がどの Operator が定義したものか (cert-manager、ALB Controller、Karpenter、ArgoCD など) をマッピングします。その Operator の reconcile がどんなリソースたちを作って管理するかを §「運用でよく出会う Operator」のモデルで一段落ずつ整理します。 - 本文の
widget-crd.yamlをそのまま適用した後、Widgetオブジェクト1個を作ってみます。CRD は登録されていますがそれを見るコントローラがないのでオブジェクトだけが etcd に保存され何の動作も起きません。この「オブジェクトはあるのにコントローラがない」状態が §「CRD だけでは足りない」のモデルとどう接続されるかを自分の表現で一段落で整理します。 status subresourceのある CRD とない CRD の違いをシミュレーションで書いてみます。ArgoCD が git マニフェストをクラスタに同期するとき、status フィールドがコントローラによって更新されている間 git にはその値がない状態です。status subresource が分離されているときとそうでないときで ArgoCD の drift 検出がどう違って動作するかを 第20章 GitOps と接続して一段落で整理します。
一行まとめ: CRD が K8s API を新しいオブジェクト種類で拡張し、controller-runtime ベースの Operator がそのオブジェクトの上に 第1章 の reconcile loop を掛けて K8s の宣言的モデルをユーザードメインまで引き込む。運用の3つの標準パターンは ownerReference (子の自動整理)・finalizer (外部リソース整理 hook)・status subresource (GitOps 衝突防止) だ。cert-manager・External Secrets・CloudNativePG・Karpenter のような運用クラスタの核心ツールがどれもこのパターンの上に立っている。
次の章 #
本章まで K8s API 自体の深さ — CNI・RBAC・Admission・CRD — を辿ってきました。これだけが K8s オブジェクトモデルの拡張メカニズムです。次の章は視点を一段移します — このすべてのコンポーネントが回るクラスタをどう観測するか です。
第19章 可観測性 ではクラスタとワークロードのメトリクス・ログ・トレースの3つの次元を扱います。Prometheus + Grafana のメトリクススタック、Loki のログ集計、OpenTelemetry の分散トレース、そしてその上に載る kube-state-metrics・node_exporter のような標準エクスポータまでを一連の流れで整理します。第11章 resources.requests / limits・第12章 Health check・第13章 オートスケーリング のシグナルがすべてこの可観測性スタックを経て運用者に届くという点が本章群の最後の接点になります。