After hearing about a colleague working on a microservices project in Golang, producing Docker images under 10mb (example), I became interested in how we might go about doing the same for other languages, in particular in the .NET/Windows environment where I have spent the last several months working on a continuous delivery project at SEEK.
For the impatient, there is a GitHub repo where you can skip straight to the fun.
Buzz words aside, let’s get stuck into it.
UPDATE: I have modified the project to now use a series of Docker containers responsible for building, testing and running the entire system. Vagrant is still used as a basic Docker `shell` so the build scripts can work cross-platform.
UPDATE 26/01/2016 – Apparently small Docker images are now being referred to as ‘Microcontainers’.
Storage is cheap, why do we care how big an image is?
Well, for one, its interesting. .NET is often seen as really big and heavy, possibly to do with it generally being associated with Windows which very much is big and clunky.
But more importantly, there are at least 3 good reasons for this:
- Security – the less stuff you have on a runtime machine the smaller your attack surface is. For instance; if you don’t need a shell, and didn’t install Bash, you wouldn’t ever had any chance of being exposed to shellshock.
- Performance – following 1), if there are services running that aren’t actually required, you will get better performance out of your containers – more CPU/Memory for your app.
- Continuous Delivery pipeline speed – you want your pipeline to be fast, and anything that gets in the way of that is a sin. Docker does cache intermediate layers so this can speed things up, but mitigating this with separate build and runtime images can assist (which we shall see how to do below).
Onwards to Tiny Town
So, the first step is getting a repeatable environment where we can create and build these images in an isolated and cross-platform way. I’m a big fan of Vagrant, so we create a Vagrantfile that will provision the Mono build environment onto a Debian Linux machine where we can build and run the Docker app (Look here for the non-Vagrant version). You will need Vagrant and Virtualbox before you can proceed if you are following along:
Now we need to create a (trivially simple) Application to test (full code):
as the folder is synced into the Vagrant VM, we can now build it within the VM directly:
Finally, again from within the Vagrant box, we can cURL the application to get our obligatory “Hello World” response:
curl $(boot2docker ip):8888 Hello World
Cool!!! Now, prove to me how small it is:
[email protected] ~/ $ docker images | egrep "(mfellows|REPO)" REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE mfellows/mono-api latest 7c0b8d6ba28c 3 days ago 18.45 MB
Wow, that small – how’d you do it?!?
Creating our Dockerfile
Here is our Dockerfile:
OK, so I may have lied to you. Sorry about that. As you may have noticed, there is no Mono runtime environment on this image. In fact, there is really nothing on it at all part from a base OS, the App and a core system library. And this is the trick: if you want a small image, you need to statically compile the Mono binary.
command is the key to doing this:
mkbundle generates an executable program that will contain static
copies of the assemblies listed on the command line.
It seems this is a fairly esoteric command, so I couldn’t find a lot about it on the Interwebs, but reading through the man pages combined with experimentation, I was able to get it working with this build script:
Put simply, this basically:
- Restores Nuget packages and builds the solution using xbuild, producing console.exe
- Finds all .dll files in the ./packages directly
- Creates a static binary (–static) consoleapp from console.exe, bundling all system dependencies it requires (–deps) and all of the .dll dependencies from (2) into it
The result is a binary that can run natively on Linux without Mono other dependencies. Let’s take a look at the file:
file ./consoleapp ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, not stripped
So, there is still some work to be done to make it a real statically linked binary and also to strip it of its debug symbols (yep, we can make it even smaller!), but that is for a future post. For now, we have a 13mb binary.
The last step of build.sh is to build the docker image (line 14), which warrants a final explanation of our Dockerfile. In essence, it:
- Pulls the tiny 4.789 MB Busybox image
- Adds libc, the standard library in GNU for system calls
- Pops the static binary in and makes it the Docker container entry point
That’s all there is to it.
- Create a Production Docker image (Base) with only the basic runtime dependencies for the application. No more, no less. In this case, this is our busybox image.
- From this Base, we enrich the image with the Mono runtime and build dependencies so that we can automate the build, test and production of our static artifact.
- From the CI Image, we add any extra dev dependencies we may need (e.g. debugger etc.) to run it locally
This gives us the benefits of fast, isolated and repeatable local development with the ability to have a higher degree of confidence that our environments are at parity with one another, and exceptionally fast build and deploy times – all things we need to achieve continuous delivery.