Capstone lab 3 of 4. It operates the estate built in capstone lab 1 and deployed in capstone lab 2. The service must be running and healthy before you start.
Objective
By the end you will have taken one version bump from staging to production through a canary and a batched rollout, absorbed a deliberate mid-fleet failure without improvising, and produced a written assessment and an audit record that together answer the question every incident review asks: which hosts changed, and to what?
The command sequences in this lab are the easy part. The deliverable is the judgement: at every stage, what will change, on which hosts, how is it validated, and how is it undone.
Architecture
The same estate, now being changed while it is serving.
client traffic
│
┌────▼────┐
│ lb01 │ drain/ready via admin socket
└────┬────┘
┌───────────┼───────────┐
▼ ▼ ▼
app01 app02 app03
──────────────────────────────────
batch 1 batch 2 batch 2 serial: [1, 2]
canary ← failure injected here
The rollout is serial: [1, 2]: one host, then the remaining two. The
first batch is the canary. If it fails, the play stops and two hosts have
never been touched.
Capacity arithmetic, which belongs in the change record and not in your head: three hosts serving, one draining, leaves 67 per cent of capacity. Two draining leaves 33 per cent. That is why the second batch is two and not three, and it is a decision about your traffic, not a default.
Requirements
- Capstone labs 1 and 2 complete.
playbooks/health.ymlmust currently pass against production. ansible-core2.21.x andcommunity.generalfor thehaproxymodule.- Six VMs with systemd as PID 1.
B-nestedonly. This lab measures real service interruption across real restarts; a simulated restart measures nothing. - A second terminal on the controller, for the traffic generator in Task 5. The measurement is the point of that task.
- Roughly 4 hours. Do not start Task 7 with less than 90 minutes left — the assessment is the part that takes thought, and rushing it defeats the exercise.
Scenario
Version 1.1.0 of the application is ready. The change is small — the
health endpoint gains a field — and the previous three deployments of
this application were done by running site.yml against appservers
with no batching, which worked twice and caused a nine-minute outage the
third time.
Your job is to deploy 1.1.0 without an outage, and to be able to prove afterwards exactly what happened.
Tasks
Task 1: The second-run test
Before changing anything, establish that the current playbooks are
honest. Run site.yml against production twice with no modifications
between the runs.
$ cd "$HOME/estate"
ansible-playbook -i inventories/production/hosts.yml \
playbooks/site.yml --limit estate \
| tee reports/idempotency-run1.txt$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/site.yml --limit estate \
| tee reports/idempotency-run2.txt
grep -E 'changed=[1-9]' reports/idempotency-run2.txt \
|| echo 'IDEMPOTENT: second run changed nothing'PLAY RECAP *********************************************************************
app01 : ok=14 changed=0 unreachable=0 failed=0 skipped=1
app02 : ok=14 changed=0 unreachable=0 failed=0 skipped=1
app03 : ok=14 changed=0 unreachable=0 failed=0 skipped=1
db01 : ok=11 changed=0 unreachable=0 failed=0 skipped=0
lb01 : ok=7 changed=0 unreachable=0 failed=0 skipped=0
IDEMPOTENT: second run changed nothingIllustrative output
The second run carries a SERVICE-IMPACT badge and not READ-ONLY
because you do not yet know it will change nothing. That is the whole
point of running it.
If any host reports a change, find the task and fix it before going further. The three that commonly do, and what each one really is:
| Task reports changed every run | What it actually is | Fix |
|---|---|---|
A command with no changed_when | Ansible cannot know, so it assumes yes | Add changed_when: comparing before and after, or changed_when: false for a read |
A template whose output contains a timestamp | The file genuinely differs each render | Remove the timestamp, or move it to a file that is not managed |
A copy with content: ending without a newline | The file on disk gains one; the comparison never matches | End the content with \n |
Task 2: The change record and the rollback plan, written first
The rollback is planned before the change, not discovered during it.
Write docs/change-2026-08-12-app-110.md now:
CHANGE: estate-app 1.0.0 -> 1.1.0
WHAT CHANGES
inventories/production/group_vars/all/main.yml app_version: 1.1.0
On each app host: /opt/estate-app/app.py re-rendered, service restarted.
Nothing on db01. Nothing on lb01 except backend state during drain.
WHICH HOSTS
app01, app02, app03. Three of five. 100% of the application tier.
db01 and lb01 are NOT in scope and must not appear in any --limit.
BLAST RADIUS (from docs/blast-radius.md)
appservers = 3 hosts. Losing one = 33% capacity. Losing two = 67%.
Batching: serial [1, 2]. Never more than one host out of service at a
time in batch 1; never more than two in batch 2.
VALIDATION AT EACH STAGE
Per host, before return to service:
- systemd reports estate-app active
- GET /health on the host returns 200
- the JSON version field equals 1.1.0
After the last host:
- playbooks/health.yml passes for the whole estate
- all three backends report UP on the proxy
ABORT CRITERIA — decided now, not during the incident
Abort if: the canary host fails its health gate;
the proxy reports two or more backends DOWN at once;
any host takes longer than 120s to return 200.
ROLLBACK
Mechanism: re-run the deploy with app_version 1.0.0.
Command: HOSTS=appservers # narrow this to the affected hosts
ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml -e app_version=1.0.0 --limit "$HOSTS"
Time: ~40s per host, same drain and health gate.
Tested: yes — see reports/rollback-drill.txt (Task 8).
NOT covered by this rollback: any database schema change. There is
none in 1.1.0. If there were, the rollback would not be symmetric and
this section would say so.
WHO KNOWS
Change ticket CHG-0000 raised. Proxy logs retained for the window.
Task 3: The deploy playbook
site.yml builds the estate. Deployment is a narrower operation and
deserves its own entry point, because the safety controls differ.
# playbooks/deploy.yml
- name: Rolling application deployment
hosts: appservers
become: true
gather_facts: true
serial:
- 1
- 2
max_fail_percentage: 0
vars:
drain_settle_seconds: 5
health_retries: 12
health_delay: 5
proxy_host: "{{ groups['loadbalancers'][0] }}"
pre_tasks:
- name: Guardrail
ansible.builtin.import_tasks: guard.yml
- name: Record the version this host is running before the change
ansible.builtin.uri:
url: "http://{{ ansible_host }}:{{ app_port }}{{ app_health_path }}"
status_code: [200, 503]
return_content: true
timeout: 5
register: before
delegate_to: localhost
become: false
- name: Remember it
ansible.builtin.set_fact:
version_before: "{{ (before.content | from_json).version }}"
tasks:
- name: Drain this host from the proxy
community.general.haproxy:
state: drain
host: "{{ inventory_hostname }}"
backend: "{{ lb_backend_name }}"
socket: "{{ lb_admin_socket }}"
wait: true
wait_interval: 1
wait_retries: 30
delegate_to: "{{ proxy_host }}"
become: true
- name: Let in-flight requests finish
ansible.builtin.wait_for:
timeout: "{{ drain_settle_seconds }}"
delegate_to: localhost
become: false
- name: Apply the application role at the new version
ansible.builtin.include_role:
name: estate_app
- name: Wait for the port to accept connections again
ansible.builtin.wait_for:
host: "{{ ansible_host }}"
port: "{{ app_port }}"
state: started
timeout: 60
delegate_to: localhost
become: false
- name: Health gate on the host itself, before it sees traffic
ansible.builtin.uri:
url: "http://{{ ansible_host }}:{{ app_port }}{{ app_health_path }}"
status_code: 200
return_content: true
timeout: 5
register: after
retries: "{{ health_retries }}"
delay: "{{ health_delay }}"
until: after.status == 200
delegate_to: localhost
become: false
- name: Confirm it is serving the version we deployed
ansible.builtin.assert:
that: (after.content | from_json).version == app_version
fail_msg: >-
{{ inventory_hostname }} answers 200 but reports version
{{ (after.content | from_json).version }}, not {{ app_version }}.
The file was written and the process was not replaced. Do not
return this host to service.
success_msg: >-
{{ inventory_hostname }}: {{ version_before }} -> {{ app_version }}
- name: Return the host to service
community.general.haproxy:
state: enabled
host: "{{ inventory_hostname }}"
backend: "{{ lb_backend_name }}"
socket: "{{ lb_admin_socket }}"
wait: true
wait_interval: 1
wait_retries: 30
delegate_to: "{{ proxy_host }}"
become: true
- name: Write the per-host audit record
ansible.builtin.copy:
content: |
host: {{ inventory_hostname }}
at: {{ lookup('pipe', 'date -Is') }}
version_before: {{ version_before }}
version_after: {{ (after.content | from_json).version }}
batch: {{ ansible_play_batch | join(',') }}
run_by: {{ lookup('env', 'USER') }}
dest: "{{ playbook_dir }}/../reports/deploy-{{ inventory_hostname }}.yml"
mode: '0644'
delegate_to: localhost
become: false
Two keywords do the safety work, and both were checked against
ansible-core 2.21.3 rather than assumed.
serial: [1, 2] splits the play into batches of one host and then two.
The play is re-entered per batch — you will see PLAY [Rolling application deployment] printed three times for five hosts, or twice for
three.
max_fail_percentage: 0 aborts the play when more than zero per cent
of a batch fails. The comparison is strictly greater-than, which is worth
proving to yourself:
$ # 1 host of 5 fails, max_fail_percentage: 20 -> 20 is not > 20 -> CONTINUES
# 1 host of 5 fails, max_fail_percentage: 0 -> 20 is > 0 -> NO MORE HOSTS LEFT
# 1 host of 3 fails, max_fail_percentage: 20 -> 33 is > 20 -> aborts
echo 'max_fail_percentage aborts when the failed percentage EXCEEDS it.'Task 4: Staging first, and what staging cannot prove
Bump the version in staging and run the deploy.
$ cd "$HOME/estate"
ansible-playbook -i inventories/staging/hosts.yml \
playbooks/deploy.yml -e app_version=1.1.0 \
| tee reports/staging-deploy-110.txtIt passes. Now write down what it did not tell you — this is the second deliverable and it is the one most teams skip.
docs/promotion-record-110.md
STAGING RUN: reports/staging-deploy-110.txt — PASSED
WHAT STAGING PROVED
- The template renders with the new version and compiles.
- The unit restarts and the service comes back.
- /health returns 200 and reports 1.1.0.
- The drain and enable calls against the admin socket work.
WHAT STAGING COULD NOT PROVE
- Batching. staging has ONE host in appservers, so serial [1, 2]
produced a single batch of one. The second batch was never
exercised, so a bug that only appears with two hosts in a batch —
a shared resource, a lock, a race on the proxy socket — is
undetected.
- Capacity during drain. Draining the only host removed 100% of
staging capacity; nothing measured what draining one of three does.
- Cross-host version skew. In production, app01 runs 1.1.0 while
app02 and app03 still run 1.0.0 for the length of the rollout. On
staging that state cannot exist. If 1.1.0 changed a shared data
format, staging would never see the incompatibility.
- Real proxy behaviour. staging's proxy backend has one server, so
"the proxy has other healthy backends to route to" was never true.
CONCLUSION
Staging authorises the change mechanically. It does not authorise the
rollout shape. The canary in production is what tests the shape, and
it is not optional because staging passed.
Task 5: Measure the interruption, then canary
Start a traffic generator in a second terminal and leave it running for the whole rollout. What you are measuring is whether drain actually works.
$ # Substitute your own values before running:
VIP=192.0.2.11
OUT="$HOME/estate/reports/client-rollout.txt"
: > "$OUT"
while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "http://$VIP/")
printf '%s %s\n' "$(date +%s.%N)" "$code" >> "$OUT"
sleep 0.05
doneNow bump the version in the inventory and run the canary — one host, explicitly, before the fleet.
# inventories/production/group_vars/all/main.yml
app_version: '1.1.0'
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml --limit app01 \
| tee reports/canary-app01.txtStop and evaluate against the abort criteria you wrote in Task 2 before running anything else. The canary is only a canary if there is a pause after it.
$ cd "$HOME/estate"
for h in 192.0.2.21 192.0.2.22 192.0.2.23; do
printf '%s ' "$h"
curl -s --max-time 2 "http://$h:8080/health" | jq -r '.version + " " + .status'
done
ansible -i inventories/production/hosts.yml loadbalancers -b -m shell \
-a 'echo "show stat" | socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18 | grep estate_backend'192.0.2.21 1.1.0 ok
192.0.2.22 1.0.0 ok
192.0.2.23 1.0.0 ok
estate_backend,app01,UP
estate_backend,app02,UP
estate_backend,app03,UPIllustrative output
One host on the new version, all three backends UP, and the client log
should show no 5xx at all. Check that:
$ awk '{print $2}' "$HOME/estate/reports/client-rollout.txt" | sort | uniq -c 1843 200Illustrative output
Every 200 means the drain did its job: the proxy stopped sending requests
to app01 before the service was restarted, and the other two carried
the load. Any 503 means the drain did not settle before the restart —
increase drain_settle_seconds and investigate before continuing.
Task 6: Roll the rest
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml --limit 'appservers:!app01' \
| tee reports/rollout-remaining.txtConfirm the fleet and the client log:
$ cd "$HOME/estate"
ansible-playbook -i inventories/production/hosts.yml \
playbooks/health.yml --limit estate
awk '{print $2}' reports/client-rollout.txt | sort | uniq -c
cat reports/deploy-app0*.yml 4102 200
host: app01
at: 2026-08-12T10:14:07+00:00
version_before: 1.0.0
version_after: 1.1.0
batch: app01
host: app02
...Illustrative output
Now stop the traffic generator with Ctrl-C in the second terminal.
Task 7: The partial fleet failure
This is the part of the capstone that is about judgement rather than commands. You will break one host, run a rollout that fails midway, and then write an assessment before touching anything.
First, revert the fleet to 1.0.0 so there is a real change to make. This is also your rollback drill.
$ cd "$HOME/estate"
ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml -e app_version=1.0.0 --limit appservers \
| tee reports/rollback-drill.txtThat file is the evidence for the “Tested: yes” line in your change record. A rollback plan whose command has never been run is a plan whose first execution is during an incident.
Now inject the fault. app02 gets the marker file that makes /health
return 503 while the process is otherwise perfectly healthy.
$ ansible -i inventories/production/hosts.yml app02 -b \
-m file -a 'path=/etc/estate-app-fail state=touch mode=0644'Run the rollout to 1.1.0 across the whole tier and watch it stop.
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml --limit appservers \
| tee reports/partial-failure.txt
echo "exit status: ${PIPESTATUS[0]}"PLAY [Rolling application deployment] ******************************************
TASK [Health gate on the host itself, before it sees traffic] ******************
ok: [app01]
...
PLAY [Rolling application deployment] ******************************************
TASK [Health gate on the host itself, before it sees traffic] ******************
FAILED - RETRYING: [app02] (12 retries left).
...
fatal: [app02]: FAILED! => {"attempts": 12, "status": 503}
NO MORE HOSTS LEFT *************************************************************
PLAY RECAP *********************************************************************
app01 : ok=12 changed=3 unreachable=0 failed=0
app02 : ok=8 changed=3 unreachable=0 failed=1
app03 : ok=4 changed=0 unreachable=0 failed=0
exit status: 2Illustrative output
Stop here. Before running anything else, gather evidence and write the assessment. These commands are read-only.
$ cd "$HOME/estate"
echo '--- what each host is actually serving ---'
for h in 192.0.2.21 192.0.2.22 192.0.2.23; do
printf '%s ' "$h"
curl -s --max-time 2 "http://$h:8080/health" \
| jq -r '.version + " " + .status' || echo 'no response'
done
echo '--- what the proxy believes ---'
ansible -i inventories/production/hosts.yml loadbalancers -b -m shell \
-a 'echo "show stat" | socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18 | grep estate_backend'
echo '--- what the run recorded ---'
cat reports/deploy-app0*.yml
echo '--- what is serving through the front door ---'
for i in 1 2 3 4 5 6; do
curl -s --max-time 2 http://192.0.2.11/ | jq -r '.host + " " + .version'
done--- what each host is actually serving ---
192.0.2.21 1.1.0 ok
192.0.2.22 1.1.0 unfit
192.0.2.23 1.0.0 ok
--- what the proxy believes ---
estate_backend,app01,UP
estate_backend,app02,MAINT
estate_backend,app03,UP
--- what is serving through the front door ---
app01 1.1.0
app03 1.0.0
app01 1.1.0
app03 1.0.0
app01 1.1.0
app03 1.0.0Illustrative output
Now write docs/incident-partial-rollout.md. Answer these six questions
in writing, with evidence, before you take any action:
docs/incident-partial-rollout.md
1. WHICH HOSTS CHANGED?
app01: changed, 1.0.0 -> 1.1.0, healthy, in service.
app02: changed, 1.0.0 -> 1.1.0, code deployed and running, but
/health reports 503 so it was NEVER returned to service.
app03: NOT changed. Still 1.0.0. Still in service.
Evidence: reports/deploy-app01.yml exists; there is no
reports/deploy-app02.yml, because that task is after the health gate.
2. WHAT IS SERVING TRAFFIC RIGHT NOW?
Two of three hosts: app01 on 1.1.0 and app03 on 1.0.0.
Capacity is 67%. The service is up and is serving two versions.
3. IS app02 DANGEROUS WHERE IT IS?
No. It is in MAINT on the proxy, so it receives no traffic. The
drain step ran before the deploy and the enable step never ran.
This is the designed outcome: a host that fails its gate stays out.
4. WHY DID IT FAIL?
/health returns 503. The process is running and answering, so this
is not a crash. The endpoint returns 503 when the database is
unreachable OR when /etc/estate-app-fail exists. app01 and app03
reach the same database, so the database is not the cause.
Conclusion: the marker file. Verify with `stat` before acting.
5. ROLL FORWARD OR ROLL BACK?
Roll forward. Justification: 1.1.0 is proven healthy on app01 under
real traffic; the failure is host-specific and diagnosed; app03 is
untouched and can be deployed after app02 is fixed. Rolling back
app01 would mean two changes instead of one and would leave the
original defect on app02 undiagnosed.
The decision would be the opposite if app01 had ALSO failed: two of
three failing is a property of the release, not of a host.
6. WHAT IS THE NEXT COMMAND, AND WHAT IS ITS BLAST RADIUS?
Fix app02's fault, re-run the gate against app02 alone, and only
then deploy app02 and app03 with --limit naming exactly those two.
Blast radius: 2 hosts. app01 must not be in the limit; it is
already correct and re-running it would drain a healthy host for
no reason.
Task 8: Recover, deliberately
Only now, with the assessment written, fix it.
$ ansible -i inventories/production/hosts.yml app02 -b \
-m stat -a 'path=/etc/estate-app-fail' | grep -E '"exists"'$ ansible -i inventories/production/hosts.yml app02 -b \
-m file -a 'path=/etc/estate-app-fail state=absent'Build the resume limit from the assessment, not from memory. A limit file records the decision alongside the command.
$ cd "$HOME/estate"
{
echo "app02"
echo "app03"
} > reports/resume.limit
ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml --limit @reports/resume.limit --list-hostsplaybook: playbooks/deploy.yml
play #1 (appservers): Rolling application deployment TAGS: []
pattern: ['appservers']
hosts (2):
app02
app03Illustrative output
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml --limit @reports/resume.limit \
| tee reports/resume-rollout.txtTask 9: The audit record
The question an auditor, an incident review or your future self will ask is not “did it work”. It is “which hosts changed, when, by whom, from what to what”. Assemble the answer.
$ cd "$HOME/estate"
{
echo "AUDIT RECORD — estate-app 1.0.0 -> 1.1.0"
echo "assembled: $(date -Is)"
echo
echo "== per-host records (present only for hosts returned to service) =="
for f in reports/deploy-*.yml; do echo "--- $f"; cat "$f"; done
echo
echo "== runs, from the controller log =="
grep -E 'PLAY RECAP|^(app|db|lb)[0-9]+ +:' reports/ansible.log | tail -40
echo
echo "== final observed state =="
for h in 192.0.2.21 192.0.2.22 192.0.2.23; do
printf '%s ' "$h"
curl -s --max-time 2 "http://$h:8080/health" | jq -c .
done
} > reports/audit-record-110.txt
wc -l reports/audit-record-110.txtValidation
$ cd "$HOME/estate"
# 1. Idempotency proof exists and is clean.
grep -E 'changed=[1-9]' reports/idempotency-run2.txt || echo 'IDEMPOTENT'
# 2. The canary was a separate run against one host.
grep -c 'app01' reports/canary-app01.txt
grep -E 'app0[23]' reports/canary-app01.txt || echo 'CANARY TOUCHED ONE HOST'
# 3. No 5xx reached the client during the clean rollout.
awk '$2 ~ /^5/ {n++} END {print (n ? n : 0), "5xx responses"}' \
reports/client-rollout.txt
# 4. The partial failure stopped the play and left app03 untouched.
grep -E 'NO MORE HOSTS LEFT' reports/partial-failure.txt
grep -E '^app03 +: .*changed=0' reports/partial-failure.txt
# 5. The assessment was written, and lists all three hosts.
grep -cE 'app0[123]' docs/incident-partial-rollout.md
# 6. Every host is on the new version and in service.
ansible-playbook -i inventories/production/hosts.yml \
playbooks/health.yml --limit estate
# 7. The rollback command has actually been run.
grep -E 'version_after: 1.0.0|app_version=1.0.0' reports/rollback-drill.txt \
| head -3Every line must pass:
- The second
site.ymlrun reportschanged=0on all five hosts. - The canary run names
app01and does not touchapp02orapp03. - Zero 5xx responses in the client log across the clean rollout — the drain worked.
partial-failure.txtcontainsNO MORE HOSTS LEFTandapp03withchanged=0.docs/incident-partial-rollout.mdexists, was written before recovery, and answers all six questions.health.ymlpasses and all three hosts report1.1.0.rollback-drill.txtshows the rollback to 1.0.0 actually executed.
Expected Outcome
estate/
├── docs/
│ ├── change-2026-08-12-app-110.md
│ ├── incident-partial-rollout.md
│ └── promotion-record-110.md
├── playbooks/deploy.yml
└── reports/
├── audit-record-110.txt
├── canary-app01.txt
├── client-rollout.txt
├── deploy-app0{1,2,3}.yml
├── idempotency-run{1,2}.txt
├── partial-failure.txt
├── resume.limit
├── resume-rollout.txt
├── rollback-drill.txt
├── rollout-remaining.txt
└── staging-deploy-110.txt
Three application hosts on 1.1.0, all three backends UP, zero 5xx
served during the clean rollout, one deliberate failure absorbed without
an outage, and a written record that answers which hosts changed and to
what.
Troubleshooting
The haproxy module reports “Could not connect to socket”. The task
must run on the proxy host with become: true — the socket is mode 660
owned by the haproxy group. Confirm delegate_to names the proxy and
that become: true is on the task, not only on the play.
Drain succeeds but the client still sees 503s. drain_settle_seconds
is too short: requests already in flight when the drain landed were still
being served when the restart happened. Increase it, and note that the
right value is a function of your slowest request, not a constant.
The health gate passes but the version assertion fails. The template
was rendered and the service was not restarted. Look for a handler that
did not run — a task failing between the template and the end of the play
leaves handlers unflushed unless force_handlers is set. This is
precisely the failure the assertion exists to catch, and it is why the
gate checks the version rather than only the status code.
ansible_limit is undefined and the guardrail fires when you did pass
--limit. The variable is set from the command line only.
A limit set through ANSIBLE_LIMIT in the environment also populates it;
a hosts: pattern in the play does not.
The rollout is slow and one host takes 60 seconds to return.
wait_for on the port succeeds as soon as something is listening, but
the until loop on /health retries health_retries times at
health_delay intervals — 12 × 5 = 60 seconds of tolerance. That is the
budget you set; if the service genuinely needs it, the abort criterion of
120 seconds in the change record is what decides whether it is acceptable.
show stat reports a host in MAINT after a successful run. The
enable task did not run, which means the play aborted after the drain.
Re-enable explicitly rather than re-running the whole deploy:
ansible -i ... loadbalancers -b -m shell -a 'echo "set server estate_backend/app02 state ready" | socat stdio /run/haproxy/admin.sock'.
Two hosts drain at once and capacity collapses. serial: [1, 2] on a
three-host tier is doing what you told it to. If one host cannot carry
the load, the shape is wrong for your traffic, not the tool.
Cleanup
Lab 4 continues from here and needs the estate running. Do not run Cleanup if you are continuing to lab 4.
Step 1. Remove any injected fault, unconditionally, on every host. A forgotten marker file makes a host fail its gate weeks later during an unrelated change, and nobody will connect the two:
$ cd "$HOME/estate"
ansible -i inventories/production/hosts.yml appservers -b \
-m file -a 'path=/etc/estate-app-fail state=absent'
ansible -i inventories/staging/hosts.yml appservers -b \
-m file -a 'path=/etc/estate-app-fail state=absent'Step 2. Confirm no host is left drained. This is the silent one:
$ ansible -i inventories/production/hosts.yml loadbalancers -b -m shell \
-a 'echo "show stat" | socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18 | grep estate_backend'Any host reporting MAINT or DRAIN must be returned to service before
you walk away:
$ # Substitute your own values before running:
HOST=app02
BACKEND=estate_backend
ansible -i inventories/production/hosts.yml loadbalancers -b -m shell \
-a "echo 'set server $BACKEND/$HOST state ready' | socat stdio /run/haproxy/admin.sock"Step 3. Decide what version the estate should be left on, and say so. The honest default is to leave it on 1.1.0 — that is a normal, supported state and it is what a real deployment would leave behind. If you want 1.0.0, use the documented rollback rather than editing files by hand:
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/deploy.yml -e app_version=1.0.0 --limit appserversStep 4. Stop the traffic generator if it is still running, and keep the evidence:
$ mkdir -p "$HOME/estate-deliverables/capstone-3"
cp -a "$HOME/estate/docs" "$HOME/estate/reports" \
"$HOME/estate-deliverables/capstone-3/"Step 5. Confirm the estate is healthy before you leave it:
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/health.yml --limit estateWhat You Learned
- The second run is the safety test. An estate that reports changes on every run cannot answer “has anything drifted”, and its phantom changes fire handlers that restart production.
max_fail_percentageaborts when the failure rate exceeds the value. One host of five failing is 20 per cent, which does not abort at20. If you mean “stop on the first failure”, write0.run_onceunderserialruns once per batch, so a notification task fires three times over five hosts atserial: 2. Put genuinely once-per-run work in a separate play.- Handlers flush per batch, which is right for a rolling restart and wrong for an end-of-deployment notification.
- Staging with one host per tier cannot prove batching, capacity or version skew. Writing down what it could not prove is what makes the canary non-optional.
- Drain, change, validate, then enable. Any reordering that returns a host to service before it is proven is a bug however green the recap.
- The absence of an audit record is evidence. Writing it after the
return-to-service makes
ls reports/answer “which hosts finished”. - A limit file has no comments, and the warning it emits for a
#line is one you must not learn to ignore.