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.