位置:首页 > JavaScript > Python 调用 Node.js vm2 沙箱实战:两种接入方案与生产化要点

Python 调用 Node.js vm2 沙箱实战:两种接入方案与生产化要点

时间:2026-08-25  |  作者:电竞小硕  |  阅读:0

目录

  1. 准备环境:先把 Python 与 vm2 的调用链跑通
  2. 方案 A:用 Python 子进程调用 Node,结构最简单
  3. 方案 B:长连接 RPC 模式,适合频繁执行
  4. 生产可用版本:进一步收紧 NodeVM 权限
  5. 怎么选方案:看调用频率,也看你的工程控制力

前言

当业务主流程写在 Python 里,却又要执行来源不受控的 JavaScript 时,关键不只是“怎么调用”,更是“怎样把执行权限压进可控边界”。这篇文章把 Python 调度、Node.js 执行和 vm2 沙箱隔离拆开来看,分别给出一次性子进程与长连接 RPC 两种落地方案,并补上生产环境下更值得关注的安全收紧点。

当业务主流程在 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

对比 Python 子进程调用 Node 与长连接 RPC 的执行路径和适用场景
两种调用方案怎么走用流程和对比卡片呈现两种胶水方案的调用方式、返回路径与适用频率,方便读者先做架构判断。

方案 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,并关闭了多项高风险能力,适合作为更严的基线。

展示 NodeVM 生产配置中 timeout、eval、wasm 与 require 限制的关系图
生产版沙箱限制项把生产版 safe_runner.js 的关键限制项拆成四块。

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: falsedeny: ['*'],尽量不让脚本接触宿主模块系统。

如果你的目标是“给用户一个可计算表达式或少量受控逻辑的执行环境”,这类限制比开放能力更重要。它并不追求让沙箱像完整 Node 运行时那样什么都能做,而是尽量把执行面压缩到业务真正需要的那一小块。

怎么选方案:看调用频率,也看你的工程控制力

把前面的代码放在一起看,选型并不复杂。

什么时候优先选方案 A

  • 你的调用频率不高;
  • 先要快速上线一个稳定版本;
  • 希望每次执行彼此隔离,便于回收进程状态;
  • 希望 Python 侧实现尽量简单,维护成本更低。

什么时候考虑方案 B

  • 你需要连续、大量执行 JS 代码;
  • 不能接受反复启动 Node 带来的额外耗时;
  • 愿意自己维护一套更完整的请求响应协议;
  • 能处理并发、缓冲、超时、进程存活检测等工程细节。

还需要保持的判断

原始材料里把 vm2 描述为“绝对防逃逸”,这个说法在工程实践里不宜直接照收。更稳妥的理解是:vm2 提供了比直接执行 JS 更强的隔离与限制能力,但上线前仍然应该结合超时、进程级隔离、输入校验、日志审计和版本维护来做整体防护,而不是把安全完全寄托在单一库上。

真正可用的结论可以归纳为三条:一是 Python 作为主流程语言,通过子进程或长连接调用 Node.js,是一条清晰且现实的跨语言路线;二是 vm2 负责执行不可信 JS 时,最先要做的是收紧权限而不是扩展能力;三是简单场景优先一次性子进程,高频场景再考虑 RPC 常驻进程,这样更符合多数项目的实施成本和维护节奏。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多