Testing Dockerfiles with Serverspec

While there are many ways to test your code under Docker, for example puppet modules with dockunit, discussions about how to run acceptance checks against docker image and container creation are less common. In this post we’ll present one approach using the docker api and serverspec to test the creation and execution of a dockerised Redis.

As our first step we’ll create the directory we’ll be testing under and a basic Dockerfile. For our examples we’ll go with a stock CentOS 7 container.

    $ mkdir docker-serverspec-redis
    $ cd blog-docker-serverspec-redis

    $ cat <<EOF > Dockerfile
    FROM centos:7
    MAINTAINER Dean Wilson <dean.wilson@gmail.com>
    EOF

Now we have a basic, if uninteresting, Dockerfile we could run docker build . and manually explore inside the container to confirm everything’s correct. Instead we’re going to be a little more lazy/ambitious and automate the acceptance of the new image and containers started from it.

Before we run our tests we’ll install our testing requirements.

    $ cat <<EOF > Gemfile
    source 'https://rubygems.org'

    gem 'docker-api', :require => 'docker'
    gem 'serverspec'
    EOF

    $ bundle install
    Fetching gem metadata from https://rubygems.org/.......
    Resolving dependencies...
    ...
    Your bundle is complete!

    $ mkdir spec

    $ cat <<EOF > .rspec
    --format documentation
    EOF

Boilerplate complete we add the simplest check we can to test all our dependencies are working. As we’re building a CentOS container we’ll test to ensure /etc/centos-release is present. Add the following code to spec/Dockerfile_spec.rb.

    require "docker"
    require "serverspec"

    describe "Dockerfile" do
      before(:all) do
        @image = Docker::Image.build_from_dir('.')

        set :os, family: :redhat
        set :backend, :docker
        set :docker_image, @image.id
      end

      describe file('/etc/centos-release') do
        it { should be_file }
      end

    end

In our spec file we’re using the Docker api, via the docker gem, to build our container from the Dockerfile located in our current directory. Because this is done in the before(:all) block this will happen only once and before any of our specs run. We then run our spec, using the Docker backend added to serverspec v0.4.0 and confirm we’re building a CentOS image as planned.

    $ bundle exec rspec spec/Dockerfile_spec.rb

    Dockerfile
      File "/etc/centos-release"
        should be file

    Finished in 0.45969 seconds (files took 0.44155 seconds to load)
    1 example, 0 failures

If you have everything installed correctly then you should also see 1 example, 0 failure. Now we’re happy with our basic container let’s make it actually do something. In our case we’ll install and run redis. If you’re planning on running Redis in Docker anywhere other than this little test case I’d suggest the official redis docker image.

Taking a baby step we’ll add the redis package to our container.

    $ cat <<EOF > Dockerfile
    FROM centos:7
    MAINTAINER Dean Wilson <dean.wilson@gmail.com>

    RUN yum install -y epel-release
    RUN yum install -y redis
    EOF

Then we add a test to ensure it gets installed. If you’re following along put the spec below in spec/Dockerfile_spec.rb on line 16 and then rerun the spec tests.

  describe package('redis') do
    it { should be_installed }
  end
    $ bundle exec rspec spec/Dockerfile_spec.rb

    Dockerfile
      File "/etc/centos-release"
        should be file
      Package "redis"
        should be installed

    Finished in 41.45 seconds (files took 0.42084 seconds to load)
    2 examples, 0 failures

We now reach the more complicated, and interesting, part of our testing. We want to run redis inside the container and check that we can connect to it. Below you can see the final version of our Dockerfile. This will run redis inside our CentOS 7 container and bind it to port 6379 on all interfaces.

    $ cat <<EOF > Dockerfile
    FROM centos:7
    MAINTAINER Dean Wilson <dean.wilson@gmail.com>

    RUN yum install -y epel-release
    RUN yum install -y redis

    EXPOSE 6379

    ENTRYPOINT  ["/usr/bin/redis-server", "/etc/redis.conf"]
    CMD ["--bind", "0.0.0.0"]

To test redis starts correctly, and presents a port to clients, we’ll need to actually start a container and test against it. The docker gem does the heavy lifting and manages the container itself. All we need to do is call it in the correct places inside our spec file.

    REDIS_PORT = 6379

    describe 'Dockerfile#running' do
      before(:all) do
        @container = Docker::Container.create(
          'Image'      => @image.id,
          'HostConfig' => {
            'PortBindings' => { "#{REDIS_PORT}/tcp" => [{ 'HostPort' => "#{REDIS_PORT}" }] }
          }
        )

        @container.start
      end


      ############################
      # tests go here
      ############################


      after(:all) do
        @container.kill
        @container.delete(:force => true)
      end
    end

These two chunks of code, as the names imply, run before and after (respectively) all the specs inside this scope. This ensures that we have a running container to test against. The container itself is started by Docker::Container.create inside the before(:all) do block. We clean everything back up, including stopping the container and removing the image, in the after(:all) do block. If you need to retain an image after your specs have run, for example to perform exploratory testing, remove the @container.delete(:force => true) line and you can manually spin the container back up and use your normal tools against it.

Now we’ve seen the interesting parts of the code let’s increase our spec coverage a little and add checks to ensure we can read and write a key in the redis data store and confirm redis-cli runs against it. We’ll also check we expose the Redis port from our container as an example of testing the containers configuration.

require 'docker'
require 'serverspec'
require 'redis'

REDIS_PORT = 6379

describe "Dockerfile" do
  before(:all) do
    @image = Docker::Image.build_from_dir('.')

    set :os, family: :redhat
    set :backend, :docker
    set :docker_image, @image.id
  end

  describe 'Dockerfile#config' do
    it 'should expose the redis port' do
      expect(@image.json['ContainerConfig']['ExposedPorts']).to include("#{REDIS_PORT}/tcp")
    end
  end

  describe file('/etc/centos-release') do
    it { should be_file }
  end

  describe package('redis') do
    it { should be_installed }
  end



  describe 'Dockerfile#running' do
    before(:all) do
      @container = Docker::Container.create(
        'Image'      => @image.id,
        'HostConfig' => {
          'PortBindings' => { "#{REDIS_PORT}/tcp" => [{ 'HostPort' => "#{REDIS_PORT}" }] }
        }
      )

      @container.start
    end

    describe 'round trip a key' do
      it 'should be able to write and read a key' do
        redis = Redis.new(:host => '127.0.0.1')
        redis.set('test_key', 'hello world')

        expect(redis.get('test_key') == 'hello world')
      end
    end

    # doesn't work
    #describe port("6379") do
    #  it { should be_listening.on('0.0.0.0') }
    #end

    describe command('redis-cli info') do
      its(:stdout) { should match /redis_version:/ }
    end

    after(:all) do
      @container.kill
      @container.delete(:force => true)
    end
  end
end

In the full example file above you might notice the commented out describe port("6379") spec. I’m unsure why but I couldn’t get this to work in my testing. Instead we explicitly call out to the redis gem and use it to test both the connection and that the datastore is read/write. To do this we need to add redis to our Gemfile.

    $ cat <<EOF > Gemfile
    source 'https://rubygems.org'

    gem 'docker-api', :require => 'docker'
    gem 'serverspec'
    gem 'redis'
    EOF

    $ bundle install

Running the final version of our spec file we can now create, run and exercise our redis container.

    $ bundle exec rspec spec/Dockerfile_spec.rb

    Dockerfile
      Dockerfile#config
        should expose the redis port
      File "/etc/centos-release"
        should be file
      Package "redis"
        should be installed
      Dockerfile#running
        round trip a key
          should be able to write and read a key
        Command "redis-cli info"
          stdout
            should match /redis_version:/

    Finished in 1.23 seconds (files took 0.42603 seconds to load)
    5 examples, 0 failures

If you’re already using ruby and serverspec then this can be a simple way to add some repeatable acceptance tests to your containers. If you’re more of a python person all of the presented concepts will work with testinfra and the native python Docker API client. Now, to go add a few of these to my jenkins deploy.