How to build, containerize and autoreload your Go Server

I explained the structure of reread's Go API server in the previous post. Now that I have a web server that's able to respond to the /_health endpoint, I'll focus on being able to build it, test it, containerize it, and have it auto reload.

Go has some excellent tooling out of the box, so building a go project is simply executing the following command:

go build

By default, Go build will take the name of the directory the main.go file is in, which in my case is server. To name the built executable, I can give the build command an output argument:

go build -o build/reread main.go

This will create a binary with the name reread, and place it within the build folder. Now that there's a binary, lets start by containerizing the application.

While not always necessary, containerization is useful because it allows having a reproducible environment for an application. The most popular containerization platform is Docker, and I'll use that for reread.

Using Docker is simple, I need to create a configuration file, called a Dockerfile, that will contain the following pieces of information:

  • The base Docker image to build off of
  • Steps to modify the base Docker image
  • Let docker know which command to execute when the container starts

My Dockerfile looks like so:

FROM golang:1.17-alpine

WORKDIR /server

COPY . ./

RUN go build -o build/reread main.go

EXPOSE 1323

CMD [ "build/reread" ]

It is doing the following things:

  • Use golang Docker image, with the version (called a tag) of 1.17-alpine. The tag can be anything, but in this case, I've chosen one with Go version 1.17, using Alpine Linux which is a light weight linux distribution ideal for docker containers.
  • Set the working directory for the following commands to /server. When you ssh into the docker container, you'll find the library (and any associated configuration files) are present within the /server directory
  • Copy over the entire contents of the server directory from the repo over to the docker image
  • Build the go binary inside the container
  • Let docker know to expose port 1323 externally
  • Let docker know to start build/reread on container start

Now that I have a Dockerfile, I can reference this within my Docker Compose configuration. I find Docker Compose a really useful utility for local development because it allows me to orchestrate parts of my app together very easily.

For running just my server, all I need is this docker-compose.yml file:

version: '3.6'
services:
    server:
        build: './server'
        expose:
            - '8080:1323'

All I'm saying here is: I have a service called server, that you can find a Dockerfile for in ./server and expose the port 1323 of the docker container as port 8080 on my local machine. With this set up, I can just run docker-compose up and it will build my Go server, and make it available on http://localhost:8080.

While is really nice, one annoying thing was that I had to stop docker-compose and restart it whenever my code changed. This gets old pretty fast, so I needed a better solution. Luckily, the brilliant open source engineers behind comstrek/air solved this problem for me.

Air is a really useful tool that can be used with Docker to allow automatic reloading. Setting it up is also trivial:

  • Install the Air binary
  • Auto configure Air for your project by running air init
  • Update docker compose like so:
version: '3.6'
services:
  server:
    image: cosmtrek/air
    working_dir: /server
    ports:
      - '8080:1323'
    volumes:
      - ./server/:/server/

Now this looks more complex, but here's what I've changed:

  • Instead of telling docker-compose to build our project, I'm providing an image comstrek/air
  • I'm telling docker-compose to use the working directory /server for the source.
  • I'm 'mounting' the ./server directory from my machine to the /server directory in the container as a volume. This basically acts as a shared folder. Any changes that I make to the code will also show up within the docker container. When that happens, Air will automatically build and reload the server.

Now, once I run docker-compose up, I don't have to fiddle with it for the most part.

Testing

Now that running the application can be run within a container, I'd like to focus back on testing a bit. In the last article, I discussed writing tests, but never talked about how to run them. Again, Go has some really great tooling, and all you have to do is run:

go test

However, this is a bit incomplete. Go test by default only looks at the current directory. Meanwhile, I like to interleave my tests within the source code by prefixing the file name with _test. I would like Go to run all the tests. For this, Go supplies an easy fix, described in their documentation:

go test ./...

I also add some useful flags to my test file, and it looks like so:

go test -race -cover ./...

These flags tell go to warn me about race conditions and give me a test coverage percentage.

I add all these commands to my Makefile, making them easy to use. It looks like this now:

build:
    go build -o reread main.go 

test:
    go test -race -cover ./...

lint:
    golangci-lint run

run:
    go run main.go

build-docker:
    docker build --tag reread-server .

The one thing I haven't discussed earlier is the lint command. A linter is a tool to identify if you're using bad practices or certain kinds of errors in your source. While I have the linter command, VS Code also has that baked in with their Go Extension, so I don't use it often yet. It will become more useful when I start building a CICD process for reread.

I'm pretty happy with the state of the go server set up now. Next, I'll start with the Next.js part of the application.