ARM64 v Azure a jak používat s Kubernetes, Terraform a GitHub Actions a multi-arch image

ARM64 procesory v Azure jsou už v General Availability a zajímalo mě, jaké to má provozní konsekvence při používání v Azure Kubernetes Service. Podívejme se tedy jak vytvářet a provozovat multi-arch image s technologiemi GitHub Actions, QEMU a buildx, Terraform a AKS.

Jako obvykle technické podklady (Terraform šablony, Kubernetes manifesty, GitHub workflow) najdete na mém GitHubu.

Procesory Ampere Altra v Azure

Architekturu ARM64 najdete u VM, které mají ve svém typu písmenko p a to buď ve variantách s lokálním diskem nebo bez (písmenko malé d) a pak v poměru CPU:RAM 1:2 (písmenko D a malé l), poměru 1:4 (D bez l) a 1:8 (E). Z pohledu výkonu je Ampere Altra opravdu velmi zajímavý, ale díky velmi odlišné architektuře se dost špatně srovnává s Intel a AMD. Pokud koukáte na výkony Intel vs. AMD, tyto procesory jsou si dost podobné. V poslední době jsou výsledky obvykle takové, že AMD je o fousek rychlejší a přitom VM v Azure levnější pro většinu běžné zátěže, ale Intel je lepší tam, kde je úloha velmi náročná na CPU a je optimalizovaná na speciální instrukce (různé výpočty - statistické modely, Monte Carlo simulace, fluidní dynamika). S ARM64 to ale tak jednoduché není. Tak například je z testů dobře vidět, že v operacích s čísly je na tom Apmere Altra zatraceně dobře - často lépe, než AMD a blízko Intelu. Jakmile ale zapojíte plovoucí desetinnou čárku je situaci úplně jiná - Ampere Altra ztrácí klidně 50% na Intelu i AMD. V porovnání s Amazon Graviton 2 se mu ale ve všech směrech velmi daří a zejména v běžných operací s čísly dá i o 70% víc než ARM od Amazonu. Nová generace Graviton 3 zdá se náskok Ampere (částečně) dorovná (zejména odstraní nesmírně slabý výkon Graviton 2 v plovoucí desetinné čárce). Závěr? Tohle si budete muset vyzkoušet pro svůj workload a změřit si sami.

Cenově je dle očekávání tato varianta nejníž (mimo jiné díky nižším nákladům na energie). Porovnejme základní srovnatelné modely s v5 generací Intel a AMD:

  • 2 core a 4 GB RAM bez lokálního disku v North Europe
    • D2pls_v5 (ARM) - 53,4 EUR v PAYG a 20,3 EUR v 3-leté rezervaci
    • F2s_v2 (Intel) - 70,2 EUR v PAYG a 25,3 EUR v 3-leté rezervaci
    • Pro zajímavost AWS C7g.large (Graviton 3) je cirka 60 EUR a 29,5 v 3-leté rezervaci (Irsko)
  • 2 core a 8 GB RAM bez lokálního disku v North Europe
    • D2ps_v5 (ARM) - 62,9 EUR v PAYG a 23,9 EUR v 3-leté rezervaci
    • D2as_v5 (AMD) - 70,2 EUR v PAYG a 26,7 EUR v 3-leté rezervaci
    • D2s_v5 (Intel) - 78,3 EUR v PAYG a 29,8 EUR v 3-leté rezervaci
    • Pro zajímavost o dost pomalejší starší Graviton 2 v M6g.large vyjde na asi na 66 EUR nebo 30 EUR v 3-leté rezervaci (Irsko)
  • 2 core a 16 GB RAM bez lokálního disku v North Europe
    • E2ps_v5 (ARM) - 82,6 EUR v PAYG a 31,3 EUR v 3-leté rezervaci
    • E2as_v5 (AMD) - 92,9 EUR v PAYG a 35,3 EUR v 3-leté rezervaci
    • E2s_v5 (Intel) - 103,1 EUR v PAYG a 39,2 EUR v 3-leté rezervaci
    • Pro zajímavost o dost pomalejší starší Graviton 2 v R6g.large vyjde na asi na 86 EUR nebo 39 EUR v 3-leté rezervaci (Irsko)

Proč se pokouknout po ARM64?

  • Pro mojí aplikaci možná bude přinášet lepší poměr cena/výkon.
  • Potřebuji testovat a buildovat aplikace na této architektuře (mobily, některé laptopy, edge scénáře typu Raspberry) a potřebuji v cloudu nativní prostředky (simulace přes QEMU jsou notoricky pomalé).

Nicméně s používáním ARM64 mám jeden problém - nechi mít ve svojí aplikaci závislost na této architektuře. Proč? Je pravděpodobné, že budu potřebovat stejnou aplikaci spustit i na AMD64/x86 architektuře:

  • Možná musím jít do regionu, kde ARM64 není.
  • Dojde k nějakým potížím s kapacitou a bude dávat smysl vykrýt špičku z jiného SKU s AMD64.
  • Potřebuji jednoduše spustit aplikaci a na notebooku vývojáře, GitHub Codespaces nebo Azure Dev Box a u těch nemusím mít ARM64 k dispozici.
  • Občas musím běžet někde na kraji - ve výdejním boxu, na pobočce, v továrně a nemám garantováno, že tam bude zrovna ARM64.

S jinou architekturou mohou být spojeny potenciální operační potíže - pokud na ARM64 nemám zkušenosti, budu je muset získat a některé věci mohou být jinak (např. mohou chybět nějaké knihovny či nástroje nebo nemusí být jednoduše dostupné - např. dodavatel má ke stažení hotové balíčky, ale pro ARM64 si musíte kompilovat ze zdrojáků).

Azure Kubernetes Service s kombinací AMD64 a ARM64 nodů

V době psaní článku byla sice ARM64 VM už v GA, ale v AKS ještě v preview, takže bylo nutné registrovat feature dle návodu. Pak už je to triviální - prostě přidáme další node pool. Tady snip z Terraformu.

// Random string
resource "random_string" "random" {
  length  = 12
  special = false
  upper   = false
}

// Resource group
resource "azurerm_resource_group" "main" {
  name     = "d-aks-arm64"
  location = "westeurope"
}

// Azure Container Registry
resource "azurerm_container_registry" "main" {
  name                = random_string.random.result
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  sku                 = "Basic"
}

// Azure Kubernetes Service
resource "azurerm_kubernetes_cluster" "main" {
  name                = random_string.random.result
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  dns_prefix          = random_string.random.result
  kubernetes_version  = "1.24"
  node_resource_group = "d-aks-arm64-nodes"

  default_node_pool {
    name       = "amd64"
    node_count = 1
    vm_size    = "Standard_B2ms"
  }

  identity {
    type = "SystemAssigned"
  }
}

// ARM64 node pool
resource "azurerm_kubernetes_cluster_node_pool" "main" {
  name                  = "arm64"
  kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id
  vm_size               = "Standard_D2pds_v5"
  node_count            = 1
}

// RBAC for AKS to access ACR
resource "azurerm_role_assignment" "main" {
  scope                = azurerm_container_registry.main.id
  role_definition_name = "AcrPull"
  principal_id         = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id
}

output "acr_name" {
  value = azurerm_container_registry.main.name
}

output "aks_name" {
  value = azurerm_kubernetes_cluster.main.name
}

output "rg_name" {
  value = azurerm_resource_group.main.name
}

Příprava imagů v GitHub Actions

Celou ukázku nahazuji v GitHub Actions a tam také uvidíme jak na vybudování multi-arch image, tedy takového, který v sobě skrývá varianty pro různé architektury procesorů. Začněme tedy rozplétat GitHub Actions workflow kousek po kousku. Zdroj najdete na GitHubu.

Používám variables pro konfiguraci remote state pro Terraform a také pro přístupy do Azure. Nicméně všimněte si, že nikde není žádný secret - používám totiž GitHub workload identity federation do AAD, což je skvělá věc, o které už jsem psal. Dále vidíme definici jobu, permissions na token (to kvůli OIDC přihlášení) a definici environmentu (při federaci svého Service Principal do GitHubu jsem to kromě repozitáře použil jako identitikátor).

name: d-aks-arm64-create

on:
  workflow_dispatch:

env:
  TF_STATE_BLOB_ACCOUNT_NAME: tkubicastore
  TF_STATE_RESOURCE_GROUP_NAME: base
  TF_STATE_BLOB_CONTAINER_NAME: tfstate
  TF_STATE_BLOB_FILE: d-aks-arm64.tfstate
  TF_STATE_BLOB: d-aks-arm64.tfstate
  ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
  ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    environment: demo

Pojďme na jednotlivé kroky. Nejdřív stáhnem obsah Gitu a nainstalujeme QEMU. V toto chvíli totiž GitHub hosted runnery nepodporují ARM64 (protože zatím v Azure nebyly), takže než se tak stane musím buď použít self-hosted runner (na ARM64 VM v Azure například) nebo nasadit jen simulaci. To je notoricky pomalé, ale pro moje jednoduché účely naprosto dostačující.

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Set up QEMU
        uses: docker/setup-qemu-action@master
        with:
          platforms: all

Dále si připravíme buildx prostředí, protože akce běží v kontejneru a v něm už dnes není staré docker API.

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@master

Připojím se do Azure přes OIDC (proto žádný client secret netřeba).

      - name: Azure Login
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

V další akci připravím Terraform. Pozor na nastavení terraform_wrapper na false - jinak totiž nefunguje příkaz terraform output, který později používám.

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_wrapper: false

Provedeme inicializaci a napojení na backend storage. To jsem chtěl dělat přes proměnné, ale nebylo by to potřeba (pak to dám to providers.tf) a tak je předávám do CLI příkazu. Všimněte si zejména, že v providers.tf jsem zapnul podporu pro UIDC, takže jak Terraform samotný tak napojení na storage backend znovu využije federované řešení - což je skvělé, nikde žádná hesla.

      - name: Terraform Init
        working-directory: ./d-aks-arm64/terraform
        run: |
          terraform init \
            -backend-config=storage_account_name=$TF_STATE_BLOB_ACCOUNT_NAME \
            -backend-config=resource_group_name=$TF_STATE_RESOURCE_GROUP_NAME \
            -backend-config=container_name=$TF_STATE_BLOB_CONTAINER_NAME \
            -backend-config=key=$TF_STATE_BLOB \
            -backend-config=client_id=$ARM_CLIENT_ID \
            -backend-config=subscription_id=$ARM_SUBSCRIPTION_ID \
            -backend-config=tenant_id=$ARM_TENANT_ID

A tady jsou providers v Terraformu.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~>3"
    }
    random = {
      source  = "hashicorp/random"
      version = "~>3"
    }
  }
  backend "azurerm" {
    use_oidc = true
  }
}

provider "azurerm" {
  use_oidc = true
  features {
    resource_group {
      prevent_deletion_if_contains_resources = false
    }
  }
}

V dalším kroku nechám Terraform nahodit infrastrukturu a následně si vezmu jeho outputs a vytáhnu si je do GitHubu (přes výpis na obrazovku v předepsaném formátu), ať s nimi mohu operovat jako output ze stepů. Možná to nějaká připravená action dělá sama, ale ta přímo od Hashicorpu ne, tak jsem to udělal takhle. Výstupy jsou jen názvy, žádné tajnosti, takže s tím netřeba dělat nějaké štráchy.

      - name: Terraform apply
        working-directory: ./d-aks-arm64/terraform
        run: terraform apply -auto-approve

      - name: Get Terraform outputs
        working-directory: ./d-aks-arm64/terraform
        id: tf-outputs
        run: |
          echo "::set-output name=acr_name::$(terraform output -raw acr_name)"
          echo "::set-output name=aks_name::$(terraform output -raw aks_name)"
          echo "::set-output name=rg_name::$(terraform output -raw rg_name)"

Zaloguji se do Azure Container Registry - zase bez nějakých ošklivých hesel.

      - name: ACR login
        working-directory: ./d-aks-arm64/terraform
        run: az acr login -n ${{ steps.tf-outputs.outputs.acr_name }}

Následně použiji oficiální Action od Dockeru, která provede build mé aplikace (zdrojáky najdete v adresáři app - je to jednoduchý Hello world web v Pythonu s Gunicorn web serverem). Všimněte si, že v platforms jich můžu specifikovat víc. Proto image s názvem app-multi bude mít buildy jak pro amd64 tak arm64 zatímco image app-amd64 jen pro amd64. Tato akce i pushne kontejnery do ACR. Jako tag u image použiji SHA příslušného commitu (pro jednoduchost - nepotřebuji v takové ukázce řešit semantické verzování, ale zase nechci zaprasit s “latest”).

      - name: Build and push multi-arch image
        uses: docker/build-push-action@v2
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: ./d-aks-arm64/app
          file: ./d-aks-arm64/app/Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.tf-outputs.outputs.acr_name }}.azurecr.io/app-multi:${{ github.sha }}

      - name: Build and push amd64 only image
        uses: docker/build-push-action@v2
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: ./d-aks-arm64/app
          file: ./d-aks-arm64/app/Dockerfile
          platforms: linux/amd64
          push: true
          tags: ${{ steps.tf-outputs.outputs.acr_name }}.azurecr.io/app-amd64:${{ github.sha }}

Následně nahodím kubectl a kustomize CLI a v existujícím kustomization.yaml změním image tak, aby mířil na správné registry a tag (SHA commitu). Pro jednoduchost jedu push metodou a soubor kustomization.yaml po úpravách nezapisuji do Gitu. To bych v praxi určitě přidal (Action udělá PR a zanese změnu do Gitu) tak, aby místo push si to mohl cluster slíznout sám přes Flux nebo ArgoCD.

      - name: Install kubectl
        uses: Azure/setup-kubectl@v3

      - name: Install Kustomize
        uses: imranismail/setup-kustomize@v1
      
      - name: Edit and commit kustomization.yaml
        working-directory: ./d-aks-arm64/kubernetes
        run: |
          kustomize edit set image \
            registry/app-multi=${{ steps.tf-outputs.outputs.acr_name }}.azurecr.io/app-multi:${{ github.sha }} \
            registry/app-amd64=${{ steps.tf-outputs.outputs.acr_name }}.azurecr.io/app-amd64:${{ github.sha }}

Pak už stačí jen natáhnout credentials do clusteru (zase nic optimálního - v praxi bych tedy jednak šel pull-based modelem a když už bych dělal push, tak přes mého Service Principal s workload identity federation) a poslat to tam.

      - name: Get AKS credentials
        run: az aks get-credentials -n ${{ steps.tf-outputs.outputs.aks_name }} -g ${{ steps.tf-outputs.outputs.rg_name }}

      - name: Deploy Kubernetes objects
        working-directory: ./d-aks-arm64/kubernetes
        run: kubectl apply -k .

Kubernetes a nody s různou architekturou

Co jsem chtěl ukázat je to, že image uložené jako multi-arch znamenají, že v registru je pod stejným názvem vícero verzí a cluster si dokáže sám vybrat tu správnou. Pokud tam ale je - když není, dopadne to chybou.

kubectl get pods -o wide
NAME                                      READY   STATUS             RESTARTS        AGE   IP            NODE                            NOMINATED NODE   READINESS GATES
app-amd64-85b69db494-8htjb                0/1     CrashLoopBackOff   7 (4m41s ago)   15m   10.244.1.6    aks-arm64-30808820-vmss000000   <none>           <none>
app-amd64-85b69db494-8jbll                1/1     Running            0               15m   10.244.0.21   aks-amd64-39196772-vmss000000   <none>           <none>
app-amd64-85b69db494-n7khv                0/1     CrashLoopBackOff   7 (4m51s ago)   15m   10.244.1.10   aks-arm64-30808820-vmss000000   <none>           <none>
app-amd64-85b69db494-xbrh7                1/1     Running            0               15m   10.244.0.15   aks-amd64-39196772-vmss000000   <none>           <none>
app-amd64-nodeaffinity-6664b98cf6-cz9mz   1/1     Running            0               15m   10.244.0.16   aks-amd64-39196772-vmss000000   <none>           <none>
app-amd64-nodeaffinity-6664b98cf6-dflnm   1/1     Running            0               15m   10.244.0.19   aks-amd64-39196772-vmss000000   <none>           <none>
app-amd64-nodeaffinity-6664b98cf6-nrq8t   1/1     Running            0               15m   10.244.0.17   aks-amd64-39196772-vmss000000   <none>           <none>
app-amd64-nodeaffinity-6664b98cf6-tgkns   1/1     Running            0               15m   10.244.0.18   aks-amd64-39196772-vmss000000   <none>           <none>
app-multi-579746c945-2zkbf                1/1     Running            0               15m   10.244.1.7    aks-arm64-30808820-vmss000000   <none>           <none>
app-multi-579746c945-8v8lz                1/1     Running            0               15m   10.244.1.8    aks-arm64-30808820-vmss000000   <none>           <none>
app-multi-579746c945-f4z45                1/1     Running            0               15m   10.244.0.20   aks-amd64-39196772-vmss000000   <none>           <none>
app-multi-579746c945-twd9b                1/1     Running            0               15m   10.244.1.9    aks-arm64-30808820-vmss000000   <none>           <none>

Vidíte jak deployment app-multi nemá problém a běží na AMD64 i ARM64 nodech? Ale u app-amd64 máme chybu - nesedí architektura. V clusterech s různými architekturami a image, které oboje neumí, bych si na tohle měl dát pozor. Jedna ze strategií může být udělat Taint - ARM64 nodepool takhle ocejchovat, takže se s ním běžně nepočítá. Pouze aplikace připravené pro tuto architekturu na něj budou mít nastavenou toleranci. To je určitě dobré řešení pokud je ARM64 pro vás spíš takový malý experiment, drobná podmnožina clusteru. Já zvolil jinou metodu a to je expecitně přidat Node affinitu tam, kde z nějakého důvodu image není multi-arch a potřebuju ho správně nasměrovat. K tomu lze použít nativní label nodu, který se tam přidá automaticky a bude u každého clusteru fungovat stejně.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-amd64-nodeaffinity
spec:
  replicas: 4
  selector:
    matchLabels:
      app: app-amd64-nodeaffinity
  template:
    metadata:
      labels:
        app: app-amd64-nodeaffinity
    spec:
      containers:
      - name: app-amd64-nodeaffinity
        image: registry/app-amd64:latest
        resources:
          requests:
            memory: "32Mi"
            cpu: "50m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        ports:
        - containerPort: 8080
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key:  kubernetes.io/arch
                operator: In
                values:
                - amd64    

A to je vlastně všechno. Moc je zajímá, jaké budou vaše zkušenosti s ARM64 pro servery v cloudu. Na mé chytré domácnosti frčí Docker na Raspberry už dávno a mám i Surface Pro X s Woknama pro ARM64 a také je to fajn. A teď jsou na řadě i servery? Nepřeceňoval bych to, ale minimálně pro vývoj ARM64 aplikací je to pecka a umím si představit, že ve velkém se bude dát na takové architektuře i pěkně ušetřit. Ale za mě ne za cenu snížení flexibility nasadit cokoli kdekoli - proto mrkněte na multi-arch image.



Je už na čase naskočit do ARM64 v cloudu? Compute
Nový Cold tier Azure Blob storage - kolik stojí premium, hot, cool, cold, archive Kubernetes
AKS má v preview spravované Istio - jak to souvisí s Open Service Mesh, proč to nebylo dřív, proč se ani tak netřeba plašit, ale proč je ambient mesh super? Kubernetes
Multi-tenant AKS - proč ano, proč ne a co udělat pro to, aby společné WC na chodbě tolik nevadilo Kubernetes
Azure, Kubernetes, FinOps a strategie účtování nákladů Kubernetes