patorashのブログ

方向性はまだない

CircleCIでdocker-composeを起動してテストを実行

ようやく、CircleCIにてdocker-composeを使ってテストを流して問題なく終了するようになったので、そのためにやったことを書いておきます。 これまでの経緯は過去の記事を参照のこと。

patorash.hatenablog.com

patorash.hatenablog.com

patorash.hatenablog.com

目次

不具合の修正について

できたと思って記事を書いたのですが、実際に運用してみるとちょこちょこ不具合が出てきたので、この記事は随時更新していきます。すみません…。

  • 👍2020-03-13修正済:testジョブでbundle installしろと言われて落ちる
  • 👍2020-03-22修正済:artifactsの保存ができていない

CircleCIでdocker-composeを動かすためにやったこと

開発で使っているdocker-composeと同じものを丸々使ってテストが完走できることを目標としました。こうすることで、開発環境・テスト環境での差異がなくなるので、より安定した開発ができるはずです。

大きな変更点

実行環境をdockerからLinux VMに変更

executorをdockerからmachineに変更しました。machineは、LinuxVMが立ち上がる設定です。

circleci.com

machineを使うことで、Linuxでできることは大概できるようになります。これで、VM内でdocker-composeが起動できます。 executorがdockerの頃は、Dockerイメージの設定をCircleCIの設定ファイルに持たせなければなりませんでしたが、machineにすると全てdocker-compose.ymlで完結するので、そういう点では楽です。

テストの実行をdocker-compose execにて行う

executorがdockerの頃は、.circleci/config.ymlで指定したrubyのコンテナでテストを実行していましたが、machineの場合はdocker-composeのrubyコンテナで行うことになるので、コマンドの先頭にdocker-compose execが付くようになります。

executorをmachineにしてテストすることのメリット・デメリット

先にメリットとデメリットを書いておきます。

メリット

docker-compose.ymlにコンテナ設定が書ける

先ほども書きましたが、CircleCI用にdocker imageを選定しなくてもよくなるのがメリットです。docker-compose.ymlに定義されているimageが丸々使えるからです。

デメリット

デメリットも結構あります。

起動が遅い

executorがdockerの場合は起動が速くて、直ぐに動き始めます(インスタントオン)。が、machineの場合は、若干もたつきます(30~60秒)。CircleCIの公式ドキュメントにそのように書いてあります。

実行時間が長くなった

テストを実行するための準備に時間がかかります。どういう点かというと、docker imageのダウンロードと、docker imageのビルドです。 ビルドに関しては、Docker Layer Cachingが使えるとありますが、1回につき200クレジットと書いてあり、結構高いです…。resource sizeがmedium 1分10クレジットなので20分に相当します。そのため、現在はまだ使っていません。

毎回docker imageをダウンロード・ビルドするのはいかがなものか?と思うので、Docker Layer Cachingを利用せずに、docker saveコマンドでイメージを出力し、それをsave_cacheとrestore_cacheを利用して使いまわしています(これはいいのか?)

これにより速度改善にはなるのですが、docker loadにかかる時間が1分半くらいかかってます。job毎にこれが発生するので、今のところ1.5分*2=3分くらいはこれで遅くなってるかなと思いますが、まぁ仕方ありません。

また、Macでのdockerの開発環境と同じにするため、docker volumesを使っています。volumeにgemやnode_modulesなどのライブラリを入れているため、それらをキャッシュするために一旦docker volumeからエクスポートしてからsave_cacheをしているので、そのあたりも遅くなる原因になります。(これは回避できるので後で解説)

クレジット消費量が増える

実行時間が長くなるということは、クレジット消費量が増えるということです。以前は20分以内に収まっていたテストが30分くらいかかるようになったので、遅くなったうえに1.5倍のクレジットがかかるとなると、結構なデメリットです…。

【2020-03-22 追記】 高速化を頑張った結果、最速で25分くらいで終わるようにはなりました。

環境変数をコンテナに渡すのが大変

executorがdockerの場合はCircleCIの環境変数を意識せずに使えるのですが、machineの場合はCircleCIの環境変数が設定されるのはLinux VMのため、docker-composeで起動するコンテナにdocker-compose execのときにそれらの環境変数を明示的に渡さなければなりません。これがいちいち面倒でした。

CircleCIのワークフロー

config.ymlの説明をする前に、使用しているワークフローを説明します。 以下が、CircleCIのワークフローです。

f:id:patorash:20200306101602p:plain
CircleCIのワークフロー図

job: generate_cache

まず、先にキャッシュを作成します。 やっていることを羅列していくと、以下の通りです。

  1. checkout
  2. docker-composeのインストール
  3. docker imageの更新・キャッシュ
  4. 必要であれば、docker-composeでtest用コンテナ起動
  5. gemの更新・キャッシュ
  6. node_modulesの更新・キャッシュ
  7. データベース定義の更新・キャッシュ
  8. docker-compose down
  9. 次のジョブにワークスペースを渡す

これを並列化せず、1コンテナで行います。たくさんのコンテナで実行しても無駄ですからね。

job: reviewdog

reviewdogはコードチェッカーで、rubocopのルールに違反しているものがあればPull Requestにコメントをしてくれるやつです。 今回は説明はこれくらいで。公開するconfig.ymlからはこの設定を削除しています(余計なので)。 詳しく知りたい人は過去の記事をどうぞ。

patorash.hatenablog.com

job: test

これがテストの本丸ですが、こっちは簡潔で、ただdocker-compose経由でテストを実行するだけです。 こちらは並列化します。(現在はparallelism: 4に設定) やっていることを羅列していくと、以下の通りです。

  1. job generate_cache から、ワークスペースを引き継ぐ
    • generate_cacheから引き継ぐため、checkout不要
    • docker images, gem, node_modulesのキャッシュも引き継ぐのでrestore_cacheが不要
  2. docker-composeのインストール
  3. docker imageのロード
  4. ダウンロードファイル置き場のディレクトリを作成
    • docker-composeを起動する前に作っておかないとエラーになる
  5. docker-composeでtest用コンテナ起動
  6. docker volumeにgemのキャッシュをロードする
  7. docker volumeにnode_modulesのキャッシュをロードする
  8. docker volumeにbootsnapのキャッシュをロードする
  9. docker volumeにPG_DATAをロードする
  10. テストを実行
  11. bootsnapのキャッシュを保存する
  12. テスト結果をsimplecovでまとめる
  13. docker-compose down

.circleci/config.ymlを公開

では、実際に使っているconfig.ymlから、一部を抜粋しました。(reviewdogのワークフローは今回の本質ではないので削除) 弊社では、parallel_testsとknapsack_proを使っていますので、そこはそのままです。そこは適宜、読み替えてください。

knapsack_proに関しては、過去の記事を読んでください。

patorash.hatenablog.com

では、config.ymlです。長いので必要なときだけ読んでください。

【2020-03-13 追記:不具合修正】

キャッシュするときのkeyの指定にバグがあったので修正しました。 下記のはgemのだけ書いてますが、yarnのほうも同様に直しています。

before: bundle-v{{ .Environment.GEM_CACHE_KEY }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}

after: `bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}

不具合の解説

以前の記事では、リストアするときにkeyに以下のように指定をしていました。

  restore_bundle_cache:
    steps:
      - restore_cache:
          key: gemfile-lock-sha256sum-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          keys:
            - bundle-v{{ .Environment.GEM_CACHE_KEY }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
            - bundle-v{{ .Environment.GEM_CACHE_KEY }}-{{ .Branch }}
            - bundle-v{{ .Environment.GEM_CACHE_KEY }}

これだと、ブランチが切り替わるとGemfile.lockのチェックサムで保存してあるキャッシュは無視され、古いgemがリストアされました。その後、bundle installが行われます。 その次のステップで、Gemfile.lockのハッシュ値が変わっていたら、docker volumeからバックアップを取ってから、save_cacheが行われるのですが、Gemfile.lockを更新するプルリクでテストが実行された時点で新しいGemfile.lockのハッシュ値が保存されているため、docker volumeからのバックアップが行われずにsave_cacheしてしまい、generate_cacheが終わった後にtestが実行されると、bundle installしろと怒られてテストが落ちました😥

そのため、save_cacheとrestore_cacheのkeyを修正して、Gemfile.lockのチェックサムを基準に見させるようにしました。

  restore_bundle_cache:
    steps:
      - restore_cache:
          key: gemfile-lock-sha256sum-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          keys:
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}
            - bundle-{{ arch }}

yarnのほうも同様の理由で修正しています。

【2020-03-22 追記:不具合修正と高速化】

artifactsの保存に失敗していたのを修正しました。CIRCLECI_TEST_REPORTSを、/tmp/test-resultsにしていたのですが、docker-composeでテストを行うと、dockerコンテナの/tmp/test-resultsに保存されてしまい、ホストOSで取得できない状態でした。そこで、共有ディレクトリであるRails.root/tmp/test-resultsに保存するように修正しました。

また、この修正をしている最中にも新たに高速化できそうなポイントが書かれていた記事を見つけたため、それを参考にさらに高速化しました。参照ページをリンクしておきます。

engineering.later.com

では、設定ファイルです。

version: 2.1

executors:
  default:
    machine: true
    working_directory: ~/project
    environment:
      RAILS_ENV: test
      RACK_ENV: test
      TZ: "/usr/share/zoneinfo/Asia/Tokyo"
      CIRCLE_TEST_REPORTS: ./tmp/test-results
      PARALLEL_TESTS_CONCURRENCY: 4
      REVIEWDOG_VERSION: v0.9.17

commands:
  install_docker_compose:
    steps:
      - run:
          name: Install Docker Compose
          command: |
            curl -L https://github.com/docker/compose/releases/download/1.25.4/docker-compose-`uname -s`-`uname -m` > ~/docker-compose
            chmod +x ~/docker-compose
            sudo mv ~/docker-compose /usr/local/bin/docker-compose

  save_docker_images:
    steps:
      - run:
          name: Check cache file, and create docker images cache
          command: |
            if [ ! -e ~/caches/images.tar ]; then
              docker-compose pull postgres redis elasticsearch memcached chrome
              docker-compose build test
              mkdir -p ~/caches
              docker save $(docker images | awk 'NR>=2 && ! /^<none>/{print $1}') -o ~/caches/images.tar
            fi
      - save_cache:
          key: docker-{{ checksum ".dockerignore" }}-{{ checksum "docker-compose.yml" }}-{{ checksum ".dockerdev/Dockerfile" }}-{{ checksum ".dockerdev/entrypoint.sh" }}
          paths: ~/caches/images.tar

  load_docker_images_and_update_libraries_if_necessary:
    steps:
      - run:
          name: Load Docker images and update libraries if necessary
          command: |
            set +e

            yarn_cache_shasum=`[ -r yarn.lock.sha256sum ] && cat yarn.lock.sha256sum`
            yarn_file_shasum=`shasum -a 256 yarn.lock`

            gem_cache_shasum=`[ -r Gemfile.lock.sha256sum ] && cat Gemfile.lock.sha256sum`
            gem_file_shasum=`shasum -a 256 Gemfile.lock`

            # ライブラリの更新が必要な場合、docker-composeを起動してbundle install, yarn installを行う
              if [ "${yarn_cache_shasum}" != "${yarn_file_shasum}" ] || [ "${gem_cache_shasum}" != "${gem_file_shasum}" ] || [ ! -e ~/caches/pg_data.tar ]; then
              docker load -i ~/caches/images.tar
              docker-compose up -d test

              # Load libraries
              if [ -e ~/caches/bundle.tar ]; then
                docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/bundle.tar
              fi

              if [ -e ~/caches/node_modules.tar ]; then
                docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/node_modules.tar
              fi

              # bundle install
              if [ "${gem_cache_shasum}" != "${gem_file_shasum}" ]; then
                docker-compose exec test sh -c "bundle check || bundle install --clean"
              fi

              # yarn install
              if [ "${yarn_cache_shasum}" != "${yarn_file_shasum}" ]; then
                docker-compose exec test yarn install
              fi

              # Database Setup & Backup $PGDATA
              if [ ! -e ~/caches/pg_data.tar ]; then
                docker-compose exec \
                  -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
                  test \
                  bin/rails db:migrate:reset
                docker-compose exec \
                  -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
                  -e PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY \
                  test \
                  bin/rails parallel:load_structure[$PARALLEL_TESTS_CONCURRENCY]
                docker-compose stop postgres

                # postgresのコンテナから$PGDATAをコールドバックアップする
                pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
                docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar cvf /backup/pg_data.tar -C / var/lib/postgresql/data
                docker kill $pg_container_name
                docker-compose start postgres
              fi
            fi

  load_docker_images:
    steps:
      - run:
          name: Load Docker images
          command: docker load -i ~/caches/images.tar

  restore_docker_images:
    steps:
      - restore_cache:
          key: docker-{{ checksum ".dockerignore" }}-{{ checksum "docker-compose.yml" }}-{{ checksum ".dockerdev/Dockerfile" }}-{{ checksum ".dockerdev/entrypoint.sh" }}
          paths: ~/caches/images.tar

  docker_compose_up:
    steps:
      - run:
          name: docker-compose up
          command: |
            docker-compose up -d test

  save_node_modules_cache:
    steps:
      - run:
          name: Create node_modules cache
          command: |
            set +e
            cache_shasum=`[ -r yarn.lock.sha256sum ] && cat yarn.lock.sha256sum`
            file_shasum=`shasum -a 256 yarn.lock`
            if [ "${cache_shasum}" != "${file_shasum}" ]; then
              shasum -a 256 yarn.lock > yarn.lock.sha256sum
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/node_modules.tar -C / app/node_modules
            fi

      - save_cache:
          name: Saving yarn.lock Hash Value
          key: yarn-lock-sha256sum-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
          paths:
            - yarn.lock.sha256sum
      - save_cache:
          name: Saving node_modules Cache
          key: yarn-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
          paths:
            - ~/caches/node_modules.tar

  load_node_modules_cache:
    steps:
      - run:
          name: Load node_modules caches
          command: |
            docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/node_modules.tar

  restore_node_modules_cache:
    steps:
      - restore_cache:
          key: yarn-lock-sha256sum-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
      - restore_cache:
          keys:
            - yarn-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
            - yarn-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}
            - yarn-{{ arch }}

  save_bundle_cache:
    steps:
      - run:
          name: Create bundle cache
          command: |
            set +e
            cache_shasum=`[ -r Gemfile.lock.sha256sum ] && cat Gemfile.lock.sha256sum`
            file_shasum=`shasum -a 256 Gemfile.lock`
            if [ "${cache_shasum}" != "${file_shasum}" ]; then
              shasum -a 256 Gemfile.lock > Gemfile.lock.sha256sum
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/bundle.tar -C / bundle
            fi
      - save_cache:
          name: Saving Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - Gemfile.lock.sha256sum
      - save_cache:
          name: Saving Gem Cache
          key: bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - ~/caches/bundle.tar

  load_bundle_cache:
    steps:
      - run:
          name: Load bundle caches
          command: |
            docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/bundle.tar

  restore_bundle_cache:
    steps:
      - run:
          name: Get the Gemfile.lock from the develop branch
          command: |
            git show develop:Gemfile.lock > DevelopGemfile.lock
      - restore_cache:
          name: Restore Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          name: Restore Gem Cache
          keys:
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "DevelopGemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}
            - bundle-{{ arch }}

  docker_compose_down:
    steps:
      - run:
          name: docker-compose down
          command: docker-compose down
          when: always

  sync:
    steps:
      - run: sync

  restore_bootsnap_cache:
    steps:
      - restore_cache:
          name: Restore bootsnap cache
          keys:
            - bootsnap-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}-{{ .Branch }}
            - bootsnap-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}-develop

  save_bootsnap_cache:
    steps:
      - run:
          name: Create bootsnap cache
          command: |
            docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/bootsnap.tar -C / app/tmp/cache
          when: always

      - save_cache:
          name: Saving bootsnap cache
          key: bootsnap-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}-{{ .Branch }}-{{ epoch }}
          paths:
            - ~/caches/bootsnap.tar
          when: always

  load_bootsnap_cache:
    steps:
      - run:
          name: Load bootsnap caches
          command: |
            if [ -e ~/caches/bootsnap.tar ]; then
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/bootsnap.tar
            fi

  restore_pg_data_cache:
    steps:
      - run: cp config/{database_circleci,database}.yml
      - run:
          name: Database Checksum
          command: |
            find db -type f -exec md5sum {} \; | sort -k 2 > db_dir_checksums.txt
            md5sum config/database.yml >> db_dir_checksums.txt
            echo $PARALLEL_TESTS_CONCURRENCY >> db_dir_checksums.txt
            md5sum docker-compose.yml >> db_dir_checksums.txt
      - run:
          name: cat db_dir_checksums.txt
          command: |
            cat db_dir_checksums.txt
      - restore_cache:
          name: Restore PG_DATA
          key: pgdata-v2-{{ arch }}-{{ checksum "db_dir_checksums.txt" }}

  save_pg_data_cache:
    steps:
      - save_cache:
          name: Saving PG_DATA
          key: pgdata-v2-{{ arch }}-{{ checksum "db_dir_checksums.txt" }}
          paths:
            - ~/caches/pg_data.tar
          when: always

  load_pg_data_cache:
    steps:
      - run:
          name: Load PGDATA cache
          command: |
            docker-compose stop postgres
            pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
            docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar xvf /backup/pg_data.tar
            docker kill $pg_container_name
            docker-compose start postgres

jobs:
  generate_cache:
    executor: default
    steps:
      - checkout
      - install_docker_compose
      - restore_docker_images
      - save_docker_images
      - restore_bundle_cache
      - restore_node_modules_cache
      - restore_bootsnap_cache
      - restore_pg_data_cache
      - load_docker_images_and_update_libraries_if_necessary
      - save_bundle_cache
      - save_node_modules_cache
      - save_pg_data_cache
      - docker_compose_down
      - persist_to_workspace:
          root: ../
          paths:
            - project
            - caches

  test:
    executor: default
    parallelism: 4
    steps:
      - attach_workspace:
          at: ~/
      - install_docker_compose
      - load_docker_images
      - run:
          name: Make download directory
          command: |
            mkdir -p tmp/download
            chmod 777 tmp/download
      - docker_compose_up
      - load_bundle_cache
      - load_node_modules_cache
      - load_bootsnap_cache
      - load_pg_data_cache
      - sync
      - run:
          name: Run rspec in parallel
          command: |
            docker-compose exec \
              -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
              -e CI=$CI \
              -e CIRCLE_TEST_REPORTS=$CIRCLE_TEST_REPORTS \
              -e KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC=$KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC \
              -e KNAPSACK_PRO_CI_NODE_TOTAL=$CIRCLE_NODE_TOTAL \
              -e KNAPSACK_PRO_CI_NODE_INDEX=$CIRCLE_NODE_INDEX \
              -e KNAPSACK_PRO_BRANCH=$CIRCLE_BRANCH \
              -e KNAPSACK_PRO_COMMIT_HASH=$CIRCLE_SHA1 \
              -e KNAPSACK_PRO_LOG_LEVEL=warn \
              -e PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY \
              test \
              bin/parallel_test -n $PARALLEL_TESTS_CONCURRENCY -e './bin/parallel_tests'
      - save_bootsnap_cache
      - deploy:
          command: |
            # テスト実行後なのでSimpleCovの結果をまとめる
            docker-compose exec \
              -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
              -e CI=$CI \
              -e CIRCLE_PROJECT_USERNAME=$CIRCLE_PROJECT_USERNAME \
              -e CIRCLE_PROJECT_REPONAME=$CIRCLE_PROJECT_REPONAME \
              -e CIRCLE_BUILD_NUM=$CIRCLE_BUILD_NUM \
              -e CIRCLE_TOKEN=$CIRCLE_TOKEN \
              -e CIRCLE_TEST_REPORTS=$CIRCLE_TEST_REPORTS \
              -e CIRCLE_WORKING_DIRECTORY=$CIRCLE_WORKING_DIRECTORY \
              test \
              bundle exec ruby circleci_simplecov_parallel.rb
      - docker_compose_down

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

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

workflows:
  version: 2
  build:
    jobs:
      - generate_cache
      - test:
          requires:
            - generate_cache

工夫した点

ライブラリのキャッシュの必要性をハッシュ値を保存しておく

executorがdockerの場合は、単純にsave_cache, restore_cacheしておけば、同じcacheのkeyがあるときはスキップすればいいだけです。

しかし、docker-composeでvolumeを使っていてそこにライブラリを入れている場合、ホストOSから直接参照できないので、volumeのバックアップを作成してホストOSから参照できるところに置かなくてはなりません。このプロジェクトの場合は、gem置き場のバックアップに2分かかっていました。

2分かかって出力してから、「あ、このcacheのkeyは既にあるからスキップね」となると、この2分が丸々無駄です!😡とはいえ、更新があった場合はsave_cacheのタイミングで新しいファイルが置いてないと困ります。

そこで、こちらでもGemfile.lockとyarn.lockのsha256のハッシュ値をファイルに保存するようにして、現在のGemfile.lock, yarn.lockと比較させて、違っていたらdocker volumeをバックアップするようにしました。

config.ymlからそこだけ抜粋します(Gemfile.lockのみ)

commands:
  # 略
  save_bundle_cache:
    steps:
      - run:
          name: Create bundle cache
          command: |
            set +e
            cache_shasum=`[ -r Gemfile.lock.sha256sum ] && cat Gemfile.lock.sha256sum`
            file_shasum=`shasum -a 256 Gemfile.lock`
            if [ "${cache_shasum}" != "${file_shasum}" ]; then
              shasum -a 256 Gemfile.lock > Gemfile.lock.sha256sum
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/bundle.tar -C / bundle
            fi
      - save_cache:
          name: Saving Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - Gemfile.lock.sha256sum
      - save_cache:
          name: Saving Gem Cache
          key: bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - ~/caches/bundle.tar

これで、2分短縮できました🥳(ライブラリの更新がない場合に限る)

dockerに環境変数を渡す

ついうっかり忘れがちでしたので、気をつけましょう。CircleCIのコンテナには、環境変数が設定されていますが、コンテナが起動するdockerのコンテナにはCircleCIが自動で設定してくれる環境変数は反映されていません!必要な環境変数は適宜、渡します!

- run:
    name: Run rspec in parallel
    command: |
      mkdir /tmp/test-results
      docker-compose exec \
        -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
        -e CI=$CI \
        -e CIRCLE_TEST_REPORTS=$CIRCLE_TEST_REPORTS \
        -e KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC=$KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC \
        -e KNAPSACK_PRO_CI_NODE_TOTAL=$CIRCLE_NODE_TOTAL \
        -e KNAPSACK_PRO_CI_NODE_INDEX=$CIRCLE_NODE_INDEX \
        -e KNAPSACK_PRO_BRANCH=$CIRCLE_BRANCH \
        -e KNAPSACK_PRO_COMMIT_HASH=$CIRCLE_SHA1 \
        -e KNAPSACK_PRO_LOG_LEVEL=warn \
        -e PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY \
        test \
        bin/parallel_test -n $PARALLEL_TESTS_CONCURRENCY -e './bin/parallel_tests'

これで、テストもちゃんと通るはず…!!👍

2020-03-22 追記分

追加でやったことを一覧にしておきます。

  1. PARALLEL_TESTS_CONCURRENCYの指定を4に変更した
  2. gemのキャッシュを参照にする際に、基本ブランチを2番目に参照にするよう修正した
  3. 基本ブランチはキャッシュがヒットする可能性が高いから
  4. bootsnapのキャッシュを保存するように修正した
  5. PG_DATAのコールドバックアップを再利用するよう修正した

1つずつ説明していきます。

PARALLEL_TESTS_CONCURRENCYを2から4に変更

これは、先ほど紹介した記事に、machine sizeがmediumなら並列化は4が最適と書かれていたから、実際にしてみたところ、2の時よりも短くなったためです(30秒~1分程度)。mediumだとCPU数がそもそも2なので、劇的な変化はありませんが、恐らくI/O待ちが発生している間に実行できるテスト数が増えることで多少速くなるのかな?と思います(あくまで推測ですが…)

ただし、4並列にすると、Chromeがメモリ不足でエラーを起こすようになるので、以下の設定が必須です。

patorash.hatenablog.com

基本ブランチを2番目に参照するよう修正

これも先ほど紹介された記事にありました。確かに現在のGemfile.lockの値を参照するのが一番で、二番目には基本ブランチのGemfile.lockを参照したほうがキャッシュ効率がよさそうでした。

うちではdevelopブランチが基本ブランチなので、そこからgit showコマンドを使ってDevelopGemfile.lockを作り、restore_cacheする際に使うように修正しました。

commands:
  # 略
  restore_bundle_cache:
    steps:
      - run:
          name: Get the Gemfile.lock from the develop branch
          command: |
            git show develop:Gemfile.lock > DevelopGemfile.lock
      - restore_cache:
          name: Restore Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          name: Restore Gem Cache
          keys:
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "DevelopGemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}
            - bundle-{{ arch }}
bootsnapのキャッシュを保存するように修正

bootsnapはRailsの起動するまでの処理をキャッシュすることで高速化するためのgemで、Rails5.2から標準になっています。これがない場合はRailsを起動するためのキャッシュ生成で結構時間がとられるので、これを保存して再利用すると、Railsが直ぐ起動できて速くなります。なるはずです。しかし、そこまで効果が高いかと言われるとうちのプロジェクトでは微妙でした…。bootsnapのキャッシュの保存にも時間がかかるので、効果があるプロジェクトだったらやるべきかなとは思います。

PG_DATAのコールドバックアップを再利用するよう修正

これは先ほど紹介した記事から着想を覚えて行った、オリジナルの高速化です。Executorがdockerであれば、先ほどの記事で紹介された手法がいいでしょう。 軽く説明しておくと、parallel_testを使うとテストが並列で行えるので速くなる一方、その分データベースを作らなければならず、そこに結構時間を取られます。各コンテナでDB作成を行わせていたのですが、4つのデータベースを作るのに大体40秒~1分くらいかかっていました。 これを、先ほどの記事では、データベースに関わる変更があるかどうかわかるものと、pg_dumpall.sqlというファイルをキャッシュしておいて、変更がなければpg_dumpall.sqlからデータベースを作るというものでした。この方法だと、わざわざRailsを介さずにデータベースを作成することができるため、高速化が期待できます。

しかし、docker-composeならば、更にPGDATA(PostgreSQLのデータが保存されている場所)を丸々コールドバックアップしてロードすれば、sqlを通してデータベースを作成する手間も省けるのでは?🤔と考えました。最終的にはそれは思った通りで成功し、40秒~1分かかっていた処理が6~10秒になったので効果ありましたが、そこにたどり着くまでに結構ハマりました。

まず、コールドバックアップを取らなければならないので、PostgreSQLを停止しなければなりません。そこで、docker-compose exec postgres /bin/bashでpostgresのコンテナの中に入り、pg_ctl stopを実行したところ、コンテナごと停止しました。まぁ確かに、コンテナが実行していたプロセスを止めたので、そうなるか…と思いましたが、じゃあどうすればコールドバックアップを取れるのか?

結論としては、docker-compose run -d postgres /bin/bashで、一時的にpostgresのコンテナを立ち上げるのですが、PostgreSQLを立ち上げずにbashを立ち上げさせておきます。その際に、一時的なコンテナ名を取得します。 そして、そのコンテナに対してバックアップを行わせました。

# postgresのコンテナから$PGDATAをコールドバックアップする
pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar cvf /backup/pg_data.tar -C / var/lib/postgresql/data
docker kill $pg_container_name

あとは、~/caches/pg_data.tarを各コンテナでロードします。ロードするときも、postgresコンテナは停止させて、runで一時的なコンテナを立ち上げています。終わったら即docker killしています。

docker-compose stop postgres
pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar xvf /backup/pg_data.tar
docker kill $pg_container_name
docker-compose start postgres

データベースのセットアップは結構時間のかかる処理ですが、これでDBに変更がない限りは数秒で終わるようになりました👍

感想

ここにたどり着くまでに3週間くらいかかったと思うので、かなり大変でしたが、なんとかなりました。 開発環境とCI環境が同じコンテナを使うので、安心感はありますね。

ただ、テストがやや遅くなるのが…😢これからも高速化できそうなポイントがあったら模索していきます😀

2020-03-22 追記

ここまでの修正で、キャッシュがフル稼働するときの時間ではありますが、30~33分→25~28分くらいにはなりました。

  • 極力docker loadをしない
  • 最もパフォーマンスのよい並列数にする(parallel_testにて)
  • コンパイルキャッシュを再利用する
  • ライブラリの更新がなければ何もしない
  • データベースの更新がなければ何もしない

という方向性で、30分が25分になったので、約16%の高速化です。

多分これ以上となると、テスト自体のリファクタリングをするか、parallelismを上げるしかないと思います!