Why NextWhy Next
Retour à la liste
💻 Dev13 min de lecture

Quand un bucket a deux propriétaires, c'est le dernier apply qui gagne

J'ai ajouté une règle de lifecycle S3 et terraform apply a échoué. La cause : une structure où deux ressources possédaient chacune la configuration lifecycle du même bucket. Le lifecycle S3 ne fonctionne pas à la règle près mais au document entier, et le dernier vainqueur efface silencieusement les règles de l'autre.

L'essentiel

Pour récupérer les objets uploadés avec un presign mais jamais commit, j'ai conçu un prefix tmp/ et une règle d'expiration lifecycle. Au merge de la règle, l'apply de staging a échoué : le module et la configuration d'environnement possédaient chacun le lifecycle du même bucket physique. L'API lifecycle de S3 remplace le document entier, donc deux propriétaires écrasent mutuellement leurs règles à chaque apply. J'ai fait oublier la ressource côté module uniquement dans le state avec un bloc removed (destroy=false), pour n'avoir qu'un seul propriétaire. Une ressource au niveau document doit avoir un seul propriétaire.

Sur cette page

Tout a commencé par un banal problème de nettoyage. Le chemin par lequel les utilisateurs envoient leurs fichiers médias (enregistrements, images) repose sur des presigned URL. Le serveur délivre une URL d'upload, le client envoie directement le fichier sur S3, puis appelle l'API de commit pour dire "enregistre cette clé comme asset officiel". Le problème, c'est le cas où le presign est obtenu mais le commit n'arrive jamais. Si l'app meurt en route, si le réseau coupe, si l'utilisateur quitte l'écran, il reste dans le bucket un objet qui n'est enregistré nulle part.

Je voulais nettoyer ça avec une règle de lifecycle, mais impossible d'en poser une. Les objets commit et non commit étaient mélangés sous le même prefix : une règle disant "supprime ce qui est vieux" aurait aussi supprimé les assets officiels. Le quota d'upload quotidien contenait la prolifération, mais le fait restait entier : il n'existait aucun chemin de récupération.

Le design : ce qui n'est pas commit vit dans tmp

L'ossature de la solution, c'est la séparation des namespaces de clés.

  1. Le presign délivre une clé temporaire sous le prefix tmp/.
  2. Quand le commit passe la validation (vérification d'existence via HEAD, Content-Type, taille maximale), il promeut l'objet vers sa clé définitive avec CopyObject et supprime l'original dans tmp.
  3. L'objet dont le commit n'arrive jamais reste dans tmp/, et la règle de lifecycle l'expire au bout de 7 jours.

Ainsi, le lifecycle n'a plus qu'à regarder tmp/. Les assets officiels sont hors de portée de la règle dès le départ. Pas besoin de toucher aux clients. J'ai lu les trois flux d'upload en entier pour le vérifier : tous réutilisent la clé renvoyée dans la réponse du commit pour leurs appels suivants, donc le serveur peut changer la forme de la clé sans qu'ils le sachent. Les commits de clés à l'ancien format encore en vol au moment du déploiement passent aussi par le chemin existant, tels quels.

Un piège au passage. Ce bucket a le versioning activé. Sur un bucket versionné, l'expiration ne supprime pas l'objet : elle pose juste un delete marker, et les octets d'origine subsistent en version noncurrent. Sans noncurrent_version_expiration (1 jour) en paire, on a beau nettoyer, le volume ne baisse pas d'un octet.

La revue de design a payé aussi. En mettant la logique de promotion en revue, trois défauts avérés sont sortis. Un trou où commit une clé tmp/ comme clé finale via le chemin des clés personnalisées côté admin ferait supprimer un asset live par le lifecycle (j'ai bloqué les clés finales tmp à la frontière du commit partagé), le sourceKey de la promotion qui devenait une primitive de copie arbitraire (j'ai forcé l'égalité entre clé tmp et clé finale), et un problème d'idempotence où deux commits simultanés de la même clé tmp font mourir l'un des deux copy sur un 404. Les trois ont été corrigés dans le code avant d'avancer.

Et puis l'apply a échoué

J'ai ajouté la règle de lifecycle dans terraform et mergé. L'apply de staging a échoué.

L'erreur était un timeout d'écriture de la configuration lifecycle, mais en creusant, le timeout n'était qu'un symptôme : le problème était structurel. Deux ressources terraform possédaient chacune le lifecycle de ce bucket. L'une vivait dans le module de redimensionnement d'images (le module posait lui-même les règles de son propre usage), l'autre dans le fichier de configuration des buckets par environnement (là où je venais d'ajouter la règle tmp). Même bucket physique, deux propriétaires.

L'API lifecycle de S3 ne connaît pas "ajouter une règle". PutBucketLifecycleConfiguration remplace le document entier. aws_s3_bucket_lifecycle_configuration de terraform est construit dessus : cette ressource écrase le document lifecycle complet du bucket avec les seules règles qu'elle connaît. Avec deux propriétaires, chacun pousse un document ne contenant que ses propres règles. S'ils se croisent dans le même apply, l'écriture concurrente provoque un timeout ; s'ils se croisent séparément, c'est pire. Ça réussit sans erreur, et le dernier vainqueur efface les règles de l'autre.

C'est la partie la plus glaçante de cette histoire. Le timeout était un échec bruyant, une chance en fait. Si cette structure avait survécu, le prochain apply touchant au module de redimensionnement d'images aurait silencieusement effacé la règle d'expiration tmp qui venait d'être posée. Dans le plan, cela ne serait apparu que comme une ligne d'in-place update sur cette ressource de bucket, et les objets non commit auraient recommencé à s'accumuler pour toujours. Aucun monitoring, nulle part, n'aurait remarqué la disparition de la règle de nettoyage.

En fait, ce point avait déjà été soulevé une fois au stade de la revue de code, sous forme de soupçon : "on dirait que deux ressources touchent au même bucket". À l'époque, on l'avait classé en risque potentiel ; l'apply a échoué juste après le merge, et il n'a pas fallu plus de quelques minutes pour que le potentiel devienne réel.

La solution : un seul propriétaire

La direction du correctif était claire. Il faut un seul propriétaire du lifecycle par bucket. J'ai retiré la ressource lifecycle du module, fait du fichier de configuration par environnement l'unique propriétaire, et déplacé vers lui jusqu'aux règles que posait le module.

L'outil utilisé ici, c'est le bloc removed de terraform. Si on supprime simplement la déclaration de ressource dans le module, terraform cherche à destroy la configuration lifecycle réelle. On ouvrirait une fenêtre où les règles actuellement en production sont supprimées puis recréées. Avec destroy = false dans le bloc removed, terraform oublie la ressource uniquement dans le state. Le réel reste en place, seul le registre l'oublie, et la propriété se transfère vers la configuration d'environnement sans interruption.

Après correction, l'apply de staging est passé, et une fois la prod à jour, j'ai vérifié directement l'état réel des buckets via l'API AWS. Sur les deux buckets, la règle d'expiration tmp était vivante et les trois règles existantes intactes. Si je ne me suis pas arrêté à terraform plan, c'est cette histoire elle-même qui me l'a appris : le plan n'est que le registre de chaque propriétaire, et dans une structure qui écrase le document, les deux registres s'ignorent.

Ce qu'il en reste

Une ressource au niveau document doit avoir un seul propriétaire. Et pas seulement le lifecycle S3. La politique de bucket, la configuration CORS, la configuration des notifications : toutes les ressources terraform construites sur une API de "remplacement total" partagent cette nature. Deux déclarations pointant vers la même cible, une dans un module et une dans la configuration d'environnement, ne donnent pas une erreur de compilation mais un écrasement silencieux au runtime. Avant d'ajouter une nouvelle règle, il faut d'abord chercher si ce document a déjà un propriétaire.

Autre chose. Supprimer une déclaration de ressource et supprimer le réel sont deux actes différents, et terraform exprime cette distinction avec le bloc removed. Quand le but est un transfert de propriété, passer par un destroy brise le zéro interruption.

Enfin, le chemin de récupération commence dans le design des clés. Quand des données aux durées de vie différentes, comme "commit et non commit", vivent dans le même namespace, aucune règle de nettoyage ne peut être posée en sécurité. Des durées de vie différentes exigent d'abord des lieux de vie séparés. À noter : cette approche ne récupère pas rétroactivement les objets non commit déjà accumulés. Ces objets sont impossibles à distinguer des assets officiels (c'était justement le point de départ du problème), donc j'ai délibérément choisi de n'appliquer le mécanisme qu'aux nouveaux uploads.

Questions fréquentes

Mes règles de lifecycle S3 disparaissent après un terraform apply. Pourquoi ?

aws_s3_bucket_lifecycle_configuration est une ressource qui possède l'intégralité du document de configuration lifecycle d'un bucket. Si deux de ces ressources pointent vers le même bucket (par exemple une dans un module et une dans la configuration d'environnement), chacune remplace le document entier avec les seules règles qu'elle connaît : le dernier apply efface donc les règles de l'autre. Si les deux tournent dans le même apply, l'écriture concurrente peut aussi provoquer un timeout. Consolidez en une seule ressource propriétaire par bucket.

J'ai mis une expiration lifecycle sur un bucket S3 versionné, mais le volume ne baisse pas.

Sur un bucket versionné, l'expiration ne supprime pas l'objet : elle pose un delete marker et rend la version existante noncurrent. Sans noncurrent_version_expiration en parallèle, les octets ne sont jamais récupérés. Concevez la règle d'expiration et l'expiration des versions noncurrent comme une paire.

Articles similaires

0 commentaires