Tiny Golang Containers

  • Ben Burbage
  • Nov 18, 2021
Tiny Golang Containers
Development Golang Docker

Over this year, I’ve slowly become more Golang oriented as a developer and I’m really enjoying the journey that choice is taking me on.

Traditionally, I’ve built web services with Spring Boot and Java but more recently, I’ve noticed there are a number of benefits to developing such services with Golang and it’s standard library: Lower runtime resource requirements and no runtime environment required to name a few.

Another benefit I’d like to discuss here is achieving tiny containerized applications with Golang.

Why?

The main advantages of creating container images which are small in size are that they:

  • Require less storage space in a container registry
  • Require less bandwidth for upload and download
  • Require less available disk in runtime environment (e.g Kubernetes)

The above points typically keep cloud subscriptions costs down and, in extension, your product team happy 🙂

Another key advantage to stripping down container images to their absolute bare necessities is that it reduces that container’s surface area for exploitation - meaning safer deployments too.

How?

Sample Application

To show how to create slender containers with Golang, we need a sample application to containerize, so I created one: https://github.com/iambenzo/dirtyhttp-example

It’s a simple web service, which exposes an in-memory data structure for the storing and retrieval of user information via HTTP request. (Only around 200 lines of code, including spacing and comments if you want to check it out)

Usage instructions are included in the README file.

Full Fat Container

In the same way some of us struggle to digest full fat milk, our container registries would eventually become bloated with full fat containers.

Let’s see how much damage a creamy Golang container with all the bells and whistles does:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Use Alpine for its small footprint
FROM golang:alpine
WORKDIR /app

# Download our dependencies
COPY go.mod ./
COPY go.sum ./
RUN go mod download

# Build the application binary
COPY *.go ./
RUN go build -o /dirtyhttp-example

EXPOSE 8080
CMD [ "/dirtyhttp-example" ]

The command to build the container locally is:

1
 docker build -t go-example:fat -f Dockerfile.fat .

Once docker has finished working it’s magic, we’ll have a container that looks like this:

Full Fat

You can see above that our “fat” container image requires a meaty 398MB of storage space.

To run the container image, you’d use the following command:

1
 docker run -it --rm -p 8080:8080 go-example:fat

Going Semi-Skimmed

The wonderful developers at Google have made a “runtime” container available to eliminate some of the bloat in our previous container.

To use it properly, we need to make use of a multi-stage build. This is where we use one [intermediary] docker image to build our application and send the output binary to a slimmed down image where the application will actually run.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Build
FROM golang:1.16-buster AS build

WORKDIR /app

COPY go.mod ./
COPY go.sum ./
RUN go mod download

COPY *.go ./
RUN go build -o /dirtyhttp-example

# Runtime
FROM gcr.io/distroless/base-debian10

WORKDIR /

COPY --from=build /dirtyhttp-example /dirtyhttp-example

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/dirtyhttp-example"]

The output container image in the above scenario is the second [runtime] one, based on gcr.io/distroless/base-debian10 , where the only addition to that image is the application binary which was built in a full-fat Golang image. Let’s build it:

1
 docker build -t go-example:semi -f Dockerfile.semi .

Semi-Skimmed

The “semi” version of our container image is already significantly smaller - 26.6MB! This is just under 15 times smaller than our previous “fat” version.

To run the container image, you’d use the following command:

1
 docker run -it --rm -p 8080:8080 go-example:semi

The Magic Soya Bean

We’re at a good size now that we’re utilising Google’s stripped-out runtime base image - but we can go even further.

Because we know our codebase is pure Golang code and doesn’t depend on any C code, we can build a statically linked binary in our stage-one (build) image and use the smallest base image available - scratch - as the base image our stage-two (runtime) image:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Builder image
FROM golang:alpine AS build
RUN apk update && apk add --no-cache ca-certificates && update-ca-certificates
WORKDIR /app

# Download our dependencies
COPY go.mod ./
COPY go.sum ./
RUN go mod download

COPY *.go ./

# Build a statically linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /dirtyhttp-example

# Super small runtime base image
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /dirtyhttp-example /dirtyhttp-example
EXPOSE 8080
ENTRYPOINT ["/dirtyhttp-example"]

Let’s quickly go over the fancy adjustments to the build step:

First, CGO_ENABLED=0 disables the C interoperability and is the part of the command that means we build a binary that’s been statically linked with any libraries we use.

Next, GOOS=linux tells the build command that we’re building a binary for the linux platform.

The -a flag forces the rebuilding of all packages, even if they’re up-to-date.

Finally, the -installsuffix flag simply separates the current build output from any default build output in the package installation directory.

Now that we’ve covered that off, let’s see how the container image looks:

1
 docker build -t go-example:soya -f Dockerfile.soya .

Soya Bean

We’ve achieved an even slimmer container image - 6.83MB! Our “soya” image is just under 4 times smaller than our semi-skimmed image and a massive 58 times smaller than our full fat image!

To run the container image, you’d use the following command:

1
 docker run -it --rm -p 8080:8080 go-example:soya

Summary

Typically, container registry SKUs place limits on storage space, image upload bandwidth and image download bandwidth. Increasing, or surpassing those limits costs cash money.

With that in mind, it’s our duty as penny-pinching product teams to ensure that our container images are as small as possible.

Taking advantage of multi-stage container builds has a huge impact on container image size - a step that should be taken regardless of programming language.

With Golang, we’ve been able to take the multi-stage build one step further by building a binary that will run on a FROM scratch base image. I can imagine that this could be applicable to any compiled language that doesn’t need a runtime (C++, Rust, etc).

I know that Spring Native is in beta right now - once it’s out of beta, I’d love to see if comparable image sizes can be built with Java.

With containers this small, it’d be a while before you start paying more for your platform!