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

Linux Namespaces: ізолюємо процес за 50 рядків Go

Linux Namespaces: ізолюємо процес за 50 рядків Go

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn

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

LinkedIn


Контейнер - це процес з обмеженим видом на систему. Ось і все. Жодної магії, жодних віртуальних машин. Ядро Linux дає процесу окремий "простір імен" (namespace), і процес думає, що він один у системі.

Я побудував контейнерний рантайм Sheep з нуля на Go, і в цій серії покажу як це працює зсередини. Почнемо з найважливішого - namespace'ів.

П'ять namespace'ів, які роблять контейнер контейнером

Linux дає кілька типів namespace'ів. Кожен ізолює свій аспект:

Namespace Прапорець Що ізолює
UTS CLONE_NEWUTS hostname - контейнер має своє ім'я
PID CLONE_NEWPID процеси - контейнер бачить тільки свої PID
Mount CLONE_NEWNS файлову систему - свій корінь, свої mount'и
IPC CLONE_NEWIPC міжпроцесну комунікацію - shared memory, semaphores
Network CLONE_NEWNET мережу - свій мережевий стек, IP, порти

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

Ось ключова функція з Sheep, яка запускає ізольований процес:

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
}

Все тримається на одному рядку - SysProcAttr.Cloneflags. Ми передаємо ядру набір прапорців, і воно створює дочірній процес в окремих namespace'ах.

Що робить кожен прапорець

CLONE_NEWUTS - ізоляція hostname

Процес у новому UTS namespace отримує свій hostname. Без цього sethostname() змінив би hostname всієї машини.

if hostname != "" {
    if err := syscall.Sethostname([]byte(hostname)); err != nil {
        return fmt.Errorf("sethostname: %w", err)
    }
}

CLONE_NEWPID - ізоляція процесів

Всередині PID namespace процес думає, що його PID = 1. Він не бачить жодного процесу хоста. Це як окрема операційна система, тільки з одним ядром.

CLONE_NEWNS - ізоляція файлової системи

Mount namespace дає процесу свою таблицю монтувань. Контейнер може монтувати /proc, /sys, /tmp без впливу на хост.

CLONE_NEWIPC - ізоляція IPC

Shared memory segments, semaphores, message queues - все своє. Контейнер не може підслухати IPC хоста.

CLONE_NEWNET - ізоляція мережі

Свій мережевий стек: свої інтерфейси, IP-адреси, routing table, iptables. Поки не налаштуємо veth пару і bridge, контейнер не має мережі взагалі.

Як все працює разом

graph TB
    subgraph "Хост"
        HOST_PROC["Процеси хоста<br/>PID 1, 2, 3..."]
        HOST_NET["Мережа хоста<br/>eth0, 192.168.1.x"]
        HOST_FS["Файлова система<br/>/, /home, /var"]
    end

    subgraph "Контейнер (нові namespace'и)"
        C_PROC["PID 1 (init)"]
        C_NET["Мережа контейнера<br/>eth0, 10.20.0.x"]
        C_FS["Своя файлова система<br/>/, /proc, /tmp"]
        C_HOST["hostname: sheep-abc123"]
    end

    HOST_PROC -.->|"CLONE_NEWPID"| C_PROC
    HOST_NET -.->|"CLONE_NEWNET"| C_NET
    HOST_FS -.->|"CLONE_NEWNS"| C_FS

Хост бачить контейнерний процес як звичайний процес з високим PID. Але зсередини контейнер бачить тільки себе.

Що може піти не так

Namespace'и дають ізоляцію, але не обмеження ресурсів. Процес у новому PID namespace все одно може з'їсти всю пам'ять машини. Для обмежень потрібні cgroups - про це буде в частині 4.

І ще одна річ. Go створює кілька потоків ще до виклику main(). Це означає, що clone() з прапорцями namespace'ів працює не зовсім коректно, якщо викликати його напряму. Потрібен re-exec pattern, про який поговоримо в наступній частині.

Спробуй сам

git clone https://github.com/igorovh/sheep && cd sheep
go build ./cmd/sheep
sudo ./sheep bootstrap minimal
sudo ./sheep run --name test minimal /bin/sh
# Всередині контейнера:
hostname        # побачиш скорочений ID контейнера
ps aux          # тільки один процес - /bin/sh
ip addr         # свій мережевий інтерфейс

Далі розберемо, чому Go і clone() конфліктують і як re-exec pattern це вирішує.

Наступна: Re-Exec Pattern