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

JSON serialization для state persistence

JSON serialization для state persistence

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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.

Ресурси

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

Попередня: CLI без фреймворків | Наступна: Docker vs Sheep