はじめに
こんにちは、タイミーでエンジニアをしている徳富(@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-cache、whywaita/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: # 手動実行も可能
やっていることはシンプルです。
- MySQL コンテナを起動(データディレクトリを
./tmp/mysql_dataにマウント) rails db:create db:migrateでフルマイグレーション実行./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 のデータ耐久性は不要なので無効化する。マイグレーションは毎回ゼロからやる必要がないのでキャッシュする。キャッシュの保存先は、ネットワーク的に近い場所に置く。こうした「当たり前だけど意外とやっていない」割り切りが、大きな高速化につながりました。
同じような課題を抱えるチームの参考になれば嬉しいです。