Dockerを使ったRailsアプリデプロイ

# 概要
この記事ではRailsアプリケーションをAWS ECSへデプロイする方法を記述いたします
チュートリアル的な記事はよく見かけるのですが、実際に運用しているアプリケーションをどうデプロイするか、困った点などを中心にお伝えできればと思います
事前知識としてDockerについてはお調べください、乱暴にいうと軽量・高速な仮想環境（以降、コンテナと呼びます）を提供してくれるものです

# システム要件
- Ruby: 2.4.1
- Rails: 5.0.2
- DB: PostgreSQL 9.6.3
- Store: Redis 3.2.9


# Dockerの設定

システム要件それぞれを一つのコンテナに封じ込めます
その際にDocker Composeというものを利用します
OSはAlpineというものが特に軽量なのでそれを使っています

```yaml:docker-compose.yml
version: "3"
services:
  psql:
    image: postgres:9.6.3-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data
  redis:
    image: redis:3.2.9-alpine
    volumes:
      - redis-data:/data
  worker:
    environment:
      DATABASE_URL: postgres://postgres@psql:5432
      REDIS_URL: redis://redis:6379
    build:
      context: .
      dockerfile: ./docker/development/Dockerfile
    volumes:
      - app-data:/app
    depends_on:
      - psql
      - redis
    command: bundle exec sidekiq -C config/sidekiq.yml
  web:
    environment:
      DATABASE_URL: postgres://postgres@psql:5432
      REDIS_URL: redis://redis:6379
    build:
      context: .
      dockerfile: ./docker/development/Dockerfile
    volumes:
      - app-data:/app
    depends_on:
      - psql
      - redis
    command: bin/rails s --bind 0.0.0.0
    ports:
      - "3000:3000"
volumes:
  postgres-data:
  redis-data:
  app-data:
    external: true
```

MySQLやMemcachedなど他のミドルウェアを使う際などは適宜変更が必要です
volumeには永続化したいものを記述しているのですが、Macを使っている方はファイルの同期が遅すぎる問題(2017.9.7執筆時点)があるのでdocker-syncというものを使います

```bash
gem install docker-sync
brew install unison
brew install unox
```

でインストールして(Homebrew依存)、設定ファイルを書き込みます

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

syncs:
  app-data:
    src: '.'
    sync_strategy: 'unison'
    sync_excludes: ['.gitignore', '.git/', 'docker*', 'tmp/pids', 'log']
```

そしてコンテナを作るためのファイルを置けば準備完了です

```:docker/development/Dockerfile
FROM ruby:2.4.1-alpine

RUN apk add -U --no-cache \
  bash \
  fontconfig \
  postgresql-client postgresql-dev \
  git \
  alpine-sdk \
  nodejs \
  tzdata

RUN mkdir /app
WORKDIR /app

ADD Gemfile /app/Gemfile
ADD Gemfile.lock /app/Gemfile.lock
# Using hosts cache
ADD vendor/bundle /app/vendor/bundle

RUN bundle install --jobs=4 --retry=5 --path=vendor/bundle --without staging production

ADD . /app
```

Gemをインストールする際に足りないライブラリなどが多いのでそれを解決するためにalpine-sdkを入れることをおすすめします（少しサイズが大きいのでDockerのイメージサイズを少しでも下げたい場合は個別にインストールすることを推奨）
もし他に欲しいパッケージ(ImageMagickなど)があったら適宜インストールしてください

### オプション
.dockerignoreファイルに記載されたものはビルド時にファイルコピーされません
うまく設定してみてください

# Rails側での設定
Dockerを利用する際に構築の思想として[Twelve-Factor App](https://12factor.net/ja/)を読むことをオススメします
ここの第３節にあるようにアプリケーションの設定を環境変数に格納するようにしました
Gemとしては[config](https://github.com/railsconfig/config)を使います

```yaml:config/database.yml
default: &default
  adapter: <%= ENV.fetch("DB_ADAPTER", "postgresql") %>
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  <<: *default
  url: <%= ENV.fetch("DATABASE_URL", 'postgres://postgres@psql:5432') %>/development

test:
  <<: *default
  url: <%= ENV.fetch("DATABASE_URL", 'postgres://postgres@localhost:5432') %>/test

staging:
  <<: *default
  url: <%= ENV['DATABASE_URL'] %>
production:
  <<: *default
  url: <%= ENV['DATABASE_URL'] %>
```

大体の環境変数は`ENV.fetch('環境変数名', 'デフォルト値')`で読み込むようにしておくと良いです

```yaml:config/settings.yml
redis_url: <%= ENV.fetch('REDIS_URL', 'redis://redis:6379') %>
```

```rb:config/initializers/sidekiq.rb
Sidekiq::Logging.logger = Rails.logger
Sidekiq.configure_server do |config|
  config.redis = { url: Settings.redis_url, namespace: "sidekiq_#{Rails.env}" }
end

Sidekiq.configure_client do |config|
  config.redis = { url: Settings.redis_url, namespace: "sidekiq_#{Rails.env}" }
end
```

先述したconfigというGemは設定したものを`Settings.yaml_key`のような形式で読み取ることができます

# ローカルで動かしてみる
これで準備は整いました(アプリケーションの構成はそれぞれのサービスによって大きく異なるので、最低限の情報しか記載していません、起動してみてRailsのエラーが出た場合は適宜修正してください)

```bash
docker-sync start # OS Xの場合のみ
docker-compose run worker bin/rails db:create db:migrate
docker-compose up # Ctrl-cで終了
docker-sync stop # OS Xの場合のみ、アプリ開発 終了時
```

`docker-compsoe up`するとdocker-compose.ymlに記載された情報にしたがって、コンテナが立ち上がります
今回はWebサーバとしてPumaをWorker(非同期処理のサーバ)としてSidekiqを立ち上げる設定になっています
うまく起動されているようでしたら、[Puma](http://localhost:3000)へ接続してみてください

# デプロイ
大きく悩んだのはここでした
[AWS ECS](http://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ECS_GetStarted.html)
ドキュメントを参照して、正しくリポジトリ(ECR)とタスク定義、クラスタを作成、クラスタへサービスを登録してください
タスク定義でコンテナを設定する際のポートはホストが0でコンテナが3000としてください、またコンテナにはエンドポイントは不要で、コマンドにPumaやSidekiqを起動するコマンドを記載してください(カンマ区切り)

今回実際にデプロイしているアプリケーションは複雑な構成ではないのでPumaの前段にNginxなどを設置せずにシンプルな構成をとりました
Javascript/CSS/Imageなどのアセットファイルもサーバが直接提供します(ここはうまいことCDNでキャッシュしてやるとPumaの負担が減ります)
そして継続デプロイとしてCircleCIを使用しています

```:docker/deploy/Dockerfile
FROM ruby:2.4.1-alpine

RUN apk add -U --no-cache \
  bash \
  fontconfig \
  postgresql-client postgresql-dev \
  git \
  alpine-sdk \
  nodejs \
  tzdata

RUN mkdir /app
WORKDIR /app

ADD Gemfile /app/Gemfile
ADD Gemfile.lock /app/Gemfile.lock

RUN bundle install --jobs=4 --retry=5 --path=vendor/bundle --without test development

ADD . /app

ARG RAILS_ENV
ARG DB_ADAPTER
ARG SECRET_KEY_BASE

RUN RAILS_ENV=${RAILS_ENV} \
  DB_ADAPTER=${DB_ADAPTER} \
  SECRET_KEY_BASE=${SECRET_KEY_BASE} \
  bin/rails assets:precompile
```

```yaml:.circleci/config.yml
version: 2
jobs:
  build:
    working_directory: ~/deploy
    docker:
      - image: docker:17.07.0-ce-git
    steps:
      - checkout
      - setup_remote_docker

      - apk add --no-cache py-pip=9.0.1-r1
      - pip install awscli==1.11.129 ecs-deploy==1.2.0
      - restore_cache:
          keys:
            - v1-{{ .Branch }}
      - run:
          name: Load Docker image layer cache
          command: |
            set +o pipefail
            docker load -i /caches/app.tar | true

      - run:
          name: Build application Docker image
          command: |
            export SECRET_KEY_BASE=$(aws ssm get-parameter --name staging.secret_key_base --output text --query "Parameter.Value" --with-decrypt)
            docker build --cache-from=app -t app \
                          --build-arg RAILS_ENV=staging \
                          --build-arg DB_ADAPTER=nulldb \
                          --build-arg SECRET_KEY_BASE=${SECRET_KEY_BASE} \
                          -f docker/deploy/Dockerfile .

      - run:
          name: Save Docker image layer cache
          command: |
            mkdir -p /caches
            docker save -o /caches/app.tar app
      - save_cache:
          key: v1-{{ .Branch }}-{{ epoch }}
          paths:
            - /caches/app.tar
      - run:
          name: Deploy Docker image
          command: |
            eval $(aws ecr get-login --no-include-email)
            docker tag app YOUR_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/staging:${CIRCLE_BUILD_NUM}
            docker push YOUR_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/staging:${CIRCLE_BUILD_NUM}
      - run:
          name: Update services
          command: |
            ecs deploy staging worker --tag ${CIRCLE_BUILD_NUM}
            ecs run staging worker-staging -c worker "bin/rails db:migrate"
            ecs deploy staging web --tag ${CIRCLE_BUILD_NUM}
```

### 困った点
1. アセットファイルを作成する際になぜかデータベースに接続しにいっている
2. 秘匿性の高い環境変数どうしよう問題
3. ECSへのデプロイどうしよう問題

### 解決策
1. [nulldb](https://github.com/nulldb/nulldb)を使う
2. [AWS SSM parameter](http://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-paramstore.html)を使う
3. [ECS Deploy](https://github.com/fabfuel/ecs-deploy)

色々と問題があったのですが、詳しく掘り下げていきましょう
### 1. アセットファイルについて
普通にsprocketsを使ってjavascript(coffeescript)をコンパイルしていたのでここで引っかかりました
他のサービスではwebpackerを使っておりwebpackでビルドするだけなので、また違う方法が必要です
ここはアセットファイルの扱いによってかなり変わってくる部分なのでそれぞれ対応が必要かと思います

### 2. 秘匿性の高い環境変数について
今回はAWSを使うのでAWS上で暗号化して保持するようにしました
方法はなんでも良いと思うので、とにかく外部に漏れないような設定を心がけてください
逆にRAILS_ENVなどのどうでも良い環境変数についてはベタがきで大丈夫です

### 3. ECSへのデプロイについて
AWS ECSへのデプロイに関しては他にもRubyやShell Scriptで書かれた開発が活発なツールがあります(CookpadのHakoなど)
私はPythonのbotoというAWSへのインターフェースを使ってAWSを操作するラッパーを作りかけたことがありPythonを選択しました

# CircleCIでテスト後にデプロイする
大体のプロダクトでは頑張ってテストを書いているかと思います
CircleCIでテストが通ってからデプロイする場合はworkflowが利用できます

```yaml:.circleci/config.yml
version: 2
jobs:
  build:
    working_directory: ~/test
    docker:
      - image: ruby:2.4.1-alpine
        environment:
          RAILS_ENV: test
          RACK_ENV: test
      - image: postgres:9.6.3-alpine
      - image: redis:3.2.9-alpine
    steps:
      - checkout

      - run: apk add -U --no-cache
             fontconfig
             postgresql-client postgresql-dev
             git
             alpine-sdk
             nodejs
             tzdata

      - restore_cache:
          keys:
            - gem-v0.1-{{ checksum "Gemfile.lock" }}
            - gem-v0.1
            - gem-v0

      - run: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs=4

      - type: cache-save
        key: gem-v0.1-{{ checksum "Gemfile.lock" }}
        paths:
          - vendor/bundle

      - run: bundle exec rake db:create db:schema:load --trace
      - run: bundle exec rspec --profile 10 --format documentation --out /tmp/test-results/rspec.txt --format progress spec

      - type: store_test_results
        path: /tmp/test-results
  deploy:
    working_directory: ~/deploy
    docker:
      - image: docker:17.07.0-ce-git
    steps:
      - checkout
      - setup_remote_docker

      - apk add --no-cache py-pip=9.0.1-r1
      - pip install awscli==1.11.129 ecs-deploy==1.2.0
      - restore_cache:
          keys:
            - v1-{{ .Branch }}
      - run:
          name: Load Docker image layer cache
          command: |
            set +o pipefail
            docker load -i /caches/app.tar | true

      - run:
          name: Build application Docker image
          command: |
            export SECRET_KEY_BASE=$(aws ssm get-parameter --name staging.secret_key_base --output text --query "Parameter.Value" --with-decrypt)
            docker build --cache-from=app -t app \
                          --build-arg RAILS_ENV=staging \
                          --build-arg DB_ADAPTER=nulldb \
                          --build-arg SECRET_KEY_BASE=${SECRET_KEY_BASE} \
                          -f docker/deploy/Dockerfile .

      - run:
          name: Save Docker image layer cache
          command: |
            mkdir -p /caches
            docker save -o /caches/app.tar app
      - save_cache:
          key: v1-{{ .Branch }}-{{ epoch }}
          paths:
            - /caches/app.tar
      - run:
          name: Deploy Docker image
          command: |
            eval $(aws ecr get-login --no-include-email)
            docker tag app YOUR_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/staging:${CIRCLE_BUILD_NUM}
            docker push YOUR_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/staging:${CIRCLE_BUILD_NUM}
      - run:
          name: Update services
          command: |
            ecs deploy staging worker --tag ${CIRCLE_BUILD_NUM}
            ecs run staging worker-staging -c worker "bin/rails db:migrate"
            ecs deploy staging web --tag ${CIRCLE_BUILD_NUM}

workflows:
  version: 2

  build_and_deploy:
    jobs:
      - build
      - deploy:
          requires:
            - build
          filters:
            branches:
              only:
                - deploy
```

# 運用に関して
プロダクション環境での運用はまだしていないのですが、開発環境で使っている段階では
ログはタスク定義のawslogs(事前にAWS Cloudwatchで設定が必要)に出力して大丈夫かと思います。ECSは標準出力をログとして扱うので環境変数`RAILS_LOG_TO_STDOUT`を設定するかGemとして[rails_12factor](https://github.com/heroku/rails_12factor)を入れるなどしてください
ログをみる際はいちいちCloudwatchからみていられないので[awslogs](https://github.com/jorgebastida/awslogs)を利用しています

コンテナの中で作業したいときはECSにより立ち上がったEC2コンテナにsshで接続して

```bash
docker ps # コンテナIDを確認
docker exec -it CONTAINER_ID bash # コンテナのbashにログイン
bin/rails c
```

などしています
普通に安定運用している限りコンテナに入ることはないと思います(そもそもコンテナに入るのは煩わしいです)

# 経緯
最近副業でHerokuからAWSへのインフラ移行を手伝わせてもらっています
Herokuを使うのは初めてだったのですが、デプロイが超簡単なんですね
最初はAnsibleでサーバを構築してCapistranoでデプロイする気でした
デプロイが活発(かつ自動的)なサービスだったのでこの構成にすることで、自由度の高いインフラを手にしつつ手間もそこまで変わらないようにできたと思います
ただCircleCIからAWS ECSへのデプロイはHerokuへのデプロイに比べると時間がかかってしまうので、そこをどうにかできたらなぁと思います

# 最後に
Dockerの環境さえ作っておけばGoogle Cloud Platformなど他のクラウドサービスやオンプレミスへの移行もしやすいかと思います
この記事をみて興味を持ってくれた方が移行を検討していただければ幸いです

また本記事には最低限の設定しか記載しておらず、設定の記述漏れなどがある（ような気がします）
私に分かることでしたら、お答えしますので[Twitterのアカウント](https://twitter.com/i_taiga1)へ連絡願います