Gdzie kończą zmienne środowiskowe kontenera

Uruchamiasz kontener poleceniem -e DB_PASSWORD=hunter2 i sprawdzasz go 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"]

Hasło leży tam jawnym tekstem, czytelne dla każdego z dostępem do socketa Docker, dla docker exec api env i dla każdego procesu działającego w kontenerze. To nie jest błąd — dokładnie do tego służą zmienne środowiskowe. Problem pojawia się, gdy używasz ich do wartości, które nigdy nie powinny być aż tak widoczne.

Gdzie ustawić zmienną środowiskową Docker: build time czy run time

Cztery mechanizmy, trzy różne cykle życia. To, gdzie ustawisz zmienną, decyduje, jak długo żyje i kto może ją odczytać:

MechanizmUstawiana gdzieTrafia do obrazu?Widoczna po docker inspect?
ARG w DockerfileTylko przy budowaniuNie (chyba że skopiowana do ENV)Nie, ale zapisana w historii builda
ENV w DockerfilePrzy budowaniuTak, na stałe w każdej kolejnej warstwieTak
-e / --env w docker runPrzy starcie konteneraNieTak
--env-file w docker runPrzy starcie konteneraNieTak

W czasie działania przekazujesz je pojedynczo albo z pliku:

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

# Z pliku, jedna para KEY=VALUE na linię, bez cudzysłowów
docker run --env-file .env.production my-api

.env.production wygląda tak:

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

--env-file staje się lepszym wyborem, gdy masz więcej niż dwie-trzy zmienne — komenda startowa zostaje czytelna, a wartości siedzą w jednym miejscu, które możesz porównać diffem.

Zmienne środowiskowe w Docker Compose: environment, env_file i .env

Compose daje trzy miejsca na zmienne, a dwa z nich to pliki o niemal identycznej nazwie. .env i env_file: robią zupełnie różne rzeczy.

  • .env w katalogu głównym projektu jest czytany przez sam Compose, żeby podstawić placeholdery ${ZMIENNA} w compose.yaml. Nigdy nie trafia do kontenera, chyba że dodatkowo odwołasz się do niego w environment:.
  • env_file: wskazuje pliki, których zawartość trafia do środowiska kontenera, dokładnie tak jak --env-file w docker run.
  • environment: ustawia zmienne bezpośrednio w pliku Compose, inline.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

Gdy zmienna jest zdefiniowana w więcej niż jednym miejscu, Compose rozstrzyga to w takiej kolejności, od najwyższego priorytetu: -e przekazane do docker compose run, potem environment:, potem env_file:, na końcu ENV już zaszyte w obrazie. Jeśli NODE_ENV pojawia się zarówno w environment:, jak i w .env.api, wygrywa wartość z environment:.

Dlaczego zmienne środowiskowe to złe miejsce na secrets

Żaden z powyższych mechanizmów nie ukrywa wartości przed kimś, kto ma dostęp do kontenera albo hosta:

  • docker inspect wypisuje każdą zmienną w czasie działania, jak pokazano wyżej.
  • docker exec <kontener> env wypisuje je od środka.
  • Proces potomny dziedziczy całe środowisko, łącznie z debuggerem, crash reporterem albo zależnością, której nie sprawdziłeś.
  • Wszystko, co przy starcie zrzuca swoje środowisko, wysyła wartość do twojego agregatora logów, a zaskakująco wiele frameworków robi dokładnie to, gdy włączone jest debug logging.
  • Zmienne środowiskowe ustawione przy budowaniu przez ENV są trwałe: docker history --no-trunc my-api pokazuje dokładną wartość, która zostaje w obrazie na każdym registry, do którego go wypchniesz.

Nic z tego nie wymaga, żeby atakujący przejął kontener. Wystarczy dostęp, który wiele osób już ma: socket Docker, logi CI, registry obrazów.

Jak działają secrets Docker: wartość montowana jako plik

Przestań przekazywać dane uwierzytelniające kontenerowi jako zmienne środowiskowe i montuj je jako pliki. Compose robi to samodzielnie, bez potrzeby 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 montuje db_password.txt pod /run/secrets/db_password w kontenerze, tylko do odczytu i nigdzie indziej — nie pojawia się nigdy w docker inspect, w docker exec ... env ani w obrazie. Sufiks _FILE to konwencja, którą już wspierają oficjalne obrazy Postgresa, MySQL i MongoDB: przy starcie skrypt entrypoint czyta plik zamiast oczekiwać wartości wprost.

Jeśli twoja aplikacja nie obsługuje tej konwencji, wczytaj plik sam przy starcie — jedna linia w większości języków, na przykład open('/run/secrets/db_password').read().strip() w Pythonie.

W klastrze Swarm odpowiedni secret tworzy i dystrybuuje orkiestrator zamiast lokalnego pliku:

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

Swarm przechowuje secret zaszyfrowany w spoczynku i w tranzycie oraz montuje go w pamięciowym systemie plików na każdej replice — nigdy nie trafia do zapisywalnej warstwy kontenera.

Trzymanie secrets poza obrazem przy budowaniu

ARG i ENV w Dockerfile mają ten sam problem, tylko krok wcześniej: wartość przekazana jako argument builda zostaje zapisana w historii builda obrazu, nawet jeśli nigdy nie zamienisz jej w ENV.

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

Ta komenda docker history go znajdzie. Token znika z finalnego systemu plików, jeśli nie zrobisz COPY pliku konfiguracyjnego dalej, ale zostaje trwale czytelny w metadanych obrazu, które wędrują razem z nim na każde registry.

Flaga --secret z BuildKit omija ten problem, montując wartość na czas jednego kroku RUN jako plik w pamięci, który nigdy nie staje się warstwą:

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 istnieje tylko na czas tej instrukcji RUN i nie jest zapisywany nigdzie, gdzie docker history mógłby go zobaczyć.

Kiedy zmienna środowiskowa, a kiedy secret

SytuacjaUżyj
Konfiguracja bez znaczenia dla bezpieczeństwa (port, log level, feature flag)-e, --env-file albo environment:/env_file: z Compose
Hasło do bazy danych, klucz API, klucz TLS w czasie działaniaSecret Docker, zamontowany jako plik
Token uwierzytelniający potrzebny tylko podczas docker build--secret z BuildKit z RUN --mount=type=secret
Wartość celowo zaszyta w obrazie (wersja aplikacji, commit builda)ARG skopiowany do ENV — to w porządku, to nie jest secret

Jedno pytanie rozstrzyga sprawę: przeszkadzałoby ci, gdyby ta wartość wyciekła? Jeśli tak, nie idzie przez -e, --env-file, environment: ani przez ARG/ENV w Dockerfile. Idzie przez secret zamontowany jako plik. Ustawienia, które twoja aplikacja bez problemu wypisuje we własnych logach, zostają zwykłymi zmiennymi środowiskowymi — i tak łatwiej je nadpisać per środowisko.

Sprawdź to teraz, u siebie. Uruchom docker inspect --format '{{json .Config.Env}}' na kontenerach, które masz teraz uruchomione, i przeczytaj wynik tak, jakby to był diff pull requesta. Wszystko, czego nie chciałbyś zobaczyć zrecenzowanego publicznie, powinno trafić do bloku secrets: w twoim pliku Compose, ze zmienną _FILE w miejscu hasła. Jeśli plik ma przetrwać dłużej niż kontener, zamiast pochodzić z kontekstu builda, umieść go na woluminie Docker.

Powiązane artykuły