Backend-разработка

Go и gRPC в Kubernetes: построение высокопроизводительных межсервисных коммуникаций с load balancing и observability

Ruslan Ismailov Опубликовано 22 мин чтения
G

1. Введение: почему gRPC выигрывает у REST для межсервисного взаимодействия внутри Kubernetes в 2026 году

В 2026 году gRPC окончательно утвердился как стандарт де-факто для межсервисного взаимодействия внутри Kubernetes-кластеров. Пока REST API остаётся удобным выбором для публичных API и взаимодействия с браузерами, внутри микросервисной архитектуры gRPC предлагает принципиальные преимущества.

Во-первых, gRPC использует HTTP/2, что означает мультиплексирование запросов в рамках одного соединения, header compression и бинарную сериализацию через Protocol Buffers. На практике это даёт снижение задержек на 20–40% и уменьшение объёма передаваемых данных в 3–10 раз по сравнению с JSON-based REST API.

Во-вторых, строгая типизация через .proto-файлы обеспечивает контрактное программирование: любое нарушение API немедленно выявляется на этапе кодогенерации, а не в runtime. Это критично при команде из 10+ разработчиков, работающих над разными микросервисами.

В-третьих, gRPC нативно поддерживает четыре типа взаимодействия: унарный RPC, серверный стриминг, клиентский стриминг и двунаправленный стриминг — возможности, которые крайне сложно реализовать чисто на REST API.

Наконец, экосистема Go имеет первоклассную поддержку gRPC через официальную библиотеку google.golang.org/grpc, что делает связку Go + gRPC + Kubernetes особенно мощной для highload-систем.

2. Базовая настройка gRPC-сервисов на Go: proto-файлы, кодогенерация, структура проекта

Начнём с определения сервиса через Protocol Buffers. Создадим типичный сервис для управления заказами в микросервисной архитектуре.

Структура проекта

order-service/
├── api/
│   └── proto/
│       └── order/
│           └── v1/
│               └── order.proto
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── server/
│   │   └── order_server.go
│   └── repository/
│       └── order_repo.go
├── gen/
│   └── go/
│       └── order/
│           └── v1/
├── Makefile
├── Dockerfile
└── go.mod

Proto-файл

// api/proto/order/v1/order.proto
syntax = "proto3";

package order.v1;

option go_package = "github.com/myorg/order-service/gen/go/order/v1;orderv1";

import "google/protobuf/timestamp.proto";

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_PROCESSING = 2;
  ORDER_STATUS_COMPLETED = 3;
  ORDER_STATUS_CANCELLED = 4;
}

message Order {
  string id = 1;
  string customer_id = 2;
  repeated OrderItem items = 3;
  OrderStatus status = 4;
  double total_amount = 5;
  google.protobuf.Timestamp created_at = 6;
}

message OrderItem {
  string product_id = 1;
  int32 quantity = 2;
  double price = 3;
}

message CreateOrderRequest {
  string customer_id = 1;
  repeated OrderItem items = 2;
}

message CreateOrderResponse {
  Order order = 1;
}

message GetOrderRequest {
  string order_id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

message ListOrdersRequest {
  string customer_id = 1;
  int32 page_size = 2;
  string page_token = 3;
}

message ListOrdersResponse {
  repeated Order orders = 1;
  string next_page_token = 2;
}

service OrderService {
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
  // Серверный стриминг для отслеживания изменений статуса
  rpc WatchOrderStatus(GetOrderRequest) returns (stream Order);
}

Кодогенерация через Makefile

# Makefile
PROTO_DIR := api/proto
GEN_DIR := gen/go

.PHONY: proto
proto:
	protoc \
		--proto_path=$(PROTO_DIR) \
		--go_out=$(GEN_DIR) \
		--go_opt=paths=source_relative \
		--go-grpc_out=$(GEN_DIR) \
		--go-grpc_opt=paths=source_relative \
		$(shell find $(PROTO_DIR) -name '*.proto')

.PHONY: run
run:
	go run ./cmd/server/...

.PHONY: test
test:
	go test -v -race ./...

Реализация gRPC-сервера на Go

// internal/server/order_server.go
package server

import (
	"context"
	"time"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/timestamppb"

	orderv1 "github.com/myorg/order-service/gen/go/order/v1"
	"github.com/myorg/order-service/internal/repository"
)

type OrderServer struct {
	orderv1.UnimplementedOrderServiceServer
	repo repository.OrderRepository
}

func NewOrderServer(repo repository.OrderRepository) *OrderServer {
	return &OrderServer{repo: repo}
}

func (s *OrderServer) CreateOrder(
	ctx context.Context,
	req *orderv1.CreateOrderRequest,
) (*orderv1.CreateOrderResponse, error) {
	if req.CustomerId == "" {
		return nil, status.Error(codes.InvalidArgument, "customer_id is required")
	}
	if len(req.Items) == 0 {
		return nil, status.Error(codes.InvalidArgument, "at least one item is required")
	}

	order, err := s.repo.Create(ctx, req.CustomerId, req.Items)
	if err != nil {
		return nil, status.Errorf(codes.Internal, "failed to create order: %v", err)
	}

	return &orderv1.CreateOrderResponse{Order: order}, nil
}

func (s *OrderServer) WatchOrderStatus(
	req *orderv1.GetOrderRequest,
	stream orderv1.OrderService_WatchOrderStatusServer,
) error {
	ctx := stream.Context()
	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			order, err := s.repo.GetByID(ctx, req.OrderId)
			if err != nil {
				return status.Errorf(codes.Internal, "failed to get order: %v", err)
			}
			if err := stream.Send(order); err != nil {
				return err
			}
			if order.Status == orderv1.OrderStatus_ORDER_STATUS_COMPLETED ||
				order.Status == orderv1.OrderStatus_ORDER_STATUS_CANCELLED {
				return nil
			}
		}
	}
}

Точка входа сервера

// cmd/server/main.go
package main

import (
	"fmt"
	"net"
	"os"
	"os/signal"
	"syscall"

	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"

	orderv1 "github.com/myorg/order-service/gen/go/order/v1"
	"github.com/myorg/order-service/internal/server"
)

func main() {
	port := os.Getenv("GRPC_PORT")
	if port == "" {
		port = "50051"
	}

	lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port))
	if err != nil {
		panic(err)
	}

	grpcServer := grpc.NewServer(
		grpc.ChainUnaryInterceptor(
			loggingInterceptor,
			recoveryInterceptor,
		),
	)

	orderSrv := server.NewOrderServer(/* inject dependencies */)
	orderv1.RegisterOrderServiceServer(grpcServer, orderSrv)

	// gRPC reflection для grpcurl и других инструментов
	reflection.Register(grpcServer)

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)

	go func() {
		fmt.Printf("gRPC server listening on :%s\n", port)
		if err := grpcServer.Serve(lis); err != nil {
			panic(err)
		}
	}()

	<-quit
	fmt.Println("Shutting down gRPC server...")
	grpcServer.GracefulStop()
}

3. Особенности gRPC в Kubernetes: почему стандартный kube-proxy плохо работает с gRPC

Здесь кроется одна из самых распространённых ловушек при развёртывании gRPC-сервисов в Kubernetes. kube-proxy реализует балансировку нагрузки на уровне L4 (TCP), используя iptables или IPVS. Для HTTP/1.1 это работает нормально: каждый запрос — новое TCP-соединение, и балансировщик может распределять их равномерно.

gRPC, построенный поверх HTTP/2, устанавливает одно долгоживущее TCP-соединение и мультиплексирует через него все запросы. Когда gRPC-клиент подключается к ClusterIP Service, kube-proxy направляет это единственное соединение на конкретный Pod и больше не балансирует последующие RPC-вызовы. В результате один Pod получает 100% трафика, а остальные простаивают.

Проблему наглядно иллюстрирует схема: 3 реплики order-service, ClusterIP Service, и весь трафик идёт только на Pod #1.

Существует три основных подхода к решению этой проблемы:

  • Client-side load balancing — клиент сам знает о всех эндпоинтах и балансирует запросы.
  • Proxy/sidecar — Envoy или другой L7-прокси перехватывает трафик и балансирует на уровне HTTP/2 фреймов.
  • Headless Service + DNS — DNS возвращает IP всех Pod'ов, клиент подключается ко всем.

4. Client-side load balancing для gRPC в Go

Go gRPC-клиент имеет встроенную поддержку балансировки нагрузки через интерфейс resolver.Resolver и balancer.Balancer. Ключевой момент — клиент должен получить список всех IP-адресов Pod'ов, а не ClusterIP Service.

Использование headless Service и DNS resolver

Для client-side балансировки создаём headless Service (с clusterIP: None). В этом случае DNS возвращает A-записи для каждого Pod'а, а не единый ClusterIP.

# kubernetes/order-service-headless.yaml
apiVersion: v1
kind: Service
metadata:
  name: order-service-headless
  namespace: production
spec:
  clusterIP: None  # Это делает Service headless
  selector:
    app: order-service
  ports:
    - name: grpc
      port: 50051
      targetPort: 50051
      protocol: TCP

gRPC-клиент с round-robin балансировкой

// pkg/client/order_client.go
package client

import (
	"context"
	"fmt"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/balancer/roundrobin"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/keepalive"

	orderv1 "github.com/myorg/order-service/gen/go/order/v1"
)

type OrderClient struct {
	client orderv1.OrderServiceClient
	conn   *grpc.ClientConn
}

func NewOrderClient(ctx context.Context, target string) (*OrderClient, error) {
	// target для headless service: "dns:///order-service-headless.production.svc.cluster.local:50051"
	conn, err := grpc.DialContext(
		ctx,
		target,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		// Включаем round-robin балансировку
		grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
		// Keepalive для поддержания живых соединений со всеми эндпоинтами
		grpc.WithKeepaliveParams(keepalive.ClientParameters{
			Time:                10 * time.Second,
			Timeout:             3 * time.Second,
			PermitWithoutStream: true,
		}),
		// Включаем wait-for-ready для автоматического переподключения
		grpc.WithDefaultCallOptions(grpc.WaitForReady(true)),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to dial order service: %w", err)
	}

	return &OrderClient{
		client: orderv1.NewOrderServiceClient(conn),
		conn:   conn,
	}, nil
}

func (c *OrderClient) CreateOrder(
	ctx context.Context,
	customerID string,
	items []*orderv1.OrderItem,
) (*orderv1.Order, error) {
	resp, err := c.client.CreateOrder(ctx, &orderv1.CreateOrderRequest{
		CustomerId: customerID,
		Items:      items,
	})
	if err != nil {
		return nil, fmt.Errorf("CreateOrder RPC failed: %w", err)
	}
	return resp.Order, nil
}

func (c *OrderClient) Close() error {
	return c.conn.Close()
}

Кастомный Kubernetes resolver

Для более гибкого управления можно реализовать кастомный resolver, который обращается к Kubernetes Endpoints API напрямую через client-go. Это позволяет мгновенно реагировать на изменения Pod'ов без TTL DNS.

// pkg/resolver/k8s_resolver.go
package resolver

import (
	"context"
	"fmt"

	v1 "k8s.io/api/core/v1"
	"k8s.io/client-go/informers"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/tools/cache"
	"google.golang.org/grpc/resolver"
)

const Scheme = "k8s"

type k8sResolverBuilder struct {
	clientset *kubernetes.Clientset
}

func NewBuilder(clientset *kubernetes.Clientset) resolver.Builder {
	return &k8sResolverBuilder{clientset: clientset}
}

func (b *k8sResolverBuilder) Build(
	target resolver.Target,
	cc resolver.ClientConn,
	opts resolver.BuildOptions,
) (resolver.Resolver, error) {
	r := &k8sResolver{
		cc:        cc,
		clientset: b.clientset,
		namespace: target.URL.Host,
		service:   target.URL.Path[1:], // убираем ведущий /
		ctx:       context.Background(),
	}
	go r.watch()
	return r, nil
}

func (b *k8sResolverBuilder) Scheme() string { return Scheme }

type k8sResolver struct {
	cc        resolver.ClientConn
	clientset *kubernetes.Clientset
	namespace string
	service   string
	ctx       context.Context
}

func (r *k8sResolver) watch() {
	factory := informers.NewSharedInformerFactoryWithOptions(
		r.clientset,
		0,
		informers.WithNamespace(r.namespace),
	)
	endpointsInformer := factory.Core().V1().Endpoints().Informer()
	endpointsInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc:    func(obj interface{}) { r.updateAddresses(obj) },
		UpdateFunc: func(_, obj interface{}) { r.updateAddresses(obj) },
		DeleteFunc: func(obj interface{}) { r.updateAddresses(obj) },
	})
	factory.Start(r.ctx.Done())
}

func (r *k8sResolver) updateAddresses(obj interface{}) {
	endpoints, ok := obj.(*v1.Endpoints)
	if !ok || endpoints.Name != r.service {
		return
	}

	var addrs []resolver.Address
	for _, subset := range endpoints.Subsets {
		for _, addr := range subset.Addresses {
			for _, port := range subset.Ports {
				addrs = append(addrs, resolver.Address{
					Addr: fmt.Sprintf("%s:%d", addr.IP, port.Port),
				})
			}
		}
	}

	r.cc.UpdateState(resolver.State{Addresses: addrs})
}

func (r *k8sResolver) ResolveNow(opts resolver.ResolveNowOptions) {}
func (r *k8sResolver) Close()                                     {}

5. Server-side load balancing: Envoy как sidecar

Альтернатива client-side балансировке — использование Envoy-прокси в режиме sidecar. Envoy понимает HTTP/2 и gRPC на уровне L7, что позволяет балансировать отдельные RPC-вызовы, а не TCP-соединения.

Deployment с Envoy sidecar

# kubernetes/order-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: order-service
        image: myorg/order-service:latest
        ports:
        - containerPort: 50051
          name: grpc
        env:
        - name: GRPC_PORT
          value: "50051"
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 256Mi
      - name: envoy
        image: envoyproxy/envoy:v1.28.0
        ports:
        - containerPort: 10000
          name: grpc-envoy
        - containerPort: 9901
          name: admin
        volumeMounts:
        - name: envoy-config
          mountPath: /etc/envoy
      volumes:
      - name: envoy-config
        configMap:
          name: envoy-config

Конфигурация Envoy для gRPC

# kubernetes/envoy-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-config
  namespace: production
data:
  envoy.yaml: |
    static_resources:
      listeners:
      - name: grpc_listener
        address:
          socket_address:
            address: 0.0.0.0
            port_value: 10000
        filter_chains:
        - filters:
          - name: envoy.filters.network.http_connection_manager
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
              codec_type: AUTO
              stat_prefix: grpc_ingress
              http2_protocol_options: {}
              route_config:
                name: local_route
                virtual_hosts:
                - name: backend
                  domains: ["*"]
                  routes:
                  - match:
                      prefix: "/"
                    route:
                      cluster: order_service_cluster
                      timeout: 30s
                      retry_policy:
                        retry_on: "reset,connect-failure,retriable-status-codes"
                        num_retries: 3
                        retriable_status_codes: [503]
              http_filters:
              - name: envoy.filters.http.grpc_stats
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_stats.v3.FilterConfig
                  stats_for_all_methods: true
              - name: envoy.filters.http.router
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

      clusters:
      - name: order_service_cluster
        connect_timeout: 5s
        type: STRICT_DNS
        lb_policy: ROUND_ROBIN
        typed_extension_protocol_options:
          envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
            "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
            explicit_http_config:
              http2_protocol_options: {}
        load_assignment:
          cluster_name: order_service_cluster
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: order-service-headless.production.svc.cluster.local
                    port_value: 50051

    admin:
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 9901

6. Health checking и graceful shutdown

Kubernetes требует работающих health-check эндпоинтов для корректного управления жизненным циклом Pod'а. gRPC Health Checking Protocol — стандартный способ это реализовать.

Реализация gRPC health check на Go

// cmd/server/main.go (дополненная версия)
package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/health"
	"google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/reflection"

	orderv1 "github.com/myorg/order-service/gen/go/order/v1"
	"github.com/myorg/order-service/internal/server"
)

func main() {
	grpcPort := getEnv("GRPC_PORT", "50051")
	httpPort := getEnv("HTTP_PORT", "8080") // для HTTP liveness probe

	lis, err := net.Listen("tcp", fmt.Sprintf(":%s", grpcPort))
	if err != nil {
		panic(err)
	}

	grpcServer := grpc.NewServer()

	// Регистрируем health check service
	healthSrv := health.NewServer()
	grpc_health_v1.RegisterHealthServer(grpcServer, healthSrv)

	orderSrv := server.NewOrderServer()
	orderv1.RegisterOrderServiceServer(grpcServer, orderSrv)
	reflection.Register(grpcServer)

	// Отмечаем сервис как SERVING
	healthSrv.SetServingStatus("order.v1.OrderService", grpc_health_v1.HealthCheckResponse_SERVING)
	healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)

	// HTTP-сервер для Kubernetes liveness/readiness probes
	httpMux := http.NewServeMux()
	httpMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})
	httpMux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
		// Проверяем зависимости (DB, cache и т.д.)
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ready"))
	})
	httpServer := &http.Server{
		Addr:    fmt.Sprintf(":%s", httpPort),
		Handler: httpMux,
	}

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)

	go func() {
		fmt.Printf("gRPC server on :%s\n", grpcPort)
		if err := grpcServer.Serve(lis); err != nil {
			panic(err)
		}
	}()

	go func() {
		fmt.Printf("HTTP health server on :%s\n", httpPort)
		if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			panic(err)
		}
	}()

	<-quit

	// Graceful shutdown
	// 1. Помечаем сервис как NOT_SERVING, чтобы новые запросы не приходили
	healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
	healthSrv.SetServingStatus("order.v1.OrderService", grpc_health_v1.HealthCheckResponse_NOT_SERVING)

	// 2. Даём время Kubernetes обновить Endpoints (terminationGracePeriodSeconds)
	time.Sleep(5 * time.Second)

	// 3. Завершаем текущие запросы
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	httpServer.Shutdown(ctx)
	grpcServer.GracefulStop()
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

Kubernetes Deployment с probes

# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  namespace: production
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      terminationGracePeriodSeconds: 60
      containers:
      - name: order-service
        image: myorg/order-service:latest
        ports:
        - containerPort: 50051
          name: grpc
        - containerPort: 8080
          name: http
        livenessProbe:
          httpGet:
            path: /healthz
            port: http
          initialDelaySeconds: 10
          periodSeconds: 10
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /ready
            port: http
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
        lifecycle:
          preStop:
            exec:
              # Дополнительная задержка перед SIGTERM для корректного drain
              command: ["/bin/sh", "-c", "sleep 5"]

7. Observability: метрики, трейсинг, логирование

Observability — один из ключевых аспектов production-ready gRPC-сервисов в Kubernetes. Рассмотрим интеграцию с Prometheus и OpenTelemetry.

gRPC метрики с Prometheus

// cmd/server/main.go — добавляем Prometheus метрики
package main

import (
	"net/http"

	"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/prometheus"
	prom "github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	"google.golang.org/grpc"
)

func buildGRPCServer() *grpc.Server {
	reg := prom.NewRegistry()

	grpcMetrics := prometheus.NewServerMetrics(
		prometheus.WithServerHandlingTimeHistogram(
			prometheus.WithHistogramBuckets([]float64{.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}),
		),
	)
	reg.MustRegister(grpcMetrics)

	grpcServer := grpc.NewServer(
		grpc.ChainUnaryInterceptor(
			grpcMetrics.UnaryServerInterceptor(),
		),
		grpc.ChainStreamInterceptor(
			grpcMetrics.StreamServerInterceptor(),
		),
	)

	// Prometheus HTTP эндпоинт
	http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))

	return grpcServer
}

OpenTelemetry трейсинг

// pkg/telemetry/tracer.go
package telemetry

import (
	"context"
	"fmt"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

func InitTracer(ctx context.Context, serviceName, otlpEndpoint string) (func(), error) {
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(otlpEndpoint),
		otlptracegrpc.WithInsecure(),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create OTLP exporter: %w", err)
	}

	res, err := resource.New(ctx,
		resource.WithAttributes(
			semconv.ServiceName(serviceName),
			semconv.ServiceVersion("1.0.0"),
		),
	)
	if err != nil {
		return nil, err
	}

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(res),
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
	)

	otel.SetTracerProvider(tp)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	))

	return func() { tp.Shutdown(ctx) }, nil
}

// Интеграция OTel с gRPC
// В main.go добавляем interceptors:
// import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
//
// grpc.NewServer(
//     grpc.StatsHandler(otelgrpc.NewServerHandler()),
// )

ServiceMonitor для Prometheus Operator

# kubernetes/service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: order-service-monitor
  namespace: production
  labels:
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      app: order-service
  endpoints:
  - port: http
    path: /metrics
    interval: 15s
    scrapeTimeout: 10s

Структурированное логирование

// internal/interceptors/logging.go
package interceptors

import (
	"context"
	"time"

	"go.uber.org/zap"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func LoggingUnaryInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req interface{},
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (interface{}, error) {
		start := time.Now()
		resp, err := handler(ctx, req)
		duration := time.Since(start)

		code := codes.OK
		if err != nil {
			code = status.Code(err)
		}

		logger.Info("gRPC request",
			zap.String("method", info.FullMethod),
			zap.String("code", code.String()),
			zap.Duration("duration", duration),
			zap.Error(err),
		)
		return resp, err
	}
}

8. Безопасность: mTLS для gRPC в Kubernetes без service mesh

Взаимная аутентификация через mTLS — стандарт безопасности для gRPC в production. Реализуем её без service mesh, используя cert-manager для управления сертификатами.

Установка cert-manager и создание CA

# kubernetes/cert-manager-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-ca-secret
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: order-service-tls
  namespace: production
spec:
  secretName: order-service-tls
  duration: 2160h  # 90 дней
  renewBefore: 360h  # обновляем за 15 дней до истечения
  issuerRef:
    name: internal-ca
    kind: ClusterIssuer
  subject:
    organizations:
      - myorg
  dnsNames:
    - order-service.production.svc.cluster.local
    - order-service-headless.production.svc.cluster.local
  usages:
    - server auth
    - client auth  # Необходимо для mTLS

gRPC-сервер с mTLS

// pkg/tls/config.go
package tlsconfig

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"os"

	"google.golang.org/grpc/credentials"
)

func NewServerTLSCredentials(certFile, keyFile, caFile string) (credentials.TransportCredentials, error) {
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, fmt.Errorf("load server keypair: %w", err)
	}

	caCert, err := os.ReadFile(caFile)
	if err != nil {
		return nil, fmt.Errorf("read CA cert: %w", err)
	}

	caPool := x509.NewCertPool()
	if !caPool.AppendCertsFromPEM(caCert) {
		return nil, fmt.Errorf("failed to add CA cert to pool")
	}

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{cert},
		ClientCAs:    caPool,
		ClientAuth:   tls.RequireAndVerifyClientCert, // требуем клиентский сертификат
		MinVersion:   tls.VersionTLS13,
	}

	return credentials.NewTLS(tlsCfg), nil
}

func NewClientTLSCredentials(certFile, keyFile, caFile string) (credentials.TransportCredentials, error) {
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, fmt.Errorf("load client keypair: %w", err)
	}

	caCert, err := os.ReadFile(caFile)
	if err != nil {
		return nil, fmt.Errorf("read CA cert: %w", err)
	}

	caPool := x509.NewCertPool()
	if !caPool.AppendCertsFromPEM(caCert) {
		return nil, fmt.Errorf("failed to add CA cert to pool")
	}

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{cert},
		RootCAs:      caPool,
		MinVersion:   tls.VersionTLS13,
	}

	return credentials.NewTLS(tlsCfg), nil
}

Монтирование TLS-секретов в Pod

# Дополнение к deployment.yaml
spec:
  containers:
  - name: order-service
    env:
    - name: TLS_CERT_FILE
      value: /etc/tls/tls.crt
    - name: TLS_KEY_FILE
      value: /etc/tls/tls.key
    - name: TLS_CA_FILE
      value: /etc/tls/ca.crt
    volumeMounts:
    - name: tls-certs
      mountPath: /etc/tls
      readOnly: true
  volumes:
  - name: tls-certs
    secret:
      secretName: order-service-tls

9. Тестирование gRPC-сервисов

Полноценное тестирование gRPC в Go включает юнит-тесты с моками, интеграционные тесты с реальным gRPC-сервером и контрактные тесты.

Моки через mockery и тестирование сервера

// internal/server/order_server_test.go
package server_test

import (
	"context"
	"net"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"

	orderv1 "github.com/myorg/order-service/gen/go/order/v1"
	"github.com/myorg/order-service/internal/mocks"
	"github.com/myorg/order-service/internal/server"
)

func setupTestServer(t *testing.T, mockRepo *mocks.OrderRepository) orderv1.OrderServiceClient {
	t.Helper()

	lis, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)

	grpcSrv := grpc.NewServer()
	orderv1.RegisterOrderServiceServer(grpcSrv, server.NewOrderServer(mockRepo))

	go grpcSrv.Serve(lis)
	t.Cleanup(grpcSrv.Stop)

	conn, err := grpc.Dial(
		lis.Addr().String(),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	require.NoError(t, err)
	t.Cleanup(func() { conn.Close() })

	return orderv1.NewOrderServiceClient(conn)
}

func TestCreateOrder_Success(t *testing.T) {
	mockRepo := mocks.NewOrderRepository(t)
	mockRepo.On("Create", mock.Anything, "customer-123", mock.Anything).
		Return(&orderv1.Order{
			Id:         "order-456",
			CustomerId: "customer-123",
			Status:     orderv1.OrderStatus_ORDER_STATUS_PENDING,
		}, nil)

	client := setupTestServer(t, mockRepo)

	resp, err := client.CreateOrder(context.Background(), &orderv1.CreateOrderRequest{
		CustomerId: "customer-123",
		Items: []*orderv1.OrderItem{
			{ProductId: "prod-1", Quantity: 2, Price: 99.99},
		},
	})

	require.NoError(t, err)
	assert.Equal(t, "order-456", resp.Order.Id)
	mockRepo.AssertExpectations(t)
}

func TestCreateOrder_ValidationError(t *testing.T) {
	mockRepo := mocks.NewOrderRepository(t)
	client := setupTestServer(t, mockRepo)

	_, err := client.CreateOrder(context.Background(), &orderv1.CreateOrderRequest{
		CustomerId: "", // пустой customer_id
		Items:      []*orderv1.OrderItem{},
	})

	require.Error(t, err)
	st, ok := status.FromError(err)
	require.True(t, ok)
	assert.Equal(t, codes.InvalidArgument, st.Code())
	mockRepo.AssertNotCalled(t, "Create")
}

Инструменты для ручного тестирования

Для ручного тестирования gRPC-сервисов незаменимы следующие инструменты:

  • grpcurl — curl для gRPC. Позволяет отправлять запросы к серверу с включённым reflection: grpcurl -plaintext localhost:50051 order.v1.OrderService/GetOrder
  • grpcui — браузерный интерфейс для gRPC, аналог Postman.
  • evans — интерактивная REPL-оболочка для gRPC с поддержкой TLS и метаданных.
  • ghz — инструмент нагрузочного тестирования gRPC: ghz --insecure --proto order.proto --call order.v1.OrderService.GetOrder -d '{"order_id":"123"}' localhost:50051

Контрактное тестирование с protovalidate

// Используем buf validate для валидации proto-контрактов
// в order.proto добавляем:
// import "buf/validate/validate.proto";
//
// message CreateOrderRequest {
//   string customer_id = 1 [(buf.validate.field).string.min_len = 1];
//   repeated OrderItem items = 2 [(buf.validate.field).repeated.min_items = 1];
// }

// В сервере используем interceptor:
func validationInterceptor() grpc.UnaryServerInterceptor {
	v, _ := protovalidate.New()
	return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
		if msg, ok := req.(proto.Message); ok {
			if err := v.Validate(msg); err != nil {
				return nil, status.Errorf(codes.InvalidArgument, "validation failed: %v", err)
			}
		}
		return handler(ctx, req)
	}
}

10. Заключение: когда gRPC — правильный выбор, а когда лучше REST API

После глубокого погружения в gRPC для Go и Kubernetes подведём итоги: когда стоит инвестировать в gRPC, а когда проще оставаться на REST API.

gRPC — правильный выбор, когда:

  • Межсервисное взаимодействие происходит внутри Kubernetes-кластера, где клиенты — другие сервисы, а не браузеры.
  • Критична производительность: highload-системы с тысячами RPS на сервис получат ощутимый выигрыш от бинарной сериализации и HTTP/2.
  • Необходим строгий API-контракт: .proto-файлы как единственный источник истины предотвращают несовместимые изменения.
  • Требуется стриминг: real-time обновления, event streaming, двунаправленное взаимодействие.
  • Многоязычная среда: .proto-файлы генерируют клиентов на Go, Python, Java, Rust — без дублирования кода.
  • Важна observability из коробки: gRPC status codes, стандартные метрики через Prometheus, нативная интеграция с OpenTelemetry.

REST API остаётся лучшим выбором, когда:

  • API публично доступен и потребляется браузерами или сторонними клиентами.
  • Команда небольшая и overhead на proto-файлы и кодогенерацию не оправдан.
  • Необходима простая отладка через curl без специализированных инструментов.
  • Сервис интегрируется с legacy-системами или third-party API, ожидающими JSON/HTTP.
  • Требуется поддержка webhooks или простых CRUD-операций без highload.

Оптимальная стратегия для большинства команд в 2026 году: gRPC для внутреннего межсервисного взаимодействия (east-west трафик) и REST API или GraphQL для внешних публичных API (north-south трафик). При этом для gRPC-сервисов в Kubernetes обязательно настройте client-side или proxy-based балансировку нагрузки, полноценную observability с Prometheus и OpenTelemetry, а также mTLS через cert-manager — эти три компонента превращают базовую gRPC-интеграцию в надёжную production-готовую систему.

CI/CD-пайплайны для gRPC-сервисов на Go также стоит адаптировать: добавьте этапы линтинга proto-файлов через buf, проверки breaking changes (buf breaking), кодогенерацию и запуск интеграционных тестов с реальным gRPC-сервером — это обеспечит стабильность контрактов в быстро растущей микросервисной архитектуре.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →