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:
parent
08afc7d4ad
commit
7b9d895ead
@ -233,8 +233,14 @@ class BrowserSearchAdapter(SearchAdapter):
|
|||||||
if self._proxy and self._proxy.enabled:
|
if self._proxy and self._proxy.enabled:
|
||||||
# 포트 확정은 여기서만 한다(유일한 async 지점) — 회전·차단으로 밀린 DB 반영도 함께 flush.
|
# 포트 확정은 여기서만 한다(유일한 async 지점) — 회전·차단으로 밀린 DB 반영도 함께 flush.
|
||||||
ensure = getattr(self._proxy, "ensure_port", None)
|
ensure = getattr(self._proxy, "ensure_port", None)
|
||||||
if ensure is not None:
|
if ensure is not None and await ensure() is None:
|
||||||
await ensure()
|
# 전부 임대/휴식/쿨다운 중 = 지금은 안전하게 크롤할 IP 가 없다.
|
||||||
|
# 남의 임대를 빌려 쓰거나 프록시 없이 도는 대신 실패한다 — 잡은 백오프 후 재시도하고,
|
||||||
|
# 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 알림이 따로 운다.
|
||||||
|
self._note_result(False)
|
||||||
|
raise AdapterError(
|
||||||
|
f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도",
|
||||||
|
source=self.source)
|
||||||
kwargs["proxy"] = self._proxy.playwright_proxy()
|
kwargs["proxy"] = self._proxy.playwright_proxy()
|
||||||
await self._begin_ip_session(self._proxy.current_port)
|
await self._begin_ip_session(self._proxy.current_port)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -170,13 +170,15 @@ class DecodoProxy:
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
self._burned = {p: t for p, t in self._burned.items() if t > now}
|
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.
|
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.
|
||||||
쿨다운 중인 포트는 건너뛰고, 전 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선)."""
|
쿨다운 중인 포트는 건너뛰고, 전 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선)."""
|
||||||
if self._store is not None:
|
if self._store is not None:
|
||||||
# 임대는 ensure_port(async)가 확정한다. 여기선 확정값을 돌려줄 뿐 — 동기 경로에서
|
# 장부 모드에선 **임대한 포트만** 쓴다(없으면 None). 계산식 폴백을 두면 안 된다 —
|
||||||
# DB 를 만지지 않는다. 아직 못 잡았으면 계산식으로 폴백(로그·프리플라이트용).
|
# 임대에 실패한 워커가 남이 쥔 포트를 그대로 집어 **같은 IP 에 요청이 겹친다**
|
||||||
if self._leased is not None:
|
# (2026-08-06 실측: 포트 2개·소유자 3인 상황에서 w1 과 w2 가 같은 포트로 동시 요청).
|
||||||
|
# 하필 풀이 마른 상태 = IP 가 가장 귀할 때 벌어져, 남은 IP 를 두 배 속도로 태운다.
|
||||||
|
# 이 장부가 막으려던 바로 그 문제이므로, 못 잡았으면 쓰지 않고 실패하는 게 맞다.
|
||||||
return self._leased
|
return self._leased
|
||||||
if self._registry is not None:
|
if self._registry is not None:
|
||||||
lease_sec = self.session_minutes * 60
|
lease_sec = self.session_minutes * 60
|
||||||
@ -207,8 +209,13 @@ class DecodoProxy:
|
|||||||
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
|
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return None
|
return None
|
||||||
|
port = self._port()
|
||||||
|
if port is None:
|
||||||
|
# 장부 모드에서 임대를 못 받은 상태. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가
|
||||||
|
# 떠 서버 공인 IP 로 크롤하게 된다(그 IP가 타면 회전으로 복구 불가). 명시적으로 실패한다.
|
||||||
|
raise RuntimeError(f"{self.host} 가용 프록시 포트 없음 — 임대 없이 크롤할 수 없습니다")
|
||||||
return {
|
return {
|
||||||
"server": f"http://{self.host}:{self._port()}",
|
"server": f"http://{self.host}:{port}",
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"password": self.password,
|
"password": self.password,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -213,6 +213,26 @@ async def test_sticky_window_expiry_actually_rotates_the_ip():
|
|||||||
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 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():
|
async def test_sticky_expiry_rotates_even_when_the_browser_was_idle_closed():
|
||||||
"""저트래픽(negodata 수동 트리거)에서는 매 검색이 브라우저 닫힌 채로 들어온다.
|
"""저트래픽(negodata 수동 트리거)에서는 매 검색이 브라우저 닫힌 채로 들어온다.
|
||||||
예전엔 그 경로에서 만료 검사를 건너뛰어, 예산을 끄면 한 IP 에 영원히 고정됐다(실측)."""
|
예전엔 그 경로에서 만료 검사를 건너뛰어, 예산을 끄면 한 IP 에 영원히 고정됐다(실측)."""
|
||||||
|
|||||||
@ -64,6 +64,42 @@ async def test_proxy_release_is_safe_without_a_lease(store):
|
|||||||
await px.release()
|
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):
|
async def test_no_two_owners_get_the_same_port(store):
|
||||||
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""
|
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user