最近,有人问我在什么情况下使用微服务是个好主意。在“系统设计诠释世界”一文中,我谈到了像第二系统效应、创新者困境等宏观问题。系统设计能回答微服务的问题吗?
是的,但你可能不喜欢这个答案。首先,我们需要了解一些历史资料。
什么是微服务?
你可以在网上找到各种各样的定义。我的观点是:微服务是对单体的最极端的抵制。
当你将整个应用程序所需要的全部东西链接成一个很大的程序,并将其作为一个大型二进制包部署时,就会发生这样的情况。单体有很长的历史,可以追溯到像 CGI、Django、Rails 和 PHP 这样的框架。
现在,我们要放弃这样的假设:单体和微服务群是仅有的两个选项。在“一个无所不能的巨型服务”和“无数个几乎什么都不做的微型服务”之间,存在着一个广泛而微妙的连续体。
如果你赶时髦,你至少有那么一次已经构建了一个单体(不管是有意的,还是因为传统框架鼓励你这么做),后来发现了单体存在的一些问题,然后,你听说微服务就是自己需要的答案,于是开始把一切都重新设计成微服务。
但是,不要赶时髦。在这两个极端之间,还有许多点。其中一个可能很适合你。更好的方法是从你想要将接口放在哪里开始。
方框和箭头
接口是模块之间的连接。模块是相关代码的集合。在系统设计中,我们讨论“方框和箭头”设计:模块是方框,接口是箭头。
更深层次的问题是:每个方框要多大?里面放多少东西?我们如何决定什么时候把一个大方框分成两个小方框?连接这些方框最好的方法是什么?有很多方法可以做到这一点。没人知道什么是最好的。这是软件架构中最困难的问题之一。
在过去几十年里,我们经历了许多种“方框”。Goto语句被“认为是有害的”,主要是因为它们完全忽视了任何层次结构。然后我们添加了函数或过程;这些都是非常简单的方框,它们之间有接口(参数和返回码)。
根据你选择的编程路线,你会了解到递归函数、选择符(combinator)、静态函数原型、库(静态链接或运行时链接)、对象(OOP)、协程、受保护的虚拟内存、进程、线程、JIT、命名空间、沙盒、chroot、Jails、容器、虚拟机、supervisor、hypervisor、微内核和unikernel。
这还只是方框!一旦有了彼此隔离的方框,就需要用箭头连接它们。为此,我们有 ABI、API、系统调用、套接字、RPC、文件系统、数据库、消息传递系统和“虚拟化硬件”。
如果你想为现代 Unix 系统画一个完整的方框和箭头图(我不会这样做),那太疯狂了:函数在线程内,线程在进程内,进程在容器内,容器在用户空间内,下层是内核,内核在 VM 里,运行在云提供商数据中心里机架上的一台硬件上,而这些硬件是通过一个编排系统连接在一起的,等等。
每个抽象层上的每个方框都以某种方式隔离开来,然后连接到同层或其他层上的其他方框。有些方框在其他方框内部。如果你想在二维空间中画出这幅画的真实版本,那么其中的线条肯定纵横交错。
这一切都是经过几十年的演变发展而来的。时髦的人称之为“路径依赖”。我称之为一个烂摊子。我们要清楚:其中的大部分已经没有多少价值。
与其把注意力集中在那些丑陋的进化结果上,我们不妨来谈谈,人们在发明这些东西的时候想要达成什么目标。
对模块化的探索
模块系统的首要目标是下面这几个:
将一部分代码与其他部分隔离
仅在明确指定的位置(通过定义良好的接口)重新连接这些部分
保证你修改的部分仍然与其他部分兼容
升级、降级以及扩展某些部分,其他部分都不需要同步升级
计算机行业花费了大量时间,试图找出所有这些模块化问题的完美平衡,同时保证开发过程尽可能的轻松愉快。
一句话,我们没有成功。
到目前为止,我们做得最糟糕的一个方面是隔离。如果我们能真正有效地将一部分代码同其他部分隔离开来,那么其他的目标也就基本可以实现了。但我们根本不知道如何做到这一点。
隔离是一个超级困难的问题。天晓得,人们已经尽力了。但浏览器沙箱逃逸仍然经常发生,未被发现的特权升级攻击在每一个操作系统上都存在,iOS 越狱仍然会周期性地发生,DRM 从来就无效(无论好坏),虚拟机和容器会经常被发现有漏洞,而在像k8这样的系统中,其容器的配置默认就是不安全的。
人们甚至已经知道,通过在因特网上适时地向远程服务器发送数据包,就可以找到加密密钥。与此同时,近年来,最惊人的隔离事故是Meltdown和Spectre攻击,它允许计算机上的任何程序,甚至是 Web 浏览器中的 JavaScript 应用程序,读取同一台计算机上其他程序的内存,甚至是跨沙盒或虚拟机。
每一种新的隔离技术都会经历一个从乐观到绝望的周期:
新想法:这次我们终于做对了,一劳永逸地!
最初的试验似乎有效。
(用户抱怨它比我们上次尝试的方法更慢、更费事。)
早期的致命缺陷被发现并修复。
广泛部署。
越来越微妙的缺陷被陆续发现并修复。
最终,我们会发现我们根本不知道如何修补的缺陷。
不再对这种方法能实现有效的隔离抱有希望。
但我们永远不能退役这种隔离方法,因为现在有太多的人依赖它。
重复上述过程。
举例来说,在这一点上,安全人员根本不相信以下任何一项(每一项都是当时最好的技术)是绝对安全的:
Unix 系统上的进程隔离和内存保护;
当允许远程执行代码(即安全人员所说的 RCE)时,OS 进程之间的权限隔离;
通过过滤系统调用来隔离进程;
互不信任的进程共享一个 CPU 超线程;
位于一个 CPU 内核上的虚拟机之间的内存隔离。
据我所知,目前最先进的隔离技术,是类似于 Chrome 沙盒或gVisor这样的东西。大型浏览器供应商和云计算提供商都使用这样的工具。这些工具也不完美,但是供应商确实在尽可能快地追踪每一个新出现的漏洞,而且新漏洞出现的速度相当缓慢。
隔离比以往任何时候都要好……如果你把所有的隔离都放在虚拟机(VM)级别,那么云提供商就可以帮你完成,因为其他人不知道怎么做,或者无法足够频繁地更新。
如果你信任云提供商的 VM 隔离,那么就可以希望所有已知的问题都得到了缓解;但我们有充分的理由认为,更多的问题将被发现。
从各方面考虑,这其实很不错。至少我们还有有效的东西。
很好!都用虚拟机!
等一等。为每个小模块创建一个孤立的 VM 是一件很痛苦的事情。一个模块该多大?
很久以前,当 Java 第一次出现时,人们的梦想是每个对象中的每个函数的每一行都有严格的权限限制,甚至是在相同应用程序二进制文件中的对象之间,这样就不需要 CPU 强制施加的内存保护。没人再相信他们能在这方面取得成功了。除了类似“云函数”这样的市场宣传,没有人真的认为你应该尝试一下。
目前,已知的隔离方法中没有一种是完美的,但每一种都能达到某种近似的效果。越来越有经验的攻击者,或者越来越有价值的目标,需要更好同时也更恼人的隔离。现在,我们所知道的最好的隔离是由一级云提供商提供的 inter-VM 沙箱。在最坏的情况下,它的隔离效果会降到零。
假如验证被跳过,由于大多数系统的耦合是如此之紧密,一个相当有经验的攻击者可以在模块之间横向突破。因此,举例来说,如果有人可以将恶意库链接到你的 Go 或 C++程序中,那么他们可能会控制整个程序。
类似地,如果程序具有数据库的写入权限,那么攻击者可能会让它写入数据库中的任何地方。如果它可以连接到网络,那么他们就可能连接到网络中的任何地方。如果它可以执行任意的 Unix 命令或系统调用,那么他们就可能获得 Unix 根访问权限。如果它在一个容器里,那么他们可能会从容器中挣脱出来,进入其他容器。如果恶意数据可以使png解码器崩溃,那么他们就可能让它做解码器程序可以做的任何事情,等等。
一种特别强大的攻击形式是获得提交代码的能力,因为这些代码最终将在开发人员的机器上运行,而某些开发人员或某处的生产机器可能有权执行你想要执行的操作。
以上说法可能有点过于悲观,但是做出这些假设,可以帮助我们避免在不提高实际安全性的情况下使系统变得过于复杂。在”qmail 1.0十年之际关于安全的一些思考“一文中,Daniel J. Bernstein 指出,在 qmail 中添加的许多防御措施,特别是使用 chroot 和不同的 Unix uid 隔离各个不同的组件,并不值得,而且从未得到回报。
无论如何,我们可以理所当然地认为,具有代码执行能力的攻击者“通常”可以在耦合模块之间横向跳转,这几乎适用于任何一种模块隔离技术。这意味着只有两种模块边界:
可信的:两个模块彼此信任对方不存在恶意,并因此可以使用弱隔离边界;
不可信的:模块彼此之间相互不信任,因此必须使用强隔离边界。
我在这里并没有说什么非常深刻的东西。现代流行的平台已经是围绕这一区别构建出来的。
例如,Chrome 在强隔离的沙箱虚拟机中运行任意的 Web JavaScript,因为网页是不可信的。
大多数操作系统仅仅以进程(没有沙箱)的形式运行原生应用,共享文件系统、网络名称空间等,因为我们曾经认为,它们是相对可信的。(病毒就是这样产生的。)
专家们不再信任多用户 Unix 系统,因为结果证明进程隔离很脆弱。云虚拟机默认可以无密码 sudo,因为 root 与非 root 隔离都被证明很脆弱,所以为什么还要麻烦呢。
(我们仍然要求用户在删除所有文件或其他东西时输入 sudo,以减少人为错误的影响。)
来自多个供应商的共享库和 DLL 被链接到来自其他供应商的应用程序中,因为所有代码都被假定是可信的。(这为通过开源库供应商进行供应链攻击打开了方便之门。我仍然感到惊讶的是,这类攻击并不经常发生。我有时怀疑,也许它们确实存在,只是很少被发现而已。)
手机操作系统之所以会被破解,是因为应用商店的限制应该会让应用沙盒足够可信,但这种隔离总是被证明太脆弱。
Kubernetes 和 Docker 在一台机器或 VM 中运行多个不完全隔离的容器,因为这些容器都被默认为可信的。强烈建议你不要尝试运行“多租户”Kubernetes 集群(不可信的应用程序代表独立的、相互不信任的用户),因为事实证明,容器的隔离效果很弱。
还有,即使你对每个服务使用像 gVisor'd VM 那样的强隔离,如果代码本身不是使用强隔离的工具链构建的,也不会有什么帮助。如果一组人可以更新一个库,然后链接到一组应用程序,那么这些应用程序并不算是真正的相互隔离,无论它们以什么方式运行。
模块边界 vs 服务边界
如果这么多隔离层都很脆弱,那么我们为什么还要费心使用它们呢?
主要是历史沿革;如果我们抛弃这些层中的大部分,安全性不会受到太大影响,而简洁性将得到提升。我预计,随着时间的推移,这会发生。我们已经看到了这种趋势。多用户 Unix 系统已几乎绝迹;“无服务器”服务器放弃了除最强隔离类型之外的所有隔离类型,并试图将你锁定在你所在的云提供商那里。
但是,让我们把历史放在一边。我必须介绍所有这些隔离概念,这样我才能说得简单点:你几乎从不出于安全原因定义模块边界。
相反,模块边界通常遵循康威定律。人们分解模块的根据是他们希望如何细分团队中的开发工作,而模块之间的通信则取决于团队和团队成员之间的沟通方式。(康威定律很吸引人,也很真实,但你可以在很多其他地方读到它。)
模块边界并不能定义部署单元的大小。
以操作系统为例:
ChromeOS 有成千上万的开发人员,但用户收到的一次更新中包含一个经过全面测试的组合,其中有 Linux 内核、设备驱动程序、窗口管理器、Web 浏览器等。这些模块之间的接口可以在任何版本中更改,因为它们不需要向后兼容(当然,硬件和 Web 除外)。macOS、iOS 和 Android 采用了类似的模式。
Debian Linux 有数千名开发人员,但用户下载和安装的是单个的软件包。你可以将来自古老的 Debian 稳定版本的程序包,和来自如今的 Debian 不稳定版本的程序包,一起运行,而且很可能行得通。可能没有人测试过你这样的特定组合,但它可能可以工作,因为程序包之间有定义得非常好的接口。
(人们在开“桌面 Linux”不可靠的玩笑时,谈论的总是第二种小众而难以测试的类型,而不是第一种主流的、容易测试的类型。我不认为人们感知到的质量差异实际上是由企业资金与开源差异所导致的。不同之处在于部署模型。)
两个系统都包含许多程序包(模块),这些包是由不同团队的许多开发人员开发的。它们的模块之间都有接口。如果你为每个系统画一个方框和箭头图,可能看起来会非常类似:内核、驱动程序、窗口系统、沙箱、Web 浏览器,等等。
然而,如果是后端云服务而不是操作系统,我们将分别称这两个模型为单体和微服务,因为它们的部署模型。一个只有一个要部署的“服务”,而另一个有许多,每个都要单独部署。相同的模块架构!这是怎么回事呢?
模块边界和服务边界是两个不同的东西。
服务的边界应该在哪里?
让我们回顾一下最初的模块化目标:
隔离:如果出于安全考虑,确实需要强隔离,如果你需要单独的服务,那么唯一的方法就是分别提供虚拟机。(不过请注意:这更多的是隔离系统的限制,而非架构目标。“基础设施即代码”和蓝/绿部署会设法让这些服务再次同步,所以你可以有一个单体式的部署模型。)
连接:遵循康威定律。模块边界倾向于遵循团队的个性化沟通模式。但与直觉相反,康威定律并不需要定义服务边界。
兼容性保证:迫使你转向单体。如果你的单体是用一种类型安全的语言编写的,比如 Go、TypeScript、Rust,甚至是 C++,则尤其如此。(例如,Chrome 就是一个巨大的二进制文件。)
升级、降级和可扩展性:这些是决定服务边界的主要因素。关于这一点,让我们再进一步探讨下。
以下是选择服务边界时需要考虑的一些事项:
你的单体需要很长时间才能启动吗?这让升级变得很痛苦,所以你可能想把慢的部分分离出来,让其他东西可以更快的升级。
你需要正确的数据存储模式版本吗?有时,这需要对后端所有实例进行同步升级/降级,以便它们处于相同的模式版本上。同步升级是有风险的,而且往往会妨碍回滚;有时,你希望使依赖于模式的部分尽可能小。
持续集成测试经常失败吗?如果是这样,那真是个坏消息。那些失败的测试说明代码有问题。这是一个功能!拆分服务并单独推出可能会欺骗测试,使其通过,但在生产环境中,你将会遇到兼容性和版本不对称问题。那没提供什么帮助。
有些部分的扩展方式不同于其他部分吗?例如,一些操作占用大量内存,而另一些操作占用大量 CPU。这并不像你想的那么重要。如果所有实例的负载都得到了适当的平衡,那么负载就会以一种非常有效的方式自然地分散在各处。如果负载平衡成为问题,你可以测量它,并在稍后修复特定粒度问题。
成本很高的请求是否需要以较低的并行度运行?一种常见的微服务架构是将请求转储到消息队列中,并让工作者实例按顺序处理请求。但这比你想象的更容易出错,而且有更好的设计可以避免“队列爆炸”问题。你可以在单体上实现同样的设计。
你的服务是否具有不同的质量/可靠性目标?这可能是拆分服务的一个好理由。例如,在 Tailscale,我们只有几个服务具有非常严格的正常运行时间目标:协调服务和日志捕捉服务。为了安全隔离,这两个已经被分开了,因为日志非常敏感。最重要的是,我们的“实时”日志/指标处理管道可以容忍更长的停机时间,因此可以进行更多的试验,因此我们把它从高可靠性服务中分离出来,用了不同的部署过程。
事实上,在服务之间创建边界时,上面的大部分理由都不是非常令人信服。它们是划分模块或团队边界的好理由!但你可以在将模块重新组合成一个或几个单体后推出它们。
记住,ChromeOS 是个单体,iOS 也是个单体。你的团队可能比这两个团队都小得多。你根本不需要为了得到你想要的东西而在大量的微服务上做文章。用简单的方法设计系统,直到你不得不用困难的方法。这就是我们的工作。