When you are playing around with Docker, you quickly notice that you are downloading large numbers of megabytes as you use preconfigured containers. A simple Ubuntu container easily exceeds 200MB and as software is installed on top of it, the size increases. In some use cases, you do not need everything that comes with Ubuntu. For example, if you want to run a simple web server, written in Go, there is no need for any tool around that at all.
I have been searching for the smallest possible container to start with and found this one:
The scratch image is perfect. Literally perfect! It is elegant, small and fast. It does not contain any bugs, security leaks, slow code or technical debt. And that is because it is basically empty. Except for a bit of metadata added by Docker. In fact, you could have created this scratch image yourself with this command as described in the Docker documentation:
So that is it, the smallest possible Docker image. End of blog post!
... or is there something more we can say about this? For example, how do you use the scratch base image? It turns out this brings some challenges of its own.
Creating content for the scratch image
What can we run on an empty base image? An executable without dependencies. Do you have executables without dependencies?
Obviously, I cannot compile my webserver inside the scratch container as there is no Go compiler in it. And as I am working on a Mac, I also cannot compile a Linux binary just like that. (Actually, it is possible to cross-compile GoLang sources to different platforms, but that is material for another blog post)
So I first need a Docker container with a Go compiler. Let's start simple:
Inside this container, I can build the Go web server, which I have committed in a GitHub repository:
The go get command is a variant of the go build command that allows fetching and building remote dependencies. You can start the resulting executable with:
This works. But it is not what we want. We need the hello world container to run inside the scratch container. So, in fact, we need a Dockerfile saying:
and then start that. Unfortunately, the way we started the google/golang container, there is no way to build this Dockerfile. So first, we need a way to access Docker from within the container.
Calling Docker from within Docker
When you use Docker, sooner or later you run into the need to control Docker from within Docker. There are multiple ways to accomplish this. You could use recursion and run Docker inside Docker. However, that seems overly complex and again leads to large containers. You can also provide access to the Docker server outside the instance with a few additional command line options:
Before you continue, please rerun the Go compiler, as Docker forgot our previous compilation during the restart:
When starting the container, the -v flag creates a volume inside the Docker container and allows you to provide a file from the Docker machine as input. The /var/run/docker.sock is the Unix socket that allows access to the Docker server. The $(which docker) part is a clever way to provide the path for the docker executable inside the container without hardcoding it. However, be careful when you use this command on an Apple when using boot2docker. If the docker executable is installed in a different location than it is installed in boot2docker's virtual machine, this results in a mismatch. It will be the executable inside the boot2docker virtual server that gets inserted into the container. So you may want to replace $(which docker) with /usr/local/bin/docker which is hardcoded. Similarly, if you run a different system, there is a chance that the /var/run/docker.sock has a different location and you need to adjust it accordingly.
Now you can use the Dockerfile inside the google/golang container in the $GOPATH directory, which points to /gopath in this example. Actually, I already checked this Dockerfile into GitHub. So you can copy it from the Go build directory to the desired location like this:
You need to copy this as the compiled binary is now located in $GOPATH/bin and it is not possible to include files from parent directories when building a Dockerfile. So after copying, the next step is:
And if all goes, well, Docker responds with something like:
Successfully built 6ff3fd5a381d
Which allows you to run the container:
But unfortunately, now Docker responds with:
2014/07/02 17:06:48 no such file or directory
So what is going on? We have a statically linked executable inside a scratch container. Did we make a mistake?
As it turns out, Go does not statically link libraries. Or at least not all libraries. Under Linux, we can see the dynamically linked libraries for an executable with the ldd command:
Which responds with:
linux-vdso.so.1 => (0x00007fff039fe000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f61df30f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f61def84000)
So before we can run the Hello World webserver, we need to tell the Go compiler to actually do static linking.
Creating statically linked executables in Go
In order to create statically linked executables, we need to tell Go to use the cgo compiler rather than the go compiler. The command to do so is:
The CGO_ENABLED environment variable tells Go to use the cgo compiler rather than the go compiler. The -a flag tells Go to rebuild all dependencies. Otherwise you still end up with dynamically linked dependencies. And finally the -ldflags '-s' flag is a nice extra. It reduces the file size of the resulting executable by roughly 50%. You can also do this without the cgo compiler. The size reduction is a result from removing debug information.
Just to be sure, rerun the ldd command.
It should now respond with:
not a dynamic executable
You can also rerun the steps for creating the Docker container around the executable from scratch:
And if all goes well, Docker responds with something like:
Successfully built 6ff3fd5a381d
Which allows you to run the container:
And this time it should respond with:
Started, serving at 8080
Until so far, there were many manual steps and there is a lot of room for error. Let's exit from the google/golang container and continue from the surrounding machine:
You can check the existence or absence of containers and images with:
And you can do some cleaning of Docker with:
Creating a Docker container that creates a Docker container
The steps we took so far, we can also record in a Dockerfile and have Docker do the work for us:
I checked this Dockerfile into a separate GitHub repository called adriaandejonge/hellobuild. It can be built with this command:
Providing the -t flag names the image as adejonge/hellobuild and implicitly tags it as latest. These names make it easier for you to remove the image later on. Next, you can create a container from this image while providing the flags that you have seen earlier in this post:
Providing the --name hellobuild flag makes it easier to remove the container after running. In fact, you can do so right away, because after running this command, you already created the adejonge/helloworld image:
And now you can start a new container named helloworld based on the adejonge/helloworld image as you have done before:
Because all these steps are run from the same command line, without opening a bash shell inside a Docker container, you can add these steps to a bash script and run it automatically. For your convenience, I have added these bash scripts to the hellobuild GitHub repository.
Also, if you want to try the smallest possible Docker container running a Hello World web server without following all the steps described in this blog post, you can also use the pre-built image that I checked into the Docker Hub repository:
With docker images -a you can see that the size is 3.6MB. Of course, you can make it even smaller if you manage to create an executable that is smaller than the web server in Go that I wrote. In C or Assembly you may be able to do so. However, you can never make it smaller than the scratch image.