@liuhui0803
2019-09-23T15:57:30.000000Z
字数 6704
阅读 1170
Bcdr
Grub
ZFS
Debian
任何曾经管理过几十上百台物理服务器的人都知道:确保所有服务器始终安装最新安全更新,或者保证所有服务器的配置和状态相一致,这始终是一件很难完成的任务。为了解决这个问题,系统管理员通常会使用Puppet、Salt等工具,或将应用程序部署到容器中。如果整个环境都能由你控制,这些当然都是很棒的方法,但如果你使用了类似BCDR一体机之类的设备(或者任何未部署在自己基础架构内的一体机/服务器设施),这些方法往往就不怎么实用了。除此之外,替换系统内核、安装大型系统升级,或安装其他需要重启的大型补丁,此时也无法适用这些方法。
当我们使用的BCDR设备面临这些问题后,我们开始寻找其他更可行的方法,并且真的有所收获。近两年来,我们为超过80,000台设备使用了这种方法,效果一直很稳定。本文我将谈谈我们是如何通过镜像、回环设备(Loop device)以及大量和Grub有关的“魔法”解决这个问题的。如果对此话题感兴趣,欢迎继续阅读下去。
我们的BCDR一体机始终运行了Ubuntu,因此在更新软件时,最自然的方法就是使用Debian软件包。过去很长时间以来都是这样做的:每两周,我们会为Ubuntu 10.04/12.04(没错,我知道你有疑问,请继续读下去!)构建所需的发布,经过全面测试后将其正式部署出去。
很长时间以来这样做完全没问题,但这种做法有一些很明显的不足之处:
apt-get dist-upgrade
命令以及reboot
命令将Ubuntu 10.04升级到16.04,整个过程将变得漫长无比,并且很多时候可能会升级失败(只要你用过usedapt-get dist-upgrade
,那么肯定会明白)。其实,实际遇到的问题远比上面列出的更多,不过这里就不拿更多问题来给大家添堵了。快速开始介绍最有趣的内容:如何解决!
鉴于会遇到这么多问题,很明显,我们需要用更好的解决方案来管理设备状态和配置。产品中不同的设备配置/软件包/版本数量不仅要降至最低,并且在每次升级时必需能保证能够升级整个栈:不仅要能升级我们自己的软件,还要能升级第三方软件,甚至诸如Libc或系统内核等系统库。
随后我们开始确定这个解决方案的前提要求,其实这些要求并不多:
而这些要求还暗含了一个最重要的前提条件:不能继续使用基于软件包的升级方法了,并且(从字里行间也能体会到)在升级过程中重引导一体机,这是可以接受的。
这些都是很大胆的念头。我们确实做出了一个重大决定!
为了减少配置的数量,我们决定不再将我们的软件及其所有依赖项看作不同个体,而是将所有这一切组合成一个统一的可交付物:镜像。
那么镜像到底是什么?镜像(在我们的环境中)是指一种EXT4文件系统,其中包含了引导和运行BCDR一体机所需的一切,例如:
下图就显示了一个这种镜像所包含的内容:
我们对这种想法非常激动,因为通过使用镜像,只需要一个数字,也就是镜像的版本号(例如上图中的“415”)就可以定义所安装的每个软件的具体版本。再也不用针对多种ZFS版本测试我们的软件,更不用暗自祈祷我们的软件能兼容所有KVM版本。太棒了!
做出所有这些重要决定后,我们依然需要通过某种方法来构建、分发,并在设备上引导这些镜像。具体怎么做呢?
通常来说,每次标记了一个新的发布(或发布候选)后,我们会自动构建镜像:每次在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池。这对于下一次升级很必要(可以确保只需要下载有变化的内容):
拿到镜像后,如何引导至这个新的文件系统?如果我们构建的每个镜像版本都是全新操作系统,又该如何从一个版本引导至下一个版本?
毫无疑问,这些问题的答案并不只有一种。我们可以通过多种方法使用镜像生成可引导的系统,因此需要多次实验找出一种最佳方法。
这个过程也很有趣,因此我准备简要介绍每种方法,以及最终未选择这些方法的原因:
/images/412
和一个/images/415
),随后修改initramfs引导至/images/415
,而非引导至/
。不管你信不信,虽然听起来挺疯狂,但这样做竟然也成功了,并且整个方法也超级简单,只要对initramfs进行少量修改:mount --bind /images/415 /root
改成这样就行。一切都可以正常运转,不过很多Linux工具(df、mount……)会因为根目录不是/
而遇到一些问题,所以这个方法也不予考虑。在尝试过用多种方法引导镜像后,我们最终采取的做法似乎感觉有些无趣。不过无趣也是好事对吧!
我们发现,如果要引导一个镜像,最简单可靠的方法是利用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会将根分区挂载在这里:
我们已经可以构建、分发并引导镜像。如果将这一切结合在一起就会发现,从一个镜像到下一个镜像的升级其实一点也不难:
我们所做的就是这样。为此还开发了一个名为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年的寒冬之后进行的春季大扫除。我们让这些设备重新焕发了生机,并且这样的工作还将继续,每两周进行一次!
本文介绍了如何将BCDR一体机的部署流程由基于Debian软件包的方法改为基于镜像的方法。此外还介绍了构建、分发镜像的方法,以及如何使用Grub的loopback机制引导镜像的做法。
虽然这种基于镜像的升级方法的诞生有我的全程参与,但这其中最让人激动的一点在于:借助这种机制,我们甚至可以在不同内核,以及不同的操作系统大版本之间切换。每次发布升级后,我们都可以有效地引导至一个全新操作系统,这意味着系统不会随着时间的延长而退化,所有手工改动都会被消除,甚至从技术上来看,还可以在愿意的情况下切换使用不同的Linux发行版。
并且这一切都是在后台进行的,完全无需用户介入,对用户来说完全透明:每两周对8万个操作系统进行升级,这该有多酷啊!
本文最初发布于Datto Engineering博客,原作者Philipp Heckel,经原作者授权由InfoQ中文站翻译并分享。点击阅读英文原文:How we upgrade the software and operating system of thousands of appliances every two weeks。