"""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}