I’ve been wanting to try Docker ever since I saw a talk about it at Red Dot Ruby Conf. The idea of being able to build a container in development and ship it to staging, then production without any changes is extremely enticing, although I admit to being a little sceptical. Now I’ve implemented a Docker deployment process for the first time with one of my projects. Is Docker the holy grail of cloud deployment, or is it all just hype? Well, probably a bit of both.

Baby steps

Docker is really, ridiculously easy to get started with. If you’ve read about it before, you’ve probably seen basic examples like this:

$ sudo apt-get -y install docker.io
$ sudo docker run -t -i ubuntu:14.04 /bin/bash

That’ll get you a shell on a Docker container. Simple. Not very useful, but a friendly starting point.

Docker concepts

Docker revolves around two main concepts: images and containers. Images are just like VM images: they’re things you can copy, download, share and run. Containers are running instances of images. You can start, stop and save them, again much like VMs. But unlike VM images, they’re much smaller and lightweight, and generally more portable.

The basic commands are:

  • docker run to start a container from an image
  • docker build to build an image from a Dockerfile (more on that later)
  • docker ps to see running containers
  • docker rm to remove/stop running containers

There’s also docker push and docker pull to push and pull images to the Docker registry (or your own registry).

Running a Rails application

My project runs on Rails, so my Docker container would have to support Rails applications. Fortunately, there’s a pre-built image for this using Nginx and Passenger: https://github.com/phusion/passenger-docker. This makes things easy to get started, but it wouldn’t be too difficult to switch to a different webserver. There’s many similar base images to choose from, but this was the one I went with.

In order to use this image for your own application, you need to make a few changes as documented in the readme. Namely:

  • Copy your application into the image
  • Configure nginx
  • Preserve environment variables for nginx

I made the additional step of pulling my project onto the image using Git, which needed me to first set up ssh.

You can change the base image by one of two ways:

  • Make changes directly to the container and save them using docker commit
  • Build a new image using a Dockerfile

I opted for the second way, because having the steps in the Dockerfile acts as documentation and makes the process completely repeatable and transparent. However, I don’t think there’s any harm in using docker commit either. Generally the argument against just modifying a VM image is because it then makes it difficult to port to other hosts or operating system, but that doesn’t really apply here: Docker images run on Docker, which can run on practically any hardware and OS.

Here’s my complete but slightly censored Dockerfile:

FROM phusion/passenger-ruby21:0.9.14

ENV HOME /root

# Use baseimage-docker’s init process. CMD ["/sbin/my_init"]

# Install git RUN apt-get -y install git &&
# Clean up APT when done. apt-get clean && rm -rf /var/lib/apt/lists/ /tmp/ /var/tmp/*

# Add my ssh key ADD id_mikec /tmp/id_mikec ADD id_docker /home/app/.ssh/id_docker ADD ssh_config /home/app/.ssh/config ADD sudoers /etc/sudoers RUN cat /tmp/id_mikec >> /root/.ssh/authorized_keys &&
rm -f /tmp/id_mikec &&
chmod 600 /home/app/.ssh/config /home/app/.ssh/id_docker &&
chown -R app:app /home/app/.ssh &&
adduser app sudo &&
mkdir -p /srv &&
chown app /srv &&
rm -f /etc/nginx/sites-enabled/default /etc/service/nginx/down

# Switch to app user USER app ENV HOME /home/app

# Do clone RUN mkdir -p /srv/myapp &&
git clone git@<git-server>:mikec/myapp /srv/myapp &&
cd /srv/myapp &&
bundle install &&
mkdir -p /srv/myapp/tmp/cache

USER root ADD nginx_env.conf /etc/nginx/main.d/nginx_env.conf ADD set_passenger_env.sh /etc/my_init.d/01_set_passenger_env.sh ADD myapp.conf /etc/nginx/sites-enabled/myapp.conf

# Update checkout USER app ADD VERSION /tmp/VERSION RUN cd /srv/myapp && git pull && bundle install USER root

Building the image

You’ll notice above that the Dockerfile does a git clone and then another git pull. This is because Docker caches each command; if the command doesn’t change, then it doesn’t run the command the next time it builds the image. To force Docker to invalidate its cache, I use a VERSION file that I modify locally before running docker build. I’ve written a simple build script to help with this task (I put it in bin/build):

#!/bin/bash
date +%s > docker/VERSION
git add docker/VERSION
git commit -m 'Build release'
git push
sudo docker build -t mcartmell/myapp docker
sudo docker push mcartmell/myapp

Building the image

This was the trickiest part for me. I want to ensure my image works on different environments (eg. staging and production) without any changes, because I think that’s one of the major selling points of Docker. But keeping your image agnostic to any environment settings is difficult.

I accomplished this by creating an environment file on the host server that stores application secrets and database settings. I know this creates a dependency between the container and the host, but that’s not really avoidable, and I see this environment file more like ‘infrastructure settings’ than configuration specific to the application. Plus, it’s similar to how dotenv works in typical Rails deployments, so I was satisfied.

The docker run command looks like this:

$ sudo docker run -p 80:80 --name="myapp" -d --env-file /etc/myapp/docker_env mcartmell/myapp

Note the --env-file argument that allows you to specify a file of environment variables rather than passing them in on the command-line.

This environment file, when used with the tweak to nginx to allow it to pass through environment variables, worked nicely for me. One slight problem was that Passenger itself doesn’t honor the RAILS_ENV environment variable – it has to be set explicitly in the config file. Since I’m only passing in the environment at runtime, we can’t set this in the config file in the image, so I had to create an init file to set the passenger_app_env.

This extra file (set_passenger_env.sh referenced above) was pretty simple:

#!/bin/bash
echo "passenger_app_env $PASSENGER_APP_ENV;" > /etc/nginx/conf.d/passenger_env.conf

Not ideal, but I’m OK with it for now.

Deploying the Docker image

I was looking for Docker deployment tools, but there’s currently not a great deal of choice out there. The closest thing I found was NewRelic’s Centurion, but I ended up not trying this and rolling my own deploy script with sshkit instead:

#!/usr/bin/env ruby
require 'sshkit'
require 'sshkit/dsl'

on [‘mikec@server’], in: :sequence, wait: 5 do execute ‘sudo docker pull mcartmell/myapp’ execute ‘sudo docker rm -f myapp’, raise_on_non_zero_exit: false execute ‘sudo docker run -p 80:80 –name=“myapp” -d –env-file /etc/myapp/docker_env mcartmell/myapp’ end

All this does is pull the new image, stop the running container and run a new one. A production environment would ideally be behind a load-balancer to do a rolling deployment, but as it only takes a second to stop and start the container, this way suits me for now. I’m opting to start simple and expand this script as needed.

Pros and Cons

Pros:

  • Very low-risk deployment: fresh container every time
  • Images can run anywhere: local, cloud VM, dedicated servers
  • Many existing images to use
  • Lightweight and easy to install
  • Rolls server configuration and application deployment into one process

Cons:

  • Slow build and push. A push with no changes takes me 2 minutes. Compared with a regular git push / git pull for your code, this is slow, but when you consider it’s pushing an entire standalone runtime environment, it’s actually pretty fast.
  • Complex deployment process. It’s not too complicated, but certainly more difficult to grasp than just git.
  • Lack of drop-in deployment tools
  • Even with a base image, still a lot of manual effort to get a working container

Overall it’s been a good experience, and I’ll probably be using Docker in production. It does slow down the deployment process, but the peace of mind you get from knowing that the exact container has been working in development and staging is definitely worth it, in my opinion. And the fact that I can run my app anywhere (assuming there’s a database and environment file) is a major bonus. No more having different configuration scripts for Vagrant in development, Amazon cloud VMs for staging and dedicated servers in production: just set them up to run Docker and push your containers.