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

Embedded vs External DB: BoltDB vs etcd trade-offs

Embedded vs External DB: BoltDB vs etcd trade-offs

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Kubernetes використовує etcd - розподілену key-value базу з Raft-консенсусом. Shepherd використовує BoltDB - embedded базу в одному файлі. Обидві зберігають key-value pairs, але різниця в тому, що відбувається при збоях.

Сховище на BoltDB ми написали ще в дванадцятій частині і з того часу просто ним користувалися: API Server читає й пише через нього, шедулер і контролери звіряють через нього desired і actual state, журнал подій лежить в окремому bucket. А минула частина показала, що весь стан кластера живе в одному файлі рівно одного процесу. Тепер час назвати ціну цього рішення вголос.

BoltDB - один рядок, нуль залежностей

func NewStore(path string) (*Store, error) {
    db, err := bolt.Open(path, 0600,
        &bolt.Options{Timeout: 1 * time.Second})
    if err != nil {
        return nil, fmt.Errorf("open store: %w", err)
    }

    err = db.Update(func(tx *bolt.Tx) error {
        for _, b := range [][]byte{
            bucketPods, bucketServices,
            bucketDeployments, bucketNodes, bucketEvents,
        } {
            tx.CreateBucketIfNotExists(b)
        }
        return nil
    })

    return &Store{db: db}, nil
}

bolt.Open() - і база готова. Один файл на диску, ~100KB для порожнього кластера, кілька MB з десятками ресурсів. Ніякого кластера, ніякого service discovery, ніякого TLS між нодами - усе це просто не існує як проблема. Bucket-и і ключі з namespace ми розібрали окремо, тут цікавить лише те, чим це відрізняється від etcd.

Порівняння

BoltDB etcd
Розгортання bolt.Open("file.db") Кластер 3-5 нод
Розмір ~3MB (Go module) ~50MB (бінарник)
Транзакції ACID, serializable Linearizable reads/writes
Readers Паралельні (MVCC) Паралельні
Writers Один at a time (mutex) Через Raft consensus
Реплікація Немає Автоматична (Raft)
Watch Ручний (Go channels) Вбудований (gRPC stream)
Бекап cp file.db backup.db etcdctl snapshot save
Відмовостійкість Впав = все впало 1 з 3 впав = працює

Як BoltDB використовується в Shepherd

Всі операції - read або write транзакції:

// Write: Update() - ексклюзивний доступ
func (s *Store) put(bucket []byte, key []byte, v any) error {
    data, _ := json.Marshal(v)
    return s.db.Update(func(tx *bolt.Tx) error {
        return tx.Bucket(bucket).Put(key, data)
    })
}

// Read: View() - паралельний доступ
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)
    })
}

View() не блокує інші View(). Кілька горутин можуть читати одночасно. Але Update() блокує все - і readers, і writers. Для Shepherd з 4 горутинами-контролерами це прийнятно, бо кожен write - мілісекунди.

Watch - найбільша різниця

У Shepherd Watch реалізований через Go channels:

type Store struct {
    db             *bolt.DB
    podWatchers    []chan Event
    watchMu        sync.Mutex
}

func (s *Store) WatchPods() chan Event {
    s.watchMu.Lock()
    defer s.watchMu.Unlock()
    ch := make(chan Event, 64)
    s.podWatchers = append(s.podWatchers, ch)
    return ch
}

func (s *Store) notify(watchers []chan Event, evt Event) {
    s.watchMu.Lock()
    defer s.watchMu.Unlock()
    for _, ch := range watchers {
        select {
        case ch <- evt:
        default: // канал повний - пропускаємо
        }
    }
}

Це працює в межах одного процесу. Якщо API Server перезапуститься - всі watchers втрачаються, потрібно підписуватися заново.

graph LR
    subgraph "BoltDB Watch (Shepherd)"
        W1["Store.UpdatePod()"] --> N["notify()"]
        N --> CH1["chan Event (buffer 64)"]
        N --> CH2["chan Event (buffer 64)"]
        CH1 --> C1["Controller 1"]
        CH2 --> C2["Controller 2"]
    end

    subgraph "etcd Watch (Kubernetes)"
        W2["etcd.Put()"] --> RAFT["Raft log"]
        RAFT --> WATCH["Watch Stream"]
        WATCH --> G1["gRPC stream (client 1)"]
        WATCH --> G2["gRPC stream (client 2)"]
        G1 --> K1["Controller (може бути на іншій машині)"]
        G2 --> K2["Controller (може бути на іншій машині)"]
    end

etcd watch - це gRPC stream з revision tracking. Клієнт відключився і перепідключився? etcd відправить всі пропущені зміни починаючи з останнього revision. В BoltDB такого немає - пропущене = втрачене. Тому в Shepherd контролери використовують ticker-based polling як основний механізм, а watch - тільки для швидшої реакції.

Це рівно та причина, чому reconcile-петля в нас побудована на періодичному звірянні повного стану, а не на потоці подій. Петля, яка щоразу порівнює desired з actual, самолікується від пропущеної події - вона просто побачить розбіжність на наступному тіку. Петля, яка вірить у доставку подій, від такого не лікується. Те саме міркування ми вже проходили в async scheduling: якщо ти й так eventual consistent, втрачена нотифікація - це затримка, а не корупція стану.

Коли BoltDB достатньо

  • Один control plane (single node) - тобто standalone або server+agents з єдиним control plane
  • Сотні ресурсів (не десятки тисяч)
  • Downtime API Server = downtime кластера (прийнятно для dev/test)
  • Бекап = cp shepherd.db ~/backup/

Коли потрібен etcd

  • HA control plane (3+ API Servers)
  • Десятки тисяч подів і сотні нод
  • Leader election між контролерами (у нас контролери унікальні за побудовою - їх рівно по одному в процесі)
  • Не можна втратити стан при падінні однієї ноди
  • Потрібен watch з гарантією доставки

Що ми пропустили з обома

BoltDB: один writer at a time означає, що при навантаженні writers чекають в черзі. Для Shepherd з 4 контролерами це нормально, для 30 контролерів Kubernetes - bottleneck.

etcd: потрібно 3-5 нод для кворуму. Кожен write проходить через Raft - мінімум 2 disk syncs (leader + один follower). Для маленького кластера etcd може бути складнішим за сам кластер.

K3s від Rancher вирішив це так само як ми: замінив etcd на SQLite (embedded) для single-node. Один файл, нуль залежностей, повна Kubernetes API compatibility. BoltDB для Shepherd - той самий підхід.

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

  • BoltDB - це Go-порт LMDB (Lightning Memory-Mapped Database). Оригінальний boltdb/bolt Бен Джонсон заморозив, тому весь світ перейшов на форк etcd-io/bbolt, який підтримує саме команда etcd. Тобто навіть "embedded альтернатива etcd" живе під крилом etcd.
  • etcd використовує bbolt як свій локальний storage engine. Тобто кожна нода etcd всередині - це той самий BoltDB, поверх якого накручено Raft, MVCC по revision і gRPC watch.
  • BoltDB - це B+tree з єдиним writer'ом і copy-on-write сторінками. Звідси й ACID "безкоштовно": запис іде в нові сторінки, а старі лишаються валідними для паралельних читачів, поки транзакція не закомітиться.
  • Raft, на якому стоїть etcd, придумали Дієго Онгаро і Джон Оустергаут саме як "зрозумілу альтернативу Paxos" - назва походить від "Reliable, Replicated, Redundant, And Fault-Tolerant".
  • Кворум - це не "більшість нод", а "більшість нод, які мають голосувати", і тому парна кількість нод в etcd гірша за непарну. 4 ноди витримують ту саму одну відмову, що й 3 (кворум 3 з 4 проти 2 з 3), але дають на одну ноду більше шансів зламатися. Саме тому в документації всюди 3 або 5, а не 4.
  • Розмір бази etcd за замовчуванням обмежений 2 GB (--quota-backend-bytes). Коли ліміт пробитий, кластер переходить у read-only alarm NOSPACE, і Kubernetes раптово перестає приймати будь-які записи. Пофіксити можна лише compaction + defrag + ручним etcdctl alarm disarm. Наш .db файл просто росте, поки є диск - гірше з точки зору контролю, але без режиму "кластер живий, але нічого не приймає".
  • Історія в etcd не безкінечна: старі revision вирізає compaction, і клієнт, який спробує стартувати watch з надто старого revision, отримає ErrCompacted. Тобто навіть "watch з гарантією доставки" гарантує її лише в межах вікна retention - просто вікно вимірюється хвилинами, а не нулем, як у нас.
  • MVCC в BoltDB безкоштовний, але не безкоштовний для диска: сторінки, які ще бачить довга read-транзакція, не можна повернути у freelist. Один забутий View(), який тримають годину, і файл роздувається на всі записи за цю годину. У bbolt це відомий клас проблем, тому там і з'явилися FreelistType та NoFreelistSync.
  • Файл BoltDB - це mmap. Розмір бази обмежений адресним простором процесу, тому на 32-бітних платформах максимум ~2 GB (у bbolt це навіть окрема константа maxMapSize). На 64-бітних - фактично необмежено.
  • Перші два блоки будь-якого .db файлу - це дві мета-сторінки з transaction id і контрольною сумою. Комміт - це запис нової мета-сторінки поверх старішої з двох; саме ця подвійна буферизація й робить крах під час запису безпечним. Тому bolt.Open() на обірваному файлі не панікує, а просто відкочується до попереднього валідного стану.
  • etcdctl snapshot save під капотом робить те саме, що ми радимо замість cp: викликає Tx.WriteTo() на своєму bbolt-і всередині read-транзакції. Різниця не в механізмі, а в тому, що etcd додає до снепшоту hash і revision, тому вміє перевірити цілісність при відновленні.
  • K3s пішов ще далі за SQLite: kine - це шар, який прикидається etcd API поверх SQLite, Postgres або MySQL. Тобто Kubernetes можна запустити взагалі без etcd, просто підсунувши йому щось, що говорить його мовою. Наш Store за інтерфейсом - та сама ідея, тільки в мініатюрі.
  • Consul і Zookeeper вирішують ту саму задачу, що й etcd, і теж через consensus-лог (Raft і Zab відповідно) - але з різними обіцянками щодо читань. У Zookeeper читання за замовчуванням можуть віддати трохи застарілі дані, і щоб отримати гарантовано свіже, треба явно попросити sync. Отже "distributed KV" - це не одна семантика, а ціле сімейство.

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

Найбільший аха-момент - що watch у BoltDB і watch у etcd це лише на словах "одне й те саме". У нас watch - це in-memory Go-канал: процес перезапустився - підписки зникли. В etcd watch має revision, тому клієнт після реконекту догоняє все пропущене.

Саме тому я в Shepherd зробив polling основним механізмом, а watch - лише прискорювачем. Спершу здавалося, що це "відсталий" дизайн. Потім дійшло: це й є та сама модель eventual consistency, тільки чесно визнана - я не вдаю, що в мене є гарантована доставка, якої немає.

На що звернути увагу

  • BoltDB має рівно одного writer'а. Довга write-транзакція (наприклад, ітерація по великому bucket під Update()) блокує всі інші записи - тримай write-транзакції короткими.
  • bolt.Open() бере ексклюзивний file lock. Два процеси на тому самому .db файлі - і другий зависне на Timeout. Це класична пастка, коли стартуєш standalone і server на одному --data-dir.
  • Канали watch у нас з буфером 64 і select/default: під сплеском подій повідомлення тихо дропаються. Без polling-а як підстраховки контролери пропустили б зміни.
  • Бекап через cp безпечний лише на закритій або тихій базі: копія "гарячого" файлу під активним записом може бути неконсистентною. Правильніше - Tx.WriteTo() всередині read-транзакції.
  • Read-транзакція, яку забули закрити, тримає сторінки від перевикористання. Довгий View() (наприклад, під час стріму великої відповіді API) роздуває файл рівно на обсяг записів за цей час. Правило просте: View() живе не довше, ніж потрібно, щоб скопіювати дані в пам'ять.
  • Файл не зменшується сам. Видалив тисячу подів - файл лишиться того ж розміру, просто зі вільними сторінками всередині. Щоб віддати місце системі, потрібен окремий прохід (перезапис бази в новий файл).

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

  • Додати revision/sequence до подій, щоб watch міг віддавати "усе після revision N" - крок у бік семантики etcd.
  • Винести snapshot-бекап через db.View(func(tx) { tx.WriteTo(w) }) замість cp, щоб робити консистентні копії на живій базі.
  • Ввести compaction/retention для bucket з подіями: зараз events ростуть необмежено і роздувають файл.
  • Як наступну вправу - сховати Store за інтерфейсом і зробити другу реалізацію поверх etcd. Тоді можна порівняти однаковий код на embedded і на distributed бекенді. Це, до речі, єдиний спосіб дізнатися, скільки припущень про "один writer" і "все в одному процесі" вже протекло у контролери.
  • Додати періодичний snapshot у фоні (той самий Tx.WriteTo()) і ротацію копій - зараз бекап існує лише як команда в README.

Спробуй сам

# BoltDB - один файл, весь стан кластера:
ls -lh /var/lib/shepherd/shepherd.db
# Бекап:
cp /var/lib/shepherd/shepherd.db ~/shepherd-backup.db
# Подивись вміст через API:
curl -s localhost:9876/api/v1/info | jq .
curl -s localhost:9876/api/v1/pods | jq 'length'
curl -s localhost:9876/api/v1/events | jq '.[0:3]'

Починаємо серію Go Systems Programming. Далі - build tags для крос-платформності.

Ресурси

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

Попередня: Two-Mode Architecture