前書いてた記事 🏭 Docker を Production 投入するメリットを考える - portal shit! の続き。

Kaizen Platform 時代は Naoya Ito さんの以下の記事にあるような感じで deploy してた。 Slack 上で hubot に話しかけると deploy 用の Pull Request が作られていい感じに deploy フローが始まる。

GitHub 時代のデプロイ戦略 - naoyaのはてなダイアリー

これがめっちゃ良くて、現職場でも導入したいと思ってたので今週ちょっとやってみたところ deploy できるようになった。

実際のデプロイフロー

まず Slack で hubot ( 山の会社なので tengu という名前にしてる)に話しかける。すると hubot で GitHub の API を叩いて deploy 対象の Pull Request を取得し、それぞれの Pull Request ごとに commit をグルーピングして、 deploy 対象の Pull Request の Author にメンションするかたちで master ブランチから deployment/production ブランチへの Pull Request が作成される。

tengu deploy 1

最近 Slack の GitHub Integration がアップデートされて、 Webhook の通知がいい感じに飛んでくるようになったので Slack 上でどんな内容が deploy されるのかが一目瞭然となる。

実際に作成される Pull Request は以下のような感じ。この Pull Request を Merge することで CircleCI 上で deploy 用のビルドが走る。その辺は Naoya さんの記事で書いてあるのと同じ。

tengu deploy 2

いま作ってるやつは AWS ECS で運用しようとしてるので、 cap deploy ではなく手製のシェルスクリプトで以下のことをやっている。

  1. deploy 用のコンテナイメージをビルド
  2. AWS ECR にコンテナイメージをプッシュ
  3. プッシュしたイメージを利用する Task Definition を追加し、 ECS のサービスを更新 ecs-deploy というシェルスクリプトでやる

以前の記事にも書いたが「 CircleCI が落ちてたら deploy できないじゃん?」というツッコミが入ったため CircleCI が落ちていても deploy できるようにシェルスクリプト化してあるので、手元からおもむろに bin/deploy production とかやっても deploy できる。

ちなみにこのフローを実現する .circleci/config.yml は以下のような感じ。

jobs:
  deploy:
    docker:
      - image: docker:17.05.0-ce-git
    steps:
      - checkout
      - setup_remote_docker:
          docker_layer_caching: true
      - 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.18.0 awscli==1.14.38
            curl -s https://raw.githubusercontent.com/silinternational/ecs-deploy/ac2b53cb358814ff2cdf753365cc0ea383d7b77c/ecs-deploy | tee -a /usr/bin/ecs-deploy && chmod +x /usr/bin/ecs-deploy
      - run:
          name: Execute deployment (Docker image build, push to ECR, create new Task and replace container)
          command: |
            case ${CIRCLE_BRANCH} in
              "deployment/dev" | "master" )
                DEPLOY_ENV="dev" ;;
              "deployment/production" )
                DEPLOY_ENV="production" ;;
            esac
            bin/deploy ${DEPLOY_ENV}

workflows:
  version: 2
  production-deploy:
    jobs:
      - deploy:
          filters:
            branches:
              only:
                - deployment/production

Chat deploy のよさ

deploy フロー・ deploy 状況が可視化され、民主化されることがよい。昔ながらのローカルからの capistrano による deploy の問題点は deploy の特権化を招いてしまうことだと思う。 ○×さんしか deploy 用の踏み台サーバーに ssh できないので一々○×さんに deploy をお願いしないといけない、というような状況はよく分からない遠慮や序列を招きがち。 deploy フローが自動化されていることでチームに入ったばかりの人でもさくっと deploy が行えるというメリットもある。

deploy の履歴が Slack 上と CircleCI 上、また GitHub 上に Pull Request として残るのもよい。ひとくちに deploy といっても schema 変更が伴う場合は作業ログの共有やコミュニケーションをどこかで行う必要があり、その場所として GitHub の Pull Request が使えるのがとてもよい。 YAMAP で作った deploy スクリプトではそこまでやってないが、 Kaizen Platform の deploy スクリプトには deploy 用の Pull Request 本文に動作確認用のチェックボックスを作って、チェックボックスにチェックが入れられるまで cronbot が二時間おきに deploy 対象の commit author に Slack 上で動作確認を促す、というような仕組みまであった。

今後 YAMAP でもどんどん deploy フローを改善していって Merge ボタンを押したあと寿司を食ってれば良いような状態1にしていきたい。


ちなみに上記の chat deploy を実現するためには GitHub App を作っていろいろやる必要があって、その辺は Kaizen Platform で同僚だった t32k さんの以下の記事が参考になった。

チャットデプロイしたい2018 - MOL

書いてあるフローはほとんど Kaizen Platform のやつと同じでちょっとウケた。いやでもそのくらい完成されてる仕組みだと思う。この割とイケてる deploy フローを体験してみたい人は僕が勤めてる YAMAP の Wantedly をご覧下さい。資金調達しており割と積極的に採用中です。


  1. Terraform + GitHub + CircleCI + Atlasを利用してAWSの操作を自動化した - Glide Note http://blog.glidenote.com/blog/2015/02/18/terraform-github-circleci-atlas-aws/ 

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