Skip to main content
RunBook Academy

AnsibleXXXII · Rolling DeploymentsRolling Deployments

Health checks that actually assert something

Advanced⏱ ~26 minansible-playbook

What you'll learn

  • Distinguish a liveness check from a readiness check from a correctness check
  • Write a uri health gate with until, retries and delay that fails when the service is wrong
  • Assert the deployed version rather than only the status code
  • State why neither uri nor wait_for runs under --check

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.

Everything else in a rolling deployment is machinery for making this one step possible. serial exists so that a health check has a small batch to protect. The failure policy exists so that a failed health check stops something. The drain exists so that the host being checked is not serving users while you check it.

If the health check does not actually assert anything, all of that machinery is running in support of nothing.

Three levels of assertion

Health checks fall into three tiers, and the distance between them is where deployments go wrong.

LevelQuestionTypical checkWhat it misses
Livenessis the port open?wait_for on the porta process that accepts connections and returns 500 to all of them
Readinessis the process serving?uri expecting 200a service serving the old version, or serving from cache
Correctnessis it serving what I deployed?uri asserting version and dependenciesvery little — this is the one worth writing

The first two are the ones people write, because they are short. The third is the one that catches the failure this part exists to prevent.

Liveness: the port is open

Read-only / Safethe weakest useful check
    - name: Wait for the application port to accept connections
    ansible.builtin.wait_for:
      host: '{{ ansible_host | default(inventory_hostname) }}'
      port: 8080
      state: started
      timeout: 60

This catches a service that failed to start. It does not catch a service that started and is broken, which — because the unit file, the systemd dependencies and the port binding all still work — is the more common outcome of a bad configuration change.

A process can bind a port before it is ready to serve, so this check can even pass before the service is usable.

Readiness: it answers

Read-only / Safethe check most people stop at
    - name: Wait for the health endpoint to return 200
    ansible.builtin.uri:
      url: 'http://{{ inventory_hostname }}:8080/healthz'
      status_code: 200
    register: health
    retries: 12
    delay: 5
    until: health.status == 200
    changed_when: false

The until / retries / delay combination is what makes this a gate rather than a coin flip. Without it, the single request lands whenever the task happens to run — often two seconds after a restart, when nothing is ready — and the task fails on a service that would have been fine four seconds later.

With retries: 12 and delay: 5 it will keep asking for a minute before giving up. A service that never comes back fails the task; a service that takes twenty seconds passes at the fourth attempt.

Correctness: it is serving what you deployed

Read-only / Safethe check worth writing
    - name: Assert the service is running the release we just deployed
    ansible.builtin.uri:
      url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
      return_content: true
      status_code: 200
      timeout: 10
    register: health
    retries: 12
    delay: 5
    until:
      - health.status | default(0) == 200
      - health.json.version | default('') == release_version
      - health.json.database | default('') == 'ok'
    changed_when: false
    failed_when:
      - health.status | default(0) != 200
        or health.json.version | default('') != release_version

Every default() in that expression defaults to a value that fails the condition. This is the same rule as the drain check, and it matters more here: if the endpoint returns an error page instead of JSON, health.json.version does not exist, and default('') makes the comparison false rather than raising an undefined-variable error that might be caught somewhere unhelpful.

Health checks that are not HTTP

Not every service answers HTTP, and the same tiering applies.

Read-only / Safeasserting on a log line
    - name: Wait for the service to log that it finished starting
    ansible.builtin.wait_for:
      path: /var/log/app/app.log
      search_regex: 'listening on :8080 version={{ release_version | regex_escape }}'
      timeout: 90

regex_escape matters — a version string containing a . is otherwise a regex wildcard, and 1.2.3 would match 1x2x3. It is a small thing that turns an exact assertion into a fuzzy one.

For a service with a command-line health tool:

Read-only / Safeasserting via the service own tooling
    - name: Wait for the cluster member to report healthy
    ansible.builtin.command: /usr/local/bin/appctl health --json
    register: appctl
    changed_when: false
    retries: 20
    delay: 3
    until:
      - appctl.rc == 0
      - (appctl.stdout | from_json).state | default('') == 'healthy'

What --check does not tell you

This is the part that catches careful people, and it is verified from the module documentation itself.

Both of the modules that do the verifying in this pattern report check_mode: support: none:

Read-only / Safemodule check-mode support on ansible-core 2.21.3
$ ansible-doc -t module ansible.builtin.uri | sed -n '/ATTRIBUTES/,/NOTES/p'
ATTRIBUTES:

      check_mode:
      description: Can run in check_mode and return changed status prediction
        without modifying target, if not supported the action will be skipped.
      support: none

      diff_mode:
      ...
      support: none

--- ansible.builtin.wait_for reports the same ---

      check_mode:
      support: none

Read the description carefully: “if not supported the action will be skipped”. Not simulated, not approximated — skipped.

So a --check run of a rolling playbook does this:

StepUnder --check
Drain (command/uri)skipped
Verify drained (wait_for)skipped
Deploy (template, copy)simulated, reports would-change
Restart (handler)simulated
Health check (uri)skipped
Return to serviceskipped

The deployment steps are simulated. Every verification step is skipped.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A rolling deploy health-checks status_code: 200 against / and every host passes, but the site is broken throughout the deployment. What is the most likely cause?

  2. Q2. What does running a rolling playbook with --check tell you about the health check step?

  3. Q3. Which practices make a health check more trustworthy rather than merely more permissive? Select all that apply.

  4. Q4. On ansible-core 2.21.3, a failing task with retries: 4 and no until clause is retried, even though the keyword documentation says retries is only used in combination with until.

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