WSS
Web Specification Studio Home
On this page
BlogDevOpsReliabilityDeploymentArchitecturePublished

Canary Deployments: Why Shared State Can Turn a 1% Release Into a 100% Outage

Canary deployments reduce the blast radius of stateless bugs, but shared databases, queues, and caches can still cause fleet-wide outages. Learn the failure modes and how to protect against them.

Short answer: Canary releases isolate CPU and memory. They do not isolate your database, cache, or message queue.

If a 1% canary writes bad data or an incompatible schema into a shared store, the other 99% of your fleet will crash trying to read it.

Rolling back the code leaves the corrupted data behind.

Every on-call developer has seen this post-mortem at least once.

You deploy a canary at 1% traffic. You watch your Grafana dashboards for 15 minutes.

HTTP 200s are solid, latency looks flat, and error rates sit at 0.0%. Automated canary analysis signs off with a green checkmark.

The rollout controller bumps traffic to 100%.

Two minutes later, PagerDuty goes off. The entire production fleet starts throwing 500s.

You hit kubectl rollout undo to revert the deployment. The rollback finishes in twenty seconds.

The outage continues.

Why? Because during that 15-minute canary window, the new pods wrote hundreds of rows to PostgreSQL, published new message formats to Kafka, and wrote new session keys to Redis.

The code rollback killed the new containers, but the data they committed is still sitting in your database.

Your old code is now choking on data it was never built to parse.

What Does a Canary Actually Isolate?

A canary deployment is just a routing rule at your load balancer or ingress controller.

It tells your proxy: send 99% of incoming HTTP requests to version 1, and 1% to version 2.

Your candidate runs in its own Kubernetes pods. It has its own memory and its own CPU.

But it connects directly to the exact same backing infrastructure as the rest of your app:

  • The same primary database
  • The same Redis cluster
  • The same Kafka topics
  • The same external payment and email APIs
Traffic routing shows 99% to stable v1 pods and 1% to canary v2 pods, but both writing to the same shared database, queue, and cache below the compute boundary.
Figure 1: Traffic splitting creates a compute boundary. It does not create a state boundary.
Text description

99% of traffic routes to stable v1 pods; 1% routes to canary v2 pods. Both pod groups sit above a compute boundary. Below that boundary, both fleets connect to the same shared database, queue, and cache, none of which are partitioned by the traffic split.

If a bug is strictly stateless (like a null-pointer exception on a GET request or a runaway loop that burns CPU), a canary works as advertised.

Only the 1% of users routed to the candidate see an error. Your stable pods never notice, and an automated rollback cleans up the problem instantly.

The moment a bug writes to shared storage, the canary boundary is useless.

The candidate writes bad data, and seconds later a stable pod reads it and crashes.

Here is what traffic splitting can and cannot protect:

Failure typeContained by traffic split?Why
Unhandled exception in a read-only endpointYesThe failure stays isolated inside that single HTTP request
CPU regression or runaway regexYesContained within the candidate containers
Slow memory leakPartiallyMasked at low traffic; surfaces quickly once promoted to 100%
Incompatible database writeNoStable pods crash when reading the modified rows
Breaking message schema on a shared queueNoStable workers crash when deserializing the payload
Unversioned cache key writeNoStable pods fail when reading the modified key
External API mutation (Stripe charge, email)NoThe external side effect has already executed

If a change introduces a bug in any of the last four rows, traffic splitting cannot save you.

The problem might start in a tiny slice of requests, but it spreads across the entire system as soon as other pods and workers touch the shared data.

Before deploying any canary, scan your PR diff for state writes: SQL inserts, cache updates, queue publications, and external API calls.

If any write touches shared resources, traffic splitting alone will not protect you.

Database migrations: when v1 and v2 disagree on a row

Schema migrations rarely fail when running the SQL.

You add a nullable column, PostgreSQL finishes the DDL in 8 milliseconds, and the deployment pipeline proceeds.

The failure happens because your stable pods and your canary pods run against the same database at the same time.

Both versions are live, but they have different assumptions about what a record contains.

Here is a common scenario:

# Canary (v2): deprecates legacy_amount, writes new rows with NULL
def create_order(user_id: str, total: int, currency: str):
    db.execute(
        "INSERT INTO orders (user_id, total_cents, currency_code, legacy_amount) VALUES (%s, %s, %s, NULL)",
        (user_id, total, currency)
    )
# Stable (v1): assumes legacy_amount is always a valid number
def get_order_summary(order_id: str):
    order = db.query_one("SELECT total_cents, legacy_amount FROM orders WHERE id = %s", order_id)
    return order["total_cents"] / order["legacy_amount"]  # Fails with ZeroDivisionError or TypeError

Adding currency_code and making legacy_amount nullable is non-breaking DDL.

The catch is that version 1 code expects legacy_amount to always contain a positive integer.

Once the canary pod starts handling write traffic and inserts rows with NULL, stable pods crash the moment a user views an order created by that canary.

The canary itself reports healthy metrics:

  • HTTP 200 on create-order requests
  • 0% error rate on candidate pods
  • Normal response latency

The crashes happen on the 99% stable fleet.

If your automated canary analysis only monitors error rates on the candidate pods, the rollout looks completely safe.

The canary succeeds because it pushes invalid state into storage, and the older fleet takes the blow.

Why rolling back code leaves the data broken

When errors spike on the stable fleet, the default reaction is to roll back the candidate container:

Timeline from T+00 to T+12 showing canary deployment, database writes, stable fleet crashes, rollback at T+11, and continued outage at T+12 because the corrupted rows remain.
Figure 2: Rollback terminates candidate containers, but modified rows remain in storage.
Text description

T+00: Canary deployed at 1% traffic. T+05: 300 database rows written with v2 schema. T+10: Stable pods crash reading NULL legacy_amount. T+11: Rollback executed, canary pods terminated. T+12: Outage continues because the 300 rows remain and still crash v1.

Reverting the container terminates the candidate pods and stops new writes from coming in.

But the 300 rows already written with NULL remain in the database.

Every time a stable pod queries one of those records, it crashes again.

The outage lasts until someone runs an emergency SQL update to repair or populate those rows.

An automated rollback handles container lifecycle, but it cannot repair modified storage.

Splitting schema changes across deploys (expand-contract)

To change column structure safely without taking down pods reading the table, you have to split the rollout across separate releases:

  1. Expand deployment: Add the new column (currency_code) as nullable, keeping legacy_amount intact. Update application code so it writes to both columns, but continues reading from legacy_amount. At this stage, stable pods and canary pods can both read and write without errors.

  2. Backfill migration: Run a background job or batch script to backfill existing records with the new column values.

  3. Switch reads deployment: Deploy code that reads from currency_code (with a fallback to legacy_amount if null). Both versions can still safely co-exist.

  4. Contract deployment: Once all pods are running the new code and old versions are terminated, remove the dual-write logic and drop the old legacy_amount column in a final schema cleanup.

Splitting changes this way ensures that no matter which pod version handles a request, every row satisfies the assumptions of both codebases.

This rule is just as critical for blue-green deployments whenever both environments connect to a single database cluster.

Shared queues: when background workers pull unparseable jobs

HTTP routing rules do not touch asynchronous workers.

Your ingress controller can direct 1% of incoming web requests to canary pods.

But if those pods publish jobs to Kafka, RabbitMQ, or SQS, workers on the other end consume directly from the queue.

If canary workers and stable workers share a topic or consumer group, they pull from the exact same stream of messages.

No load balancer routes 99% of queue messages to stable workers and 1% to canary workers.

Shared Kafka Topic (unfiltered)

├── Stable worker (v1): pulls message, sees new v2 schema, fails to parse
└── Canary worker (v2): produces v2-schema messages, processes them fine

How worker retries amplify message failures

This creates an immediate processing bottleneck:

  1. The canary API publishes an event using a new JSON schema or modified field names.

  2. A stable worker pulls the message, fails to deserialize the payload, and throws an exception.

  3. If your queue handler re-queues failed jobs on error, the message goes right back to the head of the queue.

  4. Another stable worker pulls the same message and crashes.

  5. In minutes, worker lag climbs across the cluster, and background job processing stalls.

The HTTP canary dashboard looks fine because the web endpoint returned 200 OK after pushing the message to Kafka.

Meanwhile, background operations like payment settlements, order confirmations, and webhooks are stuck.

Without exponential backoff and jitter, retries circulate the problematic message through workers faster than the queue can process legitimate traffic.

Tagging payloads with schema versions

Never publish raw, unversioned payloads to a shared queue during a rolling update.

Include a version header on every message:

producer.send(
    topic="order-events",
    headers={"x-schema-version": "2"},
    value=payload_v2
)

Configure your workers with defensive handling: if an older worker encounters a message with x-schema-version: 2, it should not try to parse it with the v1 parser and crash.

It should forward the message to a deferred topic or let only v2-capable workers claim it until the rollout finishes.

Shared caches: session invalidation and deserialization crashes

Because caches are ephemeral, developers sometimes treat cache updates as low-risk.

In practice, writing unversioned data to a shared Redis instance causes immediate user disruptions.

When two application versions share a cache keyspace without version prefixes, users get logged out mid-session:

1. User logs in via Canary (v2)
   -> Canary writes: SET session:user_914 '{"v":2, "roles":["admin"]}'

2. Next request hits Stable (v1)
   -> Stable reads: GET session:user_914
   -> Stable expects: {"roles": ["admin"]}
   -> Stable fails to parse the new structure, deletes the key, and returns HTTP 401

The user gets booted back to the login screen.

Your dashboards show sporadic 401 spikes across stable pods, with no clear indication that canary writes triggered the logout.

If you store session data using binary serialization (such as Python pickle, Java serialized objects, or Ruby Marshal), the failure is worse.

A stable pod attempting to deserialize a v2 object whose class definition does not exist in v1 crashes the worker process.

Version-prefixing cache keys

Prefix every cache key with a schema version:

session:v1:user_914
session:v2:user_914

Version 1 pods only read and write v1 keys.

Version 2 pods only read and write v2 keys.

Neither can overwrite or corrupt the other’s state, and obsolete keys expire naturally via your standard TTL settings.

Low-traffic routes: why 1% can’t catch bugs in a 20-minute window

Some canary failures are purely statistical.

Canary analysis needs enough request volume to distinguish real regressions from baseline background noise.

On a low-throughput endpoint, allocating 1% or 2% of traffic yields too few data points for meaningful analysis.

Two panels: left shows the funnel from 8 req/min to 3 canary samples in 20 minutes, with 51.2% probability of missing a 20% failure rate. Right shows a bar chart of 8 minutes to crash at 100% traffic versus 7 hours at 2% traffic, far beyond the analysis window.
Figure 3: At 2% canary allocation, a low-volume endpoint generates 3 samples in 20 minutes, which is insufficient to catch a 20% failure rate or a slow memory leak.
Text description

Left panel: 8 req/min × 2% canary × 20-min window = 3 samples. P(zero failures | 20% rate) = 0.8³ = 51.2%. Right panel: at 100% traffic (500 req/s) the pod crashes in approximately 8 minutes; at 2% traffic (10 req/s) it takes approximately 7 hours, far outside practical analysis windows.

Take an endpoint receiving 8 requests per minute (such as a billing webhook or enterprise checkout).

Over a 20-minute canary evaluation at 2% traffic, the candidate pod handles exactly 3 requests.

If the new build contains a severe defect that causes 20% of requests to fail:

The probability that all 3 requests succeed without showing an error is:

P(zero failures) = (1 - 0.20)³ = 0.8³ = 51.2%

There is a 51% probability that the canary reports 0 errors, passes the automated analysis gate, and gets promoted to 100% traffic, where the bug immediately hits all active users.

Why memory leaks look flat at 2% load

The same volume problem obscures memory leaks.

Under full production throughput (500 requests per second), a leaking pod might exhaust its 1 GB heap limit and crash after 8 minutes.

Under a 2% traffic split (10 requests per second), that same leak takes roughly 7 hours to trigger an out-of-memory restart.

During a standard 20-minute analysis window, the memory consumption graph appears flat.

Once the release is promoted to 100%, all production pods reach the memory limit at roughly the same time and restart in unison.

Using traffic mirroring for low-volume routes

Rather than relying on small percentage splits for low-throughput routes or memory profiling, use traffic shadowing (request mirroring).

Traffic shadowing clones incoming read requests at the load balancer and forwards a copy to your candidate pod.

The candidate executes the full request lifecycle (accessing the database, running application logic, and populating cache), but the proxy drops its response.

# Istio VirtualService mirroring 100% of read traffic to v2
http:
  - route:
      - destination:
          host: order-service
          subset: v1-stable
        weight: 100
    mirror:
      host: order-service
      subset: v2-candidate
    mirrorPercentage:
      value: 100.0

With shadowing, the candidate runs at full production volume.

Memory leaks, slow queries, and serialization issues reveal themselves in minutes rather than hours, without affecting real users.

Write operations in mirrored paths must be mocked or pointed to test stores to prevent duplicate side effects.

Three Rules for Safe Progressive Delivery

Before shipping any canary rollout, follow these three rules:

1. Schema changes must deploy independently

Never change database structure and application logic in the same deployment.

Ship the schema expansion first, verify it across the stable fleet, and only then deploy code that uses the new fields.

2. Version your queue messages and cache keys

Never publish raw, unversioned payloads to shared message brokers or unversioned keys to Redis.

Use schema headers for queues and version prefixes for cache keys.

3. Use feature flags for state-touching code

If a change must touch shared storage, gate the write path behind a feature flag:

if feature_flags.is_enabled("new-order-schema", user_id=user.id):
    write_order_v2(user, order)
else:
    write_order_v1(user, order)

Enable the flag for a single test account, then 1%, then 5%.

If bad data appears, flip the flag off instantly.

The fix takes milliseconds and requires zero pod rollbacks.

Pre-Flight Canary Checklist

Run through this quick check before promoting any canary release:

Database and Schema

  • Can stable pods read data written by the candidate?
  • Can the candidate read data written by stable pods?
  • Does this release change the nullability, type, or meaning of an existing column?
  • If yes, does the schema migration deploy and settle before the code change?

Queues and Workers

  • Does the candidate publish a new message format?
  • Can stable workers deserialize the payload without throwing exceptions?
  • Are consumer lag and DLQ depth included in automated canary analysis?

Cache

  • Does the candidate write to existing Redis keys?
  • Are cache keys version-namespaced to prevent cross-version collisions?

External Side Effects

  • Can the release trigger external API mutations (payments, emails, webhooks)?
  • Are external calls guarded behind targeted feature flags?

Sample Size Validity

  • Does the endpoint receive enough traffic for statistical significance during the window?
  • Are low-throughput critical paths tested through traffic shadowing instead?

Rollback Plan

  • Does the rollback runbook include data repair steps rather than just pod termination?
  • Are rollback procedures documented and tested before deployment begins?

For operational response workflows, escalation procedures, and post-mortems, follow structured runbooks and playbooks with defined data recovery steps.

Frequently Asked Questions

Can a canary deployment corrupt a production database?

Yes, in almost all standard setups. Canary pods share the same database as your stable pods. If the candidate writes rows using an enum value, format, or column nullability that the older version does not expect, stable pods crash when reading those records. Rolling back the deployment removes the new containers, but the corrupted database rows remain.

Why doesn’t rolling back a canary fix the outage?

A rollback removes application code, not the data it already committed. If a canary writes hundreds of rows that stable pods cannot parse, those rows continue causing crashes after the rollback finishes. The outage only ends when someone repairs or backfills the data in the database.

How does expand-contract prevent canary database errors?

Expand-contract splits schema updates across distinct deployments. You first deploy an expansion that adds new columns as nullable and dual-writes to both old and new columns while continuing to read from the old one. Once the new code is fully rolled out across the entire fleet and old pods are gone, a final deployment drops the deprecated column. At no point does a running pod read an unexpected null or missing column.

Are canary deployments safe for Kafka or RabbitMQ workers?

Not when canary and stable workers share a consumer group or queue. If a canary publishes messages with a modified schema, stable workers fail during deserialization. The safe pattern is attaching schema version headers to messages and configuring consumers to defer or re-route unfamiliar formats to holding queues.

Can a canary deployment detect a memory leak?

Not reliably on low-traffic endpoints. Memory consumption scales with request volume. A pod receiving 2% of traffic can take hours to exhaust its heap, far outlasting a standard 20-minute analysis window. Traffic shadowing, which runs candidate pods at 100% of production read volume, is far more reliable for surfacing slow-burn resource exhaustion.

What is the difference between a canary deployment and traffic shadowing?

In a canary deployment, a small percentage of live users receive real responses from the candidate version. In traffic shadowing, incoming production requests are mirrored to the candidate, but its responses are discarded. Users always receive responses from stable pods. Shadowing provides full-volume testing with zero user risk, provided write side effects are isolated or disabled.

When should you use blue-green instead of canary?

Blue-green deployments switch 100% of traffic atomically, making rollback fast through routing rule changes rather than container redeployments. Blue-green is suitable for scheduled maintenance windows involving clean cutovers. However, if blue and green environments share a single database, blue-green requires the exact same backward-compatibility discipline as canary releases.

The Question to Ask Before Every Deploy

When reviewing a deployment plan, the critical question is never what percentage of traffic does the canary receive?

It is: What state can this candidate write that the stable fleet will later read?

If the candidate only touches its own memory, traffic splitting provides reliable containment.

If it writes to a shared database, publishes to a shared message broker, or modifies shared cache keys, traffic splitting cannot contain the blast radius.

It only controls how fast the bad data spreads.

The fix is never dialing down canary percentages.

It is ensuring state changes are backward-compatible before rollouts begin, or choosing a deployment strategy that isolates storage alongside compute.

Written by

Platform Engineer and Technical Writer with 10+ years of full-stack development experience and 2+ years focused on DevOps and platform engineering.

Related posts