Перейти к содержимому

gRPC Advanced

Зачем знать: gRPC — де-факто стандарт internal service-to-service коммуникации в Go-экосистеме. Middle 1 знает базу (proto, unary). Middle 2 обязан понимать streaming паттерны (включая flow control, half-close), interceptors chains, deadlines, retry/hedging, load balancing (pick-first, round-robin, xDS), health checks для k8s, gRPC-Gateway, и альтернативы вроде Connect-Go. Без этого нельзя правильно построить mesh, debug latency tail, и survive в продакшене.

  1. Базовая концепция
  2. Глубокое погружение (под капотом)
  3. Gotchas (12+)
  4. Production-практики
  5. Вопросы (30)
  6. Practice
  7. Источники

gRPC = HTTP/2 + Protobuf + code generation + RPC семантика. Каждый RPC = один HTTP/2 stream. Stream идентифицируется по :path = /package.Service/Method. Сообщения сериализуются в Protobuf и оборачиваются в length-prefixed frames (5 байт header + payload).

1. Unary
Client: ----Req----> Server
Client: <----Resp--- Server
(один request, один response)
2. Server streaming
Client: ----Req----> Server
Client: <----Resp1-- Server
Client: <----Resp2-- Server
Client: <----RespN-- Server
(один request, N responses)
3. Client streaming
Client: ----Req1----> Server
Client: ----Req2----> Server
Client: ----ReqN----> Server
Client: ----half-close (END_STREAM)
Client: <----Resp--- Server
(N requests, один response)
4. Bidirectional streaming
Client: ----Req----> Server
Client: <----Resp--- Server
... независимо в обе стороны ...
обе стороны могут half-close
syntax = "proto3";
package chat.v1;
option go_package = "example.com/chat/v1;chatv1";
service Chat {
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
rpc Subscribe(SubscribeRequest) returns (stream Event); // server stream
rpc Upload(stream Chunk) returns (UploadAck); // client stream
rpc Channel(stream ClientMsg) returns (stream ServerMsg); // bidi
}
message SendMessageRequest { string text = 1; }
message SendMessageResponse { string id = 1; }
message SubscribeRequest { string channel = 1; }
message Event { string body = 1; }
message Chunk { bytes data = 1; }
message UploadAck { uint64 total = 1; }
message ClientMsg { string body = 1; }
message ServerMsg { string body = 1; }

Генерация:

Окно терминала
# C использованием protoc:
protoc --go_out=. --go-grpc_out=. chat.proto
# Или Buf (рекомендую):
# buf.gen.yaml:
# version: v1
# plugins:
# - plugin: buf.build/protocolbuffers/go
# out: gen
# opt: paths=source_relative
# - plugin: buf.build/grpc/go
# out: gen
# opt: paths=source_relative
buf generate
package main
import (
"io"
"log"
"net"
chatv1 "example.com/chat/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type chatServer struct {
chatv1.UnimplementedChatServer
}
// Server streaming: один Recv, N Send
func (s *chatServer) Subscribe(req *chatv1.SubscribeRequest,
stream chatv1.Chat_SubscribeServer) error {
for i := 0; i < 5; i++ {
if err := stream.Send(&chatv1.Event{Body: "tick"}); err != nil {
return err
}
}
return nil
}
// Client streaming: N Recv, один SendAndClose
func (s *chatServer) Upload(stream chatv1.Chat_UploadServer) error {
var total uint64
for {
chunk, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&chatv1.UploadAck{Total: total})
}
if err != nil {
return err
}
total += uint64(len(chunk.Data))
}
}
// Bidi streaming: read/write независимо
func (s *chatServer) Channel(stream chatv1.Chat_ChannelServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if err := stream.Send(&chatv1.ServerMsg{Body: "echo: " + msg.Body}); err != nil {
return err
}
}
}
func (s *chatServer) SendMessage(_ context.Context,
req *chatv1.SendMessageRequest) (*chatv1.SendMessageResponse, error) {
if req.Text == "" {
return nil, status.Error(codes.InvalidArgument, "empty text")
}
return &chatv1.SendMessageResponse{Id: "msg-1"}, nil
}
func main() {
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer()
chatv1.RegisterChatServer(srv, &chatServer{})
log.Fatal(srv.Serve(lis))
}
package main
import (
"context"
"io"
"log"
"time"
chatv1 "example.com/chat/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
conn, err := grpc.NewClient("localhost:9090",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cli := chatv1.NewChatClient(conn)
// Server streaming
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := cli.Subscribe(ctx, &chatv1.SubscribeRequest{Channel: "x"})
if err != nil {
log.Fatal(err)
}
for {
ev, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
log.Println("got:", ev.Body)
}
// Client streaming
up, _ := cli.Upload(ctx)
for i := 0; i < 3; i++ {
up.Send(&chatv1.Chunk{Data: []byte("hello")})
}
ack, _ := up.CloseAndRecv()
log.Println("uploaded total:", ack.Total)
// Bidi
ch, _ := cli.Channel(ctx)
go func() {
for {
m, err := ch.Recv()
if err != nil {
return
}
log.Println("from server:", m.Body)
}
}()
ch.Send(&chatv1.ClientMsg{Body: "ping"})
time.Sleep(time.Second)
ch.CloseSend()
}

Каждое сообщение в gRPC обёрнуто в length-prefixed frame:

+------------+----------------------+----------------------------+
| 1 byte | 4 bytes | N bytes |
| compressed | message length (BE) | serialized protobuf |
| flag | | |
+------------+----------------------+----------------------------+
  • compressed flag: 0 = uncompressed, 1 = compressed (algorithm в header grpc-encoding)
  • length: BE uint32

Эти 5-байтные frames кладутся в DATA frames HTTP/2.

:method POST
:scheme https
:path /chat.v1.Chat/SendMessage
:authority chat.example.com
content-type application/grpc (или application/grpc+proto, application/grpc+json)
grpc-encoding gzip
grpc-timeout 1S (1 секунда, propagated deadline)
grpc-trace-bin <bin> (binary metadata)
authorization Bearer ... (auth)
user-agent grpc-go/1.60.0
te trailers (обязательно!)

⚠️ HTTP/2 требует te: trailers. Без него — protocol violation. gRPC сильно полагается на trailers (для status code в конце stream).

В отличие от REST, в gRPC статус сообщается в HTTP/2 trailers (заголовки после payload):

grpc-status: 0 (codes.OK)
grpc-message: ... (URL-encoded)
grpc-status-details-bin (опционально, бинарные details)

Это нужно, потому что для streaming не известен заранее final status — он определяется в конце stream.

⚠️ HTTP/1.1 proxy, не понимающий trailers, ломает gRPC. NGINX < 1.13.10 не передавал trailers.

gRPC наследует flow control HTTP/2:

  • per-stream window (default 64 КБ)
  • per-connection window

При высокой пропускной способности увеличивайте:

srv := grpc.NewServer(
grpc.InitialWindowSize(1<<20), // 1 MiB per stream
grpc.InitialConnWindowSize(2<<20), // 2 MiB per connection
)
conn, _ := grpc.NewClient(addr,
grpc.WithInitialWindowSize(1<<20),
grpc.WithInitialConnWindowSize(2<<20),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
Server.Send(...) блокируется, если client не успел Recv → flow control window 0
Client.Recv() ждёт пока server отправит data → блокируется
Если client медленный → server's Send блокирован → server's memory заполняется буферами

⚠️ В client streaming с большими payload’ами это критично. Без deadlines клиент может «висеть» бесконечно.

Bidi RPC:
A → B: req1
A → B: req2
A → B: END_STREAM (CloseSend)
(A больше не шлёт, но B может слать ещё)
B → A: resp1
B → A: resp2
B → A: END_STREAM + trailers

CloseSend в Go — это flush END_STREAM frame.

Интерсепторы = middleware. Бывают:

  • Unary Server / Unary Client
  • Stream Server / Stream Client
// Unary Server interceptor
func loggingUnary(ctx context.Context, req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("method=%s dur=%s err=%v", info.FullMethod, time.Since(start), err)
return resp, err
}
// Stream Server interceptor (оборачивает stream)
func loggingStream(srv any, ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler) error {
start := time.Now()
err := handler(srv, ss)
log.Printf("stream=%s dur=%s err=%v", info.FullMethod, time.Since(start), err)
return err
}
// Несколько interceptors в chain:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryUnary, // panic recovery — должен быть первым
loggingUnary,
authUnary,
ratelimitUnary,
tracingUnary,
),
grpc.ChainStreamInterceptor(
recoveryStream,
loggingStream,
authStream,
),
)

⚠️ Порядок важен:

  1. recovery — первый, чтобы поймать panic из любого нижестоящего.
  2. tracing/logging — раннее, чтобы видеть весь request lifecycle.
  3. auth — до бизнес-логики.
  4. rate limit — после auth (rate-limit per user).
conn, _ := grpc.NewClient(addr,
grpc.WithChainUnaryInterceptor(
timeoutClientInterceptor,
retryClientInterceptor,
tracingClientInterceptor,
),
)
// На клиенте
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := cli.GetUser(ctx, req)

gRPC передаёт оставшийся deadline в header grpc-timeout: 2S. На сервере:

func (s *server) GetUser(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
deadline, ok := ctx.Deadline()
if ok {
log.Printf("remaining=%s", time.Until(deadline))
}
// ...
}

⚠️ Сервер ДОЛЖЕН пробрасывать ctx в downstream вызовы. Иначе deadline не propagated → orphaned работа.

CodeNameHTTP EquivalentКогда
0OK200Успех
1CANCELLED499Клиент отменил
2UNKNOWN500Неизвестная ошибка
3INVALID_ARGUMENT400Невалидный input
4DEADLINE_EXCEEDED504Timeout
5NOT_FOUND404Нет ресурса
6ALREADY_EXISTS409Дубликат
7PERMISSION_DENIED403Auth есть, прав нет
8RESOURCE_EXHAUSTED429Rate limit / quota
9FAILED_PRECONDITION400Состояние не подходит
10ABORTED409Конфликт (optimistic)
11OUT_OF_RANGE400Параметр вне диапазона
12UNIMPLEMENTED501Метод не реализован
13INTERNAL500Внутр. ошибка
14UNAVAILABLE503Сервис недоступен
15DATA_LOSS500Данные потеряны
16UNAUTHENTICATED401Auth отсутствует/невалид
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
epb "google.golang.org/genproto/googleapis/rpc/errdetails"
)
st := status.New(codes.InvalidArgument, "invalid email")
st, _ = st.WithDetails(&epb.BadRequest_FieldViolation{
Field: "email",
Description: "must be valid RFC 5322 address",
})
return nil, st.Err()
// На клиенте:
if err != nil {
st, ok := status.FromError(err)
if ok {
log.Printf("code=%s msg=%s", st.Code(), st.Message())
for _, d := range st.Details() {
// type switch на *epb.BadRequest_FieldViolation и т.д.
}
}
}

Headers/trailers через metadata.MD:

import "google.golang.org/grpc/metadata"
// Клиент → Сервер (outgoing headers)
ctx := metadata.AppendToOutgoingContext(context.Background(),
"authorization", "Bearer xxx",
"x-request-id", "abc-123",
)
resp, err := cli.GetUser(ctx, req)
// Сервер читает incoming headers
func (s *server) GetUser(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
md, ok := metadata.FromIncomingContext(ctx)
if ok {
if vals := md.Get("authorization"); len(vals) > 0 {
token := strings.TrimPrefix(vals[0], "Bearer ")
// ...
}
}
// Сервер шлёт headers/trailers обратно
header := metadata.Pairs("x-server-id", "srv-42")
grpc.SendHeader(ctx, header)
trailer := metadata.Pairs("x-response-id", "resp-42")
grpc.SetTrailer(ctx, trailer)
return resp, nil
}

⚠️ Headers vs trailers:

  • Header — отправляется ПЕРЕД payload (start of stream).
  • Trailer — отправляется ПОСЛЕ payload (end of stream). Здесь grpc-status.

Встроенный retry policy конфигурируется через JSON service config:

const policy = `{
"methodConfig": [{
"name": [{"service": "chat.v1.Chat", "method": "SendMessage"}],
"retryPolicy": {
"MaxAttempts": 4,
"InitialBackoff": "0.1s",
"MaxBackoff": "1s",
"BackoffMultiplier": 2.0,
"RetryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
}
}]
}`
conn, _ := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(policy),
)

⚠️ Retry только для idempotent RPCs. Если ваш SendMessage не идемпотентен — добавьте идемпотентный ключ (Idempotency-Key).

Hedging — отправка дубликата запроса параллельно, для уменьшения tail latency:

{
"methodConfig": [{
"name": [{"service": "search.Search"}],
"hedgingPolicy": {
"MaxAttempts": 3,
"HedgingDelay": "0.05s",
"NonFatalStatusCodes": []
}
}]
}

Через 50ms если ответа нет — отправляется второй запрос. Первый завершившийся побеждает. ⚠️ Только для idempotent RPCs и при наличии request idempotency на сервере.

gRPC client держит одно соединение к одному адресу. Для нескольких backend’ов нужен балансировщик.

Варианты:

  1. Pick-first (default) — один backend, один connection. Без LB.

  2. Round-robin — несколько subconnections, по одному на backend. RR между ними:

    conn, _ := grpc.NewClient(target,
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
    )
  3. DNS resolver (default) — резолвит DNS, получает все A-records, открывает RR к ним. Refresh каждые 30s.

  4. xDS (Envoy / Istio) — динамический discovery + LB через xds:// target.

import _ "google.golang.org/grpc/xds"
conn, _ := grpc.NewClient("xds:///myservice", ...)
  1. Custom resolver (etcd, Consul, k8s API) — реализация resolver.Builder.
target = scheme://[authority]/endpoint
  • dns:///example.com:9090 (dns resolver, default)
  • unix:///var/run/foo.sock (Unix socket)
  • xds:///service-name
  • passthrough:///host:port (без resolution)
import "google.golang.org/grpc/encoding/gzip"
// автоматически регистрирует gzip кодек
// На клиенте включаем для конкретного RPC:
resp, err := cli.GetUser(ctx, req, grpc.UseCompressor(gzip.Name))
// Или глобально:
conn, _ := grpc.NewClient(addr,
grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
)

Trade-off: gzip CPU intensive на больших payload. Для small messages (<300 байт) compression может быть медленнее. Бенчмарк перед включением.

// Server with TLS
creds, _ := credentials.NewServerTLSFromFile("cert.pem", "key.pem")
srv := grpc.NewServer(grpc.Creds(creds))
// Client with TLS
creds := credentials.NewTLS(&tls.Config{
ServerName: "myservice.example.com",
})
conn, _ := grpc.NewClient("myservice.example.com:443",
grpc.WithTransportCredentials(creds),
)
// mTLS server
cert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
caPool := x509.NewCertPool()
caBytes, _ := os.ReadFile("ca.crt")
caPool.AppendCertsFromPEM(caBytes)
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
})
srv := grpc.NewServer(grpc.Creds(creds))

Стандартизированный protocol grpc.health.v1:

service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1; // empty = overall
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}

Реализация:

import (
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
srv := grpc.NewServer()
hsrv := health.NewServer()
healthpb.RegisterHealthServer(srv, hsrv)
hsrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
hsrv.SetServingStatus("chat.v1.Chat", healthpb.HealthCheckResponse_SERVING)

В Kubernetes:

livenessProbe:
grpc:
port: 9090
service: ""
initialDelaySeconds: 5
readinessProbe:
grpc:
port: 9090
service: "chat.v1.Chat"

⚠️ Native gRPC probes в k8s доступны с 1.24 (GA в 1.27). Для старых версий используйте grpc_health_probe binary.

import "google.golang.org/grpc/reflection"
srv := grpc.NewServer()
chatv1.RegisterChatServer(srv, &chatServer{})
reflection.Register(srv) // включает grpc.reflection.v1

Теперь можно использовать grpcurl без proto файлов:

Окно терминала
grpcurl -plaintext localhost:9090 list
grpcurl -plaintext localhost:9090 describe chat.v1.Chat
grpcurl -plaintext -d '{"text":"hi"}' localhost:9090 chat.v1.Chat/SendMessage

⚠️ В production reflection лучше отключать (security — экспонирует schema).

HTTP/JSON ↔ gRPC прокси. Через protobuf annotations:

import "google/api/annotations.proto";
service Chat {
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) {
option (google.api.http) = {
post: "/v1/messages"
body: "*"
};
}
rpc GetMessage(GetMessageRequest) returns (Message) {
option (google.api.http) = {
get: "/v1/messages/{id}"
};
}
}

Архитектура:

HTTP/JSON client → grpc-gateway (HTTP) → gRPC server (HTTP/2)
^^^^^^^^^^^^^^^^^^
обычно в том же процессе или sidecar

Connect — современный протокол от Buf, совместимый с gRPC, но также поддерживает HTTP/1.1 + JSON для browser/curl без gRPC-Web прокси:

// Сервис в том же proto:
service Chat {
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
}
// Генерация:
// buf.gen.yaml
// version: v2
// plugins:
// - remote: buf.build/connectrpc/go
// out: gen

Connect-server:

import "connectrpc.com/connect"
mux := http.NewServeMux()
mux.Handle(chatv1connect.NewChatServiceHandler(&chatServer{}))
http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))

Connect-client может работать как gRPC или как HTTP/JSON:

client := chatv1connect.NewChatServiceClient(
http.DefaultClient,
"http://localhost:8080",
connect.WithGRPC(), // или connect.WithGRPCWeb() или ничего (Connect protocol)
)

Преимущества:

  • HTTP/JSON без gateway, no codegen reflection.
  • Совместим с gRPC.
  • Pure Go без cgo.
  • Из коробки браузер поддерживается.

Для браузерных клиентов: gRPC-Web — это spec, требующий прокси (Envoy, grpcwebproxy), потому что браузер не имеет доступа к trailers и низкоуровневому HTTP/2.

МетрикаgRPCREST/JSON
EncodingProtobuf binaryJSON (text)
TransportHTTP/2HTTP/1.1 (часто)
Throughput~2-5x быстрееbaseline
Latency (per req)~50% от RESTbaseline
CPU encodingFastSlow (JSON parse)
Network-50..-80%baseline
Browser supportNeed gatewayNative

  1. ⚠️ grpc.Dial deprecated в 2024, используйте grpc.NewClient. Старый Dial блокировал до connection, NewClient — lazy.

  2. ⚠️ Один client connection — один HTTP/2 connection. Все RPC мультиплексятся через streams. Для пика throughput иногда нужно несколько connections (по дефолту MaxConcurrentStreams = 100, после — стримы queue’аtся).

  3. ⚠️ Большие сообщения по умолчанию rejected. Default 4 МБ:

    grpc.MaxRecvMsgSize(64 << 20) // 64 MiB
    grpc.MaxSendMsgSize(64 << 20)
  4. ⚠️ Streams не имеют per-message deadline. Deadline применяется ко всему streamу. Для долгих streams (часами) — без deadline или с very long.

  5. ⚠️ Stream send/recv не concurrent-safe. Одна goroutine отправляет, другая принимает — это OK (разные направления). Но stream.Send из двух goroutines одновременно — race.

  6. ⚠️ stream.Send блокируется при flow control. Если client медленный — server’s Send не вернётся, пока окно не освободится. Это backpressure, но может выглядеть как deadlock.

  7. ⚠️ После stream.CloseSend в client streaming — нельзя больше Send’ить. Иначе panic / error.

  8. ⚠️ Stream interceptor не имеет доступа к message data легко. Если нужно — оборачивайте grpc.ServerStream своим типом, реализующим RecvMsg и SendMsg.

  9. ⚠️ UnimplementedFooServer обязателен (forward compat). Каждый сервер должен embed unimplemented type, иначе при добавлении методов в .proto старые серверы не компилируются.

  10. ⚠️ context.Background() в interceptor — потеря deadline. Всегда пробрасывайте ctx.

  11. ⚠️ DNS resolver кэширует 30s. Если в k8s pod исчез, gRPC может слать в мертвый IP до next DNS refresh. Решение: explicit reconnect, headless service + RR, xDS.

  12. ⚠️ gRPC client держит idle connection бесконечно. Без keepalive — соединение может стать «зомби» (NAT timeout, proxy drop):

    grpc.WithKeepaliveParams(keepalive.ClientParameters{
    Time: 10 * time.Second,
    Timeout: 3 * time.Second,
    PermitWithoutStream: true,
    })
  13. ⚠️ Server keepalive policy. Default Go-сервер банит клиентов с too-frequent pings (MinTime: 5min). Должно быть согласовано:

    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
    MinTime: 5 * time.Second,
    PermitWithoutStream: true,
    })
  14. ⚠️ Bad load balancer + bidi streaming. Если LB на L4 (не gRPC-aware), он не балансирует streams — все streams идут через одну connection → backend uneven load. Решение: gRPC-aware proxy.

  15. ⚠️ Headers vs trailers недостаток. Headers — отправлены до payload, после нельзя изменить. Trailers — после, но не все proxy/middlebox их понимают (например, NGINX < 1.13).

  16. ⚠️ Status WithDetails требует protobuf-зарегистрированных типов. Custom proto.Message нужен в global registry. Иначе client при st.Details() получит unknown messages.


package main
import (
"context"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/reflection"
chatv1 "example.com/chat/v1"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
MaxConnectionIdle: 5 * time.Minute,
MaxConnectionAge: 30 * time.Minute,
MaxConnectionAgeGrace: 30 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 10 * time.Second,
PermitWithoutStream: true,
}),
grpc.MaxConcurrentStreams(1000),
grpc.MaxRecvMsgSize(16<<20),
grpc.InitialWindowSize(1<<20),
grpc.InitialConnWindowSize(2<<20),
grpc.ChainUnaryInterceptor(
recovery.UnaryServerInterceptor(),
loggingUnary,
authUnary,
ratelimitUnary,
),
grpc.ChainStreamInterceptor(
recovery.StreamServerInterceptor(),
loggingStream,
authStream,
),
)
chatv1.RegisterChatServer(srv, &chatServer{})
hsrv := health.NewServer()
healthpb.RegisterHealthServer(srv, hsrv)
hsrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(srv) // снять в prod
// Параллельно HTTP метрик-сервер
metrics := &http.Server{
Addr: ":9091",
Handler: metricsRouter(),
}
go metrics.ListenAndServe()
go func() {
if err := srv.Serve(lis); err != nil {
log.Fatal(err)
}
}()
<-ctx.Done()
log.Println("shutting down...")
hsrv.Shutdown() // помечает все services как NOT_SERVING
done := make(chan struct{})
go func() {
srv.GracefulStop()
close(done)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
srv.Stop()
}
metrics.Shutdown(context.Background())
}
func newClient(target string) (*grpc.ClientConn, error) {
return grpc.NewClient(target,
grpc.WithTransportCredentials(loadTLS()),
grpc.WithDefaultServiceConfig(`{
"loadBalancingConfig": [{"round_robin": {}}],
"methodConfig": [{
"name": [{"service": "chat.v1.Chat"}],
"timeout": "5s",
"retryPolicy": {
"MaxAttempts": 3,
"InitialBackoff": "0.1s",
"MaxBackoff": "1s",
"BackoffMultiplier": 2,
"RetryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
}
}]
}`),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: true,
}),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(16<<20),
grpc.WaitForReady(true),
),
grpc.WithChainUnaryInterceptor(
tracingUnaryClient,
metricsUnaryClient,
),
)
}
func authUnary(ctx context.Context, req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
if strings.HasPrefix(info.FullMethod, "/grpc.health.v1.") {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "no metadata")
}
vals := md.Get("authorization")
if len(vals) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing auth")
}
tokenStr := strings.TrimPrefix(vals[0], "Bearer ")
user, err := verifyJWT(tokenStr)
if err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
}
ctx = context.WithValue(ctx, "user", user)
return handler(ctx, req)
}
func recoveryUnary(ctx context.Context, req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (resp any, err error) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("PANIC method=%s err=%v\n%s", info.FullMethod, r, stack)
err = status.Errorf(codes.Internal, "internal server error")
}
}()
return handler(ctx, req)
}
import "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
srvMetrics := grpc_prom.NewServerMetrics(
grpc_prom.WithServerHandlingTimeHistogram(),
)
prometheus.MustRegister(srvMetrics)
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(srvMetrics.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(srvMetrics.StreamServerInterceptor()),
)
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
srv := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
conn, _ := grpc.NewClient(target,
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
import "golang.org/x/time/rate"
var lim = rate.NewLimiter(rate.Limit(1000), 2000) // 1K rps, burst 2K
func ratelimitUnary(ctx context.Context, req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
if !lim.Allow() {
return nil, status.Error(codes.ResourceExhausted, "rate limit")
}
return handler(ctx, req)
}
buf.yaml
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
- remote: buf.build/grpc/go
out: gen
Окно терминала
buf lint
buf breaking --against '.git#branch=main' # CI check на breaking changes
buf generate
buf push # push schema в Buf Schema Registry
// Создаём pool из N connections к одному адресу
type connPool struct {
conns []*grpc.ClientConn
next uint64
}
func (p *connPool) get() *grpc.ClientConn {
i := atomic.AddUint64(&p.next, 1) % uint64(len(p.conns))
return p.conns[i]
}

Каждое соединение — свой HTTP/2 connection → больше параллельных streams. Полезно при >250 concurrent streams (default limit).

  • Internal microservices — gRPC mesh с Istio/Linkerd, mTLS встроен.
  • Mobile API — gRPC (Android Java) или Connect-Go (iOS Swift), один stack для server + mobile.
  • Browser apps — gRPC-Web через Envoy, или Connect-Go (предпочтительнее, без Envoy).
  • Streaming — server streaming для feeds, bidi для chat/control planes.
  • Public API — REST для legacy, gRPC + gRPC-Gateway (HTTP/JSON) или Connect-Go (single proto + два транспорта).

  1. Какие 4 типа RPC в gRPC? Unary, server streaming, client streaming, bidirectional streaming.

  2. Что такое HTTP/2 stream и как mapping на gRPC? Каждый gRPC call = один HTTP/2 stream. Stream ID идентифицирует call. Multiplexing внутри одного TCP connection.

  3. Что в HTTP/2 trailers для gRPC? grpc-status, grpc-message, опционально grpc-status-details-bin. Trailers (HEADERS frame с END_STREAM) шлются в конце.

  4. Зачем te: trailers header? HTTP/2 spec требует. Без него — protocol violation. Сигнал, что клиент понимает trailers.

  5. Как deadline propagated через gRPC? Через header grpc-timeout. Сервер видит deadline и должен прокинуть ctx дальше.

  6. Что такое status.Status в gRPC? Struct {code, message, details}. Сериализуется в gRPC-status, gRPC-message, gRPC-status-details-bin. status.Error(codes.NotFound, "msg") → клиент status.FromError(err).

  7. Чем отличаются Unary и Stream interceptors? Unary получает req, возвращает resp. Stream получает ServerStream — оборачивает send/recv. Stream interceptor более сложен (часто wrapper вокруг ServerStream).

  8. Как сделать chain interceptors? grpc.ChainUnaryInterceptor(a, b, c). Порядок выполнения: a → b → c → handler → c → b → a (как middleware в HTTP).

  9. Где применять recovery interceptor? Первым в цепочке — чтобы поймать panic из любого нижестоящего (включая бизнес-логику).

  10. Что такое half-close в bidi streaming? Одна сторона больше не отправляет (END_STREAM), другая ещё может. Client делает CloseSend().

  11. Что такое flow control в gRPC streaming? HTTP/2 flow control: per-stream window. Server.Send блокирует, если window 0 → backpressure. Тюнинг через InitialWindowSize.

  12. Как реализовать retry в gRPC client? Через service config JSON: retryPolicy с MaxAttempts, InitialBackoff, RetryableStatusCodes. Retry только для idempotent методов.

  13. Что такое hedging? Отправка дубликата запроса с небольшой задержкой → побеждает первый ответ. Уменьшает p99 latency. Только для idempotent.

  14. Какие коды gRPC мапятся на 503? UNAVAILABLE — обычно временная недоступность, retry-friendly. RESOURCE_EXHAUSTED → 429.

  15. Чем headers отличаются от trailers в gRPC? Headers — до payload, не могут меняться. Trailers — после, тут grpc-status. Использование: headers для request-id, trailers для final status.

  16. Как пробросить authorization в gRPC? Через metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer ..."). Сервер читает через metadata.FromIncomingContext.

  17. Какие методы name resolution в gRPC? dns:///host:port (default), unix:///path, xds:///name, custom resolvers (etcd, Consul).

  18. Как работает round-robin LB в gRPC? Resolver возвращает список адресов → балансировщик открывает SubConn’ы → распределяет RPCs round-robin между ними. Конфиг: loadBalancingConfig: [{"round_robin":{}}].

  19. Что такое xDS? Envoy-style discovery API. gRPC может работать как xDS client → получает endpoints, LB policies, TLS config динамически (Istio управляет).

  20. Зачем compression в gRPC? Уменьшение байт на провод. Trade-off CPU. Включается через grpc.UseCompressor("gzip"). Не для маленьких сообщений.

  21. Health check protocol — зачем? Стандартизированный способ для k8s probes, LB health checks. grpc.health.v1.Health.Check/Watch.

  22. Зачем gRPC reflection? Позволяет grpcurl, Postman, BloomRPC работать без .proto файла. В prod лучше отключать (security).

  23. Что такое gRPC-Gateway? HTTP/JSON proxy → gRPC backend. Через google.api.http annotations в .proto. Один кодекс — два транспорта.

  24. Чем Connect-Go отличается от стандартного gRPC? Connect — protocol-совместимая альтернатива. Поддерживает gRPC, gRPC-Web, и собственный Connect protocol (HTTP/1.1 + JSON / Protobuf). Pure Go, нет cgo. Браузер из коробки.

  25. Когда выбирать gRPC vs REST? gRPC: internal service-to-service, streaming, mobile native. REST: public API, browser (без proxy), low-frequency.

  26. Какие настройки keepalive обязательны? Client: Time: 30s, Timeout: 10s, PermitWithoutStream: true. Server: согласовать MinTime с client Time иначе server банит клиента.

  27. Что происходит, если client отменяет RPC? Context cancelled → клиент шлёт RST_STREAM (CANCELLED). Сервер видит ctx.Done() → должен прекратить работу.

  28. Можно ли передавать большие файлы через gRPC? Да, через client streaming (chunks). Default size limit 4 МБ — увеличьте MaxRecvMsgSize. Лучше streaming chunks (1-4 МБ каждый).

  29. Что делать с long-running streams (часы)? Без deadline на ctx, периодические keepalive ping, обработка disconnect (network failures). Может потребоваться custom reconnect протокол на app-level.

  30. Как сделать graceful shutdown gRPC сервера? srv.GracefulStop() — ждёт окончания текущих RPC. Перед этим health.SetServingStatus(NOT_SERVING) → LB убирает из rotation. Hard cap на timeout → srv.Stop().


Реализуйте bidi-stream Channel(stream ClientMsg) returns (stream ServerMsg). Server эхо-отражает сообщения с префиксом “echo:”.

Реализуйте Subscribe(req) returns (stream Event) который шлёт по 1 event/sec. Клиент устанавливает 3-секундный deadline. Проверьте, что сервер видит ctx.Done() через 3 секунды.

Соберите chain: recovery → logging → auth → ratelimit. Проверьте порядок логов при panic, при invalid token, при rate-limit.

Сервер: возвращает UNAVAILABLE для 2 первых запросов, потом OK. Клиент: настроен MaxAttempts: 3. Проверьте через grpcurl или клиент, что RPC всё-таки succeeded.

Сервер: с вероятностью 30% sleeps 1s, иначе сразу отвечает. Клиент с hedging policy MaxAttempts=3, delay=200ms. Замеряйте p50/p99 latency с и без hedging.

Запустите 3 экземпляра сервера на портах 9090, 9091, 9092. Клиент: dns:///localhost:9090,localhost:9091,localhost:9092 + round_robin. Делайте 100 RPCs, проверьте равное распределение.

Реализуйте health server. Сделайте Dockerfile, deployment в minikube с livenessProbe: grpc:. Через kubectl exec пометьте сервис как NOT_SERVING — k8s рестартит pod.

Сделайте gRPC сервис + gateway. Endpoint POST /v1/messagesSendMessage. Откройте swagger UI (через protoc-gen-openapiv2).

Возвращайте BadRequest_FieldViolation для invalid request. На клиенте распарсите detail через type switch и выведите field+description.

Сконвертируйте свой gRPC сервис на Connect-Go. Один тот же proto — два варианта клиента: gRPC (connect.WithGRPC()) и Connect (HTTP/JSON). Сравните performance.