Hori Blog

フリーランスでバックエンドエンジニアとして活動している Ryota Hori のブログです。
最近はテック系記事より雑記ブログ気味。

CircleCI 2.0 で Go 1.10 の build cache をいい感じに効かせる

今更ながら CircleCI 2.0 を本格的に触りました。 CircleCI 2.0 になりキャッシュの柔軟性が向上したようなので、 Go 1.10 から導入された build cache と併せて最適化してみました。

前提知識

ドキュメントなど

戦略

  • 依存管理は dep を使う
  • vendor は Gopkg.lock が一緒なら同じものを用いる
  • build cache は基本的にはブランチごとに管理
    • ただし、新規ブランチは master からキャッシュを拾いたい

お急ぎの人のための結論

version: 2
jobs:
    build:
        working_directory: /go/src/github.com/hori-ryota/hoge # 対象のリポジトリに合わせて変更
        docker:
            - image: circleci/golang:1.10.2
        environment:
            - GOCACHE: "/tmp/go/cache"
        steps:
            - checkout
            - run: git submodule sync
            - run: git submodule update --init
            - restore_cache:
                  keys:
                      - vendor-{{ checksum "Gopkg.lock" }}
                      - dep
            - run:
                  name: ensure
                  command: |
                      if [ ! -d vendor ]; then
                        if ! type dep >/dev/null 2>&1; then
                          go get github.com/golang/dep/cmd/dep
                        fi
                        dep ensure
                      fi
            - save_cache:
                  key: vendor-{{ checksum "Gopkg.lock" }}
                  paths:
                      - vendor
            - save_cache:
                  key: dep
                  paths:
                      - /go/bin/dep
            - restore_cache:
                  keys:
                      - build-cache-{{ .Branch }}--
                      - build-cache-master--
                      - build-cache-
            - run:
                  name: build
                  command: make build
            - save_cache:
                  key: build-cache-{{ .Branch }}--{{ .Revision }}
                  paths:
                      - /tmp/go/cache
                  when: on_fail
            - run:
                  name: prepare cache dir if not exists
                  command: mkdir -p $GOCACHE
            - persist_to_workspace:
                  root: /
                  paths:
                      - tmp/go/cache
                      - go/src/github.com/hori-ryota/hoge/artifacts

    test:
        working_directory: /go/src/github.com/hori-ryota/hoge
        docker:
            - image: circleci/golang:1.10.2
        environment:
            - GOCACHE: "/tmp/go/cache"
        steps:
            - checkout
            - restore_cache:
                  keys:
                      - vendor-{{ checksum "Gopkg.lock" }}
            - attach_workspace:
                  at: /
            - run:
                  name: test
                  command: make test
            - save_cache:
                  key: build-cache-{{ .Branch }}--{{ .Revision }}
                  paths:
                      - /tmp/go/cache
                  when: always
            - persist_to_workspace:
                  root: .
                  paths:
                      - artifacts

    deploy:
        working_directory: /go/src/github.com/hori-ryota/hoge
        docker:
            - image: circleci/golang:1.10.2
        steps:
            - checkout
            - attach_workspace:
                  at: .
            - run:
                  name: deploy
                  command: make deploy

workflows:
    version: 2
    build:
        jobs:
            - build
            - test:
                  context: test # if needed
                  requires:
                      - build
            - deploy:
                  context: deploy # if needed
                  requires:
                      - test

解説

キャッシュの挙動とともに上から解説していきます。

version: 2
jobs:
    build:
        working_directory: /go/src/github.com/hori-ryota/hoge # 対象のリポジトリに合わせて変更
        docker:
            - image: circleci/golang:1.10.2

Go なので woking_directory を GOPATH 内に設定しています。

environment:
    - GOCACHE: "/tmp/go/cache"

build cache の保存先は $GOCACHE で指定できます。扱いやすいように明示的に設定。

steps:
    - checkout
    - run: git submodule sync
    - run: git submodule update --init

本題と関係ないですが submodule を取得するならここ。

- restore_cache:
      keys:
          - vendor-{{ checksum "Gopkg.lock" }}
          - dep
- run:
      name: ensure
      command: |
          if [ ! -d vendor ]; then
            if ! type dep >/dev/null 2>&1; then
              go get github.com/golang/dep/cmd/dep
            fi
            dep ensure
          fi
- save_cache:
      key: vendor-{{ checksum "Gopkg.lock" }}
      paths:
          - vendor
- save_cache:
      key: dep
      paths:
          - /go/bin/dep

ここは vendor のキャッシュです。まず restore_cache ですが、 restore_cachekeys で指定したリストを順番にチェックし、初めに見つけた最初のキャッシュのみ取得します。

- restore_cache:
      keys:
          - vendor-{{ checksum "Gopkg.lock" }}
          - dep

よってこの場合だと vendor-{{ checksum "Gopkg.lock" }} があれば使用し、なければ dep を取得します。更になければ無視。vendor が存在していたら dep は入れる必要がないので便利ですね。

次に ensure 部ですが、 vendor/dep の存在によって処理を分岐させます。

- run:
      name: ensure
      command: |
          if [ ! -d vendor ]; then
            if ! type dep >/dev/null 2>&1; then
              go get github.com/golang/dep/cmd/dep
            fi
            dep ensure
          fi

vendor/ ディレクトリがなかった場合のみ、 dep の存在チェックをして、なければインストール。その後 dep を使って ensure しています。

最後はキャッシュに保存。 CircleCI 2.0 のキャッシュは key がすでに存在すれば上書きせずにスルーします。今回は vendorGopkg.lock に対して一意とし、 dep の更新性も考慮しないので決め打ちです。

- save_cache:
      key: vendor-{{ checksum "Gopkg.lock" }}
      paths:
          - vendor
- save_cache:
      key: dep
      paths:
          - /go/bin/dep

もし dep の更新を行いたい場合は key にリビジョンや日付などの suffix をつけても良いかもです。

次に build cache です。方針のおさらいをしておきます。

  • build cache は基本的にはブランチごとに管理
    • ただし、新規ブランチは master からキャッシュを拾いたい

これを実現するための restore_cache がこちら。

- restore_cache:
      keys:
          - build-cache-{{ .Branch }}--
          - build-cache-master--
          - build-cache-

上からチェックされるので意図通りの取得ができるかと思います。3 つ目の build-cache- のみのものは使われることはないと思いますが、一応つけてみました。

save_cache 側がこちら。

- run:
      name: build
      command: make build
- save_cache:
      key: build-cache-{{ .Branch }}--{{ .Revision }}
      paths:
          - /tmp/go/cache
      when: on_fail

CircleCI 2.0 のキャッシュは、 key が前方一致する中で最新のものを取得する仕組みになっています。

なので更新性のあるデータについては {{ .Revision }} のような suffix をつけて保存しておき、取得側では suffix を取り除いた key を用いるとうまくいきます。同じ Revision でも更新性をもたせたい場合は {{ epoch }} などを併用すると良いかと思います。

変数の一覧はこちら。

Caching Dependencies - CircleCI

ここで when: on_fail を使っている理由はこちら。

  • build が成功した場合は test 後のキャッシュを保存したい
  • build が失敗した場合でも何らかのキャッシュが作成される場合、保存しておきたい

まぁ、一つ前の revision のキャッシュが残っているのであれば頑張って保存するほどじゃないかなーとは思っています。前方一致で取れるのを知らずに CIRCLE_PREVIOUS_BUILD_NUM らへんで頑張ろうとしていたときの名残り。

そして build の workflow のラスト。

- run:
      name: prepare cache dir if not exists
      command: mkdir -p $GOCACHE
- persist_to_workspace:
      root: /
      paths:
          - tmp/go/cache
          - go/src/github.com/hori-ryota/hoge/artifacts

persist_to_workspace で次の workflow にデータを渡します。ディレクトリがないと失敗するので mkdir -p して保証。 artifacts ディレクトリは build の成果物などなどの想定です。

次に test の workflow ですが、ここは vendorrestore_cache をして test して build cache を save_cache するだけです。 vendorattach_workspace するより restore_cache のほうが速かった。

test:
    working_directory: /go/src/github.com/hori-ryota/hoge
    docker:
        - image: circleci/golang:1.10.2
    environment:
        - GOCACHE: "/tmp/go/cache"
    steps:
        - checkout
        - restore_cache:
              keys:
                  - vendor-{{ checksum "Gopkg.lock" }}
        - attach_workspace:
              at: /
        - run:
              name: test
              command: make test
        - save_cache:
              key: build-cache-{{ .Branch }}--{{ .Revision }}
              paths:
                  - /tmp/go/cache
              when: always

ただし、 test が失敗してもキャッシュは保存したいので when: always を使用しています。

あとは artifacts らへんの必要なものを deploy などの後続 workflow に渡してあげれば完了です。

      - persist_to_workspace:
          root: .
          paths:
            - artifacts
  deploy:
    working_directory: /go/src/github.com/hori-ryota/hoge
    docker:
    - image: circleci/golang:1.10.2
    steps:
    - checkout
    - attach_workspace:
        at: .
    - run:
        name: deploy
        command: make deploy

workflows:
  version: 2
  build:
    jobs:
      - build
      - test:
          context: test # if needed
          requires:
          - build
      - deploy:
          context: deploy # if needed
          requires:
          - test

context は横断的に環境変数を設定できるもののようなので、 deploy や test に必要な認証情報などなどを渡したりしています。workflow に対して複数設定できるようになって欲しい。

以上

同一 key に対して上書き更新をかけられないとのことだったので最初は混乱しましたが、前方一致での検索と把握して納得しました。

build cache を効かせるととにかく爆速になるのでおすすめです。

キャッシュの有無による挙動変更はゴリゴリ書くしかなかったので、 step 間に条件分岐など仕込めるようになったらうれしいですね。