Go-Software mit Goreleaser und ... Zig ausliefern?

Veröffentlicht: 26. Okt. 2025

Aktualisiert: 2. Nov. 2025

Die Go-Community ist sich uneinig, ob man C-Libraries oder Neuimplementierungen in Go verwenden sollte. C in Go zu verwenden ist auf den ersten Blick einfach, bringt aber im weiteren Verlauf Probleme mit sich.

Ich veröffentliche mein Versionskontrollsystem Pogo schon eine Weile. Anfangs habe ich für alles reines Go verwendet, auch für die Kompression mit Zstd.

Das funktionierte problemlos. Aber dort, wo es darauf ankam, wollte ich die Geschwindigkeit und Reife von C-Libraries. Zstd wurde ursprünglich bei Facebook in C geschrieben. Mit diesem ausgereiften Code würde ich mich wohler fühlen als mit einer Neuimplementierung in Go. Das ist nicht als Kritik an der Go-Implementierung gemeint.

C in Go verwenden

C aus Go aufzurufen ist einfach. Du schreibst einen speziellen Kommentar über eine Zeile mit import "C". In diesem Kommentar bindest du Header oder kleine C-Codeabschnitte ein. Go lässt dann die C-Toolchain deines Systems diesen Code kompilieren und in dein Go-Binary linken. Mit #cgo CFLAGS und #cgo LDFLAGS kannst du Flags übergeben. Mit Build-Tags wie //go:build cgo kannst du C-Abhängigkeiten auch ein- und ausschalten.

/*
#cgo CFLAGS: -O3
#include <stdlib.h>

int add(int a, int b) { return a + b; }
*/
import "C"

func Add(a, b int) int {
	return int(C.add(C.int(a), C.int(b)))
}

Bedingte Builds sind praktisch. Du kannst einen Fallback in reinem Go und eine C-basierte Version vorhalten:

//go:build cgo

package compression

// C-backed version here
//go:build !cgo

package compression

// Pure Go fallback here

So habe ich es gemacht. Wenn ein C-Compiler verfügbar war, habe ich die C-Libraries verwendet. Falls nicht, gab es einen Fallback in reinem Go.

Cross-Compilation mit Go, der angenehme Teil

Mit Go lassen sich leicht Builds für andere Plattformen erstellen. Du wählst das Ziel, indem du GOOS und GOARCH setzt. Die Toolchain baut deinen Code für dieses Ziel. Einfach.

GOOS=darwin GOARCH=arm64 go build -o bin/pogo

Für reines Go funktioniert das hervorragend. Es ist schnell, reproduzierbar und portabel.

Goreleaser kommt ins Spiel

Goreleaser ist ein großartiges Tool, um Go-Binaries zu bauen und über Paketmanager wie Homebrew, npm und winget zu veröffentlichen. Es nutzt Cross-Compilation, um eine Build-Matrix zu erzeugen. Das bedeutet, dass du Goreleaser einmal ausführst und es Builds für alle deine Zielplattformen auf einmal erstellt. Für reines Go ist das perfekt. Bei C-Code gibt es aber einen Haken: Die Cross-Compilation funktioniert nicht.

Das ist nachvollziehbar. Wenn du C baust, brauchst du einen Compiler und System-Header für die Zielplattform. Wenn du auf einem Linux-Runner cross-kompilierst, weiß der C-Compiler nichts von Windows oder macOS. Cross-Compilation mit CGO ist nicht trivial und erfordert eine Cross-C-Toolchain und Header für das Zielsystem. Standardmäßig schlägt sie fehl. Du solltest deine Builds auf den Zielplattformen ausführen, das ist viel einfacher.

Mit Split-and-Merge die schwierigen Teile nicht cross-kompilieren

Ich fand einen hilfreichen Beitrag: Split and Merge with Goreleaser Pro. Die Idee ist elegant:

nativer Build

nativer Build

nativer Build

Git-Tag-Push

Linux-Runner

macOS-Runner

Windows-Runner

dist/linux Artefakte

dist/macos Artefakte

dist/windows Artefakte

Merge-Job

Ein Release mit allen Zielen

In der CI sieht das so aus:

# Per-platform job
goreleaser release --clean --split --skip-publish

# Merge job (after collecting all artifacts)
goreleaser continue --merge

Neues Problem: Windows

Linux ARM und macOS ARM waren dank nativer Runner kein Problem. Windows ARM war mühsam. Mit den verfügbaren Runnern lief es nicht rund, und die Cross-Compilation wählte ständig den falschen gcc/clang.

Zig hilft aus

Zig bringt eine großartige C-Toolchain mit eingebauter Cross-Compilation mit. Ich habe Go auf die Compiler von Zig verwiesen und ein Target-Triple gesetzt. Damit ließen sich die C-Teile zuverlässig für Windows ARM kompilieren.

# Cross-compile Windows ARM CGO with Zig
GOOS=windows GOARCH=arm64 CGO_ENABLED=1 \
CC="zig cc -target aarch64-windows" \
CXX="zig c++ -target aarch64-windows" \
go build -o bin/pogo.exe

Nächstes Problem: libc

Kurz zur Erinnerung: Die libc ist eine Library, die das Betriebssystem bereitstellt. Sie übersetzt C-Funktionsaufrufe in Systemaufrufe. Es gibt mehrere libc-Implementierungen für verschiedene Betriebssysteme. Die bekanntesten sind glibc und musl, aber es gibt weitere. Debian verwendet glibc, du könntest dort aber auch musl verwenden, wenn du das musl-libc-Paket bereitstellst. Genau darum geht es. Das Betriebssystem stellt die libc bereit, damit du es nicht tun musst. Sprachen wie Go und Zig verwenden gar keine libc, sondern nutzen die Systemaufrufe direkt. Das ist ähnlich wie das statische Linken einer libc.

Standardmäßig wird C-Code dynamisch gegen die libc deines Betriebssystems gelinkt. Die meisten Linux-Distributionen verwenden glibc. Alpine verwendet musl, Android verwendet Bionic und so weiter. Separate Binaries für jede libc auszuliefern würde die Matrix sprengen. Schlimmer noch: Die von Goreleaser bereitgestellten Installer lassen dich bei der Installation kein Binary anhand der libc auswählen. Sie unterstützen nur Betriebssystem und Architektur.

Ich brauchte ein einziges Linux-Binary. Ohne libc-Abhängigkeit zur Laufzeit. Die Lösung war, statisch gegen musl zu linken. Zig macht das einfach, selbst auf Ubuntu-basierten Runnern.

# Statically link musl libc using Zig
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
CC="zig cc -target x86_64-linux-musl" \
CXX="zig c++ -target x86_64-linux-musl" \
go build -trimpath \
  -ldflags='-s -w -linkmode=external -extldflags "-static"' \
  -o bin/pogo

Das Ergebnis ist ein einziges Linux-Binary, das auf glibc- und musl-Systemen läuft, weil es die libc des Hostsystems überhaupt nicht braucht. Problem gelöst.

Split-and-Merge loswerden

Carlos, der Maintainer von Goreleaser, wies mich darauf hin, dass ich den gesamten Workflow vereinfachen könnte.

Das Beispiel ist schön, aber mit Zig brauchst du kein Split-and-Merge. Du kannst stattdessen das Feld builds.env mit einem Template abhängig von Os/Arch setzen. Hast du so etwas schon versucht?

Das wäre auch eine tolle Ergänzung zu den Beispielen in @goreleaser.

Er hatte recht, und ich fand ein Beispiel, das genau das tat.

Beide Optionen sind sinnvoll. Ich habe damit herumgespielt und bin zu dem Schluss gekommen, dass beide Vor- und Nachteile haben. Wenn du für jedes Ziel einen eigenen Runner verwendest, laufen diese parallel. Das ist gut für die Geschwindigkeit. Außerdem lassen sich so leicht die neuesten Plattform-SDKs für die Builds verwenden, was unter Windows und macOS ein Vorteil sein kann.

Wenn du den gesamten Build auf einem Runner zusammenfasst, muss dieser aus rechtlichen Gründen bei der Cross-Compilation unter macOS laufen. Entweder definierst du die C- und C++-Compiler für jedes Ziel in der Goreleaser-Konfiguration, oder du erstellst ein Wrapper-Skript. Deine CI wird deutlich einfacher, weil du nur Go und Zig installieren und dann Goreleaser ausführen musst.

Welche Lösung werde ich verwenden?

Ich bin mir noch nicht sicher, welche Lösung ich verwenden werde. Wahrscheinlich nutze ich vorerst goreleaser/example-zig-cgo. Es ist einfach und leicht zu verstehen.

Wenn ich bei der Cross-Compilation auf Probleme stoße, werde ich zu nativen Runnern mit Split-and-Merge zurückkehren.

Offizielles Beispiel mit einem Runner: goreleaser/example-zig-cgo
Meine Lösung mit nativen Runnern und Split-and-Merge: tsukinoko-kun/goreleaser-cgo
Meine Lösung mit einem Runner: tsukinoko-kun/goreleaser-cgo:no-pro