vlambda博客
学习文章列表

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

作者丨Philipp Heckel

译者丨大愚若智

任何曾经管理过几十上百台物理服务器的人都知道:确保所有服务器始终安装最新安全更新,或者保证所有服务器的配置和状态相一致,这始终是一件很难完成的任务。为了解决这个问题,系统管理员通常会使用 Puppet 、 Salt 等工具,或将应用程序部署到容器中。如果整个环境都能由你控制,这些当然都是很棒的方法,但如果你使用了类似 BCDR 一体机之类的设备(或者任何未部署在自己基础架构内的一体机 / 服务器设施),这些方法往往就不怎么实用了。除此之外,替换系统内核、安装大型系统升级,或安装其他需要重启的大型补丁,此时也无法适用这些方法。

当我们使用的 BCDR 设备面临这些问题后,我们开始寻找其他更可行的方法,并且真的有所收获。近两年来,我们为超过 80,000 台设备使用了这种方法,效果一直很稳定。本文我将谈谈我们是如何通过镜像、回环设备(Loop device)以及大量和 Grub 有关的“魔法”解决这个问题的。如果对此话题感兴趣,欢迎继续阅读下去。

1 从头到尾使用 Debian 软件包?

我们的 BCDR 一体机始终运行了 Ubuntu,因此在更新软件时,最自然的方法就是使用 Debian 软件包。过去很长时间以来都是这样做的:每两周,我们会为 Ubuntu 10.04/12.04(没错,我知道你有疑问,请继续读下去!)构建所需的发布,经过全面测试后将其正式部署出去。

很长时间以来这样做完全没问题,但这种做法有一些很明显的不足之处:

第三方依赖项的更新:使用少量 Debian 软件包,这容易让人觉得只需要为自己的软件负责,而不需要为一体机中运行的其他软件负责。如果你只使用了自己的 datto.deb,但此时 Apache、Samba、libc 甚至 PHP 的更新管理工作其实同样重要。鉴于我们作为 Datto,本身所销售的就是完整的一体机,当然也就需要负责管理整个栈,尤其是第三方软件的安全补丁等内容。

服务重启动 / 重引导:对于一些需要重启动服务甚至重引导计算机的大型更新,依赖项问题也会变得异常棘手。当然,Debian 软件包自己就应该能处理服务重启动问题,但实际上并非所有软件包都能妥善搞定。并且一旦需要重引导(例如需要升级系统内核),还需要确保不会打断重要的设备任务(例如备份、虚拟化……),此外还要保证设备最终能引导成功(这事情并不像你想的那么容易,下文将会详细介绍!)。

发行版升级:如果整个操作系统的版本需要升级,这才是最麻烦的地方。举例来说,如果只使用 apt-get dist-upgrade 命令以及 reboot 命令将 Ubuntu 10.04 升级到 16.04,整个过程将变得漫长无比,并且很多时候可能会升级失败(只要你用过 usedapt-get dist-upgrade,那么肯定会明白)。

数千个版本和状态:在 Debian 的升级模型中,“设备”的实际行为其实和普通计算机无异:刚刚创建好镜像并部署后,一切都是崭新的,一切都可以正常运转。但随着镜像越来越老,操作系统退化的问题就变得越严峻,导致不同设备的状态产生巨大差异。能严重到什么程度?我们的设备(在切换到 KVM 前)曾经使用了 40 个不同版本的 VirtualBox、25 个不同的 ZFS 版本,以及超过 80 个不同的 Linux 内核!

其实,实际遇到的问题远比上面列出的更多,不过这里就不拿更多问题来给大家添堵了。快速开始介绍最有趣的内容:如何解决!

2 使用镜像,而非软件包!

鉴于会遇到这么多问题,很明显,我们需要用更好的解决方案来管理设备状态和配置。产品中不同的设备配置 / 软件包 / 版本数量不仅要降至最低,并且在每次升级时必需能保证能够升级整个栈:不仅要能升级我们自己的软件,还要能升级第三方软件,甚至诸如 Libc 或系统内核等系统库。

3 前提要求

随后我们开始确定这个解决方案的前提要求,其实这些要求并不多:

所有设备沿用相同的升级路径,并且只存在一个升级路径。

所有设备均可通过这种方式升级(哪怕操作系统盘较小的老设备)。

从一个版本切换到另一个版本的过程必需满足原子性要求(或尽可能满足这种要求)。(如果升级失败)能够回滚到上一个版本。而这些要求还暗含了一个最重要的前提条件:不能继续使用基于软件包的升级方法了,并且(从字里行间也能体会到)在升级过程中重引导一体机,这是可以接受的。

这些都是很大胆的念头。我们确实做出了一个重大决定!

4 那么镜像到底是什么?

为了减少配置的数量,我们决定不再将我们的软件及其所有依赖项看作不同个体,而是将所有这一切组合成一个统一的可交付物:镜像。

那么镜像到底是什么?镜像(在我们的环境中)是指一种 EXT4 文件系统,其中包含了引导和运行 BCDR 一体机所需的一切,例如:

Ubuntu 基础操作系统(内核、系统库……)

必需的第三方工具和库(Apache、KVM、ZFS……)

Datto 设备软件(我们的营销团队将其称之为 IRIS)

下图就显示了一个这种镜像所包含的内容:

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

我们对这种想法非常激动,因为通过使用镜像,只需要一个数字,也就是镜像的版本号(例如上图中的“415”)就可以定义所安装的每个软件的具体版本。再也不用针对多种 ZFS 版本测试我们的软件,更不用暗自祈祷我们的软件能兼容所有 KVM 版本。太棒了!

5 基于镜像的升级

做出所有这些重要决定后,我们依然需要通过某种方法来构建、分发,并在设备上引导这些镜像。具体怎么做呢?

构建镜像

通常来说,每次标记了一个新的发布(或发布候选)后,我们会自动构建镜像:每次在 Git 中推送标签后,一个 CI 工作进程会开始构建镜像。构建过程本身也挺有趣,不过已经超出了本文的范围,但为了不吊大家胃口,下文将简单介绍这个过程:

我们首先会为自己的软件构建 Debian 软件包,并将其发布至一个 Debian 仓库。随后使用 aptly (参阅“Datto packages”一图)为这个 Debian 仓库创建快照,同时还会定期对一个上游 Ubuntu 仓库(“Upstream packages”)执行类似操作。随后使用 debootstrap 创建一个 Ubuntu 基准系统,并将我们的所有软件及其依赖项安装到一个 Chroot 中。一旦完成这些操作,会对其创建 Tar 归档并 Rsync 到我们自己的镜像服务器。在镜像服务器上,我们会提取出 Tarball 并 Rsync 给最新镜像,这个最新镜像位于一个格式化为 EXT4 文件系统的 ZFS 卷(zvol)中。在将所有未使用的 EXT4 块归零后,会对包含该文件系统的 zvol 创建最终快照。

因此在镜像服务器上可以看到类似下图所示的内容:

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

上述 zvol 包含了我们 BCDR 一体机的 EXT4 文件系统。这就是一个镜像,也是我们唯一需要交付的东西。它可以作为一个整体进行测试,一旦通过了 QA 流程,就可以分发到客户的 BCDR 设备中了。

分发镜像

在成功构建镜像后,又该如何将其从我们的数据中心发送给超过 8 万台设备?很简单,我们使用了 ZFS send/recv !

我们的所有设备都具备 ZFS 池,其中存储了设备的镜像备份,并且之前我们就在大量使用 ZFS send/recv 为这些备份提供离场保存能力。而此时只不过是换种方向使用这种技术。

我们是这样做的:需要升级时,会让一部设备通过 HTTPS 下载 ZFS sendfile diff(之前曾经尝试过直接通过 SSH 使用 ZFS send/recv,但这种方式无法进行缓存):

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

从上图中可以看到,通常并不需要下载完整镜像,因为设备以前就升级过,已经在本地池中保存了镜像的一个版本。这就很棒了:通过这种技术,我们可以进行差异化的操作系统升级,也就是说,设备只需要下载镜像中有变化的块。

这是一种双赢的结果,因为不会过多占用客户网络带宽,而我们自己的数据中心也可以节约一笔带宽费用。

下载好的镜像会被导入本地 ZFS 池。这对于下一次升级很必要(可以确保只需要下载有变化的内容):

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

引导镜像

拿到镜像后,如何引导至这个新的文件系统?如果我们构建的每个镜像版本都是全新操作系统,又该如何从一个版本引导至下一个版本?

6 ZFS-on-root、A/B 分区和 A/B 文件夹

毫无疑问,这些问题的答案并不只有一种。我们可以通过多种方法使用镜像生成可引导的系统,因此需要多次实验找出一种最佳方法。

这个过程也很有趣,因此我准备简要介绍每种方法,以及最终未选择这些方法的原因:

ZFS-on-root 和 A/B 数据集:我们的镜像备份操作中大量使用了 ZFS,因此一开始很自然就觉得也可以将 ZFS 用作一体机的根文件系统。为此可以将 BCDR 一体机的镜像作为一个 ZFS 数据集(而非上文提到的 zvol)来进行分发,对其进行克隆并直接引导至 ZFS 的克隆副本。由于 Grub 的新版本已经可以支持读取 ZFS,此外还提供了 ZFS initramfs 模块,ZFS-on-root 绝对是可行的。如果要从一个镜像升级到下一个(例如从一个 ZFS 数据集升级到下一个),只需要更新 Grub 的配置并重引导就行。这种方式可以正常起效,但因为引导至 ZFS,这是一种比较新的做法,我们认为其成熟度还不足以满足我们产品的需求。不予考虑。

简单的 A/B 分区:有些一体机和手机会使用两个分区,其中一个包含当前系统,另一个包含下一个系统。这种思路也很简单:下载新镜像,将其 Rsync 到不活跃分区,更新 Grub,然后重引导。然而这种做法的问题在于,我们的有些设备不具备额外创建一个分区所需的存储空间(或者至少需要重建分区)。我们在实验中尝试过在首次重引导过程中,从 initramfs 内部将活跃根分区拆分为两个并且成功了(挺酷的对吧),但考虑到这将要用于我们的主要产品,该方法风险太大。不予考虑。

引导至 A/B 目录:由于一些设备缺乏备用分区,我们还实验过将镜像的两个副本保存到根分区中的两个文件夹中(例如一个 /images/412 和一个 /images/415),随后修改 initramfs 引导至 /images/415,而非引导至 /。不管你信不信,虽然听起来挺疯狂,但这样做竟然也成功了,并且整个方法也超级简单,只要对 initramfs 进行少量修改:mount --bind /images/415 /root 改成这样就行。一切都可以正常运转,不过很多 Linux 工具(df、mount……)会因为根目录不是 / 而遇到一些问题,所以这个方法也不予考虑。

7 循环往复,这就够了!

在尝试过用多种方法引导镜像后,我们最终采取的做法似乎感觉有些无趣。不过无趣也是好事对吧!

我们发现,如果要引导一个镜像,最简单可靠的方法是利用 Grub 的回环引导(Loopback booting)机制,并配合 initramfs 对 Loop 的支持(请参阅 loop=…参数):

众所周知,Grub 是种引导加载器(Boot loader)。它的责任是加载初始的 RAM 磁盘和内核。为此,Grub 内置了对很多文件系统的读取能力,并能通过 loopback 命令支持稍后将要提到的“文件系统中的文件系统”。loopback 命令可在根分区找到镜像文件并对其进行环回(Loop),这样就可以照常使用 linux 和 initrd 命令找到内核和 RAM 磁盘。例如我们在设备 grub.cfg 文件中(通过 /etc/grub.d 中的钩子)生成的菜单项范例如下所示:

menuentry 'Datto OS (v415.0)' { search --set=root --no-floppy --fs-uuid 8c43bf01-046c-401c-8cb8-97cb658ef698 loopback loop /images/415.0.img linux (loop)/vmlinuz root=UUID=8c43bf01-046c-401c-8cb8-97cb658ef698 rw loop=/images/415.0.img ... initrd (loop)/initrd.img}

在这个例子中,Grub 首先会通过 search 以及 UUID 寻找根分区(就像对常规安装的 Ubuntu 做的那样)。随后会发现根分区中的镜像文件 /images/415.0.img,最后找到镜像中的内核((loop)/vmlinuz)和 RAM 磁盘((loop)/initrd.img)。

整个过程异常简单,但同时却非常酷:引导加载器竟然能这样做,这一点让我大为惊奇。

当 Grub 找到内核和初始 RAM 磁盘后,会将 RAM 磁盘载入内存(震惊!),随后挂载根文件系统,最后将控制权转交给 init 进程。

在 Ubuntu 中,initramfs-tools 软件包提供了创建和修改初始 RAM 磁盘的工具。幸亏该软件包已经可以支持回环引导机制,因此一般来说除了需要在内核行传递 loop= 参数,其他什么都不用做。如果设置了该参数,initramfs 会用回环的方式,使用 mount -o loop(参阅源代码)将根文件系统加载至镜像。考虑到代码中有一条相当吓人的 FIXME 消息(# FIXME This has no error checking),我们认为最好能提高它的弹性,为其增加错误处理和 fsck 能力。不过大部分情况下,使用 initramfs 都可以顺利引导并且不显示任何信息。

就是这样,一个简单的解决方案,洋洋洒洒写了这么多。

这种方法在实践中用起来是这样的。如图所示,该设备的根文件系统位于 /dev/loop0,该回环设备在 initramfs 中设置而来,指向了一个镜像文件:

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

本例中,镜像是位于根分区(如 /dev/sda1)下的 /images/412.0.img。请注意,如果镜像中存在空的 /host 文件夹,initramfs 会将根分区挂载在这里:

数千台设备的软件和操作系统,每两周升级一次,这是怎么做到的

8 镜像间的升级

我们已经可以构建、分发并引导镜像。如果将这一切结合在一起就会发现,从一个镜像到下一个镜像的升级其实一点也不难:

清理老镜像,下载新镜像,导入到池,导出到镜像文件。

将配置从当前镜像迁移到下一个镜像。

更新 Grub 以指向新镜像。

重引导。

我们所做的就是这样。为此还开发了一个名为 upgradectl 的工具:

upgradectl 通常可由我们的签入进程远程触发:在设备正常运转的过程中,它可以下载并导出镜像(第 1 步),借此在后台为升级过程做准备。需要进行升级时(通常是夜间的设备闲置时段),实际的升级过程将非常快速地完成,因为只需要迁移配置,更新 Grub 并重引导(第 2-4 步)即可。一般来说,升级过程中的设备停机时间约为 5-10 分钟,并且这主要取决于重引导所需的时间(大型设备可能需要更久,因为需要 IPMI/BMC 初始化)。

当然,这一过程中也有数不胜数的问题和边缘案例需要考虑:听起来确实简单,但想要做对其实并不容易,尤其是考虑到我们现有的 8 万台一体机中,有些在生产环境中连续运转已经有超过 7 年时间了。

但这也造就了一些有趣的挑战:我们已经将数千台设备从 Ubuntu 12.04(甚至 10.04)直接升级至 Ubuntu 16.04。如果升级过程因为某些原因失败,会通过一些逻辑来处理老镜像的回滚。我们处理了完整的操作系统盘、有故障的硬件(磁盘、IPMI、RAM……)、配置为 RAID 的操作系统盘以及 Grub 无法向其中写入的问题,当然还有 ZFS 池出错、Linux 进程挂起(D 状态)、重引导挂起等各种问题。

但是你猜怎样:这一切都是值得的。这就好像结束了一场为期 7 年的寒冬之后进行的春季大扫除。我们让这些设备重新焕发了生机,并且这样的工作还将继续,每两周进行一次!

9 总结

本文介绍了如何将 BCDR 一体机的部署流程由基于 Debian 软件包的方法改为基于镜像的方法。此外还介绍了构建、分发镜像的方法,以及如何使用 Grub 的 loopback 机制引导镜像的做法。

虽然这种基于镜像的升级方法的诞生有我的全程参与,但这其中最让人激动的一点在于:借助这种机制,我们甚至可以在不同内核,以及不同的操作系统大版本之间切换。每次发布升级后,我们都可以有效地引导至一个全新操作系统,这意味着系统不会随着时间的延长而退化,所有手工改动都会被消除,甚至从技术上来看,还可以在愿意的情况下切换使用不同的 Linux 发行版。

并且这一切都是在后台进行的,完全无需用户介入,对用户来说完全透明:每两周对 8 万个操作系统进行升级,这该有多酷啊!

本文最初发布于 Datto Engineering 博客,原作者 Philipp Heckel,经原作者授权由 InfoQ 中文站翻译并分享。

原文链接:

https://datto.engineering/post/how-we-upgrade-thousands-of-appliances-every-two-weeks


点个在看少个 bug 👇