Put your app in a container

You'll learn
  • Why and how to put your app in a Docker container.

Excellent, your app is now running and almost ready to be deployed. There's just one step missing: putting your app inside a Docker container.

Wait, why?

Why would I want to do that? Isn't Go supposed to build one binary that's easy to ship?, you might be thinking to yourself right about now. First of all, excellent thought if you did, and no worries if you didn't.

Yes, of one Go's strengths is that it builds a binary that you can just copy around and run wherever you want, without any extra dependencies. But when building for the cloud, putting the binary inside a container is really useful. That's because in the cloud, the container is often the unit of execution.

The unit of what now?

When I say unit of execution, I mean that all cloud providers have a way of running a container, no matter what's inside the container. That's great for us developers, because we can be sure that we can run whatever we need in the cloud, whether it's a Go program or something we've written in COW.

Putting the binary inside a container therefore makes it really easy to run our Go app on any major cloud provider, and they will never know that it's written in Go. As a bonus, if you ever need to run external programs as part of your app, you can just include them in the container.

I'm convinced, let's do this

I'm happy to hear that. Let's get started! I'm going to start with showing you the Dockerfile that you need. As always, you can check out the branch to see the finished result with:

$ git fetch && git checkout --track golangdk/container

See the diff on Github.

The Dockerfile

Dockerfile
FROM golang:1-bullseye AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -x COPY . ./ RUN GOOS=linux GOARCH=amd64 go build -ldflags="-X 'main.release=`git rev-parse --short=8 HEAD`'" -o /bin/server ./cmd/server FROM gcr.io/distroless/base-debian11 WORKDIR /app COPY --from=builder /bin/server ./ CMD ["./server"]

That doesn't look too bad, does it? But you don't actually need to know what's going on here. It's enough to know that this is a configuration file that builds your app into a container. If you're not interested, feel free to skip to Building the container below. Otherwise, read on.

Some things to understand first

There are a few things to understand about this Dockerfile first.

The first has to do with how Docker builds images. This file uses something that's called a multi-stage build in Docker, which is just a fancy way of saying that we can build multiple Docker images defined in the same file. In this case, we use one image to build our Go application, and another for running it. We do it this way so we don't have to include our source code, the Go compiler etc. in our final image.

The second thing has to do with caching. To speed up the build process, Docker caches the result of each line of a Dockerfile. That means that we don't necessarily run all the commands in a Dockerfile on each build, but instead re-use the results of previous lines. Therefore, the order of lines is important, and you should generally do things that don't change very often earlier in the Dockerfile than things that do change more often. One common example is pulling dependencies before building your app from source.

Okay, so with that out of the way, let's go through the file.

The builder image line by line

Let's start with the image that builds your application.

FROM golang:1-bullseye AS builder tells Docker to pull the offical Go image as a base image for our build process. In this case, we use the tag 1-bullseye to ensure that we build with Go 1.x in Debian Bullseye. We call the resulting image builder.

WORKDIR /src sets the working directory to /src, so that we don't have to write it on every line. This is where files are now copied to.

COPY go.mod go.sum ./ copies the go module dependency files to the image. go.sum in particular is a lock-file that has all the (transitive) dependencies in it that your application has, so your dependencies have changed only if this file has changed. This also means that the Docker cache is invalidated at this line if these files have changed.

RUN go mod download -x downloads the app dependencies and prints the progress to standard out. Again, because of the caching, this is only done if your dependencies change, which is nice because it can take a while.

COPY . ./ copies all of your code into the image. Note that, if you need to exclude anything from being copied into your image, use a file called .dockerignore.

RUN GOOS=linux GOARCH=amd64 go build -ldflags="-X 'main.release=`git rev-parse --short=8 HEAD`'" -o /bin/server ./cmd/server uses Go to build your app for linux into a binary located at /bin/server. Note that the path is outside the /src dir, so we avoid potential name collisions from your source directory (e.g. if you have a directory/package called server). This is also where we set the release version we added as a string variable previously. I'll get back to that.

The runner image line by line

After the builder image comes the image that will be used to actually run your application.

FROM gcr.io/distroless/base-debian11 starts us out with a specially slimmed-down version of Debian. You could of course use other images here if you prefer. Some also prefer to use FROM scratch, if you don't need anything from the OS at all.

WORKDIR /app sets the working directory to /app, again so that we don't have to repeat it all the time.

COPY --from=builder /bin/server ./ copies the compiled binary from the builder image into this image.

Finally, CMD ["./server"] sets the default command to run.

Building the container

Building the container is now very easy. Run this in your project directory:

$ docker build -t canvas .

Use this time to give your body a nice stretch.

All stretched out? Good. After some dependency downloading, a bit of compiling and copying, your Docker image is ready to run. How do you know? Try it out with:

$ docker run -p 8081:8080 -e HOST="" canvas

That command instructs Docker to run the canvas image, and to forward port 8081 on your machine to port 8080 inside the container. It also sets the environment variable HOST to the empty string, so that the app listens on all network interfaces.

Because no one likes to remember weird build commands, put this in the top of your Makefile:

Makefile
.PHONY: build cover start test test-integration build: docker build -t canvas .

So what about that release version?

Did you notice the go build -ldflags="-X 'main.release=`git rev-parse --short=8 HEAD`'" in the Dockerfile? That tells the Go compiler to set the release variable in the main package to the current Git commit hash of the repository, in a shortened form. If you look at the output of the logger when starting the app, you'll see the release string in action:

2024-05-17T11:49:13Z	INFO	server/server.go:56	Starting	{"release": "6d6f47ec", "address": ":8080"}

So from now on, every time you have a log line, you know exactly what release version of your app wrote it out. Neat.

Off to deployment

We have finally arrived at the first milestone of your app development. We are ready for the first deployment. Read on in the next section for how to push our container to the clooouuud. ☁️

Review questions

Sign up or log in to get review questions with teacher feedback by email! 📧

Questions?

Get help at support@golang.dk.