跳到主要内容

运行时后端:Docker 与 Kubernetes、池化、快照、暂停恢复

30 秒导读: 02 控制面 讲清了「一次 create 请求怎么走到后端」;本章接着讲后端自己怎么把容器造出来、管起来。OpenSandbox 把「造容器」抽象成一个契约,背后挂两个可插拔实现:Docker(单机,直接调 daemon)和 Kubernetes(集群,提交 CRD 交给 operator)。同一份 create 请求,在 Docker 上变成一次 create_container,在 K8s 上变成一个 BatchSandbox 对象。之后三个高级能力——预热池、快照、暂停/恢复——两个后端语义一致,但实现天差地别。


1. 这章在整幅拼图里的位置(先对齐坐标)

先用一句话锚定边界,避免和相邻章重叠:

  • 01 协议与生命周期 定义了「沙箱」这个抽象对象、它的状态机(Creating→Running→Paused→…)、以及对外协议。
  • 02 控制面 讲一次 POST /sandboxes 如何被鉴权、校验、路由,最后分派到某个运行时后端。
  • 本章(03) 只讲后端如何真正造/管容器:镜像、端口、卷、网络命名空间、Pod 模板、池、快照、暂停。
  • 04 execd 数据面 讲容器跑起来之后,里面那个 execd 守护进程怎么执行命令、传文件、开 PTY。
  • 05 嵌套隔离 讲容器内再用 bubblewrap 套一层。
  • 06 网络与保险箱 讲入站路由、出站策略、凭证注入的网络组件本身

一句话:本章是「造容器的车间」,不是「容器里干活的工人」,也不是「车间外的管网」。


2. 顶层全景:一套契约,两个后端

2.1 为什么要「可插拔后端」

OpenSandbox 想让同一个 SDK / 同一套 API,既能在开发者笔记本上(一个 Docker daemon)跑,也能在生产集群里(成百上千节点的 K8s)跑。做法是经典的策略模式:控制面只依赖一个抽象接口,具体用哪个后端由配置 runtime.type 决定。

两个后端的根本差异,一句话概括:

维度Docker 后端Kubernetes 后端
造容器的方式命令式:直接调 daemon 的 create_container / start声明式:向 API Server 提交一个 CRD 对象,operator 异步把它变成 Pod
谁真正干活服务进程自己(同步)集群里的 operator + kube-scheduler(异步)
状态从哪读docker inspect 容器读 CRD 的 .status,必要时再看 Pod
天然支持单机、快多节点调度、池、签名路由、secureAccess
快照实现docker commit 内联派发一个 Job 跑 nerdctl commit
暂停实现冻结进程(freezer)快照 rootfs + 删 Pod + 重建

2.2 一张图看清分派

下面这张图从上往下读:控制面拿到规范化后的请求,按 runtime.type 选一条路;两条路最终都产出「一个跑着 execd 的容器/Pod」。

┌───────────────────────────────┐
│ 控制面(见 02):鉴权→校验→ │
│ 快照恢复→选后端(runtime.type)│
└───────────────┬───────────────┘
runtime.type=docker │ runtime.type=kubernetes
┌─────────────────────┴─────────────────────┐
▼ ▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ DockerSandboxService │ │ KubernetesSandboxService │
│ (一堆 Mixin 拼成) │ │ → WorkloadProvider(工厂选) │
├────────────────────────┤ ├──────────────────────────────┤
│ ① 拉镜像/校验平台 │ │ ① 组装 initContainer(装execd)│
│ ② 造 host_config(安全/ │ │ ② 组装 main container │
│ 资源/GPU/runtime) │ │ ③ 合并 YAML 模板 │
│ ③ create_container │ │ ④ create BatchSandbox CRD │
│ ④ 注入 execd(put_archive)│ └───────────────┬──────────────┘
│ ⑤ container.start() │ │ 声明式,交给集群
└───────────┬─────────────┘ ▼
│ 命令式,立刻生效 ┌────────────────────────────────────┐
▼ │ operator(Go):Reconcile │
┌────────────────────────┐ │ · batchsandbox_controller 造 Pod │
│ Docker 容器 │ │ · pool_controller 管预热池 │
│ 跑着 execd + 用户进程 │ │ · sandboxsnapshot_controller 派 Job │
└────────────────────────┘ │ · kube-scheduler 落到某节点 │
└──────────────────┬─────────────────┘

┌────────────────────────┐
│ Pod:initContainer 装 │
│ execd → main 容器跑起来 │
└────────────────────────┘

部件一句话职责:

部件干什么在哪
DockerSandboxServiceDocker 后端总入口,由若干 Mixin 组合而成server/opensandbox_server/services/docker/docker_service.py
WorkloadProviderK8s「造 CRD」的抽象接口server/opensandbox_server/services/k8s/workload_provider.py:27
BatchSandboxProvider默认 K8s provider,支持池/暂停/快照services/k8s/batchsandbox_provider.py:64
AgentSandboxProvider对接上游 agents.x-k8s.io Sandbox CRDservices/k8s/agent_sandbox_provider.py:74
operator集群侧 Go 控制器,把 CRD 变成 Podkubernetes/internal/controller/

3. 后端契约:两个后端到底承诺了什么

在钻进任一后端前,先看它们共同实现的契约。K8s 侧这个契约写得最清楚——WorkloadProvider 抽象基类:

# services/k8s/workload_provider.py:27 WorkloadProvider(ABC)
class WorkloadProvider(ABC):
@abstractmethod
def create_workload(self, sandbox_id, namespace, image_spec, entrypoint,
env, resource_limits, labels, expires_at, execd_image,
...) -> Dict[str, Any]: ... # 造工作负载
@abstractmethod
def get_workload(self, sandbox_id, namespace): ... # 查
@abstractmethod
def delete_workload(self, sandbox_id, namespace): ... # 删
@abstractmethod
def get_status(self, workload) -> Dict[str, Any]: ... # 状态机映射
# 默认抛 NotImplementedError,只有支持的 provider 覆盖:
def pause_sandbox(self, sandbox_id, namespace):
raise NotImplementedError("Pause is not supported by this provider")

关键设计:pause_sandbox / resume_sandbox 默认抛 NotImplementedError(workload_provider.py:188-208)。这样「支不支持暂停」变成一个后端能力开关——agent-sandbox 不实现就自动 501,batchsandbox 覆盖了才可用。上层 kubernetes_service.py:1056 把这个 NotImplementedError 翻译成 HTTP 400「Pause is not supported for this sandbox type」。

Docker 侧没有显式抽象基类,而是用 Mixin 组合:DockerContainerOpsMixin(造容器)、DockerNetworkingMixin(网络)、DockerVolumesMixin(卷)、DockerRuntimeMixin(装 execd)、OSSFSMixin(OSS 挂载)拼成一个 DockerSandboxService。每个 Mixin 管一块关注点——这是本章 §4 的骨架。


4. Docker 后端:怎么造、怎么管一个容器

这一节走一遍 Docker 后端从「拿到规范化请求」到「容器跑起来」的真实路径,再补上卷、端口、OSSFS、Windows 四个横切细节。

4.1 主线:_create_and_start_container

Docker 造容器的核心函数是 container_ops.py:380 _create_and_start_container。它做的事按顺序是:

镜像就绪 → 造 host_config → create_container → 拿到容器
→ 注入 execd(Linux)/装 OEM 脚本(Windows) → container.start()
→ 出错则 remove(force=True) 兜底清理

几个不显然的点:

① 镜像与平台核对(不是简单 pull)。 _ensure_image_available(container_ops.py:217)先 images.get 看本地缓存,再比对缓存镜像的 Os/Architecture 是否和请求的 platform 一致;平台不匹配会强制重新 pull(container_ops.py:252),避免在 arm64 机器上误用 amd64 缓存镜像。

② host_config 把安全策略一次性焊上。 _base_host_config_kwargs(container_ops.py:338)集中注入 no-new-privileges、AppArmor/seccomp profile、cap_droppids_limit、内存/CPU 上限;GPU 通过 DeviceRequest(count=n, capabilities=[["gpu"]]) 表达(container_ops.py:368);若配了安全运行时(如 Kata/gVisor)则塞 host_config_kwargs["runtime"]

③ entrypoint 被劫持成 bootstrap。 Linux 容器创建时强制把 entrypoint 设成 BOOTSTRAP_PATH(/opt/opensandbox/bootstrap.sh,见 container_ops.py:417runtime.py:44)。用户的 command 变成参数——真正先跑的是 OpenSandbox 的启动脚本,由它拉起 execd 再拉起用户进程。这条线在 04 展开。

4.2 精华:execd 是怎么「塞」进每个容器的

这是 Docker 后端最巧的一处。沙箱的用户镜像是任意的(可能连 curl 都没有),但每个沙箱里都要跑 OpenSandbox 自己的 execd 守护进程。怎么把 execd 二进制送进一个你无法控制内容的镜像?

答案:从一个专用 execd 镜像里「拷」出来,缓存在服务内存里,再用 put_archive 灌进目标容器

# runtime.py:51 _fetch_execd_archive —— 示意化的核心逻辑,非逐字源码
# 1) 起一个临时容器,镜像 = execd_image,命令 = tail -f /dev/null(挂着别退出)
container = docker.containers.create(image=execd_image, command=["tail","-f","/dev/null"])
container.start()
# 2) 从它里面把三个产物 tar 出来,缓存进内存字典(按 os/arch 做 key)
execd = container.get_archive("/execd") # execd 主程序
bootstrap= container.get_archive("/bootstrap.sh") # 启动脚本
bwrap = container.get_archive("/usr/local/bin/bwrap") # 隔离用,best-effort
# 3) 临时容器用完即删
container.remove(force=True)

拿到这三份 tar 之后,_prepare_sandbox_runtime(runtime.py:265)把它们 put_archive真正的沙箱容器/opt/opensandbox/ 下。缓存按 os/arch 归档(_normalize_platform_key),所以同平台的第二个沙箱起步就命中缓存,不再起临时容器。

重点看: bwrap(bubblewrap)是 best-effort 拷贝——老版 execd 镜像里没有就跳过,只是日志告警「隔离不可用,升级到 v1.1.0+」(runtime.py:127)。这解释了 05 嵌套隔离 为什么是个可选能力。

这个「临时容器当搬运工」的套路,在 K8s 侧换成了 initContainer(见 §5.3),思路一模一样:用一个自带 execd 的镜像,把二进制拷进共享卷。

4.3 端口:随机分配 + host 端口映射

port_allocator.py 很小但关键。allocate_host_port(port_allocator.py:54)在 [40000, 60000]随机试探一个可 bind 的空闲端口——注意它 bind 的是 0.0.0.0(DOCKER_PUBLISH_HOST),刻意和 Docker 后续 publish 的作用域一致,免得只探 localhost 却在别的网卡上撞端口。

拿到宿主端口后,访问入口由 networking.py:230 _resolve_host_mapped_endpoint 组装:

容器内端口含义对外形态
8080沙箱 HTTP{host}:{http_host_port}
44772execd 代理口{host}:{execd_host_port}
其它端口 p用户服务{host}:{execd_host_port}/proxy/{p}(经 execd 转发)

也就是说,Docker 后端不给每个用户端口单独 publish,而是把非标准端口统一走 execd 的 /proxy/{port} 转发口。这和 K8s 用 Ingress/Gateway 的做法(见 06)形成对比。_extract_bridge_ip(networking.py:547)则负责在 bridge/用户自定义网络下从 NetworkSettings.Networks 里挖出容器 IP(内部代理用)。

4.4 卷:host / pvc(命名卷)/ ossfs 三种后端

volumes.py 把请求里的 volumes[] 翻译成 Docker 的 bind 字符串(_build_volume_binds,volumes.py:349)。三种后端各有校验:

  • host bind:_validate_host_volume(volumes.py:104)做符号链接感知的 allowlist 校验——先 normpath 词法检查,再 realpath 解析真实路径防止 link -> / 这种越权;目录不存在就自动 makedirs
  • pvc = Docker 命名卷:_validate_pvc_volume(volumes.py:164)。卷不存在且 createIfNotExists 时自动 create_volume 并打上 opensandbox.io/volume-managed-by=server 标签;带 subPath 时要求 local 驱动,并对 Mountpoint + subPath 做越界检查。
  • ossfs:见 §4.6。

清理时 _cleanup_managed_volumes(volumes.py:448)只删自己打了 managed 标签的卷,预先存在的卷绝不动——这是「谁创建谁负责」的干净边界。

4.5 Windows profile:另一条装 execd 的路

Windows 容器没有 bootstrap.sh 这套 Linux 机制。windows_profile.py 走另一条:创建时注入 Linux execd,而是 install_windows_oem_scripts(windows_profile.py:279)把 execd 的 Windows 二进制和一个 install.bat 拷进容器的 /oem 目录(通过一个名为 opensandbox-win-oem-{sandbox_id} 的命名卷,windows_profile.py:43),交给 Windows 的 OEM 启动机制去装。resolve_docker_platform(windows_profile.py:46)统一把 PlatformSpec 转成 Docker 的 os/arch 字符串。删除沙箱时 _cleanup_windows_oem_volume(volumes.py:425)清掉这个 OEM 卷。

4.6 OSSFS:把阿里云 OSS 桶挂成本地目录

ossfs_mixin.py 让沙箱能把一个 OSS(对象存储)桶当文件系统用。流程是两级挂载:

OSS 桶 ──(ossfs 进程,在宿主机)──► /mnt/ossfs/<bucket>/...(宿主机路径)
│ 再 bind mount

容器内 /your/mount/path

_mount_ossfs_backend_path(ossfs_mixin.py:305)在宿主机上跑 ossfs(v1 命令行 / v2 配置文件两种形态),把桶挂到一个后端路径;_ensure_ossfs_mounted(ossfs_mixin.py:393)用 mount_key 引用计数——多个沙箱挂同一个桶只挂一次,_release_ossfs_mount(ossfs_mixin.py:427)在最后一个沙箱走时才卸载。凭证走私密配置文件而非命令行(防泄漏),桶名/选项/端点都做了注入防护校验。


5. Kubernetes 后端:提交一个对象,让集群去造 Pod

K8s 后端的心智模型和 Docker 完全相反:它不直接造 Pod,而是造一个自定义资源(CRD),然后相信集群里的 operator 会把它变成 Pod。服务进程的活到「create CRD」就结束了。

5.1 provider 家族与工厂

provider_factory.py:39 create_workload_provider 按名字从注册表选 provider:

# provider_factory.py:30
PROVIDER_TYPE_BATCHSANDBOX = "batchsandbox" # 默认,功能最全
PROVIDER_TYPE_AGENT_SANDBOX = "agent-sandbox" # 对接上游 agents.x-k8s.io

两个 provider 的分工:

provider对应 CRDgroup暂停/恢复Windows
BatchSandboxProviderBatchSandboxsandbox.opensandbox.io支持支持支持
AgentSandboxProviderSandboxagents.x-k8s.io(上游)不支持不支持(继承默认 NotImplementedError)拒绝

BatchSandbox 是 OpenSandbox 自研 CRD,能力最全,是本章后续 §6/§7/§8 的主角;AgentSandbox 是为了对接 Kubernetes 社区的 agent-sandbox 标准,agent_sandbox_provider.py:53 里那个 _to_dns1035_label 就是因为上游 CRD 要求资源名符合 DNS-1035,得把任意 sandbox_id 哈希规整成合法名字。

5.2 create:把请求装配成一个 CRD

BatchSandboxProvider.create_workload(batchsandbox_provider.py:103)为例,它组装一个这样的对象:

BatchSandbox
├─ spec.replicas: 1
├─ spec.expireTime: <过期时间> # 到点由 operator 删除
└─ spec.template (PodTemplateSpec)
├─ initContainers: [execd-installer] # 装 execd 的搬运工(见 5.3)
├─ containers: [sandbox, (egress sidecar?)]
└─ volumes: [opensandbox-bin(emptyDir), (isolation-upper?)]

组装完成后 merge_with_runtime_values(template_manager.py:75)把运维预置的 YAML 模板和这份运行时清单深合并(_deep_merge,template_manager.py:92;None 值跳过、dict 递归、其余覆盖),让运维能预设节点亲和、sidecar、标签等,再由运行时值覆盖关键字段。最后 k8s_client.create_custom_object 提交给 API Server。带镜像鉴权时还会创建对应的 imagePullSecret 并以 CRD 为 owner(失败则回滚删 CRD,batchsandbox_provider.py:293)。

5.3 精华:initContainer = K8s 版的「搬运工」

Docker 用临时容器 + put_archive,K8s 用 initContainer + 共享 emptyDirprovider_common.py:116 _build_execd_init_container 造的 init 容器就一段脚本:

# provider_common.py:122 —— init 容器真实执行的脚本
cp ./execd /opt/opensandbox/execd
cp ./bootstrap.sh /opt/opensandbox/bootstrap.sh
chmod +x /opt/opensandbox/execd /opt/opensandbox/bootstrap.sh
(cp /usr/local/bin/bwrap /opt/opensandbox/bwrap && chmod +x /opt/opensandbox/bwrap || true)

/opt/opensandbox 是一个 emptyDir 卷(opensandbox-bin),同时挂进 init 容器和主容器。init 容器(镜像 = execd_image)把三个二进制拷进这个共享卷,退出;主容器(用户镜像)启动时这个卷里已经有了 execd,主容器 command 被设成 /opt/opensandbox/bootstrap.sh + entrypoint(provider_common.py:219)。bwrap 那句尾巴的 || true 和 Docker 侧的 best-effort 完全对应。

一句话:两个后端注入 execd 的思路同构——「拿一个自带 execd 的镜像,把二进制搬进一个共享位置」——只是 Docker 用 API 拷贝,K8s 用 initContainer + emptyDir。

5.4 informer:别把 API Server 打爆

K8s 后端每次查状态如果都打 API Server,规模一大就撑不住。informer.py:27 WorkloadInformer 是一个轻量 watch 缓存:后台线程 list 一次做全量同步(_full_resync,informer.py:165),然后 watch 流式增量更新本地字典(_run_watch_loop,informer.py:186),resourceVersion 只增不退(_advance_resource_version,防止陈旧响应回退游标),watch 报 410(游标过旧)就强制重新全量。

client.py:156 get_custom_object读穿缓存:informer 已 synced 就直接读内存,否则回落到 API 调用并顺手回填缓存;写操作(create_custom_object)成功后主动 update_cache,让读能立刻看到自己刚写的对象。这套东西让 K8s 后端的状态查询几乎不打 API Server。

5.5 状态映射:CRD phase → 沙箱状态机

get_status(batchsandbox_provider.py:768)把 CRD 的 .status.phase 翻译成 01 的沙箱状态。phase 权威时直接映射:

BatchSandbox phase沙箱 state
PendingCREATING
Succeed / RunningRUNNING
PausingPAUSING
PausedPAUSED
ResumingRESUMING
FailedFAILED

phase 缺失时回落到「看 Pod 有没有 IP、Ready 没有」来推断,并专门识别平台不可调度(节点没有匹配 kubernetes.io/os|arch 的)这种永久性失败,和「暂时资源不足」区分开(workload_provider.py:405 is_platform_unschedulable)。


6. 沙箱池:用预热换掉冷启动延迟

6.1 为什么要池

从零起一个沙箱要:调度节点 → 拉镜像 → 起 initContainer 装 execd → 起主容器 → execd 就绪。这几秒到几十秒的冷启动,对「用户点一下就要一个沙箱」的交互场景是致命的。

池的思路:提前把一批 Pod 起好、execd 装好、卡在「就绪等待」,来请求时直接从池里领一个改个归属,省掉所有起步开销。 这只在 K8s 后端有(Docker 单机没这需求),API 也明确:非 K8s 运行时调 /pools 直接返回 501(api/pool.py:57)。

6.2 两种创建模式

BatchSandboxProvider.create_workload 一开始就分叉(batchsandbox_provider.py:133):

extensions.poolRef 有值?
├─ 有 → 池模式:_create_workload_from_pool
│ 造一个精简 BatchSandbox { replicas:1, poolRef:<pool> }
│ (可选 taskTemplate 携带用户 entrypoint/env)
│ —— 不含 initContainer/主容器模板,因为 Pod 已在池里备好
└─ 无 → 模板模式:自己拼完整 pod_spec(§5.2)

池模式下 _build_task_template(batchsandbox_provider.py:464)把用户 entrypoint 包成 /opt/opensandbox/bootstrap.sh <escaped-args> & 交给 task-executor 在已就绪的池 Pod里拉起——预热 Pod 本来跑着 tail -f /dev/null,领取后才注入真正的用户进程。池模式此版本不支持 platform 和 volumes(batchsandbox_provider.py:134-143 显式拒绝)。

6.3 Pool CRD 与容量语义

PoolService(pool_service.py:41)是薄薄一层 CRUD,把 REST 请求转成 Pool CRD。真正有意思的是容量四元组(kubernetes/apis/sandbox/v1alpha1/pool_types.go:71 CapacitySpec):

字段含义
bufferMin / bufferMax暖 buffer 的下限/上限——空闲待领的 Pod 保持在这个区间
poolMin / poolMax整池(含已分配)的下限/上限

RecycleStrategy(pool_types.go:39)决定 Pod 被归还池时怎么处理:Delete(默认,删掉)、Restart(重启容器复用)、Noop(啥也不做)。

6.4 operator 侧:pool_controller 的调谐循环

集群里 pool_controller.go:186 reconcilePool 是池的大脑,一次调谐六步:

1. 拿最新 Pool CR
2. handleEviction —— 处理需驱逐的 Pod
3. scheduleSandbox —— 把待分配的 BatchSandbox 绑到空闲池 Pod(§6.5)
4. updatePool —— 模板变了就滚动升级(算 revision)
5. scalePool —— 按 buffer/pool 上下限扩缩容(scalePool:712)
6. updatePoolStatus —— 回写 total/allocated/available

scalePool(pool_controller.go:712)的核心算术:期望暖 buffer 落在 [bufferMin, bufferMax](越界则取中点),期望总量 = allocated + supply + buffer,再被 poolMax 封顶——这就是「预热多少」的策略函数

6.5 分配:allocator + poolassign

「一个待创建沙箱该领池里哪个 Pod」由 poolassign 包用预选 + 打分决定(和 kube-scheduler 同构):

  • 预选谓词(poolassign/predicate_*.go):镜像匹配、节点选择器、资源够不够、容量够不够、标签选择器。
  • 打分(poolassign/scorer_resbalance.go):资源均衡度评分,挑最优 Pod。

allocator.go:60 InMemoryAllocationStore 是「哪个池 Pod 分给了哪个沙箱」的内存账本,启动时能从集群现状 Recover 重建(allocator.go:69),避免 operator 重启后账本丢失。

注意区分:这里的「分配」是把预热 Pod 配给沙箱;而 internal/scheduler/(default_scheduler.go)是另一个东西——task-executor 的任务调度器,负责把 taskTemplate 里的进程派到 Pod 上跑,属于 04 的范畴。


7. 快照:把一个运行中的沙箱冻成可复用镜像

7.1 快照是什么、给谁用

快照 = 把一个正在跑的沙箱的根文件系统提交成一个容器镜像,之后可以用这个镜像恢复出一个内容一致的新沙箱(装好的依赖、改过的文件都在)。典型用途:agent 装了一堆环境后存档,下次秒开。

恢复路径很简单:create 请求带 snapshotId 而非 image,snapshot_restore.py:30 resolve_sandbox_image_from_request 从仓库查出快照的镜像 URI 塞进 request.image,后面就当普通镜像创建。

7.2 服务端编排:一套 SQLite 状态机 + 后台 worker

不管哪个后端,服务端的快照编排是同一套(snapshot_service.py:93 PersistedSnapshotService):

create_snapshot(sandbox_id)
├─ 校验源沙箱必须 Running(snapshot_service.py:480)
├─ 在 SQLite 写一条 state=Creating 的记录
└─ 丢给 ThreadPoolExecutor(最多 2 worker)后台执行
└─ _create_snapshot_worker:调 runtime.create_snapshot()
→ 拿到终态(Ready+image / Failed)
→ _complete_snapshot 用 update_if_state 原子落库

几个稳健性设计:

  • 状态记录落 SQLite(repositories/snapshots/sqlite.py:39),开 WAL、update_if_state(sqlite.py:183)做乐观并发(WHERE id=? AND state=?,rowcount==1 才算成功),防止后台 worker 和删除请求互相踩。
  • 崩溃恢复:服务启动时 recover_unfinished_snapshots(snapshot_service.py:336)扫所有 Creating/Deleting 的残留记录,inspect_snapshot 探真实产物状态,把它们推向终态——服务中途挂了也不会留下永久卡住的快照。

7.3 Docker 快照:一次内联 docker commit

Docker 后端的 runtime 实现最直接(docker/snapshot_runtime.py:128 _create_snapshot):

# 找到容器,直接 commit 成 opensandbox-snapshots:<snapshot_id>
container = self._get_container_by_sandbox_id(sandbox_id)
container.commit(repository="opensandbox-snapshots", tag=snapshot_id) # 同步、就地

docker commit 由本机 daemon 完成,worker 线程里同步等它返回,拿到镜像引用就是 Ready。超时/失败各有专门的 reason 分类(snapshot_runtime.py:141)。删除快照就是 images.remove(遇 409 冲突转成 HTTP 409)。

7.4 Kubernetes 快照:派一个 Job 去节点上 commit

K8s 没有「本机 daemon」这种东西——容器分散在各节点,由 containerd 管。所以 K8s 快照是声明 + 异步 Job:

① 服务端 KubernetesSnapshotRuntime.create_snapshot(k8s/snapshot_runtime.py:84)
创建一个 SandboxSnapshot CRD { spec.sandboxName: <id> },然后轮询它的 phase


② operator sandboxsnapshot_controller(Reconcile:96)
handlePending:找到源 Pod 所在节点,为每个容器算出目标镜像 URI
buildCommitJob:造一个 Job(lifecycle:325)
· nodeName = 源 Pod 所在节点(必须同节点!)
· hostPath 挂 /var/run/containerd/containerd.sock
· 跑 image-committer


③ image-committer(cmd/image-committer/main.go:75)在那个节点上:
nerdctl pause 容器 → nerdctl commit → unpause → nerdctl push → 取 digest
把结果 JSON 写进 /dev/termination-log 回传


④ operator 读 Job 成功 + termination message → phase=Succeed,写回每容器 imageURI/digest
⑤ 服务端轮询看到 Succeed → 落库 Ready

几个关键真源码:

  • Job 必须钉在源节点:buildCommitJobNodeName: snapshot.Status.SourceNodeName(sandboxsnapshot_lifecycle.go:397),因为要通过该节点的 containerd socket 才能看到那个容器。
  • image-committer 的动作序列:main.go 里明确是 pause→commit→resume→push→digest(commitContainernerdctl commit,main.go:411;pushImage,main.go:425)。commit 前 nerdctl pause(冻结)保证文件系统一致,commit 后无论成败都 resume(resumeAllPausedContainers,main.go:390),连收到 SIGTERM 都要先把冻住的容器解冻再退出(main.go:81)——不然会把用户的容器永久冻死。
  • 失败兜底:commit Job 失败时 operator 还会派一个 unpause Job(ensureUnpauseJob,sandboxsnapshot_lifecycle.go:409),防止 committer 崩溃导致容器停在 paused。

一句话对比:

Docker 快照K8s 快照
谁 commit本机 daemon,container.commit节点上的 nerdctl commit(经 hostPath containerd socket)
同步性worker 线程内同步派 Job + 轮询 CRD phase
产物本地镜像 opensandbox-snapshots:<id>push 到远程 registry 的镜像
一致性保证commit 语义自带committer 先 pause 容器再 commit

8. 暂停/恢复:同一个动词,两种完全不同的物理

「暂停」在两个后端的语义都是「先别占着我算力,但保住状态」,但实现南辕北辙。

8.1 Docker:冻结进程,原地不动

Docker 后端就是一层薄封装(docker_service.py:1177):

def pause_sandbox(self, sandbox_id):
container = self._get_container_by_sandbox_id(sandbox_id)
# 必须 Running 才能 pause
container.pause() # docker pause == freezer cgroup,SIGSTOP 全部进程

def resume_sandbox(self, sandbox_id):
container.unpause() # 解冻,进程从原地继续

docker pause 用的是内核 freezer——容器还在、内存还在、进程只是被冻住。暂停/恢复都是毫秒级,内存态(RAM 里的一切)完整保留。代价:它没释放宿主机的内存/端口,只是让 CPU 不再调度这些进程。

8.2 Kubernetes:快照 rootfs + 删 Pod + 重建

K8s 后端的暂停要真正把节点资源还给集群,所以它做的是一套复杂得多的编排。服务端只是 patch spec.pause=true(batchsandbox_provider.py:615),真正的活全在 operator(batchsandbox_pause_resume.go)。

暂停(handlePausecompletePause,batchsandbox_pause_resume.go:225/424):

1. 校验 replicas==1(快照格式只记单 Pod 的镜像,只支持单副本,225 行)
2. 停掉 task-executor 的任务
3. 创建内部 SandboxSnapshot(名字 = <bs>-pause)→ 复用 §7.4 那套 commit Job
把当前 rootfs 提交成镜像
4. 快照 Succeed 后 completePause:
· 普通模式:删掉所有 Pod(资源真正释放)
· 池模式:把当前 Pod 的模板「固化」写回 spec.template、清掉 poolRef,
让 pool_controller 回收那个池 Pod
5. phase = Paused

恢复(handleResumecontinueResume,batchsandbox_pause_resume.go:351/510):

1. 读那个内部 SandboxSnapshot 的 status.containers,拿到每个容器的快照镜像 URI
2. 把 spec.template 里各容器的 image 替换成快照镜像
3. 清掉 poolRef(如果还有)
4. 交给正常调谐:用改写后的模板重建那一个 Pod → 从快照 rootfs 起来
5. phase = Succeed

这套流程靠 spec.pause(三态:nil/true/false)+ status.pauseObservedGeneration幂等门控(batchsandbox_types.go:139/179):controller ACK 一个 generation 后写 pauseObservedGeneration,防止同一个 pause 意图被重复执行;server 侧还有个「retry bridge」——失败重试时先把 pause patch 成 nil 再设目标值,强行制造一个新 generation 让 controller 重新处理(batchsandbox_provider.py:567 _patch_pause_with_retry_bridge)。

8.3 一表看清本质差异

维度Docker pauseK8s pause
底层机制freezer cgroup(container.pause())快照 rootfs → 删 Pod → 重建
内存态(RAM)完整保留丢失(只留文件系统)
释放算力否(只不调度 CPU)是(Pod 删除,节点资源归还)
快慢毫秒级秒~分钟级(要 commit + push + 重调度)
副本限制replicas==1
恢复来源同一个容器原地解冻从快照镜像重建的新 Pod

精华一句: Docker 的暂停是「把人按住别动」,K8s 的暂停是「给现场拍张照、清场、下次照着照片重建」。前者省不了资源但零状态损失,后者真省资源但只保住磁盘、丢内存。选哪个取决于你到底想省什么。


9. 巧妙之处(可借鉴的设计)

  • execd 注入的同构双实现。 Docker 用「临时容器 + put_archive + 内存缓存」,K8s 用「initContainer + emptyDir」,思路一致:永远别假设用户镜像里有 execd,而是从一个自带 execd 的镜像搬进来(runtime.py:51provider_common.py:116)。
  • 能力靠「默认抛异常」表达。 pause_sandbox 在基类默认 raise NotImplementedError,支持的 provider 才覆盖,上层统一翻译成 501/400——加新后端时「支不支持某能力」是自然涌现的,不用维护能力矩阵(workload_provider.py:188)。
  • informer 读穿缓存 + 只增游标。 watch 缓存把绝大多数状态读挡在 API Server 之外,resourceVersion 严格只增避免陈旧响应回退(informer.py:113),写后立即回填缓存保证读己之所写(client.py:151)。
  • 快照的乐观并发落库。 update_if_stateWHERE state=? 让「后台 worker 完成」和「用户请求删除」这两条并发路径靠数据库单行 CAS 仲裁,而不是靠锁(sqlite.py:183)。
  • committer 的解冻保障是「防御到底」的。 pause 后正常 resume、异常 resume、SIGTERM resume、operator 再补一个 unpause Job——四重保险确保不会把用户容器永久冻死(main.go:81sandboxsnapshot_lifecycle.go:409)。

10. 边界与局限(诚实清单)

  • K8s 暂停/快照只支持单副本。 handlePause 显式拒绝 replicas != 1(batchsandbox_pause_resume.go:230),因为内部快照格式只记一个源 Pod 的容器镜像。
  • K8s 暂停会丢内存态。 只保 rootfs;任何驻留在 RAM 里的运行时状态(未落盘的进程内存)在恢复后都不在了。要「热」暂停请用 Docker 后端。
  • 池模式功能受限。 poolRefplatformvolumes 互斥(batchsandbox_provider.py:134-143),池化目前只服务「同构、无卷」的快启场景。
  • Docker 的签名路由/secureAccess 不支持。 get_endpointexpires 直接 400,secureAccess 直接拒绝并建议改用 K8s(networking.py:178networking.py:144)——这些能力依赖 K8s 的 Ingress/网关。
  • Docker 快照产物是本地镜像。 opensandbox-snapshots:<id> 存在那台 daemon 上,不像 K8s 会 push 到远程 registry,跨机复用能力弱。
  • GPU 仅覆盖 NVIDIA。 两个后端都硬编码 NVIDIA(Docker 的 capabilities=[["gpu"]]、K8s 的 nvidia.com/gpu),其它厂商键位是 TODO(provider_common.py:50)。
  • image-committer 需要节点特权。 commit Job 要 hostPath 挂 containerd socket 并钉在源节点(sandboxsnapshot_lifecycle.go:334),这是个需要节点级权限的敏感操作。

11. 代码地图(导航索引)

优先按符号名 grep(行号会随上游漂移,符号名相对稳定)。路径相对克隆根 server/opensandbox_server/kubernetes/

Docker 后端

主题文件符号
造+启容器主线services/docker/container_ops.py_create_and_start_container
镜像就绪与平台核对services/docker/container_ops.py_ensure_image_available / _pull_image
host_config(安全/资源/GPU)services/docker/container_ops.py_base_host_config_kwargs
execd 拉取与缓存services/docker/runtime.py_fetch_execd_archive / _prepare_sandbox_runtime
端口随机分配services/docker/port_allocator.pyallocate_host_port / allocate_port_bindings
端点/host 映射/桥 IPservices/docker/networking.pyget_endpoint / _resolve_host_mapped_endpoint / _extract_bridge_ip
卷校验与 bind 生成services/docker/volumes.py_validate_volumes / _build_volume_binds / _validate_pvc_volume
OSSFS 两级挂载services/docker/ossfs_mixin.py_mount_ossfs_backend_path / _ensure_ossfs_mounted
Windows OEM 注入services/docker/windows_profile.pyinstall_windows_oem_scripts / resolve_docker_platform
Docker 暂停/恢复services/docker/docker_service.pypause_sandbox / resume_sandbox
Docker 快照 commitservices/docker/snapshot_runtime.pyDockerSnapshotRuntime._create_snapshot

Kubernetes 后端(server 侧)

主题文件符号
provider 抽象/能力开关services/k8s/workload_provider.pyWorkloadProvider / pause_sandbox
provider 工厂services/k8s/provider_factory.pycreate_workload_provider / _PROVIDER_REGISTRY
BatchSandbox providerservices/k8s/batchsandbox_provider.pycreate_workload / _create_workload_from_pool / pause_sandbox
agent-sandbox providerservices/k8s/agent_sandbox_provider.pyAgentSandboxProvider / _to_dns1035_label
execd initContainer / 主容器services/k8s/provider_common.py_build_execd_init_container / _build_main_container
YAML 模板深合并services/k8s/template_manager.pymerge_with_runtime_values / _deep_merge
watch 缓存services/k8s/informer.pyWorkloadInformer / _run_watch_loop
读穿缓存的客户端services/k8s/client.pyget_custom_object / _get_informer
池 CRUDservices/k8s/pool_service.pyPoolService / create_pool
K8s 快照 runtimeservices/k8s/snapshot_runtime.pyKubernetesSnapshotRuntime.create_snapshot
暂停/恢复分派services/k8s/kubernetes_service.pypause_sandbox / resume_sandbox

快照编排(后端无关)

主题文件符号
快照服务/后台 workerservices/snapshot_service.pyPersistedSnapshotService / _create_snapshot_worker / recover_unfinished_snapshots
runtime 工厂(选 Docker/K8s)services/snapshot_runtime_factory.pycreate_snapshot_runtime
快照恢复为镜像请求services/snapshot_restore.pyresolve_sandbox_image_from_request
SQLite 落库/乐观并发repositories/snapshots/sqlite.pySQLiteSnapshotRepository.update_if_state

operator(kubernetes/,Go)

主题文件符号
BatchSandbox CRD 定义apis/sandbox/v1alpha1/batchsandbox_types.goBatchSandboxSpec.Pause / BatchSandboxPhase / PauseObservedGeneration
Pool CRD 定义apis/sandbox/v1alpha1/pool_types.goCapacitySpec / RecycleStrategy
SandboxSnapshot CRD 定义apis/sandbox/v1alpha1/sandboxsnapshot_types.goSandboxSnapshotPhase / ContainerSnapshot
BatchSandbox 调谐入口internal/controller/batchsandbox_controller.goBatchSandboxReconciler.Reconcile
暂停/恢复状态机internal/controller/batchsandbox_pause_resume.godispatchPauseResume / handlePause / completePause / continueResume
快照调谐internal/controller/sandboxsnapshot_controller.goSandboxSnapshotReconciler.Reconcile
快照 Job 组装internal/controller/sandboxsnapshot_lifecycle.gohandlePending / buildCommitJob / ensureUnpauseJob
池调谐/扩缩容internal/controller/pool_controller.goreconcilePool / scalePool / scheduleSandbox
池分配账本internal/controller/allocator.goInMemoryAllocationStore
池预选/打分internal/controller/poolassign/Predicate / Scorer(predicate_*.go / scorer_resbalance.go)
镜像提交器cmd/image-committer/main.gocommitContainer / pushImage / resumeAllPausedContainers