Wie ein Go-Service heimlich Speicher fraß — und wie ich das Leak mit 20 Zeilen gefunden habe

Roter Panda als Mechaniker Symbolbild: Wenn der Speicher voll ist, hilft nur noch der Wagenheber.

Incident

Montag, 14:42 Uhr — der Monitoring-Alarm piepste zum ersten Mal seit Monaten. Unser Deployment-Service, ein knapp 800 Zeilen starker Go-Binary, war auf 1,8 GB RAM angewachsen. Normal sind 320 MB.

Wir dachten erst an ein Speicherleck im Docker-Layer. Aber docker stats zeigte: der Container selbst wuchs kontinuierlich. Stunde für Stunde. Ohne Traffic-Spitzen, ohne neue Features, ohne erkennbaren Grund.

Ich startete den Prozess lokal unter pprof — und nach 30 Minuten reproduzierte sich das Problem exakt. Jetzt musste ich nur noch herausfinden, welche Funktion heimlich Byte um Byte verschlang.

Root Cause

In Go ist der Garbage Collector clever, aber nicht allwissend. Er gibt Speicher nur zurück, wenn er merkt, dass er ihn jetzt nicht braucht. Solange der Heap langsam wächst, bleibt der belegte Speicher reserviert — das ist per Design kein Leak.

Unser Service verarbeitet eingehende Webhooks und extrahiert daraus JSON-Felder wie event_id, repository oder commit_sha. Das Parsing lief über json.Unmarshal in ein map[string]interface{}. Dann folgte eine Zeile, die auf den ersten Blick harmlos aussah:

value := raw["meta"].(string)[:len(raw["meta"].(string))-1]

Der Ausdruck raw["meta"].(string) erzeugt jedes Mal eine neue Kopie des Strings. In Go sind Strings immutable — jede Teilung, jeder Slice erzeugt ein neues Header-Objekt. Wenn diese Zeile tausendmal pro Sekunde läuft, häufen sich die Kopien im Heap. Der GC sammelt sie, aber nur mit Verzögerung. Das Ergebnis: ein Speicherverbrauch, der wie ein Leck aussieht, aber nur eine unbeabsichtigte Retention ist.

Ich bestätigte es mit pprof:

go tool pprof -alloc_space -cum http://localhost:6060/debug/pprof/allocs

Die heißeste Spur führte direkt zu dieser einen Zeile. 78 % aller Allokationen stammten aus genau diesem Substring.

Lösung

Die einfachste und sauberste Lösung: den String nur einmal casten und danach operieren. Das vermeidet die Doppel-Cast-Kopie komplement.

// VORHER: zwei Casts, zwei Kopien
// value := raw["meta"].(string)[:len(raw["meta"].(string))-1]

// NACHHER: ein Cast, ein Slice
metaStr := raw["meta"].(string)
value := metaStr[:len(metaStr)-1]

Das ist der einzige Fix, den ich im Produktionscode vorgenommen habe. Die Zeile lief bereits in der nächsten Minute — ohne Neustart des gesamten Services, dank Hot-Reload der Konfiguration.

Für den Rest des Services fügte ich ein kleines Inflight-Monitoring hinzu, damit wir zukünftig solche Retentionen früher erkennen:

import "runtime"

func logMemory(label string) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    log.Printf("[MEM] %s — Alloc: %d MB, Sys: %d MB, NumGC: %d",
        label, m.Alloc/1024/1024, m.Sys/1024/1024, m.NumGC)
}

Ich rufe logMemory("webhook-received") am Anfang jedes Handlers auf. Seitdem sehe ich im Log, wenn ein Request ungewöhnlich viel Heap hinterlässt.

Verifikation

So habe ich den Fix bestätigt:

  1. Stress-Test lokal: 100.000 Webhooks mit hey oder einem einfachen Go-Benchmark simulieren.
func BenchmarkWebhook(b *testing.B) {
    payload := `{"meta":"test-value-123"}`
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var raw map[string]interface{}
        json.Unmarshal([]byte(payload), &raw)
        metaStr := raw["meta"].(string)
        _ = metaStr[:len(metaStr)-1]
    }
}

Erwartete Ausgabe: 0 allocs/op. Vorher waren es 2 Alloks pro Operation — genau die beiden Casts.

  1. Heap-Vergleich: Vorher wuchs der Heap nach 30 Minuten auf ~600 MB an. Nach dem Fix bleibt er stabil unter 120 MB.

  2. Production-Canary: Den Fix zuerst auf einer Canary-Instanz ausgerollt. Nach 6 Stunden: Speicher bei 298 MB, keine Retention sichtbar. Dann Full Rollout.

Fazit

Die Lehre ist einfach: In Go sind Casts keine Null-Kosten-Operation. Jede Typumwandlung, jeder String-Slice kann eine Kopie erzeugen — und tausend solcher Kopien pro Sekunde werden langsam, aber sicher zum Problem. Der Garbage Collector rettet dich nicht vor schlechtem Code, er bremst nur die Katastrophe.

Wenn also der Speicher steigt, aber keine offensichtliche Schleife oder kein wachsendes Slice zu sehen ist: schau auf die kleinen Casts. Oft liegt das Leak nicht im Algorithmus, sondern in einem einzigen Ausdruck, der zu oft ausgewertet wird.

Siehe auch: