Skip to content

필사 모드: Controllers Without CRDs — Patterns for Automating Existing Resources

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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

"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.

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).

"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

현재 단락 (1/273)

When the thought of automating something in Kubernetes arises, many people jump straight to "I need ...

작성 글자: 0원문 글자: 15,881작성 단락: 0/273