部署上线 + 公网可观测
B9 把前面攒下的「能在本机跑通」的 agent/eval/MCP 升级成「能在公网活着且被观测」。昨天(Day 85)做的是把 29-task eval suite 接进 OTel、让评测变成可观测的 trace 流;今天往前再走一步——真正把容器推上 Cloud Run,拿到公网 URL,并确认 trace 落进托管 Langfuse。在 B1→B18 整条能力曲线上,这是从「P2 评测 /
阶段: B9 · 部署 + 韧性 + 可观测 + 独立红队(Day 81-90) 标签: #cloud-run #mcp-auth #observability #rfc9728
今日导引(由浅入深)
B9 把前面攒下的「能在本机跑通」的 agent/eval/MCP 升级成「能在公网活着且被观测」。昨天(Day 85)做的是把 29-task eval suite 接进 OTel、让评测变成可观测的 trace 流;今天往前再走一步——真正把容器推上 Cloud Run,拿到公网 URL,并确认 trace 落进托管 Langfuse。在 B1→B18 整条能力曲线上,这是从「P2 评测 / P5 可观测」跨进「P3 上线交付」的关键一跳:上线不等于把镜像跑起来,而是把 B7 攒的 OAuth 鉴权(无 token→401、有 token→200)一并接住,否则公网 URL 就是一个裸露的工具网关。今天的最小可判定产出:一个可访问的公网 URL + 至少 1 条线上 trace 入库——这两件目前都是「待云」,但鉴权底座(401/200)已 live 可验证。
1. 机理精读
上线的本质是「不可变制品 + 外部注入的配置」。 部署到 Cloud Run 不是把代码 copy 上去,而是把一个已构建好的容器镜像(不可变制品)交给平台,平台按请求把实例从 0 弹到 N。镜像里绝不能含任何密钥——模型 API key、MCP_AUTH_SECRET 一律通过环境变量在部署时注入。这条「密钥不进镜像」是 12-factor 的 config 原则,也是供应链安全底线:镜像会被推到 registry、被多处拉取、被层缓存,任何打进镜像的密钥都等于公开。
公网 URL 一旦存在,鉴权就从「可选」变成「必须」。 本机 dev 时 MCP server 默认不鉴权(方便本地调试),但 src/agent/mcp/server.ts 的设计是按 MCP_AUTH_SECRET 环境变量 opt-in:一旦该 env 存在,每个请求都必须带合法 Bearer token,否则 401。这正好对齐 B7 在 Day 不远处做的 OAuth 2.1 resource-server 原语——上线时把 MCP_AUTH_SECRET 注入,鉴权自动激活,公网就不再裸奔。
鉴权对齐的是 RFC 9728 / RFC 8707 与 MCP 2026-07-28 规范。 RFC 9728(OAuth 2.0 Protected Resource Metadata)让客户端能发现「该资源期望什么样的 token、谁是合法 issuer」;RFC 8707(Resource Indicators)让 token 携带「受众(audience)」,使一个被签发给资源 A 的 token 不能拿去打资源 B。这两条共同构成「token 受众绑定」——防止 token 在不相关的 server 间被重放。src/agent/mcp/auth.ts 在本地/demo 场景用 HS256 + 共享 secret 简化,但显式 pin 了算法并保留了 issuer/audience 校验的钩子,生产则换成 RS256 + JWKS。
关键权衡:cold start vs 常驻成本。 Cloud Run scale-to-zero(Day 81 已学)让低/突发流量的 eval runner 极省钱,但首请求要付 cold start(拉镜像→启容器→初始化)。对延迟敏感的链路要 min-instances=1 换掉冷启动、付常驻代价;对 eval runner 这种批处理则 scale-to-zero 即可。今天上线选的是后者——eval 触发是间歇的,冷启动可接受。
与相邻概念的边界。 上线 ≠ CI/CD:今天是「手动 gcloud run deploy」把制品推上去,自动化的「commit→build→deploy」流水线属于后续 CI 工作(Day 80 的 SOTA 段已标注 CI 自动触发 docker build 为待接)。可观测 ≠ 部署:trace 上报是部署后的「确认动作」,用来证明线上链路真的连通了 Langfuse,而非只是 URL 返回 200。
2. 代码走读:auth.ts + server.ts
今天上线接住的鉴权底座已在仓库且测试过。读 src/agent/mcp/auth.ts 与 src/agent/mcp/server.ts:
mintToken(secret, opts)(auth.ts:17):用jose的SignJWT铸造 HS256 token,setProtectedHeader({ alg: 'HS256' })固定算法,opts.ttlSec允许传负值铸造「已过期」token(用于测试过期路径)。issuer/audience 可选注入。verifyAccessToken(token, opts)(auth.ts:33):jwtVerify校验签名,显式algorithms: ['HS256']——这是对 alg-confusion 攻击的纵深防御(不 pin 算法时攻击者可把 token 改成alg:none或换非对称算法绕过);同时校验 issuer/audience。requireScope(claims, scope)(auth.ts:50):token 必须携带所需 scope(如mcp:call),否则抛insufficient_scope——这是「鉴权之上的授权」一层。bearerFromHeader(header)(auth.ts:57):从Authorization: Bearer <token>抽 token,无则返回 null。startHttpServer(port)的鉴权门(server.ts:38-54):读process.env.MCP_AUTH_SECRET,存在才开启鉴权(opt-in,本地 dev 默认关)。无 token→401带WWW-Authenticate: Bearer;token 校验失败→401带error="invalid_token",且响应体是 opaque 的——注释明确写「不回显 jose 的具体错误,否则会变成过期/签名的 oracle」(防止攻击者通过错误信息区分「签名错」还是「过期」来做侧信道探测)。- stateless 形态(server.ts:65-66):每个请求
createMcpServer()+ 新 transport(sessionIdGenerator: undefined),对齐 MCP 2026-07-28 的无状态远端 server 风格——这正是能跑在 Cloud Run 这种「实例随时被弹起/销毁」环境后的前提。
鉴权请求生命周期(逐步推演)
把一次受保护的 tools/call 请求在 startHttpServer 里走一遍,看 401/200 是怎么分流的:
- 请求到达 →
req.url以/mcp开头否则 404(server.ts:31)。 - 读
process.env.MCP_AUTH_SECRET(server.ts:39)。未注入 → 跳过鉴权(本地 dev 路径);已注入 → 进入鉴权门。 bearerFromHeader(req.headers['authorization'])(server.ts:41)抽 token。无 token →401+WWW-Authenticate: Bearer,直接return(这就是「无 token→401」)。verifyAccessToken(token, { secret })(server.ts:47)校验签名/过期/受众。抛错 →401+error="invalid_token",响应体 opaque(不回显 jose 错误,避免过期/签名 oracle)。requireScope(claims, 'mcp:call')(server.ts:48)校验 scope。缺 scope → 同样落到 catch → 401。- 全部通过 → 解析 body →
createMcpServer()+ 新 transport →transport.handleRequest(server.ts:65-73),返回200(这就是「铸造 token→200」)。
这条生命周期说明:MCP_AUTH_SECRET 这一个环境变量,就是公网 URL「裸奔 vs 鉴权」的总开关。上线时若忘记注入它,第 2 步直接跳过整个鉴权门——这是上线 checklist 必须盯死的一行。
3. 今日实战
可执行步骤,指向真实仓库路径:
- 构建镜像:
docker build -f Dockerfile.mcp -t momoweb3-aml-mcp .(复用 B8 的多阶段Dockerfile.mcp+.dockerignore)。 - 部署 + 注入密钥:
gcloud run deploy momoweb3-aml-mcp --image <registry>/momoweb3-aml-mcp --set-env-vars MCP_AUTH_SECRET=<secret>,DEEPSEEK_API_KEY=<key>,LANGFUSE_*=<...>——密钥只在部署命令里出现,不进镜像、不进 git。 - 验证鉴权:对公网 URL 发一个无 Bearer 的
tools/call请求 → 期望401;用mintToken铸一个带mcp:callscope 的 token,带上 → 期望200。 - 触发线上 eval:对公网 URL 跑 1 次 eval(
src/agent/eval/tasks.ts任一 task),确认 trace 经 OTel 上报落到托管 Langfuse 面板。 - 取证:截图公网 URL 可访问 + Langfuse 里那条线上 trace。
4. 今日实测 / 产出
- B7 OAuth(
src/agent/mcp/auth.tsjose/HS256-pinned +server.ts):已建且 401/200 已验证(✅ live)。 - Cloud Run 部署 + 公网 URL + 线上 trace:待云。
- 目标产出:可访问公网 URL + 1 条线上 trace。
诚实状态:鉴权的 401/200 是本机/测试可验证的 live 事实;公网部署与线上 trace 仍是「待云」,不升级为已完成。
5. 常见误区 / 陷阱
- 把密钥打进镜像:任何
ENV DEEPSEEK_API_KEY=...写进 Dockerfile 或 build-arg 烘进层都等于泄密——镜像层会被缓存、被推送、可被任意拉取者逆向。密钥只走运行时环境变量。 - 公网 URL 不开鉴权:忘记注入
MCP_AUTH_SECRET会让server.ts走「默认不鉴权」分支,公网 MCP 工具网关裸奔——任何人都能tools/call。上线必须确认该 env 已注入、401 路径生效。 - 裸 token(无受众校验):不校验 audience 的 token 可被重放到其它 server;RFC 8707 的 resource indicator 就是为此。本仓
verifyAccessToken保留 audience 钩子,生产须真正绑定。 - 把「URL 返回 200」当成「链路连通」:URL 活着 ≠ trace 真落库。必须在 Langfuse 面板看到那条 trace 才算可观测闭环成立。
6. 学习资源(每条带 YYYY-MM)
- Google Cloud Run docs — Container runtime contract & autoscaling(2026-01)
- RFC 9728 — OAuth 2.0 Protected Resource Metadata(2025-04,IETF)
- RFC 8707 — Resource Indicators for OAuth 2.0(2020-02,IETF;MCP 2026 规范引用)
- MCP 规范 2026-07-28 定稿(RC 2026-05-21 锁定)— 远端 server 鉴权与 stateless 语义
joseJWT 库文档(2026 当周版本,pin 版本号)
7. 上线链路图(数据流)
[git 源码]
│ docker build -f Dockerfile.mcp(多阶段,<300MB)
▼
[不可变镜像] ── 推送 ──▶ [registry]
│ gcloud run deploy
│ --set-env-vars MCP_AUTH_SECRET / DEEPSEEK_API_KEY / LANGFUSE_*
▼ (密钥只在部署命令注入,不进镜像)
[Cloud Run 实例] ◀── 请求驱动 0→N(scale-to-zero)
│ 公网 URL /mcp
├─ 无 Bearer ────────────▶ 401(WWW-Authenticate: Bearer)
├─ token 失效 ───────────▶ 401(error="invalid_token", opaque body)
└─ token 合法 + mcp:call ─▶ 200 ── 触发 eval ──┐
▼
[OTel gen_ai.* span]
▼
[托管 Langfuse 面板](线上 trace)
这张图把今天的两件「待云」产出(公网 URL、线上 trace)和已 live 的鉴权门(401/200)放在同一条链路上,一眼看清「上线 = 不可变制品 + 外部注入配置 + 鉴权门 + 可观测回路」。
8. 面试自测(30 秒口径)
- 密钥为什么不能进镜像? 镜像层会被缓存/推送/可被任意拉取者逆向;密钥只走运行时 env(12-factor config)。
- 公网 MCP server 怎么鉴权?
MCP_AUTH_SECRETopt-in → Bearer token →verifyAccessToken(pin HS256)→requireScope('mcp:call');对齐 RFC 9728/8707 的受众绑定。 - 401 响应体为何 opaque? 回显 jose 错误会变成过期/签名 oracle,给攻击者侧信道。
- scale-to-zero 的代价? 首请求 cold start;延迟敏感链路用
min-instances=1换掉,eval runner 这种批处理可接受。
SOTA检查 (2026-06 更新)
- 主流方案:serverless 容器(Cloud Run)+ 环境变量注入密钥 + OAuth 2.1 resource-server 鉴权 + OTel→托管 Langfuse 是 2026 的标准上线形态,仍 SOTA。
- 须复验:MCP auth 对齐 RFC 9728/8707 + MCP 2026-07-28 规范——server 鉴权细节(受众绑定、metadata 发现)须在 07-28 规范定稿后逐条复验;执行当周再查 jose / SDK 版本。
- 过时黑名单:避免在镜像里硬编码 key;避免无受众校验的裸 token;避免引用已弃的 App Engine Flex 部署叙事(Day 81 已标)。
- 下次复查点:2026-07-28 MCP 规范定稿后,重核 server.ts 的鉴权与 metadata 是否对齐;GA 状态/版本号执行当周重验。
衔接
- 昨天:Day 85 — 把 eval harness 接入 OTel(评测变成可观测 trace 流)
- 今天:把容器推上 Cloud Run,密钥走 env 注入,接住 B7 的 401/200 鉴权,确认线上 trace 落 Langfuse
- 明天:Day 87 — trace 量产 + 仪表盘(单条 trace 没意义,量产后聚合出 p50/p95/p99 分布)