When you start packaging your application in docker containers you may or may not have noticed the size of your images. Building images is not that hard if you know what the app needs.
In this post I will take a basic HelloWorld golang
example and guide you from
an image that is 812MB to an image that is 2.01MB.
Note that the language does not make a difference and the following approach can
be done with any language.
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
Getting a working build
Let’s build a Dockerfile
that will create an image containing the binary.
First, we start by locating a community image that can build golang binaries.
I would suggest depending on official images whenever possible. To
find these navigate to docker hub and use the Official Images
1.
It is best practice to lock your images to a specific version tag. By doing this
you ensure your builds are consistent and reproducible.
FROM golang:1.12.14-buster
Next is to set the workdir
for your build. This changes to (and creates if
needed) a directory. It’s a good idea to keep this directory consistent over
all your projects.
WORKDIR /app
Now it’s time to add the source to the image. Nothing special here, make
sure you include all files needed to build your application. For projects that
have local libraries like node_modules
for node projects, these should not be
added. You can run npm
inside the image.
COPY main.go /app/
After all the source files are added to the image, it’s time to build the artifact.
RUN go build main.go
As a final step, we will add a command
to define how to run the image.
CMD ["/app/main"]
If you followed the previous steps, you should have something like this:
FROM golang:1.12.14-buster
WORKDIR /app
COPY main.go /app
CMD ["/app/main"]
Now we can build the image and test it. While we are at it, let’s also have a look at the size of the image we just created.
$ docker build -t docker-multistage:1 .
Sending build context to Docker daemon 3.072kB
Step 1/5 : FROM golang:1.12.14-buster
...
Successfully built 8dea0fad83db
Successfully tagged docker-multistage:1
$ docker run docker-multistage
Hello world
$ docker images docker-multistage:1
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-multistage 1 8dea0fad83db 2 seconds ago 812MB
Smaller build image
A first thing we can do, it picking a smaller golang image. Most official images
have a choice of multiple platforms. We will pick alpine
here as it’s a Linux
distribution with a very small footprint.
Let’s update
FROM golang:1.13.5-alpine
If we rebuild the image using our new base image, it will be smaller.
$ docker build -t docker-multistage:2 .
Building docker-multistage:2
Sending build context to Docker daemon 3.072kB
Step 1/5 : FROM golang:1.13.5-alpine3.10
...
---> cb95a5047faf
Successfully built cb95a5047faf
Successfully tagged docker-multistage:2
$ docker images docker-multistage
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-multistage 1 8dea0fad83db 1 minute ago 812MB
docker-multistage 2 cb95a5047faf 4 seconds ago 361MB
By using a smaller base image we’re able to reduce the image size by 451MB. So far, we have not used any multistage build features, so let’s do that now.
Multistage
By adding AS <name>
to the FROM
statement, we’re able to reference this at
a later stage.
FROM golang:1.13.5-alpine AS build
In the same Dockerfile
we can add another FROM
statement using a small
alpine
image. Note that this image only contains the runtime needed for the
project and no additional build tools. In the case of golang this will be an
empty os. If you are using php
, you can use just the interpreter
and can leave any tooling like composer
out. In the case of java
you can use
the jre
version of the java runtime instead of the jdk
you may use for
building the image.
FROM alpine:3.10.3
COPY --from=build /app/main /app/main
Now your Dockerfile
should look like this:
FROM golang:1.13.5-alpine AS build
WORKDIR /app
COPY main.go /app
RUN go build main.go
FROM alpine:3.10.3
COPY --from=build /app/main /app/main
CMD ["/app/main"]
We did not define a CMD
in the build
image. This is because we will
not be running the build
image.
$ docker build -t docker-multistage:3 .
Sending build context to Docker daemon 3.072kB
Step 1/7 : FROM golang:1.13.5-alpine AS build
...
Step 5/7 : FROM alpine:3.10.3
...
Successfully built 39b9e1ffb59a
Successfully tagged docker-multistage:3
$ docker images docker-multistage
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-multistage 1 8dea0fad83db 2 minutes ago 812MB
docker-multistage 2 cb95a5047faf 1 minute ago 361MB
docker-multistage 3 39b9e1ffb59a 4 seconds ago 7.56MB
If we look at our newly created image, it just got a lot smaller. This is
because we only added the alpine
os and our artifact, no additional packages
are included in the image.
Scratch
Although we already have a very small image, we can do even better. This option however, is most likely not be possible for most applications.
We can update our runtime base image to scratch
. This is an image made for
base images and super minimal images that contain a single binary. Because of
the nature of golang, this is possible.
FROM scratch
$ docker build -t docker-multistage:4 .
Sending build context to Docker daemon 3.072kB
Step 1/7 : FROM golang:1.13.5-alpine AS build
...
Successfully built 42ef8addf7b5
Successfully tagged docker-multistage:4
$ docker images docker-multistage
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-multistage 1 8dea0fad83db 3 minutes ago 812MB
docker-multistage 2 cb95a5047faf 2 minutes ago 361MB
docker-multistage 3 39b9e1ffb59a 1 minute ago 7.56MB
docker-multistage 4 42ef8addf7b5 2 seconds ago 2.01MB
By looking at the result, we now see the image has about the same size as the original artifact we are building.