mybook

可観測性 — マイクロサービスの監視

「本番で障害が起きたとき、今はどうやって原因を調べてますか?」

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
end

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

OpenTelemetry: ベンダー中立なトレーシング

特定のベンダーに縛られたくない場合は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  # 全インストゥルメンテーションを有効化
end

CloudWatch メトリクス: ビジネスメトリクスの計測

# 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

可観測性は「障害が起きてから実装する」のでは遅い。サービスをリリースする前に必ず実装すること。マイクロサービスで障害が起きて、トレースもログ集約もない状態でのデバッグは悪夢になる。

次章では、このマイクロサービス環境をどうテストするかを学ぶ。