Skip to main content
RunBook Academy

AnsibleXLI · Service and Application DeploymentService and Application Deployment

Health gates that actually gate

Advanced⏱ ~25 minansible-playbook

What you'll learn

  • Distinguish a gate whose failure stops the run from a monitor whose failure is logged
  • Compute the real worst-case duration of a retried health check including the request timeout
  • Assert on response content and the deployed version rather than on HTTP 200
  • Recognise a health gate that passes because it waited long enough

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.

A health check and a health gate are different things, and the difference is not in the check.

A gate is a check whose failure changes what happens next. A monitor is a check whose failure is recorded somewhere. The same uri task is either one depending on three things around it: whether it can fail, whether the play stops when it does, and whether anything after it depends on it having passed.

Most health checks in deployment roles are monitors. They look identical to gates in review.

The three ways a gate stops being one

Read-only / Safea check that cannot fail
# A monitor. Reads like a gate.
- name: Check the application is healthy
ansible.builtin.uri:
  url: 'http://{{ inventory_hostname }}:8080/healthz'
ignore_errors: true

ignore_errors: true is the obvious one. It is usually added the week after a flaky check failed a run, and it is never removed.

No failure policy on the play. A check that fails one host in a serial: 10 batch, on a play with no max_fail_percentage, stops that host and lets the other nine continue — and the next batch starts on schedule. The check failed and the deployment proceeded.

Placement after the last thing that matters. A health check as the final task of the play, after the last batch, is a report. There is nothing left for it to gate.

The retry arithmetic, which is not what it looks like

until reruns a task until its condition holds. retries and delay govern how many times and how far apart. The upstream defaults are retries: 3 and delay: 5 — small enough that an unset retries on a service that takes twenty seconds to start is a guaranteed failure.

The arithmetic people do is retries × delay. That is the sleeping time, not the elapsed time.

Read-only / Safea health gate with an honest budget
- name: Wait for the application to serve the version we deployed
ansible.builtin.uri:
  url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
  return_content: true
  status_code: [200]
  timeout: 10
register: health
retries: 30
delay: 5
until:
  - health.status | default(0) == 200
  - health.json.version | default('') == myapp_version
  - health.json.database | default('') == 'connected'
changed_when: false

Worst case for that task:

30 attempts × 10s request timeout   = 300s
29 delays    ×  5s                  = 145s
                                    -----
                                      445s  ≈ 7.5 minutes, per host

The naive figure is 150 seconds. The real one is nearly three times that, and it is reached exactly when the service is not responding at all — the case where every attempt burns the full timeout rather than failing fast.

Two mechanical details worth knowing:

The registered variable gains an attempts key recording how many tries the task took. Logging it turns “the deployment felt slow tonight” into a number, and a service whose attempts has been creeping up over months is telling you something before it fails.

Since ansible-core 2.16, retries works without until — the task retries until it succeeds, at most retries times. That is useful for a genuinely flaky operation and is not what a health gate wants: a health gate should express its success condition explicitly, so the run log says what was being waited for.

wait_for on a port is not a readiness check

wait_for polls a TCP port and succeeds when something accepts a connection. That proves a process called listen(). It proves nothing about whether that process can serve a request.

The gap is not theoretical. A JVM application binds its port during start-up and then spends forty seconds initialising; a service with a cold connection pool accepts connections and returns errors; a process that crashed and was restarted by systemd binds the port immediately and fails every request.

Read-only / Safethe port check as a cheap first stage, not the gate
- name: Wait for the listener to appear
ansible.builtin.wait_for:
  host: '{{ ansible_host | default(inventory_hostname) }}'
  port: 8080
  state: started
  timeout: 60

- name: Wait for the application to report itself ready
ansible.builtin.uri:
  url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/readyz'
  return_content: true
register: ready
retries: 20
delay: 3
until: ready.json.status | default('') == 'ready'
changed_when: false

Note the explicit host:. wait_for defaults host to 127.0.0.1, which means an unqualified wait_for polls a port on the host the task is running on — and under delegate_to: localhost it polls the controller. That default is correct for its commonest use and wrong for the one people write first.

Assert on content, not on 200

An HTTP 200 from a health endpoint means a handler returned. In a large share of real applications, that handler is a static return 200 that knows nothing about the state of anything.

Read-only / Safewhat to assert on instead
  until:
  - health.status | default(0) == 200
  - health.json.version | default('') == myapp_version
  - health.json.database | default('') == 'connected'
  - health.json.migrations_pending | default(1) | int == 0
  - health.json.queue_depth | default(999999) | int < 1000

Every default() there is chosen to fail the condition. That is the discipline: default('') compared against a real value is false; default(1) compared against 0 is false; default(999999) fails a threshold. A field the endpoint stopped reporting must not read as good news.

The version condition is the one to add first if a role has only one. It is what distinguishes “the service is healthy” from “the service is healthy and is running what this play deployed” — and the second is the question a deployment is actually asking.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A health check task has retries: 30, delay: 5 and the uri module with timeout: 10. What is its worst-case duration on one host?

  2. Q2. What distinguishes a health gate from a health monitor?

  3. Q3. Which criticisms of a health gate written as "wait_for port 8080, then assert status 200" are valid? Select all that apply.

  4. Q4. Setting retries: 200 with delay: 10 makes a health gate more robust, because it tolerates any legitimate slow start.

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