一种已经落地的容错虚机系统设计

Jul 5, 2021 21:00 · 7321 words · 15 minute read Distributed System

来自 VMWare 于 2010 年发表的论文 The Design of a Practical System for Fault-Tolerant Virtual Machines

VMWare vSphere 4.0 的容错虚机基于双机备份,通过在另一台服务器上的备用虚机复制执行主虚拟机上的操作实现。他们设计的这一整套系统,只会降低实际应用程序少于 10% 的性能,而且双机之间所需的带宽也少于 20Mb/s,使得两者之间远距离成为可能(备机一般不能和主机在同一台物理服务器上,甚至同一个机架同一个机房,机架、机房断电这种情况也并不是没有可能,这个也叫“异地容灾”)。原理很简单,但是要设计一个从故障中自动恢复的商业化系统远不止复制虚机执行的操作。本文将讲述 VMWare 的基本设计、讨论多种可选方案和一系列实现细节。

个人认为,没啥必要去看原文了,已经在商业化产品中使用这项技术了,本文也只给出了一个基础设计,并没有具体到技术细节。

1. 介绍

实现服务器容错最通常的办法是主备,当主机挂掉备机随时都可以进入可用状态并接管。这就要求任何时候备机的状态都要尽可能和主机一致,当主机出现故障备机立即接管,这样才能对外部客户端隐藏故障并且没有数据丢失。有两种将状态复制到备机上的方法:第一种是持续传递主机所有的状态变化,包括 CPU、内存、I/O。但是发送这些状态(尤其是内存变化)所需的带宽非常巨大

另一种办法只要少得多的带宽,叫做状态机(state machine)。这种方法将服务器抽象成确定的状态机,通过从相同的初始状态启动并确保它们以相同的顺序接收相同的输入保持同步。因为服务器会有一些不确定的操作,所以要一些额外的协同来保证主备同步,即便如此这些额外的数据量也比第一种方法少得多。

在 hypervisor 上运行的虚机,是实现状态机绝佳的平台,对状态机的操作就是对机器的虚拟化操作。和物理机一样,虚机也有不确定的操作(比如读取时间或中断),这些额外的信息必须发送给备机以确保同步。hypervisor 对 VM 的执行有完全的控制,包括传递所有输入,hypervisor 能够捕捉到所有主机上不确定操作的必要信息并将它们正确回放至备机。

所以状态机方案能够在最新的处理器上立即实现容错;另外,低带宽允许主备之间物理间隔更大,虚机分布在不同机房的物理机上要可靠得多。

VMWare 在 vSphere 4.0 平台上实现的录制主机执行的操作并确保备机同步执行被称作 deterministic replay。VMWare vSphere 容错(Fault Tolerance)基于 deterministic replay,还增加了额外的协议和功能来构建完整的容错系统。截至论文发表时,生产版本的 deterministic replay 和 VMware vSphere FT 暂时只支持单核处理器;多核版本还在开发中因为几乎每次访问共享内存都是一次不确定的操作。

虽然原理很简单,但是因为性能的原因必须有些本质上的修改并且要调研一系列设计方案。为了让整个系统高效易用,他们不得不设计并实现许多额外的组件。他们只处理 fail-stop 故障,这些都是服务器的问题,应用程序自己的 bug 是用户自己的问题,也没法管。

论文剩余部分概览:

  1. 描述基础设计和协议的细节(如何做到切换至备机时无数据丢失)
  2. 打造一个稳定、完整、自动化的系统时遇到的问题
  3. 其他实现和取舍
  4. 性能基准测试(这部分本文略过,太长也没人看)

2. 基础容错设计

简单来说就是在不同的物理机上跑一台备用虚机,与主机保持同步,执行与主虚拟机同样的操作,尽管会有一点延迟。这两个虚机在 virtual lock-step 状态。虚机的磁盘都基于共享存储,主备虚机都可以 I/O 访问(还有一种设计,主备虚机的磁盘是分开的,后面会讨论)。只有主机在网络上暴露自己,所有的网络输入都打向主机。类似地,所有的其他输入(键盘鼠标)也都只去往主机。

主机将接收到的输入通过网络发向备机,叫做 logging channel。至于服务器负载,最主要的流量是网络和磁盘。备用机总是和主虚机执行同样的操作,但是备机的输出会被 hypervisor 丢掉,只有主机产生的输出会返回客户端。主备虚机遵循特定的协议,包括备机的 ACK(确认),为了确保如果主机故障时无数据丢失。

为了探测主备机是否故障,系统同时检测心跳并监控 logging channel 的流量。必须确保只有主机或备机接管操作,即便当主备之间失联会造成脑裂。

2.1 Deterministic Replay 实现

**虚拟机副本执行的操作可以被抽象成状态机确定性的输入。两个确定的状态机从相同的起始状态启动并以相同的顺序接收相同的输入,它们会经历相同的状态并产生相同的输出。**虚机有着诸多输入,比如网络来包、读盘、键鼠的输入。不确定事件(虚拟中断)和操作(从处理器读取时钟计数器)也会影响到虚机的状态。这些代表了四个难点:

  1. 正确捕捉所有输入和不确定
  2. 将输入和不确定应用至备机
  3. 不降低性能
  4. x86 微处理器很多复杂操作有不确定的副作用,要捕捉到这些副作用并回放来产生相同的状态

deterministic replay 会录制虚机的输入和所有与虚机相关的不确定状态到一个写入文件的日志流中,通过对日志文件的读取来回放虚机执行的操作。至于不确定的操作,要记录下足够充分的信息来重新生成相同的变更和输出。像计时器和 IO 完成中断这些不确定事件,事件发生时的指令也会记录下来。回放时,事件在指令流中传递。deterministic replay 通过与 AMD 还有 Intel 联合开发实现了一个高效的事件录制与传递机制。

Bressoud & Schneider 的论文 Hypervisor-based Fault-tolerance 中将虚机执行的操作划分成 epoch,中断这样的不确定事件只会在每个 epoch 末期传递。epoch 概念在每次中断都传递确切的指令开销太大了。deterministic replay 没有使用 epoch,每次中断都会记录下来,回放时在合适的时机传递。

2.2 容错协议

deterministic replay 来生成记录主机执行操作的必要日志,通过 logging channel 发送至备机而不是落盘。备机实时回放这些日志,因此和主机执行同样的操作。

输出需求:当备机取代故障的主机,备机要继续主机的向外接的输出。

切换后,备机可能并不在执行主机本该继续执行的操作,因为存在许多不确定事件。但是只要备机一旦满足输出需求,外界就不会感知到。

输出需求可以通过延迟任何外部输出(通常是网络封包),直到备机接收到了能够使它回放至那个点所有的信息来确保。一个必要条件是备机必须已经接收到了所有在输出操作前的日志。如果备机在输出前最新的日志点上线,有些不确定的事件可能会改变其执行路径。

实现输出需求最简单的方法是为每个输出执行创建一种特殊的日志条目,然后由这条规则来限定:

输出规则:直到备机接收并确认了输出操作相关的日志,主机才会向外界返回。

如果备机收到了所有日志,包括输出操作的那些,备机就能够精准重现在输出点的主机状态。相反,如果备机在未收到所有日志就接管,它的状态可能产生偏差。

要注意到输出规则没说要停掉主机。只需要延迟输出,但是虚机自己还是可以继续执行的。因为操作系统自己网络 I/O 和写盘是非阻塞的,通过异步中断来通知完成的,虚拟并不会立即受到输出延迟的影响。

举个栗子,图 2 展示了主备虚机事件的时间线。从主线到备线的箭头表示日志传输,反之表示确认。异步事件和输入输出操作的信息必须得到备机的确认。

除了使用“二阶段提交”事务,备机没有其他办法可以确定主机在发送它最近的输出前后是否崩溃了。幸运的是有 TCP 这类协议来应对丢包和重传。注意到发往主机的包在主机故障时可能丢失,因而不会被传递到备机。但是传输层协议、操作系统和应用都有丢包补偿的能力。

2.3 故障探测

主备机必须快速应答除非有虚机挂掉。如果备机故障。主机会进入 go live 模式,也就是退出记录(日志发送也会停止)并正常执行操作。如果主机故障,备机同样也会 go live,但是过程有些复杂。因为备机执行操作是慢一拍的,备机可能有些收到的日志要确认,但是还没来得及消费。此时备机会停止回放模式并像正常的虚拟那样执行。本质上,备机就已经提升为主机了,现在就缺少了一个备机。在切换至正常模式中,可能有些设备相关的操作。VMware FT 会自动在网络上暴露新晋主机的 MAC 地址,物理交换机会知道新的主机在哪台服务器上。

探测主备虚机故障有多种方式。VMWare FT 使用 UDP 心跳检查虚机是否挂掉。另外,VMWare FT 监控主备机之间的日志流量和确认信息。因此日志流停顿表示虚机可能有故障。

但是任何故障探测方法都会受到脑裂影响。如果备机停止接收主机的心跳,有可能是主机故障了,或者仅仅只是网络断了。如果这时备机上线,主机还在运行中,那么很有可能会出问题。所以,必须确保当探测到故障时二者只有其中之一上线。为了避免脑裂问题,他们使用共享存储来作为虚机的虚拟磁盘。无论主机还是备机上线,会在共享存储上做一个原子性的测试操作,如果操作成功,虚机就被允许上线;如果操作失败了,另一台虚机一定还在线,那么当前的虚机会“自爆”。如果虚机尝试做测试操作时无法访问共享存储,它就一直等着直到可以。注意如果共享存储访问不了,虚机可能啥都干不了因为虚拟磁盘都在它上面。因此使用共享存储来解决脑裂不会有啥副作用。

当故障发生虚机其中之一上线,VMWare FT 会自动在另一台宿主机上启动一个备机来恢复冗余,这个步骤会在 3.1 节讨论到。

3. 容错在实践中的实现

要构建一个实用、稳定又自动化的系统,还有很多组件要设计和实现。

3.1 启动与重启 FT 虚机

必须要考虑的一点是如何启动一台与主机状态相同的备机。这个机制在备机故障重启也将被用到。另外,主机的执行也不能被打断,因为会影响到所有客户端。

VMWare 利用了 vSphere 现有的功能,VMWare VMotion 允许将虚机从一台服务器迁移至另一台服务器,暂停时间通常在一秒以内。他们修改了 VMotion 使其在创建副本时保留原来的虚机。因此,FT VMotion 将虚机克隆至另一台宿主机而不是迁移。FT VMotion 也会建立 logging channel,并使源虚机像主机那样进入日志模式,目标虚机像备机一样进入回放模式。

另一方面,启动备机时要先选择宿主机。因为虚机所在的集群使用共享存储,所以通常虚机可以在集群中的任何服务器上运行。这种灵活性允许多台物理机故障仍能恢复冗余。VMWare vSphere 实现了一个集群服务来维护管理资源信息。当故障发生,主机需要一个新的备机来重建冗余,主机会通知集群服务它需要一个新的备机。集群服务会确定一个合适的节点来运行备机。VMWare FT 能够在服务器故障后数分钟以内就重建冗余。

3.2 管理 Logging Channel

hypervisor 为主备虚机的日志都维护了一个大缓存。随着主机执行,它产生的日志会写入缓存,类似地,备机从缓存中消费日志。缓存的主机日志会尽快刷到 logging channel 中,并尽快从 logging channel 读取到备机的日志缓存中。备机每次将日志从网络读取到缓存中就会向主机应答确认。这些确认使得 VMWare FT 决定是否发送延迟的输出。

如果备机的缓存空了,它就会停止直到有可用的日志。既然备机不与外界通讯,暂停并不会影响到客户端。同样地,如果主机在写日志的时候缓存满了,它必须暂停执行直到日志可以刷入缓存。这里的执行停止是一种自然的流控机制,当主机产生日志太快时对其降速。但是这种暂停就会影响到虚机的客户端。完全停止虚机直到能够记录日志为止。因此,设计时就必须考虑到最小化主机日志缓存塞满的可能性

主机日志缓存塞满一个原因是备机跑的太慢了而不能及时消费日志。总体上,备机回放执行操作的速度必须和主机记录执行操作的速度相同。VMWare deterministic replay 中记录和回放的开销差不多。但是,如果其他虚机导致承载备机的宿主服务器过载了,备机可能无法得到足够的 CPU 和内存资源来和主机执行得一样快。

另一个不希望两者之间延迟太大的原因是,如果主机挂掉了,备机在上线前必须通过回放所有确认过的日志来追上进度。备机上线需要的时间和故障探测时间加上与主机执行之间的延迟。因此,这段延迟的执行时间会直接影响到故障切换的时间,越短越好。

VMWare 有额外的机制来对主机减速防止备机落后太多。发送日志和确认的协议中,添加了额外的信息用了确定主备虚机之间的真实执行延迟。如果备机落后了太多(超过一秒),VMWare FT 将通知调度器稍微少给一些 CPU 来对主机减速。在这里他们使用了减速反馈循环,逐渐摸清主机能够使得备机跟上其进度的 CPU 限制。如果备机还是持续落后,那就再减一点。一旦备机追上进度,就会逐渐提高主机的 CPU 限制直到备机轻微落后。

对主机减速的情况是非常罕见的,通常只在系统极度过载的情况下发生。

3.3 FT 虚机操作

另一个实际的问题是处理对主机应用的各种控制操作。举个栗子,如果主机关机了,备机也应该被关机,不能尝试上线。另一个例子是,主机任何硬件资源变化(加 CPU)同样也要应用至备机。对于这些类型的操作,主机通过 logging channel 给备机发送特殊的控制信息。总之,对虚机的操作,都是要成对完成的,除了 VMotion。主备虚机可以分别被单独迁移至其他宿主机。VMWare 还会确保主备机不会被迁移至同一台服务器上,那样容错就没有意义了。

VMotion 一台主机比正常的 VMotion 要复杂一些,因为备机必须先从源主机断连,并在适当的时间和目的主机重连。VMotion 一台备机也有类似的问题,更复杂一点。对于普通的 VMotion,在切换时需要所有磁盘 IO 都停顿。对于主机这种停顿相对容易处理,就是等待直到物理 IO 完成。但是对于备机,要使 IO 在某个特定的点完成并不容易,因为备机必须重放主机的执行操作并在相同的执行点完成 IO。主机上可能跑着 IO 密集型负载。VMWare FT 有一种独特的办法来解决这个问题。当备机处于 VMotion 的最终切换点时,它通过 logging channel 请求主机暂时停止它所有的 IO,备机的 IO 也会随之停止。

3.4 磁盘 IO 问题

  1. 磁盘操作都是非阻塞且并行,访问相同磁盘位置会导致不确定性。而且他们磁盘 IO 的实现使用 DMA 直接读写虚机的内存,所以同时的磁盘操作会访问相同内存页面,这也会导致不确定性。他们的解决方案是逐渐探测所有这类竞争,强制这些竞争的磁盘操作顺序执行。
  2. 磁盘操作可能和应用程序访问内存发生竞争,因为磁盘操作也是通过 DMA 直接访问虚机内存的。如果虚机的应用程序/操作系统在访问一块内存的同时同一块内存也在发生读盘,可能会有不确定的结果。
    • 一种解决方案是在页面上设置临时的页面保护。页面保护会在两者同时访问同一页时导致 trap,然后虚机会暂停直到磁盘操作完成。
    • 但是修改页面的 MMU 保护开销太大了,第二种解决方案叫做 bounce buffer(回弹缓冲区)。回弹缓冲区和磁盘操作访问的内存大小相同,一次读盘操作时首先将数据读入回弹缓冲区,只有在 IO 完成时才拷贝到内存中。类似地,写盘操作也是先将数据复制到回弹缓冲区中,然后再从缓冲区写数据到磁盘。使用回弹缓冲区会减慢磁盘操作的速度,但是不会对性能产生致命影响。
  3. 当故障发生备机接管时主机上还在执行大量磁盘 IO,也会有问题。新晋的主机没法知道磁盘 IO 是否成功。另外备机上磁盘 IO 没有对外发布,新晋主机继续运行时不会有明确的 IO 完成,最终可能导致虚机上的操作系统终止并重置程序。可以发送错误完成信息来表示 IO 失败,即使 IO 成功完成,返回错误也是可以接受的。但是虚机操作系统可能不会很好地响应本地磁盘的错误。相反,在备机上线的过程中重新发布挂起的 IO。因为所有的竞争都消除了,这些磁盘操作可以被重新发布即使它们已经成功完成了。

3.5 网络 IO 问题

VMWare vSphere 对虚机网络做了很多性能优化。有些基于 hypervisor 对虚机网络状态的异步更新。举个栗子,当虚机正在运行时接收缓存可以由 hypervisor 直接更新。不幸的是这些虚机状态的异步更新带来了不确定性。除非我们能够保证所有的更新都在主备机的指令流中的同一个点发生,否则备机的执行会偏离主机。

FT 对虚拟网络最大的改动是禁用了异步。异步更新虚机的入包环形缓冲区(ring buffer)的代码被修改为强制虚机陷入(trap)到 hypervisor,在这里可以记录下更新并将它们应用至虚机。类似地,从传输队列中异步拉取包的代码也被禁用了,取而代之的是通过陷入 hypervisor 来完成传输。

取消网络设备的异步更新对性能带来了挑战。VMWare 通过两种途径提升虚机的网络性能:

  1. 优化集群来减少虚机 trap 和中断。当虚机以充裕的比特率传输数据,hypervisor 每传输一帧包会做一次 trap,而最佳情况是零 trap。同样地,hypervisor 能够通过在一组包后发送中断来减少数据包过来时虚机的中断次数。
  2. 第二个网络性能优化关于降低包传输时的延迟。之前提过,hypervisor 必须延迟所有待传输的包直到从备机得到确认。降低传输延迟的关键在于减少发送日志到备机和应答的时间。**确保发送和接收日志无需线程上下文切换。**VMWare vSphere hypervisor 允许将函数注册到 TCP 栈中,无论是否接收到 TCP 数据,在返回上下文前被回调(有点像 Golang 中的 defer)。这使备机无需线程切换就能够快速处理任何到达的日志信息;另外,在主机将待传输的包入队时,通过回调函数强制立即刷走日志。

4. 其他设计取舍

4.1 共享磁盘 vs 非共享磁盘

主备机共享虚拟磁盘。因此共享磁盘的内容自然是正确的而且在故障发生时可用。本质上共享磁盘对主备机来说也是外界,写共享磁盘可以看作与外界通讯。因此只有主机真正在写盘,根据输出规则写共享磁盘必须延后。

另一种设计是主备机有分开的(不共享)虚拟磁盘。这种设计中备机是要向它的虚拟磁盘做所有写入的,这样做的话,自然能够保持两者虚拟磁盘中的内容同步。图 4 说明了这种配置。非共享磁盘的案例,虚拟磁盘必须作为虚机的部分状态。因此主机写盘不必延后。在主备机无法访问共享磁盘的情景中非共享的设计就非常有用了,比如承载主备机的服务器距离太过遥远。非共享设计的一个缺点是虚拟磁盘的两个副本必须明确同步。另外,磁盘在故障时偏离同步,必须重新同步。

而且不能使用共享存储来处理脑裂了,系统要利用其它外界联系,比如主备都可以连接的第三方服务器。在这个案例中,一台虚机只有运行在包含了大多数原始节点的子集群的服务器上时才被允许上线。

4.2 在备用虚机上执行读盘

默认的设计中,备机永远不从它的虚拟读盘取(不管共不共享)。既然读盘可被看作是一种输入,通过 logging channel 将读盘的结果发送至备机也是理所当然的。

另一种方案是让备机执行读盘,所以消除了读盘数据的日志。这种方式能够大幅减少 logging channel 的流量。但是这种方式也有些微妙之处,它可能会使备机的执行速度降低,因为备机必须执行所有读盘并等待直到物理上完成。

而且,必须做额外的工作来处理读盘失败。如果主机读盘成功但是相同的操作备机失败了,必须重试到成功为止,备机一定要和主机内存中的这块数据相同。相反地,如果主机读盘失败,目标内存的内容必须通过 logging channel 发送给备机,因为备机如果读盘成功了那这块内存中的内容就不一致了。

最后。如果主机在特定的磁盘位置读取,在同一个位置写盘紧接而至,写盘必须延后至备机执行了第一次读盘。这种依赖性是可以探测到并正确处理的,但会增加复杂度。