サービスディスカバリ — サービスを見つける
「商品サービスの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
endApplication 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 の設定を学ぶ。