Files
werkdock/docs/rfcs/0004-docker-compatible-image-creation.md
7504b97235
werkator build succeeded after 00:06
A Dockerfile builds on the target, and two backends for one interface (0.12.0) (#14)
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
2026-10-20 15:41:48 +02:00

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:

  1. 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.
  2. 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 not planned: needs writable rootfs snapshots per step and pull for FROM; own RFC when a consumer needs it — planned after all, in RFC 0006: pull 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.24 loaded from docker save, or a Temurin tarball imported with import -c 'ENV JAVA_HOME=/usr/local/jdk-21'. There is no second artifact kind in the store, and images, inspect, rmi apply 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, GOROOT a 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-subpath and Env-merging read it later.
  • Image references are the docker ones (done), so a docker save name round-trips.

Concrete Proposal

werkdock load -i ARCHIVE

  • Accepts the archive docker save writes (both the legacy manifest.json layout and the OCI layout Docker 25+ writes, which carries manifest.json as 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 import does, then for every manifest: applies the layers in order into rootfs/ with whiteout handling, maps the config's architecture, os, and config (Env, Entrypoint, Cmd, WorkingDir, Labels) into image.json, and names the image after the manifest's RepoTags (or the OCI org.opencontainers.image.ref.name annotation); --name is 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 the zstd binary doctor already checks.
  • Ownership is dropped (--no-same-owner semantics, as import): inside the sandbox uid 0 is the calling user anyway.
  • Multi-arch OCI indexes pick the manifest matching the host (linux/amd64 on the webspaces); an archive without a matching manifest is refused with the platforms it does contain.

Later, in this order

  1. rmi IMAGE (done, 0.3.0) and tag SOURCE TARGET — the store housekeeping composition needs (a tag is a copy; hardlink dedup can follow).
  2. --mount type=image,... in run — the composition itself; small, since a store directory is already what -v binds.
  3. save -o ARCHIVE IMAGE — one-layer docker save format from rootfs + config, so a werkdock image can go back to Docker or to another webspace without the original archive.
  4. pull — RFC 0002 level 2, reusing the flattening. 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.

Consequences for Werkator

  • The build environment can become a Dockerfile: docker build, docker save, upload, werkdock load — with ENTRYPOINT, ENV, WORKDIR intact, replacing tools/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.
  • build first: the most docker-looking verb, but the least useful one here — builds want a registry for FROM and network-heavy RUN steps 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).