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 与临时凭证的分工
写代码前先明确各组件职责与必须记住的硬参数:
| 组件 | 职责 | 关键参数 |
|---|---|---|
STUN(stun.cloudflare.com:3478) | 发现客户端公网候选,用于尝试直连 | 无需凭证 |
TURN over UDP/TCP(turn.cloudflare.com:3478) | 直连被阻时中继媒体流 | 临时username/credential |
TURN over TLS(turns:turn.cloudflare.com:5349/443) | 企业防火墙只放行 TLS 端口 | 同一份临时凭证 |
| 临时凭证 | 客户端向 TURN 服务器证明身份 | TTL 取值 1~172800 秒(48 小时),超出被 API 拒绝 |
| ICE restart | failed/disconnected后重建候选对 | createOffer({ iceRestart: true }) |
需要记牢的数字:172800(TTL 上限,秒)、3478 / 5349 / 443(浏览器可用端口)、53 / 80(非浏览器客户端可用、浏览器不可用)、3000000ms(50 分钟,对应 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?transport=udp与turn:turn.cloudflare.com:80?transport=tcp两类地址(见 turn/api.md 的完整响应示例),非浏览器客户端可用,浏览器里却会静默失败。过滤放在签发层一次完成,避免每个前端各自实现。 - TTL 对齐预期会话时长,而不是顶格 48 小时。上限 172800 秒,超了 API 直接拒绝;TTL 越长不代表越好,泄露后的暴露窗口更长。常规会议建议 3600 秒,离线长任务用 86400 秒。
- 内存缓存层可选但建议。全体客户端复用同一份未过期凭证,每个 TTL 周期只打一次生成端点:
// 未过期直接复用;写缓存时预留 1 分钟缓冲,留出刷新窗口 private current: { servers: RTCIceServer[]; expiresAt: number } | null = null; async get(): Promise<RTCIceServer[]> { 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 SFU,createSession即可,TURN 会在需要时自动启用,无需手动编排两者协调。
分步实现:从 TURN Key 到 ICE 自动恢复
第一步:创建 TURN Key,密钥立即入库
Key 管理端点 Base URL 为https://api.cloudflare.com/client/v4,Token 需具备 "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): Promise<Response> { 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):
| 顺序 | 地址 | 适用网络 |
|---|---|---|
| 1 | turn:turn.cloudflare.com:3478?transport=udp | 普通网络,延迟最低 |
| 2 | turn:turn.cloudflare.com:3478?transport=tcp | UDP 被封禁的网络 |
| 3 | turns:turn.cloudflare.com:5349?transport=tcp | 企业防火墙,最可靠 |
| 4 | turns:turn.cloudflare.com:443?transport=tcp | 只放行 443 的环境 |
客户端拿到列表后原样交给RTCPeerConnection即可,择优由 ICE 候选对机制自动完成:
async function loadIceServers(): Promise<RTCIceServer[]> { 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; // TTL=3600 时为 50 分钟 async function rotateCredentials(pc: RTCPeerConnection): Promise<void> { 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 | 丢包 |
| 凭证 TTL | 1~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/32;IPv62a06: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 6156),IPv6 客户端可以接入、中继流量仍走 IPv4;TCP 中继(RFC 6062)也不支持。TLS 支持 1.1/1.2/1.3,TLS 1.3 下建议启用AEAD-CHACHA20-POLY1305-SHA256等套件。
验证与调试:确认流量真的走了中继
实现完成后,不要只看"通话建立了"。用三个观测点确认链路:
// 1) relay 候选是否被收集:type=relay 出现才说明 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传 username,204 后计费立即停止)
延伸阅读
- turn/README.md:服务地址、端口清单与阅读顺序
- turn/api.md:凭证生成/吊销 API、Key 管理、TypeScript 类型与 TTL 约束
- turn/configuration.md:Worker 搭建、wrangler.jsonc、环境变量、IP 白名单
- turn/patterns.md:实现模式与用例示例
- turn/gotchas.md:限额、排查手册与安全清单
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考