Se un bucket ha due proprietari, vince l'ultimo che fa apply
Ho aggiunto una regola lifecycle a S3 e terraform apply è fallito. La causa: due risorse possedevano ciascuna la configurazione lifecycle dello stesso bucket. Il lifecycle di S3 non funziona per singola regola ma per documento intero, quindi l'ultimo vincitore cancella in silenzio le regole dell'altro.
In sintesi
Per recuperare gli oggetti caricati con presign ma mai passati per commit ho progettato un prefix tmp/ e una regola lifecycle di scadenza. Al merge della regola, l'apply su staging è fallito: un modulo e la configurazione d'ambiente possedevano ciascuno il lifecycle dello stesso bucket fisico. L'API lifecycle di S3 sostituisce il documento intero, quindi due proprietari si sovrascrivono a vicenda a ogni apply. Ho fatto dimenticare la risorsa del modulo solo dallo state con un blocco removed (destroy=false), riducendo tutto a un proprietario unico. Una risorsa a livello di documento deve avere un solo proprietario.
In questa pagina
All'inizio era un banale problema di pulizia. Il percorso con cui gli utenti caricano file multimediali (registrazioni, immagini) usa presigned URL. Il server emette un URL di upload, il client carica direttamente su S3 e poi chiama la commit API per dire "registra questa chiave come asset ufficiale". Il problema sono i casi in cui arriva solo il presign e il commit non arriva mai. Se l'app muore a metà, la rete cade o l'utente lascia la schermata, nel bucket resta un oggetto che non risulta registrato da nessuna parte.
Volevo ripulire tutto questo con una regola lifecycle, ma non potevo applicarla. Gli oggetti committati e quelli non committati convivevano nello stesso prefix, quindi una regola del tipo "cancella ciò che è vecchio" avrebbe cancellato anche gli asset ufficiali. La quota giornaliera di upload teneva a bada la crescita, ma il fatto restava: non esisteva un percorso di recupero.
Il design: ciò che non è committato vive in tmp
L'ossatura della soluzione è la separazione dei namespace delle chiavi.
- Il presign emette una chiave temporanea con prefix
tmp/. - Se il commit supera le verifiche (esistenza via HEAD, Content-Type, limite di dimensione), promuove l'oggetto alla chiave definitiva con CopyObject ed elimina l'originale in tmp.
- Gli oggetti per cui il commit non arriva mai restano in
tmp/, e la regola lifecycle li fa scadere dopo 7 giorni.
Così il lifecycle deve guardare solo tmp/. Gli asset ufficiali sono fuori dalla portata della regola fin dall'inizio. Il client non andava toccato: ho letto tutti e tre i flussi di upload per verificarlo, e tutti usano nelle chiamate successive la chiave contenuta nella risposta del commit, quindi non si accorgono se il server cambia la forma della chiave. Anche i commit di chiavi nel vecchio formato, in volo al momento del deploy, vengono gestiti dal percorso esistente.
Qui c'è una trappola. Questo bucket ha il versioning attivo. In un bucket versionato, expiration non elimina l'oggetto: si limita ad aggiungere un delete marker, e i byte originali restano come versione noncurrent. Senza abbinare una noncurrent_version_expiration (1 giorno), puoi pulire quanto vuoi ma lo spazio non cala di un byte.
Anche la design review è valsa il suo prezzo. Sottoponendo la logica di promozione a review sono usciti tre difetti confermati. Una falla per cui, tramite il percorso delle chiavi custom per amministratori, si poteva fare commit di una chiave tmp/ come chiave finale e il lifecycle avrebbe cancellato un asset live (bloccata la chiave finale tmp al confine del commit condiviso); il problema per cui la sourceKey della promozione diventava una primitiva di copia arbitraria (imposta l'uguaglianza tra chiave tmp e chiave finale); e un problema di idempotenza per cui, con due commit concorrenti sulla stessa chiave tmp, la copy di uno dei due moriva con un 404. Tutti e tre corretti nel codice prima di andare avanti.
E poi l'apply è fallito
Ho aggiunto la regola lifecycle in terraform e ho fatto il merge. L'apply su staging è fallito.
L'errore era un timeout in scrittura della configurazione lifecycle, ma scavando è emerso che il timeout era il sintomo e il problema era la struttura. Il lifecycle di questo bucket era posseduto da due risorse terraform distinte. Una stava dentro il modulo di ridimensionamento immagini (il modulo applicava direttamente le regole per il proprio uso), l'altra nel file di configurazione dei bucket per ambiente (dove avevo appena aggiunto la regola tmp). Stesso bucket fisico, due proprietari.
Nell'API lifecycle di S3 non esiste "aggiungi una regola". PutBucketLifecycleConfiguration sostituisce il documento intero. Anche aws_s3_bucket_lifecycle_configuration di terraform poggia su quella base: la risorsa sovrascrive l'intero documento lifecycle del bucket con le sole regole che conosce. Con due proprietari, ciascuno spinge un documento che contiene solo le proprie regole. Se si incontrano nello stesso apply, la scrittura concorrente produce un timeout; se si incontrano in momenti diversi è peggio: tutto riesce senza errori e l'ultimo vincitore cancella le regole dell'altro.
Questa è la parte più agghiacciante della vicenda. Il timeout, essendo un fallimento rumoroso, è stato quasi una fortuna. Se quella struttura fosse rimasta viva, il prossimo apply qualunque che toccava il modulo di ridimensionamento immagini avrebbe cancellato in silenzio la regola di scadenza tmp appena inserita. Nel plan sarebbe apparsa solo una riga di in-place update su quella risorsa bucket, e gli oggetti non committati avrebbero ricominciato ad accumularsi per sempre. Non esisteva da nessuna parte un monitoraggio capace di accorgersi che la regola di pulizia era sparita.
In realtà questo punto era già stato segnalato in fase di code review con il sospetto che "due risorse sembrano toccare lo stesso bucket". Allora era stato classificato come rischio potenziale; con il fallimento dell'apply subito dopo il merge, il potenziale è diventato reale in pochi minuti.
La soluzione: un solo proprietario
La direzione della correzione era chiara. Il lifecycle di un bucket deve avere un solo proprietario. Ho rimosso la risorsa lifecycle dal modulo e ho reso il file di configurazione per ambiente il proprietario unico, spostando lì anche le regole che il modulo applicava.
Lo strumento usato qui è il blocco removed di terraform. Se cancelli e basta la dichiarazione della risorsa nel modulo, terraform prova a fare destroy della configurazione lifecycle reale. Si aprirebbe una finestra in cui le regole attualmente live vengono eliminate e poi ricreate. Con destroy = false nel blocco removed, terraform dimentica la risorsa soltanto nello state. L'oggetto reale resta com'è e sparisce solo dal registro, quindi la proprietà passa alla configurazione d'ambiente senza interruzioni.
Dopo la correzione l'apply su staging è passato e, una volta propagato fino a prod, ho verificato direttamente lo stato reale dei bucket con l'API di AWS. In entrambi i bucket la regola di scadenza tmp era viva, e anche le tre regole preesistenti erano intatte. Il motivo per cui non mi sono fermato al terraform plan è la lezione di questo stesso incidente: il plan è solo il registro contabile di ciascun proprietario, e in una struttura che sovrascrive documenti i due registri non si conoscono a vicenda.
Cosa resta
Una risorsa a livello di documento deve avere un solo proprietario. Non vale solo per il lifecycle di S3. Le bucket policy, la configurazione CORS, le configurazioni di notifica: tutte le risorse terraform costruite sopra API di tipo "sostituzione totale" hanno la stessa natura. Se una dichiarazione che punta allo stesso oggetto esiste sia in un modulo sia nella configurazione d'ambiente, al posto di un errore di compilazione ottieni una sovrascrittura silenziosa a runtime. Prima di aggiungere una nuova regola bisogna cercare se quel documento ha già un proprietario.
Un'altra cosa. Cancellare la dichiarazione di una risorsa e cancellare l'oggetto reale sono due operazioni diverse, e terraform esprime questa distinzione con il blocco removed. Quando l'obiettivo è un passaggio di proprietà, passare per un destroy rompe la continuità del servizio.
Infine, un percorso di recupero comincia dal design delle chiavi. Se dati con cicli di vita diversi, come "il committato e il non committato", vivono nello stesso namespace, nessuna regola di pulizia può essere applicata in sicurezza. Se le vite sono diverse, prima vanno separati i luoghi in cui abitano. Per la cronaca, questo approccio non recupera retroattivamente gli oggetti non committati già accumulati in passato. Quegli oggetti non si possono distinguere dagli asset ufficiali (era proprio il punto di partenza del problema), quindi ho scelto deliberatamente di applicare la soluzione solo ai nuovi upload.
Domande frequenti
Le regole lifecycle di S3 spariscono dopo terraform apply. Perché?
aws_s3_bucket_lifecycle_configuration è una risorsa che possiede l'intero documento di configurazione lifecycle del bucket. Se questa risorsa esiste in due posti che puntano allo stesso bucket (per esempio un modulo e la configurazione d'ambiente), ciascuna sostituisce il documento intero con le sole regole che conosce, quindi l'ultima ad andare in apply cancella le regole dell'altra. Se entrambe finiscono nello stesso apply, la scrittura concorrente può anche produrre un timeout. Consolidate in una sola risorsa proprietaria per bucket.
Ho impostato una scadenza lifecycle su un bucket S3 versionato, ma lo spazio non diminuisce.
In un bucket versionato, expiration non elimina l'oggetto: aggiunge un delete marker e rende noncurrent la versione esistente. Senza una noncurrent_version_expiration abbinata, i byte non vengono mai recuperati. La regola di scadenza e la scadenza delle versioni noncurrent vanno progettate in coppia.
Articoli correlati
- 💻 Sviluppo
Lo sconto che non stavamo incassando: perché Spot e Graviton costano meno
La fattura AWS ha superato il budget per due mesi di fila. Cercando risorse da tagliare, ho scoperto che la vera leva non era 'quanto' si usa, ma 'come' lo si compra. Perché Spot e Graviton costano meno, e perché un costo che sembra spreco (RDS Proxy) in realtà non va spento - una storia sul 'perché' dietro ogni cifra della fattura.
- 💻 Sviluppo
Self-hosting su un mini PC con Docker + Cloudflare Tunnel
Come esporre in sicurezza più servizi web da un solo mini PC di casa. HTTPS con Cloudflare Tunnel senza aprire porte, e tutto in esecuzione con Docker Compose.
- 💻 Sviluppo
I test erano verdi, ma le notifiche non erano mai partite
Il codice che accodava i job falliva sempre, fin dal primo giorno. L'errore veniva inghiottito e i test restavano verdi grazie ai mock. La storia di come un singolo due punti ha ucciso in silenzio tre funzionalità.