Eine stille Prüfsumme, die jeden S3-kompatiblen Upload zerschossen hat
Problem
Wir hatten einen kleinen Go-Service, der generierte Dateien in einen S3-kompatiblen Objektspeicher schrieb, der von einem selbst gehosteten, Ceph-basierten Gateway bereitgestellt wurde. Er nutzte eine verbreitete, cloud-portable Bibliothek zur Blob-Storage-Abstraktion, also die Art, mit der man s3://, gs:// oder file:// hinter einer einzigen Schnittstelle austauschen kann, sodass der Anwendungscode nie wissen muss, mit welchem Backend er gerade spricht.
Jeder Schreibvorgang schlug fehl. Nicht sporadisch, sondern jeder einzelne PutObject-Aufruf kam mit einem 400 zurück und einem Fehler in der Art von:
api error XAmzContentSHA256Mismatch: UnknownError
Dieser Fehler bedeutet, dass der Objektspeicher einen Hash des Request-Bodys berechnet hat und dieser nicht zu dem passte, was der Client angeblich gesendet hatte. Auf dem Papier deutet das auf eine Datenkorruption während der Übertragung hin: ein Proxy, der den Body verstümmelt, ein defekter TLS-Terminator, Bytes, die sich zwischen Signieren und Senden ändern.
Wir haben all das methodisch ausgeschlossen:
- Das Erzwingen einer bestimmten AWS-Region machte keinen Unterschied, wir probierten sowohl eine Platzhalter-Region als auch die tatsächliche des Speichers.
- Das SDK dazu zu zwingen, den Payload-Hash korrekt zu signieren (statt den HTTPS-Standard eines “unsigned payload”-Sentinels zu verwenden), machte keinen Unterschied.
- Das Zielobjekt vor jedem Schreibvorgang zu löschen, um irgendeine Art von Überschreib-/Versionierungsverwirrung auszuschließen, machte keinen Unterschied, der Speicher lehnte ein
PutObjectauf einen noch nicht existierenden Schlüssel genauso bereitwillig ab. - Der Fehler war zu 100 % reproduzierbar, nicht last- oder größenabhängig.
Nichts davon passte zu einer echten Geschichte von Korruption während der Übertragung. Also hörten wir auf zu raten und schalteten das rohe Request-/Response-Logging des SDK ein, um genau zu sehen, was den Prozess verließ.
Da tauchte es auf: Jedes ausgehende PutObject trug einen x-amz-checksum-crc32-Header, und der Request-Body war als chunked, trailer-signierter Upload aufgebaut. Wir hatten den Client ausdrücklich so konfiguriert, dass er Prüfsummen nur dann berechnet, wenn die Operation eine erfordert, nicht standardmäßig, über die SDK-Standardeinstellung “when required” für die Prüfsummenberechnung. Diese Einstellung wurde korrekt gelesen und auf unserem Bucket-Handle gespeichert. Sie erreichte nur nicht den Codepfad, der den Schreibvorgang tatsächlich ausführte.
Die Blob-Abstraktionsbibliothek nutzte intern eine moderne, streaming-fähige “Transfer-Manager”-Komponente, um Uploads durchzuführen, und umhüllte damit den einfachen SDK-Client. Dieser Transfer-Manager hat seine eigene Kopie der Prüfsummen-Einstellung, völlig getrennt von der auf dem darunterliegenden SDK-Client, und der Verbindungscode der Bibliothek, der die beiden zusammenschaltet, kopierte nur ein paar unzusammenhängende Optionen (Puffergröße, Nebenläufigkeit). Die Prüfsummen-Einstellung wurde stillschweigend fallengelassen. Auf ihrem Nullwert belassen, löste sie sich zum SDK-eigenen Standard auf, “Prüfsumme, wann immer unterstützt”, was bedeutete, dass ein CRC32-Trailer an jeden Request angehängt wurde, ob wir danach fragten oder nicht.
Unser selbst gehostetes Gateway mochte dieses chunked, trailer-prüfsummierte Request-Format nicht und lehnte es generisch als Content-Hash-Mismatch ab. Ein echtes S3-kompatibles Backend eines anderen Anbieters, oder eine neuere Version desselben, hätte damit vielleicht problemlos umgehen können, und genau deshalb bleibt diese Art von Lücke lange unbemerkt: Sie beißt einen nur bei Implementierungen, die strenger oder einfach anders mit einem selten genutzten Codepfad umgehen.
Lösung
Sobald der eigentliche Mechanismus klar war, war die Behebung eng begrenzt und wenig glamourös: für diese eine Operation nicht mehr über den High-Level-Schreibpfad der Bibliothek gehen, sondern die einfache, Low-Level-Operation PutObject direkt auf dem darunterliegenden SDK-Client aufrufen.
Der High-Level-Pfad (nennen wir ihn WriteAll) ist bequem, Pufferung, Retries, Multipart-Handling, alles gratis, aber dort lebte auch die kaputte Options-Weiterleitung. Der Low-Level-Client, einmal konstruiert und gecacht, respektiert die Prüfsummen-Einstellung korrekt, weil diese Logik direkt im offiziell gepflegten SDK liegt und nicht im Drittanbieter-Kleber darüber.
Als Skizze sah die Form der Behebung so aus:
// Vorher: geht durch die Writer-Abstraktion der Bibliothek, die
// unsere Präferenz zur Prüfsummenberechnung stillschweigend ignoriert.
err := bucket.WriteAll(ctx, key, data, nil)
// Nachher: die Abstraktion für diesen einen Aufruf umgehen und den
// darunterliegenden Client direkt verwenden. Lese- und Löschvorgänge
// laufen weiterhin über die normale Abstraktion, nur Schreibvorgänge
// waren betroffen.
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: bytes.NewReader(data),
})
Ein paar Dinge machten dies zur richtigen Eingriffstiefe statt zu einem Hack:
- Es zielt auf den eigentlichen Mechanismus, nicht auf ein Symptom. Retrying, Backoff hinzufügen oder den spezifischen Fehlercode abfangen und verschlucken hätte den sichtbaren Fehler “behoben”, während jeder Schreibvorgang stillschweigend nicht persistiert oder bestenfalls unzuverlässig geblieben wäre. Wir haben zuerst etwas in der Art versucht (den spezifischen Fehler tolerieren und weitermachen), und es ließ das Symptom verschwinden, einen 502 an den Aufrufer, ohne den Schreibvorgang erfolgreich zu machen. Das ist eine Falle: Es sieht behoben aus, weil dein Smoke-Test durchläuft, aber die Daten landen nie.
- Es ist eng begrenzt. Nur der Schreibpfad ändert sich. Lese-, Lösch- und alle anderen Bucket-Operationen laufen weiterhin genau wie zuvor über die Bibliothek, weil sie von Anfang an nie im kaputten Codepfad waren. Kein Grund, eine nützliche Abstraktion wegen einer Lücke darin komplett aufzugeben.
- Es wurde durchgängig verifiziert, nicht nur über “kein Fehler zurückgegeben”. Nach der Änderung kam ein Schreibvorgang, unmittelbar gefolgt von einem Lesen desselben Schlüssels, byte-identisch zurück, bestätigt gegen das echte Backend, nicht gegen einen Mock.
Die weiter gefasste Lektion: Wenn ein bekannter, gut typisierter SDK-Fehler auftaucht (“checksum mismatch”) gegen Infrastruktur, die du nicht vollständig kontrollierst, widerstehe dem Drang, die Fehlermeldung für die ganze Geschichte zu halten. Sie ist eine akkurate Beschreibung dessen, was der Server beobachtet hat, aber sie sagt nichts darüber aus, warum der Client das gesendet hat. Den Fehler zu reproduzieren, während wir jede plausible Ursache variierten, die wir konfigurieren konnten, Region, Signiermodus, Existenz des Objekts, und keine Änderung zu bekommen, war selbst ein starkes Signal dafür, dass die eigentliche Ursache irgendwo komplett oberhalb unserer Konfiguration lag. Rohes Logging auf Wire-Ebene klärte es in Minuten, sobald wir danach griffen; wir hätten wahrscheinlich mehrere falsche Vermutungen früher danach greifen sollen.