使用 Envoy Proxy 实现 Quarkus 与 Go gRPC 服务间的金丝雀发布


在维护一个由多种语言构成的微服务体系时,服务的平滑升级是一个无法回避的挑战。特别是对于核心 gRPC 服务,任何一个微小的变更失误都可能引发连锁故障。直接切换流量的“蓝绿部署”风险过高,我们需要一种更精细的控制手段。这里的核心痛点是:如何将一小部分特定用户的生产流量,无感知地路由到新版本的服务上,同时不侵入客户端业务逻辑,并且能够快速回滚。

这个场景在真实项目中非常普遍。比如,我们有一个用 Go 编写的高性能 gRPC 用户服务,现在需要上线一个v2版本,该版本包含一个实验性功能。我们希望只有内部测试人员发出的请求才能访问到v2版本,而所有普通用户的请求继续由稳定运行的v1版本处理。客户端是一个 Quarkus 应用,我们绝对不希望在 Quarkus 的代码里硬编码任何关于v1v2的路由逻辑。这种逻辑属于基础设施的范畴,应当与业务代码解耦。

初步构想是利用服务网格的 sidecar 模式。让 Quarkus 客户端不直接与 Go 服务通信,而是将所有 gRPC 请求发送到其本地的 Envoy Proxy。由 Envoy 检查请求的元数据(例如一个特定的 gRPC header),然后根据预设的规则,将请求动态地转发到v1v2服务实例。这种架构将复杂的路由决策下沉到基础设施层,应用本身对此毫无感知。

技术选型决策如下:

  • Quarkus (客户端): 作为 JVM 体系下的云原生框架,其极快的启动速度、低内存占用和原生编译能力非常适合构建微服务。其内置的 gRPC 客户端支持和 CDI(Contexts and Dependency Injection)可以很方便地实现 ClientInterceptor 来附加路由所需的元数据。
  • Go (gRPC 服务端): Go 在网络编程和并发处理上的性能优势使其成为编写 gRPC 服务的理想选择。我们可以轻易地构建并运行两个不同版本的服务实例。
  • Envoy Proxy (流量中间人): Envoy 对 gRPC 的支持是其核心特性之一。它不仅能理解 HTTP/2 协议,还能基于 gRPC 的请求头(Header)、方法名(Method)等信息进行复杂的路由决策,是实现我们金丝雀发布策略的关键。

整个系统的流量走向将通过一个 docker-compose 文件来编排。

graph TD
    subgraph Client Pod
        A[Quarkus gRPC Client] -- gRPC request --> B[Envoy Sidecar];
    end

    subgraph " "
        B -- "Header: x-user-group=internal" --> D[Go gRPC Server v2];
        B -- "Default Route" --> C[Go gRPC Server v1];
    end

    style A fill:#c9f,stroke:#333,stroke-width:2px
    style C fill:#9cf,stroke:#333,stroke-width:2px
    style D fill:#f96,stroke:#333,stroke-width:2px
    style B fill:#f80,stroke:#333,stroke-width:2px

下面我们将一步步构建这个完整的系统。

1. 定义 gRPC 服务接口

首先,我们需要一个统一的 Protocol Buffers 文件来定义服务。这个 .proto 文件是客户端和服务端之间的契约,两个版本的 Go 服务都会实现这个接口。

proto/greeter.proto:

syntax = "proto3";

package greeter;

option go_package = "greeter/proto";

// The greeter service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

这个定义非常简单,但足以用来演示复杂的路由逻辑。

2. 实现 Go gRPC 服务 (v1 和 v2)

我们将创建两个版本的Greeter服务实现。它们共享同一个 .proto 定义,但在响应消息上有所区别,以便我们能清晰地看到哪个版本的服务处理了请求。

项目结构如下:

go-grpc-server/
├── proto/
│   ├── greeter.pb.go
│   └── greeter.proto
├── go.mod
├── go.sum
└── main.go

首先生成 Go gRPC 代码:

# protoc --go_out=. --go_opt=paths=source_relative \
#    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
#    proto/greeter.proto

main.go 文件是核心。我们通过一个环境变量 SERVICE_VERSION 来控制启动哪个版本的服务实现。在真实项目中,这通常是通过不同的 Docker 镜像或启动参数来管理的。

main.go:

package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"os"

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

	pb "go-grpc-server/proto" // 确保路径正确
)

// server is used to implement greeter.GreeterServer.
type serverV1 struct {
	pb.UnimplementedGreeterServer
}

// SayHello implementation for v1
func (s *serverV1) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("[V1] Received request for: %v", in.GetName())
	// 简单的业务逻辑 v1
	return &pb.HelloReply{Message: "Hello " + in.GetName() + " (from v1 - Stable)"}, nil
}

type serverV2 struct {
	pb.UnimplementedGreeterServer
}

// SayHello implementation for v2
func (s *serverV2) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("[V2] Received request for: %v", in.GetName())
	// 实验性功能 v2
	return &pb.HelloReply{Message: "Hello " + in.GetName() + " (from v2 - Experimental)"}, nil
}

func main() {
	port := os.Getenv("GRPC_PORT")
	if port == "" {
		port = "50051"
	}
	lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port))
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	s := grpc.NewServer()
	version := os.Getenv("SERVICE_VERSION")

	// 根据环境变量决定注册哪个版本的服务实现
	// 这是一个关键点,它允许我们用同一份代码库,通过配置启动不同行为的服务实例
	if version == "v2" {
		log.Println("Starting Greeter service version: v2")
		pb.RegisterGreeterServer(s, &serverV2{})
	} else {
		log.Println("Starting Greeter service version: v1")
		pb.RegisterGreeterServer(s, &serverV1{})
	}

	// 在 gRPC 服务器上注册反射服务。
	// 这对于 gRPCurl 这样的调试工具非常有用。
	reflection.Register(s)

	log.Printf("Server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

这份 Go 代码非常干净,没有任何关于路由或网络策略的逻辑。它只专注于实现业务。

3. 构建 Quarkus gRPC 客户端

Quarkus 客户端的职责是发起 gRPC 请求。为了触发 Envoy 的路由规则,它需要在特定条件下为请求添加 x-user-group: internal-testers 的 header。在真实场景中,这个条件可能来自于用户的认证信息、JWT token 的声明,或者其他业务上下文。在这里,我们通过一个简单的 REST 端点来模拟这个过程,根据 URL path 决定是否添加 header。

项目结构 (使用 Quarkus Maven archetype 生成):

quarkus-grpc-client/
├── src/main/
│   ├── java/org/acme/
│   │   ├── GreeterResource.java
│   │   └── HeaderInterceptor.java
│   ├── proto/
│   │   └── greeter.proto
│   └── resources/
│       └── application.properties
└── pom.xml

application.properties 配置 gRPC 客户端指向 Envoy 的地址:

# application.properties
quarkus.grpc.client.greeter.host=envoy
quarkus.grpc.client.greeter.port=9000
quarkus.grpc.client.greeter.negotiation-type=plaintext

# Quarkus 应用的 HTTP 服务端口
quarkus.http.port=8080

注意,客户端连接的是 Envoy 的 9000 端口,而不是 gRPC 服务的实际端口。

接下来是关键部分:使用 ClientInterceptor 附加元数据。

HeaderInterceptor.java:

package org.acme;

import io.grpc.*;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class HeaderInterceptor implements ClientInterceptor {

    // 使用 ThreadLocal 来在同一个线程的不同方法调用之间传递 header 值
    // 在真实项目中,这可能来自 CDI RequestScope 或其他上下文传递机制
    public static final ThreadLocal<String> userGroup = new ThreadLocal<>();

    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
            MethodDescriptor<ReqT, RespT> method,
            CallOptions callOptions,
            Channel next) {
        
        return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
            @Override
            public void start(Listener<RespT> responseListener, Metadata headers) {
                String group = userGroup.get();
                if (group != null && !group.isBlank()) {
                    // 这是实现动态路由的核心
                    // 我们将特定的元数据附加到 gRPC 请求的 header 中
                    Metadata.Key<String> userGroupKey = Metadata.Key.of("x-user-group", Metadata.ASCII_STRING_MARSHALLER);
                    headers.put(userGroupKey, group);
                    System.out.printf("Attaching header 'x-user-group: %s'%n", group);
                    userGroup.remove(); // 清理 ThreadLocal, 避免内存泄漏和状态污染
                } else {
                     System.out.println("No user group header to attach.");
                }
                super.start(responseListener, headers);
            }
        };
    }
}

为了让 Quarkus gRPC 客户端使用这个拦截器,我们需要一个 GrpcClientBean 来配置它:

// 可以放在任意一个 @ApplicationScoped 或 @Singleton 的类里
// 或者专门创建一个配置类
// GreeterResource.java
import io.quarkus.grpc.GrpcClient;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;

public class GrpcClientConfig {
    @Produces
    @Singleton
    @GrpcClient("greeter")
    public GreeterGrpc.GreeterBlockingStub greeterClientWithInterceptor(GreeterGrpc.GreeterBlockingStub client, HeaderInterceptor interceptor) {
        return client.withInterceptors(interceptor);
    }
}

最后是暴露 REST API 的 GreeterResource.java,它会调用 gRPC 服务。

GreeterResource.java:

package org.acme;

import greeter.Greeter;
import greeter.GreeterGrpc;
import io.quarkus.grpc.GrpcClient;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;

@Path("/hello")
public class GreeterResource {

    // 直接注入经过拦截器配置的 Stub
    @Inject
    @GrpcClient("greeter")
    GreeterGrpc.GreeterBlockingStub client;

    @GET
    @Path("/regular/{name}")
    public String sayHelloRegular(@PathParam("name") String name) {
        // 对于常规用户,不设置 header
        HeaderInterceptor.userGroup.remove();
        return client.sayHello(Greeter.HelloRequest.newBuilder().setName(name).build()).getMessage();
    }

    @GET
    @Path("/internal/{name}")
    public String sayHelloInternal(@PathParam("name") String name) {
        // 对于内部用户,设置 'internal-testers' header
        HeaderInterceptor.userGroup.set("internal-testers");
        return client.sayHello(Greeter.HelloRequest.newBuilder().setName(name).build()).getMessage();
    }
}

这里的 ThreadLocal 是一种在请求线程内传递状态的简单方式。在更复杂的应用中,应当使用更健壮的上下文传递机制。

4. 配置 Envoy Proxy 实现动态路由

这是整个方案的核心。envoy.yaml 配置文件定义了 Envoy 的所有行为。它需要监听来自 Quarkus 客户端的流量,并根据 x-user-group header 的存在与否,将流量分发到 greeter_v1greeter_v2 集群。

envoy/envoy.yaml:

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 9000 # 监听来自 Quarkus 客户端的 gRPC 请求
    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
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              # 路由规则 1: 匹配 header
              # 这是实现金丝雀发布逻辑的关键。Envoy 会检查每个请求的元数据。
              - match:
                  prefix: "/greeter.Greeter" # 匹配 gRPC 服务
                  headers:
                  - name: "x-user-group"
                    exact_match: "internal-testers"
                route:
                  cluster: greeter_service_v2 # 如果匹配,则路由到 v2 服务集群
                  timeout: 60s
              
              # 路由规则 2: 默认路由
              # 如果上面的规则不匹配,所有其他流量都会走到这里。
              - match:
                  prefix: "/greeter.Greeter"
                route:
                  cluster: greeter_service_v1 # 路由到 v1 服务集群
                  timeout: 60s

          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          # Envoy 需要知道这是一个 HTTP/2 (gRPC) 连接
          http2_protocol_options: {}

  clusters:
  - name: greeter_service_v1
    connect_timeout: 5s
    type: LOGICAL_DNS # 使用 Docker Compose 的服务名进行 DNS 解析
    # DNS 刷新率。在容器环境中,这有助于 Envoy 快速感知后端 IP 的变化。
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    # 必须指定这是 http2 集群,因为 gRPC 运行在 HTTP/2 之上
    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: greeter_service_v1
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: go-server-v1 # Docker Compose 服务名
                port_value: 50051

  - name: greeter_service_v2
    connect_timeout: 5s
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    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: greeter_service_v2
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: go-server-v2 # Docker Compose 服务名
                port_value: 50052

这份配置的逻辑非常清晰:

  1. Listener:9000 端口监听所有入站连接。
  2. HttpConnectionManager: 将网络字节流解析为 HTTP/2 请求。
  3. VirtualHost Routes: 按顺序匹配路由规则。
    • 第一条规则检查 x-user-group header 是否精确等于 internal-testers。如果是,请求被发送到 greeter_service_v2 cluster。
    • 如果第一条规则不匹配,第二条(默认)规则将捕获所有剩余的 greeter.Greeter 服务的请求,并将它们发送到 greeter_service_v1 cluster。
  4. Clusters: 定义了两个上游服务集群,greeter_service_v1 指向运行 v1 版本的 Go 服务容器,greeter_service_v2 指向 v2 版本。LOGICAL_DNS 类型允许 Envoy 使用 Docker 的内部 DNS 来发现服务。

5. 整合与运行

现在,我们用 Docker Compose 把所有部分串联起来。

docker-compose.yml:

version: '3.8'

services:
  go-server-v1:
    build:
      context: ./go-grpc-server
    environment:
      - SERVICE_VERSION=v1
      - GRPC_PORT=50051 # v1 服务监听 50051
    expose:
      - "50051"

  go-server-v2:
    build:
      context: ./go-grpc-server
    environment:
      - SERVICE_VERSION=v2
      - GRPC_PORT=50052 # v2 服务监听 50052
    expose:
      - "50052"

  envoy:
    image: envoyproxy/envoy:v1.27.0
    volumes:
      - ./envoy/envoy.yaml:/etc/envoy/envoy.yaml
    ports:
      - "9000:9000" # 将 Envoy 的 gRPC 监听端口暴露给客户端
      - "9901:9901" # Envoy 管理端口
    depends_on:
      - go-server-v1
      - go-server-v2

  quarkus-client:
    build:
      context: ./quarkus-grpc-client
      dockerfile: src/main/docker/Dockerfile.jvm
    ports:
      - "8080:8080"
    depends_on:
      - envoy

这个编排文件定义了四个服务:

  • go-server-v1: 构建并运行 Go 服务,环境变量 SERVICE_VERSION 设置为 v1
  • go-server-v2: 构建并运行同一份 Go 服务代码,但 SERVICE_VERSION 设置为 v2,并监听不同端口。
  • envoy: 运行官方的 Envoy 镜像,并将我们的配置文件挂载进去。它依赖于两个 Go 服务。
  • quarkus-client: 构建并运行 Quarkus 应用,它依赖于 Envoy。

启动所有服务:

docker-compose up --build

6. 验证结果

当所有容器成功启动后,我们可以通过 Quarkus 应用暴露的 REST 端点来验证路由逻辑。

测试常规用户流量(应路由到 v1):

curl http://localhost:8080/hello/regular/World

预期输出:

Hello World (from v1 - Stable)

同时,在 go-server-v1 容器的日志中,你会看到一条新的请求记录:

go-server-v1_1  | [V1] Received request for: World

测试内部用户流量(应路由到 v2):

curl http://localhost:8080/hello/internal/Developer

预期输出:

Hello Developer (from v2 - Experimental)

此时,go-server-v2 容器的日志中会显示请求记录:

go-server-v2_1  | [V2] Received request for: Developer

结果清晰地证明,Envoy 成功地根据 x-user-group header 将流量路由到了不同的后端服务版本,而 Quarkus 客户端和 Go 服务端本身对此完全不知情。我们成功地在基础设施层实现了一次精细化的金丝雀发布。

方案的局限性与未来展望

当前这个方案虽然功能完备,但在生产环境中还存在一些局限性。它使用的是 Envoy 的静态配置文件 (static_resources)。在大型系统中,手动管理和分发 envoy.yaml 文件是不可行的。每次路由规则变更都需要重启 Envoy 实例,这会导致服务短暂中断。

一个更成熟的路径是引入一个控制平面(Control Plane),例如 Istio 或 Consul,通过 xDS (Discovery Service) API 动态地向 Envoy 数据平面推送配置。在这种模式下,我们可以通过修改 Kubernetes CRD (Custom Resource Definition) 或调用控制平面的 API 来实时更新路由规则,Envoy 会热加载新配置,整个过程对流量无任何影响。

此外,本次实现只演示了基于 header 的精确匹配路由。Envoy 还支持更复杂的策略,例如基于权重的流量分配(比如将 10% 的流量发送到 v2,90% 发送到 v1),这对于渐进式发布非常有用。实现这一点只需修改 Envoy 配置中的 route 部分,使用 weighted_clusters 即可。

最后,一个完整的生产级方案还需要集成可观测性。Envoy 能够产生大量的指标(Metrics)、访问日志(Access Logs)和分布式追踪(Tracing Spans)。将这些数据接入 Prometheus、ELK Stack 和 Jaeger 等系统,可以让我们实时监控金丝雀版本的性能和错误率,为发布决策提供数据支持。


  目录