CircleCIのジョブのスキップを使った依存関係のキャッシュ活用の効率化

CircleCI のチューニングを行う際、依存パッケージのインストール周りのジョブ(あるいは step)がそこそこ時間がかかるケースはよくある。 特にフロントエンドプロジェクトにおいては HEAVIEST OBJEXCTS IN THE UNIVERSE と揶揄されるように、依存パッケージ自体の数が膨大になりがちで、それに比例してインストール時間も伸びる。

circleci 公式 Doc にもあるように、この対策として依存管理ツールの作る *lock ファイル(あるいは go.sumなど)のチェックサムを key にして、 依存パッケージのキャッシュを CircleCI のキャッシュ機能 (save_cache / restore_cache) を用いて保存/復元することで、依存インストールプロセスの実行時間の改善が期待できることがある。1

下記はキャッシュ活用した依存インストールの一例:

.circleci/config.yml
executors:
  node:
    docker:
      - image: circleci/node:14
    working_directory: /tmp/app
commands:
  install_deps:
    steps:
      - restore_cache:
          keys:
            - &cache-key-yarn yarn-{{ .Environment.YARN_CACHE_KEY }}-{{ arch }}-{{ checksum "/tmp/app/yarn.lock" }}
            - yarn-{{ .Environment.YARN_CACHE_KEY }}-{{ arch }}
      - run: yarn --frozen-lockfile --silent --cache-folder ~.cache/yarn
      - save_cache:
          key: *cache-key-yarn
          paths:
            - ~/.cache/yarn
  restore_deps:
    steps:
      - restore_cache:
          keys:
            - *cache-key-yarn
      - run: yarn --frozen-lockfile --silent --cache-folder ~.cache/yarn

jobs:
  setup:
    executor: node
    steps:
      - checkout
      - install_deps
  lint:
    executor: node
    steps:
      - checkout
      - restore_deps
      - run: yarn lint
  unit_test:
    executor: node
    steps:
      - checkout
      - restore_deps
      - run: yarn e2e-test
  e2e_test:
    executor: node
    steps:
      - checkout
      - restore_deps
      - run: yarn unit-test

workflows:
  version: 2
  build:
    jobs:
      - setup:
          filters:
            branches:
              only: /.*/
      - lint:
          requires:
            - setup
          filters:
            branches:
              only: /.*/
      - unit_test:
          requires:
            - setup
          filters:
            branches:
              only: /.*/
      - e2e_test:
          requires:
            - setup
          filters:
            branches:
              only: /.*/

上記例では、最初に setup というジョブが実行され、その完了を待って lint unit_test e2e_test という後続ジョブが並列実行される。 各ジョブでそれぞれ checkout して、依存関係を下記のように扱い:

  1. restore_cache でキャッシュ復元
  2. yarn で依存パッケージのインストール
  3. save_cache でキャッシュ保存(setup ジョブでのみ)

その後、それぞれのジョブ名の示す内容を実行する、という流れになっている。

*lock が完全一致していたときの無駄な step 実行

このとき一つ問題があって、 *lock ファイルのチェックサムが完全一致してキャッシュが hit した場合、yarn によって作られるパッケージキャッシュは restore_cache で復元したものと一致して更新されず、その後の save_cache も全く同じファイルが保存される(*lock ファイルの存在意義からしてそのはずである)。

yarn (パッケージのインストール) 、save_cache はどちらも、たとえ差分がなかったとしてもファイル数が多いとそれなりに時間がかかるので、不要なら省略したい。

ならば *lock ファイルが完全に一致する場合のみ yarnsave_cache の工程をスキップしちゃえばいいというのが本題。

.circleci/config.yml
commands:
  install_deps:
    steps:
+     - restore_cache:
+         key: &find-cache-key-yarn yarn.lock.cachekey-{{ .Environment.YARN_CACHE_KEY }}-{{ arch }}-{{ checksum "/tmp/app/yarn.lock" }}
+     - run:
+         name: if cache exists exit
+         command: |
+           FILE=/tmp/cacheflags/yarn.lock
+           if test -f "$FILE"; then
+               circleci-agent step halt
+           fi
      - restore_cache:
          keys:
            - yarn-{{ .Environment.YARN_CACHE_KEY }}-{{ arch }}
      - run: yarn --frozen-lockfile --silent --cache-folder ~.cache/yarn
      - save_cache:
          key: &cache-key-yarn yarn-{{ .Environment.YARN_CACHE_KEY }}-{{ arch }}-{{ checksum "/tmp/works/yarn.lock" }}
          paths:
            - ~/.cache/yarn
+     - run:
+         name: create match flag file
+         command: mkdir -p /tmp/cacheflags/ && touch /tmp/cacheflags/yarn.lock
+     - save_cache:
+         key: *find-cache-key-yarn
+         paths:
+           - /tmp/cacheflags/yarn.lock

上記はそれを実現するための install_deps の差分になる。

  1. キャッシュ復元
  2. 依存インストール
  3. キャッシュ保存

というシンプルな steps を少し工夫して、

  1. (追加)キャッシュが存在しているフラグの復元(無のファイルなのでめちゃくちゃ早い)
  2. (追加)フラグがあったら stepsを抜けてジョブを正常終了
  3. 部分キャッシュリストア
  4. 依存インストール
  5. キャッシュ保存
  6. (追加)キャッシュフラグファイルを作成
  7. (追加)キャッシュフラグファイルをキャッシュとして保存

このように「*lockファイルのチェックサムに該当するキャッシュがあるかどうかのファイルをキャッシュ」 して、「もしファイルがあった場合には以後の処理をスキップしてジョブを正常終了」が達成でき、無駄な依存インストールと save_cache を省略してジョブの実行時間を削ることができる。

まとめ

  • キャッシュ復元/保存は(ファイル数次第で)それなりに時間かかるから不要ならスキップしましょう

    • プロジェクト規模にもよると思うんだけど、上記例で checkout をそれぞれのジョブでやってるのもその文脈
    • もちろんキャッシュ使ったほうがはやいパターンも有る あくまでケースバイケース
  • step をスキップするにはジョブを正常終了して抜けられる circleci-agent step halt 便利

    • throw / raise じゃなくて early return ってやつ

って言う話でした。他にもいいやり方あるよ〜というのがあれば、ぜひ教えて下さい。

参考


  1. 状況によってはキャッシュの保存/復元にかかる時間より package repository から普通にダウンロードするほうが早いというケースがあり、転送量的に無駄が多くて申し訳ない気持ちにもなるけど、各並列ジョブで yarn 叩くことで高速化したプロジェクトがありました・・・。