o2o-negosium-original/lps/docs/operations.md
민헌 c81df5bd88 refactor(lps): 설정을 TOML 단일 소스로 통합 — env/.env 이중 관리 제거
설정이 .env(compose 주입)·config.toml·코드 곳곳의 os.environ 직독 3계층에
흩어져 관리가 어려웠다. TOML 하나로 통합한다(협의 결정).

- 신설 [WorkerConfig](동시성·폴백·프로필·데드라인·유예·Chrome·하트비트),
  [AlertConfig](웹훅·쿨다운·임계 10종). [WebServerConfig].api_keys(guard),
  [DecodoConfig].ip_request_budget/port_cooldown_sec 추가 — 흩어져 있던
  LPS_* env 20여 개를 섹션으로 흡수.
- server_configs 의 env override 계층(DB_*·시크릿·NAVER_KEYS 등) 삭제.
  남는 env 는 APP_ENV(부트스트랩)·PROCESS_COUNT/WORKER_CONCURRENCY(실행
  스크립트 대화형 입력 전용)·LPS_LIVE(테스트 옵트인)뿐.
- Docker: env 주입 → config.docker.toml 마운트 + APP_ENV=docker.
  이미지 무시크릿 유지, 마운트 누락 시 FileNotFoundError 즉시 실패.
  .env.example 삭제, config.docker.toml.example 신설.
- negodata 호출부: guard 키를 env 직독에서 [WebServerConfig].lps_api_key
  (+기존 관례대로 env override)로 이동.
- 실행 스크립트: 프로필·폴백·예산 프롬프트 제거(toml 소스 안내),
  동시성/프로세스 수만 임시 override 로 유지.
- docs 7종·example toml 의 env 표기를 toml 키로 일괄 갱신.
- 전체 145 passed + APP_ENV=docker 로딩·API 기동 스모크 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:11:31 +09:00

227 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 운영 가이드 — 실행 · 로그 · DB · 문제 해결
[← README로](../README.md)
## 1. 사전 준비
**필요한 것**: Python 3.12+ (로컬은 3.14), PostgreSQL, Google Chrome(쿠팡 크롤링용)
**설정 파일** (`config/config.local.toml`, git 미커밋)
```bash
cp config/config.local.toml.example config/config.local.toml
```
채워야 할 값:
| 섹션 | 값 |
|------|-----|
| `[MainDBConfig]` | DB 접속(host/port/id/pw, name=lps_db) |
| `[NaverConfig].keys` | 네이버 쇼핑 API 키(id/secret). 여러 개면 자동 로테이션 |
| `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) |
| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) |
> **설정 소스는 TOML 하나다**(2026-07-13 협의 — env/.env 이중 관리 제거). 호스트 실행은
> `config.local.toml`, Docker 는 `config.docker.toml`(example 복사)을 컨테이너에 마운트하고
> `APP_ENV=docker` 로 읽는다. 이미지에는 시크릿이 없고(빌드 시 `.dockerignore` 제외), 마운트를
> 잊으면 기동 시 FileNotFoundError 로 즉시 실패한다. env 는 `APP_ENV`·실행 스크립트의 대화형
> 입력(`PROCESS_COUNT`/`WORKER_CONCURRENCY`)·`LPS_LIVE`(테스트)만 남는다.
**DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성.
```bash
createdb -h 127.0.0.1 -U postgres lps_db
psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;"
```
## 2. 실행
**API 서버** (요청 접수)
```bash
./run_local_server.sh # → http://localhost:9600/docs
```
**워커** (실제 검색 수행) — 별도 터미널
```bash
./run_local_worker.sh # 대화형: 동시성(WORKER_CONCURRENCY)·Chrome 프로필·폴백 선택
# 또는 직접:
PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간. 동시성·폴백 등은 config.local.toml [WorkerConfig]
WORKER_CONCURRENCY=3 python worker_main.py # 동시성만 실행 시 임시 override 가능(권장 2~3, Chrome 최대 4×N개)
# 오픈마켓 폴백 재가동: [WorkerConfig].fallbacks = ["gmarket","auction","st11"] (기본 OFF — decision 문서 참고)
```
> 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프리플라이트 OK — egress IP ...`, `AI: ON/OFF`가 표시됩니다.
> 동시성 N이면 상품 N개가 진짜 병렬 처리됩니다(각 워커가 자기 프로필·프록시 IP 사용).
**워커 종료 (graceful)**
- `Ctrl+C`(SIGINT) 또는 `docker stop`(SIGTERM) 1회 → **새 잡은 안 받고, 하던 잡을 마무리한 뒤** 리스너·브라우저를 정리하고 종료합니다(`LPS 워커 종료 완료` 로그, 트레이스백 없음).
- 유예시간 `[WorkerConfig].shutdown_grace_sec`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다.
- Docker 는 compose 의 `stop_grace_period: 75s`(유예 60s + 정리 여유)가 SIGKILL 을 그만큼 미뤄줍니다 — 유예를 늘리면 이 값도 같이 늘리세요.
**부하 테스트**
```bash
N=8 python loadtest.py # e2e: 상품 8개 제출→처리량·지연(p50/p95)·AI/DECODO/총비용 집계 (워커 필요)
./run_loadtest_gui.sh # API 부하: Locust 웹 UI(:8089)에서 RPS/지연 실시간 관측 (워커 OFF)
```
> e2e(loadtest.py)는 워커 동시성만큼 병렬 처리됩니다(동시성 낮으면 큐에서 순차 대기 — 그게 부하 관측 포인트).
> API 부하(GUI)는 enqueue/조회 경로만 측정하므로 **워커를 끄고** 실행합니다(실제 크롤 비용 회피).
## 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`)면 `[MainDBConfig].connection_budget = 90` 으로 상향.
- `PROCESS_COUNT` env 는 실행 스크립트·부하벤치의 대화형 입력 전용 임시 override(설정은 toml 이 소스).
- 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치).
## 3. 로그 보는 법 (워커 터미널)
| 로그 | 의미 |
|------|------|
| `DECODO 프리플라이트 OK — egress IP ...` | 시작 시 살아있는 프록시 포트 선점 성공(egress IP 표시) |
| `[warmup:gmarket] 챌린지 통과·쿠키 확보` | 시작 웜업 — 챌린지 미리 풀어 쿠키 선점(실 작업 웜) |
| `[naver] query='...' → N건` | 네이버 검색 결과 수 |
| `[coupang] query='...' → N건 (ip_req#K)` | 쿠팡 결과 수 / 이 IP로 K번째 요청 |
| `[gmarket/auction/st11] query='...' → N건` | 오픈마켓 폴백 크롤 결과 수 |
| `[ai] 판정 N건 중 매칭 M건` | AI 같은상품 선별 결과 |
| `[coupang] IP 회전 — 요청예산 3회 도달` | 예산 선제 회전(정상 동작 — 차단 전 교체, 포트는 재사용됨) |
| `[proxy] 포트 10005 쿨다운 1800s — 활성 N/100` | 차단 감지된 포트 격리(만료까지 로테이션이 건너뜀) |
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → 포트 쿨다운 + IP 회전 |
| `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP |
| `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 |
| `[coupang] 유휴 120s 초과 → 브라우저 정리` | 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동) |
| `[worker-0] done <id>` / `fail ... → DEAD` | 작업 완료 / 실패 |
> 디버그 로그가 안 보이면 `config.local.toml`의 `[LogConfig] log_level = "debug"` 확인.
## 4. DB 조회 (유용한 쿼리)
```bash
psql -h 127.0.0.1 -U postgres -d lps_db
```
```sql
-- 큐 상태 요약 (1=대기 2=처리중 3=완료 4=실패)
SELECT status, count(*) FROM job GROUP BY status;
-- 최근 작업 결과
SELECT job_id, status, result->>'outcome' AS outcome,
result->'lowest'->>'price' AS lowest, result->'sources' AS sources
FROM job ORDER BY created_at DESC LIMIT 5;
-- 특정 상품의 최저가 이력(그래프 원본) + 몰별 스냅샷
SELECT triggered_at, naver_lowest, coupang_lowest, final_lowest, final_source, by_mall
FROM price_history WHERE product_code='T1' ORDER BY triggered_at;
-- 검색 원가(최근 완료 작업의 metrics)
SELECT job_id,
result->'metrics'->'cost'->>'total_usd' AS 총비용,
result->'metrics'->'cost'->>'proxy_usd' AS DECODO,
result->'metrics'->'crawl'->>'proxy_bytes' AS 전송바이트,
result->'metrics'->>'duration_ms' AS 소요ms
FROM job WHERE status=3 ORDER BY updated_at DESC LIMIT 5;
-- 봇 감지 패턴 (IP당 평균 몇 요청 만에 감지?)
SELECT avg(ip_request_no), count(*) FROM bot_detection;
-- 네거티브 캐시(없음으로 기록된 상품)
SELECT key, until, reason FROM search_negative ORDER BY created_at DESC;
```
## 4-1. 관측·알림 (모니터링)
| 엔드포인트/신호 | 용도 |
|------|------|
| `GET /healthz` | liveness — 프로세스 살아있는지(DB 무관) |
| `GET /readyz` | readiness — DB 도달성까지 확인(실패 503). LB/오케스트레이터용 |
| `GET /v1/lps/ops` | 운영 스냅샷: 큐 카운트 + `oldest_pending_sec`(큐 지연) + `dead_1h` + `stuck_running` + `blocks_1h`(최근 차단). 외부 모니터가 스크랩·알림 |
| 워커 하트비트 | `/tmp/lps_worker_heartbeat`(mtime) — 컨테이너 HEALTHCHECK 가 신선도<120s 로 행/좀비 워커 감지 |
**실시간 대시보드(로컬)**: `./run_monitor.sh` → http://localhost:9700 — 큐 추이·처리량(개/분)·
코어별 CPU·프로세스 그룹(worker/api/chrome/postgres) 사용률을 2초 간격으로 시각화.
부하테스트/e2e(`N=100 python loadtest.py`) 관측용. 상세는 `loadtest/README.md`.
**임계 알림**(AlertManager — 워커 ops-monitor + API 풀 모니터 공용): 룰별로 상태를 관리해
발화 시 1회 + 쿨다운(기본 30분)마다 리마인드, **조건 해소 시 '해소' 알림 1회**를 보낸다
(과거처럼 조건 지속 중 30초마다 반복 발송되지 않음). WARN/INFO 로그는 항상, 웹훅은 env 있을 때만.
| 룰 키 | 조건 | 임계 [AlertConfig] 키(기본) |
|------|------|----------------|
| `dead` | 최근 1h DEAD 잡 수 | `dead_1h`(20) |
| `blocks` | 최근 1h 봇 감지 수 | `blocks_1h`(80) |
| `queue_lag` | 가장 오래된 PENDING 대기 초 | `queue_lag_sec`(300) |
| `stuck` | lease 만료 RUNNING 잔존 | (0 초과 시) |
| `db_pool` | DB 커넥션 풀 포화율(%) — 워커·API 각자 자기 풀 감시 | `pool_pct`(90) |
| `source_fail:<src>` | 소스별 최근 30분 시도 N회 이상 & 성공 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) | `source_fail_30m`(5) |
| `deadline` | 최근 1h 잡 데드라인 강제종료 수(크롤 행 반복 신호 — 재시도로 살아나면 dead 엔 안 잡힘) | `deadline_1h`(5) |
| `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `cost_1h_usd`(1.0) |
| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) |
| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `block_sessions_6h`(1) |
```toml
[AlertConfig]
webhook = "https://hooks.slack.com/..." # 있으면 웹훅 알림 전송(워커·API 공통)
cooldown_min = 30 # 같은 룰 재발송 억제 시간(분)
```
지표는 알림 없이도 `GET /v1/lps/ops` 로 노출된다(`pool_pct`·`deadline_1h`·`cost_1h_usd` 포함) — 외부 모니터 스크랩용.
(`proxy_ports_avail`·`block_sessions_6h` 는 워커 웹훅 스냅샷에만 포함 — 프록시 상태는 워커 프로세스에만 있음)
## 5. 테스트
```bash
python -m pytest # 단위·통합(145) — 브라우저/네트워크 불필요
LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라이브 스모크(셀렉터·안티봇 드리프트 감지)
```
> ⚠️ **워커가 실행 중이면 테스트가 깨집니다** — 워커가 같은 `lps_db`의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요:
> ```bash
> pkill -f worker_main.py
> ```
> 라이브 스모크는 IP 의존·느려서 기본 skip. 배포 후 셀렉터가 깨졌는지 수동/야간 점검용.
## 6. 문제 해결
| 증상 | 원인 / 해결 |
|------|------------|
| 포트 9600 사용 중 | `lsof -ti:9600 \| xargs kill` 후 재실행 |
| 백그라운드 실행 시 로그 안 보임 | `print` 버퍼링 → `PYTHONUNBUFFERED=1` 붙여 실행 |
| `프리플라이트 실패`/모든 크롤 실패 | DECODO 프록시 문제 — **대시보드에서 잔여 트래픽·플랜·자격증명** 확인(407=인증거부). 게이트 다운이면 네이버(직접)만 동작 |
| G마켓 결과 계속 0건 | Cloudflare Turnstile 미통과(나쁜 IP는 인터랙티브 체크박스) — 웜업 IP회전 재시도로 완화. 지연 부담이면 폴백 데드라인이 스킵 |
| 쿠팡 `blocked=True`(Access Denied 등) | Akamai 차단 → 자동 IP 회전(감지 이력 `bot_detection`). 반복되면 프록시 IP 풀 확대 |
| Chrome이 계속 쌓임 | 유휴 정리(120s)가 닫음. 스파이크/이전 워커 잔여는 `pkill -f "user-data-dir=/tmp/lps_"` |
| AI 매칭이 0건 자주 발생 | 검색어 모호/스펙 불일치 → `product_name`/`specification`을 더 정확히 |
| 검색이 너무 느림/비쌈 | `result.metrics`로 소스별 시간·DECODO 바이트 확인. 대역폭이 대부분(오픈마켓 크롤) |
| `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id |
## 7. Docker 배포
```bash
# 루트에서 (DB 는 외부 PostgreSQL, host.docker.internal 로 연결)
docker compose build lps-api lps-worker
docker compose up -d lps-api lps-worker
docker logs -f lps-worker # 웜업·검색 로그
docker ps # lps-worker "(healthy)" 확인
```
- **워커 = 헤드풀 Chromium + Xvfb**(`Dockerfile.worker`): **headless 는 Akamai·Cloudflare Turnstile 에 탐지됨**(실측). Xvfb 가상 디스플레이로 headful 실행.
- **API = lean**(`Dockerfile`, 브라우저 불필요).
- **시크릿은 이미지에 없음(강제)**: 이미지는 example config 로 빌드된다(`.dockerignore` 가
config.local.toml·`.profiles/` 제외). 실값은 **`lps/config/config.docker.toml`**(example 복사,
미커밋)을 compose 가 마운트해 주입(`APP_ENV=docker`). 마운트를 잊으면 기동 시 즉시 실패. 기동 로그의
`AI: ON/OFF`·`DECODO 프록시: ON/OFF` 로 주입 성공을 반드시 확인할 것.
- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `[WorkerConfig].profile_dir`): 재시작해도 cf_clearance 유지 → 재웜업 회피.
- **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. compose 의 `restart` 는 unhealthy 를
재시작하지 않으므로 **autoheal 컨테이너**(라벨 `autoheal=true` 감시)가 재시작 담당. k8s 는 liveness probe 로 대체.
- **잡 데드라인**: 잡 1건 300s 상한(`[WorkerConfig].job_deadline_sec`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함.
- **IP 선제 회전**: `[DecodoConfig].ip_request_budget`(기본 3) — IP당 요청 예산, 도달 시 차단 전에 회전(0=비활성).
`[DecodoConfig].port_cooldown_sec`(0=자동 max(sticky, 1800)) — 차단 감지된 포트 격리 시간. 포트 수를 늘리면
([DecodoConfig].port_start/end) 자동 반영 — 코드에 포트 수 하드코딩 없음. 튜닝은 `ip_session` 분석 쿼리(database.md) 참고.
- **API guard**: `[WebServerConfig].api_keys` 설정 시 `/v1/*` 전체에 X-API-Key 검증(복수 키 —
무중단 교체). 개발(local/dev)은 빈값=개방 모드. **prod 체크리스트**: ① config.docker.toml 에
`api_keys` 채움(negodata 쪽은 `lps_api_key` 에 같은 키 — 헤더 자동 첨부) ② lps-api 포트 공개
제거(내부 네트워크만, `ports:` 삭제) ③ 기동 로그에서 `API guard ON` 확인.
**남은 배포 과제**: 레이트리밋(키별 요청량 제한), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정.
**비용**: 대역폭이 원가의 대부분(오픈마켓 크롤) — 같은 상품 재크롤을 줄이는 **TTL 캐시**가 다음 절감 후보.