Philipp Lehmann @PhilippTheServer

Designing a Docker Image Build API: Job Model and Streamed Logs

A build takes minutes and produces output while it runs, so the API has to model both

The problem

The obvious first attempt at “an API that builds a Docker image” is one handler:

// Broken. Do not copy this.
func handleBuild(w http.ResponseWriter, r *http.Request) {
	resp, err := dockerClient.ImageBuild(r.Context(), r.Body, types.ImageBuildOptions{
		Tags: []string{"demo:latest"},
	})
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	io.Copy(w, resp.Body)
}

This blocks the HTTP request for as long as the build runs — anywhere from seconds to many minutes — and it gives the client no way to see anything until the whole response is written, because io.Copy here streams to the response, but there is nothing on the client side deciding to read it incrementally unless you already designed for that. Any reverse proxy or load balancer with an idle timeout shorter than the slowest build will kill the connection midway, and the build itself has no independent existence: if the HTTP request is cancelled, gets retried, or the client just closes the tab, there is no way to ask “is that build still running, and how did it go” — the only handle on the build was the request that started it.

The actual shape of the problem is that a build is not a request/response operation at all. It is a long-running job that emits output while it runs, and a client wants three things a single blocking call cannot give: an immediate acknowledgement that the build started, a way to watch its output live, and a way to ask about its final status later, independent of whether anyone was watching when it happened.

Working through it

Separating “start” from “watch”

The fix is to stop treating the build as one HTTP exchange and split it into two concerns: a job that exists independently of any particular HTTP connection, and any number of HTTP connections that observe it. POST /builds creates the job and returns immediately with an identifier; the actual ImageBuild call runs in a goroutine that outlives the request. GET /builds/{id}/logs and GET /builds/{id} are read-only views onto whatever that goroutine has produced so far.

Giving every log line a home before anyone is watching

Because build output starts arriving before a client necessarily connects to watch it — or after that client has disconnected and a different one has taken over — the log cannot live only “on the wire” of one HTTP response. It has to accumulate in the job itself, so a new watcher can catch up on everything that already happened, and a slow watcher does not block the build by failing to read fast enough.

Streaming to a client without polling

Once the log lives in the job, a streaming handler is a loop: hand the client whatever is new since they last checked, and when there is nothing new, wait to be woken up rather than either blocking forever or polling in a spin loop. A small broadcast primitive — a channel that gets closed and replaced every time new data arrives — lets any number of waiters wake up on new data without a busy loop and without a fixed poll interval imposing an artificial delay on live output.

Reading the Docker daemon’s own build stream correctly

ImageBuild returns before the build is finished; the returned Body is a stream of newline-delimited JSON objects, one per build step or log line, and — this is the part that is easy to miss — a failed build step is not necessarily reported as a Go error from ImageBuild itself. It shows up as an error field inside one of the streamed JSON messages. A job model that only checks the function’s returned error will report a build that failed at RUN false as a success, because the HTTP call to start the build succeeded even though the build did not.

The solution

A complete build API: a job store with broadcast-based log streaming, and the Docker SDK calls wired up correctly, including detecting an in-stream build failure.

// main.go
package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"sync"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
)

// --- Job model -------------------------------------------------------------

type Job struct {
	ID string

	mu     sync.Mutex
	log    []byte
	status string // "running", "success", "failed"
	notify chan struct{}
}

func newJob(id string) *Job {
	return &Job{ID: id, status: "running", notify: make(chan struct{})}
}

func (j *Job) append(p []byte) {
	j.mu.Lock()
	j.log = append(j.log, p...)
	old := j.notify
	j.notify = make(chan struct{})
	j.mu.Unlock()
	close(old)
}

func (j *Job) finish(status string) {
	j.mu.Lock()
	j.status = status
	old := j.notify
	j.notify = make(chan struct{})
	j.mu.Unlock()
	close(old)
}

// snapshot returns any log bytes after `from`, whether the job is finished, and a
// channel that closes the next time either changes — so a caller with nothing new to
// read can wait on it instead of polling.
func (j *Job) snapshot(from int) (chunk []byte, status string, notify chan struct{}) {
	j.mu.Lock()
	defer j.mu.Unlock()
	if from < len(j.log) {
		chunk = j.log[from:]
	}
	return chunk, j.status, j.notify
}

// --- Job store ---------------------------------------------------------------

type Store struct {
	mu   sync.Mutex
	jobs map[string]*Job
}

func newStore() *Store { return &Store{jobs: map[string]*Job{}} }

func (s *Store) create() *Job {
	id := randomID()
	job := newJob(id)
	s.mu.Lock()
	s.jobs[id] = job
	s.mu.Unlock()
	return job
}

func (s *Store) get(id string) (*Job, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	job, ok := s.jobs[id]
	return job, ok
}

func randomID() string {
	b := make([]byte, 8)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}

// --- Build execution ---------------------------------------------------------

type buildMessage struct {
	Stream string `json:"stream"`
	Error  string `json:"error"`
}

func runBuild(cli *client.Client, job *Job, buildContext io.Reader, tag string) {
	resp, err := cli.ImageBuild(context.Background(), buildContext, types.ImageBuildOptions{
		Tags:   []string{tag},
		Remove: true,
	})
	if err != nil {
		job.append([]byte(fmt.Sprintf("could not start build: %v\n", err)))
		job.finish("failed")
		return
	}
	defer resp.Body.Close()

	failed := false
	dec := json.NewDecoder(resp.Body)
	for {
		var msg buildMessage
		if err := dec.Decode(&msg); err != nil {
			if err != io.EOF {
				job.append([]byte(fmt.Sprintf("stream error: %v\n", err)))
				failed = true
			}
			break
		}
		if msg.Stream != "" {
			job.append([]byte(msg.Stream))
		}
		if msg.Error != "" {
			job.append([]byte("ERROR: " + msg.Error + "\n"))
			failed = true
		}
	}

	if failed {
		job.finish("failed")
	} else {
		job.finish("success")
	}
}

// --- HTTP handlers -------------------------------------------------------------

type server struct {
	store  *Store
	docker *client.Client
}

func (s *server) createBuild(w http.ResponseWriter, r *http.Request) {
	tmp, err := os.CreateTemp("", "build-context-*.tar")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if _, err := io.Copy(tmp, r.Body); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	_ = tmp.Close()

	job := s.store.create()

	go func() {
		defer os.Remove(tmp.Name())
		f, err := os.Open(tmp.Name())
		if err != nil {
			job.append([]byte(fmt.Sprintf("could not reopen build context: %v\n", err)))
			job.finish("failed")
			return
		}
		defer f.Close()
		runBuild(s.docker, job, f, "build-api-demo:"+job.ID)
	}()

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusAccepted)
	json.NewEncoder(w).Encode(map[string]string{"id": job.ID})
}

func (s *server) getBuild(w http.ResponseWriter, r *http.Request) {
	job, ok := s.store.get(r.PathValue("id"))
	if !ok {
		http.NotFound(w, r)
		return
	}
	_, status, _ := job.snapshot(0)
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"id": job.ID, "status": status})
}

func (s *server) streamLogs(w http.ResponseWriter, r *http.Request) {
	job, ok := s.store.get(r.PathValue("id"))
	if !ok {
		http.NotFound(w, r)
		return
	}
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusOK)

	offset := 0
	for {
		chunk, status, notify := job.snapshot(offset)
		if len(chunk) > 0 {
			w.Write(chunk)
			flusher.Flush()
			offset += len(chunk)
			continue
		}
		if status != "running" {
			return
		}
		select {
		case <-notify:
		case <-r.Context().Done():
			return
		}
	}
}

func main() {
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		log.Fatal(err)
	}

	s := &server{store: newStore(), docker: cli}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /builds", s.createBuild)
	mux.HandleFunc("GET /builds/{id}", s.getBuild)
	mux.HandleFunc("GET /builds/{id}/logs", s.streamLogs)

	log.Println("listening on :8081")
	log.Fatal(http.ListenAndServe(":8081", mux))
}
go.mod (skeleton — run `go mod tidy` to resolve exact transitive versions)
module docker-build-api

go 1.22
go mod init docker-build-api
go get github.com/docker/docker@v24.0.9
go mod tidy
go build -o build-api .

This talks to whatever Docker daemon DOCKER_HOST points at (the default, unix:///var/run/docker.sock, is picked up automatically by client.FromEnv), so run it on a machine that already has Docker installed — no daemon-in-a-container needed for the demo.

# demo-context/Dockerfile — a trivial image to build through the API
FROM alpine:3.20
RUN echo "built through the streaming build API" > /note.txt
CMD ["cat", "/note.txt"]
./build-api &

cd demo-context && tar -cf ../context.tar Dockerfile && cd ..

BUILD_ID=$(curl -s -X POST --data-binary @context.tar \
  -H "Content-Type: application/x-tar" localhost:8081/builds | jq -r .id)
echo "started build $BUILD_ID"

curl -N localhost:8081/builds/$BUILD_ID/logs &
wait $!

curl -s localhost:8081/builds/$BUILD_ID
started build 4f9a1c2b7e3d5061
Step 1/2 : FROM alpine:3.20
Step 2/2 : RUN echo "built through the streaming build API" > /note.txt
{"id":"4f9a1c2b7e3d5061","status":"success"}

Break the Dockerfile deliberately — RUN false — rerun the same sequence, and the log stream shows the daemon’s ERROR: line while GET /builds/{id} reports "status":"failed", confirming the in-stream failure is caught even though ImageBuild’s own Go error was nil.

Conclusion

A long-running operation is a resource, not a request. The moment an operation outlives the connection that started it, it needs an identity of its own — an ID, a status, a log — independent of any single client watching it.

Streaming to an unknown number of watchers means the source of truth cannot be the wire. The log has to live in the job so a watcher who connects late, or a second watcher entirely, sees the same thing a watcher who was there from the start would have.

Trust the data in the stream over the function call that started it. Any SDK wrapping a long-running external process can report success at the call-site while the underlying operation reports failure in its own output. Parse what the operation itself says happened; do not assume the wrapper already did.