o2o-negosium-original/lps/services/search/proxy.py
민헌 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

237 lines
13 KiB
Python

"""DECODO(구 Smartproxy) residential 프록시 제공자 — 쿠팡(Akamai) 전용.
Decodo residential 은 **포트 기반 sticky** 모델이다:
gate.decodo.com : 10001..N (각 포트 = 별도 sticky 세션, 대시보드에서 지속시간 지정: 예 10분)
username/password 는 고정. → **IP 회전 = 포트를 바꾸는 것.**
매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물.
→ 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP.
자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿).
"""
import time
from urllib.parse import quote
import httpx
from common.logger import LOG
from config.server_configs import decodo_config
class DecodoProxy:
def __init__(self, cfg=None, host: str | None = None, registry=None, owner: str = "", store=None):
cfg = cfg if cfg is not None else decodo_config
# host 를 넘기면 그 게이트웨이를 쓴다 — 국가 타깃(kr.decodo.com)용. 포트/자격증명은 동일.
self.host = host or cfg.host
# 포트 배정 방식 3가지(우선순위 순):
# store DB 장부(PortLeaseStore) — **프로세스 간 공유**. 여러 워커 프로세스가 한 계정을
# 나눠 쓸 때 유일하게 안전하다. 프로덕션 경로.
# registry 인메모리 중재자 — 단일 프로세스 전용(테스트·단독 실행)
# 둘 다 없으면 기존 계산식(시간창+오프셋)
self._store = store
self._registry = registry
self._owner = owner or "proxy"
self._leased: int | None = None
# DB 왕복은 비동기라 검색 루프(동기 호출부)에서 곧바로 못 한다. 회전·차단은 여기에 적어두고
# 다음 ensure_port(브라우저 재기동 직전, async)에서 한 번에 반영한다.
self._pending_release: tuple[int, float] | None = None # (port, rest_sec)
self._pending_burn: list[tuple[int, float]] = [] # [(port, cooldown_sec)]
self.username = cfg.username
self.password = cfg.password
self.port_start = cfg.port_start
self.port_end = cfg.port_end
self.session_minutes = cfg.session_minutes or 10
self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가
# 불탄(차단 감지된) 포트 격리 시간([DecodoConfig].port_cooldown_sec). sticky 만료(session_minutes)
# 이상이어야 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 0=자동 max(sticky, 30분).
self.cooldown_sec = getattr(cfg, "port_cooldown_sec", 0) or max(self.session_minutes * 60, 1800)
# 선제 회전으로 놓은 포트의 휴식 시간. 0=자동(sticky 수명) — 그 IP 의 세션이 만료돼
# 다음에 잡힐 땐 사실상 새 IP 가 배정된다. 차단 쿨다운(cooldown_sec)보다 훨씬 짧다.
self.rest_sec = getattr(cfg, "port_rest_sec", 0) or self.session_minutes * 60
self._burned: dict[int, float] = {} # port → 쿨다운 만료 시각(monotonic)
@property
def enabled(self) -> bool:
return all([self.host, self.username, self.password, self.port_start, self.port_end])
def rotate(self, kind: str = "rotate"):
"""시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지·예산 도달 시 호출.
kind="budget"(차단 전 선제 회전)이면 놓는 포트에 **휴식**을 준다 — 방금 쓴 IP 를
다른 워커가 곧바로 집으면 요청률이 도로 올라가 예산의 의미가 사라진다.
차단으로 태우는 건 mark_burned 가 따로 처리한다(휴식보다 훨씬 긴 쿨다운).
"""
self._rotate_offset += 1
if self._leased is None:
return
rest = self.rest_sec if kind == "budget" else 0
if self._store is not None:
self._pending_release = (self._leased, rest) # 다음 ensure_port 에서 DB 반영
self._leased = None
elif self._registry is not None:
self._registry.release(self.host, self._leased, self._owner, rest_sec=rest)
self._leased = None
def seed_offset(self, k: int):
"""워커별 시작 포트 분산용 — 동시 워커가 같은 포트(=같은 IP)를 쓰지 않도록 시작점을 벌린다."""
self._rotate_offset = k
def mark_burned(self, port: int | None, cooldown_sec: float | None = None):
"""차단 감지된 포트를 쿨다운 격리 — _port() 가 만료 전까지 건너뛴다.
선제(예산) 회전된 포트는 부르지 않는다 — 불탄 게 아니므로 로테이션 복귀 시 재사용."""
if port is None:
return
cd = cooldown_sec if cooldown_sec is not None else self.cooldown_sec
if self._store is not None:
self._pending_burn.append((port, cd))
if self._leased == port:
self._leased = None
LOG.i(f"[port] {self.host}:{port} 차단 기록 예약 {int(cd)}s ({self._owner})")
return
if self._registry is not None:
# 전역 격리 — 태운 포트를 다른 소스/워커가 곧바로 집는 걸 막는다.
self._registry.burn(self.host, port, cd, owner=self._owner, reason="block")
if self._leased == port:
self._leased = None
return
self._burned[port] = time.monotonic() + cd
LOG.i(f"[proxy] 포트 {port} 쿨다운 {int(cd)}s — 활성 {self.available_ports()}/{self.port_end - self.port_start + 1}")
async def ensure_port(self) -> int | None:
"""다음 요청에 쓸 포트를 확정한다(브라우저 재기동 직전에 await).
DB 장부 모드에서 이 함수가 유일한 I/O 지점이다: 밀린 반납·차단을 먼저 flush 하고,
임대가 없으면 새로 잡는다. 임대가 살아 있으면 연장(renew)해 sticky 수명 동안 붙잡는다
— 연장에 실패하면 남이 회수해 간 것이므로 새 포트를 잡는다.
"""
if self._store is None:
return self._port() if self.enabled else None
if self._pending_release is not None: # 선제 회전/일반 반납
port, rest = self._pending_release
self._pending_release = None
await self._store.release(self.host, port, self._owner, rest_sec=rest)
while self._pending_burn: # 차단 격리(전역)
port, cd = self._pending_burn.pop(0)
await self._store.burn(self.host, port, cd, owner=self._owner, reason="block")
lease_sec = self.session_minutes * 60
if self._leased is not None and not await self._store.renew(self.host, self._leased, self._owner, lease_sec):
self._leased = None # 만료·회수됨 → 새로 잡는다
if self._leased is None:
self._leased = await self._store.acquire(self.host, self._owner, lease_sec)
if self._leased is None:
LOG.w(f"[port] {self.host} 가용 포트 없음 — 전부 임대/휴식/쿨다운 중({self._owner})")
return self._leased
async def release(self):
"""쥐고 있는 임대를 **즉시 반납**한다(프로세스 종료 시).
이걸 안 하면 워커를 재시작해도 그 포트는 leased_until(sticky 수명, 기본 10분)까지 남의
것으로 남아 아무도 못 쓴다 — 재시작이 잦을수록 가용 풀이 계속 줄어든다.
**유휴 정리에서는 부르지 않는다**: 브라우저만 닫고 같은 IP 로 돌아와야 웜 쿠키
(cf_clearance 등)가 살고 요청 예산도 이어진다.
"""
await self.flush()
if self._leased is None:
return
port, self._leased = self._leased, None
if self._store is not None:
await self._store.release(self.host, port, self._owner)
elif self._registry is not None:
self._registry.release(self.host, port, self._owner)
async def flush(self):
"""밀린 반납·차단만 반영(검색 종료·셧다운 시). 포트를 새로 잡지는 않는다."""
if self._store is None:
return
if self._pending_release is not None:
port, rest = self._pending_release
self._pending_release = None
await self._store.release(self.host, port, self._owner, rest_sec=rest)
while self._pending_burn:
port, cd = self._pending_burn.pop(0)
await self._store.burn(self.host, port, cd, owner=self._owner, reason="block")
def available_ports(self) -> int:
"""쿨다운 중이 아닌 포트 수(관측용) — **인메모리 모드 전용**.
DB 장부(store) 모드에서는 이 값을 믿으면 안 된다: 차단은 _pending_burn 으로만 쌓였다가
DB 로 나가므로 로컬 _burned 는 늘 비어 있고, 다른 프로세스가 태운 포트도 보이지 않아
**항상 풀 크기(전량 가용)를 돌려준다**. 장부 모드의 실제 가용량은 비동기 조회라
여기서 낼 수 없다 — PortLeaseStore.snapshot() 을 쓴다(worker_main._port_pool_status).
"""
if self._registry is not None:
return self._registry.available(self.host)
self._prune_burned()
return (self.port_end - self.port_start + 1) - len(self._burned)
def _prune_burned(self):
now = time.monotonic()
self._burned = {p: t for p, t in self._burned.items() if t > now}
def _port(self) -> int:
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.
쿨다운 중인 포트는 건너뛰고, 전 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선)."""
if self._store is not None:
# 임대는 ensure_port(async)가 확정한다. 여기선 확정값을 돌려줄 뿐 — 동기 경로에서
# DB 를 만지지 않는다. 아직 못 잡았으면 계산식으로 폴백(로그·프리플라이트용).
if self._leased is not None:
return self._leased
if self._registry is not None:
lease_sec = self.session_minutes * 60
if self._leased is not None and self._registry.lease_expired(self.host, self._leased, self._owner, lease_sec):
self._registry.release(self.host, self._leased, self._owner) # sticky 수명 만료 → 새 IP
self._leased = None
if self._leased is None:
# 시작 오프셋은 기존과 동일한 의미(워커별 분산). 실제 중복 방지는 registry 가 보장한다.
bucket = int(time.time() // (self.session_minutes * 60))
start = (bucket + self._rotate_offset) % (self.port_end - self.port_start + 1)
self._leased = self._registry.acquire(self.host, self._owner, start_at=start, lease_sec=lease_sec)
return self._leased
n = self.port_end - self.port_start + 1
bucket = int(time.time() // (self.session_minutes * 60))
self._prune_burned()
for k in range(n):
port = self.port_start + ((bucket + self._rotate_offset + k) % n)
if port not in self._burned:
return port
return min(self._burned, key=self._burned.get)
@property
def current_port(self):
return self._port() if self.enabled else None
def playwright_proxy(self) -> dict | None:
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
if not self.enabled:
return None
return {
"server": f"http://{self.host}:{self._port()}",
"username": self.username,
"password": self.password,
}
def _proxy_url(self, port: int) -> str:
return f"http://{quote(self.username)}:{quote(self.password)}@{self.host}:{port}"
async def healthcheck(self, timeout: float = 6.0) -> tuple[str | None, int | None]:
"""시작 프리플라이트: 현재 포트로 egress IP 확인, 실패하면 회전하며 살아있는 포트를 찾는다.
반환: (egress_ip, port) 성공 / (None, None) 전 포트 실패. residential IP 는 실행 중에도
죽으므로 이건 '빠른 실패+가시성'용이고, 실제 회복은 런타임 IP 회전이 담당한다."""
if not self.enabled:
return None, None
n = self.port_end - self.port_start + 1
for _ in range(n):
port = self._port()
try:
async with httpx.AsyncClient(proxy=self._proxy_url(port), timeout=timeout) as c:
r = await c.get("https://ip.decodo.com/ip")
if r.status_code == 200:
return r.text.strip(), port
except Exception as ex:
LOG.d(f"[proxy] 포트 {port} 헬스체크 실패: {type(ex).__name__} → 회전")
self.rotate()
return None, None