mybook

サービスディスカバリ — サービスを見つける

「商品サービスのIPアドレスが変わったって通知が来てるんですけど、注文サービスの設定を更新しないといけないですか?」

ケンジのメッセージを見て、ミサキは首を横に振った。「そのためのサービスディスカバリだよ。IPを直接管理しなくていい仕組みを作ろう」


サービスディスカバリとは

マイクロサービス環境では、コンテナのIPアドレスは頻繁に変わる。デプロイのたびに、スケールアウト・インのたびに変わる。

Loading diagram...

ECS Service Connect

AWS ECS Service Connectは、ECS上のサービスディスカバリとロードバランシングをシンプルに実現する機能。

{
  "family": "product-service",
  "networkMode": "awsvpc",
  "serviceConnectConfiguration": {
    "enabled": true,
    "namespace": "shopnova.local",
    "services": [{
      "portName": "http",
      "discoveryName": "product-service",
      "clientAliases": [{
        "port": 80,
        "dnsName": "product-service"
      }]
    }]
  },
  "containerDefinitions": [{
    "name": "product-service",
    "image": "xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/product-service:latest",
    "portMappings": [{
      "name": "http",
      "containerPort": 3000,
      "protocol": "tcp"
    }]
  }]
}

Service Connect を使うと、サービスは単純なDNS名でアクセスできる:

# config/initializers/service_urls.rb
# ECS Service Connect使用時: DNS名で接続できる
PRODUCT_SERVICE_URL = ENV.fetch('PRODUCT_SERVICE_URL', 'http://product-service')
ORDER_SERVICE_URL   = ENV.fetch('ORDER_SERVICE_URL',   'http://order-service')
USER_SERVICE_URL    = ENV.fetch('USER_SERVICE_URL',    'http://user-service')
 
# IPアドレスやポート番号を管理する必要がない

AWS Cloud Map: サービスレジストリ

より細かい制御が必要な場合はAWS Cloud Map を直接使用する。

# CloudFormation: Cloud Map サービス登録
ShopNovaNamespace:
  Type: AWS::ServiceDiscovery::PrivateDnsNamespace
  Properties:
    Name: shopnova.local
    Vpc: !Ref VPC
 
ProductServiceDiscovery:
  Type: AWS::ServiceDiscovery::Service
  Properties:
    Name: product-service
    NamespaceId: !Ref ShopNovaNamespace
    DnsConfig:
      DnsRecords:
        - Type: A
          TTL: 10  # TTL短め: スケールアウト時の反映を速くする
        - Type: SRV
          TTL: 10
    HealthCheckCustomConfig:
      FailureThreshold: 1
# Cloud Map API でサービスインスタンスを検索
class ServiceRegistry
  CLIENT = Aws::ServiceDiscovery::Client.new(region: 'ap-northeast-1')
 
  def self.discover(service_name, namespace: 'shopnova.local')
    result = CLIENT.discover_instances(
      namespace_name: namespace,
      service_name: service_name,
      health_status: 'HEALTHY',
      max_results: 10
    )
 
    result.instances.map { |instance|
      {
        host: instance.attributes['AWS_INSTANCE_IPV4'],
        port: instance.attributes['AWS_INSTANCE_PORT']
      }
    }
  end
end
 
# ロードバランシング付きのクライアント
class DiscoveredServiceClient
  def initialize(service_name)
    @service_name = service_name
    @instances = []
    @last_refresh = nil
  end
 
  def get(path, **options)
    instance = next_instance
    HTTP.get("http://#{instance[:host]}:#{instance[:port]}#{path}", **options)
  end
 
  private
 
  def next_instance
    refresh_if_stale!
    # ラウンドロビン
    @instances.rotate!.first
  end
 
  def refresh_if_stale!
    return if @last_refresh && Time.current - @last_refresh < 30.seconds
 
    @instances = ServiceRegistry.discover(@service_name)
    @last_refresh = Time.current
    raise "No healthy instances for #{@service_name}" if @instances.empty?
  end
end

Application Load Balancer との統合

# ECS サービスとALBの統合
ProductServiceALB:
  Type: AWS::ElasticLoadBalancingV2::LoadBalancer
  Properties:
    Name: shopnova-product-service
    Scheme: internal  # 内部向けALB
    Type: application
    Subnets:
      - !Ref PrivateSubnet1
      - !Ref PrivateSubnet2
 
ProductServiceTargetGroup:
  Type: AWS::ElasticLoadBalancingV2::TargetGroup
  Properties:
    Name: shopnova-product-tg
    Port: 3000
    Protocol: HTTP
    VpcId: !Ref VPC
    TargetType: ip
    HealthCheckPath: /health
    HealthCheckIntervalSeconds: 15
    HealthyThresholdCount: 2
    UnhealthyThresholdCount: 3
    Matcher:
      HttpCode: '200'
    DeregistrationDelay:
      Value: 30  # デプロイ時のドレイニング秒数
 
ProductServiceECS:
  Type: AWS::ECS::Service
  Properties:
    ServiceName: product-service
    LoadBalancers:
      - ContainerName: product-service
        ContainerPort: 3000
        TargetGroupArn: !Ref ProductServiceTargetGroup
    NetworkConfiguration:
      AwsvpcConfiguration:
        Subnets:
          - !Ref PrivateSubnet1
          - !Ref PrivateSubnet2

サービスメッシュ: App Mesh

より高度なトラフィック制御が必要な場合は AWS App Mesh を使用する。

# App Mesh 設定(Envoy プロキシを使用)
ProductServiceVirtualNode:
  Type: AWS::AppMesh::VirtualNode
  Properties:
    MeshName: shopnova-mesh
    VirtualNodeName: product-service
    Spec:
      Listeners:
        - PortMapping:
            Port: 3000
            Protocol: http
          HealthCheck:
            Path: /health
            HealthyThreshold: 2
            UnhealthyThreshold: 3
            IntervalMillis: 5000
            TimeoutMillis: 2000
      ServiceDiscovery:
        AWSCloudMap:
          NamespaceName: shopnova.local
          ServiceName: product-service
 
ProductServiceVirtualService:
  Type: AWS::AppMesh::VirtualService
  Properties:
    MeshName: shopnova-mesh
    VirtualServiceName: product-service.shopnova.local
    Spec:
      Provider:
        VirtualRouter:
          VirtualRouterName: product-service-router

カナリアデプロイ with App Mesh

# トラフィックを新旧バージョンに重み付けで分散
ProductServiceVirtualRouter:
  Type: AWS::AppMesh::VirtualRouter
  Properties:
    MeshName: shopnova-mesh
    VirtualRouterName: product-service-router
    Spec:
      Listeners:
        - PortMapping:
            Port: 3000
            Protocol: http
 
ProductServiceRoute:
  Type: AWS::AppMesh::Route
  Properties:
    MeshName: shopnova-mesh
    VirtualRouterName: product-service-router
    RouteName: product-service-canary
    Spec:
      HttpRoute:
        Match:
          Prefix: /
        Action:
          WeightedTargets:
            - VirtualNode: product-service-v1
              Weight: 90  # 90% を v1 へ
            - VirtualNode: product-service-v2
              Weight: 10  # 10% を v2 へ(カナリア)

Rails でのサービスディスカバリクライアント

# app/services/service_client_base.rb
class ServiceClientBase
  include ActiveSupport::Configurable
 
  config_accessor :timeout, default: 3
  config_accessor :retry_count, default: 3
 
  def initialize(base_url = nil)
    @base_url = base_url || self.class.default_url
  end
 
  def get(path, params: {}, headers: {})
    with_retry do
      response = connection.get(path, params, headers)
      parse_response!(response)
    end
  end
 
  def post(path, body: {}, headers: {})
    with_retry do
      response = connection.post(path, body.to_json, headers.merge(
        'Content-Type' => 'application/json'
      ))
      parse_response!(response)
    end
  end
 
  private
 
  def connection
    @connection ||= Faraday.new(@base_url) do |f|
      f.options.timeout = self.class.timeout
      f.options.open_timeout = 1
      f.response :json
      f.adapter :net_http_persistent  # 接続再利用でパフォーマンス向上
    end
  end
 
  def with_retry(&block)
    retries = 0
    begin
      block.call
    rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
      retries += 1
      if retries <= self.class.retry_count
        sleep(0.5 * retries)  # 指数バックオフ
        retry
      else
        raise ServiceUnavailableError, "#{self.class.name} unavailable after #{retries} retries: #{e.message}"
      end
    end
  end
 
  def parse_response!(response)
    case response.status
    when 200..299
      response.body
    when 404
      nil
    when 503
      raise ServiceUnavailableError, "Service returned 503"
    else
      raise ServiceError, "Service returned #{response.status}: #{response.body}"
    end
  end
end
 
# app/services/product_service_client.rb
class ProductServiceClient < ServiceClientBase
  def self.default_url
    ENV.fetch('PRODUCT_SERVICE_URL', 'http://product-service')
  end
 
  configure do |config|
    config.timeout = 2
    config.retry_count = 2
  end
 
  def find(product_id)
    get("/api/v1/products/#{product_id}")
  end
 
  def find_batch(product_ids)
    post('/api/v1/products/batch', body: { ids: product_ids })
  end
end

ヘルスチェックエンドポイントの標準化

すべてのサービスで一貫したヘルスチェックを実装する。

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  skip_before_action :authenticate_from_gateway!, raise: false
 
  # GET /health — 基本チェック(ロードバランサー用)
  def check
    render json: { status: 'ok' }, status: :ok
  end
 
  # GET /health/ready — 準備完了チェック(依存関係含む)
  def ready
    checks = {
      database: db_healthy?,
      cache: cache_healthy?
    }
 
    if checks.values.all?
      render json: { status: 'ready', checks: checks }
    else
      render json: { status: 'not_ready', checks: checks }, status: :service_unavailable
    end
  end
 
  # GET /health/live — 生存チェック(ゾンビプロセス検出)
  def live
    render json: { status: 'alive', pid: Process.pid }
  end
 
  private
 
  def db_healthy?
    ActiveRecord::Base.connection.execute('SELECT 1')
    true
  rescue StandardError
    false
  end
 
  def cache_healthy?
    Rails.cache.write('health_check', '1', expires_in: 10.seconds)
    Rails.cache.read('health_check') == '1'
  rescue StandardError
    false
  end
end

まとめ

「これで、サービスのIPが変わっても、スケールアウトしても、自動的に新しいインスタンスにルーティングされる」とミサキがまとめた。

サービスディスカバリの選択肢:
  シンプル:   ECS Service Connect + DNS名
  標準:       Cloud Map + ALB
  高度:       App Mesh(カナリア、サーキットブレーカー、リトライ)

原則:
  ✓ サービスはDNS名でアクセスする(IPを直接書かない)
  ✓ ヘルスチェックを実装して、不健全なインスタンスを除外
  ✓ ALBのドレイニングを設定してデプロイ時の断絶を防ぐ

INFO

ShopNovaでは最初はECS Service Connectのシンプルな構成から始めた。カナリアデプロイが必要になった段階でApp Meshに移行。最初からApp Meshを導入する必要はない。

次章では、これらのサービスをコンテナでデプロイするための Docker と ECS/Fargate の設定を学ぶ。