self-hosted runner導入で悪戦苦闘した話
カウンターワークスで主にDevOpsなところでお手伝いしている@tchikubaです。ベンチャー企業のTech支援やアジャイルコーチ、エンジニア向け研修など複数社に関わってます。
今回、まぁまぁ時間をかけて悪戦苦闘したGitHub Actionsのself-hosted runner環境を構築した話をお届けします。
GitHub ActionsとCIの課題
- GitHub Actionsの利用料金が肥大化して追加課金が問題になっていた。
- Actionsのrawデータを分析すると、Railsリポジトリ1(バックエンド環境)のCIで 83.9% もの実行時間がかかっており、ボトルネックとなっていたのでまずここを改善することに。
- RailsのCIは高速化のためにRSpecを8並列化していたのでデフォルトランナーの消費時間が1回あたり2hとかとんでもないことになってしまっていた😅
- CIに20分以上かかっていて高速化も必要だし、Actionsの課金も下げたいしで二兎を追う方法を模索した。
やったことの概要
- Actionsのself-hosted runnerを自前で用意してそっちでCIを実行するようにすればひとまずActionsの課金を下げられそうだし、CI高速化のチューニングもやりやすそう、ということで検証開始。
- 以下のself-hosted runnerイメージを使ってECS on EC2でランナーを立ててActions経由でCIを実行する検証を行った。
- このイメージでGitHub Personal Access Token(PAT)使えばorganization単位で使えるのでリポジトリまたいで使える。
- 必要な権限は以下の通り。ちょっと記述が古いPATのままだけどこれに従うことでいけた。
- 当初、コストが安いFargate Spotで試してみたがDocker outside of Docker(DooD)できずECS on EC2にする必要があった。
- Rubocop実行はDockerイメージ使っていないのでFargateでも動いた。
- RSpec実行はMySQL, Redisイメージを使っていてFargateではDooDが難しくECS on EC2なら検証段階では動いた。
- ここまで検証した段階で、後述の
terraform-aws-github-runner
を使う方がオートスケーリングするならデファクトで、こちらを使う方針に。- これはECSを介さないEC2インスタンスマネジメント方法になる。
- AMI作成にはEC2 Image Builderを採用。
- AMI作成に必要な処理はECS on EC2検証時にDockerイメージを作成していたのが役立った。
ランナー用EC2インスタンスのオートスケーリング
オートスケーリングはgithub公式が推奨している以下を使うことに。
これがどういうものかはChatGPTでWebPilotプラグイン使って解説させてみたら非常にわかりやすかったので結果を貼っておく。
ChatGPT解説
このTerraformモジュールは、AWS上でスケーラブルなGitHub Actionsのセルフホストランナーを作成するためのものです。以下に、その主な動作フローを簡単に説明します。
-
GitHub Actionsのトリガー: GitHub Actionsのワークフローがトリガーされると、GitHubはそのワークロードを実行できるランナーを探します。このモジュールは、トリガーされたワークフローの
workflow_job
イベントを受け取り、必要に応じて新しいランナーを作成します。 -
Webhookの設定:
workflow_job
イベントをWebhook(Lambda)で受け取るために、GitHubにWebhookを作成する必要があります。WebhookはGitHub App内で作成するか、別途作成することができます。 - API Gatewayの設定: AWSではAPI Gatewayエンドポイントが作成され、GitHubのWebhookイベントをHTTP POSTで受け取ることができます。ゲートウェイはWebhook Lambdaをトリガーし、イベントの署名を検証します。この検証により、イベントがGitHub Appから送信されたことが保証されます。
-
SQSキューへのイベントのポスト: Lambdaは
workflow_job
イベントを受け取り、そのステータスがqueued
でランナーラベルと一致する場合のみ処理します。受け入れられたイベントはSQSキューにポストされます。このキュー上のメッセージは、利用可能なランナーがビルドを開始する時間を与えるために、設定可能な秒数(デフォルトは30秒)遅延させます。 - ランナーのスケーリング: "scale up runner" LambdaはSQSキューをリッスンし、イベントを取得します。Lambdaは、新しいEC2スポットインスタンスを作成する必要があるかどうかを決定するための様々なチェックを実行します。例えば、既存のランナーによってビルドがすでに開始されている場合や、ランナーの最大数に達している場合は、インスタンスは作成されません。
- EC2インスタンスの作成: Lambdaはまず、ランナーが自身を登録するために後で必要となるJIT設定または登録トークンをGitHubからリクエストします。次に、EC2スポットインスタンスはローンチテンプレートを介して作成されます。ローンチテンプレートは必要なインスタンスの仕様を定義し、ユーザーデータスクリプトを含みます。このスクリプトは必要なソフトウェアをインストールし、それを設定します。
- ランナーのスケーリングダウン: ランナーのスケーリングダウンは、一定の間隔でLambdaが各ランナー(インスタンス)がビジーかどうかをチェックし、ビジーでない場合はGitHubからランナーを削除し、AWSでインスタンスを終了します。
以上が主な動作フローとなります。詳細な設定やオプションについては、GitHubのリポジトリをご覧ください。
ちなみにこれ以外にもKubernetesを使う、
もあるが既存資産的にTerraformで管理する方が相性が良いのでTerraformモジュールを使うことに。
GitHub App
新規作成
runnerオートスケーリングの検知用WebhookとしてGitHub APPの設定が必要なので以下から新規作成する。
この左メニューのDeveloper settings
が折りたたまれていて場所が分かりづらい。。
Third-party Access
のGitHub Apps
と混同する。。
その他諸々の設定は公式READMEに記載ある通りでOK。
webhook_url
公式ドキュメントだとterraform apply
後にgithub上でセットするwebhook_url
参照できるよって書いてあるけどTerraformプラグインのバージョン依存なのか標準出力に出てこなかった。
terraform console
でmodule.runners
を出力してwebhook.endpoint
を入力して解決。
api_endpoint
ではないので注意。
"webhook" = {
"endpoint" = "https://xxx.execute-api.ap-northeast-1.amazonaws.com/webhook" # ←この値をgithub organizationのgithub app設定のwebhook urlにセット
"gateway" = {
"api_endpoint" = "https://xxx.execute-api.ap-northeast-1.amazonaws.com" # こっちじゃないので注意
Ubuntu対応
デフォルトではAmazon Linuxが起動するが、デフォルトランナーと合わせるためコミュニティAMIのubuntu-jammy-22.04-amd64-server
をベースイメージとして使用。
それに応じて公式の例の通りuser-data.sh
を用意する必要がある。
user-data.sh
でやってることは以下。
- 追加で必要なaptパッケージのインストール
- rootless dockerの起動
- self-hosted runnerインストール
EC2 Image BuilderによるAMI作成
実際にはuser-data.sh
でインストール等しているパッケージ以外にも事前に環境構築としてやるべきことがある。
- Rubyインストール
- バックエンド用Railsで使うもの。
- self-hosted runnerでは
actions/setup-ruby
が使用できないので自前でインストール。 -
bundle install
は一旦CI側で行っている。- ここはカイゼンの余地あるかも。
- Npm/Yarnインストール
-
apt-get
で入るnpmのバージョンでも問題なかったので一旦それで。
-
- Rails(のCI)で必要になるパッケージインストール
TerraformがEC2 Image Builderに対応していたのでTerraformにてコード管理。
注意事項
- 以下は手動で行う必要がある。
- イメージパイプラインの実行。
- 作成済AMIを差し替える場合は
terraform apply
が必要。
- AMI作成には25mくらいかかる。
- 作成頻度は毎週月曜AM9時で指定。
- そこまで頻繁にしなくても良いレベルかもだけど一旦これで様子見。
- self-hosted runner用のEC2インスタンスは夜間にスケールダウン。
- 毎日22:15〜翌朝8:45の間で30分おきにスケールダウン用のlambdaが走るように設定。
- awsのcron式の制限があるので一旦これで行くことに。
- 夜間リリース前などは一時的に変更する運用で対応する。
- EC2はスポットインスタンスを使っているので落ちる可能性あり。
- CI実行時にはこれまでほぼ遭遇したことないけど、遭遇したら残念ながらCIリランする以外現状回避策なし😅
- 毎日22:15〜翌朝8:45の間で30分おきにスケールダウン用のlambdaが走るように設定。
awstoe
コンポーネントのインストールコマンドを記述するYamlはawstoe
と呼ばれるローカルテスト用のツールで検証可能。
-
awstoe
の形式が若干特殊でActionsのYamlに似て非なるものなので注意が必要。 -
commands
以下に記述した連続したコマンドが環境変数を引き継いでくれない感じになっていて結構ハマった…😭 - ハマりやすいという意味でも
awstoe
使ってある程度ローカルで確認した方が良い。 -
awstoe
検証用のDockerは以下。
version: '3'
services:
awstoe:
container_name: awstoe
build: .
tty: true
stdin_open: true
volumes:
- .:/opt/awstoe
# use ubuntu22.4 image
FROM ubuntu:22.04
# install packages
RUN apt-get update && apt-get install -y wget sudo
# install awstoe
RUN mkdir -p bin
RUN cd bin && wget https://awstoe-us-east-1.s3.us-east-1.amazonaws.com/latest/linux/arm64/awstoe
RUN chmod +x bin/awstoe
トラブルシューティング
ec2が起動しない場合
CloudWatchの以下のロググループでEC2インスタンスが起動していないエラーが出ていないか確認する。
/aws/lambda/github-runner-scale-up
インスタンス起動に失敗している場合、levelがerror
じゃなくてwarn
なので見落とさないようにする必要がある。
よくあるのは立ち上げようとしたAZでインスタンスタイプがそのAZ未対応だった、みたいなケース。
EC2 Image Builderコンポーネントログ
AMI作成時のログはビルド実行中のbuild-imageワークフローのApplyBuildComponentsステップから参照できる。
ステップIDのリンク辿ってアプリケーションログを開くとubuntu上で実行されたコマンドのコンソールログが参照可能。
EC2 Image BuilderでAMIビルドが落ちた場合
Terraformのaws_imagebuilder_infrastructure_configuration
リソースでterminate_instance_on_failure
をfalse
にセットするとビルド失敗時にEC2インスタンスが残る。
上記ログを参照する方法と同じログは、残ったEC2インスタンスにセッションマネージャー(SSM)でアクセスして/var/lib/amazon/awstoe
配下でも確認可能。
実際にEC2インスタンスにアクセスして該当のエラーを修正する方法を模索して解決策をTerraformに反映するのが手っ取り早い。
極端にパフォーマンスが下がるケース
一度だけCI実行検証中に全然終わらないインスタンスがあった。
これがなぜ発生したのか原因が追えていないが著しくパフォーマンスが劣化したのは間違いない。想像するに、使用しているスポットインスタンスのパフォーマンスの問題っぽい。
この現象に遭遇したら回避方法としてはCI実行を手動キャンセル→リランするしかなさそう…
EC2インスタンスタイプの選定
最初、EC2インスタンスタイプをt3系にしていたが、デフォルトランナーよりパフォーマンスが劣ってしまい、CI実行により時間がかかるようになってしまっていた。
CIコンテキストにおいては、メモリよりCPUリソースが重要で、パフォーマンス最適化タイプのc5,c6系を指定することでパフォーマンスが改善した。
instance_types = [
"c5.large",
"c6a.large",
"c6in.large",
"c6i.large"
]
webhook secret
Terraformの定義名変更などの要因でGitHub Appsのwebhook回りの設定が変更になるケースがある。
この場合、terraform plan
結果を見て適宜GitHub Apps側の設定値を変更する必要がある。
EC2のスケールアップが動かない場合、CloudWatch Logsのスケールアップ用のログには何も出力されないので、この場合、/aws/lambda/github-runner-webhook
のログを参照すること。
webhook secret値が不整合の場合以下のようなエラーログが出る。
{
"level": "ERROR",
"message": "Unable to verify signature!",
"service": "runners",
"timestamp": "2023-10-12T07:35:20.712Z",
"xray_trace_id": "1-6527a1b8-5efa7f2c61d078c44930cc63",
"module": "handler",
"region": "ap-northeast-1",
"environment": "github-runner",
"aws-request-id": "b73381a3-7e40-47a2-abbd-a4d4075c92ff",
"function-name": "github-runner-webhook",
"github": {
"github-event": "workflow_job",
"github-delivery": "e4d5bb50-68d1-11ee-9de6-d8889c017cce"
}
}
この場合、パラメータストアの/github-action-runners/github-runner/app/github_app_webhook_secret
にある値でGitHub Appsのwebhook secretを書き換えると動くようになる。
chromedriverのバージョン
feature spec実行に必要なchrome
とchromedriver
をapt-getでインストールしているが、chromeはセキュリティ上の問題から最新のバージョンのみ公開されている。
apt-getでは最新のchrome(google-chrome-stable)がインストールされるようにしているので、chromeのバージョンが変わるとchromedriverのバージョンも指定し直す必要がある。
chromedriverのバージョンは以下で確認できる。
残課題
今回の対応の主目的はActionsのコスト削減だったのでCIをより高速にする手当は別途やりたい。
- CIの並列数の最適化
- 遅いrequest specの手当て
- その他のバックエンド用CIもself-hosted runnerで動かす対象にする
終わりに
ActionsのデフォルトランナーはCI・CDコンテキストでよく使われるイメージをカスタマイズして提供してくれている。そのお陰でActions実行の前処理としてapt-getでパッケージインストールするのを端折れたりしてる。
Actionsのデフォルトランナーは無料枠を使い切ると有料枠はお高いけど、AMIの管理を手放せるという意味ではそれなりに支払う価値があるものなのかもしれないと感じた😅
self-hosted runner導入でコスト低減とパフォーマンスチューニングの自由度は高まった反面、管理コストは多少増える。このあたりはIaaS, PaaS, SaaSなどを導入する際のジレンマと似通っているから、コストを総合的に判断してself-hosted runnerを導入するか否かを判断すべきかも。
参考資料
- self-hosted runner運用の基本的な考え方byDeNA
-
サイボウズの運用事例
- 今回対応時はこれが一番参考になった。
- docker pullを時短するAMIイメージを作成
- 使い方
- (Terraform + AWS使うので直接は関係ないけど)Kubernetesの事例
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion