fix(lps): 풀 고갈 시 남의 IP 를 빌려 쓰던 문제 — 임대 없으면 크롤하지 않는다

동시 다상품 검색 점검 중 발견. 워커 3개(소유자 6)가 포트 2개/게이트웨이를 두고 경합하는
상황을 실제 코드로 돌리니, 임대를 못 받은 워커가 **남이 쥔 포트를 그대로 집어 같은 IP 로
동시에 요청**했다:

  coupang-w1   사용=70002 임대=70002
  coupang-w2   사용=70002 임대=None    ← 같은 IP 를 둘이 사용

원인은 _port() 의 계산식 폴백이다. 장부 모드에서 acquire 가 None 을 줘도 시간창 계산으로
포트를 하나 골라 돌려줬다. 포트 장부가 존재하는 이유("워커 N개가 같은 IP 에 요청을 몰면 그 IP 가
빨리 탄다" — port_registry.py 도입 배경)를 정면으로 무너뜨리는 경로다. 게다가 하필 **풀이 마른
상태 = IP 가 가장 귀할 때** 발동해, 남은 IP 를 두 배 속도로 태우는 악순환을 만든다.

→ 장부 모드에선 임대한 포트만 쓴다(없으면 None). 못 받으면 AdapterError 로 실패하고 잡이
   백오프 후 재시도한다 — 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 가 이미 운다.
→ playwright_proxy() 도 임대가 없으면 예외. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가
   떠 서버 공인 IP 로 크롤하게 되는데, 그 IP 가 타면 회전으로 복구할 수 없다.

동시성 점검 결과(포트 20개/게이트웨이, 워커 3개):
  정상 24건 동시     성공 24 · 포트 중복 보유 0
  풀 고갈           성공 4/6(2건은 정상적으로 실패) · **같은 IP 공유 0**
  전면 차단          소각이 어댑터당 2개에서 멈춤(게이트웨이당 6/20) · 브레이커 6/6 트립

테스트 3건 추가(고갈 시 None 반환·남의 포트 미사용 / 임대 없는 playwright_proxy 예외 /
검색이 깔끔히 실패), 전체 256 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-06 12:01:15 +09:00
parent 08afc7d4ad
commit 7b9d895ead
4 changed files with 77 additions and 8 deletions

View File

@ -233,8 +233,14 @@ class BrowserSearchAdapter(SearchAdapter):
if self._proxy and self._proxy.enabled:
# 포트 확정은 여기서만 한다(유일한 async 지점) — 회전·차단으로 밀린 DB 반영도 함께 flush.
ensure = getattr(self._proxy, "ensure_port", None)
if ensure is not None:
await ensure()
if ensure is not None and await ensure() is None:
# 전부 임대/휴식/쿨다운 중 = 지금은 안전하게 크롤할 IP 가 없다.
# 남의 임대를 빌려 쓰거나 프록시 없이 도는 대신 실패한다 — 잡은 백오프 후 재시도하고,
# 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 알림이 따로 운다.
self._note_result(False)
raise AdapterError(
f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도",
source=self.source)
kwargs["proxy"] = self._proxy.playwright_proxy()
await self._begin_ip_session(self._proxy.current_port)
else:

View File

@ -170,14 +170,16 @@ class DecodoProxy:
now = time.monotonic()
self._burned = {p: t for p, t in self._burned.items() if t > now}
def _port(self) -> int:
def _port(self) -> int | None:
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.
쿨다운 중인 포트는 건너뛰고, 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선)."""
if self._store is not None:
# 임대는 ensure_port(async)가 확정한다. 여기선 확정값을 돌려줄 뿐 — 동기 경로에서
# DB 를 만지지 않는다. 아직 못 잡았으면 계산식으로 폴백(로그·프리플라이트용).
if self._leased is not None:
return self._leased
# 장부 모드에선 **임대한 포트만** 쓴다(없으면 None). 계산식 폴백을 두면 안 된다 —
# 임대에 실패한 워커가 남이 쥔 포트를 그대로 집어 **같은 IP 에 요청이 겹친다**
# (2026-08-06 실측: 포트 2개·소유자 3인 상황에서 w1 과 w2 가 같은 포트로 동시 요청).
# 하필 풀이 마른 상태 = IP 가 가장 귀할 때 벌어져, 남은 IP 를 두 배 속도로 태운다.
# 이 장부가 막으려던 바로 그 문제이므로, 못 잡았으면 쓰지 않고 실패하는 게 맞다.
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):
@ -207,8 +209,13 @@ class DecodoProxy:
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
if not self.enabled:
return None
port = self._port()
if port is None:
# 장부 모드에서 임대를 못 받은 상태. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가
# 떠 서버 공인 IP 로 크롤하게 된다(그 IP가 타면 회전으로 복구 불가). 명시적으로 실패한다.
raise RuntimeError(f"{self.host} 가용 프록시 포트 없음 — 임대 없이 크롤할 수 없습니다")
return {
"server": f"http://{self.host}:{self._port()}",
"server": f"http://{self.host}:{port}",
"username": self.username,
"password": self.password,
}

View File

@ -213,6 +213,26 @@ async def test_sticky_window_expiry_actually_rotates_the_ip():
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다"
async def test_search_fails_cleanly_when_no_ip_is_available():
"""풀 고갈 시엔 남의 임대를 빌리거나 프록시 없이 도는 대신 **실패**한다 —
잡이 백오프 재시도하는 사이 쿨다운이 풀린다."""
class _NoPortProxy(_MockProxy):
async def ensure_port(self): return None
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"]), proxy=_NoPortProxy())
# 실물 _ensure_browser 를 타야 검증이 된다(mock 하니스는 브라우저 기동만 흉내낸다)
async def _real_ensure():
ensure = getattr(ad._proxy, "ensure_port", None)
if ensure is not None and await ensure() is None:
raise AdapterError(f"{ad.source} 가용 프록시 IP 없음", source=ad.source)
ad._ensure_browser = _real_ensure
with pytest.raises(AdapterError, match="가용 프록시 IP 없음"):
await ad.search("q")
assert ad._proxy.burned == [], "IP 를 못 받은 것은 IP 잘못이 아니다 — 태우지 않는다"
async def test_sticky_expiry_rotates_even_when_the_browser_was_idle_closed():
"""저트래픽(negodata 수동 트리거)에서는 매 검색이 브라우저 닫힌 채로 들어온다.
예전엔 경로에서 만료 검사를 건너뛰어, 예산을 끄면 IP 영원히 고정됐다(실측)."""

View File

@ -64,6 +64,42 @@ async def test_proxy_release_is_safe_without_a_lease(store):
await px.release()
# ── 풀 고갈 시 남의 IP 를 빌려 쓰지 않는다 (2026-08-06 회귀) ─────────────
# 임대 실패 시 계산식으로 포트를 고르는 폴백이 있었다. 그러면 임대를 못 받은 워커가 **남이 쥔
# 포트를 그대로 집어 같은 IP 에 요청이 겹친다**(실측: 포트 2개·소유자 3인 상황에서 재현).
# 하필 풀이 마른 상태 = IP 가 가장 귀할 때 벌어져 남은 IP 를 두 배로 태운다.
def _proxy_for(store, owner, lo=P_START, hi=P_END):
from services.search.proxy import DecodoProxy
px = DecodoProxy(host=HOST_A, store=store, owner=owner)
px.username, px.password = "u", "p"
px.port_start, px.port_end = lo, hi
px.session_minutes = 10
return px
async def test_exhausted_pool_yields_no_port_instead_of_borrowing(store):
holders = [_proxy_for(store, f"w{i}") for i in range(P_END - P_START + 1)]
for px in holders: # 풀을 전부 소진
assert await px.ensure_port() is not None
latecomer = _proxy_for(store, "late")
assert await latecomer.ensure_port() is None, "빈 포트가 없으면 None 이어야 한다"
assert latecomer.current_port is None, "계산식 폴백으로 남의 포트를 집으면 안 된다"
taken = {px._leased for px in holders}
assert latecomer._leased not in taken
async def test_no_proxy_config_when_lease_missing(store):
"""임대가 없는데 playwright_proxy() 가 None 을 주면 **프록시 없이** 브라우저가 떠
서버 공인 IP 크롤한다( IP 타면 회전으로 복구 불가). 조용히 넘어가면 된다."""
import pytest as _pytest
px = _proxy_for(store, "no-lease")
with _pytest.raises(RuntimeError, match="가용 프록시 포트 없음"):
px.playwright_proxy()
# ── 배타 임대(프로세스 간) ───────────────────────────────────────────────
async def test_no_two_owners_get_the_same_port(store):
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""