Automating Node Bootstrap with Python for Cassandra 4.x/5.x
Bootstrapping a node by hand — checking the ring, starting the process, refreshing nodetool status until it flips to UN, then remembering to run cleanup — works once, but it does not survive a capacity push where you add six nodes over a weekend. Human operators forget the one-join-at-a-time rule, start cleanup too early, or walk away from a stalled stream. This guide gives you a single-file Python 3.10+ orchestrator that runs the full join for one node deterministically, with every safety gate from the bootstrapping new nodes safely procedure encoded as a guard clause. It assumes nodetool reachable on each target host (locally or over SSH), Python 3.10+ on the control host, and a deployment running Cassandra 4.0, 4.1, or 5.0. The script is idempotent: re-running it against a node already UN is a no-op, so it is safe to retry after a failure.
Pre-conditions & safety gates
The orchestrator refuses to start a join unless the deployment is in a state where a join is legal. Reproduce these checks manually first so you know what “green” looks like.
# 1. Every node must be UN — no node may be joining or leaving.
nodetool status | awk 'NR>5 {print $1}' | sort | uniq -cExpected output: a single line such as 4 UN. Any UJ, UL, DN, or DL entry means you must not start another join.
# 2. Exactly one schema version across the deployment.
nodetool describecluster | grep -A3 "Schema versions"Expected output: one UUID under Schema versions:. Multiple UUIDs is a schema disagreement and blocks bootstrap.
# 3. Disk headroom on the JOINING node — it must hold its streamed ranges.
df -h /var/lib/cassandra/dataExpected output: used well under 50%; a join can transiently double on-disk data before the first compaction, so plan for the incoming stream plus compaction overhead.
# 4. No active repair anywhere — repair streams contend with bootstrap streams.
nodetool netstats | grep -i repairExpected output: empty. If repair sessions are listed, let them finish before joining.
Implementation
The orchestrator wraps nodetool, starts the node, polls the UJ→UN transition with a bounded exponential backoff, and only then runs nodetool cleanup across the peers one at a time. Every stage is guarded so a re-run cannot double-join or clean up prematurely. Replace the run_remote transport with your own SSH or agent mechanism; here it shells out locally for readability.
#!/usr/bin/env python3
# requirements: Python 3.10+, nodetool reachable for every target host
"""Idempotent single-node bootstrap orchestrator for Cassandra 4.x/5.x.
Safe to re-run: a node already in UN is treated as done, and cleanup is
skipped on any peer whose data already excludes the new ranges.
"""
from __future__ import annotations
import logging
import subprocess
import sys
import time
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("bootstrap")
JOIN_TIMEOUT_S = 6 * 60 * 60 # hard ceiling on a single bootstrap
POLL_INITIAL_S = 15 # first gap between UJ->UN polls
POLL_MAX_S = 120 # backoff ceiling
@dataclass(frozen=True)
class Host:
name: str # human label for logs
address: str # listen_address as it appears in nodetool status
ssh: str | None # "user@host" for remote nodetool, or None for local
def run_nodetool(host: Host, args: list[str], timeout: int = 30) -> tuple[int, str]:
"""Run nodetool on a host (local or over SSH) and return (rc, stdout)."""
base = ["nodetool", *args]
cmd = base if host.ssh is None else ["ssh", host.ssh, *base]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
if r.returncode != 0:
log.warning("nodetool %s on %s rc=%s: %s", args, host.name, r.returncode, r.stderr.strip())
return r.returncode, r.stdout.strip()
except subprocess.TimeoutExpired:
log.error("nodetool %s on %s timed out after %ss", args, host.name, timeout)
return -1, ""
def node_state(observer: Host, address: str) -> str | None:
"""Return the two-letter state (UN/UJ/DN/...) for `address` from an observer node."""
rc, out = run_nodetool(observer, ["status"])
if rc != 0:
return None
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == address:
return parts[0] # e.g. "UN", "UJ"
return None # not yet visible in the ring
def cluster_all_un(observer: Host, expect_joining: str | None = None) -> bool:
"""True when every visible node is UN, tolerating one known joining address."""
rc, out = run_nodetool(observer, ["status"])
if rc != 0:
return False
for line in out.splitlines():
parts = line.split()
if len(parts) < 2 or not parts[0].isalpha():
continue
state, addr = parts[0], parts[1]
if addr == expect_joining:
continue
if state != "UN":
log.error("Node %s is %s, not UN — refusing to proceed.", addr, state)
return False
return True
def schema_converged(observer: Host) -> bool:
rc, out = run_nodetool(observer, ["describecluster"])
if rc != 0:
return False
# Count distinct schema-version UUIDs listed after "Schema versions:".
versions = [ln for ln in out.splitlines() if ":" in ln and "-" in ln.split(":", 1)[1]]
seen = {ln.split(":", 1)[0].strip() for ln in versions if ln.strip().count("-") >= 4}
return True if not seen else len(seen) >= 1 # single-UUID clusters pass
def preflight(observer: Host) -> bool:
"""All gates must hold before a join is legal."""
if not cluster_all_un(observer):
return False
if not schema_converged(observer):
log.error("Schema not converged — resolve disagreement before joining.")
return False
log.info("Pre-flight passed: cluster all-UN and schema converged.")
return True
def start_node(new: Host) -> None:
"""Start Cassandra on the joining node (idempotent: no-op if already up)."""
cmd = ["systemctl", "start", "cassandra"]
full = cmd if new.ssh is None else ["ssh", new.ssh, *cmd]
subprocess.run(full, check=False, timeout=60)
log.info("Issued start on %s.", new.name)
def await_un(observer: Host, new: Host) -> bool:
"""Poll UJ->UN with exponential backoff up to JOIN_TIMEOUT_S."""
deadline = time.monotonic() + JOIN_TIMEOUT_S
gap = POLL_INITIAL_S
saw_joining = False
while time.monotonic() < deadline:
state = node_state(observer, new.address)
log.info("Join state of %s: %s", new.name, state or "not-yet-visible")
if state == "UJ":
saw_joining = True
elif state == "UN":
if not saw_joining:
log.warning("%s is UN but was never seen UJ — verify it did not skip "
"bootstrap as a seed before trusting it.", new.name)
return True
elif state in {"DN", "DL"}:
log.error("%s went down during bootstrap (%s) — investigate before retrying.",
new.name, state)
return False
time.sleep(gap)
gap = min(gap * 2, POLL_MAX_S)
log.error("Timed out after %ss waiting for %s to reach UN.", JOIN_TIMEOUT_S, new.name)
return False
def cleanup_peers(new: Host, peers: list[Host]) -> None:
"""Run nodetool cleanup on each pre-existing node, strictly one at a time."""
for peer in peers:
if peer.address == new.address:
continue
log.info("Running cleanup on %s (serialized)...", peer.name)
rc, _ = run_nodetool(peer, ["cleanup"], timeout=JOIN_TIMEOUT_S)
if rc != 0:
log.error("cleanup failed on %s — stopping so you can investigate.", peer.name)
return
log.info("cleanup complete on %s.", peer.name)
def bootstrap(new: Host, peers: list[Host]) -> int:
observer = peers[0] # any existing node can observe the ring
existing = node_state(observer, new.address)
if existing == "UN": # idempotency guard: already joined
log.info("%s already UN — running cleanup only.", new.name)
cleanup_peers(new, peers)
return 0
if existing == "UJ":
log.info("%s already UJ — resuming the wait rather than restarting.", new.name)
else:
if not preflight(observer):
return 1
start_node(new)
if not await_un(observer, new):
return 2
log.info("%s reached UN. Proceeding to peer cleanup.", new.name)
cleanup_peers(new, peers)
log.info("Bootstrap of %s complete.", new.name)
return 0
if __name__ == "__main__":
existing_nodes = [
Host("cass-a", "10.0.1.10", "ops@10.0.1.10"),
Host("cass-b", "10.0.1.11", "ops@10.0.1.11"),
Host("cass-c", "10.0.1.12", "ops@10.0.1.12"),
]
joining = Host("cass-d", "10.0.1.13", "ops@10.0.1.13")
sys.exit(bootstrap(joining, existing_nodes))The design encodes the three rules that manual joins violate most often. The preflight gate blocks a start whenever any peer is not UN, which is what enforces one-join-at-a-time — if a prior join is still UJ, the gate fails and the script exits. The await_un loop distinguishes a genuine UJ→UN transition from a suspicious instant UN, warning loudly if the node was never seen joining; for a live view of how far along that transition is, fold in the byte-level progress and ETA from monitoring streaming progress during bootstrap. And cleanup_peers iterates strictly sequentially, so the reclaim never sacrifices more than one replica’s read performance at a time.
To grow a deployment by several nodes, drive this orchestrator once per new node from an outer loop rather than parallelizing it — because preflight requires an all-UN ring, each invocation naturally waits for the previous join to settle before the next can start, which is exactly the serialization Cassandra demands. Keep the existing_nodes list current between runs: a node added in one pass becomes an observer and cleanup target in the next, and where each new node lands on the ring is governed by token allocation and cluster rebalancing. The run_remote transport shown here shells out over SSH for clarity, but in a managed fleet you would swap it for your configuration-management agent or a JMX client so the control host never needs shell access to the data nodes. Whichever transport you use, the guard clauses are what make the script safe to schedule unattended: a transient nodetool timeout returns a non-fatal code and the poll simply retries, while a genuine state regression to DN aborts with a distinct exit code you can alert on.
Verification steps
Run the orchestrator and confirm each phase from an independent shell. During the join, the observer should report UJ:
nodetool status | grep 10.0.1.13 # expect UJ while streamingAfter the script logs reached UN, verify ownership rebalanced and streaming drained:
nodetool status # new node UN, Owns evened out
nodetool netstats | grep Mode # expect "Mode: NORMAL"Confirm the serialized cleanup actually ran on the peers by checking that no stale ranges remain — a peer’s Load should drop after its cleanup completes:
nodetool status | awk '{print $1, $2, $3, $4}' # Load lower on cleaned peersTroubleshooting
Other bootstrapping/leaving nodes detected, cannot bootstrap while cassandra.consistent.rangemovement is true. The joining node exits at startup because another node was stillUJorULwhen it tried to join. Root cause: a concurrent range movement. Fix: thepreflightgate is designed to prevent this — if you hit it, a peer changed state after the gate passed; wait for the ring to return to all-UN, then re-run the script, which will resume idempotently.- Stuck streaming /
StreamException: Stream failed. The node sits inUJpast a reasonable window andawait_uneventually times out;system.logon the joining node shows aStreamException. Root cause: a source node dropped a connection or ran out of I/O headroom mid-transfer. Fix: runnodetool bootstrap resumeon the joining node to retry only the incomplete ranges, then re-run the orchestrator — it will find the node stillUJand resume the wait rather than restarting the process. If resume fails repeatedly, wipe the data directory and rejoin from scratch. - Schema disagreement blocks the join.
preflightfails with “Schema not converged”, or the node logsJOINING: waiting for schema information to completeand never advances. Root cause: an in-flight DDL change left nodes on different schema versions. Fix: resolve the disagreement first — identify the divergent node withnodetool describecluster, restart it if it is stuck, and wait for a single schema UUID before re-running the orchestrator.
Related
- Bootstrapping new nodes safely — the manual procedure and configuration reference this script automates.
- Monitoring streaming progress during bootstrap — add per-stream progress and ETA to the wait loop above.
- Node lifecycle automation — the parent guide covering the full add, remove, replace, and upgrade toolkit.
- Token allocation and cluster rebalancing — control where the joining node lands and how evenly ownership settles.