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

OverlayFS: copy-on-write шари як у Docker

OverlayFS: copy-on-write шари як у Docker

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Уяви, що у тебе 10 контейнерів з одного образу nginx. Кожен має свою файлову систему, може писати файли, змінювати конфіги. Але копіювати 100MB rootfs для кожного - марнотратство. OverlayFS вирішує це через copy-on-write.

Це п'ята частина серії Sheep & Shepherd. Попередні частини: namespaces дали контейнеру свій погляд на систему, re-exec обійшов конфлікт Go з threading-моделлю clone(), pivot_root дав свій корінь файлової системи, а cgroups v2 обмежив ресурси. Тепер зробимо цю файлову систему дешевою для створення.

Ідея проста

OverlayFS складає кілька директорій в одну. Є нижній шар (read-only, з образу) і верхній шар (read-write, для контейнера). Контейнер бачить обидва як одну файлову систему.

graph TB
    subgraph "Що бачить контейнер"
        MERGED["merged/<br/>/bin, /etc, /var, /tmp<br/>(все разом)"]
    end

    subgraph "Реальність"
        LOWER["lower/ (образ)<br/>read-only<br/>/bin/sh, /etc/hosts..."]
        UPPER["upper/ (контейнер)<br/>read-write<br/>нові та змінені файли"]
        WORK["work/<br/>тимчасові файли OverlayFS"]
    end

    LOWER --> MERGED
    UPPER --> MERGED
    WORK -.-> MERGED

Як це працює при читанні

Коли контейнер читає файл: 1. OverlayFS шукає в upper (контейнерний шар) 2. Якщо немає - шукає в lower (шар образу) 3. Контейнер не знає, звідки прийшов файл

Як це працює при записі (copy-up)

Коли контейнер змінює файл з lower шару: 1. OverlayFS копіює файл з lower в upper (copy-up) 2. Зміни записуються в копію у upper 3. Lower залишається незмінним 4. Наступні читання цього файлу йдуть з upper

Нові файли створюються одразу в upper.

Код в Sheep

func (m *Manager) setupOverlay(id, lowerDir string) (string, error) {
    overlayBase := filepath.Join(m.baseDir, "overlay", id)
    upper := filepath.Join(overlayBase, "upper")
    work := filepath.Join(overlayBase, "work")
    merged := filepath.Join(overlayBase, "merged")

    for _, d := range []string{upper, work, merged} {
        os.MkdirAll(d, 0755)
    }

    if err := mountOverlay(lowerDir, upper, work, merged); err != nil {
        // Fallback: копіюємо rootfs якщо overlay не підтримується
        return copyRootFS(lowerDir, merged)
    }

    return merged, nil
}

І сам mount:

func mountOverlay(lower, upper, work, merged string) error {
    opts := fmt.Sprintf(
        "lowerdir=%s,upperdir=%s,workdir=%s",
        lower, upper, work)
    return syscall.Mount("overlay", merged, "overlay", 0, opts)
}

Один системний виклик mount() з типом "overlay" і опціями - і файлова система готова. Той самий механізм syscall.Mount, який використовували в pivot_root, просто з іншим типом файлової системи.

Структура директорій

/var/lib/sheep/
  images/
    abc123/
      rootfs/          <- lower layer (образ)
        bin/
        etc/
        usr/
  overlay/
    container_id/
      upper/           <- зміни контейнера
      work/            <- робоча директорія OverlayFS
      merged/          <- те, що бачить контейнер

Fallback для систем без OverlayFS

Не всі системи підтримують OverlayFS (наприклад, деякі старі ядра або macOS для розробки). Тому є fallback - просте копіювання:

func copyRootFS(src, dst string) (string, error) {
    entries, err := os.ReadDir(src)
    if err != nil {
        return "", err
    }
    for _, e := range entries {
        srcPath := filepath.Join(src, e.Name())
        dstPath := filepath.Join(dst, e.Name())

        info, err := e.Info()
        if err != nil { continue }

        if info.IsDir() {
            os.MkdirAll(dstPath, info.Mode())
            copyRootFS(srcPath, dstPath)
        } else if info.Mode().IsRegular() {
            copyFile(srcPath, dstPath)
        }
    }
    return dst, nil
}

Працює, але повільно і їсть диск. OverlayFS - набагато краще рішення.

Прибирання

Коли контейнер видаляється:

func (m *Manager) cleanupOverlay(id string) {
    overlayBase := filepath.Join(m.baseDir, "overlay", id)
    merged := filepath.Join(overlayBase, "merged")
    unmountOverlay(merged)
    os.RemoveAll(overlayBase)
}

Спочатку відмонтовуємо overlay, потім видаляємо upper, work, merged.

Чому це ефективно

10 контейнерів з nginx: - Без overlay: 10 x 100MB = 1GB - З overlay: 100MB (один lower) + мінімум (upper для кожного)

Docker йде далі - у нього шари можуть бути спільними між різними образами. Якщо ubuntu:22.04 і nginx використовують однакові базові шари, вони зберігаються тільки раз. В офіційній документації Docker про overlay2 storage driver детально описано модель шарування.

У Sheep один lower шар на образ. Простіше, але принцип той самий.

Що ми спростили

Copy-up копіює весь файл, навіть якщо ти змінив один байт. Для великих файлів (наприклад, баз даних) це може бути повільно. Тому Docker рекомендує використовувати volumes для даних, які часто змінюються.

Спробуй сам

# Запусти два контейнери з одного образу:
sudo ./sheep run --name ov1 -d minimal /bin/sleep 3600
sudo ./sheep run --name ov2 -d minimal /bin/sleep 3600
# Порівняй overlay директорії:
ls /var/lib/sheep/overlay/
# upper/ у кожного свій, lower (rootfs) - спільний

Файлова система є. Далі - bridge networking: як дати контейнеру IP і з'єднати з мережею.

Попередня: Cgroups v2