You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1238 lines
31 KiB
1238 lines
31 KiB
|
4 weeks ago
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"context"
|
||
|
|
"crypto/tls"
|
||
|
|
"encoding/json"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"math"
|
||
|
|
"math/rand"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"regexp"
|
||
|
|
"runtime"
|
||
|
|
"sort"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gorilla/websocket"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
modeLoadTest = "loadtest"
|
||
|
|
modeFindMaxUsers = "find-max-users"
|
||
|
|
|
||
|
|
labelRoot = "GET / 메인"
|
||
|
|
labelHealth = "GET /health 상태확인"
|
||
|
|
labelWebSocket = "WS /ws 터미널"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
netstatPIDPattern = regexp.MustCompile(`LISTENING\s+(\d+)\s*$`)
|
||
|
|
ssPIDPattern = regexp.MustCompile(`pid=(\d+)`)
|
||
|
|
globalAggregator atomic.Pointer[aggregator]
|
||
|
|
)
|
||
|
|
|
||
|
|
type config struct {
|
||
|
|
mode string
|
||
|
|
targetURL string
|
||
|
|
users int
|
||
|
|
duration time.Duration
|
||
|
|
rampUp time.Duration
|
||
|
|
timeout time.Duration
|
||
|
|
wsTimeout time.Duration
|
||
|
|
pauseMin time.Duration
|
||
|
|
pauseMax time.Duration
|
||
|
|
wsRatio float64
|
||
|
|
wsMaxConn int
|
||
|
|
wsCommand string
|
||
|
|
insecure bool
|
||
|
|
monitorInterval time.Duration
|
||
|
|
|
||
|
|
searchStartUsers int
|
||
|
|
searchStepUsers int
|
||
|
|
searchMaxUsers int
|
||
|
|
maxFailureRate float64
|
||
|
|
maxP95 time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
type scenario int
|
||
|
|
|
||
|
|
const (
|
||
|
|
scenarioRoot scenario = iota
|
||
|
|
scenarioHealth
|
||
|
|
scenarioWebSocket
|
||
|
|
)
|
||
|
|
|
||
|
|
type result struct {
|
||
|
|
name string
|
||
|
|
statusCode int
|
||
|
|
duration time.Duration
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
type aggregator struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
startedAt time.Time
|
||
|
|
finishedAt time.Time
|
||
|
|
total int64
|
||
|
|
success int64
|
||
|
|
failed int64
|
||
|
|
statusCounts map[int]int64
|
||
|
|
scenarioCounts map[string]int64
|
||
|
|
errorCounts map[string]int64
|
||
|
|
latencies []time.Duration
|
||
|
|
scenarioLatencies map[string][]time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
type aggregateSnapshot struct {
|
||
|
|
total int64
|
||
|
|
success int64
|
||
|
|
failed int64
|
||
|
|
}
|
||
|
|
|
||
|
|
type testReport struct {
|
||
|
|
Config config
|
||
|
|
StartedUsers int64
|
||
|
|
Elapsed time.Duration
|
||
|
|
Total int64
|
||
|
|
Success int64
|
||
|
|
Failed int64
|
||
|
|
SuccessRate float64
|
||
|
|
RequestsPerSecond float64
|
||
|
|
AverageLatency time.Duration
|
||
|
|
P50 time.Duration
|
||
|
|
P95 time.Duration
|
||
|
|
P99 time.Duration
|
||
|
|
MaxLatency time.Duration
|
||
|
|
StatusCounts map[int]int64
|
||
|
|
ScenarioCounts map[string]int64
|
||
|
|
ErrorCounts map[string]int64
|
||
|
|
ScenarioStats map[string]scenarioStat
|
||
|
|
Memory serverMemoryStatsSnapshot
|
||
|
|
}
|
||
|
|
|
||
|
|
type scenarioStat struct {
|
||
|
|
Count int64
|
||
|
|
Average time.Duration
|
||
|
|
P95 time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
type socketResponse struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Data string `json:"data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type socketRequest struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Data string `json:"data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type serverMemorySample struct {
|
||
|
|
PID int
|
||
|
|
WorkingSetBytes uint64
|
||
|
|
PrivateBytes uint64
|
||
|
|
}
|
||
|
|
|
||
|
|
type serverMemoryStats struct {
|
||
|
|
Enabled bool
|
||
|
|
LocalTarget bool
|
||
|
|
TargetPort string
|
||
|
|
ProcessName string
|
||
|
|
PID int
|
||
|
|
LastWorkingSet uint64
|
||
|
|
LastPrivate uint64
|
||
|
|
MaxWorkingSet uint64
|
||
|
|
MaxPrivate uint64
|
||
|
|
SampleCount int64
|
||
|
|
LastError string
|
||
|
|
Verbose bool
|
||
|
|
mu sync.Mutex
|
||
|
|
}
|
||
|
|
|
||
|
|
type serverMemoryStatsSnapshot struct {
|
||
|
|
Enabled bool
|
||
|
|
LocalTarget bool
|
||
|
|
ProcessName string
|
||
|
|
PID int
|
||
|
|
LastWorkingSet uint64
|
||
|
|
LastPrivate uint64
|
||
|
|
MaxWorkingSet uint64
|
||
|
|
MaxPrivate uint64
|
||
|
|
SampleCount int64
|
||
|
|
LastError string
|
||
|
|
}
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
cfg := parseFlags()
|
||
|
|
if err := validateConfig(cfg); err != nil {
|
||
|
|
log.Fatalf("잘못된 설정입니다: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
switch cfg.mode {
|
||
|
|
case modeLoadTest:
|
||
|
|
report := runLoadTest(cfg, true, true)
|
||
|
|
printSummary(report)
|
||
|
|
case modeFindMaxUsers:
|
||
|
|
runMaxUserSearch(cfg)
|
||
|
|
default:
|
||
|
|
log.Fatalf("지원하지 않는 실행 모드입니다: %s", cfg.mode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseFlags() config {
|
||
|
|
cfg := config{}
|
||
|
|
|
||
|
|
flag.StringVar(&cfg.mode, "mode", modeLoadTest, "실행 모드(loadtest, find-max-users)")
|
||
|
|
flag.StringVar(&cfg.targetURL, "target", "http://localhost:8080", "테스트 대상 기본 URL")
|
||
|
|
flag.IntVar(&cfg.users, "users", 200, "동시 가상 사용자 수")
|
||
|
|
flag.DurationVar(&cfg.duration, "duration", 30*time.Second, "전체 테스트 지속 시간")
|
||
|
|
flag.DurationVar(&cfg.rampUp, "ramp-up", 10*time.Second, "전체 사용자를 점진적으로 올리는 시간")
|
||
|
|
flag.DurationVar(&cfg.timeout, "timeout", 5*time.Second, "HTTP 요청 타임아웃")
|
||
|
|
flag.DurationVar(&cfg.wsTimeout, "ws-timeout", 15*time.Second, "웹소켓 연결/읽기/쓰기 타임아웃")
|
||
|
|
flag.DurationVar(&cfg.pauseMin, "pause-min", 150*time.Millisecond, "사용자 액션 사이 최소 대기 시간")
|
||
|
|
flag.DurationVar(&cfg.pauseMax, "pause-max", 600*time.Millisecond, "사용자 액션 사이 최대 대기 시간")
|
||
|
|
flag.Float64Var(&cfg.wsRatio, "ws-ratio", 0.05, "전체 시나리오 중 웹소켓 비율(0~1)")
|
||
|
|
flag.IntVar(&cfg.wsMaxConn, "ws-max-conns", 20, "동시에 열 수 있는 최대 웹소켓 세션 수")
|
||
|
|
flag.StringVar(&cfg.wsCommand, "ws-command", "Get-Location", "웹소켓 연결 후 전송할 PowerShell 명령어")
|
||
|
|
flag.BoolVar(&cfg.insecure, "insecure", false, "wss 대상의 TLS 인증서 검증 건너뛰기")
|
||
|
|
flag.DurationVar(&cfg.monitorInterval, "monitor-interval", 1*time.Second, "로컬 서버 메모리 실시간 출력 주기")
|
||
|
|
|
||
|
|
flag.IntVar(&cfg.searchStartUsers, "search-start-users", 100, "최대 동접 탐색 시작 사용자 수")
|
||
|
|
flag.IntVar(&cfg.searchStepUsers, "search-step-users", 100, "최대 동접 탐색 증가 폭")
|
||
|
|
flag.IntVar(&cfg.searchMaxUsers, "search-max-users", 2000, "최대 동접 탐색 상한 사용자 수")
|
||
|
|
flag.Float64Var(&cfg.maxFailureRate, "max-failure-rate", 1.0, "허용 실패율 퍼센트")
|
||
|
|
flag.DurationVar(&cfg.maxP95, "max-p95", 1*time.Second, "허용 P95 지연 시간")
|
||
|
|
|
||
|
|
flag.Parse()
|
||
|
|
return cfg
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateConfig(cfg config) error {
|
||
|
|
if cfg.users <= 0 {
|
||
|
|
return fmt.Errorf("users must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.duration <= 0 {
|
||
|
|
return fmt.Errorf("duration must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.rampUp < 0 {
|
||
|
|
return fmt.Errorf("ramp-up cannot be negative")
|
||
|
|
}
|
||
|
|
if cfg.timeout <= 0 {
|
||
|
|
return fmt.Errorf("timeout must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.wsTimeout <= 0 {
|
||
|
|
return fmt.Errorf("ws-timeout must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.pauseMin < 0 || cfg.pauseMax < 0 {
|
||
|
|
return fmt.Errorf("pauses cannot be negative")
|
||
|
|
}
|
||
|
|
if cfg.pauseMax < cfg.pauseMin {
|
||
|
|
return fmt.Errorf("pause-max must be greater than or equal to pause-min")
|
||
|
|
}
|
||
|
|
if cfg.wsRatio < 0 || cfg.wsRatio > 1 {
|
||
|
|
return fmt.Errorf("ws-ratio must be between 0 and 1")
|
||
|
|
}
|
||
|
|
if cfg.wsMaxConn <= 0 {
|
||
|
|
return fmt.Errorf("ws-max-conns must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.monitorInterval < 0 {
|
||
|
|
return fmt.Errorf("monitor-interval cannot be negative")
|
||
|
|
}
|
||
|
|
if cfg.searchStartUsers <= 0 {
|
||
|
|
return fmt.Errorf("search-start-users must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.searchStepUsers <= 0 {
|
||
|
|
return fmt.Errorf("search-step-users must be greater than 0")
|
||
|
|
}
|
||
|
|
if cfg.searchMaxUsers < cfg.searchStartUsers {
|
||
|
|
return fmt.Errorf("search-max-users must be greater than or equal to search-start-users")
|
||
|
|
}
|
||
|
|
if cfg.maxFailureRate < 0 || cfg.maxFailureRate > 100 {
|
||
|
|
return fmt.Errorf("max-failure-rate must be between 0 and 100")
|
||
|
|
}
|
||
|
|
if cfg.maxP95 <= 0 {
|
||
|
|
return fmt.Errorf("max-p95 must be greater than 0")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func runMaxUserSearch(cfg config) {
|
||
|
|
log.Printf(
|
||
|
|
"최대 동접 탐색 시작: 시작=%d 증가폭=%d 상한=%d 허용실패율=%.2f%% 허용P95=%s",
|
||
|
|
cfg.searchStartUsers,
|
||
|
|
cfg.searchStepUsers,
|
||
|
|
cfg.searchMaxUsers,
|
||
|
|
cfg.maxFailureRate,
|
||
|
|
cfg.maxP95,
|
||
|
|
)
|
||
|
|
|
||
|
|
var best *testReport
|
||
|
|
for users := cfg.searchStartUsers; users <= cfg.searchMaxUsers; users += cfg.searchStepUsers {
|
||
|
|
stageCfg := cfg
|
||
|
|
stageCfg.users = users
|
||
|
|
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Printf("=== 동접 %d명 탐색 단계 ===\n", users)
|
||
|
|
|
||
|
|
report := runLoadTest(stageCfg, true, false)
|
||
|
|
printCompactStageResult(report, cfg.maxFailureRate, cfg.maxP95)
|
||
|
|
|
||
|
|
if isStable(report, cfg.maxFailureRate, cfg.maxP95) {
|
||
|
|
reportCopy := report
|
||
|
|
best = &reportCopy
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("최대 동접 탐색 결과")
|
||
|
|
if best == nil {
|
||
|
|
fmt.Printf("안정 구간을 찾지 못했습니다. 시작 사용자 수 %d명부터 기준을 넘었습니다.\n", cfg.searchStartUsers)
|
||
|
|
} else {
|
||
|
|
fmt.Printf("최대 안정 동시 사용자 수: %d명\n", best.Config.users)
|
||
|
|
fmt.Printf("기준: 실패율 %.2f%% 이하, P95 %s 이하\n", cfg.maxFailureRate, cfg.maxP95)
|
||
|
|
fmt.Printf("해당 단계 결과: 성공률 %.2f%%, P95 %s, 초당 요청 수 %.2f\n", best.SuccessRate, best.P95, best.RequestsPerSecond)
|
||
|
|
if best.Memory.SampleCount > 0 {
|
||
|
|
fmt.Printf("해당 단계 최대 서버 사용 메모리: %s\n", formatBytes(best.Memory.MaxPrivate))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("최대 동접 탐색 결과")
|
||
|
|
if best == nil {
|
||
|
|
fmt.Println("안정 구간을 찾지 못했습니다.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf("탐색 상한까지 모두 통과했습니다. 현재 최대 안정 동시 사용자 수는 최소 %d명입니다.\n", best.Config.users)
|
||
|
|
fmt.Printf("기준: 실패율 %.2f%% 이하, P95 %s 이하\n", cfg.maxFailureRate, cfg.maxP95)
|
||
|
|
fmt.Printf("마지막 단계 결과: 성공률 %.2f%%, P95 %s, 초당 요청 수 %.2f\n", best.SuccessRate, best.P95, best.RequestsPerSecond)
|
||
|
|
if best.Memory.SampleCount > 0 {
|
||
|
|
fmt.Printf("마지막 단계 최대 서버 사용 메모리: %s\n", formatBytes(best.Memory.MaxPrivate))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func printCompactStageResult(report testReport, maxFailureRate float64, maxP95 time.Duration) {
|
||
|
|
result := "실패"
|
||
|
|
if isStable(report, maxFailureRate, maxP95) {
|
||
|
|
result = "통과"
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf(
|
||
|
|
"결과=%s 사용자=%d 성공률=%.2f%% 실패=%d P95=%s RPS=%.2f",
|
||
|
|
result,
|
||
|
|
report.Config.users,
|
||
|
|
report.SuccessRate,
|
||
|
|
report.Failed,
|
||
|
|
report.P95,
|
||
|
|
report.RequestsPerSecond,
|
||
|
|
)
|
||
|
|
if report.Memory.SampleCount > 0 {
|
||
|
|
fmt.Printf(" 최대서버메모리=%s", formatBytes(report.Memory.MaxPrivate))
|
||
|
|
}
|
||
|
|
fmt.Println()
|
||
|
|
}
|
||
|
|
|
||
|
|
func isStable(report testReport, maxFailureRate float64, maxP95 time.Duration) bool {
|
||
|
|
if report.Total == 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
failureRate := 100 - report.SuccessRate
|
||
|
|
return failureRate <= maxFailureRate && report.P95 <= maxP95
|
||
|
|
}
|
||
|
|
|
||
|
|
func runLoadTest(cfg config, printStartLog bool, verboseMemory bool) testReport {
|
||
|
|
if printStartLog {
|
||
|
|
log.Printf(
|
||
|
|
"부하 테스트 시작: 대상=%s 사용자=%d 지속시간=%s 램프업=%s 웹소켓비율=%.2f",
|
||
|
|
cfg.targetURL,
|
||
|
|
cfg.users,
|
||
|
|
cfg.duration,
|
||
|
|
cfg.rampUp,
|
||
|
|
cfg.wsRatio,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
agg := newAggregator()
|
||
|
|
globalAggregator.Store(agg)
|
||
|
|
agg.startedAt = time.Now()
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.duration)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
httpClient := newHTTPClient(cfg.timeout)
|
||
|
|
wsURL, err := deriveWebSocketURL(cfg.targetURL)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("잘못된 대상 URL입니다: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
memoryStats := startServerMemoryMonitor(ctx, cfg, verboseMemory)
|
||
|
|
wsSlots := make(chan struct{}, cfg.wsMaxConn)
|
||
|
|
var startedUsers atomic.Int64
|
||
|
|
var wg sync.WaitGroup
|
||
|
|
|
||
|
|
for i := 0; i < cfg.users; i++ {
|
||
|
|
wg.Add(1)
|
||
|
|
go func(userID int) {
|
||
|
|
defer wg.Done()
|
||
|
|
delay := rampDelay(cfg.rampUp, cfg.users, userID)
|
||
|
|
if !sleepWithContext(ctx, delay) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
startedUsers.Add(1)
|
||
|
|
runVirtualUser(ctx, userID, cfg, httpClient, wsURL, wsSlots, agg)
|
||
|
|
}(i)
|
||
|
|
}
|
||
|
|
|
||
|
|
wg.Wait()
|
||
|
|
agg.finishedAt = time.Now()
|
||
|
|
|
||
|
|
return buildReport(cfg, agg, startedUsers.Load(), memoryStats.snapshot())
|
||
|
|
}
|
||
|
|
|
||
|
|
func newHTTPClient(timeout time.Duration) *http.Client {
|
||
|
|
transport := &http.Transport{
|
||
|
|
MaxIdleConns: 512,
|
||
|
|
MaxIdleConnsPerHost: 512,
|
||
|
|
MaxConnsPerHost: 0,
|
||
|
|
IdleConnTimeout: 90 * time.Second,
|
||
|
|
}
|
||
|
|
|
||
|
|
return &http.Client{
|
||
|
|
Timeout: timeout,
|
||
|
|
Transport: transport,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func deriveWebSocketURL(target string) (string, error) {
|
||
|
|
parsed, err := url.Parse(target)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
switch parsed.Scheme {
|
||
|
|
case "http":
|
||
|
|
parsed.Scheme = "ws"
|
||
|
|
case "https":
|
||
|
|
parsed.Scheme = "wss"
|
||
|
|
case "ws", "wss":
|
||
|
|
default:
|
||
|
|
return "", fmt.Errorf("지원하지 않는 스킴 %q", parsed.Scheme)
|
||
|
|
}
|
||
|
|
|
||
|
|
parsed.Path = "/ws"
|
||
|
|
parsed.RawQuery = ""
|
||
|
|
parsed.Fragment = ""
|
||
|
|
return parsed.String(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func rampDelay(rampUp time.Duration, users int, userID int) time.Duration {
|
||
|
|
if rampUp <= 0 || users <= 1 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
step := float64(rampUp) / float64(users)
|
||
|
|
return time.Duration(step * float64(userID))
|
||
|
|
}
|
||
|
|
|
||
|
|
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||
|
|
if d <= 0 {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
timer := time.NewTimer(d)
|
||
|
|
defer timer.Stop()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return false
|
||
|
|
case <-timer.C:
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func runVirtualUser(ctx context.Context, userID int, cfg config, httpClient *http.Client, wsURL string, wsSlots chan struct{}, agg *aggregator) {
|
||
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(userID)*7919))
|
||
|
|
|
||
|
|
for {
|
||
|
|
if ctx.Err() != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
currentScenario := chooseScenario(rng, cfg.wsRatio)
|
||
|
|
var res result
|
||
|
|
|
||
|
|
switch currentScenario {
|
||
|
|
case scenarioRoot:
|
||
|
|
res = hitEndpoint(ctx, httpClient, cfg.targetURL, labelRoot)
|
||
|
|
case scenarioHealth:
|
||
|
|
res = hitEndpoint(ctx, httpClient, strings.TrimRight(cfg.targetURL, "/")+"/health", labelHealth)
|
||
|
|
default:
|
||
|
|
res = hitWebSocketWithLimit(ctx, wsURL, cfg.wsCommand, cfg.wsTimeout, cfg.insecure, wsSlots)
|
||
|
|
}
|
||
|
|
|
||
|
|
agg.add(res)
|
||
|
|
|
||
|
|
pause := cfg.pauseMin
|
||
|
|
if cfg.pauseMax > cfg.pauseMin {
|
||
|
|
pause += time.Duration(rng.Int63n(int64(cfg.pauseMax - cfg.pauseMin)))
|
||
|
|
}
|
||
|
|
|
||
|
|
if !sleepWithContext(ctx, pause) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func chooseScenario(rng *rand.Rand, wsRatio float64) scenario {
|
||
|
|
roll := rng.Float64()
|
||
|
|
if roll < wsRatio {
|
||
|
|
return scenarioWebSocket
|
||
|
|
}
|
||
|
|
if roll < wsRatio+0.30 {
|
||
|
|
return scenarioHealth
|
||
|
|
}
|
||
|
|
return scenarioRoot
|
||
|
|
}
|
||
|
|
|
||
|
|
func hitEndpoint(ctx context.Context, client *http.Client, endpoint string, name string) result {
|
||
|
|
startedAt := time.Now()
|
||
|
|
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
|
|
if err != nil {
|
||
|
|
return result{name: name, duration: time.Since(startedAt), err: err}
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return result{name: name, duration: time.Since(startedAt), err: err}
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
return result{
|
||
|
|
name: name,
|
||
|
|
statusCode: resp.StatusCode,
|
||
|
|
duration: time.Since(startedAt),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func hitWebSocketWithLimit(ctx context.Context, wsURL string, command string, timeout time.Duration, insecure bool, wsSlots chan struct{}) result {
|
||
|
|
select {
|
||
|
|
case wsSlots <- struct{}{}:
|
||
|
|
defer func() { <-wsSlots }()
|
||
|
|
return hitWebSocket(ctx, wsURL, command, timeout, insecure)
|
||
|
|
case <-ctx.Done():
|
||
|
|
return result{name: labelWebSocket, err: ctx.Err()}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func hitWebSocket(ctx context.Context, wsURL string, command string, timeout time.Duration, insecure bool) result {
|
||
|
|
startedAt := time.Now()
|
||
|
|
|
||
|
|
dialer := websocket.Dialer{
|
||
|
|
HandshakeTimeout: timeout,
|
||
|
|
TLSClientConfig: &tls.Config{
|
||
|
|
InsecureSkipVerify: insecure,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
conn, _, err := dialer.DialContext(ctx, wsURL, nil)
|
||
|
|
if err != nil {
|
||
|
|
return result{name: labelWebSocket, duration: time.Since(startedAt), err: err}
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||
|
|
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
|
||
|
|
|
||
|
|
var welcome socketResponse
|
||
|
|
if err := conn.ReadJSON(&welcome); err != nil {
|
||
|
|
return result{name: labelWebSocket, duration: time.Since(startedAt), err: err}
|
||
|
|
}
|
||
|
|
|
||
|
|
req := socketRequest{
|
||
|
|
Type: "input",
|
||
|
|
Data: command,
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := conn.WriteJSON(req); err != nil {
|
||
|
|
return result{name: labelWebSocket, duration: time.Since(startedAt), err: err}
|
||
|
|
}
|
||
|
|
|
||
|
|
var output socketResponse
|
||
|
|
if err := conn.ReadJSON(&output); err != nil {
|
||
|
|
return result{name: labelWebSocket, duration: time.Since(startedAt), err: err}
|
||
|
|
}
|
||
|
|
|
||
|
|
if welcome.Type == "error" || output.Type == "error" {
|
||
|
|
return result{
|
||
|
|
name: labelWebSocket,
|
||
|
|
duration: time.Since(startedAt),
|
||
|
|
err: fmt.Errorf("서버가 웹소켓 오류를 반환했습니다"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result{
|
||
|
|
name: labelWebSocket,
|
||
|
|
statusCode: http.StatusSwitchingProtocols,
|
||
|
|
duration: time.Since(startedAt),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newAggregator() *aggregator {
|
||
|
|
return &aggregator{
|
||
|
|
statusCounts: make(map[int]int64),
|
||
|
|
scenarioCounts: make(map[string]int64),
|
||
|
|
errorCounts: make(map[string]int64),
|
||
|
|
scenarioLatencies: make(map[string][]time.Duration),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *aggregator) add(res result) {
|
||
|
|
a.mu.Lock()
|
||
|
|
defer a.mu.Unlock()
|
||
|
|
|
||
|
|
a.total++
|
||
|
|
a.scenarioCounts[res.name]++
|
||
|
|
a.latencies = append(a.latencies, res.duration)
|
||
|
|
a.scenarioLatencies[res.name] = append(a.scenarioLatencies[res.name], res.duration)
|
||
|
|
|
||
|
|
if res.err != nil {
|
||
|
|
a.failed++
|
||
|
|
a.errorCounts[shortError(res.err)]++
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
a.success++
|
||
|
|
a.statusCounts[res.statusCode]++
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *aggregator) snapshot() aggregateSnapshot {
|
||
|
|
a.mu.Lock()
|
||
|
|
defer a.mu.Unlock()
|
||
|
|
|
||
|
|
return aggregateSnapshot{
|
||
|
|
total: a.total,
|
||
|
|
success: a.success,
|
||
|
|
failed: a.failed,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildReport(cfg config, agg *aggregator, startedUsers int64, memory serverMemoryStatsSnapshot) testReport {
|
||
|
|
agg.mu.Lock()
|
||
|
|
defer agg.mu.Unlock()
|
||
|
|
|
||
|
|
elapsed := agg.finishedAt.Sub(agg.startedAt)
|
||
|
|
if elapsed <= 0 {
|
||
|
|
elapsed = time.Millisecond
|
||
|
|
}
|
||
|
|
|
||
|
|
successRate := 0.0
|
||
|
|
if agg.total > 0 {
|
||
|
|
successRate = float64(agg.success) / float64(agg.total) * 100
|
||
|
|
}
|
||
|
|
|
||
|
|
report := testReport{
|
||
|
|
Config: cfg,
|
||
|
|
StartedUsers: startedUsers,
|
||
|
|
Elapsed: elapsed,
|
||
|
|
Total: agg.total,
|
||
|
|
Success: agg.success,
|
||
|
|
Failed: agg.failed,
|
||
|
|
SuccessRate: successRate,
|
||
|
|
RequestsPerSecond: float64(agg.total) / elapsed.Seconds(),
|
||
|
|
StatusCounts: copyIntMap(agg.statusCounts),
|
||
|
|
ScenarioCounts: copyStringMap(agg.scenarioCounts),
|
||
|
|
ErrorCounts: copyStringMap(agg.errorCounts),
|
||
|
|
ScenarioStats: make(map[string]scenarioStat),
|
||
|
|
Memory: memory,
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(agg.latencies) > 0 {
|
||
|
|
report.AverageLatency = averageDuration(agg.latencies)
|
||
|
|
report.P50 = percentile(agg.latencies, 50)
|
||
|
|
report.P95 = percentile(agg.latencies, 95)
|
||
|
|
report.P99 = percentile(agg.latencies, 99)
|
||
|
|
report.MaxLatency = maxDuration(agg.latencies)
|
||
|
|
}
|
||
|
|
|
||
|
|
for name, latencies := range agg.scenarioLatencies {
|
||
|
|
report.ScenarioStats[name] = scenarioStat{
|
||
|
|
Count: agg.scenarioCounts[name],
|
||
|
|
Average: averageDuration(latencies),
|
||
|
|
P95: percentile(latencies, 95),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return report
|
||
|
|
}
|
||
|
|
|
||
|
|
func shortError(err error) string {
|
||
|
|
if err == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
msg := normalizeError(err.Error())
|
||
|
|
if len(msg) > 120 {
|
||
|
|
return msg[:120]
|
||
|
|
}
|
||
|
|
return msg
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeError(msg string) string {
|
||
|
|
switch {
|
||
|
|
case strings.Contains(msg, "i/o timeout"):
|
||
|
|
return "입출력 시간 초과"
|
||
|
|
case strings.Contains(msg, "context deadline exceeded"):
|
||
|
|
return "컨텍스트 제한 시간 초과"
|
||
|
|
case strings.Contains(msg, "server returned websocket error"):
|
||
|
|
return "서버가 웹소켓 오류를 반환했습니다"
|
||
|
|
default:
|
||
|
|
return msg
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func printSummary(report testReport) {
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("=== 부하 테스트 요약 ===")
|
||
|
|
fmt.Printf("대상: %s\n", report.Config.targetURL)
|
||
|
|
fmt.Printf("시작한 사용자: %d/%d\n", report.StartedUsers, report.Config.users)
|
||
|
|
fmt.Printf("지속 시간: %s\n", report.Elapsed.Truncate(time.Millisecond))
|
||
|
|
fmt.Printf("총 요청 수: %d\n", report.Total)
|
||
|
|
fmt.Printf("성공: %d\n", report.Success)
|
||
|
|
fmt.Printf("실패: %d\n", report.Failed)
|
||
|
|
fmt.Printf("성공률: %.2f%%\n", report.SuccessRate)
|
||
|
|
fmt.Printf("초당 요청 수: %.2f\n", report.RequestsPerSecond)
|
||
|
|
|
||
|
|
if report.Total > 0 {
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("지연 시간")
|
||
|
|
fmt.Printf("평균: %s\n", report.AverageLatency)
|
||
|
|
fmt.Printf("P50: %s\n", report.P50)
|
||
|
|
fmt.Printf("P95: %s\n", report.P95)
|
||
|
|
fmt.Printf("P99: %s\n", report.P99)
|
||
|
|
fmt.Printf("최대: %s\n", report.MaxLatency)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(report.ScenarioCounts) > 0 {
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("시나리오별 통계")
|
||
|
|
for _, name := range sortedStringKeys(report.ScenarioCounts) {
|
||
|
|
stat := report.ScenarioStats[name]
|
||
|
|
fmt.Printf("%s: %d", name, stat.Count)
|
||
|
|
fmt.Printf(" (평균 %s, p95 %s)\n", stat.Average, stat.P95)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(report.StatusCounts) > 0 {
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("HTTP/웹소켓 상태 코드")
|
||
|
|
for _, code := range sortedIntKeys(report.StatusCounts) {
|
||
|
|
fmt.Printf("%d%s: %d\n", code, describeStatusCode(code), report.StatusCounts[code])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
printMemorySummary(report.Memory)
|
||
|
|
|
||
|
|
if len(report.ErrorCounts) > 0 {
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("오류")
|
||
|
|
for _, msg := range sortedStringKeys(report.ErrorCounts) {
|
||
|
|
fmt.Printf("%s: %d\n", msg, report.ErrorCounts[msg])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func describeStatusCode(code int) string {
|
||
|
|
switch code {
|
||
|
|
case http.StatusOK:
|
||
|
|
return " (HTTP 정상 응답)"
|
||
|
|
case http.StatusSwitchingProtocols:
|
||
|
|
return " (웹소켓 연결 성공)"
|
||
|
|
default:
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func printMemorySummary(memory serverMemoryStatsSnapshot) {
|
||
|
|
if !memory.Enabled {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Println()
|
||
|
|
fmt.Println("서버 메모리")
|
||
|
|
|
||
|
|
if !memory.LocalTarget {
|
||
|
|
fmt.Println("로컬 서버가 아닌 대상이라 메모리 모니터링을 건너뛰었습니다.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if memory.SampleCount == 0 {
|
||
|
|
fmt.Printf("메모리 수집 실패")
|
||
|
|
if memory.LastError != "" {
|
||
|
|
fmt.Printf(": %s", memory.LastError)
|
||
|
|
}
|
||
|
|
fmt.Println()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf("PID: %d\n", memory.PID)
|
||
|
|
if memory.ProcessName != "" {
|
||
|
|
fmt.Printf("프로세스: %s\n", memory.ProcessName)
|
||
|
|
}
|
||
|
|
fmt.Printf("현재 서버 사용 메모리: %s\n", formatBytes(memory.LastPrivate))
|
||
|
|
fmt.Printf("테스트 중 최대 서버 사용 메모리: %s\n", formatBytes(memory.MaxPrivate))
|
||
|
|
fmt.Printf("현재 작업 집합 메모리: %s\n", formatBytes(memory.LastWorkingSet))
|
||
|
|
fmt.Printf("테스트 중 최대 작업 집합 메모리: %s\n", formatBytes(memory.MaxWorkingSet))
|
||
|
|
fmt.Printf("수집 샘플 수: %d\n", memory.SampleCount)
|
||
|
|
}
|
||
|
|
|
||
|
|
func averageDuration(values []time.Duration) time.Duration {
|
||
|
|
if len(values) == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
var total time.Duration
|
||
|
|
for _, v := range values {
|
||
|
|
total += v
|
||
|
|
}
|
||
|
|
return total / time.Duration(len(values))
|
||
|
|
}
|
||
|
|
|
||
|
|
func percentile(values []time.Duration, p float64) time.Duration {
|
||
|
|
if len(values) == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
sorted := append([]time.Duration(nil), values...)
|
||
|
|
sort.Slice(sorted, func(i, j int) bool {
|
||
|
|
return sorted[i] < sorted[j]
|
||
|
|
})
|
||
|
|
|
||
|
|
rank := int(math.Ceil((p / 100) * float64(len(sorted))))
|
||
|
|
if rank < 1 {
|
||
|
|
rank = 1
|
||
|
|
}
|
||
|
|
if rank > len(sorted) {
|
||
|
|
rank = len(sorted)
|
||
|
|
}
|
||
|
|
return sorted[rank-1]
|
||
|
|
}
|
||
|
|
|
||
|
|
func maxDuration(values []time.Duration) time.Duration {
|
||
|
|
if len(values) == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
max := values[0]
|
||
|
|
for _, v := range values[1:] {
|
||
|
|
if v > max {
|
||
|
|
max = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return max
|
||
|
|
}
|
||
|
|
|
||
|
|
func copyStringMap(src map[string]int64) map[string]int64 {
|
||
|
|
dst := make(map[string]int64, len(src))
|
||
|
|
for k, v := range src {
|
||
|
|
dst[k] = v
|
||
|
|
}
|
||
|
|
return dst
|
||
|
|
}
|
||
|
|
|
||
|
|
func copyIntMap(src map[int]int64) map[int]int64 {
|
||
|
|
dst := make(map[int]int64, len(src))
|
||
|
|
for k, v := range src {
|
||
|
|
dst[k] = v
|
||
|
|
}
|
||
|
|
return dst
|
||
|
|
}
|
||
|
|
|
||
|
|
func sortedStringKeys[T any](m map[string]T) []string {
|
||
|
|
keys := make([]string, 0, len(m))
|
||
|
|
for key := range m {
|
||
|
|
keys = append(keys, key)
|
||
|
|
}
|
||
|
|
sort.Strings(keys)
|
||
|
|
return keys
|
||
|
|
}
|
||
|
|
|
||
|
|
func sortedIntKeys[T any](m map[int]T) []int {
|
||
|
|
keys := make([]int, 0, len(m))
|
||
|
|
for key := range m {
|
||
|
|
keys = append(keys, key)
|
||
|
|
}
|
||
|
|
sort.Ints(keys)
|
||
|
|
return keys
|
||
|
|
}
|
||
|
|
|
||
|
|
func startServerMemoryMonitor(ctx context.Context, cfg config, verbose bool) *serverMemoryStats {
|
||
|
|
stats := &serverMemoryStats{
|
||
|
|
Enabled: cfg.monitorInterval > 0,
|
||
|
|
Verbose: verbose,
|
||
|
|
}
|
||
|
|
if !stats.Enabled {
|
||
|
|
return stats
|
||
|
|
}
|
||
|
|
|
||
|
|
port, local := extractTargetPortIfLocal(cfg.targetURL)
|
||
|
|
stats.LocalTarget = local
|
||
|
|
stats.TargetPort = port
|
||
|
|
if !local {
|
||
|
|
if verbose {
|
||
|
|
log.Printf("실시간 메모리 모니터링 생략: 로컬 대상이 아닙니다 (%s)", cfg.targetURL)
|
||
|
|
}
|
||
|
|
return stats
|
||
|
|
}
|
||
|
|
|
||
|
|
if verbose {
|
||
|
|
log.Printf("실시간 메모리 모니터링 활성화: 포트=%s 주기=%s", port, cfg.monitorInterval)
|
||
|
|
}
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
ticker := time.NewTicker(cfg.monitorInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
startedAt := time.Now()
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return
|
||
|
|
case <-ticker.C:
|
||
|
|
sample, processName, err := captureServerMemorySample(port)
|
||
|
|
stats.mu.Lock()
|
||
|
|
if err != nil {
|
||
|
|
stats.LastError = err.Error()
|
||
|
|
stats.mu.Unlock()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
stats.PID = sample.PID
|
||
|
|
stats.ProcessName = processName
|
||
|
|
stats.LastWorkingSet = sample.WorkingSetBytes
|
||
|
|
stats.LastPrivate = sample.PrivateBytes
|
||
|
|
if sample.WorkingSetBytes > stats.MaxWorkingSet {
|
||
|
|
stats.MaxWorkingSet = sample.WorkingSetBytes
|
||
|
|
}
|
||
|
|
if sample.PrivateBytes > stats.MaxPrivate {
|
||
|
|
stats.MaxPrivate = sample.PrivateBytes
|
||
|
|
}
|
||
|
|
stats.SampleCount++
|
||
|
|
stats.LastError = ""
|
||
|
|
stats.mu.Unlock()
|
||
|
|
|
||
|
|
if stats.Verbose {
|
||
|
|
snapshot := aggregateSnapshotIfAvailable()
|
||
|
|
log.Printf(
|
||
|
|
"[실시간] 경과=%s 요청=%d 성공=%d 실패=%d 서버사용메모리=%s 작업집합=%s PID=%d",
|
||
|
|
time.Since(startedAt).Truncate(time.Second),
|
||
|
|
snapshot.total,
|
||
|
|
snapshot.success,
|
||
|
|
snapshot.failed,
|
||
|
|
formatBytes(sample.PrivateBytes),
|
||
|
|
formatBytes(sample.WorkingSetBytes),
|
||
|
|
sample.PID,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
return stats
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *serverMemoryStats) snapshot() serverMemoryStatsSnapshot {
|
||
|
|
if s == nil {
|
||
|
|
return serverMemoryStatsSnapshot{}
|
||
|
|
}
|
||
|
|
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
return serverMemoryStatsSnapshot{
|
||
|
|
Enabled: s.Enabled,
|
||
|
|
LocalTarget: s.LocalTarget,
|
||
|
|
ProcessName: s.ProcessName,
|
||
|
|
PID: s.PID,
|
||
|
|
LastWorkingSet: s.LastWorkingSet,
|
||
|
|
LastPrivate: s.LastPrivate,
|
||
|
|
MaxWorkingSet: s.MaxWorkingSet,
|
||
|
|
MaxPrivate: s.MaxPrivate,
|
||
|
|
SampleCount: s.SampleCount,
|
||
|
|
LastError: s.LastError,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func aggregateSnapshotIfAvailable() aggregateSnapshot {
|
||
|
|
agg := globalAggregator.Load()
|
||
|
|
if agg == nil {
|
||
|
|
return aggregateSnapshot{}
|
||
|
|
}
|
||
|
|
return agg.snapshot()
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureServerMemorySample(port string) (serverMemorySample, string, error) {
|
||
|
|
pid, err := findPIDByPort(port)
|
||
|
|
if err != nil {
|
||
|
|
return serverMemorySample{}, "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
workingSet, privateBytes, processName, err := readProcessMemory(pid)
|
||
|
|
if err != nil {
|
||
|
|
return serverMemorySample{}, "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
return serverMemorySample{
|
||
|
|
PID: pid,
|
||
|
|
WorkingSetBytes: workingSet,
|
||
|
|
PrivateBytes: privateBytes,
|
||
|
|
}, processName, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractTargetPortIfLocal(raw string) (string, bool) {
|
||
|
|
parsed, err := url.Parse(raw)
|
||
|
|
if err != nil {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
|
||
|
|
host := parsed.Hostname()
|
||
|
|
if host == "" {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
|
||
|
|
if !isLocalHost(host) {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
|
||
|
|
port := parsed.Port()
|
||
|
|
if port != "" {
|
||
|
|
return port, true
|
||
|
|
}
|
||
|
|
|
||
|
|
switch parsed.Scheme {
|
||
|
|
case "http", "ws":
|
||
|
|
return "80", true
|
||
|
|
case "https", "wss":
|
||
|
|
return "443", true
|
||
|
|
default:
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func isLocalHost(host string) bool {
|
||
|
|
switch strings.ToLower(host) {
|
||
|
|
case "localhost", "127.0.0.1", "::1":
|
||
|
|
return true
|
||
|
|
default:
|
||
|
|
ip := net.ParseIP(host)
|
||
|
|
return ip != nil && ip.IsLoopback()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func findPIDByPort(port string) (int, error) {
|
||
|
|
switch runtime.GOOS {
|
||
|
|
case "windows":
|
||
|
|
return findPIDByPortWindows(port)
|
||
|
|
case "linux":
|
||
|
|
return findPIDByPortLinux(port)
|
||
|
|
default:
|
||
|
|
return 0, fmt.Errorf("현재 환경에서는 서버 메모리 모니터링을 지원하지 않습니다")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func findPIDByPortWindows(port string) (int, error) {
|
||
|
|
out, err := exec.Command("netstat", "-ano", "-p", "TCP").CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("netstat 실행 실패: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
lines := strings.Split(string(out), "\n")
|
||
|
|
for _, line := range lines {
|
||
|
|
trimmed := strings.TrimSpace(line)
|
||
|
|
if !strings.Contains(trimmed, "LISTENING") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if !strings.Contains(trimmed, ":"+port) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
matches := netstatPIDPattern.FindStringSubmatch(trimmed)
|
||
|
|
if len(matches) != 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
pid, convErr := strconv.Atoi(matches[1])
|
||
|
|
if convErr != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return pid, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
return 0, fmt.Errorf("포트 %s에서 LISTENING 중인 프로세스를 찾지 못했습니다", port)
|
||
|
|
}
|
||
|
|
|
||
|
|
func findPIDByPortLinux(port string) (int, error) {
|
||
|
|
out, err := exec.Command("ss", "-ltnp").CombinedOutput()
|
||
|
|
if err == nil {
|
||
|
|
lines := strings.Split(string(out), "\n")
|
||
|
|
for _, line := range lines {
|
||
|
|
if !strings.Contains(line, ":"+port) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
matches := ssPIDPattern.FindStringSubmatch(line)
|
||
|
|
if len(matches) != 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
pid, convErr := strconv.Atoi(matches[1])
|
||
|
|
if convErr == nil {
|
||
|
|
return pid, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
out, lsofErr := exec.Command("lsof", "-nP", "-iTCP:"+port, "-sTCP:LISTEN", "-t").CombinedOutput()
|
||
|
|
if lsofErr != nil {
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("ss/lsof 실행 실패: %v / %w", err, lsofErr)
|
||
|
|
}
|
||
|
|
return 0, fmt.Errorf("lsof 실행 실패: %w", lsofErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
text := strings.TrimSpace(string(out))
|
||
|
|
if text == "" {
|
||
|
|
return 0, fmt.Errorf("포트 %s에서 LISTEN 중인 프로세스를 찾지 못했습니다", port)
|
||
|
|
}
|
||
|
|
|
||
|
|
pid, convErr := strconv.Atoi(strings.Split(text, "\n")[0])
|
||
|
|
if convErr != nil {
|
||
|
|
return 0, fmt.Errorf("lsof PID 파싱 실패: %w", convErr)
|
||
|
|
}
|
||
|
|
return pid, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func readProcessMemory(pid int) (uint64, uint64, string, error) {
|
||
|
|
switch runtime.GOOS {
|
||
|
|
case "windows":
|
||
|
|
return readProcessMemoryWindows(pid)
|
||
|
|
case "linux":
|
||
|
|
return readProcessMemoryLinux(pid)
|
||
|
|
default:
|
||
|
|
return 0, 0, "", fmt.Errorf("현재 환경에서는 프로세스 메모리 조회를 지원하지 않습니다")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func readProcessMemoryWindows(pid int) (uint64, uint64, string, error) {
|
||
|
|
script := fmt.Sprintf(`$p = Get-Process -Id %d -ErrorAction Stop; Write-Output ($p.WorkingSet64.ToString() + "," + $p.PrivateMemorySize64.ToString() + "," + $p.ProcessName)`, pid)
|
||
|
|
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script).CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return 0, 0, "", fmt.Errorf("Get-Process 실행 실패: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
text := strings.TrimSpace(string(out))
|
||
|
|
parts := strings.SplitN(text, ",", 3)
|
||
|
|
if len(parts) != 3 {
|
||
|
|
return 0, 0, "", fmt.Errorf("메모리 정보 파싱 실패: %s", text)
|
||
|
|
}
|
||
|
|
|
||
|
|
workingSet, err := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return 0, 0, "", fmt.Errorf("작업 집합 메모리 파싱 실패: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
privateBytes, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return 0, 0, "", fmt.Errorf("서버 사용 메모리 파싱 실패: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return workingSet, privateBytes, strings.TrimSpace(parts[2]), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func readProcessMemoryLinux(pid int) (uint64, uint64, string, error) {
|
||
|
|
statusPath := fmt.Sprintf("/proc/%d/status", pid)
|
||
|
|
file, err := os.Open(statusPath)
|
||
|
|
if err != nil {
|
||
|
|
return 0, 0, "", fmt.Errorf("/proc 상태 파일 열기 실패: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
var (
|
||
|
|
processName string
|
||
|
|
vmRSSKB uint64
|
||
|
|
vmSizeKB uint64
|
||
|
|
)
|
||
|
|
|
||
|
|
scanner := bufio.NewScanner(file)
|
||
|
|
for scanner.Scan() {
|
||
|
|
line := scanner.Text()
|
||
|
|
switch {
|
||
|
|
case strings.HasPrefix(line, "Name:"):
|
||
|
|
processName = strings.TrimSpace(strings.TrimPrefix(line, "Name:"))
|
||
|
|
case strings.HasPrefix(line, "VmRSS:"):
|
||
|
|
value, parseErr := parseProcKBValue(line)
|
||
|
|
if parseErr == nil {
|
||
|
|
vmRSSKB = value
|
||
|
|
}
|
||
|
|
case strings.HasPrefix(line, "VmSize:"):
|
||
|
|
value, parseErr := parseProcKBValue(line)
|
||
|
|
if parseErr == nil {
|
||
|
|
vmSizeKB = value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := scanner.Err(); err != nil {
|
||
|
|
return 0, 0, "", fmt.Errorf("/proc 상태 파일 읽기 실패: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if vmRSSKB == 0 && vmSizeKB == 0 {
|
||
|
|
return 0, 0, "", fmt.Errorf("리눅스 프로세스 메모리 정보를 찾지 못했습니다")
|
||
|
|
}
|
||
|
|
|
||
|
|
return vmRSSKB * 1024, vmSizeKB * 1024, processName, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseProcKBValue(line string) (uint64, error) {
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
if len(fields) < 2 {
|
||
|
|
return 0, fmt.Errorf("메모리 값 형식이 올바르지 않습니다: %s", line)
|
||
|
|
}
|
||
|
|
return strconv.ParseUint(fields[1], 10, 64)
|
||
|
|
}
|
||
|
|
|
||
|
|
func formatBytes(v uint64) string {
|
||
|
|
const unit = 1024
|
||
|
|
if v < unit {
|
||
|
|
return fmt.Sprintf("%d B", v)
|
||
|
|
}
|
||
|
|
|
||
|
|
div, exp := uint64(unit), 0
|
||
|
|
for n := v / unit; n >= unit; n /= unit {
|
||
|
|
div *= unit
|
||
|
|
exp++
|
||
|
|
}
|
||
|
|
|
||
|
|
suffixes := []string{"KB", "MB", "GB", "TB"}
|
||
|
|
return fmt.Sprintf("%.2f %s", float64(v)/float64(div), suffixes[exp])
|
||
|
|
}
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
log.SetOutput(os.Stdout)
|
||
|
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r socketResponse) String() string {
|
||
|
|
payload, _ := json.Marshal(r)
|
||
|
|
return string(payload)
|
||
|
|
}
|