feat(lps): process_count 기반 커넥션 풀 자동 산정 — 멀티코어 풀 오버서브스크립션 방지
connection_budget(기본 40)을 두고, 기동 시 process_count 에 맞춰 pool_size/max_overflow 를 역산: (pool+overflow)×2엔진×process_count ≤ budget. 워커를 늘려도 config 가 스스로 예산을 지켜 커넥션 고갈→요청 실패를 예방. - config_models: MainDBConfig.connection_budget 추가 - server_configs: _autosize_pool(process_count 확정 후 산정) + _apply_pool_env_override (우선순위 = 명시 DB_POOL_SIZE > 자동 산정 > toml pool) - web_main: 기동 로그에 실효 풀/총커넥션/예산 출력 - env: DB_CONNECTION_BUDGET override, docker-compose 에 knob 노출 - tests: test_pool_autosize 10케이스(예산 준수·분할비·비활성·infeasible 바닥) - docs: operations 2-1 멀티코어/풀 섹션 + loadtest README 자동산정 표 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a63793daf0
commit
9c2432c818
@ -124,6 +124,10 @@ services:
|
||||
APP_ENV: local
|
||||
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override)
|
||||
PYTHONUNBUFFERED: "1"
|
||||
# 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다.
|
||||
# (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장.
|
||||
# PROCESS_COUNT: "4"
|
||||
# DB_CONNECTION_BUDGET: "40" # 전용 PG(max_connections≈100)면 90 근처로 상향
|
||||
ports:
|
||||
- "9600:9600"
|
||||
extra_hosts:
|
||||
|
||||
@ -37,8 +37,13 @@ read_port = 5432
|
||||
read_id = "<DB_USER>"
|
||||
read_pw = "<DB_PASSWORD>"
|
||||
show_log = false
|
||||
pool_size = 10
|
||||
max_overflow = 20
|
||||
pool_size = 10 # connection_budget>0 이면 무시(자동 산정). budget=0 일 때만 이 값 사용.
|
||||
max_overflow = 20 # 〃
|
||||
# 커넥션 예산(자동 산정). process_count 에 맞춰 pool_size/max_overflow 를 자동 계산:
|
||||
# (pool+overflow) × 2엔진 × process_count ≤ connection_budget.
|
||||
# 'lps API 가 쓸 총 커넥션 상한' — 공유 PG(max_connections)·동거 서비스(worker 등)를 고려한 값.
|
||||
# 예) 전용 PG(max_connections=100)면 90 근처, 공유 PG면 40 권장. 0 이면 자동 끔(위 pool 값 사용).
|
||||
connection_budget = 40 # env DB_CONNECTION_BUDGET 로 override
|
||||
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
|
||||
|
||||
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ──
|
||||
|
||||
@ -32,10 +32,14 @@ class MainDBConfig(ConfigModel):
|
||||
read_id: str = ""
|
||||
read_pw: str = ""
|
||||
show_log: bool = False
|
||||
# 커넥션 풀 사이징. 실제 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
|
||||
# PostgreSQL max_connections 를 넘지 않도록 설정해야 한다. (예: 10+20=30 x 2 x 5워커 = 300)
|
||||
# 커넥션 풀 사이징. 실제 동시 커넥션 = (pool_size + max_overflow) x 엔진수(R/W=2) x process_count.
|
||||
pool_size: int = 10
|
||||
max_overflow: int = 20
|
||||
# 커넥션 예산(자동 산정). >0 이면 process_count 에 맞춰 pool_size/max_overflow 를 자동 계산한다:
|
||||
# (pool+overflow)x2xprocess_count ≤ connection_budget 가 되도록. (이때 위 pool_size/max_overflow 는 무시)
|
||||
# PG max_connections·공유 DB 동거 서비스(worker·negosium 등)를 고려한 'lps API 가 쓸 총 커넥션 상한'.
|
||||
# 0 이면 자동 산정 끔(위 pool_size/max_overflow 그대로 사용). env DB_CONNECTION_BUDGET 로 override.
|
||||
connection_budget: int = 40
|
||||
# SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure).
|
||||
sslmode: str = ""
|
||||
|
||||
|
||||
@ -39,7 +39,35 @@ 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 를 넘지 않게 조절.
|
||||
# 커넥션 예산 override (자동 산정용). env DB_CONNECTION_BUDGET.
|
||||
if os.environ.get("DB_CONNECTION_BUDGET"):
|
||||
cfg.connection_budget = int(os.environ["DB_CONNECTION_BUDGET"])
|
||||
|
||||
|
||||
def _autosize_pool(cfg: MainDBConfig, process_count: int):
|
||||
"""process_count 기반 커넥션 풀 자동 산정.
|
||||
|
||||
실제 동시 커넥션 = (pool_size + max_overflow) × 2엔진(R/W) × process_count.
|
||||
이 값이 connection_budget 를 넘지 않도록 pool_size/max_overflow 를 역산한다.
|
||||
budget<=0 이면 비활성(toml 의 pool_size/max_overflow 그대로 사용).
|
||||
|
||||
반환: 자동 산정을 수행했으면 (pool_size, max_overflow), 아니면 None.
|
||||
"""
|
||||
budget = cfg.connection_budget
|
||||
if budget <= 0:
|
||||
return None
|
||||
pc = max(1, process_count)
|
||||
# 워커·엔진당 커넥션 = 예산 / (2엔진 × process_count). 최소 1(pool_size>=1) 보장.
|
||||
# (예산 준수가 최우선 — process_count 가 너무 크면 엔진당 1커넥션까지 줄여서라도 예산을 넘지 않는다)
|
||||
per_engine = max(1, budget // (2 * pc))
|
||||
# 정상(pool) 60% + 버스트(overflow) 40% 로 분할.
|
||||
cfg.pool_size = max(1, round(per_engine * 0.6))
|
||||
cfg.max_overflow = max(0, per_engine - cfg.pool_size)
|
||||
return cfg.pool_size, cfg.max_overflow
|
||||
|
||||
|
||||
def _apply_pool_env_override(cfg: MainDBConfig):
|
||||
"""명시적 풀 override — 자동 산정보다 우선(테스트·특수 배포용)."""
|
||||
if os.environ.get("DB_POOL_SIZE"):
|
||||
cfg.pool_size = int(os.environ["DB_POOL_SIZE"])
|
||||
if os.environ.get("DB_MAX_OVERFLOW"):
|
||||
@ -72,3 +100,9 @@ _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"])
|
||||
|
||||
# 커넥션 풀 자동 산정: process_count(위에서 확정) 기준으로 예산 안에 맞춘다.
|
||||
# → 멀티워커 배포 시 풀 오버서브스크립션(→커넥션 고갈)을 config 가 스스로 방지.
|
||||
_pool_autosized = _autosize_pool(main_db_config, web_server_config.process_count)
|
||||
# 명시적 DB_POOL_SIZE/DB_MAX_OVERFLOW 는 자동 산정보다 우선(최종 override).
|
||||
_apply_pool_env_override(main_db_config)
|
||||
|
||||
@ -50,6 +50,22 @@ N=8 python loadtest.py # 상품 8개 제출→처리량·지연(p50/p95)·AI/
|
||||
```
|
||||
> 워커 동시성만큼 병렬 처리됩니다(동시성 낮으면 큐에서 순차 대기 — 그게 부하 관측 포인트).
|
||||
|
||||
## 2-1. 멀티코어 스케일 & 커넥션 풀 (자동)
|
||||
|
||||
API 서버는 asyncio(스레드 1개)라 **1 프로세스 = 1 코어**입니다. 처리량을 코어만큼 올리려면
|
||||
`process_count`(uvicorn 워커 수)를 늘립니다 — 이때 **DB 커넥션 풀은 config 가 자동으로 맞춰줍니다**.
|
||||
|
||||
```
|
||||
실제 동시 커넥션 = (pool_size + max_overflow) × 2엔진(R/W) × process_count
|
||||
config 가 보장: 위 값 ≤ connection_budget (기본 40)
|
||||
```
|
||||
- `process_count` 를 올리면 `pool_size/max_overflow` 가 **자동으로 축소**되어 예산을 넘지 않습니다.
|
||||
(수동 튜닝 불필요 — 예전엔 이걸 안 맞춰서 워커↑ 시 커넥션 고갈→요청 실패가 났음)
|
||||
- 기동 로그에서 실효값 확인: `DB Pool : pool_size=.. max_overflow=.. × 2engine × Nworkers = M conns (budget=..)`
|
||||
- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `DB_CONNECTION_BUDGET=90` 으로 상향.
|
||||
- env 로 조절(코드/toml 수정 없이): `PROCESS_COUNT`, `DB_CONNECTION_BUDGET`, (특수 시)`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`.
|
||||
- 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치).
|
||||
|
||||
## 3. 로그 보는 법 (워커 터미널)
|
||||
|
||||
| 로그 | 의미 |
|
||||
|
||||
@ -48,7 +48,21 @@ API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `w
|
||||
```
|
||||
(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
|
||||
|
||||
**이 규칙은 이제 config 가 자동으로 지킨다** — `MainDBConfig.connection_budget`(기본 40)을 두면,
|
||||
기동 시 `process_count` 에 맞춰 `pool_size/max_overflow` 를 역산한다:
|
||||
`(pool+overflow)×2×process_count ≤ connection_budget`. 워커를 늘려도 커넥션이 예산을 넘지 않는다.
|
||||
(`server_configs._autosize_pool`. 기동 로그 `DB Pool : ... = N conns (budget=…)` 로 실효값 확인)
|
||||
|
||||
| process_count | 자동 산정(예산 40) | 총 커넥션 |
|
||||
|:---:|:---:|---:|
|
||||
| 1 | pool 12 / overflow 8 | 40 |
|
||||
| 2 | pool 6 / overflow 4 | 40 |
|
||||
| 4 | pool 3 / overflow 2 | 40 |
|
||||
| 8 | pool 1 / overflow 1 | 32 |
|
||||
|
||||
- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장. `env DB_CONNECTION_BUDGET`.
|
||||
(공유 PG 기본 40 은 API + worker + 타 서비스가 100 안에 공존하도록 잡은 안전값 → 처리량보다 안정 우선)
|
||||
- **override 우선순위**: 명시 `DB_POOL_SIZE`/`DB_MAX_OVERFLOW` > 자동 산정(budget>0) > toml `pool_size/max_overflow`(budget=0)
|
||||
- 더 큰 처리량이 필요하면: 예산 상향 + **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입
|
||||
- **부하 한계(이 머신)**: 예산 96(≈max_connections)·4워커 ~2,700 RPS, p95 ~900ms, 실패 0
|
||||
|
||||
57
lps/tests/test_pool_autosize.py
Normal file
57
lps/tests/test_pool_autosize.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""커넥션 풀 자동 산정(_autosize_pool) — process_count 기준으로 예산을 넘지 않아야 한다."""
|
||||
|
||||
import pytest
|
||||
|
||||
from config.config_models import MainDBConfig
|
||||
from config.server_configs import _autosize_pool
|
||||
|
||||
|
||||
def _conns(cfg: MainDBConfig, pc: int) -> int:
|
||||
# 실제 동시 커넥션 = (pool + overflow) × 2엔진(R/W) × process_count
|
||||
return (cfg.pool_size + cfg.max_overflow) * 2 * pc
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pc", [1, 2, 4, 8, 16])
|
||||
def test_autosize_within_budget(pc):
|
||||
cfg = MainDBConfig(connection_budget=40)
|
||||
_autosize_pool(cfg, pc)
|
||||
assert _conns(cfg, pc) <= 40
|
||||
assert cfg.pool_size >= 1
|
||||
assert cfg.max_overflow >= 0
|
||||
|
||||
|
||||
def test_autosize_uses_budget_efficiently():
|
||||
# 예산을 지나치게 낭비하지 않아야(넉넉한 예산일 때 절반 이상 활용)
|
||||
cfg = MainDBConfig(connection_budget=96)
|
||||
_autosize_pool(cfg, 4)
|
||||
assert _conns(cfg, 4) <= 96
|
||||
assert _conns(cfg, 4) >= 96 // 2
|
||||
|
||||
|
||||
def test_autosize_split_ratio():
|
||||
# pool(정상) 이 overflow(버스트)보다 크거나 같게 분할
|
||||
cfg = MainDBConfig(connection_budget=96)
|
||||
_autosize_pool(cfg, 1)
|
||||
assert cfg.pool_size >= cfg.max_overflow
|
||||
|
||||
|
||||
def test_autosize_disabled_when_budget_zero():
|
||||
# budget<=0 이면 자동 산정 끔 → toml/기본 pool 값 유지, None 반환
|
||||
cfg = MainDBConfig(connection_budget=0, pool_size=10, max_overflow=20)
|
||||
assert _autosize_pool(cfg, 4) is None
|
||||
assert (cfg.pool_size, cfg.max_overflow) == (10, 20)
|
||||
|
||||
|
||||
def test_autosize_returns_computed_pair():
|
||||
cfg = MainDBConfig(connection_budget=40)
|
||||
result = _autosize_pool(cfg, 2)
|
||||
assert result == (cfg.pool_size, cfg.max_overflow)
|
||||
|
||||
|
||||
def test_autosize_infeasible_process_count_floors_at_one():
|
||||
# process_count×2 > budget 이면 예산 준수가 물리적으로 불가능(워커당 최소 1커넥션 필요).
|
||||
# 이때는 pool_size=1/overflow=0(엔진당 1커넥션)까지 줄이는 게 한계 — 0 풀은 만들지 않는다.
|
||||
cfg = MainDBConfig(connection_budget=40)
|
||||
_autosize_pool(cfg, 64)
|
||||
assert cfg.pool_size == 1
|
||||
assert cfg.max_overflow == 0
|
||||
@ -9,7 +9,7 @@
|
||||
import uvicorn
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import web_server_config
|
||||
from config.server_configs import web_server_config, main_db_config
|
||||
|
||||
LOG.SetPrefix(web_server_config.server_name)
|
||||
|
||||
@ -20,6 +20,11 @@ if __name__ == "__main__":
|
||||
LOG.i(f"Server Name : {web_server_config.server_name}")
|
||||
LOG.i(f"Server Port : {web_server_config.port}")
|
||||
LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}")
|
||||
# 실효 커넥션 풀(자동 산정 결과) — 멀티워커 시 커넥션 예산 준수 여부 확인용.
|
||||
_pc = web_server_config.process_count
|
||||
_conn = (main_db_config.pool_size + main_db_config.max_overflow) * 2 * _pc
|
||||
LOG.i(f"DB Pool : pool_size={main_db_config.pool_size} max_overflow={main_db_config.max_overflow} "
|
||||
f"× 2engine × {_pc}workers = {_conn} conns (budget={main_db_config.connection_budget})")
|
||||
|
||||
if web_server_config.is_ssl:
|
||||
uvicorn.run(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user