サーバーレスアーキテクチャ — 関数で考える
「サーバーがないって、どういうこと?」
新メンバーのサクラが素直に質問した。
「サーバーレスって、文字通りサーバーがないわけじゃないよね?」
「そう、サーバーは存在する。でも私たちがその存在を意識しないで良くなる。プロビジョニング、スケーリング、OSのパッチ適用—全部クラウドプロバイダーが管理してくれる。私たちはコード(関数)だけを書く」
カオリは具体例で説明した。「毎晩0時に売上集計レポートを送信するバッチ処理、どうやって実装する?」
「EC2サーバーを立てて、cronを設定して...」
「それだと、月30回しか動かない処理のために24時間サーバーを維持することになる。Lambda なら、実行した分だけ課金。月30回なら、ほぼ無料」
INFO
サーバーレスは「インフラ管理からの解放」がキーメッセージです。FaaS(Function as a Service)として、AWS Lambda・Google Cloud Functions・Azure Functionsなどが代表的なサービスです。
Lambdaの基本構造
AWS SAMでのRuby Lambda実装
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: ruby3.3
Timeout: 30
MemorySize: 512
Environment:
Variables:
DATABASE_URL: !Sub "{{resolve:secretsmanager:${AWS::StackName}/db-url}}"
Resources:
# 注文確認メール送信
SendOrderConfirmationFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/send_order_confirmation.handler
Events:
SQSTrigger:
Type: SQS
Properties:
Queue: !GetAtt OrderConfirmationQueue.Arn
BatchSize: 10
FunctionResponseTypes:
- ReportBatchItemFailures
Policies:
- SQSPollerPolicy:
QueueName: !GetAtt OrderConfirmationQueue.QueueName
- Statement:
Effect: Allow
Action: ses:SendEmail
Resource: "*"
# 日次売上集計
DailySalesReportFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/daily_sales_report.handler
Timeout: 300
Events:
ScheduledEvent:
Type: Schedule
Properties:
Schedule: cron(0 15 * * ? *) # 毎日00:00 JSTLambda ハンドラーの実装
# handlers/send_order_confirmation.rb
require 'json'
require 'aws-sdk-ses'
def handler(event:, context:)
# SQSのバッチ処理
failed_message_ids = []
event['Records'].each do |record|
message_id = record['messageId']
begin
body = JSON.parse(record['body'])
order_data = JSON.parse(body['Message']) # SNS経由の場合
send_confirmation_email(order_data)
rescue => e
# 部分的な失敗を報告(失敗分だけ再試行)
puts "Error processing message #{message_id}: #{e.message}"
failed_message_ids << { itemIdentifier: message_id }
end
end
# ReportBatchItemFailures: 失敗したメッセージのみSQSに戻す
{ batchItemFailures: failed_message_ids }
end
def send_confirmation_email(order_data)
ses = Aws::SES::Client.new(region: 'ap-northeast-1')
ses.send_email(
destination: {
to_addresses: [order_data['user_email']]
},
message: {
subject: { data: "注文確認 ##{order_data['order_id']}", charset: 'UTF-8' },
body: {
html: {
data: render_email_template(order_data),
charset: 'UTF-8'
}
}
},
source: 'noreply@myapp.com'
)
end
def render_email_template(order_data)
# ERBテンプレートなど
<<~HTML
<h1>ご注文ありがとうございます</h1>
<p>注文番号: #{order_data['order_id']}</p>
<p>合計: ¥#{order_data['total_price']}</p>
HTML
end日次バッチ処理
# handlers/daily_sales_report.rb
require 'json'
require 'aws-sdk-rds-data'
require 'aws-sdk-ses'
def handler(event:, context:)
puts "日次売上集計開始: #{Time.now.iso8601}"
report_date = Date.yesterday
# RDS Data API(サーバーレスでDBアクセス)
data_client = Aws::RDSDataService::Client.new(region: 'ap-northeast-1')
result = data_client.execute_statement(
resource_arn: ENV['DB_CLUSTER_ARN'],
secret_arn: ENV['DB_SECRET_ARN'],
database: ENV['DB_NAME'],
sql: <<~SQL,
SELECT
COUNT(*) as order_count,
SUM(total_price) as total_revenue,
AVG(total_price) as avg_order_value,
COUNT(DISTINCT user_id) as unique_customers
FROM orders
WHERE DATE(created_at) = :report_date
AND status NOT IN ('cancelled')
SQL
parameters: [
{ name: 'report_date', value: { string_value: report_date.to_s } }
]
)
metrics = extract_metrics(result)
# S3にレポートを保存
save_report_to_s3(report_date, metrics)
# Slackに通知
post_to_slack(report_date, metrics)
puts "完了: #{metrics.inspect}"
{ statusCode: 200, body: metrics.to_json }
rescue => e
puts "エラー: #{e.class} - #{e.message}"
puts e.backtrace.join("\n")
raise # エラーを再発生させてLambdaにリトライを任せる
end
def extract_metrics(result)
row = result.records.first
{
order_count: row[0].long_value,
total_revenue: row[1].double_value.to_i,
avg_order_value: row[2].double_value.to_i,
unique_customers: row[3].long_value
}
endStep Functionsで複雑なフローを管理
注文処理の複数ステップを、Step Functionsでオーケストレーションする。
# state_machine.yaml
Comment: 注文処理ワークフロー
StartAt: ValidateOrder
States:
ValidateOrder:
Type: Task
Resource: !GetAtt ValidateOrderFunction.Arn
Retry:
- ErrorEquals: ["ServiceUnavailable"]
IntervalSeconds: 2
MaxAttempts: 3
BackoffRate: 2
Catch:
- ErrorEquals: ["InvalidOrderError"]
Next: OrderFailed
Next: CheckInventory
CheckInventory:
Type: Task
Resource: !GetAtt CheckInventoryFunction.Arn
Next: ProcessPayment
Catch:
- ErrorEquals: ["InsufficientInventoryError"]
Next: OrderFailed
ProcessPayment:
Type: Task
Resource: !GetAtt ProcessPaymentFunction.Arn
Next: UpdateInventory
Catch:
- ErrorEquals: ["PaymentFailedError"]
Next: OrderFailed
UpdateInventory:
Type: Task
Resource: !GetAtt UpdateInventoryFunction.Arn
Next: SendConfirmation
SendConfirmation:
Type: Task
Resource: !GetAtt SendConfirmationFunction.Arn
Next: OrderSucceeded
OrderSucceeded:
Type: Succeed
OrderFailed:
Type: Task
Resource: !GetAtt HandleOrderFailureFunction.Arn
Next: OrderFailedEnd
OrderFailedEnd:
Type: Fail
Error: OrderProcessingFailed# Step Functionsの各ステップ実装
# handlers/validate_order.rb
def handler(event:, context:)
order_data = event['order']
raise 'InvalidOrderError' if order_data['quantity'].to_i <= 0
raise 'InvalidOrderError' if order_data['product_id'].nil?
# バリデーション通過: 次のステートに渡すデータを返す
{
order: order_data,
validated_at: Time.now.iso8601
}
endコールドスタートとウォームアップ
サーバーレスの有名な問題「コールドスタート」。
対策1: Provisioned Concurrency
# 常に起動済みのコンテナを確保(コスト増加)
OrderProcessorFunction:
Type: AWS::Serverless::Function
Properties:
ProvisionedConcurrencyConfig:
ProvisionedConcurrentExecutions: 5 # 常に5つ起動対策2: コードの軽量化
# NG: 全てのgemをrequireする
require 'rails' # 重い
# OK: 必要なものだけrequire
require 'json'
require 'aws-sdk-ses' # 必要なサービスのみ
require_relative '../lib/email_renderer'対策3: グローバル変数でクライアントを使い回す
# コンテナ再利用時にクライアントを再初期化しない
$ses_client ||= Aws::SES::Client.new(region: 'ap-northeast-1')
$dynamodb ||= Aws::DynamoDB::Client.new(region: 'ap-northeast-1')
def handler(event:, context:)
# $ses_client は2回目以降再利用される
$ses_client.send_email(...)
end冪等性の実装
サーバーレスではリトライが頻発する。同じリクエストが複数回実行されても安全であることが重要。
# handlers/process_payment.rb
def handler(event:, context:)
order_id = event['order']['id']
# 冪等性キー:同じ注文の決済は1回だけ
idempotency_key = "payment-#{order_id}"
# DynamoDBでべき等性チェック
dynamodb = $dynamodb
begin
dynamodb.put_item(
table_name: ENV['IDEMPOTENCY_TABLE'],
item: {
'pk' => idempotency_key,
'status' => 'INPROGRESS',
'ttl' => (Time.now + 24.hours).to_i
},
condition_expression: 'attribute_not_exists(pk)' # 既存があればエラー
)
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
# 既に処理済み: キャッシュされた結果を返す
existing = dynamodb.get_item(
table_name: ENV['IDEMPOTENCY_TABLE'],
key: { 'pk' => idempotency_key }
).item
return existing['result']
end
# 決済処理
result = charge_stripe(event['order'])
# 結果をキャッシュ
dynamodb.update_item(
table_name: ENV['IDEMPOTENCY_TABLE'],
key: { 'pk' => idempotency_key },
update_expression: 'SET #s = :status, #r = :result',
expression_attribute_names: { '#s' => 'status', '#r' => 'result' },
expression_attribute_values: { ':status' => 'COMPLETED', ':result' => result }
)
result
endサーバーレスのトレードオフ
メリット:
✅ インフラ管理不要(サーバーのパッチ適用、OSアップデートなし)
✅ 自動スケール(トラフィックスパイクに対応)
✅ 使用した分だけ課金
✅ 高可用性が標準(Lambdaは自動でマルチAZ)
✅ イベント駆動と相性が良い
デメリット:
❌ コールドスタート(初回レイテンシ)
❌ 実行時間制限(Lambda最大15分)
❌ デバッグが難しい(ローカル環境の再現が困難)
❌ ベンダーロックイン
❌ 長時間処理には不向き
❌ ステートレスの強制(セッションは外部に持つ必要あり)
WARNING
サーバーレスは「全てのシステム」に適切ではありません。リクエストが常時高頻度で来るAPIサーバーはEC2/ECSの方が安価な場合が多いです。イベント駆動のバッチ処理、Webhook処理、スケジュール実行に特に向いています。
ローカル開発
# AWS SAM CLIでローカルテスト
sam local invoke SendOrderConfirmationFunction \
--event events/order_placed.json
# API Gatewayをローカルでエミュレート
sam local start-api --port 3001
# Step Functions ローカルエミュレーター
docker run -p 8083:8083 amazon/aws-stepfunctions-localカオリはチームに提案した。「Railsは引き続きメインのAPIサーバーとして使う。サーバーレスは、夜間バッチ・イベント処理・Webhook受信の専用にする。ハイブリッドが正解だと思う」
次章では「イベント駆動アーキテクチャ」を学ぶ。EventBridgeとSNS/SQSを使って、サービス間を疎結合にリアクティブに連携させる設計を見ていこう。