Ein Go-Projekt zu übernehmen ist leichter als fast jedes andere.
Bis auf die eine Frage, die zuerst kommen müsste.
Go-Code verrät viel über sich. Das Format erzwingt gofmt, die Abhängigkeiten stehen in einer Datei statt in drei Konfigurationsschichten, Fehler sind Rückgabewerte statt Ausnahmen, die irgendwo weiter oben gefangen werden, und es gibt kein Framework, das zur Laufzeit Dinge tut, die nicht im Code stehen. Wer Go lesen kann, findet sich in einer fremden Go-Codebasis meist an einem Tag zurecht. Bei einem gewachsenen PHP-Projekt dauert dasselbe eine Woche.
Was der Code nicht verrät: ob er überhaupt läuft, aus welchem Stand die Binärdatei in Produktion gebaut wurde, und was passiert, wenn die Last kommt. Dort liegen die Überraschungen. Dieser Artikel beschreibt, was ich in der ersten Woche mit einem fremden Go-Projekt tue, und zwar in dieser Reihenfolge.
Zugänge, Eigentum an den Konten und eine Sicherung, die sich zurückspielen lässt, sind die Vorarbeit für jede Übernahme. Die steht in Ihr Entwickler ist weg. Hier geht es um das, was an Go besonders ist.
Eine Offenlegung vorweg: Nicht alle Fälle unten stammen aus Übernahmen im Auftrag. Zwei betreffen Code, den ich selbst geschrieben und dann monatelang nicht angefasst hatte. Das ist dieselbe Lage. Code, den gerade niemand im Kopf hat, ist fremder Code, auch wenn der eigene Name im Git-Log steht.
Tag 1: Läuft das überhaupt?
Im September habe ich einen eigenen Go-Dienst aufgeräumt, einen kleinen Job-Runner für persönliche Automatisierungen. Er schrieb bei jedem Lauf Metriken in eine Datei. Die Datei war zuletzt am 17. März geschrieben worden. Der Dienst lief also ein halbes Jahr nicht, und niemand hatte es bemerkt, ich eingeschlossen.
Schlimmer war der Deploy. Nach dem Umzug des Repositories zeigte die Compose-Datei auf einen Image-Pfad, den es nicht mehr gab. Vier Deploys in vier Tagen meldeten Erfolg, während der Dienst heruntergefahren und nicht wieder hochgekommen war. Der SSH-Schritt der Pipeline gab den Fehlercode des Befehls auf dem Server nicht weiter. Grün war die Meldung des Werkzeugs, nicht der Zustand des Servers.
Seitdem lautet die erste Frage bei jedem fremden Dienst nicht „wie ist der Code“, sondern „läuft er, und woran sehe ich das“. Ich prüfe drei Dinge, und zwar auf dem Zielsystem, nicht im Repository:
- Die letzte echte Arbeit. Nicht der letzte Deploy, sondern die letzte Logzeile, die eine verarbeitete Anfrage oder einen erledigten Job belegt. Ein Health-Check, der „ok“ sagt, belegt nur, dass der Prozess lebt.
- Das laufende Image. Welcher Tag, welcher Digest, gebaut wann. Der Tag
latestist keine Antwort. - Den Stand in der Binärdatei. Hier hilft Go mehr als jede andere Sprache, mit der ich gearbeitet habe.
go version -m ./serviceDer Befehl liest die Build-Informationen, die der Compiler seit Go 1.18 in jede Binärdatei schreibt: die Go-Version, den Modulpfad, jede Abhängigkeit mit ihrer Version und, wenn in einem Git-Checkout gebaut wurde, den Commit. Gekürzt sieht das so aus:
service: go1.22.5
path example.com/billing/cmd/service
dep github.com/aws/aws-sdk-go-v2 v1.30.3
build vcs=git
build vcs.revision=4f1c2e9d0b7a…
build vcs.modified=trueDie letzte Zeile ist die interessante. vcs.modified=true heißt: Die Binärdatei in Produktion wurde aus einem Arbeitsstand mit Änderungen gebaut, die nie committet wurden. Den Code, der dort läuft, gibt es dann in keinem Repository. Fehlen die vcs-Zeilen ganz, wurde meist in einem Docker-Build ohne .git gebaut. Das ist kein Fehler. Dann muss der Commit aber auf anderem Weg ans Image, etwa über ein Label, sonst weiß niemand, was läuft.
Tag 2: Baut es, und womit?
Ein Go-Projekt baut man mit einem Befehl. Das ist die Theorie. In der Praxis scheitert der erste Build an einer von drei Stellen, und keine davon steht in der README.
- Die Go-Version. Seit Go 1.21 ist die
go-Zeile in dergo.modeine Mindestanforderung, und einetoolchain-Zeile kann eine bestimmte Version verlangen, die das Werkzeug dann selbst herunterlädt. Wer im Container mitGOTOOLCHAIN=localbaut, bekommt stattdessen einen Fehler. Ich lese die Version an vier Stellen: in dergo.mod, im Dockerfile, in der CI und in der Binärdatei von gestern. Selten sagen alle vier dasselbe. - Private Module. Modulpfade, die auf einen internen Git-Server zeigen, brauchen
GOPRIVATEund Zugangsdaten. Zeigt ein Pfad auf einen Server, den es nicht mehr gibt, baut das Projekt nur noch, solange auf irgendeinem Rechner der Modul-Cache warm ist. Das merkt man erst, wenn dieser Rechner weg ist. - Cgo. Ein Paket mit C-Anteil braucht einen C-Compiler im Build-Image. Steht irgendwo
CGO_ENABLED=0, weil die Binärdatei statisch und klein sein soll, bricht genau dieses Paket.
Die Dokumentation hilft dabei weniger, als man hofft. In einem gewachsenen Repository stand in der Anleitung für Coding-Agenten eine Go-Version, die dort nie lief, gleich neben einem Verweis auf einen Git-Server, der längst abgeschaltet war. Niemand hatte gelogen. Die Datei war einmal richtig gewesen und danach nie wieder geprüft worden. Ich glaube deshalb der go.mod und der Binärdatei, nicht dem Text daneben.
Warum solche Anleitungen für Agenten gefährlicher sind als für Menschen, steht in Ist Ihr Code zu alt für Coding-Agenten?
Am Ende des zweiten Tages steht ein Befehl, der alle Prüfungen ausführt, die das Projekt kennt, lokal und in der CI derselbe:
go build ./... && go vet ./... && go test ./...Was dabei rot ist, wird nicht repariert. Es wird aufgeschrieben. Reparieren ist Woche zwei.
Tag 3: Welche Lücken sind erreichbar?
Ein Go-Repository, das niemand anfasst, altert trotzdem. In einer Vorlage für gRPC-Dienste, die bei mir auf GitHub liegt, meldete GitHub im September eine als kritisch eingestufte Lücke in google.golang.org/grpc und eine weitere in golang.org/x/net. Am Code hatte sich nichts geändert. An der Welt um ihn herum schon.
Dependabot meldet, dass ein verwundbares Modul im Abhängigkeitsbaum steht. Das ist nützlich, aber für eine Übernahme zu laut, denn es meldet auch Lücken in Funktionen, die der Code nie aufruft. Die engere Frage beantwortet govulncheck:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
govulncheck -mode=binary ./serviceIm ersten Aufruf verfolgt das Werkzeug die Aufrufe im Quellcode und meldet nur Lücken, die der Code tatsächlich erreicht. Im zweiten prüft es die Binärdatei, die in Produktion läuft. Bei einer Übernahme ist das oft die ehrlichere Antwort, weil Repository und Binärdatei nicht denselben Stand haben müssen. Tag 1 hat gezeigt, warum.
Danach verschaffe ich mir einen Überblick, wie weit die Abhängigkeiten zurückliegen:
go list -m -u all | grep '\['Jede Zeile mit einer Version in eckigen Klammern hat ein Update. In der ersten Woche aktualisiere ich nur, was govulncheck als erreichbar meldet, ein Modul je Commit. Der Rest kommt auf eine Liste und wird zu einem Teil des Patch-Managements, das es vorher meist nicht gab.
Tag 4: Was passiert unter Last?
Go-Code, der im Test grün ist, kann in Produktion trotzdem stehen bleiben. Die Gründe sind in fremden Codebasen erstaunlich oft dieselben, und man findet sie, bevor die Last sie findet.
Den ersten habe ich selbst erlebt, in Code, den ein Agent geschrieben hatte. Eine Schleife öffnete in jedem Durchlauf eine Datei und schloss sie erst am Ende der Funktion statt direkt nach dem Gebrauch. Im Test mit zehn Dateien fällt das nicht auf. In Produktion stapeln sich die offenen Handles, bis das Betriebssystem keine mehr hergibt und der Dienst steht. Kompiliert, Tests grün, Review unauffällig.
Deshalb suche ich in einer fremden Codebasis gezielt nach vier Mustern:
deferin Schleifen. In Go steckt hinter dem Fall oben fast immer eindefer, das erst beim Verlassen der Funktion greift. Was in einer Schleife geöffnet wird, gehört in der Schleife geschlossen, am einfachsten in einer eigenen kleinen Funktion.- HTTP ohne Zeitgrenzen.
http.Getund derhttp.DefaultClientwarten ohne Zeitgrenze. Einhttp.ServerohneReadHeaderTimeoutwartet ebenso geduldig auf langsame Clients. Hängt ein Fremddienst, hält er Goroutinen fest, bis der Speicher voll ist. - Goroutinen ohne Ende. Jede
go-Anweisung braucht einen Weg, auf dem sie endet, meist über einencontext. Das sichtbare Symptom im Betrieb ist eine Zahl von Goroutinen, die nur steigt und nie fällt. - Datenwettläufe. Dafür gibt es ein Werkzeug, das ich auf jede fremde Codebasis einmal loslasse.
go test -race ./...
staticcheck ./...Der Race Detector braucht Cgo und macht die Tests deutlich langsamer. Er gehört deshalb in einen eigenen Schritt der CI, nicht in jeden lokalen Lauf. Findet er bei einer Übernahme nichts, heißt das meist nur, dass die Tests die nebenläufigen Pfade nicht berühren. Findet er etwas, ist es echt: Er meldet nur Wettläufe, die während des Laufs tatsächlich stattgefunden haben.
Tag 5: Was die Fehlermeldungen verschweigen
Am letzten Tag lese ich, was der Dienst im Betrieb schreibt, nicht, was im Code steht. Das ist der Teil, der am meisten über die Menschen verrät, die ihn gebaut haben.
In einem Dienst, an dem ich gerade arbeite, meldete das Log sporadisch, dass Kampagnen nicht aufgelöst werden konnten. Das klang nach fehlenden Daten. Die Ursache waren sporadische Zeitüberschreitungen beim Zugriff auf DynamoDB und Valkey, und die Meldung führte zuverlässig in die falsche Richtung. Wer nach fehlenden Kampagnen sucht, findet keine.
In Go hat so etwas fast immer dieselbe Form: Ein Fehler wird durch eine neue Meldung ersetzt, statt eingepackt zu werden. Der Unterschied ist eine Zeile, und sie entscheidet, ob im Log die Ursache steht oder nur ihre Folge.
// verliert die Ursache
return errors.New("campaign not resolved")
// behält sie
return fmt.Errorf("resolve campaign %s: %w", id, err)Ich suche deshalb nach errors.New und fmt.Errorf ohne %w auf den Pfaden, die Fremdsysteme aufrufen, und gleiche die häufigsten Fehlermeldungen im Log gegen ihre Ursachen ab. Jede Meldung, die etwas anderes behauptet, als passiert ist, kommt auf die Liste.
Wenn der Dienst Teil einer größeren Kette ist, reicht das Log nicht. Wie ein Aufruf über Dienstgrenzen hinweg sichtbar wird, steht in OpenTelemetry in PHP und Go einführen.
Was ich in der ersten Woche nicht tue
- Keine neue Go-Version. Erst wenn der Build reproduzierbar ist und die Tests laufen. Sonst weiß niemand, ob ein Fehler vom Wechsel kommt oder schon vorher da war.
- Keine neue Verzeichnisstruktur. Ein Projekt ohne
cmd/undinternal/ist nicht kaputt. Umziehen erzeugt Diffs, die jede spätere Suche im Git-Log erschweren, und bringt dem Betrieb nichts. - Kein Framework und kein ORM. Wer in einem fremden Go-Dienst ein Web-Framework einführt, übernimmt nicht. Er baut neu, nur langsamer.
- Keine gesammelten Updates. Ein Commit, der vierzig Module auf einmal hebt, lässt sich beim ersten Fehler nicht mehr halbieren.
Eine Ausnahme mache ich, wenn Geheimnisse im Klartext in der Umgebung oder im Repository liegen. Wie das in Go sauber aussieht, steht in Konfiguration und Secrets für Go-Services.
Was am Ende der Woche auf dem Tisch liegt
Keine Umbauten. Eine Seite mit vier Antworten:
- Wo der Dienst läuft, aus welchem Commit er gebaut ist und woran man sieht, dass er arbeitet.
- Wie man ihn baut, mit welcher Go-Version, und der eine Befehl, der alle Prüfungen ausführt.
- Welche Lücken erreichbar sind und welche schon behoben sind.
- Die drei Stellen, die unter Last zuerst brechen, jede mit einem Beleg.
Dazu kommt ein Runbook für die zwei Störungen, die am wahrscheinlichsten sind. Mit dieser Seite ist das Projekt übernommen, auch wenn noch keine Zeile Code geändert wurde. Der Bus-Faktor hängt ab diesem Tag nicht mehr an der Person, die gegangen ist.
Liegt der Code noch bei einem Dienstleister, kommt vor all dem ein anderer Schritt: Quellcode und Zugänge zurückholen.
Häufige Fragen
Wie lange dauert eine Übernahme? Für einen einzelnen Dienst mit überschaubarem Umfang etwa eine Woche bis zu der Seite oben. Hängen mehrere Dienste über gRPC oder Warteschlangen zusammen, kommen je Dienst ein paar Tage dazu, und die Verbindungen dazwischen sind ein eigener Punkt.
Braucht das jemanden, der Go kann? Für die Befehle oben nicht zwingend. Für die Bewertung dessen, was sie finden, schon: Der Race Detector sagt, dass es einen Wettlauf gibt, nicht, ob er Geld kostet. Und govulncheck sagt, dass eine Lücke erreichbar ist, nicht, ob sie von außen ausnutzbar ist.
Kann ein Coding-Agent die Einarbeitung übernehmen? Die Orientierung ja, und zwar gut: Aufrufwege verfolgen, Pakete beschreiben, Tests schreiben, die das heutige Verhalten festhalten. Die Fragen aus Tag 1 und Tag 5 nicht, weil ihre Antwort nicht im Repository steht, sondern auf dem Server und im Log.
Was ich diese Woche tun würde
Wenn bei Ihnen ein Go-Dienst läuft, den gerade niemand im Kopf hat, würde ich zwei Befehle ausführen: go version -m auf die Binärdatei, die in Produktion läuft, und govulncheck -mode=binary auf dieselbe Datei. Der erste sagt, welcher Code dort wirklich läuft. Der zweite, ob er verwundbar ist.
Beide dauern eine Minute. Passt der Commit zum Repository und meldet der zweite Befehl nichts, ist die Übernahme ein überschaubares Vorhaben. Sonst wissen Sie jetzt, womit die Woche anfängt.
Go-Code verrät fast alles über sich. Nur nicht, ob er läuft.
Wenn Sie für diese Woche jemanden suchen, der sie schon öfter gemacht hat: Go-Freelancer für Backends und verteilte Systeme.

