第4章: コンフィグレーション

すべての分散システムは環境に適応しなければなりません。サーバーアドレスの変更、フィーチャーフラグの切り替え、レート制限の調整、運用パラメータの変更——しばしばシステムの実行中に行われます。これらの値をバイナリにハードコードすると、パラメータが変更されるたびに再コンパイルと再デプロイが必要になります。コンフィグレーションサービスはランタイムパラメータをそれを使用するコードから分離し、運用者がソースコードに触れたりプロセスを再起動したりすることなくシステムの動作を変更できるようにします。

コアにおいてコンフィグレーションサービスは一つのひねりを加えた分散キーバリューストアです:クライアントは値を読み取るだけでなく変更を監視します。コンフィグレーション値が変更されるとそれに依存するシステムは迅速に変更を知る必要があります。これによりコンフィグレーションは基盤サービスとなり、プラネタリスケールコンピュータのほぼすべてのサービスがそれに依存します。

インターフェース

configuration/src/lib.rs コンフィグレーションサービスはRPCインターフェースを通じて5つのプロシージャを公開します。基本操作——get、set、delete——は標準的なキーバリューセマンティクスを提供します。list操作はプレフィックスベースのキー列挙をサポートし、storage.caching.のような名前空間下のすべてのコンフィグレーションの発見に有用です。watch操作によりクライアントは特定のキーの変更をポーリングできます。

pub const GET_PROCEDURE: ProcedureId = 1;
pub const SET_PROCEDURE: ProcedureId = 2;
pub const DELETE_PROCEDURE: ProcedureId = 3;
pub const LIST_PROCEDURE: ProcedureId = 4;
pub const WATCH_PROCEDURE: ProcedureId = 5;

#[derive(Debug, Serializable, Deserializable)]
pub struct GetArgs {
    pub key: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct GetResult {
    pub value: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct SetArgs {
    pub key: String,
    pub value: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct DeleteArgs {
    pub key: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct ListArgs {
    pub prefix: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct ListResult {
    pub keys: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct WatchArgs {
    pub key: String,
}

#[derive(Debug, Serializable, Deserializable)]
pub struct WatchEvent {
    pub key: String,
    pub value: String,
}

各構造体は正規化システムからSerializableDeserializableを派生し、rpcを介したネットワーク転送を可能にします。ListResultはキーをカンマ区切り文字列として返します——これはリストや配列の正規化を必要としない意図的なシンプルさです。

実装

configuration/src/main.rs サーバーはインメモリHashMapと変更をウォッチャーに通知するためのブロードキャストチャネルを含むConfigStoreを維持します。ブロードキャストチャネルはTokioのbroadcast::channelを使用し複数のレシーバーと有界バッファリングをサポートします。

struct ConfigStore {
    data: HashMap<String, String>,
    watchers: broadcast::Sender<(String, String)>,
}

impl ConfigStore {
    fn new() -> Self {
        let (tx, _) = broadcast::channel(256);
        ConfigStore {
            data: HashMap::new(),
            watchers: tx,
        }
    }
}

setプロシージャのハンドラはキーバリューペアをハッシュマップに挿入し、ウォッチャーチャネルを通じて変更をブロードキャストします。このキーの変更を監視しているシステムはsetが完了するとすぐに通知されます:

pub async fn set(payload: &str, store: &mut ConfigStore) -> Response {
    let args = SetArgs::deserialize(payload)
        .expect("Failed to deserialize payload");
    store.data.insert(args.key.clone(), args.value.clone());
    let _ = store.watchers.send((args.key, args.value));
    Response { payload: "OK".to_string() }
}

listハンドラはプレフィックスでキーをフィルタリングします。これはコンフィグレーションの階層的な整理を可能にするパターンです。watchプロシージャはキーの現在の値を返しクライアントが変更をポーリングできるようにします。

設計の議論

この実装にはいくつかの設計上のトレードオフがあります。インメモリストアは高速な読み書きを提供しますが再起動に耐えられません。本番のコンフィグレーションサービスはデータを永続的なストレージに保存します——実際には後で見るストレージサービスを使用できるでしょう。

ウォッチャー用のブロードキャストチャネルは実用的な選択です。通知メカニズムをストレージメカニズムから分離し有界バッファが遅いコンシューマーがライターをブロックするのを防ぎます。しかしバッファが一杯になると遅れて到着するウォッチャーはイベントを見逃します——完全性よりも可用性を優先するトレードオフです。

コンフィグレーションは分散システムで最初に起動し最後に停止するサービスであることが多いです。ほぼすべてのサービスがコンフィグレーションに依存するためその可用性は重要です。本番環境ではコンフィグレーションサービスは個々のサーバーが障害を起こしてもコンフィグレーションデータが常に利用可能であることを保証するためにコンセンサスプロトコルを使用して複数のサーバーにレプリケートされます。