This post is outdated as of October 2016. Refer to the Docker Project
Boilerplates for updated versions.


Ruby (and many other languages) are easy to dockerize for
TDD and/or production. The general approach follows these
steps:

  1. Use docker to download application dependencies to commit to source
     control
  2. Use dependencies to create a docker image
  3. Use previously built docker image to run arbitrary code w/o
     rebuilding new docker images.

The same process may be applied to other interpreted languages such as
Node.js or Python. This process can be orchestrated through
various build tools. This post uses make because it is adequate
and ubiquitous. (Plus make is great.)

First, create the Gemfile. This file lists all application
dependencies. Concepts like “development” and “test” are irrelevant
because all dependencies will be committed to source control and the
final docker image will be used in all environments/stages¹.
All changes to dependency versions must be committed to the Gemfile.
Let’s consider a simple web application using sinatra. Here is a
sample Gemfile:

source 'https://rubygems.org'

gem 'sinatra', '~> 1.4.7'

Next the runtime environment may be created from the dependencies.
This is done by using the current Gemfile with bundle package. All
dependencies will be added to vendor/cache and committed to source
control². Given we are using docker, dependency installation
should also run through docker. The current directory may be mounted
as a volume to capture the generated artifacts. make makes this easy
enough.

Gemfile.lock: Gemfile
	docker run -w /data -v "$(CURDIR):/data" -u $$(id -u) ruby:2.3 \
		bundle package --all

Running make Gemfile.lock will run bundle package --all
downloading all .gem files into the local vendor/cache. Note the
-u argument. This important because the current directory is a
mounted volume in the docker container. New files will be created with
the user’s ID instead of root (the docker default user).

Now time to build the application docker image. This takes two parts:
a Dockerfile and a new make target. Let’s start with the
Dockerfile.

FROM ruby:2.3

ENV LC_ALL C.UTF-8

RUN mkdir -p /app/vendor
WORKDIR /app
ENV PATH /app/bin:$PATH

COPY Gemfile Gemfile.lock /app/
COPY vendor/cache /app/vendor/cache
RUN bundle install --local -j $(nproc)

COPY . /app/

CMD [ "irb" ]

This Dockerfile creates a directory for all the source code in
/app. Next everything in vendor/cache is copied into the image.
Then bundle install --local runs. --local ensures that only .gem
files in vendor/cache are used. Finally every other file is
copied over to /app. Now time to build the image with make.

DOCKER_IMAGE:=tmp/image
IMAGE_NAME:=my_company/my_app

$(DOCKER_IMAGE): Gemfile.lock
	docker build -t $(IMAGE_NAME) .
	mkdir -p $(@D)
	touch $@

The above make target builds the docker image with the dependencies
installed + source code. The docker image is ready for
production/staging/etc. Great but is it possible to avoid rebuilding
the docker image on each code change? Yes! This is entirely possible.
Given Ruby is an interpreted language all it needs is the ruby
interpreter and all available dependencies. Given those two things are
available we can run code. We can do this with a shared volume. Our
docker image expects application code at /app. So mount the current
directory (with all current application code) at /app and we’re off.
The below make test target does exactly that.

.PHONY: test
test: $(DOCKER_IMAGE)
	docker run --rm -v $(CURDIR):/app $(IMAGE_NAME) \
		ruby test/some_test.rb

The make target works by mounting the current directory /app (the
source code directory specified in the Dockerfile).

Everything is packaged up in a handy example repo for use on your
projects.


  1. Typically Ruby applications declare dependencies for a
    particular environment. Example: rack-test is not needed in
    production. This is all well and good when things are running
    directly on a given machine. However using docker as a delivery
    mechanism negates this problem. Installing dependencies (and thus
    things like C extensions) are all encapsulated so there is no need
    to enforce context specific dependencies at installation time.
  2. Dependencies should be committed to source control. This
    removes a dependency on the upstream package sytsem. It also
    insulates you from upstream deletions. (See the Node.js leftpad
    module discussion for an example). People argue this creates undo
    bloat in the git repo. I do not see this as big enough tradeoff to
    warrant ignoring the stability vendoring everything applies. Once
    your hit by a problem that could have been solved from vendoring
    everthing, you will do it. Take my advice and vendor everything from
    the beginning.