Go-Container-Images und Kubernetes-Deployments mit .ko.yaml
Das Problem
Bei einer Go-Anwendung ist der eigentliche Container oft erstaunlich viel Handarbeit: Dockerfile pflegen, Cross-Compilation konfigurieren, ein passendes Runtime-Image auswählen, Tags vergeben, Images pushen und anschließend die Referenz im Kubernetes-Manifest aktualisieren.
Für eine einzelne Anwendung ist das machbar. In CI-Pipelines und bei mehreren kleinen Go-Diensten wird es aber schnell zu wiederholtem Glue Code. Genau hier setzt ko an: Statt von einem Dockerfile auszugehen, nimmt ko den Go-Importpfad als zentrale Information und baut daraus direkt ein Container-Image.
Das ist besonders angenehm für Go, weil ein kompiliertes Binary und ein kleines, möglichst leeres Runtime-Image meistens alles sind, was der Container braucht.
.ko.yaml als Build-Konfiguration
Die Build-Konfiguration liegt in .ko.yaml im Projektverzeichnis. In meinem Portfolio sieht sie so aus:
defaultBaseImage: gcr.io/distroless/static-debian12:nonroot
defaultPlatforms:
- linux/amd64
builds:
- id: portfolio
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
Jede Einstellung erfüllt dabei einen konkreten Zweck:
defaultBaseImagelegt das Runtime-Image fest. Das distroless-Image bringt keine Shell und kaum unnötige Userspace-Tools mit und läuft als nicht privilegierter Benutzer.defaultPlatformsbegrenzt den Build hier auflinux/amd64. Für ARM-Deployments können mehrere Plattformen eingetragen werden, etwalinux/amd64undlinux/arm64.buildsbeschreibt die Go-Builds des Projekts. Eine Konfiguration kann mehrere Binaries mit unterschiedlichenid-Werten enthalten.main: .verweist auf das Go-Paket im Projektverzeichnis. Genauso kann dort ein Unterpfad wie./cmd/apistehen.CGO_ENABLED=0erzeugt ein statisch gelinktes Binary, das zu einem statischen Runtime-Image passt.-trimpathentfernt lokale Dateisystempfade aus dem Binary. Das macht Builds reproduzierbarer und verrät weniger über die Build-Umgebung.-s -wreduziert die Größe, indem Symbol- und Debug-Informationen aus dem Binary entfernt werden.
Die Build-spezifischen Werte überschreiben dabei passende globale Defaults. Das ist praktisch, wenn mehrere Go-Binaries im selben Repository unterschiedliche Entry Points oder Build-Tags benötigen.
Image bauen und pushen
Für einen Registry-Build genügt ein Importpfad beziehungsweise ein lokales Go-Paket:
export KO_DOCKER_REPO=registry.example.com/portfolio
ko build .
ko kompiliert das Binary, baut das Container-Image, pusht es in die konfigurierte Registry und gibt eine vollständige Image-Referenz mit Digest aus. Der Digest ist wichtiger als ein frei gewähltes, veränderliches Tag: Er identifiziert exakt den Inhalt, der gebaut und veröffentlicht wurde.
Die Authentifizierung kann dabei über die vorhandene Container-Registry-Konfiguration erfolgen. In CI wird KO_DOCKER_REPO typischerweise aus der Pipeline-Umgebung gesetzt und die Registry vorher mit dem jeweiligen CI-Mechanismus authentifiziert.
Für lokale Tests kann ko auch gegen den lokalen Docker-Daemon auflösen:
ko resolve --local -f k8s/
Dabei werden die ko://-Referenzen unter ko.local/... aufgelöst. Für einen echten Cluster-Betrieb ist eine erreichbare Registry normalerweise die robustere Variante.
Kubernetes kennt den Go-Importpfad
Traditionell enthält ein Deployment bereits eine konkrete Image-Referenz:
apiVersion: apps/v1
kind: Deployment
metadata:
name: portfolio
spec:
selector:
matchLabels:
app: portfolio
template:
metadata:
labels:
app: portfolio
spec:
containers:
- name: portfolio
image: registry.example.com/portfolio:v1.2.3
Mit ko wird daraus eine Referenz auf das Go-Paket:
apiVersion: apps/v1
kind: Deployment
metadata:
name: portfolio
spec:
selector:
matchLabels:
app: portfolio
template:
metadata:
labels:
app: portfolio
spec:
containers:
- name: portfolio
image: ko://github.com/srkn0/main
Das ko://-Schema ist kein Image, das Kubernetes selbst pullen kann. Es ist eine Anweisung an ko: Baue das Go-Paket, veröffentliche das Image und ersetze die Referenz im YAML durch die konkrete Registry-Adresse samt Digest.
Zwei Wege zum Deployment
Wenn ich das gerenderte Manifest kontrollieren oder in eine GitOps-Pipeline übergeben möchte, löse ich die Referenzen separat auf:
ko resolve -f k8s/ > rendered.yaml
kubectl apply -f rendered.yaml
Für den direkten Weg übernimmt ko beide Schritte:
ko apply -f k8s/
Das ist der eigentliche Charme der Integration. Das YAML beschreibt weiterhin Kubernetes-Ressourcen, aber die Image-Auswahl bleibt an der Stelle, an der sie fachlich hingehört: beim Go-Importpfad. Ein manuelles Nachziehen eines Image-Tags zwischen Build und Deployment entfällt.
Die von ko erzeugten Referenzen sind zudem content-addressed. Ändert sich der Code, entsteht ein anderer Digest. Das Deployment zeigt dadurch nachvollziehbar auf genau das Image, das zu diesem Rollout gehört.
Zum Entfernen derselben Ressourcen kann die gleiche Konfiguration wiederverwendet werden:
ko delete -f k8s/
Warum .ko.yaml für Go so gut funktioniert
Die Konfiguration ist klein, aber sie verbindet mehrere bisher getrennte Verantwortlichkeiten:
- Go-Paket und Entry Point definieren, was gebaut wird.
.ko.yamldefiniert reproduzierbare Compiler- und Image-Parameter.ko builderzeugt und veröffentlicht das Image.ko://macht denselben Importpfad im Kubernetes-YAML wiederverwendbar.ko applybringt das Ergebnis in den Cluster.
Dadurch bleibt ein Deployment-Manifest deklarativ, ohne dass es ein veraltetes, von Hand gepflegtes Image-Tag enthalten muss. Gleichzeitig bleibt die Anwendung ein normales Go-Projekt: kein Dockerfile, kein eigener Multi-Stage-Build und kein zusätzliches Image-Build-System.
Das ist kein Ersatz für jede Container-Pipeline. Sobald ein Image zusätzliche native Libraries, mehrere Prozesse oder komplexe Build-Schritte braucht, ist ein klassischer Dockerfile- oder BuildKit-Workflow oft die passendere Wahl. Für statische Go-Dienste, kleine Operatoren und Kubernetes-Controller ist ko aber ein bemerkenswert direkter Weg von main bis zum laufenden Deployment.