Embedded vs External DB: BoltDB vs etcd trade-offs¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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 alarmNOSPACE, і 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 для крос-платформності.
Ресурси¶
- etcd-io/bbolt: embedded KV-сховище, підтримуваний форк BoltDB
- bbolt: Transactions і Tx.WriteTo: консистентний снепшот живої бази
- etcd data model: revision, MVCC, compaction
- etcd: Watch API: доставка з revision і
ErrCompacted - etcd: Maintenance: 2 GB quota, alarm
NOSPACE, compaction і defrag - etcd: FAQ про розмір кластера: чому 3 і 5, а не 4
- In Search of an Understandable Consensus Algorithm: оригінальна стаття про Raft
- LMDB design: copy-on-write B+tree, з якого виріс BoltDB
- k3s: kine: etcd API поверх SQLite/Postgres/MySQL
- Patterns of Distributed Systems: replicated log, write-ahead log
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Two-Mode Architecture
