Build Tags: один код для Linux і macOS¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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.
Ресурси¶
- Build constraints: офіційна Go-документація
- golang.org/x/sys/unix: платформенні syscalls за build tags
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Embedded vs External DB | Наступна: Go Syscalls
