返回 AICAP-180
B9 · Day 86部署 + 韧性 + 可观测 + 独立红队

部署上线 + 公网可观测

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.tssrc/agent/mcp/server.ts

  • mintToken(secret, opts)(auth.ts:17):用 joseSignJWT 铸造 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→401WWW-Authenticate: Bearer;token 校验失败→401error="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 是怎么分流的:

  1. 请求到达 → req.url/mcp 开头否则 404(server.ts:31)。
  2. process.env.MCP_AUTH_SECRET(server.ts:39)。未注入 → 跳过鉴权(本地 dev 路径);已注入 → 进入鉴权门。
  3. bearerFromHeader(req.headers['authorization'])(server.ts:41)抽 token。无 token401 + WWW-Authenticate: Bearer,直接 return(这就是「无 token→401」)。
  4. verifyAccessToken(token, { secret })(server.ts:47)校验签名/过期/受众。抛错401 + error="invalid_token"响应体 opaque(不回显 jose 错误,避免过期/签名 oracle)。
  5. requireScope(claims, 'mcp:call')(server.ts:48)校验 scope。缺 scope → 同样落到 catch → 401。
  6. 全部通过 → 解析 body → createMcpServer() + 新 transport → transport.handleRequest(server.ts:65-73),返回 200(这就是「铸造 token→200」)。

这条生命周期说明:MCP_AUTH_SECRET 这一个环境变量,就是公网 URL「裸奔 vs 鉴权」的总开关。上线时若忘记注入它,第 2 步直接跳过整个鉴权门——这是上线 checklist 必须盯死的一行。

3. 今日实战

可执行步骤,指向真实仓库路径:

  1. 构建镜像docker build -f Dockerfile.mcp -t momoweb3-aml-mcp .(复用 B8 的多阶段 Dockerfile.mcp + .dockerignore)。
  2. 部署 + 注入密钥gcloud run deploy momoweb3-aml-mcp --image <registry>/momoweb3-aml-mcp --set-env-vars MCP_AUTH_SECRET=<secret>,DEEPSEEK_API_KEY=<key>,LANGFUSE_*=<...>——密钥只在部署命令里出现,不进镜像、不进 git
  3. 验证鉴权:对公网 URL 发一个无 Bearer 的 tools/call 请求 → 期望 401;用 mintToken 铸一个带 mcp:call scope 的 token,带上 → 期望 200
  4. 触发线上 eval:对公网 URL 跑 1 次 eval(src/agent/eval/tasks.ts 任一 task),确认 trace 经 OTel 上报落到托管 Langfuse 面板。
  5. 取证:截图公网 URL 可访问 + Langfuse 里那条线上 trace。

4. 今日实测 / 产出

  • B7 OAuthsrc/agent/mcp/auth.ts jose/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 语义
  • jose JWT 库文档(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_SECRET opt-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 分布)