Upgrading materialize-monitoring#
StatefulSet fields that cannot be patched#
Most of a StatefulSet is mutable, but volumeClaimTemplates and selector are not.
A release that changes either — a component gaining, losing, or resizing its volume — cannot be applied by helm upgrade, which fails partway with:
cannot patch "thanos-compactor" with kind StatefulSet: StatefulSet.apps
"thanos-compactor" is invalid: spec: Forbidden: updates to statefulset spec for
fields other than 'replicas', 'ordinals', 'template', 'updateStrategy',
'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbiddenNothing in the chart can smooth this over — it is a Kubernetes API constraint, not a Helm one — so the StatefulSet has to be deleted and recreated by the upgrade.
The two Thanos storage changes in chart v0.16.0 require this, and only for installs upgrading across it:
| Component | Change | What the upgrade needs |
|---|---|---|
| Compactor | gains a PVC (its scratch outgrew node ephemeral storage) | delete the StatefulSet; the upgrade recreates it with the claim |
| Receive | drops its PVC for emptyDir (durability is RF 3, and a volume cannot cross zones) | delete the StatefulSet, then delete the orphaned PVCs |
# Compactor: local state is scratch, so recreating the pod costs nothing but a
# restarted compaction.
kubectl -n monitoring delete statefulset thanos-compactor
helm upgrade mzmon ... # recreates it with the volumeClaimTemplate
# Receive: the same, plus cleaning up volumes nothing will reclaim. Do this one
# pod at a time if you want to preserve write quorum through the change.
kubectl -n monitoring delete statefulset thanos-receive
helm upgrade mzmon ...
kubectl -n monitoring delete pvc -l app.kubernetes.io/component=receiveDeleting a StatefulSet does not delete its PVCs. The default
persistentVolumeClaimRetentionPolicyisRetain, so volumes created fromvolumeClaimTemplatesoutlive it. That cuts both ways, and both directions matter here:
- Recreating a StatefulSet rebinds the existing PVCs by name (
data-<sts>-0), which is what makes the Compactor migration safe to repeat.- Moving away from a volume leaves PVCs with nothing to reclaim them, still provisioned and still billed. Receive’s must be deleted explicitly — which is safe, because that data was never the durable copy.
--cascade=orphanis the wrong tool for a volume change. It keeps the pods running while the StatefulSet is replaced, which is useful when a restart is what you are avoiding — but an orphaned pod keeps the volumes it started with, so the new claim only takes effect once the pod is replaced anyway.
Losing Receive’s local data during this is not a data-loss event: at most 2h of it had not yet reached object storage, every write exists on three replicas, and the Store Gateway serves everything older. The Compactor’s scratch is not authoritative at all — the bucket is, and compaction is idempotent.
If a Compactor later gets wedged in an unavailable zone by the volume it just gained, that is recoverable by hand; see The Thanos Compactor is stuck in a zone for the ordering, which matters because two Compactors running at once is the one thing that corrupts data.
Alertmanager: two replicas, renamed#
The release that made Alertmanager highly available (DEP-226) also pinned its resource
names to alertmanager, which were previously derived from the release name as <release>-alertmanager.
helm upgrade applies it without error.
| Change | What to expect |
|---|---|
Resources renamed to alertmanager | Helm deletes the old StatefulSet and creates a new one, whose two replicas start together on new volumes. The old volume is left orphaned: storage-<release>-alertmanager-0, safe to delete. Nothing routed alerts anywhere before this release, so there is no configuration to carry over; an install with silences worth keeping can move them with amtool silence query --output=json and amtool silence import. |
| Hard zone spread | A cluster whose nodes carry no zone label needs no-zone-spread or min_zones = 0 first, or a replica stays Pending. |
Both rulers retarget alertmanager-headless | The Loki ruler’s address lives in Loki’s shared configuration, so every Loki pod rolls once. |
Both rulers stamp cluster on alerts | Read from a new ruler-env ConfigMap in each ruler’s namespace; both rulers roll once. Set clusterName (Terraform: cluster_name) first if it is still default; it replaces setting pipeline.env.CLUSTER_NAME directly. |
Thanos: selectors name the chart#
The release that moves the Thanos subchart to 0.46.0 (DEP-229) adds app.kubernetes.io/name: thanos to every Thanos selector.
Earlier releases selected on component and release name alone.
Loki ships a compactor, a query-frontend and a ruler into the same namespace under the same release, so those three Thanos components also selected Loki’s pods.
| Thanos object | Effect on the matching Loki pods |
|---|---|
| PodDisruptionBudget | Each Loki ruler was covered by both loki-ruler and thanos-ruler. The eviction API refuses a pod with more than one budget, so a node drain, node-pool upgrade or autoscaler scale-down could not move it. The Loki query frontend had the same problem wherever thanos.queryFrontend.enabled was set. |
| NetworkPolicy | Thanos’s policies allow all egress, and ingress from anywhere on the ports they name. Policies are additive, so Loki’s own policy did not restrict the Loki ruler or compactor. |
| Service | Endpoints included the Loki pods wherever a port name matched. Thanos Query dialled both Loki rulers’ grpc port every five seconds and logged a failure each time. No Loki pod has an http port, so none was scraped as a Thanos target. |
Services, budgets and policies take the new selectors in place.
Deployment and StatefulSet selectors are immutable, so helm upgrade fails, naming every Thanos workload it could not patch, as described under StatefulSet fields that cannot be patched.
The workloads are deleted with --cascade=orphan before the upgrade.
kubectl -n monitoring delete statefulset,deployment \
-l app.kubernetes.io/part-of=thanos,app.kubernetes.io/instance=mzmon \
--cascade=orphan
helm upgrade mzmon ... # or terraform applymzmon is the release name the Terraform module installs under.
A Helm install substitutes its own.
Orphaning is the right tool here, unlike for a volume change. The pods already carry every label in the new selectors, and the chart leaves their templates unchanged, so the recreated workloads adopt the running pods without restarting them. No PVC is touched.
A workload whose pod template was changed outside Helm rolls once instead.
kubectl rollout restart is the usual cause: it stamps a kubectl.kubernetes.io/restartedAt annotation that Helm preserves across upgrades, and a recreated workload starts from the chart’s template without it.
A StatefulSet rolls one pod at a time and waits for each to be ready.
An upgrade that already failed on this recovers the same way: delete the workloads with --cascade=orphan, then run it again.
The failed attempt has already applied everything except the workloads, including the new Service, budget and policy selectors.
The Terraform module installs with atomic = false, so nothing was rolled back.
Resource names, pod labels and the thanos-thanos ServiceAccount that workload identity bindings name are all unchanged.
Ingester rollouts: duration and deploy timeouts#
Any change to the ingester pod spec — image, resources, or scaling the replica count — rolls the ingester StatefulSet.
That roll is ordered and readiness-gated: pods cycle one at a time, and the next is not touched until the previous re-joins the ring and reports Ready.
This is deliberate — with replication_factor: 3, one-at-a-time keeps at most one of three replicas down, preserving write quorum throughout the roll.
Budget roughly one minute per ingester, plus headroom for any new nodes.
Each pod pays: graceful ring-leave + best-effort flush (up to terminationGracePeriodSeconds, ~60s) → start → memberlist join → ring ACTIVE → readiness stabilization.
A 6-ingester fleet therefore takes ~5 minutes to roll, and it is not bounded by how fast new nodes appear.
That overruns tools that wait on the rollout with a short default:
| Tool | Default wait | Fix |
|---|---|---|
helm upgrade --wait | 5m (--timeout) | --timeout 15m |
Flux HelmRelease | 5m | raise spec.timeout |
Pulumi helm.v4.Chart | resource await | extend customTimeouts |
| ArgoCD | async (no wait) | tolerant — shows Progressing until healthy |
A --wait timeout on a rollout you deliberately triggered means “still rolling,” not “failed” — the rollout completes correctly and the client just stopped watching.
That is the narrow exception, not the rule. On any other timeout, assume something is broken and inspect events before touching the timeout: see Troubleshooting.
Do not speed the roll by allowing more than one ingester down at a time (
updateStrategy.rollingUpdate.maxUnavailable > 1, via the alphaMaxUnavailableStatefulSetgate). With RF 3 that can drop you to a single healthy replica and break write quorum mid-roll.zoneAwareReplication(roll a whole zone at once) is the only quorum-safe burst, at the cost of cross-AZ replication traffic — see the logging architecture.