Microservices in Golang - Part2
内容提要:Docker & Go-Micro
原作者:Ewan Valentine
原文链接:https://ewanvalentine.io/microservices-in-golang-part-2/
引言
在上篇文章中,我们介绍了编写基于gRPC的微服务的基础知识,在这一部分我们将介绍Docker化服务(Dockerising a service) 的相关知识,同时我们也会在程序中使用 go-micro 以替代 gRPC ( go-micro 中也对 gRPC 进行了封装),最后引入第二个服务。
Docker简介
随着云计算的出现和微服务的诞生,通常我们需要一次部署更多更小的代码块,这给我们服务部署的工作带来了很大的压力,但也因而产生了很多有趣的思路和技术——其中有个很重要的概念就是容器。
传统的部署方案中,运维团队会将应用整体部署到运行特定操作系统的静态服务器上,或者也许是部署在Chef或者Puppet提供的虚拟机上。在这些场景中,应用扩展需要付出昂贵的代价,并且效率也不尽如人意。最常见的选择是垂直扩展——给静态服务器配置更多的资源。
之后诸如 vagrant 之类的工具应运而生,使得配置VM变得相当简单。但是运行一台虚拟机仍然是一项相当繁重的操作:要知道,你是在宿主机中运行的是具有所有功能、内核及其他配置的完整的操作系统。在资源方面,VM的代价是很高的,所以当微服务面世的时候,在多个VM环境中运行单独的代码库显然是不可行的。
直到容器出现
容器是精简版的操作系统,但容器并不包括内核、访客系统及其他任何更底层的操作系统的基础结构。
容器只包含高层的库和运行时 (Run-time) 组件,容器共享宿主机的内核,因此Unix内核只在宿主机上运行,并被很多个具有不同运行时的容器共享。
在底层,容器利用内核提供的不同功能,以便在容器空间中共享资源和网络功能。
这意味着你并不需要启动好几个完整的操作系统,就可以运行代码所需要的运行时和依赖库。容器改变了游戏规则,和VM相比容器的整体大小要小得多。以Ubuntu为例,最小的版本也有接近1GB大小,而相较之下Docker镜像可以仅仅只有188MB。
你可能会注意到我提到更多的是“容器”,而不是 Docker。事实上Docker与容器其实就是一个东西,但是容器更多的是Linux中的概念或功能集,Docker则是容器技术的一种实现,由于其易用性而变得流行,当然容器技术也有其他实现。之后的内容我们依然还是使用Docker,我认为它能提供最好的支持,而且对新手来说也是最友好的。
相信到现在你已经充分了解到容器化技术的价值了,那么就让我们开始把第一个服务容器化吧。第一步是创建一个Dockerfile $ touch consignment-service/Dockerfile
.
在里面加上以下内容:
1 | FROM golang:alpine as builder |
所以这几行代码干了什么?我们现在正在使用的是所谓的多阶段dockerfile (Multi-stage Build Dockerfile),它允许我们使用单独的docker镜像来构建和运行容器。当我们构建容器时,构建的第一个容器会使用Golang runtime作为基础镜像,引入我们需要的依赖项,接着生成我们需要的二进制文件。Dockerfile的第二部分 (也就是我留下“Run container”注释的下方) ,从build容器中得到二进制文件 (由于两个容器可以隐式地共享状态和产物,因此Alpine镜像可以在没有Golang运行时的情况下运行我们的二进制文件,这意味着二进制文件中就已经包括了所有用于连接网络和执行文件所需的依赖,这意味着我们的容器可以做到更小。更小的容器意味着更快的部署、更快的扩容以及更小的空间占用)
如果你使用Linux运行Alpine,你可能会碰到一些问题。因此如果你是在Linux平台上学习本教程,你可以直接将 alpine
更换成 debian
,那样应该就没什么问题了。之后我们会用到一种更好的方式来编译二进制文件。
接下来可以使用如下指令构建Docker镜像:
1 | $ docker build -t consignment-service . |
编者注:
也可以直接在
Makefile
中加入:
1
2
3
4 build:
...
GOOS=linux GOARCH=amd64 go build
docker build -t consignment-service .并使用
make build
编译
使用如下指令运行这个镜像:
1 | $ docker run -p 50051:50051 consignment-service |
-p
标志着一个端口映射,意味着将容器的 50051
端口映射到宿主机的 50051
端口上。我们也可以修改一下这个映射,将容器端口绑定到宿主机其他端口上,比如 8080:50051
,就意味着在本地 8080
上接收请求。
使用以上命令创建docker镜像并且运行它,之后在另一个终端面板中,运行你的cli客户端 $ go run cli.go
,再次确认容器能够正常工作。
当你使用 $ docker build
的时候,此时将你的代码和运行时环境都编译到了一个镜像里面,Docker 镜像是你的环境以及依赖项的一个“快照”,你可以通过将 Docker 镜像发布到 Docker Hub 来分享出去。Docker Hub有些类似 npm、yum 这样的包管理仓库,它存放的是 docker 镜像文件。当你在 Dockerfile
中定义了一个 From
的时候,就会告知 docker 程序从远程 Docker Hub 中拉取所需要的镜像作为基础镜像,然后可以通过重定义的方式扩展或者覆盖基础镜像中的内容。现在你就可以去 docker hub 上看一下,你会发现很多软件其实都已经被容器化了,看这个视频你会发现容器化有多么美妙!
Dockerfile 中的声明会在它第一次构建时被缓存,这大大节省了你作出修改后,重新编译整个运行环境所需要消耗的时间。 Docker 非常智能,它能知道那个地方被修改了,那个地方需要被重新编译,这一切使得编译工作变得非常之快。
关于容器就说到这了,让我们回到代码部分!
回到代码
当我们创建gRPC服务的时候,有很多用于创建网络连接的模版代码 (Boilerplate),你必须将服务的地址的位置 (Hard-code, 硬编码) 到客户端或者其他服务中,才能使其连接到相应的地址 (比如在客户端中写死远端服务器的地址和端口,又或者一个微服务将另一个微服务的地址和端口写死)。这是一个非常麻烦的问题,特别是当你在云中运行了很多的微服务,这些微服务可能不在同一台主机上,又或者它们的地址可能会在重部署之后发生改变。
这就到了服务发现(Service Discovery)大显身手的时候了。服务发现会保留所有服务的信息以及它们的地址,并实时更新。每个服务在开始运行的时候都会想“服务发现”程序注册,在服务关闭的时候向其注销,每一个服务都会有一个名称或者id唯一标记它,所以就算它换了一个新的IP地址或者主机地址,只要服务名还保持一样,你就不需要在你的程序中修改相应的调用代码。
当然了,其实还有其他方法可以解决这个问题,但正像编程界的其他技术一样,如果已经有人提出了一个方法处理问题,那么就不必重复造轮子了 :) . 已经有人足够清晰的解决了这个问题,且方案非常简单,他就是 @chuhnk (Asim Aslam),Go-micro 的开发者。Go-Micro 是一个非常可靠的 Go 语言微服务框架,现在拥有一个完整的团队,发展非常迅速,并得到了一些知名人士的支持。
Go-micro
Go-micro 拥有许多用于构建基于 Go 的微服务的强大功能,我们这里要用到的就是它所提供的服务发现的功能。为了使用Go-micro,我们需要修改服务中的一些代码,以下使用 Go-micro 作为 protoc 插件,替换掉正在使用的标准 gRPC 插件。
首先确保安装了 go-micro 依赖:
1 | $ go get -u github.com/micro/protobuf/{proto,protoc-gen-go} |
换用go-micro作为插件重新生成proto的代码:
1 | $ protoc -I. --go_out=plugins=micro:. \ |
现在为了使用go-micro我们需要更新 consignment-service/main.go
中的一些内容,通过抽象出我们之前的gRPC 代码,go-micro 可以轻松的注册和扩展我们的服务。
1 | // shippy-service-consignment/main.go |
主要的改动发生在 gRPC 服务器创建的这个部分,这一部分内容中用 micro.NewService()
方法将原本的流程进行了抽象,用于注册我们的服务。最后 service.Run()
方法处理了服务的连接,一如之前,我们将接口方法的实现进行注册,只不过这次用了一些不同的方法。
编者注:
我们将gRPC的服务调用与go-micro服务调用对比
gRPC:
1
2
3
4
5
6
7
8
9
10 repo := &Repository{}
srv := micro.NewService(
micro.Name("consignment"),
micro.Version("latest"),
)
srv.Init()
// Register handler
pb.RegisterShippingServiceHandler(srv.Server(), &service{repo})go-micro:
1
2
3
4
5
6
7
8
9 repo := &Repository{}
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterShippingServiceServer(s, &service{repo})其实这里go-micro做了一个从服务名到地址的封装。
另一个重要的改动发生在服务方法本身,方法参数和返回值的类型发生了一些变化,go-micro 统一了原来 gRPC 中四种不同的方法声明的接口,将其统一抽象为 req
和 res
,并将请求和返回的结构体都作为了参数,只返回一个错误信息。在这个方法之中,我们设置的返回值由go-micro进行处理。
最后我们不再将端口部分写死,而是采用服务名 micro.Name("cnosignment")
来调用服务,go-micro中使用环境变量或者通过命令行输入来设置地址,如 MICRO_SERVER_ADDRESS=:50051
。
编者注:
micro.Name()
中的服务名一定要和 proto 文件中定义的 package 名字相同Makefile:
1
2
3
4 run:
docker run -p 50051:50051 \
-e MICRO_SERVER_ADDRESS=:50051 \
-e MICRO_REGISTRY=mdns consignment-service
默认情况下,Go-micro使用 “多播 dns” (mdns, multicast dns) 作为本地服务发现代理供本地使用。您可能不会在生产环境中使用这个,但我们这里仅仅只是为了测试,所以就不用在本地运行像 Consul 或者 etcd 这样的服务了,之后的文章会讲到关于它们的内容的。
现在我们需要做的事情就是将环境变量传入容器中:
1 | $ docker run -p 50051:50051 \ |
-e
是一个环境变量的标志,允许我们将环境变量传递到Docker容器中,每个标志只能对应一个变量,比如 -e ENV=staging -e DB_HOST=localhost
等。
现在我们就有了一个容器化的服务了,并且用到了服务发现。所以接下来更新我们的CLI工具:
1 | import ( |
我们新创建的客户端中引入了go-micro库,并使用go-micro客户端代码代替了现有的建立网络连接的代码,该代码使用服务解析而不是直接连接到地址。
好吧,其实直到现在CLI还是正常工作,这是由于我们的服务运行在 Docker 中,使用的是它自己的 mdns (多播DNS),这与我们正在使用的宿主机的 mdns 是不同的。修复这个问题最简单的方法就是让服务端和客户端都容器化,这样它们就都在同一个宿主机上运行,共用一个网络层了。接着运行下面的指令吧:
1 | $ docker build -t consignment-cli . |
为我们的客户端再写一个 Dockerfile
:
1 | FROM golang:alpine as builder |
这个和我们之前服务端的Dockerfile非常像,除了在最后我们将json文件传了进去。
现在我们就可以运行这个镜像了,请先确保你的consignment镜像已经在运行了。和之前一样,可以看到 Created: true
的信息。
Vessel service (船只管理服务)
现在让我们开始创建第二个服务。现在我们已经有了一个托运服务 (consignment service),它会将最适合货物托运的集装箱和船只匹配起来,我们需要将集装箱 (container) 的重量和数量发送到船只服务 (vessel) 中,后者将会找到合适的船托运这些货物。
protobuf
接下来就创建一个新的项目 vessel-service
,配置和Dockerfile之前的设置一样,$ mkdir -p vessel-service/proto/vessel
,并新建一个protobuf文件 vessel-service/proto/vessel/vessel.proto
:
1 | // vessel-service/proto/vessel/vessel.proto |
如你所见,和 consignment-service
中的 proto 文件非常像。我们可以创建一个具有单个方法 FindAvailable
的服务,需要一个Specification
类型的参数,并返回 Response
类型,Response
类型返回单个或多个Vessel
类型 (使用 repeated
类型)。
创建一个 vessel-service/Makefile
来记录编译流程和执行流程:
1 | build: |
与 consignment-service/Makefile"
中唯一不同的地方就是,我们需要确保容器在宿主机上使用与之前不一样的端口。
main.go
最后我们可以开始实现方法了:
1 | // vessel-service/main.go |
好的,这里顺便把 vessel-service/Dockerfile
编辑一下,和之前的几乎一样:
1 | FROM golang:alpine as builder |
consigment-service
接下来就是有趣的地方了,我们需要修改货运服务 ( consignment-service/main.go
) 以使得前者可以调用货船服务 (vessel-service):找到适合的船、更新 vessel_id
等:
1 | // consignment-service/main.go |
以上代码我们创建了一个货船服务的客户端实例,通过这个实例我们可以使用服务名 vessel
来调用货船服务中的方法。在这种情况下,只要调用一个方法 FindAvailable
,我们就将货物的重量以及需要运输的集装箱数量作为详细说明发送给货船服务 vessel-service
,后者会返回一个合适的船只。
也要修改 consignment-cli/consignment.json
文件,修改之前写死的 vessel_id
,我们想要确认 vessel-service
是否能正常的生成 vessel_id
,所以在文件中加入一些集装箱和它们的重量信息:
1 | { |
现在,在 consignment-cli
中运行 $ make build && make run
,你应该可以看到返回信息,其中包含已经创建的货运清单,里面也已经设置好了 vessel_id
。
那么到此为止,我们已经拥有两个相互连接的微服务和一个命令行界面了。
本系列的下一篇文章将会介绍如何使用MongoDB进行数据持久化,而且我们还会添加第三项服务,并使用docker-compose在本地管理我们不断增加的容器。
代码仓库:
觉得这系列文章对您有帮助,可以请原作者喝杯咖啡,Cheers! https://monzo.me/ewanvalentine.
或者通过 Patreon 来支持原作者。