Skip to content
Published on

Operator の可観測性 — メトリクス、イベント、ロギング、そして SLO

Authors

はじめに

Operator を初めて書くときは「reconcile ループがきちんと回っているか」だけに集中しがちです。しかし運用に入ると問いが変わります。「いま reconcile はどれくらいの頻度で失敗しているか」「キューが詰まっていないか」「ユーザーが作ったリソースがなぜ反映されないのか」「この Operator は約束した信頼水準(SLO)を守れているのか」といった問いです。

本記事は Kubebuilder(2026年時点で Kubernetes 1.36 / Go 1.26、controller-runtime v0.24.x、controller-tools v0.21.x)で作った Operator の可観測性(observability)を扱います。可観測性とは単にダッシュボードを立てることではなく、システム内部の状態を外部から推論できるようにするあらゆるシグナル(signal)を設計することです。本記事で扱うシグナルは大きく五つあります。

可観測性の五つのシグナル
┌──────────────┬───────────────────────────────────────────┐
│ シグナル       │ 誰のためのものか                            │
├──────────────┼───────────────────────────────────────────┤
│ メトリクス      │ SRE / 運用者 — 傾向、アラート、SLO           │
│ イベント        │ エンドユーザー — kubectl describe の文脈      │
│ ロギング        │ 開発者 / 運用者 — 単一 reconcile の詳細      │
│ トレーシング     │ 開発者 — 複数段階にまたがる遅延の分析         │
│ status/条件     │ エンドユーザー / コントローラー — 宣言的状態   │
└──────────────┴───────────────────────────────────────────┘

各シグナルは対象者と目的が異なります。メトリクスは集計された傾向を、イベントはユーザーが kubectl describe で見る文脈を、ログは単一 reconcile の詳細な流れを、トレーシングは分散した遅延を、status/conditions は宣言的な現在の状態を表します。この五つをバランスよく設計してはじめて、運用可能な Operator になります。

controller-runtime のデフォルトメトリクス

controller-runtime は特別な設定なしでも豊富なデフォルトメトリクスをメトリクスサーバー経由で公開します。これらのメトリクスは Operator 可観測性の出発点です。最も重要なものを見ていきましょう。

reconcile 系メトリクス

controller_runtime_reconcile_total{controller, result}
    reconcile 呼び出し回数。result ラベルは success / error /
    requeue / requeue_after で区別される。成功率計算の分母・分子
    になる。

controller_runtime_reconcile_errors_total{controller}
    Reconcile がエラーを返した回数。この値が着実に増えるなら、
    何かが繰り返し失敗しているという意味。

controller_runtime_reconcile_time_seconds{controller}
    reconcile 1回の処理時間のヒストグラム。_bucket / _sum /
    _count 系列を生成し、分位数(p50/p95/p99)の計算に使う。

controller_runtime_active_workers{controller}
    現在 reconcile を同時実行中のワーカー数。

controller_runtime_max_concurrent_reconciles{controller}
    設定された最大同時 reconcile 数(MaxConcurrentReconciles)。

reconcile_total の result ラベルは運用上とても重要です。requeue / requeue_after が異常に多い場合、reconcile が desired state に収束できず再試行を繰り返しているシグナルかもしれません。

workqueue 系メトリクス

reconcile をトリガーするワークキュー(workqueue)の状態は、Operator の負荷と健全性を直接示します。

workqueue_depth{name}
    キューで待機中の項目数。継続的に高いと、コントローラーが
    入ってくるイベント速度に追いつけていないという意味。

workqueue_adds_total{name}
    キューに追加された累積項目数。rate() で流入速度を見る。

workqueue_queue_duration_seconds{name}
    項目がキューに滞在した時間(ヒストグラム)。処理開始までの待ち。

workqueue_work_duration_seconds{name}
    項目処理にかかった時間(ヒストグラム)。reconcile の実作業時間。

workqueue_retries_total{name}
    キューから再試行された累積回数。エラーによる再試行が多いと増加。

workqueue_unfinished_work_seconds{name}
    まだ処理されていない作業がキューに溜まっている時間。

workqueue_longest_running_processor_seconds{name}
    最も長く実行中の処理タスクの時間。止まった reconcile の検知に有用。

これらのメトリクスの関係を図にすると次のようになります。

イベント発生 -> predicate 通過 -> workqueue.Add()
                              [workqueue_adds_total]
                            ┌──────────────┐
                            │   workqueue  │  <- [workqueue_depth]
                            │  (待機項目)    │  <- [queue_duration]
                            └──────┬───────┘
                                   │ Get()
                            Reconcile() 実行
                              │  <- [active_workers]
                              │  <- [work_duration / reconcile_time]
                  成功 -> Forget()        エラー -> AddRateLimited()
              [reconcile_total            [reconcile_errors_total
               result=success]             workqueue_retries_total]

メトリクスサーバーとセキュリティ

controller-runtime v0.24.x では、メトリクスサーバーは manager.Options の Metrics フィールドで設定します。かつてよく使われた kube-rbac-proxy サイドカーは削除され、メトリクスエンドポイント自体が認証・認可を内蔵する方式に変わりました。

import (
	"sigs.k8s.io/controller-runtime/pkg/manager"
	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
	"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
)

func newManager(cfg *rest.Config) (manager.Manager, error) {
	return ctrl.NewManager(cfg, ctrl.Options{
		Metrics: metricsserver.Options{
			BindAddress:   ":8443",
			SecureServing: true,
			// メトリクスエンドポイントに認証・認可を適用する。
			// かつての kube-rbac-proxy サイドカーの役割を代替する。
			FilterProvider: filters.WithAuthenticationAndAuthorization,
		},
	})
}

WithAuthenticationAndAuthorization は受信リクエストの ServiceAccount トークンを検証し、nonResourceURLs パスへの RBAC 権限を確認します。つまり Prometheus がメトリクスを取得するには、そのパスへの権限を持つ ServiceAccount が必要です。

カスタムメトリクスを追加する

デフォルトメトリクスはコントローラーの動作を示しますが、ドメイン固有のシグナルは自分で定義する必要があります。たとえば「現在管理中のワークロード数」「最後の同期からの経過時間」「外部 API 呼び出しの失敗回数」などです。

controller-runtime は独自の Prometheus レジストリ(metrics.Registry)を公開するので、そこにカスタムコレクターを登録すれば同じ /metrics エンドポイントで一緒に公開されます。

package controller

import (
	"github.com/prometheus/client_golang/prometheus"
	"sigs.k8s.io/controller-runtime/pkg/metrics"
)

var (
	// 管理中リソースの現在の状態別カウント(ゲージ)
	managedResources = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "myoperator_managed_resources",
			Help: "現在管理中のリソース数(phase ラベル別)",
		},
		[]string{"namespace", "phase"},
	)

	// 外部 API 呼び出し結果のカウンター
	externalAPICalls = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "myoperator_external_api_calls_total",
			Help: "外部 API 呼び出し回数(結果ラベル別)",
		},
		[]string{"endpoint", "result"},
	)

	// 最後の成功同期からの経過時間を推論するためのタイムスタンプゲージ
	lastSyncTimestamp = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "myoperator_last_successful_sync_timestamp_seconds",
			Help: "リソースごとの最後の成功同期の Unix タイムスタンプ",
		},
		[]string{"namespace", "name"},
	)

	// reconcile 段階別の所要時間ヒストグラム
	reconcileStageDuration = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "myoperator_reconcile_stage_duration_seconds",
			Help:    "reconcile 内部段階別の所要時間",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"stage"},
	)
)

func init() {
	// controller-runtime のグローバルレジストリに登録する。
	// こうするとデフォルトメトリクスと同じ /metrics で公開される。
	metrics.Registry.MustRegister(
		managedResources,
		externalAPICalls,
		lastSyncTimestamp,
		reconcileStageDuration,
	)
}

これで reconcile の中でこれらのメトリクスを更新します。

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

	var obj appsv1alpha1.MyResource
	if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 段階別タイマー開始
	stageTimer := prometheus.NewTimer(
		reconcileStageDuration.WithLabelValues("fetch_external"),
	)
	state, err := r.External.FetchState(ctx, obj.Spec.ID)
	stageTimer.ObserveDuration()

	if err != nil {
		externalAPICalls.WithLabelValues("fetch", "error").Inc()
		log.Error(err, "外部状態の取得に失敗")
		return ctrl.Result{}, err
	}
	externalAPICalls.WithLabelValues("fetch", "success").Inc()

	// desired state へ収束させる適用段階
	applyTimer := prometheus.NewTimer(
		reconcileStageDuration.WithLabelValues("apply"),
	)
	phase, err := r.applyDesiredState(ctx, &obj, state)
	applyTimer.ObserveDuration()
	if err != nil {
		return ctrl.Result{}, err
	}

	// ゲージ更新
	managedResources.WithLabelValues(obj.Namespace, phase).Set(1)
	lastSyncTimestamp.WithLabelValues(obj.Namespace, obj.Name).
		SetToCurrentTime()

	return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}

ゲージメトリクスと削除処理の落とし穴

ゲージ(Gauge)を使うときの最もよくある誤りは、リソースが削除された後もゲージが最後の値を報告し続けることです。managed_resources のようにラベルのカーディナリティがリソース数に比例するゲージは、削除時に必ず DeleteLabelValues で取り除く必要があります。

// finalizer または NotFound 処理時に、該当ラベルの系列を片付ける。
managedResources.DeleteLabelValues(obj.Namespace, oldPhase)
lastSyncTimestamp.DeleteLabelValues(obj.Namespace, obj.Name)

これを忘れると、削除されたリソースに対する「ゴースト」系列が残り、カーディナリティが無限に増加して Prometheus のメモリを侵食します。これは、ラベルに name のような高カーディナリティの値を入れること自体を慎重に判断すべき理由でもあります。

Prometheus スクレイプと ServiceMonitor

メトリクスを公開したら、Prometheus がそれを収集する必要があります。Prometheus Operator を使う環境なら、ServiceMonitor カスタムリソースでスクレイプ対象を宣言します。

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: myoperator-controller-manager-metrics
  namespace: myoperator-system
  labels:
    app.kubernetes.io/name: myoperator
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      control-plane: controller-manager
  endpoints:
    - port: https
      scheme: https
      path: /metrics
      interval: 30s
      bearerTokenSecret:
        name: metrics-reader-token
        key: token
      tlsConfig:
        insecureSkipVerify: true
  namespaceSelector:
    matchNames:
      - myoperator-system

メトリクスエンドポイントが認証を要求するため、Prometheus が使う ServiceAccount に次の権限を付与する必要があります。

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: myoperator-metrics-reader
rules:
  - nonResourceURLs:
      - /metrics
    verbs:
      - get

ServiceMonitor を使わない場合は、Prometheus の静的スクレイプ設定でも同じことができます。

scrape_configs:
  - job_name: myoperator
    scheme: https
    metrics_path: /metrics
    tls_config:
      insecure_skip_verify: true
    authorization:
      type: Bearer
      credentials_file: /var/run/secrets/tokens/metrics-token
    kubernetes_sd_configs:
      - role: endpoints
        namespaces:
          names:
            - myoperator-system
    relabel_configs:
      - source_labels: [__meta_kubernetes_endpoint_port_name]
        action: keep
        regex: https

Grafana ダッシュボードと重要な PromQL

収集したメトリクスは PromQL でクエリしてダッシュボードを構成します。Operator 運用で必ずパネルに置くべきクエリを整理します。

reconcile 成功率

sum(rate(controller_runtime_reconcile_total{controller="myresource", result!="error"}[5m]))
/
sum(rate(controller_runtime_reconcile_total{controller="myresource"}[5m]))

この値が SLO の中核指標になります。1 に近いほど健全です。0.99 を下回ったら警告を検討します。

reconcile エラー率

sum(rate(controller_runtime_reconcile_errors_total{controller="myresource"}[5m]))

エラー自体の絶対速度を見ます。0 から突然跳ね上がったら、新規デプロイや外部依存の障害を疑います。

reconcile 遅延の分位数(p95 / p99)

histogram_quantile(
  0.95,
  sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller="myresource"}[5m])) by (le)
)

p99 まで見るには 0.95 を 0.99 に変えます。遅延が長くなったら、外部 API や apply 段階のボトルネックを疑います。

workqueue の深さと待ち時間

workqueue_depth{name="myresource"}
histogram_quantile(
  0.95,
  sum(rate(workqueue_queue_duration_seconds_bucket{name="myresource"}[5m])) by (le)
)

depth が着実に右肩上がりなら、コントローラーが負荷に追いついていません。MaxConcurrentReconciles を増やすか、predicate で不要なイベントを減らすか、reconcile 自体を軽くする必要があります。

データの新鮮さ(freshness)

カスタムのタイムスタンプゲージを活用すると、「最後の同期からどれだけ経ったか」を直接アラートにできます。

time() - max by (namespace, name) (myoperator_last_successful_sync_timestamp_seconds)

この値がたとえば 900 秒(15分)を超えたら、そのリソースはしばらく reconcile されていません。

Kubernetes イベントを記録する

メトリクスが SRE のためのものなら、イベント(Event)はエンドユーザーのためのものです。ユーザーは kubectl describe で自分のリソースに何が起きたかを見ます。よく作られたイベントは「なぜ私のリソースが Ready でないのか」という問いに即座に答えます。

controller-runtime では mgr.GetEventRecorderFor で EventRecorder を取得します。

type MyResourceReconciler struct {
	client.Client
	Scheme   *runtime.Scheme
	Recorder record.EventRecorder
}

// SetupWithManager で Recorder を注入する。
func (r *MyResourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
	r.Recorder = mgr.GetEventRecorderFor("myresource-controller")
	return ctrl.NewControllerManagedBy(mgr).
		For(&appsv1alpha1.MyResource{}).
		Complete(r)
}

reconcile の中で Normal/Warning イベントを記録します。

func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var obj appsv1alpha1.MyResource
	if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	deployment, err := r.ensureDeployment(ctx, &obj)
	if err != nil {
		// ユーザーに見える Warning イベント:何が、なぜ失敗したか
		r.Recorder.Eventf(&obj, corev1.EventTypeWarning, "DeploymentFailed",
			"子 Deployment の作成に失敗: %v", err)
		return ctrl.Result{}, err
	}

	if deployment.Status.ReadyReplicas == *deployment.Spec.Replicas {
		// 正常状態を知らせる Normal イベント
		r.Recorder.Eventf(&obj, corev1.EventTypeNormal, "Ready",
			"すべてのレプリカ(%d個)が準備完了しました", deployment.Status.ReadyReplicas)
	}

	return ctrl.Result{}, nil
}

イベント使用時の注意点

イベントは強力ですが、乱用すると有害です。核心となる原則は次のとおりです。

イベント記録の原則
- 状態の「遷移」だけに記録せよ。毎 reconcile ごとに同じイベントを
  打つと、etcd にとってもユーザーにとってもノイズになる。
- Reason は PascalCase の語で、機械が grep できるようにせよ。
  (例: DeploymentFailed, Ready, Scaling, QuotaExceeded)
- メッセージは人間のための文に。次の行動を示唆できればなお良い。
- イベントはデフォルトの TTL(約1時間)で消える。永続的な状態は
  イベントではなく status.conditions に置け。
- Warning は本当に注意が必要なときだけ。乱発すると鈍る。

イベントはデフォルトで約1時間後にガベージコレクションされます。したがって「現在の状態」の永続的な表現は、イベントではなく status に置く必要があります。

logr を使った構造化ロギング

Kubebuilder Operator は logr インターフェースを使います。要点は、文字列フォーマットではなくキー・バリュー対の構造化ロギングである点です。こうすることでログを JSON で出力し、フィールド単位で検索・集計できます。

import (
	logf "sigs.k8s.io/controller-runtime/pkg/log"
)

func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	// コンテキストからロガーを取得する。controller-runtime が
	// すでに reconcileID, controller, namespace, name などを注入済み。
	log := logf.FromContext(ctx)

	var obj appsv1alpha1.MyResource
	if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// この reconcile の間ずっと付いて回る共通フィールドを束ねる。
	log = log.WithValues("generation", obj.Generation, "phase", obj.Status.Phase)

	log.Info("reconcile 開始")

	if err := r.doWork(ctx, &obj); err != nil {
		// エラーは Error レベルで。err を第一引数に渡すと構造化される。
		log.Error(err, "作業の実行に失敗", "step", "doWork")
		return ctrl.Result{}, err
	}

	// 詳細なデバッグ情報は V-level を活用し、普段はオフにする。
	log.V(1).Info("desired state の計算完了", "replicas", obj.Spec.Replicas)

	log.Info("reconcile 完了")
	return ctrl.Result{}, nil
}

V-level とロギングポリシー

logr の V-level は「詳細度」を意味します。V(0)(= Info)は常に表示され、数字が大きくなるほど詳細で、普段はオフにしておくのが一般的です。

ロギングレベルガイド
  Error          : reconcile が失敗し再試行される状況。常に出力。
  Info / V(0)    : 状態遷移、reconcile 開始/完了のようなマクロな流れ。
  V(1)           : 判断の根拠 — なぜこの経路を選んだか。
  V(2) 以上       : ループ内部、個別項目単位の非常に詳細な流れ。

何をロギングしないか
  - Secret の値、トークン、パスワード(絶対禁止)
  - 毎 reconcile ごとの同じ「変化なし」ログ(ノイズ)
  - 巨大なオブジェクト全体のダンプ(必要なフィールドだけ WithValues で)

良いロギングの核心は「1本の reconcile の流れを reconcileID で追えるか」です。controller-runtime はコンテキストロガーに reconcileID を自動で入れてくれるので、同じ ID でフィルタすれば単一 reconcile の全体の物語を再構成できます。

OpenTelemetry による分散トレーシング

reconcile が複数の外部システム(クラウド API、データベース、他のコントローラー)にまたがって動作するとき、遅延がどこで発生するかを知るにはトレーシングが必要です。OpenTelemetry で reconcile を計装し、コンテキストを伝播します。

import (
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
)

var tracer = otel.Tracer("myoperator/controller")

func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	// reconcile 全体を包むルートスパンを開始する。
	ctx, span := tracer.Start(ctx, "Reconcile",
		trace.WithAttributes(
			attribute.String("resource.namespace", req.Namespace),
			attribute.String("resource.name", req.Name),
		),
	)
	defer span.End()

	var obj appsv1alpha1.MyResource
	if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
		span.RecordError(err)
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 外部呼び出しを子スパンで包む。ctx をそのまま渡して
	// 親子関係が伝播するようにする。
	if err := r.fetchExternal(ctx, &obj); err != nil {
		span.SetStatus(codes.Error, "external fetch failed")
		span.RecordError(err)
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}

func (r *MyResourceReconciler) fetchExternal(ctx context.Context, obj *appsv1alpha1.MyResource) error {
	// 同じ ctx から子スパンを開始すると自動的に親に紐づく。
	ctx, span := tracer.Start(ctx, "fetchExternal")
	defer span.End()
	span.SetAttributes(attribute.String("external.id", obj.Spec.ID))
	return r.External.Call(ctx, obj.Spec.ID)
}

トレーシングの核心はコンテキスト伝播です。ctx をすべての呼び出しに一貫して渡せば、1回の reconcile が生み出すすべてのスパンが1つのトレースにまとまります。これにより「p99 遅延の80%が外部 API 呼び出しで発生する」といった結論をデータで出せます。

1回の reconcile トレース
Reconcile [120ms] ──────────────────────────────────────────
  ├─ Get(MyResource) [3ms] ─
  ├─ fetchExternal [95ms] ────────────────────────────
  │     └─ HTTP GET cloud-api [92ms] ──────────────────
  └─ applyDesiredState [20ms] ───────
        └─ Patch(Deployment) [18ms] ──────

status と conditions でユーザーに状態を見せる

イベントが一時的な出来事なら、status.conditions は永続的で宣言的な現在の状態です。Kubernetes エコシステムの標準は、metav1.Condition の配列を status に置くことです。ユーザーは kubectl get の出力からこの条件を読み、他のコントローラーもこれを読んで動作します。

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/api/meta"
)

func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var obj appsv1alpha1.MyResource
	if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	ready, reason, msg := r.evaluateReadiness(ctx, &obj)

	// meta.SetStatusCondition は同じ Type の条件を更新するか、
	// なければ追加する。LastTransitionTime を自動で管理する。
	meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
		Type:               "Ready",
		Status:             conditionStatus(ready),
		Reason:             reason,             // PascalCase、機械判読可能
		Message:            msg,                // 人間のための説明
		ObservedGeneration: obj.Generation,     // どの世代を評価したか
	})

	// status サブリソースを更新する。
	if err := r.Status().Update(ctx, &obj); err != nil {
		return ctrl.Result{}, err
	}
	return ctrl.Result{}, nil
}

func conditionStatus(ok bool) metav1.ConditionStatus {
	if ok {
		return metav1.ConditionTrue
	}
	return metav1.ConditionFalse
}

ObservedGeneration は特に重要です。ユーザーが spec を変えて generation が上がったのに conditions[].observedGeneration がそれより小さければ、コントローラーはまだ最新の spec を反映していません。これは「私の変更が適用されたか」を判断する標準のシグナルです。

status.conditions の設計原則
- Type は名詞的な能力/状態にせよ: Ready, Available,
  Progressing, Degraded など。標準的な意味に従うとツール互換性が良い。
- Status は True/False/Unknown の三つだけ。
- Reason は PascalCase の語。Message は人間のための文。
- ObservedGeneration を必ず埋めよ。適用有無判断の核心。
- conditions は冪等に set せよ。毎回 append してはいけない。

Operator の SLO を定義する

シグナルを集めたので、次はこれを「約束」に変える番です。SLO(Service Level Objective)は、Operator が守るべき信頼水準を数値で定義します。Operator に適した三つの SLI(指標)を提案します。

Operator のための三つの SLI / SLO 例
┌────────────────┬─────────────────────────────┬──────────────┐
│ SLI            │ 定義                          │ SLO 目標(例)  │
├────────────────┼─────────────────────────────┼──────────────┤
│ reconcile      │ エラー以外の reconcile /        │ 30日 99.5%   │
│ 成功率          │ 全 reconcile                  │              │
├────────────────┼─────────────────────────────┼──────────────┤
│ reconcile      │ reconcile p99 処理時間         │ p99 < 2s     │
│ 遅延           │                              │ (5分ウィンドウ)│
├────────────────┼─────────────────────────────┼──────────────┤
│ 新鮮さ          │ 最後の成功同期からの経過時間     │ 95% が        │
│ (freshness)    │                              │ < 10分        │
└────────────────┴─────────────────────────────┴──────────────┘

成功率 SLO を PromQL のエラーバジェット(error budget)の観点で表すと次のようになります。

1 - (
  sum(rate(controller_runtime_reconcile_errors_total{controller="myresource"}[30d]))
  /
  sum(rate(controller_runtime_reconcile_total{controller="myresource"}[30d]))
)

PrometheusRule でアラートを定義する

SLO 違反に近づいたらアラートを送るために、PrometheusRule を定義します。

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: myoperator-slo-alerts
  namespace: myoperator-system
  labels:
    release: kube-prometheus-stack
spec:
  groups:
    - name: myoperator.slo
      rules:
        - alert: ReconcileErrorRateHigh
          expr: |
            sum(rate(controller_runtime_reconcile_errors_total{controller="myresource"}[5m]))
            /
            sum(rate(controller_runtime_reconcile_total{controller="myresource"}[5m]))
            > 0.05
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "MyResource reconcile エラー率が 5% を超えました"
            description: "直近5分間で reconcile エラー率が SLO しきい値を超えました。"

        - alert: ReconcileLatencyHigh
          expr: |
            histogram_quantile(0.99,
              sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller="myresource"}[5m])) by (le)
            ) > 2
          for: 15m
          labels:
            severity: warning
          annotations:
            summary: "MyResource reconcile p99 遅延が 2秒を超えました"

        - alert: ResourceStaleness
          expr: |
            time() - max by (namespace, name) (
              myoperator_last_successful_sync_timestamp_seconds
            ) > 900
          for: 5m
          labels:
            severity: critical
          annotations:
            summary: "リソースが15分以上同期されていません"
            description: "特定の MyResource が SLO 新鮮さ目標に違反しています。"

        - alert: WorkqueueBacklog
          expr: workqueue_depth{name="myresource"} > 50
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "workqueue の滞留が発生しました"
            description: "コントローラーが入ってくるイベントに追いついていません。"

アラート設計では for 句が重要です。一時的なスパイクでアラートが鳴らないように継続時間を十分に取りつつ、SLO 違反をあまりに遅く知らせないようにバランスを取る必要があります。

デバッグワークフロー — 「reconcile が動かない」

Operator 運用で最もよくあり、最も戸惑う状況は「リソースを作ったのに何も起きない」です。reconcile 自体が呼ばれていないケースで、原因が複数の箇所に散らばっているため追跡が厄介です。次は段階別の診断ランブックです。

ランブック:「reconcile がトリガーされない」の診断

1) まずメトリクスで reconcile が回っているか確認
   - controller_runtime_reconcile_total{controller="..."} が
     増えているか?
   - 全く増えない -> コントローラーがイベントを受け取っていない。
     以下の 2-6 へ。
   - 増えてはいるが errors_total も増える -> reconcile は呼ばれて
     いるが失敗。ログ/イベントで原因を追跡(このランブックの対象外)。

2) コントローラーマネージャーは生きていてリーダーか
   - Pod は Running か? クラッシュループではないか?
   - リーダー選出(leader election)使用時:リーダーでない複製は
     reconcile を回さない。リーダーロックの Lease を確認。
       kubectl get lease -n myoperator-system
   - リーダー Pod のログを見ているか確認(別の Pod のログを見ると
     「静か」に見えることがある)。

3) 情報がキャッシュに入ってきているか(informer / RBAC)
   - コントローラー SA が対象リソースを watch/list/get する RBAC が
     あるか?なければ informer が静かに失敗するか権限エラー。
       kubectl auth can-i list myresources \
         --as=system:serviceaccount:myoperator-system:controller-manager
   - ログに "failed to list" / "forbidden" 系のエラーがないか。

4) predicate がイベントをフィルタしていないか
   - SetupWithManager の WithEventFilter / Owns / Watches に
     付いた predicate がその変更を通過させるか?
   - 例: GenerationChangedPredicate は metadata/label だけ変わった
     更新を無視する -> status/annotation 変更だけでは reconcile が
     回らない。
   - 疑わしければ predicate を一時的に外して再現してみる。

5) イベントソース(watch)が実際に登録されているか
   - For()/Owns()/Watches() に対象の型が抜けていないか。
   - 別の namespace / クラスタスコープのリソースを見るのに、
     マネージャーが namespace でキャッシュを制限していないか
     (Cache.DefaultNamespaces 設定を確認)。

6) オブジェクトは実際に変わったか
   - kubectl get myresource -o yaml で generation と
     status.observedGeneration を比較。
   - resourceVersion がそのままなら apiserver から見て変更が
     ない -> イベント自体がない。

決定木の要約
  reconcile_total が増えない?
    ├─ Pod/リーダーの問題   -> 2)
    ├─ RBAC 不足           -> 3)
    ├─ predicate フィルタ   -> 4)
    └─ watch 抜け          -> 5)

このランブックの核心は「メトリクス(1番)でまず reconcile が呼ばれているかを切り分けること」です。reconcile が一度も回らない問題と、回るが失敗する問題は、原因の領域が全く異なるからです。可観測性をよく設計しておけば、この最初の分岐を推測ではなくデータで下せます。

運用ランブック

最後に、平常時と障害時に参照する運用ランブックを整理します。

日常点検(ダッシュボードで毎日/週次)
  - reconcile 成功率が SLO(例: 99.5%)を上回っているか
  - reconcile p99 遅延が目標(例: 2s)を下回っているか
  - workqueue_depth が 0 近くに収束しているか
  - 新鮮さアラートが静かか

デプロイ後の確認
  - reconcile_errors_total がデプロイ直後に跳ねていないか
  - active_workers が max_concurrent_reconciles に
    張り付き続けていないか(飽和シグナル)
  - 新バージョンのログに新規 Error がないか

障害対応
  - エラー率急増 -> 最近のデプロイのロールバックが第一候補。
    外部依存の状態も同時に確認。
  - 遅延急増 -> トレースでどのスパンが遅いかを特定。
    外部 API のレート制限 / タイムアウトを疑う。
  - workqueue 滞留 -> MaxConcurrentReconciles を引き上げ、
    predicate で不要イベントを遮断、reconcile を軽量化。
  - 特定のリソースだけ stale -> 当該オブジェクトを kubectl describe
    して Warning イベントと conditions を確認。observedGeneration 比較。

容量計画
  - 管理リソース数の増加に伴う reconcile_total rate の傾向
  - メトリクスのカーディナリティ(特に name ラベル)の増加傾向を監視
  - Prometheus TSDB のメモリ/ディスク使用量

おわりに

可観測性は、Operator を「動作するコード」から「運用可能なシステム」へ引き上げる最後の一歩です。要点を改めて整理します。

  • controller-runtime のデフォルトメトリクス(reconcile total/errors/duration、workqueue depth/adds/latency)は、無料で得られる強力な出発点です。各ラベルの意味を理解することが先決です。
  • ドメイン固有のシグナルはカスタムメトリクスで自分で定義しますが、ゲージの削除とラベルのカーディナリティに注意が必要です。
  • イベントはユーザーのための文脈、ログは開発者のための流れ、トレーシングは遅延分析、status/conditions は宣言的な現在の状態 — 対象者を意識してシグナルを分けて設計しましょう。
  • SLO を数値で定義し PrometheusRule でアラートをかければ、Operator の信頼性を推測ではなく約束として管理できます。
  • 「reconcile が動かない」問題は、メトリクスでまず呼び出し有無を切り分け、predicate / RBAC / watch / leader election を体系的に当たれば素早く絞り込めます。

良い Operator は静かに働きますが、良い可観測性はその静けさが「健全な沈黙」なのか「障害の沈黙」なのかを常に区別してくれます。

参考資料