56

Prometheus and Mimir: From Metric to Incident

Build the whole path on Kind: an application emits a metric, Prometheus • scrapes it, Mimir stores it, a rule fires, Alertmanager routes it, and a local • webhook receives both the firing and resolved notifications.

Key terms

TermMeaning
PrometheusScrapes targets, stores a local time-series window, evaluates rules
Grafana MimirHorizontally scalable, multi-tenant, long-term Prometheus metrics backend
PromQLQuery language shared by Prometheus and Mimir
ServiceMonitorPrometheus Operator CRD that declares how to scrape a Service
PrometheusRulePrometheus Operator CRD containing recording and alerting rules
remote writeProtocol Prometheus uses to send samples to a remote backend
AlertmanagerGroups, deduplicates, silences, and routes alerts to receivers
labelKey/value metadata used for querying and routing
forTime an expression must remain true before an alert fires
firing / resolvedAlert lifecycle states after a condition starts/stops matching

Problem and solution

Prometheus is excellent at discovering nearby targets, scraping metrics, and evaluating alerts. A single Prometheus server is deliberately simple, though: its local disk and one-node query capacity become limits when retention, tenant count, or cluster count grows.

Solution: keep Prometheus close to the workloads as the scraper and rule evaluator, then use remote write to send every sample to Grafana Mimir. Mimir exposes a Prometheus-compatible query API while scaling ingestion, query, and object storage independently. Alertmanager remains the notification router.

The important boundary is:

application -> Prometheus -> Mimir
                    |
                    +-> alert rule -> Alertmanager -> receiver

Mimir does not create an incident merely because a sample arrived. A rule evaluates a PromQL condition; Alertmanager routes the resulting alert; an external incident system may then turn that notification into an incident.

The analogy

A supermarket has checkout counters, a shift supervisor, a central archive, and an incident desk. Each counter reports throughput and failed payments. The shift supervisor watches the live counters and reacts quickly (Prometheus). The central archive keeps every store's history and answers long-range questions (Mimir). When a sustained failure breaches policy, the supervisor hands a structured alarm to the incident desk (Alertmanager), which groups duplicates and calls the correct team.

Graph legend

Graph nodeReal componentResponsibility
checkout countersapplication replicasExport counters and histograms at /metrics
shift supervisorPrometheusScrape, query recent data, evaluate rules
central archiveMimirStore and query metrics beyond one Prometheus
incident deskAlertmanagerGroup, deduplicate, silence, and route
payments teamwebhook receiverNotify a human or start incident automation

Where this fits in the cluster

The Prometheus Operator watches monitoring CRDs. It converts a ServiceMonitor into scrape configuration and a PrometheusRule into loaded rule groups. Prometheus sends samples to Mimir and alerts to Alertmanager.

Graph legend

Graph nodeKubernetes objectWhat it does
Prometheus OperatorDeploymentReconciles monitoring CRDs into runtime configuration
ServiceMonitormonitoring.coreos.com/v1Selects the Service and /metrics endpoint
PrometheusRulemonitoring.coreos.com/v1Defines the PromQL expression, labels, and for
PrometheusOperator-managed StatefulSetScrapes, keeps recent samples, evaluates the rule
Mimirlocal DeploymentReceives remote write and serves PromQL
AlertmanagerOperator-managed StatefulSetRoutes alert notifications
alert sinkfictional webhook DeploymentPrints received JSON so the E2E path is observable

Prometheus and Mimir are complementary

Do not treat Mimir as a replacement for the scrape loop. They solve different parts of the metrics path.

ConcernPrometheusMimir
Target discoveryNative Kubernetes discovery / Operator CRDsNo
Pull /metricsYesNo
Recent local queriesYesYes, after remote write
Local rule evaluationYesOptional Mimir ruler at larger scale
Long retentionLimited by local diskDesigned for object storage
Horizontal query scaleLimitedQuery frontend, schedulers, queriers
Multi-tenancyNot its primary modelFirst-class tenant isolation
Global viewOne Prometheus unless federatedMany Prometheus writers in one backend

For this localhost lab, Mimir runs as one process with filesystem storage. That is intentionally a development topology. Production Mimir normally separates the distributor, ingesters, query frontend, queriers, store gateways, compactor, and ruler, with durable object storage.

Graph legend

ComponentPathPurpose
distributorwriteValidate, rate-limit, and shard incoming samples
ingesterwrite and recent readHold active series, WAL, and create TSDB blocks
object storagedurable storageStore immutable metric blocks
query frontendreadSplit, schedule, cache, and combine queries
querierreadExecute PromQL over recent and stored data
store gatewayreadServe blocks from object storage
compactorbackgroundCompact blocks and enforce retention

One alert's complete journey

An alert is not a log line and not an incident. It is a state machine crossing several boundaries.

The for: 30s clause filters brief spikes. The expression must remain true through the entire window. If it becomes false at 29 seconds, the pending alert returns to inactive and no firing notification is sent.

Labels are the routing contract

Metrics labels make series queryable. Alert labels also become the contract between Prometheus, Alertmanager, notification systems, and status pages.

labels:
  severity: critical
  environment: lab
  owner: payments
  component: storefront

Typical uses:

  • severity selects paging versus ticketing.
  • environment prevents development alerts from paging production on-call.
  • owner routes to the team responsible for remediation.
  • component maps an incident to a user-visible capability.

If component is missing, downstream automation cannot infer the affected capability safely. A status page may show impact unknown even though the alert and incident are valid. That is expected behavior, not proof that Alertmanager failed.

Prefer explicit labels in the rule. Do not try to recover ownership by parsing free-text descriptions downstream.

Local lab architecture

The lab uses a single-node Kind cluster and two namespaces:

observability
  mimir
  Prometheus Operator
  Prometheus
  Alertmanager
  Grafana

demo
  checkout-api
  ServiceMonitor
  PrometheusRule
  alert-sink

Prerequisites:

docker version
kind version
kubectl version --client
helm version
curl --version
jq --version

The manifests use fictional names and values. They are not copied from a production system.

Step 1 — create the Kind cluster

Use a dedicated name so cleanup cannot delete another local cluster.

kind create cluster --name metrics-lab --wait 120s
kubectl cluster-info --context kind-metrics-lab
kubectl create namespace observability
kubectl create namespace demo

Expected:

Kubernetes control plane is running at https://127.0.0.1:...
namespace/observability created
namespace/demo created

Step 2 — run a development Mimir inside Kubernetes

This is the official single-process filesystem pattern adapted to a Kubernetes Deployment. It is small enough for a laptop and deliberately not a production configuration.

# mimir.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mimir
  namespace: observability
data:
  mimir.yaml: |
    multitenancy_enabled: false
    usage_stats:
      enabled: false
    blocks_storage:
      backend: filesystem
      bucket_store:
        sync_dir: /tmp/mimir/tsdb-sync
      filesystem:
        dir: /tmp/mimir/data/tsdb
      tsdb:
        dir: /tmp/mimir/tsdb
    compactor:
      data_dir: /tmp/mimir/compactor
      sharding_ring:
        kvstore:
          store: memberlist
    distributor:
      ring:
        instance_addr: 127.0.0.1
        kvstore:
          store: memberlist
    ingester:
      ring:
        instance_addr: 127.0.0.1
        kvstore:
          store: memberlist
        replication_factor: 1
    ruler_storage:
      backend: filesystem
      filesystem:
        dir: /tmp/mimir/rules
    server:
      http_listen_port: 9009
      log_level: warn
    store_gateway:
      sharding_ring:
        replication_factor: 1
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mimir
  namespace: observability
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mimir
  template:
    metadata:
      labels:
        app: mimir
    spec:
      containers:
        - name: mimir
          image: grafana/mimir:3.0.6
          args:
            - -config.file=/etc/mimir/mimir.yaml
          ports:
            - name: http
              containerPort: 9009
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 3
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              memory: 1Gi
          volumeMounts:
            - name: config
              mountPath: /etc/mimir
      volumes:
        - name: config
          configMap:
            name: mimir
---
apiVersion: v1
kind: Service
metadata:
  name: mimir
  namespace: observability
spec:
  selector:
    app: mimir
  ports:
    - name: http
      port: 9009
      targetPort: http
kubectl apply -f mimir.yaml
kubectl rollout status deployment/mimir -n observability --timeout=180s
kubectl port-forward -n observability service/mimir 9009:9009

In another terminal:

curl -fsS http://localhost:9009/ready
# ready

Step 3 — install kube-prometheus-stack with remote write

The chart installs the Prometheus Operator, Prometheus, Alertmanager, and Grafana. The selectors below allow our independently-authored ServiceMonitor and PrometheusRule objects to be discovered.

# monitoring-values.yaml
defaultRules:
  create: false
kubeStateMetrics:
  enabled: false
nodeExporter:
  enabled: false

prometheus:
  prometheusSpec:
    retention: 2h
    scrapeInterval: 5s
    evaluationInterval: 5s
    serviceMonitorSelectorNilUsesHelmValues: false
    podMonitorSelectorNilUsesHelmValues: false
    ruleSelectorNilUsesHelmValues: false
    remoteWrite:
      - url: http://mimir.observability.svc:9009/api/v1/push

alertmanager:
  config:
    global:
      resolve_timeout: 1m
    route:
      receiver: local-webhook
      group_by: [alertname, environment, component]
      group_wait: 5s
      group_interval: 10s
      repeat_interval: 1h
    receivers:
      # The chart's built-in Watchdog route targets this inert receiver.
      - name: "null"
      - name: local-webhook
        webhook_configs:
          - url: http://alert-sink.demo.svc:8080/alerts
            send_resolved: true

grafana:
  adminPassword: admin
  additionalDataSources:
    - name: Mimir
      type: prometheus
      access: proxy
      url: http://mimir.observability.svc:9009/prometheus
      isDefault: false
helm repo add prometheus-community \
  https://prometheus-community.github.io/helm-charts
helm repo update
helm upgrade --install monitoring \
  prometheus-community/kube-prometheus-stack \
  --version 86.0.0 \
  --namespace observability \
  --values monitoring-values.yaml \
  --wait --timeout 10m

kubectl get pods -n observability

Step 4 — deploy the fictional metrics application

The application uses only Python's standard library. /checkout increments a success or error counter; /metrics exports both counters in Prometheus text format.

# checkout-api.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: checkout-api
  namespace: demo
data:
  server.py: |
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import parse_qs, urlparse

    counts = {"success": 0, "error": 0}

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            parsed = urlparse(self.path)
            if parsed.path == "/checkout":
                failed = parse_qs(parsed.query).get("fail", ["0"])[0] == "1"
                result = "error" if failed else "success"
                counts[result] += 1
                body = f'{{"result":"{result}"}}\n'.encode()
                self.send_response(503 if failed else 200)
                self.send_header("Content-Type", "application/json")
            elif parsed.path == "/metrics":
                text = (
                    "# HELP checkout_requests_total Checkout attempts by result.\n"
                    "# TYPE checkout_requests_total counter\n"
                    f'checkout_requests_total{{result="success"}} {counts["success"]}\n'
                    f'checkout_requests_total{{result="error"}} {counts["error"]}\n'
                )
                body = text.encode()
                self.send_response(200)
                self.send_header("Content-Type", "text/plain; version=0.0.4")
            else:
                body = b"not found\n"
                self.send_response(404)
                self.send_header("Content-Type", "text/plain")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *_):
            pass

    HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-api
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: checkout-api
  template:
    metadata:
      labels:
        app: checkout-api
    spec:
      containers:
        - name: app
          image: python:3.12-alpine
          command: [python, /app/server.py]
          ports:
            - name: metrics
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /metrics
              port: metrics
          volumeMounts:
            - name: code
              mountPath: /app
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              memory: 128Mi
      volumes:
        - name: code
          configMap:
            name: checkout-api
---
apiVersion: v1
kind: Service
metadata:
  name: checkout-api
  namespace: demo
  labels:
    app: checkout-api
spec:
  selector:
    app: checkout-api
  ports:
    - name: metrics
      port: 8080
      targetPort: metrics
kubectl apply -f checkout-api.yaml
kubectl rollout status deployment/checkout-api -n demo --timeout=180s

Step 5 — add the local webhook receiver

The receiver prints every Alertmanager POST to its pod logs. It stands in for chat, paging, incident, or status-page automation without requiring credentials.

# alert-sink.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: alert-sink
  namespace: demo
data:
  server.py: |
    from http.server import BaseHTTPRequestHandler, HTTPServer

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"ok\n")

        def do_POST(self):
            size = int(self.headers.get("Content-Length", "0"))
            body = self.rfile.read(size).decode()
            print("ALERT_WEBHOOK " + body, flush=True)
            self.send_response(200)
            self.end_headers()

        def log_message(self, *_):
            pass

    HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: alert-sink
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: alert-sink
  template:
    metadata:
      labels:
        app: alert-sink
    spec:
      containers:
        - name: sink
          image: python:3.12-alpine
          command: [python, /app/server.py]
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /
              port: http
          volumeMounts:
            - name: code
              mountPath: /app
          resources:
            requests:
              cpu: 10m
              memory: 24Mi
            limits:
              memory: 64Mi
      volumes:
        - name: code
          configMap:
            name: alert-sink
---
apiVersion: v1
kind: Service
metadata:
  name: alert-sink
  namespace: demo
spec:
  selector:
    app: alert-sink
  ports:
    - name: http
      port: 8080
      targetPort: http
kubectl apply -f alert-sink.yaml
kubectl rollout status deployment/alert-sink -n demo --timeout=180s

Step 6 — discover the target and load the alert rule

The ServiceMonitor selects the Service's labels, then refers to its named port, not the container port number. The rule calculates the fraction of checkout attempts returning errors.

# checkout-monitoring.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: checkout-api
  namespace: demo
spec:
  namespaceSelector:
    matchNames: [demo]
  selector:
    matchLabels:
      app: checkout-api
  endpoints:
    - port: metrics
      path: /metrics
      interval: 5s
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: checkout-api
  namespace: demo
spec:
  groups:
    - name: checkout.rules
      rules:
        - alert: CheckoutErrorRateHigh
          expr: |
            sum(rate(checkout_requests_total{result="error"}[30s]))
            /
            clamp_min(sum(rate(checkout_requests_total[30s])), 0.001)
            > 0.20
          for: 30s
          labels:
            severity: critical
            environment: lab
            owner: payments
            component: storefront
          annotations:
            summary: Checkout error rate is above 20 percent
            description: More than one in five checkout attempts failed for 30 seconds.
            runbook_url: https://example.invalid/runbooks/checkout-errors
kubectl apply -f checkout-monitoring.yaml
kubectl get servicemonitor,prometheusrule -n demo
kubectl port-forward -n observability \
  service/monitoring-kube-prometheus-prometheus 9090:9090

Verify target discovery:

curl -fsS -G http://localhost:9090/api/v1/query \
  --data-urlencode 'query=up{service="checkout-api"}' \
  | jq '.data.result[] | {metric, value}'

Expected value:

{
  "metric": {
    "namespace": "demo",
    "service": "checkout-api"
  },
  "value": ["...", "1"]
}

If the result is empty, inspect the rendered configuration boundary:

kubectl get servicemonitor checkout-api -n demo -o yaml
curl -fsS http://localhost:9090/api/v1/targets \
  | jq '.data.activeTargets[] | select(.labels.service=="checkout-api")'

Step 7 — prove Prometheus remote writes to Mimir

Generate a baseline, wait for two scrapes, and query Mimir's Prometheus-compatible API:

kubectl run healthy-baseline -n demo --rm -i --restart=Never \
  --image=curlimages/curl:8.12.1 -- \
  sh -c 'for i in $(seq 1 20); do
    curl -sf http://checkout-api.demo.svc:8080/checkout >/dev/null
  done'

sleep 15

curl -fsS -G http://localhost:9009/prometheus/api/v1/query \
  --data-urlencode 'query=sum(checkout_requests_total)' \
  | jq '.data.result'

A non-empty vector proves:

  1. the app exported the series;
  2. Prometheus discovered and scraped it;
  3. Prometheus remote-wrote it;
  4. Mimir ingested it;
  5. Mimir's query path returned it.

Check the remote-write queue directly if the query is empty:

curl -fsS -G http://localhost:9090/api/v1/query \
  --data-urlencode \
  'query=prometheus_remote_storage_samples_failed_total' \
  | jq '.data.result'

kubectl logs -n observability deployment/mimir --tail=100

Step 8 — E2E: make the alert fire

Send sustained failures for long enough to cover the 30-second range vector and the separate 30-second for period:

kubectl delete pod failure-load -n demo --ignore-not-found
kubectl run failure-load -n demo --restart=Never \
  --image=curlimages/curl:8.12.1 -- \
  sh -c 'for i in $(seq 1 320); do
    curl -s http://checkout-api.demo.svc:8080/checkout?fail=1 >/dev/null
    sleep 0.25
  done'

Watch the state move from pending to firing:

watch -n 5 "curl -fsS http://localhost:9090/api/v1/alerts \
  | jq -r '.data.alerts[] |
  select(.labels.alertname==\"CheckoutErrorRateHigh\") |
  [.state,.labels.owner,.labels.component] | @tsv'"

Expected:

pending  payments  storefront
firing   payments  storefront

Then prove Alertmanager crossed the webhook boundary:

kubectl logs -n demo deployment/alert-sink --since=5m \
  | grep 'CheckoutErrorRateHigh'

The JSON contains:

{
  "status": "firing",
  "commonLabels": {
    "alertname": "CheckoutErrorRateHigh",
    "component": "storefront",
    "environment": "lab",
    "owner": "payments",
    "severity": "critical"
  }
}

Step 9 — E2E: make the alert resolve

Wait for the failure pod to finish, then make the range window healthy:

kubectl wait -n demo --for=jsonpath='{.status.phase}'=Succeeded \
  pod/failure-load --timeout=180s

kubectl run recovery-load -n demo --rm -i --restart=Never \
  --image=curlimages/curl:8.12.1 -- \
  sh -c 'for i in $(seq 1 240); do
    curl -sf http://checkout-api.demo.svc:8080/checkout >/dev/null
    sleep 0.1
  done'

sleep 60

kubectl logs -n demo deployment/alert-sink --since=10m \
  | grep '"status":"resolved"'

The resolved webhook proves the entire lifecycle, not merely alert creation. send_resolved: true is what tells Alertmanager to deliver this transition.

Step 10 — open Grafana, Prometheus, and Alertmanager locally

Run each port-forward in its own terminal:

# Grafana: admin / admin
kubectl port-forward -n observability \
  service/monitoring-grafana 3001:80

# Prometheus
kubectl port-forward -n observability \
  service/monitoring-kube-prometheus-prometheus 9090:9090

# Alertmanager
kubectl port-forward -n observability \
  service/monitoring-kube-prometheus-alertmanager 9093:9093

# Mimir
kubectl port-forward -n observability service/mimir 9009:9009

Open:

  • Grafana: http://localhost:3001 and select the Mimir data source.
  • Prometheus: http://localhost:9090/alerts.
  • Alertmanager: http://localhost:9093.
  • Mimir readiness: http://localhost:9009/ready.

Try this PromQL in Grafana Explore:

sum by (result) (rate(checkout_requests_total[5m]))

From webhook to incident and status page

The lab stops at a local webhook because incident products require credentials. In a real system the next boundary commonly looks like this:

Graph legend

BoundaryInputOutput
rule evaluationmetric seriespending/firing/resolved alert
Alertmanager routealert labelsgrouped receiver notification
incident workflowwebhook payloadincident or alert record
component mappingoptional component labelaffected component or unknown impact
publicationincident stateinternal or public status update

Two investigations require different evidence:

QuestionEvidence needed
Where is the rule, threshold, owner, and route defined?Git configuration and rendered Kubernetes objects
What is firing now and what value crossed the threshold?Live Prometheus/Mimir query plus Alertmanager/incident API access

Repository access can prove the intended configuration. It cannot prove current runtime state. Conversely, a screenshot can prove a visible incident but not which committed rule generated it. Reliable incident analysis joins both.

Common failure modes

SymptomCheckLikely cause
up{service="checkout-api"} is emptyPrometheus targets APIServiceMonitor selector or named port mismatch
Prometheus has data; Mimir does notremote storage failure metricswrong URL, Mimir not ready, rejected writes
Rule absent from /ruleskubectl get prometheusrule -o yamlrule selector or namespace selector excludes it
Rule pending but never firinggraph the exact expressionexpression drops below threshold during for
Alert fires; sink has no POSTAlertmanager status/config and logsroute mismatch, DNS failure, receiver unavailable
Incident shows unknown impactinspect alert labelsmissing component label
Duplicate pagesinspect group_by and external labelsgrouping too granular or HA replicas not deduplicated
Mimir pod restartskubectl describe podlaptop memory limit or invalid configuration

Production differences

This local lab deliberately removes production complexity. Before production:

  • deploy distributed Mimir with object storage and multiple replicas;
  • enable authentication and tenant IDs (X-Scope-OrgID);
  • use TLS and NetworkPolicies between writers and Mimir;
  • persist Prometheus WAL and Mimir state;
  • set Mimir ingestion/query limits and capacity alerts;
  • add HA Prometheus replicas with replica labels and deduplication;
  • version and test rules as code;
  • keep secrets in Kubernetes Secrets or an external secret manager;
  • monitor the monitoring stack itself;
  • use real runbooks and owned component labels;
  • test both firing and resolved receiver behavior.

Cleanup

Delete only the dedicated cluster:

kind delete cluster --name metrics-lab

Assignment

Extend the lab with a warning tier and a critical tier:

  1. warning when the checkout error rate is above 10% for 2 minutes;
  2. critical when it is above 25% for 1 minute;
  3. warnings go only to a second local webhook receiver;
  4. critical alerts keep the component: storefront label;
  5. remove component from the warning and explain the downstream result;
  6. prove firing and resolved delivery for both routes.

Answer

Use two rules with different thresholds, durations, and severity labels:

- alert: CheckoutErrorRateWarning
  expr: |
    sum(rate(checkout_requests_total{result="error"}[2m]))
    /
    clamp_min(sum(rate(checkout_requests_total[2m])), 0.001)
    > 0.10
  for: 2m
  labels:
    severity: warning
    environment: lab
    owner: payments
  annotations:
    summary: Checkout error rate is above 10 percent

- alert: CheckoutErrorRateCritical
  expr: |
    sum(rate(checkout_requests_total{result="error"}[1m]))
    /
    clamp_min(sum(rate(checkout_requests_total[1m])), 0.001)
    > 0.25
  for: 1m
  labels:
    severity: critical
    environment: lab
    owner: payments
    component: storefront
  annotations:
    summary: Checkout error rate is above 25 percent

Add severity-first child routes:

route:
  receiver: local-webhook
  group_by: [alertname, environment, component]
  routes:
    - matchers:
        - severity="warning"
      receiver: warning-webhook
    - matchers:
        - severity="critical"
      receiver: local-webhook

The critical notification can map directly to storefront. The warning lacks a component, so downstream automation must leave impact unknown or require a human to attach the component. That behavior is safer than guessing from the alert name.

Further reading