RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

Kubernetes Operator 入門の入門

RevComm Advent Calendar 2025 2日目です。

qiita.com

AI Div. Research Group で機械学習のサービスの開発・運用(+MLOps)を行うチームでエンジニアリングマネージャーをしている高橋です。我々のチームは、EKSで音声解析や自然言語処理に関するサービスを提供したり、MLモデルの改善サイクルを回すための仕組みの構築を行っています。現在、あるプロジェクトでKubernetes の持つ宣言的定義でシンプルに管理しつつ、動的に状態を管理することができる Kubernetes Operator を活用して、推論プラットフォームを発展させる活動を行っています。

この記事は Kubernetes Operator の入門のための記事です。Kubernetes Operator に関しては、kubebuilder-training など素晴らしいサイトがありますが、この記事ではさらにその入門として、Kubernetes の基本的な話も含めて解説しています。Kubernetes の基本概念に自信がない人は、この記事を読むことで kubebuilder-training をスラスラ進みやすくなれば良いなと思っています。逆にいうと、kubebuilder-training を読める方はそちらをみていただければこの記事を読む意味はあまりないと思います。

Operator に初めて触れると、

  • Reconcile, Controller とは何なのか
  • どういう仕組みで Operator が動作するのか

といった点が把握しにくいことがあります。

この記事では、

まず Kubernetes 自体の Reconciliation (調整) を丁寧に理解し、その延長として Operator を自然に理解できる流れ

を重視しています。


1. Operator とは何か?

Operator は、

「Kubernetes に自作の調整ロジック(コントローラー)を追加する仕組み」

です。

Kubernetes には標準で多くのコントローラーが存在しており、ユーザーが宣言した状態にクラスタを自動で寄せてくれます。

例:Deployment(replicas=3)

  • Pod が2つ → Controller が1つ増やす
  • Pod が4つ → Controller が1つ減らす
  • 最終的に3つに“収束”する

Operator は、この「自動調整」の仕組みを自作のリソースでも実現するための手段です。

*後で書くように厳密には、Deployment のコントローラーが直接 Pod の数を調整するのではなく、Deployment は ReplicaSet を管理し、ReplicaSet のコントローラーが Pod の管理を行います。


2. リソースとコントローラー ― Kubernetes の基本概念

Operator を理解するには、Kubernetes の根本アイデアである リソースコントローラー を押さえる必要があります。

2.1 リソースとは

Kubernetes のクラスタ上の情報はすべて「リソース」という形で管理されています。

  • Pod
  • Deployment
  • Service
  • ConfigMap
  • Node

など、 kubectl get で一覧できるものはすべてリソースです。

Operator が扱う CR(Custom Resource)も、この仲間に加わるだけ です。

2.2 コントローラーとは

コントローラーは次のようなプログラムです:

リソースの状態を監視し、クラスタを“望ましい状態”へ調整する

Deployment Controller を例にすると:

  1. Deployment を監視
  2. Pod の状態を読み取り
  3. 差分があれば調整
  4. 何度でも実行される(Reconcile Loop)

この仕組みが Kubernetes 全体を構成しています。


3. API Server とは何か?

「API Server」は Kubernetes の中心にある“窓口”ですが、初心者には突然登場するため混乱しやすいポイントです。

そこで kubectl との関係から説明 します。

3.1 kubectl は常に API Server を叩いている

  • kubectl apply
  • kubectl get
  • kubectl describe

これらすべては API Server に対する HTTP(S) リクエストです。

つまり、

kubectl は API Server と会話しているだけ

です。

3.2 Controller も同じ構造

Controller も基本的に次の2つのことを実行します。

  • API Server からリソースを読む
  • API Server にリソース作成/更新/削除を依頼する

kubectl も Controller も、API Server を通じてクラスタ状態を操作している。

*厳密には、Controller が直接 API Server へリクエストを大量に送るということはなく、キャッシュなどを行うアクセスを最適化する Informer からデータを取得するが、Informer のデータソースも結局 API Server になっています。メンタルモデルとしては、Controller は API Server で読み書きをしているイメージがシンプルで分かりやすいですが、実際には Informer などで最適化されています。


4. Reconcile Loop ― Controller のコア

すべての Controller(標準のものも Operator も)は Reconcile Loop の思想で動きます。

4.1 Reconcile が担当するのは3つだけ

  1. あるべき状態(ユーザーの宣言)を読む
  2. 現在の状態(クラスタの実情)を読む
  3. 差分を埋める

Deployment Controller も ReplicaSet Controller も、Operator の Reconcile も同じです。

4.2 冪等性が重要

Kubernetes では Reconcile が何度も呼ばれます:

  • イベントの重複
  • キャッシュ更新
  • 再試行(Retry)

そのため次のような「安全な書き方」が重要です。

  • 存在しないときだけ作成
  • 必要なときだけ更新
  • 同じ状態なら何もしない

5. Operator の構造理解のための Deployment Controller

Deployment Controller の流れは次のとおりです:

  1. Deployment(望ましい状態)を読む
  2. ReplicaSet を読む
  3. Pod を読む
  4. 差分があれば調整
  5. 収束まで繰り返す

Operator はこれを自作の CR で行うだけです。

  • Deployment → 自作の CR
  • Deployment Controller → 自作の Controller

構造は全く同じです。


6. 手元で動かせるクラスタ: kind(Kubernetes in Docker)

Operator を学ぶうえで「手元で動かす」ことは非常に重要です。

今回は kind を使います。

6.1 インストール

https://kind.sigs.k8s.io/docs/user/quick-start/#installation を参考にインストールしてください

6.2 クラスタ作成

kind create cluster --name operator-demo

確認:

kubectl get nodes

Node が表示されれば準備完了です。


7. Kubebuilder プロジェクトの作成

7.1 プロジェクト作成

mkdir operator-demo
cd operator-demo
kubebuilder init --domain example.com --repo example.com/operator-demo

生成物:

.
├── cmd
├── config
│     ├── default
│     ├── manager
│     ├── network-policy
│     ├── prometheus
│     └── rbac
├── Dockerfile
├── go.mod
├── go.sum
├── hack
├── internal
│   └── controller # ロジックのコア部分
├── Makefile
├── PROJECT
├── README.md
└── test

7.2 CR と Controller の生成

kubebuilder create api \
  --group demo \
  --version v1alpha1 \
  --kind Sample

Create Resource [y/n] , Create Controller [y/n] と聞かれるので y で進んでください。

増えるファイル:

api/v1alpha1/sample_types.go 
api/v1alpha1/groupversion_info.go 
internal/controller/suite_test.go 
internal/controller/sample_controller.go 
internal/controller/sample_controller_test.go 

8. Operator ミニマム実装: ConfigMap 同期 Operator

CR の内容に応じて ConfigMap を作成・更新する

単に「ConfigMap を作るだけ」では Reconcile の本質ががわかりにくいので、

  • CR の spec.message を読む
  • ConfigMap に message を書き込む
  • 差分がある場合にのみ更新する
  • 同じであれば何もしない(冪等性)

という“Reconcile そのもの”がわかりやすく、かつミニマムな例にしています。


8.1 CR の Spec に message を追加

api/v1alpha1/sample_types.go の SampleSpec にフィールドを追加:

type SampleSpec struct {
    Message string `json:"message,omitempty"`
}

サンプル CR: config/samples/demo_v1alpha1_sample.yaml

apiVersion: demo.example.com/v1alpha1
kind: Sample
metadata:
  labels:
    app.kubernetes.io/name: operator-demo
    app.kubernetes.io/managed-by: kustomize
  name: sample-sample
spec:
  message: "hello world"

8.2 Reconcile の本体(差分判定・更新・冪等性)

operator-demo/internal/controller/sample_controller.go

import (
  ...
    corev1 "k8s.io/api/core/v1"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    ...
)

...

func (r *SampleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

    // CR を取得(あるべき状態)
    var cr demov1alpha1.Sample
    if err := r.Get(ctx, req.NamespacedName, &cr); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // 望ましい ConfigMap(desired)
    desired := corev1.ConfigMap{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cr.Name + "-config",
            Namespace: cr.Namespace,
        },
        Data: map[string]string{
            "message": cr.Spec.Message,
        },
    }

    // 現在の ConfigMap(current)
    var current corev1.ConfigMap
    err := r.Get(ctx, client.ObjectKeyFromObject(&desired), &current)

    // 存在しない → 作る
    if apierrors.IsNotFound(err) {
        if err := r.Create(ctx, &desired); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{}, nil
    }
    if err != nil {
        return ctrl.Result{}, err
    }

    // 差分チェック → message が違う場合のみ更新
    if current.Data["message"] != cr.Spec.Message {
        current.Data["message"] = cr.Spec.Message
        if err := r.Update(ctx, &current); err != nil {
            return ctrl.Result{}, err
        }
    }

    // 差分なし → 何もしない(冪等)
    return ctrl.Result{}, nil
}

Reconcile の本質の復習

ここで、Reconcile の以下の点を実感できるはずです。

  • “あるべき状態” を CR から取得
  • “現在の状態” をクラスタから取得
  • 差分があれば調整
  • 同じなら何もしない

これは Kubernetes Controller が日常的に行っている動作と同じです。


9. ローカルで動かすコントローラー

9.1 make install

次をクラスタへ適用します:

  • CR の定義(Customer Resource Definition: CRD)

config/crd の内容が適用されます。

クラスタに「新しいリソース」が使えるようにするフェーズ

9.2 make run

  • main.go をビルド
  • コントローラーをローカルプロセスとして起動

この実行方法の場合、Operator 自体はクラスタ内で動かない(ローカルで動く)

ローカルテストでない場合は、クラスタ内にデプロイします (make deploy)が、この記事では扱いません


10. 動作確認

CR を適用します:

kubectl apply -f config/samples/demo_v1alpha1_sample.yaml

ConfigMap を確認:

kubectl get configmap
kubectl get configmap sample-sample-config -o yaml

次のような表示になっていれば成功です! hello world が確認できているところがポイントです。

apiVersion: v1
data:
  message: hello world
kind: ConfigMap
metadata:
  creationTimestamp: "2025-11-28T01:53:35Z"
  name: sample-sample-config
  namespace: default
  resourceVersion: "3604"
  uid: 680ec943-8527-4044-a8d6-b22c4020ccb6

次に CR を変更してみます:

kubectl patch sample/sample-sample --type merge -p '{"spec":{"message":"updated"}}'

ConfigMap の内容が自動で更新されます。確認してみましょう。

% kubectl get configmap sample-sample-config -o yaml
apiVersion: v1
data:
  message: updated
kind: ConfigMap
metadata:
  creationTimestamp: "2025-11-28T01:53:35Z"
  name: sample-sample-config
  namespace: default
  resourceVersion: "3761"
  uid: 680ec943-8527-4044-a8d6-b22c4020ccb6

updated に更新されています。逆に、ConfigMap の方を直接編集してみましょう。

kubectl edit configmap sample-sample-config

を実行して、 data.message を次のように更新します。

data:
  message: fixed_directly

どうなるでしょうか?

% kubectl get configmap sample-sample-config -o yaml
apiVersion: v1
data:
  message: fixed_directly
kind: ConfigMap
metadata:
  creationTimestamp: "2025-11-28T01:53:35Z"
  name: sample-sample-config
  namespace: default
  resourceVersion: "3881"
  uid: 680ec943-8527-4044-a8d6-b22c4020ccb6

変化がありません。予想通りでしたでしょうか?これは、現状の設定だと Reconcile が実行されれば CR の内容に上書きされますが、ConfigMap の編集では Reconcile がトリガーされていない、と説明できます。Controller が Watch していないリソースの変更は検知されない 点は重要なので、頭に入れておきましょう。ここでは、 OwnerReference を設定して、SetupWithManager を次のように更新することでリソースの変更を検知できるようにします。

internal/controller/sample_controller.go

// SetupWithManager sets up the controller with the Manager.
func (r *SampleReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&demov1alpha1.Sample{}).
        Named("sample").
        Owns(&corev1.ConfigMap{}). // これを追加
        Complete(r)
}

また、ConfigMap にオーナーへの参照 (OwnerReference) を設定する必要があります。internal/controller/sample_controller.goReconcile 関数の中身を少し修正します。

   // 望ましい ConfigMap(desired)
    desired := corev1.ConfigMap{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cr.Name + "-config",
            Namespace: cr.Namespace,
        },
        Data: map[string]string{
            "message": cr.Spec.Message,
        },
    }

    // [NEW] OwnerReference を設定
    if err := ctrl.SetControllerReference(&cr, &desired, r.Scheme); err != nil {
        return ctrl.Result{}, err
    }

    // 現在の ConfigMap(current)
    var current corev1.ConfigMap
    err := r.Get(ctx, client.ObjectKeyFromObject(&desired), &current)

編集後、 make run を止めてコントローラを止めてください。また、

kubectl delete configmap sample-sample-config

で一度 ConfigMap を削除してください。その後、再度 make run を実行します。そうすると、前回最後に CR 側で更新した updated の値になっています。

% kubectl get configmap sample-sample-config -o yaml
apiVersion: v1
data:
  message: updated
kind: ConfigMap
metadata:
  creationTimestamp: "2025-11-28T02:07:38Z"
  name: sample-sample-config
  namespace: default
  ownerReferences:
  - apiVersion: demo.example.com/v1alpha1
    blockOwnerDeletion: true
    controller: true
    kind: Sample
    name: sample-sample
    uid: 323bdd14-45ff-4e98-806a-4f82086616b0
  resourceVersion: "4695"
  uid: d41f188f-c701-49d1-895e-9b86e63864e7

また、今回は ownerReferences が追加されています。この状態で、先ほどと同様に ConfigMap を書き換えてみましょう。

kubectl edit configmap sample-sample-config

を実行して、 data.message を次のように更新します。

data:
  message: fixed_directly

どうなるでしょうか?

% kubectl get configmap sample-sample-config -o yaml
apiVersion: v1
data:
  message: updated
kind: ConfigMap
metadata:
  creationTimestamp: "2025-11-28T02:07:38Z"
  name: sample-sample-config
  namespace: default
  ownerReferences:
  - apiVersion: demo.example.com/v1alpha1
    blockOwnerDeletion: true
    controller: true
    kind: Sample
    name: sample-sample
    uid: 323bdd14-45ff-4e98-806a-4f82086616b0
  resourceVersion: "4940"
  uid: d41f188f-c701-49d1-895e-9b86e63864e7

今度は CR の値に上書きされました。OwnerReference が設定されている ConfigMap を更新したことで Reconcile がトリガーされ、値が更新されたわけです。

(ちなみに、kubectl delete sample/sample-sample とすると、作成されていた ConfigMap も消えていることもわかります。OwnerReference はこうしたトリガーのためだけのものではなく、ガベージコレクション の際にも利用されます。)

CRで宣言的に定義した内容が、Reconcile Loop によって実現されていく様子を見ることができました。最後の方ではいつトリガーされるか、という点に関しても修正してみました。基本的には、Reconcile をコアロジックとしていつトリガーするかを工夫することで多くのことが実現でき、シンプルに管理できるという点を何となく感じ取れたかと思います。


11. まとめ

この記事で押さえたポイント

  • Operator のコアは 自作のコントローラー
  • Operator のコアロジックは Reconciliation (調整)
  • Reconcile 処理で、あるべき状態と現在の状態の差分を埋める(← 冪等性が重要)

Operator 開発は、ハードルが高そうに見えますが、kubebuilder を活用し、kind を使うことで簡単に試したり、遊んだりすることができます。Kubernetes の “宣言的な自動調整” の思想を自分で実装できるので色々試して実運用に活かしたいものです。より実践的な例や他のトピックが気になる方は、kubebuilder-training を手を動かして見るのが個人的にはおすすめです。