HomeBlogProjects

在生产环境使用 gRPC

sorcererxw

本文将总结部分工作当中在后端引入 gRPC 过程当中所做的工作,将主要基于 Go 来展示 gRPC 的实践。

兼容 HTTP/JSON 接口

由于是在既有的系统上做迁移,必然需要考虑到两点:

在我们已经使用 ProtoBuf 定义了服务每个 RPC 和 HTTP 映射了时候,理想的情况下是将 ProtoBuf 作为 Single source of truth,直接生成 gRPC client + HTTP client,最好他们还能使用相同的抽象类型,屏蔽底层的传输协议。

Envoy or gRPC-Gateway?

为了使 gRPC 兼容原有的 HTTP 接口,业界已经有了不少方案:https://github.com/envoyproxy/envoyhttps://github.com/grpc-ecosystem/grpc-gateway......不过总体上都是通过一个前置网关实现转换协议。

envoy 需要在容器内单独启动一个 envoy sidecar 网关进程,通过其 gRPC-JSON transcoder 实现协议转换,是理想当中美好的方案:将协议转换工作下沉到基础设施,所有语言都能兼容,服务本身专注 gRPC 的实现,可以保证代码简洁。

而 gRPC-Gateway 方案基于 ProtoBuf 定义生成一个 HTTP 服务,它会接受 HTTP 请求并转而调用 gRPC 服务,它可以嵌入在现有的服务代码当中,跟随服务一起启动。

相比 gRPC-Gateway,envoy 方案需要服务多引入一个外部依赖,就目前来讲,在初期迁移的过程中,选用 gRPC-Gateway 可以在一个进程内控制所有逻辑,便于调试,更加简单务实。

定义 ProtoBuf RPC 接口映射

gRPC 本身已经提供了一套 JSON 类型转换协议,另一边 Google API 还提供了 HTTP 接口到 gRPC 接口映射的规范。gRPC-Gateway 就是在这一套规范的基础上实现了协议转换。它的表现力非常强,几乎完全满足常规的 HTTP 接口的所有需求:

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 提供了多种挂载方式:

目前 gRPC-Gateway 更加推荐使用反向代理的方式,其能够最大程度发挥 gRPC 的能力。

那我们可以在一个进程内分别启动一个 gRPC Server 和一个 HTTP Server,让它们分别监听两个端口,让 HTTP Server 根据规则拦截请求经由 gRPC-Gateway 发送至 gRPC Server:

// 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 Server。这种方案相当于在服务进程内再加上一道网关,这样一来可以实现 gRPC 和 HTTP 服务监听同一个端口,一定程度上降低使用者的认知成本(啥都不用管,就往这个端口上连)。

不过实践下来发现,这种方案虽然看起来美好,实际上带来的收益非常有限。相反地,存在一些缺点:

为前端提供接口

更进一步,除了提供内部调用,我们还可以使用 gRPC 来为前端提供接口。就目前来说我们绝大多数接口都可以直接使用 gRPC 来定义,通过网关将客户端的 HTTP 请求转换为 gRPC 调用。这样带来的好处非常明显:

不过一些特殊接口还是需要单独处理,比如渲染图片、网页这类不输出 JSON 的接口。

一些陷阱

在将 HTTP 接口映射到 gRPC 的时候,会因为原来不太好的实践,导致在转换数据结构的存在一些麻烦:

HTTP/JSON Client 改造

之前的文章里面提过,在刚刚引入 Go 的时候,基于 ProtoBuf 实现过一个基础的 HTTP Client 生成器。由于 ProtoBuf 强大的表现力和代码生成器良好的设计,我们使用 ProtoBuf 在其上面定义了大量内部接口、甚至是很多外部服务的接口,通过其生成 HTTP Client 为我们提供了一个通用简单的 RPC Client,大大提高了研发效率。

现在,由于这个 HTTP Client 已经被大量使用,在逐步迁移 gRPC 的过程中,希望能够尽可能减少不兼容,让新旧版本行为保持一致。

这个 Client interface 设计在一开始就考虑到了与 gRPC 的兼容性,现在我们只需要作细微的调整,就能平滑地替换掉各个服务的 RPC Client:

// 原来版本
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)
}

上面的 option.Option 是我们自定义的调用选项,而 grpc.CallOption 是 gRPC 要求的调用选项,两者目前在类型是无法互相兼容的,只需要改造 option.Option 让其实现两套 interface,就能平滑地兼容新旧两个版本。

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

客户端容错

gRPC 是一个纯粹的 RPC 工具,本身并没有内置降级熔断、降级等方案,Google 给出的方案就是通过 Service Mesh 将流量管控服务治理能力下沉到基础设施。容器内的 envoy sidecar 支持熔断,可以通过错误注入的方式,让 gRPC client 直接抛出错误。虽然通过这种方式,可以在被调用方发生异常的时候保护被调用方服务,但是我们依然应当在调用方进程内实现相应的容错处理,不可放任错误扩散,造成整个服务链路的雪崩。

所以,我们在调用 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 调用都需要写这么多的错误处理逻辑。我们需要找到一种更加优雅的方案。

我们可以通过为整个 Client 挂载一个熔断+降级的 intercepter 来处理异常,如果发生熔断且 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
}

通过上面的 interceptor,我们就能够在调用 RPC 的时候优雅地实现熔断降级:

// 新的方式

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

服务发现与负载均衡

我们服务直接使用了 K8S 提供的服务发现能力。我们只需要声明不同 gRPC Service 所指向的目标 K8S Service,就可以直接访问对应服务。为了降低维护成本,我们在 ProtoBuf 上通过自定义 Service Option 的方式,为每一个 Service 定义了对应默认的 K8S service name(将 grpc service 与 K8S service 关联起来):

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 server 注册一个 Health Server:

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 deployment 当中声明相应的 gRPC 健康检查命令,我们可以直接使用社区提供的 https://github.com/grpc-ecosystem/grpc-health-probe,它是 grpc_health_v1 的 client 端实现。

# 通过 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 依赖

在大型 monorepo 当中,所有依赖都以本地文件的方式存在,相互引用非常方便。但是,在其他项目当中引入第三方 ProtoBuf 非常不方便,我们往往需要将第三方依赖直接存放在一个 third_party 目录下,以便直接依赖。

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

在使用 google.api.annotation 的时候,笔者同样遇到这样的问题,上面的方式显然不是一种优雅的方案,一方面造成了冗余,另一方面未来更新比较麻烦。恰好 ProtoBuf 构建工具 https://github.com/bufbuild/buf 正式发布了 ProtoBuf 中央仓库服务(The Buf Schema Rregistry),我们可以直接使用 Buf 管理 ProtoBuf 外部依赖:

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

通过上面的声明,就能够在构建的时候直接 import "google/api/annotations.proto"; 了。

Node.js + TypeScript

当前我们有大量的 Node.js+TypeScript 的服务,让其支持 gRPC 一方面可以提高开发效率,另一方面让 server 端代码免去兼容 HTTP 的必要(直接用 gRPC client 就好了)。目前 gRPC 官方提供了 @grpc/grpc-js ,用于在 Node 当中使用 gRPC。不同于其他语言提供代码生成器的方案,它需要使用者在运行时动态导入 proto 定义,然后放可调用其中方法。这意味则在编译期没有任何类型约束,这样一来,我们就失去了使用 ProtoBuf Schema 或者 TypeScipt 所带来的好处了。

为了解决这个问题,社区里面有大量解决方案。经过对比,笔者选择了 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 本身支持 stream 模式,无法简单地使用 async/await 来实现代码,为了整体接口的统一,grpc-js 生成的 rpc 方法都是类型 Callback 的模式:

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

我们通过自己实现 client 代码生成器,在代码生成的时候就完成封装以支持 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 两套 RPC 体系;同时,调试 gRPC 相比 HTTP 也会有一定的麻烦。

能够顺利地在不同语言当中使用 gRPC,离不开大量社区提供的开源工具和最佳实践,除了上面提到的,更多的资料还能在 https://github.com/grpc-ecosystem/awesome-grpc 仓库当中找到。