【.NET Aspire入門】第4回:観測可能性とモニタリング

はじめに

マイクロサービスアーキテクチャでは、システムの健全性を維持するために観測可能性(Observability)が不可欠です。前回はメッセージングとイベント駆動アーキテクチャについて学びました。今回は、.NET AspireでOpenTelemetry、Prometheus、Grafanaを使った包括的な監視システムの構築方法を解説します。

.NET Aspireは、OpenTelemetryを標準で統合しており、ログ、メトリクス、トレースの3つの柱を簡単に実装できます。これにより、分散システムの問題を迅速に特定し、解決することが可能になります。

観測可能性の3つの柱

1. ログ(Logs)

アプリケーションの動作の詳細な記録

  • エラーメッセージ
  • デバッグ情報
  • 監査ログ

2. メトリクス(Metrics)

システムの数値的な測定値

  • レスポンスタイム
  • エラー率
  • リソース使用率

3. トレース(Traces)

分散システム内のリクエストの追跡

  • サービス間の呼び出し関係
  • 各処理のレイテンシ
  • ボトルネックの特定

OpenTelemetryの統合

基本的なセットアップ

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// OpenTelemetry Collectorの追加
var otelCollector = builder.AddOpenTelemetryCollector("otel-collector")
    .WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317");

// Prometheusの追加
var prometheus = builder.AddPrometheus("prometheus")
    .WithDataVolume("prometheus-data");

// Grafanaの追加
var grafana = builder.AddGrafana("grafana")
    .WithDataVolume("grafana-data");

// Jaegerの追加(分散トレーシング)
var jaeger = builder.AddJaeger("jaeger");

// アプリケーションサービスの追加
var api = builder.AddProject<Projects.Api>("api")
    .WithReference(otelCollector)
    .WithReference(prometheus);

var orderService = builder.AddProject<Projects.OrderService>("order-service")
    .WithReference(otelCollector);

var inventoryService = builder.AddProject<Projects.InventoryService>("inventory-service")
    .WithReference(otelCollector);

builder.Build().Run();

サービス側の実装

// ServiceDefaults/Extensions.cs
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

public static class Extensions
{
    public static IHostApplicationBuilder AddServiceDefaults(
        this IHostApplicationBuilder builder)
    {
        // サービス情報の設定
        builder.Services.Configure<OpenTelemetryLoggerOptions>(logging =>
        {
            logging.IncludeFormattedMessage = true;
            logging.IncludeScopes = true;
        });

        // リソース情報の設定
        var resourceBuilder = ResourceBuilder
            .CreateDefault()
            .AddService(
                serviceName: builder.Environment.ApplicationName,
                serviceVersion: Assembly.GetExecutingAssembly()
                    .GetName().Version?.ToString() ?? "1.0.0",
                serviceInstanceId: Environment.MachineName);

        // ログの設定
        builder.Logging.AddOpenTelemetry(logging =>
        {
            logging.SetResourceBuilder(resourceBuilder)
                .AddOtlpExporter();
        });

        // メトリクスの設定
        builder.Services.AddOpenTelemetry()
            .ConfigureResource(resource => resource
                .AddService(builder.Environment.ApplicationName))
            .WithMetrics(metrics =>
            {
                metrics
                    .AddAspNetCoreInstrumentation()
                    .AddHttpClientInstrumentation()
                    .AddRuntimeInstrumentation()
                    .AddProcessInstrumentation()
                    .AddMeter("Microsoft.AspNetCore.Hosting")
                    .AddMeter("Microsoft.AspNetCore.Server.Kestrel")
                    .AddMeter("System.Net.Http")
                    .AddPrometheusExporter();
            })
            .WithTracing(tracing =>
            {
                if (builder.Environment.IsDevelopment())
                {
                    tracing.SetSampler(new AlwaysOnSampler());
                }
                else
                {
                    tracing.SetSampler(new TraceIdRatioBasedSampler(0.1));
                }

                tracing
                    .AddAspNetCoreInstrumentation(options =>
                    {
                        options.Filter = (httpContext) =>
                        {
                            // ヘルスチェックをトレースから除外
                            return !httpContext.Request.Path.StartsWithSegments("/health");
                        };
                        options.RecordException = true;
                    })
                    .AddHttpClientInstrumentation(options =>
                    {
                        options.RecordException = true;
                        options.FilterHttpRequestMessage = (httpRequestMessage) =>
                        {
                            // 外部APIへのリクエストのみトレース
                            return !httpRequestMessage.RequestUri?.Host
                                .Contains("localhost") ?? false;
                        };
                    })
                    .AddEntityFrameworkCoreInstrumentation(options =>
                    {
                        options.SetDbStatementForText = true;
                        options.SetDbStatementForStoredProcedure = true;
                    })
                    .AddOtlpExporter();
            });

        return builder;
    }
}

カスタムメトリクスの実装

メトリクスクラスの作成

// Services/OrderMetrics.cs
using System.Diagnostics.Metrics;

public class OrderMetrics : IDisposable
{
    private readonly Meter _meter;
    private readonly Counter<long> _ordersCreated;
    private readonly Counter<long> _ordersFailed;
    private readonly Histogram<double> _orderProcessingTime;
    private readonly ObservableGauge<int> _pendingOrders;
    private readonly UpDownCounter<int> _activeOrders;
    
    private int _pendingOrderCount = 0;

    public OrderMetrics(IMeterFactory meterFactory)
    {
        _meter = meterFactory.Create("OrderService");
        
        _ordersCreated = _meter.CreateCounter<long>(
            "orders.created",
            unit: "order",
            description: "Number of orders created");
            
        _ordersFailed = _meter.CreateCounter<long>(
            "orders.failed",
            unit: "order",
            description: "Number of failed orders");
            
        _orderProcessingTime = _meter.CreateHistogram<double>(
            "orders.processing.duration",
            unit: "ms",
            description: "Time taken to process an order");
            
        _activeOrders = _meter.CreateUpDownCounter<int>(
            "orders.active",
            unit: "order",
            description: "Number of currently active orders");
            
        _pendingOrders = _meter.CreateObservableGauge<int>(
            "orders.pending",
            () => _pendingOrderCount,
            unit: "order",
            description: "Number of pending orders");
    }

    public void RecordOrderCreated(Dictionary<string, object?> tags = null)
    {
        _ordersCreated.Add(1, tags?.ToArray());
    }

    public void RecordOrderFailed(string reason)
    {
        _ordersFailed.Add(1, 
            new KeyValuePair<string, object?>("failure_reason", reason));
    }

    public void RecordProcessingTime(double milliseconds, string orderType)
    {
        _orderProcessingTime.Record(milliseconds,
            new KeyValuePair<string, object?>("order_type", orderType));
    }

    public void IncrementActiveOrders() => _activeOrders.Add(1);
    public void DecrementActiveOrders() => _activeOrders.Add(-1);
    public void SetPendingOrders(int count) => _pendingOrderCount = count;

    public void Dispose()
    {
        _meter?.Dispose();
    }
}

// Program.cs での登録
builder.Services.AddSingleton<OrderMetrics>();

メトリクスの使用

// Controllers/OrderController.cs
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly OrderMetrics _metrics;
    private readonly ILogger<OrderController> _logger;

    public OrderController(
        IOrderService orderService,
        OrderMetrics metrics,
        ILogger<OrderController> logger)
    {
        _orderService = orderService;
        _metrics = metrics;
        _logger = logger;
    }

    [HttpPost]
    public async Task<ActionResult<OrderResponse>> CreateOrder(
        CreateOrderRequest request)
    {
        using var activity = Activity.Current;
        activity?.SetTag("order.customer_id", request.CustomerId);
        activity?.SetTag("order.total_amount", request.TotalAmount);

        var stopwatch = Stopwatch.StartNew();
        _metrics.IncrementActiveOrders();

        try
        {
            _logger.LogInformation("Creating order for customer {CustomerId}", 
                request.CustomerId);

            var order = await _orderService.CreateOrderAsync(request);

            _metrics.RecordOrderCreated(new Dictionary<string, object?>
            {
                ["customer_type"] = request.CustomerType,
                ["payment_method"] = request.PaymentMethod
            });

            _logger.LogInformation("Order {OrderId} created successfully", 
                order.OrderId);

            return Ok(order);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create order for customer {CustomerId}", 
                request.CustomerId);

            _metrics.RecordOrderFailed(ex.GetType().Name);
            
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            
            return StatusCode(500, new { error = "Order creation failed" });
        }
        finally
        {
            _metrics.DecrementActiveOrders();
            _metrics.RecordProcessingTime(
                stopwatch.ElapsedMilliseconds, 
                request.OrderType);
        }
    }
}

分散トレーシングの実装

トレースコンテキストの伝播

// Services/InventoryService.cs
public class InventoryService : IInventoryService
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<InventoryService> _logger;
    private static readonly ActivitySource ActivitySource = 
        new("InventoryService", "1.0.0");

    public InventoryService(HttpClient httpClient, ILogger<InventoryService> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
    }

    public async Task<bool> CheckAvailabilityAsync(
        Guid productId, int quantity)
    {
        using var activity = ActivitySource.StartActivity(
            "CheckInventory",
            ActivityKind.Internal);

        activity?.SetTag("product.id", productId);
        activity?.SetTag("requested.quantity", quantity);

        try
        {
            _logger.LogInformation(
                "Checking inventory for product {ProductId}, quantity {Quantity}",
                productId, quantity);

            // HTTPリクエストは自動的にトレースコンテキストを伝播
            var response = await _httpClient.GetAsync(
                $"/api/inventory/{productId}/availability?quantity={quantity}");

            if (response.IsSuccessStatusCode)
            {
                var result = await response.Content.ReadFromJsonAsync<InventoryResponse>();
                
                activity?.SetTag("inventory.available", result.IsAvailable);
                activity?.SetTag("inventory.current", result.CurrentStock);

                _logger.LogInformation(
                    "Inventory check completed. Available: {IsAvailable}",
                    result.IsAvailable);

                return result.IsAvailable;
            }

            activity?.SetStatus(ActivityStatusCode.Error, 
                $"HTTP {response.StatusCode}");
            
            return false;
        }
        catch (Exception ex)
        {
            activity?.RecordException(ex);
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            
            _logger.LogError(ex, 
                "Error checking inventory for product {ProductId}", 
                productId);
            
            throw;
        }
    }
}

バゲージ(Baggage)の使用

// Middleware/CorrelationIdMiddleware.cs
public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;
    private const string CorrelationIdHeader = "X-Correlation-Id";

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        // バゲージに追加(すべての子スパンに伝播)
        Baggage.SetBaggage("correlation.id", correlationId);
        
        // レスポンスヘッダーに追加
        context.Response.Headers.Add(CorrelationIdHeader, correlationId);

        // ログスコープに追加
        using (context.RequestServices.GetRequiredService<ILogger<CorrelationIdMiddleware>>()
            .BeginScope(new Dictionary<string, object>
            {
                ["CorrelationId"] = correlationId
            }))
        {
            await _next(context);
        }
    }
}

構造化ログの実装

ログエンリッチャー

// Logging/LogEnricher.cs
public class LogEnricher : ILogEventEnricher
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public LogEnricher(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext == null) return;

        // リクエスト情報の追加
        logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
            "RequestPath", httpContext.Request.Path));
        
        logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
            "RequestMethod", httpContext.Request.Method));

        // ユーザー情報の追加
        if (httpContext.User.Identity?.IsAuthenticated == true)
        {
            logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
                "UserId", httpContext.User.FindFirst("sub")?.Value));
        }

        // トレース情報の追加
        var activity = Activity.Current;
        if (activity != null)
        {
            logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
                "TraceId", activity.TraceId.ToString()));
            
            logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
                "SpanId", activity.SpanId.ToString()));
        }

        // バゲージ情報の追加
        foreach (var baggage in Baggage.GetBaggage())
        {
            logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
                $"Baggage_{baggage.Key}", baggage.Value));
        }
    }
}

Serilogの設定

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Serilogの設定
builder.Host.UseSerilog((context, services, configuration) =>
{
    configuration
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext()
        .Enrich.WithMachineName()
        .Enrich.WithEnvironmentName()
        .Enrich.With<LogEnricher>()
        .WriteTo.Console(new JsonFormatter())
        .WriteTo.OpenTelemetry(options =>
        {
            options.Endpoint = context.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] 
                ?? "http://localhost:4317";
            options.ResourceAttributes = new Dictionary<string, object>
            {
                ["service.name"] = context.HostingEnvironment.ApplicationName,
                ["service.version"] = "1.0.0"
            };
        });
});

Grafanaダッシュボードの作成

ダッシュボード定義

{
  "dashboard": {
    "title": "Order Service Dashboard",
    "panels": [
      {
        "title": "Request Rate",
        "targets": [
          {
            "expr": "rate(http_server_request_duration_seconds_count[5m])",
            "legendFormat": "{{method}} {{route}}"
          }
        ],
        "type": "graph"
      },
      {
        "title": "Response Time (p95)",
        "targets": [
          {
            "expr": "histogram_quantile(0.95, rate(http_server_request_duration_seconds_bucket[5m]))",
            "legendFormat": "95th percentile"
          }
        ],
        "type": "graph"
      },
      {
        "title": "Error Rate",
        "targets": [
          {
            "expr": "rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m])",
            "legendFormat": "5xx errors"
          }
        ],
        "type": "graph"
      },
      {
        "title": "Active Orders",
        "targets": [
          {
            "expr": "orders_active",
            "legendFormat": "Active Orders"
          }
        ],
        "type": "stat"
      }
    ]
  }
}

アラートルール

# prometheus/alerts.yml
groups:
  - name: order_service
    interval: 30s
    rules:
      - alert: HighErrorRate
        expr: |
          rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]) 
          / rate(http_server_request_duration_seconds_count[5m]) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "High error rate detected"
          description: "Error rate is above 5% for 5 minutes"
      
      - alert: SlowResponseTime
        expr: |
          histogram_quantile(0.95, rate(http_server_request_duration_seconds_bucket[5m])) > 1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Slow response time"
          description: "95th percentile response time is above 1 second"

ヘルスチェックの実装

詳細なヘルスチェック

// HealthChecks/DatabaseHealthCheck.cs
public class DatabaseHealthCheck : IHealthCheck
{
    private readonly AppDbContext _dbContext;
    private readonly ILogger<DatabaseHealthCheck> _logger;

    public DatabaseHealthCheck(
        AppDbContext dbContext, 
        ILogger<DatabaseHealthCheck> logger)
    {
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var stopwatch = Stopwatch.StartNew();
            
            // データベース接続の確認
            await _dbContext.Database.CanConnectAsync(cancellationToken);
            
            // 簡単なクエリの実行
            var count = await _dbContext.Orders
                .CountAsync(cancellationToken);
            
            stopwatch.Stop();

            var data = new Dictionary<string, object>
            {
                ["database"] = _dbContext.Database.GetDbConnection().Database,
                ["response_time_ms"] = stopwatch.ElapsedMilliseconds,
                ["order_count"] = count
            };

            if (stopwatch.ElapsedMilliseconds > 1000)
            {
                return HealthCheckResult.Degraded(
                    "Database response time is slow",
                    data: data);
            }

            return HealthCheckResult.Healthy(
                "Database is healthy",
                data: data);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Database health check failed");
            
            return HealthCheckResult.Unhealthy(
                "Database connection failed",
                exception: ex);
        }
    }
}

// Program.cs での登録
builder.Services.AddHealthChecks()
    .AddCheck<DatabaseHealthCheck>("database", tags: new[] { "db" })
    .AddRedis("redis", tags: new[] { "cache" })
    .AddRabbitMQ("rabbitmq", tags: new[] { "messaging" });

// ヘルスチェックUIの追加
builder.Services.AddHealthChecksUI(options =>
{
    options.SetEvaluationTimeInSeconds(30);
    options.MaximumHistoryEntriesPerEndpoint(50);
}).AddInMemoryStorage();

パフォーマンス分析

APMとの統合

// Profiling/ProfilingMiddleware.cs
public class ProfilingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ProfilingMiddleware> _logger;
    private readonly DiagnosticSource _diagnosticSource;

    public ProfilingMiddleware(
        RequestDelegate next,
        ILogger<ProfilingMiddleware> logger,
        DiagnosticSource diagnosticSource)
    {
        _next = next;
        _logger = logger;
        _diagnosticSource = diagnosticSource;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();
        var requestId = Guid.NewGuid();

        // リクエスト開始の通知
        if (_diagnosticSource.IsEnabled("Http.Request"))
        {
            _diagnosticSource.Write("Http.Request.Start", new
            {
                RequestId = requestId,
                HttpContext = context,
                Timestamp = DateTimeOffset.UtcNow
            });
        }

        try
        {
            await _next(context);
        }
        finally
        {
            stopwatch.Stop();

            // リクエスト終了の通知
            if (_diagnosticSource.IsEnabled("Http.Request"))
            {
                _diagnosticSource.Write("Http.Request.Stop", new
                {
                    RequestId = requestId,
                    HttpContext = context,
                    Duration = stopwatch.Elapsed,
                    Timestamp = DateTimeOffset.UtcNow
                });
            }

            // 遅いリクエストのログ
            if (stopwatch.ElapsedMilliseconds > 1000)
            {
                _logger.LogWarning(
                    "Slow request detected: {Method} {Path} took {Duration}ms",
                    context.Request.Method,
                    context.Request.Path,
                    stopwatch.ElapsedMilliseconds);
            }
        }
    }
}

トラブルシューティング

デバッグ用エンドポイント

// Controllers/DiagnosticsController.cs
[ApiController]
[Route("api/[controller]")]
public class DiagnosticsController : ControllerBase
{
    private readonly ILogger<DiagnosticsController> _logger;
    private readonly ActivitySource _activitySource;

    public DiagnosticsController(ILogger<DiagnosticsController> logger)
    {
        _logger = logger;
        _activitySource = new ActivitySource("Diagnostics");
    }

    [HttpGet("trace")]
    public IActionResult GetCurrentTrace()
    {
        var activity = Activity.Current;
        if (activity == null)
            return NotFound("No active trace");

        return Ok(new
        {
            TraceId = activity.TraceId.ToString(),
            SpanId = activity.SpanId.ToString(),
            ParentSpanId = activity.ParentSpanId.ToString(),
            OperationName = activity.OperationName,
            StartTime = activity.StartTimeUtc,
            Duration = activity.Duration,
            Tags = activity.Tags.Select(t => new { t.Key, t.Value }),
            Baggage = activity.Baggage.Select(b => new { b.Key, b.Value })
        });
    }

    [HttpPost("test-trace")]
    public async Task<IActionResult> TestDistributedTrace()
    {
        using var activity = _activitySource.StartActivity(
            "TestTrace", 
            ActivityKind.Server);

        _logger.LogInformation("Starting test trace");

        // 複数のスパンを作成
        using (var span1 = _activitySource.StartActivity("DatabaseQuery"))
        {
            await Task.Delay(100);
            span1?.SetTag("db.operation", "SELECT");
        }

        using (var span2 = _activitySource.StartActivity("CacheOperation"))
        {
            await Task.Delay(50);
            span2?.SetTag("cache.operation", "GET");
        }

        return Ok(new { TraceId = activity?.TraceId.ToString() });
    }
}

まとめ

今回は、.NET Aspireでの観測可能性とモニタリングについて学びました。重要なポイント:

  1. 統合された観測可能性: OpenTelemetryによるログ、メトリクス、トレースの統合
  2. 自動計装: 最小限の設定で包括的な監視を実現
  3. 分散トレーシング: サービス間の呼び出しを可視化
  4. カスタムメトリクス: ビジネス固有の指標を追跡
  5. プロアクティブな監視: アラートとダッシュボードによる問題の早期発見

次回は、本番環境へのデプロイメントとスケーリングについて解説します。


次回予告:「第5回:デプロイメントとスケーリング」では、Docker、Kubernetes、Azure Container Appsへのデプロイメント方法と、自動スケーリングの実装について詳しく解説します。

技術的な課題をお持ちですか専門チームがサポートします

記事でご紹介した技術や実装について、
より詳細なご相談やプロジェクトのサポートを承ります。