feat: chunked image push via OCI compatible push#2760
Conversation
8b01c53 to
88ff363
Compare
Replace Docker's monolithic ImagePush with a chunked push path for container image layers. Images are exported from the Docker daemon to OCI layout via ImageSave, then pushed through the registry client's existing chunked upload infrastructure (WriteLayer with 256MB chunks). This bypasses the ~500MB Cloudflare Workers request body limit that blocks Docker's native push for large layers. Key changes: - Add OCIImagePusher to pkg/registry/ with concurrent layer uploads - Export images from Docker daemon to OCI layout via ImageSave + tarball - Integrate into Resolver.Push and BundlePusher with Docker push fallback - Add ImageSave method to command.Command interface - Delete unused tools/uploader/ S3 multipart code (363 lines)
…pkg/model Move OCI layout utilities to pkg/oci/, extract registry transport config (chunk size, multipart threshold env vars) to pkg/registry/config.go, and relocate OCIImagePusher to pkg/model/ alongside ImagePusher and WeightPusher. - pkg/oci/: pure OCI format utilities (Docker tar <-> OCI layout), no registry deps - pkg/registry/config.go: configurable chunk size and multipart threshold - pkg/model/oci_image_pusher.go: push orchestration with shared pushImageWithFallback() - Deduplicate fallback logic between resolver.go and pusher.go - Add error discrimination: no fallback on auth errors or context cancellation - Create OCIImagePusher once in NewResolver, not per-push call
…weight pushers - Unify ImagePushProgress and WeightPushProgress into shared PushProgress type - Extract writeLayerWithProgress() helper to deduplicate progress channel boilerplate between OCIImagePusher and WeightPusher - Unify push concurrency: both image layer pushes and weight pushes use GetPushConcurrency() (default 4, overridable via COG_PUSH_CONCURRENCY) - Fix BundlePusher.pushWeights() which had no concurrency limit (launched all goroutines at once); now uses errgroup.SetLimit - Implement auth error detection in shouldFallbackToDocker() to match its documented behavior (don't fall back on UNAUTHORIZED/DENIED errors)
String-based error detection is fragile. Fall back to Docker push on any error except context cancellation/timeout.
Replace ~200 lines of custom ANSI escape progress rendering with the mpb (multi-progress-bar) library, which was already a dependency but unused. mpb handles TTY detection, cursor management, concurrent bar updates, and size formatting natively. Retry status is shown via a dynamic decorator.
…etTotal completion When bars are created with total > 0, mpb sets triggerComplete=true internally. This causes SetTotal(n, true) to early-return without triggering completion, so bars never finish and p.Wait() deadlocks. Creating bars with total=0 leaves triggerComplete=false, allowing explicit completion via SetTotal(current, true) after push finishes. The real total is still set dynamically via ProgressFn callbacks.
Merge OCIImagePusher (OCI chunked push) and the old ImagePusher (Docker push) into a single ImagePusher type that tries OCI first and falls back to Docker push on non-fatal errors. - ImagePusher.Push() handles OCI→Docker fallback internally - Delete OCIImagePusher type and oci_image_pusher.go - BundlePusher takes *ImagePusher directly instead of separate oci/docker pushers - Resolver stores single imagePusher field instead of ociPusher - Remove dead Pusher interface - Consolidate tests into image_pusher_test.go
ImagePusher now calls p.docker.ImageSave() directly instead of going through the oci.ImageSaveFunc indirection. The OCI layout export logic is inlined into ImagePusher.ociPush(). The pkg/oci package is deleted entirely since it had no other consumers.
OCI push is now opt-in rather than always-on when a registry client is present. Requires COG_PUSH_OCI=1 to activate.
Signed-off-by: Mark Phelps <mphelps@cloudflare.com>
- Add dynamic mpb progress bars for per-layer upload progress during OCI push - Wire ImageProgressFn through PushOptions → Resolver → ImagePusher - Force HTTP/1.1 for registry chunked uploads to avoid HTTP/2 RST_STREAM errors - Add HTTP/2 stream errors to isRetryableError for retry resilience - Reduce default chunk size from 256MB to 95MB to stay under CDN body limits
9930c18 to
cd606d8
Compare
Parse OCI-Chunk-Min-Length and OCI-Chunk-Max-Length headers from the registry's upload initiation response (POST /v2/.../blobs/uploads/). The server-advertised maximum always takes precedence over client defaults, and the result is clamped to be at least the server minimum. Rename COG_PUSH_CHUNK_SIZE to COG_PUSH_DEFAULT_CHUNK_SIZE to clarify that it is only a fallback for registries that don't advertise limits.
Replace the mpb progress bar library with Docker's jsonmessage rendering (the same code used by `docker push`) for OCI layer and weight upload progress. This fixes terminal corruption when the terminal is resized during a push. Root cause: mpb writes all bars then uses a bulk cursor-up (CUU N) to reposition. When the terminal shrinks, previously rendered lines wrap to occupy more visual lines, but the cursor-up count stays at the logical count, leaving ghost copies of progress bars on screen. Docker's jsonmessage avoids this by erasing and rewriting each line individually (ESC[2K + per-line cursor up/down), and re-querying terminal width on every render via ioctl(TIOCGWINSZ). Also removes the mpb dependency entirely from go.mod.
…back Strip HTML response bodies from transport errors (e.g., Cloudflare 413 pages) before displaying to user. Add OnFallback callback to close the progress writer before Docker push starts, preventing stale OCI progress bars from lingering above Docker's output.
Latest: sanitize error output and clear progress on Docker fallbackTwo fixes for terminal output quality when OCI push fails and falls back to Docker push:
Both fixes are covered by new tests ( |
|
This is incredibly exciting. |
- Remove unused OCI layout directory creation that doubled disk I/O (C1) - Randomize retry jitter to avoid thundering herd (W2) - Skip Docker fallback on 401/403 auth errors since they'd fail identically (W3) - Pool chunk buffers via sync.Pool to reduce memory pressure (W4) - Suppress duplicate retry log messages in TTY mode (S5)
michaeldwan
left a comment
There was a problem hiding this comment.
Strong overall, but a couple of design quirks that would be easy now to correct so the spirit of the OCI artifact & model resolver refactor isn't lost.
- Fix multipart threshold/chunksize inversion: raise DefaultMultipartThreshold from 50MB to 128MB so blobs that fit in a single chunk avoid unnecessary multipart overhead - Use HTTP/1.1 for all registry operations, not just chunked uploads, to avoid HTTP/2 head-of-line blocking and stream errors on large uploads - Move ProgressWriter from pkg/cli to pkg/docker since it wraps Docker's jsonmessage rendering and belongs with Docker concerns - Replace manual semaphore+WaitGroup in weights push with errgroup for bounded concurrency and first-error cancellation - Refactor ImagePusher.Push to accept *ImageArtifact instead of a raw string, preserving the resolved reference from the Model type - Unify BundlePusher construction: NewBundlePusher now takes docker+registry clients and creates both sub-pushers internally - Clarify that tarball.ImageFromPath is lazy (reads layers on-demand from the temp file, not into memory)
Addressed review feedback in 6e0ed39Worked through all unresolved comments from @mfainberg-cf and @michaeldwan. Here's what changed: Threshold/ChunkSize inversion (
|
Signed-off-by: Mark Phelps <mphelps@cloudflare.com>
Replace the variadic struct slice ([]ImagePushOptions) with idiomatic functional options (WithProgressFn, WithOnFallback). The resolved config struct is now unexported, and callers compose options cleanly: pusher.Push(ctx, artifact, WithProgressFn(fn), WithOnFallback(fn))
…mments Remove isHTTP2StreamError() and its test cases — HTTP/2 is no longer possible since we force HTTP/1.1 on all registry operations. Fix stale comments referencing 95 MB chunk size (now 96 MB), 50 MB multipart threshold (now 128 MiB), and concurrency 4 (now 5).
- Remove outdated 'Requires registry client' comment from canOCIPush (nil registry is no longer a concern) - Simplify sanitizeError doc comment - Add context to DefaultPushConcurrency matching Docker's default - Document why pooled chunk buffers don't need zeroing
Signed-off-by: Mark Phelps <mphelps@cloudflare.com>
michaeldwan
left a comment
There was a problem hiding this comment.
looks good, concerns addressed, ship it!
Summary
Replace Docker's monolithic
ImagePushwith chunked layer uploads, gated behindCOG_PUSH_OCI=1. The image is exported from Docker viaImageSave, loaded lazily using go-containerregistry'starball.ImageFromPath(layers are read on-demand from a temp file, not into memory), then each layer is pushed through the existingRegistryClient.WriteLayerchunked upload path — the same infrastructure weight artifacts already use. This bypasses request body limits that block Docker's native push for large layers.Off by default. Set
COG_PUSH_OCI=1to enable. Falls back to Docker push on any non-fatal error (except context cancellation/timeout and auth errors), so there's zero regression risk.What changed
Chunked image push
ImagePushertries OCI chunked push first whenCOG_PUSH_OCI=1, falls back to Docker pushdocker.ImageSave()→ tar → lazy v1.Image (viatarball.ImageFromPath) → concurrent layer uploadshouldFallbackToDocker()blocks fallback on context cancellation/timeout and authentication errors (401/403) — auth errors won't be fixed by Docker fallback, so the original error is surfaced directlyImageSave(ctx, imageRef)to the DockerCommandinterfaceImagePusher.Push()takes*ImageArtifactdirectly (not a raw string ref), preserving the resolved reference from the Model typeUnified push infrastructure
PushProgresstype replaces separate image/weight progress typeswriteLayerWithProgress()helper deduplicates progress channel boilerplateGetPushConcurrency()(default 5,COG_PUSH_CONCURRENCY) shared by image layers, weight pushes, and CLI progressBundlePusher.pushWeights()useserrgroupwith concurrency limitNewBundlePushertakesdocker.Command+registry.Clientand creates both sub-pushers (image + weight) internally — unified constructionProgress rendering
mpbprogress bar library with Docker'sjsonmessage.DisplayJSONMessagesStream— the same rendering code used bydocker pushdocker pushexactly: layer ID, status, and progress bar on each lineESC[2K+ per-line cursor up/down), rather than relying on a bulk cursor-up count that desyncs when lines wrap after a resizeioctl(TIOCGWINSZ)on every render, so progress bars adapt dynamicallyProgressWriteradapter inpkg/docker/progress.goconvertsPushProgresscallbacks into JSON-encodedJSONMessagestructs fed toDisplayJSONMessagesStreamvia anio.Pipeconsole.Warnfin non-TTY mode — in TTY mode the progress writer handles display inline, avoiding duplicate outputmpbdependency entirelyHTTP/1.1 transport for all registry operations
http.Transportnegotiates HTTP/2 via TLS ALPN. High-throughput HTTP/2 suffers from head-of-line blocking when multiplexing large blob uploads on a single connection. Additionally, certain CDN/proxy edges can sendRST_STREAM INTERNAL_ERRORon large requests — killing uploads before they reach the origin.RegistryClientstores anhttp1OnlyTransport()and passes it to allremote.*calls via aremoteOptions()helper. Multiple concurrent HTTP/1.1 connections outperform a single multiplexed HTTP/2 connection for large blob uploads.RST_STREAM) are also recognized byisRetryableError()as a belt-and-suspenders measure, so they trigger the existing retry-from-scratch logic inWriteLayer.Retry and resource management
rand.Float64()) to avoid thundering herd when multiple clients retry simultaneouslysync.Poolto reduce memory pressure when pushing multiple layers concurrentlyServer-driven chunk sizing (OCI-Chunk-Min/Max-Length)
initiateUpload()now parsesOCI-Chunk-Min-LengthandOCI-Chunk-Max-Lengthheaders from the registry's 202 responsemax - 64KBmargin to stay safely under the limitCOG_PUSH_DEFAULT_CHUNK_SIZEis only used as a fallback when the registry doesn't advertise limitsuploadSessionstruct carries location + chunk constraints from initiation through to uploadRegistry config
getDefaultChunkSize()andgetMultipartThreshold()env var helpers intopkg/registry/config.goOCI-Chunk-Max-Length)Cleanup
pkg/oci/package — OCI image loading inlined intoImagePushertools/uploader/— unused S3 multipart uploader (363 lines, zero imports)Pusherinterface,OCIImagePusher,pushImageWithFallback()— consolidated into singleImagePusherDefaultFactory→defaultFactory,NewImagePusher→newImagePusherEnvironment variables
COG_PUSH_OCI1to enable OCI chunked image pushCOG_PUSH_CONCURRENCY5COG_PUSH_DEFAULT_CHUNK_SIZE100663296(96 MiB)OCI-Chunk-Max-LengthCOG_PUSH_MULTIPART_THRESHOLD134217728(128 MiB)