Abstract Layers

Immer wieder beschäftigen wir uns mit dem Thema Docker Builds für unsere Projekte - gerade weil wir Container mittlerweile für fast jedes Projekt verwenden. Viele Build-Tools benötigen spezielle Berechtigungen für die Erzeugung von Container Images und lassen sich nur mit viel Aufwand sicher und isoliert in einem Container oder in einem Kubernetes Cluster betreiben. Das Problem: RUN Befehle in einem Dockerfile sollen auch isoliert laufen - wie in einem Container.

Mit Kaniko haben wir eigentlich bereits seit längerer Zeit ein recht zuverlässiges Tool zum Erstellen von Container Images etabliert, welches wir in unserem Kubernetes Cluster für Builds über die GitLab CI verwenden. Jedoch sind wir hier in einem speziellen Fall auf ein Problem gestoßen: die Timestamps von Dateien, die zum Image hinzugefügt wurden, sollten erhalten bleiben - dies kann Kaniko bisher nicht und setzt die Timestamps bei einer COPY Direktive immer auf das aktuelle Datum.

Welche Alternativen gibt es?

Es ist also mal wieder Zeit zu schauen, welche Alternativen es für die Erstellung von Container Images gibt. Das Kaniko Projekt listet hier einige der wichtigsten anderen Projekte auf:

Unsere Anforderungen sind im konkreten Fall recht klar:

  • Keine speziellen Berechtigungen für die Container, welche über den Gitlab Kubernetes Executor gestartet werden
  • Hinzufügen von Dateien unter Beibehaltung der Attribute (Timestamps) und Angabe der UID / GID
  • Keine Notwendigkeit für spezielle Mounts (FUSE, Proc, usw.)
  • Möglichst effizient und schnell

Mit umoci Images direkt bearbeiten

Viele Kandidaten scheiden hier leider direkt aus, da sie nicht für den Betrieb in unprivilegierten Containern gemacht sind oder spezielle Security-Profiles benötigen. Ein interessantes Low-Level Tool ist umoci, welches OCI Images direkt manipulieren kann. Für unseren Use-Case einem Base-Image Dateien hinzuzufügen und ein paar Konfigurationen (USER, EXPOSE, ENTRYPOINT) zu setzen, reicht dies völlig aus. Um ein Docker Image aus einer Registry zu laden und am Ende wieder zu pushen, kann man dies noch mit dem Tool skopeo kombinieren.

OCI? Die Open Container Initiative ist eine Organisation zur Standardisierung Rund um Formate und Runtimes für Container Images. Bekannt sind hier vor allem die OCI Images, welche eine Grundlage vieler Tools bilden und im Wesentlichen aus Metadaten in JSON Dateien und Tar-Dateien mit den eigentlichen Inhalten der Layer bestehen. Für den Austausch der Images ist hier jedoch noch kein Standard definiert, so dass Docker-kompatible Registries nach wie vor geläufig sind.

Ein kurzer lokaler Test (auf macOS; und siehe da, die Tools laufen hier auch direkt) war erfolgreich und so haben wir ein kleines Base Image für unsere GitLab CI erstellt:

oci-tools/Dockerfile
FROM debian:bullseye-slim

RUN apt-get update && apt-get install -y \
    ca-certificates \
    skopeo \
    umoci \
    && rm -rf /var/lib/apt/lists/*

Ein Build Job für GitLab CI

Der Build Job für unsere GitLab CI kann so natürlich auch für andere System adaptiert werden und hat im Wesentlichen folgenden Ablauf:

  1. Login via skopeo für den späteren Push des Images
  2. Via skopeo das Base Image aus dem Docker Hub laden und als OCI Image im /tmp Ordner ablegen
  3. Mit umoci Konfiguration im Image setzen (entsprechend USER, EXPOSE, ENTRYPOINT im Dockerfile)
  4. Mit umoci die Dateien aus dem Projektverzeichnis in das OCI Image einfügen (als neuen Layer)
  5. Das Image mit skopeo in die eigene Docker Registry pushen

Ein Dockerfile benötigen wir nun für diesen Ansatz gar nicht mehr. Jedoch gibt es hier natürlich eine Einschränkung: eine Ausführung von RUN ist über diesen Weg nicht möglich - diese Direktiven müssten in ein Base-Image verlagert werden.

.gitlab-ci.yml

# ...

docker-build:
  stage: docker-build
  image: my-registry/oci-tools:latest
  before_script:
    # branch main -> latest, other branch -> slug for branch (safe with slashes), tag -> tag
    - if [ $CI_COMMIT_REF_NAME == 'main' ];
      then export CI_REGISTRY_TAG=latest;
      else export CI_REGISTRY_TAG=${CI_COMMIT_TAG:-$CI_COMMIT_REF_SLUG};
      fi
  script:
    - cd /tmp
    - skopeo login
        --username $CI_REGISTRY_USER
        --password $CI_REGISTRY_PASSWORD
        $CI_REGISTRY
    - skopeo copy docker://node:14-alpine oci:my-project:$CI_REGISTRY_TAG
    - umoci config
        --config.user node
        --config.workingdir /app
        --config.exposedports 3000
        --config.entrypoint ./docker/entrypoint.sh
        --image my-project:$CI_REGISTRY_TAG
    - umoci insert
        --image my-project:$CI_REGISTRY_TAG
        --uid-map=1000:0
        --gid-map=1000:0
        $CI_PROJECT_DIR /app
    - skopeo copy
        oci:my-project:$CI_REGISTRY_TAG
        docker://$CI_REGISTRY_IMAGE:$CI_REGISTRY_TAG
  dependencies:
    - build

Fazit

Unsere spezielleren Anforderungen in diesem Node.js basierten Projekt konnten mit den beiden Tools gelöst werden und die Builds sind zudem auch etwas schneller als mit Kaniko geworden (gerade die vielen Dateien in node_modules erhöhen hier wohl die Laufzeit). umoci läuft ohne spezielle Berechtigungen, da die Images direkt auf der Dateiebene + Metadaten manipuliert werden: perfekt für sichere und isolierte Builds. Und skopeo ist ein sehr nützliches Tool zum Kopieren und Synchronisieren von Images, welches wir wohl auch in anderen Situationen gut verwenden können.

Kaniko bleiben wir für den Großteil unserer Builds aber treu, dort läuft das Tool meist ohne Probleme. Es ist aber trotzdem gut für den ein oder anderen Fall noch etwas in der Hinterhand zu haben und eine Ebene tiefer direkt auf den OCI Images ansetzen zu können.