Deklarative, reproduzierbare und sichere OCI-Container mit Nix

Im letzten Jahrzehnt ist es Docker gelungen, Container von einem Nischenkonzept zu einer grundlegenden Technologie für cloud-native Infrastrukturen zu transformieren. Die Open Container Initiative (OCI) bildet die Grundlage einer ganzen Branch und standardisiert, wie Container verpackt, verteilt und ausgeführt werden.

 

Das Buildsystem von Docker weist jedoch einige Einschränkungen auf. Aufgrund des Netzwerkzugriffs während der Buildzeit mangelt es zum einen an Determinismus, was zu nicht reproduzierbaren Artefakten führt und die Bemühungen um die Sicherheit der Software-Lieferkette (SSCS) erschwert. Zum anderen ist es herausfordernd, die von Docker bereitgestellten Layering-Strategien zur Verbesserung der Speicher- und Cache-Effizienz so einzusetzen, dass gemeinsame Abhängigkeiten über verschiedene OCI-Artefakte hinweg abgebildet werden.

 

Nix als Paketmanager ermöglicht sowohl deklarative als auch reproduzierbare Builds. Der Build findet in hermetischen, netzwerkisolierten Sandboxen statt, wobei alle Abhängigkeiten im Voraus angegeben werden müssen. Selbst bei riesigen Repositorys wie Nixpkgs ermöglicht dieser Ansatz die genaue Wiederherstellung jeder historischen Revision und gewährleistet so langfristige Reproduzierbarkeit. Nix behandelt Abhängigkeiten als Entitäten erster Klasse, wodurch die Erstellung umfassender und genauer Software Bill of Materials (SBOMs) vereinfacht wird.

 

Mit dockerTools als Teil der Nix-Standardbibliothek können diese Vorteile nahtlos auf OCI-Container-Images übertragen werden. Dieser Blogpost beleuchtet die Vorteile vollständig deklarativer und reproduzierbarer OCI-Container-Builds mit Nix anhand praktischer Beispiele. Darüber hinaus werden die Vorteile des deklarativen Ansatzes hervorgehoben, der tiefere Einblicke in die Artefakte und positive Auswirkungen auf die SSCS bietet.

Beispielanwendung: HTTP-Caching-Proxy in Golang

Zur praktischen Nachvollziehbarkeit soll eine realitätsnahe Golang-Applikation dienen, die durch unterschiedliche Buildsysteme in ein Container-Image überführt werden soll.

Die Applikation nutzt eine Reihe von Modulen aus der Golang Standardbibliothek (1) und hat darüber hinaus eine externe Abhängigkeit zu einer SQLite-Bibliothek (2), die naturgemäß C-Bindings benötigt.

 

Zunächst wird eine Datenbank aus der Datei weather.db geöffnet (3) und gegebenenfalls eine Tabelle erzeugt (4), die einen Cache implementieren soll. Die Applikation definiert eine HTTP-Handlerfunktion für beliebige Pfade (5) und für jeden HTTP-Request wird in der Datenbank nachgeschlagen, ob es einen Eintrag mit der entsprechenden URL in der letzten Minute gegeben hat (6). Wenn ein Eintrag existiert (7), wird mit der gecachten Antwort aus der Datenbank geantwortet (8).

 

Andernfalls wird ein HTTP-Request zu dem Downstreamservice vorbereitet (9) und ein gewünschter User-Agent gesetzt (10). Der HTTP-Request wird gesendet (11) und die gesamte Antwort eingelesen (12). Anschließend wird die Antwort der Gegenstelle in die Cache-Datenbank mit einem aktuellen Zeitstempel geschrieben (13) und als Antwort auf den HTTP-Request durchgereicht (14).

Die Applikation startet einen Server für alle verfügbaren Adressen auf dem TCP-Port 8080 (15).

				
					package main

import (
  "database/sql"                                                      // (1)
  "io"                                                                // (1)
  "net/http"                                                          // (1)

  _ "github.com/mattn/go-sqlite3"                                     // (2)
)

func main() {
  db, _ := sql.Open("sqlite3", "weather.db")                          // (3)
  db.Exec(`
    CREATE TABLE IF NOT EXISTS cache (
      key TEXT PRIMARY KEY,
      body BLOB,
      ts DATETIME
    );
  `)                                                                  // (4)

  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // (5)
    body := []byte{}
    db.QueryRow(`
      SELECT body
      FROM cache
      WHERE key = ? AND ts > DATETIME('now', '-1 minute')
    `, r.URL.String()).Scan(&body)                                    // (6)
    if len(body) > 0 {                                                // (7)
      w.Write(body)                                                   // (8)
      return
    }

    req, _ := http.NewRequest(
      "GET",
      "https://wttr.in"+r.URL.String(),
      nil,
    )                                                                 // (9)
    req.Header.Set("User-Agent", "curl")                              // (10)

    resp, _ := http.DefaultClient.Do(req)                             // (11)
    defer resp.Body.Close()
    body, _ = io.ReadAll(resp.Body)                                   // (12)

    db.Exec(`
      INSERT INTO cache(key, body, ts)
      VALUES (?, ?, CURRENT_TIMESTAMP)
      ON CONFLICT(key) DO UPDATE SET
        body=excluded.body,
        ts=CURRENT_TIMESTAMP
    `, r.URL.String(), body)                                          // (13)
    w.Write(body)                                                     // (14)
  })

  http.ListenAndServe("0.0.0.0:8080", nil)                            // (15)
}

				
			

Aus Vereinfachungsgründen werden lediglich Linux-Zielsysteme mit der CPU-Architektur AMD64 betrachtet. Darüber hinaus wird aus Gründen der besseren Lesbarkeit auf die für Golang sonst typische Fehlerbehandlung verzichtet.

docker build: Vom Build- zum Runtime-Image (Multi-Stage)

Die zuvor vorgestellte Golang-Applikation soll nun mittels eines Dockerfiles paketiert werden.

Als Basis-Image wird auf der Alpine-Variante des Golang-Images aufgesetzt und zur besseren Reproduzierbarkeit wird der exakte Digest gepinnt (1). Da die Applikation eine Systemabhängigkeit zu der SQLite-Bibliothek hat und C-Bindings benötigt, müssen eine Reihe von Systempaketen installiert werden (2). Bevor die Applikation im Container gebaut werden kann, muss der Quellcode in den Container kopiert werden (3). Schließlich kann die Applikation mit eingeschalteten C-Bindings gebaut werden (4). Grundsätzlich existiert jetzt eine lauffähige Binärdatei im Container-Image. Das Basis-Image für Golang bringt allerdings bereits eine Reihe von Buildabhängigkeiten mit, die in diesem Fall sogar noch zusätzlich erweitert wurden und zur Laufzeit nicht benötigt werden.

 

Um Ressourcen zu sparen und die Angriffsfläche aus Sicherheitsgründen zu verringern, soll das Container-Image minimal gestaltet werden. Hierfür wird mittels Multi-Stage-Builds auf ein neues, leeres Basis-Image aufgesetzt (5). Zunächst wird die Binärdatei aus der vorherigen Build-Stage kopiert (6). Die Applikation benötigt darüber hinaus noch eine libc als Abhängigkeit, die ebenfalls mit in das Container-Image kopiert werden muss (7). Da die Applikation potenziell eine HTTPS-Verbindung zu dem Downstreamservice herstellt, müssen CA-Zertifikate als weitere Abhängigkeit in das Container-Image kopiert werden (8), damit zur Laufzeit eine TLS-Verbindung initiiert werden kann.

Abschließend wird die Binärdatei als Entrypoint für den Container spezifiziert (9).

				
					FROM golang:1.25.4-alpine3.22@sha256:d3f0…1bbb                   # (1)

RUN apk --no-cache add gcc musl-dev sqlite-dev                   # (2)

COPY . .                                                         # (3)

RUN CGO_ENABLED=1 go build                                       # (4)

FROM scratch                                                     # (5)

COPY --from=0 /go/weather /bin/weather                           # (6)

COPY --from=0 /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1  # (7)

COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # (8)

ENTRYPOINT ["/bin/weather"]                                      # (9)

				
			

Die Verwendung eines Distroless Images, kann prinzipiell das Kopieren von Abhängigkeiten aus dem ursprünglichen Image in das Zielimage (7, 8) obsolet machen, wodurch man sich allerdings an die Inhalte dieses Images bindet.

 

Durch den Multi-Stage-Build und die Minimierung von Artefakten im Zielimage ist die Containerisierung der Beispielapplikation für einen Docker-basierten Buildprozess nahezu optimal, auf Kosten von einer Vielzahl manueller Schritte. Allerdings bringt die notwendige Installation der Systemabhängigkeiten (2) zwei entscheidende Nachteile mit sich: Zum einen sind diese nicht reproduzierbar, weil die entsprechenden Versionen vom Stand des Alpine-Repositories abhängen. Zum anderen ist es aufgrund des Multi-Stage-Builds später nicht mehr nachvollziehbar, mit welchen Versionen das Artefakt gebaut wurde. Ein weiterer Nachteil besteht in der imperativen Natur der Anweisungen im Dockerfile, deren spezifische Reihenfolge die topologische Sortierung der Abhängigkeiten erfüllt. Während der Quellcode zunächst kopiert werden muss (3) bevor die Applikation gebaut werden kann (4), ist es unerheblich, ob zunächst die Abhängigkeiten installiert werden (2) oder der Quellcode kopiert wird (3). Obwohl diese Reihenfolge willkürlich ist, führt eine Veränderung von dieser Reihenfolge zu unterschiedlichen OCI-Images. Darüber hinaus invalidiert eine Veränderung der Reihenfolge einer Anweisung im Dockerfile den Cache für alle nachfolgenden Anweisungen. Wenn im obigen Dockerfile beispielsweise der Quellcode (3) zuerst kopiert wird, bevor die Abhängigkeiten installiert werden (2), wird der Cache für alle nachfolgenden Schritte (4 – 9) invalidiert. Diese Änderung der Reihenfolge ohne praktische Auswirkungen verlängert nicht nur die Buildzeit, sondern verändert ebenfalls das OCI-Image.

Anatomie des gebauten OCI-Images

Die Open Container Initiative (OCI) standardisiert sowohl das Format für Container-Images als auch die Container-Runtime, die für die Instanzisierung von Containern verantwortlich ist und diese Images konsumiert. Im Rahmen dieses Artikels wird neben den Buildsystemen in erster Linie auf das Paketformat eingegangen.

 

Die zuvor vorgestellte Beispielapplikation kann mit dem dargestellten Dockerfile wie folgt gebaut werden.

 

$ docker build -t weather:scratch -f Dockerfile .
[1/2] STEP 1/4: FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb
Trying to pull docker.io/library/golang@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb…
Getting image source signatures
Copying blob 4f4fb700ef54 done 
Copying blob 7c9d4a4eea0d done 
Copying blob 2d35ebdb57d9 done 
Copying blob ae96bccb6682 done 
Copying blob 8005175e490b done 
Copying config f86e735e7a done 
Writing manifest to image destination
Storing signatures
[1/2] STEP 2/4: RUN apk –no-cache add gcc musl-dev sqlite-dev
fetch https://dl-cdn.alpinelinux.org/alpine/v3.22/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.22/community/x86_64/APKINDEX.tar.gz
(1/20) Installing libgcc (14.2.0-r6)
(2/20) Installing jansson (2.14.1-r0)
(3/20) Installing libstdc++ (14.2.0-r6)
(4/20) Installing zstd-libs (1.5.7-r0)
(5/20) Installing binutils (2.44-r3)
(6/20) Installing libgomp (14.2.0-r6)
(7/20) Installing libatomic (14.2.0-r6)
(8/20) Installing gmp (6.3.0-r3)
(9/20) Installing isl26 (0.26-r1)
(10/20) Installing mpfr4 (4.2.1_p1-r0)
(11/20) Installing mpc1 (1.3.1-r1)
(12/20) Installing gcc (14.2.0-r6)
(13/20) Installing musl-dev (1.2.5-r10)
(14/20) Installing ncurses-terminfo-base (6.5_p20250503-r0)
(15/20) Installing libncursesw (6.5_p20250503-r0)
(16/20) Installing readline (8.2.13-r1)
(17/20) Installing sqlite (3.49.2-r1)
(18/20) Installing pkgconf (2.4.3-r0)
(19/20) Installing sqlite-libs (3.49.2-r1)
(20/20) Installing sqlite-dev (3.49.2-r1)
Executing busybox-1.37.0-r19.trigger
OK: 177 MiB in 37 packages
–> eb02a007a4e
[1/2] STEP 3/4: COPY . .
–> 7cb4f559946
[1/2] STEP 4/4: RUN CGO_ENABLED=1 go build
go: warning: ignoring go.mod in $GOPATH /go
go: downloading github.com/mattn/go-sqlite3 v1.14.32
–> 8261319434e
[2/2] STEP 1/5: FROM scratch
[2/2] STEP 2/5: COPY –from=0 /go/weather /bin/weather
–> aea77753066
[2/2] STEP 3/5: COPY –from=0 /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
–> ef45ee0de82
[2/2] STEP 4/5: COPY –from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
–> 32fed34343d
[2/2] STEP 5/5: ENTRYPOINT [„/bin/weather“]
[2/2] COMMIT weather:scratch
–> 424c16eeb73
Successfully tagged localhost/weather:scratch
424c16eeb73870d43fd25004a7627112de920d9cbdf1d79fcbe71fb929696634

 

Die Indikatoren [1/2] und [2/2] signalisieren die jeweilige Stufe des Multi-Stage-Builds, die ihrerseits aus mehreren Schritten STEP … bestehen.

 

Das gebaute Container-Image kann dann mit docker save –format=oci-archive weather:scratch im OCI-Archivformat exportiert werden, wobei sich ein Archiv mit folgender Struktur ergibt.

├── blobs
│   └── sha256
│       ├── 0741ec69c9de78719fc30489cf5c4eb54a165a3a05a8c3de6ec9a77ef0c3809f
│       ├── 424c16eeb73870d43fd25004a7627112de920d9cbdf1d79fcbe71fb929696634
│       ├── d2b42e0fbdc8ca03c79f260b710b60b3ac61e169c1c32199bcd2de655529f206
│       ├── d817b1249bdd001d9ec5f85133f4228068a72721d7c4a3b04628bce65698fdca
│       └── fa52ca554b4fa76574e93e09d5f420ac264414bf48159b465cb1301dac37ba67
├── index.json
└── oci-layout

Die Datei oci-layout spezifiziert die Version des zugrunde liegenden OCI-Imageformats, während in der Datei index.json die OCI-Manifeste für unterschiedliche CPU-Architekturen aufgelistet sind. In dieser Datei befindet sich auch der SHA256-Digest des Manifests, welches als Datei unter blobs/sha256/d817…fdca zu finden ist. Dieses Manifest listet zum einen die Konfiguration (blobs/sha256/424c…6634) und zum anderen die eigentlichen Schichten des OCI-Images, welche als gzip-komprimierte Tar-Archive unter blobs/sha256/0741…809f, blobs/sha256/d2b4…f206 und blobs/sha256/fa52…ba67 liegen. Dabei entspricht jedes dieser Archive einer COPY-Anweisung aus der finalen Stufe im Dockerfile.

 

Das dargestellte Format spiegelt prinzipiell die Datenhaltung von Systemen wider, die einen oder mehrere Container ausführen. Damit (neue) Container möglichst schnell gestartet werden können, gilt es so viele OCI-Image-Schichten wie möglich wiederzuverwenden, so dass effektiv nur noch nicht vorhandene Schichten übertragen werden müssen. Dies gilt vor allem für potenziell geteilte Abhängigkeiten wie eine libc, die über die Grenzen von OCI-Images hinweg idealerweise identisch sein sollte. Bei der Betrachtung des Beispiel-Dockerfiles wird allerdings deutlich, dass dies zwar grundsätzlich möglich ist, in der Praxis jedoch kaum erreichbar ist. Schon das alleinige Beziehen einer reproduzierbaren libc (2) ist aufgrund des Mangels an Reproduzierbarkeit von apk add herausfordernd. Erschwerend kommt hinzu, dass für ein minimales OCI-Image ein Multi-Stage-Build bemüht werden muss, welcher eine Indirektion für das Beziehen dieser Abhängigkeit (7) darstellt. Diese Schwierigkeit wächst, wenn ein wichtiges Sicherheitsupdate einer geteilten Abhängigkeit schnell und unkompliziert in eine Vielzahl von OCI-Images Einzug erhalten soll. Der imperative Ansatz eines Dockerfiles in Kombination mit der Abwesenheit einer wirkungsvollen Sandbox kann außerdem dazu führen, dass Inhalte auf unerwartete Weise verändert werden.

Zwischenfazit: Herausforderungen und Limitierungen von docker build

Als kurzes Zwischenfazit lässt sich bisher festhalten, dass docker build eine Reihe von Herausforderungen und Limitierungen mit sich bringt.

 

Es bedarf zumeist Multi-Stage-Builds und eine Vielzahl von manuellen und fehleranfälligen Schritten um minimale OCI-Images zu erzeugen. Das Bauen einer Applikation mittels eines Paketmanagers im Container als Teil des Containerbuildprozess ist in der Regel nicht reproduzierbar. Das Bereitstellen einer geteilten Abhängigkeiten über die Grenzen von OCI-Images hinweg ist zumeist nicht praktikabel. Die imperative Natur von Dockerfiles führt zu keiner stabilen topologischen Sortierung der Abhängigkeiten (Änderung der Reihenfolge von Anweisungen führt zu unterschiedlichen OCI-Images), was sich negativ auf die Reproduzierbarkeit auswirkt. Die während des Containerbuilds verwendete Sandbox ist sehr schwach und hat keine Limitierungen bezüglich Netzwerkzugriff, was eine Reihe von Sicherheitsimplikationen nach sich zieht.

Container Builds mit Nix als deklarative, reproduzierbare und sichere Alternative

Nix ist eine funktionale und domänenspezifische Sprache für den gleichnamigen Paketmanager. Dieser bedient sich nixpkgs einem großen Monorepositorium, welches als Sammlung von Gebrauchsfunktionen zur Paketierung dient und zugleich über 120.000 Pakete definiert. Unser letzter Blogpost beinhaltet eine ausführlichere Einleitung in Nix.

Statt eine Sequenz von Schritten zu definieren, geht Nix von der Definition des gewünschten Ergebnisses aus.

 

Statt eine Golang-Applikation wie folgt zu bauen

 

$ apk –no-cache add gcc musl-dev sqlite-dev
$ CGO_ENABLED=1 go build

 

bringt Nix sprachspezifische Builder für alle wesentlichen Sprachen mit. Der im Folgenden dargestellte Nix-Ausdruck repräsentiert eine Funktion, die eine sogenannte Derivation zurückgibt. Als Argumente für diese Funktion werden üblicherweise die Abhängigkeiten angegeben, in diesem Fall die nixpkgs mit dem Standardwert des entsprechenden Systems (1). Die Funktion gibt eine Derivation zurück, die durch buildGoModule abstrahiert wird (2). Als Argumente werden zum einen der Name des Pakets (3) und zum anderen dessen Version (4) spezifiziert. Als Quellcode kann das lokale Verzeichnis spezifiziert werden (5) und mit dem vendorHash wird signalisiert welche Prüfsumme das Verzeichnis aller Abhängigkeiten aufweist (6).

				
					{ pkgs ? import <nixpkgs> { } }:                                      # (1)
pkgs.buildGoModule {                                                  # (2)
  pname = "weather";                                                  # (3)
  version = "0.0.1";                                                  # (4)
  src = ./.;                                                          # (5)
  vendorHash = "sha256-Swi56SaPh4AN7LZ2a+j3p/jNf/InnbmE6AEErjqLg0g="; # (6)
};

				
			

Der deklarative Ansatz sowie das stringente Definieren von Abhängigkeiten führen zu einem reproduzierbaren Build einer Golang-Applikation. Da die Reihenfolge der Attribute keine Semantik hat, führt eine Änderunge von dieser zu gleichen Resultaten. Ebenfalls können für gleiche Abhängigkeiten (pkgs, src) auch identische Artefakte produziert werden. In der Regel finden Builds mit Nix in einer hermetisch abgeriegelten Sandbox statt, bei der weder Umgebungsvariablen noch ein Netzwerkstack zur Verfügung stehen. Für Derivations, die dennoch Netzwerkzugriff benötigen, wird dieser durch einen Hash geschützt (6), um Unveränderlichkeit über die Zeit sicherzustellen und missbräuchliche Änderungen von Netzwerkinhalten zu erkennen.

Der oben dargestellte Nix-Ausdruck kann mit nix build –file go.nix gebaut werden. Dies erzeugt einen Symlink result in den Nix-Store, in dem sämtliche Artefakte für Nix gehalten werden.

 

Diese Applikation kann nun sehr einfach in einen OCI-Container verpackt werden. Auch dieser Nix-Ausdruck ist eine Funktion, welche eine Derivation zurückgibt. Analog zum vorherigem Beispiel wird nixpkgs mit dem entsprechenden Standardwert als Argument übergeben (1). Zur Zentralisierung und Wiederverwendbarkeit wird eine Reihe von lokalen Variablen mit let … in definiert. Neben name (2) und version (3) ist dies ebenfalls die zuvor demonstrierte Paketierung der Golang-Applikation basierend auf buildGoModule unter dem Bezeichner bin (4). Im Unterschied zu dem vorherigen Beispiel wird für dieses Artefakt die version aus dem übergeordneten Scope vererbt (5) und der pname auf Basis des zuvor definierten name gesetzt (6). Der Quellcode (7) und vendorHash (8) werden analog zum vorherigen Beispiel gesetzt. Der abgebildete Nix-Ausdruck gibt eine Derivation zurück, die durch dockerTools.buildLayeredImage abstrahiert wird. Hier wird über inherit der name für diese Derivation aus dem höheren Scope vererbt (10). Mittels String-Interpolation wird der tag auf die version mit einem entsprechenden Präfix gesetzt (11). Da die Golang-Applikation eine potenzielle Abhängigkeit zu CA-Zertifikaten hat, wird das korrespondierende nixpkgs-Paket cacert explizit zu den contents des Container-Images hinzugefügt (12). Vollständig im Sinne eines deklarativen Ansatzes wird die eigentliche Applikation zum Container-Image hinzugefügt, indem der Entrypoint auf die Inhalte der zuvor definierten Golang-Applikation konfiguriert wird (13). Dies führt dazu, dass die als bin definierte Derivation implizit dem OCI-Image hinzugefügt wird.

				
					{ pkgs ? import <nixpkgs> { } }:                                        # (1)
let
  name = "weather";                                                     # (2)
  version = "0.0.1";                                                    # (3)
  bin = pkgs.buildGoModule {                                            # (4)
    inherit version;                                                    # (5)
    pname = name;                                                       # (6)
    src = ./.;                                                          # (7)
    vendorHash = "sha256-Swi56SaPh4AN7LZ2a+j3p/jNf/InnbmE6AEErjqLg0g="; # (8)
  };
in
pkgs.dockerTools.buildLayeredImage {                                    # (9)
  inherit name;                                                         # (10)
  tag = "v${version}";                                                  # (11)
  contents = with pkgs; [ cacert ];                                     # (12)
  config.Entrypoint = [ "${bin}/bin/weather" ];                         # (13)
}

				
			

Die zuvor demonstrierten Vorteile des Nix-basierten Ansatzes zum deklarativen und reproduzierbaren Bauen von sicheren Golang-Applikationen, können mit allen ihren Vorteilen mittels dockerTools.buildLayeredImage unmittelbar auf OCI-Images übertragen werden. Nennenswert ist hier darüber hinaus, dass geteilte Abhängigkeiten wie CA-Zertifikate oder eine libc sehr kontrolliert zwischen unterschiedlichen OCI-Images wiederverwendet werden können.

Wird beispielsweise ein zweiter Container für Nginx als Reverse-Proxy für die Webapplikation benötigt, kann diese wie folgt definiert werden.

				
					{ pkgs ? import <nixpkgs> { } }:
pkgs.dockerTools.buildLayeredImage {
  name = "nginx";
  tag = "v${pkgs.nginx.version}";
  config.Entrypoint = [ "${pkgs.nginx}/bin/nginx" "-g" "daemon off;" ];
}

				
			

Wenn dieser Nix-Ausdruck mit der gleichen Version von pkgs wie die ursprüngliche Webapplikation gebaut wird, ist sichergestellt, dass für beide die selbe libc-Abhängigkeit verwendet wird. Das führt dazu, dass diese Abhängigkeit sich als Schicht im OCI-Image manifestiert. Somit können beide OCI-Container potenziell schneller bezogen und gestartet werden, weil die Schicht des OCI-Images für libc bereits vorhanden ist. Damit erzeugt dockerTools.buildLayeredImage nicht nur minimale OCI-Images, sondern bietet ohne zusätzlichen Aufwand perfekte Wiederbenutzbarkeit von geteilten Abhängigkeiten über die Grenzen von OCI-Artefakten hinweg.

Vergleich von unterschiedlichen Containerisierungen

Nachdem die unterschiedlichen Ansätze bezüglich ihrer qualitativen Eigenschaften betrachtet wurden, soll nun auch eine quantitative Betrachtung der jeweiligen Ansätze durchgeführt werden. Das folgende gestapelte Balkendiagramm zeigt die Größe von individuellen Schichten für das OCI-Image der jeweiligen Containerisierung an. Dabei entspricht die Summe aller Schichten der Gesamtgröße des OCI-Container-Images, wobei geringere Größen besser sind.

Ein Multi-Stage-Build auf Basis von golang:1.25.4-trixie (Debian) für das Build-Image und gcr.io/distroless/base-debian12 als Runtime-Image erzeugt ein OCI-Image mit der Größe von 15,93 MiB. Fast die Hälfte der Gesamtgröße macht die Applikation aus (7,54 MiB) und die in Debian verwendete glibc schlägt mit 926,1 KiB zu Buche. Die CA-Zertifikate sind mit 130,6 KiB vergleichsweise schlank, während sich sonstige Inhalte im OCI-Image auf insgesamt moderate 7,33 MiB belaufen.

 

Der zweite Containerisierungsansatz erzeugt mittels eines Multi-Stage-Builds ein OCI-Image auf Basis von golang:1.25.4-alpine als Build-Image und alpine als Runtime-Image. Ein Großteil dieses OCI-Images macht hier die Applikation aus (7,55 MiB) und die in Alpine verwendete musl libc ist mit 402,7 KiB deutlich schlanker als das Gegenstück des vorherigen Ansatzes. Die Größe der CA-Zertifikate umfasst 124,6 KiB während die restliche Größe der Schichten des OCI-Images auf immerhin 3,25 MiB summieren.

 

Der dritte Ansatz spiegelt die Containerisierung des Dockerfiles in diesem Artikel wider: In einem Multi-Stage-Build wird mit golang:1.25.4-alpine als Build-Image und alpine als Runtime-Image ein OCI-Image erzeugt. Da das Build-Image identisch zum vorherigen Ansatz ist, ist die erzeugte Applikation ebenfalls 7,55 MiB groß und macht ganz klar den Großteil der Gesamtgröße des OCI-Images aus. Es kommen eine vergleichbare musl libc sowie CA-Zertifikate zum Einsatz, die 419,5 KiB beziehungsweise 125,8 KiB groß sind. In diesem OCI-Image sind keine weiteren Schichten enthalten, neben dem Manifest und der Konfiguration selber, die sich lediglich auf 1,8 KiB summieren.

 

Der vierte und letzte Ansatz entspricht der in diesem Artikel demonstrierten Containerisierung auf Basis von Nix. Erstaunlicherweise ist die Applikation mit 3,61 MiB weniger als halb so groß wie bei den vorherigen Ansätzen, die auf Debian beziehungsweise Alpine basieren. Die Größe der musl libc sowie der CA-Zertifikate ist mit 1,19 MiB beziehungswiese 292,8 KiB deutlich größer als die jeweiligen Gegenstücke der vorherigen Ansätze. Die Größe der restlich Schichten des OCI-Images summiert sich auf 672,1 KiB.

 

Die Größe der Applikation ist für die drei zuerst dargestellten Build-Ansätze mit Docker auf Basis von Debian und Alpine fast gleich. Die Unterschiede dieser drei Ansätze ergeben sich im Wesentlichen durch das durch den Multi-Stage-Build verwendete Runtime-Image. Während die Größe der CA-Zertifikate eine gleiche Größenordnung haben, ist die glibc in Distroless signifikant größer als die in Alpine und Scratch verwendete musl libc. Distroless bringt darüber hinaus in der Gesamtgröße die meisten Schichten des OCI-Images mit, die nicht unmittelbar als Abhängigkeiten durch die Applikation benötigt werden. Da der Ansatz auf Basis von Alpine ein vollständiges Userland auf Basis von BusyBox integriert, existiert auch hier ein nennenswerten Anteil an ungenutzten Schichten im OCI-Image, der interessanterweise deutlich kleiner als der von Distroless ist. Der im Artikel demonstrierte Multi-Stage-Build mit Scratch als Runtime-Image hat per Definition keine ungenutzten Abhängigkeiten im OCI-Image.

 

Überraschenderweise ist die Größe des OCI-Images des Nix-basierte Ansatzes nochmals signifikant kleiner als Scratch, welches intuitiverweise als untere Größenschranke vermutet wird. Obwohl sowohl die verwendete libc sowie die CA-Zertifikate größer als die Gegenstücke der anderen Ansätze ist, macht die signifikant reduzierte Größe der Applikation diesen Unterschied wett. Inklusive nicht unmittelbar genutzter Schichten im OCI-Images ist der Ansatz auf Basis von Nix fast ein Drittel so klein wie Distroless, mehr als halb so klein wie Alpine und sogar über 28% kleiner als Scratch.

Fazit

Die Gegenüberstellung der verschiedenen Containerisierungsansätze zeigt deutlich, dass es heute nicht mehr ausreicht, nur auf möglichst kleine Images zu optimieren. Multi-Stage-Builds mit Docker ermöglichen zwar kompakte Runtime-Images und bilden nach wie vor eine sinnvolle Baseline, sind aber in ihrer Natur imperativ, schwer reproduzierbar und abhängig von Build-Zeit-Networking und dem Zustand externer Paketquellen. Die Folge sind fragile Builds, schwer nachvollziehbare Abhängigkeitsbäume und eine eingeschränkte Aussagekraft hinsichtlich der tatsächlichen Software-Lieferkette.

 

Nix adressiert genau diese Schwächen, ohne beim Ergebnis Kompromisse eingehen zu müssen. Durch deklarative Definitionen, hermetische Sandboxen und explizite Modellierung aller Abhängigkeiten werden Builds deterministisch und langfristig reproduzierbar. Mit dockerTools.buildLayeredImage lassen sich diese Eigenschaften nahtlos auf OCI-Images übertragen, inklusive kontrollierter Wiederverwendung gemeinsamer Laufzeitkomponenten wie libc oder CA-Zertifikaten. Die quantitative Analyse der Imagegrößen unterstreicht, dass dieser Ansatz nicht nur konzeptionell überzeugt, sondern auch praktisch: Der Nix-basierte Container ist signifikant kleiner als die Docker-Varianten auf Basis von Distroless, Alpine und sogar Scratch.

 

Damit erweist sich Nix als robuste Grundlage für Teams, die Container-Builds nicht mehr als undurchsichtigen, imperativen Nebenprozess betrachten wollen, sondern als zentralen Bestandteil einer nachvollziehbaren, sicheren und auditierbaren Software-Lieferkette. Wer Wert auf deterministische Builds, gute Wiederverwendbarkeit von Schichten und transparente Abhängigkeiten legt, findet in Nix und dockerTools eine zeitgemäße Alternative zu klassischen Docker-basierten Workflows.

Am 9. November 2025 hat Arik Grahl auf der ContainerDays Conference in Hamburg in seinem Talk Kubenix: Declare Your K8s Workloads Fully Declarative gezeigt, wie sich Kubernetes-Workloads mit Nix und Kubenix durchgängig deklarativ, reproduzierbar und ohne YAML-Templating definieren lassen.


Aufbauend auf diesen Ideen vertieft er am 12. Februar 2026 auf den ContainerDays London in seinem Vortrag Beyond Docker Builds: Declarative, Reproducible and Secure OCI Containers with Nix die hier vorgestellten Konzepte rund um deterministische OCI-Container-Builds, geteilte Abhängigkeiten und Software-Supply-Chain-Sicherheit.

Share: