Skip to main content
RunBook Academy

← All runbooks in Ansible

high riskservice affecting~120 min

Runbook: Perform a canary deployment

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The version being deployed and the version to roll back to are both written down, by exact identifier
  • · The canary host is named, and it is a host that receives real traffic
  • · The health check is defined and has been proven to FAIL against a deliberately broken instance
  • · The stop rule is agreed in advance: what observation ends the rollout, and who may call it
  • · The hold duration between the canary and the first batch is agreed
  • · Total host count and batch sizes are known, and --list-hosts has been run against the exact command that will deploy
  • · The rollback path has been tested on staging within the current release cycle

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Capture the pre-deployment baseline: version in place, error rate, latency, request rate
  2. 2Confirm the health check fails when it should, using a host you break on purpose in staging
  3. 3Deploy to the canary only, with an explicit --limit, after running --list-hosts on that exact command
  4. 4Verify the canary against the health check and against real traffic, not against a port
  5. 5Hold for the agreed duration, watching error rate and latency against the baseline
  6. 6Decide at the gate: proceed, hold longer, or roll back - and record which and why
  7. 7Widen to the first batch with serial and max_fail_percentage set so a bad batch stops the play
  8. 8Verify after each batch before the next begins; the play should gate itself rather than relying on you watching
  9. 9Complete the rollout, then verify fleet-wide version consistency
  10. 10Return the canary to the standard configuration if it was treated specially

4 · Verification

Confirm the procedure actually fixed the problem.

  • The health check demonstrably fails against a broken instance - a check that cannot fail proves nothing
  • The canary reports the new version, and the version is read from the running service not from the package
  • Error rate and latency on the canary are within the agreed threshold of the baseline over the full hold
  • After each batch, every host in that batch answers a real request
  • At the end, every host in the group reports the new version and no host was skipped
  • The recap host list matches the --list-hosts output taken before the run

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • Rollback is per batch and is the reason batches exist - the smaller the batch, the cheaper the reversal
  • Canary only: redeploy the previous version to that host and confirm the health check passes
  • Mid-rollout: stop the play, deploy the previous version to every host that received the new one, working from the run log
  • Do not roll back by re-running the deployment playbook with an old branch checked out unless that branch is the recorded rollback version
  • POINT OF NO RETURN: a deployment that ran a schema migration, a data conversion or an irreversible external call cannot be rolled back by redeploying the previous version - identify these before starting and treat the rollout as forward-only
  • After any rollback, verify version consistency across the whole group; a partially rolled back fleet is worse than either state

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate at the canary gate if the observation is ambiguous - an unclear canary is a stop, and the decision to proceed anyway belongs to the service owner
  • · Escalate immediately if the deployment includes an irreversible step and something has gone wrong after it
  • · Escalate if hosts were skipped mid-rollout and their state is unknown; do not continue widening over an unknown
  • · Escalate to the load balancer owner if hosts cannot be drained before deployment; deploying to an in-rotation host is a decision, not a default

A canary deployment is a bet that one host tells you what forty will do. The bet only pays if two conditions hold: the canary carries real traffic, and you have decided in advance what observation would make you stop.

Without the first, the canary proves the software starts. Without the second, the canary becomes a formality - it went green, everyone proceeds, and the thing that would have been visible in the error rate was never anybody’s job to look at.

When to use this runbook

  • Deploying a new application version to a group of servers.
  • Rolling out a configuration change with a plausible failure mode.
  • Any change where “it worked in staging” is true and insufficient.

Blast radius

Stated as a sequence, because that is the point:

canary        1 host      real traffic, reversible in minutes
batch 1       25%         reversible, but ten hosts to reverse
batch 2       25%         half the fleet is now on the new version
batch 3       25%
batch 4       25%         complete

The number to write in the change record is not “the web group”. It is hosts (40), taken from --list-hosts on the exact command you will run.

Inputs

  • The version identifier being deployed, and the one being replaced.
  • The canary hostname.
  • The health check, and evidence it can fail.
  • The stop rule, agreed with the service owner.
  • Baseline metrics: error rate, p95 latency, request rate.

Step 1: Baseline

Read-only / Safecapture the baseline
# Version currently running, read from the service
ansible web -m uri -o \
-a 'url=http://{{ ansible_host }}:8080/version return_content=true' \
| tee baseline-versions.txt

# Metrics, from wherever they live
curl -sS 'http://metrics.example.com/api/v1/query?query=error_rate_5m' \
> baseline-errors.json

Record the numbers, not the impression. “Latency looked normal” is not something you can compare against forty minutes later, and forty minutes later is when you need to.

Step 2: Prove the health check can fail

This is the step that distinguishes a canary from a ceremony.

Read-only / Safethe health check
- name: Application answers correctly
ansible.builtin.uri:
  url: "http://{{ ansible_host }}:8080/healthz"
  status_code: 200
  return_content: true
  timeout: 10
register: health
retries: 6
delay: 10
until: health.status == 200 and 'ok' in health.content

Now break something in staging on purpose - stop the upstream, corrupt the config, point it at a dead database - and run the check. It must fail.

Step 3: Deploy to the canary

Read-only / Safeconfirm the target first
ansible-playbook -i inventories/production deploy.yml \
--limit web01.example.com \
-e app_version=2.4.0 \
--list-hosts

Run --list-hosts on the exact command, including every flag. It is two seconds and it is the only thing between a mistyped limit and a fleet-wide deployment.

Service impact possibledeploy the canary
ansible-playbook -i inventories/production deploy.yml \
--limit web01.example.com \
-e app_version=2.4.0 \
--diff | tee "canary-deploy-$(date -u +%Y%m%dT%H%M%SZ).log"

If the host is behind a load balancer, drain it first and return it afterwards. Deploying to an in-rotation host means users see the restart, and it also means your canary observation is contaminated by the deployment itself.

Service impact possibledrain, deploy, return
- name: Canary deployment
hosts: canary
become: true
tasks:
  - name: Remove from the load balancer pool
    ansible.builtin.uri:
      url: "https://lb.example.com/api/pool/web/{{ inventory_hostname }}/drain"
      method: POST
      status_code: [200, 204]
    delegate_to: localhost

  - name: Wait for connections to finish
    ansible.builtin.wait_for:
      timeout: 30
    delegate_to: localhost

  - name: Deploy the application
    ansible.builtin.include_role:
      name: app_deploy

  - name: Application answers correctly before it takes traffic
    ansible.builtin.uri:
      url: "http://{{ ansible_host }}:8080/healthz"
      status_code: 200
      return_content: true
    register: health
    retries: 6
    delay: 10
    until: health.status == 200 and app_version in health.content

  - name: Return to the load balancer pool
    ansible.builtin.uri:
      url: "https://lb.example.com/api/pool/web/{{ inventory_hostname }}/enable"
      method: POST
      status_code: [200, 204]
    delegate_to: localhost

The order matters: the health check sits between the deployment and the return to the pool. A host that fails the check never takes traffic, and the play fails there rather than putting a broken instance back into rotation.

Step 4: Verify the canary

Read-only / Safeverify
# Version, read from the running service
curl -sS http://192.0.2.11:8080/version

# Real request, with timing
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' \
http://192.0.2.11:8080/api/orders

# Errors since the deployment
ansible web01.example.com -b -m command \
-a 'journalctl -u app --since "10 min ago" --no-pager -p err' -o

Read the version from the service, not from the package manager. A package can be installed while the old process keeps running, which is the exact failure a canary is supposed to catch and the exact failure rpm -q reports as success.

Step 5: Hold, and watch against the baseline

The hold is where the canary earns its name. Watch:

  • Error rate compared with the baseline, on this host specifically. A one-in-forty host contributes 2.5% of fleet traffic; a fleet-level error rate graph will not show it.
  • Latency, same reason.
  • Log volume. A large increase in log lines is often the first sign of a retry loop that has not failed yet.
  • Memory, if the change could plausibly leak. This is the class of problem that only appears after the hold, which is why the hold has a duration rather than being “until it looks fine”.

Duration comes from the traffic pattern: long enough that the service has done its normal range of work at least once. For a request-serving tier that is often thirty to sixty minutes. For something with a nightly batch, the honest answer is a day.

Step 6: The gate

Three outcomes, decided against the stop rule agreed before you started:

ObservationDecision
Metrics within threshold, health check green, logs cleanProceed to batch 1
Anything outside thresholdRoll back the canary; do not widen
Ambiguous - a small increase, a new log line nobody recognisesHold longer or stop. Ambiguity is not a pass

Record the decision and the numbers behind it. The value of writing it down is not the audit trail; it is that a decision you have to write is a decision you have to actually make.

Step 7: Widen in batches, with the play gating itself

Service impact possiblebatched rollout
- name: Rolling deployment
hosts: web
become: true
serial:
  - 1
  - "25%"
max_fail_percentage: 0
tasks:
  - name: Deploy the application
    ansible.builtin.include_role:
      name: app_deploy

  - name: Host answers correctly before the batch is considered done
    ansible.builtin.uri:
      url: "http://{{ ansible_host }}:8080/healthz"
      status_code: 200
      return_content: true
    register: health
    retries: 6
    delay: 10
    until: health.status == 200 and app_version in health.content

max_fail_percentage: 0 means any failure in a batch ends the play, and subsequent batches never start. That is the automatic version of the gate, and it works when nobody is watching.

Verified batch arithmetic on 2.21.3: a percentage that does not divide evenly rounds down to whole hosts with a minimum of one. serial: [1, 2, "30%"] on six hosts produced batches of 1, 2, 1, 1, 1 - five batches, not three. Read the batch boundaries in the output; do not calculate them in your head and assume.

Also verified: run_once: true under serial runs once per batch, not once per play. A “notify the channel that the rollout started” task marked run_once will fire on every batch.

Step 8: Verify the whole group

Read-only / Safefleet-wide version check
ansible web -m uri -o \
-a 'url=http://{{ ansible_host }}:8080/version return_content=true' \
| tee final-versions.txt

grep -c '2.4.0' final-versions.txt
ansible-playbook -i inventories/production deploy.yml --limit web --list-hosts \
| grep -c 'example.com'

Those two counts must match. A host on the old version at the end of a rollout is version skew, and version skew is the state where two hosts behind the same load balancer answer the same request differently - which produces intermittent, unreproducible failures that nobody attributes to the deployment.

Rollback

Rollback is per batch, which is the entire reason batches exist.

Service impact possibleroll back the canary
ansible-playbook -i inventories/production deploy.yml \
--limit web01.example.com \
-e app_version=2.3.7 \
--diff

curl -sS http://192.0.2.11:8080/version   # must report 2.3.7
Service impact possibleroll back mid-rollout
# The hosts that received the new version, from the run log
grep -E 'ok:|changed:' "canary-deploy-*.log" \
| awk -F'[][]' '{print $2}' | sort -u > deployed-hosts.txt

LIMIT=$(paste -sd, deployed-hosts.txt)
ansible-playbook -i inventories/production deploy.yml \
--limit "$LIMIT" -e app_version=2.3.7 --list-hosts
ansible-playbook -i inventories/production deploy.yml \
--limit "$LIMIT" -e app_version=2.3.7 --diff

Roll back by deploying the recorded previous version, not by checking out an old branch and re-running. Those are different things: the branch may contain other changes, and “the state before” is a version identifier, not a point in Git history.

After any rollback, verify version consistency across the whole group. A fleet half on 2.4.0 and half on 2.3.7 is worse than either, and it is the state a panicked partial rollback produces.

Common patterns

SymptomLikely causeResolution
Canary green, fleet breaks at 50%The canary was not carrying real traffic, or the fault is load-dependentCanary must be in rotation; extend the hold
Health check passes on a broken hostPort check, or no assertion on the bodyAssert on content the new working version produces
Rollout stopped, unclear what was deployedUntouched hosts are absent from the recapDiff the recap against --list-hosts taken before the run
Batches were not the size expectedPercentage rounds down to whole hosts, minimum oneRead the batch boundaries in the output
A run_once notification fired repeatedlyrun_once is per batch under serialMove it to a separate play, or gate it on ansible_play_batch
Version check passes, service runs old codeThe package was updated but the process was not restartedRead the version from the running service
Rollback restored the code, service still brokenThe deployment included an irreversible stepForward fix; escalate

Escalation

Escalate when:

  • The canary observation is ambiguous. Ambiguity is a stop, and overriding a stop is the service owner’s call.
  • Something went wrong after an irreversible step.
  • Hosts were skipped and their state is unknown.
  • Hosts cannot be drained before deployment.

References

  1. Controlling playbook execution: strategies and more
  2. Error handling in playbooks
  3. ansible.builtin.uri module