Webhook icon
Schedule icon
If icon
Fail icon
Commands icon
Process icon
LoopUntil icon
SlackIncomingWebhook icon
Exit icon
Pause icon

Take an HAProxy Server Out of Rotation Without Dropping Live Sessions

Drain an HAProxy server for maintenance after a capacity check and approval, wait for its sessions to close, then re-enable it once health is UP.

Categories
InfrastructureSystem

Taking a server out of an HAProxy backend for maintenance is easy to get wrong. Setting it to maint cuts the connections it is still serving. Taking out one server too many overloads the rest. And the capacity of the pool is rarely checked at the moment it matters, which is right before the change.

This blueprint does it in order. It checks that enough other servers are UP, asks on-call to approve in the Kestra UI, checks the pool again once the approval comes back (the pool can change while people decide), sets the server to drain so no new sessions arrive, waits until its open sessions reach zero, and only then sets maint. It talks to HAProxy's runtime API over the stats socket on TCP, so it needs no Data Plane API and no agent. Its shell tasks run with the Process runner, so no Docker is needed.

How it works

  1. validate_input (If) rejects a bad backend or server name, or a bad mode, before the socket is opened.
  2. read_pool (Commands) reads show stat for the backend. target_check (If) stops if the server is not in it.
  3. Drain mode (if_drain):
    • precheck (If) refuses without asking anyone when fewer than min_healthy other servers are UP. refuse_drain ends the run FAILED.
    • ask_approval (Slack) sends the approval link. wait_for_decision (Pause, one hour) waits for drain or decline.
    • recheck_pool reads the pool again and recheck_gate (If) refuses if the answer came after the pool changed.
    • start_drain sets drain. wait_drain (LoopUntil) reads the session count every poll_seconds until it reaches 0 or drain_timeout_seconds passes. confirm_drained reads once more and drain_gate fails the run if sessions are still open.
    • set_maint, confirm_maint and maint_gate set maint and check that HAProxy reports it. notify_drained reports.
  4. Enable mode (if_enable): set_ready, then wait_health (LoopUntil) until HAProxy reports UP or health_timeout_seconds passes. health_gate (If) fails the run if it did not, and notify_enabled reports.
  5. A webhook can start either mode with query parameters (action, backend, server).

Error handling

  • Bad input changes nothing. Names may contain only letters, digits, dot, dash and underscore. Anything else stops at reject_input before the socket is touched.
  • Capacity is checked twice. Once before the approval is requested, and again after it. The second check decides, so a stale approval is refused.
  • Timeouts never send a server to maint with sessions open. If sessions remain at the drain timeout, drain_timed_out fails the run and the server stays in DRAIN: no new sessions, existing ones continue. Nothing is changed beyond that.
  • Errors name the task. The errors alert gives the failing task and its cause, for example read_pool: "HAProxy runtime socket not reachable at 127.0.0.1:9990", or not_up when an enabled server does not pass its health check.
  • HAProxy's replies are checked. HAProxy answers errors such as "No such server." with text and success with nothing, so a non-empty reply to set server fails the task.
  • Retries with exponential backoff on every socket call, so a restarting HAProxy is retried before the run fails.
  • One run at a time. concurrency (limit: 1, QUEUE) holds a second run until the approval in progress is answered. Without it, two drains of different servers could each pass the capacity check on their own.

What this blueprint teaches about Kestra

  • A human gate in the middle of a flow: Pause with an onResume SELECT, a default of decline when nobody answers, and a fresh read after the pause.
  • LoopUntil for "wait until this holds", with the timer in the flow. In the Kestra UI the execution shows as PAUSED between polls. That is expected, not a stuck run.
  • Shell tasks with the Process runner, and values passed through env instead of being pasted into the command.
  • Webhook query parameters through trigger.parameters, and render() for a variable that holds a template used inside an expression.
  • Flow errors with errorLogs() to name the failed task in the alert, and Exit for an intended refusal.

Prerequisites

  • HAProxy 2.x (tested with 2.9.15) with a stats socket on TCP at admin level, in the global section: stats socket ipv4@127.0.0.1:9990 level admin. Bind it to loopback or a management network only: it has no authentication.
  • Kestra 2.0 or later, with worker hosts that have bash, awk and date.
  • A Slack incoming webhook.

Secrets

  • SLACK_WEBHOOK_URL: incoming webhook for the channel that approves maintenance and receives the alerts.
  • HAPROXY_DRAIN_WEBHOOK_KEY: a long random string, the key in the webhook URL ($KEY in the trigger description example).

Inputs

  • action (SELECT drain or enable, default drain): drain goes through approval; enable puts the server back and waits for UP.
  • backend (STRING, default web): the HAProxy backend name.
  • server (STRING, default web1): the server inside it. A name that does not exist is refused.
  • min_healthy (INT, default 2): other servers that must be UP after the drain. A drain that would leave fewer is refused.
  • drain_timeout_seconds (INT, default 600): how long to wait for sessions to close before failing and leaving the server in DRAIN.
  • poll_seconds (INT, default 10): how often the session count and health are read while waiting.
  • health_timeout_seconds (INT, default 120): how long an enable waits for UP.
  • haproxy_host and haproxy_port (defaults 127.0.0.1 and 9990): the stats socket.
  • kestra_url (STRING, default http://localhost:8080): base URL for the approval link.

Outputs

  • server_status (STRING): HAProxy status at the end of a successful run, MAINT after a drain and UP after an enable. Empty when the run was refused, declined or failed, because Kestra writes no outputs on those runs.

Quick start

  1. Set the SLACK_WEBHOOK_URL secret and configure HAProxy with the stats socket above.
  2. Run with the defaults. The run checks web for web1, then posts an approval request to Slack and pauses. Choose drain in the Kestra UI.
  3. The server drains, and when its sessions reach 0 it is set to MAINT. Run again with action set to enable to put it back, which waits for UP.

Measured on the test rig (3 backends, HAProxy 2.9.15, 6 long connections, 2 per server): a drain approved from the UI ran for 60 to 64 seconds. The sessions closed as their 60-second requests finished, then the server was set to MAINT and HAProxy confirmed it. The matrix passed 12 of 12 on Kestra 2.0.1 and on 2.0.5 (latest).

How to read the result

  • FAILED at refuse_drain, refuse_late or reject_input: nothing was changed. The Slack message says what was checked.
  • FAILED at drain_timed_out: the server is in DRAIN with sessions still open. Find those clients; the server is not in MAINT.
  • SUCCESS with end_declined: nobody approved, so the server stayed in rotation.

How to extend

  • Use the HAProxy Data Plane API instead of the stats socket for setups that need authentication. Swap the Commands tasks for io.kestra.plugin.core.http.Request calls with options.auth.
  • Drain several servers in turn with a Loop over a list input, keeping min_healthy checked for each one.
  • Open a ticket in your tracker from notify_drained so the maintenance window has a record.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.