크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로 재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물). **① 요청 예산이 사실상 발화하지 않았다 (핵심)** 유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를 0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴 임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은 포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼 드문드문한 실사용 패턴에서만 깨진다. 파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한 '1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라 IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가). → IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session). 세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸). **② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다** 쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로 웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도 9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14). → 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정, 태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계). 같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요). 결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0). **③ 종료가 임대를 반납하지 않았다** close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용 풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를 위해 그대로 둔다). **④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다. sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다. **⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다** 네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다. 확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은 회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다. **⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용). 세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지. 테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로 바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
8.6 KiB
Python
167 lines
8.6 KiB
Python
"""프로세스 간 공유 포트 장부(proxy_port) 테스트 — 실제 DB 필요.
|
|
|
|
여기서 지키는 계약은 멀티 프로세스 운영과 직결된다:
|
|
- 두 프로세스가 같은 IP 를 동시에 잡으면 그 IP 가 두 배 속도로 탄다
|
|
- 한 프로세스가 태운 IP 를 다른 프로세스가 곧바로 집으면 차단이 전파된다
|
|
- 워커가 죽은 채로 임대를 쥐고 있으면 그 IP 는 영영 안 돌아온다(임대 만료로 자동 회수돼야 함)
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, DBWRType
|
|
from crud.port_lease import PortLeaseStore
|
|
|
|
HOST_A, HOST_B = "test-gate.example", "test-kr.example"
|
|
P_START, P_END = 20001, 20005
|
|
|
|
|
|
@pytest.fixture
|
|
async def store(db_engine):
|
|
st = PortLeaseStore()
|
|
await _exec("DELETE FROM proxy_port WHERE host IN (:a, :b)", {"a": HOST_A, "b": HOST_B})
|
|
await st.ensure_ports(HOST_A, P_START, P_END)
|
|
await st.ensure_ports(HOST_B, P_START, P_END)
|
|
yield st
|
|
await _exec("DELETE FROM proxy_port WHERE host IN (:a, :b)", {"a": HOST_A, "b": HOST_B})
|
|
|
|
|
|
async def _exec(sql: str, params: dict | None = None):
|
|
s = await DB_SESSION_MNG.start_session(DBType.MAIN.value, DBWRType.DB_WRITE.value)
|
|
try:
|
|
await s.execute(text(sql), params or {})
|
|
await s.commit()
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(DBType.MAIN.value, DBWRType.DB_WRITE.value)
|
|
|
|
|
|
# ── 종료 시 반납 (2026-08-05) ────────────────────────────────────────────
|
|
async def test_proxy_release_returns_the_port_immediately(store):
|
|
"""워커 종료가 임대를 놓아야 한다 — 안 놓으면 재시작해도 최대 sticky 수명(10분)까지 못 쓴다."""
|
|
from services.search.proxy import DecodoProxy
|
|
|
|
px = DecodoProxy(host=HOST_A, store=store, owner="shutdown-w0")
|
|
px.username, px.password = "u", "p"
|
|
px.port_start, px.port_end = P_START, P_END
|
|
port = await px.ensure_port()
|
|
assert port is not None
|
|
assert (await store.snapshot(HOST_A))[HOST_A]["held"] == 1
|
|
|
|
await px.release()
|
|
assert (await store.snapshot(HOST_A))[HOST_A]["held"] == 0, "종료 후에는 남이 곧바로 쓸 수 있어야 한다"
|
|
assert px._leased is None
|
|
assert await store.acquire(HOST_A, "other-process", 600) is not None
|
|
|
|
|
|
async def test_proxy_release_is_safe_without_a_lease(store):
|
|
"""임대를 못 잡은 채 종료돼도(풀 고갈 등) 예외 없이 지나가야 한다."""
|
|
from services.search.proxy import DecodoProxy
|
|
|
|
px = DecodoProxy(host=HOST_A, store=store, owner="never-leased")
|
|
px.username, px.password = "u", "p"
|
|
px.port_start, px.port_end = P_START, P_END
|
|
await px.release()
|
|
|
|
|
|
# ── 배타 임대(프로세스 간) ───────────────────────────────────────────────
|
|
async def test_no_two_owners_get_the_same_port(store):
|
|
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""
|
|
got = [await store.acquire(HOST_A, f"p{i}-w0", 600) for i in range(5)]
|
|
assert None not in got
|
|
assert len(set(got)) == 5
|
|
|
|
|
|
async def test_pool_exhaustion_returns_none(store):
|
|
for i in range(5):
|
|
assert await store.acquire(HOST_A, f"p{i}", 600) is not None
|
|
assert await store.acquire(HOST_A, "p-late", 600) is None # 남는 포트 없음 → 호출부가 대기/재시도
|
|
|
|
|
|
async def test_gateways_are_independent(store):
|
|
"""같은 포트 번호라도 게이트웨이가 다르면 다른 IP(실측) — 한쪽 임대가 다른 쪽을 막으면 안 된다."""
|
|
a = await store.acquire(HOST_A, "coupang", 600)
|
|
b = await store.acquire(HOST_B, "naver", 600)
|
|
assert a == b == P_START # 번호는 같아도 서로 다른 자원
|
|
assert (await store.available(HOST_A)) == 4 and (await store.available(HOST_B)) == 4
|
|
|
|
|
|
# ── 차단·휴식(전역) ──────────────────────────────────────────────────────
|
|
async def test_burned_port_is_invisible_to_every_process(store):
|
|
p = await store.acquire(HOST_A, "coupang-p1", 600)
|
|
await store.burn(HOST_A, p, 1800, owner="coupang-p1")
|
|
others = {await store.acquire(HOST_A, f"naver-p{i}", 600) for i in range(4)}
|
|
assert p not in others # 다른 프로세스도 태운 IP 를 못 집는다
|
|
assert await store.acquire(HOST_A, "naver-p9", 600) is None
|
|
|
|
|
|
async def test_rested_port_is_held_back_then_returns(store):
|
|
p = await store.acquire(HOST_A, "naver-p1", 600)
|
|
await store.release(HOST_A, p, "naver-p1", rest_sec=600)
|
|
others = {await store.acquire(HOST_A, f"x{i}", 600) for i in range(4)}
|
|
assert p not in others # 쉬는 동안은 아무도 못 집는다
|
|
await store.release(HOST_A, p, "nobody", rest_sec=0) # 소유자 불일치 → 무시돼야 함
|
|
assert await store.acquire(HOST_A, "y", 600) is None
|
|
await _exec("UPDATE proxy_port SET rest_until = now() - interval '1 s' WHERE host=:h AND port=:p",
|
|
{"h": HOST_A, "p": p})
|
|
assert await store.acquire(HOST_A, "z", 600) == p # 휴식 만료 → 복귀
|
|
|
|
|
|
async def test_release_without_rest_returns_port_to_pool(store):
|
|
"""반납된 포트는 곧바로 풀에 돌아온다. 단 **다음 차례가 되는 건 아니다** —
|
|
LRU 라 한 번도 안 쓴 포트가 먼저 나가고, 반납분은 한 바퀴 뒤에 다시 온다(회전의 정의)."""
|
|
p = await store.acquire(HOST_A, "naver-p1", 600)
|
|
await store.release(HOST_A, p, "naver-p1")
|
|
assert await store.available(HOST_A) == 5 # 5개 전부 가용
|
|
|
|
picked = [await store.acquire(HOST_A, f"w{i}", 600) for i in range(5)]
|
|
assert p in picked and picked[-1] == p # 가장 최근 사용분이 맨 마지막
|
|
|
|
|
|
# ── 죽은 프로세스 회수 ───────────────────────────────────────────────────
|
|
async def test_expired_lease_is_reclaimed_without_a_reaper(store):
|
|
"""워커가 임대를 쥔 채 죽어도 sticky 수명이 지나면 자동으로 풀려야 한다."""
|
|
p = await store.acquire(HOST_A, "dead-process", 600)
|
|
assert await store.available(HOST_A) == 4 # 죽은 프로세스가 쥔 동안은 빠져 있고
|
|
await _exec("UPDATE proxy_port SET leased_until = now() - interval '1 s' WHERE host=:h AND port=:p",
|
|
{"h": HOST_A, "p": p})
|
|
assert await store.available(HOST_A) == 5 # 임대 만료 → 별도 정리 없이 자동 복귀
|
|
picked = [await store.acquire(HOST_A, f"alive{i}", 600) for i in range(5)]
|
|
assert p in picked
|
|
|
|
|
|
async def test_renew_keeps_the_lease_and_fails_after_takeover(store):
|
|
p = await store.acquire(HOST_A, "p1", 600)
|
|
assert await store.renew(HOST_A, p, "p1", 600) is True
|
|
await _exec("UPDATE proxy_port SET owner='p2' WHERE host=:h AND port=:p", {"h": HOST_A, "p": p})
|
|
assert await store.renew(HOST_A, p, "p1", 600) is False # 남이 가져감 → 새로 잡아야 함
|
|
|
|
|
|
# ── 회전(LRU) ────────────────────────────────────────────────────────────
|
|
async def test_rotation_is_least_recently_used(store):
|
|
"""프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가 한 바퀴씩 돈다."""
|
|
first = []
|
|
for i in range(5): # 5포트를 한 바퀴 소진
|
|
p = await store.acquire(HOST_A, f"w{i}", 600)
|
|
first.append(p)
|
|
await store.release(HOST_A, p, f"w{i}")
|
|
second = []
|
|
for i in range(5):
|
|
p = await store.acquire(HOST_A, f"w{i}", 600)
|
|
second.append(p)
|
|
await store.release(HOST_A, p, f"w{i}")
|
|
assert sorted(first) == sorted(second) # 같은 5개를
|
|
assert first == second # 같은 순서로(LRU) — 특정 IP 편중 없음
|
|
|
|
|
|
async def test_snapshot_counts_each_state(store):
|
|
held = await store.acquire(HOST_A, "w0", 600)
|
|
rested = await store.acquire(HOST_A, "w1", 600)
|
|
await store.release(HOST_A, rested, "w1", rest_sec=600)
|
|
burned = await store.acquire(HOST_A, "w2", 600)
|
|
await store.burn(HOST_A, burned, 1800)
|
|
snap = (await store.snapshot(HOST_A))[HOST_A]
|
|
assert snap["held"] == 1 and snap["resting"] == 1 and snap["cooling"] == 1
|
|
assert snap["available"] == 2 and snap["total"] == 5
|
|
assert held != rested != burned
|