monitoring
Observability stack: kube-prometheus-stack (Prometheus + Grafana + AlertManager), Grafana Tempo (distributed tracing), Grafana Loki (log aggregation), and Grafana Alloy (OTLP telemetry pipeline). All
SKILL.mdskills/monitoring/SKILL.md
GitHubMonitoring Skill
Stack Overview
| Component | Chart | Version | App Version | Namespace |
|---|---|---|---|---|
| Prometheus + Grafana + AlertManager | prometheus-community/kube-prometheus-stack |
82.17.0 | v0.89.0 | monitoring |
| Tempo (tracing backend) | grafana-community/tempo |
1.26.7 | 2.10.1 | monitoring |
| Loki (log aggregation) | grafana/loki |
6.55.0 | 3.x | monitoring |
| Alloy (telemetry pipeline) | grafana/alloy |
1.7.0 | v1.15.0 | monitoring |
| version-checker (image version tracking) | jetstack/version-checker |
0.10.0 | 0.10.0 | monitoring |
| helm-dashboard (Helm release UI, read-only) | komodorio/helm-dashboard |
2.0.6 | 2.1.1 | monitoring |
| Docker registry:2 (ARM64 image storage) | N/A (kubectl) | 2 | registry |
Grafana is included in kube-prometheus-stack — do NOT install a separate Grafana chart.
Helm Repositories
# prometheus-community (kube-prometheus-stack)
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
# grafana (alloy, loki, etc.)
helm repo add grafana https://grafana.github.io/helm-charts
# grafana-community (tempo — migrated here after Jan 2026)
helm repo add grafana-community https://grafana-community.github.io/helm-charts
IMPORTANT:
grafana/tempoandgrafana/tempo-distributedare deprecated. Usegrafana-community/tempo(single-binary) orgrafana-community/tempo-distributed.
Critical: Tempo chart version
grafana-community/tempo v2.0.0 has a template bug:
it generates overrides.defaults: {} in the Tempo config which Tempo 2.10.x rejects:
field defaults not found in type overrides.legacyConfig
Use v1.26.7 (latest 1.26.x) — same app version 2.10.1, no bug.
Architecture
Apps (OpenTelemetry instrumented) All Pod Logs
│ OTLP gRPC :4317 / HTTP :4318 │
▼ ▼
[Alloy DaemonSet] discovery.kubernetes → discovery.relabel
otelcol.receiver.otlp → otelcol.exporter.otlp.tempo (traces)
loki.source.kubernetes → loki.write (logs)
│ │
│ OTLP gRPC │ HTTP push
▼ ▼
[Tempo StatefulSet] [Loki StatefulSet + MinIO]
← single binary, local-path ← single binary, MinIO 1Gi object storage
metricsGenerator → remote_write ← chunksCache/resultsCache disabled (CM4 memory)
│
│ remote_write → :9090/api/v1/write
▼
[Prometheus StatefulSet] ← local-path PVC 20Gi
│
▼
[Grafana Deployment]
datasources: Prometheus + Tempo + Loki
HTTPRoute: grafana.cluster.home → kube-prometheus-stack-grafana:80
k3s-specific: disabled components
These k8s components are embedded in the k3s server process and do NOT expose metrics:
kubeControllerManager:
enabled: false # embedded in k3s
kubeScheduler:
enabled: false # embedded in k3s
kubeEtcd:
enabled: false # embedded etcd, no external port
kubeProxy:
enabled: false # replaced by Cilium BPF
Enabled (work fine on k3s):
kubelet,coreDns,kubeStateMetrics,nodeExporter,prometheusOperator
Grafana: Gateway API routing
Grafana uses ClusterIP + HTTPRoute — same pattern as ArgoCD and Pi-hole.
# HTTPRoute (created by install-kube-prometheus-stack role):
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: grafana
namespace: monitoring
spec:
parentRefs:
- name: cluster-gateway
namespace: gateway
hostnames:
- grafana.cluster.home
rules:
- backendRefs:
- name: kube-prometheus-stack-grafana
port: 80
Pi-hole wildcard *.cluster.home → .200 covers DNS automatically.
cert-manager wildcard TLS cert already covers *.cluster.home.
Tempo: metricsGenerator
Generates RED (Rate, Error, Duration) metrics from traces → remote_write to Prometheus. Enables trace↔metric correlation in Grafana.
Processors enabled: service-graphs, span-metrics.
Remote write URL: http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/write
Alloy: OTLP pipeline + Kubernetes log collection
Alloy runs as a DaemonSet on every node with two pipelines:
Traces pipeline:
- Receives traces via OTLP from instrumented apps
- Service ports:
4317(gRPC),4318(HTTP) - Forwards to Tempo via
otelcol.exporter.otlp
Logs pipeline:
discovery.kubernetesdiscovers all podsdiscovery.relabelmaps K8s metadata to labels (namespace, pod, container, job)loki.source.kubernetestails container logs on each nodeloki.writesends to Loki withtenant_id: fake
To instrument an app (environment variables):
OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy.monitoring:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
Query logs in Grafana Explore: {namespace="monitoring"} or {pod="my-app-xyz"}
Loki: log aggregation
SingleBinary mode with MinIO for object storage.
Key details:
- Chart:
grafana/lokiv6.55.0 (fromgrafanaHelm repo) - Mode:
singleBinary— all components in one pod - Object storage: MinIO (bundled, 1Gi PVC via local-path)
- Retention: 24h (CM4 has limited storage)
- Endpoint:
loki-gateway.monitoring:80 - Multi-tenancy: enabled by default. All requests require
X-Scope-OrgId: fakeheader. - Caches:
chunksCacheandresultsCacheare disabled — they request 8GB+ memory which exceeds CM4 capacity.
Querying Loki from outside the cluster:
kubectl port-forward -n monitoring svc/loki-gateway 3100:80
curl -H 'X-Scope-OrgId: fake' 'http://localhost:3100/loki/api/v1/labels'
Grafana datasource config (set via kube-prometheus-stack additionalDataSources):
- name: Loki
type: loki
url: http://loki-gateway.monitoring:80
access: proxy
jsonData:
httpHeaderName1: X-Scope-OrgId
secureJsonData:
httpHeaderValue1: fake
Grafana Datasources
Automatically configured via kube-prometheus-stack values:
| Datasource | URL | Notes |
|---|---|---|
| Prometheus | http://kube-prometheus-stack-prometheus.monitoring:9090 |
auto by chart |
| Tempo | http://tempo.monitoring:3200 |
added via additionalDataSources |
| Loki | http://loki-gateway.monitoring:80 |
added via additionalDataSources; requires X-Scope-OrgId: fake header |
Tempo datasource features enabled:
tracesToMetrics→ correlate traces to Prometheus metricsserviceMap→ service dependency graphnodeGraph→ node graph visualization
Storage Dependency
All monitoring roles use smb-nas StorageClass by default.
The storage backend (install-cifs-nas) is auto-installed as the first task of each role — no need to run make storage first.
| Role | Storage var | Default SC |
|---|---|---|
install-kube-prometheus-stack |
kube_prometheus_stack_storage_class |
smb-nas |
install-loki |
loki_storage_class |
smb-nas |
install-tempo |
tempo_storage_class |
smb-nas |
To override to local-path (no NAS required):
ansible-playbook playbooks/bootstrap.yml -i inventory/hosts.ini --tags observability \
-e "kube_prometheus_stack_storage_class=local-path loki_storage_class=local-path tempo_storage_class=local-path"
See skills/storage/SKILL.md for full pattern documentation.
Ansible Roles
| Role | File |
|---|---|
install-kube-prometheus-stack |
roles/install-kube-prometheus-stack/ |
install-tempo |
roles/install-tempo/ |
install-loki |
roles/install-loki/ |
install-alloy |
roles/install-alloy/ |
Bootstrap order (after install-argocd):
install-kube-prometheus-stack → install-tempo → install-loki → install-alloy
Tempo depends on Prometheus being up (metricsGenerator remote_write). Loki is independent — installs after Tempo for ordering consistency. Alloy depends on Tempo being up (OTLP exporter endpoint).
Health Checks
# All monitoring pods
kubectl get pods -n monitoring
# Helm releases
helm list -n monitoring
# Prometheus targets (check k3s components are scraped)
kubectl port-forward -n monitoring svc/kube-prometheus-stack-prometheus 9090:9090
# then: http://localhost:9090/targets
# Grafana (via HTTPRoute)
curl -s https://grafana.cluster.home/api/health
# Tempo ready
kubectl exec -n monitoring tempo-0 -- wget -qO- http://localhost:3200/ready
# Loki ready
kubectl port-forward -n monitoring svc/loki-gateway 3100:80 &
curl -s -H 'X-Scope-OrgId: fake' http://localhost:3100/ready
# Loki labels (verify it's receiving logs)
curl -s -H 'X-Scope-OrgId: fake' 'http://localhost:3100/loki/api/v1/labels'
# Alloy UI
kubectl port-forward -n monitoring svc/alloy 12345:12345
# then: http://localhost:12345
Upgrade Procedure
- Check latest versions:
helm search repo prometheus-community/kube-prometheus-stack --versions | head -3 helm search repo grafana-community/tempo --versions | head -5 helm search repo grafana/loki --versions | head -3 helm search repo grafana/alloy --versions | head -3 - Update version in
roles/<role>/defaults/main.yml - Check release notes for breaking changes
- Re-run bootstrap:
ansible-playbook playbooks/bootstrap.yml -i inventory/hosts.ini \ --start-at-task "Add prometheus-community Helm repository"
Troubleshooting
CRITICAL: NEVER delete Helm secrets or uninstall releases on upgrade failure
→ If helm upgrade fails with "release: already exists" or "another operation in progress":
- Check
helm status <release> -n <namespace>— the release may already be deployed - Check
helm history <release> -n <namespace>— see if the upgrade actually succeeded - Check
kubectl get pods -n <namespace> -l app.kubernetes.io/name=<release>— pods may be running fine - If the release is "deployed" and pods are healthy, the upgrade likely succeeded despite the error
→ Deleting Helm secrets (
kubectl delete secret sh.helm.release.v1.*) or runninghelm uninstalldestroys the release state and makes idempotent re-runs impossible. Only do this as absolute last resort after confirming the release is genuinely corrupted.
Tempo TraceQL metrics: "localblocks processor not found"
→ Tempo needs local-blocks processor for TraceQL metrics queries ({ } | rate(), etc.)
→ Add to overrides.defaults.metrics_generator.processors: [service-graphs, span-metrics, local-blocks]
→ See roles/install-tempo/tasks/main.yml for the correct config
→ The kubernetes.core.helm module 6.2.0 has a bug where it reports "release does not exist"
and "already exists" simultaneously on upgrade -i. All roles now use helm list -o json --filter <name>
set_factto check deployment status and skip install when already deployed.
Alloy not collecting pod logs (Grafana Explore shows no logs)
→ loki.source.kubernetes requires discovery.relabel to map K8s metadata labels.
Using stage.labels in loki.process does NOT work for discovery metadata.
→ Correct pattern: discovery.kubernetes → discovery.relabel → loki.source.kubernetes → loki.write
→ Verify: kubectl logs -n monitoring -l app.kubernetes.io/name=alloy | grep "opened log stream"
Tempo CrashLoopBackOff: field defaults not found in type overrides.legacyConfig
→ You are using chart v2.0.0. It has a template bug. Use v1.26.7.
Grafana pod 0/3 (third container not ready)
→ Grafana has a slow readiness probe (60s initialDelay). Wait ~2 minutes after pods start.
Prometheus targets showing 0/1 up for k3s components
→ Expected for kubeControllerManager, kubeScheduler, kubeEtcd, kubeProxy — these are disabled.
→ If kubelet or coredns shows down, check Cilium network policies.
Alloy not forwarding traces
→ Check Alloy config: kubectl get configmap -n monitoring alloy -o yaml
→ Check Alloy logs: kubectl logs -n monitoring -l app.kubernetes.io/name=alloy
→ Verify Tempo endpoint: kubectl get svc -n monitoring tempo
Grafana HTTPRoute not working
→ Check route: kubectl get httproute -n monitoring
→ Check Gateway: kubectl get gateway -n gateway
→ Verify DNS: dig grafana.cluster.home @192.168.178.203
Loki not receiving logs / Grafana shows no data
→ Check Loki pods: kubectl get pods -n monitoring -l app.kubernetes.io/name=loki
→ Check Loki gateway: kubectl get svc loki-gateway -n monitoring
→ Verify ready: kubectl port-forward -n monitoring svc/loki-gateway 3100:80 then curl -H 'X-Scope-OrgId: fake' http://localhost:3100/ready
→ Missing X-Scope-OrgId header → Grafana datasource must set it (via secureJsonData.httpHeaderValue1: fake)
Loki MinIO not ready
→ Check MinIO pod: kubectl get pods -n monitoring -l app.kubernetes.io/name=minio
→ Check PVC: kubectl get pvc -n monitoring
→ MinIO needs local-path StorageClass — ensure K3s has it enabled
loki-chunks-cache-0 stuck in Pending
→ Optional cache component. Safe to ignore — Loki works without it. → If you want to fix: ensure a StorageClass exists that can provision PVCs for the cache.