Zettelkasten

fork 전에 열어둔 리소스는 자식에서 소켓 공유·스레드 소멸·락 교착으로 깨진다

·수정 1회

요약

  • 프로세스를 fork 하면 부모가 이미 열어둔 리소스가 종류별로 서로 다르게 망가진다. 소켓은 공유되고, 스레드는 사라지고, 락은 잠긴 채 복제된다.
  • 셋 중 락 교착만 예외가 아니라 블로킹이라 try/except로 못 잡고 프로세스가 살아 있는 채로 멈춘다. 감시 로직도 정상으로 본다.
  • 대응은 "fork 전에 닫기 / fork 후 새로 만들기 / pid 바뀌면 스스로 리셋" 셋 중 하나다.

본문

워커를 프로세스 여러 개로 늘릴 때(프로세스 풀, fork per job) 흔히 밟는다. 부모가 초기화 단계에서 뭔가를 열어두고 그 뒤에 fork 하는 구조면 아래 셋이 한꺼번에 온다.

1. 파일 디스크립터 — 복제되어 같은 연결을 공유한다

fork 하면 자식은 부모의 fd 사본을 갖는데, 사본이 가리키는 건 같은 open file description이다. DB 커넥션 소켓이 이 상태로 복제되면 부모와 자식이 같은 소켓에 각자 쿼리를 쓰고 각자 응답을 읽는다. 요청·응답이 짝을 잃고 프로토콜이 깨진다.

고약한 건 간헐적이라는 점이다. 부모가 그 커넥션을 안 쓰면 아무 일도 없고, 자식이 하나뿐일 때도 잘 돌아간다. 자식이 둘 이상 동시에 붙는 순간부터 터진다. 그래서 "워커 1개일 땐 멀쩡했는데 2개로 늘리니 이상해진다".

2. 스레드 — 자식에는 남지 않는다

fork로 만들어진 자식은 fork를 호출한 스레드 하나만 갖는다. 부모가 띄워둔 백그라운드 스레드(pub/sub 구독자, 메트릭 flush, 하트비트)는 자식에 없다. 자식은 그 기능이 죽은 줄도 모르고 돈다.

여기 함정이 하나 더 붙는다. 스레드 객체를 담아둔 클래스 변수는 복제돼 남는다. 그래서 아래 같은 중복 실행 가드가 재초기화를 막아버린다.

class Subscriber:
    _thread = None          # 클래스 변수 → fork 후에도 자식에 남는다

    def subscribe(self):
        if self._thread:    # 죽은 스레드 객체가 남아 있어 "이미 구독 중"으로 보인다
            return
        self._thread = start_background_thread()

자식은 영영 재구독하지 않고, 무효화 메시지를 못 받아 캐시가 TTL 만료 때까지 낡은 값을 쓴다. 아무 에러도 안 난다.

3. 락 — 잠긴 채 복제된다

가장 조용하고 가장 나쁘다. 어떤 스레드가 뮤텍스를 쥔 상태에서 fork가 일어나면, 자식에는 잠긴 락만 복제되고 그 락을 풀어줄 스레드는 없다. 자식이 그 락을 처음 건드리는 지점에서 영구히 멈춘다.

  • 예외가 아니라 블로킹이라 try/except가 못 잡는다
  • 프로세스는 살아 있어서 풀의 헬스체크나 respawn 로직도 안 걸린다
  • 재현이 어렵다. 락을 쥐는 구간이 짧을수록 fork와 겹칠 확률이 낮아 운 좋게 넘어가다 어느 날 걸린다

redis-py는 이 시나리오를 자기 소스 주석에 적어두고 있다 — fork 락을 쥔 채 fork되면 자식이 그 락을 영원히 못 얻는다고.

락이 걸려 있는 시간이 실제로 얼마나 되는지 따져보면 위험도가 갈린다. 락 안에서 메모리 조작만 한다면 fork와 겹칠 확률은 매우 낮고, 락 안에서 I/O를 한다면 현실적인 위험이다.

대응 패턴 세 가지

패턴 언제 예
fork 전에 닫는다 재생성이 싼 리소스 워커 띄우기 직전 DB 커넥션 close, 백그라운드 스레드 stop + join
fork 후 자식에서 새로 만든다 자식마다 필요한 것 자식 진입점에서 구독 재등록, 캐시 재워밍, 락 객체 교체
pid를 확인해 스스로 리셋한다 라이브러리가 알아서 redis-py ConnectionPool._checkpid() — 저장해둔 pid와 현재 pid가 다르면 풀 전체를 리셋

Python이면 os.register_at_fork(after_in_child=...)로 3번을 직접 붙일 수 있다. 다만 fork가 자주 일어나는 구조에서는 쓰면 안 된다. job마다 fork 하는 워커라면 매 job마다 콜백이 돌아 스레드나 커넥션을 새로 만들게 된다.

락 교착만 따로 막고 싶다면 자식 진입점에서 락 객체 자체를 새것으로 갈아끼우는 방법이 가장 확실하다. fork 직후 자식은 단일 스레드라 안전하고, 부모 쪽 정리(stop·join)의 성공 여부와 무관하게 성립한다.

def child_entrypoint():
    SharedCache._lock = RLock()      # fork 시점에 잠겨 있었을 수 있다
    Subscriber._thread = None        # 죽은 스레드 객체를 치운다
    Subscriber().subscribe()

stop()이 비동기라는 함정

"fork 전에 스레드를 정리한다"를 stop() 호출만으로 끝내면 안 된다. 백그라운드 스레드의 stop()은 대개 종료 플래그만 내리고 곧바로 반환한다. 스레드는 다음 폴링 주기까지 더 산다. 그 사이에 fork 하면 결국 살아 있는 스레드와 함께 복제된다. join(timeout=...)으로 실제 종료를 기다리고, 타임아웃했을 때 어떻게 할지도 정해야 한다.

진단 감각

  • 프로세스를 1개에서 N개로 늘린 뒤부터 생긴 간헐적 이상 → fd 공유 의심
  • 자식만 설정·캐시 갱신이 안 되고 부모는 멀쩡 → 스레드 소멸 의심
  • 프로세스는 살아 있는데 아무 일도 안 하고 로그도 안 남김 → 락 교착 의심 (py-spy dump 같은 도구로 스택을 떠보면 락 대기에서 멈춰 있다)

관련 노트

참고