<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>controller-runtime Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/controller-runtime/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/controller-runtime/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Sat, 09 May 2026 18:40:40 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>controller-runtime Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/controller-runtime/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Build a Simple Kubernetes Operator with controller-runtime and kubebuilder</title>
		<link>https://linuxcent.com/build-kubernetes-operator-controller-runtime/</link>
					<comments>https://linuxcent.com/build-kubernetes-operator-controller-runtime/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:16:26 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[controller-runtime]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[kubebuilder]]></category>
		<category><![CDATA[Operator]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<guid isPermaLink="false">https://linuxcent.com/build-kubernetes-operator-controller-runtime/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>Build a real Kubernetes operator with controller-runtime and kubebuilder — scaffold, reconciler code, RBAC markers, and testing on a local cluster.</p>
<p>The post <a href="https://linuxcent.com/build-kubernetes-operator-controller-runtime/">Build a Simple Kubernetes Operator with controller-runtime and kubebuilder</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 7</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · </em><em><a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a></em><em> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Building a Kubernetes operator means writing a Go reconciler with <code class="" data-line="">controller-runtime</code> — kubebuilder scaffolds the project structure, RBAC markers, and Makefile targets so you focus on the reconcile logic<br />
  <em>(kubebuilder = a CLI and framework that generates the operator project scaffold; controller-runtime = the Go library that provides the informer cache, work queue, and reconciler interface)</em></li>
<li>The reconciler for <code class="" data-line="">BackupPolicy</code> in this episode creates and manages a <code class="" data-line="">CronJob</code> — it is the behavior layer for the CRD built in EP03–EP05</li>
<li>RBAC is expressed as Go code comments (<code class="" data-line="">//+kubebuilder:rbac:...</code>) — kubebuilder generates the ClusterRole YAML from them</li>
<li>Run the operator locally with <code class="" data-line="">make run</code> during development; no cluster deployment needed until ready</li>
<li>The same project that builds the operator also builds and installs the CRD — <code class="" data-line="">make install</code> applies the CRD YAML generated from your Go types</li>
<li>Testing: the operator ships with <code class="" data-line="">envtest</code> — a local API server + etcd for controller testing without a real cluster</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  OPERATOR PROJECT STRUCTURE (kubebuilder scaffold)

  backup-operator/
  ├── api/v1alpha1/
  │   ├── backuppolicy_types.go     ← Go types that define CRD schema
  │   └── groupversion_info.go
  ├── internal/controller/
  │   └── backuppolicy_controller.go ← reconcile logic (our main focus)
  ├── config/
  │   ├── crd/                       ← generated CRD YAML
  │   ├── rbac/                      ← generated RBAC YAML
  │   └── manager/                   ← controller Deployment YAML
  ├── cmd/main.go                    ← entrypoint, sets up the manager
  └── Makefile                       ← build, test, install, deploy targets

  FLOW:
  Go types → kubebuilder generate → CRD YAML + RBAC YAML
  Reconcile function → runs in cluster → watches BackupPolicy → manages CronJobs
</code></pre>
<p>Building a Kubernetes operator with <code class="" data-line="">controller-runtime</code> is where CRDs become living infrastructure — the <code class="" data-line="">BackupPolicy</code> objects created in <a href="/write-kubernetes-crd-yaml-walkthrough/">EP04</a> now get actual behavior attached to them.</p>
<hr />
<h2 id="prerequisites">Prerequisites</h2>
<pre><code class="" data-line=""># Go 1.22+
go version

# kubebuilder CLI
curl -L -o kubebuilder \
  https://github.com/kubernetes-sigs/kubebuilder/releases/latest/download/kubebuilder_linux_amd64
chmod +x kubebuilder
sudo mv kubebuilder /usr/local/bin/

# A running cluster (kind works well for development)
kind create cluster --name operator-dev

# Verify kubectl works
kubectl cluster-info --context kind-operator-dev
</code></pre>
<hr />
<h2 id="step-1-scaffold-the-project">Step 1: Scaffold the Project</h2>
<pre><code class="" data-line="">mkdir backup-operator &amp;&amp; cd backup-operator

# Initialize the Go module and project structure
kubebuilder init \
  --domain storage.example.com \
  --repo github.com/example/backup-operator

# Create the API (Go types + controller scaffold)
kubebuilder create api \
  --group storage \
  --version v1alpha1 \
  --kind BackupPolicy \
  --resource \
  --controller
</code></pre>
<p>When prompted:</p>
<pre><code class="" data-line="">Create Resource [y/n]: y
Create Controller [y/n]: y
</code></pre>
<p>The generated directory tree:</p>
<pre><code class="" data-line="">backup-operator/
├── api/
│   └── v1alpha1/
│       ├── backuppolicy_types.go
│       └── groupversion_info.go
├── internal/
│   └── controller/
│       └── backuppolicy_controller.go
├── cmd/
│   └── main.go
├── config/
│   ├── crd/bases/
│   ├── rbac/
│   └── manager/
├── go.mod
├── go.sum
└── Makefile
</code></pre>
<hr />
<h2 id="step-2-define-the-go-types">Step 2: Define the Go Types</h2>
<p>Edit <code class="" data-line="">api/v1alpha1/backuppolicy_types.go</code> to match the schema from <a href="/kubernetes-crd-schema-explained/">EP03</a>:</p>
<pre><code class="" data-line="">package v1alpha1

import (
    metav1 &quot;k8s.io/apimachinery/pkg/apis/meta/v1&quot;
)

// BackupTarget specifies a namespace to include in the backup.
type BackupTarget struct {
    Namespace      string `json:&quot;namespace&quot;`
    IncludeSecrets bool   `json:&quot;includeSecrets,omitempty&quot;`
}

// BackupPolicySpec defines the desired state of BackupPolicy.
type BackupPolicySpec struct {
    // Schedule is a cron expression for when to run backups.
    // +kubebuilder:validation:Pattern=`^(\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+)$`
    Schedule string `json:&quot;schedule&quot;`

    // RetentionDays is how long to keep backup snapshots.
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=365
    RetentionDays int32 `json:&quot;retentionDays&quot;`

    // StorageClass is the storage class to use for backup volumes.
    // +kubebuilder:default=standard
    // +kubebuilder:validation:Enum=standard;premium;encrypted;archive
    StorageClass string `json:&quot;storageClass,omitempty&quot;`

    // Targets lists the namespaces and resources to include.
    // +kubebuilder:validation:MaxItems=20
    Targets []BackupTarget `json:&quot;targets,omitempty&quot;`

    // Suspended pauses backup execution when true.
    // +kubebuilder:default=false
    Suspended bool `json:&quot;suspended,omitempty&quot;`
}

// BackupPolicyStatus defines the observed state of BackupPolicy.
type BackupPolicyStatus struct {
    // Conditions reflect the current state of the BackupPolicy.
    Conditions []metav1.Condition `json:&quot;conditions,omitempty&quot;`

    // LastBackupTime is when the most recent backup completed.
    LastBackupTime *metav1.Time `json:&quot;lastBackupTime,omitempty&quot;`

    // CronJobName is the name of the managed CronJob.
    CronJobName string `json:&quot;cronJobName,omitempty&quot;`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name=&quot;Schedule&quot;,type=string,JSONPath=`.spec.schedule`
// +kubebuilder:printcolumn:name=&quot;Retention&quot;,type=integer,JSONPath=`.spec.retentionDays`
// +kubebuilder:printcolumn:name=&quot;Suspended&quot;,type=boolean,JSONPath=`.spec.suspended`
// +kubebuilder:printcolumn:name=&quot;Ready&quot;,type=string,JSONPath=`.status.conditions[?(@.type==&#039;Ready&#039;)].status`
// +kubebuilder:printcolumn:name=&quot;Age&quot;,type=date,JSONPath=`.metadata.creationTimestamp`

// BackupPolicy is the Schema for the backuppolicies API.
type BackupPolicy struct {
    metav1.TypeMeta   `json:&quot;,inline&quot;`
    metav1.ObjectMeta `json:&quot;metadata,omitempty&quot;`

    Spec   BackupPolicySpec   `json:&quot;spec,omitempty&quot;`
    Status BackupPolicyStatus `json:&quot;status,omitempty&quot;`
}

// +kubebuilder:object:root=true

// BackupPolicyList contains a list of BackupPolicy.
type BackupPolicyList struct {
    metav1.TypeMeta `json:&quot;,inline&quot;`
    metav1.ListMeta `json:&quot;metadata,omitempty&quot;`
    Items           []BackupPolicy `json:&quot;items&quot;`
}

func init() {
    SchemeBuilder.Register(&amp;BackupPolicy{}, &amp;BackupPolicyList{})
}
</code></pre>
<p>Regenerate the CRD YAML and DeepCopy methods:</p>
<pre><code class="" data-line="">make generate   # regenerates zz_generated.deepcopy.go
make manifests  # regenerates CRD YAML under config/crd/bases/
</code></pre>
<hr />
<h2 id="step-3-write-the-reconciler">Step 3: Write the Reconciler</h2>
<p>Edit <code class="" data-line="">internal/controller/backuppolicy_controller.go</code>:</p>
<pre><code class="" data-line="">package controller

import (
    &quot;context&quot;
    &quot;fmt&quot;

    batchv1 &quot;k8s.io/api/batch/v1&quot;
    corev1 &quot;k8s.io/api/core/v1&quot;
    apierrors &quot;k8s.io/apimachinery/pkg/api/errors&quot;
    &quot;k8s.io/apimachinery/pkg/api/meta&quot;
    metav1 &quot;k8s.io/apimachinery/pkg/apis/meta/v1&quot;
    &quot;k8s.io/apimachinery/pkg/runtime&quot;
    &quot;k8s.io/apimachinery/pkg/types&quot;
    ctrl &quot;sigs.k8s.io/controller-runtime&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/client&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/log&quot;

    storagev1alpha1 &quot;github.com/example/backup-operator/api/v1alpha1&quot;
)

// BackupPolicyReconciler reconciles BackupPolicy objects.
type BackupPolicyReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// RBAC markers — kubebuilder generates ClusterRole YAML from these comments.
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies/finalizers,verbs=update
//+kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete

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

    // Step 1: Fetch the BackupPolicy
    bp := &amp;storagev1alpha1.BackupPolicy{}
    if err := r.Get(ctx, req.NamespacedName, bp); err != nil {
        if apierrors.IsNotFound(err) {
            // Object deleted before we could reconcile — nothing to do.
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, fmt.Errorf(&quot;fetching BackupPolicy: %w&quot;, err)
    }

    // Step 2: Define the desired CronJob name
    cronJobName := fmt.Sprintf(&quot;%s-backup&quot;, bp.Name)

    // Step 3: Fetch the existing CronJob (if any)
    existing := &amp;batchv1.CronJob{}
    err := r.Get(ctx, types.NamespacedName{Name: cronJobName, Namespace: bp.Namespace}, existing)
    notFound := apierrors.IsNotFound(err)
    if err != nil &amp;&amp; !notFound {
        return ctrl.Result{}, fmt.Errorf(&quot;fetching CronJob: %w&quot;, err)
    }

    // Step 4: Build the desired CronJob
    desired := r.buildCronJob(bp, cronJobName)

    // Step 5: Create or update
    if notFound {
        logger.Info(&quot;Creating CronJob&quot;, &quot;name&quot;, cronJobName)
        if err := r.Create(ctx, desired); err != nil {
            return ctrl.Result{}, fmt.Errorf(&quot;creating CronJob: %w&quot;, err)
        }
    } else {
        // Update schedule and suspend state if they differ
        if existing.Spec.Schedule != desired.Spec.Schedule ||
            existing.Spec.Suspend != desired.Spec.Suspend {
            existing.Spec.Schedule = desired.Spec.Schedule
            existing.Spec.Suspend = desired.Spec.Suspend
            logger.Info(&quot;Updating CronJob&quot;, &quot;name&quot;, cronJobName)
            if err := r.Update(ctx, existing); err != nil {
                return ctrl.Result{}, fmt.Errorf(&quot;updating CronJob: %w&quot;, err)
            }
        }
    }

    // Step 6: Update status
    bpCopy := bp.DeepCopy()
    meta.SetStatusCondition(&amp;bpCopy.Status.Conditions, metav1.Condition{
        Type:               &quot;Ready&quot;,
        Status:             metav1.ConditionTrue,
        Reason:             &quot;CronJobReady&quot;,
        Message:            fmt.Sprintf(&quot;CronJob %s is configured&quot;, cronJobName),
        ObservedGeneration: bp.Generation,
    })
    bpCopy.Status.CronJobName = cronJobName

    if err := r.Status().Update(ctx, bpCopy); err != nil {
        return ctrl.Result{}, fmt.Errorf(&quot;updating status: %w&quot;, err)
    }

    return ctrl.Result{}, nil
}

func (r *BackupPolicyReconciler) buildCronJob(bp *storagev1alpha1.BackupPolicy, name string) *batchv1.CronJob {
    suspend := bp.Spec.Suspended
    retentionArg := fmt.Sprintf(&quot;--retention-days=%d&quot;, bp.Spec.RetentionDays)

    cj := &amp;batchv1.CronJob{
        ObjectMeta: metav1.ObjectMeta{
            Name:      name,
            Namespace: bp.Namespace,
            Labels: map[string]string{
                &quot;app.kubernetes.io/managed-by&quot;: &quot;backup-operator&quot;,
                &quot;backuppolicy&quot;:                 bp.Name,
            },
        },
        Spec: batchv1.CronJobSpec{
            Schedule: bp.Spec.Schedule,
            Suspend:  &amp;suspend,
            JobTemplate: batchv1.JobTemplateSpec{
                Spec: batchv1.JobSpec{
                    Template: corev1.PodTemplateSpec{
                        Spec: corev1.PodSpec{
                            RestartPolicy: corev1.RestartPolicyOnFailure,
                            Containers: []corev1.Container{
                                {
                                    Name:    &quot;backup&quot;,
                                    Image:   &quot;backup-tool:latest&quot;,
                                    Args:    []string{retentionArg},
                                },
                            },
                        },
                    },
                },
            },
        },
    }

    // Set owner reference — CronJob is garbage-collected when BackupPolicy is deleted
    _ = ctrl.SetControllerReference(bp, cj, r.Scheme)
    return cj
}

// SetupWithManager registers the controller with the manager and declares what to watch.
func (r *BackupPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&amp;storagev1alpha1.BackupPolicy{}).
        Owns(&amp;batchv1.CronJob{}).    // reconcile BackupPolicy when owned CronJob changes
        Complete(r)
}
</code></pre>
<hr />
<h2 id="step-4-install-the-crd-and-run-locally">Step 4: Install the CRD and Run Locally</h2>
<pre><code class="" data-line=""># Install the CRD into the cluster
make install
</code></pre>
<pre><code class="" data-line="">customresourcedefinition.apiextensions.k8s.io/backuppolicies.storage.example.com created
</code></pre>
<pre><code class="" data-line=""># Run the controller locally (outside the cluster)
make run
</code></pre>
<pre><code class="" data-line="">2026-04-25T08:00:00Z  INFO  Starting manager
2026-04-25T08:00:00Z  INFO  Starting workers  {&quot;controller&quot;: &quot;backuppolicy&quot;, &quot;worker count&quot;: 1}
</code></pre>
<p>In a separate terminal:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: nightly
  namespace: default
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
EOF
</code></pre>
<p>Watch the controller output:</p>
<pre><code class="" data-line="">2026-04-25T08:01:00Z  INFO  Creating CronJob  {&quot;name&quot;: &quot;nightly-backup&quot;}
</code></pre>
<p>Check the result:</p>
<pre><code class="" data-line="">kubectl get bp nightly
</code></pre>
<pre><code class="" data-line="">NAME      SCHEDULE    RETENTION   SUSPENDED   READY   AGE
nightly   0 2 * * *   30          false       True    10s
</code></pre>
<pre><code class="" data-line="">kubectl get cronjob nightly-backup
</code></pre>
<pre><code class="" data-line="">NAME             SCHEDULE    SUSPEND   ACTIVE   LAST SCHEDULE   AGE
nightly-backup   0 2 * * *   False     0        &lt;none&gt;          10s
</code></pre>
<p>Test self-healing — delete the CronJob and watch the controller recreate it:</p>
<pre><code class="" data-line="">kubectl delete cronjob nightly-backup
# Controller output:
# 2026-04-25T08:02:00Z  INFO  Creating CronJob  {&quot;name&quot;: &quot;nightly-backup&quot;}

kubectl get cronjob nightly-backup
# Back within seconds
</code></pre>
<p>Test suspend:</p>
<pre><code class="" data-line="">kubectl patch bp nightly --type=merge -p &#039;{&quot;spec&quot;:{&quot;suspended&quot;:true}}&#039;
kubectl get cronjob nightly-backup -o jsonpath=&#039;{.spec.suspend}&#039;
# true
</code></pre>
<hr />
<h2 id="step-5-deploy-to-cluster">Step 5: Deploy to Cluster</h2>
<p>When ready for in-cluster deployment:</p>
<pre><code class="" data-line=""># Build and push the controller image
make docker-build docker-push IMG=your-registry/backup-operator:v0.1.0

# Deploy to cluster (creates Deployment, RBAC, CRD)
make deploy IMG=your-registry/backup-operator:v0.1.0
</code></pre>
<pre><code class="" data-line="">kubectl get pods -n backup-operator-system
</code></pre>
<pre><code class="" data-line="">NAME                                          READY   STATUS    RESTARTS   AGE
backup-operator-controller-manager-abc123     2/2     Running   0          30s
</code></pre>
<hr />
<h2 id="understanding-the-rbac-markers">Understanding the RBAC Markers</h2>
<p>The <code class="" data-line="">//+kubebuilder:rbac:...</code> comments in the controller generate the ClusterRole YAML when you run <code class="" data-line="">make manifests</code>:</p>
<pre><code class="" data-line="">//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
</code></pre>
<p>Generated YAML under <code class="" data-line="">config/rbac/role.yaml</code>:</p>
<pre><code class="" data-line="">apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: manager-role
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies/status&quot;]
    verbs: [&quot;get&quot;, &quot;update&quot;, &quot;patch&quot;]
  - apiGroups: [&quot;batch&quot;]
    resources: [&quot;cronjobs&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
</code></pre>
<p>This approach keeps RBAC co-located with the code that needs it — if you add a new resource access in the controller, you add the marker next to it.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Not setting an owner reference on child resources.</strong> Without <code class="" data-line="">ctrl.SetControllerReference(parent, child, scheme)</code>, deleting the <code class="" data-line="">BackupPolicy</code> leaves orphaned CronJobs. Owner references enable automatic garbage collection of child resources.</p>
<p><strong>Updating the object after <code class="" data-line="">r.Get()</code> without handling conflicts.</strong> If two reconciles run concurrently (possible after a controller restart), both may try to update the same resource. The API server uses resource version for optimistic concurrency — you will get a conflict error. Retry the reconcile on conflict errors rather than failing.</p>
<p><strong>Writing to <code class="" data-line="">bp</code> directly instead of <code class="" data-line="">bp.DeepCopy()</code> for status updates.</strong> If the status update fails and you retry, the original <code class="" data-line="">bp</code> object now has the modified status in memory. Always update a deep copy when writing status so the in-memory state stays consistent with what was actually persisted.</p>
<p><strong>Not watching owned resources.</strong> If you forget <code class="" data-line="">.Owns(&amp;batchv1.CronJob{})</code> in <code class="" data-line="">SetupWithManager</code>, the controller will not reconcile when a CronJob is deleted. Self-healing requires watching the resources you manage.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># Scaffold a new API + controller
kubebuilder create api --group mygroup --version v1alpha1 --kind MyKind

# Regenerate deep copy methods after changing types
make generate

# Regenerate CRD YAML + RBAC from markers
make manifests

# Install CRD into current cluster
make install

# Run controller locally (outside cluster)
make run

# Build + push image, then deploy to cluster
make docker-build docker-push IMG=registry/operator:tag
make deploy IMG=registry/operator:tag

# Uninstall CRD (WARNING: deletes all instances)
make uninstall
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>kubebuilder scaffolds the project; you write the types and the reconcile function</li>
<li>Go struct markers (<code class="" data-line="">//+kubebuilder:...</code>) generate the CRD YAML and RBAC — keep them close to the code they describe</li>
<li><code class="" data-line="">ctrl.SetControllerReference</code> enables automatic garbage collection of child resources</li>
<li>Always deep-copy the object before writing status; retry on conflict errors</li>
<li><code class="" data-line="">make run</code> runs the controller locally — no Docker build needed during development</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-crd-versioning-conversion-webhook/">EP08: Kubernetes CRD Versioning</a> covers how to evolve the <code class="" data-line="">BackupPolicy</code> schema from <code class="" data-line="">v1alpha1</code> to <code class="" data-line="">v1</code> without breaking existing clients — storage versions, conversion webhooks, and the hub-and-spoke model for safe API evolution in production clusters.</p>
<p>Get EP08 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&#038;title=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" data-a2a-url="https://linuxcent.com/build-kubernetes-operator-controller-runtime/" data-a2a-title="Build a Simple Kubernetes Operator with controller-runtime and kubebuilder"></a></p><p>The post <a href="https://linuxcent.com/build-kubernetes-operator-controller-runtime/">Build a Simple Kubernetes Operator with controller-runtime and kubebuilder</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/build-kubernetes-operator-controller-runtime/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1693</post-id>	</item>
		<item>
		<title>The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime</title>
		<link>https://linuxcent.com/kubernetes-controller-reconcile-loop/</link>
					<comments>https://linuxcent.com/kubernetes-controller-reconcile-loop/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:16:12 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Controller]]></category>
		<category><![CDATA[controller-runtime]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Informer]]></category>
		<category><![CDATA[Operator Pattern]]></category>
		<category><![CDATA[Reconcile Loop]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-controller-reconcile-loop/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>Understand the Kubernetes controller reconcile loop — how a controller watches CRDs, detects drift, and reconciles desired vs actual state. The theory behind every operator.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-controller-reconcile-loop/">The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 6</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · </em><em><a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a></em><em> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>The Kubernetes controller reconcile loop is the mechanism that makes CRDs do something — it watches custom resources, compares desired state (spec) to actual state, and takes actions to close the gap<br />
  <em>(reconcile = &#8220;make actual match desired&#8221;; the loop runs repeatedly because the world is not static — things drift, fail, and change)</em></li>
<li>Controllers do not receive events like webhooks — they receive object names from a work queue, then re-read the full object from the API server cache</li>
<li>The reconcile function is <strong>idempotent</strong>: calling it ten times with the same object must produce the same result as calling it once</li>
<li><code class="" data-line="">controller-runtime</code> is the Go library that provides the informer cache, work queue, and reconciler interface — kubebuilder scaffolds controllers on top of it</li>
<li>Kubernetes uses the same reconcile loop internally — the Deployment controller, ReplicaSet controller, and node lifecycle controller all follow this exact pattern</li>
<li>A failed reconcile returns an error or explicit requeue request; the controller retries with exponential backoff, not an infinite tight loop</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  THE KUBERNETES CONTROLLER RECONCILE LOOP

  etcd
   │ change event
   ▼
  Informer cache
  (API server-side list+watch,
   local in-memory replica)
   │ cache update → enqueue object name
   ▼
  Work queue
  (rate-limited, deduplicating)
   │ dequeue: &quot;demo/nightly&quot;
   ▼
  Reconcile(ctx, Request{Name, Namespace})
   │
   ├── 1. Fetch object from cache
   │        if not found → ignore (already deleted)
   │
   ├── 2. Read spec (desired state)
   │
   ├── 3. Read actual state
   │        (check child resources, external systems)
   │
   ├── 4. Compare: actual vs desired
   │
   ├── 5. Act: create/update/delete child resources
   │        OR update external system
   │
   └── 6. Update status with outcome
           └── return Result{}, nil      → done
               return Result{Requeue}, nil → retry after delay
               return Result{}, err     → immediate retry + backoff
</code></pre>
<p>The Kubernetes controller reconcile loop is what separates a CRD (validated storage) from an operator (automated behavior). Understanding this loop is the prerequisite for writing controllers that work correctly under failure, partial completion, and concurrent modification.</p>
<hr />
<h2 id="what-reconcile-actually-means">What &#8220;Reconcile&#8221; Actually Means</h2>
<p>Reconcile means: look at what the user asked for (<code class="" data-line="">spec</code>), look at what actually exists, and do whatever is needed to make actual match desired.</p>
<p>The key insight is that this is <strong>not event-driven in the traditional sense</strong>. A controller does not receive a &#8220;diff&#8221; — it receives a name. It reads the full current state of the object and acts accordingly.</p>
<p>This matters because:</p>
<ol>
<li><strong>Multiple events get deduplicated.</strong> If a BackupPolicy is updated five times in one second, the work queue delivers one reconcile call, not five.</li>
<li><strong>The reconcile is stateless.</strong> The controller should not maintain in-memory state about what it &#8220;did last time.&#8221; It re-reads everything on each reconcile.</li>
<li><strong>Partial failure is safe.</strong> If the reconcile fails halfway through, the next reconcile re-reads actual state and continues from where it left off.</li>
</ol>
<hr />
<h2 id="the-informer-cache">The Informer Cache</h2>
<p>Controllers do not call the API server directly for every read. They use an <strong>informer</strong> — a list-and-watch mechanism that maintains a local in-memory copy of all objects of a given type.</p>
<pre><code class="" data-line="">  HOW THE INFORMER CACHE WORKS

  Controller startup:
  ┌─────────────────────────────────────────────────────┐
  │ 1. List all BackupPolicies from API server          │
  │    → populate local cache                           │
  │ 2. Establish a Watch stream                         │
  │    → receive incremental updates                    │
  │ 3. For each update: update cache + enqueue object   │
  └─────────────────────────────────────────────────────┘

  On reconcile:
  ┌─────────────────────────────────────────────────────┐
  │ controller reads from LOCAL cache (not API server)  │
  │ → fast, no network round-trip per reconcile         │
  │ → cache is eventually consistent                    │
  └─────────────────────────────────────────────────────┘
</code></pre>
<blockquote>
<p><strong>Cache consistency:</strong> After writing a change (creating a child Secret, for example), re-reading from the cache may return the old state for a brief period. This is normal and expected. Well-written controllers handle this by returning a requeue rather than assuming the write is immediately visible.</p>
</blockquote>
<hr />
<h2 id="walking-through-a-reconcile-for-backuppolicy">Walking Through a Reconcile for BackupPolicy</h2>
<p>Suppose a user creates this BackupPolicy:</p>
<pre><code class="" data-line="">apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: nightly
  namespace: demo
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
  targets:
    - namespace: production
</code></pre>
<p>The controller&#8217;s reconcile function runs. Here is what it does conceptually:</p>
<pre><code class="" data-line="">Reconcile(ctx, {Namespace: &quot;demo&quot;, Name: &quot;nightly&quot;})

Step 1: Fetch BackupPolicy &quot;demo/nightly&quot; from cache
  → found; spec.schedule = &quot;0 2 * * *&quot;, spec.retentionDays = 30

Step 2: Check if a CronJob for this BackupPolicy exists
  → kubectl get cronjob nightly-backup -n demo
  → not found

Step 3: Gap detected: CronJob should exist but doesn&#039;t
  → Create CronJob &quot;nightly-backup&quot; in namespace &quot;demo&quot;
    spec.schedule = &quot;0 2 * * *&quot;
    spec.jobTemplate.spec.template.spec.containers[0].args = [&quot;--retention=30&quot;]

Step 4: Set owner reference on CronJob pointing to BackupPolicy
  → CronJob is now garbage-collected if BackupPolicy is deleted

Step 5: Update BackupPolicy status
  → conditions: [{type: Ready, status: True, reason: CronJobCreated}]
  → lastScheduleTime: null (not yet run)

Step 6: Return Result{}, nil   → reconcile complete
</code></pre>
<p>Next time the BackupPolicy is modified (e.g., <code class="" data-line="">suspended: true</code>):</p>
<pre><code class="" data-line="">Reconcile(ctx, {Namespace: &quot;demo&quot;, Name: &quot;nightly&quot;})

Step 1: Fetch → spec.suspended = true

Step 2: Fetch CronJob &quot;nightly-backup&quot;
  → found; spec.suspend = false  ← actual state

Step 3: Gap: CronJob.spec.suspend should be true but is false
  → Patch CronJob: set spec.suspend = true

Step 4: Update status
  → conditions: [{type: Ready, status: True, reason: Suspended}]

Step 5: Return Result{}, nil
</code></pre>
<hr />
<h2 id="idempotency-the-essential-property">Idempotency: The Essential Property</h2>
<p>The reconcile function must be idempotent. If it runs ten times with the same object state, the result must be the same as if it ran once.</p>
<p>Why? Because the controller framework delivers at-least-once semantics — your reconcile function will be called more than once for the same object state, especially at startup (the informer re-lists all objects) and after controller restarts.</p>
<p><strong>Non-idempotent (wrong):</strong></p>
<pre><code class="" data-line="">// Creates a new CronJob every time, even if one already exists
err := r.Create(ctx, cronJob)
</code></pre>
<p><strong>Idempotent (correct):</strong></p>
<pre><code class="" data-line="">// Only creates if it doesn&#039;t exist; updates if it does
existing := &amp;batchv1.CronJob{}
err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: ns}, existing)
if apierrors.IsNotFound(err) {
    err = r.Create(ctx, cronJob)
} else if err == nil {
    // update if spec differs
    existing.Spec = cronJob.Spec
    err = r.Update(ctx, existing)
}
</code></pre>
<p>The get-before-create pattern is the most basic idempotency mechanism. <code class="" data-line="">controller-runtime</code> provides <code class="" data-line="">CreateOrUpdate</code> helpers that codify this.</p>
<hr />
<h2 id="requeue-and-retry-semantics">Requeue and Retry Semantics</h2>
<p>The reconcile function returns a <code class="" data-line="">(Result, error)</code> pair:</p>
<pre><code class="" data-line="">return Result{}, nil
  → Reconcile succeeded. Re-run only if object changes again.

return Result{RequeueAfter: 5 * time.Minute}, nil
  → Reconcile succeeded, but requeue in 5 minutes regardless.
  → Used for: polling external system, TTL-based refresh.

return Result{Requeue: true}, nil
  → Requeue immediately (with rate limiting).
  → Used for: cache not yet consistent after a write.

return Result{}, err
  → Reconcile failed. Retry with exponential backoff.
  → Used for: API errors, transient failures.
</code></pre>
<pre><code class="" data-line="">  RETRY BEHAVIOR

  First failure  → retry after ~1s
  Second failure → retry after ~2s
  Third failure  → retry after ~4s
  ...
  Max backoff    → ~16min (controller-runtime default)

  Object changes (new version from informer) → reset backoff, reconcile immediately
</code></pre>
<p>Do not return <code class="" data-line="">Result{Requeue: true}, nil</code> in a tight loop — this saturates the work queue and starves other objects. If you need to poll, use <code class="" data-line="">RequeueAfter</code> with a meaningful interval.</p>
<hr />
<h2 id="watches-what-triggers-a-reconcile">Watches: What Triggers a Reconcile</h2>
<p>The controller does not only watch the primary resource (BackupPolicy). It also watches child resources and maps child changes back to the parent:</p>
<pre><code class="" data-line="">  WATCH CONFIGURATION (conceptual)

  Controller watches:
    BackupPolicy (primary) → reconcile when BackupPolicy changes
    CronJob (child/owned)  → reconcile BackupPolicy owner when CronJob changes
    ConfigMap (watched)    → reconcile BackupPolicy when referenced ConfigMap changes
</code></pre>
<p>If a user accidentally deletes the CronJob that the controller created:</p>
<ol>
<li>CronJob deletion event arrives in the informer</li>
<li>Controller maps the deleted CronJob → its owner BackupPolicy</li>
<li>BackupPolicy is enqueued</li>
<li>Reconcile runs, detects missing CronJob, recreates it</li>
</ol>
<p>This &#8220;self-healing&#8221; behavior — where controllers reconcile the world back to desired state — is the core operational value of operators. It is not magic; it is the result of watching child resources and re-running reconcile when they drift.</p>
<hr />
<h2 id="level-triggered-vs-edge-triggered">Level-Triggered vs Edge-Triggered</h2>
<p>Kubernetes controllers are <strong>level-triggered</strong>, not edge-triggered. This distinction matters:</p>
<pre><code class="" data-line="">  EDGE-TRIGGERED (not what Kubernetes uses)
  → &quot;BackupPolicy was updated FROM retained-30 TO retained-7&quot;
  → If event is lost, the update is lost forever

  LEVEL-TRIGGERED (what Kubernetes uses)
  → &quot;BackupPolicy exists with retentionDays=7&quot;
  → On every reconcile, the controller reads the current level (state)
  → Missing an event is safe — the next reconcile corrects the state
</code></pre>
<p>Level-triggered design is why controllers survive restarts, network partitions, and lost events gracefully. The reconcile does not need to track &#8220;what changed&#8221; — it only needs to know &#8220;what is the desired state right now.&#8221;</p>
<hr />
<h2 id="the-same-pattern-in-kubernetes-core">The Same Pattern in Kubernetes Core</h2>
<p>Every built-in Kubernetes controller follows this loop:</p>
<table>
<thead>
<tr>
<th>Controller</th>
<th>Watches</th>
<th>Manages</th>
<th>Reconciles</th>
</tr>
</thead>
<tbody>
<tr>
<td>Deployment controller</td>
<td>Deployment</td>
<td>ReplicaSets</td>
<td>desired replicas <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> actual ReplicaSet count</td>
</tr>
<tr>
<td>ReplicaSet controller</td>
<td>ReplicaSet</td>
<td>Pods</td>
<td>desired replicas <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> running Pod count</td>
</tr>
<tr>
<td>Node lifecycle controller</td>
<td>Node</td>
<td>Node conditions</td>
<td>NotReady nodes → taint, evict pods</td>
</tr>
<tr>
<td>Service controller (cloud)</td>
<td>Service</td>
<td>LoadBalancer</td>
<td>cloud LB exists <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Service spec</td>
</tr>
</tbody>
</table>
<p>The <code class="" data-line="">BackupPolicy</code> controller you will build in <a href="/build-kubernetes-operator-controller-runtime/">EP07</a> follows exactly the same structure as the Deployment controller.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Reading from the API server directly instead of the cache.</strong> Every reconcile reading directly from the API server (not the informer cache) creates N×M load on the API server as the number of objects and reconcile frequency grows. Always read via the controller&#8217;s cached client.</p>
<p><strong>Not handling &#8220;not found&#8221; on object fetch.</strong> If a reconcile is triggered but the object has been deleted by the time reconcile runs, the cache returns &#8220;not found.&#8221; This is normal — the correct response is to return <code class="" data-line="">Result{}, nil</code>, not an error.</p>
<p><strong>Tight requeue loop on recoverable error.</strong> Returning <code class="" data-line="">Result{Requeue: true}, nil</code> or <code class="" data-line="">Result{}, err</code> on every call creates an infinite busy-loop. Use <code class="" data-line="">RequeueAfter</code> for expected wait conditions, and only return errors for unexpected failures that should back off.</p>
<p><strong>Mutable reconcile state.</strong> Do not store reconcile state in struct fields on the reconciler. The reconciler is shared across goroutines; mutable fields cause race conditions. Everything transient must be local to the reconcile function.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line="">Reconcile input:
  ctx context.Context
  req ctrl.Request   → {Namespace: &quot;demo&quot;, Name: &quot;nightly&quot;}

Reconcile output:
  (ctrl.Result, error)

Common returns:
  Result{}, nil                        → done, wait for next change
  Result{Requeue: true}, nil           → retry now (rate limited)
  Result{RequeueAfter: 5*time.Minute}  → retry in 5 minutes
  Result{}, err                        → retry with backoff

Key operations:
  r.Get(ctx, req.NamespacedName, &amp;obj)     → fetch from cache
  r.Create(ctx, &amp;obj)                      → create in API server
  r.Update(ctx, &amp;obj)                      → full update
  r.Patch(ctx, &amp;obj, patch)                → partial update
  r.Delete(ctx, &amp;obj)                      → delete
  r.Status().Update(ctx, &amp;obj)             → update status only
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>The reconcile loop reads desired state from <code class="" data-line="">spec</code>, reads actual state from the cluster, and closes the gap — on every trigger, not just on changes</li>
<li>Controllers use an informer cache for reads — fast, eventually consistent, does not hammer the API server</li>
<li>Idempotency is not optional: the reconcile function will be called multiple times with the same state</li>
<li>Level-triggered design means missing events is safe — the next reconcile corrects any drift</li>
<li>Return values from reconcile control retry behavior: <code class="" data-line="">RequeueAfter</code> for polling, <code class="" data-line="">err</code> for failures, <code class="" data-line="">nil</code> for success</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/build-kubernetes-operator-controller-runtime/">EP07: Build a Simple Kubernetes Operator with controller-runtime</a> puts the reconcile loop into practice — kubebuilder scaffold, a complete reconciler for <code class="" data-line="">BackupPolicy</code>, RBAC markers, and running the operator locally against a real cluster.</p>
<p>Get EP07 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&#038;title=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" data-a2a-url="https://linuxcent.com/kubernetes-controller-reconcile-loop/" data-a2a-title="The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-controller-reconcile-loop/">The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-controller-reconcile-loop/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1690</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-09-01 06:49:29 by W3 Total Cache
-->