Сразу хотел бы обозначить, что в данной статье не стоит цели рассказать обо всех указанных в заголовке технологиях, а скорее просто how to инструкция как настроить это все у себя в проекте.
Причина по которой я сел писать этот пост - когда мне было нужно я не смог найти ничего подобного (возможно, как всегда, плохо искал). Так же во многих гайдах для работы с Jaeger использовали старую библиотеку от uber, а сейчас уже советуют отказываться от нее в пользу opentelementry клиента. Еще я толком не cмог найти информации как прокидывать trace-id через context между сервисами.
В данной статье я пошагово опишу как настроить grpc сервер, как подключать grpc сервисы между собой, как подключить tracing и в конце как сделать чтобы tracing был внутри одной сессии если запросы происходят по цепочке от сервиса к сервису.
GRPC сервер
Описываем proto файл сервера, который будет находится по пути pkg/server/server.proto
syntax = "proto3";
package server;
option go_package = "github.com/HardDie/grpc_with_tracing_example/pkg/server";
service Server
{
rpc Test(TestRequest) returns (TestResponse)
{
}
}
message TestRequest
{
}
message TestResponse
{
string message = 1;
}
Сгенерируем файлы для golang, чтобы они лежали рядом с proto файлом
protoc -I./pkg/server \
--go_out ./pkg/server \
--go_opt=paths=source_relative \
--go-grpc_out ./pkg/server \
--go-grpc_opt=paths=source_relative \
./pkg/server/*.proto
Теперь начнем реализацию сервера. Для начала откроем TCP сокет на порту 9000
lis, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatal(err)
}
Создадим grpc сервер
grpcServer := grpc.NewServer()
Для того, чтобы удобно было общаться с сервером и можно было посмотреть список всех доступных команд, подключим reflect
reflection.Register(grpcServer)
Создадим структуру и реализуем метод описанный в proto файле
type ServerServeObject struct {
pb.UnimplementedServerServer
}
func (s *ServerServeObject) Test(_ context.Context, _ *pb.TestRequest) (*pb.TestResponse, error) {
return &pb.TestResponse{
Message: "Server response",
}, nil
}
Зарегистрируем объект структуры в grpc сервере
pb.RegisterServerServer(grpcServer, &ServerServeObject{})
Запускаем сервер на прослушивание сокета
err = grpcServer.Serve(lis)
if err != nil {
log.Fatal(err)
}
Полный код сервера
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
pb "github.com/HardDie/grpc_with_tracing_example/pkg/server"
)
const (
grpcPort = ":9000"
)
func main() {
// Create a TCP connection
lis, err := net.Listen("tcp", grpcPort)
if err != nil {
log.Fatal(err)
}
// Create the GRPC server
grpcServer := grpc.NewServer()
// Allows us to use a 'list' call to list all available APIs
reflection.Register(grpcServer)
// We register an object that should implement all the described APIs
pb.RegisterServerServer(grpcServer, &ServerServeObject{})
// Serving the GRPC server on a created TCP socket
log.Println("GRPC server listening on " + grpcPort)
err = grpcServer.Serve(lis)
if err != nil {
log.Fatal(err)
}
}
// ServerServeObject Describe the structure that should implement the interface described in the proto file
type ServerServeObject struct {
pb.UnimplementedServerServer
}
// Test Implement a only endpoint
func (s *ServerServeObject) Test(_ context.Context, _ *pb.TestRequest) (*pb.TestResponse, error) {
return &pb.TestResponse{
Message: "Server response",
}, nil
}
Запустим сервер и проверим ответ
grpcurl -plaintext localhost:9000 server.Server.Test
{
"message": "Server response"
}
GRPC клиент
Чтобы у нас был не один сервис, а они общались между собой реализуем еще один. Скопируем файлы относящиеся к серверу и назовем их клиент.
Так выглядит proto файл pkg/client/client.proto
syntax = "proto3";
package client;
option go_package = "github.com/HardDie/grpc_with_tracing_example/pkg/client";
service Client
{
rpc Test(TestRequest) returns (TestResponse)
{
}
}
message TestRequest
{
}
message TestResponse
{
string message = 1;
}
Переименуем объекты с Server в имени на Client, изменим логику в API, чтобы происходил запрос к серверу.
func (s *ClientServeObject) Test(ctx context.Context, _ *pb.TestRequest) (*pb.TestResponse, error) {
conn, err := grpc.DialContext(ctx, "localhost:9000", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
serv := server.NewServerClient(conn)
resp, err := serv.Test(ctx, &server.TestRequest{})
if err != nil {
return nil, err
}
return &pb.TestResponse{
Message: resp.GetMessage(),
}, nil
}
Полный код клиента
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
pb "github.com/HardDie/grpc_with_tracing_example/pkg/client"
)
const (
grpcPort = ":9001"
)
func main() {
// Create a TCP connection
lis, err := net.Listen("tcp", grpcPort)
if err != nil {
log.Fatal(err)
}
// Create the GRPC server
grpcServer := grpc.NewServer()
// Allows us to use a 'list' call to list all available APIs
reflection.Register(grpcServer)
// We register an object that should implement all the described APIs
pb.RegisterClientServer(grpcServer, &ClientServeObject{})
// Serving the GRPC server on a created TCP socket
log.Println("GRPC server listening on " + grpcPort)
err = grpcServer.Serve(lis)
if err != nil {
log.Fatal(err)
}
}
// ClientServeObject Describe the structure that should implement the interface described in the proto file
type ClientServeObject struct {
pb.UnimplementedClientServer
}
// Test Implement a only endpoint
func (s *ClientServeObject) Test(ctx context.Context, _ *pb.TestRequest) (*pb.TestResponse, error) {
// Create a connection to the server
conn, err := grpc.DialContext(ctx, "localhost:9000", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
// Create a client object with connection
serv := server.NewServerClient(conn)
// Calling a method on the server side
resp, err := serv.Test(ctx, &server.TestRequest{})
if err != nil {
return nil, err
}
// Forwarding the response from the server to the client
return &pb.TestResponse{
Message: resp.GetMessage(),
}, nil
}
Запустим и проверим
grpcurl -plaintext localhost:9001 client.Client.Test
{
"message": "Server response"
}
GRPC header
Теперь рассмотрим как в grpc работают заголовки и как их можно передавать между сервисами.
Когда мы делаем вызов, мы можем передать параметры через заголовки, при помощи флага -H
grpcurl -plaintext -H 'username: habr' localhost:9001 client.Client.Test
Чтобы получить доступ к заголовкам внутри метода нужно вызвать функцию
md, ok := metadata.FromIncomingContext(ctx)
if ok {
// Удалось получить заголовки вызова из контекста
}
md - это map слайсов. Извлечем значение из заголовка, если оно есть
var username string
md, ok := metadata.FromIncomingContext(ctx)
if ok {
val := md["username"]
if len(val) > 0 {
username = val[0]
}
}
Таким образом мы можем извлекать значения из заголовка входящего запроса. Теперь рассмотрим как указать значение заголовка при вызове другого сервиса.
ctx = metadata.AppendToOutgoingContext(ctx, "username", username)
А дальше мы просто передаем новый ctx при вызове к сервису и он сможет так же на входе получить значение.
Запуск Jaeger
Для того, чтобы локально развернуть jaeger можно использовать готовый docker образ
docker run --rm -d \
-p 16686:16686 \
-p 14268:14268 --name jaeger jaegertracing/all-in-one
На порту 16686 работает web интерфейс, а на порту 14268 сидит collector, который будет принимать сообщения от сервисов о событиях.
Подключаем сервер к Jaeger
Добавим такой код инициализации провайдера jaeger в main.go файлы. На вход эта функция принимает http адрес до коллектора и название сервиса
var (
// Store a global trace provider variable to clear it before closing
tracer *tracesdk.TracerProvider
)
func NewTracer(url, name string) error {
// Create the Jaeger exporter
exp, err := jaegerExporter.New(jaegerExporter.WithCollectorEndpoint(jaegerExporter.WithEndpoint(url)))
if err != nil {
return err
}
tracer = tracesdk.NewTracerProvider(
// Always be sure to batch in production.
tracesdk.WithBatcher(exp),
// Record information about this application in a Resource.
tracesdk.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(name),
)),
)
return nil
}
Добавим код инициализации трейсера в server и client в начало main() функций
// server
err := NewTracer("http://localhost:14268/api/traces", "server")
if err != nil {
log.Fatal(err)
}
defer tracer.Shutdown(context.Background())
// client
err := NewTracer("http://localhost:14268/api/traces", "client")
if err != nil {
log.Fatal(err)
}
defer tracer.Shutdown(context.Background())
Так же добавим отправку событий при вызове API метода
ctx, span := tracer.Tracer("server").Start(ctx, "Test")
defer span.End()
Полный код сервера
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/reflection"
pb "github.com/HardDie/grpc_with_tracing_example/pkg/server"
jaegerExporter "go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
tracesdk "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
)
const (
grpcPort = ":9000"
)
var (
// Store a global trace provider variable to clear it before closing
tracer *tracesdk.TracerProvider
)
func NewTracer(url, name string) error {
// Create the Jaeger exporter
exp, err := jaegerExporter.New(jaegerExporter.WithCollectorEndpoint(jaegerExporter.WithEndpoint(url)))
if err != nil {
return err
}
tracer = tracesdk.NewTracerProvider(
// Always be sure to batch in production.
tracesdk.WithBatcher(exp),
// Record information about this application in a Resource.
tracesdk.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(name),
)),
)
return nil
}
func main() {
err := NewTracer("http://localhost:14268/api/traces", "server")
if err != nil {
log.Fatal(err)
}
defer tracer.Shutdown(context.Background())
// Create a TCP connection
lis, err := net.Listen("tcp", grpcPort)
if err != nil {
log.Fatal(err)
}
// Create the GRPC server
grpcServer := grpc.NewServer()
// Allows us to use a 'list' call to list all available APIs
reflection.Register(grpcServer)
// We register an object that should implement all the described APIs
pb.RegisterServerServer(grpcServer, &ServerServeObject{})
// Serving the GRPC server on a created TCP socket
log.Println("GRPC server listening on " + grpcPort)
err = grpcServer.Serve(lis)
if err != nil {
log.Fatal(err)
}
}
// ServerServeObject Describe the structure that should implement the interface described in the proto file
type ServerServeObject struct {
pb.UnimplementedServerServer
}
// Test Implement a only endpoint
func (s *ServerServeObject) Test(ctx context.Context, _ *pb.TestRequest) (*pb.TestResponse, error) {
ctx, span := tracer.Tracer("server").Start(ctx, "Test")
defer span.End()
return &pb.TestResponse{
Message: "Server response: " + username,
}, nil
}
Distributed tracing
У нас почти все готово, кроме одного. Сейчас, если открыть веб интерфейс Jaeger, то мы не сможем посмотреть всю цепочку запросов от первого сервиса до последнего. Нам нужно сделать так, чтобы при создании span, использовался TraceID вышестоящего сервиса, который произвел первый запрос в цепочке.
1 шаг. На стороне клиента, после создания span, извлечем TraceID и отправим в заголовке x-trace-id
traceId := fmt.Sprintf("%s", span.SpanContext().TraceID())
ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceId)
2 шаг. На стороне сервера нам нужно извлечь это значение из заголовка и поместить настройки span внутрь ctx, чтобы при создании нового span использовался указанный TraceID
// Extract TraceID from header
md, _ := metadata.FromIncomingContext(ctx)
traceIdString := md["x-trace-id"][0]
// Convert string to byte array
traceId, err := trace.TraceIDFromHex(traceIdString)
if err != nil {
return nil, err
}
// Creating a span context with a predefined trace-id
spanContext := trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceId,
})
// Embedding span config into the context
ctx = trace.ContextWithSpanContext(ctx, spanContext)
ctx, span := tracer.Tracer("server").Start(ctx, "Test")
defer span.End()
Все готово! Теперь, если посмотреть веб интерфейс, то мы видим цепочку вызовов. Чтобы открыть веб интерфейс, переходим в http://localhost:16686
Полный пример можно посмотреть тут: https://github.com/HardDie/grpc_with_tracing_example
Комментарии (5)
McPaha
08.11.2022 12:14+1Сам занимался подобной задачей, могу сказать что существует вот такое решение для [сервера](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc#UnaryServerInterceptor) и [клиента](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc#UnaryClientInterceptor). По сути делает все тоже самое, что вы написали в разделе *Distributed tracing*. Также есть готовые instrumentations для:
1. github.com/uptrace/opentelemetry-go-extra/otelsql
2. go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo
3. github.com/go-redis/redis/extra/redisotel/v8
Такие обертки упрощают сбор трейсингов сервиса.HardDie Автор
08.11.2022 12:19По поводу существующих интерцепторов не знал, спасибо!
Но в любом случае, в примере я писал минимальный рабочий код, чтобы было проще понять принцип работы механизмов. А так у меня задача чуть сложнее была, плюс логика сверху еще есть, поэтому я в итоге написал свои интерцепторы для трейсинга)
А вот ссылки 1 и 3 пустые
geebv
Вы упомянули о клиенте и сервере. Стоит в этом случае корневой span начинающий обслуживать запрос от клиента или клиент инициирующий запрос создавать с явным trace.SpanKind
По умолчанию span все Internal
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind
HardDie Автор
Спасибо за замечание! Не знал об этом