JSON serialization для state persistence¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
JSON в Sheep і Shepherd скрізь: state.json для кожного контейнера, manifest.json для кожного образу, BoltDB зберігає ресурси як JSON bytes, весь API - JSON request/response. Один формат для всього.
Чотири місця, де живе JSON¶
graph TB
subgraph "1. Файли на диску"
STATE["containers/abc/state.json<br/>стан контейнера"]
MANIFEST["images/def/manifest.json<br/>метадані образу"]
end
subgraph "2. BoltDB"
DB["shepherd.db<br/>key: bytes → value: JSON bytes"]
end
subgraph "3. HTTP API"
REQ["Request body: JSON"]
RESP["Response body: JSON"]
end
subgraph "4. OCI Registry"
OCI_MAN["OCI manifest: JSON"]
OCI_CFG["Image config: JSON"]
end
Container state.json¶
Кожна зміна стану контейнера записується на диск:
func (m *Manager) saveState(c *Container) error {
dir := filepath.Join(m.baseDir, "containers", c.ID)
os.MkdirAll(dir, 0755)
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(
filepath.Join(dir, "state.json"), data, 0644)
}
MarshalIndent з двома пробілами. Файл можна відкрити і прочитати:
{
"id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"name": "web",
"image": "nginx",
"command": ["nginx", "-g", "daemon off;"],
"state": "running",
"pid": 12345,
"exit_code": 0,
"created_at": "2026-04-28T10:30:00.123456Z",
"started_at": "2026-04-28T10:30:01.456789Z",
"config": {
"hostname": "a1b2c3d4e5f6",
"env": ["PORT=8080", "WORKER_COUNT=4"],
"work_dir": "",
"memory": 268435456,
"cpu_shares": 0,
"cpu_quota": 0,
"pids_limit": 100
},
"rootfs": "/var/lib/sheep/overlay/a1b2c3d4.../merged",
"mounts": [
{
"source": "/data",
"target": "/app/data",
"readonly": true
}
],
"network": {
"ip_address": "10.20.0.2",
"gateway": "10.20.0.1",
"bridge": "sheep0",
"veth_host": "vetha1b2c3d4",
"veth_guest": "eth0",
"mac_address": ""
}
}
JSON struct tags¶
Go використовує теги для контролю серіалізації:
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
State State `json:"state"`
Pid int `json:"pid"`
ExitCode int `json:"exit_code"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at,omitempty"`
StoppedAt time.Time `json:"stopped_at,omitempty"`
Mounts []Mount `json:"mounts,omitempty"`
Network *NetworkSettings `json:"network,omitempty"`
}
json:"exit_code" - snake_case в JSON, CamelCase в Go. Конвенція, яку Go community прийняв.
omitempty - не включати поле, якщо значення нульове. StartedAt не буде в JSON для created контейнера. Network (pointer) не буде, якщо nil. Це тримає JSON чистим.
Дивись, яка штука з time.Time: Go серіалізує його як RFC3339 string автоматично. "2026-04-28T10:30:00.123456Z" - людиночитабельний формат.
Завантаження при старті¶
func (m *Manager) loadExisting() error {
dir := filepath.Join(m.baseDir, "containers")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil // перший запуск, ще нема контейнерів
}
return err
}
for _, e := range entries {
if !e.IsDir() { continue }
data, err := os.ReadFile(
filepath.Join(dir, e.Name(), "state.json"))
if err != nil { continue }
var c Container
if err := json.Unmarshal(data, &c); err != nil {
continue // пропускаємо пошкоджені файли
}
// Перевіряємо живучість раніше запущених контейнерів
if c.State == StateRunning && c.Pid > 0 {
if !isProcessAlive(c.Pid) {
c.State = StateStopped
c.Pid = 0
}
}
m.containers[c.ID] = &c
}
return nil
}
Пошкоджений state.json? Пропускаємо. Контейнер "running", але процесу немає? Ставимо stopped. Defensive десеріалізація.
BoltDB - JSON як bytes¶
BoltDB зберігає []byte key → []byte value. Ми серіалізуємо Go структури в JSON:
func (s *Store) put(bucket []byte, key []byte, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return s.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket(bucket).Put(key, data)
})
}
func (s *Store) get(bucket []byte, key []byte, v any) error {
return s.db.View(func(tx *bolt.Tx) error {
data := tx.Bucket(bucket).Get(key)
if data == nil {
return fmt.Errorf("not found")
}
return json.Unmarshal(data, v)
})
}
func (s *Store) list(bucket []byte, prefix string,
fn func([]byte) error) error {
return s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucket)
return b.ForEach(func(k, v []byte) error {
if prefix == "" ||
strings.HasPrefix(string(k), prefix+"/") {
return fn(v) // v - це JSON bytes
}
return nil
})
})
}
Ключ для подів: default/web-0 (namespace/name). Значення: весь Pod як JSON.
API - streaming JSON¶
// Request: streaming decode з body
func (api *APIServer) createPod(w http.ResponseWriter,
r *http.Request) {
var pod Pod
if err := json.NewDecoder(r.Body).Decode(&pod); err != nil {
httpError(w, http.StatusBadRequest,
"invalid pod: %v", err)
return
}
// ...
}
// Response: streaming encode в writer
func respondJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
NewDecoder(r.Body) читає з HTTP body напряму, без буферизації всього в пам'ять. NewEncoder(w) пише напряму в HTTP response.
Image manifest.json¶
func (im *ImageManager) saveMetadata(id string, img *Image) error {
data, err := json.Marshal(img)
if err != nil {
return err
}
return os.WriteFile(
filepath.Join(im.baseDir, id, "manifest.json"),
data, 0644)
}
Тут Marshal без Indent - manifest не для людського читання, тільки для машини. Менший файл.
Чому JSON, а не protobuf/msgpack¶
| JSON | Protobuf | |
|---|---|---|
| Schema | Implicit (struct tags) | Explicit (.proto file) |
| Читабельність | cat file.json | protoc --decode |
| Розмір | Більший | Менший (~30-50%) |
| Швидкість | Повільніший | Швидший (~2-5x) |
| Залежності | stdlib | google.golang.org/protobuf |
Kubernetes використовує protobuf для internal communication і JSON для API. Ми скрізь JSON, бо:
- encoding/json є в stdlib
- cat state.json для дебагу
- Для десятків об'єктів різниця в швидкості непомітна
Що ми спростили¶
json.Marshal/Unmarshal використовують reflection, що повільно. Для тисяч серіалізацій на секунду варто подивитись на json.NewEncoder (streaming) або code generation бібліотеки типу easyjson. Для Sheep з десятками контейнерів - reflection достатньо.
💡 Цікаві факти¶
- JSON придумав Даглас Крокфорд на початку 2000-х - не як новий формат, а як підмножину синтаксису об'єктів JavaScript. Він навмисно не додавав ні коментарів, ні версій, щоб формат неможливо було "розширити" і зламати сумісність.
- Go серіалізує
mapз відсортованими ключами - детерміновано, на відміну від багатьох мов. Тому дваMarshalоднакової мапи дають байт-у-байт однаковий вивід, що зручно для діфів і хешування. []byteв Go маршалиться не як масив чисел, а як base64-рядок. Це сюрприз, на який натикаються майже всі - бінарні дані в JSON завжди коштують ~33% розміру зверху.- Kubernetes зберігає всі об'єкти в etcd, і хоч API віддає JSON, всередині для швидкості використовується protobuf. JSON там - "людський" фасад над бінарним ядром, рівно та сама ідея "один формат для зручності, інший для швидкості".
Що я зрозумів, поки розбирався з темою¶
Найбільший інсайт - omitempty зрадливий. Він прибирає поле, коли значення "нульове", але для Go "нуль" і "не задано" - це одне й те саме. Тобто replicas: 0 зникне з JSON так само, як і незадане поле, і потім ти не відрізниш "хочу нуль реплік" від "не вказав". Побачив це на власному state.json і зрозумів, чому Kubernetes у своїх API так часто тягає вказівники (*int32) замість простих чисел - тільки так nil відрізняється від нуля. Дрібниця в тегу, а семантика різна.
На що звернути увагу¶
omitemptyне відрізняє "нуль" від "не задано". Якщо нульове значення для тебе значуще (0 реплік, false, порожній рядок) - використовуй вказівник або кастомний Marshaler.json.Unmarshalмовчки ігнорує невідомі поля. Старий state.json з полем, якого вже немає в структурі, завантажиться без помилки - зручно, але приховує дрейф схеми.Decoder.DisallowUnknownFields()вмикає сувору перевірку.MarshalIndentна кожному записі state - це зайві алокації та більший файл. На частих оновленнях стану воно складається.- Немає версіонування схеми: якщо завтра перейменуєш поле, старі файли на диску тихо втратять його значення при першому ж завантаженні.
Що можна покращити¶
- Додати поле
schemaVersionу кожен state.json і manifest, щоб при зміні структури можна було мігрувати старі файли, а не мовчки втрачати дані. - Атомарний запис: писати в
state.json.tmpі робитиos.Rename- інакше краш посередWriteFileзалишить напівзаписаний, побитий JSON. - Замінити reflection-based
Marshalна згенерований код (easyjson/ffjson) у гарячих шляхах, якщо профіль покаже, що серіалізація стала вузьким місцем. - Розглянути кастомні
MarshalJSON/UnmarshalJSONдля типів на кшталтState, щоб валідувати значення на вході, а не приймати будь-який рядок.
Спробуй сам¶
# Подивись state.json контейнера:
cat /var/lib/sheep/containers/*/state.json | jq .
# Manifest образу:
cat /var/lib/sheep/images/*/manifest.json | jq .
# API response:
curl -s localhost:9876/api/v1/pods | jq '.[0]'
Починаємо останню серію - Architecture & Design.
Ресурси¶
- encoding/json: стандартна Go-бібліотека для JSON
- Marshaler interface: кастомна серіалізація
- JSON and Go (blog): вступний пост у Go-блозі
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: CLI без фреймворків | Наступна: Docker vs Sheep
