25
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ディップAdvent Calendar 2023

Day 17

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

Last updated at Posted at 2023-12-16

ディップ Advent Calendar 2023 の17日目の記事です!

はじめに

皆さん、Github Actions 使ってますか?

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

このGithub Actionsですが、Github Enterprise上で使うためには セルフホストランナー と呼ばれる、Actionsの実行環境を自前で作成する必要があります。
筆者の所属するチームでは、このセルフホストランナーをAWSのEC2マシンを使って用意していたのですが、運用面で若干不満があり...

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

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

目次

導入背景

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

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

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

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

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

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

を作るに至ります🙌

オートスケーリングランナーの構築

オートスケーリングなセルフホストランナーを構築するためにGithub公式から※当時推奨されていた方法は、以下の2つです。

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

※ 現在Github公式から推奨されている方法は、actions-runner-controller/actions-runner-controllerのみになったようです。

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

philips-labs/terraform-aws-github-runner について

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

作成されるリソース
(画像は https://github.com/philips-labs/terraform-aws-github-runner より引用)

仕組み

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

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

EC2スポットインスタンスは、AWS側の空き資源を利用するインスタンスです。空き具合によって終了させられる可能性がある分、通常より安価でインスタンスを利用することができます。

手順

基本は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の権限さえあれば、手順に則っていくと詰まることなく作成できるかと思います。

(こちらの記事で詳しくまとめていただいていたので、ぜひ参考にしてみてください)

2. Lambdaの設定ファイルをダウンロード

philips-labs/terraform-aws-github-runnerが提供しているLambdaの設定ファイルをダウンロードします。
こちらのページから各zipファイルをダウンロードするか、こちらのterraformを実行してダウンロードできます。

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

3. 必要があればEC2のAMIを作成

EC2スポットインスタンスで使用するAMIを作成します。
philips-labs/terraform-aws-github-runnerはデフォルトでAmazon Linux2のAMIを使ってインスタンスを立ち上げてくれます。
このAMIをカスタマイズしたい場合は、こちらの内容を参考に、packerを使用してAMIを作成することができます。

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

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

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のコマンドを実行していきます。

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

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

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

philips-labs/terraform-aws-github-runnerのREADMEを参考に、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

}

これらの設定項目はあくまで一例です。
philips-labs/terraform-aws-github-runnerには他にも様々な設定項目があるので、より便利な使い方をしたい方はREADMEをご覧ください🙌

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

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

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

ファイル構成
.
├── 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を実行していきます!

terraformの実行
terraform init
terraform apply

無事にAWSのリソースが作成されれば完了です✅

5. Github Appに作成したAWSリソースの情報をセット

最後に作成したAWSリソースの情報をGithub Appに設定していきます。
こちらもphilips-labs/terraform-aws-github-runnerのReadMeの手順に則っていくと詰まることなく作成できるかと思いますので、詳細は割愛させていただきます👍

(詳しくはまたまたこちらの記事が参考になるかと思います。)

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

実装のポイント

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

1. Lambdaたちのdebug

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

回り道して発見したのが以下のログレベルの記述です。

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

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

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

2. ghesの設定

上で書いた問題の原因です。
GithubEnterpriseを使用している場合、そのEnterpriseのドメインをterraformの記述に追加する必要がありました。
GHEを使用する方は忘れずに書くようにしましょう👍

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

3. EC2スポットインスタンスのサイズ

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

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

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

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

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

4. EC2のセキュリティグループ

これは詰まったポイントではないのですが、役に立ちそうな知見なので共有しておきます。
Github Actionsのセルフホストランナーですが、実は通常 アウトバウンド通信しか必要としません
(最初に知った時はかなり意外でした)

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

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

おわりに

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

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

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

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

参考

25
8
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?