Containers were one of the driving forces behind the modern DevOps culture. Where there used to be a strict barrier between developers and operations, is now a common, agreed upon standard. Developers are able to configure their runtime environment to their needs, focusing on what happens inside a container, IT-Ops takes care of things outside a container - how to run, scale and monitor them.
While the concept of process isolation has been around for much longer, the one thing which brought container technology to the masses was Docker in 2013. Gone should be the days when developers handed over some kind of deliverable and IT-Ops had to somehow take care of it. No more “but it works on my machine” - if it works on your machine, we will ship your machine!
With great power comes great responsibility
Given the ability to customise the runtime environment, responsibility for providing and maintaining a proper environment shifts from IT-Ops towards developers. We’re not only responsible for our application, but also the environment to run it in. Fortunate enough, we can apply well-known methods to make this effort less tedious: automated tests and test-driven development (TDD).
Meet Goss and DGoss
Goss is a neat little utility which helps you validate your server configuration.
So, how will Goss help us test our Docker images? With Goss itself it's possible to verify a multitude of properties on Linux systems, ranging from filesystem layouts, permissions and content over network ports, installed packages for various distributions to running services. This would allow us to e.g. verify the content of our Apache config, that our
apache2 service is actually enabled and running and last but not least, whether it’s listening on port 80.
Now when building a Docker image, we can apply these things too. DGoss is a wrapper script around Goss which allows us to target running Docker containers, so let’s explore how we can write Docker tests.
As mentioned earlier, Goss and DGoss currently only support Linux systems. Since we want to target Linux containers, this is not a problem for us.
We can get the latest release on GitHub:
curl -L https://github.com/aelsabbahy/goss/releases/latest/download/goss-linux-amd64 -o /usr/local/bin/goss chmod +rx /usr/local/bin/goss curl -L https://github.com/aelsabbahy/goss/releases/latest/download/dgoss -o /usr/local/bin/dgoss chmod +rx /usr/local/bin/dgoss
In case you should decide to download Goss to a location outside your
PATH, you’ll have to provide an additional environment variable
GOSS_PATH, which points to the executable.
To get a first impression, we will create a short test for the httpd Docker image. If not specified otherwise via
GOSS_FILE environment variable, the default spec file is called
This exemplary test verifies that we have a server running on TCP port 80, listening on all IP addresses. Additionally, it makes sure it serves the default page which only displays ”It works!”.
To run our tests we execute
dgoss run httpd instead of
docker run httpd. DGoss will start a container based on our specified image and copy all required files for us. Assuming the image under test works like expected we should see them succeed:
INFO: Starting docker container INFO: Container ID: 76715e17 INFO: Sleeping for 0.2 INFO: Container health INFO: Running Tests Port: tcp:80: listening: matches expectation: [true] Port: tcp:80: ip: matches expectation: [["0.0.0.0"]] HTTP: http://localhost: status: matches expectation:  HTTP: http://localhost: Body: matches expectation: [It works!] Total Duration: 0.010s Count: 4, Failed: 0, Skipped: 0 INFO: Deleting container
Test Driven Docker Development (TDDD)
Now the above example is not that interesting, let’s instead focus on building an image for e.g. a node backend. We will be using a Debian base image,
debian:10.4-slim to be precise, and change the default shell to
bash for our build.
Next to our
Dockerfile we create a
goss.yaml file as well as a little helper script to build and test our image,
The repository linked at the end of the post also contains a nodemon setup to automatically build and test the image on file changes.
Since we did not yet apply any changes to our base image, our tests will fail:
+ IMAGE=s1hofmann/node-runtime + TAG=12.18.0 + docker build -t s1hofmann/node-runtime:12.18.0 . Sending build context to Docker daemon 4.096kB Step 1/2 : FROM debian:10.4-slim ---> 108d75da320f Step 2/2 : SHELL [ "/bin/bash", "-c" ] ---> Using cache ---> 39a73e053958 Successfully built 39a73e053958 Successfully tagged s1hofmann/node-runtime:12.18.0 + dgoss run -it s1hofmann/node-runtime:12.18.0 INFO: Starting docker container INFO: Container ID: ae8fbadc INFO: Sleeping for 0.2 INFO: Container health INFO: Running Tests Group: node: exists: Expected <bool>: false to equal <bool>: true Group: node: gid: skipped User: node: exists: Expected <bool>: false to equal <bool>: true User: node: uid: skipped User: node: gid: skipped User: node: home: skipped User: node: groups: skipped User: node: shell: skipped Failures/Skipped: Group: node: exists: Expected <bool>: false to equal <bool>: true Group: node: gid: skipped User: node: exists: Expected <bool>: false to equal <bool>: true User: node: uid: skipped User: node: gid: skipped User: node: home: skipped User: node: groups: skipped User: node: shell: skipped Total Duration: 0.003s Count: 8, Failed: 2, Skipped: 6 INFO: Deleting container
Once we extended our image, our tests should pass:
+ IMAGE=s1hofmann/node-runtime + TAG=12.18.0 + docker build -t s1hofmann/node-runtime:12.18.0 . Sending build context to Docker daemon 4.096kB Step 1/3 : FROM debian:10.4-slim ---> 108d75da320f Step 2/3 : SHELL [ "/bin/bash", "-c" ] ---> Using cache ---> 39a73e053958 Step 3/3 : RUN groupadd --gid 1000 node && useradd --uid 1000 --gid node --shell /bin/bash --create-home node ---> Using cache ---> 8700babb2f3b Successfully built 8700babb2f3b Successfully tagged s1hofmann/node-runtime:12.18.0 + dgoss run -it s1hofmann/node-runtime:12.18.0 INFO: Starting docker container INFO: Container ID: 4f21c178 INFO: Sleeping for 0.2 INFO: Container health INFO: Running Tests Group: node: exists: matches expectation: [true] Group: node: gid: matches expectation:  User: node: exists: matches expectation: [true] User: node: uid: matches expectation:  User: node: gid: matches expectation:  User: node: home: matches expectation: ["/home/node"] User: node: groups: matches expectation: [["node"]] User: node: shell: matches expectation: ["/bin/bash"] Total Duration: 0.003s Count: 8, Failed: 0, Skipped: 0 INFO: Deleting container
With our first tests passing we still want to make sure our image actually switches to the created user. A command test can be used to verify this:
As expected, our new tests will initially fail, but once we extend our Dockerfile, they should pass again:
Count: 14, Failed: 0, Skipped: 0
Our upcoming node setup requires either of wget or curl. It is certainly not required to keep them in our image, but since curl has been really useful more than once, I like to have it pre-installed. Let’s add a package test for it:
To fix our now failing test, let’s also update our Dockerfile:
nvm turned into my go-to solution to install node. When installing node using nvm, we want to make sure that
.nvmfolder is located in our home directory
- A node version corresponding to our image tag is installed at
- Our node version is set as default in
The following additions to our Dockerfile are required to pass our latest test:
Our image should provide an entrypoint script which either forwards all non-command parameters to node, or executes any other command so we’re able to start our container running e.g.
To make sure we're providing an entrypoint script we’re going to extend our file test:
And with our entrypoint in place, we expect a running node process in our container:
In order to pass our last tests we have will have to copy our script and configure an entrypoint:
In this post we built a node Docker image the test-driven way. Instead of manually validating our setup at runtime we were able to write tests for our setup upfront, which made it easier for us to verify our image configuration.
The resulting image is configurable, so we’re free to choose our runtime, but still comes with a safety net which makes sure everything is in place. Best practices known from software development also apply to building Docker images!
The whole setup can be found on GitHub, if you have any questions, feel free to get in touch with me!