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:
- opens
/session with Upgrade: h2c, as withSession does;
- advertises its methods via repeated
X-Docker-Expose-Session-Grpc-Method headers;
- serves
moby.filesync.v1.Auth and moby.filesync.v1.FileSync over @grpc/grpc-js;
POSTs /build?version=2&remote=client-session&session=<uuid> with Content-Length: 0;
- 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
- Would you welcome this change? It is a lot of work to do speculatively.
- 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.
Summary
buildImagewithversion: "2"asks the daemon for the BuildKit builder but keeps the classic transport, so the caller's entire tar isPOSTed to/build.docker builddoes not do this: it transfers only the paths the Dockerfile'sCOPY/ADDinstructions 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.POSTed to/buildThe classic
/buildendpoint 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 contextfollowed bycopy /context /; the filesync path shows[internal] load build contextwith a livetransferring contextcounter.This is not a JS problem specifically -
testcontainers-gotars whole context directories too.What is required?
version=2session=<uuid>remote=client-sessionX-Docker-Expose-Session-Grpc-Methodheadersmoby.filesync.v1.Authmoby.filesync.v1.FileSyncThe 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:
/sessionwithUpgrade: h2c, aswithSessiondoes;X-Docker-Expose-Session-Grpc-Methodheaders;moby.filesync.v1.Authandmoby.filesync.v1.FileSyncover@grpc/grpc-js;POSTs/build?version=2&remote=client-session&session=<uuid>withContent-Length: 0;DiffCopycalls with an fsutil packet stream.Result:
496,688 bytes against ~375 MB
Three
DiffCopycalls happen, in this order:dir-namefollowpathsexclude-patternsdockerfileminio.Dockerfile,minio.Dockerfile.dockerignorecontext.dockerignorecontextCOPYsources.dockerignoreA second run transferred zero bytes of file data for the context - 43 stats and no
PACKET_REQat all, because the daemon's cache was warm. Incrementality comes for free on top of the selectivity.Proposed shape
1. An fsutil
DiffCopysender. Only the sender is needed so roughly half the protocol is out of scope. Walk the filtered tree,emit
PACKET_STAT, answerPACKET_REQwithPACKET_DATA, honour include/exclude/followpaths, terminate on
PACKET_FIN.This could live in
dockerode/lib/or as a separate packagedockerodedependson. 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: registerFileSyncalongsideAuthinwithSession, sendthe
-Grpc-Methodheaders, and add a build option that setsremote: 'client-session'andserves the context over the session instead of putting a tar in the request body.
What I would like to know before starting
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.