LoginSignup
3
0

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

Last updated at Posted at 2023-11-13

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

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

しかし...

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

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

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

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

全体の仕組み

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

Fargateで起動する場合

Step
1 GitHub Actionsが動くと、WebHookでAPI Gatewayにペイロードが送信されます。 image.png
2 AWS LambdaがWebhookのペイロード内容を判断して、タスク定義からECSタスクを開始します。 image.png
3 ECSで起動したランナー内で、GitHub Actionsのjobを実行します。 image.png
4 jobが終了すると、ランナーが停止します。 image.png

CodeBuildで起動する場合

Step
1 GitHub Actionsが動くと、WebHookでAPI Gatewayにペイロードが送信されます。 image.png
2 AWS LambdaがWebhookのペイロード内容を判断して、CodeBuildを開始します。 image.png
3 CodeBuildで起動したランナー内で、GitHub Actionsのjobを実行します。 image.png
4 jobが終了すると、ランナーが停止します。 image.png

ジョブの記述方法

jobのruns-onにラベルを指定して、起動するランナーマシンを指定しています。

  • runs-on: FARGATE_SPOT, CODEBUILD
    • 起動する環境
  • type: SMALL, MEDIUM, LARGE
    • 起動するマシンの性能
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)
  2. self-hosted runnerを動かす ECSクラスター + Fargateタスク定義
  3. self-hosted runnerを動かす CodeBuildプロジェクト

20230502_サーバーレスセルフホストランナー.png

1. GitHub Webhook (API Gateway + AWS Lambda)

AWS Lambda

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

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のラベルから起動する環境を決定します。

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を起動するコード

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を起動するコード

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アドレスは以下で確認できます。

// 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タスク定義

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

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

// 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で無理やり動かしています。

(今後、使えるようになるのかも...)

// 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.」から
「Workflow jobs」のみチェック
(デフォルトでPushesにチェックが付いているので注意)

image.png

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

3
0
0

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
3
0