A release is on probation until the window closes A gated rollback in Rust
Passing readiness once is not the same as being good. Count the exits, close the window, then confirm.
Lab download. Starter is the tree you edit. Worked answer is a separate file; open it after the starter passes. Rust 1.85 or newer, tar, and jq. One online cargo vendor vendor. The zip has no vendored crates. All lab requirements.
B-DevOps · A · arc: self-healing-edge · langs: rust, go · CKA-workloads-rollout, DOP-1.4
By Evan Eugene Pincham.
Concept
Some vocabulary first, because the rest of the lesson leans on it.
- A release is one version of a service, named here
v1,v2, and so on. - A supervisor is a program whose job is to watch a process and start it again when it dies. Each restart is a restart, and the death that causes it is an exit.
- A rollback is the act of making an earlier release the current one again.
- A deploy is the move from the release now serving traffic to a new candidate.
A readiness check asks one question at one instant: is the candidate answering right now? A release can say yes, be promoted, and then crash and restart every few seconds. The check passed, and the deploy is still bad. Readiness at one instant is a weak test.
The remedy in this lesson is a time-boxed probation. After promotion the candidate enters a soak window, a fixed span of time during which the deployer watches it. The deployer also holds a restart budget, the number of restarts it will tolerate inside that window. This lesson uses a 30 000 ms window and a budget of 2, so two restarts are allowed and the third exit inside the window fails the deploy.
Three rules follow from that, and the program enforces all of them.
- A promoted release is not "good" yet. It becomes
confirmed, and becomes the rollback target, only when the window closes with the budget intact. - If the budget is exceeded, the deployer rolls back to the last confirmed release (
last_good) and writes down why. - The rollback target comes from the deployer's own state. It never comes from a
previousfield on an incoming event, because that field is a claim made by whoever wrote the event, and it can be stale. It is also never a release that was itself rolled back.
The program also keeps a ledger: an append-only record of every action the deployer took. The ledger is JSONL, meaning one JSON object per line, so each line can be read on its own with jq or loaded into a table later. Every line carries ts (a timestamp in milliseconds), event, and version. Other fields depend on the event.
Honesty line: this is a deploy decision kernel over a fixture plan. A plan is a JSONL file of events, each with a millisecond ts. The program uses the CPU only. It starts no processes, reads no clock, and talks to no cluster. Time moves only when an event's ts says so. What is real is the soak window, the restart budget, the rollback target rule, and the honest ledger. What is simulated is actually starting releases and probing them.
The same two knobs exist in Kubernetes, separately. A Pod is the smallest unit Kubernetes runs, one or more containers together. A Deployment is the Kubernetes object that manages a set of Pods and rolls them from one version to the next. The Deployment documentation describes .spec.minReadySeconds as "an optional field that specifies the minimum number of seconds for which a newly created Pod should be ready without any of its containers crashing, for it to be considered available." That is probation. It defaults to 0, "the Pod will be considered available as soon as it is ready", which is the weak test described above. The second knob, progressDeadlineSeconds, covers stalls. Neither knob is a restart budget with a rollback attached. See Other paths.
The idea of "N restarts inside a window" is older than Kubernetes. Joe Armstrong's thesis describes supervisor restart intensity written as {one_for_one,5,1000}: if a supervised process restarts more than 5 times in 1000 seconds, the supervisor itself gives up and fails. The release-level version here applies the same cap to a release instead of a worker.
The Site Reliability Workbook makes the matching point about canaries: a canary has to last long enough, and be large enough, to be representative, and ending it after a handful of requests gives no useful signal. A soak window is the duration half of that advice.
Worked example
The kernel is a small state machine, a program that is always in exactly one named state and moves between states only when an event arrives. (A decision kernel is the part of a deployer that decides what to do, kept apart from the code that would start and stop processes.) Deploy holds four states: Serving, Soaking, Confirmed, RolledBack. It also holds current, last_good, candidate, the window start, and two counters. Four events drive it:
serving: the baseline release. It is confirmed by definition.promoted: a candidate takes over and its window starts.exited: a release process died, and the supervisor restarts it.tick: time passes. The event does nothing except carry ats.
Here is the whole program.
use serde::Deserialize;
use serde_json::{json, Value};
use std::{env, fs, process::exit};
const SOAK_MS: u64 = 30_000;
const MAX_RESTARTS: u32 = 2;
#[derive(Deserialize)]
struct Event {
ts: u64,
event: String,
version: Option<String>,
}
#[derive(Clone, Copy, PartialEq)]
enum State {
Serving,
Soaking,
Confirmed,
RolledBack,
}
struct Deploy {
current: String,
last_good: String,
candidate: String,
state: State,
start: u64,
restarts: u32,
exits: u32,
}
impl Deploy {
fn new() -> Self {
Deploy {
current: String::new(),
last_good: String::new(),
candidate: String::new(),
state: State::Serving,
start: 0,
restarts: 0,
exits: 0,
}
}
fn step(&mut self, e: &Event, out: &mut Vec<Value>) {
// The soak window closes when time passes its end with the candidate still alive.
if self.state == State::Soaking && e.ts >= self.start + SOAK_MS {
self.last_good = self.candidate.clone();
self.state = State::Confirmed;
out.push(json!({"event":"confirmed","ts":e.ts,"version":self.candidate,"restarts":self.restarts}));
}
let version = e.version.clone().unwrap_or_default();
match e.event.as_str() {
"serving" => {
self.current = version.clone();
self.last_good = version;
self.state = State::Serving;
}
"promoted" => {
self.candidate = version.clone();
self.current = version;
self.state = State::Soaking;
self.start = e.ts;
self.restarts = 0;
self.exits = 0;
}
"exited" => {
if self.state == State::Soaking && version == self.candidate {
self.exits += 1;
if self.exits <= MAX_RESTARTS {
self.restarts += 1;
out.push(json!({"event":"restarting","ts":e.ts,"version":version,"attempt":self.restarts,"max":MAX_RESTARTS}));
} else {
// Roll back to the last confirmed release, never to a release that was itself rolled back.
self.current = self.last_good.clone();
self.state = State::RolledBack;
out.push(json!({"event":"rolled_back","ts":e.ts,"version":version,"from":version,"to":self.last_good,"reason":"restart_budget_exceeded","exits_in_window":self.exits,"max":MAX_RESTARTS,"window_ms":SOAK_MS}));
}
} else if self.state == State::Confirmed && version == self.current {
out.push(json!({"event":"restarted_post_soak","ts":e.ts,"version":version}));
}
}
_ => {}
}
}
fn deploy(&self) -> &'static str {
match self.state {
State::Serving => "serving",
State::Soaking => "soaking",
State::Confirmed => "confirmed",
State::RolledBack => "rolled_back",
}
}
}
fn run(path: &str) -> Result<(Deploy, Vec<Value>), String> {
let text = fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let mut d = Deploy::new();
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let e: Event = serde_json::from_str(line).map_err(|e| format!("{path}:{}: {e}", i + 1))?;
d.step(&e, &mut out);
}
Ok((d, out))
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 3 || !(args[1] == "apply" || args[1] == "report") {
eprintln!("usage: soak apply|report PLAN");
exit(2);
}
let (d, out) = match run(&args[2]) {
Ok(v) => v,
Err(m) => {
eprintln!("soak: {m}");
exit(2);
}
};
if args[1] == "apply" {
for a in out {
println!("{a}");
}
} else {
println!("soak_ms: {SOAK_MS}");
println!("max_restarts: {MAX_RESTARTS}");
println!("current: {}", d.current);
println!("last_good: {}", d.last_good);
println!("candidate: {}", d.candidate);
println!("candidate.restarts: {}", d.restarts);
println!("deploy: {}", d.deploy());
}
}
The binary has two commands. soak apply PLAN prints the ledger, one JSON line per action. soak report PLAN prints the final state: soak_ms, max_restarts, current, last_good, candidate, candidate.restarts, and deploy, which is one of serving, soaking, confirmed, rolled_back. A missing or unreadable plan file exits with code 2.
Trace the storm by hand. This is the plan the checker calls flap.plan. The release v2 exits three times inside its window.
{"ts":0,"event":"serving","version":"v1"}
{"ts":1000,"event":"promoted","version":"v2","previous":"v1"}
{"ts":3000,"event":"exited","version":"v2"}
{"ts":6000,"event":"exited","version":"v2"}
{"ts":9000,"event":"exited","version":"v2"}
- At
ts0,v1serves and islast_good. - At 1000,
v2is promoted. It becomescurrentandcandidate, the state isSoaking, and the window runs to 31 000.last_goodis stillv1. - At 3000,
v2exits.exitsis 1, which is within the budget, so the ledger gets arestartingline withattempt1 andmax2. - At 6000, the second exit gets
restartingwithattempt2. - At 9000, the third exit makes
exits3, which is greater thanMAX_RESTARTS. The ledger gets arolled_backline withtoset tolast_good(v1) andreasonset torestart_budget_exceeded. It also recordsexits_in_window,max, andwindow_ms, so a reader sees the evidence for the decision without re-deriving it.currentreturns tov1.
The reference run on the lab image, with the network disabled, printed:
flap apply: restarting=2 rolled_back=1 to=v1 reason=restart_budget_exceeded
flap report: current=v1 last_good=v1 deploy=rolled_back
Three details in the code decide the harder cases.
- The window check runs first, before the event is interpreted. The first event whose
tsis at or past promotion plus 30 000 closes the window, whatever event it is. A late exit is therefore judged after the window has closed. In the planslowburn.plan,v2exits at 3000 and 6000, a tick arrives at 31 500, and a third exit arrives at 32 000. The tick confirmsv2. The exit at 32 000 hits theConfirmedbranch and becomesrestarted_post_soak, which is logged but never triggers a rollback. The reference run printedslowburn apply: rolled_back=0 confirmed=1 post_soak=1. - *Rollback reads
self.last_good, andEventhas nopreviousfield.* TheEventstruct declares onlyts,event, andversion, so apreviousin the input is not read at all. Inchain.plan,v2is rolled back, thenv3is promoted claimingprevious=v2, thenv3storms.last_goodis stillv1, becausev2never survived a window. The reference run printedchain apply: rolled_back=2 last_to=v1. - Time moves only on events. If a plan ends while the window is open, nothing closes it.
soaking.planhas one exit at 3000 and then stops. The reference run printedsoaking report: current=v2 last_good=v1 deploy=soaking. The candidate is serving, but it is not called good, andlast_goodis stillv1. A real deployer would need a timer to close the window with no event. This kernel deliberately has none.
The ledger schema is the Data half of the lesson. Every line, whatever its event, has a numeric ts, a string event, and a string version. On a rolled_back line version names the release that failed, and to names the release restored. A query tool such as jq, or a table loader later, can read the file with no special parsing. The checker tests the schema with this filter, which prints any line that breaks it:
jq -c 'select((.ts|type)!="number" or (.event|type)!="string" or (.version|type)!="string")'
Lab
You get a starter project with the same layout as the reference: Cargo.toml, src/main.rs, a vendored vendor.tgz of serde and serde_json, and check.sh. The checker is POSIX sh and needs cargo, tar, and jq. It builds offline, so no network is needed. Run it from the directory that holds check.sh:
sh check.sh
It prints one RESULT line per check, eight in all, each ending in PASS or FAIL: build, storm_rolls_back, survivor_confirmed, no_confirm_while_soaking, window_closes, rollback_target_last_good, ledger_schema, and missing_file_exit_2. The checker writes its own fixture plans (flap, stable, soaking, slowburn, chain) and runs soak against them.
The starter has three planted bugs. On it, four checks fail (the CI gate log has the full starter run):
RESULT survivor_confirmed FAIL
RESULT no_confirm_while_soaking FAIL
RESULT window_closes FAIL
RESULT rollback_target_last_good FAIL
The bugs, described by behavior:
- Promotion counts as good at once. On
promoted, the starter setslast_goodto the candidate and moves straight toConfirmed. A release that has only just been promoted becomes the rollback target and shows asconfirmed. After the storm inflap.plan, the starter's gate run reportedflap report: current=v1 last_good=v2 deploy=rolled_back, which names the failed release as the last good one. In the reference, thepromotedarm setsState::Soakingand leaveslast_goodalone. - The window is ignored. Exits count forever, so a slow, healthy release eventually trips the budget. The fix is the block at the top of
step, which closes the window whentsreaches the end:
// The soak window closes when time passes its end with the candidate still alive.
if self.state == State::Soaking && e.ts >= self.start + SOAK_MS {
self.last_good = self.candidate.clone();
self.state = State::Confirmed;
out.push(json!({"event":"confirmed","ts":e.ts,"version":self.candidate,"restarts":self.restarts}));
}
- **The rollback target is the event's
previous.** The starter readspreviousfrom the plan, so onchain.planits gate run printedchain apply: rolled_back=2 last_to=v2, rolling back to a release that had already failed. The fix is to take the target fromself.last_good, as in the reference:
// Roll back to the last confirmed release, never to a release that was itself rolled back.
self.current = self.last_good.clone();
self.state = State::RolledBack;
Work through the checks in this order: no_confirm_while_soaking, survivor_confirmed, window_closes, rollback_target_last_good. After each fix, re-run sh check.sh and read the diagnostic lines the checker prints above each RESULT. When all eight lines read PASS, the reference output looks like this:
RESULT build PASS
RESULT storm_rolls_back PASS
RESULT survivor_confirmed PASS
RESULT no_confirm_while_soaking PASS
RESULT window_closes PASS
RESULT rollback_target_last_good PASS
RESULT ledger_schema PASS
RESULT missing_file_exit_2 PASS
Then extend it. Change MAX_RESTARTS to 0 and predict, before running, which plans change outcome and why. Then write your own plan where v2 is confirmed, v3 is promoted and storms, and v2 is the correct rollback target.
Other paths
Kubernetes. A Deployment already has both knobs, as separate fields. minReadySeconds is the probation: a Pod must be ready without crashing for that many seconds before it counts as available. The default of 0 means a Pod counts as available as soon as it is ready. progressDeadlineSeconds handles the case where a rollout stalls. When it expires, the Deployment gains a condition with type: Progressing, status: "False", and reason: ProgressDeadlineExceeded. Rolling back is a separate act. The Deployment documentation shows kubectl rollout undo deployment/nginx-deployment, with --to-revision to choose a specific revision. The documentation also says that for a stalled Deployment, Kubernetes takes no action other than to report a status condition.
So the declarative part is the probation and the deadline: you state them in the Deployment, and Kubernetes evaluates them. What is still missing is a restart budget wired to a rollback. Something above the Deployment has to read the condition and run the undo. That something can be a person, or a controller or pipeline step that you write. Seen that way, the kernel in this lesson is the decision step such a controller would call. Separately, the Pod Lifecycle documentation notes that a Pod which keeps failing to start may show CrashLoopBackOff in the Status field of some kubectl commands. That is a display field, not a Pod phase, so it is a symptom you read, not a state you can rely on to trigger anything.
Go. The same decision step can be written in Go as a function that takes the current deploy state and one event and returns the new state plus any ledger lines. The branches are the same: close the window, then promote, exit, or ignore. The Go version is a sketch for comparison. It was not compiled or run for this lesson.
jq. Because the ledger is JSONL, questions about it are one-line filters. The schema check in the worked example is one. A table loader can read the same files without any conversion.
Citations
- Kubernetes documentation, Deployments. Sections "Min Ready Seconds", "Progress Deadline Seconds", "Rolling Back a Deployment", and "Failed Deployment". https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
- Kubernetes documentation, Pod Lifecycle. The note on
CrashLoopBackOffas a Status display value. https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/ - Beyer, Murphy, Rensin, Kawahara, Thorne (eds.), The Site Reliability Workbook, O'Reilly, 2018, chapter 16 "Canarying Releases": "Requirements of a Canary Process" and "Choosing a Canary Population and Duration".
- Joe Armstrong, Making reliable distributed systems in the presence of software errors, KTH, 2003, chapter 6: supervisor restart intensity,
{one_for_one,5,1000}.