Salta al contenuto principale

108 - ML-Ops: variabili d'ambiente e segreti fuori dal codice

Come tenere configurazione e segreti fuori dal codice: variabili d'ambiente, file .env, precedenza delle fonti, pydantic-settings e cosa fare dopo una fuga.

Cosa ne pensi di questo articolo?

108 - ML-Ops: variabili d'ambiente e segreti fuori dal codice
Machine Learning Engineerfedericocalo.dev

In Breve

Come tenere configurazione e segreti fuori dal codice: variabili d'ambiente, file .env, precedenza delle fonti, pydantic-settings e cosa fare dopo una fuga.

Variabili d'ambiente e segreti: la configurazione fuori dal codice

Una variabile d'ambiente è una coppia nome-valore (API_TOKEN=abc123) che il sistema operativo mette a disposizione di ogni programma che avvia. Il codice la legge, e così lo stesso identico codice si comporta in modo diverso sul tuo portatile, nei test e sul server, senza che tu lo modifichi. È la terza delle cinque dipendenze implicite del notebook descritte nell'introduzione al modulo: la configurazione (percorsi, indirizzi, chiavi). Qui impari a tenerla fuori dal codice, a validarla all'avvio e a non farla trapelare.

Le parole della configurazione

Sono poche, ma vengono usate a vanvera, e questo causa errori veri. Le cinque che servono per leggere il resto:

  • Configurazione: tutto ciò che cambia tra un ambiente e l'altro senza cambiare la logica (indirizzi, percorsi, limiti, interruttori). Come le impostazioni di una lavatrice: lo stesso apparecchio, programmi diversi.
  • Segreto: una configurazione che dà accesso a qualcosa e che non deve essere vista da chi non è autorizzato (password, token, chiavi). Come la chiave di casa.
  • Variabile d'ambiente: coppia NOME=valore che un processo, cioè un programma in esecuzione, passa ai suoi figli. Come un biglietto che il genitore dà al figlio prima di mandarlo fuori: il figlio lo legge, ma ciò che scrive sul suo biglietto non torna al genitore.
  • Validazione: controllare all'avvio che la configurazione sia completa e del tipo giusto (un numero dove serve un numero), e fermarsi con un messaggio chiaro se non lo è.
  • Rotazione di un segreto: sostituirlo con uno nuovo e revocare il vecchio. È la risposta standard a una fuga.

La distinzione che regge tutto il resto è la prima coppia: la configurazione può stare in chiaro in un file del repository, il segreto no. Quasi tutti gli incidenti di questo argomento nascono da un segreto trattato come una configurazione qualsiasi, cioè scritto in un file che poi finisce in Git, in un log o in un'immagine. Gli altri tre termini descrivono il meccanismo con cui un programma riceve i valori e il modo in cui lo si difende dagli errori.

Dove va a cercare un programma il valore di una impostazione, nell'ordine:

Precedenza della configurazione: variabile d'ambiente vera, poi file .env, poi valore di defaultse manca, scendise manca, scendi1. Ambiente veroexport API_TOKEN=...2. File .envAPI_TOKEN=... (sviluppo)3. Default nel codicevalore di ripiegovince la fonte più in alto che ha un valore

Lo schema è quello di pydantic-settings, la libreria che useremo sotto: la documentazione elenca, in ordine di priorità decrescente, gli argomenti passati al costruttore della classe, le variabili d'ambiente, il file dotenv, la cartella dei segreti e infine i valori predefiniti. Nel disegno il costruttore manca per semplicità: lo ritroviamo nella sezione sui test.

L'analogia: la targhetta sulla porta e la chiave

La targhetta sul citofono ("Rossi, interno 4") può stare scritta sul muro, la leggono tutti. La chiave di casa no: non la incidi sul portone, la tieni in tasca, e se la perdi cambi la serratura senza rifare il portone. Nel software, indirizzo del database e percorso del modello sono la targhetta: configurazione. La password del database è la chiave: segreto. Entrambe stanno fuori dal codice, ma il segreto ha regole più severe.

Il perché: tre modi in cui la configurazione nel codice fa danni

"Metto l'indirizzo del database direttamente nel codice, per ora" è una delle frasi più costose dello sviluppo. La configurazione dentro il codice sembra innocua perché funziona, e fa danni in tre modi precisi.

Primo: l'ambiente sbagliato. Hai un indirizzo di sviluppo e uno di produzione. Se sono nel codice, spostare il programma da un ambiente all'altro significa modificarlo, e chi modifica prima o poi dimentica di rimettere a posto. Il risultato classico è un programma di test che scrive nel database di produzione, o uno di produzione che legge dati finti. La configurazione esterna rende l'identico codice capace di girare ovunque: ciò che cambia è l'ambiente, non il programma.

Secondo: il segreto nella storia. Una password scritta nel codice finisce in un commit; il commit finisce nella storia di Git (vedi Git: versionare il lavoro); la storia finisce su un remoto (GitHub e le pull request) e nelle copie di chi clona. Anche se la togli dopo, resta nei commit vecchi. L'unica azione davvero efficace dopo una fuga è sostituire il segreto. Tenerlo fuori dal codice dalla prima riga evita l'intero problema.

Terzo: la modifica che richiede un rilascio. Se il limite di richieste al minuto o l'indirizzo di un servizio stanno nel codice, cambiarli significa nuova versione, test, build, deploy. Se stanno in un file di configurazione, cambi una riga e riavvii. Per le impostazioni che si regolano spesso (soglie, limiti, interruttori di funzionalità) la differenza è enorme.

Questi tre punti sono la sostanza del terzo fattore della Twelve-Factor App, un insieme di principi per costruire servizi che girano bene nel cloud: "Store config in the environment", conserva la configurazione nell'ambiente. Il test che il documento propone per sapere se la separazione è fatta bene è questo: la base di codice potrebbe diventare open source in qualsiasi momento, senza compromettere nessuna credenziale?

Il meccanismo: ogni processo ha il suo ambiente

Quando un programma ne avvia un altro, il figlio riceve una copia delle variabili del padre. La copia scende, non risale. Il blocco qui sotto la mette alla prova in bash con una variabile di nome DEMO, poi mostra le tre forme in cui si può definire una variabile in una shell: davanti a un solo comando, con export, e rimossa con unset:

(export DEMO=ciao; sh -c 'echo "figlio vede: $DEMO"')   # figlio vede: ciao
echo "padre vede: [$DEMO]"                              # padre vede: []
sh -c 'echo "1) [$ZZ]"'                       # 1) []         la variabile non esiste
ZZ=ciao sh -c 'echo "2) [$ZZ]"'               # 2) [ciao]     definita solo per questo comando
sh -c 'echo "3) dopo: [$ZZ]"'                 # 3) dopo: []   sparita: valeva solo per il comando
export ZZ=export; sh -c 'echo "4) [$ZZ]"'     # 4) [export]   esportata: la ereditano tutti i figli
unset ZZ; sh -c 'echo "5) [$ZZ]"'             # 5) []         rimossa
env -i /usr/bin/env | wc -l                   # 0

Eseguito due volte con bash, il blocco ha dato la stessa uscita riportata nei commenti. La prima riga mostra che il figlio vede la variabile e il padre no. Le righe 2 e 4 sono le due forme da distinguere: NOME=valore comando vale solo per quel comando, ed è la forma più sicura per un segreto temporaneo perché non resta nella sessione; export NOME=valore la rende disponibile a tutti i programmi avviati da quella shell, finché non usi unset o chiudi il terminale. Senza export, una variabile definita nella shell non passa ai figli. L'ultima riga lancia env con un ambiente completamente vuoto (env -i) e conta le righe che stampa: zero. Serve a scoprire che un programma funzionava solo perché sulla tua macchina c'era per caso HOME o PATH. Per vedere cosa è definito, printenv NOME mostra una variabile, env da solo le elenca tutte.

Da Python si legge con os.environ:

import os
token = os.environ["API_TOKEN"]              # KeyError se manca: fallisce subito
porta = int(os.environ.get("PORT", "8000"))  # con un valore di ripiego, SOLO se ha senso

La differenza fra le due righe è una decisione di progetto. os.environ["API_TOKEN"] solleva KeyError se la variabile non c'è; .get("PORT", "8000") restituisce il valore di ripiego, e a quel punto int() converte la stringa in numero (le variabili d'ambiente sono sempre stringhe). Per un valore senza cui il servizio non può lavorare, come un token, la mancanza deve essere un errore all'avvio. Un valore di ripiego va bene per le impostazioni davvero opzionali, come la porta.

I file .env: formato e trappole

Dichiarare a mano dieci variabili a ogni apertura del terminale è scomodo, quindi nasce la convenzione del file .env: righe NOME=valore, righe vuote e commenti con #, che una libreria carica all'avvio del programma.

# .env  (NON va in Git: è nel .gitignore)
API_TOKEN=abc123
MLFLOW_TRACKING_URI=sqlite:///mlflow.db

Accanto metti un .env.example con i nomi ma senza i valori segreti, che invece va in Git: documenta quali variabili servono. Chi clona il progetto copia l'esempio, compila i valori e parte.

# .env.example
API_TOKEN=cambiami
MLFLOW_TRACKING_URI=sqlite:///mlflow.db
MODEL_PATH=model.joblib

Il formato ha qualche sottigliezza che causa errori silenziosi. Con pydantic-settings 2.14.1 ho letto un file che ha le virgolette attorno a un valore con spazi e un # dentro, e degli spazi attorno a un =:

# f.env
API_TOKEN="con spazi # non commento"
MODEL_PATH = /tmp/m.joblib
# commento

# lettura con BaseSettings(env_file="f.env"):
# 'con spazi # non commento'
# '/tmp/m.joblib'
#
# lo stesso file letto dalla shell (. ./f.env):
# sh: 2: ./f.env: MODEL_PATH: not found

Il token tra virgolette mantiene spazi e il simbolo #, che fuori dalle virgolette comincia un commento. Gli spazi attorno al = nella seconda riga sono tollerati da pydantic-settings, mentre la shell prova a eseguire MODEL_PATH come comando e fallisce. Il confronto dice che lettori diversi hanno regole diverse, e che i container e systemd ne hanno di proprie sulle virgolette. Regola pratica per un file letto da più strumenti: niente spazi attorno al =, valori con spazi tra virgolette doppie, un solo formato per tutto il progetto.

Validare all'avvio con pydantic-settings

Leggere os.environ a mano, sparso nel codice, porta a errori scoperti tardi (un "8000" stringa dove serve un numero). pydantic-settings concentra la configurazione in una classe tipizzata e la valida quando il programma parte:

# settings.py
from functools import lru_cache
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    mlflow_tracking_uri: str = "sqlite:///mlflow.db"
    model_path: str = "model.joblib"
    api_token: SecretStr          # nessun default: se manca, l'avvio fallisce

@lru_cache
def get_settings() -> Settings:
    return Settings()

Le righe che contano sono tre. env_file=".env" dice quale file leggere, ed è il percorso relativo alla cartella da cui lanci il programma. extra="ignore" evita l'errore per le variabili presenti nel file ma non dichiarate nella classe (senza, il default sarebbe "forbid" sul file dotenv e il programma non partirebbe). api_token: SecretStr non ha un default: è il campo obbligatorio, e SecretStr maschera il valore quando l'oggetto viene stampato. lru_cache su get_settings() fa leggere la configurazione una volta sola.

Ho eseguito, con questo settings.py, un breve script (prova.py: stampa l'oggetto e poi il valore del token con .get_secret_value()) in tre situazioni, due volte ciascuna con lo stesso esito:

# 1) nessun API_TOKEN e nessun .env
# pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
# api_token
#   Field required [type=missing, input_value={}, input_type=dict]
#
# 2) .env con API_TOKEN=abc123
# mlflow_tracking_uri='sqlite:///mlflow.db' model_path='model.joblib' api_token=SecretStr('**********')
# abc123
#
# 3) lo stesso .env, ma API_TOKEN=dalla-shell impostato nel terminale
# mlflow_tracking_uri='sqlite:///mlflow.db' model_path='model.joblib' api_token=SecretStr('**********')
# dalla-shell

Nel primo caso il servizio non parte e il messaggio dice quale variabile manca. Nel secondo parte, e la stampa dell'oggetto non rivela il segreto: per leggerlo serve chiamare esplicitamente .get_secret_value(), che la seconda riga stampa. Nel terzo vince la shell sul file, come promette l'ordine di priorità. Questa precedenza ha una ragione pratica: un valore impostato in produzione non viene sovrascritto da un file dimenticato su disco. Il blocco gira con Python 3.14.4, pydantic 2.13.4 e pydantic-settings 2.14.1.

Trappola: un servizio che parte con una configurazione sbagliata e sbaglia in silenzio è peggio di uno che non parte. Un indirizzo di database vuoto che cade su un default di sviluppo può far scrivere dati reali nel posto sbagliato. Preferisci il fallimento rumoroso all'avvio.

Tipi e validazione: cosa fa davvero pydantic-settings

Le variabili d'ambiente sono sempre stringhe. Il programma vuole numeri, booleani, liste. La conversione manuale è facile da dimenticare e da sbagliare. pydantic-settings converte in base ai tipi dichiarati e rifiuta ciò che non si converte. Il prefisso CHURN_ evita collisioni con le variabili di altri programmi:

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="CHURN_", env_file=None, extra="ignore")
    port: int = 8000
    debug: bool = False
    allowed_origins: list[str] = []
    api_token: SecretStr = SecretStr("")

s = Settings()
print(s.port, type(s.port).__name__, s.debug, s.allowed_origins)
print(s.model_dump())
print(Settings(port=1, debug=True).port)

# CHURN_PORT=9000 CHURN_DEBUG=yes CHURN_ALLOWED_ORIGINS='["https://a.it","https://b.it"]' CHURN_API_TOKEN=abc
# 9000 int True ['https://a.it', 'https://b.it']
# {'port': 9000, 'debug': True, 'allowed_origins': ['https://a.it', 'https://b.it'], 'api_token': SecretStr('**********')}
# 1

L'uscita nei commenti è quella con le quattro variabili CHURN_* impostate. CHURN_PORT=9000 diventa un vero int, yes diventa True, e la lista arriva da una stringa in formato JSON, che è come pydantic-settings legge i tipi complessi. L'ultima riga mostra che gli argomenti del costruttore hanno la precedenza sull'ambiente: Settings(port=1, debug=True) dà port=1. Il model_dump() mostra il token mascherato, quindi registrare l'intera configurazione all'avvio (utile per il debug) non scrive il segreto nei log. Una buona pratica è proprio questa: stampare la configurazione effettiva all'avvio, con i segreti mascherati, per sapere con quali valori sta girando il servizio.

Ho poi provato i casi che falliscono. Con CHURN_PORT=otto l'avvio si ferma con Input should be a valid integer, unable to parse string as an integer, indicando il campo e il valore. Con CHURN_DEBUG=maybe l'errore è Input should be a valid boolean, unable to interpret input. Per i booleani, true, 1, yes, on danno True, mentre 0 e off danno False: utile, ma la shell o un altro linguaggio non usano le stesse regole, quindi concorda un formato. Il prefisso isola davvero: con PORT=1234 senza prefisso il programma continua a usare 8000, e una variabile generica impostata da altro non modifica per sbaglio il tuo servizio. Un errore all'avvio costa un minuto; un "otto" che esplode in mezzo a una richiesta costa un incidente.

Testare la configurazione senza toccare l'ambiente vero

Nei test non vuoi dipendere dall'ambiente della macchina. Con monkeypatch (pytest) imposti e togli variabili solo per la durata del test:

import pytest
from pydantic import ValidationError
from settings import Settings, get_settings

@pytest.fixture(autouse=True)
def isolato(monkeypatch, tmp_path):
    monkeypatch.chdir(tmp_path)               # nessun .env reale a portata di mano
    monkeypatch.delenv("API_TOKEN", raising=False)
    get_settings.cache_clear()                # svuota la cache di lru_cache
    yield
    get_settings.cache_clear()

def test_manca_il_token_l_avvio_fallisce():
    with pytest.raises(ValidationError, match="api_token"):
        Settings()

def test_il_token_arriva_dall_ambiente(monkeypatch):
    monkeypatch.setenv("API_TOKEN", "segreto-di-prova")
    s = Settings()
    assert s.api_token.get_secret_value() == "segreto-di-prova"
    assert "segreto-di-prova" not in repr(s)    # non finisce nei log

# python3 -m pytest -q test_settings.py
# ..
# 2 passed

La fixture con autouse=True gira prima di ogni test: sposta la directory corrente in una cartella temporanea, così il .env reale del progetto non viene letto, toglie API_TOKEN dall'ambiente e svuota la cache. Il cache_clear() è la trappola tipica: con lru_cache la configurazione viene letta una volta sola, e un test successivo vedrebbe quella del precedente. Il secondo test controlla anche che il valore non compaia in repr(), cioè in ciò che finirebbe in un log. Il tempo di esecuzione (0,1 s sulla mia macchina) cambia da una macchina all'altra.

Come alternativa leggera, pydantic permette di passare i valori direttamente nel costruttore, che hanno la precedenza sull'ambiente (Settings(port=1, debug=True) nel blocco precedente). Le due tecniche si usano insieme: il costruttore per testare la logica che usa la configurazione, monkeypatch per testare la lettura.

Un test ulteriore tiene onesto il .env.example, perché un esempio che nessuno aggiorna invecchia. Confronta le chiavi dell'esempio con i campi della classe:

from settings import Settings

def test_env_example_elenca_tutte_le_variabili():
    chiavi = {r.split("=")[0].lower() for r in open(".env.example") if "=" in r}
    campi = set(Settings.model_fields)
    assert chiavi == campi

# con l'esempio completo (tre chiavi):  1 passed
# togliendo MLFLOW_TRACKING_URI dall'esempio:
# E   AssertionError: assert {'api_token', 'model_path'} == {'api_token',... 'model_path'}
# 1 failed

Il test legge le chiavi dal file (la parte prima del =, in minuscolo), prende i nomi dei campi dichiarati nella classe (model_fields) e verifica che coincidano. L'ho eseguito sulla cartella di prova: con l'esempio dell'articolo passa, e togliendo una riga dall'esempio diventa rosso, come deve. Va lanciato dalla cartella del progetto, dove sta il .env.example, perché il percorso è relativo. Se aggiungi una variabile al codice e dimentichi l'esempio, la prossima esecuzione ti avvisa.

Il caso reale: come ci arriva la configurazione in produzione

Il servizio ML di questo sito mostra due pratiche vere, entrambe nel repository. Nell'unità systemd portfolio-ml.service la riga EnvironmentFile=/opt/portfolio/ml-service/.env.prod fa sì che systemd legga il file e passi le variabili al processo, così il codice non sa dove vivono; la documentazione di systemd.exec descrive EnvironmentFile= ed Environment=. Le variabili non segrete, come le cartelle di cache dei modelli, sono dichiarate nella stessa unità con Environment=. Il modulo app/config.py supporta poi il pattern NOME_FILE: se esiste una variabile come JWT_SECRET_FILE, il valore si legge dal contenuto di quel file, e altrimenti dalla variabile JWT_SECRET. È la convenzione dei segreti di Docker e Kubernetes, che montano il segreto come file anziché come variabile.

Le parti essenziali della classe di configurazione, abbreviate:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=f".env.{os.environ.get('APP_ENV', 'dev')}",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",
    )
    redis_url: str = "redis://localhost:6379"

Riga per riga. env_file=f".env.{APP_ENV}": il file da leggere dipende da una variabile, APP_ENV, che per default vale dev. Così APP_ENV=prod sceglie .env.prod, e lo stesso codice si configura per ogni ambiente senza modifiche. case_sensitive=False: REDIS_URL e redis_url sono la stessa cosa, un'indulgenza utile perché le variabili d'ambiente si scrivono per convenzione in maiuscolo. extra="ignore": le variabili non dichiarate nella classe vengono ignorate invece di dare errore, perché nello stesso file possono convivere variabili di altri programmi. redis_url ha un default sensato per lo sviluppo (localhost), mentre in produzione il file lo sovrascrive. Questo blocco è un estratto, non un programma eseguibile da solo.

La funzione _read_secret che implementa il pattern NOME_FILE legge il file con .strip(), che toglie spazi e a-capo finali: un dettaglio che risolve l'errore descritto qui sotto.

Il segreto con l'a-capo: una trappola vera

Quando un segreto sta in un file, il file di solito finisce con un carattere di nuova riga, perché la maggior parte degli editor e dei comandi (echo, printf '...\n') lo aggiunge. Il listato crea un file così e lo legge con e senza .strip():

# printf 'tok123\n' > segreto
raw = open("segreto").read()
print(repr(raw))           # 'tok123\n'
print(repr(raw.strip()))   # 'tok123'

Senza .strip() il segreto letto è tok123\n, non tok123: il confronto con il valore atteso fallisce, o una chiamata a un servizio esterno risponde "non autorizzato" per un motivo invisibile a occhio nudo, perché uno spazio o un a-capo finale non si vede nei log. È uno dei bug di configurazione più frustranti. Regola: quando un'autenticazione "non funziona" con un segreto che sembra giusto, controlla spazi e a-capo finali, stampando repr(segreto) (in locale, a schermo, per un istante: non nei log condivisi).

Dove un segreto trapela anche usando le variabili

"Variabile d'ambiente" tiene il segreto fuori dal codice e da Git, ma non lo rende invisibile. Il segreto può uscire da quattro punti: dai log e dai messaggi di errore, se stampi la configurazione intera (SecretStr aiuta, ma non è una garanzia universale); dall'immagine del container, se lo scrivi con ENV nel Dockerfile; dall'ispezione del processo, perché le variabili sono leggibili a chi amministra la macchina; e dai processi figli, che ereditano l'intero ambiente anche se non dovrebbero vederlo. Tre di questi punti si vedono con tre comandi.

1. Dentro il container. Passando le variabili con --env-file, chiunque abbia accesso al motore Docker le legge con docker inspect. Non è un difetto: le variabili sono visibili al processo, e quindi a chi amministra il processo.

docker run -d --rm --name envt --env-file e.env python:3.12-slim sleep 20
docker inspect envt --format '{{range .Config.Env}}{{println .}}{{end}}' | grep API_TOKEN
# API_TOKEN=abc123   (uscita dedotta, non eseguita)

Questo blocco è uscita dedotta, non eseguita: il comando richiede Docker, assente dall'ambiente di prova, quindi l'uscita è quella che il comando produce secondo la sua definizione (.Config.Env è l'elenco delle variabili del container). Prova a eseguirlo tu, con un e.env che contiene API_TOKEN=abc123.

2. Nell'immagine, se la cuoci in costruzione. Un Dockerfile con ENV API_TOKEN=abc123 lascia il valore in chiaro tra le istruzioni dell'immagine, e docker history le mostra. La documentazione dei build secrets lo dice in una frase: argomenti di build e variabili d'ambiente sono inadatti a passare segreti alla build, perché restano nell'immagine finale; per la build si usano i secret mount.

docker history envleak --no-trunc --format '{{.CreatedBy}}'
# ENV API_TOKEN=abc123   (riga attesa tra quelle stampate; uscita dedotta, non eseguita)

Anche questo blocco è uscita dedotta, non eseguita. Chiunque possa scaricare l'immagine da un registry (ne parla l'articolo sui registry) può leggere quella riga senza avviare nulla. Per questo i segreti si passano all'avvio (docker run -e, --env-file), mai nel Dockerfile. Come si scrive un Dockerfile corretto è argomento dell'articolo sul Dockerfile.

3. Nel sistema operativo. Su Linux ogni processo espone il proprio ambiente in /proc/<pid>/environ. Questo blocco l'ho eseguito, due volte:

MIO=segreto123 sleep 5 &
tr '\0' '\n' < /proc/$!/environ | grep MIO
# MIO=segreto123

Il & manda sleep in background, $! è il suo numero di processo, e tr sostituisce i caratteri nulli che separano le voci con degli a-capo. La pagina di manuale di proc_pid_environ descrive il file come l'ambiente iniziale con cui il programma è stato avviato. Il file è leggibile dall'utente proprietario del processo e da root: un'altra ragione per cui un servizio gira con un utente dedicato (ne parla l'articolo sul deploy). Le variabili non sono una cassaforte, ma un modo per tenere i segreti fuori dal codice e dalla storia di Git.

Quale livello di protezione scegliere

"Variabile d'ambiente" è il livello base. Salendo di protezione, e di complessità:

LivelloComeCosa protegge in più
1. File .env escluso da GitUn file locale, fuori dal repositoryIl segreto non entra nella storia
2. File di ambiente con permessi stretti sul serverEnvironmentFile= di systemd, chmod 600Solo il proprietario e root lo leggono
3. Segreti come file montatiPattern NOME_FILE; segreti di Docker e KubernetesNon compaiono in docker inspect né nell'ambiente dei processi figli
4. Gestore di segreti dedicatoUn servizio apposito (autogestito o del cloud) che consegna i segreti a richiesta, con audit e rotazioneRotazione, tracciabilità, revoca centralizzata

Quale livello? Dipende dal costo di una fuga e da chi altro ha accesso alla macchina. Per un progetto con una persona e un server, i livelli 1-2 sono proporzionati; per un team con più servizi sulla stessa macchina, o dati sensibili, si sale. Un criterio utile: il livello giusto è il più semplice che renda la rotazione di un segreto un'operazione da cinque minuti. Se ruotare una chiave richiede un rilascio o la modifica di dieci file, nessuno lo farà nemmeno dopo una fuga.

Nota di trasparenza sul progetto di questo sito: i suoi file .env.prod sono tracciati in Git perché il repository è privato, una scelta consapevole, accettata per semplicità di deploy. Rispetto alla tabella è un livello 1 "ridotto" che regge finché l'accesso al repository è controllato; la stessa scelta in un repository pubblico sarebbe un incidente, perché lì un segreto committato è un segreto pubblicato. I livelli sono compromessi che dipendono dal contesto, non scale morali.

Un ordine di lavoro per i segreti

  1. Elenca ciò che è segreto (token, password, chiavi) e ciò che è solo configurazione (indirizzi, percorsi).
  2. Fuori dal codice e da Git: file .env in .gitignore, con git check-ignore -v .env per verificarlo.
  3. Permessi stretti sul file del server: chmod 600 .env.prod (solo il proprietario legge e scrive).
  4. Un segreto per ambiente: una chiave di sviluppo che trapela non deve aprire la produzione.
  5. Mai nei log (SecretStr aiuta) e mai nella build.
  6. Un piano di rotazione: sapere dove si cambia il segreto e chi lo fa, prima che serva.

L'ordine non è casuale: i primi due passi decidono dove il segreto vive, i successivi due limitano chi può leggerlo, gli ultimi due riguardano che cosa fare quando le difese falliscono. Se puoi fare soltanto un passo oggi, fai il sesto: sapere come ruotare un segreto trasforma una fuga da catastrofe in manutenzione.

Se un segreto è trapelato: la procedura

  1. Revoca il segreto subito, dal pannello del servizio a cui dà accesso (non "aspettare di capire").
  2. Genera un segreto nuovo e distribuiscilo dove serve (file di ambiente, gestore di segreti).
  3. Riavvia i servizi che lo usano, e verificane il funzionamento.
  4. Cerca l'uso improprio: i log del servizio interessato nelle ore tra la fuga e la revoca.
  5. Rimuovi il segreto dal punto di fuga (il commit, il log, la pagina) dopo la revoca, ricordando che rimuoverlo non basta senza il punto 1.
  6. Annota cosa è successo e come evitare che si ripeta (nel runbook, come fa questo progetto per ogni errore risolto).

L'ordine conta: il punto 1 viene prima del punto 5. Cancellare il segreto dalla pagina senza averlo revocato lascia valido ciò che altri potrebbero aver già copiato, e il punto 4 ti dice se la fuga ha già avuto conseguenze.

Prova tu: quattro esercizi guidati

Esercizio 1: le tre forme di una variabile

Nel terminale, riproduci le cinque righe del blocco sul meccanismo con sh -c 'echo "[$ZZ]"': senza la variabile, con ZZ=ciao davanti al comando, subito dopo, dopo export ZZ=export e dopo unset ZZ. Dovresti vedere [], [ciao], [], [export], []. Poi env -i /usr/bin/env | wc -l: nella mia prova, 0 variabili. Cosa impari: che le variabili scendono ai processi figli e non risalgono, e che un programma può funzionare solo grazie a una variabile che hai dimenticato di avere.

Esercizio 2: la configurazione che si ferma subito

Scrivi la classe Settings con un campo obbligatorio (api_token: SecretStr) e uno numerico (port: int). Avviala senza il token: dovresti vedere Field required con il nome del campo. Imposta PORT=otto e osserva unable to parse string as an integer. Poi metti API_TOKEN=abc in un .env, stampa Settings() e verifica che il token compaia come SecretStr('**********'). Infine imposta API_TOKEN=altro nella shell: dovresti vedere che vince la shell sul file. Cosa impari: la precedenza tra le fonti e il valore di fallire all'avvio.

Esercizio 3: dove sta davvero un segreto

Avvia un container con docker run -d --rm --name envt --env-file e.env python:3.12-slim sleep 20 (con API_TOKEN=abc123 nel file) e leggi docker inspect envt --format '{{range .Config.Env}}{{println .}}{{end}}'. Poi costruisci un'immagine con ENV API_TOKEN=abc123 nel Dockerfile e osserva docker history --no-trunc. Infine, su Linux, MIO=segreto sleep 5 & e tr '\0' '\n' < /proc/$!/environ. Dovresti vedere il valore in chiaro in tutti e tre i punti. Cosa impari: che una variabile d'ambiente tiene un segreto fuori dal codice e da Git, ma non lo rende invisibile a chi amministra la macchina.

Esercizio 4: il segreto con l'a-capo

Crea un file con printf 'tok123\n' > segreto, leggilo da Python con e senza .strip() e stampa repr(...) di entrambi. Dovresti vedere 'tok123\n' e 'tok123'. Scrivi poi un piccolo test che confronta il valore letto con quello atteso e verifica che fallisce senza .strip(). Cosa impari: una delle cause più frustranti di un'autenticazione che "non funziona", e come renderla impossibile con un test.

Che cosa sai fare adesso

Sai tenere la configurazione fuori dal codice e leggerla dall'ambiente, scrivere una classe di configurazione che si ferma all'avvio con un messaggio chiaro quando un valore manca o ha il tipo sbagliato, e prevedere la precedenza fra ambiente, file .env e default. Sai tenere il .env vero fuori da Git, documentarne i nomi in un .env.example controllato da un test, non cuocere i segreti nelle immagini e non stamparli nei log. E sai cosa fare quando un segreto trapela, nell'ordine giusto. Il passo successivo è capire cosa significa mettere un programma in un container: container, immagini e isolamento.

Nella stessa serie trovi anche ML-Ops: UV, ambienti e dipendenze che si ripetono uguali, ML-Ops: testare un modello con invarianti, soglie e dati (articolo precedente), ML-Ops: MLflow, tracciare gli esperimenti invece di ricordarli e ML-Ops: model registry, versioni, alias e promozione di un modello.

Fonti

Come sono stati eseguiti i listati: shell bash, Python 3.14.4, pydantic 2.13.4, pydantic-settings 2.14.1, pytest 9.0.3, ciascuno due volte con la stessa uscita, in una cartella temporanea fuori dal repository. I blocchi Docker (docker run, docker inspect, docker history) sono dichiarati nel testo come uscita dedotta e non eseguiti. Il frammento di app/config.py è un estratto del repository di questo sito, non un programma autonomo. Valori come abc123 e segreto123 sono inventati per l'esempio.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti