RFC 0004 had ruled `build` out as "not planned: needs writable rootfs snapshots per step and `pull` for FROM". Both halves expired: `pull` arrived in 0.3.0, and per-step snapshots are what a build *cache* needs, not what a build needs — one writable tree over the base is enough. ## RFC 0006 [docs/rfcs/0006-dockerfile-builds.md](docs/rfcs/0006-dockerfile-builds.md) decides the build model, cuts the one version-dependent interface, and argues the "no layers" question from measurements rather than from the README's shorthand. Measured on a `debian:trixie` rootfs with bwrap 0.11.1 and a `0 <uid> 1` map — every number in the RFC was taken, not reasoned about: - A writable root bind (`--bind` instead of `--ro-bind`) works unprivileged. - `CapEff` is `0000000000000000`: `mount -t tmpfs` and `mknod` are both EPERM, so the sandbox can mount nothing itself and every mount must stand in bwrap's argv. **That** is what "no layers" was ever about. - Plain `apt-get update` dies on apt's `_apt` user (`seteuid 42 failed`, `Method http has died unexpectedly`); `APT::Sandbox::User root` fixes it. - `apt-get install nginx` still fails (`chown: changing ownership of '/var/log/nginx': Invalid argument`, dpkg exit 100). `fakeroot` **alone does not help** — libfakeroot is demonstrably active and its `chown` wrapper still reports the real syscall's EINVAL. `fakeroot` plus `FAKEROOTDONTTRYCHOWN=1` does. - Overlay works unprivileged on bwrap 0.11, `--overlay-src` stacks, and the upper directory is already a layer diff: changed files as themselves, deletions as character devices 0:0 — what OCI writes as `.wh.NAME`. Two snags: the workdir's `work/` is left at `d---------`, and bwrap refuses overlay when installed setuid. - With a writable root, bwrap creates a missing bind mountpoint itself and it stays in the tree — so a build binds only `/etc/resolv.conf`, which base images already have. ## The interface (the point of the RFC) `build.Backend` / `build.Tree` / `build.Step`. The boundary is exactly "how does a writable tree over a base come into being, and how is its result harvested"; no caller ever sets a `RunSpec`'s root fields. Everything else — parser, instructions, COPY, multi-stage, CLI, uid levers, commit — is identical in both worlds. - **Copy backend** (implemented): a real full copy of the base, root bound read-write, commit is the `rename`, no cache. It stays after the Debian 13 upgrade, as the fallback `doctor` picks when bwrap is too old or setuid, and for single-shot builds. - **Overlay backend** (deliberately *not* implemented): designed, with the measurements above as the evidence that it fits. The per-step cache waits for it, because without a union mount a cached step costs a whole tree copy. The warning that belongs with this, in the RFC and as a comment in the code: `store.copyTree` hardlinks so a second tag costs no data — there both copies are read-only. For a build tree it is unusable, because a `RUN` step writing in place through such a hardlink would modify the base image. ## Stage 1, implemented `werkdock build [-f Dockerfile] -t IMAGE CONTEXT` with docker's flags. `FROM`, `RUN`, `COPY` plus the metadata instructions `import -c` already knew, through the same `store.ApplyChange`. Staging and commit reuse the mechanics `import` has had since 0.11.0 (`<image>.tmp`, the per-image `flock`, `rename`), now exported as `store.Staging`. Werkdock sets both uid levers itself so the Dockerfile stays portable; the base image needs `fakeroot` installed, and `build` says so up front when it has not. `engine.RunSpec` gained `WritableRoot`, and a writable-root sandbox gets **no** implicit tmpfs over `/tmp`, `/root`, `/run` — with one, what a `RUN` step writes there would vanish in the next step while docker keeps it in the image. Refused loudly rather than ignored: `EXPOSE`, `HEALTHCHECK`, `STOPSIGNAL`, `ONBUILD`, `MAINTAINER` at parse time; `ARG`, `SHELL`, `ADD` and multi-stage named as stages 2 and 3; BuildKit's `RUN --mount` with the CapEff measurement as the reason; and the dozen `docker build` flags werkdock cannot keep the promise of. ## Verified - `go test ./...`, `go vet ./...`, `gofmt -l .` — green and empty. - A real sandbox integration test builds `FROM`/`WORKDIR`/`COPY`/`RUN`/`CMD` and then *runs* the result; it skips without bwrap/userns like the existing ones. - The apt path, which the levers exist for, is a second integration test pointed at a Debian base (`WERKDOCK_TEST_BASE_ROOTFS`). Run here: `nginx-common` configures, `dpkg-query` in the built image reports `install ok installed`, the apt fragment is gone from the result. - End to end through the CLI: `import` a 143 MB `debian:trixie` base with fakeroot, `build -t probe:1` with `apt-get install -y nginx jq` in the Dockerfile — **6.8 s** — then `run --rm probe:1` prints what its `RUN` steps and its `COPY` produced. Follow-ups the RFC names without deciding: store dedup between image versions, keeping layer blobs as a transport and cache format (the one with the highest running cost today), `doctor IMAGE`, and `ADD` from a URL. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: claude-code <claude-code@javagil.de> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #14
8.6 KiB
RFC 0004: Docker-Compatible Image Creation
Status:
- proposed: 2026-09-05
- accepted: -
- rejected: -
Proposal: Werkdock images are created the way docker images travel, not the way docker builds them: import takes a rootfs archive, load takes a docker save or OCI image archive and flattens its layers, pull (later) fetches the same from a registry. All three end in one image model — a flat rootfs plus the execution-relevant OCI config — and a toolchain is nothing but an image of its own, mounted into the sandbox with docker's --mount type=image. That keeps RFC 0003's composition reachable without a werkdock-only surface.
Context and Problem Statement
On the run side, werkdock follows docker run --rm closely (RFC 0002 and the CHANGELOG entries that implemented it).
On the image side it matches only docker import: the rootfs archive has to be produced elsewhere, and its ENTRYPOINT/CMD/ENV metadata is lost on the way (docker export drops it) and re-typed with import -c.
Two requirements pull on the design:
- Image creation shall become docker-compatible: an image built with a Dockerfile anywhere shall land in the werkdock store with its metadata, by one command.
- Werkator shall later combine a base image with several apps — a JDK, a Go toolchain, a Node runtime, each in its own version — without one fat image per combination (RFC 0003). Nothing decided now may foreclose that.
What Docker Offers, and What Fits
Docker creates images by build (Dockerfile), commit (from a container), import (from a rootfs tar), load (from docker save), and pull (from a registry).
Only the last three describe finished images, and only they are meaningful without a build daemon:
| Verb | Input | Werkdock |
|---|---|---|
import |
flat rootfs tar (docker export output) |
done; metadata via -c, as docker |
load |
docker save archive or OCI image layout: layers + config |
this RFC |
pull |
the same from a registry over HTTP | done as a wrapper around the crane binary: crane writes a docker save tarball, load flattens it; the registry client stays replaceable behind werkdock pull (RFC 0002 level 2) |
build |
Dockerfile | pull for FROM; own RFC when a consumer needs itpull arrived in 0.3.0, and per-step snapshots turned out to be what a build cache needs, not what a build needs. One writable tree over the base is enough, and a writable root bind is measured to work unprivileged |
commit |
a running container | not planned: werkdock instances are ephemeral by design |
Flattening layers at load time is the werkdock equivalent of docker's layered store: apply each layer's tar in order, honour the OCI whiteouts (.wh.NAME deletes, .wh..wh..opq empties a directory), take config.json as the image metadata.
The store keeps no layers, no copy-on-write; the cost is disk per image (README, Disk Footprint), the gain is that bwrap needs nothing but a directory.
Composition Without Foreclosure
RFC 0003 sketched --with NAME over store artifacts called toolchains.
Docker has since gained the same thing under its own name: --mount type=image,source=IMAGE,destination=/prefix[,image-subpath=/sub][,readonly] (Docker Engine 28) binds an image's filesystem — or a subtree of it — into a container.
That is exactly a read-only bind of an unpacked store directory, which bwrap has always been able to do.
So the composition model becomes:
-
A toolchain is an image:
golang:1.24loaded fromdocker save, or a Temurin tarball imported withimport -c 'ENV JAVA_HOME=/usr/local/jdk-21'. There is no second artifact kind in the store, andimages,inspect,rmiapply to toolchains unchanged. -
A build composes at run time:
werkdock run --rm \ --mount type=image,src=golang:1.24,dst=/usr/local/go,image-subpath=/usr/local/go \ --mount type=image,src=eclipse-temurin:21,dst=/opt/java/openjdk,image-subpath=/opt/java/openjdk \ -e PATH=/usr/local/go/bin:/opt/java/openjdk/bin:/usr/bin:/bin \ base:trixie sh -c './gradlew build'The same line works against Docker 28, which is the compatibility goal stated in the README.
-
Environment merging (the
PATH,JAVA_HOME,GOROOTa toolchain needs) is where docker stops: an image mount carries no Env. Werkator, which knows what it mounts, passes-e; a later werkdock convenience (--with IMAGE= image mount at the image's own prefix plus its Env) can be added on top without touching the docker-compatible core.
What this RFC therefore requires of the image model, and what load must respect:
- An image stays a flat directory tree; nothing in the store may assume it is only ever mounted at
/. - The OCI config is stored with every image, whichever verb created it;
image-subpathand Env-merging read it later. - Image references are the docker ones (done), so a
docker savename round-trips.
Concrete Proposal
werkdock load -i ARCHIVE
- Accepts the archive
docker savewrites (both the legacymanifest.jsonlayout and the OCI layout Docker 25+ writes, which carriesmanifest.jsonas well) and a plain OCI image layout (oci-layout+index.json, as skopeo or buildah write it). - Unpacks the outer archive into the staging directory the way
importdoes, then for every manifest: applies the layers in order intorootfs/with whiteout handling, maps the config'sarchitecture,os, andconfig(Env, Entrypoint, Cmd, WorkingDir, Labels) intoimage.json, and names the image after the manifest'sRepoTags(or the OCIorg.opencontainers.image.ref.nameannotation);--nameis not needed, the archive knows its name. - Layer extraction is werkdock's own (Go
archive/tar), not the tar CLI: whiteouts, a file replacing a directory of a lower layer, and paths that pass through a lower layer's symlink (/bin → usr/bin) must be resolved inside the rootfs, never on the host. gzip layers are handled in-process, zstd layers through thezstdbinary doctor already checks. - Ownership is dropped (
--no-same-ownersemantics, asimport): inside the sandbox uid 0 is the calling user anyway. - Multi-arch OCI indexes pick the manifest matching the host (
linux/amd64on the webspaces); an archive without a matching manifest is refused with the platforms it does contain.
Later, in this order
rmi IMAGE(done, 0.3.0) andtag SOURCE TARGET— the store housekeeping composition needs (a tag is a copy; hardlink dedup can follow).--mount type=image,...inrun— the composition itself; small, since a store directory is already what-vbinds.save -o ARCHIVE IMAGE— one-layerdocker saveformat from rootfs + config, so a werkdock image can go back to Docker or to another webspace without the original archive.Done (0.3.0), through crane rather than an in-process registry client: the managed webspaces have no package for crane, but it is one static binary shipped next to werkdock, and the token dance, manifest lists, and digest checks it brings would otherwise be a week of stdlib code. An in-process client can replace it behind the same verb once the second binary proves a burden in operation.pull— RFC 0002 level 2, reusing the flattening.
Consequences for Werkator
- The build environment can become a Dockerfile:
docker build,docker save, upload,werkdock load— with ENTRYPOINT, ENV, WORKDIR intact, replacingtools/build-bwrap-rootfs.sh's debootstrap-in-Docker dance. - The apps Werkator wants to combine are docker images (
golang:1.24,eclipse-temurin:21,node:20), loaded once each and mounted per build; the base shrinks to what apt must provide (RFC 0003).
Considered Alternatives
- A separate toolchain artifact kind with its own manifest (RFC 0003 sketch): one more store concept, one more verb, and a werkdock-only surface — rejected in favour of "a toolchain is an image", now that docker itself mounts images.
- Layered store with overlayfs: the general solution, blocked on bwrap 0.9+ on the target (README); flattening at load time is the cheap path that needs no kernel feature, and dedup can be added later without changing the CLI.
buildfirst: the most docker-looking verb, but the least useful one here — builds want a registry for FROM and network-heavyRUNsteps on a shared webspace, which step 21 deliberately moved off the target.
Decision Outcome
Pending — load and pull are implemented as the first steps under this RFC; the composition verbs follow when Werkator's second toolchain combination comes due (the RFC 0003 trigger).