envd 深度阅读 · 第 1 讲:心智模型与启动编排
代码仓库:e2b-dev/infra 本文基于:commit
b8ca332f4· envdv0.5.13阅读时间:25–30 分钟(跟着源码走)
TL;DR
- envd 是跑在每个 Firecracker microVM 内部的守护进程,监听
49983端口,对外提供 Process/Filesystem 两套 Connect RPC + 一组 REST API(/init、/files、/health)。 main.go不到 250 行就把整个启动链串了起来,它的顺序不是随便写的 —— MMDS 轮询先起、logger 等 MMDS、cgroup 在 HTTP 服务前建好,都是为了避免后续路径拿不到依赖。/init是 envd 的灵魂。它一口气完成"鉴权 + 对时 + 装环境变量 + 注入 access token + 改 /etc/hosts + 装 CA 证书 + 挂 NFS",并且用memguard+ SecureToken 抽象把敏感数据锁在加密内存里。- 鉴权是双通道:
X-Access-Token走常量时间比较(热路径),/init额外用 MMDS 里的 token hash 做旁路授权 —— 这让 orchestrator 在 Resume 时可以远程轮换 token,又不泄漏 token 明文给任何参与者。
一、背景:envd 在 E2B 里的位置
E2B 给 LLM agent 提供"一个可以安全跑代码的云沙箱"。架构大致是三层:
Client SDK → Client-Proxy (边缘路由) → Orchestrator (控制面) → Firecracker microVM
└─ envd (VM 内守护进程)
Orchestrator 用 gRPC 调用 Firecracker 创建 VM,但它不伸到 VM 里面。VM 里所有的事(起用户进程、读写文件、开端口、改 hosts、装证书)都由 envd 来做。更关键的是:Client SDK 可以绕过 Orchestrator 直接打 envd(通过 Client-Proxy 路由),所以 envd 自己就是安全边界 —— 鉴权、路径校验、权限隔离都得它自己扛。
三个关键设计约束(理解后面代码都离不开这三点):
- envd 在用户代码同一个 VM 里,没有 cgroup 隔离它就可能被用户代码抢 CPU / OOM 掉,所以它给自己设了
Nice=-20、OOMScoreAdjust=-1000,还要自己管 cgroup。 - VM 可以 Resume(冷启动一次、之后多次唤醒),所以
/init会被调用多次,必须幂等,还要支持 access token 的远程轮换。 - 版本号是能力契约。
packages/envd/pkg/version.go的Version字符串每次行为变更都要 bump,Orchestrator 用它来 feature-gate。当前v0.5.13。
// packages/envd/pkg/version.go
// https://github.com/e2b-dev/infra/blob/b8ca332f435370397bf42be614b2a5b620d65d39/packages/envd/pkg/version.go
package pkg
const Version = "0.5.13"
📍 下一步:如果你对"为什么一个常量值得上整套流程"感到好奇,先跳到本文第三节看 PR #2207。
二、关键设计
2.1 main.go 的启动链(16 步)
main.go 只有 293 行,但它几乎是 envd 的"施工图"。把它读懂,后面所有模块在哪里拼进来就清晰了。
先看 main() 函数的骨架(我给每一步加了编号注释):
// packages/envd/main.go:132-242
// https://github.com/e2b-dev/infra/blob/b8ca332f435370397bf42be614b2a5b620d65d39/packages/envd/main.go#L132-L242
func main() {
parseFlags() // 1. CLI 参数
if versionFlag { fmt.Printf("%s\n", pkg.Version); return }
if commitFlag { fmt.Printf("%s\n", commitSHA); return } // 2. 打版本/commit 后退出
ctx, cancel := context.WithCancel(context.Background()) // 3. 根 context
defer cancel()
os.MkdirAll(host.E2BRunDir, 0o755) // 4. 创建 /run/e2b/
defaults := &execcontext.Defaults{ // 5. 全局默认用户/环境变量
User: defaultUser, // "root"
EnvVars: utils.NewMap[string, string](),
}
isFCBoolStr := strconv.FormatBool(!isNotFC)
defaults.EnvVars.Store("E2B_SANDBOX", isFCBoolStr)
os.WriteFile(filepath.Join(host.E2BRunDir, ".E2B_SANDBOX"), ...)
mmdsChan := make(chan *host.MMDSOpts, 1) // 6. MMDS 管道
defer close(mmdsChan)
if !isNotFC {
go host.PollForMMDSOpts(ctx, mmdsChan, defaults.EnvVars) // 6a. 后台开始 50ms 轮询
}
l := logs.NewLogger(ctx, isNotFC, mmdsChan) // 7. Logger (会等 MMDS 给它 collector 地址)
m := chi.NewRouter() // 8. HTTP 路由
fsLogger := l.With().Str("logger", "filesystem").Logger()
filesystemRpc.Handle(m, &fsLogger, defaults) // 9. 挂 Filesystem RPC
cgroupManager := createCgroupManager() // 10. cgroup v2 (含 noop 回退)
defer cgroupManager.Close()
processLogger := l.With().Str("logger", "process").Logger()
processService := processRpc.Handle(m, &processLogger, defaults, cgroupManager) // 11. 挂 Process RPC
service := api.New(&envLogger, defaults, mmdsChan, isNotFC) // 12. REST API store
handler := api.HandlerFromMux(service, m)
middleware := authn.NewMiddleware(permissions.AuthenticateUsername) // BasicAuth → user.Lookup
s := &http.Server{ // 13. HTTP Server
Handler: withCORS(
service.WithAuthorization( // ← token 校验中间件
middleware.Wrap(handler), // ← BasicAuth 中间件
),
),
Addr: fmt.Sprintf("0.0.0.0:%d", port),
ReadTimeout: 0, WriteTimeout: 0, // 流式 RPC 不超时
IdleTimeout: idleTimeout, // 640s — 见 2.2
}
if startCmdFlag != "" { /* 14. 已弃用的启动命令路径 */ }
portScanner := publicport.NewScanner(portScannerInterval) // 15. 端口扫描
portForwarder := publicport.NewForwarder(&portLogger, portScanner, cgroupManager)
go portForwarder.StartForwarding(ctx)
go portScanner.ScanAndBroadcast()
err := s.ListenAndServe() // 16. 开始服务
if err != nil { log.Fatalf("error starting server: %v", err) }
}
MMDS 是什么
MMDS = Microvm Metadata Service,是 Firecracker 提供的一个只对 microVM 内部可读、只对 host 侧可写的元数据通道,模仿 AWS EC2 IMDS。沙箱里的 envd 通过 HTTP 请求
http://169.254.169.254(IMDS 标准链路本地地址)就能拿到 host 写进去的内容——典型字段包括SandboxID、TemplateID、LogsCollectorAddress、AccessTokenHash。它在 envd 里同时承担三个角色:
- 身份下发:告诉沙箱"你叫什么、你是哪个 template、Resume 后你的新 ID 是什么"
- 配置下发:日志收集器地址等运行时参数
- 旁路授权信道:Orchestrator 写
AccessTokenHash到 MMDS,envd 拿请求 token 算sha512比对 hash,从而实现 token 远程轮换(详见 §2.5)之所以选 MMDS 而不是直接走 HTTP 调用,是因为 host 写 MMDS 不需要任何网络认证——它本身就是物理机层面的特权操作,只有 Firecracker 的拥有者(orchestrator)能写,构成了天然的信任根。
实现细节见
internal/host/mmds.go,envd 用 IMDSv2 两步协议拉取(先 PUT 拿 token,再 GET 带 token 拉 JSON)。
读这段代码时,注意两组"隐式依赖":
- MMDS 必须先起(第 6 步):因为 Logger(第 7 步)会等 MMDS 里的
LogsCollectorAddress才能把日志推出去。Logger 初始化本身不阻塞,但拿到地址前日志只到 stdout。 - cgroupManager 必须在 Process RPC 之前(第 10 步在第 11 步之前):因为
processRpc.Handle把 cgroupManager 作为参数注入到每个新生成的进程里。如果 cgroup 创建失败,会回退成NoopManager,让 envd 在老内核上也能跑,不过进程没有资源隔离。
noop 回退是什么
NoopManager是 cgroupManager接口的空壳实现——GetFileDescriptor永远返回(0, false),Close永远返回 nil。当真正的 cgroup v2 manager 创建失败时(老内核、本地开发、权限不够),envd 不会log.Fatal,而是塞一个 noop 进去让程序继续跑,只是失去 cgroup 资源隔离能力。这个模式三层意义:
- 优雅降级:cgroup 是优化不是核心,没有它 envd 仍能提供 RPC,本地开发环境也能起
- Null Object 模式:调用方代码
mgr.GetFileDescriptor(...)拿到(0, false)跳过 attach 即可,不需要写if mgr != nil的 nil 检查- defer 集中兜底:
createCgroupManager有多个return nil失败路径,用defer在最末尾统一把 nil 替换成 noop——失败路径再多也不会漏处理// 简化形式 func createCgroupManager() (m cgroups.Manager) { defer func() { if m == nil { m = cgroups.NewNoopManager() } }() // ...各种可能 return nil 的失败路径 }一句话:“宁可没 cgroup,也别死给你看”——核心功能不被非核心子系统拖死。
📍 下一步:
- 打开 main.go:244-293 看
createCgroupManager,注意defer里的 noop 回退逻辑 —— 它用 defer 实现"任何失败路径都回退到 noop",很优雅。 - 如果你想看 cgroup 三档(pty/socat/user)的权重配置,也在这个函数里,但细节留到第 3 讲讲。
2.2 为什么 IdleTimeout = 640s
// packages/envd/main.go:33-36
const (
// Downstream timeout should be greater than upstream (in orchestrator proxy).
idleTimeout = 640 * time.Second
maxAge = 2 * time.Hour
defaultPort = 49983
)
这个 640s 不是拍脑袋定的。envd 前面有 Orchestrator 的代理层,代理层有自己的上游 (upstream) 空闲超时。如果下游 envd 的 IdleTimeout 比上游短,会发生:
- 代理层还以为连接活着,继续往 envd 转包
- envd 这边已经把 connection idle kick 掉了
- 代理层收到 RST,用户看到"连接被重置"
反过来,envd 等久一点,哪怕代理先 close,对用户体验就是"连接正常断开"。所以下游总是要比上游久。
同理,ReadTimeout 和 WriteTimeout 都设为 0(不超时)。为什么?因为 envd 有流式 RPC —— 比如 process.Start 返回一个服务端流,进程跑几小时输出都在同一个连接上,期间可能很长时间没新数据。任何固定超时都会误杀这种合法连接。
📍 下一步:想深入?搜 idleTimeout 在整个项目有几个引用,你会发现 Orchestrator 侧的 proxy 也有类似常量,两者是配对的。
2.3 /init:envd 的灵魂
一个 VM 冷启动或 Resume 时,Orchestrator 做的第一件事就是调 POST /init。这个接口一口气做了七件事:鉴权 → 对时 → 装环境变量 → 转移 access token → 改 /etc/hosts(Hyperloop)→ 装 CA 证书 → 挂 NFS。
先看入口:
// packages/envd/internal/api/init.go:97-164
// https://github.com/e2b-dev/infra/blob/b8ca332f435370397bf42be614b2a5b620d65d39/packages/envd/internal/api/init.go#L97-L164
func (a *API) PostInit(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
ctx := r.Context()
operationID := logs.AssignOperationID()
logger := a.logger.With().Str(string(logs.OperationIDKey), operationID).Logger()
if r.Body != nil {
// 读原始 body,读完要能擦掉
body, err := io.ReadAll(r.Body)
defer memguard.WipeBytes(body) // ★ 关键:出作用域前抹掉 body
if err != nil {
w.WriteHeader(http.StatusBadRequest); return
}
var initRequest PostInitJSONBody
if len(body) > 0 {
if err := json.Unmarshal(body, &initRequest); err != nil {
w.WriteHeader(http.StatusBadRequest); return
}
}
// ★ 关键:如果 token 没被 TakeFrom 走(即没被"接手"),这里兜底销毁
defer initRequest.AccessToken.Destroy()
a.initLock.Lock()
defer a.initLock.Unlock()
// 只在新请求时间戳更大或无时间戳时才落地 —— 防止乱序请求覆盖新状态
if initRequest.Timestamp == nil || a.lastSetTime.SetToGreater(initRequest.Timestamp.UnixNano()) {
if err := a.SetData(ctx, logger, initRequest); err != nil {
// ...401/400 分支...
}
}
}
// /init 成功后,顺手让 MMDS 再轮询一轮(60s cap)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
host.PollForMMDSOpts(ctx, a.mmdsChan, a.defaults.EnvVars)
}()
w.WriteHeader(http.StatusNoContent)
}
两个关键的 defer:
defer memguard.WipeBytes(body)—— body 里有明文 access token,JSON 解析完必须擦掉,否则 GC 前它一直躺在 heap 上,core dump 能 grep 到。defer initRequest.AccessToken.Destroy()—— 如果中间某一步失败返回了,initRequest.AccessToken还没被TakeFrom转移走,它的 memguard buffer 要主动 Destroy,不然内存锁定的页不会被还。
📍 下一步:看 SetData()(同文件 166–232 行),它是真正的"七步编排"。下面一节我们挑其中最值得看的鉴权和 token 转移细讲。
2.4 鉴权:三层叠加
envd 的鉴权其实有 三层,大多数读者会忽略第一层:
第 1 层 — HTTP Basic Auth 的 username:不是用来校验密码的(basic auth 的 password 字段 envd 根本没读),而是把 username 当作"以谁的身份执行后续操作"的 hint:
// packages/envd/internal/permissions/authenticate.go:14-28
func AuthenticateUsername(_ context.Context, req authn.Request) (any, error) {
username, _, ok := req.BasicAuth() // ← 只取 username
if !ok {
// 没给 username 的话,让后续 endpoint 各自处理
return nil, nil
}
u, err := GetUser(username) // ← os/user.Lookup()
if err != nil {
return nil, authn.Errorf("invalid username: '%s'", username)
}
return u, nil
}
这个 *user.User 对象会被 Connect 框架存到 context 里,后面生成子进程时通过 GetAuthUser(ctx, defaultUser) 取出来决定以哪个 UID 启动 —— 这就是"username 即身份"。
第 2 层 — X-Access-Token header:真正的 token 校验,WithAuthorization 中间件:
// packages/envd/internal/api/auth.go:26-53
var authExcludedPaths = []string{
"GET/health",
"GET/files", // 走签名 URL 而不是 token
"POST/files",
"POST/init", // 走 MMDS hash
}
func (a *API) WithAuthorization(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if a.accessToken.IsSet() {
authHeader := req.Header.Get(accessTokenHeader)
allowedPath := slices.Contains(authExcludedPaths, req.Method+req.URL.Path)
if !a.accessToken.Equals(authHeader) && !allowedPath {
err := fmt.Errorf("unauthorized access, ...")
jsonError(w, http.StatusUnauthorized, err)
return
}
}
handler.ServeHTTP(w, req)
})
}
读这段代码时注意几点:
a.accessToken.IsSet()返回 false 时所有请求都放行:这就是"沙箱还没初始化"的安全假设 —— 没人告诉我 token 是什么,那我只能相信最先来调用我的人。a.accessToken.Equals是常量时间比较(memguard 实现),防时序攻击。- 4 条
authExcludedPaths各有各的理由:/health要给 LB 探活;/files改走签名 URL;/init改走 MMDS hash。
第 3 层 — /init 的 MMDS hash 分支:只在 /init 用。下面 2.5 节专门讲。
三层各自的使用时机
光看代码很容易觉得"三层都一样在每个请求上跑",但其实三层的触发场景、调用频率、设计目的完全不同。把它们的生命周期画清楚,后面读 validateInitAccessToken、Process.Start 才不会反复迷路。
| 层 | 触发场景 | 频率 | 用途 |
|---|---|---|---|
| 第 1 层 username | Process / Filesystem RPC | 每次进程相关调用 | 选身份(uid/gid) |
| 第 2 层 X-Access-Token | 几乎所有 RPC | 每个请求 | 防外部直接调用 |
| 第 3 层 MMDS hash | 仅 /init | 冷启动 + 每次 Resume | token 远程轮换 |
第 2 层是日常态。客户端 SDK 跑用户代码时,每秒可能几十次 RPC,全都带 X-Access-Token。所以 accessToken.Equals 必须是常量时间比较——既防时序攻击,也避免鉴权层成为热点。
第 1 层只在"要消费 Linux 身份"的 RPC 里有意义。虽然每个请求都可能带 BasicAuth,但 username 真正被读出来的地方就两类:
Process.Start—— 决定 fork 时的 uid/gidFilesystem.*—— 决定以哪个用户身份做 stat/read/write
/init、/health 不消费 username。客户端没传时 fallback 到 defaults.User。典型用法:同一个沙箱里既要以 user 身份跑用户代码,又要以 root 装包,客户端在不同请求里换不同 BasicAuth,一个 envd 实例多重身份分时复用。
第 3 层一辈子被调用的次数很少:
- 冷启动一次:沙箱第一次起来,envd 自己还没 token,走"首次 setup 放行"分支
- 每次 Resume 一次:snapshot 复活,orchestrator 可能想轮换 token,但旧 orchestrator 实例可能都不在了——这时新 orchestrator 把
sha512(新token)写进 MMDS,调/init带新 token,envd 算 hash 比对通过
频率低(一个沙箱生命周期 1~N 次,N = Resume 次数),所以这一层可以"重"——做完整 hash 计算 + MMDS 拉取都不会成为瓶颈。
一个常被忽略的点:三层是叠加不是替代
/init 走 MMDS hash 不代表它绕过了 username——它只是绕过了第 2 层的 token 校验。但因为 /init 是 root 上下文,username 实际上没被使用,所以看起来像"只有第 3 层"。
普通 RPC 也不是"只用第 2 层"——第 1 层 username 同样在每次 RPC 里被解析,只是大部分 RPC 不消费它而已。
一句话:第 2 层是"每个请求的护城河",第 1 层是"以谁的身份开始执行",第 3 层是"orchestrator 远程改 envd 状态时的旁路通道"。三层各管各的,不重叠也不冲突。
📍 下一步:
- 看
/files签名 URL 怎么生成?跳到 auth.go:55-72 的generateSignature,是HMAC-SHA256(path:op:username:token[:expiration])。这一段第 3 讲会细讲。 - 想知道 token 是什么时候"第一次被 Set 到 envd"?搜
a.accessToken.TakeFrom—— 只有一处调用,就在SetData里。
2.5 validateInitAccessToken:token 轮换的小机关
这是 /init 最精妙的一段:如何让 Orchestrator 在 Resume 时"改 token",却不让任意人都能改?
// packages/envd/internal/api/init.go:40-61
// validateInitAccessToken 校验 /init 请求的 access token。
// 满足以下任一条件即视为合法:与已存在的 token 相同 或 命中 MMDS hash。
// 两者都不存在时,视为首次冷启动,放行。
func (a *API) validateInitAccessToken(ctx context.Context, requestToken *SecureToken) error {
requestTokenSet := requestToken.IsSet()
// 快路径:与当前 token 完全一致(常量时间比较,防时序攻击)
if a.accessToken.IsSet() && requestTokenSet && a.accessToken.EqualsSecure(requestToken) {
return nil
}
// 与当前 token 不匹配时,再去查 MMDS 里的 hash
matchesMMDS, mmdsExists := a.checkMMDSHash(ctx, requestToken)
switch {
case matchesMMDS:
return nil
case !a.accessToken.IsSet() && !mmdsExists:
return nil // 首次冷启动:envd 没 token,MMDS 也没写过 hash
case !requestTokenSet:
return ErrAccessTokenResetNotAuthorized // 想重置 token 却没带任何凭证
default:
return ErrAccessTokenMismatch // token 既不匹配当前值,也对不上 MMDS hash
}
}
四条放行/拒绝规则:
| 条件 | 结果 | 语义 |
|---|---|---|
| request token == 当前 token | ✅ 放行 | 正常调用(热路径) |
sha512(request token) == MMDS hash | ✅ 放行 | Resume 场景,Orchestrator 提前在 MMDS 里写了新 token 的 hash |
| 当前无 token + MMDS 也没 hash | ✅ 放行 | 第一次冷启动 |
| 其他 | ❌ 401 | 拒绝 |
机关在 MMDS 那一条。MMDS (Firecracker Metadata Service) 是 Firecracker 提供的一个 169.254.169.254 的只读 metadata 服务 —— 但只对 orchestrator 可写(写 MMDS 必须有 host 侧权限)。所以流程是:
Orchestrator 要轮换 token
↓
先把 hash(newToken) 写进 MMDS
↓
再调 POST /init 带上 newToken
↓
envd 看到当前 token ≠ newToken,检查 MMDS hash
↓
hash(newToken) == MMDS 里写的 hash → 放行
↓
`a.accessToken.TakeFrom(data.AccessToken)` → 替换掉本地 token
为什么这安全:攻击者就算拿到了 MMDS 里的 hash,也反推不出 token(SHA-512 单向);就算拿到了旧 token,也无法覆盖新 token(因为 MMDS hash 已经是新 token 的 hash)。
澄清一个常见误解:MMDS 里存的不是 token,是 token 的指纹
容易把 MMDS 想成"token 的真正存储地"——其实MMDS 里只有
sha512(token),永远没有明文。token 明文真正住的地方是 orchestrator 的内存、客户端 SDK 的内存、envd 的 memguard buffer。MMDS 这条通道在 envd 看来只能用来"验证",不能用来"读取" ——它是单向凭据,不是 secret store。这个区分重要在哪?回到 Resume 场景:新 orchestrator 要发新 token,但它没法把新 token 明文留在 MMDS 里(MMDS 在沙箱里可读,token 一旦放进去等于明文落地),所以它放 hash。envd 收到请求 body 里的明文新 token 时,自己算一次 hash 比对。MMDS 的角色是"等会儿会有一个明文 token 来,如果它的 sha512 等于这个值就接受"——这是一个"延迟验证凭据",不是密钥本身。
📍 下一步:
- 看
checkMMDSHash(同文件 70–95 行)—— 里面有个"当前 token 为 nil 怎么办"的 edge case,用hash("")表达"orchestrator 授权把 token 清零"。是个用 hash 编码"意图"的小技巧。 - 看
SetData里a.accessToken.TakeFrom(data.AccessToken)这一行的前后,理解验证通过后 token 是怎么"零拷贝"接管的。
2.6 SecureToken:把 token 锁进内存保险箱
前面出现了好几次 SecureToken。这是一个专门为 access token 设计的类型,用 memguard 库把 token 放进受物理保护的内存页。
先澄清一个容易踩的坑:memguard 不是"加密",是"内存物理保护 + 生命周期管理"
SecureToken 内部存的始终是明文——
a.accessToken.EqualsSecure(other)比对的也是明文字节。memguard 的"安全"完全来自对内存物理位置和生命周期的控制:mlock 不让换 swap、guard pages 防越界读、Destroy 时手动清零。换句话说,如果把 envd 的 memguard buffer 想象成"加密保险箱"是错的——它更像**“放在带门禁的物理保险室里、用完立刻碎纸的明文文件”**:纸上的字一直可读,但谁能进保险室、纸何时被销毁都被严格控制。
这个区分重要在哪?密码学加密会改变字节本身(密文 ≠ 明文),但 memguard 不动字节内容。所以接 token 比对时仍然要走"拿出明文做常量时间比较"(见后面的
EqualsSecure)——你不可能让客户端发一个加密后的 token 来"密文比对",客户端发的就是明文,服务端就是用明文比的。
// packages/envd/internal/api/secure_token.go:16-46
// SecureToken wraps memguard for secure token storage.
// It uses LockedBuffer which provides memory locking, guard pages,
// and secure zeroing on destroy.
type SecureToken struct {
mu sync.RWMutex
buffer *memguard.LockedBuffer
}
// Set securely replaces the token, destroying the old one first.
// ...
func (s *SecureToken) Set(token []byte) error {
if len(token) == 0 {
return ErrTokenEmpty
}
s.mu.Lock()
defer s.mu.Unlock()
// Destroy old token first (zeros memory)
if s.buffer != nil {
s.buffer.Destroy()
s.buffer = nil
}
// Create new LockedBuffer from bytes (source slice is wiped by memguard)
s.buffer = memguard.NewBufferFromBytes(token)
return nil
}
memguard 提供的三层保护:
| 机制 | 防范的风险 |
|---|---|
mlock() 内存锁定 | 敏感数据被 swap 到磁盘(重启后还能读到) |
| 前后 guard 页 | 缓冲区溢出读(邻近内存被读出 token) |
Destroy 时显式清零 | GC 后 heap 残留,core dump 暴露 |
但真正值得学习的是这个 API 的设计。Set 有一个反直觉的细节:先 Destroy 旧的,再装新的。为什么?如果先装新的再 Destroy 旧的,中间那一瞬间同时有两份明文在内存里。Set 的语义强制保证"任一时刻只有一份"。
再来看一个更精妙的方法 —— TakeFrom,零拷贝移交:
// packages/envd/internal/api/secure_token.go:94-115
// TakeFrom transfers the token from src to this SecureToken, destroying any
// existing token. The source token is cleared after transfer.
// This avoids copying the underlying bytes.
func (s *SecureToken) TakeFrom(src *SecureToken) {
if src == nil || s == src {
return
}
// Extract buffer from source
src.mu.Lock()
buffer := src.buffer
src.buffer = nil
src.mu.Unlock()
// Install buffer in destination
s.mu.Lock()
if s.buffer != nil {
s.buffer.Destroy()
}
s.buffer = buffer
s.mu.Unlock()
}
这里没有复制任何字节。只是把底层的 *memguard.LockedBuffer 指针从 src “搬"到 s。这意味着:
- 解析
/initbody 时 token 进了initRequest.AccessToken这个 SecureToken SetData调a.accessToken.TakeFrom(data.AccessToken)把它接管走- 然后
defer initRequest.AccessToken.Destroy()看到buffer == nil,什么都不做
整个流程 token 明文在 memguard buffer 里只存在过一份。这是 PR #1823 引入 SecureToken 的最大收益。
最后看 EqualsSecure,它解决了一个不太明显的死锁问题:
// packages/envd/internal/api/secure_token.go:134-160
// EqualsSecure compares this token with another SecureToken using constant-time comparison.
func (s *SecureToken) EqualsSecure(other *SecureToken) bool {
if s == nil || other == nil {
return false
}
if s == other {
return s.IsSet()
}
// Get a copy of other's bytes (avoids holding two locks simultaneously)
otherBytes, err := other.Bytes()
if err != nil {
return false
}
defer memguard.WipeBytes(otherBytes)
s.mu.RLock()
defer s.mu.RUnlock()
if s.buffer == nil || !s.buffer.IsAlive() {
return false
}
return s.buffer.EqualTo(otherBytes)
}
为什么要 “先拿副本再加锁”?朴素实现会是:先锁 self 的 mu,再锁 other 的 mu,然后比较。但是想象两个 goroutine 同时比较 A 和 B:
- G1:
A.EqualsSecure(B)→ 锁 A → 试图锁 B - G2:
B.EqualsSecure(A)→ 锁 B → 试图锁 A
经典 AB-BA 死锁。作者的解法:先通过 other.Bytes() 拿到 other 的明文副本(Bytes 内部短锁一下就释放),再只锁 self 做比较。副本用完 memguard.WipeBytes 擦掉。
📍 下一步:
- 打开 secure_token_test.go,461 行测试,专门有一节覆盖 deadlock prevention,用
runtime.Gosched()构造调度窗口。 - 搜
memguard.WipeBytes在整个 envd 有多少处调用 —— 这是敏感数据出 memguard 边界时"必须擦"的一个纪律。
附:token 在系统里的几种形态
读完 §2.5 + §2.6 后,可能仍会困惑"那 token 到底在哪儿”。下面这张表把整套生命周期里 token 的所有形态收拢:
| 位置 | 存的是什么 | 形态 | 防护手段 | 谁能读 |
|---|---|---|---|---|
| Orchestrator 内存 | token 明文 | 普通字符串 | OS 进程隔离 | 控制面服务自己 |
| Client SDK 内存 | token 明文 | SDK 内部变量 | 客户端进程隔离 | 调用方自己 |
HTTP Header X-Access-Token | token 明文 | 字节流 | TLS 在传输层加密 | 服务端解析后立即放进 memguard |
HTTP Body(/init 时) | token 明文 | JSON 字段 | TLS + body 用完立即 WipeBytes | envd 解析时短暂在普通堆上 |
| envd 的 a.accessToken | token 明文 | memguard LockedBuffer | mlock + guard pages + 常量时间比较 | 同进程其他代码理论可读,但被物理保护提高门槛 |
MMDS AccessTokenHash | sha512(token) | 字符串 | host 才能写,VM 内只读 | VM 内任何进程,但反推不出明文 |
三个关键观察:
- token 明文同时存在于多个位置(orchestrator、client、envd 内存、传输流),每个位置的防护手段不同,但没有"加密存储"——都是明文 + 物理隔离。
- MMDS 是唯一不存明文的位置,它存 hash。这是因为 MMDS 在 VM 内是公开可读的,放明文等于直接泄漏。
- token 明文每次跨边界(Header→memguard,body→memguard,memguard→比对) 都会有一段在普通内存里短暂存活的窗口,所以代码里到处是
defer WipeBytes—— 这是 envd 处理敏感数据的纪律:任何明文从受保护区域逃出来,都必须有 defer 抹零兜底。
📍 延伸阅读:
- §2.5 讲 hash 验证流程,§2.6 讲明文保护机制,合起来才是完整的 token 安全模型——单看任一节都会形成"token 就在 X"的片面印象。
- 搜
memguard.WipeBytes的所有调用点,你会发现它们恰好对应这张表里"明文短暂跨边界"的每一处。
三、为什么这么设计(PR 溯源)
上面讲的 SecureToken + MMDS hash 是同一批改动的两半。代码里看不出"为什么这样",PR 讲得最清楚。
3.1 PR #1823 — SecureToken 内存保护
合入:2026-02-04 · 规模:+832 / −87
原来是什么样:type API struct { ... accessToken *string ... } —— 就一个普通的字符串指针。Go 的字符串放在 heap 上,三个风险:
- swap 泄漏:内存压力大时 Linux 把 heap 页换到 swap 分区,重启也还能 grep 到
- GC 残留:string 不可变,被替换的旧 string 可能要等好几轮 GC 才被清,core dump 期间暴露
- 时序攻击:常规
s == t字符串比较在第一个不同字节就短路,攻击者可以测时间逐位猜
PR 做的:新建 SecureToken,底层 memguard.LockedBuffer。上面 2.6 节讲的 API 细节都是这个 PR 设计的。
为什么值得学:它不是简单"包一下就完事",而是重新设计了一套 API(Set / TakeFrom / EqualsSecure / UnmarshalJSON 拒 \ 转义),每一个方法都针对一类具体攻击面。这是"把隐式安全假设变成显式机制"的典型例子。
3.2 PR #1822 — MMDS hash 做 Resume 时 token 轮换
合入:2026-02-03 · 规模:+637 / −17
要解决的问题:Resume 时 Orchestrator 要给一个运行中的沙箱换 token(租户切换、template 复用)。可是:
- 走普通 API 需要先持旧 token 才能认证
- 新 Orchestrator 实例可能根本不知道旧 token
- 不能让任意人都能改
PR 的方案:让 MMDS 成为"旁路授权信道"。
packages/shared/pkg/keys/sha512.go加HashAccessToken- Orchestrator 在 FC Resume 流程中把
accessTokenHash写进 MMDS(MMDS 只能 host 侧写) /init加到authExcludedPaths,鉴权改走validateInitAccessToken- envd 比对
sha512(request.token)是否等于 MMDS 里的 hash
两个巧思:
- 用
hash("")表达"我要你清零 token":Orchestrator 可以写一个空 hash + 发 nil token → envd 认为 hash 匹配空字符串 → 主动 Destroy 当前 token。这是"写 MMDS"来表达"我要 envd 做什么动作"的副通信机制。 - 状态码从 409 改 401:原来 token 更改冲突返回 409 Conflict,PR 改成 401 Unauthorized —— 语义上这是鉴权失败,不是资源冲突。
这对 PR 必须一起读:SecureToken + MMDS hash 共同构成了新的鉴权模型。单看任一个都不完整。
四、值得留意的问题 & 开放讨论
4.1 MMDS 不可达时的 50ms 忙轮询
host.PollForMMDSOpts 每 50ms 打一次 MMDS。如果 Orchestrator 由于某种原因忘了给这个沙箱配 MMDS:
- envd 启动完成后会持续 CPU 打 stderr(每次失败都 log 一条)
- 没有指数退避
- 没有 circuit breaker
影响范围:通常不会触发,因为 Orchestrator 正常流程都会配 MMDS。但自建部署 / 本地调试模式 (-isnotfc) 时要小心 —— 好在 -isnotfc 分支里这个 goroutine 不会启动。
讨论点:要不要加个指数退避?还是 50ms 故意做得激进(因为 Resume 刚唤醒时 MMDS 可能还没就绪)?
4.2 Hyperloop 的异步失败静默
// init.go:206-208
if data.HyperloopIP != nil {
go a.SetupHyperloop(*data.HyperloopIP) // ← 异步,无返回
}
这里 fire-and-forget。如果 /etc/hosts 写失败(磁盘满、权限问题),用户完全感知不到 —— /init 会返回 204,但 events.e2b.local 这个域名仍然解析不到。
触发条件:罕见,但 OOM 重启窗口里比较危险。
缓解手段:日志有打,但没有上报给 client。可以考虑用 Hyperloop 状态做一个 readiness 指标。
4.3 initRequest.AccessToken.Destroy() 的 defer 位置
这是 PR #1823 后续 bug fix 才加的兜底。原本 SetData 内部 TakeFrom 后就把 token 接管走了,看似不需要 Destroy。但任何"SetData 之前"的早期返回(validation 失败、时间戳过期跳过等)都会让 token 留在 initRequest 里没人清 —— memguard 的 mlock 不释放就一直占着内存锁定页。
教训:memguard 这种"用完必须主动释放"的资源,最稳妥是在构造它的作用域加 defer Destroy(),哪怕后面会转移。
4.4 首次 /init 的 TOCTOU 窗口
checkMMDSHash 和 SetData 中间是有时间差的。极端情况下:
- t=0:envd 启动,MMDS 还没写好 hash
- t=1:有人(非 orchestrator)打
/init—— 这时a.accessToken为 nil,MMDS hash 也是空,走"首次 setup"分支放行 - t=2:attacker 把自己的 token 装进去了
前提:VM 的网络是隔离的,只有 orchestrator 能访问 envd。目前这个"前提"由基础设施保证。但如果将来 VM 网络拓扑变化,这里会变成脆弱点。
五、延伸阅读
推荐阅读顺序
如果你想完整吃透本讲内容:
1. main.go 完整读一遍(~250 行)
2. internal/api/init.go 的 PostInit + SetData
3. internal/api/secure_token.go 全文
4. internal/api/auth.go 的 WithAuthorization
5. internal/permissions/authenticate.go(简短,但重要)
6. 最后看 PR #1822 + #1823 的 description(放一起读)
相关 PR
本讲涉及的源码文件
| 文件 | 关键内容 |
|---|---|
packages/envd/main.go | 启动链、CLI、HTTP Server、cgroup 配置 |
packages/envd/pkg/version.go | 能力版本 |
packages/envd/internal/api/store.go | API struct、New 构造器 |
packages/envd/internal/api/init.go | /init 的编排(PostInit + SetData) |
packages/envd/internal/api/auth.go | WithAuthorization 中间件、签名 URL |
packages/envd/internal/api/secure_token.go | SecureToken 全套 API |
packages/envd/internal/permissions/authenticate.go | BasicAuth username → OS user |
下一讲预告
将覆盖:
- 用户命令的进入路径(Connect-RPC + chi router 路径分流)
- pre-exec shell wrapper 如何消除 OOM/nice race(PR #2028)
CLONE_INTO_CGROUP+ Credential 一次 syscall 完成隔离 + 降权- Start vs Connect 为什么在客户端断网后进程还能活
- MultiplexedChannel 的广播模式和慢消费者隐患
- 为什么同时提供流式
WatchDir和轮询式CreateWatcher - 路径安全:
~展开、Abs、依赖 home 目录位置的弱前提