『スタディサプリENGLISH』web フロントエンドのCIを高速化した話

こんにちは、見習いCI庭師の長谷川竜也と申します。普段は趣味のサイトを作ったりキーボードを作ったりしています。

私は RECRUIT Job for Student 2021 Summerに参加し、webフロントエンドエンジニアとしてスタディサプリENGLISHの業務に携わりました。

今回は、その期間中に取り組んだタスクの中からCIを高速化した件についてお話します。

はじめに

技術スタック・アーキテクチャは同時期に参加されている矢田さんの記事にて紹介されているため、本記事では割愛します。

web チームの CI は JavaScript で書かれています。以前は ShellScript で書かれていましたが、保守・運用を効率化すべくチームの共通言語である JavaScript で書き直された経緯があります1)実際に私もすぐにキャッチアップできました!

また、CI/CD は GitHub Actions (以降 GHA と呼ぶ)と CircleCI で構成されており、GHA を着火剤にCircleCI 側のロジックを動かしています。

CI周りのディレクトリ構造は以下のようになっています。

├── .circleci
│   ├── config.yml
│   └── scripts
│       ├── commons.js
│       ├── run_compile.js
│       └── run_lint.js
└── .github
    ├── ISSUE_TEMPLATE
    ├── PULL_REQUEST_TEMPLATE.md
    └── workflows
        ├── create_codegen_pr.yml
        ├── renovate_additional_check_items.yml
        ├── run_check.yml
        ├── run_vrt.yml
        └── scripts
            ├── sync_issue_functional_component.js
            ├── sync_issue_scss.js
            └── sync_issue_tsc_errors.js

GHAにはテスト以外にも便利ツールとして様々なものが実装されていています。
上記ではOpenAPI で書かれた API 定義から スキーマのTypeScript型定義を作成したりRenovateが作成するプルリクエストに追加チェック項目を付与させるもの等があります。
また、.github配下のscriptsディレクトリにはプロジェクト内にある SCSS ファイルや Class Component の数を計測して、 GitHub Issues へ張り出すワークフローが書かれています。当初このプロジェクトでは、これまでスタイル定義には CSS moduleを、React のコンポーネントは Class componentで実装されていたのですが、カスタムフックによる部品化の推進・型制約付けやすい・ビルドチェーンをシンプルにできる等の理由から、現在はそれぞれ CSS-in-JS と Functional Component への移行を進めています。

このように、過去に採用されていた技術の置き換えも見える化がされています。

保守・運用の効率化

思い立つまで

CI/CDの速度は開発体験を大きく左右します。一つのテストに30分以上かかる環境ではどのようなエンジニアでも不満を感じるでしょう。

一方、webチームでは既にCircleCIのcacheparallelismを用いることでかなり最適化が進められています。具体的には2つのジョブが並列で走っており、Visual Regression Testing で10分、その他 Unit Test/Lint/Build 等で8分かかっています。

上のfull_checkがbuild・compile等のテスト 下がVRTテスト
また、プロジェクト自体はモノレポで構成されており、複数パッケージが一つのリポジトリにまとまっています。

このような大規模プロダクトのテストにおいても10分程度で終わるよう工夫されていましたが、現状でもまだ改善できる点がありました。なぜなら、webチームではyarn <package> buildで各パッケージのビルドを行っていますが、逐次実行する以下のような書き方になっていたからです。

yarn catalog build && yarn coach build && yarn main build && <省略>

各パッケージに依存関係が無いにも関わらず、直列に実行しテストを行うのは時間の無駄です。例えば4つのパッケージを直列実行から並列実行にすれば、実行時間を四分の一に短縮できます。

今回私はこの各パッケージのビルドを並列実行させる事に取り組みました。

直列実行から並列実行へ

仮に実行するパッケージが決まっている場合、単純にCircleCIのJobs parallelismを用いることで並列化できます。しかし、WebチームではプルリクのBaseBranchとCompare Branchで差分を取得し、変更のあるパッケージのみのcompile等を実行しています。つまり、job分割数が動的に定まるためJobs parallelismを用いるのは難しいと判断しました。

そこで、巨大な計算リソース上でマルチプロセス実行させる方針を立てました。一見難しそうに感じますが、世の中には便利なツールがいくつかあります。数ある中から、npm-run-allGNU parallelの二つに絞りました2)煩雑になるため fork()worker_threads 等は省いています。

npm-run-all
複数のnpm-scriptsを実行するためのコマンドラインツールです。Nodeの環境にて実行可能かつ、シンプルな記述でnpm-scriptsを順次・並列実行できます。
node moduleでの管理であるため、webチームでのメンテナンス性が高いです。
GNU parallel
Unix系OSのコマンドラインユーティリティで、シェル上でコマンドの並列実行を可能にしてくれます。豊富なオプションや機能があり便利な上に、シンプルな記述で並列実行が可能です。
実行するOSを考えてインストールを分けるとロジックが重くなります。
(デフォルトで処理結果をバッファして出力してくれる機能がありますが、エラー時にバッファ内容が吐き出されず恩恵を受けることができなかった)

どちらも実装しテストを行ったうえで、今回はnpm-run-allを選択しました。

両者のパフォーマンスに大差がない中、重視した点はメンテナンス性です。CI周りは常に誰かがメンテナンスしているわけではありません。チームメンバーは普段フロントエンドの機能開発などを行っているため、これらの処理を目にするのはよほどCI周りでエラーが発生したときくらいです。そのため、CI周りのロジックはよりチームメンバーが理解しやすいコードである必要があります。

上記の2つはどちらもシンプルな記述で並列実行を可能にしますが、GNU parallelでは実行環境に応じたインストール方法を別途追加する必要があります。そのため、全体を通してシンプルにわかりやすく記述できる方を選択しました。

これらの試行錯誤を経て最終的に以下の関数を追加しました。

/**
 * 与えられる任意のpackageをコンパイルする
 *
 * @param packages {string[]} コンパイルするパッケージ
 */
function runCompile(packages) {
    if (!packages.length) return;
    if (packages.length === 1) {
        const command = `yarn ${packages[0]} build`;
        execSync(command, { stdio: 'inherit' });
        return;
    }
    if (packages.length > 1) {
        const command = `yarn npm-run-all --parallel ${packages.map(value => `"${value} build"`).join(' ')}`;
        execSync(command, { stdio: 'inherit' });
    }
}

テスト対象のパッケージのみを受け取りnpm-run-allで並列実行しています。
これは仮にhogepiyoというパッケージに変更があるプルリクが上がった場合、

yarn npm-run-all --parallel "hoge build" "piyo build"

といったコマンドを作成し実行します。パフォーマンス向上も本来の目的であるため、パッケージ一つのみの変化である場合はそもそも並列処理をする必要もありません。

実行結果

lint処理もcompile同様に直列実行だったため、こちらも並列実行に移行しました。

その結果、元々8分以上かかっていた処理を約5分半にまで短縮できました。

Before
After
約30%もの削減に成功し、開発体験はさらに向上。チームの方も喜んでいました。

まとめ

今回はbuildやlintの実行時間を短縮しましたが、VRTでは未だ10分以上かかっています。こちらも並列の仕方や分割数にまだまだ改善の余地があります。

また、最近CircleCIにはcpuやramの使用率も確認できる機能が追加されていました。

可視化されることによって、パフォーマンスチューニングのモチベーションが刺激されることでしょう。

ぜひ皆さんも快適なCIライフをお送りください😸

脚注

脚注
1 実際に私もすぐにキャッチアップできました!
2 煩雑になるため fork()worker_threads 等は省いています。