RTFM · Load balancing
External healthchecks: the Saphira check library
Two packages, 34 checks, one interface: small scripts that perform the real transaction a service promises, a DICOM echo, a DNS query, a RADIUS Status-Server, and answer with an exit code any load balancer can read.
The idea in plain words
A normal health check is like checking whether a shop's door is unlocked. The Saphira checks walk in, buy something, and check the change: each one performs the exact transaction the service promises its customers. If that transaction works, the exit code is 0 and the balancer sends real traffic. If anything in the chain fails, the listener, the protocol, the database behind it, the check says so.
The package rule that makes this trustworthy: a named check is a named capability, with no silent fallbacks. https never degrades to "port open"; http3 never quietly becomes quic; dicom.echo never passes because a port answered. A pass verdict certifies exactly the promised transaction.
Install
# Generic checks - pulls the full Saphira LB stack with it
# (haproxy, ipvsadm/LVS, ldirectord via resource-agents)
apk add saphira-lb-healthchecks
# Medical checks - depends on the generic package and dcmtk
apk add saphira-lb-medical-healthchecks
# Where things land
ls /var/lib/lb/saphira/ # the checks themselves
ls /usr/share/saphira/lb-healthchecks/lib/ # shared shell library
ls /usr/share/saphira/lb-medical-healthchecks/lib/
saphira-lb-probe --help # the probe engine
/usr/share/doc/saphira-lb-healthchecks/README.md
| Path | Contents |
|---|---|
| /var/lib/lb/saphira/lb.saphira.* | The executable checks (both packages install here) |
| /usr/bin/saphira-lb-probe | The raw/protocol probe engine behind many checks |
| /usr/share/saphira/lb-healthchecks/lib/lb-saphira-common.sh | Shared ABI, config loader, timeout handling |
| /usr/share/saphira/lb-medical-healthchecks/lib/lb-saphira-dicom-synth.py | Synthetic DICOM object generator (no sample files) |
| /etc/saphira/lb-healthchecks.d/ | Your configuration layer (you create it) |
The caller contract: $1–$5
ldirectord, HAProxy's external-check and the ipvsadm toolchain all share one habit: they pass a fixed set of arguments to an external check and read its exit status. The Saphira checks accept that ABI as gospel; nothing beyond these five is ever assumed from a caller:
$1 virtual service / firewall mark (e.g. 21, or the VIP)
$2 virtual port
$3 real server address ← the check target
$4 real server port
$5 virtual source where supplied (optional)
# exit 0 healthy
# exit 1 unhealthy, the named capability failed
# exit 2 misconfiguration, fix the setup, do not flap the server
Everything beyond $5 is check-specific configuration, resolved in a fixed order so the same script works interactively, under ldirectord, and under HAProxy:
-
1. Positional $6 and beyond
Manual use and adapter wrappers, where the caller permits richer arguments; a convenience, never required.
-
2. Environment variables
LB_SAPHIRA_*; the highest-priority machine configuration, and the only place credentials may arrive.
-
3. Check/service config file
/etc/saphira/lb-healthchecks.d/<check>.conf for all services, or <check>/<service>.conf for one virtual service (the service token is $1 sanitized).
-
4. Safe baked-in default
What the check author chose when you have said nothing.
# /etc/saphira/lb-healthchecks.d/dicom.find.conf
LB_SAPHIRA_DICOM_PATIENT_ID=SAPHIRA-SYNTHETIC-HEALTHCHECK
LB_SAPHIRA_DICOM_MODALITY=CT
LB_SAPHIRA_DICOM_QUERY_LEVEL=PATIENT
LB_SAPHIRA_DICOM_AET=SAPHIRA-HC
LB_SAPHIRA_DICOM_AEC=MODALITY-SCP
# Per-service override (service token = sanitized $1):
# /etc/saphira/lb-healthchecks.d/dicom.find/21.conf
# Format: KEY=VALUE lines, keys must match LB_SAPHIRA_[A-Z0-9_]+,
# '#' comments. Caller environment always wins over conf files.
Credentials arrive via environment or admin-owned conf files only and are never echoed by any check. Exit 2 is deliberate: a misconfigured check refuses to vote, so a broken setup can never silently mark healthy servers down (or keep dead ones up).
Timeouts and dual-stack
Every check enforces a bounded timeout via LB_SAPHIRA_TIMEOUT (default 3 seconds); a check that hangs would otherwise hang the balancer's scheduler. All addresses resolve through getaddrinfo, so any check whose protocol permits IPv6 handles a v4 or v6 real server indifferently; raw-packet checks (gre, ipproto) are IPv4 in v1 and say so on their pages.
The check catalogue
| Check | Layer | Proves |
|---|---|---|
| icmp | L3 | ICMP echo round-trip |
| tcp | L4 | TCP accept |
| udp | L4 | UDP listener bound (ICMP-unreachable discrimination) |
| multiport | L4 | all/any of a port list accepts TCP |
| http | L7 | HTTP status/body transaction |
| https | L7 | HTTPS status/body transaction |
| tls | L7 | Full TLS handshake (+ optional cert-expiry floor) |
| sni | L7 | TLS handshake asserting a specific SNI hostname |
| http3 | L7 | Strict real HTTP/3 transaction (no fallback) |
| quic | L7 | QUIC listener via Version Negotiation handshake |
| dns | L7 | DNS query round-trip (ID/QR/rcode verified) |
| smtp | L7 | SMTP greeting + EHLO + QUIT (stops before DATA) |
| imap | L7 | IMAP greeting/CAPABILITY, no AUTH |
| pop3 | L7 | POP3 greeting, no AUTH |
| ldap | L7 | LDAP anonymous bind + rootDSE response |
| ssh | L7 | SSH-2.0 banner handshake (credential-free) |
| radius | L7 | RADIUS Status-Server (RFC 5997) round-trip |
| sip | L7 | SIP OPTIONS round-trip (UDP or TCP) |
| mysql | L7 | MySQL protocol handshake parse |
| postgresql | L7 | PostgreSQL SSLRequest negotiation |
| redis | L7 | Redis PING → PONG (optional AUTH) |
| gre | L3 | GRE encapsulated-packet loopback (two-party contract) |
| ipproto | L3 | Raw IP protocol primitive (probe/accept modes) |
| sendexpect-tcp | L4/L7 | Raw TCP send-expect: the custom-protocol escape hatch |
| sendexpect-udp | L4/L7 | Raw UDP send-expect |
| Check | Class | Proves |
|---|---|---|
| dicom-echo | continuous-safe | C-ECHO association + verification SCP |
| dicom-find | continuous-safe | C-FIND cycle with synthetic non-patient keys |
| dicom-move | opt-in | C-MOVE retrieve pipeline to a dedicated receiving SCP |
| dicom-store | opt-in | C-STORE of a generated synthetic object |
| dicom-qido | continuous-safe | QIDO-RS query → 200 + application/dicom+json |
| dicom-wado | continuous-safe | WADO-RS retrieve → 200 + application/dicom |
| dicom-stow | opt-in | STOW-RS store of a synthetic object |
| hl7-mllp | continuous-safe | MLLP QRY^A19 (synthetic, read-only) + valid ACK |
| fhir | continuous-safe | FHIR CapabilityStatement served |
Wiring: the same check under both directors
# ldirectord.cf
virtual = 21
protocol = fwm
scheduler = rr
real = 192.168.20.21:0 gate 1
checktype = external
checkcommand = "/var/lib/lb/saphira/lb.saphira.dicom.echo"
# haproxy.cfg - external-check runs the same script with the
# same five arguments (drain-support requires HAProxy 2.2+ semantics)
global
external-check
backend dicom_pool
mode tcp
option external-check
external-check command "/var/lib/lb/saphira/lb.saphira.dicom.echo"
server pacs1 192.168.20.21:104 check inter 5s fall 3 rise 2
Check-specific settings ride in the environment or the conf-file layer, not in the balancer config, which is exactly why one check serves both directors unchanged.
Run any check by hand first
# The five arguments: FWM/VIP vport real rport [vsource]
/var/lib/lb/saphira/lb.saphira.tcp 21 443 192.168.20.11 443
echo $? # 0 healthy, 1 unhealthy, 2 misconfig
# With settings, exactly as the balancer would see them:
LB_SAPHIRA_HTTP_PATH=/healthz \
/var/lib/lb/saphira/lb.saphira.http web-pool 80 192.168.20.11 80
echo $?
Prove it works: Your first check passes
You picked a check from the catalogue, ran it by hand against a real server, saw exit 0, wired it into one director, and watched ipvsadm or the HAProxy stats page mark the server up; then down again when you stopped the service.
Did we miss something?
If this page left something unanswered, found an error, or there is another subject you would like documented, tell us. Saphira’s documentation grows from real problems people need to solve.
Send feedback or request a new section →
Prefer not to do it yourself?
Everything needed to do the work yourself is documented here and remains free; we charge for human time, not for withholding knowledge. Sometimes the missing resource is simply time. The same people who build Saphira can provide paid professional help with implementation, migration, troubleshooting and administration.