CircleCI Workflow + Orbs & Reusing Config実践ガイド

# はじめに

クラウドワークス Advent Calendar 2018、18日目となりました🎁

先日、[CircleCI Advent Calendar 2018](https://qiita.com/advent-calendar/2018/circleci)に「[CircleCI Orbsをテストする](https://qiita.com/ganta/items/0c517f9a69faf06e3541)」を投稿しましたが、今回もCircleCIネタです。

本記事ではRailsアプリケーションである[CrowdWorks](https://crowdworks.jp/)のCircleCI設定でWorkflowの活用を実践し、設定の可読性を高めつつ、CIのメイン処理であるRSpec実行のオーバーヘッドを減らすことで並列数引き上げの効果を高めたお話をします。

Rails 4.2におけるお話となりますが、Workspaceの仕様など言語・フレームワークを問わない内容もありますので、ぜひご覧ください。


# Motivation

- 従量課金であるPerformance Planを利用しているため、少ないコンテナ数で長く実行するよりも、並列数を上げて短い時間で終わらせた方がうれしい。それをほぼ同じ料金で行うには並列数を上げるジョブのオーバーヘッドを減らす必要がある。
- 1つのジョブでたくさんのことを実行しているため、設定ファイルの見通しが悪い。
- YAMLのアンカー＆エイリアスだらけになっているため、CircleCI 2.1の機能でスッキリさせたい。


# Workflowの活用

まずはWorkflowを使ってジョブをどのように分割していったかについてと、Workflowと切っても切れない関係にあるWorkspaceについて解説します。

## 意味のある単位でジョブを分割

改善前は処理がほぼ1つのジョブに入っており、「とりあえずWorkflowとして動くようにした」という状態でした。

そのため、`build`ジョブにいろいろなものが詰め込まれていました。これだと中で何が行われているかわかりません。

```yaml:Before
workflows:
  version: 2
  build:
    jobs:
      - build
      - validate_factory
```

そこで、次のように意味のある単位でステップの集合をジョブに抽出していきました。

![Workflow](https://qiita-image-store.s3.amazonaws.com/0/7055/4cdcf0ce-17cb-f49d-4a50-0515109e4c60.png)

これにより、`rspec`ジョブのオーバーヘッドが減り、並列数を上げる効果が高まりました。

また、改善後はWorkflowの定義を見るだけで概要がわかるようになりました。

```yaml:After
workflows:
  version: 2
  continuous-integration:
    jobs:
      - static-checks
      - bundle-install
      - yarn-install
      - assets-precompile:
          requires:
            - bundle-install
            - yarn-install
      - setup-database:
          requires:
            - bundle-install
      - rspec:
          requires:
            - assets-precompile
            - setup-database
      - validate-factory:
          requires:
            - bundle-install
            - setup-database
      - validate-dwh-tag:
          requires:
            - bundle-install
            - setup-database
```

`config.yml`のサンプルではよくWorkflowの定義がファイルの末尾に書かれていることが多いですが、YAMLとしてはここの順序は関係ないため、概要が先にわかるようにWorkflowの定義はファイルの先頭に記述することをおすすめします。


## Workspaceについて

ジョブを分けていくと、`bundle install`で入ったGemファイルや`yarn install`で入ったNodeモジュール、`assets:precompile`で生成されたAssetを、それらを必要とするジョブに渡してあげなければなりません。

そのために、[Workspace](https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs)を利用します。

Workspaceを利用するには、データを永続化する[`persist_to_workspace`](https://circleci.com/docs/2.0/configuration-reference/#persist_to_workspace)と、データを取り出す[`attach_workspace`](https://circleci.com/docs/2.0/configuration-reference/#attach_workspace)の2つのステップを使います。

Workspaceの特徴として以下が挙げられます。

- 実行されるWorkflowごとに1つのWorkspaceが確保される
- Workspaceにはファイルが相対的なパスで保存される
- Workspaceに永続化された時点で格納されたデータが他のジョブから参照可能となる


### 実行されるWorkflowごとに1つのWorkspaceが確保される

WorkspaceのライフサイクルはWorkflowごとです。

また、Workflowの再実行の場合は元のWorkspaceが継承されてデータを参照することができます。同様に失敗したジョブのみ再実行するときや、SSHを有効にして再実行するときも元のWorkspaceが継承されるため、上流のジョブを再実行しなくても問題ありません。

ただし、データの保存期間は最大30日間となっているため、ジョブを個別に再実行するとWorkspaceのデータを必要とするステップでエラーになります。逆に、この保存期間を過ぎるまでは自分で削除することもできない点には注意です。


### Workspaceにはファイルが相対的なパスで保存される

データをWorkspaceに永続化する`persist_to_workspace`ステップには2つのプロパティが存在します。

1つは`root`プロパティで、絶対パスか`working_directory`からの相対パスが指定できます。これをどう指定するかは2つ目の`paths`プロパティによって決まります。

`paths`プロパティではWorkspaceに永続化したいファイルを、`root`プロパティで指定したパスからの相対パスで指定します。同時にWorkspace内にはこの`paths`プロパティで指定されたパスで保存され、`root`プロパティで指定されたパスの情報はWorkspaceに残りません。

データを取り出すときにはどのようになるかというと、`attach_workspace`ステップの`at`プロパティで指定されたパスからの相対パスでファイルが展開されます。

```yaml:job-a
- persist_to_workspace:
    root: /home/ruby/project
    paths:
      - vendor/bundle
```
例えば上記のように記述した場合、`job-a`で`/home/ruby/project/vendor/bundle`がWorkspaceの`vendor/bundle`に永続化されます。

```yaml:job-b
- persist_to_workspace:
    root: /home/ruby/project
    paths:
      - node_modules
```

次に`job-b`では`/home/ruby/project/node_modules`がWorkspaceの`node_module`に永続化されます。


```yaml:job-c
- attach_workspace:
    at: /var/tmp/workspace
```

そして、`job-c`で`/var/tmp/workspace/vendor/bundle`と`/var/tmp/workspace/node_modules`に展開されます。1回の`attach_workspace`ステップで、Workspace内に永続化されているものがすべて展開されます。

また、`paths`プロパティで指定するパスにはGo言語の[`filepath.Match`](https://golang.org/pkg/path/filepath/#Match)の書式を用いて記述することができます。


### Workspaceに永続化された時点で格納されたデータが他のジョブから参照可能となる

`persist_to_workspace`ステップが呼び出されるごとに、データがレイヤーとして積み重ねられていきます。`attach_workspace`ステップで取り出す際は、その時点までに格納されたデータが指定されたディレクトリ配下にすべて展開されます。

![Workspaceのイメージ](https://qiita-image-store.s3.amazonaws.com/0/7055/e9d7fd6c-23d7-233b-e200-fa3291acb10b.png)


この図の`setup-database`ジョブの時点では、依存している`bundle-install`ジョブが完了しているので、「Gemファイル」を取り出すことができます。

注意点として、`persist_to_workspace`ステップが完了した時点でWorkspaceに永続化したものが参照できるようになるため、もし`yarn-install`ジョブが`bundle-install`ジョブよりも早く終わっていた場合は`setup-database`ジョブ内の`attach_workspace`ステップで「Gemファイル」だけでなく、「Nodeモジュール」も展開されます。

あくまで「その時点で永続化されているものが見える」という仕様なので、Workflowの設定で`requires`に定義されているジョブで永続化されたものは順序が保証されているため確実に参照できますが、そうでないものは不定となっています。


# OrbsとReusing Configの活用

もともとYAMLのアンカーとエイリアスで記述の重複を回避していたのですが、せっかく[Orbs](https://circleci.com/orbs/)と[Reusing Config](https://circleci.com/docs/2.0/reusing-config/)が使えるようになったため、このタイミングで置き換えました。

YAMLのアンカー＆エイリアスの問題点はステップ1つにしかアンカーが張れないことが大きかったです。Reusing Configであれば、意味のある複数ステップの塊を1つのCommandにまとめることができます。

## 具体例

ここでは実際にOrbsとReusing Configを使っている箇所をピックアップして紹介します。

### ソースコードのチェックアウト

CrowdWorksのリポジトリは約1.3GBあり、そのまま`git clone`すると非常に時間が掛かります。そのため[Shallow Cloneを使っていた](http://engineer.crowdworks.jp/entry/2017/04/04/202719)のですが、これを[Orb化](https://circleci.com/orbs/registry/orb/ganta/git)したので参照するようにしました。

```yaml
orbs:
  git: ganta/git@1.2.0

...

commands:
  ...
  https-shallow-clone-checkout:
    description: Gitのユーザー設定を行い、リポジトリをHTTPS経由のShallowでチェックアウトします。
    steps:
      - run:
          name: git config
          command: |
            git config --global user.email "**********"
            git config --global user.name "*****"
      - git/shallow-clone-checkout:
          use-https: true
          github-access-token: ${GITHUB_TOKEN}
```

実際には直接ジョブ内で呼び出さずに、Commandを作ってラップしています。これはブランチを切ってコミットするステップが存在するため、Gitのユーザー設定を行っておきたかったのと、`shallow-clone-checkout`コマンドのパラメーターを毎回指定するのを避けるためです。

今回はチェックアウトを1回にしてソースコードをWorkspace経由で渡さず、各ジョブの先頭で行うようにしました。Workspaceのサイズが大きいと`attach_workspace`でOOM Killerされる事例を聞いていたため、ちょっとサイズが大きめなリポジトリを渡すのは避け、優先順位を下げました。


### Bundlerの場合

[sue445](https://twitter.com/sue445)さんの[ruby-orbs](https://circleci.com/orbs/registry/orb/sue445/ruby-orbs)を上記同様Commandにラップして使っています。

```yaml
orbs:
  ...
  ruby: sue445/ruby-orbs@1.3.0

...

commands:
  ...
  bundle-install:
    description: BundlerでGemをインストールします。
    parameters:
      persist-to-workspace:
        description: インストールしたGemをWorkspaceに永続化するかを指定します。
        type: boolean
        default: false
    steps:
      - ruby/bundle-install:
          cache_key_prefix: '{{ .Environment.COMMON_CACHE_KEY }}-gems-v1'
          # GemfileとGemfile.lockの不一致を検出するため、--deploymentオプションを付ける。
          bundle_extra_args: "--deployment"
      - run:
          name: Bundlerの設定確認
          command: bundle config
      - run:
          name: vendor/bundleのサイズ確認
          command: du -hs vendor/bundle
      - when:
          condition: << parameters.persist-to-workspace >>
          steps:
            - persist_to_workspace:
                root: .
                paths:
                  - vendor/bundle
                  - .bundle/config
```

Workspaceを利用しないジョブからも使えるように、Workspaceへの永続化は選択できるようにしておきました。

```yaml
    parameters:
      persist-to-workspace:
        description: インストールしたGemをWorkspaceに永続化するかを指定します。
        type: boolean
        default: false
```

`when`ステップを使ってパラメーターの値が`true`になるときだけWorkspaceへ永続化するようにしています。

```yaml
      - when:
          condition: << parameters.persist-to-workspace >>
          steps:
            - persist_to_workspace:
                root: .
                paths:
                  - vendor/bundle
                  - .bundle/config
```

`bundle-install`のパラメーターは`cache_key_prefix`と`bundle_extra_args`を指定しています。

```yaml
      - ruby/bundle-install:
          cache_key_prefix: '{{ .Environment.COMMON_CACHE_KEY }}-gems-v1'
          # GemfileとGemfile.lockの不一致を検出するため、--deploymentオプションを付ける。
          bundle_extra_args: "--deployment"
```

`cache_key_prefix`パラメーターには全キャッシュをまとめて飛ばせるように`{{ .Environment.COMMON_CACHE_KEY }}`と、Commandをチューニングしているときにキャッシュを飛ばせるようバージョンを付けています。

`bundle_extra_args`パラメーターには`Gemfile`と`Gemfile.lock`の不一致を検出できる`--deployment`を渡しています。

CrowdWorksでは[Rubyのオフィシャルイメージ](https://hub.docker.com/_/ruby)をベースとしたDockerイメージを使っているのですが、環境変数`BUNDLE_PATH`と`BUNDLE_APP_CONFIG`が定義された状態になっています。

```shell-session:オフィシャルRubyイメージの初期状態
$ bundle config
Settings are listed in order of priority. The top value will be used.
path
Set via BUNDLE_PATH: "/usr/local/bundle"

app_config
Set via BUNDLE_APP_CONFIG: "/usr/local/bundle"

...
```

ところが、`bundle install`コマンドに`--deployment`を渡すと、`--path vendor/bundle`オプションも暗黙的に設定され、Gemの保存先となる`path`設定が`BUNDLE_PATH`のデフォルトの`/usr/local/bundle`と異なる場所に変わります。

```shell-session:--deployment付きでbundleコマンドを実行した状態
$ bundle config
Settings are listed in order of priority. The top value will be used.
frozen
Set for your local app (/usr/local/bundle/config): true

path
Set for your local app (/usr/local/bundle/config): "vendor/bundle"
Set via BUNDLE_PATH: "/usr/local/bundle"

app_config
Set via BUNDLE_APP_CONFIG: "/usr/local/bundle"

...
```

Workspaceのアタッチ先を`working_directory`に統一しているため、カレントの`vendor/bundle`ディレクトリに保存されるのは都合がよいのですが、そのまま`attach_workspace`するだけでは`--deployment`オプションによって書き換えられた`path`設定が保持されず、`BUNDLE_PATH`の値である`/usr/local/bundle`を参照してしまい、`bundle exec`でエラーになってしまいます。

そこで環境変数`BUNDLE_PATH`をExecutorの`environment`設定で`vendor/bundle`に書き換えてしまう方法を思いつきますが、環境変数`BUNDLE_PATH`による指定と`--path`オプションによる指定ではディレクトリ構造が変わってしまうためうまく動きません。[^1]

[^1]: [BUNDLE_PATH differences in local vs. global configuration · Issue #3552 · bundler/bundler](https://github.com/bundler/bundler/issues/3552)

そのため、Executorの`environment`設定には`BUNDLE_PATH`ではなく、`BUNDLE_APP_CONFIG`を`working_directory`直下の`.bundle`ディレクトリに設定し、`path`が設定された`config`を一緒にWorkspaceに永続化します。

```shell-session:BUNDLE_APP_CONFIGに/usr/src/app/.bundleを設定した状態
$ bundle config
Settings are listed in order of priority. The top value will be used.
path
Set via BUNDLE_PATH: "/usr/local/bundle"

app_config
Set via BUNDLE_APP_CONFIG: "/usr/src/app/.bundle"

...
```

すると、`attach_workspace`するだけで`--path vendor/bundle`が設定された状態にすることができます。

```shell-session:attach_workspaceによって.bundle/configが展開された状態
$ bundle config
Settings are listed in order of priority. The top value will be used.
frozen
Set for your local app (/usr/src/app/.bundle/config): true

path
Set for your local app (/usr/src/app/.bundle/config): "vendor/bundle"
Set via BUNDLE_PATH: "/usr/local/bundle"

app_config
Set via BUNDLE_APP_CONFIG: "/usr/src/app/.bundle"
```

# まとめ

Workflowを活用してジョブを意味のある塊に分割することで可読性を高めるだけでなく、ジョブのオーバーヘッドを減らし、並列化の効果も高めることができます。

改善前は10〜12分ほどで完了していたWorkflowが、8〜9分で完了するようになりました:tada: 

![改善前後](https://qiita-image-store.s3.amazonaws.com/0/7055/168c12d7-77a0-84fb-5fd7-ad1c3b79bbed.png)

実はもっと`rspec`ジョブの並列数を上げれば速くなるのですが、Pull Requestのチェックを行う他のサービスを待つ状態になったことと、まだジョブのDockerのイメージのpullに時間が掛かるというオーバーヘッドが残っているため、いったん並列数を上げるのを止めています。

また、CircleCI 2.1のOrbsとReusing Configも活用することで、より可読性を高めることができます。

これらの機能を活用してメンテナンス性の高い`config.yml`にしていきましょう。

明日のAdvent Calendarは@k-waragaiさんの社内でのVR活用についてです:santa_tone1: 
残り7日、引き続き[クラウドワークス Advent Calendar 2018](https://qiita.com/advent-calendar/2018/crowdworks)をよろしくおねがいします:christmas_tree: