Announcing GoReleaser v2.5 - multi languages, 9th anniversary edition
Merry Christmas - the last release of 2024 is here!
Or: how to ship your app in a <20Mb container.
Well, as you may know, there is a good amount of people now building microservices in Go and deploying them as Docker containers.
I do not yet have a lot of experience with Go and Docker, but I’ll try to share what I learned while building and shipping an internal tool, here, at ContaAzul.
I will assume that you know at least a little bit of Go, and, for the sake of simplicity and brevity, I’ll just use a very basic example from the Go wiki:
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
If we compile this file, the binary will have 5.5MB:
$ go build
$ du -h example
5.5M example
Now, let’s dockerize this thing!
What I usually see is people starting with the official Golang image, but, we are talking about small containers here, so, let’s be hipsters and go with some Alpine-based image.
For those who don’t know, Alpine is a very minimalistic Linux distribution. It became “famous” (at least for me) because of its adoption in the Docker community, mainly, because its image is only 5MB in size.
We could probably use kiasaki/alpine-golang
but, seems like it still bundling Go 1.3, and, because I wanted to use Go 1.4+, I created a new image, based on kiasaki’s image, but with Go 1.4.2. [source]
Using my image, the Dockerfile
may look like this:
FROM caarlos0/alpine-go
WORKDIR /gopath/src/app
ADD . /gopath/src/app/
RUN go get -v app
ENTRYPOINT ["/gopath/bin/app"]
Now let’s build it:
$ docker build -t caarlos0/example-small .
# ...
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
caarlos0/example-small latest b300ec38662c 3 minutes ago 220.5 MB
caarlos0/alpine-go latest 1cd569752ed7 3 days ago 214.7 MB
alpine 3.2 31f630c65071 3 weeks ago 5.254 MB
Yes, from ~5MB (of the alpine image) to 220.5MB!!!!
Looking at the layers, we can see that the problem is that, well, the entire Go language and its tool, Git, Mercurial and a lot of stuff are bundled together with the image. We only need them to compile the program, but they will not be used anymore after that, and we can safely remove them.
I guess we could just add another RUN
statement removing all those stuff, right? WRONG!
Each instruction in the Dockerfile
ends up being a new layer, so, removing stuff from a previous layer in the current one will not have the desired effect in the final image size.
To fix that, instead of inheriting from caarlos0/alpine-go
, we’ll have to inherit directly from alpine
, install what we need to compile the app, compile the app, and, finally, remove what we don’t need anymore - all this in one single step.
So, we might end up with something like this:
FROM alpine:3.2
ENV GOROOT=/usr/lib/go \
GOPATH=/gopath \
GOBIN=/gopath/bin \
PATH=$PATH:$GOROOT/bin:$GOPATH/bin
WORKDIR /gopath/src/app
ADD . /gopath/src/app
RUN apk add -U git go && \
go get -v app && \
apk del git go && \
rm -rf /gopath/pkg && \
rm -rf /gopath/src && \
rm -rf /var/cache/apk/*
ENTRYPOINT ["/gopath/bin/app"]
And, vòilá:
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
example-smaller latest 6c4f2066e02e 9 seconds ago 11.43 MB
alpine 3.2 31f630c65071 3 weeks ago 5.254 MB
Just 11.43MB.
I think that’s literally the smaller you can get.
It works very well as a dev environment. It also don’t actually hurt to have all those stuff in your Docker image if you don’t care about the image size.
But, you know, the smaller the container, the faster the push and the pull. Besides, more free HD space is always a good thing.
You might have noticed that I’m removing the entire gopath/src
folder in the Dockerfile
. This is not intended to free the space used by the app source itself, but to free us the space used by the sources of the dependencies we got with go get -v app
.
I also remove the entire gopath/pkg
folder for the same reason.
This two actions might have no effect in this particular example, but I decided to leave them there as an idea of what you can do in the so-called “real world apps”.
You can also, of course, compile your app outside the container and just ADD
the binary to it. To do that you need to pay attention and compile it for the same ARCH
and OS
, like, and, of course, have the right Go installed:
$ GOARCH=i386 GOOS=linux go build
The downside of this approach is that Docker Hub will not be able to automatically build this, if you don’t care about that (pushing containers by hand), go for it.
As a final PROTIP™, pay attention to what you ADD
to your image. You might want to remove the .git
folder, binaries, unused files and everything you don’t need. You can do this using a .dockerignore
file. Reference.
By the way, the app code is available on Github. Go for it!
That’s all folks. 🍻