ที่อยู่ในหน้านี้ถูกแทนด้วยที่อยู่ของ server นี้เอง https://qm.weekday100.com
Queue Manager — คู่มือการปฏิบัติการ
คู่มือนี้มีสองส่วน:
- สิ่งที่ต้องตัดสินใจ ก่อน ให้ server นี้รับ traffic จริง ได้แก่ การตั้งค่า การตั้ง proxy/CDN (ถ้าตั้งผิด limit ต่อ IP จะใช้ไม่ได้) API key และวิธีผูก pass กับผู้เข้าชม
- สิ่งที่ใช้หลังระบบเปิดแล้ว ได้แก่ operator console, scheduled drop และ metrics
INTEGRATION.md อธิบายฝั่งเว็บของลูกค้า (script tag, /api/check) ไฟล์นี้อธิบายฝั่ง server ทั้งสองไฟล์เปิดอ่านได้จาก server ที่รันอยู่ที่ /docs และมีลิงก์อยู่บน header ของ dashboard ฉบับภาษาไทยอยู่ที่ /docs/th (ไฟล์ต้นฉบับอยู่ใน docs/th/) ถ้าสองภาษาขัดกัน ให้ถือฉบับภาษาอังกฤษเป็นหลัก
ตัวแปรสภาพแวดล้อม (Environment variables)
ไม่ต้องตั้งตัวไหนเลยก็ได้ ค่าเริ่มต้นปลอดภัยสำหรับเครื่องเดียวบน localhost แต่ตั้งใจให้ ไม่ ปลอดภัยถ้าเปิดออกสู่ภายนอกโดยไม่แก้
| Variable | ค่าเริ่มต้น | หน้าที่ |
|---|---|---|
PORT | 8080 | port ที่ server รอรับ connection |
HOST | (ไม่ได้ตั้ง) | address ที่ server รอรับ connection ถ้าไม่ตั้ง จะรับทุก network interface ยกเว้นตอนที่ ADMIN_KEY ยังเป็นค่าเริ่มต้น ตอนนั้นการไม่ตั้งแปลว่ารับเฉพาะ loopback (127.0.0.1) และถ้าตั้ง HOST เป็นค่าที่ไม่ใช่ loopback server จะไม่ยอมเริ่ม (ดู การปฏิเสธการเริ่มทำงาน) ตั้งเป็น 127.0.0.1 ถ้าอยากให้ตอบเฉพาะ loopback เหมาะกับกรณีที่มีแค่ reverse proxy บนเครื่องเดียวกันที่ควรคุยกับ process นี้ได้ |
DATA_DIR | (เอาออกแล้ว) | เอาออกใน step 5: ระบบไม่เขียนอะไรลงดิสก์ในเครื่องอีกแล้ว ถ้าตั้งไว้ ระบบจะไม่สนใจค่านี้และขึ้นคำเตือนตอนเริ่ม (DATA_DIR is set but ignored since step 5: nothing is written to local disk.) manifest เก่าจึงยัง deploy ได้ ให้ลบออกจาก manifest |
ADMIN_KEY | admin-dev | key ของ Private API ใช้กับ /api/admin/* (สร้าง/ลบ room, ตั้ง rate, flush, eject, ดู metrics) ห้ามแจกให้ใคร ตราบที่ยังเป็นค่าเริ่มต้น server จะรับเฉพาะ loopback และไม่ยอมเริ่มถ้า HOST ไม่ใช่ loopback เหตุผลคือค่าเริ่มต้นนี้เขียนไว้ในเอกสารนี้ ใครก็รู้ จึงห้ามให้เครื่องอื่นเข้าถึงได้เด็ดขาด |
PUBLIC_API_KEY | (ไม่ได้ตั้ง) | key ของ Public API ใช้ได้อย่างเดียว คือรับรองตัวผู้เข้าชมปลายทางใน /api/check?ip=&agent= ต้องส่งผ่าน Authorization: Bearer เท่านั้น ถ้าส่งเป็น ?key= จะถูกปฏิเสธ (401 credential_in_query) แจกให้ edge server หรือ backend ทุกตัวได้อย่างปลอดภัย ถ้าไม่ตั้ง การเรียกแบบนี้ต้องใช้ ADMIN_KEY แทน ซึ่งเท่ากับให้สิทธิ์เกินจำเป็น ระบบจึงแสดงคำเตือน |
ADMIN_HOST | (ไม่ได้ตั้ง) | ชื่อ host (คั่นด้วย comma) ที่ operator console และ /api/admin/* จะตอบ ถ้าไม่ตั้ง: ระบบที่มีแต่ room แบบ snippet จะตอบทุกชื่อ แต่ เมื่อมี room แบบ inline แล้ว console จะตอบเฉพาะ IP ตรง ๆ (127.0.0.1, IP ของ pod), localhost หรือ host ของ PUBLIC_URL (QM-440) ส่วน Host อื่นทุกชื่อ หน้าของ console และ /__qm/ จะตอบ 404 เปล่า ๆ ขณะที่ตัว queue ยังตอบปกติ เหมือนตอนตั้ง ADMIN_HOST ทุกอย่าง (QM-428; ชื่อ host ของลูกค้ายังเปิด console ได้ใต้ /__qm/) ถ้าตั้ง: ใช้เพื่อวาง console บนชื่อจริง และ console จะตอบ เฉพาะชื่อนี้เท่านั้น ชื่ออื่นทุกชื่อ (รวมถึง IP ของ pod) จะตอบ 404 สำหรับหน้าของ console ได้แก่ /, /api/admin/*, /metrics, /docs, /progress และไฟล์ของ console เอง /public/admin.html และบนชื่อ host ที่อยู่หน้า room แบบ inline ก็คือ path เดียวกันใต้ /__qm/ ค่านี้กั้นเฉพาะ console ตัว queue เอง (/healthz, /snippet/qm.js, /w/<room>, /api/check, /api/join, status, events, notify, หน้ารอ) ยังตอบทุกชื่อ host host ของ snippet และการ probe ผ่าน IP ของ pod จึงใช้ได้เหมือนเดิม ตั้ง room เป็น inline บนชื่อใน ADMIN_HOST หรือบน host ของ PUBLIC_URL ไม่ได้ (400 reserved_host) และหน้าของ console บนชื่อเหล่านั้นจะไม่ถูกส่งต่อให้ room ไหนเลย แม้ room ที่บันทึกไว้ก่อนมีกฎนี้ ถ้าไม่ตั้ง IP ตรง ๆ และ localhost ก็เป็นของ console ด้วย /api/admin/* และ /api/v1/admin/* บนชื่อเหล่านี้จึงไม่ถูกส่งต่อให้ room ไหนเช่นกัน แม้ room ที่ targetUrl เป็น address ของ server นี้เอง (QM-439) ส่วนที่เหลือของ host นั้นยังเป็นของ room ซึ่งทำให้รันโหมด inline บน laptop ได้ และไม่ว่า host ไหน proxy จะไม่ส่งต่อ credential ของ server นี้เลย: Authorization: Bearer ที่เป็น ADMIN_KEY หรือ operator key จะถูกตัดออกก่อน request ไปถึง origin ส่วน Authorization ของแอปเองผ่านไปตามเดิม ดู การ deploy สองแบบ |
ADMIN_AUTH_FAIL_PER_MIN | 20 | จำนวนครั้งที่ยืนยันตัวตน admin ผิดได้ ต่อ address ต่อนาที (นับแบบ token bucket) เกินแล้ว credential ทุกตัว จาก address นั้น รวมถึงตัวที่ถูก จะได้ 429 too_many_auth_failures พร้อม Retry-After จนกว่าโควตาจะเติมกลับมาได้หนึ่งครั้ง นับเฉพาะครั้งที่ผิด 0 คือปิด ดู สอง key สองรัศมีความเสียหาย |
SECRET | สุ่มใหม่ทุกครั้งที่เริ่ม (memory); จำเป็นเมื่อใช้ SIDESTORE=pg | secret หลักที่ใช้ sign ticket ของผู้เข้าชม, ลิงก์ monitor, session ของ console และตัวตรวจ (verifier) ของ operator key ค่าเริ่มต้นคือ sign ด้วย secret นี้ตรง ๆ เหมือน release ก่อน ๆ ถ้าตั้ง TOKEN_SIGN_V2=1 งานแต่ละประเภทจะมี key ของตัวเองที่แตกออกมาจาก secret นี้ (HKDF-SHA256) และ token ทุกใบจะบอกชื่อ key ไว้ใน kid ระบบรับทั้งสองแบบเสมอ ต้องตั้งค่านี้ ถ้ารันมากกว่าหนึ่ง instance ไม่อย่างนั้น token ที่ instance หนึ่งออกให้จะถูก instance อื่นปฏิเสธ ถ้าไม่ตั้ง ระบบสร้าง key ใหม่ทุกครั้งที่เริ่ม restart แต่ละครั้งจึงทำให้ ticket, ลิงก์ monitor, session ของ console และ operator key ทุกอันใช้ไม่ได้ (รับได้ใน development ซึ่งไม่มีอะไรอยู่รอดหลัง restart อยู่แล้ว) จำเป็นเมื่อ SIDESTORE=pg: server จะไม่ยอมเริ่มถ้าไม่มีค่านี้ เพราะ key ใหม่ทุกครั้งที่ deploy ทำให้ pass ทุกใบ (ticket, ลิงก์ monitor, operator key) ที่ Postgres ยังเก็บไว้ใช้ไม่ได้ ดู Signing key และการ rotate SECRET |
SECRET_PREVIOUS | (ไม่ได้ตั้ง) | SECRET ตัวเก่าที่กำลังเปลี่ยนออก token ที่ sign ด้วยค่านี้ยังตรวจผ่าน (ทั้งสองแบบ ทุกประเภทงาน) แต่ token ใหม่ sign ด้วย SECRET เสมอ operator key ที่ตรวจผ่านด้วยค่านี้จะถูกเปลี่ยนไปใช้ SECRET ตอนที่ถูกใช้งาน ลบค่านี้ทิ้งเมื่อการเปลี่ยนเรียบร้อยแล้ว ตราบที่ยังตั้งอยู่ /api/admin/health จะแสดงไว้ในหมวด hardening ดู Signing key และการ rotate SECRET |
TOKEN_SIGN_V2 | 0 | 1 = sign token และ operator verifier ตัวใหม่แบบ v2 คือแยก key ตามประเภทงานและมี kid ส่วน 0 (ค่าเริ่มต้น) = sign แบบเดิม ใช้ SECRET ตรง ๆ ไม่มี kid ซึ่ง release ก่อนที่จะมี key แยกตามประเภทงานก็อ่านได้ ไม่ว่าตั้งแบบไหน ระบบรับทั้งสองแบบ เปิดแล้วจะถอยกลับไป release ที่เก่ากว่านี้ไม่ได้ถ้าไม่ยอมเสีย queue ดู Signing key และการ rotate SECRET ค่าอื่นที่ไม่ใช่ 0/1 นับเป็น 0 และจะถูกระบุไว้ใน warnings |
TRUST_PROXY | 0 | จำนวน reverse proxy ที่อยู่หน้า server นี้ ระบบจะอ่าน address ของผู้เข้าชมจาก X-Forwarded-For โดยนับ จากขวา และจะอ่านก็ต่อเมื่อ connection มาจาก address ที่อยู่ใน TRUST_PROXY_IPS เท่านั้น 0 = address ของ socket คือผู้เข้าชม |
TRUST_PROXY_IPS | (ว่าง) | address หรือช่วง CIDR ของ proxy เหล่านั้น คั่นด้วย comma เป็น IPv4 หรือ IPv6 ก็ได้ (10.0.0.7, 173.245.48.0/20, 2400:cb00::/32) peer ที่เป็น IPv4 ในรูป IPv6 (::ffff:1.2.3.4) จับคู่กับรายการ IPv4 ได้ ถ้ามีรายการที่อ่านไม่ออก server จะหยุดตอนเริ่มและบอกว่ารายการไหน ถ้า connection มาจาก peer อื่น ระบบไม่สนใจ X-Forwarded-For เลย ต้องตั้งค่านี้ถ้าอยู่หลัง CDN และต้องการจำกัดความถี่แยกเป็นรายผู้เข้าชม (ดูด้านล่าง) |
EDGE_SECRET | (ไม่ได้ตั้ง) | ค่าลับที่ rule ของ Cloudflare ใส่มาใน header X-QM-Edge (ดู ค่าลับจาก Cloudflare edge) เมื่อตั้งค่านี้ ทุก request และทุก WebSocket upgrade ที่ไม่มี header ที่ตรงกันจะถูกปฏิเสธด้วย 403 (edge_required) ยกเว้น /healthz address ของผู้เข้าชมจะมาจาก cf-connecting-ip และระบบจะไม่สนใจ TRUST_PROXY/TRUST_PROXY_IPS ใส่ได้หลายค่าคั่นด้วย comma เพื่อหมุนค่า (ค่าไหนในรายการก็ผ่าน) ถ้ามีค่าที่สั้นกว่า 16 ตัวอักษร server จะหยุดตอนเริ่ม ถ้าไม่ได้ตั้งค่านี้ขณะใช้ STORE=valkey หรือ NODE_ENV=production จะมีข้อความตอนเริ่มบอกว่า request ที่อ้อม Cloudflare ยังเข้ามาได้ |
IPV6_PREFIX_BITS | 64 | กำหนดว่า "ต่อ address" หมายถึงอะไรสำหรับผู้เข้าชมที่ใช้ IPv6 คือใช้ N bit แรก (32..128) เครื่อง IPv6 มักได้ทั้งช่วง /64 และใช้ address ไหนในช่วงนั้นก็ได้ limit ต่อ address ทุกตัวจึงนับตาม /64 ไม่ใช่ /128 ได้แก่ โควตา join, check, notify, page และ SSE, การล็อกเมื่อ login ผิด, queueMaxPerIp, preQueueMaxPerIp และบันทึกการจำแนก traffic ตั้ง 128 ถ้าอยากกลับไปนับทีละ address ผู้เข้าชม IPv4 และ IPv4 ในรูป IPv6 (::ffff:1.2.3.4) ไม่ได้รับผล สิ่งที่เปลี่ยนมีแค่ตัวที่ใช้นับ log, audit trail และ console ยังแสดง address เต็ม และ TRUST_PROXY_IPS ยังจับคู่กับ peer จริงแบบตรงตัว หรือตาม CIDR ที่เขียนในรายการ |
JOIN_LIMIT_PER_MIN | 600 | โควตา /api/join ต่อ address 0 คือปิด ต่อ instance เมื่อใช้ STORE=valkey: แต่ละ instance นับของตัวเอง ถ้ามี N instance client หนึ่งจะใช้ได้ถึง N เท่าของค่านี้ทั้งระบบ ส่วนการคุมทั้งระบบเป็นหน้าที่ของ rate limiting ที่ edge ของ Cloudflare |
CHECK_LIMIT_PER_MIN | 6000 | โควตา /api/check ต่อ address 0 คือปิด ต่อ instance เมื่อใช้ STORE=valkey: แต่ละ instance นับของตัวเอง ถ้ามี N instance client หนึ่งจะใช้ได้ถึง N เท่าของค่านี้ทั้งระบบ ส่วนการคุมทั้งระบบเป็นหน้าที่ของ rate limiting ที่ edge ของ Cloudflare |
RATE_LIMIT_MAX_KEYS | 200000 | จำนวน address มากที่สุดที่ตารางนับโควตาแต่ละตัวจำไว้ เกินแล้วระบบลบตัวที่ไม่ได้ใช้นานที่สุดออก ไม่เคยล้างทั้งตาราง ผู้ที่ถูกจำกัดอยู่จึงเปลี่ยน address วนไปเพื่อให้ได้โควตาใหม่ไม่ได้ การล็อกของ ADMIN_AUTH_FAIL_PER_MIN ก็เก็บแบบเดียวกัน |
WAITING_LIMIT_PER_MIN | 120 | โควตาต่อ address สำหรับ หน้า รอเอง และ (แยกโควตาอีกชุด) สำหรับหน้า console, /progress และ /docs (รวมที่อยู่ใต้ /__qm/) 0 ปิดทั้งสองชุด ระบบเลิกใช้โควตานี้เองกับ address ของ proxy ที่ไม่ได้ประกาศไว้ (เฉพาะ address นั้น) ดู Rate limit ต่อ instance เมื่อใช้ STORE=valkey: แต่ละ instance นับของตัวเอง ถ้ามี N instance client หนึ่งจะใช้ได้ถึง N เท่าของค่านี้ทั้งระบบ ส่วนการคุมทั้งระบบเป็นหน้าที่ของ rate limiting ที่ edge ของ Cloudflare |
SSE_PER_IP | 200 | จำนวน stream /events ที่เปิดพร้อมกันได้ต่อ address 0 คือปิด ตั้งไว้เผื่อคนทั้งออฟฟิศ หรือทั้งกลุ่ม CGNAT ที่ใช้ address เดียวกัน ตัวที่ป้องกันหน่วยความจำของ process คือ SSE_MAX_TOTAL ต่อ instance เมื่อใช้ STORE=valkey: แต่ละ instance มีเพดานของตัวเอง N instance จึงเปิด stream ได้รวมถึง N เท่าของค่านี้ ส่วนการคุมทั้งระบบเป็นหน้าที่ของ Cloudflare |
SSE_MAX_TOTAL | 5000 | จำนวน stream SSE สูงสุดทั้งระบบ (ผู้เข้าชม + admin) ตัวเลขนี้กำหนดตาม CPU ไม่ใช่หน่วยความจำ: การอัปเดตที่ทำให้ผู้เข้าชมทุกคนขยับ ต้องส่งข้อมูลหนึ่งครั้งต่อ stream ที่เปิดอยู่ ราว 0.1 ms ต่อ stream บนเครื่องอ้างอิง (QM-381) 5,000 stream จึงใช้ CPU ราว 0.5 s ต่อการอัปเดตหนึ่งครั้ง ขณะที่ engine เดินรอบละ 1 s ถ้า 20,000 stream จะราว 2 s ระบบส่งทีละชุด ชุดละ 256 stream การส่งก้อนใหญ่จึงไม่ทำให้ event loop ค้างแล้ว แต่ถ้าเกินเพดานนี้ ผู้เข้าชมแต่ละคนจะได้อัปเดตห่างขึ้น ผู้เข้าชมที่ขอ stream ไม่ได้ (503 sse_capacity) ไม่ได้ถูกไล่ออก หน้ารอจะเปลี่ยนไปถาม /api/status ทุก 5 s แทน เพิ่มค่านี้เฉพาะเมื่อเครื่องเร็วกว่า และวัดผลแล้วเท่านั้น ข้อมูลที่ไม่เปลี่ยนจะไม่ส่งซ้ำ ยกเว้นส่งเป็น keepalive ให้ stream ที่เงียบมาแล้ว 4 s ต่อ instance เมื่อใช้ STORE=valkey: แต่ละ instance มีเพดานของตัวเอง N instance จึงเปิด stream ได้รวมถึง N เท่าของค่านี้ ส่วนการคุมทั้งระบบเป็นหน้าที่ของ Cloudflare |
ADMIN_SSE_RESERVE | 8 | ช่อง stream สำรอง เพิ่มจาก SSE_MAX_TOTAL ใช้ได้เฉพาะ console ที่ถือ credential ที่แก้ไขได้ คือ ADMIN_KEY หรือ key แบบมีชื่อที่เป็น owner/operator เพื่อให้ dashboard ยังต่อได้ตอนที่ผู้เข้าชมใช้ stream จนเต็ม ซึ่งเป็นตอนที่ต้องดู dashboard ที่สุด ลิงก์ monitor ที่แชร์เป็น credential แบบอ่านอย่างเดียว จึงไม่ได้สิทธิ์นี้ ต้องแย่ง SSE_MAX_TOTAL เหมือนผู้เข้าชม |
MAX_CONNECTIONS | 50000 | เพดาน socket แบบยืดหยุ่นที่ process นี้คุมเอง เมื่อ connection ที่เปิดอยู่ถึงจำนวนนี้หรือเกิน request ใหม่ ทุกตัวจะได้ 503 (ดู qm_connections_shed_total) แทนที่จะถูกส่งต่อหรือ proxy |
CONNECTION_RESERVE | 256 | ช่องเผื่อ เพิ่มจาก MAX_CONNECTIONS ก่อนถึงเพดานตายตัวของ Node เอง (server.maxConnections) ถ้าไม่มีช่องเผื่อ socket ตัวที่ทำให้จำนวนเกิน MAX_CONNECTIONS จะไม่มีทางได้คำตอบ Node จะทำลายมันใน C++ ก่อนที่จะส่ง 503 ได้ |
TOKEN_MAX_AGE_SEC | 86400 | อายุที่ token ที่ sign แล้วยังตรวจผ่านได้ ผู้เข้าชมที่ยังรออยู่ไม่เสียที่เมื่อเลยอายุนี้ (QM-391): เมื่อ token อายุถึงครึ่งหนึ่งของค่าที่สั้นกว่าระหว่างค่านี้กับ QUEUE_COOKIE_MAX_AGE_SEC (6 ชั่วโมงถ้าใช้ค่าเริ่มต้นทั้งคู่) /api/status และ /events จะ sign ที่เดิมใหม่ด้วย key ปัจจุบัน และตั้ง queue cookie ทั้งสองตัวใหม่ ทำให้เฉพาะ browser ที่ cookie qms_<room> ถือที่นั้นอยู่ ใน generation ปัจจุบันของ room และที่นั้นยังรออยู่หรืออยู่ใน pre-queue เท่านั้น pass, ที่ที่หมดอายุหรือถูก eject, หรือผู้เรียกที่จับคู่ได้แค่ด้วย fingerprint จะไม่ได้ token ที่เลยอายุนี้ไปแล้วยังถูกปฏิเสธ ลดค่านี้จึงไม่ได้ตัดการรอของผู้เข้าชมที่ยังเปิดหน้าอยู่ แต่จำกัดว่า token ที่หลุดออกไปใน log จะใช้ได้นานแค่ไหน |
REFRESH_ENDS_PER_SEC | 200 | จำนวนสูงสุดของ stream /events ที่เปิดอยู่ซึ่งถูกปิดต่อวินาทีเพื่อต่ออายุ token (QM-391) stream ที่ถึงกำหนดพร้อมกันจะรอคิว และยังได้รับ status frame ตามปกติ แทนที่จะ reconnect พร้อมกันทั้งหมด จะปิดเร็วกว่านี้เฉพาะเมื่ออัตรานี้ปิด stream ที่รออยู่ไม่ทันก่อน token หมดอายุ |
MONITOR_TOKEN_TTL_SEC | 604800 | ลิงก์ Share monitor ใช้ได้นานเท่าไรก่อนหมดอายุเอง ค่าเริ่มต้นเจ็ดวัน การหมดอายุแยกจากการยกเลิก (revoke): ยกเลิกลิงก์ก่อนกำหนดได้จาก console และลิงก์ที่หมดอายุแล้วคัดลอกซ้ำไม่ได้ |
EMAIL_NOTIFY | (ไม่ได้ตั้ง) | 1 (หรือ true/yes/on) เปิดใช้ POST /api/notify คือการเก็บคำขอให้แจ้งเมื่อถึงคิว และช่องกรอกอีเมลบนหน้ารอ ปิดไว้เป็นค่าเริ่มต้น และตอนปิด หน้ารอจะซ่อนช่องนี้ทั้งหมด เพราะ server นี้แค่จดคำขอไว้ ไม่ได้ส่งอะไรเอง การมีช่องให้กรอกแต่ไม่มีใครส่งเมลจริงแย่กว่าไม่มีช่องเลย ตอนปิด route นี้ตอบ 404 email_notify_disabled ตอนเปิด อีเมลเก็บไว้กับที่ในแถว และลบเมื่อบัตรคิวนั้นสิ้นสุด หรือช้ากว่านั้นได้ถึง LAPSED_TTL_MS (24 ชั่วโมง) ถ้าช่วงเวลาเข้าของบัตรหมดไป: บัตรคิวสิ้นสุดเมื่อผ่านเข้าไปแล้ว ถูก eject ถูก purge หรือ room ถูกลบ ส่วนบัตรที่ช่วงเวลาเข้าหมดไป ผู้เข้าชมที่กลับมาจะได้อีเมลเดิมติดไปกับบัตรใหม่ ไม่มีรายการใดถูกเก็บนานเกิน 24 ชั่วโมงนับจากตอนที่กรอก และคำขอสำหรับบัตรที่สิ้นสุดแล้วถูกปฏิเสธ (409 ticket_ended) ถ้าใช้ STORE=memory อีเมลอยู่ในหน่วยความจำของ process นี้ และหายหมดทุกครั้งที่ restart หรือ deploy ถ้าใช้ STORE=valkey อีเมลอยู่ใน Valkey ที่ใช้ร่วมกัน (qm:notify:{room}) console ของทุก instance จึงเห็น snapshot RDB ของ Valkey และ backup ของผู้ให้บริการอาจมีสำเนาอยู่หลังจากลบแล้ว จนกว่าจะถูกหมุนเวียนออกไป ไม่เขียนลง Postgres (events หรือ audit), log หรือ /metrics server นี้ไม่ส่งไปไหน และไม่แสดงเต็ม ๆ ให้ใครเห็น (หน้ารายละเอียดผู้เข้าชมใน console เห็นเป็น al•••@e••••••.com) build นี้ไม่มีทาง export ออกมา การเปิดทำให้มีคำเตือนตอนเริ่มระบบและใน warnings ของ /api/admin/health badge ของ console จึงเตือนด้วย และหน้ารอก็บอกผู้เข้าชมแบบเดียวกัน |
NOTIFY_LIMIT_PER_MIN | 20 | โควตา POST /api/notify ต่อ address ผู้เข้าชมกรอกอีเมลครั้งเดียว อาจแก้อีกครั้ง ถ้ามาจาก address เดียวเกินไม่กี่ครั้งต่อนาทีก็คือ script 0 คือปิด ต่อ instance เมื่อใช้ STORE=valkey: แต่ละ instance นับของตัวเอง ถ้ามี N instance client หนึ่งจะใช้ได้ถึง N เท่าของค่านี้ทั้งระบบ ส่วนการคุมทั้งระบบเป็นหน้าที่ของ rate limiting ที่ edge ของ Cloudflare |
NOTIFY_MAX_ENTRIES | 50000 | จำนวนคำขอแจ้งเตือนที่เก็บได้มากที่สุด กันไม่ให้ใครยัดรายชื่ออีเมลเข้ามาจนหน่วยความจำหมด เต็มแล้วลบรายการเก่าสุดก่อน ใช้กับ STORE=memory เท่านั้น ถ้าใช้ STORE=valkey ทุกรายการต้องมีบัตรคิวที่ sign แล้ว hash ของแต่ละ room จึงมีได้ไม่เกินจำนวนบัตรคิวที่ room แจกไป และแต่ละรายการถูกลบเมื่อบัตรคิวนั้นสิ้นสุด |
QUEUE_COOKIE_MAX_AGE_SEC | 43200 | อายุของ queue cookie (qm_<room>, qms_<room>) นับจากครั้งล่าสุดที่ตั้ง หน้ารอที่เปิดค้างไว้จะได้ cookie ตั้งใหม่พร้อมกับตอนต่ออายุ token (ดู TOKEN_MAX_AGE_SEC) browser ที่ปิดหน้าไปนานกว่านี้จะเสีย qms_<room> ที่ใช้พิสูจน์ตัว และเสียที่ในคิวไปด้วย |
COOKIE_SECURE | (ไม่ได้ตั้ง) | 1 บังคับให้ cookie มี Secure แม้ server นี้เห็น request เป็น HTTP ธรรมดา (เช่น proxy ด้านหน้าถอด TLS ออกแล้วแต่ไม่ส่ง X-Forwarded-Proto มา หรือไม่อยู่ใน TRUST_PROXY_IPS) ถ้าไม่ตั้ง cookie ก็ยังเป็น Secure เมื่อ Host ของ request ตรงกับ host ของ PUBLIC_URL ที่เป็น https:// |
PROXY_TIMEOUT_MS | 30000 | ใช้กับโหมด inline เท่านั้น: รอแอปที่อยู่หลัง proxyOrigin ของ room นานเท่าไรก่อนตอบ 504 ดู การ deploy สองแบบ ด้านล่าง |
PROXY_ALLOW_PRIVATE | ไม่ได้ตั้ง | ปกติระบบไม่ยอมให้ต่อไปยัง loopback, RFC1918, link-local/cloud-metadata และ address ภายในอื่น ๆ 1 = ยอมให้ proxyOrigin ของ room (และการ probe สุขภาพของมัน) และการ probe สุขภาพ targetUrl ของ room แบบ snippet ต่อไปที่ address เหล่านั้นได้ probe ที่ถูกห้ามจะแสดงผลเป็นล่ม และ targetHealth.blocked บอกว่า address ไหน ระบบไม่ส่งอะไรไปที่ address นั้นเลย Autotune ไม่ตัดสินใจจาก probe ที่ถูกห้ามและคง rate ไว้ ถ้าถูกห้ามติดกัน 3 ครั้ง จะยกเลิกการเพิ่ม rate ของตัวเองครั้งเดียว กลับไปที่ rate ก่อน autotune เพิ่ม (rate ที่ถูกลดไว้จะคงไว้ และ rate ที่ operator ตั้งเองจะไม่ถูกแตะ) ค่า rate ก่อนเพิ่มนี้เก็บไว้ใน config ของ room (เขียนพร้อมกับ rate) จึงยังอยู่หลัง failover หรือ restart และ leader ใหม่จะกลับไปที่ rate เดิมแบบเดียวกัน rate ที่ operator ตั้งระหว่างที่ระบบกำลังเขียนค่ากลับจะไม่ถูกเขียนทับ ตั้งค่านี้เฉพาะเมื่อแอปอยู่ใน LAN จริง ๆ ดู การ deploy สองแบบ ด้านล่าง |
PROXY_IDLE_TIMEOUT_MS | 60000 | ใช้กับโหมด inline เท่านั้น: response ที่เริ่มส่งแล้ว เงียบไม่มีข้อมูลสักไบต์ได้นานเท่าไร ก่อนตัด socket ทั้งสองฝั่ง นับเป็น ช่วงที่เงียบ ไม่ใช่เวลารวม stream หรือการดาวน์โหลดที่ยังส่งข้อมูลอยู่จะไม่ถูกตัด ลดค่านี้ได้ถ้าแอปไม่มี stream ที่เปิดค้างนาน |
HEADERS_TIMEOUT_MS | 20000 | กันการโจมตีแบบ Slowloris |
REQUEST_TIMEOUT_MS | 60000 | เวลาสูงสุดของ request หนึ่งตัวทั้งหมด |
PROTECTION_MODE | monitor | ค่าเริ่มต้นของ server สำหรับการให้คะแนนบอท: off, monitor (ให้คะแนนและนับ ไม่ปฏิเสธใคร) หรือ enforce (ไม่ให้ที่ในคิวถ้าคะแนนเกินเกณฑ์) protection.mode ของแต่ละ room ใช้แทนค่านี้ได้ เริ่มที่ monitor และอ่าน Security panel ก่อนจะ enforce อะไร |
PROTECTION_WARN_AT | 40 | คะแนนที่ request ถูกนับเป็นคำเตือน |
PROTECTION_BLOCK_AT | 70 | คะแนนที่ enforce ไม่ให้ที่ในคิว ตั้งสูงขึ้นระบบจะใจดีขึ้น ตั้งต่ำกว่าราว 55 เริ่มมีโอกาสบล็อกคนที่มีแค่หลักฐานแวดล้อม |
PROBE_EVERY_MS | 12000 | server ตรวจ (probe) ปลายทางของแต่ละ room เองถี่แค่ไหน ตามนาฬิกาคงที่ การวัดและ autotune จึงไม่หยุดเมื่อปิดแท็บ dashboard และ operator ทุกคนเห็นตัวเลขเดียวกัน ค่านี้ขับ alert target_down และตัวเลขเวลาตอบสนอง 0 คือปิดการ probe ค่าต่ำสุดคือ 1000 ค่าที่ต่ำกว่านั้นจะถูกปรับเป็น 1000 พร้อมคำเตือนตอนเริ่ม server URL ที่ต่างกันแต่ละตัวถูก probe ครั้งเดียวต่อรอบ ไม่ว่าจะมีกี่ room ใช้ร่วมกัน และ probe ถูกกระจายไปตลอด 80 % แรกของรอบ |
PROBE_TIMEOUT_MS | 8000 | probe หนึ่งครั้งรอนานเท่าไรก่อนนับว่าปลายทางล่ม probe จับเวลาแค่ถึง response header แล้วทิ้ง body status ต่ำกว่า 500 นับว่าติดต่อได้ 5xx นับว่าล่ม เหมือน network error และ timeout เพราะแอปที่ตอบ 500 ทุก request ไม่ได้ทำงานอยู่จริง และระหว่างเกิดเหตุ คำถามไม่ใช่ "มีอะไรรอรับ connection อยู่ไหม" |
PROTECTION_ALLOW_IPS | (ว่าง) | address (คั่นด้วย comma) ที่ไม่ถูกให้คะแนนเลย ใส่ระบบ monitor อัตโนมัติและ load test ของคุณเองไว้ที่นี่ |
SHUTDOWN_GRACE_MS | 250 | ตอนหยุดระบบ connection ที่ว่างอยู่หรือเป็น SSE ได้เวลาเท่านี้ก่อนถูกตัด socket ที่กำลังส่ง response ผ่าน proxy (โหมด inline) จะไม่ถูกตัดตรงนี้ ใช้แค่เพดานตายตัวในแถวถัดไป การ deploy จึงไม่ตัดหน้าเว็บของลูกค้ากลางทางแล้ว |
SHUTDOWN_DRAIN_MS | 2000 (15000 เมื่อมี room ใดเป็น inline) | เวลาสูงสุดของการหยุดทั้งหมด socket ที่ค้างต้องไม่ทำให้การ deploy ค้างไปด้วย ระบบแบบ inline ใช้ค่าขั้นต่ำที่ยาวกว่าเป็นค่าเริ่มต้น เพราะวัดแล้วพบว่า 2000ms ตัดการดาวน์โหลดจริงผ่าน proxy ทุกครั้งที่ deploy แต่ถ้า operator ตั้งค่านี้เอง จะได้ค่านั้นตรง ๆ เสมอ ไม่ว่าจะ inline หรือไม่ เดิมถ้าตั้ง SHUTDOWN_DRAIN_MS=3000 เองบนระบบ inline แล้ว origin ค้าง process ยังรอเต็ม 15000ms และจะโดน SIGKILL กลางทางถ้า terminationGracePeriodSeconds ต่ำกว่านั้น ตั้งค่านี้เองถ้า 15s นานกว่าช่วงผ่อนผัน (grace period) ของแพลตฟอร์มคุณ |
QM_FORCE_LOCK | (เอาออกแล้ว) | เอาออกใน step 5 พร้อมกับ lock ของ data directory ถ้าตั้งไว้ ระบบจะไม่สนใจและขึ้นคำเตือนตอนเริ่ม เหมือน DATA_DIR |
QM_IGNORE_CORRUPT_SNAPSHOT | (เอาออกแล้ว) | เอาออกใน step 5 พร้อมกับ snapshot.json ถ้าตั้งไว้ ระบบจะไม่สนใจและขึ้นคำเตือนตอนเริ่ม เหมือน DATA_DIR |
HISTORY_RETAIN_DAYS | 90 | เก็บข้อมูลรายชั่วโมงไว้นานเท่าไร: ใน Postgres เมื่อ SIDESTORE=pg ในหน่วยความจำของ process นี้เมื่อ SIDESTORE=memory แถวที่เก่ากว่านี้จะไม่แสดงในรายงานและถูกลบ |
SIDESTORE | memory | ที่เก็บข้อมูลผู้ดูแล ลิงก์มอนิเตอร์ บันทึก audit โน้ตบนไทม์ไลน์ metrics และข้อมูลรายชั่วโมง: memory หรือ pg ค่า memory เก็บไว้ใน process นี้เท่านั้น restart แล้วหายหมด (สำหรับ development และ test) ค่า pg เก็บใน Postgres ที่ DATABASE_URL: migrate schema ตอนเริ่ม และโหลดข้อมูลทั้งหมดก่อน server จะรับ request ค่า pg ต้องตั้ง SECRET เมื่อใช้ STORE=memory จะถือว่ามี instance เดียว (instance อื่นจะไม่เห็นลิงก์ monitor หรือ operator ใหม่จนกว่าจะ restart) เมื่อใช้ STORE=valkey ทุก instance จะอ่าน operator, ลิงก์ monitor และโน้ตบนไทม์ไลน์ใหม่เมื่อ instance อื่นเปลี่ยน (ดู RESYNC_MS) ค่า file ถูกเอาออกใน step 5 และ server จะไม่ยอมเริ่มโดยบอกชื่อค่านี้ (ดู การปฏิเสธการเริ่มทำงาน) ค่าอื่นก็จะไม่ยอมเริ่มเช่นกัน |
STORE | memory | ที่เก็บสถานะคิว ค่า memory: ไม่ใช้ดิสก์ สำหรับ development เท่านั้น engine ในโปรเซสไม่เขียนอะไรลงดิสก์ ทุกครั้งที่ restart จึงเริ่มจากว่าง (พร้อม room demo) และ NODE_ENV=production จะไม่ยอมใช้ค่านี้ ค่า valkey เก็บสถานะห้องใน Valkey ที่ VALKEY_URL และบันทึกทุกการเปลี่ยนแปลงลงตาราง events ใน Postgres ต้องมี SIDESTORE=pg, SECRET และ VALKEY_URL ถ้าขาดตัวไหน server จะไม่ยอมเริ่มและบอกชื่อตัวที่ขาดทุกตัว สิ่งที่ใช้ได้บน Valkey: ห้องและ config ของห้อง, join, status, การตรวจที่ประตู, tick และการรับเข้า, session และ door key, การ eject, flush และ purge ของ admin, URL targeting (urlPatterns, ตัวนับการ match, ตัวทดสอบ URL), autotune, stats และ metrics, การเปิดตามกำหนดเวลา (opensAt, branding.opensAt, setOpensAt), pre-queue (preQueueMaxPerIp) และการสุ่มลำดับตอนเปิด ไม่มีการเรียกใดที่ได้ not_supported_yet หลาย instance ใช้ Valkey และ Postgres ชุดเดียวกันหลัง load balancer ที่ไม่มี sticky session ได้ ดู หลาย instance (STORE=valkey) ลำดับที่สุ่มได้ของการเปิดแต่ละครั้งเก็บในตาราง open_orders ใน Postgres (ราว 8 ไบต์ต่อหนึ่งรายการใน pre-queue หนึ่งแถวต่อ 20 000 รายการ โดย event open อ้างถึงแถวเหล่านี้) เช่นเดียวกับ events ตาราง open_orders ยังไม่มีการลบข้อมูลเก่า ทั้งสองตารางโตขึ้นทุกครั้งที่เปิดและทุกการเปลี่ยนแปลงตลอดอายุฐานข้อมูล จึงต้องเผื่อพื้นที่ดิสก์ Postgres และคอยดูขนาดไว้ room ที่ rebuild แล้วเจอการเปิดที่ replay ไม่ได้ (แถว open_orders ของการเปิดนั้นหายหรือไม่ครบ หรือลำดับอ้างถึงรายการ pre-queue ที่ journal ไม่เคยบันทึก) จะถูก fence ค้างไว้แทนที่จะออก ticket ที่ไม่มีเจ้าของ: ทุกการเรียกได้ recovering ระบบลอง rebuild ซ้ำ และ log recovery rebuild <room>: rebuild <room>: … (ระบุ seq เมื่อแถวหาย) ไม่เกินนาทีละครั้ง วิธีกู้คือ restore แถว open_orders และ events ของ room นั้นจาก backup ของ Postgres ที่ทำหลังการเปิด แล้วการลองครั้งถัดไปจะ rebuild room และปลด fence ห้ามลบหรือแก้แถว events ของ room เพื่อข้ามปัญหานี้ การเปลี่ยน IPV6_PREFIX_BITS ต้องรอให้คิวว่างก่อน เพราะ key ต่อเครือข่ายที่อยู่ใน Valkey แล้วเขียนด้วย prefix เดิม request ใดก็ตาม (ผู้เข้าชมหรือ console) ที่การเรียกสถานะห้องล้มเหลวเพราะต่อ Valkey ไม่ได้ Valkey ไม่พร้อมให้บริการ (READONLY, LOADING, MASTERDOWN, CLUSTERDOWN, TRYAGAIN) หรือไม่ตอบภายใน VALKEY_COMMAND_TIMEOUT_MS จะได้ 503 storage_unavailable พร้อม Retry-After: 2 คำสั่งที่คำตอบหายไปจะไม่ถูกส่งซ้ำ join ที่ลองใหม่จึงไม่ได้ ticket ใบที่สอง ความล้มเหลวของ Postgres ไม่ใช่กรณีนี้: join และการรับเข้าจะได้ 503 storage_unavailable เมื่อบันทึก journal ไม่สำเร็จ (query หมดเวลาใน 10 วินาที) route อื่นจัดการตามแบบของตัวเอง ค่าอื่นจะไม่ยอมเริ่ม |
VALKEY_URL | ไม่มี | URL เชื่อมต่อ Valkey อ่านเฉพาะเมื่อ STORE=valkey (และต้องตั้งเมื่อใช้ค่านั้น) rediss:// เชื่อมต่อผ่าน TLS ที่ตรวจกับ CA ของระบบ ใช้ db index ตามที่ระบุใน path |
VALKEY_PREFIX | qm: | prefix ของทุก key ที่ server เขียนใน Valkey และของ control channel (<prefix>ctl) เมื่อ STORE=valkey สอง deployment ที่ใช้ Valkey ตัวเดียวกันต้องใช้ prefix ต่างกัน |
VALKEY_COMMAND_TIMEOUT_MS | 2000 | เมื่อ STORE=valkey คือเวลาที่คำสั่ง Valkey หนึ่งคำสั่งรอคำตอบได้ ก่อนจะล้มเหลวและ request ได้ 503 storage_unavailable เป็นจำนวนเต็ม 100–60000 ค่าที่อยู่นอกช่วงจะถูกปรับให้อยู่ในช่วง และค่าที่ไม่ใช่ตัวเลขจะใช้ 2000 แทน ทั้งสองกรณีมีคำเตือนตอนเริ่มที่บอกชื่อตัวแปร เพิ่มค่านี้เฉพาะเมื่อ Valkey ที่ช้าหรืออยู่ไกลทำให้หมดเวลาในภาวะปกติ ค่าที่สูงขึ้นทำให้ request ค้างนานขึ้นขณะ Valkey ไม่ตอบ ค่านี้ไม่จำกัดคำสั่งที่การเชื่อมต่อ Valkey แต่ละเส้นส่งเมื่อเชื่อมต่อ (ใหม่) (AUTH, SELECT และการตรวจความพร้อมด้วย INFO) ซึ่งรอได้ถึง 10 วินาที (connect timeout) ก่อนจะตัดการเชื่อมต่อแล้วลองใหม่ ดังนั้น Valkey ที่ทำงานอยู่แต่ตอบช้ากว่าค่านี้ก็ยังเชื่อมต่อได้ |
NODE_ENV | (ไม่ได้ตั้ง) | ค่า production (อ่านแบบตัดช่องว่างและไม่สนตัวพิมพ์เล็กใหญ่) คือกฎของ production: server จะไม่ยอมเริ่มถ้าไม่ได้ตั้งทั้ง STORE=valkey และ SIDESTORE=pg และจะบอกชื่อทุกตัวที่ขาด (ดู การปฏิเสธการเริ่มทำงาน) นอกจากนี้ยังขึ้น EDGE_SECRET ที่ไม่ได้ตั้งไว้ใน hardening (ดู ค่าลับจาก Cloudflare edge) และปิด hook ที่มีไว้สำหรับทดสอบ (QM_TEST_VALKEY_COUNT ซึ่งเพิ่ม header นับจำนวนคำสั่ง Valkey ต่อ request QM_TEST_DROP_CTL ซึ่งทิ้งข้อความ pub/sub QM_TEST_ROOM_EVENT_DELAY_MS ซึ่งหน่วง room event QM_TEST_STORAGE_FAIL_FILE ซึ่งทำให้ alert เห็น storage ว่าล้มเหลว QM_TEST_JOURNAL_FAIL_FILE ซึ่งทำให้การ commit journal ของ instance นี้ล้มเหลว QM_TEST_ALERT_HOLD_FILE ซึ่งหน่วง alert frame หนึ่งรอบไว้เกินเวลาจำกัด QM_TEST_HISTORY_SAMPLE_MS ซึ่งทำให้รอบการเก็บตัวอย่าง history สั้นลง QM_TEST_NO_TICK ซึ่งหยุด engine tick ของ instance หนึ่ง QM_TEST_NOTIFY_TTL_MS ซึ่งทำให้ขอบเขตเวลาของคำขอแจ้งเตือนสั้นลง และ QM_TEST_NOTIFY_FAIL_FILE ซึ่งทำให้การลบคำขอแจ้งเตือนล้มเหลว) |
QM_TEST_VALKEY_COUNT | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production: ค่า 1 กับ STORE=valkey จะเพิ่ม header x-qm-valkey-commands (จำนวนคำสั่ง Valkey ที่ request นั้นส่ง) ในทุก response และปิด auto-pipelining ของ Valkey client |
RESYNC_MS | 5000 | เมื่อใช้ STORE=valkey แต่ละ instance จะตรวจทุกช่วงเวลานี้ว่าสำเนารายชื่อ operator และลิงก์ monitor ของตนยังเป็นปัจจุบัน (แถว side_rev ใน Postgres) และถาม Valkey ถึง console session ของ admin stream ที่เปิดอยู่ การเปลี่ยนแปลงจาก instance อื่นปกติมาถึงทันทีผ่าน Valkey pub/sub การตรวจนี้ซ่อมข้อความที่หายไปภายในเวลานี้ หากสำเนาไม่ได้รับการยืนยันนานเกิน 3 เท่าของค่านี้ (อย่างน้อย 15 วินาที) เช่นเมื่อ Postgres ติดต่อไม่ได้ operator key, console session ของ operator และลิงก์ monitor จะถูกปฏิเสธด้วย 503 storage_unavailable ส่วน ADMIN_KEY ยังใช้ได้ การตรวจเดียวกันนี้เทียบ revision ของห้องใน Valkey (<prefix>roomsrev ซึ่งขยับทุกครั้งที่สร้าง แก้ไข หรือลบห้อง) และเมื่อขยับจะสร้างสำเนา config ห้องที่ใช้ตัดสินว่าคำขอต้องผ่านคิวหรือไม่ของ instance นี้ใหม่ ได้แก่ protection ของ sentinel, ดัชนี host ของห้อง inline และ config ล่าสุดที่ทราบซึ่ง onBackendDown ใช้ตอบขณะ Valkey ติดต่อไม่ได้ สำเนาที่สร้างใหม่ไม่สำเร็จจะถูกลองใหม่ในการตรวจครั้งถัดไป แล้วถี่น้อยลงหากยังล้มเหลวต่อ (เพิ่มเป็นสองเท่าจนถึง 60 วินาที เช่นขณะที่ห้องกำลังถูกกู้คืน) ส่วน revision ที่ขยับและการเชื่อมต่อ Valkey ใหม่จะสร้างสำเนาทั้งหมดใหม่ทันทีเสมอ จำนวนเต็ม 100–600000 |
QM_TEST_ROOM_EVENT_DELAY_MS | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): หน่วงการ commit ลง Postgres ของ room config event ทุกรายการ (upsertRoom, การเปลี่ยนตารางเวลาเปิด) ตามจำนวน ms นี้ เพื่อทดสอบว่าการเปิดห้องของ instance อื่นรอ event นั้น (Notion 627) เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_DROP_CTL | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): ชนิดข้อความ pub/sub คั่นด้วยจุลภาค (acct, revoked, rooms, notes) ที่ instance นี้จะไม่ส่ง เพื่อทดสอบการซ่อมด้วย RESYNC_MS เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_JOURNAL_FAIL_FILE | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): กับ STORE=valkey ขณะที่ไฟล์ที่ระบุยังมีอยู่ การ commit events journal ของ instance นี้ (และ storage probe) จะล้มเหลว เพื่อทดสอบความผิดพลาดของ storage ใน instance เดียวของ fleet เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_STORAGE_FAIL_FILE | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): ขณะที่ไฟล์ที่ระบุยังมีอยู่ instance นี้จะรายงาน storage ของตนว่าล้มเหลวให้ alert เห็น เพื่อทดสอบ storage_failing ข้ามหลาย instance เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_ALERT_HOLD_FILE | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): ไฟล์ที่ระบุเก็บจุดหนึ่งใน alert frame (evaluate, load หรือ save) frame แรกที่ถึงจุดนั้นจะเปลี่ยนชื่อไฟล์เป็น <file>.held.<n> แล้วรอจนกว่าไฟล์นั้นจะถูกลบ เพื่อทดสอบว่า frame ที่ค้างเกินเวลาจำกัดไม่บันทึกและไม่ส่งอะไร เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_HISTORY_SAMPLE_MS | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): ตัวเก็บตัวอย่าง history ทำงานทุก ๆ จำนวน ms นี้ (อย่างน้อย 100) แทนทุก 15 วินาที เพื่อทดสอบว่าหลาย instance เขียน history เพียงแถวเดียวต่อห้องต่อชั่วโมง เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_NO_TICK | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): ค่า 1 ทำให้ instance นี้ไม่รัน engine tick เลย การทดสอบจึงแยกได้ว่าการเปลี่ยนแปลงมาจาก tick ของ instance ไหน เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_NOTIFY_TTL_MS | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): จำกัดอายุคำขอแจ้งเตือนแต่ละรายการไว้ที่จำนวน ms นี้ (อย่างน้อย 100) นับจากตอนที่กรอก แทน 24 ชั่วโมง เพื่อทดสอบว่ารายการเก่าหายไปใน room ที่มีการเขียนอยู่ตลอด เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
QM_TEST_NOTIFY_FAIL_FILE | unset | ใช้ในการทดสอบเท่านั้น ห้ามตั้งใน production (ไม่มีผลเมื่อ NODE_ENV=production): ระหว่างที่ไฟล์นี้มีอยู่ การลบคำขอแจ้งเตือนจะล้มเหลวเหมือนติดต่อ Valkey ไม่ได้ เพื่อทดสอบว่า eject, purge และการลบ room ตอบ 503 ในกรณีนั้น เมื่อเปิดอยู่จะมีคำเตือนตอนบูตระบุชื่อตัวแปรนี้ |
LEADER_LEASE_MS | 15000 | เมื่อใช้ STORE=valkey คือ lease ที่ทำให้ instance หนึ่งเป็น leader ซึ่งเป็นตัวเดียวที่ probe ปลายทาง (PROBE_EVERY_MS) รัน autotune และการเปิดใช้งานตาม threshold และส่ง alert ไปที่ ALERT_WEBHOOK_URL งานเหล่านี้จึงเกิดครั้งเดียวต่อทั้งชุด ไม่ใช่ครั้งละ instance leader ต่ออายุ lease (<prefix>leader ใน Valkey) ทุกหนึ่งในสามของค่านี้ และเลิกทำหน้าที่ leader เมื่อผ่านไปครึ่งหนึ่งของค่านี้โดยต่ออายุไม่สำเร็จ ก่อนที่ lease จะหมดอายุ จึงไม่มีสอง instance ทำงานพร้อมกัน instance อื่นจะรับช่วงภายในเวลาประมาณค่านี้หลัง leader ตาย และรับช่วงทันทีเมื่อ leader หยุดแบบปกติ ซึ่งคืน lease ให้ instance อื่นยังตรวจเงื่อนไข alert และแสดงสุขภาพปลายทางกับบันทึก autotune ของ leader ที่อ่านจาก Valkey ขณะติดต่อ Valkey ไม่ได้ จะไม่มี instance ใดเป็น leader: การ probe, autotune, การเปิดใช้งานตาม threshold และการส่ง alert จะหยุดจนกว่า Valkey จะตอบอีกครั้ง และไม่มีการถอยกลับไปให้ทุก instance ทำเอง alert เดียวที่ยังส่งในช่วงนั้นคือ valkey_unreachable (ดู VALKEY_ALERT_AFTER_MS) และควรมี monitor ภายนอกที่ /healthz ด้วย การเขียนของ autotune และการเปิดใช้งานตาม threshold มี epoch ของ leader ติดไปด้วย และถูกปฏิเสธเมื่อ instance อื่นรับ lease ไปแล้ว การเขียนที่มาช้าของ leader เก่าจึงไม่ทับของ leader ใหม่ leader ใน /healthz บอกว่า instance นี้เป็น leader หรือไม่ เมื่อใช้ STORE=memory process เดียวนั้นเป็น leader เสมอ จำนวนเต็ม 1000–600000 |
VALKEY_ALERT_AFTER_MS | 30000 | เมื่อใช้ STORE=valkey instance ที่ติดต่อ Valkey ไม่ได้นานเท่านี้จะส่ง alert valkey_unreachable ไปที่ ALERT_WEBHOOK_URL เอง และส่ง resolved เมื่อ Valkey ตอบอีกครั้ง ขณะ Valkey ล่มจะไม่มี instance ใดเป็น leader และการส่ง alert อื่นทั้งหมดหยุด alert นี้จึงเป็นการแจ้งเตือนของเหตุการณ์นั้น แต่ละ instance ที่ติดต่อ Valkey ไม่ได้ส่งของตัวเอง จึงคาดได้ว่าจะได้หนึ่งครั้งต่อ instance เพราะแจ้งซ้ำดีกว่าไม่แจ้งเลย ค่าที่ใช้จริงอย่างน้อยเท่ากับการต่ออายุ lease หนึ่งรอบ (LEADER_LEASE_MS/3) บวก command timeout ของ Valkey (VALKEY_COMMAND_TIMEOUT_MS) บวก 1 วินาที เพราะเป็นความถี่ที่ instance ปกติได้ยินจาก Valkey ค่าที่ต่ำกว่านั้นจะถูกปรับขึ้นพร้อมคำเตือนตอนเริ่ม server ที่ระบุทั้งสองค่า (ที่ค่าเริ่มต้นขั้นต่ำคือ 5000 + 2000 + 1000 = 8000 ms) ค่าขั้นต่ำนี้โตตาม VALKEY_COMMAND_TIMEOUT_MS ที่ค่าสูงสุด 60000 จะเป็นราว 66 วินาที เหตุการณ์ล่มจริงจึงแจ้งเตือนช้าลงเท่านั้น การส่ง alert นี้ที่ webhook ไม่รับจะถูกส่งใหม่ในการตรวจครั้งถัด ๆ ไป รวมไม่เกิน 5 ครั้ง จำนวนเต็ม 1000–3600000 |
QM_INSTANCE_ID | (ไม่ตั้ง) | เมื่อใช้ STORE=valkey คือชื่อแสดงของ instance นี้: label instance บนทุก series ของ /metrics, instance ใน GET /api/admin/health และชื่อที่ alert ใช้เรียก เป็นแค่ label: ตัวตนภายในทุกอย่าง (lease, key สำหรับ staging, ผู้ส่ง pub/sub, key ตัวนับ <prefix>ctr:<name>:<random>) คือ <name>:<random> ที่สร้างใหม่ทุกครั้งที่เริ่ม instance ที่ใช้ชื่อเดียวกัน หรือ rolling deploy ที่ใช้ชื่อเดิมซ้ำ จึงยังทำงานถูกต้อง ชื่อที่ไม่ซ้ำกันทำให้อ่าน metrics และ alert ง่ายขึ้น และจำเป็นต้องไม่ซ้ำกันต่อ scrape target เมื่อ Prometheus scrape ด้วย honor_labels: true (ดู Metrics และ monitoring) ถ้าไม่ตั้ง ชื่อจะเป็น i:<random> ที่สร้างใหม่ทุกครั้งที่เริ่ม ยาว 1 ถึง 64 ตัวอักษรจาก A-Z a-z 0-9 . _ - ค่าอื่นจะไม่ยอมเริ่ม เมื่อใช้ STORE=memory จะเพิ่มแค่ label ใน /metrics |
DATABASE_URL | ไม่มี | URL เชื่อมต่อ Postgres อ่านเฉพาะเมื่อ SIDESTORE=pg (และต้องตั้งเมื่อใช้ค่านั้น) การเชื่อมต่อใช้ TLS ที่ตรวจกับ PG_CA_FILE เสมอ sslmode ใน URL จะถูกละไว้ ถ้าจะใช้ schema อื่นที่ไม่ใช่ public ให้เติม options=-c search_path=<schema> ใน URL และ schema นั้นคือตัวที่ถูก migrate |
PG_CA_FILE | certs/do-ca.crt | ใบรับรอง CA ที่ใช้ตรวจ server Postgres เมื่อ SIDESTORE=pg ไม่มีการปิดการตรวจ ถ้าไม่มีไฟล์ server จะไม่ยอมเริ่ม |
PUBLIC_URL | (หาให้อัตโนมัติ) | origin เต็มที่ใช้เข้าถึง server นี้ ใช้สร้างคำสั่งติดตั้ง, URL ดาวน์โหลด connector, ลิงก์หน้ารอ และคำสั่งที่คัดลอกได้ใน /docs ในกรณีที่ Host ของ request ไม่ใช่ชื่อสาธารณะ (อยู่หลัง proxy ที่เปลี่ยนค่านั้น) ถ้าเป็น https:// จะทำให้ cookie ของ request ที่มาถึง host นั้นเป็น Secure ด้วย ไม่ว่า socket จะเป็นอะไร ถ้าไม่ตั้ง ADMIN_HOST host ของค่านี้ยังเป็นที่อยู่ของ console ด้วย: console ตอบที่นี่เมื่อมี room แบบ inline แล้ว (QM-440) ตั้ง room เป็น inline บน host นี้ไม่ได้ (400 reserved_host) และหน้าของ console บน host นี้ไม่ถูกส่งต่อให้ room ไหนเลย |
ALERT_WEBHOOK_URL | (ไม่ได้ตั้ง) | URL ที่ระบบ POST alert ไปเป็น JSON ถ้าไม่ตั้ง จะไม่มีอะไร ถูกส่งออกไป แต่ server ยังตรวจเงื่อนไขและแสดงไว้ใน Diagnostics → Alerting console จึงทำหน้าที่แจ้งเตือนแทน ดู Alerting ด้านล่าง |
ALERT_WAIT_SEC | 900 | ถ้าเวลารอโดยประมาณ (วินาที) ถึงค่านี้หรือเกิน room จะแจ้งเตือน 0 คือปิด |
ALERT_DEPTH | 0 | ถ้าจำนวนคนในคิวถึงค่านี้หรือเกิน room จะแจ้งเตือน 0 (ค่าเริ่มต้น) คือปิด เพราะคิวยาวอย่างเดียวยังไม่ใช่เหตุร้าย เวลารอที่นานจนไม่มีใครทนรอไหวต่างหากที่ใช่ |
ALERT_STALL_SEC | 120 | มีคนรออยู่ แต่ ไม่มีใคร ได้เข้าเลยนานเท่านี้ จะแจ้งเตือนระดับ critical 0 คือปิด |
ALERT_FROZEN_SEC | 300 | มีคนรออยู่ใน room ที่ถูก pause หรือตั้งเป็น 0/min นานเท่านี้ จะแจ้งเตือนระดับ critical ใช้จับ room ที่ pause แล้วลืม 0 คือปิด |
ALERT_HOLD_SEC | 30 | เงื่อนไขต้องเป็นจริงต่อเนื่องนานเท่านี้ก่อนแจ้งเตือน กันไม่ให้ traffic พุ่งแค่สี่วินาทีไปปลุกใคร |
ALERT_REPEAT_SEC | 900 | ความถี่ที่แจ้งเตือนซ้ำ ตราบที่เงื่อนไขยังเป็นจริง |
ALERT_EVERY_MS | 15000 | ความถี่ที่ตรวจเงื่อนไข |
หลัง deploy ทุกครั้ง ให้เปิด GET /api/admin/health ดู มันบอกค่าที่ใช้อยู่จริง สถานะของที่เก็บข้อมูล และคำเตือนถ้าตั้งค่าผิด
ใน dashboard ข้อมูลชุดเดียวกันอยู่หลังปุ่ม Diagnostics ได้แก่ status, version, uptime, จำนวน room และผู้เข้าชม, stream SSE, rate limit, ที่เก็บข้อมูล, proxy, หน่วยความจำ และคำเตือนทุกข้อ มีตัวนับสองตัวที่ควรดูเป็นประจำ:
- checks for unknown rooms: snippet ที่อ้างถึง room id ที่ไม่มีอยู่บน server นี้ เช่น พิมพ์ผิด หรือ room ถูกลบไปแล้ว
- blocked return URLs: ดู
returnOriginsด้านล่าง
หน้านี้แสดงข้อมูลแบบอ่านง่าย ไม่ใช่ JSON ดิบ ส่วน pid, นาฬิกาของ server และบล็อกการตั้งค่า key มีเฉพาะใน API response ตราบที่ยังมีคำเตือนค้างอยู่ header จะแสดง badge insecure config
อยู่หลัง CDN หรือ load balancer (อ่านหัวข้อนี้)
ถ้า server นี้อยู่หลัง CDN หรือ load balancer ต้องบอก server ว่า proxy คือใคร:
TRUST_PROXY=1 # hops between the client and this server
TRUST_PROXY_IPS=10.0.0.7 # the address(es) those hops connect FROM
เหตุผล: limit ต่อ IP นับตาม address ที่ server นี้ พิสูจน์ได้ คือ address ของ เครื่องที่ต่อเข้ามาตรง ๆ (socket peer) เมื่ออยู่หลัง CDN ผู้เข้าชมทุกคนต่อเข้ามาผ่าน address เดียวกันนั้น limit ต่อ IP จึงกลายเป็น limit รวมของทั้งเว็บไปเงียบ ๆ เช่น join ได้ 600 ครั้งต่อนาทีสำหรับทั้งเว็บ
- ระบบเชื่อ
X-Forwarded-Forเฉพาะเมื่อ peer อยู่ในTRUST_PROXY_IPSถ้ามาจากที่อื่น (รวมถึงทุก peer เมื่อตั้งแค่TRUST_PROXYอย่างเดียว) address ของผู้เข้าชมคือ address ของ socket ไม่ว่า header จะเขียนว่าอะไร address นี้ตัวเดียวใช้กับทุกอย่าง: การจำกัดความถี่และเพดาน SSE, ตัวตนใน pre-queue,PROTECTION_ALLOW_IPSและสัญญาณบอท,ipใน audit trail และX-Forwarded-Forที่แอปแบบ inline ได้รับ เครื่องเดียวที่ปลอมค่า header 900 แบบ จึงไม่ได้โควตา join เท่าผู้เข้าชม 900 คน อ้างว่าเป็น address ที่อยู่ใน allowlist ไม่ได้ และล้างคะแนนบอทของตัวเองไม่ได้ ส่วน CDN ก็ยังถูกนับรวมเป็นโควตาชุดเดียวจนกว่าคุณจะใส่TRUST_PROXY_IPS - ตั้งครบทั้งสองตัว ผู้เข้าชมแต่ละคนจะได้โควตาของตัวเอง และตัว proxy ไม่ถูกนับในโควตาตาม address ของ socket
- มีหลายชั้น (CDN → LB ของคุณ → server นี้): ตั้ง
TRUST_PROXY=2และใส่ address ที่ LB ของคุณใช้ต่อเข้ามา - CDN ที่ต่อเข้ามาตรง ๆ จะมาจากหลายช่วง address ที่ผู้ให้บริการประกาศไว้ ไม่ใช่ address เดียว ใส่เป็น CIDR ทั้ง IPv4 และ IPv6:
TRUST_PROXY_IPS=173.245.48.0/20,103.21.244.0/22,2400:cb00::/32,...ระบบไม่มีรายการสำเร็จรูปของ CDN ไหนเลย ให้คัดลอกรายการล่าสุดจากผู้ให้บริการ และอัปเดตตามเมื่อเขาเปลี่ยน ถ้าเขาเพิ่มช่วงใหม่แต่คุณยังไม่ได้ใส่ ผู้เข้าชมที่มาทางช่วงนั้นจะใช้โควตาชุดเดียวกันหมด ระบบตรวจทุกรายการตอนเริ่ม ถ้ารายการไหนไม่ใช่ address หรือช่วง address (173.245.48.0/33, สองรายการที่ลืมใส่ comma คั่น, ชื่อ host) server จะหยุดและบอกรายการนั้นใน error เพราะพิมพ์ผิดในรายการนี้จะทำให้ ไม่เชื่อ proxy ตัวจริง หรือไปเชื่อเครื่องที่ไม่ได้ตั้งใจ TRUST_PROXY_IPSยังทำให้ผู้เข้าชมถือ pass ต่อไปได้ แม้ CDN จะย้ายเขาไปมา ระหว่าง edge node ดู Pass ผูกกับผู้เข้าชมอย่างไร ระบบไม่รับตัวตนที่ส่งต่อมา จาก peer ที่ไม่อยู่ในรายการนี้เลยX-Forwarded-ProtoและX-Forwarded-Hostใช้กฎเดียวกัน ถ้ามาจาก peer ในTRUST_PROXY_IPSสองตัวนี้ตัดสินว่า:- ผู้เข้าชมใช้ HTTPS หรือไม่ ซึ่งมีผลกับ flag
Secureของ cookie ทุกตัว, scheme ที่บอกแอปแบบ inline ในX-Forwarded-Proto/-Portของมัน และ URL ที่ใช้จับคู่กับกฎของ room - URL เต็มที่ server นี้แจกออกไปจะใช้ host ไหน (connector manifest, feed อัปเดตของ WordPress, เอกสารที่ render แล้ว) ในกรณีที่ไม่ได้ตั้ง
PUBLIC_URL
ถ้ามาจากที่อื่น (รวมถึงทุก peer เมื่อตั้งแค่ TRUST_PROXY อย่างเดียว) ระบบไม่สนใจทั้งสองตัว scheme ใช้ของ socket และ host ใช้ Host proxy ที่ถอด TLS แต่ไม่ได้อยู่ในรายการ จึงได้ cookie ที่ไม่มี Secure แก้โดยใส่ proxy นั้นในรายการ หรือตั้ง COOKIE_SECURE=1
กฎเดียวกันนี้ใช้กับเพดาน SSE ต่อ IP ด้วย
proxy ที่คุณยังไม่ได้ใส่ในรายการ ระบบจะบอกชื่อให้ ไม่เงียบ header ที่ใช้ส่งต่อ address ได้แก่ X-Forwarded-For, Forwarded, CF-Connecting-IP, X-Real-IP และ True-Client-IP ถ้า header เหล่านี้มาจาก peer ที่ไม่อยู่ใน TRUST_PROXY_IPS ตั้งแต่ 10 request ขึ้นไปจาก address เดียวภายในหนึ่งนาที (ไม่ว่าจะตั้ง TRUST_PROXY หรือไม่) server จะถือว่า peer นั้นเป็น proxy ที่ไม่ได้ประกาศ
ทำไมถึงสำคัญ: ผู้เข้าชมทุกคนหลัง proxy นั้นใช้ address เดียวกันของ proxy queueMaxPerIp (ค่าเริ่มต้น 16 ที่ต่อ address) และ preQueueMaxPerIp จึงจำกัด ทั้ง room ไว้แค่จำนวนนั้น และโควตา join กับ check ก็กลายเป็นโควตา ชุดเดียวของทั้งเว็บ เมื่อเจอแบบนี้ server จะ:
- เพิ่มคำเตือนใน
warningsของ/api/admin/healthบอก address และบรรทัดTRUST_PROXY_IPS=…ที่ควรตั้ง (รายการเดิมของคุณบวก address นี้) ตั้งproxy.ok: false,proxy.forwardedFromUndeclaredPeer: trueและใส่ address ไว้ในproxy.undeclaredPeersconsole จะแสดงใน badge ของ header - เขียน log หนึ่งบรรทัด และไม่เกินหนึ่งบรรทัดทุก 10 นาทีตราบที่ยังเกิดอยู่
request เดียวที่ปลอม header ไม่ทำให้เตือน แต่ script ที่ส่งมาเรื่อย ๆ ทำได้ ดู address ก่อนคัดลอก ใส่เฉพาะเมื่อเป็น load balancer หรือ CDN ของคุณเท่านั้น address ที่ไม่รู้จักคือผู้เข้าชมที่ปลอม header ถ้าใส่ลงรายการ ผู้เข้าชมคนนั้น จะเลือก address ของตัวเองได้ วิธีแก้คือแก้ TRUST_PROXY_IPS ตัวคำเตือนเองไม่เคยเปลี่ยนว่าระบบเชื่อ header ของใคร
ระวังการตั้งแค่ตัวเดียวในสองตัว สองตัวแปรนี้คือการตั้งค่าเดียวกัน server จึงไม่ยอมให้ตั้งครึ่งเดียวแล้วเงียบ: พิมพ์คำเตือนตอนเริ่ม ส่งคืนใน warnings ของ /api/admin/health พร้อม proxy.ok: false และ header ของ dashboard แสดง badge insecure config (แถว Proxy แสดง … · INCOMPLETE) จากการวัดจริง traffic ที่พุ่งขึ้นผ่าน proxy ที่ตั้งครึ่งเดียว เสีย join ไปราว 94% เป็น HTTP 429 และจากภายนอกดูไม่ออกเลยว่าต่างจาก queue ที่ทำงานปกติ
ค่าลับจาก Cloudflare edge
ใน production ผู้เข้าชมเข้าถึง server นี้ผ่าน Cloudflare zone ของเราเท่านั้น แต่ address ของตัว app เอง (URL ของ DO App Platform หรือตัว container) ก็ตอบได้ด้วย request ที่ยิงตรงไปที่นั่นจะข้ามทุก rule, cache และ rate limit ของ Cloudflare และใส่ ค่าอะไรก็ได้ลงใน CF-Connecting-IP EDGE_SECRET ปิดช่องนี้: Cloudflare ใส่ header ลับให้ทุก request ที่ส่งต่อมา และ server นี้ปฏิเสธ request ที่ไม่มี header นั้น
วิธีตั้งค่า (ทำตามลำดับนี้ จะได้ไม่มี request ถูกปฏิเสธระหว่างตั้ง):
- สร้างค่าสุ่มยาว ๆ เช่น
openssl rand -hex 32ค่าที่สั้นกว่า 16 ตัวอักษรจะทำให้ server หยุดตอนเริ่ม เพราะค่าลับที่เดาได้แย่กว่าไม่มีเลย มันดูเหมือนป้องกันอยู่แต่ไม่ได้ป้องกัน - ใน dashboard ของ Cloudflare สำหรับ zone นั้น: Rules → Transform Rules → Modify Request Header → Create rule แล้วเลือก Set static header
X-QM-Edge= ค่านั้น แล้ว deploy rule ให้ rule จับเฉพาะ hostname ของ app นี้ (เช่น Hostname equalsqm.weekday100.com) ห้ามใช้ All incoming requests เพราะ rule ที่ครอบทั้ง zone จะใส่ค่าลับให้ทุก request ที่ Cloudflare ส่งไปทุก origin ใน zone ทั้งแอปอื่นของคุณ และบริการภายนอกที่ hostname ชี้ไปด้วย CNAME (ระบบ help desk, แพลตฟอร์มร้านค้า, หน้า status) ใครในนั้นก็เก็บค่านี้ลง log ได้ และใครที่ได้ค่านี้ไปก็เรียก address ของ app นี้ตรง ๆ แล้วถูกนับว่ามาจาก Cloudflare พร้อมCF-Connecting-IPที่ตั้งเองได้ - ตั้ง
EDGE_SECRETของ app เป็นค่าเดียวกัน แล้ว deploy
เมื่อตั้งแล้วจะเป็นอย่างนี้:
- ทุก HTTP request (API, console และ admin API, SSE, ไฟล์ static, หน้ารอ และทุก path ที่ผ่านด่านและถูก proxy ในแบบ inline) และทุก WebSocket upgrade ต้องมี
X-QM-Edgeตรงกับค่าใดค่าหนึ่งในรายการ ถ้าไม่มีจะได้403พร้อม{"error":"edge_required","code":"edge_required"}หรือหน้า403แบบเรียบ ๆ ถ้าเป็นการเปิดหน้าใน browser ทั้งสองแบบไม่บอกชื่อ header upgrade จะถูกปฏิเสธ ก่อนเปิด socket ไปที่ origin /healthzเป็นข้อยกเว้นเดียว เพราะ health check ของ DO App Platform ยิงตรงเข้า container ครอบคลุมเฉพาะGET/HEADของ/healthzตรงตัว (มี query string ได้ แต่/HEALTHZ,//healthzหรือแบบ encode ไม่ได้) และใช้ได้เฉพาะบน host ที่ไม่ได้เป็นหน้าของ room แบบ inline (บน host แบบ inline/healthzเป็น path ของแอปเอง) รายการนี้อยู่ใน constant เดียวคือEDGE_EXEMPTในserver.js- address ของผู้เข้าชมคือ
cf-connecting-ipในทุก request ที่ผ่านการตรวจ: ใช้กับ key ของ throttle, เพดาน SSE, ตัวตนใน pre-queue, สัญญาณ bot,ipใน audit trail และ hop ขวาสุดของX-Forwarded-Forที่แอปแบบ inline ได้รับX-Forwarded-Forที่ปลอมมาไม่มีผลอะไร ถ้าไม่มีcf-connecting-ipหรือค่าไม่ใช่ address ระบบจะใช้ address ของ socket และนับไว้ (edge.clientIpFallbackTotalใน/api/admin/health,qm_edge_client_ip_fallback_total) หลัง Cloudflare ค่านี้ควรเป็น 0 ตลอด - ระบบไม่สนใจ
TRUST_PROXYและTRUST_PROXY_IPS(ดู อยู่หลัง CDN หรือ load balancer) ถ้ายังตั้งไว้จะมี warning บอกX-Forwarded-Protoเชื่อได้ใน request ที่ผ่านการตรวจ ส่วนX-Forwarded-Hostไม่เชื่อ เพราะ Cloudflare ส่งค่าที่ client ใส่มาผ่านไปด้วย - ค่าลับไม่ถูกเขียนลง log, audit หรือ metric และ header นี้ถูกลบออกก่อนส่ง request หรือ upgrade ต่อไปที่
proxyOriginของ room - การปฏิเสธนับแยกต่อ instance:
qm_edge_refusedใน/metrics(มี labelinstanceเมื่อใช้STORE=valkey) และedge.refusedTotalใน/api/admin/healthถ้าขึ้นเรื่อย ๆ แปลว่ามีอะไรเรียก address ของ app ตรง ๆ ถ้ากระโดดขึ้นทุก request แปลว่า rule ของ Cloudflare กับEDGE_SECRETไม่ตรงกันแล้ว
หมุนค่า โดยไม่ปฏิเสธใคร: เพิ่มค่าใหม่ลงในรายการ (EDGE_SECRET=old,new) แล้ว deploy เปลี่ยน rule ของ Cloudflare เป็นค่าใหม่ แล้วเอาค่าเก่าออก (EDGE_SECRET=new) แล้ว deploy อีกครั้ง
ถ้าไม่ได้ตั้ง ทุกอย่างเหมือน release ก่อนหน้า เมื่อใช้ STORE=valkey หรือ NODE_ENV=production server จะพิมพ์ EDGE_SECRET is not set: requests that bypass Cloudflare are accepted ตอนเริ่ม และแสดงไว้ใน hardening ของ /api/admin/health
การ deploy สองแบบ: snippet และ inline
วาง queue นี้หน้าเว็บได้สองแบบ และสองแบบนี้ พังไม่เหมือนกัน ให้เลือกจากตรงนี้ ไม่ใช่จากว่าแบบไหนติดตั้งง่ายกว่า
| Snippet (ค่าเริ่มต้น) | Inline (proxy) | |
|---|---|---|
| ทำงานอย่างไร | เว็บโหลด script เล็ก ๆ script ถาม server นี้ว่าผู้เข้าชมเข้าได้ไหม ถ้าไม่ได้ ก็ส่งไปหน้ารอ queue อยู่ ข้าง traffic | DNS ของเว็บชี้มาที่ server นี้ ทุก request ของ host นั้นมาถึงที่นี่ คนที่ได้เข้าจะถูกส่งต่อไปที่แอป คนอื่นเห็นหน้ารอที่ URL เดิมที่เขาขอ queue อยู่ บน ทางเดินของ traffic |
| ถ้า server นี้ตาย | script ถามไม่ได้ หน้าเว็บจึงเปิดได้ โดยไม่ต่อคิว เว็บยังอยู่ แต่ไม่มีการป้องกันแล้ว | เข้าเว็บไม่ได้เลย เพราะฉะนั้นหน้า failover ของ CDN ใน เมื่อตัว queue เองล่ม จึงเป็นสิ่งที่ต้องทำ ไม่ใช่ทางเลือก สำหรับแบบ inline |
เทียบสามแบบ:
- snippet: หน้าเว็บมาจากเว็บของคุณ และ tag ใน browser เป็นตัวถาม queue ถ้า queue ล่ม หน้าเว็บจะโหลดโดยไม่ต่อคิว
- edge connector: ก็เป็นแบบ snippet เหมือนกัน แต่ Cloudflare Worker เป็นตัวถาม queue ก่อนที่ request จะถึงเว็บของคุณ ถ้า queue ล่ม Worker จะส่ง request ต่อไปที่เว็บของคุณโดยไม่ต่อคิว และติด
X-QM-Failoverไว้ใน response - inline: ทุก request ผ่าน queue ถ้า queue ล่ม จะเข้าเว็บไม่ได้ เว้นแต่ CDN จะแสดงหน้า failover
เปิดโหมด inline ใน console: Edit (หรือ + New room) → ติ๊ก Serve this room inline แล้วกรอก Internal address of the site behind the queue เอาติ๊กออกเมื่อไร room จะกลับเป็นแบบ snippet โดยมีหน้าต่างยืนยันบอกไว้ก่อน หรือทำผ่าน API:
curl -sX POST "$BASE/api/admin/rooms" -H "Authorization: Bearer $ADMIN_KEY" \
-H 'Content-Type: application/json' -d '{
"id": "shop",
"targetUrl": "https://shop.example",
"proxyOrigin": "http://10.0.0.5:3000"
}'
targetUrlคือ address สาธารณะ คือ host ที่ browser ของผู้เข้าชมส่งมา และเป็นตัวที่ server นี้ใช้จับคู่กับ request ที่เข้ามาproxyOriginคือ address ภายใน ที่มีแค่ server นี้เข้าถึงได้ เขียนแค่ origin: scheme, host, port ไม่มีอย่างอื่น ถ้าชี้กลับไปที่ host สาธารณะจะถูกปฏิเสธ (proxy_origin_loop) เพราะจะวนไม่รู้จบผ่าน CDN ของคุณproxyOriginที่เป็น address ภายในจะถูกปฏิเสธ เว้นแต่ตั้งPROXY_ALLOW_PRIVATE=1รวมถึง10.0.0.5ในตัวอย่างข้างบนด้วย รายการที่ถูกห้าม: loopback (127.0.0.0/8,::1),0.0.0.0/8, RFC1918, CGNAT100.64.0.0/10, link-local (169.254.0.0/16ซึ่งมี endpoint metadata ของ cloud169.254.169.254อยู่ในนั้น;fe80::/10), unique-localfc00::/7(มีfd00:ec2::254อยู่ในนั้น),::และทุกตัวในรูป IPv4-mapped เหตุผล: ไม่อย่างนั้นใครก็ตามที่แก้ room ได้ จะสั่งให้ server นี้ไปดึงข้อมูลจากเครือข่ายภายในของมันเองได้ ระบบตรวจ address ที่ต่อจริงหลังแปลง DNS แล้ว ทุกครั้งที่เปิด connection ใหม่ ชื่อ host ที่ต่อมาเปลี่ยนไปชี้127.0.0.1(DNS rebinding) จึงถูกปฏิเสธตอนนั้น ชื่อที่ DNS ตอบมามี address ภายในแม้แค่ตัวเดียว ถูกปฏิเสธทันที การ probe สุขภาพของ origin ใช้กฎเดียวกัน ถ้าแอปอยู่ใน LAN จริง ให้ตั้งPROXY_ALLOW_PRIVATE=1- ไม่มี
proxyOrigin= โหมด snippet ระบบที่ติดตั้งอยู่แล้วไม่มีอะไรเปลี่ยน เพียงเพราะมีฟีเจอร์นี้ ratePerMinuteรับชื่อrateแทนได้ เพราะGET /api/admin/roomsเรียก field นี้ว่าratescript ที่อ่าน room แล้ว POST กลับไปตรง ๆ จึงไม่ต้องเปลี่ยนชื่อก่อน ส่งทั้งสองชื่อได้ถ้าค่าตรงกัน ถ้าไม่ตรงได้400 conflicting_fieldชื่อ field ที่ไม่อยู่ในรายการที่ระบุไว้จะถูกปฏิเสธ (400 unknown_field) ไม่ใช่ถูกทิ้งเงียบ ๆ เพราะเดิมพิมพ์ชื่อผิดแล้วดูเหมือนแก้สำเร็จทุกอย่าง
สิ่งที่เปลี่ยนเมื่อ room เป็น inline:
| Snippet | Inline | |
|---|---|---|
| route ของ queue เอง | /api/*, /w/<room> | อยู่ใต้ /__qm (/__qm/api/join, …) |
| route สำหรับ monitor | /healthz, /api/version, /metrics | เหมือนเดิม และมีใต้ /__qm ด้วย probe จะได้ตัวไหนขึ้นกับ address ที่ใช้ ไม่ใช่ path: ถ้าเรียกผ่านชื่อ host ของ ลูกค้า path พวกนี้เป็นของลูกค้า และด่านคัดผู้เข้าชมจะตอบ 503 ตลอดไป ให้ probe ที่ process ตรง ๆ หรือใช้ /__qm/healthz และ metrics_path: /__qm/metrics |
| operator console | / บน host ของ queue เอง | /__qm/ บน host ของ ลูกค้า เป็นหน้าเดียวกัน แต่เรียก route ที่มี prefix บุ๊กมาร์ก address นี้ ไม่ใช่ / เพราะ / บน host นั้นคือหน้าร้าน |
| การ sign-in เข้า console | qm_console, HttpOnly, Path=/api/admin | qm_console, HttpOnly, Path=/__qm/api/admin: script ของลูกค้าอ่านไม่ได้ และตัว key เองไม่เคยถูกเก็บใน browser |
| door key | อยู่ใน URL (?sess=) script อ่านได้ | cookie qm_dk_<roomId> แบบ HttpOnly เท่านั้น ไม่มีอะไรใน query string แม้ใต้ /__qm แยกตาม room การล้าง key ของ room หนึ่งจึงไม่กระทบอีก room |
ticket token qm_<room> | cookie ที่อ่านได้ + localStorage + ?token= เพราะ host นี้เป็นของเรา และคนอ่านคือ script ของหน้าเราเอง | cookie แบบ HttpOnly เท่านั้น ไม่มีใน localStorage ไม่มีใน query string แม้ใต้ /__qm |
| การจับคู่ URL | จับคู่กับค่าที่ snippet รายงานมา | จับคู่กับ URL จริงของ request |
returnOrigins / tagOrigins / CORS | จำเป็น | ไม่ได้ใช้ เพราะไม่มีอะไรข้าม origin |
| server นี้ล่ม | เว็บยังอยู่ แต่ไม่ต่อคิว | เว็บล่ม ต้องมี CDN failover |
prefix /__qm ถูกจองไว้ ไม่ได้มีไว้สวย ๆ: แอปของแคมเปญมี /api/* ของตัวเอง และ queue ต้องไม่ไปทับมันเด็ดขาด ทุกอย่างบน host ที่ถูก proxy ที่ไม่อยู่ใต้ /__qm เป็นของแอป
room id ที่ขึ้นต้นด้วย dk_ ก็ถูกจองไว้ด้วยเหตุผลเดียวกัน (QM-533) cookie ตั๋วของ room dk_x จะชื่อ qm_dk_x ซึ่งตรงกับชื่อ cookie door key ของ room x ทุกตัวอักษร: สอง cookie นี้เขียนทับกันในเบราว์เซอร์ และเพดาน cookie ต่อ host จะนับตั๋วนั้นเป็นของ room x แล้วลบทิ้งไปพร้อม cookie ของ x การสร้าง room แบบนี้จะได้ 400 invalid_room_id room dk_ ที่สร้างไว้ก่อนกฎนี้ยังใช้งานและแก้ไขได้ แต่ปัญหาชื่อชนกันยังอยู่: ให้สร้าง room ใหม่ด้วย id อื่นแล้วย้าย tag ไปใช้ room นั้น
room id อื่นที่ถูกจองก็มาจากกฎเดียวกัน (546): ตั๋วของ room เก็บไว้ใต้ชื่อ qm_<room> ทั้งเป็น cookie และเป็น key ใน localStorage บน host ของ queue ดังนั้น room id ต้องไม่ทำให้ชื่อนั้น ไปตรงกับชื่อที่ queue ใช้อยู่แล้ว การสร้างจะได้ 400 invalid_room_id (ตัวพิมพ์เล็กใหญ่มีผล):
- id ที่ขึ้นต้นด้วย
meta_,notify_หรือemail_(key ต่อ room ของหน้ารอ คือqm_meta_<room>,qm_notify_<room>,qm_email_<room>) - id ที่ตรงกับ
console,theme,lang,admin_key,autotune,autotune_cfg,wait_samples,hidden_series,selected_room,alerts,offline_attempts,origin_down_attemptsพอดีconsoleจะชนกับ cookie ลงชื่อเข้า consoleqm_consoleส่วนที่เหลือเป็น key ใน storage ของ console และของหน้ารอ หน้า offline และหน้า origin-down บน origin เดียวกัน room ชื่อadmin_keyจะเขียนตั๋วลงไปในที่ที่ console เก็บ key ของมันไว้
เมื่อ server หาตั๋วจาก cookie ของ request (/api/status, /events) จะไม่อ่าน qm_console เป็นตั๋วเลย และ cookie ที่ใช้ชื่อที่ถูกจองจะถูกลองหลังจาก cookie qm_ อื่นทั้งหมดแล้วเท่านั้น door key ของแปด room จึงเบียดตั๋วจริงออกไปไม่ได้
แถว ticket token ใช้เหตุผลเดียวกับ prefix แต่ใช้กับ credential ในโหมด inline cookie และ localStorage ของเราไปอยู่บน origin ของลูกค้า ใช้ร่วมกับ analytics tag, chat widget และ script โฆษณาทุกตัวที่เว็บใส่ไว้ จึงเก็บ token ไว้ในที่ที่ไม่มีใครในนั้นเอื้อมถึง และหน้ารอไม่เก็บอะไรไว้เลย ซึ่งก็ไม่จำเป็นต้องเก็บ: /api/join คืน token ใน body ของ response ทุกครั้งที่โหลด และ /api/status กับ /events ยืนยันตัวจาก cookie เมื่อไม่ได้ส่ง token= มา
ตอนส่งต่อ ระบบคง Host ของผู้เข้าชมไว้ และเพิ่ม X-Forwarded-Host, -Proto, -Port และ -For แอป Next.js ที่อยู่ข้างหลังจึงสร้าง URL เต็มของตัวเองได้ถูกต้อง ทั้งสี่ตัวบอกข้อมูลช่วงของ ผู้เข้าชม ไม่เคยบอก proxyOrigin: -Port คือ port ที่อยู่ใน Host ของผู้เข้าชม หรือ 443/80 ตาม -Proto ถ้าเขาไม่ได้ระบุ port การ upgrade เป็น WebSocket ก็ส่งสี่ตัวนี้เหมือนกัน
รายการ ขวาสุด ของ X-Forwarded-For คือ address ของผู้เข้าชมที่ server นี้หาได้เสมอ แอปที่เชื่อ proxy แค่ชั้นเดียว (คือ server นี้) จึงได้ค่าจริง ถ้ามาจาก proxy ที่อยู่ใน TRUST_PROXY_IPS ลำดับ address ที่ proxy ส่งมาจะถูกเก็บไว้ ทางซ้ายของรายการนั้น และ Forwarded, X-Real-IP, CF-Connecting-IP กับ True-Client-IP ของมันผ่านไปได้ ถ้ามาจาก peer อื่น header เหล่านี้คือสิ่งที่ ผู้เรียกพูดเอง ระบบทิ้งทั้งหมด และ X-Forwarded-For จะมีแค่ address ของ socket TRUST_PROXY / TRUST_PROXY_IPS ยังใช้ตามปกติ เพราะเป็นวิธีที่ server นี้ รู้ address และ scheme ของผู้เข้าชมจาก CDN ของคุณ -Proto (และ -Port ค่าเริ่มต้น) ที่ส่งต่อไป จะเป็น scheme ที่ proxy รายงาน ก็ต่อเมื่อ proxy นั้นอยู่ใน TRUST_PROXY_IPS เท่านั้น ถ้ามาจาก peer อื่น จะเป็น scheme ของ connection ที่ server นี้ได้รับจริง ไม่ว่าผู้เรียกจะอ้างว่าอะไร
เมื่อตัวแอปเองมีปัญหา:
| เกิดอะไรขึ้น | ผู้เข้าชมเห็นอะไร | อื่น ๆ |
|---|---|---|
| แอปไม่รับ connection / ติดต่อไม่ได้ | 502 + X-QM-Origin: origin_unreachable ถ้าเป็นการเปิดหน้าเว็บได้ origin-down.html ถ้าเป็น XHR ได้ JSON | นับแยกตาม room; log หนึ่งบรรทัดต่อนาที บอกชื่อ room |
proxyOrigin เป็น address ภายใน และ PROXY_ALLOW_PRIVATE ไม่ใช่ 1 | 502 + X-QM-Origin: origin_unreachable เหมือนกัน (WebSocket ก็ได้ 502) ไม่มีอะไรถูกส่งไปที่ address นั้น | นับแยกตาม room ในหมวด blocked; originErrors.reason ของ room และ log (หนึ่งครั้งต่อนาทีต่อ room) บอก address ที่ถูกห้าม; probe แสดงผลว่าล่ม โดย targetHealth.blocked บอกเหตุผล |
| แอปรับ connection แล้วไม่ตอบเลย | 504 + X-QM-Origin: origin_timeout หลัง PROXY_TIMEOUT_MS | เหมือนกัน |
| แอปเริ่มตอบแล้วค้างกลางทาง | การส่งถูกตัด header ออกไปแล้ว จึงไม่เหลือ status code ให้ส่ง และไม่มีทางจบหน้านั้นอย่างซื่อตรง | นับแยกตาม room ในหมวด body_stall หลัง PROXY_IDLE_TIMEOUT_MS; ถ้าผู้เข้าชมเลิกรับเอง จะนับเป็น client_reset แทน |
| XHR หรือ API call ของผู้เข้าชมที่ยังต่อคิว | 503 queued + Retry-After + X-QM-Queued: 1 | สิ่งที่ browser แสดงผล ให้คนดูจะได้หน้ารอแทน ดูด้านล่าง |
process นี้ถึง MAX_CONNECTIONS | 503 + Retry-After (ไม่มี X-QM-Queued) เป็นหน้า offline ไม่ใช่หน้ารอ | qm_connections_shed_total; /healthz ก็ตอบ 503 ของตัวเองพร้อม ready:false ดู CONNECTION_RESERVE ด้านบน |
เมื่อเกิน MAX_CONNECTIONS ระบบตั้งใจ ทิ้ง request ไม่ได้ให้ต่อคิว เพราะไม่มีกรณี "รอสักครู่แล้วจะได้เข้า" process ไม่มีที่ว่างจริง ๆ คำตอบนี้จึงตั้งใจไม่ใส่ X-QM-Queued: กฎ failover ของ CDN ที่ดู 503 ควร ทำงานตรงนี้และแสดงหน้าเว็บล่มของตัวเอง เหมือนเหตุล่มจริงอื่น ๆ ก่อนมี CONNECTION_RESERVE ระบบตอบในสถานะนี้ไม่ได้เลย เพราะ server.maxConnections ของ Node ทำลาย socket ไปก่อนแบบเงียบ ๆ และ origin ที่ค้างจะกิน socket สองตัวต่อผู้เข้าชมที่ติดอยู่ ตลอด PROXY_TIMEOUT_MS ทำให้ชนเพดานได้ในเวลาไม่ถึงนาที ซึ่งเกิดตรงกับเหตุล่มที่ product นี้มีไว้รับมือพอดี
X-QM-Queued มีไว้สองอย่าง: กันไม่ให้กฎ failover ของ CDN ที่ดู 503 เอาหน้าเว็บล่มมาแทน queue ที่ปกติดี และเป็นสัญญาณให้แอปของลูกค้าใช้ reload หน้าที่ pass หมดอายุกลางทาง แทนที่จะพังแบบเงียบ ๆ ส่วน X-QM-Origin บอกว่าตัวที่พังคือเว็บที่อยู่หลัง queue
request ไหนได้แบบไหน ระบบดูว่า browser จะแสดงคำตอบให้คนดูหรือไม่ ไม่ได้ดูว่า method เป็น GET หรือเปล่า: Sec-Fetch-Mode: navigate (method ไหนก็ได้), Sec-Fetch-Dest ที่เป็นการเปิดหน้า หรือ (สำหรับ client ที่ไม่ส่ง Fetch Metadata) Accept: text/html <form method="post"> ที่ส่งหลัง pass หมดอายุ นับเป็นการเปิดหน้า จึงได้หน้ารอ ส่วน fetch() ที่ขอ JSON จะได้ JSON
กู้หน้าที่ pass หมดอายุ คำตอบแบบ queued บอกชื่อ room และเหตุผลจาก engine (expired, ejected, stale_generation, waiting, at_capacity, …) และตั้ง Access-Control-Expose-Headers ไว้ ให้อ่าน header ได้แม้ call นั้นข้าม origin:
{ "error": "…", "code": "queued", "roomId": "shop", "reason": "ejected", "reload": true }
ในโหมด inline ไม่มี address หน้ารอแยก หน้ารอแสดง ที่ URL ที่ผู้เข้าชมขอ การกู้จึงมีแค่ขอ URL ปัจจุบันใหม่:
const r = await fetch('/api/cart');
if (r.status === 503 && r.headers.get('X-QM-Queued')) { location.reload(); return; }
ถ้าไม่มีบรรทัดนี้ เว็บจะแสดงผลตามที่โค้ดจัดการ request ที่ล้มเหลวต่อไป ผู้เข้าชมที่แค่ถูกส่งกลับไปต่อคิว หน้ารอจะบอกเขาเองว่าเพราะอะไร ไม่ได้ทำเหมือนเขาเพิ่งมาถึงใหม่
บนชื่อ host ที่ server นี้ไม่ได้อยู่หน้า room ไหน operator console จะเป็น 404 เปล่า ๆ ดู ADMIN_HOST ด้านบน: ในโหมด inline console ตอบบน IP ตรง ๆ, localhost และ host ของ PUBLIC_URL หรือถ้าตั้ง ADMIN_HOST ก็บนชื่อที่คุณใส่ และไม่ตอบที่อื่น
- room แบบ inline ที่
targetUrlเป็น IP ตรง ๆ (แบบที่มักใช้ตอนพัฒนาบนเครื่อง) ได้หน้าต่าง ๆ ของ host นั้น แต่ถ้าไม่ได้ตั้งADMIN_HOSTจะไม่ได้/api/admin/*ของมันเลย เพราะ address นั้นเป็นของ console ด้วย admin call ที่ส่งไปตรงนั้น queue จะตอบเอง ไม่ส่งต่อ (QM-439) - ถ้าตั้ง
ADMIN_HOSTชื่อ host ของลูกค้าก็นับเป็น "ที่อื่น" ด้วย:/__qm/console,/__qm/api/admin/*และ/__qm/public/admin.htmlตอบ404เหลือแค่ path สำหรับผู้เข้าชมใต้/__qm/ - ชื่อ host ที่ไม่ได้อยู่หน้า room ไหน จะไม่มี console อย่างเดียว ไม่ว่าจะตั้ง
ADMIN_HOSTหรือไม่ (QM-428) ส่วน snippet,/healthz, หน้ารอ และ API สำหรับผู้เข้าชมยังตอบที่นั่น เพราะนั่นคือ address ที่ script tag ของลูกค้าอ้างถึง
ระวังจุดนี้ (QM-454): ถ้าไม่ได้ตั้งทั้ง ADMIN_HOST และ PUBLIC_URL แล้วบันทึก room แบบ inline ตัวแรก จาก console ที่เปิดผ่านชื่อ host (เช่น qm.weekday100.com) ตั้งแต่ request ถัดไป console นั้นและลิงก์ monitor ทุกลิงก์บน host นั้นจะได้ 404 การบันทึกยังสำเร็จ แต่คำตอบจะมีคำเตือน (warnings[], code: "console_host_withheld" พร้อมชื่อ host) และ console จะแสดงคำเตือนนี้หลังบันทึก ตราบใดที่ยังมี room แบบ inline และไม่ได้ตั้งทั้งสองค่า /api/admin/health ก็จะบอกเหตุผลเดียวกันใน configWarnings วิธีแก้คือตั้ง ADMIN_HOST (หรือ PUBLIC_URL) เป็นชื่อ host นั้นแล้ว restart
ถ้าเปลี่ยน room แบบ inline ตัวสุดท้าย บน host หนึ่งกลับเป็น snippet /__qm/* ของ queue จะหายไปจาก host นั้นด้วย รวมถึง console ที่คุณอาจกำลังเปิดอ่านอยู่ ถ้าคุณจัดการ queue ผ่าน domain ของลูกค้า console จะเตือนก่อนบันทึก และพาคุณไปที่ PUBLIC_URL หลังบันทึก address /__qm/… เก่าจะตอบ 404 {"code":"not_inline_host"} พร้อมบอกว่า console ย้ายไปไหน แทนที่จะเป็น not-found ธรรมดา ตั้ง PUBLIC_URL ไว้ ระบบจะได้บอกที่อยู่ใหม่ได้ (ถ้าตั้ง ADMIN_HOST console ไม่เคยอยู่บน domain ของลูกค้า กรณีนั้นจึงได้ 404 เปล่า ๆ)
ทั้งหมดนี้ไม่ทำให้ instance นี้ถูกนับว่าไม่ปกติ: origin ของลูกค้าดับ ไม่ใช่เหตุผล ที่จะเอา pod ออกจากการรับ traffic /healthz จึงยังเป็น 200 ดู Readiness ตัวเลขนับอยู่ใน /api/admin/health ใต้ inline ได้แก่ gatedTotal, bypassedTotal, originErrorsTotal และ originErrors ที่แยกตาม room และหมวด ถ้าไม่มี room ไหนถูก proxy บล็อกนี้จะไม่มีเลย
อะไรบ้างที่ข้าม gate
ไฟล์ประกอบหน้าเว็บที่เป็นไฟล์นิ่ง (static subresource) ถูกส่งต่อโดยไม่ตรวจคิว เหตุผลคือต้นทุน ไม่ใช่มารยาท: หน้าเดียวของแอปสมัยนี้มี request ย่อย 30–80 ตัว ถ้าตรวจทุกตัว ก็ต้องตรวจคิว ให้คะแนนบอท และเขียน cookie ทุกไฟล์ queue จะกลายเป็นคอขวดที่มันถูกติดตั้งมาเพื่อกันเสียเอง โดยไม่ได้ป้องกันอะไร การโหลด stylesheet ไม่ได้แย่งที่ใครในคิว
request จะข้าม gate ก็ต่อเมื่อเข้าเงื่อนไข ครบทั้งสี่ข้อ:
- method เป็น
GETหรือHEAD - path ลงท้ายด้วยนามสกุลของไฟล์สื่อ ได้แก่
js mjs cjs css mapรูปภาพ ฟอนต์ เสียง/วิดีโอ ตั้งใจ ไม่ รวม.json,.txtหรือ.xmlเพราะบ่อยครั้งเป็นคำตอบจาก API ไม่ใช่ไฟล์บนดิสก์ - path ตามที่แอปจะได้รับ เป็นแบบธรรมดา: ไม่มี
;ไม่มี percent-escape (%3b,%2f,%2eแม้แต่%20) ไม่มี backslash และไม่มีส่วนที่ตัวอ่าน URL ของ queue ต้องเขียนใหม่ (ส่วน..) เหตุผล: บรรทัด request ถูกส่งต่อให้แอปตามตัวอักษร และ framework จะตัดหรือถอดรหัสก่อนเลือก route เช่น/api/stock;.jsคือ/api/stockสำหรับ Tomcat และ Spring path แบบนี้จึงถูกตรวจเหมือนหน้าเว็บทั่วไป ต้นทุนคือตรวจคิวหนึ่งครั้ง ไม่มีใครเสียที่ Sec-Fetch-Dest(ถ้า browser ส่งมา) ไม่ใช่การเปิดหน้า (document,iframe,object,embed)
API ของแอปไม่เคยข้าม gate POST /api/checkout คือจุดที่ของมีจำกัดถูกซื้อ ถ้าปล่อยผ่าน ใครที่ยอมไม่ใช้ browser ก็ซื้อได้โดยไม่ต้องรอเลย Sec-Fetch-Dest ปลอมได้ แต่ปลอมแล้วได้แค่ไฟล์ในรายการข้างบน
กรณีขอบที่ต้องรู้: ถ้าแอปส่งเนื้อหา dynamic ที่กินทรัพยากรมากจาก URL ที่ลงท้ายด้วย .js หรือส่ง path อย่าง /api/stock/x.css ไปให้ handler ที่ต้องป้องกัน หรือใช้ .js เป็นตัวบอกรูปแบบข้อมูลแบบที่ Rails ทำ request ย่อยแบบนั้นจะข้าม gate ได้ ตอนนี้ยังไม่มีสวิตช์ปิดเรื่องนี้ (การจำกัดให้ข้ามได้เฉพาะ prefix ของ asset ที่ประกาศไว้ ยังเป็นเรื่องที่ต้องตัดสินใจ ด้าน product) ถ้าแอปของคุณทำแบบนั้น ให้แจ้งเรา
Streaming และ WebSocket
ระบบไม่พักข้อมูลไว้ทั้งขาไปและขากลับ response ที่ stream จะเริ่มถึงผู้เข้าชม ทันทีที่แอปสร้าง (โครงหน้าของ Next.js App Router จะขึ้นพร้อมกับตอนที่ไม่มี queue คั่นอยู่) และการ upload ก็ไหลผ่านไปโดยไม่ถูกเก็บไว้ใน process นี้
WebSocket และการ upgrade connection แบบอื่นถูกส่งต่อ และ ตรวจคิวเหมือนหน้าเว็บ ไม่ใช่เหมือนไฟล์ประกอบ: socket ที่เปิดค้างตลอดการเข้าชมไม่ใช่ไฟล์ประกอบ ถ้าปล่อยผ่านโดยไม่ต่อคิว ก็เท่ากับอยู่ในแอปได้โดยไม่ต้องต่อคิวเลย ผู้เข้าชมที่ยังรออยู่จะได้ 503 ตอน upgrade ไม่มีหน้ารอ เพราะตรงนั้นไม่มีหน้าเว็บ ปล่อยให้โค้ด reconnect ของแอปเองลองใหม่ เมื่อ handshake ผ่านแล้ว socket ทั้งสองจะถูกต่อกัน และ process นี้จะไม่ดูข้อมูลที่ผ่านอีกเลย
การ upgrade บน /__qm/* ถูกปฏิเสธด้วย 501 เพราะการอัปเดตสดของ queue เอง ใช้ Server-Sent Events ซึ่งเป็น HTTP ธรรมดา
Rate limit
ทุก route ที่มีการจำกัดความถี่จะตอบ header X-RateLimit-Limit, X-RateLimit-Remaining และ X-RateLimit-Reset (หน่วยวินาที) และเพิ่ม Retry-After เมื่อตอบ 429 client จึงปรับจังหวะของตัวเองได้ ไม่ต้องรู้ว่าชนเพดานตอนถูกตัดไปแล้ว
หน้ารอก็ถูก throttle ด้วย
หน้ารอก็มีโควตาต่อ address: WAITING_LIMIT_PER_MIN (ค่าเริ่มต้น 120) ประมาณสองครั้งต่อวินาทีจาก client เดียว สูงกว่าผู้เข้าชมจริงทุกคนมาก
ทำไมต้องจำกัด: หน้านี้ขนาด 141 KB เป็นของที่ใหญ่ที่สุดที่คนแปลกหน้าขอจาก server นี้ได้ ระบบ render และบีบอัดหน้านี้ครั้งเดียวต่อ room แล้วส่งจากหน่วยความจำ (private, no-cache + ETag ผู้เข้าชมที่กด reload ระหว่างรอนาน ๆ จึงเสียแค่ 304 ไม่ใช่ทั้งหน้า) แต่ client ที่ไม่ยอมรับ Accept-Encoding ก็ยังได้ทั้ง body ทุกครั้ง วัดบน laptop เครื่องเดียว ที่ 120 connection:
| ข้อมูลที่ถูกดึงไปใน 10 s | request อื่นบน process เดียวกัน | |
|---|---|---|
| ก่อน | 2,212 MB | ช้าลง 21 เท่า |
| หลัง | 29 MB | ช้าลง 1.3 เท่า |
เมื่อถูกปฏิเสธ:
- การเปิดหน้าเว็บ ได้หน้าขนาด 941 byte ที่ reload ตัวเองหลัง
Retry-After - XHR ได้ข้อความธรรมดา
- คิวไม่ถูกแตะ ผู้เข้าชมยังมี session และที่ในคิวเหมือนเดิม และ endpoint status ยังตอบ
ถ้าแยกผู้เข้าชมไม่ออก โควตานี้จะหยุดทำงาน ถ้าไม่ได้ตั้ง TRUST_PROXY ผู้เข้าชมทุกคนที่อยู่หลัง proxy จะดูเหมือน client เดียว (address ของ proxy เอง) ถ้าจำกัดต่อ address ตรงนั้น เว็บของลูกค้าจะล่มทั้งเว็บ ซึ่งแย่กว่าการถูกยิงมาก ดังนั้นเมื่อระบบจำ peer ได้ว่าเป็น proxy (ส่ง header ที่ส่งต่อ address จาก address เดียวกัน 10 ครั้งในหนึ่งนาที หรือเท่ากับ WAITING_LIMIT_PER_MIN ถ้าค่านั้นต่ำกว่า เพื่อให้จำได้ก่อนโควตาหมด) โควตานี้จะหยุดทำงาน เฉพาะ address นั้น address อื่นยังถูกจำกัดตามปกติ ระหว่างที่หยุดให้ address ใดอยู่ /api/admin/health จะรายงาน waitingPage.enforced: false และแถว Proxy ใน console จะบอกว่ามี proxy ที่ไม่ได้ประกาศอยู่ด้านหน้า แก้การตั้งค่า proxy แล้ว restart โควตาจะกลับมาใช้กับ address นั้น
header X-Forwarded-For ปลอมครั้งเดียวปิดโควตานี้ไม่ได้ (เดิมทำได้ และปิดให้ทุก address ตลอดอายุของ process) client ที่ส่ง header ปลอมมาต่อเนื่องยังยกเว้น address ของตัวเอง ได้ เพราะถ้าไม่ได้ตั้ง TRUST_PROXY server แยกมันจาก proxy ไม่ออก แต่ยกเว้นให้คนอื่นไม่ได้เลย ถ้าประกาศ proxy ของคุณ (TRUST_PROXY + TRUST_PROXY_IPS) ช่องนี้ก็ปิดด้วย เพราะโควตาจะไม่หยุดทำงานอีก ระบบนับแยกรายผู้เข้าชมได้แล้ว รายการ address ที่ได้รับยกเว้นเก็บได้ไม่เกิน 1024 ตัว เต็มแล้วล้างทิ้ง proxy ตัวจริงจะถูกจำได้ใหม่ภายในไม่กี่ request
Console และ docs ก็เช่นกัน
/, /progress และ /docs* ใช้โควตาแบบเดียวกับหน้ารอ: WAITING_LIMIT_PER_MIN ต่อ address, หน้าปฏิเสธเล็ก ๆ ที่ reload ตัวเองแบบเดียวกัน และหยุดทำงานแบบเดียวกัน เมื่ออยู่หลัง proxy ที่ไม่ได้ประกาศ
ทำไม: หน้า console ใหญ่ประมาณครึ่ง megabyte และ /docs/* render จาก markdown เดิมทั้งสองตอบทุก client ไม่จำกัดจำนวนครั้ง บน host แบบ inline นั่นรวมถึง /__qm/, /__qm/docs และ /__qm/progress บน domain ของลูกค้าเองด้วย
โควตานี้นับ แยก จากของหน้ารอ operator ที่อ่านคู่มือจึงไม่ไปใช้โควตาหน้ารอของผู้เข้าชม และ admin API ไม่ได้รับผล คู่มือแต่ละหน้า render ครั้งเดียวต่อไฟล์หนึ่งเวอร์ชัน หลังจากนั้นส่งจากหน่วยความจำ
สิ่งที่ churn ทำไม่ได้
churn คือการเปลี่ยน address หรือ key ไปเรื่อย ๆ เพื่อหนีการจำกัด
โควตาแต่ละชุดเป็นตารางที่นับแยกตาม address เก็บได้ไม่เกิน RATE_LIMIT_MAX_KEYS (ค่าเริ่มต้น 200,000) เกินแล้วจะลบ address ที่ ไม่ได้ใช้นานที่สุด ออก เดิมตารางล้างตัวเองทั้งหมด ซึ่งทำให้ client ที่ถูกจำกัดทุกตัวได้โควตาใหม่ เพียงมีคนสร้าง key ได้ 200,000 ตัว เช่น คนที่ถือ Public API key แล้วใส่ ?ip= ใหม่ทุกครั้งที่ตรวจ หรือ client ที่เปลี่ยน address IPv6 ไปเรื่อย ๆ client ที่กำลังถูกจำกัดอยู่ คือ key ที่ใช้ล่าสุดในตารางเสมอ การลบจึงไม่มีทางไปถึงมัน
การเรียก /api/check แบบรับรองแทน (vouched) นับโควตาให้ผู้เข้าชมที่ถูกรับรอง (?ip=) ซึ่งตั้งใจไว้แบบนี้ เพราะ connector ตัวเดียวพูดแทนผู้เข้าชมหลายพันคนจาก address ไม่กี่ตัว โควตาเหล่านี้อยู่ในตารางแยก ไม่ว่าคนถือ key จะระบุผู้เข้าชมมากแค่ไหน ก็ลบโควตาของผู้ที่เรียกเข้ามาตรง ๆ ไม่ได้
สอง key สองรัศมีความเสียหาย
ตั้งทั้งสอง key แต่แจกเฉพาะตัวแรก
PUBLIC_API_KEY | ADMIN_KEY | |
|---|---|---|
GET /api/check พร้อม ?ip=&agent= | ได้ | ได้ |
/api/admin/* (rooms, rate, flush, eject, schedule, visitors, metrics, health) | ไม่ได้ (401) | ได้ |
GET /metrics (ข้อมูลสำหรับ Prometheus) | ไม่ได้ (401) | ได้ |
ใส่ key ผิดได้จำนวนจำกัด แต่ละ address ยืนยันตัวตน admin ผิดได้ ADMIN_AUTH_FAIL_PER_MIN ครั้งต่อนาที (ค่าเริ่มต้น 20) เกินแล้ว credential ทุกตัว ที่ address นั้นส่งมา ถูกหรือผิด เป็น Bearer หรือ SSE ticket จะได้ 429 too_many_auth_failures พร้อม Retry-After โดยไม่ตรวจก่อน การถูกล็อกจึงไม่เคยบอกใบ้ว่าเดาถูก
- ไม่นับ: key ที่ถูก, ลิงก์ monitor ที่ใช้ได้, key ที่ถูกแต่ใช้กับ route ที่ไม่มีสิทธิ์ (
403) และ request ที่ไม่ส่ง credential มาเลย - ประตูของ public key (
/api/check?ip=&agent=,/api/verify) ใช้โควตาเดียวกัน เพราะรับADMIN_KEYด้วย key ผิดที่นั่นถูกนับ และ key จาก address ที่ถูกล็อก จะถือว่าไม่ได้ส่งมา (401และ connector จะปล่อยผ่าน) - หลัง CDN: address จะเป็นของผู้เข้าชมที่ส่งต่อมา ก็ต่อเมื่อ
TRUST_PROXY_IPSระบุ CDN ไว้ ไม่อย่างนั้นผู้เข้าชมทุกคนใช้ address ของ socket ของ CDN ร่วมกัน และคนเดาคนเดียวก็ล็อก console ได้หนึ่งนาที นี่เป็นอีกเหตุผลที่ต้องตั้งค่านี้
ห้ามส่ง credential ใน query string admin route ทุกตัวรับแค่ Authorization: Bearer เช่นเดียวกับ Public API key บน /api/check และ /api/verify เดิมรับ ?key= ได้ ตอนนี้ถูกปฏิเสธด้วย 401 {"code":"credential_in_query"} เมื่อเป็นการตรวจแบบรับรองแทน นับใน qm_refused_key_checks_total เหมือน key ที่ถูกปฏิเสธอื่น ๆ และ /api/verify รายงานว่า "Public API key sent in the URL" connector ทุกตัวที่เราแจกส่งแบบ header อยู่แล้ว integration ที่เขียนเองแล้วยังส่ง ?key= จะปล่อยผ่าน (หน้าเว็บไม่ต่อคิว) จนกว่าจะย้ายไปใช้ header หาตัวที่ยังส่งแบบเดิมได้จาก ตัวนับนี้และบรรทัดใน stderr
ข้อยกเว้นเดียวคือ event stream เพราะ EventSource ตั้ง header ไม่ได้: console จะขอ ticket ใช้ครั้งเดียว (POST /api/admin/sse-ticket ผ่าน Bearer สิทธิ์เท่ากับ credential ที่ขอ) แล้วใช้กับ GET /api/admin/events?ticket=… ticket อยู่ได้ 30 วินาทีและถูกทำลายทันทีที่ใช้ครั้งแรก ถ้าหลุดไปอยู่ใน log พอมีคนมาอ่านก็ใช้ไม่ได้แล้ว เครื่องมือของ operator ที่เปิด stream นี้ต้องขอ ticket ใหม่ทุก connection
console เก็บ session cookie ไม่เคยเก็บ key (QM-349) เดิม console เก็บ ADMIN_KEY หรือ operator key ไว้ใน localStorage และในระบบ inline console อยู่ที่ /__qm/ บน origin ของ ลูกค้า script ใดก็ตามที่เว็บนั้นใส่ไว้จึงอ่านได้ ตอนนี้การ sign in ส่ง key ครั้งเดียว ใน body ของ POST /api/admin/session ({"key":"…"} พร้อม header X-QM-CSRF: 1) และคำตอบจะตั้ง cookie qm_console:
- HttpOnly,
SameSite=Strict,Path=/api/admin(หรือ/__qm/api/adminบน host แบบ inline) Secureทุกครั้งที่ request มาทาง TLS- อยู่ได้ 12 ชั่วโมง
- ค่าข้างในเป็นข้อมูลที่ sign แล้ว บอกว่าใคร sign in และ session id ไม่เคยมี key
DELETE /api/admin/session คือ sign out: ล้าง cookie และยกเลิก session นั้นฝั่ง server (cookie ใน browser นั้นถูกล้างไปก่อนแล้ว) การยกเลิกนี้จำไว้ในหน่วยความจำเมื่อใช้ STORE=memory restart แล้วจะลืม และจำไว้ใน Valkey เมื่อใช้ STORE=valkey ถ้า Valkey ทำข้อมูลหายก็จะลืม: instance ที่ได้รับ hint ของการ sign out แล้วยังปฏิเสธ session นั้นจนกว่าจะ restart ส่วน instance ที่เริ่มหลังข้อมูลหายจะไม่ปฏิเสธ ยกเลิก operator แล้ว session ของเขาจะหมดใน request ถัดไป เปลี่ยน ADMIN_KEY แล้ว session ทุกตัวที่เปิดด้วย key เก่าจะหมด ใส่ key ผิดตอน sign in ใช้โควตา ADMIN_AUTH_FAIL_PER_MIN เหมือน Bearer ผิดทุกประการ และ address ที่ถูกล็อกก็ถูกปฏิเสธที่นี่ด้วย 401 จะล้าง qm_console เฉพาะเมื่อ cookie นี้เองคือ credential ที่ใช้ไม่ได้ request ที่มี Authorization: Bearer ถูกตัดสินจาก Bearer อย่างเดียว เปิดลิงก์ monitor ที่หมดอายุในเบราว์เซอร์ของ owner จึงไม่ทำให้ owner ถูก sign out (QM-460)
ยกเลิกสิทธิ์แล้ว stream ที่เปิดค้างอยู่ก็ถูกปิดด้วย (QM-442) stream สถิติสดของ console (/api/admin/events) คือ request เดียวที่เปิดค้างไว้นาน การตรวจสิทธิ์ตอน request เข้ามาเท่านั้นจึงไม่พอ ตอนนี้แต่ละ stream จำไว้ว่าเปิดด้วยอะไร: Bearer (ADMIN_KEY, key ของ operator หรือ monitor link) หรือ cookie ของ console ซึ่งส่งต่อผ่าน SSE ticket เมื่อยกเลิก monitor link, กด Revoke all, ยกเลิก operator หรือ console sign out stream ทุกตัวที่เปิดด้วยสิทธิ์นั้นจะถูกปิดทันที และทุก 2 วินาที server จะตรวจทุก stream ซ้ำอีกรอบ ซึ่งจับกรณีหมดอายุได้ด้วย ก่อนปิด stream จะส่ง event: revoked ({"error":"unauthorized"}) มาเป็นอย่างสุดท้าย แล้ว console จะแสดงหน้า sign in หรือหน้าบอกว่าลิงก์ใช้ไม่ได้แล้ว แทนที่จะพยายามต่อใหม่ SSE ticket ที่ยังไม่ได้ใช้ แต่สิทธิ์ที่ขอ ticket นั้นถูกยกเลิกไปแล้ว จะได้ 401 การเอา SECRET_PREVIOUS ออกต้อง restart และการ restart ก็ปิด stream ทุกตัวอยู่แล้ว
qm_console ไม่เคยผ่าน proxy แบบ inline (QM-427) browser แยก cookie ตาม host ไม่ใช่ตาม port ดังนั้นถ้า console กับ room อยู่บน host เดียวกัน (console ใต้ /__qm/ บนชื่อของลูกค้า หรือทั้งคู่อยู่บน address เดียวกัน) session cookie จะติดไปกับ request ที่ถูก proxy ไปถึงแอป และ Set-Cookie: qm_console=… ของแอปเองจะไปแทนที่ session ของ operator ได้ proxy จึงลบ qm_console ออกจาก header Cookie ของทุก request และทุก WebSocket handshake ที่ส่งต่อ และทิ้งบรรทัด Set-Cookie จากแอปที่ตั้งชื่อนี้ cookie อื่นทุกตัวผ่านไปมาได้ตามเดิม รวมถึง queue cookie ของผู้เข้าชม
**การ แก้ไข ที่ใช้ cookie (ทุก method ยกเว้น GET) ต้องมี header X-QM-CSRF และ browser ที่ส่ง Sec-Fetch-Site ต้องเป็น same-origin ไม่อย่างนั้นได้ 403 {"code":"csrf_required"} เหตุผล: form ส่ง header ที่ตั้งเองไม่ได้ และ script ข้าม origin ก็ส่งไม่ได้ถ้าไม่ผ่าน preflight ซึ่ง server นี้ไม่เคยอนุญาต หน้าเว็บที่อื่นจึงสั่ง session ของ console ไม่ได้ ผู้เรียกแบบ Bearer ทำงานเหมือนเดิม**: request ที่มี header Authorization ถูกตัดสินจาก header นั้นอย่างเดียว ไม่สนใจ cookie และไม่ต้องมี CSRF header นี่คือเหตุที่ลิงก์ #monitor= ที่เปิดใน browser ของ operator ยังอ่านได้อย่างเดียว console รุ่นเก่าที่ยังมี key อยู่ใน localStorage จะย้ายเองตอนโหลดครั้งแรก: เอา key ออกจากที่เก็บ ใช้ sign in หนึ่งครั้ง แล้วทิ้ง
credential ตัวที่สามเล็กกว่ามาก: ลิงก์ Share monitor ของ dashboard มี token สำหรับดูที่จำกัดสิทธิ์ อยู่ ในส่วน fragment ของ URL (#monitor=…) ซึ่ง browser ไม่เคยส่งไปที่ server จึงไม่อยู่ใน access log, log ของ CDN หรือ Referer รวมถึง log ของลูกค้าเองในระบบ inline
ลิงก์นี้ให้สิทธิ์อ่าน ไม่ใช่แค่ "stream สถิติอย่างเดียว":
| ผู้ถือลิงก์อ่านได้ | ตั้งใจ ไม่ ให้เห็น (ตอบ 403 insufficient_role) |
|---|---|
SSE สถิติสด, รายการ room, metrics, แผง security และ reports, GET /metrics, GET /api/admin/alerts (โดยไม่มีข้อความปฏิเสธของ probe ที่ถูกบล็อก, address ของ webhook และ error ของการส่ง ซึ่งแต่ละอย่างอาจบอก address ภายในได้ และปุ่ม Send test alert ถูกปิดใช้งาน) และ GET /api/admin/health (ซึ่งบอก process id, หน่วยความจำ และบอกว่าการตั้งค่า proxy สอดคล้องกันหรือไม่ แต่ไม่แสดงคำเตือนการตั้งค่า, รายการ hardening, address ของ proxy peer, ว่าบังคับใช้ EDGE_SECRET หรือตั้ง PUBLIC_API_KEY ไว้หรือไม่, maxmemory-policy ของ Valkey (ไม่มี backend.maxmemoryPolicy และ incident บอกเพียงว่า policy ไม่ใช่ noeviction) และข้อความ error ของ storage หรือ warehouse ซึ่งอาจบอก path หรือ host: ADMIN_KEY ค่าเริ่มต้น, SECRET ที่สั้นเกินไป หรือการไม่ได้ตั้ง EDGE_SECRET ไม่ใช่สิ่งที่ควรบอกคนที่ได้ลิงก์ต่อมา ส่วน URL qm_return ที่ถูกปฏิเสธแสดงเป็นจำนวนต่อ room เท่านั้น ไม่แสดงตัว URL ไม่ว่าจะใช้ credential ใด) | proxyOrigin ของ room (address ภายในของคุณ), รายการ origin ที่อนุญาตและกฎ URL, รายละเอียดรายผู้เข้าชม และ audit trail ซึ่งบอกชื่อ operator ทุกคนในทีม แต่ละคนเปลี่ยนอะไร และทำจาก IP ไหน |
สี่อย่างทางขวาถูกซ่อนเพราะลิงก์นี้ตั้งใจให้ส่งออกนอกทีมได้ operator key แบบ viewer ที่มีชื่อเห็นได้ทั้งหมด เพราะนั่นคือคนในทีม ลิงก์ที่แชร์แก้อะไรไม่ได้เลย การเขียนทุกแบบ ตอบ 403 insufficient_role และจัดการ operator ไม่ได้
และตัวที่สี่ ซึ่งทีมของคุณควรใช้ทุกวัน คือ operator key แบบมีชื่อ
Operator, role และ audit trail
ออก key ให้แต่ละคนแยกกัน จากแผง Access ของ dashboard หรือผ่าน API เพราะ ADMIN_KEY เป็น secret ที่ใช้ร่วมกันและไม่มีชื่อติด คำถามว่า "ใคร pause room" จึงไม่มีคำตอบ
curl -sX POST https://qm.weekday100.com/api/admin/operators \
-H "Authorization: Bearer $ADMIN_KEY" \
-d '{"name":"Night ops","role":"operator"}'
# → {"operator":{"id":"535dddaa","name":"Night ops","role":"operator",...},
# "key":"qmo_535dddaa_...","shownOnce":true}
key จะแสดง ครั้งเดียวเท่านั้น ระบบเก็บแค่ตัวตรวจแบบ HMAC key ที่หายจึงต้องออกใหม่ กู้คืนไม่ได้
| Role | ทำอะไรได้ |
|---|---|
owner | ทุกอย่างที่ ADMIN_KEY ทำได้ รวมถึงออกและยกเลิก operator key, ลบ room และสร้างหรือเปลี่ยนปลายทางของ room แบบ inline |
operator | ดูแลคิว: rate, state, flush, eject, schedule, ตั้งค่า room (branding, targeting, origins, target URL ของ room แบบ snippet), ล้างคิว |
viewer | อ่านอย่างเดียว: stats, metrics, security, reports, audit |
key ที่ role ไม่พอได้ 403 {"code":"insufficient_role"} ไม่ใช่ 401 ผู้ถือจึงแยกได้ว่า "key ผิด" หรือ "ไม่ใช่หน้าที่คุณ"
การลบ room ต้องใช้ owner (หรือ ADMIN_KEY) เป็นการกระทำเดียวที่ย้อนกลับไม่ได้: ทุกคนที่ยืนต่อคิวอยู่ถูกทิ้ง และ room กับการตั้งค่าและ schedule ของมันหายไป การล้างคิว (POST /api/admin/rooms/<id>/purge) ก็ทำลายข้อมูลเหมือนกัน แต่ room ยังอยู่ จึงให้ operator ทำได้ console จะซ่อนปุ่ม Delete ไปเลยสำหรับ key ที่ไม่มีสิทธิ์ แทนที่จะให้กดยืนยันแล้วค่อยเจอ 403
การติดตั้งแบบ inline ต้องใช้ owner (หรือ ADMIN_KEY) เหตุผล: targetUrl ของ room แบบ inline คือ host สาธารณะที่ server นี้ตอบแทน และ proxyOrigin คือ address ที่ traffic ของ host นั้นถูกส่งต่อไป สองค่านี้รวมกันจึงตัดสินว่า server นี้ดักรับ traffic ของใคร และส่งไปที่ไหน
operatorทำสิ่งเหล่านี้ไม่ได้: สร้าง room แบบ inline, เปลี่ยนtargetUrlหรือproxyOriginของ room แบบ inline, ล้างproxyOriginของมัน หรือใส่proxyOriginให้ room แบบ snippet (ซึ่งทำให้กลายเป็น inline) จะได้403 {"code":"inline_requires_owner"}และการปฏิเสธถูกบันทึกใน audit (room.create/room.update,ok: false) ถ้าเป็นการแก้ room ที่มีอยู่ ข้อความจะบอกว่าการตั้งค่าอื่นของ room ยังแก้ได้ ถ้าเป็นการสร้างใหม่ (ยังไม่มี room) ข้อความจะบอกว่ายังสร้างเป็น room แบบ snippet (ไม่มีproxyOrigin) ได้ (QM-458)- นับเฉพาะการ เปลี่ยน เท่านั้น: การบันทึกที่ส่ง
targetUrlและproxyOriginปัจจุบันของ room กลับมาโดยไม่เปลี่ยน (การบันทึกทั้ง body แบบที่ script หรือ console ทำ) ผ่านได้ และการตั้งค่าอื่นทุกอย่างของ room แบบ inline ยังเป็นของoperator - การเปลี่ยนที่ผ่านแต่ละครั้งถูกบันทึกใน audit เป็น
room.inlineพร้อมค่าเก่าและค่าใหม่ ของแต่ละ field - console ปิดสวิตช์ inline และช่อง internal address สำหรับ key แบบ
operatorและปิดช่อง target URL ด้วยถ้าเป็น room แบบ inline พร้อมบอกเหตุผลไว้ใต้สวิตช์
# revoke immediately; the key stops working on the next request
curl -sX DELETE "https://qm.weekday100.com/api/admin/operators/535dddaa" \
-H "Authorization: Bearer $ADMIN_KEY"
operator ที่ถูกยกเลิกจะถูก ปิดใช้ ไม่ใช่ลบ audit ย้อนหลังของเขาจึงยังแสดงชื่อได้ ?purge=1 คือทางออกสำหรับ operator ที่เพิ่มผิด
ยกเลิก operator แล้ว ลิงก์ monitor ทุกลิงก์ที่เขาแชร์ไว้จะถูกปิดด้วย ในการเขียนครั้งเดียวกัน จึงอยู่รอดหลัง restart เหมือนการยกเลิกลิงก์ทีละลิงก์ เหตุผล: ลิงก์ที่อยู่ต่อหลังเจ้าของถูกยกเลิก จะเป็น credential ตัวเดียวที่การยกเลิกหลุดไป และเป็นตัวที่ถูกส่งต่อไปกว้างที่สุด ลิงก์ที่ operator คนอื่นและ ADMIN_KEY แชร์ไว้ยังเปิดอยู่
- response มี
linksRevoked - รายการ audit
operator.revoke(หรือoperator.purge) รายการเดียวบอกจำนวน (Night ops; closed 2 monitor links) - หน้ายืนยันใน console บอกว่าจะปิดกี่ลิงก์ก่อนคุณกด
GET /api/admin/operatorsรายงานliveLinksของ operator แต่ละคน
ระบบจับคู่ลิงก์กับผู้สร้างด้วย operator id ซึ่งบันทึกไว้ตอนสร้างลิงก์ (createdById บนตัวลิงก์) ไม่เคยใช้ชื่อ เพราะชื่อเป็นข้อความอิสระ operator สองคนอาจชื่อเดียวกัน และ operator ยังตั้งชื่อว่า ADMIN_KEY ได้ด้วยซ้ำ ลิงก์ที่สร้างก่อน release นี้มีแค่ชื่อ จึงระบุผู้สร้างไม่ได้ การยกเลิก operator จึงไม่ปิดลิงก์เหล่านั้น ถ้าลิงก์แบบนั้นอาจอยู่ในมือคนที่ไม่ควรมี ให้ยกเลิกจากรายการลิงก์ monitor หรือใช้ Revoke all ลิงก์เหล่านี้ก็หมดอายุเองหลัง MONITOR_TOKEN_TTL_SEC (ค่าเริ่มต้นเจ็ดวัน) หลังจากนั้นลิงก์ที่ยังใช้ได้ทุกลิงก์จะระบุผู้สร้างได้
ทุกการกระทำของ admin ที่เปลี่ยนสถานะจะถูกเขียนต่อท้าย audit trail (ใน Postgres เมื่อ SIDESTORE=pg) พร้อมผู้ทำ สิ่งที่ถูกทำ เวลา และ address ของผู้เรียก รวมถึงสิ่งที่ server ทำเอง ซึ่งแสดงเป็นผู้ทำชื่อ system (autotune เปลี่ยน rate, threshold activation เปิดปิด) การแก้ room บันทึกแค่ ชื่อ field ไม่บันทึกค่า เพราะค่าอ่านได้จากตัว room และบรรทัด audit อยู่นานกว่าการตั้งค่าที่มันพูดถึง
การ restart ก็อยู่ใน audit ด้วย server.start ถูกเขียนเมื่อ process เริ่มรับ connection และ server.stop เมื่อปิดอย่างเรียบร้อย บรรทัด start บอกว่ารอบ ก่อนหน้า ปิดเรียบร้อยไหม ซึ่งเป็นข้อมูลเดียวที่ย้อนมาหาทีหลังไม่ได้ และบน Windows การปิดทุกครั้งคือการ kill เป็นปกติ (ดู การหยุดเซิร์ฟเวอร์) GET /api/admin/health มี startedAt (เวลาจริง) คู่กับ uptimeSec, /metrics มี qm_process_start_time_seconds (ตั้ง alert เมื่อค่าเปลี่ยน) และ console ที่เปิดอยู่จะแสดง toast ทันทีที่ค่านี้เปลี่ยน
curl -s "https://qm.weekday100.com/api/admin/audit?roomId=checkout&format=csv" \
-H "Authorization: Bearer $ADMIN_KEY"
ไม่มี SSO ไม่มี SAML, OIDC, SCIM หรือ MFA /api/admin/health รายงาน access.sso: false ฝ่ายจัดซื้อที่ตรวจระบบจะได้คำตอบตรง ๆ ไม่ต้องมาเจอเองทีหลัง
การป้องกันบอท
คนที่ขอที่ในคิวจะถูกให้คะแนน 0–100 จากสิ่งที่ server นี้เห็นได้จริง แต่ละสัญญาณบวกหรือลบคะแนน เช่น:
- client ที่ประกาศตัวว่าเป็นโปรแกรมอัตโนมัติ
- ไม่มี header ที่ browser จริงต้องส่ง
X-Forwarded-Forที่ปลอมมา- มาถึงเป็นจังหวะสม่ำเสมอแบบเครื่องจักร
- address เดียวถือ ticket กระจายไปทั่วคิวแบบที่คนจริงทำไม่ได้
เลือกโหมด:
PROTECTION_MODE=monitor(ค่าเริ่มต้น) ให้คะแนนและนับ ไม่ปฏิเสธใคร เริ่มที่โหมดนี้ก่อน แผง Security ของ dashboard แสดงยอดรวม สัญญาณไหนกำลังทำงาน และการตัดสินใจล่าสุดPROTECTION_MODE=enforceไม่ให้ที่ในคิวเมื่อคะแนนเกินPROTECTION_BLOCK_ATตอบ403 {"code":"blocked_by_protection"}พร้อม headerX-QM-Protection: block score=NN- ตั้งราย room ได้ด้วย
protection: {"mode":"enforce"}ซึ่งใช้แทนค่าเริ่มต้นของ server ส่วนnull= ใช้ค่าของ server
สองเรื่องที่ควรรู้ก่อน enforce:
- enforce ปฏิเสธแค่ ticket ไม่เคยห้ามเข้าเว็บของคุณ
/api/checkยังปล่อยผ่านสำหรับทุกคน ถ้าตัดสินผิด ผลเสียคือผู้เข้าชมที่ดูเหมือนบอทเสียที่ในคิว ไม่ใช่คุณเสียลูกค้า - address ที่ใช้ร่วมกันไม่ทำให้ตัวเองโดนบล็อก คะแนนถูกปรับไว้ให้สัญญาณทุกแบบ ที่ NAT ของบริษัทหรือกลุ่ม CGNAT สร้างขึ้นได้เอง รวมกันแล้วไม่ถึงเกณฑ์บล็อก มีแค่ client ที่ ประกาศ ตัวเองว่าเป็นโปรแกรมอัตโนมัติเท่านั้นที่ถึงเกณฑ์ได้โดยไม่ต้องมี สัญญาณอื่นช่วย ผลที่ตามมาตรง ๆ คือ botnet บน address บ้านที่ใช้ browser จริง จะไม่ถูกจับด้วยวิธีนี้ ความยุติธรรมต่อกรณีนั้นมาจากโครงสร้างของระบบ (มาก่อนได้ก่อน (FIFO), pass ที่ sign แล้ว, pre-queue หนึ่งที่ต่อหนึ่งตัวตน) ไม่ใช่จากการจำแนก
crawler ที่ประกาศตัว (Googlebot, bingbot, Pingdom, UptimeRobot, Prometheus และที่เหลือในรายการใน lib/sentinel.js) ถูกรายงานแต่ไม่ถูกให้คะแนน ยกเว้น User-Agent เดียวกันนั้นมีชื่อ HTTP library หรือ browser ที่ควบคุมด้วย script อยู่ด้วย python-requests/2.31 googlebot จะถูกให้คะแนนแบบ python-requests การต่อชื่อ crawler ท้ายข้อความที่บอกไปแล้วว่าตัวเองคืออะไร ไม่ได้ช่วยอะไร UA ที่เขียนแค่ Googlebot ระบบยังเชื่อตามนั้น ไม่ได้ตรวจกับ reverse DNS (ต้องค้นเครือข่ายทุกครั้งที่เจอ address ใหม่) และยังเป็นเรื่องที่ต้องตัดสินใจด้าน product
queue cookie นับเฉพาะตัวจริง การ "ส่ง queue cookie มา" คือสิ่งที่ทำให้ browser ที่กลับมาไม่โดนสัญญาณ cookieless_repeat ตอนนี้หมายถึง token qm_<room> ที่ server นี้ sign เอง สำหรับ room นั้น และยังไม่หมดอายุ ค่าอื่นถือว่าไม่มี cookie
ใส่ระบบ monitor อัตโนมัติของคุณใน PROTECTION_ALLOW_IPS เพื่อไม่ให้ถูกให้คะแนนเลย ระบบจับคู่กับ address ของผู้เข้าชมที่หาได้ ซึ่งจะเป็น address ที่ส่งต่อมาก็ต่อเมื่อ connection มาจาก TRUST_PROXY_IPS การใส่ address ที่อยู่ใน allowlist ลงใน X-Forwarded-For จากที่อื่นไม่มีผลอะไร
จำนวนที่ต่อแอดเดรสในแถวที่ใช้งานอยู่ (queueMaxPerIp)
ตั้งเพดานว่า address เดียวถือที่ในคิวได้กี่ที่พร้อมกัน:
| Field | ค่าเริ่มต้น | ความหมาย |
|---|---|---|
queueMaxPerIp | 16 | จำนวนที่ในคิวที่ address ของ client หนึ่งตัวถือได้ 1–100000; null = ไม่มีเพดาน |
ทำไมต้องมี: presenceSec (ดู Passes vs sessions) กันไม่ให้ client ที่ไม่เคยถามสถานะ (poll) ทำให้ room ค้าง แต่กัน client ที่ ถาม สถานะ ไม่ให้ กักตุน ไม่ได้ เช่น ถือ ticket หกสิบใบกระจายไปทั่วคิว แล้วได้ pass ทุกครั้งที่แต่ละใบถึงคิว
เมื่อเกินเพดาน:
POST /api/joinตอบ429 {"code":"queue_identity_limit"}พร้อมRetry-After: 30และไม่ให้อะไรกลับไป ไม่มี token ไม่มี cookie- ผู้เข้าชมที่ส่ง ticket ที่ถืออยู่แล้วมา ไม่ถูกนับ
- ที่จะว่างทันทีที่ ticket ใบใดของ address นั้นถึงหัวคิว (หรือถูก eject)
- นับตาม address เดียวกับ
preQueueMaxPerIpซึ่งเป็นสิ่งเดียวที่ผู้เรียกสร้างใหม่ทุก request ไม่ได้ และยังอยู่หลัง restart (คำนวณจากเจ้าของ ticket ที่บันทึกไว้)
ค่าเริ่มต้นคือ 16 เท่ากับของ pre-queue ในคิวที่คึกคัก อาจมีคนจริงมากกว่า 16 คน อยู่หลัง address CGNAT ของค่ายมือถือเดียวกันในเวลาเดียวกัน และเพดานนี้จะปฏิเสธพวกเขา ไม่ใช่ผู้โจมตี เพิ่มค่านี้ใน room ที่ผู้ชมอยู่หลัง NAT ของค่ายมือถือ (carrier-grade NAT) หรือตั้ง null ถ้าไม่ต้องการเพดาน (load test ที่สร้างที่หลายพันที่จาก address เดียว ต้องตั้งแบบนี้) room ที่บันทึกไว้ก่อนค่าเริ่มต้นเปลี่ยนและไม่ได้เก็บค่าไว้ จะได้ 16 ส่วน room ที่เก็บ null ไว้ ยังไม่มีเพดานเหมือนเดิม:
curl -X POST http://localhost:8080/api/v1/admin/rooms \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"id":"drop","queueMaxPerIp":64}'
การเปิดใช้งานตาม threshold (spike ตอนตีสาม)
room เปิดคิวเองได้ ตั้ง autoActivate ที่ room หรือใช้ช่อง Threshold activation ในหน้าแก้ room:
{"enabled": true, "joinsPerMin": 120, "sustainSec": 60, "releaseAfterSec": 300}
room จะเปลี่ยน bypass → active เมื่อจำนวนคนที่มาถึงอยู่ที่ joinsPerMin หรือมากกว่าต่อเนื่องนาน sustainSec และกลับเป็น bypass เมื่อเงียบไปนาน releaseAfterSec ถ้าตั้ง releaseAfterSec: 0 คิวจะไม่ปิดเองเลย
- เปลี่ยนได้แค่
bypass⇄activeroom ที่ pause อยู่คือสิ่งที่ operator ตัดสินใจ ระบบไม่เคยไปแก้ - ไม่ปิดเองตราบที่ยังมีคนอยู่ในคิว
- ทั้งตอนเปิดและปิด ระบบจดไว้บน timeline ของ room และเขียนลง audit trail ในชื่อผู้ทำ
systemพร้อมตัวเลขที่ทำให้เปลี่ยน ระหว่างที่ตั้งไว้พร้อมทำงาน การ์ด room มี badgeAUTO ≥n/MINและภายใน 15 นาทีหลังระบบเปลี่ยนเอง badge จะเป็นสีเหลืองอำพันและแสดงAUTO · SELFจึงไม่มีใครเข้าใจผิดว่าคิวที่เครื่องเปิด เป็นคิวที่คนเปิด - เมื่อ
SIDESTORE=pgบันทึกบน timeline อยู่รอดหลัง restart: เก็บใน Postgres (24 h บันทึกราวหนึ่งวินาทีหลังการเปลี่ยนแต่ละครั้ง และตอนปิดระบบเรียบร้อย) ตอนทบทวนเหตุการณ์ย้อนหลัง เครื่องหมายจึงยังอยู่บนกราฟ - ตัวกระตุ้นมีอย่างเดียวคือจำนวนคนที่มาถึงต่อนาที ไม่มีเกณฑ์ตามจำนวนคนที่อยู่ในเว็บพร้อมกัน (concurrency) เพราะตอนคิวปิดอยู่ ไม่มีอะไรนับ session เกณฑ์นั้นจึงไม่มีวันทำงาน กลายเป็นปุ่มที่ดูเหมือนพร้อมแต่ไม่ได้พร้อมจริง ตัวเลขที่ใช้เทียบคือ joins/min บนการ์ด room ซึ่งเป็นตัวเลขเดียวกับที่ระบบอัตโนมัติอ่าน
เรื่องนี้ต่างจาก AUTOTUNE ซึ่งปรับ rate การปล่อยคนเข้าของ room ที่มีคิวอยู่แล้ว
รายงานย้อนหลัง
/api/admin/reports ให้ข้อมูลหนึ่งแถวต่อ room ต่อชั่วโมง (หรือต่อวัน) เก็บไว้ตาม HISTORY_RETAIN_DAYS:
curl -s "https://qm.weekday100.com/api/admin/reports?bucket=day&from=2026-08-01&format=csv" \
-H "Authorization: Bearer $ADMIN_KEY" -o last-month.csv
คอลัมน์: roomId, bucket, start, startIso, joins, passes, netUnserved, peakDepth, avgDepth, peakActive, waitP50Sec, waitP90Sec, waitMaxSec, waitAvgSec, measuredWaits, blocked, warned, samples, partial, coveredSec, missingSec สามคอลัมน์ที่บอกความครบของข้อมูล (coverage) ถูกต่อไว้ท้ายสุด โปรแกรม import ที่อ่านตามตำแหน่งคอลัมน์จึงยังใช้ได้
| คอลัมน์ | นับอะไร |
|---|---|
joins | ticket ที่ออกให้ |
passes | ticket ที่ ได้เลื่อนเป็น pass คือได้สิทธิ์เข้า ไม่ใช่เข้าไปแล้ว |
netUnserved | joins - passes; เป็น null ในแถวที่ข้อมูลไม่ครบ |
peakDepth / avgDepth | จำนวนคนในคิว จากตัววัดความยาวคิว |
peakActive | session บนเว็บที่ถูกป้องกัน คือคอลัมน์ "คนที่ได้เข้าไปจริง" |
waitP50Sec / waitP90Sec | percentile ของเวลารอ แบบประมาณ |
waitMaxSec / waitAvgSec | เวลารอนานสุดและเฉลี่ย แบบค่าจริง |
measuredWaits | จำนวนครั้งที่ได้เลื่อนเป็น pass ที่มีเวลา join→pass ที่วัดได้ |
blocked / warned | การตัดสินใจของระบบป้องกันบอท เมื่อมีหลาย instance คือของทั้ง fleet (ตัวนับที่แต่ละ instance แชร์) |
samples | จำนวนรอบเก็บตัวอย่างที่รวมอยู่ในแถว |
ทุก JSON response แนบคำอธิบายตรง ๆ สี่ข้อไว้ ขอย้ำไว้ตรงนี้:
waitP50Sec/waitP90Secเป็นค่าประมาณ แต่ละชั่วโมงเก็บตัวอย่างเวลารอ ได้ไม่เกิน 256 ค่า สุ่มแบบกระจายเท่ากัน ส่วนmeasuredWaits,waitAvgSecและwaitMaxSecเป็นค่าจริงmeasuredWaitsไม่ใช่ "คนที่เข้าเว็บ" เดิมชื่อadmittedซึ่งชวนให้อ่านผิด มันคือตัวหารของwaitAvgSecและน้ำหนักของ percentile คือจำนวนคนที่ รอเสร็จ ในช่วงนั้น เช่น room ที่ตั้งเพดานคนในเว็บพร้อมกันไว้ 5 และออก pass ไปสิบใบ จะรายงานmeasuredWaits: 10เพราะสิบคนรอคิวเสร็จ แต่ห้าคนถูกปฏิเสธที่ประตู และไม่ได้เข้า คอลัมน์ที่นับ session บนเว็บคือpeakActiveทำไมสองกลุ่มนี้ต่างกัน ดู The claim window ใน Pass กับ sessionnetUnservedคือ joins ลบ passes ไม่ใช่จำนวนคนที่เห็นว่าเลิกรอจริง ในชั่วโมงเดียว ค่านี้ยังนับทุกคนที่ยังรออยู่ตอนขึ้นชั่วโมงใหม่ด้วย ใช้ประมาณจำนวนคนที่เลิกรอได้เฉพาะเมื่อดูช่วงหนึ่งวันขึ้นไปpartial: trueแปลว่าแถวนั้นมีช่วงที่ขาดหาย server ไม่ได้ทำงานบางส่วนของช่วงนั้นjoins,passes,measuredWaits,blockedและwarnedจึงเป็น ค่าต่ำสุดที่เป็นไปได้ ไม่ใช่ยอดรวมcoveredSecและmissingSecบอกว่านับได้เท่าไรและขาดไปเท่าไร และnetUnservedเป็นnullเพราะคำนวณจากข้อมูลที่ไม่ครบไม่ได้ ถ้ารายงานเป็น0คู่กับคิวสูงสุด 45,000 คน การวิเคราะห์หลังเหตุการณ์จะสรุปผิด console ใส่⚠ที่แถวเหล่านั้น และเติม+ท้ายตัวเลข
ชั่วโมงที่ยังไม่จบถูกรวมเข้ามาแบบสด รายงานที่ดึงตอน 10:59 จึงไม่ขาด 59 นาทีล่าสุด เมื่อใช้ STORE=valkey รายงานที่ Postgres ตอบไม่ได้จะเป็น 503 storage_unavailable (ให้ลองใหม่) ข้อมูลนี้ถูกเขียนลง warehouse ตอนปิดระบบเรียบร้อย ดู การหยุดเซิร์ฟเวอร์ ด้านล่าง เพราะบน Windows วิธีหยุด process ที่ใคร ๆ ก็ใช้ ไม่ใช่การปิดแบบเรียบร้อย
ไม่มีรายงานตามเวลาที่ตั้งไว้ ไม่มีรายงานทางอีเมล และไม่มีการส่งข้อมูลไป data warehouse ไฟล์ CSV คือช่องทางเชื่อมต่อกับระบบอื่น
การปฏิเสธการเริ่มทำงาน: เมื่อเซิร์ฟเวอร์ไม่ยอมสตาร์ต
การตั้งค่าผิดส่วนใหญ่เป็นแค่คำเตือน แต่ 5 กรณีนี้ server จะไม่ยอมเริ่ม เพราะเริ่มไปแย่กว่าหยุด: process จะขึ้นมาดูปกติดี แต่ตอบคำถามผิด หรือทำสิ่งที่เพิ่งรับไว้หายไปตอน restart ครั้งถัดไป แต่ละกรณีจบด้วย exit code ที่ไม่ใช่ 0 และพิมพ์บอกค่าที่ตั้งและวิธีแก้
| ไม่ยอมเริ่มเมื่อ | เพราะ | ต้องทำอะไร |
|---|---|---|
NODE_ENV=production แต่ไม่ได้ตั้งทั้ง STORE=valkey และ SIDESTORE=pg | memory mode ไม่เก็บอะไรข้ามการ restart: ทุกครั้งที่ deploy คิว room และ operator จะหายหมด และถ้ามีหลาย instance แต่ละตัวจะมีคิวของตัวเอง ข้อความจะบอกชื่อทุกค่าที่ขาด และไม่มีทางข้าม | ตั้ง STORE=valkey และ SIDESTORE=pg พร้อมค่าที่ต้องใช้ (SECRET, VALKEY_URL, DATABASE_URL) |
SIDESTORE=file | side store แบบไฟล์ถูกเอาออกใน step 5: ระบบไม่เขียนอะไรลง disk ในเครื่องอีกแล้ว | ใช้ SIDESTORE=pg หรือ SIDESTORE=memory (ค่าเริ่มต้นเมื่อ STORE=memory) ตอนพัฒนา |
STORE=valkey แต่ไม่มี SIDESTORE=pg, SECRET หรือ VALKEY_URL | คิวถูกบันทึกลง journal ใน Postgres ทุก instance ต้อง sign และตรวจด้วย key เดียวกัน และต้องมี Valkey ให้เชื่อมต่อ ข้อความจะบอกชื่อทุกค่าที่ขาด | ตั้งค่าที่ข้อความบอก |
SIDESTORE=pg แต่ไม่มี SECRET หรือ DATABASE_URL | ถ้าไม่มี SECRET key จะใหม่ทุกครั้งที่เริ่ม และ key ใหม่ทุกครั้งที่ deploy ทำให้ pass ทุกใบ (ticket ของผู้เข้าชม, ลิงก์ monitor, operator key) ที่ Postgres เก็บไว้ใช้ไม่ได้ | ตั้ง SECRET (openssl rand -hex 32) และ DATABASE_URL |
ADMIN_KEY เป็นค่าเริ่มต้น admin-dev และตั้ง HOST เป็นอะไรก็ได้ที่ไม่ใช่ loopback (0.0.0.0, ::, address จริง) | key เริ่มต้นเขียนไว้ในเอกสารนี้ ถ้ารับ connection นอก loopback ด้วย key นี้ ส่วนที่ลบ room และไล่ผู้เข้าชมได้จะเปิดอยู่บนเครือข่ายโดยมีรหัสผ่านที่ใครก็รู้ ถ้า ไม่ได้ตั้ง HOST ระบบไม่ปฏิเสธ แต่รับเฉพาะ 127.0.0.1 และบอกไว้ node server.js บน laptop จึงใช้ได้เหมือนเดิม | ตั้ง ADMIN_KEY เป็นค่าลับ (openssl rand -hex 32) ถ้าใช้บนเครื่องตัวเองเท่านั้น ตั้ง HOST=127.0.0.1 |
ค่าบางตัวไม่ยอมเริ่มด้วยตัวเอง และแถวของค่านั้นในตาราง environment บอกไว้: ค่า STORE หรือ SIDESTORE ที่ไม่รู้จัก, ไม่มีไฟล์ PG_CA_FILE, QM_INSTANCE_ID ที่รูปแบบผิด นอกจากนั้น เช่น พิมพ์ตัวแปรตัวเลขผิด, SECRET สั้นเกินไป, เกณฑ์เรียงลำดับผิด ระบบยังเริ่มได้ และแสดงไว้ใน warnings ของ /api/admin/health ค่าที่ใช้ตามที่เขียนไม่ได้ ระบบไม่เคยแอบใช้ค่าอื่นแทนเงียบ ๆ คำเตือนจะบอกชื่อตัวแปร ค่าที่คุณตั้ง และค่าที่ใช้อยู่จริง
ถอย release กลับข้ามการเปลี่ยนวิธี sign token
- release ที่เก่ากว่าการแยก key ตามประเภทงาน อ่านได้แค่ token แบบเดิม
- ตราบที่ไม่ได้ตั้ง
TOKEN_SIGN_V2(ค่าเริ่มต้น) release นี้ไม่เขียนแบบอื่น การถอยกลับจึงปลอดภัย - หลังตั้ง
TOKEN_SIGN_V2=1ถอยกลับแล้วระบบยังเริ่มได้ แต่ token แบบ v2 ทุกใบถูกปฏิเสธ: ผู้เข้าชมต้องไปต่อท้ายคิวใหม่ ลิงก์ที่แชร์และ session ของ console หมดอายุ และ operator ทุกคนที่ key ถูกออกหรือเปลี่ยนภายใต้ v2 จะได้401(ออก key ใหม่ให้) - ปิด
TOKEN_SIGN_V2อีกครั้ง จะหยุดออก token v2 ใหม่ และเมื่ออายุ token หมดครบแล้วก็จะไม่เหลือเลย แต่การปิดไม่แปลง operator verifier แบบ v2 กลับ operator เหล่านั้นจึงยังต้องได้ key ใหม่
ดู Signing key และการ rotate SECRET
Signing key และการ rotate SECRET
SECRET คือต้นทางของลายเซ็นทุกอย่างที่ server สร้าง token มีรูปแบบ base64url(JSON payload) "." base64url(HMAC-SHA256(key, body)) และมีสองแบบ ระบบรับทั้งสองแบบเสมอ TOKEN_SIGN_V2 แค่เลือกว่า server นี้จะ เขียน แบบไหน
- แบบเดิม (legacy) (ค่าเริ่มต้น เป็นแบบที่ release ก่อน ๆ ทุกตัวเขียน): key คือ
SECRETตรง ๆ และ payload ไม่มีkid - v2 (
TOKEN_SIGN_V2=1): งานแต่ละประเภทมี key ขนาด 32 byte ของตัวเองK(purpose) = HKDF-SHA256(ikm = SECRET, salt = empty, info = "qm:<purpose>", 32)
ประเภทงานคือ visitor (ticket ของคิวและ handle ของ pre-queue), monitor (ลิงก์ที่แชร์), session (cookie qm_console ของ console) และ operator (ตัวตรวจของ operator key ที่เก็บไว้) payload มี kid คือเลขฐานสิบหกตัวเล็กของ HKDF-SHA256(ikm = SECRET, salt = empty, info = "qm:kid", 4) แปดตัวอักษร ที่ใช้เรียกชื่อ SECRET โดยไม่เปิดเผยค่า kid บอกว่าจะตรวจ token ด้วย key ไหน ถ้า server ไม่มี kid นั้นจะปฏิเสธ สอง instance ใช้ลายเซ็นเดียวกันก็ต่อเมื่อ ticket แบบ v2 ของทั้งคู่แสดง kid เดียวกัน
การแยก key ตามประเภทงานป้องกันอะไรได้ และไม่ได้ token v2 ที่ออกให้งานหนึ่ง จะไม่มีวันตรวจผ่านเป็นงานอื่น token แบบเดิมไม่มีประเภทงานติดมา จึงผ่านได้ทุกประเภท ที่ payload ตรวจผ่าน ซึ่งเหมือนก่อนมีการแยก key ทุกอย่าง และการตรวจ payload นั้น (scope, room, field ของ ticket) คือสิ่งที่กันไม่ให้ ticket ของผู้เข้าชมเปิด console ได้ ตราบที่ TOKEN_SIGN_V2 ปิดอยู่ token ทุกใบที่ server นี้ออกเป็นแบบเดิม การแยกจึงยังไม่มีผล จะเริ่มมีผลกับ token ที่ออกหลังตั้ง TOKEN_SIGN_V2=1 และมีผลเต็มที่เมื่อ token แบบเดิมใบสุดท้ายหมดอายุ
ทำเป็นสอง release
- ปล่อย release นี้โดยไม่ตั้ง
TOKEN_SIGN_V2มันรับทั้งสองแบบแต่เขียนแค่แบบเดิม จึงถอยกลับได้อิสระ - เมื่อทุก instance ใช้ release นี้อย่างมั่นคงแล้ว ตั้ง
TOKEN_SIGN_V2=1ทุกที่ token แบบเดิมที่ออกไปแล้วใช้ได้จนหมดอายุ และ operator verifier แบบเดิม จะถูกเปลี่ยนเป็น v2 ตอนที่ operator แต่ละคนยืนยันตัวครั้งถัดไป
ถ้าถอยกลับหลังจากนั้นจะเกิดอะไร ดู ถอย release กลับข้ามการเปลี่ยนวิธี sign token ด้านบน
การ rotate (เปลี่ยน SECRET) token มีอายุสูงสุด TOKEN_MAX_AGE_SEC (24 h) สำหรับผู้เข้าชม, MONITOR_TOKEN_TTL_SEC (7 วัน) สำหรับลิงก์ที่แชร์ และ 12 h สำหรับ session ของ console ให้ rotate ขณะตั้ง TOKEN_SIGN_V2=1:
- ตั้ง
SECRET_PREVIOUSเป็นค่าปัจจุบัน และSECRETเป็นค่าใหม่ ทุก instance แล้ว restart token ใหม่จะมี kid ใหม่ token ที่ใช้ kid เก่ายังตรวจผ่าน ไม่มีใครในคิวเสียที่ - รอให้พ้นอายุที่ยาวที่สุดที่คุณสนใจ: 24 h เก็บ ticket ของผู้เข้าชมได้ครบ (ตัวที่สำคัญ) 7 วันเก็บลิงก์ที่แชร์ได้ครบด้วย (ถ้าไม่รอ console คัดลอกลิงก์ใหม่ ภายใต้ key ใหม่ได้) operator key ไม่มีวันหมดอายุ operator ที่ยืนยันตัวในช่วงนี้ จะถูกเปลี่ยนไปใช้ SECRET ใหม่ใน request นั้น operator key ที่ไม่ถูกใช้ในช่วงนี้ จะใช้ไม่ได้เมื่อลบ
SECRET_PREVIOUSให้ออก key ใหม่ให้ operator คนนั้น - ลบ
SECRET_PREVIOUSแล้ว restart ตอนนี้ token ที่ใช้ kid เก่าจะถูกปฏิเสธ
ถ้า TOKEN_SIGN_V2 ปิดอยู่ ขั้นตอนเดียวกันยังใช้ได้ แค่ไม่มี kid token แบบเดิมไม่ได้บอกว่าใช้ SECRET ตัวไหน ระบบจึงลองตรวจกับ SECRET ก่อน แล้วค่อยลอง SECRET_PREVIOUS ไม่มีอะไรใน ticket ที่บอกคุณหรือ log ว่า SECRET ตัวไหน sign และไม่มีอะไรปฏิเสธ key ที่ไม่รู้จักโดยระบุชื่อได้ token แบบนั้นแค่ตรวจไม่ผ่านทั้งสองครั้ง ผู้เข้าชม ลิงก์ที่แชร์ และ session ของ console ยังผ่านการ rotate ได้เหมือนกัน operator verifier ถูกย้ายไปใช้ SECRET ใหม่ ในแบบเดิมที่มันเป็นอยู่ (verifier แบบ v2 ไม่เคยถูกลดเป็นแบบเดิม) ควร rotate ขณะตั้ง TOKEN_SIGN_V2=1 มากกว่า เพราะ kid ทำให้ตรวจได้ว่าทุก instance ใช้ค่าเดียวกัน (เทียบ kid ใน ticket) และรู้ว่า token เก่าหมดไปเมื่อไร
rotate โดยไม่ตั้ง SECRET_PREVIOUS ticket ทุกใบจะใช้ไม่ได้ทันที: ทุกคนในคิว ต้องไปต่อท้ายใหม่ (server เขียน log เตือนเมื่อมี ticket ที่ถูกปฏิเสธเข้ามาพร้อมกันจำนวนมาก) ลิงก์ที่แชร์และ session ของ console ทุกตัวหมด และ operator key ทุกตัวใช้ไม่ได้ กรณีเดียวที่ต้องการผลแบบนี้คือ SECRET รั่ว
หลาย instance (STORE=valkey)
เมื่อใช้ STORE=valkey และ SIDESTORE=pg server รันเป็นหลาย instance ที่เหมือนกันหลัง load balancer ตัวเดียว โดยไม่มี sticky session: ผู้เข้าชมหรือ operator อาจไปถึง instance คนละตัวในทุก request และได้คำตอบเหมือนที่ instance เดียวให้ ทุก instance ต้องใช้ SECRET, VALKEY_URL, VALKEY_PREFIX และ DATABASE_URL ชุดเดียวกัน ไม่มี instance ไหนเก็บอะไรไว้บนดิสก์ของตัวเอง การออกแบบอยู่ใน docs/adr/0001-live-state-in-valkey.md ใน repository
เมื่อใช้ STORE=memory (สำหรับ development เท่านั้น) หลาย instance ไม่ได้ใช้อะไรร่วมกันนอกจากค่าที่คุณให้ SECRET ที่ใช้ร่วมกันทำให้ ลายเซ็น ของ ticket ใช้ได้ทุกที่ แต่ไม่ได้ทำให้ใช้คิวร่วมกัน แต่ละ instance มี room, หมายเลข ticket และ rate ของตัวเอง และที่ในคิวมีอยู่แค่บน instance ที่ออกให้ ถ้ากระจาย room เดียวไปหลาย instance จะได้หลายคิวที่แยกกัน และแต่ละคิวปล่อยคนเข้าด้วย rate เต็ม
- ใช้ร่วมกัน คิว (room, ticket, ลำดับ, การรับเข้า) อยู่ใน Valkey และบันทึกลง journal ใน Postgres ทุก instance รัน tick ทุกวินาที และแต่ละ tick ให้เครดิต room ตามเวลาจริงนับจาก tick ล่าสุดของ room ที่ใช้ร่วมกัน (จองช่วงเวลาแบบ atomic) N instance จึงให้เครดิตเท่ากับ ticker ตัวเดียวที่ tick ในทุกจังหวะ ของทุก instance รวมกันพอดี อัตรารับเข้าระยะยาวและเครดิตสะสมสูงสุด 1 ที่ยังเท่าเดิม operator, ลิงก์ monitor และโน้ตบนไทม์ไลน์ เขียนลง Postgres ทีละแถว และทุก instance อ่านใหม่เมื่อ instance อื่นเปลี่ยน: มีสัญญาณผ่าน Valkey pub/sub (
<prefix>ctl) และถ้าสัญญาณหายจะซ่อมภายในRESYNC_MSการตรวจเดียวกันนี้ rebuild config ของ room ที่ใช้กั้น request (sentinel protection, index ของ inline host,onBackendDown) การ logout ของ console, ticket ของ stream ใน console, งบADMIN_AUTH_FAIL_PER_MIN, สุขภาพของปลายทาง, สถานะ alert และที่อยู่อีเมลสำหรับแจ้งเมื่อถึงคิว อยู่ใน Valkey key ของ operator, session ของ console หรือลิงก์ monitor ที่ instance นี้ยืนยันสำเนาไม่ได้จะถูกปฏิเสธด้วย503(ดูRESYNC_MS) ไม่ปล่อยผ่านด้วยสำเนาเก่า ทุก console อ่าน room จาก Valkey ทุก tick 2 วินาที การเปลี่ยนที่ทำผ่าน instance หนึ่งจึงขึ้นบน console ของทุก instance ภายในหนึ่ง tick - leader หนึ่งตัว การ probe ปลายทาง, autotune, การเปิดใช้งานตาม threshold, การส่ง alert และแถวประวัติรายชั่วโมง ทำเฉพาะบน instance ที่ถือ leader lease จึงเกิดครั้งเดียวต่อทั้ง fleet (ดู
LEADER_LEASE_MS)leaderใน/healthzบอกว่า instance นี้ถืออยู่หรือไม่ เมื่อ leader ตาย instance อื่นจะรับต่อภายในราวLEADER_LEASE_MSการหยุดแบบเรียบร้อยคืน lease ทันที ขณะติดต่อ Valkey ไม่ได้ไม่มี instance ใดเป็น leader งานเหล่านั้นหยุดรอ และแต่ละ instance ส่ง alertvalkey_unreachableของตัวเอง (ดูVALKEY_ALERT_AFTER_MS) - ต่อ instance งบต่อ address (
JOIN_LIMIT_PER_MIN,CHECK_LIMIT_PER_MIN,WAITING_LIMIT_PER_MIN,NOTIFY_LIMIT_PER_MINและตัวอื่น) และเพดาน stream (SSE_PER_IP,SSE_MAX_TOTAL) เป็นของแต่ละ instance: N instance ยอมได้ถึง N เท่าของแต่ละค่า การจำกัด rate ที่ edge ของ Cloudflare คือตัวควบคุมระดับ fleet/metricsรายงาน process ของ instance นี้เอง พร้อม labelinstance(QM_INSTANCE_IDเป็นแค่ชื่อแสดง) ให้ scrape ทุก instance (ดู Metrics และ monitoring) ตัวนับใน console คือผลรวมของ instance ที่ยังทำงานอยู่ - Deploy การ deploy แทนที่ทุก instance และการหยุดแต่ละครั้งปิดทุก stream บน instance นั้น ไม่มีอะไรในคิวหาย (อยู่ใน Valkey) หน้ารอเริ่ม poll
/api/statusทุก 5 วินาทีทันที และเปิด stream ใหม่: browser ลองเชื่อมต่อเอง และเมื่อ browser เลิกลอง หน้าจะลองใหม่หลัง 2 วินาที แล้วนานขึ้นจนถึง 20 วินาที console เชื่อมต่อใหม่และได้ frame เต็ม ระหว่าง rolling deploy ทุก instance ต้องส่งได้ทุก hash ของไฟล์หน้ารอที่ยังแจกอยู่ (ดู ไฟล์ของหน้ารอและ stream ที่เปิดค้างไว้ระหว่าง deploy) - หยุดทีละตัว
POST /api/admin/shutdownหยุดเฉพาะ instance ที่ตอบ ซึ่งหลัง load balancer คือตัวที่ request ไปถึง (ดู การหยุดเซิร์ฟเวอร์)
Readiness (/healthz)
ชี้ readiness ของ k8s, target group ของ ALB และ uptime monitor ไปที่ /healthz ตั้ง alert ตาม status และปลุกคนเวร (page) ตาม ready
คำตอบของ /healthz มีสองค่า ซึ่งตอบคำถามคนละข้อ:
| ค่า | เป็นได้ | ตอบคำถามว่า |
|---|---|---|
status | ok, degraded หรือ shutting_down | "มีอะไรผิดปกติที่ operator ควรดูไหม" origin ปลายทางที่เลิกตอบ หรือนาฬิกาที่กระโดด จะทำให้เป็น degraded และข้อความเดียวกันจะขึ้นใน incidents ของ /api/admin/health และบน badge ของ console |
ready | true หรือ false | "load balancer ควรส่งผู้เข้าชมมาที่ instance นี้ไหม" HTTP code ตามค่านี้: 200 เมื่อ ready, 503 เมื่อไม่ |
ready เป็น false ด้วยเหตุผลสามข้อเท่านั้น:
- ที่เก็บข้อมูลมีปัญหา (storage degraded) (บน
STORE=valkeyเฉพาะของ instance นี้เอง ดูด้านล่าง): instance บันทึก join ไม่ได้ จึงตอบ503 storage_unavailableกับผู้เข้าชมทุกคนที่ขอ ticket อยู่แล้ว ต้องไม่ให้มันรับส่วนแบ่ง traffic ต่อ บนSTORE=valkeyการ commit events journal ลง Postgres ที่ล้มเหลวทำให้statusเป็นdegraded(มีstorage is degraded: events journal commit failed: …ในincidents) และเตือนstorage_failingแต่readyยังเป็น true: ทุก instance ใช้ Postgres ตัวเดียวกัน การถอดออกจาก rotation จะทำให้ทั้ง fleet หลุดจาก load balancer โดยไม่ได้ย้าย join ไปที่ใดที่บันทึกได้ join แต่ละครั้งยังรอ commit ของตัวเองและตอบ503 storage_unavailableเมื่อ commit ล้มเหลว จึงไม่มี ticket ที่ถูกยืนยันโดยไม่อยู่ใน Postgres storage กลับเป็นปกติเมื่อมี commit ที่สำเร็จหลังจากนั้น และผ่านไป 3 ×ALERT_EVERY_MSนับจากความล้มเหลวครั้งล่าสุด ความผิดพลาดที่เกิด ๆ หาย ๆ จึงยังนับว่าล้มเหลว แทนที่จะอ่านเป็นปกติระหว่างแต่ละครั้ง ส่วนstorage.writeErrorsนับสะสมต่อไปstorage_failingเตือนเมื่อ alert frame สองรอบ ภายในช่วงนั้นเห็นความล้มเหลวใหม่: commit ที่ล้มเหลวครั้งเดียว (หรือ join ที่ถูกปฏิเสธครั้งเดียว ซึ่งคือ commit ของมันและของการ rollback) แสดงเป็น degraded แต่ไม่เตือน ความผิดพลาดที่เกิด ๆ หาย ๆ หรือความล้มเหลวที่ probe ของ frame ถัดไปยังเจออยู่ จะเตือน เมื่อไม่มี traffic มายืนยัน alert แต่ละรอบจะ probe journal ขณะที่ล้มเหลว (insert แบบเดียวกับ commit หนึ่งแถว ใน transaction ที่ rollback) ความผิดพลาดชั่วครู่ในช่วงเงียบจึงกลับเป็นปกติได้เอง ข้อยกเว้นคือความผิดพลาดของ instance เดียว: เมื่อ journal ของ instance นี้ล้มเหลว ขณะที่ instance อื่นรายงาน storage ปกติภายใน 3 ×ALERT_EVERY_MSที่ผ่านมา การส่งผู้เข้าชมไปที่อื่นทำให้ join ของเขาถูกบันทึกได้จริงreadyจึงเป็น false (storage is degraded on this instance only) และ load balancer ถอดเฉพาะ instance นี้ออก readiness ตามเฉพาะความล้มเหลวแบบ hard ไม่ใช่ช่วงเวลาข้างบน: จะถูกถอดเมื่อ journal ไม่มี commit หรือ probe ใดสำเร็จนับจากความล้มเหลวครั้งล่าสุด และ alert frame สองรอบติดกัน ต่างเห็นความล้มเหลวใหม่ (commit หรือ probe ของ frame) และกลับมาเมื่อ commit หรือ probe แรกสำเร็จ (probe ยืนยันได้แม้ไม่มี traffic) statement timeout ครั้งเดียวที่ commit ถัดไปสำเร็จ จะไม่ทำให้ถูกถอด instance ที่ commit ของตัวเองกำลังล้มเหลวไม่นับว่าปกติ เมื่อไม่มี instance อื่นที่ปกติ (Postgres ที่ใช้ร่วมกัน, fleet ที่มี instance เดียว, ติดต่อ Valkey ไม่ได้) จะยังอยู่ใน rotation shutting_down: ตั้งทันทีที่เริ่มหยุด ก่อน snapshot สุดท้าย และก่อนปิด connection ใด ๆ load balancer จึงเลิกส่งงานใหม่มา ขณะที่ request ที่กำลังทำอยู่ทำต่อจนเสร็จ- ถึงเพดาน connection: ถึง
MAX_CONNECTIONSแล้ว request ใหม่ได้503ที่นี่แทนที่จะถูก proxy อยู่แล้ว (ดูCONNECTION_RESERVEในตารางตัวแปร และตาราง surge ด้านบน) ถ้า load balancer ยังส่งส่วนแบ่ง traffic มาที่ instance นี้ ก็ตรงข้ามกับสิ่งที่ readiness probe มีไว้ทำ สังเกตว่าตอนนี้statusยังเป็นok(การเต็มไม่ถือเป็น incident) คำแนะนำ "alert ตามstatus, page ตามready" จึงไม่ใช่การพูดเรื่องเดียวกันสองแบบ นี่คือกรณีที่readyจับได้ตัวเดียว
origin ของลูกค้าติดต่อไม่ได้ ไม่ ทำให้ ready เป็น false (ตั้งใจ) และนาฬิกากระโดดก็เช่นกัน ทั้งสองถูกรายงานเป็น incident แต่ไม่ใช่เหตุผลที่จะถอด pod ออก ถ้าผูก readiness กับเรื่องเหล่านี้ origin ล่มตัวเดียวจะกลายเป็นห้องรอล่มทั้งหมด: ทุก instance ไม่ผ่าน probe พร้อมกัน และทั้งชุดหลุดออกจาก load balancer ส่วนที่มีหน้าที่ ยืนหน้าเว็บที่ล่มอยู่ กลับหายไปเสียเอง การ restart pod ไม่ได้ซ่อม origin ของคนอื่นหรือนาฬิกาของเครื่องนี้
เมื่อใช้ STORE=valkey ข้อมูลจะมี backend ด้วย: {store, valkey, recoveringRooms, ok} valkey เป็น up หรือ down (การเชื่อมต่อของ process นี้ และเป็น down อีก 5 วินาที หลัง command timeout บน socket ที่ยังต่ออยู่) recoveringRooms คือจำนวน room ที่กำลังถูกสร้างใหม่จาก journal ใน Postgres (ถูก mark ที่นี่ หรือ Valkey ตอบว่าถูก fence ภายใน 30 วินาทีที่ผ่านมา) /healthz เป็น endpoint สาธารณะ จึงบอกแค่จำนวน ส่วน /api/admin/health บอกชื่อ room (และมี instance คือชื่อของ instance ที่ตอบ กับ fleet: {instances, partial} ดู Metrics และ monitoring ส่วน /healthz สาธารณะไม่มีทั้งสองอย่าง) อย่างใดอย่างหนึ่งทำให้ status เป็น degraded และเพิ่ม incident ใน /api/admin/health แต่ไม่ทำให้ ready เป็น false เพราะทุก instance ใช้ Valkey ตัวเดียวกัน การถอด instance นี้ออกไม่ได้แก้อะไร ผู้เข้าชมได้อะไรในระหว่างนั้น ดู onBackendDown
leader (true หรือ false) บอกว่า instance นี้ถือ lease ของ leader และรันงานของทั้งชุดอยู่หรือไม่ ได้แก่ การ probe ปลายทาง, autotune, การเปิดใช้งานตาม threshold และการส่ง alert (ดู LEADER_LEASE_MS) ในชุดที่ใช้ STORE=valkey จะมี instance เดียวที่ตอบ true และไม่มีเลยขณะติดต่อ Valkey ไม่ได้ ซึ่งทำให้งานเหล่านั้นหยุด ค่านี้ไม่เปลี่ยน ready หรือ HTTP code เมื่อใช้ STORE=memory จะเป็น true เสมอ
/api/version คืนข้อมูลชุดเดียวกัน แต่ตอบ 200 เสมอ เพราะตอบคำถามเรื่อง build ไม่ใช่ readiness script ที่ใช้ rollout ซึ่งอ่านค่านี้จึงไม่สะดุดเพราะ instance ที่ degraded
ระบบแบบ inline: address ที่ probe ใช้ เป็นตัวตัดสินว่าได้คำตอบอะไร
- probe ที่เรียก process ตรง ๆ (IP ของ pod, port ของ container,
localhost) ขอ host ที่ไม่มี room ไหนอยู่หน้า/healthzจึงเป็น route ของ queue และตอบตามปกติ - probe ที่เรียก ชื่อ host สาธารณะ server นี้ถือว่าเป็นผู้เข้าชมคนหนึ่ง และ
/healthzตรงนั้นเป็น path ของลูกค้า วัดบน host แบบ inline แล้วGET /healthzตอบ503พร้อม{"code":"queued"}ตลอดไป เพราะ monitor ไม่มีวันได้เข้า ถ้าผูกกับ target group ของ ALB มันจะถอด instance ที่ปกติดีออกหมดทุกตัว
ใต้ prefix ที่จองไว้ มันเป็น route ของ queue อีกครั้ง บนชื่อ host สาธารณะให้ probe /__qm/healthz:
| probe เรียกไปที่ | path ที่ใช้ |
|---|---|
process (IP ของ pod, port ของ container, localhost) | /healthz |
| ชื่อ host สาธารณะของ room แบบ inline | /__qm/healthz |
| ชื่อ host ของ queue เอง (ระบบแบบ snippet) | /healthz |
การแยกแบบเดียวกันใช้กับ /api/version และ /metrics ด้านล่าง
HEAD ใช้ได้ทุกที่ที่ GET ใช้ได้ และตอบ status และ Content-Length เดียวกันโดยไม่มี body การตรวจที่ใช้ HEAD เป็นค่าเริ่มต้น (มีหลายตัว) หรือ curl -I กับไฟล์ดาวน์โหลด connector จึงได้คำตอบจริง ไม่ใช่ 404 เป็นคำตอบเดียวกัน จึงตัดสินแบบเดียวกัน: HEAD /healthz บนชื่อ host สาธารณะแบบ inline ยังเป็น 503 ด้วยเหตุผลข้างบน
เมื่อตัว queue เองล่ม
ready เป็น false ทำให้ instance ตัวเดียว ออกจากการรับ traffic ซึ่งเป็นเรื่องปกติ และไม่มีใครเสียอะไร หัวข้อนี้พูดถึงอีกกรณี: ทุก instance ไม่พร้อมพร้อมกัน
ถ้า queue ติดตั้งแบบ inline (Users → Cloudflare → Queue → your app) "ทุก instance ไม่พร้อม" แปลว่าเว็บไม่มีทางไปถึงแอป ต้องมีคนตัดสินว่าผู้เข้าชมจะเห็นอะไร และการตัดสินใจนี้ queue ทำตอนมี request เข้ามาไม่ได้ ต้องเป็นกฎ routing ที่คุณเขียนไว้ล่วงหน้า
สิ่งที่เราเลือก: แสดงหน้า static ไม่ปล่อยผ่าน (fail open) การปล่อยผ่านจะส่ง traffic ทั้งหมดไปที่ origin ที่ไม่มีอะไรกั้น ซึ่งคือเหตุล่มแบบที่ซื้อ queue มากันไว้ และมาถึงในนาทีที่แย่ที่สุด แคมเปญที่เข้าไม่ได้ไม่กี่นาทียังกู้ได้ origin ที่พังไปแล้วกู้ไม่ได้ ถ้าแคมเปญของคุณมีค่ามากกว่าความเสี่ยงนั้น ก็ชี้ failover pool ไปที่แอปแทน และยอมรับผลที่ตามมา ตัดสินใจตอนนี้ ไม่ใช่ตอน 21:04
หน้านั้นคือ public/offline.html server ที่รันอยู่แจกไฟล์นี้ที่ /offline.html และต้องเอาไปวางในที่ที่ Cloudflare แสดงได้ โดยไม่ต้องมี server นี้
หน้านี้สร้างมาเพื่องานนี้โดยเฉพาะ:
- เป็นไฟล์เดียวจบในตัว ไม่มี
<link>,<script src>, รูป หรือ webfont เพราะไฟล์ประกอบทุกไฟล์คือ request ไปที่ origin ที่เพิ่งเลิกตอบ และหน้าขอโทษที่ขึ้นมาครึ่ง ๆ ดูเหมือนบั๊กตัวที่สอง - มีห้าภาษาเหมือนหน้ารอ และอ่าน key
qm_langตัวเดียวกัน ผู้เข้าชมที่เลือกภาษาไทยไว้ ก็ยังได้ภาษาไทย - ลองใหม่เองโดย เว้นระยะแบบสุ่ม (jittered backoff) (20 s → 60 s บวกสุ่มเพิ่มได้ถึง 80 %) เพราะทุกคนมาถึงหน้านี้ในวินาทีเดียวกัน ถ้าลองใหม่ทุกช่วงเวลาเท่ากัน ทุกคนจะกลับมาพร้อมกัน เป็นคลื่นเดียว หนักที่สุดตรงตอนที่ queue กำลังพยายามกลับขึ้นมา
หน้านี้ ไม่ แสดงลำดับที่ เวลาที่คาดว่าจะถึง หรือแถบความคืบหน้า เพราะส่วนที่ถือที่ในคิว คือส่วนที่ล่ม ตัวเลขใดที่แสดงตรงนั้นก็คือตัวเลขที่แต่งขึ้น
ติดตั้ง: failover Worker (ไม่ต้องสมัคร Load Balancing) server นี้สร้างและแจก Worker ให้ โดยฝัง offline.html ของตัวเองไว้ข้างในแล้ว หน้าที่ deploy จึงไม่มีทางต่างจากหน้าที่มากับ release:
curl -fsSL http://localhost:8080/connectors/cloudflare-failover/latest.js -o qm-failover.js
npx wrangler deploy qm-failover.js --name qm-failover \
--compatibility-date 2025-01-01 --route 'YOUR-QUEUE-HOSTNAME/*'
หรือเอาทั้งโปรเจกต์ wrangler (wrangler.toml, ตัวตรวจก่อน deploy, README) จาก /connectors/cloudflare-failover/latest.tgz ไปเก็บใน repo ของคุณ npm run deploy จะรันตัวตรวจก่อน ไม่ต้องตั้ง room id หรือ API key เพราะ Worker นี้ไม่เคยเรียก API ของ queue
ผูก route กับชื่อ host ที่ queue ตอบ (แบบ inline ก็คือชื่อ host สาธารณะของคุณ) Worker นี้ไม่ใช่ edge gate connector (/connectors/cloudflare) ซึ่งตัดสินว่าใครต้องรอ และอยู่หน้า origin ของคุณในระบบแบบ snippet Cloudflare รัน Worker ได้ตัวเดียวต่อ route ให้ทั้งสองตัวใช้ pattern ต่างกัน
Worker ทำอะไรเมื่อเจอคำตอบแบบไหน และข้อยกเว้นสองข้อที่ทำให้ปลอดภัย:
| queue ตอบว่า | Worker ทำ |
|---|---|
| ไม่ตอบเลย: ถูกปฏิเสธ, DNS, TLS, timeout | หน้า offline, 503 |
521–527, 530 (Cloudflare ใช้ origin ไม่ได้) | หน้า offline, 503 |
503 ที่มี X-QM-Queued | ส่งผ่าน |
503 ที่ไม่มี header นั้น | หน้า offline, 503 |
502 / 504 | ส่งผ่าน |
| อย่างอื่น | ส่งผ่าน |
503 อย่างเดียวไม่พอที่จะตัดสิน gate ที่ปกติดีตอบ 503 ทุกครั้งที่ผู้เข้าชมที่ยังรอ ถามสถานะ และติด X-QM-Queued ไว้ ถ้าเอาหน้าเว็บล่มไปแทน คิวที่ทำงานอยู่จะหายไป และตัวนับถอยหลังสดถูกแทนด้วย "ระบบล่ม"
502/504 ไม่ใช่เรื่องของกฎนี้ สองตัวนี้แปลว่า queue ปกติ แต่ แอป ของคุณล่ม และ server นี้ตอบเองอยู่แล้ว ดู เมื่อเว็บไซต์หลัง queue ล่ม ด้านล่าง ถ้าแสดง offline.html ตรงนั้น จะบอกผู้เข้าชมว่าเข้าไม่ได้ ทั้งที่จริงเขาผ่านคิวแล้ว และตัวที่พังคือเว็บ
deploy แล้วต้องพิสูจน์: หยุด queue แล้วโหลดหน้าเว็บ response จะมี X-QM-Failover: unreachable (หรือ timeout หรือ http-<status>) curl -I จึงแยกได้ว่า failover ทำงานจริง หรือแค่บังเอิญ ส่วนนี้เป็นส่วนเดียวของ product ที่ไม่เคยถูกใช้ตอนระบบปกติ ไม่มีอะไรบอกคุณว่ามันพัง จนถึงคืนที่มันสำคัญ
หรือใช้ Load Balancer ถ้ามี: primary pool = instance ของ queue, health check GET /healthz (มีไว้ทำสิ่งนี้), failover pool = ที่ที่คุณวาง offline.html เมื่อใช้ STORE=memory (สำหรับ development เท่านั้น) instance ไม่ได้ใช้คิวร่วมกัน pool จึงต้องผูกแต่ละ room ไว้กับ instance เดียว instance ที่สองเป็นที่รองรับตอน failover ไม่ใช่กำลังเพิ่มของ room เดียวกัน เมื่อใช้ STORE=valkey instance ใช้คิวร่วมกัน และ primary pool คือทุก instance (ดู หลาย instance (STORE=valkey))
ไม่ว่าแบบไหน ต้องมีห้าข้อนี้ (Worker ข้างบนทำครบ ส่วน failover pool ของ Load Balancer ต้องตั้งเอง):
503ไม่ใช่200เพราะ200บอก crawler และ uptime monitor ว่าหน้าที่ได้คือตัวเว็บRetry-Afterให้ client ที่ดีถอยเองCache-Control: no-storeไม่อย่างนั้น Cloudflare จะแสดงหน้าขอโทษต่อ แม้ queue กลับมาปกติแล้ว- หน้านี้มี
noindexด้วย เป็นการกันซ้ำอีกชั้น - จำกัดกฎให้ใช้กับ request ที่เป็นหน้าเว็บ
/favicon.icoที่ได้ 404 ไม่เป็นไร แต่ตอบมันด้วยหน้า HTML ไม่ได้
Edge gate จะ fail open และบอกให้รู้
กฎข้างบนใช้กับ queue แบบ inline ส่วน edge gate connector (/connectors/cloudflare) อยู่หน้า origin ของคุณ และถาม queue ทุก request เมื่อ queue ช้าหรือพัง มันจะ ปล่อยผ่าน (fail open) คือผู้เข้าชมไปที่ origin โดยไม่ต่อคิว เพราะ queue ที่ล่มต้องไม่ทำให้เว็บที่มันป้องกันล่มตาม ข้อนี้ยังเหมือนเดิม ที่เปลี่ยน (connector 1.1.0, QM-340) คือมันไม่เงียบแล้ว:
- ทุก response ที่ปล่อยผ่านมี
X-QM-Failover: <reason>โดย reason เป็นtimeout(ไม่ได้คำตอบภายใน 2 s),unreachable(ต่อ/DNS/TLS ไม่สำเร็จ),http-<status>(คำตอบที่ไม่ใช่ 2xx ยกเว้น 429 เช่นhttp-503หรือhttp-401เมื่อ key ถูกปฏิเสธ) หรือinvalid-response(ได้200ที่ไม่ใช่ทั้งpassและqueueที่ใช้ได้) ใช้ header และชื่อเดียวกับ failover Worker แต่ตรงนี้แปลว่า ส่งต่อโดยไม่ต่อคิว ไม่ใช่ หน้า offline คำตอบที่ queue ตอบมาจริง (pass, redirect ไปหน้ารอ) ไม่ถูกติด header นี้ Worker ที่ไม่ได้ตั้งค่าหรือไม่มี key จะส่งต่อโดยไม่ติด header แต่เขียนไว้ใน log แทน เพราะนั่นคือ deploy ผิด ไม่ใช่ queue พัง 429ไม่ใช่การปล่อยผ่าน (connector 1.2.0, QM-379)CHECK_LIMIT_PER_MINที่หมดโควตาแปลว่า queue ยังอยู่และกำลังลดภาระ Worker จึงส่งผู้เข้าชมไปหน้ารอ (/w/<room>) แทน origin: ไม่มีX-QM-Failoverไม่นับใน Analytics Engine และเขียน log หนึ่งบรรทัดต่อ isolate Node connector (1.1.0), WordPress plugin (1.2.0) และ browser tag (1.4.0) ทำแบบเดียวกัน ฝั่ง server จะเห็นเป็นqm_rate_limited_checks_totalถ้าเพิ่มขึ้นต่อเนื่อง แปลว่ามีผู้เข้าชมที่ room อาจมีที่ให้กำลังรออยู่ ให้เพิ่มCHECK_LIMIT_PER_MIN- แต่ละครั้งถูกนับใน Workers Analytics Engine เมื่อผูก dataset ไว้ในชื่อ
QM_ANALYTICSถ้าไม่ผูก จะไม่มีการนับ และไม่มีอะไรพัง# wrangler.toml of the edge Worker [[analytics_engine_datasets]] binding = "QM_ANALYTICS" dataset = "qm_failover"
ข้อมูลแต่ละจุดคือ blob1 = room id, blob2 = reason, double1 = 1, index1 = room id
ตั้ง alert queue server นับการตรวจที่ไม่เคยมาถึงตัวเองไม่ได้ alert จึงต้องอ่านตัวเลข จาก edge ไม่ใช่จาก server รันคำสั่งนี้กับ Analytics Engine SQL API จากระบบที่ปลุกคุณอยู่แล้ว (cron หรือ HTTP check ของระบบ monitor) ทุกไม่กี่นาที ด้วย API token ที่มีสิทธิ์ Account Analytics: Read:
curl -s "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/analytics_engine/sql" \
-H "Authorization: Bearer $CF_API_TOKEN" --data "
SELECT blob1 AS room, blob2 AS reason, SUM(_sample_interval) AS fail_opens
FROM qm_failover
WHERE timestamp > NOW() - INTERVAL '5' MINUTE
GROUP BY room, reason
ORDER BY fail_opens DESC
FORMAT JSON"
แจ้งเตือนเมื่อมีแถวใดกลับมา เพราะทุกแถวคือผู้เข้าชมที่ไปถึง origin โดยไม่ได้ต่อคิว ใช้ SUM(_sample_interval) ไม่ใช่ COUNT() เพราะ Analytics Engine เก็บแบบสุ่มตัวอย่าง เมื่อปริมาณสูง และบันทึกอัตราที่เก็บไว้ timeout/unreachable/http-5xx แปลว่า queue ล่มหรือรับไม่ไหว ดู /healthz ด้านบน ถ้าจะเช็คครั้งเดียวโดยไม่ใช้ dataset: curl -sI https://shop.example.com/checkout | grep -i x-qm-failover
ทำไมไม่ให้ Worker รายงานตัวเลขกลับไปที่ queue ตอนกลับมาปกติ:
- หน่วยความจำของ Worker แยกตาม isolate ซึ่งถูกลบทิ้งได้ตลอด และมีหลายตัวต่อ colo ตัวเลขที่ส่งย้อนหลังจึงหายระหว่างทาง ตรงกับตอนที่ล่มนาน ๆ พอดี
- รายงาน
401ไม่ได้เลย (key คือตัวที่พัง) - ต้องเพิ่ม endpoint สำหรับเขียนข้อมูลบน server
Analytics Engine ถูกเขียนตอนที่พังเลย แยกจาก queue และรวมจากทุก colo
เมื่อ backend ล่ม (onBackendDown)
เมื่อใช้ STORE=valkey สถานะสดของ room อยู่ใน Valkey เมื่อติดต่อ Valkey ไม่ได้ (การเชื่อมต่อหลุด command timeout หรือ failover) หรือ room กำลังถูกสร้างใหม่จาก journal ใน Postgres หลัง Valkey ทำ state หายหรือย้อนกลับ API จะตอบ 503 storage_unavailable พร้อม Retry-After: /api/check /api/join /api/status และอื่น ๆ ทั้งแบบ snippet และ inline room ที่กำลังกู้คืนจะไม่ตอบ no_room เด็ดขาด
การเปลี่ยนแปลงของ operator ช่วงที่ room ถูกสร้างใหม่ การสร้างใหม่จะ replay สิ่งที่ journal ใน Postgres มีอยู่ตอนที่อ่าน การเปลี่ยนแปลงที่ทำงานใน Valkey แล้วแต่ไปถึง journal หลังการอ่านนั้น จะไม่อยู่ใน room ที่สร้างใหม่ การสร้างหรือแก้ไข room การเปลี่ยนกำหนดเวลา (opensAt) หรือการลบ room จะรอให้ journal บันทึกก่อน การเปลี่ยนแปลงแบบนี้จึงตอบ 503 storage_unavailable ทั้งที่ทำงานไปแล้ว เมื่อการสร้างใหม่ไม่ได้รวมมันไว้ (หรือ room ถูก fence อยู่ตอนที่บันทึก) ให้ลองใหม่เมื่อ room ตอบได้อีกครั้ง แล้วมันจะมีผลกับ room ที่สร้างใหม่ ส่วนการหยุด การเริ่มต่อ หรือการเปลี่ยน rate ของ room (โดย operator autotune หรือการเปิดใช้งานตาม threshold) บันทึกลง journal เบื้องหลังและตอบ 200 ทันที จึงอาจถูก การสร้างใหม่ย้อนกลับโดยไม่มี error ถ้าทำในไม่กี่วินาทีก่อน Valkey ทำ room หาย หลังการกู้คืน ให้ตรวจ สถานะและ rate ของ room ใน console แล้วตั้งใหม่ถ้าไม่ตรงกับที่ตั้งไว้
Valkey ต้องตั้ง maxmemory-policy noeviction (จำเป็น) ถ้าใช้ policy ที่ evict ได้ Valkey อาจทิ้งข้อมูลบางส่วนของ room (การรับเข้า ชุด passed ความเป็นเจ้าของ ticket) ขณะที่ hash หลักของ room ยังอยู่ ไม่มีอะไรตรวจพบ state ที่ขาดไปแบบนี้ และผู้เข้าชมที่การรับเข้าถูก evict จะถูกส่งกลับเข้าคิวหรือถูกปล่อยผ่านผิด ๆ เมื่อใช้ noeviction Valkey ที่เต็มจะปฏิเสธการเขียนแทน ซึ่ง API ตอบเป็น 503 ตอน boot แต่ละ process อ่าน policy จาก INFO memory (CONFIG ถูกปิดบน DO managed Valkey) และ log คำเตือนดัง ๆ ถ้าไม่ใช่ noeviction และรายงานเป็น backend.maxmemoryPolicy และ incident ใน /api/admin/health (share link เห็น incident โดยไม่บอกชื่อ policy และไม่เห็น maxmemoryPolicy)
room แบบ inline ยังเลือกได้ด้วยว่าผู้เข้าชมเว็บของตัวเองจะได้อะไร ผ่านค่า onBackendDown ของ room:
| ค่า | Browser navigation | API / XHR / fetch |
|---|---|---|
closed (default) | หน้ารอของ room ใน retry mode: 503 + Retry-After: 5 ไม่มีการรับคิว มีข้อความ "ไม่พร้อมใช้งานชั่วคราว" และหน้าจะโหลดใหม่เอง gate จะตัดสินใหม่ตอนโหลดใหม่ | JSON 503 storage_unavailable, Retry-After: 5 |
open | ส่งต่อไปที่ proxyOrigin เหมือน room อยู่ใน bypass: ไม่ตรวจคิว ไม่ตั้ง cookie | ส่งต่อแบบเดียวกัน |
open ปล่อย traffic ทั้งหมดเข้า origin ตลอดเวลาที่ backend ล่ม เลือกใช้เฉพาะเว็บที่ยอมช้า ดีกว่าปิด
ระหว่างที่ Valkey ล่ม gate อ่าน room จาก Valkey ไม่ได้ จึงใช้ config ล่าสุดที่ process นี้เห็น สำหรับ host นั้น (inline host index ซึ่ง refresh ทุกครั้งที่ room เปลี่ยน) host ที่ process นี้ ไม่เคยเห็นว่าเป็น inline จะไม่ถูก gate เลย (ถูก route แบบ request ที่ไม่ใช่ inline) process ที่ boot ขึ้นมาตอนติดต่อ Valkey ไม่ได้ยังเริ่มทำงานได้ (log valkey: unreachable at boot และพยายามต่อใหม่เรื่อย ๆ /healthz รายงาน valkey: "down") และสร้าง inline host index จาก config ของ room ใน Postgres (ตาราง rooms ไม่รวม room ที่ลบแล้ว) พร้อม log inline host index built from Postgres ดังนั้น onBackendDown มีผลกับทุก inline room ทันที: host ของ room แบบ closed ตอบหน้า retry ไม่ใช่ 404 และไม่ใช่ origin ระหว่างนั้น inline.indexFrom ใน /api/admin/health เป็น postgres และเมื่อ Valkey ตอบแล้ว การตรวจ rooms จะแทนที่ index ด้วยสำเนา จาก Valkey (valkey) ถ้าอ่าน Postgres ไม่ได้ด้วย process จะ log Valkey and Postgres are both unreachable และไม่ gate อะไร (inline host ตอบ 404) จนกว่าตัวใดตัวหนึ่งจะตอบ โดยจะลองอ่าน Postgres ใหม่ทุก 1 วินาที เพิ่มเป็นสองเท่าจนถึง 30 วินาที WebSocket upgrade บน inline host ก็ทำตามค่าเดียวกันระหว่างที่ backend ล่ม: closed ตอบ 503 พร้อม Retry-After: 5 ส่วน open ส่ง handshake ต่อไปที่ origin error ตอนเชื่อมต่อที่ทำให้เริ่มไม่ได้มีเพียงสามแบบ แต่ละแบบบอกชื่อโดยไม่พิมพ์ URL: Valkey ปฏิเสธ credential (WRONGPASS, NOAUTH, NOPERM, error ของ ACL, password ไม่ถูกต้อง) ชื่อ host ที่ resolve ไม่ได้ (ENOTFOUND) และ TLS certificate ที่ตรวจไม่ผ่าน error ตอนเชื่อมต่อแบบอื่นทั้งหมดจะ boot แบบ degraded และพยายามต่อใหม่เรื่อย ๆ: connection ถูกปฏิเสธ หมดเวลา หรือถูก reset, host หรือ network ล่ม, Valkey ที่กำลังโหลดข้อมูล ขาดการเชื่อมกับ master หรือขอให้ลองใหม่ (LOADING, MASTERDOWN, TRYAGAIN) และ error ใดก็ตามที่รายการนี้ไม่รู้จัก เช่น ERR max number of clients reached หรือ TLS proxy ที่ทำงานได้ไม่เต็มที่ (EPROTO, ERR_SSL_*) error ที่ไม่รู้จัก จะถูก log หนึ่งครั้งตอน boot เป็น WARNING ที่บอก code หรือคำแรก ๆ ของ error ดังนั้น VALKEY_URL ที่ชี้ไป address ที่ติดต่อได้แต่ไม่มีอะไรรออยู่จะ boot แบบ degraded หลังทุก deploy ให้ตั้ง alert ที่ backend.valkeyEverConnected ใน /healthz: เป็น false จนกว่า process นี้ จะติดต่อ Valkey ได้ครั้งแรก และเป็น true ตลอดหลังจากนั้น ค่านี้ใช้รายงานเท่านั้น ไม่ทำให้ instance ไม่ ready แต่ละ instance ตัดสินจาก config ล่าสุดที่ตัวเองเห็น: การเปลี่ยน onBackendDown (หรือ room) ที่ทำบน instance อื่น ก่อนล่มไม่นานอาจยังมาไม่ถึง instance นี้ ตลอดช่วงที่ล่ม สอง instance จึงอาจตอบ host เดียวกันต่างกันได้ ก่อนปิดปรับปรุง Valkey ตามแผน ให้เปลี่ยน onBackendDown ล่วงหน้าอย่างน้อย RESYNC_MS (ค่าเริ่มต้น 5 วินาที) เพื่อให้ทุก instance ได้ค่าใหม่แล้ว หลังความล้มเหลวครั้งแรกที่หมายถึงติดต่อ Valkey ไม่ได้ แต่ละ process จะตอบแบบ backend ล่มทันทีเป็นเวลา 5 วินาที (ระหว่างที่การเชื่อมต่อยังไม่กลับมา หรือให้ request ทีละตัวลอง Valkey ที่ timeout) แทนที่จะรอ command timeout ทุก request นับแยกตาม mode ใน qm_proxy_backend_down_total{mode} และใน inline.backendDownOpenTotal / backendDownClosedTotal ของ /api/admin/health และ log หนึ่งบรรทัดต่อ room ต่อนาที
ตั้งค่าเหมือน field อื่นของ room: POST /api/admin/rooms ด้วย {"id": "shop", "onBackendDown": "open"} ค่าอื่นจะได้ 400 invalid_on_backend_down เมื่อใช้ STORE=memory ค่านี้ถูกเก็บไว้ด้วยแต่ไม่มีผล เพราะ engine ใน process ไม่มี backend ให้หาย
เมื่อเว็บไซต์หลัง queue ล่ม
เป็นเหตุล่มแบบตรงข้าม และสำหรับแบบ inline เกิดบ่อยกว่า: server นี้ปกติ แต่ proxyOrigin ไม่ตอบ ไม่ต้องตั้งอะไรเพิ่ม queue จัดการให้ แต่คุณต้องดูออก
| queue ล่ม | เว็บล่ม | |
|---|---|---|
| Status | 503 (จากกฎของ CDN) | 502 ติดต่อไม่ได้, 504 หมดเวลา |
| Header | — | X-QM-Origin: origin_unreachable|origin_timeout |
| หน้าเว็บ | public/offline.html CDN เป็นคนแสดง | public/origin-down.html server นี้เป็นคนแสดง |
| บอกผู้เข้าชมว่า | ตอนนี้เข้าไม่ได้ คุณไม่ได้อยู่ในคิว | คุณผ่านคิวแล้ว แต่เว็บไม่ตอบ |
| ป้ายสถานะ | แดง: ตัวระบบคิวเองพัง | เหลืองอำพัน: รอสักครู่ ที่ของคุณยังอยู่ |
ไม่มีหน้าไหนใช้คำว่า paused คำนี้เป็นของ operator: บนหน้ารอ มันแปลว่ามีคนตั้งใจหยุดคิวไว้ ผู้เข้าชมที่เคยเห็นทั้งสองหน้าต้องแยกได้ว่าอันไหนคือระบบล่ม อันไหนคือคนเลือก
ผู้เข้าชมที่เปิดหน้าเว็บได้หน้านี้ ส่วน fetch() เบื้องหลังได้ JSON ที่มี code เดียวกัน เพราะถ้าส่งหน้า HTML ให้ XHR จะดูเหมือนแอปมีบั๊ก ทั้งสองมี X-QM-Origin header เดียวจึงตอบได้ว่า "ฝั่งไหนพัง" ไม่ว่า request จะเป็นแบบไหน
origin-down.html ใช้กฎเดียวกับหน้า failover (ไฟล์เดียว ไม่มีไฟล์ประกอบ ห้าภาษา ลองใหม่แบบสุ่มระยะ 15 s → 60 s) โดยต่างกันหนึ่งข้อที่สำคัญ: ไม่เคยบอกเป็นนัยว่า ผู้เข้าชมเสียที่ เพราะเขาไม่ได้เสีย การลองใหม่คือ reload ธรรมดา ด้วย pass ที่เขายังถืออยู่
จะเห็นได้ที่ไหน console ไม่ต้องให้คุณไปหาเอง:
- การ์ด room แสดง "Site not answering — N visitors got an error page" และป้ายสุขภาพเปลี่ยนเป็น
site · failingป้ายนี้มีน้ำหนักกว่าผล probe เพราะ HEAD ที่/อาจสำเร็จ ขณะที่ทุกหน้าจริงได้ 502 - badge incident บน header ขึ้น
⚠ origin down — <room>เป็นอันดับแรก แม้อีก room จะมีคิวยาวกว่า เพราะปลายทางที่ตายของ room แบบ snippet ทำร้ายคนที่ จะ ได้เข้า แต่ origin แบบ inline ที่ตาย ทำร้ายทุกคนที่ผ่านเข้าไปแล้ว และwaitingของมันอาจเป็น 0 จริง ๆ ขณะที่ทั้งเว็บล่ม - Diagnostics เพิ่มสองแถว: Inline rooms (ตรวจคิว vs ข้าม พร้อมอัตราส่วน) และ Origin errors (ยอดรวม แยกตาม room และตามแบบของความผิดพลาด) การตัดการเชื่อมต่อจากฝั่งผู้เข้าชมแสดงแยก และไม่รวมในยอดนั้นเลย เพราะการปิดฝา laptop ไม่ใช่เว็บพัง
ตัวเลขเบื้องหลังทั้งหมด: inline.originErrorsTotal และ inline.originErrors ใน GET /api/admin/health และใน /metrics:
| Series | แจ้งเตือนเมื่อ |
|---|---|
qm_proxy_origin_errors_total{room,phase} | เพิ่มขึ้นต่อเนื่อง แปลว่าเว็บหลัง room นั้นพัง connect = ถูกปฏิเสธ/ติดต่อไม่ได้, timeout = รับแล้วไม่ตอบ, body_stall = ตอบแล้วตายกลางทาง, upgrade = WebSocket handshake ไม่สำเร็จ client_reset คือ socket ของผู้เข้าชมเอง ตั้งใจไม่นับรวมในยอดสุขภาพ |
qm_proxy_gated_total / qm_proxy_bypassed_total | อัตราส่วนห่างจากราว 1:40 มาก แปลว่ากฎ asset จับทุกอย่างหรือไม่จับอะไรเลย |
qm_proxy_inline_rooms | ลดลงโดยที่คุณไม่ได้ทำ |
qm_join_rate_limited_total | เพิ่มขึ้น แปลว่าผู้เข้าชมถูกปฏิเสธที่ในคิว |
qm_waiting_page_rate_limited_total | เพิ่มขึ้นต่อเนื่อง แปลว่ามีอะไรดึงหน้ารอเร็วกว่า browser ปกติมาก |
qm_open_connections / qm_max_connections | ตัวแรกเข้าใกล้ตัวที่สอง |
qm_connections_refused_total | เพิ่มขึ้นเลย แปลว่าถึงเพดาน socket และกำลังทิ้ง request |
qm_process_resident_bytes | โตขึ้นเรื่อย ๆ ไม่หยุด |
การ์ด badge และตัวนับ ตั้งใจให้บอกเรื่องเดียวกัน: queue ยังอยู่ เว็บไม่อยู่ และการปรับ rate ไม่ช่วยอะไร
การหยุดเซิร์ฟเวอร์
การหยุดแบบเรียบร้อยจะ:
- ตั้ง process เป็นไม่พร้อม (not-ready)
- เลิกรับ connection ใหม่
- รวมชั่วโมงที่ยังไม่จบเข้าคลังข้อมูลรายงาน
- คืน leader lease (
STORE=valkey) - commit ครั้งสุดท้ายของ events journal ลง Postgres (
STORE=valkey) - บันทึก annotation บน timeline
- บันทึก
server.stopใน audit trail
การ kill ไม่ทำอะไรเลยในนี้ ข้อมูลของชั่วโมงที่ยังไม่จบจะหาย และเมื่อใช้ STORE=valkey instance อื่นต้องรอให้ lease หมดอายุ (LEADER_LEASE_MS) ก่อนตัวใดตัวหนึ่งจะเป็น leader เมื่อใช้ STORE=memory การหยุดแบบใดก็ตามทำให้คิว room และ operator หายหมด เพราะไม่มีอะไรถูกเก็บข้ามการ restart
connection ที่ว่างอยู่และ SSE ได้เวลา SHUTDOWN_GRACE_MS ให้จบ แล้วถูกตัด (stream SSE ไม่มีวันจบเอง) socket ที่กำลังส่ง response ผ่าน proxy แบบ inline ไม่ถูกแตะตรงนี้ เพราะต่างจาก SSE มันจบเองได้ ถูกจำกัดแค่ด้วย SHUTDOWN_DRAIN_MS ซึ่งเป็นเวลาสูงสุดของการหยุดทั้งหมด ระบบแบบ inline ที่ไม่ได้ตั้ง SHUTDOWN_DRAIN_MS ใช้ค่าขั้นต่ำ 15s แทนค่าเริ่มต้นปกติ 2s เพราะวัดแล้วพบว่า 2s ตัดการดาวน์โหลดจริงผ่าน proxy กลางทางทุกครั้งที่ deploy แต่ถ้าตั้ง SHUTDOWN_DRAIN_MS เอง ค่าที่ตั้งจะชนะเสมอ ไม่ว่าจะ inline หรือไม่ และไม่ถูกแอบดันขึ้นเป็น 15s อีกแล้ว
บรรทัด audit บอกตามจริงว่าเกิดอะไรขึ้น: ถ้า checkpoint สุดท้ายไม่สำเร็จ (เมื่อใช้ STORE=valkey คือการ commit events journal ลง Postgres ที่ล้มเหลว) จะบันทึก shutdown … WITH STORAGE FAILING และบอกว่าการเปลี่ยนแปลงตั้งแต่ commit ที่สำเร็จครั้งล่าสุด ไม่อยู่ใน events journal แทนที่จะเขียนคำว่า "clean" ทับ checkpoint ที่ไม่เคยเกิดขึ้น
| แพลตฟอร์ม | หยุดแบบเรียบร้อย |
|---|---|
| Linux / macOS, รันหน้าจอ | Ctrl-C หรือ kill <pid> (SIGTERM) |
| Linux / macOS, รันเป็น service | systemctl stop, docker stop ทั้งคู่ส่ง SIGTERM |
| Windows | Ctrl-C ในหน้าต่าง console หรือ admin API ด้านล่าง |
Windows ไม่มีวิธีหยุดแบบเรียบร้อยอื่น taskkill /PID <pid> ที่ไม่มี /F จะตอบ This process can only be terminated forcefully (with /F option) และ /F คือการ kill ทันทีที่ตัวจัดการไม่เคยเห็น ดังนั้นอะไรก็ตามที่ไม่ได้รันในหน้าต่าง console ที่คุณเข้าถึงได้ (service wrapper, scheduled task, เครื่องระยะไกล) ให้ใช้:
curl -sX POST http://localhost:8080/api/admin/shutdown -H "Authorization: Bearer $ADMIN_KEY"
คำสั่งนี้ตอบ {"ok":true,"stopping":true,"uptimeSec":N} แล้วปิดผ่านเส้นทางเดียวกับ SIGTERM ต้องใช้ role owner (หรือ ADMIN_KEY) เกณฑ์เดียวกับการลบ room และตั้งใจไม่ให้ key แบบ operator ที่ใช้ดูแลคิวทำได้
คำสั่งนี้หยุดเฉพาะ process ที่ตอบ เมื่อมีหลาย instance หลัง load balancer นั่นคือ instance ที่ request ไปถึง ตัวอื่นยังให้บริการต่อ ถ้าจะหยุด instance ใดโดยเฉพาะ ให้ส่งไปที่ address ของ instance นั้นเอง หรือหยุดผ่าน platform
เมื่อใช้ STORE=valkey kill ทันทีก็ยังไม่เสียหายร้ายแรง: คิวอยู่ใน Valkey และทุก join ที่ตอบรับแล้วอยู่ใน journal ของ Postgres ไม่มีผู้เข้าชมคนไหนเสียที่ สิ่งที่หายคือส่วนของ ชั่วโมงปัจจุบันที่ยังไม่ได้เขียน
Deploy, backup และการกู้คืน
production ใช้ STORE=valkey + SIDESTORE=pg (NODE_ENV=production ไม่ยอมใช้แบบอื่น) และไม่มี instance ไหนเก็บอะไรไว้บนดิสก์ของตัวเอง: คิวอยู่ใน Valkey และทุกอย่างที่ต้องอยู่รอด อยู่ใน Postgres การออกแบบ (deploy ทีละตัว, backup ของ Valkey และ Postgres, RPO 1 s / RTO 5 min, ตัวเลขความจุและวิธีวัด) ดู docs/adr/0002-deploy-backup-recovery.md ใน repository ตามที่แก้ไขสำหรับ step 5
Deploy instance ทุกตัวใช้แทนกันได้ จึงเปลี่ยนทีละตัวหลัง load balancer: เริ่มตัวใหม่ รอจน /healthz ของมันตอบ 200 แล้วหยุดตัวเก่าหนึ่งตัวแบบเรียบร้อย (ดู การหยุดเซิร์ฟเวอร์) ไม่มีใครเสียที่ในคิว การหยุดแต่ละครั้งปิด stream บน instance นั้น และหน้ารอจะต่อใหม่กับตัวอื่น (ดู Deploy ใน หลาย instance (STORE=valkey)) ระหว่างนั้นมีสองเวอร์ชันทำงานพร้อมกัน ทุก instance จึงต้องให้บริการ hash ของไฟล์หน้ารอทุกตัวที่ยังถูกแจกอยู่ (ดูหัวข้อถัดไป) เมื่อใช้ SIDESTORE=pg schema จะถูก migrate ตอนเริ่ม อย่า deploy ช่วงใกล้ scheduled drop
Rollback (ถอยกลับ) deploy เวอร์ชันก่อนหน้าด้วยวิธีเดียวกัน การถอยกลับไปก่อน release ที่เพิ่ม kid ใน token จะทำให้ผู้เข้าชมทุกคนที่ join หลังอัปเกรดต้องต่อคิวใหม่ เพราะ build เก่าตรวจ token เหล่านั้นไม่ได้ (ดู ถอย release กลับข้ามการเปลี่ยนวิธี sign token ใน การปฏิเสธการเริ่มทำงาน)
สิ่งที่ต้อง backup
| อะไร | สำคัญเพราะ |
|---|---|
Postgres (ฐานข้อมูลที่ DATABASE_URL) | บันทึกถาวร journal events, open_orders และ checkpoint ใน room_snapshots เก็บคิวของทุก room: เป็นสิ่งที่ใช้ rebuild room ฐานข้อมูลเดียวกันเก็บ operator และลิงก์ monitor, audit trail, โน้ตบนไทม์ไลน์, metrics 5 s และข้อมูลรายชั่วโมง backup ด้วย point-in-time recovery ของผู้ให้บริการ หรือ pg_dump |
| Valkey | คิวที่กำลังทำงาน เมื่อ Valkey ทำสถานะของ room หายหรือย้อนกลับ room จะถูก rebuild จาก journal ใน Postgres (ดู เมื่อ backend ล่ม) backup ของ Valkey จึงช่วยให้กู้เร็วขึ้น แต่ไม่ใช่สิ่งที่ทำให้กู้ได้ ต้องรันด้วย maxmemory-policy noeviction |
SECRET (และ SECRET_PREVIOUS ระหว่าง rotate) | เก็บใน secret manager ห้ามอยู่ใน backup ข้อมูล ถ้าหาย ticket, ลิงก์ monitor, session ของ console และ operator key ทุกอันใช้ไม่ได้ทันที |
การกู้ Postgres กู้พร้อมกับ Valkey ที่ว่าง (หรือ VALKEY_PREFIX ใหม่): ทุก room จะไม่มีใน Valkey และแต่ละ room ถูก rebuild จาก journal ที่กู้มา เป็นสถานะ ณ จุดที่กู้ join ทั้งหมดหลังจุดนั้นหาย อย่ากู้ Postgres ใต้ Valkey ที่ยังมีสถานะใหม่กว่าอยู่: room ที่ใน Valkey ไปไกลกว่าใน journal จะถูกมองว่ามี event ที่ยังไม่ได้ commit และจะไม่ถูก rebuild ถ้าจะทดสอบ backup ก่อนต้องใช้จริง ให้กู้ลงฐานข้อมูลใหม่ แล้วเริ่ม instance ทดสอบบนนั้นด้วย VALKEY_PREFIX ของตัวเอง แล้วเทียบค่า waiting ของแต่ละ room ใน /api/admin/rooms
เมื่อใช้ STORE=memory ไม่มีอะไรต้อง backup: server ไม่เก็บอะไรข้ามการ restart และการ deploy ทำให้คิว room และ operator หายหมด production จึงไม่ยอมใช้แบบนี้
สิ่งที่ backup กู้คืนไม่ได้ อีเมลสำหรับแจ้งเตือนเมื่อถึงคิว (EMAIL_NOTIFY) ถูกลบเมื่อบัตรคิวนั้นสิ้นสุด หรือช้ากว่านั้นได้ถึง LAPSED_TTL_MS (24 ชั่วโมง) ถ้าช่วงเวลาเข้าของบัตรหมดไป ไม่เคยเขียนลง Postgres ถ้าใช้ STORE=memory ทุกการ restart หรือ deploy ทำให้หายหมด ถ้าใช้ STORE=valkey อีเมลอยู่ใน Valkey ที่ใช้ร่วมกันจนถึงตอนนั้น snapshot RDB ของ Valkey และ backup ของผู้ให้บริการอาจมีสำเนาอยู่หลังจากลบแล้ว จนกว่าจะถูกหมุนเวียนออกไป build นี้ไม่ส่งและ export ไม่ได้ ADR อธิบายว่า production ทำอย่างไรแทน
ไฟล์ของหน้ารอและ stream ที่เปิดค้างไว้ระหว่าง deploy
ไฟล์ของหน้ารอ style และ script หลักของหน้ารอไม่ได้ฝังในหน้า แต่ส่งจาก address ที่มี hash ของเนื้อหา คือ /assets/waiting-<hash>.css และ /assets/waiting-<hash>.js โดย <hash> คือ 22 ตัวแรกของ SHA-256 แบบ base64url ของไฟล์ บน inline host อยู่ใต้ /__qm/assets/… ส่งพร้อม Cache-Control: public, max-age=31536000, immutable browser หรือ CDN จึงเก็บแต่ละไฟล์ไว้หนึ่งปี build ใหม่ที่แก้ public/waiting.html จะได้ hash ใหม่ process หนึ่งส่งได้ เฉพาะ hash ของ build ตัวเอง hash เก่าจะได้ 404 และหน้าที่ขอไฟล์นั้น จะแสดงโดยไม่มี style และ script
ดังนั้นกฎเมื่อมีหลาย instance อยู่หลัง address เดียว และระหว่าง rolling deploy คือ ทุก instance ต้องส่งได้ทุก hash ที่ instance ใดก็ตามยังแจกอยู่ หน้าที่ instance เก่าสร้าง จะขอ hash เก่า และ load balancer อาจส่งคำขอนั้นไปที่ instance ใหม่ วิธีทำให้เป็นไปตามกฎ:
- deploy ทุก instance พร้อมกัน (การ deploy แบบหยุดแล้วเริ่มของ build นี้ทำแบบนี้อยู่แล้ว)
- ให้ผู้เข้าชมคนหนึ่งอยู่กับ instance เดียวตลอดการโหลดหน้าหนึ่งครั้ง (ทำไม่ได้หลัง load balancer ที่ไม่มี sticky session เช่นของ DO App Platform)
- เก็บไฟล์ของ build ก่อนหน้าไว้ให้เรียกได้ เช่นที่ CDN หรือ object store หน้า
/assets/จนกว่าจะไม่เหลือ instance เก่า
CDN ที่ cache hash เก่าไว้แล้วรับคำขอได้เกือบทั้งหมด แต่ edge ที่ยังไม่มี cache รับไม่ได้ จึงอย่าพึ่งแค่ CDN
Stream ของผู้เข้าชม server จะปิด stream ของหน้ารอ (/events) ที่เปิดอยู่ เมื่อ token ของมันถึงเวลาต้อง sign ใหม่ (ดู TOKEN_MAX_AGE_SEC) ก่อนปิด server ส่ง event: refresh ที่มีข้อมูล {} แล้วหน้ารอจะเปิด stream ใหม่เอง response ของ stream ใหม่จะ sign ที่ในคิวใหม่ และตั้ง cookie ใหม่ เวลาที่ถึงกำหนดคือ TOKEN_REFRESH_AFTER_MS หลังออก token คือครึ่งหนึ่งของค่าที่น้อยกว่าระหว่าง TOKEN_MAX_AGE_SEC กับ QUEUE_COOKIE_MAX_AGE_SEC แต่ละ stream จะถูกปิดช้ากว่าเวลานั้นได้ไม่เกิน 10% ของช่วงเวลานั้น ความช้านี้คงที่ต่อที่ในคิว (คิดจาก hash ของที่ในคิว) คนที่ join พร้อมกันด้วยเวลาออก token เดียวกันจึงไม่ต่อใหม่พร้อมกัน ระบบที่ port ไปต้องส่งเฟรมเดียวกันและใช้กฎเดียวกัน การ deploy ปิดทุก stream และหน้ารอจะต่อใหม่เอง
Stream ของ console /api/admin/events ส่งเฟรม stats เต็มตอนต่อ และอีกครั้งใน tick ถัดไป (ทุก 2 s) หลังจากนั้น tick หนึ่งส่งแค่ delta: {"delta":true,"rooms":[room ที่เปลี่ยน],"removed":[ids],…} พร้อม totals แบบเดียวกับเฟรมเต็ม ถ้าลำดับ room เปลี่ยน (ลบ room แล้วสร้างใหม่ด้วย id เดิม) tick นั้นจะส่งเฟรมเต็มแทน console ที่ได้ delta โดยยังไม่มีเฟรมเต็มอยู่ก่อนจะต่อใหม่ นั่นคือวิธี resync การ deploy ปิด stream นี้ console จึงได้เฟรมเต็มอีกครั้ง
Pass ผูกกับผู้เข้าชมอย่างไร
pass token เดินทางใน URL (?qm_token=) จึงถูกเขียนลง access log ของ origin และ CDN ทุกตัว ถ้าระบบถือว่า "ใครถือ token คนนั้นคือผู้เข้าชม" ใครก็ตามที่อ่าน log ได้ก็ยึดคิวของคุณได้ มีสามชั้นที่กันเรื่องนี้:
- ความเป็นเจ้าของ ticket ticket ถูกบันทึกกับ client ที่ต่อคิวเอาไว้ ถ้าเอา token ของคนอื่นมาใช้ จะได้ ticket ใหม่ท้ายคิว ไม่มีวันได้ที่ของเขา และไม่มีวันได้ pass ของเขา
- session cookie ที่ผูกกับ browser (
qms_<roomId>,HttpOnly,SameSite=Lax, เป็น cookie ของ server นี้เอง และเก็บแค่ค่า hash) นี่คือหลักฐานความเป็นเจ้าของ แนวเดียวกับ session cookie ของ Queue-it: ไม่เคยอยู่ใน URL หรือ log ยังอยู่เมื่อผู้เข้าชมเปลี่ยนเครือข่าย และเจ้าของใช้มันดึง pass คืนจากใครก็ได้ที่แย่งไป - ลายนิ้วมือของ client (fingerprint) สำหรับ browser ที่บล็อก cookie มีสองระดับ: แบบเข้ม (socket peer + ตัวตนที่ส่งต่อมา +
User-Agent) และระดับที่สองที่ไม่มี socket peer เพื่อไม่ให้ CDN ที่ตอบจาก edge node คนละตัว ล็อกผู้เข้าชมออกจาก pass ของตัวเอง
ระดับที่สองสร้างจาก request header ล้วน ๆ และ header เหล่านั้น (address ของ client, User-Agent) อยู่ในบรรทัดเดียวกับ token ใน access log ระบบจึงคำนวณระดับนี้ เฉพาะ request ที่มา จาก proxy ที่คุณระบุไว้ใน TRUST_PROXY_IPS ถ้าไม่มีรายการนี้ ก็แยกไม่ออกว่าเป็น edge node ของ CDN หรือคนร้ายที่เอาบรรทัด log มาส่งซ้ำ และตัวตนเดียวที่ใช้ได้คือแบบเข้ม (ซึ่งผูกกับ socket peer สิ่งเดียวที่ผู้ส่งเลือกเองไม่ได้)
ผลที่ควรรู้:
- ผู้เข้าชมที่บล็อก cookie และ เปลี่ยน address เครือข่ายกลางคิว จะถูกนับเป็นคนใหม่ และเริ่มท้ายคิว เหมือนผู้เข้าชมที่ล้างข้อมูลใน browser
- ระบบที่อยู่หลัง CDN แต่ไม่ได้ตั้ง
TRUST_PROXY_IPSผู้เข้าชมที่บล็อก cookie และ request ย้ายไปมาระหว่าง edge node จะต้องต่อคิวใหม่ ตั้งTRUST_PROXY_IPS(/api/admin/healthจะเตือนถ้ายังไม่ได้ตั้ง)
สิ่งที่ระบบเก็บ และสิ่งที่ควรอยู่ในประกาศความเป็นส่วนตัวของคุณ
ต่อ ticket หนึ่งใบ server เก็บ SHA-256 digest แบบตัดสั้นของ address ของ client, socket peer, ลำดับ address ที่ส่งต่อมา และ User-Agent นอกจากนี้ events journal ใน Postgres (STORE=valkey) ยังบันทึก address ของ client แบบอ่านได้ตรง ๆ ทุกครั้งที่ join มีแค่นี้: ไม่มี cookie ของบุคคลที่สาม ไม่มีตัวระบุข้ามเว็บ ไม่มีโปรไฟล์ และไม่มีอะไรออกจาก server ของคุณ
แต่ยังนับเป็นข้อมูลส่วนบุคคลตาม GDPR เพราะ IP address เป็นข้อมูลส่วนบุคคล จึงต้องระบุไว้ในประกาศความเป็นส่วนตัว โดยให้ที่ปรึกษากฎหมายของคุณตัดสินฐานทางกฎหมาย และระยะเวลาเก็บ (คือนานเท่าที่คุณเก็บ journal events ใน Postgres และ backup ของมัน: journal ยังไม่มีการลบข้อมูลเก่า) วัตถุประสงค์แคบและควรเขียนตรง ๆ: pass token เดินทางใน URL จึงไปอยู่ใน access log และการผูกนี้คือสิ่งเดียวที่กันไม่ให้ คนที่อ่านบรรทัด log เหล่านั้นแย่งที่ของผู้เข้าชม FAQ สำหรับลูกค้าใน Integration guide ลิงก์มาที่นี่
Pass กับ session
แต่ละ room มีนาฬิกาสองตัวที่ต่างกัน:
| Field | ค่าเริ่มต้น | ความหมาย |
|---|---|---|
passedTtlSec | 600 | ผู้เข้าชมที่ได้ pass มีเวลาเท่าไรที่จะ เดินเข้าประตู ไม่ใช้ภายในเวลานี้ pass หมดอายุ และต้องต่อคิวใหม่ |
sessionTtlSec | 1800 | อยู่ ข้างใน ได้นานเท่าไรหลังได้เข้า นับเวลาที่ไม่ใช้งาน: ทุก /api/check เลื่อนเวลาออกไป (เหมือน extendCookieValidity ของ Queue-it) |
sessionMaxSec | null (4 h) | เพดานตายตัวของการเข้าชมหนึ่งครั้ง ไม่ว่าจะใช้งานอยู่แค่ไหน นับจากตอนเดินเข้า null แปลว่า 14400 s หรือ sessionTtlSec ถ้าค่านั้นยาวกว่า ใส่ตัวเลขถ้าต้องการเปลี่ยน |
session หนึ่งตัวถือ slot ของ maxConcurrent แค่หนึ่ง slot เสมอ ไม่ว่า token จะถูกใช้ซ้ำกี่ครั้ง และคืน slot เมื่อผู้เข้าชมเงียบไปนาน sessionTtlSec (หรือถูก eject)
The claim window: นาฬิกาตัวที่สาม และตัวที่ทำให้คนแปลกใจ
claim window คือช่วงเวลาที่ pass ที่ยังไม่ได้ใช้ได้จอง slot ไว้
pass ที่ยังไม่ถูกใช้จะถือ slot ของ maxConcurrent ไว้ 120 s (หรือ passedTtlSec ถ้าสั้นกว่า) ไม่ใช่ตลอดอายุของ pass ไม่อย่างนั้นผู้เข้าชมคนเดียวที่ได้ pass แล้วปิดแท็บ จะจอง slot ค้างไว้ถึงสิบนาทีเต็ม พ้นช่วงนี้แล้ว pass ยังใช้ได้ ผู้ถือยังเดินเข้าได้ แต่ไม่นับในเพดานแล้ว และผู้ถือต้องแย่ง slot ที่ประตูเหมือนคนอื่น
นาฬิกาทั้งหมดเรียงตามลำดับ:
- ได้เลื่อนขึ้นมา (promotion) = ได้ pass ซึ่งเป็นสิทธิ์เข้าที่ใช้ได้
passedTtlSec(600 s) - ช่วงไม่เกิน 120 s แรก (claim window: สั้นกว่านี้ถ้า
passedTtlSecสั้นกว่า และจบเร็วกว่านั้นอีกถ้าผู้ถือไม่อยู่ ดูpresenceSecด้านล่าง) pass ที่ยังไม่ใช้ ถือ slot ของmaxConcurrentไว้ - เดินเข้า = เริ่ม session ทุกการตรวจเลื่อนเวลาออกไป และ session จบเมื่อไม่มีการตรวจเลย นาน
sessionTtlSec(1800 s) หรือเมื่อครบsessionMaxSecนับจากเดินเข้า แล้วแต่อย่างไหนถึงก่อน - pass ที่ยังไม่ถูกใช้เมื่อสิทธิ์เข้าหมด จะหมดอายุ และผู้ถือต้องต่อคิวใหม่ ส่วน session ที่เริ่มไปแล้วไม่ถูกตัดเพราะสิทธิ์เข้าหมด
ผลที่ตามมาควรพูดตรง ๆ เพราะดูน่าตกใจแต่ไม่ใช่ปัญหา:
room ที่ตั้ง maxConcurrent: 5 อาจมี สิบคนที่ถือ pass ที่ยังใช้ได้และยังไม่หมดอายุ ห้าคนมี slot อีกห้าคนคืน slot ไปแล้ว ถ้ามาพร้อมกันทั้งสิบคน จะได้เข้าห้าคนพอดี และอีกห้าคนถูกปฏิเสธ เพดานไม่มีวันเกิน คนถือ pass ที่ถูกปฏิเสธจะได้เข้าตามลำดับ ticket เก่าสุดก่อนเมื่อมี slot ว่าง และยังรักษาที่ไว้ได้ตราบที่ยังถามสถานะอยู่
ห้าคนนั้นเป็นคนจริง และไม่ได้อยู่ใน waiting เพราะเขามี pass แล้ว การ์ด room แสดงพวกเขาใต้ CAPACITY เป็น +5 holding passes และ API รายงานแยกตาม room:
| Field | ความหมาย |
|---|---|
occupancy | slot ที่ใช้อยู่: session ที่ active + pass ที่ยังอยู่ใน claim window |
passesOutstanding | pass ที่ยังใช้ได้แต่พ้น claim window แล้ว ไม่มี slot ถ้าคนเหล่านี้มาตอนนี้จะถูกปฏิเสธ |
atDoor | คนในกลุ่มนั้นที่กำลังถูกปฏิเสธ ตอนนี้ (มาเคาะประตูภายใน 30 s ล่าสุด) |
claimWindowSec | ความยาวของ claim window ที่ room นี้ใช้จริง |
อ่านตัวเลขอย่างไร:
passesOutstandingเพิ่มขึ้น ขณะที่occupancyอยู่ที่เพดาน คือรูปแบบปกติของ drop ที่ติดเพดานความจุpassesOutstandingเพิ่มขึ้น ขณะที่occupancyต่ำกว่า เพดาน แปลว่าออก pass เร็วกว่าที่คนเดินเข้า มักเป็นเพราะปล่อยคนเร็วกว่าที่หน้าปลายทางซึ่งช้ารับไหว- ค่านี้ยังเพิ่มขึ้นเมื่อคิวเต็มไปด้วย "ผี" (ดูด้านล่าง)
ผู้ถือที่ไม่อยู่ไม่กิน slot (presenceSec)
ตอนนี้ pass จะจอง slot ให้เฉพาะผู้ถือที่เห็นชัดว่ายังอยู่:
| Field | ค่าเริ่มต้น | ความหมาย |
|---|---|---|
presenceSec | 30 | ผู้เข้าชมหายไปได้นานเท่าไร โดยยังมี slot จองไว้ให้ null = ปิดกฎนี้สำหรับ room นั้น |
ทำไมต้องมี: ก่อนมีกฎนี้ client ตัวเดียวทำให้ room ค้างได้โดยไม่ต้องเปิดดูเลย วัดบน room ที่ 600/min และ maxConcurrent: 5: address เดียวส่ง join 200 ครั้ง ไม่มี cookie และไม่เคยถามสถานะ ทั้ง 200 ได้ที่ ผู้เข้าชมจริงคนถัดไปได้ลำดับ 201 และ 15 วินาทีต่อมายังอยู่ที่ 196 โดย occupancy 5, active 0 ทุก slot ถูกถือโดย pass "ผี" ที่ไม่มีใครใช้ ตลอด claim window 120 s ทีละห้า ผีหกสิบตัวทำให้ room นั้น ค้างไปราว 24 นาที
"เห็น" แปลว่าผู้เข้าชม join, ถาม /api/status หรือเปิด stream /events ค้างไว้ (หน้ารอทำอย่างใดอย่างหนึ่งทุก 5 s หน้าที่เปิดอยู่จึงไม่มีวันถูกนับว่าหายไป) ผลมีสองข้อ:
- ตอนถึงคิว ticket ที่ผู้ถือหายไปนาน
presenceSecยังได้ pass ตามลำดับ ไม่มีใครเสียที่ แต่ pass นั้น ไม่มี slot และไม่กิน rate ของ room เลย เหมือน pass ที่พ้น claim window ทุกอย่าง: ถ้าผู้ถือกลับมาภายในpassedTtlSecก็ผ่านได้ และแย่ง slot ที่ประตูตามลำดับ ticket เก่าสุดก่อน เหมือนผู้ถือ pass ที่ถูกปฏิเสธคนอื่น มือถือที่ล็อกจอไว้ระหว่างคิวเดิน เสียแค่การจองเท่านั้น - หลังได้ pass pass ที่ผู้ถือยังไม่เคย ได้รับแจ้ง ว่าผ่านแล้ว (ไม่มีการถามสถานะ ตั้งแต่นั้น) จะคืน slot เมื่อหายไปนาน
presenceSecแทนที่จะถือไว้ตลอด claim window ผู้เข้าชมที่ได้รับแจ้งแล้ว ถือ slot ได้ตลอด window เพราะเขากำลังเดินทางไปหน้าเว็บของคุณ และไม่มีอะไรให้เฝ้าดูแล้ว
room เดิมที่มีผี 200 ตัว ตอนนี้ปล่อยผู้เข้าชมจริงผ่านได้ในราว presenceSec หลังจากถูกยิง pass ของผีไปอยู่ใน passesOutstanding ไม่ใช่ใน occupancy หลัง restart ระบบถือว่าทุก ticket ถูกเห็นตอนเริ่ม (สถานะการอยู่ไม่ได้ถูกบันทึกลงดิสก์) การพังจึงไม่ทำให้ใครเสีย slot และให้ผีได้เวลาเพิ่มอีกไม่เกินหนึ่ง presenceSec ทางเลือกที่ถูกปัดตกคือทำให้ claim window สั้นลงสำหรับทุกคน เพราะจะเด้งผู้เข้าชมจริงทิ้ง ทุกครั้งที่หน้าปลายทางช้า และผีชุดใหม่ที่มาพร้อมกันก็ยังถือทุก slot ได้ตลอด window ที่สั้นลงนั้นอยู่ดี
รายการ origin สองชุด และทำไมถึงไม่รวมเป็นชุดเดียว (returnOrigins, tagOrigins)
เดิมเป็น field เดียว การแก้ครั้งเดียวจึงตัดสินสองเรื่องที่พังไปคนละทิศ:
| Field | ตอบคำถามว่า | ถ้าตั้งผิด |
|---|---|---|
returnOrigins | ผู้เข้าชมที่ได้ pass ถูก ส่งไป ที่ไหนได้ | กลายเป็น open redirect บน domain ของคุณเองที่แจก pass token เห็นชัด เป็นเรื่องของทีม security ต้องคุมให้แคบ |
tagOrigins | หน้าไหน อ่าน ผลตรวจที่ประตูได้ (รายการ CORS ที่ browser ยอม) | หน้านั้นถูกแสดง โดยไม่ต่อคิวเลย แบบเงียบ ๆ: browser ทิ้งคำตอบที่ไม่มี Access-Control-Allow-Origin snippet จึงปล่อยผ่าน และ room ยังดูปกติดีจากฝั่งนี้ |
origin ของ targetUrl ของ room อยู่ในทั้งสองรายการเสมอ tagOrigins มีค่าเริ่มต้นเป็น null แปลว่า "ใช้ returnOrigins" room ทุกตัวที่ตั้งไว้ก่อนแยกจึงทำงานเหมือนเดิมทุกอย่าง ตั้งค่านี้เฉพาะเมื่อสองรายการต่างกันจริง (เช่น tag บน domain การตลาดที่ไม่เคยส่งผู้เข้าชมกลับไป หรือนโยบายส่งกลับที่แคบ และไม่ควรขยายแค่เพื่อให้การติดตั้งใช้ได้)
ตั้งแยกกัน:
curl -X POST http://localhost:8080/api/v1/admin/rooms \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"id":"checkout",
"returnOrigins":["https://www.shop.example"],
"tagOrigins":["https://www.shop.example","https://landing.brand.example"]}'
การปฏิเสธแบบที่สองถูกนับแยกตาม (room, origin) และแสดงใน Diagnostics และใน qm_refused_origin_checks_total ถ้าไม่ใช่ 0 แปลว่าตอนนี้มีหน้าที่ติด tag ของคุณ เปิดใช้อยู่โดยไม่มีการป้องกัน ตัวตรวจการติดตั้งใน console ถามทั้ง origin และ URL และปุ่มแก้คลิกเดียวของมันเขียนแค่ tagOrigins เพราะการปลดบล็อก tag ไม่ใช่การตัดสินใจขยายที่ที่ผู้เข้าชมถูกส่งไปได้
ผู้เข้าชมถูกส่งไปปลายทางไหนได้บ้าง (returnOrigins)
ผู้เข้าชมที่ได้ pass จะถูกส่งกลับไปหน้าที่เขามา ซึ่งมาถึงหน้ารอในรูป URL qm_return เป็นข้อความที่ผู้โจมตีใส่เองได้ server จะปฏิเสธ URL ส่งกลับทุกตัวที่นโยบายของ room ไม่ครอบคลุม URL ที่ยอมได้มีแค่:
- origin ของ
targetUrlของ room เอง เสมอ; และ - origin ใดก็ได้ที่อยู่ใน
returnOriginsของ room เช่นhttps://shop.exampleหรือhttps://*.shop.exampleเพื่อครอบคลุม subdomain จุดหลัง*ต้องมี pattern จึงไม่มีวันจับคู่กับevilshop.exampleและ scheme กับ port ต้องตรงด้วย
URL แบบ relative, scheme ที่ไม่ใช่ http(s) และ URL ที่มี credential ฝังอยู่ (https://user:pass@…) ถูกปฏิเสธทันที การปฏิเสธ URL ส่งกลับไม่ใช่ error สำหรับผู้เข้าชม เขาแค่ไปลงที่ปลายทางของ room แทน จึงตั้งใจให้พังแบบเงียบ แต่ระบบนับไว้ และ Diagnostics แสดงยอดเป็น blocked return URLs
คอยดูตัวนับนี้ origin ส่งกลับที่ลืมใส่ ดูเหมือนการโจมตีทุกอย่าง: ถ้าหน้า checkout อยู่ที่ https://shop.example แต่ปลายทางของ room คือ https://www.shop.example ผู้เข้าชมจริงทุกคนจะไปลงผิดหน้าแบบเงียบ ๆ ถ้าไม่ใช่ 0 แปลว่ามี domain ที่ต้องเพิ่ม หรือมีคนพยายามใช้หน้ารอของคุณเป็น open redirect
Scheduled drop
scheduled drop คือการตั้งเวลาเปิดประตูให้ room ก่อนถึงเวลานั้น ยังไม่มีคิวเลย: คนที่มาถึงจะเข้า pre-queue และได้ handle ที่อ่านความหมายไม่ได้ แทนหมายเลข ticket พอถึงเวลาเปิด ทั้ง pre-queue จะถูกสุ่มเรียงด้วยการสุ่มระดับ crypto แล้วกลายเป็นคิวแบบ มาก่อนได้ก่อน (FIFO)
curl -X POST http://localhost:8080/api/v1/admin/rooms/drop/schedule \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"opensAt":"2026-09-01T09:00:00Z","preQueueMaxPerIp":16}'
| Field | ความหมาย |
|---|---|
opensAt | null (ไม่มีตาราง ประตูเปิดอยู่), เวลาเป็น epoch milliseconds หรือ datetime แบบ ISO |
preQueueMaxPerIp | จำนวนที่ใน pre-queue ที่ตัวตนเดียวถือได้ ค่าเริ่มต้น 16; null = ไม่จำกัด |
ระบบใช้เฉพาะ field ที่คุณส่งมา ปรับเพดานกลาง drop ด้วย -d '{"preQueueMaxPerIp":4}' จะเปลี่ยนแค่เพดาน และ drop ยังตั้งเวลาไว้เหมือนเดิม การยกเลิก drop ต้องส่ง {"opensAt": null} ตรง ๆ การปรับเล็กน้อยตามปกติจึงไม่มีทาง เปิดประตูเงียบ ๆ ให้คิวที่กำลังรอการสุ่ม body ที่ไม่มีทั้งสอง field ถูกปฏิเสธ (400 nothing_to_change) ไม่ใช่รับไว้แล้วไม่ทำอะไร
ทำไมทำงานแบบนี้:
- มาก่อนไม่ได้เปรียบ คลิกทันทีที่ประกาศลิงก์ กับคลิกหนึ่งนาทีก่อน drop ได้สุ่มจากกองเดียวกัน นี่คือจุดประสงค์ทั้งหมด: คิวแบบ FIFO ที่เปิดก่อนหลายชั่วโมง แค่ย้ายการแห่กันเข้ามาไปอยู่ตอนที่คุณประกาศลิงก์
- การสุ่มวนไปทีละตัวตน (round-robin) ที่ที่สองของตัวตนหนึ่งจะถูกจับหลังที่แรก ของทุกตัวตนอื่นเสมอ ที่ที่เกินมาจึงไม่มีทางเบียดคนที่มาครั้งแรก
preQueueMaxPerIpเป็นพื้นขั้นต่ำของความยุติธรรม ไม่ใช่ตัวกันการโกง คนทั้งออฟฟิศหรือทั้งกลุ่ม CGNAT ใช้ address เดียวกัน ค่าเริ่มต้นจึงตั้งไว้เผื่อมาก เพิ่มได้ถ้าผู้ชมอยู่หลัง NAT ของค่ายมือถือ ถ้าลด จะไปลงโทษ address ที่ใช้ร่วมกัน นานก่อนที่จะทำให้ผู้โจมตีที่ตั้งใจลำบาก- ลำดับที่สุ่มได้ถูกบันทึกไว้ ไม่คำนวณใหม่ restart คร่อมเวลาเปิดจะเล่นลำดับที่สุ่มไว้แล้วซ้ำ ไม่สุ่มรอบที่สอง
ระหว่างที่ประตูยังปิด POST /api/join ตอบ state:"scheduled", preQueued:true, preCount และ opensAt/now หน้าเว็บจึงนับถอยหลังตามนาฬิกาของ server ได้ position และ ahead เป็น null เพราะยังไม่มีทั้งคู่
room ที่ตั้งเวลาไว้มีสองมุมมอง: GET /api/admin/rooms รายงานสถานะ ที่ตั้งไว้ (active, paused, bypass) คู่กับ opensAt และจำนวน preQueue ส่วนข้อมูลที่ส่งให้ผู้เข้าชมรายงานสถานะ ที่มีผลจริง คือ scheduled จนกว่าประตูจะเปิด dashboard รวมสองอย่างนี้เป็น badge SCHEDULED, เวลา "opens …" และตัวนับ in pre-queue แทน waiting
Operator console
dashboard ที่ / คือที่ที่ operator อยู่ระหว่างเกิดเหตุ ทุกอย่างบนหน้านี้เป็นข้อมูลสด (frame stats มาทุก 2 s ผ่าน SSE) และทุกปุ่มมีผลทันที ไม่มีขั้นตอนกดบันทึก
ต่อ room
| ปุ่ม | ทำอะไร |
|---|---|
Active / Paused / Bypass | active = ต่อคิวตามปกติ; paused = หยุดการปล่อยคน อัตโนมัติ และทุกคนยังรักษาที่ไว้; bypass = ปิดคิวและปล่อยทุกคนผ่าน ก่อน pause จะมีข้อความบอกผลให้ยืนยัน |
| แถบเลื่อน / ช่องตัวเลข rate | ratePerMinute คือการปล่อยคนแบบเรียบ ๆ ทุกวินาที มีผลในรอบย่อยถัดไป ไม่ต้องรอนาทีถัดไป |
Let through n | ปล่อยผู้เข้าชม n คนทันที เพิ่มจาก rate รวมถึงตอน room ถูก pause ซึ่งเป็นทางเดียวที่ใครจะได้เข้าระหว่าง pause ตั้งใจให้เป็นแบบนี้ เพราะการปล่อยคนไม่กี่คนระหว่าง pause เป็นงานปกติของ operator ปล่อยจาก หัวคิว ตามลำดับ ticket เลือกผู้เข้าชมเฉพาะคนไม่ได้ หน้ายืนยันบอกว่าเป็นกรณีไหนในสองกรณีนี้ |
Autotune | ปรับ rate ตามเวลาตอบสนองของเว็บปลายทางที่วัดได้ แทนการเดาของคุณ ปุ่มรูปเฟืองใช้ตั้งขอบเขต |
Install | script tag ของ room นี้ พร้อมวาง หรือถ้า room มี proxyOrigin จะเป็นรายการตรวจสำหรับติดตั้งแบบ inline แทน: DNS ชี้ไปไหน, address ภายใน, prefix /__qm ที่จองไว้ และกฎ failover room แบบ inline ไม่เคยได้เห็น snippet เพราะไม่มีหน้าให้วาง room แบบ inline ยังมีป้าย inline บนการ์ดด้วย |
Waiting page | เปิด /w/<room> ตรงกับที่ผู้เข้าชมเห็นทุกอย่าง |
Empty queue | ทิ้งคนที่รออยู่ทั้งหมด โดยไม่ปล่อยใครเข้า ใช้กับ room ที่เลยงานไปแล้ว ไม่มีใครได้ pass กราฟจึงไม่มียอดพุ่งที่ไม่เคยเกิดขึ้นจริง ticket ที่ถูกล้างอ่านเป็น expired และต้อง join ใหม่ pre-queue ของ room ที่ตั้งเวลาไว้ก็นับเป็นคนที่รอด้วยและถูกล้างเช่นกัน ผู้เข้าชมเหล่านั้นจะได้รับแจ้งว่าที่ใน pre-queue ปิดแล้ว และต้อง join ใหม่ room การตั้งค่า และประวัติยังอยู่ ย้อนกลับไม่ได้ และหน้ายืนยันบอกไว้ |
Edit / Delete | ตั้งค่า room (target URL, TTL, branding, tagOrigins, returnOrigins, schedule) และลบ room |
อ่านการ์ด room
CAPACITYมีตัวหารเสมอ12/25= ใช้อยู่สิบสองจากยี่สิบห้า slot;0/∞= room ที่ไม่มีเพดานจำนวนคนพร้อมกันเลย (ถ้าแสดงแค่0ใต้คำว่า CAPACITY ข้างการ์ดที่แสดง25/25จะสื่อความหมายตรงข้าม) ตัวเลขนับ session ที่ใช้งานอยู่บนเว็บ ที่ป้องกัน บวก pass ที่ออกภายใน claim window และยังไม่ถูกใช้ ดู Passes vs sessionsEST. WAITสั้นและไม่ถูกตัด:40s,3m,1h20mและเกินสิบชั่วโมงแสดงแค่109hเพราะถึงตอนนั้นนาทีไม่มีความหมายแล้ว ถ้าขึ้นต้นด้วย≥แปลว่าเป็น ค่าต่ำสุด ไม่ใช่ค่าประมาณ: room อยู่ที่เพดานและไม่มีใครออกมาเลยหนึ่งนาที การเพิ่ม rate จึงไม่ช่วย ชี้เมาส์ค้างเพื่อดูว่าเป็นกรณีไหน∞ใต้ EST. WAIT คือ room ที่ไม่ปล่อยใครเข้า (pause อยู่ หรือ active ที่ 0/min) ทั้งที่ยังมีคนรอ ค่านี้คือ จำนวนคนรอ ÷ 0 ตามการตั้งค่าปัจจุบัน ไม่ใช่การพยากรณ์: กลับเป็นตัวเลขทันทีที่ resume หรือเพิ่ม rate และ tooltip บอกว่ารออย่างไหน หน้าของผู้เข้าชมใช้คำว่า "no estimate" สำหรับกรณีเดียวกัน เพราะผู้เข้าชมปรับ rate ไม่ได้ และคำว่า "ไม่มีที่สิ้นสุด" จะเป็นการสัญญาเรื่องอนาคตของเขา แต่คุณปรับได้ การ์ดจึงแสดงเลขคณิตให้ดู สถานะเองแสดงบน badge ครั้งเดียว- WORST EST. WAIT บน header ไม่รวม room ที่เป็น
∞และบอกไว้ ตัวเลขบนสุดคือ เวลารอที่แย่ที่สุดของ room ที่ ยังเดินอยู่ room ที่ pause ตัวเดียวจึงไม่ดันทั้งระบบ ไปค้างที่∞ถ้ามี room ที่มีคนแต่ไม่ปล่อยใครเข้า บรรทัดใต้ตัวเลขจะแสดงexcludes 3 at ∞header และการ์ดที่แสดง∞จึงถูกทั้งคู่ และ header บอกว่าไม่ได้นับ room ไหน ถ้าทุก room ที่มีคิวหยุดหมด ตัวเลขจะเป็น∞และบรรทัดแสดง3 not admitting - room ที่ตั้งเวลาไว้จะนับถอยหลัง ใต้ชื่อ room แสดง
Doors open in 4m 12s · 12:13 PMเป็นสีเหลืองอำพันในห้านาทีสุดท้าย คิวเริ่มก่อนหน้านั้น ผู้เข้าชม join และถือที่ไว้ได้ แค่คิวยังไม่เดิน
ข้าม room
- Chart: จำนวนคนในคิว, joins/min และ passes/min รวมกับเส้นเวลารอสองเส้น ที่ตั้งใจให้เป็นคนละตัวเลข: est. wait คำนวณ (คนในคิว ÷ อัตราปล่อยที่วัดได้) ส่วน actual wait p50/p90 วัดฝั่ง server จากเวลา join→pass จริง ถ้าไม่ตรงกัน ให้เชื่อตัวที่วัดได้
- สเกล rate แยกกันได้ ปกติ joins/min และ passes/min ใช้สเกล
/minสีเหลืองอำพัน ตัวเดียวกันในแกนซ้าย เพราะ ช่องว่าง ระหว่างสองเส้นคืออัตราที่คิวยาวขึ้น แต่ถ้าตัวหนึ่งมากกว่าอีกตัวหลายเท่า (เช่น traffic พุ่ง 1,000 joins/min เทียบกับ 20 passes/min) เส้นที่เล็กกว่าจะถูกกดแบนติดแกน และ passes/min คือเส้นที่คุณใช้ปรับ การขยับจาก 20 เป็น 30 จะขยับเส้นไม่ถึงหนึ่ง pixel เมื่ออัตราส่วนเกิน 4 เท่า แต่ละเส้นจะได้สเกลของตัวเอง ตัวเลขบนแกนพิมพ์ในแกนซ้าย เป็นสีของเส้นนั้น (เหลืองอำพันสำหรับ joins เขียวสำหรับ passes) และป้ายของทั้งสองเส้นใน legend เปลี่ยนจาก(rate scale)เป็น(own scale)ระหว่างที่แสดง own scale เส้นตัดกันไม่ได้แปลว่าตัวเลขตัดกัน ชี้เมาส์เพื่อดูทั้งสองค่า เมื่อสองค่ากลับมาใกล้กัน กราฟจะกลับเป็นสเกลเดียวเอง - Events: เส้นแนวตั้งแสดงทุกอย่างที่เปลี่ยน room ได้แก่ เปลี่ยน rate, เปลี่ยนสถานะ, threshold activation, autotune, flush, purge, eject และการบล็อกของระบบป้องกัน ชี้เมาส์เพื่อดูบันทึกและเวลา เครื่องหมายที่ตกบน pixel เดียวกันจะถูกรวมกัน และ tooltip แสดงครบทุกตัว ถ้า timeline แน่นจนเกะกะ ปิด series นี้ใน legend ได้
- Export CSV: ประวัติทั้งหมดที่เก็บไว้ (1 h) รวม percentile
- URL targeting: กฎที่ตัดสินว่า room นี้ป้องกันหน้าไหน แต่ละกฎแสดงว่าตรงกับการตรวจ ที่ประตูไปกี่ครั้ง และตรงครั้งล่าสุดเมื่อไร แก้ได้ใน
Editมีผลในการตรวจครั้งถัดไป ไม่ต้อง deploy ถ้ากฎไหนแสดง never matched ระหว่างการขายจริง นั่นคืออาการที่เห็นได้ ว่า room ชี้ไปหน้าผิด ช่องด้านล่างใช้ทดลอง: วาง URL แล้วดูว่ากฎไหนจะชนะ ก่อนบันทึกอะไร คำเตือนจะขึ้นที่นี่เมื่อกฎระบุ origin ที่ room จะไม่ส่งผู้เข้าชมกลับไป และเมื่อการตรวจเข้ามา โดยไม่มี URL ของหน้าเลย (integration ที่เก่ากว่า qm.js 1.2.0 ซึ่งกำหนดขอบเขตไม่ได้ จึงถูกต่อคิวเหมือนไม่มีขอบเขต) - Visitor drill-down (ดูรายผู้เข้าชม): ค้นด้วย หมายเลข ticket หรือรหัส
Refที่ผู้เข้าชมอ่านจากหน้ารอของตัวเอง ก็ได้ แสดงสถานะ ลำดับที่ เวลาที่รอ นาฬิกาตัวไหนกำลังเดิน (pass expires in ก่อนเข้าเว็บ, session expires in เมื่ออยู่ในเว็บ) ตอนนี้อยู่ในเว็บหรือไม่ และ ticket ผูกกับ client หรือไม่ หน้ารอแสดงทั้ง ref และหมายเลข ticket ผู้เข้าชมอ่านตัวไหนให้ฟัง ก็วางค้นได้ (ref เป็น digest สั้น ๆ room ที่ใหญ่มาก อาจมีรหัสชนกัน ถ้าเป็นแบบนั้นระบบจะแสดงรายการ ticket ที่เป็นไปได้ แทนการเดา)Ejectยกเลิก pass ตัด session ที่ใช้อยู่ และส่งไปท้ายคิว ต้องยืนยันสองขั้น การเตรียมยืนยันยังอยู่แม้หน้าจอรีเฟรชทุก 2 s แต่จะหายถ้าคุณไปค้นคนอื่น
state เป็นหนึ่งในนี้:
| State | ความหมาย |
|---|---|
waiting | อยู่ในคิว position และ est. wait เป็นของคนนี้ |
passed | ถือ pass ที่เข้าได้ทันที หรืออยู่ในเว็บแล้ว (on site พร้อมนาฬิกา session) |
holding | แสดงเป็น holding pass pass ที่ยังใช้ได้แต่คืน slot ไปแล้ว (พ้น claim window) ขณะที่ room เต็ม: ถูกปฏิเสธที่ประตูจนกว่าจะมี slot ว่าง ticket เก่าสุดได้ก่อน คนกลุ่มนี้คือ holding passes บนการ์ด room pass expires in ยังเดินอยู่; door position แสดงตอนที่เขากำลังเคาะประตูอยู่จริง ยังไม่หมดอายุ และ Eject ทำให้ pass ใช้ไม่ได้ |
expired | engine จบกับ ticket นี้แล้ว: pass หมดอายุ และ ไม่มี session ที่ใช้อยู่ (หรือคิวถูกล้างก่อนถึงตาเขา) ต้อง join ใหม่ |
ejected | operator eject ออก |
prequeued | ref ที่ขึ้นต้น PQ-: อยู่ใน pre-queue ของ room ที่ตั้งเวลาไว้ ก่อนการสุ่ม หลังสุ่มแล้ว ref PQ- เดิมยังค้นเจอ จะเปิดบัตรคิวที่เขาได้ พร้อมป้าย drawn from pre-queue (เฉพาะ room รุ่นเดียวกัน) |
unknown | หมายเลข ticket นี้ไม่เคยถูกออกใน room นี้ ไม่มีลำดับที่หรือเวลารอให้รายงาน |
ref ยังบอก generation (รุ่น) ของ room ที่ออกให้ด้วย ถ้าลบ room แล้วสร้างใหม่ด้วย id เดิม หมายเลข ticket จะเริ่มที่ 1 ใหม่ ref ที่ผู้เข้าชมอ่านให้ฟังซึ่งออกก่อนการเริ่มใหม่ จึงไม่ถูกจับคู่กับคนที่ถือหมายเลขนั้นในวันนี้ ระบบจะบอกว่าเป็น ticket ไหนของ generation ก่อนหน้า (previous_generation) และบอกว่าผู้เข้าชมต้อง join ใหม่ ระบบตรวจ 3 generation ล่าสุด ใน 50,000 ticket แรกของแต่ละ generation
ช่องเวลารอใช้คำบอกเวลาต่างกัน และความต่างนี้คือประเด็นสำคัญ waiting คือตัวเลขที่ยังเพิ่มขึ้น = เวลาตั้งแต่เข้าคิว waited คือการรอที่จบแล้ว หยุดนิ่งตรงตอนที่ได้ pass ไม่เพิ่มขึ้นระหว่างที่คุณอ่าน ticket ที่ค้นหาหนึ่งชั่วโมงหลังเกิดเหตุ จึงยังตอบคำถาม ผู้เข้าชมคนนี้รอนานเท่าไร ด้วยเวลารอจริง ไม่ใช่หนึ่งชั่วโมงนั้น สำหรับ room ที่มี opensAt นาฬิกาเริ่มตอนที่ผู้เข้าชม เข้า pre-queue ไม่ใช่ตอนประตูเปิด ชั่วโมงที่เขารอไว้ก็คือการรอจริง และทุกตัวเลขที่นี่นับแบบนั้น รวมถึง series Actual wait บนกราฟ และ percentile ใน Reports joined คือเวลานั้น passed คือเวลาที่เขาได้ผ่านเข้าไป ค่านี้ยังอยู่แม้บัตรผ่านหมดอายุแล้ว ถ้าเขาเข้าไปตอนที่ระบบยังเป็นรุ่นเก่า พอบัตรผ่านหมดอายุ ระบบจะไม่รู้เวลานี้ หน้าจอจะขึ้นว่า passed: unknown ไม่เดาให้
หลังการ reclaim (เจ้าของเอา session คืนจาก tab หรืออุปกรณ์ที่สอง) passed และ waited ยังแสดงเวลาที่ถูกปล่อย ครั้งแรก ไม่ใช่เวลาที่ reclaim ออก pass ให้ใหม่
เมื่อ engine จบกับ ticket แล้ว (pass หมดอายุ และ ไม่มี session ที่ใช้อยู่) state จะเป็น expired และยังเก็บ joined, passed และ waited ไว้ รวมถึงผู้เข้าชมที่ถูกปล่อยแล้วแต่ไม่เคย เข้ามา ระบบเก็บ ticket ที่จบแล้ว 50,000 ใบล่าสุดต่อ room ใบเก่าสุดถูกลบก่อน การ eject ลบข้อมูลนี้ทันที
- Share monitor: ลิงก์อ่านอย่างเดียวสำหรับผู้เกี่ยวข้อง มี token สำหรับดูที่จำกัดสิทธิ์ ไม่ใช่
ADMIN_KEYจึงดูได้แต่แก้ไม่ได้ console ถาม server ว่า credential ที่ sign in อยู่ ทำอะไรได้ แล้วซ่อนปุ่มแก้ไขทุกปุ่มตามนั้น มุมมองอ่านอย่างเดียวจึงยังอยู่ แม้ส่วน#monitorหายไปตอนวางลิงก์ และ key แบบviewerที่มีชื่อก็ได้แบบเดียวกัน header บอกว่าคุณ sign in ด้วย credential ตัวไหน
การเปิด Share monitor จะแสดงลิงก์อ่านอย่างเดียวที่ยังใช้ได้อยู่ ไม่สร้างใหม่เอง (กด Mint a new link เพื่อสร้าง) และแสดงในช่องที่เลือกข้อความได้ คัดลอกจากตรงนั้นหรือกดปุ่มข้าง ๆ การสร้างลิงก์ที่สองไม่ยกเลิกลิงก์แรก หน้าต่างจึงบอกไว้ และเสนอลิงก์ที่มีอยู่ก่อน ปุ่ม Copy ในแถวของรายการด้านล่าง เอาลิงก์ที่มีอยู่ กลับมาให้ แทนการสร้างใหม่ จำนวนลิงก์ที่ใช้ได้จึงเท่ากับจำนวนคนที่ควรมี ระบบบันทึก audit เป็น monitor_token.reveal แยกจาก monitor_token.mint เพราะ ส่งให้ใครอีก เป็นคนละคำถามกับ ใครสร้าง ลิงก์ที่ถูกยกเลิกหรือหมดอายุ คัดลอกซ้ำไม่ได้ ไม่อย่างนั้นจะเป็นช่องให้ปลุก credential ที่เพิ่งถูกยึดคืนกลับมา ลิงก์หมดอายุเองหลัง MONITOR_TOKEN_TTL_SEC ค่าเริ่มต้นเจ็ดวัน การยกเลิกเป็นอีกเรื่องหนึ่ง และมีผลทันที การยกเลิก operator จะปิดทุกลิงก์ที่ operator คนนั้นแชร์ไว้ (ดู Operator, role และ audit trail)
- Alerts: การแจ้งเตือนบน desktop เมื่อ traffic เริ่มพุ่ง, target URL ล่ม และการปล่อยคนหยุดนิ่ง
- Diagnostics: รายงานสุขภาพที่อธิบายไว้ด้านบน
ปุ่มอยู่ตรงไหน แถบ header มีแค่สิ่งที่ต้องใช้ระหว่างคิวกำลังเดิน: Alerts, Share monitor, Security, Diagnostics และ + New room ส่วนสิ่งที่ตั้งค่า ระหว่าง เหตุการณ์ อยู่ใต้ Console อีกหนึ่งคลิก ได้แก่ Reports, Access, Docs และ Sign out เมนูเปิดทับบนหน้า ไม่แทรกลงไปในหน้า การเปิดเมนูจึงไม่ทำให้ปุ่ม ที่คุณกำลังจะกดขยับ ถ้าเป็น credential แบบอ่านอย่างเดียว ทั้งแถบและเมนูจะเหลือแค่สิ่งที่ credential นั้นอ่านได้ ซึ่งสำหรับลิงก์ monitor ที่แชร์คือ Security และ Reports
console ไม่เคยเก็บ ADMIN_KEY: การ sign in แลก key ครั้งเดียวเป็น session cookie qm_console แบบ HttpOnly (POST /api/admin/session ดูหมายเหตุ QM-349 ด้านบน) และ Sign out (ใต้ Console) ส่ง DELETE /api/admin/session ซึ่งล้าง cookie และยกเลิก session ฝั่ง server
ใช้งานด้วยคีย์บอร์ด
console ที่มีงานเยอะมีจุดที่ Tab ไปหยุดเกินร้อยจุด และการ์ด room หนึ่งใบมีราวสิบแปดจุด การกด Tab ไปถึง room ที่หกจึงไม่ใช่ทางเลือกจริง
- ลิงก์ข้าม
Tabครั้งแรกบนหน้าจะมี Skip to rooms และ Skip to chart ซ่อนอยู่นอกจอจนกว่าจะได้ focus - ข้าม room จุดแรกในการ์ด room ทุกใบคือ **Skip past name to the next room** การเลื่อนลงไปในรายการจึงใช้
Enterหนึ่งครั้งต่อ room แทน Tab สิบแปดครั้ง ปุ่มข้ามของการ์ดใบสุดท้ายไปที่กราฟ ปลายทางคำนวณตอนกด จึงตามลำดับการ์ดสดเสมอ - ปุ่มหมุน rate
←/→ขยับทีละ 10/min,PageUp/PageDownทีละ 50/min และShift+←/→ทีละ 1/min ช่องตัวเลขข้าง ๆ ยังใช้พิมพ์ค่าที่แน่นอนได้ (ขั้นจริงข้างในคือ 1 rate ที่ server ส่งมาจึงแสดงตรงเสมอ แค่ปุ่มลูกศรกระโดดทีละช่วง) - เมนู Console
Enterหรือ↓เปิดเมนู↑/↓เลื่อนและวนรอบHome/Endไปหัว/ท้ายEscapeปิดและคืน focus ไปที่ปุ่มTabออกจากเมนูและปิด ไม่วนอยู่ข้างใน - ปิดหน้าต่างแล้วกลับไปที่เดิม ปิดหน้าต่าง (ด้วย
Escape,Closeหรือคลิกข้างนอก) focus จะกลับไปที่ปุ่มที่เปิดหน้าต่างนั้น รวมถึงหน้ายืนยันที่เปิดจากในแผง:Escapeครั้งแรกกลับไปที่ปุ่มในแผง ครั้งที่สองกลับไปที่ปุ่มที่เปิดแผง ถ้า room ของหน้าต่างนั้น ถูกลบไประหว่างนั้น focus จะไปที่หัวการ์ดของ room นั้น หรือที่รายการ room ไม่มีวันไปอยู่ที่ว่างเปล่า
Metrics และ monitoring
| Endpoint | รูปแบบ | ต้องยืนยันตัว |
|---|---|---|
GET /metrics | ข้อความสำหรับ Prometheus | ADMIN_KEY |
GET /api/v1/admin/metrics?roomId=&range= | JSON แบบอนุกรมเวลาสำหรับกราฟ | ADMIN_KEY |
GET /api/admin/health | JSON สุขภาพ การตั้งค่า และคำเตือน (รวม startedAt เวลาจริง) | ADMIN_KEY |
POST /api/admin/shutdown | หยุด instance ที่ตอบแบบเรียบร้อย ทางเดียวที่ Windows มีเมื่อไม่ได้รันในหน้าต่าง console | owner / ADMIN_KEY |
GET /healthz | Readiness: 200 ขณะ ready, 503 เมื่อที่เก็บข้อมูลพัง (STORE=memory; บน STORE=valkey เฉพาะเมื่อ instance นี้ล้มเหลวขณะที่ instance อื่นปกติ), process กำลังปิด หรือ instance นี้ถึง MAX_CONNECTIONS | ไม่ต้อง |
GET /api/version | ข้อมูลชุดเดียวกัน ตอบ 200 เสมอ | ไม่ต้อง |
/metrics ไม่ใช่ ของสาธารณะ ถ้าไม่มี key จะตอบ 401 เพราะ room id และความยาวคิวสด ๆ เป็นข้อมูลอ่อนไหวทางธุรกิจ ให้ Prometheus ใช้ bearer token:
scrape_configs:
- job_name: queue-manager
metrics_path: /metrics # inline, on the public hostname: /__qm/metrics
authorization: { type: Bearer, credentials: "<ADMIN_KEY>" }
static_configs: [{ targets: ["qm.weekday100.com"] }]
ที่ต้องเขียน metrics_path ไว้ชัด ๆ เพราะ Prometheus ใช้ /metrics เป็นค่าเริ่มต้น และบนชื่อ host สาธารณะของ room แบบ inline path นี้เป็นของเว็บลูกค้า: ด่านคัดผู้เข้าชม จะตอบการ scrape ด้วย 503 และ job กลายเป็น up=0 ทั้งที่ server ปกติดี เป้าหมายของการ scrape คือ host กับ port ซึ่งใส่ prefix ไม่ได้ path จึงเป็นที่เดียวที่บอกได้ ดูตาราง probe ใน Readiness ถ้า scrape process ตรง ๆ (IP ของ pod, port ของ container) ไม่ต้องเปลี่ยนอะไร
series ราย room มี label {room="…"}: qm_room_waiting, qm_room_active_sessions, qm_room_pending_entry, qm_room_occupancy, qm_room_max_concurrent, qm_room_rate_per_minute, qm_room_passed_last_minute, qm_room_oldest_wait_seconds, qm_room_state ทั้ง process: qm_rooms, qm_sse_subscribers, qm_uptime_seconds, qm_shutting_down และตัวนับที่ควรตั้ง alert ได้แก่ qm_storage_write_errors_total, qm_unknown_room_checks_total, qm_blocked_return_urls_total, qm_refused_key_checks_all_total, qm_rate_limited_checks_all_total และ qm_rejected_tokens_all_total
หลาย instance (STORE=valkey) ทุก series มี instance="<QM_INSTANCE_ID>" เพิ่มด้วย และแต่ละ instance รายงานเฉพาะ process ของตัวเอง: ตัวนับ, connection และ SSE stream ของตัวเอง (gauge ราย room อ่านจาก Valkey ที่ใช้ร่วมกัน ทุก instance จึงรายงานคิวเดียวกัน) ให้ scrape ทุก instance แล้วรวมใน Prometheus Prometheus ตั้ง target label instance ของตัวเองด้วย เมื่อใช้ค่าเริ่มต้น honor_labels: false label นี้จะมาเป็น exported_instance ตั้ง honor_labels: true ใน job เพื่อคงชื่อจาก server ไว้ เมื่อใช้ honor_labels: true ชื่อนี้ต้องไม่ซ้ำกันในแต่ละ scrape target: ตั้ง QM_INSTANCE_ID ของแต่ละ instance ไม่ให้ซ้ำกัน หรือไม่ต้องตั้ง (i:<random> สุ่มใหม่ทุกครั้งที่เริ่ม) target สองตัวที่รายงานชื่อเดียวกันจะเขียนลง series เดียวกัน และ Prometheus จะทิ้งหรือปนตัวอย่างของทั้งสอง ถ้า platform เปิด address เดียวให้ทั้ง fleet (DO App Platform) การ scrape จะไปถึง instance ที่ load balancer เลือก ตรงนั้น console คือภาพรวมของ fleet: ทุก instance เขียนตัวนับ origin error, protection และ diagnostic ลง Valkey ทุก 5 วินาที (<prefix>ctr:<id> หมดอายุหลัง 15 วินาที) การ์ด room ใน console และ GET /api/admin/health แสดงผลรวมของ instance ที่ยังทำงานอยู่ ส่วนของ instance ที่หยุดไปจะหายจากผลรวมภายใน 15 วินาที fleet: {instances, partial} ใน health บอกว่าผลรวมครอบคลุมกี่ instance และเป็น partial: true เมื่ออ่านตัวนับของ instance อื่นจาก Valkey ไม่ได้ (ผลรวมจะเป็นของ instance นี้ บวกค่าที่อ่านได้ล่าสุดภายใน 15 วินาที) จำนวน connection และ SSE ใน health ยังเป็นของ instance นี้ อยู่คู่กับ limit ของมันเอง
ตั้ง alert ตาม อัตรา ของตัวสุดท้าย ticket ที่ถูกปฏิเสธทีละนิดคือบุ๊กมาร์กเก่า แต่ถ้ากระโดดขึ้นทีเดียว แปลว่าทุกคนใน room ถูกไล่ออกพร้อมกัน ซึ่งเป็นผลของ signing key ที่ถูกสร้างใหม่ หรือนาฬิกาที่เลื่อน และเป็นความผิดพลาดแบบเดียวที่ไม่มีอาการอื่นให้เห็นเลย ผู้เข้าชมเหล่านั้นไม่เห็น error แค่ถูกย้ายไปท้ายคิวเงียบ ๆ
alert ตัวเดียวที่ห้ามข้ามคือเรื่องที่เก็บข้อมูล: server ที่เขียนต่อท้าย event log ไม่ได้ ต้องหยุดแจก ticket และมันก็หยุด (503 storage_unavailable) แต่คุณควรรู้ก่อนผู้เข้าชม
Alerting (แจ้งเตือนคนที่ไม่ได้ดู dashboard อยู่)
ปุ่ม Alerts ของ console แสดงการแจ้งเตือนบน desktop ในแท็บนั้นเท่านั้น มีประโยชน์ตอนมีคนเฝ้าดู แต่ไร้ประโยชน์ตอนตีสาม เพราะปิดแท็บก็หาย และมีแค่หนึ่งตัวต่อ laptop ของ operator หนึ่งคน
ต้องมี monitor ภายนอกที่ /healthz เสมอ (ดู Readiness) ทุกอย่างด้านล่างทำงาน ใน process ของ queue ถ้า process ล่ม ค้าง หรือติดต่อไม่ได้ จะไม่มีอะไรทำงานเลย ไม่มี webhook ไม่มี resolved ไม่มีอะไรในแผง ตัวที่ปลุกคุณได้ตอนนั้นมีแค่ monitor ภายนอก เพราะเป็น alert ตัวเดียวที่รอดจากความผิดพลาด ที่มันรายงาน
server ตรวจเงื่อนไขด้านล่างตามนาฬิกาของตัวเองทุก ALERT_EVERY_MS ไม่ว่าจะตั้ง webhook หรือไม่ และสิ่งที่กำลังเตือนอยู่จะแสดงใน Diagnostics → Alerting → Firing now ทั้งสองกรณี ถ้าไม่ได้ตั้งอะไร แผงนั้นคือระบบแจ้งเตือน: เงื่อนไขเป็นจริง แต่ไม่มีใครถูกปลุก และแผงบอกไว้ตรง ๆ แบบนั้น เงื่อนไขหลายตัว (queue_frozen, outflow_stalled) ขึ้นกับระยะเวลาต่อเนื่อง ที่ dashboard ไหนก็คำนวณแทนไม่ได้ ที่นี่จึงเป็นที่เดียวที่มันปรากฏ
ตั้ง ALERT_WEBHOOK_URL แล้ว server จะ POST JSON ไปด้วย:
{
"source": "queue-manager",
"event": "outflow_stalled",
"status": "firing",
"severity": "critical",
"roomId": "shop",
"message": "Shop: 412 waiting and nobody let through in the last minute",
"ts": 1730000000000,
"since": 1729999880000,
"text": "[queue-manager] CRITICAL: Shop: 412 waiting and nobody let through in the last minute"
}
text มีไว้ให้ incoming webhook ของ Slack แสดงข้อความอ่านได้โดยไม่ต้องแปลงอะไร ส่วน field ที่มีโครงสร้างมีไว้สำหรับ PagerDuty Events v2, Opsgenie และระบบอื่นที่อ่าน JSON เหตุการณ์ทั้งหมด:
event | ระดับ | เตือนเมื่อ |
|---|---|---|
storage_failing | critical | เขียน event log ไม่ได้ join จึงถูกปฏิเสธ บน STORE=valkey คือการ commit events journal ลง Postgres ที่ล้มเหลวใน alert frame สองรอบภายใน 3 × ALERT_EVERY_MS (commit ที่ล้มเหลวครั้งเดียวไม่เตือน) จนกว่าจะมี commit ที่สำเร็จหลังจากนั้นและผ่านช่วงนั้นไปนับจากความล้มเหลวครั้งล่าสุด เมื่อมีหลาย instance ข้อความจะระบุ instance ที่ storage ล้มเหลว |
valkey_unreachable | critical | เมื่อใช้ STORE=valkey: instance นี้ติดต่อ Valkey ไม่ได้นาน VALKEY_ALERT_AFTER_MS แต่ละ instance ส่งเอง ไม่ใช่ leader |
target_down | critical | probe ของ server เองติดต่อเว็บปลายทางของ room ไม่ได้ |
outflow_stalled | critical | มีคนรอ แต่ไม่มีใครได้เข้าเลยนาน ALERT_STALL_SEC room พยายามปล่อยแต่ไม่สำเร็จ |
queue_frozen | critical | มีคนรอ และ room ถูก pause หรือตั้ง 0/min นาน ALERT_FROZEN_SEC คือ room ที่ pause แล้วลืม room ไม่ได้พยายามปล่อย เพราะคนสั่งให้หยุด และอาจยังไม่กลับมา |
queue_deep | warning | จำนวนคนในคิวถึง ALERT_DEPTH หรือเกิน (ปิดเป็นค่าเริ่มต้น) |
wait_high | warning | เวลารอโดยประมาณถึง ALERT_WAIT_SEC หรือเกิน |
ทุกเงื่อนไขต้องเป็นจริงต่อเนื่อง ALERT_HOLD_SEC ก่อนเตือน เตือนซ้ำทุก ALERT_REPEAT_SEC ตราบที่ยังเป็นจริง และส่ง event "status": "resolved" เมื่อหาย เพราะช่องแจ้งเตือนที่ไม่เคยบอกว่า "จบแล้ว" คือช่องที่ operator จะเรียนรู้ที่จะเมิน
เมื่อใช้ STORE=valkey หลาย instance ทุก instance ตรวจเงื่อนไขเอง Firing now และ GET /api/admin/alerts จึงเหมือนกันไม่ว่าจะถาม instance ใด แต่มีเพียง leader (ดู LEADER_LEASE_MS) ที่ POST ไปที่ webhook สถานะที่ใช้ตัดสินการส่ง (เงื่อนไขเริ่มเมื่อไร เตือนไปเมื่อไร เตือนซ้ำล่าสุดเมื่อไร) เก็บไว้ใน Valkey (<prefix>alerts:state) เมื่อ leader ตายกลางเหตุการณ์ leader ตัวถัดไปจึงไม่เตือน เหตุการณ์ที่เปิดอยู่ซ้ำ และไม่ลืมส่ง resolved อย่าลบ <prefix>leader:epoch ด้วยมือ: สถานะที่เก็บไว้ผูกกับ epoch นี้ ถ้า <prefix>alerts:state ยังอยู่ epoch จะเริ่มใหม่ต่ำกว่าค่าที่บันทึกไว้กับสถานะนั้น และการบันทึกสถานะ alert ทุกครั้งหลังจากนั้นจะถูกปฏิเสธจนกว่า epoch จะเกินค่าที่บันทึกไว้อีกครั้ง (หรือจนกว่าจะลบ <prefix>alerts:state ด้วย) ถ้าจำเป็นต้องรีเซ็ต epoch ให้ลบทั้งสอง key พร้อมกัน (alert ที่ยัง firing อยู่จะถูกเตือนอีกครั้ง) storage เป็นของแต่ละ instance เอง ทุก instance รายงานสถานะ storage ของตนไปที่ Valkey (<prefix>alerts:storage) ทุกรอบ leader เตือน storage_failing เมื่อมี instance ใดรายงานว่าล้มเหลว ภายใน 3 × ALERT_EVERY_MS ที่ผ่านมา และระบุชื่อ instance นั้น (instance ใน /api/admin/health คือชื่อของแต่ละ instance) และส่ง resolved เมื่อทุก instance รายงานว่าปกติแล้วเท่านั้น ขณะติดต่อ Valkey ไม่ได้จะไม่มีการส่งใด ๆ ยกเว้น valkey_unreachable ซึ่งแต่ละ instance ที่ติดต่อ Valkey ไม่ได้ส่งเองหลัง VALKEY_ALERT_AFTER_MS (จึงคาดได้ว่าจะได้หนึ่งครั้งต่อ instance) monitor ภายนอกที่ /healthz ยังคงเป็น alert เดียวที่รอดเมื่อตัว queue เองล่ม บันทึกการส่ง sent และ failed เป็นของ instance ที่ตอบเอง รายการของ leader คือรายการที่มีการส่งอยู่
ts คือเวลาที่ตัดสินส่งการแจ้งเตือนนี้ และ since คือเวลาที่เงื่อนไขเริ่ม ซึ่ง firing และ resolved ของเหตุการณ์เดียวกันมีค่าเดียวกัน resolve อาจถึง webhook ก่อน firing ได้ขณะที่ firing ยังส่งไม่เสร็จ (ALERT_EVERY_MS ต่ำกว่า timeout ของ webhook 5 วินาที หรือผู้รับตอบรับช้า) จึงควรเรียงการแจ้งเตือนตาม ts และจับคู่ด้วย event, roomId และ since
GET /api/admin/alerts รายงานการตั้งค่า สิ่งที่กำลังเตือน และการส่ง 20 ครั้งล่าสุด พร้อมผล HTTP POST /api/admin/alerts (ต้องมีสิทธิ์เขียน) ส่ง event ทดสอบ console ทำแบบนี้จาก Diagnostics → Alerting → Send test alert ซึ่งเป็นทางเดียวที่จะรู้ว่า เส้นทางแจ้งเตือนใช้ได้ ก่อน ถึงตอนที่ต้องใช้ ปุ่มนี้อยู่ในแผงนั้นเสมอ ถ้าไม่ได้ตั้ง ALERT_WEBHOOK_URL ปุ่มจะอยู่แต่กดไม่มีผล และบอกไว้ แทนที่จะไม่มีปุ่มเลย สำหรับผู้อ่านที่กำลังทำตามคำแนะนำนี้เพราะยังไม่ได้ตั้งอะไร ปุ่มถูกปิดใช้งานพร้อมบอกเหตุผลสำหรับ credential ที่ไม่มีสิทธิ์เขียน (share link หรือ key viewer ที่มีชื่อ) ซึ่ง server จะตอบเป็น 403
Durability (ความทนทานของข้อมูล)
เมื่อใช้ STORE=valkey บันทึกถาวรคือ journal events ใน Postgres Valkey เก็บคิวที่กำลังทำงาน และ room ที่สถานะใน Valkey หายหรือย้อนกลับจะถูก rebuild จาก journal (ดู เมื่อ backend ล่ม) เมื่อใช้ STORE=memory ไม่มีอะไรถูกเขียนที่ไหนเลย: การรอทุกข้อด้านล่างผ่านทันที restart แล้วเริ่มจากว่าง และคำตอบ 503 ด้านล่างเกิดขึ้นไม่ได้
- join จะได้รับการตอบรับก็ต่อเมื่อ event การ join ถูก commit ลง journal แล้ว ถ้า commit ไม่สำเร็จ
/api/joinตอบ503 storage_unavailableแทนการแจก ticket ที่ sign แล้วแต่ไม่มีอะไรบันทึกไว้ และ ticket (หรือที่ใน pre-queue) ที่สร้างขึ้นจะถูกดึงคืน: ไม่อยู่ในคิว ไม่นับในqueueMaxPerIp/preQueueMaxPerIpและไม่กลับมาตอน rebuild - กฎเดียวกันใช้กับทุกอย่างที่ restart ต้องไม่ย้อนกลับ (QM-390): การสุ่ม pre-queue, eject, purge และการตั้งค่า room
/api/statusและ/eventsตอบก็ต่อเมื่อผลการสุ่ม ที่มันเปิดเผยถูก commit แล้ว และ eject, purge, สร้าง/แก้/ลบ room, schedule และ flush ตอบ200ก็ต่อเมื่อ event ของมันถูก commit แล้ว ถ้า commit ไม่สำเร็จ จะตอบ503 storage_unavailable(/eventsจะรอส่ง frame แทน) ลอง eject หรือ purge ซ้ำได้อย่างปลอดภัย ถ้าไม่มีอะไรค้างอยู่ การตรวจนี้ใช้แค่การค้นใน map หนึ่งครั้ง - การปล่อยเข้าที่ประตู (admission) ก็ใช้กฎเดียวกันนี้ (QM-431)
/api/check(ทั้งแบบตรงและแบบรับรองแทน) จะตอบpassที่ปล่อยใครเข้า ก็ต่อเมื่อ admission ของ room ถูก commit แล้ว และ gate แบบ inline จะส่งต่อหน้าเว็บหรือ WebSocket upgrade หลังจากนั้นเท่านั้น ถ้า commit ไม่สำเร็จ คำตอบคือ503 storage_unavailable(แบบ inline คือปฏิเสธ upgrade) และ admission ถูกดึงคืน: pass กลับเป็นรอเข้าพร้อม slot และ claim window เดิม การลองใหม่จึงได้เข้าด้วย door key ใหม่ แทนที่จะได้session_elsewheresnippet และ connector จะปล่อยผ่านเมื่อได้503นี้ เหมือน 5xx อื่น ๆ การรอนี้แยกตาม room และแยกจากตัวที่/api/statusและ/eventsใช้ สองตัวนั้นจึงไม่ต้องรอประตู - join หรือ admission ที่ถูกปฏิเสธ ยังอาจกลับมาเป็น "ของกำพร้า" (orphan) ได้ ถ้า process ตายก่อน commit event ชดเชย หรือ commit นั้นไม่สำเร็จเหมือนกัน ticket กำพร้าหมดไปเหมือนแท็บที่ปิด: นับใน
queueMaxPerIpจนถึงหัวคิว ถูกเลื่อนขึ้นโดยไม่ใช้ slot เมื่อไม่เห็นเจ้าของนานpresenceSecและหมดอายุพร้อม pass ของมัน ที่ใน pre-queue ที่กำพร้านับในpreQueueMaxPerIpจนถึงเวลาเปิด แล้วถูกสุ่มเป็น ticket แบบนั้น admission ที่กำพร้าถือ slot ไว้จน session หมดเวลา และผู้เข้าชมของมันซึ่ง admission ไม่รู้ door key จะได้session_elsewhereและต่อคิวใหม่ เว้นแต่ cookie คิวจะดึงที่คืนมาได้ - ความเป็นเจ้าของ ticket, admission และ ejection อยู่ใน journal ทั้งหมด
kill -9จึงเปลี่ยน token ที่รั่วให้เป็นการเข้าฟรีไม่ได้ GET /api/admin/healthแสดงstorage.ok,storage.writeErrors(เมื่อใช้STORE=valkeyคือจำนวน commit ของ journal ที่ล้มเหลว) และ error ล่าสุด ตั้ง alert จากค่านี้ หรือจาก alertstorage_failing- checkpoint ทำให้ rebuild สั้น instance หนึ่งตัวต่อครั้งจะรวม journal ของ room ที่มี event มาก เป็นแถวใน
room_snapshotsrebuild จึงเล่นซ้ำแค่ checkpoint ล่าสุดกับ event หลังจากนั้น แทนทั้งชีวิตของ room checkpoint อ่านจาก Postgres อย่างเดียว ไม่อ่านจาก Valkey จึงตรงกับ journal เสมอ - journal มีการป้องกันการถอยกลับแบบราย event: rebuild ที่เจอ event ประเภทที่ build นี้ไม่รู้จัก หรือ event ที่มีรูปแบบใหม่กว่า (
lv, ไม่มี = 1) จะล้มเหลวแทนการข้าม และ room ยังถูกกั้นไว้ จนกว่า build ที่อ่านได้จะ rebuild สำหรับคนที่จะเปลี่ยนรูปแบบ: event ชนิดใหม่ต้องได้ type ใหม่ ส่วนการเปลี่ยนความหมายของ type ที่มีอยู่ ให้ประทับ event เหล่านั้นด้วยlvถัดไป (EVENT_LOG_VERSIONในlib/engine.js) - ข้อมูลรายชั่วโมงเป็นหนึ่งแถวต่อ room ต่อชั่วโมงใน Postgres leader เขียนชั่วโมงที่ยังไม่จบใหม่ ทุกครั้งที่เก็บตัวอย่าง และแถวที่เก่ากว่า
HISTORY_RETAIN_DAYSถูกลบ ถ้าอ่าน warehouse ไม่ได้ตอนเริ่ม ระบบจะเขียน log ([history]) และไม่ยอมเริ่ม แทนที่จะเริ่มบนสถานะว่างแล้วเขียนทับ
นาฬิกา
การปล่อยคนเข้าใช้นาฬิกาที่เดินไปข้างหน้าอย่างเดียว (monotonic) การแก้เวลาของ NTP จึงไม่ทำให้การปล่อยค้างหรือพุ่ง ส่วนเวลาที่บันทึกใช้นาฬิกาจริง แต่ไม่เคยถอยหลัง ซึ่งทำให้ log และลำดับการลบรายการเก่าตรงกัน
สิ่งที่นาฬิกากระโดด ทำให้เสีย คือ ticket ticket ทุกใบบันทึกเวลาที่ออก และถูกปฏิเสธ ถ้าออกในอนาคตเกินหนึ่งนาที หรือเก่ากว่า TOKEN_MAX_AGE_SEC (ค่าเริ่มต้น 24 ชั่วโมง) การตรวจนี้คือสิ่งที่กันไม่ให้เอา ticket ของสัปดาห์ที่แล้วมาใช้ซ้ำ และต่อรองไม่ได้ ดังนั้นเครื่องที่นาฬิกาเลื่อน (VM ที่บูตด้วย RTC ผิดก่อน NTP จะแก้, เครื่องที่กลับมาจาก snapshot, container ที่ได้นาฬิกาผิดมา) จะปฏิเสธ ticket ทุกใบในคิวพร้อมกัน และผู้เข้าชมทุกคนนั้นจะไปต่อท้ายคิวใหม่แบบเงียบ ๆ
server กันเรื่องนี้ไม่ได้ จึงบอกไว้แทน: การกระโดดเกินห้าวินาทีจะถูกเขียน log, แสดงใน incidents ไปอีกหนึ่งชั่วโมง และตั้ง status เป็น degraded ใน /healthz probe ยังตอบ 200 เพราะการถอด pod ออกไม่ได้ซ่อมนาฬิกาของเครื่อง (ดู Readiness) ดู qm_rejected_tokens_all_total เพื่อรู้ว่ากระทบมากแค่ไหน เปิด NTP บนเครื่องไว้เสมอ และตั้งให้ปรับเวลาแบบค่อย ๆ เลื่อน (slew) ดีกว่ากระโดด (step)
ไม่พึ่งพาอินเทอร์เน็ตสาธารณะ
ทุกหน้าที่ server นี้แจก (console, หน้ารอ และ /docs) แสดงผลได้ครบ โดยไม่ต้องขออะไรจาก origin อื่นเลย ไม่มี CDN ไม่มี analytics ไม่มี stylesheet หรือ script จากภายนอก รันทั้ง product บนเครือข่ายที่ตัดขาดจากภายนอก หลัง proxy ของบริษัทที่บล็อกทุกอย่างที่ไม่อยู่ในรายการ หรือระหว่าง DNS ล่ม ซึ่งเป็นเหตุผลที่มีคนเปิด console ตั้งแต่แรก ก็ยังหน้าตาเหมือนเดิมทุกอย่าง
เรื่องนี้สำคัญพอที่จะเลิกโหลดฟอนต์จาก Google Fonts Geist และ Geist Mono อยู่ใน public/fonts/ ของ repo และ process นี้แจกที่ /fonts/<file>.woff2:
| ไฟล์ | ฟอนต์ | ชุดตัวอักษร |
|---|---|---|
geist-latin.woff2 | Geist, variable 400–700 | latin |
geist-latin-ext.woff2 | Geist, variable 400–700 | latin-ext |
geist-mono-latin.woff2 | Geist Mono, variable 400–600 | latin |
geist-mono-latin-ext.woff2 | Geist Mono, variable 400–600 | latin-ext |
- รวมราว 82 KB และหน้าเว็บโหลดเฉพาะชุดที่ใช้แสดงจริง หน้ารอภาษาอังกฤษโหลดสองไฟล์ ไม่ใช่สี่
- ส่งด้วย
Cache-Control: public, max-age=604800การ reload dashboard กลางเหตุการณ์จึงไม่โหลดใหม่ เป็น asset ตัวเดียวที่นี่ที่เป็นเนื้อหาล้วน ๆ เหมือนกันทุกระบบ และไม่มีข้อมูลอะไรของระบบคุณ - ตั้งใจส่งแบบไม่บีบอัด woff2 บีบอัดด้วย Brotli อยู่ข้างในแล้ว gzip ซ้ำเปลือง CPU และได้ไฟล์ใหญ่ขึ้น
- ละตินเท่านั้น ภาษาไทยและตัวอักษรที่ไม่ใช่ละตินทุกแบบ ใช้ฟอนต์ของระบบ เหมือนตอนใช้ CDN ซึ่งชุดตัวอักษรก็ไม่มีภาษาไทยเช่นกัน หน้ารอภาษาไทยแสดงผลถูกต้อง แค่ใช้ฟอนต์ของเครื่องผู้เข้าชม
- สัญญาอนุญาต SIL OFL 1.1 (Vercel / basement.studio) ไฟล์สัญญาอนุญาตอยู่คู่กับฟอนต์ใน
public/fonts/LICENSE.txtต้องเก็บไว้ที่นั่น เพราะการแจกจ่ายต่อบังคับให้มี
อัปเดตโดยแทนที่ไฟล์ทั้งสี่ ใช้ชื่อเดิม และรัน test ใหม่: test/surfaces.test.js ดึงแต่ละไฟล์ผ่าน HTTP แล้วตรวจ magic wOF2 และจำนวน byte ไฟล์ที่ขาดหรือพิมพ์ผิดจึงทำให้ test ล้ม แทนที่จะกลายเป็น Arial เงียบ ๆ บน production
การรัน HTTP test
ทุกชุด test แบบ HTTP เริ่ม server ผ่าน startServer() ใน test/_harness.js ซึ่งรัน node server.js ด้วย PORT, HOST=127.0.0.1, ADMIN_KEY, SECRET, ตัวปรับ limit ตั้งเป็น 0 (ปิด) และอะไรก็ตามที่ชุด test เพิ่ม (ดูตารางตัวแปรด้านบน) server แต่ละตัวได้ directory ชั่วคราวของตัวเอง (dataDir) ตั้งแต่ step 5 ไม่มีอะไรถูกเขียนลงไป มันใช้ตั้งชื่อการรันบน Valkey ด้านล่าง restart บน dataDir เดิมจึงเห็นสถานะเดิม
บน Valkey store ถ้า environment ของ process ที่รัน test มี STORE=valkey (และตั้ง TEST_VALKEY_URL, TEST_DATABASE_URL ไว้) startServer() จะรัน server ทุกตัวบน STORE=valkey + SIDESTORE=pg: ใช้ Valkey สำหรับ test ภายใต้ key prefix ของตัวเอง และ schema Postgres ของตัวเองต่อ dataDir หนึ่งตัว ทั้งสองถูกลบหลังจบไฟล์ ไม่ใช้ VALKEY_URL หรือ DATABASE_URL จาก environment เลย และปฏิเสธ Valkey db 0 กับฐานข้อมูลที่ชื่อไม่ลงท้ายด้วย _test:
STORE=valkey npx vitest run test/conformance-http.test.js
ชุด test ไหนผ่านบน Valkey แล้ว และทำไมชุดอื่นยังไม่ผ่าน ดูที่ docs/superpowers/plans/2026-09-25-valkey-step3a-http.md test ที่มีไว้ตรวจว่าสถานะอยู่รอดหลัง restart (test/_valkey-only.js) รันบน Valkey เท่านั้น เพราะ memory mode ไม่เก็บอะไรข้ามการ restart: ถ้าไม่มี TEST_VALKEY_URL และ TEST_DATABASE_URL test เหล่านี้ถูกข้าม โดยมีคำเตือนหนึ่งครั้งต่อไฟล์ที่บอกชื่อ test ที่ข้ามทุกตัว ถ้าตั้ง QM_REQUIRE_VALKEY=1 (CI) test เหล่านี้จะล้มเหลวแทน
รันกับคำสั่ง server อื่น QM_SERVER_CMD (ใช้ใน test เท่านั้น อ่านโดย test/_harness.js) ใช้แทน node server.js เพื่อรันชุด HTTP test เดิมกับ implementation หรือ build อื่น คำสั่งถูกแยกด้วยช่องว่าง (เครื่องหมายคำพูดคู่รวมเป็น token เดียว) และ spawn โดยไม่ผ่าน shell ด้วย environment เดียวกับด้านบน ต้องเป็น process ของ server เอง ไม่ใช่ wrapper เพราะชุด test kill มันและอ่าน exit code และต้องพิมพ์บรรทัดที่มีคำว่า listening ลง stdout เมื่อพร้อมรับการเชื่อมต่อ ถ้าไม่ตั้ง harness จะรัน node server.js ของ repo นี้
test:conformance รันเฉพาะไฟล์ที่คุยกับ server ผ่าน HTTP อย่างเดียว: ไม่มี require('../lib/...') ไม่ตรวจ stderr และไม่ถอดหมายเลข ticket ออกจาก token ไฟล์เหล่านั้นคือ abuse, admin-host-gate, conformance-http, contract, edge-connector, failover, forwarded-proto-trust, forwarded-trust, identity, origin-down, prequeue-forwarded, proxy, snippet, stream-generation, surfaces, timeline และ visitor-support ไฟล์ที่มีส่วนทดสอบแบบดูข้างในแม้แค่บล็อกเดียว ไม่ถูกรวม test แบบดูข้างในจึงต้องแยกไปไว้ใน <name>.internal.test.js คู่กัน (แบบที่แยกออกมาจาก proxy, abuse, visitor-support, failover, identity และ edge-connector) รายการอยู่ใน script test:conformance ใน package.json ไฟล์ใหม่ที่ผ่านเกณฑ์ให้เพิ่มไว้ที่นั่น