未来の開発体験を作る技術基盤チーム | Wantedly Engineer Blog
こんにちは、Wantedly DX Squad の 大坪です。 ...
https://www.wantedly.com/companies/wantedly/post_articles/242144
Photo by Glen Wheeler on Unsplash
こんにちは、Wantedly の Developer Experience Squad で生産性に関わるあらゆることに手を出している大坪です。今回は巨大化した Rails の CI 高速化手法について解説します。
上にリンクした DX Squad のミッションでも書いていますが、CIは早ければ早いほどよいと考えています。遅い CI は Pull Request の merge までのリードタイムを長くするという短期的なデメリットだけでなく、開発者の test を書くモチベーションを削いでしまい長期的にもプロダクトの安定性を悪化させます。
「他の人がテストを書いてくれない」「なんでこのコードはテストされていないのか」と思ったことは誰しもあるでしょう。そんな状況において自分が大事だと考えているのは「テストを書くことがお得である」と感じることのできる開発環境を用意することです。極論ですが「テストが実装も実行も一瞬」である環境があるとすれば、そこではテストを書くことのコスパが非常に良いため自然とカバレッジは高くなるでしょう。
ユーザーが思った通りにプロダクトを使ってくれないときに多くの人はプロダクトを治すことを考えるでしょう。同じように Developer Experience を改善する上でも社内のエンジニアが思ったとおりに開発をしてくれなかったらその環境を変えることで自分たちがより良いと思う開発スタイルを浸透させることができます。CI が早ければそれだけ「テストを書くことがお得」だと感じさせることができます。
例として下のような状況の時本番環境に問題のあるコードがデプロイされてしまった時、ロールバックで解決できないこともあります。
こういった時には「とにかくすぐに修正を deploy したい」ということもあるでしょう。CI が早いとこれが達成されるまでの時間が短くなり、新たな問題を起こしていない自信をもって deploy ができます。
ここまででCI が早いことのメリットを述べました。しかしながら、CI は早くしようとしないと遅くなっていくので高速化を定期的に行っていく必要があります。
CIの高速化は、ともすれば「他に応用できない局所的なハックを並べる場所」という見方をする人もいるかもしれませんが、クリエイティビティと科学的なアプローチを武器にできるエンジニアとして取り組むには非常に面白い問題の一つだと考えています。この点において ISUCON が好きな人にとってはピッタリのタスクだと思います。
筆者はCI高速化とISUCONに特に下の3つの共通点があると感じています。
1,2 については割と自明だと思うので飛ばします。3点目については意外と忘れがちなので詳しく説明しようと思います。ISUCON では「全て再実装」や「全てオンメモリ」などのクリエイティブなアイデアがうまくいくこともありますが、CI高速化でもそうであることがあるでしょう。例えば「CI を別サービスに載せ替える」「Microservice に切ってテストを軽くする」みたいな方針も選択肢に入れるべきです。CI で「守りたいことやりたいことは何であるか」を突き詰めて考えることで思っても見なかった解決策を見つけることができます。
一般のプロダクト開発では「全てをスクラッチから書き直す」という決断はなかなか勇気がいりますが CI の設定だと割と簡単にできてしまいます。問題がある程度限定されているという前提があるために「何でもできるぞ」という感覚になれる点が自分が CI 高速化が好きな理由の一つです。また、プロダクトだと「ある機能を落とす」という意思決定は簡単にできないこともあるでしょう。しかしCIにおいては「あるテストを落とす」というような多少大胆なアイデアも実現しやすくなります。
つまりCI高速化は面白い問題でありつつ短時間で大胆な決断をしながらゴリゴリ進める楽しいタスクであるというのが筆者の考えです。
ここまで「CIは早いほうがいい」「早くする考え方」の2つの観点から概論を述べたところで実際の例をとってどのように高速化をしていくのか考えていきます。基本的には Wantedly の社員に一つの社内ドキュメントとして伝わることを前提に書きます。したがってここからは一般論というより我々の環境という特殊ケースに付いて扱います。社外の方には、一つのケーススタディを届けられたらと思います。
今回は wantedly 最大の Rails wantedly/wantedly (以下 wtd/wtd )の例を紹介します。このサイズ感を説明します。
+----------------------+--------+--------+---------+---------+-----+-------+
| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers | 32864 | 27513 | 473 | 2500 | 5 | 9 |
| Helpers | 8943 | 7519 | 1 | 921 | 921 | 6 |
| Jobs | 3079 | 2449 | 117 | 223 | 1 | 8 |
| Models | 44078 | 29968 | 717 | 2735 | 3 | 8 |
| Mailers | 1892 | 1582 | 27 | 137 | 5 | 9 |
| JavaScripts | 24487 | 18519 | 394 | 3904 | 9 | 2 |
| Libraries | 20473 | 16694 | 147 | 713 | 4 | 21 |
| Task specs | 395 | 300 | 0 | 1 | 0 | 298 |
| Form specs | 255 | 192 | 0 | 0 | 0 | 0 |
| Mailer specs | 1534 | 1292 | 6 | 16 | 2 | 78 |
| View_model specs | 377 | 310 | 0 | 0 | 0 | 0 |
| Feature specs | 16261 | 12599 | 0 | 81 | 0 | 153 |
| Util specs | 542 | 505 | 0 | 1 | 0 | 503 |
| Model specs | 32668 | 27101 | 0 | 20 | 0 | 1353 |
| Policy specs | 665 | 548 | 0 | 0 | 0 | 0 |
| Request specs | 8375 | 7031 | 0 | 3 | 0 | 2341 |
| Grpc_service specs | 4894 | 4187 | 0 | 4 | 0 | 1044 |
| Serializer specs | 278 | 229 | 0 | 0 | 0 | 0 |
| Lib specs | 5801 | 4951 | 3 | 26 | 8 | 188 |
| Validator specs | 186 | 144 | 0 | 0 | 0 | 0 |
| Job specs | 1471 | 1223 | 0 | 1 | 0 | 1221 |
| I18n specs | 29 | 24 | 0 | 0 | 0 | 0 |
| Controller specs | 25431 | 21275 | 2 | 34 | 17 | 623 |
| Helper specs | 1188 | 990 | 0 | 2 | 0 | 493 |
| Service specs | 22241 | 18560 | 2 | 19 | 9 | 974 |
| Concern specs | 6383 | 5415 | 3 | 51 | 17 | 104 |
| Initializer specs | 47 | 34 | 0 | 0 | 0 | 0 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total | 264837 | 211154 | 1892 | 11392 | 6 | 16 |
+----------------------+--------+--------+---------+---------+-----+-------+
Code LOC: 104244 Test LOC: 106910 Code to Test Ratio: 1:1.0
このため rspec を一般的な開発用 Mac で全て直列で実行すると少なくとも2時間かかることがわかっています。このサイズのアプリケーションのテストをするためには多少複雑な設定をしないとまともな時間内にCIが返ってきません。
これは wtd/wtd の CircleCI の workflow の結果です。多少必要以上に複雑な表示になっている部分がありますが少なくともぱっと見ではとっつきにくそうです。しかし実はそこまで複雑ではありません。大事な step はtest-prepare, rspec, docker-build-2 の
3つだけで、残りは時間としても長くなく分割したほうが config が書きやすかったというだけの理由で分かれているだけの補助的なものがほとんどです。
余談ですが docker-build-2 につく謎の数字は golden-image という別手法を使うための設定を行った結果 docker-build が2種類存在し、CircleCI がそれぞれを区別するために自動でつけた suffix なので深い意味はありません。golden-image については後述します。
wtd/wtd の CI時間の大半を占める3つの job は大きく build と test の2つに分かれ並列で動きます。また rspec は test-prepare が終了した後に動くので直列の関係です。
test-prepare は bundle install,
yarn, assets:precompile
などの全ての test で必要な依存の生成を行います。一度行えば良いのでスペックの高いマシンを1つだけ使っています。対して rspec は1並列ではとてもまともな時間に返ってこないのでスペック低めのマシンを 40 並列することで時間を節約しています。
wtd/wtd の CI が遅い!と思ったときにまず見ると良いことを列挙します。
ここまでざっくり CI の内部を説明したところで高速化する方法を考えます。まず wtd/wtd では test
と build
の大きく2つのタスクが並列で並んでいるため、開発者の体験としてはこの2つの遅いほうが CI の速度として感じられます。したがってまずこのどちらが問題であるかを見極めることが大事です。多くの場合少しデータを眺めるだけでどちらを治すべきかを決定できるででしょう。
前述したとおり、実際の CI のデータからボトルネックを特定することが大事ですがwtd/wtd に対して「とりあえず効きやすい」方法をいくつか説明します。
test-prepare に5分以上かかっていたら cache key を変える(cache を clear する)
wtd/wtd では bundle、yarn の cache がそれぞれ数百MBになるためいらないものが残っていると簡単に肥大化します。本来は「ほっといても cache に不要なものが残らない仕組み」を作るのが望ましいと考えていますが webpack の build cache など、時間を追うごとに必要性が段階的に下がっていく cache もありますし、何かの考慮漏れでいらない cache が乗ってしまうこともあり、「絶対に cache が肥大化しない」という仕組みは多少の困難性がつきまといます。根本解決は色々ありえますが、遅いなと感じたときはとりあえずは cache を飛ばしてしまいましょう。CircleCI の場合は cache key を変えるという操作でこれを達成します。test-prepare
に5分以上かかっていたらこれを疑うといいでしょう。
rspec に5分以上かかっていたら 一番遅い spec file を短くする
test-prepare
ではなく rspec
が遅いとわかったとします。すると多くの場合一つの spec を早くするだけで全体がかなり早くなります。上は wtd/wtd のテストを CircleCI で40個のプロセスに分割して実行した様子です。殆どが3分台で終わっているのに飛び抜けて遅い7分後半のプロセスが一つあります。
CircleCI では file 単位でどのプロセスにどの test を実行させる仕組みになっているため、当然ながら7分かかる file が一つ存在すればどれだけ並列性を高めても体感としてそれより早くはなりません。しかしながらその遅い test を高速化すれば、それが難しければ2つの file に分割してしまえば体感がぐっと早くなります。
注: プロセスという単語は本来不適だと思いますが CircleCI 側でも呼び方が確定していないのでこのように呼んでいます。
webpack に 5分かかっていたらどうにかして早くするtest-prepare の中で行っている
wtd/wtd の frontend の build は cache のある理想的な環境では 30s くらいで終わるはずです。もしこれに分単位の時間がかかっていたら何かがおかしくなっているはずです。実際我々も webpack の build に3分かかっている状況に遭遇しましたが、「本来30sで終わるはず」という情報を持っていたため webpack を遅くしてしまった commit を特定するのにさほど時間はかかりませんでした。他の環境でも「一番うまく行っているときはどれくらいか」という情報を持っておくとと良いでしょう。
wtd/wtd において build 編は基本的に全て docker build の中なのでこの問題は実質的に「いかにして docker build を早くするか」という問題になります。実のところ docker build を片手間程度の時間で高速化する手法は今の所ありません。layer caching などの自明なものは全て取り組まれているので、何らか根本的な変更が必要になります。DX Squad では build ツールの改修などを含めた高速化を考えていますが、wtd/wtd をメインで開発するエンジニアがサクッとできることはさほど多くないでしょう。
そこで今回は現状がどのようにして高速化を達成しているかを説明します。
一般に知られる Docker の layer caching を使った高速化の弱点として、「少しでも条件が変われば再ビルドになってしまう」という点があります。Gemfile 一行が変わっただけで bundle install
が全て再実行されるという問題です。2021年の現在ではこのような問題は BuildKit で解決していくのが良い方針だと思いますが、筆者が docker build 高速化に取り組んだ当時は CircleCI では利用することができず、cache の運搬方法が問題になっていました。
そこで自分がとった解決策は「少し前の docker image から cache だけ copy してしまおう」というものです。これで簡単に高速化ができましたが、古い image からずっと運搬されるデータに依存したイメージができてしまったり cache が肥大化したりするなどの問題が考えられたため、過去の cache を一切使わずストレートに build した image を golden image とよび、通常の docker build では golden image から必要な cache を COPY
してくることで bundle
, yarn
, assets:precompile
を高速化しました。golden image は1日一回自動で build されて :golden
という tag が付けられています。
具体的には下のような Dockerfile を書いています。
# !skip-begin golden-build
COPY --from=some-registry:golden /app/vendor/bundle /app/vendor/bundle
# !skip-end golden-build
これを golden image の build 時に下のように Dockerfile を編集してから build することでゴミが溜まらないようにしています。
sed -i '/!skip-begin golden-build/,/!skip-end golden-build/d' Dockerfile
過去に何度か BuildKit に移行してみようとしたことがありますが今の所この構成より高速に build することに成功していません。
他の build ツールなどを利用することでさらなる高速化を図ることを模索中です。
上のような簡単な方法を試してもみてもっと高速したい場合はどうしたら良いでしょうか。ここでは wtd/wtd では導入していない手法を紹介することで、未来の wtd/wtd と事情の違う他のサービスの CI 高速化のヒントを提示しようとおもいます。
Launchable は落ちそうな test から実装する、そもそも落ちないと思われる test を実行しないというような考え方で test を高速化するためのツールです。我々が未導入な理由としては CI の時間のボトルネックが build であり、test の時間のほうがまだ早くできるため、導入しても大きな高速化が期待できないためです。今後の build が更に高速化できれば Launchable で更に test を高速化するという手法を取る可能性があります。
wtd/wtd はほぼ10年前から維持されているコードベースなので完全に枯れきったユーティリティとそれに付随する test もあります。これらの test に時間を使うのはリソースがもったいないのでライブラリにきったりしてしまえば test が楽になるでしょう。また、専門的かつ複雑なロジックを持つモジュールを別の microservice に切ってしまうという手もあります。もちろんインテグレーションテストが別に問題になるわけですが、少なくとも全 push に対して実行しなくても良い test は多々あるはずなのでこれを軽くするという方法がありえます。「落ちることがない test は実行しなくても良い」という考えです。
これも Lanchable 未導入の理由と同様に今のボトルネックが test ではないので、 test のためにこの道を取ることはないでしょう。しかしながら docker build の時間には大きく寄与することになれば少し事情は変わってくるかもしれません。純粋に Rails の docker image を作ることだけに集中して assets:precompile や webpack build を行なわなくて良い構成にできれば build も test も高速化できるので、「frontend を完全に別 repository に切ってしまう」という手法は期待ができます。現状ではコストが大きすぎて手を出せていませんが今後検討されるオプションの一つだと考えています。
前述の通り、wtd/wtd の test のフェーズは依存の build を行う test-prepare
と実際に test を実行する rspec
に大きく分かれています。前者が1、後者が40の並列性を持つため前半の job で作られた file 群は一度 tar ball に固めて CircleCI の内部ストレージに upload され後半の job で download されます。このやり取りに大体2分かかっています。したがって test-prepare
という job をやめて 40並列の全てが自分で依存を build するようにすれば 2分も簡単に削減できます。しかしながらこれを行うと CircleCI で使うコンピューティングリソースが増えてコストパフォーマンスがよくないためまだ行っていません。今後 build が高速化されてトータル5分などを目指す段階に入ったら検討されるオプションになるでしょう。
Golden Image による hack は BuildKit の登場によりもはや過剰になっています。前述の通りまだ BuildKit を使って同等以上の速度を出すことに成功していませんが、BuildKit 周りのノウハウが溢れている今ならよりよい構成で高速化できるかもしれません。また他の image build ツールを使う選択肢もあるでしょう。社内からでも社内からでもこれに取り組んでみたい人を WANTED しています。
Inline cache や cache mount などを上手く組み合わせれば十分な速度が出るのではないかなと思います。
CI が早い環境は高速かつ安全なプロダクト開発に必須です。それを達成するためのCI 高速化は実験と論理的思考を武器に、時にはクリエイティビティと大胆な決断も動員できるエンジニアの総合格闘技です。またある程度のところまでは数日から一週間くらいの期間で取り組めるちょうどよいサイズの問題でもあります。最高の開発環境を目指して爆速なCI を作っていきましょう。
自分ならもっと早くできるぞ?という方は是非遊びに来てください!