KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

GitHub Actions上でテストを約3倍早くした話

はじめに

こんにちは、LINE上で動くおくすり連絡帳 Pocket Musubi というサービスを開発している種岡です。
ある日チーム内メンバーから CI実行時間がとても長くなり困っている というアラートが発せられました。
実際に確認しに行くと、開発初期の頃は5分ぐらいだったテストが、いつの間にか 20分 以上にもなっていました。
待ち時間は、DX体験を損なうだけでなく、本来できたはずである付加価値を生む開発時間を奪う側面も持ち合わせており、即刻対処すべき案件と捉えテストを早くするタスクに取り掛かりました。
結果、当サービス比ではありますが、3倍ほど早くすることができました。
そこで備忘録がてらこちらにまとめてみることにしました。

やったこと

全体の概要図は以下のようになります。

Actions.drawio.png (4.3 MB)

現状から対処方針の検討

20分以上かかっていたワークフローの詳細はこちらです。
この中で時間がかかっているステップを洗い出し、対応可能なものを抜粋しました。(赤枠)

base.png (67.1 kB)

名前 概要
Setup mysql テスト時に必要なmysqlの起動(dockerファイル経由)
Package Install npm installの実行
Run tests Jestフレームワークによるテスト実行
Check CDK NAG CDKの静的セキュリティチェック

cdk-nagの削除

cdk-nagとは、CDKでデプロイするリソースに対する静的解析ツールです。
作成されるcloudformationに対して、危険な設定をしていないかをチェックしてくれるので重宝しています。
一方で、

  • CIが頻繁に回るDEV環境で実行するのはtoo muchでは?
  • STG環境で実行してチェックしておけば良いのでは?

という意見が採用され、DEV環境からは削除しました。

サービスコンテナを利用したMySQLの起動

GitHub Actions側で用意しているサービスコンテナにはMySQLのものあります。
使い方は簡単で、servicesキーワード配下に以下の設定を記載するだけです。
optionsのインスタンスの起動確認オプションも忘れずに追加しておきます。

services:
  db:
    image: mysql:5.7
    ports:
      - 13306:3306
    env:
      MYSQL_ROOT_PASSWORD:
      MYSQL_DATABASE: hoge
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
    options: >-
      --health-cmd "mysqladmin ping"
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

今までは自前のDockerファイル経由でMySQLを立ち上げていた時に比べると、1分ほど早くなりました。

use-mysql-service-container.png (76.9 kB)

マトリクスを使ってJestのテストを並列化

GitHub Actionsのマトリクスを使うことでJobを並列化できます。
一方で、Jestには、shardオプションがあり、テストケースを分割できます。
この分割されたJobそれぞれに、分割されたjestのテストケースを割り当てることで、テストの並列化が実現できます。

テストが複数並列して動作するため、テストで利用するDBのインスタンスも並列化した分増やす必要があります。
ここでは以下のようにJestのshardオプションの値に対応したDBを見に行くようにテストロジックを修正しています。

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      matrix:
        shard: [1/2, 2/2]
    services:
      db:
        image: mysql:5.7
        ports:
          - 13306:3306
        env:
          MYSQL_ROOT_PASSWORD:
          MYSQL_DATABASE: hoge
          MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      db2:
        image: mysql:5.7
        ports:
          - 13307:3306
        env:
          MYSQL_ROOT_PASSWORD:
          MYSQL_DATABASE: hoge
          MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps
      - name: Run tests
        shell: bash
        run: SHARD_IDX=${{ matrix.shard }} npx jest --detectOpenHandles --shard=${{ matrix.shard }}
// testロジック側のDB接続部分の設定
import { createConnection } from "mysql";

export async function setDbConnection() {
  let port;
  if (process.env.SHARD_IDX === '1/2') {
    port = 13306
  } else if (process.env.SHARD_IDX === '2/2') {
    port = 13307
  }

  const connection = createConnection({
    host: "localhost",
    user: "root",
    password: "",
    multipleStatements: true,
    port: port,
  });

実際に、4つJobを並列化させた結果、テストによって処理時間のばらつきはあるものの、概ね処理時間は4等分されました。
それに伴い全体の待ち時間もぐっと減らすことができました。

shared-mysql.png (65.1 kB)

並列化すると各Job毎に課金されるので注意

並列化することでJob毎に課金されます。
そのため、待ち時間は短くなったが、支払うお金は増えたということにもなりかねません。
待ち時間とコストを天秤にかけ良い落とし所を探す必要がありそうです。

shared-billing.png (50.2 kB)

cacheアクションの利用

npm installの実行をスキップすることを目的として actions/cache を利用しました。
利用方法はGitHubのドキュメントを参考にし、以下の設定を追加しました。

- name: Cache node modules
  id: node_modules_cache_id
  uses: actions/cache@v3
  env:
    cache-name: cache-node-modules
  with:
    # ドキュメントだと~/.npmとあったが、node_modulesのディレクトリを指定しないと動かなかった
    path: '**/node_modules'
    key: node-modules-${{ runner.os }}-${{ hashFiles('.node-version') }}-${{ hashFiles('**/package-lock.json') }}

- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
  run: npm install

導入後比較

node_modulesがキャッシュされ30秒ほど処理が早くなったことを確認することができました。
はまりどころとしては、実行時にキャッシュがヒットしないという現象に遭遇しました。
Cacheのスコープのドキュメントを読むと

The cache is scoped to the key, version and branch. The default branch cache is available to other branches.

とあり、キャッシュを構成する要素が、キーやバージョンやブランチの影響を受けるとありました。
上記の設定の場合、同じブランチのテストを再実行したことで、1回目に作成されたキャッシュを無事2回目で参照することができました。

before

no-cache.png (114.7 kB)

after

cache-hit.png (121.3 kB)

actions/setup-nodeのキャッシュについて

actions/setup-nodeのキャッシュオプションでも同じことができました。

- uses: actions/setup-node@v3
  id: setup_node_id
  with:
    node-version: 16
    cache: 'npm'

- if: ${{ steps.setup_node_id.outputs.cache-hit != 'true' }}
  run: npm ci

こちらのドキュメントから

It uses actions/cache under the hood for caching global packages data but requires less configuration settings.

actions/cacheを内部で利用しており、キャッシュのキーとして

The action defaults to search for the dependency file (package-lock.json, npm-shrinkwrap.json or yarn.lock) in the repository root, and uses its hash as a part of the cache key.

とあることから、root直下にあるpackage-lock.jsonのハッシュをキーとして利用しているとのことでした。
そのため、キャッシュがヒットしたかの確認もactions/cacheと同じ方法で判断することができました。

おわりに

さいごに、CIを早くするということに関しては、システム運用アンチパターン の7章が参考になり、モチベーションを上げてくれました。
特に以下の部分に関しては今回のタスクを通じても腑に落ちる部分がありました。

  • 最大の無駄は、待ち時間が発生すること
  • 待ち時間が発生するということは、本当はプロダクトに付加価値を与えるために使えた時間を消費してしまっていること
  • 待ち時間短縮のタスクは、大きく表面化しないと着手しないが、結局対処する時に一番時間かかってしまい非効率であること
  • バックログに入れないで、優先的に対応するということをチームの文化として取り組むこと

引き続き、プロダクトの付加価値を最大化させるために待ち時間を少しでも削る努力を続けて行こうと思いました。