AWS CodeBuildでのRailsアプリのdocker buildを早くしたい

メディアプロダクト開発部の後藤(id:mtgto)です。

世間ではバレンタインですね。最近私はハンドメイドスイーツオークションというWebサービスの立ち上げをやっていました。ライブ配信でバレンタインのスイーツを作っていただき、ライバーのファンがスイーツをオークション形式で実際に購入できるというサービスです。 私のチームでは仮想DOMを扱うのにVue 2を使うことが多いのですが、今回は期日がずらせないイベントだったことや必要なライブラリがReact版しか提供されていなかったこともあり私がVueより使い慣れているReactで作りました。

本記事ではAWS CodeBuildでのRailsアプリのDocker buildを早くするための工夫を紹介します。

docker buildを早くしたい理由

クックパッドでは多くのアプリケーションを運用していますが、その多くはAWS ECS上で動いています(ref. ECS インフラの変遷)。 デプロイにかかる時間の大部分を占めるのはCIおよびDocker imageのビルド時間なのでコード修正→ステージング環境へのデプロイ→動作確認→デプロイのデベロップルーティンを日に何度もくり返すような生活をしているときに発生する待ち時間を減らすためにもdocker buildにかかる時間を減らすことができれば幸せになります(主に私が)。

まずは現状どれだけかかっているかを見てみましょう。今回使うのは生まれてから数年経っておりVue.jsを含むフロントエンド実装がそこそこあるRailsアプリです。

f:id:mtgto:20220214133414p:plain

CodeBuildのフェーズ詳細で確認すると、現状のDocker buildのためのCodeBuildジョブには10分弱ほどかかっていることがわかりました。

今回はこれを半分の時間にするのを目標としてみます。

今回のRailsアプリケーション

今回の実験に使用したRailsアプリケーションです。数年の歴史を経てかなりのページ数を持っています。

  • Ruby 420ファイル
  • TypeScript 180ファイル

PROVISIONINGフェーズにかかる時間が長い

まずはAWS CodeBuild Console上の最近のビルド履歴のフェーズ詳細からどのフェーズに時間がかかっているかを確認しましょう。すると PROVISIONING というフェーズに232秒かかっていることがぱっと目につきます。「CodeBuild PROVISIONING 遅い」で検索すると、CodeBuildに利用しているDockerイメージが古いと環境構築にかかる時間が長くなってしまいそれがPROVISIONINGフェーズが長くなる原因となるようです。実際、今回実験に使用したプロジェクトでは aws/codebuild/standard:2.0 という大変古いバージョン 1 を使用していました。 これを「ビルドの詳細」→「環境」から現行の最新の aws/codebuild/standard:5.0 に変更し、ビルドしてみます。同時にDockerのバージョンも18から20に変更します。

f:id:mtgto:20220214133450p:plain

PRIVISIONINGフェーズで232秒かかっていたのが95秒になりました。これだけで2分以上改善できました。

BUILDフェーズの地道な改善

次になんとかしたいのは309秒かかっているBUILDフェーズです。このフェーズではDockerfileをもとにdocker build && docker pushを行なっているため、工夫次第で改善できそうです。

今回のプロジェクトのDockerfileはマルチステージビルドを使っており、後述の yarn installbundle install をDockerのレイヤーキャッシュでなるべくスキップするような工夫はすでにされていました。それにもかかわらず5分近くかかっているのであれば感覚的にはこれは短縮できそうです。さっそくプロジェクトを見ていきましょう。

babel-loader → swc-loaderを使う

swc (Speedy Web Compiler) はRustで書かれたJavaScript / TypeScriptのトランスパイラで、babelよりも早いという特徴があります。Parcel v2ではTypeScriptのトランスパイルにデフォルトでswcを使ってくれたりするので、知らない間にswcのお世話になっているかもしれません。

今回実験に使用したプロジェクトではCodeBuildでのwebpack buildに約192秒かかっていました (JavaScript/TypeScriptの他にscss/cssの処理やコピーだけですが画像アセットの処理を含みます)。これならbabelからswcに変更するメリットがありそうです。

実際にbabel-loaderからswc-loaderに切り替えたところCodeBuildでのwebpack buildは192秒→174秒に改善しました。

チャンク分割

swc-loaderの導入により早くはなりましたが劇的には早くなりませんでした。この背景として今回のプロジェクトでは元々出力JSファイル数が多く、またチャンク分割 (splitChunk) の設定もしてないことが原因と思われました。

まずはこの仮説が正しいかを調べてみましょう。 webpack-bundle-analyzerを使って出力されるJavaScriptにどのようなパッケージが含まれているかを見てみたところ、このプロジェクトでは32個のJavaScriptファイルが生成され、そのうち4ファイルが1MBを越えていました。同じnpmパッケージを複数のJSがもっていることもわかったため、webpackのチャンク分割の設定を行い共通部分をまとめるようにしたところCodeBuildでのwebpack buildの実行時間は174秒から50秒に一気に短縮されました。

今回は時間の都合で行っていませんが、webpack-bundle-analyzerで見たところかなりの部分をaws-sdkが占めていることがわかりました。aws-sdkをv2からv3にアップデートすることで必要なサービス用のライブラリだけをインストールすることができるようになるためTree shakingなどビルド時間や生成ファイルサイズのさらなる改善も得られそうです。

AWS SDK for JavaScript v3

それでもだめなときの最終手段「金」

これまでいくつかの改善を行ってきましたがあとすこしだけCodeBuildの実行時間を半分にするには足りませんでした。そこで最後の手段である「お金で殴る」を使ってみることにしました。

CodeBuildではビルドの設定で利用するマシンスペックを変更できます。これまで使用していた「3GBメモリ / 2vCPU」で不足なのであればその上の「7GBメモリ / 4vCPU」を使用してみましょう。

これによりBUILDフェーズが56秒短縮されました。まさに「時は金なり」です。

ちなみにAWS CodeBuild の料金はビルド一分あたりの料金で計算され、だいたいvCPUが倍になれば料金も倍になります(分単位に切り上げ)。今回のプロジェクトのようにアセットのコンパイルにかかる時間がボトルネックな場合には強いマシンスペックを選ぶことでビルド時間が短縮されることが期待できます。コスパを考えつつスペックを選択しましょう。

実施済みの改善ポイント

今回実験に利用したプロジェクトでは既に実施済みでしたが、以下のような設定もビルド時間削減が期待できます。

webpackerをやめる

docker buildの高速化とは直接つながりませんが、webpackerをやめて直接webpackを使えるようにすることで、RubyGemsのインストールをアセットコンパイルの依存から外すことができます。request specやfeature specを実行するためにはRailsからアセットが利用できないといけないため、アセットコンパイルをBundle installと並列して行うことによりCIの時間短縮も期待できます。

Rails 7からはWebpackerは標準で入らなくなり、2022/1/19にはwebpackerは以降セキュリティパッチのみの対応で機能追加は後続のShakapackerへの移行が必要になりました。

Webpacker自体は悪ではないとは思いますが、なにをやっているかわからずRailsやwebpackのバージョンアップのたびにwebpackerへのマイグレーションで苦労していたので私の周りでは脱webpackerすることが多いです。

Webpackerを外すときには config/webpacker.yml および config/webpack の git logやgit blame を見てどんな修正をしているか確認します。大した修正をしてなさそうだとわかったらwebpackerを外してしまって1からwebpackの設定をしてしまうのが楽なんじゃないかなと思っています (これはWebpackおじさんがチームにいる場合なので異論は認めます)。

一度webpackerを外してしまえば今回やったようなbabel => swcの導入などもしやすくなるでしょう。

Dockerレイヤーキャッシュを活用する

CodeBuildでもDockerレイヤーキャッシュを利用する設定ができるためライブラリのインストールをDockerのレイヤーキャッシュを使ってスキップすることを期待できます。

Webアセットを含むRailsアプリの場合、

  • package.json, package-lock.json (or yarn.lock) だけを先にCOPYしてからnpm install && webpackする
  • Gemfile, Gemfile.lock だけを先にCOPYしてからbundle installする

のようにライブラリのインストールに必要なファイルだけを先にCOPYしておくことで、それ以外のファイルを変更しても上記のファイルが更新されない限りはキャッシュが有効になることを期待できます。

まとめ

今回実施した工夫によりCodeBuild実行時間は9分半から4分44秒になり、目標とした半分の時間にすることができました。

  • 最適化前にかかっていた時間 570秒
  • 利用イメージのバージョンアップによりPROVISIONINGフェーズの改善 -137秒
  • babelからswcに変更 -18秒
  • webpackのチャンク分割 -124秒
  • CodeBuildのスペック変更 -56秒
  • トータル 570 - 335 = 235秒
改善前 改善後
f:id:mtgto:20220214133414p:plain f:id:mtgto:20220214133607p:plain

これ以上の最適化は今回のプロジェクトではコスパが悪そうなので、まずは社内の別のプロジェクトでもCodeBuildで古いイメージを使ってないかを確認していこうと思います。

この記事がCodeBuildユーザーやDocker Buildが遅くて困っている方の参考になれば幸いです。


  1. 古すぎても4.0未満はもはやAWS Console上で選択できません。