Skip to content

Dockerfile Consumer

CYPT71 edited this page Aug 9, 2026 · 1 revision

Dockerfile Consumer

The repository root Dockerfile is a separate, independent consumer of an OCI layout - it demonstrates turning the layout cmd/oci-builder produces into an ordinary container image, for use with any container runtime (Docker, Podman, containerd, Kubernetes). It does not build the layout itself; it expects one to already exist at ./oci-image in its build context.

flowchart LR
    subgraph S1["stage 1: verify (alpine)"]
        A["./oci-image"] --> B["marker files + blob digests checked"]
    end
    subgraph S2["stage 2: unpack (alpine)"]
        C["walk index → manifest → config → layer"] --> D["extract layer to /rootfs"]
        D --> E["symlink /rootfs/entrypoint → declared Entrypoint"]
    end
    subgraph S3["stage 3: final (scratch)"]
        F["COPY /rootfs/ /"] --> G["USER 65532:65532"]
        G --> H["ENTRYPOINT /entrypoint"]
    end
    S1 --> S2 --> S3
Loading

Alpine appears twice as a disposable tool, never as a runtime base - the FROM scratch in stage 3 means none of it ships.

The three stages

FROM alpine:3.20@sha256:... AS verify
WORKDIR /layout
COPY oci-image/ ./
RUN test -f oci-layout && test -f index.json && \
    test "$(find blobs/sha256 -type f | wc -l)" -eq 3 && \
    for blob in blobs/sha256/*; do test "$(sha256sum "$blob" | cut -d' ' -f1)" = "$(basename "$blob")"; done

Stage 1 (verify). Copies the layout in and independently re-checks it before touching anything else: the two required marker files exist, there are exactly three blobs (config, manifest, layer - see Architecture and OCI Layout), and every blob's filename actually equals sha256(its own content). If the layout was tampered with between being built and being handed to docker build, this stage fails before any of it is extracted.

FROM alpine:3.20@sha256:... AS unpack
RUN apk add --no-cache jq tar gzip
COPY --from=verify /layout/blobs/sha256 /blobs
COPY --from=verify /layout/index.json /index.json
RUN manifest=$(jq -er '...' /index.json) && ... && \
    config=$(jq -er '.config.digest | ...' "/blobs/$manifest") && ... && \
    entrypoint=$(jq -er '.config.Entrypoint | ...' "/blobs/$config") && \
    layer=$(jq -er '...' "/blobs/$manifest") && ... && \
    mkdir /rootfs && gzip -dc "/blobs/$layer" | tar -x --no-same-owner --no-same-permissions -C /rootfs && \
    ln -s "$entrypoint" /rootfs/entrypoint

Stage 2 (unpack). Walks the manifest chain itself with jq - index.json → the one manifest → the one layer (asserting at each hop that there really is exactly one entry, failing loudly via jq -er ... error(...) otherwise) - extracts the layer into /rootfs, and also reads the config to find the image's actual declared Entrypoint, symlinking a fixed /rootfs/entrypoint to it. This matters more than it looks: Dockerfile syntax has no way to compute a static ENTRYPOINT value from something discovered during the build, so without this step the final stage would have to hardcode a path - which silently breaks the moment an image declares a different entrypoint than /app/service (for example, a binary packaged with a custom -entrypoint, or a dynamically-linked legacy binary). This was a real bug, found by actually running a legacy binary through this pipeline end to end rather than assuming the happy path.

FROM scratch
COPY --from=unpack /rootfs/ /
USER 65532:65532
ENTRYPOINT ["/entrypoint"]

Stage 3 (final). FROM scratch - no base image at all, not even Alpine. Only the extracted layer content makes it into the final image; Alpine, jq, tar, gzip, and both earlier stages are discarded entirely (standard Docker multi-stage build behavior - nothing from a non-final stage ships unless explicitly COPY --from'd into the final one). USER 65532:65532 matches the layout's own OCI config; ENTRYPOINT ["/entrypoint"] is always this same literal, resolving via the symlink stage 2 created to whatever path that specific image actually declares.

Using it

# oci-image/ must already exist in the build context - e.g. downloaded
# from a release artifact, or built locally with cmd/oci-builder.
docker build -t service:local .
docker run --rm --read-only --security-opt no-new-privileges \
  --cap-drop=ALL --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --publish 8080:8080 service:local

The Dockerfile does not replace runtime --read-only/no-new-privileges settings - see Security Model for why no image config can enforce those, no matter which consumer builds it.

Why this design

  • Independent re-verification, not trust. The verify stage doesn't assume the layout it was handed is correct just because it looks like one - it's the same "treat inputs as untrusted" principle CI applies to itself (see Security Model), applied here to the build context.
  • scratch final stage. The smallest possible attack surface: no shell, no package manager, no libc unless your binary statically links one, nothing left over from the verify/unpack tooling.
  • Alpine only as a throwaway builder, never as the runtime base. This is the same pattern scripts/local/Dockerfile uses for local development (see Local Development) - pull exactly one external image, use it only to do verification/unpacking work, and never let it end up in the image you actually ship.

Debugging a container built this way

Because the final image has no shell, docker exec -it ... sh doesn't work. See Debugging Minimal Containers for how to inspect a running container without one (nsenter, and when to prefer it over the alternatives).

Clone this wiki locally