容器化执行机制深度分析
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-code | Claude Agent SDK | latest |
为什么全局安装?:
- 避免每次启动容器都安装依赖
- 确保容器内工具版本一致
- 减小容器启动延迟
2.2.4 用户与工作目录
USER node
WORKDIR /workspace/group
安全考虑:
- 非 root 用户 -
node用户(uid 1000) - 工作目录 -
/workspace/group(挂载点)
2.2.5 入口点设计
ENTRYPOINT ["/app/entrypoint.sh"]
entrypoint.sh 功能:
- 从 stdin 读取 JSON 配置
- 解析 secrets 并设置环境变量
- 启动 Claude Agent SDK
- 流式输出到 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 的容器化执行机制设计精良:
- 临时容器模式 - 每次执行都是干净环境,无状态污染风险
- stdin 协议 - 凭据通过 stdin 传递,避免环境变量泄露
- 显式挂载 - 文件系统访问由 Docker 强制控制
- 流式处理 - 实时输出支持,低延迟响应
下一章预告: 第 3 章将深入分析 NanoClaw 的安全隔离设计,包括文件系统隔离、挂载白名单、IPC 授权、凭证保护,以及 Bash 安全钩子的实现细节。