イベント駆動アーキテクチャ — リアクティブなシステム
「メール送信が失敗しても注文を止めたくない」
ある夜、カオリにアラートが届いた。メール送信サービス(SendGrid)が5分間ダウンした。その間に来た注文は全て失敗し、エラーレートが急上昇した。
根本原因を調べると、注文確定のトランザクション内でメール送信をしていたことが分かった。
# 問題のあるコード
ActiveRecord::Base.transaction do
order.save!
inventory.reserve!(order.quantity)
# ここが失敗すると、注文全体がロールバックされる
OrderMailer.confirmation(order).deliver_now
end「メール送信は注文確定の必須処理じゃない。送信が遅れても、後で送れればいい」
これがイベント駆動アーキテクチャの出番だ。
INFO
イベント駆動アーキテクチャでは、サービスがイベント(「何かが起きた」という事実)を発行し、そのイベントに反応するコンシューマーが独立して動作します。プロデューサーはコンシューマーの存在を知らず、疎結合が実現されます。
イベント駆動の基本
Loading diagram...
注文サービスはイベントを発行するだけ。誰が購読しているかを知らない。新しいコンシューマーを追加しても、注文サービスのコードは変わらない。
Railsでのイベント発行
EventBridgeへの発行
# app/services/event_publisher.rb
class EventPublisher
def self.publish(event_type, detail, source: 'myapp.orders')
client = Aws::EventBridge::Client.new(region: 'ap-northeast-1')
client.put_events(
entries: [{
source: source,
detail_type: event_type,
detail: detail.to_json,
event_bus_name: ENV.fetch('EVENT_BUS_NAME', 'default'),
# トレーシング用
resources: ["arn:aws:...#{detail[:order_id]}"]
}]
)
rescue Aws::EventBridge::Errors::ServiceError => e
Rails.logger.error "EventBridge publish failed: #{e.message}"
# イベント発行失敗は致命的ではない(補助的な処理用なら)
# ただしデータ整合性に関わる場合はSQSなどを使う
end
end
# app/services/order_service.rb
class OrderService
def confirm_order(order)
ActiveRecord::Base.transaction do
order.update!(status: 'confirmed')
Inventory.reserve!(order.product_id, order.quantity)
# ここではDBの整合性のみ保証する
end
# トランザクション外でイベントを発行
EventPublisher.publish('order.confirmed', {
order_id: order.id,
user_id: order.user_id,
user_email: order.user.email,
product_id: order.product_id,
product_name: order.product.name,
quantity: order.quantity,
total_price: order.total_price,
confirmed_at: order.updated_at.iso8601
})
end
endOutboxパターン(確実なイベント発行)
「EventBridgeへの発行自体が失敗したら?」という問題への対策。
# db/migrate/create_outbox_messages.rb
class CreateOutboxMessages < ActiveRecord::Migration[7.1]
def change
create_table :outbox_messages do |t|
t.string :aggregate_type, null: false
t.string :aggregate_id, null: false
t.string :event_type, null: false
t.jsonb :payload, null: false, default: {}
t.string :status, null: false, default: 'pending'
t.integer :retry_count, null: false, default: 0
t.datetime :last_attempted_at
t.datetime :processed_at
t.timestamps
end
add_index :outbox_messages, [:status, :created_at]
end
end
# トランザクション内でアウトボックスにも書く
class OrderService
def confirm_order(order)
ActiveRecord::Base.transaction do
order.update!(status: 'confirmed')
Inventory.reserve!(order.product_id, order.quantity)
# 同じトランザクション内でアウトボックスに書く(確実!)
OutboxMessage.create!(
aggregate_type: 'Order',
aggregate_id: order.id.to_s,
event_type: 'order.confirmed',
payload: {
order_id: order.id,
user_email: order.user.email,
# ...
}
)
end
# トランザクションが成功すれば、アウトボックスにも確実に書かれている
end
end
# app/jobs/outbox_relay_job.rb
class OutboxRelayJob < ApplicationJob
queue_as :outbox
def perform
OutboxMessage.where(status: 'pending').find_each do |message|
begin
EventPublisher.publish(message.event_type, message.payload)
message.update!(status: 'processed', processed_at: Time.current)
rescue => e
message.increment!(:retry_count)
message.update!(
status: message.retry_count >= 3 ? 'failed' : 'pending',
last_attempted_at: Time.current
)
end
end
end
endSQSコンシューマーの実装
Lambda コンシューマー
# handlers/send_order_confirmation.rb
require 'json'
require 'aws-sdk-ses'
$ses_client ||= Aws::SES::Client.new(region: 'ap-northeast-1')
def handler(event:, context:)
failed_items = []
event['Records'].each do |record|
message_id = record['messageId']
begin
# EventBridge → SNS → SQS の場合、bodyはJSON
body = JSON.parse(record['body'])
detail = JSON.parse(body['Message'])
send_confirmation_email(detail)
puts "Sent confirmation for order #{detail['order_id']}"
rescue => e
puts "Failed to process message #{message_id}: #{e.class} - #{e.message}"
failed_items << { itemIdentifier: message_id }
end
end
# 部分的な失敗を報告(失敗したメッセージだけ再キューイング)
{ batchItemFailures: failed_items }
endRailsでの非同期コンシューマー
# app/jobs/process_order_confirmed_job.rb
class ProcessOrderConfirmedJob < ApplicationJob
queue_as :order_events
# 冪等性: 同じイベントを複数回受け取っても安全
def perform(event_data)
order_id = event_data['order_id']
# 処理済みチェック
return if ProcessedEvent.exists?(event_id: event_data['event_id'])
ActiveRecord::Base.transaction do
# ドメイン処理
update_analytics(event_data)
award_loyalty_points(event_data)
# 処理済みとしてマーク
ProcessedEvent.create!(
event_id: event_data['event_id'],
event_type: event_data['event_type'],
processed_at: Time.current
)
end
rescue ActiveRecord::RecordNotUnique
# 二重処理の競合: 無視してOK
Rails.logger.info "Event #{event_data['event_id']} already processed"
end
private
def update_analytics(event_data)
DailySales.find_or_initialize_by(date: Date.today).tap do |record|
record.increment(:order_count)
record.increment(:total_revenue, event_data['total_price'])
record.save!
end
end
def award_loyalty_points(event_data)
User.find(event_data['user_id'])
.loyalty_points
.award!(event_data['total_price'] / 100)
end
endAWSでのイベント駆動構成
Loading diagram...
# AWS SAMでのイベント駆動構成
Resources:
# カスタムイベントバス
OrderEventBus:
Type: AWS::Events::EventBus
Properties:
Name: myapp-orders
# SNSトピック(ファンアウト用)
OrderConfirmedTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: order-confirmed
# EventBridge → SNS のルール
OrderConfirmedRule:
Type: AWS::Events::Rule
Properties:
EventBusName: !Ref OrderEventBus
EventPattern:
source: ["myapp.orders"]
detail-type: ["order.confirmed"]
Targets:
- Id: OrderConfirmedSNS
Arn: !Ref OrderConfirmedTopic
# SQS: メール送信用
EmailNotificationQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: email-notification
VisibilityTimeout: 60
RedrivePolicy:
deadLetterTargetArn: !GetAtt EmailNotificationDLQ.Arn
maxReceiveCount: 3
# デッドレターキュー
EmailNotificationDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: email-notification-dlq
MessageRetentionPeriod: 1209600 # 14日間保持
# SNS → SQS サブスクリプション
EmailQueueSubscription:
Type: AWS::SNS::Subscription
Properties:
TopicArn: !Ref OrderConfirmedTopic
Protocol: sqs
Endpoint: !GetAtt EmailNotificationQueue.Arnデッドレターキュー(DLQ)による障害対策
失敗したメッセージは自動的にDLQへ。後で調査・再処理できる。
# app/jobs/dlq_reprocessing_job.rb
class DlqReprocessingJob < ApplicationJob
queue_as :admin
def perform(queue_name:, max_messages: 100)
sqs = Aws::SQS::Client.new(region: 'ap-northeast-1')
dlq_url = sqs.get_queue_url(queue_name: "#{queue_name}-dlq").queue_url
original_queue_url = sqs.get_queue_url(queue_name: queue_name).queue_url
processed_count = 0
loop do
break if processed_count >= max_messages
response = sqs.receive_message(
queue_url: dlq_url,
max_number_of_messages: 10
)
break if response.messages.empty?
response.messages.each do |message|
# DLQから元のキューに移動
sqs.send_message(
queue_url: original_queue_url,
message_body: message.body
)
sqs.delete_message(
queue_url: dlq_url,
receipt_handle: message.receipt_handle
)
processed_count += 1
end
end
puts "DLQから#{processed_count}件を再処理キューに移動しました"
end
endイベントスキーマの管理
イベントのスキーマが変わると、コンシューマーが壊れる。バージョニングで対処する。
# イベントのバージョニング
class OrderConfirmedEvent
VERSION = '2.0'
def to_event
{
version: VERSION,
event_type: 'order.confirmed',
# v2で追加されたフィールド
total_price: total_price,
currency: 'JPY',
# 後方互換性のため古いフィールドも維持
total: total_price # v1との互換性
}
end
end
# コンシューマー側でバージョン対応
def process_event(event)
version = event['version'] || '1.0'
case version
when '1.0'
total = event['total']
when '2.0'
total = event['total_price']
else
raise "未対応のイベントバージョン: #{version}"
end
# 処理続行...
endEventBridge Pipes(新しいパターン)
# EventBridge PipesでSQS → Lambda をシンプルに繋ぐ
OrderProcessingPipe:
Type: AWS::Pipes::Pipe
Properties:
Name: order-processing-pipe
Source: !GetAtt EmailNotificationQueue.Arn
Target: !GetAtt SendEmailFunction.Arn
SourceParameters:
SqsQueueParameters:
BatchSize: 10
MaximumBatchingWindowInSeconds: 5
# フィルタリング: 特定のイベントのみ処理
Filter:
Filters:
- Pattern: '{"body": {"event_type": ["order.confirmed"]}}'疎結合の恩恵
カオリは振り返った。「SendGridが落ちたあの夜、もしイベント駆動だったら何が起きていたか」
イベント駆動の場合:
1. 注文確定 → DBに保存 ✅
2. EventBridgeにイベント発行 ✅
3. SendGridダウン → Lambdaが失敗
4. SQSがメッセージを保持(VisibilityTimeout後に再試行)
5. SendGrid復帰後、自動的にメール送信 ✅
6. ユーザーへの注文確定に影響なし ✅
INFO
イベント駆動アーキテクチャの最大の強みは「部分的な障害の隔離」です。一つのサービスが落ちても、イベントキューがバッファとなり、システム全体には影響しません。
次章では、ここまで学んだ全アーキテクチャを俯瞰する。「どの文脈でどれを選ぶか」のトレードオフを実践的に分析する最終比較へ進もう。