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

syscall пакет Go: mount, clone, pivot_root

syscall пакет Go: mount, clone, pivot_root

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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. Тому stdlib syscall і досі живий, але нові виклики додають тільки в 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.

Ресурси

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

Попередня: Build Tags