Skip to main content
RunBook Academy

AnsibleXXXII · Rolling DeploymentsRolling Deployments

Return to service and the soak interval

Advanced⏱ ~23 minansible-playbook

What you'll learn

  • Verify a host is receiving traffic rather than that the enable call succeeded
  • Place the return-to-service step so no failure path can skip it
  • Justify a soak interval and choose its length
  • Explain why the last batch needs the same verification as the first

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.

The drain created an obligation. This is where it is discharged.

Two things have to be true before the batch is finished: the host is back in the load balancer, and it is actually serving traffic. Those are different claims, and only the first one is easy.

The enable call is not the verification

Service impact possibleputting the host back
    - name: Return this host to the load balancer pool
    ansible.builtin.uri:
      url: >-
        https://lb.example.com/api/v1/pools/{{ app_pool }}/members/{{ inventory_hostname }}
      method: PATCH
      body_format: json
      body:
        state: active
      headers:
        Authorization: 'Bearer {{ lb_api_token }}'
      status_code:
        - 200
        - 204
    delegate_to: localhost
    changed_when: true

A 204 means the load balancer accepted the request. It does not mean the backend is in the pool, that its health check has passed, or that a single request has reached it.

Most load balancers will not send traffic to a backend until their own health check has passed against it, and that check runs on the balancer’s schedule — commonly every five to thirty seconds, sometimes requiring several consecutive successes. So there is a window, after your enable call returns 204, during which the host is enabled and receiving nothing.

If the play proceeds to drain the next batch during that window, capacity is lower than you think. Do it across enough batches and you can drain your way into an outage while every task reports success.

Read-only / Safewaiting for the balancer to agree the host is healthy
    - name: Wait for the balancer to mark this backend healthy and in service
    ansible.builtin.uri:
      url: >-
        https://lb.example.com/api/v1/pools/{{ app_pool }}/members/{{ inventory_hostname }}
      method: GET
      headers:
        Authorization: 'Bearer {{ lb_api_token }}'
      return_content: true
    delegate_to: localhost
    register: member
    retries: 20
    delay: 3
    until:
      - member.json.state | default('') == 'active'
      - member.json.health | default('') == 'healthy'
    changed_when: false

Again the default() values are chosen to fail the condition. An absent field must not read as success.

Proving traffic is arriving

The strongest form asks the host itself whether requests are reaching it. Any service that exposes a request counter can answer:

Read-only / Safethe host confirms it is being asked for things
    - name: Record the request counter before waiting
    ansible.builtin.uri:
      url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/metrics'
      return_content: true
    register: metrics_before
    changed_when: false

  - name: Give the balancer time to route requests here
    ansible.builtin.pause:
      seconds: '{{ traffic_settle_seconds | default(20) }}'

  - name: Confirm the request counter has moved
    ansible.builtin.uri:
      url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/metrics'
      return_content: true
    register: metrics_after
    retries: 10
    delay: 5
    until: >-
      (metrics_after.json.requests_total | default(0) | int)
      > (metrics_before.json.requests_total | default(0) | int)
    changed_when: false

The step that must never be skipped

Part XXXI established that when a failure policy triggers, the play stops at the end of the failing task and the surviving hosts run no further tasks. A return-to-service step written as an ordinary final task is exactly such an unreached task.

Service impact possiblethe whole batch in a block, with the return in always
    - name: Deploy this host, returning it to service whatever happens
    block:
      - name: Drain, deploy, restart and health-check
        ansible.builtin.include_tasks: deploy-one-host.yml

    always:
      - name: Return a healthy host to the load balancer
        ansible.builtin.uri:
          url: >-
            https://lb.example.com/api/v1/pools/{{ app_pool }}/members/{{ inventory_hostname }}
          method: PATCH
          body_format: json
          body:
            state: active
          headers:
            Authorization: 'Bearer {{ lb_api_token }}'
          status_code:
            - 200
            - 204
        delegate_to: localhost
        changed_when: true
        when: health.json.version | default('') == release_version

      - name: Announce a host left out of service
        ansible.builtin.debug:
          msg: >-
            CAPACITY WARNING: {{ inventory_hostname }} failed its health
            check and remains OUT OF SERVICE in pool {{ app_pool }}.
            The fleet is running with reduced capacity until this host is
            repaired or manually returned.
        when: health.json.version | default('') != release_version

The soak interval

The last step of the loop is doing nothing, on purpose.

Read-only / Safethe deliberate pause between batches
    - name: Soak before starting the next batch
    ansible.builtin.pause:
      seconds: '{{ soak_seconds | default(120) }}'
    run_once: true
    when: not (skip_soak | default(false) | bool)

The health check answers “is this host working now”. A whole class of failures does not present immediately:

  • A memory leak in the new version that takes minutes to exhaust the heap, then crashes the process.
  • A connection pool that is fine until it fills, which depends on traffic volume over time.
  • A cache that starts cold and is fine until the first cache-expiring request pattern arrives.
  • A scheduled job the new version runs on a timer, which fails the first time it fires.
  • A slow resource leak — file descriptors, threads, temporary files.

Without a soak, a rolling deploy can put the new version on the entire fleet before any of these has had time to appear on the first host. The canary was healthy when you checked it, and by the time it fell over, all 200 hosts were running the same code.

The soak is what converts the canary from “it started successfully” into “it survived a realistic period of production traffic”.

Choosing the length. Long enough for the failure mode you are most afraid of, and no longer. Two minutes is a reasonable default for a web application, and it is a default rather than an answer — if the leak you have seen before takes fifteen minutes to manifest, a two-minute soak does not protect you against it.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A rolling play re-enables each backend and gets a 204 from the load balancer API, then immediately drains the next batch. What risk does this create?

  2. Q2. What does a soak interval between batches protect against that a health check cannot?

  3. Q3. Which statements about placing the return-to-service step in an always block are correct? Select all that apply.

  4. Q4. The final batch of a rolling deployment deserves the same health check and return-to-service verification as the first, even though every other host is already healthy.

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