Skip to main content
RunBook Academy

← All runbooks in Observability

high riskservice affecting~240 min

Runbook: Upgrade the Full Stack

1 · Prerequisites

Confirm every item is in place before any state change.

  • Upgrade Basics
  • Reading Release Notes
  • Compatibility
  • Upgrade Order
  • Canary Upgrade
  • Upgrade Test
  • The current and the target version of every component, written down as exact strings - never "latest"
  • The deployment mechanism for each component (package manager, systemd unit, or the image pin in the manifest repository) and the access to drive it
  • Write access to the repository that owns the stack configuration, and a way to merge under change control during the window
  • Somewhere to put a rollback artefact that is not the host being upgraded
  • A witness outside the stack - a probe, a dead-man switch or a person - because the stack cannot be the only observer of its own upgrade

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The release notes for every version being crossed have been read, not only the target. A team three minors behind meets three release notes worth of breaking changes in one window, and the removed flag is usually in the middle one.
  • · The plan names ONE component for this window, with one owner and one rollback artefact. Two components in the same window means the first regression cannot be attributed to either of them, and attribution is the whole reason for the sequence.
  • · The compatibility matrix has been updated with the pairings actually running, read from the processes themselves (/api/v1/status/buildinfo, /api/v2/status, /api/health) rather than from the wiki. Release notes document supported pairings; the matrix documents observed ones.
  • · The rollback artefact for this component exists, has been checked for size and readability, and lives off the host: a Prometheus TSDB snapshot from POST /api/v1/admin/tsdb/snapshot, a dump of the Grafana database, an export of the Alertmanager silences, and the configuration repository at a tagged commit.
  • · The running configuration matches the repository. curl /api/v1/status/config prints what the Prometheus process actually loaded; an upgrade that lands on undeclared configuration has a rollback that restores the wrong file.
  • · The target version has been started in staging against production-shaped configuration, and the dry-run parse passed for this component: promtool check config, promtool check rules, amtool check-config, loki -verify-config, otelcol validate --config=....
  • · A witness outside the stack is in place for the whole window. Upgrading the thing that would page you means arranging in advance for something else to page you - a blackbox probe from another network, a dead-man switch, or a named person watching.
  • · The window is one where a regression is both detectable and attributable: enough traffic that a failure shows, enough staffing that somebody sees it, and no unrelated change in flight from another team.
  • · The hold decision has an owner and a clock: who is allowed to call "stop here and stay on the current step", and the wall-clock time at which the window closes whether or not the sequence is finished.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Freeze and tag. Merge nothing else into the stack configuration repository for the duration of the window, and tag the commit that describes the currently running state. That tag is the rollback target for configuration; binaries and data have their own artefacts and their own, worse, rollback stories.
  2. 2Capture the baseline into a file, not a screenshot. Component versions, count by (job) (up), the active target count, the rule group count, the Alertmanager peer count and cluster status, the Grafana datasource list. Every verification step later in this runbook is a comparison against that file, and a number nobody wrote down cannot be compared.
  3. 3Confirm the destinations before any binary moves. The paging provider, the chat webhook, the remote-write or long-term backend: these sit outside your change control and are the one part of the chain a git revert does not reach. If a newer Alertmanager will send them a different payload shape, that conversation happens now, not at 03:00.
  4. 4Alertmanager first. amtool check-config against the new binary, then roll the whole cluster to the new version in one pass - never leave two Alertmanager versions gossiping across a boundary the release notes flagged. Gate: /-/ready answers, /api/v2/status reports the new version with cluster status ready and the full peer count, and a deliberately fired test alert arrives at a real receiver.
  5. 5Hold, then Prometheus. Take the TSDB snapshot, run promtool check config and promtool check rules with the NEW promtool, then restart. Gate: /-/ready, /api/v1/status/buildinfo shows the target version, /api/v1/alertmanagers lists every Alertmanager on the /api/v2 path, the active target count matches the baseline, and prometheus_rule_group_iterations_missed_total is not climbing.
  6. 6Exporters and node agents after Prometheus, not before. They emit the shape Prometheus parses, so the parser moves first. Roll one host, diff its /metrics output against the baseline for renamed metrics and changed units, and only then roll the rest. Gate: every target up, and no recording rule that consumed a changed series has gone empty.
  7. 7Loki. Configuration compatibility, schema compatibility and storage compatibility are three separate decisions; take at most one of them per window. loki -verify-config, then roll. Gate: writes are accepted, and a LogQL query over a range that spans the cutover returns rows from BOTH sides of it.
  8. 8Tempo. Same shape as Loki, same rule about migrations. Gate: a trace ID that was written before the restart is still retrievable after it, and the ingest path accepts new spans.
  9. 9OTel Collector or Grafana Alloy, after the backends they write into. otelcol validate --config=/etc/otelcol/config.yaml or alloy fmt --check, then restart. The sending queue held in memory is lost across the restart in both directions, so drain it or accept the gap deliberately and write down which.
  10. 10Grafana last. Dump the database first: the schema migration that runs on the first start of a new Grafana is the one step in this runbook with no reverse. Gate: /api/health reports the new version and "database": "ok", every datasource passes its health check, and one dashboard per backend renders real data rather than an empty panel.
  11. 11Hold each component for its own window before starting the next. The gate proves the process started; the hold proves it survives a compaction, a full scrape rotation and a rule evaluation cycle. Most slow-path regressions are invisible for the first ten minutes and obvious by the fourth hour.
  12. 12Close the change. Reconcile the baseline file line by line, update the compatibility matrix with the observed pairing, and record every gap window with a start and end time. Each restart in this sequence produced a hole in the data, and somebody will eventually ask which minutes are missing.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓Every component reports the intended version from its own API rather than from the package manager: /api/v1/status/buildinfo (Prometheus), /api/v2/status (Alertmanager), /api/health (Grafana), loki -version, tempo -version, otelcol --version. A package manager reports what is installed on disk, not what the running process loaded.
  • ✓The counts match the baseline file: active targets, rule groups, Alertmanager peers, Grafana datasources. A count that is lower but still plausible is exactly the failure this runbook exists to catch, because nothing about it looks like an error.
  • ✓/api/v1/alertmanagers on Prometheus lists every Alertmanager replica, on the /api/v2 path. One entry where the deployment has two replicas means half the alerting chain is silently not receiving.
  • ✓A real alert has traversed the entire chain and arrived at a real receiver since the window opened. "Alertmanager reports ready" is a different and much weaker claim; the only proof of delivery is a delivery.
  • ✓Queries spanning the cutover return data from both sides of it for metrics, logs and traces alike. The gap should be the length of a restart, not the length of the retention.
  • ✓No recording rule has gone silent: each rule group still produces rows, and prometheus_rule_group_iterations_missed_total is flat rather than climbing.
  • ✓Every Grafana datasource passes its health check and at least one dashboard per backend renders real data. A datasource that saves without error and returns nothing is the commonest post-upgrade shape.
  • ✓The compatibility matrix records the observed pairing, and every gap window is written into the change record with start and end times.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Configuration rolls back by reverting to the tagged commit and reloading. That is the only genuinely symmetrical part of this runbook; treat everything below as recovery rather than reversal.
  • ↶Alertmanager: reinstall the previous version across the WHOLE cluster in one pass. A mixed-version gossip ring is the failure you are rolling back from - do not create one on the way out. Re-import the exported silences; any silence created during the window is otherwise lost, and a lost silence becomes a page.
  • ↶Prometheus: reinstall the previous binary, and if the new version has already written blocks, restore the data directory from the pre-upgrade snapshot. A newer on-disk format is not readable by the older binary, so a rollback without the snapshot is a rollback that starts with an empty database.
  • ↶Exporters: reinstall the previous package. Series whose names or units changed during the window keep both shapes on disk, so dashboards that span the window stay split until retention rolls past it. That is a cosmetic defect with an expensive-looking graph, and it is worth telling the dashboard owners rather than letting them find it.
  • ↶Loki and Tempo: reverting the binary is safe only while no schema entry has been added and the old index files still exist. Once a migration has run and the old files have been removed, there is no rollback - only a restore from object storage, at object-storage speed.
  • ↶Grafana: restore the database dump. The schema migration on first start of the new version is one-way, and the previous binary will either refuse the migrated database or misread it. Rolling back the package alone, without the dump, is how a dashboard estate is lost.
  • ↶OTel Collector and Alloy: revert the configuration and the image pin. Whatever was in the sending queue at the moment of the restart is gone, in both directions, and no rollback recovers it.
  • ↶If the sequence is several components deep, roll back in reverse order of the upgrade and re-run each gate on the way out. An unvalidated rollback is just another untested change, made faster and under more pressure.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate the moment two components are on a pairing the compatibility matrix does not cover and the window has closed. The stack is now running a combination nobody tested, and every incident diagnosed against it inherits that doubt.
  • · Escalate to the service owners before any step that stops alert delivery, however briefly. During the Alertmanager step nothing pages; whether that is acceptable is a business decision and not an operator one.
  • · Escalate to every consuming team if the sequence has to be held mid-way. A half-upgraded stack is a supported state only for as long as a named person owns it and knows it is half-upgraded.
  • · Escalate to a second operator before restoring a Prometheus data directory or a Grafana database over a live one. Both are one-way, both take a path as an argument, and both have been run at 03:00 against the wrong one.
  • · Escalate to the storage or schema owner before running any Loki or Tempo migration that rewrites or removes index files. That is a migration, not a version bump, and it does not belong in the same window as one.
  • · Escalate to security if the release being applied is a CVE fix. The window is then bounded by the exposure rather than by convenience, and holding on the current version may not be one of the available options.

Upgrading the observability stack is the one change where the instrument you would normally use to watch a change is the thing being changed. While Alertmanager is restarting, nothing pages. While Prometheus is replaying its write-ahead log, the dashboards that would show you the damage are flat for a reason that has nothing to do with the damage. Every signal you would reach for is, for a few minutes, unreliable by design.

That is the whole reason this runbook is about sequence rather than about commands. The commands are short and mostly boring. The expensive mistakes are all ordering mistakes: two components moved in one window so no symptom can be attributed to either, or a component upgraded before the one that has to parse its output, or a binary replaced before the artefact that would let you put the old one back.

What this runbook is, and what it is not

It is the coordination layer: which component moves when, what has to be true before the next one moves, and what each step costs if it turns out to be wrong.

It is not the per-component mechanics. Swapping the Prometheus binary, backing up the Grafana database and validating a Loki configuration each have their own runbook and their own failure modes. This one is the frame those hang from, and it deliberately stops at the gate for each component rather than duplicating the step-by-step.

It does not apply when:

  • One component needs an out-of-cycle security patch. That is a single-component change with a single-component rollback. Use that component’s runbook and skip the sequence entirely - the sequence exists to make attribution possible across several changes, and there is only one.
  • The change is a schema or storage migration. A Loki index migration, a Grafana database engine move, a Tempo block-layout change: those rewrite data rather than replacing a binary, and their rollback is a restore rather than a reinstall. They get their own window, their own approval and their own runbook. Putting one inside a version-bump window is how a stack loses data during what the change record calls routine maintenance.
  • The jump crosses a major boundary on any component. A major release is permitted to remove configuration, change on-disk formats and amend query semantics. Read the upstream migration guide and plan that component as a project. The release note is where such a plan starts, not what it replaces.

The single rule that generates the order

Every step in the sequence comes from one rule: upgrade the side that has to parse the new shape before the side that produces it. Receiver before emitter, per wire.

WireEmitterReceiverTherefore
Alert statePrometheusAlertmanagerAlertmanager first
NotificationsAlertmanagerPaging, chat, webhooksDestinations confirmed first
ScrapeExportersPrometheusPrometheus first, exporters after
OTLPCollector / AlloyLoki, Tempo, PrometheusBackends first, collector after
Remote writePrometheusLong-term storeThe store first
QueryEverythingGrafanaGrafana last

Apply the rule to your own topology and the order falls out of it. For the stack this course builds, it produces: destinations, Alertmanager, Prometheus, exporters, Loki, Tempo, Collector or Alloy, Grafana.

Blast radius: what each step costs if it is wrong

Read this table before the window, not during it. The line it draws is the line between steps you can undo and steps you can only recover from.

StepReversible?What it costs if it goes wrong
Configuration change, tagged commitYes, on reloadNothing beyond the reload interval
Alertmanager versionYes, whole cluster at onceSilences created during the window; a split gossip ring if rolled half-way
Prometheus binaryOnly with the pre-upgrade snapshotWithout it, an empty database: newer blocks are unreadable by the older binary
Exporter packageYesSeries that changed name or unit stay split across the window until retention passes
Loki / Tempo binaryYes, until a schema entry is addedAfter a migration removes old index files, restore only
Collector / AlloyYesWhatever was in the sending queue at restart, in both directions
Grafana packageOnly with the database dumpThe dashboard estate: the first start migrates the schema one way

Two rows deserve their own attention. The Prometheus row is the one operators assume is symmetrical because reinstalling a package is symmetrical - but the data directory is not part of the package, and a newer TSDB format on disk turns “put the old binary back” into “start with nothing”. The Grafana row is the same trap wearing different clothes: the package downgrade succeeds and the database it now has to read has already been migrated.

Before the first binary moves

Capture the baseline as files. The point is not the reading - it is having something to diff against at 04:00 when the question is whether 412 targets is the right number.

Read-only / Safebaseline capture
PROM=http://localhost:9090
AM=http://localhost:9093
OUT=/var/tmp/upgrade-baseline
mkdir -p "$OUT"

curl -fsS "$PROM/api/v1/status/buildinfo" | jq -r '.data.version' \
> "$OUT/prometheus-version.txt"

curl -fsS "$PROM/api/v1/targets?state=active" \
| jq '.data.activeTargets | length' > "$OUT/target-count.txt"

curl -fsS "$PROM/api/v1/rules" | jq '.data.groups | length' \
> "$OUT/rule-group-count.txt"

curl -fsSG "$PROM/api/v1/query" --data-urlencode 'query=count by (job) (up)' \
| jq -S '.data.result' > "$OUT/up-by-job.json"

curl -fsS "$PROM/api/v1/alertmanagers" \
| jq -r '.data.activeAlertmanagers[].url' > "$OUT/alertmanagers.txt"

curl -fsS "$AM/api/v2/status" \
| jq '{version: .versionInfo.version, cluster: .cluster.status,
       peers: (.cluster.peers | length)}' > "$OUT/am-status.json"
# $OUT/target-count.txt
412
# $OUT/alertmanagers.txt
http://alertmanager-0:9093/api/v2
http://alertmanager-1:9093/api/v2

Illustrative output

Then capture the rollback artefacts. The Prometheus snapshot needs --web.enable-admin-api on the running process; if that flag is not set, this is the moment you discover it, which is a much better moment than the one after the upgrade.

Data-loss riskrollback artefacts, off the host
PROM=http://localhost:9090
SNAP_ROOT=/var/lib/prometheus/snapshots

# Prometheus: the snapshot API, not a tar of a live directory.
SNAP=$(curl -fsS -X POST "$PROM/api/v1/admin/tsdb/snapshot" | jq -r '.data.name')
tar -C "$SNAP_ROOT" -czf "/var/tmp/$SNAP.tar.gz" "$SNAP"

# Alertmanager: silences are state, and they are not in the config repo.
amtool --alertmanager.url=http://localhost:9093 silence query -o json \
> /var/tmp/am-silences.json

# Grafana: the database is the dashboards, the datasources and the alert rules.
systemctl stop grafana-server
sqlite3 /var/lib/grafana/grafana.db ".backup '/var/tmp/grafana-pre-upgrade.db'"
systemctl start grafana-server

# Then move all three somewhere that is not this host.

The systemctl stop around the Grafana backup is deliberate and is itself a service impact: a hot copy of a SQLite file that Grafana is writing to is a file that may not restore. If the deployment uses MySQL or PostgreSQL instead, use that engine’s dump tool and skip the stop.

The gates

A gate is not “the process started”. A gate is a statement about the wire on either side of the component, checked before the next component is allowed to move.

Read-only / SafeAlertmanager gate
AM=http://localhost:9093

amtool check-config /etc/alertmanager/alertmanager.yml
curl -fsS "$AM/-/ready"
curl -fsS "$AM/api/v2/status" | jq '{v: .versionInfo.version,
status: .cluster.status, peers: (.cluster.peers | length)}'
SUCCESS: /etc/alertmanager/alertmanager.yml is valid
{ "v": "0.27.0", "status": "ready", "peers": 2 }

Illustrative output

Peer count matters more than it looks. A cluster that reports ready with one peer where the deployment has two has not converged - it has split, and the symptom downstream is duplicate pages rather than an error anybody logs.

The last part of this gate cannot be automated away: fire a test alert and confirm it arrives at a real receiver. Alertmanager reporting ready says the process is up; it says nothing about whether the notification reached the paging provider, and the notification path is the reason the component exists.

Read-only / SafePrometheus gate
PROM=http://localhost:9090
OUT=/var/tmp/upgrade-baseline

promtool check config /etc/prometheus/prometheus.yml
promtool check rules /etc/prometheus/rules/*.yml

curl -fsS "$PROM/api/v1/status/buildinfo" | jq -r '.data.version'
curl -fsS "$PROM/api/v1/alertmanagers" | jq -r '.data.activeAlertmanagers[].url'

curl -fsS "$PROM/api/v1/targets?state=active" | jq '.data.activeTargets | length'
cat "$OUT/target-count.txt"

curl -fsS "$PROM/metrics" | grep '^prometheus_rule_group_iterations_missed_total'
SUCCESS: /etc/prometheus/prometheus.yml is valid
SUCCESS: 27 rules found
2.55.1
http://alertmanager-0:9093/api/v2
http://alertmanager-1:9093/api/v2
412
412

Illustrative output

Run promtool from the new package, not the one already on the path. The whole point of the check is that the new binary’s parser accepts the configuration; running last version’s parser answers a question nobody asked.

For the remaining components the gate has the same three parts every time: the dry-run parse, the version read from the process rather than the package, and one query that crosses the restart boundary.

Read-only / SafeLoki, Tempo, Collector, Grafana gates
# Loki: parse, version, and a query that spans the cutover.
loki -config.file=/etc/loki/loki.yaml -verify-config
loki -version | head -1
curl -fsS http://localhost:3100/ready

# Tempo: same shape. The real gate is that an OLD trace id still resolves.
tempo -config.file=/etc/tempo/tempo.yaml -verify-config
tempo -version | head -1
curl -fsS http://localhost:3200/ready

# Collector: parse, version, and the health-check extension.
otelcol validate --config=/etc/otelcol/config.yaml
otelcol --version
curl -fsS http://localhost:13133/

# Grafana: version and database state come from one endpoint.
curl -fsS http://localhost:3000/api/health
{"database":"ok","version":"11.3.0","commit":"..."}

Illustrative output

For Grafana the gate is not /api/health alone. Walk the datasource list and run each datasource’s health check - the one the UI calls Save & test. It is the cheapest cross-component check in the stack and it catches the regression that nothing else does: a datasource that saves without complaint and returns no rows, because the Grafana plugin now expects a response shape the backend on the other side of the wire does not produce yet.

Where you stop and think

Four decision points interrupt the sequence. Each one is a place where continuing is a choice, not a default.

A release note removes a configuration flag you use. Stop. The configuration change has to merge, be reviewed and pass the dry-run parse before the binary moves, because the new binary will refuse to start with the old file. Discovering this at the restart converts a version bump into an outage with a config change in the middle of it.

The component wants a schema or format migration. Stop, and take it out of this window. A migration rewrites data. Its rollback is a restore, its duration is a function of data volume rather than of process restart time, and its failure mode is a gap in history rather than a service that is down. Nothing about it belongs next to a binary swap.

The gate passes but the hold window is not clean. The gate is a snapshot; the hold is the test. Compaction, garbage collection, write-ahead-log replay and the full scrape rotation are all slow paths that a five-minute check cannot reach. A canary that passed every gate and then degraded at hour four has told you something true, and the correct response is to roll that one component back rather than to reason about whether the fleet would behave differently.

Two components each need the other to move first. That is a genuine deadlock and it is a re-plan, not a moment for improvisation. It usually means a version was skipped somewhere. The way out is an intermediate version of one of them, chosen in daylight with the matrix open, and never a simultaneous rollout of both.

Hold is a first-class outcome

The sequence stopping half-way is not a failure of the runbook. It is the runbook working. But a half-upgraded stack is only a supported state while somebody owns it, so holding is a decision with the same paperwork as proceeding:

  • An owner, by name, who is accountable for the mixed-version pairing.
  • An end time by which the stack is either finished or returned to the starting version. “We will get back to it” turns into a pairing nobody tested running for six months, which is exactly the state the compatibility matrix exists to prevent.
  • A written note of which pairing is live, in the matrix, so the next incident is not diagnosed against an assumption about versions.
  • A decision about the destinations, if the hold is at the Alertmanager step. Held with alert delivery degraded is a different state from held safely, and the difference has to be explicit.

Common patterns

SymptomLikely causeResolution
Alerts arrive twice after the Alertmanager stepGossip ring split across two versionsReturn the whole cluster to one version and wait for convergence before resuming
Prometheus logs an error to Alertmanager every evaluation intervalPrometheus moved before AlertmanagerRoll Alertmanager forward; the order was inverted
Target count is lower than the baseline and nothing erroredA scrape job silently failed to parse after a config changeDiff /api/v1/status/config against the tagged commit
A recording rule stopped producing rowsStricter parsing in the new release, or an exporter renamed a seriesRead the release note entry; the rule test suite is what catches this before the window
A Grafana panel returns no data, datasource saves cleanlyPlugin ahead of the backend it queriesCheck the pairing in the matrix; the fix is usually to move the backend, not Grafana
The old binary will not start after a rollbackOn-disk format already advancedRestore the pre-upgrade snapshot; this is what it was for
Everything is healthy but a window of data is missingRestart gaps, expected and unrecordedRecord them; if the gap is longer than the restart, look at the collector queue

References

  1. Prometheus management API (reload, snapshot, lifecycle)
  2. Prometheus HTTP API (status, targets, rules, alertmanagers)
  3. Prometheus storage: TSDB layout and on-disk format
  4. promtool command-line reference
  5. Alertmanager configuration and clustering
  6. Grafana upgrade guide
  7. Loki upgrade notes
  8. Tempo upgrade notes
  9. OpenTelemetry Collector configuration
  10. Semantic Versioning 2.0.0