한 DECODO 계정으로 성격이 다른 두 풀을 쓰게 됐다: 쿠팡 gate.decodo.com 국가 무지정(실측 VN·MY·BD·ID·KZ·IN·PH) 네이버 kr.decodo.com 한국 전용(LG U+·KT·SK브로드밴드) 네이버는 해외 IP 를 즉시 하드차단한다 — 같은 포트 10091 에서 gate=차단(2,641B) / kr=정상 14건. 같은 포트 번호라도 게이트웨이가 다르면 IP 가 다르다 (port 10061 → gate=103.99.27.55(ID) / kr=121.180.128.2(KR)) → 자원 키는 (host, port). PortRegistry 가 프로세스 전체의 포트를 중재한다: - 배타 임대: 한 (host,port) 는 동시에 한 소유자만. 워커 N개가 회전하다 같은 IP 로 수렴해 한 IP 에 요청이 몰리던 문제를 없앤다(seed_offset 은 시작점만 벌렸다) - 전역 쿨다운: 누가 태웠든 만료 전까지 아무도 못 집는다(예전엔 쿨다운이 프록시 인스턴스별이라 다른 소스가 곧바로 재사용했다) - 리스 만료 = sticky 수명(session_minutes) → 자동 반납 후 새 IP - 전 포트 소진 시 가장 빨리 풀릴 포트를 회수(멈추는 것보다 낫다) - snapshot() 을 ops 알림 페이로드에 실어 게이트웨이별 보유/쿨다운/소유자를 남긴다 DecodoProxy 는 registry 주입 시에만 임대 경로를 타고, 미주입이면 기존 동작 그대로다 (단독 사용·기존 테스트 경로 보존). 검증: 워커 2개 동시 실행 e2e — 쿠팡·네이버 병렬 수집, 삼다수에서 네이버가 최저가 획득(naver 8,960 < coupang 13,200). 테스트 14건 추가, 전체 196 passed.
125 lines
6.6 KiB
Python
125 lines
6.6 KiB
Python
"""DECODO 포트(=IP 세션) 중재자 — 누가 어떤 IP 를 쓰고 있고, 어떤 IP 가 탔는지 한곳에서 관리한다.
|
|
|
|
## 왜 필요한가
|
|
|
|
DECODO 는 포트 1개 = sticky 세션 1개다. 그런데 우리는 **한 계정으로 두 종류의 풀**을 쓴다:
|
|
|
|
쿠팡 gate.decodo.com:10001-10100 국가 무지정(실측: VN·MY·BD·ID·KZ·IN…)
|
|
네이버 kr.decodo.com:10001-10100 한국 전용(실측: LG U+·KT·SK브로드밴드)
|
|
|
|
네이버는 해외 IP 를 즉시 하드차단하고(2.6KB), 쿠팡은 한국 IP 가 필수는 아니다. 그래서
|
|
소스마다 게이트웨이가 갈린다. **같은 포트 번호라도 게이트웨이가 다르면 IP 가 다르다**
|
|
(실측: port 10061 → gate=103.99.27.55(ID) / kr=121.180.128.2(KR)) — 그래서 키는 (host, port) 다.
|
|
|
|
여기가 없으면 생기는 문제:
|
|
1. 워커 N개가 같은 게이트웨이를 쓰면 시간이 지나며 같은 포트로 수렴해 **한 IP 에 요청이 몰린다**
|
|
(seed_offset 은 시작점만 벌릴 뿐, 회전하다 보면 겹친다) → 그 IP 가 빨리 탄다
|
|
2. 차단으로 태운 포트를 **다른 소유자가 곧바로 다시 집는다** — 쿨다운이 프록시 인스턴스별이라서
|
|
3. kr_host 를 비워 네이버가 gate 로 폴백하면 쿠팡과 같은 풀을 나눠 쓰게 된다. 이때는
|
|
포트 번호가 곧 같은 IP 라 1·2 번이 실제 IP 충돌로 이어진다
|
|
|
|
## 설계
|
|
|
|
- 소유권은 **배타적**이다. 한 (host, port) 는 동시에 한 소유자만 쥔다.
|
|
- 쿨다운(burn)은 **전역**이다. 누가 태웠든 만료 전까지 아무도 못 쓴다.
|
|
- 리스는 session_minutes 로 만료된다(sticky 세션 수명과 맞춤). 만료되면 자동 반납된다.
|
|
- 전 포트가 막히면 **가장 빨리 풀리는 포트**를 내준다(가용성 우선 — 멈추는 것보다 낫다).
|
|
|
|
프로세스 안에서만 공유한다. 워커 동시성은 한 프로세스 안의 asyncio 태스크라 이걸로 충분하고,
|
|
프로세스를 늘리면 그때 DB(ip_session)로 올려야 한다 — 지금 그 복잡도를 미리 지불하지 않는다.
|
|
"""
|
|
|
|
import time
|
|
|
|
from common.logger import LOG
|
|
|
|
|
|
class PortRegistry:
|
|
def __init__(self, port_start: int, port_end: int):
|
|
self.port_start = port_start
|
|
self.port_end = port_end
|
|
self._held: dict[tuple[str, int], tuple[str, float]] = {} # (host,port) → (owner, 취득시각)
|
|
self._burned: dict[tuple[str, int], float] = {} # (host,port) → 쿨다운 만료(monotonic)
|
|
|
|
@property
|
|
def size(self) -> int:
|
|
return self.port_end - self.port_start + 1
|
|
|
|
# ── 할당 ────────────────────────────────────────────────────────────
|
|
def acquire(self, host: str, owner: str, start_at: int = 0, lease_sec: float = 600) -> int:
|
|
"""(host, *) 중 비어있고 쿨다운 아닌 포트를 배타 배정. start_at 은 탐색 시작 오프셋(워커 분산용).
|
|
|
|
전부 막혀 있으면 가장 먼저 풀리는 포트를 뺏어서라도 내준다 — 검색이 멈추는 것보다 낫다.
|
|
"""
|
|
self._prune(host)
|
|
n = self.size
|
|
for k in range(n):
|
|
port = self.port_start + ((start_at + k) % n)
|
|
key = (host, port)
|
|
if key in self._burned or key in self._held:
|
|
continue
|
|
self._held[key] = (owner, time.monotonic())
|
|
return port
|
|
|
|
# 전 포트 소진 — 쿨다운 만료가 가장 임박한 포트를 회수해 넘긴다.
|
|
victim = min(self._burned, key=self._burned.get, default=None)
|
|
if victim is None: # 쿨다운은 없는데 전부 점유 중 = 소유자가 포트 수보다 많다
|
|
victim = min(self._held, key=lambda k: self._held[k][1])
|
|
LOG.w(f"[port] {host} 가용 포트 없음 — {victim[1]} 회수해 {owner} 에 배정 "
|
|
f"(보유 {len(self._held)} · 쿨다운 {len(self._burned)} / {n})")
|
|
self._burned.pop(victim, None)
|
|
self._held[victim] = (owner, time.monotonic())
|
|
return victim[1]
|
|
|
|
def release(self, host: str, port: int, owner: str):
|
|
"""리스 반납. 소유자가 다르면 무시한다(뒤늦은 반납이 남의 리스를 깨지 않도록)."""
|
|
key = (host, port)
|
|
cur = self._held.get(key)
|
|
if cur and cur[0] == owner:
|
|
del self._held[key]
|
|
|
|
def burn(self, host: str, port: int, cooldown_sec: float, owner: str = "", reason: str = ""):
|
|
"""차단·소진된 포트를 전역 격리. 보유 중이면 함께 반납한다."""
|
|
key = (host, port)
|
|
self._burned[key] = time.monotonic() + cooldown_sec
|
|
self._held.pop(key, None)
|
|
LOG.i(f"[port] {host}:{port} 쿨다운 {int(cooldown_sec)}s ({owner}{' · ' + reason if reason else ''}) "
|
|
f"— 가용 {self.available(host)}/{self.size}")
|
|
|
|
def lease_expired(self, host: str, port: int, owner: str, lease_sec: float) -> bool:
|
|
"""리스가 sticky 수명을 넘겼는지(넘겼으면 호출부가 반납 후 재취득)."""
|
|
cur = self._held.get((host, port))
|
|
if cur is None or cur[0] != owner:
|
|
return True
|
|
return (time.monotonic() - cur[1]) > lease_sec
|
|
|
|
# ── 관측 ────────────────────────────────────────────────────────────
|
|
def available(self, host: str) -> int:
|
|
self._prune(host)
|
|
used = sum(1 for (h, _) in self._held if h == host)
|
|
cooling = sum(1 for (h, _) in self._burned if h == host)
|
|
return self.size - used - cooling
|
|
|
|
def snapshot(self) -> dict:
|
|
"""게이트웨이별 현황 — ops 모니터/알림용. {host: {held, cooling, available, owners}}"""
|
|
self._prune()
|
|
hosts = {h for (h, _) in self._held} | {h for (h, _) in self._burned}
|
|
out = {}
|
|
for h in sorted(hosts):
|
|
owners: dict[str, int] = {}
|
|
for (hh, _), (owner, _) in self._held.items():
|
|
if hh == h:
|
|
owners[owner] = owners.get(owner, 0) + 1
|
|
out[h] = {
|
|
"held": sum(1 for (hh, _) in self._held if hh == h),
|
|
"cooling": sum(1 for (hh, _) in self._burned if hh == h),
|
|
"available": self.available(h),
|
|
"total": self.size,
|
|
"owners": owners,
|
|
}
|
|
return out
|
|
|
|
def _prune(self, host: str | None = None):
|
|
now = time.monotonic()
|
|
self._burned = {k: t for k, t in self._burned.items() if t > now}
|