pivot_root: як контейнер отримує свою файлову систему¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Stanislav Gorovyy
HBO-ICT Student at Hogeschool Saxion | Software Engineer
Контейнер бачить свою файлову систему, а не хостову. Як це працює? Через системний виклик 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
