Medley Developer Blog

株式会社メドレーのエンジニア・デザイナーによるブログです

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

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

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

課題感

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

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

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

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

CircleCI2.0への移行メリット

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

f:id:medley_inc:20171024100735p:plain

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

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

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

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

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

f:id:medley_inc:20171024100814p:plain

フロントエンドのビルドを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 circleci.com

※Using Private Images circleci.com

buildの設定とcache

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

checkoutgithubからコードを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/2.0/configuration-reference/#store_test_results

このような形でArtifactsが保存されています。 f:id:medley_inc:20171024101642p:plain

また、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の環境変数が変わっています。詳細は以下のリンクへ記載されています。 circleci.com

Workflowsの設定

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

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

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ブランチの例: f:id:medley_inc:20171024101757p:plain

  • developブランチの例: f:id:medley_inc:20171024101818p:plain

  • master、sandboxブランチの例: f:id:medley_inc:20171024101852p:plain

今回の例だとブランチ毎に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 f:id:medley_inc:20171025162233p:plain

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

改善後: CircleCI2.0

  • rspecの実行時間: 19:40 f:id:medley_inc:20171024102115p:plain

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

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

改善後: CircleCI2.0 with Workflows

  • rspecの実行時間: 21:14 f:id:medley_inc:20171024102148p:plain

結果は、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へ移行するにあたり、以下の記事を参考にさせていただきました。ありがとうございます。 qiita.com medium.com

お知らせ

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

www.medley.jp

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