Ihr Dockerfile ist wahrscheinlich zu groß für die Produktion
Sie haben gerade Ihre Anwendung fertiggestellt. Sie kompiliert, läuft lokal und Sie sind bereit, sie auszuliefern. Sie schreiben ein Dockerfile, bauen ein Image, pushen es in eine Registry und deployen es auf einen Server. Das Deployment funktioniert, aber etwas fühlt sich falsch an. Das Image ist 1,2 GB groß. Das Herunterladen auf dem Server dauert drei Minuten. Die Speicherkosten steigen. Und irgendwo im Hinterkopf fragen Sie sich: Brauche ich wirklich ein vollständiges Betriebssystem mit Paketmanager und Shell, nur um mein Go-Binary auszuführen?
Dies ist der Moment, in dem die meisten Teams erkennen, dass das Erstellen eines Images und das Erstellen eines für die Auslieferung geeigneten Images zwei verschiedene Dinge sind. Das Dockerfile, das Sie für lokale Tests schreiben, ist nicht dasselbe Dockerfile, das Sie in der Produktion verwenden sollten. Wenn Ihr Image groß ist, langsam zu bauen oder voller unnötiger Tools ist, leidet Ihre gesamte Pipeline. Jeder Pull dauert länger. Jeder Build verschwendet Zeit. Jeder Vulnerability-Scanner findet mehr Probleme als nötig.
Die gute Nachricht ist, dass die Behebung dieses Problems keine vollständige Neuimplementierung Ihrer Infrastruktur erfordert. Es erfordert das Verständnis von drei Dingen: wie Dockerfiles tatsächlich funktionieren, wie Images klein gehalten werden und wie Builds reproduzierbar gemacht werden.
Wie aus einem Dockerfile ein Image wird
Ein Dockerfile ist eine Textdatei mit Anweisungen. Jede Anweisung erzeugt eine Schicht (Layer). Wenn Sie das Image neu bauen, überprüft Docker, welche Schichten sich nicht geändert haben, und verwendet sie aus dem Cache wieder. Deshalb ist der zweite Build schneller als der erste: Unveränderte Schichten werden nicht neu gebaut.
Das Problem ist, dass die meisten Leute Dockerfiles schreiben, als ob sie ein Setup-Skript schreiben würden. Sie installieren alles, kopieren alles und stellen erst später fest, dass das endgültige Image Compiler, Debug-Tools und Systemdienstprogramme enthält, die nichts mit dem Ausführen der Anwendung zu tun haben. Jedes zusätzliche Tool im Image ist zusätzliches Gewicht beim Pull, zusätzliche Angriffsfläche für Schwachstellen und zusätzliche Komplexität, wenn Sie versuchen zu verstehen, was sich tatsächlich im Image befindet.
Die Lösung besteht nicht darin, weniger Code zu schreiben. Es geht darum, die Build-Umgebung von der Laufzeitumgebung zu trennen.
Multi-Stage-Builds: Das mit Abstand wichtigste Muster
Multi-Stage-Builds ermöglichen es Ihnen, mehrere Stufen in einem Dockerfile zu definieren. Die erste Stufe enthält alles, was zum Kompilieren der Anwendung benötigt wird: das vollständige SDK, Compiler, Build-Tools und Abhängigkeiten. Die zweite Stufe enthält nur das, was zum Ausführen des kompilierten Artefakts benötigt wird: das Binary, Laufzeitbibliotheken und sonst nichts.
So sieht das für eine Go-Anwendung aus:
Das folgende Diagramm veranschaulicht, wie die beiden Stufen zusammenarbeiten:
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp .
# Stage 2: Run
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
Die erste Stufe verwendet das vollständige Go-SDK. Die zweite Stufe verwendet ein minimales Alpine-Image, das nur das Nötigste enthält. Das Go-SDK, der Quellcode und der Build-Cache gelangen nie in das endgültige Image. Das Ergebnis ist ein Image, das oft 20 bis 30 Megabyte groß ist, anstatt über ein Gigabyte.
Das gleiche Muster gilt für jede kompilierte Sprache. Für interpretierte Sprachen wie Python oder Node.js können Sie Multi-Stage-Builds verwenden, um Abhängigkeiten in einer Stufe zu installieren und nur die installierten Pakete in die endgültige Stufe zu kopieren, wobei der Paketmanager-Cache und die Build-Tools zurückgelassen werden.
Sicherheit beginnt damit, was Sie weglassen
Jedes Tool in Ihrem Image ist eine potenzielle Schwachstelle. Eine Shell wie Bash oder ein Paketmanager wie apt mögen harmlos erscheinen, aber wenn ein Angreifer Zugriff auf Ihren Container erhält, werden diese Tools zu Waffen. Sie ermöglichen es dem Angreifer, neue Software zu installieren, Payloads herunterzuladen oder zu anderen Systemen zu wechseln.
Das Prinzip ist einfach: Fügen Sie nichts hinzu, was nicht zum Ausführen der Anwendung erforderlich ist. Wenn Sie gelegentlich einen laufenden Container debuggen müssen, behalten Sie keine Debugging-Tools im Produktionsimage. Erstellen Sie stattdessen ein separates Debugging-Image oder verwenden Sie ephemere Container, die die erforderlichen Tools zur Laufzeit bereitstellen.
Für Produktionsimages sollten Sie die Verwendung von Distroless-Basisimages in Betracht ziehen. Diese Images enthalten nur die Laufzeit und Bibliotheken, die Ihre Anwendung benötigt. Keine Shell, kein Paketmanager, keine Dienstprogramme. Google unterhält Distroless-Images für mehrere Sprachen, darunter Go, Python, Java und Node.js. Sie sind klein, minimal und reduzieren die Angriffsfläche erheblich.
Wenn Distroless zu restriktiv erscheint, ist Alpine eine vernünftige Alternative. Es ist klein und enthält eine Shell, verwendet aber musl libc anstelle von glibc, was bei einigen Anwendungen zu Kompatibilitätsproblemen führen kann. Testen Sie Ihre Anwendung auf Alpine, bevor Sie sich darauf festlegen.
Reproduzierbarkeit ist keine Option
Ein Image, das in sechs Monaten nicht identisch neu gebaut werden kann, ist eine Verbindlichkeit. Die häufigste Ursache für nicht reproduzierbare Builds ist die Verwendung des Tags latest für Basisimages. latest ändert sich, sobald der Maintainer eine neue Version veröffentlicht. Der heutige Build verwendet möglicherweise Go 1.22, aber der Build im nächsten Monat könnte Go 1.23 verwenden, und Sie werden es nicht wissen, bis etwas kaputt geht.
Pinnen Sie Ihr Basisimage immer auf eine bestimmte Version oder, noch besser, auf einen Digest-Hash. Ein Digest-Hash ist die kryptografische Prüfsumme des Image-Inhalts. Er ändert sich nie. Wenn Sie golang:1.22-alpine@sha256:abc123... verwenden, erhalten Sie garantiert jedes Mal das exakt gleiche Basisimage, unabhängig davon, wann oder wo Sie bauen.
FROM golang:1.22-alpine@sha256:abc123def456 AS builder
Dies gilt für jedes Image, das Sie pullen, einschließlich Zwischenimages, die in Multi-Stage-Builds verwendet werden. Wenn Sie den Digest für eine bestimmte Version nicht finden können, verwenden Sie das spezifischste verfügbare Tag, wie golang:1.22.0-alpine3.19 anstelle von golang:1.22-alpine.
Die Schichten-Reihenfolge beeinflusst die Build-Geschwindigkeit
Docker cached Schichten basierend auf der Reihenfolge der Anweisungen. Wenn Sie eine Datei ändern, die früh im Dockerfile kopiert wird, werden alle nachfolgenden Schichten ungültig und neu gebaut. Wenn Sie eine Datei ändern, die spät kopiert wird, werden nur die Schichten nach diesem Punkt neu gebaut.
Die praktische Regel lautet: Platzieren Sie Anweisungen, die sich selten ändern, oben und Anweisungen, die sich häufig ändern, unten.
- Installieren Sie zuerst Systemabhängigkeiten.
- Kopieren Sie Abhängigkeitsmanifeste (wie
go.modundgo.sum) und führen Sie den Schritt zum Herunterladen der Abhängigkeiten aus. - Kopieren Sie den Quellcode zuletzt.
Auf diese Weise wird der Schritt zur Abhängigkeitsinstallation aus dem Cache wiederverwendet, wenn Sie nur Ihren Quellcode ändern. Der Build ist schneller, weil Docker keine Pakete erneut herunterlädt, die sich nicht geändert haben.
Eine praktische Checkliste für Ihr Dockerfile
Bevor Sie Ihr nächstes Image in die Produktion pushen, gehen Sie diese Checkliste durch:
- Enthält das endgültige Image nur das, was zum Ausführen der Anwendung benötigt wird?
- Sind Build-Tools, SDKs und Quellcode aus der endgültigen Stufe ausgeschlossen?
- Ist das Basisimage auf eine bestimmte Version oder einen Digest gepinnt, nicht auf
latest? - Ist das Basisimage minimal (Distroless oder Alpine) anstelle eines vollständigen OS-Images?
- Sind selten geänderte Anweisungen vor häufig geänderten Anweisungen platziert?
- Befindet sich keine Shell oder kein Paketmanager im Produktionsimage, es sei denn, es ist absolut notwendig?
Was am wichtigsten ist
Ein Dockerfile ist nicht nur ein Build-Skript. Es ist ein Vertrag zwischen Ihrem Build-Prozess und Ihrer Produktionsumgebung. Ein gutes Dockerfile erzeugt ein Image, das klein, sicher und reproduzierbar ist. Ein schlechtes Dockerfile erzeugt ein Image, das langsam zu pullen, schwer zu debuggen und unmöglich mit Vertrauen neu zu bauen ist.
Wenn Sie das nächste Mal ein Dockerfile schreiben, beginnen Sie mit dem endgültigen Image im Hinterkopf. Fragen Sie sich: Was braucht diese Anwendung tatsächlich, um zu laufen? Lassen Sie dann alles andere weg. Ihre Pipeline wird schneller sein, Ihre Deployments werden reibungsloser verlaufen, und Ihr Sicherheitsteam wird eine Sache weniger haben, um die es sich sorgen muss.