Skip to main content
RunBook Academy

AnsibleXXXII · Rolling DeploymentsRolling Deployments

Version skew: what must be true to roll at all

Expert⏱ ~25 minansible-playbook

What you'll learn

  • State the precondition that every rolling deployment assumes
  • Assess schema, API, message and cache compatibility in both directions
  • Apply the expand-and-contract pattern to make a breaking change rollable
  • Recognise when a rolling deploy is the wrong pattern and say so

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Every rolling deployment makes a claim, and almost nobody writes it down:

The old version and the new version can run at the same time, serving the same users, against the same data.

That is not a nice-to-have. It is the defining property of the pattern. serial guarantees that for the duration of the rollout — minutes to hours — some hosts run the old code and some run the new, and both are in the load balancer taking live requests.

If that is not survivable, serial has not made the deployment safer. It has converted a short outage into a long one and given you a fleet you cannot easily return to a consistent state.

Four places skew bites

Database schema

The most common and the most damaging, because the data outlives the rollout.

A migration that runs at the start of the deploy changes the schema under the old version, which is still running on most of the fleet.

ChangeOld version during rollout
Add a nullable columnfine — it ignores it
Add a column with a NOT NULL constraint and no defaultbreaks — its INSERT omits the column
Rename a columnbreaks — it selects a column that no longer exists
Drop a columnbreaks if it still reads it
Add an indexfine, though it may lock during creation
Change a column type narrowing the rangebreaks — it writes values that no longer fit

The rename row is the classic. ALTER TABLE users RENAME COLUMN email TO email_address is one statement, runs in milliseconds, and every host still on the old version starts throwing errors on the next request.

Skew has to work in both directions:

  • Old code, new schema — during the rollout, before a host is updated.
  • New code, old schema — if the migration runs after the code, or if you roll back.

A change that only satisfies one direction is a change you cannot reverse.

API and message formats

Where services talk to each other, or to queues.

A queue is the sharp case. A new version publishes messages in a new format; consumers still on the old version cannot parse them. Unlike an HTTP call, a message persists — a malformed message sits in the queue being retried, failing, and possibly blocking everything behind it long after the rollout finished.

Removed API fields break consumers you did not deploy. Adding a field is usually safe; removing or renaming one is not, and the consumer may be a different team’s service on a different release cycle.

Session affinity and shared session state

A user whose session was created by the old version and whose next request lands on a new-version host.

If sessions live in a shared store — Redis, a database, signed cookies — the two versions must agree on the format. A new version that adds a required field to the session object will reject sessions created by the old one, and the user is silently logged out mid-checkout.

Sticky sessions reduce the exposure and do not remove it: your rollout restarts the host the user was stuck to.

Shared caches

Two versions writing different structures under the same cache keys is a mutual corruption problem. The old version reads a new-format entry and misinterprets it, or crashes on it.

Cache key namespacing by release version solves it cleanly and costs a cold cache at each deploy — which is itself a capacity event worth planning for on a heavily cached service.

Expand and contract

The pattern that makes a breaking change rollable. It converts one incompatible change into three compatible deployments.

Renaming email to email_address:

flowchart TD
  A["Release 1 — EXPAND<br/>Add email_address.<br/>Write to both columns,<br/>read from email."]
  B["Backfill<br/>Copy email into email_address<br/>for existing rows."]
  C["Release 2 — MIGRATE<br/>Read from email_address.<br/>Still write to both."]
  D["Release 3 — CONTRACT<br/>Stop writing email.<br/>Drop the column."]
  A --> B --> C --> D
  A -.- N1["safe: the pre-release code<br/>still reads email"]
  C -.- N2["safe: rolling back to<br/>Release 1 still works"]
  D -.- N3["safe only once every host<br/>runs Release 2"]

At every point, the version before and the version after can both run. That is the property that makes each of the three deployments a normal rolling deploy with no special handling.

The cost is honest and worth stating: three deployment cycles instead of one, a period where the application writes the same data twice, and the discipline to actually perform the contract step rather than leaving the expand state in place forever. Repositories accumulate half-finished expand-and-contract migrations, and each one is a column nobody dares touch.

The alternative to paying that cost is a maintenance window with the service stopped — which is a legitimate engineering choice and should be made deliberately rather than discovered at 03:00.

Deciding before you start

The question belongs in review, not in the incident.

Read-only / Safemaking the precondition explicit in the playbook
- name: Confirm this release is safe to roll
hosts: localhost
gather_facts: false
tasks:
  - name: Refuse to roll a release that has not been assessed for skew
    ansible.builtin.assert:
      that:
        - skew_assessed | default(false) | bool
        - skew_compatible | default(false) | bool
      fail_msg: >-
        Release {{ release_version }} has not been declared skew-compatible.
        During a rolling deploy the previous release runs alongside it.
        If they cannot coexist, use a maintenance window instead.
        Set skew_assessed and skew_compatible in the release vars once
        the schema, API, session and cache compatibility have been checked.
      success_msg: >-
        Release {{ release_version }} declared skew-compatible; rolling.

When to say no

Sometimes the answer is that a rolling deployment is the wrong pattern, and the lesson is to say so rather than to roll carefully and hope.

The versions genuinely cannot coexist, and expand-and-contract is not available — a third-party component, a binary format change, a protocol with no versioning. A maintenance window with the service stopped is shorter, more predictable, and reversible.

The migration is irreversible. If you cannot go back, rolling gains you nothing: the ability to stop halfway is the whole value of the pattern, and stopping halfway through an irreversible change leaves you in the worst state available.

Consistency matters more than availability. For a clustered system where members must agree — a quorum configuration, a replication topology — a split fleet is not degraded, it is broken. That is what any_errors_fatal is for, and it usually pairs with a window rather than a rollout.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A release renames a database column and the migration runs before the rolling deploy. Every health check passes and the run exits 0, but the application error rate rises throughout. What happened?

  2. Q2. Which schema change is safe to apply immediately before a rolling deployment of code that uses it?

  3. Q3. Which are true of the expand-and-contract pattern? Select all that apply.

  4. Q4. For a change whose versions genuinely cannot coexist, a bounded maintenance window is usually a better choice than a careful rolling deployment.

Passing score: 75%. Answers are checked in this browser.