CodeSolid

A Go Programming Notebook

Building a Docker Golang Container

One of the main benefits I get from blogging about software is that it gives me the opportunity to dust off and improve skills that I otherwise might not get too much opportunity to practice during my day job as a Java peasant. (If you don’t know, Java Peasants work in object factories).

Along these lines, I’ve set myself a goal of creating a non-trivial build pipeline for Kubernetes, consisting of a Go API server, a front end server running nginx, and perhaps an auth server (FusionAuth). The database layer is TBD but may not run in Kubernetes (except perhaps in development).

From Simple To Complex

I’m told that there’s this thing called Gall’s Law, that I’m quite fond of:

A complex system that works is invariably found to have evolved from a simple system that worked.

This really reflects the way I work. In this case, knowing that I ultimately want a bunch of containers running as Kubernetes pods, I started in the simplest way I could, with a basic container build. For that, we’ll need a simple Go app to test the build with.

A go.mod is always a good starting point:

go.mod:

module gorilla/example

We can point this to a repository later if we need to – for now it’s enough to prime the Go 1.11+ module system.

We’ll be needing a go application to serve. Here’s a tasty little morsel of a Go application. Yes, the port is hard-coded (oh the horror), but this is Hello World, not rocket surgery:

// main.go

package main

import (
	"net/http"

	"github.com/gorilla/mux"
)

func main() {

	r := mux.NewRouter()

	r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello world."))
	})

	http.ListenAndServe(":9000", r)
}

Just in case it’s not obvious from the code, this will stand up a web server, serving up a time honored message on port 9000. The gorilla library lets us have sophisticated routes later on, but for now it just tests that we can resolve modules as we’re building. It also lets us use “gorilla” in our container names – and heck, given the chance, why wouldn’t we want to do that!

OK so now we have an app. You can build it or run it locally if you want, but that’s no fun. We want to put it in a docker container and get it running from there.

A Naive Approach

We want a Dockerfile to build a container from our app. Keeping in mind Gall’s Law, we want to start with something simple that works, even if it’s not production-ready at this point. (Don’t worry, we’ll improve it a bit later in the article).

# Dockerfile

FROM golang
WORKDIR /app
ADD go.* /app/
ADD *.go /app/
RUN go build -o main . 
ENTRYPOINT ["/app/main"]

The FROM line grabs the latest GOLANG container from Dockerhub. In the next few lines we copy our go source and mod.go into our working directory, /app, on the container. Finally, we RUN go build to build our source into an executable named “main”, then with “ENTRYPOINT”, we set the executable to be what the container runs when it starts.

Let’s build it and see if it works. From the directory containing the Dockerfile, run:

docker build -t golang-gorilla:latest .

If all goes well this will build an image, tagging it with the name “golang-gorilla:latest”.

Next, let’s use this image to start a container, so our gorilla can serve up web requests.

The next command runs the golang-gorilla image in detached mode (“-d”), naming the container “gorilla”. As an example, we’ll map the container port (9000) to a different host port (8080):

docker run --name gorilla -p8080:9000 -d golang-gorilla

Once this is running, you can point a browser http://localhost:8080, or run this:

curl localhost:8080

This should output:

Hello world.

To see it running in the container:

docker exec -it gorilla curl http://localhost:9000

What’s Wrong with the Naive Approach, and How To Fix It

So if our Gorilla is off in the forest happily greeting visitors with “Hello world,” what’s the problem? Well, honestly, for a toy app like this, there is no problem, but for a real production app, there are two issues we want to solve. First, we’re basing our image off of “golang”, to both build and run our image. That makes the resulting image file much larger and more complex than it needs to be. Once we start storing it in Dockerhub or a similar repository, that means the download will take longer than it has to.

Let’s see how bad the problem is. Docker images will tell us some basic facts about an image, including the size:

docker images golang-gorilla
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
golang-gorilla      latest              67edf2eee9fa        8 hours ago         782MB

Wow! Almost a gigabyte. Talking gorilla notwithstanding, all we really have so far is Hello world. We can do better.

The second problem is that in order to build our image, we first copied our source code to it! We can prove to ourselves that it’s still there:

docker exec -it gorilla ls /app
go.mod	go.sum	main  main.go

This means that if anyone gets access to our server, in addition to the other havoc they can wreak, they’re going to have access to all our code. At this point we don’t care, of course,because the code’s in the article anyway, but for a production application that’s not what we want.

Fortunately, recent versions of Docker have an elegant and straightforward solution to this problem.

Multi-stage Builds

What we want is to be able to build our application with a first step, then copy only the files we need to the image. Since version 17.05, Docker has shipped with a feature called multi-stage builds. This feature that allows us to create a build using a preliminary image, then package the results into a much smaller – and much more secure – final image.

It works by allowing multiple “FROM” clauses, which can be based on completely different images. Earlier images can be as large as we want for our build tools, but we can copy the results to a more compact image as a final step. We can give the steps names in the FROM clause to make this process even easier.

To see how this works, let’s take our original code and modify our Dockerfile.

# File: Dockerfile
# Supports multi-stage build

FROM golang as builder
WORKDIR /app
COPY * /app/
RUN env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o server .

FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/server .
ENTRYPOINT ["/root/server"] 

This time we’re calling our executable “server”. I’ve also set up the build command with some environment variables I found I needed for Alpine. Two remaining differences in the first part from before is that we’ve named the result as “builder”, and we have no ENTRYPOINT (yet).

In the second part, we’re basing our image on alpine, a Linux distribution that’s popular in the Docker community for it’s small size and consequent “low attack surface”. What we do is set the WORKDIR, and simply copy the server executable we built in the first step to it. Naming the first step allows us to refer to it by name (“–from=builder”). Finally, the second step runs the resulting server.

Let’s build it as before, but since we expect it to be smaller, we’ll call it mini-gorilla.

docker build -t mini-gorilla:latest .

Did we save any space?

docker images mini-gorilla
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
mini-gorilla        latest              5ae087964c8d        53 seconds ago      13.2MB

Wow, from 782 Mb down to 13.2 Mb. Our original image was about 59 times as big as our second attempt. That’s pretty good!

Let’s run our new mini-gorilla container, giving it a new name and port:

docker run --name gorilla2 -p9000:9000 -d mini-gorilla

Is our app still working? Let’s check our server:

curl localhost:9000
Hello world.

All good. But what about our source code? Is that anywhere to be found in our container?

docker exec -it gorilla2 find / -name *.go

No output – that’s good, nothing to see here folks!

So there we are – we’ve done the simplest docker go build that will work at all, and we’ve improved on it somewhat with a multi-stage build, all using simple docker commands.

I’ve stood up a Github repo with the source code for you if you want to check it out (so to speak).

Some commands you can use in your environment to clean up are:

# Stop containers
docker stop gorilla
docker stop gorilla2

# Remove containers
docker rm gorilla 
docker rm gorilla2 

# Remove images
docker  image rm golang-gorilla:latest
docker image rm mini-gorilla:latest