Tiny Docker Images with Go and Cloud Run

NOTE: This post is not done yet.

Building docker images is incredibly easy, which is part of why docker became so popular. Building a small and secure image can sometimes be more difficult though. This is the first of a series of log entries exploring how to build small and secure docker images for various languages. We’ll deploy them to cloud services and evaluate their security and performance.

This post goes over a solution for golang, and is based on a post from C H on Medium here. I had to extend it a bit since then, so I’ll go over the full solution in this post, including differences.

TODO: It looks like that post has been update with all the changes I did haha.

Overview

This works in a few steps. The first part is to use an image that has build tools in order to build the project into a single binary. Then we use a second docker build configuration to create a very small image that only contains the binary and root certs.

The reason we use two stages and compile in docker is to make building more compatible accross many different systems, and easily runnable in CI/CD. One of the most important parts of docker is being able to create a build that doesn’t require development machines to be setup in a specific way.

As an aside, when I was in college I took a programming languages class. Much of the class was spent fighting with various packages to get strange esoteric language compiles to work on my machine. If the class were done todoay, it would be very simple to just create a docker build once, and then give it to all the students in the class.

Build Step

The build step compiles the program. We start with a golang base image and install what we need (like git):

# 1. Builder
FROM golang:1.16-alpine AS builder

# Install git.
# Git is required for fetching the dependencies.
RUN apk update && apk add --no-cache git

Next we create a user and group. This makes it simpler to set permissions when we build the binary, and we will actually copy this into our final image and use it there as well.

# Create appuser.
ENV USER=appuser
ENV UID=10001 
# See https://stackoverflow.com/a/55757473/12429735RUN 
RUN adduser \    
    --disabled-password \    
    --gecos "" \    
    --home "/nonexistent" \    
    --shell "/sbin/nologin" \    
    --no-create-home \    
    --uid "${UID}" \    
    "${USER}"

Finally we have the actual validation and build steps. We run tests here, since this build is what we run in CI/CD as the only step. We could split out testing, but it’s sometimes easier to just do everything at once.

WORKDIR /project/
COPY . .

# Fetch dependencies.
# Using go get.
RUN go mod download
RUN go mod verify

# Generate code.
RUN go generate

# Build the binary.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/personal-site

The general idea is that we copy the code to the docker image, get the dependencies, and then build a binary for our destination platform. This example include the generate command because we want to embed some build information in our binary.

NOTE: -ldflags="-w -s" turns off debug and the go symbol table. The goal of this is to ship a production binary.

Tiny Package

The next part of our Dockerfile builds a tiny image. It’s pretty simple, but we can look through it briefly.

We want to start with scratch so we have a blank slate:

# 2. Final Image
FROM scratch

Next, we should copy the user/group details from the builder step:

# Import the user and group files from the builder.
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group

A tiny detail that I couldn’t figure out at first is that making HTTPS requests is difficult to do unless you have root certs installed. This took me a bit of searching to figure out, but the solution is simple. Just copy the certs from the build image like this:

# Add in certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

Finally, we will copy our binary, set the runtime user and entrypoint:

# Copy our static executable.
COPY --from=builder /go/bin/personal-site /go/bin/personal-site

# Use an unprivileged user.
USER appuser:appuser

# Run the hello binary.
ENTRYPOINT ["/go/bin/personal-site"]

There are different options for this, but we chose pretty simple ones. We don’t want to run as root, and we just want to run the binary directly.

When docker was first being adopted a lot of people tried to run a bunch of binary files in the same image, including processes that would restart on failure, and manage the startup. This is not really great practice, and we’ve opted to just stick with a single binary. Kubernetes, or something similar, can be used to coordinate multiple binaries that support a single application.

There are other ways to run a binary than using ENTRYPOINT. Which one you choose will effect how your runtime can communicate signals with your binary. Other’s have written much more about this, so research it more if you wish. Basically by using ENTRYPOINT, which binary you run can not be modified. We want this for security.

Conclusion

That’s basically it! You can now build a tiny docker image that only has your go binary. A normal web application will also have a bunch of resource files in it, so I might write about embedding them in your binary next.

Full File

The full docker file is below. Note you might need to tweak some of it to match your project use case.

# 1. Builder
FROM golang:1.16-alpine AS builder

# Install git.
# Git is required for fetching the dependencies.
RUN apk update && apk add --no-cache git

# Create appuser.
ENV USER=appuser
ENV UID=10001 
# See https://stackoverflow.com/a/55757473/12429735RUN 
RUN adduser \    
    --disabled-password \    
    --gecos "" \    
    --home "/nonexistent" \    
    --shell "/sbin/nologin" \    
    --no-create-home \    
    --uid "${UID}" \    
    "${USER}"


WORKDIR /project/
COPY . .

# Fetch dependencies.
# Using go get.
RUN go mod download
RUN go mod verify

# Generate code.
RUN go generate

# Build the binary.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/personal-site

# 2. Final Image
FROM scratch

# Import the user and group files from the builder.
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group

# Add in certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt


# Copy our static executable.
COPY --from=builder /go/bin/personal-site /go/bin/personal-site

# Use an unprivileged user.
USER appuser:appuser

# Run the hello binary.
ENTRYPOINT ["/go/bin/personal-site"]

more log entries