From a63793daf0ae94854017373d10070d162666f3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 9 Jul 2026 23:45:25 +0900 Subject: [PATCH] =?UTF-8?q?test(lps):=20Locust=20API=20=EB=B6=80=ED=95=98?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20+=20=EB=A9=80=ED=8B=B0?= =?UTF-8?q?=EC=BD=94=EC=96=B4=20=EB=B2=A4=EC=B9=98=20(=ED=92=80=20?= =?UTF-8?q?=EB=B3=91=EB=AA=A9=20=EB=B0=9C=EA=B2=AC=C2=B7=EC=A6=9D=EB=AA=85?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API(enqueue/조회, asyncpg I/O 바운드) 부하 테스트로 멀티코어 활용·한계 측정. - loadtest/locustfile.py: enqueue(고유코드 write)+조회 가중 부하. - loadtest/bench_multicore.sh: PROCESS_COUNT 1→N 자동 비교(--processes 로 부하생성기도 멀티프로세스). - server_configs: PROCESS_COUNT / DB_POOL_SIZE / DB_MAX_OVERFLOW env override(코드·toml 수정 없이 튜닝). - loadtest/README.md: 발견 문서화. 발견(11코어·1500users): 기본 풀(10/20)로 워커 늘리면 실패 폭증(1w=0 → 4w=10336) — (pool+overflow)×2엔진×workers=240 > PG max_connections=100 커넥션 고갈(SQLAlchemy pool checkout 실패). 증명: 풀 8/4(96<100)로 PC=4 재실행 → RPS 1336→2705(2배), 실패 0. 코드는 멀티코어 활용 가능, 막는 건 풀 오버서브스크립션. 규칙: (pool+overflow)×2×process_count ≤ max_connections. Co-Authored-By: Claude Fable 5 --- lps/config/server_configs.py | 9 +++++ lps/loadtest/README.md | 54 ++++++++++++++++++++++++++ lps/loadtest/bench_multicore.sh | 69 +++++++++++++++++++++++++++++++++ lps/loadtest/locustfile.py | 63 ++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 lps/loadtest/README.md create mode 100755 lps/loadtest/bench_multicore.sh create mode 100644 lps/loadtest/locustfile.py diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 6c1405a..0f6ddbe 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -39,6 +39,11 @@ def _apply_db_env_override(cfg: MainDBConfig): cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"] if os.environ.get("DB_NAME"): cfg.name = os.environ["DB_NAME"] + # 커넥션 풀 사이징 override — 멀티워커 시 (pool+overflow)×2엔진×workers 가 PG max_connections 를 넘지 않게 조절. + if os.environ.get("DB_POOL_SIZE"): + cfg.pool_size = int(os.environ["DB_POOL_SIZE"]) + if os.environ.get("DB_MAX_OVERFLOW"): + cfg.max_overflow = int(os.environ["DB_MAX_OVERFLOW"]) # 시크릿 env override — 프로덕션에선 API 키를 이미지에 굽지 않고 env(또는 시크릿매니저)로 주입한다. @@ -63,3 +68,7 @@ def _apply_secret_env_override(): _apply_db_env_override(main_db_config) _apply_secret_env_override() + +# uvicorn 워커 수(멀티코어) env override — 부하테스트에서 1↔N 비교용(코드/toml 수정 없이). +if os.environ.get("PROCESS_COUNT"): + web_server_config.process_count = int(os.environ["PROCESS_COUNT"]) diff --git a/lps/loadtest/README.md b/lps/loadtest/README.md new file mode 100644 index 0000000..227fe77 --- /dev/null +++ b/lps/loadtest/README.md @@ -0,0 +1,54 @@ +# LPS 부하 테스트 (Locust) + +**대상 = API 서버**(web_main). 워커(브라우저 크롤)는 프록시/브라우저에 처리량이 묶여 Locust 대상이 아니다. +API 는 요청을 받아 `job` 테이블에 적재만 하고 즉시 응답한다(**asyncpg I/O 바운드**, bcrypt 없음). + +## 파일 +- `locustfile.py` — enqueue(POST /search, 고유코드 write) + 조회(jobs/stats/ops/readyz) 가중 부하 +- `bench_multicore.sh` — **PROCESS_COUNT 1→N 자동 비교**(멀티코어 스케일링 측정, 대화형) + +## 실행 +```bash +# 웹 UI (:8089) +.venv/bin/locust -f loadtest/locustfile.py --host http://localhost:9600 +# 헤드리스 +.venv/bin/locust -f loadtest/locustfile.py --host http://localhost:9600 --headless --processes 4 -u 1500 -r 150 -t 1m +# 멀티코어 자동 벤치 (PROCESS_COUNT 1/2/4) +LOCUST_PROCESSES=4 ./loadtest/bench_multicore.sh +``` +> ⚠️ **워커는 끄고** 실행(부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓임 → 끝나면 정리: +> `psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;"` +> 부하 중 커넥션 관측: `SELECT count(*) FROM pg_stat_activity WHERE datname='lps_db';` + +## 핵심 발견 (2026-07-09, 11코어 맥 · 1500 users · 20s) + +**1) 멀티코어는 되지만 DB 커넥션 풀이 발목을 잡는다.** +API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `workers`(=`process_count`)를 늘리면 +코어만큼 스케일해야 하는데, **기본 풀(pool_size=10, max_overflow=20)에선 워커를 늘릴수록 오히려 실패 폭증**: + +| workers | RPS | 실패 | 원인 | +|:---:|---:|---:|---| +| 1 | 1,137 | 0 | 정상(단일 코어) | +| 2 | 1,390 | 2,997 | 커넥션 초과 시작 | +| 4 | 1,336 | 10,336 | **커넥션 고갈**(SQLAlchemy pool checkout 실패) | + +원인: **`(pool_size+max_overflow) × 2엔진(R/W) × workers` 가 PG `max_connections`(기본 100)를 초과**. +4워커면 (10+20)×2×4 = **240 > 100** → 워커 3~4가 커넥션 못 받아 요청 실패. + +**2) 풀을 올바르게 잡으면 멀티코어가 제대로 작동한다.** + +| PC=4 설정 | RPS | 실패 | +|---|---:|---:| +| 풀 10/20 (240 요구) | 1,336 | 10,336 | +| **풀 8/4 (96 요구)** | **2,705** | **0** | + +풀만 줄이니 **RPS 2배 + 실패 0**. 즉 코드는 멀티코어를 활용할 수 있고, **막는 건 풀 오버서브스크립션**이다. + +## 튜닝 규칙 (프로덕션) +``` +(pool_size + max_overflow) × 2 × process_count ≤ PG max_connections +``` +- 예) max_connections=100, process_count=4 → (pool+overflow) ≤ 12 → **pool_size=8, max_overflow=4** +- env 로 조절(코드 수정 없이): `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `PROCESS_COUNT` +- 더 큰 처리량이 필요하면: **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입 +- **부하 한계(이 머신)**: 풀 정상화 시 4워커 ~2,700 RPS, p95 ~900ms, 실패 0 diff --git a/lps/loadtest/bench_multicore.sh b/lps/loadtest/bench_multicore.sh new file mode 100755 index 0000000..275d354 --- /dev/null +++ b/lps/loadtest/bench_multicore.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# 멀티코어 스케일링 벤치 (대화형). +# PROCESS_COUNT 를 1→N 으로 바꿔가며 API 를 띄우고, 각각 locust 헤드리스로 RPS/지연을 측정해 비교한다. +# API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어 → workers 를 늘리면 코어만큼 스케일해야 정상. +# +# 주의: +# - **워커는 반드시 OFF** (부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓임 → 끝나면 TRUNCATE. +# - 커넥션 한계: (pool_size+max_overflow)×2엔진×workers 가 PG max_connections 를 넘으면 거기서 막힌다. +set -euo pipefail +cd "$(dirname "$0")/.." # lps/ + +VENV=".venv"; PY="$VENV/bin/python"; LOCUST="$VENV/bin/locust" +HOST="http://localhost:9600"; PORT=9600 + +command -v psql >/dev/null || true +[[ -x "$LOCUST" ]] || { echo "[setup] locust 설치..."; "$PY" -m pip install -q locust; } + +CORES=$("$PY" -c "import os;print(os.cpu_count())") +echo "── 멀티코어 부하 벤치 ── (CPU 코어: $CORES)" + +# 워커 실행 중이면 경고 +if pgrep -f worker_main.py >/dev/null; then + echo "[warn] 워커(worker_main.py)가 실행 중입니다 — 부하 중 실제 크롤 비용 발생. 중단 권장:" + read -rp " 워커를 종료할까요? [Y/n]: " k; [[ "${k:-Y}" =~ ^[Nn] ]] || pkill -f worker_main.py || true +fi + +read -rp "동시 유저 수(-u) [200]: " USERS; USERS="${USERS:-200}" +read -rp "spawn rate(-r) [20]: " SPAWN; SPAWN="${SPAWN:-20}" +read -rp "각 단계 지속(-t) [45s]: " DUR; DUR="${DUR:-45s}" +read -rp "PROCESS_COUNT 목록(공백구분) [1 2 4]: " PCS; PCS="${PCS:-1 2 4}" + +# PG max_connections (참고) +MAXC=$(psql -h 127.0.0.1 -U postgres -tAc "SHOW max_connections;" 2>/dev/null || echo "?") +echo "PG max_connections=$MAXC · 풀 추정=(pool+overflow)×2×workers" +echo "" + +RESULTS=() +for PC in $PCS; do + # 기존 API 종료 + lsof -ti:"$PORT" 2>/dev/null | xargs kill 2>/dev/null || true; sleep 1 + echo "▶ PROCESS_COUNT=$PC 로 API 기동..." + PROCESS_COUNT="$PC" APP_ENV=local "$PY" web_main.py > "/tmp/lps_api_pc${PC}.log" 2>&1 & + APIPID=$! + # 기동 대기(헬스) + for _ in $(seq 1 20); do curl -s "$HOST/healthz" >/dev/null 2>&1 && break; sleep 1; done + # locust 헤드리스 (--processes 로 부하 생성기도 멀티프로세스 → locust 가 병목되지 않게) + "$LOCUST" -f loadtest/locustfile.py --host "$HOST" --headless --processes "${LOCUST_PROCESSES:-4}" \ + -u "$USERS" -r "$SPAWN" -t "$DUR" --csv "/tmp/lps_locust_pc${PC}" --only-summary >/dev/null 2>&1 || true + kill "$APIPID" 2>/dev/null || true; sleep 1 + # Aggregated 행 파싱: $10=Req/s $17=95% $3=count $4=fail + read -r RPS P95 CNT FAIL < <(awk -F, '$2=="Aggregated"{print $10, $17, $3, $4}' "/tmp/lps_locust_pc${PC}_stats.csv" 2>/dev/null || echo "0 0 0 0") + RESULTS+=("$PC|${RPS:-0}|${P95:-0}|${CNT:-0}|${FAIL:-0}") + printf " → RPS=%.0f p95=%sms 요청=%s 실패=%s\n\n" "${RPS:-0}" "${P95:-0}" "${CNT:-0}" "${FAIL:-0}" +done + +echo "════════ 결과 (동시유저 $USERS, $DUR) ════════" +printf "%-8s %-10s %-10s %-10s %-8s %-10s\n" "workers" "RPS" "p95(ms)" "요청" "실패" "vs 1코어" +BASE="" +for r in "${RESULTS[@]}"; do + IFS='|' read -r pc rps p95 cnt fail <<< "$r" + [[ -z "$BASE" ]] && BASE="$rps" + SCALE=$("$PY" -c "print(f'{(${rps:-0}/${BASE:-1}):.2f}x')" 2>/dev/null || echo "-") + printf "%-8s %-10.0f %-10s %-10s %-8s %-10s\n" "$pc" "${rps:-0}" "${p95:-0}" "${cnt:-0}" "${fail:-0}" "$SCALE" +done +echo "" +echo "해석: RPS 가 workers 에 비례해 오르면 멀티코어 활용 정상. 어느 지점부터 안 오르고 실패가 늘면" +echo " 거기가 한계 — 보통 DB 커넥션(max_connections=$MAXC) 또는 write 경합. 풀/PG 튜닝 대상." +echo "정리: psql -h 127.0.0.1 -U postgres -d lps_db -c 'TRUNCATE job;' (쌓인 부하 잡 제거)" diff --git a/lps/loadtest/locustfile.py b/lps/loadtest/locustfile.py new file mode 100644 index 0000000..0e933a9 --- /dev/null +++ b/lps/loadtest/locustfile.py @@ -0,0 +1,63 @@ +"""LPS **API 서버** 부하 테스트 (enqueue/조회 경로). + +부하 대상은 API(web_main) 다 — 워커(브라우저 크롤)는 프록시/브라우저에 묶여 처리량이 결정되므로 +Locust 대상이 아니다. API 는 요청을 받아 job 테이블에 적재만 하고 즉시 응답한다(asyncpg I/O 바운드). + +측정 목적 + 1) **멀티코어 활용**: API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn workers(=process_count) + 를 1→N 으로 올리며 RPS 가 스케일하는지 본다. (bench_multicore.sh 가 자동 비교) + 2) **부하 한계**: enqueue 는 job write(+dedupe unique index). 한계는 대개 DB(커넥션 풀·write 경합). + (pool_size+max_overflow)×2엔진×workers 가 PG max_connections 를 넘으면 거기서 막힌다. + +⚠️ **워커는 끄고** 실행하라(부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓였다가 끝나면 정리: + psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;" + -- 부하 중 커넥션 관측: SELECT count(*) FROM pg_stat_activity WHERE datname='lps_db'; + +실행 + locust -f loadtest/locustfile.py --host http://localhost:9600 # 웹 UI(:8089) + locust -f loadtest/locustfile.py --host http://localhost:9600 --headless -u 200 -r 20 -t 2m +""" + +import random + +from locust import HttpUser, between, task + + +class LpsApiUser(HttpUser): + # 실제 클라이언트처럼 짧게 쉬며 반복(과도한 wait 없이 API 한계를 본다) + wait_time = between(0.05, 0.3) + + def on_start(self): + self.last_job = None + + @task(6) + def enqueue_search(self): + # 고유 product_code → 실제 INSERT(활성 중복 dedupe 회피). 부하의 핵심 write 경로. + code = f"LOAD-{random.randint(0, 2_000_000_000)}" + body = {"data": [{"product_code": code, "product_name": "부하테스트 상품", + "specification": "1박스", "job_type": "batch"}]} + with self.client.post("/v1/lps/search", json=body, name="POST /search", catch_response=True) as r: + if r.status_code == 200 and r.json().get("accepted", 0) == 1: + self.last_job = (r.json().get("items") or [{}])[0].get("job_id") + r.success() + else: + r.failure(f"{r.status_code} {r.text[:120]}") + + @task(3) + def poll_job(self): # 접수 후 상태 폴링(DB read) + if not self.last_job: + return + with self.client.get(f"/v1/lps/jobs/{self.last_job}", name="GET /jobs/{id}", catch_response=True) as r: + r.success() if r.status_code == 200 else r.failure(f"{r.status_code}") + + @task(1) + def queue_stats(self): + self.client.get("/v1/lps/queue/stats", name="GET /queue/stats") + + @task(1) + def ops(self): + self.client.get("/v1/lps/ops", name="GET /ops") + + @task(1) + def readyz(self): + self.client.get("/readyz", name="GET /readyz")