gVisor 资源模型深度解析:Sentry 如何按需弹性管理 CPU、内存、线程与文件资源
【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor
导读
本文基于 gVisor 架构指南中的 Resource Model(资源模型) 章节,系统讲解 gVisor 沙箱在进程、网络、文件、线程、时间、内存与资源限制七个维度上的资源管理设计。gVisor 的核心思想是:不预先固定 vCPU 数量或物理内存大小,而是让沙箱的资源形状紧密跟随被沙箱化进程的形状——忙碌时向上扩展占用大量核心与内存,空闲时把资源交还宿主机。读完本文,你将理解 gVisor 为何能做到接近零 CPU 的空闲开销、应用内存为何在 cgroup 中显示为 shmem 而非 anon,以及如何用runsc usage从沙箱内部获取准确的内存明细。
资源模型总览:沙箱形状跟随工作负载形状
gVisor 的资源模型并不假设存在固定数量的执行线程(vCPU)或固定大小的物理内存。凡是涉及底层物理资源的决策,只要可能,都会委托给拥有全局信息的宿主机系统去优化。这种委托机制使沙箱在资源使用上高度动态化:繁忙时横跨大量 CPU 核与大量内存,空闲时则把资源释放回宿主机。
换言之,沙箱的形状应当紧密跟踪被沙箱化进程的形状。下图直观展示了不同形状(不同资源需求形态)的工作负载如何在 gVisor 沙箱之上运行、并最终共享宿主机资源:
gVisor 资源模型示意图:不同形状的工作负载运行在 gVisor 沙箱之上,共享宿主机资源
进程:沙箱在宿主机上是"一个不透明进程"
与虚拟机类似,一个 gVisor 沙箱在系统上表现为一个不透明的进程。沙箱内部的进程不会以宿主机进程的形式显现——在宿主机上运行top(1)是看不到它们的。沙箱内进程层面的交互需要进入沙箱才能进行,例如通过docker exec进入容器执行命令。
这一点与 gVisor 的 Sentry 实现直接对应:Sentry 维护自己的 PID 表来表示沙箱内的进程(见 pkg/sentry/kernel 中的进程管理实现),这些并非真实的宿主机进程,沙箱内进程调用getpid(2)时,Sentry 直接用自己的 PID 表回答,不触发任何宿主机系统调用。
网络:自有协议栈,资源全部限定在沙箱内
沙箱会向系统挂接一个网络端点(endpoint),但网络协议栈是 gVisor 自己实现的(位于 pkg/tcpip,一个 Go 语言从零实现的网络栈)。除宿主机上"在途"(in flight)的数据包之外,所有网络资源只存在于沙箱内部,并受相应资源限制约束。
你可以像操作普通容器一样与沙箱暴露的网络端点交互,但网络内部的探查(introspection)同样需要进入沙箱才能进行。
文件:Gofer 通过 SCM_RIGHTS 传递文件描述符
沙箱内的文件可能由不同的后端实现支撑。对于宿主机原生文件(已有可用文件描述符的情况),Gofer(一个略高权限的伴生进程,负责代表 Sentry 访问宿主机文件系统)可以通过SCM_RIGHTS将文件描述符传递给 Sentry。
这一机制在源码中有清晰体现:
- pkg/sentry/socket/control/control.go 中实现了
SCMRights类型与NewSCMRights、PackRights等函数,专门处理通过 Unix socket 控制消息传递文件描述符的逻辑; - pkg/sentry/control/fs.go 中的
FilePayload注释也明确指出其包含通过SCM_RIGHTS发送的文件描述符(例如传给 Gofer 的 socket FD)。
脚注补充:除非启用了宿主网络(host networking),Sentry 自身无法创建或打开宿主机文件描述符,它只能以这种方式从 Gofer 接收文件描述符。
这些文件可以通过标准系统调用读写,也可以映射进关联应用程序的地址空间。这意味着同一份宿主内存可以被多个沙箱共享(例如多个沙箱映射同一文件页)。需要说明的是,这种机制并不排除侧信道攻击的可能,相关讨论见 Security Model(安全模型)。
另外,有些文件系统只存在于沙箱上下文中。例如在很多情况下,tmpfs挂载会出现在/tmp或/dev/shm,它直接从沙箱内存文件(见下文"内存"章节的 memfd)中分配内存。这类内存最终会以与宿主机原生文件类似的方式计入相关限制。
线程:goroutine 即绿色线程,宿主线程按需创建
Sentry 用goroutine来为每一个任务线程(task thread)建模。因此:
- 每个任务线程都是一个轻量级绿色线程(green thread),不一定对应底层的一个宿主线程;
- 但是,应用程序的执行被建模为对 Sentry 的一次阻塞式系统调用。这意味着宿主机可能会创建额外的线程——取决于活跃应用线程的数量。
在实际运行中,一个繁忙的应用会趋近于"活跃线程数 = 宿主线程数"的状态,此时宿主机能够对所有应用线程做出合理的调度决策。这种设计让宿主机调度器得以介入沙箱内的线程调度,实现了"沙箱线程随应用负载伸缩"的动态性。
时间:Sentry 自持时钟,空闲时进入"无 tick"模式
沙箱内的时间由 Sentry 通过自己的 vDSO 与时间保持(time-keeping)实现来提供,这与宿主机时间截然不同,且不与宿主机共享任何状态(尽管初始时会用宿主机时钟校准)。
- 与运行在硬件上的内核类似,Sentry 会运行定时器来感知时间流逝——只不过这里是软件定时器。这些定时器负责:更新 vDSO、提供系统调用返回的时间、记录用于用量/限额跟踪的时间(例如
RLIMIT_CPU)。 - 当所有应用线程都空闲时,Sentry 会禁用定时器,直到某个事件唤醒 Sentry 或某个应用线程为止——这与"无 tick 内核"(tickless kernel)类似。这使得空闲应用可以达到接近零的 CPU 占用。
gVisor 的 vDSO 实现位于 vdso 目录(如 vdso_time.cc、cycle_clock.h),通过共享映射向应用提供无系统调用的时间读取路径。
内存:单一 memfd 支撑全部应用内存
Sentry 实现了自己的内存管理,包括:
- 按需分页(demand-paging);
- 针对无法原生使用的文件,维护Sentry 内部页缓存。
关键设计是:一个单独的 memfd 支撑全部应用内存。memfd_create(2)的封装位于 pkg/memutil/memfd_linux_unsafe.go,其CreateMemFD函数在实现中明确要求 Linux 3.17 及以上版本(否则报错 "memfd_create(2) is not implemented")。Sentry 内核启动时会创建该 memfd(见 pkg/sentry/kernel/kernel.go 中的相关调用),所有应用内存页都挂在这一个文件上。
地址空间:平台相关,必要时创建 stub 进程
地址空间的创建是平台相关的(例如 Systrap 平台与 KVM 平台的行为不同)。对某些平台,宿主机上可能会创建额外的"stub 进程"来支持额外的地址空间。这些 stub 进程同样受沙箱级别的各种限制约束(例如 PID 限制)。
物理内存:交给宿主机管理,Sentry 启发式批量映射
宿主机可以使用常规手段管理物理内存(例如跟踪工作集、在压力下回收与交换)。Sentry惰性地为应用填充宿主映射,并允许宿主机对这些区域按需分页——这对上述机制的正常运转至关重要。
为避免过高开销,Sentry不会逐页按需分页,而是基于启发式算法选择合适的内存区域批量处理。这带来一个权衡:
- Sentry 无法轻易判断哪些页是活跃的、哪些不是;
- 即使逐页触发 fault,宿主机也可能在 Sentry 不知情的情况下回收或交换某些页。
因此,沙箱内的内存使用统计(例如通过 proc 查看)只是近似值。Sentry 内部维护了内存使用的细分账目,可以收集准确信息,但只能通过一个相对昂贵的 API 调用实现;而且从安全角度讲,把宿主机管理内存的精确信息暴露给沙箱也被认为是不明智的。
madvise:释放内存归还宿主
当应用标记某段内存不再需要时(例如调用madvise),Sentry 会立即把这块内存释放回宿主机。这存在性能代价——在很多情况下,保留内存并用它满足其他请求反而更便宜。但立即释放让宿主机能更有效地复用资源、执行全局性的高效策略,这与"资源决策交给宿主机"的整体模型一脉相承。
资源限制与 cgroup:为什么 memory.stat 里 anon 很低?
所有 Sentry 线程和 Sentry 内存都受容器 cgroup约束。但这里有一个关键点:应用的内存使用不会表现为匿名内存(anon)用量,而是被计入那个memfd。也就是说:
- 所有匿名内存(anon)对应的是Sentry 自身的用量;
- 应用内存以
memfd形式存在,宿主机对容器收取的内存费用按常规方式工作(即计为 file-backed/shmem)。
这带来一个容易困惑的后果:读取 gVisor 沙箱 cgroup 的memory.stat(或memory.current)时,由于应用内存由 memfd 支撑,内核会将其计为shmem而不是anon。因此:
- 即使容器运行着内存饥渴的应用,
memory.stat中的anon字段也会保持很低; - 而
shmem(或file,视内核版本而定)则反映应用真实内存用量的绝大部分。
如果你需要沙箱内部应用内存用量的明细,有两个途径:
- 查看
memory.current获取总量; - 使用Sentry 自身的记账:
runsc usage <container id>。
runsc usage 命令实操
runsc usage是 runsc 提供的专门命令,其实现位于 runsc/cmd/usage.go。基本用法:
# 打印内存用量(按类别,以字节为单位) runsc usage <container id> # 枚举所有类别的完整用量明细 runsc usage --full <container id> # 通过既有的 usage FD 获取用量的子集 runsc usage --fd <container id>从源码看,其输出通过 JSON 编码器美化后打印(usage子命令的 Synopsis 为 "Usage shows application memory usage across various categories in bytes."),--full标志用于枚举所有类别的用量,--fd则通过容器沙箱的 usage FD(cont.Sandbox.UsageFD())快速抓取 Mapped/Unknown/Total 三个数值。
cgroup 可以按常规方式监控标准信号:压力指示(pressure indicators)、阈值通知(threshold notifiers)等,也可以动态调整。值得注意的实现细节是:Sentry 自身可能会监听所在 cgroup 的压力信号,以便清理内部缓存(即内存压力驱动的缓存回收机制,从源码结构看这一行为与 pkg/sentry/kernel 中的内存管理逻辑相关)。
延伸阅读
- 资源模型背后的安全设计动机,参见 Security Model(安全模型) 与 Introduction to gVisor security;
- 系统调用与缺页拦截的平台实现(Systrap / KVM),参见 Platforms(平台);
- gVisor 的网络栈(沙箱自有协议栈)源码位于 pkg/tcpip,Gofer 与文件系统后端相关逻辑可查阅 pkg/sentry/fsimpl 与 runsc/fsgofer;
runsc usage等命令的完整命令集可参考 runsc/cmd 目录与 runsc 用户指南。
【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考