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.
Level
Question
Typical check
What it misses
Liveness
is the port open?
wait_for on the port
a process that accepts connections and returns 500 to all of them
Readiness
is the process serving?
uri expecting 200
a service serving the old version, or serving from cache
Correctness
is it serving what I deployed?
uri asserting version and dependencies
very 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— Read-only. Confirms something is listening. Fails if the service did not start at all, which is a real class of failure and not the interesting one.
- 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— Read-only: a GET request, marked changed_when: false. Retries because a service that has just restarted needs a moment. Better than a port check and still not sufficient.
- 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— Read-only. Asserts the running service reports the version just deployed and that its dependencies are healthy. A cached response cannot satisfy this, and neither can a service still running the previous release.
- 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— Read-only: wait_for reads the file and matches a pattern. Useful for services whose readiness is only expressed in their log.
- 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— Read-only: the health subcommand reports state without changing it, and changed_when: false keeps it out of the change signal.
- 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— Read-only introspection of the module documentation. support: none means the action is skipped in check mode, not simulated.
$ 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:
Step
Under --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 service
skipped
The deployment steps are simulated. Every verification step is skipped.
Knowledge check
Knowledge check · 4 questions
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?
Q2. What does running a rolling playbook with --check tell you about the health check step?
Q3. Which practices make a health check more trustworthy rather than merely more permissive? Select all that apply.
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.