kube-prometheus-stack on multi-cluster: spoke Prometheus, central Grafana

Published: 2026-02-27

Every spoke cluster runs its own Prometheus. Grafana runs once, on infra, and federates from all of them. No Thanos, no remote-write, no extra moving parts.


Why one Grafana on infra

Cross-cluster Grafana with multiple Prometheus datasources is simpler than remote-write or Thanos when you have fewer than ten clusters. Each Prometheus retains 7 days of data locally. Grafana queries them directly using cluster-specific datasources. Long-term storage goes to VictoriaLogs (for logs) — metrics beyond 7 days aren't needed in this setup.

Comparison:

Approach Complexity Unified view Long-term storage
Multi-datasource Grafana Low Yes (manual switch) No
Remote-write to central Medium Yes Yes
Thanos High Yes Yes

For ≤10 clusters with 7-day retention needs, multi-datasource Grafana is the pragmatic choice.


Spoke HelmRelease: Grafana disabled

The base/monitoring/kube-prom-stack/helmrelease.yaml applies to every spoke:

yamlvalues:
  grafana:
    enabled: false        # central Grafana on infra only

  defaultRules:
    create: false         # custom rules managed separately

  kubeScheduler:
    enabled: false        # k3s doesn't expose scheduler metrics
  kubeProxy:
    enabled: false
  kubeControllerManager:
    enabled: false

  alertmanager:
    enabled: false        # Alertmanager disabled in base

kubeScheduler, kubeProxy, and kubeControllerManager are disabled because k3s doesn't expose these component metrics endpoints by default.

defaultRules: create: false because custom PrometheusRules are managed in a separate Kustomization — this avoids conflicts between chart-generated rules and custom ones.


Prometheus image

The standard quay.io/prometheus/prometheus image is replaced with prompp/prompp — a drop-in replacement with PromQL improvements:

yamlprometheus:
  prometheusSpec:
    image:
      registry: registry.deckhouse.io
      repository: prompp/prompp
      tag: "0.7.6"
    version: v3.2.1
    securityContext:
      fsGroup: 64535
      runAsGroup: 64535
      runAsNonRoot: true
    retentionSize: "18GB"

retentionSize prevents the PVC from filling up when retention: 7d is set. Without it, a traffic spike can produce more than expected data volume.


Per-env patches

Each environment has a patch that sets environment-specific things: storageClass and retention:

yaml# projects/dev/kustomization/hub/patches/kube-prom-stack.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: kube-prom-stack
  namespace: dev
spec:
  values:
    prometheus:
      prometheusSpec:
        retention: 7d
        storageSpec:
          volumeClaimTemplate:
            spec:
              storageClassName: local-path   # on-prem
              resources:
                requests:
                  storage: 20Gi
        externalLabels:
          cluster: dev                       # label all metrics with cluster name

The externalLabels.cluster is crucial for multi-cluster Grafana — it lets you filter dashboards by cluster.

For Yandex Cloud environments:

yamlstorageClassName: yc-network-ssd
tolerations:
  - key: "node.kubernetes.io/not-ready"
    operator: "Exists"

Alertmanager on infra only

Alertmanager is disabled in the base HelmRelease. Only infra enables it via patch, with routing rules for Telegram and Mattermost. Spoke Prometheus instances send their alerts to the infra Alertmanager via the standard Prometheus alerting configuration.

yaml# infra patch
prometheus:
  prometheusSpec:
    alerting:
      alertmanagers:
        - namespace: observability
          name: alertmanager-operated
          port: web

ServiceMonitor convention

All custom exporters use labels: { release: kube-prom-stack } on their ServiceMonitor objects. The Prometheus operator discovers ServiceMonitors with this label:

yamlprometheus:
  prometheusSpec:
    serviceMonitorSelector:
      matchLabels:
        release: kube-prom-stack
    podMonitorSelector:
      matchLabels:
        release: kube-prom-stack

Application metrics sit on port 9595 in the mon namespace. One ServiceMonitor covers all app services by matching the app label selector.


Grafana on infra

The infra patch enables Grafana and adds all spoke datasources:

yamlgrafana:
  enabled: true
  ingress:
    enabled: true
    hosts:
      - grafana.infra.test.antonnovikov.com
  additionalDataSources:
    - name: prometheus-dev
      type: prometheus
      url: http://prometheus-operated.observability.dev.svc.cluster.local:9090
      jsonData:
        timeInterval: "30s"
    - name: prometheus-staging
      type: prometheus
      url: http://prometheus-operated.observability.staging.svc.cluster.local:9090

Each datasource corresponds to one spoke cluster. In Grafana dashboards, switch between clusters by changing the datasource variable.


Checking status

bash# All Prometheus instances across all spokes
kubectl get prometheus -A --context=infra-k8s

# Targets on a specific spoke
kubectl port-forward \
  -n observability svc/prometheus-operated 9090:9090 \
  --context=dev-k8s
# then open localhost:9090/targets

# Check that externalLabels are applied
curl -s http://localhost:9090/api/v1/labels | jq '.data[] | select(. == "cluster")'

Troubleshooting

bash# Prometheus not discovering ServiceMonitors
kubectl get servicemonitor -A --context=dev-k8s
kubectl describe prometheus -n observability --context=dev-k8s | grep -A5 "Status"

# Check TSDB head stats (storage pressure)
curl -s http://localhost:9090/api/v1/status/tsdb | jq .data.headStats

# Reload Prometheus config without restart
kubectl exec -n observability prometheus-kube-prom-stack-prometheus-0 -- \
  kill -HUP 1