Building Better Docker Images

It’s been about eight months since I wrote down some of my thoughts on building good docker images. The docker ecosystem has continued moving quickly and I’m pleased to say that most of the principles listed in the old article have aged well. I don’t have a ton to add, but here’s a few things that I’ve discovered since then:

  • Base images off of Alpine

    alpine is a 5 megabyte image based on Alpine Linux (Docker hub link). Despite being small, it comes with a package manager (apk) with access to a well-maintained, modern package repository. This base image is ideal for building tiny application containers (e.g. a 6 MB redis image, a 17 MB node.js 0.10 image, a 6 MB PostgreSQL client).

    Alpine presents some challenges if you need to stray beyond the package manager. Alpine uses musl libc, so most dynamically-linked “Linux” binaries that you can download off the web will not run. Instead, you may find yourself building from source, in which case it’s helpful to know that the Alpine build-base package is roughly equivalent to Debian’s build-essential. Alpine’s default shell is ash, but you can install bash through the package manager if you need or prefer.

  • Write tests

    I picked this up from the bright guys at Aptible. When you want to guarantee that your image has a certain feature set, it can be useful to run a suite of tests as part of the Dockerfile. If the image is just for you, this is probably overkill, but if other people are using or building off of your image, or if you want to make things explicit and maintainable (e.g. for others on your development team), it can be very helpful.

    With tests in the Dockerfile, a compiled and published image comes with guarantees about the image’s behavior. If you try to rebuild the image and the tests fail, you get some indication of what’s changed since then (typically some external state, as discussed in the previous article).

    Here’s an example of some simple tests from my jbergknoff/sass repository (link):

    #!/bin/sh
    echo --- Tests ---
    
    echo -n "it should install sassc 3.2.1... "
    sass -v | grep sassc | grep "3.2.1" > /dev/null
    [ "$?" -ne 0 ] && echo nope && exit 1
    echo ok
    
    echo -n "it should compile SCSS... "
    echo '$blue: #00f; .thing { color: $blue; }' > /tmp/test.scss
    sass /tmp/test.scss | grep "color: #00f" > /dev/null
    [ "$?" -ne 0 ] && echo nope && exit 1
    rm /tmp/test.scss
    echo ok
    

    This content is in a file test.sh which gets RUN as part of the Dockerfile. If a test fails, the Dockerfile build fails.

    If you’d prefer a test runner/framework, consider bats. It’s a light wrapper around bash scripting, adding some structure for testing (the ability to skip tests, setup/teardown steps, etc.).

  • Use scripts

    Sometimes it makes sense to break out a part of a Dockerfile into a shell script. For instance, while it’s good to clean up after installing a package through a package manager, it can get awkward to have a long && chain in a RUN command just to enforce cleanliness.

    Instead, consider making a script. Here is another example from jbergknoff/sass, the build.sh script (link):

    #!/bin/sh
    
    # build
    apk --update add git build-base
    git clone https://github.com/sass/sassc
    cd sassc
    git clone https://github.com/sass/libsass
    SASS_LIBSASS_PATH=/sassc/libsass make
    
    # install
    mv bin/sassc /usr/bin/sass
    
    # cleanup
    cd /
    rm -rf /sassc
    apk del git build-base
    apk add libstdc++ # sass binary still needs this because of dynamic linking.
    rm -rf /var/cache/apk/*
    

    This script is responsible for grabbing the SASS source code, building it, and then cleaning up. Obviously the final image (a 9 megabyte SASS application container) shouldn’t have git installed, but imagine encoding that entire sequence as one big RUN simply to keep git out. It seems incongruous.

    Because all of the installation and cleanup happens in one RUN command, there is no extra bloat hanging around (recall that files introduced in one RUN and removed in a subsequent RUN are still taking up space in the image). This technique can help all sorts of Dockerfiles, making them cleaner and easier to understand. In the case of building from source, it’s almost always beneficial.