Testing a Go-based S2I builder image

Testing a Go-based S2I builder image

In the third article in this series on Source-to-Image for Golang applications, build your application image and take it out for a spin.

gopher illustrations
Image credits : 
x

Get the newsletter

Join the 85,000 open source advocates who receive our giveaway alerts and article roundups.

In the first two articles in this series, we explored the general requirements of a Source To Image (S2I) system and prepared an environment specifically for a Go (Golang) application. Now let's give it a spin.

Building the builder image

Once the Dockerfile and Source-to-Image (S2I) scripts are ready, the Golang builder image can be created with the docker build command:

docker build -t golang-builder .

This will produce a builder image named golang-builder with the context of our current directory.

Building the application image

The golang-builder image is not much use without an application to build. For this exercise, we will build a simple hello-world application.

GoHelloWorld

Let's meet our test app, GoHelloWorld. Download the latest version of Go if you want to follow along. There are two important (for this exercise) files in this repository:

// goHelloWorld.go
package main

import "fmt"

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

This is a very basic app, but it will work fine for testing the builder image. We also have a basic test for GoHelloWorld:

// goHelloWorld_test.go
package main

import "testing"

func TestMain(t *testing.T) {
  t.Log("Hello World!")
}

Build the application image

Building the application image entails running the s2i build command with arguments for the repository containing the code to build (or . to build with code from the current directory), the name of the builder image to use, and the name of the resulting application image to create.

$ s2i build https://github.com/clcollins/goHelloWorld.git golang-builder go-hello-world

To build from a local directory on a filesystem, replace the Git URL with a period to represent the current directory. For example:

$ s2i build . golang-builder go-hello-world

Note: If a Git repository is initialized in the current directory, S2I will fetch the code from the repository URL rather than using the local code. This results in local, uncommitted changes not being used when building the image (if you're unfamiliar with what I mean by "uncommitted changes," brush up on your Git terminology over here). Directories that are not Git-initialized repositories behave as expected.

Run the application image

Once the application image is built, it can be tested by running it with the Docker command. Source-to-Image has replaced the CMD in the image with the run script created earlier so it will execute the /go/src/app/app binary created during the build process:

$ docker run go-hello-world
Hello World!

Success! We now have a compiled Go application inside a Docker image created by passing the contents of a Git repo to S2I and without needing a special Dockerfile for our application.

The application image we just built includes not only the application, but also its source code, test code, the S2I scripts, Golang libraries, and much of the Debian Linux distribution (because the Golang image is based on the Debian base image). The resulting image is not small:

$ docker images | grep go-hello-world
go-hello-world      latest      75a70c79a12f      4 minutes ago      789 MB

For applications written in languages that are interpreted at runtime and depend on linked libraries, like Ruby or Python, having all the source code and operating system are necessary to run. The build images will be pretty large as a result, but at least we know it will be able to run. With these languages, we could stop here with our S2I builds. 

There is the option, however, to more explicitly define the production requirements for the application.

Since the resulting application image would be the same image that would run the production app, I want to assure the required ports, volumes, and environment variables are added to the Dockerfile for the builder image. By writing these in a declarative way, our app is closer to the Twelve-Factor App recommended practice. For example, if we were to use the builder image to create application images for a Ruby on Rails application running Puma, we would want to open a port to access the webserver. We should add the line PORT 3000 in the builder Dockerfile so it can be inherited by all the images generated from it.

But for the Go app, we can do better.

Build a runtime image

Since our builder image created a statically compiled Go binary with our application, we can create a final "runtime" image containing only the binary and none of the other cruft.

Once the application image is created, the compiled GoHelloWorld app can be extracted and put into a new, empty image using the save-artifacts script.

Runtime files

Only the application binary and a Dockerfile are required to create the runtime image.

Application binary

Inside of the application image, the save-artifacts script is written to stream a tar archive of the app binary to stdout. We can check the files included in the tar archive created by save-artifacts with the -vt flags for tar:

$ docker run go-hello-world /usr/libexec/s2i/save-artifacts | tar -tvf -
-rwxr-xr-x 1001/root   1997502 2019-05-03 18:20 app

If this results in errors along the lines of "This does not appear to be a tar archive," the save-artifacts script is probably outputting other data in addition to the tar stream, as mentioned above. We must make sure to suppress all output other than the tar stream.

If everything looks OK, we can use save-artifacts to copy the binary out of the application image:

$ docker run go-hello-world /usr/libexec/s2i/save-artifacts | tar -xf -

This will copy the app file into the current directory, ready to be added to its own image.

Dockerfile

The Dockerfile is extremely simple, with only three lines. The FROM scratch source denotes that it uses an empty, blank parent image. The rest of the Dockerfile specifies copying the app binary into /app in the image and using that binary as the image ENTRYPOINT:

FROM scratch
COPY app /app
ENTRYPOINT ["/app"]

Save this Dockerfile as Dockerfile-runtime.

Why ENTRYPOINT and not CMD? We could do either, but since there is nothing else in the image (no filesystem, no shell), we couldn't run anything else anyway.

Building the runtime image

With the Dockerfile and binary ready to go, we can build the new runtime image:

$ docker build -f Dockerfile-runtime -t go-hello-world:slim .

The new runtime image is considerably smaller—just 2MB!

$ docker images | grep -e 'go-hello-world *slim'
go-hello-world      slim      4bd091c43816      3 minutes ago     2 MB

We can test that it still works as expected with docker run:

$ docker run go-hello-world:slim
Hello World!

Bootstrapping s2i with s2i create

While we hand-created all the S2I files in this example, the s2i command has a sub-command to help scaffold all the files we might need for a Source-to-Image build: s2i create.

Using the s2i create command, we can generate a new project, creatively named go-hello-world-2 in the ./ghw2 directory:

$ s2i create go-hello-world-2 ./ghw2
$ ls ./ghw2/
Dockerfile  Makefile  README.md  s2i  test

The create sub-command creates a placeholder Dockerfile, a README.md with information about how to use Source-to-Image, some example S2I scripts, a basic test framework, and a Makefile. The Makefile is a great way to automate building and testing the Source-to-Image builder image. Out of the box, running make will build our image, and it can be extended to do more. For example, we could add steps to build a base application image, run tests, or generate a runtime Dockerfile.

Conclusion

In this tutorial, we have learned how to use Source-to-Image to build a custom Golang builder image, create an application image using s2i build, and extract the application binary to create a super-slim runtime image.

In a future extension to this series, I would like to look at how to use the builder image we created with OKD to automatically deploy our Golang apps with buildConfigs, imageStreams, and deploymentConfigs. Please let me know in the comments if you are interested in me continuing the series, and thanks for reading.

About the author

Chris Collins
Chris Collins - Chris Collins is a senior automation engineer and the web architecture lead at Duke University’s Office of Information Technology. He’s a container and automation evangelist, helps leads adoption of containers within the university, and loves to talk about them with anyone who will listen, much to the annoyance of the co-workers who sit closest to him.