Automating Minor-Version Upgrades Safely in Cassandra 4.x/5.x
A minor-version (patch) upgrade — moving a deployment from 4.1.3 to 4.1.5, or 5.0.1 to 5.0.3 — looks routine, but it changes the binary on disk and therefore drags the deployment through a mixed-version window where streaming between differing versions is disabled and schema changes are refused. The two rules that keep it safe are simple and non-negotiable: snapshot before you touch a node, and take one node through the whole upgrade at a time. This guide automates that discipline. It extends the rolling restarts and minor upgrades guide, which covers the loop mechanics; here the loop additionally swaps the package, verifies the reported version, runs nodetool upgradesstables, and can roll a node back from its pre-upgrade snapshot. Unlike the pure restart orchestrator, which changes no binary, this procedure must guard against every other lifecycle operation running during the window: no repair, no bootstrap, no decommission until the last node is on the new version.
Prerequisites: the new package staged and reachable on every node, passwordless SSH with sudo, Python 3.10+ on the orchestration host, and a verified backup path.
Pre-conditions & safety gates
Upgrades are the one lifecycle operation where a bad step can lose data, so the gates are stricter than for a plain restart. Run all of them before starting, and re-take the snapshot per node inside the loop.
# 1. Confirm the source version and that every node is on it and UN.
nodetool version && nodetool status | awk 'NR>5 {print $1, $2}'Expected output: one identical ReleaseVersion intended as the source, and every status row prefixed UN.
# 2. Verify a recoverable backup exists BEFORE any change.
# Take a fresh snapshot and confirm it landed on disk.
nodetool snapshot -t pre_upgrade_$(date +%Y%m%d) ledger
ls -d /var/lib/cassandra/data/ledger/*/snapshots/pre_upgrade_* | headExpected output: snapshot directories listed under each table of the ledger keyspace; an empty result means the snapshot failed and you must stop.
# 3. Check release compatibility: minor upgrades are only safe within a major line
# and along the vendor's supported upgrade path (e.g. 4.1.x -> 4.1.y, or 4.1 -> 5.0).
# Never skip a major (e.g. 3.11 -> 5.0) in one hop.
nodetool describecluster | grep -A5 "Schema versions"Expected output: a single schema UUID for the whole cluster. A schema disagreement must be resolved before upgrading — you cannot start an upgrade from an inconsistent schema.
Only proceed when all three pass. The snapshot in gate 2 is the rollback anchor for the entire operation; SSTable snapshots are hard links, so they cost almost no disk until compaction diverges the live files, making a per-node snapshot cheap insurance.
Implementation
The upgrade is expressed as a per-node bash function (the node-local work) wrapped by a Python coordinator that enforces global ordering and refuses to run if any other lifecycle operation is active. The bash function does exactly six things in order — drain, snapshot, stop, swap package, start, verify version, then upgradesstables — and is idempotent enough to re-run on a partially-upgraded node.
#!/usr/bin/env bash
# upgrade_node.sh — run ON a single Cassandra node. Args: <new_pkg_version>
# Idempotent-ish: skips the package swap if already on the target version.
set -euo pipefail
TARGET="$1" # e.g. 4.1.5
KEYSPACES="ledger analytics" # snapshot these; adjust to your keyspaces
SNAP="preupg_$(date +%Y%m%d_%H%M%S)"
current="$(nodetool version | awk -F': ' '/ReleaseVersion/ {print $2}')"
if [[ "$current" == "$TARGET" ]]; then
echo "already on $TARGET — running upgradesstables only"
nodetool upgradesstables
exit 0
fi
echo "[1/6] draining"
nodetool drain
echo "[2/6] snapshot $SNAP"
# shellcheck disable=SC2086
nodetool snapshot -t "$SNAP" $KEYSPACES
echo "[3/6] stopping service"
sudo systemctl stop cassandra
echo "[4/6] swapping package to $TARGET"
# Debian/Ubuntu shown; use the matching yum/dnf line on RHEL-family hosts.
sudo apt-get install -y --allow-downgrades "cassandra=$TARGET"
echo "[5/6] starting service"
sudo systemctl start cassandra
# Wait for the process to answer nodetool before verifying.
for _ in $(seq 1 30); do
if nodetool version >/dev/null 2>&1; then break; fi
sleep 4
done
verified="$(nodetool version | awk -F': ' '/ReleaseVersion/ {print $2}')"
if [[ "$verified" != "$TARGET" ]]; then
echo "VERSION MISMATCH: expected $TARGET, got $verified" >&2
exit 3
fi
echo "version verified: $verified"
echo "[6/6] upgradesstables (I/O heavy; safe while serving)"
nodetool upgradesstables
echo "node upgrade complete"The Python coordinator below drives that script across the deployment one node at a time, blocks until each node returns to UN on its peers, and — the upgrade-specific guard — aborts the whole run if it ever detects an active repair, bootstrap, or decommission anywhere in the deployment while the versions are mixed.
#!/usr/bin/env python3
# requirements: Python 3.10+, OpenSSH client, upgrade_node.sh staged on every node.
"""One-at-a-time minor-version upgrade coordinator for Cassandra 4.x/5.x.
Guards the mixed-version window: refuses to advance if any other lifecycle
operation (repair, bootstrap, decommission) is detected mid-upgrade.
"""
from __future__ import annotations
import subprocess, sys, time
TARGET_VERSION = "4.1.5"
NODES = ["10.0.2.11", "10.0.2.12", "10.0.2.13", "10.0.2.14"] # upgrade order
SSH_TIMEOUT = 900 # upgradesstables can run long
def ssh(host: str, cmd: str, timeout: int = 60) -> tuple[int, str]:
try:
p = subprocess.run(["ssh", "-o", "BatchMode=yes", host, cmd],
capture_output=True, text=True, timeout=timeout)
return p.returncode, (p.stdout + p.stderr).strip()
except subprocess.TimeoutExpired:
return -1, "ssh timeout"
def lifecycle_clear(observer: str) -> bool:
"""Abort guard: no repair/streaming/joining/leaving may run mid-upgrade."""
rc, net = ssh(observer, "nodetool netstats")
if rc != 0 or "Repair" in net or "Receiving" in net or "Sending" in net:
return False
rc, st = ssh(observer, "nodetool status")
if rc != 0:
return False
# No node may be joining (UJ) or leaving (UL/DL) during the window.
for line in st.splitlines():
code = line.split()[0] if line.split() else ""
if code in {"UJ", "UL", "DL", "DJ"}:
return False
return True
def peer_sees_un(observer: str, target: str) -> bool:
rc, out = ssh(observer, "nodetool status")
if rc != 0:
return False
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2 and target in parts[1]:
return parts[0] == "UN"
return False
def wait_un(target: str, observers: list[str], attempts: int = 45) -> bool:
delay = 4.0
for _ in range(attempts):
if all(peer_sees_un(o, target) for o in observers):
return True
time.sleep(delay)
delay = min(delay * 1.5, 25.0)
return False
def upgrade() -> int:
for target in NODES:
observers = [n for n in NODES if n != target]
# Guard the mixed-version window before touching the node.
if not all(lifecycle_clear(o) for o in observers):
print(f"ABORT before {target}: another lifecycle op is active")
return 1
print(f"[{target}] upgrading to {TARGET_VERSION}")
rc, out = ssh(target, f"sudo bash /opt/ops/upgrade_node.sh {TARGET_VERSION}",
timeout=SSH_TIMEOUT)
print(out)
if rc != 0:
print(f"[{target}] upgrade_node.sh failed rc={rc} — halting; roll back this node")
return 1
if not wait_un(target, observers):
print(f"[{target}] did not return to UN — halting")
return 1
print(f"[{target}] on {TARGET_VERSION} and UN\n")
print("All nodes upgraded. Mixed-version window closed; repair is safe again.")
return 0
if __name__ == "__main__":
sys.exit(upgrade())The coordinator never proceeds while the window is unsafe: lifecycle_clear runs before each node and treats any active repair stream, or any node in a joining/leaving state, as a hard abort. That is what enforces the “no repair, bootstrap, or decommission during a mixed-version window” rule in code rather than in a runbook someone might skip.
Two properties keep the run recoverable. The per-node bash script short-circuits when a node is already on the target version — it runs only upgradesstables and exits — so re-invoking the coordinator after an interruption never re-swaps a package that already moved, and the SSH_TIMEOUT of 900 seconds gives upgradesstables room to finish on nodes with large data volumes without the coordinator giving up prematurely. The snapshot taken inside step 2 of the bash script is per-node and timestamped, so every node carries its own rollback anchor; you are never dependent on a single cluster-wide snapshot that may have aged out. Stagger the actual upgradesstables I/O if disk pressure is a concern — it does not block reads or writes, so a node can serve traffic while its SSTables rewrite in the background, and you can even defer it to a quieter window as long as you complete it before the next major upgrade.
Verification
After the coordinator reports completion, confirm the whole cluster is homogeneous and the on-disk format has been rewritten.
# Every node reports the target ReleaseVersion.
for h in 10.0.2.11 10.0.2.12 10.0.2.13 10.0.2.14; do
echo -n "$h "; ssh "$h" "nodetool version | awk -F': ' '/ReleaseVersion/{print \$2}'"
doneExpected output: the target version (e.g. 4.1.5) on every line.
# One schema version, and no lingering old-format SSTables.
nodetool describecluster | grep -A5 "Schema versions"
nodetool compactionstats # upgradesstables work should be finished, not queuedExpected output: a single schema UUID; compactionstats showing no pending upgrade tasks. Only now is it safe to resume repair — see the read-repair versus anti-entropy repair guide for scheduling it after the window closes.
Troubleshooting
-
Cannot stream/ streaming disabled between mixed versions. During the window, a node on the new binary refuses to stream to or from a node on the old one, and any operation that needs streaming (a repair someone kicked off, a replacement, a bootstrap) logsCannot streamor hangs. Root cause: internode streaming is intentionally disabled across differing versions to prevent format corruption. Fix: do not stream during the window — the coordinator’slifecycle_clearguard exists precisely to stop this. If a repair is already stuck, cancel it (nodetool stop VALIDATION), finish upgrading every node, then run repair once the deployment is homogeneous. -
SSTable format incompatibility after start. A freshly-upgraded node starts but logs warnings about reading old-format SSTables, or reads on it are unusually slow because the data is still in the previous major’s format. Root cause: the new binary reads the old format for compatibility but has not yet rewritten it. Fix: this is expected until
nodetool upgradesstablescompletes on that node — let it finish (it appears innodetool compactionstats). Never downgrade a node once new-format SSTables have been written, because older binaries cannot read the newer format; that path is one-way and is why the snapshot is your only clean rollback. -
A node fails the upgrade and must roll back from snapshot. The package swap succeeds but the node crash-loops on the new version, or the version verification fails. Root cause: a bad package, a config incompatibility, or a corrupted install. Fix: roll that single node back before touching any other node. Stop it, reinstall the previous package version, restore the pre-upgrade snapshot into the table directories, and restart:
sudo systemctl stop cassandra sudo apt-get install -y --allow-downgrades cassandra=4.1.3 # previous version # For each table, clear live SSTables and hard-link the snapshot back in: # (do this per table dir under /var/lib/cassandra/data/ledger/<table>-<id>/) nodetool refresh ledger transactions # after copying snapshot files into place sudo systemctl start cassandra nodetool version # confirm it is back on the source version and UNBecause you only ever have one node in flight, a rollback affects a single replica and the deployment keeps serving. The mechanics of the tombstones and shadowed data that a snapshot restore can resurrect are covered under tombstone management and garbage collection.
Related
- Rolling restarts & minor-version upgrades — the parent guide with the loop mechanics and mixed-version constraints this procedure automates.
- Orchestrating rolling restarts with Python — the no-binary-change counterpart for config and certificate rollouts.
- Read repair vs anti-entropy repair — why repair must wait until the whole cluster is on one version after an upgrade.
- Node Lifecycle Automation — the top-level guide to serialized, state-gated membership operations.