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.
--- --- >> .
. .
-- --
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;
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;
location / {
try_files $uri/index.html $uri @app;
expires max;
add_header Cache-Control public;
etag on;
}
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.