Repository navigation
How Levelrail keeps deploys safe: health gates and instant rollback #1246
thegdsks
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
A deploy platform is judged by its worst day, not its best one. Building, starting and serving is the easy part. The hard part is the new version that never listens on its port, gets OOM-killed ten seconds in, or starts fine and then answers 500 to one request in three.
Here is what Levelrail does between "a deploy was triggered" and "that exact content is serving", and what it does when something goes wrong. It all comes from the deploy safety docs and the code behind them. Every default is named with the setting that changes it.
The short version
Note
The one rule behind all of this: the previous release keeps serving until the new one has proven itself. Every check below is a way of enforcing that.
The cutover, step by step
Blue-green is the default strategy. Here is a healthy deploy:
sequenceDiagram participant CP as Control plane participant Old as Old container (blue) participant New as New container (green) participant Ing as Ingress Note over Old,Ing: Old release serves all traffic CP->>New: create and start loop readiness probe CP->>New: probe New-->>CP: pass or fail end Note over CP,New: Gate: N passes in a row,<br/>stable window, no exit, restart or OOM CP->>CP: digest check (running image vs pinned digest) CP->>Ing: cut traffic to the new release CP->>CP: pin new image as a rollback target Ing->>New: new requests CP->>Old: drain (in-flight requests finish) Note over Old: held for the previous-release window,<br/>instant rollback target loop post-deploy watch (default 5m) CP->>New: restarts, OOM, 5xx share end Note over CP: cleared, or automatic rollbackWith multiple replicas, each is gated on its own. In blue-green, nothing from the old release is retired until every new replica has passed its gate. If replica 0 passes and replica 1 is OOM-killed, the old release stays fully in place.
Readiness gate vs liveness
Readiness answers "should this container receive traffic yet?" and decides whether a deploy proceeds. Liveness answers "is this running container still alive?" and decides whether it gets restarted. The split is the standard pattern for zero-downtime deploys (general overview), but Levelrail asks for more than one 200.
A new container takes traffic only when all of these hold:
2APP_DEPLOY_SUCCESS_THRESHOLD10sAPP_DEPLOY_STABLE_WINDOWThe controller compares Docker's
RestartCount,StartedAt,OOMKilledand exit code against what it saw when the window began. Any exit counts, code 0 included, because a service that stops is not serving.Without a readiness probe, the gate falls back:
The readiness budget is 60 seconds by default, and
health.readyTimeoutinapp.yamloverrides it per service.Drain and the post-cutover watch
After cutover, the previous release keeps running for at least
APP_DEPLOY_DRAIN_TIMEOUT(default15s), measured from the cutover itself, so requests already in flight on it finish before it stops. Ingress moves new requests to the new release in the same reconcile pass.Then the watch starts. For
APP_DEPLOY_WATCH_WINDOW(default5m) the release is checked everyAPP_DEPLOY_WATCH_INTERVAL(default10s). It trips on:PostDeployOOMKilledAPP_DEPLOY_WATCH_MAX_RESTARTS=2PostDeployCrashloop,PostDeployExited0.2after20requestsPostDeployErrorRateThe 5xx share comes from existing ingress request metrics. The first sample after cutover is skipped because it can still hold answers from the previous release.
The previous release then stays held for
APP_DEPLOY_PREVIOUS_RELEASE_HOLD(default3m), and for the whole watch window with auto-rollback on. Only one previous release is ever held.When the new version never becomes ready
This is the case that matters most.
flowchart TD A[New container starts] --> B{Readiness gate} B -->|never passes, exits or OOM| C[Candidate stopped, logs kept] C --> D[Old release keeps serving] D --> E[Deploy attempt marked failed with a reason] E --> F[Next reconcile retries under restart backoff] F -->|keeps exiting| G[Ready False: CrashLoopBackOff, bounded delay] B -->|passes| H[Cutover] H --> I{Post-deploy watch} I -->|quiet for the window| J[Cleared] I -->|OOM, crashloop or 5xx share| K[Deploy failed, alert sent] K --> L{Auto-rollback on?} L -->|yes, default| M[Roll back to newest good pinned release, with its config] M --> N[Same readiness gate applies] L -->|no| O[Left for an operator]Step by step:
ReadinessFailed,OOMKilledDuringReadiness,ExitedDuringReadiness, or the stable-window variantsOOMKilledDuringStableWindow,ExitedDuringStableWindow,RestartedDuringStableWindowandStabilityGateTimeout. A container that dies mid-wait is noticed within a second, not at the probe's next interval, with a message such asContainer exited with code 0 after 2s, before it became ready.APP_RESTART_BACKOFF_BASE(default5s) and capped atAPP_RESTART_BACKOFF_MAX(default2m). While it waits, the app'sReadycondition isFalsewith reasonCrashLoopBackOffand a message likekeeps exiting (2 restarts), next restart in 9s. The delay resets once the container stays up forAPP_RESTART_BACKOFF_RESET(default10m).The 2 minute cap keeps the crashloop alert firing instead of resolving between restarts.
Important
Two limits. Apps with a pinned host port (
--host-port) cannot run two releases at once because the port can only be bound once, so for them a deploy is a short outage rather than a zero-downtime cutover. If the new release fails there, it is removed and the previous one restarts on the same port. And for remote nodes, restart counts are not carried over the agent transport yet, so the stable window detects exits and OOM kills but not a restart the container survived.What you see while it happens
Deploys are recorded as attempts with a status. The deployments list filters on
queued,held,awaiting_approval,building,ready,failed,canceled,rolled_backandsuperseded. Arolled_backrow is a succeeded deploy that a later rollback replaced.Each deploy also records its gate as a timeline (readiness, stable window, cutover, drain, rollback pin, watch, rollback), every step with a status, reason and message. The deploy detail page shows it on the Roll out tab and refreshes until the release clears its watch, fails or is replaced.
From the CLI:
deploy statusexits7when the release failed its gate, tripped its watch or was rolled back, so scripts can branch on it.Strategies and canary
Three strategies, set per app in Deploy settings or
app.yaml:blue-green(default)rollingrecreateA canary is the more cautious path. It runs a new image next to the current release and sends it a share of requests:
The canary docs cover the details:
Limits: one canary per app, image apps only, single replica, no pinned host port. The canary writes to the same database as the stable release, so keep schema changes compatible with both versions.
Rollbacks: automatic, by digest, instant
There are three rollback paths, and all of them are ordinary deploys that pass the same readiness gate.
Automatic. When the watch trips, the deploy attempt is marked failed with the reason, an alert goes out through the app's deploy notification channels, and with
APP_DEPLOY_AUTO_ROLLBACK=true(the default) the app returns to the newest pinned release still marked succeeded, with that release's config. Two guards prevent loops: a release the watch already tripped on is failed, so rollbacks never ping-pong, and a release that is itself an automatic rollback is never rolled back again. That points at something outside the app, so it is left for you.By digest. Every image deploy resolves its tag to a content digest at deploy time and pins the desired image to it, for example
nginx:latest@sha256:.... A floating tag likelatestnames different content over time, so a platform that compares tags can report success while the old image keeps running. Because the pinned reference is part of the container's identity, new content always gets a new container. The controller also checks that the running image ID matches what the pinned reference resolves to; if not, the app reportsReady: FalsewithImageDigestMismatchand the previous release keeps serving.rollback-toredeploys exactly what that past deploy ran, by digest or by its rollback pin, and restores that deploy's literal env values, port, health checks, replicas, strategy, resources and labels. Secret, database and vault references keep their current values, and domains, volumes and host ports stay as they are now.Instant. For
APP_DEPLOY_PREVIOUS_RELEASE_HOLD(default3m) after a cutover, the previous release's containers are still running, so rolling back to it needs no pull and no cold start. Beyond that, pins take over: every release that took traffic gets an extra local tag, and the newestAPP_DEPLOY_ROLLBACK_PINS(default3) per app are kept. A tagged image is never dangling, so image cleanup cannot remove a rollback target, even after a rebuild moved the original tag.Warning
"Tag now points at different content." If you roll back to a deploy whose image tag has since been re-pushed with other content,
rollback-torefuses with a 409:tag <image> now points at different content than this deploy recorded; redeploy from source instead. The refusal is deliberate: a rollback should restore a known state, and deploying a moved tag would run code you never tested while calling it a rollback. A 410 means the local image was garbage collected (raiseAPP_DEPLOY_ROLLBACK_PINSto keep more); redeploy from source then too.Deploy history records how the digest was resolved (
Resolved,AlreadyPinned,LocalBuild,PullFailedUsingCachedorUnresolved). Pass--pullto refuse the cached fallback.Hooks, freezes and approvals
Deploy hooks. In
app.yaml, a service can declarehooks, run inside its own container through the Docker Engine API exec facility:preDeployruns once per deploy, before the new container's readiness probe and before any old container is removed. A nonzero exit blocks the deploy: the new container is rolled back and whatever was running keeps serving.postDeployruns after the full replica set has cut over. A nonzero exit is reported as aPostDeployHookFailedcondition but never rolls back a successful cutover.staticorcomposebuilds.levelrail-cli apps hook-runs webshows the latest outcome.Freeze windows. A window starts at every match of a 5 field cron expression, lasts a duration, and can be set per app or globally. Pushes, release webhooks and pipeline steps are held and the newest one replays when the window ends. Manual deploys return 423 unless they carry a recorded override reason.
Approvals. Deploying into a protected environment needs a second person: the request becomes a pending approval (202), someone else with the
deployability approves it, and unanswered requests expire after 24 hours. Approval runs the normal path, so the readiness gate still applies.What we test
Safety claims are cheap, so here is the evidence and where it stops.
test/e2e/reconcile/deploy_safety_live_test.goruns the real application and ingress controllers against Docker while four clients hammer the ingress. It deploys a good release, one that exits inside its stable window, and one that answers 5xx until the watch rolls it back. The fixture drops in-flight requests onSIGTERM, so only a real drain keeps them alive. One run on Docker Desktop: 2617 requests, 0 failed (the bad release's own 500s excluded). With the drain turned off, the same test loses requests at cutover.internal/docker/prune_images_live_test.gochecks against a real daemon that a pinned tag and an untagged image backing a running container survive cleanup, and only a truly orphaned image is removed.Note
These are results from specific runs on particular hosts with particular fixtures, not a guarantee. Your probe quality, memory limits and database migrations still shape your outcome.
Troubleshooting
ReadinessFailed, exit or OOM during the window)Ready: False,CrashLoopBackOfflevelrail-cli apps status web, then check logs; the delay is capped at2mPostDeployOOMKilled,PostDeployCrashloop,PostDeployErrorRate)rollback-toreturns 409Failure codes are listed in Deploy failures, and
levelrail-cli apps diagnose webexplains a failed deploy or crashloop.Try it
Break a rollout on purpose in a throwaway app before you trust it:
levelrail-cli apps deploys rollback-toagainst an earlier deployDocs: Deploy safety, Canary deploys. Code: github.com/glincker/levelrail.
If a deploy misbehaves in a way this post does not explain, open an issue with the gate timeline attached. We want to find that bug before you do.
All reactions