将多租户 SaaS 拆解并塞入物理隔离环境
将云 SaaS 作为单个本地设备交付,不是部署方式的改变。这意味着要在正确的位置移除租户、信用点、托管认证以及所有云依赖。
客户希望将你的多租户媒体 SaaS 产品以单机设备的形式部署在他们自己的网络上,无需连接互联网,无需按使用量计费,并为他们的员工提供无限的处理能力。这听起来像个打包工作。但事实并非如此。该产品在每一层都预设了租户、点数、托管登录和云控制平面,而这些预设中的每一个都必须从其所在的确切位置剥离出来。漏掉任何一个,该设备要么会泄露云调用,要么会无法满足其销售时所承诺的“无限使用”要求。
陷阱:隐藏 UI 并不等于移除限制
需求是无限处理能力。第一反应是隐藏升级页面和点数计量器。这恰恰是错误的。点数检查并非只存在于一处。它在三个地方运行:
- 前端在余额为零时隐藏操作。
- API 会拒绝将超出余额的请求。
- 工作进程在作业完成时扣减账本。
在演示中只能看到第一项,而它也是唯一不具权威性的。API 的所有其他客户端,如管理控制台、旧的移动端构建版本、从队列中取出的重试任务,都会访问相同的端点,而无需加载你修补过的页面。
所以限制无论如何都会暴露出来。一个耗时长的作业在余额尚为正数时启动,一小时后,工作进程将余额扣减至负数,于是下一个请求返回一个关于购买点数的“需要付款”错误,而这发生在一台没有账单页面也无法访问互联网的设备上。接着,夜间的对账作业会发现负数余额并暂停该账户。
交付“无限”功能意味着要同时移除所有三个层面的检查:没有 UI 门控,没有 API 拒绝,并且账本扣减变成一个空操作。有两个细节决定了这是否能成功。将计量与强制执行分离开来,而不是将两者都删除,因为客户仍然想知道他们的团队上个月处理了多少分钟的数据。并且将开关放在配置中,而不是放在一个分支 (fork) 中:一个删除了检查的分支在一个发布周期内就会与云构建版本产生差异,而直到客户升级时,才有人会发现这个差异。
每个云依赖项都需要一个本地替代品
物理隔离意味着设备不会发出任何未经您明确允许的调用。该 SaaS 在启动时和几乎每次请求时都会连接云服务,而其中每一个都变成了一个本地替代品:
具体的替换如下:
- 配置在启动时来自云参数存储。替换为由安装程序写入的、带有初始值的本地
.env文件。 - 签名媒体 URL 由托管的边缘工作器生成。替换为验证路径上 HMAC 的
nginx反向代理。 - 登录是云单点登录。替换为由管理员预先创建的本地 ID 和密码账户,没有自助注册功能。
- 对象存储是云存储桶。替换为本地 S3 兼容服务器,复用相同的客户端,但使用不同的端点。
- 日志和指标发送到托管后端。替换为本地磁盘上的文件,达到大小上限时进行轮替。
每一项替换都隐藏了一个你之前免费获得的功能特性。参数存储是具有热重载和变更历史的单一事实来源,因此使用磁盘文件意味着每次配置变更都需要重启,并且需要在启动日志中打印配置版本。存储保留了相同的客户端,只更改了端点,这看起来是免费的,但云存储桶永远不会满,而磁盘会。这次替换让你欠下了一个数据保留作业、一个在产品内部呈现的磁盘警告(因为现场没人会看仪表盘),以及一个针对卷满时的明确行为定义。
单点登录之前提供了密码策略、MFA、会话吊销和审计跟踪,而本地账户意味着要自己实现这四项功能。它还移除了电子邮件功能:密码重置变成了管理员在设备控制台上的操作,并且安装程序会创建一个初始管理员,客户必须在首次登录时更改其凭据。
出站调用获得了一个只包含两项的允许列表:翻译 API 和文本转语音供应商。其他一切都是设备上的本地进程。向网络团队提供主机名和端口而不是 IP,因为供应商的地址会变动,并让这两个调用在失败时能快速失败并显示可见的错误。一个锁定的网络会静默地丢弃意外连接,而挂起会被解读为产品损坏,而不是防火墙规则问题。
拒绝启动的环境门禁
该服务有一项启动检查,拒绝在其已知的云环境之外运行,这是一项防护措施,可防止有人意外启动生产配置。在一台物理隔离的机器上,该防护措施每次启动时都会触发。修复方法是增加一种新的环境模式,即 "on-prem" 模式,配置验证器会接受这种模式,这样同一个二进制文件就可以针对本地基础设施启动,而无需执行它永远无法满足的云环境检查。这样做比删除防护措施更清晰,因为该防护措施仍然保护着云构建版本。
拒绝启动才是关键所在。一个半配置状态下启动的应用会在稍后发生故障,故障发生在你无法访问的网络上,以及无法读取你日志的客户面前。因某个具名的配置缺失而在启动时停止,则会在安装期间就失败,而此时工程师就在现场。因此 "on-prem" 模式增加了自己的断言,而不仅仅是跳过云环境的断言:存储端点可达、数据目录可写、许可证文件存在、密钥非空。
代价是增加了第三条代码路径,而没有人针对其开发的模式就是会腐化的模式。一个为每种模式都运行一次验证器的表驱动测试,是一种廉价的防御措施:一个被添加的必需配置项,若没有为其提供 on-prem 模式下的值,就会破坏构建,而不是破坏应用本身。
在相信自己的架构图之前,先阅读代码
迁移工作的一部分是从每个服务中移除一个云配置客户端。有一个 worker 被列入清单,因为“它在运行时从云端读取配置”。但它并没有。阅读代码后发现,它一直读取的都是一个普通的本地文件,而云参数存储只出现在其部署脚本中,从未出现在运行进程中。那个 worker 不需要任何代码更改,只需要一个不同的种子文件。
反过来的错误也同样存在。一个被所有人都当作本地处理的媒体步骤,结果发现在首次使用时会从一个存储桶中获取资源包。它通过了所有的冒烟测试,因为那些机器在几个月前就缓存了资源包,而在一个全新的机器上,它在第一天就失败了。
有效的方法是双管齐下。阅读代码,寻找你能叫出名字的调用:SDK imports、client constructors、env variable names、配置中的 hostname literals。然后让网络来给出答案。在一条默认拒绝的出站规则后启动机器,并记录被阻止的连接和 DNS 查询,运行一遍验收测试,然后读取拒绝日志。阅读代码能找到你怀疑的依赖项,而防火墙能找到你没想到的依赖项。
“物理隔离”的真正代价
客户获得了隔离性。其代价体现在四个方面。
更新不再是连续的。发布版本变成了一个由专人带入的、经过签名的离线捆绑包,客户运行的版本会落后好几个版本,而且数据迁移必须是可恢复的,因为没人盯着迁移过程,也没有回滚按钮。
授权必须能离线工作。许可证变成一个签名文件,它会根据客户控制的时钟进行检查,所以在交付前就要决定好过期的行为。拒绝在运行生产工作流的机器上启动,只会换来一个支持事件;而过期后变为只读,则能在不中断业务的情况下保持压力。
技术支持失去了它的工具。没有上报的日志,没有指标,通常也没有 shell,所以每个工单都始于一通电话,请求某人朗读屏幕上的内容,除非你提供一个捆绑命令,能将日志、脱敏后的配置、版本信息和磁盘状态打包到一个文件中。
调试失去了速度。复现问题需要在实验室设备上安装客户的确切版本,而修复程序则要搭着下一个捆绑包,在维护窗口期才能部署。
将 SaaS 产品转变为一体机产品主要是在做减法,且需小心谨慎:
- 在前端、API 和工作进程中一并移除按租户和按使用量计费的限制,否则“无限制”的承诺会在演示无法触及的地方失效。
- 为每个云依赖项提供一个具名的本地替代品,并对出站调用设置一个严格的白名单,这样被遗忘的依赖项就会明确地失败,而不是悄悄地向外部发送请求。
- 添加一个环境模式,而不是删除那些保护云构建的防护措施。
- 对照源码验证每个依赖项,因为那些看起来有风险的依赖项通常只在部署时使用,而真正的风险隐藏在请求路径中。
关于离线设备的常见问题
设备应该是独立分支还是同一代码库?
同一代码库,带有一个环境模式和一个配置标志。一个删除了检查的分支在某个版本中看起来很整洁,但随后会产生差异,而这些差异会在客户升级期间浮现。代价是 CI 也必须在 on-prem 模式下运行配置验证器。
如果客户允许部分出站访问怎么办?
那么允许列表就是协商的结果,而不是硬性约束。保持列表尽可能简短且有理有据,指明主机名和端口,并将每个允许的调用都视为在下个季度可能被撤销。基于某个允许的调用构建的功能应该能优雅降级并给出明确信息,绝不能挂起。
如何修复无法访问的设备上的 bug?
支持包进来,补丁包出去。在每个受支持的版本上保留一个实验室设备,这样复现问题就不依赖于客户,并使迁移可恢复,以便在客户自己的时间窗口内应用修复。这个循环以天为单位计算,这就是为什么设备版本比云版本需要更高的质量标准。