#Backend

Python의 GC가 멀티프로세싱과 만나면 생기는 일 - Python과 Airflow, 그리고 관련된 문제 해결기 2편

Python의 GC가 멀티프로세싱과 만나면 생기는 일 - Python과 Airflow, 그리고 관련된 문제 해결기 2편
01

Summary

Python의 GC와 멀티프로세싱이 만나면 생기는 치명적인 '메모리 폭발'과 'DB 단절' 해결기

가비지 컬렉터의 보이지 않는 쓰기 동작과 Copy-on-Write의 한계를 끝까지 추적한 깊이 있는 분석

본 아티클은 Airflow의 멀티프로세싱(fork) 환경에서 발생하는 심각한 두 가지 오류의 실태와 이를 완벽하게 분석해 나가는 과정을 다룹니다. CPython의 순환 참조 GC가 의도치 않게 Copy-on-Write를 자극하여 부모의 공유 메모리를 파괴하는 기전과, 자식 프로세스가 가비지를 해제할 때 부모의 DB 커넥션까지 유실시키는 미스터리를 원리 수준에서 파고듭니다. 저자가 실제로 모니터링 도구와 가상 메모리 영역 분석을 통해 원인을 규명하고 글로벌 오픈소스에 직접 패치를 보낸 생생한 노하우가 담겨 있습니다.

  • 01Memray 프로파일러를 통한 힙 누수 추적과 Airflow 3.1.2 버그 픽스 기여
  • 02CPython 순환 가비지 컬렉션(GC) 작동 시 PyGC_Head 헤더 수정으로 인해 리눅스의 COW가 불필요하게 가동되는 현상 검증
  • 03SQLAlchemy와 mysqlclient의 순환 참조로 인해 지연 수거된 자식 프로세스의 커넥션 객체가 부모의 소켓 연결을 종료하는 역설 분석
  • 04주소 영역별 가상 메모리 매핑(/proc/pid/pagemap) 조사를 통한 물리 프레임 번호(PFN) 단위의 극적인 실험 결과 제시
  • 05누구나 쉽게 Airflow 컴포넌트를 프로파일링할 수 있도록 공식 프로파일링 문서 및 도구 기능 편입

RECOMMENDATION

대규모 트래픽을 처리하며 Python 멀티프로세싱 환경을 고도화하고 있거나, Airflow 등 오케스트레이션 툴의 메모리 비대화 및 알 수 없는 DB 연결 종료 문제로 고통받는 시니어 백엔드 아키텍트들에게 강력히 권장합니다.

The Problem

Airflow PoC 환경에서 fork 방식을 사용함에 따라 두 가지 오동작이 발견되었습니다. 첫째는 worker 프로세스의 PSS 메모리가 누적 증가하는 현상이었고, 둘째는 MySQL 메타데이터 DB 환경에서 자식 프로세스가 종료될 때 부모 프로세스의 커넥션이 끊겨 dag-processor가 예외로 재시작되는 현상이었습니다.

The Solution

분석 결과 두 문제 모두 CPython의 메모리 관리 및 가비지 컬렉션 방식에 연관되어 있음이 밝혀졌습니다. 자식 프로세스에서 가비지 컬렉션이 돌 때 부모로부터 승계된 객체의 헤더 영역에 쓰기 동작이 가해져 Copy-on-Write(COW)를 유발했고, SQLAlchemy의 풀에 잔존하던 순환 참조된 커넥션 레코드가 수거되는 과정에서 소켓 연결을 명시적으로 종료했음을 확인했습니다.

The Result

Memray 추적을 통해 SSL context 생성 누수 및 불필요한 전체 import 요소를 먼저 수정해 Airflow 3.1.2에 기여했습니다. 더불어 향후 사용자들이 동일 문제를 쉽게 예방할 수 있도록 Airflow 프레임워크 자체에 Memray 프로파일링 실행 기능과 공식 메모리 프로파일링 문서를 추가하는 성과를 냈습니다.

Trade-off

메모리 팽창 문제를 원천 방지하기 위해 fork 시점 전에 무거운 모듈 로드를 지연시키는 지연 로딩 방안을 모색하였으나, Airflow CLI 및 프레임워크 특성상 기본적으로 로드해야 하는 모듈이 많아 자식 프로세스의 최초 메모리 양을 대폭 줄이기엔 현실적인 한계가 존재했습니다.

03

Key Concepts

Concept · 01

Copy-on-Write (COW)

자식 프로세스가 부모의 메모리 프레임을 물리적으로 복사하지 않고 가상 페이지 테이블만 공유하다가, 한쪽에서 데이터 쓰기가 일어날 때만 해당 페이지만 실제 복제하여 사용량을 절감하는 리눅스 메모리 관리 메커니즘입니다.

  • 본문에서는 자식 프로세스가 가비지 컬렉터 외엔 데이터를 수정하지 않았음에도 GC가 작동할 때마다 물리 프레임 공유가 점진적으로 해제되어 PSS 사용량이 급증하는 주원인이 되었습니다.
Concept · 02

PyGC_Head

CPython의 순환 가비지 컬렉터가 컨테이너 객체의 순환 참조를 감지하고 추적하기 위해 객체 앞에 부착하는 16바이트 혹은 24바이트 크기의 추가적인 메모리 헤더입니다.

  • 순환 GC가 외부 참조 카운트를 계산하기 위해 PyGC_Head 내의 gc_refs 등 임시 플래그 값을 갱신하는 과정에서 메모리 쓰기를 시도함으로써 COW를 촉발시켰습니다.
Concept · 03

SQLAlchemy Engine Connection Pool

매 요청마다 데이터베이스 커넥션을 맺고 끊는 병목을 제거하기 위해 데이터베이스 연결 객체들을 풀 형태로 유지관리해 주는 SQLAlchemy 라이브러리의 핵심 기능입니다.

  • fork 직후 자식 프로세스에서 engine.dispose(close=False)를 통해 부모의 풀을 공유하지 않고 버리려 했으나, 내부 순환 참조 관계에 묶여 있다가 이후 GC가 도는 타이밍에 mysqlclient 소멸자가 가동되면서 부모의 데이터베이스 연결마저 강제 해제해 버렸습니다.