oci: keep bulk transfers off a single HTTP/2 connection - #11
Merged
Conversation
Go's default transport negotiates h2, which multiplexes every concurrent chunk request onto one TCP connection whose frames all pass through a single reader goroutine. That goroutine was the ceiling: on internal-cocoon-node-7 a 14 GiB snapshot pull sat at 337 MiB/s and an uncompressed push at 411 MiB/s while neither vk nor the cocoon import subprocess saturated a core, and 8 parallel range GETs over separate connections measured 1649 MiB/s on the same host. Pinning the registry transport to HTTP/1.1 gives each in-flight chunk its own connection: pull 42.5s -> 35.0s, push 34.9s -> 28.0s (512 MiB/s). The idle pool must hold every in-flight chunk or HTTP/1.1 pays a fresh TLS handshake per chunk.
MaxConnsPerHost=0 restates the zero value on both clone branches; the five deleted godocs restate the Registry interface contract.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Go's default transport negotiates HTTP/2, so every concurrent chunk request to Artifact Registry multiplexes onto one TCP connection, and every DATA frame for every stream passes through that connection's single
clientConnReadLoopgoroutine. A 14 GiB pull moves ~917k frames at the default 16 KiB frame size, all through one goroutine.That goroutine was the ceiling. On
target test node:cocoon snapshot importsubprocess saturated a coreSo the bytes were available and the CPU was available; the connection was not.
How
bulkTransport()cloneshttp.DefaultTransport, turns off HTTP/2 (ForceAttemptHTTP2 = falseplusNextProtos: ["http/1.1"], since ALPN would negotiate h2 anyway), and raisesMaxIdleConnsPerHostto 32. Over HTTP/1.1 each in-flight chunk gets its own connection and its own reader. The idle pool has to hold every in-flight chunk or HTTP/1.1 pays a fresh TLS handshake per chunk, which is what the raised limit is for.Measured
Compressed push does not move because it is zstd-encode-bound, not upload-bound — which is a useful independent confirmation of where each direction's limit sits.
Verified
go test -race ./...,make lint(linux + darwin, 0 issues),make fmt-check.Why not just tune HTTP/2 instead
Fair question, and the first thing to try — dropping to HTTP/1.1 looks like going backwards. It was tried and measured, and it does not work against this server.
Go's HTTP/2 slowness on bulk transfer is a known issue (golang/go#47840, golang-nuts thread) and the usual remedy is the frame size: that thread reports 8 Gbps → 38 Gbps by raising it to 256 KiB.
net/http.HTTP2Config.MaxReadFrameSizeexposes it, so the fix should be two lines.It isn't, because
SETTINGS_MAX_FRAME_SIZEadvertises what we are willing to receive; the sender picks the frame size. Probed againstus-central1-docker.pkg.devfromtarget test node, fetching 512 MiB of a real blob withGODEBUG=http2debug=2:MAX_FRAME_SIZEOur SETTINGS go out correctly (
wrote SETTINGS ... MAX_FRAME_SIZE=1048576), and the frontend sends 8 KiB frames either way — half the default, and not something a client can change. The 8→38 Gbps result in that thread is Go server to Go client, where raising the client's setting makes the Go server send bigger frames.End to end, with everything else in this series in place (14 GiB compressed pull, budget 2048):
Sharding across several HTTP/2 connections would divide the reader-goroutine bottleneck, but not the framing cost: 8 KiB frames are per byte, not per connection. For a transfer that is ~100% body with no headers worth compressing and no need for multiplexing, HTTP/1.1's framing is the cheaper one — one
Content-Lengthbody read straight off the socket. This is matching the transport to the workload rather than regressing it, and it is scoped to this registry client, not to vk's other HTTP traffic.