Skip to content

Make idle reconciles issue no Kubernetes API requests - #227

Open
pcholakov wants to merge 6 commits into
mainfrom
claude/nervous-brattain-707d68
Open

pcholakov wants to merge 6 commits into
mainfrom
claude/nervous-brattain-707d68

Conversation

@pcholakov

@pcholakov pcholakov commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ AI-generated (Claude Code (Opus 5.5))

Summary

An idle RestateCluster made 21 Kubernetes API requests per reconcile and was reconciled twice every 5 minutes, about 12,000 requests (18 MB of audit log) per cluster per day. Almost all of them were server-side applies that changed nothing, plus deletes of objects that were already gone. On EKS every request is an audit record that CloudWatch bills per GB. With this change an idle reconcile makes no API requests (apart from a recheck every 30 minutes of objects that should be absent), each resource is reconciled once per interval, and the default interval is 30 minutes.

Measured on kind with apiserver audit logging (method under Validation):

per idle reconcile main this branch
RestateCluster (3 replicas) 21 requests, 30.9 KB 0
RestateDeployment (ReplicaSet mode) 6 requests, 8.9 KB 0
per object per day main (5 min, 2 reconciles/interval for RC) this branch (30 min default)
RestateCluster 12,096 requests, 17.8 MB ~96 (absence rechecks, see below)
RestateDeployment 1,728 requests, 2.6 MB 0
whole operator, watches (interval-independent) ~2,450 requests, 3.6 MB ~2,590 requests, 3.8 MB

Two per-cluster costs remain:

  • A recheck of each object that should be absent: one DELETE (answered with 404) every 30 minutes. That is 2 per RestateCluster with default settings, the old DNS policy and the Pod Identity policy, or ~96 per day.
  • One apply of everything after an operator restart: ~19 requests per RestateCluster and 2 per RestateDeployment.

Approach and decisions

  • Skip applies the watch cache shows would change nothing (src/controllers/owned_objects.rs). Each kind the operator writes has an OwnedObjects<K> backed by its watch store. It remembers, in memory, a hash of the last object it applied to each name and a fingerprint of the object that apply returned. An apply is skipped only when both still match the cache. The fingerprint is uid + generation + labels + annotations + owner references, or the resourceVersion for kinds without a generation. So any spec or metadata write by someone else, a delete, a cache miss or an operator restart means applying again, and drift is repaired as soon as the watch delivers the change. Status-only writes don't count, so a busy status doesn't cause re-applies.
    • Rejected: stamping a hash annotation on the objects. It survives restarts, but it can't tell when someone else edited the spec under an unchanged annotation unless you also track generations, which brings back the in-memory state anyway.
    • Rejected: comparing the desired object with the live one field by field. Server-side defaulting and normalisation (quantities, int-or-string) make that error-prone.
  • Skip deletes of objects this process saw the apiserver report gone in the last 30 minutes, as long as it hasn't applied them since and the cache doesn't show them again. The cache alone can't prove absence: it only holds objects that match its label selector, so an object recreated under one of our names without our labels is invisible to it. The 30-minute recheck catches that, matching the default resync.
  • Canary Jobs use the same path, so a Job that has run for the current spec is no longer re-applied every reconcile. Its result is read from the cache.
  • Status patches are skipped when the computed status serialises the same as the current one.
  • RestateDeployment looks the current ReplicaSet up in its prewarmed store instead of POSTing and handling the 409. It patches scale only when spec.replicas differs. It patches annotations only when a value differs, or when the propagate-annotations field manager owns a different set of keys (read from managedFields). The 409 path stays as the fallback for a stale store. A cached ReplicaSet that looks like a hash collision also goes through that path, so only the apiserver's answer can bump collisionCount.
  • Double reconciles (pre-existing bug). kube-runtime's owns_stream keys an owner by its child's namespace (ObjectRef::from_owner_ref(ns, …), kube-runtime 2.0.1 controller/mod.rs:231). For the cluster-scoped RestateCluster and RestateCloudEnvironment, that queued a second key (rc-a.rc-a), which reconciled and requeued alongside the real one. Children are now mapped to their owner by name with watches_stream.
  • Resync interval. Configurable with --resync-interval-seconds / RESYNC_INTERVAL_SECONDS / Helm resyncIntervalSeconds. Default 1800 s, jittered ±10% on every requeue so resources reconciled together after a restart drift apart.
    • Why 30 minutes rather than 60: with watches doing the drift repair, the resync only catches what watches can't see, such as a deployment removed from Restate behind the operator's back. After this change it costs no Kubernetes requests, so the interval no longer matters for audit volume and the shorter of the two bounds was chosen.
    • Unchanged: NotReady (60 s), error (30 s), drain polling (10 s) and scheduled-removal requeues. A scheduled removal is now capped at the resync interval instead of 5 minutes.

Impact

  • New watches:
    • The RestateDeployment controller watches its per-version Services, mapped to the RestateDeployment by the restate.dev/owned-by label. Edits to them used to wait for the next requeue; they are now reverted immediately.
    • The RestateCloudEnvironment controller now watches its tunnel Service.
    • Its Deployment watch selected on app.kubernetes.io/managed-by=restate-operator, a label the tunnel objects never carry, so it saw nothing. It now selects on app.kubernetes.io/name=restate-cloud-tunnel-client.
  • No RBAC change: the operator already lists and watches every kind it now caches. PVCs, NetworkPolicies, Namespaces, ServiceAccounts, PDBs and canary Jobs move from metadata-only to full-object watches, so the operator holds slightly more memory.
  • Behaviour change: a change that only Restate can see (not Kubernetes) is now noticed within 30 minutes instead of 5. Lower resyncIntervalSeconds if that matters.
  • Not covered: Knative mode's Configuration/Route applies and per-version HPA applies still go out on every reconcile. Knative isn't used in Restate Cloud; it is a candidate follow-up.

Validation

  • cargo test --lib (148 passed) and cargo clippy --all-targets are clean. A new unit test checks that OwnedObjects skips an unchanged apply, re-applies after an out-of-band edit, and skips a delete the synced cache says is absent. cargo test --doc has 5 failures that are already on main (doc comments in podidentityassociations.rs and quantity_parser.rs).
  • e2e: e2e/rollback.sh passed 13/13. One earlier full run failed a single assertion in the pinned-new scenario ("v2 never became latest"). At that point Restate was already routing to v2. The likely cause is a race in the script: rd_ready accepts the stale Ready=True left from v1, so v2's deployment id can be read before its ReplicaSet exists. That scenario passed when run on its own, and on a full rerun.
  • Audit measurement. Setup:
    • kind, 1 control-plane and 3 workers, kube-apiserver audit policy at Metadata level with omitStages: [RequestReceived] and a file backend.
    • Three RestateClusters with 3 replicas each (auto-provisioned), plus one ReplicaSet-mode RestateDeployment registered against one of them.
    • Both builds ran with a 30 s resync: main patched in place, the branch via resyncIntervalSeconds: 30.
    • Once steady, the run counted the operator ServiceAccount's audit events over 10 minutes and divided by the reconciles logged in the same window. Per-day figures scale the per-reconcile counts to the production interval.
    • Treat the byte figures as relative: kind's audit records are close to EKS's but not identical.
  • Drift repair on the branch:
    • Deleting a NetworkPolicy: recreated in 0.1 s (main: 0.1 s).
    • Editing an operator-owned field of the restate Service: reverted in 0.2 s (main: 0.2 s).
    • Editing the RestateDeployment's Service targetPort: reverted in under 1 s (main: only at the next requeue).
    • Fields the operator doesn't own, like publishNotReadyAddresses, are left alone, as on main (server-side apply semantics).
  • Review: Claude (Fable 5.1) reviewed the diff and found no blockers. It found two issues, both fixed here: a deleted tunnel Deployment wasn't noticed until the resync, because the generation predicate dropped the delete event; and a stale ReplicaSet cache could bump collisionCount.
  • Review: Codex (gpt-6.1-sol, then gpt-6-astra) found that skipping deletes on a cache miss alone could leave behind an object that had lost our labels. It then found that trusting a recorded absence forever had the same problem for an object recreated without them. The recheck described above fixes both. Unit tests and clippy pass after these fixes; e2e last ran before them.

Claude (Opus 5.5) built the measurement harness, ran both builds, and traced the double reconcile to kube-runtime's owns_stream.

Every idle reconcile sent the apiserver a server-side apply for each
object the operator manages, DELETEs for objects already gone, a
create-then-409 for the current ReplicaSet and a status patch: about
22 requests per RestateCluster every 5 minutes. On EKS each of those is
an audit record billed by CloudWatch.

Writes now go through OwnedObjects, which fronts each kind with its
watch cache. An apply is skipped when this process last applied the
same desired object and the cached object is still what that apply
produced; any other write to it (spec, labels, annotations, owner
references, or any change for kinds without a generation), a cache
miss or a restart applies again, so out-of-band drift is still repaired
when the watch reports it. Deletes are skipped when the synced cache
has no such object, the canary Jobs are no longer re-applied once they
have run, and status patches are skipped when the status is unchanged.
RestateDeployment looks the current ReplicaSet up in its prewarmed
store instead of creating it and handling the 409, and only patches its
scale and annotations when they differ.

The steady-state resync is now configurable (--resync-interval-seconds,
RESYNC_INTERVAL_SECONDS, Helm resyncIntervalSeconds), defaults to 30
minutes, and is jittered by 10% so resources reconciled together after
a restart drift apart. NotReady and error requeues are unchanged.

The RestateCloudEnvironment watch selected on a label the tunnel
objects never carry, so it saw nothing; it now selects on
app.kubernetes.io/name=restate-cloud-tunnel-client.
kube-runtime's owns_stream keys an owner by its child's namespace. For
the cluster-scoped RestateCluster and RestateCloudEnvironment, every
namespaced child therefore queued a second, namespaced key for its
owner, which reconciled and requeued alongside the real one: each
RestateCluster was reconciled twice per interval. Map children to
their owners by name instead.
The tunnel Deployment watch filtered on generation and labels, which a
delete event leaves unchanged, so a deleted tunnel Deployment waited
for the resync to be recreated. All cached watches now share the
reflector helper that bumps the generation on deletion.

A ReplicaSet in the store that looks like someone else's may already be
gone from the apiserver, for example after the RestateDeployment was
deleted and recreated. Treating it as a hash collision would rename the
version permanently, so such a cache hit now goes through the create,
and only the apiserver's 409 can establish a collision.
@pcholakov
pcholakov requested a review from lukebond October 22, 2026 09:44
@pcholakov
pcholakov marked this pull request as ready for review October 22, 2026 09:45
The watch caches only hold objects that match their label selector, so
a cache miss could hide an object of ours that had lost its labels,
and the delete would never be sent. A delete is now skipped only once
this process has seen the apiserver report the object gone, and has
neither applied it since nor seen it reappear in the cache.
A recorded absence was trusted forever, so an object recreated under
one of our names but without our labels, which the label-selected
cache cannot see, would never be deleted. Absences now expire after 30
minutes, the default resync, at the cost of one DELETE per absent name
per interval.

@lukebond lukebond left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for addressing this problem. i've known about it and haven't done anything about it, so i appreciate it.

the implementation is correct i think but it's unconventional in the k8s controller space. so i want to push back on this approach and steer to a different solution that will be simpler, less unusual and more idiomatic for a k8s controller.

it's common to re-reconcile on a timer even when a watched resource hasn't changed, but the onus is on the controller to ensure that this is cheap and results in an early exit. the current behaviour blindly applies and deletes objects unnecessarily, so it's failing that and this is what you're fixing. but the more idiomatic way to do it is a spec-hash annotation, and it's what most operators that care about this do (e.g. Flux). something like:

  1. compute the desired hash
  2. stamp it on the object as an annotation when you apply
  3. on reconcile, read the object from the watch cache (no API call) and skip the apply if its stored annotation already equals hash(desired)

the controller machinery (kube-rs in rust, controller-runtime in go) has in-built caching to be leveraged here, no need to roll your own.

your description shows you considered the annotation and rejected it because it "can't tell when someone edited the spec under an unchanged annotation unless you track generations, which brings back in-memory state." i think that's misundertood: 1. external edits to a child are caught by the watch; the change event re-triggers a reconcile and you re-apply, so the annotation never has to detect drift itself. 2. generation tracking doesn't need in-memory state: for the CRs it belongs in status.observedGeneration, the conventional persisted field, and the guard is observedGeneration == metadata.generation. pair that with status predicates on the child watches and an unchanged resource does no work at all- no per-object cache, and it survives restarts (yours re-applies everything on restart). the spec-hash annotation then handles the secondary case of skipping an unchanged child.

@darkmuggle

Copy link
Copy Markdown
Contributor

:100 -- hard agree with @lukebond

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants