返回博客

nsjail 沙箱深潜:安全执行用户自定义函数

1. [为什么需要沙箱?](#1-为什么需要沙箱)

Coomia发布于 2025年11月29日12 分钟阅读
分享本文Twitter / X

系列:S8 技术组件深潜 · 第 20 篇 | 难度:高级 | 阅读时间:20 分钟

nsjail 沙箱深潜:安全执行用户自定义函数

#TL;DR

  • nsjail 是 coomia-dip Function Runtime(Agent Runtime Layer)的安全沙箱组件,为用户自定义函数(UDF)提供 Linux namespace 级别的进程隔离、资源限制和网络控制
  • 本文深入分析 nsjail 的 6 种 Linux namespace 隔离机制、seccomp-bpf 系统调用过滤、cgroup 资源限制、以及 coomia-dip 的安全执行模型
  • 涵盖文件系统挂载策略、网络隔离、超时控制、安全审计日志、以及与 Docker/gVisor 的对比选型

#目录

  1. 为什么需要沙箱?
  2. nsjail 架构与原理
  3. Linux Namespace 隔离
  4. seccomp-bpf 系统调用过滤
  5. cgroup 资源限制
  6. 文件系统隔离
  7. 网络隔离
  8. coomia-dip 的 Function Runtime 集成
  9. 安全审计与监控
  10. 与 Docker/gVisor 的对比
  11. Key Takeaways

#1. 为什么需要沙箱?

#1.1 UDF 安全风险

coomia-dip 允许用户编写自定义函数(TypeScript/Python),这些代码运行在平台内部,存在以下风险:

风险描述严重度
文件系统访问读取 /etc/passwd、密钥文件严重
网络外联外泄数据到外部服务器严重
资源耗尽无限循环、内存泄漏
进程创建fork bomb 导致系统崩溃
系统调用滥用提权、内核漏洞利用极严重
信息泄露读取其他用户的数据

#1.2 coomia-dip 的安全模型

Code
用户代码 → nsjail 沙箱 → 宿主系统

┌─────────────────────────────────────────┐
│              nsjail 沙箱                 │
│  ┌─────────────────────────────────┐    │
│  │  用户 UDF 进程                   │    │
│  │  ├── PID Namespace (隔离进程)    │    │
│  │  ├── NET Namespace (隔离网络)    │    │
│  │  ├── MNT Namespace (隔离文件)    │    │
│  │  ├── USER Namespace (隔离用户)   │    │
│  │  ├── UTS Namespace (隔离主机名)  │    │
│  │  ├── IPC Namespace (隔离 IPC)    │    │
│  │  ├── seccomp-bpf (系统调用过滤)  │    │
│  │  └── cgroup (CPU/内存/IO 限制)   │    │
│  └─────────────────────────────────┘    │
│                                          │
│  只暴露:stdin/stdout/stderr + 返回值    │
└─────────────────────────────────────────┘

#2. nsjail 架构与原理

#2.1 nsjail 工作流程

Code
1. 解析配置(.cfg 文件或命令行参数)
2. 创建 Linux namespaces (clone(2))
3. 配置 cgroup 资源限制
4. 设置文件系统挂载点
5. 加载 seccomp-bpf 过滤器
6. 切换用户(drop privileges)
7. chroot/pivot_root 到沙箱根目录
8. 执行目标程序
9. 监控资源使用和超时
10. 收集退出状态和输出

#2.2 coomia-dip 的 nsjail 配置

PROTOBUF
// coomia-dip UDF 执行沙箱配置
name: "coomia-dip-udf-sandbox"

mode: ONCE  // 执行一次后退出

time_limit: 30        // 30 秒超时
max_cpus: 1           // 1 CPU 核心
rlimit_as: 512        // 最大虚拟内存 512MB
rlimit_cpu: 30        // CPU 时间限制 30 秒
rlimit_fsize: 10      // 最大文件大小 10MB
rlimit_nofile: 64     // 最大打开文件数 64
rlimit_nproc: 1       // 禁止创建子进程

clone_newnet: true     // 网络隔离
clone_newuser: true    // 用户隔离
clone_newns: true      // 文件系统隔离
clone_newpid: true     // PID 隔离
clone_newipc: true     // IPC 隔离
clone_newuts: true     // UTS 隔离
clone_newcgroup: true  // cgroup 隔离

uidmap {
    inside_id: "1000"
    outside_id: "65534"   // nobody
    count: 1
}

gidmap {
    inside_id: "1000"
    outside_id: "65534"
    count: 1
}

mount {
    src: "/usr/lib/python3.12"
    dst: "/usr/lib/python3.12"
    is_bind: true
    rw: false
}

mount {
    src: "/tmp/udf-input"
    dst: "/input"
    is_bind: true
    rw: false
}

mount {
    src: "/tmp/udf-output"
    dst: "/output"
    is_bind: true
    rw: true
}

mount {
    dst: "/tmp"
    fstype: "tmpfs"
    rw: true
    options: "size=50m"
}

mount {
    dst: "/proc"
    fstype: "proc"
    rw: false
}

seccomp_string: "ALLOW { read, write, exit, exit_group, brk, mmap, munmap, mprotect, futex, clock_gettime, close, fstat, openat, lseek, ioctl, sigaltstack, rt_sigaction, rt_sigprocmask, gettid, getpid, getrandom, set_tid_address, set_robust_list, arch_prctl, prlimit64, sched_getaffinity, sched_yield, poll, epoll_create1, epoll_ctl, epoll_wait, pipe2, newfstatat, access, readlink } DEFAULT KILL"

exec_bin {
    path: "/usr/bin/python3"
    arg: "/input/user_function.py"
}

log_level: WARNING

#3. Linux Namespace 隔离

#3.1 6 种 Namespace

Namespace隔离内容coomia-dip 用途
PID进程 IDUDF 看不到宿主进程
NET网络栈阻止网络外联
MNT文件系统挂载限制可见文件
USER用户/组 ID以非特权用户运行
UTS主机名隔离主机信息
IPC信号量/共享内存阻止 IPC 通信

#3.2 PID Namespace 效果

Code
宿主系统(真实 PID):
  PID 1: systemd
  PID 1234: nsjail
  PID 1235: python3 (UDF)

UDF 视角(隔离 PID):
  PID 1: python3  ← UDF 认为自己是 PID 1
  (看不到其他进程)

#4. seccomp-bpf 系统调用过滤

#4.1 允许的系统调用白名单

Python
# coomia-dip UDF 允许的系统调用(最小化)
ALLOWED_SYSCALLS = {
    # 基础 I/O
    "read", "write", "close", "fstat", "lseek",
    # 内存管理
    "brk", "mmap", "munmap", "mprotect",
    # 进程控制(只读)
    "exit", "exit_group", "getpid", "gettid",
    # 信号处理
    "rt_sigaction", "rt_sigprocmask", "sigaltstack",
    # 时间
    "clock_gettime", "clock_getres",
    # 线程同步
    "futex", "sched_yield",
    # 文件打开(受限路径)
    "openat", "access", "newfstatat",
    # 随机数
    "getrandom",
    # Python 运行时需要
    "arch_prctl", "set_tid_address", "set_robust_list",
    "prlimit64", "sched_getaffinity",
}

# 禁止的关键系统调用
BLOCKED_SYSCALLS = {
    "execve",      # 禁止执行其他程序
    "fork", "vfork", "clone",  # 禁止创建进程
    "socket", "connect", "bind", "listen",  # 禁止网络
    "ptrace",      # 禁止调试其他进程
    "mount", "umount2",  # 禁止挂载
    "chroot", "pivot_root",  # 禁止改变根目录
    "setuid", "setgid",  # 禁止提权
}

#4.2 seccomp 违规处理

Python
# 当 UDF 尝试调用禁止的系统调用时
# nsjail 行为: KILL (发送 SIGSYS 信号,立即终止进程)

# coomia-dip 捕获并记录
class SandboxExecutor:
    async def execute(self, udf_code: str, input_data: dict) -> dict:
        result = await self._run_in_nsjail(udf_code, input_data)

        if result.exit_signal == signal.SIGSYS:
            # seccomp 违规
            await self.audit_log.record(
                event="SECCOMP_VIOLATION",
                udf_id=udf_code.id,
                user_id=udf_code.owner,
                severity="CRITICAL",
            )
            raise SecurityViolationError(
                "UDF attempted a forbidden system call"
            )

#5. cgroup 资源限制

#5.1 资源限额

Python
# coomia-dip UDF 资源限制配置
UDF_RESOURCE_LIMITS = {
    "cpu_time_seconds": 30,        # CPU 时间上限
    "wall_time_seconds": 60,       # 总时间上限(含 I/O 等待)
    "memory_mb": 512,              # 内存上限
    "disk_write_mb": 10,           # 磁盘写入上限
    "open_files": 64,              # 打开文件数上限
    "processes": 1,                # 进程数上限(禁止 fork)
    "cpu_cores": 1,                # CPU 核心数
}

# 按 UDF 类型分级
UDF_TIERS = {
    "basic": {
        "cpu_time_seconds": 10,
        "memory_mb": 128,
    },
    "standard": {
        "cpu_time_seconds": 30,
        "memory_mb": 512,
    },
    "premium": {
        "cpu_time_seconds": 120,
        "memory_mb": 2048,
    },
}

#5.2 cgroup v2 配置

Bash
# nsjail 自动配置 cgroup v2
# /sys/fs/cgroup/coomia-dip-udf-{id}/

# CPU 限制
echo "100000 1000000" > cpu.max  # 10% CPU (100ms/1000ms)

# 内存限制
echo "536870912" > memory.max    # 512MB
echo "536870912" > memory.swap.max  # 禁止 swap

# PID 限制
echo "1" > pids.max              # 只允许 1 个进程

#6. 文件系统隔离

#6.1 挂载策略

Code
沙箱内文件系统布局:

/                          (tmpfs, 只读)
├── /usr/lib/python3.12/   (bind mount, 只读 — Python 运行时)
├── /usr/lib/node_modules/ (bind mount, 只读 — Node.js 运行时)
├── /input/                (bind mount, 只读 — 输入数据)
│   └── user_function.py   (用户代码)
├── /output/               (bind mount, 读写 — 输出目录)
├── /tmp/                  (tmpfs 50MB, 读写 — 临时文件)
└── /proc/                 (procfs, 只读 — 最小化)

不可见:
  /etc/          — 系统配置
  /home/         — 用户目录
  /var/          — 系统数据
  /root/         — root 目录
  宿主机其他所有路径

#6.2 输入/输出协议

Python
class SandboxIO:
    """沙箱 I/O 协议"""

    @staticmethod
    def prepare_input(udf_id: str, input_data: dict) -> str:
        """准备输入目录"""
        input_dir = f"/tmp/nsjail/input/{udf_id}"
        os.makedirs(input_dir, exist_ok=True)

        # 写入用户代码
        with open(f"{input_dir}/user_function.py", "w") as f:
            f.write(generate_wrapper(udf_id))

        # 写入输入数据(JSON)
        with open(f"{input_dir}/data.json", "w") as f:
            json.dump(input_data, f)

        return input_dir

    @staticmethod
    def collect_output(udf_id: str) -> dict:
        """收集输出结果"""
        output_dir = f"/tmp/nsjail/output/{udf_id}"
        result_file = f"{output_dir}/result.json"

        if os.path.exists(result_file):
            with open(result_file) as f:
                return json.load(f)
        raise UDFExecutionError("No output produced")

#7. 网络隔离

#7.1 完全网络隔离

Code
默认模式(NET Namespace):
  UDF 进程无网络接口 → 无法连接任何外部服务

沙箱内 `ip addr` 输出:
  1: lo: <LOOPBACK> mtu 65536
      link/loopback 00:00:00:00:00:00
      (仅有 loopback,且未 UP)

#7.2 受控网络访问(高级模式)

Python
# 某些 UDF 需要访问 coomia-dip 内部 API
# 通过 veth pair 和 iptables 实现受控网络

ALLOWED_ENDPOINTS = [
    "control-Layer:9090",   # gRPC
    "data-Layer:50051",     # Flight SQL
]

# nsjail 配置:
# macvlan_iface: "eth0"
# 配合 iptables 规则只允许访问特定 IP:Port

#8. coomia-dip 的 Function Runtime 集成

#8.1 执行流程

Python
class FunctionRuntime:
    """coomia-dip 函数运行时"""

    def __init__(self):
        self.nsjail_binary = "/usr/bin/nsjail"
        self.config_template = load_config_template()

    async def execute_function(
        self,
        function_id: str,
        code: str,
        input_data: dict,
        timeout: int = 30,
        memory_mb: int = 512,
    ) -> FunctionResult:

        # 1. 准备沙箱
        sandbox_id = f"{function_id}-{uuid4().hex[:8]}"
        input_dir = SandboxIO.prepare_input(sandbox_id, input_data)
        output_dir = f"/tmp/nsjail/output/{sandbox_id}"
        os.makedirs(output_dir, exist_ok=True)

        # 2. 写入用户代码
        self._write_user_code(input_dir, code, input_data)

        # 3. 生成 nsjail 配置
        config = self._generate_config(
            sandbox_id, input_dir, output_dir, timeout, memory_mb
        )
        config_path = f"/tmp/nsjail/config/{sandbox_id}.cfg"
        with open(config_path, "w") as f:
            f.write(config)

        # 4. 执行 nsjail
        start_time = time.monotonic()
        process = await asyncio.create_subprocess_exec(
            self.nsjail_binary,
            "--config", config_path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )

        try:
            stdout, stderr = await asyncio.wait_for(
                process.communicate(),
                timeout=timeout + 5,  # 额外 5 秒缓冲
            )
        except asyncio.TimeoutError:
            process.kill()
            raise FunctionTimeoutError(f"Function timed out after {timeout}s")

        duration = time.monotonic() - start_time

        # 5. 收集结果
        if process.returncode == 0:
            output = SandboxIO.collect_output(sandbox_id)
            return FunctionResult(
                status="success",
                output=output,
                duration_ms=duration * 1000,
                stdout=stdout.decode(),
            )
        else:
            return FunctionResult(
                status="error",
                error=stderr.decode(),
                duration_ms=duration * 1000,
                exit_code=process.returncode,
            )

        # 6. 清理
        finally:
            await self._cleanup(sandbox_id)

#8.2 代码包装器

Python
def generate_wrapper(function_code: str) -> str:
    """生成安全的代码包装器"""
    return f'''
import json
import sys

def main():
    # 读取输入
    with open("/input/data.json") as f:
        input_data = json.load(f)

    # 执行用户函数
    try:
        # 用户代码在受限的全局环境中执行
        user_globals = {{"__builtins__": __builtins__}}
        exec("""{function_code}""", user_globals)

        if "handler" not in user_globals:
            raise RuntimeError("Function must define a 'handler' function")

        result = user_globals["handler"](input_data)

        # 写入输出
        with open("/output/result.json", "w") as f:
            json.dump(result, f)

    except Exception as e:
        error = {{"error": str(e), "type": type(e).__name__}}
        with open("/output/result.json", "w") as f:
            json.dump(error, f)
        sys.exit(1)

if __name__ == "__main__":
    main()
'''

#9. 安全审计与监控

#9.1 审计日志

Python
class SandboxAuditLogger:
    async def log_execution(self, event: SandboxEvent) -> None:
        await self.audit_service.record(
            event_type="UDF_EXECUTION",
            metadata={
                "function_id": event.function_id,
                "user_id": event.user_id,
                "world_id": event.world_id,
                "duration_ms": event.duration_ms,
                "memory_peak_mb": event.memory_peak_mb,
                "exit_code": event.exit_code,
                "seccomp_violations": event.seccomp_violations,
                "sandbox_id": event.sandbox_id,
            },
        )

#9.2 监控指标

Python
SANDBOX_METRICS = {
    "udf_execution_total": Counter("总执行次数"),
    "udf_execution_duration": Histogram("执行耗时"),
    "udf_execution_errors": Counter("执行错误次数"),
    "udf_memory_peak": Histogram("内存峰值"),
    "udf_seccomp_violations": Counter("seccomp 违规次数"),
    "udf_timeout_total": Counter("超时次数"),
}

#10. 与 Docker/gVisor 的对比

维度nsjailDockergVisor
启动时间< 10 ms500-2000 ms100-500 ms
内存开销< 1 MB50-100 MB20-50 MB
隔离强度高(namespace + seccomp)中(共享内核)极高(用户态内核)
系统调用兼容白名单全部部分实现
适用场景短时 UDF长时间服务高安全需求
coomia-dip 选择UDF 执行服务容器未使用

选择 nsjail 的理由

  1. 极低启动延迟:UDF 需要毫秒级启动,Docker 太慢
  2. 极低内存开销:每个 UDF 只需几 MB,Docker 容器需要几十 MB
  3. 精细控制:seccomp 白名单精确控制系统调用
  4. 简单部署:单个二进制文件,无需 daemon

#11. Key Takeaways

主题关键结论
隔离6 种 Linux Namespace 全面隔离
系统调用seccomp-bpf 白名单,默认 KILL
资源cgroup v2 限制 CPU/内存/进程数
文件系统最小化只读挂载 + tmpfs
网络默认完全隔离,可选受控访问
启动< 10ms,适合短时 UDF
安全审计日志 + seccomp 违规监控
选型优于 Docker(速度)和 gVisor(简单)

本篇是 S8 系列的最后一篇。S9 系列将开始源码精读。