バージョン管理された人

subversionで管理されてます

Github Actions の self hosted runner を Github Apps + AWS で動かす

Github Actions の self hosted runner の登録は Github API を叩くことで行うことができるので、これを利用して AWS 上に、 Github の Organization 単位な self hoster runner の実行環境を用意して、 runner を登録して走らせる、ということを行う。

なお、 AWS 側の構築は CDK で行う。

前提

Github Apps は個人に紐付かないアクセストークンを発行できるもので、これの作りかたは Github Apps のドキュメント を読んでもらって作成していることを前提としている。

Github Apps を利用してアクセストークンを発行するのに必要な情報は次のとおり:

  • App ID
  • Client Secrets

App ID は Github Apps の設定画面から閲覧できるので、事前にメモしておいた方がいい。

また、 self hosted runner の登録を Github Apps として行うので、 Github Apps に Self-hosted runnersRead & Write 権限はつけておく必要がある。

runner の登録用トークンの発行

runner の登録用トークンの発行は Github API を通して行うことができる。 しかし、runner 登録用トークンの発行を行う Github API を叩くにはアクセストークンの発行が必要となる。

アクセストークンの発行

アクセストークンの発行は少々複雑で、 JWT を発行する必要がある。 公式ページには Ruby による発行例 が載っているので、 Ruby があり、 JWT のライブラリを利用できる環境ならばそれをそのまま利用しても良いだろう。

しかし、 Amazon Linux 2 などには標準で Ruby などは入っていないので、 Ruby の構築からでは少々手間になってしまう。 実は JWT の発行アルゴリズム 自体は非常に単純なので、 jq コマンドと openssl コマンドさえあれば bash シェルスクリプトでもできる(例では RS256 固定となっているが、先程の Ruby による発行例のページに RS256エンコードするように記載されているのにならった)。

JWT の発行

まず、ヘッダーやペイロードなどは URL 向け base64 エンコード を行うため、これを行う関数を作成する:

# URL 向け base64 エンコードを施す
# ## 引数
# stdin :byte[]: エンコードしたいもの
# ## 返り値
# stdout :string: エンコード結果を標準出力へ出力する
#
# ## 使用例
# ```bash
# $ printf "%s\n" "$(echo "test" | base64url)"
# dGVzdAo
# $
# ```
function base64url() {
  # base64 エンコードして
  base64 | \
    # `+` は `-` に、 `/` は `_` に置き換える
    tr '+/' '-_' | \
    # パディングは削除
    tr -d '=' | \
    # 一応改行も削除しておく
    tr -d '\n'
 }

JWT のヘッダーとペイロードは、この関数を用いてエンコードするだけなので、実に単純

HEADER="$(jq -ncj '{alg: "RS256", typ: "JWT"}' | base64url)"
PAYLOAD="$(echo "<payload>" | base64url)"

シグネチャはヘッダーとペイロードをそれぞれ URL 向け base64 エンコードした上で、 encodedHeader.encodedPayload のように . で接続したものを、 openssl コマンドを用いて署名して得られる:

# 署名する
#
# ## 引数
# $1 :string: client secret の値
# stdin :byte[]: 署名したい値
# ## 返り値
# stdout :string: URL 向け base64 エンコードされた文字列
#
# ## 使用例
# ```bash
# $ echo "test" | sign "$SECRET"
# A0sd...
# $
function sign() {
  local secret="$1"
  openssl dgst -binary -sha256 -sign <(echo "$secret")
}
SECRET="<署名に利用するシークレット値>"
SIGNATURE="$(printf "%s.%s" "$HEADER" "$PAYLOAD" | sign "$SECRET" | base64url)"

最後は、以上で得られた URL 向け base64 エンコードされたヘッダー・ペイロードシグネチャ. を用いて encodedHeader.encodedPayload.encodedSignature のように結合することで JWT トークンを得ることができる。

アクセストークンの発行

ペイロードを作成する:

# アクセストークンの発行要求時刻
IAT="$(date "+%s")"
# トークンの失効時刻
EXP="$((IAT + 10 * 60))"
# Github Apps の App ID
ISS="<App ID>"
PAYLOAD="$(
  jq -ncj \
    --argjson iat "$IAT" \
    --argjson exp "$EXP" \
    --arg iss iss "$ISS" \
    '{iat: $iat, exp: $exp, iss: $iss}'
)"

このペイロードを用いて、上の JWT の発行手順を同じように踏んで JWT トークンを発行する。

発行した JWT トークンをもとに、アクセストークンを取得するための URL を Github に生成させる:

ACCESS_TOKEN_URL="$(
  curl -s \
      -H "Authorization: Bearer <JWTトークン>" \
      -H "Accept: application/vnd.github.v3+json" \
      https://api.github.com/app/installations | \
  jq -r --argjson app_id "$ISS" '.[] | select(.app_id == $app_id) | .access_tokens_url'
)"

この URL に JWT トークンを用いて接続すればアクセストークンを得られる:

curl -s \
    -X POST
    -H "Authorization: Bearer <JWTトークン> \
    -H "Accept: application/vnd.github.v3+json" \
    "$ACCESS_TOKEN_URL" | \
  jq -r .token

runner の登録用トークンの取得

上記で得られたアクセストークンを用いることで runner を登録することが可能になる。 登録自体はアクセストークンさえ得られれば この API をたたくだけで実現できる:

curl -sSL \
    -X POST \
    -H "Accept: application/vnd.github.v3+json" \
    -H "Authorization: token <アクセストークン>" \
    https://api.github.com/orgs/<Organization 名>/actions/runners/registration-token | \
  jq -r .token

API のドキュメントを読んでもらうとわかるが、トークンには有効期限が設定されているので、扱う時間については注意して欲しい。

CDK

Github API のアクセストークンを発行し、 runner を登録するトークンさえ得られれば、あとはそのトークンを持ってして runner を起動すれば自動で runner 登録処理が走る。 なので、あと我々としてやることは少なく、 runner を走らせるスタックを用意できさえすればよい。

ここでは Docker を Github Actions で利用できるように EC2 インスタンス上に runner の環境を作成し、ついでに常に CI 環境が1台存在するような Auto Healing 機能が付いたスタックを用意する。 ECS などでも EC2 インスタンスをバックエンドで利用するようにしてやれば Docker outside of Docker でできる ようではある、が自分は未検証。

スタックの構築

まず、スタックを用意する:

import * as core from "@aws-cdk/core";
// RunnerGroup はここで構築する
import { RunnerGroup, Parameter } from "./runner-group";

export class SelfHostedRunnerStack extends core.Stack {
  constructor(scope: core.Construct, id: string, param: Parameter, props?: core.StackProps) {
    super(scope, id, props);

    // このコンポーネントをつくる
    new RunnerGroup(this, "RunnerGroup", param);
  }
}

そして、 RunnerGrouplib/runner-group.ts に記載する:

import * as ec2 from "@aws-cdk/aws-ec2";
import * as core from "@aws-cdk/aws-ec2";
import * as iam from "@aws-cdk/aws-iam";

type Parameter = {
  /**
   * EC2 インスタンスの設置先 VPC
   */
  vpc: ec2.IVpc;
  /**
   * 起動する runner のバージョン
   */
  runnerVersion: string;
  /**
   * スタックの設置先リージョン
   */
  region: string;
};
/**
 * Runner を走らせる AutoScalingGroup
 */
export class RunnerGroup extends ec2.AutoScalingGroup {
  constructor(scope: core.Construct, id: string, param: Parameter) {
    // デプロイされる EC2 インスタンスのロール
    // SSM を利用するために作っているだけなので、いらないかも
    const executionRole = new iam.Role(scope, "ExecutionRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
      ],
    });

    const securityGroup = new ec2.SecurityGroup(scope, "SecurityGroup", {
      vpc: param.vpc,
    });
    // runner は Github と 443 を通してジョブのやりとりをしているので、
    // 443 番ポートはあけておく
    securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443));

    super(scope, id, {
      // 常に1台が起きているようにする
      maxCapacity: 1,
      minCapacity: 1,
      role: executionRole,
      vpc: param.vpc,
      machineImage: new ec2.AmazonLinuxImage(),
      // CPU をそこそこ占有するので、コンピューティング最適化されたインスタンスタイプを選択し、
      // 余裕を持って、 vCPU コアもそこそこ大きめに確保しておく
      instanceType: ec2.InstanceType.of(InstanceClass.C5, InstanceSize.LARGE),
      // public なサブネットに設置するならば、 `associatePublicIpAddress` プロパティを `true` に設定しておく必要がある。
      // ここでは Public Subnet への NAT Gateway が設置されている Private Subnet に配置してしてしまう設定にする。
      vpcSubnets: {
        subnets: vpc.privateSubnets,
      },
      securityGroup,
      // ここで runner を起動するスクリプトを指定することで、インスタンス起動時に runner の自動登録・起動がなされるようにする
      userData: userData(param.runnerVersion, param.region),
    });
  }
}

/**
 * EC2 インスタンスの起動スクリプトを作成する
 *
 * なお、ここでは起動される OS は Amazon Linux 2 であると仮定している。
 */
const userData = (runnerVersion: string, region: string): ec2.UserData => {
  // Amazon Linux 2 なら `ec2-user` というユーザーが最初からいるので、それを利用しても良いだろう
  const user = "<runner を走らせるユーザー名>";
  const commands = [
    // デバッグ用
    "set -xe",
    // Docker をインストールし、 runner が docker を走らせられるようにする(docker グループに加えるのはよろしくない気もするが、面倒なので...)。
    "yum install -y docker",
    `gpasswd -a ${user} docker`,
    // self hosted runner で最低限 git は必要(コードのチェックアウトはかならず行うため)
    "yum install -y git",
    // ログを /var/log/user-data.log に残しておきつつ、 journalctl にも残しておく
    "exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1",
    // runner 登録用トークンを発行する一連の処理をここに記載するのだが、
    // 一々書いていると大きくなるので、ここでは `access-token.bash` という
    // スクリプトにそれらの一連の処理が記載されているとする。
    'export RUNNER_TOKEN="$(/opt/bin/access-token.bash)"',
    // runner をダウンロードする
    `su "${user}" -c 'curl -sSL "https://github.com/actions/runner/releases/download/v${runnerVersion}/actions-runner-linux-x64-${runnerVersion}.tar.gz" | tar zxf - -C "$HOME/runner"'`,
    // 登録する runner 名をランダムに生成する。
    // ただし、登録できるのは64文字となるため、 sha256 で64文字化する
    'export NAME="$(dd bs=<好きなバイト数> count=1 if=/dev/urandom status=none | sha256sum | cut -f 1 -d " ")"',
    // runner を起動する
    // `--replace` で起動時に名前が被っているときは新しく登録する runner で置き換えるようにする。
    // `--unattended` でインタラクティブな操作を無効化している。
    `su "${user}" -c 'cd "$HOME/runner"; ./config.sh --unattended --url "https://github.com/<Organization 名>" --replace --token "$RUNNER_TOKEN" --name "$NAME"'`,
  ];

  const ud = ec2.UserData.forLinux({ shebang: "#!/usr/bin/env bash" });
  ud.addCommands(...commands);
  return ud;
}

あとは、 bin ディレクトリにある起動スクリプトに次のようにスタックを追加してやればデプロイできるようになる:

import * as core from "@aws-cdk/aws-core";

const app = new core.App();
const region = "<設置先リージョン>";
new SelfHostedRunnerStack(
  app,
  "SelfHostedRunnerStack",
  {
    // 執筆時点で、最新版は `2.277.1`
    runnerVersion: "2.277.1",
    region,
  },
  {
    env: {
      region,
      account: process.env.CDK_DEFAULT_ACCOUNT,
    },
  },
);