The first time I got involved with Docker was three and a half years ago while I was still an undergraduate student. By that time the only thing that used to matter to me in regards to Docker was the fact that I was able to run “a minimalistic VM” (which is almost an absurd way of referring to Docker or Linux containers in general). Remember the time when the blogs were all about “how a container compares to a VM”?

Some years later (and having dealt with Docker in production a lot) the most interesting piece for me (and others) became the image - this immutable thing that carries all of your dependencies. Just create a little recipe specifying the steps to reach the final desired state and ship that packed filesystem. “Tarball++”.

In regards to Go, do we need much effort to create a working image? Totally not!

For almost every Go project I tend to always structure my Golang application in the same way:

.
├── .editorconfig
├── Dockerfile          # By placing the Dockerfile on the root
│                       # we can make it very simple - when executing
│                       # the `docker build` command from the root
│                       # the context of the build will carry all the
│                       # relevant Go files and dependencies (assuming 
│                       # you have a `vendor` directory at least).
├── Makefile
├── VERSION
├── vendor
├── lib                 # a golang package
│   ├── foo.go
│   ├── foo_test.go
│   ├── something.go
│   └── something_test.go
├── anotherpackage      # another go package
│   ├── bar.go
│   └── bar_test.go
└── main.go             # a `main` file so that one can reference 
                        # the project right from the repository name.

This way I have to make minimal changes to this Dockerfile and the Makefile that I use (check the Makefile at Minimal Golang Makefile).

By keeping it simple like that we can build almost any kind of docker images for all sorts of golang projects without having to learn weird makefile syntax or something complex for such simple task.

The snippet

This is how the final Dockerfile looks like:

# Retrieve the `golang:alpine` image to provide us the 
# necessary Golang tooling for building Go binaries.
# Here I retrieve the `alpine`-based just for the 
# convenience of using a tiny image.
FROM golang:alpine as builder

# Add the `main` file that is really the only golang 
# file under the root directory that matters for the 
# build 
ADD ./main.go /go/src/github.com/cirocosta/l7/main.go

# Add all the files from the packages that I own
ADD ./lib /go/src/github.com/cirocosta/l7/lib

# Add vendor dependencies (committed or not)
# I typically commit the vendor dependencies as it
# makes the final build more reproducible and less
# dependant on dependency managers.
ADD ./vendor /go/src/github.com/cirocosta/l7/vendor

# 0.    Set some shell flags like `-e` to abort the 
#       execution in case of any failure (useful if we 
#       have many ';' commands) and also `-x` to print to 
#       stderr each command already expanded.
# 1.    Get into the directory with the golang source code
# 2.    Perform the go build with some flags to make our
#       build produce a static binary (CGO_ENABLED=0 and 
#       the `netgo` tag).
# 3.    copy the final binary to a suitable location that
#       is easy to reference in the next stage
RUN set -ex && \
  cd /go/src/github.com/cirocosta/l7 && \       
  CGO_ENABLED=0 go build \
        -tags netgo \
        -v -a \
        -ldflags '-extldflags "-static"' && \
  mv ./l7 /usr/bin/l7

# Create the second stage with the most basic that we need - a 
# busybox which contains some tiny utilities like `ls`, `cp`, 
# etc. When we do this we'll end up dropping any previous 
# stages (defined as `FROM <some_image> as <some_name>`) 
# allowing us to start with a fat build image and end up with 
# a very small runtime image. Another common option is using 
# `alpine` so that the end image also has a package manager.
FROM busybox

# Retrieve the binary from the previous stage
COPY --from=builder /usr/bin/l7 /usr/local/bin/l7

# Set the binary as the entrypoint of the container
ENTRYPOINT [ "l7" ]

That’s it! Just run docker build -t <image_name> . in the root directory of your project and you’re done.

A simple example of this is cirocosta/l7, a simplistic “load-balancer” that I created to try out some libs (definitely not ready for production - not it’s goal).

Notes

You might want to end up with a FROM alpine instead of FROM busybox and then run apk add --update ca-certificates if your binary needs to perform requests to HTTPS endpoints - just using busybox will lead to an image that doesn’t contain root CA certificates which would make HTTPS requests fail.

If you’re interested in learning how to deal with Docker, Golang or just wanting to learn more about software engineering in general, make sure you subscribe to the mailing list! Every week I send to your inbox some stuff that I’d really like to receive.

finis