GitHub Self-Hosted Runners on Azure Container Apps with Autocsaling

peepeepopapapeepeepo

Sawit M.

Posted on March 7, 2023

GitHub Self-Hosted Runners on Azure Container Apps with Autocsaling

เรื่องมันมีอยู่ว่า อยากได้ GitHub Self-Hosted Runners แบบที่ autoscale ได้ตาม workload ครับ และตอนที่ไม่ใช้เลยก็สามารถ scale in instance เหลือ 0 ได้ ผมค้นไปเจอ Autoscaling self hosted GitHub runner containers with Azure Container Apps (ACA) มา คิดว่าค่อนข้างตรงกับที่ต้องการ เลยเอามาลอง POC ดูครับ

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

การทำงานจะเป็นแบบนี้ครับ

  1. เมื่อ GitHub workflow ถูก trigger ให้ run มันจะไป push message ลงไปที่ Azure Storage Queue
  2. KEDA scaler ใน Azure Container Apps ตรวจพบว่ามี queue เพิ่มขึ้น จึงทำการ scale out instance ของ GitHub selfhosted runners ตามจำนวน queue
  3. เมื่อ scale เรียบร้อย ตัว GitHub selfhosted runners จะมารับงานจาก GitHub workflow ไป run จนเรียบร้อย
  4. GitHub workflow ทำการ delete message ออกจาก Azure Storage Queue
  5. 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)
  • ปรับ workflow ให้ใช้ Workload Identity Federation เพื่อให้เป็น passwordless
  • สร้าง Azure Container Apps แบบมี Virtual Network Integration เพื่อให้ interact กับ resources ที่เป็น private ได้

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

Diagram

และเช่นกัน ณ เวลาที่ผมเขียน 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

Preflight

  • ทำการ install extensions ของ Azure CLI และ providers ที่ subscription
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
Enter fullscreen mode Exit fullscreen mode
  • Define variables
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>.."
Enter fullscreen mode Exit fullscreen mode

Prerequisite Azure Resources

  • Create Resource group
az group create \
--name $RESOURCE_GROUP \
--location $LOCATION
Enter fullscreen mode Exit fullscreen mode
  • Create Log analytics workspaces
az monitor log-analytics workspace create \
--name $LOG_ANALYTIC_WORKSPACE \
--resource-group $RESOURCE_GROUP \
--quota 1 \
--retention-time 30 \
--sku PerGB2018
Enter fullscreen mode Exit fullscreen mode
  • Create Virtual network and subnet
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
Enter fullscreen mode Exit fullscreen mode

Azure Service Bus and Queue

  • Create Azure service bus and queue
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
Enter fullscreen mode Exit fullscreen mode
  • Create connection string of Azure Service Bus Queue
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
Enter fullscreen mode Exit fullscreen mode
  • Add yourself as Azure Service Bus Data Owner on Azure Service Bus Namespace

service-bus-queue-role-assignment

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

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

# 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"
Enter fullscreen mode Exit fullscreen mode
# Receive / Delete message
az rest \
--resource "https://servicebus.azure.net" \
--method delete \
--url "https://${SERVICEBUS_NAME}.servicebus.windows.net/${SERVICEBUS_QUEUE}/messages/head"
Enter fullscreen mode Exit fullscreen mode

Create Azure Container App

  • รวบรวมข้อมูลที่ใช้ในการสร้าง Azure Container App และ Azure Container Environment
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:]')
Enter fullscreen mode Exit fullscreen mode
  • Create Azure Container App Environment
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
Enter fullscreen mode Exit fullscreen mode
  • Create Azure Container App with autoscaling
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"
Enter fullscreen mode Exit fullscreen mode
  • ตรวจสอบ system log
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type console \
--follow
Enter fullscreen mode Exit fullscreen mode
  • ตรวจสอบ console log
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type system \
--follow
Enter fullscreen mode Exit fullscreen mode
  • ตรวจสอบว่า runner เกิดขึ้นใน organization ของเรา

github-self-hosted-runner

  • ลอง send และ delete message แล้วตรวจสอบ Queue ใน Azure Service Bus และ check จำนวน runner ที่เกิดขึ้นใน organization
# 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"
Enter fullscreen mode Exit fullscreen mode
# Receive / Delete message
az rest \
--resource "https://servicebus.azure.net" \
--method delete \
--url "https://${SERVICEBUS_NAME}.servicebus.windows.net/${SERVICEBUS_QUEUE}/messages/head"
Enter fullscreen mode Exit fullscreen mode
# See console log
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type console \
--follow
Enter fullscreen mode Exit fullscreen mode
# See system log
az containerapp logs show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPP_NAME \
--type system \
--follow
Enter fullscreen mode Exit fullscreen mode

Create GitHub workflow to verify our system

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

Credential Federation

รายละเอียดดูได้จาก Let Github Action Access Azure Resources without password

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

queue-role-assignment

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

subscription-role-assignment

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

Runner-Groups-Default

  • ต่อไปเข้าไปที่ 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

action-secret

action-variable

  • จากนั้นทำการสร้าง file workflow .github/workflows/demo.yaml ใน GitHub Repository
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"
Enter fullscreen mode Exit fullscreen mode

github-workflow

ใน workflow จะมี 3 jobs

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

run-workflow

  • ตรวจสอบดูว่าการ autoscale ทำงานได้ตามที่คาดการณไว้หรือไม่

    • Azure Service Bus Queue Azure-Service-Bus-Queue
    • Container App Replicas Container-App-Replicas
    • Github Organization-Level Self-hosted Runners Github-Organization-Level-Self-hosted-Runners
  • หลังจาก workflow run เรียบร้อยหมดแล้ว ลองตรวจสอบ อีกครั้ง ทุกอย่างต้องเป็น 0

    • Azure Service Bus Queue Azure-Service-Bus-Queue
    • Container App Replicas Container-App-Replicas
    • Github Organization-Level Self-hosted Runners Github-Organization-Level-Self-hosted-Runners

References

💖 💪 🙅 🚩
peepeepopapapeepeepo
Sawit M.

Posted on March 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related