Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang vận hành một hệ thống thương mại điện tử kiểu Tiki hay Shopee. Người dùng bấm nút "Đặt hàng" và... phải chờ 8 giây mới thấy màn hình xác nhận. Tám giây trong thế giới e-commerce là một sự tra tấn. Sếp gọi bạn lên hỏi: "Vì sao chậm? Fix ngay."
Vấn đề là: một cú bấm "Đặt hàng" ấy không chỉ chạm vào một service. Nó đi qua API Gateway, rồi tới Auth Service để kiểm tra token, sang Cart Service để lấy giỏ hàng, gọi Inventory Service để trừ kho, gọi Pricing Service để tính khuyến mãi, ghé Payment Service để trừ tiền, rồi Notification Service để bắn email. Mười service, có khi hơn. Câu hỏi "service nào chậm?" nghe đơn giản nhưng nếu bạn chỉ có log rời rạc của từng service, bạn sẽ ngồi đối chiếu timestamp bằng mắt cả buổi chiều mà vẫn không chắc.
Ở thời monolith, mọi thứ chạy trong một process. Bạn bật profiler, xem stack trace, thấy ngay hàm nào ngốn thời gian. Nhưng khi hệ thống vỡ ra thành hàng chục microservices chạy trên hàng trăm pod Kubernetes, "stack trace" không còn nằm gọn trong một máy nữa — nó trải dài qua mạng, qua nhiều container, nhiều ngôn ngữ. Đây chính là lý do Distributed Tracing ra đời: nó khâu lại toàn bộ hành trình của một request xuyên suốt hệ thống phân tán, cho bạn thấy chính xác thời gian tiêu tốn ở đâu và lỗi phát sinh từ đâu. Bài này sẽ giúp bạn nắm vững khái niệm tracing, hiểu OpenTelemetry và Jaeger — hai công cụ chuẩn công nghiệp — và biết cách triển khai để không bao giờ phải "đoán mò" khi hệ thống chậm nữa.
Khái niệm cốt lõi
Trace và Span — hai từ khóa phải thuộc lòng
Distributed Tracing xoay quanh hai khái niệm nền tảng:
- Span là một đơn vị công việc — ví dụ "gọi Payment Service", "query database", "kiểm tra token". Mỗi span có: tên, thời điểm bắt đầu, thời điểm kết thúc (từ đó suy ra latency), và một tập metadata gọi là attributes (như
http.method=POST,db.statement=SELECT...,user.id=12345). - Trace là tập hợp tất cả các span của cùng một request, được ghép lại thành một cây (tree). Span đầu tiên gọi là root span; các span con nằm dưới nó phản ánh những lời gọi mà request kích hoạt.
Context Propagation — sợi chỉ xuyên suốt
Điều khiến tracing "distributed" hoạt động được là context propagation (lan truyền ngữ cảnh). Khi request đi từ service A sang service B, service A phải "nhét" một mã định danh vào — cụ thể là Trace ID (định danh toàn bộ hành trình) và Span ID (định danh span cha). Chuẩn phổ biến nhất hiện nay là W3C Trace Context, truyền qua HTTP header tên traceparent, trông như thế này:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Trong đó 4bf92f...4736 là Trace ID, 00f067...02b7 là Span ID của span cha. Service B đọc header này, biết mình thuộc trace nào, cha là span nào, rồi tạo span con và tiếp tục truyền tiếp sang service C. Nhờ sợi chỉ này mà 10 service ở 10 nơi khác nhau vẫn ghép được thành một bức tranh liền mạch.
OpenTelemetry (OTel) — chuẩn chung để không bị khóa nhà cung cấp
Trước đây mỗi hãng có một cách instrument riêng: Jaeger có client riêng, Zipkin có client riêng, Datadog có agent riêng. Bạn viết code cho Jaeger thì sau này muốn đổi sang công cụ khác phải viết lại. OpenTelemetry — dự án thuộc CNCF, hình thành từ việc hợp nhất OpenTracing và OpenCensus — sinh ra để giải quyết đúng chuyện này. OTel cung cấp:
- API và SDK cho hầu hết ngôn ngữ (Go, Java, Python, Node.js, .NET...) để bạn tạo span một cách chuẩn hóa.
- Auto-instrumentation: nhiều library phổ biến (HTTP client, gRPC, database driver) được instrument sẵn, bạn gần như không phải sửa code nghiệp vụ.
- OTel Collector: một thành phần trung gian nhận dữ liệu tracing (cả metrics, logs), xử lý rồi gửi tới bất kỳ backend nào — Jaeger, Tempo, Datadog... Bạn đổi backend chỉ bằng đổi config Collector, không đụng vào code ứng dụng.
Jaeger — backend để lưu và xem trace
Nếu OpenTelemetry lo phần sinh ra và truyền dữ liệu trace, thì Jaeger (do Uber tạo ra, cũng thuộc CNCF) là nơi lưu trữ, truy vấn và trực quan hóa chúng. Giao diện Jaeger cho bạn tìm trace theo service, theo tag, theo khoảng thời gian, và quan trọng nhất là vẽ ra biểu đồ waterfall (thác nước) — từng span xếp thành các thanh ngang theo timeline, nhìn phát là biết span nào là "thủ phạm" kéo dài latency.
Sampling — vì không thể lưu tất cả
Một hệ thống lớn có thể xử lý hàng chục nghìn request mỗi giây. Lưu trace cho mọi request sẽ tốn khủng khiếp về lưu trữ lẫn hiệu năng. Vì thế có sampling (lấy mẫu): chỉ giữ lại một phần. Hai kiểu chính:
- Head-based sampling: quyết định giữ hay bỏ ngay tại điểm bắt đầu trace (ví dụ giữ 1% ngẫu nhiên). Đơn giản, rẻ, nhưng có thể bỏ sót đúng những trace bị lỗi.
- Tail-based sampling: chờ trace hoàn tất rồi mới quyết định — ưu tiên giữ những trace có lỗi hoặc latency cao. Thông minh hơn nhưng tốn tài nguyên hơn vì phải buffer toàn bộ trace trước khi quyết định.
Tình huống thực tế
Tình huống 1 — Sàn TMĐT giả định "ChợViệt": 8 giây bí ẩn
Đội DevOps của ChợViệt (một sàn TMĐT giả định ở TP.HCM, khoảng 40 microservices) nhận cảnh báo: p95 latency của endpoint đặt hàng nhảy từ 900ms lên 8 giây trong giờ cao điểm 20h. Log của từng service thì bình thường, không service nào báo lỗi rõ ràng.
Họ mở Jaeger, lọc các trace của POST /order có latency > 5s. Biểu đồ waterfall lộ ra ngay: span call inventory chiếm 7 giây, trong khi các span khác chỉ mất vài chục ms. Đào sâu vào span con của Inventory Service, họ thấy span query stock DB mất 6.8 giây, kèm attribute db.statement cho thấy một câu query quét bảng tồn kho không có index phù hợp. Vào giờ cao điểm, lượng truy vấn tăng khiến query này bị chậm theo cấp số nhân.
Bài học: không có tracing, đội này có thể đã đổ lỗi cho mạng, cho Payment Service, hoặc scale nhầm service khác. Nhờ waterfall, họ khoanh vùng chính xác trong 10 phút thay vì cả buổi. Fix là thêm index và cache tồn kho — p95 về lại 1 giây.
Tình huống 2 — Uber và bài toán quy mô khổng lồ
Jaeger sinh ra tại Uber không phải ngẫu nhiên. Uber vận hành hàng nghìn microservices; một chuyến xe được đặt sẽ kích hoạt vô số lời gọi giữa dịch vụ định vị, tính giá, ghép tài xế, thanh toán. Khi một khu vực bị chậm, kỹ sư cần biết chuỗi nào trong hàng nghìn dịch vụ đang là điểm nghẽn. Uber dùng tracing với sampling ở mức rất thấp (chỉ một phần nhỏ request được trace) nhưng vẫn đủ để phát hiện các mẫu bất thường, vì với hàng triệu request thì 0.1% cũng đã là lượng dữ liệu khổng lồ.
Bài học: ở quy mô lớn, bạn không cần trace 100% request. Sampling hợp lý cho bạn đủ tín hiệu mà không phá sản về chi phí lưu trữ. Đừng bật full sampling trên production chỉ vì "cho chắc".
Tình huống 3 — Fintech "PayNhanh": lỗi 500 chập chờn
Một công ty fintech giả định tên PayNhanh gặp lỗi 500 xuất hiện ngẫu nhiên khoảng 2% giao dịch, không tái hiện được ở môi trường test. Đội on-call rất đau đầu vì log của Payment Service chỉ ghi "internal error" chung chung.
Sau khi tích hợp OpenTelemetry với tail-based sampling (ưu tiên giữ mọi trace có span lỗi), họ phát hiện: các trace lỗi đều có một span call fraud-check với attribute error=true và error.message=timeout. Fraud-check Service gọi sang một API bên thứ ba (đối tác chấm điểm rủi ro), và API này thỉnh thoảng phản hồi quá 3 giây khiến timeout. Vì lỗi chỉ xảy ra khi đối tác chậm nên không bao giờ tái hiện được ở test.
Bài học: tail-based sampling là "vũ khí" cho các lỗi hiếm và chập chờn. Nếu dùng head-based sampling 1%, khả năng cao bạn sẽ bỏ lỡ đúng những trace 2% bị lỗi. Ngoài ra, span xuyên biên giới (sang API bên thứ ba) cho thấy giá trị của việc instrument cả các lời gọi ra ngoài.
Hướng dẫn từng bước
Dưới đây là lộ trình triển khai distributed tracing với OpenTelemetry và Jaeger cho một hệ thống microservices điển hình.
Bước 1 — Dựng backend Jaeger. Cách nhanh nhất để thử là chạy Jaeger all-in-one bằng Docker:
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
Cổng 16686 là giao diện web, 4317/4318 là cổng nhận dữ liệu theo giao thức OTLP (chuẩn của OpenTelemetry). Mở http://localhost:16686 để thấy UI Jaeger.
Bước 2 — Cài OpenTelemetry SDK vào service. Ví dụ với Node.js, cài các gói @opentelemetry/sdk-node, @opentelemetry/auto-instrumentations-node và @opentelemetry/exporter-trace-otlp-http, rồi khởi tạo trước khi app chạy:
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const sdk = new NodeSDK({
serviceName: 'order-service',
traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
Chỉ với chừng này, auto-instrumentation đã tự động tạo span cho mọi request HTTP vào/ra và mọi query database phổ biến — bạn chưa cần đụng vào code nghiệp vụ.
Bước 3 — Đảm bảo context propagation. Nếu các service gọi nhau qua HTTP client được auto-instrument (như http, axios, fetch), OTel sẽ tự động chèn header traceparent. Bạn chỉ cần kiểm tra rằng mọi service trong chuỗi đều đã cài SDK, nếu không sợi chỉ trace sẽ "đứt" tại service chưa instrument và trace bị chia mảnh.
Bước 4 — Thêm span thủ công cho logic nghiệp vụ quan trọng. Auto-instrumentation không biết đâu là "bước tính khuyến mãi" trong code của bạn. Với những đoạn quan trọng, hãy tạo span thủ công và gắn attribute có ý nghĩa nghiệp vụ:
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('order-service');
async function applyPromotion(order) {
return tracer.startActiveSpan('apply-promotion', async (span) => {
span.setAttribute('order.id', order.id);
span.setAttribute('promotion.code', order.promoCode);
try {
const result = await calculatePromotion(order);
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2 }); // ERROR
throw err;
} finally {
span.end();
}
});
}
Bước 5 — Triển khai OTel Collector (khuyến nghị cho production). Thay vì cho mỗi service gửi thẳng tới Jaeger, hãy đặt một Collector ở giữa. Service gửi tới Collector, Collector xử lý (thêm/bớt attribute, sampling, batch) rồi mới gửi tới Jaeger. Lợi ích: đổi backend chỉ sửa config Collector, và bạn tập trung logic sampling ở một nơi.
Bước 6 — Cấu hình sampling. Bắt đầu với head-based sampling tỷ lệ vừa phải (ví dụ 10%) ở môi trường vừa. Khi lưu lượng lớn, cân nhắc chuyển tail-based sampling ở tầng Collector để luôn giữ được các trace lỗi và trace chậm.
Bước 7 — Dùng để điều tra. Khi có sự cố, vào Jaeger, lọc theo service và theo tag (ví dụ error=true hoặc http.status_code=500), sắp xếp theo latency giảm dần, mở trace chậm nhất và đọc waterfall để tìm span thủ phạm.
Lỗi thường gặp & mẹo
- Trace bị đứt gãy giữa chừng. Nguyên nhân phổ biến nhất là một service trong chuỗi chưa được instrument, hoặc dùng giao thức truyền (như message queue) không tự propagate context. Mẹo: kiểm tra từng service đều cài SDK; với queue như Kafka/RabbitMQ, phải chủ động chèn
traceparentvào message header và trích xuất lại ở phía consumer.
- Bật full sampling trên production. Trace 100% ở hệ thống lưu lượng cao sẽ làm phình chi phí lưu trữ và thêm overhead vào latency. Mẹo: production dùng sampling thấp + tail-based để giữ trace lỗi; chỉ bật full sampling tạm thời khi đang điều tra một vấn đề cụ thể.
- Đưa dữ liệu nhạy cảm vào attribute. Rất dễ vô tình gắn số thẻ, mật khẩu, token vào span attribute (ví dụ log nguyên
db.statementchứa dữ liệu cá nhân). Mẹo: cấu hình processor ở Collector để lọc/che (redact) các trường nhạy cảm trước khi lưu.
- Nhầm tracing với logging và metrics. Ba trụ cột observability bổ trợ nhau chứ không thay thế: metrics trả lời "có vấn đề không?", tracing trả lời "vấn đề ở đâu trong luồng?", logs trả lời "chi tiết chuyện gì đã xảy ra?". Mẹo: liên kết chúng lại — nhét
trace_idvào log để từ một dòng log lỗi nhảy thẳng sang trace tương ứng trong Jaeger.
- Cardinality nổ do attribute có giá trị vô hạn. Gắn attribute như
user.idvới hàng triệu giá trị khác nhau lên span thì không sao (span lưu riêng lẻ), nhưng nếu bạn cố derive metrics từ đó thì sẽ nổ cardinality. Mẹo: phân biệt attribute dùng để lọc trace (được phép cardinality cao) và attribute dùng để tổng hợp metrics (phải cardinality thấp).
- Quên end span. Nếu tạo span thủ công mà quên gọi
span.end(), span sẽ không bao giờ được gửi đi hoặc bị treo. Mẹo: luôn đặtspan.end()trong khốifinally.
Bài tập thực hành
- Dựng môi trường: Chạy Jaeger all-in-one bằng Docker theo hướng dẫn ở Bước 1, xác nhận truy cập được UI ở cổng 16686.
- Instrument một chuỗi 3 service: Viết ba service nhỏ (ví dụ
gateway→order→payment) gọi nhau qua HTTP, cài OpenTelemetry auto-instrumentation cho cả ba, và xuất trace về Jaeger. Gửi một request quagatewayrồi tìm trace hoàn chỉnh trong Jaeger — xác nhận cả ba service đều xuất hiện trong cùng một cây trace.
- Cố tình gây chậm: Thêm một
sleepgiả 2 giây vàopaymentservice. Gửi lại request, mở waterfall trong Jaeger và chỉ ra chính xác span nào chịu trách nhiệm cho 2 giây đó.
- Thêm span thủ công: Trong
orderservice, bọc logic "tính tổng đơn" bằng một span thủ công têncalculate-total, gắn attributeorder.item_count. Kiểm tra span mới xuất hiện dưới đúng span cha trong Jaeger.
- Thử nghiệm sampling và error: Cấu hình head-based sampling 50%, gửi 20 request và đếm xem bao nhiêu trace được lưu. Sau đó cho
paymenttrả lỗi 500 ngẫu nhiên, gắnspan.setStatusERROR, rồi lọc trong Jaeger theoerror=trueđể tìm đúng các trace lỗi.
Tóm tắt
Distributed Tracing là công cụ không thể thiếu khi bạn vận hành microservices — nó khâu lại hành trình của một request xuyên qua hàng chục service thành một bức tranh liền mạch, trả lời hai câu hỏi cốt lõi: "service nào chậm?" và "lỗi phát sinh từ đâu?". Nền tảng của nó là span (đơn vị công việc) ghép thành trace (cây hành trình), gắn kết nhờ context propagation truyền traceparent qua các service.
Về công cụ, hãy nhớ sự phân vai: OpenTelemetry là chuẩn chung để sinh và truyền dữ liệu trace (instrument một lần, xuất đi bất cứ đâu), còn Jaeger là backend để lưu, truy vấn và trực quan hóa bằng biểu đồ waterfall. Sampling — đặc biệt là tail-based để giữ trace lỗi — giúp bạn cân bằng giữa độ phủ và chi phí. Cuối cùng, đừng quên tracing là một trong ba trụ cột observability: kết hợp với metrics và logs, và liên kết chúng qua trace_id, bạn sẽ có khả năng điều tra sự cố nhanh gấp nhiều lần so với việc ngồi đối chiếu log bằng mắt. Ở bài tiếp theo, chúng ta sẽ bước sang chủ đề reliability với SLI, SLO và SLA — cách đặt mục tiêu định lượng cho độ tin cậy của hệ thống.