syscall пакет Go: mount, clone, pivot_root¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Go дає прямий доступ до системних викликів Linux. В Sheep ми використовуємо два пакети: стандартний syscall і розширений golang.org/x/sys/unix. Ось чому потрібні обидва і як кожен працює.
Кожен механізм цього циклу зрештою впирається в один із цих викликів: namespaces - це прапорці clone(), pivot_root - це unix.PivotRoot(), OverlayFS - один mount() з рядком опцій через кому, а cgroups v2 - взагалі не syscall, а open/write/close по файлах. Попередня частина пояснювала, чому все це ховається за //go:build linux; ця - про те, що саме стоїть за тим тегом.
syscall vs unix¶
syscall - в стандартній бібліотеці, заморожений (Go team більше не додає нових функцій). golang.org/x/sys/unix - розширений пакет, який активно оновлюється і має більше syscalls.
| Функція | Пакет | Чому |
|---|---|---|
Mount() |
syscall |
Є в stdlib |
Unmount() |
syscall |
Є в stdlib |
Sethostname() |
syscall |
Є в stdlib |
PivotRoot() |
unix |
Немає в stdlib |
Mknod() |
unix |
Немає в stdlib |
Mkdev() |
unix |
Немає в stdlib |
clone() - як Go створює namespace'и¶
Go не дає викликати clone() напряму. Замість цього використовуємо exec.Command з SysProcAttr:
cmd := exec.Command(self, "init", "--rootfs", rootfs, "--", "/bin/sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | // hostname
syscall.CLONE_NEWPID | // process IDs
syscall.CLONE_NEWNS | // mount points
syscall.CLONE_NEWIPC | // IPC
syscall.CLONE_NEWNET, // network
Unshareflags: syscall.CLONE_NEWNS, // private mount propagation
}
Коли ти викликаєш cmd.Start(), Go runtime внутрішньо:
1. Викликає clone() з вказаними прапорцями
2. Дочірній процес отримує нові namespace'и
3. Дочірній процес робить execve() для запуску бінарника
Те, що бінарник викликає сам себе з аргументом init, - це re-exec pattern: Go не може безпечно зробити fork(), тому namespace'и створюються на етапі exec.
Unshareflags додатково ізолює mount namespace: зміни mount'ів в контейнері не просочуються назовні.
mount() - файлові системи¶
syscall.Mount() - обгортка над Linux mount(2):
// Сигнатура:
func Mount(source string, target string, fstype string,
flags uintptr, data string) error
В Sheep використовується в кількох місцях:
// 1. Mount /proc для ps, top
syscall.Mount("proc",
filepath.Join(rootfs, "proc"),
"proc", 0, "")
// 2. Mount /sys для інформації про пристрої
syscall.Mount("sysfs",
filepath.Join(rootfs, "sys"),
"sysfs", 0, "")
// 3. Mount /tmp як tmpfs (в пам'яті)
syscall.Mount("tmpfs",
filepath.Join(rootfs, "tmp"),
"tmpfs", 0, "")
// 4. Mount /dev з обмеженнями безпеки
syscall.Mount("tmpfs",
filepath.Join(rootfs, "dev"),
"tmpfs",
syscall.MS_NOSUID|syscall.MS_STRICTATIME,
"mode=755")
// 5. OverlayFS - злиття шарів
opts := fmt.Sprintf(
"lowerdir=%s,upperdir=%s,workdir=%s",
lower, upper, work)
syscall.Mount("overlay", merged, "overlay", 0, opts)
// 6. Bind mount для pivot_root
syscall.Mount(newRoot, newRoot, "",
syscall.MS_BIND|syscall.MS_REC, "")
Флаг MS_NOSUID забороняє setuid біти в /dev. MS_STRICTATIME оновлює access time при кожному доступі. MS_BIND|MS_REC - рекурсивний bind mount.
pivot_root() - зміна кореня¶
pivot_root немає в стандартному syscall, тому імпортуємо unix:
import "golang.org/x/sys/unix"
func pivotRoot(newRoot string) error {
putOld := filepath.Join(newRoot, ".pivot_old")
os.MkdirAll(putOld, 0700)
// Bind mount на себе (вимога pivot_root)
syscall.Mount(newRoot, newRoot, "",
syscall.MS_BIND|syscall.MS_REC, "")
// Атомарна зміна кореня
if err := unix.PivotRoot(newRoot, putOld); err != nil {
return fmt.Errorf("pivot_root: %w", err)
}
os.Chdir("/")
// Відмонтовуємо старий корінь
syscall.Unmount("/.pivot_old", syscall.MNT_DETACH)
os.RemoveAll("/.pivot_old")
return nil
}
unix.PivotRoot(newRoot, putOld) атомарно міняє кореневу FS процесу. Стара FS потрапляє в putOld, де ми її відмонтовуємо з MNT_DETACH (лінивий unmount, не чекає закриття файлів). Чому саме pivot_root, а не chroot, і навіщо той bind mount на себе - у частині про pivot_root.
Mknod() і Mkdev() - створення пристроїв¶
func createDevices(rootfs string) {
devPath := filepath.Join(rootfs, "dev")
devices := []struct {
name string
major uint32
minor uint32
mode uint32
}{
{"null", 1, 3, 0666}, // /dev/null - чорна діра
{"zero", 1, 5, 0666}, // /dev/zero - нулі
{"random", 1, 8, 0666}, // /dev/random - ентропія
{"urandom", 1, 9, 0666}, // /dev/urandom - швидка ентропія
{"tty", 5, 0, 0666}, // /dev/tty - термінал
}
for _, d := range devices {
path := filepath.Join(devPath, d.name)
dev := unix.Mkdev(d.major, d.minor)
unix.Mknod(path, syscall.S_IFCHR|d.mode, int(dev))
}
// Симлінки для fd
os.Symlink("/proc/self/fd",
filepath.Join(devPath, "fd"))
os.Symlink("/proc/self/fd/0",
filepath.Join(devPath, "stdin"))
os.Symlink("/proc/self/fd/1",
filepath.Join(devPath, "stdout"))
os.Symlink("/proc/self/fd/2",
filepath.Join(devPath, "stderr"))
}
unix.Mkdev(major, minor) комбінує major і minor номери пристрою в одне число. Major визначає тип пристрою (1 = char devices в пам'яті), minor визначає конкретний пристрій (3 = null, 5 = zero).
unix.Mknod(path, mode, dev) створює спеціальний файл. S_IFCHR каже ядру, що це character device. Без Mknod програми в контейнері не зможуть писати в /dev/null або читати з /dev/urandom.
Signals - управління процесами¶
Спершу SIGTERM, потім SIGKILL по таймауту - увесь протокол graceful shutdown зводиться до двох викликів proc.Signal():
// Graceful stop
proc, _ := os.FindProcess(c.Pid)
proc.Signal(syscall.SIGTERM) // "заверши роботу акуратно"
// Force kill
proc.Signal(syscall.SIGKILL) // "зупинись негайно"
state, _ := proc.Wait() // чекаємо завершення
Signal 0 - прихований трюк для перевірки, чи процес існує:
func isProcessAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
// Signal 0 не відправляє сигнал,
// але повертає помилку якщо процесу немає
err = proc.Signal(syscall.Signal(0))
return err == nil
}
Sheep використовує це при завантаженні існуючих контейнерів: якщо state.json каже "running", але процесу з таким PID немає - ставимо "stopped".
Cgroups - не syscall, а файли¶
graph LR
A["Go os.WriteFile()"] -->|"write(fd, data, len)"| B["VFS"]
B --> C["/sys/fs/cgroup/sheep/abc/memory.max"]
C --> D["cgroup controller<br/>встановлює ліміт"]
Cgroups v2 - це файлова система, не набір syscalls. Але кожен os.WriteFile() внутрішньо - це syscalls open, write, close:
func writeFile(path, content string) error {
return os.WriteFile(path,
[]byte(strings.TrimSpace(content)), 0644)
}
// memory.max = 256MB
writeFile("/sys/fs/cgroup/sheep/abc123/memory.max",
"268435456")
// pids.max = 100
writeFile("/sys/fs/cgroup/sheep/abc123/pids.max",
"100")
// cpu.max = 50% одного ядра (50ms з 100ms)
writeFile("/sys/fs/cgroup/sheep/abc123/cpu.max",
"50000 100000")
Повна карта syscalls в Sheep¶
graph TB
subgraph "startContainer()"
CLONE["clone()<br/>через SysProcAttr.Cloneflags"]
end
subgraph "ContainerInit()"
HOSTNAME["sethostname()"]
MOUNT_PROC["mount(proc)"]
MOUNT_SYS["mount(sysfs)"]
MOUNT_TMP["mount(tmpfs)"]
MOUNT_DEV["mount(tmpfs, /dev)"]
MKNOD["mknod() x 5 devices"]
BIND["mount(MS_BIND)"]
PIVOT["pivot_root()"]
UMOUNT["unmount(.pivot_old)"]
EXEC["execve(target_command)"]
end
subgraph "setupCgroups()"
WRITE1["write(cgroup.procs)"]
WRITE2["write(memory.max)"]
WRITE3["write(pids.max)"]
WRITE4["write(cpu.max)"]
end
subgraph "setupNetwork()"
IP["ip link add (exec)"]
NSENTER["nsenter (exec)"]
IPTABLES["iptables (exec)"]
end
CLONE --> HOSTNAME
HOSTNAME --> MOUNT_PROC --> MOUNT_SYS --> MOUNT_TMP --> MOUNT_DEV
MOUNT_DEV --> MKNOD --> BIND --> PIVOT --> UMOUNT --> EXEC
Де підводні камені¶
Мережеві команди (ip, nsenter, iptables) - усе, що будує bridge і veth-пари та правила NAT, - викликаються через exec.Command, а не через netlink syscalls. Це повільніше і залежить від встановлених утиліт. Docker/containerd використовують Go netlink бібліотеку для прямого спілкування з ядром.
Ще пара граблів:
- syscall.Mount і друзі не можна викликати з довільної горутини: namespace прив'язаний до OS-треда, тому код, який заходить у новий mount namespace, має сидіти під runtime.LockOSThread(). Інакше Go може перекинути горутину на інший тред - уже не в тому namespace.
- errno від syscall - це не звичайна Go-помилка: syscall.Mount повертає syscall.Errno, і EPERM vs EINVAL означають зовсім різні причини. Загортати їх голим %w без розбору - значить втратити діагностику.
💡 Цікаві факти¶
- В Linux немає окремого syscall
pivot_rootдля "стати новим коренем" іchroot- це різні речі:chrootлише міняє/для процесу, аpivot_rootфізично переставляє кореневу точку монтування. Саме тому контейнерні рантайми обираютьpivot_root- ізchrootлегше "втекти" з ізоляції. - Пакет
syscallофіційно заморожений з Go 1.4: Go-команда вирішила не тягнути платформозалежні API в стандартну бібліотеку назавжди й винесла розвиток уgolang.org/x/sys. Тому stdlibsyscallі досі живий, але нові виклики додають тільки вx/sys/unix. clone()насправді один із найскладніших syscall у Linux - має понад 20 прапорців. Go свідомо не дає його напряму: рантайму потрібен контроль над створенням тредів, тож namespace-прапорці прокидаються лише черезSysProcAttrпід часfork+exec.- Major/minor номери пристроїв - спадок із 1970-х:
/dev/nullмає major 1, minor 3 на кожній Linux-системі у світі. Ці числа закріплені в офіційному реєстрі девайсів ядра й не змінюються десятиліттями.
Що я зрозумів, поки розбирався з темою¶
Найбільше здивувало, наскільки мало "справжніх" syscalls лишається, коли дивишся уважно. Cgroups виявились звичайними файлами в /sys/fs/cgroup - жодного спецвиклику, просто open/write/close. Мережа - взагалі exec зовнішніх утиліт. "Магія контейнерів" звелась до кількох mount, одного pivot_root і запису в файли.
І ще момент із Signal(0): я довго шукав "правильний" спосіб перевірити, чи живий процес, поки не дійшло, що ядро вже дає це безкоштовно - надсилаєш нульовий сигнал, і ESRCH каже, що процесу немає. Елегантно й нульова ціна.
Що можна покращити¶
- Замінити
exec.Command("ip", ...)на бібліотеку netlink (vishvananda/netlink) - прямі syscalls до ядра замість парсингу stdout зовнішніх утиліт. - Обгорнути
syscall.Errnoу власні типізовані помилки, щоб відрізняти "немає прав" від "невалідні аргументи" й давати юзеру зрозуміле повідомлення. - Додати
runtime.LockOSThread()навколо коду, що працює з mount namespace, - зараз це покладається на те, що Go не перекине горутину в невдалий момент. - Як вправу - переписати
createDevicesчерез bind mount реальних/dev/nullтощо з хоста замістьMknod: це обхід вимогиCAP_MKNODі ближче до того, що роблять сучасні рантайми в rootless-режимі.
Спробуй сам¶
# Подивись syscalls контейнера через strace:
sudo strace -f -e trace=clone,mount,pivot_root \
./sheep run --name trace-test minimal /bin/ls 2>&1 | head -20
Далі - goroutines і тікери: як паралельні control loops координуються в Shepherd.
Ресурси¶
- syscall package: стандартний пакет syscall (заморожений, використовуй x/sys)
- golang.org/x/sys/unix: підтримуваний наступник
- clone(2): syscall створення namespace
- pivot_root(2): syscall зміни кореня
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Build Tags
