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.
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 revolves around two main concepts:
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 runto start a container from an image
docker buildto build an image from a
Dockerfile(more on that later)
docker psto see running containers
docker rmto remove/stop running containers
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
- Build a new image using a
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
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/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.
docker run command looks like this:
$ sudo docker run -p 80:80 --name="myapp" -d --env-file /etc/myapp/docker_env mcartmell/myapp
--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
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
#!/usr/bin/env ruby require 'sshkit' require 'sshkit/dsl'
on [‘[email protected]’], 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
- 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
- Slow build and push. A push with no changes takes me 2 minutes. Compared with a regular
git pullfor 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
- 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.