Docker¶
The docker.* family runs containers as pipeline steps. It is four blocks:
docker.runruns one container and lets the engine probe it to completion. It speaks the Docker Engine HTTP API over the daemon socket.docker.compose.upanddocker.compose.downrun a whole stack from a compose file. They shell out to thedocker composeCLI -- only the CLI can orchestrate compose -- and read the stack's state back over the Engine API.docker.buildbuilds an image from a context with buildx, and pushes it where a connection holds a registry credential.
Each block's fields are the generated block reference; this page is the family:
what they share, how the daemon is reached, and the compose lifecycle that ties up, a drive
step, and down into one run.
These are unsafe blocks¶
Every block here declares local_execution, which means the engine refuses to run it unless
the instance names its id in DIRIGENT_ENABLED_UNSAFE_BLOCKS. That gate exists because
reaching a Docker daemon is reaching root wherever that daemon runs: anything that can talk to
a daemon can start a privileged container on its host. "Can edit pipelines" must never silently
mean "can run containers on the worker's host".
Enable them per instance, and only for a worker you trust with what its daemon controls:
dg run --local examples/docker/docker-compose-stack.yaml \
--enable-unsafe docker.compose.up,docker.compose.down,docker.run
export DIRIGENT_ENABLED_UNSAFE_BLOCKS='["docker.compose.up","docker.compose.down","docker.run"]'
The daemon these blocks reach¶
A block talks to whatever daemon DOCKER_HOST names, reading it and the TLS variables beside
it the way the docker CLI does:
DOCKER_HOST |
Reached as |
|---|---|
| unset | the local unix socket /var/run/docker.sock |
unix:///path/to.sock |
that unix socket |
tcp://host:port |
http://host:port |
tcp://host:port with DOCKER_TLS_VERIFY=1 and DOCKER_CERT_PATH |
https://host:port with the client ca.pem/cert.pem/key.pem |
any other scheme, ssh:// included |
refused: the daemon is reached over HTTP here, and nothing tunnels one |
The reference shape: a dind sidecar¶
infra/compose.yaml gives the worker its own daemon. A docker:dind sidecar serves
tcp://docker:2376 with TLS required, generating its CA, server and client certificates into
DOCKER_TLS_CERTDIR=/certs; the worker mounts the client half read-only and carries three
variables, which nothing else in the stack does:
A pipeline's containers are then the sidecar's, and no host socket is mounted anywhere. The API
blocks read DOCKER_HOST through the resolution above; the CLI blocks pass these three to the
docker binary they run, so env_allowlist never has to name them and a document cannot point
a step at another daemon.
The sidecar runs privileged: true, which docker-in-docker requires -- it has no unprivileged
mode that starts containers. It keeps /var/lib/docker on a named volume, so an image pulled
once survives a restart.
One constraint the sidecar imposes: a bind mount is resolved on the daemon's filesystem,
not the worker's. docker.run mounts inputs and outputs from the run's work directory, and
hands the daemon those paths, so the sidecar mounts the same work volume at the same
/var/lib/dirigent/work. Point a worker at a daemon that cannot see that path and every step
with inputs or outputs mounts an empty directory.
Mounting the host's /var/run/docker.sock into a containerized worker is the discouraged
fallback: it works, but it grants that container root on the host. See
operations.
The worker image (infra/Dockerfile) always ships the docker CLI with the compose and buildx
plugins, so the CLI blocks have something to run. A step's HOME is its own work directory, so
the CLI blocks write the run a docker config that names the worker's own plugin directory:
buildx and compose resolve wherever Docker Desktop or a package put them.
The docker connection kind¶
A step that names no connection reaches the daemon the worker's own environment names, which is
the table above and nothing else. A docker connection is how a document names one instead,
and how a build reaches a registry:
| Field | What it holds |
|---|---|
host |
The daemon, in one of the two schemes the worker speaks: tcp://host:2376, with the TLS triple below, or unix:///var/run/docker.sock. Empty leaves the worker's own environment standing. |
tls_ca, tls_cert, tls_key |
The client TLS triple for a tcp:// daemon, in PEM form. All three or none. |
registry |
The registry a push authenticates to, such as ghcr.io. Empty means Docker Hub. |
username, password |
The registry credential, together or not at all. |
A connection names a daemon, a registry credential, or both, and one that names neither is
refused. dg connection check proves whichever it holds: docker version against the daemon,
and a docker login followed straight by a docker logout against the registry, in a config
directory of its own so nothing about it persists.
dg connection create docker build-daemon \
--set host=tcp://docker:2376 \
--set "tls_ca=$(cat /certs/client/ca.pem)" \
--set "tls_cert=$(cat /certs/client/cert.pem)" \
--set "tls_key=$(cat /certs/client/key.pem)"
dg connection check build-daemon
tls_key and password are sealed: encrypted at rest, redacted in every API response, and
never an argument or an inherited variable. Each reaches the CLI and the Engine API as a 0600
file in a 0700 directory under the run's work directory -- the TLS triple as DOCKER_CERT_PATH,
the login as a DOCKER_CONFIG of its own -- and both directories are removed when the step
leaves, on success, on failure and on cancellation.
Every step of one stack names the same connection. A stack exists only on the daemon that
was told to create it, so a docker.compose.up on one daemon and a docker.compose.down on
another tear down nothing. The up, the docker.run that drives it and the down all carry
the same connection:, and
examples/docker/docker-remote-daemon.yaml
is that document.
A connection names the daemon; it does not name the worker. A compose file and a build context
still live on the worker's own filesystem, so a pipeline that names one keeps
requires.workers: [docker] beside it exactly as one that does not.
Files live in the run's work directory¶
A compose file, a build context and a bind mount are things a tool opens through the filesystem, so they live in the run's work directory on the worker's own disk -- whatever the artifact root is. Within it:
- a compose file is either inline
contentor a path relative to that directory (one an upstreamgit.checkoutput there). Inline content is written undercompose/<step>[/<item>]/attempt-<n>, so two compose steps of one run, and two items of one fan-out, never write over each other's document; - a build context is a relative directory, with its Dockerfile relative to that.
docker.run's outputs puts a file in either place. Each entry maps a name the container
wrote in its output mount to a target: a target carrying a URI scheme is copied to storage,
where it outlives the run, and a target without one is a path relative to the work directory,
which is how one step produces the compose file or the build context the next step opens.
inputs is always storage: a URI read into the read-only mount.
On the compose stack the daemon mounts that directory at the same path the worker sees it at, because a bind is resolved on the daemon's filesystem rather than the worker's. It is local to one worker, so a step that reads what another step put there must run on the same worker.
The compose blocks are written for compose files that reference pre-built images. A
compose file with build: stanzas needs its build contexts already in the work directory --
getting them there is the pipeline's job, through a git.checkout or a docker.run output.
The compose lifecycle¶
This is the pattern the compose blocks are shaped around, and the reason up and down are
separate steps rather than one self-contained block:
upbrings the stack up detached, and it persists. The step returns once the stack is up (withwait: true, once every service is healthy) and reports the project's services and the network they share. The containers keep running for the rest of the run.- A later step drives it. A
docker.runstep joins the compose network by name -- the up step reports it asdefault_network(<project>_default) -- and reaches the services on it. downtears it down, even on failure. A separatedocker.compose.downstep withrule: all_doneruns whether the drive step passed or failed, so a stack is never left running. The engine already evaluatesall_done; no "finally" primitive is needed.
Both blocks default their project name deterministically from the run id, so the down
addresses the exact project the up created with nothing wired between them, and two runs
never collide. On a bring-up that does not succeed -- a non-zero exit, a bring-up that ran
past its deadline, or a step that was cancelled -- the block itself best-effort tears the
half-built project down (cleanup: true) before leaving, so containers compose had already
started are never orphaned. A successful up is never torn down that way: it persists for the
rest of the run by design.
steps:
stack:
block: docker.compose.up
config:
wait: true
content: |
services:
api:
image: nginx:alpine
drive:
block: docker.run
depends_on: [stack]
config:
image: alpine:3
network: "${steps.stack.output.default_network}"
command: wget -qO- http://api/
teardown:
block: docker.compose.down
depends_on: [drive]
rule: all_done
config:
down_volumes: true
The full example is
examples/docker/docker-compose-stack.yaml.
Building an image¶
docker.build shells out to docker buildx build -- BuildKit, its cache and multi-stage
builds come for free -- and reads the built image's id from an --iidfile rather than scraping
the log. The image lands in the worker's own daemon store, so a later docker.run or
docker.compose.up step on the same worker references it by tag.
Pushing¶
push: true sends every tag the build produced to the registry a docker connection names.
It needs that connection: push with no connection is refused when the document is applied,
and a connection with no username and password is refused when the step starts. Neither is
a build that quietly produced a local image nobody can reach.
build:
block: docker.build
config:
context: context
tags: ["ghcr.io/owner/app:1.2.3"]
connection: ghcr
push: true
The step logs in against a DOCKER_CONFIG directory of its own under the run's work
directory, with the
password on stdin rather than in an argument, pushes each tag with docker push, and logs out
again; the directory goes with the step. The worker's own docker config is never written to and
no session survives the step. The output carries pushed, the tags that reached the registry,
and digests, the digest the registry gave each of them where the CLI reported one -- a tag can
be moved and a digest cannot, so the digest is the only exact name for what was pushed. The
password is scrubbed out of every log line and every failure message.
examples/docker/docker-build-push.yaml
is the worked document.
The orphan reaper¶
docker.compose.up names its project dirigent-<run id> and compose labels every container of
it com.docker.compose.project, so a stack is addressable by the run that created it long after
that run has gone. Usually nothing is left: a down step with rule: all_done runs on any
outcome, and a failed up tears its own half-built project down. What is left over is what none
of that reached -- a worker that died mid-run, a pipeline cancelled between up and down, a
down step that never ran.
A worker sweeps for those on a cadence. Each pass lists the compose projects on its daemon, reads the run id out of each project's name, and looks that run up:
- a run that is still active is never touched, whatever else is true;
- a run that is terminal, and a run the instance no longer holds, are both orphans;
- a project younger than the grace is left alone whatever its run says, because a stack whose
uphas only just returned is a stack whosedownhas not been reached yet. A project's age is its newest container's.
Each orphan is taken down with docker compose -p <project> down -v --remove-orphans, and each
one is one record: kind: docker_reaped, carrying the project, the run id and the run's status,
so dg worker shows what went. A project this instance did not create -- anything
whose name is not dirigent-<32 hex digits> -- is never listed as a candidate at all.
Two settings govern it:
| Setting | Default | What it does |
|---|---|---|
docker_reap_interval |
5m |
How often a pass runs. Zero turns the reaper off. |
docker_reap_grace |
10m |
How old a project must be before a pass will consider it. |
A worker with no docker CLI on its PATH runs no pass at all, rather than one that fails every five minutes.
The same pass runs on demand:
--dry-run writes the same records and takes nothing down.
The reaper only sees the daemon the worker sees. With one dind sidecar per worker that is exactly the stacks that worker created, which is the deployment this is written for. A worker pointed at a shared daemon sees every dirigent project on it, including projects other workers created -- which is correct, since the run lookup is the instance's own, but it means two instances must not share one daemon.
One daemon per pipeline¶
A stack brought up on worker A, or an image built there, is invisible to worker B and its
daemon. Give the docker-capable workers a docker tag (dg worker --tag docker, or
DIRIGENT_WORKER_TAGS=docker) and declare requires.workers: [docker] on the pipeline, so
up, the steps that drive it, down, and any build all meet the same daemon.