fix(#2319): treat container start as success when the unit reaches active, not on the start exit code
This commit is contained in:
parent
3f7f24dd0b
commit
bbbc2e28c7
1 changed files with 77 additions and 23 deletions
|
|
@ -418,9 +418,31 @@ pub async fn kill(name: &str) -> Result<()> {
|
||||||
priv_run("stop", name).await
|
priv_run("stop", name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a container. Success is defined as the container's unit reaching
|
||||||
|
/// `active`, **not** the `nixos-container start` exit code — a start-job
|
||||||
|
/// timeout on a slow boot (e.g. DHCP taking ~10s) exits non-zero while
|
||||||
|
/// `container@<c>.service` keeps retrying and the container comes up seconds
|
||||||
|
/// later. So a zero exit returns immediately (already active), and a non-zero
|
||||||
|
/// exit polls the unit state for [`START_SETTLE_TIMEOUT`] before concluding
|
||||||
|
/// the start actually failed. Every caller (dashboard restart, reconcile
|
||||||
|
/// start, the cold-start fallback) gets this truth for free.
|
||||||
pub async fn start(name: &str) -> Result<()> {
|
pub async fn start(name: &str) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
priv_run("start", name).await
|
if priv_run("start", name).await.is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let container = container_name(name);
|
||||||
|
if wait_until_running(name, START_SETTLE_TIMEOUT).await {
|
||||||
|
tracing::info!(
|
||||||
|
container = %container,
|
||||||
|
"start exited non-zero but the container reached active on systemd's retry; treating as success"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"container {container} did not reach active within {}s of start",
|
||||||
|
START_SETTLE_TIMEOUT.as_secs()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque token produced by [`converge_start_preamble`].
|
/// Opaque token produced by [`converge_start_preamble`].
|
||||||
|
|
@ -472,41 +494,73 @@ pub async fn start_with_fallback(token: StartableAgent) -> Result<()> {
|
||||||
start_with_fallback_inner(&token.name).await
|
start_with_fallback_inner(&token.name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long to wait for a container's unit to reach `active` after a
|
||||||
|
/// `nixos-container start` that returned a non-zero exit. A start-job timeout
|
||||||
|
/// on a slow boot (e.g. DHCP taking ~10s) returns an error while
|
||||||
|
/// `container@<c>.service` keeps retrying and the container comes up seconds
|
||||||
|
/// later — so the exit code is NOT authoritative, the unit state is. 60s
|
||||||
|
/// comfortably covers an observed slow boot (the reported incident settled
|
||||||
|
/// ~16s after the "failure") without pinning a queue node on a genuine
|
||||||
|
/// never-boots failure for too long.
|
||||||
|
const START_SETTLE_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
|
||||||
|
|
||||||
|
/// Poll cadence while waiting for a container to reach `active`.
|
||||||
|
const START_SETTLE_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Poll [`is_running`] until the container's unit is active or `timeout`
|
||||||
|
/// elapses; returns true as soon as it's active.
|
||||||
|
async fn wait_until_running(name: &str, timeout: std::time::Duration) -> bool {
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if is_running(name).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(START_SETTLE_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Internal implementation of the cold-start fallback. Used by
|
/// Internal implementation of the cold-start fallback. Used by
|
||||||
/// [`start_with_fallback`] (public, token-gated) and by
|
/// [`start_with_fallback`] (public, token-gated) and by
|
||||||
/// [`rebuild_no_meta`] where the preamble is already enforced structurally.
|
/// [`rebuild_no_meta`] where the preamble is already enforced structurally.
|
||||||
|
///
|
||||||
|
/// [`start`] already treats unit-active (not the exit code) as success and
|
||||||
|
/// waits out a slow boot, so this only layers the activation-error recovery on
|
||||||
|
/// top: if the container is still down after `start`, tear it down and try
|
||||||
|
/// `start` once more. The teardown prefers a graceful `stop` and only
|
||||||
|
/// escalates to SIGKILL when that `stop` itself fails.
|
||||||
async fn start_with_fallback_inner(name: &str) -> Result<()> {
|
async fn start_with_fallback_inner(name: &str) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
if let Err(start_err) = priv_run("start", name).await {
|
if start(name).await.is_ok() {
|
||||||
let container = container_name(name);
|
return Ok(());
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
%name,
|
||||||
|
"start did not bring the container up; hard-resetting (stop, kill only if that fails) and retrying once"
|
||||||
|
);
|
||||||
|
// Graceful `stop` first; only escalate to SIGKILL if the stop itself
|
||||||
|
// fails (e.g. a wedged container whose `machinectl poweroff` never
|
||||||
|
// completes). A clean stop is enough on its own — SIGKILL is the
|
||||||
|
// last-resort teardown for when graceful shutdown can't finish.
|
||||||
|
if let Err(stop_err) = priv_run("stop", name).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
container = %container,
|
%name,
|
||||||
error = %start_err,
|
error = %stop_err,
|
||||||
"start failed (possible activation error); retrying via stop + kill + start"
|
"graceful stop failed; escalating to SIGKILL"
|
||||||
);
|
);
|
||||||
priv_run("stop", name).await.unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
container = %container,
|
|
||||||
error = %e,
|
|
||||||
"stop before cold-start retry failed (ignored)"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
priv_run("kill", name).await.unwrap_or_else(|e| {
|
priv_run("kill", name).await.unwrap_or_else(|e| {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
container = %container,
|
%name,
|
||||||
error = %e,
|
error = %e,
|
||||||
"kill before cold-start retry failed (ignored)"
|
"kill after failed stop also failed (ignored)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
priv_run("start", name).await.map_err(|e| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"cold-start fallback also failed: {e:#} \
|
|
||||||
(original start error: {start_err:#})"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
start(name)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("cold-start fallback also failed for {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop + start without regenerating any config. For "kick the container"
|
/// Stop + start without regenerating any config. For "kick the container"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue