Airgapped Execution¶
The docker-airgapped runner executes workflows you do not trust — above
all, model-authored (dynamic) workflows — and workflows over data that must
not leave the box. Each execution runs in its own container whose only
capability channel is the stdio protocol to the parent worker:
checkpoints, secrets, configs, and approval-gate operations all flow
through the worker, where every request is permission-checked. Everything
else is closed.
The locked profile¶
| Surface | Enforcement |
|---|---|
| Network | --network=none — no egress, no DNS, no pip install |
| Filesystem | --read-only rootfs; size-capped tmpfs /tmp |
| Privileges | --cap-drop=ALL, --security-opt=no-new-privileges |
| Resources | memory / cpus / pids limits (required, never unlimited) |
| Time | wall-clock ceiling; on expiry the container is killed and the execution fails terminally for both durabilities |
| Credentials | none — the child holds no worker or fleet credentials |
The profile is emitted from code after any operator extra_args, so
docker's last-wins flag parsing keeps it authoritative; extra_args that
would re-open a closed surface are rejected at worker startup.
Enabling the runner¶
[flux.workers]
runners = ["docker-airgapped"]
airgapped_image = "<registry>/flux:<version>" # falls back to docker_image
airgapped_memory = "512m" # required non-empty
airgapped_cpus = 1.0
airgapped_pids_limit = 256
airgapped_tmp_size = "64m"
airgapped_execution_timeout = 900 # seconds; 0 disables (discouraged)
The image contract is minimal: it must run python -m flux.runners.child,
i.e. have flux-core installed at a worker-compatible version. The
official image satisfies this when its tag matches the worker (see
DOCKER.md).
Pin workflows to the sealed runner with
@workflow.with_options(runner="docker-airgapped") — this also constrains
dispatch, so the workflow only reaches workers advertising the runner:
@workflow.with_options(runner="docker-airgapped")
async def sealed_keyword_count(ctx: ExecutionContext[str]):
...
See examples/airgapped.py for runnable examples (sealed text processing
with read-only asset mounts and graceful fallback, and privacy-preserving
redaction of sensitive data).
Capability knobs¶
Capabilities are granted only through named config keys — the raw
flags (--gpus, --shm-size, and all mount flags) are rejected in
airgapped_extra_args — so a grep of flux.toml for airgapped_ is the
complete audit trail of opened surfaces:
| Key | Grants | Why it's safe to grant |
|---|---|---|
airgapped_gpus |
--gpus all / "device=0" |
a compute device; no data path out |
airgapped_mounts |
read-only bind mounts | an input channel — data can enter, results still leave only via the stdio protocol |
airgapped_shm_size |
/dev/shm sizing |
RAM allocation (large inter-process buffers); accounted next to airgapped_memory |
airgapped_service_sockets |
UDS access to local sidecars | point-to-point to loopback-bound trusted infrastructure; no network stack, no lateral channel, no egress |
airgapped_mounts entries are "/host/path:/container/path". Read-only
is forced by the runner regardless of what the entry says; rw,
relative paths, missing host paths, and duplicate targets fail worker
startup. Mounted content is readable by every airgapped workflow on the
worker — mount reference datasets and static assets, never directories
containing secrets.
Capability channels are never knobs: network, DNS, published ports,
privileges, host namespaces, and writable mounts cannot be enabled on this
runner. Operators who need them switch to the plain docker runner —
changing the runner name, and therefore the guarantee that dispatch and
the dynamic-workflows server rely on.
Service sockets — warm runtimes for sealed executions¶
A runtime whose startup is expensive (state loaded into memory over
minutes) cannot live inside a single-use container. airgapped_service_sockets
lets it live outside as a long-lived, operator-managed sidecar on the
worker host, reached over a Unix domain socket — no network stack
anywhere, point-to-point by construction:
[flux.workers]
runners = ["docker-airgapped"]
airgapped_service_sockets = { inference = "/run/flux-services/inference" }
Each value is a host directory containing the service's socket at
<dir>/service.sock; it is bind-mounted into containers at
/run/flux/services/<name>. The directory contract keeps the mount
write-proof: mode 0555 (validated at worker startup; created if
absent), sockets 0666. The mount is rw — connecting to a UDS requires
write permission on the socket inode — but --cap-drop=ALL strips
CAP_DAC_OVERRIDE, so the write-less directory binds every container
uid, root included: executions can connect, never create files. A
missing socket at startup is only a warning (the sidecar may start
later); a down sidecar surfaces to workflows as a normal connect error.
Granted services are advertised as flux.service.<name> worker labels
(the flux. label prefix is reserved — user labels can't spoof grants),
so workflows target service-bearing workers with the existing affinity
mechanism. In workflow code:
from flux.tasks import service_client
@task
async def generate(prompt: str) -> dict:
async with service_client("inference") as client: # httpx over UDS
response = await client.post("/v1/completions", json={"prompt": prompt})
return response.json()
@workflow.with_options(
runner="docker-airgapped",
affinity={"flux.service.inference": "true"},
)
async def sealed_generate(ctx: ExecutionContext[str]):
return await generate(ctx.input)
Streaming is native HTTP over the socket (SSE/chunked responses work
as-is; tee tokens into progress() for live visibility), and because
service calls live inside ordinary tasks, replay short-circuits from the
event log without re-contacting the sidecar.
The honest tradeoff, documented deliberately: socket traffic is the one channel the worker does not mediate — no per-call allowlist, audit, or rate limit (the grant granularity is the service, and the sidecar enforces its own limits). The sidecar receives data, not code, from processes with no other capabilities; run it as its own hardened unit (dedicated user, no egress, GPU pinned) — it is trusted infrastructure, like a database. Sidecar recipes (llama-server, vLLM) are in DOCKER.md.
Sizing for data- and compute-heavy workloads¶
The profile's defaults are sized for untrusted glue code. Heavier sealed workloads — numeric simulation, media processing, large dataset transforms — raise the limits and grant what they need (image recipe, sizing guidance, and cache environment variables for the read-only rootfs are documented in DOCKER.md under "Sizing the airgapped runner for heavy workloads"):
[flux.workers]
runners = ["docker-airgapped"]
airgapped_image = "my-registry/flux-compute:0.60.0"
airgapped_gpus = "all" # GPU-accelerated compute
airgapped_mounts = ["/srv/datasets:/data"] # reference data, read-only
airgapped_shm_size = "8g" # large inter-process buffers
airgapped_memory = "32g"
airgapped_tmp_size = "8g"
airgapped_execution_timeout = 3600
--network=none keeps the container's own loopback, so a workflow can
spawn helper processes and talk to them on 127.0.0.1 inside the
sandbox — nothing is reachable from outside.
Dynamic workflows run here by default¶
Workflows registered by agents through the dynamic-registration endpoint
get runner="docker-airgapped" stamped server-side
([flux.dynamic_workflows] require_runner), overriding anything the
authored source declares. The author is the adversary in that model; the
sealed runner is what makes accepting model-authored code tenable. Relax
require_runner only in development.
Failure semantics¶
- Crash (OOM kill, segfault, hard exit): durable executions are released for re-dispatch and deterministic replay resumes from the last checkpoint; transient executions fail terminally.
- Wall-clock timeout (
ExecutionTimedOut): terminal FAILED for both durabilities — a deterministic timeout would re-dispatch forever, so it is a policy violation, not a crash. - Cancellation: SIGTERM is forwarded into the container
(
docker runsig-proxying), with a grace period before the container is killed.