Chuyển tới nội dung chính

15.11 — 10. .NET Aspire (.NET 8+)

Tóm tắt

Aspire thay thế đúng một thứ: file docker-compose.yaml mà bạn dùng để chạy toàn bộ stack lúc phát triển. Thay vì YAML, bạn mô tả stack bằng C# — và nhờ đó nó làm được ba việc YAML không làm được: tự tiêm connection string vào đúng dự án (không phải chép tay chuỗi kết nối vào biến môi trường), service discovery để bạn gọi http://payment-service thay vì nhớ cổng, và một dashboard OpenTelemetry hiện log, trace, metric của toàn bộ stack ngay lập tức. Hiểu lầm phổ biến nhất: Aspire không phải công cụ triển khai — nó sinh manifest để azd hoặc aspirate đọc, nhưng bản thân nó không deploy gì cả.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Dựng AppHost mô tả stack bằng C#.
  • Dùng service discovery và tiêm connection string.
  • Đọc dashboard để chẩn đoán.
  • Dùng ServiceDefaults cho cấu hình chung.
  • Biết Aspire làm gì và không làm gì ở production.

Nội dung bài học​

15.11.1 — Vấn đề nó giải​

Chạy một stack .NET lúc phát triển thường cần: SQL Server, Redis, RabbitMQ, API, worker — cộng cấu hình để chúng tìm thấy nhau.

Với Docker Compose, bạn viết YAML và chép tay connection string vào biến môi trường. Đổi tên dịch vụ hay cổng là sửa nhiều chỗ, và sai một chỗ thì lỗi lúc chạy.

// AppHost/Program.cs — toàn bộ stack bằng C#
var builder = DistributedApplication.CreateBuilder(args);

var password = builder.AddParameter("sql-password", secret: true);

var sql = builder.AddSqlServer("sql", password)
.WithDataVolume() // dữ liệu sống qua restart
.AddDatabase("crmdb");

var redis = builder.AddRedis("redis")
.WithRedisCommander(); // giao dien quan tri

var rabbit = builder.AddRabbitMQ("messaging")
.WithManagementPlugin();

var api = builder.AddProject<Projects.CrmApi>("crm-api")
.WithReference(sql)
.WithReference(redis)
.WithReference(rabbit)
.WaitFor(sql) // CHO den khi san sang
.WithReplicas(2);

builder.AddProject<Projects.CrmWorker>("crm-worker")
.WithReference(sql)
.WithReference(rabbit)
.WaitFor(rabbit);

builder.Build().Run();

Ba điều YAML không làm được:

WithReference(sql) tự tiêm connection string. Trong API, bạn chỉ cần:

builder.AddSqlServerDbContext<CrmDbContext>("crmdb");

Không có chuỗi kết nối nào trong appsettings.json, không có biến môi trường nào phải đặt tay.

WaitFor(sql) giải quyết đúng vấn đề depends_on của Compose (bài 15.4) — nhưng ở đây nó là một dòng, không cần viết healthcheck.

WithReplicas(2) chạy hai bản API lúc dev — hữu ích để phát hiện sớm những vấn đề chỉ xuất hiện khi nhiều instance: cache trong tiến trình lệch nhau (bài 14.5), job chạy trùng (bài 14.7), SignalR thiếu backplane (bài 11.11).

Đây là giá trị bị đánh giá thấp nhất: những lỗi đó thường chỉ lộ ra trên staging, và Aspire đưa chúng về máy dev.

15.11.2 — ServiceDefaults​

Aspire sinh một project ServiceDefaults chứa cấu hình chung:

public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
builder.ConfigureOpenTelemetry();

builder.AddDefaultHealthChecks();

builder.Services.AddServiceDiscovery();

builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler(); // retry, circuit breaker, timeout
http.AddServiceDiscovery();
});

return builder;
}
// Trong moi project
builder.AddServiceDefaults();

Một dòng đó cho bạn: OpenTelemetry đầy đủ (bài 15.9), health check (bài 8.10), và resilience handler cho mọi HttpClient (bài 14.10).

ServiceDefaults dùng được ở production, kể cả khi bạn không dùng Aspire để chạy. Đó là một project bình thường trong solution — nhiều đội lấy riêng phần này.

Service discovery cho phép gọi bằng tên logic:

builder.Services.AddHttpClient<PaymentClient>(c =>
c.BaseAddress = new Uri("https+http://payment-service"));

https+http:// nghĩa là "dùng HTTPS nếu có, không thì HTTP". Aspire phân giải tên thành endpoint thật qua biến môi trường services__payment-service__https__0.

Ở production, cùng cơ chế đó đọc từ cấu hình — nên code không đổi giữa dev và production.

15.11.3 — Dashboard​

Chạy AppHost và mở dashboard (mặc định http://localhost:15888):

TabCho biết
ResourcesMọi dịch vụ, trạng thái, endpoint, biến môi trường
Console logsLog của từng dịch vụ, gom về một chỗ
Structured logsLog có cấu trúc, lọc được theo thuộc tính
TracesTrace phân tán qua nhiều service
MetricsMetric runtime và ứng dụng

Tab Traces là thứ đáng giá nhất: bạn thấy ngay một request đi qua API → database → message bus mất bao lâu ở mỗi chặng. Với Docker Compose, để có điều này bạn phải dựng Jaeger hoặc Seq và cấu hình exporter.

Dashboard dùng được độc lập với Aspire:

docker run -p 18888:18888 -p 4317:18889 \
mcr.microsoft.com/dotnet/aspire-dashboard:latest

Trỏ OTEL_EXPORTER_OTLP_ENDPOINT vào nó và bạn có một công cụ xem trace nhẹ cho bất kỳ ứng dụng nào — kể cả khi không dùng Aspire.

15.11.4 — Aspire không phải công cụ triển khai​

Đây là hiểu lầm phổ biến nhất.

# Aspire SINH manifest
dotnet run --project AppHost -- --publisher manifest --output-path manifest.json

Manifest mô tả stack, nhưng cần công cụ khác để triển khai:

Công cụTriển khai tới
azd (Azure Developer CLI)Azure Container Apps
Aspirate (cộng đồng)Kubernetes manifest
Tự viếtBất cứ đâu

Nếu bạn triển khai lên VPS bằng Docker Compose, Aspire không giúp gì ở khâu đó — bạn vẫn cần compose.yaml riêng cho production.

Đó là lý do khuyến nghị thực dụng: Aspire cho dev, Compose hoặc Kubernetes cho production. Hai file mô tả hai thứ khác nhau, và điều đó chấp nhận được vì chúng phục vụ mục đích khác nhau.

Cái giá: mô tả stack tồn tại ở hai nơi và có thể lệch nhau. Giảm rủi ro bằng cách giữ cùng danh sách dịch vụ và cùng tên biến cấu hình ở cả hai.

15.11.5 — Aspire hay Compose​

AspireDocker Compose
Mô tả bằngC#YAML
Tiêm connection stringTự độngChép tay
Service discoveryCó sẵnTên dịch vụ trong mạng
DashboardCó sẵnPhải tự dựng
Trace phân tán lúc devCó sẵnPhải tự dựng
Chạy dịch vụ không phải .NETCó (container)Có
Dùng ở productionKhông trực tiếpCó
Người không viết .NET dùng đượcKhôngCó

Hai hàng cuối là giới hạn thật. Nếu đội có người làm frontend hoặc DevOps không viết C#, compose.yaml là ngôn ngữ chung; AppHost bằng C# thì không.

Aspire đáng dùng khi: stack có nhiều dự án .NET, bạn muốn trace phân tán ngay lúc dev, và cả đội viết C#.

Compose đáng giữ khi: cần cùng một file cho dev và production, hoặc đội không thuần .NET.

Nhiều đội dùng cả hai: Aspire cho vòng lặp phát triển hằng ngày, Compose cho CI và staging. Không có xung đột kỹ thuật nào giữa chúng.

15.11.6 — Rà lại code của bạn​

Danh sách rà soát Aspire

  • •Đã hiểu Aspire không tự triển khai; cần azd, Aspirate hoặc tự viết.
  • •AppHost dùng WaitFor cho dịch vụ cần thời gian khởi động.
  • •Secret dùng AddParameter với secret: true, không viết thẳng.
  • •Dữ liệu dev dùng WithDataVolume để sống qua restart.
  • •Chạy WithReplicas nhiều hơn 1 để phát hiện sớm lỗi đa instance.
  • •Mọi project gọi AddServiceDefaults.
  • •ServiceDefaults được dùng cả ở production, không chỉ lúc dev.
  • •Dùng service discovery thay vì hardcode cổng.
  • •Nếu giữ cả Compose thì danh sách dịch vụ và tên biến khớp nhau.
  • •Đã cân nhắc dashboard Aspire độc lập cho ứng dụng không dùng Aspire.

Bài tập áp dụng​

Bài 1 — Lỗi đa instance lộ ra khi chạy nhiều replica​

Chạy AppHost với WithReplicas(3) và một ứng dụng dùng IMemoryCache cho dữ liệu chia sẻ. Cập nhật qua một instance và gọi API nhiều lần, đếm tỷ lệ nhận dữ liệu cũ.

Tiêu chí hoàn thành: bạn nêu được vì sao đây là giá trị lớn nhất của Aspire với một lập trình viên, và liệt kê được những lỗi đa instance khác mà cách này phát hiện được.

Gợi ý và lời giải — Bài 1

Gợi ý. Trước Aspire, bạn chạy mấy instance trên máy dev?

Lời giải:

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

var redis = builder.AddRedis("cache");
var sql = builder.AddSqlServer("sql").AddDatabase("crm");

builder.AddProject<Projects.Crm_Api>("api")
.WithReference(redis)
.WithReference(sql)
.WithReplicas(3); // <- ba instance trên máy dev

builder.Build().Run();
// Code có lỗi — IMemoryCache cho dữ liệu chia sẻ
app.MapGet("/config/{key}", async (string key, IMemoryCache cache, CrmDbContext db) =>
await cache.GetOrCreateAsync($"config:{key}", async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
return await db.Configs.FirstOrDefaultAsync(c => c.Key == key);
}));

app.MapPut("/config/{key}", async (string key, string value, IMemoryCache cache, CrmDbContext db) =>
{
var cfg = await db.Configs.FirstAsync(c => c.Key == key);
cfg.Value = value;
await db.SaveChangesAsync();
cache.Remove($"config:{key}"); // chỉ xoá cache của INSTANCE NÀY
return Results.NoContent();
});
curl -X PUT "http://localhost:5000/config/gioi-han?value=100"

for i in $(seq 1 90); do
curl -s http://localhost:5000/config/gioi-han | jq -r .value
done | sort | uniq -c
     30 100          <- instance đã nhận PUT
60 50 <- hai instance còn lại, dữ liệu cũ

66,7% dữ liệu cũ — đúng công thức (N-1)/N ở bài 14.4.

Vì sao đây là giá trị lớn nhất của Aspire với một lập trình viên. Vì nó làm cho môi trường dev giống production về mặt cấu trúc, và cấu trúc mới là nơi loại lỗi này sinh ra.

Trước Aspire:
dotnet run -> 1 instance
docker compose up -> 1 replica (thường)
Integration test -> 1 tiến trình
Production -> 3–10 pod
^^^^^^^^^^ lần đầu tiên code chạy đa instance
là trên production, với người dùng thật

Với Aspire:
F5 -> 3 instance, ngay trên máy dev

Lỗi đa instance có một đặc điểm chung: code hoàn toàn đúng khi chạy một mình. Không có phân tích tĩnh nào, không có unit test nào, không có code review nào bắt được — vì không có gì sai trong code. Sai ở giả định về môi trường.

Cách duy nhất để phát hiện là chạy nhiều instance. Aspire biến việc đó từ một bài tập phải chuẩn bị thành một dòng cấu hình.

Những lỗi đa instance khác mà cách này phát hiện được:

LỗiTriệu chứng khi chạy 3 instance
IMemoryCache cho dữ liệu chia sẻDữ liệu cũ, tỷ lệ (N-1)/N
BackgroundService chạy trên mọi instanceJob chạy 3 lần — bài 14.11
Trạng thái trong biến staticBộ đếm sai, session lẫn lộn
Rate limit trong bộ nhớGiới hạn thật là 3× giới hạn cấu hình
Session không có backplaneNgười dùng bị đăng xuất ngẫu nhiên
SignalR không có backplaneTin nhắn chỉ tới một phần người nhận
File tạm lưu trên đĩa cục bộUpload nhiều phần thất bại
Khoá bằng lock của C#Không khoá được gì giữa các tiến trình
Sinh số tuần tự trong bộ nhớTrùng số
Data protection key không chia sẻCookie và token của instance này instance kia không đọc được

Hai dòng cuối đáng chú ý vì chúng là những lỗi khó chẩn đoán nhất:

// Data protection key mặc định lưu trong bộ nhớ hoặc thư mục cục bộ
// -> mỗi instance có khoá riêng
// -> cookie xác thực do instance 1 phát hành, instance 2 không giải mã được
// -> người dùng bị đăng xuất ở khoảng 2/3 số request

builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
.SetApplicationName("crm"); // phải giống nhau ở mọi instance

Không có hai dòng này, ứng dụng chạy hoàn hảo với một instance và đăng xuất người dùng ngẫu nhiên với nhiều instance — một triệu chứng thường bị đổ cho "lỗi trình duyệt" trong nhiều tuần.

Bản sửa cho bài toán ở đầu bài:

// Dùng HybridCache — L1 trong bộ nhớ, L2 Redis, đồng bộ tự động
builder.AddRedisDistributedCache("cache");
builder.Services.AddHybridCache(o =>
{
o.DefaultEntryOptions = new HybridCacheEntryOptions
{
LocalCacheExpiration = TimeSpan.FromSeconds(30),
Expiration = TimeSpan.FromMinutes(30),
};
});
await _cache.RemoveAsync($"config:{key}", ct);     // xoá ở MỌI instance
     90 100          <- cả 90 lần đều đúng

Quy trình nên áp dụng cho mọi dự án dùng Aspire:

#if DEBUG
.WithReplicas(3) // luôn chạy nhiều instance khi phát triển
#endif

Và một danh sách rà soát khi thấy hành vi lạ với nhiều replica:

# Trạng thái static — gần như luôn là lỗi
grep -rn "static.*=.*new\|private static.*{.*get.*set" --include="*.cs" src/ \
| grep -v "readonly\|const\|ILogger\|Meter\|ActivitySource"

# IMemoryCache — kiểm tra từng chỗ xem dữ liệu có phải chia sẻ không
grep -rn "IMemoryCache" --include="*.cs" src/

# BackgroundService trong project API
grep -rn "AddHostedService\|BackgroundService" --include="*.cs" src/Crm.Api/

# lock của C# — không khoá được gì giữa các tiến trình
grep -rn "lock (" --include="*.cs" src/

Và một giới hạn cần nói rõ: WithReplicas chạy nhiều tiến trình trên cùng một máy, nên nó không tái hiện được các vấn đề mạng — độ trễ giữa các vùng, network partition, đồng hồ lệch nhau, hay DNS thay đổi. Nó bắt được lỗi về trạng thái chia sẻ, không bắt được lỗi về phân tán thật.

Đó vẫn là phần lớn các lỗi đa instance mà một ứng dụng nghiệp vụ gặp phải — nhưng đừng nhầm "chạy được với 3 replica trên máy dev" với "sẵn sàng cho hệ phân tán".


Bài 2 — Trace phân tán trong dashboard​

Dựng API gọi một service khác và một database, mở tab Traces, và xác định chặng nào chiếm nhiều thời gian nhất.

Tiêu chí hoàn thành: bạn đọc được waterfall và phân biệt được lời gọi tuần tự với lời gọi song song, và biết đọc khoảng trống giữa các span.

Gợi ý và lời giải — Bài 2

Gợi ý. Nếu hai span bắt đầu cùng lúc, chúng chạy song song. Nếu span thứ hai bắt đầu khi span thứ nhất kết thúc, chúng tuần tự.

Lời giải — Aspire bật OpenTelemetry sẵn:

// ServiceDefaults/Extensions.cs — đã có sẵn khi tạo project Aspire
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation());
app.MapGet("/leads/{id}/tong-quan", async (
int id, CrmDbContext db, IScoringClient scoring, IEmailClient email, CancellationToken ct) =>
{
var lead = await db.Leads.FirstAsync(l => l.Id == id, ct);
var diem = await scoring.TinhDiemAsync(lead.Email, ct);
var lichSu = await email.LayLichSuAsync(lead.Email, ct);
var ghiChu = await db.GhiChu.Where(g => g.LeadId == id).ToListAsync(ct);

return new TongQuanDto(lead.Name, diem, lichSu, ghiChu);
});

Waterfall trong dashboard:

GET /leads/123/tong-quan                                     2.184 ms  ████████████████████
├─ SELECT Leads WHERE Id = @p0 14 ms █
├─ HTTP GET scoring-api/score 892 ms ████████
├─ HTTP GET email-api/history 1.264 ms ███████████
└─ SELECT GhiChu WHERE LeadId = @p0 11 ms █

Đọc waterfall — ba thứ cần nhìn:

1. Độ dài của span — chặng nào tốn nhất. Ở đây: email-api 1.264 ms, scoring-api 892 ms.

2. Vị trí bắt đầu — tuần tự hay song song:

Tuần tự (ở trên):
├─ A ████
├─ B ████████ <- bắt đầu khi A kết thúc
└─ C ████ <- bắt đầu khi B kết thúc
Tổng = A + B + C

Song song:
├─ A ████
├─ B ████████ <- bắt đầu CÙNG LÚC với A
└─ C ████
Tổng = max(A, B, C)

Đây là thông tin mà chỉ trace cho bạn — log ghi cả bốn lời gọi nhưng không thể hiện quan hệ thời gian giữa chúng.

3. Khoảng trống giữa các span — thời gian không thuộc về span nào:

├─ HTTP GET scoring-api          892 ms  ████████
│ ░░░ <- 180 ms khoảng trống
├─ HTTP GET email-api 1.264 ms ███████████

Khoảng trống là thời gian trong code của bạn — deserialize, map, tính toán, hoặc chờ một tài nguyên (luồng, kết nối) mà bạn chưa tạo span cho nó. Khoảng trống lớn thường là dấu hiệu:

- Deserialize một payload lớn
- Chờ luồng trong ThreadPool (dấu hiệu cạn luồng)
- Chờ kết nối từ pool
- Một phép tính đồng bộ nặng

Tối ưu — hai lời gọi độc lập chạy song song:

var lead = await db.Leads.FirstAsync(l => l.Id == id, ct);

// Hai lời gọi không phụ thuộc nhau
var diemTask = scoring.TinhDiemAsync(lead.Email, ct);
var lichSuTask = email.LayLichSuAsync(lead.Email, ct);
var ghiChuTask = db.GhiChu.Where(g => g.LeadId == id).ToListAsync(ct);

await Task.WhenAll(diemTask, lichSuTask, ghiChuTask);

return new TongQuanDto(lead.Name, await diemTask, await lichSuTask, await ghiChuTask);
GET /leads/123/tong-quan                                     1.301 ms  ████████████
├─ SELECT Leads WHERE Id = @p0 14 ms █
├─ HTTP GET scoring-api/score 892 ms ████████
├─ HTTP GET email-api/history 1.264 ms ███████████
└─ SELECT GhiChu WHERE LeadId = @p0 11 ms █

Từ 2.184 ms xuống 1.301 ms — giờ tổng thời gian bằng lời gọi chậm nhất thay vì tổng mọi lời gọi.

Một cảnh báo quan trọng về DbContext: đoạn trên chạy song song một truy vấn EF Core với hai lời gọi HTTP, và điều đó an toàn vì chỉ có một truy vấn EF Core. Chạy hai truy vấn EF Core song song trên cùng DbContext sẽ ném exception:

System.InvalidOperationException: A second operation was started on this context
instance before a previous operation completed.

DbContext không thread-safe (bài 13.2). Nếu cần nhiều truy vấn song song, dùng IDbContextFactory:

await using var db1 = await _factory.CreateDbContextAsync(ct);
await using var db2 = await _factory.CreateDbContextAsync(ct);
var t1 = db1.Leads.ToListAsync(ct);
var t2 = db2.GhiChu.ToListAsync(ct);
await Task.WhenAll(t1, t2);

Bốn mẫu hình nhận ra ngay trong waterfall:

1. N+1 — nhiều span giống hệt nhau, ngắn, liên tiếp
├─ SELECT Leads WHERE CustomerId = @p0 4 ms ▌
├─ SELECT Leads WHERE CustomerId = @p0 4 ms ▌
├─ ... (lặp 500 lần)

2. Tuần tự không cần thiết — các span nối đuôi nhau, không phụ thuộc nhau
├─ HTTP GET api-a ████
├─ HTTP GET api-b ████
├─ HTTP GET api-c ████

3. Một chặng át hẳn — 90% thời gian ở một span
├─ HTTP GET cham-api ████████████████████

4. Khoảng trống lớn — thời gian không thuộc span nào
├─ A ███
│ ░░░░░░░░░░ <- 800 ms không giải thích được
├─ B ███

Mẫu 4 thường bị bỏ qua nhất, và nó hay là dấu hiệu của cạn ThreadPool — thứ mà không metric nào trỏ thẳng vào.

Thêm span cho code của chính bạn, để lấp khoảng trống:

private static readonly ActivitySource _source = new("Crm.Api");

using var activity = _source.StartActivity("TinhTongQuan");
activity?.SetTag("lead.id", id);
activity?.SetTag("so.ghi.chu", ghiChu.Count);
// Đăng ký source — nếu quên, span không xuất hiện
.WithTracing(t => t.AddSource("Crm.*"))

Và giới hạn của dashboard Aspire: nó lưu trace trong bộ nhớ và mất hết khi dừng AppHost. Nó là công cụ để phát triển và chẩn đoán cục bộ, không phải hệ thống giám sát.

Điểm hay là bạn không phải đổi code khi lên production — cùng một cấu hình OpenTelemetry, chỉ đổi endpoint xuất:

// Dev: dashboard Aspire nhận qua OTLP
// Production: đổi biến môi trường, code không đổi
// OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com

Đây là giá trị thứ hai của Aspire sau WithReplicas: nó khiến khả năng quan sát trở thành mặc định trên máy dev, nên bạn quen đọc trace trước khi cần chúng để xử lý sự cố.


Bài 3 — Dashboard độc lập cho ứng dụng không dùng Aspire​

Chạy container dashboard Aspire, trỏ một ứng dụng .NET không dùng Aspire vào nó qua OTLP, và xác nhận trace hiện lên.

Tiêu chí hoàn thành: bạn giải thích được vì sao cách này hoạt động, và nêu được ranh giới giữa Aspire như công cụ phát triển và như công cụ triển khai.

Gợi ý và lời giải — Bài 3

Gợi ý. Dashboard nhận dữ liệu qua giao thức gì? Giao thức đó có phải của riêng Aspire không?

Lời giải:

docker run --rm -it \
-p 18888:18888 \
-p 4317:18889 \
-e DASHBOARD__FRONTEND__AUTHMODE=Unsecured \
--name aspire-dashboard \
mcr.microsoft.com/dotnet/aspire-dashboard:9.0
Login to the dashboard at http://localhost:18888
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.SqlClient
// Một ứng dụng ASP.NET Core BÌNH THƯỜNG, không có gì của Aspire
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("ung-dung-cu", serviceVersion: "1.0.0"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());

builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeScopes = true;
o.IncludeFormattedMessage = true;
o.AddOtlpExporter();
});
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_SERVICE_NAME=ung-dung-cu
dotnet run

Mở http://localhost:18888 — trace, metric và log của ứng dụng cũ hiện lên đầy đủ.

Vì sao cách này hoạt động. Vì dashboard Aspire không phải một thành phần của Aspire theo nghĩa ràng buộc. Nó là một OTLP receiver tiêu chuẩn:

OTLP (OpenTelemetry Protocol) là chuẩn MỞ của CNCF.

Mọi thứ nói OTLP đều gửi được vào dashboard:
ứng dụng .NET (có hoặc không có Aspire)
Java, Python, Go, Node.js, Rust
OpenTelemetry Collector
hạ tầng có instrumentation OTLP

Aspire AppHost chỉ làm một việc: tự động đặt biến môi trường OTEL_EXPORTER_OTLP_ENDPOINT cho các project mà nó khởi chạy. Đó là toàn bộ "phép màu". Đặt tay biến đó thì kết quả giống hệt.

Điều này có nghĩa là bạn dùng được dashboard ngay hôm nay, cho dự án hiện tại, mà không cần chuyển sang Aspire:

# docker-compose.yml — thêm vào stack dev hiện có
services:
aspire-dashboard:
image: mcr.microsoft.com/dotnet/aspire-dashboard:9.0
environment:
DASHBOARD__FRONTEND__AUTHMODE: Unsecured
ports:
- "127.0.0.1:18888:18888"
- "127.0.0.1:4317:18889"

api:
build: .
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: http://aspire-dashboard:18889
OTEL_SERVICE_NAME: crm-api

Đây thường là bước đầu tiên tốt nhất để cải thiện khả năng quan sát của một dự án đang chạy: một container, vài dòng cấu hình, không đổi kiến trúc.

Ranh giới: Aspire như công cụ phát triển và như công cụ triển khai.

Aspire gồm ba phần độc lập, và chúng không buộc phải đi cùng nhau:

PhầnLà gìDùng ở đâu
AppHostMô tả stack bằng C#, khởi chạy dịch vụChỉ máy dev
ServiceDefaultsGói cấu hình OpenTelemetry, health check, resilienceDev và production
DashboardOTLP receiver và giao diệnChủ yếu dev, dùng được ở staging

AppHost không phải công cụ triển khai. Nó chạy các tiến trình cục bộ và container qua Docker trên một máy. Nó không có: lập lịch, tự phục hồi, scale, rolling update, phân bổ tài nguyên, hay mạng đa máy.

azd (Azure Developer CLI) đọc mô hình AppHost và sinh ra manifest cho Container Apps hoặc Kubernetes:

azd init
azd up
// aspire-manifest.json — sinh ra, rồi công cụ khác dùng nó để triển khai
{
"resources": {
"api": {
"type": "project.v0",
"path": "../Crm.Api/Crm.Api.csproj",
"env": { "ConnectionStrings__crm": "{sql.connectionString}" }
}
}
}

Nhưng thứ chạy trên production là Container Apps hoặc Kubernetes, không phải AppHost.

ServiceDefaults thì ngược lại — nó thuộc về production:

// Program.cs của ứng dụng — chạy ở MỌI môi trường
builder.AddServiceDefaults();
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}

Đây là một tập cấu hình mặc định tốt, và nó chạy được ở mọi nơi. Bạn thậm chí sao chép file này vào một dự án không dùng Aspire và nó vẫn hoạt động.

Dashboard ở staging:

Dùng được:  staging, môi trường thử nghiệm, chẩn đoán tạm thời
Không nên: production

Lý do:

  • Lưu trong bộ nhớ — mất hết khi restart, không truy vấn được dữ liệu cũ.
  • Không có cảnh báo — nó là công cụ xem, không phải hệ thống giám sát.
  • Không scale — một instance, không có lưu trữ phân tán.
  • Xác thực đơn giản — Unsecured chỉ chấp nhận được sau một mạng riêng.

Cho production, xuất cùng dữ liệu OTLP đó sang một hệ thống thật:

Ứng dụng -> OTLP -> OpenTelemetry Collector -> Prometheus / Jaeger / Grafana
-> Azure Monitor
-> Datadog / Honeycomb / Grafana Cloud

Và điểm quan trọng nhất: code không đổi. Cùng một cấu hình OpenTelemetry, chỉ đổi biến môi trường:

# Dev
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

# Production
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317
OTEL_EXPORTER_OTLP_HEADERS=api-key=...

Đây chính là lý do OpenTelemetry đáng đầu tư: nó tách việc tạo dữ liệu quan sát khỏi việc chọn hệ thống lưu trữ nó. Đổi nhà cung cấp giám sát trở thành một thay đổi cấu hình thay vì một dự án viết lại instrumentation.

Tự kiểm tra​

Câu hỏi thường gặp

Aspire thay thế cái gì?

Đúng một thứ: file docker-compose dùng để chạy toàn bộ stack lúc phát triển. Nó mô tả stack bằng C# thay vì YAML, nhờ đó tự tiêm connection string, có service discovery, và có dashboard OpenTelemetry sẵn.

WithReference làm gì?

Nó tự tiêm connection string của tài nguyên vào dự án, nên trong code bạn chỉ cần gọi AddSqlServerDbContext với tên logic, không có chuỗi kết nối nào trong appsettings hay biến môi trường phải đặt tay.

WithReplicas lúc dev có ích gì?

Nó chạy nhiều bản ứng dụng ngay trên máy dev, giúp phát hiện sớm những lỗi chỉ xuất hiện khi có nhiều instance: cache trong tiến trình lệch nhau, job định kỳ chạy trùng, SignalR thiếu backplane. Những lỗi đó thường chỉ lộ ra trên staging.

ServiceDefaults cho gì?

Một lời gọi AddServiceDefaults cấu hình OpenTelemetry đầy đủ, health check, service discovery, và resilience handler cho mọi HttpClient. Nó là một project bình thường nên dùng được cả ở production dù bạn không chạy Aspire.

Aspire có triển khai được lên production không?

Không trực tiếp. Nó sinh manifest mô tả stack, còn việc triển khai cần azd cho Azure Container Apps, Aspirate cho Kubernetes, hoặc công cụ tự viết. Nếu bạn triển khai lên VPS bằng Compose thì vẫn cần file compose riêng cho production.

Khi nào nên giữ Docker Compose thay vì chuyển sang Aspire?

Khi cần cùng một file cho cả dev và production, hoặc khi đội có người không viết C# vì YAML là ngôn ngữ chung còn AppHost bằng C# thì không. Nhiều đội dùng cả hai: Aspire cho vòng lặp phát triển, Compose cho CI và staging.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Aspire là công cụ phát triển, không phải công cụ triển khai.
  2. WithReplicas lúc dev phát hiện sớm lỗi đa instance — giá trị bị đánh giá thấp nhất.
  3. ServiceDefaults và dashboard dùng được độc lập, kể cả khi không chạy Aspire.

Tham khảo​

Điều hướng​