Repository navigation
history export: one record takes ~26s over a remote builder (223 sequential Content/Read RPCs) #4136
Description
Description
buildx history export reads blobs from the builder's content store in many small sequential Content/Read RPCs. Over a remote builder each read is a network round trip, so exporting one build record takes tens of seconds. docker/build-push-action runs this export after every build to produce the job summary, so every CI build on a remote builder pays that cost.
For context, in our CI, we use a remote buildkit builder, to get consistent high cache hit rates and to not be limited by the compute of GH hosted runners.
Expected behaviour
Exporting one record reads each blob in a handful of RPCs, so the time is dominated by transfer, not by the number of round trips.
Actual behaviour
One record took 28.1s to export. 27s of that was 223 sequential Content/Read RPCs of 80–290 ms each:
| phase | time | Content/Read RPCs |
bytes |
|---|---|---|---|
sanitizeTrace |
17.85s | 162 | 2,040,891 (trace), ~12.6 KB per read |
imgarchive.Export |
9.12s | 61 | 1,669,976 (logs) in 51 × 32 KiB, 266,390 (provenance) in 9, 856 (index) in 1 |
| everything else | 1.2s | 0 |
The traces come from a build of master with a root span added to history export (the BuildKit client then traces its gRPC calls) plus a few phase spans.
Where the small reads come from
Each ReadAt on containerd's proxy content store opens its own Content/Read stream:
buildx/vendor/github.com/containerd/containerd/v2/core/content/proxy/content_reader.go
Lines 39 to 51 in c7748d0
| func (ra *remoteReaderAt) ReadAt(p []byte, off int64) (n int, err error) { | |
| rr := &contentapi.ReadContentRequest{ | |
| Digest: ra.digest.String(), | |
| Offset: off, | |
| Size: int64(len(p)), | |
| } | |
| // we need a child context with cancel, or the eventually called | |
| // grpc.NewStream will leak the goroutine until the whole thing is cleared. | |
| // See comment at https://godoc.org/google.golang.org/grpc#ClientConn.NewStream | |
| childCtx, cancel := context.WithCancel(ra.ctx) | |
| // we MUST cancel the child context; see comment above | |
| defer cancel() | |
| rc, err := ra.client.Read(childCtx, rr) |
-
sanitizeTraceruns ajson.Decoderdirectly over anio.SectionReaderof the remote blob. Every decoder refill (sized to the decoder's buffer, so between ~512 bytes and the largest span) becomes one RPC:buildx/util/desktop/bundle/trace.go
Lines 40 to 48 in c7748d0
func sanitizeTrace(ctx context.Context, mp *contentutil.MultiProvider, desc ocispecs.Descriptor) (*ocispecs.Descriptor, error) { ra, err := mp.ReaderAt(ctx, desc) if err != nil { return nil, err } defer ra.Close() buf := &bytes.Buffer{} dec := json.NewDecoder(io.NewSectionReader(ra, 0, ra.Size())) -
bundle.Exportpasses the content store to containerd's archive exporter, which copies each blob withio.Copyovercontent.NewReader, so it reads 32 KiB per RPC:buildx/util/desktop/bundle/export.go
Line 62 in c7748d0
if err := imgarchive.Export(ctx, mp, gz, imgarchive.WithManifest(desc), imgarchive.WithSkipDockerManifest()); err != nil { buildx/vendor/github.com/containerd/containerd/v2/core/images/archive/exporter.go
Lines 475 to 484 in c7748d0
r, err := cs.ReaderAt(ctx, desc) if err != nil { return 0, fmt.Errorf("failed to get reader: %w", err) } defer r.Close() // Verify digest dgstr := desc.Digest.Algorithm().Digester() n, err := io.Copy(io.MultiWriter(w, dgstr.Hash()), content.NewReader(r))
The cost is the number of reads times the round-trip time, so it doesn't show up against a local builder.
Proposed fix
Buffer both paths so each RPC fetches up to 4 MiB:
sanitizeTracedecodes throughbufio.NewReaderSize(io.NewSectionReader(ra, 0, ra.Size()), min(ra.Size(), 4 MiB)).bundle.Exportwraps the provider it hands toimgarchive.Exportso eachReaderAtit returns also has aReader()backed by the same buffered reader.content.NewReaderusesReader()when it exists, so the exporter'sio.Copyreads in 4 MiB chunks.
Memory stays bounded by one 4 MiB buffer, capped at the blob size. With this change, the same record:
| median of 5 | min | max | Content/Read RPCs |
|
|---|---|---|---|---|
| master | 26.02s | 24.80s | 26.48s | 223 |
| buffered reads | 3.54s | 2.85s | 4.51s | 4 |
Buildx version
github.com/docker/buildx c7748d0 (master, built from source). Also seen with v0.38.0 in the docker/build-push-action post step, where exporting the same record took 13.1s from a GitHub-hosted runner.
Builders list
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
bench remote
\_ bench0 \_ tcp://127.0.0.1:17234 running v0.28.0 linux/amd64 (+4), linux/386
The endpoint is the rootless buildkitd's unix socket, SSH-forwarded from a Kubernetes pod (~80–120 ms per RPC).
Configuration
$ docker buildx --builder bench history export <ref> -o record.dockerbuildAny build record works; the more trace spans and log output, the more round trips.
Additional info
To capture the trace: add tracing.TraceCurrentCommand to the history export command (as build and bake already do), run it with OTEL_EXPORTER_OTLP_ENDPOINT pointing at Jaeger, and every Content/Read shows up as a span.
Assisted by Claude Code (Claude Opus 5.5). Reviewed and validated fully by myself.
Activity
bundle: read blobs in 4 MiB chunks during history export
bundle: read blobs in 4 MiB chunks during history export
kevinmingtarja commentedon Oct 10, 2026
I was curious to see if there are concrete examples of this happening in other OSS repos, so my agent did some digging, and it seems like this would hit Docker Build Cloud users in general. For example, in this https://github.com/runtipi/runtipi run, the post step's buildx history export took 24s for an 83 KB record:
https://github.com/runtipi/runtipi/actions/runs/37732444639/job/113164411587#step:10:1
A few others: