GitHub Actionsのself-hosted runnerに使っているVMを実行時だけ起動する

# はじめに

GitHub Actionsのself-hosted runnerを必要とするケースがあると思います。

- 同じネットワーク上でCIを動かしたい
- 固定IPからしかアクセスを許可していないリソースにCIからアクセスが必要

など

一方で、GitHub Actionsの実行回数が多くない場合には、Self-hosted Runnerの待機時間が長くリソースの無駄遣いになってしまいます。

:::note info
今回の対象は、開発規模が小さく https://github.com/actions/actions-runner-controller などの仕組みはオーバースペックな一方、なにかしらの要件で簡易的に作成したself-hosted runnerを効率よく運用したいと言うケースです。
:::

# 今回の構成

今回はシンプルに、「GCP Virtual Machine 1台のみ」でSelf-hosted Runnerを構成しているケースでのAutoscalingを紹介します。

（同様のやり方でAutoscalingGroupを使っている場合にも応用が可能だと思います。）

https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners

# 起動と停止の方法

GitHub Actionsの [workflow_run](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run) eventの event_type `requested` と `completed` を使ってGitHub Actionsのworkflowとして実装しました。

## GitHub Actionsのトリガー条件

```yaml
on:
  workflow_run: # to start/stop vm instance when workflow has been started/completed.
    workflows: [some-workflow-using-self-hosted-runner]
    types: 
      - requested # start
      - completed # completed
```

Triggerの条件を上のように書いて、`workflows` にself-hosted-runnerを使っているworkflowsをリスとしてあげればOkです。

## GitHub Event Action から start or stopへ変換

`requested`なら `start`、 それ以外なら`stop`と設定します。
ついでに、Slack通知用のメッセージも書いています。

```yaml
      - name: Determine action
        id: determine-action
        run: |
          if [[ "${{ github.event.action }}" == "requested" ]];then
            echo "action=start" >> "$GITHUB_OUTPUT"
            echo "MESSAGE=VM ${VM_NAME} has been stgarted." >> "$GITHUB_OUTPUT"
          else
            echo "action=stop" >> "$GITHUB_OUTPUT"
            echo "MESSAGE=VM ${VM_NAME} has been stopped." >> "$GITHUB_OUTPUT"
          fi
```

## GCP VMの起動・停止

今回紹介はしていませんが、GitHub ActionsからGCPリソースへアクセスする方法として[Configuring OpenID Connect in Google Cloud Platform](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform)に載っている、OIDCでの認証しているので設定されている以下の値をenvに設定しておきます。

- WORKLOAD_IDENTITY_PROVIDER
- SERVICE_ACCOUNT

```yaml
env:
  WORKLOAD_IDENTITY_PROVIDER: xxxx
  SERVICE_ACCOUNT: xxx
```

`google-github-actions/auth`を使って認証し、gcloudを設定して、(このステップ不要かも)、最後に `gcloud compute instances <start or stop> <instance name>` を使って起動or停止をします

```yaml
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ env.SERVICE_ACCOUNT }}

      - name: Set up Google Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: "${{ steps.determine-action.outputs.action }} instance"
        if: steps.check-status.outputs.need_to_update == 'true'
        run: |
          echo "${{ steps.determine-action.outputs.action }} instance..."
          gcloud compute instances "${{ steps.determine-action.outputs.action }}" "${{ env.VM_NAME }}" --zone "${{ env.VM_ZONE }}" --project ${{ env.GCP_PROJECT }} --async
```


# 最終的なGitHub Actions

最終的なGitHub ActionsのYamlは以下のようになります。

上で紹介したものの他に、複数workflowが走ったときに、無駄に`gcloud compute instances start/stop` を実行しないように、その時点起動中か停止中かどうかも取得してから、コマンドを実行しています。

また、最後にSlackに通知をするようにして、いつ起動されていつ停止されたのかもわかるようにしています。

```yaml
name: start-and-stop-self-hosted-runner

on:
  workflow_run: # to start/stop vm instance when workflow has been started/completed.
    workflows: [some-workflow-using-self-hosted-runner]
    types: 
      - requested # start
      - completed # completed

concurrency:
  group: start-and-stop-self-hosted-runner
  cancel-in-progress: true

env:
  WORKLOAD_IDENTITY_PROVIDER: projects/<project-number>/locations/global/workloadIdentityPools/<pool-name>/providers/<provider-name>
  SERVICE_ACCOUNT: github-actions@<your-gcp-project>.iam.gserviceaccount.com
  VM_NAME: test-instance-ubuntu
  VM_ZONE: asia-northeast1-c
  GCP_PROJECT: <your-gcp-project>

jobs:
  action:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      actions: read
    steps:
      - name: Determine action
        id: determine-action
        run: |
          if [[ "${{ github.event.action }}" == "requested" ]];then
            echo "action=start" >> "$GITHUB_OUTPUT"
            echo "MESSAGE=VM ${VM_NAME} has been stgarted." >> "$GITHUB_OUTPUT"
          else
            echo "action=stop" >> "$GITHUB_OUTPUT"
            echo "MESSAGE=VM ${VM_NAME} has been stopped." >> "$GITHUB_OUTPUT"
          fi

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ env.SERVICE_ACCOUNT }}

      - name: Set up Google Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Check the status of the instance
        id: check-status
        run: |
          status=$(gcloud compute instances describe "${{ env.VM_NAME }}" --zone "${{ env.VM_ZONE }}" --format="get(status)" --project ${{ env.GCP_PROJECT }})
          # set need to update desired status
          if [ "${{ steps.determine-action.outputs.action }}" == 'start' ] && [ "$status" == 'TERMINATED' ]; then
            echo "need_to_update=true" >> "$GITHUB_OUTPUT"
          elif [ "${{ steps.determine-action.outputs.action }}" == 'stop' ] && [ "$status" == 'RUNNING' ]; then
            echo "need_to_update=true" >> "$GITHUB_OUTPUT"
          else
            echo "need_to_update=false" >> "$GITHUB_OUTPUT"
          fi

      - name: "${{ steps.determine-action.outputs.action }} instance"
        if: steps.check-status.outputs.need_to_update == 'true'
        run: |
          echo "${{ steps.determine-action.outputs.action }} instance..."
          gcloud compute instances "${{ steps.determine-action.outputs.action }}" "${{ env.VM_NAME }}" --zone "${{ env.VM_ZONE }}" --project ${{ env.GCP_PROJECT }} --async

      - name: Send slack notification
        if: steps.check-status.outputs.need_to_update == 'true'
        run: |
          curl -X POST -H 'Content-type: application/json' --data "{\"text\":\"${{ steps.determine-action.outputs.message }}\"}" ${{ secrets.SLACK_WEBHOOK_URL }}
```

# まとめ

GitHub Actions のself-hosted runnerをGCP VMで作成した場合に、このrunnerを使うGitHub Actions Workflowが実行されるときだけVMを起動して、Workflowが終了したらVMも停止する方法を紹介しました。

gcloud compute instances の部分のコマンドをautoscalingのinstance数の調整などにすれば、VM1台の場合以外にも使えると思います。

規模が大きいケースでは、https://github.com/actions/actions-runner-controller などの仕組みを使うのが適切だと思いますが、規模が小さい場合には今回紹介したような仕組みが使えるケースも多いと思います。

今回は普段あまり使われていない `workflow_run` というGitHub Actionsのeventを活用してみました。