UUUM攻殻機動隊

UUUMのエンジニアによる技術ブログです

CircleCIでRailsのDockerコンテナをECSにデプロイする

nazoです。

Graceful RestartによるRailsアプリのデプロイ を行っていたのですが、もっと手軽に環境ごとデプロイをしたいという需要もあって、Dockerを採用したデプロイも行っています。

CircleCI(1.0)では辛うじてDockerのサポートがありますが、いくつか癖があるので、ECSと含めて運用まで踏まえた手順について紹介したいと思います。

目的

AWS EC2 Container Service(以下 ECS )を使用し、DockerコンテナをEC2にデプロイします。github.comにコードをpushしたら CircleCI 上で自動でビルド・テスト・デプロイが行われるようにします。

ECSを使う理由は、AWSへの依存をぎりぎり少ないところで手軽にDockerコンテナのデプロイを行いたかったというところです。ECSを使用することにより、簡単にDockerコンテナのローリングデプロイを行うことができます。Dockerを使用する理由は、自動テストで使用したものをそのまま本番に上げることによってテストを確実にするのと、コンテナの差し替えを簡単にできるようにしておくことによって環境の更新を簡単にする、設定をECSに保存しておくことによって設定の全サーバーへの展開を簡単にするためです。

設計

Dockerコンテナの運用には、それに適した設計が必要になります。不適切な設計のアプリケーションをDockerで運用しようとすると、Docker不使用の状態より余計に不便になってしまうこともあります。

The Twelve-Factor App に準拠させる

まず、アプリケーションは The Twelve-Factor App に可能な限り準拠させておきます。詳細は割愛しますが、特に「設定を環境変数でのみ保持する」「アプリケーションは1つのプロセスとする」「ログを標準出力に出力する」「管理タスクを1回限りのプロセスとして実行する」あたりは、設計上重要になります。

アプリケーションプロセスとログ

大体上記で書いた通りですが、まず「アプリケーションは1つのプロセスとする」「ログを標準出力に出力する」を実現するためには、必然的にコンテナのentrypointでアプリケーションサーバーを起動し、それをフォアグラウンドで起動し続けるものになります。Dockerは標準出力にきたログの出力をどうするかという設定がありますので、それによってログの取扱を決めます。最近はfluentdにフォワードできますので、どこかに用意したfluentdサーバーに流しておくのが一番扱いやすいでしょう。

設定

設定は環境変数に保持します。Dockerはコンテナ起動時に環境変数を指定することができますので、これによってコンテナ内部は同一で、環境変数だけによって動作設定を変えることができます。

The Twelve-Factor Appにもありますが、設定を環境毎にファイルで持ってしまうと、環境が増える度にファイルが増え続けてしまいます。また、パスワードのような機密情報の保持方法がネックになります。環境変数で読んでおけば、環境変数を各環境に自動で仕込んでおくだけでよく、アプリケーションリポジトリとは全く別の環境で設定を作ることができます。例えば ansible + ansible vault のようなツールでECSのタスク定義に環境変数を仕込めば、機密情報をリポジトリにコミットしつつ環境設定を展開することができます。

Dockerを使用しない開発環境では、dotenv系のツールを使用し、ファイルから環境変数が自動で読み込まれるようにしておきましょう。

ソースコードの配布

The Twelve-Factor Appでは、管理タスク(データベースのマイグレーションなどの単発タスクのこと)も単発のDockerコンテナの実行プロセスとして扱います。これを実現するには、アプリケーションと管理タスクを同一のコンテナで、entrypointの切り替えで振る舞いが変わるようにします。こうすることにより、同一の環境を実行設定を変えるだけで振る舞いを変えることができるからです。

もしアプリケーションと管理タスクが別のコンテナの場合、管理タスク毎にコンテナが生まれてしまい、コンテナの管理が大変になります。また、アプリケーションコンテナの実行中にdocker execで入ってコマンドを叩くような運用は、そもそもホストにSSHログインする必要が発生するので根本的に煩雑で、せっかくのDocker運用のメリットが失われてしまいます。

これらを実現するためには、ソースコードはコンテナのビルド時に全て含まれており、docker runするとすぐに動作するコンテナになっている必要があります。コンテナ内からgit pullする必要はありませんし、コンテナ内にSSHログインする必要は全くありません。

nginxなどをフロントに置く場合

PHPのような実行環境の選択肢があまり多くない言語の場合や、アプリケーションのフロントで様々な制御を行いたい場合に、nginxなどを配置することがあります。この場合でも、nginxを動作させるコンテナと、アプリケーションのコンテナを分けずに同一にしておき、entrypointでアプリケーションを起動するかnginxを起動するかを選択させることで、同様にコンテナの数を最小限に抑えることができます。

また、このような構成にすることで、nginxが動作するコンテナにもソースコードが配布されていますので、nginxから静的ファイルを配信するような構成も簡単に作ることができます。アプリケーション側で静的ファイルの配布をするのが遅いが、CloudFrontを持ち出すまでもないような構成の場合でも手軽に環境の構築が可能です。

マウントしない

Dockerには、ホストボリュームをマウントする機能がありますが、これを使用してしまうと、ホストボリュームの状態に環境が依存してしまうことになります。特殊な用途では使用する場合もありますが、Webアプリケーションの通常のファイルアップロードのようなものではアップロード先を設定で切り替えれるようにしておき、本番ではS3などの外部ストレージを使用するようにしましょう。

The Twelve-Factor Appに完全準拠するのであれば、上記のような手法よりは、開発環境でも全く同じ構成にすべきとありますので、例えばS3であれば開発環境用S3を用意するか、 minio のような互換サーバーを用意しておくのがいいと思います。個人的にはそこまでの厳密性を求める場面がそこまで多くないので、そこは準拠せずに開発環境ではローカルで確認しています。フレームワークにそのようなアダプタが存在せず、自作するのが面倒な場合は、最初から本番環境と同様のものにするのが良いでしょう。

CircleCIの設定

基本設定

CircleCIでは、以下のように設定することでDockerがインストールされた状態になります。

machine:
  services:
    - docker

ECRをレジストリとして使う

ECRにログインするには以下で行うことができます。事前にアクセスキーなどは環境変数に仕込んでおきましょう。

- eval `aws ecr get-login --region ap-northeast-1`

CircleCI上でDockerコンテナのキャッシュを使用する

CircleCIのDockerバージョンはやや古く、最新の機能は使用することはできません。

キャッシュさせる一番簡単な方法は、エクスポートしたものをキャッシュ機能で保存し、それを復元することです。この時、ビルド時に使うベースイメージも同時にキャッシュしておく必要があります。

dependencies:
  cache_directories:
    - "~/.docker_cache"
  override:
    - mkdir -p ~/.docker_cache
    - eval `aws ecr get-login --region ap-northeast-1`
    - >-
      if [[ -e ~/.docker_cache/base.tar ]]; then
        docker load -i ~/.docker_cache/base.tar
      else
        docker pull ${DOCKER_REPO_ROOT}/base:latest
        docker save -o ~/.docker_cache/base.tar ${DOCKER_REPO_ROOT}/base:latest
      fi
    - if [[ -e ~/.docker_cache/app.tar ]]; then docker load --input ~/.docker_cache/app.tar; fi
    - docker build -f Dockerfile --rm=false -t ${DOCKER_REPO_ROOT}/app:latest .
    - docker save -o ~/.docker_cache/app.tar ${DOCKER_REPO_ROOT}/app:latest

大体このような流れで、キャッシュが有効になると思います。Dockerfileを正しくキャッシュされるように記述していれば、通常の利用方法より高速にビルドができると思います。

ビルド

最小限のアプリケーション動作に必要な要素(RailsならRubyとか)が揃ったベースイメージは事前に用意しておきましょう。ベースイメージは私はpacker + ansibleで作成しています。

キャッシュをなるべく効かせるために、ビルド処理は以下のように分離します。

  • bundle install
  • yarn install
  • アセットビルド

テスト前にアセットのビルドが必要なければ、「テスト前用のイメージ」と「テスト後(リリース)用イメージ」の2種類を用意するイメージで、この間にテストを行うようにすると、リリースしないブランチでのビルドは高速になるかと思います。

テスト

ビルドができたら、そのコンテナそのままでテストを行うことにより、本番と全く同じ構成のものでテストを行うことができます。docker run で都度コンテナを起動してテストすることによって、テスト結果によってファイルが書き換えられるようなことがあっても元に戻るので、それが本番に影響することはありません。

リポジトリに含まれても問題ないような環境変数はファイルに書いておき、docker run時に --env-file=circleci.env のように指定することで、テスト時の設定を流し込むことができます。パスワードのようなコミットできないものは、CircleCI自体の環境変数に入れておき、 -e で個別に指定しましょう。全部をCircleCIの環境変数から読ませてもいいですが、やや面倒かと思われます。

コンテナ内からコンテナ外のMySQL等に接続する

MySQLもコンテナで用意してlinkしたほうがスムーズではありますが、バージョンにそこまでこだわりがない場合は、最初からインストールされているものを使用したほうがビルドが高速になります。

最初からインストールされているものは、バインドアドレスが 127.0.0.1 になっているため、そのままでは接続することができません。Dockerのバージョンを上げて --net=host で同一ネットワークにするという手もありますが、面倒なのでCircleCIが提供しているDockerをそのまま使用したいところです。 ここは、設定をsedで置換してあげましょう。

- echo "GRANT ALL PRIVILEGES ON *.* TO root@'%' IDENTIFIED BY 'root'" | mysql -u root
- sudo sed -i -e 's/127.0.0.1/0.0.0.0/' /etc/mysql/mysql.conf.d/mysqld.cnf
- sudo service mysql restart

これで、以下のような方法で docker run を行うと、正常にDBに接続できるようになります。

- docker run --add-host=circleci:$(ip addr show docker0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1) --env-file=circleci.env -it bundle exec rails db:migrate

また、この場合、Railsの db:create:all は使用できません。ローカルアドレス以外はエラーになるように組み込まれているからです。データベースの作成はmysqlコマンドを直接叩きましょう。

- echo "CREATE DATABASE IF NOT EXISTS app_test;" | mysql -u root

リリース

テストが終わったらpushしてレジストリに登録します。

ECSにデプロイする場合は、silinternational/ecs-deploy を利用するのが便利です。シェルスクリプトでECSのタスク定義更新から、その後の起動監視までを面倒見てくれます。事前に登録してあるタスク定義を流用するので、CircleCI側に本番の設定を持つ必要もありません。

- docker push ${DOCKER_REPO_ROOT}/app:${CIRCLE_SHA1}
- ./ecs-deploy --region ap-northeast-1 --cluster app-cluster --service-name app-service --image ${DOCKER_REPO_ROOT}/app:${CIRCLE_SHA1}

また、Railsのmigrationのような、事前準備が必要な場合も、以下のようにサービスとは関係ないタスク定義を更新し、タスクを直接実行することで実現できます。この時、コンテナはアプリケーションと同一のものを使うようにし、entrypointの切り替えでタスク実行になるようにしましょう。ecs-deployスクリプトはタスク定義の更新はできるものの、そのタスクの単発実行ができないので、私はスクリプトを一部書き換えています。

まとめ

CircleCIでDockerコンテナをビルド・テストし、ECSに自動デプロイする手順について紹介しました。今回は定期実行ジョブについて説明しませんでしたが、キューで実行する、外部から定期的にecs run-taskする、などの方法が考えられます。

開発環境の話 に続き、Dockerは正しく使うことで大きな利便性を獲得することができますが、それには仕組みを理解し、何故使うのか、本当に使う必要があるのか、を明確にする必要があります。とにかくDockerにすれば便利になるというものではありませんので、適切に運用できるようにしましょう。