- Published on
Controllers Without CRDs — Patterns for Automating Existing Resources
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — Not Being Intimidated by the Word "Operator"
- What Is a Controller — The Essence of the Reconcile Loop
- Typical Cases of Controllers That Watch Built-in Resources
- Implementing Without a CRD Using controller-runtime
- Reducing Unnecessary Reconciles with Predicates
- Operator vs Simple Controller — What's the Difference
- Building With client-go Without kubebuilder (Overview)
- Policy With an Admission Webhook Alone
- A Collection of Internal Automation Ideas
- Pitfalls — Permissions and Infinite Loops
- Operations — Deployment and Observability
- Deployment Manifests — Putting the Controller Onto the Cluster
- Testing — Verifying Reconcile With envtest
- Closing
- References
Introduction — Not Being Intimidated by the Word "Operator"
When the thought of automating something in Kubernetes arises, many people jump straight to "I need to build an Operator." They scaffold a project with kubebuilder, design a CRD, write API types, and then fall into doubt: "Do I really have to go this far?" In fact, many internal automations need no CRD at all.
Let me state the core first. A controller is independent of a CRD. A controller is merely "a loop that observes the state of some resource and converges it toward the desired state." That target resource does not have to be a custom resource. You can build a controller just as well targeting Kubernetes built-in resources like ConfigMap, Secret, Namespace, or Node.
This article takes a deep look at the surprisingly common but rarely discussed pattern of "a controller that automates using only built-in resources, without a CRD."
What Is a Controller — The Essence of the Reconcile Loop
Every Kubernetes controller follows the same mental model.
[Observe] Read the current state (API server watch)
|
v
[Compare] Compute the diff between desired state and current state
|
v
[Reconcile] Take action to remove the diff (create/update/delete)
|
+--> When an event fires again, start over (idempotent loop)
The key here is where the "desired state" comes from. An Operator reads that desired state from a custom resource (CR) declared by the user. But the desired state does not have to be a CR.
- "A ConfigMap with a specific label must be replicated to all namespaces" — this rule itself is the desired state. No CR needed.
- "When a new namespace is created, it must have a default NetworkPolicy and ResourceQuota" — expressed as a rule.
- "GPU nodes must have specific labels and taints" — likewise.
If the desired state can be expressed as a convention baked into code, a CRD is unnecessary.
Typical Cases of Controllers That Watch Built-in Resources
Case 1 — Label-based ConfigMap/Secret Sync
The most common request. "Spread this ConfigMap to all team namespaces." Examples are a shared CA certificate, shared registry credentials, shared config values. The controller watches ConfigMaps labeled on the source and replicates/synchronizes their contents to the target namespaces. When the source changes, all replicas are updated, and if someone changes a replica by hand, it is reverted to match the source.
Case 2 — Namespace Bootstrap
Each time a new namespace is created, automatically inject a default set of resources: a default ResourceQuota, LimitRange, a default-deny NetworkPolicy, default ServiceAccount RBAC, and so on. The controller watches namespace creation events and creates the missing default resources.
Case 3 — Node Labeling/Taint Management
Guarantee consistent labels or taints on nodes based on certain conditions (e.g., instance type, zone, presence of GPU). Normalizing cloud-applied labels into internal-standard labels is a representative task.
What these three have in common is that they all handle only built-in resources, with no new resource type for users to declare. The desired state is entirely a rule inside the controller code.
Implementing Without a CRD Using controller-runtime
controller-runtime is the foundation library of kubebuilder, but it can be used as-is without a CRD. The only key is to specify the watch target as a built-in type (corev1.ConfigMap, etc.).
The following is the skeleton of a controller that "synchronizes a labeled source ConfigMap to target namespaces."
package main
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)
const syncLabel = "example.com/sync"
// ConfigMapSyncReconciler replicates labeled ConfigMaps into target namespaces.
type ConfigMapSyncReconciler struct {
client.Client
TargetNamespaces []string
}
func (r *ConfigMapSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
var src corev1.ConfigMap
if err := r.Get(ctx, req.NamespacedName, &src); err != nil {
// Ignore if the source was deleted (add cleanup logic if needed)
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Not a target if it lacks the sync label.
if src.Labels[syncLabel] != "true" {
return ctrl.Result{}, nil
}
for _, ns := range r.TargetNamespaces {
if ns == src.Namespace {
continue
}
desired := corev1.ConfigMap{}
desired.Name = src.Name
desired.Namespace = ns
desired.Data = src.Data
var existing corev1.ConfigMap
key := types.NamespacedName{Namespace: ns, Name: src.Name}
err := r.Get(ctx, key, &existing)
switch {
case errors.IsNotFound(err):
if err := r.Create(ctx, &desired); err != nil {
return ctrl.Result{}, err
}
l.Info("created replica", "namespace", ns)
case err != nil:
return ctrl.Result{}, err
default:
existing.Data = src.Data
if err := r.Update(ctx, &existing); err != nil {
return ctrl.Result{}, err
}
l.Info("updated replica", "namespace", ns)
}
}
return ctrl.Result{}, nil
}
func (r *ConfigMapSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.ConfigMap{}). // watch a built-in type — no CRD needed
Complete(r)
}
The one line worth noting is passing the built-in ConfigMap type into For(...). If you had built this with kubebuilder, a homegrown CR type would usually go in that spot; here we simply put a built-in type. No CRD, no API type definition, no deepcopy generation needed.
The part in main that starts the manager is also standard.
func main() {
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
if err != nil {
panic(err)
}
reconciler := &ConfigMapSyncReconciler{
Client: mgr.GetClient(),
TargetNamespaces: []string{"team-a", "team-b", "team-c"},
}
if err := reconciler.SetupWithManager(mgr); err != nil {
panic(err)
}
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
panic(err)
}
}
Reducing Unnecessary Reconciles with Predicates
The controller above invokes Reconcile for every ConfigMap change. Pulling in even unlabeled ConfigMaps is wasteful. Filtering ahead of time at the watch stage with a predicate greatly improves efficiency.
import "sigs.k8s.io/controller-runtime/pkg/builder"
import "sigs.k8s.io/controller-runtime/pkg/predicate"
import "sigs.k8s.io/controller-runtime/pkg/event"
func (r *ConfigMapSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {
hasSyncLabel := predicate.NewPredicateFuncs(func(obj client.Object) bool {
return obj.GetLabels()[syncLabel] == "true"
})
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.ConfigMap{}, builder.WithPredicates(hasSyncLabel)).
Complete(r)
}
This way only ConfigMaps with the sync label enter the reconcile queue, so even with thousands of ConfigMaps in the cluster the burden is small.
Operator vs Simple Controller — What's the Difference
Some terminology clarification is needed. The terms are often used interchangeably in practice, but a clear distinction is as follows.
| Category | Simple controller | Operator |
|---|---|---|
| CRD presence | None (watches built-in resources) | Present (defines its own CR) |
| Source of desired state | Convention in code | A CR declared by the user |
| User interface | Labels/annotations/namespaces | Writing a CR with kubectl |
| Suitable work | Enforcing internal operational rules | Automating application operations |
| Complexity | Low | High |
The key branch point is "do users need new vocabulary to declare intent?" If users must express rich intent like "3 instances, backup daily at 2 a.m." as for a PostgreSQL cluster, a CR is needed, and that is an Operator. Conversely, if it is a fixed rule like "resources with this label are handled this way," labels suffice, and that is a simple controller.
Practical advice: when in doubt, start without a CRD. It causes less regret to first build on labels/annotations, and introduce a CRD only when the intent users must express truly becomes too rich for labels to bear.
Building With client-go Without kubebuilder (Overview)
There is an even lighter path. Writing a controller directly with client-go informers without even using controller-runtime. This is the pattern used by standard Kubernetes controllers (e.g., inside kube-controller-manager).
import (
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/workqueue"
)
// Core building blocks
// 1. SharedInformerFactory — watches built-in resources + local cache
// 2. workqueue — a queue that collects keys to process (rate limit, retry)
// 3. EventHandler — adds keys to the queue on Add/Update/Delete
// 4. worker loop — pulls keys from the queue and runs reconcile logic
func setupInformer(clientset *kubernetes.Clientset, queue workqueue.RateLimitingInterface) {
factory := informers.NewSharedInformerFactory(clientset, 0)
cmInformer := factory.Core().V1().ConfigMaps().Informer()
cmInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
key, _ := cache.MetaNamespaceKeyFunc(obj)
queue.Add(key)
},
UpdateFunc: func(old, new interface{}) {
key, _ := cache.MetaNamespaceKeyFunc(new)
queue.Add(key)
},
})
}
This approach has the advantage of lighter dependencies and transparent mechanics, but you must wire up informers/workqueue/leader election all by hand. In most cases controller-runtime abstracts this boilerplate well, so unless you have a special reason, controller-runtime is recommended. Using client-go directly fits special situations where you must "minimize library dependencies or fully control the behavior."
Policy With an Admission Webhook Alone
Another form of automation is not a controller but an admission webhook. If a controller "reconciles an already-created resource later," an admission webhook "validates or mutates a resource before it is stored."
- Validating webhook: rejects resources that violate rules. E.g., "reject any Pod without a team label."
- Mutating webhook: automatically modifies a resource before storing. E.g., "auto-inject a sidecar container," "auto-add default labels."
These days, instead of hand-writing webhooks in Go, using a policy engine is common. Kyverno (Kubernetes-native, YAML policies) and OPA Gatekeeper (Rego language) are representative. The following is a Kyverno policy example that enforces a label.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-team-label
spec:
validationFailureAction: Enforce
rules:
- name: check-team-label
match:
any:
- resources:
kinds:
- Pod
validate:
message: "Every Pod requires a team label."
pattern:
metadata:
labels:
team: "?*"
Webhooks (policies) and controllers are complementary. A webhook "blocks wrong things at the entrance," while a controller "continuously keeps existing things in the correct state." Using both together is powerful.
A Collection of Internal Automation Ideas
These are real tasks well suited to CRD-less controllers.
- Auto-inject standard RBAC, ResourceQuota, NetworkPolicy into new namespaces
- Sync a shared ImagePullSecret to all namespaces
- Watch Secrets holding soon-to-expire certificates and trigger renewal
- Auto-attach standard annotations (monitoring scrape config, etc.) to Deployments with a specific label
- Periodically clean up orphaned (ownerless) resources
- Assist auto cordon/uncordon based on node conditions
What these have in common is "instead of humans tending internal conventions every time, let the controller guarantee them."
Pitfalls — Permissions and Infinite Loops
Even though the absence of a CRD looks simple, controllers handling built-in resources have their own pitfalls.
Pitfall 1 — Excessive RBAC
Handling built-in resources tends to require broad permissions over ConfigMap, Secret, Namespace, etc. Read access to all Secrets means being able to read every credential in the cluster, so be careful. Honor the principle of least privilege and, where possible, narrow RBAC to the target namespaces. If you use controller-runtime, it is good to generate permissions explicitly with RBAC markers.
Pitfall 2 — Infinite Reconcile Loop
The most common and dangerous pitfall. When a controller modifies a resource, that modification triggers another watch event, which runs reconcile again, which modifies again... an infinite loop. It happens especially often when updating a resource the controller itself manages.
How to prevent it:
- Call Update only when modification is truly needed. Compare current state with desired state and do nothing if there is no diff (idempotency).
- Use predicates to ignore events where only status changed (e.g., a generation-change filter).
- Use annotations/hashes to distinguish your own changes from external changes.
Pitfall 3 — Conflict With Other Controllers
If your controller applies a label and another controller (or a GitOps agent) removes that label, the two fight endlessly. Make clear who owns a given field, and if you use it alongside GitOps, exclude controller-managed fields with ignoreDifferences.
Pitfall 4 — Cache Consistency
The controller-runtime client reads from cache by default. If you Get a resource right after creating it, it may not be in the cache yet. Assume such races, treat NotFound as part of the normal flow, and design to retry via requeue when needed.
Operations — Deployment and Observability
A CRD-less controller still requires the same operational concerns.
- Single-instance guarantee: enable leader election so multiple replicas do not touch the same resource simultaneously. controller-runtime supports it by default.
- Metrics: expose reconcile count, error rate, and processing latency. controller-runtime provides default metrics.
- Event recording: leave important reconcile actions as Events so they are traceable via kubectl describe.
- Health checks: attach liveness/readiness probes.
- AuthN/AuthZ: the metrics endpoint must be protected. Recent controller-runtime handles this with WithAuthenticationAndAuthorization, and a separate kube-rbac-proxy sidecar is no longer needed.
Deployment Manifests — Putting the Controller Onto the Cluster
Once you have written the code, you now have to deploy it to the cluster. Since there is no CRD, all you need is three things: a ServiceAccount, RBAC, and a Deployment.
# 1) ServiceAccount for the controller
apiVersion: v1
kind: ServiceAccount
metadata:
name: cm-sync-controller
namespace: platform-system
---
# 2) Least-privilege RBAC — for ConfigMaps only, and only the verbs needed
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cm-sync-controller
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cm-sync-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cm-sync-controller
subjects:
- kind: ServiceAccount
name: cm-sync-controller
namespace: platform-system
---
# 3) Controller Deployment (single instance, enabling leader election is recommended)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cm-sync-controller
namespace: platform-system
spec:
replicas: 1
selector:
matchLabels:
app: cm-sync-controller
template:
metadata:
labels:
app: cm-sync-controller
spec:
serviceAccountName: cm-sync-controller
containers:
- name: controller
image: registry.example.com/cm-sync-controller:v0.1.0
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
Note that the RBAC grants permissions only on ConfigMaps, and only the verbs needed without delete. Since it does not handle Secrets, it has no Secret permissions at all. This is the concrete shape of the least privilege emphasized earlier. Narrowing permissions limits the blast radius even if the controller is compromised.
Testing — Verifying Reconcile With envtest
A CRD-less controller still needs tests. envtest from the controller-runtime ecosystem lets you verify reconcile without a real cluster by spinning up a fake API server (etcd + kube-apiserver binaries).
// The broad flow of a test (at pseudocode level)
// 1. Start a test API server with envtest
// 2. Create a source ConfigMap with the label
// 3. Call reconciler.Reconcile (or run the manager)
// 4. Assert that a replica appeared in the target namespace
// 5. Change the source Data and reconcile again -> confirm the replica is updated
func TestConfigMapSync(t *testing.T) {
// Prepare the envtest environment
// (start TestEnv, register scheme, create client)
// given: a labeled source ConfigMap
// when: reconcile runs
// then: a replica with identical Data exists in the target namespace
}
What you must especially confirm in tests is idempotency. When reconcile is called twice with the same input, the second call should make no changes. If this property breaks, it leads to infinite loops or unnecessary API load in production. Recent setup-envtest can fetch binaries up to the Kubernetes 1.36 line, so you can verify in an environment close to your production version.
Closing
In Kubernetes automation, "let's build an Operator" is often too heavy a starting point. A CRD is justified only when there is genuinely new intent for users to express. For enforcing internal conventions or tending built-in resources, a CRD-less simple controller (or a policy engine) is far lighter and easier to maintain.
To restate the core: the essence of a controller is the reconcile loop, not a CRD. You can build powerful automation just by watching built-in resources, and the starting point is one line of a label and one line of controller-runtime's For(). Just keep permissions minimal and the loop idempotent — those two are all it takes.
References
- Kubernetes controller concepts: https://kubernetes.io/docs/concepts/architecture/controller/
- controller-runtime: https://pkg.go.dev/sigs.k8s.io/controller-runtime
- Kubebuilder Book: https://book.kubebuilder.io/
- client-go example (sample controller): https://github.com/kubernetes/sample-controller
- Admission Controllers reference: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
- Kyverno official docs: https://kyverno.io/docs/
- OPA Gatekeeper: https://open-policy-agent.github.io/gatekeeper/website/docs/
- RBAC authorization: https://kubernetes.io/docs/reference/access-authn-authz/rbac/
- controller-runtime GitHub: https://github.com/kubernetes-sigs/controller-runtime