Containers

I’ve been writing an API in Golang for a personal project, which is deployed using Google Cloud Run.

Cloud Run currently (August 2019) is a beta service from Google, and is a managed compute platform that automatically scales stateless Docker Containers.

I like that Cloud Run is language agnostic - it just needs a container image - all of the server infrastructure is provided for you by Google. As long as the container conforms to the (very simple and logical) Cloud Run container contract then you’re ready to deploy your code as either serverless or on your own GKE cluster - very cool.

I’ve been interested in Docker and Kubernetes technologies for a little while now and gave a talk at the Isle of Man Tech Club earlier this year entitled Docker & Kubernetes - 11 Feb 19

I’ll walk through using Golang with Docker containers.

Let’s start off with a Hello World program

package main
import "fmt"
func main() {
    fmt.Println("Hello World")
}

We can then build and run it using the Golang command line tools

$ go build helloworld.go
$ ./helloworld
Hello World
$

So we have a working program, lets look at our files

$ ls -lh hello*
-rwxr-xr-x  1 jm  staff   2.0M 29 Jul 20:11 helloworld
-rw-r--r--  1 jm  staff    72B 29 Jul 20:04 helloworld.go
$

Golang has produced a single binary of 2MB from the 72 bytes of source code. One of the great things about Golang is that a single binary is produced. That’s all good, but of course we want the hello world as a docker image.Here is the simple docker file

FROM golang:alpine
WORKDIR /app
ADD . /app
RUN cd /app && go build -o helloworld
ENTRYPOINT ./helloworld

Now lets build the dockerfile using the command docker build.

$ docker build .
Sending build context to Docker daemon  2.157MB
Step 1/5 : FROM golang:alpine
alpine: Pulling from library/golang
050382585609: Already exists
0bb4ee3360d7: Already exists
893f09c2afb0: Already exists
db25f79b026e: Already exists
4387e72e4ead: Already exists
Digest: sha256:1b8a5e7aad9be99e4d795ab2e108270ccaa6ca1bceb34d46489e91cc5cd09c36
Status: Downloaded newer image for golang:alpine
 ---> 6b21b4c6e7a3
Step 2/5 : WORKDIR /app
 ---> Running in 71a5e9d7eae7
Removing intermediate container 71a5e9d7eae7
 ---> 5e992b45eb7b
Step 3/5 : ADD . /app
 ---> e44ce1bb624d
Step 4/5 : RUN cd /app && go build -o helloworld
 ---> Running in a9b977a27ca1
Removing intermediate container a9b977a27ca1
 ---> efca4b287101
Step 5/5 : ENTRYPOINT ./helloworld
 ---> Running in 39fc108a1041
Removing intermediate container 39fc108a1041
 ---> 2b9d30d1952b
Successfully built 2b9d30d1952b
$

and check the size of the image using the command docker images

$ docker images
REPOSITORY   TAG        IMAGE ID       CREATED          SIZE
<none>       <none>     2b9d30d1952b   10 minutes ago   354MB
golang       alpine     6b21b4c6e7a3   2 weeks ago      350MB
$

The container is now ready for use, and we can see that the golang-alpine images is shown to, lets test it our freshly created container by running container 2b

$ docker run 2b
Hello World

It appears to be working fine, but 354MB seems to be a bit heavy, whats the reason for this? The base image golang:alpine is a good image for building Golang programs, but we don’t need any of the build tools at runtime. There is a technique called Multi-stage Docker builds which is well explained here. The basic premise is that after building the program using say the golang:alpine image, that you copy the binary and the minimum files that are required for the application into a minimal container for deployment. This has the following advantages:

  • The images are smaller, so therefore less disk space is required

  • Images should load and startup faster when deployed (important for orchestrated or serverless deployment methods)

  • There are no extra binaries included in the final image, reducing the attack surface, and improving security

Let’s look at the dockerfile for a two stage build

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
FROM golang:1.12-alpine@sha256:1121c345b1489bb5e8a9a65b612c8fed53c175ce72ac1c76cf12bbfc35211310 as builder
# Force the go compiler to use modules
ENV GO111MODULE=on
# Create the user and group files to run unprivileged 
RUN mkdir /user && \
    echo 'nobody:x:65534:65534:nobody:/:' > /user/passwd && \
    echo 'nobody:x:65534:' > /user/group
RUN apk update && apk add --no-cache git ca-certificates tzdata 
RUN mkdir /build 
COPY . /build/
WORKDIR /build 
# Import the code
COPY ./ ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o helloworld .
FROM scratch AS final
LABEL author="John Middleton"
# Import the time zone files
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Import the user and group files
COPY --from=builder /user/group /user/passwd /etc/
# Import the CA certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Import the compiled go executable
COPY --from=builder /build/helloworld /
WORKDIR /
# Run as unpriveleged
USER nobody:nobody
ENTRYPOINT ["/helloworld"]
# expose port
EXPOSE 8080

There is lots to see here, and explain, but lets build the container, and see what happens.

$ docker build .
Sending build context to Docker daemon  2.158MB
Step 1/19 : FROM golang:1.12-alpine@sha256:1121c345b1489bb5e8a9a65b612c8fed53c175ce72ac1c76cf12bbfc35211310 as builder
 ---> 6b21b4c6e7a3
Step 2/19 : ENV GO111MODULE=on
 ---> Running in 36e4e29908ed
Removing intermediate container 36e4e29908ed
 ---> 086fab1bbccf
Step 3/19 : RUN mkdir /user &&     echo 'nobody:x:65534:65534:nobody:/:' > /user/passwd &&     echo 'nobody:x:65534:' > /user/group
 ---> Running in faac2f273040
Removing intermediate container faac2f273040
 ---> 660d34124db8
Step 4/19 : RUN apk update && apk add --no-cache git ca-certificates tzdata
 ---> Running in 719f9bbe9be4
fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/community/x86_64/APKINDEX.tar.gz
v3.10.1-40-g92381611d0 [http://dl-cdn.alpinelinux.org/alpine/v3.10/main]
v3.10.1-47-g7d4b654c6b [http://dl-cdn.alpinelinux.org/alpine/v3.10/community]
OK: 10335 distinct packages available
fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/community/x86_64/APKINDEX.tar.gz
(1/6) Installing nghttp2-libs (1.38.0-r0)
(2/6) Installing libcurl (7.65.1-r0)
(3/6) Installing expat (2.2.7-r0)
(4/6) Installing pcre2 (10.33-r0)
(5/6) Installing git (2.22.0-r0)
(6/6) Installing tzdata (2019a-r0)
Executing busybox-1.30.1-r2.trigger
OK: 24 MiB in 21 packages
Removing intermediate container 719f9bbe9be4
 ---> a279d783d34c
Step 5/19 : RUN mkdir /build
 ---> Running in 1457f2497cdb
Removing intermediate container 1457f2497cdb
 ---> 702ad71a88c4
Step 6/19 : COPY . /build/
 ---> eae4818065f5
Step 7/19 : WORKDIR /build
 ---> Running in 7f695063d9a6
Removing intermediate container 7f695063d9a6
 ---> 461e56ea85e8
Step 8/19 : COPY ./ ./
 ---> 9c0c007e39b2
Step 9/19 : RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o helloworld .
 ---> Running in b0f3c7b29b15
go: creating new go.mod: module 
Removing intermediate container b0f3c7b29b15
 ---> b4a554e416c5
Step 10/19 : FROM scratch AS final
 ---> 
Step 11/19 : LABEL author="John Middleton"
 ---> Running in aa06697fe096
Removing intermediate container aa06697fe096
 ---> c4830ef1c9f9
Step 12/19 : COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
 ---> e81003fb8567
Step 13/19 : COPY --from=builder /user/group /user/passwd /etc/
 ---> 170e6c2bfdc4
Step 14/19 : COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 ---> 61c85ce661e4
Step 15/19 : COPY --from=builder /build/helloworld /
 ---> 4a70be015f98
Step 16/19 : WORKDIR /
 ---> Running in 7e7c0d39cde0
Removing intermediate container 7e7c0d39cde0
 ---> edde0374db61
Step 17/19 : USER nobody:nobody
 ---> Running in 68657a5e5558
Removing intermediate container 68657a5e5558
 ---> db39f22e65c3
Step 18/19 : ENTRYPOINT ["/helloworld"]
 ---> Running in 3427a204f06c
Removing intermediate container 3427a204f06c
 ---> df0e6f8c47e0
Step 19/19 : EXPOSE 8080
 ---> Running in f2203f9f2ef9
Removing intermediate container f2203f9f2ef9
 ---> 468bbdfc5f9a
Successfully built 468bbdfc5f9a
$

Lets see the size of the container

$ docker images 
REPOSITORY   TAG       IMAGE ID            CREATED             SIZE
<none>       <none>    468bbdfc5f9a        3 minutes ago       3.4MB
$ 

That’s impressive, our container has “shrunk” to only 3.4MB !

$ docker run 468
Hello World
$

And it works! The Dockerfile is now built from a scratch container, with only the minimum necessary binaries, and running in its own restricted-permissions user namespace

This is a trivial hello world example, but in a future posts, I’ll show what the results were with a simple REST API I’ve been working on, and also serverless deployment using Google Cloud Run.