Ruby on Railsプロジェクトの開発環境をDocker化する

https://qiita.com/advent-calendar/2020/access

こちらの16日目の記事です。

# 概要

現在運用中の[Ruby on Rails](https://rubyonrails.org/)プロジェクトの開発環境をDocker化する案件があり、その際に行った移行作業の手順を示します。

# Dockerについて

[Docker](https://www.docker.com/)とは、コンテナと呼ばれる仮想環境を構築・実行できるようにするためのプラットフォームです。

私は今回初めてDockerを触ったのですが、Dockerの理解にあたっては[入門Docker](https://y-ohgi.com/introduction-docker/)が大変参考になりました。

# 前提

## バージョン

- macOS Catalina 10.15.7
- Docker 19.03.13
- docker-compose 1.27.4
- Ruby 2.4.5
- mongoDB 3.0.15
- postgres 10


## システム構成

こちらのシステム構成図の通りにDocker化していきます。
(※大分簡略化しています)

![system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189802/42ca60bc-f8d5-aa4d-c28c-0519ec12f965.png)

初めにRailsアプリケーションのDocker Containerを作成し、
その後オーケストレーションツールである[docker-compose](https://docs.docker.jp/compose/toc.html)によって、
RailsアプリケーションをmongoDB及びpostgreSQLに接続します。

# 手順

## Dockerのインストール

Docker Hubより[Docker Desktop for Mac](https://hub.docker.com/editions/community/docker-ce-desktop-mac/)を導入します。

ターミナルで下記の2つのコマンドが実行できればOKです。

```bash
$ docker -v
Docker version 19.03.13, build 4484c46d9d
$ docker-compose -v
docker-compose version 1.27.4, build 40524192
```

## 必要なファイルの作成

Docker及びdocker-composeを動かすのに必要なファイルをプロジェクトのルートに作成します。

### Dockerfile
ruby 2.4.5環境が予めインストールされている`ruby:2.4.5-slim`というDockerイメージを[Docker Hub](https://hub.docker.com/)から取得し、そのイメージ上にRails環境をセットアップしています。

```Dockerfile:Dockerfile

FROM ruby:2.4.5-slim

# Dockerコンテナ上におけるプロジェクトルートを指定
ENV APP_ROOT=/app
RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT

# apt-utilsインストールの時の警告を抑制する
# https://qiita.com/haessal/items/0a83fe9fa1ac00ed5ee9
ENV DEBCONF_NOWARNINGS yes

# aptパッケージのインストール
RUN apt-get update -y -qq && \
    apt-get install -y -qq build-essential libpq-dev libmagickwand-dev

# Railsのセットアップ
COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock
RUN gem install bundler -v 1.17.3 && bundle install

# プロジェクトディレクトリをDocker Imageにコピー
COPY . $APP_ROOT
```

### docker-compose.yml
`postgres`, `mongo`, `web`の3つのサービスを定義し、
`web`を`postgres`及び`mongo`に依存させています。

```docker-compose.yml
version: "3"

services:
    # postgreSQL containerの定義
    postgres: 
        image: postgres:10
        ports:
            # <Host Port>:<Container Port>
            - "5432:5432"
        environment:
            POSTGRES_USER: xxxxxx
            POSTGRES_PASSWORD: xxxxxx
    
    # mongoDB containerの定義
    mongo:
        image: mongo:3.0.15
        ports:
           - "27017:27017"
    
    # Rails app containerの定義
    web: 
        build: . 
        env_file: .env
        # pid error の回避のため、server.pidを削除したのちにrails sを実行
        # https://qiita.com/sakuraniumarete/items/ac07d9d56c876601748c
        command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
        # 依存関係の定義 (webをビルドするとpostgresとmongoが同時にビルドされる)
        depends_on:
            - postgres
            - mongo
```

## ビルド実行

これら2つのファイルを作成すると、

```shell
$ docker-compose build
```

でコンテナをビルドできるようになります。

## DBの永続化

現在の状態ではDBがコンテナ内部のストレージに生成されており、
コンテナを削除して再ビルドすると、DBに保存されていたデータは全て消失してしまいます。

DB上のデータを永続化するためには、Dockerが提供している`volume`という機能を利用します。
volumeは、Docker Containerのライフサイクルからは独立して生成されるデータ保存領域です。
volume上にDBを生成することにより、コンテナを再ビルドしてもDB上のデータが残り続けるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189802/f8cb9808-7950-d490-a19a-7ae46b91f3c0.png)
引用元: https://matsuand.github.io/docs.docker.jp.onthefly/storage/volumes/

volumeを利用するためには、docker-compose.ymlに以下の内容を追記します。

```diff:docker-compose.yml
 services:
    postgres: 
+        volumes:
+            - "postgres-data:/var/lib/postgresql/data"
    mongo:
+        volumes:
+            - "mongo-data:/data/db"
+ volumes:
+    postgres-data:
+    mongo-data:
```

上記を追記した上で改めてビルドすると、Docker Volumeが作成されているはずです。

```shell
$ docker volume ls
local               mongo-data
local               postgres-data
```

## ホストとコンテナのソースコードを同期

現在の状態では、Dockerfileをビルドした時点で、ホストの全データををImageにコピーしています。


```Dockerfile:Dockerfile
COPY . $APP_ROOT
```

つまり、これより後にホスト側でソースコードを変更した場合、動作しているコンテナを一旦停止させ、
`docker-compose build` からやり直す必要があります。

開発環境において毎度ビルドからやり直しているのでは非常に効率が悪いので、
ホストのコード変更をコンテナに即時反映できるようにします。

よくある方法は、下記のように、プロジェクトのルートディレクトリを無名volumeとしてコンテナにマウントする方法です。

```docker-compose.yml
services:
    # ${APP_ROOT}はDockerfileにおいてENVで定義した環境変数
    web:.:${APP_ROOT}
```

しかし上記の方法では、Docker for Mac特有のVolume I/Oの遅さがパフォーマンスへ影響を及ぼすという問題があります。
(この問題について、[docker/for-macのGitHubリポジトリにおいて議論されています](https://github.com/docker/for-mac/issues/77)。)
手元の環境においては特にRSpecへの影響が顕著で、上記の方法でマウントした場合、**普段5分ほどで完了していたテストが30分ほどかかりました**...

### docker-sync

この問題の解決策として、[docker-sync](https://docker-sync.readthedocs.io/en/latest/)というサードパーティライブラリを利用することができます。

新たに`docker-sync.yml`と`docker-compose-dev.yml`を作成します。
作成にあたっては[docker-syncのドキュメント](https://docker-sync.readthedocs.io/en/latest/getting-started/configuration.html)を参考にしました。

```docker-sync.yml
version: "2"

syncs:
  sync-volume:
    src: "."
    sync_excludes:
      - "log"
      - "tmp"
      - ".git"

```

```docker-compose-dev.yml

version: "3"

services:
  web:
    volumes:
      - "sync-volume:/app:nocopy"

volumes:
  sync-volume:
    external: true
```

## DBセットアップ

以下のコマンドで、postgreSQLとmongoDBをセットアップします。

```shell
$ docker-compose run --rm -e RAILS_ENV=development -T web rake db:setup
$ docker-compose run --rm -e RAILS_ENV=devlopment -T web rake db:mongoid:create_indexes
```

`docker-compose run --rm <container name> <command>`は、
指定したコンテナサービスを起動し、任意のコマンドを実行後、そのコンテナを削除するというコマンドです。
DBはvolumeで永続化されているので、セットアップのためだけにコンテナを作成し、その後削除してしまっても問題ないということです。

## Railsサーバーの実行

ここまでの手順を実施した上で、下記コマンドを実行することでRailsサーバーが起動します。

```shell
$ docker-sync-stack start
```

これは以下のコマンドを短縮したものです。

```shell
$ docker-sync start
$ docker-compose -f docker-compose.yml -f docker-compose.yml up
```

`-f`オプションを使って複数のdocker-composeファイルを指定すると、
コンテナ作成時の各種パラメーターを上書きすることができます。
参考: [設定の追加と上書き - Docker-docs-ja](https://docs.docker.jp/compose/extends.html#adding-and-overriding-configuration)

また、上記はフォアグラウンドで起動するためのコマンドで、
バックグラウンドで起動する場合は以下のコマンドを実行します。

```shell
# 起動
$ docker-sync start
$ docker-compose -f docker-compose.yml -f docker-compose.yml up
# 停止
$ docker-compose down
$ docker-sync stop
```

# テスト実行

RSpecを実行するためには、サーバーを起動した状態で以下のコマンドを実行します。

```shell
$ docker-compose exec -e COVERAGE=true -T web bundle exec rspec
```

`docker-compose exec`で、起動中のDockerコンテナに対して任意のコマンドを実行できます。

もしくは、以下のようにコンテナに入って実行することもできます。

```shell
$ docker-compose exec web bash
root@container:/app# bundle exec rspec
```

# CI対応

CIツールとして[Jenkins](https://www.jenkins.io/)を使用しています。

## テストを実行するシェルスクリプト

ビルドジョブにおいて、下記のシェルを実行することで自動テストが行われるようにしました。

```bash
# 終了時の処理
# docker-composeが失敗した際でもJenkinsビルドマシンにゴミが残らないよう後処理をかける
# https://qiita.com/ryo0301/items/7bf1eaf00b037c38e2ea
function finally {
	# Clean project
 	docker-compose down --rmi local --volumes --remove-orphans
}
trap finally EXIT

# 並列実行のために、プロジェクト名としてJenkinsの環境変数である$BUILD_TAGを指定
export COMPOSE_PROJECT_NAME=$BUILD_TAG

# 環境変数で予めビルドするdocker-composeファイルを指定することで、
# -fオプションによる指定を省略できる
# https://docs.docker.jp/compose/reference/envvars.html
export COMPOSE_PATH_SEPARATOR=:
export COMPOSE_FILE=docker-compose.yml:docker-compose-test.yml

# Build Container
docker-compose build --no-cache
docker-compose up -d

# Setup DB
docker-compose exec -e RAILS_ENV=test -T web rake db:setup
docker-compose exec -e RAILS_ENV=test -T web rake db:mongoid:create_indexes

# Run RSpec
docker-compose exec  -e COVERAGE=true -T web bundle exec rspec
```

## ビルドジョブを並列実行できるようにする

RailsプロジェクトをDocker化していない時の問題として、
2つ以上のビルドジョブを並列実行すると、同じマシン上でDBの取り合いが起こり、
エラーが発生する問題がありました。

Docker化したことで、それぞれのビルドが独立したコンテナの中で実行されるようになり、
並列実行してもエラーが起こらないようになります。
ただし、並列ビルドの実行時にコンテナのポート番号が重複しないよう、
ポートフォワーディングの設定を変更する必要があります。
参考: [ホスト上にコンテナのポートを割り当て - Docker-docs-ja](http://docs.docker.jp/v1.11/engine/userguide/networking/default_network/binding.html)

```shell
export COMPOSE_FILE=docker-compose.yml:docker-compose-test.yml
```

で指定している `docker-compose-test.yml`の中身でそれを行っています。

```docker-compose-test.yml
version: "3"

services:
    postgres:
        ports:
            - "5432"
    mongo:
        ports:
            - "27017"
    web:
        ports:
            - "3000"
```


また、`docker-compose.yml`に記載したポート番号を`docker-compose-dev.yml`に移動する必要があります。


```diff:docker-compose.yml
services:
    postgres:
-        ports:
-            - "5432:5432"
    mongo:
-        ports:
-            - "27017:27017"
    web:
-        ports:
-            - "3000:3000"
```

```diff:docker-compose-dev.yml
services:
    postgres:
+        ports:
+            - "5432:5432"
    mongo:
+        ports:
+            - "27017:27017"
    web:
+        ports:
+            - "3000:3000"
```

理由としては、このまま `docker-compose up -d` を実行すると、`docker-compose.yml`と`docker-compose-test.yml`がマージされ、
結果としてポートの指定が以下のようになってしまい、`docker-compose-test.yml`でわざわざポート指定した意味がなくなってしまうためです。

```yml
services:
    postgres:
        ports:
            - "5432:5432"
            - "5432"
    mongo:
        ports:
            - "27017:27017"
            - "27017"
    web:
        ports:
            - "3000:3000"
            - "3000"
```

[Railsサーバーの実行](#Railsサーバーの実行)の項で、

> `-f`オプションを使って複数のdocker-composeファイルを指定すると、
> コンテナ作成時の各種パラメーターを上書きすることができます。

と述べましたが、複数指定可能なパラメータの場合は設定値は上書きされずに**マージされる**ので注意が必要です。

# おまけ: RubyMineへの対応

JetBrainsのIDEである[RubyMine](https://www.jetbrains.com/ja-jp/ruby/)は、Docker Container上のRuby on Railsの開発環境に完全対応しており、以下の手順で設定することができます。
[チュートリアル : リモートインタープリターとしての Docker Compose — RubyMine](https://pleiades.io/help/ruby/using-docker-compose-as-a-remote-interpreter.html)

# まとめ

## 今回新たに作成したファイル

開発環境をDocker化するにあたり、新たに作成したファイルは以下の通りです。

```
.
├── Dockerfile
├── docker-compose.yml
├── docker-compose-dev.yml
├── docker-compose-test.yml
└── docker-sync.yml
```


## その他

今回初めてコンテナ技術に触れ、Docker化にあたっては様々な試行錯誤を重ねました。
これまでに書いた中で、もっと良い対応方法がある、或いは対応方法として正しくない箇所があるかもしれませんが、その時はご指摘いただければ幸いです。