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 imagedocker build
to build an image from aDockerfile
(more on that later)docker ps
to see running containersdocker 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.