GitHub ActionsのセルフホストランナーをAWS上にサーバーレスで構築する

### GitHub Actions お手軽に使えて便利ですよね

GitHubでランナーが用意されているので、リポジトリにジョブの定義を置いておくだけでテストなど動かせます。

### しかし...

コストやセキュリティの関係で、セルフホストランナーを使う必要もあります。
となると、ランナーマシンを立てる必要が出てしまいますし、オートスケールするためには、[actions/actions-runner-controller](https://github.com/actions/actions-runner-controller) などでkubernetesの構築が必要になるかもしれません。

せっかくお手軽なGitHub Actionsなのにランナーマシンのメンテナンスが必要になるのは面倒です。

今回はセルフホストランナーをAWSのFargat、CodeBuildで上にサーバーレスで構築する方法をご紹介します。

基本的にこちらの記事をそのまま参考にしています。
（素晴らしいシステムです。詳細をお伺いしたい！）

https://note.varu3.me/n/n95cd97ee07e9

## 全体の仕組み

GitHub WebHooksでAWS Lambdaを実行し、Fargate、CodeBuildで動くセルフホストランナーを起動しています。

### Fargateで起動する場合

| Step | | |
| ---- | ---- | ---- |
| 1 | GitHub Actionsが動くと、WebHookでAPI Gatewayにペイロードが送信されます。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/965d74da-57ed-3e3c-4344-18d152810415.png) |
| 2 | AWS LambdaがWebhookのペイロード内容を判断して、タスク定義からECSタスクを開始します。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/4d9bd5f9-6eed-8a80-66e4-96921a2b82b5.png) |
| 3 | ECSで起動したランナー内で、GitHub Actionsのjobを実行します。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/ac5df6a0-5396-40f8-57a0-6b1d47335549.png) |
| 4 | jobが終了すると、ランナーが停止します。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/68c23a4c-1211-0df5-5529-bfbf8dd08274.png) |

### CodeBuildで起動する場合

| Step | | |
| ---- | ---- | ---- |
| 1 | GitHub Actionsが動くと、WebHookでAPI Gatewayにペイロードが送信されます。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/965d74da-57ed-3e3c-4344-18d152810415.png) |
| 2 | AWS LambdaがWebhookのペイロード内容を判断して、CodeBuildを開始します。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/003ff3c3-4eeb-aefc-0679-82278842e3eb.png) |
| 3 | CodeBuildで起動したランナー内で、GitHub Actionsのjobを実行します。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/c31af8b6-7375-249a-82e2-17a0b0c867b8.png) |
| 4 | jobが終了すると、ランナーが停止します。 | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/6da532d3-049a-f8f0-c018-d65a19c89671.png) |

### ジョブの記述方法

jobのruns-onにラベルを指定して、起動するランナーマシンを指定しています。
* runs-on: FARGATE_SPOT, CODEBUILD
    * 起動する環境
* type: SMALL, MEDIUM, LARGE
    * 起動するマシンの性能

```yaml
name: serverless
on:
  workflow_dispatch:

jobs:
  fargate_small:
    runs-on: [self-hosted, '${{ github.run_id }}', 'runs-on=FARGATE_SPOT', 'type=SMALL']
    steps:
      - run: echo fargate_small

  fargate_medium:
    runs-on: [self-hosted, '${{ github.run_id }}', 'runs-on=FARGATE_SPOT', 'type=MEDIUM']
    steps:
      - run: echo fargate_medium

  fargate_large:
    runs-on: [self-hosted, '${{ github.run_id }}', 'runs-on=FARGATE_SPOT', 'type=LARGE']
    steps:
      - run: echo fargate_large

  codebuild:
    runs-on: [self-hosted, '${{ github.run_id }}', 'runs-on=CODEBUILD']
    steps:
      - run: echo codebuild
```

## ソースコード

### AWSリソース

AWS CDKで以下のリソースを作成します。

1. GitHub Webhook (API Gateway + AWS Lambda)
1. self-hosted runnerを動かす ECSクラスター + Fargateタスク定義
1. self-hosted runnerを動かす CodeBuildプロジェクト

![20230502_サーバーレスセルフホストランナー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/9e6de954-8135-af89-ae50-6e7083a7cfa7.png)

#### 1. GitHub Webhook (API Gateway + AWS Lambda)
##### AWS Lambda
ランナーを起動させるAWS Lambdaを作成します。
ECSタスクを実行する時、サブネットIDが必要になるので、環境変数「`FargateSubnet`」にカンマ区切りで設定しています。
また、AWS LambdaからECS、Codebuildを実行するので、ポリシーに `codebuild:*`, `ecs:*` を設定しています。

```typescript
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';

declare const vpc: ec2.Vpc;

// AWS Lambda作成
const webhookFunction = new nodejs.NodejsFunction(this, 'WebhookFunction', {
  entry: 'lambda/index.ts',
  runtime: lambda.Runtime.NODEJS_18_X,
  timeout: cdk.Duration.seconds(15),
  environment: {
    FargateSubnet: vpc.privateSubnets.map((subnet) => subnet.subnetId).join(','),
    // FargateSecurityGroup: デフォルトセキュリティグループを使用したくない場合
  },
});
// ポリシーの設定
webhookFunction.addToRolePolicy(
  new iam.PolicyStatement({
    actions: ['iam:PassRole', 'codebuild:*', 'ecs:*'],
    resources: ['*'],
  })
);
```

##### Lambda関数のコード

runs-onのラベルから起動する環境を決定します。

```typescript:lambda/index.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { startFargate } from "./start-fargate";
import { startCodeBuild } from "./start-codebuild";

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  // labelsとrun_idを取得
  const body = JSON.parse(event.body ?? "{}");
  const workflowJob = body?.workflow_job ?? {};
  const labels: string[] = workflowJob?.labels ?? [];
  const runId: string = workflowJob?.run_id ?? "";

  // queuedイベント以外は処理しない
  const action = body?.action;
  if (action !== "queued") {
    const message = `Skip because it is not a \`queued\` event: ${runId}`;
    return {
      statusCode: 200,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    };
  }

  // ワークフローのジョブラベルに "self-hosted" が存在するか確認する
  const isSelfHosted = labels.includes("self-hosted");
  if (!isSelfHosted) {
    const message = `Skip because it is not a \`self-hosted\` event: ${runId}`;
    return {
      statusCode: 200,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    };
  }

  // リポジトリのURLを取得
  const htmlUrl = body?.repository.html_url ?? "";

  try {
    // ワークフローのジョブラベルから "runs-on" を取得する
    const runOn =
      labels
        .find((label) => label.includes("runs-on="))
        ?.match(/runs-on=(.*)/)?.[1] ?? "";
    // ワークフローのジョブラベルから "type" を取得する
    const type =
      labels
        .find((label) => label.includes("type="))
        ?.match(/type=(.*)/)?.[1] ?? "SMALL";

    // run-onにより、実行先を指定
    const message =
      // CodeBuildで起動する
      runOn === "CODEBUILD"
        ? await startCodeBuild(htmlUrl, labels, type)
        : // FargateSpotで起動する
        runOn === "FARGATE_SPOT"
        ? await startFargateSpot(htmlUrl, labels, type)
        : "Skipping self-hosted runner setup";

    return {
      statusCode: 200,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    };
  } catch (err: any) {
    const message = `Failed to start: ${err.message}`;
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    };
  }
};
```

Fargateを起動するコード

```typescript:lambda/start-fargate.ts
// FargateのCPU/メモリの設定
const fargateType = (type: string) => {
  switch (type) {
    case 'LARGE':
      return {
        cpu: '2048',
        memory: '16384',
      };
    case 'MEDIUM':
      return {
        cpu: '1024',
        memory: '8192',
      };
    default:
      return {
        cpu: '512',
        memory: '4096',
      };
  }
};

// Fargate Spotでself-hosted runnersを開始する
const startFargateSpot = async (
  htmlUrl: string,
  labels: string[],
  type: string
): Promise<string> => {
  const runTaskRequest = {
    cluster: 'GitHubSelfHostedRunner',
    taskDefinition: 'GitHubSelfHostedRunner',
    capacityProviderStrategy: [
      {
        capacityProvider: 'FARGATE_SPOT',
        weight: 1,
        base: 1,
      },
    ],
    networkConfiguration: {
      awsvpcConfiguration: {
        subnets: process.env.FargateSubnet?.split(','),
        securityGroups: [process.env.FargateSecurityGroup ?? ''],
        assignPublicIp: 'DISABLED',
      },
    },
    overrides: {
      containerOverrides: [
        {
          name: 'WebContainer',
          environment: [
            {
              name: 'REPO_URL',
              value: htmlUrl,
            },
            {
              name: 'LABELS',
              value: labels.join(','),
            },
            {
              name: 'EPHEMERAL',
              value: '1',
            },
          ],
        },
      ],
      ...fargateType(type),
    },
  };
  const response = await ecsClient.send(new RunTaskCommand(runTaskRequest));
  return `Started Fargate spot task: ${response.tasks?.[0]?.taskArn}`;
};
```

CodeBuildを起動するコード

```typescript:lambda/start-codebuild.ts
// CodeBuildコンピューティングタイプの設定
const codeBuildType = (type: string) => {
  switch (type) {
    case 'LARGE':
      return {
        computeTypeOverride: 'BUILD_GENERAL1_LARGE',
      };
    case 'MEDIUM':
      return {
        computeTypeOverride: 'BUILD_GENERAL1_MEDIUM',
      };
    default:
      return {
        computeTypeOverride: 'BUILD_GENERAL1_SMALL',
      };
  }
};

// CodeBuildでself-hosted runnersを開始する
const startCodeBuild = async (htmlUrl: string, labels: string[], type: string): Promise<string> => {
  const buildParams = {
    projectName: 'GitHubSelfHostedRunner',
    environmentVariablesOverride: [
      {
        name: 'REPO_URL',
        value: htmlUrl,
        type: 'PLAINTEXT',
      },
      {
        name: 'LABELS',
        value: labels.join(','),
        type: 'PLAINTEXT',
      },
      {
        name: 'EPHEMERAL',
        value: '1',
        type: 'PLAINTEXT',
      },
    ],
    ...codeBuildType(type),
  };

  const response = await codebuild.send(new StartBuildCommand(buildParams));
  return `Started CodeBuild project: ${response.build?.id}`;
};
```

##### API Gateway

作成したLambda関数を設定し、許可するIPアドレスをGitHub Webhooksで使用されるIPレンジに設定します。
GitHubで使用されるIPアドレスは以下で確認できます。

https://api.github.com/meta

```typescript
// APIGatewayの作成
const api = new apigateway.LambdaRestApi(this, 'WebhookFunctionAPI', {
  handler: webhookFunction,
  proxy: false,
  policy: new iam.PolicyDocument({
    statements: [
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        principals: [new iam.AnyPrincipal()],
        actions: ['execute-api:Invoke'],
        resources: ['execute-api:/*/*/*'],
      }),
      new iam.PolicyStatement({
        effect: iam.Effect.DENY,
        principals: [new iam.AnyPrincipal()],
        actions: ['execute-api:Invoke'],
        resources: ['execute-api:/*/*/*'],
        conditions: {
          NotIpAddress: {
            'aws:SourceIp': [
              '192.30.252.0/22',
              '185.199.108.0/22',
              '140.82.112.0/20',
              '143.55.64.0/20',
            ],
          },
        },
      }),
    ],
  }),
});
api.root.addMethod('POST');
}
```

##### 2. self-hosted runnerを動かす ECSクラスター + Fargateタスク定義

コンテナで動かすイメージに以下を使用しています。

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

また、セルフホストランナーをリポジトリに登録するため Personal access token を パラメータストアの `GitHubPersonalAccessToken` に設定しておきます。


```typescript
// Self hosted runner 用 ECS 作成
new ecs.Cluster(this, 'Cluster', {
  vpc,
  clusterName: 'GitHubSelfHostedRunner',
  enableFargateCapacityProviders: true,
});

// タスク定義の作成
const fargateTaskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {
  family: 'GitHubSelfHostedRunner',
  // executionRole: 適切なタスク実行ロール,
  // taskRole: 適切なタスクロール,
});

fargateTaskDefinition.addContainer('WebContainer', {
  image: ecs.ContainerImage.fromRegistry('myoung34/github-runner:latest'),
  secrets: {
    ACCESS_TOKEN: ecs.Secret.fromSsmParameter(
      ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SecureValue', {
        parameterName: 'GitHubPersonalAccessToken',
      })
    ),
  },
});
```

##### 3. self-hosted runnerを動かす CodeBuildプロジェクト

buildImageにFargateと同じく `myoung34/docker-github-actions-runner` を使用しています。
CodeBuildは、Dockerfile の ENTRYPOINT が実行されないため、BuildSpecの `commands`で無理やり動かしています。

（今後、使えるようになるのかも...）

https://github.com/aws/aws-codebuild-docker-images/issues/254

```typescript
// Self hosted runner 用 Codebuild 作成
new codebuild.Project(this, `SelfHostedRunnerCodeBuild`, {
  vpc,
  projectName: `GitHubSelfHostedRunner`,
  buildSpec: codebuild.BuildSpec.fromObject({
    version: '0.2',
    phases: {
      build: {
        commands: [
          'nohup dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2 &',
          'timeout 15s sh -c "until docker info > /dev/null 2>&1; do echo .; sleep 1; done"',
          'cd /actions-runner',
          '/entrypoint.sh ./bin/Runner.Listener run --startuptype service',
        ],
      },
    },
  }),
  environment: {
    buildImage: codebuild.LinuxBuildImage.fromDockerRegistry('myoung34/github-runner:latest'),
    privileged: true,
    environmentVariables: {
      ACCESS_TOKEN: {
        type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE,
        value: 'GitHubPersonalAccessToken',
      },
    },
  },
  // role: 適切なIAMロール,
});
```

## WebHooksの設定

対象のリポジトリの
[Settings]タブ → [Webhooks] → [Add webhook]
から、以下の内容で登録します。

|  項目  |  設定値  |
| ---- | ---- |
|  Recent Deliveries  |  作成したAPI GatewayのURL  |
|  Content type  |  application/json  |
|  Which events would you like to trigger this webhook?  |  「Let me select individual events.」から<br>「Workflow jobs」のみチェック<br>(デフォルトでPushesにチェックが付いているので注意)  |

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/39672302-e0c8-8bed-945d-68f2e050a0b2.png)

今回はシークレットトークンを使用していませんが、設定することをおすすめします。

https://qiita.com/bigmac/items/6e7decbfb734ba1551bd