Repository navigation
kind: shut down, roll and self-repair the control plane as deployments do - #507
Conversation
6e022d7 to
02ebd18
Compare
nickdujay-pplx
left a comment
There was a problem hiding this comment.
Reviewed 02ebd18 against #484. No additional blocker in this layer.
Checked shutdown budgets against the server/client code, startup/readiness probes, internal-api's concurrent-replica behavior, PDB selectors, PriorityClass creation before workloads, and the umbrella installer's prerequisite check. Reviewed each newly enabled repair/GC setting: the EC2-backed queue GC stays disabled, while the Kubernetes-presence and orphaned-pod GCs remain dry-run. No schema, RPC or production Rust change is introduced here.
Exact-head chart renders passed; Kubernetes schema validation accepted all 119 rendered resources. The existing harness/chart run passed 648 tests; its one macOS wc-formatting failure also passed when rerun with GNU tools. All four Kind CI shards passed. I did not independently rerun the Linux/KVM shutdown/leader-election drills.
Rollback can revert these chart settings without a schema rollback, but cannot undo repairs already applied to sandbox state. The single admin replica still has rollout downtime, as documented. Approval covers this delta; #480's separate installer finding remains outstanding.
…s do Kind's control-plane pods were stopped abruptly and its backstop reconcilers were off, so the code that drains in-flight work on shutdown, survives voluntary disruption and repairs stuck rows never ran in Kind. Shutdown and rollouts: - space-api and space-admin-api render --shutdown-grace-secs at the binary's 150 s default, sleep 5 s in preStop so the pod leaves the entry Services' endpoints before the listener stops, and get a 180 s termination grace that covers both (the chart refuses a smaller one). A startupProbe on /readyz is the boot budget the Pod reflector leaves to Kubernetes; minReadySeconds holds a rollout on a replacement that fails its first probes. - space-data-plane has no drain budget of its own. On SIGTERM it waits for plain HTTP streaming responses (native session SSE, ECW process and file streams); upgraded connections close at SIGTERM or end with the process. It gets the same preStop delay, a 180 s grace as the bound on those streams, a startupProbe covering its wait for ClusterApi, and minReadySeconds. - space-internal-api runs two replicas with a rolling update, a 5 s preStop and a 50 s grace: its state is in Postgres under row locks and claim tokens, its RPCs are unary, and nothing in it is a singleton. The server bounds no call itself and every ClusterApi client times out at 30 s, so the grace is the preStop delay, that client budget, and the two 5 s telemetry flushes, plus exit. Disruption and priority: - PodDisruptionBudgets (maxUnavailable: 1) for api, admin-api, data-plane, cluster-controller, internal-api and credmgr-authority. - manifests/priorityclasses.yaml, applied by bootstrap and Tilt before any workload: space-control-plane for the control plane and its in-cluster dependencies, space-replaceable (never preempts) for the UI and one-shot Jobs. Node-service DaemonSets run as system-node-critical. - The UI is pinned to the infra node, as the README already claimed. Backstop reconcilers now run as deployments run them: the transient-lifecycle sweeper (restoring rows after 600 s, other transient lifecycles effectively off, failed-sandbox cleanup on), the stuck-snapshot reaper, the started-sandbox and failed-restorable repairs (live), the gone-node queue GC (dry-run) and the orphaned-restore-pod GC (enabled, dry-run, binary-default intervals). The reconcile-queue GC stays off: its EC2 detector would sign DescribeInstances to the public EC2 API, no switch disables that detector alone, and its client takes only the global endpoint override, which would also redirect the controller's S3 clients. Postgres gets the control-plane priority and keeps its memory limit and max_connections. Its connection budget (space-postgres values) now counts the second internal-api replica and its rollout surge, 1328 of 1400; the new reconcilers run on the cluster-controller's existing two pools. The space-cluster installer (infra/space/cluster) checks the two SPACE PriorityClasses before installing, read-only, as it checks RuntimeClass space, and its README asks for manifests/priorityclasses.yaml once per cluster: the control plane and the migrate Job now name them, and a pod naming a missing class is refused. The README gains a section on shutdown, disruption and self-repair, and the differences table records what stays different: the Kind-sized preStop delay, no spread constraints on a single infra node, the single-replica admin API under its budget, and the reconcile-queue GC, which stays off so a Kind bench never calls AWS.
02ebd18 to
a5335ff
Compare
What Kind now does like deployments
Kind's control plane used to stop abruptly and left its backstop reconcilers off, so the code that drains work on shutdown, survives voluntary disruption and repairs stuck rows never ran in Kind. This PR changes only Kind (charts, both bring-up paths, README, and one comment in the smoke harness). No production Rust changes.
Graceful shutdown and rollouts
--shutdown-grace-secs, rendered explicitly at the binary's defaultDEFAULT_SHUTDOWN_GRACE_SECS= 150 (crates/libs/api/src/run.rs). They also get a 5 s preStop sleep and a 180 s termination grace; the template refuses a grace that doesn't exceed preStop + shutdown grace. On SIGTERM the process reports draining on/readyzand stops accepting in the same instant, so the preStop delay is what takes the pod out of the entry Services first. The drain then waits for HTTP requests, SSE streams, the ingress-activation listener and the substrate work its TaskTracker holds (restore leaders, suspend continuations)./readyz. This is the boot budget the Pod reflector leaves to Kubernetes (pod_reflector.rs: a replica whose initial LIST never completes stays NotReady until restarted). It is 18 × 5 s, covering the 30 sSTORE_READY_TIMEOUTon the node caches plus the LIST.minReadySeconds: 10is applied to every Deployment in this list./readyzok, and axum's graceful shutdown waits with no deadline only for plain HTTP streaming responses: native session SSE and ECW process and file streams (run,crates/libs/data/plane/server/src/lib.rs). Upgraded connections do not drain. Service-port proxies and ingress, upgraded HTTP proxies and raw service sessions watch child tokens of the shutdown token and close at SIGTERM (proxy_upgraded_connection,bridge_raw_service_session). The other WebSocket session attaches run outside the server's connection tracking and end with the process. The data plane now gets the same 5 s preStop and a 180 s grace as the bound on the HTTP streams. Its startupProbe covers its wait for ClusterApi before it binds.Recreateto two replicas with RollingUpdate, a 5 s preStop and a 50 s grace. Raising the count is safe in code: all state is in Postgres under row locks and claim tokens (FOR UPDATE/SKIP LOCKED), every RPC is unary, nothing is a singleton or a background job, and its per-replica caches (template visibility, TokenReview) are bounded and documented as per replica. The grace is sized from the code:PublishNodeStateruns several database operations with no overall deadline). A call ends when its client times out: 30 s for every ClusterApi client (CLUSTER_API_RPC_TIMEOUTin the shared client,CLUSTER_RPC_TIMEOUTin node-ctl's).Disruption and priority
maxUnavailable: 1for space-api, space-admin-api, space-data-plane, space-cluster-controller, space-internal-api and space-credmgr-authority. All of them run on the infra node; the node-drain scenarios drain sandbox workers, which carry none of these pods.manifests/priorityclasses.yamlis applied bybootstrap.shand by Tilt (inlabel-kind-infra, which every workload naming a SPACE class waits on) before any workload, because admission rejects a pod whose class doesn't exist yet. It defines two classes:space-control-plane: the control-plane Deployments plus the in-cluster Postgres, object store, Redis and key broker.space-replaceable: the UI and the one-shot Jobs. It haspreemptionPolicy: Never.system-node-criticalon every node-service DaemonSet, through a newspace-node-service.priorityClasshelper.Backstop reconcilers (
space-cluster-controller-local/values.yaml; each setting cites its code). These now run as deployments run them:restoringrows are failed after 600 s. The other transient lifecycles are effectively off: suspending and checkpointing are set to0, and creating, starting, pausing, resuming and stopping, which have no per-lifecycle knob, inherit a one-year default age. Failed-sandbox cleanup stays on, and the unrestorable-restoring phase runs under the same switch.SPACE_RECONCILE_QUEUE_GC_*, the EC2 instance): stays off, rendered explicitly asfalse. A Kind bench must not call AWS (see the differences below).SPACE_GONE_NODE_QUEUE_GC_*): on, dry-run. These are the names the code uses, confirmed from env parsing and log lines.Postgres. On top of #485, this PR keeps #485's 8 GiB memory limit and its reasoning, and adds only the control-plane priority class to that chart. The second internal-api replica and its rollout surge are counted in #485's
max_connectionsbudget (1328 of 1400). The standalone round below ran onmainwith a 1Gi limit: there, the concurrent-restore scenario opened 79 client backends and brought Postgres within 4% of the old 512Mi, and one run had a backend OOM-killed.What stays different (README "Differences from the EKS deployment modules")
maxUnavailable: 1. It never holds a drain, and it rolls byRecreate, so a rollout leaves the admin surface down while the old pod drains.Ec2Detector) signsDescribeInstanceswith the controller'sAWS_REGIONand the object store's static keys to the public EC2 API.Gone(Ec2InstanceLookup::lookup_by_private_dns). Kind's refs would be kept only because that call fails (Unknown), not because there is no instance.pplx_space_aws_client::ec2) takes only the globalAWS_ENDPOINT_URL, which would also redirect the controller's S3 clients. Pointing it at the object store, or at a dead address, would be a fake EC2 endpoint, not an honest configuration.run_dead_prune), so it stays off too.Validation (one Devbox, CI's settings:
SPACE_KIND_TELEMETRY_MODE=otel,DATA_PLANE_URL=http://127.0.0.1:8090, admin token file and cold-receipts dir as inkind-ci.yml)This round validated
ac138f6(base0286c67, then the latestmain) on a fresh cluster. The pushed head6e022d7is the same patch rebased onto three moremaincommits (#489, #502, #500). They touch node-manager's btrfs send path and the docs site, none of the files this PR changes, andgit range-diffshows the patch unchanged.Every suite ran after the health gate (Tilt quiet, no ReplicaSet younger than 180 s, every node Ready and published, 120 s settle). Provenance on the live pods:
version=ac138f627c44.terminationGracePeriodSeconds: 50on the Deployment and on both pods, with a 5 s preStop. The controller hasSPACE_RECONCILE_QUEUE_GC_ENABLED=false.smoke-kind-e2e.shnative_recovery.*(native surface)local.concurrent_restore_converges_to_one_executionlocal.orphaned_restore_pod_gc_reaps_only_supersededlocal.forced_drain_evacuates_sandboxecw.command_round_trip(ECW)ecw.pause_connect(ECW and native)ecw.filesystem_round_trip(ECW)native.resident_pausenative_concurrency.lifecycle_contentionsmoke-kind-leader-election-brownout.sh(SPACE_SMOKE_LEADER_ELECTION=1)Every scenario passed on its first attempt, so no reruns were needed. The full typed suite was not rerun this round. The previous round's full-suite results (below) stand for the parts this round did not change: everything except internal-api's grace, the reconcile-queue GC switch, and comments.
internal-api rolled while node-ctl publishes.
kubectl rollout restart deploy/space-internal-apiran three times. node-ctl publishes on a 60 s tick, so two of the rolls were timed so that an old pod's terminating window covered a tick.Completed) in under a second.PublishNodeStatefor two nodes at 01:35:14.1, during its preStop, then took SIGTERM at 01:35:15 and exited 0.No call to public AWS (reconcile-queue GC off). Every leader start in the pods' logs says
reconcile-queue GC (ec2 detector) is disabled; skipping its AWS SDK initand starts only thekube_presencedetector. A monitor sampled conntrack on every Kind node every 10 s from the first suite to the end of the brownout (01:20–02:20). It recorded each destination contacted from a cluster-controller pod IP: 26 distinct destinations, all in-cluster (Postgres, the kube-apiserver, CoreDNS, the OTLP collectors, node and pod gRPC peers on 9800), and none outside the pod, Service and Kind node networks. The logs contain noEC2 lookup failedline and noamazonawshost.Observed, not change-induced: the four credential-manager DaemonSet pods restarted twice each during bring-up (01:14, before the first gate), on a DNS lookup for the authority before it resolved. Node services are documented to restart per pod until their peers are ready, and nothing restarted after the gate.
Previous round (full suite; stands for the unchanged parts)
Heads validated then:
1bd4173(baseline),ac39ba7,31c512fand809901e. On809901ethe results were: bring-up + e2e 569 s PASS, typedall78/78 ECW + 24/24 native, brownout 2253 s PASS. That round also showed::suspendand its operation SSE finished inside the drain, and the container exited 0 sixteen seconds after SIGTERM.disruptionsAllowed: 0.candidates_scanned: 0); this round turns it off.The one change-induced failure then was the Kind Postgres sizing, fixed in that round by a 1Gi limit and now covered by #485's 8 GiB. No product failure was exposed in either round.
Review follow-up
Restack onto #484 (content changed):
priorityClassNameto that chart, and its own 1Gi comment is gone. kind: run Postgres like the deployed database #485'smax_connectionsbudget comment now counts internal-api's second replica and its rollout surge (33 × 3 = 99), 1328 of 1400, so the setting is unchanged. The new reconcilers share the cluster-controller's two pools.space-clusterinstaller from Add cluster deployment overlays for existing SPACE charts #391 now checks thespace-control-planeandspace-replaceablePriorityClasses before installing (read-only, as it checks RuntimeClassspace), and its README asks formanifests/priorityclasses.yamlonce per cluster. The control plane and the migrate Job name those classes, and a pod naming a missing class is refused.Earlier rounds:
Gone; only a failed call keeps refs), and the differences table records the reason.priorityClassNameblocks in the object-store and Redis values now sit above the infra-node comments, which again describenodeSelector.native_recoveryrollout wait (comment added):ROLLOUT_TIMEOUTnow explains why 90 s is enough beside the 180 s grace: each restart holds no open stream.Rollback
Reverting the chart settings needs no schema rollback. Repairs the newly enabled reconcilers have already applied to sandbox state stay applied. admin-api still runs one replica with
Recreate, so its surface is briefly down during a rollout, as the README says.This change was written and validated with AI assistance (Claude Code); a person should review it before merge.
🤖 Generated with Claude Code