😎

self-hosted runner導入で悪戦苦闘した話

2023/12/20に公開

カウンターワークスで主に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を実行する検証を行った。

https://github.com/myoung34/docker-github-actions-runner

  • このイメージでGitHub Personal Access Token(PAT)使えばorganization単位で使えるのでリポジトリまたいで使える。
    • 必要な権限は以下の通り。ちょっと記述が古いPATのままだけどこれに従うことでいけた。

https://github.com/myoung34/docker-github-actions-runner/wiki/Usage#token-scope

  • 当初、コストが安い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公式が推奨している以下を使うことに。

https://github.com/philips-labs/terraform-aws-github-runner

これがどういうものかはChatGPTでWebPilotプラグイン使って解説させてみたら非常にわかりやすかったので結果を貼っておく。

ChatGPT解説

このTerraformモジュールは、AWS上でスケーラブルなGitHub Actionsのセルフホストランナーを作成するためのものです。以下に、その主な動作フローを簡単に説明します。

  1. GitHub Actionsのトリガー: GitHub Actionsのワークフローがトリガーされると、GitHubはそのワークロードを実行できるランナーを探します。このモジュールは、トリガーされたワークフローのworkflow_jobイベントを受け取り、必要に応じて新しいランナーを作成します。
  2. Webhookの設定: workflow_jobイベントをWebhook(Lambda)で受け取るために、GitHubにWebhookを作成する必要があります。WebhookはGitHub App内で作成するか、別途作成することができます。
  3. API Gatewayの設定: AWSではAPI Gatewayエンドポイントが作成され、GitHubのWebhookイベントをHTTP POSTで受け取ることができます。ゲートウェイはWebhook Lambdaをトリガーし、イベントの署名を検証します。この検証により、イベントがGitHub Appから送信されたことが保証されます。
  4. SQSキューへのイベントのポスト: Lambdaはworkflow_jobイベントを受け取り、そのステータスがqueuedでランナーラベルと一致する場合のみ処理します。受け入れられたイベントはSQSキューにポストされます。このキュー上のメッセージは、利用可能なランナーがビルドを開始する時間を与えるために、設定可能な秒数(デフォルトは30秒)遅延させます。
  5. ランナーのスケーリング: "scale up runner" LambdaはSQSキューをリッスンし、イベントを取得します。Lambdaは、新しいEC2スポットインスタンスを作成する必要があるかどうかを決定するための様々なチェックを実行します。例えば、既存のランナーによってビルドがすでに開始されている場合や、ランナーの最大数に達している場合は、インスタンスは作成されません。
  6. EC2インスタンスの作成: Lambdaはまず、ランナーが自身を登録するために後で必要となるJIT設定または登録トークンをGitHubからリクエストします。次に、EC2スポットインスタンスはローンチテンプレートを介して作成されます。ローンチテンプレートは必要なインスタンスの仕様を定義し、ユーザーデータスクリプトを含みます。このスクリプトは必要なソフトウェアをインストールし、それを設定します。
  7. ランナーのスケーリングダウン: ランナーのスケーリングダウンは、一定の間隔でLambdaが各ランナー(インスタンス)がビジーかどうかをチェックし、ビジーでない場合はGitHubからランナーを削除し、AWSでインスタンスを終了します。

以上が主な動作フローとなります。詳細な設定やオプションについては、GitHubのリポジトリをご覧ください。

ちなみにこれ以外にもKubernetesを使う、

https://github.com/summerwind/actions-runner-controller

もあるが既存資産的にTerraformで管理する方が相性が良いのでTerraformモジュールを使うことに。

GitHub App

新規作成

runnerオートスケーリングの検知用WebhookとしてGitHub APPの設定が必要なので以下から新規作成する。

この左メニューのDeveloper settingsが折りたたまれていて場所が分かりづらい。。

Third-party AccessGitHub Appsと混同する。。

その他諸々の設定は公式READMEに記載ある通りでOK。

webhook_url

公式ドキュメントだとterraform apply後にgithub上でセットするwebhook_url参照できるよって書いてあるけどTerraformプラグインのバージョン依存なのか標準出力に出てこなかった。

terraform consolemodule.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を用意する必要がある。

https://github.com/philips-labs/terraform-aws-github-runner/blob/main/examples/ubuntu/main.tf#L47-L56

user-data.shでやってることは以下。

  1. 追加で必要なaptパッケージのインストール
  2. rootless dockerの起動
  3. 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リランする以外現状回避策なし😅

awstoe

コンポーネントのインストールコマンドを記述するYamlはawstoeと呼ばれるローカルテスト用のツールで検証可能。

https://docs.aws.amazon.com/imagebuilder/latest/userguide/toe-get-started.html

  • awstoeの形式が若干特殊でActionsのYamlに似て非なるものなので注意が必要。
  • commands以下に記述した連続したコマンドが環境変数を引き継いでくれない感じになっていて結構ハマった…😭
  • ハマりやすいという意味でもawstoe使ってある程度ローカルで確認した方が良い。
  • awstoe検証用のDockerは以下。
docker-compose.yml
version: '3'
services:
  awstoe:
    container_name: awstoe
    build: .
    tty: true
    stdin_open: true
    volumes:
      - .:/opt/awstoe
Dockerfile
# 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_failurefalseにセットするとビルド失敗時に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値が不整合の場合以下のようなエラーログが出る。

/aws/lambda/github-runner-webhookエラーログ
    {
        "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実行に必要なchromechromedriverをapt-getでインストールしているが、chromeはセキュリティ上の問題から最新のバージョンのみ公開されている。

apt-getでは最新のchrome(google-chrome-stable)がインストールされるようにしているので、chromeのバージョンが変わるとchromedriverのバージョンも指定し直す必要がある。

chromedriverのバージョンは以下で確認できる。

https://googlechromelabs.github.io/chrome-for-testing/

残課題

今回の対応の主目的は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を導入するか否かを判断すべきかも。

参考資料

COUNTERWORKS テックブログ

Discussion