当业务主流程在 Python,但又必须执行用户提交或来源不受控的 JavaScript 时,常见做法不是把两边硬揉在一起,而是让 Python 负责调度、Node.js 负责沙箱执行。下面按“怎么搭环境、两种方案怎么选、代码怎样直接跑、生产环境还要收紧哪些边界”的顺序展开,读完你可以判断自己该用一次性子进程,还是保留长连接的 RPC 模式。
准备环境:先把 Python 与 vm2 的调用链跑通
这个需求可以拆成三个环节:Python 发起调用,Node.js 中的 vm2 执行不可信 JS,结果再回传给 Python。先把项目结构和依赖准备好,后面两种方案都基于这套基础环境。
创建项目目录
mkdir python-vm2
cd python-vm2
安装依赖
# 安装 vm2
npm init -y
npm install vm2
# Python 无需额外安装库(内置 subprocess)
这里的关键点很明确:Python 侧先用标准库 subprocess 即可,不需要额外引入第三方桥接库;真正负责执行不可信 JS 的组件,是 Node.js 里的 vm2。

方案 A:用 Python 子进程调用 Node,结构最简单
如果你是第一次做跨语言沙箱调用,或者业务调用频率不高,优先看这一套。它的工作方式是:Python 启动 Node 子进程,把待执行的 JS 代码作为参数传入;Node 用 vm2 执行后,把结果以 JSON 形式写回标准输出;Python 再解析这个 JSON。
Node 胶水脚本:runner.js
const { VM } = require('vm2');
// 从命令行接收 Python 传过来的 JS 代码
const userCode = process.argv[2] || '';
try {
// 创建安全沙箱
const vm = new VM({
timeout: 1000,
allowAsync: false,
sandbox: {
// 你可以给沙箱开放安全变量
PI: 3.14159
}
});
// 执行代码
const result = vm.run(userCode);
// 返回结果给 Python
console.log(JSON.stringify({ success: true, data: result }));
} catch (err) {
// 错误返回
console.log(JSON.stringify({ success: false, error: err.message }));
}
这段脚本做了几件事:
- 使用
VM创建一个基础沙箱。 - 设置
timeout: 1000,限制单次执行时间。 - 设置
allowAsync: false,避免异步能力带来更复杂的控制面。 - 通过
sandbox暴露PI: 3.14159这样的安全变量。 - 把执行结果统一封装成 JSON,方便 Python 侧稳定解析。
Python 调用脚本:main.py
import subprocess
import json
import sys
def run_js_in_vm2(js_code: str):
"""
Python 胶水函数:调用 Node.js + vm2 执行不安全 JS 代码
"""
try:
# 调用 node runner.js "代码"
result = subprocess.run(
[sys.executable.replace("python", "node"), "runner.js", js_code],
capture_output=True,
text=True,
encoding="utf-8",
timeout=3
)
# 解析返回结果
output = result.stdout.strip()
return json.loads(output)
except Exception as e:
return {"success": False, "error": str(e)}
# ====================== 测试 ======================
if __name__ == "__main__":
# 测试 1:安全代码
code1 = "1 + 2 * 3 + PI"
print("执行结果:", run_js_in_vm2(code1))
# 测试 2:沙箱禁止的危险代码(会被拦截)
code2 = "process.exit()"
print("危险代码执行:", run_js_in_vm2(code2))
这一版 Python 胶水层的优点在于好理解、好排错,也容易接进现有 Python 服务。调用逻辑完全围绕一次请求一次执行展开,比较适合:
- 任务触发频率不高的后台处理;
- 先求稳定跑通,再逐步升级的原型或首版服务;
- 希望让 Node.js 进程生命周期足够短、易于回收的场景。
运行方式与返回结果
python main.py
执行结果: {'success': True, 'data': 10.14159}
危险代码执行: {'success': False, 'error': 'ReferenceError: process is not defined'}
从这个结果可以直接看出两件事。第一,普通表达式 1 + 2 * 3 + PI 可以正常执行,并拿到 10.14159。第二,像 process.exit() 这种依赖宿主环境对象的危险调用,在沙箱内没有被暴露,因此会返回 ReferenceError: process is not defined。
对于大多数“低到中频调用”的业务,这种方式已经足够实用。它的主要代价,是每次执行都要启动一次 Node 进程,吞吐量和调用延迟通常不如长连接模式。
方案 B:长连接 RPC 模式,适合频繁执行
如果你的场景是批量执行脚本、在线编辑器实时运行、规则引擎高频计算,方案 A 的进程启动成本就会变得明显。这时更常见的做法,是让 Node.js 常驻运行,Python 通过 stdin/stdout 持续收发消息。
Node 服务端:rpc_server.js
const { VM } = require('vm2');
const readline = require('readline');
// 创建沙箱
const vm = new VM({
timeout: 1000,
allowAsync: true,
sandbox: { PI: 3.14159 }
});
// 读取 Python 传来的消息
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log("VM2 RPC 服务已启动");
rl.on('line', (code) => {
try {
const res = vm.run(code);
console.log(`[RESULT]${JSON.stringify(res)}`);
} catch (err) {
console.log(`[ERROR]${err.message}`);
}
});
和方案 A 相比,这里最大的差别不是 vm2 本身,而是进程模型:
- Node 进程只启动一次;
- 后续多次执行都复用同一个服务进程;
- Python 通过标准输入持续发送待执行代码;
- Node 用
[RESULT]和[ERROR]这种前缀区分响应类型。
Python 客户端:rpc_client.py
import subprocess
import threading
import json
class VM2Client:
def __init__(self):
# 启动 Node 服务
self.proc = subprocess.Popen(
["node", "rpc_server.js"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8"
)
self.response = None
def _read_output(self):
while True:
line = self.proc.stdout.readline()
if not line:
break
self.response = line.strip()
def run(self, code):
# 启动监听线程
threading.Thread(target=self._read_output, daemon=True).start()
# 发送代码
self.proc.stdin.write(code + "n")
self.proc.stdin.flush()
# 等待返回
while self.response is None:
pass
res = self.response
self.response = None
return res
# ====================== 测试 ======================
if __name__ == "__main__":
client = VM2Client()
print(client.run("100 + 200"))
print(client.run("PI * 2"))
print(client.run("process")) # 被沙箱禁止
运行方式
python rpc_client.py
这套模式的核心价值在于减少重复启动进程的开销,尤其适合多次连续执行。但从工程角度看,它也比方案 A 更需要你自己补上协议和并发控制,例如:
- 请求和响应最好带上唯一 ID,避免多次调用时串线;
- 输出格式最好统一成完整 JSON,而不只是字符串前缀;
- 等待响应时不宜使用空转的
while self.response is None: pass; self.proc.stdin.write(code + "n")这一行按字面来看写入的是字符n,如果要按行通信,通常应写入换行符。
也就是说,方案 B 的方向是对的,但它更像适合继续工程化加固的骨架。业务上只要确实有高频调用需求,保留长连接会比反复拉起子进程更有意义。
生产可用版本:进一步收紧 NodeVM 权限
如果你已经确认要把这套模式放进正式服务,单纯“能跑”还不够,权限边界需要继续收紧。原文给出的整合版本使用了 NodeVM,并关闭了多项高风险能力,适合作为更严的基线。

safe_runner.js
const { NodeVM } = require('vm2');
const code = process.argv[2];
const vm = new NodeVM({
timeout: 1500,
eval: false,
wasm: false,
console: 'off',
require: {
builtin: [],
external: false,
deny: ['*']
}
});
try {
const result = vm.run(code);
console.log(JSON.stringify({ ok: 1, data: result }));
} catch (e) {
console.log(JSON.stringify({ ok: 0, err: e.message }));
}
Python 调用函数
import subprocess
import json
def js_sandbox(js_code: str):
res = subprocess.run(
["node", "safe_runner.js", js_code],
capture_output=True,
text=True
)
return json.loads(res.stdout)
# 使用
print(js_sandbox("1 + 2 + 3"))
这版配置值得注意的点主要有四个:
timeout: 1500:继续保留执行时间上限。eval: false:禁用动态求值能力。wasm: false:禁用 WebAssembly。require全面收紧:builtin: []、external: false、deny: ['*'],尽量不让脚本接触宿主模块系统。
如果你的目标是“给用户一个可计算表达式或少量受控逻辑的执行环境”,这类限制比开放能力更重要。它并不追求让沙箱像完整 Node 运行时那样什么都能做,而是尽量把执行面压缩到业务真正需要的那一小块。
怎么选方案:看调用频率,也看你的工程控制力
把前面的代码放在一起看,选型并不复杂。
什么时候优先选方案 A
- 你的调用频率不高;
- 先要快速上线一个稳定版本;
- 希望每次执行彼此隔离,便于回收进程状态;
- 希望 Python 侧实现尽量简单,维护成本更低。
什么时候考虑方案 B
- 你需要连续、大量执行 JS 代码;
- 不能接受反复启动 Node 带来的额外耗时;
- 愿意自己维护一套更完整的请求响应协议;
- 能处理并发、缓冲、超时、进程存活检测等工程细节。
还需要保持的判断
原始材料里把 vm2 描述为“绝对防逃逸”,这个说法在工程实践里不宜直接照收。更稳妥的理解是:vm2 提供了比直接执行 JS 更强的隔离与限制能力,但上线前仍然应该结合超时、进程级隔离、输入校验、日志审计和版本维护来做整体防护,而不是把安全完全寄托在单一库上。
真正可用的结论可以归纳为三条:一是 Python 作为主流程语言,通过子进程或长连接调用 Node.js,是一条清晰且现实的跨语言路线;二是 vm2 负责执行不可信 JS 时,最先要做的是收紧权限而不是扩展能力;三是简单场景优先一次性子进程,高频场景再考虑 RPC 常驻进程,这样更符合多数项目的实施成本和维护节奏。







