Filter concepts by levelShowing all levels.

Django · Section 96

Deployment Strategies

Level
advanced
Read
36 min
Concepts
4

The three strategies differ in exactly one variable — what share of traffic the new version receives over time — and their costs all follow from that curve. Rolling ramps as instances are replaced, needs no extra capacity, and keeps both versions live throughout, so a bad release reaches a growing share of users and the undo is another full roll. Blue/green steps: a second fleet is built, warmed and smoke-tested with no traffic, then everything switches at once, which costs double capacity for the window and buys the fastest rollback there is. A canary holds a small share while you watch, and it is the only one that produces evidence from real traffic before full exposure — provided your metrics carry the version, because five per cent of the errors otherwise vanishes into normal variation. What matters more than the choice is what all three share: two versions of your code in front of one database, one cache and one queue. The schema in effect during a deploy has to satisfy both, which is expand-and-contract rather than a strategy setting — and a canary that drops a column is the worst case, because it breaks the ninety-five per cent that did not receive the new code. Sessions, cache values and queued task payloads need the same treatment; renaming a Celery task strands every message already queued. Feature flags attack the same risk from the other side by separating the deploy from the release. Code ships dark, so the deploy proves only that nothing broke; the behaviour is enabled afterwards for staff, then a percentage, then everyone, and turning it off takes effect at the next request rather than at the next pipeline run. The cost is a branch in the code, so every flag needs an owner and a removal date, evaluation from cache rather than a query per check, a defined value for when the flag store is unreachable, and stable bucketing so a user in the five per cent does not see the feature flicker. Note that a "permission flag" is not a flag at all — it is authorisation, and it belongs in the permission model. Then the mechanics of replacing an instance without dropping a request, which is an ordering problem: fail readiness first while still serving, wait at least one load-balancer check interval so it stops routing to you, then `SIGTERM`, then drain, then exit — with the platform's grace period longer than the sum, or it sends the kill and the drain never completes. Readiness may check dependencies and should be cached; liveness must not touch the database, or a five-second blip restarts the fleet. Afterwards the smoke test is the first evidence this artefact works in *this* environment, with real credentials, real static files and real DNS — so it should assert content rather than status codes, and its failure must trigger the rollback or it is only a notification. Finally, rollback planning, and the sentence the section closes on: migrations make application rollback harder. A release changes two things with different reversibility. Code is a redeploy away. A destructive migration makes the previous version unable to start; a data transform cannot be undone by reversing the migration, because the reverse restores the shape and not the values; and emitted side effects — emails, payments, published events — need compensating actions rather than an undo. So the plan is written before the deploy, names the digest, the trigger and what going back will not fix, and carries an expiry: the release after which rollback is no longer possible.

What is true here

  1. Traffic share over time is the only real difference between the three strategies.
  2. Two versions, one database: the schema, sessions, cache and queue must fit both.
  3. A flag separates deploy from release, and undoes in one request.
  4. Announce unavailability before becoming unavailable, and nest the grace periods.
  5. Write the rollback plan first, including what it cannot undo and when it expires.

What you will be able to do

  • Choose a strategy from the risk of the change rather than from habit
  • Ship a schema change that leaves the previous version runnable
  • Configure a drain that loses no requests, and a smoke test that would fail
  • Write a rollback plan someone can execute at 02:00 without asking a question
One release, from a chosen strategy to a rollback that works — or does not
passedfailed, and itwas flaggedfailed, schemaadditivefailed, schemadestructiveknown in advance — sayso in the release notes

Classify the change

additive schema · destructive schema · data transform · side effects

Behind a flag?

ship dark, and the release becomes reversible in one request

Pick the traffic curve

rolling ramp · blue/green step · canary hold

Expand-only migration first

nullable, defaulted, unused by the previous version

Drain each instance

readiness fails → wait one interval → TERM → finish → exit

Smoke test the environment

assert content, not status — this is the release gate

Release stands

contract in a later release, once nothing needs the old shape

Turn the flag off

next request, no deploy, no schema involved

Redeploy previous digest

works because the migration was additive

Forward fix only

destructive migration, transformed data, or effects already emitted

  • Classify the change — additive schema · destructive schema · data transform · side effects
    • leads to Behind a flag?
    • on error, leads to Forward fix only (known in advance — say so in the release notes)
  • Behind a flag? — ship dark, and the release becomes reversible in one request
    • leads to Pick the traffic curve
  • Pick the traffic curve — rolling ramp · blue/green step · canary hold
    • leads to Expand-only migration first
  • Expand-only migration first — nullable, defaulted, unused by the previous version
    • leads to Drain each instance
  • Drain each instance — readiness fails → wait one interval → TERM → finish → exit
    • leads to Smoke test the environment
  • Smoke test the environment — assert content, not status — this is the release gate
    • leads to Release stands (passed)
    • leads to Turn the flag off (failed, and it was flagged)
    • on error, leads to Redeploy previous digest (failed, schema additive)
    • on error, leads to Forward fix only (failed, schema destructive)
  • Release stands — contract in a later release, once nothing needs the old shape
  • Turn the flag off — next request, no deploy, no schema involved
  • Redeploy previous digest — works because the migration was additive
  • Forward fix only — destructive migration, transformed data, or effects already emitted

The three strategies

One variable, three curves — and the database all of them share.

Rolling, blue/green and canary

coreadvanced

A **rolling** deploy replaces instances a few at a time, so both versions serve traffic while it runs. **Blue/green** brings up a whole second fleet, tests it, then switches all traffic at once — and switches back just as fast. A **canary** sends a small share of real traffic to the new version, watches the error rate, and only then continues. All three run two versions of your code against **one** database.

Think of it as

The three differ in one variable — what fraction of traffic the new version receives over time — and everything else follows from that. Rolling ramps: 25%, 50%, 75%, 100% as instances are replaced, using no extra capacity because each new instance takes a retired one's place. That makes it the cheap default, and its cost is that both versions are live throughout, so a bug is exposed to a growing share of users and the rollback is another full roll. Blue/green steps: the green fleet is built and warmed with no traffic, smoke-tested for real, then the load balancer switches everything at once. You pay for double capacity during the switch and you gain the fastest possible rollback — flip back to blue, which is still running and still warm. Canary holds: a small slice, maybe 5%, goes to the new version while you watch error rate and latency for that slice specifically, and only when it looks right do you continue. It is the only one of the three that gives you evidence from production before full exposure, and it costs the machinery to route by share and to compare metrics *per version*, which is worth naming because a canary without per-version metrics is just a slow rolling deploy. What unites them, and matters more in Django than the choice itself, is that they all put two versions of your application in front of one database. Nothing about the strategy changes that: blue and green share the schema, the canary shares it with the stable fleet, and a rolling deploy has both versions writing rows for minutes. So the schema in effect during a deploy has to satisfy the old code and the new code simultaneously, which is exactly the expand-and-contract discipline — add the column, deploy code that writes both shapes, switch reads, and remove the old shape in a later release. Get that wrong and the strategy makes it worse rather than better: a canary that drops a column takes down the 95% that did not receive the new code. Two smaller consequences are worth holding. Sessions and caches are shared too, so a change to what you store in a session or under a cache key has to be readable by both versions or the deploy shows up as random logouts. And background workers are a third fleet: if a task signature changes, the old workers are still consuming messages the new code produced, so tasks need the same backward-compatibility treatment as the schema.

bash
./deploy.sh --strategy canary --share 5 --watch-minutes 15

What we're doing: Ship a change that adds a column and a new read path, using a canary, without ever putting the database in a state one of the two live versions cannot use.

release plan: v1.14 (canary), v1.15 (contract)text
RELEASE v1.14 — "orders get a fulfilment_channel"

1. MIGRATE (expand only)
   - add orders.fulfilment_channel, NULL allowed, with a default
   - no drops, no renames, no new NOT NULL on an existing column
   -> v1.13 keeps working: it never mentions the column

2. DEPLOY CANARY, 5% of traffic
   - v1.14 writes fulfilment_channel on every new order
   - v1.14 still READS the old derivation, so both versions agree
   - watch, per version, for 15 minutes:
       error rate       v1.14 vs v1.13
       p95 latency      v1.14 vs v1.13
       orders created   v1.14 share ~= traffic share
   -> if any diverges: drain the canary. The schema is unchanged,
      so there is nothing to undo.

3. PROMOTE to 100%
   - only after the canary window is clean
   - both versions still run for the length of the roll
   - queued tasks: v1.14 adds an OPTIONAL task argument, so a v1.13
     worker consuming a v1.14 message still accepts it

4. BACKFILL, out of band
   - a management command fills fulfilment_channel for old rows
   - batched and resumable; it is not part of the deploy

RELEASE v1.15 — "read the new column"
   - switch reads to fulfilment_channel
   - v1.14 is still rollback-safe: it wrote the column all along

RELEASE v1.16 — "contract"
   - NOW drop the old derivation and its code path
   - from here, rolling back to v1.13 is no longer possible,
     which is stated in the release notes rather than discovered
3–6
The migration is deliberately additive and goes out *before* the code that uses it. A nullable column with a default is invisible to the version that does not know about it.
8–10
The canary writes the new column but still reads the old source, so the two live versions cannot disagree about what an order means while both are serving.
11–15
Metrics compared *per version*. Without that split the canary's errors are five per cent of a number that looks unchanged, which is why a canary without per-version metrics is just a slow roll.
21–22
The queue is the fleet people forget. Adding an optional argument keeps old workers able to consume new messages; renaming the task would strand every message already queued.
24–26
The backfill is out of band because it is long, batched and resumable. Putting it in the deploy makes the release as slow as the largest table.
32–35
The contract step is a separate release, and the point at which rollback stops being available is written down. That sentence is the difference between a planned constraint and an incident.

Why this works: At every moment the database satisfies both deployed versions, the canary produces per-version evidence before full exposure, and the release where rollback stops working is named in advance.

Canarying a release that contains a destructive migration

Wrong

text
1. migrate: DROP COLUMN orders.legacy_channel
2. deploy 5% canary of v1.14
-> the 95% still running v1.13 breaks immediately

Better

text
1. migrate: expand only (add the new column)
2. canary v1.14, which writes both shapes
3. drop legacy_channel in v1.16, once nothing reads it

What you see: The canary looks perfect and the site goes down. The 5% on the new code is fine; the 95% on the old code is raising `column orders.legacy_channel does not exist` on nearly every request.

Why: A canary limits exposure to the new *code*, and a migration is not code — it is a change to shared state that every version sees at once. So a destructive migration inverts the safety property completely: the smaller the canary, the larger the fraction of traffic broken by it. This is the clearest illustration of the rule the whole section rests on: during any of these strategies the schema must satisfy both versions, and the only way to get that with a removal is to postpone the removal to a later release. If a change genuinely cannot be made additive, then it cannot be canaried or rolled either, and the deployment needs a maintenance window stated in advance.

Share of traffic on the new version, over the deploy

One variable separates the three: how fast the new version reaches 100% of traffic. Everything else — capacity cost, blast radius, speed of rollback — follows from that curve.

  • Three small step charts side by side, each plotting the share of traffic served by the new version against time, from zero per cent at the bottom to one hundred per cent at the top.
  • Rolling is a staircase climbing in four equal steps from zero to one hundred per cent, labelled "no extra capacity, both versions live throughout".
  • Blue/green is flat at zero and then jumps straight to one hundred per cent in a single step, labelled "double capacity briefly, and the fastest rollback".
  • Canary is flat at zero, rises to a narrow five per cent shelf that it holds for a long stretch, then jumps to one hundred per cent, labelled "evidence from real traffic before full exposure".
  • A footer states that in all three cases both versions share one database, one cache and one queue, so the schema in effect must satisfy the old code and the new code at the same time.

The three strategies, compared on what they actually cost

The three strategies, compared on what they actually cost
DimensionRollingBlue/greenCanary
extra capacitynone**2× during the switch**a little
both versions liveyes, throughoutbriefly, at the switchyes, by design
blast radius of a bad releasegrows as it rollseveryone, instantly**the canary share only**
rollbackanother full rollflip back — secondsstop and drain the canary
evidence before full exposurenonesmoke tests, no real trafficreal traffic, real users
needsa health checka second fleet and a switchtraffic splitting + per-version metrics

Together

text
low-risk change, ordinary release   -> rolling
schema-free change, want instant undo -> blue/green
risky change, high traffic, real doubt -> canary

What must be compatible across the two live versions

What must be compatible across the two live versions
Shared thingThe failure if it is notThe discipline
database schemaold code queries a column the new release droppedexpand → migrate → switch → contract, one release later
session contentsrandom logouts, or a `KeyError` on a session keyadd keys; never repurpose or remove one in the same release
cache valuesa pickled object the other version cannot readversion the cache key when the shape changes
queued task payloadsold worker gets an argument it has no parameter foradd optional arguments only; never rename a task in place
static asset URLsa page from v2 requests an asset only v2 collectedhashed filenames, and keep the previous build served

Together

python
# Cache keys carry the shape version, so the two releases cannot
# read each other's serialised objects.
cache.set(f"cart:v3:{user_id}", payload, 900)

Remember: The three strategies differ only in how fast the new version reaches 100% of traffic: rolling ramps with no extra capacity, blue/green steps with double capacity and the fastest rollback, and a canary holds a small share while you gather real evidence. All of them run two versions of the code against one database, one cache and one queue, so the schema, the session format, the cache values and the task payloads must all satisfy both versions at once — which is expand-and-contract, not a strategy choice. And a canary only works with per-version metrics; without them, five per cent of the errors disappears into normal variation.

See also: feature flags separate release from deploy · rollback planning and the migration problem · the expand and contract technique · artifacts approvals and rollback

Feature flags: deploying is not releasing

standardintermediate

A **feature flag** is a runtime switch around new behaviour. The code ships to production turned off, and you turn it on separately — for internal users first, then a percentage, then everyone. That splits one risky event into two: **deploying** the code, which is now boring, and **releasing** the behaviour, which is reversible in seconds without a deploy.

Think of it as

The reason flags matter is that a deploy and a release are usually the same moment, and that moment carries two different risks at once — did the build break anything, and is the new behaviour right? A flag separates them. The code goes out dark, so the deploy proves only that nothing broke, and the behaviour is enabled afterwards on a schedule you control and can reverse instantly. That reversal is the property people underrate: turning a flag off takes effect at the next request, whereas rolling back a deploy takes a pipeline run, and during an incident that difference is the difference between a blip and an outage. Flags come in a few shapes and it is worth being deliberate about which you are creating. A release flag is temporary and exists to control the rollout of one change — it should be deleted within weeks of reaching 100%. An operational flag ("skip the recommendation service") is a permanent switch used to degrade under load, and it is legitimate to keep. An experiment flag splits traffic to compare outcomes, and it has its own lifecycle owned by whoever reads the results. A permission flag ("this customer has the reporting add-on") is not a feature flag at all — it is authorisation, and it belongs in your permission model where it can be audited. Mixing those up is how a flag system becomes an unremovable second permission system. Two practical rules keep flags from becoming a liability. First, every flag is a branch in the code and therefore a doubling of the states you must reason about; ten flags is a thousand combinations, most of which nobody has ever run. So flags need an owner and a removal date, and the cleanup is part of the work rather than a follow-up ticket that never gets done. Second, evaluation must be cheap and safe. A flag checked in a template loop that hits the database each time is an N+1 you added on purpose, so cache the values for the request or for a short TTL; and every check needs a default for when the flag store is unreachable, because a flag system that fails closed can take down the feature it was protecting. In Django, the natural home for the check is a small service function rather than a scattered `if` — one place to read, one place to instrument, and one place to delete when the flag goes.

python
if flags.enabled("checkout.new-tax-engine", user=request.user):

What we're doing: Add a flag that is cheap to evaluate, safe when its store is unavailable, and impossible to forget about.

flags/models.py + flags/api.pypython
# flags/models.py
class FeatureFlag(models.Model):
    key = models.SlugField(unique=True)
    enabled_for_staff = models.BooleanField(default=False)
    percent = models.PositiveSmallIntegerField(default=0)   # 0-100

    # Every flag has a named owner and a date. Without these, a flag
    # system becomes a permanent, undocumented second configuration
    # layer that nobody dares to remove.
    owner = models.CharField(max_length=100)
    remove_after = models.DateField()


# flags/api.py
def enabled(key, *, user=None, default=False):
    """One place to read a flag: one cache, one default, one thing to
    delete when the flag goes."""
    # Cached, because this is called from templates and loops. Reading
    # the table per call is an N+1 you added deliberately.
    flag = cache.get_or_set(f"flag:{key}", lambda: _load(key), 30)

    if flag is None:
        # The store is unreachable or the key is unknown. Returning the
        # caller's default — normally False — means an outage in the
        # flag system cannot switch behaviour on by surprise.
        return default

    if user is not None and user.is_staff and flag.enabled_for_staff:
        return True

    if flag.percent >= 100:
        return True
    if flag.percent <= 0:
        return False

    # STABLE bucketing: the same user must get the same answer on every
    # request, or a user at 5% sees the feature flicker page to page.
    # random() here would be a bug that looks like a flaky feature.
    bucket = int(hashlib.sha256(f"{key}:{user.pk}".encode()).hexdigest(), 16) % 100
    return bucket < flag.percent
7–11
Owner and removal date are fields, not conventions, so a weekly report can list flags past their date. Flags that outlive their change are the main cost of a flag system.
18–20
Cached with a short TTL. The check gets called from templates and loops, so an uncached read turns one flag into hundreds of queries on a busy page.
22–26
The unavailable case is a decision, not an exception. Defaulting to the caller's value — usually `False` — means a flag-store outage leaves behaviour as it was rather than enabling something untested.
31–34
The two absolute cases short-circuit before any hashing, which keeps the common path (fully on, or fully off) as cheap as an integer comparison.
36–40
Stable bucketing by a hash of the key and the user id. `random()` would re-roll per request, so a user in the 5% would see the feature appear and disappear as they navigate.

Why this works: Evaluation costs a cache read, an outage of the flag store cannot turn anything on, a user's experience is consistent across requests, and every flag carries the owner and date needed to remove it.

One event, or two

Deploy = release

  • +New code and new behaviour arrive in the same minute
  • +A problem could be the build, the config or the feature — you cannot tell
  • +Undo is a pipeline run: minutes, and only if the schema allows it
  • +Every change waits for a deploy window, so changes get batched
  • +Batched changes make the next incident harder to attribute

Deploy, then release

  • Code ships dark; the deploy proves only that nothing broke
  • Behaviour is enabled for staff, then 5%, then everyone
  • Undo is a flag write: effective on the next request
  • A bad feature does not force a rollback of unrelated changes
  • The cost is a branch in the code, which you must later delete
  • Deploy = release
    • New code and new behaviour arrive in the same minute
    • A problem could be the build, the config or the feature — you cannot tell
    • Undo is a pipeline run: minutes, and only if the schema allows it
    • Every change waits for a deploy window, so changes get batched
    • Batched changes make the next incident harder to attribute
  • Deploy, then release
    • Code ships dark; the deploy proves only that nothing broke
    • Behaviour is enabled for staff, then 5%, then everyone
    • Undo is a flag write: effective on the next request
    • A bad feature does not force a rollback of unrelated changes
    • The cost is a branch in the code, which you must later delete

Four things people call a feature flag

Four things people call a feature flag
KindLifetimeOwned byBelongs in
release flagweeks — delete after 100%the team shipping the changea flag store
operational flagpermanentwhoever is on calla flag store, with a runbook entry
experiment flagthe length of the experimentwhoever reads the resultsan experiment tool
permission flagthe life of the accountthe product**your permission model**, not a flag store

Together

python
if flags.enabled("checkout.new-tax-engine", user=request.user):
    total = new_tax_engine(cart)
else:
    total = legacy_tax(cart)

Remember: A flag splits one risky event into two: the deploy, which becomes boring because the code ships dark, and the release, which you control and can reverse at the next request instead of at the next pipeline run. Know which kind you are adding — a release flag is temporary and must be deleted, an operational flag is a permanent degradation switch, and a "permission flag" is authorisation that belongs in your permission model. Evaluate from cache so a flag in a loop is not an N+1, decide what the value is when the store is unreachable, and bucket users by a stable hash so a percentage rollout does not flicker.

See also: rolling blue green and canary · rollback planning and the migration problem · degrading falling back and dead lettering · object level and resource level authorization

Advertisement

Replacing an instance without dropping a request

Readiness first, then the drain, then the smoke test that decides.

Draining, health checks and the smoke test that decides

coreadvanced

A **graceful shutdown** stops accepting new requests, finishes the ones in flight, and only then exits. The **health check** is what the load balancer polls to decide whether to send you traffic — so failing it *first*, while still serving, is what makes the drain invisible. A **smoke test** is the handful of real requests run against the new deployment to decide whether the release stands or is rolled back.

Think of it as

Zero-downtime replacement is an ordering problem, and the order is counter-intuitive: you tell the world you are unavailable *before* you become unavailable. The sequence is fail readiness, keep serving, wait one load-balancer check interval so it stops sending you new work, then stop accepting connections, then finish what is in flight, then exit. Skip the first two steps and the drain is racing the balancer: requests keep arriving at a process that has stopped accepting them, and the client sees a refused connection. That is the whole of the "a few 502s on every deploy" phenomenon. Underneath that sit the two numbers, and they have to nest. Gunicorn drains for `--graceful-timeout` and then force kills; systemd's `TimeoutStopSec` and a container platform's termination grace period each have to be larger, or the platform sends the kill and the drain you configured never completes. Add the pre-stop wait to the sum: if you wait ten seconds for the balancer plus thirty seconds of draining, the platform needs more than forty. Health checks split into the two kinds §90 covers, and the deploy use is worth restating in one line: liveness is "restart me", readiness is "send me traffic", and only readiness participates in a deploy. A readiness check that verifies the database is right; a *liveness* check that verifies the database is a way to turn a five-second database blip into a fleet-wide restart. Keep readiness cheap and cached for a couple of seconds, because it is polled constantly by every instance. Smoke tests are the last gate and the one most often skipped, because "the pipeline is green" feels like enough. It is not: the pipeline tested the artefact in CI, and the smoke test is the first evidence that this artefact works *in this environment*, with its real configuration, its real database, its real object storage and its real DNS. Keep them few and fast — a handful of requests that touch the paths that matter — and make them assert content rather than status. A page that returns 200 with a rendered error banner passes a status check and fails a real one. Above all, the smoke result must be wired to the rollback: a smoke test whose failure does not trigger anything is a notification, not a gate.

bash
POST /internal/drain   ->   readiness 503, still serving   ->   SIGTERM

What we're doing: Write the drain endpoint and a smoke test that would actually catch a broken release, and wire the failure to a rollback.

ops/health.py + smoke.pypython
# ops/health.py
def drain(request):
    """Called by the platform's pre-stop hook, BEFORE any signal.

    The process keeps serving normally after this; all it changes is
    the answer to /readyz, which is what the load balancer polls.
    """
    require_internal(request)          # never reachable from outside
    cache.set("shutting_down", True, timeout=300)
    return HttpResponse("draining")


def readyz(request):
    if cache.get("shutting_down"):
        return HttpResponse("draining", status=503)
    # Cached for two seconds: every instance polls this constantly, and
    # an uncached database round trip per poll is real load.
    if not cache.get_or_set("ready", _check_dependencies, 2):
        return HttpResponse("dependencies unavailable", status=503)
    return HttpResponse("ready")


# smoke.py — run against the deployed environment, not against CI
CHECKS = [
    # Anonymous, cheap, and proves routing, TLS and the app all work.
    ("GET", "/", 200, "Sign in"),

    # Proves the DATABASE is reachable with the real production
    # credentials — the thing CI could not test.
    ("GET", "/api/products/?limit=1", 200, '"results"'),

    # Proves STATIC files were collected and are being served, which is
    # a per-environment fact and a classic deploy-only failure.
    ("GET", "/static/app.css", 200, None),

    # Proves the session/cookie path works end to end.
    ("POST", "/api/auth/token/", 200, '"access"'),
]


def run(base_url):
    for method, path, expect_status, expect_body in CHECKS:
        r = request(method, base_url + path, timeout=(3, 10))
        assert r.status_code == expect_status, f"{path}: {r.status_code}"
        # Assert CONTENT, not just status. A page rendering an error
        # banner returns 200 and passes a status-only check.
        if expect_body:
            assert expect_body in r.text, f"{path}: body did not contain {expect_body}"
3–9
The drain endpoint changes one thing: the answer to readiness. The process is still healthy and still serving, which is exactly why the balancer has time to stop routing before anything stops working.
13–20
Readiness checks the drain flag first, then dependencies, and caches the dependency result for two seconds — because this endpoint is polled continuously by every instance in the fleet.
23–30
The smoke list is short on purpose. Each entry proves something CI could not: real routing, real credentials, real configuration in this environment.
32–34
The static-file check is the one people leave out, and it is a per-environment failure — `collectstatic` ran in the build, but whether those files are actually served depends on this environment's storage and proxy.
44–48
Asserting content is the difference between a real gate and a formality: a Django error page, a maintenance banner and a login screen can all return 200.

Why this works: The instance stops receiving traffic before it stops serving, readiness costs a cached lookup rather than a query per poll, and the smoke test checks facts that only exist in the deployed environment.

Sending SIGTERM before the load balancer knows

Wrong

yaml
# no pre-stop hook — the platform signals immediately
terminationGracePeriodSeconds: 60
# requests keep arriving for several seconds after the app stops accepting

Better

yaml
lifecycle:
  preStop:
    exec: { command: ["/bin/sh", "-c", "curl -sf -XPOST localhost:8000/internal/drain && sleep 10"] }
terminationGracePeriodSeconds: 60

What you see: A predictable handful of 502s at the moment of every deploy, spread across all instances, with no application error to match them — because the requests never reached the application at all.

Why: Shutdown and traffic removal are two independent systems, and nothing synchronises them for you. The load balancer only learns an instance is unavailable when its next health check fails, which is up to one interval away; if the process stops accepting connections before that, everything routed in the gap is refused at the socket. Failing readiness first inverts the order — the balancer stops sending, and *then* the process stops accepting — so the gap contains no requests. The sleep in the pre-stop hook is what buys that interval, and it must be at least the health-check period plus its failure threshold, which is a number to read from the balancer configuration rather than guess.

Replacing one instance without dropping a request
  1. t+0s

    Drain flag set; readiness starts failing

    the process is still serving normally — this is an announcement, not a state change

  2. t+0s → t+10s

    The balancer notices

    one check interval, plus a margin. New requests stop arriving; in-flight ones continue

  3. t+10s

    SIGTERM to the gunicorn master

    gunicorn: "graceful shutdown; waits for workers to finish requests up to graceful_timeout"

  4. t+10s → t+40s

    Draining

    no new connections are accepted; typically finishes in a second or two because nothing new is arriving

  5. t+40s

    Force kill of anything still running

    gunicorn kills stragglers at the graceful timeout — the window is a guarantee, not a plan

  6. t+55s

    Platform grace period would fire

    deliberately later than every step above; if it fired first, it would kill a healthy drain

  7. after

    New instance starts, readiness passes, smoke test runs

    the balancer adds it only once readiness passes; the smoke result decides whether the release stands

  1. t+0s: Drain flag set; readiness starts failing — the process is still serving normally — this is an announcement, not a state change
  2. t+0s → t+10s: The balancer notices — one check interval, plus a margin. New requests stop arriving; in-flight ones continue
  3. t+10s: SIGTERM to the gunicorn master — gunicorn: "graceful shutdown; waits for workers to finish requests up to graceful_timeout"
  4. t+10s → t+40s: Draining — no new connections are accepted; typically finishes in a second or two because nothing new is arriving
  5. t+40s: Force kill of anything still running — gunicorn kills stragglers at the graceful timeout — the window is a guarantee, not a plan
  6. t+55s: Platform grace period would fire — deliberately later than every step above; if it fired first, it would kill a healthy drain
  7. after: New instance starts, readiness passes, smoke test runs — the balancer adds it only once readiness passes; the smoke result decides whether the release stands

The shutdown sequence, and what breaks if you skip a step

The shutdown sequence, and what breaks if you skip a step
StepWhat it doesSkip it and
fail readinesstells the balancer to stop routing herenew requests arrive at a draining process
keep servingcovers the balancer's next check intervalyou race the balancer and lose
SIGTERM to the masterbegins gunicorn's graceful shutdown
finish in-flight requestsup to `--graceful-timeout`responses are cut off mid-write
exitthe process ends before the platform's deadlinethe platform sends SIGKILL instead

Together

bash
# pre-stop hook: flip readiness, then give the LB time to notice
curl -sf -X POST localhost:8000/internal/drain && sleep 10

Three checks that get confused

Three checks that get confused
CheckAsksFailure means
livenessis this process wedged?restart the container
readinessshould this instance get traffic?remove it from the pool
smoke testdoes this release actually work here?roll the release back

Together

bash
curl -fsS https://acme.example/healthz            # liveness: no I/O
curl -fsS https://acme.example/readyz             # readiness: DB + cache
./smoke.sh --base-url https://acme.example        # release gate

Remember: Announce unavailability before becoming unavailable: fail readiness, keep serving for at least one balancer check interval, then `SIGTERM`, then drain, then exit — and make sure the platform's grace period is longer than the sum, or it sends the kill instead. Readiness may check dependencies and should be cached; liveness must not touch the database, or a blip restarts the fleet. Then run a smoke test against the deployed environment, because that is the first evidence this artefact works with real configuration, real credentials and real static files — assert on content rather than status, and wire the failure to the rollback, or it is a notification rather than a gate.

See also: rolling blue green and canary · rollback planning and the migration problem · degrading falling back and dead lettering · timeouts max requests and graceful restart

Advertisement

Planning the way back

Which releases can be rolled back, which cannot, and how to say so in advance.

Rollback planning, and why migrations make it hard

coreadvanced

A rollback plan is a sentence written **before** the deploy: which version we go back to, what makes us decide, and whether going back actually works. It often does not, because a deploy changes two things with different reversibility — the code, which is a redeploy away, and the database, which is not. Some changes have no rollback at all, and the time to discover that is while planning, not during an incident.

Think of it as

Every release is really two changes with different undo properties. The code is versioned, immutable and already built, so going back is a redeploy of an artefact that exists. The database is shared, mutable state that both versions see at once, so "going back" means running something new against data that has already moved. That asymmetry is what the roadmap means by migrations making rollback harder, and planning is the practice of noticing it before you need it. Classify the release first. An additive schema change — a nullable column, a new table, a new index — leaves the previous version fully runnable, so rollback is a redeploy and nothing else. A destructive change — dropping a column, renaming one, tightening a constraint — removes something the previous version needs, so the moment it applies the old image can no longer start; the release is forward-fix-only from then on, and that should be written in the release notes rather than learned at 02:00. Then look past the schema at the two other categories that no redeploy touches. Data transforms: a migration that rewrote or deleted rows cannot be undone by reversing the migration, because the reverse operation restores the *shape* and not the values — re-adding a dropped column gives you a column full of nulls. Emitted side effects: emails sent, payments captured, webhooks delivered, events published to a broker. Those left your system, so the only "undo" is a compensating action — a refund, a correction email, a tombstone event — which is a product decision, not an operational one. A useful planning habit is to write the rollback line at the same time as the migration, and to make it specific: "redeploy v1.13; the new column is nullable and unused by v1.13" is a plan, while "we can roll back if needed" is not. Add the trigger — what observation makes you do it — and the deadline, because most bad releases are recognised in the first ten minutes and the cost of waiting is linear. Finally, know the escape hatches when there is no rollback: a feature flag turns a behaviour change into something reversible in seconds without touching the schema, and expand-and-contract turns a destructive change into two releases where the risky half happens after the code has been running for a week. Both are planning decisions made before the code is written, which is why rollback planning belongs at design time and not at deploy time.

text
Rollback: redeploy <digest> · Trigger: <observation> · Valid until: <release>

What we're doing: Write a rollback plan that answers the three questions someone will have at 02:00, for a release that is only partly reversible.

RELEASE.md — v1.14, shipped with the change, not after ittext
v1.14 — new tax engine behind a flag, plus fulfilment_channel

ROLLBACK
  Version:  v1.13.2, digest sha256:9c1f4b… (still in the registry,
            retention 90 days — checked, not assumed)
  Command:  ./deploy.sh --image sha256:9c1f4b…
  Time:     ~90 seconds, no build required

TRIGGER — decided now, so nobody debates it during the incident
  - 5xx rate > 1% for 5 minutes, OR
  - checkout conversion down > 20% against the same hour last week, OR
  - any error mentioning fulfilment_channel

WHAT ROLLS BACK CLEANLY
  - all application code
  - the new tax engine: it is behind flag checkout.new-tax-engine, so
    it can also be turned off WITHOUT a deploy, in one request

WHAT DOES NOT
  - orders.fulfilment_channel stays. It is nullable and v1.13 never
    references it, so leaving it is harmless — do NOT try to drop it
    as part of a rollback.
  - Confirmation emails already sent cannot be recalled. If the tax
    total was wrong, send a correction: template billing/correction.txt
  - Payments already captured: refund via the ops runbook, do not
    attempt to reverse them in the database.

VALID UNTIL
  v1.16, which drops the legacy tax code path and orders.legacy_rate.
  After v1.16 is deployed, v1.13 can no longer start and this plan is
  void. v1.16's own plan says "forward fix only" for that reason.
3–7
The digest, not a tag, and a checked retention window. A plan that names an image nobody confirmed still exists is a plan that fails at the moment it is used.
9–12
The trigger is agreed in advance, in measurable terms. During an incident the argument is always whether it is bad enough; deciding beforehand removes that argument.
14–17
The flag is the faster path and is listed first for that reason: turning the behaviour off takes one request, while a rollback takes a deploy.
19–26
The three things a redeploy does not undo, named individually with what to do instead. "Do not try to drop the column" is there because that is the instinct under pressure, and it would break v1.14 if the rollback were itself rolled back.
28–31
The expiry. This is the sentence that turns "we can always roll back" into something with a date, and it makes the next release's risk visible before that release is planned.

Why this works: Somebody woken at 02:00 has the version, the command, the decision rule and — crucially — an explicit list of what going back will not fix, so no time is spent discovering it.

Reversing a data migration to undo a data change

Wrong

bash
# the migration normalised phone numbers, overwriting the originals
python manage.py migrate accounts 0031     # "reverse it"
# the reverse restores the COLUMN, not the values that were overwritten

Better

python
# Never overwrite in place. Write to a new column and keep the old one
# until a later release removes it.
migrations.AddField("user", "phone_e164", models.CharField(max_length=20, null=True))
# backfill in a resumable command; 'phone' is untouched and still readable

What you see: The reverse migration runs successfully and the data is still wrong — or worse, the column comes back full of nulls and the application starts failing on records that were fine an hour ago.

Why: A migration reversal is defined in terms of schema operations, not of the values those operations touched. `RemoveField` reversed is `AddField`, which produces an empty column; a `RunPython` step reversed runs whatever `reverse_code` you wrote, and if that function cannot reconstruct the original values — which it usually cannot, because the transform lost information — then it either fails or fabricates. The discipline is to make data changes additive too: write the new representation beside the old one, backfill in a resumable command outside the migration, switch reads in a later release, and only remove the original when nothing needs it. That way the undo for a data change is the same as for a schema change — stop using the new column — rather than an attempt to invent the past.

What kind of undo a release actually has
Redeploy the previous digest
The ordinary release. The old image still runs against this schema, so rollback is one command and takes seconds.
Roll back, then compensate
Code goes back cleanly, but 400 emails were sent and 12 payments captured. The undo is a product decision: refunds, corrections, a tombstone event.
Forward fix only
A dropped or renamed column means the previous version cannot start. Say this in the release notes before deploying, not during the incident.
Restore, not rollback
Destroyed data plus emitted effects. This needs a verified backup, a restore rehearsal and a communications plan — it is an incident procedure, not a deploy step.
  • Redeploy the previous digest: schema: additive only, no side effects emitted — The ordinary release. The old image still runs against this schema, so rollback is one command and takes seconds.
  • Roll back, then compensate: schema: additive only, side effects already emitted — Code goes back cleanly, but 400 emails were sent and 12 payments captured. The undo is a product decision: refunds, corrections, a tombstone event.
  • Forward fix only: schema: destructive, no side effects emitted — A dropped or renamed column means the previous version cannot start. Say this in the release notes before deploying, not during the incident.
  • Restore, not rollback: schema: destructive, side effects already emitted — Destroyed data plus emitted effects. This needs a verified backup, a restore rehearsal and a communications plan — it is an incident procedure, not a deploy step.

Four release shapes, and what "roll back" means for each

Four release shapes, and what "roll back" means for each
Release containsRollbackPlan
code onlyredeploy the previous digestthe ordinary case — seconds, no schema involved
additive migrationredeploy; leave the new columnprevious version never mentions it, so it is inert
destructive migration**none**forward fix only — say so before deploying
data transform / deletion**none** (shape can return, values cannot)take a verified backup; plan a restore path
side effects emittedcompensating action, not a rollbacka refund, a correction, a tombstone event

Together

text
Rollback: redeploy v1.13.2 (digest sha256:9c1f…).
Trigger:  5xx rate above 1% for 5 min, or checkout conversion down >20%.
Valid until: v1.16, which drops orders.legacy_channel.

Making an unrollbackable change rollbackable

Making an unrollbackable change rollbackable
Instead ofDoBecause
drop a column in the releasedrop it two releases laterthe previous version stops needing it first
rename a columnadd, dual-write, switch reads, drop latera rename is a drop and an add at the same instant
add `NOT NULL` to an existing columnadd nullable, backfill, then constrainthe constraint fails on rows the old code still writes
change behaviour in the deployship dark behind a flaga flag is reversible at the next request
rewrite rows in a migrationbackfill in a resumable commanda migration that fails halfway leaves neither shape

Together

python
# Reversible by construction: nullable, defaulted, unused by the old code.
migrations.AddField(
    "order", "fulfilment_channel",
    models.CharField(max_length=32, null=True, blank=True),
)

Remember: Write the rollback plan with the change, not after it: the version and digest, the command, the trigger that decides, and — most importantly — what going back will *not* fix. A deploy changes two things with different reversibility, so classify it: additive schema means a redeploy is enough, destructive schema means forward fix only, a data transform cannot be undone by reversing a migration because the shape returns without the values, and emitted side effects need compensating actions rather than a rollback. Name the release where the plan expires. And when a change has no rollback, use the two tools that give you one: a feature flag, and expand-and-contract.

See also: rolling blue green and canary · feature flags separate release from deploy · artifacts approvals and rollback · dangerous schema changes · forward reverse and irreversible operations

Advertisement