Railsオンプレミス製品のためのDockerベストプラクティスver1.0

レトリバの今村です。2019年9月中旬、Answer Finder 2.1.0 をリリースいたしました。「Docker による提供形態の開始」が 2.1.0 の主な変更点となりますが、本記事ではその内容の設計の過程で得られた知見やハマりどころなどを紹介します。

レトリバ製品開発部で確立できた「Docker まわりの定石」に関する一種の備忘録的な内容となります。Answer Finder をはじめとして、レトリバの製品は Ruby on Rails アプリケーションがほとんどのため、本記事で紹介する Docker イメージは基本的には Rails アプリ提供用のものです。

自己紹介

改めまして、レトリバ製品開発部の遊び人、今村です。

プロフェッショナル揃いのレトリバ製品開発部の中において、何事にも浅く広く馴染んでメンバーの弱点を埋める、遊撃手のような立ち位置で日々お仕事をしております。普段は Ruby on Rails を主に書いていて、お客様に気軽に明快に使っていただける UI/UX を提供するために日々邁進しておりますが、ここ半年は Dockerfile ばかり書いていた気がします。

最近はデザインやユーザインタフェースまわりの知見を高めるべく勉強中です。

本記事のあらすじ

オンプレミス提供が基本であるレトリバの製品群には、採用いただいたお客様ごとにどうしても「導入作業」が必要となります。 要求スペックや前提パッケージを調べて列挙し、ディレクトリ構成と各ディレクトリの必要空き容量を書き出し、コマンドラインに打ち込むコマンドを1つ1つ並べて期待される出力を併記し……。 それをもとにプリセールスエンジニアが客先にて数時間の作業を経て導入作業を完遂する、あるいは完遂できずにてんやわんや……なんてこともなかったわけではありません。かくいう自分も、レトリバのメンバーが少なかったころはよく導入・アップデート作業に駆り出されたものです。

多くの場合は手順を列挙したものを納品物とし、たまに Ansible のレシピや、それと Packer を組み合わせて Vagrant の BOX を作成して提供することもありました。 ですが令和元年現在においては、Docker のイメージを提供するのがもっともシンプルで確実性の高い「構成定義」と言えるでしょう。

ところで2019年9月下旬、Answer Finder 2.1.0 をリリースいたしました。Docker による提供形態の開始(+バグフィックス数点)という変更内容のためプレス等は打ちませんでしたが、大工事ではありました。 本記事では、その過程で直面した Docker まわりの設計に関するハマりどころ、選択肢と我々の選択、メンテナンス性を向上させるノウハウなどを散文的・備忘録的にまとめています。

Docker とはなんぞや?という根本的な説明は今や不要でしょうから省略させてください。 また、弊社製品はほとんどが Ruby on Rails を主体とする製品のため、Rails アプリケーションに固有のノウハウが多めです。

レトリバ製品のDockerに関する定石あれこれ

1コンテナ = 1プロセス

# in docker-compose.yml
services:
  rails:
    image: rails_application_image
    ...
    command: ['docker/production/start_rails.sh']
    ...
  job_worker:
    image: rails_application_image
    ...
    command: ['docker/production/start_job_worker.sh']
    ...

これは、docker-compose.yml の一部です。

上記の例では、同じ rails_application_image という製品イメージについて2つのコンテナを立て、それぞれ別のシェルスクリプトを実行しています。

前者は Unicorn サーバを起動するスクリプト、後者は DelayedJob のジョブワーカーを起動するスクリプトなのですが、いずれも非デーモンのフォアグラウンドプログラムとして起動しています。

これにより、「command に記したプロセスの死亡」=「コンテナの死亡」として扱え、非可用性の検出が容易になります。

Dockerfile の内部で CMD を記述しない

前述のとおりに、1つのイメージで複数用途のコンテナを立てる想定がある場合、 CMD に指定すべき「主要な用途」というのを定められません。

Unicorn サーバを立てるときは command なし、ジョブワーカーを立てるときは command を指定せよ、というのも不気味に思えます。コンテナ内で行われる処理を過度に隠蔽化しすぎないよう、あえて CMD エントリは付与していません。あまり行儀のよいイメージではないかもしれませんが…。

開発用ファイルと本番用ファイルの置き方

  • 開発用:
    • ./Dockerfile
    • ./docker-compose.yml
    • ./docker/development/*.sh
  • 本番用:
    • ./Dockerfile.production
    • ./docker/production/docker-compose.yml
    • ./docker/production/.env
    • ./docker/production/*.sh

ここは一種の「決め」の問題です。試行錯誤の結果でもあります。

1つのプロジェクトの中に、開発者向けの環境構築のための Dockerfile および docker-compose.yml ファイル、本番環境構築のための同ファイルが混在することとなります。これら2種を混同しないよう、いい感じの棲み分けを試みています。

docker/development/ ディレクトリには開発者向けの環境構築時に実行するプロビジョニング用シェルスクリプト等を含めています。

docker/production/ ディレクトリには本番環境構築時に実行するシェルスクリプトを含めていますが、同時に docker-compose.yml およびその中から参照する環境変数定義ファイル .env も配置しています。

開発者は手元環境にて頻繁に docker-compose コマンドを打つでしょうから、プロジェクトのルートディレクトリに docker-compose.yml ファイルを置いています。 Dockerfile.production に触れるのは基本的にバージョンアップ後のリリース時くらいですので、明示的に -f オプションをつけないと使えないようにしています。 リリースの際は、このディレクトリに配置してある docker-compose.yml.env を、Dockerイメージのtarballファイルと共にお渡しする流れとなります(後述)。

ただ、この構造の難点として、 doc まで打っても docker まで打っても下層ディレクトリに至るTAB補完が効かないという点があります。地味につらい。

docker-compose.yml ファイルと .env ファイル

# in .env
COMPOSE_PROJECT_NAME=example_app-1.1.0
RAILS_RELATIVE_URL_ROOT=/example_app
PORT=8678
...
# in docker-compose.yml
services:
  rails:
    ...
    ports:
      - ${PORT:?PORT is missing or blanked in .env}:8678
    environment:
      RAILS_RELATIVE_URL_ROOT: ${RAILS_RELATIVE_URL_ROOT:?RAILS_RELATIVE_URL_ROOT is missing or blanked in .env}
    ...

お客様には、Dockerイメージファイル(save コマンドで作った tarball)、docker-compose.yml 、および .env ファイルをお渡しします。後者2つはホスト側環境の同一ディレクトリに配置し、docker-compose の内容は極力直接編集せずに .env 内に記載された変数を変更してもらって設定してもらう流れとなります。

いずれはレトリバ製品リリース用の Docker リポジトリをご用意し、お客様に pull してもらうこともできるようにしたいですが、お客様の中には外部ネットワークに接続できない環境をお持ちの方もいらっしゃいますので、save によってエクスポートした tarball を load してもらうことでの導入をプライマリな手順としています。

docker-compose.yml 内では、bash と同様に :? 記法を用いることができます。想定している変数が .env 内になかったり空欄だった場合に右辺のメッセージを出力してエラーとなります。

COMPOSE_PROJECT_NAME のみ例外で、docker-compose up としたときに生成されるコンテナ群の接頭辞としてこれが用いられます。アプリケーションのバージョンアップ時に混乱しないよう、バージョン番号を含めておくのが適切と考えます。

tmpfs の使用

# in docker-compose.yml
services:
  rails:
    ...
    tmpfs:
      - /var/opt/retrieva/myapp/tmp
    ...
$ docker run --mount type=tmpfs,dst=/var/opt/retrieva/myapp/tmp \
             ...

Rails アプリケーションでは、Web サーバの pid ファイルやキャッシュ等を保持するための tmp ディレクトリをプロジェクトルートに持ちます。その名のとおり /tmp と同様の一時ファイル用ディレクトリとして用いられますので、内容を永続化する必要はなく、むしろ pid ファイル等が残ってしまうことで不具合の発生要因にもなります。

いちいち起動スクリプト等で中身を抹消するのも手ではありますが、 tmpfs を用いたほうが処理の明確化としても明瞭でしょう。

ちなみに Docker コマンドでは、ボリュームのマウントやバインドには -v オプションでなく --mount オプションを用いるようにしましょう。コマンドとしては長ったらしくなってしまいますが、ボリュームマウントなのかバインドなのかが明示化されることはメリットと考えます。 (参考:https://docs.docker.com/storage/volumes/#choose-the--v-or---mount-flag

開発者向け環境では . をバインドする

# WORKDIR /var/opt/retrieva/myapp_dev/
# in docker-compose.yml (for development)
services:
  rails:
    ...
    volumes:
      .:/var/opt/retrieva/myapp_dev/
    ...

プロジェクトルートをまるごと WORKDIR としてバインドすることで、ホスト側ローカルでの編集内容を用いて、コンテナ内で Web サーバを動かしたり RSpec を走らせたりできるようになります。docker-compose による環境構築の容易化を享受しつつ、慣れた開発ツールでコーディングできるようになります。

Railsのプロビジョニングのタイミング

# in Dockerfile.production
RUN bundle install --path=vendor/bundle --local \
                   --without=development test
# in docker/production/provisioning.sh
set -ex
bundle exec rails db:create
bundle exec rails db:migrate
bundle exec rails db:seed
bundle exec rails assets:precompile

Rails アプリケーションの導入時に行う作業はいくつかありますが、タイミングは以下のようにしています。ここで言う provisioning.sh は、導入時に1回のみ実行することを想定しています。

  • bundle install (gem 群の一括インストール)
    • Dockerfile 内にて実行し、Docker イメージに含めます
    • 特定バージョンのイメージをビルドする時点では Gemfile の内容は確定しているはずのため、イメージに含めてしかるべきです
  • データベース初期化系操作
    • provisioning.sh で実行させます
    • 外部 DB もしくは DB コンテナが先に立っている必要があるためです
  • bundle exec rails assets:precompile (アセット事前コンパイル)
    • provisioning.sh で実行させ、永続化ディレクトリに生成させます
    • Dockerfile 内で実行させる選択肢もありえますが、このコマンドは環境変数を受け取って挙動を変えることがあり、導入環境ごとに変えたい場合が予見されるため、イメージに含めません
      • 例えば、 example.com/example_app のようにサブディレクトリで動く Rails アプリとしたい場合、RAILS_RELATIVE_URL_ROOT=/example_app とする必要があります

おしまい

以上、Answer Finder の改良の過程で確立した Docker および docker-compose.yml まわりの書き方・構造の組み方に関するよしなし事でした。 まだ他の製品に今回見出だせたノウハウを浸透させるには時間が要りますが、各製品で一貫した導入手順にてご提供できますよう、これからも推進と改良に励みたいと思います。

弊社以外のアプリケーションでここに述べたノウハウがすべて適用できるとは考えにくいですが、本記事から「こんな使い方もできるのか」といった発見が得られたならば幸いです。