p.blog
Unternehmenskultur, Softwareentwicklung und Architektur

19. Februar 2021

How to: Docker-Images ohne Docker – Kaniko in ein GitLab-Projekt integrieren

5 Minuten Lesedauer

Photo by John Barkiple on Unsplash

 

Während meiner Arbeit an einem Kundenprojekt und dem damit verbundenen Aufsetzen einer CI/CD-Pipeline bin ich auf ein Problem gestoßen. Nehmen wir einmal an, wir haben eine klassische Microservice-Anwendung, beispielsweise auf Spring Boot basierend. Als Repository- und DevOps-Umgebung ist außerdem GitLab gesetzt. Die damit verbundenen Runner, also die Laufzeitumgebungen, die Pipeline-Jobs für GitLab ausführen, können diverse Ausprägungen haben. Es existieren zum einen verschiedene „Executor“-Arten, die bestimmen, welche Art von Code ausgeführt werden kann. Zum anderen kann man Runner auf unterschiedlichen Umgebungen betreiben. In den allermeisten Fällen werden sie aber in einem Kubernetes-Namespace installiert und laufen dort als Container.

Lautet die Aufgabe nun, unsere Anwendung mit einem solchen Runner in ein Docker-Image zu verpacken, wird es etwas kompliziert. Grundsätzlich benötigt man einen Runner mit Docker-Executor (siehe GitLab-Doku) – das ist naheliegend. Zusätzlich muss innerhalb dieses Docker-Containers aber auch noch ein Docker-Daemon installiert sein, denn ohne diesen lässt sich der docker build-Befehl nicht ausführen.

Diese Konstellation nennt man auch Docker-in-Docker (“DinD”, hier genauer erklärt). Vielleicht hat der eine oder andere den Film Inception gesehen: Ähnlich wie die verschiedenen Traumlevel kann man sich auch diese Software-Verschachtelung vorstellen, quasi eine Dockerception. Unabhängig davon kann ich den Streifen übrigens nur empfehlen, ab zum Trailer!

Warum Docker-in-Docker eine schlechte Idee ist

Jetzt denkt ihr euch vielleicht: „Ja und? Warum sollte man das denn nicht nutzen, wenn es funktioniert?“ – berechtigte Frage! Allerdings bringt der DinD-Modus einen entscheidenden Nachteil mit sich: Er stellt ein nicht zu vernachlässigendes Sicherheitsrisiko dar.

Um den Modus nutzen zu können, muss der Quell-Container, in diesem Fall der GitLab-Runner, im sogenannten Privileged-Modus gestartet werden. Im Gegensatz zum normalen Betrieb stehen den entsprechenden Containern hier deutlich mehr kritische Zugriffsrechte zur Verfügung. So können sie beispielsweise auf das Dateisystem ihres übergeordneten Quell-Containers zugreifen. Diesen Angriffspunkt könnten sich Unbefugte zu Nutze machen, um Zugriff auf sensible Daten oder weitere Systeme zu erhalten.

Lösung: Kaniko

Eine interessante Alternative zu Docker-in-Docker kann das Tool Kaniko sein. Es stammt – wie Kubernetes selbst – von Google und ist in den GoogleContainerTools enthalten. Im Prinzip tut Kaniko genau das, was auch die oben beschriebene Docker-in-Docker-Konstellation tut, nur ohne dabei die Sicherheitsprobleme zu übernehmen. Ein einzelner Pod mit einer auf das Projekt angepassten Konfiguration und ohne den Privileged-Modus übernimmt den vollständigen Prozess.

Nachfolgend zeige ich euch, wie diese Konfiguration vorzunehmen ist und was es dabei zu beachten gilt.

Kaniko-Pod konfigurieren in 4 Schritten

Copy-pastet euch folgendes Gerüst und speichert es in einer .yaml-Datei:

apiVersion: v1
kind: Pod
metadata:
name: kaniko-app
spec:
containers:
- name: kaniko-app
image: gcr.io/kaniko-project/executor:latest
args: []
restartPolicy: Never

 

In das noch leere args-Feld fügen wir nun die benötigten Parameter ein.

1. Dockerfile

Angegeben wird der Link zur Datei relativ zum Build-Kontext – mehr dazu im nächsten Schritt. Liegt sie im Root-Projektverzeichnis, schreibt ihr einfach:

--dockerfile=Dockerfile

 

Ein Umzug eines bestehenden Projekts zu Kaniko wird hier dahingehend erleichtert, dass keine Änderungen am Dockerfile selbst notwendig sind. Nachfolgend ein Beispiel.

FROM maven:3.5-jdk-11 as maven 
COPY . . 
RUN mvn clean package -Dmaven.test.skip=true 
FROM adoptopenjdk/openjdk11:jdk-11.0.9_11 
COPY --from=maven target/app.jar app.jar 
CMD ["java", "-jar", "/app.jar"]

 

2. Build-Kontext

Das ist der Ablageort unserer Quelldateien für das zu bauende Image.

Bei Verwendung eines GitLab-Repository ist zu beachten, dass zunächst ein Access Token erstellt werden muss. Dafür navigiert ihr innerhalb eures Projekts in den Settings-Bereich → Access Tokens. “Read Repository” ist der richtige Scope.

URL und Token so einfügen:

--context=git://auth:<token>@<repository-url>.git

 

Es besteht außerdem die Möglichkeit, einen bestimmten Branch als Kontext zu verwenden:

--git=branch=production

 

3. Destination

Der dritte Parameter ist das Ziel-Verzeichnis, in das unser fertiges Image nach Abschluss gepusht werden soll. Auch hier werden verschiedene Registries unterstützt, neben Docker Hub sind auch wieder Google und Amazon mit ihren Cloud Registries vertreten. Hier gibt es nicht viel zu beachten, einfach die URL einfügen:

--destination=<registry-url>:<version>

 

4. Registry-Secret

Mit der Destination im Zusammenhang steht die letzte zu beachtende Angabe: Bei einer Private Registry wird ein Authentication-Secret benötigt, um dorthin pushen zu können.

Das Secret kann mit Hilfe eines kubectl-Befehls erstellt werden:

kubectl create secret docker-registry kaniko-secret --docker-server=<your-registry-server> --docker-username=<your-name> --docker-password=<your-pword> --docker-email=<your-email>

 

Anschließend wird es im Deployment File des Pods referenziert:

spec:
  containers:
    volumeMounts:
      - name: kaniko-secret
        mountPath: .docker/
 
  volumes:
    - name: kaniko-secret
      secret:
        secretName: kaniko-secret
        items:
          - key: .dockerconfigjson
            path: config.json

Build ausführen

Das fertige Deployment-File für unseren Kaniko-Pod sollte dann ungefähr so aussehen:

apiVersion: v1 
kind: Pod
metadata:
  name: kaniko-app
spec:
  containers:
  - name: kaniko-app
    image: gcr.io/kaniko-project/executor:latest
    args: [ "--dockerfile=Dockerfile",
            "--context=git://auth:someToken@gitlab.someHost.com/test/test-application.git",
            "--destination=harbor.someHost.com/testApplication:1.0"]
    volumeMounts:
      - name: kaniko-secret
        mountPath: .docker/
  restartPolicy: Never
  volumes:
    - name: kaniko-secret
      secret:
        secretName: kaniko-secret
        items:
          - key: .dockerconfigjson
            path: config.json

 

Die Datei kann an beliebiger Stelle im Repository abgelegt werden. Ich habe sie in den Ordner infrastructure gepackt.

Um den Build jetzt zu triggern, wird ein entsprechender Job in der .gitlab-ci.yaml hinzugefügt:

docker_build:
tags:
    - kaniko
  stage: build_image
  script:
    - kubectl create -f infrastructure/pod.yaml

 

Optimierung

Komplett fertig sind wir an dieser Stelle noch nicht, denn beim Einsatz in der GitLab-Pipeline hatte ich noch mit Kleinigkeiten bei der Automatisierung zu kämpfen, die in der Kaniko-Doku nicht erwähnt werden.

Zum einen meldet der Pod nach Abschluss des Build-Prozesses zwar einen Status completed – er wird allerdings nicht automatisch wieder gelöscht. Das kann dann zum Problem werden, wenn die Pipeline das nächste Mal getriggert wird. Dann würde der kubectl-Befehl zum Erstellen nämlich fehlschlagen, da der Pod ja bereits existiert.

Die Suche nach einer Möglichkeit, den Pod automatisch wieder löschen zu lassen, sobald er completed meldet, blieb leider erfolglos. Als Alternative dazu habe ich einfach diese beiden Zeilen zum Script des Jobs (siehe oben) innerhalb der .gitlab-ci.yaml hinzugefügt:

- sleep 360
- kubectl delete pod kaniko-app

 

Es wird also sechs Minuten gewartet und dann der Pod entfernt.

Die Zeitspanne ist natürlich variabel. Manche Projekte werden mit Sicherheit schneller gebaut, bei anderen könnte es länger dauern. Überprüft zunächst mit Hilfe von kubectl get pods, wie lange der Kaniko-Pod im Durchschnitt benötigt, bis completed erscheint, und verwendet dann diese Zeit plus zwei bis drei Minuten Puffer.

Ein ähnliches Problem kann auftreten, wenn innerhalb kurzer Zeit zweimal hintereinander die Pipeline getriggert wird, sodass sich die Build-Prozesse überholen. Der neue Kaniko-Pod versucht zu starten, während der alte noch nicht beendet ist. Auch hier würde die Pipeline fehlschlagen und melden, dass der Pod bereits existiert.

Abhilfe schafft wieder eine Anpassung der .gitlab-ci.yaml, in der wir wieder einen neuen Job (vor dem Bauen) hinzufügen:

delete_kaniko_pod:
tags:
    - kaniko
  stage: prepare_build
  allow_failure: true
  script:
    - kubectl delete pod kaniko-app

 

Sollte die eingangs beschriebene Situation auftreten und der Pod bereits laufen, wird er gestoppt und entfernt. Andernfalls schlägt der Befehl zwar aus bereits beschriebenen Gründen fehl – das wird allerdings durch die allow_failure abgefangen und die Pipeline läuft weiter.

Done!

Das war es nun aber wirklich. Kaniko sollte zuverlässig angetriggert werden und unsere Images ausspucken.

Ich empfand den Prozess mit etwas Einarbeitung durchaus intuitiv. Einmal aufgesetzt kann das System mit kleinen Anpassungen auch problemlos auf andere Microservices und Projekte im gleichen Repository übertragen werden.