Skip to content
Published on

RustでKubernetes Operatorを作る(kube-rs)

Authors

はじめに — 運用知識をコードに

Kubernetesを学び始めると、Deployment、Service、ConfigMapといった基本リソースでアプリをデプロイします。しかし実際の運用ではそれ以上が必要です。データベースを一つきちんと運用するには、バックアップを仕込み、障害時にはフェイルオーバーし、スキーマを移行し、ストレージが埋まれば拡張しなければなりません。こうした手順はたいてい誰かの頭の中(runbook)にあります。

Operatorとは、まさにこの運用知識をコードに移したものです。人がクラスターを見守りながら「状態がAだがBであるべきだから、こう直す」を繰り返すように、オペレーターはコントローラープログラムとして同じことを自動で行います。2016年にCoreOSがこの概念を確立し、今ではPrometheus、Cert-Manager、各種データベースがすべてオペレーターで管理されています。

本記事ではOperatorパターンの原理を押さえ、Rustエコシステムのkube-rsで実際にオペレーターを作る方法を扱います。そして、なぜこの領域でRustが魅力的な選択なのかも語ります。

Operatorパターン — 三つのピース

オペレーターは概念的に三つの部分から成ります。

  • CRD(Custom Resource Definition): Kubernetesに新しい種類のリソースを登録するスキーマです。組み込みリソースがPodやServiceだとすれば、CRDによって独自のリソース(例: DatabaseやBackup)をAPIサーバーに追加できます。CRDを登録すると、ユーザーはそのリソースをYAMLで宣言し、kubectl get databaseのように扱えるようになります。
  • カスタムリソース(CR): CRDが定義したスキーマに沿って実際に作ったインスタンスです。「名前はmy-db、レプリカは3、ストレージは10Gi」といったユーザーの意図(desired state、望ましい状態)を表します。
  • コントローラー(controller): カスタムリソースを監視し、実際の状態(actual state)を望ましい状態へ合わせていくプログラムです。この合わせていく過程が核心であり、reconcile(調整)と呼ばれます。

この構造の根底には、Kubernetes全体を貫く考え方があります。すなわち宣言的(declarative)な制御です。ユーザーは「何を望むか」だけを宣言し、コントローラーが「どうやってそこへ到達するか」を担います。

reconcileループ — オペレーターの心臓

コントローラーの核心はreconcileループです。その動作原理は驚くほど単純です。

  1. 望ましい状態(spec)を読む
  2. 実際の状態を観測する
  3. 両者の差を計算する
  4. 実際の状態を望ましい状態へ一歩進める
  5. 1に戻る(または一定時間後に再実行)

ここで重要な原則が二つあります。

第一に、reconcileは**冪等(idempotent)**でなければなりません。同じ入力で何度実行しても結果が同じである必要があります。reconcile関数は「作れ」ではなく「この状態が保たれるよう保証せよ」として書きます。すでに望ましい状態なら何もせず、足りなければ埋める、それだけです。

第二に、reconcileは**レベル駆動(level-triggered)**であり、エッジ駆動(edge-triggered)ではありません。「何のイベントが起きたか(エッジ)」ではなく「今の状態がどうか(レベル)」を見て判断します。このおかげで、イベントをいくつか取りこぼしても、コントローラーが再起動しても、現在の状態だけを見て正しく収束します。オペレーターの堅牢さはまさにこの性質から来ます。

kube-rs — RustからKubernetesを扱う

kube-rsは、RustでKubernetesのクライアントとコントローラーを作るための事実上の標準クレートです。大きく三つの部分で構成されます。

  • kube::Client — APIサーバーと通信するクライアント
  • kube::Api — 特定の種類のリソースへの型安全なアクセス(get、list、patchなど)
  • kube::runtimeControllerwatcherreflectorなど、コントローラーを書くのに必要な上位ツール

依存関係はおおよそ次のように書きます。実際のバージョンはcrates.ioで最新を確認してください。

[dependencies]
kube = { version = "0.99", features = ["runtime", "derive", "client"] }
k8s-openapi = { version = "0.24", features = ["latest"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
tokio = { version = "1", features = ["full"] }
thiserror = "2"
futures = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"

k8s-openapiはPodやDeploymentといった組み込みリソース型をRustの構造体として提供し、schemarsはCRDのスキーマ(JSON Schema)を自動生成するのに使います。

CustomResource導出 — CRDをコードで定義する

kube-rsの最も優雅な点は、CRDをRustの構造体として定義することです。CustomResource導出マクロを付けると、一つのspec構造体から完全なカスタムリソース型とCRD定義が生成されます。

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// 我々が管理するアプリケーションの「望ましい状態」
#[derive(CustomResource, Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[kube(
    group = "example.com",
    version = "v1",
    kind = "WebApp",
    namespaced,
    status = "WebAppStatus",
    shortname = "wa"
)]
pub struct WebAppSpec {
    /// 実行するコンテナイメージ
    pub image: String,
    /// 望ましいレプリカ数
    pub replicas: i32,
}

/// コントローラーが埋め込む「観測された状態」
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct WebAppStatus {
    pub available_replicas: i32,
    pub ready: bool,
}

このマクロ一つがいくつもの仕事をします。WebAppという型が生まれ(WebAppSpecspecフィールドに包んだ形)、WebApp::crd()を呼ぶとクラスターに登録するCRDマニフェストが得られます。statusサブリソースはspecと分離されるため、コントローラーがstatusを更新してもユーザーのspecと衝突しません。

生成されたCRDをYAMLとして出力すると、おおよそ次のような姿になります。この中には中括弧や山括弧といったスキーマ表現が含まれるため、プローズ(本文)ではなく必ずコードブロック内に置きます。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: webapps.example.com
spec:
  group: example.com
  names:
    kind: WebApp
    plural: webapps
    shortNames: ["wa"]
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image: { type: string }
                replicas: { type: integer }

ユーザーはこれで、次のようなカスタムリソースを宣言できます。

apiVersion: example.com/v1
kind: WebApp
metadata:
  name: hello
  namespace: default
spec:
  image: nginx:1.27
  replicas: 3

reconcile関数 — 核心ロジック

いよいよコントローラーの心臓であるreconcile関数を書きます。kube-rsのControllerは、監視中のリソースに変化があったとき(または周期的に)この関数を呼びます。シグネチャは「カスタムリソース一つと共有コンテキストを受け取り、次にいつ再び呼ぶか(Action)を返す」です。

use std::sync::Arc;
use std::time::Duration;
use k8s_openapi::api::apps::v1::Deployment;
use kube::api::{Api, Patch, PatchParams};
use kube::runtime::controller::Action;
use kube::{Client, ResourceExt};

pub struct Context {
    pub client: Client,
}

async fn reconcile(obj: Arc<WebApp>, ctx: Arc<Context>) -> Result<Action, Error> {
    let ns = obj.namespace().unwrap_or_default();
    let name = obj.name_any();
    let deployments: Api<Deployment> = Api::namespaced(ctx.client.clone(), &ns);

    // 望ましい状態に合うDeploymentを構成する
    let desired = build_deployment(&obj)?;

    // server-side applyで「この状態が保たれるよう保証」する(冪等)
    let pp = PatchParams::apply("webapp-operator").force();
    deployments
        .patch(&name, &pp, &Patch::Apply(&desired))
        .await?;

    tracing::info!(%ns, %name, "reconciled WebApp");

    // 成功したら5分後に周期的に再確認する
    Ok(Action::requeue(Duration::from_secs(300)))
}

核心はPatch::Apply(server-side apply)を使う点です。「作れ」ではなく「結果がこのマニフェストと同じになるようにせよ」なので、何度呼んでも安全です。これが先に述べた冪等性の実際の実装です。

build_deploymentは、specのimagereplicasから標準的なDeployment構造体を作る純粋関数です。k8s-openapiが提供する型を埋めていくだけです。

エラー処理とrequeue — 指数バックオフ

reconcileは失敗し得ます。APIサーバーが一時的に応答しない、別のコントローラーと競合する、一時的なネットワークエラーが起きる、といったことです。RustのResultとkube-rsのActionがこれを優雅に処理します。

まずエラー型をthiserrorで定義します。

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Kube API error: {0}")]
    Kube(#[from] kube::Error),

    #[error("Missing object key")]
    MissingKey,
}

そして、reconcileが失敗したときにどう再試行するかを決めるerror_policyを書きます。ここで指数バックオフの第一歩を表現できます。

fn error_policy(_obj: Arc<WebApp>, err: &Error, _ctx: Arc<Context>) -> Action {
    tracing::warn!("reconcile failed: {err}, retrying");
    // 失敗したら短く待ってから再試行する
    Action::requeue(Duration::from_secs(10))
}

Actionには三つの選択肢があります。

  • Action::requeue(duration) — この時間の後に再びreconcileする(周期的な再確認や再試行)。
  • Action::await_change() — リソースに実際の変化が起きるまで待つ(やることがないとき)。
  • 成功後に長いrequeueを与えると、イベントがなくても周期的に状態を確認する(ドリフト検知)。

reconcileがErrを返すと、Controllerが自動的にerror_policyを呼び、返された間隔の後に再試行します。この組み合わせによって、失敗を自然に再試行として吸収します。

ファイナライザ — クリーンアップの安全装置

カスタムリソースが削除されるとき、関連する外部リソース(クラウドのロードバランサー、外部DB、オブジェクトストレージのバケットなど)も一緒に片付けなければならない場合が多くあります。問題は、リソースが削除されるとコントローラーがそのspecをもう見られなくなることです。これを解決する仕組みが**ファイナライザ(finalizer)**です。

ファイナライザは、オブジェクトのメタデータに付く文字列のリストです。このリストが空でない限り、Kubernetesはオブジェクトを実際には削除せず、deletionTimestampを刻むだけにします。つまり「削除予約」状態になります。コントローラーが片付け作業を終えてファイナライザを外すと、そこで初めてオブジェクトが消えます。これによって、クリーンアップ処理を実行する時間を確保できます。

kube-rsはこのパターンをfinalizerヘルパーで包んでくれます。適用(作成/更新)と削除の二つの分岐に分けて処理させてくれます。

use kube::runtime::finalizer::{finalizer, Event as FinalizerEvent};

async fn reconcile(obj: Arc<WebApp>, ctx: Arc<Context>) -> Result<Action, Error> {
    let ns = obj.namespace().unwrap_or_default();
    let api: Api<WebApp> = Api::namespaced(ctx.client.clone(), &ns);

    finalizer(&api, "webapp.example.com/cleanup", obj, |event| async {
        match event {
            // 作成または更新のとき: 通常のreconcile
            FinalizerEvent::Apply(app) => apply(app, ctx.clone()).await,
            // 削除のとき: 外部リソースを片付けてからファイナライザを外す
            FinalizerEvent::Cleanup(app) => cleanup(app, ctx.clone()).await,
        }
    })
    .await
    .map_err(|_| Error::MissingKey)
}

このヘルパーがファイナライザの追加・削除を自動で処理してくれるので、我々は「適用時にやること」と「削除時に片付けること」だけを埋めれば済みます。Cleanup分岐が成功裏に終わって初めてファイナライザが外れ、オブジェクトが実際に削除されます。もし片付けが失敗すればファイナライザが残り、オブジェクトも残るので、リソースが漏れることはありません。

Controllerの組み立て — main関数

最後に、これらすべてをControllerで束ねて実行します。Controllerは監視対象(我々のWebApp)と、そのオペレーターが所有(own)する子リソース(ここではDeployment)を一緒に見張ります。子リソースが変わっても(誰かがDeploymentに手を触れても)reconcileが再びトリガーされます。これが自己修復(self-healing)の源です。

use futures::StreamExt;
use kube::runtime::watcher::Config;
use kube::runtime::Controller;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();
    let client = Client::try_default().await?;

    let webapps: Api<WebApp> = Api::all(client.clone());
    let deployments: Api<Deployment> = Api::all(client.clone());
    let ctx = Arc::new(Context { client });

    Controller::new(webapps, Config::default())
        .owns(deployments, Config::default())
        .run(reconcile, error_policy, ctx)
        .for_each(|res| async move {
            match res {
                Ok((obj, _action)) => tracing::info!("reconciled {:?}", obj.name),
                Err(e) => tracing::warn!("reconcile error: {e}"),
            }
        })
        .await;

    Ok(())
}

Controllerは内部でwatcherとreflectorによって対象リソースのキャッシュを保ち、関連イベントが来ると該当オブジェクトを作業キューに入れてreconcileを呼びます。複数のイベントが一度に押し寄せても、同じオブジェクトに対するreconcileは直列化され、重複はマージされるため、我々は並行性の問題を大きく気にする必要がありません。

なぜGo/kubebuilderではなくRustなのか

Kubernetes自体はGoで書かれており、オペレーターの主流フレームワーク(controller-runtime、kubebuilder、Operator SDK)もGoベースです。エコシステムの成熟度や例の量では、Goが依然として圧倒的です。それでもRustが魅力的な理由があります。

  • 小さなリソースフットプリント。 オペレーターはクラスター内に常駐し、回り続けるプロセスです。Rustのバイナリはガベージコレクタを持たないためメモリ使用が小さく安定し、コンテナイメージも静的リンクで数MBまで縮められます。数百のクラスターにオペレーターをデプロイする状況なら、この節約が積み上がります。GCによる断続的な遅延(ストップ・ザ・ワールド)がない点も、遅延に敏感なコントローラーに有利です。
  • メモリ安全性と強い型。 reconcileロジックは複数のリソースの状態を扱う繊細なコードです。Rustの所有権モデルとResultベースのエラー処理は、ヌル参照、データ競合、未処理のエラーをコンパイル時にかなりの程度捕捉します。「コンパイルが通れば、だいたい動く」という感覚は、運用コードで特に価値があります。
  • 表現力のある型で状態をモデル化する。 Rustの列挙型(enum)とパターンマッチは、リソースの状態遷移(例: Pending → Provisioning → Ready → Failed)を型として正確に表現するのに適しています。不正な状態の組み合わせを、そもそも表現不可能にできます。

もちろんトレードオフはあります。学習曲線は急で、コンパイル時間は長く、Goほど例が豊富ではありません。チームがすでにGoに慣れていて、素早くオペレーターを量産する必要があるなら、kubebuilderが実用的です。逆に、オペレーターの効率と堅牢さが重要で、特にリソースが限られたエッジや大規模なマルチクラスター環境なら、Rustとkube-rsが良い選択です。

自分で触ってみる

オペレーターは結局、Kubernetesのネットワーキング、スケジューリング、リソースモデルの上で動きます。この土台に手を慣らしておくと、オペレーターが何を調整しているのかがずっと鮮明になります。クラスター内でPodやService、ネットワークポリシーがどうつながるのかを自分で実験したいなら、当サイトのKubernetesネットワークラボで視覚的に扱えます。また、オペレーターがデプロイするのは結局コンテナなので、コンテナが隔離とリソース制限をどう実装しているのか気になるなら、コンテナラボでその原理を見られます。

おわりに

Operatorは運用知識をコードに落とし込んだコントローラーであり、その心臓は、望ましい状態と実際の状態の差を繰り返し狭めていく冪等なreconcileループです。CRDによってKubernetesに新しい種類のリソースを登録し、コントローラーがそれを監視して、世界を望んだ姿に保ちます。

Rustエコシステムのkube-rsは、このパターンを驚くほど滑らかに支援します。CustomResource導出マクロでCRDを型として定義し、Apiでリソースを型安全に扱い、Controllerでwatcherと作業キューを組み立て、Actionでrequeueとバックオフを表現し、finalizerヘルパーで削除時のクリーンアップを安全に処理します。

Rustを選ぶ理由は、結局オペレーターというワークロードの性格と噛み合うからです。クラスターに常駐して回り続けるプロセスには小さなフットプリントと予測可能な性能がふさわしく、繊細な状態調整ロジックには強い型とメモリ安全性がふさわしい。堅牢なオペレーターを作りたいなら、Rustとkube-rsは真剣に検討する価値のある組み合わせです。

参考資料