Skip to main content

Best Practices für Go-Anwendungen in Docker-Containern

Artikel von
Headshot of Jekayin-Oluwa Olabemiwo

Jekayin-Oluwa Olabemiwo

blog feature docker labels

23. März 2022

0 Min. Lesezeit

Go-Anwendungen und Container passen perfekt zusammen. Die kleinen Binärdateien von Go-Anwendungen eignen sich ideal für die Microservices-Bereitstellungsstrategien, die Docker und Kubernetes besonders gut unterstützen. Diese Synergie bringt jedoch auch Herausforderungen mit sich. Daher ist es wichtig, Best Practices für Container und zentrale Konzepte zu verstehen, um Sicherheitsrisiken zu vermeiden, die sich leicht in Ihre Container-Images einschleichen können.

In diesem Artikel programmieren Sie eine beispielhafte Go-Anwendung und erfahren, wie Sie sie am besten containerisieren und sicher ausführen.

Voraussetzungen

Für dieses Tutorial müssen Docker und Go auf Ihrem Rechner installiert sein. Außerdem sollten Sie mit beiden grundlegende Erfahrung haben. Eine Einführung und Downloads für Docker finden Sie auf der Seite Orientierung und Einrichtung in der Docker-Dokumentation. Gute Go-Tutorials finden Sie in der offiziellen Go-Dokumentation oder direkt auf der Seite zur Go-Installation für Linux.

Eine beispielhafte Go-Anwendung erstellen

Erstellen wir zunächst unsere Go-API. Wechseln Sie dazu in das Verzeichnis, in dem Ihre Go-Anwendung auf Ihrem Rechner gespeichert werden soll. Erstellen Sie anschließend ein Verzeichnis mit dem Namen „godocker“. Führen Sie in diesem Verzeichnis godocker/ den folgenden Befehl aus, um Ihr Go-Modul zu definieren:

go mod init godocker

Erstellen Sie als Nächstes im Verzeichnis „godocker“ eine Datei namens „main.go“. Diese Datei enthält Ihren API-Code. Nun können Sie Logik hinzufügen, um über die API die aktuelle Uhrzeit bereitzustellen und zu verarbeiten.

Geben Sie den folgenden Code in godocker/main.go ein:

package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
)

type Time struct {
CurrentTime string `json:"current_time"`
}

func main() {
// defining router
mux := http.NewServeMux()
mux.HandleFunc("/time", getTime)

// starting server
fmt.Println("Server is running at 127.0.0.1:8080")
log.Fatal(http.ListenAndServe( "localhost:8080", mux))
}

func getTime(w http.ResponseWriter, r *http.Request) {
currentTime := []Time{
    { CurrentTime: http.TimeFormat },
}

json.NewEncoder(w).Encode(currentTime)
}

Dieser Code stellt die aktuelle Uhrzeit über die API bereit und verwendet dazu die Variable TimeFormat des http-Pakets. Anschließend definieren wir die Bibliothek NewServeMux als mux, um den HTTP-Server zu konfigurieren. Schließlich registrieren wir den Endpunkt /time, damit die API die aktuelle Uhrzeit zurückgibt und die Antwort am Endpunkt /time als JSON codiert.

go mod init godocker

Führen wir einen kurzen Test durch. Starten Sie die Anwendung mit dem folgenden Befehl in der Shell:

go run main.go

Die Ausgabe sollte ungefähr so aussehen:

Server is running at 127.0.0.1:8080

Testen wir nun die API in einem anderen Terminalfenster mit cURL. Geben Sie im Terminal den folgenden Befehl ein:

curl http://127.0.0.1:8080/time

Die Ausgabe sollte so aussehen:

[{"current_time":"Mon, 02 Jan 2006 15:04:05 GMT"}]

Die Dockerfile vorbereiten

Eine Dockerfile enthält eine Reihe von Anweisungen, mit denen eine Anwendung als Container gepackt und bereitgestellt wird. In diesem Abschnitt erstellen wir eine Dockerfile und sehen uns einige Anweisungen an, mit denen wir unsere beispielhafte Anwendung als Container packen können.

Die Docker-Syntaxversion angeben

Zunächst fügen wir die Syntax-Direktive hinzu. Erstellen Sie im Verzeichnis godocker/ eine neue Datei namens „Dockerfile“ und geben Sie in der ersten Zeile den folgenden Code ein:

# syntax=docker/dockerfile:1

Die Syntax-Direktive gibt den Speicherort der Dockerfile-Syntax an, die wir zum Erstellen unserer Dockerfile verwenden. Diese Codezeile legt den Speicherort der Dockerfile-Syntax als docker/dockerfile:1 fest – die neueste veröffentlichte Syntaxversion. Docker prüft die Syntaxversion, bevor es das Buildkit-Backend zum Erstellen der Dockerfile verwendet.

Achten Sie darauf, dass diese Zeile am Anfang der Datei auskommentiert ist. Nach der Syntax-Direktive folgt üblicherweise eine Leerzeile.

Ein Basis-Image mit geringem Speicherbedarf übernehmen

Als Nächstes geben wir mit der FROM-Anweisung das Docker-Basis-Image an, das wir übernehmen möchten.

Fügen Sie den folgenden Code zu godocker/Dockerfil` hinzu:

FROM golang:1.17-alpine

Mit dieser Anweisung müssen wir kein eigenes Docker-Basis-Image erstellen. Stattdessen übernehmen wir das offizielle Docker-Image für Go-Anwendungen in der Alpine-Linux-Variante. Die Go-Version des Basis-Images ist 1.17. Das alpine-Image ist im Vergleich zu einer Variante wie dem ubuntu-Image sehr klein.

Nun ergänzen wir den Rest der Dockerfile und fügen Kommentare in den Code ein, um den Zweck der einzelnen Zeilen kurz zu erläutern.

Aktualisieren Sie godocker/Dockerfile, sodass die Datei den folgenden Code enthält:

# syntax=docker/dockerfile:1

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

# tells Docker that the container listens on specified network ports at runtime
EXPOSE 8080

# command to be used to execute when the image is used to start a container
CMD [ "/godocker" ]

Das Image erstellen

Nachdem unsere Dockerfile fertig ist, erstellen wir mit dem Befehl docker build daraus unser Docker-Image. Docker verwendet den Docker-Daemon, um Images zu erstellen. Mit der Option --tag – abgekürzt -t – legen wir mit dem Befehl docker build einen benutzerdefinierten Namen für unser Docker-Image fest.

Geben Sie den folgenden Befehl im Terminal ein:

docker build --tag godocker .

Wir hängen . an den Befehl docker build an, damit das Image im aktuellen Verzeichnis erstellt wird, das als Build-Kontext dient. Verwenden Sie für den Build-Kontext nicht den Pfad /, da dabei der gesamte Quellcode an den Docker-Daemon übertragen werden kann.

Ihre Build-Ausgabe sollte die Zeile FINISHED enthalten und ungefähr so aussehen:

[+] Building 6.8s (17/17) FINISHED                                                            
...
 => => writing image sha256:539bdb3e661f66d489467ef217e1b46786de9cf3c29dc9a2dd6b4e9fa763  0.0s
 => => naming to docker.io/library/godocker

Diese Ausgabe bedeutet, dass das Docker-Image vollständig erstellt und mit dem Tag godocker versehen wurde.

Geben Sie den folgenden Befehl im Terminal ein, um die Liste der lokalen Images anzuzeigen:

docker image ls

Ihre Ausgabe sollte ungefähr so aussehen:

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                latest     539bdb3e661f    2 minutes ago   319MB
docker/getting-started  latest     720f449e5af2    1 hour ago      27.2MB

Das neu erstellte godocker-Image in unserer Ausgabe ist 319 MB groß. Für eine einfache API-Anwendung ist das viel. Daher müssen wir den Build optimieren und ein schlankeres Image erstellen. Im nächsten Abschnitt verwenden wir Multi-Stage-Builds, um einen schlanken Build zu erzielen.

Multi-Stage-Builds verwenden

Mit Multi-Stage-Builds lassen sich deutlich kleinere Images erstellen als mit dem Single-Stage-Ansatz aus dem vorherigen Abschnitt. Bei einem Multi-Stage-Build wird ein Image verwendet, um einzelne Bestandteile zu erstellen. Diese werden in ein kleineres Image gepackt, das nur die für ihre Ausführung notwendigen Komponenten enthält. Indem wir unsere Images auf das für die Anwendung erforderliche Minimum reduzieren, können wir das Risiko von Sicherheitslücken verringern. Dazu verwenden wir mehrere FROM-Anweisungen in unserer Dockerfile.

Das offizielle Scratch-Image verwenden

Wir können unseren Build mit einem leeren Image beginnen, indem wir das scratch-Image des offiziellen Docker-Images übernehmen. In diesem Abschnitt zeigen wir, wie Sie das Image scratch für Multi-Stage-Builds verwenden.

Wechseln Sie zunächst in das Stammverzeichnis Ihrer Anwendung. Erstellen Sie dann eine Datei namens „Dockerfile.multistage“ und geben Sie den folgenden Code ein:

# syntax=docker/dockerfile:1

##
## STEP 1 - BUILD
##

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine AS build

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

EXPOSE 8080

ENTRYPOINT ["/godocker"]

Dieser Code gibt das Basis-Image an, das vom offiziellen Image golang:1.17-alpine übernommen wird, und benennt die Stage build. Anschließend verwenden wir eine weitere FROM-Anweisung, um das Multi-Stage-Konzept umzusetzen: Dabei kopieren wir die erstellte Binärdatei aus der ersten Stage in das leere Image der zweiten Stage.

Als Nächstes müssen wir mit der neuen Datei Dockerfile.multistage ein neues Image erstellen. Außerdem müssen wir dem neuen Image den Tag „multistage“ geben. So können wir es von dem zuvor erstellten Image unterscheiden.

Geben Sie den folgenden Befehl im Terminal ein:

docker build -t godocker:multistage -f Dockerfile.multistage .

Prüfen Sie nach dem erfolgreichen Build die Image-Liste, indem Sie den folgenden Befehl im Terminal eingeben:

docker image ls

Ihre Ausgabe sollte ungefähr so aussehen:

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                multistage 192cc137f88b    9 seconds ago   6.18MB
godocker                latest     539bdb3e661f    1 hour ago      319MB

Diese Ausgabe zeigt den deutlichen Größenunterschied zwischen den Images godocker:multistage und godocker:latest. Das Single-Stage-Image wurde von 319 MB auf 6,1 MB beim Multi-Stage-Image verkleinert – eine klare Verbesserung. Da Container schnell gestartet werden müssen, sind solche Optimierungen beim Containerisieren von Go-Anwendungen entscheidend.

Den Container bereitstellen

Neben der Optimierung von Leistung und Effizienz müssen wir auch überlegen, wie wir unsere Container am besten bereitstellen, damit sie sicher ausgeführt werden. In dieser Phase des Tutorials setzen wir einige Best Practices um.

Als Nicht-Root-Benutzer ausführen

Das Prinzip der geringsten Berechtigungen erfordert, dass wir den Zugriff auf Systemressourcen begrenzen. Unsere Go-Docker-Container sind Anwendungscontainer und müssen nicht mit Root-Rechten ausgeführt werden. Daher sollten wir zur Erhöhung der Sicherheit in unserer Dockerfile einen neuen Benutzer und eine Gruppe mit eingeschränktem Zugriff erstellen.

Um einen Nicht-Root-Benutzer zu erstellen, fügen Sie die folgenden Zeilen direkt nach der ersten FROM-Anweisung in godocker/Dockerfile.multistage ein:

RUN useradd -u 1001 -m iamuser

Mit dieser Anweisung werden die Argumente USERNAME und PASSWORD mit dem Schlüsselwort ARG festgelegt. Anschließend wird mit der Anweisung RUN adduser ein Benutzer erstellt.

Als Nächstes fügen wir Anweisungen hinzu, um die Benutzerangaben aus der ersten Stage zu kopieren und in der zweiten Stage anzuwenden.

Fügen Sie den fett gedruckten Code im folgenden Beispiel zur zweiten Stage in godocker/Dockerfile.multistage hinzu. Die Datei sollte dann mit dem folgenden Code enden:

...
##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

COPY --from=build /etc/passwd /etc/passwd

USER 1001

EXPOSE 8080

ENTRYPOINT ["/godocker"]

In Kubernetes können Sie runAsuser: UID im Feld securityContext angeben. In der Kubernetes-Dokumentation erfahren Sie, wie Sie den Sicherheitskontext für einen Pod festlegen.

Ein schreibgeschütztes Root-Dateisystem verwenden

Eine weitere Möglichkeit, die Sicherheit unserer Anwendung zu erhöhen, besteht darin, den Container mit einem schreibgeschützten Dateisystem auszuführen. Dazu übergeben wir dem Befehl docker run das Flag für den schreibgeschützten Zugriff.

Geben Sie den folgenden Befehl im Terminal ein:

docker run -read-only godocker

Linux-Capabilities entfernen oder verweigern

Linux-Capabilities sind Berechtigungen, die wir unter Linux aktivieren oder deaktivieren können. Da unser Container auf einer Linux-Variante basiert, können wir diese Capabilities ebenfalls nutzen, um die Sicherheit unserer Anwendung zu erhöhen. Das Entfernen bestimmter Capabilities verringert das Risiko für unseren Container.

Entfernen wir in diesem Tutorial alle Capabilities außer setuid.

Geben Sie den folgenden Befehl im Terminal ein:

docker run --cap-drop=all --cap-add=setuid

CPU- und Speichernutzung begrenzen

Mit dem Befehl docker run können wir die Ressourcennutzung unseres Docker-Containers auf dem Host-Rechner begrenzen. Dazu verwendet Docker das Flag --cpus.

Um beispielsweise zu verhindern, dass der Container mehr als 50 % einer einzelnen CPU nutzt, geben Sie den folgenden Befehl in der Docker-CLI ein:

docker run -it --cpus=".5" alpine /bin/bash

Wenn Sie zwei CPUs verwenden möchten, können Sie die Nutzung mit dem folgenden Befehl in der Docker-CLI begrenzen:

docker run -it --cpus=2 alpine /bin/bash

Wenn Sie die Speichernutzung eines Docker-Containers auf 1.024 MB begrenzen möchten, können Sie den Befehl docker run wie folgt verwenden:

docker run -m 1024m --memory-reservation=256m alpine /bin/bash

Mit diesem Befehl wird außerdem ein Speicherlimit von 256 MB festgelegt, das greift, wenn Docker erkennt, dass auf dem Host nur noch wenig Speicher verfügbar ist.

Fazit

In diesem Artikel haben wir eine Go-Anwendung eingerichtet und mit Docker containerisiert. Außerdem haben wir Multi-Stage-Builds zur Leistungsoptimierung eingesetzt, mit einem leeren offiziellen Docker-Image ein Image von Grund auf erstellt und einige Best Practices besprochen, die beim Erstellen und Bereitstellen von Containern hilfreich sein können. Diese Richtlinien bieten einen Einstieg in die effiziente und sichere Containerisierung von Go-Webanwendungen mit Docker sowie in ein effektives Speichermanagement.

Weitere Best Practices für Sicherheit finden Sie im Ressourcen-Center Snyk Learn.