Cómo contenerizar agentes de Azure Pipelines para Windows

En el artículo anterior compartí contigo cómo es posible desplegar agentes de Azure Pipelines en un clúster de AKS y escalar el mismo con KEDA. Sin embargo, en muchos clientes no sólo necesitan que estos agentes se ejecuten en Linux sino que tienen diferentes aplicaciones legacy, con .NET Framework, que necesitan también la posibilidad de ejecutar sus pipelines en Windows. Es por ello que en el artículo de hoy te comparto cómo puedes generar la imagen para el agente en Windows.

Siguiendo la misma idea que en este vídeo de mi canal de YouTube:

Vídeo sobre cómo hospedar agentes de Azure Pipelines en AKS (y escalarlos con KEDA)

Crear un clúster con un node pool Windows

Para poder ejecutar estos agentes dentro de tu clúster de Kubernetes necesitas que el mismo disponga de un nodepool con Windows:

# Variables
RESOURCE_GROUP="ado-agents-on-aks"
AKS_NAME="ado-agents-cluster"
LOCATION="uksouth"
ACR_NAME="adoimages"
LINUX_AGENT_POOL_NAME="agents-on-aks"
WINDOWS_AGENT_POOL_NAME="win-agents-on-aks"
ORGANIZATION_NAME="returngisorg"
WIN_PASSWORD="P@ssw0rd1234#@"
LINUX_IMAGE_NAME="linux-ado-agent"
WINDOWS_IMAGE_NAME="windows-ado-agent"
# Check if .env file exists
if [ -f .env ]; then
  echo -e ".env file exists"
else
  echo -e ".env file does not exist"
  exit 1
fi
# Load the .env file
source .env
echo -e "Variables set"
# Create a resource group
az group create \
--name $RESOURCE_GROUP \
--location $LOCATION
# Create Azure Container Registry
az acr create \
--resource-group $RESOURCE_GROUP \
--name $ACR_NAME \
--sku Basic
# Create AKS cluster with KEDA enabled
az aks create \
--resource-group $RESOURCE_GROUP \
--name $AKS_NAME \
--enable-keda \
--generate-ssh-keys \
--attach-acr $ACR_NAME \
--node-vm-size Standard_B2ms \
--node-count 1 \
--enable-cluster-autoscaler \
--min-count 1 --max-count 3 \
--enable-vpa \
--windows-admin-username azureuser \
--windows-admin-password $WIN_PASSWORD \
--network-plugin azure
# Add node pool for Windows containers
az aks nodepool add \
--resource-group $RESOURCE_GROUP \
--cluster-name $AKS_NAME \
--name win \
--os-type Windows \
--node-vm-size Standard_B2ms \
--node-count 1
# Get the credentials for the AKS cluster
az aks get-credentials \
--resource-group $RESOURCE_GROUP \
--name $AKS_NAME \
--overwrite-existing

A día de hoy puedes tener tanto node pools para Windows como para Linux en el mismo clúster 🥳 Una vez que ya tengas un sitio donde hospedarlos, vamos a ver cómo los creamos.

Generar la imagen para Windows con el agente de Azure Pipelines

Para poder generar la imagen para tus contenedores Windows, necesitas crear un Dockerfile con lo siguiente:

FROM mcr.microsoft.com/dotnet/framework/sdk:4.8.1
SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"]
WORKDIR /azp/
RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \
  [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; \
  iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'));
RUN choco install dotnetcore-runtime --version 2.2.7 -y;
RUN choco install aspnetcore-runtimepackagestore --version 2.2.7 -y;
COPY ./start.ps1 ./
CMD powershell .\start.ps1

En este ejemplo estoy basando la misma en la imagen que contiene el SDK de .NET Framework en su versión 4.8.1, ya que es retrocompatible, por si tenemos algún desarrollo en esta versión o anteriores. Por otro lado, instalo el gestor de paquetes Chocolatey para que puedas instalar cualquier otra herramienta que necesites de forma sencilla. En este ejemplo he instalado además una versión antigua de .NET Core, la 2.2.7.

A este archivo, como puedes ver al final del mismo, le acompaña otro llamado start.ps1 el cuál tiene todo lo necesario para poder descargar y configurar el agente de Azure Pipelines:

function Print-Header ($header) {
  Write-Host "`n${header}`n" -ForegroundColor Cyan
}
if (-not (Test-Path Env:AZP_URL)) {
  Write-Error "error: missing AZP_URL environment variable"
  exit 1
}
if (-not (Test-Path Env:AZP_TOKEN_FILE)) {
  if (-not (Test-Path Env:AZP_TOKEN)) {
    Write-Error "error: missing AZP_TOKEN environment variable"
    exit 1
  }
$Env:AZP_TOKEN_FILE = "\azp\.token"
  $Env:AZP_TOKEN | Out-File -FilePath $Env:AZP_TOKEN_FILE
}
Remove-Item Env:AZP_TOKEN
if ((Test-Path Env:AZP_WORK) -and -not (Test-Path $Env:AZP_WORK)) {
  New-Item $Env:AZP_WORK -ItemType directory | Out-Null
}
New-Item "\azp\agent" -ItemType directory | Out-Null
# Let the agent ignore the token env variables
$Env:VSO_AGENT_IGNORE = "AZP_TOKEN,AZP_TOKEN_FILE"
Set-Location agent
Print-Header "1. Determining matching Azure Pipelines agent..."
$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$(Get-Content ${Env:AZP_TOKEN_FILE})"))
$package = Invoke-RestMethod -Headers @{Authorization=("Basic $base64AuthInfo")} "$(${Env:AZP_URL})/_apis/distributedtask/packages/agent?platform=win-x64&`$top=1"
$packageUrl = $package[0].Value.downloadUrl
Write-Host $packageUrl
Print-Header "2. Downloading and installing Azure Pipelines agent..."
$wc = New-Object System.Net.WebClient
$wc.DownloadFile($packageUrl, "$(Get-Location)\agent.zip")
Expand-Archive -Path "agent.zip" -DestinationPath "\azp\agent"
try {
  Print-Header "3. Configuring Azure Pipelines agent..."
.\config.cmd --unattended --agent "$(if (Test-Path Env:AZP_AGENT_NAME) { ${Env:AZP_AGENT_NAME} } else { hostname })" --url "$(${Env:AZP_URL})" --auth PAT --token "$(Get-Content ${Env:AZP_TOKEN_FILE})" --pool "$(if (Test-Path Env:AZP_POOL) { ${Env:AZP_POOL} } else { 'Default' })" --work "$(if (Test-Path Env:AZP_WORK) { ${Env:AZP_WORK} } else { '_work' })" --replace
Print-Header "4. Running Azure Pipelines agent..."
.\run.cmd
} finally {
  Print-Header "Cleanup. Removing Azure Pipelines agent..."
.\config.cmd remove --unattended --auth PAT --token "$(Get-Content ${Env:AZP_TOKEN_FILE})"
}

Este lo he recuperado de la documentación oficial. Sin embargo, en la misma el Dockerfile utiliza como imagen base mcr.microsoft.com/windows/servercore:ltsc2022 y yo quería tener ya lo mínimo necesario para compilar proyectos con .NET Framework 😎

Con todo ello, lo único que quedaría por hacer sería generar la imagen y subirla al container registry que he generado en Azure. Para ello puedes usar directamente el mismo, al igual que en el vídeo, para que no necesites siquiera de un equipo con Windows, o Docker en tu local, para poder construir la misma:

az acr build \
--resource-group $RESOURCE_GROUP \
--registry $ACR_NAME \
--image windows-ado-agent:{{.Run.ID}} windows-ado-agent/. \
--platform windows

Cuando finalice el proceso recupera el tag generado:

WINDOWS_IMAGE_ID=$(az acr repository show-tags \
--name $ACR_NAME \
--repository windows-ado-agent \
--orderby time_desc \
--top 1 --output tsv)

Y úsalo para montar el manifiesto que despliega el deployment de los agentes, así como el ScaledObject para KEDA y el VPA, para asegurarnos de que tus agentes están haciendo el mejor uso de los recursos del clúster:

kubectl create namespace windows-agents
kubectl create secret generic azdevops-pat --from-literal=personalAccessToken=$PAT -n windows-agents
cat <<EOF | kubectl apply -f -
# cat <<EOF > azdevops-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: azdevops-deployment
  labels:
    app: azdevops-agent
  namespace: windows-agents
spec:
  replicas: 1
  selector:
    matchLabels:
      app: azdevops-agent
  template:
    metadata:
      labels:
        app: azdevops-agent
    spec:
      nodeSelector:
        "kubernetes.io/os": windows
      containers:
      - name: azdevops-agent
        image: $ACR_NAME.azurecr.io/$WINDOWS_IMAGE_NAME:$WINDOWS_IMAGE_ID
        env:
          - name: AZP_URL
            value: "https://dev.azure.com/$ORGANIZATION_NAME"
          - name: AZP_POOL
            value: "$WINDOWS_AGENT_POOL_NAME"
          - name: AZP_TOKEN
            valueFrom:
              secretKeyRef:
                name: azdevops-pat
                key: personalAccessToken
        resources:
          requests:
            cpu: 100m
            memory: 1Gi
        volumeMounts:
        - mountPath: /var/run/docker.sock
          name: docker-volume
      volumes:
      - name: docker-volume
        hostPath:
          path: /var/run/docker.sock
EOF
# Let create KEDA configuration to scale the agents
# cat <<EOF > keda-config.yaml
cat <<EOF | kubectl apply -f -
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: pipeline-trigger-auth
  namespace: windows-agents
spec:
  secretTargetRef:
    - parameter: personalAccessToken
      name: azdevops-pat
      key: personalAccessToken
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: azure-pipelines-scaledobject
  namespace: windows-agents
spec:
  scaleTargetRef:
    name: azdevops-deployment
  minReplicaCount: 1
  maxReplicaCount: 5 
  triggers:
  - type: azure-pipelines
    metadata:
      poolName: "$WINDOWS_AGENT_POOL_NAME"
      organizationURLFromEnv: "AZP_URL"
    authenticationRef:
     name: pipeline-trigger-auth
EOF

# VPA for the windows agents
cat <<EOF | kubectl apply -f -
apiVersion: "autoscaling.k8s.io/v1"
kind: VerticalPodAutoscaler
metadata:
  name: windows-agents-vpa
  namespace: windows-agents
spec:
  updatePolicy:
    updateMode: "Off"
  targetRef:
    apiVersion: "apps/v1"
    kind: Deployment
    name: azdevops-deployment
  resourcePolicy:
    containerPolicies:
      - containerName: '*'
        # minAllowed:
        #   cpu: 100m
        #   memory: 50Mi
        maxAllowed:
          cpu: 1
          memory: 3Gi
        controlledResources: ["cpu", "memory"]
EOF

El ejemplo completo lo tienes en este repositorio en mi cuenta de GitHub.

¡Saludos!