跳到主要内容

数据截至 (上游 commit 97c8097d51d0)

04 · 沙箱文件系统的装配与依赖管理

这章讲什么: chroot 之后,沙箱里的世界是空的——空到连 import json 都会失败。这章讲这个世界是怎么被一件件搬进去的,以及为什么这套「搬运」逻辑写得比想象中复杂得多。


1. 问题:chroot 之后,连标准库都没了

03 章 说过,沙箱进程的根被换成了 /var/sandbox/sandbox-python。那么:

  • 解释器要 import json,它会去 sys.path 里那些路径找——比如 /usr/local/lib/python3.13/json/
  • chroot 之后,这个路径解析成 /var/sandbox/sandbox-python/usr/local/lib/python3.13/json/
  • 如果这个目录不存在,import json 就炸。

所以沙箱根里必须原样重建一份解释器需要的目录树。FAQ 的第一条问题(xxx.so: cannot open shared object file)就是这件事没做好的症状。

宿主容器 沙箱根 /var/sandbox/sandbox-python/
/usr/local/lib/python3.13/ ──► usr/local/lib/python3.13/ ← 标准库
/usr/lib/x86_64-linux-gnu/ ──► usr/lib/x86_64-linux-gnu/ ← C 动态库
/etc/ssl/certs/... ──► etc/ssl/certs/... ← HTTPS 证书
/usr/share/zoneinfo/ ──► usr/share/zoneinfo/ ← 时区
(嵌在二进制里) ──► python.so ← 上锁库
(每次请求生成) ──► tmp/<uuid>.py ← bootstrap

注意路径是原样保留的:/usr/local/lib/... 在沙箱里还叫 /usr/local/lib/...,只是根变了。这一点是刻意的,源码注释专门解释过原因(internal/static/python_lib_discovery.go:196-205):解释器的 sys.path 里记的就是这些名字,改名就找不到了。


2. 该搬哪些路径?让 Python 自己说

早期做法与它的问题

以前这是个配置项 python_lib_path,运维手填。现在这个配置被彻底废弃,填了只会打一行警告(internal/static/config.go:34-3677-79):

if _, ok := rawConfig["python_lib_path"]; ok {
slog.Warn("python_lib_path is deprecated and ignored; python library paths are discovered from python_path")
}

手填的问题很直观:换个 Python 版本、换个基础镜像,路径就变了,运维得跟着改。

现在的做法:启动时问解释器

服务启动时,直接把目标解释器跑起来,让它自己报告 sys.path、stdlib、site-packages(internal/static/python_lib_discovery.go:173-194,discoverPythonLibPaths):

command := exec.Command(pythonPath, "-P", "-c", pythonLibDiscoveryScript)
command.Env = pythonDiscoveryEnv()

那段内嵌的 Python 探测脚本(python_lib_discovery.go:12-116)会收集四类路径,最后 print(json.dumps(...)) 吐出 JSON:

来源拿到什么
sysconfig.get_path("stdlib"/"platstdlib"/"purelib"/"platlib")标准库与站点包的规范位置
sys.path 逐项解释器真实的搜索路径
site.getsitepackages() / getusersitepackages()第三方包目录
python3x.zip 形式的 stdlib 压缩包某些发行版把标准库打成 zip

两道防投毒的门

这段代码真正有意思的地方,是它假设探测过程本身可能被做手脚

门一:-P 加清空环境变量。 命令行的 -P 让 Python 不把脚本所在目录/当前目录加进 sys.path;pythonDiscoveryEnv() 则把 PYTHONHOMEPYTHONPATHPYTHONUSERBASEPYTHONSTARTUP 等 10 个变量统统置空,只保留 PATH/LANG/TMPDIR 这几个无害的(python_lib_discovery.go:314-349)。

目的很明确:结果必须来自解释器本身,不能来自当前目录或环境变量。测试 TestDiscoverPythonLibPathsIgnoresCurrentWorkingDirectoryShadowing 就是在验证这一点(internal/static/config_test.go:47)。

门二:json 模块必须来自标准库的规范位置。 这是全项目最偏执的一段校验(python_lib_discovery.go:206-261,buildPythonLibPaths):

拿到 json.__file__

├─ 它的根目录在 stdlib / platstdlib / 已认可的 stdlib zip 里吗?
│ 否 → 报错:"not provided by the interpreter standard library"

├─ 相对路径正好是 json.py、json、或 json/... 吗?
│ 否 → 报错:"not in a canonical standard-library json location"

└─ 通过,把这个根加进要搬运的清单

判定函数是 isCanonicalJSONRelativePath(python_lib_discovery.go:361-370)。

为什么单挑 json? 因为探测脚本自己用 json.dumps 输出结果。如果环境里有个冒牌的 json 包被优先 import 到,它就能伪造整个路径清单,进而让任意目录被搬进沙箱根。这里等于在说:我用来汇报的工具本身,必须先证明自己是正版的。

最后还有兜底断言:清单里必须真的包含 stdlib 路径和 json 根,少一个就整体失败(python_lib_discovery.go:252-259)。启动阶段宁可起不来,也不要带着残缺的沙箱上线。

再补一份固定的系统清单

发现来的路径之外,还固定追加一批系统文件,按架构分两份(internal/static/config_default_amd64.go:5-17):

路径为什么要
/usr/lib/x86_64-linux-gnu(arm64 是 aarch64-...)C 扩展依赖的 .so
/etc/ssl/certs/ca-certificates.crtHTTPS 验证证书
/etc/nsswitch.conf/etc/hosts/etc/resolv.conf域名解析
/etc/localtime/usr/share/zoneinfo/etc/timezone时区,TestPythonTimezone 依赖它

这份清单可以用环境变量 SYSTEM_LIB_REQUIREMENTS 整体替换(internal/static/config.go:90-100)。


3. 怎么搬:优先硬链接,而不是复制

搬运由一段 shell 干(internal/core/runner/python/env.sh),Go 侧对每个发现到的路径调一次(internal/core/runner/python/env.go:19-52,PreparePythonDependenciesEnv)。

核心是 copy_and_link 这个函数(env.sh:13-28),三个分支:

源文件是什么?

├─ 符号链接 → cp -P 原样复制这个链接

├─ 设备文件 → cp 之后 chmod 444

└─ 普通文件 → ln -f 建硬链接 ← 默认走这条
失败(跨设备)则退回 cp + chmod 444

为什么优先硬链接: 硬链接不占额外空间、建立几乎瞬时。Python 标准库 + site-packages 动辄几百 MB,真复制一份既慢又费磁盘。硬链接让沙箱根和宿主目录指向同一批 inode

代价是:硬链接指向同一份数据,沙箱里若能写这个文件,宿主的文件也会变。项目靠两点挡住:降权后的 UID 对这些文件没有写权限,而且 unlink/rename 根本不在 syscall 白名单里。

遍历用 find -L "$src" -type f,l(env.sh:40)——-L 表示跟随目录符号链接,这样即使 /usr/local/lib 是个软链,底下的真实文件也能被摊平搬进去。


4. 依赖:装、登记、定期刷新

三个动作

① 装 pip3 install -r requirements.txt
internal/core/runner/python/setup.go:104-176


② 登记 逐行解析包名+版本,写进内存 map
ExtractOnelineDepency → SetupDependency


③ 灌进沙箱 PreparePythonDependenciesEnv 重新搬一遍库路径
internal/core/runner/python/env.go:19

第 ② 步的解析很朴素(setup.go:81-102,ExtractOnelineDepency):按 ==>=<=~= 四种分隔符切一刀,切不动就整行当包名。登记结果存在一个带读写锁的 map 里(internal/core/runner/python/dependencies/init.go:9-16),只用于 /v1/sandbox/dependencies 接口回显,不参与执行

有意思的是有两个文件用 init() 硬编码了「一定装了」的包(dependencies/network.godependencies/jinja2.go):httpxrequestsjinja2。因为镜像构建时就 pip3 install 了它们(docker/versions.yaml:9),但那是 Dockerfile 干的、Go 侧不知道,只能补登记。

定期刷新

服务启动时起一个 goroutine,按 python_deps_update_interval(默认 30 分钟)周期性重装依赖并重新灌进沙箱(internal/server/server.go:78-9295-107):

ticker := time.NewTicker(tickerDuration)
for range ticker.C {
if err := updatePythonDependencies(dependencies); err != nil { ... }
}

注意 initDependencies异步启动的(server.go:113,go initDependencies()),HTTP 服务不等它。所以服务刚起来的头几十秒,沙箱里可能还没有第三方库 (inferred)。

手动触发的三个接口

路由干什么service 符号
GET /v1/sandbox/dependencies列出登记的依赖ListPython3Dependencies
POST /v1/sandbox/dependencies/update只重新灌一遍沙箱(不重装)UpdateDependencies
GET /v1/sandbox/dependencies/refresh重装 + 重新登记RefreshPython3Dependencies

三者都只支持 python3,Node 传进来直接 -400(internal/controller/run.go:32-69)。


5. Node 那边:清单短得多,但每次都重来

Node 不做路径发现,它有一份写死的 7 项清单(internal/core/runner/nodejs/nodejs.go:29-38,REQUIRED_FS):

作用
<LIB_PATH>/nodejs-project/node_tempkoffi 依赖 + bootstrap 落点
<LIB_PATH>/nodejs.so上锁库
/etc/ssl/certs/ca-certificates.crtHTTPS
/etc/nsswitch.conf/etc/resolv.conf/run/systemd/resolve/stub-resolv.conf/etc/hosts域名解析

为什么这么短?因为 Node 的运行时是一个自包含的二进制(/usr/local/bin/node),不像 Python 那样要在文件系统里到处找模块。

但代价在另一头:Python 的沙箱根是全局一份、装配一次;Node 的沙箱根是每次请求现搭、cp -r 一遍(internal/core/runner/temp_dir.go:47)。两种模型的取舍:

PythonNode
chroot 根固定 /var/sandbox/sandbox-python每次新建 /tmp/sandbox-<uuid>
装配时机服务启动 + 定期刷新每次请求
并发时的隔离共享根,靠 UID + 0600 隔开各有各的根,天然隔开
单次执行开销只写一个 bootstrap 文件复制整棵 node_modules
依赖pip 可动态增删vendored 死的,只有 koffi

6. 镜像里长什么样

Dockerfile 是模板生成的:docker/templates/production.dockerfile + docker/versions.yamldocker/generate.sh 用 sed 替换,产出 <arch>-<env>.gen.dockerfile(生成物在 .gitignore 里)。CI 也走同一条路(.github/workflows/tests.yml:41-49)。

运行镜像里的关键几件东西:

位置内容
/main主服务二进制
/env一次性的沙箱初始化工具,跑完就被删(production.dockerfile:57-58)
/conf/config.yaml配置
/opt/node-<ver>-<arch>.tar.xzNode 压缩包,在容器启动时才解压

最后这条有点特别:docker/entrypoint.sh:3-7 每次容器启动都 tar -xvf 解压 Node、软链到 /usr/local/bin/node、再删掉压缩包。

/env 这个工具本身就是 cmd/dependencies/init.go——它做的事和服务启动时一模一样(读配置 + PreparePythonDependenciesEnv),目的是在构建镜像时就把 Python 库搬进沙箱根,让容器起来就能直接跑。


7. 代码地图

主题文件路径符号名
库路径发现(Go 侧)internal/static/python_lib_discovery.godiscoverPythonLibPathsbuildPythonLibPathsresolvePythonPath
内嵌的 Python 探测脚本internal/static/python_lib_discovery.gopythonLibDiscoveryScript
防投毒:环境清空internal/static/python_lib_discovery.gopythonDiscoveryEnv
防投毒:json 规范位置internal/static/python_lib_discovery.goisCanonicalJSONRelativePath
固定系统清单internal/static/config_default_amd64.go / _arm64.godefaultSystemLibRequirements
配置装载与废弃项警告internal/static/config.goInitConfig
搬运编排internal/core/runner/python/env.goPreparePythonDependenciesEnv
硬链接搬运脚本internal/core/runner/python/env.shcopy_and_link
pip 安装与登记internal/core/runner/python/setup.goInstallDependenciesExtractOnelineDepency
依赖登记表internal/core/runner/python/dependencies/init.goSetupDependencyListDependencies
定期刷新internal/server/server.goinitDependenciesupdatePythonDependencies
Node 必需文件清单internal/core/runner/nodejs/nodejs.goREQUIRED_FS
镜像内初始化工具cmd/dependencies/init.gomain
发现逻辑的测试internal/static/config_test.goTestDiscoverPythonLibPathsFindsStdlibAndJSONTestBuildPythonLibPathsFailsWithoutStdlibOrJSONRoot