gRPC Distributed Tracing: от proto до production
gRPC Distributed Tracing: от proto до production
Цель урока
Научиться трассировать gRPC сервисы, включая streaming calls. Вы узнаете:
- Как gRPC metadata отличается от HTTP headers
- Автоматическая инструментация gRPC с OpenTelemetry
- Manual interceptors для custom spans
- Трассировка всех типов streaming (server/client/bidirectional)
- Debugging gRPC errors через traces
- Production best practices для gRPC tracing
Готовые примеры кода
Полная gRPC система с тремя сервисами и всеми типами streaming доступна в репозитории.
Готовые примеры: gRPC Tracing
См. директорию: 08-grpc-tracing/ в примерах курса
Быстрый старт:
cd 08-grpc-tracing
docker-compose up -d
# Выполнить gRPC запрос через gateway
curl http://localhost:8080/api/user/42
# Посмотреть trace
open http://localhost:16686Что включено:
- API Gateway (Go) — gRPC client на порту 8080
- User Service (Node.js) — gRPC server на порту 50052
- Payment Service (Python) — gRPC server на порту 50053
- Proto definitions для всех сервисов
- Примеры unary, server streaming, client streaming, bidirectional streaming
- Jaeger для визуализации
Подробные инструкции: README.md
Почему gRPC отличается от HTTP?
gRPC использует HTTP/2 и Protocol Buffers, что делает трассировку немного иначе:
Ключевые отличия:
| Аспект | HTTP REST | gRPC |
|---|---|---|
| Protocol | HTTP/1.1 | HTTP/2 |
| Format | JSON/XML | Protocol Buffers (binary) |
| Context Propagation | HTTP headers (traceparent) | gRPC metadata (grpc-trace-bin) |
| Streaming | ❌ Нет | ✅ Server/Client/Bidi |
| Instrumentation | Auto (HTTP middleware) | Auto + Manual interceptors |
Хорошая новость: OpenTelemetry поддерживает gRPC из коробки! 🎉
Архитектура для практики
Создадим систему с тремя gRPC сервисами:
Что увидим в Jaeger:
Proto Definitions
Создайте файл user.proto:
syntax = "proto3";
package user;
// User Service
service UserService {
// Unary call
rpc FetchUser (FetchUserRequest) returns (UserResponse);
// Server streaming
rpc StreamUsers (StreamUsersRequest) returns (stream UserResponse);
// Client streaming
rpc CreateUsers (stream CreateUserRequest) returns (CreateUsersResponse);
// Bidirectional streaming
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message FetchUserRequest {
int32 user_id = 1;
}
message UserResponse {
int32 id = 1;
string name = 2;
string email = 3;
}
message StreamUsersRequest {
int32 page_size = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message CreateUsersResponse {
int32 count = 1;
}
message ChatMessage {
string user = 1;
string message = 2;
int64 timestamp = 3;
}Файл payment.proto:
syntax = "proto3";
package payment;
service PaymentService {
rpc GetBalance (GetBalanceRequest) returns (BalanceResponse);
rpc ProcessPayment (PaymentRequest) returns (PaymentResponse);
}
message GetBalanceRequest {
int32 user_id = 1;
}
message BalanceResponse {
double amount = 1;
string currency = 2;
}
message PaymentRequest {
int32 user_id = 1;
double amount = 2;
}
message PaymentResponse {
bool success = 1;
string transaction_id = 2;
}Docker Compose Setup
Создайте docker-compose.yml:
version: "3.8"
services:
jaeger:
image: jaegertracing/all-in-one:1.53
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
environment:
- COLLECTOR_OTLP_ENABLED=true
# gRPC сервисы будут запускаться локально для удобства debuggingЧасть 1: Unary Calls (простые вызовы)
User Service: gRPC Server (Node.js)
Структура проекта:
user-service/
├── package.json
├── proto/
│ └── user.proto
├── tracing.js
└── server.jsФайл package.json:
{
"name": "user-service-grpc",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@grpc/grpc-js": "^1.9.0",
"@grpc/proto-loader": "^0.7.0",
"@opentelemetry/sdk-node": "^0.46.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/instrumentation-grpc": "^0.46.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.46.0",
"@opentelemetry/semantic-conventions": "^1.18.1"
}
}Файл tracing.js:
const { NodeSDK } = require("@opentelemetry/sdk-node");
const {
OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-grpc");
const { GrpcInstrumentation } = require("@opentelemetry/instrumentation-grpc");
const sdk = new NodeSDK({
serviceName: "user-service-grpc",
traceExporter: new OTLPTraceExporter({
url: "localhost:4317", // gRPC endpoint
}),
instrumentations: [
new GrpcInstrumentation({
// ⭐ Автоматическая инструментация gRPC
}),
],
});
sdk.start();
process.on("SIGTERM", () => {
sdk.shutdown().finally(() => process.exit(0));
});
module.exports = sdk;Файл server.js:
require("./tracing"); // ВАЖНО: Первая строка!
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const path = require("path");
const { trace, SpanStatusCode } = require("@opentelemetry/api");
// Загрузка proto файла
const PROTO_PATH = path.join(__dirname, "proto", "user.proto");
const packageDefinition = protoLoader.loadSync(PROTO_PATH);
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
const tracer = trace.getTracer("user-service-grpc");
// Mock database
const users = {
1: { id: 1, name: "Alice", email: "alice@example.com" },
2: { id: 2, name: "Bob", email: "bob@example.com" },
42: { id: 42, name: "Charlie", email: "charlie@example.com" },
};
// ⭐ Unary RPC handler
function fetchUser(call, callback) {
// ✅ Trace context автоматически извлекается из gRPC metadata!
const userId = call.request.user_id;
// Manual span для business logic
return tracer.startActiveSpan("fetchUser.logic", (span) => {
span.setAttribute("user.id", userId);
const user = users[userId];
if (!user) {
const error = new Error(`User ${userId} not found`);
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
callback({
code: grpc.status.NOT_FOUND,
message: error.message,
});
return;
}
span.setAttribute("user.name", user.name);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
callback(null, user);
});
}
// Создание gRPC сервера
const server = new grpc.Server();
server.addService(userProto.UserService.service, {
FetchUser: fetchUser,
// Остальные методы добавим позже
});
const PORT = "50052";
server.bindAsync(
`0.0.0.0:${PORT}`,
grpc.ServerCredentials.createInsecure(),
(err, port) => {
if (err) {
console.error("Failed to bind server:", err);
return;
}
console.log(`✅ User Service (gRPC) running on port ${port}`);
server.start();
}
);Ключевые моменты Node.js:
GrpcInstrumentation- автоматически инструментирует gRPC calls- Trace context - автоматически извлекается из gRPC metadata
- Manual spans - можно добавлять для business logic
- Error handling -
span.recordException()для gRPC errors
API Gateway: gRPC Client (Python)
Структура проекта:
api-gateway/
├── requirements.txt
├── proto/
│ ├── user_pb2.py (generated)
│ └── user_pb2_grpc.py (generated)
├── tracing.py
└── client.pyФайл requirements.txt:
grpcio==1.59.0
grpcio-tools==1.59.0
opentelemetry-sdk==1.21.0
opentelemetry-exporter-otlp-proto-grpc==1.21.0
opentelemetry-instrumentation-grpc==0.42b0Генерация Python файлов из proto:
python -m grpc_tools.protoc \
-I./proto \
--python_out=./proto \
--grpc_python_out=./proto \
./proto/user.protoФайл tracing.py:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient
# Resource
resource = Resource.create({"service.name": "api-gateway-grpc"})
# TracerProvider
provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="localhost:4317", insecure=True)
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
# ⭐ Автоматическая инструментация gRPC client
GrpcInstrumentorClient().instrument()Файл client.py:
import tracing # ВАЖНО: Первая строка!
import grpc
from proto import user_pb2, user_pb2_grpc
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode
tracer = trace.get_tracer("api-gateway-grpc")
def get_user_profile(user_id):
"""Вызов gRPC сервиса с автоматической трассировкой"""
with tracer.start_as_current_span("getUserProfile", kind=SpanKind.CLIENT):
# Создание gRPC channel
channel = grpc.insecure_channel("localhost:50052")
stub = user_pb2_grpc.UserServiceStub(channel)
try:
# ⭐ gRPC call - trace context автоматически передается!
response = stub.FetchUser(
user_pb2.FetchUserRequest(user_id=user_id)
)
print(f"✅ Received user: {response.name} ({response.email})")
return response
except grpc.RpcError as e:
print(f"❌ gRPC Error: {e.code()} - {e.details()}")
# Записываем ошибку в span
span = trace.get_current_span()
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
finally:
channel.close()
if __name__ == "__main__":
# Тестовый вызов
get_user_profile(42)
import time
time.sleep(2) # Ждем отправки spansКлючевые моменты Python:
GrpcInstrumentorClient()- инструментирует gRPC client calls- Trace context - автоматически передается через gRPC metadata
- Error handling -
grpc.RpcErrorс трассировкой
Payment Service: gRPC Server (Go)
Структура проекта:
payment-service/
├── go.mod
├── proto/
│ ├── payment.proto
│ └── payment.pb.go (generated)
├── tracing.go
└── main.goФайл go.mod:
module payment-service
go 1.21
require (
google.golang.org/grpc v1.59.0
google.golang.org/protobuf v1.31.0
go.opentelemetry.io/otel v1.21.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0
go.opentelemetry.io/otel/sdk v1.21.0
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0
)Генерация Go файлов:
protoc --go_out=. --go-grpc_out=. proto/payment.protoФайл tracing.go:
package main
import (
"context"
"log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func InitTracing() func() {
ctx := context.Background()
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithEndpoint("localhost:4317"),
)
if err != nil {
log.Fatal(err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("payment-service-grpc"),
),
)
if err != nil {
log.Fatal(err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
return func() {
if err := tp.Shutdown(ctx); err != nil {
log.Fatal(err)
}
}
}Файл main.go:
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes as traceCodes"
pb "payment-service/proto"
)
type paymentServer struct {
pb.UnimplementedPaymentServiceServer
}
// ⭐ Unary RPC handler
func (s *paymentServer) GetBalance(
ctx context.Context,
req *pb.GetBalanceRequest,
) (*pb.BalanceResponse, error) {
tracer := otel.Tracer("payment-service-grpc")
// Manual span для business logic
_, span := tracer.Start(ctx, "getBalance.logic")
defer span.End()
userID := req.GetUserId()
span.SetAttributes(attribute.Int("user.id", int(userID)))
// Mock balance lookup
balances := map[int32]float64{
1: 1000.50,
2: 500.00,
42: 1500.75,
}
balance, ok := balances[userID]
if !ok {
err := status.Errorf(codes.NotFound, "balance for user %d not found", userID)
span.RecordError(err)
span.SetStatus(traceCodes.Error, err.Error())
return nil, err
}
span.SetAttributes(attribute.Float64("balance.amount", balance))
span.SetStatus(traceCodes.Ok, "balance retrieved")
return &pb.BalanceResponse{
Amount: balance,
Currency: "USD",
}, nil
}
func main() {
// Инициализация трейсинга
shutdown := InitTracing()
defer shutdown()
lis, err := net.Listen("tcp", ":50053")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
// ⭐ Создание gRPC server с OpenTelemetry interceptor
server := grpc.NewServer(
grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
grpc.StreamInterceptor(otelgrpc.StreamServerInterceptor()),
)
pb.RegisterPaymentServiceServer(server, &paymentServer{})
log.Println("✅ Payment Service (gRPC) running on port 50053")
if err := server.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}Ключевые моменты Go:
otelgrpcinterceptors - автоматическая инструментацияgrpc.UnaryInterceptor()- для unary callsgrpc.StreamInterceptor()- для streaming (добавим позже)- Manual spans -
tracer.Start(ctx, "name")для business logic
Часть 2: Streaming Calls
Server Streaming (многие ответы на один запрос)
Use case: Получить список всех пользователей постранично
Node.js Server (User Service):
// Добавьте в server.js
function streamUsers(call) {
return tracer.startActiveSpan("streamUsers.logic", (span) => {
const pageSize = call.request.page_size || 10;
span.setAttribute("page.size", pageSize);
// Имитация потоковой отправки
let sent = 0;
for (const [id, user] of Object.entries(users)) {
call.write(user); // ⭐ Отправляем каждого пользователя отдельно
sent++;
if (sent >= pageSize) break;
}
span.setAttribute("users.sent", sent);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
call.end(); // Завершаем stream
});
}
// Обновите server.addService:
server.addService(userProto.UserService.service, {
FetchUser: fetchUser,
StreamUsers: streamUsers, // ⭐ Добавили streaming
});Python Client:
def stream_users(page_size=10):
"""Получение пользователей через server streaming"""
with tracer.start_as_current_span("streamUsers", kind=SpanKind.CLIENT):
channel = grpc.insecure_channel("localhost:50052")
stub = user_pb2_grpc.UserServiceStub(channel)
try:
# ⭐ Server streaming - получаем итератор
response_stream = stub.StreamUsers(
user_pb2.StreamUsersRequest(page_size=page_size)
)
users = []
for user in response_stream: # ⭐ Итерируемся по stream
print(f"📥 Received user: {user.name}")
users.append(user)
print(f"✅ Total users received: {len(users)}")
return users
finally:
channel.close()Trace в Jaeger:
Client Streaming (много запросов, один ответ)
Use case: Batch создание пользователей
Node.js Server:
function createUsers(call, callback) {
return tracer.startActiveSpan("createUsers.logic", (span) => {
let count = 0;
call.on("data", (request) => {
// ⭐ Получаем каждого пользователя из stream
const user = {
id: Object.keys(users).length + count + 1,
name: request.name,
email: request.email,
};
users[user.id] = user;
count++;
console.log(`✅ Created user: ${user.name}`);
});
call.on("end", () => {
span.setAttribute("users.created", count);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
// ⭐ Отправляем ОДИН ответ в конце
callback(null, { count });
});
call.on("error", (err) => {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
callback(err);
});
});
}Python Client:
def create_users_batch(users_data):
"""Создание пользователей через client streaming"""
with tracer.start_as_current_span("createUsersBatch", kind=SpanKind.CLIENT):
channel = grpc.insecure_channel("localhost:50052")
stub = user_pb2_grpc.UserServiceStub(channel)
try:
# ⭐ Client streaming - передаем итератор
def request_generator():
for user in users_data:
yield user_pb2.CreateUserRequest(
name=user["name"],
email=user["email"]
)
response = stub.CreateUsers(request_generator())
print(f"✅ Created {response.count} users")
return response.count
finally:
channel.close()
# Использование:
create_users_batch([
{"name": "Dave", "email": "dave@example.com"},
{"name": "Eve", "email": "eve@example.com"},
])Bidirectional Streaming (чат)
Use case: Real-time chat
Node.js Server:
function chat(call) {
return tracer.startActiveSpan("chat.session", (span) => {
console.log("💬 Chat session started");
call.on("data", (message) => {
// ⭐ Получаем сообщение от клиента
console.log(`📥 ${message.user}: ${message.message}`);
// Echo back (в реальности - broadcast всем)
call.write({
user: "Server",
message: `Echo: ${message.message}`,
timestamp: Date.now(),
});
});
call.on("end", () => {
console.log("💬 Chat session ended");
span.setStatus({ code: SpanStatusCode.OK });
span.end();
call.end();
});
call.on("error", (err) => {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
});
});
}Python Client:
import threading
def chat_client():
"""Bidirectional streaming chat"""
with tracer.start_as_current_span("chatClient", kind=SpanKind.CLIENT):
channel = grpc.insecure_channel("localhost:50052")
stub = user_pb2_grpc.UserServiceStub(channel)
# ⭐ Bidi streaming
def request_generator():
messages = [
"Hello!",
"How are you?",
"Bye!",
]
for msg in messages:
yield user_pb2.ChatMessage(
user="Alice",
message=msg,
timestamp=int(time.time() * 1000)
)
time.sleep(1)
try:
response_stream = stub.Chat(request_generator())
# Читаем ответы
for response in response_stream:
print(f"📥 {response.user}: {response.message}")
finally:
channel.close()Trace для Bidi Streaming:
Metadata Propagation (под капотом)
Как trace context передается через gRPC:
В gRPC metadata:
// Автоматически добавляется OpenTelemetry:
{
"grpc-trace-bin": "<binary encoded trace context>",
"traceparent": "00-abc123-def456-01", // W3C format (опционально)
}Если нужен manual propagation:
const { propagation, context } = require("@opentelemetry/api");
// В client:
const metadata = new grpc.Metadata();
propagation.inject(context.active(), metadata);
// В server:
const ctx = propagation.extract(context.active(), call.metadata);Но обычно OpenTelemetry делает это автоматически! ✅
Debugging gRPC Errors
Типы gRPC ошибок
| gRPC Status Code | Значение | Пример |
|---|---|---|
OK (0) | Успех | Все хорошо |
CANCELLED (1) | Отменен клиентом | Timeout |
NOT_FOUND (5) | Не найдено | User не существует |
ALREADY_EXISTS (6) | Уже существует | Email занят |
PERMISSION_DENIED (7) | Нет прав | Unauthorized |
RESOURCE_EXHAUSTED (8) | Лимит превышен | Rate limit |
UNAVAILABLE (14) | Сервис недоступен | Network error |
DEADLINE_EXCEEDED (4) | Timeout | Slow query |
Как ошибки выглядят в Jaeger
Span attributes для ошибок:
span.setAttributes({
"rpc.system": "grpc",
"rpc.service": "UserService",
"rpc.method": "FetchUser",
"rpc.grpc.status_code": 5, // NOT_FOUND
"error.type": "NotFoundError",
"error.message": "User 999 not found",
});Production Best Practices
1. Deadlines и Timeouts
// Go client с deadline
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
response, err := stub.GetBalance(ctx, &pb.GetBalanceRequest{UserId: 42})# Python client с timeout
response = stub.FetchUser(
user_pb2.FetchUserRequest(user_id=42),
timeout=2.0 # 2 seconds
)В Jaeger увидите:
- Если timeout:
status_code: DEADLINE_EXCEEDED - Span длительность = ровно timeout значение
2. Health Checks - НЕ трассируем!
const { GrpcInstrumentation } = require("@opentelemetry/instrumentation-grpc");
new GrpcInstrumentation({
ignoreGrpcMethods: [
// ⭐ Игнорируем health checks
"grpc.health.v1.Health/Check",
"grpc.health.v1.Health/Watch",
],
});3. Load Balancing Impact
Load balancer не ломает trace context - он передается через metadata!
4. TLS/mTLS Considerations
// Secure gRPC client
const credentials = grpc.credentials.createSsl(
fs.readFileSync("ca.pem"),
fs.readFileSync("client-key.pem"),
fs.readFileSync("client-cert.pem")
);
const channel = new grpc.Channel("server:50051", credentials);Трассировка работает одинаково с TLS и без него! ✅
5. Connection Pooling
// Переиспользуем connection для performance
var conn *grpc.ClientConn
func getConnection() *grpc.ClientConn {
if conn == nil {
conn, _ = grpc.Dial(
"localhost:50052",
grpc.WithInsecure(),
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
)
}
return conn
}Trace context передается в каждом вызове, не в connection!
Практические задания
Задание 1: Сквозной trace через 3 сервиса
- Запустите все 3 gRPC сервиса (User, Payment, Gateway)
- Вызовите
GetUserProfileиз Gateway - Gateway должен вызвать User Service И Payment Service
- В Jaeger найдите trace со всеми 3 сервисами
- Проверьте что все spans связаны одним trace ID
Задание 2: Debugging Streaming
- Реализуйте Server Streaming для получения списка пользователей
- Вызовите с
page_size=100 - В Jaeger проверьте:
- Сколько времени занял stream?
- Видны ли отдельные spans для каждого пользователя?
Задание 3: Timeout Simulation
- Добавьте
time.sleep(3)в Payment Service - Установите timeout 2s на клиенте
- Вызовите GetBalance
- В Jaeger найдите:
- Span с
DEADLINE_EXCEEDEDstatus - Duration = ровно 2s (timeout)
- Span с
Что дальше
В следующем уроке рассмотрим Service Mesh (Istio) - автоматическую трассировку БЕЗ изменения кода через sidecar proxies!
Поздравляем! Вы овладели gRPC трассировкой - включая все типы streaming! Теперь вы можете отлаживать даже самые сложные gRPC системы.