Cuando un bucket tiene dos dueños, gana el último que hace apply
Añadí una sola regla de lifecycle en S3 y terraform apply falló. La causa: una estructura en la que dos recursos poseían cada uno por su cuenta la configuración de lifecycle del mismo bucket. El lifecycle de S3 no opera por reglas sino por documento entero, así que el último ganador borra en silencio las reglas del otro.
Lo esencial
Para recuperar los objetos subidos que tenían presign pero nunca recibieron commit, diseñé un prefix tmp/ con una regla de expiración de lifecycle. Al mergear la regla, el apply de staging falló: un módulo y la configuración por entorno poseían cada uno el lifecycle del mismo bucket físico. La lifecycle API de S3 reemplaza el documento entero, así que dos dueños se sobrescriben mutuamente las reglas en cada apply. Saqué el recurso del módulo con un bloque removed (destroy = false) para que terraform lo olvidara solo en el state, dejando un dueño único. Un recurso a nivel de documento debe tener un solo dueño.
En esta página
Todo empezó como un problema corriente de limpieza. La ruta por la que los usuarios suben archivos multimedia (grabaciones e imágenes) usa presigned URLs: el servidor emite una URL de subida, el cliente sube directamente a S3 y después llama a la commit API para avisar «registra esta clave como un asset definitivo». El problema aparece cuando alguien obtiene el presign pero nunca hace commit. Si la app muere a mitad de camino, se corta la red o el usuario abandona la pantalla, en el bucket queda un objeto que no está registrado en ninguna parte.
Quería limpiar esto con una regla de lifecycle, pero no había dónde colgarla. Los objetos con commit y sin commit convivían bajo el mismo prefix, así que una regla de «borra lo viejo» borraría también los assets definitivos. La cuota diaria de subidas frenaba la proliferación, pero el hecho seguía intacto: no existía una vía de recuperación.
Diseño: lo que no tiene commit vive en tmp
El esqueleto de la solución es separar el namespace de claves.
- El presign emite una clave temporal con el prefix
tmp/. - Si el commit pasa las validaciones (existencia vía HEAD, Content-Type, tope de tamaño), promueve el objeto a su clave definitiva con CopyObject y borra el original de tmp.
- Los objetos cuyo commit nunca llega se quedan en
tmp/, y una regla de lifecycle los expira a los 7 días.
Con esto, el lifecycle solo tiene que mirar tmp/. Los assets definitivos quedan fuera de su alcance desde el principio. No hizo falta tocar los clientes: leí los tres flujos de subida completos y todos usan en las llamadas posteriores la clave que viene en la respuesta del commit, así que el servidor puede cambiar la forma de la clave sin que se enteren. Los commits de claves con el formato antiguo que estuvieran en vuelo en el momento del deploy se procesan por la ruta de siempre.
Aquí hay una trampa. Este bucket tiene el versionado activado. En un bucket versionado, expiration no borra el objeto: solo apila un marcador de borrado, y los bytes originales quedan como versión noncurrent. Si no emparejas la regla con noncurrent_version_expiration (1 día), puedes limpiar todo lo que quieras y no recuperarás ni un byte.
La revisión del diseño también valió lo suyo. Puse una revisión sobre la lógica de promoción y salieron tres defectos confirmados. Un agujero por el que la ruta de claves personalizadas para administradores permitía hacer commit de una clave tmp/ como clave final, con lo que el lifecycle borraría un asset vivo (bloqueé las claves finales tmp en la frontera compartida del commit). El problema de que el sourceKey de la promoción se convertía en una primitiva de copia arbitraria (forcé la equivalencia entre la clave tmp y la final). Y un problema de idempotencia: si dos commits simultáneos llegaban con la misma clave tmp, el copy de uno de los dos moría con un 404. Arreglé los tres en el código y seguí adelante.
Y entonces el apply falló
Añadí la regla de lifecycle en terraform y la mergeé. El apply de staging falló.
El error era un timeout al escribir la configuración de lifecycle, pero al excavar quedó claro que el timeout era el síntoma y el problema era estructural. El lifecycle de este bucket lo poseían dos recursos de terraform, cada uno por su cuenta. Uno vivía dentro del módulo de redimensionado de imágenes (el módulo colgaba directamente sus propias reglas) y el otro en el archivo de configuración de buckets por entorno (donde acababa de añadir la regla de tmp). Un mismo bucket físico, dos dueños.
La lifecycle API de S3 no tiene un «añade una sola regla». PutBucketLifecycleConfiguration reemplaza el documento entero. El recurso aws_s3_bucket_lifecycle_configuration de terraform está construido encima, así que sobrescribe el documento de lifecycle completo del bucket con las reglas que él conoce. Con dos dueños, cada uno empuja un documento que solo contiene sus propias reglas. Si coinciden en el mismo apply, la escritura concurrente produce un timeout; si se encuentran por separado, es peor: todo termina en éxito, sin ningún error, y el último ganador borra las reglas del otro.
Esta es la parte más escalofriante del incidente. El timeout fue, paradójicamente, una suerte: un fallo ruidoso. Si esta estructura hubiera seguido viva, cualquier apply futuro que tocara el módulo de redimensionado de imágenes habría borrado en silencio la regla de expiración de tmp recién añadida. En el plan solo se habría visto una línea de in-place update de ese recurso del bucket, y los objetos sin commit habrían vuelto a acumularse para siempre. No había en ninguna parte una monitorización capaz de detectar que la regla de limpieza había desaparecido.
De hecho, este punto ya se había señalado una vez en la revisión de código con la sospecha de que «parece que dos recursos tocan el mismo bucket». Entonces lo clasifiqué como riesgo potencial; con el apply fallando justo después del merge, lo potencial tardó pocos minutos en volverse real.
La solución: un solo dueño
La dirección del arreglo era clara: cada bucket debe tener un único dueño de su lifecycle. Quité el recurso de lifecycle del módulo, convertí el archivo de configuración por entorno en dueño único y moví allí también las reglas que colgaba el módulo.
La herramienta fue el bloque removed de terraform. Si simplemente borras la declaración del recurso en el módulo, terraform intenta hacer destroy de la configuración real de lifecycle: se abre una ventana en la que las reglas que están funcionando en vivo se borran y se vuelven a crear. Con destroy = false en el bloque removed, terraform olvida ese recurso solo en el state. Lo físico queda intacto y solo se tacha del libro de cuentas, así que la propiedad se transfiere a la configuración del entorno sin interrupción.
Tras el arreglo, el apply de staging pasó; después de llevarlo a prod, verifiqué el estado real de los buckets directamente con la AWS API. En ambos buckets la regla de expiración de tmp estaba viva y las tres reglas preexistentes seguían intactas. La razón para no quedarme solo con el terraform plan me la enseñó este mismo incidente: el plan es solo el libro de cuentas de cada dueño, y en una estructura que sobrescribe el documento entero, los dos libros no se conocen entre sí.
Lo que queda
Un recurso a nivel de documento debe tener un solo dueño. Y no es solo el S3 lifecycle. La política del bucket, la configuración de CORS, la configuración de notificaciones: todos los recursos de terraform construidos sobre APIs de «reemplazo total» comparten la misma naturaleza. Si hay una declaración apuntando al mismo objetivo en un módulo y otra en la configuración del entorno, en lugar de un error de compilación obtienes una sobrescritura silenciosa en runtime. Antes de añadir una regla nueva, busca primero si ese documento ya tiene dueño.
Otra cosa. Borrar la declaración de un recurso y borrar el objeto real son cosas distintas, y terraform expresa esa distinción con el bloque removed. Cuando el objetivo es transferir la propiedad, pasar por un destroy rompe la continuidad sin cortes.
Por último, la vía de recuperación empieza en el diseño de claves. Cuando datos con vidas distintas, como «lo que tiene commit y lo que no», viven en el mismo namespace, no hay regla de limpieza que se pueda colgar con seguridad. Si las vidas son distintas, primero separa dónde viven. Un apunte: este enfoque no recupera retroactivamente los objetos sin commit ya acumulados. Esos objetos no se pueden distinguir de los assets definitivos (justo ese era el punto de partida del problema), así que elegí deliberadamente limitar el alcance a las subidas nuevas.
Preguntas frecuentes
Mis reglas de S3 lifecycle desaparecen después de terraform apply. ¿Por qué?
aws_s3_bucket_lifecycle_configuration es un recurso que posee el documento completo de configuración de lifecycle del bucket. Si hay dos de estos recursos apuntando al mismo bucket (por ejemplo, uno en un módulo y otro en la configuración por entorno), cada uno reemplaza el documento entero solo con las reglas que conoce, de modo que el último apply borra las reglas del otro. Si ambos coinciden en el mismo apply, la escritura concurrente puede terminar en timeout. Consolida la propiedad en un único recurso por bucket.
Puse una expiración de lifecycle en un bucket de S3 con versionado y el espacio ocupado no baja.
En un bucket versionado, expiration no borra el objeto: apila un marcador de borrado y convierte la versión existente en noncurrent. Si no añades también noncurrent_version_expiration, los bytes no se recuperan jamás. La regla de expiración y la expiración de versiones noncurrent hay que diseñarlas como un par.
Artículos relacionados
- 💻 Desarrollo
El descuento que no estabas cobrando: por qué Spot y Graviton son tan baratos
La factura de AWS superó el presupuesto dos meses seguidos. Buscando qué recurso recortar, descubrí que la palanca real no era 'cuánto usas' sino 'cómo lo compras'. Por qué Spot y Graviton son baratos, y por qué hay costos que parecen desperdicio (RDS Proxy) pero no se deben apagar - una historia sobre el 'porqué' detrás de cada cifra de la factura.
- 💻 Desarrollo
Los tests estaban en verde, pero la notificación nunca llegó a enviarse
El código que encolaba el job fallaba desde el primer día, siempre. El error se tragaba en un catch y los tests seguían en verde gracias a los mocks. La historia de cómo un solo carácter, dos puntos, mató en silencio tres funcionalidades.
- 💻 Desarrollo
Llegó una alerta diciendo que la base de datos estaba caída. La base de datos nunca se cayó
En un solo día se acumularon cuatro alertas CRITICAL. Un 504, un P2028 de Prisma, un 500 en el admin y 'servicio DATABASE caído'. Abrí primero las métricas de RDS y durante 21 horas seguidas la CPU no pasó del 19.7%, tan tranquila. Lo lento no era la base de datos, sino un viaje de ida y vuelta que cruzaba el Atlántico veinticinco veces, y la alerta de 'caída' se la había fabricado el propio health check.