2019-05-11

Rust のプロジェクトを CircleCI でテストしてリリースまで自動化するための設定例

このエントリーをはてなブックマークに追加

先日このブログを自作の Salmon という静的サイトジェネレータに移行した というエントリを書きましたが、快適に開発を進めていくために CI 環境を整えようと思いたち、ちまちまと CircleCI を設定していました。

以前から Travis CI を個人プロジェクトで利用してきましたが、少し前から本格的に CircleCI を利用しはじめてみたところ、非常に手に馴染む感じがして気に入りました。じつは CircleCI 2.0 になる前にも少し使ったことがあったのですが、2.0 になって Docker ベースになったため、もろもろの概念や裏側で何が起こっているのかも理解・推測しやすくて扱いやすいです。

不慣れなのではじめは上手に設定が書けませんでしたが、ドキュメントとにらめっこしながらパターンを学んでいき、ようやく Rust プロジェクトでコミットをプッシュするたびにテストを走らせ、リリース (docker push および cargo publish) するところまで自動化できたので、メモとしてこのエントリを書くことにしました。

設定を書く際には https://www.ncaq.net/2019/03/08/21/12/35/ のエントリも参考にしました。

Salmon で実際に利用している .circleci/config.yml

https://github.com/mozamimy/salmon/blob/b8bfa448e5255ed89ee1b7a27750a7028baabe17/.circleci/config.yml に設定の YAML ファイルがあります。このエントリを書いてからも変更を加えている可能性があるため、master ブランチの設定は変わっているかもしれません。

version: 2.1

executors:
  default:
    docker:
      - image: 'rust:1.34-stretch'
    environment:
      MAKE_LIBSASS_JOBS: '4'
      BUILD_JOBS: '4'
  docker:
    docker:
      - image: 'docker:18.09'

jobs:
  lint:
    executor: 'default'
    steps:
      - 'checkout'
      - run: 'rustup component add rustfmt'
      - run: 'cargo fmt -- --check'
  build:
    executor: 'default'
    parameters:
      release:
        type: 'boolean'
        default: false
    steps:
      - 'checkout'
      - restore_cache:
          key: 'v1-cargo-lock-{{ checksum "Cargo.lock"}}<<# parameters.release >>-release<</ parameters.release>>'
      - run: 'cargo build --jobs ${BUILD_JOBS} <<# parameters.release >>--release --locked<</ parameters.release>>'
      - save_cache:
          key: 'v1-cargo-lock-{{ checksum "Cargo.lock"}}<<# parameters.release >>-release<</ parameters.release>>'
          paths:
            - '/usr/local/cargo/registry'
            - 'target/'
  test:
    executor: 'default'
    parameters:
      release:
        type: 'boolean'
        default: false
    steps:
      - 'checkout'
      - restore_cache:
          key: 'v1-cargo-lock-{{ checksum "Cargo.lock"}}<<# parameters.release >>-release<</ parameters.release>>'
      - run: 'cargo test <<# parameters.release >>--release --locked<</ parameters.release>>'
  build_and_push_docker_image:
    executor: 'docker'
    environment:
      DOCKER_USER: 'mozamimy'
    steps:
      - 'checkout'
      - setup_remote_docker:
          docker_layer_caching: true
      - run: 'docker build --tag mozamimy/salmon:latest --tag mozamimy/salmon:${CIRCLE_TAG:1} --tag mozamimy/salmon:${CIRCLE_SHA1} .'
      - run: 'docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}'
      - run: 'docker push mozamimy/salmon:latest'
      - run: 'docker push mozamimy/salmon:${CIRCLE_TAG:1}'
      - run: 'docker push mozamimy/salmon:${CIRCLE_SHA1}'
  publish_crate:
    executor: 'default'
    steps:
      - 'checkout'
      - restore_cache:
          key: 'v1-cargo-lock-{{ checksum "Cargo.lock"}}-release'
      - run: 'cargo login ${CRATES_IO_API_TOKEN}'
      - run: 'cargo package --jobs ${BUILD_JOBS} --locked'
      # Cannot publish this crate because using forked sass-rs now.
      # https://github.com/mozamimy/sass-rs/tree/make-libsass-env
      # We are waiting to merge https://github.com/compass-rs/sass-rs/pull/43.
      # - run: 'cargo publish --jobs ${BUILD_JOBS} --locked'

workflows:
  version: 2
  # This workflow is invoked in all branches.
  run_test:
    jobs:
      - 'lint'
      - 'build'
      - test:
          requires:
            - 'lint'
            - 'build'
  # This workflow is invoked when a vX.Y.Z tag is pushed.
  release:
    jobs:
      - lint:
          filters:
            branches:
              ignore: '/.*/'
            tags:
              only: '/^v\d+\.\d+\.\d+/'
      - build:
          release: true
          filters:
            branches:
              ignore: '/.*/'
            tags:
              only: '/^v\d+\.\d+\.\d+/'
      - test:
          release: true
          requires:
            - 'lint'
            - 'build'
          filters:
            tags:
              only: '/^v\d+\.\d+\.\d+/'
      - build_and_push_docker_image:
          requires:
            - 'test'
          filters:
            branches:
              ignore: '/.*/'
            tags:
              only: '/^v\d+\.\d+\.\d+/'
      - publish_crate:
          requires:
            - 'test'
          filters:
            branches:
              ignore: '/.*/'
            tags:
              only: '/^v\d+\.\d+\.\d+/'

2 つの executor を定義する

executorsdefaultdocker の 2 つの executor を定義し、それぞれバイナリをビルドする用と Docker イメージをビルドする用としました。CircleCI の Pre-Built CircleCI Docker Images を見たところ、Rust 用のイメージが用意されているようでしたが、当該の Dockerfile をざっと見たところ、特にこのイメージに含まれているツール類は必要なさそうだったので、rust:1.34.1-stretch イメージを利用することにしました。

workflow は普段のテスト用とリリース用に分ける

workflow では run_testrelease という 2 種類の workflow を定義し、それぞれ master ブランチや pull request のビルド用、リリース作業の自動化用としました。release workflow は、タグが vx.y.z であるようなタグが push されたときのみ発火するようになっています。GitHub のリリース機能を使うと Git のタグが打たれるため、その場合に release workflow が実行されて docker pushcargo publish が行われます。

run_test は以下のような感じ。

release は以下のような感じ。

cargo fmt による lint と cargo build による build は並列に実行し、それぞれが正常に終了したら cargo test でテストケースを流したあと、release の場合はさらに docker buildcargo package/publish を並列に実行します。このようなパイプラインを簡単にガチャガチャ組めるのは楽しいですね。

本当は docker builddocker push は job を分割して cargo build などを流している間にも投機的に docker build を実行したかったのですが、Docker layer caching を使うためにはプランを変更する必要があり、さもなくば自前でキャッシュをがんばる必要があったので今回は見送りました。そういうわけで、docker_layer_caching: true となっていますが、実はこれは効いていません。

キャッシュを利用して少しでも高速にコンパイルする

Rust はお世辞にもビルドが速いとはいえないため、毎回依存ライブラリをビルドしていると悲しいことになります。幸い CircleCI には組み込みのキャッシュ機能があるため、ビルド済みの依存ライブラリをキャッシュして再利用することができます。

キャッシュキーは、ちょっと苦しい感じなのですが v1-cargo-lock-{{ checksum "Cargo.lock"}}<<# parameters.release >>-release<</ parameters.release>> としました。build job ではパラメータを使ってデバッグビルドとリリースビルドを切り替えられるようにしているため、キャッシュキーの中でパラメータを見て -release という文字を入れるか入れないかを切り替えています。

${CIRCLE_TAG} 環境変数から Git タグを取得して Docker イメージを焼いてアップロードする

CircleCI では組み込みの環境変数がいろいろ用意されており、たとえば Git タグは CIRCLE_TAG 環境変数を通じて知ることができます。

build_and_push_docker_image job では、CIRCLE_TAG 環境変数の中身の v0.1.0 のような文字列を 0.1.0 のような形に整形し、その値を使って docker builddocker push をして Docker Hub にイメージをアップロードしています。

メモリ不足でときどきビルドがコケる問題

たとえば、https://circleci.com/gh/mozamimy/salmon/93 の実行のように、ときどき (いつもではない) ビルドがコケる現象に悩まされ、やや難儀しました。CircleCI では resource_class を使えるプランに移行しない限り、ビルド環境として与えられるのは 2 CPU と 4096 MB のメモリになるため、その制限内でビルドできるようにしないといけません。

いろいろ調べて、結局 Salmon が利用している libsass のラッパーである sass-rs の、libsass をビルドする sass-sys でコケていることがわかりました。

sass-sys の build.rs にある実装を見てみると、どうやら libsass を make するときに CPU のコア数をもとに並列数を決めているということがわかりました。

世の中にはいろいろなコンテナランタイムがありますが、Docker ではナイーブに CPU の数を取得して並列数を決めた場合、意図しない値になることがあります。CircleCI 環境では、どうやらホストマシンは 36 個の CPU コアが載っているようで、libsass のビルドも 36 並列で行われた結果、メモリ不足に陥っているようでした。

そこで、MAKE_LIBSASS_JOBS 環境変数から並列数を差し込めるようにする Override --jobs option value with an env var in build.rs by mozamimy · Pull Request #43 · compass-rs/sass-rs のような pull request を投げました。まだ本体には入っていないため、Salmon ではわたしの fork 版を利用するように Cargo.toml に設定しています。

ただ、悲しいことに Cargo.toml 中で sass-rs = { git = "https://github.com/mozamimy/sass-rs.git", branch = "make-libsass-env" } のように Git リポジトリをソースとして設定すると、cargo publish できないため、今は publish_crate ジョブの一部をコメントアウトした状態にし、Docker イメージだけリリースするような状態になっています..。

まとめ

Rust プロジェクトを CircleCI でテストしてリリースまで自動化するための設定の一例を紹介しました。テストの実行やリリースを自動化できると、開発に集中できてよいですね。

改善点やご意見、ご感想があれば @mozamimy までおしらせていただけるとよろこびます。