Cloudflare TURN 实战:解决 WebRTC 掉线、凭证过期与 ICE 失败的 5 个生产级做法
发布时间:2026/9/15 16:05:40 作者:尧图编辑部 阅读量:1,286

Cloudflare TURN 实战解决 WebRTC 掉线、凭证过期与 ICE 失败的 5 个生产级做法【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills本文基于 skills4/skills 仓库面向 Codex 的技能目录中的 cloudflare-deploy 技能模块讲解 Cloudflare TURN——运行在 Cloudflare 全球 anycast 网络310 城市上的托管 WebRTC 中继服务——如何在客户端与 SFU 的直连被 NAT 或防火墙阻断时接管流量。文章从凭证生命周期、端口取舍与 ICE 重启三个维度给出一套可直接落地的生产级 TURN 接入方案读完即可组装出凭证签发、到期前刷新与掉线自动恢复的完整代码。长通话生产环境里反复出现的三类故障现象WebRTC 业务上线跑几周后基本会撞上三种问题长通话在固定时间点掉线。通话进行约 1 小时TTL 给足时约 48 小时后双方同时断开原因是临时凭证到期TURN 认证不再通过。浏览器端没有 relay 候选。同一份iceServers在 Node SDK 里正常Chrome/Firefox 里只出现 host/srflx 候选企业网络下通话不通。原因是 53 端口 URL 被浏览器静默拦截且不会抛出任何错误。网络切换后连接不再恢复。用户从 Wi-Fi 切到蜂窝网、或 TURN 侧维护时iceConnectionState进入failed此后一直停着。三个现象指向同一个根因TURN 凭证和连接状态都有生命周期但代码按无状态处理了。下文的完整方案就是补齐这套生命周期管理。 一张表看懂 STUN、TURN 与临时凭证的分工写代码前先明确各组件职责与必须记住的硬参数组件职责关键参数STUNstun.cloudflare.com:3478发现客户端公网候选用于尝试直连无需凭证TURN over UDP/TCPturn.cloudflare.com:3478直连被阻时中继媒体流临时username/credentialTURN over TLSturns:turn.cloudflare.com:5349/443企业防火墙只放行 TLS 端口同一份临时凭证临时凭证客户端向 TURN 服务器证明身份TTL 取值 1~172800 秒48 小时超出被 API 拒绝ICE restartfailed/disconnected后重建候选对createOffer({ iceRestart: true })需要记牢的数字172800TTL 上限秒、3478 / 5349 / 443浏览器可用端口、53 / 80非浏览器客户端可用、浏览器不可用、3000000ms50 分钟对应 1 小时 TTL 的刷新周期。出处为仓库内的 turn/patterns.md 与 turn/api.md。方案设计密钥放 Worker凭证放缓存整条链路只有四跳每跳一个职责浏览器 ──/api/turn-credentials──▶ Worker唯一持有 TURN_KEY_SECRET 的位置 │ ▼ POST /v1/turn/keys/{key_id}/credentials/generate rtc.live.cloudflare.com凭证生成端点 │ 浏览器 ◀──── 过滤后的 iceServers ────┘四个关键决策每个都有对应取舍密钥只留在 Worker客户端只拿临时凭证。TURN_KEY_SECRET等同于无限生成凭证的钥匙打进浏览器 bundle 等于公开。客户端一律走自家带鉴权的凭证接口搭建方式见 turn/configuration.md。53/80 端口在服务端过滤。生成端点响应里包含turn:turn.cloudflare.com:53?transportudp与turn:turn.cloudflare.com:80?transporttcp两类地址见 turn/api.md 的完整响应示例非浏览器客户端可用浏览器里却会静默失败。过滤放在签发层一次完成避免每个前端各自实现。TTL 对齐预期会话时长而不是顶格 48 小时。上限 172800 秒超了 API 直接拒绝TTL 越长不代表越好泄露后的暴露窗口更长。常规会议建议 3600 秒离线长任务用 86400 秒。内存缓存层可选但建议。全体客户端复用同一份未过期凭证每个 TTL 周期只打一次生成端点// 未过期直接复用写缓存时预留 1 分钟缓冲留出刷新窗口 private current: { servers: RTCIceServer[]; expiresAt: number } | null null; async get(): PromiseRTCIceServer[] { if (this.current this.current.expiresAt Date.now()) return this.current.servers; const servers await issueFromUpstream(); // 调生成端点并做 53/80 过滤 this.current { servers, expiresAt: Date.now() TTL_SECONDS * 1000 - 60_000 }; return servers; }细节提醒多实例部署时把current换成 KV 即可仓库示例绑定的就是CREDENTIALS_CACHE命名空间。成本上还有一个关键事实与 Cloudflare Calls SFU 搭配使用时 TURN 免费单独使用按 $0.05/GB 出站流量计费。若项目已用 Calls SFUcreateSession即可TURN 会在需要时自动启用无需手动编排两者协调。分步实现从 TURN Key 到 ICE 自动恢复第一步创建 TURN Key密钥立即入库Key 管理端点 Base URL 为https://api.cloudflare.com/client/v4Token 需具备 Calls Write 权限curl -X POST https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/calls/turn_keys \ -H Authorization: Bearer $CF_API_TOKEN \ -H Content-Type: application/json \ -d {name: webrtc-prod}细节提醒响应里的key仅创建时返回一次必须立即保存——后续GET只能拿到uid/name/时间戳密钥不可找回。管理类操作还有GET列出/单个查询、PUT改名、DELETE删除。第二步凭证签发 Worker// src/index.ts —— 全系统唯一的凭证签发入口 interface Env { TURN_KEY_ID: string; // wrangler vars非敏感 TURN_KEY_SECRET: string; // wrangler secret绝不进 vars } const TTL_SECONDS 3600; // 与典型会议时长对齐硬上限 172800 export default { async fetch(request: Request, env: Env): PromiseResponse { const { pathname } new URL(request.url); if (pathname ! /api/turn-credentials) { return new Response(Not found, { status: 404 }); } // 先验客户端身份防止匿名流量无限制消耗生成端点 if (!request.headers.get(x-user-token)) { return new Response(Unauthorized, { status: 401 }); } const upstream await fetch( https://rtc.live.cloudflare.com/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate, { method: POST, headers: { Authorization: Bearer ${env.TURN_KEY_SECRET}, Content-Type: application/json, }, body: JSON.stringify({ ttl: TTL_SECONDS }), } ); if (!upstream.ok) { // 用 502 区分 404调用方据此判断是上游凭证服务异常 return new Response(TURN upstream error, { status: 502 }); } const data await upstream.json(); // 53/80 在浏览器中静默失败在签发层统一剔除前端无需各自处理 const urls data.iceServers.urls.filter( (u: string) !u.includes(:53) !u.includes(:80) ); return Response.json({ iceServers: [ { urls: stun:stun.cloudflare.com:3478 }, { urls, username: data.iceServers.username, credential: data.iceServers.credential }, ], }); }, };细节提醒仓库参考实现只过滤:53因为那是浏览器明确拦截的端口:80浏览器端同样用不到一并剔除可让响应更干净。对应部署配置{ name: turn-credentials-api, main: src/index.ts, compatibility_date: 2025-01-01, vars: { TURN_KEY_ID: your-turn-key-id }, env: { production: { kv_namespaces: [{ binding: CREDENTIALS_CACHE, id: kv-namespace-id }] } } }wrangler secret put TURN_KEY_SECRET第三步浏览器侧的端口取舍浏览器可用的四个端口角色并不对等。仓库给出的尝试顺序是 UDP 优先、TLS 兜底见 turn/patterns.md顺序地址适用网络1turn:turn.cloudflare.com:3478?transportudp普通网络延迟最低2turn:turn.cloudflare.com:3478?transporttcpUDP 被封禁的网络3turns:turn.cloudflare.com:5349?transporttcp企业防火墙最可靠4turns:turn.cloudflare.com:443?transporttcp只放行 443 的环境客户端拿到列表后原样交给RTCPeerConnection即可择优由 ICE 候选对机制自动完成async function loadIceServers(): PromiseRTCIceServer[] { const res await fetch(/api/turn-credentials, { headers: { x-user-token: await getSessionToken() }, }); if (!res.ok) throw new Error(turn credential endpoint returned ${res.status}); return (await res.json()).iceServers; } const pc new RTCPeerConnection({ iceServers: await loadIceServers(), // all先尝试直连失败才走中继中继流量是主要成本来源 iceTransportPolicy: all, });细节提醒IoT 等连通性可预期优先于效率的场景改用iceTransportPolicy: relay强制全量走中继屏幕共享场景可叠加bundlePolicy: max-bundle把多路媒体流聚合到单条传输以降低开销。第四步凭证到期前 1 分钟刷新两个事实决定了刷新逻辑其一setConfiguration()能替换凭证但不会触发 ICE 重启其二仓库建议刷新间隔按TTL*1000 - 60000计算预留 1 分钟缓冲见 turn/gotchas.md// 刷新间隔 TTL - 1 分钟缓冲避免凭证先过期、刷新后到 const refreshIntervalMs TTL_SECONDS * 1000 - 60_000; // TTL3600 时为 50 分钟 async function rotateCredentials(pc: RTCPeerConnection): Promisevoid { const fresh await loadIceServers(); const next pc.getConfiguration(); next.iceServers fresh; pc.setConfiguration(next); } let refreshTimer: number | undefined; function armRefresh(pc: RTCPeerConnection): void { if (refreshTimer ! undefined) window.clearInterval(refreshTimer); refreshTimer window.setInterval(() { rotateCredentials(pc).catch((err) console.error(TURN credential rotate failed, err) ); }, refreshIntervalMs); }细节提醒仓库示例中硬编码的setInterval(..., 3000000)50 分钟与此公式等价。若 TTL 改为 86400刷新周期要同步调整为约 24 小时减 1 分钟不要沿用旧常量。第五步failed / disconnected 时自动 ICE 重启网络切换、TURN 维护、凭证过期任何让候选对失效的事件都会让iceconnectionstatechange进入failed移动网络切换常先进入disconnected。恢复动作顺序固定刷新凭证 → restartIce → 带 iceRestart 的新 offer → 经信令发给对端pc.addEventListener(iceconnectionstatechange, async () { // failed 与 disconnected 都纳入恢复条件网络切换常先短暂进入 disconnected if (pc.iceConnectionState ! failed pc.iceConnectionState ! disconnected) return; // 先换新凭证旧凭证过期后即使重建候选对TURN 认证也会失败 await rotateCredentials(pc); pc.restartIce(); const offer await pc.createOffer({ iceRestart: true }); await pc.setLocalDescription(offer); // 对端需在信令通道上收到后 createAnswer 回传重启才能完成 });细节提醒只有 offer 发起方能主动重启。双端同时进入failed时信令层要加一个重启锁避免双方互相反复发 offer。⚠️ 边界与陷阱限额、高频错误与 IPv6/TLS 边界每分配限额以下限额是按用户分配而非账户级出处 turn/gotchas.md超限不返回错误、直接丢包限额维度每分配取值超限后果新增唯一 IP 速率每秒 5 个新 IP丢包入/出包速率5-10k pps丢包入/出带宽50-100 Mbps丢包凭证 TTL1~172800 秒API 拒绝请求凭证吊销生效秒级计费立即停止活跃连接数秒内断开IP 白名单变更通知14 天IP 变更后旧白名单失效高频错误对照表按现象查现象根因处置长通话在固定时间掉线凭证到期、未刷新到期前 1 分钟刷新第四步浏览器无 relay 候选且不报错:53/:80URL 被浏览器静默拦截服务端过滤第二步生成端点直接拒绝请求ttl: 604800超过 172800 秒上限改ttl: 86400或更小连接整体突然失效硬编码 IP 在 14 天通知后变更用turn.cloudflare.com域名或建 DNS 监控网络切换后不自动恢复failed只打日志、无重启rotate restartIce()第五步任何人可生成无限凭证TURN_KEY_SECRET进了前端 bundle密钥只留在 Worker企业防火墙白名单与 IPv6/TLS 边界严格防火墙环境可对turn.cloudflare.com白名单化 4 个地址IPv4141.101.90.1/32、162.159.207.1/32IPv62a06:98c1:3200::1/128、2606:4700:48::1/128出处 turn/configuration.md。但这些 IP 可能提前 14 天通知后变更必须用dig turn.cloudflare.com A/dig turn.cloudflare.com AAAA周期核查并设置自动告警。两个容易被忽略的边界中继地址只分配 IPv4不支持 RFC 6156IPv6 客户端可以接入、中继流量仍走 IPv4TCP 中继RFC 6062也不支持。TLS 支持 1.1/1.2/1.3TLS 1.3 下建议启用AEAD-CHACHA20-POLY1305-SHA256等套件。验证与调试确认流量真的走了中继实现完成后不要只看通话建立了。用三个观测点确认链路// 1) relay 候选是否被收集typerelay 出现才说明 TURN 通路接通 pc.addEventListener(icecandidate, (e) { if (e.candidate) { console.log(candidate, e.candidate.type, e.candidate.protocol); } }); // 2) 状态流转正常路径为 checking → connected → completed pc.addEventListener(iceconnectionstatechange, () { console.log(ice state, pc.iceConnectionState); }); // 3) 实际选中的候选对selected 为 true 的条目就是当前流量路径 const stats await pc.getStats(); stats.forEach((report) { if (report.type candidate-pair report.selected) { console.log(selected pair, report.protocol, report.nominated); } });细节提醒若只看到host/srflx而没有relay按顺序排查凭证是否已过期、53/80 URL 是否被过滤、防火墙是否放行 3478/5349/443企业网络优先验证turns:443。连接建立慢时检查候选收集完整性与到 Cloudflare 边缘的延迟turn/gotchas.md 的 Slow connection establishment 一节有完整清单。✅ 上线前检查清单凭证仅在服务端签发密钥存于 wrangler secrets 而非 vars签发前先校验客户端身份未鉴权返回 401凭证生成端点已加限流TTL ≤ 预期会话时长且 ≤ 172800 秒53/80 端口在服务端过滤浏览器侧零硬编码 URL刷新定时器按TTL*1000 - 60000计算而非写死常量failed与disconnected都接入刷新 restartIce恢复路径无硬编码 IP如需白名单已建 DNS 监控与 14 天更新流程吊销端点已接入credentials/revoke传 username204 后计费立即停止延伸阅读turn/README.md服务地址、端口清单与阅读顺序turn/api.md凭证生成/吊销 API、Key 管理、TypeScript 类型与 TTL 约束turn/configuration.mdWorker 搭建、wrangler.jsonc、环境变量、IP 白名单turn/patterns.md实现模式与用例示例turn/gotchas.md限额、排查手册与安全清单【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考