Repository navigation
prometheus-pushgateway is scraped twice, so every pushed metric is duplicated and its alerts fire twice #1159
Description
What
prometheus-pushgateway in the monitoring namespace is scraped twice, by two different objects targeting the same endpoint on the same pod. Every metric pushed to the gateway therefore exists in VictoriaMetrics as two series, differing only in scrape-derived labels.
From vmagent's /api/v1/targets today — both up, both http://10.42.4.230:9091/metrics:
health=up job=monitoring/prometheus-pushgateway endpoint=9091
scrapePool: podScrape/monitoring/prometheus-pushgateway/0
health=up job=prometheus-pushgateway endpoint=http
scrapePool: serviceScrape/monitoring/prometheus-pushgateway/0
And the consequence, measured:
count by (__name__) ({__name__=~"mctl_telegram_canary_success|vmbackup_last_success_timestamp_seconds"})
mctl_telegram_canary_success -> 2
vmbackup_last_success_timestamp_seconds -> 2
The two series carry identical job and instance (both scrapes set honorLabels: true, correctly) and differ only in endpoint — "9091" vs "http" — plus a service label present on the serviceScrape copy.
Why it matters
An alert on a pushed metric fires twice for one fact. Neither of these expressions aggregates, so each matches both series and produces two identical alerts, two notifications, and two incidents for a single underlying event:
vm-rules/backup-alerts.yaml:108—VictoriaMetricsBackupStalevm-rules/backup-alerts.yaml:135—VictoriaMetricsBackupMetricAbsentvm-rules/mctl-telegram-canary.yaml:44—MctlTelegramCanaryStalevm-rules/mctl-telegram-canary.yaml:85—MctlTelegramCanaryTokenExpiringvm-rules/mctl-telegram-canary.yaml:111—MctlTelegramCanaryTokenRenewFailing
(MctlTelegramCanaryFailing and MctlTelegramCanaryAbsent use max_over_time / absent_over_time and are affected differently but still evaluate over duplicated series.)
It is also a wasted scrape of a target every 30s, and it silently doubles the sample count for everything that goes through the gateway.
How it got here
Both objects are deliberate, added two months apart, each fixing a real problem at the time:
| File | Commit | Date | |
|---|---|---|---|
VMServiceScrape |
infra-components/observability/pushgateway/servicescrape.yaml |
0c7d3e70 |
2026-05-25 |
VMPodScrape |
infra-components/observability/vm-rules/pushgateway-podscrape.yaml |
6529632b |
2026-06-26 |
The VMPodScrape's own header explains why it was added:
the pushgateway was deployed manually (outside gitops) together with a VMServiceScrape whose selector matched
app: prometheus-pushgatewayon the Service — but that Service carries no labels, so vmagent never discovered the target […] Fix: select the pushgateway Pod […] bypassing the unlabeled Service.
That was true of the live Service at the time. The gitops-managed Service in pushgateway/pushgateway.yaml does carry app: prometheus-pushgateway and a named port http, and the serviceScrape is up today — so the condition the podScrape works around no longer holds. Neither file mentions the other, which is presumably why the overlap went unnoticed.
Ask
Remove one of the two, almost certainly the VMPodScrape, since the Service path is the conventional one and is demonstrably working.
Do not delete it blind. The check that matters is that after removal exactly one target remains and it is still up, and that the pushed labels survive — MctlTelegramCanaryStale keys off push_time_seconds{job="mctl_telegram_canary"}, so honorLabels: true on the surviving scrape is load-bearing. serviceScrape already sets it.
Suggested shape:
- Delete
infra-components/observability/vm-rules/pushgateway-podscrape.yaml. Note it lives invm-rules/, which is a directory of VMRule objects —scripts/check-vm-rules.shexplicitly skips it withskip (kind=VMPodScrape). If instead the podScrape is the one kept, it should move toobservability/pushgateway/alongside the thing it scrapes. - Carry the useful history from its header comment into whichever file survives, so the next person does not re-add a second scrape for the same reason.
- After sync, verify:
/api/v1/targetsshows exactly one pushgateway pool,up;count by (__name__) (mctl_telegram_canary_success)is1;push_time_seconds{job="mctl_telegram_canary"}still exists (proveshonorLabelssurvived);MctlTelegramCanaryStaleandVictoriaMetricsBackupStalestayinactive/okin vmalert.
Not in scope
Aggregating the alert expressions (max by (job, instance) (...)) as a workaround. That hides the duplication rather than fixing it, and would have to be repeated in every future rule touching a pushed metric.
Found while shipping the vmbackup success-timestamp metric in #1071 / #1157.
Activity
mctl-agents commentedon Sep 10, 2026
mctl-agents issue-investigator has analyzed this issue and created a proposal:
https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-gitops/proposals/issue-1159-prometheus-pushgateway-is-scraped-twice/
Status:
proposed— pending human approval. Reviewrequirements.md,design.mdandtasks.md, then approve: signal this issue's DevLoopWorkflow (mctl-apiPOST /api/v1/agents/dev-loop/dev-loop-mctlhq-mctl-gitops-1159/approve, orpython -m orchestrator.temporal.cli approve dev-loop-mctlhq-mctl-gitops-1159 --approver <your-identity>) — the workflow flips.status.yamltoacceptedvia themctl-agents-approveoperation and runs the Tier 2 implementer. If no DevLoopWorkflow is running for this issue (pre-Temporal proposal), run themctl-agents-approveoperation directly withservice=mctl-gitops slug=issue-1159-prometheus-pushgateway-is-scraped-twice.The approver is recorded in
.status.yamland in the gitops commit, and the implementer refuses a proposal whosecontrol.requires_human_approvalcarries no named approver — so prefer the mctl-api endpoint, which takes the approver from the authenticated caller rather than from what the caller types.chore(agents): investigate #1159 2026-09-10
mashkovd commentedon Sep 10, 2026
Merged in #1160 (
78aa9372). ArgoCD synced and pruned theVMPodScrape. All five verification checks fromtasks.mdpass in-cluster.T1 — exactly one scrape pool, up
The
podScrape/...pool is gone, and the object was pruned by ArgoCD rather than left orphaned.T2 — duplication gone (measured after ~7 minutes, past VM's staleness horizon, so the abandoned
endpoint="9091"series had aged out)T3 —
honorLabelssurvivedThe pushed
joblabels are intact, soMctlTelegramCanaryStaleand the backup rules still have series to evaluate. Onlyendpoint=httpremains.T4 — no alert regression
ScrapePoolHasNoTargetsstaying quiet is the one that matters — it is the safety net for "the one remaining scrape selected nothing".T5 — CI green on the PR, including the kubeconform sweep and the alerting-rule step.
T6 — the guard was proved to fire. Ran locally by dropping a copy of the deleted
VMPodScrapeback intovm-rules/:That manual check has since been made permanent. agy raised a P3 on the PR — the new guard had no automated test, so a later refactor could quietly disarm it, which is the shape of this bug one level up.
scripts/check-vm-rules.shnow takes--selftestand CI runs it before the real pass, matching the convention every other detector invalidate-manifests.ymlalready follows. It re-runs the script against a throwawayRULES_DIRrather than reimplementing the check, so it exercises the guard and not a copy of it, and it was itself validated by mutation:CI output on the merged commit:
check-vm-rules.sh self-test: kind guard rejects non-VMRule, accepts VMRule.Closing.
fix(ci): check where the CA is fetched from, and that no template ove…