feat(worker): rootless podman container runtime for docker-mode jobs

Add a per-worker-group `container_runtime` option. When set to "podman", the
worker starts a rootless, daemonless `podman system service` on a unix socket
and points DOCKER_HOST at it, so docker-mode jobs (`# docker` bash scripts) run
against a Docker-API-compatible daemon without a privileged dind sidecar or the
host Docker socket. bollard's existing handle_docker_job talks to it unchanged
(validated: inspect/wait/logs/stats/kill/stop/remove against podman 5.8.2).

- windmill-common: container_runtime field on WorkerConfigOpt/WorkerConfig,
  loaded from group config or CONTAINER_RUNTIME env.
- windmill-worker: start_container_runtime_maybe() spawns the rootless podman
  service once per process (i_worker==1), waits for the socket, sets DOCKER_HOST.
  Respects an existing DOCKER_HOST (legacy dind) and degrades with a clear log if
  the podman binary is absent (use a *-full image or an init script).
- monitor: restart the worker (killpill) when container_runtime changes.
- api-configs: allow container_runtime in CE config updates.

Requires rootless podman with cgroup v2 delegation for memory monitoring;
podman ships in the *-full images (next commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-03 12:48:14 +00:00
parent 956b07a095
commit 52f39a0e04
4 changed files with 131 additions and 12 deletions
+8
View File
@@ -315,6 +315,9 @@ pub async fn initial_load(
additional_python_paths: None,
pip_local_dependencies: None,
native_mode,
container_runtime: std::env::var("CONTAINER_RUNTIME")
.ok()
.filter(|x| !x.is_empty()),
}));
}
}
@@ -3002,6 +3005,11 @@ pub async fn reload_worker_config(db: &DB, tx: KillpillSender, kill_if_change: b
let _ = tx.send();
}
if wc.container_runtime != config.container_runtime {
tracing::info!("Container runtime config changed, sending killpill. Expecting to be restarted by supervisor.");
let _ = tx.send();
}
if wc.cache_clear != config.cache_clear {
tracing::info!("Cache clear changed, sending killpill. Expecting to be restarted by supervisor.");
let _ = tx.send();
+18 -10
View File
@@ -63,10 +63,12 @@ async fn list_worker_groups(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<Config>> {
let mut configs_raw =
sqlx::query_as!(Config, "SELECT name, config FROM config WHERE name LIKE 'worker__%'")
.fetch_all(&db)
.await?;
let mut configs_raw = sqlx::query_as!(
Config,
"SELECT name, config FROM config WHERE name LIKE 'worker__%'"
)
.fetch_all(&db)
.await?;
// Remove the 'worker__' prefix from all config names
for config in configs_raw.iter_mut() {
if let Some(name) = &config.name {
@@ -119,10 +121,14 @@ async fn get_config(
) -> error::JsonResult<Option<serde_json::Value>> {
require_devops_role(&db, &authed.email).await?;
let config = sqlx::query_as!(Config, "SELECT name, config FROM config WHERE name = $1", name)
.fetch_optional(&db)
.await?
.map(|c| c.config);
let config = sqlx::query_as!(
Config,
"SELECT name, config FROM config WHERE name = $1",
name
)
.fetch_optional(&db)
.await?
.map(|c| c.config);
Ok(Json(config))
}
@@ -137,12 +143,14 @@ async fn update_config(
#[cfg(not(feature = "enterprise"))]
let config = if name.starts_with("worker__") {
// In CE, only allow setting worker_tags, cache_clear, init_bash, and native_mode
// In CE, only allow setting worker_tags, cache_clear, init_bash, native_mode,
// and container_runtime
serde_json::json!({
"worker_tags": config.get("worker_tags"),
"cache_clear": config.get("cache_clear"),
"init_bash": config.get("init_bash"),
"native_mode": config.get("native_mode")
"native_mode": config.get("native_mode"),
"container_runtime": config.get("container_runtime")
})
} else {
config
+14 -2
View File
@@ -234,6 +234,7 @@ lazy_static::lazy_static! {
pip_local_dependencies: Default::default(),
env_vars: Default::default(),
native_mode: false,
container_runtime: None,
});
pub static ref WORKER_PULL_QUERIES: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]);
@@ -1960,6 +1961,10 @@ pub async fn load_worker_config(
.or_else(|| load_additional_python_paths_from_env()),
env_vars: resolved_env_vars,
native_mode,
container_runtime: config
.container_runtime
.or_else(|| std::env::var("CONTAINER_RUNTIME").ok())
.and_then(|x| if x.is_empty() { None } else { Some(x) }),
})
}
@@ -2049,6 +2054,11 @@ pub struct WorkerConfigOpt {
pub env_vars_static: Option<HashMap<String, String>>,
pub env_vars_allowlist: Option<Vec<String>>,
pub native_mode: Option<bool>,
/// Container runtime for docker-mode jobs on this worker group. When set to
/// "podman", the worker starts a rootless podman service and points
/// DOCKER_HOST at it (see start_container_runtime). None = no managed runtime
/// (legacy dind/host-socket via DOCKER_HOST/socket still works if present).
pub container_runtime: Option<String>,
}
impl Default for WorkerConfigOpt {
@@ -2067,6 +2077,7 @@ impl Default for WorkerConfigOpt {
env_vars_static: Default::default(),
env_vars_allowlist: Default::default(),
native_mode: Default::default(),
container_runtime: Default::default(),
}
}
}
@@ -2085,12 +2096,13 @@ pub struct WorkerConfig {
pub pip_local_dependencies: Option<Vec<String>>,
pub env_vars: HashMap<String, String>,
pub native_mode: bool,
pub container_runtime: Option<String>,
}
impl std::fmt::Debug for WorkerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?}, native_mode: {:?} }}",
self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "), self.native_mode)
write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?}, native_mode: {:?}, container_runtime: {:?} }}",
self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "), self.native_mode, self.container_runtime)
}
}
+91
View File
@@ -2244,6 +2244,10 @@ pub async fn run_worker(
) = (HashMap::new(), vec![]);
if i_worker == 1 {
// Start the configured container runtime (rootless podman) before any job
// runs, so docker-mode jobs have a Docker-API-compatible daemon and
// DOCKER_HOST is set process-wide. No-op unless container_runtime is set.
start_container_runtime_maybe().await;
// Initialize runtime asset inserter for batched database inserts
if let Connection::Sql(db) = conn {
init_runtime_asset_loop(db.clone(), killpill_rx.resubscribe());
@@ -3235,6 +3239,93 @@ pub async fn run_worker(
tracing::info!(worker = %worker_name, hostname = %hostname, "number of jobs executed: {}", jobs_executed);
}
// Holds the managed podman service child so it is not dropped (and thus not
// reaped) for the lifetime of the worker process.
static PODMAN_SERVICE: std::sync::Mutex<Option<tokio::process::Child>> =
std::sync::Mutex::new(None);
/// Start the container runtime configured on this worker group (currently only
/// rootless "podman") so docker-mode jobs have a Docker-API-compatible daemon to
/// talk to — without a privileged dind sidecar or the host Docker socket. Spawns
/// a rootless `podman system service` on a unix socket and points DOCKER_HOST at
/// it, which both `connect_docker()` (bollard) and the docker CLI forwarded to
/// scripts then use. No-op unless `container_runtime` is set on the group.
async fn start_container_runtime_maybe() {
let runtime = match WORKER_CONFIG.load().container_runtime.clone() {
Some(r) => r,
None => return,
};
if runtime != "podman" {
tracing::error!(
"Unknown container_runtime '{runtime}' (expected 'podman'); not starting a container runtime."
);
return;
}
// Respect an explicitly-provided DOCKER_HOST (e.g. a legacy dind/host socket).
if std::env::var("DOCKER_HOST").is_ok() {
tracing::info!(
"container_runtime=podman but DOCKER_HOST is already set; using the existing daemon and not starting the managed podman runtime."
);
return;
}
let podman_ok = tokio::process::Command::new("podman")
.arg("--version")
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !podman_ok {
tracing::error!(
"container_runtime=podman but the 'podman' binary was not found on this worker. Use a windmill *-full image, or install podman via a worker-group init script. Docker-mode jobs will fail until podman is available."
);
return;
}
let sock_dir = "/tmp/windmill";
let _ = tokio::fs::create_dir_all(sock_dir).await;
let sock_path = format!("{sock_dir}/podman.sock");
let _ = tokio::fs::remove_file(&sock_path).await; // clear any stale socket
let docker_host = format!("unix://{sock_path}");
match tokio::process::Command::new("podman")
.args(["system", "service", "--time=0", &docker_host])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => {
if let Ok(mut guard) = PODMAN_SERVICE.lock() {
*guard = Some(child);
}
// Wait (up to ~10s) for the rootless podman service socket to appear
// so the first docker job doesn't race startup.
let mut ready = false;
for _ in 0..50 {
if tokio::fs::metadata(&sock_path).await.is_ok() {
ready = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
std::env::set_var("DOCKER_HOST", &docker_host);
if ready {
tracing::info!(
"Started rootless podman container runtime, DOCKER_HOST={docker_host}"
);
} else {
tracing::warn!(
"Started rootless podman container runtime but socket {sock_path} did not appear within 10s; DOCKER_HOST set to {docker_host} anyway."
);
}
}
Err(e) => {
tracing::error!(
"Failed to start podman container runtime: {e:#}. Docker-mode jobs will fail."
);
}
}
}
async fn queue_init_bash_maybe<'c>(
conn: &Connection,
same_worker_tx: SameWorkerSender,