Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 23 — Distributed Tracing: OpenTelemetry và Jaeger

Operations Management for Tech Bài 23/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn đang là kỹ sư trực ca (on-call) của một sàn thương mại điện tử. 9 giờ tối, giờ cao điểm, khách hàng bắt đầu than phiền rằng "bấm nút Đặt hàng mà nó quay mãi không xong". Bạn mở dashboard lên: CPU bình thường, database không quá tải, không service nào báo lỗi 500. Nhưng thời gian phản hồi của trang checkout thì đội lên từ 400ms lên 6 giây.

Vấn đề nằm ở đâu? Một request "Đặt hàng" đi qua 10 service khác nhau: API Gateway → Auth Service → Cart Service → Inventory Service → Pricing Service → Promotion Service → Payment Service → Order Service → Notification Service → và cuối cùng là ghi vào database. Chỉ cần MỘT trong 10 service này chậm, cả request chậm theo. Nhưng nhìn vào log của từng service riêng lẻ, bạn không thể ghép chúng lại để biết một request cụ thể đã đi qua đâu, dừng ở đâu, tốn bao nhiêu thời gian tại mỗi chặng.

Đây chính là bài toán mà Distributed Tracing (truy vết phân tán) sinh ra để giải quyết. Trong kiến trúc monolith cũ, khi mọi thứ nằm trong một tiến trình, bạn chỉ cần đọc stack trace là biết hàm nào gọi hàm nào. Nhưng khi hệ thống bị chẻ nhỏ thành hàng chục, hàng trăm microservice chạy trên các máy khác nhau, "stack trace" của một request bị xé lẻ ra khắp nơi. Distributed tracing khâu chúng lại thành một bức tranh hoàn chỉnh.

Trong bài này, chúng ta sẽ tập trung riêng vào tracing — cách một trace được cấu thành, chuẩn OpenTelemetry để sinh và truyền dữ liệu trace, và Jaeger để lưu trữ và trực quan hóa. Đây là một trong ba trụ cột observability (bên cạnh logs và metrics mà các bài khác đã bàn), nhưng là trụ cột trả lời câu hỏi khó nhất: "Request này đã đi qua đâu và chậm ở chỗ nào?".

Khái niệm cốt lõi

Trace, Span và quan hệ cha–con

Nền tảng của tracing chỉ gồm hai khái niệm, nhưng bạn phải nắm thật chắc:

Trace là toàn bộ hành trình của MỘT request, từ lúc nó bước vào hệ thống cho tới khi có phản hồi trả về. Một trace được định danh bằng một trace_id duy nhất — thường là một chuỗi 128-bit như 4bf92f3577b34da6a3ce929d0e0e4736. Mọi thứ xảy ra trong hành trình request đó đều mang chung trace_id này.

Span là một đơn vị công việc trong trace — ví dụ "gọi Payment Service", "truy vấn database", "xử lý logic tính giá". Mỗi span có:

  • span_id riêng của nó;
  • trace_id để biết nó thuộc trace nào;
  • parent_span_id — trỏ về span cha đã gọi nó (span đầu tiên, gọi là root span, không có cha);
  • thời điểm bắt đầu và kết thúc, từ đó suy ra duration (thời lượng);
  • các attributes (thuộc tính) mô tả bối cảnh: http.method=POST, db.statement=SELECT ..., user.id=12345;
  • trạng thái: OK hoặc ERROR.
Quan hệ cha–con giữa các span tạo thành một cây. Khi Jaeger vẽ cây này ra theo trục thời gian, bạn được một biểu đồ thác nước (waterfall) — mỗi span là một thanh ngang, thanh nào dài là chặng đó tốn thời gian, thanh nào có màu đỏ là chặng đó lỗi. Chỉ cần liếc mắt là biết ngay "thủ phạm".

Context Propagation — sợi chỉ xuyên suốt hệ thống

Đây là phần "ma thuật" của distributed tracing và cũng là chỗ hay hỏng nhất. Khi Service A gọi Service B qua HTTP, làm sao Service B biết được nó đang phục vụ trace nào của Service A?

Câu trả lời là context propagation: khi gọi sang service khác, thông tin trace (trace_id, span_id của span hiện tại) được nhét vào header của request. Chuẩn phổ biến nhất hiện nay là W3C Trace Context, dùng header tên traceparent với định dạng:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Bốn phần ngăn cách bởi dấu gạch: version, trace_id, parent span_id, và flags (bit sampling). Service B đọc header này, tạo span con với parent_span_id chính là span_id nó nhận được, và tiếp tục truyền traceparent sang service tiếp theo. Cứ thế, sợi chỉ này xuyên suốt cả 10 service, khâu tất cả span rời rạc thành một trace liền mạch.

Nếu một service ở giữa "đánh rơi" header này (ví dụ code cũ không hỗ trợ, hoặc đi qua một hàng đợi message không truyền context), trace sẽ bị đứt — bạn thấy một trace kết thúc đột ngột và một trace mới bắt đầu không rõ nguồn gốc. Đây là lỗi kinh điển sẽ nói ở phần dưới.

OpenTelemetry — chuẩn hóa việc sinh dữ liệu trace

Trước đây mỗi hãng có một chuẩn tracing riêng (Zipkin, Jaeger client, OpenTracing, OpenCensus...), dẫn đến việc đổi backend là phải viết lại toàn bộ instrumentation. OpenTelemetry (viết tắt OTel) ra đời từ sự hợp nhất của OpenTracing và OpenCensus, trở thành chuẩn mở duy nhất được cả ngành ủng hộ. OTel gồm:

  • API và SDK cho hàng loạt ngôn ngữ (Java, Go, Python, Node.js, .NET, PHP...) để tạo span trong code;
  • Auto-instrumentation — thư viện tự động sinh span cho các framework phổ biến (HTTP server, gRPC, database driver...) mà bạn gần như không phải sửa code nghiệp vụ;
  • OTLP (OpenTelemetry Protocol) — giao thức chuẩn để gửi dữ liệu trace/metrics/logs đi;
  • OpenTelemetry Collector — một tiến trình trung gian nhận dữ liệu, xử lý (lọc, gộp, đổi định dạng) và chuyển tiếp tới backend.
Điểm mạnh lớn nhất: OTel tách rời việc sinh dữ liệu khỏi việc lưu trữ. Bạn instrument code một lần theo chuẩn OTel, rồi muốn đổi backend từ Jaeger sang Datadog, Grafana Tempo, hay Zipkin thì chỉ cần đổi cấu hình Collector — không đụng tới một dòng code nghiệp vụ nào.

Jaeger — nơi lưu trữ và trực quan hóa

Jaeger (do Uber phát triển, nay thuộc CNCF) là một backend tracing mã nguồn mở. Nó nhận span (qua OTLP hoặc giao thức Jaeger native), lưu vào một storage backend (Cassandra, Elasticsearch, hoặc bộ nhớ cho môi trường dev), và cung cấp giao diện web để:

  • tìm trace theo service, operation, thời gian, thời lượng, hoặc tag;
  • xem biểu đồ waterfall của từng trace;
  • so sánh hai trace với nhau;
  • vẽ sơ đồ phụ thuộc giữa các service (service dependency graph).
Cặp OpenTelemetry (sinh dữ liệu) + Jaeger (lưu và xem) là combo mã nguồn mở phổ biến bậc nhất, đặc biệt với các đội có ngân sách hạn chế — điều rất thực tế với nhiều startup Việt Nam.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT phát hiện "thủ phạm" ẩn sau Promotion Service

Một sàn thương mại điện tử tại TP.HCM (gọi tên giả định là ShopViet) chạy khoảng 25 microservice. Trong đợt sale 12/12, thời gian checkout trung bình đội từ 500ms lên 4,2 giây, tỉ lệ bỏ giỏ hàng tăng vọt. Đội SRE nhìn metrics thì mỗi service đều "trông ổn": P50 latency của từng service đều dưới 200ms.

Họ mở Jaeger, lọc các trace của operation POST /checkout có duration lớn hơn 3 giây. Biểu đồ waterfall lộ ra sự thật: span PromotionService.applyVouchers chiếm 3,6 trên 4,2 giây. Đào sâu vào các attribute của span, họ thấy db.statement là một truy vấn quét toàn bộ bảng voucher không có index phù hợp, và trong mùa sale số lượng voucher active tăng gấp 40 lần khiến truy vấn chậm theo cấp số nhân.

Bài học: Metrics dạng trung bình che giấu vấn đề vì mỗi service riêng lẻ vẫn nhanh — chỉ khi chúng nối lại trong một trace cụ thể thì tổng thời gian mới lộ ra. Trace giúp định vị chính xác span thủ phạm trong vài phút thay vì cả buổi tối đoán mò. Sau khi thêm index và cache danh sách voucher, span này rớt xuống 80ms.

Ví dụ 2 — Fintech phát hiện trace bị đứt vì hàng đợi Kafka

Một công ty fintech ở Singapore phục vụ thị trường Đông Nam Á có luồng xử lý giao dịch đi qua Kafka: Payment API nhận request rồi đẩy message vào Kafka, một Settlement Worker tiêu thụ message đó và xử lý bất đồng bộ. Khi bật Jaeger lên, đội ngũ thấy các trace của Payment API luôn kết thúc ngay tại điểm "đẩy vào Kafka", còn công việc của Settlement Worker thì xuất hiện dưới dạng những trace riêng lẻ, mồ côi, không nối được với request gốc.

Nguyên nhân: context propagation hoạt động mượt qua HTTP nhờ header traceparent, nhưng khi đi qua Kafka thì message không mang theo trace context. Họ khắc phục bằng cách dùng OTel instrumentation cho Kafka — nhét traceparent vào phần headers của Kafka message ở phía producer, và đọc lại ở phía consumer để tạo span con nối tiếp trace gốc. Sau đó, một giao dịch thanh toán từ lúc nhận request tới lúc settle xong hiện ra thành một trace liền mạch, kể cả phần bất đồng bộ.

Bài học: Trace rất dễ bị đứt tại các ranh giới bất đồng bộ (message queue, cron job, event bus). Context propagation không tự động qua các kênh này — bạn phải chủ động truyền context qua message header. Đây là điểm cần kiểm tra kỹ khi thiết kế observability cho hệ thống event-driven.

Ví dụ 3 — Startup giao đồ ăn kiểm soát chi phí bằng sampling

Một startup giao đồ ăn (giả định FoodNow) xử lý khoảng 8 triệu request/ngày. Khi bật tracing 100% (ghi lại mọi trace), họ nhận ra chi phí lưu trữ Elasticsearch cho Jaeger phình lên tới mức không chịu nổi, và bản thân việc gửi span cũng tạo overhead lên ứng dụng. Nhưng nếu tắt tracing thì lại mất khả năng điều tra sự cố.

Giải pháp là sampling (lấy mẫu). Họ triển khai tail-based sampling qua OpenTelemetry Collector: giữ lại 100% các trace có lỗi hoặc có duration lớn hơn 1 giây, và chỉ giữ 5% các trace "bình thường". Nhờ vậy, mọi trace đáng điều tra (chậm hoặc lỗi) đều được lưu đầy đủ, còn các trace nhàm chán thì chỉ giữ một mẫu nhỏ đủ để nhìn xu hướng. Chi phí lưu trữ giảm khoảng 85% mà không đánh mất giá trị chẩn đoán.

Bài học: Tracing toàn bộ ở quy mô lớn là bất khả thi về chi phí. Sampling thông minh — đặc biệt tail-based, quyết định giữ trace sau khi đã biết nó có lỗi/chậm hay không — là chìa khóa để cân bằng giữa khả năng quan sát và ngân sách.

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, phù hợp cho đội mới bắt đầu:

Bước 1 — Dựng Jaeger để làm nơi nhận trace. Ở môi trường dev, cách nhanh nhất là chạy image jaegertracing/all-in-one bằng Docker; nó gói sẵn collector, storage in-memory và UI. Truy cập UI ở cổng 16686, mở cổng OTLP (4317 cho gRPC, 4318 cho HTTP) để nhận dữ liệu. Với production, thay storage in-memory bằng Elasticsearch hoặc Cassandra.

Bước 2 — Cài OpenTelemetry SDK vào từng service. Chọn đúng gói cho ngôn ngữ của bạn (ví dụ @opentelemetry/sdk-node cho Node.js, opentelemetry-sdk cho Python). Cấu hình service.name cho mỗi service — đây là tên sẽ hiển thị trên Jaeger, đặt rõ ràng như checkout-service, đừng để mặc định unknown_service.

Bước 3 — Bật auto-instrumentation trước. Đừng vội viết span thủ công. Auto-instrumentation của OTel tự sinh span cho HTTP server/client, database, gRPC... Chỉ với vài dòng khởi tạo, bạn đã có ngay các trace cơ bản cho luồng request. Đây là cách đạt "80% giá trị với 20% công sức".

Bước 4 — Cấu hình exporter trỏ về Jaeger. Đặt exporter dùng OTLP, trỏ endpoint tới địa chỉ Collector/Jaeger (ví dụ http://jaeger:4318/v1/traces). Deploy, chạy vài request thử, rồi mở Jaeger UI kiểm tra xem trace đã xuất hiện chưa.

Bước 5 — Bổ sung span thủ công cho logic nghiệp vụ quan trọng. Với những đoạn code lõi (tính giá, chấm điểm rủi ro, gọi bên thứ ba), tạo span thủ công và gắn attribute có ý nghĩa nghiệp vụ: order.id, payment.provider, promotion.count. Chính những attribute này sau này giúp bạn lọc và điều tra.

Bước 6 — Đưa OpenTelemetry Collector vào giữa. Thay vì mỗi service gửi thẳng tới Jaeger, hãy cho chúng gửi tới Collector. Collector là nơi tập trung để cấu hình sampling, thêm/bớt attribute, và đổi backend mà không cần deploy lại service.

Bước 7 — Thiết lập chiến lược sampling. Bắt đầu với head-based sampling đơn giản (ví dụ giữ 10%), rồi tiến tới tail-based sampling ở Collector khi lưu lượng lớn: luôn giữ trace lỗi và trace chậm, lấy mẫu phần còn lại.

Bước 8 — Liên kết trace với logs. Nhét trace_id vào mọi dòng log. Khi điều tra một trace, bạn nhảy thẳng sang log tương ứng; khi thấy một log lỗi, bạn nhảy ngược về trace để xem toàn cảnh. Đây là cầu nối cực kỳ giá trị giữa hai trụ cột observability.

Lỗi thường gặp & mẹo

Trace bị đứt tại ranh giới bất đồng bộ. Như ví dụ Kafka ở trên: message queue, cron, background job không tự truyền context. Luôn kiểm tra và chủ động nhét traceparent vào message header.

Quên truyền context qua service không dùng OTel. Nếu một service cũ (hoặc bên thứ ba) không đọc/ghi header traceparent, sợi chỉ trace đứt tại đó. Ưu tiên dùng chuẩn W3C Trace Context để tương thích rộng.

Bật tracing 100% ở production rồi "cháy" ngân sách. Overhead về CPU, băng thông và chi phí lưu trữ là thật. Đừng bao giờ lên production mà không có chiến lược sampling.

Đặt service.name mặc định hoặc trùng nhau. Khi mọi service đều tên unknown_service, Jaeger vô dụng. Đặt tên rõ ràng, nhất quán ngay từ đầu.

Gắn dữ liệu nhạy cảm vào attribute. Đừng nhét số thẻ tín dụng, mật khẩu, token, hay dữ liệu cá nhân (PII) vào span attribute — chúng sẽ nằm trong storage tracing mà nhiều người xem được. Dùng processor ở Collector để lọc/che các trường nhạy cảm.

Tạo span quá mịn (over-instrumentation). Tạo span cho từng vòng lặp nhỏ khiến trace ngập trong hàng nghìn span vô nghĩa, vừa khó đọc vừa tốn tài nguyên. Chỉ tạo span cho những ranh giới có ý nghĩa: gọi mạng, truy vấn DB, khối logic nghiệp vụ quan trọng.

Mẹo: Luôn thêm attribute mang tính nghiệp vụ (order id, user tier, region) chứ không chỉ dừng ở dữ liệu kỹ thuật. Chính chúng biến tracing từ công cụ của riêng kỹ sư hạ tầng thành công cụ trả lời được câu hỏi kinh doanh: "Khách VIP ở miền Bắc có bị chậm hơn không?".

Bài tập thực hành

  • Dựng lab tối thiểu. Chạy Jaeger all-in-one bằng Docker. Viết hai service nhỏ (A gọi B qua HTTP) bằng ngôn ngữ bạn quen, bật OpenTelemetry auto-instrumentation, cấu hình exporter OTLP trỏ về Jaeger. Gửi vài request và xác nhận bạn thấy một trace nối liền A và B trên Jaeger UI.
  • Đọc waterfall. Cố tình chèn một sleep 2 giây vào service B. Chạy lại, mở trace trên Jaeger và chỉ ra chính xác span nào chiếm phần lớn thời gian. Viết một đoạn ngắn mô tả bạn kết luận điều gì chỉ từ biểu đồ waterfall.
  • Làm đứt và nối lại trace. Thêm một hàng đợi (hoặc chỉ đơn giản là một lời gọi không truyền header) giữa A và B để trace bị đứt. Quan sát hiện tượng trên Jaeger, rồi tự sửa bằng cách truyền traceparent qua ranh giới đó và xác nhận trace liền lại.
  • Thiết kế sampling. Giả sử hệ thống của bạn có 5 triệu request/ngày và ngân sách lưu trữ chỉ đủ cho 300.000 trace/ngày. Viết ra chiến lược sampling của bạn: giữ trọn nhóm trace nào, lấy mẫu tỉ lệ bao nhiêu cho phần còn lại, và giải thích vì sao.

Tóm tắt

Distributed tracing là công cụ trả lời câu hỏi khó nhất trong hệ microservice: "Một request đã đi qua đâu, và chậm/lỗi ở chặng nào?". Nền tảng của nó chỉ gồm trace (toàn bộ hành trình một request, định danh bằng trace_id) và span (từng đơn vị công việc, nối với nhau bằng quan hệ cha–con). Sợi chỉ khâu chúng lại là context propagation — truyền trace context qua header traceparent theo chuẩn W3C, và đây cũng là chỗ dễ đứt nhất, đặc biệt tại các ranh giới bất đồng bộ như message queue.

OpenTelemetry là chuẩn mở để sinh và truyền dữ liệu trace, tách rời instrumentation khỏi backend, cho phép đổi nơi lưu trữ mà không sửa code. Jaeger là backend mã nguồn mở phổ biến để lưu và trực quan hóa trace bằng biểu đồ waterfall. Khi triển khai thực tế, hãy bắt đầu với auto-instrumentation, đặt service.name rõ ràng, gắn attribute nghiệp vụ có ý nghĩa, dùng Collector làm trung tâm, và đặc biệt là áp dụng sampling (ưu tiên tail-based) để cân bằng giữa khả năng quan sát và chi phí. Nắm vững tracing, bạn biến những đêm trực ca đoán mò thành vài phút mở Jaeger là ra thủ phạm.