Filesystem Layout: де живуть контейнери¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Все в 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: чому явні стани запобігають цілому класу багів.
Ресурси¶
- Filesystem Hierarchy Standard: огляд Linux FHS
- Docker storage drivers: як Docker організовує /var/lib/docker
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Kubernetes vs Shepherd
