GitHub Self-Hosted Runners on Azure Container Apps with Autocsaling
Tags: github, azure, cloud, devops
Published: 2023-03-07 04:41:00

เรื่องมันมีอยู่ว่า อยากได้ GitHub Self-Hosted Runners แบบที่ autoscale ได้ตาม workload ครับ และตอนที่ไม่ใช้เลยก็สามารถ scale in instance เหลือ 0 ได้ ผมค้นไปเจอ [Autoscaling self hosted GitHub runner containers with Azure Container Apps (ACA)](https://dev.to/pwd9000/run-docker-based-github-runner-containers-on-azure-container-apps-aca-1n13) มา คิดว่าค่อนข้างตรงกับที่ต้องการ เลยเอามาลอง POC ดูครับ

จากใน link ข้างต้น จะเป็นการ run container image ของ GitHub Self-Hosted Runners ด้วย Azure Container Apps และอาศัยความสามารถของ [KEDA autoscaler](https://keda.sh/) ในการ scale replica ตามจำนวน queue ใน Azure Storage Queue

การทำงานจะเป็นแบบนี้ครับ
1. เมื่อ GitHub workflow ถูก trigger ให้ run มันจะไป push message ลงไปที่ Azure Storage Queue
1. KEDA scaler ใน Azure Container Apps ตรวจพบว่ามี queue เพิ่มขึ้น จึงทำการ scale out instance ของ GitHub selfhosted runners ตามจำนวน queue
1. เมื่อ scale เรียบร้อย ตัว GitHub selfhosted runners จะมารับงานจาก GitHub workflow ไป run จนเรียบร้อย
1. GitHub workflow ทำการ delete message ออกจาก Azure Storage Queue
1. KEDA scaler ตรวจพบว่ามี queue ลดลงจึง scale in  instance ของ GitHub selfhosted runners ลง ถ้า queue เป็น 0 จำนวน instance ของ GitHub selfhosted runners ก็เป็น 0

ด้วยความคัน ผมจะเปลี่ยนนิดหน่อย ดังนี้
- ใช้เป็น Azure Service Bus Queue แทน Azure Storage Queue เพราะในกรณีที่มีการ send / receive message เยอะ Azure Service Bus Queue จะถูกกว่านิดหน่อย (อ่านเพิ่ม [Storage queues and Service Bus queues - compared and contrasted](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-azure-and-service-bus-queues-compared-contrasted))
- ปรับ workflow ให้ใช้ [Workload Identity Federation](https://dev.to/peepeepopapapeepeepo/let-github-action-access-azure-resources-without-password-f0i) เพื่อให้เป็น passwordless
- สร้าง Azure Container Apps แบบมี Virtual Network Integration เพื่อให้ interact กับ resources ที่เป็น private ได้

Diagram จะเป็นแบบนี้นะครับ

![Diagram](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n6w2u2a30zbuk58uosjl.png)

และเช่นกัน ณ เวลาที่ผมเขียน blog นี้นั้น Azure Container Apps ยังมีข้อจำกัดดังนี้
- Support แค่ Linux-based x86-64 (linux/amd64) container image.
- ยังไม่มี KEDA scalers for GitHub runners
- ยังไม่มีที่ Southeast Asia

ทีนี้ก่อนเริ่มทำเรามาดูกันก่อนว่าเราต้องมีอะไรกันบ้าง
- **Azure Subscription** ( ใช้เพื่อ deploy resources ต่างๆ เช่น vnet, log analytics workspace, container apps และ service bus เป็นต้น )
- **Github Organization** ( เราจะให้ runners เป็น oganization level runners )
- **Github Repository** ( ใช้เก็บและ run workflow )

---

## ลงมือทำกันเลย

### Prerequisites

- [Create Azure subscription](https://learn.microsoft.com/en-us/dynamics-nav/how-to--sign-up-for-a-microsoft-azure-subscription)
- [Sign up Github](https://docs.github.com/en/get-started/signing-up-for-github/signing-up-for-a-new-github-account)
- [Create repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-new-repository)
- [Create organization](https://docs.github.com/en/organizations/collaborating-with-groups-in-organizations/creating-a-new-organization-from-scratch)
- [Create Github Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) โดยเลือก scope เป็น `admin:org`
- Create GitHub Self-hosted Runners Container Image ดูได้จาก [docker-github-actions-runner](https://github.com/myoung34/docker-github-actions-runner) หรือ [Create a Docker based Self Hosted GitHub runner Linux container](https://dev.to/pwd9000/create-a-docker-based-self-hosted-github-runner-linux-container-48dh) แล้ว push ขึ้น container registry ของเรา

### Preflight

- ทำการ install extensions ของ Azure CLI และ providers ที่ subscription

``` bash
az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights
az provider register --namespace Microsoft.ContainerService
az provider register --namespace Microsoft.ServiceBus
```

- Define variables

``` bash
POC_PREFIX="pocghaca"
LOCATION="westus3"
LOC="usw3"
RESOURCE_GROUP="rg-${POC_PREFIX}-az-${LOC}-001"
VNET_NAME="vnet-${POC_PREFIX}-az-${LOC}-001"
INFRASTRUCTURE_SUBNET="snet-infra-az-${LOC}-001"
LOG_ANALYTIC_WORKSPACE="log-${POC_PREFIX}-az-${LOC}-001"
SERVICEBUS_NAME="sbn-${POC_PREFIX}-az-${LOC}-001"
SERVICEBUS_QUEUE="gh-runner-scaler"
CONTAINERAPPS_ENVIRONMENT="cte-${POC_PREFIX}-az-${LOC}-001"
CONTAINERAPP_NAME="cta-${POC_PREFIX}-az-${LOC}-001"
CONTAINER_REGISTRY_SERVER="..<YOUR-CONTAINER-REGISTRY>.."
CONTAINER_REGISTRY_USER="..<YOUR-REGISTRY-USERNAME>.."
CONTAINER_REGISTRY_PASS="..<YOUR-REGISTRY-PASSWORD>.."
CONTAINER_IMAGE="..<YOU-CONTAINER-IMAGE>.."
PAT="..<YOUR-GITHUB-ACCESS-TOKEN>.."
GH_ORG="..<YOUR-ORGANIZATION-NAME>.."
```

### Prerequisite Azure Resources

- Create Resource group

``` bash
az group create \
--name $RESOURCE_GROUP \
--location $LOCATION
```

- Create Log analytics workspaces

``` bash
az monitor log-analytics workspace create \
--name $LOG_ANALYTIC_WORKSPACE \
--resource-group $RESOURCE_GROUP \
--quota 1 \
--retention-time 30 \
--sku PerGB2018
```

- Create Virtual network and subnet

``` bash
az network vnet create \
--resource-group $RESOURCE_GROUP \
--name $VNET_NAME \
--location $LOCATION \
--address-prefix 10.0.0.0/16
az network vnet subnet create \
--resource-group $RESOURCE_GROUP \
--vnet-name $VNET_NAME \
--name $INFRASTRUCTURE_SUBNET \
--address-prefixes 10.0.0.0/21
```

### Azure Service Bus and Queue

- Create Azure service bus and queue

``` bash
az servicebus namespace create \
--name $SERVICEBUS_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION
az servicebus queue create \
--resource-group $RESOURCE_GROUP \
--namespace-name $SERVICEBUS_NAME \
--name $SERVICEBUS_QUEUE
```

- Create connection string of Azure Service Bus Queue

``` bash
az servicebus queue authorization-rule create \
--resource-group $RESOURCE_GROUP \
--namespace-name $SERVICEBUS_NAME \
--queue-name $SERVICEBUS_QUEUE \
--name $CONTAINERAPP_NAME \
--rights Manage Send Listen
```

- Add yourself as `Azure Service Bus Data Owner` on Azure Service Bus Namespace

![service-bus-queue-role-assignment](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/94xeqexiob7lie0mga07.png)

- Test send a message and receive/delete a message from the queue

> ⚠️ ถ้าทำใน bash ของ Azure Cloud Shell ใน run `az login` อีกครั้ง

``` bash
# Send message
az rest \
--resource "https://servicebus.azure.net" \
--method post \
--headers BrokerProperties='{"TimeToLive":10}' \
--url "https://${SERVICEBUS_NAME}.servicebus.windows.net/${SERVICEBUS_QUEUE}/messages" \
--body "Hello from Az REST with TTL"
```

``` bash
# Receive / Delete message
az rest \
--resource "https://servicebus.azure.net" \
--method delete \
--url "https://${SERVICEBUS_NAME}.servicebus.windows.net/${SERVICEBUS_QUEUE}/messages/head"
```

### Create Azure Container App

- รวบรวมข้อมูลที่ใช้ในการสร้าง Azure Container App และ Azure Container Environment

``` bash
INFRASTRUCTURE_SUBNET=$(az network vnet subnet show --resource-group $RESOURCE_GROUP --vnet-name $VNET_NAME --name $INFRASTRUCTURE_SUBNET --query "id" -o tsv | tr -d '[:space:]')
LOG_WORKSPACE_ID=$(az monitor log-analytics workspace show --name $LOG_ANALYTIC_WORKSPACE --resource-group $RESOURCE_GROUP --query "customerId" -o tsv | tr -d '[:space:]')
LOG_WORKSPACE_KEY=$(az monitor log-analytics workspace get-shared-keys --name $LOG_ANALYTIC_WORKSPACE --resource-group $RESOURCE_GROUP --query "primarySharedKey" -o tsv | tr -d '[:space:]')
CONNECTION_QUEUE=$(az servicebus queue authorization-rule keys list --resource-group $RESOURCE_GROUP --namespace-name $SERVICEBUS_NAME --queue-name $SERVICEBUS_QUEUE --name $CONTAINERAPP_NAME --query primaryConnectionString --output tsv | tr -d '[:space:]')
```

- Create Azure Container App Environment

``` bash
az containerapp env create \
  --name $CONTAINERAPPS_ENVIRONMENT \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION \
  --infrastructure-subnet-resource-id $INFRASTRUCTURE_SUBNET \
  --docker-bridge-cidr "172.17.0.1/16" \
  --platform-reserved-cidr "100.64.0.0/16" \
  --platform-reserved-dns-ip "100.64.0.10" \
  --logs-destination "log-analytics" \
  --logs-workspace-id $LOG_WORKSPACE_ID \
  --logs-workspace-key $LOG_WORKSPACE_KEY \
  --internal-only
```

- Create Azure Container App with autoscaling

``` bash
az containerapp create \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--image $CONTAINER_IMAGE \
--environment $CONTAINERAPPS_ENVIRONMENT \
--registry-server $CONTAINER_REGISTRY_SERVER \
--registry-username $CONTAINER_REGISTRY_USER \
--registry-password $CONTAINER_REGISTRY_PASS \
--secrets gh-token="$PAT" servicebus-connection-string="$CONNECTION_QUEUE" \
--env-vars \
  ACCESS_TOKEN=secretref:gh-token \
  RUNNER_SCOPE="org" \
  ORG_NAME=$GH_ORG \
  LABELS="poc-gh-aca" \
  RUNNER_WORKDIR="/tmp/github-runner" \
--cpu "1.75" \
--memory "3.5Gi" \
--min-replicas 0 \
--max-replicas 3 \
--scale-rule-auth "connection=servicebus-connection-string" \
--scale-rule-name queue-scaling \
--scale-rule-type azure-servicebus \
--scale-rule-metadata \
  "queueName=$SERVICEBUS_QUEUE" \
  "namespace=$SERVICEBUS_NAME" \
  "messageCount=1"
```

- ตรวจสอบ system log

``` bash
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type console \
--follow
```

- ตรวจสอบ console log

``` bash
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type system \
--follow
```

- ตรวจสอบว่า runner เกิดขึ้นใน organization ของเรา

![github-self-hosted-runner](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9lmz57zm30vr42h39pyl.png)

- ลอง send และ delete message แล้วตรวจสอบ Queue ใน Azure Service Bus และ check จำนวน runner ที่เกิดขึ้นใน organization

``` bash
# Send message
az rest \
--resource "https://servicebus.azure.net" \
--method post \
--headers BrokerProperties='{"TimeToLive":3600}' \
--url "https://${SERVICEBUS_NAME}.servicebus.windows.net/${SERVICEBUS_QUEUE}/messages" \
--body "Hello from Az REST with TTL"
```

``` bash
# Receive / Delete message
az rest \
--resource "https://servicebus.azure.net" \
--method delete \
--url "https://${SERVICEBUS_NAME}.servicebus.windows.net/${SERVICEBUS_QUEUE}/messages/head"
```

``` bash
# See console log
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type console \
--follow
```

``` bash
# See system log
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type system \
--follow
```

### Create GitHub workflow to verify our system

- ทำการ create Service Principal แล้ว federate credential ไปให้ Github Organization และ Repository ที่เราสร้างเตรียมไว้โดยเลือกเป็น branch main

![Credential Federation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ejw53kzfisu7i43cfl8r.png)

> รายละเอียดดูได้จาก [Let Github Action Access Azure Resources without password](https://dev.to/peepeepopapapeepeepo/let-github-action-access-azure-resources-without-password-f0i)

- Create role assignment ให้ Service Principal ของเรามีสิทธิ์ `Azure Service Bus Data Receiver` และ `Azure Service Bus Data Sender` ที่ Queue ที่เราสร้างไว้

![queue-role-assignment](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4ovxk3ri7ucyws5h7kno.png)

- Create role assignment ให้ Service Principal ของเรามีสิทธิ์ `Contributor` บน subscription ของเรา

![subscription-role-assignment](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ix8bd5tn0gb06ydzxk82.png)

- สำหรับสาย free ถ้า GitHub repository ของคุณเป็น public ต้องมา allow ที่ Runner Groups Default ก่อน

![Runner-Groups-Default](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/06z4p33zw46jmpp2upxb.png)

- ต่อไปเข้าไปที่ Repository ของตัวเองแล้ว define secret และ variable เหล่านี้
  - Secrets
      - `AZURE_CLIENT_ID`
      - `AZURE_SUBSCRIPTION_ID`
      - `AZURE_TENANT_ID`
  - Variables
      - `AZ_SERVICE_BUS_NAMESPACE`
      - `AZ_SERVICE_BUS_QUEUE`

![action-menu](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4s1f9hbjof0riz2whd52.png)

![action-secret](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y7y6isydhatlaw3770i6.png)

![action-variable](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/od4xo8h2xaku1lvhugn5.png)

- จากนั้นทำการสร้าง file workflow `.github/workflows/demo.yaml` ใน GitHub Repository

``` yaml
name: Scale up selfhosted runner and run
on:
   workflow_dispatch:

permissions:
  id-token: write
  contents: read
  
env:
  AZ_SERVICE_BUS_NAMESPACE: ${{ vars.AZ_SERVICE_BUS_NAMESPACE }}
  AZ_SERVICE_BUS_QUEUE:  ${{ vars.AZ_SERVICE_BUS_QUEUE }}

jobs:
  scale-out-runner:
    runs-on: ubuntu-latest
    steps:
      - name: 'Az CLI login'
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: scale out self hosted
        run: |
         az rest \
         --resource "https://servicebus.azure.net" \
         --method post \
         --headers BrokerProperties='{"TimeToLive":3600}' \
         --url "https://${{ env.AZ_SERVICE_BUS_NAMESPACE }}.servicebus.windows.net/${{ env.AZ_SERVICE_BUS_QUEUE }}/messages" \
         --body "${{ github.run_id }}"
     
  deploy:
    runs-on: [self-hosted, poc-gh-aca]
    needs: [scale-out-runner]
    steps:
      - name: 'Az CLI login'
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: 'Run az commands'
        run: |
          az account show --output table

  scale-in-runner:
    runs-on: ubuntu-latest
    needs: [scale-out-runner, deploy]
    if: ${{ always() && needs.scale-out-runner.result == 'success' }}
    steps:
      - name: 'Az CLI login'
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: scale in self hosted
        run: |
          az rest \
          --resource "https://servicebus.azure.net" \
          --method delete \
          --url "https://${{ env.AZ_SERVICE_BUS_NAMESPACE }}.servicebus.windows.net/${{ env.AZ_SERVICE_BUS_QUEUE }}/messages/head"
```

![github-workflow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/99uv7qqnmwpfvf7as9so.png)

ใน workflow จะมี 3 jobs
1. `scale-out-runner`: จะเป็นการ send message เข้าไปที่ Service Bus เพื่อ ให้ container apps scale out GitHub SelfHosted Runners ขึ้นมารับงานไปทำ
1. `deploy`: จะเป็นส่วนงานหลักที่ pipeline ต้องทำ โดย job นี้จะทำก็ต่อเมื่อ `scale-out-runner` success
1. `scale-in-runner`: หลังจากทำงานเสร็จไม่ว่าจะ success หรือไม่ job นี้จะทำการ delete message ออกจาก Service Bus เพื่อให้ container apps ทำการ scale in GitHub SelfHosted Runners ลงไป

- ให้ลองทำการ run workflow หลายๆ ครั้ง พร้อมๆ กัน

![run-workflow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ryjfwty1jyooz0r2d4h.png)

- ตรวจสอบดูว่าการ autoscale ทำงานได้ตามที่คาดการณไว้หรือไม่
  - Azure Service Bus Queue
    ![Azure-Service-Bus-Queue](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/551n6f397wa7fegi1bvk.png)
  - Container App Replicas
    ![Container-App-Replicas](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nio0su9ayt7yjfg9xh6h.png)
  - Github Organization-Level Self-hosted Runners
    ![Github-Organization-Level-Self-hosted-Runners](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rgr3j7sin9hrtezcfxin.png)

- หลังจาก workflow run เรียบร้อยหมดแล้ว ลองตรวจสอบ อีกครั้ง ทุกอย่างต้องเป็น 0
  - Azure Service Bus Queue
    ![Azure-Service-Bus-Queue](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gmu66kv9a66inhhg0llu.png)
  - Container App Replicas
    ![Container-App-Replicas](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mjpd6mf2yoey8zalax8b.png)
  - Github Organization-Level Self-hosted Runners
    ![Github-Organization-Level-Self-hosted-Runners](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6gq1yk4jihv5p86oyx5d.png)

---

## References
- [KEDA | Azure Service Bus](https://keda.sh/docs/2.9/scalers/azure-service-bus/)
- [Scaling in Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/scale-app?pivots=azure-cli#example-2)
- [Autoscaling self hosted GitHub runner containers with Azure Container Apps (ACA)](https://dev.to/pwd9000/run-docker-based-github-runner-containers-on-azure-container-apps-aca-1n13)
- [Provide a virtual network to an internal Azure Container Apps environment](https://learn.microsoft.com/en-us/azure/container-apps/vnet-custom-internal?tabs=bash&pivots=azure-cli)
- [Send Message to Queue](https://learn.microsoft.com/en-us/rest/api/servicebus/send-message-to-queue)
- [Receive and Delete Message (Destructive Read)](https://learn.microsoft.com/en-us/rest/api/servicebus/receive-and-delete-message-destructive-read)
- [Calling Azure REST API via curl](https://mauridb.medium.com/calling-azure-rest-api-via-curl-eb10a06127)