New Relic 파이썬 에이전트의 패키지 목록 수집이 gevent 허브를 harvest마다 최대 2초 막는다
요약
- New Relic 파이썬 에이전트는 60초마다 도는 harvest에서 설치된 패키지 목록을 모으는데, 이때
importlib.metadata.packages_distributions()가 venv 전체의 METADATA 파일을 읽는다. 파일 읽기는 gevent가 논블로킹으로 못 바꾸므로 허브가 통째로 멈춘다. - harvest 한 번당 최대 2초 예산이라 1초 임계로 감지하면 워커마다 분당 한 건씩 잡힌다.
sys.modules스냅샷을 다 소진하면 끝나며, 관측상 워커 4개 환경에서 기동 후 약 17분 걸렸다. NEW_RELIC_PACKAGE_REPORTING_ENABLED=false로 harvest 경로는 끌 수 있다. 다만 계측 훅이 모듈을 처음 임포트할 때 하는 버전 조회는 남으므로 완전히 없어지지는 않는다.
본문
증상 (관측됨)
gunicorn gevent 워커로 도는 Django 앱에서 허브 블로킹을 계측했더니, 요청과 무관한 블로킹이 분당 4~6건씩 꾸준히 잡혔다. 워커 4개였으니 워커당 분당 1.25건이다. 잡힌 스택은 전부 패키지 메타데이터를 읽는 경로였다.
contextlib.py:446:__exit__
< importlib_metadata:948:read_text
< importlib_metadata:491:metadata
< importlib/metadata/__init__.py:1076:packages_distributions
pathlib.py:1044:open < pathlib.py:1058:read_text
< importlib_metadata:955:read_text
< importlib/metadata/__init__.py:1081:_top_level_declared
트랜잭션 이름이 붙지 않았고 스택 어디에도 애플리케이션 프레임이 없었다. 요청 처리 중이 아니라는 뜻이다.
원인 (에이전트 소스로 확인, newrelic 10.16.0)
# newrelic/core/agent.py 60초마다 harvest
self._scheduler.enter(60.0, 2, self._harvest_default, ())
# newrelic/core/application.py harvest 안에서 2초 예산만큼 제너레이터를 소비
while (configuration
and configuration.package_reporting.enabled
and self._remaining_plugins
and ((time.time() - stopwatch_start) < MAX_PACKAGE_CAPTURE_TIME_PER_SLOW_HARVEST)):
module_info = next(self.plugins)
# newrelic/core/environment.py sys.modules 스냅샷을 돌며 버전을 조회
for name, module in sys.modules.copy().items():
version = get_package_version(name)
# newrelic/common/package_version_utils.py
distributions = importlib_metadata.packages_distributions()
packages_distributions()는 설치된 모든 배포판을 훑어 top-level 모듈 이름 → 배포판 이름 매핑을 만든다. 배포판마다 METADATA 파일을 읽고 email 파서로 파싱하므로, 의존성이 많은 환경에서는 한 번 호출에 초 단위가 든다.
같은 스캔을 수십 번 반복한다
_get_package_version(name)에는 @lru_cache()가 붙어 있지만, 그 안에서 부르는 packages_distributions()에는 캐시가 없다. 그래서 캐시 미스가 난 모듈 이름마다 venv 전체를 다시 훑는다. 낭비의 실체는 "스캔을 한다"가 아니라 "같은 스캔을 N번 반복한다"다.
배포판 242개 환경에서 재보면 이렇다. 실제 프로세스는 이름 수백 개를 조회하므로 격차는 더 벌어진다.
스캔 1회 184ms
이름 25개 조회 (캐시 없음) 스캔 23회 / 1,625ms ← 전체 시간의 100%가 스캔
이름 25개 조회 (캐시 씌움) 스캔 1회 / 181ms
에이전트 최신 13.4.0에도 같은 코드라 업그레이드로는 안 풀린다. upstream에 이슈로 올려뒀다.
왜 gevent에서 특히 문제인가
monkey.patch_all()은 소켓·시간·스레드는 바꾸지만 파일 I/O는 그대로 둔다. 파일을 읽는 동안 허브로 제어권이 넘어가지 않으므로 그 워커의 모든 greenlet이 멈춘다. 같은 코드가 스레드 기반 워커에서는 한 스레드만 느려지고 끝난다. gevent에서만 전면 정지가 된다.
언제 끝나나
self.plugins와 _remaining_plugins는 Application 객체의 __init__에서만 설정된다. 세션이 재연결돼도 초기화되지 않으므로, StopIteration이 나면 그 프로세스에서는 다시 돌지 않는다. 대상 목록도 제너레이터 첫 호출 때 뜬 sys.modules.copy() 스냅샷이라 런타임에 늘지 않는다.
즉 상시 부하가 아니라 기동 후 일회성이다. 다만 프로세스 단위라 배포·스케일아웃으로 새 프로세스가 뜰 때마다 워커별로 처음부터 반복한다.
문서와 구현이 다르다
공식 문서는 "captures package and version information on startup"이라고 쓰지만, 구현은 startup에 한 번에 끝내지 않고 harvest마다 2초씩 나눠 처리한다. 그래서 기동 직후가 아니라 그 뒤 십수 분 동안 이어진다.
대응
- 끄기 —
NEW_RELIC_PACKAGE_REPORTING_ENABLED=false. 잃는 것은 APM Environment 탭의 패키지 목록뿐이고 트랜잭션·에러·메트릭에는 영향이 없다. 단, 진입점이 두 개라 절반만 막는다. 이 설정은 harvest의 plugins 루프만 끈다. 계측 훅이 모듈을 처음 임포트할 때 하는 버전 조회는 그대로 남고, 그것도 같은 스캔을 부른다. - 캐시 씌우기 —
packages_distributions를lru_cache(1)로 감싸면 N번 스캔이 1번이 된다. 컨테이너 안에서 패키지 목록은 안 바뀌므로 안전하다. 진입점 두 개를 모두 덮으므로 이쪽이 낫다. 에이전트 코드는 모듈 속성을 호출 시점에 조회하므로 속성 교체만으로 적용된다. 다만 stdlib 몽키패치라 프로세스 전역에 영향을 주고, 반환 매핑을 수정하는 호출자가 있으면 오염된다(에이전트는 읽기만 한다). - 그냥 두기 — 어차피 끝난다. 배포가 잦지 않으면 우선순위가 낮다.
계측할 때 걸린 함정
gevent 모니터 스레드로 허브 블로킹을 감지할 때, 기본 monitor_blocking은 감지할 때마다 gc.get_objects()로 전체 힙을 훑어 greenlet 트리를 덤프한다. 진단하려다 더 큰 정지를 만들기 때문에 프로덕션에서는 쓰면 안 된다. 허브 스레드의 프레임만 찍는 구현으로 갈아끼우는 편이 낫다.
그리고 가장 안쪽 프레임만 기록하면 원인을 못 짚는다. 여기서도 pathlib.py:1013:stat만 보였다. 라이브러리 프레임을 건너뛴 첫 애플리케이션 프레임과 상위 몇 프레임을 함께 담아야 호출자를 알 수 있다. 이 경우엔 스택 전체가 stdlib과 site-packages라 애플리케이션 프레임이 아예 없다는 사실 자체가 "요청 처리가 아니다"라는 단서가 됐다.
또 gevent가 이벤트에 실어주는 blocking_time은 실제로 막힌 시간이 아니라 설정한 임계값 그대로다. 지속 시간은 알 수 없고 발생 횟수로만 봐야 한다.
검증 상태
- 관측: 발생 빈도, 지속 시간, 스택 내용 — 직접 계측해 확인
- 소스 확인: 위 코드 경로 네 곳,
package_reporting.enabled설정 (upstream 저장소와 공식 문서 양쪽) - 스택에
newrelic/프레임이 직접 찍히지는 않았다. 계측이 안쪽 4프레임만 담았고caller는 site-packages를 건너뛰도록 만들어서, 에이전트 프레임이 구조적으로 안 보였다. - 반증 실험으로 확정.
NEW_RELIC_PACKAGE_REPORTING_ENABLED=false만 바꿔 재배포했더니 harvest 유래 이벤트가 사라졌다. 그 전에는 1초라는 관대한 임계에서도 기동 3분 안에 20건 넘게 나왔는데, 끈 뒤에는 임계를 0.2초로 더 낮췄는데도 12분간 2건이었다. - 다만 그 2건이 두 번째 진입점을 드러냈다. 남은 이벤트의 스택은
packages_distributions가 아니라importlib_metadata:514:version → :491:metadata였고, 요청 처리 중 지연 임포트가 계측 훅의 버전 조회를 부르는 경로였다. 설정 하나로 다 막힌다고 생각했던 게 틀렸다.
관련 노트
- gevent의 monkey.patch_all은 파일 IO를 논블로킹으로 만들지 않는다
- gevent는 I∕O 동시성만 늘리므로 적용 전 워크로드가 CPU bound인지 확인해야 한다
- 비동기 프레임워크는 head-of-line blocking과 C10K 두 문제를 해결한다
- 변수 하나만 바꾼 카나리를 같은 타깃그룹에 동시 투입하면 부하 교란 없이 회귀 원인을 격리한다
- 스택은 함수의 실행 정보를 프레임 단위로 저장한다
- pymysql 파싱 CPU의 원인 쿼리는 performance_schema digest의 SUM_ROWS_SENT 랭킹으로 특정한다
참고
- https://docs.python.org/3/library/importlib.metadata.html
- https://docs.newrelic.com/docs/apm/agents/python-agent/configuration/python-agent-configuration/
- https://github.com/newrelic/newrelic-python-agent/blob/main/newrelic/core/config.py
- https://github.com/gevent/gevent/blob/master/src/gevent/_monitor.py
- https://github.com/newrelic/newrelic-python-agent/issues/1817 (이 건으로 등록한 이슈)