Im ersten Teil dieser Serie wird gezeigt wie GitLab CI Pipeline-Artefakte mittels Ansible deployt werden können. GitLab CI ist die Continuous Integration-Lösung, welche direkt in GitLab integriert ist um automatisch Projekte zu bauen. GitLab CI kombiniert mit Ansible, einer simplen, aber mächtigen Provisionierungs- und Deploymentautomatisierungslösung, ergibt eine einfach zu benutzende Continuous Delivery-Pipeline. Der Artikel gibt zuerst einen Überblick über die involvierte physikalische und technologische Maschinerie. Anschließend wird ein Plan entwickelt, wie sich adäquate Unterstützung von Ansible-Deployments für alle GitLab Projekte einer GitLab Community Edition umsetzen lässt. Die detaillierte Beschreibung der Implementierung des Plans rundet das Ganze ab.

Der erste Teil des Artikels erklärt die Arbeitsweise von GitLab CI & Ansible und ist für jeden GitLab-Benutzer relevant der verstehen möchte, wie ein Ansible-Deployment in die GitLab-Softwarelandschaft passt, während der zweite Teil im Detail beschreibt, wie eine selbstgehostete GitLab Community Edition durch einen Admin-Benutzer um Ansible-Support erweitert werden kann.

Der nächste Artikel in dieser Blogserie zeigt dann, wie eine Spring Boot Anwendung vollautomatisiert durch Ansible innerhalb einer GitLab CI Pipeline deployt wird. Die Installation von GitLab selbst, sowie GitLab CI/Runner, liegt außerhalb des Fokus der Reihe.

Vom git push zu ausgeführtem Code

Automatisiertes Deployment jeglicher Code-Änderungen ist etwas, das man nicht mehr missen möchte, nachdem man es einmal erlebt hat. Neue Features direkt den Anforderern zeigen zu können oder Bugfixes von Testern nach jedem Commit überprüfen lassen zu können, verkürzt den Feedback-Loop deutlich, was zu besserer Software in kürzerer Zeit führt. Also was benötigen wir, um dies umzusetzen?

Abbildung 1: Continuous Delivery
Abbildung 1: Continuous Delivery

Abbildung 1 zeigt den Ansatz, den wir umsetzen wollen. Bei einem Push zum GitLab-Server wird eine Reihe von Jobs, die in einer Pipeline laufen, gestartet. Die Jobs erstellen Artefakte, testen diese und laden sie anschließend in ein Ziel-Repository hoch. Beispiele für ein solches Ziel-Repository sind u.a. Maven Repositories wie Nexus oder Artifactory, eine Docker Registry oder GitLab selbst. Der Deployment-Job lädt die Artifakte dann auf einen Staging-Server und startet die neue Version der Anwendung, wodurch die aktuellsten Änderungen allen Nicht-Entwicklern im Team zur Verfügung gestellt werden. Bevor sich ein erfahrener Leser wundert: die Pipeline wird im nächsten Teil dieser Serie um eine Produktionsumgebung erweitert.

Eine typische GitLab-CI-Installation besteht aus mehreren Servern: einem GitLab-Server und einem (oder mehreren) GitLab-Runner-Servern, wie die Server-Landschaft in Abbildung 2 zeigt:

Abbildung 2: GitLab Continuous Delivery
Abbildung 2: GitLab Continuous Delivery

Nach einem git push zum GitLab-Server wird eine CI-Pipeline auf dem GitLab Runner gestartet, welche die in der .gitlab-ci.yml des Projekts definierten CI Jobs ausführt. Nach Umsetzung des in diesem Artikel beschriebenen Plans sind dann lediglich die folgenden Zeilen in der .gitlab-ci.yml nötig, um automatisiert nach jedem Push zu deployen:

deploy_app:
  stage: deploy
  tags:
    - ansible
  script:
    - 'ansible-playbook -i staging deploy.yml'

Ansible-Deployments aus GitLab heraus

Ansible führt Playbooks aus, welche eine Liste von Tasks enthalten, wie z.B. “lade diese Datei herunter” und “starte diesen Service neu”. Das Playbook wird auf ein oder mehrere Zielmaschinen angewendet, in unserem Fall auf den Staging-Server. Damit Ansible innerhalb der Pipeline verwendet werden kann, ist es nötig zu verstehen, wie die Jobs einer Pipeline durch den GitLab Runner ausgeführt werden:

In GitLab können mehrere verschiedene Runner hinzugefügt werden, welche auf bestimmte Pipeline-Jobs spezialisiert sind. Diese speziellen Runner (nicht zu verwechseln mit dem GitLab Runner Host, welcher nur der Server ist, auf dem der Runner läuft) können mit zusätzlicher Maschinerie ausgestattet werden, wie z.B. Datenbanken, virtuellen Maschinen oder Direktzugriff auf den Runner Host. Dies wird durch verschiedene Executors ermöglicht. Desweiteren können Runner einem bestimmten GitLab-Projekt zugeordnet werden oder zwischen allen Projekten geteilt werden (Shared Runner). Da wir Ansible-Deployments für alle Projekte ermöglichen wollen, werden wir einen Shared Runner anlegen, der einen Docker-Executor verwendet.

Abbildung 3: Ausführung von Ansible innerhalb von Docker
Abbildung 3: Ausführung von Ansible innerhalb von Docker

Der Executor eines Runners stellt die Ausführungsumgebung für die Pipeline-Jobs zur Verfügung. Der Docker-Executor ermöglicht es, ein Docker-Image anzugeben, in dem das Ansible-Deployment ausgeführt wird. Das Image kann dabei, wie in Abbildung 3 verdeutlicht, direkt aus der offiziellen Docker Hub Registry gezogen werden, welche mehrere vorgefertigte Ansible Images enthält. Der neue Runner kann zusätzlich mit einem Etikett (englisch: Tag) versehen werden, wodurch die Verwendung des Runners auf Jobs mit diesem Tag beschränkt wird.

Um sich mit dem Zielrechner zu verbinden, benutzt Ansible SSH:

Abbildung 4: Schlüsselbasierte Authentifizierung und Authorisierung mit SSH
Abbildung 4: Schlüsselbasierte Authentifizierung und Authorisierung mit SSH

Das bedeutet, dass der GitLab Runner Host einen SSH-Schlüssel benötigt, um sich gegenüber dem Staging-Server zu authentifizieren. Zusätzlich muss dieser Schlüssel für einen idealerweise Nicht-Rootbenutzer autorisiert werden, wie in Abbildung 4 verdeutlicht. Außerdem muss der private Teil des SSH-Schlüssels innerhalb des Ansible Containers verfügbar gemacht werden, welcher auf dem GitLab Runner Host durch Docker ausgeführt wird.

Abbildung 5: Mounten des SSH-Schlüssels in den Ansible Container
Abbildung 5: Mounten des SSH-Schlüssels in den Ansible Container

Ein häufig gewählter Weg, um Geheimnisse wie private SSH-Schlüssel innerhalb von Containern zur Verfügung zu stellen, ist die Verwendung von Docker Volumes, wie in Abbildung 5 zu sehen ist. Das Volume wird zur Laufzeit in den Container gemountet, wodurch Ansible Zugriff auf den SSH-Schlüssel hat. Die Volumes eines Executors können innerhalb der Konfigurationsdatei des GitLab Runners angegeben werden.

Ein anderer Ansatz, um die Geheimnisse verfügbar zu machen, ist die Verwendung von GitLabs Secure Variables. Der private SSH-Schlüssel kann in einer solchen Variable gespeichert werden und zusätzlich konfiguriert man den GitLab Runner mit einem pre_build_script, welches den SSH-Schlüssel zu einem SSH-Agent hinzufügt. Dieser Ansatz erlaubt es, SSH-Schlüssel pro Projekt zu konfigurieren, wodurch das System theoretisch sicherer würde und außerdem könnten Benutzer neue Deploy-Zielrechner definieren, ohne Zugriff auf den Gitlab Runner Host zu benötigen. Eine offene Frage dieses Ansatzes ist, wie sich das Einrichten der sicheren Variable in GitLab automatisieren ließe. Aus diesem Grund haben wir uns für den ersten Ansatz entschieden.

Der Plan

Unser Plan, um Ansible innerhalb von GitLab Pipelines zur Verfügung zu stellen, besteht aus den folgenden Schritten:

  1. Erstelle einen SSH-Schlüssel auf dem GitLab Runner host.
  2. Erstelle einen Deploy-Benutzer, für den der SSH-Schlüssel autorisiert wird.
  3. Erstelle einen neuen GitLab Runner basierend auf einem Ansible Docker Image und konfiguriere den Runner mit einem Volume, welches das Verzeichnis mit dem SSH-Schlüssel mountet.
  4. Versehe den neuen Runner mit einem ansible-Tag in GitLab und konfiguriere den Runner so, dass er nur Jobs mit ansible-Tag ausführt.

Umsetzung des Plans

Schritt ​1 und 2 sind typische Provisionierungsaufgaben, welche Ansible mit Bravour erledigen kann. Anstatt den Schlüssel und Benutzer von Hand anzulegen, können wir ein kurzes Playbook erstellen, welches den SSH-Schlüssel erstellt (wenn noch keiner existiert), den öffentlichen Teil des Schlüssels ausliest und ihn auf dem Zielrechner autorisiert:

- hosts: gitlab_runner
  …
  - tasks:
   - name: create ssh key if it does not exist
      expect:
        command: ssh-keygen -t rsa
        # only creates the key if the file does not exist
        creates: "{{ runner_user_home }}/.ssh/id_rsa"
        ...
        responses:
          "file": "{{ runner_user_home }}/.ssh/id_rsa"
          "passphrase": ""
   - name: read public key
      command: "cat {{ runner_user_home }}/.ssh/id_rsa.pub"
      register: runner_pub_key


- hosts: deploy_target
  …
  - tasks:
   - name: add deploy key to authorized keys
      authorized_key:
        user: "{{ user }}"
        key: "{{ hostvars[ deploy_source_host ].runner_pub_key.stdout}}"

Die Details des Playbooks sind hier nicht weiter von Bedeutung. Der Punkt ist, dass sich diese Tasks einfach skripten lassen und das vollständige Playbook vom bevuta-Github-Account heruntergeladen werden kann. Durch die Automatisierung dieser Schritte können wir später ohne viel Aufwand neue Zielrechner hinzufügen und das Playbook erneut ausführen. Ansible stellt dabei sicher, dass alle Tasks idempotent ausgeführt werden, wodurch die Anpassungen nur auf neuen Zielrechnern durchgeführt werden. Durch ein paar Erweiterungen ließe sich eine automatische Schlüsselerneuerung und Deautorisierung alter Schlüssel umsetzen, die periodisch ausgeführt werden könnte.

Schritt 3 des Plans muss auf dem GitLab Runner Host ausgeführt werden. Dazu wird mithilfe des Shellprogramms gitlab-ci-multi-runner ein neuer Runner registriert. Für die Registrierung wird das Token aus dem Tab Admin Area -> Overview -> Runners in GitLab benötigt:

sudo ./gitlab-ci-multi-runner register \ 
  --non-interactive \
  --executor docker \ 
  --url <gitlab-url> \ 
  --name deploy-ansible-runner \ 
  --registration-token <Registrierungstoken> \ 
  --docker-image williamyeh/ansible:centos7 \ 
  --docker-privileged false \ 
  --docker-volumes "/home/<deploy-user>/.ssh:/root/.ssh" \ 
  --tag-list Ansible

Um sicherzustellen, dass alle Werte korrekt sind, kann die /etc/gitlab-runner/config.toml überprüft werden. Nach der Runner-Registrierung kann die Konfigurationsdatei einfach editiert und gespeichert werden. GitLab reagiert dann automatisch auf Änderungen in der Datei.

[[runners]]
  name = "deploy-ansible-runner"
  url = <gitlab-url>
  token = <deploy-token>
  executor = "docker"
  environment = ["GIT_SSL_NO_VERIFY=1"]              (1)
  [runners.docker]
    image = williamyeh/ansible:centos7
    privileged = false
    volumes = ["/cache",
               "/home/<deploy-user>/.ssh:/root/.ssh" (2)
    ]

(1) Falls ein selbstsigniertes SSL-Zertifikat verwendet wird, lässt sich durch Setzen dieser Umgebungsvariable verhindern, dass git das Auschecken verweigert.

(2) Mounte das .ssh-Verzeichnis auf den Pfad /root/.ssh, da dies der Standardpfad ist, wodurch Ansible den SSH-Schlüssel automatisch verwendet.

Schritt 4 des Plans kann mit ein paar Klicks umgesetzt werden. Dazu einfach den ansible-deploy-runner in GitLab den Tab Admin Area -> Overview -> Runners auswählen, verifizieren, dass er das ansible-Tag hat und die Run untagged jobs Checkbox abwählen. Ansonsten würde der Runner auch für andere Jobs verwendet werden können, welche eventuell eine andere Laufzeitumgebung erwarten.

Continuous Integration!

Das ist alles, was benötigt wird, um eine GitLab-CI-Installation um Ansible-Deployments zu erweitern. Von jetzt an können alle GitLab-Projekte den Ansible Runner verwenden und automatisch die aktuellsten Änderungen deployen, indem lediglich das ansible-Tag in der .gitlab-ci.yml angegeben wird und ein Ansible Playbook bereitgestellt wird. Dies wird anhand eines Beispiels einer kompletten Pipeline mit Staging- und Produktionsumgebung im nächsten Blogpost dieser Serie erläutert.