A primer for experienced engineers approaching Kubernetes API machinery
Before we dive into Kubernetes specifics, let's establish what we're actually building: a control plane. Not container orchestration, not pod schedulingβthose are implementation details of one particular control plane (the one that ships with Kubernetes). We're interested in the machinery itself.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LAYER 1: UNIVERSAL PATTERNS β
ββββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββββ€
β State Storage β API Contract β Change Detection β Reconciliation β
ββββββββββ¬ββββββββββ΄βββββββββ¬ββββββββββ΄βββββββββ¬ββββββββββ΄βββββββββββ¬ββββββββββ
β β β β
βΌ βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LAYER 2: CLASSICAL IMPLEMENTATION β
ββββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββββ€
β Database β REST API β CDC / Polling β Background Workers β
β (Postgres, Redis)β (OpenAPI spec) β (Debezium, cron) β (Celery, Sidekiq) β
ββββββββββ¬ββββββββββ΄βββββββββ¬ββββββββββ΄βββββββββ¬ββββββββββ΄βββββββββββ¬ββββββββββ
β β β β
βΌ βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LAYER 3: KUBERNETES PRIMITIVES β
ββββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββββ€
β etcd β API Server β Watch Protocol β Controllers β
β (or kine backendsβ(resource endpts) β (resourceVersion)β (control loops) β
ββββββββββ¬ββββββββββ΄βββββββββ¬ββββββββββ΄βββββββββ¬ββββββββββ΄βββββββββββ¬ββββββββββ
β β β β
βΌ βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LAYER 4: YOUR OPERATOR β
ββββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββββ€
β Custom Resources β CRD + Webhooks β Informers β Reconcile() β
β(your domain modelβ (your API) β(your event streamβ(your business logicβ
ββββββββββββββββββββ΄βββββββββββββββββββ΄βββββββββββββββββββ΄βββββββββββββββββββββ
Every control planeβwhether you're building it with Rails, Go microservices, or Kubernetes operatorsβsolves the same fundamental problems. Your team has solved these problems before, just with different tools.
The problem: Multiple processes need to agree on "what is true right now."
In classical systems, you reach for a database. The choice depends on your consistency requirements: strong consistency (Postgres with serializable isolation), eventual consistency (Cassandra), or something in between.
The key insight isn't which databaseβit's that you need a single source of truth that handles concurrent writes safely.
The problem: Clients need a stable interface to read and mutate state, and invalid data should be rejected before it corrupts the system.
You've built this with REST APIs, GraphQL, gRPC. The implementation varies, but the shape is consistent: define a schema, validate inputs, perform CRUD operations, return structured responses.
The problem: Other components need to know when state changes, without hammering the database with polling queries.
Solutions you've likely used: database triggers, Change Data Capture (CDC) systems like Debezium, message queues (Kafka, RabbitMQ), or webhook callbacks. The goal is the same: push-based notification of state changes so downstream systems can react.
The problem: The desired state (what should exist) drifts from actual state (what does exist). Something needs to continuously fix this drift.
You've built this as background workers: Sidekiq jobs, Celery tasks, cron scripts that run ensure_consistency(). The pattern is always: observe current state, compare to desired state, take corrective action, repeat.
Let's make this concrete. Imagine you're building a control plane for managing "Widgets"βsome domain object your system cares about.
βββββββββββββββββββββββββββββββββββββββββββ
β CLIENTS β
β βββββββββ βββββββββ ββββββββββββββββ β
β β CLI β βWeb UI β βOther Servicesβ β
β βββββ¬ββββ βββββ¬ββββ ββββββββ¬ββββββββ β
ββββββββΌβββββββββββΌββββββββββββββΌββββββββββ
β β β
ββββββββββββΌββββββββββββββ
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CONTROL PLANE β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β REST API β β
β β POST/GET/PUT/DELETE /widgets β β
β βββββββββββββββββββββββββββββ¬ββββββββββββββββββββ¬βββββββββββββββββββββββ β
β β β β
β βββββββββββββββ βββββββββββββββ β
β βΌ βΌ β
β ββββββββββββββββββ ββββββββββββββββββββ β
β β PostgreSQL β β Message Queue β β
β β widgets table β β widget.created β β
β ββββββββββββββββββ β widget.updated β β
β ββββββββββ¬ββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββΌβββββββ β
β β β β β β
β βΌ βΌ βΌ β β
β βββββββββββββββ ββββββββββββββββ ββββββββββββββ β β
β β Provisioner β βHealth Checkerβ β Garbage β β β
β β Worker β β Worker β β Collector β β β
β ββββββββ¬βββββββ ββββββββ¬ββββββββ βββββββ¬βββββββ β β
β β β β β β
ββββββββββββββββββββββββββΌββββββββββββββββββΌββββββββββββββββββΌβββββββββββ β
β β β β
βββββββββββββββββββΌββββββββββββββββββ β
βΌ β
βββββββββββββββββββββββββββ β
β Widget Infrastructure βββββββββββββββββββββββββ
β (the actual things) β (status updates
βββββββββββββββββββββββββββ via API)
This is a completely reasonable architecture. You've probably built something like this. The data flow:
- Client makes API call:
POST /widgets {"name": "foo", "size": "large"} - API validates against schema, writes to Postgres, publishes event
- Provisioner worker picks up event, creates actual widget, updates status via API
- Health checker periodically scans, updates widget health status
- When widget deleted, garbage collector cleans up external resources
This works. But notice what you've had to build and maintain:
- Custom API server with routing, validation, auth
- Database schema migrations
- Message queue infrastructure and delivery guarantees
- Multiple worker processes with their own deployment/scaling concerns
- Custom schema for tracking resource versions and handling conflicts
- Audit logging
- API versioning strategy
Now let's rebuild this using Kubernetes API machinery. The patterns map directly:
| Classical Component | Kubernetes Equivalent | What You Get For Free |
|---|---|---|
| PostgreSQL | etcd (via API server) | Distributed consensus, watch support |
| REST API | API Server + CRD | Authn/authz, admission control, OpenAPI |
| Schema (SQL DDL) | CustomResourceDefinition | Validation, versioning, conversion |
| Message Queue | Watch protocol | Reliable delivery, resumable streams |
| Background Workers | Controller (in operator) | Leader election, work queuing |
| Resource versioning | Built-in resourceVersion | Optimistic concurrency, conflict detection |
βββββββββββββββββββββββββββββββββββββββββββ
β CLIENTS β
β βββββββββ βββββββββ βββββββββββββββ β
β βkubectlβ βWeb UI β β Other β β
β β β β β β Controllers β β
β βββββ¬ββββ βββββ¬ββββ ββββββββ¬βββββββ β
ββββββββΌβββββββββββΌββββββββββββββΌββββββββββ
β β β
ββββββββββββΌββββββββββββββ
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β KUBERNETES API MACHINERY β
β β
β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β
β CRD: Widget schema (defines the API) β
β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β API SERVER β β
β β GET/POST/PATCH /apis/yourco.io/v1/widgets β β
β ββββββββββββββββββββββ¬βββββββββββββββββββββββββββ¬βββββββββββββββββββββββ β
β β β β
β βΌ β watch stream β
β βββββββββββββββ β β
β β etcd β β β
β β (consistent β β β
β β KV store) β β β
β βββββββββββββββ β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β YOUR OPERATOR β
β β
β βββββββββββββββ βββββββββββββββββββββββ β
β β Informer β triggers β Controller β β
β β(watch+cache)βββββββββββΊβ Reconcile loop β β
β βββββββββββββββ ββββββββββββ¬βββββββββββ β
β β β
ββββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββ
β
β status update βββββββββββββββββββββββββββ
β (back to API) ββββΊ β Widget Infrastructure β
ββββββββββββββββββββββΊβ (external systems) β
βββββββββββββββββββββββββββ
Instead of SQL DDL, you define a CustomResourceDefinition:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.yourco.io
spec:
group: yourco.io
names:
kind: Widget
plural: widgets
singular: widget
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [name, size]
properties:
name:
type: string
size:
type: string
enum: [small, medium, large]
status:
type: object
properties:
state:
type: string
lastProvisioned:
type: string
format: date-timeOnce applied, the API server immediately provides:
GET /apis/yourco.io/v1/namespaces/{ns}/widgetsβ list with label filteringPOST /apis/yourco.io/v1/namespaces/{ns}/widgetsβ create with validationGET /apis/yourco.io/v1/namespaces/{ns}/widgets/{name}β readPUT/PATCHβ update with optimistic concurrencyDELETEβ with finalizer supportGET ...?watch=trueβ change stream
No code. Just a declaration.
Your controller (the "operator") is where domain logic lives. The structure is remarkably simple:
func (r *WidgetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 1. Fetch the Widget resource
var widget yourcoiov1.Widget
if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 2. Check if being deleted (finalizer pattern)
if !widget.DeletionTimestamp.IsZero() {
return r.handleDeletion(ctx, &widget)
}
// 3. Compare desired state (spec) to actual state
actual, err := r.externalClient.GetWidget(widget.Spec.Name)
// 4. Take corrective action
if actual == nil {
// Doesn't exist, create it
err = r.externalClient.CreateWidget(widget.Spec)
} else if needsUpdate(widget.Spec, actual) {
// Exists but drifted, update it
err = r.externalClient.UpdateWidget(widget.Spec)
}
// 5. Update status subresource
widget.Status.State = "Ready"
widget.Status.LastProvisioned = metav1.Now()
r.Status().Update(ctx, &widget)
return ctrl.Result{}, err
}This is the same reconciliation loop you'd write in a background worker. The difference is everything around it:
- Triggered by watch events β no polling, no message queue to manage
- Work queue with rate limiting β built into controller-runtime
- Leader election β one instance reconciles at a time
- Retries with backoff β return an error and it re-queues automatically
- Caching β informers maintain an in-memory cache, reducing API load
At this point, a reasonable question is: "Why not just use Postgres and Sidekiq? I already know those."
The answer isn't that Kubernetes is betterβit's that Kubernetes provides a standardized control plane substrate with properties that are expensive to build yourself:
Every Kubernetes resource has a resourceVersion. When you update a resource, you include this version. If someone else modified it since you read it, your update fails with a conflict error. This is optimistic concurrency control, implemented consistently across all resources.
In classical systems, you implement this per-table with version columns. It works, but it's custom each time.
Kubernetes watches are resumable. If your controller restarts, it can resume from its last-seen resourceVersion. The API server guarantees you won't miss events (within the history window).
Compare to message queues where you manage consumer offsets, dead letter queues, exactly-once delivery semantics. The watch protocol isn't perfect (it's not exactly-once), but the failure modes are well-understood and the client libraries handle reconnection.
When your Widget v1 needs to become v2 with breaking changes, Kubernetes provides conversion webhooks. The API server can serve both versions simultaneously, converting on the fly. Clients using v1 keep working.
This is sophisticated API versioning infrastructure that you'd otherwise build custom.
Mutating and validating webhooks let you inject policy without modifying your operator:
- Inject default values (mutating admission)
- Enforce naming conventions (validating admission)
- Require specific labels (validating admission)
- Inject sidecar configurations (mutating admission)
These are registered dynamically as resources. Add a policy, delete a policyβno code deployment required.
RBAC applies uniformly to your custom resources. A ServiceAccount can be granted get, list, watch on Widgets but not create, delete. No custom authorization code; it's configuration.
Once your CRD exists:
kubectl get widgetsworkskubectl describe widget fooworks- GitOps tools (Flux, ArgoCD) can manage your resources
- Monitoring tools can scrape metrics about your resources
- Audit logs capture all access
The heart of an operator is the reconciliation loop. Let's examine the pattern more carefully, because it has subtle properties that make it robust.
ββββββββββββββββββββββββ
β β
βΌ β
βββββββββββββ β
ββββββββββββββββΊβ IDLE ββββββββββββββββββ€
β βββββββ¬ββββββ β
β β β
β β watch event β
β β or timer β
β βΌ β
β βββββββββββββ β
β β TRIGGERED β β
β βββββββ¬ββββββ β
β β β
β β dequeue work item β
β βΌ β
β βββββββββββββ β
β β FETCH β β
β βββββββ¬ββββββ β
β β β
β βββββββββββββ΄ββββββββββββ β
β β β β
β βΌ βΌ β
β βββββββββββββ βββββββββββββ β
β β DELETED β β RECONCILE β β
β β(not found)β β (exists) β β
β βββββββ¬ββββββ βββββββ¬ββββββ β
β β β β
β ββββββ΄βββββ β get β
β β β β actual β
β βΌ β β state β
β has no β βΌ β
βfinalizers finalizers βββββββββββββ β
β β β β COMPARE β β
β βΌ β βββββββ¬ββββββ β
βββββββββββ β β β
ββCLEANUP β β βββββββββββββΌββββββββββββ€
ββββββ¬βββββ β β β β
β β β βΌ βΌ βΌ
β β β βββββββββ ββββββββββ ββββββββββ
β βΌ β βCREATE β β UPDATE β β NOOP β
βββββββββββββ β β(miss- β β(driftedβ β(matchesβ
ββ REMOVE β β β ing) β β ) β β ) β
ββFINALIZER β β βββββ¬ββββ βββββ¬βββββ βββββ¬βββββ
βββββββ¬ββββββ β β β β
β β β ββββββββββββ΄ββββββββββββ
β β β β
β β β βΌ
β β β βββββββββββββββ
β β β βUPDATE STATUSβ
β β β ββββββββ¬βββββββ
β β β β
β β β ββββββββββββ΄βββββββββββ
β β β β β
β β β βΌ βΌ
β β β success transient
β β β β error
β β β β β
βββββββ΄βββββββββ΄ββββββ β
βΌ
βββββββββββββ
β REQUEUE β
β(w/backoff)β
βββββββ¬ββββββ
β
β after backoff
β
βΌ
(back to TRIGGERED)
Idempotency: The reconcile function can be called multiple times with the same input and produce the same result. This is essential because the controller will call it multiple timesβon watch events, on resyncs, on restarts.
Level-triggered, not edge-triggered: The controller doesn't react to "widget was created" (edge). It reacts to "widget exists and needs reconciliation" (level). This means if events are lost or duplicated, correctness is maintained.
Eventual consistency: The system doesn't guarantee instant convergence. It guarantees that given enough time without new changes, actual state will match desired state.
Status as observed state: The status subresource represents what the controller observed, not what it desires. This separation is crucialβspec is the user's intent, status is reality as known to the controller.
When building an operator-based control plane, several architectural choices arise:
You can have one operator managing many CRDs, or multiple operators each managing one CRD. Considerations:
- Coupling: If resources are tightly coupled (Widget always needs a WidgetConfig), single operator reduces coordination overhead
- Lifecycle: If resources evolve at different rates, separate operators allow independent deployment
- Failure isolation: Separate operators can fail independently
- Namespace-scoped: Resources exist within a namespace. Users in different namespaces can have Widgets with the same name. RBAC can be granted per-namespace.
- Cluster-scoped: Resources are global. Typically used for cluster-wide configuration or singleton resources.
Most domain resources should be namespace-scoped. It aligns with multi-tenancy patterns and simplifies access control.
Your operator likely manages resources outside Kubernetes (cloud resources, databases, etc.). Two patterns:
Adopt-or-create: If external resource exists, adopt it. If not, create it. Requires careful handling of ownership and drift.
Create-only with import: Only create new resources. Provide a separate import mechanism for existing resources. Simpler but less flexible.
Rather than a single status.state string, the community convention uses an array of conditions:
status:
conditions:
- type: Ready
status: "True"
reason: ProvisioningComplete
message: Widget successfully provisioned
lastTransitionTime: "2024-01-15T10:30:00Z"
- type: Degraded
status: "False"
reason: AllReplicasHealthy
lastTransitionTime: "2024-01-15T10:30:00Z"This pattern allows representing multiple independent aspects of resource health without conflation.
Kubernetes API machinery isn't free. Here's what you accept when choosing this path:
- Kubernetes dependency: Your control plane requires a Kubernetes cluster (though it can be minimalβk3s, kind, etc.)
- Learning curve: Controller-runtime, kubebuilder, informers, work queuesβthese have learning curves
- YAML configuration: Love it or hate it, YAML is the interface
- Eventual consistency semantics: If you need strong consistency or transactions across resources, Kubernetes doesn't provide this natively
- API server implementation: Auth, routing, validation, OpenAPI spec generation
- Database operations: Schema migrations, connection pooling, backup/restore
- Event delivery: Message queue infrastructure, delivery guarantees
- Access control implementation: RBAC system design and enforcement
- Audit logging: Who did what when
- Client tooling: kubectl, client libraries work automatically
- Simple CRUD applications: If you just need a REST API with a database, a standard web framework is simpler
- Strong transactional requirements: Banking systems, inventory with hard constraints
- Low-latency requirements: The reconciliation loop adds latency; real-time systems may not fit
- Team unfamiliarity: If no one knows Kubernetes and there's no time to learn, shipping matters more than architecture purity
βββββββββββββββββββ
β CONTROL PLANE β
ββββββββββ¬βββββββββ
β
βββββββββββββββ¬ββββββββββββββββββββΌββββββββββββββββββββ¬ββββββββββββββ
β β β β β
βΌ βΌ βΌ βΌ βΌ
βββββββββββ ββββββββββββ ββββββββββββββββ ββββββββββββββββ ββββββββββββ
β STATE β β API β β CHANGE β βRECONCILIATIONβ β ACCESS β
β β β β β DETECTION β β β β CONTROL β
ββββββ¬βββββ ββββββ¬ββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββ¬ββββββ
β β β β β
ββββββ΄βββββ ββββββ΄βββββ βββββββ΄ββββββ βββββββ΄ββββββ ββββββ΄βββββ
βClassicalβ βClassicalβ β Classical β β Classical β βClassicalβ
βDatabase β βREST β β CDC/Queuesβ β Backgroundβ βCustom β
β β βFrameworkβ β β β Workers β βAuthN/Z β
ββββββ¬βββββ ββββββ¬βββββ βββββββ¬ββββββ βββββββ¬ββββββ ββββββ¬βββββ
β β β β β
ββββββ΄βββββ ββββββ΄βββββ βββββββ΄ββββββ βββββββ΄ββββββ ββββββ΄βββββ
β K8s β β K8s β β K8s β β K8s β β K8s β
β etcd β βAPI Srvr β β Watch β βControllersβ β RBAC + β
βvia API β β + CRDs β β Protocol β β β βAdmissionβ
ββββββ¬βββββ ββββββ¬βββββ βββββββ¬ββββββ βββββββ¬ββββββ ββββββ¬βββββ
β β β β β
ββββββ΄βββββ ββββββ΄βββββ βββββββ΄ββββββ βββββββ΄ββββββ ββββββ΄βββββ
β Yours β β Yours β β Yours β β Yours β β Yours β
β Custom β βYour CRD β β Informers β β Reconcile β β Config β
βResourcesβ β Schema β β β β Function β β Only β
βββββββββββ βββββββββββ βββββββββββββ βββββββββββββ βββββββββββ
The fundamental insight: Kubernetes API machinery is a domain-agnostic control plane substrate. The same machinery that reconciles Pods can reconcile your Widgets. You're not learning "Kubernetes"βyou're learning a well-designed implementation of patterns you already know, with the bonus that the ecosystem understands it natively.
Your job becomes: define your domain model as CRDs, implement your business logic in reconciliation functions, and let the machinery handle the rest.
- Start here: Kubernetes API Concepts β understand the primitives
- Then: Custom Resources β how to define your own
- Then: kubebuilder book β hands-on operator development
- Reference: controller-runtime godoc β when you need the details