1. 結論(この記事で得られること)
この記事では、Rails から Lambda を呼び出し、DynamoDB を使った非同期ジョブで確実にエラーをハンドリングし、リトライ・通知・運用監視まで完結させる実装パターンを解説します。
得られるもの:
- Lambda 側の例外を Rails 側でどう捕捉すべきか(同期/非同期呼び出しの違い)
- DynamoDB を使ったジョブステータス管理の実装
- Dead Letter Queue(DLQ)と Slack 通知の連携
- AI を使った障害切り分けの最速フロー
- テスト・監視のチェックリスト
私自身、最初は「Lambda のエラーは CloudWatch に出てるからいいか」と放置して、本番で無音障害を起こした経験があります。この記事はその反省を込めた実務ガイドです。
2. 前提(環境・読者層)
想定環境
- Rails 7.x(API モード or フルスタック)
- AWS Lambda(Ruby 3.2 or Python 3.11)
- DynamoDB(ジョブステータス・ログ保存用)
- EventBridge or 直接 Lambda 呼び出し
読者層
- Rails から非同期処理を外部に切り出したい人
- Sidekiq / ActiveJob では対応しきれないスケールや実行環境制約がある人
- すでに動いているがエラーハンドリングが不安な人
3. Before:よくあるつまずきポイント
3-1. 同期呼び出しと非同期呼び出しの混同
# ❌ 非同期呼び出しなのに rescue で捕まえようとする
begin
lambda_client.invoke(
function_name: 'MyFunction',
invocation_type: 'Event', # 非同期
payload: JSON.generate(user_id: user.id)
)
rescue Aws::Lambda::Errors::ServiceError => e
# ここには来ない!非同期なので invoke は即座に 202 を返す
logger.error(e)
end
落とし穴:「invocation_type: 'Event'」 の場合、Lambda の実行結果を待たずに成功ステータスが返ります。つまり Rails 側では Lambda 内のエラーを直接 rescue できません。
3-2. DynamoDB への状態書き込み忘れ
Lambda 側でエラーが起きても、DynamoDB に 「status: 'failed'」 を書かないと Rails 側が検知できません。
# ❌ Lambda 内でエラーが起きても何も残らない
def lambda_handler(event, context):
user_id = event['user_id']
# 何か処理
send_email(user_id) # ここで失敗しても DynamoDB 更新なし
3-3. リトライ設計の欠如
Lambda のデフォルトリトライは非同期呼び出しで 2 回ですが、それを超えたらエラーは闇に消えます。DLQ を設定していないと、障害に気づくのは「ユーザーから問い合わせが来たとき」です。
4. After:基本的な解決パターン
4-1. Rails 側:ジョブ登録 + 非同期呼び出し
# app/services/async_job_invoker.rb
class AsyncJobInvoker
def self.call(job_type:, payload:)
# 1. DynamoDB にジョブレコード作成
job_id = SecureRandom.uuid
dynamodb_client.put_item(
table_name: 'async_jobs',
item: {
job_id: job_id,
job_type: job_type,
status: 'pending',
payload: payload.to_json,
created_at: Time.current.iso8601,
updated_at: Time.current.iso8601
}
)
# 2. Lambda 非同期呼び出し
lambda_client.invoke(
function_name: ENV['LAMBDA_FUNCTION_NAME'],
invocation_type: 'Event',
payload: JSON.generate(job_id: job_id, job_type: job_type, payload: payload)
)
job_id
rescue Aws::DynamoDB::Errors::ServiceError, Aws::Lambda::Errors::ServiceError => e
# invoke 前のエラーは Rails 側で捕捉可能
Rails.logger.error("Failed to invoke job: #{e.message}")
raise
end
private
def self.dynamodb_client
@dynamodb_client ||= Aws::DynamoDB::Client.new(region: ENV['AWS_REGION'])
end
def self.lambda_client
@lambda_client ||= Aws::Lambda::Client.new(region: ENV['AWS_REGION'])
end
end
4-2. Lambda 側:ステータス更新 + エラーハンドリング
# lambda_function.py
import boto3
import json
from datetime import datetime
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('async_jobs')
def lambda_handler(event, context):
job_id = event['job_id']
try:
# ステータスを processing に更新
table.update_item(
Key={'job_id': job_id},
UpdateExpression='SET #status = :status, updated_at = :updated_at',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={
':status': 'processing',
':updated_at': datetime.utcnow().isoformat()
}
)
# 実際の処理
result = process_job(event['payload'])
# 成功時
table.update_item(
Key={'job_id': job_id},
UpdateExpression='SET #status = :status, result = :result, updated_at = :updated_at',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={
':status': 'completed',
':result': json.dumps(result),
':updated_at': datetime.utcnow().isoformat()
}
)
except Exception as e:
# 失敗時
table.update_item(
Key={'job_id': job_id},
UpdateExpression='SET #status = :status, error_message = :error, updated_at = :updated_at',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={
':status': 'failed',
':error': str(e),
':updated_at': datetime.utcnow().isoformat()
}
)
raise # DLQ に送るため再 raise
4-3. DLQ + SNS で Slack 通知
Lambda の設定で Dead Letter Queue(SQS or SNS)を指定します。
# serverless.yml の例
functions:
asyncJob:
handler: lambda_function.lambda_handler
onError: arn:aws:sns:ap-northeast-1:123456789012:lambda-dlq-topic
maximumRetryAttempts: 2
SNS トピックから Slack に通知する Lambda を別途用意:
# dlq_notifier.py
import json
import urllib.request
def lambda_handler(event, context):
message = event['Records'][0]['Sns']['Message']
payload = {
"text": f"🚨 Lambda DLQ Alert\n```{message}```"
}
req = urllib.request.Request(
os.environ['SLACK_WEBHOOK_URL'],
data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req)