Перейти до змісту

Build Tags: один код для Linux і macOS

Build Tags: один код для Linux і macOS

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Sheep використовує Linux-специфічні системні виклики: namespaces, cgroups, overlay mounts, bridge networking. На macOS їх немає. Але ми хочемо розробляти на Mac і запускати на Linux. Build tags вирішують це елегантно.

Усе, що рантайм реально робить - namespaces, cgroups v2, pivot_root, OverlayFS, bridge і veth-пари, NAT через iptables - це Linux, і тільки Linux. Усе, що над ним - Manager і життєвий цикл контейнера, імпорт і bootstrap образів, CLI - звичайний Go, який компілюється будь-де. Build tags - це рівно та лінія, що розділяє ці дві половини, і ця частина про те, як провести її у правильному місці.

Проблема

Спробуй скомпілювати цей код на macOS:

cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWPID,
}

Отримаєш помилку: undefined: syscall.CLONE_NEWPID. Ця константа існує тільки в Linux. І таких місць в Sheep десятки: mount, pivot_root, mknod, iptables.

Рішення: два файли, одна функція

Go дивиться на коментар //go:build на початку файлу і вирішує, чи включати цей файл у збірку.

runtime_linux.go - повна реалізація для Linux. Усе, від re-exec патерну до overlay mount, живе саме в цьому файлі:

//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 для всього, що не 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) {}

Зверни увагу: stub не імпортує syscall (крім базових речей) і не використовує unix. Це дозволяє компілюватись без помилок.

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 напряму,<br/>без ізоляції"]
    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/>Платформо-незалежний код"]

    LINUX -->|"go build (Linux)"| BIN_L["sheep binary<br/>повна ізоляція"]
    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

Мережа - те саме

network_linux.go (134 рядки): bridge, veth пари, IP allocation, iptables NAT.

network_stub.go (10 рядків):

//go:build !linux

package container

func setupNetworkForContainer(c *Container, pid int) error {
    return nil
}

func LoadIPCounter(baseDir string)  {}
func SaveIPCounter(baseDir string)  {}

На macOS контейнер (ну, процес) працює з мережею хоста. Немає ізоляції, але для розробки і тестування логіки Manager'а - достатньо.

Які файли в Sheep мають build tags

Файл Build tag Що робить
runtime_linux.go linux namespaces, cgroups, overlay, devices
runtime_stub.go !linux exec.Command без ізоляції
network_linux.go linux bridge, veth, iptables
network_stub.go !linux no-op
container.go немає типи, GenerateID - працює скрізь
manager.go немає Create/Start/Stop/Remove - працює скрізь
image.go немає Import, Bootstrap, List - працює скрізь

Ключовий момент: Manager, який координує життєвий цикл контейнера, не має build tags. Він викликає startContainer(), яка залежно від платформи або створює namespace'и, або просто запускає процес.

Як це виглядає на практиці

На macOS:

$ go build ./cmd/sheep
$ ./sheep bootstrap minimal
bootstrapped minimal:latest (a1b2c3d4)

$ ./sheep run --name test minimal /bin/ls
# Працює! Без ізоляції, але працює.
# ls виводить вміст rootfs

На Linux:

$ go build ./cmd/sheep
$ sudo ./sheep bootstrap minimal
$ sudo ./sheep run --name test -m 256m minimal /bin/sh
# Повна ізоляція: namespaces, cgroups, overlay, network

Крос-компіляція:

# На macOS для Linux
$ GOOS=linux GOARCH=amd64 go build -o sheep-linux ./cmd/sheep

# В Docker
$ docker run --rm -v $(pwd):/src -w /src golang:1.23 \
    go build -o sheep ./cmd/sheep

Fallback в Manager

Manager теж враховує платформу. Коли overlay mount не працює (macOS або старе ядро), він копіює rootfs:

func (m *Manager) setupOverlay(id, lowerDir string) (string, error) {
    // ...
    if err := mountOverlay(lowerDir, upper, work, merged); err != nil {
        // Fallback: копіюємо rootfs
        return copyRootFS(lowerDir, merged)
    }
    return merged, nil
}

На macOS mountOverlay() зі stub повертає помилку, і Manager автоматично переходить на копіювання. Контейнер працює, тільки повільніше і без copy-on-write.

Що може піти не так

Demo mode на macOS не тестує реальну ізоляцію. Можна написати код, який працює на Mac, але падає на Linux через особливості namespace'ів. Тому CI/CD повинен запускати інтеграційні тести на Linux.

Ще один момент: //go:build повинен бути на першому рядку файлу (або після коментаря з copyright). Якщо поставити його не туди - Go його проігнорує і спробує скомпілювати обидва файли, отримавши duplicate function error.

І ще дві пастки: - Після //go:build обов'язково має бути порожній рядок перед package. Без нього Go трактує рядок як звичайний коментар і тег не спрацює. - Суфікс імені файлу _linux.go сам по собі вже є build constraint - навіть без рядка //go:build. Якщо назвати файл foo_linux.go, він ніколи не потрапить у macOS-збірку, хоч би що було всередині. Легко спіткнутись, перейменувавши файл.

💡 Цікаві факти

  • Старий синтаксис // +build linux (з пробілом і плюсом) існував до Go 1.17. Новий //go:build ввели саме тому, що старий було важко парсити й легко зламати зайвим пробілом. gofmt тепер тримає обидва синхронними під час переходу.
  • Go має близько 40 вбудованих "GOOS" значень (linux, darwin, windows, freebsd, plan9, навіть js для WebAssembly) і власні теги під кожен GOARCH. Усе це доступно у //go:build без жодної конфігурації.
  • Хитрі суфікси файлів: _test.go - тести, _linux.go - GOOS, _amd64.go - GOARCH, а _linux_amd64.go - обидва одразу. Компілятор розбирає це з самого імені файлу.
  • Docker, containerd і Kubernetes масово користуються цим самим прийомом: купа *_linux.go / *_windows.go / *_unsupported.go, щоб один репозиторій збирався під будь-яку платформу.

Що я зрозумів, поки розбирався з темою

Найкорисніше усвідомлення - build tags не про "підтримку macOS", а про швидкість циклу розробки. Я пишу й ганяю логіку Manager'а прямо на Mac за секунди, без VM і без sudo, а реальну ізоляцію перевіряю на Linux уже в CI.

Але далося це не одразу. Спочатку я тулив runtime.GOOS == "linux" прямо в коді - і отримував undefined: syscall.CLONE_NEWPID на компіляції, бо if не рятує від того, що символу просто немає на цій платформі. Дійшло, що розводити треба на рівні файлів, а не гілок if: лінуксові константи не повинні навіть потрапляти в парсер на macOS.

Що можна покращити

  • Зробити stub чеснішим: замість тихого no-op у мережі логувати "запущено в demo mode, без ізоляції", щоб ніхто не сплутав Mac-збірку з реальною.
  • Додати окремий тег //go:build linux && cgo для шляхів, що потребують cgo, і чистий Go-fallback - зараз цей нюанс не розведено.
  • Винести спільні сигнатури (startContainer, mountOverlay) в runtime.go як документований "контракт платформи", щоб stub і linux-версія гарантовано не розходились.
  • У CI додати матрицю GOOS=linux,darwin × GOARCH=amd64,arm64 з go vet - щоб ловити, що обидві гілки взагалі компілюються, ще до інтеграційних тестів.

Спробуй сам

# На macOS (demo mode):
go build ./cmd/sheep && ./sheep version
# Крос-компіляція для Linux:
GOOS=linux GOARCH=amd64 go build -o sheep-linux ./cmd/sheep
file sheep-linux  # ELF 64-bit LSB executable

Далі - syscall пакет Go: mount, clone, pivot_root і чим syscall відрізняється від unix.

Ресурси

Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow

Попередня: Embedded vs External DB | Наступна: Go Syscalls