跳到主要内容

模板构建:把 Dockerfile 式 DSL 变成可秒起的沙箱镜像

30 秒导读: E2B 的沙箱要"秒起",靠的是提前把你要的软件环境烤成一张模板镜像(template)。 本章讲这张镜像怎么用一段链式代码(或一个 Dockerfile)描述出来、怎么按"每一层的内容指纹" 去重上传、怎么触发远端构建并盯着日志,最后拿到一个 templateId —— 之后 第 1 章Sandbox.create(templateId) 就能瞬间拉起一台预装好的机器。

本章聚焦构建期(把镜像做出来)。运行期怎么连进沙箱、跑命令、读写文件,见 02/03/04;本章不重复。


1. 这是什么(零基础也能懂)

一句话定义: 模板 = 一台自定义沙箱的"出厂镜像"。你先把一台机器该装什么、该放哪些文件、 启动时跑什么命令都写清楚,E2B 帮你构建成镜像并存起来;之后每次开沙箱都从这张镜像快照复制, 所以能秒起。

解决什么问题 / 给谁用。 假设你在做一个 AI agent,它每次执行任务都要一台装好 Python + pandas + 你自己代码的干净机器。如果每次开机再 pip install,既慢又不稳定。 模板让你装一次、烤成镜像,之后开一万台都是现成的。

默认模板 vs 自定义模板。 E2B 自带一个通用基础镜像 e2bdev/base (见 packages/js-sdk/src/template/index.ts:58defaultBaseImage)。当默认镜像不够用 ——缺某个系统包、要预置一份数据、要装私有依赖——你就自己定义一张模板。

用起来什么样。 一段链式代码就是一张模板:

// 示意,非源码:一张最小自定义模板
import { Template } from 'e2b'

const template = Template()
.fromPythonImage('3.12') // 基于 python:3.12 官方镜像
.copy('requirements.txt', '.') // 把本地文件拷进镜像
.pipInstall() // 安装 requirements.txt 里的依赖
.setReadyCmd('echo ready') // 就绪探针:这条命令退出 0 就算装好了

// 构建并注册到 E2B,拿到可用的模板名
await Template.build(template, 'my-python-env:v1.0')

一句话直觉/类比。 把它当成"代码写的 Dockerfile":fromPythonImage 就是 FROMcopy 就是 COPYrunCmd 就是 RUN。区别在于——它最终不是在你本地 docker build,而是把 一份构建计划 + 缺失的文件上传到 E2B 编排器,由远端构建,且按层缓存,改一行只重跑那一层。


2. 顶层全景(一次 build 大概怎么转)

模板构建就是把"一段 DSL"变成"一个 templateId"。中间要经过:定义 → 序列化 → 按层 hash 查缓存 → 只传缺失文件 → 触发构建 → 轮询日志

先看主要部件各干什么:

部件干什么在哪个文件
Template() / TemplateBase链式 DSL,收集每一步 Instructionsrc/template/index.ts:1343 / :55
parseDockerfile把 Dockerfile 文本翻成同样的 DSL 调用src/template/dockerfileParser.ts:48
calculateFilesHash给每个 COPY 步骤算"内容指纹",用于去重/缓存src/template/utils.ts:202
buildApi.ts对编排器 REST(v3/v2 templates、files/{hash}、builds、status)的封装src/template/buildApi.ts
waitForBuildFinish轮询构建状态、把日志喂给回调src/template/buildApi.ts:296
ReadyCmd 系列生成"就绪探针"shell 命令src/template/readycmd.ts
LogEntry*构建日志事件(含 Start/End 计时)src/template/logger.ts

主线走一遍(高层,不进代码)——Template.build() 内部的顺序(index.ts:127 → 私有 build:1109):

你的 DSL / Dockerfile


① TemplateBase 收集 instructions[](COPY/RUN/ENV/WORKDIR/USER)


② requestBuild → POST /v3/templates 拿到 templateID + buildID(先建壳)


③ instructionsWithHashes():给每个 COPY 算 filesHash(内容指纹)


④ 对每个 COPY:GET /templates/{id}/files/{hash}
│ ├─ present=true → 已缓存,跳过上传
│ └─ present=false → PUT 到返回的预签名 URL(打成 tar.gz 上传)

⑤ triggerBuild → POST /v2/templates/{id}/builds/{buildID} 把"构建计划(steps)"提交


⑥ waitForBuildFinish:轮询 GET .../builds/{buildID}/status,边拉日志边判 ready/error


BuildInfo { templateId, buildId, name, tags } → Sandbox.create(templateId)

一句话记住这张图:先建壳拿 ID,再按文件指纹只补缺失的层,最后提交计划并盯日志。


3. 核心原理(逐个机制,由浅入深)

3.1 链式 DSL:每个方法只是往 instructions[] 里推一步

它要解决的小问题: 怎么让"描述一台机器"读起来像顺手写脚本,而不是拼一个大 JSON。

思路/直觉: Template() 返回一个 TemplateBase 实例,每个方法都改自身状态并 return this, 于是可以一路 .copy().runCmd().pipInstall() 链下去。方法分三类状态,用 TypeScript 接口约束顺序:

阶段接口典型方法含义
选基底TemplateFromImagefromImage / fromPythonImage / fromTemplate / fromDockerfile必须先定"从哪张镜像起"
加工TemplateBuildercopy / runCmd / aptInstall / setEnvs / gitClone往里装东西
收尾TemplateFinalsetStartCmd / setReadyCmd定启动/就绪命令,之后只能 build

接口定义见 types.ts:223TemplateFromImage)、:394TemplateBuilder)、:780TemplateFinal)。

真实实现: 大多数"动作"方法本质是往数组推一条 Instruction。以 runCmd 为例 (index.ts:705):

this.instructions.push({
type: InstructionType.RUN,
args,
force: this.forceNextLayer, // 见 3.4 缓存
})
this.collectStackTrace() // 记一条调用栈,出错时定位到你这行
return this

指令类型只有五种(types.ts:182 InstructionType):COPY / ENV / RUN / WORKDIR / USER。 像 aptInstallpipInstallgitCloneremovemakeDir 这些"高级"方法, 最终都被翻译成一条 RUN(例如 aptInstall 拼出 apt-get update && apt-get install -y ..., 见 index.ts:832),并不新增指令种类。

关键细节: 每推一条指令都配一条 collectStackTrace()index.ts:973)。这样当第 4 步远端 构建在"第 N 步"失败时,SDK 能用 getBuildStepIndexutils.ts:440)把步号映射回你源码里的那一行 再抛错,而不是甩给你一坨远端日志。

3.2 Dockerfile 解析:把熟悉的语法翻成同一套 DSL

它要解决的小问题: 很多人已经有 Dockerfile,不想改写成链式代码。

思路/直觉: fromDockerfile() 不另起炉灶——它把 Dockerfile 逐条翻译成对同一个 builder 的方法调用, 所以后续所有缓存/上传逻辑完全复用。

真实实现: parseDockerfiledockerfileParser.ts:48)用 dockerfile-ast 解析文本, 然后遍历指令,RUN→runCmdCOPY/ADD→copyENV/ARG→setEnvsCMD/ENTRYPOINT→setStartCmddockerfileParser.ts:101 起的 switch)。

关键边界(重要):

  • 不支持多阶段构建。 一旦发现 >1 个 FROM 就直接抛错(dockerfileParser.ts:78)。
  • EXPOSEVOLUME静默跳过:136/:140),未知指令只 console.warn:150)。
  • CMD/ENTRYPOINT 会被转成 start command,并配一个默认 20 秒的 waitForTimeout 就绪探针 (dockerfileParser.ts:314)——因为 Dockerfile 里没有"就绪"这个概念,只能给个保守默认。
  • 若解析失败,CLI migrate 会降级成 fromImage('my-custom-image') 并提示你手动构建镜像 (packages/cli/src/commands/template/migrate.ts:57)。

3.3 层 hash:按"文件内容指纹"去重上传,复用缓存(本章核心)

它要解决的小问题: COPY 进去的文件可能几百 MB。每次 build 都重传、重跑,太慢。怎么做到 "文件没变就别传、也别重建这一层"?

思路/直觉: 给每个 COPY 步骤算一个 sha256 内容指纹。远端用这个指纹当"这层的缓存 key":

  • 指纹在服务端已存在 → 这层缓存命中,跳过上传
  • 不存在 → 才把文件打成 tar.gz 上传。

因为指纹只由内容+路径+稳定元数据决定,同样的文件在任何机器算出的指纹都一样,缓存能跨环境复用。

指纹算了哪些东西calculateFilesHashutils.ts:202):

const content = `COPY ${src} ${dest}` // ① 指令本身(源、目标路径)
hash.update(content)
// 对每个文件(getAllFilesInPath 已按 fullpath 排序,保证顺序确定):
hash.update(relativePath) // ② 相对路径
hash.update(stats.mode.toString()) // ③ 权限位
hash.update(stats.size.toString()) // ④ 大小
hash.update(fileContent) // ⑤ 文件内容

刻意排除 uid / gid / mtimeutils.ts:226 注释明说)——否则同样内容在两台机器上因为属主或改动时间 不同就会算出不同指纹,缓存永远打不中。文件遍历用 getAllFilesInPathutils.ts:138),并按 fullpath 排序utils.ts:183),否则文件系统的遍历顺序会让指纹漂移。

指纹挂到哪: instructionsWithHashes()index.ts:1257)遍历所有指令,只给 COPY 补上 filesHash 字段,其余指令原样返回。

查缓存 + 条件上传(私有 buildindex.ts:1154 起,对每个 COPY 并行):

const { present, url } = await getFileUploadLink(client, { templateID, filesHash })
if ((forceUpload && url != null) || (present === false && url != null)) {
await uploadFile(/* 打 tar.gz,PUT 到预签名 url */) // 缺失才传
} else {
// onBuildLogs: `Skipping upload of '${src}', already cached`
}

getFileUploadLink 打的是 GET /templates/{templateID}/files/{hash}buildApi.ts:76)—— 用指纹当路径去问服务端"这层在不在"。返回 present 表示是否已缓存,url 是缺失时用的 S3 预签名上传地址。所有 COPY 的查/传是 Promise.all 并行的(index.ts:1222)。

关键细节(为什么用临时文件流式上传): uploadFilebuildApi.ts:107不把 tar 塞进内存, 而是先把归档 spool 到临时文件、再以已知 Content-Length 流式 PUT。原因写在注释里:S3 预签名 PUT 拒绝 Transfer-Encoding: chunked(返回 501),所以必须带明确长度。上传超时默认放到 1 小时 (FILE_UPLOAD_TIMEOUT_MSconsts.ts:43),避免 60 秒的 API 默认掐断几百 MB 的大包。

3.4 强制重建:skipCache / force 怎么绕过缓存

它要解决的小问题: 缓存太"聪明"有时反而坑——比如 apt-get install 装的是"latest", 文件没变但你想拿最新版。得能手动打破缓存。

三档 force,粒度不同:

方式作用范围代码
Template.build(t, name, { skipCache: true })整个模板重建私有 buildif (options.skipCache) this.force = trueindex.ts:1115
.skipCache() 链式调用从这一步起之后每层都重建skipCache()forceNextLayer=trueindex.ts:929
.copy(..., { forceUpload: true })只强制重传某个 COPY 的文件forceUpload 参与上传条件(index.ts:1183

巧妙处: forceNextLayer 一旦置上,之后每条指令 push 时都会带 force: this.forceNextLayer (见 3.1 的 runCmd)。而如果 forceNextLayer 期间又碰到一个 FROMfromImage 等), 会把 this.force 直接置 true,整张模板作废index.ts:471)——因为换基底意味着后面全得重来。

3.5 就绪探针:怎么判断"沙箱起来了"

它要解决的小问题: 模板里可能跑一个服务(如 python -m http.server)。"进程起了"不等于 "能用了"。要有一条命令来判定就绪。

思路/直觉: setStartCmd(start, ready) 里的 ready 是一条 shell 命令,退出 0 即视为就绪readycmd.ts 提供了几个语义化生成器,省得你手写 shell:

生成器生成的 shell判定
waitForPort(8000)[ -n "$(ss -Htuln sport = :8000)" ]端口在监听
waitForURL(url, 200)curl ... | grep -q "200"URL 返回指定状态码
waitForProcess('nginx')pgrep nginx > /dev/null进程存在
waitForFile('/tmp/ready')[ -f /tmp/ready ]文件存在
waitForTimeout(5000)sleep 5固定等待(保底)

定义见 readycmd.ts:34/59/80/101/122setReadyCmd/setStartCmdindex.ts:904/:888) 接受字符串或 ReadyCmd 实例,内部统一取 getCmd() 存成一条字符串。

3.6 轮询构建:盯状态、抽日志、把错误映射回你的代码行

它要解决的小问题: 构建是异步的远端任务。SDK 要持续问"好了没",并把日志实时喂给你。

真实实现: waitForBuildFinishbuildApi.ts:296)是个 while (status==='building' || 'waiting') 循环,每轮:

const buildStatus = await getBuildStatus(client, { templateID, buildID, logsOffset })
logsOffset += buildStatus.logEntries.length // 只拉增量日志
buildStatus.logEntries.forEach((e) => onBuildLogs?.(e))

logsOffset游标:每次带上已消费的条数,只取新增日志(buildApi.ts:330)。 状态到 ready/error 时还会继续把尾部日志抽干(状态接口一次最多返回 100 条,buildApi.ts:348)。

错误映射回源码行(巧妙):errorreason.step 有值,用 getBuildStepIndexutils.ts:440)把远端步号(含特殊名 base/finalize)换成本地 stackTraces[] 的下标, 把当初记的那条调用栈塞进 BuildErrorbuildApi.ts:358-370)——于是报错直接指到你写 DSL 的那一行。

两种入口:

  • Template.buildindex.ts:127):build 完等构建结束才返回。
  • Template.buildInBackgroundindex.ts:209):只提交、不等;随后你用 Template.getBuildStatusindex.ts:261)自己轮询。

日志事件: LogEntryStart/LogEntryEndlogger.ts:33/:42)是特殊事件,build 在最外层 成对发出(index.ts:163/:187),默认日志器 defaultBuildLoggerlogger.ts:210)据此启停一个 计时动画,构造时用 stripAnsi 洗掉颜色码(logger.ts:22)。

3.7 exists / 幂等构建前置检查

Template.exists(name)index.ts:294)→ checkAliasExistsbuildApi.ts:262)打 GET /templates/aliases/{alias},并把 HTTP 状态语义化:404=不存在返回 false,403=存在但你不是 拥有者返回 true,200=存在且你拥有(buildApi.ts:277-293)。用它在 build 前判断"要不要跳过"。


4. 深入实现(一次 build 的完整调用链)

把 3.x 串成一条端到端的真实路径(Template.build 的私有实现,index.ts:1109):

调用REST说明
0normalizeBuildArguments兼容新 name 与旧 { alias } 两种签名(utils.ts:75
1requestBuildPOST /v3/templates传 name/tags/cpuCount/memoryMB,拿回 templateID+buildIDbuildApi.ts:49
2instructionsWithHashes给每个 COPY 算 filesHashindex.ts:1257
3getFileUploadLinkGET /templates/{id}/files/{hash}查缓存,返回 present + 预签名 urlbuildApi.ts:76
4uploadFilePUT {presigned url}缺失才传,tar.gz 流式上传(buildApi.ts:107
5triggerBuildPOST /v2/templates/{id}/builds/{buildID}提交 serialize() 出的构建计划(buildApi.ts:178
6waitForBuildFinishGET .../builds/{buildID}/status轮询直到 ready/error(buildApi.ts:296

注意 API 版本混用: 建壳走 v3/v3/templates),触发构建走 v2/v2/templates/{id}/builds/{buildID}),文件/状态/别名走无版本前缀的 /templates/...。这是上游 迁移期的现状,直接照 buildApi.ts 里的字面路径为准。

提交的"构建计划"长啥样: serialize()index.ts:1301)产出 TriggerBuildTemplate (即 OpenAPI 的 TemplateBuildStartV2buildApi.ts:47):

{
startCmd, readyCmd,
steps, // 带 filesHash 的 instructions[]
force, // 整模板是否强制重建
fromImage | fromTemplate | fromImageRegistry // 三选一的基底
}

基底的三种来源(互斥,index.ts:1309-1318):fromImage(Docker 镜像名)、fromTemplate (基于另一张 E2B 模板,此时不能 toDockerfile,见 index.ts:1062)、fromImageRegistry (私有仓库凭据,支持通用 registry / AWS ECR / GCP GAR,类型见 types.ts:785 起)。


5. CLI 侧:同一套构建 API 的三个入口

CLI(packages/cli/src/commands/template/不重新实现构建,而是薄薄地包一层 SDK。三个命令:

命令干什么关键点
e2b template init交互式脚手架,生成 template.ts/build.dev.ts/build.prod.ts + READMEinit.ts:141,用 handlebars 生成代码
e2b template create读 Dockerfile,直接 fromDockerfileTemplate.buildcreate.ts:23,v2 构建的主入口
e2b template migrate把老的 e2b.Dockerfile+e2b.toml 转成新 SDK 代码文件migrate.ts:87

注意 template build 命令(build.ts)已废弃,只打印迁移提示并 exit(1)build.ts:10); 真正构建 Dockerfile 的是 create

create 的核心create.ts:118)就是 §3.2 + §3.6 的组合:

const baseTemplate = Template({ fileContextPath: root }).fromDockerfile(dockerfileContent)
// ...按需 setStartCmd / setReadyCmd...
await Template.build(finalTemplate, {
alias: templateName,
onBuildLogs: defaultBuildLogger(), // 复用 SDK 的日志器
skipCache: opts.noCache,
})

handlebars 生成器怎么复用同一 API。 init/migrate 生成代码文件时,先在内存里用 同一个 Template builder 把步骤搭好,再 Template.toJSON(template, false) 拿到结构化 steps (generators/handlebars.ts:56,第二参 false 表示此刻不算文件 hash,因为只是生成代码、还没真构建), 然后把 steps 喂给 typescript-template.hbs / python-template.hbs 渲染成人类可读的 .ts/.pygenerateTypeScriptCode:109 / generatePythonCode:155)。生成的文件里再调 Template.build —— 于是"生成的代码"和"手写的代码"走的是完全相同的构建路径。语言分发见 generators/template-generator.ts:11(TS / Python-sync / Python-async 三种)。


6. 巧妙之处(可借鉴的技术)

  • 内容指纹当缓存 key,且刻意排除易变元数据。 hash 只吃内容/路径/mode/size,排除 uid/gid/mtime (utils.ts:226),让缓存能跨机器、跨时间命中——这是"秒起"背后省掉重复上传/重建的关键。
  • 先查后传,present 决定跳过。 用指纹先 GET .../files/{hash} 问一句,命中就完全不传 (index.ts:1182)。大文件项目里这一步省的带宽最可观。
  • 流式上传规避 S3 501。 spool 到临时文件 + 显式 Content-Length,绕开预签名 PUT 拒绝 chunked 编码的坑(buildApi.ts:126 注释直指 e2b-dev/e2b#1243),临时文件靠 stream 的 close 事件自删 (utils.ts:418)。
  • 步号 ↔ 源码行的双向映射。 每步存一条 stack trace,远端报"第 N 步失败"时能精确抛回你写的那行 (utils.ts:440 + buildApi.ts:358)。
  • 日志游标只拉增量。 logsOffset 累加已消费条数,避免每轮重复整段日志(buildApi.ts:330), 终态还把尾部抽干(一次上限 100 条)。
  • CLI 与 SDK 单一真相源。 CLI/handlebars 生成器全部复用 Template builder 与 Template.build, 不另写构建逻辑(create.ts:141handlebars.ts:56)。

7. 边界与局限(诚实)

  • 多阶段 Dockerfile 不支持:>1 个 FROM 直接抛错(dockerfileParser.ts:78)。
  • EXPOSE/VOLUME 被静默忽略,未知指令只 warn(dockerfileParser.ts:136/:150)——转换不是无损的。
  • 基于模板的模板不能转 DockerfilefromTemplatetoDockerfile() 抛错(index.ts:1062)。
  • 浏览器运行时受限copy/copyItems 在 browser runtime 直接抛错(index.ts:575/:612), 文件 hash 也跳过 .dockerignoreindex.ts:1283)——因为浏览器没有本地文件系统。
  • COPY 源路径必须是 context 内的相对路径:绝对路径或 ../ 逃逸会被 validateRelativePath 拦掉(utils.ts:30),防路径穿越。
  • addMcpServer / devcontainer 方法有基底限制:分别要求基底是 mcp-gateway / devcontainer 模板,否则抛 BuildErrorindex.ts:851/:935)。
  • waitForTimeout 最小 1 秒readycmd.ts:124);Dockerfile 转来的 start 命令默认给 20 秒 超时探针,不一定贴合你的服务。

8. 横向对比(本章 vs 同组其它章)

关切本章(构建期)运行期章节
产物templateId(一张镜像)一台活的 Sandbox
通信面编排器 REST(v3/v2 templates)01 控制面 的沙箱生命周期 API
连接沙箱不涉及02 数据面连接
在沙箱里跑命令/读写文件不涉及03 命令与 PTY04 文件系统
多语言一致性handlebars 生成 TS/Python06 一份 spec 多个 SDK

模板构建的产物(templateId)正是 01 控制面Sandbox.create() 的入参—— 本章结束的地方,就是运行期章节开始的地方。


9. 代码地图(导航索引)

主题文件路径符号名
链式 DSL 主类packages/js-sdk/src/template/index.ts:55TemplateBase
DSL 入口工厂packages/js-sdk/src/template/index.ts:1343Template
构建(等完成)packages/js-sdk/src/template/index.ts:127TemplateBase.build
后台构建packages/js-sdk/src/template/index.ts:209buildInBackground
查构建状态packages/js-sdk/src/template/index.ts:261getBuildStatus
模板是否存在packages/js-sdk/src/template/index.ts:294exists / checkAliasExists
私有构建流程packages/js-sdk/src/template/index.ts:1109TemplateBase.build(private)
给 COPY 补 hashpackages/js-sdk/src/template/index.ts:1257instructionsWithHashes
序列化构建计划packages/js-sdk/src/template/index.ts:1301serialize
skipCache / forcepackages/js-sdk/src/template/index.ts:929skipCache / forceNextLayer
建壳(v3)packages/js-sdk/src/template/buildApi.ts:49requestBuild
查缓存/取上传链接packages/js-sdk/src/template/buildApi.ts:76getFileUploadLink
流式上传 tarpackages/js-sdk/src/template/buildApi.ts:107uploadFile
触发构建(v2)packages/js-sdk/src/template/buildApi.ts:178triggerBuild
轮询状态/抽日志packages/js-sdk/src/template/buildApi.ts:296waitForBuildFinish
内容指纹packages/js-sdk/src/template/utils.ts:202calculateFilesHash
文件遍历+排序packages/js-sdk/src/template/utils.ts:138getAllFilesInPath
打 tar.gz 到临时文件packages/js-sdk/src/template/utils.ts:374tarFileStream
步号→源码行packages/js-sdk/src/template/utils.ts:440getBuildStepIndex
Dockerfile 解析packages/js-sdk/src/template/dockerfileParser.ts:48parseDockerfile
就绪探针生成器packages/js-sdk/src/template/readycmd.ts:34waitForPort
构建日志事件packages/js-sdk/src/template/logger.ts:33LogEntryStart / LogEntryEnd
指令类型/DSL 接口packages/js-sdk/src/template/types.ts:182InstructionType / TemplateBuilder
CLI 脚手架packages/cli/src/commands/template/init.ts:141initCommand
CLI 构建 Dockerfilepackages/cli/src/commands/template/create.ts:23createCommand
CLI 迁移packages/cli/src/commands/template/migrate.ts:87migrateCommand
handlebars 生成器packages/cli/src/commands/template/generators/handlebars.ts:52transformTemplateData