ردیابی توزیعشده (Distributed Tracing) با OpenTelemetry
یک درخواست در سیستم توزیعشده از چند سرویس رد میشود. ردیابی توزیعشده به هر درخواست یک شناسه میدهد و هر بخش کار را به شکل یک Span با زمان شروع و مدت ثبت میکند. با یک نگاه میبینیم درخواست کجا رفت، کجا گیر کرد و وقتش کجا صرف شد.
نویسنده: bezzad
مشکل: یک سفارش، چهار سرویس، هزاران خط لاگ
در فروشگاه ما، یک سفارش این مسیر را طی میکند:
- سرویس سفارش. درخواست HTTP را میگیرد، سفارش را ذخیره میکند و یک پیام در Kafka میگذارد.
- سرویس Worker. پیام را میخواند و سرویس پرداخت را صدا میزند.
- سرویس پرداخت. با بانک تماس میگیرد.
مشتری زنگ میزند: «پرداخت من خیلی طول کشید.» هر سرویس چند نسخه (Pod) دارد و هر کدام لاگ خودش را مینویسد. سؤالها اینها هستند:
- این درخواست از کدام Pod ها رد شد؟
- از سه ثانیه زمان کل، چقدر در دیتابیس بود، چقدر در صف و چقدر پیش بانک؟
- کدام لاگها مال همین درخواست هستند؟
با لاگ تنها، باید ساعت لاگها را با هم تطبیق دهیم و حدس بزنیم. این کار ساعتها طول میکشد.
ایده: Trace و Span
ردیابی توزیعشده (Distributed Tracing) دو مفهوم ساده دارد:
- مسیر کامل (Trace). کل سفر یک درخواست در همه سرویسها. یک شناسه یکتا دارد که به آن Trace Id میگوییم.
- یک بخش کار (Span). یک کار مشخص داخل این سفر. مثلاً یک کوئری دیتابیس، یک فراخوانی HTTP یا پردازش یک پیام. هر Span زمان شروع، مدت، یک اسم، چند برچسب (Tag) و یک Span والد دارد.
همه Span های یک درخواست Trace Id یکسان دارند. هر Span میداند والدش کیست. پس ابزار میتواند آنها را به شکل یک درخت و یک نمودار آبشاری نشان دهد:
این نمودار جواب سؤالهای بالا را با یک نگاه میدهد. به یک نکته دیگر هم دقت کن: درخواست اصلی بعد از ۶۰ میلیثانیه تمام شد، ولی کار Worker بعداً و جدا انجام شد. Trace کار ناهمزمان را هم نشان میدهد.
انتقال شناسه بین سرویسها
برای اینکه Span های سرویسهای مختلف به یک Trace وصل شوند، هر سرویس باید Trace Id و شناسه Span والد را به سرویس بعدی بدهد. به این کار انتقال زمینه (Context Propagation) میگوییم.
استاندارد W3C Trace Context یک هدر به اسم traceparent تعریف میکند. این هدر چهار بخش دارد:
- در HTTP خودکار است. در .NET، خود ASP.NET Core هدر traceparent را از درخواست میخواند. کلاس HttpClient هم آن را به درخواست بعدی اضافه میکند.
- در صف پیام خودکار نیست. Kafka خودش این کار را انجام نمیدهد. تولیدکننده باید شناسه را در هدر پیام بگذارد. مصرفکننده باید آن را بخواند و Span خودش را فرزند آن بسازد. یا از یک کتابخانه Instrumentation آماده استفاده کن که همین کار را میکند.
اگر این قدم را فراموش کنیم، Trace درست در Kafka پاره میشود. ببین چه اتفاقی میافتد:
کد: راهاندازی OpenTelemetry در .NET
در .NET، کلاس Activity همان Span است و کلاس ActivitySource چیزی است که Span میسازد. این کلاسها خود .NET هستند. OpenTelemetry آنها را جمع میکند و به ابزار نمایش میفرستد.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("orders-api"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation() // incoming HTTP requests
.AddHttpClientInstrumentation() // outgoing HTTP calls
.AddSource(Telemetry.SourceName) // our own spans
.AddOtlpExporter()); // send to the collector
برای کارهای مهم خودمان، Span دستی میسازیم و شناسه سفارش را روی آن میگذاریم. پشتیبانی Trace Id را نمیداند، ولی شماره سفارش را میداند. با این برچسب، از شماره سفارش به Trace میرسیم:
public static class Telemetry
{
public const string SourceName = "Shop.Orders";
public static readonly ActivitySource Source = new(SourceName);
}
public async Task ReserveStockAsync(Order order, CancellationToken ct)
{
using var activity = Telemetry.Source.StartActivity("ReserveStock");
activity?.SetTag("order.id", order.Id);
try
{
await warehouse.ReserveAsync(order.Id, order.Items, ct);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
علامت سؤال بعد از activity مهم است. اگر هیچ کس به این منبع گوش ندهد یا این درخواست برای ذخیره انتخاب نشده باشد، متد StartActivity مقدار null برمیگرداند. این کار عمدی است تا وقتی Trace لازم نیست، هزینهای هم نداشته باشد.
انتقال شناسه از Kafka
این کد با کتابخانه Confluent.Kafka و API انتقال زمینه OpenTelemetry نوشته شده است. سمت تولیدکننده شناسه را در هدر میگذارد:
private static readonly TextMapPropagator Propagator = Propagators.DefaultTextMapPropagator;
public async Task PublishAsync(OrderPlaced message, CancellationToken ct)
{
using var activity = Telemetry.Source.StartActivity("PublishOrderPlaced", ActivityKind.Producer);
var headers = new Headers();
Propagator.Inject(
new PropagationContext(activity?.Context ?? default, Baggage.Current),
headers,
(h, key, value) => h.Add(key, Encoding.UTF8.GetBytes(value)));
await producer.ProduceAsync("orders",
new Message<string, string> { Key = message.OrderId.ToString(), Value = Serialize(message), Headers = headers }, ct);
}
سمت مصرفکننده شناسه را میخواند و Span خودش را فرزند آن میسازد:
var parent = Propagator.Extract(default, result.Message.Headers,
(h, key) => h.TryGetLastBytes(key, out var bytes)
? new[] { Encoding.UTF8.GetString(bytes) }
: Enumerable.Empty<string>());
Baggage.Current = parent.Baggage;
using var activity = Telemetry.Source.StartActivity(
"ProcessOrderPlaced", ActivityKind.Consumer, parent.ActivityContext);
دادهها کجا میروند؟
برنامه داده Trace را با پروتکل استاندارد OTLP میفرستد. معمولاً اول به یک OpenTelemetry Collector میرود. Collector داده را جمع میکند، فیلتر میکند و به ابزار نمایش میفرستد. ابزارهایی مثل Jaeger، Grafana Tempo یا Zipkin. چون پروتکل استاندارد است، عوض کردن ابزار نمایش به تغییر کد برنامه نیاز ندارد.
نمونهبرداری (Sampling)
ذخیره Trace همه درخواستها در سیستم پرترافیک خیلی گران است. پس فقط بخشی را نگه میداریم. دو روش داریم:
از اول درخواست (Head-based)
- اولین سرویس تصمیم میگیرد. مثلاً ده درصد درخواستها.
- تصمیم در پرچم آخر traceparent به سرویسهای بعدی میرود. پس Trace نصفه نمیماند.
- ساده و ارزان است.
- عیب: هنوز نمیداند درخواست خطا میدهد یا نه. شاید Trace خطای مهم دور ریخته شود.
بعد از تمام شدن (Tail-based)
- بعد از تمام شدن Trace تصمیم میگیریم. معمولاً در Collector.
- همه Trace های خطادار و کند را نگه میداریم. از Trace های عادی فقط درصد کمی.
- عیب: Collector باید همه Span ها را مدتی در حافظه نگه دارد. پیچیدهتر و گرانتر است.
نمونهبرداری از اول درخواست در .NET این شکل است. ParentBased یعنی اگر سرویس قبلی تصمیم گرفته، همان را دنبال کن:
tracing.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1)));
قانونهای مهم
- انتقال زمینه را در همه مرزها چک کن. HTTP، صف پیام، کارهای پسزمینه. هر جا شناسه گم شود، Trace پاره میشود.
- شناسه کسبوکار روی Span. شماره سفارش، نه داده حساس.
- شناسه Trace را در لاگها هم بگذار. از لاگ خطا مستقیم به Trace برسی، و برعکس.
- برای کارهای مهم Span دستی بساز، ولی نه برای هر متد. Span زیاد هم هزینه دارد و هم نمودار را شلوغ میکند.
- اسم Span کوتاه و ثابت باشد. شناسهها را در اسم نگذار. آنها را برچسب کن. اسم ثابت اجازه میدهد Span های مشابه را با هم مقایسه کنیم.
- خطا را روی Span علامت بزن. وگرنه در ابزار نمایش، Span شکستخورده سبز دیده میشود.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| انتقال ندادن شناسه در Kafka | Trace در صف پاره میشود و دو تکه بیربط داریم. | گذاشتن traceparent در هدر پیام و خواندن آن. |
| ساختن Trace Id دستی و جدا از استاندارد | ابزارها و کتابخانهها آن را نمیشناسند. | استاندارد W3C و کلاس Activity. |
| گذاشتن شناسه سفارش در اسم Span | هزاران اسم مختلف، مقایسه ممکن نیست. | اسم ثابت، شناسه به شکل برچسب. |
| ذخیره صد درصد Trace ها در ترافیک بالا | هزینه ذخیره خیلی زیاد. | نمونهبرداری، و نگه داشتن همه خطاها. |
| فقط نمونهبرداری ثابت ده درصدی | Trace خطای مهم شاید دور ریخته شود. | نمونهبرداری بعد از تمام شدن برای خطاها. |
| داده حساس در برچسبها | توکن یا شماره کارت در ابزار نمایش. | فقط شناسهها و داده بیخطر. |
خلاصه در شش خط
- یک Trace کل مسیر یک درخواست است. هر Span یک بخش از کار با زمان و والد است.
- نمودار آبشاری نشان میدهد وقت کجا صرف شده است. دیگر لازم نیست حدس بزنیم.
- شناسه با هدر traceparent بین سرویسها میرود. در HTTP خودکار است، در Kafka نه.
- در .NET کلاس Activity همان Span است و OpenTelemetry آن را جمع میکند و میفرستد.
- شماره سفارش را روی Span و Trace Id را روی لاگها بگذار.
- برای کنترل هزینه نمونهبرداری کن، ولی Trace های خطادار را نگه دار.