Build Tags: One Codebase for Linux and macOS¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Sheep uses Linux-specific system calls: namespaces, cgroups, overlay mounts, bridge networking. None of these exist on macOS. But we want to develop on Mac and run on Linux. Build tags solve this cleanly.
Everything the runtime actually does - namespaces, cgroups v2, pivot_root, OverlayFS, bridge and veth pairs, NAT through iptables - is Linux, and only Linux. Everything above it - the Manager and the container lifecycle, image import and bootstrap, the CLI - is ordinary Go that compiles anywhere. Build tags are exactly the line between those two halves, and this part is about drawing it in the right place.
The Problem¶
Try compiling this code on macOS:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID,
}
You'll get an error: undefined: syscall.CLONE_NEWPID. This constant only exists on Linux. And there are dozens of places like this in Sheep: mount, pivot_root, mknod, iptables.
The Fix: Two Files, One Function¶
Go looks at the //go:build comment at the top of a file and decides whether to include it in the build.
runtime_linux.go - full implementation for Linux. Everything from the re-exec pattern to the overlay mount lives in this file:
//go:build linux
package container
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
func startContainer(c *Container) (int, error) {
cmd := reexecCommand(c)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWNET,
Unshareflags: syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("start namespaced process: %w", err)
}
pid := cmd.Process.Pid
if err := setupCgroups(c, pid); err != nil {
cmd.Process.Kill()
return 0, fmt.Errorf("setup cgroups: %w", err)
}
if err := setupNetworkForContainer(c, pid); err != nil {
fmt.Fprintf(os.Stderr,
"warning: network setup failed: %v\n", err)
}
go cmd.Wait()
return pid, nil
}
func stopContainer(c *Container) (int, error) {
if c.Pid <= 0 {
return 0, nil
}
proc, err := os.FindProcess(c.Pid)
if err != nil {
return 0, nil
}
proc.Signal(syscall.SIGTERM)
proc.Signal(syscall.SIGKILL)
state, _ := proc.Wait()
cleanupCgroups(c)
if state != nil {
return state.ExitCode(), nil
}
return 0, nil
}
func mountOverlay(lower, upper, work, merged string) error {
opts := fmt.Sprintf(
"lowerdir=%s,upperdir=%s,workdir=%s",
lower, upper, work)
return syscall.Mount("overlay", merged, "overlay", 0, opts)
}
func unmountOverlay(merged string) {
syscall.Unmount(merged, syscall.MNT_DETACH)
}
runtime_stub.go - stub for everything that's not Linux:
//go:build !linux
package container
import (
"fmt"
"os"
"os/exec"
)
func startContainer(c *Container) (int, error) {
if len(c.Command) == 0 {
return 0, fmt.Errorf("no command specified")
}
cmd := exec.Command(c.Command[0], c.Command[1:]...)
cmd.Dir = c.RootFS
cmd.Env = append(os.Environ(), c.Config.Env...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("start process: %w", err)
}
go cmd.Wait()
return cmd.Process.Pid, nil
}
func stopContainer(c *Container) (int, error) {
if c.Pid <= 0 {
return 0, nil
}
proc, err := os.FindProcess(c.Pid)
if err != nil {
return 0, nil
}
proc.Kill()
state, _ := proc.Wait()
if state != nil {
return state.ExitCode(), nil
}
return 0, nil
}
func mountOverlay(lower, upper, work, merged string) error {
return fmt.Errorf("overlayfs not supported on this platform")
}
func unmountOverlay(merged string) {}
Notice: the stub doesn't import syscall (beyond the basics) and doesn't use unix. This lets it compile without errors.
graph TD
SRC["package container"]
SRC --> LINUX["runtime_linux.go<br/>//go:build linux<br/>namespaces, cgroups,<br/>overlay, pivot_root"]
SRC --> STUB["runtime_stub.go<br/>//go:build !linux<br/>exec.Command directly,<br/>no isolation"]
SRC --> NET_L["network_linux.go<br/>//go:build linux<br/>bridge, veth, iptables"]
SRC --> NET_S["network_stub.go<br/>//go:build !linux<br/>no-op"]
SRC --> COMMON["container.go, manager.go, image.go<br/>Platform-independent code"]
LINUX -->|"go build (Linux)"| BIN_L["sheep binary<br/>full isolation"]
STUB -->|"go build (macOS)"| BIN_M["sheep binary<br/>demo mode"]
NET_L --> BIN_L
NET_S --> BIN_M
COMMON --> BIN_L
COMMON --> BIN_M
Networking - Same Approach¶
network_linux.go (134 lines): bridge, veth pairs, IP allocation, iptables NAT.
network_stub.go (10 lines):
//go:build !linux
package container
func setupNetworkForContainer(c *Container, pid int) error {
return nil
}
func LoadIPCounter(baseDir string) {}
func SaveIPCounter(baseDir string) {}
On macOS, the container (well, the process) uses the host's network. No isolation, but good enough for developing and testing Manager logic.
Which Files in Sheep Have Build Tags¶
| File | Build tag | What it does |
|---|---|---|
runtime_linux.go |
linux |
namespaces, cgroups, overlay, devices |
runtime_stub.go |
!linux |
exec.Command without isolation |
network_linux.go |
linux |
bridge, veth, iptables |
network_stub.go |
!linux |
no-op |
container.go |
none | types, GenerateID - works everywhere |
manager.go |
none | Create/Start/Stop/Remove - works everywhere |
image.go |
none | Import, Bootstrap, List - works everywhere |
The key point: the Manager that coordinates the container lifecycle has no build tags. It calls startContainer(), which depending on the platform either creates namespaces or just launches a process.
What This Looks Like in Practice¶
On macOS:
$ go build ./cmd/sheep
$ ./sheep bootstrap minimal
bootstrapped minimal:latest (a1b2c3d4)
$ ./sheep run --name test minimal /bin/ls
# Works! No isolation, but it works.
# ls shows the rootfs contents
On Linux:
$ go build ./cmd/sheep
$ sudo ./sheep bootstrap minimal
$ sudo ./sheep run --name test -m 256m minimal /bin/sh
# Full isolation: namespaces, cgroups, overlay, network
Cross-compilation:
# On macOS for Linux
$ GOOS=linux GOARCH=amd64 go build -o sheep-linux ./cmd/sheep
# In Docker
$ docker run --rm -v $(pwd):/src -w /src golang:1.23 \
go build -o sheep ./cmd/sheep
Fallback in Manager¶
The Manager also accounts for the platform. When the overlay mount doesn't work (macOS or an old kernel), it copies the rootfs:
func (m *Manager) setupOverlay(id, lowerDir string) (string, error) {
// ...
if err := mountOverlay(lowerDir, upper, work, merged); err != nil {
// Fallback: copy rootfs
return copyRootFS(lowerDir, merged)
}
return merged, nil
}
On macOS, mountOverlay() from the stub returns an error, and the Manager automatically falls back to copying. The container works, just slower and without copy-on-write.
What Can Go Wrong¶
Demo mode on macOS doesn't test real isolation. You can write code that works on Mac but crashes on Linux due to namespace quirks. That's why CI/CD should run integration tests on Linux.
One more thing: //go:build must be on the first line of the file (or after a copyright comment). If you put it somewhere else, Go ignores it and tries to compile both files, giving you a duplicate function error.
And two more traps:
- There must be a blank line after //go:build before package. Without it, Go treats the line as a regular comment and the tag does nothing.
- The filename suffix _linux.go is itself a build constraint - even without a //go:build line. Name a file foo_linux.go and it will never end up in a macOS build, no matter what's inside. Easy to trip over when renaming a file.
💡 Fun facts¶
- The old syntax
// +build linux(with a space and a plus) existed before Go 1.17. The new//go:buildwas introduced precisely because the old one was hard to parse and easy to break with a stray space.gofmtnow keeps both in sync during the transition. - Go has around 40 built-in "GOOS" values (
linux,darwin,windows,freebsd,plan9, evenjsfor WebAssembly) and its own tags for every GOARCH. All of it is available in//go:buildwith zero configuration. - Clever filename suffixes:
_test.gois tests,_linux.gois GOOS,_amd64.gois GOARCH, and_linux_amd64.gois both at once. The compiler figures this out from the filename alone. - Docker, containerd, and Kubernetes lean heavily on this same trick: piles of
*_linux.go/*_windows.go/*_unsupported.go, so a single repository builds for any platform.
What I figured out while digging into this¶
The most useful realization is that build tags aren't about "supporting macOS" - they're about development-loop speed. I write and run Manager logic right on the Mac in seconds, no VM and no sudo, and I verify real isolation on Linux in CI.
But I didn't get there right away. At first I stuffed runtime.GOOS == "linux" straight into the code, and got undefined: syscall.CLONE_NEWPID at compile time, because an if doesn't save you when the symbol simply doesn't exist on this platform. It clicked that you have to split at the file level, not at if branches: Linux constants shouldn't even reach the parser on macOS.
What could be improved¶
- Make the stub more honest: instead of a silent no-op in networking, log "running in demo mode, no isolation" so nobody confuses the Mac build with the real thing.
- Add a separate
//go:build linux && cgotag for paths that need cgo, plus a pure-Go fallback - right now this nuance isn't split out. - Move the shared signatures (
startContainer,mountOverlay) into aruntime.goas a documented "platform contract," so the stub and the Linux version are guaranteed not to drift apart. - In CI, add a
GOOS=linux,darwin×GOARCH=amd64,arm64matrix withgo vet- to catch that both branches even compile, before the integration tests.
Try It Yourself¶
# On macOS (demo mode):
go build ./cmd/sheep && ./sheep version
# Cross-compile for Linux:
GOOS=linux GOARCH=amd64 go build -o sheep-linux ./cmd/sheep
file sheep-linux # ELF 64-bit LSB executable
Next up - Go's syscall package: mount, clone, pivot_root and the difference between syscall and unix.
Resources¶
- Build constraints: official Go documentation
- golang.org/x/sys/unix: platform-specific syscalls behind build tags
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Embedded vs External DB | Next: Go Syscalls
