Timee Product Team Blog

タイミー開発者ブログ

【GitHub Actions】明日からマネできる CI 高速化テクニック 3 選

はじめに

こんにちは、タイミーでエンジニアをしている徳富(@yannKazu1)です。

タイミーではメインサービスのバックエンドを Rails で開発しています(Go を採用しているプロダクトもありますが、本記事では Rails を前提とします)。

突然ですが、皆さんのチームでは CI の待ち時間、気になっていませんか?

「Push した、コーヒー淹れた、戻ってきた、まだ回ってる……」みたいな経験は、開発者なら一度はあるのではないでしょうか。

本記事では、そんな状況を改善するために GitHub Actions 上のテスト実行パイプラインで取り組んだ 3 つの高速化テク を紹介します。どれも「知っていれば明日から試せる」くらいの温度感なので、気軽に読んでいただければと思います。


1. キャッシュの保存先を GitHub Cache から S3 に移行

課題: actions/cache が安定して速くない

最初にぶつかった壁が actions/cache の速度でした。vendor/bundle(数百 MB〜1 GB 超)の save/restore でやたら時間がかかることがあり、リストアだけで数分待たされる場面がちょくちょくありました。これはセルフホストランナーに限った話ではなく、GitHub ホステッドランナーでも起きます。

実際、公式リポジトリにも Extremely slow cache on self-hosted from time to time という Issue が立っていて、セルフホスト・GitHub ホステッド問わず同様の報告が寄せられています。

さらに私たちの場合、AWS 上のセルフホストランナー を使っているのでなおさらです。actions/cache のバックエンドは Azure Blob Storage のため、セルフホストランナーからだとインターネット経由のアクセスになり、スループットが 約 20 MB/s まで落ちる ケースも報告されています(Actuated Blog)。突発的に遅いうえに経路も遠い——これでは安定した速度は望めません。

容量面でも、リポジトリあたり 10GB の制限があります。また、7 日間アクセスのないキャッシュは自動削除されます。その結果、ブランチが増えるとすぐに上限に達し、必要なキャッシュが消えてしまうのも地味にストレスでした。

解決策: runs-on/cache で S3 をバックエンドに

そこで [runs-on/cache](https://github.com/runs-on/cache) を導入し、キャッシュの保存先を 同一リージョン(東京)の S3 バケット に切り替えました。

前述のとおり、セルフホストランナーで actions/cache を使うとスループットが ~20 MB/s まで落ちるケースがあります。一方 runs-on/cache は同一リージョンの S3 を使えるため、200 MiB/s 以上 のスループットが出ます(公式ドキュメント)。単純計算で 10 倍近い改善 です。

actions/cache とインターフェースがそのまま同じなので、uses: を差し替えて環境変数を 1 つ足すだけで移行できました。

# .github/actions/setup-ruby-with-s3-cache/action.yml
- name: Restore cache
  uses: runs-on/cache@v4.2.3-r2
  env:
    RUNS_ON_S3_BUCKET_CACHE: your-gha-cache-bucket
  with:
    path: "**/vendor/bundle"
    key: bundle-v1-${{ runner.os }}-${{ inputs.ruby_version }}-${{ hashFiles('Gemfile.lock') }}
    restore-keys: |
      bundle-v1-${{ runner.os }}-${{ inputs.ruby_version }}-
      bundle-v1-${{ runner.os }}-

なぜ runs-on/cache を選んだか

S3 をキャッシュバックエンドにする方法は他にもあります(tespkg/actions-cachewhywaita/actions-cache-s3、自前の aws s3 cp スクリプトなど)。その中で runs-on/cache にした決め手はこのあたりです。

  • 環境変数 1 つで切り替え: RUNS_ON_S3_BUCKET_CACHE を設定するだけで S3 バックエンドに切り替わる
  • 自前実装が不要: 圧縮・展開・キャッシュキーのマッチング・フォールバックなど、地味にめんどくさい部分を全部やってくれる
  • 容量無制限: S3 なので 10GB の制限もキャッシュの自動削除もなし

キャッシュキーの設計

キャッシュキーは 3 段階のフォールバック構造にしています。

bundle-v1-Linux-3.3.6-<Gemfile.lock のハッシュ>   ← 完全一致(最速)
bundle-v1-Linux-3.3.6-                            ← Ruby バージョン一致
bundle-v1-Linux-                                   ← OS のみ一致

完全一致しなくても、部分一致したキャッシュをリストアして bundle install すれば差分の gem だけで済みます。ゼロからインストールするより圧倒的に速いので、新しいブランチでもほぼキャッシュが効く状態を維持できます。

OIDC 認証で安全に S3 にアクセス

AWS へのアクセスには OIDC 認証 を使っています。長期的なアクセスキーをシークレットに保存しなくて済むので、セキュリティ面でも安心です。

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v6
  with:
    role-to-assume: arn:aws:iam::123456789012:role/your-gha-role
    aws-region: ap-northeast-1

2. マイグレーション結果をまるごとキャッシュ

課題: 毎回のマイグレーションが地味に重い

テストジョブは毎回データベースをセットアップします。ここで問題になったのが、マイグレーション数が数百を超えてくると rails db:create db:schema:load だけで 数分かかる ということ。

「schema:load だからすぐ終わるでしょ?」と思いきや、テーブル数が多いとそうでもないんですよね。

解決策: MySQL のデータディレクトリごと S3 にキャッシュ

発想を変えて、マイグレーション済みの MySQL データディレクトリ (/var/lib/mysql) をまるごと S3 にキャッシュ することにしました。要は「マイグレーション済みの DB をそのまま持ってくれば、マイグレーション自体を省略できるよね」という作戦です。

仕組みの全体像

【キャッシュの生成】                          【キャッシュの利用】
master ブランチ                               feature ブランチ

 db/migrate/** 変更                            テストジョブ起動
      or 毎日定時                                    │
         │                                          ▼
         ▼                                    S3 からキャッシュをリストア
  MySQL 起動                                  → ./tmp/mysql_data に展開
         │                                          │
         ▼                                          ▼
  rails db:create db:migrate                  MySQL 起動(データマウント済み)
         │                                          │
         ▼                                          ▼
  ./tmp/mysql_data を S3 に保存                rails db:migrate(差分のみ)
                                                    │
                                                    ▼
                                               テスト実行

キャッシュの生成: master で定期的に焼き直す

master ブランチでマイグレーションファイルが変更されたとき、または毎日定時に、専用のワークフローがキャッシュを更新します。

# .github/workflows/update-migration-cache.yml
on:
  push:
    branches: [master]
    paths:
      - 'db/migrate/**'
      - 'db/schema.rb'
      - '.github/workflows/update-migration-cache.yml'
      - '.github/actions/migration-hash/**'
  schedule:
    - cron: '0 2 * * *'  # 毎日 UTC 2:00(JST 11:00)に実行
  workflow_dispatch:       # 手動実行も可能

やっていることはシンプルです。

  1. MySQL コンテナを起動(データディレクトリを ./tmp/mysql_data にマウント)
  2. rails db:create db:migrate でフルマイグレーション実行
  3. ./tmp/mysql_data をまるごと S3 にアップロード
- name: Run database migration
  run: bundle exec rails db:create db:migrate

- name: Save migration cache
  uses: runs-on/cache/save@v4.2.3-r2
  env:
    RUNS_ON_S3_BUCKET_CACHE: your-gha-cache-bucket
  with:
    path: ./tmp/mysql_data
    key: test-${{ runner.os }}-${{ runner.arch }}-mysql${{ steps.migration-hash.outputs.mysql_version }}-${{ runner.environment }}-db-migration-${{ steps.migration-hash.outputs.hash }}

キャッシュキーの設計: 何をキーに含めるかが大事

キャッシュキーには地味に気を使っています。

test-Linux-X64-mysql8.0.28-self-hosted-db-migration-<db/schema.rb のハッシュ>
     │     │        │            │                        │
     OS   ARCH   MySQL Ver   ランナー環境           スキーマハッシュ

ポイントは db/schema.rb のハッシュを含めていること。マイグレーションの内容が変われば schema.rb も変わるので、自動的に新しいキャッシュが生成されます。MySQL バージョンやアーキテクチャもキーに入れているのは、バイナリ非互換でハマらないための保険です(一度やらかしました……)。

キャッシュの利用: Composite Action で再利用しやすく

キャッシュの利用ロジックは Composite Action に切り出して、RSpec だけでなく Steep(型チェック)など他のワークフローからも使い回しています。

# .github/actions/setup-mysql/action.yml
- name: Create MySQL data directory
  run: mkdir -p ./tmp/mysql_data

- name: Restore migration cache
  id: cache-hit-check
  uses: runs-on/cache/restore@v4.2.3-r2
  env:
    RUNS_ON_S3_BUCKET_CACHE: your-gha-cache-bucket
  with:
    path: ./tmp/mysql_data
    key: test-${{ runner.os }}-${{ runner.arch }}-mysql${{ mysql_version }}-${{ runner.environment }}-db-migration-${{ hash }}

- name: Start MySQL service with docker compose
  run: docker compose -f compose.ci.yml up -d mysql8

Docker Compose では、リストアしたデータディレクトリをそのままボリュームマウントします。

# compose.ci.yml
services:
  mysql8:
    volumes:
      - ./tmp/mysql_data:/var/lib/mysql

MySQL が起動すると、キャッシュ内のデータファイルがそのまま認識されるので、マイグレーション済みのデータベースが即座に使える 状態になります。

テストジョブでの分岐: キャッシュがあれば差分だけ

各テストジョブでは、キャッシュがヒットしたかどうかで処理を分岐しています。

- name: RSpec
  run: |
    if [ "${{ steps.setup-mysql.outputs.cache_hit }}" == "true" ]; then
      echo "Using cached migration data, running incremental migration"
      bundle exec rails db:migrate       # ← ブランチ固有の差分だけ
    else
      echo "No cache found, running schema load"
      bundle exec rails db:create db:schema:load
    fi
  • キャッシュヒット時: master のマイグレーション済みデータが復元されているので、db:migrate で差分だけ適用。たいていは数秒で終わります
  • キャッシュミス時: MySQL バージョンアップ直後などキャッシュがない場合は db:schema:load にフォールバック

この仕組みのおかげで、並列のテストジョブそれぞれで数分かかっていた DB セットアップが数秒になりました。体感で一番効果が大きかった施策かもしれません。


3. CI 用 MySQL のパフォーマンスチューニング

課題: デフォルト設定の MySQL が意外とボトルネック

テスト環境の MySQL をデフォルト設定のまま使っていたのですが、ある日ふと気づきました。テストでは各テストケースごとに BEGIN / ROLLBACK やテーブルのクリーンアップが走るので、書き込みが尋常じゃない量になっている んですよね。

デフォルト設定だと、コミットのたびにディスクへの fsync が走ります。本番では安全のために必要ですが、テスト環境では……正直、オーバースペックです。

解決策: テスト環境に限定して、耐久性よりパフォーマンスを優先する

CI 専用の compose.ci.yml で、データ耐久性を思い切って犠牲にして、書き込みパフォーマンスを最大化 しました。

# compose.ci.yml
services:
  mysql8:
    image: ${MYSQL_IMAGE}
    command: >
      mysqld
      --innodb-flush-log-at-trx-commit=0
      --sync-binlog=0
      --skip-innodb-doublewrite
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
    ports:
      - "3306:3306"
    volumes:
      - ./tmp/mysql_data:/var/lib/mysql

各パラメータの解説

innodb-flush-log-at-trx-commit=0

InnoDB のログ書き込み動作を制御するパラメータです。

動作 用途
1(デフォルト) コミットのたびにログをディスクに fsync 本番環境(ACID 完全準拠)
2 コミットのたびに OS バッファに書き込み、fsync は毎秒 レプリカなど
0 ログの書き込みも fsync も毎秒のバッチ処理 テスト環境

テストで 1 秒以内にクラッシュリカバリが必要な場面はないので、0 にして コミットごとの fsync オーバーヘッドを完全に排除 しています。

sync-binlog=0

バイナリログ(レプリケーション用)の同期タイミングです。

動作
1(デフォルト) コミットごとにバイナリログを fsync
0 OS のファイルシステムキャッシュに任せる

テスト環境ではレプリケーションを使わないので、バイナリログを sync_binlog=0 にし、同期を OS のキャッシュに任せることでコミットごとの fsync を省いています。

skip-innodb-doublewrite

InnoDB の doublewrite バッファを無効化します。これは書き込み途中のクラッシュに備えて全ページを 2 回書く安全機構なのですが、テスト環境では不要です。無効化すれば 書き込み I/O が大幅に減ります

注意: 本番では絶対にやらないでください

念のため書いておきますが、上記の設定は データの耐久性・整合性を犠牲にしています

  • innodb-flush-log-at-trx-commit=0: クラッシュで最大 1 秒分のトランザクションが消える
  • sync-binlog=0: クラッシュでバイナリログが不完全になる可能性
  • skip-innodb-doublewrite: 部分書き込みでデータ破損のリスク

テスト環境は「テストが通ればデータは捨てる」使い捨ての世界なので、これらのリスクは許容しています。くれぐれも本番には適用しないように!


まとめ

施策 何をキャッシュ/最適化しているか 効果
S3 キャッシュ vendor/bundle(Gem パッケージ) ダウンロード高速化・容量制限の解消
マイグレーションキャッシュ マイグレーション済み MySQL データ DB セットアップ時間を数分→数秒に
MySQL チューニング fsync・doublewrite の無効化 テスト中の書き込み I/O を削減

CI の高速化に近道はなくて、結局はボトルネックを一つずつ潰していくしかありません。

DB のデータ耐久性は不要なので無効化する。マイグレーションは毎回ゼロからやる必要がないのでキャッシュする。キャッシュの保存先は、ネットワーク的に近い場所に置く。こうした「当たり前だけど意外とやっていない」割り切りが、大きな高速化につながりました。

同じような課題を抱えるチームの参考になれば嬉しいです。