CircleCI 2.0 でNode.jsのマルチバージョンビルド

CircleCI 2.0が高速でカスタマイズできて最高だという話と、Node.jsのマルチバージョンテストのやり方、キャッシュ戦略などを紹介する。

前提として、以下はnpmパッケージのようなユニットテストでほぼ完結するシンプルなライブラリのCIを想定している。 サービスやアプリ開発のCIは要求が違ってくるのでまた別。

先に結論としてサンプルプロジェクトを貼っておく。急ぎの方は.circleci/config.ymlをコピペして “How to use” だけ読んで設定したらOK

github.com

CircleCI 2.0はまだβテスト中なので、もろもろ変更される可能性はある。 ここから申し込めばすぐ使えるようになるのでGO。

vs. Travis CI

この用途の場合、以前はCircleCIよりもTravis CIを好んで利用していた。理由は以下の2つ

  • Travisの方が高速(キューの待ち時間とジョブの実行速度の両方)
  • TravisはNode.jsのマルチバージョンテストが標準機能で簡単に実行可能

速度

ジョブの完了にかかる時間は、キューの待ち時間とジョブの実行時間に大別される。 待ち時間は条件によって大きく違うので参考程度ではあるが、OSS無料プランではTravisの方がCircleCIよりもだいぶ空いている印象があった。

ジョブ実行時間については3つぐらいの段階に分けられる。

  1. コンテナの起動
  2. Node.jsのインストールやキャッシュの展開など各種環境設定
  3. テスト実行

CircleCI 1.0のベースイメージはMySQL, PostgreSQL, MongoDBがデフォルトで起動するなどてんこ盛りなため、起動が遅く、また不要なサービスをkillしないとOut Of Memoryが発生するなどのバッドノウハウもあった。 また、指定されたバージョンのNode.jsを用意するため、TravisでもCircleCIでもコンテナ起動後にnvmを毎回実行してインストールしていた。

以下はOSS無料プランでNode.jsによる最小構成のテストを回すジョブで雑な実験をした比較。

Travis CircleCI 1.0 CircleCI 2.0 AppVeyor(参考)
キュー待ち 数秒〜1分 数秒〜2分 数秒 数十秒〜2分
最小ジョブ実行 30秒 1分30秒 5秒 40秒
同時実行 ? 4 4 1

CircleCI 2.0ではGitHubにpush直後にジョブが発火されるように感じるぐらい、異様に早い。まだベータだから空いているだけという可能性があるので参考値ではあるが。

最小構成のジョブ実行も2.0では一瞬で終わる。理由はDockerイメージを自由に指定できることが大きい。node:6などのイメージを指定することで、無駄なサービス起動がなく、nvmを実行する必要もない。

また、最近のNode.jsの公式Dockerイメージには標準でyarnが搭載されているため、yarnを別途インストールする時間も省略できる。

それから後述するマルチバージョンのジョブを3つ登録した場合でも、即座に全てのジョブが実行される。 これはもともとCircleCIの特性でOSSプロジェクトはフリープランでも4つのコンテナを同時実行可能であるため。 Travisはフリープランの同時実行数がよく分からないけど、キューが空いてたらじわじわ同時実行されるが、3バージョンが綺麗に同時実行されることはあまりない。 そのため、マルチバージョンテストの場合はトータルでより大きな差が開く。

ということで、速度についてはCircleCI 2.0で申し分ないほど改善された。

Node.jsのマルチバージョンテスト

npmパッケージをNode.js v4, v6, v8の3バージョンでテストしたい、みたいな一般的なシナリオ。

Travisではこれが非常に簡単な標準機能で実現可能だったが、CircleCI 1.0では以下のような謎のシェルスクリプトを書き、設定で並列数を4に上げて手動でnvmを実行する必要があった。

dependencies:
  pre:
    - case $CIRCLE_NODE_INDEX in 0) NODE_VERSION=4 ;; 1) NODE_VERSION=5 ;; 2) NODE_VERSION=6 ;; esac; nvm install $NODE_VERSION && nvm alias default $NODE_VERSION

test:
  override:
    - ./<your-test-command>

それがこんな感じに書くことができる。

version: 2
jobs:
  build:
    docker:
      - image: node:6
    working_directory: ~/working_directory
    steps:
      - checkout
      - run:
          name: Trigger Jobs
          command: |
            function trigger_job() {
                job_name=$1
                echo "trigger_job $job_name"
                http_code=$(curl --user ${CIRCLE_API_TOKEN}: \
                    --data build_parameters[CIRCLE_JOB]=$job_name \
                    --data revision=$CIRCLE_SHA1 \
                    -o curl.output \
                    -w '%{http_code}\n' \
                    -s \
                    https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/tree/$CIRCLE_BRANCH)
                cat curl.output
                echo "http_code: $http_code"
                if [[ ! "$http_code" =~ ^20[0-9]$ ]]; then
                    echo "Bad status code: $http_code" >&2
                    return 1
                fi
            }
            trigger_job node-v4
            trigger_job node-v6
            trigger_job node-v8

  node-base: &node-base
    docker:
      - image: node
    working_directory: ~/working_directory
    steps:
      - run:
          name: Test
          command: npm test

  node-v4:
    <<: *node-base
    docker:
      - image: node:4

  node-v6:
    <<: *node-base
    docker:
      - image: node:6

  node-v7:
    <<: *node-base
    docker:
      - image: node:8

長くなってるだろ!というお叱りの言葉が聞こえそうだけど落ち着いて見て欲しい。 長いのはほとんど別ジョブを叩くcurlコマンドのせいなので本質ではない(後述するWorkflow機能で解消される予定)。

1.0がただのシェルスクリプトだったことに対して、2.0では複数のジョブを宣言的に定義できるようになった。 最初はデフォルトのbuildジョブが起動されるので、そこからNode.jsの各バージョンでのテストを実行する3つのジョブを起動する。 各ジョブのコンテナイメージでNodeの各バージョンを指定することで、時間がかかるnvmの実行も省いている。

YAMLマージでDRY

YAMLには&, *による参照機能、 <<によるマージ機能が存在していて、それを使った。 詳しくはRubyist Magazine - プログラマーのための YAML 入門 (検証編)を参照してもらうとして、この機能を使うことで、node-baseジョブでテスト処理本体を定義し、node-v6等のジョブはNodeのバージョンをコンテナイメージで指定しつつその他はnode-baseをマージすることでDRYな感じで記述できる。

YAMLすごいけどむずい。

GitHubからの見え方

この方法でマルチビルドをすると、GitHubのプルリクからはNodeのバージョンごとにステータスが表示されるので便利。 Travis標準のマルチビルドでは、ステータスは1つにまとめられるので、落ちたときやビルドの進捗の確認をするにはTravisの画面を開く必要があった。2.0ではプルリク画面から状況を把握できる。

f:id:teppeis:20170528011500p:plain

Workflowですっきり

将来的には、現在一部のユーザーでアルファテスト中のWorkflow機能によって、curlを使わずに標準機能で複数ジョブの並列/直列実行が可能になる。 現在公開されている情報によると、上述のcurlの部分は恐らくこんな感じにスッキリ書けるようになるらしいが、まだアルファテスト中なので詳細は不明。

workflows:
  version: 2
  node-multi-build:
    jobs:
      - node-v4
      - node-v6
      - node-v8

デフォルトのbuildジョブの代わりにworkflowsで定義した順にビルドが走る。この場合はnode-vNを並列実行する。 直列実行するにはこんな感じで依存関係を定義するらしい。

workflows:
  version: 2
  build:
    jobs:
      - build1
      - build2:
          requires:
            - build1

キャッシュ制御

ここまでで、もはやTravisを使う理由がないという感じになって来ているが、さらにCircleCI 2.0の優れたポイントであるキャッシュ制御について紹介する。 詳細は公式のドキュメントリファレンスを読んでもらうとして、ポイントはキャッシュがキーに対してイミュータブルであることと、Partial Cache Retoreと呼ばれる優先順位付けられたリストアの仕組み。

キャッシュはイミュータブルなので、同一キーで一度キャッシュを保存すると、それ以降のジョブでは二度とキャッシュが上書きされない。例えばこんなkeyをつけてしまうと、masterブランチでは初回のキャッシュ保存以降は二度と上書きされない。

- save_cache:
    key: yarn-cache-{{ .Branch }}
    paths:
      - /usr/local/share/.cache/yarn

もし毎回キャッシュを上書きしたいなら{{ .BuildNum }}などをうまく使う必要がある。

Partial Cache Restoreをうまく使うと、キーの完全一致がなかった場合でもなるべくキャッシュを使うようにできる。 例えばyarnの場合、自分はこんな感じに落ち着いた。

- restore_cache:
    keys:
      - v1-yarn-lock-{{ .Branch }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "yarn.lock" }}
      - v1-yarn-lock-master-{{ .Environment.CIRCLE_JOB }}-{{ checksum "yarn.lock" }}
      - v1-yarn-cache-{{ .Branch }}-{{ .Environment.CIRCLE_JOB }}
      - v1-yarn-cache-master-{{ .Environment.CIRCLE_JOB }}
- run:
    command: yarn install
- run:
    command: yarn test
- save_cache:
    key: v1-yarn-lock-{{ .Branch }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "yarn.lock" }}
    paths:
      - node_modules
- save_cache:
    key: v1-yarn-cache-{{ .Branch }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "yarn.lock" }}
    paths:
      - /usr/local/share/.cache/yarn

以下のようなことを実現している。

  • yarn.lockに変更がない場合、キャッシュしたnode_modulesをそのまま復元すればyarn installが1秒以下で終わる。
  • yarn.lockが変更されている場合、yarnのキャシュディレクトリを復元する。30秒以上かかるyarn installが10秒ぐらいに短縮される。
  • masterブランチとその他ブランチのキャッシュを分離したい。実験ブランチで色々試すとキャッシュが無駄に膨れるため。
  • 各ブランチでの初回ビルドはmasterブランチのキャッシュを使う。
  • 念のためNode.jsのバージョンによってキャッシュを分ける。今回は環境変数CIRCLE_JOBを使った。
  • キャッシュがおかしくなったりゴミが増えたとき、prefixのv1-をインクリメントすることでイミュータブルなキャッシュを破棄する。環境変数にするとコードを変更せずにCircleCIの管理画面だけでキャッシュ破棄とリビルドができるようになるのでオススメ。

仕組み上はnpm@5でもyarn.lockの代わりにpackage-lock.jsonを使い、キャッシュを~/.npmあたりに設定すれば動くはずだが未検証。

ブランチ名が過去のブランチとかぶるとyarnのキャッシュが過去から復元されてしまうけど、まあmasterとの分離が目的だし、適当なタイミングでv1-をインクリメントしてれば気にならないレベルだろう。

このキャッシュ戦略がベストかどうかはまだわからない。リストアが思ったより速いので、yarnのキャッシュがよほど大きくならない限りは毎回全部復元した方がシンプルで楽になるのかも。

現状の課題

コミュニティに対する中の人のレスポンスが良いので、おそらくすぐ直りそうな気がするけど、挙げておく。

paths, path環境変数などを使えない

キャッシュの設定等で動的な設定ができない。例えばyarnのキャッシュディレクトリはOSやバージョンによって異なるので本当はyarn cache dirの結果を設定したいが、不可能。ハードコードだけだと辛いので、一応簡単なシェルスクリプトで変更がないかチェックしている。

Dockerイメージのキャッシュを制御できない

例えばnode:6と指定すると、Node 6の最新版がv6.10.3だったとしても、ガチャで当たったホストにnode:6.10.1がキャッシュされていた場合はv6.10.1が降ってくる。これまでの体感では、すべてのホストのキャッシュが切り替わるのに数日かかる印象。特にyarnはまだv0.xなので、アップデートでキャッシュディレクトリの変更などCIに影響があるbreaking changeが平気で入ってくるので、これを固定できないのはちょっと辛い。

Node 6 以下はyarn? npm@5?

Node 6以下ではイメージに標準でバンドルされてるyarnに比べて、npm@5は別途アップデートを実行する必要がありむしろ手間と時間がかかるという状況になってしまっている。共存も微妙だし、今後どうしていくべきか…

結論

CI沼にハマると無限に時間が過ぎていく。