跳到主要内容

沙箱文件系统的搭建与多语言支持

30 秒导读: 上一章(03-seccomp-chroot.md)讲了子进程被 chroot(".") 换根——但换根之后,那个新的"根"里到底有什么?本章讲清:一个刚被关进笼子的 Python / Node 子进程,眼中的整个文件系统世界,是怎么被精确、最小化地一块块拼出来的;为什么解释器能 import json,却看不到宿主机的其它任何东西;以及 Python 和 Node 两条实现路线的异同。

本章聚焦"笼子里的地面",不重复关在笼子里的动作本身(那是 0203)。


1. 这是什么:换根之后,子进程看见的世界

先回忆上一章的关键一步:子进程在跑用户代码前,会执行 syscall.Chroot(".")——把当前工作目录变成它的新根 /

真源码 internal/core/lib/python/add_seccomp.go:18InitSeccomp:

err := syscall.Chroot(".") // 把 cwd 变成新的 /
...
err = syscall.Chdir("/") // 换根后回到新的 /

chroot(".") 之后,子进程再也看不到 / 以上的宿主文件系统——它的整个宇宙,就是那个 . 目录里有什么。于是问题变成:那个 . 目录里,该放什么、怎么放?

放少了,解释器起不来(import json 失败、libpython.so 找不到);放多了,等于把宿主机文件漏进笼子,前功尽弃。所以本章的主题一句话:

把子进程需要的文件——且只把这些文件——精确地拷进一个临时目录,再拿这个目录当根。

这个"该拷什么"的清单,不是硬编码猜的,而是启动时真的跑一次解释器问出来的


2. 顶层全景:从启动到一次运行,文件系统怎么拼出来

分两个时间点:进程启动时发现并物化库路径(一次性),每次请求时再建一个临时目录当根(每次)。

启动时(initConfig / initDependencies,一次性)
────────────────────────────────────────────
① discoverPythonLibPaths 跑一次内嵌 Python 脚本,问出 stdlib/site-packages/zip 路径
(python_lib_discovery.go) + json 必须来自 canonical stdlib 的安全校验
│ 得到 config.PythonLibPaths = [ /opt/python/lib/... , /usr/lib/x86_64..., /etc/resolv.conf, ... ]

② PreparePythonDependenciesEnv 把上面每个路径,用 env.sh 拷进 /var/sandbox/sandbox-python
(python/env.go) ← 这就是 Python 的"沙箱根模板"

③ InstallDependencies pip3 -r requirements.txt(带镜像源)装 httpx/requests 等
(python/setup.go) 后台 goroutine 定时 RefreshDependencies

每次请求时(PythonRunner.Run / NodeJsRunner.Run,每次)
────────────────────────────────────────────
④ WithTempDir("/", paths, ...) /tmp/sandbox-<uuid>/ 建临时目录,cp 依赖进去,chdir 过去
(temp_dir.go)

⑤ 子进程 exec 解释器 → 加载 .so → Chroot(".") → 笼子成型 → 跑用户代码

各部件一句话职责:

部件干什么在哪
discoverPythonLibPaths启动时跑内嵌脚本,问解释器要 stdlib/site 路径internal/static/python_lib_discovery.go:173
buildPythonLibPaths校验发现结果,尤其 json 必须来自 canonical stdlibinternal/static/python_lib_discovery.go:206
PreparePythonDependenciesEnv把发现到的路径拷进 /var/sandbox/sandbox-pythoninternal/core/runner/python/env.go:19
env.sh硬链接/拷贝一个 src 目录到 dest(跟随符号链接)internal/core/runner/python/env.sh
TempDirRunner.WithTempDir建临时目录、cp 指定文件、chdirinternal/core/runner/temp_dir.go:13
InstallDependencies跑 pip3 + 镜像源,登记装了哪些包internal/core/runner/python/setup.go:104
NodeJsRunner.RunNode 版:用 REQUIRED_FS 显式列出要拷的文件internal/core/runner/nodejs/nodejs.go:40
prescript.jsNode 版引导:koffi.load + eval(code)internal/core/runner/nodejs/prescript.js

3. 核心原理一:Python 库路径的自动发现

3.1 它要解决的小问题

以前 dify-sandbox 靠人肉配一个 python_lib_path(告诉它 stdlib 在哪),换个基础镜像、换个 Python 版本就得改。现在改成:别猜,直接问解释器

internal/static/config.go:78 已把老配置标记为废弃:

if os.Getenv("PYTHON_LIB_PATH") != "" {
slog.Warn("PYTHON_LIB_PATH is deprecated and ignored; ...discovered from python_path")
}

3.2 思路:在 chroot 之前,跑一次干净的解释器

发现动作发生在 chroot 之前(那时还能看见完整宿主文件系统)。做法是:用配置里的 python_path 跑一段内嵌的 Python 脚本,让解释器自己报告它的 stdlibsite-packagessys.path

关键是"跑得干净"——不能让当前目录、环境变量污染结果。见 discoverPythonLibPaths(internal/static/python_lib_discovery.go:173):

command := exec.Command(pythonPath, "-P", "-c", pythonLibDiscoveryScript)
command.Env = pythonDiscoveryEnv() // 清空 PYTHONPATH/PYTHONHOME 等

两个防污染手段:

  • -P 标志:让 Python 不把脚本所在目录(以及 cwd)加进 sys.path,避免 cwd 里放个假模块被误当成库。
  • pythonDiscoveryEnv()(:314):只透传 PATH/LANG/TMPDIR 等无害变量,而把一串 Python 覆盖变量显式清空:
for _, pythonEnvVar := range []string{
"PYTHONHOME", "PYTHONPATH", "PYTHONUSERBASE", "PYTHONSTARTUP", ...
} {
env = append(env, pythonEnvVar+"=") // 强制置空,不继承宿主设置
}

3.3 内嵌脚本问了什么

内嵌脚本 pythonLibDiscoveryScript(:12)用标准库 sysconfigsite 收集路径:

来源收的是什么
sysconfig.get_path("stdlib"/"platstdlib"/"purelib"/"platlib")标准库 + 平台库 + site-packages
sys.path解释器实际的导入搜索路径(含绝对路径项)
site.getsitepackages() / getusersitepackages()各类 site-packages
stdlib 目录旁的 python3x.zip有些安装把标准库打进 zip,也要收

最后脚本 print(json.dumps({...})),Go 侧 json.Unmarshal 解析回 pythonLibDiscoveryResult 结构体(:118)。

3.4 精华:json 必须来自"正宗"stdlib——防同名包投毒

这是本节最巧的一处。Go 侧解析 Python 输出用的正是 json;而用户环境里完全可能装一个第三方的、名叫 json 的恶意包,抢在标准库前面被 import。如果放任,发现脚本可能从一个被投毒的 json 里报告路径。

buildPythonLibPaths(internal/static/python_lib_discovery.go:206)因此对 json 做三重校验:

  1. 来源根必须在白名单里 —— json 的导入根,必须是 stdlib / platstdlib / 被批准的 stdlib zip 之一:
if !containsPath(allowedJSONRoots, jsonRootPath) {
return nil, fmt.Errorf("json module origin %q is not provided by the interpreter standard library", ...)
}
  1. 相对路径必须"正宗" —— isCanonicalJSONRelativePath(:361)要求 json 相对其根的路径只能是 json.pyjson、或 json/...:
if cleanPath == "json.py" || cleanPath == "json" {
return true
}
return strings.HasPrefix(cleanPath, "json"+string(os.PathSeparator))
  1. Python 侧也先算好这个相对路径(脚本里的 canonical_json_relative_path,:30)并用 os.path.realpath 解掉符号链接,双端呼应。

一句话:只有从标准库正确位置来的那个 json,才被信任;任何第三方同名包都会让发现直接失败——宁可起不来,也不吃被投毒的路径。

3.5 坑:.so cannot open shared object file

FAQ.md:3 记录的经典报错("xxx.so: cannot open shared object file")根因就在这里:chroot 后,子进程只能 import 被拷进 /var/sandbox/sandbox-python/ 的东西;某个 C 扩展依赖的 .so 若不在发现到的库路径里,笼子里就找不到。

FAQ 给的正解不是"加拷贝路径",而是把包装进 python_path 指向的那个 Python 环境,让发现自然覆盖到它——保证 python_path -c 'import json' 在沙箱启动前能成功(FAQ.md:15)。


4. 核心原理二:沙箱根目录的物化

发现出的只是一串路径清单;要变成子进程能 chroot 进去的真实目录,得把这些路径的内容拷进 /var/sandbox/sandbox-python

4.1 PreparePythonDependenciesEnv + env.sh

PreparePythonDependenciesEnv(internal/core/runner/python/env.go:19)遍历 config.PythonLibPaths,对每个路径调 env.sh,把它拷到常量 LIB_PATH = "/var/sandbox/sandbox-python"(python/setup.go:27):

for _, lib_path := range config.PythonLibPaths {
if _, err := os.Stat(lib_path); err != nil {
slog.Warn("python lib path is not available", "path", lib_path)
continue // 缺了就跳过,不硬失败
}
exec_cmd := exec.Command("bash", path.Join(root_path, "env.sh"), lib_path, LIB_PATH)
...
}

env.sh(python/env.sh)的拷法有讲究,copy_and_link 按文件类型分三种处理:

源文件类型处理
符号链接cp -P 原样拷链接本身
设备文件cpchmod 444
普通文件优先 ln -f 建硬链接(省空间),失败退回 cp + chmod 444(只读)

对目录则 find -L "$src" -type f,l(env.sh:40)——-L跟随符号链接,把逻辑路径背后的真实文件都物化进来。这样 chroot 后,解释器沿它记住的 sys.path 逻辑名去找,就能命中这些拷进来的文件。

4.2 每次请求的临时根:TempDirRunner.WithTempDir

/var/sandbox/sandbox-python共享模板;但每次请求要一个隔离的、可丢弃的根。这由 TempDirRunner.WithTempDir(internal/core/runner/temp_dir.go:13)完成:

tmpDir := path.Join(basedir, "tmp", "sandbox-"+uuid.String()) // 每次一个新 uuid 目录
os.Mkdir(tmpDir, 0755)
for _, file_path := range paths { // 把指定文件 cp 进临时目录,保持原路径结构
exec.Command("cp", "-r", file_path, path.Join(tmpDir, file_path)).Run()
}
os.Chdir(tmpDir) // chdir 过去 —— 之后子进程 chroot(".") 就锁在这
closures(tmpDir)

注意串起来的逻辑:WithTempDir 把 cwd 切到临时目录 → 子进程被 exec 后 chroot(".")(§1)→ 那个临时目录就成了笼子的根。WithTempDir 既被 PreparePythonDependenciesEnv 用(准备阶段),也被 PythonRunner.Run / NodeJsRunner.Run 用(运行阶段)。


5. 核心原理三:依赖安装与网络子系统

用户代码常要 import requests;沙箱得预先把这些包装进 python_path 的环境里(装完自然被 §3 的发现、§4 的物化带进笼子)。

5.1 InstallDependencies:pip3 + 镜像源

InstallDependencies(internal/core/runner/python/setup.go:104)把 requirements 写进临时目录,跑 pip3 install -r,并支持配置镜像源加速:

args := []string{"install", "-r", "requirements.txt"}
if pipMirrorURL != "" {
args = append(args, "-i", pipMirrorURL)
parsedUrl, _ := url.Parse(pipMirrorURL)
args = append(args, "--trusted-host", parsedUrl.Host) // 镜像主机加白
}
cmd := exec.Command("pip3", args...)

装完后,ExtractOnelineDepency(:81)按 == / >= / <= / ~= 分隔符解析出包名与版本,逐个登记进依赖表(SetupDependency):

delimiters := []string{"==", ">=", "<=", "~="}
for _, delimiter := range delimiters {
if strings.Contains(dependency, delimiter) {
parts := strings.Split(dependency, delimiter)
return parts[0], parts[1] // 包名, 版本
}
}

5.2 默认网络依赖

dependencies/network.goinit 让沙箱默认就带上 HTTP 客户端库:

func init() {
SetupDependency("httpx", "")
SetupDependency("requests", "")
}

这解释了为什么 defaultSystemLibRequirements(internal/static/config_default_amd64.go:5)一定要把 DNS/证书相关文件拷进笼子——否则开了网络也发不出请求:

"/etc/ssl/certs/ca-certificates.crt", // TLS 根证书
"/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf", // 名字解析
"/run/systemd/resolve/stub-resolv.conf", "/etc/localtime", ...

5.3 后台定时刷新

server.goinitDependencies(internal/server/server.go:59)在后台 goroutine 里起一个 ticker,按 PythonDepsUpdateInterval 周期性重装 + 重新物化,让沙箱库保持最新:

go func() {
ticker := time.NewTicker(tickerDuration)
for range ticker.C {
updatePythonDependencies(dependencies) // 再跑 InstallDependencies + PreparePythonDependenciesEnv
}
}()

Run()(:113)本身也把 initDependencies 放进 goroutine——因为首次装依赖很慢,不能阻塞 HTTP 服务起来。


6. 对照:Node.js runner 的两条差异

Python 和 Node 是同一套思路的两条实现:都要"把最小文件集拷进临时目录当根 + 加载一个 Go c-shared 库装 seccomp/chroot + 跑用户代码"。差异在于"文件清单怎么来"和"怎么调那个库"。

6.1 文件清单:发现 vs 显式列表

Python 靠 §3 的运行时发现得到一长串库路径;Node 则写死一份显式清单 REQUIRED_FS(internal/core/runner/nodejs/nodejs.go:29):

var REQUIRED_FS = []string{
path.Join(LIB_PATH, PROJECT_NAME, "node_temp"),
path.Join(LIB_PATH, LIB_NAME), // nodejs.so
"/etc/ssl/certs/ca-certificates.crt",
"/etc/nsswitch.conf", "/etc/resolv.conf",
"/run/systemd/resolve/stub-resolv.conf", "/etc/hosts",
}

NodeJsRunner.Run(:60)把它直接交给同一个 WithTempDir:p.WithTempDir("/", REQUIRED_FS, ...)。为什么 Node 能写死?因为 Node 运行时是自包含的,不像 Python 那样把标准库/C 扩展散落在依 Python 版本而变的多处;所以枚举几份系统文件(证书、DNS)就够了。

6.2 加载 Go 库:ctypes vs koffi

两边都靠一个编译好的 Go c-shared 库(python.so / nodejs.so)在子进程内部装 seccomp 并 chroot,但外语调用方式不同:

PythonNode.js
引导脚本prescript.pyprescript.js
FFI 机制标准库 ctypes第三方 koffi
加载库ctypes.CDLL("./python.so")koffi.load('./var/sandbox/sandbox-nodejs/nodejs.so')
调 seccomplib.DifySeccomp(uid, gid, net)difySeccomp(uid, gid, options['enable_network'])
跑用户代码exec(compile(code, ...))eval(code)
代码来源读文件描述符 3(os.fdopen(3))读文件描述符 3(fs.readFileSync(3))

Node 引导脚本 prescript.js 全貌很短,核心几行:

const koffi = require('koffi')
const lib = koffi.load('./var/sandbox/sandbox-nodejs/nodejs.so')
const difySeccomp = lib.func('void DifySeccomp(int, int, bool)')
...
difySeccomp(uid, gid, options['enable_network']) // 装 seccomp + chroot(在 Go 侧)
delete process.env.GODEBUG
const code = fs.readFileSync(3, 'utf8')
eval(code) // 跑用户代码

对照 prescript.py(核心几行):

lib = ctypes.CDLL("./python.so")
lib.DifySeccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool]
os.chdir(running_path)
lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}}) # 装 seccomp + chroot
os.environ.pop("GODEBUG", None)
with os.fdopen(3, "rb") as code_fd:
code = code_fd.read().decode("utf-8")
exec(compile(code, "<fd3>", "exec"))

真正的 Chroot(".") 和 seccomp 都在 Go 库里(add_seccomp.goInitSeccomp,§1),对两种语言是同一份逻辑——引导脚本只负责"加载库、把 uid/gid/网络开关传进去、然后跑代码"。

细节:两边都设 GODEBUG=decoratemappings=0,...(nodejs.go:87python.go:72)——这是给 c-shared 库里的 Go runtime 用的,关掉 seccomp 生效后可能触发系统调用的后台特性;引导脚本装完 seccomp 后立刻把它删掉。


7. 边界与局限

  • 发现依赖 python_path 正确:若配置的解释器起不来、或 import json 拿不到正宗标准库,启动直接失败(config.go:101 的错误上抛)。这是刻意的"fail closed"。
  • 物化是拷贝,不是挂载:笼子里的库是启动时的快照。宿主机后来改了某个库,笼子里不会跟着变,只有等定时刷新(§5.3)重跑。
  • WithTempDircp -r:大依赖树的临时目录构建有 I/O 成本;这是隔离性换来的代价。
  • Node 的清单是硬编码的:换基础镜像若把系统文件挪了位置,得改 REQUIRED_FS,不像 Python 那样自适应。

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

主题文件符号
Python 库路径发现入口internal/static/python_lib_discovery.godiscoverPythonLibPaths
发现结果校验 / json 防投毒internal/static/python_lib_discovery.gobuildPythonLibPathsisCanonicalJSONRelativePath
清空 Python env 的发现环境internal/static/python_lib_discovery.gopythonDiscoveryEnv
内嵌发现脚本internal/static/python_lib_discovery.gopythonLibDiscoveryScript
触发发现、写入配置internal/static/config.goInitConfig(:101)
系统文件固定清单internal/static/config_default_amd64.godefaultSystemLibRequirements
沙箱根物化internal/core/runner/python/env.goPreparePythonDependenciesEnv
拷贝/硬链接脚本internal/core/runner/python/env.shcopy_and_link
临时根目录构建internal/core/runner/temp_dir.goTempDirRunner.WithTempDir
依赖安装 / 版本解析internal/core/runner/python/setup.goInstallDependenciesExtractOnelineDepency
默认网络依赖internal/core/runner/python/dependencies/network.goinit
后台定时刷新internal/server/server.goinitDependenciesupdatePythonDependencies
Node 显式文件清单 + 运行internal/core/runner/nodejs/nodejs.goREQUIRED_FSNodeJsRunner.Run
Node koffi 引导internal/core/runner/nodejs/prescript.jskoffi.load / eval(code)
Python ctypes 引导internal/core/runner/python/prescript.pyctypes.CDLL / exec(compile(...))
chroot + seccomp(两语言共用逻辑)internal/core/lib/python/add_seccomp.goInitSeccomp

相邻章节: index.md 全景与阅读地图 · 01-request-lifecycle.md 请求生命周期 · 02-sandbox-core.md 沙箱核心 · 03-seccomp-chroot.md 两道锁 · 05-cleverness-and-boundaries.md 巧妙与边界