While working on a separate project, I needed to create a simple echoserver. As I wanted to run it inside container, I decided to use pretty standard flow of multi-stage build. But then I thought: It would be good to write something about it.
What is a multi-stage build
It’s a technique for building container image that allows to drastically reduce the size of container image. That’s pretty much all the theory. But what does it mean in practice?
Standard container images comes with quite big amount of pakcages included, that will not be used during application uptime. They will just “lie and get dusty”, but in the same time, at least some of them may be required for building process.
So it owuld be the best, to have them during build time, but also remove them for the final image.
But this have a small downside.
Few bits of theory
To achieve that we need to make sure that our application will work properly without any dependencies. That is, it must be “linked statically”. To be precisely simple: we must elimnate all external dependencies. To avoid unnecessary increasement in size, most applications dynamically load “shared libraries”. These are packages that are present inside the operating system, or being installed with some other programs (like Windows Redistributables, which had to be installed to pretty much any game about 10-15 years ago)
Statically linked application have all dependencies packed inside the executable file.
That’s a bit counterintuitive: in order to reduce the image size of container at all, we need to increase the size of application.
But that’s enough of theory.
Let’s do some comparison.
I’ve mentioned creating simple echoserver application for other project that I’m woirking on. It’s created in Golang, and that’s exactly what I wanted to have - no external dependencies.
You can find it on my github.
Comparison
Let’s begin by building the echoserver, using Golang compiler, as it is. You will se direct output from my terminal
ls -lh echoserver
-rwxrwxrwx 1 andrzej andrzej 369 Feb 27 21:40 Dockerfile
-rwxrwxrwx 1 andrzej andrzej 565 Feb 27 21:40 README.md
-rwxrwxrwx 1 andrzej andrzej 7.8M Feb 27 21:40 echoserver
-rwxrwxrwx 1 andrzej andrzej 89 Feb 27 21:40 go.mod
-rwxrwxrwx 1 andrzej andrzej 163 Feb 27 21:40 go.sum
-rwxrwxrwx 1 andrzej andrzej 1.1K Feb 27 21:40 main.go
Ls command parameters (always worth to remind the basics)
-l- means long - outputs access rights, owner, group, size and modification date-h- mean human - output file/dir sizes in more human-readable units, here megabytes instead of bytes
So, the compiled binary of echoserver is 7.8 megabytes in size. Source code for this file have 52 lines, and uses one binary cloned from github. I guess that using net/http from standard packages inside golang could reduce the size further, but this doesn’t really matter right now.
Container sizes
Inside dockerfile of this project - i’ve used alpine linux based image. As we can find on dockerhub:
A minimal Docker image based on Alpine Linux with a complete package index and only 5 MB in size!
I’ve used alpine 3.23.3, which was the newest, except from edge (I assume this is pretty much the same as latest) at the moment of creating this article.
According to my list of container images:
docker image ls | grep alpine
alpine:3.23.3 a40c03cbb81c 8.44MB 0B
A bit more than I’ve expected, but if we compare this to Ubuntu, for example (ubuntu:26.04)
docker image ls | grep ubuntu
ubuntu:26.04 ec9077217931 88.2MB 0B
88.2 MB - That’s 10x the size of alpine.
It’s pretty clear why anybody should prefer using alpine, or any else minimal distro as a base for container, right?
So let’s see the size of echoserver container after building it using provided Dockerfile:
docker image ls | grep echoserver
echoserver:1.0.0 e958e8eca88c 17.4MB 0B U
Looks legit. 7.8 + 8.44MB = 16.2MB, plus some overhead for bash and othe stuff. Not bad at all.
Even more radical way
Let’s try to create even smaller image. We can do it by using “scratch” as a base image. “Scratch” is a special tag used for image that is just empty. No shell, no package manager, nothing. Just blank space for application.
Let’s start by modyfing the Dockerfile a bit:
ARG SERVER_PORT=8080
ARG BASE_IMAGE=golang:1.26.0
ARG ALPINE_IMAGE=scratch # replaced alpine with scratch
# build stage
FROM ${BASE_IMAGE} AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o echoserver .
# minimal runtime with alpine
FROM ${ALPINE_IMAGE}
WORKDIR /app
COPY --from=builder /app/echoserver .
EXPOSE ${SERVER_PORT}
CMD ["./echoserver"]
I’ve called it “minimal” during my build:
docker build -t echoserver:minimal .
And the result is:
docker image ls | grep echoserver
echoserver:1.0.0 e958e8eca88c 17.4MB 0B U
echoserver:minimal 9ec53ab7757f 8.95MB 0B
Looks like the size in this case about 50% less than previous one. That’s nice result, but we need to remember that this is not for free. Total minimalism like this comes with potental difficulties with debugging. There is no shell, no packages. Just plain application, and it’s logs. If everything is fine, then you will receive lower bill for you container repository.
But if something goes wrong, problems with debugging can turn into a nightmare. Copying execs to container, moving files around, if something crashes, you will need to start all over again. That’s not fun. But sometimes can be worth it.
Just don’t do it blindly.
Conclusion
Multi stage builds are brilliant. And so does minimalism in container image architecture, as size is not only factor that we should tak into consideration here. Less packages also mean less potential vulnerabilites, but so does more problems with debugging. This is a tradeoff. But can be worth it, especially if you know what are you doing.