How to Build a Kubernetes Controller to Watch CRD Changes
Kubernetes has become the de facto standard for deploying and managing containerized applications, offering unparalleled scalability, resilience, and declarative configuration. At its core, Kubernetes operates on a reconciliation loop, continuously striving to match the "actual state" of the cluster with the "desired state" declared by users. This powerful paradigm is largely driven by its internal components, known as controllers, which watch for changes in resources and act accordingly. However, the true extensibility of Kubernetes shines through its Custom Resource Definitions (CRDs), which allow users to define their own resource types, extending the Kubernetes API itself.
The ability to create and manage custom resources opens up a world of possibilities for automating complex operational tasks and building sophisticated, Kubernetes-native applications. But merely defining a CRD is not enough; to bring these custom resources to life, you need a custom controller. A Kubernetes controller designed to watch CRD changes is the engine that observes instances of your custom resources, interprets their desired state, and takes the necessary steps to achieve and maintain that state within the cluster. This comprehensive guide will take you on an in-depth journey through the process of building such a controller, from understanding the fundamental concepts to writing, testing, and deploying a robust solution. We will delve into the intricacies of client-go, controller-runtime, and kubebuilder, providing a foundation for anyone looking to harness the full power of Kubernetes extensibility.
I. Understanding Kubernetes Controllers: The Heartbeat of Automation
At the very core of Kubernetes’ operational model lies the concept of a controller. These are active components that continuously monitor the state of your cluster and, when they detect discrepancies between the desired state (as specified in your resource definitions) and the actual state, they take corrective actions. This mechanism is often referred to as a control loop, a fundamental pattern in automation and system management that ensures a robust and self-healing infrastructure. Without controllers, Kubernetes would merely be a static data store for API objects; it's the controllers that imbue it with its dynamic, self-managing capabilities.
The Control Loop: Observe, Analyze, Act
The control loop is a continuous cycle of three primary phases: observe, analyze, and act. First, the controller observes the current state of specific resources within the Kubernetes API server. This observation can be passive, like periodically polling the API, or more commonly, event-driven, using a "watch" mechanism that notifies the controller of any changes in real-time. Once changes are detected, the controller then analyzes the current state in relation to the desired state. The desired state is typically expressed through the .spec field of a Kubernetes object, while the actual state is often reflected in the .status field. The analysis phase involves complex comparisons and logical determinations about what needs to be done. Finally, based on this analysis, the controller acts to reconcile any differences. This might involve creating new resources, updating existing ones, deleting stale resources, or even interacting with external systems. This cycle repeats indefinitely, ensuring that the cluster always converges towards the declared desired state, even in the face of failures or manual misconfigurations.
Desired State vs. Actual State: The Reconciliation Principle
The distinction between desired state and actual state is paramount to understanding Kubernetes. When you apply a YAML manifest to a Kubernetes cluster, you are declaring your desired state. For example, when you define a Deployment with three replicas, you are expressing the desire for three identical pods to be running. The Kubernetes Deployment controller then continuously monitors the actual number of running pods associated with that deployment. If it observes fewer than three, it acts by creating new Pod objects. If it observes more than three, it deletes the excess. This reconciliation principle is foundational; users declare what they want, and the system, through its controllers, figures out how to get there and maintain it. This declarative model simplifies operations significantly, as users no longer need to script imperative steps but rather describe the ultimate goal.
The API Server: The Central Nervous System
The Kubernetes API server acts as the front end for the Kubernetes control plane. It exposes the Kubernetes API, which is a RESTful interface, and serves as the single point of contact for all cluster interactions. All controllers, whether built-in or custom, communicate with the API server to perform their observation and action phases. When a controller needs to observe a resource, it queries the API server. When it needs to create, update, or delete a resource, it sends a request to the API server. This centralization ensures consistency, provides authentication and authorization for all operations, and serves as the persistent store for the cluster's state. Etcd, a distributed key-value store, underpins the API server, providing reliable storage for all Kubernetes objects.
Informers: Efficiently Watching for Changes
Directly polling the API server for changes can be inefficient and put a significant load on the API server, especially in large clusters or when monitoring many resources. To overcome this, Kubernetes controllers typically leverage a pattern called Informers. An Informer is a client-side cache and event notification system. Instead of constantly asking the API server "what's new?", an Informer establishes a long-lived connection to the API server and receives notifications (events) whenever a watched resource is added, updated, or deleted. These events trigger the controller's reconciliation logic. Crucially, the Informer also maintains an in-memory cache of the resources it's watching. This means that when a controller needs to read the current state of a resource, it can often do so from this local cache, significantly reducing the number of direct API server calls and improving performance.
Workqueues: Decoupling Event Handling from Reconciliation
When an Informer detects a change and fires an event (Add, Update, Delete), the controller needs a mechanism to process these events reliably and efficiently. This is where Workqueues come into play. A workqueue is essentially a queue that stores keys (typically namespace/name for a Kubernetes object) representing the resources that need reconciliation. When an event occurs for a resource, its key is added to the workqueue. The actual reconciliation logic is then handled by a separate worker goroutine that pulls items from the workqueue, processes them, and then marks them as done. This decoupling serves several vital purposes:
- Concurrency: Multiple worker goroutines can process items from the workqueue concurrently, speeding up reconciliation.
- Rate Limiting and Retries: If a reconciliation fails (e.g., due to a transient network error or a dependency not yet being ready), the item can be re-added to the workqueue with a backoff delay, ensuring eventual consistency without busy-waiting.
- Idempotency: Controllers are designed to be idempotent; processing the same item multiple times should yield the same result. The workqueue helps manage this by potentially adding duplicate keys, but the reconciler logic handles this gracefully.
- Order Guarantees: While overall concurrency is achieved, workqueues can also ensure that processing for a specific resource happens serially, preventing race conditions on a single object.
Shared Informers: Optimizing Resource Usage
In a complex Kubernetes environment, multiple controllers might need to watch the same types of resources. For example, several different custom controllers might all need to know about Pod events. If each controller ran its own dedicated Informer, they would each establish separate connections to the API server, maintain separate caches, and consume unnecessary network bandwidth and memory. Shared Informers solve this problem. A Shared Informer, as its name suggests, is a single Informer instance that is shared among multiple controllers within the same process. It establishes only one connection to the API server and maintains a single cache, which is then made available to all interested controllers. This significantly reduces the overhead on the API server and the memory footprint within the controller's process, leading to a more efficient and scalable control plane. The controller-runtime library heavily leverages Shared Informers to simplify controller development and ensure optimal resource utilization.
Components of a Controller: A Blueprint
While the internal mechanisms are complex, the logical components of a Kubernetes controller are relatively straightforward:
- Main Function/Entry Point: This is the starting point of your controller application. It typically initializes the
Manager(fromcontroller-runtime), registers your controllers, and starts the manager. - Client-go Library: This is the official Go client library for interacting with the Kubernetes API. It provides structured access to Kubernetes resources and handles HTTP requests, JSON serialization/deserialization, authentication, and error handling. Most modern controllers, however, abstract this away with
controller-runtime's client interface, which builds uponclient-go. - Watchers (via Informers): As discussed, these components are responsible for observing changes in specific Kubernetes resources (e.g., your custom resource or standard resources like Deployments, Services). They notify the controller's reconciliation logic when an event occurs.
- Reconciler: This is the core business logic of your controller. It receives a request (typically a
namespace/namepair) for a resource that needs reconciliation. Its job is to fetch the current state of that resource and any related resources, compare it to the desired state, and then perform the necessary API operations to bring the actual state in line with the desired state. This function must be idempotent. - Resource Event Handlers: These are small functions that are triggered by Informer events (Add, Update, Delete). Their primary role is to extract the relevant information from the event (e.g., the key of the changed resource) and add it to the workqueue for subsequent processing by the reconciler.
Why Build Custom Controllers? Expanding Kubernetes Horizons
Building custom controllers goes beyond simply automating existing Kubernetes resources; it's about extending the Kubernetes API to manage new types of resources that are specific to your applications or infrastructure. This paradigm shift offers tremendous benefits:
- Extending Kubernetes Capabilities: You can integrate external systems, manage application-specific infrastructure (e.g., databases, message queues), or implement complex deployment strategies directly within the Kubernetes ecosystem. This turns Kubernetes into a truly universal control plane for your entire technical stack.
- Automating Complex Operational Tasks: Instead of writing bespoke scripts or relying on manual interventions for tasks like database provisioning, certificate rotation, or multi-cluster deployments, you can encode this operational knowledge directly into a controller. This ensures consistency, reduces human error, and improves the reliability of your operations.
- Managing Custom Resources (CRDs): This is the primary driver for custom controller development. CRDs allow you to define high-level abstractions for your applications. For instance, instead of managing a
Deployment,Service,Ingress, andConfigMapseparately for a web application, you could define a singleWebAppcustom resource. Your controller would then watchWebAppinstances and automatically create and manage all the underlying standard Kubernetes resources required for thatWebAppto run. - Implementing Custom Admission Control Logic: While not the primary focus of this article, controllers can also work in conjunction with admission webhooks to enforce policies (validating webhooks) or inject default values (mutating webhooks) when resources are created or updated, further enhancing cluster governance.
- Examples of Use Cases:
- Database Operators: A popular example where a controller manages the entire lifecycle of a database instance (provisioning, scaling, backup, restore, upgrades) using a custom
DatabaseCRD. - Application Lifecycle Managers: Controllers that manage complex multi-component applications, handling dependencies, upgrades, and rollbacks.
- Network Policy Enforcers: Controllers that dynamically adjust network policies based on application-specific tags or external configuration.
- Database Operators: A popular example where a controller manages the entire lifecycle of a database instance (provisioning, scaling, backup, restore, upgrades) using a custom
By embracing custom controllers, you transform Kubernetes from a generic container orchestrator into a highly specialized platform perfectly tailored to your organization's unique needs, driving efficiency and innovation.
II. Deep Dive into Custom Resource Definitions (CRDs)
Before we can build a controller to watch CRD changes, we must first understand what a CRD is, why it's so powerful, and how to define one. CRDs are the cornerstone of Kubernetes' extensibility model, enabling users to expand the Kubernetes API with their own custom objects, thus building domain-specific abstractions directly into the control plane.
What is a CRD? Extending the Kubernetes API
Traditionally, Kubernetes comes with a predefined set of built-in resources like Pods, Deployments, Services, ConfigMaps, and Secrets. These are powerful and cover many common use cases. However, real-world applications often involve more complex concepts or external dependencies that don't neatly fit into these standard resource types. This is where CRDs come in. A Custom Resource Definition (CRD) is a declaration that tells the Kubernetes API server about a new, user-defined resource type. It extends the Kubernetes API without requiring you to modify the core Kubernetes source code or even recompile the API server. This means you can create your own API objects that behave just like native Kubernetes objects, complete with kubectl support, RESTful endpoints, and watch capabilities.
For instance, if your application stack frequently involves a "DatabaseInstance" which comprises a database, a corresponding user, and specific access credentials, you could define a DatabaseInstance CRD. Once defined, you can then create instances of this DatabaseInstance custom resource in your cluster, just as you would create a Deployment. The Kubernetes API server will then store and serve these custom objects, treating them as first-class citizens alongside its built-in resources.
Components of a CRD: Defining Your Custom Object
A CRD itself is a Kubernetes resource that defines the schema and behavior of a new custom resource. It’s essentially a blueprint. When you create a CRD, you are defining the metadata and structural rules for the instances of your custom resource.
The key fields in a CRD YAML definition include:
apiVersion: Specifies the API version for the CRD itself (e.g.,apiextensions.k8s.io/v1). This is different from theapiVersionof the custom resources that your CRD will define.kind: Must beCustomResourceDefinition.metadata: Standard Kubernetes metadata, including thenameof the CRD (which must be in the format<plural-name>.<group>).spec: This is where the actual definition of your custom resource resides.group: The API group to which your custom resource belongs (e.g.,mycompany.com,stable.example.com). This helps organize your custom resources and prevents naming conflicts.names: Defines the various names for your custom resource, making it user-friendly. This includes:plural: The plural name used in API endpoints (e.g.,myapps,databaseinstances). This is what you'll use withkubectl(e.g.,kubectl get myapps).singular: The singular name.kind: TheKindfield that instances of your custom resource will use (e.g.,MyApp,DatabaseInstance). This is used in thekindfield of the custom resource YAML.shortNames: Optional, provides shorthand aliases (e.g.,maformyapps).
scope: Defines whether the custom resource isNamespaced(like Pods and Deployments) orClusterscoped (like Nodes or StorageClasses). Most application-specific resources are Namespaced.versions: This is a crucial section where you define the different versions of your custom resource's schema. Each version object includes:name: The version string (e.g.,v1alpha1,v1).served: A boolean indicating if this version is served by the API.storage: A boolean indicating if this version is used for storing the resource in etcd. Only one version can bestorage: true.schema: This is where you define the OpenAPI v3 schema for your custom resource'sspecandstatusfields. This schema acts as a contract, enforcing data types, required fields, and structural rules, providing validation for your custom resources.
subresources: Optional, allows you to enable/statusand/scalesubresources, which are useful for controllers to update the status of a custom resource without affecting its spec, or for scaling.
Why Use CRDs? The Power of Abstraction
The advantages of using CRDs are significant for anyone building on Kubernetes:
- Abstraction and Simplification: CRDs allow you to create high-level abstractions that hide the underlying complexity of multiple standard Kubernetes resources. Instead of developers needing to understand and manage 10 different Kubernetes objects to deploy their application, they can interact with a single, custom
Applicationobject. This simplifies the developer experience and reduces the cognitive load. - Declarative Configuration for Domain-Specific Objects: Just like standard Kubernetes resources, CRDs embrace the declarative configuration model. Users define the desired state of their custom application or infrastructure component in a YAML file, and the controller ensures that state is achieved. This fits perfectly into a GitOps workflow, where infrastructure and application configurations are version-controlled and applied automatically.
- Kubernetes-Native Management: Custom resources benefit from all the tooling and ecosystem built around Kubernetes. You can use
kubectlto create, view, update, and delete them. They integrate with RBAC for access control, admission controllers for policy enforcement, and standard Kubernetes clients for programmatic interaction. - Standardization and Governance: By defining a CRD, you standardize how certain components or applications are deployed and managed across your organization. The schema validation ensures that all instances of your custom resource adhere to a predefined structure, improving consistency and reducing errors.
- GitOps Integration: CRDs are perfect candidates for GitOps practices. Defining your custom resources in Git alongside your standard Kubernetes manifests allows for a single source of truth, automated deployments, and clear audit trails for all infrastructure and application changes.
Creating a Sample CRD: A Practical Example
Let's illustrate with a simple example of a custom resource, MyApp, which might represent a basic web application.
First, we define the CRD itself in a YAML file (e.g., myapps_crd.yaml):
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
# Name must match the plural form of the kind, and the group name
name: myapps.stable.example.com
spec:
group: stable.example.com
versions:
- name: v1alpha1
served: true
storage: true # This marks v1alpha1 as the storage version
schema:
openAPIV3Schema:
type: object
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
properties:
image:
type: string
description: The Docker image to use for the application.
replicas:
type: integer
minimum: 1
description: The number of desired replicas for the application.
message:
type: string
description: A custom message to display in the application logs or UI.
required:
- image
- replicas
status:
type: object
properties:
availableReplicas:
type: integer
description: The number of currently available replicas.
currentState:
type: string
description: Current operational state of the MyApp.
lastUpdateTime:
type: string
format: date-time
description: Last time the status was updated.
scope: Namespaced # This resource will be specific to a Kubernetes namespace
names:
plural: myapps
singular: myapp
kind: MyApp
shortNames:
- ma
To apply this CRD to your Kubernetes cluster:
kubectl apply -f myapps_crd.yaml
You can verify its existence:
kubectl get crd myapps.stable.example.com
# Expected output:
# NAME CREATED AT
# myapps.stable.example.com 2023-10-27T10:00:00Z
Now that the CRD is registered with the API server, you can create instances of your MyApp custom resource. Create a file named my-first-app.yaml:
apiVersion: stable.example.com/v1alpha1
kind: MyApp
metadata:
name: my-first-app
namespace: default
spec:
image: "nginx:latest"
replicas: 2
message: "Hello from my custom app!"
Apply this custom resource:
kubectl apply -f my-first-app.yaml
You can now interact with your custom resource using kubectl:
kubectl get myapps
# Expected output:
# NAME IMAGE REPLICAS MESSAGE AGE
# my-first-app nginx:latest 2 Hello from my custom app! 10s
kubectl get myapp my-first-app -o yaml
At this point, you have successfully extended the Kubernetes API. The API server recognizes MyApp objects, stores them, and allows you to query them. However, nothing happens yet when you create, update, or delete a MyApp instance. This MyApp object is just data. To give it meaning and make it do something—like actually deploying an NGINX application—you need a custom controller. The next section will guide you through setting up the environment to build this controller, which will watch for changes in MyApp instances and bring them to life.
III. Setting Up Your Development Environment
Building a Kubernetes controller in Go requires a specific set of tools and libraries. While it's possible to write a controller from scratch using just the client-go library, the complexity of correctly implementing all the patterns (Informers, Workqueues, Shared Caches, Leader Election, metrics, webhooks) can be daunting. Fortunately, projects like controller-runtime and kubebuilder have emerged to simplify this process dramatically, providing robust frameworks and scaffolding tools.
Prerequisites: Your Toolbox
Before diving into code, ensure your development environment is properly configured with the following:
- Go Language (Version 1.19+ recommended): Controllers are predominantly written in Go. You can download and install Go from the official Go website (golang.org). Verify your installation with
go version. - Docker or Podman: You'll need a containerization tool to build your controller into a Docker image, which can then be deployed to your Kubernetes cluster. Docker Desktop or Podman are excellent choices. Ensure your Docker daemon or Podman service is running.
- Kubernetes Cluster: You need access to a Kubernetes cluster for testing and deployment.
- Minikube: A popular choice for local development, running a single-node Kubernetes cluster inside a VM on your machine.
- Kind (Kubernetes in Docker): Another excellent option for local development, running Kubernetes clusters as Docker containers, making it lightweight and fast.
- Managed Kubernetes Service: Cloud providers like GKE, EKS, AKS offer managed Kubernetes clusters that you can use.
- Ensure your
kubectlis configured to connect to your chosen cluster (kubectl get nodes).
kubectl: The command-line tool for interacting with your Kubernetes cluster. Make sure it's installed and configured.controller-runtimeLibrary: This is a core library that provides the abstractions and components necessary to build controllers quickly and correctly. It handles much of the boilerplate, like setting up Informers, caches, and workqueues. While you won't directly install it as an executable, it will be added as a Go module dependency to your project.operator-sdkorkubebuilder: These are scaffolding tools that generate boilerplate code for your controller project, including CRD definitions, controller logic skeletons, and deployment manifests. They significantly accelerate development and ensure adherence to best practices. For this guide, we'll primarily usekubebuilderdue to its direct integration withcontroller-runtimeand strong community support.
Installing kubebuilder: Your Scaffolding Assistant
kubebuilder is an essential tool for rapidly developing Kubernetes operators and controllers. It provides a CLI tool that helps you set up a new project, generate API definitions (CRDs), create controller boilerplate, and even manage webhooks.
To install kubebuilder, follow these steps:
- Verify Installation:
bash kubebuilder version # Expected output similar to: # kubebuilder version: 3.12.0 # commit: 8e8f8c8 # go version: go1.21.3 # ...
Download kubebuilder: You can download the latest version from the kubebuilder releases page on GitHub. It's often recommended to use a specific stable version. For example, to install v3.12.0:```bash
For Linux:
curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH) chmod +x kubebuilder sudo mv kubebuilder /usr/local/bin/ `` (Adjust for macOS or Windows if needed, refer tokubebuilder` official documentation for specific instructions.)
Project Initialization with kubebuilder: Setting the Stage
Now, let's create a new Go module for our controller and use kubebuilder to scaffold the project.
- Create a New Directory for Your Project:
bash mkdir my-crd-controller cd my-crd-controller - Initialize the Go Module:
bash go mod init github.com/your-username/my-crd-controller # Replace with your actual GitHub path - Initialize
kubebuilderProject: This command sets up the basic project structure and pulls in necessary dependencies likecontroller-runtime.bash kubebuilder init --domain stable.example.com --repo github.com/your-username/my-crd-controllerkubebuilder initwill generate several files and directories: *main.go: The entry point for your controller, setting up theManagerand starting it. *Dockerfile: For containerizing your controller. *go.mod,go.sum: Go module files. *Makefile: Contains useful commands for building, deploying, and managing your controller. *config/: Contains Kubernetes YAML manifests for deploying your controller (RBAC, Deployment, Kustomize files). *api/: Will contain your CRD definitions. *controllers/: Will contain your controller logic.--domain: This will be used as the API group suffix for your CRDs (e.g.,stable.example.com).--repo: The Go module path for your project.
- Create Your Custom Resource API and Controller: This is the crucial step where
kubebuildergenerates the CRD definition and the boilerplate for your controller. We'll use theMyAppexample from the previous section.bash kubebuilder create api --group stable --version v1alpha1 --kind MyApp --resource --controller*--group stable: Defines the API groupstable.example.com. *--version v1alpha1: Defines the API version for your custom resource. *--kind MyApp: Defines theKindof your custom resource. *--resource: Generates the API definition (the Go struct for your CRD). *--controller: Generates the controller boilerplate (Reconcilefunction,SetupWithManager).This command will generate: *api/v1alpha1/myapp_types.go: This file will contain the Go struct definitions forMyApp'sSpecandStatus. This is where you'll define the fields that describe your custom resource. *controllers/myapp_controller.go: This file will contain the main reconciliation logic for yourMyAppcontroller. It includes a basicReconcilefunction and aSetupWithManagerfunction. - Review Generated Files: Take a moment to explore the generated files.
api/v1alpha1/myapp_types.go: You'll seeMyAppSpecandMyAppStatusstructs.kubebuilderprovides placeholder comments for you to add your custom fields.controllers/myapp_controller.go: Notice theReconcilemethod signature and theSetupWithManagerfunction.SetupWithManageris where you tell thecontroller-runtimemanager which resources your controller should watch.
With your environment set up and the project scaffolded, you are now ready to dive into the core task: defining your custom resource fields and implementing the controller logic that will watch and reconcile changes to these resources. This structured approach, facilitated by kubebuilder and controller-runtime, significantly reduces the boilerplate and allows you to focus on the unique business logic of your custom controller.
APIPark is a high-performance AI gateway that allows you to securely access the most comprehensive LLM APIs globally on the APIPark platform, including OpenAI, Anthropic, Mistral, Llama2, Google Gemini, and more.Try APIPark now! 👇👇👇
IV. Building the Controller: Step-by-Step Implementation
Now that our project is scaffolded, it's time to define the structure of our custom resource and implement the core reconciliation logic. This involves modifying the generated API types, writing the Reconcile function to handle CRD changes, and configuring the controller to watch the relevant resources.
Defining the Custom Resource (CR): Adding Spec and Status Fields
The api/v1alpha1/myapp_types.go file contains the Go struct definitions for your MyApp custom resource. This is where you specify the fields that define the desired state (Spec) and the actual observed state (Status) of your application.
Open api/v1alpha1/myapp_types.go and modify the MyAppSpec and MyAppStatus structs as follows:
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// MyAppSpec defines the desired state of MyApp
type MyAppSpec struct {
// Important: Run "make generate" to regenerate code after modifying this file
// +kubebuilder:validation:Minimum=1
// Replicas is the number of desired pods.
Replicas int32 `json:"replicas"`
// Image is the container image to deploy.
Image string `json:"image"`
// Message is a custom message to be displayed by the application.
// +optional
Message string `json:"message,omitempty"`
// Port is the container port for the application.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +optional
Port int32 `json:"port,omitempty"`
}
// MyAppStatus defines the observed state of MyApp
type MyAppStatus struct {
// Important: Run "make generate" to regenerate code after modifying this file
// ObservedGeneration is the most recent generation observed for this MyApp. It corresponds to the MyApp's generation, which is
// updated on mutation requirements by the API server.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// AvailableReplicas is the number of currently available replicas.
// +optional
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
// Conditions represent the latest available observations of an object's state
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// CurrentState provides a human-readable summary of the MyApp's operational state.
// +optional
CurrentState string `json:"currentState,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.image",description="The container image to deploy"
// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".spec.replicas",description="Desired number of replicas"
// +kubebuilder:printcolumn:name="Available",type="integer",JSONPath=".status.availableReplicas",description="Current available replicas"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.currentState",description="Current operational status"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// MyApp is the Schema for the myapps API
type MyApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyAppSpec `json:"spec,omitempty"`
Status MyAppStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// MyAppList contains a list of MyApp
type MyAppList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []MyApp `json:"items"`
}
func init() {
SchemeBuilder.Register(&MyApp{}, &MyAppList{})
}
Explanation of Changes: * MyAppSpec: * Replicas (int32): The desired number of Pod replicas. +kubebuilder:validation:Minimum=1 adds schema validation. * Image (string): The Docker image for the application. * Message (string): An optional custom message. +optional and omitempty ensure it's not required and omitted if empty. * Port (int32): Optional port for the application, with validation. * MyAppStatus: * ObservedGeneration (int64): Useful for knowing if the status reflects the latest spec. * AvailableReplicas (int32): The actual number of running, ready replicas. * Conditions ([]metav1.Condition): A standard way to report the health and progress of a resource. * CurrentState (string): A human-readable summary (e.g., "Ready", "Updating", "Failed"). * +kubebuilder:printcolumn directives: These annotations define custom columns that kubectl get myapps will display, making it easier to view summary information.
After modifying myapp_types.go, you must run the following commands to generate the updated CRD manifests and Go code for deep copying, defaulting, and conversions:
make generate
make manifests
These commands ensure that your config/crd/bases/stable.example.com_myapps.yaml file (the actual CRD definition applied to Kubernetes) is updated with your Spec and Status schema, and that the necessary helper methods for your Go types are generated.
Implementing the Reconciler Logic: The Core of the Controller
The controllers/myapp_controller.go file contains the MyAppReconciler struct and its Reconcile method, which is the heart of your controller. This function is called every time a change related to a MyApp resource (or any secondary resource it watches) is detected.
Open controllers/myapp_controller.go and let's walk through implementing the reconciliation logic. The goal is that when a MyApp CR is created or updated, our controller should: 1. Create or update a Deployment to run the specified Image with Replicas. 2. Create or update a Service to expose the Deployment. 3. Update the MyApp's Status to reflect the current state of the Deployment and Service.
package controllers
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
stableexamplecomv1alpha1 "github.com/your-username/my-crd-controller/api/v1alpha1" // Adjust this import path
)
// MyAppReconciler reconciles a MyApp object
type MyAppReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=stable.example.com,resources=myapps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=stable.example.com,resources=myapps/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=stable.example.com,resources=myapps/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the MyApp object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. Fetch the MyApp instance
myApp := &stableexamplecomv1alpha1.MyApp{}
err := r.Get(ctx, req.NamespacedName, myApp)
if err != nil {
if errors.IsNotFound(err) {
// MyApp object not found, could have been deleted. Nothing to reconcile.
logger.Info("MyApp resource not found. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request.
logger.Error(err, "Failed to get MyApp")
return ctrl.Result{}, err
}
// Set initial status conditions if not already present
if myApp.Status.Conditions == nil || len(myApp.Status.Conditions) == 0 {
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionUnknown,
Reason: "Reconciling",
Message: "Starting reconciliation for MyApp",
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "Creating",
Message: "Creating underlying resources",
})
if err := r.Status().Update(ctx, myApp); err != nil {
logger.Error(err, "Failed to update MyApp status initially")
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil // Requeue to process the updated status
}
// 2. Define the desired Deployment
deployment := r.desiredDeployment(myApp)
foundDeployment := &appsv1.Deployment{}
err = r.Get(ctx, types.NamespacedName{Name: deployment.Name, Namespace: deployment.Namespace}, foundDeployment)
if err != nil && errors.IsNotFound(err) {
logger.Info("Creating a new Deployment", "Deployment.Namespace", deployment.Namespace, "Deployment.Name", deployment.Name)
err = r.Create(ctx, deployment)
if err != nil {
logger.Error(err, "Failed to create new Deployment", "Deployment.Namespace", deployment.Namespace, "Deployment.Name", deployment.Name)
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionFalse,
Reason: "DeploymentFailed",
Message: fmt.Sprintf("Failed to create Deployment: %s", err.Error()),
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionFalse,
Reason: "CreationFailed",
Message: fmt.Sprintf("Failed to create Deployment: %s", err.Error()),
})
r.Status().Update(ctx, myApp)
return ctrl.Result{}, err // Requeue with error
}
// Deployment created successfully - return and requeue
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "DeploymentCreated",
Message: "Deployment created successfully, waiting for readiness",
})
r.Status().Update(ctx, myApp)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Requeue to check deployment status
} else if err != nil {
logger.Error(err, "Failed to get Deployment")
return ctrl.Result{}, err
}
// 3. Update the Deployment if necessary
// Check if the deployment spec is different from the desired spec.
// This simplified check compares image, replicas and message,
// in a real-world scenario you might use a deeper diffing library.
needUpdateDeployment := false
if foundDeployment.Spec.Replicas != &myApp.Spec.Replicas {
logger.Info("Deployment replica count differs", "Current", *foundDeployment.Spec.Replicas, "Desired", myApp.Spec.Replicas)
foundDeployment.Spec.Replicas = &myApp.Spec.Replicas
needUpdateDeployment = true
}
if foundDeployment.Spec.Template.Spec.Containers[0].Image != myApp.Spec.Image {
logger.Info("Deployment image differs", "Current", foundDeployment.Spec.Template.Spec.Containers[0].Image, "Desired", myApp.Spec.Image)
foundDeployment.Spec.Template.Spec.Containers[0].Image = myApp.Spec.Image
needUpdateDeployment = true
}
// Also check for environment variable changes related to message
// For simplicity, we are checking the first container only and assume a consistent env var name.
foundEnvVarIndex := -1
for i, envVar := range foundDeployment.Spec.Template.Spec.Containers[0].Env {
if envVar.Name == "APP_MESSAGE" {
foundEnvVarIndex = i
break
}
}
if myApp.Spec.Message != "" {
if foundEnvVarIndex == -1 {
// Message should exist but doesn't, add it.
foundDeployment.Spec.Template.Spec.Containers[0].Env = append(foundDeployment.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{
Name: "APP_MESSAGE",
Value: myApp.Spec.Message,
})
needUpdateDeployment = true
} else if foundDeployment.Spec.Template.Spec.Containers[0].Env[foundEnvVarIndex].Value != myApp.Spec.Message {
// Message exists but value differs.
foundDeployment.Spec.Template.Spec.Containers[0].Env[foundEnvVarIndex].Value = myApp.Spec.Message
needUpdateDeployment = true
}
} else { // MyApp.Spec.Message is empty, ensure env var is removed
if foundEnvVarIndex != -1 {
foundDeployment.Spec.Template.Spec.Containers[0].Env = append(foundDeployment.Spec.Template.Spec.Containers[0].Env[:foundEnvVarIndex], foundDeployment.Spec.Template.Spec.Containers[0].Env[foundEnvVarIndex+1:]...)
needUpdateDeployment = true
}
}
if needUpdateDeployment {
logger.Info("Updating existing Deployment", "Deployment.Namespace", foundDeployment.Namespace, "Deployment.Name", foundDeployment.Name)
err = r.Update(ctx, foundDeployment)
if err != nil {
logger.Error(err, "Failed to update Deployment", "Deployment.Namespace", foundDeployment.Namespace, "Deployment.Name", foundDeployment.Name)
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionFalse,
Reason: "DeploymentUpdateFailed",
Message: fmt.Sprintf("Failed to update Deployment: %s", err.Error()),
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionFalse,
Reason: "UpdateFailed",
Message: fmt.Sprintf("Failed to update Deployment: %s", err.Error()),
})
r.Status().Update(ctx, myApp)
return ctrl.Result{}, err // Requeue with error
}
// Deployment updated successfully - return and requeue
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "DeploymentUpdating",
Message: "Deployment updated, waiting for new replicas to be ready",
})
r.Status().Update(ctx, myApp)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Requeue to check deployment status
}
// 4. Define and Reconcile the desired Service (if a port is specified)
if myApp.Spec.Port > 0 {
service := r.desiredService(myApp)
foundService := &corev1.Service{}
err = r.Get(ctx, types.NamespacedName{Name: service.Name, Namespace: service.Namespace}, foundService)
if err != nil && errors.IsNotFound(err) {
logger.Info("Creating a new Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name)
err = r.Create(ctx, service)
if err != nil {
logger.Error(err, "Failed to create new Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name)
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionFalse,
Reason: "ServiceFailed",
Message: fmt.Sprintf("Failed to create Service: %s", err.Error()),
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionFalse,
Reason: "CreationFailed",
Message: fmt.Sprintf("Failed to create Service: %s", err.Error()),
})
r.Status().Update(ctx, myApp)
return ctrl.Result{}, err
}
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "ServiceCreated",
Message: "Service created successfully",
})
r.Status().Update(ctx, myApp)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
} else if err != nil {
logger.Error(err, "Failed to get Service")
return ctrl.Result{}, err
}
// 5. Update the Service if necessary (simplified: just compare port)
needUpdateService := false
if foundService.Spec.Ports[0].Port != myApp.Spec.Port || foundService.Spec.Ports[0].TargetPort.IntVal != myApp.Spec.Port {
logger.Info("Service port differs", "Current", foundService.Spec.Ports[0].Port, "Desired", myApp.Spec.Port)
service.Spec.Ports = []corev1.ServicePort{
{
Port: myApp.Spec.Port,
TargetPort: intstr.FromInt32(myApp.Spec.Port),
Protocol: corev1.ProtocolTCP,
Name: "http",
},
}
foundService.Spec.Ports = service.Spec.Ports // update the found service with desired ports
needUpdateService = true
}
if needUpdateService {
logger.Info("Updating existing Service", "Service.Namespace", foundService.Namespace, "Service.Name", foundService.Name)
err = r.Update(ctx, foundService)
if err != nil {
logger.Error(err, "Failed to update Service", "Service.Namespace", foundService.Namespace, "Service.Name", foundService.Name)
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionFalse,
Reason: "ServiceUpdateFailed",
Message: fmt.Sprintf("Failed to update Service: %s", err.Error()),
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionFalse,
Reason: "UpdateFailed",
Message: fmt.Sprintf("Failed to update Service: %s", err.Error()),
})
r.Status().Update(ctx, myApp)
return ctrl.Result{}, err
}
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "ServiceUpdating",
Message: "Service updated successfully",
})
r.Status().Update(ctx, myApp)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
} else { // Port is not specified, ensure service is deleted if it exists
serviceName := fmt.Sprintf("%s-service", myApp.Name)
foundService := &corev1.Service{}
err = r.Get(ctx, types.NamespacedName{Name: serviceName, Namespace: myApp.Namespace}, foundService)
if err == nil { // Service exists, delete it
logger.Info("Deleting existing Service as port is no longer specified", "Service.Namespace", myApp.Namespace, "Service.Name", serviceName)
if err := r.Delete(ctx, foundService); err != nil {
logger.Error(err, "Failed to delete Service", "Service.Namespace", myApp.Namespace, "Service.Name", serviceName)
// Don't requeue with error here, let the next reconciliation attempt handle it,
// or retry after a short period.
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "ServiceDeleting",
Message: "Service deletion initiated as port is no longer specified",
})
r.Status().Update(ctx, myApp)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Requeue to confirm deletion
} else if !errors.IsNotFound(err) {
logger.Error(err, "Failed to get Service for deletion check")
return ctrl.Result{}, err
}
}
// 6. Update MyApp status
myApp.Status.AvailableReplicas = foundDeployment.Status.AvailableReplicas
// Check if the deployment is fully available
if foundDeployment.Status.ReadyReplicas == myApp.Spec.Replicas &&
foundDeployment.Status.AvailableReplicas == myApp.Spec.Replicas {
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionTrue,
Reason: "DeploymentReady",
Message: "Deployment is ready and all replicas are available.",
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionFalse,
Reason: "DeploymentReady",
Message: "All underlying resources are up-to-date and ready.",
})
myApp.Status.CurrentState = "Ready"
} else {
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionFalse,
Reason: "DeploymentNotReady",
Message: fmt.Sprintf("Deployment not fully ready. Available: %d, Desired: %d", foundDeployment.Status.AvailableReplicas, myApp.Spec.Replicas),
})
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Progressing",
Status: metav1.ConditionTrue,
Reason: "DeploymentNotReady",
Message: fmt.Sprintf("Waiting for deployment replicas to become ready. Available: %d, Desired: %d", foundDeployment.Status.AvailableReplicas, myApp.Spec.Replicas),
})
myApp.Status.CurrentState = "Reconciling"
}
// Update observed generation (important for tracking status progress)
myApp.Status.ObservedGeneration = myApp.Generation
if err := r.Status().Update(ctx, myApp); err != nil {
logger.Error(err, "Failed to update MyApp status")
return ctrl.Result{}, err
}
logger.Info("Reconciliation finished for MyApp", "MyApp.Namespace", myApp.Namespace, "MyApp.Name", myApp.Name)
return ctrl.Result{}, nil
}
// desiredDeployment creates a Deployment object for the MyApp CR.
func (r *MyAppReconciler) desiredDeployment(myApp *stableexamplecomv1alpha1.MyApp) *appsv1.Deployment {
labels := map[string]string{
"app": myApp.Name,
"controller": "my-crd-controller",
}
envVars := []corev1.EnvVar{
{
Name: "POD_NAME",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
FieldPath: "metadata.name",
},
},
},
}
if myApp.Spec.Message != "" {
envVars = append(envVars, corev1.EnvVar{
Name: "APP_MESSAGE",
Value: myApp.Spec.Message,
})
}
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: myApp.Name,
Namespace: myApp.Namespace,
Labels: labels,
},
Spec: appsv1.DeploymentSpec{
Replicas: &myApp.Spec.Replicas,
Selector: &metav1.LabelSelector{
MatchLabels: labels,
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: labels,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "app",
Image: myApp.Spec.Image,
Env: envVars,
Ports: []corev1.ContainerPort{
{
ContainerPort: myApp.Spec.Port,
Name: "http",
Protocol: corev1.ProtocolTCP,
},
},
}},
},
},
},
}
// Set MyApp instance as the owner and controller
// This ensures that the Deployment is garbage collected when the MyApp is deleted
ctrl.SetControllerReference(myApp, dep, r.Scheme)
return dep
}
// desiredService creates a Service object for the MyApp CR.
func (r *MyAppReconciler) desiredService(myApp *stableexamplecomv1alpha1.MyApp) *corev1.Service {
labels := map[string]string{
"app": myApp.Name,
"controller": "my-crd-controller",
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-service", myApp.Name),
Namespace: myApp.Namespace,
Labels: labels,
},
Spec: corev1.ServiceSpec{
Selector: labels,
Ports: []corev1.ServicePort{
{
Port: myApp.Spec.Port,
TargetPort: intstr.FromInt32(myApp.Spec.Port),
Protocol: corev1.ProtocolTCP,
Name: "http",
},
},
Type: corev1.ServiceTypeClusterIP, // Could be NodePort or LoadBalancer based on MyApp spec
},
}
ctrl.SetControllerReference(myApp, svc, r.Scheme)
return svc
}
// SetupWithManager sets up the controller with the Manager.
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&stableexamplecomv1alpha1.MyApp{}). // Primary resource: MyApp
Owns(&appsv1.Deployment{}). // Secondary resource: Deployment (owned by MyApp)
Owns(&corev1.Service{}). // Secondary resource: Service (owned by MyApp)
Complete(r)
}
Detailed Breakdown of the Reconcile Function:
- Fetch the
MyAppInstance:- The first step in any reconciliation loop is to retrieve the custom resource instance that triggered the request.
r.Get()attempts to fetch theMyAppobject using thereq.NamespacedName(which contains thenamespaceandnameof the changed resource). - Error Handling:
- If the
MyAppobject isNotFound, it means the resource was likely deleted, and there's nothing more for the controller to do, so it returnsctrl.Result{}(no error, no requeue). - Any other error during
Getimplies a transient issue (e.g., API server down), so the request isrequeued(ctrl.Result{}, err) for a retry.
- If the
- The first step in any reconciliation loop is to retrieve the custom resource instance that triggered the request.
- Initialize Status Conditions:
- Controllers should always maintain the
.statusfield of their custom resources to report the observed state back to the user. Here, we initialize standard KubernetesConditions(e.g., "Available", "Progressing") to provide granular status updates. meta.SetStatusConditionis a helper function to manage these conditions. If the status is updated, we immediatelyr.Status().Update()andRequeuethe request. This ensures that the controller's view of the CR includes its newly set initial status, preventing potential race conditions and guaranteeing the reconciler starts with an up-to-date object.
- Controllers should always maintain the
- Reconcile
Deployment:- Desired State: The
r.desiredDeployment(myApp)helper function constructs anappsv1.Deploymentobject based on theMyApp.Spec. This is the desired state of the deployment. Crucially,ctrl.SetControllerReferenceis called, which establishesmyAppas the owner of thisDeployment. This ensures that whenmyAppis deleted, Kubernetes' garbage collector will automatically delete the associatedDeployment. - Current State: The controller then attempts to
Getan existingDeploymentwith the same name and namespace. - Creation: If the
DeploymentisNotFound, the controllerCreates it. If creation fails, it updates theMyApp'sStatuswith an error andRequeues. If successful, it updatesMyAppstatus andRequeues after a short delay, allowing time for the deployment to start. - Update: If the
Deploymentalready exists, the controller compares its current spec (image, replicas, message environment variable) with the desired spec frommyApp. If differences are detected, the controllerUpdates theDeployment. Again, status is updated, and the request isrequeued.- Note on
foundDeployment.Spec.Replicas != &myApp.Spec.Replicas:Replicasis a pointer inDeployment.Spec. We need to compare pointer values, or dereference them. For updates, we directly assign the dereferenced value. - Note on
Envvar handling: We add anAPP_MESSAGEenvironment variable to the container, or remove it ifmyApp.Spec.Messageis empty. This demonstrates how controllers can manipulate fine-grained details of child resources.
- Note on
- Desired State: The
- Reconcile
Service:- This logic mirrors the
Deploymentreconciliation. IfmyApp.Spec.Portis greater than 0, aServiceis desired. - The
r.desiredService(myApp)function constructs the desiredServiceobject, again settingmyAppas its owner. - The controller fetches the existing
Service, creates it if not found, or updates it if the port configuration changes. - If
myApp.Spec.Portis 0 or not set, and a service does exist, the controller initiates deletion of the service to match the desired state.
- This logic mirrors the
- Update
MyAppStatus:- After attempting to reconcile all child resources, the controller gathers information about their actual state (e.g.,
foundDeployment.Status.AvailableReplicas). - It then updates the
MyApp.Statusfields (AvailableReplicas,Conditions,CurrentState,ObservedGeneration). r.Status().Update(ctx, myApp)persists these status changes to the API server. This is a critical step, as users observe the controller's progress and the application's health through theStatusfield.
- After attempting to reconcile all child resources, the controller gathers information about their actual state (e.g.,
- Return
ctrl.Result:- A successful reconciliation typically returns
ctrl.Result{}. This means the controller has done its job for now, and the resource will not be immediately requeued unless another change event occurs. ctrl.Result{RequeueAfter: X}is used to force a requeue after a specified duration, which is useful for checking on the progress of asynchronous operations (like waiting for a deployment to become ready) or for implementing periodic health checks.- Returning
ctrl.Result{}, errindicates a failure and usually triggers a backoff retry mechanism by the workqueue.
- A successful reconciliation typically returns
Client-go Interactions: The client.Client Interface
The MyAppReconciler struct embeds client.Client, which is part of controller-runtime. This interface provides a powerful, cached client for interacting with Kubernetes API objects. * r.Get(ctx, namespacedName, obj): Retrieves a Kubernetes object (e.g., myApp, deployment, service) by its name and namespace. It preferentially uses the shared cache, falling back to the API server if not found or explicitly configured. * r.Create(ctx, obj): Creates a new Kubernetes object. * r.Update(ctx, obj): Updates an existing Kubernetes object. * r.Delete(ctx, obj): Deletes a Kubernetes object. * r.Status().Update(ctx, obj): Specifically updates the status subresource of an object. This is important because updating only the status avoids conflicts with other controllers or users who might be updating the spec.
Managing Owned Resources: OwnerReferences
The ctrl.SetControllerReference(owner, owned, scheme) function is vital. It establishes an OwnerReference on the owned object (e.g., Deployment, Service), pointing back to the owner object (MyApp in our case). This OwnerReference marks myApp as the "controller owner" of the child resources. Kubernetes' garbage collector uses this information: when myApp is deleted, any resources that list it as their controller owner are automatically deleted as well. This prevents resource leaks and simplifies clean-up.
Setting up Watches: Connecting Events to the Reconciler
The SetupWithManager function (located at the end of controllers/myapp_controller.go) is where you configure the controller-runtime Manager to know which resources your controller should watch and how events from those resources should trigger your Reconcile function.
// SetupWithManager sets up the controller with the Manager.
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&stableexamplecomv1alpha1.MyApp{}). // Primary resource: MyApp
Owns(&appsv1.Deployment{}). // Secondary resource: Deployment (owned by MyApp)
Owns(&corev1.Service{}). // Secondary resource: Service (owned by MyApp)
Complete(r)
}
For(&stableexamplecomv1alpha1.MyApp{}): This tells the manager thatMyAppis the primary resource this controller manages. AnyAdd,Update, orDeleteevents forMyAppinstances will directly trigger a reconciliation for that specificMyAppobject.Owns(&appsv1.Deployment{}): This tells the manager thatMyAppReconcilerownsDeploymentresources. When aDeploymentchanges (is created, updated, or deleted), and thatDeploymenthas anOwnerReferencepointing to aMyAppresource, the manager will enqueue a reconciliation request for the owningMyAppobject. This is critical: if aDeploymentcreated by our controller goes down or is manually deleted, theMyAppcontroller needs to be notified to reconcile and bring it back to the desired state.Owns(&corev1.Service{}): Similarly, this registers watches forServiceresources that are owned byMyAppobjects.
By configuring these watches, controller-runtime sets up the necessary Informers and Workqueues behind the scenes, ensuring that your Reconcile function is called precisely when needed, keeping your custom resources in their desired state. This declarative approach to controller setup is one of the most powerful features of controller-runtime and kubebuilder.
At this point, you have defined your custom resource, implemented the core reconciliation logic, and configured the controller to react to relevant changes. The next phase involves rigorous testing and seamless deployment to a Kubernetes cluster to see your custom operator in action.
V. Testing and Deployment
Bringing a Kubernetes controller to life involves more than just writing code; it requires thorough testing and a robust deployment strategy. The kubebuilder framework provides excellent support for both, enabling you to confidently deploy your custom operator.
Local Testing: Iteration and Debugging
Before containerizing and deploying your controller to a remote cluster, it's highly beneficial to test it locally. This allows for rapid iteration and debugging.
- Ensure CRD is Applied: Even when running locally, your controller interacts with a real Kubernetes API server. Therefore, the CRD for
MyAppmust be present in your cluster. If you haven't already, run:bash make installThis command applies the CRD definition generated inconfig/crd/bases/stable.example.com_myapps.yamlto your cluster. - Run the Controller Locally: Navigate to your project root and execute the
runcommand from theMakefile:bash make runThis command compiles and runs your controller as a local Go process. It connects to the Kubernetes cluster configured by your~/.kube/configfile (just likekubectl). You should see logs indicating that the manager is starting and yourMyAppReconcileris running. - Interact with Your Custom Resources: Now, in a separate terminal, you can create, update, and delete
MyAppinstances and observe your controller's behavior in the first terminal.- Create a
MyApp: Create a file namedmy-app-instance.yaml:yaml apiVersion: stable.example.com/v1alpha1 kind: MyApp metadata: name: my-example-app namespace: default spec: image: "nginx:latest" replicas: 2 message: "Hello from local controller!" port: 80Apply it:bash kubectl apply -f my-app-instance.yamlIn your controller's logs, you should see messages about fetching theMyApp, creating theDeployment, creating theService, and updating theMyApp's status.Verify resources:bash kubectl get myapps kubectl get deployment my-example-app kubectl get service my-example-app-serviceYou should observe theDeploymentandServicebeing created and theMyApp's status updating over time. - Update a
MyApp: Modifymy-example-app.yaml(e.g., changereplicasto 3,imagetohttpd:latest, ormessage).yaml # ... spec: image: "httpd:latest" replicas: 3 message: "Updated message with Apache!" port: 80Apply the change:bash kubectl apply -f my-app-instance.yamlObserve the controller logs for messages about updating theDeploymentandMyAppstatus. TheDeploymentshould perform a rolling update, and its replicas should eventually reach 3. - Delete a
MyApp:bash kubectl delete -f my-app-instance.yamlThe controller logs should show it detecting the deletion. Due toOwnerReferences, theDeploymentandServiceowned bymy-example-appshould also be automatically garbage collected by Kubernetes.
- Create a
- Debugging: Running locally makes debugging with Go's standard debugging tools (like
delve) straightforward. You can set breakpoints and inspect variables to understand your controller's flow. Yourlogger.Infoandlogger.Errorcalls provide valuable insights into the reconciliation process.
Unit and Integration Tests: Ensuring Reliability
High-quality controllers require robust testing. kubebuilder facilitates this by setting up basic test infrastructure.
- Unit Tests: Focus on individual functions or methods in isolation, typically mocking external dependencies (like the
client.Client). These are fast and ensure the logic within a single component is correct. - Integration Tests with
envtest:envtest(fromcontroller-runtime/pkg/envtest) is a powerful tool for testing controllers. It spins up a minimal, in-memory Kubernetes API server and etcd instance (without a fullkubeletorkube-proxy). This allows you to run your controller against a real API server that understands Kubernetes objects and CRDs, without needing a full-blown cluster. You can create, update, and delete objects and assert the controller's behavior and the resulting cluster state.kubebuildergenerates a_test.gofile for your controller (e.g.,controllers/myapp_controller_test.go) with anenvtestsetup. You can extend these tests to cover various scenarios for yourMyAppCR: * Creation ofMyAppleads toDeploymentandService. * UpdatingMyApp.Spec.ReplicasupdatesDeployment.Spec.Replicas. * UpdatingMyApp.Spec.ImageupdatesDeployment.Spec.Template.Spec.Containers[0].Image. * DeletingMyAppgarbage collectsDeploymentandService. * Error scenarios (e.g., invalid spec, API server errors).To run these tests:bash go test ./...
Building and Deploying to Cluster: Making it Production-Ready
Once your controller is tested, the next step is to build it into a container image and deploy it to your Kubernetes cluster. kubebuilder generates a Makefile with commands to streamline this process.
- Build the Docker Image:
bash make docker-build IMG="your-docker-registry/my-crd-controller:v1.0.0"- Replace
your-docker-registrywith your actual Docker Hub username or private registry address (e.g.,docker.io/myuser). - This command uses the
Dockerfilegenerated bykubebuilderto build your controller's executable and package it into a Docker image.
- Replace
- Push the Docker Image:
bash make docker-push IMG="your-docker-registry/my-crd-controller:v1.0.0"This command pushes your newly built image to the specified container registry, making it accessible from your Kubernetes cluster. You might need todocker loginfirst. - Deploy to Cluster: The
config/directory generated bykubebuildercontains all the necessary Kubernetes manifests for deploying your controller:kubebuilderuseskustomizeto manage and apply these manifests.bash make deploy IMG="your-docker-registry/my-crd-controller:v1.0.0"This command performs several actions: * Applies the CRD definition (if not already present). * Creates theNamespace,ServiceAccount,ClusterRole, andClusterRoleBindingfor your controller. * Deploys your controller as aDeploymentin the designated namespace (usuallymy-crd-controller-system).config/rbac: Defines theClusterRole,ClusterRoleBinding, andServiceAccountneeded for your controller to interact with the Kubernetes API (e.g.,getDeployments,createServices,updateMyApp status). The+kubebuilder:rbacannotations in your controller's Go files are used to automatically generate these.config/samples: ExampleMyAppcustom resource manifests.config/manager: TheDeploymentmanifest for your controller, specifying the container image, resource limits, and other operational parameters.config/crd: Your CRD definition.
- Verify Deployment:
bash kubectl get pods -n my-crd-controller-system # Check if your controller pod is running kubectl logs -f -n my-crd-controller-system <your-controller-pod-name> # View controller logsNow, your controller is running inside the cluster. You can again apply yourmy-app-instance.yamland observe the controller's behavior, this time by checking its logs within the cluster.
Observability: Monitoring Your Controller
For a production-grade controller, observability is crucial:
- Logging: Use structured logging (e.g.,
controller-runtime/pkg/log) to emit useful information during reconciliation. Includerequest.NamespacedNameand other relevant identifiers in your log messages. Configure log levels (e.g., debug, info, warn, error) appropriately. - Metrics:
controller-runtimeautomatically exposes Prometheus metrics for things like reconciliation duration, workqueue length, and API server errors. You can also add custom metrics to track application-specific events or states within your controller. Ensure your cluster has Prometheus installed to scrape these metrics. - Event Reporting: Kubernetes has an Event API for reporting notable occurrences (e.g., a Pod failing to start, a Deployment scaling up). Your controller can leverage this to emit custom events when it takes significant actions or encounters errors, making it easier for users to understand what's happening.
By following these testing and deployment practices, you can build and operate reliable Kubernetes controllers that seamlessly extend your cluster's capabilities.
VI. Advanced Topics and Best Practices
Developing a Kubernetes controller effectively extends the control plane, bringing significant power but also demanding careful consideration of robustness, security, and maintainability. Let's explore some advanced topics and best practices that elevate your controller from a basic proof-of-concept to a production-ready operator.
Finalizers: Preventing Accidental Deletion of External Resources
One of the critical challenges in controller development is managing the lifecycle of external resources. Imagine your MyApp controller provisions a database instance in a cloud provider or creates a DNS record when a MyApp is created. If the MyApp resource is deleted from Kubernetes, and your controller doesn't get a chance to clean up these external resources, you'll end up with resource leaks and orphaned infrastructure.
Finalizers solve this problem. A finalizer is a list of keys attached to a Kubernetes object (metadata.finalizers). When an object with finalizers is deleted, Kubernetes doesn't immediately remove it from the API server. Instead, it marks the object as "deletion pending" (metadata.deletionTimestamp is set) and waits for its finalizers to be removed. Your controller can watch for objects marked for deletion:
- When your controller detects a
MyAppobject with adeletionTimestampand a finalizer it recognizes (e.g.,stable.example.com/finalizer), it knows it needs to perform cleanup. - It then executes the necessary external cleanup logic (e.g., delete the cloud database, remove the DNS entry).
- Once the cleanup is complete, the controller removes its finalizer from the
MyAppobject. - Once all finalizers are removed, Kubernetes can then safely delete the object from etcd.
Implementing a Finalizer:
- Define the Finalizer: Choose a unique string for your finalizer, typically using your API group as a prefix (e.g.,
myapps.stable.example.com/finalizer). - Add Finalizer on Creation: In your reconciler, when creating a new
MyApp, add the finalizer tomyApp.ObjectMeta.Finalizersif it's not already present, thenUpdatetheMyAppobject. - Handle Deletion: In your
Reconcileloop, checkmyApp.ObjectMeta.DeletionTimestamp.IsZero().- If
DeletionTimestampis not zero, the object is being deleted. Check if your finalizer is present. - If your finalizer is present, perform cleanup.
- After successful cleanup, remove the finalizer from
myApp.ObjectMeta.FinalizersandUpdatethe object. - If your finalizer is not present (either never added or already removed), then the object has been cleaned up by others or is pending final deletion. Return
ctrl.Result{}.
- If
This robust pattern ensures that your controller always has an opportunity to perform necessary cleanup, even when the owning resource is deleted.
Webhooks (Admission Controllers): Enforcing Policies and Defaults
While reconciliation handles the desired state post-creation, Admission Controllers allow you to intercept requests to the Kubernetes API server before an object is persisted. They come in two forms:
- Validating Webhooks: These webhooks can reject API requests that violate specific policies or business rules. For example, you could write a validating webhook for
MyAppthat ensuresreplicasis always an odd number, or that theimagecomes from a trusted registry. - Mutating Webhooks: These webhooks can modify an object before it's stored. For instance, you could use a mutating webhook to inject default values into your
MyApp'sSpecif certain fields are omitted, or add specific labels/annotations.
kubebuilder provides excellent support for generating webhook boilerplate, including the necessary TLS certificates and ValidatingWebhookConfiguration/MutatingWebhookConfiguration manifests. While separate from the reconciliation loop, webhooks are powerful companions to controllers, enabling more robust governance and automation at the API level.
Controller Runtime Manager: The Orchestrator
The controller-runtime Manager is the central component that orchestrates all aspects of your controller. When you run make run or deploy your controller, the main.go file sets up and starts this manager. Its responsibilities include:
- Starting Controllers: The
Managerstarts all registeredControllerinstances (which wrap yourReconcilers). - API Client and Cache Management: It provides shared
client.Clientinstances, ensuring that all controllers use a common, efficient cached interface to the Kubernetes API. This minimizes API server load and memory usage. - Scheme Management: It registers all known Kubernetes API types (including your CRDs) with a
runtime.Scheme, enabling proper object serialization/deserialization. - Webhook Management: If you have webhooks, the
Managerhandles their registration and serving. - Leader Election: For highly available controllers, the
Managerintegrates with Kubernetes' leader election mechanism, ensuring only one instance of your controller is active at any given time, preventing duplicate actions. - Metrics and Health Endpoints: It exposes standard
/metricsand/healthzendpoints, crucial for Prometheus scraping and Kubernetes liveness/readiness probes.
Understanding the Manager's role helps in debugging and optimizing your controller, as it provides a single point of configuration for many operational aspects.
Shared Informers and Caching: Performance at Scale
As previously discussed, controller-runtime leverages Shared Informers extensively. The Manager initializes a shared cache (backed by Shared Informers) that watches for all resources handled by the registered controllers.
- Reduced API Server Load: Instead of each controller instance making direct API calls, they query the local, in-memory cache managed by the Manager. The Shared Informers maintain a single watch connection to the API server for each resource type.
- Performance: Reading from an in-memory cache is significantly faster than making network calls to the API server. This improves the responsiveness of your reconciler.
- Event-Driven Updates: The cache is kept up-to-date by events streamed from the API server, ensuring that controllers always operate on a fresh, consistent view of the cluster state.
While generally beneficial, it's important to be aware of the cache's eventual consistency model. There might be a slight delay between an object being updated in the API server and that change being reflected in the local cache. For most controller operations, this brief delay is acceptable.
Error Handling and Idempotency: Building Resilient Controllers
A resilient controller must handle errors gracefully and be idempotent.
- Idempotency: Your
Reconcilefunction must be designed such that applying it multiple times with the same input yields the same result, without causing unintended side effects. This is critical because the workqueue might requeue items, causing your reconciler to be called multiple times for the same change. Always check if a resource exists before creating it, and compare desired state with actual state before updating. - Error Handling:
- Transient Errors: For network issues, temporary API server unavailability, or dependent resources not yet being ready, return
ctrl.Result{}, err.controller-runtime's workqueue will automatically requeue the item with an exponential backoff, retrying later. - Permanent Errors: If an error indicates a fundamental problem (e.g., an invalid spec that cannot be reconciled), you might update the
MyApp'sStatusto reflect the error and then returnctrl.Result{}(without an error). This prevents continuous retries for an unresolvable issue, reducing load and cluttering logs. However, it's generally better to let the default backoff handle most errors, as "permanent" issues can often become "transient" (e.g., a misconfigured secret eventually gets corrected). - Panic Recovery: Production controllers should include mechanisms to recover from panics, ensuring the controller process doesn't crash entirely due to unexpected runtime errors.
- Transient Errors: For network issues, temporary API server unavailability, or dependent resources not yet being ready, return
Security Considerations: Least Privilege RBAC
Controllers operate with elevated privileges because they need to modify resources within the cluster. Therefore, Role-Based Access Control (RBAC) is paramount.
- Least Privilege: Your controller's
ClusterRoleandClusterRoleBinding(generated inconfig/rbac) should grant only the permissions absolutely necessary for its operation. If your controller only creates Deployments and Services, it shouldn't have permissions to modify Pods directly or delete Namespaces. - Audit Permissions: Regularly review the
ClusterRoledefinitions. Overly broad permissions (e.g.,*verb on*resources) pose a significant security risk. - Image Security: Ensure your controller's Docker image is built from trusted base images, free of known vulnerabilities, and scanned regularly.
By diligently adhering to these advanced practices, you can build Kubernetes controllers that are not only functional but also resilient, secure, and maintainable in demanding production environments. The power of custom controllers, when responsibly harnessed, makes Kubernetes an even more formidable platform for automation.
APIPark Integration: Streamlining External API Interactions for Your Controller
Many sophisticated Kubernetes controllers, particularly those extending the platform to manage complex enterprise applications or integrate with AI services, often need to interact with external APIs. For example, your MyApp controller might need to: * Provision external cloud resources through a REST API. * Call a sentiment analysis AI model API to process data for an application managed by the CRD. * Interact with a custom internal microservice API to configure application-specific settings.
Efficiently managing and securing these external API calls becomes paramount. This is where platforms like ApiPark come into play. APIPark, an open-source AI gateway and API management platform, allows you to abstract away the complexities of interacting with external services, especially AI models.
How APIPark enhances your Kubernetes controller:
Imagine your MyApp CRD has a field sentimentAnalysisEnabled: true. When this field is set, your controller needs to deploy an AI-powered microservice that continuously analyzes user comments for the MyApp. Instead of having your controller or the deployed microservice directly manage authentication, rate limiting, and versioning for various AI model APIs (e.g., OpenAI, Claude, custom models), it can route all these requests through APIPark.
Here's how APIPark could be invaluable for such a controller:
- Unified API Format for AI Invocation: If your controller needs to switch between different AI models based on the
MyApp's configuration, APIPark standardizes the request and response formats. This means your controller's logic remains stable, regardless of changes in the underlying AI model provider or API schema. - Quick Integration of 100+ AI Models: APIPark provides built-in integrations, allowing your controller to tap into a wide range of AI capabilities without extensive custom coding for each model's API.
- Prompt Encapsulation into REST API: For AI-driven features, APIPark can encapsulate complex prompts and model configurations into simple REST APIs. Your controller, or the microservices it deploys, can then call these stable, versioned APIs, simplifying the interaction with AI.
- End-to-End API Lifecycle Management: Your controller might provision external APIs as part of the
MyApp's lifecycle. APIPark helps manage these APIs, including traffic forwarding, load balancing, and versioning, ensuring robust communication. - API Security and Access Control: APIPark provides robust authentication, authorization, and rate limiting features. Your controller can rely on APIPark to secure access to external APIs, ensuring that only authorized requests are processed, and preventing abuse or data breaches. This offloads a significant security burden from your controller's logic.
- Performance and Observability: With its high-performance gateway (rivaling Nginx) and detailed API call logging, APIPark ensures that your controller's interactions with external APIs are fast, reliable, and auditable. This provides critical data analysis capabilities for troubleshooting and performance monitoring.
By integrating with APIPark, your custom Kubernetes controller can focus on its core responsibility – reconciling the desired state of MyApp resources – while delegating the complexities of external API communication to a specialized, open-source platform. This separation of concerns leads to more robust, scalable, and maintainable controllers, especially as your Kubernetes applications become more intertwined with external services and AI capabilities.
VII. Conclusion
Building a Kubernetes controller to watch CRD changes is a transformative skill for anyone looking to truly master and extend the Kubernetes platform. We've embarked on a comprehensive journey, starting with the fundamental concepts of Kubernetes controllers, their control loops, and the vital role of the API server, Informers, and Workqueues in maintaining the desired state of your cluster. We then delved into the power of Custom Resource Definitions, understanding how they allow you to extend the Kubernetes API with domain-specific abstractions, simplifying complex operational tasks and aligning Kubernetes with your unique application requirements.
The practical implementation phase guided you through setting up your development environment with kubebuilder, defining your custom resource's Spec and Status, and meticulously crafting the Reconcile function. This core logic, using client-go through controller-runtime, demonstrated how to create, update, and delete underlying standard Kubernetes resources like Deployments and Services based on the desired state expressed in your custom resource. We emphasized the importance of OwnerReferences for proper garbage collection and the declarative setup of watches to ensure your controller reacts appropriately to all relevant events.
Finally, we explored the critical aspects of testing and deployment, highlighting local debugging, the utility of envtest for robust integration tests, and the kubebuilder Makefile commands for building and deploying your controller as a containerized application within your cluster. We also touched upon advanced topics like finalizers for external resource cleanup, webhooks for policy enforcement, the orchestration power of controller-runtime's Manager, and crucial best practices for error handling, idempotency, and security. In an increasingly interconnected world, where controllers often interact with external services, we saw how an AI gateway and API management platform like ApiPark can significantly streamline and secure these critical external API interactions.
The ability to create custom controllers empowers you to elevate Kubernetes from a generic orchestrator to a highly specialized, self-managing platform perfectly tailored to your organization's needs. By embracing this extensibility, you unlock new levels of automation, consistency, and resilience for your applications and infrastructure. The journey of building operators is continuous, with ever-evolving best practices and patterns, but the foundation laid here provides a solid stepping stone into this exciting and impactful realm of cloud-native development. We encourage you to continue experimenting, building, and contributing to the vibrant Kubernetes ecosystem.
VIII. Frequently Asked Questions (FAQ)
1. What is the fundamental difference between a Kubernetes Controller and an Operator?
While often used interchangeably, there's a nuanced difference. A Kubernetes Controller is a general term for any component that implements a control loop to watch Kubernetes resources and reconcile their actual state to a desired state. Built-in Kubernetes components like the Deployment controller or ReplicaSet controller are examples. An Operator is a specialized type of controller that manages instances of a custom application or service. It encapsulates human operational knowledge for a specific application (e.g., a database, a message queue) into code, using CRDs to define its configuration. All Operators are controllers, but not all controllers are Operators. Operators typically manage CRDs and handle complex, application-specific lifecycle events like upgrades, backups, and failovers.
2. Why do I need Custom Resource Definitions (CRDs) if I already have standard Kubernetes resources like Deployments and Services?
CRDs extend the Kubernetes API with new, domain-specific resource types that are tailored to your applications or infrastructure. While standard resources are versatile, they might not capture the high-level abstractions or unique operational logic of your specific use cases. CRDs allow you to define simpler, more intuitive interfaces for your users (e.g., a DatabaseInstance instead of a Deployment, StatefulSet, PVC, Secret, and Service combined). This simplifies configuration, improves developer experience, enforces standardization, and aligns Kubernetes more closely with your business domain, enabling true declarative management of complex systems.
3. What is the role of client-go and controller-runtime in building controllers?
client-go is the foundational Go client library for interacting directly with the Kubernetes API server. It provides low-level access to Kubernetes resources and handles network communication, authentication, and object serialization. However, building a robust controller with client-go alone can be complex due to the need to correctly implement patterns like Informers, caches, and workqueues. controller-runtime is a higher-level framework that abstracts away much of this complexity. It builds on client-go and provides ready-to-use components like a Manager, shared caches, workqueues, and a simplified client.Client interface, significantly streamlining controller development and ensuring adherence to best practices.
4. How do I ensure my custom controller is highly available and doesn't suffer from split-brain issues?
For high availability, you typically deploy multiple replicas of your controller. To prevent multiple replicas from trying to reconcile the same resource simultaneously (which could lead to race conditions or conflicting actions, known as a split-brain), Kubernetes uses a mechanism called Leader Election. controller-runtime integrates leader election seamlessly. When you initialize the Manager in your main.go, you can enable leader election. This ensures that only one instance of your controller is "active" (the leader) at any given time, performing reconciliation, while other instances act as backups, ready to take over if the leader fails.
5. My controller is deployed, but it's not reacting to changes. How do I debug it?
Debugging a deployed controller involves several steps: * Check Controller Pod Logs: Use kubectl logs -n <controller-namespace> <controller-pod-name> to see if your controller is starting correctly, reporting any errors, or logging messages during reconciliation. * Verify RBAC Permissions: Ensure your controller's ClusterRole and ClusterRoleBinding grant it the necessary get, list, watch, create, update, patch, and delete permissions for both your custom resources and any standard Kubernetes resources it manages (Deployments, Services, etc.). Insufficient permissions are a common cause of controllers failing silently. * Inspect MyApp CR Status: Check the .status field of your MyApp instances (kubectl get myapp <name> -o yaml). Your controller should be updating this to reflect its current state and any errors. * Check CRD Availability: Confirm that your MyApp CRD is successfully applied to the cluster (kubectl get crd myapps.stable.example.com). * Validate SetupWithManager: Ensure the For() and Owns() calls in your SetupWithManager function correctly specify all the primary and secondary resources your controller needs to watch. * Use kubectl describe: kubectl describe myapp <name> can provide events and detailed information about your custom resource, which might shed light on why the controller isn't acting.
🚀You can securely and efficiently call the OpenAI API on APIPark in just two steps:
Step 1: Deploy the APIPark AI gateway in 5 minutes.
APIPark is developed based on Golang, offering strong product performance and low development and maintenance costs. You can deploy APIPark with a single command line.
curl -sSO https://download.apipark.com/install/quick-start.sh; bash quick-start.sh

In my experience, you can see the successful deployment interface within 5 to 10 minutes. Then, you can log in to APIPark using your account.

Step 2: Call the OpenAI API.

