見出し画像

オートスケールするGitHub Actionsセルフホストランナー環境 tornadeの紹介 〜多分これが一番安いです〜

Subaru Nakamura(varu3)

こんにちは、note株式会社でSREをやっているvaru3です。

この記事は note株式会社 Advent Calendar 2022 の1日目の記事です。トップバッターですね、よろしくお願いします。

はじめに

みなさん、GitHub Actionsは利用していますか。

先日、Github actionsのコストパフォーマンスについて検討していた以下の記事が少し話題になっていました。

この記事のデータによると、単純な料金の比較ではFargate Spotを利用してセルフホストランナーを起動するのが圧倒的にコストが低くなるということがわかります。

2022年12月現在、Fargate SpotはEKSに未対応で対応していないため、利用するためにはECSでないといけません。そのため、EKSでオートスケールするので有名な actions-runner-controller ではFargate Spotは利用できません。

そこで思いつきました。ECS上でFargate Spotを利用してオートスケールする仕組みを作れば、より安くセルフホストランナーを利用することができるのではないか、と。

初めにECSでセルフホストランナーをオートスケールするようにしたプロダクトやOSSがないかな〜と思って調べてみたのですが、あまり用途に沿うものが見つからなかったので、自分で実装してみることにしました。

というわけで、GitHub Actionsをオートスケールするシステムを社内に環境を立てたらとても上手くいったよ〜という紹介記事です。

tl;dr

  • GitHub ActionsセルフホストランナーをECS / Fargateで起動するための仕組みを構築したよ

  • コストが多少安かったり、セルフホストランナーの空き待ちが発生しないようになっているよ

  • AWS App Runner を利用してWebhookのイベントを検知するようにしているよ

  • GitHub Appを利用している場合は、PersonalAccessTokenではなくてTokenを生成することが推奨されているよ

  • Actionsのワークフローのラベルでアーキテクチャーやどこで起動するかを制御できるようにしているよ

オートスケールするGitHub Actionsセルフホストランナー tornadeの紹介

以下のような構成のシステムを立ててみました。 tornade という名前をつけています。

tornadeの構成図

GitHubにはGitHub Appsを利用して、特定のエンドポイントに特定のイベントのWebhookを送ることができます。

それを利用して、ActionsのqueueイベントをApp Runnerに立てたエンドポイントにPOSTするようにし、それを検知してECS / CodeBuildでセルフホストランナーを実行するというような仕組みです。

工夫したポイントとしては以下のような感じです。

  • Fargate / Fargate Spot / CodeBuild のどこでセルフホストランナーを起動するかをラベルで選べるようにした(詳細は後述)

    • Fargate / Fargate Spot では Docker in Dockerができないという欠点をCodeBuild で補う形

  • Fargate / CodeBuild ではx86とarm64のアーキテクチャーを選べるようにした

    • マルチアーキテクチャーでコンテナビルドしたい場合などに便利

またメリットとしては、

  • 待機しておくセルフホストランナーが不要になるため、余計なコストがかからない(利用していないときはApp Runner + ECSクラスタ分の料金のみ)

  • オートスケールするため、ジョブの実行前の空き待ちの時間が発生しなくなる

という点が挙げられます。

ただし、セルフホストランナーが起動するまでに30秒くらいかかるようになるため、それはデメリットとして挙げられます。

これについては常駐させておくセルフホストランナーも最低限用意しておいて、時間にシビアなジョブにはこちらを利用してもらうことで対応しました(詳しくは後述)

ラベルで制御する使い方

以下は、tornadeを利用して、GitHub Actionsのworkflowを実行する際のサンプルです。こんな感じで、ラベルを用いて制御するようにしています。

name: tornade

on:
  workflow_dispatch:

jobs:
  fargate_x86:
    runs-on: ["self-hosted", "tornade", "${{ github.run_id }}", "runs-on=FARGATE", "arch=X86_64"]
    steps:
      - run: |
          uname -a
          echo "runs on FARGATE!!"

  fargate_arm64:
    runs-on: ["self-hosted", "tornade", "${{ github.run_id }}", "runs-on=FARGATE", "arch=ARM64"]
    steps:
      - run: |
          uname -a
          echo "runs on FARGATE!!"

  fargate_spot:
    runs-on:
      ["self-hosted", "tornade", "${{ github.run_id }}", "runs-on=FARGATE_SPOT"]
    steps:
      - run: |
          uname -a
          echo "runs on FARGATE_SPOT!!"

  codebuild_x86:
    runs-on:
      ["self-hosted", "tornade", "${{ github.run_id }}", "runs-on=CODEBUILD", "arch=X86_64]
    steps:
      - run: |
          uname -a
          echo "runs on CODEBUILD!!"

  codebuild_arm64:
    runs-on:
      ["self-hosted", "tornade", "${{ github.run_id }}", "runs-on=CODEBUILD", "arch=ARM64]
    steps:
      - run: |
          uname -a
          echo "runs on CODEBUILD!!"
  • ラベル例

    • self-hosted, tornade

      • 必ず必要なものです

    • runs-on=(FARGATE | FARGATE_SPOT | CODEBUILD)

      • ランナーを起動する環境を選択します

    • arch=(X86_64 | ARM64)

      • ランナーのCPUアーキテクチャです

    • type=static

      • ランナーを常駐しているものを使うためのオプションです

      • このlabelがついている場合は、常駐しているランナー上で 実行されます

Fargate Sport は ARM64アーキテクチャーに 2022,12月現在では対応していないので注意が必要です。

また、CodeBuild / Fargate / Fargate Spot の使い分けについては、以下のように推奨しています

  • 通常の短い処理(およそ10分以内)の場合 -> `FARGATE_SPOT`

  • 10分以上かかる処理の場合 ->  FARGATE

  • DockerのビルドやDockerを起動したい場合 -> CODEBUILD

実装例

ではこれらをどんな感じで実装しているのか、簡単に紹介します。

1. イベントを検知するApp Runnerを用意する

まずApp Runner側ですが、golangで実装しました。GitHub側のWebhookを受信するために以下のようにエンドポイントを生やしています

http.HandleFunc("/events", eventHandler)
if err := http.ListenAndServe(fmt.Sprintf(":%v", port), nil); err != nil {
	return err
}

GitHub App側でこのエンドポイントにWebhookを投げるように設定します。ここではGitHub App側の設定方法の紹介は割愛しますが、以下のドキュメントを参考にしました。

そして、このエンドポイントに飛んできたイベントを以下のようにフィルターします。

func (event *WebhookJobEvent) shouldLaunchRunner() bool {
	launchLabel := getEnv("LAUNCH_LABEL", "tornade")
	selfHosted := false
	launchable := false
	if event.Action == "queued" {
		for _, label := range event.WorkflowJob.Labels {
			if label == "self-hosted" {
				selfHosted = true
				continue
			}

			if label == launchLabel {
				launchable = true
				continue
			}
		}
	}
	return (selfHosted && launchable)
}

GitHub Actionsのワークフローが実行開始されると `queued` というイベントが飛んできますので、それをキャッチしたら Fargate / CodeBuild でセルフホストランナーを起動するというようになっています。

2. App RunnerでAccess Tokenを生成する

セルフホストランナーを起動するための、Access TokenについてはPersonal AccessTokenを利用することもできるのですが、GitHub Appsを利用する場合は都度トークンを発行する方法が推奨されています。詳しくは以下のドキュメントを参照してください。

golangでの実装例は以下のようになります

func generateToken(installationId string) (string, error) {
	JWTToken, err := generateJWTToken()
	if err != nil {
		return "", err
	}

	client := new(http.Client)
	req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%s/access_tokens", installationId), nil)
	req.Header.Add("Authorization", "Bearer "+JWTToken)
	req.Header.Add("Accept", "application/vnd.github+json")

	res, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("failed to get access token: %s", err)
	}

	defer res.Body.Close()

	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return "", fmt.Errorf("failed to read body: %v", err)
	}

	var tokenResponse TokenResponse
	if err := json.Unmarshal(body, &tokenResponse); err != nil {
		return "", fmt.Errorf("failed to unmarshal json: %v", err)
	}

	return tokenResponse.Token, nil
}

func generateJWTToken() (string, error) {
	secretKeyPemBase64 := getEnv("SECRET_KEY_PEM", "")
	appId := getEnv("APP_ID", "")

	dec, err := base64.StdEncoding.DecodeString(secretKeyPemBase64)
	if err != nil {
		return "", fmt.Errorf("failed to decode SECRET_KEY_PEM")
	}

	c := &jwt.StandardClaims{
		Issuer:    appId,
		ExpiresAt: time.Now().Unix() + 600,
		IssuedAt:  time.Now().Unix() - 60,
	}
	token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), c)

	key, err := jwt.ParseRSAPrivateKeyFromPEM(dec)
	if err != nil {
		return "", err
	}

	t, err := token.SignedString(key)
	return t, err
}

この方法で得たトークンをPERSONAL_TOKEN として利用します

3. CodeBuild / Fargateでセルフホストランナーを起動する

ここでセルフホストランナーの起動には GitHub Runnerを含めたイメージを用いる必要があるのですが、 tornadeでは以下のコンテナイメージを利用しています。

ここでセルフホストランナーはEPHEMERALモードを有効化して起動しています。EPHEMERALモードは、GitHub Actionsが起動し、ジョブが実行されたらそのセルフホストランナーを終了する、というモードです。

これによって、App Runner側ではセルフホストランナーの起動を制御するだけでよく、終了はセルフホストランナー側に任せることができます。

このコンテナイメージの環境変数に 2. で取得したアクセストークンを埋め込んで起動するようにしています。

CodeBuldはあらかじめプロジェクトを作っておき、特定のラベルがある場合はそれを起動するようにしています。ECSについてもクラスタとタスク定義を先に作っておき、特定のラベルがある場合はRunTaskを実行するようにしています。

この辺りのインフラ構成については terraform で管理するようにしています。

と、ざっくり紹介してみました。

いかがでしたか?

おわりに

というわけで、社内の環境に構築してみて、1ヶ月くらいドッグフーディング的に使ってもらっていますが、今のところ大きな不具合もなく利用していただけているようです。

本当はApp Runnerではなく Lambda Function とかにしたかったんだけど、レスポンスした後に非同期でセルフホストランナーの起動処理を入れたりしなくちゃいけなくて、その辺りをめんどくさがってApp Runnerを利用してみました。
初めて利用してみましたが、コンテナビルドとプッシュするだけでエンドポイントが生えるので大変便利です!(少々、デプロイまでに時間がかかるという欠点がありますが…)GCPを利用している方はCloudRunでもよさそうですね。

コストを抑えてセルフホストランナーを利用したい場合には、是非とも上記の構成例を参考にしていただけると幸いです。

(もしかしたらそのうちOSSとしてパブリックにするかもですが…)

今年の弊社のアドベントカレンダーは技術系とそれ以外で分けられているようなので、noteの中の人のエモい系の記事を求めている方はぜひこちらも読んでみてください。

明日は @sunochi さんがOKRフレームワークとデイリースクラムについて書いてくれるそうです。楽しみですね!

それではみなさん、良いお年を〜。

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!