mybook

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'
  )
end

Lambda を直接呼び出す(同期)

# 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
end

SQS 経由での非同期処理委託

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: }
end

Lambda から 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
end

Webhookの処理を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週
2Stripe等のWebhook受信1週
3商品検索などの読み取りAPI2-3週
4セール時スパイクが発生するAPI2-3週
5注文確定などのコアAPI4-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ヶ月の試行錯誤の末に辿り着いた答えだった。