Probing the Names You Publish, Not the Services You Run
Why an internal healthz endpoint cannot see the failures that actually take a service down.
The problem
A typical health check looks like this:
GET /healthz -> 200 OK
The process answers, the database connection pool is warm, background workers are alive.
Every internal signal says the service is fine. And yet a user hits app.example.com and
gets nothing: the DNS record for app.example.com points at an IP address that changed
last week and nothing updated the record; or the reverse proxy in front of the service has
a routing rule that broke in a config change and now returns 502 for that hostname
specifically; or the TLS certificate expired six hours ago and every client refuses the
connection before a request is ever sent; or a firewall rule introduced upstream now blocks
the port from outside while everything inside the boundary talks to everything else just
fine.
None of these show up in /healthz, because /healthz runs inside the exact boundary that
broke. DNS resolution, routing, TLS, and network reachability from outside are all things
that happen before a request reaches the process being checked, so a check that only the
process can answer is structurally blind to all of them. This is not a check that is
implemented badly — it is a check answering a question (“is the process alive”) that is
not the question that actually matters to a user (“can I reach it”).
Working through it
Probe from where the failure actually happens
The fix is to test the same thing an external client tests: resolve the published name,
open the connection, negotiate TLS, send the request, read the response — from outside the
service’s own process, ideally from outside its own network boundary entirely. Prometheus’s
blackbox_exporter does exactly this: it is handed a target and a module (HTTP, TCP, DNS,
ICMP), it performs the real operation against that target, and it exposes the result as
metrics, chiefly probe_success (1 or 0) plus module-specific detail.
Treat the published name as the thing under test
The target for a blackbox probe is not the service’s internal address — it is the name a real client would type or resolve: the public hostname, the URL a user bookmarks. Probing an internal service address instead defeats the entire point, because it recreates exactly the internal-boundary blindness this technique exists to escape. If DNS is broken for the published name, the probe has to actually do DNS resolution against that name to notice.
Use the relabelling pattern, because the target and the exporter’s address are different things
blackbox_exporter itself listens on one address; the thing it is told to probe is a
completely different address. Prometheus’s scrape config for blackbox always looks slightly
unusual for this reason — it rewrites __address__ after scraping, so the actual URL being
probed travels as a label rather than as the scrape target:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
This is the one part of a blackbox setup that looks like magic until you have written it
once. static_configs lists the URLs to probe. Relabelling copies each one into
__param_target (which becomes the ?target= query parameter blackbox_exporter reads),
then overwrites __address__ with blackbox_exporter’s own address, so Prometheus actually
connects to the exporter and asks it to probe the target, rather than trying to scrape the
target directly as if it were a Prometheus endpoint.
Read the specific metrics, not just probe_success
probe_success answers “did the whole probe succeed”, which is useful for alerting but
useless for diagnosis. The module-specific metrics say why: probe_http_status_code tells
you whether it was a 4xx from routing or a 5xx from the origin; probe_ssl_earliest_cert_expiry
gives you a number of seconds until a certificate expires, which lets you alert before
expiry rather than after; probe_dns_lookup_time_seconds being present but slow tells you
DNS resolved but is unhealthy, versus its complete absence telling you resolution failed
outright. Alert on probe_success == 0 for immediate pages; alert on the certificate expiry
metric days in advance, because that failure is entirely predictable and there is no reason
to let it become an outage.
The solution
A complete, runnable setup with an http_2xx module and a dns module, wired into
Prometheus with the relabelling pattern above, and a way to see both a passing probe and a
deliberately broken one.
# blackbox.yml
modules:
http_2xx:
prober: http
timeout: 5s
http:
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
valid_status_codes: [200, 201, 202]
method: GET
preferred_ip_protocol: ip4
tls_config:
insecure_skip_verify: false
dns_lookup:
prober: dns
timeout: 5s
dns:
query_name: "example.com"
query_type: "A"
valid_rcodes:
- NOERROR
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: blackbox-http
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- http://target-app:80/
- http://target-app:80/this-path-does-not-exist
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
- job_name: blackbox-dns
metrics_path: /probe
params:
module: [dns_lookup]
static_configs:
- targets:
- 8.8.8.8:53
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
# docker-compose.yml
services:
target-app:
image: nginx:1.27-alpine
# Serves 200 on / with nginx's default page; anything else 404s,
# which is the deliberate failure case for the http_2xx module.
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
volumes:
- ./blackbox.yml:/etc/blackbox_exporter/config.yml:ro
ports:
- "9115:9115"
prometheus:
image: prom/prometheus:v2.55.1
depends_on:
- blackbox-exporter
- target-app
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
Run it:
docker compose up -d
sleep 5
curl -s "http://localhost:9115/probe?target=http://target-app:80/&module=http_2xx" \
| grep probe_success
curl -s "http://localhost:9115/probe?target=http://target-app:80/this-path-does-not-exist&module=http_2xx" \
| grep probe_success
Expected output — the real path succeeds, the nonexistent path fails:
probe_success 1
probe_success 0
Through Prometheus itself, the same distinction shows up as a query:
curl -s 'http://localhost:9090/api/v1/query?query=probe_success' | python3 -m json.tool
which returns one time series per probed target, each with its own instance label carrying
the actual URL, and a value of 1 or 0 — the deliberately broken path reporting 0
while the correct one reports 1, all without either target’s own process ever being asked
whether it feels healthy.
Conclusion
An internal health check and an external probe answer different questions, and a system needs both, but only one of them tells you what a user actually experiences.
Two points generalise beyond blackbox_exporter specifically:
Anything that happens between a client and your process — DNS, TLS, a proxy, a firewall — is invisible to a check that runs inside the process. If the failure can happen outside the boundary your health check runs in, the check cannot see it by construction, no matter how thorough it is about everything inside that boundary.
Probe the name a real client uses, not the address that is convenient to reach from inside your own network. A probe against an internal address is still useful as a liveness check, but it is not the same measurement, and conflating the two is how “everything internal looks fine” and “the service is down for everyone” end up indistinguishable on the same dashboard.