mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-08 07:44:57 +00:00
Add design doc for docker-less, layer-aware Python image builder
Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
42fd405534
commit
d5dbc7993e
1 changed files with 289 additions and 0 deletions
289
py-image-builder/DESIGN.md
Normal file
289
py-image-builder/DESIGN.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# `py-image-builder` — a docker-less, layer-aware Python image builder
|
||||
|
||||
> Status: **Design / plan** (no code yet). This document proposes the architecture
|
||||
> and an incremental implementation plan. Comments and pushback welcome.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Build OCI container images for Python applications **without a Docker daemon**, in a
|
||||
way that is fast and cheap because it exploits content-addressed layering:
|
||||
|
||||
- **Wheel dependencies are split into reusable layers.** Once a layer for a given
|
||||
wheel exists, builds reuse it **without downloading the wheel or uploading the
|
||||
layer bytes** again.
|
||||
- **Only genuinely new dependencies cause layer changes.** Bumping app code, or
|
||||
rebuilding an unchanged dependency set, changes *no* dependency layers.
|
||||
- **Application code is a thin layer on top** of the dependency layers, so the
|
||||
common edit-rebuild loop touches only one small layer.
|
||||
- Builds are **reproducible**: same lock + same source + same base ⇒ same image
|
||||
digest, which lets us skip work entirely when nothing changed.
|
||||
|
||||
### Non-goals (initially)
|
||||
|
||||
- Compiling C extensions / building wheels from sdists. We consume **pre-built
|
||||
wheels** only. (sdist→wheel is a future extension; it would run a build, then
|
||||
the resulting wheel re-enters the normal layer path.)
|
||||
- Replacing dependency *resolution*. We delegate resolution to existing,
|
||||
correct tools (`uv` / `pip`) and consume their lockfile output.
|
||||
- A general-purpose Dockerfile interpreter. This is a focused Python app builder
|
||||
(think `ko`, but for Python wheels).
|
||||
|
||||
## 2. Why this works (the core idea)
|
||||
|
||||
Three well-understood registry/OCI facts combine to make "no-bytes" rebuilds possible:
|
||||
|
||||
1. **Layers are content-addressed.** A layer blob is referenced by the digest of
|
||||
its (gzip-compressed) tar. Two builds that produce byte-identical layers
|
||||
produce the same digest, and a registry stores it once.
|
||||
2. **Registries support blob existence checks and cross-repo mounts.** Before
|
||||
uploading, we `HEAD /v2/<repo>/blobs/<digest>`. If the blob already exists in
|
||||
the target repo, we upload nothing. If it exists elsewhere in the same
|
||||
registry, we `POST .../blobs/uploads/?mount=<digest>&from=<srcRepo>` to mount
|
||||
it — **zero bytes transferred**.
|
||||
3. **A manifest is just JSON referencing blob digests.** If every layer + config
|
||||
blob already exists, a "build" is reduced to `PUT manifest` (a few KB).
|
||||
|
||||
So if we make each wheel produce a **deterministic** layer and we **cache the
|
||||
mapping `wheel-sha256 → {diffID, blob digest, size}`**, then for a dependency we
|
||||
have seen before we never touch the wheel bytes: we already know its layer digest,
|
||||
we confirm the blob is in the registry, and we reference it. Adding a brand-new
|
||||
dependency is the only thing that forces a download + layer build + one upload.
|
||||
|
||||
This is the same family of tricks behind `crane`/`go-containerregistry` blob
|
||||
mounting, `nixery`'s per-package layering, and Bazel `rules_oci` `py_image`'s
|
||||
deps-vs-app split — specialized here for the Python wheel ecosystem.
|
||||
|
||||
## 3. Layering strategy
|
||||
|
||||
### 3.1 One deterministic layer per wheel (default)
|
||||
|
||||
Each resolved distribution (`name==version`, for a specific
|
||||
platform/abi/python tag) becomes **its own layer** whose tar contains exactly the
|
||||
files that installing that wheel writes into the environment's `site-packages`
|
||||
(plus its `*.dist-info` and any `bin/` console scripts).
|
||||
|
||||
Properties this gives us, matching the requirements:
|
||||
|
||||
- Adding a dependency ⇒ exactly one new layer; all existing layers keep the same
|
||||
digest ⇒ already present in registry ⇒ mounted/skipped.
|
||||
- Removing a dependency ⇒ that layer is dropped from the manifest; no other layer
|
||||
changes.
|
||||
- Bumping a dependency version ⇒ old layer dropped, one new layer added.
|
||||
|
||||
Layer **order** in the manifest is sorted deterministically (by distribution name,
|
||||
then version) so the assembled image/config is itself reproducible and cacheable.
|
||||
The app code layer is always last (top).
|
||||
|
||||
### 3.2 Determinism requirements (critical)
|
||||
|
||||
A wheel must always produce a byte-identical layer, or the cache and reuse break.
|
||||
"Installing" a wheel is essentially: unzip it, lay files under a fixed
|
||||
`site-packages` prefix, generate `RECORD`, and synthesize `console_scripts`. We do
|
||||
this ourselves (in Go) to control every byte:
|
||||
|
||||
- **tar normalization:** fixed `mtime` (epoch 0 or a fixed `SOURCE_DATE_EPOCH`),
|
||||
`uid=gid=0`, fixed mode bits (`0644` files / `0755` dirs / preserve exec bit on
|
||||
scripts), no device/xattr/PAX-extra records, **entries emitted in sorted order**,
|
||||
consistent name prefixes, no `__pycache__`/`.pyc` (compiled at runtime or in a
|
||||
separate optional step).
|
||||
- **deterministic gzip:** pin compression level and strip the gzip header mtime.
|
||||
Better still, **cache the compressed blob** keyed by content so we never
|
||||
recompress (avoids depending on the compressor being bit-stable across versions).
|
||||
- **fixed install layout:** a single prefix shared by all wheels, e.g.
|
||||
`/app/.venv/lib/python<X.Y>/site-packages` with scripts in `/app/.venv/bin`. The
|
||||
base image sets `PATH`/`VIRTUAL_ENV` (or we drop a `.pth`) so the interpreter
|
||||
finds them.
|
||||
- **disjoint file sets:** distributions own mostly-disjoint files; tar handles
|
||||
repeated *directory* headers fine. Namespace packages (pkgutil/PEP-420) share a
|
||||
dir but distinct files. We generate each package's `RECORD` deterministically and
|
||||
set `INSTALLER` to a constant. Edge cases (data files, `*.pth` from a package)
|
||||
are captured inside that package's own layer.
|
||||
|
||||
The cache key for a layer is:
|
||||
`(wheel-sha256, target-os/arch, python-tag/abi-tag, install-layout-version)`.
|
||||
Pure-python wheels (`py3-none-any`) are platform-independent ⇒ one layer shared
|
||||
across all arches.
|
||||
|
||||
### 3.3 Layer-count trade-off (configurable)
|
||||
|
||||
Per-wheel layering maximizes reuse but a large dependency tree can exceed practical
|
||||
limits (registries/runtimes tolerate many layers, but ~100+ tiny blobs slow pulls
|
||||
and some tooling caps near 127). Strategies, selectable via flag:
|
||||
|
||||
- `per-wheel` (default): best reuse, one layer per dist.
|
||||
- `hybrid`: large/heavily-shared wheels get their own layer; the long tail of tiny
|
||||
wheels is **bin-packed by a stable partition** (e.g. hash of name into K buckets)
|
||||
so adding a dep usually perturbs only one bucket layer. Bounded layer count,
|
||||
slightly weaker "only-new-dep" guarantee for bucketed deps.
|
||||
- `single-deps-layer`: all deps in one layer + app layer. Simplest, weakest reuse
|
||||
(any dep change rebuilds the whole deps layer). Useful for tiny apps.
|
||||
|
||||
## 4. The "no-bytes" build flow
|
||||
|
||||
```
|
||||
lockfile + source + base-ref
|
||||
│
|
||||
▼
|
||||
1. Ingest lock ─► [{name, version, wheel-url, sha256, tags}, ...]
|
||||
│
|
||||
▼
|
||||
2. For each dep:
|
||||
key = sha256 (+ platform/py tags)
|
||||
├─ layer meta in cache? ── no ─► download wheel ─► build layer
|
||||
│ │ yes │ (deterministic)
|
||||
│ ▼ ▼
|
||||
│ know {blob digest, diffID} cache meta + blob
|
||||
▼
|
||||
3. For each layer blob:
|
||||
HEAD /v2/<repo>/blobs/<digest>
|
||||
├─ exists in target repo ─► reference only (0 bytes)
|
||||
├─ exists elsewhere in reg. ─► cross-repo mount (0 bytes)
|
||||
└─ missing ─► upload once
|
||||
│
|
||||
▼
|
||||
4. App layer: deterministic tar of source (respect ignore file, drop __pycache__),
|
||||
content-addressed → same HEAD/mount/upload logic.
|
||||
│
|
||||
▼
|
||||
5. Assemble: base (by digest) + sorted dep layers + app layer.
|
||||
Build config (entrypoint, env: PATH/VIRTUAL_ENV/PYTHONPATH, workdir, user,
|
||||
labels), compute manifest digest.
|
||||
│
|
||||
▼
|
||||
6. If multi-arch: repeat per arch, assemble an image index.
|
||||
│
|
||||
▼
|
||||
7. If manifest digest already tagged ─► done (no upload at all).
|
||||
Else PUT config (if missing) + PUT manifest/index. ← only small JSON moves
|
||||
```
|
||||
|
||||
For an unchanged dependency set, steps 2–3 transfer **zero** dependency bytes; only
|
||||
the app layer (if source changed) and the manifest move.
|
||||
|
||||
### 4.1 Caches
|
||||
|
||||
- **Local cache** (always): content-addressed dirs for wheels and built layer
|
||||
blobs + a small metadata DB (`wheel-sha256 → layer meta`). Lets repeated local
|
||||
builds skip downloads and recompression.
|
||||
- **Remote shared cache** (optional): the **target registry itself** is the cache
|
||||
for blobs — `HEAD` is the lookup. Optionally a side bucket (GCS/S3) for layer
|
||||
metadata so CI runners share the `sha256 → digest` map without each re-deriving
|
||||
it. This is what enables a cold runner to build with no wheel downloads.
|
||||
|
||||
## 5. Dependency resolution / lock ingestion
|
||||
|
||||
We do **not** resolve; we ingest a fully-pinned, hashed lock so every wheel is
|
||||
identified by URL + sha256. Supported inputs (pluggable parsers):
|
||||
|
||||
- `requirements.txt` with `--hash=sha256:...` (from `pip-compile`/`uv pip compile`).
|
||||
- `uv.lock`.
|
||||
- `poetry.lock` / PDM lock.
|
||||
- `pip install --report <json>` output (rich, includes resolved wheel URLs).
|
||||
|
||||
If only loose requirements are provided, we can **shell out** to `uv pip compile`
|
||||
to produce a hashed lock first (clearly an online step, separate from the
|
||||
no-bytes build path). The lock is the contract that makes builds reproducible.
|
||||
|
||||
## 6. Base image handling
|
||||
|
||||
- Reference a base by **digest** (e.g. a distroless or Chainguard `python` image).
|
||||
We fetch only its *manifest + config* (small) — base layer blobs are referenced
|
||||
by digest and **never pulled**.
|
||||
- We append our layers on top and rewrite the config: set
|
||||
`Env` (`PATH`, `VIRTUAL_ENV`, optional `PYTHONPATH`), `WorkingDir`, `User`
|
||||
(non-root by default), `Entrypoint`/`Cmd`, and `Labels`/annotations.
|
||||
- The base must provide a compatible CPython (matching the wheels' python/abi
|
||||
tags). We validate the base's interpreter version against the lock's tags and
|
||||
fail fast on mismatch.
|
||||
|
||||
## 7. Reproducibility, SBOM, provenance
|
||||
|
||||
- Deterministic everything ⇒ identical inputs yield an identical image digest.
|
||||
We expose `--dry-run`/`--print-digest` to compute the would-be digest offline.
|
||||
- **SBOM**: we already hold every wheel's name/version/sha256, so we emit an
|
||||
SBOM (SPDX/CycloneDX) and attach it. (Natural fit for this repo's Chainguard
|
||||
leanings.)
|
||||
- Optional signing/attestation (`cosign`-style) and SLSA provenance as a later
|
||||
add-on.
|
||||
|
||||
## 8. Multi-arch
|
||||
|
||||
Wheels are tagged by platform/abi (manylinux, macОS, etc.). For multi-arch images
|
||||
we resolve a wheel set **per target platform**, build per-arch images (sharing all
|
||||
pure-python layers), and publish an **image index** referencing each arch manifest.
|
||||
|
||||
## 9. Surface area & tech choices
|
||||
|
||||
- **Language: Go**, consistent with the rest of this monorepo, using
|
||||
[`go-containerregistry`](https://github.com/google/go-containerregistry) (ggcr)
|
||||
for all registry I/O, layer/image types, blob mounting, and auth (keychain). No
|
||||
Docker daemon, no shelling out to `crane`.
|
||||
- Wheel "install" implemented in Go: parse the wheel zip (it is a zip with
|
||||
`*.dist-info/{RECORD,WHEEL,METADATA,entry_points.txt}`), lay files into the
|
||||
staging prefix, synthesize console scripts, write deterministic tar.
|
||||
- **CLI** first:
|
||||
```
|
||||
py-image-builder build \
|
||||
--base cgr.dev/chainguard/python@sha256:... \
|
||||
--lock requirements.txt \
|
||||
--source ./ \
|
||||
--entrypoint "python -m myapp" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.example.com/me/myapp:latest
|
||||
```
|
||||
- Later, a thin **server** (Cloud Run, matching the pattern in `image-workflow`,
|
||||
`iap`, `litestream`) that builds on push/dispatch, plus its own `main.tf`.
|
||||
- New self-contained module dir `py-image-builder/` with its own `go.mod`,
|
||||
`README.md`, and (eventually) `main.tf`, mirroring repo conventions.
|
||||
|
||||
## 10. Incremental implementation plan
|
||||
|
||||
Each phase is independently useful and reviewable.
|
||||
|
||||
1. **Skeleton + deterministic tar.** New `py-image-builder/` Go module; a function
|
||||
that writes a normalized, reproducible tar and a round-trip determinism test
|
||||
(same input ⇒ same bytes ⇒ same digest).
|
||||
2. **Wheel → layer.** Parse a wheel zip, lay out files into the fixed prefix,
|
||||
generate `RECORD`/console scripts, produce a ggcr `v1.Layer`. Golden-file tests
|
||||
asserting stable diffIDs for fixture wheels (one pure-python, one manylinux).
|
||||
3. **Lock ingestion.** Parser(s) for hashed `requirements.txt` (first), normalized
|
||||
to `{name, version, url, sha256, tags}`. Add `uv.lock` next.
|
||||
4. **Local layer cache.** Content-addressed wheel + blob store and the
|
||||
`sha256 → layer meta` DB. Prove second build does zero downloads/recompression.
|
||||
5. **Image assembly + push (ggcr).** Base-by-digest, sorted dep layers, app layer,
|
||||
config rewrite, manifest. Wire `HEAD` existence checks + cross-repo blob mount.
|
||||
Prove an unchanged-deps rebuild pushes only the manifest (and app layer if
|
||||
source changed).
|
||||
6. **App layer + ignore file.** Deterministic source tar honoring an ignore file,
|
||||
excluding `__pycache__`/VCS dirs.
|
||||
7. **Multi-arch + image index.**
|
||||
8. **SBOM emission**, then optional signing/provenance.
|
||||
9. **Cloud Run server + Terraform** to trigger builds (optional, repo-pattern).
|
||||
|
||||
## 11. Risks & open questions
|
||||
|
||||
- **Determinism is load-bearing.** Any non-reproducible byte (mtime, gzip header,
|
||||
entry ordering, locale-dependent sorting) silently breaks reuse. Mitigation:
|
||||
cache the *compressed blob* by content, plus determinism tests in CI.
|
||||
- **Layer-count vs. reuse.** Per-wheel is ideal for reuse but can produce many
|
||||
layers; the `hybrid` strategy bounds it at some reuse cost. Pick defaults and
|
||||
document the trade-off (§3.3).
|
||||
- **File collisions** between distributions (shared namespace dirs, data files,
|
||||
duplicate scripts). Need a conflict policy (last-writer / fail / dedicated
|
||||
layer). Mostly rare in practice; must be handled deterministically.
|
||||
- **`__pycache__`/`.pyc`.** Excluded for determinism; optionally pre-compile in a
|
||||
separate, clearly-marked optional layer (bytecode is hash-stable given fixed
|
||||
inputs + `PYTHONHASHSEED`).
|
||||
- **Cross-repo mount auth.** The push credential must also have *pull* scope on the
|
||||
source repo for `mount=...&from=...` to succeed; otherwise we fall back to
|
||||
upload. Handle the 202-without-mount case gracefully.
|
||||
- **Base/interpreter mismatch.** Wheel tags must match the base CPython; validate
|
||||
and fail fast.
|
||||
- **sdist-only dependencies.** Out of scope initially; later, build the wheel once
|
||||
then feed it into the normal layer path.
|
||||
|
||||
## 12. Prior art (for reference)
|
||||
|
||||
`ko`, `apko`/`melange`, `crane`/`go-containerregistry` (blob mounts), Bazel
|
||||
`rules_oci` / `rules_docker` `py3_image` (deps-vs-app split), `nixery` (per-package
|
||||
layers), Cloud Native Buildpacks (layer reuse + rebase), `distroless`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue