Config Management · python · 36 copies
F5 Pool Member Drain
Disable an F5 pool member, wait for its connections to actually reach zero, and report when it is safe to touch the server. Never resets a connection unless you ask for it.
- F5 BIG-IP (TMOS)
$
pip install "requests>=2.28" "PyYAML>=6.0"terminal
f5_pool_drain.py
#!/usr/bin/env python3
"""
F5 Pool Member Drain
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/f5-pool-drain
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28" "PyYAML>=6.0"
"""
import argparse
import os
import sys
import time
import requests
import urllib3
import yaml
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# --- iControl REST client -------------------------------------------------
#
# Inlined verbatim rather than imported: these scripts download standalone
# from the site, so there is no shared module to import. The same block
# appears in every F5 script here; change it in all of them or in none.
def credentials():
user = os.environ.get("F5_USER")
password = os.environ.get("F5_PASS")
if not user or not password:
raise SystemExit(
"ERROR: F5_USER and F5_PASS must be set in the environment.\n"
"\n"
" Linux / macOS: export F5_USER=svc_automation\n"
" export F5_PASS='...'\n"
" PowerShell: $env:F5_USER='svc_automation'\n"
" $env:F5_PASS='...'\n"
"\n"
"They are read from the environment on purpose: an argument is\n"
"visible in `ps auxww` while the process runs and lands in shell\n"
"history."
)
return user, password
class BigIP:
"""Minimal iControl REST client.
Token auth rather than Basic on every call: a token is scoped, expires,
and does not put the password on the wire repeatedly.
"""
def __init__(self, host, user, password, verify=True, timeout=30):
self.base = "https://%s/mgmt" % host
self.host = host
self.timeout = timeout
self.s = requests.Session()
self.s.verify = verify
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._login(user, password)
def _login(self, user, password):
r = self.s.post(
self.base + "/shared/authn/login",
json={"username": user, "password": password, "loginProviderName": "tmos"},
timeout=self.timeout,
)
if r.status_code == 401:
raise SystemExit("ERROR: %s rejected the credentials (401)." % self.host)
r.raise_for_status()
token = r.json()["token"]["token"]
self.s.headers.update({"X-F5-Auth-Token": token})
def get(self, path, **kw):
r = self.s.get(self.base + path, timeout=self.timeout, **kw)
r.raise_for_status()
return r.json()
def patch(self, path, body):
r = self.s.patch(self.base + path, json=body, timeout=self.timeout)
r.raise_for_status()
return r.json()
def post(self, path, body):
r = self.s.post(self.base + path, json=body, timeout=self.timeout)
r.raise_for_status()
return r.json()
def load_inventory(path):
"""Read the device inventory. Credentials never live in it."""
try:
with open(path, "r", encoding="utf-8") as fh:
doc = yaml.safe_load(fh) or {}
except FileNotFoundError:
raise SystemExit("ERROR: inventory file not found: %s" % path)
except yaml.YAMLError as exc:
raise SystemExit("ERROR: %s is not valid YAML: %s" % (path, exc))
defaults = doc.get("defaults") or {}
devices = doc.get("devices")
if not devices:
raise SystemExit(
"ERROR: %s has no `devices:` list.\n"
"\n"
" defaults:\n"
" verify_ssl: true\n"
" devices:\n"
" - host: ltm01.example.com\n"
" - host: 10.0.0.10\n"
" verify_ssl: false" % path
)
out = []
for i, d in enumerate(devices, 1):
merged = dict(defaults)
merged.update(d or {})
if not merged.get("host"):
raise SystemExit("ERROR: device #%d in %s has no `host`." % (i, path))
out.append(merged)
return out
def encode(name):
"""tmsh paths use ~ for / in the REST URI: /Common/pool -> ~Common~pool"""
return name.replace("/", "~")
# --- drain ----------------------------------------------------------------
def member_uri(pool, member):
return "/tm/ltm/pool/%s/members/%s" % (encode(pool), encode(member))
def current_connections(bip, pool, member):
stats = bip.get(member_uri(pool, member) + "/stats")
entries = stats.get("entries", {})
for v in entries.values():
nested = v.get("nestedStats", {}).get("entries", {})
cur = nested.get("serverside.curConns", {}).get("value")
if cur is not None:
return int(cur)
return None
def drain_member(bip, pool, member, wait, poll, force, dry_run):
"""Disable a member and wait for its connections to reach zero.
The point of the script. Setting a member down and immediately taking the
server out drops every connection it was serving; the correct sequence is
disable, then WAIT, then act. Almost nobody waits.
`session: user-disabled` stops NEW connections but lets existing ones
finish. `state: user-down` kills them. This uses the first.
"""
before = current_connections(bip, pool, member)
print(" %-28s connections=%s" % (member, "unknown" if before is None else before))
if dry_run:
print(" [dry-run] would set session=user-disabled and wait up to %ds" % wait)
return "dry-run"
bip.patch(member_uri(pool, member), {"session": "user-disabled"})
print(" disabled (no new connections). Waiting up to %ds..." % wait)
deadline = time.time() + wait
last = before
while time.time() < deadline:
time.sleep(poll)
cur = current_connections(bip, pool, member)
if cur != last:
print(" connections=%s" % cur)
last = cur
if cur == 0:
print(" DRAINED: 0 connections. Safe to work on the server.")
return "drained"
print(" TIMEOUT: still %s connection(s) after %ds." % (last, wait))
if force:
bip.patch(member_uri(pool, member), {"state": "user-down"})
print(" FORCED DOWN as requested. Those connections were reset.")
return "forced"
print(" Left disabled, NOT forced down. Existing connections are still being")
print(" served. Re-run with --force only if resetting them is acceptable.")
return "timeout"
def enable_member(bip, pool, member, dry_run):
if dry_run:
print(" [dry-run] would set session=user-enabled and state=user-up")
return "dry-run"
bip.patch(member_uri(pool, member), {"session": "user-enabled", "state": "user-up"})
print(" %-28s enabled" % member)
return "enabled"
def resolve_members(bip, pool, wanted):
data = bip.get("/tm/ltm/pool/%s/members" % encode(pool))
names = [m["name"] for m in data.get("items", [])]
if not names:
raise SystemExit("ERROR: pool %s has no members, or does not exist." % pool)
if not wanted:
return names
missing = [w for w in wanted if w not in names]
if missing:
raise SystemExit(
"ERROR: not a member of %s: %s\nMembers are: %s"
% (pool, ", ".join(missing), ", ".join(names))
)
return wanted
def main():
ap = argparse.ArgumentParser(
description="Drain F5 pool members: disable, wait for connections to "
"reach zero, and report. Never resets a connection unless "
"--force is given."
)
ap.add_argument("--inventory", required=True, help="YAML inventory of BIG-IPs")
ap.add_argument("--pool", required=True, help="Pool name, e.g. /Common/pool_app")
ap.add_argument("--member", action="append", default=[],
help="Member as addr:port. Repeatable. Omit for every member "
"(refused unless --all-members is also given)")
ap.add_argument("--all-members", action="store_true",
help="Required to act on every member of the pool")
ap.add_argument("--enable", action="store_true",
help="Re-enable instead of draining")
ap.add_argument("--wait", type=int, default=300, help="Seconds to wait (default 300)")
ap.add_argument("--poll", type=int, default=5, help="Seconds between checks (default 5)")
ap.add_argument("--force", action="store_true",
help="After the wait, force the member down, RESETTING any "
"remaining connections")
ap.add_argument("--dry-run", action="store_true",
help="Show what would happen and change nothing")
args = ap.parse_args()
if not args.member and not args.all_members:
raise SystemExit(
"ERROR: specify --member addr:port, or pass --all-members to act on\n"
"the whole pool. Draining every member at once takes the service\n"
"offline, so it has to be asked for explicitly."
)
user, password = credentials()
devices = load_inventory(args.inventory)
worst = 0
for dev in devices:
host = dev["host"]
print("=" * 72)
print("%s pool=%s" % (host, args.pool))
print("=" * 72)
try:
bip = BigIP(host, user, password,
verify=dev.get("verify_ssl", True),
timeout=dev.get("timeout", 30))
members = resolve_members(bip, args.pool, args.member)
if args.all_members and not args.member and not args.enable:
print("WARNING: draining ALL %d members. The pool will have no"
% len(members))
print(" available member when this finishes.")
for m in members:
if args.enable:
enable_member(bip, args.pool, m, args.dry_run)
else:
result = drain_member(bip, args.pool, m, args.wait,
args.poll, args.force, args.dry_run)
if result == "timeout":
worst = max(worst, 1)
except requests.exceptions.SSLError as exc:
print("ERROR: TLS failed for %s: %s" % (host, exc))
print(" Set verify_ssl: false in the inventory only if the")
print(" certificate is a known self-signed one.")
worst = 2
except requests.exceptions.RequestException as exc:
print("ERROR: %s unreachable: %s" % (host, exc))
worst = 2
except SystemExit:
raise
print()
return worst
if __name__ == "__main__":
sys.exit(main())
--inventorytextrequiredYAML inventory of BIG-IPs. Credentials never go in it
--pooltextrequiredPool full path, e.g. /Common/pool_app
--membertextMember as addr:port. Repeatable. Omit only with --all-members
--all-membersflagRequired to act on every member: draining all of them takes the service offline
--enableflagPut the member back in service instead of draining
--waitnumberSeconds to wait for connections to reach zero. Default 300
--pollnumberSeconds between connection-count checks. Default 5
--forceflagAfter the wait, force the member down, RESETTING any remaining connections
--dry-runflagShow what would happen and change nothing
What This Solves
You need to patch, reboot or reimage a server that sits behind an F5. The usual approach is to mark the pool member down and get on with it. That drops every connection the member was serving. Users see resets, the application logs fill with half-finished transactions, and somebody files a ticket about the maintenance window. The correct sequence is: disable the member so it stops receiving NEW connections, WAIT for the existing ones to finish, and only then touch the server. Almost nobody waits, because waiting means watching a counter. This script does the waiting, and tells you when it is actually safe.
Is It Safe to Run
It changes state on the BIG-IP, so read this before the first run. What it does by default: - Sets the member's session to user-disabled. This stops NEW connections. EXISTING connections keep being served to completion. - Polls the member's connection count until it reaches zero or the wait expires. - Reports. It does NOT reset anything. What it does only if you ask: --force After the wait expires, sets the member state to user-down, which RESETS any connections still open. Without --force the script leaves the member disabled and tells you it timed out. --enable Puts the member back in service. What protects you: - Draining every member of a pool takes the service offline, so it is refused unless you pass --all-members explicitly. - --dry-run shows exactly what would happen and changes nothing. API calls used: GET on the pool and member stats, PATCH on the member's session (and state, only with --force).
Before You Start
- Python 3.8 or newer - pip install requests PyYAML - A BIG-IP account with REST access that can modify pool members - Network access to the management address on TCP 443 - An inventory file (see REFERENCE below)
Your First Run
Three steps, on one member, with nothing clever. 1. Create devices.yaml: devices: - host: ltm01.example.com 2. Set the credentials: export F5_USER=svc_automation export F5_PASS='...' 3. Look before you touch anything: python3 f5_pool_drain.py --inventory devices.yaml \ --pool /Common/pool_app --member 10.1.1.10:80 --dry-run Read the output. When it looks right, drop --dry-run.
What the Output Means
======================================================================== ltm01.example.com pool=/Common/pool_app ======================================================================== 10.1.1.10:80 connections=47 disabled (no new connections). Waiting up to 300s... connections=31 connections=12 connections=0 DRAINED: 0 connections. Safe to work on the server. The connection count is serverside.curConns for that member. It should fall steadily. If it does not fall at all, the application is holding long-lived connections and you need either a longer --wait or a decision about resetting them. A timeout looks like this: TIMEOUT: still 4 connection(s) after 300s. Left disabled, NOT forced down. Existing connections are still being served. Re-run with --force only if resetting them is acceptable. That is the script refusing to make the decision for you. Four connections might be a health monitor, a websocket, or a long upload. Look before you force. Putting the member back: python3 f5_pool_drain.py --inventory devices.yaml \ --pool /Common/pool_app --member 10.1.1.10:80 --enable
Exit Codes for Cron and CI
0 Every member drained, or enabled, successfully. 1 At least one member timed out with connections still open. 2 A device was unreachable, or credentials or TLS failed. Exit 1 is the one to gate a pipeline on: it means the maintenance step is not safe to proceed with yet.
Every Flag
The inventory file Passed with --inventory devices.yaml. Credentials NEVER go in it. defaults: # optional, applied to every device below verify_ssl: true timeout: 60 devices: - host: ltm01.example.com - host: 10.0.0.10 verify_ssl: false # per-device override, self-signed cert timeout: 300 host REQUIRED. Management IP or DNS name of the BIG-IP. verify_ssl Verify the management certificate. Default true. Set false ONLY for a known self-signed management certificate. timeout Seconds per HTTP request. Credentials - environment only F5_USER username (required) F5_PASS password (required) Linux / macOS: export F5_USER=svc_automation export F5_PASS='...' PowerShell: $env:F5_USER='svc_automation' $env:F5_PASS='...' CMD: set F5_USER=svc_automation set F5_PASS=... Environment rather than arguments on purpose: an argument is visible in ps auxww while the process runs, and lands in shell history. The account needs a role with REST access. Operator is enough for the read-only work; changing pool member state needs more. Arguments --inventory PATH REQUIRED. YAML inventory of BIG-IPs. --pool NAME REQUIRED. Full path, e.g. /Common/pool_app --member ADDR:PORT Member to act on. Repeatable. Omit only with --all-members. --all-members Required to act on every member of the pool. --enable Re-enable instead of draining. --wait SECONDS How long to wait for connections to reach zero. Default 300. --poll SECONDS Seconds between checks. Default 5. --force After the wait, force the member down, RESETTING any remaining connections. --dry-run Change nothing.
When Something Fails
ERROR: F5_USER and F5_PASS must be set in the environment You exported them in a different shell, or in a shell that has since closed. Export them again in this one. ERROR: <host> rejected the credentials (401) Wrong password, or the account has no REST access. Confirm with: curl -ku USER:PASS https://HOST/mgmt/tm/sys/version ERROR: TLS failed The management certificate is self-signed and verify_ssl is true. Set verify_ssl: false for that device in the inventory, and only for that device. Do not turn it off globally out of convenience. ERROR: <host> unreachable Port 443 to the management address is blocked, or the management interface is on a network you cannot reach from here. 404 on a pool or object name Names are case sensitive and include the partition. Use the full path, /Common/pool_app, not pool_app. Connections never reach zero Long-lived connections: websockets, database pools, keep-alive with a long idle timeout. Either raise --wait, or accept the reset with --force. A count that is stable at 1 or 2 is often the health monitor itself. The member shows as disabled but traffic still arrives Persistence. A client with an existing persistence record keeps going to that member. Check with: tmsh show ltm persistence persist-records
Agents
/agents/f5-expertTags
f5big-ipltmicontrol-restmaintenancedrainautomation