Philipp Lehmann @PhilippTheServer

Prometheus HTTP Service Discovery Backed by a Live Inventory

Replacing a hand-edited targets file with an endpoint that cannot drift from reality.

The problem

Prometheus needs to know what to scrape. The simplest way to tell it is a static config:

# prometheus.yml — the part that drifts
scrape_configs:
  - job_name: node
    static_configs:
      - targets:
          - "host-a.example.internal:9100"
          - "host-b.example.internal:9100"

This file is correct exactly once: the moment someone finishes editing it after the last infrastructure change. A new host is provisioned and nobody remembers to add it here. A host is decommissioned and the entry lingers, so Prometheus dutifully scrapes a target that no longer exists and shows it as down, which trains everyone to ignore down targets as noise — which is precisely when a real outage on a real target stops standing out. The failure is quiet in both directions: missing monitoring on a new host produces no alert, because there is no target to be unreachable. It is not a bug that announces itself; it is an absence that only shows up when someone goes looking for a host that should have metrics and does not.

The list of what to scrape and the list of what actually exists are two different sources of truth the moment either changes independently, and a hand-edited file guarantees they will.

Working through it

Point Prometheus at a source of truth that already changes when infrastructure changes

Somewhere in most infrastructures there is already a system that knows the current set of hosts — an Ansible inventory, a CMDB, a cloud provider’s API, a database a provisioning tool writes to. Prometheus’s http_sd_configs lets it poll an HTTP endpoint for the current target list instead of reading a static file, which means the fix is not “edit the targets file less carelessly” but “stop hand-maintaining a target list that duplicates data that already exists somewhere live”.

scrape_configs:
  - job_name: node
    http_sd_configs:
      - url: http://sd.example.internal:8080/targets
        refresh_interval: 30s

Match the exact contract, not an approximation of it

http_sd expects a specific JSON shape: a list of objects, each with a targets array of host:port strings and an optional labels map. Prometheus is not forgiving about this — get the shape wrong and the whole scrape config silently discovers zero targets, with a log line easy to miss rather than a hard failure.

[
  {
    "targets": ["host-a.example.internal:9100"],
    "labels": {
      "environment": "production",
      "role": "web"
    }
  }
]

The labels map is where the inventory’s own metadata (environment, role, rack, whatever your inventory already tracks) becomes Prometheus labels, for free, without a second system to keep those labels in sync.

Choose refresh_interval as a trade-off, not a default

A short interval means new hosts are discovered quickly and decommissioned ones drop out quickly, at the cost of hitting the endpoint more often. A long interval is cheaper and slower to react. Thirty seconds to a minute is a reasonable default for most infrastructure — hosts do not usually appear and disappear faster than that, and the endpoint itself is cheap enough that polling it every 30 seconds costs nothing. Set it shorter only if your infrastructure genuinely churns that fast, and if it does, ask whether something downstream also needs to know that quickly, because Prometheus is probably not the first system that should be finding out.

Design for the endpoint being briefly unreachable

This is the detail that makes http_sd safe to depend on rather than a new single point of failure: when Prometheus cannot reach the discovery endpoint, it keeps the last successful target list rather than blanking it. A restart of the discovery service, a deploy, a transient network blip — none of it drops monitoring coverage, because Prometheus does not throw away targets it already knows about just because the most recent poll failed. It does mean a genuinely stale inventory (a host decommissioned an hour ago, an endpoint down since) is not immediately visible as a change in behaviour, which is a reasonable trade — stale-but-known beats empty-and-unmonitored — but it means the discovery endpoint’s own health needs its own monitoring, ideally scraped through a static target rather than through itself.

Keep the endpoint boring

The endpoint does not need to be clever. It needs to answer fast, answer correctly, and fail loudly if its backing data is unavailable rather than silently returning an empty list — an empty list from a broken endpoint looks identical to “there is genuinely nothing to scrape”, and Prometheus cannot tell the difference. A read-only view over the inventory, cached briefly if the inventory read is expensive, is normally enough.

The solution

A complete setup: a small Python service reading a YAML inventory file and serving it in the http_sd JSON format, a Prometheus config using it, and a docker-compose.yml that runs both on a laptop.

# sd_server.py
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

import yaml

INVENTORY_PATH = "/etc/sd/inventory.yaml"


def build_http_sd_response() -> list[dict]:
    with open(INVENTORY_PATH) as f:
        inventory = yaml.safe_load(f) or {}

    response = []
    for host in inventory.get("hosts", []):
        response.append({
            "targets": [f"{host['address']}:{host['port']}"],
            "labels": {
                "environment": host.get("environment", "unknown"),
                "role": host.get("role", "unknown"),
            },
        })
    return response


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/targets":
            self.send_response(404)
            self.end_headers()
            return

        try:
            body = json.dumps(build_http_sd_response()).encode()
        except Exception as exc:
            self.send_response(500)
            self.end_headers()
            self.wfile.write(str(exc).encode())
            return

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        pass  # keep container logs quiet for this example


if __name__ == "__main__":
    HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
# inventory.yaml
hosts:
  - address: node-exporter
    port: 9100
    environment: production
    role: web
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: node
    http_sd_configs:
      - url: http://sd-server:8080/targets
        refresh_interval: 30s
# Dockerfile.sd
FROM python:3.12-slim
RUN pip install --no-cache-dir pyyaml==6.0.2
COPY sd_server.py /sd_server.py
COPY inventory.yaml /etc/sd/inventory.yaml
CMD ["python3", "/sd_server.py"]
# docker-compose.yml
services:
  sd-server:
    build:
      context: .
      dockerfile: Dockerfile.sd
    ports:
      - "8080:8080"

  node-exporter:
    image: prom/node-exporter:v1.8.2
    ports:
      - "9100:9100"

  prometheus:
    image: prom/prometheus:v2.55.1
    depends_on:
      - sd-server
      - node-exporter
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

Run it and check discovery:

docker compose up -d --build
sleep 5
curl -s http://localhost:8080/targets
curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | grep -A3 health

Expected output from the discovery endpoint:

[{"targets": ["node-exporter:9100"], "labels": {"environment": "production", "role": "web"}}]

Expected output from Prometheus’s own targets API, confirming it discovered and scraped the target through http_sd:

    "health": "up",

To see the “endpoint briefly unreachable, targets not blanked” behaviour, stop the discovery service and check Prometheus still lists the target:

docker compose stop sd-server
curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | grep -A3 health

The target still shows "health": "up" (or "down" only once the actual scrape starts failing for its own reasons) — Prometheus kept the last known target list rather than discovering zero targets.

Conclusion

Static target files fail by omission, and omission does not page anyone. http_sd does not remove the need for a source of truth about what infrastructure exists — it just refuses to let Prometheus’s view of that infrastructure be a second, hand-maintained copy of it.

Two points generalise beyond Prometheus specifically:

Whenever a monitoring system’s configuration is a copy of state that lives somewhere else, the copy will drift, and the fix is to serve it live rather than re-editing the copy more carefully. This applies to more than target lists — alert routing based on ownership, dashboards keyed to a service catalogue, anything derived from infrastructure that changes independently of the monitoring config.

A discovery mechanism that fails closed (empty list on error) is worse than a hand-edited file, because at least a stale file was deliberately wrong. Fail by keeping the last known good state, and monitor the discovery path itself as a separate concern from what it discovers.