CI Jobs auf eigener Kubernetes Infrastruktur laufen zu lassen ist heutzutage relativ einfach. Die Kubernetes Integrationen für Github Actions1 oder Gitlab CI beispielsweise können innerhalb weniger Minuten aufgesetzt werden. Und wenn sollte das nicht ausreichen, bleibt immer noch Jenkins. Für Letzteres existiert inzwischen sogar ein nativer Kubernetes Operator!

Das Endprodukt vieler CI Pipelines sind Container Images. In vielen CI Umgebungen ist deshalb Docker bereits vorinstalliert. Laufen die CI Jobs bereits in Containern kommt meist Docker in Docker zum Einsatz. Läuft die eigene Kubernetes Infrastruktur auf Docker, ist zur Einrichtung lediglich notwendig, den lokalen Docker Socket des Hosts innerhalb des Kubernetes Pods verfügbar zu machen. Es mag daher verführerisch sein, diesen Weg zu wählen, jedoch hat diese Lösung einen großen Makel.

Da die standardmäßige Docker Installation Superuser Privilegien besitzt, gibt die o.g. Variante den CI Jobs deutlich mehr Zugriffsrechte als sie benötigen und haben sollten. In öffentlichen CI Umgebungen ist das meist kein Problem, weil diese häufig (immer?) in virtuellen Maschinen, die nur einmal für den jeweiligen Job zum Einsatz kommen, laufen. Auf der eigenen Kubernetes Infrastruktur kann die Ausstattung von CI Jobs mit Root Rechten ein großes Sicherheitsproblem darstellen. Zum Glück ist dies in den meisten Situationen jedoch völlig unnötig. Warum sollte ein bloßes Kompilieren und Verpacken von Software in eine Archivdatei auch erhöhte Zugriffsrechte erfordern?

Immer wieder liest man inzwischen vom rootless mode von Docker. Dieser befindet sich jedoch zum aktuellen Zeitpunkt immer noch in einem experimentellen Stadium. Eine bessere Lösung ist vermutlich Docker gar nicht erst einzusetzen. Die Build Pipeline läuft ja bereits in einer Container Runtime, sodass eine weitere eigentlich gar nicht nötig sein sollte. Container Images lassen sich nämlich auch ohne Runtime packen, z. B. mit dem exzellenten moby/buildkit.

Der BuildKit Prozess kann ohne erweiterte Rechte in einem einfachen Container ausgeführt werden. Erklärungen sowie Beispielmanifeste finden sich im example Ordner des BuildKit Repos. Folgt man diesen Beispielen erhält man einen geteilten BuildKit Dienst, der von allen CI Pods über einen Kubernetes Service erreichbar ist.

Diese Lösung ist effizient, teilt jedoch einige Ressourcen zwischen den CI Prozessen. Wenn man mit damit leben kann, hat man an dieser Stelle bereits alles was man für seine CI Jobs braucht. Das Kommando zum bauen des Images im CI Job könnte folgendermaßen aussehen

buildctl --addr tcp:///buildkitd:1234 \
  build \
  --frontend dockerfile.v0 \
  --local context=. \
  --local dockerfile=. \
  --output type=image,\"name=myreg/org/example-image:latest,myreg/org/example-image:stable\",push=true \
  --export-cache type=local,dest=/mnt/buildkit-cache \
  --import-cache type=local,src=/mnt/buildkit-cache

Möchte man eine noch stärkere Abschottung dieser erreichen, lässt sich der BuildKit Dienst auch als Sidecar Container zu jedem CI Pod ausführen. Dies ist vor allem dann sinnvoll, wenn man bereits für jeden Job einen frischen Container startet. Es ist jedoch zu beachten, dass diese Lösungen ein wenig Ressourcen intensiver ist, länger für das Starten eines Jobs benötigt und einige Optionen für das Zwischenspeichern von Container Schichten ausschließt. Dies ist ein Beispiel Ausschnitt aus meinem Kubernetes Setup:

containers:
- name: runner
  [...]
  volumeMounts:
        - mountPath: /mnt/buildkit
          name: buildkit
- name: buildkit
  args:
    - --addr
    - unix:///run/user/1000/buildkit/buildkitd.sock
    - --addr
    - unix:///mnt/buildkit/buildkitd.sock
    - --oci-worker-no-process-sandbox
  readinessProbe:
    exec:
      command:
        - buildctl
        - debug
        - workers
    initialDelaySeconds: 5
    periodSeconds: 30
  livenessProbe:
    exec:
      command:
        - buildctl
        - debug
        - workers
    initialDelaySeconds: 5
    periodSeconds: 30
  securityContext:
    runAsUser: 1000
    runAsGroup: 1000
  image: moby/buildkit:v0.9.0-rootless
  imagePullPolicy: IfNotPresent
  resources: {}
  volumeMounts:
    - mountPath: /mnt/buildkit
      name: buildkit
volumes:
    - emptyDir: {}
      name: buildkit

Damit es funktioniert muss lediglich sichergestellt werden, dass der Unix Socket von BuildKit aus allen relevanten Containern des Pods erreichbar ist.

Wenn alles läuft, kann man in der CI Pipeline folgendes Kommando nutzen, um ein Dockerfile zu bauen:

buildctl --addr unix:///mnt/buildkit/buildkitd.sock \
  build \
  --frontend dockerfile.v0 \
  --local context=. \
  --local dockerfile=. \
  --output type=image,\"name=myreg/org/example-image:latest,myreg/org/example-image:stable\",push=true \
  --export-cache type=inline \
  --import-cache type=registry,ref=myreg/org/example-image:cache-tag

Dieses Beispiel lädt alle Containerschichten die beim Bauen anfallen zur Zwischenspeicherung in die Registry hoch, da der BuildKit Sidecar Container selbst keinen persistenten Speicher besitzt. In anderen Setups, besonders wenn Schnelligkeit und Ressourceneffizienz eine hohe Priorität haben, sollte man andere Caching Optionen in Erwägung ziehen.


  1. Für eine Cloud native Lösung zur Orchestrierung von Runnern für GitHub Actions bietet sich evryfs/github-actions-runner-operator an. ↩︎