In this tutorial, I guide you through the process of writing a Dockerfile which compiles and runs a Golang application packaged into a Docker image. Thereby, I use a multi-stage setup, removing the need for seperate Dockerfiles for compilation and execution.
For this article, I am using my recently published csv2gpx Golang application for geocaching.com. It consists of a main.go file, the entrypoint for the compilation. The Kingpin library parses the command line arguments. The converter class is called with these arguments and converts the given CSV file into a GPX file for geocaching.com
Some Docker basics
A Dockfiler describes the structure of a Docker image. It defines the commands to build the exact same image every time it is built on any machine. Each line of the Dockerfile contains one command in the form
Each command creates a new layer in the resulting image. But only the last layer is writable. All others are read-only.
The docker build command builds an image using the Dockerfile. The example shows a build command for the csv2gpx application mentioned above. In this example, nothing has changed in the second stage (step 10 to 14) compared to the previous execution. Therefore, Docker utilises the information it has from its cache.
C:\Users\Matthias\go\src\github.com\matthiassommer\csv2gpx>docker build --rm -t csv2gpx . Sending build context to Docker daemon 23.58MB Step 1/14 : FROM golang:1-alpine AS builder ---> cb1c8647572c Step 2/14 : LABEL maintainer="Matthias Sommer, email@example.com" ---> Running in 983e57bab04a Removing intermediate container 983e57bab04a ---> 878f934dcb4e Step 3/14 : WORKDIR /go/src/github.com/matthiassommer/csv2gpx ---> Running in 3b86ce182668 Removing intermediate container 3b86ce182668 ---> d5b6c98b3364 Step 4/14 : RUN apk add --update git ---> Running in 1e3b9455755d fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/main/x86_64/APKINDEX.tar.gz fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/community/x86_64/APKINDEX.tar.gz (1/6) Installing nghttp2-libs (1.35.1-r0) (2/6) Installing libssh2 (1.8.0-r4) (3/6) Installing libcurl (7.64.0-r1) (4/6) Installing expat (2.2.6-r0) (5/6) Installing pcre2 (10.32-r1) (6/6) Installing git (2.20.1-r0) Executing busybox-1.29.3-r10.trigger OK: 20 MiB in 21 packages Removing intermediate container 1e3b9455755d ---> 2cd7a342f98e Step 5/14 : RUN go get gopkg.in/alecthomas/kingpin.v2 ---> Running in 547f25084238 Removing intermediate container 547f25084238 ---> 52baca484098 Step 6/14 : COPY data/example_input.csv ./data/ ---> d89836342bc6 Step 7/14 : COPY main.go . ---> b2700b154032 Step 8/14 : COPY converter.go . ---> 1e0ddd684bf3 Step 9/14 : RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o app . ---> Running in 959e46c2120f Removing intermediate container 959e46c2120f ---> 515a6cb5ad31 Step 10/14 : FROM alpine ---> caf27325b298 Step 11/14 : COPY --from=builder /go/src/github.com/matthiassommer/csv2gpx/ . ---> Using cache ---> 2c98343ee8be Step 12/14 : ENV INPUT "" ---> Using cache ---> c45a03d23f5b Step 13/14 : ENV OUTPUT "" ---> Using cache ---> 3631bd10479b Step 14/14 : CMD ./app $INPUT $OUTPUT ---> Using cache ---> d93fdf209c5c Successfully built d93fdf209c5c Successfully tagged csv2gpx:latest
The –rm option removes intermediate containers after a successful build. The -t option names and optionally adds a tag in the ‘name:tag’ format to the image. By default, it adds the tag latest to the image name. The dot at the end defines that the current working directory is used as build context.
In the following, I will go through the Dockerfile command by command until we finally end up with the complete file. I am using a multi-stage build, a feature introduced with Docker 17.05. It greatly simplifies keeping the final image small by defining several stages, where each FROM instruction can use a different base.
First stage: Let’s go
The first command is the FROM which defines the base image. We want to construct a golang image, thus we use one of the base images from the Golang Docker hub.
FROM golang:1-alpine AS builder
Next, I use the LABEL instruction to define the maintainer of this package (The MAINTAINER command is deprecated). It adds the information as metadata to the image .
LABEL maintainer="Matthias Sommer, firstname.lastname@example.org"
Each of the Docker commands is by default executed in the root directory. If we want to change this, we can use the WORKDIR command. This command also generates directories recursively in case they do not exist.
Here, I am following the standard Go project folder structure convention.
Next, we have to download the libraries used in our Golang application. I am only relying on the Kingpin command line parser. First, we have to download Git, then we can let Docker download the library from Github.
RUN apk add --update git RUN go get gopkg.in/alecthomas/kingpin.v2
Package manage apk is the tool used to install, upgrade, or delete libraries in the image. The –update flag fetches the current package index before adding the package.
Copy data into the container
Next, we copy the Go code and necessary data files into the image.
COPY data/example_input.csv ./data/ COPY main.go . COPY converter.go .
COPY main.go . copies the main.go file directly into the WORKDIR, whereas COPY data/example_input.csv ./data/ copies the csv file into a subdirectory named data, which is created on the fly.
Build the app
Now, we can build the Go app with the Go compiler using the RUN command.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o app .
We are disabling cgo which gives us a static binary with all libraries built in. We set GOOS and GOARCH to define the compilation target (you could also cross-compile it as a Windows executable). By setting -o app we are naming the resulting binary.
Second stage: Runnable image
The second FROM instruction starts a new build stage with the alpine image as its base. The COPY –from=builder line copies just the built artifact from the builder stage into this new stage and leaves everything else behind.
Next, we define two environment variables, INPUT and OUTPUT, which are set at runtime in the go run command.
ENV INPUT "" ENV OUTPUT ""
The ENV instruction sets the environment variable key to a value. This value will be in the environment for all subsequent instructions. Here, both enviroment variables are initialised as empty string. They are set using the -e flag of the docker run command.
docker run -e INPUT=example_input.csv -e OUTPUT=example_input.gpx csv2gpx
Finally, the CMD instruction defines the command to be executed when running the image. There can only be one CMD instruction (if there are more, only the last one is considered).
CMD ./app $INPUT $OUTPUT
Due to this issue, I am using the shell form. Thereby, the command is executed in /bin/sh -c and the ENV variables are resolved to their values.
To sum it up, here is the full Dockerfile
FROM golang:1-alpine AS builder LABEL maintainer="Matthias Sommer, email@example.com" WORKDIR /go/src/github.com/matthiassommer/csv2gpx # install dependencies RUN apk add --update git RUN go get gopkg.in/alecthomas/kingpin.v2 # copy the code and data COPY data/example_input.csv ./data/ COPY main.go . COPY converter.go . # build the app RUN GOOS=linux GOARCH=amd64 go build -o app . # Second stage FROM alpine COPY --from=builder /go/src/github.com/matthiassommer/csv2gpx/ . ENV INPUT "" ENV OUTPUT "" CMD ./app $INPUT $OUTPUT
and the docker build and docker run command.
docker build --rm -t csv2gpx . docker run -e INPUT=data/example_input.csv -e OUTPUT=data/example_input.gpx csv2gpx
where the later one produces for example the following output
CSV: data/example_input.csv GPX: data/example_input.gpx Total rows: 2 Converted rows: 2 GPX written to data/example_input.gpx