Rails × サーバーレス — ハイブリッドアーキテクチャ
「全部Lambdaに移行する必要はない」
6ヶ月のサーバーレス移行を経て、ダイチは確信していた。Railsアプリはそのまま残し、周辺の処理をLambdaで補強する「ハイブリッドアーキテクチャ」が、多くの現実のプロジェクトに適した形だ。
ハイブリッドアーキテクチャとは
Loading diagram...
Railsが担う役割:
- コアビジネスロジック(注文、ユーザー管理、在庫管理)
- 管理画面
- 複雑なリレーショナルクエリを必要とする処理
Lambdaが担う役割:
- スパイク性のある処理(セール時の商品検索)
- イベント駆動処理(画像リサイズ、メール送信)
- 定期バッチ(レポート生成)
- サードパーティWebhook受信
Rails から Lambda を呼び出す
AWS SDK for Ruby を Rails から使う
# Gemfile
gem 'aws-sdk-lambda', '~> 1'
gem 'aws-sdk-sqs', '~> 1'# config/initializers/aws.rb
Aws.config.update(
region: ENV.fetch('AWS_REGION', 'ap-northeast-1')
)
# 開発環境ではLocalStackを使う場合
if Rails.env.development?
Aws.config.update(
endpoint: 'http://localhost:4566',
access_key_id: 'test',
secret_access_key: 'test'
)
endLambda を直接呼び出す(同期)
# app/services/lambda_invoker.rb
class LambdaInvoker
def initialize
@client = Aws::Lambda::Client.new
end
# 同期呼び出し(レスポンスを受け取る)
def invoke(function_name:, payload:)
response = @client.invoke(
function_name:,
invocation_type: 'RequestResponse',
payload: JSON.generate(payload)
)
raise LambdaError, response.function_error if response.function_error
JSON.parse(response.payload.read)
rescue Aws::Lambda::Errors::ServiceError => e
Rails.logger.error("Lambda invocation failed: #{e.message}")
raise
end
# 非同期呼び出し(レスポンスを待たない)
def invoke_async(function_name:, payload:)
@client.invoke(
function_name:,
invocation_type: 'Event',
payload: JSON.generate(payload)
)
nil
end
end# app/services/pdf_generator_service.rb
class PdfGeneratorService
FUNCTION_NAME = ENV.fetch('PDF_GENERATOR_FUNCTION', 'ec-site-pdf-generator')
def self.generate_invoice(order)
invoker = LambdaInvoker.new
result = invoker.invoke(
function_name: FUNCTION_NAME,
payload: {
order_id: order.id,
customer_name: order.user.name,
items: order.items.map { |i| serialize_item(i) },
total: order.total_price,
issued_at: order.created_at.iso8601
}
)
result['pdf_url']
end
def self.serialize_item(item)
{
name: item.product.name,
quantity: item.quantity,
unit_price: item.price,
subtotal: item.price * item.quantity
}
end
endSQS 経由での非同期処理委託
RailsのActive Jobと組み合わせてSQSをジョブキューとして使う。
# Gemfile
gem 'aws-sdk-sqs', '~> 1'
# config/application.rb または config/environments/production.rb
config.active_job.queue_adapter = :amazon_sqs
# config/initializers/sqs.rb
Aws::Rails::SqsActiveJob.configure do |config|
config.queues = {
default: ENV['SQS_DEFAULT_QUEUE_URL'],
mailers: ENV['SQS_MAILERS_QUEUE_URL'],
heavy_jobs: ENV['SQS_HEAVY_JOBS_QUEUE_URL']
}
end# app/jobs/resize_product_images_job.rb
class ResizeProductImagesJob < ApplicationJob
queue_as :heavy_jobs
def perform(product_id, image_s3_key)
# このJobはSQSに投入され、Lambda が処理する
# Lambda側のコードは05章の画像リサイズを参照
Rails.logger.info("Enqueued image resize for product #{product_id}")
end
end
# コントローラからの呼び出し
# app/controllers/api/v1/products_controller.rb
def upload_image
s3_key = upload_to_s3(params[:image])
ResizeProductImagesJob.perform_later(@product.id, s3_key)
render json: { message: 'Image upload accepted', s3_key: }
endLambda から Rails を呼び出す(コールバック)
Lambda処理が完了したら、Railsに通知する逆方向のパターン。
# Lambda側: 処理完了後にRails APIを呼び出す
# src/image_resize/app.rb(続き)
def notify_rails_api(product_id, image_urls)
rails_api_url = ENV['RAILS_API_URL']
token = ENV['INTERNAL_API_TOKEN']
uri = URI.parse("#{rails_api_url}/api/v1/internal/products/#{product_id}/images")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
request = Net::HTTP::Post.new(uri.path)
request['Authorization'] = "Bearer #{token}"
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ image_urls: })
response = http.request(request)
raise "Rails API error: #{response.code}" unless response.code == '200'
end# Rails側: Lambda からのコールバックを受け付ける
# app/controllers/api/v1/internal/products_controller.rb
module Api
module V1
module Internal
class ProductsController < ApplicationController
before_action :verify_internal_token
def update_images
@product = Product.find(params[:id])
image_urls = params[:image_urls]
@product.update!(
thumbnail_url: image_urls[:thumbnail],
medium_image_url: image_urls[:medium],
large_image_url: image_urls[:large],
image_processed_at: Time.current
)
render json: { success: true }
end
private
def verify_internal_token
token = request.headers['Authorization']&.sub('Bearer ', '')
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(
token.to_s,
ENV['INTERNAL_API_TOKEN'].to_s
)
end
end
end
end
endWebhookの処理をLambdaで受ける
決済サービス(Stripe等)のWebhookはスパイクが予測できないため、Lambdaに任せるのがベストだ。
Loading diagram...
# Lambda: Stripe Webhook の受信と検証
# src/stripe_webhook/app.rb
# frozen_string_literal: true
require 'json'
require 'openssl'
require 'aws-sdk-sqs'
require 'aws-sdk-secretsmanager'
$sqs = Aws::SQS::Client.new
$webhook_secret = nil
def lambda_handler(event:, context:)
body = event['body']
signature = event.dig('headers', 'stripe-signature')
# Stripe の署名検証
webhook_secret = get_webhook_secret
verify_stripe_signature!(body, signature, webhook_secret)
stripe_event = JSON.parse(body)
event_type = stripe_event['type']
case event_type
when 'payment_intent.succeeded'
handle_payment_succeeded(stripe_event['data']['object'])
when 'payment_intent.payment_failed'
handle_payment_failed(stripe_event['data']['object'])
when 'charge.refunded'
handle_refund(stripe_event['data']['object'])
else
puts "Unhandled event type: #{event_type}"
end
{ statusCode: 200, body: JSON.generate({ received: true }) }
rescue SignatureVerificationError => e
{ statusCode: 400, body: JSON.generate({ error: e.message }) }
end
private
def verify_stripe_signature!(payload, signature_header, secret)
# Stripe の署名検証ロジック
timestamp, signatures = parse_signature_header(signature_header)
# タイムスタンプが5分以内か確認(リプレイ攻撃防止)
raise SignatureVerificationError, "Timestamp too old" if Time.now.to_i - timestamp.to_i > 300
signed_payload = "#{timestamp}.#{payload}"
expected_sig = OpenSSL::HMAC.hexdigest('sha256', secret, signed_payload)
unless signatures.include?(expected_sig)
raise SignatureVerificationError, "Signature mismatch"
end
end
def handle_payment_succeeded(payment_intent)
enqueue_to_rails({
type: 'payment_succeeded',
payment_intent_id: payment_intent['id'],
metadata: payment_intent['metadata'],
amount: payment_intent['amount']
})
end
def enqueue_to_rails(message)
$sqs.send_message(
queue_url: ENV['RAILS_JOB_QUEUE_URL'],
message_body: JSON.generate(message)
)
end
def get_webhook_secret
$webhook_secret ||= begin
client = Aws::SecretsManager::Client.new
JSON.parse(client.get_secret_value(secret_id: ENV['STRIPE_WEBHOOK_SECRET_ARN']).secret_string)['secret']
end
end
def parse_signature_header(header)
elements = header.split(',')
timestamp = elements.find { |e| e.start_with?('t=') }&.sub('t=', '')
signatures = elements.select { |e| e.start_with?('v1=') }.map { |e| e.sub('v1=', '') }
[timestamp, signatures]
end
class SignatureVerificationError < StandardError; endマイグレーションの戦略
既存Railsアプリからハイブリッドアーキテクチャへの段階的な移行手順。
Loading diagram...
| フェーズ | 移行対象 | リスク | 期間 |
|---|---|---|---|
| 1 | 画像リサイズ、PDF生成などのバックグラウンド処理 | 低 | 1-2週 |
| 2 | Stripe等のWebhook受信 | 低 | 1週 |
| 3 | 商品検索などの読み取りAPI | 中 | 2-3週 |
| 4 | セール時スパイクが発生するAPI | 中 | 2-3週 |
| 5 | 注文確定などのコアAPI | 高 | 4-6週 |
WARNING
「一気に全部移行」は避けること。Phase 1から始めて、Lambda運用の知見を積み上げてからコアAPIに手をつける。ダイチのチームもこの段階的な移行で、本番環境での重大インシデントを1件も起こさずに移行できた。
テスト戦略
ハイブリッドアーキテクチャでは、Rails側とLambda側のテストを分けて考える。
# Rails: Lambda呼び出しのモック
# spec/services/pdf_generator_service_spec.rb
RSpec.describe PdfGeneratorService do
describe '.generate_invoice' do
let(:order) { create(:order, :with_items) }
before do
# LambdaInvoker をモック
allow_any_instance_of(LambdaInvoker).to receive(:invoke)
.with(function_name: 'ec-site-pdf-generator', payload: anything)
.and_return({ 'pdf_url' => 'https://s3.example.com/invoice.pdf' })
end
it 'returns the PDF URL from Lambda' do
url = described_class.generate_invoice(order)
expect(url).to eq('https://s3.example.com/invoice.pdf')
end
end
end# Lambda側のRubyテスト(ハンドラ関数の単体テスト)
# spec/handlers/stripe_webhook_spec.rb
require 'spec_helper'
require_relative '../../src/stripe_webhook/app'
RSpec.describe '#lambda_handler' do
let(:payment_intent) do
{
'id' => 'pi_xxx',
'amount' => 5000,
'metadata' => { 'order_id' => '123' }
}
end
let(:event_payload) do
JSON.generate({
'type' => 'payment_intent.succeeded',
'data' => { 'object' => payment_intent }
})
end
before do
allow_any_instance_of(Aws::SQS::Client).to receive(:send_message)
allow_any_instance_of(Aws::SecretsManager::Client).to receive(:get_secret_value)
.and_return(double(secret_string: '{"secret":"whsec_test"}'))
stub_const('ENV', ENV.to_h.merge('STRIPE_WEBHOOK_SECRET_ARN' => 'arn:test'))
end
it 'handles payment_intent.succeeded event' do
# 署名をモック
allow_any_instance_of(Object).to receive(:verify_stripe_signature!).and_return(nil)
result = lambda_handler(
event: {
'body' => event_payload,
'headers' => { 'stripe-signature' => 'mocked' }
},
context: double('context')
)
expect(result[:statusCode]).to eq(200)
end
endダイチのチームの現在
移行完了から3ヶ月後。ダイチはチームの月次レポートを書いた。
【ハイブリッドアーキテクチャ移行完了レポート】
■ インフラコスト
Before: ¥30,000/月
After: ¥8,500/月(Lambda + 縮小したEC2)
削減: ¥21,500/月
■ 運用負荷
深夜対応: 月平均4回 → 0回
スケーリング対応: 手動 → 自動
■ 開発速度
新機能のバックグラウンド処理: 実装時間 50%短縮
(Lambdaはインフラ設定より先にコードが書ける)
■ 残課題
- VPC LambdaのコールドスタートはRDSアクセス時に顕著(200-500ms)
- Lambda関数が増えてきたので管理ツール整備が必要
- テスト環境でのLocalStackが一部サービスで不安定
ハイブリッドアーキテクチャはゴールではなく、現実的な着地点だった。Rails の豊富なエコシステムとサーバーレスの柔軟性を、それぞれが得意な領域で活用する。これがダイチが6ヶ月の試行錯誤の末に辿り着いた答えだった。