container: improve mount reconciliation and error handling
Nightly Build / build (push) Successful in 6m46s

This commit is contained in:
2026-07-22 19:33:12 +01:00
parent 6f0461f7f5
commit d5f80dfcb3
+50 -9
View File
@@ -55,6 +55,16 @@ pub const CONTAINER_HOME: &str = "/root";
/// before force-killing — enough for a shell or MCP `docker exec` child to exit. /// before force-killing — enough for a shell or MCP `docker exec` child to exit.
const STOP_GRACE: Duration = Duration::from_secs(10); const STOP_GRACE: Duration = Duration::from_secs(10);
/// Grace window at **app shutdown** ([`ContainerManager::stop_all`]). Deliberately
/// short: a healthy container (tini as PID 1, via `--init`) exits within ~100 ms of
/// SIGTERM, so this only bounds the pathological case — an old container whose PID 1
/// is `sleep infinity` (created before `--init`) ignores SIGTERM entirely (the kernel
/// applies no default signal disposition to PID 1) and would otherwise burn the full
/// 10 s `STOP_GRACE` before SIGKILL, once **per user, in sequence**. `ensure`'s init
/// self-heal recreates such containers with tini on the next boot; this cap keeps the
/// shutdown fast in the meantime.
const SHUTDOWN_STOP_GRACE: Duration = Duration::from_secs(2);
/// The deterministic container name for a user — derivable without any manager, /// The deterministic container name for a user — derivable without any manager,
/// so `UserFs` can carry it and `execute_cmd` can exec into it directly. /// so `UserFs` can carry it and `execute_cmd` can exec into it directly.
pub fn container_name(user_id: &str) -> String { pub fn container_name(user_id: &str) -> String {
@@ -203,16 +213,19 @@ impl ContainerManager {
let want_user = host_uid_gid().map(|(uid, gid)| format!("{uid}:{gid}")); let want_user = host_uid_gid().map(|(uid, gid)| format!("{uid}:{gid}"));
match container_state(name).await { match container_state(name).await {
// Reuse only if it runs as the expected user; otherwise recreate below. // Reuse only if it runs as the expected user AND has tini as PID 1;
ContainerState::Running if user_matches(name, &want_user).await => return Ok(()), // otherwise recreate below.
ContainerState::Stopped if user_matches(name, &want_user).await => { ContainerState::Running if reusable(name, &want_user).await => return Ok(()),
ContainerState::Stopped if reusable(name, &want_user).await => {
docker(&["start", name]).await.context("docker start failed")?; docker(&["start", name]).await.context("docker start failed")?;
return Ok(()); return Ok(());
} }
ContainerState::Absent => {} ContainerState::Absent => {}
// Present but with a stale `--user` (e.g. an old root container): tear it // Present but stale — a mismatched `--user` (e.g. an old root container) or
// down. The container holds no durable state — everything is in the bind // missing `--init` (an old container whose PID 1 is `sleep infinity`, which
// mounts — so a recreate is safe. // ignores SIGTERM and hangs `docker stop` for the full grace, see
// `SHUTDOWN_STOP_GRACE`): tear it down. The container holds no durable state
// — everything is in the bind mounts — so a recreate is safe.
_ => { _ => {
let _ = docker(&["rm", "-f", name]).await; let _ = docker(&["rm", "-f", name]).await;
} }
@@ -266,14 +279,25 @@ impl ContainerManager {
} }
/// Stops every user's container (best-effort) at shutdown. /// Stops every user's container (best-effort) at shutdown.
///
/// Stops run **concurrently** and with a short [`SHUTDOWN_STOP_GRACE`], so total
/// shutdown time is bounded by one grace window regardless of how many users there
/// are — not `N × 10 s` as the old sequential, default-grace loop was (a pre-`--init`
/// container ignores SIGTERM and burns the full grace before SIGKILL).
pub async fn stop_all(&self) -> Result<()> { pub async fn stop_all(&self) -> Result<()> {
let users = db::users::list(&self.system).await?; let users = db::users::list(&self.system).await?;
let secs = SHUTDOWN_STOP_GRACE.as_secs().to_string();
let mut set = tokio::task::JoinSet::new();
for user in &users { for user in &users {
let name = container_name(&user.id); let name = container_name(&user.id);
if let Err(e) = docker(&["stop", &name]).await { let secs = secs.clone();
tracing::debug!(container = %name, error = %e, "container stop (ignored)"); set.spawn(async move {
} if let Err(e) = docker(&["stop", "-t", &secs, &name]).await {
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
}
});
} }
while set.join_next().await.is_some() {}
Ok(()) Ok(())
} }
@@ -354,6 +378,23 @@ async fn user_matches(name: &str, want: &Option<String>) -> bool {
} }
} }
/// Whether a container was created with `--init` (tini as PID 1). `.HostConfig.Init`
/// is `true` only then; an old container (pre-`--init`) reports `<nil>`/`false`, so its
/// PID 1 is `sleep infinity`, which ignores SIGTERM and makes `docker stop` hang for
/// the full grace before SIGKILL. A `false` here triggers a recreate in [`ensure`].
async fn init_matches(name: &str) -> bool {
docker(&["inspect", "-f", "{{.HostConfig.Init}}", name])
.await
.map(|s| s.trim() == "true")
.unwrap_or(false)
}
/// Whether an existing container can be reused as-is: right `--user` (§6 UID coherence)
/// **and** `--init` (fast, clean `docker stop`). A mismatch on either recreates it.
async fn reusable(name: &str, want_user: &Option<String>) -> bool {
user_matches(name, want_user).await && init_matches(name).await
}
/// Gives the container's runtime `uid`/`gid` a passwd + shadow (+ group) entry, so /// Gives the container's runtime `uid`/`gid` a passwd + shadow (+ group) entry, so
/// tools that resolve the invoking user work despite the arbitrary numeric uid — and /// tools that resolve the invoking user work despite the arbitrary numeric uid — and
/// so `sudo` succeeds (without a shadow entry PAM's account phase fails with "account /// so `sudo` succeeds (without a shadow entry PAM's account phase fails with "account