한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰는 전제로 전환한다. 인메모리 장부는 프로세스마다 따로라 (1) 같은 IP 를 동시에 잡고 (2) 한쪽이 태운 IP 를 다른 쪽이 곧바로 집으며 (3) 재시작하면 쿨다운이 통째로 사라졌다. proxy_port 테이블 = 단일 진실. 상태는 세 시각으로만 표현한다(leased/rest/cooldown_until). - acquire: 한 UPDATE 안에서 FOR UPDATE SKIP LOCKED 로 후보를 잠그고 임대까지 끝낸다 (잡 큐와 같은 방식 — SELECT 후 UPDATE 로 나누면 그 틈에 다른 프로세스가 같은 행을 집는다) - 회전은 LRU(last_used_at). 프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가 자연히 한 바퀴씩 돈다 → 프로세스별 seed_offset 계산 제거 - 죽은 프로세스 회수: leased_until 만료로 자동 복귀(별도 reaper 불필요) - 차단·휴식은 전역이라 재시작해도 유지된다 DB 왕복은 비동기라 검색 루프(동기)에서 곧바로 못 한다 → 회전·차단을 pending 에 적어두고 ensure_port(브라우저 재기동 직전, async)에서 한 번에 flush. _close_ctx 에서도 flush 해 종료 시 유실(=태운 IP 를 남이 그대로 집는 상황)을 막는다. **프로필 슬롯**(services/search/profile_slot): Chrome 은 user_data_dir 당 1 인스턴스다. 예전엔 워커 인덱스로만 갈라서 프로세스 2개면 같은 경로를 잡아 두 번째가 통째로 죽었다 (실측: 잡 3건 중 2건 DEAD, TargetClosedError). 파일 락으로 슬롯을 선점한다 — PID 경로가 아니라 슬롯이라 재시작 시 재사용돼 웜 쿠키(cf_clearance·Akamai)를 버리지 않는다. 검증: 프로세스 2개 동시 acquire 20회 → 중복 배정 0건. 워커 2프로세스 e2e → 잡 3건 모두 DONE(네이버가 삼다수 최저가 획득 8,960 < 13,200). 테스트 14건 추가, 전체 217 passed.
47 lines
2.4 KiB
Python
47 lines
2.4 KiB
Python
"""Chrome 프로필 슬롯 배정 — 프로세스가 여러 개여도 같은 프로필을 잡지 않게.
|
|
|
|
Chrome 은 user_data_dir 당 인스턴스 하나만 허용한다(ProcessSingleton). 워커 프로세스를
|
|
여러 개 띄우면 두 번째가 "기존 브라우저 세션에서 여는 중입니다" 로 죽고, 그 소스의 검색이
|
|
전부 TargetClosedError 로 실패한다(2026-08-05 실측: 워커 2프로세스 → 잡 2건 DEAD).
|
|
|
|
PID 를 경로에 넣는 방식은 쓰지 않는다 — 재시작마다 새 디렉터리가 생겨 **웜 쿠키
|
|
(cf_clearance·Akamai 세션)를 매번 버리고** 디렉터리가 무한히 쌓인다. 대신 슬롯 번호를
|
|
파일 락으로 선점한다: 살아있는 프로세스가 없으면 같은 슬롯을 다시 쓰므로 쿠키가 유지되고,
|
|
프로세스가 죽으면 OS 가 락을 자동 해제해 슬롯이 곧바로 회수된다(정리 코드 불필요).
|
|
"""
|
|
|
|
import fcntl
|
|
import os
|
|
|
|
from common.logger import LOG
|
|
|
|
# 잡은 락 핸들을 프로세스 수명 동안 살려 둔다 — GC 되면 락이 풀려 다른 프로세스가 같은 슬롯을 집는다.
|
|
_HELD: list = []
|
|
|
|
|
|
def claim_profile_slot(base_dir: str, source: str, worker_index: int = 0, max_slots: int = 32) -> str:
|
|
"""이 프로세스가 배타적으로 쓸 프로필 경로를 돌려준다.
|
|
|
|
같은 (source, worker_index) 라도 프로세스가 다르면 다른 슬롯이 배정된다.
|
|
슬롯을 모두 뺏겼으면 마지막 후보를 그대로 쓴다 — 검색을 못 하는 것보다 낫고,
|
|
그 경우엔 로그로 드러난다(동시 프로세스 수가 max_slots 를 넘었다는 뜻).
|
|
"""
|
|
os.makedirs(base_dir, exist_ok=True)
|
|
for slot in range(max_slots):
|
|
path = os.path.join(base_dir, f"lps_{source}_w{worker_index}_s{slot}")
|
|
os.makedirs(path, exist_ok=True)
|
|
lock_path = path + ".lock"
|
|
try:
|
|
fh = open(lock_path, "w")
|
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) # 비블로킹 — 남이 쥐고 있으면 즉시 실패
|
|
except OSError:
|
|
continue
|
|
fh.write(f"{os.getpid()}\n")
|
|
fh.flush()
|
|
_HELD.append(fh)
|
|
return path
|
|
|
|
fallback = os.path.join(base_dir, f"lps_{source}_w{worker_index}_s{max_slots - 1}")
|
|
LOG.w(f"[profile] {source} 슬롯 {max_slots}개 모두 사용 중 — {fallback} 공유(프로세스 과다)")
|
|
return fallback
|