部署

生产级 API 的另一个关键步骤是将服务部署到线上。在本节中,我们将看到如何为 gRPC Go 创建 Docker 镜像,如何将其部署到 Kubernetes 中,并最终部署 Envoy 代理,让客户端能够从集群外部向集群内的服务器发起请求。

Docker

部署的第一步通常是将应用程序容器化为 Docker 镜像。如果我们不这么做,就需要处理依赖于服务器架构的错误、工具在服务器上不可用等问题。通过容器化我们的应用程序,我们只需构建一次镜像,并可以在任何支持 Docker 的环境中运行它。

我们将重点容器化我们的服务器。这比容器化客户端更有意义,因为我们稍后会将我们的 gRPC 服务器部署为 Kubernetes 中的微服务,并让外部的客户端向这些服务发起请求。

首先,我们需要思考构建应用程序所需的所有步骤。我们已经运行了好几次,但我们需要记住一开始设置的所有工具,包括:

  • protoc 用于编译我们的 proto 文件

  • Proto Go、gRPC 和验证插件,用于从 proto 文件生成 Go 代码

  • 当然,还有 Golang

让我们从获取 protoc 开始。为此,我们将创建一个基于 Alpine 的第一阶段,使用 wget 获取 protoc 的 ZIP 文件,并将其解压到 /usr/local。如果你着急,可以在 server/Dockerfile 中找到完整的 Dockerfile,但我们将一步一步地解释:

FROM --platform=$BUILDPLATFORM alpine as protoc
ARG BUILDPLATFORM TARGETOS TARGETARCH

RUN export PROTOC_VERSION=23.0 \
    && export PROTOC_ARCH=$(uname -m | sed s/aarch64/aarch_64/) \
    && export PROTOC_OS=$(echo $TARGETOS | sed s/darwin/linux/) \
    && export PROTOC_ZIP=protoc-$PROTOC_VERSION-$PROTOC_OS-$PROTOC_ARCH.zip \
    && echo "downloading: " https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/$PROTOC_ZIP \
    && wget https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/$PROTOC_ZIP \
    && unzip -o $PROTOC_ZIP -d /usr/local bin/protoc 'include/*' \
    && rm -f $PROTOC_ZIP

这里有几个要点。首先请注意,我们使用 Docker BuildKit 引擎。这让我们能够使用如 BUILDPLATFORMTARGETOSTARGETARCH 等定义的变量。我们这么做是因为,尽管我们将应用程序容器化以避免处理架构问题,但运行一个与主机架构相同的容器(虚拟化)比仿真更高效。此外,如你所见,我们需要在下载 protoc 的 URL 中指定架构和操作系统。

接下来,我们定义了一些重要的变量来构建下载链接。我们设置了 protoc 的版本(这里是 23.0),然后设置我们要使用的架构。这个值来自 uname -m,它提供有关机器架构的信息。注意,我们使用了一些技巧来将 aarch64 替换为 aarch_64,因为在 Protobuf 发布的 ZIP 文件名中使用的是 aarch_64

然后,我们使用 TARGETOS 变量来定义我们要处理的操作系统。同样地,注意我们用类似的技巧将 darwin 替换为 linux,这是因为 protoc 并没有提供特定于 macOS 的二进制文件,你可以直接使用 Linux 的版本。

之后,我们通过拼接之前定义的变量来下载文件,并将其解压到 /usr/local。我们提取了 protoc 二进制文件(/bin/protoc)和 /include 文件夹,因为前者是我们要使用的编译器,后者包含了我们需要的 Well-Known Types。

完成此操作后,我们可以创建另一个阶段,用于使用 Go 构建应用程序。在此阶段,我们将从上一阶段复制 protoc,下载 protoc 插件,编译 proto 文件,并构建 Go 项目。我们将使用一个基于 Alpine 的镜像:

FROM --platform=$BUILDPLATFORM golang:1.20-alpine as build
ARG BUILDPLATFORM TARGETOS TARGETARCH

COPY --from=protoc /usr/local/bin/protoc /usr/local/bin/protoc
COPY --from=protoc /usr/local/include/google /usr/local/include/google

RUN go install google.golang.org/protobuf/cmd/protoc-gengo@latest
RUN go install google.golang.org/grpc/cmd/protoc-gen-gogrpc@latest
RUN go install github.com/envoyproxy/protoc-genvalidate@latest

WORKDIR /go/src/proto
COPY ./proto .

RUN protoc –I. \
    --go_out=. \
    --go_opt=paths=source_relative \
    --go-grpc_out=. \
    --go-grpc_opt=paths=source_relative \
    --validate_out="lang=go,paths=source_relative:." \
    **/*.proto

WORKDIR /go/src/server
COPY ./server .

RUN go mod download
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /go/bin/server

此部分应该不难理解。这是我们之前在本书中所做的操作。然而,我想提及一些不太显而易见的事情。我们再次使用了 BuildKit 定义的参数。这使得我们可以使用 GOOSGOARCH 环境变量为特定设置构建 Go 二进制文件。

另外,注意我们复制了 protocinclude 文件夹。正如前面所说,后者包含 Well-Known Types,我们的 proto 文件中使用了其中的一些,所以这是必要的。

最后,我使用了两个链接器标志。-s 用于禁用生成 Go 符号表。虽然我不深入讲解这意味着什么,但在创建较小的二进制文件时,通常会移除一些对运行时无影响的信息。-w 用于移除调试信息。由于生产环境中不需要这些信息,因此我们可以去掉它们。

最后,我们将构建我们的最后一个阶段,该阶段基于 scratch 镜像。这是一个没有操作系统的镜像,适用于托管二进制文件,并让我们的镜像变得非常小。在这个阶段,我们将把证书复制到 certs 目录,把我们用 go build 创建的二进制文件复制进去,并用我们通常设置的参数启动应用程序:

FROM scratch

COPY ./certs/server_cert.pem ./certs/server_cert.pem
COPY ./certs/server_key.pem ./certs/server_key.pem

COPY --from=build /go/bin/server /
EXPOSE 50051 50052
CMD ["/server", "0.0.0.0:50051", "0.0.0.0:50052"]

这样,我们就可以准备好构建第一个服务器镜像。首先,我们可以创建一个 Docker Builder。正如 Docker 文档中所描述的:“Builder 实例是一个隔离环境,在这里可以调用构建。” 这基本上是我们用来启动构建镜像的环境。我们可以通过以下命令来创建它:

你需要确保 Docker 正在运行。这就像确保 Docker Desktop 正在运行一样简单。如果你在 Linux/Mac 上使用,且没有创建 Docker 组并将用户添加到其中,你可能需要在以下所有 Docker 命令前加上 sudo

$ docker buildx create --name mybuild --driver=dockercontainer

请注意,我们为这个构建环境指定了名称 mybuild,并且使用了 dockercontainer 驱动程序。这个驱动程序允许我们生成多平台镜像。稍后我们将看到这一点。

执行命令后,我们将能够在另一个 Docker 命令中使用这个 Builder:

docker buildx build。通过这个命令,我们将生成镜像。我们将为它指定一个标签(名称),指定 Dockerfile 的位置,指定要构建的架构,并将镜像加载到 Docker 中。要为 arm64 架构构建镜像(你也可以尝试 amd64),我们运行以下命令(来自第九章):

$ docker buildx build \
--tag clementjean/grpc-go-packt-book:server \
--file server/Dockerfile \
--platform linux/arm64 \
--builder mybuild \
--load .

构建完成后,我们应该能够通过执行以下命令来查看镜像:

$ docker image ls
REPOSITORY                     TAG    SIZE
clementjean/grpc-go-packt-book server 10.9MB

最后,让我们尝试运行服务器镜像并向其发起请求。我们将运行刚刚创建的镜像,并将我们为服务器使用的端口(5005150052)暴露到主机的相同端口:

$ docker run -p 50051:50051 -p 50052:50052 clementjean/grpc-go-packt-book:server
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051

现在,如果我们正常运行客户端,应该能够获取到之前所有的日志:

$ go run ./client 0.0.0.0:50051

总结一下,我们看到了如何为 gRPC 应用程序创建精简的 Docker 镜像。我们使用了一个多阶段的 Dockerfile,首先下载了 protoc 和 Protobuf 的 Well-Known Types。接着,我们下载了所有 Go 依赖并构建了二进制文件,最后我们将二进制文件复制到 scratch 镜像中,创建了一个轻量的包装器。

Kubernetes

现在我们已经有了服务器镜像,我们可以部署多个实例的服务,这样就创建了我们的待办事项微服务。在这一部分,我们将主要关注如何部署我们的 gRPC 服务。这意味着我们将编写一个 Kubernetes 配置。如果你不熟悉 Kubernetes,不必担心。我们的配置很简单,我会解释所有的配置块。

首先,我们需要考虑的是如何访问我们的服务。我们有两种主要的方式来暴露我们的服务:使其仅在集群内部可访问,或者使其从集群外部可访问。在大多数情况下,我们不希望服务直接被访问。我们希望通过一个代理,它将请求重定向并负载均衡到多个服务实例上。

因此,我们将创建一个 Kubernetes Service,它将为我们服务的所有实例分配一个 DNS A 记录。这基本上意味着每个服务将拥有自己在集群中的内部地址。这将允许我们的代理解析所有的地址并在它们之间进行负载均衡。

这样的服务被称为无头服务(headless service)。在 Kubernetes 中,这是一种将 clusterIP 属性设置为 None 的服务。以下是服务定义(k8s/server.yaml):

apiVersion: v1
kind: Service
metadata:
  name: todo-server
spec:
  clusterIP: None
  ports:
    - name: grpc
      port: 50051
  selector:
    app: todo-server

在这个服务配置中,我们创建了一个名为 grpc 的端口,并指定它的值为 50051,因为我们希望通过端口 50051 访问所有服务实例。接着,注意到我们创建了一个 selector 来指定这个服务将处理的应用程序。在我们的例子中,应用程序被命名为 todo-server,这也将是我们稍后部署的应用实例名称。

接下来,我们可以考虑创建服务实例。我们将通过 Kubernetes 部署(Deployment)来实现这一点。这将使我们能够指定需要多少个实例、使用哪个镜像以及使用哪个容器端口。以下是 Kubernetes 部署配置的示例(k8s/server.yaml):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: todo-server
  labels:
    app: todo-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: todo-server
  template:
    metadata:
      labels:
        app: todo-server
    spec:
      containers:
        - name: todo-server
          image: clementjean/grpc-go-packt-book:server
          imagePullPolicy: Always
          ports:
            - name: grpc
              containerPort: 50051

在这里,我们指定了 Pod 的名称为 todo-server,并通过服务处理这些 Pod。我们指定了希望使用之前创建的镜像(clementjean/grpc-go-packt-book:server)。注意,我们将 imagePullPolicy 设置为 Always,这意味着每次创建 Pod 时,它都会从镜像仓库拉取最新的镜像。这可以确保我们每次都使用最新的镜像。然而,如果镜像没有发生变化,并且你本地已有有效的镜像,这种做法可能会低效。因此,建议根据你所在的 Kubernetes 环境,选择合适的 imagePullPolicy 值。最后,我们指定了容器暴露的端口为 50051,该端口用于服务的 gRPC API。

在本章的剩余部分,我假设你已经有了一个 Kubernetes 集群。如果你在云中有一个集群,那就太好了,你可以继续。如果你没有集群,可以参考 Kind( https://kind.sigs.k8s.io/ ),安装后,你可以使用 k8s/kind.yaml 中提供的配置创建一个简单的集群。只需运行 kind create cluster --config k8s/kind.yaml

这样,我们现在可以部署我们的三个服务了。我们将从 chapter9 文件夹运行以下命令:

$ kubectl apply -f k8s/server.yaml

我们将执行以下命令来查看正在创建的 Pod:

$ kubectl get pods
NAME READY STATUS
todo-server-7d874bfbdb-2cqjn 1/1 Running
todo-server-7d874bfbdb-gzfch 1/1 Running
todo-server-7d874bfbdb-hkmtp 1/1 Running

现在,由于我们没有代理,我们将简单地使用 Kubernetes 的 port-forward 命令来访问一个服务器,并查看它是否正常工作。这纯粹是为了测试目的,我们稍后会看到如何通过代理隐藏服务。因此,我们运行以下命令:

$ kubectl port-forward pod/todo-server-7d874bfbdb-2cqjn 50051
Forwarding from 127.0.0.1:50051 -> 50051
Forwarding from [::1]:50051 -> 50051

然后,我们应该能够在 localhost:50051 上正常使用我们的客户端:

$ go run ./client 0.0.0.0:50051

总结一下,我们看到可以使用无头服务为部署中的每个 Pod 创建 DNS A 记录。然后我们部署了三个 Pod,并通过使用 kubectl 中的 port-forward 命令测试它们是否正常工作。

Envoy 代理

现在我们已经创建了微服务,接下来需要添加一个代理来在所有服务之间进行负载均衡。这个代理是 Envoy,它是少数能够与 gRPC 服务交互的代理之一。我们将看看如何设置 Envoy 以将流量重定向到我们的服务,使用轮询算法进行负载均衡,并启用 TLS。

首先,我们需要集中精力写一个监听器。监听器是一个实体,用来指定监听的地址和端口,并定义一些过滤器。在我们的案例中,这些过滤器将允许我们将请求路由到 Envoy 集群中的 todo.v2.TodoService。集群是定义实际端点的实体,并显示如何进行负载均衡。首先,我们可以编写监听器配置(envoy/envoy.yaml):

node:
  id: todo-envoy-proxy
  cluster: grpc_cluster

static_resources:
  listeners:
  - name: listener_grpc
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 50051
    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: listener_http
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          route_config:
            name: route
            virtual_hosts:
            - name: vh
              domains: ["*"]
              routes:
              - match:
                  prefix: /todo.v2.TodoService
                grpc: {}
                route:
                  cluster: grpc_cluster

最重要的是要注意,我们定义了一个路由,用于匹配来自任何域名的所有 gRPC 请求,并匹配 /todo.v2.TodoService 前缀。然后,所有这些请求将被重定向到 grpc_cluster

接下来,让我们定义我们的集群。我们将使用 STRICT_DNS 解析来通过 DNS A 记录检测所有的 gRPC 服务。然后,我们将指定只接受 HTTP/2 请求。这是因为,如你所知,gRPC 基于 HTTP/2。接下来,我们将设置负载均衡策略为轮询(round robin)。最后,我们将指定端点的地址和端口:

clusters:
- name: grpc_cluster
  type: STRICT_DNS
  http2_protocol_options: {}
  lb_policy: round_robin
  load_assignment:
    cluster_name: grpc_cluster
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: "todo-server.default.svc.cluster.local"
              port_value: 50051

注意,我们使用了 Kubernetes 生成的地址,它的形式是:$SERVICE_NAME-$NAMESPACE-svc-cluster.local

为了先在本地测试配置,我们可以先将监听器端口设置为 50050,以避免与服务器端口冲突:

static_resources:
  listeners:
  - name: listener_grpc
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 50050

同时,我们将端点地址更改为 localhost,以便访问本地运行的服务器:

- endpoint:
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 50051

现在,我们可以运行我们的服务器:

$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051

接着,我们可以使用 func-e 运行 Envoy:

$ func-e run -c envoy/envoy.yaml

最后,我们可以运行客户端,指定 50050 端口,而不是 50051

$ go run ./client 0.0.0.0:50050
--------ADD--------
2023/06/04 11:36:45 rpc error: code = Unavailable desc = last connection error: connection error: desc = "transport: authentication handshake failed: tls: first record does not look like a TLS handshake"

这是因为 Envoy 在客户端和服务器之间打破了 TLS 连接。为了解决这个问题,我们需要指定集群的上游使用 TLS,并且监听器的下游也使用 TLS。

在过滤器中,我们告诉 Envoy 在哪里找到我们的自签名证书:

#...
filter_chains:
- filters:
    #...
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
        common_tls_context:
          tls_certificates:
          - certificate_chain:
              filename: /etc/envoy/certs/server_cert.pem
            private_key:
              filename: /etc/envoy/certs/server_key.pem

请注意,这可能不是你在生产环境中会做的事情。你会使用像 Let’s Encrypt 这样的工具来自动生成证书并进行链接。

现在,我们将告诉集群,上游服务也使用 TLS:

clusters:
- name: grpc_cluster
  #...
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext

此时,在本地计算机上,我们没有 /etc/envoy/certs/server_cert.pem/etc/envoy/certs/server_key.pem 文件,但我们可以将 chapter9/certs 文件夹中的证书替换进去:

certificate_chain:
  filename: ./certs/server_cert.pem
private_key:
  filename: ./certs/server_key.pem

现在,让我们终止之前的 Envoy 实例并重新运行它:

$ func-e run -c envoy/envoy.yaml

最后,我们应该能够运行客户端并从服务器接收响应:

$ go run ./client 0.0.0.0:50050

现在,我们可以确定我们的请求通过 Envoy 传递,并被重定向到我们的 gRPC 服务器。下一步是恢复我们为测试所做的所有临时更改(监听端口改为 50051,端点地址改为 todo-server.default.svc.cluster.local,证书路径改为 /etc/envoy),并创建一个我们将在 Kubernetes 集群中部署 Envoy 的 Docker 镜像。

为了构建这个镜像,我们将证书复制到 /etc/envoy/certs(再次强调,在生产环境中不推荐这样做),并将配置文件(envoy.yaml)复制到 /etc/envoy。最后,这个镜像将运行带有 --config-path 标志的 envoy 命令,指向 /etc/envoy/envoy.yaml 路径。在 envoy/Dockerfile 中,我们有如下内容:

FROM envoyproxy/envoy-distroless:v1.26-latest
COPY ./envoy/envoy.yaml /etc/envoy/envoy.yaml
COPY ./certs/server_cert.pem /etc/envoy/certs/server_cert.pem
COPY ./certs/server_key.pem /etc/envoy/certs/server_key.pem
EXPOSE 50051
CMD ["--config-path", "/etc/envoy/envoy.yaml"]

现在,我们可以像这样为 arm64 架构(你也可以使用 amd64)构建镜像:

$ docker buildx build \
    --tag clementjean/grpc-go-packt-book:envoy-proxy \
    --file ./envoy/Dockerfile \
    --platform linux/arm64 \
    --builder mybuild \
    --load .

就是这样!我们已经准备好在我们的 TODO 微服务前面部署 Envoy 了。我们需要为 Envoy 创建一个无头服务。这与我们为微服务创建无头服务时的原因相同。在生产环境中,可能会有多个 Envoy 实例,您需要确保它们都能被访问到。在 envoy/service.yaml 中,我们有如下内容:

apiVersion: v1
kind: Service
metadata:
  name: todo-envoy
spec:
  clusterIP: None
  ports:
  - name: grpc
    port: 50051
  selector:
    app: todo-envoy

然后,我们需要创建一个部署(Deployment)。这次,由于我们处于开发环境中,我们将只部署一个 Envoy Pod。其余配置与我们为 gRPC 服务器所做的类似。在 envoy/deployment.yaml 中,我们有如下内容:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: todo-envoy
  labels:
    app: todo-envoy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: todo-envoy
  template:
    metadata:
      labels:
        app: todo-envoy
    spec:
      containers:
      - name: todo-envoy
        image: clementjean/grpc-go-packt-book:envoy-proxy
        imagePullPolicy: Always
        ports:
        - name: grpc
          containerPort: 50051

现在,我们可以运行这一切。我假设你没有删除我们之前为部署微服务所做的步骤。此时,你应该看到以下内容:

$ kubectl get pods
NAME                                         READY   STATUS    RESTARTS   AGE
todo-server-7d874bfbdb-2cqjn                1/1     Running   0          5m
todo-server-7d874bfbdb-gzfch                1/1     Running   0          5m
todo-server-7d874bfbdb-hkmtp                1/1     Running

所以,现在我们可以首先添加服务(service),然后是 Envoy 的部署(deployment):

$ kubectl apply -f envoy/service.yaml
$ kubectl apply -f envoy/deployment.yaml
$ kubectl get pods
NAME                                         READY   STATUS    RESTARTS   AGE
todo-envoy-64db4dcb9c-s2726                1/1     Running   0          5m
todo-server-7d874bfbdb-2cqjn               1/1     Running   0          5m
todo-server-7d874bfbdb-gzfch               1/1     Running   0          5m
todo-server-7d874bfbdb-hkmtp               1/1     Running   0          5m

最后,在运行客户端之前,我们可以使用 port-forward 命令将 Envoy 的 50051 端口转发到 localhost:50051

$ kubectl port-forward pod/todo-envoy-64db4dcb9c-s2726 50051
Forwarding from 127.0.0.1:50051 -> 50051
Forwarding from [::1]:50051 -> 50051

然后,我们可以运行客户端,应该能够得到一些结果:

$ go run ./client 0.0.0.0:50051
//...
error while receiving: rpc error: code = Internal desc = unexpected error: task with id 1 not found

请注意,由于负载均衡以及我们没有使用真正的数据库,Pods 无法找到存储在其他 Pods 内存中的任务。这在我们的案例中是正常的,但在生产环境中,你会依赖于共享数据库,这些问题就不会发生了。

总结一下,我们看到我们可以在服务前实例化 Envoy,以特定的负载均衡策略重定向请求。这一次,与第 7 章中看到的负载均衡不同,客户端实际上并不知道服务器的任何地址。它连接到 Envoy,然后 Envoy 重定向请求和响应。显然,我们没有涵盖所有 Envoy 的配置选项,我建议你查看其他功能,如速率限制和认证。