Skip to content

BuildKit builds still POST the whole context; session filesync would transfer only what the Dockerfile references #846

Description

@davidfurey

Summary

buildImage with version: "2" asks the daemon for the BuildKit builder but keeps the classic transport, so the caller's entire tar is POSTed to /build. docker build does not do this: it transfers only the paths the Dockerfile's COPY/ADD instructions actually reference. On our repo that is 500kB transferred instead of ~375 MB.

I have used Claude to produce a working spike that demonstrates that we could take the same efficient approach as the Docker CLI entirely from Node, building the same image while transferring ~496kB.

It isn't a small change, so I would rather find out now whether you want it at all, and in what shape, than waste time on an unwanted PR.

We discovered this behaviour while chasing a build failure in a large repo, which we originally attributed to context size. That turned out to be a separate bug (#845). So the case for this issue is efficiency, rather than that anything is broken.

What is different from the Docker CLI

BuildKit's dockerfile frontend parses the Dockerfile first, works out which paths the instructions reference, and requests only those back from the client over the session's gRPC filesync service. Directories that are not referenced are never sent, not because they are .dockerignoreded, but because they are not required.

CLI / buildx dockerode
Transport session filesync service tar POSTed to /build
What is sent only paths the Dockerfile references whatever the caller packed
When decided after the daemon has parsed the Dockerfile before the daemon has seen it
Our repo 500.77 kB ~375 MB

The classic /build endpoint has no back-channel, so the client must serialise and upload everything from which the daemon then selects what it needs.

You can tell the two apart in build output: the tar path shows [internal] load remote build context followed by copy /context /; the filesync path shows [internal] load build context with a live transferring context counter.

This is not a JS problem specifically - testcontainers-go tars whole context directories too.

What is required?

Requirement today
version=2 ✅
session=<uuid> ✅
remote=client-session ❌ never set
X-Docker-Expose-Session-Grpc-Method headers ❌ never sent
serve moby.filesync.v1.Auth ✅
serve moby.filesync.v1.FileSync ❌

The first two gaps are a flag and a header. The third is a protocol implementation.

The spike - proof this can be done

Roughly 450 lines of throwaway Node https://gist.github.com/davidfurey/3c0f53d215babfae29b4ccfad016d437 that:

  1. opens /session with Upgrade: h2c, as withSession does;
  2. advertises its methods via repeated X-Docker-Expose-Session-Grpc-Method headers;
  3. serves moby.filesync.v1.Auth and moby.filesync.v1.FileSync over @grpc/grpc-js;
  4. POSTs /build?version=2&remote=client-session&session=<uuid> with Content-Length: 0;
  5. answers the daemon's DiffCopy calls with an fsutil packet stream.

Result:

BUILD OK in 16554 ms

=== transfer totals ===
  dockerfile: 916 bytes of file data, 3 stats, 1 files
  context: 496688 bytes of file data, 43 stats, 27 files

496,688 bytes against ~375 MB

Three DiffCopy calls happen, in this order:

# dir-name followpaths exclude-patterns transferred
1 dockerfile minio.Dockerfile, minio.Dockerfile.dockerignore - 916 B
2 context .dockerignore - 115 B
3 context the 5 COPY sources the 11 lines of .dockerignore 496,688 B

A second run transferred zero bytes of file data for the context - 43 stats and no PACKET_REQ at all, because the daemon's cache was warm. Incrementality comes for free on top of the selectivity.

Proposed shape

1. An fsutil DiffCopy sender. Only the sender is needed so roughly half the protocol is out of scope. Walk the filtered tree,
emit PACKET_STAT, answer PACKET_REQ with PACKET_DATA, honour include/exclude/follow
paths, terminate on PACKET_FIN.

This could live in dockerode/lib/ or as a separate package dockerode depends
on. I'd lean towards keeping it in dockerode but am happy to spin up a new library if you prefer.

2. Wire it up in dockerode: register FileSync alongside Auth in withSession, send
the -Grpc-Method headers, and add a build option that sets remote: 'client-session' and
serves the context over the session instead of putting a tar in the request body.

What I would like to know before starting

  1. Would you welcome this change? It is a lot of work to do speculatively.
  2. Would you prefer In-tree or separate package for the fsutil sender?

It is worth noting that fsutil's wire format has no specification, it is defined by its Go implementation and this is what I'd use as my reference.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions