はじめに
クラウドワークス Advent Calendar 2018、18日目となりました🎁
先日、CircleCI Advent Calendar 2018に「CircleCI Orbsをテストする」を投稿しましたが、今回もCircleCIネタです。
本記事ではRailsアプリケーションであるCrowdWorksのCircleCI設定でWorkflowの活用を実践し、設定の可読性を高めつつ、CIのメイン処理であるRSpec実行のオーバーヘッドを減らすことで並列数引き上げの効果を高めたお話をします。
Rails 4.2におけるお話となりますが、Workspaceの仕様など言語・フレームワークを問わない内容もありますので、ぜひご覧ください。
Motivation
- 従量課金であるPerformance Planを利用しているため、少ないコンテナ数で長く実行するよりも、並列数を上げて短い時間で終わらせた方がうれしい。それをほぼ同じ料金で行うには並列数を上げるジョブのオーバーヘッドを減らす必要がある。
- 1つのジョブでたくさんのことを実行しているため、設定ファイルの見通しが悪い。
- YAMLのアンカー&エイリアスだらけになっているため、CircleCI 2.1の機能でスッキリさせたい。
Workflowの活用
まずはWorkflowを使ってジョブをどのように分割していったかについてと、Workflowと切っても切れない関係にあるWorkspaceについて解説します。
意味のある単位でジョブを分割
改善前は処理がほぼ1つのジョブに入っており、「とりあえずWorkflowとして動くようにした」という状態でした。
そのため、build
ジョブにいろいろなものが詰め込まれていました。これだと中で何が行われているかわかりません。
workflows:
version: 2
build:
jobs:
- build
- validate_factory
そこで、次のように意味のある単位でステップの集合をジョブに抽出していきました。
これにより、rspec
ジョブのオーバーヘッドが減り、並列数を上げる効果が高まりました。
また、改善後はWorkflowの定義を見るだけで概要がわかるようになりました。
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を利用します。
Workspaceを利用するには、データを永続化するpersist_to_workspace
と、データを取り出す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
プロパティで指定されたパスからの相対パスでファイルが展開されます。
- persist_to_workspace:
root: /home/ruby/project
paths:
- vendor/bundle
例えば上記のように記述した場合、job-a
で/home/ruby/project/vendor/bundle
がWorkspaceのvendor/bundle
に永続化されます。
- persist_to_workspace:
root: /home/ruby/project
paths:
- node_modules
次にjob-b
では/home/ruby/project/node_modules
がWorkspaceのnode_module
に永続化されます。
- 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
の書式を用いて記述することができます。
Workspaceに永続化された時点で格納されたデータが他のジョブから参照可能となる
persist_to_workspace
ステップが呼び出されるごとに、データがレイヤーとして積み重ねられていきます。attach_workspace
ステップで取り出す際は、その時点までに格納されたデータが指定されたディレクトリ配下にすべて展開されます。
この図の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とReusing Configが使えるようになったため、このタイミングで置き換えました。
YAMLのアンカー&エイリアスの問題点はステップ1つにしかアンカーが張れないことが大きかったです。Reusing Configであれば、意味のある複数ステップの塊を1つのCommandにまとめることができます。
具体例
ここでは実際にOrbsとReusing Configを使っている箇所をピックアップして紹介します。
ソースコードのチェックアウト
CrowdWorksのリポジトリは約1.3GBあり、そのままgit clone
すると非常に時間が掛かります。そのためShallow Cloneを使っていたのですが、これをOrb化したので参照するようにしました。
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さんのruby-orbsを上記同様Commandにラップして使っています。
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への永続化は選択できるようにしておきました。
parameters:
persist-to-workspace:
description: インストールしたGemをWorkspaceに永続化するかを指定します。
type: boolean
default: false
when
ステップを使ってパラメーターの値がtrue
になるときだけWorkspaceへ永続化するようにしています。
- when:
condition: << parameters.persist-to-workspace >>
steps:
- persist_to_workspace:
root: .
paths:
- vendor/bundle
- .bundle/config
bundle-install
のパラメーターはcache_key_prefix
とbundle_extra_args
を指定しています。
- 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のオフィシャルイメージをベースとしたDockerイメージを使っているのですが、環境変数BUNDLE_PATH
とBUNDLE_APP_CONFIG
が定義された状態になっています。
$ 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
と異なる場所に変わります。
$ 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
そのため、Executorのenvironment
設定にはBUNDLE_PATH
ではなく、BUNDLE_APP_CONFIG
をworking_directory
直下の.bundle
ディレクトリに設定し、path
が設定されたconfig
を一緒にWorkspaceに永続化します。
$ 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
が設定された状態にすることができます。
$ 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分で完了するようになりました
実はもっとrspec
ジョブの並列数を上げれば速くなるのですが、Pull Requestのチェックを行う他のサービスを待つ状態になったことと、まだジョブのDockerのイメージのpullに時間が掛かるというオーバーヘッドが残っているため、いったん並列数を上げるのを止めています。
また、CircleCI 2.1のOrbsとReusing Configも活用することで、より可読性を高めることができます。
これらの機能を活用してメンテナンス性の高いconfig.yml
にしていきましょう。
明日のAdvent Calendarは@k-waragaiさんの社内でのVR活用についてです
残り7日、引き続きクラウドワークス Advent Calendar 2018をよろしくおねがいします