State Management (ステート管理)によるマイクロサービスの状態維持
今まで、以下の流れで作業を行ってきました。今回はDaprに備わっているState Management (ステート管理)による状態維持について触れていきたいと思います。
State Management (ステート管理)について
State Management (ステート管理)は、各サービスにおいて使うことができる状態維持に扱うストアになります。セッションやECでいう、カートの状態などもその様な状態ストアです。Daprでは、コンポーネント機能として提供しており、以下のようなサービスをコンポーネントで設定する事で抽象化を行い、セッションストアとして使うことで、アクセスを容易にします。
今回はローカルで実行していますので、これ何がうれしいの?と思われるかもしれませんが、後日KubernetesにデプロイしてPodで展開している数が二つになったときにState Management (ステート管理)の存在が便利と思えます。つまり、サービスで展開されているPodをスケールアウトした際、ロードバランサで振り分けてくる状況になったとしても、サービスのPodは同じState Management (ステート管理)を参照する事ができます。
- Azure CosmosDB
- Azure SQL Server
- MongoDB
- PostgreSQL
- Redis
- Aerospike
- Azure Blob Storage
- Azure Table Storage
- Cassandra
- Cloudstate
- Couchbase
- etcd
- Google Cloud Firestore
- Hashicorp Consul
- Hazelcast
- Memcached
- Zookeeper
これも使ってみた方が早いので試してみます。
componentを設定する
まず、前回のプロジェクトからtye.yamlを編集します。 # 以下のコメントを外すとコメントをつけていますが、 components-path: "./ components-path: "./components/"/" の行をコメントアウトします。
componentsフォルダも作っておきましょう。
# tye application configuration file
# read all about it at https://github.com/dotnet/tye
#
# when you've given us a try, we'd love to know what you think:
# https://aka.ms/AA7q20u
#
name: dapr
extensions:
- name: dapr
# log-level configures the log level of the dapr sidecar
log-level: debug
# config allows you to pass additional configuration into the dapr sidecar
# config will be interpreted as a named k8s resource when deployed, and will be interpreted as
# a file on disk when running locally at `./components/myconfig.yaml`
#
# config: myconfig
# components-path configures the components path of the dapr sidecar
# 以下のコメントを外す
components-path: "./components/"
# If not using the default Dapr placement service or otherwise using a placement service on a nonstandard port,
# you can configure the Dapr sidecar to use an explicit port.
# placement-port: 6050
services:
- name: service-a
project: ServiceA/ServiceA.csproj
- name: service-b
project: ServiceB/ServiceB.csproj
- name: app
project: App/App.csproj
# This may conflict with the redis instance that dapr manages.
#
# Doing a `docker ps` can show if its already running. If that's the case
# then comment out out when running locally.
# - name: redis
# image: redis
# bindings:
# - port: 6379
State Management (ステート管理)を設定する
先ほど追加したcomponentsフォルダに新しくstatestore.yamlを追加します。以下は状態保存にRedisを用いる場合の一つの例です。このアクセス先のRedisはどこ?って思われるかもしれません、dapr initの際にあらかじめ、この為のRedisがセットアップされています。
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.redis
version: v1
metadata:
- name: redisHost
value: localhost:6379
- name: redisPassword
value: ""
- name: actorStateStore
value: "true"
docker psをしてみると、ローカルの6379でredisが起動しています。
C:\Users\user>docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ba8a9bdb62ab daprio/dapr:1.6.0 "./placement" 18 hours ago Up 18 hours 0.0.0.0:6050->50005/tcp dapr_placement
808b6358b4e7 openzipkin/zipkin "start-zipkin" 5 days ago Up 27 hours (healthy) 9410/tcp, 0.0.0.0:9411->9411/tcp dapr_zipkin
bed085e54a77 redis "docker-entrypoint.s…" 5 days ago Up 27 hours 0.0.0.0:6379->6379/tcp dapr_redis
いったん起動して確認
ここまでできれば、tyeを使って一度起動確認をしましょう。
C:\Users\user\DaprQiita>tye run
Loading Application Details...
Launching Tye Host...
[13:52:20 INF] Executing application from C:\Users\user\DaprQiita\tye.yaml
[13:52:20 INF] Dashboard running on http://127.0.0.1:8000
[13:52:20 INF] Build Watcher: Watching for builds...
[13:52:20 INF] Building projects
[13:52:22 INF] Application dapr started successfully with Pid: 11604
[13:52:22 INF] Launching service service-a-dapr_9f730849-f: C:/dapr/dapr.exe run --app-id service-a --dapr-grpc-port 51097 --dapr-http-port 51098 --metrics-port 51099 --app-port 51091 --components-path ./components/ --log-level debug
[13:52:22 INF] Launching service service-b-dapr_4249ed66-1: C:/dapr/dapr.exe run --app-id service-b --dapr-grpc-port 51100 --dapr-http-port 51101 --metrics-port 51102 --app-port 51093 --components-path ./components/ --log-level debug
[13:52:22 INF] Launching service app-dapr_ba083775-8: C:/dapr/dapr.exe run --app-id app --dapr-grpc-port 51103 --dapr-http-port 51104 --metrics-port 51105 --app-port 51095 --components-path ./components/ --log-level debug
サイドカーのDaprプロセスに --components-path ./components/ ってのが追加されていますね。
[13:52:22 INF] Launching service service-a-dapr_9f730849-f: C:/dapr/dapr.exe run --app-id service-a --dapr-grpc-port 51097 --dapr-http-port 51098 --metrics-port 51099 --app-port 51091 --components-path ./components/ --log-level debug
ステートの保存、読み込み
今回はステート保存用に、新しくサービスを一つ作ってみます。
プロジェクトのルートフォルダで、以下のコマンドを実行しましょう。
dotnet new webapi -o StateService
dotnet sln add StateService
作成後はこのようになっていると思います。
忘れずにtye.yamlにもプロジェクトを追加しておきましょう。
# tye application configuration file
# read all about it at https://github.com/dotnet/tye
#
# when you've given us a try, we'd love to know what you think:
# https://aka.ms/AA7q20u
#
name: dapr
extensions:
- name: dapr
# log-level configures the log level of the dapr sidecar
log-level: debug
# config allows you to pass additional configuration into the dapr sidecar
# config will be interpreted as a named k8s resource when deployed, and will be interpreted as
# a file on disk when running locally at `./components/myconfig.yaml`
#
# config: myconfig
# components-path configures the components path of the dapr sidecar
components-path: "./components/"
# If not using the default Dapr placement service or otherwise using a placement service on a nonstandard port,
# you can configure the Dapr sidecar to use an explicit port.
# placement-port: 6050
services:
- name: service-a
project: ServiceA/ServiceA.csproj
- name: service-b
project: ServiceB/ServiceB.csproj
- name: app
project: App/App.csproj
- name: stateservice
project: StateService/StateService.csproj
# This may conflict with the redis instance that dapr manages.
#
# Doing a `docker ps` can show if its already running. If that's the case
# then comment out out when running locally.
# - name: redis
# image: redis
# bindings:
# - port: 6379
プロジェクトの編集
まず、プロジェクトにDaprのクライアントライブラリを前回同様追加しましょう。今回は、Visual Studio 2022からGUIで追加しました。
追加ができたら、プロジェクトのProgram.csを以下を参考に書き換えてみましょう。
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDaprClient(); // この行追加
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// 以下のリダイレクションはコメントアウト(backgroundでは、httpのみで通信)
//app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
次に、WeatherForecastController.csを編集します。
こんな感じにしました、雑に書いていますので、例外処理とか書いてないです。使うときにはきちんと書いてくださいね。
中身はシンプルです、GETで呼び出されたら定数で指定されたキーで指定されたステートストア領域から読み出します。
POSTの場合は、同じくステートストア領域に指定したキーで保存しています。
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
namespace StateService.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
private readonly DaprClient _daprClient;
const string storeName = "statestore";
const string key = "LastWeatherForecast";
public WeatherForecastController(ILogger<WeatherForecastController> logger, DaprClient daprClient)
{
_logger = logger;
_daprClient = daprClient;
}
[HttpGet(Name = "GetWeatherForecast")]
public async Task<WeatherForecast> GetAsync()
{
return await _daprClient.GetStateAsync<WeatherForecast>(storeName, key);
}
[HttpPost(Name = "PostWeatherForecast")]
public async Task PostAsync(WeatherForecast weatherForecast)
{
await _daprClient.SaveStateAsync(storeName, key, weatherForecast);
return;
}
}
確認
ここまで来たら、tye run で起動して、このサービスだけで動作するか確認してみましょう。
デバッグについては、前回の記事を参考にプロセスをアタッチしてみてください。
以下のように、サービスが起動しています。今回はtyeによってstateserviceのプロセスは http://localhost:64377 が割り当てられています。
今回割り当てられた http://localhost:64377/swagger でSwaggerにアクセスしてみます。
まずは、POSTして確認します。温度は適当に100度にしました。
レスポンスは200ですので、正常に動いていそうです。
Service Invocation と Statestoreのコントロール
さて、ここで気になるのは各サービスのステートはDaprを使って共有されるのか?もしくは、サービスが異なれば別のステートとして扱ってくれるのか?という点です。
そこで、次に以下のような事を試してみたいと思います。
- フロントのアプリAppでは、App側のDaprを使って直接Statestoreに保存します。
- フロントのアプリAppでは、App側のDaprを使って直接Statestoreから読み出します。 (1で保存された内容と同じであるはずです)
- 次にService invocationを使って、StatestoreサービスからGETで読み出してみます。前述までの所で、Appで書き込んだ値を返却してくるはずです。
まず、AppのWeatherForecastController.csを以下のように書き換えてみました。
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
namespace App.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
private readonly DaprClient _daprClient;
const string storeName = "statestore";
const string key = "LastWeatherForecast";
public WeatherForecastController(ILogger<WeatherForecastController> logger, DaprClient daprClient)
{
_logger = logger;
_daprClient = daprClient;
}
[HttpGet(Name = "GetWeatherForecast")]
public async Task<IEnumerable<WeatherForecast>> GetAsync()
{
WeatherForecast forcast = new WeatherForecast
{
TemperatureC = 1000,
Summary = "from App",
Date = DateTime.Now
};
// Appからステートを保存
await _daprClient.SaveStateAsync(storeName, key, forcast);
// 保存したステートを読み出し
WeatherForecast forcastFromApp = await _daprClient.GetStateAsync<WeatherForecast>(storeName, key);
// サービス間起動でバックエンドに存在するStateserviceからも読み出し
WeatherForecast forcastFromService = await _daprClient.InvokeMethodAsync<WeatherForecast>(HttpMethod.Get, "stateservice", "weatherforecast");
// それぞれの結果をListに格納して返却
List<WeatherForecast> weatherForecasts = new List<WeatherForecast>();
weatherForecasts.Add(forcastFromApp);
weatherForecasts.Add(forcastFromService);
return weatherForecasts;
}
}
tyeで起動したら、まずはStateservice側のSwaggerからGETでステートの値を読み出してみます。
{
"date": "2022-03-03T08:54:48.258Z",
"temperatureC": 100,
"temperatureF": 211,
"summary": "string"
}
次にApp側のSwaggerからGETしてみます。
App側のコードでは、意図的に "temperatureC": 1000にしています。
以下のように返却されました。
1つ目はAppの中で記録し、読みだした結果です。2つ目はService呼び出しを使って呼び出した結果です。
この結果から、記録したステートは各サービス単位で管理される事がわかります。
[
{
"date": "2022-03-03T18:26:21.0188215+09:00",
"temperatureC": 1000,
"temperatureF": 1831,
"summary": "from App"
},
{
"date": "2022-03-03T08:54:48.258Z",
"temperatureC": 100,
"temperatureF": 211,
"summary": "string"
}
]
ステート状態を各サービス間で共有したい
先ほどの実験では、各サービスが保存したステートは各アプリ個別に保持されている事がわかりました。これはどうしてでしょうか?実際にRedisの中を覗いてみます。(VSCodeでDatabase Clientという拡張を使っています。)
なるほど、キーを見てみると一目瞭然ですね。サービス名||キー名という形で保持されています。
実は、これはcomponentの設定がデフォルトの状態のままで、初期値であるステートは各サービス単位で保持するというセッティングなっているからです。
詳しくは以下に譲りますが。ステートストアで設定を行う際に、キー管理戦略を変更する事で対応ができます。
以下の行をstatestore.yamlに追加してください。
- name: keyPrefix
value: "name"
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.redis
version: v1
metadata:
- name: redisHost
value: localhost:6379
- name: redisPassword
value: ""
- name: actorStateStore
value: "true"
- name: keyPrefix
value: "name"
確認、tye runで実行してみましょう。
先ほどと同じように、App側のSwaggerからGETしてみます。
App側のコードでは、意図的に "temperatureC": 1000にしていました。
以下のように返却されました。
[
{
"date": "2022-03-07T16:56:17.3166189+09:00",
"temperatureC": 1000,
"temperatureF": 1831,
"summary": "from App"
},
{
"date": "2022-03-07T16:56:17.3166189+09:00",
"temperatureC": 1000,
"temperatureF": 1831,
"summary": "from App"
}
]
同じデータが返却されています。これはApp側でステートを保存し、stateserviceに同じデータが共有され、それが返却された為です。
つまり、二つのサービスの間で同じステートが参照できるようになっている事がわかります。
キー管理戦略がNameになった結果、サービス名||キー名だったのが、メタデータの名前||キー名になっている事がわかると思います。
キー戦略を使い分けよう
サービスの中でしか共有できないキー、サービス間で共有できるキー、これはどちらも使えそうです。
どちらも設定してみます。componentsフォルダの下に新しく sharestore.yaml を追加して、statestore.yaml 微調整します。
どちらも設定
まず、statestore.yamlです。こちらはサービス個別にステートを保持するようにしました。
つまり、影響範囲は同じサービスの中のみで共有されます。例えば、PODでのバッチ処理実行の進行管理とか、サービスの中だけで共有するべきデータなどの場合は便利そうです。
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.redis
version: v1
metadata:
- name: redisHost
value: localhost:6379
- name: redisPassword
value: ""
次に、sharestore.yamlです。こちらはサービス間での共有ステートを保持するようにしました。先ほどのkeyPrefixを追加し、そして
つまり、影響範囲は同じサービスの中のみで共有されます。例えば、PODでのバッチ処理実行の進行管理とか、サービスの中だけで共有するべきデータなどの場合は便利そうです。
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: sharestore
spec:
type: state.redis
version: v1
metadata:
- name: redisHost
value: localhost:6379
- name: redisPassword
value: ""
- name: actorStateStore
value: "true"
- name: keyPrefix
value: "name"
注意点として、 actorStateStore はどちらか一つにしか設定ができません。これが何故なのか?については、いずれActorサービスモデルについて触れる際にでもトピックにできればと思います。今は、そのまま設定しておいてください。
コードの修正
Appプロジェクトのコードを以下のように書き換えてみました。
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
namespace App.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
private readonly DaprClient _daprClient;
const string shareStore = "sharestore";
const string storeName = "statestore";
const string key = "LastWeatherForecast";
public WeatherForecastController(ILogger<WeatherForecastController> logger, DaprClient daprClient)
{
_logger = logger;
_daprClient = daprClient;
}
[HttpGet(Name = "GetWeatherForecast")]
public async Task<IEnumerable<WeatherForecast>> GetAsync()
{
WeatherForecast forcast = new WeatherForecast
{
TemperatureC = 1000,
Summary = "from App",
Date = DateTime.Now
};
// Sharestoreに保存(これはサービスをまたいで保持参照可能)
// ここでは、仮に全体で識別できるユーザーが以下として、データを保持
var username = "410e0136-f7d5-437f-a844-e40c0ce40e00";
await _daprClient.SaveStateAsync(shareStore, username, "from_users_data");
// Appからステートを保存(これは、Appサービス範囲内でのみ保持)
await _daprClient.SaveStateAsync(storeName, key, forcast);
// Appからステートを読み取り(これは、Appサービス範囲内でのみ保持)
WeatherForecast forcastFromApp = await _daprClient.GetStateAsync<WeatherForecast>(storeName, key);
// サービス間起動でバックエンドに存在するStateserviceからも読み出し
// つまり、Stateservice側でのみ、保持されている値をService Invocation経由で読み出している
WeatherForecast forcastFromService = await _daprClient.InvokeMethodAsync<WeatherForecast>(HttpMethod.Get, "stateservice", "weatherforecast");
// それぞれの結果をListに格納して返却
List<WeatherForecast> weatherForecasts = new List<WeatherForecast>();
weatherForecasts.Add(forcastFromApp);
weatherForecasts.Add(forcastFromService);
return weatherForecasts;
}
}
試してみる
以下の想定のように動いてくれるか確認しましょう。そろそろ、慣れてきたと思いますが、 tye run で実行しましょう。
まず、Statestore側のSwaggerから以下のようにPOSTしてみます。ここで記録される値はStatestoreを通じてのみ取得ができるはずです。
{
"date": "2022-03-07T08:34:29.650Z",
"temperatureC": 2000,
"summary": "from state store"
}
GETしても、今回記録した値が読み出せます。
Redisにもstateservice||キー名で登録されているのがわかります。
次にApp側です。App側のSwaggerからGETしてみます。
App側では、以下のように帰ってくる事が期待動作になります。
- フロントのアプリAppでは、App側のDaprを使って直接Statestoreに保存します。温度はわかりやすいように1000にしました。
{
"date": "2022-03-07T08:34:29.650Z",
"temperatureC": 1000,
"summary": "from app"
}
- フロントのアプリAppでは、App側のDaprを使って直接Statestoreから読み出します。 1で保存された内容と同じであるはずです。
3.sharestoreに仮のユーザーIDで保存しました。Redis側にはsharestore||キー名で保存されているはずです。
- 次にService invocationを使って、StatestoreサービスからGETで読み出してみます。これは、先ほどStateservice側で記録した以下の値が返却されるはずです。
{
"date": "2022-03-07T08:34:29.650Z",
"temperatureC": 2000,
"summary": "from state store"
}
[
{
"date": "2022-03-07T17:45:16.8532645+09:00",
"temperatureC": 1000,
"temperatureF": 1831,
"summary": "from App"
},
{
"date": "2022-03-07T08:34:29.65Z",
"temperatureC": 2000,
"temperatureF": 3631,
"summary": "from state store"
}
]
Redisの中も見てみます。それぞれ、サービス間共有用のステートストアとサービス個別のみで使えるステートストアのどちらとも動作しているようです。
非常に便利ですね、これならサービス間でデータ保持する為に、Service InvokeでPOSTとか、不要になるんじゃないかって思えちゃいます。もちろん、このあたりは設計次第だと思いますし、目的やサービスドメインの分離とか、思想も考えもあると思うので、いずれ書きたくなったら書く日が来るかもしれません。