数据截至 (上游 commit d7bc4affdac9)
边缘路由与 VM 内守护进程:请求如何抵达具体沙箱
30 秒导读: 你手上有成千上万台随时开关的沙箱(microVM),外部一个普通的 HTTPS 请求怎么"找到"其中某一台、进到它里面去跑命令或读文件?本章把这条链路一次讲完:边缘的
client-proxy负责"从 URL 解出是哪台沙箱、它在哪个节点、把请求反代过去";如果那台沙箱已经被暂停,边缘层会用一次带鉴权的 gRPC 让控制面即时把它唤醒再转发;请求抵达节点后,由节点内的反向代理打到 guest 里的envd守护进程——它才是真正在沙箱内部执行进程、读写文件的那个"手脚"。
1. 这是什么(零基础也能懂)
一句话定义: 这是 E2B 的**"最后一公里"**——把一个来自公网的请求,精确送进某一台正在运行(或需要被唤醒)的沙箱内部。
它要解决的问题,用一个场景说清:
你在沙箱里启动了一个 Web 服务,E2B 给你一个地址,比如
https://3000-ixj4k2m9.e2b.app。你用浏览器打开它。可后台有几万台沙箱、分布在几十台物理机上,而且很多沙箱为省钱**已经被冻结(暂停)**了。这一个请求,凭什么能找到编号ixj4k2m9的那台、进到它的3000端口?
回答这个"凭什么",正是本章的两个主角:
| 主角 | 白话职责 | 跑在哪 | 代码位置 |
|---|---|---|---|
client-proxy(边缘路由) | 看一眼 URL,查出"是哪台沙箱、在哪个节点",把请求反代过去;必要时先唤醒它 | 集群边缘,一个独立服务 | packages/client-proxy/ |
envd(VM 内守护进程) | 在沙箱内部监听,替外部把命令跑起来、把文件收发好 | 每台沙箱的 guest 里,一个进程 | packages/envd/ |
一句话直觉/类比: 把 client-proxy 当写字楼前台——它不认识每个人,但看一眼你要找的"房号"(sandbox id)就知道该带你上哪层楼、进哪个房间;envd 则是房间里的助理——你进来后,真正帮你干活(开程序、拿文件)的是它。前台还有个特殊本事:如果那个房间的人"睡着了"(沙箱暂停),前台会先打个电话(gRPC)把人叫醒,再放你进去。
本章只讲"请求怎么进到沙箱";沙箱本身怎么被创建、怎么秒级恢复,见 02-sandbox-lifecycle.md 与 03-fast-resume-uffd.md。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右就是一个请求的旅程;每一跳只做一件事,箭头上标了"它靠什么决定下一跳"。
按 host 解出 sandbox_id + port
│
外部请求 ▼
┌────────┐ ┌──────────────────────────────────┐ catalog 命中: 查到 orchestrator IP
│ 浏览器 │──▶│ client-proxy(边缘) │──────────────┐
│ / SDK │ │ :3002 │ │
└────────┘ │ ① 从 URL/Header 解 id+port │ catalog 未命中(沙箱暂停?)
│ ② 查 Redis 目录 → 节点 IP │──────┐ │
│ ③ 未命中则 gRPC 唤醒 │ │ │
└──────────────────────────────────┘ │ │
▼ │
┌────────────────────┐ │
│ API(控制面) │ │
│ ResumeSandbox gRPC │ │
│ 鉴权→即时 Resume │ │
│ 返回 orchestrator IP│ │
└─────────┬──────────┘ │
│ 唤醒后的 IP │
▼ ▼
┌──────────────────────────────────────┐
│ 目标 orchestrator 节点(物理机) │
│ ┌────────────────────────────────┐ │
│ │ orchestrator-proxy :5007 │ │
│ │ 按 id 找到本机沙箱 → 打到 guest │ │
│ └───────────────┬────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ 沙箱 guest 内: envd :49983 │ │
│ │ chi + Connect RPC + authn │ │
│ │ 进程 / 文件系统 / 上传下载 / init│ │
│ └─────────────────────────────── ─┘ │
└──────────────────────────────────────┘
两层反代,不是一层。 这是最容易忽略、也是理解本章的关键:请求先被边缘的 client-proxy 反代到"正确的物理机",再被那台机器上的 orchestrator-proxy 反代进"正确的沙箱"。分工是:
- 边缘层解决**"在哪台机器"**(跨节点、跨集群的服务发现与唤醒);
- 节点层解决**"进哪台沙箱、哪个端口"**(本机沙箱表、连接限流、访问令牌校验)。
主线走一遍(高层、不进代码):
- 请求打到
client-proxy,它从host(或路由头)里解出sandbox_id与目标port。 - 拿
sandbox_id去 Redis 沙箱目录查:这台沙箱现在跑在哪个 orchestrator 节点? - 查到了 → 直接把请求反代到
节点IP:5007(orchestrator-proxy 的端口)。 - 没查到(很可能是被暂停了) → 用一次 gRPC 请求让 API 鉴权并即时 Resume,拿回唤醒后的节点 IP,再反代过去。
- 请求抵达节点,
orchestrator-proxy按 id 找到本机那台沙箱,把请求打到 guest 内envd的49983端口(或用户自己的服务端口)。 envd在沙箱内部执行:跑进程、读写文件、返回结果。
下面三节把 ①②③ 和 ⑤⑥ 拆开讲原理。
3. 核心原理(逐个机制,由浅入深)
3.1 第一步:从一个 URL 里解出"是哪台沙箱、哪个端口"
要解决的小问题: 边缘代理手上只有一个 HTTP 请求。沙箱的身份藏在哪?
思路: E2B 把身份编码进了域名的最左子域——形如 {port}-{sandboxId}.{域名}。所以解析就是"切最左边那段、按 - 拆开"。另外还支持用请求头直接传 id/port(给 IP 直连、本地开发等域名编不进信息的场景)。
真实实现: 入口是 GetTargetFromRequest(),它返回一个闭包,对每个请求先尝试路由头、再回退到解析 host。
真源码 packages/shared/pkg/proxy/host.go:74 parseHost:切掉第一个 . 之后的域名部分,再按 - 拆出 port 和 sandboxId。
// packages/shared/pkg/proxy/host.go:74 parseHost —— 真实源码(节选)
host = host[:dot] // 只留最左子域 "3000-ixj4k2m9"
hostParts := strings.Split(host, "-")
sandboxPortString := hostParts[0] // "3000"
sandboxID = hostParts[1] // "ixj4k2m9"
这段在做的事:把 3000-ixj4k2m9.e2b.app 解成 (sandboxID=ixj4k2m9, port=3000)。
关键细节 / 坑:
- 两种取值来源,有优先级。 只有当 host 是本地地址或
sandbox.共享子域时才先看头(shouldParseHeadersinhost.go:45);否则一律解 host。头的名字是E2b-Sandbox-Id/E2b-Sandbox-Port(host.go:110-111)。 - 解出来立刻校验 id 合法性(
id.ValidateSandboxID,host.go:24/37),非法直接ErrInvalidSandboxID,不进入下一步。 - 同一份解析逻辑被两层代理复用。
client-proxy和orchestrator-proxy都调GetTargetFromRequest()——保证"边缘怎么理解这个 URL"和"节点怎么理解"完全一致。