The previous lesson added a new resource type to the API server. A FeatureFlag was validated and stored, and a .NET API read it, but nothing in the cluster changed because of it. An operator closes that loop. It watches a custom resource, compares what the resource asks for with what exists, and creates, updates or deletes ordinary Kubernetes objects until the two match. That is exactly how the Deployment controller turns a Deployment into ReplicaSets and Pods, and how Argo CD, KEDA, cert-manager and the Vault Secrets Operator from earlier lessons work.
In this lesson you build one in C#. A DotnetApp resource describes a .NET service in a few lines: image, replica count, port, health path and environment variables. The operator turns it into a Deployment and a Service, reports progress in the resource's status, keeps a shared catalog of all apps up to date, and cleans that catalog up through a finalizer when an app is deleted. It runs as two replicas with leader election, and it is built on nothing but the official KubernetesClient package and .NET 10's generic host.
You will break it on purpose: delete its Deployment, scale it by hand, kill the leader, and delete an app while the operator is down. Each time it converges back to what the DotnetApp says.
We will cover the following topics:
- The operator pattern: custom resource plus controller, and why controllers are level-triggered
- The reconcile loop: observe, compare, act, report, and why every step must be idempotent
- Watches, a de-duplicating work queue, periodic resync and exponential backoff
- Owner references and the garbage collector, and server-side apply for the children
- Status, conditions and observedGeneration, and the scale subresource
- Finalizers for cleanup that owner references cannot do
- RBAC for an operator, and leader election with a Lease
Technical Requirements
- A Kubernetes cluster and kubectl. The examples run on Docker Desktop's built-in Kubernetes with the kind provisioner (1 control-plane node and 3 workers, Kubernetes v1.36). A cluster created with
kind create cluster and the same layout behaves the same; there, load the two images you build with kind load docker-image. - Docker and the .NET 10 SDK to build the operator and the sample API.
- The lesson Custom Resource Definitions — Extending the Kubernetes API. Schemas, printer columns and the status subresource are used here without being re-explained.
Core Concepts
The Operator Pattern
An operator is a custom resource plus a controller that knows how to run one kind of application. The custom resource is the interface for humans and tools: short, declarative and validated. The controller holds the operational knowledge: which objects to create, in which order, how to upgrade, how to clean up.
| Without an operator | With the DotnetApp operator |
|---|
| What a team writes | Deployment, Service, probes, labels, resource limits, about 60 lines | A DotnetApp of about 10 lines |
| Conventions (labels, limits, probes) | Copied by hand, drift over time | Applied by the operator every time |
| Someone edits the Deployment by hand | The change stays until somebody notices | The operator puts it back within seconds |
| Status | Spread over Deployment, ReplicaSet and Pods | One Ready condition on the DotnetApp |
| Cleanup outside the namespace | Manual | A finalizer does it |
Level-Triggered Reconciliation
A controller does not react to "what just happened". It reacts to "what is the state now". Every event, whether a create, an update, a deleted child or a timer, only puts the object's key (namespace/name) into a queue. The reconcile function then reads the current object and the current children and makes them match. It never relies on the content of the event.
This is called level-triggered, as opposed to edge-triggered. It is what makes controllers robust: if the operator was down while three changes happened, it does not need to replay them. It reads the current state once and converges. The reconcile function must therefore be idempotent: running it twice in a row must have the same effect as running it once.
| Step | In this operator |
|---|
| Observe | Get the DotnetApp. If it is gone, do nothing; the garbage collector removes the children |
| Handle deletion | If deletionTimestamp is set, clean up the catalog and remove the finalizer |
| Ensure finalizer | Add apps.dotnetacademy.dev/catalog-cleanup if it is missing |
| Act | Server-side apply the Deployment and the Service, update the catalog entry |
| Report | Write status, but only if it changed |
Watches, the Work Queue and Resync
The operator keeps three sources of work:
- A watch on
DotnetApp objects in all namespaces. Every event enqueues the object's key. - A watch on Deployments labeled
app.kubernetes.io/managed-by=dotnetapp-operator. An event on a child is mapped to its owner through the owner reference, so a deleted, scaled or newly ready Deployment re-runs the owner's reconcile. - A resync every two minutes that lists all
DotnetApp objects and enqueues them. Watches can miss events after network problems; the resync guarantees convergence anyway.
The work queue de-duplicates: while a key waits in the queue, more events for it do not add more entries. A single worker processes the queue, so the same object is never reconciled twice at the same time. When a reconcile throws, the key is re-queued with exponential backoff (1 s, 2 s, 4 s and so on, capped at 5 minutes), so a broken object does not hammer the API server.
Frameworks such as controller-runtime in Go, or KubeOps and the Java Operator SDK, give you these pieces ready-made, including shared informer caches. Building them by hand once in plain C# makes it clear what those frameworks do.
Owner References and Server-Side Apply
Every child the operator creates carries an owner reference to its DotnetApp:
| Field | Value | Effect |
|---|
kind, name, uid | DotnetApp, greeter, the owner's UID | Identifies the owner exactly, even after a delete and re-create under the same name |
controller | true | Marks this owner as the one managing controller |
blockOwnerDeletion | true | With foreground deletion, the owner waits until this child is gone |
When the owner is deleted, the garbage collector in the controller manager deletes the children. The operator never writes delete logic for them, which is why its ClusterRole has no delete verb on Deployments. Owner references only work within a namespace, or from a namespaced object to a cluster-scoped owner. They cannot point across namespaces.
The children are written with server-side apply (SSA): the operator sends the complete object it wants under a field manager name, dotnetapp-operator, with force: true. The API server merges it, records which manager owns which field, and changes nothing if the intent is already in place. That makes the "act" step idempotent for free. If a human changes a field the operator owns, for example spec.replicas with kubectl scale, the next apply takes it back.
Status, Conditions and observedGeneration
The status follows the Kubernetes API conventions:
| Field | Meaning |
|---|
observedGeneration | The metadata.generation of the spec the operator last acted on. If it is lower than metadata.generation, the status is stale |
conditions[] | A list of {type, status, reason, message, lastTransitionTime, observedGeneration}. Here one condition, Ready |
replicas, readyReplicas | Copied from the Deployment |
selector | A label selector string, needed by the scale subresource and the HPA |
url | The in-cluster address of the app |
Two rules keep status from causing trouble. lastTransitionTime changes only when the condition's status flips, not on every write. And the operator writes status only when it differs from what is stored. A status write is itself a change that produces a watch event; writing unconditionally would make the operator reconcile itself in an endless loop.
Finalizers
A finalizer is a string in metadata.finalizers. While it is present, a delete request does not remove the object. It only sets metadata.deletionTimestamp. The object stays visible, now in a terminating state, until every finalizer has been removed. The controller sees the timestamp in its reconcile, does its cleanup, and removes its finalizer. Then the API server deletes the object.
Use a finalizer for cleanup the garbage collector cannot do: objects in another namespace, DNS records, cloud resources, entries in external systems. In this lesson it is a shared app-catalog ConfigMap in the operator's own namespace. Do not use a finalizer for children in the same namespace; owner references already handle those. And remember the cost: if the controller is gone for good, objects with its finalizer never finish deleting until someone removes the finalizer by hand.
RBAC and Leader Election
An operator needs exactly the permissions its reconcile uses. Here that is read and patch on dotnetapps, patch on dotnetapps/status, update on dotnetapps/finalizers (required for blockOwnerDeletion when the OwnerReferencesPermissionEnforcement admission plugin is on), get, list, watch, create and patch on Deployments and Services, and, in its own namespace only, the catalog ConfigMap and the leader-election Lease.
Running two replicas avoids a gap when a node fails, but two active controllers would fight over the same objects. Leader election solves it with a Lease object in coordination.k8s.io. Each replica tries to write its own identity into the Lease. The holder renews it every few seconds; the others watch. If the holder stops renewing for LeaseDuration, another replica takes over. KubernetesClient ships this as LeaderElector with a LeaseLock.
| Setting | Value here | Meaning |
|---|
LeaseDuration | 15 s | How long a lease is valid without renewal; the worst-case failover time |
RenewDeadline | 10 s | The leader gives up if it cannot renew within this time |
RetryPeriod | 2 s | How often replicas try to acquire or renew |
Hands-On: Kubernetes Commands
See which subresources a CRD enables
kubectl get --raw /apis/apps.dotnetacademy.dev/v1 | jq -r '.resources[].name'
Find the children of a custom resource
The owner reference connects them.
kubectl get deploy greeter -n operator-lab -o jsonpath='{.metadata.ownerReferences}'
See who owns which fields
Server-side apply records a manager per field set.
kubectl get deploy greeter -n operator-lab --show-managed-fields -o json | jq -r '.metadata.managedFields[].manager'
Check whether a controller has caught up
kubectl get dna greeter -n operator-lab -o jsonpath='{.metadata.generation} {.status.observedGeneration}{"\n"}'
Scale a custom resource
Works because the CRD defines a scale subresource.
kubectl scale dotnetapp greeter -n operator-lab --replicas=3
Find the current leader
kubectl get lease dotnetapp-operator -n dotnetapp-system -o jsonpath='{.spec.holderIdentity}{"\n"}'
Unblock an object stuck on a finalizer
Only when the controller that owns the finalizer is gone for good. The object disappears immediately, but its cleanup never runs: in a test of exactly this command, the app's entry stayed in app-catalog.
kubectl patch dna pricing -n operator-lab --type merge -p '{"metadata":{"finalizers":null}}'
Step-by-Step Example
The Scenario
A platform team wants every .NET service to be deployed the same way: a readiness probe on /healthz, CPU and memory requests, a memory limit, standard labels and a ClusterIP Service on port 80. Teams should only describe what differs, in a DotnetApp. The platform also keeps an app-catalog ConfigMap in its own namespace listing every app and its URL, for a developer portal to read.
The operator runs in dotnetapp-system. The apps live in operator-lab. Both namespaces, the CRD, one ClusterRole and one ClusterRoleBinding are removed in the last step.
Step 1 — The Sample .NET API
The operator needs something to deploy. greeter-api is a minimal ASP.NET Core app that answers with a greeting taken from configuration:
dotnet new web -n GreeterApi -o greeter-api
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var greeting = app.Configuration["GREETING"] ?? "Hello";
var version = app.Configuration["APP_VERSION"] ?? "1.0";
app.MapGet("/", () => new { message = $"{greeting} from greeter-api", version, pod = Environment.MachineName });
app.MapGet("/healthz", () => "ok");
app.Run();
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY GreeterApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "GreeterApi.dll"]
Build it twice, as 1.0 and 1.1, so there is something to upgrade to later:
docker build -t greeter-api:1.0 greeter-api
docker build -t greeter-api:1.1 greeter-api
Step 2 — The DotnetApp CRD
Save as 1-dotnetapp-crd.yaml. The scale subresource points at .spec.replicas, .status.replicas and .status.selector. The Status column reads the reason of the Ready condition with a JSONPath filter.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: dotnetapps.apps.dotnetacademy.dev
spec:
group: apps.dotnetacademy.dev
scope: Namespaced
names:
kind: DotnetApp
listKind: DotnetAppList
plural: dotnetapps
singular: dotnetapp
shortNames: ["dna"]
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- name: Image
type: string
jsonPath: .spec.image
- name: Desired
type: integer
jsonPath: .spec.replicas
- name: Ready
type: integer
jsonPath: .status.readyReplicas
- name: Status
type: string
jsonPath: .status.conditions[?(@.type=="Ready")].reason
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
subresources:
status: {}
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas
labelSelectorPath: .status.selector
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["image"]
properties:
image:
type: string
minLength: 1
replicas:
type: integer
minimum: 0
maximum: 10
default: 1
port:
type: integer
minimum: 1
maximum: 65535
default: 8080
healthPath:
type: string
default: /healthz
env:
type: object
additionalProperties:
type: string
status:
type: object
properties:
observedGeneration:
type: integer
replicas:
type: integer
readyReplicas:
type: integer
selector:
type: string
url:
type: string
conditions:
type: array
x-kubernetes-list-type: map
x-kubernetes-list-map-keys: ["type"]
items:
type: object
required: ["type", "status", "lastTransitionTime", "reason", "message"]
properties:
type: { type: string }
status: { type: string, enum: ["True", "False", "Unknown"] }
observedGeneration: { type: integer }
lastTransitionTime: { type: string, format: date-time }
reason: { type: string }
message: { type: string }
kubectl apply -f 1-dotnetapp-crd.yaml
kubectl wait --for=condition=Established crd/dotnetapps.apps.dotnetacademy.dev --timeout=30s
kubectl get --raw /apis/apps.dotnetacademy.dev/v1 | jq -r '.resources[] | "\(.name) verbs=\(.verbs|join(","))"'
customresourcedefinition.apiextensions.k8s.io/dotnetapps.apps.dotnetacademy.dev created
customresourcedefinition.apiextensions.k8s.io/dotnetapps.apps.dotnetacademy.dev condition met
dotnetapps verbs=delete,deletecollection,get,list,patch,create,update,watch
dotnetapps/status verbs=get,patch,update
dotnetapps/scale verbs=get,patch,update
Step 3 — The Operator in C#
Create a worker project and add the client library:
dotnet new worker -n DotnetAppOperator -o dotnetapp-operator
cd dotnetapp-operator
dotnet add package KubernetesClient --version 21.0.8
DotnetApp.cs maps the resource to C#. The top of the file declares the DotnetApp class itself (apiVersion, kind, metadata, spec, status) and the list class, exactly like FeatureFlag in the previous lesson. Spec and status:
public class DotnetAppSpec
{
[JsonPropertyName("image")] public string Image { get; set; } = "";
[JsonPropertyName("replicas")] public int Replicas { get; set; } = 1;
[JsonPropertyName("port")] public int Port { get; set; } = 8080;
[JsonPropertyName("healthPath")] public string HealthPath { get; set; } = "/healthz";
[JsonPropertyName("env")] public Dictionary<string, string>? Env { get; set; }
}
public class DotnetAppStatus
{
[JsonPropertyName("observedGeneration")] public long? ObservedGeneration { get; set; }
[JsonPropertyName("replicas")] public int Replicas { get; set; }
[JsonPropertyName("readyReplicas")] public int ReadyReplicas { get; set; }
[JsonPropertyName("selector")] public string? Selector { get; set; }
[JsonPropertyName("url")] public string? Url { get; set; }
[JsonPropertyName("conditions")] public List<V1Condition> Conditions { get; set; } = [];
}
WorkQueue.cs is the de-duplicating queue with per-key backoff:
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace DotnetAppOperator;
// A de-duplicating queue of "namespace/name" keys. Ten events for the same object
// while it waits in the queue still produce one reconcile.
public class WorkQueue
{
private readonly Channel<string> _channel = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _queued = new();
private readonly ConcurrentDictionary<string, int> _failures = new();
public void Add(string key)
{
if (_queued.TryAdd(key, 0)) _channel.Writer.TryWrite(key);
}
public void AddAfter(string key, TimeSpan delay) =>
_ = Task.Delay(delay).ContinueWith(_ => Add(key));
// Exponential backoff per key: 1s, 2s, 4s ... capped at 5 minutes.
public TimeSpan Fail(string key)
{
var n = _failures.AddOrUpdate(key, 1, (_, c) => c + 1);
return TimeSpan.FromSeconds(Math.Min(Math.Pow(2, n - 1), 300));
}
public void Forget(string key) => _failures.TryRemove(key, out _);
public async IAsyncEnumerable<string> ReadAllAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var key in _channel.Reader.ReadAllAsync(ct))
{
_queued.TryRemove(key, out _);
yield return key;
}
}
}
Reconciler.cs is the heart of the operator. ReconcileAsync follows the table from Core Concepts: observe, handle deletion, ensure the finalizer, apply the children, report.
public class Reconciler(IKubernetes client, ILogger<Reconciler> logger, IConfiguration config)
{
public const string FieldManager = "dotnetapp-operator";
public const string Finalizer = "apps.dotnetacademy.dev/catalog-cleanup";
public const string ManagedByLabel = "app.kubernetes.io/managed-by";
private readonly string _operatorNamespace = config["POD_NAMESPACE"] ?? "dotnetapp-system";
public async Task ReconcileAsync(string ns, string name, CancellationToken ct)
{
DotnetApp app;
try
{
app = await client.CustomObjects.GetNamespacedCustomObjectAsync<DotnetApp>(
DotnetApp.Group, DotnetApp.Version, ns, DotnetApp.Plural, name, ct);
}
catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound)
{
logger.LogInformation("{Ns}/{Name} is gone, nothing to do", ns, name);
return;
}
var finalizers = app.Metadata.Finalizers ?? [];
// 1. Deletion: run our cleanup, then let the API server finish the delete.
if (app.Metadata.DeletionTimestamp is not null)
{
if (finalizers.Contains(Finalizer))
{
await RemoveFromCatalogAsync(ns, name, ct);
var remaining = finalizers.Where(f => f != Finalizer).ToList();
await PatchAppAsync(ns, name, new { metadata = new { finalizers = remaining, resourceVersion = app.Metadata.ResourceVersion } }, ct);
logger.LogInformation("{Ns}/{Name} catalog entry removed, finalizer released", ns, name);
}
return;
}
// 2. Make sure we get a chance to clean up before the object disappears.
if (!finalizers.Contains(Finalizer))
{
await PatchAppAsync(ns, name, new { metadata = new { finalizers = finalizers.Append(Finalizer).ToList(), resourceVersion = app.Metadata.ResourceVersion } }, ct);
logger.LogInformation("{Ns}/{Name} finalizer added", ns, name);
}
// 3. Declare the children we want. Server-side apply makes this idempotent:
// applying the same intent twice changes nothing.
var deployment = await ApplyAsync(BuildDeployment(app), ct);
await ApplyServiceAsync(BuildService(app), ct);
await AddToCatalogAsync(app, ct);
// 4. Report what we observed.
await UpdateStatusAsync(app, deployment, ct);
}
BuildDeployment and BuildService (not shown) create plain V1Deployment and V1Service objects from the spec: labels app.kubernetes.io/name and app.kubernetes.io/managed-by, the container with its port, environment variables, readiness probe on healthPath, requests of 50m CPU and 64Mi memory and a 128Mi memory limit, and a Service from port 80 to the app port. Both carry this owner reference and are written with server-side apply:
// controller=true marks us as the managing controller; the garbage collector deletes
// the child when the owner is deleted.
private static V1OwnerReference OwnerOf(DotnetApp app) => new()
{
ApiVersion = app.ApiVersion,
Kind = DotnetApp.KindName,
Name = app.Metadata.Name,
Uid = app.Metadata.Uid,
Controller = true,
BlockOwnerDeletion = true
};
private Task<V1Deployment> ApplyAsync(V1Deployment d, CancellationToken ct) =>
client.AppsV1.PatchNamespacedDeploymentAsync(
new V1Patch(KubernetesJson.Serialize(d), V1Patch.PatchType.ApplyPatch),
d.Metadata.Name, d.Metadata.NamespaceProperty, fieldManager: FieldManager, force: true, cancellationToken: ct);
The catalog lives in another namespace, so owner references cannot clean it up. AddToCatalogAsync (not shown) server-side applies a ConfigMap app-catalog in the operator's namespace containing a single key, <namespace>.<name>, with the image and URL as value. It uses a separate field manager per app, dotnetapp-operator-<namespace>.<name>, so each app owns only its own key and applies never remove other apps' entries. RemoveFromCatalogAsync deletes the key with a JSON merge patch that sets it to null. Finally the status. "Ready" means more than "enough Pods are ready": during a rolling update old and new Pods are both ready, so the operator also requires that the Deployment controller has seen the latest template and that every replica runs it.
private async Task UpdateStatusAsync(DotnetApp app, V1Deployment deployment, CancellationToken ct)
{
var desired = app.Spec.Replicas;
var ds = deployment.Status;
var ready = ds?.ReadyReplicas ?? 0;
// Ready only when the Deployment controller has seen the latest template and every
// replica runs it: no old Pods left over from a rollout, no missing ones.
var isReady = (ds?.ObservedGeneration ?? 0) >= (deployment.Metadata.Generation ?? 0)
&& (ds?.UpdatedReplicas ?? 0) == desired
&& (ds?.Replicas ?? 0) == desired
&& ready == desired;
var old = app.Status?.Conditions.FirstOrDefault(c => c.Type == "Ready");
var status = isReady ? "True" : "False";
var condition = new V1Condition
{
Type = "Ready",
Status = status,
Reason = isReady ? "AllReplicasReady" : "WaitingForReplicas",
Message = $"{ready}/{desired} replicas ready",
ObservedGeneration = app.Metadata.Generation,
// Only move the transition time when the status actually flips.
LastTransitionTime = old?.Status == status ? old.LastTransitionTime : DateTime.UtcNow
};
var next = new DotnetAppStatus
{
ObservedGeneration = app.Metadata.Generation,
Replicas = deployment.Status?.Replicas ?? 0,
ReadyReplicas = ready,
Selector = $"app.kubernetes.io/name={app.Metadata.Name}",
Url = $"http://{app.Metadata.Name}.{app.Metadata.NamespaceProperty}.svc.cluster.local",
Conditions = [condition]
};
// Skip the write when nothing changed, otherwise every status write would trigger
// a watch event, another reconcile and another write.
if (app.Status is not null && KubernetesJson.Serialize(next) == KubernetesJson.Serialize(app.Status)) return;
await client.CustomObjects.PatchNamespacedCustomObjectStatusAsync(
new V1Patch(KubernetesJson.Serialize(new { status = next }), V1Patch.PatchType.MergePatch),
DotnetApp.Group, DotnetApp.Version, app.Metadata.NamespaceProperty, DotnetApp.Plural, app.Metadata.Name, cancellationToken: ct);
logger.LogInformation("{Ns}/{Name} status: {Ready}/{Desired} ready (generation {Gen})",
app.Metadata.NamespaceProperty, app.Metadata.Name, ready, desired, app.Metadata.Generation);
}
}
Controller.cs feeds the queue. The watch on DotnetApp objects follows the list-then-watch pattern from the previous lesson, across all namespaces with ListClusterCustomObjectAsync and WatchListClusterCustomObjectAsync. The rest of the class:
public class Controller(IKubernetes client, Reconciler reconciler, ILogger<Controller> logger)
{
private readonly WorkQueue _queue = new();
private static readonly TimeSpan ResyncPeriod = TimeSpan.FromMinutes(2);
public Task RunAsync(CancellationToken ct) => Task.WhenAll(
WatchAppsAsync(ct),
WatchDeploymentsAsync(ct),
ResyncAsync(ct),
WorkAsync(ct));
// ... WatchAppsAsync: list, then watch DotnetApps from the list's resourceVersion ...
// A change to a child (someone scales it, edits it, deletes it, or its Pods become ready)
// is mapped back to the owning DotnetApp through the owner reference.
private async Task WatchDeploymentsAsync(CancellationToken ct)
{
var selector = $"{Reconciler.ManagedByLabel}={Reconciler.FieldManager}";
while (!ct.IsCancellationRequested)
{
try
{
await foreach (var (type, d) in client.AppsV1.WatchListDeploymentForAllNamespacesAsync(
labelSelector: selector, cancellationToken: ct))
{
var owner = d.Metadata.OwnerReferences?.FirstOrDefault(o => o.Kind == DotnetApp.KindName && o.Controller == true);
if (owner is null) continue;
if (type == WatchEventType.Deleted)
logger.LogInformation("Deployment {Ns}/{Name} was deleted, requeue owner", d.Metadata.NamespaceProperty, d.Metadata.Name);
_queue.Add($"{d.Metadata.NamespaceProperty}/{owner.Name}");
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception ex)
{
logger.LogWarning("Deployment watch failed: {Message}", ex.Message);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
}
// Watches can miss events (network blips, restarts). A periodic full resync makes the
// operator level-triggered: it converges on the current state, whatever it missed.
private async Task ResyncAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(ResyncPeriod);
while (await timer.WaitForNextTickAsync(ct))
{
var list = await client.CustomObjects.ListClusterCustomObjectAsync<DotnetAppList>(
DotnetApp.Group, DotnetApp.Version, DotnetApp.Plural, cancellationToken: ct);
foreach (var app in list.Items) _queue.Add(Key(app.Metadata));
}
}
private async Task WorkAsync(CancellationToken ct)
{
await foreach (var key in _queue.ReadAllAsync(ct))
{
var parts = key.Split('/');
try
{
await reconciler.ReconcileAsync(parts[0], parts[1], ct);
_queue.Forget(key);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
var delay = _queue.Fail(key);
logger.LogWarning("Reconcile {Key} failed: {Message}. Retry in {Delay}s", key, ex.Message, delay.TotalSeconds);
_queue.AddAfter(key, delay);
}
}
}
private static string Key(V1ObjectMeta m) => $"{m.NamespaceProperty}/{m.Name}";
}
Program.cs registers the services and wraps the controller in leader election. Only the replica that holds the Lease runs controller.RunAsync. If it loses the Lease, it stops reconciling and exits, and Kubernetes restarts the Pod as a follower.
using DotnetAppOperator;
using k8s;
using k8s.LeaderElection;
using k8s.LeaderElection.ResourceLock;
var builder = Host.CreateApplicationBuilder(args);
var k8sConfig = KubernetesClientConfiguration.IsInCluster()
? KubernetesClientConfiguration.InClusterConfig()
: KubernetesClientConfiguration.BuildConfigFromConfigFile();
builder.Services.AddSingleton<IKubernetes>(new Kubernetes(k8sConfig));
builder.Services.AddSingleton<Reconciler>();
builder.Services.AddSingleton<Controller>();
builder.Services.AddHostedService<LeaderElectedOperator>();
builder.Build().Run();
// Several replicas can run for availability, but only the holder of the Lease reconciles.
public class LeaderElectedOperator(IKubernetes client, Controller controller, IConfiguration config,
IHostApplicationLifetime lifetime, ILogger<LeaderElectedOperator> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var identity = Environment.MachineName;
var ns = config["POD_NAMESPACE"] ?? "dotnetapp-system";
var leaseLock = new LeaseLock(client, ns, "dotnetapp-operator", identity);
var elector = new LeaderElector(new LeaderElectionConfig(leaseLock)
{
LeaseDuration = TimeSpan.FromSeconds(15),
RenewDeadline = TimeSpan.FromSeconds(10),
RetryPeriod = TimeSpan.FromSeconds(2)
});
using var leading = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
Task? work = null;
elector.OnStartedLeading += () =>
{
logger.LogInformation("{Identity} became the leader, starting controllers", identity);
work = controller.RunAsync(leading.Token);
};
elector.OnNewLeader += leader => logger.LogInformation("Current leader: {Leader}", leader);
elector.OnStoppedLeading += () => logger.LogWarning("{Identity} lost the lease", identity);
await elector.RunUntilLeadershipLostAsync(stoppingToken);
// Lost the lease (or shutting down): stop reconciling and exit so the Pod restarts clean.
leading.Cancel();
if (work is not null) { try { await work; } catch (OperationCanceledException) { } }
if (!stoppingToken.IsCancellationRequested) lifetime.StopApplication();
}
}
The image uses the chiseled .NET runtime image, since the operator serves no HTTP:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY DotnetAppOperator.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "DotnetAppOperator.dll"]
docker build -t dotnetapp-operator:1.0 .
Step 4 — RBAC and Deploy the Operator
2-operator-rbac.yaml contains the dotnetapp-system namespace, the dotnetapp-operator ServiceAccount, a ClusterRole with a ClusterRoleBinding, and a Role with a RoleBinding. The two roles:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dotnetapp-operator
rules:
- apiGroups: ["apps.dotnetacademy.dev"]
resources: ["dotnetapps"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: ["apps.dotnetacademy.dev"]
resources: ["dotnetapps/status"]
verbs: ["get", "patch", "update"]
- apiGroups: ["apps.dotnetacademy.dev"]
resources: ["dotnetapps/finalizers"]
verbs: ["update"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "patch", "update"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "patch", "update"]
# Only in its own namespace: the shared catalog and the leader-election Lease.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: dotnetapp-operator
namespace: dotnetapp-system
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "create", "patch"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "create", "update"]
kubectl apply -f 2-operator-rbac.yaml
kubectl auth can-i delete deployments -n operator-lab --as=system:serviceaccount:dotnetapp-system:dotnetapp-operator
kubectl auth can-i patch dotnetapps.apps.dotnetacademy.dev/status -n operator-lab --as=system:serviceaccount:dotnetapp-system:dotnetapp-operator
kubectl auth can-i patch configmaps -n default --as=system:serviceaccount:dotnetapp-system:dotnetapp-operator
namespace/dotnetapp-system created
serviceaccount/dotnetapp-operator created
clusterrole.rbac.authorization.k8s.io/dotnetapp-operator created
clusterrolebinding.rbac.authorization.k8s.io/dotnetapp-operator created
role.rbac.authorization.k8s.io/dotnetapp-operator created
rolebinding.rbac.authorization.k8s.io/dotnetapp-operator created
no
yes
no
The operator can never delete a Deployment, and it can touch ConfigMaps only in its own namespace. Deploy two replicas with 3-operator.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dotnetapp-operator
namespace: dotnetapp-system
spec:
replicas: 2
selector:
matchLabels:
app: dotnetapp-operator
template:
metadata:
labels:
app: dotnetapp-operator
spec:
serviceAccountName: dotnetapp-operator
containers:
- name: operator
image: dotnetapp-operator:1.0
imagePullPolicy: IfNotPresent
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
kubectl apply -f 3-operator.yaml
kubectl rollout status deploy/dotnetapp-operator -n dotnetapp-system --timeout=120s
kubectl get lease dotnetapp-operator -n dotnetapp-system -o jsonpath='holder={.spec.holderIdentity} duration={.spec.leaseDurationSeconds}s transitions={.spec.leaseTransitions}{"\n"}'
kubectl logs -n dotnetapp-system -l app=dotnetapp-operator --prefix | grep -A1 LeaderElected
deployment.apps/dotnetapp-operator created
Waiting for deployment "dotnetapp-operator" rollout to finish: 0 of 2 updated replicas are available...
Waiting for deployment "dotnetapp-operator" rollout to finish: 1 of 2 updated replicas are available...
deployment "dotnetapp-operator" successfully rolled out
holder=dotnetapp-operator-585d9d94b9-6qv4v duration=15s transitions=0
dotnetapp-operator-585d9d94b9-6xs5s Current leader: dotnetapp-operator-585d9d94b9-6qv4v
dotnetapp-operator-585d9d94b9-6qv4v Current leader: dotnetapp-operator-585d9d94b9-6qv4v
dotnetapp-operator-585d9d94b9-6qv4v dotnetapp-operator-585d9d94b9-6qv4v became the leader, starting controllers
dotnetapp-operator-585d9d94b9-6qv4v DotnetApp watch failed: Operation returned an invalid status code 'TooManyRequests', response body {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"storage is (re)initializing","reason":"TooManyRequests","details":{"retryAfterSeconds":1},"code":429}
The log lines are shortened to pod message for readability. One replica holds the Lease; the other only follows. The last line is a real-world detail worth knowing: right after a CRD is created, the API server's watch cache for it is still initializing and answers 429 TooManyRequests with retryAfterSeconds: 1. The watch loop retries after two seconds, and that is the end of it. Every watch in an operator needs such a retry loop.
Step 5 — Create a DotnetApp
Save as 4-greeter.yaml. Ten lines, and the defaults for port and health path come from the CRD:
apiVersion: apps.dotnetacademy.dev/v1
kind: DotnetApp
metadata:
name: greeter
namespace: operator-lab
spec:
image: greeter-api:1.0
replicas: 2
env:
GREETING: Merhaba
kubectl create namespace operator-lab
kubectl apply -f 4-greeter.yaml
kubectl get dna -n operator-lab
namespace/operator-lab created
dotnetapp.apps.dotnetacademy.dev/greeter created
NAME IMAGE DESIRED READY STATUS AGE
greeter greeter-api:1.0 2 0s
Twelve seconds later:
kubectl get dna -n operator-lab
kubectl get deploy,svc,pods -n operator-lab
NAME IMAGE DESIRED READY STATUS AGE
greeter greeter-api:1.0 2 2 AllReplicasReady 12s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/greeter 2/2 2 2 12s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/greeter ClusterIP 10.96.36.59 <none> 80/TCP 12s
NAME READY STATUS RESTARTS AGE
pod/greeter-fb8fd5b69-rbckm 1/1 Running 0 12s
pod/greeter-fb8fd5b69-zk8s4 1/1 Running 0 12s
Look at what the operator wrote: the owner reference on the child, the finalizer on the app, the status, and the catalog entry in the other namespace.
kubectl get deploy greeter -n operator-lab -o jsonpath='{range .metadata.ownerReferences[*]}{.kind}/{.name} controller={.controller} blockOwnerDeletion={.blockOwnerDeletion}{"\n"}{end}'
kubectl get dna greeter -n operator-lab -o jsonpath='{.metadata.finalizers}{"\n"}'
kubectl get dna greeter -n operator-lab -o jsonpath='{.status}' | jq .
kubectl get configmap app-catalog -n dotnetapp-system -o jsonpath='{.data}' | jq .
DotnetApp/greeter controller=true blockOwnerDeletion=true
["apps.dotnetacademy.dev/catalog-cleanup"]
{
"conditions": [
{
"lastTransitionTime": "2026-10-21T12:15:20.297577Z",
"message": "2/2 replicas ready",
"observedGeneration": 1,
"reason": "AllReplicasReady",
"status": "True",
"type": "Ready"
}
],
"observedGeneration": 1,
"readyReplicas": 2,
"replicas": 2,
"selector": "app.kubernetes.io/name=greeter",
"url": "http://greeter.operator-lab.svc.cluster.local"
}
{
"operator-lab.greeter": "greeter-api:1.0 http://greeter.operator-lab.svc.cluster.local"
}
Call the app through its Service from inside the cluster, and read the operator's log:
kubectl run curl --image=curlimages/curl:8.17.0 -n operator-lab --rm -i --restart=Never -q --command -- sh -c 'sleep 1; curl -s http://greeter'
kubectl logs -n dotnetapp-system -l app=dotnetapp-operator --prefix --since=1m | grep -A1 Reconciler
{"message":"Merhaba from greeter-api","version":"1.0","pod":"greeter-fb8fd5b69-rbckm"}
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter finalizer added
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 0/2 ready (generation 1)
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 0/2 ready (generation 1)
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 1/2 ready (generation 1)
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 2/2 ready (generation 1)
Four status writes for one app, each caused by a change on the Deployment: created with no Pods, Pods created but not ready (replicas went from 0 to 2, hence the second 0/2), one ready, two ready. Between those, many more events arrived (the finalizer patch, each status write), but they changed nothing and produced no writes. That is the idempotency rule at work.
Step 6 — Self-Healing and Drift
Delete the Deployment, as an over-eager colleague might:
kubectl delete deployment greeter -n operator-lab
kubectl get deploy greeter -n operator-lab
deployment.apps "greeter" deleted from operator-lab namespace
NAME READY UP-TO-DATE AVAILABLE AGE
greeter 2/2 2 2 1s
One second later a new Deployment exists. The garbage collector removed the old ReplicaSet and its Pods together with the deleted Deployment, and the new Pods of this small API were ready within about two seconds. The operator log shows the chain:
kubectl logs -n dotnetapp-system -l app=dotnetapp-operator --prefix --since=20s | grep -A1 -E "Reconciler|Controller"
dotnetapp-operator-585d9d94b9-6qv4v Deployment operator-lab/greeter was deleted, requeue owner
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 0/2 ready (generation 1)
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 0/2 ready (generation 1)
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 1/2 ready (generation 1)
dotnetapp-operator-585d9d94b9-6qv4v operator-lab/greeter status: 2/2 ready (generation 1)
Now drift: scale the Deployment directly, bypassing the DotnetApp.
kubectl scale deployment greeter -n operator-lab --replicas=5
kubectl get deploy greeter -n operator-lab
kubectl get deploy greeter -n operator-lab --show-managed-fields -o json | jq -r '.metadata.managedFields[] | "\(.manager) \(.operation) \(.subresource // "-")"'
deployment.apps/greeter scaled
NAME READY UP-TO-DATE AVAILABLE AGE
greeter 2/2 2 2 14s
dotnetapp-operator Apply -
kube-controller-manager Update status
Three seconds later the Deployment is back at 2. The scale changed spec.replicas, the Deployment watch fired, the reconcile re-applied the desired object with force: true, and the field went back to its owner, dotnetapp-operator. kubectl's manager entry is gone from managedFields. The place to change replicas is the DotnetApp.
Step 7 — Scale Through the Scale Subresource
kubectl scale dotnetapp greeter -n operator-lab --replicas=3
kubectl get dna greeter -n operator-lab -o jsonpath='generation={.metadata.generation} observedGeneration={.status.observedGeneration} ready={.status.readyReplicas}{"\n"}'
dotnetapp.apps.dotnetacademy.dev/greeter scaled
generation=2 observedGeneration=2 ready=2
The spec changed, so the generation went to 2. The operator has already acted on it (observedGeneration=2), but only two replicas are ready so far. A few seconds later:
kubectl get dna greeter -n operator-lab -o jsonpath='generation={.metadata.generation} observedGeneration={.status.observedGeneration} ready={.status.readyReplicas}{"\n"}'
kubectl get dna,deploy -n operator-lab
generation=2 observedGeneration=2 ready=3
NAME IMAGE DESIRED READY STATUS AGE
dotnetapp.apps.dotnetacademy.dev/greeter greeter-api:1.0 3 3 AllReplicasReady 37s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/greeter 3/3 3 3 22s
kubectl scale worked on a custom resource because the CRD maps the Scale API onto .spec.replicas. The same mapping lets a HorizontalPodAutoscaler target a DotnetApp directly, using .status.selector to find its Pods.
Step 8 — Roll Out a New Version
Change the image and the configuration in one patch, and watch the status column:
kubectl run curl --image=curlimages/curl:8.17.0 -n operator-lab --rm -i --restart=Never -q --command -- sh -c 'sleep 1; curl -s http://greeter'
kubectl get dna greeter -n operator-lab -w &
kubectl patch dna greeter -n operator-lab --type merge -p '{"spec":{"image":"greeter-api:1.1","env":{"GREETING":"Selam","APP_VERSION":"1.1"}}}'
{"message":"Merhaba from greeter-api","version":"1.0","pod":"greeter-fb8fd5b69-c5vjs"}
dotnetapp.apps.dotnetacademy.dev/greeter patched
NAME IMAGE DESIRED READY STATUS AGE
greeter greeter-api:1.0 3 3 AllReplicasReady 40s
greeter greeter-api:1.1 3 3 AllReplicasReady 41s
greeter greeter-api:1.1 3 3 WaitingForReplicas 41s
greeter greeter-api:1.1 3 3 WaitingForReplicas 41s
greeter greeter-api:1.1 3 4 WaitingForReplicas 42s
greeter greeter-api:1.1 3 3 WaitingForReplicas 42s
greeter greeter-api:1.1 3 3 WaitingForReplicas 42s
greeter greeter-api:1.1 3 4 WaitingForReplicas 43s
greeter greeter-api:1.1 3 3 WaitingForReplicas 43s
greeter greeter-api:1.1 3 4 WaitingForReplicas 44s
greeter greeter-api:1.1 3 3 AllReplicasReady 44s
The first 1.1 line is the spec change itself, with the old status. One reconcile later the condition is WaitingForReplicas. During the rolling update READY briefly shows 4, the surge Pod, and the condition stays False although three or more Pods are ready, because old Pods are still part of the count. Only when all three replicas run the new template does it flip to AllReplicasReady. An earlier version of this operator compared only readyReplicas with replicas and reported AllReplicasReady in the middle of the rollout. That is the kind of bug a careful status definition prevents.
kubectl rollout status deploy/greeter -n operator-lab
kubectl run curl --image=curlimages/curl:8.17.0 -n operator-lab --rm -i --restart=Never -q --command -- sh -c 'sleep 1; curl -s http://greeter'
kubectl get deploy greeter -n operator-lab -o jsonpath='{.spec.template.spec.containers[0].image} {.spec.template.spec.containers[0].env}{"\n"}'
kubectl get dna greeter -n operator-lab -o jsonpath='{.status.conditions[0]}' | jq -c .
deployment "greeter" successfully rolled out
{"message":"Selam from greeter-api","version":"1.1","pod":"greeter-76746487f9-bvt6w"}
greeter-api:1.1 [{"name":"APP_VERSION","value":"1.1"},{"name":"GREETING","value":"Selam"}]
{"lastTransitionTime":"2026-10-21T12:16:03.308468Z","message":"3/3 replicas ready","observedGeneration":3,"reason":"AllReplicasReady","status":"True","type":"Ready"}
Step 9 — Leader Failover
Delete the Pod that holds the Lease:
kubectl get lease dotnetapp-operator -n dotnetapp-system -o jsonpath='{.spec.holderIdentity}{"\n"}'
kubectl delete pod dotnetapp-operator-585d9d94b9-6qv4v -n dotnetapp-system
dotnetapp-operator-585d9d94b9-6qv4v
pod "dotnetapp-operator-585d9d94b9-6qv4v" deleted from dotnetapp-system namespace
Twenty seconds later:
kubectl get lease dotnetapp-operator -n dotnetapp-system -o jsonpath='holder={.spec.holderIdentity} transitions={.spec.leaseTransitions}{"\n"}'
kubectl get pods -n dotnetapp-system
kubectl logs -n dotnetapp-system -l app=dotnetapp-operator --prefix --since=30s | grep -A1 LeaderElected
holder=dotnetapp-operator-585d9d94b9-8p7gn transitions=1
NAME READY STATUS RESTARTS AGE
dotnetapp-operator-585d9d94b9-6xs5s 1/1 Running 0 94s
dotnetapp-operator-585d9d94b9-8p7gn 1/1 Running 0 21s
dotnetapp-operator-585d9d94b9-6xs5s Current leader: dotnetapp-operator-585d9d94b9-8p7gn
dotnetapp-operator-585d9d94b9-8p7gn Current leader: dotnetapp-operator-585d9d94b9-6qv4v
dotnetapp-operator-585d9d94b9-8p7gn Current leader: dotnetapp-operator-585d9d94b9-8p7gn
dotnetapp-operator-585d9d94b9-8p7gn dotnetapp-operator-585d9d94b9-8p7gn became the leader, starting controllers
The leadership went to the replacement Pod 8p7gn, not to the old follower 6xs5s. Leader election only guarantees that there is at most one leader, not which replica wins; whoever retries first after the lease expires gets it. Prove that the new leader is working:
kubectl delete deployment greeter -n operator-lab
kubectl get deploy greeter -n operator-lab
deployment.apps "greeter" deleted from operator-lab namespace
NAME READY UP-TO-DATE AVAILABLE AGE
greeter 3/3 3 3 6s
Step 10 — Deletion with a Finalizer
Add a second app, 5-pricing.yaml:
apiVersion: apps.dotnetacademy.dev/v1
kind: DotnetApp
metadata:
name: pricing
namespace: operator-lab
spec:
image: greeter-api:1.0
env:
GREETING: Hello
kubectl apply -f 5-pricing.yaml
kubectl get dna -n operator-lab
kubectl get configmap app-catalog -n dotnetapp-system -o jsonpath='{.data}' | jq .
dotnetapp.apps.dotnetacademy.dev/pricing created
NAME IMAGE DESIRED READY STATUS AGE
greeter greeter-api:1.1 3 3 AllReplicasReady 101s
pricing greeter-api:1.0 1 1 AllReplicasReady 10s
{
"operator-lab.greeter": "greeter-api:1.1 http://greeter.operator-lab.svc.cluster.local",
"operator-lab.pricing": "greeter-api:1.0 http://pricing.operator-lab.svc.cluster.local"
}
Delete greeter while the operator runs:
kubectl delete dna greeter -n operator-lab
kubectl get configmap app-catalog -n dotnetapp-system -o jsonpath='{.data}' | jq .
kubectl get deploy,svc -n operator-lab
kubectl logs -n dotnetapp-system -l app=dotnetapp-operator --prefix --since=15s | grep -A1 Reconciler
dotnetapp.apps.dotnetacademy.dev "greeter" deleted from operator-lab namespace
{
"operator-lab.pricing": "greeter-api:1.0 http://pricing.operator-lab.svc.cluster.local"
}
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/pricing 1/1 1 1 13s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/pricing ClusterIP 10.96.33.207 <none> 80/TCP 13s
dotnetapp-operator-585d9d94b9-8p7gn operator-lab/pricing status: 1/1 ready (generation 1)
dotnetapp-operator-585d9d94b9-8p7gn operator-lab/greeter catalog entry removed, finalizer released
dotnetapp-operator-585d9d94b9-8p7gn operator-lab/greeter is gone, nothing to do
dotnetapp-operator-585d9d94b9-8p7gn Deployment operator-lab/greeter was deleted, requeue owner
dotnetapp-operator-585d9d94b9-8p7gn operator-lab/greeter is gone, nothing to do
Two mechanisms worked together. The finalizer let the operator remove the catalog entry in dotnetapp-system before the object vanished. Once it was gone, the garbage collector deleted its Deployment and Service through their owner references; the operator wrote no code for that. The later events for greeter find nothing and do nothing.
Now the failure case: delete pricing while no operator runs.
kubectl scale deploy dotnetapp-operator -n dotnetapp-system --replicas=0
kubectl delete dna pricing -n operator-lab --wait=false
kubectl get dna pricing -n operator-lab -o jsonpath='deletionTimestamp={.metadata.deletionTimestamp} finalizers={.metadata.finalizers}{"\n"}'
kubectl get dna,deploy -n operator-lab
deployment.apps/dotnetapp-operator scaled
dotnetapp.apps.dotnetacademy.dev "pricing" deleted from operator-lab namespace
deletionTimestamp=2026-10-21T12:17:08Z finalizers=["apps.dotnetacademy.dev/catalog-cleanup"]
NAME IMAGE DESIRED READY STATUS AGE
dotnetapp.apps.dotnetacademy.dev/pricing greeter-api:1.0 1 1 AllReplicasReady 22s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/pricing 1/1 1 1 22s
"Deleted", says kubectl, but the object is still there with a deletionTimestamp, held by the finalizer. Its Deployment keeps running too: the garbage collector waits until the owner is really gone. Without --wait=false, kubectl would hang here. Start the operator again:
kubectl scale deploy dotnetapp-operator -n dotnetapp-system --replicas=2
kubectl get dna,deploy -n operator-lab
kubectl get configmap app-catalog -n dotnetapp-system -o jsonpath='{.data}'
kubectl logs -n dotnetapp-system -l app=dotnetapp-operator --prefix --since=30s | grep -A1 -E "Reconciler|LeaderElected"
deployment.apps/dotnetapp-operator scaled
No resources found in operator-lab namespace.
dotnetapp-operator-585d9d94b9-7bfzb Current leader: dotnetapp-operator-585d9d94b9-8p7gn
dotnetapp-operator-585d9d94b9-7bfzb Current leader: dotnetapp-operator-585d9d94b9-jklp7
dotnetapp-operator-585d9d94b9-jklp7 operator-lab/pricing catalog entry removed, finalizer released
dotnetapp-operator-585d9d94b9-jklp7 operator-lab/pricing is gone, nothing to do
dotnetapp-operator-585d9d94b9-jklp7 Deployment operator-lab/pricing was deleted, requeue owner
dotnetapp-operator-585d9d94b9-jklp7 operator-lab/pricing is gone, nothing to do
dotnetapp-operator-585d9d94b9-jklp7 operator-lab/pricing is gone, nothing to do
The new replicas first saw the stale Lease of the scaled-down leader 8p7gn, waited for it to expire, and then jklp7 took over. Its initial list found pricing with a deletion timestamp, ran the cleanup and released the finalizer. Level-triggered reconciliation needed no record of the missed delete event, only the current state. The catalog is empty and the namespace has no apps left.
Step 11 — Clean Up
Delete the apps before the operator, so that it can still release its finalizers. In the other order, every remaining DotnetApp waits for an operator that no longer exists, and its namespace stays in Terminating until you remove the finalizers by hand with the patch from the Hands-On section.
kubectl delete namespace operator-lab
kubectl delete namespace dotnetapp-system
kubectl delete crd dotnetapps.apps.dotnetacademy.dev
kubectl delete clusterrolebinding,clusterrole dotnetapp-operator
namespace "operator-lab" deleted
namespace "dotnetapp-system" deleted
customresourcedefinition.apiextensions.k8s.io "dotnetapps.apps.dotnetacademy.dev" deleted
clusterrolebinding.rbac.authorization.k8s.io "dotnetapp-operator" deleted
clusterrole.rbac.authorization.k8s.io "dotnetapp-operator" deleted
Summary
- An operator is a custom resource plus a controller that turns it into ordinary Kubernetes objects and keeps them that way.
- Controllers are level-triggered: events only enqueue a key, and the reconcile reads the current state. Every reconcile must be idempotent; a periodic resync covers missed events.
- A de-duplicating work queue with one worker prevents parallel reconciles of the same object, and exponential backoff protects the API server from failing objects.
- Watch the children too and map their events back through the owner reference. That is how a deleted or hand-edited Deployment is repaired within a second.
- Owner references let the garbage collector delete children; server-side apply with a field manager makes "create or update" a single idempotent call and takes back fields others changed.
- Report progress with status, a Ready condition and observedGeneration, write status only when it changes, and define "ready" carefully enough to be false during a rollout.
- The scale subresource makes
kubectl scale and the HPA work on your resource. - Use a finalizer only for cleanup owner references cannot do, such as objects in another namespace. While the controller is down, deletion waits.
- Grant the operator only what its reconcile uses, and run several replicas with leader election on a Lease, so exactly one of them acts.