Best practices for containerizing Go applications with Docker
2022年3月23日
0 分で読めますGo applications and containers are made for each other. Go’s small application binary sizes are a perfect fit for the microservices deployment strategies that Docker and Kubernetes excel at delivering. This synergy is not without its challenges, though. So it’s important to understand container best practices and key concepts to avoid security pitfalls that can easily creep into your container images.
In this article you will code up a sample Go application and learn how best to containerize it and run it securely.
Prerequisites
To follow along with this tutorial, you’ll need to have Docker and Go installed on your machine and a basic familiarity with both. You can find introductory instructions and downloads for Docker on the orientation and setup page of the Docker documentation. Good tutorials for Go can be found in the official Go documentation, or by skipping to the Go installation page for Linux.
Create a sample Go application
To start, let’s create our Go API. First, navigate to the directory where you want your Go application to reside on your machine. Then, create a directory named “godocker.” In this godocker/
directory, run the following command to define your Go module:
1go mod init godocker
Next, create a file called “main.go” inside the “godocker” directory. This file will hold your API code. Now, you can add logic to provide and handle the current time via the API.
Enter the following code in godocker/main.go
:
1package main
2
3import (
4"encoding/json"
5"fmt"
6"log"
7"net/http"
8)
9
10type Time struct {
11CurrentTime string `json:"current_time"`
12}
13
14func main() {
15// defining router
16mux := http.NewServeMux()
17mux.HandleFunc("/time", getTime)
18
19// starting server
20fmt.Println("Server is running at 127.0.0.1:8080")
21log.Fatal(http.ListenAndServe( "localhost:8080", mux))
22}
23
24func getTime(w http.ResponseWriter, r *http.Request) {
25currentTime := []Time{
26 { CurrentTime: http.TimeFormat },
27}
28
29json.NewEncoder(w).Encode(currentTime)
30}
This code provides the current time via the API using the TimeFormat
variable of the http package. Then, we define the NewServeMux
library as mux to configure the HTTP server. Finally, we register the /time
endpoint to return the current time in the API and encode the response at the /time
endpoint as JSON.
1go mod init godocker
Let’s do a quick test. Run the application with the following command in the shell terminal:
1go run main.go
You should get an output that looks similar to this:
1Server is running at 127.0.0.1:8080
Now, let’s test the API in another terminal window by using cURL. Enter the following command in the terminal:
1curl http://127.0.0.1:8080/time
The output should look like this:
1[{"current_time":"Mon, 02 Jan 2006 15:04:05 GMT"}]
Prepare the Dockerfile
A Dockerfile contains a series of instructions for packaging and deploying an application as a container. In this section, we’ll create a Dockerfile and review some of the instructions that we can use to package our sample application as a container.
Specify the Docker syntax version
First, we add the syntax directive. In the godocker/
directory, create a new file called "Dockerfile" and enter the following code on the first line:
1# syntax=docker/dockerfile:1
The syntax directive specifies the location of the Dockerfile syntax that we’ll use to build our Dockerfile. This line of code defines the location of the Dockerfile syntax as docker/dockerfile:1
, which is the latest syntax version release. Docker will check for the syntax version before it uses the Buildkit backend to build the Dockerfile.
Ensure that this line is commented at the beginning of the file.. After the syntax directive, the convention is to leave a blank line.
Inherit a base image with a small memory footprint
Next, we specify the Docker base image we want to inherit with the FROM instruction.
Add the following code to godocker/Dockerfil
`:
1FROM golang:1.17-alpine
This instruction ensures that we don't need to build our own Docker base image. Instead, we inherit the Docker official image for Go applications for the Alpine Linux variant. The Go version of the base image is 1.17. The alpine
image is very small compared to a variant like the ubuntu
image.
Now we fill in the rest of the Dockerfile, including comments in the code, to briefly illustrate the purpose of each line.
Update godocker/Dockerfile
so that it contains the following code:
1# syntax=docker/dockerfile:1
2
3# specify the base image to be used for the application, alpine or ubuntu
4FROM golang:1.17-alpine
5
6# create a working directory inside the image
7WORKDIR /app
8
9# copy Go modules and dependencies to image
10COPY go.mod ./
11
12# download Go modules and dependencies
13RUN go mod download
14
15# copy directory files i.e all files ending with .go
16COPY *.go ./
17
18# compile application
19RUN go build -o /godocker
20
21# tells Docker that the container listens on specified network ports at runtime
22EXPOSE 8080
23
24# command to be used to execute when the image is used to start a container
25CMD [ "/godocker" ]
Build the image
With our Dockerfile complete, let’s build our Docker image from the Dockerfile with the docker build
command. Docker uses the Docker daemon to build images. We use the --tag
option — which we can shorten to -t
— with the docker build
command to set a custom name for our Docker image.
Enter the following command in the terminal:
1docker build --tag godocker .
We append .
to the docker build
command so the image is built in the current directory, which serves as the build context. You should avoid using the /
path for the build context because doing so can transfer the entire source code to the Docker daemon.
Your build output should contain the FINISHED
line and look similar to this:
1[+] Building 6.8s (17/17) FINISHED
2...
3 => => writing image sha256:539bdb3e661f66d489467ef217e1b46786de9cf3c29dc9a2dd6b4e9fa763 0.0s
4 => => naming to docker.io/library/godocker
This output means that the Docker image has been completely built with the godocker
tag.
To view the list of local images, enter the following command in the terminal:
1docker image ls
Your output should look similar to this:
1REPOSITORY TAG IMAGE ID CREATED SIZE
2godocker latest 539bdb3e661f 2 minutes ago 319MB
3docker/getting-started latest 720f449e5af2 1 hour ago 27.2MB
The size of the newly built godocker
image in our output is 319MB, which is large for a simple API application. So, we need to optimize the build to create a leaner image. In the next section, we’ll implement the concept of multi-stage builds to achieve a lean build.
Use multi-stage builds
An approach using multi-stage builds helps create much smaller images compared to those produced by the single-stage approach we demonstrated in the previous section. A multi-stage build uses an image to build fragments that are packaged in a smaller image that consists of only the essential parts needed to enable the fragments to run. By reducing our images to the bare minimum needed to run the application, we can reduce the potential for security vulnerabilities. To achieve this, we need to use multiple FROM
instructions in our Dockerfile.
Use the official scratch image
We can start our build with an empty image by inheriting the scratch
Docker official image. In this section, we’ll demonstrate how to use the scratch
image for multi-stage builds.
First, navigate to your app’s root directory. Then create a file called “Dockerfile.multistage” and enter the following code:
1# syntax=docker/dockerfile:1
2
3##
4## STEP 1 - BUILD
5##
6
7# specify the base image to be used for the application, alpine or ubuntu
8FROM golang:1.17-alpine AS build
9
10# create a working directory inside the image
11WORKDIR /app
12
13# copy Go modules and dependencies to image
14COPY go.mod ./
15
16# download Go modules and dependencies
17RUN go mod download
18
19# copy directory files i.e all files ending with .go
20COPY *.go ./
21
22# compile application
23RUN go build -o /godocker
24
25##
26## STEP 2 - DEPLOY
27##
28FROM scratch
29
30WORKDIR /
31
32COPY --from=build /godocker /godocker
33
34EXPOSE 8080
35
36ENTRYPOINT ["/godocker"]
This code specifies the base image to be inherited from the official golang:1.17-alpine
image with the stage name build
. Then, we use another FROM instruction to implement the multi-stage concept by copying the built binary from the first stage into the empty image in the second stage.
Next, we need to build a new image with the new Dockerfile.multistage
file. We also need to give the new image a tag called “multistage.” This helps differentiate it from the image we built earlier.
Enter the following command in the terminal:
1docker build -t godocker:multistage -f Dockerfile.multistage .
After the successful build, check the list of images by entering the following command in the terminal:
1docker image ls
Your output should look similar to this:
1REPOSITORY TAG IMAGE ID CREATED SIZE
2godocker multistage 192cc137f88b 9 seconds ago 6.18MB
3godocker latest 539bdb3e661f 1 hour ago 319MB
This output shows the large difference in the size between the godocker:multistage
and godocker:latest
images. There's an obvious improvement from 319 MB in the single-stage image to 6.1 MB in the multi-stage image. Because we rely on containers to spin up quickly, optimizations like this are critical when containerizing Go applications.
Deploy the container
In addition to optimizing for performance and efficiency, we also need to think about how to best deploy our containers so that they run securely. We’ll implement a few best practices during this stage of the tutorial.
Run as a non-root user
The principle of least privilege makes it necessary to ensure that we are limiting access to system resources. Our Go Docker containers are application containers and don't need to be run with root privileges. So, we should create a new user and group with limited access in our Dockerfile for added security.
To create a non-root user, add the following lines immediately after the first FROM instruction in godocker/Dockerfile.multistage
:
1RUN useradd -u 1001 -m iamuser
This instruction sets the USERNAME
and PASSWORD
arguments with the ARG keyword and then creates a user with the RUN adduser
instruction.
Next, let’s implement the instructions to copy the details of the user from the first stage and apply them to the second stage.
Add the bolded code in the following example to the second stage in godocker/Dockerfile.multistage
, which should now end with the following code:
1...
2##
3## STEP 2 - DEPLOY
4##
5FROM scratch
6
7WORKDIR /
8
9COPY --from=build /godocker /godocker
10
11COPY --from=build /etc/passwd /etc/passwd
12
13USER 1001
14
15EXPOSE 8080
16
17ENTRYPOINT ["/godocker"]
In Kubernetes, you can specify the runAsuser: UID
in thesecurityContext
field. Review the Kubernetes documentation to learn how to set the security context for a Pod.
Run read-only root filesystem
Another way to increase our app’s security is by running the container with a read-only filesystem. To enforce read-only privilege on the filesystem in our container, we’ll pass the read-only flag with the docker run
command.
Enter the following command in the terminal:
1docker run -read-only godocker
Drop or deny Linux capabilities
Linux capabilities are the set of privileges we can enable or disable when using Linux. Because our container is based on a Linux variant, we can also use these capabilities to increase our app’s security. Removing some capabilities reduces the risk to our container.
For our tutorial, let’s drop all capabilities except setuid
.
Enter the following command in the terminal:
1docker run --cap-drop=all --cap-add=setuid
Limit CPU and memory usage
The docker run
command allows us to set host machine resource usage limits for our Docker container. Docker uses the --cpus
flag.
For example, to prevent the container from using more than 50% of a single CPU, enter the following command in the Docker CLI:
1docker run -it --cpus=".5" alpine /bin/bash
If you need to use 2 CPUs, you can limit usage using the following command in the Docker CLI:
1docker run -it --cpus=2 alpine /bin/bash
If you want to limit the memory usage of a Docker container to 1024MB, you can use the docker run
command, like this:
1docker run -m 1024m --memory-reservation=256m alpine /bin/bash
This command also sets a 256MB memory space limit that is enforced when Docker detects that the host is running low on memory.
Conclusion
In this article, we walked through the process of setting up a Go application and containerizing it with Docker. We also implemented the concept of multi-stage builds to optimize performance, built from scratch with an empty Docker official image, and discussed some best practices that might come in handy when building and deploying containers. These guidelines are a starting point to implementing robust efficiency, security, and memory management when containerizing Go web applications with Docker.
To learn more about security best practices, visit the Snyk Learn resource center.