Compiling Your Go Application for Containers
An excerpt from Powerful Command-Line Applications in Go by Ricardo Gerardi
📘 If you enjoy this excerpt, please consider purchasing the book or joining the conversation on DevTalk (or both).
Another alternative way to distribute your application that has become increasingly popular in recent years is allowing your users to run the application in Linux containers. Containers package your application and all the required dependencies using a standard image format, and they run the application in isolation from other processes running on the same system. Containers use Linux kernel resources such as Namespaces and Cgroups to provide isolation and resource management.
There are different container runtimes available, such as Podman and Docker. If you’re running these examples on a Linux system, you can use either one interchangeably. If you’re running on Windows or macOS, Docker provides a desktop version that makes it easier to start. You can also use Podman on these operating systems, but you need to install a Virtual Machine to enable it. We’ll not cover a container runtime installation process here. For more details, check the respective project’s documentation.
To distribute your application as a container, you have to create a container image. You can do this in several ways, but a common way is by using a Dockerfile, which contains a recipe for how to create an image. Then you pass this file as input to docker or podman commands to build the image. For more details on how to create the Dockerfile, consult its documentation.
The focus of this section is to provide some build options to optimize your application to run in containers. Go is a great choice for creating applications that run in containers because it generates a single binary file that you can add to the container image without additional runtimes or dependencies.
To make the binary file even more suitable to run in a container, you can pass additional build options. For example, you’ll enable a statically linked binary by setting CGO_ENABLED=0
, and you can pass additional linker options using the flag -ldflags
. To reduce the binary size, use the options -ldflags=”-s -w”
to strip the binary of debug symbols. Before you get started, take a closer look at some of the build options that you’ll use:
CGO_ENABLED=0
: Enables statically linked binaries to make the application more portable. It allows you to use the binary with source images that don’t support shared libraries when building your container image.GOOS=linux
: Since containers run Linux, set this option to enable repeatable builds even when building the application on a different platform.-ldflags=”-s -w”
: The parameter-ldflags
allows you to specify additional linker options that go build uses at the link stage of the build process. In this case, the option-s -w
strips the binary of debugging symbols, decreasing its size. Without these symbols, it’s harder to debug the application, but this is usually not a major concern when running in a container. To see all linker options you can use, run go tool link.-tags=containers
: This is specific to your Pomodoro application. Build the application using the files specified with the container tag to remove dependency on SQLite and notifications as you did in Conditionally Building Your Application.
Now build your binary using these options:
$ CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -tags=containers
Inspect this file to verify its properties and size:
$ ls -lh pomo
-rwxr-xr-x 1 ricardo users 7.2M Feb 28 12:06 pomo
$ file pomo
pomo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked,
... ... , stripped
Notice the file size is about 7MB and the file is statically linked and stripped.
Compare this with building the application without these options:
$ go build
$ ls -lh pomo
-rwxr-xr-x 1 ricardo users 13M Feb 28 12:09 pomo
$ file pomo
pomo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, ..., for GNU/Linux 4.4.0,
not stripped
The binary file optimized for containers is almost 50% smaller than the original. It’s also statically linked and stripped of debugging symbols.
Once you have the binary, you’ll create a container image by using a Dockerfile. Switch back to the chapter’s root directory and create a new subdirectory containers:
$ cd $HOME/pragprog.com/rggo/distributing
$ mkdir containers
Create and edit a file called Dockerfile in this subdirectory. Add the following contents to create a new image from the base image alpine:latest, create a regular user pomo to run the application, and copy the binary file pomo/pomo you built before to the image under directory /app:
distributing/containers/Dockerfile
FROM alpine:latest
RUN mkdir /app && adduser -h /app -D pomo
WORKDIR /app
COPY --chown=pomo /pomo/pomo .
CMD ["/app/pomo"]
Build your image using the docker build command providing this Dockerfile as input:
$ docker build -t pomo/pomo:latest -f containers/Dockerfile .
STEP 1: FROM alpine:latest
STEP 2: RUN mkdir /app && adduser -h /app -D pomo
--> 500286ad2c9
STEP 3: WORKDIR /app
--> 175d6b43663
STEP 4: COPY --chown=pomo /pomo/pomo .
--> 2b05fa6dbba
STEP 5: CMD ["/app/pomo"]
STEP 6: COMMIT pomo/pomo:latest
--> 998e1c2cc75
998e1c2cc75dc865f57890cb6294c2f25725da97ce8535909216ea27a4a56a38
This command creates an image tagged with pomo/pomo:latest
. List it using docker images:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/pomo/pomo latest 998e1c2cc75d 47 minutes ago 13.4 MB
docker.io/library/alpine latest e50c909a8df2 4 weeks ago 5.88 MB
Run your application using Docker, providing the -it
flags to enable a terminal emulator, which is required to run Pomodoro’s interactive CLI:
$ docker run --rm -it localhost/pomo/pomo
You can also use Docker to build the application with Go’s official image and a multistage Dockerfile. A multistage Dockerfile instantiates a container to compile the application and then copies the resulting file to a second image, similar to the previous Dockerfile you created. Create a new file called Dockerfile.builder in the containers subdirectory. Define the multistage build using the following code:
distributing/containers/Dockerfile.builder
FROM golang:1.15 AS builder
RUN mkdir /distributing
WORKDIR /distributing
COPY notify/ notify/
COPY pomo/ pomo/
WORKDIR /distributing/pomo
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -tags=containers
FROM alpine:latest
RUN mkdir /app && adduser -h /app -D pomo
WORKDIR /app
COPY --chown=pomo --from=builder /distributing/pomo/pomo .
CMD ["/app/pomo"]
Now use this image to build the binary and the container image for your application:
$ docker build -t pomo/pomo:latest -f containers/Dockerfile.builder .
STEP 1: FROM golang:1.15 AS builder
STEP 2: RUN mkdir /distributing
--> e8e2ea98b04
STEP 3: WORKDIR /distributing
--> 81cee711389
STEP 4: COPY notify/ notify/
--> ac86b302a7a
STEP 5: COPY pomo/ pomo/
--> 5353bc4d73e
STEP 6: WORKDIR /distributing/pomo
--> bfddd5217bf
STEP 7: RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -tags=containers
go: downloading github.com/spf13/viper v1.7.1
go: downloading github.com/spf13/cobra v1.1.1
go: downloading github.com/mitchellh/go-homedir v1.1.0
go: downloading github.com/mum4k/termdash v0.13.0
go: downloading github.com/spf13/afero v1.1.2
go: downloading github.com/spf13/cast v1.3.0
go: downloading github.com/pelletier/go-toml v1.2.0
go: downloading gopkg.in/yaml.v2 v2.2.8
go: downloading github.com/mitchellh/mapstructure v1.1.2
go: downloading github.com/spf13/pflag v1.0.5
go: downloading golang.org/x/text v0.3.4
go: downloading github.com/subosito/gotenv v1.2.0
go: downloading github.com/magiconair/properties v1.8.1
go: downloading github.com/fsnotify/fsnotify v1.4.7
go: downloading github.com/mattn/go-runewidth v0.0.9
go: downloading github.com/spf13/jwalterweatherman v1.0.0
go: downloading github.com/hashicorp/hcl v1.0.0
go: downloading github.com/gdamore/tcell/v2 v2.0.0
go: downloading gopkg.in/ini.v1 v1.51.0
go: downloading golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba
go: downloading github.com/gdamore/encoding v1.0.0
go: downloading github.com/lucasb-eyer/go-colorful v1.0.3
--> de7b70a3753
STEP 8: FROM alpine:latest
STEP 9: RUN mkdir /app && adduser -h /app -D pomo
--> Using cache 500286ad2c9f1242184343eedb016d53e36e1401675eb6769fb9c64146...
--> 500286ad2c9
STEP 10: WORKDIR /app
--> Using cache 175d6b43663f6db66fd8e61d80a82e5976b27078b79d59feebcc517d44...
--> 175d6b43663
STEP 11: COPY --chown=pomo --from=builder /distributing/pomo/pomo .
--> 0292f63c58f
STEP 12: CMD ["/app/pomo"]
STEP 13: COMMIT pomo/pomo:latest
--> 3c3ec9fafb8
3c3ec9fafb8f463aa2776f1e45c216dc60f7490df1875c133bb962ffcceab050
The result is the same image as before, but with this new Dockerfile, you don’t have to compile the application manually before creating the image. The multistage build does everything for you in a repeatable and consistent way.
Go builds applications into single binaries; you can build them statically linked and can also create images that have no other files or dependencies. These tiny images are optimized for data transfer and are more secure since they contain only your application binary.
To create such an image, you’ll use a multistage Dockerfile. So copy the file containers/Dockerfile.builder
into a new file containers/Dockerfile.scratch
, and edit this new file, replacing the second stage image on the FROM command with scratch. This image has no directories or users, so replace the remaining commands with a command to copy the binary to the root directory. When you’re done, your Dockerfile will look like this:
distributing/containers/Dockerfile.scratch
FROM golang:1.15 AS builder
RUN mkdir /distributing
WORKDIR /distributing
COPY notify/ notify/
COPY pomo/ pomo/
WORKDIR /distributing/pomo
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -tags=containers
FROM scratch
WORKDIR /
COPY --from=builder /distributing/pomo/pomo .
CMD ["/pomo"]
Build your image using this Dockerfile as you did before:
$ docker build -t pomo/pomo:latest -f containers/Dockerfile.scratch .
STEP 1: FROM golang:1.15 AS builder
STEP 2: RUN mkdir /distributing
--> 9021735fd16
... TRUNCATED OUTPUT ...
STEP 8: FROM scratch
STEP 9: WORKDIR /
--> 00b6e665a3f
STEP 10: COPY --from=builder /distributing/pomo/pomo .
--> c6bbaccb87b
STEP 11: CMD ["/pomo"]
STEP 12: COMMIT pomo/pomo:latest
--> 4068859c281
Check your new image and notice that its size is close to the binary size because it’s the only file in the image:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/pomo/pomo latest 4068859c281e 5 seconds ago 8.34 MB
Not all applications are good candidates to run in a container, but for the ones that are, this is another option to distribute your application for your users.
Next, let’s explore go get to distribute your application with source code.
We hope you enjoyed this excerpt from Powerful Command-Line Applications in Go by Ricardo Gerardi. You can continue reading on Medium or purchase the ebook directly from the Pragmatic Programmers.