cover

プロダクション環境でのgRPCの使用

sorcererxw

本記事では、バックエンドに gRPC を導入する過程で行った作業の一部をまとめ、Go 言語をベースに gRPC の実践を示します。

HTTP/JSON インターフェースとの互換性

既存のシステムに移行する際には、必ず2点を考慮する必要があります:

  • 既存のサービスがgRPCインターフェースを呼び出す方法
  • 新しいサービスがHTTPインターフェースを呼び出す方法

私たちがすでにProtoBufで各RPCとHTTPのマッピングを定義した場合、理想的にはProtoBufをシングルソースオブトゥルースとして直接gRPCクライアント+HTTPクライアントを生成し、さらにそれらが同じ抽象型を使用して、基盤となるトランスポートプロトコルを隠蔽できることが望ましいです。

EnvoyまたはgRPC-Gateway?

gRPCを既存のHTTPインターフェースと互換させるために、業界ではすでにいくつかの解決策があります:https://github.com/envoyproxy/envoyhttps://github.com/grpc-ecosystem/grpc-gateway......しかし、全体的にはプロトコルを変換するためのフロントエンドゲートウェイを通じて実現されています。

Envoy では、コンテナ内に個別に Envoy Sidecar ゲートウェイプロセスを起動し、その gRPC-JSON トランスコーダ を使用してプロトコル変換を実現することが理想的な解決策です。これにより、プロトコル変換の作業をインフラストラクチャに委ね、すべての言語が互換性を持ち、サービス自体は gRPC の実装に集中でき、コードをシンプルに保つことができます。

gRPC-Gateway 方案は、ProtoBuf 定義に基づいて HTTP サービスを生成し、HTTP リクエストを受け取って gRPC サービスを呼び出します。既存のサービスコードに組み込むことができ、サービスと共に起動します。

gRPC-Gateway に比べて、envoy 方案はサービスに外部依存を追加する必要がありますが、現時点では、初期移行の過程で gRPC-Gateway を選択することで、全てのロジックを一つのプロセス内で制御でき、デバッグが容易で、よりシンプルで実用的です。

定義した ProtoBuf RPC インターフェースのマッピング

gRPC はすでに一連の JSON 型変換プロトコル を提供しており、一方で Google API は HTTP インターフェースから gRPC インターフェースへのマッピング の規格も提供しています。gRPC-Gateway はこの規格に基づいてプロトコル変換を実現しています。その表現力は非常に強く、通常の HTTP インターフェースのほぼ全ての要求を完全に満たしています:

  • クエリパラメータ、パスパラメータ、ボディパラメータをリクエストメッセージにマッピングするサポート
  • すべてのHTTPメソッドをサポート
  • 複数のHTTPインターフェースが同一のRPCにマッピングされることをサポートしています。
service UserService {
	rpc GetUser(GetUserRequest) returns (GetUserResponse) {
		option(google.api.http) = {
			get: "/internals/users/{id}"
			additional_bindings: {
        post: "/internals/user/getById"
        body: "*"
			}
		}	
	};
}

詳細については、GoogleのHttpRuleの注釈文書gRPCの文書を参照してください。これらの文書では、プロトコルと使用法について非常に詳細に説明しています。

私たちはProtoBufの定義に従って、protoc-gen-grpc-gateway を使用することで、対応するRPCマッピングロジックコードを生成することができます。

gRPC-Gateway をマウントする

gRPC-Gateway は複数のマウント方法を提供しています:

  • RegisterHandlerServer:Serverの実装をバインドし、gRPC-GatewayがHTTPリクエストを直接対応するServerに転送しますが、ストリームリクエストはサポートしていません。
  • RegisterHandlerFromEndpoint:ターゲットアドレスをバインドし、gRPC-Gateway がそのアドレスに接続してリバースプロキシを実現します。
  • RegisterHandler:gRPC-Gateway がリバースプロキシする gRPC サーバーコネクションをバインドします。
  • RegisterHandlerClient:gRPC-Gateway が HTTP リクエストをこのクライアントへの呼び出しに変換するために、gRPC クライアントをバインドします。

現在、gRPC-Gateway はリバースプロキシの方式をより推奨しており、gRPC の能力を最大限に発揮できます。

では、一つのプロセス内で gRPC サーバーと HTTP サーバーをそれぞれ別々のポートで起動し、HTTP サーバーがルールに従ってリクエストをインターセプトし、gRPC-Gateway を経由して gRPC サーバーに送信することができます:

// HTTP
gw := runtime.NewServeMux() // 新建 grpc gateway ServerMux
service.RegisterHandlerFromEndpoint(context.Background(), gw, ":9090", []grpc.DialOption{grpc.WithInsecure()}) // 注册目标 gRPC 服务

e := echo.New()
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		// 使用 echo 完成 HTTP 路由
		// 在最外层直接拦截符合规则的请求,避免经过任何 HTTP Middleware
		if strings.HasPrefix(c.Path(), "/internals/") {
			gw.ServeHTTP(c.Response(), c.Request())
			return nil
		}
		return next(c)
	}
})
e.Use(MiddlewareA)
e.Use(MiddlewareB)
e.GET("/v1/user", handler)
// ...
go e.Start(":3000")

// gRPC

l, _ := net.Listen("tcp", ":9090")
srv := grpc.NewServer()
// ...
go srv.Serve(l)
⚠️
K8S のサービスディスカバリを使用する際には、ポートが追加されたため、K8S Service の宣言で gRPC のポートマッピングを設定することを忘れないでください。

同じポートをリッスンしますか?

コミュニティでは、もう一つの人気のある方法があります。それは https://github.com/soheilhy/cmux を使用して、HTTPのタイプとContent-Typeに基づいて目的のアドレスを区別する方法です。リクエストがHTTP/2であり、Content-Typeがapplication/grpcである場合は、リクエストを直接gRPCサーバーに転送します。この方法は、サービスプロセス内にさらにゲートウェイを追加することに相当し、これによりgRPCとHTTPサービスが同じポートをリッスンすることができ、ある程度、ユーザーの認識コストを下げることができます(何も気にせず、このポートに接続するだけです)。

しかし実際に試してみると、このような解決策は理想的に見えますが、実際に得られる利益は非常に限られています。それどころか、いくつかの欠点が存在します:

  • プロセスが終了する際には、cmux、grpcサーバー、httpサーバーの終了順序を正しく調整する必要があります。そうしないとデッドロックが発生する可能性があります。

    https://github.com/soheilhy/cmux/issues/76

  • HTTP Path に基づいて直接分散処理することはできず、別の HTTP Router を通じてさらに分散処理を行う必要があります。

フロントエンドにインターフェースを提供する

さらに進んで、内部呼び出しを提供するだけでなく、gRPCを使ってフロントエンドにインターフェースを提供することもできます。現在のところ、私たちのほとんどのインターフェースは直接gRPCで定義することができ、ゲートウェイを通じてクライアントのHTTPリクエストをgRPC呼び出しに変換します。これによって得られる利点は非常に明白です:

  • 強型の入出力
  • ProtoBuf を使用して OpenAPI ドキュメントを直接生成する
  • サービスプロセスはgRPCサービスのみを気にすればよく、よりシンプルです。
  • プロトコルをゲートウェイで変換し、クライアントに影響を与えない

しかし、画像やウェブページなどのJSONを出力しない特定のインターフェースは、個別に処理する必要があります。

いくつかの罠

HTTPインターフェースをgRPCにマッピングする際には、以前のあまり良くない実践が原因で、データ構造を変換する際にいくつかの問題が生じることがあります:

  • ポリモーフィズム型

    多くの既存インターフェースはポリモーフィックタイプを使用しており、以下のような形式です:

    {
    	"type": "POST" | "USER" | "COMMENT",
    	"data": Post | User | Comment
    }

    このようなタイプはProtoBufで定義することができず、汎用的な設計ではありません。より良い設計は以下の通りです:

    {
    	"type": "POST" | "USER" | "COMMENT",
    	"post": Post,
    	"user": User,
    	"comment": Comment,
    }

    しかし、このようなインターフェースをgRPCで改造しようとする場合、互換性を保証するためには、戻り値の強い型制約を諦め、google.protobuf.Value を使用して対応する data フィールドを定義し、このフィールドを非推奨としてマークするしかありません。

  • null値の処理

    ProtoBufがシリアライズする際には、空のMapやArrayなどの空値を省略します。そのため、トランスコードされたJSONにも当該フィールドが存在しない(undefined)ことになります。上流サービスがデータを受け取った時に、対応するフィールドを直接読み書きすると、ヌルポインターを引き起こす可能性があります。したがって、RPCの戻り値にアクセスする際には、空安全を確保する必要があり、あるフィールドが絶対に空でないと思い込むべきではありません。

    ただし、grpc-gateway が JSON をシリアライズする際には、カスタムコーデックを使用することができます。デフォルトでは、protojson を使用し、EmitUnpopulated を有効にしています。つまり、未定義を使用せずに、すべてのゼロ値フィールドを保持します。

    defaultMarshaler = &HTTPBodyMarshaler{
    		Marshaler: &JSONPb{
    			MarshalOptions: protojson.MarshalOptions{
    				EmitUnpopulated: true,
    			},
    			UnmarshalOptions: protojson.UnmarshalOptions{
    				DiscardUnknown: true,
    			},
    		},
    	}

    ほとんどの場合、JSONレスポンスに大量のデータを冗長に含めることで、上流が直接アクセスしてnullポインターを引き起こすことを避けることができ、大きな問題はありません。しかし、上流が呼び出す際に、ゼロ値とnullの二つの状態を明確に区別する必要があるかどうかには注意が必要です。上流がfield == nullに基づいて対応するロジックを実行する必要がある場合、追加のnull状態を表すためにgoogle.protobufの様々なボックス化された型を導入する必要があります。

  • 型変換

    多くのサービスでは、データモデル(PO)とデータ転送モデル(DTO)を明確に区別していません。プログラムはデータベースオブジェクトを直接プロセス内オブジェクトに逆シリアライズし、最終的にそれを外部に直接公開しています。

    response.Write(GetDataFromDB()) // 一个极端的例子
    

    現在のgRPCが生成するコードでは、DTOが明確に個別に定義されており、コードで明示的に型変換を行う必要があります。

    もしGoやJavaのような、structural subtypingをサポートしていないタイプシステムを使用する場合、型変換を実現するためにかなりのコードを書く必要があるということです。

    もちろん、TypeScriptのような柔軟な型システムを持つ言語を使用することでこの問題を処理することができますが、これはデータベースモデルを変換せずに直接外部に渡すことが良い考えであるという意味ではありません。これはシステム間の結合度を大幅に高めることになります。

HTTP/JSONクライアントの改造

前の記事で触れたように、Goを導入したばかりの時、ProtoBufを基にした基本的なHTTPクライアント生成器を実装しました。ProtoBufの強力な表現力とコード生成器の優れた設計により、多数の内部インターフェースや多くの外部サービスのインターフェースを定義し、HTTPクライアントを生成することで、汎用的でシンプルなRPCクライアントを提供し、開発効率を大幅に向上させました。

現在、このHTTPクライアントは広く使用されているため、gRPCへの段階的な移行の過程で、非互換性をできるだけ減らし、新旧のバージョンの動作が一致するようにしたいと考えています。

このClientインターフェースは、最初からgRPCとの互換性を考慮して設計されていました。現在、わずかな調整を加えるだけで、各サービスのRPCクライアントをスムーズに置き換えることができます:

// 原来版本
type UserServiceClient interface {
	GetUser(ctx context.Context, req *GetUserRequest, ...option.Option) (*GetUserResponse, error)
}

// grpc 生成的版本
type UserServiceClient interface {
	GetUser(ctx context.Context, req *GetUserRequest, ...grpc.CallOption) (*GetUserResponse, error)
}

上記の <code>option.Option</code> は私たちがカスタマイズした呼び出しオプションであり、<code>grpc.CallOption</code> はgRPCが要求する呼び出しオプションです。現在、これら二つのタイプは互換性がないのですが、<code>option.Option</code> を改造して二つのインターフェースを実装することで、新旧のバージョンをスムーズに互換させることができます。

userservice.GetUser(ctx, &userservice.GetUserRequest{}, option.WithRetry(2))

クライアント側のフォールトトレランス

gRPCは純粋なRPCツールであり、デグレードや熔断などの仕組みは元々組み込まれていません。Googleが提供するソリューションは、Service Meshを通じてトラフィック管理とサービスガバナンスの機能をインフラストラクチャに委ねることです。コンテナ内のenvoy sidecarは熔断をサポートしており、エラー注入によってgRPCクライアントに直接エラーを投げさせることができます。この方法により、呼び出される側のサービスに異常が発生した場合に、そのサービスを保護することができますが、呼び出し側のプロセス内でも適切なフォールトトレランスを実装し、エラーの拡散を放置せず、サービスチェーン全体の雪崩を防ぐべきです。

したがって、RPCを呼び出す際には、エラーに対して適切なデグレード処理を行う必要があります:

// 原始方式
res, err := userservice.GetUser(ctx, &userService.GetUserRequest{})
if err!=nil {
	if s,ok:=status.FromError(err);ok {
			if grpcErr.Code() == codes.Unavailable {
					// 降级
					res = &userservice.GetUserResponse{}
			} else {
					return err
			}
	} else {
		return err
	} 
}

上記はGoでgRPCエラーを処理する方法を示していますが、非常に原始的で、毎回のRPC呼び出しにこれほど多くのエラー処理ロジックを書くことは想像できません。もっとエレガントな解決策を見つける必要があります。

私たちは、クライアント全体に対して、熔断(サーキットブレーカー)と降格のインターセプターを取り付けることで例外を処理することができます。熔断が発生し、かつCallOptionの中に特定のDegradeOptionが含まれている場合、この呼び出しの結果を上書きします:

// interceptor
var breaker = gobreaker.NewCircuitBreaker(
	gobreaker.Settings{
		...
		IsSuccessful: func (err error) bool {
			// 判断错误是否被计入熔断
			if s, ok := status.FromError(err); ok {
				switch s.Code() {
				// 忽略特定 error code
				case codes.InvalidArgument, codes.NotFound, codes.Canceled:
					return true
				}
			}
			return err == nil
		},
	},
)

// intercepte is the grpc interceptor func
func intercepte(
  ctx context.Context,
	method string,
	req,reply interface{},
	cc *grpc.ClientConn,
	invoker grpc.UnaryInvoker,
	opts ...grpc.CallOption,
) error {
	 // 获取一个熔断器执行任务
   err := breaker.Execute(func() (interface{},error) {
      return nil,invoker(ctx,method,req,reply,cc,opts...)
   })
   if errors.Is(err,gobreaker.ErrOpenState) || 
			errors.Is(err,gobreaker.ErrTooManyRequests) {
			// 如果发生熔断则降级
      if d,ok := extractDegrade(ctx,opts);ok {
         if err := copier.Copy(reply,d); err != nil {
            panic("cannot copy to dst")
         }
         return nil
      }
      return status.Error(codes.Unavailable,err.Error())
   }
   return err
}

// 从 option 中导出降级选项
func extractDegrade(ctx context.Context, opts []grpc.CallOption) (interface{}, bool) {
	for _, o := range opts {
		if d, ok := o.(degrader); ok {
			return d.degrade(ctx), true
		}
	}
	return nil, false
}

上記のインターセプターを通じて、RPCを呼び出す際に、エレガントにサーキットブレーカーとデグレードを実現することができます:

// 新的方式

res, err := userservice.GetUser(ctx, &userservice.GetUserRequest{}, WithDegrade(&userservice.GetUserResponse{}))

サービスディスカバリとロードバランシング

私たちのサービスは、K8Sが提供するサービスディスカバリ機能を直接使用しています。異なるgRPCサービスが指すK8Sサービスのターゲットを宣言するだけで、対応するサービスに直接アクセスできます。メンテナンスコストを低減するために、ProtoBuf上でカスタムService Optionを使用し、各サービスに対応するデフォルトのK8Sサービス名(gRPCサービスとK8Sサービスを関連付ける)を定義しました:

service UserService {
	option (google.api.default_host) = "user-service"
	rpc GetUser(GetUserRequest) returns (GetUserResponse)
}

このようにして、生成されたコード内で直接対応するサービスに接続することができます:

func DialUserService(ctx context.Context, opts ...grpc.DialOption) (grpc.ClientConnInterface, error) {
	return grpc.DialContext(ctx, "user-service:9090", opts...)
}

EnvoyはgRPCをネイティブにサポートしているため、サービスプロセスがロードバランシングを自分で実装する必要はありません。対応するServiceに接続するだけで、Envoyは下流にgRPCトラフィックを均等に分散させることができます。

ヘルスチェック

K8SはgRPCプロトコルのプローブをネイティブにサポートしていませんが、標準のgRPC Health Checking Protocolを使用してヘルスチェックを実装することができます。

gRPC-go には grpc_health_v1 が既に統合されており、gRPC サーバーにヘルスチェックサーバーを登録するだけで済みます:

import (
	grpc "google.golang.org/grpc"
	grpcHealth "google.golang.org/grpc/health"
	grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1"
)

grpcServ := grpc.NewServer()
grpc_health_v1.RegisterHealthServer(grpcServ, grpcHealth.NewServer())

また、対応するK8SデプロイメントでgRPCのヘルスチェックコマンドを宣言する必要があります。コミュニティが提供するgrpc-health-probeを直接使用することができます。これはgrpc_health_v1のクライアント側実装です。

# 通过 Dockerfile 在容器内预装 grpc_health_probe

RUN GRPC_HEALTH_PROBE_VERSION=v0.3.1 && \
    wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
    chmod +x /bin/grpc_health_probe
spec:
  containers:
		- name: grpc
			...
			readinessProbe:
	      exec:
	        command: ["/bin/grpc_health_probe", "-addr=:9090"]
	      initialDelaySeconds: 5
	    livenessProbe:
	      exec:
	        command: ["/bin/grpc_health_probe", "-addr=:9090"]
	      initialDelaySeconds: 10
			...

その他

ProtoBuf 依存関係の管理

大規模なモノレポ内では、すべての依存関係がローカルファイルとして存在し、相互参照が非常に便利です。しかし、他のプロジェクトでサードパーティのProtoBufを導入するのは非常に不便です。そのため、サードパーティの依存関係を直接参照できるように、third_partyディレクトリに直接配置することがよくあります。

project
├── third_party
│   └── google
│       └── api
│           └── annotation.proto
└── proto
    └── service
        └── user
            └── v1
                └── api.proto

google.api.annotationを使用する際にも、筆者は同様の問題に直面しました。上記の方法は明らかにエレガントではなく、一方で冗長性を生み出し、もう一方で将来の更新が面倒になります。ちょうど良いタイミングで、ProtoBufのビルドツール https://github.com/bufbuild/bufProtoBuf 中央リポジトリサービス(The Buf Schema Registry) を正式にリリースしました。これにより、Bufを使用してProtoBufの外部依存関係を直接管理することができます:

# buf.yaml
version: v1
deps:
  - buf.build/googleapis/googleapis

上記の宣言を通じて、ビルド時に直接 <code>import "google/api/annotations.proto";</code> を行うことができます。

Node.js + TypeScript

現在、私たちは多くの Node.js+TypeScript のサービスを持っており、それらに gRPC をサポートさせることで開発効率を向上させる一方、サーバーサイドのコードが HTTP との互換性を保つ必要がなくなります(直接 gRPC クライアントを使用すれば良いのです)。現在、gRPC の公式からは <code>@grpc/grpc-js</code> が提供されており、Node.js で gRPC を使用するために使われています。他の言語がコード生成器を提供するのとは異なり、これはユーザーが実行時に動的に proto 定義をインポートし、その中のメソッドを呼び出すことができるようにする必要があります。これは、コンパイル時にいかなる型の制約もないことを意味し、結果として、ProtoBuf Schema や TypeScript の利点を失ってしまいます。

この問題を解決するために、コミュニティには多くの解決策があります。比較した結果、筆者は https://github.com/stephenh/ts-proto を選びました。これは JavaScript + d.ts のアプローチを使用せず、純粋な TypeScript の Server と Client を直接生成し、内部実装も grpc-js を直接呼び出しており、公式 SDK の良いラッパーです。

それはまた、Bufとも良好に連携できます:

# node.gen.yaml
version: v1

plugins:
	- name: ts_proto
    path: ./node_modules/.bin/protoc-gen-ts_proto
    out: .
    opt:
      - forceLong=string
      - esModuleInterop=false
      - context=true
      - env=node
      - lowerCaseServiceMethods=true
      - stringEnums=true
      - unrecognizedEnum=false
      - useDate=true
      - useOptionals=true
      - outputServices=grpc-js
    strategy: all

ただし、grpc-go と比較して、grpc-js の grpc client interceptor はより多くのアスペクトを導入しており、開発者にとってより高い認識のハードルをもたらしています。具体的な内容は、この node grpc client interceptor の設計を参照してください:

proposal/L5-node-client-interceptors.md at master · grpc/proposal
A repository for gRFCs . Contribute to grpc/proposal development by creating an account on GitHub.
https://github.com/grpc/proposal/blob/master/L5-node-client-interceptors.md

また、gRPC自体がストリームモードをサポートしているため、async/awaitを使ってコードを簡単に実装することはできません。そのため、全体的なインターフェースの統一性を保つために、grpc-jsが生成するrpcメソッドはすべてコールバック型のモードです:

interface ServiceClient {
	call(
	   input: Request,
	   callback: (err: grpc.ServiceError | null, value?: Response) => void,
	): grpc.ClientUnaryCall
}

私たちは独自にクライアントコードジェネレーターを実装し、コード生成時にasync/awaitモードをサポートするためのカプセル化を行い、より良い使用体験を得ることができました:

const client = UserServiceClient.createDefaultInstance()

const user = await client.getUser(
  {
    username: "guoguo",
  },
  {
		deadline: 1000,
    interceptors: [
			interceptors.withRetry(2),
			interceptors.withDegrade({} as GetUserResponse),
		],
  }
);

由于用户发送的内容“总结”不是一行文章内容,而是一个命令,根据指示,我不会翻译这一部分。如果您有具体的文章内容需要翻译,请发送过来。

gRPCを導入することで、サービス間の呼び出し性能がある程度向上しました。さらに重要なのは、統一されたRPC定義とコード生成の方法により、より信頼性の高い通信タイプをサービスにもたらし、開発効率を向上させたことです。しかし、万能薬は存在せず、一方でgRPCはシステム全体により多くの複雑さをもたらしました。私たちは、システム内にHTTP/JSONとgRPCの2つのRPC体系が共存する状況に直面せざるを得ませんでした。また、HTTPに比べてgRPCのデバッグはある程度面倒になることもあります。

異なる言語間でgRPCをスムーズに使用するためには、コミュニティが提供するオープンソースツールやベストプラクティスが不可欠です。上記で触れたもの以外にも、より多くの情報は https://github.com/grpc-ecosystem/awesome-grpc のリポジトリで見つけることができます。