GitHub Actions Self Hosted RunnerをAWS CDKを使ってEC2インスタンスで構築する

2020.11.08

はじめに

おはようございます、加藤です。GitHubが提供するワークフロー・CI/CDサービスのGitHub Actionsにはビルトインされた実行環境がありますが、自分で実行環境を用意するSelf Hosted Runnerという機能があります。この機能を使う場合は費用が発生しない(実行環境の費用は除く)などいくつかのメリットがあります。

本ブログではAWS上にSelf Hosted RunnerをAWS CDKで構築する方法をお伝えします。

全体構成

コンピュートにはEC2インスタンスをスポットインスタンスでAuto Scaling使用します。UserDataでSecrets ManagerからのPrivate Access Token取得からRunner登録までの処理をBashスクリプトとして実行することで、スケールアウト時に自動でRunnerとして登録を行います。スケールイン時には登録解除の処理を行うべきですが、Self Hosted Runnerは非アクティブになってから30日以上が経過すると登録が自動で削除されるのでこれに甘えて登録解除処理は実装していません。

また、Auto ScalingといってもAuto Healingを期待してのものであって待機しているワークフローの数に応じてスケーリングといったことは行いません。

コンピュートにEC2を選んだ理由

最近はコンピュートにEC2インスタンスをファーストチョイスしないので、最初はECSを使っての構築を考えました。しかし、この場合はワークフロー上でコンテナを実行するにはコンテナ on コンテナを行わなくてはならず、必要な設定がいくつか増えます。またスケーリング速度などコンテナのメリットも今回作成する仕組みには不要です。

これらの理由からEC2インスタンスを選び、コストを抑えるためにスポットインスタンスを使用することにしました。さらにインスタンス停止のケアおよびAuto Healingを期待してAuto Scalingを設定することにしました。Secrets Managerに持つPrivate Access Token以外はAWS上にステートを持たないので、これらのサービスと今回の構成の相性は良かったです。

コード解説

UserData

今回のもっと重要な部分はこのUserDataによってRunnerを登録する箇所です。コード内にコメントで説明を記載しました。UserDataの作成は今後WindowsのRunnerを作りたくなることを考慮して関数にまとめました。

lib/get-user-data/linux-repository.ts

import { UserData } from '@aws-cdk/aws-ec2'

export const getLinuxRepositoryUserData = ({
  region,
  owner,
  repo,
  runnerVersion,
  secretName,
}: {
  region: string
  runnerVersion: string
  secretName: string
  owner: string
  repo: string
}): UserData => {
  const url = `https://github.com/${owner}/${repo}`
  const labels = ['linux', 'x64', 'aws', 'amd', 'amazonlinux2']
  const userData = UserData.forLinux({ shebang: '#!/bin/bash -xe' })
  userData.addCommands(
    `exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1`,  // ①デバッグしやすくするためにログを吐き出す、意図した動作をしなかったらSession Managerでログインしてログをチェックします。
    `yum -y install jq git`, // ②jqはSecrets Managerからのレスポンスの整形に、gitはRunnerとして使用するなら必ず必要なパッケージなのでインストールします。
    `export REGION=${region}`,
    `export RUNNER_VERSION=${runnerVersion}`,
    `export SECRET_NAME=${secretName}`,
    `export ACCESS_TOKEN=$(aws --region $REGION secretsmanager get-secret-value --secret-id $SECRET_NAME --query SecretString --output text)`, // ③GitHub Private Access Tokenを取得します。repo, workflow, admin:orgの権限が必要です。
    `export RUNNER_TOKEN=$(curl -XPOST -fsSL -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $ACCESS_TOKEN" https://api.github.com/repos/${owner}/${repo}/actions/runners/registration-token | jq -r .token)`,  // ④Runner登録コードを取得します。このコードには有効期限があるので都度取得する必要があります。
    `su ec2-user -c 'mkdir $HOME/actions-runner'`,
    `su ec2-user -c 'curl -L https://github.com/actions/runner/releases/download/v$RUNNER_VERSION/actions-runner-linux-x64-$RUNNER_VERSION.tar.gz -o $HOME/actions-runner/actions-runner-linux-x64-$RUNNER_VERSION.tar.gz'`,
    `su ec2-user -c 'tar xzf $HOME/actions-runner/actions-runner-linux-x64-$RUNNER_VERSION.tar.gz -C $HOME/actions-runner'`,
    `su ec2-user -c '$HOME/actions-runner/config.sh --unattended --url ${url} --token $RUNNER_TOKEN --labels ${labels.join(
      ','
    )}'`,
    `su ec2-user -c '$HOME/actions-runner/run.sh &'` // ⑤非スーパーユーザーで実行する必要があるため、ec2-userで実行しています。ec2-userはsudoで昇格が出来てしまうので本来はより制限されたユーザーを作るべきです。
  )
  return userData
}

GitHub Private Access Tokenを発行する際に必要な権限は repo, workflow, admin:org(Organization単位でRunnerを登録したい場合)です。確認しやすいように画像を添付して起きます。

CloudFormationスタック

UserDataを受け取って必要なリソースを作成する部分です。こちらもコード内にコメントで説明を記載します。

lib/asg-for-github-actions-self-hosted.ts

import { AutoScalingGroup } from '@aws-cdk/aws-autoscaling'
import {
  Vpc,
  InstanceType,
  SubnetType,
  InstanceClass,
  InstanceSize,
  AmazonLinuxImage,
  UserData,
  AmazonLinuxGeneration,
} from '@aws-cdk/aws-ec2'
import { ManagedPolicy, Role, ServicePrincipal } from '@aws-cdk/aws-iam'
import { Stack, Construct, StackProps } from '@aws-cdk/core'

interface AsgForGitHubActionsSelfHostedProps extends StackProps {
  instances?: number
  userData: UserData
}

export class AsgForGitHubActionsSelfHosted extends Stack {
  public readonly vpc: Vpc
  public readonly autoScalingGroup: AutoScalingGroup

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

		// ①EC2インスタンスを動かすためのVPCを作成します。
    this.vpc = new Vpc(this, 'Vpc', {
      maxAzs: 3,
      natGateways: 0,
      subnetConfiguration: [
        {
          name: 'public',
          subnetType: SubnetType.PUBLIC,
        },
      ],
    })

    // ②インスタンスに割り当てるロールを権限します。この権限はワークフロー実行の権限となります、つまりAWS上へのIaCを行う場合などは強力な権限が必要になります。
    const role = new Role(this, 'InstanceRole', {
      assumedBy: new ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess'),
      ],
    })

		// ③Auto Scalingをスポットインスタンスで作成します。台数はpropsで指定が可能にしています。デフォルトでは1台です。
    const capacity = props.instances ?? 1
    this.autoScalingGroup = new AutoScalingGroup(
      this,
      'GitHubActionsSelfHostedRunnerASG',
      {
        vpc: this.vpc,
        instanceType: InstanceType.of(
          InstanceClass.BURSTABLE3_AMD,
          InstanceSize.MICRO
        ),
        machineImage: new AmazonLinuxImage({
          generation: AmazonLinuxGeneration.AMAZON_LINUX_2,
        }),
        spotPrice: '0.013',
        role,
        userData: props.userData,
        minCapacity: capacity,
        maxCapacity: capacity,
      }
    )
  }
}

その他

リポジトリはこちらに公開しています。単一のプロジェクトとして作成しましたが、将来ライブラリにしたくなった時のことを考慮して雑にですがテストは書いておきました。

https://github.com/intercept6/aws-cdk-actions-self-hosted

あとがき

Bashでスクリプトを書くの苦手なんですが、Bashのスクリプトを実行するための前処理のために色々やるのは無駄な感じがすごいのでBashで頑張りました。ハマりまくって辛くなりデバッグするための仕組みを途中から入れました。 以上です。