Logo
热心市民王先生

容器化执行机制深度分析

Docker 容器运行时 执行模型 安全边界

深入分析 NanoClaw 的容器化执行机制,包括 Docker 运行时配置、容器启动流程、Dockerfile 结构、stdin 协议设计,以及临时容器与常驻容器的架构选择。

2.1 容器运行时选择

2.1.1 运行时抽象层

NanoClaw 实现了一个轻量级的容器运行时抽象层 (src/container-runtime.ts):

/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';

/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(
  hostPath: string,
  containerPath: string,
): string[] {
  return ['-v', `${hostPath}:${containerPath}:ro`];
}

/** Returns CLI args for a read-write bind mount. */
export function readWriteMountArgs(
  hostPath: string,
  containerPath: string,
): string[] {
  return ['-v', `${hostPath}:${containerPath}`];
}

关键设计决策:

  • 默认 Docker - 跨平台支持(macOS、Linux、Windows WSL2)
  • macOS 可选 Apple Container - 通过 /convert-to-apple-container 技能切换
  • CLI 封装 - 直接调用 docker 命令,不使用 Docker SDK

2.1.2 运行时配置

// 容器运行时的关键配置
const CONTAINER_IMAGE = 'nanoclaw-agent:latest';
const TIMEZONE = process.env.TZ || 'Asia/Shanghai';

// 非 root 执行
const hostUid = process.getuid?.() ?? 1000;
const hostGid = process.getgid?.() ?? 1000;

// 容器启动参数
const containerArgs = [
  'run',
  '-i',                    // 交互模式(stdin)
  '--rm',                  // 执行后自动销毁
  `--name=${containerName}`,
  '-e', `TZ=${TIMEZONE}`,  // 时区设置
  '--user', `${hostUid}:${hostGid}`,  // 非 root 用户
  ...mountArgs,            // 挂载配置
  CONTAINER_IMAGE
];

2.2 Dockerfile 分析

2.2.1 基础镜像选择

FROM node:22-slim

选择理由:

  • Node.js 22 - LTS 版本,支持最新特性
  • slim 变体 - 最小化镜像体积(~400MB vs alpine ~200MB)
  • Debian base - 兼容性优于 Alpine(musl libc 问题)

2.2.2 系统依赖安装

RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    libgbm1 \
    libnss3 \
    libxkbcommon0 \
    libxshmfence1 \
    xdg-utils \
    && rm -rf /var/lib/apt/lists/*

依赖用途:

  • chromium - 浏览器自动化(通过 agent-browser)
  • fonts-liberation - 字体渲染支持
  • libgbm1, libnss3 - Chromium 图形渲染依赖
  • xdg-utils - 桌面集成工具

优化措施:

  • 单 RUN 层减少镜像层数
  • 清理 apt 缓存减小体积

2.2.3 全局工具安装

RUN npm install -g agent-browser @anthropic-ai/claude-code

预装工具:

工具用途版本
agent-browser浏览器自动化(Playwright 封装)latest
@anthropic-ai/claude-codeClaude Agent SDKlatest

为什么全局安装?:

  • 避免每次启动容器都安装依赖
  • 确保容器内工具版本一致
  • 减小容器启动延迟

2.2.4 用户与工作目录

USER node
WORKDIR /workspace/group

安全考虑:

  • 非 root 用户 - node 用户(uid 1000)
  • 工作目录 - /workspace/group(挂载点)

2.2.5 入口点设计

ENTRYPOINT ["/app/entrypoint.sh"]

entrypoint.sh 功能:

  1. 从 stdin 读取 JSON 配置
  2. 解析 secrets 并设置环境变量
  3. 启动 Claude Agent SDK
  4. 流式输出到 stdout

2.3 容器启动流程

2.3.1 完整启动序列

export async function runContainerAgent(
  group: RegisteredGroup,
  input: ContainerInput,
  onProcess: (proc: ChildProcess, containerName: string) => void,
  onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<ContainerOutput> {
  
  // Step 1: 构建挂载配置
  const mounts = buildVolumeMounts(group, input.isMain);
  
  // Step 2: 生成容器名称(唯一性保证)
  const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
  const containerName = `nanoclaw-${safeName}-${Date.now()}`;
  
  // Step 3: 构建 Docker CLI 参数
  const containerArgs = buildContainerArgs(mounts, containerName);
  
  // Step 4: 生成容器进程
  const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
    stdio: ['pipe', 'pipe', 'pipe'],
  });
  
  // Step 5: 设置超时清理
  const timeout = setTimeout(() => {
    execSync(`docker kill ${containerName}`);
  }, CONTAINER_TIMEOUT_MS);
  
  // Step 6: 通过 stdin 传递配置(包括 secrets)
  container.stdin.write(JSON.stringify(input));
  container.stdin.end();
  
  // Step 7: 流式处理输出
  const outputChunks: string[] = [];
  container.stdout.on('data', async (chunk) => {
    outputChunks.push(chunk.toString());
    if (onOutput) {
      await onOutput(parseOutput(chunk));
    }
  });
  
  // Step 8: 等待容器退出
  const exitCode = await waitForExit(container);
  clearTimeout(timeout);
  
  return { exitCode, output: outputChunks.join('') };
}

2.3.2 容器命名规范

nanoclaw-{group-folder}-{timestamp}

示例:
- nanoclaw-whatsapp-main-1741334567890
- nanoclaw-telegram-bot-1741334589012

命名设计理由:

  • 唯一性 - 时间戳确保不会冲突
  • 可追溯 - 从名称可识别所属群组
  • 易清理 - 可通过 docker ps --filter name=nanoclaw- 查找

2.3.3 孤儿容器清理

export function cleanupOrphans(): void {
  try {
    const output = execSync(
      `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
      { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
    );
    const orphans = output.trim().split('\n').filter(Boolean);
    for (const name of orphans) {
      execSync(`docker stop ${name}`);
      execSync(`docker rm ${name}`);
    }
  } catch (err) {
    logger.warn({ err }, 'Failed to clean up orphaned containers');
  }
}

清理触发时机:

  • 主进程启动时
  • 定期后台任务(每 5 分钟)
  • 容器超时强制杀死后

2.4 stdin 协议设计

2.4.1 输入格式

interface ContainerInput {
  // Agent 提示词
  prompt: string;
  
  // 群组信息
  groupId: string;
  folder: string;
  isMain: boolean;
  
  // 会话恢复
  sessionId?: string;
  resumeAt?: string;  // 上一条 assistant 消息的 UUID
  
  // 凭据(通过 stdin 传递,不挂载)
  secrets: {
    CLAUDE_CODE_OAUTH_TOKEN?: string;
    ANTHROPIC_API_KEY?: string;
    ANTHROPIC_BASE_URL?: string;
    ANTHROPIC_AUTH_TOKEN?: string;
  };
  
  // 额外配置
  additionalMounts?: AdditionalMount[];
  mcpServers?: Record<string, MCPServerConfig>;
}

2.4.2 凭据处理机制

/**
 * 从 .env 文件读取允许传递的 secrets
 * Secrets 永远不会写入磁盘或挂载为文件
 */
function readSecrets(): Record<string, string> {
  return readEnvFile([
    'CLAUDE_CODE_OAUTH_TOKEN',
    'ANTHROPIC_API_KEY',
    'ANTHROPIC_BASE_URL',
    'ANTHROPIC_AUTH_TOKEN',
  ]);
}

// 在 runContainerAgent 中:
input.secrets = readSecrets();
container.stdin.write(JSON.stringify(input));  // 通过 stdin 传递
container.stdin.end();
delete input.secrets;  // 从日志中移除,防止泄露

安全考虑:

  • ✅ 不会出现在 /proc/{pid}/environ
  • ✅ 不会泄露给容器的子进程
  • ✅ 容器销毁后无残留
  • ⚠️ 容器内 agent 仍然可以通过 SDK 状态获取

2.4.3 输出流处理

// 输出流解析
container.stdout.on('data', async (chunk) => {
  const output = parseOutput(chunk);
  
  // 实时发送到响应层
  if (onOutput) {
    await onOutput(output);
  }
  
  // 累积用于日志
  outputChunks.push(chunk.toString());
});

// 输出格式
interface ContainerOutput {
  type: 'message' | 'error' | 'done';
  content: string;
  sessionId?: string;
  lastAssistantUuid?: string;
}

2.5 临时容器 vs 常驻容器

2.5.1 NanoClaw 的选择:临时容器

docker run -i --rm nanoclaw-agent:latest

优点:

  • 无状态 - 每次执行都是干净环境
  • 安全性 - 恶意代码无法持久化
  • 简化运维 - 不需要管理容器生命周期

缺点:

  • 启动延迟 - 每次 ~1-2 秒
  • 重复安装 - 无法利用容器内缓存(除了镜像层)
  • 资源开销 - 频繁创建/销毁容器

2.5.2 替代方案:常驻容器

如果使用常驻容器:

# 启动一个长期运行的容器
docker run -d --name nanoclaw-agent nanoclaw-agent:latest

# 通过 docker exec 执行命令
docker exec -i nanoclaw-agent claude-code --prompt "..."

优点:

  • 快速启动 - ~100ms vs 1-2 秒
  • 状态保持 - 可以利用 npm 缓存等

缺点:

  • 状态污染风险 - 恶意代码可能持久化
  • 资源占用 - 容器常驻内存
  • 管理复杂 - 需要健康检查、重启逻辑

2.5.3 性能对比

指标临时容器 (--rm)常驻容器 + exec
首次启动~1-2 秒~100ms
后续启动~1-2 秒~100ms
内存占用执行时占用常驻 +50MB
安全性高(无状态)中(状态可能污染)
运维复杂度

NanoClaw 的权衡: 选择了安全性而非性能,对于个人用户场景是可接受的。


2.6 挂载配置详解

2.6.1 Main Group 挂载

function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] {
  const mounts: VolumeMount[] = [];

  if (isMain) {
    // 项目根目录(只读)
    mounts.push({
      hostPath: projectRoot,
      containerPath: '/workspace/project',
      readonly: true,
    });
    
    // 遮蔽 .env 文件
    mounts.push({
      hostPath: '/dev/null',
      containerPath: '/workspace/project/.env',
      readonly: true,
    });
  }

  // 群组目录(读写)
  mounts.push({
    hostPath: groupDir,
    containerPath: '/workspace/group',
    readonly: false,
  });

  // Claude 会话目录(读写)
  const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude');
  mounts.push({
    hostPath: groupSessionsDir,
    containerPath: '/home/node/.claude',
    readonly: false,
  });

  // IPC 命名空间(读写)
  const groupIpcDir = resolveGroupIpcPath(group.folder);
  mounts.push({
    hostPath: groupIpcDir,
    containerPath: '/workspace/ipc',
    readonly: false,
  });

  return mounts;
}

2.6.2 Non-Main Group 挂载

if (!isMain) {
  // 仅群组目录(读写)
  mounts.push({
    hostPath: groupDir,
    containerPath: '/workspace/group',
    readonly: false,
  });

  // 全局记忆目录(只读)
  const globalDir = path.join(GROUPS_DIR, 'global');
  if (fs.existsSync(globalDir)) {
    mounts.push({
      hostPath: globalDir,
      containerPath: '/workspace/global',
      readonly: true,
    });
  }
}

2.6.3 挂载层次结构

容器内视角 (Main Group):
/
├── workspace/
│   ├── project/        (ro) - 项目根目录
│   ├── group/          (rw) - 群组目录
│   ├── ipc/            (rw) - IPC 命名空间
│   └── global/         (ro) - 全局记忆
└── home/node/
    └── .claude/        (rw) - Claude 会话

容器内视角 (Non-Main Group):
/
├── workspace/
│   ├── group/          (rw) - 群组目录
│   ├── ipc/            (rw) - IPC 命名空间
│   └── global/         (ro) - 全局记忆
└── home/node/
    └── .claude/        (rw) - Claude 会话

2.7 容器内 Agent 执行

2.7.1 查询循环

// container/agent-runner/src/index.ts
let resumeAt: string | undefined;
try {
  while (true) {
    // Step 1: 执行查询
    const queryResult = await runQuery(
      prompt,
      sessionId,
      mcpServerPath,
      containerInput,
      sdkEnv,
      resumeAt  // 用于恢复会话
    );
    
    // Step 2: 更新会话状态
    if (queryResult.newSessionId) {
      sessionId = queryResult.newSessionId;
    }
    if (queryResult.lastAssistantUuid) {
      resumeAt = queryResult.lastAssistantUuid;
    }

    // Step 3: 检查是否收到关闭信号
    if (queryResult.closedDuringQuery) {
      break;
    }

    // Step 4: 等待下一条 IPC 消息
    const nextMessage = await waitForIpcMessage();
    if (nextMessage === null) {
      break;  // 收到 _close 信号
    }
    prompt = nextMessage;
  }
} finally {
  // Step 5: 清理会话
  await cleanupSession(sessionId);
}

2.7.2 会话恢复机制

// 通过 resumeSessionAt 参数恢复之前的对话上下文
const session = await sdk.sessions.retrieve({
  sessionId,
  resumeSessionAt: resumeAt,  // 上一条 assistant 消息的 UUID
});

设计意图:

  • 支持连续对话(不是每次消息都新建会话)
  • 通过 resumeSessionAt 参数恢复之前的对话上下文
  • 会话转录存储在 data/sessions/{group}/.claude/

2.7.3 允许的工具集

const allowedTools = [
  // 文件系统操作
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
  
  // Bash 执行(在容器内)
  'Bash',
  
  // 网络访问
  'WebSearch', 'WebFetch',
  
  // 任务管理
  'Task', 'TaskOutput', 'TaskStop',
  
  // 团队协作
  'TeamCreate', 'TeamDelete', 'SendMessage',
  
  // 其他工具
  'TodoWrite', 'ToolSearch', 'Skill',
  'NotebookEdit',
  
  // MCP 工具(nanoclaw 自定义)
  'mcp__nanoclaw__*',
];

关键限制:

  • 所有工具都在容器内执行
  • Bash 命令在容器内运行,无法直接影响主机
  • 网络访问不受限制(依赖应用层控制)

2.8 小结

NanoClaw 的容器化执行机制设计精良:

  1. 临时容器模式 - 每次执行都是干净环境,无状态污染风险
  2. stdin 协议 - 凭据通过 stdin 传递,避免环境变量泄露
  3. 显式挂载 - 文件系统访问由 Docker 强制控制
  4. 流式处理 - 实时输出支持,低延迟响应

下一章预告: 第 3 章将深入分析 NanoClaw 的安全隔离设计,包括文件系统隔离、挂载白名单、IPC 授权、凭证保护,以及 Bash 安全钩子的实现细节。


参考资料