mybook

サービス指向アーキテクチャ — SOAの教訓

「マイクロサービスとどう違うの?」

カオリの元に、別チームのエンジニア・ダイキが相談に来た。

「SOAって古い概念ですよね?マイクロサービスと同じじゃないんですか?」

「似ているけど、重要な違いがある。SOAが失敗した理由を知っておくと、マイクロサービスの設計で同じ過ちを繰り返さなくて済む」

カオリはホワイトボードに向かった。

INFO

SOA(Service Oriented Architecture)は2000年代に普及した設計思想で、企業システムを再利用可能なサービスとして構成します。ESB(Enterprise Service Bus)を中心とした実装が多く、複雑な統合レイヤーがボトルネックになりやすい問題がありました。

SOAとは何だったのか

SOAは「サービス」という単位でシステムを構成する。顧客サービス、注文サービス、在庫サービスがあり、それらがESBを通じて通信する。

Loading diagram...

ESBの問題

ESBは「全ての通信はここを通る」という中央集権的な設計だ。これが問題を引き起こした。

<!-- SOA時代のESB設定例(WSDLより単純化)-->
<service name="OrderService">
  <interface name="IOrderService">
    <operation name="createOrder">
      <input message="CreateOrderRequest"/>
      <output message="CreateOrderResponse"/>
      <fault message="OrderFault"/>
    </operation>
  </interface>
  
  <binding name="OrderServiceSOAP">
    <!-- SOAP/XML over HTTP -->
    <soap:binding style="document"/>
  </binding>
  
  <orchestration>
    <!-- ESBがビジネスロジックを持ち始める... これが問題 -->
    <step name="validateOrder" service="ValidationService"/>
    <step name="checkInventory" service="InventoryService"/>
    <step name="processPayment" service="PaymentService"/>
    <step name="sendConfirmation" service="NotificationService"/>
  </orchestration>
</service>

ESBが「賢い配管工」になり始める。ルーティング、変換、オーケストレーション、ロジックまで持つようになる。

結果:ESBがスパゲッティの温床になり、デプロイのボトルネックになった

マイクロサービスとの本質的な違い

観点SOAマイクロサービス
通信基盤ESB(中央集権)分散(P2P)
サービスサイズ大きい(機能の塊)小さい(1つの責務)
データ共有共有DBが多いDB per Service
技術スタック統一(SOAP/XML)多様性を許容
デプロイ単位統一デプロイ独立デプロイ
チーム構造機能横断チームプロダクトチーム

Martine Fowlerの名言:

マイクロサービスはSOAが正しく実装された場合に辿り着くものだ

SOAから学ぶべき教訓

教訓1: 通信のボトルネックを作るな

# NG: 全ての通信が単一のゲートウェイを通る(SOAのESBと同じ過ち)
class ApiGateway
  def process(request)
    case request.service
    when 'orders'
      # ここで変換・ルーティング・ログ・認証・全部やる
      validate(request)
      transform(request)
      log(request)
      forward_to_order_service(request)
      transform_response(response)
    when 'inventory'
      # ...
    end
  end
end
 
# OK: ゲートウェイは薄く保ち、各サービスが独立して賢い
# サービス間は直接通信か、軽量なメッセージングを使う

教訓2: 共有データベースは避ける

SOAの失敗パターンの一つが「共有DB」だ。

# NG: 複数サービスが同じテーブルに直接アクセス
# 注文サービス
OrderRecord.where(user_id: user_id)
 
# 分析サービスも同じテーブルに直接アクセス
OrderRecord.where(created_at: last_month).group(:status).count
 
# → どちらかがスキーマ変更すると両方が壊れる
 
# OK: サービスは自分のDBを持ち、他サービスへはAPIでアクセス
# 分析サービスはAPIを通じてデータを取得し、自前のDWHに保存
AnalyticsService.fetch_orders_from_api(period: last_month)

教訓3: ロジックをバスに載せるな

# NG: メッセージングシステムにビジネスロジックが混入
class EventBusProcessor
  def process(event)
    if event.type == 'order.placed'
      # ここでビジネスロジックを書き始めると...
      if event.data[:total] > 10000
        trigger_fraud_check(event)
      end
      
      if event.data[:user_new?]
        send_welcome_discount(event)
      end
      
      # どんどん膨らんでいく = ESBの再現
    end
  end
end
 
# OK: バス/キューはシンプルな配達係
# ロジックは各サービスが持つ
class FraudDetectionService
  def on_order_placed(event)
    return unless event.total > 10000
    check_fraud_risk(event.order_id)
  end
end

Railsでのサービス分割パターン

実際にRailsモノリスをサービスに分割するとき、どうアプローチするか。

ストラングラー無花果パターン

Loading diagram...
# API Gatewayのルーティング設定(nginx example)
# /etc/nginx/conf.d/api_gateway.conf
upstream monolith {
  server rails-app:3000;
}
 
upstream payment_service {
  server payment-service:8080;
}
 
upstream notification_service {
  server notification-service:8081;
}
 
server {
  # 新しいサービスへのルーティング
  location /api/v1/payments {
    proxy_pass http://payment_service;
  }
  
  location /api/v1/notifications {
    proxy_pass http://notification_service;
  }
  
  # まだモノリスに残っているもの
  location / {
    proxy_pass http://monolith;
  }
}

サービス間通信のRails実装

# lib/services/inventory_client.rb
class InventoryClient
  include HTTParty
  
  base_uri ENV.fetch('INVENTORY_SERVICE_URL')
  
  def check_availability(product_id:, quantity:)
    response = self.class.get(
      "/api/v1/products/#{product_id}/availability",
      query: { quantity: quantity },
      headers: {
        'Authorization' => "Bearer #{service_token}",
        'Content-Type' => 'application/json',
        'X-Request-ID' => RequestStore.store[:request_id]
      },
      timeout: 5  # タイムアウトは必須
    )
    
    raise ServiceUnavailableError unless response.success?
    
    response.parsed_response.deep_symbolize_keys
  rescue HTTParty::Error, Timeout::Error => e
    # サーキットブレーカーパターン
    circuit_breaker.handle_failure(e)
    raise ServiceUnavailableError, "在庫サービスが利用できません"
  end
  
  private
  
  def service_token
    # サービス間認証トークン(短命なJWT)
    JWT.encode(
      { iss: 'order-service', exp: 5.minutes.from_now.to_i },
      Rails.application.credentials.service_key
    )
  end
  
  def circuit_breaker
    @circuit_breaker ||= CircuitBreaker.new(
      failure_threshold: 5,
      recovery_timeout: 30.seconds
    )
  end
end

サーキットブレーカーパターン

分散システムの必須パターン。依存サービスが落ちたとき、自分も道連れにならないための防護壁。

# lib/circuit_breaker.rb
class CircuitBreaker
  STATES = [:closed, :open, :half_open].freeze
  
  def initialize(failure_threshold: 5, recovery_timeout: 30)
    @failure_threshold = failure_threshold
    @recovery_timeout = recovery_timeout
    @failure_count = 0
    @state = :closed
    @last_failure_time = nil
  end
  
  def call(&block)
    case @state
    when :open
      if recovery_timeout_elapsed?
        @state = :half_open
        try_call(&block)
      else
        raise CircuitOpenError, "サービスが利用できません(回路開放中)"
      end
    when :half_open
      try_call(&block)
    when :closed
      try_call(&block)
    end
  end
  
  private
  
  def try_call(&block)
    result = block.call
    on_success
    result
  rescue => e
    on_failure
    raise
  end
  
  def on_success
    @failure_count = 0
    @state = :closed
  end
  
  def on_failure
    @failure_count += 1
    @last_failure_time = Time.current
    
    if @failure_count >= @failure_threshold
      @state = :open
    end
  end
  
  def recovery_timeout_elapsed?
    @last_failure_time && (Time.current - @last_failure_time) >= @recovery_timeout
  end
end

AWSでのSOA/マイクロサービス構成

Loading diagram...
# AWS SAM / CloudFormation でサービスメッシュ構成
# App Mesh を使ってサービス間通信を管理する例
Resources:
  AppMesh:
    Type: AWS::AppMesh::Mesh
    Properties:
      MeshName: myapp-mesh
      
  OrderServiceVirtualService:
    Type: AWS::AppMesh::VirtualService
    Properties:
      MeshName: !GetAtt AppMesh.MeshName
      VirtualServiceName: order-service.myapp.local
      Spec:
        Provider:
          VirtualRouter:
            VirtualRouterName: order-router
            
  # ヘルスチェックとサーキットブレーカー
  InventoryServiceVirtualNode:
    Type: AWS::AppMesh::VirtualNode
    Properties:
      NodeName: inventory-node
      Spec:
        Listeners:
          - PortMapping:
              Port: 8080
              Protocol: http
            HealthCheck:
              HealthyThreshold: 2
              IntervalMillis: 5000
              TimeoutMillis: 2000
              UnhealthyThreshold: 3

分散システムの現実

SOAとマイクロサービス、どちらを選んでも分散システムの問題は避けられない。

分散システムの8つの誤った仮定(Peter Deutsch):
1. ネットワークは信頼できる
2. レイテンシはゼロ
3. 帯域幅は無限
4. ネットワークはセキュア
5. ネットワークトポロジーは変わらない
6. 管理者は一人
7. 転送コストはゼロ
8. ネットワークは均一

→ これらは全て誤りだ

WARNING

分散システムの複雑さを過小評価しないでください。ネットワーク障害、タイムアウト、部分的な失敗、データの不整合—これらは全て必ず起きます。「起きたとき」ではなく「起きることを前提に」設計することが重要です。


「SOAから学べることは多い」とカオリはまとめた。「ESBという失敗パターンと、分散システムの現実。これを知った上でマイクロサービスに進むのと、知らないで進むのでは大きく違う」

次章ではサーバーレスアーキテクチャへ進む。「関数」という単位でシステムを考える新しいパラダイムを、AWS Lambda と Step Functions で実装する。