本文へスキップ
【.NET Orleans入門】第2回 ステート管理と永続化 - 信頼性の高いステートフルサービスの構築のアイキャッチ画像
Architecture

【.NET Orleans入門】第2回 ステート管理と永続化 - 信頼性の高いステートフルサービスの構築

公開: 更新: 約11分で読めます

第1回では、.NET Orleans の全体像として、バーチャルアクターモデル、グレインとサイロという中心概念、そして高スケールなワークロードに向く理由を整理しました。連載第2回となる本稿の主題は「状態管理と永続化」です。状態を持つグレインを、サーバー障害やプロセスの再起動をまたいで信頼性高く運用するために、Orleans が用意している仕組みと、それを実務で扱ううえでの勘所を、.NET 10 を前提としたコード例とともに解説します。

分散システムにおいて、状態の扱いは最も慎重を要する部分です。どのタイミングで永続ストアへ書き込むのか、書き込みが競合したときにどう振る舞うのか、グレインがメモリから降ろされたあとに状態をどう復元するのか。これらの判断を明確にしておくことが、ステートフルなサービスの信頼性を左右します。Orleans はこの領域を IPersistentState<T> という抽象に集約しており、保存先の詳細に踏み込まずに一貫した書き方で状態を扱えるように設計されています。

Orleans における状態の二層構造

グレインが扱う状態には、性質の異なる二つの層があります。ひとつは活性化中にメモリ上へ保持される作業状態で、グレインが応答するあいだ高速に読み書きできる代わりに、グレインがメモリから降ろされれば失われます。もうひとつは永続状態で、外部のストレージへ書き出しておくことで、グレインが再び活性化されたときに同じ状態から再開できます。

Orleans の永続化は、この二層を IPersistentState<T> という一つの抽象でつなぎます。グレインはメモリ上のオブジェクトとして State プロパティを読み書きし、区切りのよいところで WriteStateAsync を呼ぶと、その時点の内容が永続ストアへ反映されます。逆に活性化時にはランタイムが自動的に永続ストアから読み込み、State に復元します。開発者が意識するのは「いつ書き込むか」であって、「どこへどう書くか」はストレージプロバイダーの設定に委ねられます。

この分離が効いてくるのは、保存先を切り替えたいときです。開発中はメモリ上のプロバイダーで手軽に動かし、本番では Azure のマネージドストアや SQL Server へ差し替えるといった運用を、グレインのコードをほとんど変えずに実現できます。グレイン側はプロバイダー名を参照するだけで、接続文字列やテーブル構成といった詳細から切り離されています。

Orleans のグレイン状態永続化を示す図。グレイン内の IPersistentState が WriteStateAsync と ReadStateAsync でストレージプロバイダーとやり取りし、その先で Azure Table・Blob・Cosmos DB・SQL のいずれかへ保存され、グレインは活性化と非活性化のライフサイクルを持つ。
グレインの状態が IPersistentState を通じてストレージプロバイダー経由でバッキングストアへ読み書きされる流れを表す

IPersistentState で状態を永続化する

まず永続化する状態クラスを定義します。ここで押さえておきたいのが、シリアライズの指定方法が現在の Orleans で変わっている点です。かつて用いられた [Serializable] ではなく、Orleans 独自の高速なシリアライザ向けに [GenerateSerializer] をクラスへ、各メンバーへ [Id] を付けます。[Id] の番号はシリアライズ時のフィールド識別子で、いちど付けた番号は変えずに保ち、フィールドを追加するときは新しい番号を割り当てます。この規約がバージョン間の互換性を支えます。

[GenerateSerializer]
public sealed class UserState
{
    [Id(0)] public string Name { get; set; } = string.Empty;
    [Id(1)] public int Score { get; set; }
    [Id(2)] public DateTime LastActive { get; set; }
    [Id(3)] public List<Achievement> Achievements { get; set; } = new();
}

[GenerateSerializer]
public sealed class Achievement
{
    [Id(0)] public string Id { get; set; } = string.Empty;
    [Id(1)] public string Name { get; set; } = string.Empty;
    [Id(2)] public DateTime UnlockedAt { get; set; }
}

次にグレインの実装です。永続状態はコンストラクタで [PersistentState] 属性を付けて注入します。第一引数は状態名、第二引数は使用するストレージプロバイダー名で、後者は Program.cs で登録したプロバイダーの名前に対応します。活性化時に状態が読み込まれるため、OnActivateAsync の時点で State は利用可能になっています。まだ一度も保存されていないグレインかどうかは RecordExists で判定できます。

public interface IUserGrain : IGrainWithStringKey
{
    Task<UserInfo> GetInfoAsync();
    Task UpdateProfileAsync(string name);
    Task<int> IncrementScoreAsync(int points);
}

public sealed class UserGrain : Grain, IUserGrain
{
    private readonly IPersistentState<UserState> _state;
    private readonly ILogger<UserGrain> _logger;

    public UserGrain(
        [PersistentState("user", "userStore")] IPersistentState<UserState> state,
        ILogger<UserGrain> logger)
    {
        _state = state;
        _logger = logger;
    }

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        // 初回のみ初期状態を用意する
        if (!_state.RecordExists)
        {
            _state.State.LastActive = DateTime.UtcNow;
        }
        return base.OnActivateAsync(cancellationToken);
    }

    public Task<UserInfo> GetInfoAsync() => Task.FromResult(new UserInfo(
        this.GetPrimaryKeyString(),
        _state.State.Name,
        _state.State.Score,
        _state.State.Achievements.Count));

    public async Task UpdateProfileAsync(string name)
    {
        _state.State.Name = name;
        _state.State.LastActive = DateTime.UtcNow;
        await _state.WriteStateAsync(); // この時点で永続ストアへ反映
    }

    public async Task<int> IncrementScoreAsync(int points)
    {
        _state.State.Score += points;
        _state.State.LastActive = DateTime.UtcNow;
        await _state.WriteStateAsync();
        return _state.State.Score;
    }
}

IPersistentState<T> は書き込みの WriteStateAsync のほかに、永続ストアの内容で State を上書きし直す ReadStateAsync、保存済みの状態を消す ClearStateAsync を持ちます。ReadStateAsync は、外部から状態が更新された可能性を考慮して最新を読み直したいときに使い、ClearStateAsync は論理削除や初期化に用います。通常の運用では、活性化時の自動読み込みと明示的な WriteStateAsync だけで足りることがほとんどです。

ストレージプロバイダーの選択と構成

永続状態の保存先は、Program.cs でサイロにストレージプロバイダーを登録して指定します。グレインの [PersistentState] が参照するのはここで付けた名前であり、同じ名前で登録先を差し替えれば、グレインのコードを変えずに保存先を切り替えられます。開発用として最も手軽なのがメモリストレージです。

// Program.cs(開発用の最小構成)
builder.UseOrleans(silo =>
{
    silo.UseLocalhostClustering();
    silo.AddMemoryGrainStorage("userStore");
});

本番向けには、要件に応じて次のような選択肢があります。いずれも保存先の特性が異なるため、状態のサイズ、アクセス頻度、コスト、既存の運用基盤との相性から選びます。

  • Azure Table Storage — 小さめの状態を大量のグレインに持たせる用途に向き、コストを抑えやすい選択肢です
  • Azure Blob Storage — 一件あたりが大きい状態や、まとまったデータを丸ごと保存したい場合に適します
  • Azure Cosmos DB — 低レイテンシと地理分散、柔軟なスケールが必要なワークロードに向きます
  • ADO.NET(SQL Server / PostgreSQL / MySQL) — 既存のリレーショナルDB基盤を活かし、運用ノウハウを共有したい場合に有力です

Azure Table Storage を使う場合の構成例を示します。現在の Orleans では、接続情報の指定にマネージド ID を用いる TableServiceClient の受け渡しにも対応しており、接続文字列を直接埋め込まない運用が取りやすくなっています。

silo.AddAzureTableGrainStorage("userStore", options =>
{
    options.TableName = "OrleansUserState";
    options.Configure<IServiceProvider>((opt, sp) =>
    {
        opt.TableServiceClient = new TableServiceClient(
            new Uri("https://myaccount.table.core.windows.net"),
            new DefaultAzureCredential());
    });
});

Cosmos DB や ADO.NET も、対応する拡張メソッドで同様に登録します。プロバイダーは用途ごとに複数を別名で登録できるため、頻繁に読むホットな状態は低レイテンシのストアへ、履歴のような冷たい状態は安価なストアへ、というように状態の性質に合わせて使い分けられます。

// 用途の異なるプロバイダーを併用する
silo.AddCosmosGrainStorage("hotStore", options =>
{
    options.ConfigureCosmosClient(
        "https://myaccount.documents.azure.com:443/",
        new DefaultAzureCredential());
    options.DatabaseName = "Orleans";
    options.ContainerName = "HotState";
});

silo.AddAzureBlobGrainStorage("coldStore", options =>
{
    options.BlobServiceClient = new BlobServiceClient(
        new Uri("https://myaccount.blob.core.windows.net"),
        new DefaultAzureCredential());
});

ライフサイクルと非活性化

グレインは常にメモリ上に居続けるわけではありません。一定時間呼び出されなければ、ランタイムはそのグレインを非活性化(deactivate)してメモリから降ろし、資源を解放します。次に呼び出されたときに改めて活性化され、そのとき永続ストアから状態が読み込まれます。この活性化と非活性化の流れを理解しておくことが、状態を取りこぼさない設計につながります。

活性化直後の処理は OnActivateAsync に、非活性化の直前の処理は OnDeactivateAsync に書きます。メモリ上の変更をまだ書き込んでいない場合に備え、非活性化の直前で確実に永続化しておくといった使い方が典型です。OnDeactivateAsync には非活性化の理由が渡されるため、通常の待機時間切れなのか、サイロの停止に伴うものなのかを判別できます。

public override async Task OnDeactivateAsync(
    DeactivationReason reason, CancellationToken cancellationToken)
{
    // 未書き込みの変更があれば、降ろされる前に永続化する
    if (_dirty)
    {
        await _state.WriteStateAsync();
        _dirty = false;
    }

    _logger.LogInformation(
        "Grain {Key} deactivating: {Reason}",
        this.GetPrimaryKeyString(), reason.ReasonCode);

    await base.OnDeactivateAsync(reason, cancellationToken);
}

非活性化のタイミングはランタイムに委ねるのが基本ですが、明示的に制御することもできます。処理が終わって当面使われないと分かっているグレインは this.DeactivateOnIdle() で早めにメモリから降ろせますし、逆に長い処理の途中で降ろされたくないときは this.DelayDeactivation(...) で活性を延長できます。ただし OnDeactivateAsync は、サイロが不意に停止した場合には呼ばれる保証がありません。障害をまたいで守りたい状態は、非活性化時の書き込みに頼らず、変更が確定した時点で WriteStateAsync しておくのが安全です。

並行性と一貫性

Orleans の大きな利点は、単一グレインへの呼び出しが一度に一件ずつ順番に処理される点にあります。グレインの内部は実質的にシングルスレッドで動くため、State を更新する処理にロックを書く必要がなく、読み取ってから書き戻すまでのあいだに別の呼び出しが割り込む心配もありません。競合の多くは、この仕組みによってグレインの内側で自然に解消されます。

一方で、永続ストアとの整合性は別の話です。Orleans は書き込みに楽観的並行性制御を用いており、IPersistentState<T> は保存ごとに更新される Etag を保持します。同じグレインの状態が外部の別経路から書き換えられ、手元の Etag が古くなった状態で WriteStateAsync を呼ぶと、InconsistentStateException が送出されます。これは、こちらが認識していない更新を上書きしてしまうことを防ぐための安全弁です。

public async Task<int> IncrementScoreSafelyAsync(int points)
{
    _state.State.Score += points;
    try
    {
        await _state.WriteStateAsync();
    }
    catch (InconsistentStateException)
    {
        // 永続ストアの最新を読み直してから、変更を適用し直す
        await _state.ReadStateAsync();
        _state.State.Score += points;
        await _state.WriteStateAsync();
    }
    return _state.State.Score;
}

単一グレインへのアクセスがすべて Orleans 経由であれば、シングルスレッド実行のおかげでこの例外はまず起きません。意識が要るのは、同じデータをグレイン以外の経路からも書き換える構成のときです。二重の書き込み経路は整合性の管理を複雑にするため、状態の更新はできるだけグレインに一本化するのが望ましい設計です。

書き込み戦略とパフォーマンス

永続化で最も効いてくる設計判断が、書き込みの頻度です。更新のたびに WriteStateAsync を呼ぶ方式(write-through)は、常に最新が永続化されていて障害に強い反面、更新が頻繁なグレインでは書き込み回数がそのままストアへの負荷とレイテンシになります。一方、メモリ上でまとめて変更し一定間隔で書き出す方式(write-behind)は、書き込み回数を減らせる代わりに、書き出し前に障害が起きた分の変更を失う可能性があります。どちらを採るかは、失ってよいデータの範囲という要件から決めます。

両者は排他ではなく、重要度に応じて使い分けられます。次の例では、確実に残したい重要な更新はその場で書き込み、そうでない更新はタイマーでまとめて書き出しています。周期処理には、現在の Orleans で推奨される RegisterGrainTimer を使います。

public sealed class ActivityGrain : Grain, IActivityGrain
{
    private readonly IPersistentState<ActivityState> _state;
    private bool _dirty;

    public ActivityGrain(
        [PersistentState("activity", "userStore")] IPersistentState<ActivityState> state)
        => _state = state;

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        // 5秒ごとに、変更があればまとめて永続化する
        this.RegisterGrainTimer(FlushAsync, new GrainTimerCreationOptions
        {
            DueTime = TimeSpan.FromSeconds(5),
            Period = TimeSpan.FromSeconds(5)
        });
        return base.OnActivateAsync(cancellationToken);
    }

    public async Task RecordAsync(ActivityEntry entry)
    {
        _state.State.Recent.Add(entry);

        if (entry.IsCritical)
        {
            await _state.WriteStateAsync(); // 重要な更新は即座に永続化
            _dirty = false;
        }
        else
        {
            _dirty = true; // それ以外はタイマーにまとめる
        }
    }

    private async Task FlushAsync(CancellationToken cancellationToken)
    {
        if (_dirty)
        {
            await _state.WriteStateAsync();
            _dirty = false;
        }
    }
}

もう一つ、パフォーマンスを左右するのが状態のサイズです。一つのグレインに際限なく履歴を溜め込むと、書き込みのたびに大きなペイロードを転送することになり、活性化時の読み込みも重くなります。保持する件数に上限を設ける、古いデータは別のグレインやストアへ切り出す、といった形で状態を適切な大きさに保つことが、安定した応答時間につながります。状態の粒度も同様で、業務上の実体に沿ってグレインを分けておくほど、書き込みは小さく、並行度は高く保てます。

まとめ

第2回では、Orleans のステート管理と永続化を扱いました。グレインの状態はメモリ上の作業状態と永続状態の二層からなり、IPersistentState<T> がその橋渡しを担うこと、シリアライズは [GenerateSerializer] と [Id] で指定すること、保存先はストレージプロバイダーの登録名を通じてグレインのコードを変えずに差し替えられることを見てきました。さらに、活性化と非活性化のライフサイクルに沿った書き込み、楽観的並行性制御による整合性の担保、そして write-through と write-behind の使い分けと状態サイズの管理という、信頼性とパフォーマンスを両立させるための判断どころを整理しました。要点は、変更が確定した時点で確実に永続化し、更新頻度と失ってよいデータの範囲から書き込み戦略を選び、状態を適切な粒度と大きさに保つことにあります。

次回は、グレイン間の通信とストリーミングを主題に、リアルタイムなデータ処理やイベント駆動アーキテクチャを Orleans 上で実装する具体的な手法へ踏み込みます。

エンハンスド株式会社では、Orleans を用いたステートフルなサービスの設計・開発を支援しています。永続化の戦略やストレージ選定、障害を見据えた運用設計といった、本番で信頼性が問われる部分を、技術選定の妥当性の検証から実装・運用までご一緒します。分散システムの状態管理を具体的に進めたい方は、お気軽にご相談ください。

この記事をシェア

コピーしました

関連記事