This document is the operational contract for the hardened native Kubernetes
RollingUpdate of the pusher GKE workload. It is deliberately not a design
essay: it states what operators and developers can rely on, the exact termination
sequence the chart produces, the preflight gates that must pass before a rollout
is allowed, and the N/N-1 compatibility rules.
Scope and ownership:
- The chart values are the single source of truth for rollout inputs
(
backend/charts/pusher/{dev,prod}_omi_pusher_values.yaml). When the live cluster disagrees with the chart, the chart wins and the gap is a deploy problem to close, never a reason to weaken the chart. - This hardening is native
RollingUpdateonly. No Argo Rollouts, Flagger, service mesh, Gateway API, or two-color experiment is in scope. - Production adoption is a LATER, explicit decision. This PR lands the hardening and the operational contract on top of the existing pusher baseline. It is qualified dev-first (see Dev qualification plan). Do not read this document as "pusher is production-ready for the new rollout" until SCA-40 lands and a healthy prod pusher baseline is confirmed.
Related runbook:
backend/docs/runbooks/pusher-degraded.mdcovers sustained degraded-session ratio. This document covers the rollout itself.
State this plainly, because it governs every operator decision below: no approach preserves in-flight WebSocket sessions across a pusher cutover over the proxied ALB + NEG path. Pusher holds long-lived binary WebSocket sessions from backend-listen. When a pod's endpoint leaves the NEG, the load balancer cuts the existing connections on it.
What "graceful drain" actually means here:
- Zero new-connection rejection during a healthy rollout. The readiness gate
flips a draining pod to
503so the LB stops routing new sessions to it while other pods are still serving. New backend-listen connects go to healthy pods. - A bounded reconnect gap per affected session, roughly 1-60 seconds. backend-listen owns the reconnect: when its pusher socket is cut it reconnects to a healthy pod. The gap is the time between the cut and the next successful reconnect, not a migration.
- The LB cut is bounded by BackendConfig
connectionDraining. It sets how long the load balancer waits before hard-cutting in-flight connections on an endpoint that has left the NEG — heredrainingTimeoutSec: 60. Without it the default is a hard cut on endpoint removal. - Background finalization is bounded by the pod grace period. In-flight work
(audio batched to GCS, transcripts routed to integrations, LLM analysis,
speaker-sample extraction) is drained by the app within
terminationGracePeriodSeconds; anything still running at the deadline is SIGKILLed. Durable finalization is reconciled by the conversation-finalization job lane, so a SIGKILLed session does not strand a conversation inprocessing.
Do not claim sub-second or zero-session-impact cutover. The contract is: new connections keep flowing, existing sessions take a bounded reconnect gap, and background work finishes or is durably reconciled.
The chart previously sent liveness, readiness, and startup probes all to
/health, had no app-level drain (preStop was only sleep 15), and the
BackendConfig had no connectionDraining (so the LB hard-cut in-flight WebSocket
on endpoint removal). The hardening splits serving readiness from liveness and
adds an app-level drain:
| Concern | Before | After (hardened) |
|---|---|---|
| Readiness signal | readinessProbe -> /health |
readinessProbe -> /ready (200 serving / 503 draining) |
| Liveness / startup | /health |
unchanged, still /health |
| LB health check | BackendConfig healthCheck.requestPath: /health |
unchanged, still /health |
| New-connection drain | none; preStop = sleep 15 |
preStop: curl -sf -m 5 -X POST http://localhost:8080/__internal/drain || true; sleep 15 — POST flips /ready to 503 (loopback), || true never blocks termination, sleep 15 covers NEG convergence |
| LB in-flight cut | none; connectionDraining absent (hard cut on endpoint removal) |
BackendConfig connectionDraining.drainingTimeoutSec: 60 bounds the LB wait (60 <= grace 120) |
Endpoints introduced by this hardening (on the pusher process, port 8080):
GET /ready->200while serving,503once the process is draining.POST /__internal/drain-> loopback only. Triggers the drain: flips/readyto503and lets background finalization run down within the grace period.preStopis the only intended caller.
Rollout inputs that are already correct in the chart and are reused, not
rebuilt (verified against {dev,prod}_omi_pusher_values.yaml):
progressDeadlineSeconds: 9600in both charts. CI derives the healthy rollout budget from the chart (backend/scripts/verify_pusher_rollout_budget.py); prod at its 40-pod HPA ceiling is fourteen waves of ~640s availability = an 8960s healthy budget, with 9600s as the committed deadline. The observed live value of 600 on an existing release is a deploy drift gap, not a chart gap — the chart value stays at 9600 and a real deploy must reconcile the cluster to it.- prod
strategy: RollingUpdate { maxUnavailable: 1, maxSurge: 2 }(dev is{ maxUnavailable: 0, maxSurge: 1 }). terminationGracePeriodSeconds: 120in both charts.- prod autoscaling
minReplicas: 12,maxReplicas: 40,activeConnectionsPerPod: 30; devminReplicas: 1,maxReplicas: 3. podDisruptionBudget.minAvailable: 80%in both charts.
Development Pusher alone tolerates dedicated=pusher:NoSchedule. Its existing
required node affinity still requires matching service=pusher and env=dev
labels, so that toleration does not admit LLM Gateway, Agent Proxy, or other
workloads to the pool. Production deliberately retains no corresponding
toleration; it is a separate configuration boundary.
Before a dev Pusher rollout, an external approved GKE operation must provide
two Ready, schedulable Pusher-capable nodes matching that affinity, each
tainted dedicated=pusher:NoSchedule. Two matching nodes leave room for the
single serving pod and its maxSurge: 1 replacement during a rolling update.
Helm only renders the workload toleration: it does not create, label, taint, or
scale GKE node capacity. Dev HPA remains minReplicas: 1, maxReplicas: 3,
and its rolling-update settings remain maxSurge: 1, maxUnavailable: 0.
Fail-closed rollout gates (preflight scripts that must pass before a deploy) are listed in Operator runbook and the blocking signals in Rollout quality gates (fail-closed).
For a single pod being replaced during a RollingUpdate, the chart produces this
sequence (accurate to templates/deployment.yaml and the values files):
- Pod enters
Terminating. Kubernetes starts the pod'sterminationGracePeriodSeconds: 120clock. preStophook runs in parallel with endpoint removal. It runscurl -sf -m 5 -X POST http://localhost:8080/__internal/drain || true; sleep 15— the POST (loopback) flips/readyto503,|| trueguarantees the hook never blocks termination if the drain call fails, andsleep 15gives the NEG time to converge before SIGTERM.- Readiness starts failing; the endpoint leaves the NEG. The readiness probe
is
failureThreshold: 3xperiodSeconds: 10, so a draining pod is removed from the Service/NEG endpoints roughly 30 seconds after/readygoes 503. From this point the LB sends no new connections to the pod. SIGTERMis delivered (afterpreStopcompletes; thesleep 15bounds how long that takes). The pusher lifespan shutdown drains process-level background tasks (drain_background_tasks) and closes async HTTP client pools.- Background finalization drains within the grace period. Each active
WebSocket session drains its background task queue (bounded by
BG_DRAIN_TIMEOUTin the WS handler); audio/transcript/analysis work that can finish inside the remaining grace does so. SIGKILLat 120s. Anything still running at the grace deadline is killed. A SIGKILLed session does not strand a conversation: durable finalization is reconciled by the conversation-finalization job lane.
Key timing knobs and what they bound:
preStopsleep 15— NEG convergence window before SIGTERM.- readiness
failureThreshold * periodSeconds(~30s) — how long after/readyflips 503 before the endpoint is actually removed from the NEG. - BackendConfig
connectionDraining.drainingTimeoutSec: 60— how long the LB waits before hard-cutting the in-flight connections on the removed endpoint (deliberately <= the 120s pod grace). terminationGracePeriodSeconds: 120— the hard ceiling on background finalization before SIGKILL.
Small, hard-to-misuse steps. Run from the repository root of a checkout that matches the image you intend to deploy.
Render dev and prod and eyeball the values that drive availability:
helm template pusher backend/charts/pusher \
-f backend/charts/pusher/dev_omi_pusher_values.yaml \
--set-string image.digest=sha256:<64-lowercase-hex> \
--set-string image.tag= \
--set-string image.pullPolicy=IfNotPresent > /tmp/pusher-dev.yaml
helm template pusher backend/charts/pusher \
-f backend/charts/pusher/prod_omi_pusher_values.yaml \
--set-string image.digest=sha256:<64-lowercase-hex> \
--set-string image.tag= \
--set-string image.pullPolicy=IfNotPresent > /tmp/pusher-prod.yamlConfirm in the rendered output: readinessProbe.httpGet.path: /ready,
livenessProbe/startupProbe on /health, the BackendConfig healthCheck on
/health plus connectionDraining, preStop calling /__internal/drain, and
progressDeadlineSeconds: 9600. Confirm the dev Deployment has the
dedicated=pusher:NoSchedule toleration and the prod Deployment does not.
The chart budget and the rollout gate must both pass. Both are dependency-free static/contract checks (stdlib only, no cluster or registry reads):
# Derives the healthy rollout budget from the chart (waves x availability) and
# fails if progressDeadlineSeconds or the workflow rollout timeout undercut it.
python3 backend/scripts/verify_pusher_rollout_budget.py
# Static + contract preflight: capacity headroom, image/config identity, probe
# split, and that the rollout-blocking metrics are DEFINED in utils/metrics.py.
python3 backend/scripts/verify_pusher_rollout_gate.py preflightverify_pusher_rollout_budget.py recomputes prod as fourteen waves x ~640s and
fails if progressDeadlineSeconds drops below the budget or a workflow's
kubectl rollout status ... --timeout= is too short.
verify_pusher_rollout_gate.py preflight (the default subcommand) fails closed
(non-zero exit) on any open gate. It checks, statically: capacity headroom
(podDisruptionBudget.minAvailable, HPA minReplicas, maxSurge/
maxUnavailable, and terminationGracePeriodSeconds >= the connectionDraining
timeout); image and config identity; the probe split
(readinessProbe -> /ready, liveness/startup -> /health,
connectionDraining present); and that the rollout-blocking metrics are defined
in utils/metrics.py — a missing metric definition is a failure, because a
rollout cannot be judged healthy against telemetry that does not exist. It does
not scrape live values; use ... rollback --env <env> for the rollback-mode
contract check. Do not weaken the chart to make a check pass; fix the input.
The automatic development Pusher workflow builds once, resolves the pushed
sha256 digest, runs the ConfigMap reference and live capacity gates, waits for
the rollout, and only then uploads pusher-dev-qualification. Production remains
manual: it requires that digest plus the successful development run ID. The
production workflow downloads the attestation, verifies its exact GitHub run and
main source SHA, rejects Pusher source/chart drift since that bake, copies the
same digest into the production registry, and deploys repository@sha256:....
It never rebuilds a production Pusher image.
Immediately before Helm, verify_pusher_live_deployment_gate.py renders that
exact digest and reads only Kubernetes metadata. It fails closed unless the next
maxSurge wave fits on a Ready, schedulable node matching Pusher's affinity and
tolerations. This is capacity evidence, not a claim that the rollout has product
traffic or user-success proof.
The dev Pusher dashboard is backed by the isolated dev Prometheus scrape. It shows existing connection, readiness/drain, reconnect/recovery and backend-listen circuit-breaker aggregates, plus target health, without creating traffic. Pod health alone is not product-success evidence.
The development Prometheus jobs discover only dev-omi-backend pods carrying
the env=dev workload label. They retain the existing bearer-token file
reference; do not fetch /metrics directly, inspect the token, copy headers, or
weaken authentication. The dashboard contains only low-cardinality aggregates
and target labels (job, namespace, and pod), never connection IDs, user
data, or request payloads.
For a passive development-bake check in the permitted read-only operator plane, use the Pusher dashboard or these equivalent PromQL expressions:
- active Pusher WebSockets:
sum(pusher_active_ws_connections{job="pusher-metrics"}) - sessions currently reconnecting or degraded:
sum(pusher_sessions_degraded{job="backend-listen-metrics"}) - reconnect circuit state and rejected attempts:
pusher_circuit_breaker_state{job="backend-listen-metrics"}andsum(rate(pusher_circuit_breaker_rejections_total{job="backend-listen-metrics"}[5m])) - authenticated scrape target health:
up{job=~"pusher-metrics|backend-listen-metrics"}
up == 1 proves only that Prometheus authenticated and scraped a target; it is
not connection, drain, or recovery proof. A recovery aggregate can move only
when a real client reconnects. If no real connection exists during the passive
window, record reconnect/recovery as unproven—do not generate synthetic
traffic to turn the signal non-zero.
The 2026-07-22 production Pusher outage was a non-atomic cross-resource
migration: the shared key REDIS_DB_HOST stopped materializing in its source
while live pusher pods still referenced it. Pusher bulk-loads all of its
ConfigMap keys via envFrom, so a key removed from that object left pods
CreateContainerConfigError while /health stayed green — the fleet dropped to
zero healthy pods with no probe signal. verify_shared_config_migration.py
fails CLOSED before any mutation when a serving workload still references a
ConfigMap/Secret source or key the proposed state removes or reclassifies.
-
Preflight (CI / pre-push, stdlib-only).
python3 backend/scripts/verify_shared_config_migration.py preflightscans the pusher chart values and fails on a dual-source binding or a malformedvalueFrom. Registered asshared-config-migration-preflightin.github/checks-manifest.yaml; it runs automatically. It has no inventory, so it cannot detect a key removed from a bulk-loaded object. -
Guard mode (operator-invoked — the actual incident root-fix). Run this before any change that adds, removes, or moves a ConfigMap/Secret key consumed by pusher or any serving workload (Deployment/StatefulSet/DaemonSet/ Job/CronJob/ReplicaSet, incl.
initContainers). It resolves everyenv.valueFrom,envFrom, and per-key reference against the proposed source inventory and fails closed on a removed/reclassified key. It reads object and key NAMES only — never ConfigMap/Secret values.# Render the proposed state (--rendered is repeatable across envs/workloads): helm template pusher backend/charts/pusher \ -f backend/charts/pusher/prod_omi_pusher_values.yaml \ --set-string image.digest=sha256:<64-lowercase-hex> \ --set-string image.tag= \ --set-string image.pullPolicy=IfNotPresent > /tmp/pusher-prod.yaml # Capture the CURRENT live key names in the guard's inventory format # ({configmaps: {<obj>: [key,...]}, secrets: {...}} — names only, never values): kubectl -n <env>-omi-backend get configmap <cfg> -o json \ | jq '{configmaps: {(.metadata.name): (.data | keys)}}' > /tmp/live-keys.yaml # Write /tmp/proposed-keys.yaml in the same shape with the keys the object will # carry AFTER the change, then run the guard. --previous-inventory is what # catches a key removed from an envFrom bulk-loaded object (the outage class); # without it, envFrom removals are silent. python3 backend/scripts/verify_shared_config_migration.py guard \ --rendered /tmp/pusher-prod.yaml \ --source-inventory /tmp/proposed-keys.yaml \ --previous-inventory /tmp/live-keys.yaml
Non-zero exit = a serving workload still references a source/key the proposed state removes or reclassifies; do not apply. Needs PyYAML (the backend venv, or
pip install pyyaml).
Deploy the exact short-SHA image tag. Never deploy latest, and never let a
chart-only deploy reset the workload to latest (per .github/AGENTS.md). The
deploy workflow must wait for rollout completion with
kubectl rollout status deploy/<env>-omi-pusher --timeout=... and fail on
timeout.
Roll back by re-running the Helm deploy against the prior immutable image tag
(not latest). Because traffic/runtime rollback to N-1 is always safe by design
(see N/N-1 compatibility checklist), this is the
default rollback: point the chart at the previous tag and deploy it.
Production adoption is a separate, later decision. Do not treat prod rollback of this hardening as available until SCA-40 (digest chart support, chart-only/reuse-image deploy mode, and rollback evidence) has landed and a healthy pusher prod baseline is confirmed. See Relationship to SCA-40.
There are two fail-closed layers, and both must hold:
- Static/contract preflight (run before a deploy, see
Operator runbook) —
verify_pusher_rollout_gate.py preflightasserts the capacity, identity, probe-split, and metric-definition contract. It fails if a blocking metric is not even defined, because a rollout cannot be judged healthy against telemetry that does not exist. It does not scrape live values. - Live rollout gate (watched during the deploy) — the blocking signals must be green and sufficiently populated. The rule that overrides everything else: missing telemetry = pause or fail, never green. A silent or unscrapable dashboard is a failed gate, not a passed one.
Blocking signals (real metric names emitted by pusher / backend-listen):
pusher_active_ws_connections— active pusher WebSocket sessions. The new pods must accept sessions and the count must recover to the pre-rollout baseline; a flatline means new pods are not accepting traffic.backend_listen_active_ws_connections— backend-listen side of the path; a sustained drop with no recovery indicates listeners are failing to reconnect.pusher_circuit_breaker_state(0 closed / 1 open / 2 half_open) andpusher_circuit_breaker_rejections_total— an opening breaker or rising rejections during a rollout means pusher is rejecting connections.pusher_sessions_degraded— sessions backend-listen routed away from pusher. Sustained elevation (seebackend/docs/runbooks/pusher-degraded.md) is a user-impact signal.omi_journey_terminal_total{journey="pusher_session"}outcomes — terminal session outcomes; risingfailure(close code 1011 / application failure) is a regression.omi_journey_latency_seconds{journey="pusher_session"}— end-to-end session latency; a rollout must not push this outside its bounded threshold.- finalization health:
listen_finalization_jobs,listen_finalization_retries_total,listen_finalization_dead_letter_total, andlisten_finalization_oldest_nonterminal_age_seconds. A rollout that spikes retries, dead-letters, or oldest-nonterminal age is failing to drain cleanly.
The gate requires minimum capability/session counts plus bounded error and latency thresholds, not only elapsed time. Time-only gates are forbidden: a rollout that is merely "old enough" but has not served enough sessions, or is over its error/latency bounds, is not green.
Separate two kinds of rollback:
- Traffic / runtime rollback to N-1 is always safe by design. Re-pointing the chart at the previous immutable tag and redeploying never requires a data migration to undo. This is the only rollback an operator should ever need to perform under pressure.
- Irreversible data changes are a different category and must never be required by a rollout's rollback path. If a change is irreversible, the design obligation is to make N and N-1 coexist (additive, dual-read/dual-write, or feature-gated) so traffic can return to N-1 without a data rollback.
Full N-1 drain first is required before introducing any of the following incompatibilities (these are the changes that break a safe traffic rollback to N-1 and therefore cannot ride on a routine rolling update):
- Envelope / key change:
ENCRYPTION_SECRETor the per-user envelope derivation (HKDF-SHA256 inutils/encryption.py). N-1 cannot decrypt N-written segments. - GCS audio-chunk envelope: a change to how pusher batches/uploads audio chunks to GCS that N-1 cannot read back.
- New
ConversationStatus(or any persisted processing-state enum) that N-1 does not recognize and would mishandle. - Removed or renamed read fields on persisted models that released app-clients or N-1 services still read.
- Redis key or TTL change that N-1 interprets differently (cache, rate-limit buckets, listen locks).
- Finalization lease / fence protocol change: the durable conversation-
finalization lease and fence protocol (the
durable_job_required/ claim / lease-epoch contract pusher enforces) — a change here can let an N-1 session double-process or bypass a durable claim.
If a change touches any of the above, it is not a candidate for this hardened rolling update without a documented N/N-1 coexistence plan and, where relevant, a full N-1 drain. Routine pushes (probe/drain/availability hardening, non-breaking additive fields) ride the rolling update and roll back to N-1 by re-pointing the chart.
This hardening is qualified dev-first. Dev is the place to prove the choreography (readiness flip, app drain, NEG convergence, reconnect gap, finalization drain) end to end before any prod consideration.
How to qualify on dev (pusher dev: 1-3 pods, maxUnavailable: 0 / maxSurge: 1):
- Build once and render the digest exactly as in the Operator runbook. The automatic workflow records its digest only after the development rollout succeeds.
- Observe the passive bake. Use the dev Pusher dashboard's connection, readiness/drain, and reconnect-circuit panels. Do not manufacture traffic or infer product success only from readiness.
- Use the resulting attestation for a manual production request. Supply the exact digest and development qualification run ID; the production workflow validates the evidence, live ConfigMap references, exact digest render, and real next-wave capacity before Helm mutation.
- Record the evidence (commands, output, gate result) in the PR, per the
root
AGENTS.mdDefinition of Done.
Acknowledge the limit of dev qualification explicitly: dev proves the choreography, not prod-scale NEG/ILB behavior. Dev runs 1-3 pods with low WebSocket traffic, so it exercises the readiness/drain/reconnect sequence but does not prove prod-scale NEG propagation, the prod 40-pod / fourteen-wave rollout, or prod ILB connection draining. Those require the separate, later prod proof gated on SCA-40 and a healthy pusher prod baseline.
SCA-115 completes the bounded follow-up to the native RollingUpdate hardening: digest identity is selected from a completed dev rollout, promotion preserves that exact manifest digest, and the production workflow gates the live cluster's next surge capacity before changing the Pusher release. The existing rollback contract remains a separate operator action; this workflow does not auto-roll back traffic or data.