Files
computer-playbook/utils/docker/healthcheck/probes.py
kevinveenbirkenbachandClaude Opus 5 96408f23a0 perf(healthcheck): let the start window outlast a real boot
start_interval is the cadence while start_period runs, and only while it runs.
Once the window closes the probe falls back to interval, so a service that
becomes ready just after it pays a whole tick anyway and the cadence never
reached it. Six of the seven flavors were shaped that way, Curl worst: a thirty
second window against a one minute interval.

Measured across 151 converge waits in four job logs, 63 sit exactly on a tick
boundary, 36 in the thirty second step and 27 in the sixty second one.
Attributed by application, web-app-seaweedfs is the largest at 1501s over
sixteen waits with a median of 57.7s: it probes with curl and fell straight into
the gap. web-app-zammad probes with connect, whose window is fifteen minutes,
and its 154s median is real boot time with nothing to recover.

So the window moves to ten minutes and the steady state interval to two, and the
flavor overrides that sat below them go: Curl, Wget and Connect lose interval
1m, Http its twenty second window, Nc its ten. Connect keeps fifteen minutes.

Neither number costs anything while a container works. start_period delays
nothing, it only stops failures counting, and interval no longer touches
convergence at all since that hangs on the window alone; it is steady state
load, which drops from 120 probes an hour per service to 30.

Forty six role files follow. Twenty seven declarations go that repeat the new
default or undercut it, an undercut window being the defect itself. Eight
windows longer than ten minutes keep their value and now say why. The literal
blocks and the three Dockerfiles this repository builds spell every timing out,
because outside the lookup an omission takes docker's value and not the
platform's.

A fourth lint rule keeps the shape: start_period must outlast one interval,
checked against the effective values, so raising interval without the window
cannot quietly bring the cliff back.

Two faults in the lint surfaced while building it and are fixed here. It
compared timings as strings, so 600s and 10m read as different values, and it
matched the healthcheck key exactly, so a line carrying a trailing nocheck was
skipped whole, which is how the two swarm-init partials went unexamined.

One regression, bounded. A container that starts and never turns healthy is
neither rejected nor errored, so stack_ready.sh only calls it fatal after three
killed tasks, and each cycle now spans the longer window. That reaches twenty
minutes rather than six, capped there by swarm_stack_converge_retries at 600
with a two second delay, and it lands only on runs that were failing anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:54:26 +02:00

250 lines
7.9 KiB
Python

"""Probes: one class per flavor, each deciding whether the container serves.
Pure and Ansible free. Every probe targets the loopback INSIDE the container,
so it proves that this container serves, not that some replica somewhere does.
"""
from __future__ import annotations
import shlex
from typing import Any, ClassVar
CURL = ("curl",)
CURL_NO_PROXY = ("--noproxy", "*")
DEFAULT_INTERVAL = "2m"
DEFAULT_TIMEOUT = "5s"
DEFAULT_RETRIES = 3
DEFAULT_START_PERIOD = "10m"
DEFAULT_START_INTERVAL = "5s"
TIMING_DEFAULTS: dict[str, object] = {
"interval": DEFAULT_INTERVAL,
"timeout": DEFAULT_TIMEOUT,
"retries": DEFAULT_RETRIES,
"start_period": DEFAULT_START_PERIOD,
"start_interval": DEFAULT_START_INTERVAL,
}
_HTTP_REQUEST = (
"echo -e 'GET /{path} HTTP/1.1\\r\\nHost: localhost\\r\\n"
"Connection: close\\r\\n\\r\\n' >&3"
)
def curl_argv(*flags: str, url: str, hostname: str | None = None) -> list[str]:
"""Build a curl invocation as argv, for docker's exec form.
Args:
flags: curl flags this probe wants, e.g. ``-f`` or ``-fsS``.
url: the URL to request.
hostname: sent as a Host header when the vhost matters.
Returns:
The argv list, proxy-free.
Every probe here targets the container's own loopback, so a request that a
proxy picks up is wrong by definition -- the Wget probe has said so with
``--proxy=off`` since it was written. Both spellings come from here so the
exec form and the shell form cannot drift: the shell form needs the
wildcard quoted and the exec form needs it bare, which is exactly the kind
of difference that rots when it is written out three times.
"""
argv = [*CURL, *flags, *CURL_NO_PROXY]
if hostname:
argv += ["-H", f"Host: {hostname}"]
return [*argv, url]
def curl_shell(*flags: str, url: str, hostname: str | None = None) -> str:
"""The CMD-SHELL spelling of :func:`curl_argv`, quoted for a shell."""
return " ".join(
shlex.quote(part) for part in curl_argv(*flags, url=url, hostname=hostname)
)
class Probe:
"""Base for every flavor: timings plus the argv docker executes."""
flavor: ClassVar[str] = ""
interval: ClassVar[str] = DEFAULT_INTERVAL
timeout: ClassVar[str] = DEFAULT_TIMEOUT
retries: ClassVar[int] = DEFAULT_RETRIES
start_period: ClassVar[str] = DEFAULT_START_PERIOD
start_interval: ClassVar[str] = DEFAULT_START_INTERVAL
def __init__(self, **context: Any) -> None:
self.port = context.get("port", "")
self.path = context.get("path", "")
self.hostname = context.get("hostname")
self.samples = context.get("samples", 1)
self.context = context
@property
def url(self) -> str:
return f"http://127.0.0.1{f':{self.port}' if self.port else ''}/{self.path}"
def test(self) -> list[str]:
raise NotImplementedError
def shell(self) -> str:
"""The same command as one shell string, so a prefix can compose with it.
Returns:
The CMD-SHELL payload, or the exec argv quoted for a shell.
"""
form, *rest = self.test()
if form == "CMD-SHELL":
return rest[0]
return " ".join(shlex.quote(part) for part in rest)
def block(self, overrides: dict[str, Any]) -> dict[str, Any]:
"""Assemble the healthcheck mapping.
`start_interval` is the cadence while `start_period` is still running.
Without it a probe only fires every `interval`, so a service ready after
three seconds is reported healthy at the next tick -- 30s, or a minute
for the curl flavors -- and a stack converges no faster than its slowest
quantisation. It changes when the same probe runs, never what it tests.
`start_period` therefore bounds it: once the window closes the cadence
falls back to `interval`, so a service that becomes ready just after the
window pays a full tick anyway. The window has to outlast a realistic
boot, not merely a fast one, which is why it is measured in minutes
rather than sized to `interval`. It costs nothing while the container
works, only the time until a broken one is called unhealthy.
Args:
overrides: service level values that win over the flavor defaults.
"""
block: dict[str, Any] = {"test": self.test()}
for key in ("interval", "timeout", "retries", "start_period", "start_interval"):
block[key] = overrides.get(key, getattr(self, key))
return block
class Custom(Probe):
"""A service that spells its probe out instead of picking a flavor."""
flavor = "custom"
def test(self) -> list[str]:
return list(self.context["test"])
class Curl(Probe):
"""curl against the loopback, optionally sampling the whole replica pool.
``retries`` counts CONSECUTIVE failures, and a request that leaves the
container is load balanced across the service VIP. With N replicas and
one of them alive, a round robin never produces more than N-1 failures
in a row, so a streak based check is arithmetically blind to losing all
but one backend. Chaining ``samples`` requests into a single probe makes
one probe cover the whole pool instead.
"""
flavor = "curl"
timeout = "10s"
def test(self) -> list[str]:
if self.samples > 1:
probe = curl_shell("-f", url=self.url, hostname=self.hostname)
return ["CMD-SHELL", " && ".join([probe] * self.samples)]
return ["CMD", *curl_argv("-f", url=self.url, hostname=self.hostname)]
class Wget(Probe):
flavor = "wget"
timeout = "10s"
def test(self) -> list[str]:
return ["CMD", "wget", "--spider", "--proxy=off", self.url]
class Http(Probe):
"""Whichever of wget or curl the image happens to ship.
The wget half stays proxy-aware on purpose: busybox wget rejects
``--proxy=off``, and this flavor exists for images where which tool ships is
unknown, so hardening it there would break the very case it covers.
"""
flavor = "http"
retries = 5
def test(self) -> list[str]:
return [
"CMD-SHELL",
(
f"wget -qO- {self.url} >/dev/null"
f" || {curl_shell('-fsS', url=self.url)} >/dev/null"
),
]
class Tcp(Probe):
"""Speaks HTTP over a bash socket, for images without curl or wget."""
flavor = "tcp"
def request(self) -> str:
return _HTTP_REQUEST.format(path=self.path)
def test(self) -> list[str]:
return [
"CMD",
"bash",
"-c",
(
f"exec 3<>/dev/tcp/localhost/{self.port} && {self.request()} && "
"cat <&3 | grep -q 'HTTP/1'"
),
]
class HttpStatus(Tcp):
"""Like :class:`Tcp`, but insists the status line is 2xx or 3xx."""
flavor = "http_status"
def test(self) -> list[str]:
return [
"CMD",
"bash",
"-c",
(
f"exec 3<>/dev/tcp/localhost/{self.port} && {self.request()} && "
"head -n1 <&3 | grep -qE '^HTTP/1\\.[01] [23][0-9][0-9]'"
),
]
class Nc(Probe):
flavor = "nc"
timeout = "3s"
def test(self) -> list[str]:
return ["CMD-SHELL", f"nc -z localhost {self.port} || exit 1"]
class Connect(Probe):
"""Proves only that the port accepts a TCP connection.
For a service that speaks something other than HTTP there -- php-fpm answers
FastCGI -- so neither curl nor an HTTP request over the socket can judge it.
``/dev/tcp`` is a bash builtin, hence the explicit interpreter.
"""
flavor = "connect"
timeout = "20s"
retries = 5
start_period = "15m"
def test(self) -> list[str]:
return ["CMD", "bash", "-c", f"</dev/tcp/127.0.0.1/{self.port}"]
PROBES: dict[str, type[Probe]] = {
probe.flavor: probe for probe in (Curl, Wget, Http, Tcp, HttpStatus, Nc, Connect)
}