การดู : 0

12/04/2026 18:16น.

Golang The Series EP 134: Load & Stress Testing ทดสอบระบบ WebSocket ให้ถึงขีดจำกัดด้วย k6

Golang The Series EP 134: Load & Stress Testing ทดสอบระบบ WebSocket ให้ถึงขีดจำกัดด้วย k6

#Golang

#WebSocket

#k6

#Load Testing

#Stress Testing

ยินดีต้อนรับชาว Gopher ทุกท่านครับ! หลายคนมักจะมั่นใจว่า "โค้ดฉันเขียนมาดี Scale ได้แน่นอน" แต่เชื่อไหมครับว่าในสถานการณ์จริง WebSocket Server มักจะมีปัญหาแปลกๆ โผล่ออกมาเมื่อเจอ Traffic จำนวนมหาศาล เช่น Memory LeakFile Descriptors เต็ม หรือ Context Switch ที่พุ่งสูงจน CPU ทำงานไม่ทัน

 

วันนี้เราจะมาสวมบทเป็น "ผู้ทำลาย" เพื่อพิสูจน์ว่าระบบที่เราสร้างมาตั้งแต่ EP แรกๆ นั้น จะยังคงยืนหยัดอยู่ได้หรือจะพังทลายเมื่อเจอกับพายุ Traffic ครับ!

 

1. Load, Stress และ Soak Testing: ต่างกันอย่างไร?

 

ก่อนจะเริ่ม "ถล่ม" ระบบ เราต้องแยกแยะเป้าหมายให้ชัดเจนครับ:

  • Load Testing: ทดสอบว่าระบบทำงานได้ตาม "SLA" ที่คาดหวังไหม (เช่น รองรับ 10,000 Concurrent Users โดยที่ Latency ยังไม่เกิน 200ms)
  • Stress Testing: การหา "จุดแตกหัก" (Breaking Point) เราจะอัด User เข้าไปเรื่อยๆ จนกว่าระบบจะพัง เพื่อดูว่าส่วนไหนไปก่อนเพื่อน (DB, RAM หรือ Network?) และดูการฟื้นตัว (Self-healing)
  • Soak Testing (สำคัญมากสำหรับ WebSocket): คือการรัน Load ในระดับคงที่เป็นเวลานาน (เช่น 12-24 ชม.) เพื่อดูว่ามีการสะสมของ Memory (Leak) หรือไม่ เพราะ WebSocket เป็นการเชื่อมต่อที่ค้างไว้นาน ปัญหานี้จึงมักไม่โผล่ในการเทสสั้นๆ

 

2. ทำไมต้อง k6 ในการทดสอบ WebSocket?

 

แม้จะมีเครื่องมืออย่าง JMeter หรือ Locust แต่สำหรับชาว Go เราแนะนำ k6 (โดย Grafana) ด้วยเหตุผลดังนี้:

  • Go-powered Performance: ตัวเอนจินเขียนด้วย Go ให้ประสิทธิภาพที่สูงมากในการทำ Concurrency
  • JavaScript Scripting: เขียนสคริปต์ควบคุม Logic ได้ยืดหยุ่น (เหมือนเขียนโค้ดจริงๆ)
  • Native WebSocket Support: จัดการ Lifecycle ของ WebSocket (Open, Message, Close) ได้อย่างแม่นยำ

 

ตัวอย่างโค้ด Go: Instrumentation & Resource Checking

 

Go
package main

import (
	"log"
	"net/http"
	"runtime"
	"syscall"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	// วัดจำนวน Goroutine ที่รันอยู่จริง
	goroutineGauge = promauto.NewGauge(prometheus.GaugeOpts{
		Name: "current_goroutines_count",
		Help: "The total number of current goroutines",
	})
)

func main() {
	// 1. ตรวจสอบขีดจำกัดของระบบ (File Descriptors)
	var rLimit syscall.Rlimit
	if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err == nil {
		log.Printf("Current File Descriptor Limit: %d", rLimit.Cur)
	}

	// 2. เสิร์ฟ Metrics สำหรับ Prometheus/Grafana
	go func() {
		http.Handle("/metrics", promhttp.Handler())
		log.Println("Metrics server started on :2112")
		http.ListenAndServe(":2112", nil)
	}()

	// 3. จำลองการเก็บสถิติ Goroutine ทุกครั้งที่มีการเชื่อมต่อ (ตัวอย่าง)
	go func() {
		for {
			goroutineGauge.Set(float64(runtime.NumGoroutine()))
		}
	}()

	// รัน WebSocket Server ของคุณที่นี่...
}
package main
import (
    "log"
    "net/http"
    "runtime"
    "syscall"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
    // วัดจำนวน Goroutine ที่รันอยู่จริง
    goroutineGauge = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "current_goroutines_count",
        Help: "The total number of current goroutines",
    })
)
func main() {
    // 1. ตรวจสอบขีดจำกัดของระบบ (File Descriptors)
    var rLimit syscall.Rlimit
    if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err == nil {
        log.Printf("Current File Descriptor Limit: %d", rLimit.Cur)
    }
    // 2. เสิร์ฟ Metrics สำหรับ Prometheus/Grafana
    go func() {
        http.Handle("/metrics", promhttp.Handler())
        log.Println("Metrics server started on :2112")
        http.ListenAndServe(":2112", nil)
    }()
    // 3. จำลองการเก็บสถิติ Goroutine ทุกครั้งที่มีการเชื่อมต่อ (ตัวอย่าง)
    go func() {
        for {
            goroutineGauge.Set(float64(runtime.NumGoroutine()))
        }
    }()
    // รัน WebSocket Server ของคุณที่นี่...
}

 

3. ออกแบบ Scenario: จำลองพฤติกรรม User จริง

 

การเทส WebSocket ไม่ใช่แค่การจิ้มแล้วจบ (Hit & Run) แต่คือการ "แช่" การเชื่อมต่อ สิ่งที่คุณต้องจำลองคือ:

  1. Ramping Up: ค่อยๆ เพิ่ม User เพื่อดูอาการของระบบ (Warm-up)
  2. Sustained Load: จำลองการส่ง Heartbeat หรือข้อความ Chat เป็นระยะ
  3. The Storm: การ Broadcast ข้อความพร้อมกันทุกคน (เช่น Notification ส่งหาทุกคน) เพื่อดูแรงกระแทกที่ Server และ Redis Pub/Sub

 

ตัวอย่างสคริปต์ k6 (JavaScript):

 

JavaScript
import ws from 'k6/ws';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 5000 },  // เพิ่มเป็น 5,000 คนใน 2 นาที
    { duration: '5m', target: 5000 },  // แช่ไว้ที่ 5,000 คนเพื่อดูความนิ่ง
    { duration: '1m', target: 0 },     // ค่อยๆ ระบายออก
  ],
};

export default function () {
  const url = 'ws://your-api-gateway.com/ws';
  
  const res = ws.connect(url, null, function (socket) {
    socket.on('open', () => {
      // ส่งข้อความแรกเมื่อต่อติด
      socket.send(JSON.stringify({ type: 'auth', token: 'test-token' }));
      
      // ส่ง Heartbeat ทุกๆ 30 วินาที
      socket.setInterval(() => {
        socket.send(JSON.stringify({ type: 'ping' }));
      }, 30000);
    });

    socket.on('message', (data) => {
      // ตรวจสอบว่าได้รับข้อความตอบกลับถูกต้องไหม
      check(data, { 'is message ok': (d) => d.includes('pong') });
    });

    // จำลองการเชื่อมต่อค้างไว้ 60 วินาทีก่อนจะปิด (เพื่อวนรอบใหม่)
    socket.setTimeout(() => {
      socket.close();
    }, 60000);
  });

  check(res, { 'status is 101': (r) => r && r.status === 101 });
}

 

4. ตัวเลขที่ห้ามละสายตา (Key Metrics)

 

ระหว่างที่ k6 กำลังถล่มระบบ ให้คุณเปิด Dashboard (จาก EP 128) และดูสิ่งเหล่านี้:

  1. File Descriptors (FD): ใน Linux ทุก 1 Connection คือ 1 ไฟล์ เช็คด้วย cat /proc/sys/fs/file-max (ถ้า FD เต็ม ระบบจะรับคนเพิ่มไม่ได้ทันที)
  2. Goroutines Count: จำนวน Goroutine ควรจะสัมพันธ์กับจำนวน User ถ้า User ออกแล้ว Goroutine ไม่ลด แสดงว่ามี Goroutine Leak
  3. Memory Usage (RSS): ดูว่ามีการสะสมของ RAM ไปเรื่อยๆ หรือไม่
  4. Redis Throughput (จาก EP 130): เช็คว่า Redis รับไหวไหมเมื่อมีการ Broadcast หนักๆ

 

5. การ "จูน" เมื่อระบบถึงขีดจำกัด (Tuning Tips)

 

ถ้าเทสแล้วพัง แก้ตรงไหนก่อน?

  • OS Level (ulimit): ปรับค่า nofile ใน /etc/security/limits.conf ให้สูงขึ้น (เช่น 100,000+) ทั้งฝั่ง Server และฝั่งที่รัน k6
  • Go GC Tuning: ปรับค่า GOGC หรือใช้ Memory Ballast เพื่อลดความถี่ในการรัน Garbage Collector เมื่อมี Traffic พุ่งสูง
  • Buffer Reuse: ตรวจสอบว่าได้ใช้ sync.Pool (จาก EP 132) ในจุดที่มีการจอง Memory ซ้ำๆ หรือยัง

 


 

สรุป

 

การทำ Load & Stress Testing ไม่ใช่แค่การหาว่าระบบ "รับได้เท่าไหร่" แต่คือการสร้าง "ความมั่นใจ" ว่าเมื่อถึงวันที่มี Traffic ถล่มทลาย ระบบของคุณจะยังคงทำงานได้ตามปกติ หรือถ้าจะพัง คุณจะรู้ล่วงหน้าและมีแผนรับมือ (Failover) ที่เตรียมไว้แล้วครับ

 

ในตอนหน้า (EP 135): เราจะนำระบบที่ผ่านการทดสอบสุดโหดนี้เข้าสู่กระบวนการ CI/CD Pipeline เพื่อให้การส่งมอบโค้ดเป็นไปอย่างอัตโนมัติและปลอดภัยที่สุด ห้ามพลาดครับ!