GitHub Actions on AWS with CDK

はじめに

こんにちは、イノベーションセンターの福田です。

今回、開発環境改善の取り組みとして GitHub Actions の self-hosted runners を AWS 上に構築しました。 この構築で得られた知見について共有します。

概要

GitHub Actions は GitHub で CI/CD を手軽に実現する機能です。 GitHub が提供している環境を利用して、 CI/CD のジョブを実行できます1。 一方で、ハードウェア等をカスタマイズできないため、例えば容量が大きくより速度の早いストレージを利用したい場合や、より多くのメモリを利用したい場合に対応ができません。 そこで、GitHub Actions には self-hosted runners という機能があり、自身の環境で GitHub Actions の CI/CD ジョブを走らせる環境を用意できます。

今回はより柔軟に利用しやすく、保守しやすいように AWS Cloud Development Kit (以下CDK)を用いて AWS 上に self-hosted runners 環境を構築したので、その構築手順等について解説していきたいと思います。

アーキテクチャ

今回構築するシステムのアーキテクチャについて解説します。 構築するインフラの構成図は次の通りです。

図の中で点線によってグループ化されていますが、本システムは大きく次の2つの部分からなっています。

  1. Webhook イベントの受信部分
  2. self-hosted runners 環境提供部分

GitHub では GitHub Actions の CI/CD ジョブが走り始めたときに Webhook イベントを飛ばすことができ、これを利用して CI/CD ジョブの開始と同時にその CI/CD ジョブを走らせる self-hosted runners を起動するというシステムになっています。

Webhook イベントの受信

まず、 self-hosted runners で走らせたい CI/CD ジョブが開始されたのかを知る必要があります。 GitHub には workflow_job というイベントが Webhook イベントとして出ており、このイベントのペイロードに以下の情報が含まれています。

  • ジョブの実行環境の指定
    • GitHub-hosted runners での実行か、 self-hosted runners での実行かも指定されています。
  • ジョブのステータス
    • ジョブの開始が要求されたのか、現在ジョブが走っているのか、ジョブが終了したのかのステータスがわかります。

この workflow_job イベントを Webhook イベントとして受信することで self-hosted runners で走らせたい CI/CD ジョブが開始されたのかを知ることができます。 したがって、最初に workflow_job イベントを受信する Webhook の受信先を AWS に用意します。 AWS には API Gateway というサービスがあり、このサービスを使って容易に Web API を作成できます。 なので、 workflow_job イベントの受信部分に関してはこの API Gateway を使って構築します。

しかし、 workflow_job は GitHub-hosted runners で走らせたい CI/CD ジョブが開始されたときにも発行されるイベントです。 そのため、 API Gateway で作成した Web API でこの workflow_job イベントを受信し self-hosted runners を起動するという単純な実装にしてしまうと self-hosted runners で走らせたいジョブ数よりも大くの self-hosted runners が起動し、予定よりも多くの課金がなされてしまいます。 これを避けるため、作成した Web API が webhook イベントを受信したら self-hosted runners で走らせたい CI/CD ジョブの開始が要求されたイベントだけを抽出し、それ以外のイベントは無視するような仕組みを導入します。 今回使用する API Gateway は Lambda 関数 の HTTP エンポイントを提供できるので、この機能を利用し、作成した Web API のバックエンドで Lambda 関数によるイベントフィルタリングの機能を追加します。

しかし、この Lambda 関数の HTTP エンドポイントの機能には Lambda 関数の最大実行時間が29秒までであるという制約が存在します。 そのまま self-hosted runners を動かそうとするとこの制約にひっかかってしまい、 self-hosted runners が起動できなくなる可能性があります。 これを回避するために、 Lambda 関数はフィルターを行った後に self-hosted runners を起動するようなことはせず、一旦 SQS にフィルターしたイベントを送信します。 こうすれば SQS からあとの処理は独立して行えるため、先述した制限を回避できます。2

以上をまとめると、 Webhook の受信部分は次のようなインフラストラクチャーを構築することになります。

self-hosted runners の提供

ここからは受信した Webhook イベントを元に、適切な self-hosted runners を走らせる VM (以下、 self-hosted runners ノード) を立ち上げるためのインフラ構成について解説します。

SQS には Webhook イベントが送信されているので、まずはその SQS からメッセージを受信する部分を構築します。 SQS がメッセージを受信したとき、Lambda 関数へメッセージの受信イベントを通知できます3。 この機能を利用して、 SQS から Webhook イベントを受信次第 self-hosted runners ノードとなる VM である EC2 インスタンス を起動する Lambda 関数を用意します。 この際、 EC2 インスタンスを EC2 インスタンスの起動を行う API を通して起動することになるのですが、ここで Launch Template という EC2 インスタンスの起動パラメータのテンプレートを利用して起動するようにします。 Launch Template は CDK で簡単に用意することができる ため、こちらの機能を通して起動する方が Lambda 関数に EC2 インスタンスの起動パラメータを全て渡すようなやり方よりも楽に保守・管理・運用できます4。 このように、 SQS から Webhook イベントを受信してから Lambda 関数を起動し、その Lambda 関数は self-hosted runners ノードを起動することで self-hosted runners を必要な分だけ提供できます。

起動した self-hosted runners ノードは CI/CD ジョブを実行し、その CI/CD ジョブが終了次第シャットダウンするようにします。 EC2 インスタンスにはシャットダウン終了後に自動で VM の削除するように設定でき、この設定もしておきます。 こうすることでインスタンスやそのストレージへの課金額を抑えられると同時に、前のジョブの情報がインスタンス内に残らないようにできます。 つまり、毎回クリーンな環境でインスタンスを起動でき、各ジョブに依存関係が発生するのを防ぐことができます。

最後に、 self-hosted runners を起動する際には以下の2つの処理を self-hosted runners の起動前に行う必要があるので、これらを EC2 インスタンスの 起動時にスクリプトを走らせる機能 を使っておこないます。

  1. 最新版の self hosted runners のダウンロード
  2. self-hosted runners を登録

このとき、シェルクリプトでやるには複雑すぎることがあったり、やりとりされるパケットの量を抑えたりするために、いくつかの処理を Lambda 関数を通して行うようにします。

これらをまとめると、以下のような構成図を構築することになります。

図中には S3 バケットが出現していますが、こちらは 1. のダウンロードしてきた self-hosted runners をキャッシュするために利用しています。

CDK によるインフラ構築

先述したシステムを Web コンソールや CLI だけで構築し、適切に保守・運用していくのは難しいため CDK を利用します。

self-hosted runners 環境

最初に self-hosted runners ノードを起動する仕組みを構築します。 self-hosted runners ノード自体は Public な場所に置いておく必要はなく、 GitHub との接続ができればよいため Private なサブネット へと設置するようにし、 Public なサブネットに NAT ゲートウェイ を設置して Private サブネットの通信をそこへ流すようにします。

まずは self-hosted runners ノード起動時に走らせるスクリプト内で呼ばれる補助 Lambda 関数を用意します。

  1. self-hosted runners を提供するプログラムをダウンロードする関数
    • ダウンロードするバイナリーにバージョン番号がついているため、先に最新版のバージョン番号を GitHub API から取得しておく必要があり、スクリプトの複雑度や制御難易度を上げてしまうので肩代わりさせる
    • 関数を呼び出せば最新版が常に手に入り、テストも用意になる
  2. 走らせる self-hosted runners を GitHub に登録するためのトークンをつくる関数
    • この部分は JWT をつくったりするなど多少複雑なロジックを実装する必要があり、 bash スクリプトでやるよりも Lambda 関数に処理を移譲してしまった方が良さそうです

実は 1. の場合は Lambda 関数の制限として 6MB までのレスポンスしか返せません。 self-hosted runners はこの制限である 6MB を越えています。 そのため、Lambda 関数としては self-hosted runners をそのまま返すことはせずに、 S3 バケット に一旦ダウンロードしてきたものを配置し、その配置先をレスポンスに載せます。 ダウンロードは VPC に S3 のエンドポイントを設置して、 Lambda 関数のレスポンスから配置先をよみとって S3 バケットからダウンロードしてきます。 こうすることで、副次的に NAT ゲートウェイ を通るパケットの量を減らすことができるので、 NAT ゲートウェイの料金を抑える効果もあります。

さて、この2つの関数を CDK に載せていきます。 なお、ここでは用意する関数はすべて Go 言語 で記載しています。 また、 Go 言語の module 機能を利用するので go v1.11 以上が必要です。 さらに、 CDK では Python や Java 等様々な言語が使えますが、ここでは TypeScript を用いて CDK コードを記述していきます。

最初に 1. の関数を載せます。 やりたいこととしては以下の4つになります。

  1. ダウンロードしたい self-hosted runners のバージョンを取得
  2. 先程取得したバージョンを指定して self-hosted runners をダウンロード
  3. ダウンロードしたものを S3 バケットに設置
  4. 設置先を関数の返り値として返す

これを動かすコードは以下のようになります。

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

type Response struct {
    Bucket string `json:"bucket"`
    Key    string `json:"key"`
}

type PackageMetadata struct {
    TagName string `json:"tag_name"`
}

func Fetch(url string) ([]byte, error) {
    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return ioutil.ReadAll(resp.Body)
}

func HandleRequest(ctx context.Context) (Response, error) {
    // self-hosted runners のキャッシュ先 S3 バケット名は
    // 環境変数として設定する
    bucket, has := os.LookupEnv("BUCKET")
    if !has {
        return Response{}, fmt.Errorf("No such environement: BUCKET")
    }

    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return Response{}, err
    }
    client := s3.NewFromConfig(cfg)

    // 1. ダウンロードしたい self-hosted runners のバージョンを取得
    //
    // ここでは最新版をダウンロード
    b, err := Fetch("https://api.github.com/repos/actions/runner/releases/latest")
    if err != nil {
        return Response{}, err
    }
    m := &PackageMetadata{}
    if err := json.Unmarshal(b, m); err != nil {
        return Response{}, err
    }
    // 先頭に `v` がプリフィックスとしてついてるので、
    // そこだけ無視してバージョンを取得
    ver := m.TagName[1:]

    // 2. 先程取得したバージョンを指定して self-hosted runners をダウンロード
    b, err = Fetch(
        fmt.Sprintf("https://github.com/actions/runner/releases/download/v%s/actions-runner-linux-x64-%s.tar.gz", ver, ver)
    )
    if err != nil {
        return Response{}, err
    }

    // 3. self-hosted runners を S3 に設置する
    param := &s3.PutObjectInput{
        Bucket: aws.String(bucket),
        // 決め打ちで runner.tar.gz というキーで
        // self-hosted runners のバイナリーを保存しておく
        Key:    aws.String("runner.tar.gz"),
        Body:   b,
    }
    if _, err := client.PutObject(ctx, param); err != nil {
        return Response{}, err
    }

    // 4. 設置先を関数の返却値として返す
    return Response{Bucket: bucket, Key: "runner.tar.gz"}, nil
}

func main() {
    lambda.Start(HandleRequest)
}

Lambda は Docker コンテナを動かせる ので、 そのための Dockerfile を書きます。

FROM --platform=$TARGETPLATFORM golang:1 as build
WORKDIR /usr/src/app
ARG TARGETOS
ARG TARGETARCH
COPY go.mod go.sum ./
RUN go mod download && go mod tidy
COPY . .
RUN env CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -v -o /main ./...

FROM --platform=$TARGETPLATFORM public.ecr.aws/lambda/provided:al2
COPY --from=build /main ${LAMBDA_RUNTIME_DIR}/bootstrap
CMD [ "main" ]

同じように 2. の関数も用意します。 注意点として、 事前に GitHub Apps として self-hosted runners を登録する Lambda 関数を登録する必要があります。 GitHub Apps として登録する際には以下2点を設定しておかなければならないことに注意してください。

  1. self-hosted runners を登録する Lambda 関数が self-hosted runners まわりの操作を Read/Write できるよう権限を付与しておく
  2. GitHub Organizations の全てのリポジトリで動作するようにさせる場合、 Repository AccessAll Repositories に設定しておく

この GitHub Apps への登録ができたら、起動させる self-hosted runners を GitHub へ登録する関数を書いていきます。

package main

import (
    "context"
    "fmt"
    "os"
    "strconv"

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/bradleyfalzon/ghinstallation/v2"
      "github.com/google/go-github/v45/github"
)

func InstallationId(org string) (int64, error) {
    itr, err := ghinstallation.NewAppsTransport(
        https.DefaultTransport,
        appId,
        privateKey,
    )
    if err != nil {
        return 0, err
    }
    client := github.NewCient(&http.Client{Transport: itr})
    listOpt := &github.ListOptions{}
    for {
        is, resp, err := client.Apps.ListInstallations(ctx, listOpt)
        if err != nil {
            return 0, err
        }
        // GitHub Apps の全てのインストール先から、
        // 指定された Organization にインストールされているものを見つけて
        // そのインストールにつけられた ID を取得する。
        for _, inst := range insts {
            if inst.GetAccount().GetLogin() == org {
                return inst.GetID(), nil
            }
        }
        if resp.NextPage == 0 {
            break
        }
        listOpt.Page = resp.NextPage
    }
    return 0, fmt.Errorf("cannot find installation id on %s", org)
}

func HandleRequest(ctx context.Context) (map[string]string, error) {
    org, has := os.LookupEnv("ORGANIZATION")
    if !has {
        return "", fmt.Errorf("No such environment: ORGANIZATION")
    }
    appIdStr, has := os.LookupEnv("GITHUB_APPS_APP_ID")
    if !has {
        return "", fmt.Errorf("No such environment: GITHUB_APPS_APP_ID")
    }
    appId, err = strconv.ParseInt(appIdStr, 10, 64)
    if err != nil {
        return "", fmt.Errorf("GITHUB_APPS_APP_ID is not integer: %s", appIdStr)
    }
    privateKey, has := os.LookupEnv("GITHUB_APPS_APP_PRIVATE_KEY")
    if !has {
        return "", fmt.Errorf("No such environment: GITHUB_APPS_APP_PRIVATE_KEY")
    }

    instId, err := InstallationId(org)
    if err != nil {
        return "", err
    }

    // 2. self-hosted runners を登録する際に必要となるトークンの発行
    itr, err := ghinstallation.New(http.DefaultTransport, appId, instId, privateKey)
    if err != nil {
        return "", err
    }
    client := github.NewCient(&http.Client{Transport: itr})
    resp, _, err := client.Actions.CreateOrganizationRegistrationToken(ctx, org)
    if err != nil {
        return "", err
    }
    return resp.GetToken(), nil
}

func main() {
    lambda.Start(HandleRequest)
}

先述した2つの関数を CDK として載せます。

import * as cdk from "aws-cdk-lib";
import * as { Construct } from "constructs";
import * as path from "path";

// self-hosted runners をダウンロードする Lambda 関数
export class RunnerDownloader extends cdk.aws_lambda.DockerImageFunction {
    constructor(scope: Construct, id: string) {
        super(scope, id, {
            code: cdk.aws_lambda.DockerImageCode.fromImageAsset(
                // Lambda 関数のコードの設置先はこのソースファイルと同じ階層にある
                // `handler/runner-downloader` に
                path.join(__dirname, "handler", "runner-downloader"),
            ),
            timeout: cdk.Duration.minutes(5),
            memorySize: 1024,
        });

        const storage = new cdk.aws_s3.Bucket(this, "Storage", {
            lifecycleRules: [
                // 7日間だけはキャッシュしておくが、
                // 7日超えたら最新のものを取得
                {
                    enabled: true,
                    expiration: cdk.Duration.days(7),
                }
            ],
            // キャッシュとしてしか利用しないので、スタックの削除時には
            // このバケット内のオブジェクトを全て削除する
            autoDeleteObjercts: true,
            removalPolicy: cdk.RemovalPolicy.DESTROY,
        });
        storage.grantRead(this);
        storage.grantPut(this);
        this.addEnvironment("BUCKET", storage.bucketName);
    }
}

export type RegistrationTokenGeneratorProps = {
    githubAppsPrivateKey: string;
    githubAppsAppId: string;
};
// self-hosted runners を登録するためのトークンを生成する Lambda 関数
export class RegistrationTokenGenerator extends cdk.aws_lambda.DockerImageFunction {
    constructor(scope: Construct, id: string, props: RegistrationTokenGeneratorProps) {
        super(scope, id, {
            code: cdk.aws_lambda.DockerImageCode.fromImageAsset(
                // Lambda 関数のコードの設置先はこのソースファイルと同じ階層にある
                // `handler/registration-token-generator` に
                path.join(__dirname, "handler", "registration-token-generator")
            ),
            timeout: cdk.Duration.minutes(5),
            environment: {
                GITHUB_APPS_APP_ID: props.githubAppsAppId,
                GITHUB_APPS_PRIVATE_KEY: props.githubAppsPrivateKey,
            },
            memorySize: 1024,
        });
    }
}

用意できたら self-hosted runners を起動するスクリプトを組みます。

#!/usr/bin/env bash

function setup() {
    local -r \
        runner_downloader="$1" \
        registration_token_generator="$2" \
        organization="$3" \
        runner_directory="$4" \

    mkdir "$runner_directory" && cd "$runner_directory" || return 1

    # self-hosted runners を取得する
    local -r runner_json=/tmp/runner.json
    aws lambda invoke \
        --function-name "$runner_downloader" \
        --payload "{}" \
        --cli-binary-format raw-in-base64-out \
        "$runner_json"
    local -r runner_archive=/tmp/runner.tar.gz
    aws s3api get-object \
        --bucket "$(jq -r .bucket "$runner_json")" \
        --key "$(jq -r .key "$runner_json")" \
        "$runner_archive"
    tar zxf "$runner_archive"

    # self-hosted runners の登録用トークン発行
    local -r registration_token_json=/tmp/registration-token.json
    aws lambda invoke \
        --function-name "$registration_token_generator" \
        --payload "{}" \
        --cli-binary-format raw-in-base64-out \
        "$registration_token_json"
    local registration_token
    registration_token="$(jq -r .token "$registration_token_json")"

    # runner の初期設定を行う
    ./config.sh \
        --url "https://github.com/$organization" \
        --disable-update \
        --ephemeral \
        --unattended
}

function main() {
    local -r \
        runner_downloader="$1" \
        registration_token_generator="$2" \
        organization="$3"

    # self-hosted runners を走らせるためのユーザーを作成
    useradd -m -g users -G sudo -s /bin/bash runner

    # self-hosted runners を起動する準備を行う
    local runner_directory=~runner/runner
    su \
        - runner \
        -c "$(declare -f setup); setup \"$runner_downloader\" \"$registration_token_generator\" \"$organization\" \$runner_directory\""
    cd "$runner_directory" && ./bin/installdependencies.sh

    # self-hosted runners を起動
    # self-hosted runners がタスクを走り終えたら poweroff して VM を終了させる
    su - runner -c "cd '$runner_directory' && ./run.sh" \
        && systemctl poweroff
}

main "$@"

あとはこのスクリプトを利用して self-hosted runners ノードとなる EC2 インスタンスのための Launch Template を作成します。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as path from "path";
// 先程用意した補助関数をインポートする。
import { RunnerDownloader } from "./runner-downloader";
import { RegistrationTokenGenerator } from "./registration-token-generator";

export type LaunchTemplateProps = {
    runnerDownloader: RunnerDownloader;
    registrationTokenGenerator: RegistrationTokenGenerator;
    organization: string;
};
export class LaunchTemplate extends cdk.Resource {
    public readonly launchTemplate: cdk.aws_ec2.LaunchTemplate;

    constructor(scope: Construct, id: string, props: LaunchTemplateProps) {
        super(scope, id);
        const role = new cdk.aws_iam.Role(this, "Role", {
            assumedBy: new cdk.aws_iam.ServicePrincipal("ec2.amazonaws.com"),
            managedPolicies: [
                cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
            ],
        });

        const asset = new cdk.aws_s3_assets.Asset(this, "UserData", {
            // 先程のスクリプトを start.bash という名前で保存しておく。
            path: path.join(__dirname, "start.bash"),
        });
        asset.grantRead(role);
        const userData = cdk.aws_ec2.UserData.forLinux({
            shebang: "#!/usr/bin/env bash",
        });
        const filePath = this.userData.addS3DownloadCommand({
            bucket: asset.bucket,
            bucketKey: asset.s3ObjectKey,
        });
        userData.addExecuteFileCommand({
            filePath,
            arguments: [
                props.runnerDownloader.functionArn,
                props.registrationTokenGenerator.funcitonArn,
                organization,
            ].join(" "),
        });

        this.launchTemplate new cdk.aws_ec2.LaunchTemplate(
            this,
            "LaunchTemplate",
            {
                role,
                instanceType: cdk.aws_ec2.InstanceType.of(
                    cdk.aws_ec2.InstanceClass.T3,
                    cdk.aws_ec2.InstanceSize.SMALL,
                ),
                // ここには GitHub Actions で利用するイメージを指定する。
                // ただし、先程のスクリプト内で awscli v2 と jq を利用しているので、
                // これらを含めた上で、 GitHub Actions でサポートされている
                // タイプの OS を指定する必要がある。
                //
                // 参考: https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners#supported-architectures-and-operating-systems-for-self-hosted-runners
                machineImage: GithubActionsMachineImage,
                userDate,
                ebsOptimized: true,
                // シャットダウン時にはインスタンスを terminate させる。
                instanceInitiatedShutdownBehavior: cdk.aws_ec2.InstanceInitiatedShutdownBehavor.TERMINATE,
                blockDevices: [
                    {
                        deviceName: "/dev/sda1",
                        volume: cdk.aws_ec2.BlockDeviceVolume.ebs(100, {
                            volumeType: cdk.aws_ec2.EvsDeviceVolumeType.GP3,
                            deleteOnTermination: true,
                            encrypted: true,
                        })
                    }
                ]
            }
        );
    }
}

次にこの Launch template からインスタンスを起動する Lambda 関数を書きます。 まずは関数のコードを用意します(この関数も先程までの関数と同じで Lambda コンテナイメージとして提供します)。

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/ec2"
    "github.com/aws/aws-sdk-go-v2/service/ec2/types"
)

// あとでこの関数を SQS に接続し、 SQS からイベントをうけとったら起動するようにするため、
// ここでは events.SQSEvent を受け取るようにしています。
func HandleRequest(ctx context.Context, req events.SQSEvent) error {
    ltId, has := os.LookupEnv("LAUNCHTEMPLATE_ID");
    if !has {
        return fmt.Errorf("No such environment: LAUNCHTEMPLATE_ID");
    }
    ltVer, has := os.LookupEnv("LAUNCHTEMPLATE_VERSION");
    if !has {
        return fmt.Errorf("No such environment: LAUNCHTEMPLATE_VERSION")
    }
    snId, has := os.LookupEnv("SUBNET_ID");
    if !has {
        return fmt.Errorf("No such environment: SUBNET_ID");
    }
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return err
    }
    client := ec2.NewFromConfig(cfg)

    for range req.Records {
        param := &ec2.RunInstancesInput{
            LaunchTemplate: &types.LaunchTemplateSpecification{
                LaunchTemplateId: aws.String(ltId),
                Version:          aws.String(ltVer),
            },
            SubnetId: aws.String(snId),
            MaxCount: aws.Int32(1),
            MinCount: aws.Int32(1),
        }
        resp, err := client.RunInstances(ctx, param)
        if err != nil {
            return err
        }
    }

    return nil
}

func main() {
    lambda.Start(HandleRequest)
}

この関数の CDK コードを用意します。

// 先程の LaunchTemplate クラスをインポートする。
import { LaunchTemplate } from "./launch-template";

export type InstanceLauncherProps = {
    launchTemplate: LaunchTemplate;
    subnet: cdk.aws_ec2.ISubnet;
};
// self-hosted runners を登録するためのトークンを生成する Lambda 関数
export class InstanceLauncher extends cdk.aws_lambda.DockerImageFunction {
    constructor(scope: Construct, id: string, props: InstanceLauncherProps) {
        super(scope, id, {
            code: cdk.aws_lambda.DockerImageCode.fromImageAsset(
                // Lambda 関数のコードの設置先はこのソースファイルと同じ階層にある
                // `handler/registration-token-generator` に
                path.join(__dirname, "handler", "instance-launcher")
            ),
            timeout: cdk.Duration.minutes(5),
            environment: {
                LAUNCHTEMPLATE_ID: props.launchTemplate.launchTemplateId,
                LAUNCHTEMPLATE_VERSION: props.launchTemplate.latestVersionNumber,
                SUBNET_ID: props.subnet.subnetId,
            },
            memorySize: 1024,
        });

        this.addToRolePolicy(
            new cdk.aws_iam.PolicyStatement({
                resources: ["*"],
                // EC2 インスタンスを立ち上げるには `ec2:RunInstances` が必要。
                // また、 立ち上げた EC2 インスタンスにロールを付与するために `iam:PassRole` も必要で、
                // さらにタグを Launch Template を通してつけたりするためには CreateTags が必要となる。
                actions: ["ec2:RunInstances", "iam:PassRole", "ec2:CreateTags"]
            })
        );
    }
}

最後にこれらをまとめたコンポーネントを作成します。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { InstanceLauncher } from "./instance-launcher";
import { RunnerDownloader } from "./runner-downloader";
import { RegistrationTokenGenerator } from "./registration-token-generator";
import { LaunchTemplate } from "./LaunchTemplate";

export type SelfHostedRunnerProps = {
    githubAppsAppId: string;
    githubAppsPrivateKey: string;
    organization: string;
    subnet: cdk.aws_ec2.ISubnet;
};
export class SelfHostedRunner extends cdk.Resource {
    public readonly instanceLauncher: InstanceLauncher;

    constructor(scope: Construct, id: string, props: SelfHostedRunnerProps) {
        super(scope, id);

        const runnerDownloader = new RunnerDownloader(this, "RunnerDownloader");
        const registrationTokenGenerator = new RegistrationTokenGenerator(
            this,
            "RegistrationTokenGenerator",
            {
                githubAppsAppId: props.githubAppsAppId,
                githubAppsPrivateKey: props.githubAppsPrivateKey,
            }
        );
        const launchTemplate = new LaunchTemplate(
            this,
            "LaunchTemplate",
            {
                runnerDownloader,
                registrationTokenGenerator,
                organization: props.organization,
            }
        );
        this.instanceLauncher = new InstanceLauncher(
            this,
            "InstanceLauncher",
            {
                launchTemplate,
                subnet: props.subnet,
            }
        );
    }
}

Webhook

self-hosted runners ノードを起動する部分の作成ができたので、今度は Webhook を受信する部分を作成してきます。

最初に、 Webhook イベントを送信する SQS と、先程用意した self-hosted runners ノードを起動する Lambda 関数がその SQS のメッセージ受信したら起動するようにする設定します。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
// instance を立ち上げる Lambda 関数をインポート
import { InstanceLauncher } from "./instance-launcher";

export type InstanceLaunchQueueProps = {
    // プロパティで self-hosted runners ノードを起動する Lambda 関数を渡してもらう
    instanceLauncher: InstanceLauncher;
}
export class InstanchLaunchQueue extends cdk.aws_sqs.Queue {
    constructor(scope: Construct, id: string, props: InstanceLaunchQueueProps) {
        super(scope, id, { visibilityTimeout: cdk.Duration.minutes(5) });

        // SQS のイベントを受信して EC2 インスタンスを立ち上げる Lambda 関数を呼ぶ
        props.intanceLauncher.addEventSource(
            new cdk.aws_lambda_event_sourecs.SqsEventSource(
                this
            )
        );
    }
}

次に Webhook の受信部分となる API Gateway による Web API の作成と、その裏で動くイベントのフィルタリングを行ったあとに SQS へ Webhook イベントを送信する Lambda 関数を実装します。 イベントのフィルタリング方法ですが、 先述したとおり、 workflow_job イベントを利用します。 この workflow_job イベントのペイロードには workflow というオブジェクトが含まれており、この中に status というプロパティがあります。 この status プロパティは CI/CD ジョブのステータスを表しています。 ステータスとしては次の3つが用意されています。

  • queued: ジョブが開始したものの、まだ runner が割り当てられていない状態
  • in_progress: ジョブに runner が割り当てられて現在ジョブが走っている状態
  • completed: ジョブが完了したことを表す状態

このうち、 queued が今回必要になるイベントであるため、これ以外のものをまずははじくことが必要です。 さらに、 workflow_job イベントのペイロード中にある workflow オブジェクトには labels という文字配列プロパティがあり、 self-hosted runners で走らせる CI/CD ジョブが開始した場合はこの配列中に self-hosted というラベルが入っています。 なので、 self-hosted runners で走るジョブだけを抽出する際にはこの labels プロパティの要素も見てあげる必要があります。 これらを元に必要となる Webhook イベントのみを抽出し、 self-hosted runners ノードを起動するところまでを作成します5

まず、イベントフィルタリングをしたあとに SQS へメッセージを送信する Lambda 関数の実装は次の通りです。

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/sqs"
    "github.com/google/go-github/v45/github"
)

// あとで API Gateway からこの関数を呼び出す
func HandleRequest(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    qUrl, has := os.LookupEnv("QUEUE_URL")
    if !has {
        resp := events.APIGatewayProxyResponse{
            StatusCode: 500,
            Body: "No such environment: QUEUE_URL",
        }
        return resp, fmt.Errorf("No such environment: QUEUE_URL")
    }

    // イベントのフィルタリング
    var ev github.WorkflowJobEvent
    if err := json.Unmarshal(req.Body, &ev); err != nil {
        // workflow_job イベント以外は全部無視
        resp := events.APIGatewayProxyResponse{
            StatusCode: 200,
            Body: "ignore event",
        }
        return resp, nil
    }
    if stat := ev.GetWorkflowJob().GetStatus(); stat != "queued" {
        // queued なもの以外は全部無視
        resp := events.APIGatewayProxyResponse{
            StatusCode: 200,
            Body: "ignore event",
        }
        return resp, nil
    }
    isShr := false
    for _, label := range ev.GetWorkflowJob().Labels {
        if label == "self-hosted" {
            isShr = true
            break
        }
    }
    if !isShr {
        // queued なもの以外は全部無視
        resp := events.APIGatewayProxyResponse{
            StatusCode: 200,
            Body: "ignore event",
        }
        return resp, nil
    }

    // Queue にメッセージを送信
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        resp := events.APIGatewayProxyResponse{
            StatusCode: 500,
            Body: fmt.Sprintf("%v", err),
        }
        return resp, err
    }
    client := sqs.NewFromConfig(cfg)
    param := &sqs.SendMessageInput{
        QueueUrl: aws.String(qUrl),
        // Queue からイベントがでればそのまま self-hosted runners が立ち上がるため、
        // メッセージは何でもよい。そのため、ここでは `{}` をメッセージとして送信している。
        MessageBody: aws.String("{}"),
    }
    if _, err = client.SendMessage(ctx, param); err != nil {
        resp := events.APIGatewayProxyResponse{
            StatusCode: 500,
            Body: fmt.Sprintf("%v", err),
        }
        return resp, err
    }

    resp := events.APIGatewayProxyResponse{
        StatusCode: 200,
        Body: "OK",
    }
    return resp, nil
}

func main() {
    lambda.Start(HandleRequest)
}

この関数をプロビジョニングするための CDK コードは次の通りです。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as path from "path";
import { InstanceLaunchQueue } from "./instance-launch-queue";

export type EventPublisherProps = {
    queue: InstanceLaunchQueue;
};
export class EventPublisher extends cdk.aws_lambda.DockerImageFunction {
    constructor(scope: Construct, id: string, props: EventPublisherProps) {
        super(scope, id, {
            code: cdk.aws_lambda.DockerImageCode.fromImageAsset(
                // Lambda 関数のコードの設置先はこのソースファイルと同じ階層にある
                // `handler/event-publisher` に
                path.join(__dirname, "handler", "event-publisher")
            ),
            timeout: cdk.Duration.minutes(5),
            environment: {
                QUEUE_URL: props.queue.queueUrl,
            },
            memorySize: 1024,
        });

        this.addToRolePolicy(
            new cdk.aws_iam.PolicyStatement({
                resources: ["*"],
                actions: ["sqs:SendMessage"]
            })
        );
    }
}

最後に、 Webhook の受け口となる API Gateway を設置し、用意した Lambda 関数を呼び出すように設定すれば作成完了です。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
// 作成したものをインポート
import { EventPublisher } from "./event-publisher";
import { InstanceLaunchQueue } from "./instance-launch-queue";
import { InstanceLauncher } from "./instance-launcher";

export type WebhookProps = {
    instanceLauncher: InstanceLauncher;
};
export class Webhook extends cdk.Resource {
    public readonly gateway: cdk.aws_apigateway.LambdaRestApi;

    constructor(scope: Construct, id: string, props: InstanceLauncher) {
        super(scope, id);

        const queue = new InstanceLaunchQueue(this, "Queue", {
            instanceLauncher: props.instanceLauncher,
        });
        const eventPublisher = new EventPublisher(this, "EventPublisher", {
            queue,
        });
        this.gateway = new cdk.aws_apigateway.LambdaRestApi(
            this,
            "Webhook",
            {
                handler: eventPublisher
            }
        );
    }
}

あとは GitHub 側で Webhook を作成し、ここでデプロイされる API Gateway の URL を Webhook として登録すれば完了です。

まとめ

ここまで self-hosted runners を AWS 上に CDK を用いて構築する方法について解説しました。 多少複雑な機構となってはいますが、やりたいこととしては Webhook から CI/CD ジョブの開始イベントがきたら self-hosted runners を立ち上げるだけです。 ここでは解説していませんが、多少拡張していけば arm64 アーキテクチャな self-hosted runners 環境を作成したり、 Windows 環境を用意して実行したりできます。 この環境をベースとして皆さんでより良い開発環境を作成していただければと思います。


  1. https://docs.github.com/ja/actions/using-github-hosted-runners/about-github-hosted-runners

  2. self-hosted runners の立ち上げは Webhook イベントの受信とは別の操作になりますし、最悪その後の処理で EC2 インスタンスが立ち上がらなくても GitHub Actions のジョブは72時間で失敗としてとりあつかわれます。さらにジョブは再度流せるので、 self-hosted runners 環境を再度立ち上げてジョブを走らせることができればいいため、切り離すことによる問題はそこまで大きくはないと思います。

  3. https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-lambda-function-trigger.html

  4. Auto Scaling Group を使えば同じようにインスタンスの起動をより楽に行えますが、起動したインスタンスの管理が煩雑になります。少し試してみればわかりますが、インスタンス数を増やすのはとても簡単な一方、インスタンス数を減らすのはとても難しいです。たとえば、大抵の CI/CD ジョブはすぐに終了することが多いため、無駄なコストをかけないように土日には self-hosted runners 環境を提供する EC2 インスタンスの数を0にするとします。しかし、 ML トレーニングするインスタンス等時間のかかる CI/CD ジョブが金曜夜に投入され、土曜にジョブが割り込んだとき、上記ルールに則りインスタンスが終了させられ、ジョブが中断させられてしまいます。これをちゃんとどのインスタンスが走っているのかを把握するのは難しいため、適切なジョブ管理をする仕組みを導入しなければなりません。なので、 Auto Scaling Group を使わずにジョブの要求に応じて Launch Template から起動するようにしておき、ジョブが走った後はそのまま EC2 インスタンスを終了するようにすれば複雑なジョブの管理は必要なくなります。

  5. 実は GitHub には Organization で共有して利用できる self-hosted runners の他に、リポジトリー毎にも self-hosted runners を登録できます。 workflow_job イベントではこれらの区別がつかないため、リポジトリー毎に登録している self-hosted runners を利用したいのか、 Organization で共有して利用できる self-hosted runners を起動したいのかを区別することは難しいです。簡単な解決策としては各 self-hosted runners に ラベル を登録できるので、これを利用してそれぞれを区別するようにすれば良いです。ただし、今回示す例ではそのようなリポジトリー毎に登録されている self-hosted runners はないものとして進めます。

© NTT Communications Corporation All Rights Reserved.