Wo die Umgebungsvariablen eines Containers landen

Starten Sie einen Container mit -e DB_PASSWORD=hunter2 und prüfen Sie ihn mit docker inspect:

1
2
docker run -d --name api -e DB_PASSWORD=hunter2 nginx:alpine
docker inspect --format '{{json .Config.Env}}' api
1
["DB_PASSWORD=hunter2","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]

Das Passwort steht dort im Klartext, lesbar für jeden mit Zugriff auf den Docker-Socket, für docker exec api env und für jeden Prozess, der im Container läuft. Das ist kein Bug — genau dafür sind Umgebungsvariablen da. Das Problem ist, sie für Werte zu verwenden, die niemals so sichtbar sein sollten.

Docker-Umgebungsvariable setzen: beim Build oder zur Laufzeit?

Vier Mechanismen, drei Lebensdauern. Wo Sie eine Variable setzen, entscheidet, wie lange sie lebt und wer sie wieder auslesen kann:

MechanismusGesetzt woLandet im Image?Sichtbar nach docker inspect?
ARG im DockerfileNur beim BuildNein (außer in ENV kopiert)Nein, aber in der Build-History vermerkt
ENV im DockerfileBeim BuildJa, fest in jedem folgenden LayerJa
-e / --env bei docker runBeim ContainerstartNeinJa
--env-file bei docker runBeim ContainerstartNeinJa

Zur Laufzeit übergeben Sie sie einzeln oder aus einer Datei:

1
2
3
4
5
# Einzeln, wiederholbar
docker run -e NODE_ENV=production -e PORT=3000 my-api

# Aus einer Datei, ein KEY=VALUE pro Zeile, ohne Anführungszeichen
docker run --env-file .env.production my-api

.env.production sieht so aus:

1
2
3
NODE_ENV=production
PORT=3000
LOG_LEVEL=info

--env-file ist die bessere Wahl, sobald Sie mehr als zwei oder drei Variablen haben — der Startbefehl bleibt lesbar und die Werte liegen an einer Stelle, die Sie per Diff vergleichen können.

Umgebungsvariablen in Docker Compose: environment, env_file und .env

Compose bietet drei Stellen für Variablen, und zwei davon sind Dateien mit fast identischem Namen. .env und env_file: erledigen völlig unterschiedliche Aufgaben.

  • .env im Projektstamm wird von Compose selbst gelesen, um ${VARIABLE}-Platzhalter in compose.yaml zu ersetzen. Sie erreicht den Container nie, außer Sie referenzieren sie zusätzlich unter environment:.
  • env_file: listet Dateien auf, deren Inhalt in die Umgebung des Containers injiziert wird, genau wie --env-file bei docker run.
  • environment: setzt Variablen direkt in der Compose-Datei, inline.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

Ist eine Variable an mehr als einer Stelle definiert, löst Compose sie in dieser Reihenfolge auf, höchste Priorität zuerst: ein -e an docker compose run, dann environment:, dann env_file:, dann das ENV, das das Image bereits mitbringt. Taucht NODE_ENV sowohl in environment: als auch in .env.api auf, gewinnt der Wert aus environment:.

Warum Umgebungsvariablen der falsche Ort für Secrets sind

Keiner der oben genannten Mechanismen verbirgt einen Wert vor irgendetwas mit Zugriff auf Container oder Host:

  • docker inspect gibt jede Laufzeitvariable aus, wie oben gezeigt.
  • docker exec <container> env gibt sie von innen aus.
  • Ein Kindprozess erbt die gesamte Umgebung — inklusive eines Debuggers, eines Crash-Reporters oder einer Abhängigkeit, die Sie nicht geprüft haben.
  • Alles, was seine Umgebung beim Start ausgibt, schreibt den Wert in Ihren Log-Aggregator, und überraschend viele Frameworks tun genau das, wenn Debug-Logging aktiv ist.
  • Umgebungsvariablen, die beim Build mit ENV gesetzt werden, sind dauerhaft: docker history --no-trunc my-api zeigt den exakten Wert, und er bleibt im Image auf jeder Registry, in die Sie es pushen.

Nichts davon erfordert, dass ein Angreifer den Container kompromittiert. Es reicht der Zugriff, den sehr viele Leute ohnehin schon haben: der Docker-Socket, die CI-Logs, die Image-Registry.

Wie Docker Secrets funktionieren: der Wert wird als Datei gemountet

Hören Sie auf, Zugangsdaten dem Container als Umgebungsvariablen zu übergeben, und mounten Sie sie stattdessen als Dateien. Compose kann das von sich aus, ganz ohne Swarm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Compose mountet db_password.txt unter /run/secrets/db_password im Container, schreibgeschützt und nirgendwo sonst — sie taucht nie in docker inspect, in docker exec ... env oder im Image auf. Das _FILE-Suffix ist eine Konvention, die die offiziellen Images von Postgres, MySQL und MongoDB bereits unterstützen: beim Start liest das Entrypoint-Skript die Datei, statt den Wert direkt zu erwarten.

Unterstützt Ihre Anwendung diese Konvention nicht, lesen Sie die Datei beim Start selbst ein — eine Zeile in den meisten Sprachen, etwa open('/run/secrets/db_password').read().strip() in Python.

In einem Swarm-Cluster wird das entsprechende Secret vom Orchestrator erstellt und verteilt statt aus einer lokalen Datei:

1
2
echo "supersecret" | docker secret create db_password -
docker service create --name db --secret db_password postgres:16

Swarm speichert das Secret verschlüsselt, sowohl im Ruhezustand als auch bei der Übertragung, und mountet es auf jeder Replica in ein speicherbasiertes Dateisystem — es wird nie auf die beschreibbare Schicht des Containers geschrieben.

Secrets beim Build aus dem Image heraushalten

ARG und ENV in einem Dockerfile haben dasselbe Problem einen Schritt früher: Ein als Build-Argument übergebener Wert wird in der Build-History des Images vermerkt, selbst wenn Sie ihn nie zu einer ENV machen.

1
2
3
FROM node:20-slim
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
2
docker build --build-arg NPM_TOKEN=npm_abc123 -t my-api .
docker history --no-trunc my-api | grep npm_abc123

Dieser docker history-Befehl findet ihn. Der Token verschwindet zwar aus dem finalen Dateisystem, wenn Sie die Konfigurationsdatei nicht per COPY weiterreichen, bleibt aber dauerhaft in den Image-Metadaten lesbar, die mit dem Image auf jede Registry wandern.

Das --secret-Flag von BuildKit umgeht das, indem es den Wert nur für einen einzigen RUN-Schritt als speicherresidente Datei mountet, die nie zu einem Layer wird:

1
2
3
4
# syntax=docker/dockerfile:1
FROM node:20-slim
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
docker buildx build --secret id=npm_token,src="$HOME/.npm_token" -t my-api .

npm_token existiert nur für die Dauer dieser RUN-Anweisung und wird nirgendwo vermerkt, wo docker history ihn finden könnte.

Wann eine Umgebungsvariable, wann ein Secret

SituationVerwenden Sie
Unkritische Konfiguration (Port, Log-Level, Feature-Flag)-e, --env-file, oder environment:/env_file: von Compose
Datenbankpasswort, API-Key, TLS-Schlüssel zur LaufzeitEin Docker-Secret, als Datei gemountet
Auth-Token, nur während docker build benötigt--secret von BuildKit mit RUN --mount=type=secret
Wert absichtlich fest im Image (App-Version, Build-Commit)ARG, in ENV kopiert — das ist in Ordnung, es ist kein Secret

Eine Frage entscheidet: Würde es Sie stören, wenn dieser Wert durchsickert? Wenn ja, gehört er nicht in -e, --env-file, environment: oder ein ARG/ENV im Dockerfile. Er gehört in ein als Datei gemountetes Secret. Einstellungen, die Ihre App bereitwillig in den eigenen Logs ausgibt, bleiben gewöhnliche Umgebungsvariablen — die lassen sich pro Umgebung ohnehin leichter überschreiben.

Schauen Sie also nach. Führen Sie docker inspect --format '{{json .Config.Env}}' für die Container aus, die gerade laufen, und lesen Sie die Ausgabe, als wäre es der Diff einer Pull Request. Alles darin, was Sie nicht öffentlich in einem Review sehen wollen, gehört in einen secrets:-Block Ihrer Compose-Datei, mit einer _FILE-Variable anstelle des Passworts. Muss die Datei den Container überdauern statt aus dem Build-Kontext zu kommen, legen Sie sie in ein Docker-Volume.

Verwandte Artikel