o2o-negosium-original/lps/docs/operations.md
민헌 8d15fe6bb0 fix(lps): 네이버 컨테이너 차단 해결 — 원인은 UA 의 플랫폼 토큰이었다
컨테이너를 로컬에 재현해 규명했다. '컨테이너가 문제'가 아니라 **UA 가 리눅스라고 말하는 것**이
원인이다. 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다.

실측(같은 이미지·같은 KR 프록시, 서로 다른 IP 3개씩):
  X11; Linux x86_64        0/3 통과   전부 405 + wtm_captcha (~50KB)
  Macintosh; Intel Mac     3/3 통과   전부 200 · 6건 · ~1.0MB
  안드로이드·아이폰 모바일    0/2       418 '비정상적인 접근'(2.6KB, 회전 무효 하드차단)
프록시 없이 같은 집 IP 로도 호스트 통과 / 컨테이너 차단이 재현돼 IP·게이트웨이는 배제됐다.
맥에서 잘 되던 이유도 이걸로 설명된다.

**405 가 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 그동안 의심하던
WebGL·폰트·plugins 는 애초에 원인이 될 수 없었다(확인차 --enable-unsafe-swiftshader 로
WebGL 을 살려봤지만 405 그대로였다).

조치:
- services/search/user_agent.py: 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환. Chrome 버전은
  `--version` 으로 실제 값을 읽어 유지한다 — 하드코딩하면 컨테이너 Chrome 업데이트 시
  UA 와 엔진이 어긋나 그 불일치가 새 봇 신호가 된다. 조회 실패해도 크롤을 막지 않는다.
- NaverShopAdapter.mac_ua_on_linux = True (쿠팡은 잘 통과하므로 기본 False 그대로 — 멀쩡한 걸
  건드리지 않는다). 맥/윈도우에서는 자동 미적용.
- 실제 어댑터로 컨테이너 검증: '생수' 40건, '스페셜티 원두 1kg' 40건 통과.

부수:
- fingerprint.judge: WebGL 이 **아예 없는** 경우를 OK 로 흘려보내던 판정 버그 수정(컨테이너
  재현 중 발견 — 소프트웨어 렌더링보다 더 튀는 값인데 침묵했다). UA 플랫폼 항목 추가.
- docs/operations.md: '미해결' 절을 원인·수치·조치·확인법으로 교체.
- fingerprint.py: JS 위장이 이 스택에서 불가능하다는 실측 기록 유지(재시도 방지).

테스트 8건 추가(리눅스에서만 보정·실제 버전 유지·조회 실패 폴백·네이버만 opt-in), 전체 270 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:40:14 +09:00

21 KiB
Raw Permalink Blame History

운영 가이드 — 실행 · 로그 · DB · 문제 해결

← README로

1. 사전 준비

필요한 것: Python 3.12+ (로컬은 3.14), PostgreSQL, Google Chrome(쿠팡 크롤링용)

설정 파일 (config/config.local.toml, git 미커밋)

cp config/config.local.toml.example config/config.local.toml

채워야 할 값:

섹션 값
[MainDBConfig] DB 접속(host/port/id/pw, name=lps_db)
[NaverApiHubConfig] NCP 네이버 API 허브(검색·트렌드·쇼핑인사이트). 쇼핑 검색은 없음 — 최저가엔 미사용
[OpenAIConfig] api_key (AI 판정·검색어 생성용)
[DecodoConfig] 프록시 정보(비워두면 프록시 미사용). kr_host=네이버용 한국 게이트웨이(비우면 네이버가 막힌다), naver_ip_request_budget=IP 당 요청 예산(실측 12회 무차단 → 10)

설정 소스는 config.local.toml 하나다(도메인 backend·negodata·agent 와 동일). 환경 구분이 없다 — 호스트 실행·Docker·prod 서버 모두 같은 파일명을 쓰고, 서버마다 그 서버의 값(시크릿·guard 키·스케일)을 담는다(미커밋). 컨테이너는 compose 가 이 파일을 마운트한다 — 이미지엔 시크릿이 없고(빌드 시 .dockerignore 제외), 마운트를 잊으면 기동 시 FileNotFoundError 로 즉시 실패한다. env 로는 '환경별로 바뀌는 접속점'만 준다: DB_HOST(컨테이너→호스트 DB, compose 가 host.docker.internal 주입 — 관리형 DB 면 LPS_DB_HOST= 로 끔), 그리고 실행 스크립트의 대화형 입력(PROCESS_COUNT/WORKER_CONCURRENCY)·LPS_LIVE(테스트).

DB 준비: lps_db 생성 후 최초 실행 시 테이블 자동 생성.

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. 실행

환경 개요 — 환경 구분이 없다. 어디서든 config.local.toml 하나(서버마다 그 서버의 값):

실행 위치 설정 파일 실행 방법
개발자 호스트(비도커) config.local.toml ./run_local_server.sh + ./run_local_worker.sh
개발/운영 서버(Docker) config.local.toml (그 서버 값) docker compose up -d (또는 ./run_docker.sh — lps 서브셋·안전장치)

prod도 별도 환경이 아니다 — prod 서버의 config.local.toml 에 prod 값(시크릿·api_keys guard·스케일)을 채우고 docker compose up -d. 외부 노출 차단은 export LPS_API_BIND=127.0.0.1(리버스프록시 뒤).

API 서버 (요청 접수)

./run_local_server.sh            # → http://localhost:9600/docs

워커 (실제 검색 수행) — 별도 터미널

./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 을 그만큼 미뤄줍니다 — 유예를 늘리면 이 값도 같이 늘리세요.

부하 테스트

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 참고(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 회전
[coupang] 미지의 0건 응답(short_html(NB)) — 태우지 않고 회전만 한다 알려진 차단 마커가 없다 = 진짜 '검색결과 없음'일 수 있다. 확신이 없어 30분 쿨다운은 걸지 않는다(회전·재시도만)
[coupang] 환경 차단 — 서로 다른 IP 3개가 모두 첫 요청부터... 서킷브레이커 트립. IP 문제가 아니라는 판정이라 포트를 더 태우지 않는다 — 실행 환경/게이트웨이를 확인할 것
[coupang] 환경 차단 해제 — 검색 성공 서킷브레이커 리셋(성공 1회로 자동 해제 — 별도 조치 불필요)
[gmarket] IP 회전 — 프록시 전송오류/봇 감지 프록시 죽음(407/터널) 또는 차단 → 새 IP
[fallback:gmarket] 데드라인 15s 초과 → 스킵 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵
[coupang] 유휴 120s 초과 → 브라우저 정리 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동)
[worker-0] done <id> / fail ... → DEAD 작업 완료 / 실패

디버그 로그가 안 보이면 config.local.toml의 [LogConfig] log_level = "debug" 확인.

4. DB 조회 (유용한 쿼리)

psql -h 127.0.0.1 -U postgres -d lps_db
-- 큐 상태 요약 (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)
fatal_block 최근 1h '회전 무효' 차단 수 — 구조적 차단 마커(해외 IP 등) + 서킷브레이커 트립(env_block). 1건만 나와도 발화 (0 초과 시)

⚠️ ip_request_no 로 원인을 가르는 진단은 2026-08-05 이전 데이터엔 쓸 수 없다. 그전에는 유휴 정리마다 카운터가 리셋돼 실제 사용량과 무관하게 항상 1 로 찍혔다. "전량 ip_req#1 → IP 평판 문제" 로 내린 과거 결론(2026-07-28 배포서버 조사 등)은 그 착시일 수 있으니, 수정 이후 쌓인 데이터로 다시 판단할 것.

[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. 테스트

python -m pytest                                   # 단위·통합(145) — 브라우저/네트워크 불필요
LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke   # 라이브 스모크(셀렉터·안티봇 드리프트 감지)

⚠️ 워커가 실행 중이면 테스트가 깨집니다 — 워커가 같은 lps_db의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요:

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
네이버 비정상적인 접근(2.6KB) 해외 IP 로 접근한 것 — IP 회전으로 회복 불가. [DecodoConfig].kr_host 가 비었거나 오타. 구조적 차단이라 포트를 태우지 않고 즉시 실패하며 fatal_block 알림이 뜬다
네이버 wtm_captcha(47~63KB) IP 평판/세션 — 자동 IP 회전으로 회복. 반복되면 KR 풀 소모 상태(proxy_port 테이블) 확인
환경 차단 로그 / fatal_block 알림 서로 다른 IP 3개가 모두 첫 요청부터 막혔다 = IP 로 설명 안 되는 차단. 포트 소각이 자동으로 멈추니 풀 고갈을 걱정하지 말고 실행 환경(컨테이너 vs 호스트)·게이트웨이 국가 설정부터 볼 것. 환경이 회복되면 검색 성공 1회로 자동 해제
기동 직후 [warmup:*] 3회 모두 실패 그 소스가 이 환경에서 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고
워커는 healthy 인데 계속 0건 하트비트는 크롤 성공과 무관하다. docker logs에서 [warmup:*] 줄과 BOT-DETECTED 마커를 먼저 볼 것

네이버 컨테이너 차단 (2026-08-06 · 해결)

증상: 같은 코드·같은 IP 인데 호스트는 되고 컨테이너에서만 네이버가 막혔다(HTTP 405 + wtm_captcha). 쿠팡은 같은 컨테이너에서 정상이었다.

원인: 컨테이너가 아니라 User-Agent 의 플랫폼 토큰. 네이버는 리눅스 데스크톱 Chrome 을 거부한다.

UA 결과
X11; Linux x86_64 0/3 통과 — 전부 HTTP 405 + wtm_captcha (~50KB)
Macintosh; Intel Mac OS X 3/3 통과 — 전부 HTTP 200 · 6건 · ~1.0MB
안드로이드·아이폰 모바일 0/2 — HTTP 418 비정상적인 접근(2.6KB, 회전 무효 하드차단)

같은 KR 프록시로 IP 3개씩 실측. 프록시 없이(동일 IP) 호스트↔컨테이너 비교에서도 결과가 같아 IP·게이트웨이 요인은 배제됐다. 맥에서 잘 되던 이유도 이걸로 설명된다.

405 라는 점이 열쇠였다 — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 WebGL·폰트·plugins 같은 JS 지문은 원인이 될 수 없었다(실측으로도 확인: --enable-unsafe-swiftshader 로 WebGL 을 살려줘도 405 그대로).

조치: NaverShopAdapter.mac_ua_on_linux = True — 리눅스에서만 UA 의 플랫폼 토큰을 맥으로 바꾼다. Chrome 버전은 실제 값을 읽어 유지한다(하드코딩하면 업데이트 시 불일치가 새 신호가 된다). 쿠팡은 잘 통과하므로 UA 를 건드리지 않는다. 자세한 근거는 services/search/user_agent.py.

확인

docker logs lps-worker | grep -E "warmup:naver|환경 차단"     # 통과하면 [warmup:naver] 통과·쿠키 확보
docker cp lps/diag_naver.py lps-worker:/app/ && \
  docker exec -it lps-worker python /app/diag_naver.py       # 지문·통과 여부 한 번에

참고: JS 로 지문을 덮는 방법(WebGL 스푸핑 등)은 이 스택에서 동작하지 않는다 — patchright 가 add_init_script(CDP 주입)을 무력화하고, MV3 확장도 값이 바뀌지 않았다. 배경은 services/search/fingerprint.py docstring.

7. Docker 배포

# 루트에서 (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.<env>.toml(dev/prod example 복사, 미커밋)을 compose 가 마운트해 주입(APP_ENV 선택). 마운트를 잊으면 기동 시 즉시 실패. 기동 로그의 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 검증(복수 키 — 무중단 교체). 개발기는 빈값=개방 모드. prod 체크리스트: ① prod 서버의 config.local.toml 에 api_keys 채움(negodata 쪽은 lps_api_key 에 같은 키 — 헤더 자동 첨부) ② lps-api 외부 노출 차단 (export LPS_API_BIND=127.0.0.1 또는 compose ports: 삭제) ③ 기동 로그에서 API guard ON 확인.

남은 배포 과제: 레이트리밋(키별 요청량 제한), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정. 비용: 대역폭이 원가의 대부분(오픈마켓 크롤) — 같은 상품 재크롤을 줄이는 TTL 캐시가 다음 절감 후보.