diff --git a/docker-compose.yml b/docker-compose.yml index 0639251..b3829f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index bef8dba..469db4d 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -37,8 +37,13 @@ read_port = 5432 read_id = "" read_pw = "" 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 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ── diff --git a/lps/config/config_models.py b/lps/config/config_models.py index a6b279f..a8b913c 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -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 = "" diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 0f6ddbe..7e74058 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -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) diff --git a/lps/docs/operations.md b/lps/docs/operations.md index ba65325..39e894e 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -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. 로그 보는 법 (워커 터미널) | 로그 | 의미 | diff --git a/lps/loadtest/README.md b/lps/loadtest/README.md index 227fe77..b628fc3 100644 --- a/lps/loadtest/README.md +++ b/lps/loadtest/README.md @@ -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 diff --git a/lps/tests/test_pool_autosize.py b/lps/tests/test_pool_autosize.py new file mode 100644 index 0000000..3d780b3 --- /dev/null +++ b/lps/tests/test_pool_autosize.py @@ -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 diff --git a/lps/web_main.py b/lps/web_main.py index 310d11b..6f94403 100644 --- a/lps/web_main.py +++ b/lps/web_main.py @@ -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(