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.
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.
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 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
DockerfileFROM 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.
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.
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.
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 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 .
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-12-22T01:16:18Z 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.
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. ☁️
Get help at support@golang.dk.