Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

intermediateprometheus-scrape~20 min

Break/Fix: Relabel Drops Targets

Reported symptoms

  • ●Every node-level panel across the estate went blank at 09:12 on 12 August; the kubelet, blackbox and application dashboards are unaffected
  • ●Nothing paged. NodeDown is written as up{job="node"} == 0 and has stayed in the inactive state throughout
  • ●The capacity report shows prometheus_tsdb_head_series down roughly 190,000 overnight, which was attributed to the cardinality cleanup that shipped the same week and congratulated in the channel
  • ●The instance template variable on the node dashboards is empty, so the first responder concluded Grafana was at fault, restarted it and re-provisioned the dashboards with no effect
  • ●Two hosts genuinely rebooted at 09:05 for kernel patching, which made "this datacentre is having a bad morning" the working theory for ninety minutes
  • ●promtool check config passes, git log on prometheus.yml shows no change for seven weeks, and the Prometheus process has not been reloaded or restarted
  • ●SSH to any node shows node_exporter answering /metrics normally, and curling it from the Prometheus host succeeds

Evidence

  • · /api/v1/targets?state=active returns zero targets whose scrapePool is node
  • · /api/v1/targets?state=dropped returns 412 entries for the node pool, each carrying discoveredLabels with environment="prod" and no env label at all
  • · prometheus_sd_discovered_targets{config="node"} reads 412, so discovery is finding every host
  • · prometheus_sd_file_mtime_seconds for the node file_sd file steps at 09:11, one minute before the panels went blank
  • · prometheus_sd_file_read_errors_total is 0 and has never incremented
  • · promtool check service-discovery prints a populated discoveredLabels object and an empty final labels object for every target in the job
  • · up{job="node"} returns an empty result, not a set of zeros
  • · The relabel block keeps on source_labels [env] with regex production; the inventory generator repository has a commit at 09:10 titled "adopt platform label taxonomy (env becomes environment, production becomes prod)"
Diagnosis and resolutionclick to reveal

Root cause

The keep rule on the node job filters targets on a label the inventory generator stopped emitting. The rule reads source_labels [env] with regex production and action keep; the generator now writes environment: prod. A source label that does not exist reads as the empty string, relabel regexes are anchored at both ends so the empty string does not match production, and a keep whose regex does not match deletes the target outright. All 412 hosts were discovered, evaluated against the rule and removed before any scrape was attempted. Nothing failed, so nothing was logged and no error field was set. The failure is silent in a specific and important way: a target dropped in relabel_configs is never scheduled, so it produces no up series at all, which makes every alert written as up == 0 structurally incapable of firing for it - the shape of the fault removes the exact series the alert selects. The one number that did move loudly moved in the direction everyone wanted, because 190,000 series disappearing from the head looks identical to a successful cardinality reduction and landed in the same week as one. Prometheus itself was never touched: the configuration is unchanged, valid and correctly loaded, and the thing that changed was metadata owned by a different team in a different repository on a different release cadence, which is why seven weeks of clean git history on prometheus.yml read as proof of innocence.

Remediation

Resist the obvious fix first, because it is the one that turns an outage into an incident. Deleting the keep rule does restore the node panels within a scrape interval, and it also admits every target discovery produces - lab hosts, staging, decommissioned machines still in the inventory - into a production job, multiplying series against a Prometheus that is already the memory ceiling of its host. On a server sized for the filtered fleet that is a plausible way to lose the monitoring platform during the incident you are using it to investigate. The correct immediate step is narrow: make the keep rule match the taxonomy the generator actually emits, keeping the old key alongside the new one for the length of the migration rather than swapping one for the other, so that a partially rolled-out generator does not drop whichever half has not caught up. Validate it offline with promtool check service-discovery before reloading anything - the whole failure is that relabeling is silent, and the offline debugger is the only place it speaks. Then reload, confirm the target count, and be explicit that the gap is not backfilled: ninety-seven minutes of node metrics do not exist and never will, so any capacity, SLO or burn-rate arithmetic that crosses the window is wrong and needs annotating rather than quietly averaging over a hole. Holding is a legitimate option if this lands mid-change-window: a time-boxed rule matching both label names, with a named owner and a stated expiry, is better than a hurried taxonomy decision made at 10:00 by whoever is on call. Restore the absence alert that was deleted as noisy, and take the ownership problem to the generator's repository, because the output schema of that tool is an interface with Prometheus whether or not anyone has written it down.

Verification

Verify against discovery rather than against the dashboard, because the dashboard was green for the wrong reason before and will be green for the right reason now without distinguishing the two. The active target count for the pool must equal prometheus_sd_discovered_targets for the job: comparing those two numbers is the check, and either one alone proves nothing. up{job="node"} must return 412 series with value 1 - a set of zeros would mean the targets are back and failing, which is a different problem, and an empty result means the rule still does not match. Run promtool check service-discovery again and confirm every target now has a populated final label set rather than an empty one; that output is the direct evidence that the rule matched, as distinct from the indirect evidence that data appeared. Confirm prometheus_tsdb_head_series returns to roughly its previous level, and separate that recovery from the genuine cardinality cleanup so the capacity baseline is not left contaminated by the incident. Then prove the new guard can fail rather than only that it is quiet: in staging, publish a file_sd file using the old taxonomy, confirm the discovered-versus-active alert fires within its window, and confirm it routes to a human rather than only appearing in the Prometheus UI. Finally, annotate the outage window on the affected dashboards, so that the hole is read as missing data and not as a fleet that briefly went to zero.

Prevention

Alert on the difference between what discovery found and what is being scraped. Comparing prometheus_sd_discovered_targets against prometheus_target_scrape_pool_targets per job catches every member of this class - the keep rule that stops matching, the regex that was always slightly wrong, the hashmod shard whose modulus drifted, the metadata key that was renamed upstream - and it costs one recording rule and one alert. It is also the only signal available, because a dropped target emits nothing and silence is not otherwise measurable. Stop writing availability alerts whose expression depends on the series the failure removes: up == 0 detects a target that is scraped and failing and can never detect a target that is not scheduled, so every such rule needs a fleet-count sibling checked against an inventory figure rather than a hard-coded constant. Treat service-discovery metadata as a versioned interface: the label keys Prometheus relabels on belong in a schema the generator's own CI validates against, so a taxonomy change fails in the generator's pipeline instead of in Prometheus an hour later. Run promtool check service-discovery in CI on both sides of that boundary, and diff the resulting label sets rather than eyeballing the YAML. Keep the relabel block ordered and commented - filter, redirect, identify, clean - because an unreadable block is one somebody simplifies under pressure. And treat a large favourable movement in head series as something to investigate rather than something to celebrate: a number falling by 190,000 overnight has a cause, and assuming you know which one is how a known symptom became a ninety-minute outage.

Reported symptoms

At 09:12 on 12 August every node-level panel in the estate went blank at once. CPU, memory, filesystem, network, all of it, across all four datacentres. The kubelet dashboards are fine. The blackbox probes are fine. Every application dashboard is fine.

Nothing paged. NodeDown is written as up{job="node"} == 0, it is in the rule file, it has a route and a receiver, and it has been in the inactive state throughout.

Four other things happened, none of which anybody connected to the panels.

The capacity report looked like a win. prometheus_tsdb_head_series fell by roughly 190,000 overnight. A cardinality cleanup had shipped the same week, so the fall was attributed to it and congratulated in the channel. It is the only number in this incident that moved loudly, and it moved in the direction everyone was hoping for.

Grafana looked like the problem. The instance template variable on the node dashboards is empty, which makes every panel on those dashboards resolve to nothing. The first responder reasonably concluded the variable was broken, restarted Grafana, and re-provisioned the dashboards from source. No change.

Two hosts really did reboot. Kernel patching took node-14 and node-27 down at 09:05. That produced genuine, correct, unrelated signals seven minutes before the blank panels, and it made “this datacentre is having a bad morning” the working theory for the next ninety minutes.

Prometheus looks innocent, and is. promtool check config passes. git log on prometheus.yml shows nothing for seven weeks. The process has not been reloaded or restarted. SSH to any node shows node_exporter answering /metrics, and curling it from the Prometheus host works.

Evidence provided

The cheapest question is not “is the exporter up” - it is “does Prometheus think it has anything to scrape”.

Read-only / Safenot down - absent
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/targets \
--data-urlencode 'state=active' \
| jq '[.data.activeTargets[] | select(.scrapePool == "node")] | length'
0

Illustrative output

Zero active targets is a different statement from 412 unhealthy ones, and it is the statement that explains why no alert fired. Discovery, though, is working perfectly.

Read-only / Safefound, then discarded
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=prometheus_sd_discovered_targets{config="node"}' \
| jq -r '.data.result[0].value[1]'
412

Illustrative output

Four hundred and twelve discovered, none scraped. Everything between those two numbers is relabeling, and the dropped-target listing shows what relabeling was handed.

Read-only / Saferead the label keys, not the values
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/targets \
--data-urlencode 'state=dropped' \
| jq -r '[.data.droppedTargets[] | select(.discoveredLabels.job == "node")][0].discoveredLabels'
{
"__address__": "192.0.2.14:9100",
"__meta_filepath": "/etc/prometheus/file_sd/node.yml",
"__metrics_path__": "/metrics",
"__scheme__": "http",
"environment": "prod",
"job": "node",
"region": "eu-west",
"role": "compute"
}

Illustrative output

The rule those labels were evaluated against has not changed in seven weeks:

# /etc/prometheus/prometheus.yml  (unchanged since 24 June)
  - job_name: node
    file_sd_configs:
      - files:
          - /etc/prometheus/file_sd/node.yml
    relabel_configs:
      # keep only production hosts
      - source_labels: [env]
        regex: production
        action: keep

And the file it reads was rewritten one minute before the panels went blank.

Read-only / Safethe file parsed cleanly, which is the problem
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=prometheus_sd_file_read_errors_total' \
| jq -r '.data.result[] | .metric.instance + "  errors=" + .value[1]'
prometheus-01.internal.example.com:9090  errors=0

Illustrative output

Work the evidence before reading on

Discovery finds 412 targets. The scrape pool has 0. The configuration has not changed. No error is logged anywhere.

  1. The dropped target’s discoveredLabels are listed above. Read them against the keep rule. Which label does the rule name, and is it present?
  2. Relabel regexes are anchored at both ends. What value does a source_labels entry take when the named label is not on the target, and does that value match production?
  3. NodeDown is up{job="node"} == 0 and never fired. Given zero active targets, how many up series exist for this job, and what does that do to the rule?
  4. prometheus_tsdb_head_series fell by 190,000 the same night a cardinality cleanup shipped. What is the arithmetic that makes those two explanations indistinguishable, and which query separates them?
  5. Nothing in the Prometheus repository changed. Where, then, is the change - and which team owns it?

Before continuing: name the one comparison you would run every minute that would have caught this within a scrape interval, and say why up == 0 cannot be it.

Root cause

1. A missing label is the empty string, and the empty string does not match

The keep rule names env. The generator now writes environment. A source_labels entry that names a label the target does not carry resolves to the empty string - not to an error, not to a skipped rule.

Relabel regexes are anchored at both ends, so production matches the value production and nothing else, and certainly not the empty string. A keep whose regex fails to match deletes the target. All 412 were evaluated and discarded before any scrape was scheduled.

Every step there is the documented behaviour working exactly as specified. There is no bug to find in Prometheus.

2. The change was upstream, in a repository nobody looked at

At 09:10 the inventory generator adopted a platform-wide label taxonomy: env became environment, and the value production became prod. Two changes, either of which alone would have been enough.

File SD picked the rewritten file up within seconds, which is what it is for. The file parsed cleanly, so prometheus_sd_file_read_errors_total stayed at zero and Prometheus kept using the new content rather than falling back to the last good version. Everything behaved correctly.

Seven weeks of untouched git history on prometheus.yml was read by three people as evidence that Prometheus could not be involved. The configuration was indeed innocent. The input to the configuration was not, and it lives in another team’s repository on another team’s release cadence.

3. The alert could not fire, and that is structural

This is the part worth sitting with, because it generalises well beyond relabeling.

A target dropped in relabel_configs is never added to the scrape pool. It is never scraped. It therefore never produces an up series - not up == 0, not up == 1, nothing.

up{job="node"} == 0 selects up series for the job and compares them. With no such series, the selector returns an empty vector, the comparison returns an empty vector, the rule produces no alert instances, and the rule sits in the inactive state looking exactly like a healthy fleet.

An availability alert whose expression depends on a series that the failure mode removes cannot detect that failure mode. It is not a tuning problem or a threshold problem. The alert is answering “are any scraped targets failing” and being read as though it answered “is the fleet being monitored”.

4. The loud signal pointed the wrong way

Four hundred and twelve hosts times roughly 460 series each is about 190,000 series, which is what left the head block. A cardinality cleanup had shipped the same week and was expected to remove a large number of series.

So the single metric that moved decisively moved in the direction the team wanted, arrived when they were expecting it, and was accepted without a query. A falling series count is one of the few numbers in an observability platform that nobody instinctively distrusts.

Resolution

  1. Establish the shape before touching the config: zero active targets with non-zero discovered targets is a relabel drop at the target stage. A healthy target with a missing metric is the sample-stage version and a different fix; do not start editing until you know which one you have.
  2. Write the corrected rule to tolerate both taxonomies for the length of the migration rather than swapping one for the other, so a partially rolled-out generator cannot drop whichever half has not caught up yet. Record the date the tolerance is removed.
  3. Validate offline first with promtool check service-discovery against the edited file. Relabeling is silent by design, and this is the only place it will tell you what it intends to do before it does it.
  4. Reload and confirm the target count matches the discovered count. Do not confirm by looking at a dashboard - the dashboard was wrong for ninety minutes and will now be right for a reason you have not yet checked.
  5. State plainly that the gap is not backfilled. Ninety-seven minutes of node metrics do not exist and cannot be recovered, so every capacity figure, SLO calculation and burn-rate window crossing that period is wrong.
  6. Annotate the window on the affected dashboards so the hole reads as missing data rather than as a fleet that briefly went to zero. An unannotated gap is a future misdiagnosis.
  7. Restore the absence alert that was deleted as noisy, and rewrite it so it does not depend on the up series existing.
  8. Take the ownership problem to the generator repository. The set of label keys Prometheus relabels on is an interface between two teams, and right now it is documented only inside a regex in a config file the other team never reads.
  9. Add the discovered-versus-scraped alert in the same change, so the next taxonomy change is caught by the platform within a scrape interval instead of by a human ninety minutes later.

The rule that closes the class rather than the instance:

# /etc/prometheus/rules/scrape-health.yaml
groups:
  - name: scrape-health
    interval: 30s
    rules:
      # The two counters carry different label names for the same
      # job: prometheus_sd_discovered_targets uses `config`, and
      # prometheus_target_scrape_pool_targets uses `scrape_job`.
      # Align them before comparing, or the subtraction matches
      # nothing and the alert is silent for the wrong reason.
      - record: job:sd_discovered_targets:sum
        expr: sum by (config) (prometheus_sd_discovered_targets)

      - record: job:scrape_pool_targets:sum
        expr: |
          sum by (config) (
            label_replace(
              prometheus_target_scrape_pool_targets,
              "config", "$1", "scrape_job", "(.+)"
            )
          )

      # Discovery found targets that relabeling then removed.
      # Fires for a keep rule that stopped matching, a regex that
      # was always slightly wrong, or a metadata key renamed
      # upstream - none of which produce an up series to alert on.
      - alert: DiscoveredTargetsNotScraped
        expr: |
          job:sd_discovered_targets:sum - job:scrape_pool_targets:sum > 0
        for: 5m
        labels:
          severity: page
        annotations:
          summary: 'Targets discovered for {{ $labels.config }} are not being scraped'
          description: |
            Relabeling is dropping targets that service discovery found. These
            produce no up series, so up == 0 cannot see them. Run
            promtool check service-discovery for this job and compare
            discoveredLabels against the final label set.

Verification

  1. Discovery and the scrape pool agree. The active target count for the pool equals prometheus_sd_discovered_targets for the job. Either number alone proves nothing; the comparison is the check.
  2. The up series exist and are healthy. up{job="node"} returns 412 series with value 1. A set of zeros means the targets are back and failing, which is a different problem you now have; an empty result means the rule still does not match.
  3. Relabeling says so directly. promtool check service-discovery shows a populated final label set for every target, which is evidence the rule matched rather than evidence that data appeared.
  4. The head series count recovers to its previous level, and the genuine cardinality cleanup is separated from the incident so the capacity baseline is not left contaminated.
  5. The new alert can fire. In staging, publish a file_sd file using the old taxonomy and confirm DiscoveredTargetsNotScraped fires inside its window. A rule that has only ever been green has never been tested.
  6. The new alert reaches a human. Confirm it routes to the on-call receiver, not only that it appears in the Prometheus UI. Firing and paging are different achievements.
  7. The absence alert also fires when every target is gone, which is the case the original up == 0 rule could not represent at all.

Prevention

  • Compare what was discovered against what is being scraped. One recording rule and one alert on prometheus_sd_discovered_targets versus prometheus_target_scrape_pool_targets catches the whole class, and it is the only available signal, because a dropped target emits nothing and silence is not otherwise measurable.
  • Never write an availability alert on a series the failure removes. up == 0 detects a target that is scraped and failing. It cannot detect a target that was never scheduled. Pair every such rule with a fleet count checked against an inventory figure rather than a hard-coded constant.
  • Version the discovery metadata as an interface. The label keys Prometheus relabels on belong in a schema the generator’s own CI validates, so a taxonomy change fails in the generator’s pipeline rather than in Prometheus an hour later.
  • Run promtool check service-discovery in CI on both sides of that boundary, and diff the resulting label sets. The YAML diff shows the rule; only the label-set diff shows the effect.
  • Keep the relabel block ordered and commented - filter, redirect, identify, clean - with one purpose per rule. An unreadable block is one somebody simplifies under pressure, and the simplification drops a target class.
  • Investigate a large favourable movement in head series. A fall of 190,000 has a cause. Assuming you already know which one is how a visible symptom stayed unexamined for ninety minutes.