# 使用 FreeBSD 构建高弹性的私有云

——本案例研究展示了托管服务提供商的基础设施，该案例中使用了 FreeBSD 基本系统中提供的工具。

* 原文链接：[Building a Resilient Private Cloud With FreeBSD](https://freebsdfoundation.org/wp-content/uploads/2022/06/bell_private_cloud.pdf)
* 作者：**DANIEL J. BELL**

哦，当然。我们也经历过数据泄露，我见过客户丢失数据。一位潜在客户在询问我所在组织的数据保护记录时，将我那支只有六个人左右的小 IT 团队与规模更大的服务商进行比较，对我的回答显然并不满意。显然，在曼哈顿，大型托管服务提供商常在销售演讲中说“我们从未遭受过数据泄露，也从未丢失客户数据”如此一句常见而有些愚蠢的话。我接着解释说，我们曾多次帮助客户从数据丢失中恢复，每个组织早晚都会遇到数据泄露和系统故障。正如经验丰富的系统管理员所知，从未经历数据丢失仅仅意味着你还没有遭遇过。最重要的是要做好充分的准备。

做好准备意味着要有一套出色的基础设施，而这并不意味着仅仅是盲目地将所有工作负载迁移到云端。除了那句关于从未经历数据丢失的陈词滥调，技术销售人员常会大肆宣扬他们对云服务的全心投入，从而声称可以免疫安全和数据丢失问题。但是我最近遇到的客户数据丢失案例正是来自流行的云服务，而进一步的损失之所以得以缓解，全靠我们的裸金属基础设施。

举例来说，有一天早晨，我突然接到警报，显示一个中型医疗办公室中我们的 FreeBSD 基础设施所在服务器出现了大量磁盘 IO。原来，一个在 Windows 文件服务器上设立的全公司共享目录——运行在 ZFS 上的 bhyve 虚拟机中——充满了被勒索软件加密的文件。当我调查这些服务器时，我的团队找到了罪魁祸首所在的工作站，并立即将其关闭，以阻止病毒的蔓延。待一切得到控制后，修复工作仅花了几分钟，包括服务器重启的时间。（详见“恢复”部分的示例命令。）虽然这次修复看似是一场英勇之举，但大多数客户的员工几乎没有察觉到什么问题。这正是我们追求的效果。

不幸的是，其中一台受感染的工作站是一台共享机器，且其中包含了一个我们此前并不知情的“云盘”。一位医疗助理的个人文件在那里被自动同步到了云端，这些文件被永久加密，并附有一张说明如何支付赎金的便条。云端只给了我们两个完全相同的垃圾目录。这无疑提醒了所有相关人员：无论是再多一份同步备份，也永远不会嫌多。

为我们的十几位客户运营自有服务器，意味着我们拥有更高的确定性和灵活性，而且无需依赖庞大的服务器群，就能实现比相同云端工作负载更好的数据丢失防护和巨大的成本节约。我们在美国多个地点使用租赁和自有的运行 FreeBSD 的服务器，承担了客户几乎所有的需求，包括数据库、文件服务器、远程桌面环境以及复杂的专有网络应用。我们的托管环境设计得尽可能简单且易于替换，不依赖于 FreeBSD 基础环境之外的任何组件，即可提供高效且具可靠性的私有云环境。我们采用 jail 来处理 UNIX 工作负载，利用 bhyve 运行商业虚拟机，并将所有内容组织到 ZFS 卷中，使我们的 FreeBSD 主机网络可以开箱即用地调优成一个安全且冗余的云替代方案。下面介绍我们的具体做法。

## 准备我们的基础设施

### 评估何时（不）使用裸金属

在 2010 年代初期，随着客户群的不断增长，我们越来越依赖云服务，并注意到我们及客户的账单每月都在悄然上涨数千美元。几年前，我发现 FreeBSD 基本系统中的虚拟机管理程序 bhyve 能在大多数新硬件上稳定地运行 Windows 及其他各种系统。这样一来，我们几乎可以将所有租户的工作负载都托管在运行 FreeBSD 基本系统的裸金属服务器上——而 FreeBSD 自 90 年代以来一直是我的首选工具。在 bhyve 尚未成熟之前，我们仍大量依赖 FreeBSD jail 主机和云 VPS，而我们的硬件基础设施大多使用 VMware ESXi。然而，随着硬件老化且维护变得越来越繁琐，正是改变的时候。我们评估了通过 FreeBSD 托管方案，加上一些对硬件、数据中心和租赁裸金属服务的投资，能够实现哪些目标。

预期的成本节约立竿见影。我们将 VPS（例如 DigitalOcean Droplet）的三年总拥有成本，与两台等效的租赁或自购裸金属服务器进行了比较。估算显示，租赁方案的成本大约只有云端等量资源的一半，而自有服务器的成本甚至不足纯云方案的四分之一。在这份分析中，我们仔细估算了硬件维护人力成本、电费、网络设备（当然还包括 FreeBSD 路由器）以及所有其他能使我们的基础设施至少达到云端性能的费用；结果毫无悬念。

当然，也仍有一些领域，大型企业能够与我们这样的小型 FreeBSD 团队有效竞争。针对特定行业的法规合规（例如美国政府的 FedRAMP 评估项目）认证成本太高，难以承担。在需要拥有此类认证的数据中心工作负载中，我们不得不依赖那些有能力承担认证费用的大型服务提供商的 VPS。此外，一些共享服务要廉价地构建起来需要非常庞大的基础设施，比如 CDN、邮件传输以及 BIND 辅助服务器（对于这些服务，我们通常分别使用 StackPath、AWS SES 和 EasyDNS）。在我们这样的组织规模下，将它们外包不仅成本更低，而且所需的人力也更少，我们对此十分满意。

### 设计我们的网络

大型公有云提供商的巨大优势在于，能轻松地在多个地点创建和销毁资源，因此我们所有自建的服务也需要成倍冗余并在地理位置上分布开来。例如，我们在靠近我位于纽约的家的数据中心里保留了一整机架以便于操作，同时在电费低廉的德州也设有设备。我们在加州、佛罗里达和德州所使用的数据中心都提供全天候优质的现场支持服务，能够快速租用服务器、为我们提供网络 KVM 访问权限，并在需要时出售备用零件。正因为有了这种灵活性，我们知道即使遇到一些最严重的硬件故障，也能在不必预先拥有所有资源的情况下进行恢复。我已经测试过这些团队，能够在两小时内或更短时间内让一台全新的 FreeBSD 系统投入运行。当然，如果我们真的需要，大型云服务提供商仍然会在关键时刻出手相助。

我们还通过管理 VPN 将所有数据中心、租赁服务器甚至第三方云服务器互联起来。我们最近切换到了 WireGuard 网状网络，它配置简单、易于扩展，并且是 FreeBSD 内核的一个令人欢迎的新成员。为了便于管理，我们将文档数据库与私有 DNS 关联，使主机、jail 和虚拟机的名称均采用可预测且独一无二的格式：`Function##.Client`。这有助于我们使所有管理、监控和卷命名都变得简单直观。我们使用别名（CNAME）和文本（TXT）记录存储额外的信息，以便提取更多数据并供监控和维护自动化程序使用。例如，`Function##.Client` 会解析为 `Function##.Client.Site`，这样我们就可以查找任何实例对应的数据中心代码。

```sh
% host host12.dndrmfln
host12.dndrmfln is an alias for host12.dndrmfln.scr1
host12.dndrmfln.scr1 has address 10.10.10.100
% host fs1.waynecorp
fs1.waynecorp is an alias for fs1.waynecorp.gtm2
fs1.waynecorp.gtm2 has address 10.20.20.200
```

### 主机配置

除了网络对象名称之外，我们还尽可能地使所有主机的硬件和软件配置保持一致，这样在需要更换某台主机时，就可以迅速替换而无需太多担心。常用的严肃比喻是：我们希望服务器更像牛群而非宠物，这样当某台服务器需要退役时，我们也就不会太在意。虽然具体的 CPU 和内存配置会根据工作负载要求有所不同，但总的原则是：对主要工作数据盘采用固态硬盘镜像，对侧重备份的主机使用机械硬盘的 raidz2 来优化性能。我们还保证 zpool 名称绝对唯一，以增加一层额外的安全保障。例如，一台名为 **host12.bts** 的裸金属服务器，其存储池可能分别命名为 **boot12**、**ssd12** 或 **rust12**，分别表示启动盘、固态盘和机械硬盘存储池。这一做法有效防止了在复制粘贴过程中不慎将错误数据集应用于错误服务器的情况。

我们始终使用 FreeBSD 安装器默认的 zpool 根结构，并配备 SSD 启动镜像，同时避免在根存储池上安装客户机。对于数据 zpool，我们会创建几个代表主要主机功能的卷：

* **datapool/jail**：我们的 jail 通常包含一个 FreeBSD 基本系统，但有时也会使用较简单的 chroot 环境或 Linux 基本系统。
* **datapool/vm**：bhyve 虚拟机。每个数据集都包含配置注释和 bhyve 日志，子数据集则包括表示虚拟机虚拟驱动器的 zvol，例如 `datapool/vm/guest.client/c-drive`。这种直观的结构与 vm-bhyve 兼容。
* **datapool/Backup**：这些是备份伙伴的 ZFS 复制，每天至少更新一次。我们还可能包含 rsync 和 rclone 备份（例如来自 Microsoft OneDrive 或其他非 ZFS 环境的备份），并对它们进行快照。
* **datapool/Archive**：如果一个数据集中没有活跃的复制任务，我们会将其移至'Archive'。例如，当某个实例退役后，我们会使用 `zfs rename` 命令将其转移到这里。

其余配置均力求简单且一致，目标是确保所有虚拟机和 jail 都能在备用主机上迅速启动。我们仅在监控、管理、网络及类似 shell 这类不会在软件包故障时显著增加管理复杂度的领域内使用额外软件包。例如，我们有时会使用 vm-bhyve 包来简化 bhyve 和 bhyvectl 命令的执行，但也已制定了在紧急情况下不依赖它们的应急方案。

快速恢复实例过程中最令人头疼的问题之一是网络对象名称的不匹配，这种错误在有无 jail/vm 管理器的情况下都容易发生。例如，我们曾因 bridge0 在一台服务器上连接的是 LAN 接口，而在恢复主机上连接的是 WAN 接口，导致几次恢复中出现问题。起初，我们试图将桥接器和 epair 设备重命名为诸如 lan0 或 vm22a 之类的描述性名称，但随着机群规模的扩大，这种方式显得既繁琐又无效。最终，我们为虚拟网络对象确定了一个固定结构，例如：

* **桥接器名称：**
  * bridge0：LAN
  * bridge1：WAN
  * bridge2：虚拟网络
  * bridge3：客户端 VPN
* **epair 和 tap 设备名称：**\
  我们尽量记录这些名称，但最有效的方法是使用数字来对应它们的最后一个 IPv4 八位数，例如 **epair201** 或 **tap202**。虽然 vm-bhyve、jail jib 命令以及其他工具可以协助管理这些设备，但我们仍然觉得能清楚地知道每个接口对应哪个实例非常有帮助。

当然，如果某个异地备份伙伴使用不同的网络基础设施，或在恢复时需要更改 IP 地址，那么这部分预设就需要做相应的调整和记录。我们会定期进行故障演练和恢复测试，以确保能够达到我们的恢复目标。

### 服务器的耐用性和冗余设计

俗话说：“两个等于一个，一个等于零。”因此，我们的大多数数据始终存储在四台物理机器上。我们对所有数据至少保留两份 ZFS 复制备份，并根据工作负载类型及我们的恢复点目标 (RPO) 和恢复时间目标 (RTO)，提供热备份或温备份。当然，温备份必须具备足够的资源来运行其负责的所有故障切换实例，否则就算不上“温”备了。最后，我们还会将所有数据的额外副本存放在大型传统硬盘（rust bucket）上，并采用更为保守的数据修剪策略。

值得庆幸的是，我们可以信赖 ZFS 复制功能，确保所有数据都能以加密方式备份。然而，除了确保拥有足够的备份副本之外，我们还需要防范攻击从一台服务器蔓延至其他服务器。因此，我们始终使用 `zfs allow` 以受限权限运行自动复制进程。此外，对于存储在 rust bucket 设备上的三级备份，我们采取了极端的安全措施：该设备不允许直接访问互联网或 VPN，必须在办公室本地进行管理。

## 云环境的维护

### 快照

在众多优秀的 ZFS 快照管理工具中，我更偏爱 `zfs-periodic` 这类简单的工具。最近，我们开始模仿 `zfsnap2` 的可读快照命名格式，使我们能从 `zfs list` 直接获取所需的所有信息：`@时间戳--存活时间 (TimeToLive)`。例如，若当前时间的 `zfsnap2` 快照设定为 1 周的保留期，则其命名格式如下：

```sh
TTL=1w
NOW=`date -j +%Y-%m-%d_%H.%M.%S`
SNAPNOW=$NOW--$TTL
```

对我来说，目前的快照名称是 `2022-04-08_16.49.48--1w`，在 `zfs list` 输出中非常易读。

`zfsnap2` 不仅能减少快照管理的输入操作，还具备基于 TTL（存活时间）值的良好自动清理功能。每当我设置新主机时，第一件事就是把这些命令直接写入 root 用户的 crontab。

```sh
VOLS="boot02 rust02/vm rust02/jail"
0 0 * * * echo $VOLS | xargs zfsnap snapshot -ra 1w
10 0 * * 0 echo $VOLS | xargs zfsnap snapshot -ra 1m
20 0 1 * * echo $VOLS | xargs zfsnap snapshot -ra 1y
30 0 1 1 * echo $VOLS | xargs zfsnap snapshot -ra forever
0 1 * * * echo $VOLS | xargs zfsnap destroy -r
```

在此示例中，VOLS 将获取每日、每周、每月和每年的快照，并分别保留一周、一个月、一年和永久。对于我们的 rust-bucket 备份服务器，我们会更谨慎地进行修剪，并降低修剪频率，以确保数据安全。

### 复制（Replication）

由于备份是我们的最后一道防线，我们尽可能遵循最佳安全协议。我们始终使用 **pull 复制**，因为这样可以尽量减少每台服务器的攻击面。例如，我们的专用备份主机完全不对外转发互联网流量。

为了进一步提高安全性，我们从不直接使用 `root` 账户 SSH 访问备份源服务器。我并不反对使用 `root` 进行复制，但由于 `zfs allow` 允许轻松对 ZFS 进行权限分区，我们更愿意利用它来增加一点额外的安全保障。

不过，`zfs receive` 端的权限管理相对麻烦一些。接收端的用户必须拥有所有相关的非默认 ZFS 属性权限，否则复制操作会报错或失败。我们的折中方案是：

1. 第一次执行 `zfs receive` 操作时使用 `root` 运行。
2. 之后的定期备份脚本则使用非特权用户运行。

尽管 FreeBSD 上已经有许多优秀的复制脚本，但我希望彻底了解整个过程，于是最终还是使用了自己编写的脚本。

以下是一个示例，基于我的自制复制脚本，用于备份名为 `drive.client` 的虚拟机（VM）。在负责拉取备份的主机上，我们首先为备份用户设置复制目标 `host12rust/Backups`，并生成 SSH 密钥：

```sh
pw useradd backup -m
su backup -c 'ssh-keygen -N "" -f ~/.ssh/id_rsa'
cat ~backup/.ssh/id_rsa
zfs create -o compression=zstd host10rust/Backups
zfs allow -u backup receive,mount,mountpoint,create,hold host10rust/Backups
```

在源主机上，我们创建相同的备份用户，并授予其发送快照的权限：

```sh
pw useradd backup -m
cat >> ~backup/.ssh/authorized_keys
[PASTE THE KEY HERE]
zfs allow -u backup send,snapshot,hold host05data/jail
zfs allow -u backup send,snapshot,hold host05data/vm
```

我们可以在备份主机上完成剩下的工作。ZFS 发送和接收有很多选项，但我们唯一不能缺少的是 `-L`，以确保获取与源匹配的块大小。我们还喜欢使用 `-c` 选项，以原始压缩格式发送数据流，从而降低带宽占用，但如果需要在目标卷上应用更强的压缩设置，也可以省略该选项。

此外，我们倾向于强制设置 `canmount=noauto`，以避免活动的、重叠的挂载风险。这会在恢复时增加一个挂载步骤，但我认为这是值得的。我们的第一次复制操作由 root 用户执行，看起来像这样：

查找最新的快照：

```sh
REMOTE='ssh -i ~backup/.ssh/id_rsa backup@host05'
SOURCE='host05data/jail/drive.client'
TARGET='host10rust/backups/drive.client'
SOURCESNAP=`eval $REMOTE zfs list -oname -Htsnap -Screation -d1 $SOURCE | head -1`
eval $REMOTE zfs send -cLR $SOURCE | zfs receive -v -u -x atime -o canmount=noauto
$TARGET
```

基于上述内容构建我们的脚本后，我们可以以备份用户身份运行后续的复制操作。

```sh
TARGETSNAP=`zfs list -oname -Htsnap -Screation -d1 $TARGET | head -1`
ssh -i ~backup/.ssh/id_rsa backup@host05 zfs send -LcRI $TARGETSNAP $SOURCESNAP |
zfs receive $TARGET
```

由于日程变动或疏忽，快照难免会偶尔不同步，导致复制失败。为了找出两个数据集之间最新的匹配快照，我们运行一个执行以下操作的脚本：

```sh
zfs list -oname -Htsnap -Screation -d1 $SOURCE | awk -F@ '{print $2}' > /tmp/
source-snap
zfs list -oname -Htsnap -Screation -d1 $TARGET | awk -F@ '{print $2}' | grep -f /
tmp/target-snap
```

我们有更强大的自制 awk 复制脚本，负责处理上述所有任务，包括重试和修复损坏的 `-R` 复制（例如，由于恢复过程中存在不同的子快照），以及监控和报告。报告里还充满了表情符号。

### 使用 ZFS 进行迁移和恢复

如果虚拟接口名称已经与源系统保持一致，我们只需要检查并启动实例即可。以下是我们的检查清单，以确保备份服务器在需要时能够随时接管：

✓ 备份服务器的虚拟网络已准备就绪，能够接管客户端，同时还运行所需的网络管理软件，例如 VPN 或 `dhcpd`。\
✓ 其他客户端配置文件均为最新并可随时使用。对于 jails，我们喜欢使用 `/etc/jail.guest_name.conf` 格式，因此当配置缺失时，通常可以一目了然地发现问题。\
✓ 备份服务器已配置正确的资源和 `sysctl` 设置，例如 Linux 兼容支持。\
✓ 复制任务按照正确的计划运行：每日、每小时或每 15 分钟一次。\
✓ 迁移和恢复流程已经过文档记录和测试，并且团队成员知道如何操作。

如果一切准备就绪并经过测试，恢复或迁移过程将会非常顺利：

* **如果源系统仍然可用**，先关闭它，执行最后一次快照，并进行最终复制。我们为这些紧急时刻编写了一个专门优化的脚本，以实现最快速的复制，同时避免生成中间快照。详见'技巧与窍门'部分。
* **然后，我们复制、移动或克隆快照到生产环境**。对于活跃的客户端，最快捷的方式是直接重命名快照至生产位置，例如：

  ```sh
  zfs rename data1pool/Backup/guest.client data1pool/vm/guest.client
  zfs mount data1pool/vm/guest.client
  ```
* **最后，检查客户端配置并启动实例**。

我们经常使用 ZFS 克隆来恢复数据，同时它也是测试客户端迭代更改的好方法，例如，在正式执行数据库升级前进行测试。如果克隆实例需要投入生产，我们总会尽快执行 `zfs promote`，以避免后续可能出现的依赖问题。在本文开头提到的勒索软件恢复案例中，我们使用了克隆，以确保在恢复快照的时间点之后不会丢失任何正常数据。

```sh
zfs rename data1pool/vm/fs.cli/d-drive data1pool/Backup/fs.cli-d-drive-ransom.
zfs clone data1pool/Recovery/fs.cli-d-drive-ransom@last-night data1pool/vm/fs.cli/
d-drive
[…after hours…]
zfs promote data1pool/vm/fs.cli/d-drive
```

如果旧的主机仍然可访问，我们可以将迁移的数据集重命名到 `pool/Backup` 数据集中，并切换备份过程。

## 小贴士与技巧

### 更快地迁移实例

无论我多么做好准备，总会有一些情况下，我需要在压力下迅速将数据复制到目标主机。在这些特殊情况下，尤其是当使用受信任的交换机或 VPN 时，如果安全允许，我们可以暂时放弃加密的 ssh 隧道，以提高速度。

不幸的是，如果我们使用 `zfs send -R` 来获取子数据集，它将发送所有子快照，这在紧急情况下可能不是最佳选择。（在旧版的 Oracle ZFS 文档中，曾经有 `zfs send -r` 命令，可以仅复制最新的递归快照，但遗憾的是，这个功能尚未被包含在 OpenZFS 中。）

下面是一个快速的一行命令，用于递归列出最新的快照，可以用来进行复制：

```sh
VOL='pool/vm/guest.cli'
zfs list -Hroname $VOL |xargs -n1 -I% sh -c "zfs list -Honame -tsnap -Screation %
| head -1"
```

接下来，我们可以使用上面的输出通过 `nc` 创建网络管道：

```sh
zfs send -Lcp sourcepool10/vm/guest.cli@2022-02-19_00.19.77--1d | nc -Nl 60042
```

然后在目标端，我们连接到上述管道：

```sh
nc -N source 60042 | zfs receive -v localpool/jail/guest.cli
```

然后，重复前两个管道命令，处理任何子卷。在我们的例子中，我们使用了 `nc -N` 来在流完成时关闭套接字，并使用选项 `zfs send -c` 以当前压缩状态发送复制流。我们有时还会在管道中添加一个压缩器，具体如下所述。

### 逃离他人的云（或其他虚拟化平台）

我们使用类似的技术将远程卷复制到镜像文件或 zvol 中。例如，这对于将虚拟机从云服务提供商或其他虚拟化平台迁移到 bhyve 非常有用，一次性完成，而不是花费更多时间和空间分步下载并转换卷。这对于迁移尚未以原始、bhyve 友好格式提供的'云应用'尤为便利。

要开始，我们禁用 cloud-init 以及所有特定提供商的启动脚本（如果使用 cloud-init，可能会有所不同）。虽然我们已经成功地克隆了活动的 VPS，但如果源卷已挂载，可能会导致损坏。更好的选择是，如果云服务提供商允许的话，从 FreeBSD ISO 启动。如果不行，我们会复制源卷并将它们附加到另一个 VPS 上。例如，在 AWS 中，我们可以快照目标卷，将新快照转换为卷，然后将这些卷附加到运行中的主机上。

请注意，示例需要根据操作系统进行修改。例如，Linux 的 dd 命令有略微不同的选项，并且发送的操作系统可能没有 zstd（gzip 和 gzcat 是不错的替代选择）。要小心不要使用过强的压缩级别，否则 CPU 时间可能会成为传输瓶颈。

在源端，可能需要根据操作系统差异和设备名称进行调整：

```sh
dd if=/dev/ada0 bs=1m | zstd - | nc -Nl 60042
```

在我们的目标 FreeBSD 主机上：

```sh
nc -N source 60042 | zstdcat | dd of=ada.img bs=1m status=progress
```

对于使用 UEFI 启动的虚拟机，如果 bhyve 没有自动找到引导加载器文件，我们只需要将引导加载器文件移动到正确的位置。如果虚拟机使用 grub，可能需要稍微更多的手动操作来确保一切正常对齐。以下是我最近将一台 CentOS 虚拟机从大云平台释放出来时使用的 grub-bhyve 命令：

```sh
echo '(hd0) /dev/zvol/ssd11/vm/pbx.bts/vda' > device.map
grub-bhyve -m device.map -M 8G -r hd0,1 -d /grub2 -g grub.cfg pbx3.bts
[usual bhyve commands]
```

## 结论

你的灾难恢复计划来自于你的团队，而不是任何数量的云资源；我们必须雇佣、保留并培训合适的人才。在我看来，我们可以做到两者兼得。将大型云提供商保留用于它们擅长的轻量级扩展解决方案，其他方面则使用 FreeBSD 独立服务器，配合 ZFS、bhyve 和 jail，打造坚如磐石的基础。认真对待你的数据，同时还能节省资金。

***

**DANIEL J. BELL** 是 Bell Tech 的创始人，这是一家在纽约市经营超过 20 年的小型托管服务提供商。他通过结合前沿技术与经过验证的坚固标准，优先考虑隐私、安全和效率。
