株式会社メドレーDeveloper Portal

2017-10-24

CircleCI2.0 に移行してビルド実行速度を向上

こんにちは。開発本部の稲本です。医療介護の求人サイト「ジョブメドレー」の開発を担当しているエンジニアです。

最近ジョブメドレーでは CircleCI2.0 への移行を行いました。移行の方法はもちろん、その際に調べたこと、CircleCI の新機能を利用してどうだったかなどを書いていきたいと思います。

課題感

弊社では、全プロダクト(CLINICSMEDLEY介護のほんねジョブメドレー)で CircleCI を利用しています。

ジョブメドレーでは CI によるテスト実行に 37 分前後掛かっていました(コンテナを 2 つ利用した実行時間です)。 さらに、開発メンバーが増えて来たこともあり、CI のリソースが足りなくなり開発効率が落ちかねない状況でした。

まぁよくある話ですよね。

コンテナを増やすというのも解決策の一つとしてはあるのですが、速度の改善に期待が出来ると評判も良かったので CirclecCI2.0 へ移行しました。

CircleCI2.0 への移行メリット

基本的には速度の改善に期待が出来る、というのが大きなメリットではありますが、公式では以下のように記載されています。

20171024100735.png

抜粋ですが大きな特徴としては以下の点でしょうか。

  • First-class Docker Support: Docker のネイティブサポートと Docker レイヤーキャッシュの導入
  • Workflows: ビルド、テスト、デプロイをジョブとして柔軟に管理できるようになった
  • Advanced Caching: キャッシュの保存とリストアをイメージ、ソースコード、依存関係に対して行うことができるようになった。

この辺りの機能を活用し CI の速度改善へ繋げてみたいと思います。

ジョブメドレーのアプリケーション構成

移行の前提として、ジョブメドレーのアプリケーション構成について記載します。

20171024100814.png

フロントエンドのビルドを yarn+webpack で行い、生成したアセットを public/assets へ吐き出し、manifest ファイルのパスを rails の helper 経由で取得し読み込んでいます。(Rails4.2.x)

このような構成のアプリケーションを CirlceCI2.0 でビルド、テスト、デプロイ出来るようにしていきます。

config.yml の全体像

今回、作成した config.yml はこのような形になりました。

ざっくりは

  • build: bundle install, yarn install
  • code_analyze: rubocop, brakeman, scss-lint
  • rspec
  • deploy: capistrano

を行っており、ブランチによってどのジョブを実行するのかを workflows を利用して使い分けています。

詳しい解説は以降、記載していきます。

defaults: &defaults
  working_directory: ~/job-medley
  docker:
    - image: circleci/ruby:2.4.2-node-browsers
      environment:
        TZ: /usr/share/zoneinfo/Asia/Tokyo
    - image: circleci/mysql:x.x.x
      environment:
        TZ: /usr/share/zoneinfo/Asia/Tokyo
    - image: redis:x.x.x
      environment:
        TZ: /usr/share/zoneinfo/Asia/Tokyo
version: 2
jobs:
  build:
    <<: *defaults
    steps:
      - checkout
      - restore_cache:
          key: job-medley-app-{{ checksum "Gemfile.lock" }}
      - run:
          name: bundle install
          command: bundle install --jobs=4 --path=vendor/bundle
      - save_cache:
          key: job-medley-app-{{ checksum "Gemfile.lock" }}
          paths:
            - vendor/bundle
      - restore_cache:
          key: job-medley-yarn-{{ checksum "yarn.lock" }}
      - run:
          name: Yarn install
          command: |
            echo "Node $(node -v)"
            echo "Yarn v$(yarn --version)"
            yarn config set cache-folder ./yarn_cache
            echo "Yarn v$(yarn cache dir)"
            yarn install
      - save_cache:
          key: job-medley-yarn-{{ checksum "yarn.lock" }}
          paths:
            - node_modules
            - yarn_cache
      - persist_to_workspace:
          root: ~/job-medley
          paths:
            - ./*
  code_analyze:
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/job-medley
      - run:
          name: run rubocop
          command: bundle exec rubocop
      - run:
          name: run brakeman
          command: bundle exec brakeman -qz
      - run:
          name: run scss-lint
          command: bundle exec scss-lint
  rspec:
    parallelism: 2
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/job-medley
      - restore_cache:
          key: job-medley-elasticsearch
      # rspec で es のコマンドを一部実行しているため、primary container 側へ install
      - run:
          name: Elasticsearch install
          command: |
            wget https://download.elasticsearch.org/elasticsearch/elasticsearch/elasticsearch-x.x.x.tar.gz && \
              tar -xvf elasticsearch-x.x.x.tar.gz && \
              if [ -z "`elasticsearch-x.x.x/bin/plugin -l | grep analysis-kuromoji`" ]; then \
              elasticsearch-x.x.x/bin/plugin -install elasticsearch/elasticsearch-analysis-kuromoji/x.x.x; fi
      - save_cache:
          key: job-medley-elasticsearch
          paths:
            - ./elasticsearch-x.x.x
      - run:
          name: database create
          command: bundle exec rake db:create
          environment:
            RAILS_ENV: test
      - run:
          name: run test
          command: |
            circleci tests glob 'spec/**/*_spec.*' \
              | circleci tests split --split-by=timings --timings-type=filename \
              | tee -a /dev/stderr \
              | xargs bundle exec rspec \
              --profile 100 \
              --format RspecJunitFormatter \
              --out rspec/rspec.xml \
              --format progress
          environment:
            RAILS_ENV: test
            TEST_CLUSTER_COMMAND: elasticsearch-x.x.x/bin/elasticsearch
      - store_artifacts:
          path: artifacts/
      - store_test_results:
          path: rspec/
  deploy_qa:
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/job-medley
      - run:
          name: run deploy
          command: |
            sh scripts/init_deploy.sh
            BRANCH="${CIRCLE_BRANCH}" bundle exec cap develop deploy deploy:restart
  deploy_only:
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/job-medley
      - run:
          name: run deploy
          command: |
            BRANCH="${CIRCLE_BRANCH}" bundle exec cap production deploy deploy:restart
workflows:
  version: 2
  workflows:
    jobs:
      - build
      - code_analyze:
          requires:
            - build
          filters:
            branches:
              ignore: /^sandbox.*|^master$|^staging$/
      - rspec:
          requires:
            - build
          filters:
            branches:
              ignore: /^sandbox.*|^master$|^staging$/
      - deploy_qa:
          requires:
            - code_analyze
            - rspec
          filters:
            branches:
              only: develop
      - deploy_only:
          requires:
            - build
          filters:
            branches:
              only: /^sandbox.*|^master$|^staging$/

DockerImage の選定

元々、Docker を使わずに CI を回していましたが、CircleCI2.0 へ移行するに辺り Docker への利用に切り替えました。

Specifying Container Images

DockerImage は DockerHub へ登録されているCircleCI公式のもを利用しました。

アプリケーションの一部で React を使用しており、フロントのビルドは yarn+webpack を利用しています。その為、以下の image を選択しました。

  • circleci/ruby:2.4.2-node-browsers
    • node のインストールと、E2E のテストに必要なソフトウェアがインストールされています。
    • ※詳細はこちらの記事を参考にさせていただきました。

その他、現在利用している MySQL のバージョン、ElasticCacheRedis のバージョンと合わせた image を選択しました。

注意点としては、複数の DockerImage を利用する場合、一つ目に指定した image が primary として扱われます。

以下の例ですと、Ruby の image を最初に指定し、MySQL、Redis の image を指定していますが、MySQL コマンド自体は Ruby の image に含まれていないため、Ruby コマンドを実行できても MySQL コマンドを実行することは出来ません。

※詳細はこちらに記載されています。

docker:
  - image: circleci/ruby:2.4.2-node-browsers
    environment:
      TZ: /usr/share/zoneinfo/Asia/Tokyo
  - image: circleci/mysql:x.x.x
    environment:
      TZ: /usr/share/zoneinfo/Asia/Tokyo
  - image: redis:x.x.x
    environment:
      TZ: /usr/share/zoneinfo/Asia/Tokyo

また、用意されている image をカスタマイズする必要がある場合は、Docker でカスタム image を作り、public で良ければDocker Hubへ登録、private が良ければAmazon EC2 Container Registryへ登録しておくことで呼び出すことも可能になっています。

※Using Custom-Built Docker Images https://circleci.com/docs/ja/2.0/custom-images/

※Using Private Images https://circleci.com/docs/ja/2.0/private-images/

build の設定と cache

CI で実行するアプリケーションの build に関してです。

checkoutで github からコードを checkout し、その後の定義でアプリケーションのインストール、キャッシュ保存、キャッシュの展開を行っています。

steps:
  - checkout
  # Rails application setup
  - restore_cache:
      key: job-medley-app-{{ checksum "Gemfile.lock" }}
  - run:
      name: bundle install
      command: bundle install --jobs=4 --path=vendor/bundle
  - save_cache:
      key: job-medley-app-{{ checksum "Gemfile.lock" }}
      paths:
        - vendor/bundle
  • save_cache: key に Gemfile.lock を指定することでキャッシュキーとして扱い、paths に設定したパスをキャッシュするようにしています。
  • restore_cache: key と一致するキャッシュがあれば、save_cache 時に指定したパスを展開し直しています。
  • run: こちらは rails のアプリケーションインストールしているだけです。

CircleCI1.0 よりもキャッシュ管理を柔軟に行えることがわかります。

circleci コマンドによる Rspec の並列実行

rspec によるテストの実行に関してです。 circleci コマンドを利用することでテストの並列実行を効率的に行うことが出来るます。

今回は --split-by=timings --timings-type=filename のオプションを指定し、ファイル名ベースでの分割でテストを実行します。

- run:
    name: run test
    command: |
      circleci tests glob 'spec/**/*_spec.*' \
        | circleci tests split --split-by=timings --timings-type=filename \
        | tee -a /dev/stderr \
        | xargs bundle exec rspec \
        --profile 100 \
        --format RspecJunitFormatter \
        --out rspec/rspec.xml \
        --format progress
    environment:
      RAILS_ENV: test
      TEST_CLUSTER_COMMAND: elasticsearch-x.x.x/bin/elasticsearch
- store_artifacts:
    path: artifacts/
- store_test_results:
    path: rspec/
  • store_artifacts: 以前からもある機能ですが、テスト結果の成果物を保存するパスになります。
  • store_test_results: こちらはテストの実行結果を保存しておくことで、コンテナ間で rspec の実行時間にばらつきが出ないよう、対象のファイルを最適に振り分けてくれるようなのですが、workflows を利用するとサポートされないようです。

※参考: https://circleci.com/docs/ja/2.0/configuration-reference/#store_test_results

このような形で Artifacts が保存されています。

20171024101642.png

また、artifacts には coverage と capybara の screenshot などを保存しています

- simple_cov
  if ENV['CI']
  SimpleCov.coverage_dir File.join(ENV['CIRCLE_WORKING_DIRECTORY'], 'artifacts', 'coverage')
  SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
  SimpleCov::Formatter::HTMLFormatter
  ]
  SimpleCov.start do
  add_filter '/vendor/'
  add_filter '/spec/'
  add_filter '/config/'
  add_filter '/db/'
  end
  end
  • capybara
    • Capybara.save_and_open_page_path = File.join(ENV['CIRCLE_WORKING_DIRECTORY'], 'artifacts', 'capybara') if ENV['CI']

※CircleCI2.0 から CI の環境変数が変わっています。詳細は以下のリンクへ記載されています。

https://circleci.com/docs/ja/2.0/env-vars/#circleci-environment-variable-descriptions

Workflows の設定

CircleCI2.0 から Workflows の利用が可能になりました。

https://circleci.com/docs/ja/2.0/workflows/

コンテナ毎に分割しジョブを実行することで更なる並列実行の効率化、及び、ジョブ間の依存関係まで設定できるようです。 ジョブメドレーではコードの静的解析に少し実行時間が掛かっていることから、多少の改善を図れると考え単純に使ってみたかったこちらの機能を利用してみました。

workflows:
  version: 2
  workflows:
    jobs:
      - build
      - code_analyze:
          requires:
            - build
          filters:
            branches:
              ignore: /^sandbox.*|^master$|^staging$/
      - rspec:
          requires:
            - build
          filters:
            branches:
              ignore: /^sandbox.*|^master$|^staging$/
      - deploy_qa:
          requires:
            - code_analyze
            - rspec
          filters:
            branches:
              only: develop
      - deploy_only:
          requires:
            - build
          filters:
            branches:
              only: /^sandbox.*|^master$|^staging$/
  • build: 各ジョブで実行前に行っておく処理を定義
  • code_analyze: rubocop、brakeman、scss-lint などの静的解析処理を定義
  • rspec: アプリケーションのテストを定義
  • deploy: デプロイに関する処理を定義

ジョブメドレーでは、ブランチ管理に Git-flow を採用していますが、それとは別に sandbox というテスト環境を用意し運用しています。 develop ブランチでコード解析やテストをクリアしたコードだけ、master へ反映し、master ではテストフェーズなしに deploy する構成を取っています。 極力、CI のリソースを節約するように各ブランチごとで実行する処理を分けています。

各ブランチの運用は以下の通りです。

  • feature:
    • コード解析、テスト実行
  • sandbox:
    • デプロイのみ実行(一時レビュー用ブランチ)
  • develop:
    • コード解析、テスト実行、デプロイ実行
  • master:
    • デプロイのみ実行

上記の例では、

  • code_analyze:
    • sandbox、master、staging ブランチ以外は実行
    • requires で依存関係を指定し、build が正常に終了しなければ実行されないようになっています。
  • rspec:
    • sandbox、master、staging ブランチ以外は実行
    • requires で依存関係を指定し、build が正常に終了しなければ実行されないようになっています。
  • deploy_qa:
    • develop ブランチでのみ実行
    • requires で依存関係を指定し、code_analyze、rspec が正常に終了しなければ実行されないようになっています。
  • deploy_only:
    • sandbox、master、staging ブランチのみ利用するジョブ
    • requires で依存関係を指定し、build が正常に終了しなければ実行されないようになっています。

どのような workflow が出来あがるのか、以下に例を示します。

feature ブランチの例:

20171024101757.png

develop ブランチの例:

20171024101818.png

master、sandbox ブランチの例:

20171024101852.png

今回の例だとブランチ毎に workflow を変えているため、ignore、only の書き方で意図せず振り分けされないように考慮は必要ですが、柔軟に workflow を作れることがわかると思います。(やり過ぎると読み解くのが大変になりそうですね)

Workflows に関してはこちらに色々なパターンの組み方が記載されているので、こちらを読むとより理解が深まると思います。

ジョブ間でのデータ共有

ジョブを分けてビルドする=何回もアプリケーションの初期化が必要なんじゃないか? と当然疑問に思う点ではありますが、それに対する解決策も用意されています。

build:
  steps:
    ===省略===
    - persist_to_workspace:
        root: ~/job-medley
        paths:
          - ./*

deploy_qa:
  <<: *defaults
  steps:
    - attach_workspace:
        at: ~/job-medley
  • persist_to_workspace: 指定したパスにあるデータを一時的に保管してくれます
  • attach_workspace: 保管済みのデータを展開してくれます

この機能により、ビルドプロセスで生成したものを各ジョブで実行するコンテナへ渡すことが出来ます。 ただし、そもそもビルドプロセスでキャッシュを入れていることもあり、これ自体の効果は殆どありませんでした。 コンパイル済みのデータを受け渡す際には効果を発揮しそうですね。(公式でもそのような利用を想定していそうです)

改善結果

肝心の速度改善結果です。結果は以下の通りになりました。

改善前: CircleCI1.0

rspec の実行時間: 26:59

20171025162233.png

以下は、CircleCI2.0 へ移行しただけの結果です。このケースでは workflows を利用していません。

改善後: CircleCI2.0

rspec の実行時間: 19:40

20171024102115.png

CircleCI1.0 から CircleCI2.0 へ移行することにより、約 12 分程テストの実行時間を短縮することが出来ました。 Workflows など特に利用していない、かつ、ビルドフェーズの実行時間も関係しないため、CircleCI2.0 を利用するだけで単純にテスト実行速度の向上を見込めることがわかると思います。

続いて Workflows を利用した結果です。

改善後: CircleCI2.0 with Workflows

rspec の実行時間: 21:14

20171024102148.png

結果は、Workflows を利用しないケースと、利用したケースでは、Workflows を利用したほうが rspec の実行時間は長くなってしまいましたが、build-code-analyze-rspec の実行に掛かったトータルの時間に差は見られませんでした。

これは、「circleci コマンドによる Rspec の並列実行」のセクションへも記載した通り、store_test_results がサポートとされないことにより、コンテナ間での分散が最適化されていない為です。

コンテナ間で rspec の実行時間にばらつきが出ないよう、対象のファイルを最適に振り分けてくれるようなのですが、Workflows を利用するとサポートされないようです。

実行時間にばらつきが出てしまい、code_analyze のジョブを分散することで見込んでいた改善時間(約 3 分)とばらつきにより発生したテスト実行時間のロス( (20:01 - 14:57) / 2 = 2:32 )が大体同じであるため、トータルでの実行時間に差が出ない結果となりました。

ばらつきを出さない方法や、ジョブの分け方については今後も工夫してみたいと思います。 また、フロントエンドのテストをもう少し厚くしていきたいと考えているので、フロントエンドのテスト、サーバサイドのテストを Workflows を上手く使いながら分散していければ良いのかなとも思っています。

さいごに

移行に際して、CircleCI2.0 の移行ガイドを読みながら進めていましたが、基本的な記法の変更、timezone、environment の定義方法の変更、variable の変更などが多々有り、ドキュメントを結構読み込まないとどこに何が定義できるのか把握できませんでした。

また、Workflows の組み立てなど公式に良いサンプルは沢山あるのですが、依存関係の定義を色々試すのに苦労した気がします。

※素直に小規模なアプリを用意して、ローカルで circleci を実行してみた方が効率良く進められたかもしれません。

※ただ、公式のドキュメントCommunityForumをしっかり読めば余すことなく情報は合ったので非常に助かりました。

CircleCI2.0 でどのような事が出来るのか、それはどのように行えるのか。 この記事がその概観をつかむ助けになれば良いなと思っています。

参考リンク

CircleCI2.0 へ移行するにあたり、以下の記事を参考にさせていただきました。ありがとうございます。

https://qiita.com/inuscript/items/09d15ee52b1657872f80

https://medium.com/@timakin/circleci2-0%E3%81%AEworkflows%E3%82%92%E8%A9%A6%E3%81%99-1329042122fd

お知らせ

メドレーでは、医療介護の求人サイト「ジョブメドレー」、医師たちがつくるオンライン医療事典「MEDLEY」、口コミで探せる介護施設の検索サイト「介護のほんね」、オンライン診療アプリ「CLINICS」などのプロダクトを提供しています。これらのサービスの拡大を受けて、その成長を支えるエンジニア・デザイナーを募集しています。

https://www.medley.jp/recruit/creative.html

メドレーで一緒に医療体験を変えるプロダクト作りに関わりたい方のご連絡お待ちしております。

株式会社メドレーDeveloper Portal

© Medley Developer Portal