Skip to main content

Command Palette

Search for a command to run...

Day 3 of #40daysofkubernetes challenge

Multi stage build and Docker Best practices

Updated
5 min readView as Markdown
Day 3 of #40daysofkubernetes challenge

As part of the 40 days Kubernetes challenge Day 3, I have learnt the multi-stage containerization and best practices in Dockerization and containerization.

As part of the learning I have created a multi-stage Dockerfile for a simple todo app which runs on Go, and for the best practices I have gone through the official Docker documentation. So in this blog we will be looking into the Dockerfile created for the todo application and also the best practices for containerization according to me.

Multi-stage build

The multi-stage build is basically the concept of having only the things that are needed to run the application inside the final image.

What I mean by that is, in the previous example we used Alpine, which is a lightweight Linux image, for running our Python application and it came to about 135 MB. But if we look closer, we only had a simple Python + HTML application in it. Most of that size is the Python interpreter and its standard library, which the application genuinely needs to run.

That is the interesting part. For an interpreted language like Python, the things needed to build and the things needed to run are almost the same, so there is not much to separate out. But for a compiled language like Go, they are completely different. The Go toolchain is needed only to produce the binary, and once the binary exists the toolchain is dead weight.

This is where the multi-stage build comes into the picture. Let's see it with an example.

# Builder stage: has the full Go toolchain, used only to compile the binary.
FROM golang:1.23-alpine AS builder

# All subsequent paths are relative to this directory inside the build container.
WORKDIR /app

# Copy only go.mod first so the dependency layer is cached independently of source changes.
COPY go.mod ./

# Download dependencies now, as their own layer, so this step is skipped on rebuilds unless go.mod changes.
RUN go mod download

# Now bring in the rest of the source, since dependencies are already resolved.
COPY . .

# Build a fully static binary: CGO_ENABLED=0 removes the libc dependency, GOOS=linux targets the scratch image.
RUN CGO_ENABLED=0 GOOS=linux go build -o day3-todo .

# Final stage: scratch has no OS, shell, or libraries, keeping the image minimal and reducing attack surface.
FROM scratch

# Bring in nothing but the compiled binary from the builder.
COPY --from=builder /app/day3-todo /day3-todo

# Run as a non-root numeric UID, since scratch has no /etc/passwd to resolve a named user against.
USER 10001

# Document the port the app listens on.
EXPOSE 8080

# Exec-form CMD runs the binary directly as PID 1, without a shell that scratch doesn't have.
CMD ["/day3-todo"]

Here in this piece of code, the line that needs our attention is this one.

FROM scratch

Every FROM starts a new stage. What makes scratch special is that it is an empty image - no files, no shell, no libraries, nothing at all. So the second stage starts with an empty filesystem, and the only thing that goes into it is the compiled binary which we copy over with COPY --from=builder.

The Alpine image and the Go toolchain from the first stage are left behind on the build machine. They are not deleted, they are simply not part of the final image. That is what brought the image size down from

135 MB to 11.8 MB

Best practices in Dockerization

User creation

By default a container runs as root, which means if someone exploits our application they are root inside the container. So we create a normal user with no password, no home directory and no login shell, and run the app as that user. In images like scratch there is no adduser at all, so we just give a numeric UID like USER 10001 and the kernel accepts it.

Reusing stages

If we have multiple images that share the same setup, we can put that setup in one stage and base the other stages on it using FROM common AS api. Docker builds the common stage only once, so the derived images share the same layers instead of each carrying their own copy. It also means bumping a version is a one line change in one place instead of the same edit repeated in three files.

Multi-line RUN commands

When a RUN installs multiple packages, we write them one per line in alphabetical order so duplicates are easy to spot and the diffs stay readable. More importantly, apt-get update and apt-get install must be in the same RUN, otherwise Docker caches the update step and we end up installing packages from an outdated index. Keeping them together also means one layer instead of two.

EXPOSE does not open the port

This one caught me out. EXPOSE 8080 does not publish anything to the outside world, it is only documentation saying which port the application listens on. The -p 8080:8080 flag in docker run is what actually forwards traffic into the container, and the app also has to bind to 0.0.0.0 instead of 127.0.0.1, otherwise it is listening on an address that nothing from outside ever reaches.

Never put secrets in ENV

Layers are append-only, so setting a value with ENV and then unsetting it in a later RUN does not remove it. The earlier layer still holds it, and anyone who pulls the image can read it back with docker history. If a value has to stay out of the image, it belongs in a runtime -e flag or a build secret mount, never in an ENV line.

40 Days of kubernetes challenge

Part 3 of 5

In this series of blog I am going to share my experience of learning by doing and also posting my learning public. For the content I am using Tech Tutorials with Piyush Youtube channel playlist.

Up next

Day 4 of #40daysofkubernetes challenge

Baby steps towards Kubernetes - When and When not to use it