8.3 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 | not planned: needs writable rootfs snapshots per step and pull for FROM; own RFC when a consumer needs it |
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).