【.NET Aspire入門】第4回:観測可能性とモニタリング
.NETAspireOpenTelemetryPrometheusGrafanaモニタリング
はじめに
マイクロサービスアーキテクチャでは、システムの健全性を維持するために観測可能性(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での観測可能性とモニタリングについて学びました。重要なポイント:
- 統合された観測可能性: OpenTelemetryによるログ、メトリクス、トレースの統合
- 自動計装: 最小限の設定で包括的な監視を実現
- 分散トレーシング: サービス間の呼び出しを可視化
- カスタムメトリクス: ビジネス固有の指標を追跡
- プロアクティブな監視: アラートとダッシュボードによる問題の早期発見
次回は、本番環境へのデプロイメントとスケーリングについて解説します。
次回予告:「第5回:デプロイメントとスケーリング」では、Docker、Kubernetes、Azure Container Appsへのデプロイメント方法と、自動スケーリングの実装について詳しく解説します。