기존엔 네이버가 쿠팡 기준 예산(3회)을 그대로 썼다. 실측하니 체급이 다르다: 같은 KR IP 로 **12회 연속 검색까지 무차단**(IP 4개 전부 한계 미도달). 3회로 돌리면 불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 회전마다 브라우저 재기동(~20s)이 붙는다. → [DecodoConfig].naver_ip_request_budget = 10 (실측 12 에 여유). 쿠팡은 3 유지. 그리고 선제 회전에 빠져 있던 조각을 채웠다 — **휴식(rest)**: 예산 도달로 놓은 포트를 곧바로 다른 워커가 집으면 그 IP 의 요청률이 도로 올라가 예산의 의미가 사라진다. release(rest_sec=...) 로 sticky 수명만큼 쉬게 한다. 차단으로 태우는 burn(30분)과는 별개 상태다: 휴식 탄 게 아님 · 짧음 · 소진 시 가장 먼저 회수 쿨다운 차단당함 · 김 · 휴식보다 나중에 회수 회전 종류(kind)를 browser_base → DecodoProxy.rotate(kind) 로 전달해 budget 일 때만 휴식을 건다. 라이브 검증(예산 3으로 낮춰 관찰): 6회 검색 = IP 2개만 사용, 3회마다 선제 회전, 놓은 포트는 휴식 1 · 쿨다운 0 · 차단 0. 즉 IP 를 태우지 않고 로테이션만으로 돌아간다. 테스트 6건 추가(휴식 재사용 금지·만료 복귀·burn 우선·회수 우선순위·budget vs block), 전체 202 passed. _MockProxy.rotate 가 kind 를 받도록 갱신.
144 lines
8.1 KiB
Python
144 lines
8.1 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)
|
|
# 선제 회전(예산 도달)으로 놓은 포트의 '휴식' 만료. 차단(burn)과 다르다 — 탄 게 아니라
|
|
# **쉬게 하는 것**이라 훨씬 짧다. 휴식이 없으면 방금 놓은 IP 를 다른 워커가 곧바로 집어
|
|
# 요청이 다시 몰리고, 예산으로 낮춘 요청률이 도로 올라간다(선제 회전의 의미가 사라진다).
|
|
self._resting: dict[tuple[str, int], float] = {}
|
|
|
|
@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 or key in self._resting:
|
|
continue
|
|
self._held[key] = (owner, time.monotonic())
|
|
return port
|
|
|
|
# 전 포트 소진 — 쿨다운 만료가 가장 임박한 포트를 회수해 넘긴다.
|
|
# 회수 우선순위: 휴식(탄 게 아님) → 쿨다운(차단) → 보유 중(가장 오래된 리스)
|
|
pool = {k: t for k, t in self._resting.items() if k[0] == host} or \
|
|
{k: t for k, t in self._burned.items() if k[0] == host}
|
|
victim = min(pool, key=pool.get, default=None)
|
|
if victim is None: # 전부 점유 중 = 소유자가 포트 수보다 많다
|
|
victim = min(((k, v[1]) for k, v in self._held.items() if k[0] == host),
|
|
key=lambda kv: kv[1])[0]
|
|
self._resting.pop(victim, None)
|
|
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, rest_sec: float = 0):
|
|
"""리스 반납. 소유자가 다르면 무시한다(뒤늦은 반납이 남의 리스를 깨지 않도록).
|
|
|
|
rest_sec > 0 이면 그만큼 **휴식**을 준다 — 선제 회전(예산 도달)으로 놓은 IP 가
|
|
곧바로 다시 잡히지 않게 해 요청률을 실제로 낮춘다. 차단으로 태우는 burn 과는 다르다.
|
|
"""
|
|
key = (host, port)
|
|
cur = self._held.get(key)
|
|
if cur and cur[0] == owner:
|
|
del self._held[key]
|
|
if rest_sec > 0:
|
|
self._resting[key] = time.monotonic() + rest_sec
|
|
|
|
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)
|
|
self._resting.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)
|
|
resting = sum(1 for (h, _) in self._resting if h == host)
|
|
return self.size - used - cooling - resting
|
|
|
|
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} | {h for (h, _) in self._resting}
|
|
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),
|
|
"resting": sum(1 for (hh, _) in self._resting 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}
|
|
self._resting = {k: t for k, t in self._resting.items() if t > now}
|