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

BoltDB замість etcd: embedded state store

BoltDB замість etcd: embedded state store

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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