Una suma de comprobación silenciosa que rompía cada subida compatible con S3

Problema

Teníamos un pequeño servicio en Go que escribía archivos generados en un almacén de objetos compatible con S3, respaldado por una pasarela autoalojada basada en Ceph. Usaba una popular librería de abstracción de almacenamiento de blobs portable entre nubes, del tipo que te permite intercambiar s3://, gs:// o file:// detrás de una única interfaz, de modo que el código de la aplicación nunca tiene que saber con qué backend está hablando.

Cada escritura fallaba. No de forma intermitente: cada una de las llamadas a PutObject volvía con un 400 y un error del estilo de:

api error XAmzContentSHA256Mismatch: UnknownError

Ese error significa que el almacén de objetos calculó un hash del cuerpo de la petición y no coincidía con lo que el cliente afirmaba haber enviado. Sobre el papel, esto apunta a una corrupción de datos en tránsito: un proxy que altera el cuerpo, un terminador TLS defectuoso, bytes que cambian entre la firma y el envío.

Descartamos todo eso, de forma metódica:

  • Forzar una región concreta de AWS no supuso ninguna diferencia; probamos tanto una región de marcador de posición como la real del almacén.
  • Forzar al SDK a firmar correctamente el hash del payload (en lugar de usar el valor centinela por defecto de “unsigned payload” en HTTPS) no supuso ninguna diferencia.
  • Borrar el objeto de destino antes de cada escritura, para descartar algún tipo de confusión con sobrescrituras o versionado, no supuso ninguna diferencia: el almacén rechazaba con la misma facilidad un PutObject sobre una clave que todavía no existía.
  • El error era 100 % reproducible, no dependía de la carga ni del tamaño.

Nada de eso encajaba con una historia genuina de corrupción en tránsito. Así que dejamos de adivinar y activamos el registro en crudo de peticiones y respuestas del SDK para ver exactamente qué salía del proceso.

Ahí apareció: cada PutObject saliente llevaba una cabecera x-amz-checksum-crc32, y el cuerpo de la petición estaba estructurado como una subida en fragmentos (chunked) firmada con trailer. Habíamos configurado el cliente explícitamente para calcular sumas de comprobación solo cuando la operación lo requería, no por defecto, mediante el ajuste estándar del SDK de cálculo de suma de comprobación “cuando sea necesario”. Ese ajuste se leía correctamente y se guardaba en nuestro handle del bucket. Simplemente no llegaba a la ruta de código que realmente ejecutaba la escritura.

La librería de abstracción de blobs usaba internamente un componente moderno de “gestor de transferencias” con capacidad de streaming para realizar las subidas, envolviendo al cliente plano del SDK. Ese gestor de transferencias tiene su propia copia del ajuste de cálculo de suma de comprobación, completamente separada de la del cliente del SDK subyacente, y el código de pegamento de la librería que conecta ambos solo copiaba un par de opciones no relacionadas (tamaño de búfer, concurrencia). El ajuste de la suma de comprobación se descartaba silenciosamente. Dejado en su valor cero, se resolvía al propio valor por defecto del SDK, “suma de comprobación siempre que sea compatible”, lo que significaba que se adjuntaba un trailer CRC32 a cada petición, lo pidiéramos o no.

A nuestra pasarela autoalojada no le gustaba ese formato de petición en fragmentos con suma de comprobación en el trailer y lo rechazaba, de forma genérica, como una discrepancia de hash del contenido. Un backend compatible con S3 de verdad de otro proveedor, o una versión más nueva del mismo, quizá lo habría gestionado sin problemas, y esa es exactamente la razón por la que este tipo de brecha pasa desapercibida durante mucho tiempo: solo te muerde con implementaciones que son más estrictas, o simplemente distintas, respecto a una ruta de código que se ejercita en raras ocasiones.

Solución

Una vez que el mecanismo real quedó claro, el arreglo fue acotado y poco vistoso: dejar de pasar por la ruta de escritura de alto nivel de la librería para esta única operación, y llamar directamente a la operación plana de bajo nivel PutObject sobre el cliente del SDK subyacente.

La ruta de alto nivel (llamémosla WriteAll) es cómoda: búferes, reintentos, gestión de multipart, todo gratis, pero también es donde vivía el reenvío roto de opciones. El cliente de bajo nivel, construido una sola vez y cacheado, respeta correctamente el ajuste de cálculo de suma de comprobación, porque esa lógica vive directamente en el SDK mantenido oficialmente, no en el pegamento de terceros por encima.

En forma de esbozo, la forma del arreglo era esta:

// Antes: pasa por la abstracción de escritura de la librería, que
// ignora silenciosamente nuestra preferencia de cálculo de suma de comprobación.
err := bucket.WriteAll(ctx, key, data, nil)

// Después: saltarse la abstracción para esta única llamada y usar el
// cliente subyacente directamente. Las lecturas y los borrados siguen
// pasando por la abstracción normal; solo las escrituras estaban afectadas.
_, err := client.PutObject(ctx, &s3.PutObjectInput{
    Bucket: aws.String(bucketName),
    Key:    aws.String(key),
    Body:   bytes.NewReader(data),
})

Varias cosas hicieron que este fuera el nivel de arreglo adecuado en lugar de un parche:

  • Ataca el mecanismo real, no un síntoma. Reintentar, añadir backoff, o capturar el código de error concreto y tragárselo habría “arreglado” el fallo visible mientras dejaba cada escritura silenciosamente sin persistir, o como mucho inestable. Probamos algo así primero (tolerar el error concreto y continuar) y hizo desaparecer el síntoma (un 502 hacia quien llamaba), sin hacer que la escritura tuviera éxito. Eso es una trampa: parece arreglado porque tu prueba de humo pasa, pero los datos nunca llegan.
  • Está acotado con precisión. Solo cambia la ruta de escritura. Las lecturas, los borrados y cualquier otra operación del bucket siguen pasando por la librería exactamente como antes, porque nunca estuvieron en la ruta de código rota para empezar. No hace falta abandonar por completo una abstracción útil por una brecha en ella.
  • Se verificó de extremo a extremo, no solo con un “no devolvió error”. Tras el cambio, una escritura seguida inmediatamente de una lectura de la misma clave volvía byte a byte idéntica, confirmado contra el backend real, no contra un mock.

La lección más amplia: cuando un error de SDK bien conocido y bien tipado aparece (“checksum mismatch”) contra infraestructura que no controlas del todo, resiste el impulso de tratar el mensaje de error como la historia completa. Es una descripción precisa de lo que el servidor observó, pero no dice nada sobre por qué el cliente envió eso. Reproducir el fallo variando todas las causas plausibles que podíamos configurar (región, modo de firma, existencia del objeto) y no obtener ningún cambio era en sí mismo una señal fuerte de que la causa real vivía en algún punto totalmente por encima de nuestra configuración. El registro en crudo a nivel de cable lo resolvió en minutos en cuanto recurrimos a él; probablemente deberíamos haber recurrido a él varias suposiciones erróneas antes.