Skip to content

prometheus-pushgateway is scraped twice, so every pushed metric is duplicated and its alerts fire twice #1159

Closed

Description

@mashkovd

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 — VictoriaMetricsBackupStale
  • vm-rules/backup-alerts.yaml:135 — VictoriaMetricsBackupMetricAbsent
  • vm-rules/mctl-telegram-canary.yaml:44 — MctlTelegramCanaryStale
  • vm-rules/mctl-telegram-canary.yaml:85 — MctlTelegramCanaryTokenExpiring
  • vm-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-pushgateway on 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:

  1. Delete infra-components/observability/vm-rules/pushgateway-podscrape.yaml. Note it lives in vm-rules/, which is a directory of VMRule objects — scripts/check-vm-rules.sh explicitly skips it with skip (kind=VMPodScrape). If instead the podScrape is the one kept, it should move to observability/pushgateway/ alongside the thing it scrapes.
  2. 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.
  3. After sync, verify:
    • /api/v1/targets shows exactly one pushgateway pool, up;
    • count by (__name__) (mctl_telegram_canary_success) is 1;
    • push_time_seconds{job="mctl_telegram_canary"} still exists (proves honorLabels survived);
    • MctlTelegramCanaryStale and VictoriaMetricsBackupStale stay inactive/ok in 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

  1. mctl-agents commented on Sep 10, 2026

    @mctl-agents
    Contributor

    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. Review requirements.md, design.md and tasks.md, then approve: signal this issue's DevLoopWorkflow (mctl-api POST /api/v1/agents/dev-loop/dev-loop-mctlhq-mctl-gitops-1159/approve, or python -m orchestrator.temporal.cli approve dev-loop-mctlhq-mctl-gitops-1159 --approver <your-identity>) — the workflow flips .status.yaml to accepted via the mctl-agents-approve operation and runs the Tier 2 implementer. If no DevLoopWorkflow is running for this issue (pre-Temporal proposal), run the mctl-agents-approve operation directly with service=mctl-gitops slug=issue-1159-prometheus-pushgateway-is-scraped-twice.

    The approver is recorded in .status.yaml and in the gitops commit, and the implementer refuses a proposal whose control.requires_human_approval carries no named approver — so prefer the mctl-api endpoint, which takes the approver from the authenticated caller rather than from what the caller types.

  2. added a commit that references this issue on Sep 10, 2026
  3. mashkovd commented on Sep 10, 2026

    @mashkovd
    MemberAuthor

    Merged in #1160 (78aa9372). ArgoCD synced and pruned the VMPodScrape. All five verification checks from tasks.md pass in-cluster.

    T1 — exactly one scrape pool, up

    pools: 1
      serviceScrape/monitoring/prometheus-pushgateway/0  up  http://10.42.4.230:9091/metrics
    
    $ kubectl -n monitoring get vmpodscrape prometheus-pushgateway
    Error from server (NotFound): ... "prometheus-pushgateway" not found
    

    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)

    count by (__name__) ({__name__=~"mctl_telegram_canary_success|vmbackup_last_success_timestamp_seconds"})
    
      mctl_telegram_canary_success             -> 1   (was 2)
      vmbackup_last_success_timestamp_seconds  -> 1   (was 2)
    

    T3 — honorLabels survived

    push_time_seconds                        job=mctl_telegram_canary  endpoint=http
    push_time_seconds                        job=vmbackup              endpoint=http
    vmbackup_last_success_timestamp_seconds  job=vmbackup              endpoint=http
    

    The pushed job labels are intact, so MctlTelegramCanaryStale and the backup rules still have series to evaluate. Only endpoint=http remains.

    T4 — no alert regression

    MctlTelegramCanaryAbsent           inactive/ok
    MctlTelegramCanaryFailing          inactive/ok
    MctlTelegramCanaryStale            inactive/ok
    ScrapePoolHasNoTargets             inactive/ok
    VictoriaMetricsBackupMetricAbsent  inactive/ok
    VictoriaMetricsBackupStale         inactive/ok
    

    ScrapePoolHasNoTargets staying 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 VMPodScrape back into vm-rules/:

    exit=1
    ::error file=...zz-temp-podscrape.yaml::non-VMRule object (kind=VMPodScrape) found under vm-rules/; move it to infra-components/observability/<component>/ instead
    

    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.sh now takes --selftest and CI runs it before the real pass, matching the convention every other detector in validate-manifests.yml already follows. It re-runs the script against a throwaway RULES_DIR rather than reimplementing the check, so it exercises the guard and not a copy of it, and it was itself validated by mutation:

    guard reverted to `skip (kind=...)` + continue  -> self-test exits 1
    annotation text changed                          -> self-test exits 1
    restored                                         -> self-test exits 0
    

    CI output on the merged commit: check-vm-rules.sh self-test: kind guard rejects non-VMRule, accepts VMRule.

    Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions