controller-runtimeを使ったKubernetes Operatorで、CRDの存在を確認して動的に管理対象のリソースを設定したいとき

私が管理しているwalnuts1018/cloudflare-tunnel-operator というKubernetes Operatorでは、DeploymentやSecretだけではなく、ServiceMonitorも自動的に作られるようになっています。

しかし、Deploymentなどと違ってServiceMonitorはCustom Resourceなので、そのCustom Resource Definitionがインストールされていない環境ではエラーになってしまいます。 では、元となるリソースにdisableServiceMonitorみたいなフィールドを付与して、単純にServiceMonitorのReconcileをスキップすれば良いのかというとそれだけではうまく行きません。

単純にCreateOrUpdateをスキップする実装

まずは、SetupWithManagerメソッドで、OwnsにServiceMonitorを追加し、変更を監視できるようにします。

func (r *CloudflareTunnelReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&cftv1beta1.CloudflareTunnel{}).
        Owns(&corev1.Secret{}).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        Owns(&policyv1.PodDisruptionBudget{}).
        Owns(&monitoringv1.ServiceMonitor{}).
        Named("cloudflaretunnel").
        Complete(r)
}

そして、/internal/controllerのReconcileメソッドの中の処理で、enableServiceMonitorがfalseの場合はServiceMonitorのReconcileをスキップするようにします。

func (r *CloudflareTunnelReconciler) reconcileServiceMonitor(ctx context.Context, cfTunnel cftv1beta1.CloudflareTunnel) error {
    if !cfTunnel.Spec.EnableServiceMonitor {
        slog.Debug("ServiceMonitor is disabled. Skipping reconciliation.", "name", cfTunnel.Name, "namespace", cfTunnel.Namespace)
        return nil
    }

    sm := &monitoringv1.ServiceMonitor{}
    sm.SetNamespace(cfTunnel.Namespace)
    sm.SetName(cfTunnel.Name)
    sm.SetLabels(appLabels(cfTunnel))

    result, err := ctrl.CreateOrUpdate(ctx, r.Client, sm, func() error {
    ...

権限の設定も忘れずに行います。

...
// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete

さらに、main.goでSchemaの登録も行う必要があります。

func init() {
    utilruntime.Must(clientgoscheme.AddToScheme(scheme))
    utilruntime.Must(cftunneloperatorv1beta1.AddToScheme(scheme))
    utilruntime.Must(monitoringv1.AddToScheme(scheme))
    // +kubebuilder:scaffold:scheme
}

このまま動かしてみると、以下のようなエラーが出てしまいます。

{"time":"2025-09-15T07:23:15.315448586Z","level":"ERROR","msg":"if kind is a CRD, it should be installed before calling Start","logger":"controller-runtime/source/EventHandler","err":"no matches for kind \"ServiceMonitor\" in version \"monitoring.coreos.com/v1\"","kind":{"Group":"monitoring.coreos.com","Kind":"ServiceMonitor"}}
{"time":"2025-09-15T07:24:35.317541191Z","level":"ERROR","msg":"problem running manager","logger":"setup","err":"failed to wait for cloudflaretunnel caches to sync kind source: *v1.ServiceMonitor: timed out waiting for cache to be synced for Kind *v1.ServiceMonitor"}

つまり、ServiceMonitorを作成した時だけではなく、Managerの起動時にもエラーが発生します。 これは、ReconcilerがOwnsで指定したリソースをWatchし、変更があった時にReconcileを呼び出すといった挙動になっているからです。このWatchをするときに、CRDが存在しないとエラーになってしまいます。

CRDの存在を確認して動的に管理対象のリソースを設定する

では、SetupWithManagerの中で、CRDが存在するかどうかを確認し、存在する場合のみOwnsに追加するようにしてみます。

func (r *CloudflareTunnelReconciler) SetupWithManager(mgr ctrl.Manager) error {
    builder := ctrl.NewControllerManagedBy(mgr).
        For(&cftv1beta1.CloudflareTunnel{}).
        Owns(&corev1.Secret{}).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        Owns(&policyv1.PodDisruptionBudget{})

    // Prometheus Operatorがインストールされていない環境でも動作するようにする
    if err := mgr.GetAPIReader().Get(context.TODO(), types.NamespacedName{Name: "servicemonitors.monitoring.coreos.com"}, &apiextensions.CustomResourceDefinition{}); err != nil {
        if apierrors.IsNotFound(err) {
            slog.Info("ServiceMonitor CRD not found. Skipping watching ServiceMonitor.")
        } else {
            return fmt.Errorf("failed to check ServiceMonitor CRD: %w", err)
        }
    } else {
        builder = builder.Owns(&monitoringv1.ServiceMonitor{})
    }

    builder = builder.Named("cloudflaretunnel")
    return builder.Complete(r)
}

まず、CustomResourceDefinition servicemonitors.monitoring.coreos.com をGetします。この時、ReconcilerのClientではなく、ManagerのAPIReaderを使います。この時点ではまだ内部のキャッシュが初期化されていないため、ErrCacheNotStartedが返ってくるからです。

もしもCRDが存在しない場合はStatusReasonNotFoundが返ってくるので、その場合はOwnsに追加しないようにします。

先ほどと同様に、権限の設定とSchemaの登録も行います。

...
// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch
func init() {
    ...
    utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
}

これで無事に、ServiceMonitor CRDが存在しない環境でもエラーなく動作するようになりました。