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 に投入するところまでは持って行けてないので、今の自分の考察が正しいのかどうかをこれから検証していきたい。

関連してそうな記事

📝 CircleCI と autodoc で Rails API のドキュメントを自動更新


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

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 での後追いドキュメント自動生成で自分は事足りるかなという感じがしている。

docker-and-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 関連の記事には大変お世話になりました。