ระบบสตรีมมิ่งที่รับผู้ใช้จริงทุกวัน ไม่ได้ล่มเพราะโค้ดพัง แต่ค่อย ๆ ชนเพดานทีละด้าน — ฐานข้อมูลที่ scale ไม่ขึ้น, worker ที่กิน CPU จนเครื่องหายใจไม่ออก และ proxy วิดีโอที่ถือ connection ไว้นานเกินกว่าที่ PHP จะรับไหว
บทความนี้คือบันทึกการย้าย NextSeries — เว็บดูหนังและซีรีส์ไทยแบบไม่มีโฆษณา — จาก Laravel 11 + SQLite ไปเป็น NestJS 11 + PostgreSQL + Drizzle ORM ทั้งระบบ ใช้เวลาประมาณ 2.5 เดือน (439 commits ระหว่าง 30 มิ.ย. – 18 ก.ย. 2026) และระบบรัน production อยู่จริง
เราจะไม่เล่าแบบ success story ที่ทุกอย่างราบรื่น แต่จะเล่าทั้งจุดที่ตัดสินใจถูก และกับดักที่กินเวลามากที่สุด เพราะสองอย่างนั้นคือส่วนที่เอาไปใช้ต่อได้จริง
1. บริบท: ระบบเดิมทำอะไรอยู่
NextSeries เป็นแพลตฟอร์มดูหนัง-ซีรีส์สำหรับผู้ชมไทย ที่ทำงานแบบ ad-free proxy:
- Crawler (Puppeteer/Chromium) ดึงข้อมูลและลิงก์สตรีมจากแหล่งต้นทาง
- Backend แปลงไฟล์
.m3u8playlist และ rewrite URL ของ segment.tsทั้งหมดให้ชี้กลับมาที่เซิร์ฟเวอร์เรา - เซิร์ฟเวอร์ โหลด segment จากต้นทาง แล้วส่งต่อให้ผู้ชม — ตัดโฆษณาและ tracker ที่ฝังมาในสตรีมทิ้งไปในจังหวะนั้น
💡 จุดที่ 3 คือหัวใจ และเป็นจุดที่กำหนดว่า stack ไหนจะอยู่รอดในระยะยาว
2. ทำไม Laravel + SQLite ไปต่อไม่ได้
1) SQLite ไม่ใช่ฐานข้อมูลสำหรับงานนี้
SQLite เหมาะกับงาน read-heavy ที่ผู้เขียนมีคนเดียว ตอนออกแบบแรกมันตอบโจทย์ดีมาก — ไฟล์เดียว, backup ง่าย, ย้ายเครื่องง่าย
แต่พอระบบเริ่มมี: crawler เขียนข้อมูลพร้อมกันหลาย worker, admin panel เขียนระหว่างที่ผู้ใช้กำลังอ่าน, และมีรายงาน analytics ที่ query หนัก ๆ — ข้อจำกัดเรื่อง single-writer กลายเป็นคอขวดที่แก้ยังไงก็ไม่หมด
ยิ่งไปกว่านั้น โมเดล multi-tenant (1 backend → N frontends) ที่เราวางไว้ใน roadmap แทบเป็นไปไม่ได้เลยกับไฟล์ฐานข้อมูลที่อยู่เครื่องเดียว
2) PHP-FPM กับ video proxy ไม่เข้าขากัน
การ proxy วิดีโอหมายถึง request ที่ เปิดค้างไว้เป็นนาที ขณะที่โหลดและส่งต่อ segment ต่อเนื่อง
PHP-FPM ถูกออกแบบมาสำหรับ request ที่จบเร็ว จำนวน process จำกัด และแต่ละ process จอง memory ไว้เต็มที่ตลอดช่วงเวลาที่ทำงาน — ผลคือ proxy ไม่กี่สิบคนพร้อมกันก็กิน worker pool จนคำขอปกติของเว็บไม่ได้รับบริการ
Node.js ทำงานตรงข้าม: event loop เดียวจัดการ connection ที่เปิดค้างได้หลายพัน โดยไม่ต้องจอง thread ต่อ request — ตรงกับงาน streaming พอดี
3) งานหนักต้องแยกออกจากเครื่องที่ให้บริการผู้ชม
Crawler ด้วย Chromium กิน RAM และ CPU สูงมาก ถ้ารันบนเครื่องเดียวกับที่ส่งวิดีโอให้ผู้ชม ผู้ชมจะรู้สึกได้ทันทีตอน crawler ทำงาน เราต้องการแยก งานหนัก ออกจาก งานที่ต้องเสถียร อย่างชัดเจน ซึ่ง Laravel queue บนเครื่องเดียวตอบไม่ได้
3. สถาปัตยกรรมใหม่ (2 VPS + Edge)
[ผู้ชม]
│ HTTPS
▼
[Cloudflare Pages — Next.js 16 (React 19) / OpenNext, Edge runtime]
│ /api/v1
▼
[Nginx Reverse Proxy]
│ :3000
▼
[Backend VPS — nextback (185.246.191.232)]
├── netflip-backend NestJS 11 :3000
├── netflip-postgres PostgreSQL 16 :5432 (localhost only)
└── netflip-redis Redis 7 :6379 (localhost only)
▲
│ BullMQ jobs
│
[Scraper VPS — nextfloki (185.246.191.231)]
└── Puppeteer / Chromium workers| ชั้น | เทคโนโลยี | เหตุผลที่เลือก |
|---|---|---|
| Backend | NestJS 11 (TypeScript) | โครง module ชัดเจน, DI, เหมาะกับ streaming และ queue |
| ORM | Drizzle ORM | type-safe, migration ชัดเจน, ไม่มี magic ซ่อนหลังฉาก |
| Database | PostgreSQL 16 | multi-writer จริง, พร้อมสำหรับ multi-tenant |
| Queue | BullMQ + Redis 7 | แยกงานหนักข้ามเครื่องระหว่าง VPS สองตัวได้ราบรื่น |
| Crawler | Puppeteer 25 | ทำงานบน Scraper VPS แยกเด็ดขาด ไม่กระทบผู้ชม |
| Frontend | Next.js 16 + OpenNext | รันบน Cloudflare Pages กระจายโหลดทั่วโลก |
| Observability | Pino + Sentry | log มีโครงสร้าง, เห็น error ก่อนที่ผู้ใช้จะรายงาน |
Backend ประกอบด้วย 10 modules หลัก: auth, movies, streams, import, admin, settings, tenant, chat, hermes, devops
4. กับดัก 5 ข้อที่กินเวลามากที่สุด
1Cloudflare Edge runtime crash ตอน deploy ครั้งแรก
อาการคือหน้าเว็บล่มทันทีด้วย error ที่อ่านไม่รู้เรื่อง:
TypeError: Cannot read properties of undefined (reading 'require')
TypeError: Cannot read properties of undefined (reading 'react-rsc')สาเหตุคือ Next.js 16 ที่ build ด้วย OpenNext ต้องใช้ module.prototype ซึ่ง runtime ของ Cloudflare ไม่ได้จำลองไว้ สิ่งที่ต้องทำมี 3 อย่างพร้อมกัน และขาดอันใดอันหนึ่งไม่ได้:
- ตั้ง Compatibility date เป็น
2026-07-10หรือใหม่กว่า ในหน้า Cloudflare Pages Settings → Runtime - เปิด flag
nodejs_compatทั้งในwrangler.jsoncและในแดชบอร์ด - ฉีด shims ที่หัวไฟล์
dist/_worker.jsก่อน deploy ทุกครั้ง (ในโปรเจกต์เราทำผ่านcopy-pages.jsซึ่งจะเซ็ตprocess.env.NEXT_RUNTIME = 'edge'และจำลองmodule.prototype)
⚠️ บทเรียน: ทั้งสามข้อเป็นเงื่อนไข AND ไม่ใช่ OR — ตั้ง compat date อย่างเดียว หรือเปิด flag อย่างเดียว ยัง crash อยู่ดี
2Video proxy ต้อง stream จริง ไม่ใช่ buffer
กับดักที่ทำให้ RAM พุ่งกระฉูดคือการเขียน proxy แบบ "ดาวน์โหลดให้ครบทั้งก้อนก่อน แล้วค่อยส่ง" ซึ่งฟังดูปกติแต่ผิดหลักการสตรีมมิ่งอย่างสิ้นเชิง
Segment เดียวอาจใหญ่หลาย MB และผู้ชมหนึ่งคนโหลดหลายร้อย segment ต่อตอน — ถ้า buffer ไว้ในหน่วยความจำก่อนส่ง RAM จะพุ่งตามจำนวนผู้ชมพร้อมกันแบบทบต้นจน Node process โดน OOM Kill ทันที
✅ ทางออก: pipe stream ตรงจาก upstream ไปยัง response โดยไม่เก็บลง memory และให้ backpressure ของ Node.js จัดการจังหวะเอง
3Bandwidth เป็นสองเท่าโดยธรรมชาติ
สถาปัตยกรรม proxy หมายความว่าเราทั้ง โหลด segment จากต้นทาง และ ส่ง ต่อให้ผู้ชม — ใช้ bandwidth สองเท่าต่อผู้ชมหนึ่งคน
เราแก้ด้วย segment cache บนดิสก์: segment ที่เพิ่งโหลดมาและมีโอกาสถูกเรียกซ้ำ (เช่น ตอนผู้ใช้ seek กลับ หรือมีคนดูตอนเดียวกันพร้อมกัน) ถูกเก็บลงดิสก์พร้อม LRU auto-eviction แทนการดึงซ้ำ
📌 อีกจุดสำคัญ: segment ต้องถูก bypass ออกจาก Cloudflare cache (ผ่าน Cache Rules) เพราะเป็นไฟล์วิดีโอขนาดใหญ่ที่ผิดเงื่อนไขการใช้งานแพลนฟรี และเสี่ยงเจอ 524 gateway timeout
4ย้ายข้อมูล SQLite → PostgreSQL แบบ 1:1
เราตัดสินใจ ไม่ redesign schema ในการย้ายรอบแรก แต่ยกโครงเดิม 22 ตารางมา 1:1 ก่อน
ผลคือ schema ยังมีคอลัมน์ legacy ติดมาด้วย เช่น file_main, file_openload, file_streamango ที่เป็นชื่อผู้ให้บริการสตรีมยุคเก่า — ดูไม่สวย แต่มีเหตุผลสำคัญ: ทำให้เทียบข้อมูลเก่า-ใหม่ได้ตรงบรรทัด และลดจำนวนตัวแปรที่จะพังพร้อมกันในวัน deploy
เราเก็บงาน "ทำ schema ให้สะอาด" ไว้เป็นงานแยกหลังระบบนิ่งแล้ว
5ความเข้ากันได้ย้อนหลังของ API
Frontend และ crawler เป็นคนละส่วนที่ deploy คนละจังหวะ จึงต้องมีช่วงที่ระบบเก่ากับใหม่คุยกันได้
เราแก้โดยให้ path เดิมอย่าง /api/movies ถูก rewrite ไปที่ /api/v1/movies ภายใน backend — ทำให้ client เก่ายังทำงานได้ระหว่างการเปลี่ยนผ่าน แล้วค่อยทยอยย้าย client ทีละตัว
5. ผลลัพธ์หลังการย้ายระบบ
Crawler, admin panel และ analytics เขียนพร้อมกันได้โดยไม่ต้องจัดคิวแย่ง single lock แบบ SQLite
Request ที่เปิดค้างยาวหลายนาทีไม่กิน process pool แบบที่ PHP-FPM ทำ ใช้หน่วยความจำคงที่
Crawler ที่กิน CPU/RAM ทำงานบน Scraper VPS อีกเครื่อง ไม่กระทบคุณภาพการเล่นของผู้ชมแม้แต่น้อย
Backend เดียวรองรับหลาย frontend ผ่าน tenant module และ NEXT_PUBLIC_SITE_KEY
6. บทเรียน 5 ข้อสำหรับการย้าย Stack
ย้ายทีละชั้น ไม่ใช่ทีละ feature
ย้ายฐานข้อมูลให้เสร็จก่อน แล้วค่อยปรับโค้ด ดีกว่าทำพร้อมกันทั้งสองด้านจนไม่รู้ว่าจุดไหนพัง
คง schema เดิมไว้ก่อน
การ redesign schema พร้อมกับเปลี่ยน framework ทำให้ debug ยากขึ้นเป็นเท่าตัว ยอมรับ legacy ชั่วคราว
เลือก stack ตามรูปร่างของ workload
Video proxy เป็น long-lived connection ซึ่งเป็นจุดที่ Node ชนะ PHP อย่างชัดเจน ไม่ใช่เพราะ Node "ใหม่กว่า"
Runtime ที่ไม่ใช่ Node ต้องมี shim
ถ้า deploy บน Edge ให้ถือว่าต้องมีชั้นปรับความเข้ากันได้ และต้องทดสอบ deploy จริงตั้งแต่วันแรก
แยกงานหนักออกจากงานที่ต้องเสถียร
การแยก 2 VPS (Backend vs Scraper) เป็นการตัดสินใจเชิงสถาปัตยกรรมที่ให้ผลตอบแทนสูงที่สุดในโปรเจกต์นี้
สรุป
การย้ายจาก Laravel + SQLite มาเป็น NestJS + PostgreSQL ไม่ใช่เพราะ Laravel ไม่ดี — แต่มันถูกออกแบบมาสำหรับ workload คนละแบบกับที่ระบบ NextSeries เติบโตไปเป็น
ถ้าคุณกำลังเจออาการเดียวกัน — ฐานข้อมูลที่เขียนพร้อมกันไม่ได้, worker ที่กิน CPU จนเว็บช้า, หรือ request ที่เปิดค้างนานจน process pool หมด — นั่นคือสัญญาณว่า stack เดิมชนเพดานแล้ว ไม่ใช่แค่ต้อง tune ให้ดีขึ้น
ทีมวิศวกรรม NextSeries
Core Platform & DevOps Team
ทีมวิศวกรรมผู้พัฒนา NextSeries ดูแลตั้งแต่การออกแบบระบบ Video Proxy, การจัดการโครงสร้างพื้นฐาน 2 VPS บน Flokinet ไปจนถึงการพัฒนาหน้าบ้าน Next.js บน Cloudflare Pages