เสียง WebRTC ไม่ออกทั้งที่ต่อสำเร็จ: ICE, TURN relay และ SDP timing ที่ทำ call ช้าเป็นสิบวินาที
WebRTC "ต่อได้" ไม่เท่ากับ "media ไหล" — NAT/firewall บังคับให้เสียงต้องวิ่งผ่าน TURN relay และการรอ ICE gathering ครบชุดทำให้ call ช้าเป็นสิบวินาที มาดูกันว่าต้อง force relay, ตั้ง external IP ให้ถูก, และ trigger offer ให้เร็วยังไง
อ่าน ~8 นาที
ปัญหา: signaling เขียว call ติด แต่เงียบสนิท
ผมเคยทำระบบ realtime audio ตัวหนึ่งที่ให้เบราว์เซอร์พูดออกลำโพงปลายทางผ่าน WebRTC เวลา test บนเครื่องเดียวกันหรือ LAN เดียวกันทุกอย่างสวยงาม กดปุ๊บเสียงออกปั๊บ พอ deploy ขึ้น production จริงที่คนพูดอยู่คนละเน็ตกับปลายทาง อาการเปลี่ยนไปสองแบบทันที: บางเคส call "ติด" (signaling บอกว่า connected แล้ว) แต่เงียบสนิทไม่มีเสียง และบางเคสกว่าเสียงจะออกต้องรอ ~40 วินาที ซึ่งสำหรับระบบประกาศเสียงคือช้าจนใช้งานไม่ได้
แก่นของบทความนี้คือความเข้าใจผิดข้อเดียวที่ทำให้คนเสียเวลา debug เป็นวันๆ: "เชื่อมต่อ signaling สำเร็จ" กับ "media ไหลจริง" เป็นคนละเรื่องกันคนละ channel กันโดยสิ้นเชิง พอแยกสองเรื่องนี้ออกจากกันได้ ทั้ง bug เสียงเงียบและ bug call ช้าจะกลายเป็นปัญหาที่มีคำตอบชัดเจน ไม่ใช่เวทมนตร์ดำอีกต่อไป
แยกสองเรื่อง: signaling channel กับ media channel
WebRTC มีสองสายที่ทำงานแยกกัน สายแรกคือ signaling — การแลก SDP offer/answer กันผ่านช่องทางที่เราเลือกเอง (WebSocket, SIP-over-WSS, อะไรก็ได้) เพื่อตกลงว่าจะคุยกันด้วย codec อะไร เข้ารหัสยังไง และมี network path ไหนให้ลองบ้าง สายที่สองคือ media — RTP packet ที่แบกเสียงจริง วิ่งตรงระหว่างสอง endpoint (peer-to-peer) หรือผ่านตัวกลาง
กับดักคือ signaling สำเร็จก่อนได้ง่ายมาก เพราะมันวิ่งผ่าน server ที่เรา control และมักมี public endpoint อยู่แล้ว แต่ media ต้องหา path ตรงระหว่างสองเครื่องที่อาจซ่อนอยู่หลัง NAT/firewall คนละชั้น สอง endpoint อาจ "ตกลงกันได้" ว่าจะส่งเสียงยังไง แต่ตอนส่ง packet จริงยิงไม่ถึงกัน ผลคือ state บอก connected แต่ไม่มีเสียง
บทเรียนแรกที่ผมยึด: อย่าเชื่อ connection state ของ signaling เป็นตัวชี้ว่า call สำเร็จ ให้ดูที่ RTP flow จริง ฝั่ง server media ส่วนใหญ่ query ได้ว่ามี packet Tx/Rx จริงหรือไม่ — ถ้า Rx = 0 คือ media ไม่ไหล ไม่ว่า signaling จะเขียวแค่ไหน
ICE candidate types: host / srflx / relay และเมื่อไหร่ต้องพึ่ง relay
WebRTC หา media path ด้วยกลไกชื่อ ICE (Interactive Connectivity Establishment) แต่ละฝั่งจะรวบรวม "candidate" คือที่อยู่ที่ตัวเองน่าจะรับ packet ได้ มีสามประเภทเรียงจากดีสุดไปพึ่งพาตัวกลางมากสุด:
- host — IP ในเครื่องตรงๆ (เช่น
192.168.x.x) ใช้ได้เมื่ออยู่ LAN เดียวกัน - srflx (server reflexive) — public IP ที่ STUN server มองเห็นเวลาเรายิงออกไป ใช้เมื่ออยู่หลัง NAT แบบเบาๆ ที่ยอมให้ยิงกลับเข้ามาได้
- relay — ที่อยู่บน TURN server ตัวกลางที่ทำหน้าที่ relay packet ให้ ใช้เมื่อ NAT/firewall เข้มจนยิงตรงหากันไม่ได้เลย
ในโลกจริงที่ผู้ใช้อยู่หลัง corporate firewall, symmetric NAT, หรือ CGNAT ของ ISP มือถือ candidate แบบ host และ srflx จะใช้ไม่ได้ เหลือทางเดียวคือ relay ตรงนี้เองที่เป็นสาเหตุอันดับหนึ่งของอาการ "เงียบบน prod แต่ดีบน LAN": ตอน dev ทุกคนอยู่ LAN เดียวกัน host candidate จับคู่ได้ทันที เลยไม่เคยเจอเคสที่ต้องใช้ relay จนขึ้น prod
หลักการ transferable: ถ้าระบบต้องรองรับผู้ใช้ข้ามเครือข่ายจริง คุณต้องมี TURN server เสมอ STUN อย่างเดียวไม่พอ — STUN แค่บอกว่า public IP เราคืออะไร แต่ไม่ได้ relay packet ให้ ถ้าไม่มี TURN เคสที่ NAT เข้มจะเงียบ 100% และมันคือสัดส่วนที่ใหญ่กว่าที่คุณคิด
อาการ call ช้า ~40s: รอ ICE gathering ครบทั้งชุด
ทีนี้มาถึง bug ที่สอง — call ช้า ~40 วินาที ต้นเหตุคือ library WebRTC จำนวนมาก (รวมถึงตัวที่ผมใช้) มีพฤติกรรม default คือรอ ICE gathering ให้ "complete" ก่อนแล้วค่อยส่ง offer ออกไป
ปัญหาคือคำว่า complete หมายถึงรวบรวม candidate ให้ครบทุกประเภทจากทุก network interface ซึ่งบางตัว โดยเฉพาะ srflx/relay ต้องยิงไปถาม STUN/TURN แล้วรอ response ถ้า server ตอบช้าหรือ candidate บางตัว timeout ตัว gathering จะค้างรอจน hit timeout ภายในของ library ซึ่งบ่อยครั้งตั้งไว้ราวๆ ครึ่งนาทีขึ้นไป ผลคือ offer ไม่ถูกส่งจนกว่าจะครบ = ผู้ใช้กดปุ่มแล้วนั่งรอ 40 วินาที
จุดที่ต้องเข้าใจ: เราไม่จำเป็นต้องได้ candidate ครบทุกตัวก่อนเริ่มคุย เราแค่ต้องการ candidate ที่ใช้งานได้จริงอย่างน้อยหนึ่งตัว บน prod ที่บังคับ relay อยู่แล้ว candidate ที่เราสนใจคือ relay candidate ตัวแรก — พอได้ตัวนั้นก็ส่ง offer ได้เลย ไม่ต้องรอที่เหลือ
แก้: ฟัง icecandidate → ได้ relay ตัวแรก → ส่ง offer ทันที (cap เวลา)
วิธีแก้คือเลิกรอ gathering complete แล้วเปลี่ยนไปฟัง event icecandidate เอง พอเจอ candidate ประเภท relay ตัวแรกก็ trigger การส่ง offer ทันที และตั้ง เพดานเวลา (cap) ไว้กันเหนียว — ถ้าครบเวลาแล้วยังไม่เจอ relay ก็ส่งไปด้วยของที่มี ดีกว่าค้างรอไปเรื่อยๆ
// แนวคิด: ส่ง offer ทันทีที่ได้ relay candidate ตัวแรก
// ไม่ต้องรอ ICE gathering complete (ซึ่งกินเวลาเป็นสิบวินาที)
function sendOfferWhenReady(pc, ready) {
let sent = false;
const fire = (reason) => {
if (sent) return;
sent = true;
ready(); // เรียก callback ให้ layer signaling ส่ง SDP offer ออกไป
console.log('offer fired:', reason);
};
pc.addEventListener('icecandidate', (ev) => {
// ev.candidate = null คือ gathering complete (ทางช้า — เผื่อไว้)
if (!ev.candidate) return fire('gathering-complete');
// สนใจเฉพาะ relay: บน prod เราบังคับผ่าน TURN อยู่แล้ว
if (ev.candidate.candidate.includes(' typ relay')) {
fire('first-relay-candidate');
}
});
// เพดานเวลา: กันเคส TURN ตอบช้า/candidate ไม่มา จะได้ไม่ค้าง
setTimeout(() => fire('timeout-cap'), 3000);
}
ผลลัพธ์จริงคือ call ที่เคยรอ ~40s ลงมาเหลือหลัก 1-3 วินาที เพราะเราตัดการรอ candidate ที่ไม่ได้ใช้ทิ้งไป หลักการ transferable: latency ของ WebRTC setup มักไม่ได้มาจากเครือข่ายช้า แต่มาจากการรอของที่ไม่จำเป็น รู้ว่าจะใช้ path ไหนแล้วก็ไม่ต้องรอ path อื่นให้ครบ
Production: force relay policy + ตั้ง TURN external IP เป็น public IP จริง
สอง config นี้คือจุดที่ผมเห็นคนพลาดบ่อยสุด และมันทำให้เสียงเงียบแบบไร้ error
1. บังคับ relay policy. ตอน dev ปล่อยให้ ICE เลือก path เองได้ (จะได้เร็ว) แต่บน prod ที่ผู้ใช้ข้ามเน็ตจริง ควรตั้ง iceTransportPolicy: 'relay' เพื่อบังคับให้ใช้ relay เท่านั้น ข้อดีคือพฤติกรรมกลายเป็น deterministic — ไม่ต้องลุ้นว่า host/srflx จะใช้ได้ไหมในเน็ตของแต่ละคน ทุก call วิ่งผ่าน TURN เหมือนกันหมด debug ง่ายกว่ามาก
const pc = new RTCPeerConnection({
iceServers: [{
urls: 'turn:turn.example.com:3478',
username: 'turnuser',
credential: 'turnpass', // prod ควรใช้ HMAC time-limited cred ไม่ใช่ static
}],
// บังคับ relay: ทุก media วิ่งผ่าน TURN — deterministic บน prod
iceTransportPolicy: 'relay',
});
2. ตั้ง external IP ของ TURN ให้เป็น public IP จริง. นี่คือ pitfall ที่เจ็บที่สุด TURN server ที่รันในคอนเทนเนอร์/หลัง NAT จะรู้จักแค่ IP ภายในของตัวเอง (เช่น 192.168.x.x) ถ้าไม่บอกมันว่า public IP จริงคืออะไร มันจะแจก relay candidate เป็น IP ภายในที่โลกภายนอกยิงไม่ถึง ผล: ICE จับคู่ candidate สำเร็จบนกระดาษ แต่ RTP วิ่งไปหา IP ที่ route ไม่ถึง = เงียบ ต้องตั้ง external IP ให้ตรง public IP ของ server เสมอ
# coturn config — บอก public IP จริงให้ TURN แจก candidate ถูก
# ผิด: ปล่อยว่าง → แจก relay candidate เป็น IP ภายในที่ยิงไม่ถึง
external-ip=203.0.113.10 # ← public IP จริงของ server (ตัวอย่าง)
listening-port=3478
Codec constraint: เมื่อ stack รองรับ codec จำกัด
อีกสาเหตุที่ทำเสียงเงียบแบบ silent คือ codec ไม่ match กัน WebRTC ฝั่งเบราว์เซอร์ default ชอบ Opus แต่ media server หรือ image ที่คุณใช้อาจรองรับแค่ codec เก่าอย่าง ulaw (G.711) ถ้าสองฝั่งตกลง codec ร่วมกันไม่ได้ SDP negotiation จะล้มหรือได้ media line ที่ไม่มี codec ใช้งานได้ ผลคือ call ดู "ติด" แต่ไม่มีเสียง
ผมเจอเคสที่ image ของ media server ที่ build มาไม่มี Opus เลย เหลือ ulaw อย่างเดียว ทางแก้คือต้องรู้ constraint นี้ตั้งแต่ต้นแล้ว config ฝั่ง endpoint ให้ allow เฉพาะ codec ที่ทั้งสองฝั่งมีจริง อย่าปล่อยให้ negotiation ไปจบด้วย codec ที่ฝั่งใดฝั่งหนึ่ง decode ไม่ได้
หลักการ transferable: codec เป็นข้อจำกัดของทั้ง stack ไม่ใช่แค่เบราว์เซอร์ ก่อนออกแบบให้ไล่เช็คว่า media server, image, และ hardware ปลายทางรองรับ codec อะไรร่วมกันบ้าง แล้วบีบ negotiation ให้อยู่ในเซตนั้น การไล่ debug codec ทีหลังยากกว่ามากเพราะมันไม่ throw
สรุป
- signaling connected ≠ media ไหล — สองสายทำงานแยกกัน ยึด RTP Rx จริงเป็นตัวชี้ว่า call สำเร็จ ไม่ใช่ connection state
- ผู้ใช้ข้ามเน็ตจริงต้องมี TURN เสมอ — host/srflx ใช้ไม่ได้หลัง NAT/firewall เข้ม STUN อย่างเดียวไม่ relay packet ให้
- call ช้าเพราะรอ ICE gathering ครบ — ฟัง
icecandidateเอง ได้ relay ตัวแรกก็ส่ง offer ทันที พร้อม cap เวลากันค้าง - บน prod บังคับ
iceTransportPolicy: 'relay'เพื่อให้พฤติกรรม deterministic แล้ว debug ง่าย - TURN ต้องรู้ public IP จริงของตัวเอง ไม่งั้นแจก relay candidate เป็น IP ภายในที่ยิงไม่ถึง = เงียบไร้ error
- เช็ค codec ให้ match ทั้ง stack — media server/image/hardware ต้องมี codec ร่วมกัน ไม่งั้น negotiation จบด้วย call ที่ติดแต่ไม่มีเสียง




