<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Ankur Sinha</title>
    <description>The latest articles on Forem by Ankur Sinha (@ankrsinha).</description>
    <link>https://forem.com/ankrsinha</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F905661%2Fe0046082-6813-48f9-a95a-14e82e18b976.png</url>
      <title>Forem: Ankur Sinha</title>
      <link>https://forem.com/ankrsinha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ankrsinha"/>
    <language>en</language>
    <item>
      <title>From client-go to controller-runtime: Rebuilding a Kubernetes Controller</title>
      <dc:creator>Ankur Sinha</dc:creator>
      <pubDate>Fri, 13 Mar 2026 04:37:59 +0000</pubDate>
      <link>https://forem.com/ankrsinha/from-client-go-to-controller-runtime-rebuilding-a-kubernetes-controller-5c20</link>
      <guid>https://forem.com/ankrsinha/from-client-go-to-controller-runtime-rebuilding-a-kubernetes-controller-5c20</guid>
      <description>&lt;p&gt;In my previous article, I built a Kubernetes controller from scratch using &lt;strong&gt;client-go, informers, and workqueues&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you haven't read it yet, you can check it here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://dev.to/ankrsinha/from-crds-to-controllers-building-a-kubernetes-custom-controller-from-scratch-3ibk"&gt;https://dev.to/ankrsinha/from-crds-to-controllers-building-a-kubernetes-custom-controller-from-scratch-3ibk&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In that project, I built &lt;strong&gt;Mini Task Runner&lt;/strong&gt;, a simplified Tekton-like system where a &lt;code&gt;Task&lt;/code&gt; defines container steps and a &lt;code&gt;TaskRun&lt;/code&gt; triggers their execution. The controller watches &lt;code&gt;TaskRun&lt;/code&gt; resources and creates a Pod that runs those steps.&lt;/p&gt;

&lt;p&gt;While building the controller with raw client-go primitives helped me understand how Kubernetes controllers work internally, most real-world projects such as Kubebuilder and Operator SDK use a higher-level framework called controller-runtime to build controllers. Other systems like Tekton use similar abstractions built on top of client-go.&lt;/p&gt;

&lt;p&gt;In this post, I rebuild the same Mini Task Runner controller using &lt;strong&gt;controller-runtime&lt;/strong&gt; and explore how the architecture changes compared to the manual client-go implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Migration Motivation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why move from client-go → controller-runtime
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;client-go&lt;/code&gt; library provides the fundamental building blocks required to interact with the Kubernetes API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Informers&lt;/li&gt;
&lt;li&gt;Listers&lt;/li&gt;
&lt;li&gt;Workqueues&lt;/li&gt;
&lt;li&gt;Typed clients&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These primitives are powerful, but they are also &lt;strong&gt;low-level&lt;/strong&gt;. When writing controllers directly with client-go, developers must assemble all of these components manually.&lt;/p&gt;

&lt;p&gt;In my first controller implementation, I had to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create informer factories&lt;/li&gt;
&lt;li&gt;Attach event handlers&lt;/li&gt;
&lt;li&gt;Maintain a rate-limited workqueue&lt;/li&gt;
&lt;li&gt;Implement worker goroutines&lt;/li&gt;
&lt;li&gt;Handle retry logic&lt;/li&gt;
&lt;li&gt;Manage relationships between resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of this code exists &lt;strong&gt;before the actual reconciliation logic even begins&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;controller-runtime was introduced to simplify this process by providing higher-level abstractions. Instead of wiring controller infrastructure manually, developers can focus primarily on &lt;strong&gt;reconciliation logic&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is why many production Kubernetes controllers use controller-runtime as their foundation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problems in Manual Controllers
&lt;/h3&gt;

&lt;p&gt;Writing the controller manually exposed several pain points.&lt;/p&gt;

&lt;p&gt;A large portion of the code was dedicated to &lt;strong&gt;controller infrastructure&lt;/strong&gt; rather than business logic. Informers had to be initialized, caches needed to be synced, event handlers registered, and worker routines had to continuously process items from a workqueue.&lt;/p&gt;

&lt;p&gt;Handling relationships between resources also required additional logic. For example, when a Pod changed state, the controller had to explicitly map that update back to the corresponding &lt;code&gt;TaskRun&lt;/code&gt;. This often required adding labels or writing custom mapping logic.&lt;/p&gt;

&lt;p&gt;Workqueue management was another responsibility. If reconciliation failed, the key had to be requeued using rate limiting to avoid overwhelming the API server.&lt;/p&gt;

&lt;p&gt;None of this logic was directly related to the &lt;strong&gt;actual goal of the controller&lt;/strong&gt;, which was simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Observe a TaskRun and ensure a Pod exists that executes the Task.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This made the controller more complex than necessary.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Architecture Changes
&lt;/h2&gt;

&lt;p&gt;Migrating to controller-runtime significantly simplified the architecture. The controller now revolves around four core ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manager&lt;/li&gt;
&lt;li&gt;Reconciler&lt;/li&gt;
&lt;li&gt;Cached Client&lt;/li&gt;
&lt;li&gt;Automatic Workqueue&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Manager
&lt;/h3&gt;

&lt;p&gt;In controller-runtime, everything begins with the &lt;strong&gt;controller manager&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The manager acts as the central runtime for controllers. It is responsible for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;starting controllers&lt;/li&gt;
&lt;li&gt;maintaining shared caches&lt;/li&gt;
&lt;li&gt;providing Kubernetes clients&lt;/li&gt;
&lt;li&gt;coordinating controller lifecycle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The controller registers itself with the manager, which ensures it runs continuously.&lt;/p&gt;

&lt;p&gt;A typical initialization looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetConfigOrDie&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Scheme&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once started, the manager runs all registered controllers.&lt;/p&gt;

&lt;h4&gt;
  
  
  Controller Builder Pattern
&lt;/h4&gt;

&lt;p&gt;In controller-runtime, controllers are typically registered using the &lt;strong&gt;Controller Builder Pattern&lt;/strong&gt;. This pattern connects the controller with the manager and defines which resources should trigger reconciliation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewControllerManagedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;miniv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskRun&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Owns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pod&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;TaskRunReconciler&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;TaskRun&lt;/code&gt; is the &lt;strong&gt;primary resource&lt;/strong&gt; being watched by the controller. The &lt;code&gt;Owns(&amp;amp;corev1.Pod{})&lt;/code&gt; declaration tells controller-runtime to also watch Pods created by the controller. Whenever a Pod owned by a &lt;code&gt;TaskRun&lt;/code&gt; changes state, the corresponding &lt;code&gt;TaskRun&lt;/code&gt; is automatically enqueued for reconciliation.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Complete()&lt;/code&gt; call registers the &lt;code&gt;TaskRunReconciler&lt;/code&gt;, which contains the reconciliation logic executed for each event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reconciler
&lt;/h3&gt;

&lt;p&gt;Instead of manually implementing worker loops, controller-runtime uses the &lt;strong&gt;Reconciler pattern&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The framework automatically calls a &lt;code&gt;Reconcile()&lt;/code&gt; function whenever a relevant resource event occurs.&lt;/p&gt;

&lt;p&gt;The reconciler receives a request containing the resource's namespace and name. Its responsibility is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the current state of the resource&lt;/li&gt;
&lt;li&gt;Compare it with the desired state&lt;/li&gt;
&lt;li&gt;Apply changes to move the system toward that desired state&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the Mini Task Runner controller, reconciliation follows a simple state machine based on the &lt;code&gt;TaskRun&lt;/code&gt; phase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the TaskRun is new → create a Pod&lt;/li&gt;
&lt;li&gt;If the Pod is running → update status&lt;/li&gt;
&lt;li&gt;If the Pod finishes → mark success or failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reconciler focuses purely on this logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cached Client
&lt;/h3&gt;

&lt;p&gt;controller-runtime provides a &lt;strong&gt;cached Kubernetes client&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Reads from the cluster are served from a local cache maintained by shared informers rather than directly hitting the API server.&lt;/p&gt;

&lt;p&gt;This provides two advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reduced API server load&lt;/li&gt;
&lt;li&gt;Faster read operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fetching a resource therefore looks very simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt; &lt;span class="n"&gt;miniv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskRun&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NamespacedName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The framework manages cache synchronization internally.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automatic Workqueue
&lt;/h3&gt;

&lt;p&gt;Another major difference from the client-go implementation is that the &lt;strong&gt;workqueue is no longer explicitly managed in the code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;controller-runtime automatically creates and manages the workqueue.&lt;/p&gt;

&lt;p&gt;Events such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TaskRun creation&lt;/li&gt;
&lt;li&gt;TaskRun updates&lt;/li&gt;
&lt;li&gt;Pod updates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;automatically enqueue reconciliation requests.&lt;/p&gt;

&lt;p&gt;The framework also handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retry behavior&lt;/li&gt;
&lt;li&gt;rate limiting&lt;/li&gt;
&lt;li&gt;worker execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This removes a significant amount of boilerplate code.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Benefits Observed
&lt;/h2&gt;

&lt;p&gt;After migrating the controller, several improvements became clear.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reduced Boilerplate
&lt;/h3&gt;

&lt;p&gt;Most of the setup code required for informers, queues, and workers disappeared.&lt;/p&gt;

&lt;p&gt;In the previous controller, a large portion of the code was dedicated to wiring infrastructure components. With controller-runtime, the controller setup became much shorter and easier to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in Retries
&lt;/h3&gt;

&lt;p&gt;controller-runtime automatically retries reconciliation when errors occur.&lt;/p&gt;

&lt;p&gt;If the &lt;code&gt;Reconcile&lt;/code&gt; function returns an error, the request is requeued with exponential backoff.&lt;/p&gt;

&lt;p&gt;This ensures the controller remains resilient without explicitly writing retry logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaner Design
&lt;/h3&gt;

&lt;p&gt;The controller structure becomes easier to reason about.&lt;/p&gt;

&lt;p&gt;Instead of thinking about informers, worker threads, and workqueues, the developer focuses on &lt;strong&gt;reconciliation — comparing desired state with actual state and applying the necessary changes.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Easier Ownership Handling
&lt;/h3&gt;

&lt;p&gt;controller-runtime provides utilities for managing ownership relationships between resources.&lt;/p&gt;

&lt;p&gt;For example, when creating a Pod for a TaskRun, the Pod can be set as a child resource using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetControllerReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This automatically enables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;garbage collection of Pods when the TaskRun is deleted&lt;/li&gt;
&lt;li&gt;reconciliation when the Pod status changes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Maintainability
&lt;/h3&gt;

&lt;p&gt;With less infrastructure code and clearer separation of responsibilities, the controller becomes easier to extend and maintain.&lt;/p&gt;

&lt;p&gt;Future improvements can be implemented by modifying the reconciliation logic rather than changing controller wiring.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Challenges Faced
&lt;/h2&gt;

&lt;p&gt;Although controller-runtime simplifies controller development, there were still a few areas that required careful attention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Status Updates
&lt;/h3&gt;

&lt;p&gt;Updating the &lt;code&gt;status&lt;/code&gt; field of a custom resource must be done using the &lt;strong&gt;status subresource API&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of using the normal client update, the controller must call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction is important because Kubernetes treats &lt;code&gt;spec&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; updates differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache Behavior
&lt;/h3&gt;

&lt;p&gt;Because the client reads from a local cache, the result of a write operation might not immediately appear in subsequent reads.&lt;/p&gt;

&lt;p&gt;For example, right after creating a Pod, the cache might not yet contain that object.&lt;/p&gt;

&lt;p&gt;To handle this safely, reconciliation logic must be &lt;strong&gt;idempotent&lt;/strong&gt;, meaning the controller should behave correctly even if the same operation is attempted multiple times.&lt;/p&gt;

&lt;h3&gt;
  
  
  Requeue Logic
&lt;/h3&gt;

&lt;p&gt;Some situations require the controller to revisit an object after a short delay.&lt;/p&gt;

&lt;p&gt;In the Mini Task Runner, while a Pod is running, the controller periodically rechecks its status.&lt;/p&gt;

&lt;p&gt;controller-runtime supports this using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choosing when to rely on events versus explicit requeueing required some experimentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging Reconciliation
&lt;/h3&gt;

&lt;p&gt;Because reconciliation is event-driven and asynchronous, debugging sometimes requires adding detailed logs to understand when and why reconciliation is triggered.&lt;/p&gt;

&lt;p&gt;Structured logging helped trace the lifecycle of a &lt;code&gt;TaskRun&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Final Outcome
&lt;/h2&gt;

&lt;p&gt;After completing the migration, the controller showed several improvements compared to the original implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance
&lt;/h3&gt;

&lt;p&gt;Using cached clients reduces the number of direct API server calls. This improves scalability and ensures the controller behaves efficiently as the number of resources grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simplicity
&lt;/h3&gt;

&lt;p&gt;The controller code became significantly shorter and easier to understand.&lt;/p&gt;

&lt;p&gt;Most of the complexity related to informers and workqueues is now handled by controller-runtime, allowing the code to focus primarily on &lt;strong&gt;business logic&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production Readiness
&lt;/h3&gt;

&lt;p&gt;controller-runtime follows patterns widely used in modern Kubernetes controllers.&lt;/p&gt;

&lt;p&gt;After rebuilding the Mini Task Runner controller using this framework, the system aligns more closely with how real-world Kubernetes operators are implemented.&lt;/p&gt;

&lt;p&gt;The controller was also containerized and pushed to GitHub Container Registry (GHCR). The image was then deployed inside the cluster using a standard Kubernetes Deployment.&lt;/p&gt;

&lt;p&gt;When running inside the cluster, the controller uses Kubernetes &lt;strong&gt;in-cluster configuration&lt;/strong&gt; instead of a local kubeconfig file. This allows the controller to communicate with the API server using the service account mounted inside the Pod.&lt;/p&gt;

&lt;p&gt;Because the controller needs to create Pods and update TaskRun resources, appropriate &lt;strong&gt;RBAC permissions&lt;/strong&gt; were defined using a ServiceAccount, ClusterRole, and ClusterRoleBinding. This ensures the controller has only the permissions required to reconcile resources inside the cluster.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want to Try the controller-runtime Version?
&lt;/h2&gt;

&lt;p&gt;This article focuses on the architectural changes introduced by &lt;strong&gt;controller-runtime&lt;/strong&gt;. If you want to explore the implementation or run it yourself, you can find the code in the same repository.&lt;/p&gt;

&lt;p&gt;The controller-runtime version of &lt;strong&gt;Mini Task Runner&lt;/strong&gt; is available in the &lt;strong&gt;&lt;code&gt;fork2&lt;/code&gt; branch&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ankrsinha/mini-task/tree/fork2" rel="noopener noreferrer"&gt;https://github.com/ankrsinha/mini-task/tree/fork2&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository contains instructions for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;building the controller&lt;/li&gt;
&lt;li&gt;containerizing it with Docker&lt;/li&gt;
&lt;li&gt;pushing the image to &lt;strong&gt;GitHub Container Registry (GHCR)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;deploying the controller in a Kubernetes cluster&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;Building the first controller with raw client-go helped me understand the mechanics behind Kubernetes controllers: informers, caches, workqueues, and worker loops.&lt;/p&gt;

&lt;p&gt;Migrating the same controller to controller-runtime showed how those mechanics can be abstracted into a cleaner and more maintainable framework.&lt;/p&gt;

&lt;p&gt;Both approaches are valuable learning experiences. But if the goal is to build controllers that resemble those used in production Kubernetes projects, controller-runtime provides a much more practical starting point.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Authors&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ankur Sinha&lt;/li&gt;
&lt;li&gt;Aditya Shinde&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>beginners</category>
      <category>opensource</category>
    </item>
    <item>
      <title>From CRDs to Controllers: Building a Kubernetes Custom Controller from Scratch</title>
      <dc:creator>Ankur Sinha</dc:creator>
      <pubDate>Tue, 24 Feb 2026 05:47:10 +0000</pubDate>
      <link>https://forem.com/ankrsinha/from-crds-to-controllers-building-a-kubernetes-custom-controller-from-scratch-3ibk</link>
      <guid>https://forem.com/ankrsinha/from-crds-to-controllers-building-a-kubernetes-custom-controller-from-scratch-3ibk</guid>
      <description>&lt;p&gt;If you’ve worked with Kubernetes, you’ve probably seen how it can orchestrate complex workflows using custom resources.&lt;/p&gt;

&lt;p&gt;But how does this actually work behind the scenes?&lt;/p&gt;

&lt;p&gt;To understand this better, I built &lt;strong&gt;Mini Task Runner&lt;/strong&gt;, a simplified Tekton-like task execution system from scratch. This project explores how Kubernetes can be extended using &lt;strong&gt;Custom Resource Definitions (CRDs)&lt;/strong&gt; and &lt;strong&gt;custom controllers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In this post, we will look at how custom resources interact with the Kubernetes API server and &lt;code&gt;etcd&lt;/code&gt;, why code generation is required when building controllers in Go, and how an event-driven controller architecture works.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Kubernetes Extensibility with CRDs
&lt;/h2&gt;

&lt;p&gt;Out of the box, Kubernetes understands a fixed set of standard resources such as &lt;strong&gt;Pods&lt;/strong&gt;, &lt;strong&gt;Deployments&lt;/strong&gt;, &lt;strong&gt;Services&lt;/strong&gt;, and &lt;strong&gt;ReplicaSets&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you submit a Deployment to the API server, the configuration is stored in &lt;strong&gt;etcd&lt;/strong&gt;, which acts as the cluster’s key-value datastore. Built-in controllers monitor these resources and continuously reconcile the desired state with the actual state of the cluster.&lt;/p&gt;

&lt;p&gt;But what if you want Kubernetes to understand concepts like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a CI/CD pipeline&lt;/li&gt;
&lt;li&gt;a database cluster&lt;/li&gt;
&lt;li&gt;a workflow execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where &lt;strong&gt;Custom Resource Definitions (CRDs)&lt;/strong&gt; come in.&lt;/p&gt;

&lt;p&gt;CRDs allow developers to define their own API objects and extend the Kubernetes API.&lt;/p&gt;

&lt;p&gt;For the &lt;strong&gt;Mini Task Runner&lt;/strong&gt;, I defined two custom resources:&lt;/p&gt;

&lt;h3&gt;
  
  
  Task
&lt;/h3&gt;

&lt;p&gt;A reusable template describing the steps required to execute a task.&lt;/p&gt;

&lt;p&gt;Each step specifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a container image&lt;/li&gt;
&lt;li&gt;a script to run&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  TaskRun
&lt;/h3&gt;

&lt;p&gt;An execution instance that triggers a Task.&lt;/p&gt;

&lt;p&gt;A TaskRun references a Task and stores execution status such as the current phase, start time, and completion time.&lt;/p&gt;

&lt;p&gt;However, defining CRDs alone is not enough. Kubernetes will store these resources in &lt;code&gt;etcd&lt;/code&gt;, but nothing will act on them.&lt;/p&gt;

&lt;p&gt;To make these resources functional, we need a &lt;strong&gt;Custom Controller&lt;/strong&gt;. The controller watches the API server for changes to these resources and performs actions to reconcile the desired state with the current state.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Code Generation for Custom Resources
&lt;/h2&gt;

&lt;p&gt;Before writing the controller logic, our Go program needs to understand the custom API types (&lt;code&gt;Task&lt;/code&gt; and &lt;code&gt;TaskRun&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Unlike built-in Kubernetes objects, these types do not exist in standard Kubernetes client libraries. As a result, we must generate supporting code.&lt;/p&gt;

&lt;p&gt;Every Kubernetes object must implement certain interfaces, including methods that safely create deep copies of objects in memory. Writing these functions manually would be error-prone and repetitive.&lt;/p&gt;

&lt;p&gt;Kubernetes provides &lt;strong&gt;code-generation tools&lt;/strong&gt; that automatically generate the required plumbing.&lt;/p&gt;

&lt;p&gt;After defining the Go structs for our custom resources and adding the appropriate annotations, the code generator produces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DeepCopy methods&lt;/strong&gt; – required for Kubernetes object handling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed clientsets&lt;/strong&gt; – strongly typed clients for interacting with the API server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Informers and listers&lt;/strong&gt; – used to build event-driven controllers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After code generation, the controller has two clients available:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;core Kubernetes client&lt;/strong&gt; to create and manage resources such as Pods&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;custom client&lt;/strong&gt; to interact with the Task and TaskRun resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This generated code provides the foundation needed to implement the controller.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Controller Architecture
&lt;/h2&gt;

&lt;p&gt;A controller is responsible for observing changes in the cluster and taking actions to move the system toward the desired state.&lt;/p&gt;

&lt;p&gt;There are two main approaches for implementing a controller.&lt;/p&gt;




&lt;h3&gt;
  
  
  Polling-Based Controller
&lt;/h3&gt;

&lt;p&gt;A naive approach would be to continuously poll the Kubernetes API server.&lt;/p&gt;

&lt;p&gt;For example, a loop might periodically ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;list all TaskRuns&lt;/li&gt;
&lt;li&gt;list all Pods&lt;/li&gt;
&lt;li&gt;compare the state&lt;/li&gt;
&lt;li&gt;take action if needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm8i5j6hje05hfnu93fdr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm8i5j6hje05hfnu93fdr.png" alt="Polling.png" width="776" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Polling is inefficient because it repeatedly queries the API server, increasing load and introducing unnecessary latency.&lt;/p&gt;

&lt;p&gt;Kubernetes controllers instead rely on an &lt;strong&gt;event-driven architecture&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Event-Driven Controller Architecture
&lt;/h3&gt;

&lt;p&gt;Production-grade Kubernetes controllers rely on four key components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Informers&lt;/li&gt;
&lt;li&gt;Listers&lt;/li&gt;
&lt;li&gt;Workqueues&lt;/li&gt;
&lt;li&gt;Workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp2d03fx4crq5ro3qm1bq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp2d03fx4crq5ro3qm1bq.png" alt="Workflow" width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Informers
&lt;/h4&gt;

&lt;p&gt;Informers maintain a long-lived &lt;strong&gt;watch connection&lt;/strong&gt; with the Kubernetes API server. Whenever a resource is created, updated, or deleted, the API server sends an event to the informer.&lt;/p&gt;

&lt;p&gt;This avoids repeated polling and allows the controller to react immediately to changes.&lt;/p&gt;

&lt;h4&gt;
  
  
  Listers (Local Cache)
&lt;/h4&gt;

&lt;p&gt;Informers maintain a local cache of resources. Instead of querying the API server for every read operation, the controller reads data from this in-memory cache.&lt;/p&gt;

&lt;p&gt;This significantly reduces network calls and improves performance.&lt;/p&gt;

&lt;h4&gt;
  
  
  Workqueue
&lt;/h4&gt;

&lt;p&gt;When an event occurs, the informer pushes the resource key into a &lt;strong&gt;rate-limited workqueue&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The queue acts as a buffer that ensures resources are processed safely and avoids overwhelming the controller during bursts of updates.&lt;/p&gt;

&lt;h4&gt;
  
  
  Workers
&lt;/h4&gt;

&lt;p&gt;Workers are background goroutines that continuously read items from the workqueue and process them using the controller’s reconciliation logic.&lt;/p&gt;

&lt;p&gt;Multiple workers can run in parallel, allowing the controller to handle many events concurrently.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The Reconciliation Loop
&lt;/h2&gt;

&lt;p&gt;The core logic of the controller is implemented in the &lt;strong&gt;reconciliation loop&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A worker retrieves a resource key from the workqueue and performs the following steps:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fetch the Current State
&lt;/h3&gt;

&lt;p&gt;The controller retrieves the corresponding TaskRun from the local cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Determine the Current Phase
&lt;/h3&gt;

&lt;p&gt;The controller checks the TaskRun status.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the phase is &lt;strong&gt;Succeeded&lt;/strong&gt; or &lt;strong&gt;Failed&lt;/strong&gt;, no further action is required.&lt;/li&gt;
&lt;li&gt;If the phase is &lt;strong&gt;empty&lt;/strong&gt;, it indicates a new execution request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Start Execution
&lt;/h3&gt;

&lt;p&gt;For a new TaskRun:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The referenced Task is retrieved.&lt;/li&gt;
&lt;li&gt;Task steps are converted into container definitions.&lt;/li&gt;
&lt;li&gt;A Pod is created with &lt;code&gt;restartPolicy: Never&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The TaskRun status is updated to &lt;strong&gt;Pending&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Track Execution
&lt;/h3&gt;

&lt;p&gt;If the TaskRun is in &lt;strong&gt;Pending&lt;/strong&gt; or &lt;strong&gt;Running&lt;/strong&gt;, the controller checks the associated Pod.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the Pod is running → update phase to &lt;strong&gt;Running&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If the Pod succeeds → update phase to &lt;strong&gt;Succeeded&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If the Pod fails → update phase to &lt;strong&gt;Failed&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Update Status
&lt;/h3&gt;

&lt;p&gt;The TaskRun status is updated through the Kubernetes API server, ensuring the cluster state remains consistent.&lt;/p&gt;

&lt;p&gt;If reconciliation fails due to transient issues, the resource is automatically requeued with exponential backoff.&lt;/p&gt;

&lt;p&gt;This ensures reliability without overwhelming the API server.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Adding a kubectl Plugin
&lt;/h2&gt;

&lt;p&gt;Creating TaskRun resources manually using YAML can be inconvenient. Tools like Tekton provide CLI utilities to simplify this process.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl&lt;/code&gt; supports plugins, which allows custom commands to be added easily.&lt;/p&gt;

&lt;p&gt;If an executable named &lt;code&gt;kubectl-&amp;lt;command&amp;gt;&lt;/code&gt; is placed in the system PATH, &lt;code&gt;kubectl&lt;/code&gt; treats it as a native command.&lt;/p&gt;

&lt;p&gt;To improve usability, I implemented a small CLI tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl task start &amp;lt;task-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command creates a TaskRun resource with a unique name and submits it to the cluster.&lt;/p&gt;

&lt;p&gt;Once the TaskRun is created, the controller detects it through the informer, enqueues the event, and begins executing the corresponding Task.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want to Try It Yourself?
&lt;/h2&gt;

&lt;p&gt;This article focuses on the architecture and design of the controller.&lt;/p&gt;

&lt;p&gt;If you want to run the project yourself, the implementation described in this article is available in the &lt;strong&gt;&lt;code&gt;fork1&lt;/code&gt; branch&lt;/strong&gt; of the repository:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ankrsinha/mini-task/tree/fork1" rel="noopener noreferrer"&gt;https://github.com/ankrsinha/mini-task/tree/fork1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository includes instructions for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;installing the CRDs&lt;/li&gt;
&lt;li&gt;running the code generator&lt;/li&gt;
&lt;li&gt;starting the controller locally&lt;/li&gt;
&lt;li&gt;using the &lt;code&gt;kubectl task start&lt;/code&gt; plugin&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building &lt;strong&gt;Mini Task Runner&lt;/strong&gt; provided a deeper understanding of how Kubernetes controllers operate internally.&lt;/p&gt;

&lt;p&gt;Some key takeaways from this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CRDs define the data model&lt;/strong&gt;, but controllers provide the logic that makes them useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code generation simplifies controller development&lt;/strong&gt; by generating clients, informers, and deep-copy methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event-driven architectures using informers and workqueues&lt;/strong&gt; allow controllers to scale efficiently without overloading the API server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anyone interested in Kubernetes internals, implementing a custom controller is one of the best ways to understand how the control plane operates.&lt;/p&gt;




&lt;h3&gt;
  
  
  Next Step
&lt;/h3&gt;

&lt;p&gt;In the next article, I rebuild the same controller using &lt;strong&gt;controller-runtime&lt;/strong&gt;, a framework widely used for implementing Kubernetes controllers.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;From client-go to controller-runtime: Rebuilding a Kubernetes Controller&lt;/strong&gt; &lt;a href="https://dev.to/ankrsinha/from-client-go-to-controller-runtime-rebuilding-a-kubernetes-controller-5c20"&gt;https://dev.to/ankrsinha/from-client-go-to-controller-runtime-rebuilding-a-kubernetes-controller-5c20&lt;/a&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Authors&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ankur Sinha&lt;/li&gt;
&lt;li&gt;Aditya Shinde&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
