KubernetesIV · Desired State and ReconciliationDesired state and reconciliation
Level-triggered vs edge-triggered reconciliation
What you'll learn
- Distinguish level-triggered from edge-triggered reconciliation with concrete examples
- Explain why Kubernetes prefers level-triggered for correctness under failure
- Identify the rare cases where edge-triggered is appropriate
- Design controllers that survive event ordering, drops, and restarts
Prerequisites
Verified against Kubernetes 1.34.x · kubeadm 1.34.x · kubectl 1.34.x · etcd 3.6.x · CoreDNS 1.11.x · containerd 1.7.x / 2.x · 2026-08-16
Kubernetes’s controllers are level-triggered: they act on the current observed state, not on a specific event. This is a deliberate design choice that makes controllers resilient to event ordering, drops, and restarts. This lesson explains why.
Level-triggered vs edge-triggered
A level-triggered controller acts on the current state:
“There are 3 Pods; I want 5. Create 2 more.”
An edge-triggered controller acts on each event:
“Event: ‘Pod created’. Add 1 to my counter of creations.” “Event: ‘Pod deleted’. Add 1 to my counter of deletions.” “Act when counter == 5 minus expected.”
flowchart TB
subgraph Level["Level-triggered"]
L[Observe current state<br/>3 Pods, want 5] --> LD[Diff: need 2 more]
LD --> LA[Act: create 2]
LA --> L
end
subgraph Edge["Edge-triggered"]
E[Watch event stream] --> E1[Event: ADDED Pod X]
E1 --> E2[Event: DELETED Pod Y]
E2 --> E3[Event: ADDED Pod Z]
E3 --> EA[Act: maintain ratio]
end
Why level-triggered wins for Kubernetes
Level-triggered is correct under conditions where edge-triggered breaks.
Condition 1: dropped events
sequenceDiagram
autonumber
participant API as API server
participant L as Level-triggered
participant E as Edge-triggered
Note over API: Pod A created
API-->>L: event ADDED A
API-->>E: event ADDED A
Note over API: Pod B created (event lost!)
API-->>L: (event lost — L doesn't care)
API-->>E: (event lost — E thinks only A exists)
L->>API: GET current state (sees A and B)
L->>L: ok, no action needed
E->>E: thinks only A exists, may act incorrectly
Level-triggered: a dropped event is harmless. The next reconcile re-reads the current state and finds A and B. Correct.
Edge-triggered: a dropped event causes the controller to lose track of state. Without idempotency, the controller’s state drifts from reality. Recovery requires a full re-list or restart.
Condition 2: out-of-order events
sequenceDiagram
autonumber
participant API as API server
participant L as Level-triggered
participant E as Edge-triggered
API-->>L: event MODIFIED A=10
API-->>L: event MODIFIED A=20
API-->>L: event MODIFIED A=15 (out of order!)
API-->>E: event MODIFIED A=10
API-->>E: event MODIFIED A=20
API-->>E: event MODIFIED A=15
Note over L: L re-reads: A=15 (current state)
Note over E: E thinks A went 10 -> 20 -> 15
Note over E: E acts on its view of history
Level-triggered: the current state is A=15 regardless of event order. The controller’s decision is based on the current state, not the history.
Edge-triggered: out-of-order events produce incorrect state in the controller’s history. The controller may over-correct (e.g., revert A to 10 thinking it was the “right” state).
Condition 3: controller restart
sequenceDiagram
autonumber
participant API as API server
participant L as Level-triggered restarted
participant E as Edge-triggered restarted
Note over L: L restarts and lists current state
L->>API: GET current state (sees A=15)
Note over L: L acts on current state - correct
Note over E: E restarts and loses event history
E->>E: has no record of past events
Note over E: E cannot reason about A=15 without history
Level-triggered: a restart is harmless. The reflector re-lists on startup; the indexer is rebuilt; reconciliation proceeds from the current state.
Edge-triggered: a restart loses the event history. The controller must either replay the history (expensive) or accept that its view is incomplete (potentially wrong).
Condition 4: HA controllers
Two replicas of the same controller, both watching, both acting.
sequenceDiagram
autonumber
participant API as API server
participant L1 as Level-triggered replica 1
participant L2 as Level-triggered replica 2
participant E1 as Edge-triggered replica 1
participant E2 as Edge-triggered replica 2
API-->>L1: event ADDED Pod A
API-->>L2: event ADDED Pod A
API-->>L1: event ADDED Pod B
API-->>L2: event ADDED Pod B
Note over L1,L2: Both re-read and both see A and B
Note over L1,L2: Idempotent actions - one wins, the other is no-op
Note over E1,E2: Both saw different events if they split
Note over E1,E2: Both act based on partial history which may conflict
Level-triggered: both replicas converge to the same action
because they observe the same current state. Optimistic
concurrency in the API server serialises writes; one wins,
the other gets 409 and re-reads.
Edge-triggered: both replicas have partial, different event histories. Their actions may conflict; the result depends on event ordering, which is racy.
The cost of level-triggered
Level-triggered is not free. Costs:
- Latency — every reconcile re-reads the world. With a watch cache, this is fast, but a controller that wants < 100 ms reconcile latency may be slow if its reads are not local.
- Throughput — a controller that wants to scale to 10,000 objects per reconcile must list them all. The informer cache helps, but a misbehaving controller can still hammer the API server.
- Periodic re-list — the reflector re-lists every 10 minutes by default. This is a safety net for missed events.
These costs are acceptable for Kubernetes’s reconciliation model. The alternative — edge-triggered with full event replay — is more complex and less robust.
When edge-triggered is appropriate
A few places in Kubernetes use edge-triggered reasoning:
The scheduler
The scheduler reacts to Pods being created (or becoming unschedulable after binding). It does not maintain a long-term view of “all unscheduled Pods”; it picks them up as they arrive.
This works because:
- A Pod’s
spec.nodeNamebeing unset is a stable observation - The scheduler re-lists on every reconcile cycle anyway
- Edge-triggered scheduling is faster (no full re-list required)
Garbage collector
The GC reacts to deletion events. It needs to know “owner is gone, delete children” — an edge-triggered event. The GC also has a periodic full walk as a safety net.
Event recorders
Some controllers emit Events in response to state changes (“Deployment updated”). This is conceptually edge-triggered: the event is “something happened”.
Designing your own controller
If you write a custom Operator or controller, the design should be level-triggered. Specifically:
// Good: level-triggered
func Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
obj := &MyResource{}
if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
desiredState := obj.Spec
observedState := readWorldState(obj)
if !reflect.DeepEqual(desiredState, observedState) {
return r.act(ctx, obj, observedState, desiredState)
}
return ctrl.Result{}, nil // converged
}
// Bad: edge-triggered
func Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
event := getCurrentEvent() // fragile!
switch event.Type {
case "Created":
return handleCreated(event)
case "Deleted":
return handleDeleted(event)
}
return ctrl.Result{}, nil
}
The level-triggered pattern:
- Re-read the current state on every reconcile
- Diff against the spec
- Act to close the gap
- Return — the workqueue will re-trigger when the state changes
This is robust under all the failure modes described above.
The control theory view
In control theory:
- Level-triggered = “act when state crosses threshold”
- Edge-triggered = “act on transitions”
Level-triggered is correct under noisy signals; edge-triggered requires clean transitions. Kubernetes’s watch stream is noisy (drops, ordering, restarts). Level-triggered is the appropriate choice.
The corollary: a controller that appears to depend on specific events is fragile. The right model is “act on current state”.
Cross-course references
- The Linux course part
VI-Linux-Processescovers process state transitions; level-triggered reconciliation is the cluster’s analogue. - The Observability course part
CIX-Observability-InvestigationWorkflowscovers investigation methodology that benefits from understanding the reconciliation model. - The Linux course part
XXIV-Linux-Timecovers chrony — watch timestamps and resource versions depend on clock accuracy. - The Docker course part
XXX-Docker-Lifecyclecovers the container lifecycle that drives much of the reconcile flow.
Quiz
Knowledge check · 4 questions
Q1. A controller reads `obj.Spec.Replicas=5` and `obj.Status.Replicas=3` from the API server. The watch stream has been dropping events. Will the controller produce the correct result?
Q2. Edge-triggered reconciliation is better than level-triggered because it reacts faster to events.
Q3. A custom controller is restarted mid-reconcile. The state of the cluster is correct (replicas=5, ready=5). On restart, the controller lists and finds 5 Pods, all owned. Does the controller produce a correct result?
Pre-restart cluster state: ``` $ kubectl get myresource example -o yaml spec: replicas: 5 status: replicas: 5 readyReplicas: 5 $ kubectl get pods -l app=example NAME READY pod-abc-1 1/1 pod-abc-2 1/1 pod-abc-3 1/1 pod-abc-4 1/1 pod-abc-5 1/1 ``` Controller restart (SIGKILL, then startup): ``` I0815 12:01:01 controller: shutdown signal received I0815 12:01:01 controller: in-flight reconcile cancelled I0815 12:01:05 controller: starting up I0815 12:01:06 controller: listing MyResources I0815 12:01:06 controller: found 1 MyResource I0815 12:01:06 controller: reconciling example I0815 12:01:06 controller: state: spec.replicas=5, status.replicas=5, status.readyReplicas=5 I0815 12:01:06 controller: diff: 0 I0815 12:01:06 controller: no action needed ```
Q4. Design a custom controller for a CRD `Foo`. What makes your design level-triggered, and what would happen if a watch event were dropped during reconciliation?
Passing score: 75%. Answers are checked in this browser.
Production discipline
- Always design controllers as level-triggered: read the current state, diff, act. Never depend on specific events.
- Test reconcilers for correctness under event drops, ordering changes, and restarts.
- Use optimistic concurrency (
resourceVersion) to serialise concurrent reconciliations. - Make every action idempotent: re-running the reconciler with the same input produces the same output.
- Treat watch events as hints, not commands. The reconciler re-reads state, not event payloads.