Build apps with BuildKit so build stages stop piling up (closes #220)
Check / check (pull_request) Successful in 3m24s
Check / check (pull_request) Successful in 3m24s
Multi-stage app builds left an untagged image for every build stage after each deploy, and nothing could safely remove them. upaas now asks Docker for a BuildKit build, which keeps stages in Docker's build cache instead of as images; Docker limits and cleans that cache itself. The final image is still tagged `upaas-<app>:<N>`, so old-image cleanup is unchanged. BuildKit's progress messages are decoded and written to the deployment log as plain text. Deviation: adds `github.com/moby/buildkit` v0.16.0 (matching the pinned Docker client), added with `go get` since no make target adds dependencies. Judgement call: the cache limit is on by default only from Docker Engine 28.2; older engines need the `daemon.json` setting the README now names. Model: opus-5-5
This commit was merged in pull request #221.
This commit is contained in:
+61
-15
@@ -4,6 +4,7 @@ package docker
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,7 +23,11 @@ import (
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/go-connections/nat"
|
||||
controlapi "github.com/moby/buildkit/api/services/control"
|
||||
buildkitclient "github.com/moby/buildkit/client"
|
||||
"github.com/moby/buildkit/util/progress/progressui"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
@@ -603,8 +608,11 @@ func (c *Client) performBuild(
|
||||
}
|
||||
}()
|
||||
|
||||
// Build image
|
||||
// Build with BuildKit: the stages of a multi-stage build are kept in
|
||||
// its build cache, which Docker limits on its own, instead of being
|
||||
// left behind as untagged images.
|
||||
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
|
||||
Version: dockertypes.BuilderBuildKit,
|
||||
Dockerfile: opts.DockerfilePath,
|
||||
Tags: opts.Tags,
|
||||
Remove: true,
|
||||
@@ -622,7 +630,7 @@ func (c *Client) performBuild(
|
||||
}()
|
||||
|
||||
// Stream build output line by line for real-time log updates
|
||||
err = c.streamBuildOutput(resp.Body, opts.LogWriter)
|
||||
err = c.streamBuildOutput(ctx, resp.Body, opts.LogWriter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -647,28 +655,66 @@ const scannerInitialBufferSize = 64 * 1024 // 64KB
|
||||
// (base64 layers can be large).
|
||||
const scannerMaxBufferSize = 1024 * 1024 // 1MB
|
||||
|
||||
// streamBuildOutput reads Docker build output line by line and writes to
|
||||
// stdout and optional log writer. Docker sends newline-delimited JSON, so
|
||||
// reading line by line ensures each log entry is written immediately.
|
||||
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
|
||||
// streamBuildOutput reads Docker build output line by line and writes it to
|
||||
// stdout and the optional log writer as it arrives. Docker sends
|
||||
// newline-delimited JSON. BuildKit's progress arrives encoded in
|
||||
// "moby.buildkit.trace" messages; these are decoded and written as plain
|
||||
// text, as "docker build --progress=plain" shows it. Other lines, such as
|
||||
// build errors, are written unchanged.
|
||||
func (c *Client) streamBuildOutput(
|
||||
ctx context.Context,
|
||||
body io.Reader,
|
||||
logWriter io.Writer,
|
||||
) error {
|
||||
out := io.Writer(os.Stdout)
|
||||
if logWriter != nil {
|
||||
out = io.MultiWriter(os.Stdout, logWriter)
|
||||
}
|
||||
|
||||
display, err := progressui.NewDisplay(out, progressui.PlainMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create build progress display: %w", err)
|
||||
}
|
||||
|
||||
statuses := make(chan *buildkitclient.SolveStatus)
|
||||
displayDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(displayDone)
|
||||
// The display must keep reading until statuses is closed, even
|
||||
// after ctx is cancelled, or the loop below would block.
|
||||
_, _ = display.UpdateFrom(context.WithoutCancel(ctx), statuses)
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(body)
|
||||
buf := make([]byte, 0, scannerInitialBufferSize)
|
||||
scanner.Buffer(buf, scannerMaxBufferSize)
|
||||
|
||||
newline := []byte{'\n'}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
// Write to stdout
|
||||
_, _ = os.Stdout.Write(line)
|
||||
_, _ = os.Stdout.Write(newline)
|
||||
// Write to log writer if provided
|
||||
if logWriter != nil {
|
||||
_, _ = logWriter.Write(line)
|
||||
_, _ = logWriter.Write(newline)
|
||||
|
||||
var msg jsonmessage.JSONMessage
|
||||
|
||||
err = json.Unmarshal(line, &msg)
|
||||
if err == nil && msg.ID == "moby.buildkit.trace" && msg.Aux != nil {
|
||||
var data []byte
|
||||
|
||||
var status controlapi.StatusResponse
|
||||
|
||||
if json.Unmarshal(*msg.Aux, &data) == nil && status.Unmarshal(data) == nil {
|
||||
statuses <- buildkitclient.NewSolveStatus(&status)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// One write per line, so it is not split by the display's output.
|
||||
_, _ = fmt.Fprintf(out, "%s\n", line)
|
||||
}
|
||||
|
||||
close(statuses)
|
||||
<-displayDone
|
||||
|
||||
scanErr := scanner.Err()
|
||||
if scanErr != nil {
|
||||
return fmt.Errorf("failed to read build output: %w", scanErr)
|
||||
|
||||
Reference in New Issue
Block a user