これまで5回にわたって、KVMベースのプライベートクラウド管理プラットフォーム Marmot を紹介してきました。今回は少し立ち止まり、Marmotのアーキテクチャの根幹に関わる設計判断——「なぜ状態管理にRDBMSではなく etcd を選んだのか」——を掘り下げてみます。
この記事で書くこと
- MarmotがKVM/LibVirt/OVSと並んで
etcdを主要依存技術に据えている理由 - SQLデータベース(RDBMS)と分散KVストアの本質的な違い
- Marmotのようなインフラ管理基盤にとって、なぜその違いが決定的なのか
- 逆に、SQLの方が向いている場面はどこか(トレードオフの整理)
- 現在シングル構成で動いているetcdを、将来的にクラスタ化することで得られる高可用性・耐障害性
1. Marmotが管理しているのは「状態」であって「レコード」ではない
Marmotは、YAMLファイル一枚で仮想サーバー・ネットワーク・ボリュームを宣言的に定義し、複数のハイパーバイザーノード上でその状態を実現するプラットフォームです。
apiVersion: v1
kind: Server
metadata:
name: db
comment: LVMボリュームを使用するMySQLサーバー
spec:
cpu: 4
memory: 4096
mmImage: ubuntu24.04
auth:
url: https://github.com/takara9.keys
ansible:
playbook: playbook/mysql-setup.yaml
inventory: playbook/hosts.yaml
networkInterface:
- networkname: host-bridge
- networkname: db-net
storage:
- metadata:
name: data-1
mactl server create -f server.yaml を実行すると、この宣言はどこかに「望ましい状態(Desired State)」として記録され、marmotd デーモンがそれを見て実際のVMを起動します。
ここで重要なのは、Marmotが扱っているデータの性質です。
- 複数の
marmotdノードが同じクラスター状態を共有する必要がある - VMの起動・停止・削除のたびに頻繁に状態が書き換わる
- ノードは追加・離脱し、生死をリアルタイムに把握する必要がある
- IPアドレスやリソース割り当てのように、同時実行で衝突してはいけない値がある
これは会計システムの帳簿データや、ECサイトの注文履歴のような「正規化されたリレーショナルなレコード」とは根本的に性質が異なります。Marmotが必要としているのは、分散環境で一貫性を保ちながら共有できる、軽量な状態ストアです。ここに etcd を選んだ理由があります。
2. etcdとは何か(おさらい)
etcd は Raft合意アルゴリズムに基づく分散KVストアで、Kubernetesのクラスター状態管理エンジンとして採用されていることで広く知られています。特徴は次の通りです。
- 強整合性(Linearizability): クラスター内のどのノードから読んでも、常に最新の書き込みが反映されている
- Watch API: あるキー(またはプレフィックス)の変更をリアルタイムに購読できる
- Lease(TTL付きキー): 一定時間更新がなければ自動的に消えるキーを作れる(ハートビートに最適)
- トランザクション(CAS: Compare-And-Swap): 「このキーの値が期待通りであれば更新する」という条件付き書き込みができる
- 単一バイナリ・シンプルな運用: RDBMSのようなチューニングパラメータの多さがなく、構築・運用コストが低い
これらはすべて、「分散システムの状態を安全に共有する」という一点に最適化された機能です。
3. SQLデータベースではなぜダメなのか(ダメではないが、向いていない)
誤解のないように言うと、SQLデータベースが「劣っている」わけではありません。適材適所の問題です。RDBMSは以下のような用途に非常に強いツールです。
- 複雑なJOINを伴う集計・分析クエリ
- 厳密なスキーマとリレーショナルな整合性制約
- トランザクションログを長期保存し、監査・レポーティングに使う
一方で、Marmotのようなクラスターオーケストレーション基盤の要件と照らすと、いくつかミスマッチが生じます。
| 観点 | RDBMS(SQL) | etcd |
|---|---|---|
| データモデル | スキーマ定義が必要なリレーショナルモデル | シンプルなKey-Value(YAML/JSONと親和性が高い) |
| 変更の検知 | ポーリングやトリガーの自作が必要 | Watch APIでイベント駆動に購読できる |
| 生存監視 | 別途ハートビートテーブル+バッチ削除処理が必要 | Lease(TTL)キーで自動失効を標準サポート |
| 複数ノード間の排他制御 | 分散トランザクション/外部ロックが必要で複雑 | CASトランザクションで単純かつ安全に実現 |
| 運用コスト | インストール・チューニング・バックアップ設計が重い | 単一バイナリ、構成がシンプル |
| 一貫性モデル | 実装依存(結果整合〜強整合まで様々) | Raftによる強整合性がデフォルト |
※「変更の検知」の行はetcd一般が持つ機能としての比較です。後述しますが、Marmot自身は現時点ではこのWatch APIをまだ活用しておらず、ポーリング方式でリコンサイルを行っています(4章で詳しく説明します)。
たとえばMarmotがIPアドレスを払い出すIPAM機能を考えてみます。複数の marmotd ノードが同時にVMを起動しようとしたとき、同じIPアドレスを二重に割り当ててしまう危険があります。SQLでこれを安全に行うには、SELECT ... FOR UPDATE のような行ロックや、分離レベルの調整、デッドロック対策など、分散環境をまたぐと途端に複雑になる処理が必要です。
実際のMarmotのソースコード(pkg/db/db-ipam.go)を見ると、IPアドレスの払い出しは次のように実装されています。
// pkg/db/db-ipam.go の AllocateIP() より(抜粋・一部簡略化)
func (d *Database) AllocateIP(vnetId, ipnetId, hostId string) (string, int, error) {
// ...ネットワーク範囲の計算など省略...
// IPネットワークごとに専用のロックキーを切って、分散ロックを取得
lockKey := NetworkPrefix + "/" + vnetId + "/ip_network/" + ipnetId + "/ipam_lock"
mutex, err := d.LockKey(lockKey)
if err != nil {
return "", 0, err
}
defer d.UnlockKey(mutex)
// ロックを保持したまま、空いているIPアドレスを順に探す
addr := startAddr
for {
found, err := d.CheckIPaddrInUse(vnetId, ipnetId, addr.String())
if err != nil {
return "", 0, err
}
if !found {
if err := d.SetIPaddrInUse(vnetId, ipnetId, addr.String(), hostId); err != nil {
return "", 0, err
}
return addr.String(), prefix.Bits(), nil
}
addr = addr.Next()
}
}
ポイントは d.LockKey(lockKey) の部分です。これはetcdのCASトランザクションを直接叩いているのではなく、etcdの concurrency パッケージが提供するセッション(リース)ベースの分散ロックを使っています。db.go 側の実装(pkg/db/db-etcd.go)を見ると、Database 構造体は起動時に concurrency.NewSession() でリース付きのセッションを張っており、LockKey() はそのセッション上に concurrency.NewMutex() を作って Lock() するだけのシンプルな実装です。
// pkg/db/db-etcd.go より
func (d *Database) LockKey(lockName string) (*concurrency.Mutex, error) {
ctx, cancel := context.WithTimeout(d.Ctx, 5*time.Second)
defer cancel()
d.Mutex = concurrency.NewMutex(d.Session, lockName)
if err := d.Mutex.Lock(ctx); err != nil {
return nil, fmt.Errorf("failed to acquire lock %s: %w", lockName, err)
}
return d.Mutex, nil
}
このロックはetcdのリースに紐づいているため、ロックを取得した marmotd プロセスが異常終了した場合でも、リースが切れれば自動的にロックが解放されます。RDBMSで似たことをやろうとすると、コネクションプールの生存監視や、アプリケーションクラッシュ時のロック解放処理を自前で作り込む必要があり、地味に手間のかかる部分です。
一方でMarmotは、もう一つの排他制御パターンとしてCAS(Compare-And-Swap)による楽観的排他制御も併用しています。こちらは pkg/db/db-etcd.go の PutJSONCAS() として実装されており、サーバー情報の更新・認証情報の更新・ロードバランサー設定の更新など、値の更新頻度は高いが「同時に同じキーを取り合う」場面が少ない箇所で広く使われています。
// pkg/db/db-etcd.go より
// PutJSONCAS: ModRevision が expectedRev の時だけ更新する
func (d *Database) PutJSONCAS(key string, expectedRev int64, v interface{}) error {
// ...
txn := d.Cli.Txn(ctx)
txnResp, err := txn.
If(etcd.Compare(etcd.ModRevision(key), "=", expectedRev)).
Then(etcd.OpPut(key, string(byteData))).
Else(etcd.OpGet(key)).
Commit()
// ...
if !txnResp.Succeeded {
return ErrUpdateConflict
}
return nil
}
「読み取った時点のModRevision(etcd内部の更新リビジョン番号)と、書き込む時点のModRevisionが一致していれば更新する」という単純な条件付きトランザクションです。VMのシリアル番号採番(pkg/db/db-serial-number.go)などでは、この分散ロックとCASを組み合わせ、「ロックで採番処理全体を直列化しつつ、実際の書き込みはCASで安全性を二重に担保する」という手堅い実装になっています。
いずれのパターンも、SQLであれば行ロックや分離レベルの調整が必要になる処理を、etcdのシンプルなプリミティブ(リースベースのMutexと、ModRevisionベースのCAS)だけで、複数ノードにまたがる形で安全に実現できている点がポイントです。
4. リコンサイルループの実態——Ticker方式のポーリング
MarmotのようなIaC寄りの基盤では、「あるべき状態(Desired State)」と「実際の状態(Actual State)」を継続的に一致させ続ける、いわゆるリコンサイルループの考え方がKubernetesと同様に重要になります。
etcd自体はWatch APIによるイベント駆動の変更検知を得意としていますが、Marmotの現在の実装(pkg/controller/配下)を確認すると、実はこのWatch APIは使われていません。
実装を見ると、server-controller.go や network-controller.go、host-controller.go など、リソース種別ごとに用意されたコントローラーが、それぞれ time.NewTicker による定期ポーリングでetcd上の状態を読み取り、あるべき状態と実際の状態を突き合わせています。
// pkg/controller/host-controller.go の起動処理より(構造のイメージ)
ticker := time.NewTicker(HOST_CONTROLLER_INTERVAL)
for {
select {
case <-ticker.C:
// etcdから最新の状態を読み取り、実機の状態と突き合わせる
case <-ctx.Done():
return
}
}
Watch APIを使ったプッシュ型のイベント通知ではなく、一定間隔でGetし続けるプル型の実装です。これは「実装がシンプルで理解しやすい」「コントローラーがクラッシュ後に再起動しても、次のTickで自然に状態を拾い直せる」という運用上のメリットがある一方、Watch APIに比べると変更検知までのレイテンシがポーリング間隔に依存する、という特性があります。
もう一つ興味深いのが、scheduler-controller.go に実装されているリーダー選出の仕組みです。etcdには concurrency.Election という組み込みのリーダー選出プリミティブがありますが、Marmotはこれを使わず、各ホストが定期的に書き込む HostStatus(更新時刻とホストIDを含む)をポーリングで集め、「直近30秒以内に更新があった生存ノードの中で、ホストIDが最小のノードをリーダーとする」という比較的シンプルな自前ロジックでリーダーを決定しています。
// pkg/controller/scheduler-controller.go
const ActiveHostThreshold = 30 * time.Second // HostControllerが10秒間隔で更新するため、3サイクル分を許容
etcdの高機能な合意プリミティブを使わなくても、「みんなが同じKVストアを見て、同じルールで決定する」というetcdの基本的な性質(強整合性のある共有ビュー)さえあれば、この程度のリーダー選出は十分実現できる、という一つの実例です。逆に言えば、Watch APIやElection APIといったetcdのより高度な機能は、Marmotにとってまだ「使いこなせていない伸びしろ」でもあります。
5. それでもトレードオフはある
公平を期すために、etcdを選ぶことの代償にも触れておきます。
- 複雑な検索・集計には向かない: 「先月作成されたVMの一覧をCPUコア数でソートして出す」といった処理はetcd単体では苦手で、アプリケーション側でロジックを組む必要がある
- 大量データのストレージには不向き: etcdは公式に1オブジェクトあたりのサイズ制限やDB全体の容量に関する推奨値があり、大量のログや履歴データの保管場所には適さない
- 監査ログ・長期保存には別ストアが必要: Marmotの監査ログ設計でPostgreSQLの追記専用テーブルが検討されているように、「今の状態」を持つetcdと、「過去の記録」を持つRDBMSは役割分担するのが現実的
つまりMarmotの設計は「etcd一本槍」ではなく、状態管理はetcd、記録・分析はRDBMSという適材適所の思想に基づいていると言えます。
6. シングル構成からクラスタ構成へ——本当の価値はここにある
ここまでの話は、正直なところ「etcdをシングルノードで動かしているだけ」でもかなりの部分が成り立ちます。KVストアとしての手軽さ、分散ロック(concurrency.Mutex)やCASトランザクションの安全性は、1台構成でも享受できるからです。
しかし、etcdが設計上本当に狙っている価値は、クラスタ化して初めてフルに引き出されます。現在のMarmotはetcdをシングル構成で運用していますが、ここをクラスタ化することで得られるメリットは決して小さくありません。
6-1. シングル構成の弱点
今のようにetcdが1台だけで動いている構成では、そのノードが以下のような理由でダウンした瞬間、Marmotクラスター全体が機能不全に陥ります。
- OSやハードウェアの障害
- ディスク障害によるデータ破損
- メンテナンスのための再起動
つまり、etcdノード自体が**単一障害点(SPOF: Single Point of Failure)**になってしまいます。マルチノードでVMを分散配置できるように設計されたMarmotが、状態管理の要であるetcdだけはシングル構成、というのはアーキテクチャ全体で見ればアンバランスな状態です。
6-2. Raftのクォーラムによる耐障害性
etcdはRaft合意アルゴリズムに基づいて動作するため、クラスタ化するとノード数の過半数(クォーラム)が生きていれば、クラスター全体は正常にサービスを継続できます。
一般的に推奨されるのは奇数台構成です。
| ノード数 | 許容できる障害ノード数 | 備考 |
|---|---|---|
| 1台 | 0台 | シングル構成。1台落ちたら即停止 |
| 3台 | 1台 | 最小構成のHA。1台のメンテナンス・障害に耐えられる |
| 5台 | 2台 | より高い可用性。書き込みレイテンシとのトレードオフあり |
奇数台が推奨される理由は、Raftの合意形成が「過半数の合意」に基づくためです。たとえば3台構成なら2台以上、5台構成なら3台以上のノードが合意すれば書き込みが成立します。偶数台にしてもクォーラムに必要な台数は変わらない(4台でも過半数は3台必要)ため、コストに見合った耐障害性の向上が得られず、実質的なメリットがありません。
Marmotのように複数のハイパーバイザーノードを前提とした基盤であれば、そのうち3台にetcdメンバーを配置するだけで、1台がメンテナンスや障害でダウンしてもクラスター全体のVM管理機能は止まらない、という構成が実現できます。
6-3. 読み取り・書き込みの可用性が上がる
シングル構成では、そのノードへの経路が切れた時点で mactl や marmotd からのすべての読み書きが失敗します。クラスタ構成であれば、クライアント側(etcdのGoクライアントライブラリ)が複数エンドポイントを認識し、リーダーが切り替わっても自動的に再接続・リトライしてくれるため、Marmot側のコードを大きく変えることなく可用性を引き上げられます。
6-4. 運用面でのメリット
- ローリングメンテナンスが可能: 1台ずつ再起動・OSアップデートしても、残りのノードでクォーラムを維持できればサービスは継続する
- リーダー障害からの自動復旧: Raftのリーダー選出により、リーダーがダウンしても数秒〜数十秒で新リーダーが選出され、書き込みが再開する
- データの冗長化: 全メンバーが同じ状態のコピーを持つため、1台のディスク破損がそのままデータ消失に直結しない
6-5. 留意点
もちろんクラスタ化はタダではありません。
- ノード間のネットワークレイテンシが書き込み性能に直結する(Raftの合意には往復通信が必要)
- 運用するetcdメンバーの台数分、監視・証明書管理・ディスク容量計画などの対象が増える
- クラスタのメンバーシップ変更(ノード追加・削除)には手順上の注意が必要
とはいえ、Marmotが目指す「複数ハイパーバイザーノードにまたがるプライベートクラウド基盤」という性格を考えれば、etcdのシングル構成はいずれ解消すべきボトルネックであり、3台構成へのクラスタ化は費用対効果の高い投資だと言えます。
7. まとめ
MarmotがSQLデータベースではなくetcdを採用しているのは、単なる技術的な好みではなく、
- 複数ノードにまたがるクラスター状態の共有
- IPAMやシリアル番号採番のような分散ロック・CASによる排他制御が必要な処理
- Ticker方式のポーリングで実現されているリコンサイルループ
- 運用がシンプルな単一バイナリでの状態ストア
- クラスタ化によって得られる単一障害点の排除と高可用性
という、インフラオーケストレーション基盤特有の要件に対する必然的な帰結です。この設計思想はKubernetesが証明済みのアプローチでもあり、Marmotが「小さなKubernetes的発想」を仮想マシン管理に持ち込んでいることの表れとも言えます。現時点ではシングル構成で運用していますが、Raftのクォーラムを活かした3台構成へのクラスタ化は、Marmotの信頼性を一段引き上げる次の一手だと考えています。
次回は、この etcd 上に構築されている IPAM(IPアドレス管理)の具体的な実装、あるいはetcdクラスタ化の実際の構築手順について掘り下げていく予定です。
コントリビューター募集中
Marmotは個人主導で開発を進めているOSSプロジェクトですが、KVM/LibVirt、Open vSwitch、etcd、Ceph、Kubernetesといった領域に興味のある方の力を借りたいと考えています。
今回取り上げたetcd周りだけでも、
- Watch APIを使ったイベント駆動なリコンサイルループへの置き換え
- etcdクラスタ化(3台構成)の検証・ドキュメント化
-
concurrency.Electionを使ったリーダー選出への移行
など、伸びしろのあるテーマがいくつも残っています。もちろんこれ以外にも、ドキュメント整備、テスト追加、バグ報告、Issueのトリアージなど、小さな貢献も大歓迎です。
- Issues: github.com/takara9/marmot/issues — バグ報告や機能要望はこちらへ
- Pull Requests: 修正・機能追加はPRでどうぞ。まずは CONTRIBUTING.md に目を通してもらえるとスムーズです
- Discussions: github.com/takara9/marmot/discussions — 設計の相談や質問、アイデア出しはこちら
- ライセンス: GPL-3.0 license
「検証・実験・学習のための、簡便で高速な仮想サーバーの実行環境」というMarmotのコンセプトに共感してくれる方、あるいは「etcdやKVM周りの実装を触ってみたい」というエンジニアの方は、気軽にIssueやDiscussionsを覗いてみてください。Star ⭐ も励みになります。
関連リンク