JMDC TECH BLOG

JMDCのエンジニアブログです

バックエンドテストCI on セルフホストランナーを構築・運用した一年間の知見を共有します

はじめまして、株式会社JMDC プロダクト開発部 Pep Up 開発グループの武田と申します。

健康保険組合の加入者向け PHR サービス Pep Up のバックエンド開発を担当しています。

今年、JMDCではアドベントカレンダーに参加しています。 qiita.com 本記事は、JMDC Advent Calendar 2024 14日目の記事です。

概要

昨年12月にバックエンドテストCIを CircleCI から GitHub Actions に移行しました。CI の実行にはセルフホストランナーを利用しています。

今回はこちらの CI の構築時や運用時において遭遇した問題について共有しようと思います。GitHub Actions でセルフホストランナーを構築・運用したいと検討されている方の一助になれば幸いです。

環境

以下の環境でバックエンドテストを行っています。

背景

主に以下の理由から移行を行いました。

  • 歴史的経緯から CircleCI を利用していましたが、GitHub Actions の方がワークフローの管理が容易でした。すでにフロントエンド側の CI は移行が完了していて、バックエンド CI やデプロイワークフローが残っている状態でした。
  • CircleCI のワークフローをすべて GitHub Actions に移行し、CircleCI を廃止することで、コスト削減が見込める想定でした(CircleCI は月ごとのアクティブユーザー数課金)。

結果

先に今回の CI の移行で、どれくらい速度やコストが改善したかについて共有しておきます。

(新)GitHub Actions (旧)CircleCI
コスト $60 + $16 / 月 ※1 $375 + $200 / 月 ※2
実行時間(50%ile) 4.45min ※3 6.25min
実行時間(95%ile) 4.92min 8.96min
  • ※1:内訳は EC2 インスタンスの利用料金+セルフホストランナーを利用していない部分の GitHub-hosted runner 利用料金($0.016 (2分) * 1,000回)です。
  • ※2:内訳は CircleCI の利用料金+リージョン外へ ECR を pull する際に掛かっていた費用概算となります。ただし CircleCI は従量課金ではない(クレジットの範囲内に収まっていた)ので、正確な比較にならないという点はご承知おきください。
  • ※3:GitHub Actions の実行時間については、2024年11月時点の2週間あたりの平均を出しています。

特にコスト面で大幅な削減となりました(約1/7)。

EC2 コストのグラフ

実行時間については、CircleCI 時代が速度的に安定していなかったこともあり、95パーセンタイルで見たときに、大幅に実行時間を削減することができました。

移行に際しての問題

CircleCI から GitHub Actions への yaml の移行は特段問題なくできたのですが、実際にワークフローを動かしてみると、以下の問題があることがわかりました。

CircleCI よりも時間がかかる

これは主にキャッシュが効いてないことが原因でした。

特に gem を bundle install するのに時間が掛かっており、一部の CI (Rubocop)ではキャッシュ化された gem を利用していましたが、今回の CI ではテスト実行環境のディストリビューションが異なることもあり、別途キャッシュを用意する必要がありました。

しかし gem のキャッシュサイズは1GB近く、複数のバージョンを GitHub Actions のキャッシュで管理していると、容量がすぐにオーバーしてしまう問題がありました。

コストが想像以上に掛かってしまう

GitHub Actions は完全な従量課金なので、処理に時間が掛かると、結果的にコストも掛かってしまいます。

検証においては、16並列(2core × 8台)でテストを動かした場合、その総実行時間が1時間近かったので、1回のテストで約$0.48掛かる計算になってしまいました。*1

仮に月に1,000回テストが動くとすると、無料枠(3,000分/月)はすぐにオーバーしてしまい、単純計算だと月あたりで約$480($0.48 * 1,000)掛かり、CircleCI 時代とさほどコストが変わらないという状態でした。*2

また可能であれば速度改善のためテストの並列数を増やしたかったのですが、GitHub-hosted runner のコストがお高めのためにそれに躊躇してしまうような状態でした。(詳細は後述します)

セルフホストランナーの構築

これらの問題を解決するために、セルフホストランナーを構築することを検討しました。これにより、パブリッククラウド上でホストしたインスタンスを、GitHub Actions のランナーに利用することができます。

docs.github.com

コストに関して

セルフホストランナーを利用することのメリットの一つはコスト面です。

今回は EC2 スポットインスタンスを利用することにしましたが、これは GitHub Actions のランナーを利用するよりも、インスタンス料金をだいぶ抑えられるからです。

インスタンス コスト(1時間あたり)
GitHub-hosted runner(標準・2core) $0.48
AWS EC2 c7i-flex.large(ondemand・2core) $0.10673
AWS EC2 c7i-flex.large(spot・2core) $0.0357

※EC2 のコストについては、2024/11/25日時点の東京リージョンのものを掲載。

同コア数のインスタンスを比較してみると、上記のように、オンデマンドでもコストが約1/5に、スポットインスタンスであれば1/10以下になることがわかります。

もちろんスポットインスタンスは中断の可能性がありますが、

  • 1回の CI ワークフローはそこまで長くないこと
  • 中断されてもリトライすれば良いこと

で、積極的に利用するべきだと判断しました。

なお、コストが安くなったことで、気軽に高スペックのインスタンスを利用することができるので、現在 Pep Up では8コア(2xlarge)のインスタンスを利用して、8台×8コアの64並列でテストを実行しています。これでも GitHub-hosted runner(2core)の1/3ほどのコストでランナーを利用することができています。

terraform-aws-github-runner

セルフホストランナーの構築には terraform-aws-github-runner を利用しています。

github.com

こちらを利用することで、例えば、以下のようなアーキテクチャを簡単に AWS 上に展開できます。

terraform-aws-github-runner アーキテクチャ図

GitHub - philips-labs/terraform-aws-github-runner at v4.7.0 より引用。

prebuilt イメージの利用

terraform-aws-github-runner では、ランナーを常時指定台数起動して、Gtihub から Webhook event が飛んできたら、待機していたランナーで即ワークフローを実行することもできますが、今回はコスト削減のために、Webhook event が飛んできて初めて EC2 が起動されるようにしています。

この起動時間を削減するために、EC2 起動に利用する AMI を prebuilt しています。イメージについては、terraform-aws-github-runner で提供されている Amazon Linux 2023 Packer テンプレートを利用しています。*3 *4

これにより、Webhook event が飛んでからワークフローが実行可能になるまでに掛かる時間を大きく短縮することができました。

Linux Distribution 起動時間
Amazon Linux 2023 (prebuilt) 40秒
Amazon Linux 2023 1分30秒
Ubuntu (prebuilt) 2分

※2023年11月に検証したデータです

アーティファクト置き場に S3 を利用

上述のキャッシュと、そもそもキャッシュストレージサイズが小さいという問題に対しては、キャッシュを S3 に置くことで回避しています。

この回避策はセルフホストランナー環境でなくても導入することができますが、S3 ではインターネットへのアウトバウンド通信にコスト($0.09/GB(最初の10TBまで))が掛かってしまうので、キャッシュサイズが大きい場合は意図しないコストが請求されるおそれがあります。*5

EC2 を利用したセルフホストランナー環境では、リージョン内の通信になるのでここでコストが掛かることはなく、安心して利用できます。

8コアインスタンスで、PostgreSQL 絡みのエラーが生じる問題

バックエンドテストでは依存コンテナとして、PostgreSQL と Redis を同時に立ち上げています。

しかし8コア以上のインスタンスを利用したところ、テスト実行時に PG::OutOfMemory: ERROR: out of shared memory エラーが生じるという問題が発生しました。

これについては、PostgreSQL の shared_buffers や max_locks_per_transaction のデフォルト値が小さすぎることが原因で、全体的に postgresql.conf をチューニングしたイメージを作成することで回避しています。

- shared_buffers = 128MB
+ shared_buffers = 8GB

- #work_mem = 4MB
+ work_mem = 32MB

- #effective_cache_size = 4GB
+ effective_cache_size = 16GB

- #max_locks_per_transaction = 64
+ max_locks_per_transaction = 512

ephemeral runner の利用

起動時に –ephemeral オプションを渡すことで、GitHub Actions がランナーに1つのジョブのみを割り当てることを保証してくれます。

これは公式ドキュメントでも推奨されていますし、ランナーの登録を自動的に解除してくれたり、常にクリーンな環境でワークフローが実行できたりと、非常に管理が楽になります。*6

terraform-aws-github-runner では enable_ephemeral_runners = true でこれを指定できます。*7

実行時間を考慮して、テストを均等分割する

セルフホストランナーとは関係ありませんが、速度改善に寄与した施策を一つ紹介します。

CircleCI 時代から、各ランナーのテスト実行時間には大きな差が出ており、全体的なテスト実行時間が安定していませんでした。

CircleCI の実行結果

ところで rspec(parallel_tests) は、テスト実行後に JUnit XML 形式とプレーンテキストのテストレポートを出力することができます。

以下はレポート出力用のオプションです。

--format RspecJunitFormatter
--out tmp/report-out/junit<%= ENV['NODE_INDEX'] %>-<%= ENV['TEST_ENV_NUMBER'] %>.xml
--format ParallelTests::RSpec::RuntimeLogger
--out tmp/report-out/parallel<%= ENV['NODE_INDEX'] %>.log

テストレポートには各テストの実行時間が記録されており、これを利用することで、ランナー間・ランナー内の並列テストで実行時間が均等になるようにテストを分割することができます。

JUnit XML(サンプル)

<testcase classname="spec.models.test_spec" name="Test テストケース1" file="./spec/models/test_spec.rb" time="0.061039"></testcase>
<testcase classname="spec.models.test_spec" name="Test テストケース2" file="./spec/models/test_spec.rb" time="0.053426"></testcase>
<testcase classname="spec.models.test_spec" name="Test テストケース3" file="./spec/models/test_spec.rb" time="0.048749"></testcase>

split-tests-by-timing というGitHub Actions を利用して、ランナー間でテストの時間が均等になるように、こちらの XML を利用しています。

github.com

プレーンテキスト(サンプル)

spec/models/test_spec.rb:15.043076374999998
spec/controllers/tests_controller_spec.rb:1.1332716779999998
spec/models/test2_spec.rb:0.8014969460000145
spec/jobs/test_worker_spec.rb:0.553258091999993

parallel_tests でランナー内でテストの時間が均等になるように、こちらのプレーンテキストを利用しています。

bundle exec parallel_rspec \
  --runtime-log ./tmp/report-in/parallel.log \
  $rspec_paths

移行後の運用に際しての問題

AWS Config のコストが掛かってしまう

実際に運用を始めてみると、リソースの構成を監視してくれる AWS Config のコストが大幅に増えてしまいました。

これまでほとんどコストが掛かっていなかったのが、1日あたり$20以上掛かるようなケースも出てしまいました。

AWS Config のコスト(before)

これの原因はスポットインスタンスを利用しており、高頻度でインスタンスの起動・停止を繰り返すので、監査対象のリソース(EC2 Fleet, ENI, EBS)が増えてしまうことでした。

この問題については、ランナーを動作させているのが検証環境ということもあり、上記のリソースに限定して監査から除外することで回避しています。

AWS Config のコスト(after)

1/20以降はほとんどコストが掛からなくなっています。

セルフホストランナーが全く起動しない

ある日を境に、セルフホストランナーが起動せず、GitHub Actions 側ですべてのテストが待ちの状態になってしまいました。

これの原因は非常に単純な話なのですが、上述の prebuilt イメージを更新していないことが原因で、イメージを作り直すことですぐに解決することができました。

イメージ生成時にセルフホストランナー用のスクリプト・バイナリファイルをダウンロードしているのですが、これが古くなってしまったのが原因だと考えています。(terraform-aws-github-runner では disable_runner_autoupdate にてこのあたりの制御ができるのですが、起動時間短縮のためにこれを true にしていました)*8 *9

イメージを定期的に手動で作り直すことは面倒なので、CI でこのイメージ生成を自動化するようにしています。

Packer の GitHub Actions を利用して、packer init, packer build しています。

github.com

GitHub Actions で CI をリトライすると、失敗するはずのテストが成功してしまう

ジョブが失敗したとき、リトライするために「Re-run all jobs」と「Re-run failed jobs」を実行することができますが、その際「Re-run failed jobs」を選択すると失敗するはずのテストが成功してしまい、本来はメインブランチにマージされるべきでない PR をマージできてしまうという問題です。

この原因は、ランナーごとにテストを分割しているのですが、たとえコミットハッシュが同じでも、その分割が常に同じ振り分けになるとは限らないことでした。(例:Aというテストが1番のランナーで実行されていたが、1番のランナーをリトライすると、Aは1番のテストで実行されるとは限らない)

これについては、単純な対応なのですが、ブランチ・コミットハッシュ・ランナー番号ごとに、テスト対象となるパス一覧を記録したテキストファイルを S3 に置いて、もしコミットハッシュが同じであればキャッシュしたパス一覧を利用してテストを再実行する…というような形で対応しています。

セルフホストランナーが一部起動しない

ある日を境に、1テストあたり8台起動されるはずのワーカーが、何故か中途半端に6,7台しか起動しない状況に陥りました。

EC2 の起動は AWS Lambda にて行っているのですが、こちらのログを見ると以下のようなエラーが高頻度で出ていました。

Create fleet failed, ScaleError will be thrown to trigger retry for ephemeral runners.

EC2 インスタンスの起動に失敗してそうです。

これについても理由が分かれば単純な話なのですが、terraform-aws-github-runner は起動可能なインスタンスタイプを Terraform にて列挙することができ、構築当初は該当のインスタンスタイプが最新だったのでインスタンスの確保に問題はなかったのですが、新しいインスタンスタイプが追加されることで、古いインスタンスタイプの確保が難しくなったとわかりました。

そのため、以下のように最新のインスタンスタイプを追加するようにしています。

- instance_types               = ["c6i.2xlarge", "m6i.2xlarge"]
+ instance_types               = ["c7i-flex.2xlarge", "m7i-flex.2xlarge", "c7i.2xlarge", "m7i.2xlarge", "c6i.2xlarge", "m6i.2xlarge"]

なお、これだとインスタンスタイプのラインナップが刷新されたタイミングで再度修正しなければいけないので、DescribeInstanceTypes から適当なものを引っ張ってくるのがよりよいと思っています。*10

おわりに

セルフホストランナーで構築した GitHub Actions バックエンドテストCIに関して、構築時・運用時に遭遇した問題についてまとめてみました。

実際に運用をする中でいくつか問題は出てきましたが、速度面・コスト面については一年を通して劣化することもなく、比較的安定して運用ができたのは良かったと思います。

テストの実行時間について補足をしておくと、RSpec が動いている時間は各ランナーで 4m30s 中 1m10s~1m20s ほどで、それ以外の間接コスト(セルフホストランナーの起動やDBのマイグレーション、またバックエンドテストを実行するかどうかを、変更ファイルのパスを見てハンドリングする処理や、Slack への結果通知が別ジョブで実行されているので、そのためのランナー起動に掛かる時間)の方が大きいので、今後更に速度改善を行う場合は、そのあたりの無駄をいかに省くかが課題となってくると思っています。

テスト実行時間の詳細

明日15日目は、川島さんによる「Redshiftのストアドプロシージャで単体試験自動化をやってみた」です。お楽しみに!

JMDCでは、ヘルスケア領域の課題解決に一緒に取り組んでいただける方を積極採用中です!フロントエンド /バックエンド/ データベースエンジニア等、様々なポジションで募集をしています。詳細は下記の募集一覧からご確認ください。 hrmos.co まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。 hrmos.co ★最新記事のお知らせはぜひ X(Twitter)、またはBlueskyをご覧ください! twitter.com bsky.app