AnsibleXXXII · Rolling DeploymentsRolling Deployments
Restart, reload and handler timing
What you'll learn
- Decide whether a change can be applied by reload or requires a restart
- Explain why handlers rather than a plain restart task belong in a rolling play
- Place meta: flush_handlers so the restart precedes the health check
- Describe what systemd reloaded does when the unit is not running
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
Writing the file changes nothing. The running process is still holding whatever it read at startup, and it will keep holding it until something makes it read again.
Two things can make it read again, and they are not interchangeable.
Reload versus restart
Reload asks the running process to re-read its configuration. The process ID does not change, open connections survive, in-memory state survives, and there is no gap in service.
Restart stops the process and starts a new one. Connections are dropped, in-memory state is lost, and there is a window — usually short, occasionally not — where the service is not answering.
In a rolling deployment the host is drained, so the disruption of a restart is contained. That is exactly why the pattern drains first. But “contained” is not “free”: a restart costs the startup time of the service on every host in the fleet, and where a reload would have done, that time is pure waste.
What a reload cannot do
The distinction is not stylistic — a reload genuinely cannot apply certain changes, and applying it anyway leaves the process running the old behaviour while every task reports success.
| Change | Reload enough? |
|---|---|
| A value in the application configuration file | usually yes |
| A new virtual host or backend definition | usually yes |
| TLS certificate replacement | usually yes, if the server supports it |
| A new binary or package version | no — restart |
| A change to the unit file itself | no — daemon_reload then restart |
| An environment variable the process reads at startup | no — restart |
| A listening port or socket change | usually no — restart |
| A library the process links against | no — restart |
The row that matters in a deployment is the first bold one. You are deploying new code. A reload does not replace a running binary, so a rolling deployment of a new application version is a restart, always. Reload is for the configuration-only changes that a convergence run makes.
reloaded starts a stopped service
Worth knowing because it changes the failure mode. The
ansible.builtin.systemd_service documentation for state says:
started/stoppedare idempotent actions that will not run commands unless necessary.restartedwill always bounce the unit.reloadedwill always reload and if the service is not running at the moment of the reload, it is started.
So state: reloaded on a service that has crashed will start it.
That is usually convenient and occasionally hides a problem: a service
that has been down since the last deploy comes back during this one, and
nothing in the output distinguishes “reloaded a healthy service” from
“quietly resurrected a service that had been dead for a week”.
Note also that reloaded and restarted always report changed
— they are not idempotent actions in the way started is. That is
correct behaviour for a handler, which only fires when something
actually changed, and it is a reason not to put them in an ordinary
task.
Why handlers, not a restart task
The temptation in a rolling play is to write the restart as a plain task, because the ordering is then obvious on the page.
# Avoid this in a rolling play.
- name: Restart the application
ansible.builtin.systemd_service:
name: app
state: restartedIt runs unconditionally. A rolling play executed against a fleet that is
already at the target version — a re-run, a convergence pass, a CI
verification — restarts every service in the fleet for no reason.
state: restarted “will always bounce the unit”, so there is no
idempotence to fall back on.
The handler form fires only when something changed:
- name: Install the release
ansible.builtin.unarchive:
src: 'app-{{ release_version }}.tar.gz'
dest: /opt/app
owner: app
group: app
notify: Restart app
- name: Apply the restart now
ansible.builtin.meta: flush_handlers
- name: Assert the service is serving the new version
ansible.builtin.uri:
url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
return_content: true
status_code: 200
register: health
retries: 12
delay: 5
until:
- health.status | default(0) == 200
- health.json.version | default('') == release_version
changed_when: false
handlers:
- name: Restart app
ansible.builtin.systemd_service:
name: app
state: restartedYou get change-gating and correct ordering from the same construct, which is the argument for handlers in a nutshell.
The flush has to be explicit
Part XXXI established that handlers flush at the end of every batch. That is what makes a rolling restart possible at all — and inside the batch, it is too late.
Without the explicit meta: flush_handlers, the order is:
- Install the release. Handler notified, not run.
- Health check — against a process still running the old code.
- End of batch. Handler runs. Service restarts.
- Next batch begins.
Step 2 is the problem. The health check passes, because the old version is running perfectly well. It asserts nothing about the thing you just deployed, and the rollout proceeds to the next batch having verified the previous release.
Worse, if you took the advice from the health-check lesson and assert on the version string, the check now fails on every host — the process reports the old version because it has not restarted yet. Either way the gate is wrong.
Restart, then wait for the thing to actually be back
systemd_service returns when systemd reports the unit started. That is
not the same as the application being ready to serve — systemd considers
a Type=simple unit started the moment it has forked the process,
which for a JVM or a large Python application may be tens of seconds
before it can answer a request.
This is why the health check needs retries and delay rather than
running once. It is also why a wait_for on the port is a reasonable
step between the restart and the correctness check for slow-starting
services:
- name: Wait for the application to bind its port
ansible.builtin.wait_for:
host: '{{ ansible_host | default(inventory_hostname) }}'
port: 8080
state: started
timeout: 60
- name: Assert it is serving the new version
ansible.builtin.uri:
url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
return_content: true
register: health
retries: 12
delay: 5
until: health.json.version | default('') == release_version
changed_when: falseThe diagnostic value is the reason to do both. A failure at the
wait_for says “the service never started”. A failure at the uri says
“the service started and is wrong”. Those lead to different
investigations, and a single combined check would report them
identically.
Knowledge check
Knowledge check · 4 questions
Q1. A rolling play installs a new application binary and notifies a handler that runs systemd_service with state: reloaded. What is the likely outcome?
Q2. Why does a rolling play need meta: flush_handlers before its health check, given that handlers already flush once per batch?
Q3. Which changes cannot be applied by a reload and require a restart? Select all that apply.
Q4. meta: flush_handlers runs every handler currently notified, not only the one most recently notified.
Passing score: 75%. Answers are checked in this browser.