Skip to content

Logging

Loki stores logs; Grafana Alloy collects them. Both are queried from the existing Grafana at monitoring.infra.k8s.wlkr.ch.

Metrics answer what is happening; logs answer why. Without them, diagnosing a crashed pod means racing the scheduler to kubectl logs --previous before the evidence is garbage-collected — a game you lose roughly every time it matters. Node-level problems are worse: visible only over SSH, on the node that is currently misbehaving.

Not Promtail

Promtail is the collector most Loki documentation still shows. It was deprecated in early 2025 and reached end of life in March 2026. Alloy is its supported replacement and the one to reach for now.

At a glance

Namespace logging
Sync wave 1 parent, 2 Loki, 3 Alloy — the collector last, so it has somewhere to ship
Depends on Rook-Ceph object storage for chunks, Monitoring for the Grafana that queries it
If it is down Logs stop being collected and are not backfilled. The audit log loses its durable copy
Health check kubectl -n logging get pods, then a {job="kubernetes-audit"} query in Grafana
Metrics Loki and Alloy, each through its chart's ServiceMonitor; Alloy's dashboards are listed under Monitoring

What is collected

Source Where it comes from Labels
Container logs /var/log/pods/<ns>_<pod>_<uid>/<container>/*.log namespace, pod, container, node, app
Node journal /var/log/journal unit, node, job="systemd-journal"
API server audit log /var/log/kubernetes/audit/audit.log verb, audit_level, node, job="kubernetes-audit"

The journal matters more here than it would elsewhere. On Flatcar, kubelet, containerd, systemd-sysupdate and update-engine log to journald and nowhere else. Without collection, the logs explaining a failed boot or a stuck sysext are reachable only over SSH — which is exactly the moment SSH is least convenient, and occasionally the moment it is not available at all.

Each Alloy pod discovers only pods on its own node, via a spec.nodeName field selector. Without it every one of the six agents would watch every pod in the cluster and discard all but its own — six times the API server load for identical output. Log collectors are famously good at costing more than the thing they observe; this is one of the cheap ways to avoid that.

The audit log is a different case again. It exists only on the three control-plane nodes and it is JSON rather than text, so it gets its own pipeline. It lives on the root filesystem and survives a reboot there — collecting it is still what makes it queryable next to everything else, and what keeps a copy when the node itself is the thing that failed. See Audit logging for what is recorded and at which level. Alloy runs on all six nodes and local.file_match simply finds nothing on the workers, which is a cheaper way to say "control plane only" than any scheduling constraint. It does need a toleration for the control-plane NoSchedule taint, though: without one Alloy runs on the workers alone, and neither the audit log nor the control-plane kubelets' journals reach Loki.

All three sources Alloy reads — /var/log/pods, /var/log/journal and /var/log/kubernetes/audit — are directories on the 50GB root filesystem, so one varlog: true mount in the chart covers all three.

Container logs pass through stage.cri {}. containerd writes <timestamp> <stream> <flags> <message>; without that stage the timestamp and stream end up inside the log line and Loki stamps everything at ingest time — which quietly destroys the one property you actually needed, namely being able to line logs up against the incident. Audit events get the same treatment from stage.timestamp, which stamps each one with its requestReceivedTimestamp rather than the moment Alloy read the file.

The journal's job="systemd-journal" label is set by a relabel rule, not the source's labels argument. loki.source.journal overwrites job with its component ID after applying labels, and only the relabel rules run after that.

Running Alloy

Alloy runs as root (runAsUser: 0). The log files are root-owned with restrictive modes, and a non-root Alloy does not fail — it silently collects nothing. Everything else is locked down around that: a read-only root filesystem, no capabilities, no privilege escalation, and the RuntimeDefault seccomp profile, which is worth having precisely because the process is root.

The read-only root has one side effect. Alloy writes its data directory to storagePath, which the chart defaults to /tmp/alloy, and every pod would die at startup with mkdir /tmp/alloy: read-only file system. An emptyDir mounted at /tmp restores what the chart assumes. It holds file-tailing positions and does not survive a restart — nor would the container layer it replaces.

Storage

Loki runs as a single binary and keeps chunks in the Ceph object store rather than on a PVC.

Property Value
Deployment mode SingleBinary, 1 replica
Chunks + ruler S3 bucket loki, via the loki-bucket ObjectBucketClaim
Endpoint rook-ceph-rgw-object-store.rook-ceph.svc, path-style, plain HTTP in-cluster
Local PVC 10Gi on rook-ceph-block, for the WAL and the index being built
Retention 30 days, compactor enabled

The bucket uses a fixed bucketName rather than generateBucketName. A generated name carries a random suffix, which would have to be read back out of the ConfigMap and injected at runtime; a fixed name keeps the storage config static and, more usefully, keeps it the same after a rebuild.

Credentials are never written to Git. Rook puts them in a Secret when it provisions the claim, Loki reads them as environment variables, and the config refers to ${AWS_ACCESS_KEY_ID} — which is why -config.expand-env=true is set.

Those settings belong on singleBinary, not global

The chart's global.extraArgs and global.extraEnvFrom look like the right place — the name says global, the documentation implies global — but the single-binary StatefulSet template reads only singleBinary.extraArgs and singleBinary.extraEnvFrom. Setting them globally renders a config full of unexpanded ${...} and a Loki that cannot authenticate to RGW, with an error message that points nowhere near the actual cause.

chunksCache and resultsCache are off. They are memcached deployments and would add four pods in front of a Loki this size — caching infrastructure larger than the thing it caches is a decision best left to people with more logs.

The chart already runs Loki as UID 10001 with a read-only root and no capabilities; loki.podSecurityContext adds the RuntimeDefault seccomp profile at pod level, which covers the rules sidecar too. It has to be that key: singleBinary.podSecurityContext does not exist, so setting it there is silently inert.

The sidecars have limits of their own: Loki's rules sidecar (measured peak 72Mi, limit 192Mi) and Alloy's config reloader (peak 12Mi, given the platform's 128Mi floor rather than a measured figure).

Querying

The Loki datasource is registered with Grafana automatically. In Grafana, choose Loki as the datasource and query by label:

{namespace="rook-ceph"} |= "error"
{unit="kubelet.service", node="odin"}
{namespace="openbao"} |= "sealed"
{job="kubernetes-audit"} | json | objectRef_resource="secrets"

Audit events keep only verb and audit_level as labels. Both are small closed sets, which is what makes a label cheap; user and resource are far more useful to query by and far too numerous to label, so they stay in the line where | json can reach them.

Where the datasource lives

grafana-datasource.yaml declares its namespace as monitoring, not logging. The kube-prometheus-stack Grafana sidecar only watches its own release namespace for grafana_datasource ConfigMaps, so one placed next to Loki would never be picked up. It is defined with the component it describes and applied where Grafana can see it.

Dashboards

grafana-dashboards.yaml carries three dashboards from the Alloy mixin (operations/alloy-mixin/rendered/dashboards/ in grafana/alloy), unmodified, at the Alloy version the chart deploys. The chart renders none, hence the vendored copy — and Renovate does not see it, so re-copy them when Alloy moves a minor version.

The mixin's other dashboards are left out on purpose. Clustering, OpenTelemetry and Prometheus remote-write are features this Alloy does not use, and "Logs Overview" reads Alloy's own logs by a job label this pipeline never sets. The cluster variable on the remaining three has nothing to list, since no scrape sets a cluster label; its empty value matches series without one.

Directory Structure

logging/
├── application.yaml          # Directory Application (wraps the rest)
├── loki.yaml                 # ArgoCD Application (Helm: grafana/loki)
├── alloy.yaml                # ArgoCD Application (Helm: grafana/alloy)
├── bucket.yaml               # ObjectBucketClaim for Loki's chunks
├── grafana-dashboards.yaml   # Alloy mixin dashboards, vendored
└── grafana-datasource.yaml   # Loki datasource, applied into monitoring/