1. Start als root – eine schlechte Baseline
Wenn Sie jemals eine Anwendung mit sudo gestartet haben, weil „es so schneller geht“, dann spüren Sie bereits, worauf wir hinauswollen. Das Starten eines Prozesses als root in einem Container ist ungefähr dasselbe, nur mit einem zusätzlichen Bonus: Sie tun das nicht nur einmal auf Ihrem Rechner, sondern machen es zu einer Vorlage, die dann andere Leute, andere Dienste und möglicherweise Ihr zukünftiges Ich wiederholen werden. Und Ihr zukünftiges Ich ist bekanntlich äußerst rachsüchtig.
Standardmäßig wird der erste Prozess in einem Container als root (uid=0) gestartet, und dies gilt als Standardverhalten von Docker. Dies wird in der Hilfe direkt beschrieben: Der standardmäßige Benutzer im Container ist root, und man kann ihn mit der Dockerfile-Anweisung USER oder dem Parameter -u bei docker run ändern. Das ist zu Beginn des Lernens praktisch: weniger „warum kann ich kein Verzeichnis erstellen“ und weniger Schmerz mit Berechtigungen. Aber in der endgültigen Vorlage wird diese „Bequemlichkeit“ zu einem Anti-Pattern, weil root der Anwendung mehr Rechte gibt, als sie für den normalen Betrieb benötigt.
Der praktische Sinn ist einfach und ohne Ideologie. In einem Container sollte Ihr Spring-Boot-Dienst HTTP-Anfragen entgegennehmen, die Konfiguration lesen und nur in vorher festgelegte beschreibbare Verzeichnisse schreiben (z. B. Export-Verzeichnis). Er muss nicht in der Lage sein, Systemdateien zu ändern, „nur für den Fall“ Benutzer zu erstellen oder überall zu schreiben. Je weniger Rechte ein Prozess hat, desto kleiner ist der „Schadensradius“, falls etwas schiefgeht. Und selbst wenn Sie den Dienst noch nicht in der Produktion einsetzen, überlebt die Gewohnheit „root ist normal“ leicht das Lernrepository und sickert in reale Projekte ein.
Es gibt noch ein weiteres sehr pragmatisches Argument. Root versteckt oft Probleme, die dann im ungünstigsten Moment auftauchen. Solange der Prozess root ist, kann er fast immer „eine Datei schreiben“, „ein Verzeichnis erstellen“, „etwas Seltsames öffnen“. Und wenn Sie auf non-root umsteigen (oder in eine Umgebung kommen, in der root durch Richtlinien verboten ist), stellt sich plötzlich heraus, dass Ihre Anwendung nur deshalb „lebendig“ war, weil sie einen Cheat-Code hatte. Heute ziehen wir diesen Cheat-Code zurück.
2. Build-Time und Runtime: unterschiedliche Benutzer
Nach dem Thema Multi-Stage-Builds sollten Sie bereits das Gefühl haben, dass ein Dockerfile nicht „ein einziger Container“ ist, sondern ein Drehbuch mit mehreren Akten. Im ersten Akt bauen wir das Artefakt: Gradle, Quellen, Abhängigkeiten, Build bootJar. Im zweiten Akt starten wir die Anwendung: Es bleiben nur die Runtime und das, was der Dienst wirklich braucht. Und hier kommt der wichtige Gedanke: Benutzer ist ein ebenso wichtiges Trennungselement wie JDK vs. JRE.
Der Builder-Stage lebt fast immer wie eine „Baustelle“. Dort laden wir Abhängigkeiten herunter, führen Gradle aus, entpacken das Jar in Schichten, und es ist wichtig, dass der Build reproduzierbar und nachvollziehbar ist. Der Runtime-Stage ist die „Wohnung, in die Sie später einziehen“. Dort sollten keine überflüssigen Werkzeuge sein, und dort sollte definitiv nicht sein: „Ich habe die Schlüssel zum ganzen Haus“.
Es ist praktisch, eine einfache Tabelle im Kopf zu behalten – nicht als Dogma, sondern als Kompass:
| Stage im Dockerfile | Typische Aktionen | Kann man root sein |
|---|---|---|
| builder | Build des Jars, Herunterladen von Abhängigkeiten, Entpacken der Schichten | normalerweise ja, das ist in Ordnung |
| runtime | Start von java -jar ... | nein, die endgültige Vorlage ist non-root |
Und noch eine Nuance, die Anfänger oft verwirrt: Die Anweisung USER beeinflusst nicht nur, wie der Container gestartet wird, sondern auch, wie die nachfolgenden Befehle im Dockerfile ausgeführt werden. Daher ist „USER appuser mitten in der Datei gesetzt“ keine Kosmetik, sondern ein Umschalten des Betriebsmodus des gesamten Skripts. Und wenn Sie USER zu früh setzen, wird das Dockerfile bei RUN mkdir ..., apt-get ... und anderen systemweiten Schritten fehlschlagen: non-root kann sie nicht mehr „standardmäßig“ ausführen.
3. Die Anweisung USER: Verhalten
Wenn Sie zum ersten Mal USER appuser in einem Dockerfile sehen, möchten Sie denken, dass dies ein „Sicherheitshäkchen“ ist: gesetzt – und alles ist richtig. Aber Docker funktioniert nicht nach dem Prinzip von „Häkchen“, sondern nach dem Prinzip „eine Zeile ändert das Verhalten der folgenden Zeilen“. Daher ist es nützlich, die Mechanik zu verstehen, sonst wird USER wie ein Zauberspruch wahrgenommen.
Fakt Nr. 1: USER legt den Standardbenutzer (und optional die Gruppe) für alle nachfolgenden Dockerfile-Anweisungen fest, die während des Builds im Image ausgeführt werden, und für den Prozess, der vom Container gestartet wird. Docker sagt direkt: Standardmäßig ist der Benutzer root, und die Dockerfile-Anweisung USER legt den Benutzer für den ersten Prozess im Container fest; beim Start des Containers kann dies mit docker run -u ... überschrieben werden.
Fakt Nr. 2: USER erstellt keinen Benutzer. Wenn es im Container keinen Eintrag für appuser gibt, wird USER appuser entweder während des Builds/Starts fehlschlagen oder zu sehr seltsamen Effekten führen. Daher erstellen wir zuerst den Benutzer mit RUN adduser ... oder RUN useradd ... (je nach Basis-Image) und dann erst – USER.
Fakt Nr. 3: USER betrifft genau die Prozessrechte. Es „schützt keine Geheimnisse“, „repariert nicht das Netzwerk“, „macht das Image nicht klein“. Es macht eine Sache: Der Prozess ist nicht mehr root, und das ändert alles, was Dateien, Ports (z. B. unter 1024), die Möglichkeit, in Verzeichnisse zu schreiben, usw. betrifft. Daher wird USER fast immer zum Wendepunkt: Das Image beginnt sich „ehrlich“ zu verhalten, nicht „wie es gerade kommt“.
WORKDIR /app # Arbeitsverzeichnis innerhalb des Images
RUN mkdir -p /app/tmp # Verzeichnis vor Benutzerwechsel erstellen, solange root-Rechte bestehen
USER appuser # Alle folgenden Anweisungen werden als appuser ausgeführt (auch der Containerprozess selbst)
RUN echo "hello" > /app/tmp/test.txt # Schlägt beim Build fehl, wenn appuser keine Schreibrechte hat
Wichtig ist, dass RUN echo ... bereits als appuser ausgeführt wird. Wenn appuser keine Rechte auf /app/tmp hat, wird das Dockerfile direkt beim Build fehlschlagen. Das ist ein normales Signal, nicht „Docker ist komisch“. Es führt das Skript endlich so aus, wie Sie es beschrieben haben.
4. Non-root-Benutzer: Name und UID
Wenn wir von „Start als non-root“ sprechen, tauchen bei Anfängern normalerweise zwei Fragen auf. Erste: „Okay, aber welcher Benutzer?“ Zweite: „Und warum schreiben alle um mich herum so seltsame Zahlen wie 10001?“ Beide Fragen sind berechtigt, denn in Containern ist der Benutzername oft nur eine praktische Bezeichnung, während die tatsächliche Identität im Dateisystem und bei Prozessen die UID ist.
Die UID ist eine numerische Benutzerkennung. In Linux ist dies kein „optionales Detail“, sondern wie das System letztlich versteht, wer der Eigentümer einer Datei ist und wer welche Rechte hat. Daher macht man es im Dockerfile oft so: Man gibt die UID über ARG vor, erstellt den Benutzer mit dieser UID und startet dann den Prozess als diesen Benutzer. Das sorgt für Vorhersagbarkeit: Sie sind nicht davon abhängig, welche Benutzer bereits im Basis-Image vorhanden waren und welche UID sie haben. Ein Beispiel für diesen Ansatz mit ARG UID=10001, adduser ... --uid "${UID}" und anschließend USER appuser findet man in typischen Dockerfile-Leitfäden zur Containerisierung von Java-Anwendungen.
Warum wird die UID oft nicht 1000, sondern 10001 oder etwas Ähnliches gewählt? Weil 1000 auf Linux-Hosts oft vom ersten „normalen“ Benutzer belegt ist, und Sie keine zufälligen Überschneidungen und unerwarteten Effekte möchten, insbesondere wenn Bind Mounts ins Spiel kommen. Außerdem wird eine „hohe UID“ als „Dienstbenutzer“ wahrgenommen, nicht als „Benutzer Wasja, der zufällig im Container gelandet ist“. Das ist keine Magie, sondern Disziplin.
# UID festlegen, um nicht davon abhängig zu sein, welche Benutzer/UIDs bereits im Basis-Image vorhanden sind
ARG UID=10001
RUN adduser --disabled-password --gecos "" \
--home "/nonexistent" --shell "/sbin/nologin" \
--no-create-home --uid "${UID}" appuser # Benutzer ohne Home-Verzeichnis und ohne interaktive Shell
USER appuser # Danach – Ausführung von Befehlen und Start des Containers als non-root
Der Sinn der Flags ist einfach. Wir erstellen einen Benutzer ohne Passwort, ohne Home-Verzeichnis (wir brauchen kein „echtes interaktives Login“) und mit einer Shell, die keinen Systemzugang vorsieht. Das ist keine „Sicherheit auf Maximum“, sondern eine normale Baseline: Der Prozess muss nur existieren und Java starten können.
Und ein wichtiger Punkt: Selbst wenn Sie die exec-Form von ENTRYPOINT verwenden (und Sie verwenden sie bereits, weil wir keine Signale und Graceful Shutdown verlieren wollen), hebt der Wechsel zu non-root diese Regel nicht auf. Im Gegenteil, ENTRYPOINT bleibt die stabile Startmethode, und USER ändert lediglich, wer diesen Prozess startet. Bezüglich Signale und PID 1 warnt Docker auch sehr deutlich: Die Shell-Form von ENTRYPOINT leitet Signale nicht korrekt weiter, die exec-Form hingegen schon.
5. Non-root im Dockerfile des Dienstes
Hier reicht es aus, zwei Dinge festzulegen: einen non-root-Benutzer erstellen und vor dem Start der Anwendung explizit zu ihm wechseln. Das reicht bereits aus, damit der Container nicht mehr von root-Vorteilen profitiert. Die Schreibrechte für bestimmte Verzeichnisse müssen später trotzdem separat vorbereitet werden – und genau das deckt non-root schnell auf.
Hier haben wir einen typischen Spring Boot-Dienst mit einer ausführbaren Jar, der über java -jar gestartet wird. Daher reicht im Runtime-Stage ein JRE-basiertes Image aus. Und damit das Dockerfile nicht mit app.jar hier und application.jar dort ausufert, ist es sinnvoll, sofort einen einheitlichen Jar-Dateinamen und einen einheitlichen Stage-Namen beizubehalten: runtime und /app/application.jar.
Ich zeige einen sehr kompakten Runtime-Stage, in den bereits ein non-root-Benutzer eingebaut ist. Das ist ein Gerüst, das leicht wiederzuerkennen und leicht zwischen Projekten übertragbar ist:
FROM eclipse-temurin:25-jre-jammy AS runtime # Endgültiger Runtime-Stage: minimal Notwendiges für den Start
ARG UID=10001 # UID des Dienstbenutzers festlegen
RUN adduser --disabled-password --gecos "" \
--home "/nonexistent" --shell "/sbin/nologin" \
--no-create-home --uid "${UID}" appuser # Non-root-Benutzer erstellen
WORKDIR /app # Anwendungsverzeichnis innerhalb des Containers
COPY --from=builder --chown=appuser:appuser /build/build/libs/*.jar /app/application.jar
USER appuser # Start der Anwendung erfolgt als non-root
ENTRYPOINT ["java", "-jar", "/app/application.jar"] # Exec-Form: korrekte Signale und Graceful Shutdown
Es gibt hier einige wichtige Akzente, auch wenn der Code „zu einfach“ erscheint. Erstens steht USER appuser am Ende, direkt vor ENTRYPOINT. Das ist beabsichtigt: Alle Aktionen, die root-Rechte benötigen, erledigen wir vor dem Wechsel. Zweitens bleibt ENTRYPOINT in der exec-Form. Non-root hebt die grundlegende Startdisziplin nicht auf: Der Prozess muss weiterhin normal Signale empfangen und ohne Shell-Zwischenschicht beendet werden können.
Standardmäßig erstellt COPY Dateien mit dem Besitzer uid/gid 0, wenn nicht --chown verwendet wird. Daher ist es in der endgültigen Vorlage praktischer, die Jar sofort mit dem richtigen Besitzer zu kopieren und die beschreibbaren Verzeichnisse separat vorzubereiten. In dieser Phase ist es wichtig, genau die Tatsache festzuhalten: Der Prozess wird nicht als root gestartet. Sobald der Dienst in ein Verzeichnis schreiben muss, müssen die Rechte dafür explizit vorbereitet werden – und das prüft non-root schnell auf Ehrlichkeit.
6. Überprüfung des non-root-Starts
Ein sehr menschlicher Fehler ist es, das Dockerfile zu lesen, USER appuser zu sehen und sich zu beruhigen. Das Gehirn liebt „Häkchen“: Wenn die Zeile da ist, ist alles in Ordnung. Aber die Containerwelt mag keinen Glauben, sie mag überprüfbare Fakten. Daher sollte nach der Implementierung der Non-root-Regel eine kurze Verifikation erfolgen: Wer führt tatsächlich den Prozess im laufenden Container aus und was betrachtet Docker als „Container-Benutzer“.
Eine der schnellen Methoden ist, in den Container zu gehen und id oder whoami auszuführen. Die zweite ist, die Konfiguration des Containers über docker inspect anzusehen. In der Praxis ist es praktisch, beides zu tun: inspect sagt, was in der Konfiguration steht, exec sagt, was tatsächlich im Inneren passiert.
Beispiel für Befehle – ersetzen Sie die Container- und Image-Namen durch Ihre eigenen, hier wird die Idee gezeigt:
# Container im Hintergrund starten (stellen Sie sicher, dass Sie das frisch erstellte Image starten)
docker run -d --name catalog-app -p 8080:8080 catalog-service:local
# UID/GID des tatsächlichen Prozesses im laufenden Container prüfen
docker exec catalog-app id # uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
# Ansehen, was Docker in der Konfiguration als 'Container-Benutzer' betrachtet
docker inspect --format='{{.Config.User}}' catalog-app # appuser
# Zusätzlich: Benutzer von PID 1 und Startbefehl anzeigen (zur Selbstprüfung)
docker exec catalog-app ps -o user,pid,cmd | head -n 2 # USER PID CMD
Wenn Sie uid=0(root) sehen, dann ist etwas schiefgelaufen. Entweder ist das Dockerfile nicht das richtige, Sie haben das alte Image gestartet, oder Sie haben den Benutzer in Compose/CLI überschrieben. Und ja, eine Überschreibung ist möglich: docker run -u ... kann den Container als einen anderen Benutzer starten, als im Dockerfile angegeben, auch das gilt offiziell als normaler Mechanismus. Aber für unsere endgültige Vorlage ist die Idee umgekehrt: Das Dockerfile sollte „standardmäßig sicher“ sein, nicht „sicher bei der richtigen Stimmung des Entwicklers“.
Eine nützliche Miniprüfung „zur Kontrastierung“: Fast jedes Basis-Image ohne USER wird root sein. Zum Beispiel:
# Kontrast: ohne USER-Anweisung startet der Container fast immer als root (uid=0)
docker run --rm ubuntu:24.04 id # uid=0(root) gid=0(root) groups=0(root)
Das ist kein „Fehler von Ubuntu“, sondern einfach der Standard: Container starten als root, solange Sie nicht explizit etwas anderes festlegen.
7. Non-root und Dateirechte
Es folgt eine wichtige psychologische Warnung, damit Sie nicht denken, Docker sei „kaputt“. Sobald Sie die Runtime auf non-root umstellen, kann die Anwendung plötzlich mit AccessDeniedException abstürzen, „kann Datei nicht erstellen“, „kann Export nicht schreiben“, „kann Verzeichnis nicht erstellen“. Und das bedeutet nicht, dass non-root eine schlechte Idee ist. Es bedeutet, dass Sie endlich aufgehört haben, mit einem Panzer durch die Stadt zu fahren, und in ein normales Auto umgestiegen sind, das Verkehrsregeln hat.
Non-root macht das Modell lediglich ehrlich. Wenn der Dienst Dateien schreiben muss, muss er einen expliziten beschreibbaren Pfad haben, und dieser Pfad muss vorbereitet sein. Wenn der Dienst einen Bind Mount vom Host erhält, „reisen“ auch die Eigenheiten des Host-Dateisystems in den Container. Besonders schmerzhaft zeigt sich das bei Export-Verzeichnissen: Die Anwendung möchte eine CSV schreiben, aber das Verzeichnis gehört entweder root, ist schreibgeschützt oder wurde auf dem Host so erstellt, dass die Container-UID keine Rechte hat.
Das ist kein Grund, zu root zurückzukehren. Es ist ein Grund, den beschreibbaren Pfad, den Besitzer und die Rechte explizit festzulegen, anstatt zu hoffen, dass der Prozess „irgendwie selbst“ die Datei schreiben kann.
8. Typische Fehler bei non-root
Fehler Nr. 1: Benutzer im Builder-Stage erstellen und den Runtime-Stage vergessen.
Das ist eine klassische Falle des Multi-Stage-Builds. Sie haben RUN adduser ... appuser irgendwo im Builder hinzugefügt, der Build war erfolgreich, Sie sind zufrieden, aber dann beginnt der Runtime-Stage mit einem anderen FROM, und dort gibt es diesen Benutzer einfach nicht. Ergebnis: Entweder startet der Container als root (wenn USER nicht angegeben ist) oder er stürzt ab (wenn USER appuser angegeben ist, der Benutzer aber nicht existiert). Die Lösung ist einfach: Denken Sie daran, dass der finale Stage eine separate „Welt“ ist, die separat überprüft werden muss.
Fehler Nr. 2: USER appuser zu früh setzen und dann versuchen, root-Aufgaben zu erledigen.
Wenn nach USER ein RUN apt-get ..., RUN mkdir /something/system, RUN chown ... oder ein anderer Befehl folgt, der Privilegien benötigt, wird das Dockerfile beim Build fehlschlagen. Das ist nicht „Docker ist bockig“, sondern Sie haben ein Szenario beschrieben, in dem ein normaler Benutzer plötzlich zum Administrator wurde. Die richtige Reihenfolge ist fast immer: zuerst die Umgebung als root vorbereiten, dann USER, dann nur das, was ein normaler Benutzer tun kann.
Fehler Nr. 3: zu glauben, dass USER automatisch alle Berechtigungsprobleme löst.
USER „löst“ nichts, es setzt nur die Realität. Wenn Ihr Dienst in ein Verzeichnis schreibt, das root gehört, wird non-root einfach ehrlich sagen „geht nicht“. Das ist unangenehm, aber es ist gut: Es ist besser, das Problem in einem Lernprojekt und in einem lokalen Docker zu sehen, als in einer fremden Umgebung eine Woche später.
Fehler Nr. 4: den tatsächlichen Benutzer des laufenden Containers nicht zu überprüfen.
Solange Sie nicht docker exec ... id und docker inspect ... ausgeführt haben, wissen Sie nicht wirklich, wer den Prozess ausführt. Sie könnten versehentlich das alte Image starten, den Benutzer in docker run -u überschreiben oder durch Änderungen in Compose ein „gemischtes“ Ergebnis erhalten. Das Dockerfile ist die Absicht, die Überprüfung ist die Tatsache. Docker beschreibt sehr deutlich, dass USER beim Start des Containers überschrieben werden kann, daher ist die Überprüfung keine Paranoia, sondern normale Hygiene.
Fehler Nr. 5: Die UID „wahllos“ wählen und sich dann über seltsame Effekte wundern.
Wenn die UID nicht festgelegt wird, werden Sie abhängig vom Basis-Image und davon, welche Benutzer dort bereits vorhanden sind. Wenn die UID festgelegt wird, aber eine „beliebte“ wie 1000 gewählt wird, riskieren Sie Überschneidungen mit dem Host-Benutzer in Bind-Mount-Szenarien. Ja, das bringt das System nicht immer zum Absturz, aber es erzeugt manchmal eine sehr seltsame, schwer erklärbare Datei-Realität. Daher verwendet man in Vorlagen häufig eine separate Dienst-UID (z. B. 10001) und gibt diese über ARG an, wie in typischen Beispielen zur Containerisierung von Java-Anwendungen.
GO TO FULL VERSION