部署
生产级 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 引擎。这让我们能够使用如 BUILDPLATFORM
、TARGETOS
和 TARGETARCH
等定义的变量。我们这么做是因为,尽管我们将应用程序容器化以避免处理架构问题,但运行一个与主机架构相同的容器(虚拟化)比仿真更高效。此外,如你所见,我们需要在下载 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 定义的参数。这使得我们可以使用 GOOS
和 GOARCH
环境变量为特定设置构建 Go 二进制文件。
另外,注意我们复制了 protoc
和 include
文件夹。正如前面所说,后者包含 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 命令前加上 |
$ 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
最后,让我们尝试运行服务器镜像并向其发起请求。我们将运行刚刚创建的镜像,并将我们为服务器使用的端口(50051
和 50052
)暴露到主机的相同端口:
$ 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/ ),安装后,你可以使用 |
这样,我们现在可以部署我们的三个服务了。我们将从 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 的配置选项,我建议你查看其他功能,如速率限制和认证。