크롤·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>
281 lines
21 KiB
Markdown
281 lines
21 KiB
Markdown
# 운영 가이드 — 실행 · 로그 · 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 캐시**가 다음 절감 후보.
|