可観測性 — マイクロサービスの監視
「本番で障害が起きたとき、今はどうやって原因を調べてますか?」
CTOのインシデントレビューでの質問に、チームは沈黙した。
「各サービスのログを別々にGrep...してます」とケンジが正直に答えた。
「それがマイクロサービスで最もやってはいけないこと」とミサキが言った。「可観測性の設計からやり直そう」
可観測性の三本柱
Loading diagram...
構造化ログ: JSON で出力する
# config/initializers/logging.rb
if Rails.env.production?
require 'ougai'
Rails.logger = Ougai::Logger.new(STDOUT)
Rails.logger.level = Logger::INFO
# リクエストごとにトレースIDを付与
Rails.application.config.log_tags = [
-> (request) { { trace_id: request.env['HTTP_X_AMZN_TRACE_ID'] } }
]
end# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
around_action :log_request
private
def log_request
start_time = Time.current
yield
ensure
duration_ms = ((Time.current - start_time) * 1000).round(2)
Rails.logger.info({
event: 'request',
method: request.method,
path: request.path,
status: response.status,
duration_ms: duration_ms,
user_id: current_user&.id,
trace_id: request.env['HTTP_X_AMZN_TRACE_ID']
})
end
end# サービスクライアントでもログを出力
class ProductServiceClient
def find(product_id)
start = Time.current
response = connection.get("/api/v1/products/#{product_id}")
duration_ms = ((Time.current - start) * 1000).round(2)
Rails.logger.info({
event: 'service_call',
service: 'product-service',
operation: 'find',
product_id: product_id,
status: response.status,
duration_ms: duration_ms
})
response.body
rescue Faraday::TimeoutError => e
Rails.logger.error({
event: 'service_call_timeout',
service: 'product-service',
operation: 'find',
product_id: product_id,
error: e.message
})
raise
end
endAWS X-Ray: 分散トレーシング
X-Ray を使うと、リクエストが複数のサービスをまたいでどのように処理されたかを可視化できる。
# Gemfile
gem 'aws-xray-sdk'
# config/initializers/xray.rb
require 'aws-xray-sdk/facets/rails/railtie'
XRay.configure do |c|
c.service = ENV.fetch('SERVICE_NAME', 'product-service')
c.sampling = true
c.plugins = [:ecs] # ECS メタデータを自動取得
end# カスタムサブセグメントでコードの特定箇所を計測
class OrderService
def create(user_id:, items:)
XRay.recorder.capture('OrderService.create') do |subsegment|
subsegment.annotations['user_id'] = user_id
subsegment.annotations['item_count'] = items.length
order = nil
XRay.recorder.capture('DB.create_order') do
order = Order.create!(user_id: user_id, status: 'pending')
end
XRay.recorder.capture('ProductService.validate_items') do
validate_items(items)
end
XRay.recorder.capture('InventoryService.reserve') do
reserve_inventory(order, items)
end
order
end
end
endトレースIDの伝播
# X-Rayのトレースヘッダーをサービス間で伝播する
class TracedServiceClient < ServiceClientBase
def get(path, **options)
headers = options.fetch(:headers, {})
# X-RayトレースIDを伝播(サービスをまたいでトレースを繋げる)
trace_id = Thread.current[:xray_trace_id] ||
ENV['_X_AMZN_TRACE_ID'] # Lambda環境
if trace_id
headers['X-Amzn-Trace-Id'] = trace_id
end
super(path, **options.merge(headers: headers))
end
endOpenTelemetry: ベンダー中立なトレーシング
特定のベンダーに縛られたくない場合はOpenTelemetryを使用する。
# Gemfile
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'opentelemetry-instrumentation-rails'
gem 'opentelemetry-instrumentation-active_record'
gem 'opentelemetry-instrumentation-faraday'
gem 'opentelemetry-instrumentation-net_http'
# config/initializers/opentelemetry.rb
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'opentelemetry/instrumentation/all'
OpenTelemetry::SDK.configure do |c|
c.service_name = ENV.fetch('SERVICE_NAME', 'shopnova-service')
c.service_version = ENV.fetch('SERVICE_VERSION', '1.0.0')
c.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://otel-collector:4318')
)
)
)
c.use_all # 全インストゥルメンテーションを有効化
endCloudWatch メトリクス: ビジネスメトリクスの計測
# app/services/metrics_publisher.rb
class MetricsPublisher
CLIENT = Aws::CloudWatch::Client.new(region: 'ap-northeast-1')
NAMESPACE = 'ShopNova/Business'
def self.record(metric_name, value, unit: 'Count', dimensions: {})
CLIENT.put_metric_data(
namespace: NAMESPACE,
metric_data: [{
metric_name: metric_name,
value: value,
unit: unit,
timestamp: Time.current,
dimensions: dimensions.map { |name, val|
{ name: name.to_s, value: val.to_s }
}
}]
)
rescue Aws::CloudWatch::Errors::ServiceError => e
Rails.logger.warn("Failed to publish metric: #{e.message}")
end
end
# ビジネスメトリクスの記録
class OrdersController < ApplicationController
def create
order = OrderService.new.create(order_params)
# ビジネスメトリクスを記録
MetricsPublisher.record(
'OrdersCreated',
1,
dimensions: { Service: 'OrderService', Environment: Rails.env }
)
MetricsPublisher.record(
'OrderRevenue',
order.total_amount_cents / 100.0,
unit: 'None',
dimensions: { Currency: 'JPY' }
)
render json: order, status: :created
end
end# CloudWatch アラーム設定
HighErrorRateAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: shopnova-high-error-rate
MetricName: 5XXError
Namespace: AWS/ApplicationELB
Statistic: Sum
Period: 60
EvaluationPeriods: 3
Threshold: 10
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref AlertTopic
Dimensions:
- Name: LoadBalancer
Value: !GetAtt ProductServiceALB.LoadBalancerFullName
HighLatencyAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: shopnova-high-latency
MetricName: TargetResponseTime
Namespace: AWS/ApplicationELB
Statistic: p99
Period: 60
EvaluationPeriods: 3
Threshold: 2 # p99 が 2秒を超えたらアラート
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref AlertTopicダッシュボード: 全体を俯瞰する
# CloudWatch ダッシュボード
ShopNovaDashboard:
Type: AWS::CloudWatch::Dashboard
Properties:
DashboardName: ShopNova-Overview
DashboardBody: !Sub |
{
"widgets": [
{
"type": "metric",
"properties": {
"title": "注文数(直近1時間)",
"metrics": [
["ShopNova/Business", "OrdersCreated"]
],
"period": 300,
"stat": "Sum"
}
},
{
"type": "metric",
"properties": {
"title": "サービス別 p99 レイテンシ",
"metrics": [
["AWS/ApplicationELB", "TargetResponseTime",
"LoadBalancer", "${ProductServiceALB.LoadBalancerFullName}",
{"label": "商品サービス"}],
["AWS/ApplicationELB", "TargetResponseTime",
"LoadBalancer", "${OrderServiceALB.LoadBalancerFullName}",
{"label": "注文サービス"}]
],
"stat": "p99"
}
}
]
}相関IDパターン: リクエストを追跡する
# app/middleware/correlation_id_middleware.rb
class CorrelationIdMiddleware
def initialize(app)
@app = app
end
def call(env)
# 上流から来たIDを使用、なければ生成
correlation_id = env['HTTP_X_CORRELATION_ID'] || SecureRandom.uuid
request_id = env['HTTP_X_REQUEST_ID'] || SecureRandom.uuid
# スレッドローカルに保存
Thread.current[:correlation_id] = correlation_id
Thread.current[:request_id] = request_id
# レスポンスヘッダーに含める
status, headers, body = @app.call(env)
headers['X-Correlation-Id'] = correlation_id
headers['X-Request-Id'] = request_id
[status, headers, body]
ensure
Thread.current[:correlation_id] = nil
Thread.current[:request_id] = nil
end
end
# 全てのサービス呼び出しに相関IDを付与
class ServiceClientBase
def default_headers
{
'X-Correlation-Id' => Thread.current[:correlation_id] || SecureRandom.uuid,
'X-Request-Id' => SecureRandom.uuid # 各リクエストは新しいID
}
end
endインシデント対応: SLI/SLO の定義
# SLI/SLO の定義と計測
module SLO
# サービスレベル目標
TARGETS = {
availability: 0.999, # 99.9% 可用性
latency_p99: 2.0, # p99 < 2秒
error_rate: 0.001 # エラー率 < 0.1%
}.freeze
class Calculator
def self.availability(window: 24.hours)
total = CloudWatchMetrics.request_count(window: window)
errors = CloudWatchMetrics.error_count(window: window)
return 1.0 if total.zero?
(total - errors).to_f / total
end
def self.within_slo?
availability >= TARGETS[:availability]
end
def self.error_budget_remaining
# エラーバジェット = 1 - SLO目標
# 例: 99.9% SLO → 0.1% のエラーが許容される
allowed_error_rate = 1.0 - TARGETS[:availability]
actual_error_rate = 1.0 - availability
remaining = (allowed_error_rate - actual_error_rate) / allowed_error_rate
[remaining, 0].max
end
end
endまとめ
「障害が起きてから1時間で原因を特定できるようになった」とアオイが報告した。以前は丸一日かかっていた。
可観測性の三本柱:
Logs: 何が起きたか(JSON構造化ログ)
Metrics: どのくらいの頻度・大きさ(CloudWatch)
Traces: どこを通ったか(X-Ray / OpenTelemetry)
実践のポイント:
✓ ログはJSON構造化(grep不要、CloudWatch Insightsでクエリ)
✓ 全リクエストにCorrelation IDを付与して追跡
✓ X-Rayでサービス間のボトルネックを可視化
✓ SLO/エラーバジェットで障害の深刻度を定量化
WARNING
可観測性は「障害が起きてから実装する」のでは遅い。サービスをリリースする前に必ず実装すること。マイクロサービスで障害が起きて、トレースもログ集約もない状態でのデバッグは悪夢になる。
次章では、このマイクロサービス環境をどうテストするかを学ぶ。