Skip to content

Build Tags: One Codebase for Linux and macOS

Build Tags: One Codebase for Linux and macOS

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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:build was introduced precisely because the old one was hard to parse and easy to break with a stray space. gofmt now keeps both in sync during the transition.
  • Go has around 40 built-in "GOOS" values (linux, darwin, windows, freebsd, plan9, even js for WebAssembly) and its own tags for every GOARCH. All of it is available in //go:build with zero configuration.
  • Clever filename suffixes: _test.go is tests, _linux.go is GOOS, _amd64.go is GOARCH, and _linux_amd64.go is 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 && cgo tag 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 a runtime.go as 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,arm64 matrix with go 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

Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow

Previous: Embedded vs External DB | Next: Go Syscalls