Build with Modules, Ship from Scratch

If you use dep, check out this post instead.

Goals:

  • The application executable is compiled inside a container, in order to boost reproducibility
  • The resulting image must be as small as possible
  • The application must run in a container as secure as possible: an unprivileged user in a minimal environment
  • The application must be able to make HTTPS calls

It is a multistage Dockerfile: the first throwaway stage is used for building, while the final image will only contain the compiled binary executable.

The dependencies are fetched at build time using the go.mod and go.sum files; an alternative Dockerfile for vendored dependencies is available at the bottom of this post.

# Accept the Go version for the image to be set as a build argument.
# Default to Go 1.11
ARG GO_VERSION=1.11

# First stage: build the executable.
FROM golang:${GO_VERSION}-alpine AS builder

# Create the user and group files that will be used in the running container to
# run the process as an unprivileged user.
RUN mkdir /user && \
    echo 'nobody:x:65534:65534:nobody:/:' > /user/passwd && \
    echo 'nobody:x:65534:' > /user/group

# Install the Certificate-Authority certificates for the app to be able to make
# calls to HTTPS endpoints.
# Git is required for fetching the dependencies.
RUN apk add --no-cache ca-certificates git

# Set the working directory outside $GOPATH to enable the support for modules.
WORKDIR /src

# Fetch dependencies first; they are less susceptible to change on every build
# and will therefore be cached for speeding up the next build
COPY ./go.mod ./go.sum ./
RUN go mod download

# Import the code from the context.
COPY ./ ./

# Build the executable to `/app`. Mark the build as statically linked.
RUN CGO_ENABLED=0 go build \
    -installsuffix 'static' \
    -o /app .

# Final stage: the running container.
FROM scratch AS final

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

# Import the Certificate-Authority certificates for enabling HTTPS.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Import the compiled executable from the first stage.
COPY --from=builder /app /app

# Declare the port on which the webserver will be exposed.
# As we're going to run the executable as an unprivileged user, we can't bind
# to ports below 1024.
EXPOSE 8080

# Perform any further action as an unprivileged user.
USER nobody:nobody

# Run the compiled binary.
ENTRYPOINT ["/app"]
  • Line 3: the ARG keyword fetches an optional command line argument. For example: docker build --build-arg GO_VERSION=1.11.2. If not provided, the default value 1.11 is used.
  • Line 10: create the unprivileged Linux group and user that will run the executable. Running an executable as root, even inside a container, is generally not advisable, specifically unnecessary, and definitely inelegant.
  • Line 17: use the Alpine package management system to install the root certificates needed for making HTTPS calls from inside the container. If you don’t plan to run an HTTPS client, you can remove this line and line 42.
  • Line 31: compile the Go application as a static binary. Because of CGO_ENABLED=0, the executable will not depend on the system C libraries and will instead embed everything that is needed to run; only statically linked binaries can run in a scratch container.
  • Line 36: FROM scratch tells Docker to start over, using a new empty base image. The first stage, FROM golang, will be discarded. The final Docker image will only contain this stage, which doesn’t even carry an operating system.
  • COPY --from=builder fetches files from the previous image, which we have named builder on line 6.
  • Line 50: tell Docker the port the application will bind to. I usually use port 8080 for exposing HTTP.
  • Line 53: thanks to the group and user files we’ve generated and imported from the first stage, there is now an unprivileged nobody user ready to be used.
  • Line 56: the arguments to ENTRYPOINT are given as a JSON array: if instead a string had been passed, Docker would have invoked sh as a tokenizer. But since there is no sh in a scratch container, it would have resulted in an error like stat /bin/sh: no such file or directory.

Alternative Dockerfile for vendored dependencies

Locking the dependencies using the go.sum integrity checks will make sure that the code downloaded for a production build will match the dependencies you’ve set up locally… provided that they are still available on the remote origin.

Since I’m not paying per MB in my repository hosting, I often vendor dependencies to ensure both integrity and availability. Go 1.11 with enabled modules will only build using vendored dependencies when passed the flag -mod=vendor.

If you are using a .dockerignore exclusion file, remember to remove vendor/ from it and run go mod vendor from your host machine, as this Dockerfile will expect all the dependencies already vendored.

# Accept the Go version for the image to be set as a build argument.
# Default to Go 1.11
ARG GO_VERSION=1.11

# First stage: build the executable.
FROM golang:${GO_VERSION}-alpine AS builder

# Create the user and group files that will be used in the running container to
# run the process as an unprivileged user.
RUN mkdir /user && \
    echo 'nobody:x:65534:65534:nobody:/:' > /user/passwd && \
    echo 'nobody:x:65534:' > /user/group

# Install the Certificate-Authority certificates for the app to be able to make
# calls to HTTPS endpoints.
RUN apk add --no-cache ca-certificates

# Set the environment variables for the go command:
# * CGO_ENABLED=0 to build a statically-linked executable
# * GOFLAGS=-mod=vendor to force `go build` to look into the `/vendor` folder.
ENV CGO_ENABLED=0 GOFLAGS=-mod=vendor

# Set the working directory outside $GOPATH to enable the support for modules.
WORKDIR /src

# Import the code from the context.
COPY ./ ./

# Build the executable to `/app`. Mark the build as statically linked.
RUN go build \
    -installsuffix 'static' \
    -o /app .

# Final stage: the running container.
FROM scratch AS final

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

# Import the Certificate-Authority certificates for enabling HTTPS.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Import the compiled executable from the second stage.
COPY --from=builder /app /app

# Declare the port on which the webserver will be exposed.
# As we're going to run the executable as an unprivileged user, we can't bind
# to ports below 1024.
EXPOSE 8080

# Perform any further action as an unprivileged user.
USER nobody:nobody

# Run the compiled binary.
ENTRYPOINT ["/app"]