/ rails

Dockerizing a rails app part II

In part we created a dockerfile for an existing rails app.

With that done we can now move on to getting our test suite running and passing.

Docker-compose

In order to run the test suite we need to bring up the full application stack. To orchestrate this we will use a python application called docker-compose.

Docker-compose commands require a configuration file for context, let's look at a working configuration file for our application's test environment.

# docker-compose.test.yml
version: "3.3"
services:
  app:
    build: .
    image: seocahill/my-app:0.0.1
    volumes:
      - .:/app
    environment:
      - RAILS_ENV=test
      - RUBYOPT="-W0"
    depends_on:
      - db
      - selenium

  db:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=cd_development

  selenium:
    image: selenium/standalone-firefox-debug
    ports:
      - '4444:4444'
      - '5900:5900'

The app service

The build command specifies that a fresh image should be built from the current directory's Dockerfile unless one exists in the cache, as specified in the image command.

You can trigger a new build manually with:

docker-compose build .

Volumes are a method of sharing files and directories between the host (i.e you local dev environment) and a running container.

This syntax is: /host_dir:/container_dir

In this instance we are mounting the current directory (i.e. the application code) into the container running our application image at the same location that we used when we built the image initially:

# Dockerfile excerpt 
# create and change into dir /app
WORKDIR /app
# copy host code in current host dir into current container dir
COPY . ./

As a consequence of this the application code on the host is used at runtime not the version we copied at buildtime. This allows us to avoid having to rebuild the image every time we change application code.

The environment command accepts an array of environment variable key-values. Here I'm telling rails to use the test environment and not to log deprecation warnings to stdout when running the tests.

The depends_on command tells the app service to hold off starting up until the selenium and db services are started.

Having the db container started is not equivalent to having mysql started however! You can refer to this documentation for general tips on controlling service startup order.

The db service

The db service is derived from Mysql's official image.

The environment variables passed in for database name and root password are used to initialize the test database on startup

# database.yml
test:
  adapter: mysql2
  database: cd_test
  encoding: utf8
  host: db
  password: root
  username: root

The only thing out of the ordinary to note about the database configuration file is the host setting which is the same as the service name 'db'.

When binging up an application stack using docker-compose, docker automatically connects the running containers in the stack via a closed network and sets the hostnames of the containers within the network to the names of the services that spawned them.

This applies also when configuring sphinx access to mysql for indexing:

# thinking_sphinx.yml
sql_host: db
sql_port: 3306

The selenium service

In order to run integration tests rails needs access to a browser.

Selenium provides official images for the firefox and chrome browsers which you can find here.

Like most rails applications this one uses the capybara library to automate browser tests. In order to connect to a remotely running browser (i.e. in another docker host) we will need to tweak the capybara selenium driver configuration a little.

Configuring the driver

First you need to add the selenium-webdriver gem to your bundle if you haven't already.

Second add a custom driver configuration for the selenium service:

Capybara.register_driver "selenium_standalone_firefox".to_sym do |app|
  Capybara::Selenium::Driver.new(
    app, browser: :remote, url: "http://selenium:4444/wd/hub", desired_capabilities: :firefox
  )
end
Capybara.javascript_driver = :selenium_standalone_firefox

Notice again that the service names double as hostnames within the application stack's docker network

The last step is to switch to this new driver when running browser based tests:

  require 'socket'
  Capybara.server_host = Socket.gethostname
  Capybara.server_port = "3000"
  Capybara.app_host = "http://#{Socket.gethostname}:3000"
  Capybara.current_driver = :selenium_standalone_firefox

The server and app settings ensure capybara spawns the app's server on the same host and port as the urls it ultimately requests (random values are used otherwise).

I had to look up the app container's actual hostname as the "app" hostname wasn't resolving for some reason.

The last setting instructs Capybara to use the custom firefox driver defined earlier.

If you decide to use the remote driver for all Capybara tests (might be significantly slower) you should also turn off Capybara's bundled rack server which is booted by default:

Capybara.run_server = false

Running the test suite

To run the test suite execute:

docker-compose \
    -f docker-compose-test.yml \
    run --rm app \
    ash ./script/run_tests.sh

The test script waits for the mysql test database to be initiated and then runs the test suite:

# script/run_tests.sh
#! /bin/ash

# wait for mysql to be available
printf '\nwaiting for mysql to boot\n'; 

TRIES=0

# repeatedly query mysql until the cd_test database has been initialized
until mysqlshow --host=db --user=root --password=root | grep cd_test || [ $TRIES -eq 10 ]; do  
  (( TRIES++ ))
  printf '\nretrying in 2 seconds...\n'; 
  sleep 2;
done

# Load schema and run tests or timeout after n unsuccessful healthcheck tries
if [ $TRIES -eq 15 ]; then
  printf '\nno response from mysql'
else
  printf '\ndb ready - loading in schema\n'
  bin/rake db:schema:load 
  printf '\nrunning tests\n'
  bin/rspec $@
fi

This test setup is ideal for CI as it starts from a completely clean slate - the only requirements to run the test suite are docker engine and docker-compose.

When running locally however you may want to speed things up a little.

One thing you can do is adding a named volume to persist the database and schema so don't have to recreate it on each run:

services:
    db:
    image: mysql:5.7
    volumes:
        - mysql:/var/lib/mysql
volumes:
    :mysql

Debugging visually

The "debug" selenium docker image variants come bundled with a vnc server running on port 5900 to facilitate visual debugging.

The ports command here forwards port 5900 on the selenium container to port 5900 on the host.

On mac we can setup the native vnc client to access the selenium images vnc server like so:

  • With the selenium server running, open safari and enter the following url vnc://localhost:5900.
  • You will be prompted to authorized the vnc connection and then asked for a password, the default password is "secret".

Running a development stack

Most of the configuration and commands outlined above to get the test environment running applies directly to running the stack in development.

There are only two notable differences:

  • Persisting data: You will probably want to persist database in a named volume as suggested earlier. You can read about how that's done in more detail here
  • Running the stack: You will want to keep the stack up indefinitely using the docker-compose up command instead of docker-compose run.

Wrap up and part III

In part III I'll look at configuring capistrano to work in a dockerized environment.