Azure Container Apps Jobs で GitHub Actions を実行して、Private Endpoint で保護された App Service にデプロイする

# はじめに

App Service や Azure Functions においてプライベートエンドポイントが有効にし、パブリックアクセスを無効にしている場合、デプロイ時には工夫が必要となります。

https://learn.microsoft.com/ja-jp/azure/app-service/overview-access-restrictions#deploy-through-a-private-endpoint

https://qiita.com/georgeOsdDev@github/items/1827c2a5bc15db214cff#%E5%9B%9E%E9%81%BF%E6%96%B9%E6%B3%95

https://qiita.com/georgeOsdDev@github/items/59047759c985f44affb0

この記事では、Azure Container Apps Jobs を利用して、VNet 内で GitHub Actions セルフホステッドランナーを動かすことで、プライベートエンドポイントで保護されたリソース(App Service) にデプロイすることを試してみます。

# TL;DR

 - Container Apps Environment ではカスタム VNet を利用する(ターゲットとなる App Service の Private Endpoint にアクセスするため)
 - GitHub の PAT のスコープはしっかり確認しましょう
 - Container Apps Jobs 上で動かすイメージはチュートリアルのものだと物足りないので、自前で用意する必要アリ


# GitHub Action Runner on Container Apps jobs

## Azure Container Apps jobs is 何？

ざっくりいうとイベントドリブンで動作するコンテナ。その分コンピューティングリソースの使用量を抑えられる。

Container Apps としてデプロイできるので、VNet にアクセスすることが可能となり、GitHub Actions や、Azur DevOps などの、CI/CD パイプラインをホストすることが想定される利用シナリオとしても挙げられている。

https://learn.microsoft.com/ja-jp/azure/container-apps/jobs?tabs=azure-cli

> Azure Container Apps には、アプリとジョブという 2 種類のコンピューティング リソースがあります。
> アプリは継続的に実行されるサービスです。 アプリ内のコンテナーが失敗すると、自動的に再起動されます。 アプリの例としては、HTTP API、Web アプリ、入力を継続的に処理するバックグラウンド サービスなどがあります。
> ジョブとは、開始され、一定時間実行され、完了すると終了されるタスクです。 1 つのジョブを実行するたびに、通常、1 つの作業単位が実行されます。 ジョブの実行は、手動、スケジュール、またはイベントに応答して開始されます。 ジョブの例としては、オンデマンドで実行されるバッチ プロセスやスケジュール タスクなどがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/37bf1a53-8f0f-2bcd-3201-b6436770fba3.png)

https://learn.microsoft.com/ja-jp/azure/container-apps/billing#jobs

> 従量課金 プランでは、Azure Container Apps ジョブによって消費されるリソースには、アクティブな料金が課金されます。 ジョブが完了すると実行によってリソースの消費が停止されるため、アイドル料金はジョブには適用されません。

## 手順

基本的には以下のチュートリアルに沿って構成することでOK

https://learn.microsoft.com/ja-jp/azure/container-apps/tutorial-ci-cd-runners-jobs?pivots=container-apps-jobs-self-hosted-ci-cd-github-actions&tabs=bash

本記事では VNet から App Service の Private Endpoint に対してアクセスするため、Container Apps Environment にてカスタム VNet を利用する構成とした。

https://learn.microsoft.com/ja-jp/azure/container-apps/vnet-custom?tabs=bash%2Cazure-cli&pivots=azure-portal

## はまったポイント

### Workflow がキューに作成されても Job コンテナが起動されない

こんな感じで　`Queued` ステータスでとまってしまう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/d57817e5-1818-4c6f-bb38-8fc4c9cdfc5b.png)

原因としては、GitHub の PAT を作成する際に対象のリポジトリを選択する箇所がある。ここで今回ワークフローを実行したいリポジトリの選択を誤っていたことで、ワークフローがいつまでたっても実行されないことになっていた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/a305e1cf-49b6-7bb1-d198-aef97d2088db.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/74e38095-9294-ad91-f0f8-957a118301db.png)

これはなぜかというと、今回チュートリアルで作成したジョブコンテナのスケーリングルールでは Github Runner Scaler を用いている、このスケーラーは何をやっているかというと、`api.github.com` のリポジトリ系 API をインターバルに従ってキューの有無を監視、キューにアイテムがあればスケーリング(= Self hosted runner 用のコンテナの起動)といったことを実行している。
そのため、PAT が対象のリポジトリに対するアクセス権がないと、APIの応答が適切に得られず、スケーリングが発生しないという状態だった。

https://keda.sh/docs/2.12/scalers/github-runner/

`ContainerAppSystemLogs_CL` ではスケーリングが発生したときのログは出力されるが、何も起きていない状態ではログが残らないため、解決のためにはスケーラーのコードを見て呼び出してそうな API を確認、PAT を使って API を叩いてみるといった地道なデバッグが必要だった。

```bash
$ curl -L \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GHPAT" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/georgeOsdDev/ghaction-on-aca-jobs/actions/runs
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest/actions/workflow-runs#list-workflow-runs-for-a-repository"
}
```

PAT の対象リポジトリを適切に設定すると、その PAT を使った API で応答が得られるようになる。
30秒のインターバルが設定されているので、PAT の設定変更から30秒以内にジョブの開始が確認できるはず。

```bash
    --min-executions 0 \
    --max-executions 10 \
    --polling-interval 30 \
    --scale-rule-name "github-runner" \
    --scale-rule-type "github-runner" \
    --scale-rule-metadata "github-runner=https://api.github.com" "owner=$REPO_OWNER" "runnerScope=repo" "repos=$REPO_NAME" "targetWorkflowQueueLength=1" \
    --scale-rule-auth "personalAccessToken=personal-access-token" \
```

なお、`--scale-rule-metadata` については、`github-runner=https://api.github.com` という指定は間違っている気がするので以下の Issue をたてた。

https://github.com/MicrosoftDocs/azure-docs/issues/118597

`ContainerAppSystemLogs_CL`　では下記のような出力が確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/8f5267c7-0922-7973-5e71-45818e9124ad.png)

### Job 実行時に必要なツールが入っていない。

GitHub Hosted Runner の場合、開発で利用されることの多い一般的な言語やツール群がすでにインストールされている状態となっている。例えば [Ubuntu 22.04](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md) 

そのため、workflow 上で気軽に `zip` とかが使えるが、詳しくは後述するがチュートリアルで用いているジョブの実行イメージは `ghcr.io/actions/actions-runner` 単体となり、workflow 上で必要なものが足りない場合がある。App Service によって生成された yaml に対してだと、`zip`、`unzip`、`az cli` が足りなかった。詳しくは後述。

## どういうしくみで動いているのか

### Workflow Job のキュー監視

これは前述のとおり [github-runner](https://keda.sh/docs/2.12/scalers/github-runner/) という KEDA Scaler がポーリングでキューアイテムの有無を確認している。

> How does it work?
The scaler will query the GitHub API to get the number of queued jobs in the specified repositories, subject to filters. If the number of queued jobs is equal to or greater than the targetWorkflowQueueLength, the scaler will scale up.

https://keda.sh/docs/2.12/scalers/github-runner/#how-does-it-work

そのため、下記の資料にあるような GitHub 側からの [Webhook](https://docs.github.com/ja/webhooks/webhook-events-and-payloads#workflow_job) を必要としているわけではなく、GitHub リポジトリ側に Container Apps Jobs の情報を明示的に登録する必要はない。

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

### 起動するコンテナイメージは何？

KEDA によってスケールが発生したときに起動するイメージは何をやっているのかを見てみる。
チュートリアルに沿って利用したコンテナイメージは以下の通り

```bash
az acr build \
    --registry "$CONTAINER_REGISTRY_NAME" \
    --image "$CONTAINER_IMAGE_NAME" \
    --file "Dockerfile.github" \
    "https://github.com/Azure-Samples/container-apps-ci-cd-runner-tutorial.git"
```

ここで指定している `Dockerfike` は以下のとおり

```dockerfile:https://github.com/Azure-Samples/container-apps-ci-cd-runner-tutorial/blob/main/Dockerfile.github
FROM ghcr.io/actions/actions-runner:2.304.0
# for latest release, see https://github.com/actions/runner/releases

USER root

# install curl and jq
RUN apt-get update && apt-get install -y curl jq && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

COPY github-actions-runner/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh

USER runner

ENTRYPOINT ["./entrypoint.sh"]
```

```bash:https://github.com/Azure-Samples/container-apps-ci-cd-runner-tutorial/blob/main/github-actions-runner/entrypoint.sh
#!/bin/sh -l

# Retrieve a short lived runner registration token using the PAT
REGISTRATION_TOKEN="$(curl -X POST -fsSL \
  -H 'Accept: application/vnd.github.v3+json' \
  -H "Authorization: Bearer $GITHUB_PAT" \
  -H 'X-GitHub-Api-Version: 2022-11-28' \
  "$REGISTRATION_TOKEN_API_URL" \
  | jq -r '.token')"

./config.sh --url $REPO_URL --token $REGISTRATION_TOKEN --unattended --ephemeral && ./run.sh
```

`ghcr.io/actions/actions-runner:2.304.0` をベースイメージとして `./entrypoint.sh` を追加している。
`ghcr.io/actions/actions-runner` は何かというと、[GitHub が提供している ランナーの公式イメージ](https://github.com/actions/runner/pkgs/container/actions-runner)です。

`./entrypoint.sh` では、[エフェメラル ランナー](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners#using-ephemeral-runners-for-autoscaling)の登録を実行したのちに、Runner そのものを起動する `./run.sh` が実行されている。


#### エフェメラル ランナーの確認

通常時は以下の通り、Self-hosted runner は未登録
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/6240049b-f685-8bc0-ceca-402cebe6d77a.png)

Container Apps Jobs が動作したタイミングで、以下のように追加されている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/3cf0e390-1907-9484-d7cc-91c40da71ef6.png)

エフェメラルランナーとして、Container Apps Jobs で動作するコンテナが登録されることで、そのタイミングでキューにあるワークフローがそのランナーで実行されることになる。

#### ghcr.io/actions/actions-runner のバージョン

`Dockerfile` で指定されている `2.304.0` はちょっと古い（最新は`v2.311.0`) 。[`latest`](https://github.com/actions/runner/pkgs/container/actions-runner/141132628?tag=latest) 指定する必要はないのかなと思ったが、[自動更新](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners#controlling-runner-software-updates-on-self-hosted-runners)をよしなにやってくれる模様。

`ContainerAppConsoleLogs_CL` からその様子を確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/e19d1648-0c72-9b19-4dd0-cd2655285609.png)

また、GitHub 上でも同じく最新版で動作したことを確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/7458a25e-9869-51e5-4e20-901add7ca427.png)


### 通信経路

以下のとおり、GitHub から何かを受信するものではなく、セルフホステッドランナー側からアウトバウンド通信が発生する仕組みとなっている。

> セルフホステッド ランナーが GitHub.com への接続を開くので、ユーザーはセルフホステッド ランナーへのインバウンド接続を行うことを GitHub に許可する必要はありません。

https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#%E3%82%BB%E3%83%AB%E3%83%95%E3%83%9B%E3%82%B9%E3%83%88%E3%83%A9%E3%83%B3%E3%83%8A%E3%83%BC%E3%81%A8github%E3%81%A8%E3%81%AE%E9%80%9A%E4%BF%A1

セルフホステッドランナーからのアウトバウンド通信は、上記ドキュメントに記載のように、`github.com`、`api.github.com` 、`*.actions.githubusercontent.com`　などセルフホステッドランナーが通信できる必要がある FQDN が定義されている。すなわち、Container Apps Jobs をデプロイするサブネットの NSG アウトバウンドルールでは、`Internet` 向けの通信を許可しておく必要がある。

https://learn.microsoft.com/ja-jp/azure/container-apps/firewall-integration?tabs=consumption-only


# Private Endpoint で保護された App Service AppService のデプロイに使ってみる

前置きが長くなりましたが、実際に Private Endpoint で保護された App Service へのデプロイに利用してみます。

本題とは関係ないですが今ホットな [Hono](https://hono.dev/getting-started/nodejs) アプリをデプロイしてみました。

## 前提

以下の通り、パブリックアクセス無効でプライベートエンドポイントが有効。プライベートエンドポイントは先に作った Container Apps Environment の統合 VNet からアクセス可能な状態。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/60508606-3664-4536-c430-74dc1f1e66eb.png)

`yaml` ファイルは Azure ポータルのデプロイセンター経由で自動生成されたものを利用します。

```yaml:main_webapp-deploy-test2023.yml
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions

name: Build and deploy Node.js app to Azure Web App - webapp-deploy-test2023

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js version
        uses: actions/setup-node@v3
        with:
          node-version: '20.x'

      - name: npm install, build, and test
        run: |
          npm install
          npm run build --if-present
          npm run test --if-present

      - name: Zip artifact for deployment
        run: zip release.zip ./* -r

      - name: Upload artifact for deployment job
        uses: actions/upload-artifact@v3
        with:
          name: node-app
          path: release.zip

  deploy:
    runs-on: self-hosted
    needs: build
    environment:
      name: 'Production'
      url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
    permissions:
      id-token: write #This is required for requesting the JWT

    steps:
      - name: Download artifact from build job
        uses: actions/download-artifact@v3
        with:
          name: node-app

      - name: Unzip artifact for deployment
        run: unzip release.zip

      - name: Login to Azure
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_070C1CDA7B1343E4AD320E57FBF78D5C }}
          tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_82953456381644C1B354BD1E088C79BB }}
          subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_994B5092010F4FC7AE0F13F292786610 }}

      - name: 'Deploy to Azure Web App'
        id: deploy-to-webapp
        uses: azure/webapps-deploy@v2
        with:
          app-name: 'webapp-deploy-test2023'
          slot-name: 'Production'
          package: .

```

まずは、GitHub Hosted Runner で実行してみる。
想定通り 403 エラーでデプロイできない。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/6a9c9533-06c2-0316-4d36-15d1b2c92b53.png)

## Container Apps Jobs 上の Self Hosted Runner から実行されるようにワークフローを変更する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/abbb94b7-a4fa-d29c-314a-0082e3afec61.png)

### `zip` コマンドがなくてエラー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/ecd82437-aaea-c6e0-0aa4-ea2f808358b9.png)

しょうがないので `zip`、`unzip` を含むイメージを作成して 1.1 として ACR にプッシュ、ACA 側で参照タグを 1.1 に変更。

### `az` コマンドがなくてエラー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/ada9b8d4-2a2b-b9ae-7b50-de663648bad5.png)

しょうがないので `az` を含むイメージを作成して 1.2 として ACR にプッシュ、ACA 側で参照タグを 1.2 に変更

### 最終的に

最終的に以下のような Dockerfileとなった。

```Dockerfile
FROM ghcr.io/actions/actions-runner:2.304.0
# for latest release, see https://github.com/actions/runner/releases

USER root

# install curl and jq zip unzip, az-cli
RUN apt-get update && apt-get install -y curl jq zip unzip && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* && \
    curl -sL https://aka.ms/InstallAzureCLIDeb | bash

COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh

USER runner

ENTRYPOINT ["./entrypoint.sh"]
```


無事デプロイ成功

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/86a3c175-659b-737f-45b5-3024af30e6bf.png)

ターゲットとなる App Service のデプロイセンターのログは以下の通り。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/558672d4-6a96-4c1a-8040-f0bc27b4e487.png)


Private Endpoint につながる別環境からもHonoアプリがデプロイされていること確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/9632fd34-2764-3d89-ec4a-c15df8c6dc5c.png)


めでたしめでたし。