【GitHub Actions】EC2スポットインスタンスをSelf-hosted runnerとして使うための長い道のり

# はじめに
[**Amazon EC2 スポットインスタンス**](https://aws.amazon.com/jp/ec2/spot/)は余っているEC2のキャパシティを割安に利用できるサービスで、公式おすすめのユースケースとして**CI/CD**が挙げられています。

確かにCI/CDは断続的なタスクなのでインスタンスが中断されてもさした問題は起こりませんし、テストをたくさん回すからとにかく台数が必要だ！という状況において70%程度のコスト減が見込めるのは大変魅力ですね。

というわけで今回はこのスポットインスタンスとGitHub Actionsを連携させる方法を書いていくのですが、若干不穏なタイトルの通り設定は**かなり手間**です。今から始められる方は予めまとまった時間を確保しておくことをおすすめします。

# やりたいこと
スポットインスタンスは契約上インスタンスが不定期に入れ替わるため、それを前提とした仕組みが必要です。
即ちやりたいことはこんな感じ。

1. インスタンスの起動時に自身をSelf-hosted runnerとして登録するためのスクリプトを組む
1. 上記スクリプトが設定されたスポットインスタンスをリクエストする

とっても簡単ですね。

# 登場するサービス / 用語
道中で様々なサービスを横断するので事前にざっくり説明しておきます。

## GitHub側
### [GitHub Apps](https://docs.github.com/ja/developers/apps/getting-started-with-apps/about-apps)
GitHubのAPIをユーザーの権限で叩くのは運用上よろしくない[^1]のでOrganizationにインストールされたアプリの権限で叩きましょうね、という用途で使います。

## AWS側
### [AWS Secrets Manager](https://aws.amazon.com/jp/secrets-manager/)
上記Appからトークンを貰うための秘密鍵を安全に扱うために使用します。

### [スポットフリート](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-fleet.html)
スポットインスタンスをリクエストする方法は[**ここ**](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-best-practices.html)に記載の通り[Auto Scaling](https://aws.amazon.com/jp/autoscaling/)グループを作る方法とスポットフリートを作る方法の2パターンあるのですが、どっちでもいい[^2]です。お好みで選択してください。
当記事ではスポットフリートを使用します。

# 事前準備
ではこれらを踏まえて事前準備をしていきましょう。

## GitHub Apps
まず https://github.com/organizations/${Organizationの名前}/settings/apps/new からアプリを作ります。

- GitHub App nameはGitHub全体で重複しないようにつける必要があるのでOrganizationの名前とかを先頭につけると怒られない
- Homepage URLは必須項目ですがどうせ公開しないので適当に
- Expire user authorization tokensとWebhookは使わないのでチェックを外して大丈夫

権限はかなり細かく設定できます。使う分だけ付与しましょう。
今回はOrganization permissions > Self-hosted runnersをRead and writeにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/9368974b-95ab-ff19-24c6-9cfae6ed5762.png)

作成ボタンを押すと作られたアプリの設定画面に飛ぶので
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/3b7e20aa-9f25-6d14-70a8-62a17510b419.png)
App IDを控えて…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/9902e7fd-677f-3c3f-865b-3caaa610039f.png)
秘密鍵を作って…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/2b890901-3531-4ac6-fa4a-b918ebf35d9e.png)
Organizationにインストールしてください。
(参考画像は都合上ユーザーにインストールしています)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/1ce8f527-7c4d-f65d-e2cb-5f2f6ef597be.png)
インストールが終わるとインストール先の設定ページに飛びます。
アドレスの末尾に書かれている数字がInstallation IDと呼ばれるもので、後々使うためこちらもメモっておきます。

## Secrets Manager
次に先ほど作ったアプリの秘密鍵をSecrets Managerに登録します。

マネジメントコンソールからSecrets Managerにアクセスして新規登録していただければいいのですが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/4d5085db-f84e-9433-8b87-f0d5e7ca74bf.png)
ご覧の通りこのサービスはシークレットをkey-valueで持つんですね。
秘密鍵の中身はこのようになっているので、

```
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAtSXnB3rWWS4OxVvmxMffl0fi1b5TgnNScBhPo3SiRoiH26Q0
WuLIdIKNIqYl+nyhE48W+psHJIJny7nMkyP2bSM8mrTANffyfP6ZqS1i0cmx60A1
9JqWjjthQMK2FVQBSlU8Mk+1YFOaiUfgqE6x4ZDmfQVHzvQAeFtfhUMZ8nEEv/C/

...

T/rEdQKBgQCrWBhX6IsL4a9oqtbICh/BO2D2WyqALur4xbHfLn7INiEO2qNE7xiI
R/CDwUkkwPX38D2ruVoJ9rlv/VRrSz3CgwWonfmZW5ncpKlRQsglOASXCLvTw/VJ
L/8Kdio2TV7Q++LEEId25SZkuOz5prGBugNSTKGg+sJvSgm2KTwbWA==
-----END RSA PRIVATE KEY-----
```

このままだと改行が邪魔です。
なので一旦改行コードごとBase64でエンコードしてしまいましょう。

```shell
base64 -w0 your-app.yyyy-mm-dd.private-key.pem | クリップボードに直接入れるのが一番安心
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/19070349-5a5c-774b-2bb2-60a5dac07a14.png)
OK。

登録が完了するとシークレットを取得するためのサンプルコードが出てくるので
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/f525678d-63c6-1b23-de75-31f68683a7db.png)
シークレット名とリージョンを控えておいてください。

## IAM ロール
Secrets Managerを使ったことによりEC2インスタンスがAWSのサービスにアクセスする必要が出てきました。
というわけでインスタンスにアタッチする用のIAM ロールを作っていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/036f9568-e0fe-9f59-ad62-ff6bf4caffd0.png)
まずはポリシーから。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/fad1b545-5815-fbae-d14a-dbaba05eaa05.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/bff0763c-3d6d-57ee-81e1-416c79f363ce.png)
今作ったポリシーを追加して…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/966fa3ce-3695-5871-b78c-593a30add20e.png)
こんな感じでいいでしょう。

以上で準備は完了です！

# スクリプティング
スクリプト(ユーザーデータ)については実際のコードを見ながら解説を入れていきます。

```shell
#!/bin/bash

yum update -y
yum install jq -y
yum install git -y

# 前準備でメモした諸々
ORGANIZATION_NAME=****
APP_ID=****
INSTALLATION_ID=****
SECRET_NAME=****
SECRET_REGION=****
SECRET_KEY=****

# Secrets Managerから秘密鍵を取得
private_key=mktemp
aws secretsmanager get-secret-value --region $SECRET_REGION --secret-id $SECRET_NAME \
| jq -r "select(.Name == \"$SECRET_NAME\") | .SecretString" | jq -r ".runner" | base64 -d > $private_key

# GitHub APIを叩くためのアクセストークンを取得するためのトークン(JWT)を作成
# 参考: https://zenn.dev/gorohash/articles/e2c5f6ce50f4e6
header=$(echo -n "{\"alg\":\"RS256\",\"typ\":\"JWT\"}" | base64 -w0)
now=$(date +%s)
iat=$(($now - 60))
exp=$(($now + 600))
payload=$(echo -n "{\"iat\":$iat,\"exp\":$exp,\"iss\":$APP_ID}" | base64 -w0)
unsigned_token=$header.$payload
signed_token=$(echo -n $unsigned_token | openssl dgst -binary -sha256 -sign $private_key | base64 -w0)
jwt=$unsigned_token.$signed_token
rm $private_key

# 作成したJWTを渡してアクセストークンを取得
access_token=$(
  curl -s -X POST \
    -H "Authorization: Bearer $jwt" \
    -H "Accept: application/vnd.github.v3+json" \
    https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens \
  | jq -r ".token" \
)

# 最新のランナーアプリケーションのダウンロードURLと名前を取得
runner=$(
  curl -s -X GET \
    -H "Authorization: token $access_token" \
    -H "Accept: application/vnd.github.v3+json" \
    https://api.github.com/orgs/$ORGANIZATION_NAME/actions/runners/downloads \
  | jq -r ".[] | select(.os == \"linux\" and .architecture == \"x64\")"
)
runner_url=$(echo $runner | jq -r ".download_url")
runner_name=$(echo $runner | jq -r ".filename")

# このインスタンスをSelf-hosted runnerとして登録するためのトークンを取得
registration_token=$(
  curl -s -X POST \
    -H "Authorization: token $access_token" \
    -H "Accept: application/vnd.github.v3+json" \
    https://api.github.com/orgs/$ORGANIZATION_NAME/actions/runners/registration-token \
  | jq -r ".token"
)

# ユーザーデータはrootとして実行されるが
# ランナーは一般ユーザーで立ち上げる必要がある(rootでは起動できない)ためsu
# 参考: https://zenn.dev/k_i/articles/10b16875cb9fd6
su - ec2-user <<EOF
mkdir actions-runner && cd actions-runner
curl -o $runner_name -L $runner_url
tar xzf $runner_name
./config.sh \
  --url https://github.com/$ORGANIZATION_NAME \
  --token $registration_token \
  --name $(echo ip.$(hostname -i) | sed "s/\./-/g") \
  --runnergroup Default \
  --labels ec2 \
  --work _work

sudo ./svc.sh install
sudo ./svc.sh start
EOF
```
参考にさせていただいた偉大なる先達の元記事はこちらです。

[シェルスクリプトで GitHub App のインストールアクセストークンを取得する @gorohash](https://zenn.dev/gorohash/articles/e2c5f6ce50f4e6)
[AmazonLinux2022をGitHub self-hosted runnersとして動かす @k_i](https://zenn.dev/k_i/articles/10b16875cb9fd6)

またユーザーデータに関する細かな仕様については公式ドキュメントで確認してください。

[起動時に Linux インスタンスでコマンドを実行する - Linuxインスタンス用ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/user-data.html)

# スポットインスタンスのリクエスト
ではいよいよスポットインスタンスを立ち上げていきます。
とは言え必要な準備はだいたい済んでいるので、あとは設定するだけですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/23b0453e-facc-abc6-e973-a299bc49e7c5.png)
AMIはスクリプトがAmazon Linux 2であることを前提としているため固定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/14d51e67-76bf-5198-6647-51ce1cc4aba0.png)
セキュリティグループは作っていなければ作っておきましょう。
SSH(22)とHTTP(80)とHTTPS(443)を開ければOKです[^3]。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/125c0fb5-b965-3147-00cf-a7ef3234e47e.png)
IAM インスタンスプロフィールに先ほど作ったロールを指定[^4]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/0812b103-a8e2-f711-a4ab-644a991523b0.png)
ユーザーデータを`#!/bin/bash`から入力

あとはお好みで設定してリクエストを作成しましょう。
インスタンスが立ち上がったあとGitHubを確認してランナーが登録されていれば成功です！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/d9976532-70d1-5f63-c606-5ba8bd6f9e31.png)

# おわりに(言い訳)
ひとまずランナーの登録までを自動化することができました。
本当はインスタンスの終了時にランナーの登録を解除するところまで自動化して完成なのですが、実際のところ今はスポットと言えどもインスタンスが中断する事態は稀なので…まあ…

いつか必要に駆られたらその辺りの話と、Auto Scalingの話をしたいと思います。

それではまた。


[^1]: 何がよろしくないのかはカヤック様の[こちらの記事](https://techblog.kayac.com/github-apps-bot)に詳しいです
[^2]: スポットフリートも後からAuto Scalingできるので
[^3]: [GitHub Actions > 自分のランナーをホストする > self-hosted runners > 必要な環境](https://docs.github.com/ja/enterprise-server@3.2/actions/hosting-your-own-runners/adding-self-hosted-runners#prerequisites)
[^4]: 正しくはロールを作る際に自動で生成された同名のインスタンスプロフィール