每课的微服务架构演进之路

应老师的邀请,我在 SOA(面向服务的架构)课堂上分享了每课的微服务架构演进之路。本篇文章收录了分享的讲稿和 PPT,作为一个架构演进的记录,也希望能帮助到其他人。

每课的微服务架构演进之路

应老师的邀请,我在 SOA(面向服务的架构)课堂上分享了每课的微服务架构演进之路。本篇文章收录了分享的讲稿和 PPT,作为一个架构演进的记录,也希望能帮助到其他人。

大家好,我是詹泽宇,今天我和大家分享一下每课的微服务架构演进之路。

首先简单介绍一下每课是什么,虽然每课在学校里已经很有名气了,但是不一定在座的每个人都知道,所以我简单介绍一下每课。每课是一款校内 web 应用,它可以把你的课表导入手机日历,不需要安装任何 APP 就可以在手机日历中查看自己的课程以及其他日程安排。你还可以查询和自己上同一节课的同学,甚至还能查询别人的课表。

作为一款面向中南大学本科生的校内应用,我们的单日最高访问量达到了 3.3 万,总访问量达到了 93 万,可能是教务系统之外中南大学校内访问量最大、最流行的应用。并且曾经非常难得的作为一款非官方应用被校团委微信公众号推荐。

每课的架构演化大概分为以下四个阶段。接下来我会按照这个顺序来讲述每课的架构演化路线。

在最开始,和所有互联网产品一样,我们也是从一台服务器开始的。我在一台云服务器上手动配置了环境、部署了应用本体、并搭建了 MySQL 数据库。每次需要版本升级的时候,我需要手动 ssh 连接到服务器,然后 git pull,再重新启动应用服务器。相信这种原始的架构是绝大部分学生做项目的常态,它的问题是显而易见的:

  • 升级应用需要 SSH 连接服务器手动操作,版本迭代频繁的时候非常麻烦
  • 应用服务器出现性能瓶颈时无法水平扩展
  • 所有应用和数据库共享一个 Host 的资源,无法控制各应用所使用的资源量,容易互相影响,单点问题可能导致全局崩溃

于是在 2018 年 5 月,我们的产品转向了基于 Docker 和自己写的部署脚本的容器化部署。请注意一下,上一张图片中应用和数据库的边界是虚线,代表他们的隔离程度不高。而这张图中各个组件的边界变成了实线,这是因为通过容器化的部署,我们已经可以分别限制各个组件使用的 CPU、内存等资源。通过容器之间的隔离,我们在单台主机上避免了单点故障引起的整台服务器崩溃。 另外,借助于容器技术,我们已经可以在单台主机上部署一个应用的多个副本,更好地降低访问延时并提高服务稳定性。 同时,这个时候我们引入了持续集成机制。当我们把代码推向 GitHub 的 Master 分支时,持续集成系统(Travis CI)会自动拉取代码并执行单元测试,测试通过之后会执行服务器上的升级脚本,对生产环境的版本进行滚动升级。滚动升级保证了升级时不会出现服务中断,不过在这个时候我们还没有引入回滚机制,如果当前版本有 bug,需要手动停掉新版本容器然后启动旧版本容器。

总结一下,在这个阶段我们实现了资源隔离、持续集成、无间断的升级和服务的横向扩展。但是依然存在一些问题: 容器需要手动分配固定的端口,否则执行滚动升级脚本后负载均衡器找不到容器的新端口号。扩容时需要修改负载均衡的配置文件,水平扩容并不是非常便利。 因为这个时候我们已经有了除了我的其他开发者加入了,根据业务规划,每课未来会新增多个微服务,包括学生身份校验、API 开放平台等,微服务间有互相通信的需求。而 Docker 容器内部并不知道外部正在运行哪些服务。并且由于容器之间的网络隔离,各个应用间难以进行通讯。可能有的人会说 Docker 也提供了 Docker networking 呀,但是你用起来就会发现 Docker networking 太鸡肋了,难以满足实际需求。


所以到了 2018 年 9 月,在字节跳动取完经之后,我开始模仿搭建了一套基于 Consul 的服务发现体系。 Consul 是一个分布式强一致性 KV 存储,据我所知,字节跳动和京东都在使用它作为服务发现的注册中心。 具体的技术细节可以大概概括为:当应用作为 Docker 容器启动时,Registrator 会将容器的 IP 和端口注册到服务发现 Consul,consul-template 通过轮询 Consul 获取服务所有可用的 IP 和端口,然后写入到 nginx 的配置文件,并通知 nginx 重载配置。当我们需要水平扩容时,只需要通过脚本一键启动多个副本,副本将会被负载均衡自动发现,从而可以开始处理用户请求。

到这里,我们已经可以实现应用的服务化、以及非常方便的水平扩展,并且借助服务注册组件 Registrator 的健康检查功能实现了对容器的健康检查。


那么这个时候我们可以考虑如何拆分服务了。原先我们只有 everyclass-server 一个后端服务,处理所有的业务,这样的缺点是:

  • 各业务领域需要采用相同的技术栈,难以快速应用新技术;
  • 对系统的任何修改都必须整个系统一起重新部署 / 升级,运维成本高;
  • 在系统负载增加时,难以进行水平扩展;
  • 当系统中一处出现问题,会影响整个系统

为了解决这些问题,微服务架构应运而生。微服务架构是一种架构风格,它将一个复杂的应用拆分成多个独立自治的服务,服务与服务间通过松耦合的形式交互。

在对业务进行分析之后,我们发现每课可以被拆分为如下几个微服务:


在划分了微服务之后,下一个要解决的问题就是微服务之间如何进行通讯。序列化的协议大概可以分为两种,自描述的文本协议和 “讲黑话” 的二进制协议。 自描述的文本协议包括 JSON、我们课上讲过的 SOAP 以及其他 XML 格式。他们的特点是,比较适合人类阅读,但相对应的缺点就是体积过于膨胀,有很多对程序无用的信息会造成不必要的带宽浪费。 而以 PB、thrift 为代表的二进制协议大多是通信双方预先约定好消息格式,传输的时候最大限度地减少不必要的信息。


除了序列化协议之外,另一个影响 RPC 性能的是网络层和应用层协议。我在这里大致对比了一下不同的序列化协议和应用层协议的组合方式的性能:

那我们先从效率最高的看起。

  • Thrift 的网络层和传输层都是自己的协议,学习成本可能会稍微高一点,因此先不考虑
  • Python 目前只有一个支持 HTTP2.0 的包,并且还处于开发阶段,不是很成熟。gRPC 因为直接封装了调用过程,不需要考虑怎么传输,倒确实是一个可选项。
  • 然后是 HTTP/1.1 上的二进制协议,这个方案虽然在应用层 HTTP/1.1 Header 里有很多无用的信息,但内容是二进制的,相比文本格式能节省很多流量,并且实现起来也非常简单,所以我们采用了这种方案

再回到我们的系统架构。这个体系看起来似乎已经比较好了,但是很遗憾,仍然存在一些问题:

  • 使用了较多的组件、并且写了比较长的部署脚本来实现这套体系,学习成本比较高。由于代码仓库内的部署脚本是在宿主机上运行的,一旦某个微服务的开发人员改错了部署脚本甚至是恶意修改了部署脚本,可能导致整套体系崩掉
  • 整套运行环境难以复制,如果想再搭建一套 staging 环境和 testing 环境相当困难
  • 新版本发行后没有经过单元测试之外的任何验证,直接自动上线生产环境,风险较大
  • 默认应用服务器是无状态的,缺少持久化数据卷的支持,导致我们无法运行有状态的容器

其实架构做成这个样子也差不多了,毕竟像头条、京东这样的大公司其实也就是整套体系比我封装的更好一些,本质是差不多的。本来呢,这个架构演进到这里就已经结束了。但是我后来在 10 月份去了谷歌开发者大会,在会场和别人交流的时候谈到了微服务架构、服务发现这些东西。


我说我基于 Consul 搭建了一套微服务体系,对方的回复是:

我建议你去学一学 Kubernetes。有了 Kubernetes 之后,所有的微服务架构都过时了。

Kubernetes 又是什么呢?Kubernetes 是 Google 设计的,跨主机集群的自动部署、扩展以及运行应用程序容器的平台。之前实习的时候听别人说过,但是没有尝试过。总之呢,这哥们儿就是一个 Kubernetes 的拥趸,花了好几分钟向我强烈安利 Kubernetes。所以回来之后我去图书馆借了本书,粗略了解了一下。看完之后觉得这个东西确实非常先进。所以在今年的 11 月份,也就是最近,我又把整套体系迁移到了 Kubernetes 上。

事实证明,Kubernetes 真香。


这张图是我们目前的基于 Kubernetes 的微服务架构。可以看到左上角的标注再也不是 “单台云服务器” 了,而变成了 “Kubernetes 集群”。这里的集群的意思是,这套架构在拓扑上已经具有了集群的伸缩性,而集群内的实际机器台数对使用方是完全透明的。因此无论实际是 1 台还是 10 台,使用方感受不到差异。当我们需要更高的性能时,运维可以进行扩容。

我们利用 K8s 的命名空间机制,创建了三个命名空间,分别用于普通用户实际访问的生产 production 环境、预演即将发布的版本的 staging 环境,以及用于开发阶段功能验证的 testing 环境。三个环境互相隔离,通过不同的域名实现公网访问。你可以看到每个命名空间内运行同样的微服务,只是运行的版本和配置文件不同。 新的发行版本在构建镜像后会自动升级预发布环境。那么我们如何测试预发布的版本的稳定性呢?我们相信用户的真实访问流量是最好的测试用例,因此来自生产环境的流量会被实时回放到预发布环境以用于测试。运行期间如果出现异常会被自动上传到错误追踪平台并通过 IM 软件报警,运行日志也会被自动发送到中心化日志系统。

借助于 Kubernetes 的持久化存储卷申请(Persistent Volume Claim)机制,应用可以通过配置文件定义所需的存储资源,并在启动时动态为其分配基于网络文件系统(NFS)的空间。而 Stateful Set(有状态副本集)让有状态的容器,比如数据库的运行成为了可能。所以你可以看到在这个图中,我们已经开始在容器里运行 mongodb 了。


那么我们现在回顾一下之前的几个问题:

第一个问题,我们现在已经不用脚本来部署了,所以不会出现整套体系崩掉的问题了。 第二个问题,我们基于命名空间机制在集群内搭建了 staging 和 testing 环境,不需要再新建两个集群。 第三个问题,我们通过真实流量进行版本正式上线前的自动化测试,进一步验证了新版本的可靠性。 第四个问题,基于 Kubernetes 提供的特性我们实现了数据卷的持久化存储和有状态的应用服务器。

所以,我们这次真的实现了比较完美的架构。


其实在每课开发的过程中还有很多值得分享的经历,比如我们如何实现错误的追踪、如何监测程序的性能表现、如何在分布式系统中收集应用的日志、如何保证服务的可用性,但是我们现在在上的是 SOA 课,所以就不分享无关的东西了,有兴趣的同学可以关注我博客的更新。谢谢大家。