Skip to content

Architecture and OCI Layout

CYPT71 edited this page Aug 9, 2026 · 1 revision

Architecture and OCI Layout

What the builder does

cmd/oci-builder (backed by internal/oci) does exactly one thing: turn an already-compiled Linux executable, plus whatever -extra-files it needs, into an OCI Image Layout.

Multi-manifest composition

Each application/platform is built and verified independently. The composition step then creates a new layout without rewriting any config, manifest, or layer:

secure-oci compose --output catalog \
  api-amd64 api-arm64 worker-amd64
# equivalent low-level command:
oci-builder compose -output catalog \
  api-amd64 api-arm64 worker-amd64

The resulting index.json may contain:

  • the same image/tag for linux/amd64 and linux/arm64;
  • several tags referencing identical content-addressed blobs;
  • several applications and therefore several manifests for one platform.

Descriptors are sorted by image reference, platform, and digest for deterministic output. Every input is strictly verified, blobs are copied once per digest with a fixed 1 MiB buffer, and the combined layout is verified again before an atomic rename installs it. A duplicate image-reference and platform pair is rejected because a consumer could not select it unambiguously.

Composition does not emulate an OCI registry namespace. The org.opencontainers.image.ref.name descriptor annotation carries the local image/tag selection metadata. Consumers that only support a single manifest must select or export one descriptor; registry-aware tools such as Skopeo can copy the full multi-manifest layout. The pipeline, in order:

  1. Validate the input. Options.Binary must be a regular, executable file. Options.Output must not already exist — the builder never overwrites anything, to prevent an attacker-controlled directory from silently replacing a previous, trusted layout.
  2. Build the layer. The binary, every extra file, and a fixed set of directories are packed into a tar stream with fixed metadata (see below), then gzip-compressed at best-compression level.
  3. Build the config. A JSON OCI image config: architecture, OS, the single layer's diff_id (the uncompressed layer's SHA-256), User, Entrypoint, and any labels.
  4. Build the manifest and index. Standard OCI schema-2 documents referencing the config and layer blobs by digest.
  5. Write atomically. Everything is written to a fresh temporary directory (os.MkdirTemp next to the destination) and only os.Renamed into place at the very end — a crash or interrupt mid-build never leaves a partial, half-written layout at the destination path.
flowchart TD
    A["Options.Binary + ExtraFiles"] --> V{"regular file?<br/>executable?<br/>Output doesn't exist?"}
    V -- no --> E["error - nothing written"]
    V -- yes --> L["tar + gzip the layer<br/>(fixed mtimes, sorted entries)"]
    L --> C["OCI config JSON<br/>(User, Entrypoint, Labels, diff_id)"]
    C --> M["manifest.json"]
    M --> I["index.json"]
    L & C & M & I --> T["write to a temp dir"]
    T --> R["os.Rename into place"]
    R --> D["digest returned"]
Loading

Layout shape

oci-image/
  oci-layout                 # {"imageLayoutVersion":"1.0.0"}
  index.json                 # platform descriptor + image reference annotation
  blobs/sha256/
    <config digest>
    <manifest digest>
    <compressed layer digest>

Exactly one manifest, one layer, one config — always. The builder has no notion of multi-layer images; that's a deliberate simplification, not a missing feature (see Project scope).

What's inside the layer

Path Mode Purpose
/app/service (configurable via -entrypoint) 0555 the binary itself
every -extra-file destination, if any were given 0555 e.g. a shared library or the ELF interpreter - see CLI Reference
/etc/ssl/certs/ 0755 mount point for a CA bundle at runtime (nothing is placed here at build time)
/tmp/ 01777 (sticky) scratch space
/var/tmp/ 01777 (sticky) scratch space
every parent directory of the entrypoint and of every extra file 0755 e.g. /app/, /lib/x86_64-linux-gnu/

Nothing else beyond what you explicitly asked for - the filesystem equivalent of packing light. No shell, no package manager, no source tree, no .git, no build cache - and no libc either, unless the binary needs one and you supplied it as an -extra-file.

The OCI config

  • architecture: amd64 or arm64 (validated — no other value is accepted).
  • os: always linux.
  • config.User: always "65532:65532" — a fixed, high, conventionally "nobody"-range non-root UID/GID (the same one distroless images use).
  • config.Entrypoint: a one-element array, the absolute path passed via -entrypoint (default /app/service).
  • config.Labels: whatever -label key=value pairs were passed, sorted and deduplication-checked by LabelsFromPairs (see CLI Reference).
  • rootfs.diff_ids: the single layer's uncompressed SHA-256.

Determinism, precisely

Given the same input binary bytes and the same CLI options, two separate builds — on different machines, at different times — produce byte-for-byte identical tar, gzip, and JSON output. This isn't a general claim about "reproducible builds" in the abstract; it's a specific, testable property enforced by:

  • Fixed tar timestamps. Every tar header's ModTime is time.Unix(0, 0), never the build machine's clock.
  • Fixed gzip metadata. The gzip header's ModTime is time.Unix(0, 0) and OS is fixed at 255 (unknown), so gzip doesn't leak the build host's OS byte.
  • Fixed created field. The OCI config's created timestamp defaults to 1970-01-01T00:00:00Z unless explicitly overridden with -created.
  • Sorted, deduplicated directory/label ordering. Both the layer's directory list and the label map are sorted before writing, so map iteration order (which Go deliberately randomizes) never leaks into the output.

ci-reproducibility.yml (see Testing and CI/CD) proves this empirically on every push: it builds the executable and layout twice, in two separate isolated jobs, and does a byte-for-byte cmp.

Why this separation of concerns

The builder never decides how its output will be run. That's intentional: internal/oci and cmd/oci-builder produce one artifact — the layout — and three completely independent consumers exist on top of it:

  • Dockerfile — turns a layout into a container image, for normal container-runtime use.
  • scripts/microvm — boots a layout directly under QEMU/KVM, for maximum isolation or for binaries that can't be containerized.
  • ci-release.yml — turns a layout into a signed, published GHCR image (see Testing and CI/CD).

None of these consumers can influence what the builder produces, and the builder has zero knowledge of any of them. That's what makes "build once, run it three different ways" possible without three different build pipelines.

Clone this wiki locally