fix(push): split buildx --push into --load + docker push
BuildKit's --push uploads each layer as a single monolithic HTTP request. For images >100MB this gets rejected by Cloudflare's per-request size limit. Using --load to import into the local daemon then docker push gives us chunked uploads (~5MB/request) that stay well under the limit.
- commit
- cc6be98349b43382f4a94b83c9445eb79252a013
- author
- brice · · 2026-02-06 13:02 UTC
- parent
- ec03e2f
- signature
- unsigned
2 files · +40 -10
M internal/adapters/in/cli/push.go +31 -10
| @@ -127,7 +127,35 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||
| 127 | 127 | return fmt.Errorf("no Dockerfile found in current directory") | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | - | fmt.Println("\nBuilding and pushing...") | |
| 130 | + | // Build and load into local daemon (NOT --push). | |
| 131 | + | // BuildKit's --push uses monolithic blob uploads that exceed | |
| 132 | + | // Cloudflare's 100MB per-request limit. Loading locally then | |
| 133 | + | // using docker push gives us chunked uploads (~5MB per request). | |
| 134 | + | fmt.Println("\nBuilding image...") | |
| 135 | + | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, buildArgs, versionRef, latestRef)...) // #nosec G204 | |
| 136 | + | buildCmd.Stdout = os.Stdout | |
| 137 | + | buildCmd.Stderr = os.Stderr | |
| 138 | + | if err := buildCmd.Run(); err != nil { | |
| 139 | + | return fmt.Errorf("docker buildx build failed: %w", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | fmt.Println("Pushing...") | |
| 143 | + | if err := dockerPush(ctx, latestRef); err != nil { | |
| 144 | + | return fmt.Errorf("failed to push %s: %w", latestRef, err) | |
| 145 | + | } | |
| 146 | + | if version != "latest" { | |
| 147 | + | if err := dockerPush(ctx, versionRef); err != nil { | |
| 148 | + | return fmt.Errorf("failed to push %s: %w", versionRef, err) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // buildImageArgs constructs the docker buildx build arguments. | |
| 156 | + | // Uses --load instead of --push so the image is loaded into the local | |
| 157 | + | // daemon, allowing docker push to handle the upload with chunked requests. | |
| 158 | + | func buildImageArgs(version, platform string, buildArgs []string, versionRef, latestRef string) []string { | |
| 131 | 159 | args := []string{ | |
| 132 | 160 | "buildx", "build", | |
| 133 | 161 | "--platform", platform, | |
| @@ -140,15 +168,8 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||
| 140 | 168 | for _, ba := range buildArgs { | |
| 141 | 169 | args = append(args, "--build-arg", ba) | |
| 142 | 170 | } | |
| 143 | - | args = append(args, "--push", ".") | |
| 144 | - | ||
| 145 | - | dockerCmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204 | |
| 146 | - | dockerCmd.Stdout = os.Stdout | |
| 147 | - | dockerCmd.Stderr = os.Stderr | |
| 148 | - | if err := dockerCmd.Run(); err != nil { | |
| 149 | - | return fmt.Errorf("docker buildx failed: %w", err) | |
| 150 | - | } | |
| 151 | - | return nil | |
| 171 | + | args = append(args, "--load", ".") | |
| 172 | + | return args | |
| 152 | 173 | } | |
| 153 | 174 | ||
| 154 | 175 | func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error { | |
| @@ -127,7 +127,35 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||||
| 127 | return fmt.Errorf("no Dockerfile found in current directory") | 127 | return fmt.Errorf("no Dockerfile found in current directory") | ||
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | - | fmt.Println("\nBuilding and pushing...") | 130 | + | // Build and load into local daemon (NOT --push). |
| 131 | + | // BuildKit's --push uses monolithic blob uploads that exceed | |||
| 132 | + | // Cloudflare's 100MB per-request limit. Loading locally then | |||
| 133 | + | // using docker push gives us chunked uploads (~5MB per request). | |||
| 134 | + | fmt.Println("\nBuilding image...") | |||
| 135 | + | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, buildArgs, versionRef, latestRef)...) // #nosec G204 | |||
| 136 | + | buildCmd.Stdout = os.Stdout | |||
| 137 | + | buildCmd.Stderr = os.Stderr | |||
| 138 | + | if err := buildCmd.Run(); err != nil { | |||
| 139 | + | return fmt.Errorf("docker buildx build failed: %w", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | fmt.Println("Pushing...") | |||
| 143 | + | if err := dockerPush(ctx, latestRef); err != nil { | |||
| 144 | + | return fmt.Errorf("failed to push %s: %w", latestRef, err) | |||
| 145 | + | } | |||
| 146 | + | if version != "latest" { | |||
| 147 | + | if err := dockerPush(ctx, versionRef); err != nil { | |||
| 148 | + | return fmt.Errorf("failed to push %s: %w", versionRef, err) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // buildImageArgs constructs the docker buildx build arguments. | |||
| 156 | + | // Uses --load instead of --push so the image is loaded into the local | |||
| 157 | + | // daemon, allowing docker push to handle the upload with chunked requests. | |||
| 158 | + | func buildImageArgs(version, platform string, buildArgs []string, versionRef, latestRef string) []string { | |||
| 131 | args := []string{ | 159 | args := []string{ | ||
| 132 | "buildx", "build", | 160 | "buildx", "build", | ||
| 133 | "--platform", platform, | 161 | "--platform", platform, | ||
| @@ -140,15 +168,8 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||||
| 140 | for _, ba := range buildArgs { | 168 | for _, ba := range buildArgs { | ||
| 141 | args = append(args, "--build-arg", ba) | 169 | args = append(args, "--build-arg", ba) | ||
| 142 | } | 170 | } | ||
| 143 | - | args = append(args, "--push", ".") | 171 | + | args = append(args, "--load", ".") |
| 144 | - | 172 | + | return args | |
| 145 | - | dockerCmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204 | |||
| 146 | - | dockerCmd.Stdout = os.Stdout | |||
| 147 | - | dockerCmd.Stderr = os.Stderr | |||
| 148 | - | if err := dockerCmd.Run(); err != nil { | |||
| 149 | - | return fmt.Errorf("docker buildx failed: %w", err) | |||
| 150 | - | } | |||
| 151 | - | return nil | |||
| 152 | } | 173 | } | ||
| 153 | 174 | ||||
| 154 | func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error { | 175 | func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error { | ||
M internal/adapters/in/cli/push_test.go +9 -0
| @@ -60,3 +60,12 @@ func TestParseImageRef(t *testing.T) { | |||
| 60 | 60 | }) | |
| 61 | 61 | } | |
| 62 | 62 | } | |
| 63 | + | ||
| 64 | + | func TestBuildAndPush_BuildArgs(t *testing.T) { | |
| 65 | + | // Verify buildImageArgs produces --load instead of --push | |
| 66 | + | args := buildImageArgs("v1.0.0", "linux/amd64", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 67 | + | ||
| 68 | + | assert.Contains(t, args, "--load") | |
| 69 | + | assert.NotContains(t, args, "--push") | |
| 70 | + | assert.Contains(t, args, "--platform") | |
| 71 | + | } | |
| @@ -60,3 +60,12 @@ func TestParseImageRef(t *testing.T) { | |||||
| 60 | }) | 60 | }) | ||
| 61 | } | 61 | } | ||
| 62 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | func TestBuildAndPush_BuildArgs(t *testing.T) { | |||
| 65 | + | // Verify buildImageArgs produces --load instead of --push | |||
| 66 | + | args := buildImageArgs("v1.0.0", "linux/amd64", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |||
| 67 | + | ||||
| 68 | + | assert.Contains(t, args, "--load") | |||
| 69 | + | assert.NotContains(t, args, "--push") | |||
| 70 | + | assert.Contains(t, args, "--platform") | |||
| 71 | + | } | |||