test(lps): Locust API 부하 테스트 + 멀티코어 벤치 (풀 병목 발견·증명)
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 <noreply@anthropic.com>
This commit is contained in:
parent
eba78dbbb9
commit
a63793daf0
@ -39,6 +39,11 @@ def _apply_db_env_override(cfg: MainDBConfig):
|
|||||||
cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"]
|
cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"]
|
||||||
if os.environ.get("DB_NAME"):
|
if os.environ.get("DB_NAME"):
|
||||||
cfg.name = os.environ["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(또는 시크릿매니저)로 주입한다.
|
# 시크릿 env override — 프로덕션에선 API 키를 이미지에 굽지 않고 env(또는 시크릿매니저)로 주입한다.
|
||||||
@ -63,3 +68,7 @@ def _apply_secret_env_override():
|
|||||||
|
|
||||||
_apply_db_env_override(main_db_config)
|
_apply_db_env_override(main_db_config)
|
||||||
_apply_secret_env_override()
|
_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"])
|
||||||
|
|||||||
54
lps/loadtest/README.md
Normal file
54
lps/loadtest/README.md
Normal file
@ -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
|
||||||
69
lps/loadtest/bench_multicore.sh
Executable file
69
lps/loadtest/bench_multicore.sh
Executable file
@ -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;' (쌓인 부하 잡 제거)"
|
||||||
63
lps/loadtest/locustfile.py
Normal file
63
lps/loadtest/locustfile.py
Normal file
@ -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")
|
||||||
Loading…
Reference in New Issue
Block a user