BoltDB замість etcd: embedded state store¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Kubernetes використовує etcd - розподілену key-value базу з Raft-консенсусом. Для кластера з сотнями нод це потрібно. Але для навчального проекту etcd - це overkill. У Shepherd ми використовуємо BoltDB - embedded базу, яка живе в одному файлі.
Buckets - таблиці BoltDB¶
var (
bucketPods = []byte("pods")
bucketServices = []byte("services")
bucketDeployments = []byte("deployments")
bucketNodes = []byte("nodes")
bucketEvents = []byte("events")
)
graph TD
DB[("shepherd.db")]
DB --> PODS["<b>pods</b>"]
DB --> SVCS["<b>services</b>"]
DB --> DEPS["<b>deployments</b>"]
DB --> NODES["<b>nodes</b>"]
DB --> EVENTS["<b>events</b>"]
PODS --> PV["default/web-0 → Pod JSON<br/>default/web-1 → Pod JSON"]
SVCS --> SV["default/web-service → Service JSON"]
DEPS --> DV["default/web → Deployment JSON"]
NODES --> NV["node-1 → Node JSON"]
EVENTS --> EV["1714300000-pod/web-0 → Event JSON"]
Ініціалізація¶
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,
} {
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
return err
}
}
return nil
})
return &Store{db: db}, nil
}
bolt.Open створює або відкриває файл shepherd.db. Bucket'и створюються, якщо їх ще немає.
Ключі з namespace¶
Поди, сервіси і deployment'и - namespaced ресурси. Ключ формується як namespace/name:
func nsKey(namespace, name string) []byte {
if namespace == "" {
namespace = "default"
}
return []byte(namespace + "/" + name)
}
Nodes не мають namespace, тому ключ - просто ім'я ноди.
CRUD хелпери¶
Три простих функції для всіх операцій:
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)
})
}
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) delete(bucket []byte, key []byte) error {
return s.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket(bucket).Delete(key)
})
}
Update - транзакція на запис. View - тільки на читання (не блокує інші View).
Список з фільтрацією по namespace¶
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)
}
return nil
})
})
}
Якщо prefix пустий - повертаємо все. Якщо вказаний namespace - фільтруємо по префіксу ключа.
Watch - сповіщення про зміни¶
type Store struct {
db *bolt.DB
mu sync.RWMutex
podWatchers []chan Event
deploymentWatchers []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: // не блокуємо, якщо канал повний
}
}
}
Контролери підписуються на зміни через WatchPods() і WatchDeployments(). Коли под оновлюється, Store відправляє подію в усі канали.
Events - журнал подій¶
func (s *Store) RecordEvent(evt Event) error {
key := fmt.Sprintf("%d-%s",
evt.Timestamp.UnixNano(), evt.Object)
return s.put(bucketEvents, []byte(key), evt)
}
func (s *Store) ListEvents(limit int) ([]Event, error) {
var events []Event
s.db.View(func(tx *bolt.Tx) error {
c := tx.Bucket(bucketEvents).Cursor()
count := 0
// Від останніх до перших
for k, v := c.Last(); k != nil && count < limit;
k, v = c.Prev() {
var evt Event
json.Unmarshal(v, &evt)
events = append(events, evt)
count++
}
return nil
})
return events, nil
}
Ключ події - timestamp в наносекундах + об'єкт. BoltDB зберігає ключі у відсортованому порядку, тому Cursor.Last() дає найновіші події.
Є один момент¶
BoltDB - single-node. Якщо API Server впаде, дані залишаться в файлі, але: - Немає реплікації - Немає distributed watch - Один writer at a time (хоча багато readers)
Для продуктового кластера потрібен etcd з його Raft-консенсусом і лінеаризованими читаннями. Для навчання BoltDB - ідеальний вибір: нуль зовнішніх залежностей, все в одному файлі.
Спробуй сам¶
# Подивись розмір бази:
ls -lh /var/lib/shepherd/shepherd.db
# Перевір вміст через API:
curl -s localhost:9876/api/v1/nodes | jq .
curl -s localhost:9876/api/v1/events | jq '.[0:3]'
Стан зберігається. Далі - Scheduler: як вибрати найкращу ноду для поду.
Ресурси¶
- etcd-io/bbolt — підтримуваний форк BoltDB, що використовується в production
- etcd data model — як etcd побудовано поверх bbolt
- encoding/json — JSON-серіалізація в Go
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Kubernetes API Server | Наступна: Scheduler
