複雑な構成のrailsアプリをdockerで動かしたい場合はどうするべきか

はじめに

本記事は複雑な構成をとるrailsアプリケーションをdockerで動作させる場合についてまとめています。

これまでにrailsを動作させるコンテナのDockerfileをどう記述するのが良いかという観点と、railsアプリケーションのプレビュー環境をdockerで構築出来るかという検証の二つの記事を公開していました。

先に公開した二つの記事では、railsアプリケーションを単独のコンテナで動作させる場合について言及しています。
しかしアプリケーションの規模が大きくなるとrails単体だけでは無く、キャッシュにmemcachedやredis, 検索にsolrにelasticsearch, フロントにはapache, nginxと複数の要素で一つのシステムを構築することになってきます。
このような環境を出来るだけ簡単そして簡潔に構築することが本記事の目的です。

コンセプト

複数のサービスからなるシステムを構築する大きな方向性としては、サービス全部入りのコンテナを用意するものと、各機能毎にコンテナを用意するものが考えられます。
“全部入り"のコンテナを用意するとなると、Dockerfileにセットアップをごりごりと記述していくことになるでしょう。
Dockerfileの記述は複雑にならざるを得ず、少しの変更でコンテナ全体のビルドをやり直す必要も出てきます。
そこで本記事では、各サービス毎にコンテナを分離する方法をとります。
今回はコンポジションおよびオーケストレーションツールであるdocker/composeを用います。

目指す構成としてはフロントにnginx, railsアプリはunicornを動作させ、データベースにはmysqlを、キャッシュと全文検索としてredis, elasticsearchを取り扱います。

検証環境

検証はmac上にboot2dockerでdocker環境を構築して行っています。
dockerは1.5.0, docker-compose 1.0.1 を使用しています。

$ boot2docker version
Boot2Docker-cli version: v1.5.0
Git commit: ccd9032

$ docker version
Client version: 1.5.0
Client API version: 1.17
Go version (client): go1.4.1
Git commit (client): a8a31ef
OS/Arch (client): darwin/amd64
Server version: 1.5.0
Server API version: 1.17
Go version (server): go1.4.1
Git commit (server): a8a31ef

$ docker-compose --version
docker-compose 1.1.0

本記事で掲載しているソースコードの全体をk-shogo/mini_doc at figで公開しています。

railsコンテナ

主役はrailsのコンテナです。従ってリポジトリもrailsがメインで、その中に他のコンテナの設定を含めることになります。
railsアプリのDockerfileはプロジェクトルートに設置します。

FROM ruby:2.2.0

RUN apt-get update && apt-get install -y nodejs mysql-client --no-install-recommends && rm -rf /var/lib/apt/lists/*

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

COPY Gemfile /usr/src/app/
COPY Gemfile.lock /usr/src/app/
RUN bundle install -j4 --without development test

COPY . /usr/src/app

EXPOSE 3000
CMD ["bundle", "exec", "unicorn", "-c", "config/unicorn.rb"]

rubyのバージョンを切り替える場合にはFROMの公式rubyコンテナを切り替えることで容易に対応可能です。
今回はデータベースにmysqlを選択したことと、assetsのコンパイル目的で mysql-clientとnodeをインストールしています。
Gemfile及びGemfile.lockをアプリケーションのソースの前に追加しているのは、キャッシュを用いてbundle installが必要ない場合にスキップして高速化を図るためです。
RUN bundle installでGemfileに記述されたライブラリをインストールしますが、developmenttestグループを除外するかは好みで変更してください。
今回はrailsアプリケーションのDockerfile内部ではdb:createdb:migrateを行っていません。
これはdbに関するrakeタスクはデータベースに接続されていなければ実行できないためであり、railsアプリケーションのコンテナを単独でもビルドできるようにするためです。

Gemfileにはmysql, redis, elasticsearchに接続するためのライブラリ、そしてアプリケーションサーバーであるunicornを追加します。

# database
gem 'mysql2'

# redis
gem 'redis'

# elasticsearch
gem 'elasticsearch-model'
gem 'elasticsearch-rails'

# Use Unicorn as the app server
gem 'unicorn'

オーケストレーション

構成全体の設定を定義するfig.ymlもルートディレクトリに置いておきます。
構成全体の設定を定義するdocker-compose.ymlもルートディレクトリに置いておきます。
docker-compose.ymlではmysql, elasticsearech, redis, nginx, railsそしてdatastoreの5つのコンテナが定義されています。
記述方法の詳細はcompose/yml.md at 1.1.0 · docker/composeを参照してください。

mysql:
  image: mysql:5.6.23
  environment:
    MYSQL_ROOT_PASSWORD: 'pass'
  ports:
    - '3306:3306'
  volumes_from:
    - datastore

elasticsearch:
  build: containers/elasticsearch
  ports:
    - '9200:9200'
    - '9300:9300'
  volumes_from:
    - datastore

redis:
  image: redis:2.8.19
  ports:
    - '6379:6379'
  volumes_from:
    - datastore

nginx:
  build: containers/nginx
  ports:
    - '8080:80'
  volumes_from:
    - datastore
  links:
    - rails

datastore:
  build: containers/datastore

rails:
  build: .
  ports:
    - '3000:3000'
  environment:
    RAILS_ENV: preview
    MYSQL_ROOT_PASSWORD: 'pass'
    DATABASE_URL: mysql2://root:pass@mysql:3306
    REDIS_URL: redis://redis:6379
    ELASTICSEARCH_URL: http://elasticsearch:9200
    SECRET_KEY_BASE: hogehoge
  volumes_from:
    - datastore
  links:
    - mysql
    - elasticsearch
    - redis

サービス毎のコンテナ定義

まずはimageとbuildについて。
mysqlとredisはimageによってdocker hubの公式イメージを取得しています。
elasticsearchは日本語用プラグインを導入したイメージを使いたかったので、buildディレクティブで自前のDockerfileを指定しています。
今回は独自のDockerfileはリポジトリのルート以下にcontainersディレクトリを作り、さらにサービス毎にディレクトリを分割しています。

- containers/                  
 |- datastore/                 
  |  Dockerfile                
 |- elasticsearch/             
  |  Dockerfile                
 |- nginx/                     
  |  Dockerfile                
  |  nginx.conf
FROM elasticsearch:1.4.3

RUN plugin install mobz/elasticsearch-head
RUN plugin install elasticsearch/elasticsearch-analysis-kuromoji/2.4.2

elasticsearchのDockerfileについて見てみましょう。
独自の定義とはいってもベースは公式のelasticsearchを利用しています。
追加で必要になるプラグインのインストールのみ記述することで意図が明確になります。

FROM nginx:1.7.9
COPY nginx.conf /etc/nginx/nginx.conf

nginxも公式イメージをベースにします。
js, cssなどの静的アセットを配信しつつ、unicornに対してリバースプロキシとしても動作させるので、このイメージには設定ファイルを追加します。

mysqlやredisも設定を変更したい場合は公式イメージに設定を上書きする運用が良いでしょう。

コンテナ間のリンク

docker-compose.ymlのlinksディレクティブではコンテナ間のリンクを定義します。
アクセスの方向性は nginx -> rails -> (mysql, redis, elasticsearch)のため、nginxのlinksにはrailsを、railsのlinksにはmysql, redis, elasticsearchを指定しています。

さて、railsのdatabase.ymlを指定するときにはどうするでしょうか。
dockerのコンテナ間のリンクの場合、MYSQL_PORT_3306_TCP_ADDRのようなdockerが自動的に設定してくれる環境変数を用いる例が出てきます。
しかしこれは得策とはいえません。
redisへの接続を例に考えてみると、REDIS_PORT_6379_TCP_ADDRのような環境変数になりますが、この使いにくい環境変数を活用しようとすると、config/initializers/redis.rb等で切り分けが必要になります。

if Rails.env.docker?
  # docker環境の場合
  Redis.current = Redis.new host: ['REDIS_PORT_6379_TCP_ADDR'], port: ENV['REDIS_PORT_6379_TCP_PORT']
else
  # それ以外の場合...
end

このような記述をしないための設定がrailsのenvironmentディレクティブです。

DATABASE_URL: mysql2://root:pass@mysql:3306
REDIS_URL: redis://redis:6379
ELASTICSEARCH_URL: http://elasticsearch:9200

docker-composeでlinkディレクティブを指定すると、hostsに他コンテナの設定が追加されます。

$ docker-compose run rails cat /etc/hosts
172.17.2.69 6866a8e73892
127.0.0.1   localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.2.66 minidoc_elasticsearch_1
172.17.2.67 minidoc_redis_1
172.17.2.67 redis
172.17.2.67 redis_1
172.17.2.66 elasticsearch
172.17.2.66 elasticsearch_1
172.17.2.65 mysql_1
172.17.2.65 minidoc_mysql_1
172.17.2.65 mysql

直接railsからhostsの値を用いても良いですが、environmentディレクティブで整理してrailsに環境変数として渡すことで、プロダクションなどの他環境に移しやすくなります。
例えばredis, elasticsearchはgemの設計として、それぞれ REDIS_URL, ELASTICSEARCH_URLが設定されていた場合に接続先として用いてくれるので、それらに合わせることでdocker環境でもプロダクション環境でも対応できます。
database.yamlでも環境変数DATABASE_URLで接続情報を渡すと決めていれば環境の切り替えが容易になります。

default: &default
  adapter: mysql2
  encoding: utf8
  pool: 5

development:
  <<: *default
  database: minidoc_development
  username: root
  password:
  host: localhost

test:
  <<: *default
  database: minidoc_test
  username: root
  password:
  host: localhost

preview:
  <<: *default
  database: minidoc_preview
  url: <%= ENV['DATABASE_URL'] %>

production:
  <<: *default
  database: minidoc_production
  url: <%= ENV['DATABASE_URL'] %>

データボリュームコンテナ

データボリュームコンテナには永続化、共有したいパスをVOLUMEコマンドで定義しているだけです。

FROM busybox:latest

VOLUME /var/lib/mysql
VOLUME /usr/share/elasticsearch/data
VOLUME /data
VOLUME /usr/src/app/public
VOLUME /usr/src/app/log

CMD /bin/sh

mysqlやelasticsearch用には永続化するだけですが、ここでのポイントはrailsが生成するassetsの扱いです。
今回フロントのnginxから静的ファイルを配信したいので、アセットをnginxコンテナに渡さなければなりません。
もちろんローカルでassets:precompileしたものをnginxコンテナにADDする方法もとれます。
その場合、ローカルにnode.js等が必要になってくるため、今回の例ではnodeが用意されているrailsコンテナでコンパイルを行い、そのファイルを共有したボリュームによってnginxから読み出す方法を紹介します。
そのためのパスが/usr/src/app/publicです。
nginxの設定ファイルを見てみましょう。

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
  worker_connections 1024; # increase if you have lots of clients
  accept_mutex off; # "on" if nginx worker_processes > 1
}

http {
  include mime.types;
  default_type application/octet-stream;
  log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

  access_log  /var/log/nginx/access.log  main;

  sendfile on;

  tcp_nopush on; # off may be better for *some* Comet/long-poll stuff
  tcp_nodelay off; # on may be better for some Comet/long-poll stuff

  gzip on;
  gzip_http_version 1.0;
  gzip_proxied any;
  gzip_min_length 500;
  gzip_disable "MSIE [1-6]\.";
  gzip_types text/plain text/html text/xml text/css
             text/comma-separated-values
             text/javascript application/x-javascript
             application/atom+xml;

  upstream app_server {
    # for UNIX domain socket setups:
    # server unix:/path/to/.unicorn.sock fail_timeout=0;

    # for TCP setups, point these to your backend servers
    # server 192.168.0.7:8080 fail_timeout=0;
    server rails:3000 fail_timeout=0;
  }

  server {
    listen       80;
    server_name  localhost;
    client_max_body_size 4G;
    keepalive_timeout 5;

    # path for static files
    root /usr/src/app/public;

    try_files $uri/index.html $uri.html $uri @app;

    location @app {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_redirect off;
      proxy_pass http://app_server;
    }

    # Rails error pages
    error_page 500 502 503 504 /500.html;
    location = /500.html {
      root /usr/src/app/public;
    }
  }
}

ポイントはroot /usr/src/app/public;の部分です。
/usr/src/appはrailsアプリケーションが配置されている場所です。
そのためrailsコンテナでrake assets:precompileすると/usr/src/app/publicにコンパイルされたアセットが配置されます。
このパスをデータボリュームコンテナで共有することでnginxコンテナから読み出すということです。

その他設定 dockerignore

dockerignoreファイルも用意しておきましょう。
ログや一時ファイルなどを除外しておかないと、アプリケーションの変更だと見なされてしまいます。

## Rails
public/assets/*

## Environment normalisation:
.bundle
vendor/bundle
.git

## log
log
tmp

## OSX
.DS_Store

## docker
.dockerignore
containers

ビルド & 実行

一気にビルドする事も出来ますし、サービスを指定することも可能です。

$ docker-compose build
$ docker-compose build rails

rakeタスクなどを実行する場合にはdocker-compose run railsに続けて指定できます。
docker-compose.ymlのなかでRAILS_ENV`を指定している場合はそのRails.envで動作します。

$ docker-compose run rails rake db:create
$ docker-compose run rails rake db:migrate
$ docker-compose run rails rake db:seed_fu
$ docker-compose run rails rake assets:precompile

docker-compose upで複数のコンテナを一気に立ち上げましょう。

$ docker-compose up

あとはboot2docker ipでアドレスを確認し、今回の場合はnginxの80と8080番を繋いでいるので、http://192.168.59.103:8080 にアクセスします。(IPアドレスは人によって異なります)

まとめ

本記事では複雑な構成のrailsアプリケーションをdockerで動作させる場合についてまとめました。
今回はrailsのコードをコンテナに入れ、プロダクション相当で稼働させる例でした。
本番環境もdockerで運用することが出来たなら、ビルドしたコンテナをそのまま本番に持って行くことが出来るようにです。

開発環境で使いたい、つまりローカルでコードを編集し、結果をコンテナで実行させているrailsで確認したい場合などは、プロジェクトルートをData Volumeでマウントする運用も出来るでしょう。
この方法ならdocker buildやコンテナに対してrsync等すること無くコードの反映が可能になります。

dockerを用いた環境の構築では、railsのリポジトリに必要なサービスの設定ファイルを含められること、開発環境を複数開発者で揃えられること、そして容易に環境の破棄が可能になること(例えばブランチの切り替え時にデータボリュームコンテナを差し替えるなど)のメリットが考えられます、