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-multistagemain.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.222. 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
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
4. Confirm the small one actually runs
docker run --rm -d -p 8080:8080 --name lab-svc lab-multistage
curl -s localhost:8080/healthzVerify
5. See what you gave up
Try to get a shell in the distroless image:
docker exec -it lab-svc shIt 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-svcVerify
Clean up
docker rm -f lab-svc
docker image rm lab-multistage lab-naiveWhere 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.