A containerized approach to Rails apps

In the first article of this series we talked about how to deploy Opsworks. In the second one we talked about how to configure the deployment. In this third installment we’re going to look at a sample Rails Application, and how to Dockerize it in its composing parts.

We’re going to look at several Dockerfiles, and we’re going to discuss the hows and whys of certain choices. So, without further ado, let’s begin. Let’s say we have a normal real world Ruby on Rails application. Most people nowadays serve RoR application using a frontend server (usually nginx, but Apache is still strong), an application server (Puma, Unicorn,
Passenger, …) and might use a SQL Database (your choice of MySQL or Postgres) and maybe a key-value store, like Redis.

Many applications make good use of an asynchronous queue, like Sidekiq. That’s a lot of components, isn’t it? 🙂

Today we’re not going to look at containers that run MySQL, Posgtres or Redis. We’re going to focus on the app container, that will our app server of choice, and how this app container talks to its serving brothers, nginx and haproxy.

The first think we do is creating an empty Dockerfile in your app source code repo. As you should know by now, Dockerfile will tell docker how to build our container.

Let’s start to fill it with instructions:

FROM ubuntu:14.04 # my favorite way of starting containers
MAINTAINER Giovanni Intini <giovanni@mikamai.com> # yep that's me folks

ENV REFRESHED_AT 2015-01-28 # this is a trick I read somewhere
                            # useful when you want to retrigger a build

# we install all prerequisites for ruby and our app. Your app might need
# less or more packages
RUN apt-get -yqq update
RUN apt-get install -yqq autoconf 
                         build-essential 
                         libreadline-dev 
                         libpq-dev 
                         libssl-dev 
                         libxml2-dev 
                         libyaml-dev 
                         libffi-dev 
                         zlib1g-dev 
                         git-core 
                         curl 
                         node 
                         libmagickcore-dev 
                         libmagickwand-dev 
                         libcurl4-openssl-dev 
                         imagemagick 
                         bison 
                         ruby

# here we start installing ruby from sources. If you're curious about why we
# also installed ruby from apt-get, the short story is that we need it, but
# we're gonna remove it later in this file 🙂

ENV RUBY_MAJOR 2.2
ENV RUBY_VERSION 2.2.1

RUN mkdir -p /usr/src/ruby

RUN curl -SL "http://cache.ruby-lang.org/pub/ruby/$RUBY_MAJOR/ruby-$RUBY_VERSION.tar.bz2" 
        | tar -xjC /usr/src/ruby --strip-components=1

RUN cd /usr/src/ruby && autoconf && ./configure --disable-install-doc && make -j"$(nproc)"

RUN apt-get purge -y --auto-remove bison 
                                   ruby

RUN cd /usr/src/ruby && make install && rm -rf /usr/src/ruby

RUN rm -rf /var/lib/apt/lists/*

# let's stop here for now

Everything up to here is simple. When possible we chain statements so we have fewer images built by docker as it layers each step on top of each other.

Now the interesting part. We want to be able to cache gems in between image builds, and we also want to be able to override them with our local gems when developing locally using this image. We do it in two steps: first we configure bundler and add only Gemfile and Gemfile.lock to the image, then we add the rest of the application.

RUN echo 'gem: --no-rdoc --no-ri' >> $HOME/.gemrc
RUN gem install bundler
RUN bundle config path /remote_gems

COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock
WORKDIR /app

RUN bundle install --deployment --without development test

The order in which we do the rest is important, because we want to be sure to
minimize the number of discarded images when we change something in the application.

# here we just add all the environment variables we want
# more on that soon
ENV RAILS_ENV production
ENV RACK_ENV production
ENV SIDEKIQ_WORKERS 10

# what we do if we run a container from this image without telling it explicitly
# what command to use
CMD ["bundle", "exec", "unicorn", "--help"]

# now we add in the code
COPY . /app

# assets precompilation as late as possible
RUN bundle exec rake assets:precompile
ENV GIT_REVISION ef4312a
ENV SECRET_KEY_BASE af9cbc68d3ad9fe71669e791c59878ffd1fc

The first thing we have to be careful to do when we write an application that is supposed to run in containers and to scale indefinitely, is to configure it via environment variables. This is the most flexible way of working and allows you to deploy it to Heroku or Opsworks, or Opsworks+Docker.

Now, if you recall, we used Opsworks to pass in lots environment variables to our app, but a lot of those can be safely have default values set directly in here. Usually I put in Dockerfile all the variables that are required by the deployment process or that won’t change often.

The GIT_REVISION variable and SECRET_KEY_BASE are generated automatically by a rake task I wrote (warning: very rough, but it does its job). I usually expose GIT_REVISION in the backend of my applications, so I always know which version is deployed at a glance.

After writing our nice Dockerfile we launch rake docker:build or just build it with docker build -t whatever/myself/app_name .
and we have our cool image ready to be launched.

Let’s try to launch it with docker run whatever/myself/app_name:

Usage: unicorn [ruby options] [unicorn options] [rackup config file]
Ruby options:

  -e, --eval LINE          evaluate a LINE of code
  -d, --debug              set debugging flags (set $DEBUG to true)
  -w, --warn               turn warnings on for your script
  -I, --include PATH       specify $LOAD_PATH (may be used more than once)
  -r, --require LIBRARY    require the library, before executing your script
unicorn options:
  -o, --host HOST          listen on HOST (default: 0.0.0.0)
  -p, --port PORT          use PORT (default: 8080)
  -E, --env RACK_ENV       use RACK_ENV for defaults (default: development)
  -N                       do not load middleware implied by RACK_ENV
      --no-default-middleware
  -D, --daemonize          run daemonized in the background

  -s, --server SERVER      this flag only exists for compatibility
  -l {HOST:PORT|PATH},     listen on HOST:PORT or PATH
      --listen             this may be specified multiple times
                           (default: 0.0.0.0:8080)
  -c, --config-file FILE   Unicorn-specific config file
Common options:
  -h, --help               Show this message
  -v, --version            Show version

For now everything seems to be working, but obviously we need to tell unicorn what to do. For now something like this should do the trick:

$ docker run -p 80:9292 --env REQUIRED_VAR=foo whatever/myself/app_name bundle exec unicorn

If your app is simple it should already be serving requests (probably with no static assets) on port 80 of your host machine.

We’re not done yet, it’s now time to work on our haproxy and nginx configurations. I have mine in app_root/container/Dockerfile, but probably another repository is a better place.

In the first article I explained how I prefer to have haproxy serving requests to nginx to have zero downtime deployments.

Here’s my haproxy Dockerfile.

FROM dockerfile/haproxy
MAINTAINER Giovanni Intini <giovanni@mikamai.com>
ENV REFRESHED_AT 2014-11-22

ADD haproxy.cfg /etc/haproxy/haproxy.cfg

Very simple, isn’t it? Everything we need (not a lot actually) is done in haproxy.cfg (of which I present you an abridged version, just with the directives we need for our rails app).

global
  log ${REMOTE_SYSLOG} local0
  log-send-hostname
  user root
  group root

frontend main
  bind /var/run/app.sock mode 777
  default_backend app

backend app :80
  option httpchk GET /ping
  option redispatch
  errorloc 400 /400
  errorloc 403 /403
  errorloc 500 /500
  errorloc 403 /503

  server app0 unix@/var/lib/haproxy/socks/app0.sock check
  server app1 unix@/var/lib/haproxy/socks/app1.sock check

Haproxy is a very powerful but not very accessible, I know. What this configuration does is easy: it defines two backend servers that will be accessed via unix sockets, and exposes a unix socket itself, that will be used by nginx.

The most attentive readers will already know we’re going to tell unicorn to serve requests via unix sockets, and the ones with a superhuman memory will know we already showed how to to that in the first article 🙂

I promise we’re almost there, let’s look at nginx.

FROM nginx
MAINTAINER Giovanni Intini <giovanni@mikamai.com>
ENV REFRESHED_AT 2015-01-15

RUN rm /etc/nginx/conf.d/default.conf
RUN rm /etc/nginx/conf.d/example_ssl.conf

COPY proxy.conf /etc/nginx/sites-templates/proxy.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY mime.types /etc/nginx/mime.types
COPY headers.conf /etc/nginx/common-headers.conf
COPY common-proxy.conf /etc/nginx/common-proxy.conf

WORKDIR /etc/nginx

CMD ["nginx"]

As for haproxy we’re starting with the default image and just adding in our own configuration. I can’t share everything I use because it’s either taken from somewhere else or private, but I’ll give you the important details (from proxy.conf):

upstream rgts {
    server unix:/var/run/rgts.sock max_fails=0;
}

server {
    listen 80 deferred;
    listen [::]:80 deferred;

    server_name awesome-app.com;

    # IMPORTANT!!! We rsync this with the Rails assets to ensure that you can
    # server up-to-date assets.
    root /var/static;
    client_max_body_size 4G;
    keepalive_timeout 10;

    open_file_cache          max=1000 inactive=20s;
    open_file_cache_valid    30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors   on;

    spdy_keepalive_timeout 300;
    spdy_headers_comp 6;

    # This is for files in /public, assets included
    location / {
        try_files $uri/index.html $uri @app;

        expires max;
        add_header Cache-Control public;

        etag on;
    }

    # Dynamic pages
    location @app {
        add_header Pragma "no-cache";
        add_header Cache-control "no-store";

        proxy_redirect off;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;

        proxy_pass http://app;
    }
}

Simple, and it does the job. These three images will become the key containers in your app stack.

You can add containers with databases and other services, or use AWS for that, but the main building blocks are there.

Orchestrate them locally with docker compose or in the cloud with Opsworks and friends, and you shall be a happy camper.

Thanks for reading up to here, feel free to comment via mail, reddit, snail mail or pigeons, and most of all, have fun with Docker.

Leave a Reply

wpDiscuz