アクターモデルによる分散システム設計 - Proto.Actor(Go)
はじめに
Social Databank Advent Calendar 2025 の20日目です。
先日開催された Architecture Conference 2025 で、加藤潤一さんによる「メッセージ駆動が可能にする結合の最適化」というセッションを聴講しました。セッションの中で登場した位置透過性という概念がとても印象に残りました。
また、弊社では現在、ボトルネックになりがちな Aurora MySQL から分散型 NewSQL である TiDB への移行 PoC を進めています。この取り組みからも分散システムへの個人的関心が高まっており、アプリケーション層においても分散システム設計を検討できないかなと夢見ています。
本記事では、位置透過性がもたらす進化可能性と、弊社のメッセージ駆動システムの課題をどのように解決するのかを、Proto.Actor(Go) の例を交えて考えます。
位置透過性とは: 進化可能なアーキテクチャの鍵
位置透過性とは、コンポーネントの物理的な位置を意識せずに設計できることです。
コンポーネントがローカルにあってもリモートにあっても、同じコードで動作します:
// 位置透過性の例
// どちらも同じSendメソッドで送信可能
ctx.Send(localPID, message) // ローカル配送
ctx.Send(remotePID, message) // リモート配送(gRPC経由)
なぜ重要か:
- 同じコードで単一プロセス〜分散システムまで対応
- アーキテクチャを進化させてもコード変更不要
- 配置戦略を後から柔軟に変更できる
従来のアプローチの問題
位置透過性がないと、コンポーネントの配置を変更するたびにコードを書き換える必要があります:
// ローカル呼び出し
executor.Submit(func() { postMessage(job) })
// リモート呼び出し(別のコードが必要!)
client.Post("http://remote/api/messages", body)
// Redis経由のメッセージング(別のコードが必要!)
redis.RPUSH("message_queue:"+time, json)
redis.ZADD("timing_set", time, time)
redis.ZRANGE("timing_set", 0, 1) // polling
問題点: 配置戦略を変更するたびに大規模な書き換えが発生し、アーキテクチャの進化が困難になります。
(※ 弊社のシステムでは、時刻指定のタスクスケジューラに2段階キュー、排他制御、リトライ機構、順序制御...などワーカー間のデータ媒介のために複数のデータ構造を組み合わせ、コードの複雑化と保守性の低下につながっています)
弊社の現状: Redis キューベースのメッセージ駆動システムとその課題
弊社は LINE 公式アカウントの配信・運用・管理をサポートするサービス Liny を提供しています。主な技術スタックは PHP(Laravel)のモノレポ構成で、メッセージ配信の仕組みは以下のようになっています:
このアーキテクチャには、大きく分けて 3 つの課題があります。
課題 1: Redis への多重依存と SPOF
Redis が以下の複数の役割を担っています:
- データ媒介: ワーカー間のメッセージ伝達
- 排他制御: 分散ロックの実装
- 状態管理: 処理状態の保持
これにより Redis が単一障害点(SPOF)となるだけでなく、Redis キーの管理がコード全体に散在し、多様なデータ型により認知負荷が高くなっています。
課題 2: 位置透過性の欠如によるリソース非効率
弊社のシステムは位置透過性がないため、以下の問題を抱えています:
プロセス単位でしかスケールできない
- PHP の制約により、並行処理はプロセスベース
- 100 以上の ECS サービスで数百タスク(各 4vCPU/8GB)が稼働
- 各タスク 8GB 消費するが、CPU 使用率は低くメモリがボトルネック
ワーカー間の通信が常に Redis 経由
- ワーカー間の直接通信する仕組みがない
- 全ての通信が Redis キュー経由でのみ可能
- ネットワーク I/O とシリアライズのオーバーヘッドが常に発生
配置を最適化できない
- 関連するワークロードを同じプロセス内に配置できない
- 通信頻度の高いワーカー同士を近くに配置してもメリットがない(Redis 経由は変わらない)
- デプロイメント構成を変更しても、システムの効率は改善しない
課題 3: 可観測性の低さ
- メッセージの流れを追跡するのが困難
- エラーハンドリングが各所に散在
- どのワーカーがどのメッセージを処理しているか把握しづらい
リソース効率の対比: 現状 vs アクターモデル
現状: PHP ワーカー(プロセス単位のスケーリング)
各 ECS コンテナは 8GB メモリを消費しますが、CPU 使用率は低い状態です。
問題点:
- PHP プロセスはメモリを多く消費するが、CPU は余りがち
- ワーカー間の通信は必ず Redis を経由
- プロセスベースのスケーリングのため、リソース効率に限界がある
アクターモデル: 位置透過性による柔軟なアクター間通信
各ノードは Go プロセスとして動作し、メモリ効率的で CPU を有効活用できます。
改善点(位置透過性の効果):
- 同じコード(ctx.Send)でローカルもリモートでも送信可能
- アクター A からアクター B(同じノード)へ → ローカル配送(高速)
- アクター A からアクター E(別ノード)へ → gRPC 経由でリモート配送
- 1 つのノード(Go プロセス)で複数のアクターが動作可能
- 軽量な goroutine ベースでメモリ効率を向上
- CPU とメモリを効率的に活用できる
- アクターの配置を変更してもコードは変わらない(進化可能性)
Proto.Actor(Go) による位置透過性の実現
位置透過性を理解するために、protoactor-go によるコード例を見ていきます。
ローカル配送の例
// 送信側アクター
type senderActor struct {
receiverPID *actor.PID // ローカルアクターへの参照
}
func (a *senderActor) Receive(ctx actor.Context) {
switch ctx.Message().(type) {
case *actor.Started:
// ローカルアクターにメッセージ送信
ctx.Request(a.receiverPID, &messages.Ping{})
case *messages.Pong:
log.Println("Received pong")
}
}
// 受信側アクター
type receiverActor struct{}
func (*receiverActor) Receive(ctx actor.Context) {
switch ctx.Message().(type) {
case *messages.Ping:
fmt.Println("Received ping")
ctx.Respond(&messages.Pong{})
}
}
func main() {
system := actor.NewActorSystem()
// 受信側アクターを作成(ローカル)
receiverProps := actor.PropsFromProducer(func() actor.Actor { return &receiverActor{} })
receiverPID := system.Root.Spawn(receiverProps)
// 送信側アクターを作成
senderActor := &senderActor{receiverPID: receiverPID}
senderProps := actor.PropsFromProducer(func() actor.Actor { return senderActor })
system.Root.Spawn(senderProps)
}
配送の仕組み:
リモート配送の例
アクター内部のコードは全く同じまま、ネットワーク越しでも動作します:
Node 1(送信側):
// 送信側アクター(ローカル例と全く同じコード!)
type senderActor struct {
receiverPID *actor.PID // リモートアクターへの参照
}
func (a *senderActor) Receive(ctx actor.Context) {
switch ctx.Message().(type) {
case *actor.Started:
// リモートアクターにメッセージ送信(コードは完全に同じ)
ctx.Request(a.receiverPID, &messages.Ping{})
case *messages.Pong:
log.Println("Received pong from remote")
}
}
func main() {
system := actor.NewActorSystem()
cfg := remote.Configure("0.0.0.0", 8081)
r := remote.NewRemote(system, cfg)
r.Start()
// リモートアクターへの参照を作成(唯一の違い)
receiverPID := actor.NewPID("127.0.0.1:8080", "receiver")
// 送信側アクターを作成(ローカルと同じ)
senderActor := &senderActor{receiverPID: receiverPID}
senderProps := actor.PropsFromProducer(func() actor.Actor { return senderActor })
system.Root.Spawn(senderProps)
console.ReadLine()
}
Node 2(受信側):
// 受信側アクター(ローカル例と全く同じコード!)
type receiverActor struct{}
func (*receiverActor) Receive(ctx actor.Context) {
switch ctx.Message().(type) {
case *messages.Ping:
fmt.Println("Received ping from remote")
ctx.Respond(&messages.Pong{})
}
}
func main() {
system := actor.NewActorSystem()
cfg := remote.Configure("0.0.0.0", 8080)
r := remote.NewRemote(system, cfg)
r.Start()
// 受信側アクターを作成(ローカルと同じ)
receiverProps := actor.PropsFromProducer(func() actor.Actor { return &receiverActor{} })
system.Root.SpawnNamed(receiverProps, "receiver")
console.ReadLine()
}
配送の仕組み:
分散システムを支える技術要素
位置透過性を実現するための具体的な技術要素を見ていきます。アクターモデルで位置透過性を実現するには、ローカルとリモートで統一されたメッセージ表現が必要です。ここで Protocol Buffer、gRPC、そして Grain が重要な役割を果たします。
なぜ Protocol Buffer か: 言語中立的なメッセージ定義
1. 言語中立的なインターフェース定義
// cluster-basic/shared/protos.proto
syntax = "proto3";
package shared;
message HelloRequest {
string name = 1;
}
message HelloResponse {
string message = 1;
}
このスキーマから、Go/Java/C#/Rust など、多言語のコードを自動生成できます。
2. コンパクトで高速
- バイナリフォーマットでサイズが小さい
- パース・シリアライズが高速
- ネットワーク帯域の節約
3. スキーマ進化のサポート
- フィールド番号による後方互換性
- デプロイの順序を気にせず新フィールドを追加可能
gRPC の役割: リモート配送の実装基盤
Proto.Actor は gRPC ストリームを使ってリモートメッセージングを実現しています。これにより、アクターの位置がローカルかリモートかに関わらず、同じ API でメッセージを送信できます。
位置透過性への貢献:
- 双方向ストリーミング: 効率的な通信で、ローカルに近いパフォーマンス
- HTTP/2 ベース: 多重化、ヘッダー圧縮により、リモート通信のオーバーヘッドを最小化
- 負荷分散: 標準的なロードバランサーと統合可能で、スケールアウトが容易
- 言語中立性: Proto.Actor 以外のシステム(例: PHP/Laravel)からも gRPC-PHP で通信可能
Grain によるクラスタリング: Virtual Actors
分散システムを本格的に構築するには、クラスタリングが必要です。Proto.Actor の Grain 機能は、このクラスタリングを実現するための仕組みです。
クラスタリングの基本
クラスタ環境では、アクターの物理的な位置をさらに意識しなくて済むようになります。
// cluster-basic/node1/main.go
func main() {
c := startNode()
// アクターを論理的なIDで参照(どのノードにいるかは気にしない)
pid := c.Get("abc", "hello")
// クラスタが自動的に適切なノードにルーティング
res, _ := c.Request("abc", "hello", &shared.HelloRequest{Name: "Roger"})
fmt.Printf("Got response %v", res)
}
func startNode() *cluster.Cluster {
system := actor.NewActorSystem()
// Consulでサービスディスカバリ(ノードの位置を動的に発見)
provider, _ := consul.New()
lookup := disthash.New()
config := remote.Configure("localhost", 0)
clusterConfig := cluster.Configure("my-cluster", provider, lookup, config)
c := cluster.New(system, clusterConfig)
c.StartMember()
return c
}
位置透過性の実現要素:
- サービスディスカバリ: Consul/etcd/Zookeeper でノードを動的に発見、配置場所を意識不要
- Identity-based lookup: アクターを論理的な ID で参照(物理的な PID ではない)
- 自動ルーティング: クラスタがメッセージを適切なノードに自動配送
Virtual Actors (Grains) の仕組み
Grain 機能は、以下の課題を解決することで、開発者がアクターの位置を完全に意識しなくて済むようにします。
課題 1: アクターのライフサイクル管理
- 通常のアクターは明示的に
Spawn()で起動し、Stop()で停止する必要がある - 大量のアクター(例: 100 万ユーザー)を事前に全て起動するのは非効率
課題 2: アクターの位置管理
- 分散環境で「このアクターはどのノードにいるか?」を追跡するのが複雑
- 手動でルーティングテーブルを管理すると保守が困難
Grain による解決:
- 自動アクティベーション: メッセージ送信時、アクターが存在しなければクラスタが自動的に起動
- 自動デアクティベーション: 一定期間使われないアクターは自動的にメモリから解放
- 位置透過性の強化: Identity(論理 ID)でアクターを参照、クラスタが位置を自動解決
- シングルインスタンス保証: 同じ Identity のアクターは、クラスタ全体で 1 つだけ存在
これにより、数百万のユーザーアクターを扱う場合でも、実際にメモリ上に存在するのはアクティブなユーザーのみとなり、効率的にリソースを活用できます
アクターモデルによる課題解決のまとめ
アクターモデルにより、弊社の現行システムの課題がどのように解決されるかをまとめます。
課題 1 の解決: Redis への多重依存 → アクター内部で状態管理
アクターモデルでは:
type messageComposerActor struct {
messages []*Message // アクター内部で状態を保持
}
func (a *messageComposerActor) Receive(ctx actor.Context) {
switch msg := ctx.Message().(type) {
case *ComposeMessage:
composed := a.compose(msg)
a.messages = append(a.messages, composed)
// 次のアクターへ直接メッセージ送信(Redisを介さない)
ctx.Send(linePosterRef, &PostToLine{Message: composed})
}
}
改善点:
- Redis への依存を削減(データ媒介が不要)
- アクターごとの独立した状態管理
- 単一アクターインスタンスへのメッセージは順序保証される(排他制御不要)
課題 2 の解決: スケーラビリティとリソース効率 → 位置透過性による柔軟な配置
位置透過性により:
- クラスタ内でアクターを動的に配置, コード変更不要
- ノードを追加/削除してもアプリケーションコードは不変
- サービスディスカバリで自動的にノードを発見
- 1 つのノードで複数アクターが動作し, リソース効率の向上
- ワークロード特性に応じてアクターを分離可能
弊社システムでの段階的な進化:
位置透過性により、弊社のメッセージ配信システムはコード変更なしで段階的に進化できます。
開発・検証環境(単一ノード):
本番環境(複数ノード):
アクター間の通信コード(ctx.Send())は全く変わりません。配置場所を変更するだけで、Grain の仕組みによってシステムは自動的にローカル/リモート配送を選択します。
課題 3 の解決: 可観測性の低さ → 構造化されたメッセージフロー
アクターモデルでは:
- アクター間のメッセージフローが明示的
- OpenTelemetry などで分散トレーシングが容易
- 各アクターのメトリクスを取得可能
- メッセージの流れが追跡しやすい
アクターシステムの設計指針:ノード配置の決定基準
位置透過性により「どこにアクターを配置するか」の選択が柔軟になりました。では実際に、どのようにノードへ配置すべきでしょうか?本セクションでは、ノード配置を決定するための基準を整理します。
1. ドメイン集約(DDD アプローチ)
集約ルート = アクターの原則:
// メッセージ生成集約
type ComposerActor struct {
jobID string
userIDs []string
status JobStatus
}
// メッセージ配信集約
type PosterActor struct {
userID string
messageID string
retries int
}
// 結果確認集約
type CheckerActor struct {
jobID string
successCount int
failureCount int
}
分類の基準:
- トランザクション境界: 集約内は強整合性、集約間では結果整合性
- 不変条件の保護: 集約ごとにビジネスルールを強制
- 変更頻度: 集約ごとに独立してデプロイ可能
ノード配置への応用:
初期段階では、全ての集約を単一ノード内に配置し、ローカル通信の高速性を活用します。システムが成長すると、各集約の特性(スケーリング要件、変更頻度など)に応じてノードを分離することで、各集約を独立して運用できます。
2. ワークロード特性
CPU 集約型 vs I/O 集約型の分離:
CPU ノード: CPU 集約型アクター
// CPU集約型: 大量の計算処理
type MessageComposerActor struct{}
func (a *MessageComposerActor) Receive(ctx actor.Context) {
switch msg := ctx.Message().(type) {
case *ComposeRequest:
// テンプレートエンジン実行, 変数展開, パーソナライゼーション
// → CPU使用率が高い
composed := a.renderTemplate(msg.Template, msg.Variables)
ctx.Send(posterRef, &PostMessage{Content: composed})
}
}
I/O ノード: I/O 集約型アクター
// I/O集約型: 外部API呼び出し
type LinePosterActor struct{}
func (a *LinePosterActor) Receive(ctx actor.Context) {
switch msg := ctx.Message().(type) {
case *PostMessage:
// LINE Messaging API呼び出し
// → I/O待ちが大半, CPU使用率は低い
result := a.callLineAPI(msg.Content)
ctx.Send(checkerRef, &CheckResult{Result: result})
}
}
ノード配置の例:
CPU ノードにはコンピュート最適化インスタンス、I/O ノードにはネットワーク最適化インスタンスを使用します。
メリット:
- インスタンスタイプの最適化: ワークロードに応じたインスタンスタイプの選択
- コスト削減: ワークロードに応じた適切なリソース配分
- スケーリングの柔軟性: CPU 集約型と I/O 集約型を独立してスケール
3. スケーリング要件
スケールパターンの違いに着目:
// 低頻度・高コスト
type ReportGeneratorActor struct{} // 月次レポート生成: 月1回実行
// 高頻度・低コスト
type MessagePosterActor struct{} // メッセージ配信: 秒間数千リクエスト
ノード配置の考え方:
| アクター種別 | 実行頻度 | スケーリング戦略 | ノード配置 |
|---|---|---|---|
| Composer | 高 | 水平スケーリング | 専用ノード群 |
| Poster | 超高 | 大規模水平スケーリング | 専用ノード群(多数) |
| Checker | 中 | 中程度スケーリング | Composer と同居可 |
実装例:
func main() {
nodeType := os.Getenv("NODE_TYPE")
var kinds []*cluster.Kind
switch nodeType {
case "composer":
kinds = []*cluster.Kind{cluster.NewKind("composer", composerProps)}
case "poster":
kinds = []*cluster.Kind{cluster.NewKind("poster", posterProps)}
default:
log.Fatal("NODE_TYPE must be set to 'composer' or 'poster'")
}
startNode(kinds)
}
func startNode(kinds []*cluster.Kind) {
system := actor.NewActorSystem()
provider, _ := consul.New()
lookup := disthash.New()
config := remote.Configure("localhost", 0)
clusterConfig := cluster.Configure("msg-cluster", provider, lookup, config,
cluster.WithKinds(kinds...))
c := cluster.New(system, clusterConfig)
c.StartMember()
}
大規模構成の例:
大規模システムでは、各アクター種別を独立したノード群に配置します。この構成により、各アクターの特性に応じた水平スケーリングが可能になり、高可用性を実現できます。
4. データアフィニティ
同じデータにアクセスするアクターは同居させる:
// ユーザーアクター: ユーザー情報をキャッシュ
type UserActor struct {
userID string
profile *UserProfile // キャッシュ
}
// ユーザー通知設定アクター: 通知設定をキャッシュ
type UserNotificationSettingsActor struct {
userID string
settings *NotificationSettings // キャッシュ
}
// 同じuserIDを扱うため, 同一ノードに配置すると効率的
// → キャッシュの重複を避け, データベースクエリを削減
配置戦略:
メリット:
- キャッシュヒット率の向上: 関連データが同一ノードにある
- ネットワーク通信の削減: ローカルメッセージパッシング
- トランザクション境界の最適化: 関連する状態変更が同一ノードで完結
5. 障害ドメインの分離
このアクターが停止したら、他のアクターも道連れにしたくないか?
// 例: 課金処理は重要度が高いので分離
type BillingActor struct{} // 専用ノードで実行、他の障害の影響を受けない
// 例: 分析・レポート処理は分離
type AnalyticsActor struct{} // 重い処理でメインシステムに影響を与えない
判断基準:
- 🔴 クリティカル度が異なる → 分離すべき
- 🟢 同程度のクリティカル度 → 同居可能
6. スケーリングパターン
このアクター群は同じペースでスケールするか?
// 例: メッセージ配信システム
// - Composer: 1日1回バッチ実行(スケール不要)
// - Poster: 秒間数千メッセージ(大規模スケール必要)
// → 別ノードに分離してPosterだけスケール
判断基準:
- 🔴 スケールパターンが大きく異なる → 分離すべき
- 🟢 同じペースでスケール → 同居可能
7. デプロイ頻度
このアクターは頻繁にデプロイするか?
// 例: A/Bテスト用のメッセージ生成ロジック
type ExperimentalComposerActor struct{} // 頻繁に変更される
// 例: 安定したLINE API呼び出しロジック
type LinePosterActor struct{} // ほとんど変更されない
// → 分離することで, Composerのデプロイ時にPosterを再起動しない
判断基準:
- 🔴 デプロイ頻度が大きく異なる → 分離すべき
- 🟢 同じ頻度でデプロイ → 同居可能
8. チーム境界(Conway's Law)
このアクターは異なるチームが担当するか?
// 例: マーケティングチーム担当
type CampaignComposerActor struct{}
// 例: プラットフォームチーム担当
type InfraMonitoringActor struct{}
// → チーム境界に合わせてノードを分離
判断基準:
- 🔴 異なるチームが担当 → 分離を検討
- 🟢 同じチームが担当 → 同居可能
設計判断のチェックリスト
実際にノード境界を決める際のチェックリスト:
| 判断項目 | 同一ノード | 別ノード |
|---|---|---|
| 障害の影響範囲 | 同じでよい | 分離したい |
| スケーリングパターン | 同じペース | 異なるペース |
| ワークロード特性 | 似ている(CPU/IO) | 大きく異なる |
| デプロイ頻度 | 同じ頻度 | 異なる頻度 |
| データアフィニティ | 同じデータ | 異なるデータ |
| チーム担当 | 同じチーム | 異なるチーム |
| クリティカル度 | 同程度 | 大きく異なる |
| リソース要件 | 同じインスタンス | 異なるインスタンス |
設計の原則: 最初は単一ノードから始め、計測結果に基づいて段階的に分離します。位置透過性により、コード変更なしでアーキテクチャを進化させることができます。
分散システムのデメリットと導入の課題
一方で、分散システムには明確なトレードオフがあります。
デメリット 1: 学習コストの高さ
新たに学ぶ必要がある概念:
- サービスディスカバリ: Consul/etcd/Zookeeper の運用
- 分散トレーシング: OpenTelemetry、Jaeger など
- 障害モデル: ネットワーク分断、ノード障害、メッセージロスト
- 結果整合性: 強整合性から結果整合性への思考転換
弊社のような PHP 中心の組織では、Go のアクターモデルへ慣れるまで時間がかかります。
デメリット 2: 運用複雑性の増加
従来意識しなくてよかったもの:
- サービスディスカバリサーバー: Consul クラスタの管理
- ネットワークレイテンシ: プロセス間通信の遅延
- デプロイメントオーケストレーション: 複数ノードの調整
- 監視・アラート: より細かい粒度でのメトリクス収集
デメリット 3: 初期開発コスト
- モノリスならすぐに実装できる機能も、分散システムでは設計に時間がかかる
- メッセージスキーマ(Protocol Buffers)の定義と管理
- アクター設計(どの粒度で分けるか)の検討
おわりに
アクターモデルによる分散システム設計により、現在のシステムの課題を解決できる可能性を感じました。
学習コストや運用複雑性といったトレードオフもありますが、よりスケーラブルで柔軟に変更できるアーキテクチャを目指して模索中です。
ソーシャルデータバンクでは一緒に働く仲間を募集中です! 分散システムやアクターモデルに興味がある方! ご連絡お待ちしています!
参考リンク
- Proto.Actor Go - Go 向けアクターシステム
Discussion