| @技術/プログラミング

Farewell to Rails Fat Model.png
Farewell to Rails Fat Model.png

ルビーオンレイルザーの皆さん、 Fat モデル対策やってますか。 Fat モデル対策と言えば Concern ですね。 app/models/concerns/ ディレクトリに module を置いてモデルに include させるというアレです。

しかしただ module を作って Fat モデルのコードを移動し、元のモデル側に include させるだけでは結局モデルのインスタンスに生えるメソッドの数に変わりはないので臭いものに蓋をしてるだけになります。 Rubocop の Metrics/LineLength 警告を逃れるためだけの module 乱立はあんまり意味がないでしょう。間違って別の module で同名のメソッドを定義してしまい意図しない挙動になってしまうことも考えられます。

最近自分がやってるのは、 include される module に定義するメソッドはせいぜい一つか二つにして、このメソッドから別クラス( Plain Old Ruby Object)に定義したメソッドを呼び出す(委譲する)というものです。モデルに得体の知れないメソッドが増えないので便利。

例えば以下のようなモデルがあるとします。

  • app/models/entry.rb
  • app/models/comment.rb

両方ともレコードの新規登録があったときに通知を行いたい。共通の処理なので Notifiable モジュールを作ってそれを Entry モデルと Comment モデルでそれぞれ include しましょう。ここまでは皆さんよくやると思います。

  • app/models/concerns/notifiable.rb
module Notifiable
  private

  def notify
    # do something
  end
end
class Entry < ApplicationRecord
  include Notifiable
  has_many :comments
  after_commit :notify, on: :create
end
class Comment < ApplicationRecord
  include Notifiable
  belongs_to :entry
  after_commit :notify, on: :create
end

しかし Entry と Comment では通知内容が異なるので単純に #notify メソッドを callback で実行すればよいというわけではない。通知用パラメーターを生成する処理をモデルに書くとよいのですが、そういうのを繰り返した結果が Fat モデル地獄なので通知内容を生成するクラスを別に作ります。こんな感じ。

  • app/models/concerns/notifiable/entry_notification.rb
  • app/models/concerns/notifiable/comment_notification.rb

Base クラスを作って共通処理をまとめ、継承させると便利でしょう。

  • app/models/concerns/notifications/base_notification.rb
module Notifiable
  class BaseNotification
    def initialize(object)
      @object = object
    end

    def perform
      NotificationJob.perform(notification_params)
    end
  end
end
module Notifiable
  class EntryNotification < BaseNotification
    def notification_params
      {
        recipient_ids: @object.subscriber_ids,
        title: @object.title
      }
    end
  end
end
module Notifiable
  class CommentNotification < BaseNotification
    def notification_params
      {
        recipient_ids: @object.thread_joiner_ids,
        title: @object.body.truncate(25)
      }
    end
  end
end

Notifiable モジュールはこんな感じになります。

module Notifiable
  private

  def notifiy
    notification.perform
  end

  def notification
    "#{self.class}Notification".constantize.new(self)
  end
end

図にするとこんな感じ。

Rails Fat Model Strategy.png
Rails Fat Model Strategy.png

この Notifiable モジュールを include しても Model には #notify メソッドと #notification メソッドしか追加されず、通知処理の実装をモデルから分離することができます。 Entry クラスも Comment クラスも #notify メソッドより先のことは何も気にしなくてよくなる。リソースが追加されたときに #notify メソッドを実行することだけに責任を持てばよいし、通知を飛ばすという処理自体は Notifiable::EntryNotificationNotifiable::CommentNotification クラスの責任になります。

私はこれで Fat モデルのコードを concerns ディレクトリにしまい込んで臭いものに蓋をするような対応におさらばしました。よろしければお試し下さい。またもし他に良い方法をご存じであれば教えて下さい。

| @技術/プログラミング

去年の年末に大掃除も年賀状もやらずに Rails に Pull Request を出してた。

ActionMailer のプレビューで locale が複数ある場合に指定できるようにするというもの。 Kaizen Platform の Rails アプリにはこの機能付いてて多言語対応のメールをプレビューするときにめっちゃ便利だった。調べたところ Rails 4 時代にそういう Pull Request 出してた人がいて Merge 寸前まで行ってたんだけど commit が複数に分かれてたのを「 squash してくれない?」とレビューされたところでプルリク主の意欲が燃え尽きたっぽくて Merge されずにコンフリクトして死んでた。

Rails 5 でも動くようにコンフリクトを解消してテストケースも追加したのが以下。

動作イメージはこんな感じ。

34454066\-f8bf06ec\-eda5\-11e7\-82ba\-1c2a0961b6b8\.gif \(833×768\)
34454066\-f8bf06ec\-eda5\-11e7\-82ba\-1c2a0961b6b8\.gif \(833×768\)

ただ Merge 後にバグってるのを指摘されていま直してるところです。

頭良くないのでこういうしょぼい Pull Request でしか contribute できないけど自分にできる範囲で貢献していきたい。

追記 2018-01-24

問題を修正する Pull Request も Merge してもらったんで多分 Rails 5.2 にこの機能入ります

| @技術/プログラミング

Rails 5.1 から入った Encrypted Secrets というのがある。 OAuth の client_secret などパスワード的なやつを暗号化して保存する仕組み。この手のやつはこれまで環境変数などにして dotenv などの機能を使ってそれぞれの環境ごとに .env ファイルを置く、というのがベストプラクティスだったと思うけど、 Encrypted Secrets を使えば秘密情報も暗号化してリポリトリに放りこめるので管理対象が少なくなって便利になる。暗号化するときの鍵は RAILS_MASTER_KEY という環境変数に格納するか、 gitignore した上で config/secrets.yml.key という名前で配置すると Rails がいい感じに読み取ってくれる。

Rails のエコシステムには config (旧 rails_config )という gem もあって、こいつも設定系の情報を入れておく用途によく使う。秘密系の情報と設定系の情報でどちらに値が入っているかを意識するのがめんどい& Rails.application.secrets.foo_bar とか入力するのが長い& Encrypted Secrets は YAML をネストさせられないのがだるいので、 config.gem の config/settings.yml の中で以下のようにしたら便利ではないかと思ってやってみた。

foo:
  bar: <%= Rails.application.secrets.foo_bar %>

呼び出し側の before after はこんな感じ。

Before

bar = Rails.application.secrets.foo_bar

After

bar = Settings.foo.bar

「めっちゃ最高便利じゃん」と思っていたけど、これをやると副作用がでかい。なんと Rails.application.secretsfoo_bar が見つからなくなる! というか Rails.application.secrets がほぼほぼ空になる!!!、!

[3] pry(main)> Rails.application.secrets
=> {:secret_key_base=>"xxx", :secret_token=>nil}

config/settings.yml から Encrypted Secrets を参照しているコードを取り除くと見えるようになる。

結論

というわけで config/settings.yml の中に Rails の Encrypted Secrets を混ぜて使うと危険っぽいです ☢️

| @技術/プログラミング

ecs-deploy-flow
ecs-deploy-flow

仕事で開発中のシステムで、 master ブランチに Pull Request が Merge されると自動的に AWS ECS に構築した社内向けの確認環境にデプロイが行われるような仕組みを導入した。自動テスト、コンテナイメージのビルド、デプロイには CircleCI を利用している。 .circleci/config.yml は以下のような感じ。

version: 2

shared: &shared
  working_directory: ~/app
  docker:
    - image: xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/organization/app
      environment:
        PGHOST: 127.0.0.1
        PGUSER: user
        RAILS_ENV: test
        REDIS_HOST: localhost
    - image: circleci/postgres:9.6-alpine
      environment:
        POSTGRES_USER: user
        POSTGRES_PASSWORD: password
    - image: redis:3.2-alpine

jobs:
  build:
    <<: *shared
    steps:
      - checkout
      # Restore bundle cache
      - &restore_cache
        type: cache-restore
        key: app-{{ arch }}-{{ checksum "Gemfile.lock" }}
      # Bundle install dependencies
      - &bundle_install
        run: bundle install -j4 --path vendor/bundle
      # Store bundle cache
      - &save_cache
        type: cache-save
        key: app-{{ arch }}-{{ checksum "Gemfile.lock" }}
        paths:
            - vendor/bundle
      # Database setup
      - &db_setup
        run:
          name: Database Setup
          command: |
            bundle exec rake db:create
            bundle exec rake db:structure:load
      - type: shell
        command: bundle exec rubocop
      # Run rspec in parallel
      - type: shell
        command: |
          mkdir coverage
          COVERAGE=1 bundle exec rspec --profile 10 \
                            --format RspecJunitFormatter \
                            --out /tmp/test-results/rspec.xml \
                            --format progress \
                            $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
      # Save artifacts
      - type: store_test_results
        path: /tmp/test-results
      - type: store_artifacts
        path: coverage

  generate-doc:
    <<: *shared
    steps:
      - run:
          name: Install dependencies
          command: |
            apk add --no-cache git openssh ca-certificates
      - checkout
      - *restore_cache
      - *bundle_install
      - *save_cache
      - *db_setup
      # Generate document
      - run:
          name: Generate API doc
          command: |
            AUTODOC=1 bundle exec rake spec:requests
      - run:
          name: Generate Schema doc
          command: |
            diff=$(git diff HEAD^ db)
            if [ -n diff ]; then
              bundle exec rake schema_doc:out > doc/schema.md
            fi
      - run:
          name: Setup GitHub
          command: |
            export USERNAME=$(git log --pretty=tformat:%an | head -1)
            export EMAIL=$(git log --pretty=tformat:%ae | head -1)
            git config --global user.email "${EMAIL}"
            git config --global user.name "${USERNAME}"
      - run:
          name: Push updated doc to GitHub
          command: |
            git add doc
            git commit --quiet -m "[ci skip] API document Update

            ${CIRCLE_BUILD_URL}"
            git push origin ${CIRCLE_BRANCH}

  deploy:
    docker:
      - image: docker:17.05.0-ce-git
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Install dependencies
          command: |
            apk add --no-cache \
              py-pip=9.0.0-r1 jq curl curl-dev bash
            pip install \
              docker-compose==1.12.0 \
              awscli==1.11.76
            curl https://raw.githubusercontent.com/silinternational/ecs-deploy/ac2b53cb358814ff2cdf753365cc0ea383d7b77c/ecs-deploy | tee -a /usr/bin/ecs-deploy \
              && chmod +x /usr/bin/ecs-deploy
      - restore_cache:
          keys:
            - v1-{{ .Branch }}
          paths:
            - /caches/app.tar
      - 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: |
            docker build --file=docker/app/Dockerfile --cache-from=app -t organization/app .
      - run:
          name: Save Docker image layer cache
          command: |
            mkdir -p /caches
            docker save -o /caches/app.tar organization/app
      - save_cache:
          key: v1-{{ .Branch }}-{{ epoch }}
          paths:
            - /caches/app.tar
      - run:
          name: Push application Docker image to ECR
          command: |
            login="$(aws ecr get-login --region ap-northeast-1)"
            ${login}
            docker tag organiation/app:latest xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/organization/app:latest
            docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/organization/app:latest
      - run:
          name: Deploy container
          command: |
            ecs-deploy \
              --region ap-northeast-1 \
              --cluster app-dev \
              --service-name puma \
              --image xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/organization/app:latest \
              --timeout 300

workflows:
  version: 2
  build-and-generate-doc:
    jobs:
      - build
      - generate-doc:
          requires:
            - build
          filters:
            branches:
              only:
                - master
      - deploy:
          requires:
            - build
          filters:
            branches:
              only:
                - master
  1. master ブランチに対して出された Pull Request が Merge される
  2. CircleCI でテストが実行される
  3. テストが成功すると CircleCI 上からデプロイが行われる
    • コンテナイメージをビルド
    • ビルドしたイメージを AWS ECR にプッシュ
    • プッシュしたイメージを利用するタスクを AWS ECS に作成
      ecs-deploy 任せ
    • 古いコンテナから新しいコンテナに LB 切り替え
      こちらも ecs-deploy にやってもらってる
  4. CircleCI 上実行された Request Spec で自動生成された API ドキュメントを GitHub にプッシュ

コードが Merge されると勝手に確認環境にデプロイされるので、クライアントサイドの開発者からデプロイを頼まれて対応する必要がないし、クライアントサイドの人はいつでも最新の API ドキュメントを GitHub 上で確認できる。 API ドキュメントは手動更新ではなくテストから自動生成されるので、ドキュメントと実際の API の挙動が異なる、というありがちな問題も回避できる。

自分としては結構頑張ったつもりだったんだけど、「それ ECS でやる意味あるの? というか Docker じゃなくて普通の EC2 インスタンスに Capistrano でデプロイするのでよくね?」というツッコミが入った。デプロイフローで CircleCI への依存度が強すぎる、イメージのビルドとデプロイに時間がかかりすぎるし、ちょっとした typo の修正のためにイメージをビルドしたりとかあり得ない、 Docker を使うにしても ECS は使わず、 EC2 で Docker を動かし、コンテナがマウントしたディレクトリに Capistrano でデプロイするべき、という意見だった。このときぐぬぬとなってしまってあまりうまく答えられなかったので考えられるメリットを書き出してみる。

確かに Docker と ECS による環境を構築するのには時間がかかる。デプロイのためにそこそこでかいイメージをビルドしてプッシュするというのも大袈裟だ。加えて Production で運用するとなるとログの収集やデータベースのマイグレーションなど、考えなければならない問題がいくつかある1

ただコンテナベースのデプロイには以下のようなメリットがあると思う。

環境のポータビリティー

まず Ruby や Rails などのバージョンアップが容易になる。手元で試して確認した構成とほぼほぼ同じイメージをデプロイできる。デプロイ前にサーバーに新しいバージョンの Ruby をインストールしたりしなくて済むし、手元ではエラーにならなかったのに本番でエラーになった、というようなケースを減らすことができる。

サーバー構築手順のコード化

人数が少ない会社で専業のインフラエンジニアもいない状況だと Chef や Puppet でサーバーの構成管理をし、複数台あるサーバー群の管理をすることは難しい。 Dockerfile に手順を落とし込み、 Docker さえ入ってたらあとは何も考えなくて良いというのはとても助かる。少なくとも秘伝のタレ化しやすいサーバーの構築手順がコード化され、コードレビューのプロセスに載せることができる

迅速なスケール

AWS ECS のようなマネージドコンテナサービスと組み合わせて使えばスケールアウトが楽ちん極まりない。 AWS マネジメントコンソールか cli で操作するだけで簡単にスケールさせることができる。スケールに際して LB に組み込む前にプロビジョニングしたり最新のコードをデプロイしたりする必要もない。

デプロイ失敗が減る

Capistrano によるデプロイはデプロイ対象が増えてくると SSH が不安定になりデプロイに失敗することが増えてくる。 ECS のような AWS の仕組みに載せることで、イメージを ECR にプッシュさえできれば IaaS 側でよろしくやってくれるというのはとても良い。

以上のようなところだろうか。まだ Production に投入するところまでは持って行けてないので、今の自分の考察が正しいのかどうかをこれから検証していきたい。

関連してそうな記事


  1. いまは先人がいっぱいいるのでログの集約もマイグレーションも情報はいっぱいあると思う 

| @技術/プログラミング

autodoc-generation-flow.png
autodoc-generation-flow.png

autodoc を導入して Rails プロジェクトで Request Spec を書くと自動的にドキュメントが更新されるようにした。 autodoc 自体は前々職の頃から利用していて大変お世話になっていた。ただ最初の頃は手元で AUTODOC=1 bundle exec rake spec:requests して手動でドキュメント更新していた。ドキュメントが更新されるかどうかは担当者の心がけ次第なのでよくなかった。

前職では CircleCI を使っていて、デプロイや Asset Precompile など CI でいろいろやるのが当たり前だったので、 Pull Request が Merge されたタイミングでドキュメント生成するように .circleci.yml をカスタマイズしてた。

いま仕事しに行ってるところでは .circleci/config.yml を version 2 にしていて、 version 2 からは workflow の概念が導入されたので、頑張ってシェルスクリプトで条件分岐させたりする必要がなくなった。 .circleci/config.yml は以下のような感じになってる。

version: 2

shared: &shared
  working_directory: ~/app
  docker:
    - image: circleci/ruby:2.4.1-node
      environment:
        PGHOST: 127.0.0.1
        PGUSER: username
        RAILS_ENV: test
        REDIS_HOST: localhost
    - image: circleci/postgres:9.6-alpine
      environment:
        POSTGRES_USER: username
        POSTGRES_PASSWORD: pasword
    - image: redis:3.2-alpine

jobs:
  build:
    <<: *shared
    steps:
      - checkout
      # Restore bundle cache
      - &restore_cache
        type: cache-restore
        key: app-{{ checksum "Gemfile.lock" }}
      # Bundle install dependencies
      - &bundle_install
        run: bundle install -j4 --path vendor/bundle
      # Store bundle cache
      - &save_cache
        type: cache-save
        key: app-{{ checksum "Gemfile.lock" }}
        paths:
            - vendor/bundle
      # Database setup
      - &db_setup
        run:
          name: Database Setup
          command: |
            sudo apt install postgresql-client
            bundle exec rake db:create
            bundle exec rake db:structure:load
      - type: shell
        command: bundle exec rubocop
      # Run rspec in parallel
      - type: shell
        command: |
          mkdir coverage
          COVERAGE=1 bundle exec rspec --profile 10 \
            --format RspecJunitFormatter \
            --out /tmp/test-results/rspec.xml \
            --format progress \
            $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
      # Save artifacts
      - type: store_test_results
        path: /tmp/test-results
      - type: store_artifacts
        path: coverage

  generate-doc:
    <<: *shared
    steps:
      - checkout
      - *restore_cache
      - *bundle_install
      - *save_cache
      - *db_setup
      # Generate document
      - type: shell
        command: |
          AUTODOC=1 bundle exec rake spec:requests
      - run:
          name: Setup GitHub
          command: |
            export USERNAME=$(git log --pretty=tformat:%an | head -1)
            export EMAIL=$(git log --pretty=tformat:%ae | head -1)
            git config --global user.email "${EMAIL}"
            git config --global user.name "${USERNAME}"
      - run:
          command: |
            git add doc
            git commit --quiet -m "[ci skip] API document Update

            ${CIRCLE_BUILD_URL}"
            git push origin ${CIRCLE_BRANCH}

workflows:
  version: 2
  build-and-generate-doc:
    jobs:
      - build
      - generate-doc:
          requires:
            - build
          filters:
            branches:
              only:
                - master

master ブランチでのビルドのときだけ generate-doc という job が実行されるようになっている( master ブランチ以外のビルドではドキュメント生成がスキップされる)。buildgenerate-doc で重複してる部分が多いので YAML のアンカー機能を使って重複を整理しているけど結構長い。 generate-doc ジョブでドキュメントが生成されると勝手に GitHub の master ブランチに対して push する。このときコミットメッセージに [ci skip] という文字列を付けておけば、 CircleCI はビルドをスキップするので延々とドキュメントの自動更新ビルドが走り続けることはない。

おかげでいまはテストさえ書けば、実際の API と同じフォーマットのドキュメントが自動生成されるのでとても便利になったと思う。

ちなみに JSON Schema というのもあって、これは JSON に仕様を書くとドキュメントやらモックサーバーを作ってくれるものらしい。めっちゃ便利そうだけど、ちゃんと使うのにはそれなりに仕組みを整える必要がありそうで手を出していない。 autodoc の作者の r7kamura さんのブログにも書いてある通り、 autodoc の便利なところは以下だと思う。

実際にアプリが生成した内容からドキュメントを生成するため、実装とドキュメントの乖離が少なく抑えられる。 また、テストを書くことの見返りが増えるため開発者がテストを書くのを推進しやすい。

全てがJSONになる - ✘╹◡╹✘

autodoc で master ブランチへの Merge をトリガーにしてドキュメントを自動生成するというポリシーでは、 B/E 側の作業中に F/E の人が API のドキュメントを見られなくて不便だという問題は確かに存在する。しかし JSON Schema で事前に仕様を固めて実装前にモックサーバーやドキュメントを提供できたとして、果たして事前に決めたとおりに B/E も F/E も実装できるのだろうか。きっと作っていく途中で「やっぱりアレはコレに変えたい」みたいのが双方から出てくると思う。

↑の r7kamura さんの記事では他に外の API をモックするダミーサーバーを JSON Schema で作ったりしてる。確かにすでに仕様が固まった外部の何かをモックするサーバーのセットアップなどには便利なのかもしれない。ただいまのところは autodoc での後追いドキュメント自動生成で自分は事足りるかなという感じがしている。

| @技術/プログラミング

ECS 化していたブログをさくら VPS に戻した。理由としてはお金が高かった。普段、 S3 と Route 53 に払ってる金額の 20 倍くらいの金額になって少ない小遣いでは払えないと思ったので VPS に戻した。ただ AWS で運用して勉強になった点もあった。

まず ECS での運用について知見が得られたのがよかった。 deploy するためには docker builddocker push が必要で、最初はちょっとした修正のためこれやるのは大げさだと思ったが、慣れればそんなでもなかった。ただイメージのビルドをどこでやるかは考えといた方がよさげ。自宅だと高速な光回線があるからよいけど、実家やどこか旅行に行ったときに image をビルドして ECR にプッシュするのはつらい。 GitHub に git push する度に CircleCI でビルドされるような体制を構築する必要があると思った。

CloudFront をウェブアプリケーションの前段に挟む、というのもやってみたけどこれもよかった。 Rails なら Asset Precompile によって静的なファイルへのリクエストはウェブアプリケーションまで届かないようにするのを良くやると思うけど、 Lokka は Sass や CoffeeScript を動的にコンパイルしてクライアントに返すので、 CSS や JS などへのリクエストにも puma のプロセスが消費されてエコではなかった。 VPS 運用時には Nginx を前段に入れて、画像やコンパイル済みの JS / CSS ファイルへのリクエストは puma にプロキシせず直接返していたが、 ECS 化したときに Nginx を挟まなくなったので CloudFront を入れて画像や JS / CSS へのリクエストをキャッシュするようにしてみた。

cloudfront-ecs-image.png
cloudfront-ecs-image.png

すると puma の負荷が低下し、 New Relic の Appdex が 0.95 前後だったのが 0.99 になった。 ECS で利用していた EC2 コンテナは t2.micro でしょぼいのでサーバーのスペックアップで改善されたわけではない。ということで VPS に戻すときにも Nginx の proxy_cache を使って Sass や CoffeeScript から動的にコンパイルされる CSS / JS をキャッシュするようにしてみた。さすがに CDN のような配信の最適化は実現できないが、以前よりかはかなりましになるはず。

なおセッションを有効にした Rack アプリで Sass や CoffeeScript を動的にコンパイルすると Set-Cookie ヘッダーがセットされてしまう。 Nginx の proxy_cacheSet-Cookie ヘッダーがセットされてるとキャッシュをしないので、

proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;

などとしてやる必要がある。

今後仕事でも ECS 化を行う予定があるので得られた知見を有効活用していきたい。

| @技術/プログラミング

docker-on-ecs.png
docker-on-ecs.png

ブログを Docker 化して AWS ECS で運用するようにした。

なぜ Docker 化したか

  • 仕事で Docker を使う機会が増え知見がたまってきた
  • 仕事では Production 投入はできていないので個人ブログで Production 投入して知見を得ておきたかった

どうやったか

ローカルセットアップ編

  • Dockerfile & docker-compose.yml を作成した
    • Alpine Linux を使ってなるべくイメージを小さくする
  • Gem::LoadError 問題
    • Lokka の Gemfile には動的な読み込みを行っている部分があるため、 Dockerfile で単純に COPY するだけでは Gem::LoadError になってしまう。
      • Lokka のプラグインは Gem 化されておらずリポジトリ内に含める形式
      • プラグイン側で必要な gem はプラグイン内に Gemfile を配置して宣言する形式
      • Lokka 本体の Gemfile には Dir["public/plugin/lokka-*/Gemfile"].each {|path| eval(open(path) {|f| f.read }) } のようなコードがあって強引に eval で内容を取り込んでいる
    • 対策
      • Gemfile.docker を用意する
      • Gemfile.docker を生成するためのシェルスクリプトを用意して実行する
      • Dockerfile の COPY は以下のようにする
      • COPY Gemfile.docker /app/Gemfile
  • 他、 MySQL のコンテナを追加して手元でアプリが起動するところまでは確認済み

Production セットアップ編

  • ECR にリポジトリを作成し image を push (公式のチュートリアル通りにやればできる)
  • ECS にサービスやターゲットグループ、タスクの作成なども指示通りに行う
    • 土台となる EC2 インスタンスは手動で作るのではなく、 ECS の画面でポチポチやると勝手に作られる
    • 詳細コンテナ設定でエントリポイントを入力する欄に、 Dockerfile と同じように文字列で書いていたらコンテナが起動せずハマった
      • カンマ区切りで書かないといけないらしい
      • puma を起動したかったら bundle exec puma ではなく、 bundle,exec,puma というように書かないといけない スクリーンショット 2017-08-19 10.50.09.png
  • 諸々設定を済ませたらロードバランサー( ALB )を EC2 のパネルで作成してターゲットに Docker コンテナが動いている EC2 インスタンスを指定する
    • ECS の用語やサービス構成に慣れるのに時間がかかるが、歯を食いしばってがんばるしかない
  • DB に関しては RDS を使うことにした
    • 稼働中の VPS サーバーで mysqldump -ufoo -p db_name | mysql -ubar -p db_name -h foo.bar.ap-northeast-1.rds.amazonaws.com みたいな感じで雑に流し込んで移行する
  • ECS は VPC でしか使えないので、 VPC に慣れてない人は VPC に慣れるところから頑張るしかない
  • セキュリティグループの設定なども必要になるので頑張って下さい
  • Nginx を利用しないので SSL の復号を ALB で行う必要がある
    • ACM で無料で証明書を発行できるのを知らず、 Let's Encrypt の証明書を取り込んで使う
  • ここまでで一旦公開

運用して気づいた問題点

  • サイトが 503 や 504 になる
    • Docker コンテナがすぐ死ぬ
    • ALB から切り離されることしばしば
    • VPS 時代は Nginx に静的ファイルの配信をまかせていたが、 Nginx を挟まなくなったので puma が担当することになりアプリの負荷が高まったのではと推察
      • CloudFront を挟んでいい感じにキャッシュしてもらい、静的ファイルの配信は CloudFront にまかせることに

CloudFront 導入編

  • ALB で使っているのとは別に SSL 証明書を取得する必要がある
    • CloudFront <-> ALB 間の通信を HTTPS で行うため
    • Route53 で ALB に割り当てている A レコードをサブドメイン付きの別のものに変更
    • ALB 用にはワイルドカード証明書を使う(無料で証明書取得できる ACM 最高)
    • Let's Encrypt の証明書を使うのはやめ、ルートドメインの証明書も ACM で取得して CloudFront に設定
  • 動的コンテンツ( HTML など)はキャッシュしないようにしないといけない
    スクリーンショット 2017-08-19 11.32.04.png
    • 当初、設定がうまくいっておらず、以下のような問題が発生
      • POST, PUT, DELETE できない
      • Cookie が origin に転送されずセッションが維持できない
      • クエリストリングが無視されてしまい、ページ検索などができない

所感

  • 体感的にサイトの読み込みがチョッパヤになった
  • CloudFront 導入したが、まだ 503 にはなる
    • そもそもインスタンスを良いやつに変えないとダメなのかもしれない
    • タスク数を増やしてクラスタリングするなどいろいろ試してみる
      • クラスタリングするためには Cookie セッションではなく Redis や Memcached などをセッションストレージに使う必要が出てくる…
  • Deploy だるい問題
    • cap deploy しなくなり、イメージをビルドして push する感じになる
      • Alpine Linux でもそこそこイメージサイズはでかくなるので貧弱な回線では docker push にめっちゃ時間かかる
    • ECS 側でもサービスを更新するなどの作業が発生
      • Blue / Green Deployment できるがポチポチ作業が発生するのがだるい
      • Rails を運用する場合は migration なども発生するのでうまいことやる必要あり
    • git commit しなくても作りかけのコードの状態で docker-compose build してしまいがちになり、リポジトリのコードと動いてるコンテナイメージの間に差分が発生してしまいそう
      • ちゃんと CircleCI などを導入してイメージのビルドとプッシュは CI サービスでやる、というような運用にしないと破綻しそう
  • 手順書問題
    • こんな風にブログを書いて雑な手順書を作成するようではいけない。 Terraform 化しないと破滅する。
  • Lokka は CMS for Cloud です
    • git push heroku master するだけで使えることが売りの Lokka を AWS のガチな構成で運用するという皮肉
  • お金高い
    • 毎月 3000 円くらいかかる感じになりそう。 VPS は年払いで 16000 円くらいなのでだいぶ高い。払えなくなったら VPS に戻しそう。

謝辞

r7kamura さんの amakan Docker 化の一連の記事と Classmethod 社の ECS 関連の記事には大変お世話になりました。