Сразу хотел бы обозначить, что в данной статье не стоит цели рассказать обо всех указанных в заголовке технологиях, а скорее просто 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)


  1. geebv
    07.11.2022 10:56

    Вы упомянули о клиенте и сервере. Стоит в этом случае корневой span начинающий обслуживать запрос от клиента или клиент инициирующий запрос создавать с явным trace.SpanKind
    По умолчанию span все Internal
    https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind


    1. HardDie Автор
      07.11.2022 11:30

      Спасибо за замечание! Не знал об этом


  1. anaxaim
    07.11.2022 14:41

    Пригодится, спасибо за статью


  1. 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
    Такие обертки упрощают сбор трейсингов сервиса.


    1. HardDie Автор
      08.11.2022 12:19

      По поводу существующих интерцепторов не знал, спасибо!
      Но в любом случае, в примере я писал минимальный рабочий код, чтобы было проще понять принцип работы механизмов. А так у меня задача чуть сложнее была, плюс логика сверху еще есть, поэтому я в итоге написал свои интерцепторы для трейсинга)

      А вот ссылки 1 и 3 пустые