Azure DevOps Agents on Kubernetes
Azure DevOps pipeline’larını çalıştıran agent’lar genellikle bir ya da birkaç sanal makineye kurulur. Bu yöntem başlangıçta işe yarasa da zamanla sorunlar çıkmaya başlar: agent sayısını artırmanız gerektiğinde yeni VM oluşturup elle yapılandırmanız gerekir, pipeline yoğunluğuna göre kapasite ayarlamak zordur, makine bakımları agent’ları etkiler. Kubernetes’in sağladığı scaling ve pod yönetimi bu sorunların büyük bölümünü ortadan kaldırır.
Bu yazıda, Azure DevOps agent’larını K8s üzerinde container olarak nasıl çalıştıracağımızı adım adım anlatacağım. HuaweiCloud CCE üzerinde kurduğumuzda karşılaştığımız sorunları ve çözümleri de paylaşacağım.
Gereklilikler
Kullandığımız Azure DevOps Server versiyonu 2020 olduğundan, bu versiyonla uyumlu agent versiyonumuz 2.181.2’dir. Agent versiyonları Azure DevOps Server versiyonuna göre değişir; kendi versiyonunuza uygun agent’ı {AZP_URL}/_apis/distributedtask/packages/agent endpoint’inden sorgulayabilirsiniz.
Mevcut Linux base image versiyonumuz Ubuntu focal 20.04’tür.
Container Hazırlayalım
Microsoft’un Docker üzerinde agent çalıştırmak için hazırladığı resmi rehberden yararlanacağız. Rehberde Ubuntu 22.04 için verilmiş örneği baz alarak ilerleyeceğiz.
Proje oluşturup içerisine şu iki dosyayı oluşturalım:
Dockerfile
Dockerfile agent’ın çalışacağı Ubuntu imajını hazırlıyor: gerekli paketleri kuruyor, Azure CLI’ı ekliyor ve agent’ı root olmayan bir kullanıcıyla çalıştıracak şekilde yapılandırıyor.
FROM ubuntu:22.04
ENV TARGETARCH="linux-x64"
# Also can be "linux-arm", "linux-arm64".
RUN apt update && \
apt upgrade -y && \
apt install -y curl git jq libicu70
# Install Azure CLI
RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash
WORKDIR /azp/
COPY ./start.sh ./
RUN chmod +x ./start.sh
# Create agent user and set up home directory
RUN useradd -m -d /home/agent agent
RUN chown -R agent:agent /azp /home/agent
USER agent
# Another option is to run the agent as root.
# ENV AGENT_ALLOW_RUNASROOT="true"
ENTRYPOINT [ "./start.sh" ]
start.sh
start.sh container başladığında Azure DevOps’a bağlanıyor, agent binary’sini indiriyor, yapılandırıyor ve çalıştırıyor. Container kapandığında ise agent’ı pool’dan temizliyor.
#!/bin/bash
set -e
if [ -z "${AZP_URL}" ]; then
echo 1>&2 "error: missing AZP_URL environment variable"
exit 1
fi
if [ -n "$AZP_CLIENTID" ]; then
echo "Using service principal credentials to get token"
az login --allow-no-subscriptions --service-principal --username "$AZP_CLIENTID" --password "$AZP_CLIENTSECRET" --tenant "$AZP_TENANTID"
# adapted from https://learn.microsoft.com/en-us/azure/databricks/dev-tools/user-aad-token
AZP_TOKEN=$(az account get-access-token --query accessToken --output tsv)
echo "Token retrieved"
fi
if [ -z "${AZP_TOKEN_FILE}" ]; then
if [ -z "${AZP_TOKEN}" ]; then
echo 1>&2 "error: missing AZP_TOKEN environment variable"
exit 1
fi
AZP_TOKEN_FILE="/azp/.token"
echo -n "${AZP_TOKEN}" > "${AZP_TOKEN_FILE}"
fi
unset AZP_CLIENTSECRET
unset AZP_TOKEN
if [ -n "${AZP_WORK}" ]; then
mkdir -p "${AZP_WORK}"
fi
cleanup() {
trap "" EXIT
if [ -e ./config.sh ]; then
print_header "Cleanup. Removing Azure Pipelines agent..."
# If the agent has some running jobs, the configuration removal process will fail.
# So, give it some time to finish the job.
while true; do
./config.sh remove --unattended --auth "PAT" --token $(cat "${AZP_TOKEN_FILE}") && break
echo "Retrying in 30 seconds..."
sleep 30
done
fi
}
print_header() {
lightcyan="\033[1;36m"
nocolor="\033[0m"
echo -e "\n${lightcyan}$1${nocolor}\n"
}
# Let the agent ignore the token env variables
export VSO_AGENT_IGNORE="AZP_TOKEN,AZP_TOKEN_FILE"
print_header "1. Determining matching Azure Pipelines agent..."
AZP_AGENT_PACKAGES=$(curl -LsS \
-u user:$(cat "${AZP_TOKEN_FILE}") \
-H "Accept:application/json" \
"${AZP_URL}/_apis/distributedtask/packages/agent?platform=${TARGETARCH}&top=1")
AZP_AGENT_PACKAGE_LATEST_URL=$(echo "${AZP_AGENT_PACKAGES}" | jq -r ".value[0].downloadUrl")
if [ -z "${AZP_AGENT_PACKAGE_LATEST_URL}" -o "${AZP_AGENT_PACKAGE_LATEST_URL}" == "null" ]; then
echo 1>&2 "error: could not determine a matching Azure Pipelines agent"
echo 1>&2 "check that account "${AZP_URL}" is correct and the token is valid for that account"
exit 1
fi
print_header "2. Downloading and extracting Azure Pipelines agent..."
curl -LsS "${AZP_AGENT_PACKAGE_LATEST_URL}" | tar -xz & wait $!
source ./env.sh
trap "cleanup; exit 0" EXIT
trap "cleanup; exit 130" INT
trap "cleanup; exit 143" TERM
print_header "3. Configuring Azure Pipelines agent..."
# Despite it saying "PAT", it can be the token through the service principal
./config.sh --unattended \
--agent "${AZP_AGENT_NAME:-$(hostname)}" \
--url "${AZP_URL}" \
--auth "PAT" \
--token $(cat "${AZP_TOKEN_FILE}") \
--pool "${AZP_POOL:-Default}" \
--work "${AZP_WORK:-_work}" \
--replace \
--acceptTeeEula & wait $!
print_header "4. Running Azure Pipelines agent..."
chmod +x ./run.sh
# To be aware of TERM and INT signals call ./run.sh
# Running it with the --once flag at the end will shut down the agent after the build is executed
./run.sh "$@" & wait $!
Kubernetes’e Deploy
Container imajını build edip registry’ye push ettikten sonra Kubernetes’e deploy edebiliriz. Temel bir deploy.yaml şöyle görünür:
apiVersion: apps/v1
kind: Deployment
metadata:
name: azdevops-agent
labels:
app: azdevops-agent
spec:
replicas: 1
selector:
matchLabels:
app: azdevops-agent
template:
metadata:
labels:
app: azdevops-agent
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cce.cloud.com/cce-nodepool
operator: In
values:
- company-azdevops-nodepool
securityContext:
supplementalGroups:
- 10001
containers:
- name: agent
image: yildizozan/ado-agent:16
env:
- name: AZP_URL
value: "https://azdevops.company.com/DefaultCollection"
- name: AZP_TOKEN
value: "top_secret_token"
- name: AZP_POOL
value: k8s
securityContext:
privileged: true
volumeMounts:
- name: data
mountPath: /azp
- name: dockersock
mountPath: "/var/run/docker.sock"
- name: sleep
image: busybox
command: ['sh', '-c', 'sleep 1000000']
volumeMounts:
- name: data
mountPath: /azp
volumes:
- name: data
emptyDir: {}
- name: dockersock
hostPath:
path: /var/run/docker.sock
Bu manifest ile agent pod olarak ayağa kalkıp Azure DevOps pool’una bağlanır. Bir pipeline tetiklendiğinde agent işi alır ve tamamlar.
NuGet Config
Pipeline’ları çalıştırmaya başladığımızda şöyle bir hatayla karşılaştık:
(0,0): Error NU1101: Unable to find package Company.Core. No packages exist with this id in source(s): nuget.org
sproj : error NU1101: Unable to find package Company.Core. No packages exist with this id in source(s): nuget.org
Sorun şu: agent container’ı yeni ve temiz; şirket içi private NuGet artifact repository’mizi tanımıyor. Sadece nuget.org’u biliyor. Şirket ağındaki NuGet package’larını çekebilmek için NuGet konfigürasyonunu container’a taşımamız gerekiyor.
Çözüm: mevcut bir agent makinesinden nuget.config dosyasını alıp Kubernetes secret olarak saklamak:
kubectl create secret generic nuget-config --from-file nuget.config
Ardından bu secret’ı deployment’a volume olarak bağlıyoruz:
volumeMounts:
- name: nuget-config
mountPath: /home/agent/.nuget/NuGet/NuGet.Config
subPath: nuget.config
volumes:
- name: nuget-config
secret:
secretName: nuget-config
Volume İzin Sorunu ve fsGroup
NuGet config’i ekleyip deploy ettikten sonra pipeline yeniden çalıştı ama bu sefer farklı bir hatayla karşılaştık: agent, mount edilen config dosyasını okuyamıyordu.
Sorunun kaynağı şu: Kubernetes, volume mount işlemlerini varsayılan olarak root kullanıcısıyla yapar. Bizim agent’ımız ise agent kullanıcısıyla çalışıyor (UID 1000). Dosya root’a ait olduğundan agent okuyamıyor.
Çözüm olarak securityContext’e fsGroup eklememiz gerekiyor. fsGroup, mount edilen volume’ların belirtilen group ID’siyle sahiplendirilmesini sağlıyor. Değer olarak agent kullanıcısının UID’si olan 1000’i veriyoruz:
securityContext:
fsGroup: 1000 # agent kullanıcısının UID'si
supplementalGroups:
- 10001
deploy.yaml Son Hali
Tüm bu değişiklikleri içeren final manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: azdevops-agent
labels:
app: azdevops-agent
spec:
replicas: 1
selector:
matchLabels:
app: azdevops-agent
template:
metadata:
labels:
app: azdevops-agent
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cce.cloud.com/cce-nodepool
operator: In
values:
- company-azdevops-nodepool
securityContext:
fsGroup: 1000 # agent kullanıcısının UID'si
supplementalGroups:
- 10001
containers:
- name: agent
image: yildizozan/ado-agent:16
env:
- name: AZP_URL
value: "https://azdevops.company.com/DefaultCollection"
- name: AZP_TOKEN
value: "top_secret_token"
- name: AZP_POOL
value: k8s
securityContext:
privileged: true
volumeMounts:
- name: data
mountPath: /azp
- name: dockersock
mountPath: "/var/run/docker.sock"
- name: nuget-config
mountPath: /home/agent/.nuget/NuGet/NuGet.Config
subPath: nuget.config # subPath yazmazsak dizin olarak mount eder
- name: sleep
image: busybox
command: ['sh', '-c', 'sleep 1000000']
volumeMounts:
- name: data
mountPath: /azp
volumes:
- name: data
emptyDir: {}
- name: dockersock
hostPath:
path: /var/run/docker.sock
- name: nuget-config
secret:
secretName: nuget-config
Sonuç
Azure DevOps agent’larını Kubernetes üzerinde çalıştırmak başlangıçta birkaç ek adım gerektiriyor ancak uzun vadede VM yönetiminin getirdiği yükten kurtarıyor. Replicas sayısını artırarak anında yeni agent ekleyebilir, iş yüküne göre scale edebilirsiniz.
Bu kurulumda karşılaştığımız iki temel sorun şunlardı:
- Private NuGet repository: Agent container’ı şirket içi package kaynağını tanımıyor.
nuget.config’i Kubernetes secret olarak saklayıp volume mount ile çözüldü. - Volume izin sorunu: Root ile mount edilen dosyayı
agentkullanıcısı okuyamıyor.securityContext.fsGroupile çözüldü.
Eğer siz de on-premise Azure DevOps Server kullanıyorsanız agent versiyonuna dikkat edin; server versiyonuyla uyumsuz agent binary container’a indirilemez ve kurulum sessizce başarısız olabilir.