Re-Exec Pattern: чому Go і clone() не дружать¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Stanislav Gorovyy
HBO-ICT Student at Hogeschool Saxion | Software Engineer
У першій частині ми побачили, як namespace'и ізолюють процес. Але є проблема: Go і системний виклик clone() не дуже сумісні. Ось чому і як це вирішити.
Проблема: goroutines і clone()¶
Go запускає кілька OS-потоків ще до того, як твій main() починає працювати. Рантайм Go потребує їх для garbage collector'а, мережевого поллера та інших речей.
Коли ти викликаєш clone() з прапорцем CLONE_NEWPID, створюється дочірній процес з PID 1 в новому namespace. Але тут є проблема - потоки Go, які вже працювали в батьківському процесі, не клонуються разом. Дочірній процес отримує тільки один потік, а рантайм Go очікує кілька. Результат - падіння або непередбачувана поведінка.
Рішення: self re-exec¶
Замість того, щоб викликати clone() напряму, ми робимо так:
- Запускаємо свій же бінарник як дочірній процес з новими namespace'ами
- Передаємо спеціальну команду
init, щоб дочірній процес знав, що він - контейнерний init - Дочірній процес налаштовує файлову систему, hostname та інше
- Після налаштування дочірній процес робить
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
