Posted at

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


はじめに

クラウドワークス 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ジョブにいろいろなものが詰め込まれていました。これだと中で何が行われているかわかりません。


Before

workflows:

version: 2
build:
jobs:
- build
- validate_factory

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

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

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


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を利用します。

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プロパティで指定されたパスからの相対パスでファイルが展開されます。


job-a

- persist_to_workspace:

root: /home/ruby/project
paths:
- vendor/bundle

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


job-b

- persist_to_workspace:

root: /home/ruby/project
paths:
- node_modules

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


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の書式を用いて記述することができます。


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のアンカーとエイリアスで記述の重複を回避していたのですが、せっかくOrbsReusing 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_prefixbundle_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パラメーターにはGemfileGemfile.lockの不一致を検出できる--deploymentを渡しています。

CrowdWorksではRubyのオフィシャルイメージをベースとしたDockerイメージを使っているのですが、環境変数BUNDLE_PATHBUNDLE_APP_CONFIGが定義された状態になっています。


オフィシャル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と異なる場所に変わります。


--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

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


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が設定された状態にすることができます。


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:

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

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

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

明日のAdvent Calendarは@k-waragaiさんの社内でのVR活用についてです:santa_tone1:

残り7日、引き続きクラウドワークス Advent Calendar 2018をよろしくおねがいします:christmas_tree: