Building Docker images in Go

For the NearForm Node.js Docker distribution we we wanted to add some flexibility to our build process in order to be quicker to respond to changes in the ecosystem.

We are currently using “make” to build the docker images which requires a configure step. Since these images never really get built anywhere else than in an automated fashion, the configure step became more and more of a burden in duplicating configuration one too many times.

After evaluating a few options and looking at the technology that’s being used in the DevOps ecosystem, we decided to use Go for building these images. Mage is a wonderful wrapper around Go functions to treat them like make targets. For the specifics on Mage, please have a look at their documentation.

Go was not yet everyday business for me, so this was a great opportunity to learn it and put it into a live pipeline here at NearForm. Luckily I have enough colleagues with deep Go expertise to help me out. I’m taking you through the process and sharing my learnings in this post.

Docker SDK

In an initial attempt I wrapped the Docker cli in Go functions and quickly got a working equivalent to the makeFile. The build target looks like this:

The sh.Exec streams the output of the shell command to stdOut so the Docker build process can be followed as it takes place. This is in contrast to the built-in go exec which gives you the output after the command had finished.

While this worked pretty well, constructing a shell command and then just relying on the output of the command or the exit code was a thin interface. There were also going to be targets for clean, publish, squash and test, which all required Docker operations. This made me decide to use the native Go client for Docker. The Docker website has pretty well documented examples for using the API, however I struggled to find a comprehensive example to help me build a docker image.

The first bump I hit was the Docker version. When instantiating the Docker client in Go it uses a version that seemed to be incompatible with my Docker daemon. So I defined a constant to set the API version to 1.37:


const defaultDockerAPIVersion = "v1.37"
…
cli, _ := client.NewClientWithOpts(client.WithVersion(defaultDockerAPIVersion))

This solved the issue with incompatible client. The next problem was to get the docker daemon to find my dockerfile.



func build-api() error {
  cli, err := client.NewClientWithOpts(client.WithVersion(defaultDockerAPIVersion))
  if err != nil {
      panic(err)
  }
  fmt.Print(cli.ClientVersion())
  opt := types.ImageBuildOptions{
      Dockerfile:   "image/centos7/Dockerfile",
  }
  _, err = cli.ImageBuild(context.Background(), nil, opt)
  if err == nil {
      fmt.Printf("Error, %v", err)
  }
}

The above gave me the error “Error response from daemon: Cannot locate specified Dockerfile: image/centos7/Dockerfile”. After searching for a solution, I came to the conclusion that I had to send the buildcontext to the daemon. Similar to what you see when you build a container with the docker cli:


$ docker build -f image/centos7/Dockerfile .

This Stackoverflow question put me on the right track to packaging the build context into a tar file. It uses a Go library called archivex which greatly simplifies the creation of the tar file and adding directories to it.

The above code creates the tar file and adds the files and directories required to build the Node.js distribution image. This tar file serves as the build context for the “ImageBuild” method in the Docker client package.

The mage target

The entire target for the mage build system looks like this


// Build the container using the native docker api
func Build() error {
  s := ParseEnvVars()
  InstallSources(s)
  tar := new(archivex.TarFile)
  tar.Create("/tmp/nodejs-distro.tar")
  tar.AddAll("contrib", true)
  tar.AddAll("src", true)
  tar.AddAll("test", true)
  tar.AddAll("s2i", true)
  tar.AddAll("help", true)
  tar.AddAll("image", true)
  tar.AddAll("licenses", true)
  tar.Close()
  dockerBuildContext, err := os.Open("/tmp/nodejs-distro.tar")
  defer dockerBuildContext.Close()
  cli, _ := client.NewClientWithOpts(client.WithVersion(defaultDockerAPIVersion))
  args := map[string]*string{
    "PREBUILT":     &s.Prebuilt,
    "NODE_VERSION": &s.Nodeversion,
  }
  options := types.ImageBuildOptions{
    SuppressOutput: false,
    Remove:         true,
    ForceRemove:    true,
    PullParent:     true,
    Tags:           getTags(s),
    Dockerfile:     "image/" + s.Os + "/Dockerfile",
    BuildArgs:      args,
  }
  buildResponse, err := cli.ImageBuild(context.Background(), dockerBuildContext, options)
  if err != nil {
    fmt.Printf("%s", err.Error())
  }
  defer buildResponse.Body.Close()
  fmt.Printf("********* %s **********\n", buildResponse.OSType)

  termFd, isTerm := term.GetFdInfo(os.Stderr)
  return jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stderr, termFd, isTerm, nil)
}

We start by parsing the environment variables using the envconfig package.


s := ParseEnvVars()

It basically removed a bit of boilerplate code when you are extracting variables from the environment. Check our Node.js distribution repository for more details on that.

Then we call a function that runs the get_node_sources.sh script.


InstallSources(s)

Among other things, this script clones the Node.js repo and checks out the requested tag.

Then we create the tar file and add the required directories to it.


tar := new(archivex.TarFile)
  tar.Create("/tmp/nodejs-distro.tar")
  tar.AddAll("contrib", true)
  tar.AddAll("src", true)
  tar.AddAll("test", true)
  tar.AddAll("s2i", true)
  tar.AddAll("help", true)
  tar.AddAll("image", true)
  tar.AddAll("licenses", true)
  tar.Close()

Exactly like we explained above.

Subsequently the docker client is instantiated and the options are assembled


cli, _ := client.NewClientWithOpts(client.WithVersion(defaultDockerAPIVersion))
args := map[string]*string{
    "PREBUILT":     &s.Prebuilt,
    "NODE_VERSION": &s.Nodeversion,
  }
  options := types.ImageBuildOptions{
    SuppressOutput: false,
    Remove:         true,
    ForceRemove:    true,
    PullParent:     true,
    Tags:           getTags(s),
    Dockerfile:     "image/" + s.Os + "/Dockerfile",
    BuildArgs:      args,
  }

We are using the constant containing the default docker api version. The BuildArgs do the same things as the command line “–build-arg” in my first version. The ImageBuild method allows for a great number of options to be supplied. These work for my current use case, not to say I won’t be adding more down the line.

In case your build context is not the current directory, make sure you specify the relative path to the “Dockerfile” inside the tar archive.

Next up is the actual execution of the command:


buildResponse, err := cli.ImageBuild(context.Background(), dockerBuildContext, options)
  if err != nil {
    fmt.Printf("%s", err.Error())
  }
  defer buildResponse.Body.Close()
  fmt.Printf("********* %s **********\n", buildResponse.OSType)

We take the output and check it for errors, obviously and also make sure the reader gets closed at the end. For visibility sake we log the OSType on the console.

Lastly, we stream the output of the build process to StdOut.


termFd, isTerm := term.GetFdInfo(os.Stderr)
return jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stderr, termFd, isTerm, nil)

This takes the buildResponse.Body stream and sends it to the terminal on StdOut.

Conclusion

As a first observation, I am pleased that it took me only 1.5 days to become productive in Go. I have read those types of comments about Go many times, so I think I can confirm them here as well.

Secondly, the ecosystem reminds me a lot of the one from Node.js; writing common things from scratch is mainly an exercise to understand the inner workings because there is an enormous world of modules available already.

Do I like the compiler warnings; yes! They give me a similar sensation as when I have my eslint properly set up and running on code change.

I still have a lot to learn about Go and the ecosystem, so if you have any suggestions on how I can improve this code, please open a PR.

Image: unsplash-logoChristopher Burns

Top