{Cloud Native Community Blog}

Server Jobs in Azure Pipelines

Dienstag, 01. Dezember 2020

Ich bin ein großer Fan von völlig sauberer Build-Umgebungen. Wenn Sie dedizierte VMs für Ihre Builds erstellen und dort Azure-DevOps-Agents hosten, können Sie versuchen, diese unter Kontrolle zu halten, aber trotzdem kann etwas schief gehen und Sie könnten unbeabsichtigt in einer Situation mit unterschiedlichen Umgebungen landen. Eine schöne Lösung dafür sind containerisierte[^1] Build Agents, die nur bei Bedarf erstellt und gestartet werden, weil Sie 100% sicher sein können, dass die Umgebung unverändert bleibt. Microsoft stellt sogar eine recht ausführliche Dokumentation zur Verfügung, wie man ein Image für diesen Zweck erstellt. Ein Nachteil dieses Ansatzes besteht darin, dass Sie eine Möglichkeit benötigen, den Agent-Container zu starten, und die offensichtliche Antwort darauf ist ein Skript, das die von Ihnen eingesetzte Automatisierung aufruft. Aber auch dafür ist ein Agent erforderlich, also müssen Sie warten, bis einer der Standard-Azure-Agents das Skript ausführt, das Ihren Agent erstellt. Das bedeutet einen unnötigen Overhead, der nicht sehr groß ist, aber normalerweise etwa 30 Sekunden beträgt. Wenn Ihr tatsächlicher Build in Sekunden erledigt ist (oder fehlschlägt), ist das ziemlich ärgerlich.

Das TL;DR

Azure Pipelines bieten dafür eine nette Lösung: Server Jobs. Eine begrenzte Anzahl von Automatisierungsaufgaben ("Jobs") kann direkt auf dem Server laufen und benötigt keinen Agenten. Diese Jobs waren in der Dokumentation gut versteckt, sind mittlerweile aber hier zu finden und einer davon ist "Invoke REST API task", also der direkte Aufruf einer beliebigen REST API. Wenn Sie also eine solche REST API bereitstellen, um Ihren Agenten zu starten, geschieht das fast ohne Verzögerung. Das Starten des Containers dauert ein wenig, da er sich im richtigen Agent-Pool registrieren muss, und Sie benötigen eine Möglichkeit um benachrichtigt zu werden, wenn der Agent verfügbar ist, aber das ist unvermeidlich, wenn Sie Ihre Agent Container erst bei Bedarf erstellen möchten. Dennoch sind Sie schneller als mit den standardmäßigen Azure Agents, Sie haben die gleiche volle Kontrolle über die installierte Software wie bei selbst gehosteten VMs und der Agent ist immer komplett sauber. So sieht der Start eines Builds mit dem Server Job auf der linken Seite im Vergleich zur Verwendung eines Azure Agent auf der rechten Seite aus:

Sie können sehen, wie der Server Job fast sofort startet, während der Azure Agent ziemlich lange braucht, um zu starten. Der Azure Agent ist manchmal etwas schneller, manchmal etwas langsamer, aber er bringt immer einen erheblichen Overhead mit sich.

Die Einzelheiten: Der Dienstaufruf

Der Aufruf des REST-Dienstes ist eigentlich recht einfach. Die einzige etwas umständliche Sache ist, dass Sie ihm nicht einfach eine URL geben können, sondern stattdessen eine Dienstverbindung erstellen müssen, auf die Sie dann verweisen können. Ich habe mich für eine Generic service connection entschieden, die nur ein paar Klicks oder zwei Service-Aufrufe erfordert (einen, um die Verbindungen zu erstellen, und einen, um den Zugriff von den Pipelines aus zu ermöglichen), aber ich sehe immer noch nicht so recht den Sinn darin. Aber es funktioniert. Danach sieht der relevante Teil in meiner YAML-basierten Pipeline wie folgt aus:

- task: InvokeRESTAPI@1
  displayName: COSMO - Create Build Agent Container
  inputs:
    connectionType:    connectedServiceName
    serviceConnection: Swarm
    method:            POST
    headers:           '{"Content-Type":"application/json", "Authorization": "Basic $(system.AccessToken)", "Collection-URI": "$(system.CollectionUri)"}'
    urlSuffix:         "docker/$(swarmversion)/Service"
    body: '{
        "mountDockerEngine":  true,
        "serviceName":        "buildagent-$(UID)",
        "environmentVars":    [
                                  "AZP_URL=$(system.CollectionUri)",
                                  "AZP_TOKEN=$(system.AccessToken)",
                                  "AZP_AGENT_NAME=$(UID)",
                                  "UID=$(UID)",
                                  "AZP_POOL=$(poolName)",
                                  "cc.CorrelationID=$(system.CollectionUri)|$(system.TeamProjectId)|$(system.JobId)"
                              ],
        "projectId":          "$(System.TeamProjectId)",
        "orgId":              "$(System.CollectionId)",
        "additionalLabels":   {
                                  "UID":  "$(UID)"
                              },
        "additionalMounts":   [
                                {
                                  "source": "f:\\bcartifacts.cache",
                                  "target": "c:\\bcartifacts.cache",
                                  "type": "bind"
                                }
                              ],
        "memoryGb":           2,
        "image":              "$(containerizedBuildAgentImage)"
      }'

In Zeile 5 sehen Sie den Verweis auf die Service-Verbindung, die für die Basis-URL ("https://myserver.com") verwendet wird, und dann bestimmt das urlSuffix in Zeile 8 den Rest der URL. Außerdem sehen Sie die HTTP-Methode (Zeile 6), die Header (Zeile 7) und den Body (Zeile 9 bis zum Ende). Im Body sehen Sie, dass mein Dienst im Backend ein paar Argumente benötigt, um zu wissen, welches Containerimage zu verwenden ist, wie viel Speicher zu reservieren ist, welche Mounts hinzugefügt werden müssen und so weiter. Sie können auch sehen, dass der Task YAML-Variablen wie überall in einer YAML-Pipeline verwenden kann, wie z.B. das containerizedBuildAgentImage in der vorletzten Zeile

Die Einzelheiten: Das Image des Build-Agenten

Wie ich eingangs schrieb, kommt das Image des Build-Agenten mit zwei wesentlichen Änderungen ziemlich nahe an das heran, was Microsoft bereits vorschlägt: Ich verwende das Dotnet Framework 4.8-Laufzeit-Image und installiere zusätzliche Software, nämlich den Docker-Client, bccontainerhelper (da es mir in diesem Context um Builds in Dynamics 365 BC geht) und ein benutzerdefiniertes PowerShell-Modul, das von meinem Kollegen Michael Megel erstellt wurde. Im Dockerfile sieht das folgendermaßen aus:

RUN iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')); `
    choco install -y docker-cli; `
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; `
    Install-PackageProvider -Name 'Nuget' -Force; `
    Install-Module 'bccontainerhelper' -Force; `
    Install-Module AzureDevOpsAPIUtils -Force -ErrorAction SilentlyContinue

Wenn Sie es sich komplett anschauen wollen, finden Sie das Dockerfile hier. Der Grund für die meisten dieser Ergänzungen ist, dass wir in diesem Kontext hauptsächlich Microsoft Dynamics 365 Business Central-Lösungen erstellen und bccontainerhelper und das Dotnet-Framework dort helfen. Der Docker-Client ist notwendig, weil wir während der Builds auch Docker-Befehle ausführen. Jetzt werden Sie sich vielleicht fragen, wie wir Docker-Befehle in einem Docker-Container ausführen können, aber auch das ist ziemlich einfach: Wenn Sie die benannte Pipe für die Docker-Engine in den Container einhängen, können Sie dann Docker-Befehle so ausführen, als ob Sie direkt auf dem Host laufen würden. Beachten Sie jedoch, dass dies vollen Zugriff auf Ihre Engine erlaubt...

Insgesamt bin ich mit der Lösung recht zufrieden, da sie meiner Meinung nach das beste aller möglichen Szenarien mit nur einem sehr geringen Overhead kombiniert.

Hinweis: Dieser Post ist ursprünglich auf Englisch auf dem Blog des Authors erschienen.