Monitoring¶
Full observability stack based on kube-prometheus-stack.
One chart, five components, and roughly a hundred alerting rules you did not write and should read at least once. The default rule set is genuinely good; the default routing is not, which is what most of this page is about.
At a glance¶
| Namespace | monitoring |
| Sync wave | 1; its CRDs arrive earlier, with make install-core |
| Depends on | Rook-Ceph for Prometheus and Grafana volumes, Authentik for the Grafana login |
| If it is down | No alerts and no metrics — and Kured loses the alert gate it checks before rebooting a node |
| Health check | kubectl -n monitoring get prometheus,alertmanager; the Watchdog alert should always be firing |
| UI | monitoring.infra.k8s.wlkr.ch (Grafana), prometheus. and alertmanager. |
Components¶
- Prometheus: Metrics collection and storage with 10-day retention.
- Grafana: Dashboards at https://monitoring.infra.k8s.wlkr.ch.
- Alertmanager: Routes to an email receiver.
- Node Exporter: Per-node hardware and OS metrics.
- kube-state-metrics: Kubernetes object metrics (pod status, deployments, etc.).
CRDs¶
The chart owns the monitoring.coreos.com CRDs -- crds.enabled, plus an
upgrade job that re-applies them on every chart bump -- but it is not the first
thing in the cluster that needs them. Cilium's chart aborts its render while
monitoring.coreos.com/v1 is missing, cert-manager renders a ServiceMonitor
unconditionally, and both sync waves ahead of this stack, at bootstrap and on
every rebuild since. So make install-cilium applies the CRD files out of this
same chart, at the version application.yaml pins, and the upgrade job adopts
them when the stack itself lands.
Alerting¶
Alertmanager routes to email. The chart's default is a null receiver that
swallows every alert, which is worse than not deploying it at all: the stack
looks configured, the dashboards are full of data, the rules evaluate correctly,
and every single alert goes into a bin. Monitoring you believe in but that
cannot reach you is strictly more dangerous than no monitoring, because it buys
false confidence. So the route and receiver below are set explicitly.
| Property | Value |
|---|---|
| Receiver | email, to the address in kv/monitoring/smtp |
| Smarthost | smtp.mailbox.org:465, implicit TLS |
| Login, sender, password | kv/monitoring/smtp in OpenBao; the sender is the login |
| Grouping | By alertname and namespace |
| Repeat | 12h, or 3h for severity = critical |
| Resolved | Sent — a "back to normal" mail follows the alert |
Watchdog is routed to null on purpose. It fires continuously by design, as
proof the pipeline is alive; mailing it every twelve hours would train the
recipient to filter the sender — and a filtered alert sender is how outages get
missed. Alert fatigue is not a personal failing, it is a design outcome, and the
design is under your control.
Why the whole config comes from OpenBao¶
Only the password is a credential, but the login and the recipient are email
addresses, and those are not published in this repository. Alertmanager can
read the password from a file (smtp_auth_password_file) and nothing else:
there is no file form for the username, from or to. A config rendered by the
chart would have to spell them out.
So the chart's config is off (alertmanagerSpec.useExistingSecret), and
alertmanager-config.yaml renders the complete alertmanager.yaml with ESO
into the Secret named by alertmanagerSpec.configSecret. Routes, receivers and
inhibit rules are still written out in that file's template, so they stay
reviewable; only the three values below are filled in from OpenBao. The inhibit
rules are the chart's defaults, repeated: a supplied config replaces the whole
document, so nothing is inherited. Each templated value goes through toJson,
which makes it a quoted YAML string — a password containing :, # or a quote
cannot break the rendered file.
Key in kv/monitoring/smtp |
Used as |
|---|---|
username |
smtp_auth_username and smtp_from — mailbox.org refuses a sender the login does not own |
password |
smtp_auth_password, the account or an app password |
to |
The email receiver's recipient |
make bao-secrets prompts for all three. By hand:
Port 465 is implicit TLS, which Alertmanager selects from the port number;
smtp_require_tls only applies to STARTTLS and stays on so a move back to 587
cannot fall to plaintext. For a different mail provider, change
smtp_smarthost in the template and the three values in OpenBao.
Checking it works¶
Watchdog proves the pipeline as far as Alertmanager and not one step further.
Everything between Alertmanager and your inbox — SMTP auth, the provider's spam
heuristics, a password that expired — is unmonitored. To prove delivery, inspect
what Alertmanager thinks it is doing, and then, once, actually send yourself a
test alert and confirm it arrives:
kubectl -n monitoring port-forward svc/kube-prometheus-stack-alertmanager 9093:9093
# then open http://localhost:9093 and check Status -> Config
What is scraped¶
Every ServiceMonitor, PodMonitor and PrometheusRule in every namespace.
That takes the five *SelectorNilUsesHelmValues: false settings in
application.yaml, not the empty selectors they replaced: the chart treats
serviceMonitorSelector: {} as unset and renders
release: kube-prometheus-stack in its place, which quietly limits Prometheus
to this chart's own targets. Check the rendered result, not the values:
kubectl -n monitoring get prometheus kube-prometheus-stack-prometheus \
-o jsonpath='{.spec.serviceMonitorSelector}' # should print {}
Ceph reaches Prometheus once monitoring.enabled is true on the
CephCluster — that switch lives in Rook-Ceph rather than here.
kube-scheduler, kube-controller-manager and etcd are scraped too, but only
because the provisioning config binds them somewhere reachable. kubeadm binds
the first two to 127.0.0.1 and gives etcd no metrics listener, which leaves
their ServiceMonitors permanently down and KubeSchedulerDown,
KubeControllerManagerDown and the etcd alerts permanently firing. Three alerts
that are always red is how a team learns to ignore red, so this is worth fixing
rather than silencing. The
bind-address and listen-metrics-urls arguments in
ansible/templates/kubeadm.yaml.j2 are what make these targets real.
The scheduler and controller-manager serve metrics over HTTPS with a
self-signed certificate for the node address, so their ServiceMonitors set
insecureSkipVerify — the scrape still authenticates with the ServiceAccount
token. etcd's metrics listener on port 2381 is plain HTTP and serves only
/metrics and /health; the client and peer APIs stay on their mTLS
listeners.
Note
A cluster provisioned before that change keeps the old flags — they are
baked into the static pod manifests in /etc/kubernetes/manifests/. Until
those nodes are reprovisioned or the manifests edited, expect those three
targets to be down.
kube-proxy is not scraped at all (kubeProxy.enabled: false): Cilium replaces
it, so there is nothing to scrape, and leaving the chart's default on keeps
KubeProxyDown firing forever.
Accessing Grafana¶
Grafana is exposed via the infra-gateway at monitoring.infra.k8s.wlkr.ch,
behind Authentik. There is no login form: disable_login_form
and oauth_auto_login are both set, so opening the URL bounces straight to
Authentik and back.
Your role comes from group membership, evaluated by role_attribute_path:
| Authentik group | Grafana role |
|---|---|
grafana-admins |
Admin |
grafana-editors |
Editor |
| neither | Viewer |
The OIDC client ID and secret are not minted by Grafana. They are generated once
into OpenBao and materialised as the grafana-oidc Secret by
grafana-oidc.yaml, then read by both sides — which is what lets a rebuilt
Grafana and a rebuilt Authentik still agree with each other. See
Authentik → Client secrets are generated up front.
The admin account still exists
Disabling the login form hides it, it does not remove it. The local admin remains reachable through the API, which is the break-glass path for when Authentik is down — see When Authentik is down. Do not set grafana.adminPassword in application.yaml to make that easier; it would commit a credential to Git.
The admin password¶
The admin password lives in OpenBao at kv/monitoring/grafana-admin and reaches
Grafana through the grafana-admin Secret, rendered by grafana-admin.yaml and
named in grafana.admin.existingSecret. make bao-secrets generates it.
Leaving existingSecret unset is not harmless. The chart then generates a new
random password on every render, so ArgoCD always sees its Secret as OutOfSync,
and the checksum/secret annotation on the Deployment changes with it: every
sync restarts Grafana.
Grafana reads the password only when it creates its database, and the database lives on the PVC. Changing the value in OpenBao later does not change the password Grafana checks. Reset it to match:
kubectl -n monitoring exec deploy/kube-prometheus-stack-grafana -c grafana -- \
grafana cli admin reset-admin-password "$PASSWORD"
Dashboards¶
Besides the Kubernetes dashboards kube-prometheus-stack ships, each component brings its own, from upstream. Where the chart can render one, the chart does; only charts without a dashboard get a vendored copy. Renovate does not update vendored copies; the header of each file names the upstream tag it came from.
| Component | Dashboards | Source |
|---|---|---|
| Cilium | Cilium Metrics, Cilium Operator, four Hubble dashboards | Chart: dashboards, operator.dashboards, hubble.metrics.dashboards |
| OpenBao | OpenBao | Chart: serverTelemetry.grafanaDashboard |
| External Secrets | External Secrets Operator | Chart: grafanaDashboard |
| Kubescape | Kubescape | Written for this cluster: kubescape/grafana-dashboard.yaml |
| Rook-Ceph | Ceph Cluster, Ceph - OSD (Single), Ceph - Pools | Vendored from Rook: rook-ceph/grafana-dashboards.yaml |
| Alloy | Alloy / Controller, Alloy / Loki Components, Alloy / Resources | Vendored from the Alloy mixin: logging/grafana-dashboards.yaml |
| ArgoCD | ArgoCD | Vendored from Argo CD: payload/argocd/argocd-dashboard.yaml |
Adding a Dashboard¶
Grafana is configured with persistent storage (Rook-Ceph). Dashboards can be added:
- Via the UI: Changes persist across restarts because of the PVC.
- Via ConfigMap: Add a ConfigMap with the label
grafana_dashboard: "1"next to the component it describes. The sidecar watches every namespace (sidecar.dashboards.searchNamespace: ALL) and imports it. - Via the component's chart: Many charts can render that ConfigMap themselves. Prefer it over a vendored copy, since it follows the chart's version.
Prefer the ConfigMap for anything you would be annoyed to lose. A dashboard built in the UI lives in one PVC and nowhere else — it is not in Git, Velero is its only copy, and it will not follow you to a rebuilt cluster. Every organisation has exactly one irreplaceable dashboard that someone made in the UI four years ago, and nobody knows how to recreate it.
Resources and rollout¶
Memory limits are sized at roughly 2.5 times the measured peak working set.
The Grafana sidecars peaked at 89Mi and 92Mi; the operator, its config
reloader, kube-state-metrics, node-exporter and Alertmanager each have their
own limits in application.yaml.
Prometheus and Grafana are deliberately uncapped. Prometheus peaked at 1501Mi and grows with series count and retention; Grafana peaked at 680Mi, which is high enough for a dashboard renderer that it wants explaining before it is capped. Neither can be sized from a day of steady state, and OOMKilling the thing that tells you the cluster is unhealthy is the specific failure worth avoiding.
Grafana uses the Recreate deployment strategy. Its dashboard PVC is
ReadWriteOnce, and under the chart's default RollingUpdate the new pod waits
for a volume the old pod releases only once the new one is Ready — a rollout
parks at FailedAttachVolume: Volume is already used by pod(s) until someone
deletes a pod by hand. A few seconds of downtime is the price of a
single-replica, single-volume Grafana.
Alertmanager does not mount a ServiceAccount token
(automountServiceAccountToken: false). No RoleBinding or ClusterRoleBinding
names it — the operator talks to the API, not Alertmanager — so the token would
buy nothing and leave a working credential in a pod reachable from the Gateway.
Directory Structure¶
monitoring/ # Observability Stack
├── application.yaml # kube-prometheus-stack (Helm chart)
├── grafana-admin.yaml # ExternalSecret: Grafana admin password
├── grafana-oidc.yaml # ExternalSecret: Grafana OIDC client
├── alertmanager-config.yaml # ExternalSecret: the whole Alertmanager config
└── httproute.yaml # Grafana route