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

Filesystem Layout: де живуть контейнери

Filesystem Layout: де живуть контейнери

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Все в Sheep - це файли. Стан контейнера, образ, overlay шари, IP лічильник. Немає бази даних, немає daemon'а з in-memory state. Перезапустив Sheep - він прочитав файли і знає все, що було до перезапуску.

Повна структура

/var/lib/sheep/
├── containers/
│   ├── a1b2c3d4e5f6.../
│   │   └── state.json          # стан контейнера
│   └── f6e5d4c3b2a1.../
│       └── state.json
├── overlay/
│   ├── a1b2c3d4e5f6.../
│   │   ├── upper/              # зміни контейнера
│   │   ├── work/               # робоча директорія OverlayFS
│   │   └── merged/             # що бачить контейнер
│   └── f6e5d4c3b2a1.../
│       ├── upper/
│       ├── work/
│       └── merged/
├── images/
│   ├── abc123.../
│   │   ├── manifest.json       # метадані образу
│   │   └── rootfs/             # файлова система
│   │       ├── bin/
│   │       ├── etc/
│   │       ├── usr/
│   │       └── var/
│   └── def456.../
│       ├── manifest.json
│       └── rootfs/
└── network/
    └── ip_counter              # останній виділений IP

Lifecycle файлів

sequenceDiagram
    participant FS as Filesystem

    Note over FS: sheep create --name web nginx
    FS->>FS: mkdir containers/a1b2c3.../
    FS->>FS: write containers/a1b2c3.../state.json (state: created)
    FS->>FS: mkdir overlay/a1b2c3.../{upper,work,merged}
    FS->>FS: mount overlay (lower=images/abc/rootfs)

    Note over FS: sheep start web
    FS->>FS: write containers/a1b2c3.../state.json (state: running, pid: 12345)

    Note over FS: sheep stop web
    FS->>FS: write containers/a1b2c3.../state.json (state: stopped, exit_code: 0)

    Note over FS: sheep rm web
    FS->>FS: umount overlay/a1b2c3.../merged
    FS->>FS: rm -rf overlay/a1b2c3.../
    FS->>FS: rm -rf containers/a1b2c3.../

containers/ - стан кожного контейнера

state.json містить все про контейнер: ID, ім'я, образ, команду, стан, PID, конфігурацію, мережеві налаштування, точки монтування.

Код ініціалізації:

const (
    BaseDir       = "/var/lib/sheep"
    ContainersDir = "/var/lib/sheep/containers"
    OverlayDir    = "/var/lib/sheep/overlay"
)

func (m *Manager) Init() error {
    dirs := []string{
        filepath.Join(m.baseDir, "containers"),
        filepath.Join(m.baseDir, "overlay"),
        filepath.Join(m.baseDir, "images"),
        filepath.Join(m.baseDir, "network"),
    }
    for _, d := range dirs {
        os.MkdirAll(d, 0755)
    }
    return m.loadExisting()
}

Чотири директорії створюються при першому запуску. loadExisting() сканує containers/ і завантажує всі state.json.

overlay/ - Copy-on-Write файлова система

Для кожного контейнера три директорії:

  • upper/ - файли, які контейнер створив або змінив. Пустий при старті. Якщо контейнер записав /var/log/app.log - він з'явиться тут.
  • work/ - внутрішня директорія OverlayFS для atomic operations. Ніколи не чіпай вручну.
  • merged/ - union view. Контейнер бачить цю директорію як свій /. OverlayFS автоматично показує файли з upper (якщо є) або з lower (images/xxx/rootfs).
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 {
        return copyRootFS(lowerDir, merged)
    }
    return merged, nil
}

images/ - read-only шари

Кожен образ - окрема директорія з manifest.json і rootfs/:

{
  "id": "abc123def456...",
  "name": "nginx",
  "tag": "latest",
  "size": 188743680,
  "created_at": "2026-04-28T09:00:00Z",
  "rootfs": "/var/lib/sheep/images/abc123.../rootfs"
}

rootfs/ - це lower layer для overlay. Ніколи не змінюється. 10 контейнерів з nginx ділять один rootfs. Кожен має свій upper layer.

graph TB
    IMG["images/abc/rootfs/<br/>(read-only, shared)"]
    IMG --> C1_M["overlay/container1/merged/"]
    IMG --> C2_M["overlay/container2/merged/"]
    IMG --> C3_M["overlay/container3/merged/"]

    C1_U["overlay/container1/upper/<br/>(container1 changes)"] --> C1_M
    C2_U["overlay/container2/upper/<br/>(container2 changes)"] --> C2_M
    C3_U["overlay/container3/upper/<br/>(container3 changes)"] --> C3_M

network/ - один файл

// ip_counter: "42"
func LoadIPCounter(baseDir string) {
    data, _ := os.ReadFile(
        filepath.Join(baseDir, "network", "ip_counter"))
    val := strings.TrimSpace(string(data))
    if n, err := strconv.ParseUint(val, 10, 32); err == nil {
        atomic.StoreUint32(&ipCounter, uint32(n))
    }
}

func SaveIPCounter(baseDir string) {
    os.WriteFile(
        filepath.Join(baseDir, "network", "ip_counter"),
        []byte(strconv.FormatUint(
            uint64(atomic.LoadUint32(&ipCounter)), 10)),
        0644)
}

Лічильник IP-адрес зберігається на диск, щоб після перезапуску не видавати ті самі IP.

Shepherd - один файл

/var/lib/shepherd/
└── shepherd.db          # BoltDB, ~100KB-10MB

Весь стан кластера: поди, сервіси, deployments, ноди, події. Один файл, який можна скопіювати для бекапу.

store, _ := shepherd.NewStore(dataDir + "/shepherd.db")

Порівняння з Docker

Docker:
/var/lib/docker/
├── containers/          # container metadata + logs
├── image/               # image metadata database
├── overlay2/            # overlayfs layers
│   ├── <layer-id>/
│   │   ├── diff/        # layer content
│   │   ├── link         # short name
│   │   └── lower        # parent layers
│   └── l/               # short name symlinks
├── network/             # network state
├── volumes/             # named volumes
└── buildkit/            # build cache

Sheep:
/var/lib/sheep/
├── containers/          # state.json per container
├── overlay/             # upper/work/merged per container
├── images/              # manifest.json + rootfs per image
└── network/             # ip_counter

Docker складніший, бо обробляє шари образів (кожен layer окремо), link files для ефективного монтування, та content-addressable store. Sheep зберігає повний rootfs на образ - простіше, але без дедуплікації шарів.

Що тут не ідеально

state.json не атомарний. Якщо Sheep впаде посеред запису - файл може бути пошкоджений. Docker використовує atomic write (temp file + rename) для state persistence. В Sheep os.WriteFile не гарантує атомарність.

Ще пара граблів:

  • Осиротілі overlay-маунти. Якщо Sheep впаде, не зробивши umount merged, маунт залишиться в ядрі. Наступний rm -rf директорії "успішно" видалить її, але маунт висітиме в /proc/mounts, тримаючи lower зайнятим.
  • Повний rootfs на образ - дорого по диску. Десять різних образів на базі однієї Ubuntu - це десять копій базового шару. Без content-addressable store дедуплікації немає, і du -sh images/ росте швидко.

💡 Цікаві факти

  • OverlayFS вимагає, щоб upper і work лежали на одній файловій системі - саме тому Sheep тримає їх поруч в одній директорії контейнера. work/ має бути порожнім на момент монтування, інакше ядро поверне EBUSY.
  • Docker не випадково має директорію l/ з короткими симлінками: команда mount має ліміт на довжину аргументів (близько однієї сторінки пам'яті), а ланцюжок з десятків layer-шляхів цей ліміт легко пробиває. Короткі імена - це обхід цього обмеження ядра.
  • Шлях /var/lib обрано не випадково: за Filesystem Hierarchy Standard саме там живе змінний стан, який має пережити перезавантаження (на відміну від /var/run чи /tmp). Docker, containerd, Sheep - усі дотримуються цієї конвенції.
  • state.json як єдине джерело правди - це той самий підхід, що в OCI runtime spec: runc теж зберігає state.json на контейнер, і саме його читає runc state.

Що я зрозумів, поки розбирався з темою

Найкорисніше усвідомлення: "немає бази даних" - це не спрощення, а архітектурне рішення. Коли весь стан - це файли в передбачуваному дереві, дебаг стає тривіальним: cat state.json, ls overlay/, і ти бачиш усе, що бачить рантайм. Жодного прихованого in-memory стану, який зник після крешу.

Але та сама простота б'є по руках на атомарності. Я звик, що "записати файл" - це безпечно. Виявилось, що без temp+rename один невдалий запис посеред крешу робить контейнер незавантажуваним. SQLite чи BoltDB дають це безкоштовно - і тепер я розумію, чому реальні рантайми за них тримаються.

Що можна покращити

  • Атомарний запис стану: писати в state.json.tmp, потім os.Rename - на більшості ФС rename атомарний, і це прибирає клас corruption-багів.
  • Content-addressable шари замість повного rootfs на образ: зберігати кожен layer за хешем вмісту і ділити між образами - так робить Docker/containerd.
  • GC для осиротілих overlay: при старті звіряти overlay/ зі списком контейнерів і відмонтовувати/видаляти те, для чого вже немає state.json.
  • Розмістити merged через lazy-mount тільки на час running, а не тримати overlay змонтованим для stopped контейнерів - економія маунтів і дескрипторів.

Спробуй сам

# Повне дерево:
find /var/lib/sheep -maxdepth 3 -type d
# Розмір по директоріях:
du -sh /var/lib/sheep/*/
# Shepherd state:
ls -lh /var/lib/shepherd/

Далі - State Machines: чому явні стани запобігають цілому класу багів.

Ресурси

Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow

Попередня: Kubernetes vs Shepherd