Quando um bucket tem dois donos, ganha quem fez apply por último
Adicionei uma regra de lifecycle no S3 e o terraform apply falhou. A causa: duas resources eram donas, cada uma por conta própria, da configuração de lifecycle do mesmo bucket. O lifecycle do S3 não funciona por regra, e sim por documento inteiro, então o último vencedor apaga em silêncio as regras do outro.
Resumo
Para recuperar objetos que passaram pelo presign mas nunca pelo commit, projetei um prefix tmp/ e uma regra de lifecycle de expiração. Ao mergear a regra, o apply de staging falhou: um módulo e a configuração de ambiente eram donos, cada um, do lifecycle do mesmo bucket físico. A API de lifecycle do S3 substitui o documento inteiro, então dois donos sobrescrevem as regras um do outro a cada apply. Fiz o terraform esquecer a resource do módulo apenas no state com um bloco removed (destroy=false), consolidando tudo em um dono único. Uma resource em nível de documento precisa ter um único dono.
Nesta página
Tudo começou como um problema banal de limpeza. O caminho pelo qual os usuários sobem arquivos de mídia (gravações, imagens) usa presigned URL. O servidor emite uma URL de upload, o cliente envia direto para o S3 e depois chama a commit API para avisar: "registre esta chave como asset oficial". O problema são os casos em que só o presign acontece e o commit nunca chega. Se o app morre no meio, a rede cai ou o usuário sai da tela, sobra no bucket um objeto que não está registrado em lugar nenhum.
Eu queria limpar isso com uma regra de lifecycle, mas não dava para aplicá-la. Objetos commitados e não commitados estavam misturados no mesmo prefix, então uma regra do tipo "apague o que for antigo" apagaria também os assets oficiais. A cota diária de upload segurava a proliferação, mas o fato continuava lá: não existia caminho de recuperação.
O design: o que não foi commitado mora em tmp
O esqueleto da solução é a separação de namespaces de chave.
- O presign emite uma chave temporária com o prefix
tmp/. - Se o commit passa nas validações (existência via HEAD, Content-Type, limite de tamanho), ele promove o objeto para a chave definitiva com CopyObject e apaga o original em tmp.
- Objetos cujo commit nunca chega ficam em
tmp/, e a regra de lifecycle os expira depois de 7 dias.
Assim, o lifecycle só precisa olhar para tmp/. Os assets oficiais ficam fora do alcance da regra desde o início. O cliente não precisou mudar. Li os três fluxos de upload inteiros para confirmar: todos usam nas chamadas seguintes a chave que vem na resposta do commit, então nem percebem se o servidor mudar o formato da chave. Commits de chaves no formato antigo, em trânsito no momento do deploy, também são processados pelo caminho existente.
Aqui mora uma armadilha. Esse bucket tem versionamento ligado. Em um bucket versionado, expiration não apaga o objeto: apenas coloca um delete marker por cima, e os bytes originais permanecem como versão noncurrent. Sem uma noncurrent_version_expiration (1 dia) em par, você pode limpar à vontade que o espaço não diminui um byte sequer.
A revisão de design também valeu o preço. Ao colocar a lógica de promoção sob review, saíram três defeitos confirmados. Uma brecha em que, pelo caminho de chaves customizadas de administrador, dava para commitar uma chave tmp/ como chave final e o lifecycle apagaria um asset em produção (bloqueada a chave final tmp na fronteira do commit compartilhado); o problema de a sourceKey da promoção virar uma primitiva de cópia arbitrária (forçada a igualdade entre a chave tmp e a chave final); e um problema de idempotência em que dois commits simultâneos da mesma chave tmp faziam a copy de um deles morrer com 404. Os três foram corrigidos em código antes de seguir adiante.
E então o apply falhou
Adicionei a regra de lifecycle no terraform e fiz o merge. O apply de staging falhou.
O erro era um timeout na escrita da configuração de lifecycle, mas, cavando mais fundo, o timeout era sintoma; o problema era a estrutura. O lifecycle desse bucket era propriedade de duas resources terraform diferentes. Uma ficava dentro do módulo de redimensionamento de imagens (o módulo aplicava diretamente as regras do seu próprio uso), a outra ficava no arquivo de configuração de buckets por ambiente (onde eu tinha acabado de adicionar a regra de tmp). Mesmo bucket físico, dois donos.
Na API de lifecycle do S3 não existe "adicionar uma regra". PutBucketLifecycleConfiguration substitui o documento inteiro. O aws_s3_bucket_lifecycle_configuration do terraform está construído em cima disso: a resource sobrescreve o documento de lifecycle inteiro do bucket apenas com as regras que ela conhece. Com dois donos, cada um empurra um documento contendo só as próprias regras. Se os dois se encontram no mesmo apply, a escrita concorrente dá timeout; se se encontram em momentos separados, é pior: tudo passa sem erro e o último vencedor apaga as regras do outro.
Essa é a parte mais arrepiante do incidente. O timeout, por ser uma falha barulhenta, foi até uma sorte. Se aquela estrutura tivesse continuado viva, o próximo apply qualquer que tocasse o módulo de redimensionamento de imagens teria apagado em silêncio a regra de expiração de tmp recém inserida. No plan, isso apareceria só como uma linha de in-place update naquela resource de bucket, e os objetos não commitados voltariam a se acumular para sempre. Não existia em lugar nenhum um monitoramento capaz de perceber que a regra de limpeza tinha sumido.
Na verdade, esse ponto já tinha sido apontado na fase de code review, com a suspeita de que "duas resources parecem mexer no mesmo bucket". Na época foi classificado como risco potencial; com o apply falhando logo depois do merge, o potencial virou realidade em questão de minutos.
A solução: um dono só
A direção da correção era clara. O lifecycle de um bucket precisa ter um único dono. Removi a resource de lifecycle do módulo e tornei o arquivo de configuração por ambiente o dono único, movendo para lá inclusive as regras que o módulo aplicava.
A ferramenta usada aqui foi o bloco removed do terraform. Se você simplesmente apaga a declaração da resource no módulo, o terraform tenta fazer destroy da configuração de lifecycle real. Abriria uma janela em que as regras rodando em produção seriam deletadas e recriadas. Com destroy = false no bloco removed, o terraform esquece a resource apenas no state. O objeto real fica intacto e some só do registro, então a propriedade migra para a configuração de ambiente sem interrupção.
Depois da correção, o apply de staging passou e, com tudo propagado até prod, verifiquei diretamente o estado real dos buckets pela API da AWS. Nos dois buckets a regra de expiração de tmp estava viva, e as três regras preexistentes também estavam intactas. O motivo de não ter parado no terraform plan é a lição que este próprio incidente ensinou: o plan é apenas o livro-caixa de cada dono, e numa estrutura que sobrescreve documentos os dois livros não sabem um do outro.
O que fica
Uma resource em nível de documento precisa ter um único dono. Não é só o lifecycle do S3. Bucket policy, configuração de CORS, configuração de notificações: toda resource terraform construída sobre uma API de "substituição total" tem a mesma natureza. Se existe uma declaração apontando para o mesmo alvo no módulo e outra na configuração de ambiente, em vez de um erro de compilação você ganha uma sobrescrita silenciosa em runtime. Antes de adicionar uma regra nova, é preciso procurar se aquele documento já tem dono.
Mais uma coisa. Apagar a declaração de uma resource e apagar o objeto real são coisas diferentes, e o terraform expressa essa distinção com o bloco removed. Quando o objetivo é transferir propriedade, passar por um destroy quebra a continuidade do serviço.
Por fim, um caminho de recuperação começa no design das chaves. Se dados com tempos de vida diferentes, como "o commitado e o não commitado", moram no mesmo namespace, nenhuma regra de limpeza pode ser aplicada com segurança. Se os tempos de vida são diferentes, primeiro é preciso separar onde cada um mora. Para registro: essa abordagem não recupera retroativamente os objetos não commitados já acumulados no passado. Esses objetos não têm como ser distinguidos dos assets oficiais (isso era exatamente o ponto de partida do problema), então escolhi de propósito aplicar o escopo apenas aos novos uploads.
Perguntas frequentes
As regras de lifecycle do S3 somem depois do terraform apply. Por quê?
aws_s3_bucket_lifecycle_configuration é uma resource que possui o documento inteiro de configuração de lifecycle do bucket. Se essa resource existe em dois lugares apontando para o mesmo bucket (por exemplo, um módulo e a configuração de ambiente), cada uma substitui o documento inteiro apenas com as regras que conhece, então a última a passar pelo apply apaga as regras da outra. Se as duas caem no mesmo apply, a escrita concorrente pode até causar timeout. Consolide em uma única resource dona por bucket.
Configurei expiração de lifecycle em um bucket S3 versionado, mas o espaço não diminui.
Em um bucket versionado, expiration não apaga o objeto: apenas adiciona um delete marker e torna a versão existente noncurrent. Sem uma noncurrent_version_expiration em conjunto, os bytes nunca são recuperados. A regra de expiração e a expiração de versões noncurrent precisam ser projetadas em par.
Artigos relacionados
- 💻 Dev
Os testes estavam verdes, mas nenhuma notificação tinha sido enviada
O código que enfileirava os jobs falhava sempre, desde o primeiro dia. O erro era engolido, e os testes seguiam verdes graças aos mocks. A história de como um único caractere de dois-pontos matou três funcionalidades em silêncio.
- 💻 Dev
Chegou um alerta dizendo que o banco tinha caído. O banco nunca caiu
Em um único dia acumularam-se quatro alertas CRITICAL. 504, P2028 do Prisma, 500 no admin e 'serviço DATABASE fora do ar'. Abri primeiro as métricas do RDS: durante 21 horas seguidas, a CPU não passou de 19,7%. O lento não era o banco, eram as idas e voltas atravessando o Atlântico vinte e cinco vezes - e o alerta de 'fora do ar' foi o próprio health check que o fabricou.
- 💻 Dev
Rastreei os 9 que interceptavam o TLS do nosso app - era o Google
Ligamos o certificate pinning em modo report-only e o Sentry acumulou 74 eventos de SPKI mismatch. Parecia ataque MITM, e a IA descartou de forma categórica a hipótese da revisão da loja, alegando que 'o Google não intercepta HTTPS'. Os IPs reais, tirados dos logs da API em produção, derrubaram essa suposição.