Dove finiscono le variabili d’ambiente di un container

Avvia un container con -e DB_PASSWORD=hunter2 ed esegui docker inspect sul container appena avviato:

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"]

La password resta lì in chiaro, leggibile da chiunque abbia accesso al socket Docker, da docker exec api env e da qualsiasi processo in esecuzione dentro il container. Non è un bug: è esattamente a cosa servono le variabili d’ambiente. Il problema è usarle per valori che non dovrebbero mai essere così visibili.

Dove impostare una variabile d’ambiente Docker: build time vs run time

Quattro meccanismi, tre cicli di vita diversi. Dove imposti una variabile decide quanto dura e chi può rileggerla:

MeccanismoImpostata doveFinisce nell’immagine?Visibile dopo docker inspect?
ARG nel DockerfileSolo in buildNo (a meno che non venga copiata in ENV)No, ma resta nella build history
ENV nel DockerfileIn buildSì, incorporata in ogni layer successivoSì
-e / --env su docker runAll’avvio del containerNoSì
--env-file su docker runAll’avvio del containerNoSì

A run time le passi una alla volta, o da un file:

1
2
3
4
5
# Una alla volta, ripetibile
docker run -e NODE_ENV=production -e PORT=3000 my-api

# Da un file, una coppia KEY=VALUE per riga, senza virgolette
docker run --env-file .env.production my-api

.env.production è fatto così:

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

--env-file è la scelta migliore appena superi due o tre variabili: tiene il comando di avvio leggibile e i valori in un unico posto che puoi confrontare con un diff.

Variabili d’ambiente in Docker Compose: environment, env_file e .env

Compose ha tre posti dove mettere le variabili, e due sono file con un nome quasi identico. .env e env_file: fanno lavori completamente diversi.

  • .env nella root del progetto viene letto da Compose stesso, per sostituire i placeholder ${VARIABILE} dentro compose.yaml. Non arriva mai al container a meno che tu non lo richiami anche sotto environment:.
  • env_file: elenca file il cui contenuto viene iniettato nell’ambiente del container, esattamente come --env-file su docker run.
  • environment: imposta le variabili direttamente nel file Compose, in linea.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

Quando una variabile è definita in piĂą di un posto, Compose la risolve in quest’ordine, dal piĂą prioritario: un -e passato a docker compose run, poi environment:, poi env_file:, poi il valore ENV giĂ  incorporato nell’immagine. Se NODE_ENV compare sia in environment: sia in .env.api, vince il valore in environment:.

PerchĂ© le variabili d’ambiente non sono il posto giusto per i secrets

Nessuno dei meccanismi sopra nasconde un valore a chi ha accesso al container o all’host:

  • docker inspect stampa ogni variabile a run time, come visto sopra.
  • docker exec <container> env le stampa dall’interno.
  • Un processo figlio eredita l’intero ambiente, incluso un debugger, un crash reporter o una dipendenza che non hai controllato.
  • Qualsiasi cosa che stampa il proprio ambiente all’avvio manda il valore nel tuo log aggregator, e un numero sorprendente di framework lo fa proprio quando il debug logging è attivo.
  • Le variabili d’ambiente impostate in build con ENV sono permanenti: docker history --no-trunc my-api mostra il valore esatto, e quel valore resta nell’immagine su ogni registry su cui la pubblichi.

Niente di tutto questo richiede che un attaccante comprometta il container. Basta l’accesso che moltissime persone hanno giĂ : il socket Docker, i log della CI, il registry delle immagini.

Come funzionano i secrets Docker: il valore viene montato come file

Smetti di consegnare le credenziali al container come variabili d’ambiente e montale come file. Compose lo fa da solo, senza bisogno di 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 monta db_password.txt su /run/secrets/db_password dentro il container, in sola lettura e da nessun’altra parte: non compare mai in docker inspect, in docker exec ... env, nĂ© nell’immagine. Il suffisso _FILE è una convenzione giĂ  supportata dalle immagini ufficiali di Postgres, MySQL e MongoDB: all’avvio, lo script di entrypoint legge il file invece di aspettarsi il valore diretto.

Se la tua applicazione non supporta questa convenzione, leggi il file da sola all’avvio: una riga nella maggior parte dei linguaggi, ad esempio open('/run/secrets/db_password').read().strip() in Python.

Su un cluster Swarm, il secret equivalente viene creato e distribuito dall’orchestratore invece che da un file locale:

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

Swarm conserva il secret cifrato a riposo e in transito, e lo monta in un filesystem in memoria su ogni replica: non viene mai scritto sul layer scrivibile del container.

Tenere i secrets fuori dall’immagine in build

ARG e ENV in un Dockerfile hanno lo stesso problema un passo prima: un valore passato come build argument resta registrato nella build history dell’immagine anche se non lo trasformi mai in una 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

Quel comando docker history lo trova. Il token sparisce dal filesystem finale se non fai COPY del file di configurazione in avanti, ma resta leggibile per sempre nei metadati dell’immagine, che viaggiano con essa su ogni registry.

Il flag --secret di BuildKit evita il problema montando il valore in un singolo step RUN come file in memoria che non diventa mai un layer:

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 esiste solo per la durata di quell’istruzione RUN e non viene registrato in nessun posto che docker history possa vedere.

Quando usare una variabile d’ambiente e quando un secret

SituazioneUsa
Configurazione non sensibile (porta, log level, feature flag)-e, --env-file, o environment:/env_file: di Compose
Password database, API key, chiave TLS a run timeUn secret Docker, montato come file
Token di autenticazione necessario solo durante docker build--secret di BuildKit con RUN --mount=type=secret
Valore incorporato nell’immagine di proposito (versione app, commit di build)ARG copiato in ENV: va bene, non è un secret

Una sola domanda decide tutto: ti dispiacerebbe se questo valore trapelasse? Se sì, non passa da -e, --env-file, environment: o un ARG/ENV nel Dockerfile. Passa da un secret montato come file. Le impostazioni che la tua app è felice di stampare nei propri log restano variabili d’ambiente normali, piĂą facili da sovrascrivere per ambiente.

Quindi vai a controllare. Esegui docker inspect --format '{{json .Config.Env}}' sui container che hai in esecuzione adesso, e leggi l’output come se fosse il diff di una pull request. Qualsiasi cosa lì dentro che non vorresti veder revisionata in pubblico va spostata in un blocco secrets: nel tuo file Compose, con una variabile _FILE al posto della password. Se il file deve sopravvivere al container invece di arrivare dal build context, mettilo su un volume Docker.

Articoli correlati