I’m very much a fan of The Twelve-Factor App methodology. Docker (with Docker Compose) can be used to build on these ideas, allowing greater control over an app’s environment and reinforcement of the principles. Here, I present some notes related to setting up Docker Compose for a (not-quite) Twelve-Factor Ruby Rails app, specifically from commits I’ve made to Tunefl, my open-source LilyPond mini-score engraving and sharing for musicians. Note that the Docker Compose documentation has an example of containerising a Ruby Rails app. However, that installs gems as root
and executes with elevated privileges. Besides, I have something more complex in mind! :)
preliminary thoughts
I tend to be cautious about changing from approaches which work well, so have kept an interested eye on the many touted advantages of using Docker without immediately rushing to convert everything. Much like any tool, including a screwdriver, Docker can be used for a variety of approaches to solve a problem. I’m not convinced that using Docker automatically means a solution is better than one without it or without explicit containerisation. However, there are a few specific things offered by Docker for development which became too tempting for me to resist:
-
version-locking of external service dependencies: Having a specific, repeatable recipe for library dependencies is vital for long-term stability and sanity. I’ve been increasingly trying to get into the habit of explicitly documenting all external service versions in READMEs (e.g.
PostgreSQL 9.3
). Docker allows these dependencies to be enforced. -
virtualisation security: If you’re developing within a single organisation, isolated development zones might not be a concern. But if you’re working across multiple security contexts, such as for multiple clients, this becomes something you should consider seriously. If it’s possible to
git pull
some company’s code and start what you think is a local webserver, only to find a mistake or malicious modification causes another company’s code or keys to be sent over the network, you’ve got a problem. Virtualisation, such as using VirtualBox, protects against this. Docker makes this virtualisation less obtrusive. -
networking security: Even if you’re running code in isolation, it’s important to remember the security of external services. If you simply access all your PostgreSQL databases under the same user, even setting a password won’t protect you against an app connecting to the wrong database. This could be something quite accidental even within the same app, such as wiping the wrong database when running a automated test suite. That alone is one reason why I prefer to access dev and test databases with different users and passwords, sometimes adding additional protection such as requiring an extra environment variable (e.g.
CI=1
) to be set for the test stack. Docker not only allows individual service instances to be separated, but also the entire network stack to be segmented, stopping not only one organisation’s project from accessing another’s, but also your test stack from accessing your dev stack. -
mount restrictions: This is something I’ll confess I don’t usually bother with when developing. But Docker allows easy configuration of volumes, including mounting all of the working directory as read-only and granting specific exceptions. This means I can be sure than an app is not doing something unpleasant like writing a runtime file when I’m not expecting it (breaking when behind load-balancing, not to mention not being part of my server backup strategy).
docker-compose.yml
Let’s work inwards, starting from an existing Procfile
definition of internal services:
# Procfile
web: bundle exec thin start -p $PORT
worker: bundle exec sidekiq
Tunefl also has a .env.example
, designed to be copied to .env
and adjusted (and copied to .test.env
, etc. for multi-stack setups). This is the app’s central settings file in environment variable form, containing external service connections such as DATABASE_URL
. I don’t use config/database.yml
files or similar. We can continue to use these settings with only minimal changes, also keeping it easy for those who wish to install without Docker.
We’ll be using the newer networking method rather than the Docker container links method, so we need to change hostnames; e.g.:
# .env.example
DATABASE_URL=postgresql://tunefl_dev:password@tunefl_postgres_1.tunefl/tunefl_dev
REDIS_URL=redis://tunefl_redis_1.tunefl:6379/0
For the web
service, we set the build context as the current directory, point to the templated .env
, mount the current directory as read-only, declare Port 8080
within the container, accessible outside the container on an ephemeral port, and specify the web
service previously defined in Procfile
(PORT
isn’t available here, so we specify it explicitly; the port is only used within the container, so this doesn’t worry me too much).
# docker-compose.yml
---
web:
build: &build "."
env_file: &env_file ".env"
volumes:
- ".:/srv/tunefl/:ro"
ports:
- "127.0.0.1::8080"
command: "bundle exec thin start -p 8080"
For the worker
service, we use the same build context and .env
, using YAML node anchors and references to ensure settings are kept in sync (we could also use Docker Compose extends syntax), use a non-default Dockerfile
as the worker
requires additional packages to be installed, connect to the same volumes as web
, and specify the worker
service previously defined in Procfile
. It’s worth noting that although volumes are shared, here, that’s only to facilitate default Tunefl local-storage setup (rather than AWS S3 or similar), as well as to make gem installation faster.
# docker-compose.yml (continued)
worker:
build: *build
env_file: *env_file
dockerfile: "Dockerfile.worker"
volumes_from:
- web
command: "bundle exec sidekiq"
For the postgres
and redis
services, we lock to particular versions, and use the images’ defaults.
# docker-compose.yml (continued)
postgres:
image: "postgres:9.3.10"
redis:
image: "redis:3.0.5"
I also used the opportunity to change the app to write logs only to STDOUT
rather than also log/
directory files, but this wouldn’t be necessary for an app already following Twelve-Factor properly. Also because of the read-only mount and some difficulties with db/schema.rb
getting changed on gem installation from the repository version, I disabled the rake db:schema:dump
task, which is automatically called by rake db:migrate
(this is an old Rails 3 app, so config.active_record.dump_schema_after_migration
isn’t available, and the rake db:schema:load
method doesn’t work for this app).
Dockerfile
Next, we need a Dockerfile
to define how to build the image. In this case, we’ll actually create two: Dockerfile
containing the main definition for the web
service, and Dockerfile.worker
containing the definition for the worker
service, which requires additional packages to be installed. Where possible, we’ll speed up the builds by considering carefully the order of the Docker layers, and using a shared volume for gem installation. For an app with fewer services or less complex package dependencies, one Dockerfile
would likely be sufficient.
We use SYNC
comments, which have no special meaning, to denote which sections of Dockerfile
and Dockerfile.worker
should be kept in sync, either for Docker layer optimisation (SYNC: Dockerfile/1
), or because it’s a shared pattern (SYNC: Dockerfile/2
).
Dockerfile—web service
We base the build on the official Docker ruby:2.2.3
image, install packages needed for gem installation, and create a user:
# Dockerfile
# SYNC: Dockerfile/1 {
FROM ruby:2.2.3
RUN \
apt-get update -y && \
apt-get install -y \
build-essential \
libpq-dev && \
useradd --home-dir /srv/tunefl/ --shell /usr/sbin/nologin tunefl
# SYNC: }
Next, we create the skeleton structure within our writeable volumes, and set ownership. It’s probably not necessary to create every one of these directories, but I had a list handy from Capistrano deployments, so I used this. Note that we don’t actually chown
/srv/tunefl
itself, as the default permissions allow the unprivileged user read access, and that’s all we need.
# Dockerfile (continued)
RUN \
mkdir \
/srv/tunefl.bundle/ \
/srv/tunefl/ \
/srv/tunefl/public/ \
/srv/tunefl/public/assets/ \
/srv/tunefl/public/system/ \
/srv/tunefl/public/uploads/ \
/srv/tunefl/tmp/ \
/srv/tunefl/tmp/cache/ \
/srv/tunefl/tmp/pids/ \
/srv/tunefl/tmp/sockets/ && \
chown -R tunefl \
/srv/tunefl.bundle/ \
# not /srv/tunefl/
/srv/tunefl/public/ \
/srv/tunefl/tmp/
Next, we copy the library dependency definitions. These aren’t owned by the unprivileged user either, ensuring that these definitions cannot accidentally change when we install the gems.
# Dockerfile (continued)
COPY [ \
"Gemfile", \
"Gemfile.lock", \
"/srv/tunefl/"]
We set the working directory and become the unprivileged user, which will be used both to install the gems and to run the web
service itself. We define BUNDLE_APP_CONFIG
to point to a writeable volume owned by the unprivileged user. Then, we install the library dependencies.
# Dockerfile (continued)
# SYNC: Dockerfile/2 {
WORKDIR /srv/tunefl/
USER tunefl
ENV BUNDLE_APP_CONFIG /srv/tunefl.bundle/
# SYNC: }
RUN bundle install --path /srv/tunefl.bundle/
Finally, we declare the writeable volumes: /srv/tunefl.bundle
to contain installed gems, and /srv/tunefl/public/
and /srv/tunefl/tmp/
to be mounted over the top of read-only /srv/tunefl
. Ideally, we’d switch tmp/
to another location entirely, but this doesn’t appear to be straightforward with Rails 3.
# Dockerfile (continued)
VOLUME [ \
"/srv/tunefl.bundle/", \
"/srv/tunefl/public/", \
"/srv/tunefl/tmp/"]
Dockerfile.worker—worker service
We keep the SYNC: Dockerfile/1
section in sync with that in Dockerfile
, rather than modifying it to add our additional packages. This enables the Docker layer cache to be used, significantly increasing the speed of builds.
# Dockerfile.worker
# SYNC: Dockerfile/1 {
FROM ruby:2.2.3
RUN \
apt-get update -y && \
apt-get install -y \
build-essential \
libpq-dev && \
useradd --home-dir /srv/tunefl/ --shell /usr/sbin/nologin tunefl
# SYNC: }
Next, we install the additional packages needed for the worker
service. The lilypond
package has lots of dependencies, and installation takes a long time. This fragment is placed so as to maximise the shared layers between Dockerfile
and Dockerfile.worker
. However, web
does not need lilypond
, so we keep that build clean, which is the whole motivation for the separate Dockerfile.worker
.
# Dockerfile.worker (continued)
RUN \
apt-get update -y && \
apt-get install -y \
lilypond
Finally, we keep the SYNC: Dockerfile/2
section in sync with that in Dockerfile
, simply to reuse the shared pattern. We don’t benefit from the Docker layer cache cross-Dockerfile
s here, as the previous steps have diverged. Note that we don’t need to set up any directories or install any gems, as we’re reusing the web
service volumes. This means that gem installation should still be fast, because the work has already been done in Dockerfile
.
# Dockerfile.worker (continued)
# SYNC: Dockerfile/2 {
WORKDIR /srv/tunefl/
USER tunefl
ENV BUNDLE_APP_CONFIG /srv/tunefl.bundle/
# SYNC: }
usage
In this section, I note some common usage commands. I’m using Docker Compose 1.5.2, which means the --x-networking
flag is required to activate the automatic handling of a segregated project-specific bridge
network. If you’re using Docker Compose 1.6 onwards, you probably won’t need this flag.
To build and start all services, both internal and external:
docker-compose --x-networking up
To view all running containers and their names:
docker ps
To open a browser pointed to whichever ephemeral port Docker has connected the web
service to (you can also map to host ports explicitly, if you prefer):
xdg-open "http://$(docker-compose port web 8080)" # Linux
To connect to PostgreSQL:
docker exec -it tunefl_postgres_1 psql -U postgres
To connect to Redis:
docker exec -it tunefl_redis_1 redis-cli
To migrate a database using rake db:migrate
:
docker exec tunefl_web_1 bundle exec rake db:migrate
To monitor Resque or Sidekiq jobs from the command-line using my Sidekiq Spy:
docker exec -it tunefl_worker_1 sh \
-c 'TERM=xterm bundle exec sidekiq-spy -h tunefl_redis_1.tunefl -n resque'
More usage notes, including an approach to handling multiple stacks such as for executing an automated test suite, can be found in the Tunefl README.
Code related to this post is available from the Tunefl repository.