Repository navigation
feat(docker): write each image only the part of a shared build context its Dockerfile reads - #248
Open
earakely-scale wants to merge 15 commits into
Open
earakely-scale wants to merge 15 commits into
earakely-scale wants to merge 15 commits into
Conversation
… context its Dockerfile reads
write_pruned(archive, {folder: dockerfile}) writes into each folder what that image's build reads of the context
tar.gz: its COPY and ADD sources in every stage and its bind mounts' sources, less what the ignore file and the OS or
Python leave out, with the Dockerfile, the ignore file and the targets of kept links. Images sharing one context are
written from it in two passes, never unpacking the rest. A Dockerfile whose reads can't be told (a variable in a
source, a flag it doesn't follow) keeps everything the context sends, and says why.
The Dockerfile reading moves into utils/dockerfile.py (instructions with continuations, the escape directive,
comments and heredocs; COPY and ADD arguments), which the Modal rewrite now uses too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n a likely misreading, and find link targets in time docker build leaves a <<< here-string to the shell, so the reader no longer opens a heredoc on one and swallows the instructions after it. When the reading looks wrong, a heredoc running unclosed to the end or a source matching nothing in the context, the image keeps everything the context sends and says why, rather than lose a file it needs. Kept links find their targets in a sorted index instead of a scan of every name: 5,000 links among 100,000 files went from 28 s to 0.16 s. The Modal rewrite keeps the comment lines among a continued instruction's lines, above the one line it writes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… docker build does An instruction's name is split from its arguments on any whitespace, so COPY<tab>app is a COPY again. An escaped line break is removed with nothing put in its place, as docker build removes it, so a path broken across lines stays one path. A heredoc opens only outside quotes and only in shell form: docker build writes a quoted <<EOF as text and opens none for an exec-form instruction. Each was checked against a docker build. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…build does A source whose leading parts are a link in the context (COPY current/app.py, current -> releases/v2) is followed through it, keeping the links it crosses and what they lead to, instead of keeping the whole context. A link out of the context or round in a loop still keeps all of it. The integration test copies through a link and checks the images built from the whole and the pruned context still hold the same files. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es, and every flag as docker build does A Dockerfile or ignore file stored as a link is read where it leads. Wildcards follow Go's filepath.Match, which docker build matches COPY sources with: ! in a class is a character and ^ negates it. Flags split on any whitespace outside quotes, and a bind mount's options are one CSV record, so a tab between flags or a quoted source with a space keeps the folder it mounts. Arguments are exec form only when they parse as a JSON list of strings, so a shell-form COPY [!a].txt is read as one. The Modal rewrite leaves a JSON path like <<EOF as written instead of opening a heredoc in shell form. Each was checked against a docker build, and the integration test now covers them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… report each image's largest files An image given no Dockerfile path builds the context's Dockerfile, or, with none at its root, the only one in it; a context holding several or none is refused, naming them, and Pruned.dockerfile says which was used. Pruned.largest names each folder's five biggest files, so an import can show what it wrote before a folder is shared. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ep docstrings to one line A name an archive holds more than once is written over, so each image's file count, size and largest files now use the entry left in its folder rather than every entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… unknown mount type, and read mount options in any case - A `# syntax=` directive naming a frontend other than Docker's own keeps the whole context, since that frontend may read the context its own way. - A RUN --mount type that isn't bind, cache, tmpfs, secret or ssh keeps the whole context instead of being skipped. - Mount option names and the type are read in any case, as docker build reads them: `type=BIND` was skipped as a mount that reads nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and split the pruner into steps - utils/docker/ holds what was four utils modules: dockerfile (reading a Dockerfile, now with context_reads, what a build of it reads), context (BuildContext, extract and the ignore file, as on main), prune (write_pruned), build (build_image) and metadata (what a put records about a build). - write_pruned reads, plans and writes in three steps, and one helper finds where a link leads. - The packer and the pruner read an ignore file one way, as docker build reads it: its BOM dropped, and a line opening with # a comment. The packer used to read comment lines as patterns. - The COPY, ADD and bind-mount readers return what they read rather than add to a list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r build does Found by building random contexts and Dockerfiles both from the whole context and from the pruned folder: - docker build resolves a link inside the context, `/` and any `..` above it standing for its root, so a source through `sub -> /conf` reads conf/. The pruner read such a link as leading out and left its target behind. - A link that leads out of the context is written into the folder as it is, since docker build copies it as a link; nothing is still written through one. - docker build unescapes a source before matching it, so `COPY \[x].txt` copies x.txt. A source or mount source holding an escape now keeps the whole context. - A wildcard docker build refuses (a reversed or malformed class) keeps the whole context instead of raising. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d context's A stored context's files carry fixed times (BuildContext writes 0, for its digest), and docker build skips sending a file whose path, size and time match one it was sent before. Two folders of one name written from two stored contexts, such as one env in two bundles, or one folder written over, then built the first one's files. Each file is now written at the time of writing. The integration test builds one env's folder from two bundles; with the stored times kept, both images hold the first bundle's file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e only implies - docker build reads the Dockerfile and its ignore file where they lead, whatever the ignore file says. A Dockerfile stored as a link whose target the ignore file leaves out lost that target. - A folder the archive holds no member for, only the files under it, is sent by docker build even when the ignore file leaves out all of them, so COPY . and COPY sub/ copy it empty. The pruned folder now has it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…8-prune-image-contexts # Conflicts: # src/agent_env/bundle/ledger.py # src/agent_env/bundle/materialize.py # src/agent_env/bundle/run.py # tst/unit/bundle/materialize_test.py
docker build's own frontend reads an empty <Dockerfile>.dockerignore as leaving nothing out, and the docker/dockerfile:1 frontend reads the context's .dockerignore in its place. The pruned folder kept only the ignore file the reading followed, so with the other frontend a build of it could read files the whole context's leaves out. Both stay now; what is kept follows the file that leaves the most in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…BuildKit does, follow link chains - A folder after the first that shares an archive gets its files copied, without the extraction's check, so a link leading into the first folder could lead a copy out of a later one. Every copy and every folder made for one is now checked to land inside its folder. - A member named out of the context (../) is refused before anything is written, as extract refuses it, so no folder it implies is made outside. - A heredoc is a word: a file descriptor, <<, a - and the rest of the word as its delimiter, as BuildKit reads it. RUN <<EOF.sh closes at EOF.sh, and RUN cat<<EOF opens none. The reader took <<EOF.sh as <<EOF, and could read a later COPY into a heredoc. - A kept link's target is followed through the links on its way, keeping them: current -> releases/latest/app with releases/latest -> v2 kept nothing under the literal path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A stored image's build context can be far bigger than its build reads. All 11 CoopBench server images share one 1.16 GB context, the whole
synthetic_servers/tree, while each server's Dockerfile copies a few paths. A bundle image builds from its own folder, so writing a task's images out as stored would mean 12.7 GB of folders.write_pruned(archive, {folder: dockerfile})(utils/docker/prune.py) writes into each folder only what that image's build reads of the context tar.gz:COPYandADDsources and its bind mounts' sources;BuildContextdrops it);Images sharing one context are written from it in two passes, without unpacking the rest. Each folder keeps the context's layout, with its Dockerfile at its stored path.
BuildContextwrites 0).docker buildskips sending a file whose path, size and time match one it was sent before, so one env's folder in two bundles built the first bundle's files.docker buildcopies one; nothing is written, copied or made through one that leads out of its folder. A member named out of the context is refused.docker buildsends it.docker build's frontends differ: its own reads an empty<Dockerfile>.dockerignoreas leaving nothing out, anddocker/dockerfile:1reads.dockerignoreinstead. What's kept follows the one that leaves the most in.Dockerfile, or, with none at its root, the only one in it. A context holding several, or none, is refused, naming them. Some stored images record no path: CoopBench's agent's Dockerfile is atclaude_code/Dockerfile, and it's found.context_reads(dockerfile)is the reading behind it.--fromcopies, URLs, git sources and heredocs don't read the context.filepath.Match, whichdocker buildmatchesCOPYsources with:!in a class is a character, and^negates it. A classdocker buildrefuses as a syntax error (a reversed range such as[z-a], or a malformed one) keeps everything.docker builddoes, each case checked against a real build:<<<) is left to the shell;/and for any..above it, sosub -> /confreadsconf/;<<, a-, then the rest of the word, unquoted, as its delimiter, so<<EOF.shcloses atEOF.shandcat<<EOFopens none.docker buildremoves before matching so thatCOPY \[x].txtcopiesx.txt, a flag or mount type it doesn't follow, an instruction it can't parse, a# syntax=frontend other than Docker's own), the image keeps everything the context sends and the result says why.COPY .keeps everything because that's exactly what it reads.utils/docker/dockerfile.pyis now the one Dockerfile reader: instructions with continuations, the escape directive, comments and heredocs, plus aCOPY/ADD's arguments. The Modal rewrite uses it with two differences:<<EOFkeeps its instruction in exec form, since shell form would open a heredoc;utils/docker/gathers what were fourutilsmodules:dockerfile(withcontext_reads),context(BuildContext,extractand the ignore file, moved fromutils/build_context.pyas they were),prune,build(fromutils/docker_build.py) andmetadata(fromutils/build_metadata.py). Callers only change their imports.docker builddoes, with its BOM dropped and a line opening with#a comment. It used to read such a line as a pattern, so a file named like a comment line was left out of the context. Adocker buildof that ignore file sends the file, and the packer now does too.Nothing calls
write_prunedyet. Bundle import's next step, which writes a task's envs and agents into the folder, will use it.Tests
Unit:
ADD, wildcards, bind mounts (after a tab, with a quoted source, in upper case) and mounts that read nothing,# syntax=frontends, normalized paths, and each reason for keeping everything;write_pruned: several images from one shared archive; the ignore file with!exceptions, and a Dockerfile's own ignore file; links in and out of the context, a chain of links, a source reached through links, and a link loop; a Dockerfile stored as a link; an image naming no Dockerfile (root, the only one, several, none); the largest files, with a name the archive holds twice counted once; wildcard classes as Go reads them, and a malformed one; OS and Python leftovers; a source that matches nothing; a missing Dockerfile and an unsafe member refused;The Modal rewrite's existing tests pass unchanged.
Integration (
tst/integration/utils/docker/prune_test.py, slow tier):[!a]class among them), a local archiveADD, a bind mount with a quoted source after a tab, one with an upper-case type beside a cache mount, an ignore file, a linked folder and a copy through a link. The Dockerfile itself is stored as a link.Unit: each divergence the chaos run found has a test: links resolved in the context root and ones leading out; escaped sources; refused classes; the time of writing; a linked Dockerfile's target the ignore file leaves out; implied folders; both ignore files. Greptile's later findings do too: folders sharing an archive, members named out of the context, heredoc delimiters, link chains.
Unit tier: 8,221 passed on the head. One known-flaky gateway time-trigger test, outside this change, passed 3/3 on rerun.
Chaos
On an agent-environments-admin VM (Docker 29.1.3, buildx 0.30.1), now torn down. Each trial generates a random context and Dockerfile. It builds each image twice, from the whole context and from its pruned folder, and compares what the images hold under
/out.!exceptions, comments, a BOM, a Dockerfile's own), duplicate members, and archivesADDunpacks.ARGs and# syntax=lines.__pycache__,.DS_Store) is dropped, asBuildContextdrops it, even when a source or a link to it names it.CoopBench
The stored contexts were downloaded read-only from prod.
write_prunedwrote the 11 servers from one archive in 3 s. All 12 images came to 67.7 MB in 2,164 files, against 12.7 GB as stored, and no Dockerfile fell back to the whole context.On a fresh Ubuntu VM in agent-environments-admin, now torn down:
--model,--user-modeland--judge-model. Both judges graded.Later commits change how odd Dockerfiles, links and ignore files are read, add the Dockerfile fallback and the largest files, and write files at the time of writing. The CoopBench contexts still prune to byte-identical files on the head. Left with no path given, the agent's image resolves
claude_code/Dockerfileon its own.The largest-files list shows what's worth a look before sharing. Six of the eleven server folders carry
qc/universes/hg4-2026-07-02@v4.json(.gz), and eight carryqc/folders, about 32 MB of the 67.7 MB. The servers' Dockerfiles copy their whole folders, so that data is in the stored images too.🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; all four numbered findings are addressed and no new actionable issues were found.Summary
The PR adds
write_pruned()to write only the files each image needs from a shared build context.#lines as comments.utils/docker/.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR A[Shared context archive] --> B[Read names and Dockerfiles] B --> C[Find context paths each build reads] C --> D[Apply ignore rules and follow links] D --> E[Write each image folder] E --> F[Report kept files and sizes]Reviews (15) · Last reviewed commit: "fix(docker): write nothing outside a pru..." · Reviewed by Greptile