Skip to content

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
mainfrom
edgararakelyan/fd-3988-prune-image-contexts
Open

earakely-scale wants to merge 15 commits into
mainfrom
edgararakelyan/fd-3988-prune-image-contexts

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

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:

    • every stage's COPY and ADD sources and its bind mounts' sources;
    • minus what the ignore file leaves out, and what the OS or Python leaves behind (as BuildContext drops it);
    • plus the Dockerfile and both ignore files, whatever they leave out, and the targets of kept links, followed through any links on their way.

    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.

    • Each file is written at the time of writing, not the stored context's fixed time (BuildContext writes 0). docker build skips 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.
    • A link is written as it is, wherever it leads, as docker build copies one; nothing is written, copied or made through one that leads out of its folder. A member named out of the context is refused.
    • A folder the archive only implies, as the parent of files the ignore file leaves out, is written empty, as docker build sends it.
    • Both ignore files stay because docker build's frontends differ: its own reads an empty <Dockerfile>.dockerignore as leaving nothing out, and docker/dockerfile:1 reads .dockerignore instead. What's kept follows the one that leaves the most in.
    • 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. Some stored images record no path: CoopBench's agent's Dockerfile is at claude_code/Dockerfile, and it's found.
    • Each result says which Dockerfile it used and names the folder's five largest files, counting a name the archive holds twice once, so an import can show what it wrote before a folder is shared.
  • context_reads(dockerfile) is the reading behind it.

    • --from copies, URLs, git sources and heredocs don't read the context.
    • Wildcards follow Go's filepath.Match, which docker build matches COPY sources with: ! in a class is a character, and ^ negates it. A class docker build refuses as a syntax error (a reversed range such as [z-a], or a malformed one) keeps everything.
    • It reads a Dockerfile as docker build does, each case checked against a real build:
      • a tab can separate an instruction from its arguments, or one flag from the next;
      • a bind mount's options are one CSV record, so a quoted source can hold a space, and their names and type read in any case;
      • arguments are exec form only when they parse as a JSON list of strings;
      • a Dockerfile or ignore file stored as a link is read where it leads;
      • an escaped line break joins the next line on as it is, so a path broken across lines stays one path;
      • a heredoc opens only outside quotes and only in shell form;
      • a here-string (<<<) is left to the shell;
      • a source whose path crosses a link in the context is followed through it, keeping the link and what it leads to;
      • a link is followed inside the context, its root standing for / and for any .. above it, so sub -> /conf reads conf/;
      • a heredoc is a word: a file descriptor, <<, a -, then the rest of the word, unquoted, as its delimiter, so <<EOF.sh closes at EOF.sh and cat<<EOF opens none.
    • When a Dockerfile's reads can't be told (a variable in a source, an escape in one, which docker build removes before matching so that COPY \[x].txt copies x.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.
    • When the reading looks wrong, it fails safe rather than lose a file the build needs. Both of these keep everything:
      • a heredoc that runs to the end of the Dockerfile unclosed;
      • a source that matches nothing in the context.
    • Kept links find their targets in a sorted index: 5,000 links among 100,000 files take 0.16 s, where a scan of every name took 28 s.
  • utils/docker/dockerfile.py is now the one Dockerfile reader: instructions with continuations, the escape directive, comments and heredocs, plus a COPY/ADD's arguments. The Modal rewrite uses it with two differences:

    • comment lines among a continued instruction's lines now go above the one line it writes, and a JSON path like <<EOF keeps its instruction in exec form, since shell form would open a heredoc;
    • heredoc bodies are no longer scanned as instructions.
  • utils/docker/ gathers what were four utils modules: dockerfile (with context_reads), context (BuildContext, extract and the ignore file, moved from utils/build_context.py as they were), prune, build (from utils/docker_build.py) and metadata (from utils/build_metadata.py). Callers only change their imports.

    • One change to the packer: it reads an ignore file as the pruner does and as docker build does, 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. A docker build of that ignore file sends the file, and the packer now does too.

Nothing calls write_pruned yet. Bundle import's next step, which writes a task's envs and agents into the folder, will use it.

Tests

  • Unit:

    • the reader: shell and JSON forms, flags, heredocs (an unclosed one, a quoted or exec-form one that isn't, a here-string that isn't), continuations joined as Docker joins them, a tab after the name, the escape directive, a directive after the first instruction;
    • what a build reads: folders, stage copies, remote and archive 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 keeping a comment among continued lines, and taking a tab after the name.

    The Modal rewrite's existing tests pass unchanged.

  • Integration (tst/integration/utils/docker/prune_test.py, slow tier):

    • one Dockerfile built from a whole stored context and from its pruned part, whose images must hold the same files;
    • one env's folder written from two bundles' stored contexts and built twice, whose images must each hold their own bundle's file. With the stored times kept, both hold the first bundle's.
    • The Dockerfile: a multi-stage build with wildcards (a [!a] class among them), a local archive ADD, 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.
    • The context: ten times bigger than what the build reads.
  • 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.

  • The random contexts: awkward names, links (chains, loops, dangling, absolute, leading out), ignore files (! exceptions, comments, a BOM, a Dockerfile's own), duplicate members, and archives ADD unpacks.
  • The random Dockerfiles: multi-stage builds, shell and JSON forms, wildcards and classes, continued lines, heredocs, bind and other mounts in any case, ARGs and # syntax= lines.
  • First run: 1,050 trials, 1,261 images. The divergences it found are each fixed above, with a test. A first attempt was itself confounded by the stale-time bug: parallel trials' folders of one name built each other's files.
  • Confirmation on the fixed tree: the 10 seeds that had diverged replay as the same build, a failure of both, or the documented divergence below. 450 fresh trials (534 images) had none: 298 built the same, 189 of them pruned, and 236 failed both ways, mostly a source the ignore file or a dangling link leaves out.
  • The divergence that stays, by design: what the OS or Python leaves behind (__pycache__, .DS_Store) is dropped, as BuildContext drops it, even when a source or a link to it names it.

CoopBench

The stored contexts were downloaded read-only from prod.

  • What's stored: the 11 server images' contexts are 11 objects with one ETag, 1.16 GB each. The agent's is 0.5 MB.
  • Pruning: write_pruned wrote 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.
  • Against the prototype: the 11 server folders are byte-identical to the hand-pruned folders that ran CoopBench outside Scale. The agent's leaves out only test files its Dockerfile never copies.

On a fresh Ubuntu VM in agent-environments-admin, now torn down:

  • Tests: the unit tests and the integration test pass.
  • Image builds: all 12 images build from their pruned folders.
  • CoopBench t03: ran all 18 steps in 314 s on the pruned folders, with every role on Haiku through --model, --user-model and --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/Dockerfile on 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 carry qc/ 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

RetriggerView in GreptileConfidence 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.

  • Each image folder contains the files its Dockerfile reads.
  • Modal rewrites Dockerfile copy instructions using Docker’s instruction reader.
  • Docker ignore files treat leading # lines as comments.
  • Docker build helpers live together under 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]
Loading

Reviews (15) · Last reviewed commit: "fix(docker): write nothing outside a pru..." · Reviewed by Greptile

… 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>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 19, 2026 05:33
Comment thread src/agent_env/utils/dockerfile.py Outdated
Comment thread src/agent_env/utils/dockerfile.py Outdated
Comment thread src/agent_env/utils/docker/dockerfile.py
Comment thread src/agent_env/utils/build_context.py Outdated
Comment thread src/agent_env/utils/build_context.py Outdated
Comment thread src/agent_env/utils/build_context.py Outdated
Comment thread src/agent_env/utils/build_context.py Outdated
earakely-scale and others added 2 commits October 18, 2026 22:47
…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>
Comment thread src/agent_env/utils/docker/dockerfile.py
Comment thread src/agent_env/utils/docker/dockerfile.py Outdated
earakely-scale and others added 3 commits October 18, 2026 23:29
…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>
Comment thread src/agent_env/utils/build_context.py Outdated
earakely-scale and others added 3 commits October 19, 2026 08:19
…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>
@earakely-scale earakely-scale changed the title feat(build-context): write each image only the part of a shared build context its Dockerfile reads feat(docker): write each image only the part of a shared build context its Dockerfile reads Oct 19, 2026
…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>
Comment thread src/agent_env/utils/docker/prune.py Outdated
earakely-scale and others added 2 commits October 19, 2026 10:02
…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>
Comment thread src/agent_env/utils/docker/prune.py
…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
Comment thread src/agent_env/utils/docker/dockerfile.py Outdated
Comment thread src/agent_env/utils/docker/prune.py Outdated
earakely-scale and others added 2 commits October 19, 2026 10:52
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant