Der erste Go-Dienst neben einem PHP-Monolithen erbt fast immer dessen Konfigurationsmodell: eine .env auf dem Server, eingelesen beim Start. Das funktioniert, und es bringt zwei Eigenschaften mit, die in einem Dienst unangenehmer sind als in einer PHP-Anwendung.
Erstens fällt eine fehlende Angabe erst auf, wenn der Code sie braucht, und das kann Stunden nach dem Start sein. Zweitens liegt das Datenbankpasswort in einer Datei, die mit dem Ausrollen verteilt wird, und eine Änderung braucht ein Ausrollen.
Dieser Artikel beschreibt, wie Konfiguration und Geheimnisse in einem Go-Dienst getrennt werden, ohne dass der PHP-Teil davon etwas merkt.
Drei Ebenen, drei Herkünfte
Der Ausgangspunkt ist eine Unterscheidung, die in einer .env nicht existiert und die alles Weitere ordnet.
Vorgaben stehen im Code. Zeitgrenzen, Größen von Verbindungspools, Pfade. Sie sind bewusst gewählt, sie gehören zum Programm, und sie werden nur in Ausnahmefällen überschrieben. Eine Vorgabe im Code ist besser als eine in einer Datei, weil sie zusammen mit dem Code geprüft wird.
Umgebungsangaben unterscheiden die Umgebungen: Adressen, Namen von Warteschlangen, Merkmale, die eingeschaltet sind. Sie kommen aus Umgebungsvariablen, sie sind sichtbar, und genau das ist richtig: Wer wissen will, wogegen ein Dienst gerade arbeitet, soll das ohne Rückfrage sehen können.
Geheimnisse sind alles, womit man sich irgendwo anmelden kann. Sie kommen weder aus dem Code noch aus Umgebungsvariablen, sondern werden beim Start geholt.
Die Trennung zwischen den letzten beiden ist der Punkt, an dem sich etwas ändert. Umgebungsvariablen sind kein Geheimnis: Sie stehen in der Beschreibung der Aufgabe, sie tauchen in Fehlerausgaben auf, und wer auf den Prozess sieht, liest sie mit.
Beim Start prüfen und scheitern
Die erste Eigenschaft, die ich in jedem Dienst haben will: Wenn eine Angabe fehlt oder unsinnig ist, startet der Dienst nicht. Nicht später, sondern sofort, mit einer Meldung, die den Namen der Angabe enthält.
type Konfiguration struct {
DatenbankAdresse string
AltsystemURL string
Arbeiter int
Frist time.Duration
}
func Laden() (Konfiguration, error) {
k := Konfiguration{
// Vorgaben im Code. Wer sie aendern will, setzt eine Variable;
// wer sie nicht aendert, hat trotzdem einen begruendeten Wert.
Arbeiter: 8,
Frist: 800 * time.Millisecond,
}
var fehlt []string
pflicht := func(name string) string {
wert := os.Getenv(name)
if wert == "" {
fehlt = append(fehlt, name)
}
return wert
}
k.DatenbankAdresse = pflicht("DB_ADRESSE")
k.AltsystemURL = pflicht("ALTSYSTEM_URL")
if n := os.Getenv("ARBEITER"); n != "" {
v, err := strconv.Atoi(n)
if err != nil || v < 1 {
return k, fmt.Errorf("ARBEITER muss eine Zahl ab 1 sein, ist %q", n)
}
k.Arbeiter = v
}
// Alle fehlenden auf einmal melden, nicht einzeln. Sonst startet
// jemand fuenfmal und bekommt fuenfmal eine neue Ueberraschung.
if len(fehlt) > 0 {
return k, fmt.Errorf("Pflichtangaben fehlen: %s", strings.Join(fehlt, ", "))
}
return k, nil
}Der Kommentar am Ende beschreibt den Unterschied, der in der Praxis zählt. Ein Start, der bei der ersten fehlenden Angabe abbricht, erzeugt eine Reihe von Versuchen. Einer, der alle sammelt, erzeugt eine Liste, und die kann jemand in einem Schritt abarbeiten.
Dazu gehört eine zweite Gewohnheit: Die Konfiguration wird einmal beim Start geladen und danach als Wert weitergereicht. Ein os.Getenv mitten im Code ist eine Abhängigkeit, die in keinem Test auftaucht und die man beim Lesen nicht sieht.
Geheimnisse kommen nicht aus der Umgebung
Für Geheimnisse gilt derselbe Ablauf, aber eine andere Quelle. Der Dienst bekommt keinen Wert, sondern einen Verweis darauf, und holt ihn beim Start selbst.
// Was der Dienst als Umgebungsvariable bekommt, ist der Verweis:
// DB_GEHEIMNIS=arn:aws:secretsmanager:eu-central-1:...:geheimnis/db-prod
//
// Der Wert selbst steht nirgends in der Aufgabenbeschreibung, in keinem
// Protokoll und in keiner Fehlerausgabe.
func geheimnisHolen(ctx context.Context, verweis string) (Zugangsdaten, error) {
ctx, abbruch := context.WithTimeout(ctx, 5*time.Second)
defer abbruch()
aus, err := smClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(verweis),
})
if err != nil {
return Zugangsdaten{}, fmt.Errorf("geheimnis %s holen: %w", verweis, err)
}
var z Zugangsdaten
if err := json.Unmarshal([]byte(*aus.SecretString), &z); err != nil {
// Bewusst ohne den Inhalt in der Meldung: eine Fehlermeldung
// mit dem Geheimnis darin landet im Protokoll.
return Zugangsdaten{}, fmt.Errorf("geheimnis %s hat nicht die erwartete Form", verweis)
}
return z, nil
}Zwei Details darin sind wichtiger als der Rest. Der Fehler beim Auspacken nennt den Inhalt nicht; sonst steht das Passwort im Protokoll, sobald das Format einmal nicht stimmt. Und der Aufruf hat eine Frist: Ein Dienst, der beim Start ewig auf einen Geheimnisspeicher wartet, sieht für den Orchestrierer aus wie ein hängender Prozess und wird neu gestartet, immer wieder.
Für die Zugangsberechtigung gilt derselbe Grundsatz wie überall: Der Dienst darf genau dieses eine Geheimnis lesen, nicht alle. Das ist eine Zeile in der Rechtevergabe und der Unterschied zwischen einem kompromittierten Dienst und einer kompromittierten Umgebung.
Was in einem alten PHP-System an derselben Stelle schiefgeht, steht in Zehn Lücken, die in jedem alten PHP-System stecken.
Wechsel ohne Neustart
Wenn Geheimnisse beim Start geholt werden, stellt sich die Frage, was bei einem Wechsel passiert. Die einfachste Antwort ist ein Neustart, und für viele Dienste ist sie richtig: Ein rollender Neustart ist heute ein Vorgang von Minuten ohne Unterbrechung.
Wo das nicht reicht, hilft ein Muster, das ohne Hintergrundprozess auskommt: Das Geheimnis wird nicht als Wert gehalten, sondern hinter einer Funktion, die es bei Bedarf erneuert.
type Geheimnis struct {
mu sync.RWMutex
wert Zugangsdaten
geladen time.Time
holen func(context.Context) (Zugangsdaten, error)
}
// Hol den Wert; ist er aelter als die Frist, wird er erneuert.
// Kein Hintergrundprozess, kein Zeitgeber: die Erneuerung passiert
// im Aufruf, der sie braucht.
func (g *Geheimnis) Wert(ctx context.Context) (Zugangsdaten, error) {
g.mu.RLock()
if time.Since(g.geladen) < 10*time.Minute {
defer g.mu.RUnlock()
return g.wert, nil
}
g.mu.RUnlock()
g.mu.Lock()
defer g.mu.Unlock()
// Zweite Pruefung: waehrend des Wartens auf die Sperre kann ein
// anderer Aufruf schon erneuert haben.
if time.Since(g.geladen) < 10*time.Minute {
return g.wert, nil
}
neu, err := g.holen(ctx)
if err != nil {
// Der alte Wert bleibt gueltig. Ein Aussetzer des Geheimnis-
// speichers darf den Dienst nicht anhalten.
return g.wert, nil
}
g.wert, g.geladen = neu, time.Now()
return neu, nil
}Der letzte Zweig ist der wichtigste: Wenn das Holen scheitert, arbeitet der Dienst mit dem alten Wert weiter. Ein Geheimnisspeicher ist ein Fremdsystem, und ein Dienst, der bei dessen Ausfall stehen bleibt, hat eine Abhängigkeit hinzugewonnen, die er nicht braucht.
Für Datenbankverbindungen kommt eine Besonderheit dazu: Ein Wechsel des Passworts betrifft bestehende Verbindungen nicht, sondern nur neue. Deshalb reicht es, den Verbindungsaufbau über die Funktion oben laufen zu lassen; die offenen Verbindungen laufen aus, und das ist genau das gewünschte Verhalten.
Der Zugriff auf ein Geheimnis ist selbst eine Berechtigung: IAM aufräumen in einem gewachsenen AWS-Konto.
Was der PHP-Teil davon merkt
Nichts, und das ist der Punkt. Die Umstellung betrifft den neuen Dienst, nicht den Monolithen.
Das ist wichtiger, als es klingt, weil hier regelmäßig ein Vorhaben ausufert: Wenn der Go-Dienst Geheimnisse ordentlich verwaltet, liegt der Gedanke nahe, das im Monolithen auch zu tun. Das ist richtig und es ist ein eigenes Vorhaben mit eigenem Termin.
Was sich lohnt, ist eine kleine Vorarbeit: Beide Seiten benutzen dasselbe Geheimnis, nicht zwei Kopien. Wenn der Monolith seine Zugangsdaten aus einer .env liest und der Dienst aus dem Geheimnisspeicher, gibt es zwei Orte, an denen dasselbe Passwort steht. Beim nächsten Wechsel wird einer davon vergessen.
Der kleinste Weg dahin ist ein Schritt beim Ausrollen, der die .env des Monolithen aus dem Geheimnisspeicher erzeugt. Das ist keine schöne Lösung, es ist eine Zeile im Ausrollskript, und es beseitigt die zweite Quelle.
Lokale Entwicklung
Das Modell oben hat eine Schwäche, und die zeigt sich am ersten Tag: Niemand will für einen lokalen Lauf einen Geheimnisspeicher ansprechen.
Die Lösung ist eine zweite Umsetzung derselben Schnittstelle, ausgewählt über eine Umgebungsvariable.
type GeheimnisQuelle interface {
Hole(ctx context.Context, verweis string) (Zugangsdaten, error)
}
func quelleWaehlen() GeheimnisQuelle {
if os.Getenv("UMGEBUNG") == "lokal" {
// Liest aus einer Datei, die nicht im Repository liegt.
// Dieselbe Schnittstelle, damit der Pfad im Code derselbe ist.
return &DateiQuelle{Pfad: ".geheimnisse.json"}
}
return &SecretsManagerQuelle{}
}Zwei Regeln halten das sauber. Die Datei steht in .gitignore, und es gibt eine Beispieldatei mit denselben Schlüsseln und offensichtlich falschen Werten, die im Repository liegt. Und die lokale Quelle wird nur bei einer ausdrücklich gesetzten Umgebungsvariable gewählt, nicht als Rückfall: Ein Dienst, der in Produktion still auf eine Datei ausweicht, weil der Geheimnisspeicher nicht erreichbar ist, ist genau der Fehler, den dieses ganze Modell verhindern soll.
Wie ein Go-Dienst technisch neben ein PHP-System gesetzt wird, steht in einem eigenen Artikel: Golang in einer PHP-Welt. Wie ich so ein Vorhaben aufsetze, steht auf einer eigenen Seite.
Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.

