Skip to content
Docker
Lab 2 of 11·35mBeginner

Build a small image with a multi-stage Dockerfile

Build the same Go service two ways and measure why the build toolchain should not ship to production.

You need

  • Docker Engine 24+ or Docker Desktop
  • A terminal

Do first

A naive Dockerfile ships the compiler, the package cache, and the source code to production. A multi-stage build compiles in one image and copies only the binary into another. You are going to build both and compare the numbers, because the difference is large enough to be convincing rather than theoretical.

1. Make a service to build

Create an empty directory and three files.

mkdir -p ~/labs/docker-multistage && cd ~/labs/docker-multistage

main.go:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

go.mod:

module lab/multistage

go 1.22

2. Build it the obvious way

Dockerfile.naive:

FROM golang:1.22-alpine
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN go build -o /bin/server .
CMD ["/bin/server"]
docker build -f Dockerfile.naive -t lab-naive .

This works. It is also carrying an entire Go toolchain into every deployment.

Verify

docker image ls lab-naive --format '{{.Size}}' # somewhere around 250-350MB

3. Build it in two stages

Dockerfile:

FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/server .

FROM gcr.io/distroless/static-base:nonroot
COPY --from=build /bin/server /server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Three things are doing the work. CGO_ENABLED=0 produces a statically linked binary, so the final image needs no libc. --from=build copies one file out of the first stage and leaves everything else behind. And the runtime base has no shell, no package manager, and no toolchain.

docker build -t lab-multistage .

Verify

docker image ls --format '{{.Repository}} {{.Size}}' | grep lab- # lab-multistage is single-digit MB; lab-naive is 30-50x larger

4. Confirm the small one actually runs

docker run --rm -d -p 8080:8080 --name lab-svc lab-multistage
curl -s localhost:8080/healthz

Verify

curl -s localhost:8080/healthz # ok

5. See what you gave up

Try to get a shell in the distroless image:

docker exec -it lab-svc sh

It fails — there is no shell to exec. That is the trade: a much smaller attack surface, and no debugging from inside the container. In production you debug it from the outside, with logs and an ephemeral debug container:

docker logs lab-svc

Verify

docker exec -it lab-svc sh # "exec: \"sh\": executable file not found" # expected — the image has no shell

Clean up

docker rm -f lab-svc
docker image rm lab-multistage lab-naive

Where this goes next

Two images, one service, a 30-fold size difference and a much smaller attack surface. The next lab stops treating a container as a single thing and gives this service a database to talk to over a network you define.