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

pivot_root: як контейнер отримує свою файлову систему

pivot_root: як контейнер отримує свою файлову систему

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn

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

LinkedIn


Контейнер бачить свою файлову систему, а не хостову. Як це працює? Через системний виклик pivot_root(2), який міняє кореневу директорію процесу. Не chroot - він легко обходиться. pivot_root працює на рівні mount namespace і дає справжню ізоляцію.

Послідовність дій

Перед тим як зробити pivot_root, потрібно підготувати нову кореневу файлову систему. Ось що відбувається всередині контейнерного init процесу:

graph TD
    A["ContainerInit()"] --> B["Встановити hostname"]
    B --> C["Змонтувати /proc, /sys, /tmp, /dev<br/>всередині нового rootfs"]
    C --> D["Створити /dev/null, /dev/zero,<br/>/dev/random, /dev/tty"]
    D --> E["pivotRoot(rootfs)"]
    E --> F["syscall.Exec(target_command)"]

Монтування файлових систем

Перед pivot_root ми монтуємо потрібні файлові системи всередині нового кореня:

mounts := []struct {
    source string
    target string
    fstype string
    flags  uintptr
    data   string
}{
    {"proc", "proc", "proc", 0, ""},
    {"sysfs", "sys", "sysfs", 0, ""},
    {"tmpfs", "tmp", "tmpfs", 0, ""},
    {"tmpfs", "dev", "tmpfs",
        syscall.MS_NOSUID | syscall.MS_STRICTATIME, "mode=755"},
}

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

/proc - потрібен для ps, top та інших утиліт. /sys - інформація про пристрої. /tmp - тимчасові файли. /dev - пристрої.

Створення device nodes

Контейнеру потрібні базові пристрої. Без /dev/null, наприклад, багато програм просто впаде:

func createDevices(rootfs string) {
    devPath := filepath.Join(rootfs, "dev")

    devices := []struct {
        name  string
        major uint32
        minor uint32
        mode  uint32
    }{
        {"null", 1, 3, 0666},
        {"zero", 1, 5, 0666},
        {"random", 1, 8, 0666},
        {"urandom", 1, 9, 0666},
        {"tty", 5, 0, 0666},
    }

    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))
    }

    // Симлінки для stdin/stdout/stderr
    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"))
}

Mknod створює спеціальні файли пристроїв з конкретними major/minor номерами. Ядро знає, що /dev/null (1, 3) - це "чорна діра", куди можна писати і звідки нічого не прочитаєш.

Сам pivot_root

Ось ключова функція:

func pivotRoot(newRoot string) error {
    putOld := filepath.Join(newRoot, ".pivot_old")
    os.MkdirAll(putOld, 0700)

    // Bind mount newRoot на себе (вимога pivot_root)
    syscall.Mount(newRoot, newRoot, "",
        syscall.MS_BIND|syscall.MS_REC, "")

    // Міняємо корінь
    unix.PivotRoot(newRoot, putOld)

    // Переходимо в новий корінь
    os.Chdir("/")

    // Відмонтовуємо старий корінь
    syscall.Unmount("/.pivot_old", syscall.MNT_DETACH)

    // Видаляємо точку монтування
    os.RemoveAll("/.pivot_old")

    return nil
}

Розберемо по кроках:

graph LR
    subgraph "До pivot_root"
        OLD_ROOT["/ (хостова FS)"]
        NEW_ROOT["/var/lib/sheep/overlay/abc/merged"]
    end

    subgraph "Bind mount"
        BM["newRoot mount на себе"]
    end

    subgraph "Після pivot_root"
        REAL_ROOT["/ (контейнерна FS)"]
        PIVOT_OLD["/.pivot_old (стара хостова FS)"]
    end

    subgraph "Після unmount"
        CLEAN_ROOT["/ (тільки контейнер)"]
    end

    OLD_ROOT --> BM
    BM --> REAL_ROOT
    REAL_ROOT --> CLEAN_ROOT

Bind mount - pivot_root вимагає, щоб новий корінь був точкою монтування. Bind mount директорії на саму себе задовольняє цю вимогу.

PivotRoot - атомарно міняє корінь процесу. Стара коренева FS потрапляє в .pivot_old.

Unmount - після pivot_root стара FS доступна через /.pivot_old. Ми її відмонтовуємо з прапорцем MNT_DETACH (лінивий unmount, не чекає поки всі файли закриються).

RemoveAll - видаляємо директорію .pivot_old.

Чому не chroot?

chroot просто змінює, що процес вважає кореневою директорією. Але:

  • з chroot можна вийти через ../../../ трюки
  • chroot не впливає на вже відкриті файлові дескриптори
  • chroot не працює з mount namespace

pivot_root працює на рівні mount namespace і надійно ізолює файлову систему. Після unmount старого кореня процес фізично не може дістатися до хостової FS.

Обмеження

pivot_root вимагає, щоб новий корінь був mount point. Якщо забути bind mount - отримаєш EINVAL. Це не очевидна помилка, і дебагінг її займає час. Docker/runc мають ці edge cases оброблені за роки production використання.

Спробуй сам

# Запусти контейнер і перевір mount points:
sudo ./sheep run --name pivot-test minimal /bin/sh
# Всередині контейнера:
mount | head -5
ls /     # бачиш тільки контейнерну FS

Namespace'и ізолюють, але не обмежують. Далі - cgroups v2 для лімітів ресурсів.

Попередня: Re-Exec Pattern