Skip to content

history export: one record takes ~26s over a remote builder (223 sequential Content/Read RPCs) #4136

Open

Description

@kevinmingtarja

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
Image Image

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:

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)

  1. sanitizeTrace runs a json.Decoder directly over an io.SectionReader of the remote blob. Every decoder refill (sized to the decoder's buffer, so between ~512 bytes and the largest span) becomes one RPC:

    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()))

  2. bundle.Export passes the content store to containerd's archive exporter, which copies each blob with io.Copy over content.NewReader, so it reads 32 KiB per RPC:

    if err := imgarchive.Export(ctx, mp, gz, imgarchive.WithManifest(desc), imgarchive.WithSkipDockerManifest()); err != nil {

    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:

  • sanitizeTrace decodes through bufio.NewReaderSize(io.NewSectionReader(ra, 0, ra.Size()), min(ra.Size(), 4 MiB)).
  • bundle.Export wraps the provider it hands to imgarchive.Export so each ReaderAt it returns also has a Reader() backed by the same buffered reader. content.NewReader uses Reader() when it exists, so the exporter's io.Copy reads 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.dockerbuild

Any 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

  1. added 2 commits that reference this issue on Oct 10, 2026
    6037da0
    aa1417e
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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions