patorashのブログ

方向性はまだない

CircleCIのworkflowを使ってコードのチェックアウト、ライブラリのインストールを共通化した

2019-01-25 追記:CircleCI 2.1を使うとデフォルトで共通化ができるようになっています。 patorash.hatenablog.com

以下より、投稿時の原文です。

私が担当している製品では、RailsのテストをCircleCIで4並列で動かしているのだけれど、これがいちいち各コンテナ毎にコードのチェックアウト、ライブラリのインストールをしているのは無駄じゃないかなー?と常々思っていました。これが、workflowを使ったら解決できそうだとわかったので、やってみることにしました。

目標を定義する

作業前は、以下のようになっていました。

  1. コードのチェックアウト・・・各コンテナ毎に実行
  2. bundle install・・・各コンテナ毎に実行
  3. yarn install・・・各コンテナ毎に実行
  4. rspec・・・各コンテナ毎に実行

作業後は、このようにしたい。

  1. コードのチェックアウト・・・1コンテナで実行
  2. ライブラリのインストール・・・並列実行
    1. bundle install・・・1コンテナで実行
    2. yarn install・・・1コンテナで実行
  3. rspec・・・各コンテナで実行

これならば、ライブラリのインストールを並列で行えるので、速度改善が見込めるんじゃないか?と思いました。

config.ymlを修正する

まずは、定義も何もないところから、workflowだけを書いていきます。

workflows:
  version: 2
  build:
    jobs:
      - checkout_code
      - bundle_dependencies:
          requires:
            - checkout_code
      - yarn_dependencies:
          requires:
            - checkout_code
      - test:
          requires:
            - bundle_dependencies
            - yarn_dependencies

まぁ、こんな感じでしょう。requiresを定義して、ライブラリのインストールが両方終わってからテストが実行されるようにしています。

その後は、以下のconfig.ymlを参考にして作ってみました。

github.com

順に書いてみます。

コードのチェックアウト

コードをチェックアウトして、キャッシュに保存しておきます。

jobs:
  checkout_code:
    docker:
      - image: circleci/ruby:2.5.1-node-browsers
    working_directory: ~/project
    steps:
      - checkout
      - save_cache:
          key: v1-repo-{{ .Environment.CIRCLE_SHA1 }}
          paths:
            - ~/project
  # 省略

ライブラリのインストール

bundle_dependencies

先ほどキャッシュしたコードと、過去のgemのキャッシュを戻した後、bundle installを実行し、またキャッシュに保存しておきます。

jobs:
  # 省略
  bundle_dependencies:
    docker:
      - image: circleci/ruby:2.5.1-node-browsers
    working_directory: ~/project
    steps:
      - restore_cache:
          keys:
            - v1-repo-{{ .Environment.CIRCLE_SHA1 }}
      - restore_cache:
          keys:
            - v1-bundle-{{ checksum "Gemfile.lock" }}
            - v1-bundle
      - run: bundle check --path=vendor/bundle || bundle install --path vendor/bundle --jobs 4 --retry 3
      - save_cache:
          key: v1-bundle-{{ checksum "Gemfile.lock" }}
          paths:
            - ./vendor/bundle
  # 省略
yarn_dependencies

こちらもbundle_dependenciesと同様に。yarnを行ってJSライブラリを入れて、またキャッシュに保存しています。

jobs:
  # 省略
  yarn_dependencies:
    docker:
      - image: circleci/ruby:2.5.1-node-browsers
    working_directory: ~/project
    steps:
      - restore_cache:
          keys:
            - v1-repo-{{ .Environment.CIRCLE_SHA1 }}
      - restore_cache:
          keys:
            - v1-yarn-{{ checksum "yarn.lock" }}
            - v1-yarn
      - run:
          name: Install dependencies
          command: yarn
      - save_cache:
          key: v1-yarn-{{ checksum "yarn.lock" }}
          paths:
            - ./node_modules
  # 省略

test

テストは、並列実行したいので、parallelismを指定しています。また、テストにDBが必要なので、DBのdocker imageの指定も行っています。

やっていることは以下の通り。

  1. 各コンテナで、ここまでのworkflowで行ったキャッシュを取得する
  2. DBのセットアップ
  3. RSpecの実行
  4. 結果の収集
jobs:
  # 省略
  test:
    docker:
      - image: circleci/ruby:2.5.1-node-browsers
         environment:
           RAILS_ENV: test
           TZ: "/usr/share/zoneinfo/Asia/Tokyo"
      - image: circleci/postgres:9.6-alpine-postgis
    working_directory: ~/project
    parallelism: 4
    steps:
      - restore_cache:
          keys:
            - v1-repo-{{ .Environment.CIRCLE_SHA1 }}
      - restore_cache:
          keys:
            - v1-bundle-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          keys:
            - v1-yarn-{{ checksum "yarn.lock" }}
            - v1-yarn
      - run: bundle --path vendor/bundle

      # Database setup
      - run: bundle exec rake db:create
      - run: bundle exec rake db:schema:load

      # run tests!
      - run:
          name: run tests
          command: |
            mkdir /tmp/test-results
            TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)"

            bundle exec rspec --format progress \
                            --format RspecJunitFormatter \
                            --out /tmp/test-results/rspec.xml \
                            --format progress \
                            $TEST_FILES

      # Save artifacts
      - store_test_results:
          path: /tmp/test-results

      - store_artifacts:
          path: /tmp/test-results
          destination: test-results
  # 省略

これでいいはずですが、./circleci/config.ymlの構造が間違ってないか、確認しておいた方がいいでしょう。

circleci config validate -c .circleci/config.yml

コマンドがない場合は、CircleCI local cliを入れておくこと。

circleci.com

CircleCIを実行する

workflowが正しく動いているか確認してみます。

f:id:patorash:20180624114543p:plain

おおー、動きました!!見た目は理想通りです。

config.ymlのリファクタリング

このままだと、重複コードがたくさんあるので、リファクタリングを行います。 こちらのブログを参考にさせてもらいました。

medium.com

共通部分を以下のように括り出しました。

references:
  defaults: &defaults
    working_directory: ~/project

  ruby_docker_image: &ruby_docker_image
    image: circleci/ruby:2.5.1-node-browsers
    environment:
      RAILS_ENV: test
      TZ: "/usr/share/zoneinfo/Asia/Tokyo"

  restore_code_cache: &restore_code_cache
    restore_cache:
      keys:
        - v1-repo-{{ .Environment.CIRCLE_SHA1 }}

  restore_gem_cache: &restore_gem_cache
    restore_cache:
      keys:
        - v1-bundle-{{ checksum "Gemfile.lock" }}
        - v1-bundle

  restore_node_module_cache: &restore_node_module_cache
    restore_cache:
      keys:
        - v1-yarn-{{ checksum "yarn.lock" }}
        - v1-yarn

これを適用していきます。

適用した完成品がこちら。

version: 2

references:
  defaults: &defaults
    working_directory: ~/project

  ruby_docker_image: &ruby_docker_image
    image: circleci/ruby:2.5.1-node-browsers
    environment:
      RAILS_ENV: test
      TZ: "/usr/share/zoneinfo/Asia/Tokyo"

  restore_code_cache: &restore_code_cache
    restore_cache:
      keys:
        - v1-repo-{{ .Environment.CIRCLE_SHA1 }}

  restore_gem_cache: &restore_gem_cache
    restore_cache:
      keys:
        - v1-bundle-{{ checksum "Gemfile.lock" }}
        - v1-bundle

  restore_node_module_cache: &restore_node_module_cache
    restore_cache:
      keys:
        - v1-yarn-{{ checksum "yarn.lock" }}
        - v1-yarn

jobs:
  checkout_code:
    <<: *defaults
    docker:
      - *ruby_docker_image
    steps:
      - checkout
      - save_cache:
          key: v1-repo-{{ .Environment.CIRCLE_SHA1 }}
          paths:
            - ~/project

  bundle_dependencies:
    <<: *defaults
    docker:
      - *ruby_docker_image
    steps:
      - *restore_code_cache
      - *restore_gem_cache
      - run: bundle check --path=vendor/bundle || bundle install --path vendor/bundle --jobs 4 --retry 3
      - save_cache:
          key: v1-bundle-{{ checksum "Gemfile.lock" }}
          paths:
            - ./vendor/bundle

  yarn_dependencies:
    <<: *defaults
    docker:
      - *ruby_docker_image
    parallelism: 1
    steps:
      - *restore_code_cache
      - *restore_node_module_cache
      - run:
          name: Install dependencies
          command: yarn
      - save_cache:
          key: v1-yarn-{{ checksum "yarn.lock" }}
          paths:
            - ./node_modules

  test:
    <<: *defaults
    docker:
      - *ruby_docker_image
      - image: circleci/postgres:9.6-alpine-postgis
    parallelism: 4
    steps:
      - *restore_code_cache
      - *restore_gem_cache
      - *restore_node_module_cache
      - run: bundle --path vendor/bundle

      # Database setup
      - run: bundle exec rake db:create
      - run: bundle exec rake db:schema:load

      # run tests!
      - run:
          name: run tests
          command: |
            mkdir /tmp/test-results
            TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)"

            bundle exec rspec --format progress \
                            --format RspecJunitFormatter \
                            --out /tmp/test-results/rspec.xml \
                            --format progress \
                            $TEST_FILES

      # Save artifacts
      - store_test_results:
          path: /tmp/test-results

      - store_artifacts:
          path: /tmp/test-results
          destination: test-results

workflows:
  version: 2
  build:
    jobs:
      - checkout_code
      - bundle_dependencies:
          requires:
            - checkout_code
      - yarn_dependencies:
          requires:
            - checkout_code
      - test:
          requires:
            - bundle_dependencies
            - yarn_dependencies

だいぶすっきりとしたコードになりました。

テストは速くなったのか?

重複は排除できたのですが、肝心のテストの速度は速くなるどころか、少々遅くなっています…😢

原因は明白で、jobを細かく分けたため、docker imageのロードに時間がかかっているためです(各ワークフロー毎にだいたい30秒)。元々30秒かかっていたところが、3段階に分かれたので、30*3=90秒かかるようになりました。しかしこれも運次第で、テストが実行されたホストにdocker imageのキャッシュがあれば1〜2秒で終わるのですが、ないと30秒かかるという状態です。完全な条件下では、ライブラリのインストールが並列になるので、多少は速くなるのでは?🤔と思います。

とはいえ、好みとしてはこちらのコードなので、これでしばらく運用してみようかなと考えています。