博客
每个租户是否都应该拥有自己的虚拟机?
通过基于风险的决策框架以及让每种方案都经得起推敲的加固步骤,在每租户容器、虚拟机和混合部署之间做出选择。
摘要
多租户托管迫使你决定租户之间能相互触及到何种程度。容器利用 Linux 命名空间和 cgroups 来隔离进程和资源,但它们共享宿主内核。虚拟机增加了硬件级边界,但代价是速度和运维负担。混合方式——虚拟机内的容器——可以兼得两者,但同时也会让需要修补的暴露面翻倍。本文将引导你完成基于风险的决策、并排对比,以及即使在虚拟机内部也同样重要的 Docker 加固步骤。到最后,你会知道哪种隔离模型适合你的租户,以及上线前需要配置什么。
你的多租户应用快准备好了。你有一个 Docker Compose 文件,可以为每个客户快速启动一套堆栈。这时,一位经营托管公司的朋友问你:“你打算给每个租户一个单独的虚拟机吗?”你愣住了。你没想到这个问题。本文为你提供了一种今天就能回答它的方法,无需安全团队。你独自完成这一切,所以这个决定必须简单到能在凌晨两点为之辩护。
别再试图寻找“最佳”模型。首先写下如果租户的代码接管了你的宿主机会发生什么。在挑选任何工具之前,先定义爆炸半径。这个练习比任何基准测试都更有说服力。
内核是你无法赶走的室友
容器之所以高效,是因为它们共享宿主内核。这种共享既是全部诀窍,也是全部风险。Linux 命名空间为每个容器提供了自己的进程、网络和文件系统视图。控制组(cgroups)让你可以限制 CPU、内存和磁盘 I/O,使一个租户无法饿死其他租户。但两者都不会创建硬件墙。
把容器想象成一个拥有非常逼真假身份证的进程。它以为自己在一台独立的机器上。然而,内核是运行在你宿主机上的一份 Linux。如果租户利用内核漏洞,命名空间就只是元数据而已。能够调用内核函数的攻击者可以触及同一内核上的其他命名空间。这就是你不断听到的容器逃逸。
假设你托管一个小型 B2B 工具,每个客户端一个容器。某个客户端安装了一个带有远程代码执行漏洞的不靠谱插件。在默认 Docker 设置下,该进程在容器内以 root 身份运行。容器中的 root 仍然是 UID 0,内核不会将该 UID 与宿主机 root 区分开来,除非你显式映射用户。攻击者可以尝试逃逸,而共享内核就是他们的目标。
故障不一定非要戏剧化。一个租户的内存泄漏就可能将宿主机推入交换分区,拖慢所有其他租户。如果没有 cgroup 限制,一个行为异常的循环就是一次可用性攻击。有了它们,就只是一个被阻止的进程和一条警报。
这是否意味着容器不安全?不。这意味着你必须把内核视为共享信任区。在做出选择之前,写一段风险声明:“如果租户的容器被攻破,攻击者可以访问:[列表]。业务成本将是:[金额或影响]。”如果这段话让你害怕,你不是偏执,而是诚实。
要更深入地了解隔离谱系,从共享容器到完全独立的堆栈,请参阅我们关于设计多租户 Docker 架构的指南。
三种划分方式(部署前选一种)
多租户隔离实际上有三种架构。每一种“最佳实践”都是这些架构的组合。
| 方法 | 隔离屏障 | 最佳适用场景 | 最棘手的注意事项 |
|---|---|---|---|
| 每租户容器 | 内核命名空间 + cgroups | 许多小型租户,单租户风险低,需要密度 | 一个内核漏洞可破坏该宿主机上的所有租户 |
| 每租户一个虚拟机 | 虚拟机监控程序/硬件虚拟化 | 受监管数据、敌对租户、单租户价值高 | 更重、供应更慢,你需为每个租户修补操作系统 |
| 虚拟机内的容器 | 围绕容器化工作负载的虚拟机边界 | 密度加上各组之间的硬壳 | 成本和运维开销几乎翻倍 |
每租户容器。 这是大多数 SaaS 创始人的默认选择。每个租户都有自己的容器或小型 Compose 堆栈。供应即时、镜像小、CI/CD 简单。资源限制可防止吵闹的邻居吃光服务器。代价是共享内核。如果你能保持工作负载非特权并定期修补宿主机,这通常是正确的第一步。
不要把两个租户放在同一个容器里。那是共享内核加共享运行时再加共享文件系统。如果一个租户上传了一个创建进程的文件,另一个租户已经在同一个进程表里了。容器是你的隔离单元;确保一个容器一个租户。
数据库怎么办?如果每个租户使用相同的凭据连接到同一个 MongoDB 或 PostgreSQL 实例,你就已经添加了一个巨大的共享组件。给每个租户单独的凭据,理想情况下还有单独的数据库或 schema。容器隔离应用;数据库往往是攻击者首先测试的泄漏点。
每租户一个虚拟机。 给每个租户一个完整的虚拟机。虚拟机监控程序增加了硬件级边界,这正是内核漏洞要到达宿主机所必须跨越的。这对受监管环境或不可信租户很重要。代价是密度和时间。你现在要管理一整批操作系统,而不仅仅是容器。每个虚拟机都需要更新、安全代理和监控。对于独立创始人来说,这是实实在在的工作。
在这一级别有效的模式:使用基础设施即代码从相同的基线镜像创建虚拟机,将更新烘焙到新镜像中,而不是修补在线系统,并终止你不认识的工作负载。保持虚拟机的管理端口对互联网关闭。
虚拟机内的容器。 这种混合方式在初学者教程中很少讨论。你在每个租户(或小组租户)周围放置一个小型虚拟机,然后在该虚拟机内运行容器。虚拟机是爆炸半径容器;容器只是可部署单元。这为你提供了虚拟化的硬边界和镜像的可重现性。它成本更高,因为你要为虚拟化开销和容器灵活性付费,但当你不能完全信任租户时,它可能是最理智的长期模式。
一个常见的微示例:租户运行一个 Node API 和一个后台 worker。不要用一个同时运行两个进程的巨型容器,而是使用一个虚拟机,然后两个具有不同资源限制、共享网络且 worker 不直接暴露到互联网的容器。虚拟机提供硬边界;容器提供结构。
你应该选择哪个?表格就是你的候选清单。接下来的部分会让这个决定变得具体。
如果你选择容器,就做这六件事,否则别做
如果你将每个容器都视为潜在攻击者,每租户容器就没问题。这始于配置,而不是一厢情愿。
0. 在信任任何人之前先限制资源。 cgroups 是公平机制和可用性防御。为每个容器设置 --memory 和 --cpus。内存泄漏的租户应该撞上自己的限制,而不是你的服务器。这不是安全边界,但吵闹的邻居就是一次不需要一行代码的攻击。一个实用的起点:--memory 512m --cpus 0.5。对于 worker 进程,从更低的配置开始,然后逐步扩大。
1. 以非 root 用户运行。 永远不要让容器进程使用 UID 0,除非绝对必要。在 Dockerfile 中设置一个用户,并传递 --user 作为额外防护。以非特权用户运行的漏洞利用通向内核的路径要少得多。在你的 Dockerfile 中创建用户:RUN useradd -u 10001 app 和 USER app。不要为了省时间而跳过这一步。
2. 丢弃所有不需要的 capability。 Linux capabilities 将 root 的权力分成小块。大多数 Web 应用几乎不需要任何 capability。从 --cap-drop=ALL 开始,只加回你确定需要的内容。没有 CAP_SYS_ADMIN 的容器更难用于命名空间技巧。如果你的应用试图绑定特权端口,让它运行在高端口上并在前面放一个代理,而不是授予 NET_BIND_SERVICE。
3. 让文件系统只读。 你的应用不应该写入自己的容器层。挂载 tmpfs 来处理状态。无法写入磁盘的攻击者要植入持久化难得多。当根文件系统只读时,被攻破的 PHP 应用尝试写入 webshell 会失败。你可以为应用真正需要的可写目录挂载命名卷。
4. 应用 seccomp 和 AppArmor 或 SELinux。 这些会将危险的系统调用丢弃。Docker 自带默认的 seccomp 配置文件;使用它。添加一个 AppArmor 配置文件作为另一层。你不需要精通每个系统调用。你只需要拒绝普通 Web worker 从不需要的调用。永远不要使用 --privileged 运行。该标志会禁用你刚刚设置的几乎所有防御。
5. 隔离网络。 不要让每个容器都能访问其他所有容器。默认拒绝,然后只打开你需要的端口。被攻破的数据库容器不应该能够扫描你的管理面板。如果租户位于独立的网络中,一个网络的入侵就无法横向扩散。
一个实用的起点:
docker run --user 10001 --cap-drop=ALL --security-opt no-new-privileges --read-only --tmpfs /tmp:rw,size=64M --security-opt seccomp=default.json --memory 512m --cpus 0.5 myimage
将相同的标志放入 Compose 文件,并应用于每个租户。这并不完整,但比 docker run 开箱即用的默认设置强大得多。
如需更深入的演练,请使用我们为多租户托管中的 Docker 容器提供的逐步加固指南。
Docker 的增强容器隔离是你应该了解的例外
如果你在托管 Docker 环境中运行,请寻找 Docker 的增强容器隔离(ECI)。它在底层使用用户命名空间隔离和安全容器运行时。容器内的 root 映射到宿主机上的非特权用户,因此即使容器以 root 身份运行,也不会获得宿主机 root 权限。它还会默认阻止危险的 capabilities 和系统调用。这不是你在普通 Docker 上通过几个标志就能重现的。如果你的平台支持,就开启它。它不会消除对非 root 用户和资源限制的需求,但会改变风险计算。
你可以通过 Docker 守护进程中的用户命名空间重映射(userns-remap)来近似实现其中的一部分。这不如安全运行时完整,但总比没有好。如果使用它,在信任之前验证 UID 映射是否有效。
虚拟机谬误:迁移到虚拟机并非加固
这是反直觉的部分,也是大多数人跳过的部分。如果你迁移到每租户一个虚拟机,然后在其中部署你普通的容器,你并没有消除容器安全问题。你只是加了一个更宽的笼子。容器逃逸仍然有效;攻击者只是落在了虚拟机中,而不是宿主机上。这确实是改进,但你仍然需要那六个步骤。
另一个陷阱是假设虚拟机本身是安全的。带有弱 SSH 密码、未修补的基础包或开放管理端口的默认镜像就是一份大礼。虚拟机监控程序边界只有在客户机经过加固和更新时才重要。否则,你的“安全虚拟机”会更快被攻破,因为你感到安全而不再检查。
虚拟机带给你的,是可缩减的爆炸半径。一个租户的灾难只留在一个虚拟机内。它消耗的则是你的时间。你会成为与租户数量一样多的操作系统的管理员。如果你是独立创始人,正在发布产品,问问自己是否有时间修补和监控一整批系统。如果有,每租户一个虚拟机可能是正确的选择。如果没有,经过强加固的容器可能更实在。
还要记住,你的虚拟机监控程序宿主是关键目标。被攻破的虚拟机监控程序可以看到所有客户机。修补宿主机,而不仅仅是客户机。虚拟机并不能让你免于宿主机修补;它反而提高了错过修补的代价。
关于混合方式的一个警告:不要以为虚拟机内的容器就能免费提供“两层安全性”。虚拟机增加了一个边界;容器仍然需要非 root、capabilities 和 seccomp。否则,第一层的强度只取决于最弱的容器。
十分钟内解决争论的四个问题
不要抽象地优化。按顺序问自己这四个问题。把答案写下来。
1. 我的租户能访问什么? 如果租户只能访问自己的 Web 应用和数据库,那么具有严格网络规则的每租户容器是站得住脚的。如果租户的数据受监管或涉及财务敏感,就要转向虚拟机。
2. 一个租户被攻破会让我损失多少? 把流失的客户、法律风险和信任加起来。如果这个数字大于运行虚拟机的成本,那就花钱。如果不是,容器是理性的选择。
3. 我有多少租户,他们付多少钱? 许多小订阅者:容器密度很重要。少数大客户:给他们每人一个虚拟机并相应计费。付你不到一杯咖啡钱的租户,不应该每个都需要一个操作系统来管理。
4. 我能按计划修补吗? 容器共享一个宿主内核,所以修补宿主机可以保护所有人。虚拟机则成倍增加了修补目标。如果你知道自己会跳过更新,就选择活动部件更少、默认设置更严格的架构。
你的答案会形成分组。两个或更多偏向虚拟机的答案意味着你不应该默认使用每租户容器。三个或更多偏向容器的答案意味着使用虚拟机还为时过早。一个反直觉的结果:收入低但能访问敏感数据的租户仍然需要虚拟机,因为监管成本与他们付多少钱无关。
交付你能信任的最小规模,再争取更多隔离
你的第一个架构不必是最终架构。从你实际能维护的最严格配置开始,然后随着租户基础的需求逐步增加隔离。对大多数独立运营者来说,这意味着每租户容器配以非 root、受限 capabilities、只读文件系统、seccomp 和网络隔离。对于受监管或高价值租户,直接跳到每租户一个虚拟机,容器仅作为内部的打包层。
无论你选择什么,把决定写下来并每季度重新审视一次。当你第一次被问到“我们是否应该把这个租户迁移到虚拟机?”时,你会有一个答案,而且你有清单来支持它。这就是隔离的真正含义:一种你管理的权衡,而不是你购买的技术。
上线前,请过一遍我们实用的 Docker 隔离安全清单——它将这些决定变成一份清单,让你在向客户展示页面之前可以逐项核验。

