o2o-negosium-original/lps/docs/operations.md
민헌 377389f495 fix(lps): IP 로테이션 안정성 검수 — 예산이 안 먹던 근본 원인 + 차단 시 풀 소각 차단
크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로
재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물).

**① 요청 예산이 사실상 발화하지 않았다 (핵심)**
유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를
0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴
임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은
포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼
드문드문한 실사용 패턴에서만 깨진다.
파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한
'1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라
IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가).
→ IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
   세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸).

**② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다**
쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로
웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도
9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14).
→ 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정,
   태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계).
   같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요).
   결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0).

**③ 종료가 임대를 반납하지 않았다**
close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용
풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를
위해 그대로 둔다).

**④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다.
sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다.

**⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다**
네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다.
확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은
회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다.

**⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용).
세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지.

테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로
바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:01 +09:00

281 lines
21 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) |
| `[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` 생성 후 최초 실행 시 테이블 자동 생성.
```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. 실행
**환경 개요** — 환경 구분이 없다. 어디서든 `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 서버** (요청 접수)
```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 회전 |
| `[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 조회 (유용한 쿼리)
```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) |
| `fatal_block` | 최근 1h '회전 무효' 차단 구조적 차단 마커(해외 IP ) + 서킷브레이커 트립(`env_block`). 1건만 나와도 발화 | (0 초과 ) |
> ⚠️ **`ip_request_no` 로 원인을 가르는 진단은 2026-08-05 이전 데이터엔 쓸 수 없다.**
> 그전에는 유휴 정리마다 카운터가 리셋돼 **실제 사용량과 무관하게 항상 `1`** 로 찍혔다.
> "전량 `ip_req#1` → IP 평판 문제" 로 내린 과거 결론(2026-07-28 배포서버 조사 등)은
> 그 착시일 수 있으니, 수정 이후 쌓인 데이터로 다시 판단할 것.
```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 |
| **네이버 `비정상적인 접근`(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-05 실측 · 미해결)
**증상**: 같은 코드·같은 공인 IP인데 호스트에서는 되고 컨테이너에서만 막힌다.
| | 호스트(macOS Chrome) | 컨테이너(Linux Chrome + Xvfb) |
|---|---|---|
| 네이버 msearch | 200 · 40건 | **405 + wtm_captcha** |
| 쿠팡 | 200 · 60건 | **403 Akamai(엣지)** |
배제한 원인: 공인 IP(동일TLS 지문(JA4·H2 해시 동일HTTP 헤더(HTTPS 에서 구조·순서 완전 동일,
차이는 `Accept-Language`/`Sec-Ch-Ua-Platform` 값뿐로케일(ko-KR 맞춰도 동일WebGL(SwiftShader
살려도 동일UA/플랫폼 스푸핑·리소스 라우팅.
**주의**: 실측 환경이 Apple Silicon 맥이라 컨테이너가 **amd64 Rosetta 에뮬레이션**한다.
실제 x86 리눅스 서버에서는 다를 있고, 2026-07-09 에는 같은 컨테이너로 8몰 크롤이 통과한 이력이 있다.
그러니 "컨테이너는 된다" 아니라 **" 맥의 컨테이너에서는 된다"** 읽어야 한다.
**2026-08-05 이후 피해 범위**: 서킷브레이커가 붙어서, 상황이 와도 **프록시 풀은 안 마른다**.
예전엔 웜업만으로 워커당 6포트, 1건당 6포트를 30분 쿨다운에 묶어 16건이면 100포트가
고갈됐다(실측). 지금은 판정 근거로 2개를 소각이 멈추고 `fatal_block` 알림이 곧바로 뜬다.
**"조용히 풀만 태우다 멈추는" 실패가 "즉시 알리고 멈추는" 실패로 바뀌었다** 원인 자체는
아직 미해결이므로 아래 순서로 확인한다.
**배포 시 확인 순서**
1. `docker logs lps-worker | grep -E "warmup|환경 차단"` 기동 직후 소스별 통과 여부가 찍힌다
2. 실패하면 같은 서버 호스트에서 `./run_local_worker.sh` 돌려 비교(호스트는 되는데 컨테이너만 막히는지)
3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다
(compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다)
## 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.<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 캐시** 다음 절감 후보.