イベント駆動アーキテクチャを2年運用して後悔した設計判断と、やっておけばよかったこと
「疎結合になるはず」が気づけばスキーマ管理地獄に——。KafkaとEventBridgeを本番で2年動かして初めてわかった設計の落とし穴と、あのとき戻れるならこうした、という実践知見をまとめました。
先日、社内の勉強会でイベント駆動アーキテクチャ(EDA)の話をしたんですが、「ちゃんとした設計って何?」という質問が飛んできて、正直うまく答えられなかった。2年間運用してきたのに、言語化できていなかった部分がたくさんあるなと反省して、この記事を書くことにした。
「イベント駆動にすれば疎結合になる」「スケーラビリティが上がる」——教科書にはそう書いてある。でも実際に動かし始めると、イベントの爆発的増加、スキーマの管理地獄、コンシューマーの処理順保証問題など、想定外の壁が連続してくる。うちのチームも最初の半年はかなりしんどかった。
2年間の本番運用で得た「本当に効いた設計判断」と「やっておけばよかった失敗」を、できるだけ具体的に書いていく。
EDAを導入した背景と、最初に踏んだ地雷
2年前、うちのシステムはモノリスだった。月次バッチが夜中に走って、失敗すると翌朝の始業前に全員で対応する——そういう生活を繰り返していた。バッチ処理設計でも3回本番を止めた話は別の記事で書いたけど、あの経験がEDA移行を決断させたきっかけでもある。
最初の設計はこうだった。
flowchart TB
subgraph Before[「最初の設計」モノリス的EDA]
A[注文サービス] -->|OrderCreated| B[Kafka]
B --> C[在庫サービス]
B --> D[メールサービス]
B --> E[分析サービス]
end
一見きれいに見えるんだけど、これには致命的な問題があった。イベントスキーマに誰も責任を持っていない。
OrderCreatedイベントにcustomer_idフィールドを追加したいとき、「とりあえず追加して、コンシューマーは無視すればいい」という議論になる。その結果、半年後には誰も全体像を把握できないスキーマになっていた。分析サービスがcustomer_idを使い始めたのに、注文サービスが内部リファクタリングでフィールド名を変えた。深夜に分析サービスが落ちた。こういう出来事が何度も起きた。
「イベントはデータの契約だ」という感覚を全員が持つまで、半年かかった。
2026年時点のEDA設計で押さえるべきこと
正直まだ検証中の部分もあるけど、今のチームで「これは絶対やる」と決めているポイントを整理する。
CloudEvents 1.1 + Schema Registry
2026年現在、CloudEvents 1.1が業界標準として定着してきた。うちは当初独自フォーマットを使っていたけど、EventBridgeとの連携やサードパーティツールとの統合コストが高すぎて、CloudEventsに移行した。移行コストは正直しんどかったけど、今は本当によかったと思っている。
# CloudEvents 1.1準拠のイベント生成
from cloudevents.http import CloudEvent
from cloudevents.conversion import to_json
import uuid
from datetime import datetime, timezone
def create_order_event(order_id: str, customer_id: str, amount: float) -> bytes:
"""
OrderCreatedイベントをCloudEvents 1.1形式で生成
specversionは自動で1.0になるが、extensionでv1.1機能を使う
"""
event = CloudEvent(
attributes={
"type": "com.example.order.created",
"source": "//order-service/orders",
"id": str(uuid.uuid4()),
"time": datetime.now(timezone.utc).isoformat(),
"datacontenttype": "application/json",
"schemaurl": "https://schemas.example.com/order/created/v2.json",
# CloudEvents 1.1のsequence extension
"sequence": "1",
"sequencetype": "Integer",
},
data={
"order_id": order_id,
"customer_id": customer_id,
"amount": amount,
"currency": "JPY",
}
)
return to_json(event)
# 実際に動かしてみた結果
event_bytes = create_order_event("ord_12345", "cust_67890", 15000.0)
print(event_bytes.decode())
実行結果はこんな感じ。
{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "//order-service/orders",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"time": "2026-06-06T09:00:00.000000+00:00",
"datacontenttype": "application/json",
"schemaurl": "https://schemas.example.com/order/created/v2.json",
"data": {
"order_id": "ord_12345",
"customer_id": "cust_67890",
"amount": 15000.0,
"currency": "JPY"
}
}
Schema RegistryはConfluent Schema RegistryをKafka環境で使っていて、EventBridgeのEvent Bus側はEventBridgeのスキーマレジストリ機能を使っている。ここは好み分かれるかもしれないけど、プロデューサーとコンシューマーで異なるレジストリを使うのは絶対避けた方がいい。うちは半年それをやって地獄だった。
イベントの種類と責務を分けるパターン
EDAに慣れてきたチームが次に踏むのが「なんでもイベントにする」罠だ。うちもある時期、1日に数百種類のイベントが飛び交っていて、何がどこで使われているか誰もわからなくなっていた。個人的にこれが一番しんどかった。
今は以下の3分類を明確にしている。
| イベント種別 | 用途 | スキーマ変更頻度 | 例 |
|---|---|---|---|
| ドメインイベント | ビジネス状態変化の記録 | 低(契約として安定) | OrderCreated, PaymentCompleted |
| 統合イベント | サービス間の通知 | 中(バージョン管理必須) | CustomerNotificationRequested |
| オペレーショナルイベント | 監視・ログ・分析 | 高(変更しやすくていい) | ServiceHealthChanged |
ドメインイベントに関しては、イベントソーシング×CQRSの記事でも触れたけど、「一度発行したら変更できない」という前提で設計することが重要だ。これがわかるまでに1年かかった。
実際のアーキテクチャと運用構成
今のうちのシステム構成を図にするとこうなる。
flowchart TB
subgraph Producers[プロデューサー層]
OS[注文サービス\nFastAPI]
PS[支払サービス\nGo]
IS[在庫サービス\nGo]
end
subgraph Broker[メッセージブローカー層]
direction LR
KF[(Kafka Cluster\nKRaft mode)]
EB[EventBridge\nEvent Bus]
SR[(Schema\nRegistry)]
end
subgraph Consumers[コンシューマー層]
NS[通知サービス]
ANS[分析サービス]
ADS[監査サービス]
SES[検索インデックス\nサービス]
end
subgraph DLQ[デッドレターキュー層]
KDLQ[(Kafka DLQ\nTopics)]
SDLQ[(SQS DLQ)]
ALERT[アラート\n& 再処理UI]
end
OS -->|ドメインイベント| KF
PS -->|ドメインイベント| KF
IS -->|ドメインイベント| KF
OS -->|統合イベント| EB
KF <--> SR
KF --> NS
KF --> ANS
KF --> ADS
EB --> SES
NS -->|失敗時| KDLQ
ANS -->|失敗時| KDLQ
SES -->|失敗時| SDLQ
KDLQ --> ALERT
SDLQ --> ALERT
KafkaとEventBridgeを併用しているのは、用途が違うから。Kafkaはリプレイ可能な高スループット処理が必要なドメインイベント向け、EventBridgeはAWSサービスとの統合や複雑なフィルタリングが必要な統合イベント向けに使い分けている。SQS vs Kafkaの詳しい比較は別記事に書いたので、選定基準が知りたい方はそちらも参考にしてほしい。
コンシューマーの冪等性実装
EDAで絶対に避けられないのが「同じイベントが2回届く」問題。これはKafkaのat-least-once deliveryの仕様上、必ず発生する。地味に便利だったのがOutbox Patternとの組み合わせで、以下のような実装にしている。
# PostgreSQLを使ったIdempotencyキー実装
import asyncpg
from typing import Optional
from datetime import datetime, timezone
class IdempotencyStore:
def __init__(self, conn: asyncpg.Connection):
self.conn = conn
async def is_processed(self, event_id: str) -> bool:
"""イベントが処理済みかチェック"""
result = await self.conn.fetchrow(
"""
SELECT processed_at FROM processed_events
WHERE event_id = $1
AND processed_at > NOW() - INTERVAL '7 days'
""",
event_id
)
return result is not None
async def mark_processed(
self,
event_id: str,
handler_name: str,
result_summary: Optional[str] = None
) -> bool:
"""処理完了をマーク。重複実行時はFalseを返す"""
try:
await self.conn.execute(
"""
INSERT INTO processed_events
(event_id, handler_name, processed_at, result_summary)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id, handler_name) DO NOTHING
""",
event_id,
handler_name,
datetime.now(timezone.utc),
result_summary
)
return True
except Exception:
return False
async def handle_order_created(event: dict, store: IdempotencyStore):
"""OrderCreatedイベントのハンドラ"""
event_id = event["id"]
handler = "inventory_reservation"
# 処理済みチェック
if await store.is_processed(event_id):
print(f"[SKIP] already processed: {event_id}")
return
# ビジネスロジック実行
order_data = event["data"]
await reserve_inventory(order_data["order_id"])
# 処理完了をマーク
await store.mark_processed(
event_id,
handler,
f"reserved for order {order_data['order_id']}"
)
print(f"[OK] processed: {event_id}")
最初はRedisでやっていたんだけど、DBトランザクションと同じコンテキストで冪等性チェックをしたいケースが多くて、PostgreSQLに移した。正直これが正解かは状況によると思う。Redisの方がシンプルに保てるケースもある。
パフォーマンスと信頼性の実績値
2年間の運用データをまとめるとこうなる。
xychart-beta
title "月別イベント処理数とエラー率の推移"
x-axis [2024-07, 2024-10, 2025-01, 2025-04, 2025-07, 2025-10, 2026-01, 2026-04]
y-axis "イベント数(万件/日)" 0 --> 500
bar [12, 28, 45, 89, 142, 198, 287, 412]
line [8.2, 5.1, 3.8, 2.1, 1.4, 0.9, 0.6, 0.4]
※折れ線はエラー率(%)、スケールが異なるので注意
導入当初はエラー率が8%以上あって、深夜対応が週2〜3回あった。DLQ設計をきちんと整備して、スキーマバリデーションを強制し始めてから、1年後には1%を切るようになった。数字で見ると順調に見えるけど、当時は本当にきつかった。
イベント種別ごとの処理保証レベルも整理している。
| イベント種別 | 配信保証 | 順序保証 | 最大レイテンシ | リトライ戦略 |
|---|---|---|---|---|
| ドメインイベント(決済系) | Exactly-Once相当 | Partition内保証 | 500ms | 指数バックオフ×5回 |
| ドメインイベント(在庫系) | At-Least-Once | Partition内保証 | 1s | 指数バックオフ×3回 |
| 統合イベント | At-Least-Once | 保証なし | 5s | EventBridge標準 |
| オペレーショナルイベント | Best-Effort | 不要 | 制限なし | リトライなし |
「Exactly-Once」に括弧をつけたのは、KafkaのExactly-Once Semanticsを使いつつも、コンシューマー側の冪等性実装と組み合わせて初めて実現できるものだから。Kafkaだけでは不十分で、アプリケーション層の実装が必須になる。ここを「Kafkaが保証してくれる」と思っていると痛い目に遭う。
2年間で後悔した設計判断と、今やり直すなら
最後に、本当に後悔していることを正直に書く。
1. イベントの命名規則を最初に決めなかった
OrderCreatedとorder.createdとorder_createdが混在した状態になった時期がある。ドット区切り(com.example.order.created)で統一するのが、CloudEventsとの親和性も高くてよかった。命名規則なんて後でなんとかなると思っていたけど、一度コードベース全体に広がると直すのが本当に大変だった。
2. DLQの「再処理」を後回しにした
最初のうちは「DLQに積まれたら手動でなんとかする」という運用をしていた。これが本当に地獄で、インシデント対応のベストプラクティスでも書かれているように、手動対応は必ずミスが起きる。再処理UIと自動リトライは最初から作るべきだった。
# DLQ再処理の基本的な実装パターン
async def reprocess_dlq_events(
dlq_topic: str,
target_topic: str,
max_age_hours: int = 24,
dry_run: bool = True
) -> dict:
"""
DLQのイベントを再処理キューに戻す
dry_run=Trueの場合は件数確認のみ
"""
from confluent_kafka import Consumer, Producer
import json
consumer = Consumer({
'bootstrap.servers': 'kafka:9092',
'group.id': 'dlq-reprocessor',
'auto.offset.reset': 'earliest',
})
producer = Producer({'bootstrap.servers': 'kafka:9092'})
consumer.subscribe([dlq_topic])
reprocessed = 0
skipped = 0
cutoff = datetime.now(timezone.utc).timestamp() - (max_age_hours * 3600)
while True:
msg = consumer.poll(timeout=1.0)
if msg is None:
break
if msg.error():
continue
event = json.loads(msg.value())
event_time = datetime.fromisoformat(event.get('time', '')).timestamp()
if event_time < cutoff:
skipped += 1
continue
if not dry_run:
producer.produce(target_topic, value=msg.value())
reprocessed += 1
else:
reprocessed += 1 # dry_runは件数カウントのみ
if not dry_run:
producer.flush()
consumer.close()
return {
'reprocessed': reprocessed,
'skipped': skipped,
'dry_run': dry_run
}
3. イベントストーミングを設計フェーズでやらなかった
マイクロサービスで最初の半年が地獄だった話と重なるんだけど、ドメインの境界をちゃんと決める前にEDAを導入するのは本当に危険だった。EventStorming(イベントストーミング)というワークショップ手法を最初からやっておくべきだったと今でも思う。エンジニアだけじゃなく、ビジネス担当者も混ぜて「何がビジネスイベントなのか」を共通認識にするプロセスが絶対に必要だ。
EDA導入を検討しているチームに聞きたいんだけど、「イベントの設計をどのレイヤーの人が決めているか」という問いに即答できますか?ここが曖昧なまま実装を進めると、半年後に必ず地獄を見る。
まとめ
2年間EDAを本番運用してきた知見をまとめると、こういうことになる。
- スキーマ管理が全て:CloudEvents 1.1 + Schema Registryで「誰が何を発行するか」を明示化する。これを後回しにした代償は大きい
- 冪等性はアプリケーション層で担保する:KafkaのEOSだけでは不十分。コンシューマー側に必ず実装する
- DLQと再処理UIは最初から作る:「後でやる」は絶対後悔する。インシデント時に手動対応は破綻する
- イベントの3分類(ドメイン/統合/オペレーショナル)を意識する:なんでもイベントにするとカオスになる
- EventStormingをスキップしない:ドメイン境界が曖昧なままEDAを導入すると、疎結合どころか「見えない密結合」を生む
次のアクションとして、今EDAを使っているチームには「既存のイベントスキーマを棚卸しして、誰が責任者かを明確化する」ことをやってほしい。うちはこれをやっただけで、スキーマ起因の障害が3ヶ月でゼロになった。
まだ正直、完全な正解にたどり着いた感覚はない。イベントの順序保証とスケールアウトを両立する部分は今も試行錯誤中だし、マルチリージョン対応を入れてから新しい課題も出てきている。この辺はまた別の機会に書きたい。