Github Actionsのセルフホストランナーをオートスケーリングな使い捨てランナーにした話

[ディップ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/dip-dev)  の17日目の記事です!

# はじめに
皆さん、***Github Actions*** 使ってますか？

https://docs.github.com/ja/actions

workflowのyamlファイルを書くだけで、PRマージなどのGithubイベントをトリガーに様々なジョブを実行してくれるので便利ですよね。
筆者も業務において、静的解析やテストの実行・stage環境へのリリース実行など、がっつり活用しています💪

このGithub Actionsですが、Github Enterprise上で使うためには [`セルフホストランナー`](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners) と呼ばれる、Actionsの実行環境を自前で作成する必要があります。
筆者の所属するチームでは、このセルフホストランナーを`AWSのEC2マシン`を使って用意していたのですが、運用面で若干不満があり...

思い切ってこのセルフホストランナーを、`オートスケール`させて`使い捨て`にするよう設定してみたところ、中々使い心地が良かったので記事にしようと思います🙌

この記事ではオートスケーリングな使い捨てランナーを作成する簡単な手順と、その際いくつか注意すべきポイントなどを紹介していきます。

# 目次
- [導入背景](#導入背景)
- [オートスケーリングランナーの構築](#オートスケーリングランナーの構築)
- [実装のポイント](#実装のポイント)
- [おわりに](#おわりに)

# 導入背景
導入当時筆者の所属するチームでは主に3つのGithubのリポジトリを運用しており、
各リポジトリのworkflow ジョブを、`1台の EC2 セルフホストランナー`で処理するという(~~貧乏くさい~~)体制をとっていました。

この体制ではworkflowの実行自体に問題は無いのですが、1,2ヶ月くらいするとランナーの作業ディレクトリにキャッシュやログなどが貯まるのか、EC2インスタンスのディスク使用量がいっぱいになり動かなくなっていました💦

さらにランナーはworkflowのジョブを1つ1つ順番にしか実行できません。そのため3つもリポジトリを連携していると、高頻度でジョブの実行待ちが発生してしまい、開発体験的によろしくありません。

動かなくなる度に作業ディレクトリやインスタンスを作り直したりする作業が地味にめんどくさく...
かといってランナーのEC2インスタンスを増やしたりインスタンスタイプを上げるのも、運用的・金銭的コストがなぁ...と思い悩んでおりました。

そこで、実行するジョブの数だけ必要なセルフホストランナーをその場で立ち上げ、ジョブを処理し終わった後にランナーを削除するような仕組みがあれば良さそうだと考え、

***オートスケーリングな使い捨てランナー***

を作るに至ります🙌

# オートスケーリングランナーの構築
オートスケーリングなセルフホストランナーを構築するためにGithub公式から※[当時推奨されていた方法](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners)は、以下の2つです。

- [actions-runner-controller/actions-runner-controller](https://github.com/actions/actions-runner-controller) - セルフホストランナー用 `Kubernetes コントローラー`。
- [philips-labs/terraform-aws-github-runner](https://github.com/philips-labs/terraform-aws-github-runner) - セルフホストランナー用の `Terraform モジュール`。

構築当時チームではkubernetesを使っていなかったので、運用負荷を考えると難しそうなのかなと考えて`philips-labs/terraform-aws-github-runner`の仕組みを使うことにしました。

:::note warn
※ 現在[Github公式から推奨されている方法](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners)は、`actions-runner-controller/actions-runner-controller`のみになったようです。

とはいえ`philips-labs/terraform-aws-github-runner`の開発も日々活発に行われているので、terraformに慣れている方などはこちらを使うようにしても、個人的には問題ないと思います👌
:::

## [`philips-labs/terraform-aws-github-runner`](https://github.com/philips-labs/terraform-aws-github-runner) について
`philips-labs/terraform-aws-github-runner`はオートスケーリングランナーを作る上で必要になる、AWSリソースを作成するためのterraformモジュールです。
手順を進めていくと、以下のようなリソースがお使いのAWS環境上に作成されます。

<img alt="作成されるリソース" src="https://github.com/philips-labs/terraform-aws-github-runner/raw/main/docs/component-overview.svg" width="500" ></img>
(画像は https://github.com/philips-labs/terraform-aws-github-runner より引用)

### 仕組み
詳しい方なら上記の画像を見てなんとなくイメージがつくかもしれませんが、簡単に書くと以下のような仕組みでランナーが立ち上がることになります。

1. `Github App`がworkflowの実行を検知して、webhook経由で`API Gateway`にリクエストを送信
2. `API Gateway`は`リクエスト元検証用のLambda`に、リクエスト元が`Github App`であるか検証を依頼
3. 検証できた場合`Lambda`は`sqs`にメッセージを送信
4. `sqs`を監視していた`ランナー立ち上げ用のLambda`が、事前に作成された`テンプレート`を元に※`EC2スポットインスタンス`を立ち上げる
5. スケジュールに基づいて`ランナー削除用のLambda`が、インスタンスを終了させる。

:::note info
※ [EC2スポットインスタンス](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-best-practices.html)は、AWS側の空き資源を利用するインスタンスです。空き具合によって終了させられる可能性がある分、通常より安価でインスタンスを利用することができます。
:::

## 手順
基本は[`philips-labs/terraform-aws-github-runner`](https://github.com/philips-labs/terraform-aws-github-runner)の手順に沿って行えば問題ありません。

大まかに手順を整理すると以下のようになります。

1. Github Appをセットアップ
2. Lambdaの設定ファイルをダウンロード
3. 必要があればEC2のAMIを作成
4. terraformの各種パラメータを設定
5. Github Appに作成したAWSリソースの情報をセット

### 1. Github Appをセットアップ
まずはActionsを使用したいリポジトリやOrgnizationに`Github App`を作成していきます。
こちらはリポジトリやOrgnizationの権限さえあれば、手順に則っていくと詰まることなく作成できるかと思います。

([こちらの記事](https://tech.isid.co.jp/entry/2023/03/06/%E3%82%B3%E3%82%B9%E3%83%88%E5%AE%89%E3%81%AACI%E7%92%B0%E5%A2%83%E3%82%92%E7%9B%AE%E6%8C%87%E3%81%97%E3%81%A6%E3%82%AA%E3%83%BC%E3%83%88%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB%E3%81%99%E3%82%8BCI%E7%92%B0)で詳しくまとめていただいていたので、ぜひ参考にしてみてください)

### 2. Lambdaの設定ファイルをダウンロード
`philips-labs/terraform-aws-github-runner`が提供しているLambdaの設定ファイルをダウンロードします。
[こちらのページ](https://github.com/philips-labs/terraform-aws-github-runner/releases)から各zipファイルをダウンロードするか、[こちらのterraform](https://github.com/philips-labs/terraform-aws-github-runner/blob/main/modules/download-lambda)を実行してダウンロードできます。

ダウンロードしてきたファイルたちは、後述するメインのterraformの実行内容にて使用します。

### 3. 必要があればEC2のAMIを作成
`EC2スポットインスタンス`で使用するAMIを作成します。
`philips-labs/terraform-aws-github-runner`はデフォルトで`Amazon Linux2`のAMIを使ってインスタンスを立ち上げてくれます。
このAMIをカスタマイズしたい場合は、[こちらの内容](https://github.com/philips-labs/terraform-aws-github-runner/tree/main/images)を参考に、[packer](https://www.packer.io/)を使用してAMIを作成することができます。

筆者はすでに使っていたランナーの環境が`ubuntu-focal-20.04`を使用していたので、そのカスタムAMIを作成しました。

以下はそのAMI作成のpackerの記述になります。ほぼテンプレそのままです。長いのでそのままのところは一部割愛。

```hcl:github_agent.ubuntu.pkr.hcl
packer {
  required_plugins {
    amazon = {
      version = ">= 0.0.2"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "runner_version" {
  description = "The version (no v prefix) of the runner software to install https://github.com/actions/runner/releases. The latest release will be fetched from GitHub if not provided."
  default     = null
}

variable "region" {
  description = "The region to build the image in"
  type        = string
  default     = "****" // AWSのリージョン
}

    ...

variable "instance_type" {
  description = "The instance type Packer will use for the builder"
  type        = string
  default     = "c7g.large" // AMIのビルドに使用するインスタンスイメージ
}

variable "root_volume_size_gb" {
  type    = number
  default = 8
}
    ...

locals {
  runner_version = coalesce(var.runner_version, trimprefix(jsondecode(data.http.github_runner_release_json.body).tag_name, "v"))
}

source "amazon-ebs" "githubrunner" {
  ami_name                    = "github-runner-ubuntu-focal-arm64-${formatdate("YYYYMMDDhhmm", timestamp())}" // 作成されるAMIの名前
  instance_type               = var.instance_type
  region                      = var.region
  security_group_id           = var.security_group_id
  subnet_id                   = var.subnet_id
  associate_public_ip_address = var.associate_public_ip_address

  source_ami_filter {
    filters = {
      name                = "*ubuntu/images/hvm-ssd/ubuntu-focal-20.04-arm64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["******"]
  }
  ssh_username = "ubuntu"

    ...
}

build {
  name = "githubactions-runner"
  sources = [
    "source.amazon-ebs.githubrunner"
  ]
  provisioner "shell" {
    environment_vars = [
      "DEBIAN_FRONTEND=noninteractive"
    ]
    // AMIビルド時に実行したいコマンド達を書く。dockerなど、workflowで使用したいツールやパッケージなどはここで追加しておく。
    inline = concat([
      "sudo cloud-init status --wait",
      "sudo apt-get -y update",
        ...
      "sudo ./aws/install",
    ], var.custom_shell_commands)
  }

  provisioner "file" {
    content = templatefile("../install-runner.sh", {
        ...
}
```

```:packerファイル構成
.
├── images
│   ├── install-runner.ps1
│   ├── install-runner.sh
│   ├── start-runner.ps1
│   ├── start-runner.sh
│   └── ubuntu-focal
│       └── github_agent.ubuntu.pkr.hcl
```

ここまでできたら、vpcやsubnetのid、access_keyやsecret_keyを環境変数において、packerのコマンドを実行していきます。

```bash:packerコマンドの実行
packer init .
packer validate .
packer build github_agent.ubuntu.pkr.hcl
```

無事AMIが作成されていれば完了です👌

### 4. terraformの各種パラメータを設定

[`philips-labs/terraform-aws-github-runnerのREADME`](https://github.com/philips-labs/terraform-aws-github-runner?tab=readme-ov-file#setup-github-app-part-1)を参考に、terraformの内容を記述していきます！

最終的に次のようなファイル内容になりました。

```terraform: main.tf
terraform {
// S3にtfstateを置いておきたい場合に必要な設定。
  backend "s3" {
    bucket = "バケット名"
    region = "リージョン名"
    key    = "キー名"
  }
}

locals {
  version     = "使用するphilips-labsのバージョン"
  environment = "環境名"
  aws_region  = "リージョン名"
}

variable "github_app_key_base64" {}
variable "github_app_id" {}
variable "vpc_id" {}
variable "subnet_ids" {}
variable "access_key" {}
variable "secret_key" {}

provider "aws" {
  region     = "リージョン名"
  access_key = var.access_key
  secret_key = var.secret_key
}

# ランダムIDの生成
resource "random_id" "random" {
  byte_length = 20
}

module "runners" {
  source  = "philips-labs/github-runner/aws"
  version = "使用するphilips-labsのバージョン"

  # VPCを指定
  aws_region = local.aws_region
  vpc_id     = var.vpc_id
  subnet_ids = var.subnet_ids

  prefix = local.environment
  tags = {
    Project = "プロジェクト名"
  }

  # GithubAppの設定
  github_app = {
    key_base64     = var.github_app_key_base64
    id             = var.github_app_id
    webhook_secret = random_id.random.hex
  }

  # 先にダウンロードしておいたLambdaのパスを指定
  webhook_lambda_zip                = "lambdas-download/webhook.zip"
  runner_binaries_syncer_lambda_zip = "lambdas-download/runner-binaries-syncer.zip"
  runners_lambda_zip                = "lambdas-download/runners.zip"

  # organizationの設定
  enable_organization_runners = true
  runner_group_name           = "設定したいランナーグループ名"

  # ランナーユーザー設定
  runner_run_as = "ubuntu"

  # ubuntuのprebuilt AMI・ユーザーデータを設定
  enable_userdata = false
  ami_owners      = ["AMIのowner_id"]

  # packerで作成したAMI名
  ami_filter = {
    name = ["github-runner-ubuntu-focal-arm64-2023*"]
  }

  block_device_mappings = [{
    # ubuntu rootデバイスの設定
    device_name           = "/dev/sda1"
    delete_on_termination = true
    volume_type           = "gp3"
    volume_size           = 30
    encrypted             = true
    iops                  = null
    throughput            = null
    kms_key_id            = null
    snapshot_id           = null
  }]

  # ssmでのec2アクセスを有効化
  enable_ssm_on_runners = true

  # 生成するランナーの最大数
  runners_maximum_count = 20

  instance_types = ["t4g.large"]

  # ランナーのラベル指定
  runner_extra_labels = "ephemeral"

  # Runnerの立ち上げ待ち時間
  runner_boot_time_in_minutes = 5

  # lambdaなどのcloudwatchに出力する情報量。必要があれば"debug"にする
  log_level = "info"

  # ghes設定
  ghes_url = "https://github..."

  ghes_ssl_verify = true

  # ランナーの使い捨て設定
  enable_ephemeral_runners = true

  # 最低起動時間
  minimum_running_time_in_minutes = 5

  # webhookの遅延をなしに
  delay_webhook_event = 0

  # 余分なランナーを作成しない
  enable_job_queued_check = true

  # イベント順を無視
  enable_fifo_build_queue = false

  # scaling downスケジュールを上書き
  scale_down_schedule_expression = "cron(* * * * ? *)"

  # ランナーのpool設定
  pool_runner_owner = "ランナーのowner名"
  pool_config = [{
    size                = 1
    schedule_expression = "cron(* * * * ? *)"
  }]
  # pool時間を60秒に
  pool_lambda_timeout = 60

}

```

:::note info
これらの設定項目はあくまで一例です。
`philips-labs/terraform-aws-github-runner`には他にも様々な設定項目があるので、より便利な使い方をしたい方は[README](https://github.com/philips-labs/terraform-aws-github-runner?tab=readme-ov-file#inputs)をご覧ください🙌
:::

また、main.tfで使用するtfvarsのファイルも作成します。
ここで、先ほど作成した`Github Appのid`と`base64エンコードさせたGithub Appの秘密鍵`を忘れずに設定します。

```tf:terraform.tfvars
github_app_key_base64 = "base64エンコードさせたgithub appの秘密鍵"
github_app_id = github appのid
...
```

最終的に以下のようなファイル構成になりました！

```:ファイル構成
.
├── images
│   ├── install-runner.ps1
│   ├── install-runner.sh
│   ├── start-runner.ps1
│   ├── start-runner.sh
│   └── ubuntu-focal
│       └── github_agent.ubuntu.pkr.hcl
├── lambdas-download
│   ├── runner-binaries-syncer.zip
│   ├── runners.zip
│   └── webhook.zip
├── main.tf
├── modules
│    ├──...
├── outputs.tf
├── package-lock.json
└── terraform.tfvars

```

#### terraformの実行

この状態でterraformを実行していきます！
```bash:terraformの実行
terraform init
terraform apply
```
無事にAWSのリソースが作成されれば完了です✅

### 5. Github Appに作成したAWSリソースの情報をセット
最後に作成したAWSリソースの情報をGithub Appに設定していきます。
こちらも[`philips-labs/terraform-aws-github-runnerのReadMe`](https://github.com/philips-labs/terraform-aws-github-runner?tab=readme-ov-file#setup-the-webhook--github-app-part-2)の手順に則っていくと詰まることなく作成できるかと思いますので、詳細は割愛させていただきます👍

(詳しくはまたまた[こちらの記事](https://tech.isid.co.jp/entry/2023/03/06/%E3%82%B3%E3%82%B9%E3%83%88%E5%AE%89%E3%81%AACI%E7%92%B0%E5%A2%83%E3%82%92%E7%9B%AE%E6%8C%87%E3%81%97%E3%81%A6%E3%82%AA%E3%83%BC%E3%83%88%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB%E3%81%99%E3%82%8BCI%E7%92%B0)が参考になるかと思います。)


以上で全ての手順が完了となります！
これで今まで通りの使い方で、`オートスケーリングな使い捨てランナー`を使うことができるようになっているはずです👏

# 実装のポイント
いくつかこの仕組みを作る上で詰まったポイントがありましたので、皆さんが同じ轍を踏まないように共有させていただきます🤧

### 1. Lambdaたちのdebug
一番最初に全ての手順を完了させた後、ウッキウキでActionsのworkflowを走らせてみたところランナーが立ち上がらず...
なんでだろうと思って`CloudWatch`から`Lambda`のログを見に行ったのですが、具体的な内容は全然書かれていない...
この原因調査に結構な時間を持って行かれてしまいました😇

回り道して発見したのが以下の`ログレベルの記述`です。
```terraform:main.tf
  # lambdaなどのcloudwatchに出力する情報量。必要があれば"debug"にする
  log_level = "info"
```

先ほどのコードでも記述があったので皆さんはもう大丈夫かと思うのですが、
筆者が実装した当時、色々参考にさせていただいたサイトにはこちら書いている方があまりおらず、発見に時間がかかってしまいました(公式ドキュメントはしっかり読まないとですね...)

こちらの記述を`"debug"`にすることで`Lambda`が詳細なログを吐いてくれるようになるので、開発中詰まったらこちらを変更するようにしましょう。

### 2. ghesの設定
上で書いた問題の原因です。
GithubEnterpriseを使用している場合、そのEnterpriseのドメインをterraformの記述に追加する必要がありました。
GHEを使用する方は忘れずに書くようにしましょう👍
```terraform:main.tf
  # ghes設定
  ghes_url = "https://github..."
```

### 3. EC2スポットインスタンスのサイズ
ようやくインスタンスが立ち上がるようになったと思いきや、今度はworkflowのjobが実行されない(または中々実行が進まない)問題が発生しました🤔
インスタンス自体はきちんと作成されているので、Lambdaのログにも異常はありません。
筆者はその謎を解き明かすべく、インスタンスの奥地へと向かいました。

するとどうやら、インスタンスの立ち上げ自体に時間がかかっているようでした。
見かけ上は立ち上がっていても、実際に使える状態にまではなっていません。(諸々のツールのダウンロードなんかに時間がかかるよう...?)

こちらはインスタンスタイプを性能の高いものに変更することで解消しました。
`"t4g.small"`などではパワーが足りないことがあり、実用する上では`"t4g.medium"`、`"t4g.large"`などが安定するなといった所感でした。

もちろんコストは上がってしまいますが、それでも`スポットインスタンス`なので筆者チームの使い方の場合`通常インスタンス`を常駐させておくよりは安く付きました。

workflowのjobが中々実行されない場合は、インスタンスサイズを少し大きくしてあげると改善するかもしれません👍

### 4. EC2のセキュリティグループ
これは詰まったポイントではないのですが、役に立ちそうな知見なので共有しておきます。
Github Actionsのセルフホストランナーですが、実は通常 [`アウトバウンド通信しか必要としません`](https://docs.github.com/ja/enterprise-server@3.11/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#%E3%82%BB%E3%83%AB%E3%83%95%E3%83%9B%E3%82%B9%E3%83%88%E3%83%A9%E3%83%B3%E3%83%8A%E3%83%BC%E3%81%A8github-enterprise-server%E3%81%A8%E3%81%AE%E9%80%9A%E4%BF%A1)。
(最初に知った時はかなり意外でした)

そのためEC2ランナーのセキュリティグループでは特段理由がない限り、***インバウンド通信を拒否***することをおすすめします🙌
万が一変なところからアクセスされるんじゃないかと心配するくらいなら、いっそ全部拒否してしまった方が気が楽です。(使い捨てインスタンスなのでそもそも可能性は低いですが。)

ちなみに何かの理由があってインスタンスに直接アクセスしたい場合は、ssmで接続できるようにterraformから設定ができるので、そちらを使うのが良いかと思います👍

# おわりに
長くなってしまいましたが、オートスケーリングな使い捨てランナーを作成する簡単な手順と、いくつか注意すべきポイントについて書かせていただきました。

この仕組みを導入した結果、workflowジョブの待ち時間は無くなり、ランナーのメンテナンスもほとんど不要になりました。
ただしAMIは定期的にビルドしたいと考えているため、今後はその部分の自動化にも取り組んでいきたいと考えています👍

筆者と同じようにセルフホストランナー周りで不満がある方たちは、ぜひこの仕組みを導入してみてはいかがでしょうか！
この記事が何かの役に立つことを祈っています！

それでは皆さま、良いGithub Actionsライフを✅


# 参考

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

https://blog.cybozu.io/entry/2022/12/01/102842

https://tech.isid.co.jp/entry/2023/03/06/%E3%82%B3%E3%82%B9%E3%83%88%E5%AE%89%E3%81%AACI%E7%92%B0%E5%A2%83%E3%82%92%E7%9B%AE%E6%8C%87%E3%81%97%E3%81%A6%E3%82%AA%E3%83%BC%E3%83%88%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB%E3%81%99%E3%82%8BCI%E7%92%B0