Push ต้องการ inbound reachability ที่โลกจริงมักไม่มี: ออกแบบ agent เป็น outbound-only + poll fallback
'connection refused' จากอุปกรณ์เกือบทุกครั้งไม่ใช่ config bug แต่เป็น network topology bug — receiver ของคุณอยู่หลัง NAT/ใน WSL/ใน container ที่อุปกรณ์ route ไปไม่ถึง มาดูวิธีคิดที่พิสูจน์ reachability ก่อนโทษ config และออกแบบ agent เป็น outbound-only ที่ทะลุทุก firewall
อ่าน ~8 นาที
Thesis: 'connection refused' ส่วนใหญ่คือ topology bug ไม่ใช่ config bug
ผมเคยเสียเวลาเกือบทั้งวันไล่แก้ config บนอุปกรณ์ IoT ตัวหนึ่ง เพราะมันตั้ง webhook มาที่ backend ของผมแล้วได้ connection refused ตลอด ผมสลับ payload format, เช็ค auth header, เปลี่ยน content-type, restart อุปกรณ์ซ้ำแล้วซ้ำอีก — จนกระทั่งบ่ายแก่ๆ ถึงเพิ่งฉุกคิดว่า: ปัญหาไม่ได้อยู่ที่ "อุปกรณ์ส่งอะไร" แต่อยู่ที่ "packet มันเดินทางไปถึง receiver ของผมได้จริงไหม"
บทเรียนที่ตกผลึกคือ: เมื่ออุปกรณ์ในโลกจริงยิง push (webhook, callback, HTTP POST) มาแล้วล้มเหลว ให้ตั้งสมมติฐานแรกว่ามันคือ network topology bug ไม่ใช่ config bug เสมอ เพราะ push architecture มีสมมติฐานซ่อนอยู่ข้อหนึ่งที่แข็งมาก — มันต้องการ inbound reachability: อุปกรณ์ต้อง route ไปถึง IP:port ของ receiver คุณได้ และ receiver ต้องมี process ที่ listen อยู่ตรง socket นั้นจริง โลกจริงพัง reachability นี้ตลอดเวลา ทั้ง NAT, firewall, network isolation ของ container, และ WSL2
เช็ค listen socket ฝั่ง host ก่อนแตะ config อุปกรณ์
ก่อนจะไปยุ่งกับอุปกรณ์เลย ให้พิสูจน์ 3 ชั้นนี้จากล่างขึ้นบนที่ฝั่ง host ก่อน อย่าเดา:
# 1) มี process listen อยู่จริงไหม และ listen บน interface ไหน?
# 0.0.0.0 = ทุก interface (ดี), 127.0.0.1 = localhost เท่านั้น (LAN เข้าไม่ได้!)
ss -tlnp | grep 8080
# 2) จากตัว host เอง ยิงเข้า service ตัวเองได้ไหม?
curl -sS -m 3 http://127.0.0.1:8080/health
# 3) จากเครื่องอื่นใน LAN เดียวกัน (จำลองมุมของอุปกรณ์) ยิงเข้ามาได้ไหม?
# รันจากเครื่องอื่น ชี้มาที่ LAN IP ของ host
curl -sS -m 3 http://192.168.1.50:8080/health
ถ้าข้อ 1 เห็น service bind อยู่ที่ 127.0.0.1 แทน 0.0.0.0 — จบเลย นี่คือสาเหตุ อุปกรณ์ใน LAN ไม่มีทางถึง localhost ของ host คุณได้ ถ้าข้อ 2 ผ่านแต่ข้อ 3 fail แสดงว่า packet ถูก firewall/NAT block ระหว่างทาง ไม่เกี่ยวกับ config อุปกรณ์แม้แต่นิดเดียว การพิสูจน์ 3 ข้อนี้ใช้เวลาไม่ถึงหนึ่งนาที แต่ตัดปัญหาออกได้ครึ่งจักรวาล
WSL2 mirrored networking: outbound ได้ แต่ inbound จาก LAN ยิงเข้าไม่ได้
กับดักที่ทำให้ผมเสียวันไปคือการ dev บน WSL2 ผมรัน receiver ไว้ใน WSL แล้วคิดว่าอุปกรณ์ใน LAN จะยิง webhook เข้ามาได้ เพราะผมเห็น IP ของเครื่อง Windows ชัดๆ — แต่มันไม่ได้ WSL2 รันอยู่ใน virtual network ของตัวเอง แม้แต่ mirrored networking mode ที่ share IP กับ host ก็ยังออกแบบมาให้ outbound ทะลุได้ แต่ inbound แปลกหน้าจาก LAN ยิงเข้ามาที่ process ใน WSL ไม่ได้
หลักการที่ generalize ได้: ทุก virtualized/containerized environment มี network boundary ของตัวเอง ไม่ว่าจะเป็น WSL, Docker (โดยไม่ได้ publish port), หรือ VM ใน NAT network — process ข้างในยิงออกได้เสมอ แต่ packet ที่ริเริ่มจากข้างนอกจะไปไม่ถึงจนกว่าคุณจะทำ explicit port-forward/publish คุณมีสองทางเลือก:
- ย้าย receiver ไปรันฝั่ง host จริง ที่อยู่ใน routable network เดียวกับอุปกรณ์ (production ที่ถูกต้อง) แล้ว dev ใน WSL ก็แค่ยิง test ออกไปหา service นั้น
- Flip ทั้ง flow เป็น poll — ให้ receiver เป็นฝ่ายไปดึงข้อมูลเอง ซึ่งใช้แค่ outbound ที่ทะลุ boundary ได้อยู่แล้ว
Poll-mode เป็น first-class design ไม่ใช่ hack สำรอง
คนมักมอง poll เป็นของด้อยกว่า push — เปลืองกว่า, latency สูงกว่า — แล้วเก็บไว้เป็น "ทางหนีทีไล่" แต่ในสภาพแวดล้อมที่ inbound reachability ไม่แน่นอน poll มีข้อได้เปรียบเชิงสถาปัตยกรรมที่ push ไม่มีวันมี: มันใช้แค่ outbound connection ที่ทะลุ NAT และ firewall ขาออกได้แทบทุกที่ อุปกรณ์หลัง NAT ที่ push เข้ามาไม่ได้เลย ยังยอมให้ backend คุณ poll มันได้สบายๆ ผมเลยออกแบบ poll ให้เป็น first-class มี 3 เรื่องที่ต้องทำให้ถูก:
1) Dedupe ด้วย composite key — เพราะ window ของแต่ละรอบ poll จะ overlap กัน (ตั้งใจให้ overlap) คุณจะดึง event ซ้ำ ต้องมี key ที่ระบุ event ได้ unique จริงข้าม poll:
# composite key = device + channel + event type + timestamp
# กันซ้ำแม้ event เดียวกันโผล่ในหลายรอบ poll
def dedupe_key(event):
return f"{event['device_id']}:{event['channel']}:{event['type']}:{event['ts']}"
seen = set() # ในโลกจริงใช้ Redis SET + TTL แทน in-memory
def process(events):
for e in events:
k = dedupe_key(e)
if k in seen:
continue
seen.add(k)
handle(e)
2) คำนวณ clock offset — นาฬิกาอุปกรณ์กับ server มักเพี้ยนกัน ถ้าคุณ query event ด้วยช่วงเวลาแล้วยึด clock ตัวเอง คุณจะพลาด event ที่ตกขอบ วัด offset แล้วชดเชย:
# server_now = เวลาที่เรายิง request, device_ts = เวลาที่อุปกรณ์บอกกลับมา
offset = server_now - device_ts
# แปลงช่วงเวลาที่จะ query ให้อยู่ใน frame ของอุปกรณ์ก่อนส่งไป
query_from = (window_start - offset) - SAFETY_MARGIN
3) Interval สั้นพอให้ window overlap — อย่าให้ช่วงที่ query ต่อรอบเท่ากับ interval พอดี ต้องดึง window ที่กว้างกว่า interval เล็กน้อยเสมอ เพื่อให้ขอบของสองรอบซ้อนกัน จะได้ไม่มี event หลุดร่องระหว่างรอบ (dedupe ในข้อ 1 จะกินส่วนซ้ำทิ้งเอง) — overlap คือ feature ไม่ใช่ bug
Active reachability probe แทน activity-based liveness
เรื่องที่มากับ push/poll คือการบอกว่าอุปกรณ์ "online" ไหม วิธีที่คนทำกันคือ activity-based liveness: ถ้าเพิ่งได้ event จากอุปกรณ์ภายใน N นาที ก็ถือว่า online วิธีนี้พังสองทาง — อุปกรณ์ที่ เน็ตหลุดไปแล้ว จะยังโชว์ online จนครบ timeout (activity lag) และอุปกรณ์ที่ idle แต่ตายไปแล้ว (เงียบเพราะไม่มีอะไรเกิด vs เงียบเพราะดับ) แยกจากกันไม่ออกเลย
ทางที่ดีกว่าคือ active reachability probe จาก vantage point ที่ reach เป้าได้จริง ประเด็นสำคัญคือคำว่า vantage point — ต้อง probe จากจุดที่อยู่ใน network เดียวกับอุปกรณ์ ไม่ใช่จาก cloud ที่อาจ route ไปไม่ถึง ผมวาง edge agent ตัวเล็กไว้ใน LAN เดียวกับอุปกรณ์ ให้มันเป็นคน probe แล้ว report กลับ cloud แบบ outbound:
# edge agent อยู่ใน LAN เดียวกับอุปกรณ์ → probe แล้ว report ออก (outbound)
import socket, time, requests
def probe(host, port, timeout=2.0):
t0 = time.monotonic()
try:
with socket.create_connection((host, port), timeout=timeout):
return {"reachable": True, "rtt_ms": round((time.monotonic()-t0)*1000)}
except OSError as e:
return {"reachable": False, "error": str(e)}
# report กลับ cloud ด้วย outbound POST — ทะลุ NAT ได้เสมอ
result = probe("192.168.1.77", 80)
requests.post("https://collector.example.com/liveness",
json={"target": "192.168.1.77", **result}, timeout=5)
ออกแบบให้ probe เป็น fallback ที่ backward-compatible: ถ้ามี edge agent ก็ใช้ผล probe เป็น source of truth ของ liveness ถ้าไม่มี ก็ค่อย fall กลับไปใช้ activity-based แบบเดิม ระบบที่ deploy ไปแล้วไม่พัง และที่ไหนมี agent ก็ได้ signal ที่แม่นกว่าทันที
สรุป: วิธีคิดที่เอาไปใช้ได้จริง
- 'connection refused' จากอุปกรณ์ = ตั้งสมมติฐานว่าเป็น topology bug ก่อน อย่าเพิ่งไล่แก้ config — พิสูจน์ reachability ก่อนเสมอ
- เช็ค listen socket ฝั่ง host ก่อน: service bind อยู่ 0.0.0.0 หรือ 127.0.0.1? ยิงจาก host เองได้ไหม? ยิงจากเครื่องอื่นใน LAN ได้ไหม? สามข้อนี้ตัดปัญหาได้ครึ่งหนึ่งใน 1 นาที
- Virtualized/containerized environment (WSL, Docker, VM) = outbound ได้ แต่ inbound แปลกหน้าไม่ได้ receiver ที่ต้องรับ inbound ต้องรันฝั่ง host จริงที่ routable หรือ flip เป็น poll
- Poll เป็น first-class: outbound ทะลุ NAT/firewall ได้เสมอ ทำให้ถูกด้วย composite dedupe key, clock offset compensation, และ interval ที่ให้ window overlap
- Liveness ใช้ active probe จาก vantage point ที่ reach เป้าได้จริง ดีกว่า activity-based ที่ทั้ง lag เมื่อเน็ตหลุด และแยก idle ออกจาก dead ไม่ได้ — ออกแบบเป็น fallback ที่ backward-compatible
- บทเรียนใหญ่: อย่าเสียทั้งวันไล่ config อุปกรณ์ ในเมื่อ error จริงอยู่ที่ network layer ของ receiver ฝั่งคุณเอง




