Best practices for containerizing Go applications with Docker
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:
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
:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
) type Time struct {
CurrentTime string `json:"current_time"`
} func main() {
// defining router
mux := http.NewServeMux()
mux.HandleFunc("/time", getTime)
// starting server
fmt.Println("Server is running at 127.0.0.1:8080")
log.Fatal(http.ListenAndServe( "localhost:8080", mux))
} func getTime(w http.ResponseWriter, r *http.Request) {
currentTime := []Time{
{ CurrentTime: http.TimeFormat },
}
json.NewEncoder(w).Encode(currentTime)
}
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.
Let’s do a quick test. Run the application with the following command in the shell terminal:
You should get an output that looks similar to this:
Server 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:
curl http://127.0.0.1:8080/time
The output should look like this:
[{"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:
# 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
`:
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:
# syntax=docker/dockerfile:1
# specify the base image to be used for the application, alpine or ubuntu
FROM golang:1.17-alpine
# create a working directory inside the image
WORKDIR /app # copy Go modules and dependencies to image
COPY go.mod ./ # download Go modules and dependencies
RUN go mod download # copy directory files i.e all files ending with .go
COPY *.go ./ # compile application
RUN go build -o /godocker # tells Docker that the container listens on specified network ports at runtime
EXPOSE 8080
# command to be used to execute when the image is used to start a container
CMD [ "/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:
docker 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:
[+] Building 6.8s (17/17) FINISHED ...
=> => writing image sha256:539bdb3e661f66d489467ef217e1b46786de9cf3c29dc9a2dd6b4e9fa763 0.0s
=> => 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:
Your output should look similar to this:
REPOSITORY TAG IMAGE ID CREATED SIZE
godocker latest 539bdb3e661f 2 minutes ago 319MB
docker/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:
# syntax=docker/dockerfile:1
##
## STEP 1 - BUILD
## # specify the base image to be used for the application, alpine or ubuntu
FROM golang:1.17-alpine AS build
# create a working directory inside the image
WORKDIR /app # copy Go modules and dependencies to image
COPY go.mod ./ # download Go modules and dependencies
RUN go mod download # copy directory files i.e all files ending with .go
COPY *.go ./ # compile application
RUN go build -o /godocker ##
## STEP 2 - DEPLOY
##
FROM scratch WORKDIR / COPY --from=build /godocker /godocker
EXPOSE 8080
ENTRYPOINT ["/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:
docker build -t godocker:multistage -f Dockerfile.multistage .
After the successful build, check the list of images by entering the following command in the terminal:
Your output should look similar to this:
REPOSITORY TAG IMAGE ID CREATED SIZE
godocker multistage 192cc137f88b 9 seconds ago 6.18MB
godocker 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
:
RUN 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:
...
##
## STEP 2 - DEPLOY
##
FROM scratch WORKDIR / COPY --from=build /godocker /godocker
COPY --from=build /etc/passwd /etc/passwd
USER 1001
EXPOSE 8080
ENTRYPOINT ["/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:
docker 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:
docker 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:
docker 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:
docker 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:
docker 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.