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

Re-Exec Pattern: чому Go і clone() не дружать

Re-Exec Pattern: чому Go і clone() не дружать

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn

Stanislav Gorovyy
HBO-ICT Student at Hogeschool Saxion | Software Engineer

LinkedIn


У першій частині ми побачили, як namespace'и ізолюють процес. Але є проблема: Go і системний виклик clone() не дуже сумісні. Ось чому і як це вирішити.

Проблема: goroutines і clone()

Go запускає кілька OS-потоків ще до того, як твій main() починає працювати. Рантайм Go потребує їх для garbage collector'а, мережевого поллера та інших речей.

Коли ти викликаєш clone() з прапорцем CLONE_NEWPID, створюється дочірній процес з PID 1 в новому namespace. Але тут є проблема - потоки Go, які вже працювали в батьківському процесі, не клонуються разом. Дочірній процес отримує тільки один потік, а рантайм Go очікує кілька. Результат - падіння або непередбачувана поведінка.

Рішення: self re-exec

Замість того, щоб викликати clone() напряму, ми робимо так:

  1. Запускаємо свій же бінарник як дочірній процес з новими namespace'ами
  2. Передаємо спеціальну команду init, щоб дочірній процес знав, що він - контейнерний init
  3. Дочірній процес налаштовує файлову систему, hostname та інше
  4. Після налаштування дочірній процес робить exec() цільової програми
sequenceDiagram
    participant P as sheep (батько)
    participant C as sheep init (дитина)
    participant T as target process

    P->>P: cmd.SysProcAttr.Cloneflags = CLONE_NEW*
    P->>C: exec("sheep", "init", "--rootfs", "/path", "--", "cmd")
    Note over C: Новий процес в нових namespace'ах
    C->>C: sethostname()
    C->>C: mount proc, sys, dev, tmp
    C->>C: pivotRoot(rootfs)
    C->>T: syscall.Exec(target_command)
    Note over T: PID 1 в контейнері

Як це виглядає в коді

Ось як батьківський процес створює команду для re-exec:

func reexecCommand(c *Container) *exec.Cmd {
    self, _ := os.Executable()
    cmd := exec.Command(self,
        append([]string{
            "init",
            "--rootfs", c.RootFS,
            "--hostname", c.Config.Hostname,
            "--",
        }, c.Command...)...)
    cmd.Env = append(c.Config.Env,
        fmt.Sprintf("SHEEP_CONTAINER_ID=%s", c.ID))
    return cmd
}

os.Executable() повертає шлях до поточного бінарника. Ми запускаємо самих себе з аргументом init.

Обробка "init" в main()

В main() першим перевіряємо, чи ми дочірній процес:

func main() {
    if len(os.Args) < 2 {
        printUsage()
        os.Exit(1)
    }

    switch os.Args[1] {
    case "init":
        handleInit()
        return
    case "version":
        fmt.Println("sheep v0.1.0")
        return
    // ... інші команди
    }
}

Команда init обробляється до ініціалізації контейнерного менеджера. Це важливо, бо дочірній процес вже працює в новому namespace і не має доступу до директорій хоста.

Що робить handleInit()

func handleInit() {
    var rootfs, hostname string
    var command []string

    args := os.Args[2:]
    for i := 0; i < len(args); i++ {
        switch args[i] {
        case "--rootfs":
            i++
            if i < len(args) { rootfs = args[i] }
        case "--hostname":
            i++
            if i < len(args) { hostname = args[i] }
        case "--":
            command = args[i+1:]
            i = len(args)
        }
    }

    if rootfs == "" {
        fatal("init: --rootfs is required")
    }

    if err := container.ContainerInit(rootfs, hostname, command); err != nil {
        fatal("init: %v", err)
    }
}

Парсимо аргументи і викликаємо ContainerInit. Ця функція налаштовує все і робить exec():

func ContainerInit(rootfs, hostname string, command []string) error {
    if hostname != "" {
        syscall.Sethostname([]byte(hostname))
    }

    // Монтуємо /proc, /sys, /tmp, /dev
    mounts := []struct {
        source, target, fstype string
        flags                  uintptr
    }{
        {"proc", "proc", "proc", 0},
        {"sysfs", "sys", "sysfs", 0},
        {"tmpfs", "tmp", "tmpfs", 0},
        {"tmpfs", "dev", "tmpfs", syscall.MS_NOSUID | syscall.MS_STRICTATIME},
    }

    for _, m := range mounts {
        target := filepath.Join(rootfs, m.target)
        os.MkdirAll(target, 0755)
        syscall.Mount(m.source, target, m.fstype, m.flags, "")
    }

    createDevices(rootfs)
    pivotRoot(rootfs)

    if len(command) == 0 {
        command = []string{"/bin/sh"}
    }

    binary, err := exec.LookPath(command[0])
    if err != nil {
        binary = command[0]
    }

    return syscall.Exec(binary, command, os.Environ())
}

Останній рядок - syscall.Exec() - замінює поточний процес на цільову команду. Це не fork+exec, а саме заміна. Після цього Go рантайм вже не працює, працює тільки цільова програма.

Чому не просто fork()?

У C можна зробити fork() і одразу execve(). Але Go не дає прямого доступу до fork(), тому що після fork дочірній процес має тільки один потік, а Go рантайм потребує кілька.

exec.Command з SysProcAttr.Cloneflags - це "правильний" спосіб зробити fork+clone в Go. Новий процес стартує з чистим рантаймом.

Потік даних

graph LR
    A["sheep run nginx /bin/sh"] --> B["Create container"]
    B --> C["reexecCommand()"]
    C --> D["sheep init<br/>--rootfs /var/lib/sheep/overlay/abc/merged<br/>--hostname abc123<br/>-- /bin/sh"]
    D --> E["ContainerInit()"]
    E --> F["syscall.Exec(/bin/sh)"]

Ось чому, коли ти робиш ps на хості, ти бачиш не sheep init, а /bin/sh - бо Exec() повністю замінив процес.

Що з Docker?

Docker вирішує цю ж проблему інакше. containerd запускає runc, який написаний на Go, але використовує трюк з nsenter - маленькою C-програмою, яка виконується до Go рантайму через механізм cgo і init(). Це складніше, але дозволяє зробити все за один процес.

Ми в Sheep обрали простіший підхід з re-exec, який працює без cgo.

Спробуй сам

# Подивись, як sheep перезапускає себе:
ps aux | grep 'sheep init'
# Побач аргументи init процесу:
cat /proc/$(pgrep -f 'sheep init')/cmdline | tr '\0' ' '

Тепер подивимось на pivot_root - як контейнер отримує свою файлову систему.

Попередня: Linux Namespaces