- 10년 된 Rails 프로덕션 앱의
web Dyno 메모리가 배포 중 급증했고, 400~500 req/s의 지속 부하와 피크 시 수천 req/s를 처리하는 서비스라 빠른 완화가 필요했음
- Heroku에서 메모리 한계에 가까운 Dyno를 재시작하고 최근 3일간의 코드·메트릭 변경을 되돌렸지만 메모리 누수는 계속됨
- Sidekiq와 Delayed::Job은 정상인 반면, Puma worker 일부만 커지는 패턴이 보여 특정 트래픽 종류와의 연관성이 의심됨
rbtrace, ObjectSpace, heapy, sheap, reap으로 힙을 추적한 결과, Puma 요청 처리 스레드가 ActiveSupport::Notifications::Event의 @children 배열을 통해 32,067개 객체와 1.9GiB 메모리를 붙잡고 있었음
- 조작된 쿼리 파라미터가 Bugsnag의 URL 정리 과정에서
URI::InvalidURIError를 일으켰고, 단기 대응은 Bugsnag 업그레이드, 장기 대응은 Rails 업그레이드였음
운영 중인 Rails 앱에서 누수가 시작됨
- 대상은 10년 된 Rails 앱으로, 실제 매출을 만드는 프로덕션 서비스였음
- 평상시 지속 부하는 400~500 req/s였고, 피크는 초당 수천 요청까지 올라감
- 일반적인 배포 흐름 중 메모리 스파이크가 시작됐고, 페이저 알림이 발생함
- Heroku에서 실행 중이어서 Dyno별 메모리 수치를 기준으로 상태를 봄
장애 완화는 Dyno 재시작으로 시작함
- 현상은 단순 메모리 팽창(bloat)이 아니라 누수처럼 보였고, 임시 해결책은 프로세스 재시작이었음
- 평소 여러 번의 일일 배포가
web 인스턴스를 재시작했지만, 메모리 한계에 접근한 Dyno는 수동으로 재시작함
의심 변경을 되돌려도 누수는 남아 있음
- 첫 번째 큰 스파이크 직전부터 거슬러 올라가며 3일치 코드 변경을 감사함
- 관련 가능성이 있어 보인 변경은 세 가지였음
development 모드에서 Rails 코드 리로딩 때문에 메모리 누수를 일으키는 변경
- 특정 요청 필터링 중 Redis 호출이 의도보다 많아지는 변경
- 더 많은 데이터베이스 호출과
ActiveRecord 인스턴스 로딩을 유발하는 N+1 형태의 변경
- 앞의 두 변경은 수정하고, 세 번째 변경은 롤백한 뒤 하나씩 배포했지만 누수는 계속됨
- Ruby 언어 메트릭과 Puma pool 사용량 메트릭을 수집하기 위한 도구 변경도 되돌렸으나 메모리 증가는 멈추지 않음
누수 패턴은 특정 트래픽을 가리킴
- 누수는
web Dyno에서만 발생했고, Sidekiq와 Delayed::Job Dyno는 정상적으로 보였음
- 모든
web Dyno가 항상 누수되는 것은 아니었음
- 몇 시간 동안 장기 실행 웹 프로세스처럼 비교적 평탄한 메모리 사용량을 보임
- 이후 어느 순간 하나, 일부, 또는 전체 Dyno가 누수되기 시작함
- Puma는 클러스터 모드로 실행됐고, Dyno마다 8 vCPU에 대해 12 worker process를 사용함
- 한 Dyno 안에서도 12개 worker 중 일부만 거의 모든 메모리를 사용하는 경우가 있었음
- OpenTelemetry Traces는 샘플링이 강해 특정 요청 종류와 특정 Dyno를 연결하기 어려웠고, 샘플링되지 않는 로그와의 상관분석도 도구상 쉽지 않았음
힙 덤프를 수집하는 절차
- 실행 중인 Ruby 프로세스에 붙기 위해
rbtrace를 사용함
rbtrace는 프로세스에 로드돼 있어야 하므로 Gemfile에 포함했고, 환경 변수로 로딩 여부를 제어함
gem "rbtrace", require: String(ENV.fetch("FEATURE_ENABLE_MEMORY_DUMPS", false)) == "true"
- Heroku에서는
heroku ps:exec로 누수 중인 Dyno에 SSH 터널을 열고, ps로 Ruby 프로세스를 RSS 기준 정렬함
ps -eo pid,ppid,comm,rss,vsz --sort -rss | grep ruby
web Dyno에서는 같은 PPID를 가진 프로세스들이 Puma worker이고, 가장 많은 메모리를 쓰는 worker의 PID를 대상으로 삼음
- 메모리 할당 추적은
ObjectSpace.trace_object_allocations_start로 켰고, 성능·메모리·CPU에 영향을 줄 수 있음
DUMP_PID=<pid>
rbtrace --pid="${DUMP_PID}" --eval="Thread.new{require 'objspace';ObjectSpace.trace_object_allocations_start}.join"
- 힙 덤프는
ObjectSpace.dump_all로 /tmp에 생성했고, 몇 시간 실행된 누수 프로세스에서는 JSON 파일이 5~6GiB까지 커짐
rbtrace --pid="${DUMP_PID}" --eval="Thread.new{require 'objspace'; GC.start(); io=File.open('/tmp/heap-${DUMP_PID}.json', 'w'); ObjectSpace.dump_all(output: io); io.close}.join" --timeout=600
gzip "/tmp/heap-${DUMP_PID}.json"
- Heroku에서는
heroku ps:copy로 덤프를 로컬로 가져왔고, heapy로 retained memory를 보려면 최소 세 개 정도의 덤프를 수집함
- 작업 후에는 할당 추적을 끄고 덤프를 지우거나 Dyno를 재시작함
힙 분석에서 1.9GiB를 붙잡은 Thread가 드러남
heapy의 retained memory 보고서와 sheap diff만으로는 시작점을 찾기 어려웠음
- Ruby 힙 덤프의 참조 그래프를 분석하고 시각화하는
reap으로 플레임 그래프를 생성함
- 플레임 그래프는 Ruby GC 관점의 root에서 아래쪽 객체로 이어지는 참조를 보여주며, 더 많은 메모리를 붙잡는 객체일수록 셀이 넓게 표시됨
- 세 번째 힙 덤프에서 한
Thread가 1.9GiB 메모리를 붙잡고 있었음
- 실제로는 아래쪽의
Array가 32,067개 객체를 참조하며 1.9GiB를 유지하고 있었음
sheap으로 참조 경로를 따라감
- 최신
main 브랜치의 sheap을 사용해 두 번째와 세 번째 덤프를 비교함
- 덤프 크기가 6GiB에 가까워 파싱에 시간이 걸림
find_path 결과, 문제의 Thread는 텔레메트리나 메트릭 도구의 백그라운드 스레드가 아니라 요청을 처리하는 Puma thread였음
ActiveSupport::SubscriberQueueRegistry는 Rails 6.1에서 이벤트 이름별 ActiveSupport::Subscriber 목록을 저장하는 스레드별 Hash로 동작함
- 해당 registry는
Hash를 참조했고, 그 안의 Array 중 하나가 ActiveSupport::Notifications::Event를 붙잡고 있었음
- 그
Event는 다시 @children 배열을 통해 32,067개 이상의 child Event 객체를 참조함
- 첫 child
Event의 이름은 redirect_to.action_controller였고, 내부에는 ActionDispatch::Request 객체가 포함돼 있었음
이상 요청이 재현 단서가 됨
- 힙 안의
ActionDispatch::Request에는 실제 라우트와 유효한 공개 리소스 ID가 있었지만, 쿼리 파라미터는 조작된 형태였음
- 요청 경로에는
password=[FILTERED]가 포함돼 있었고, 민감 정보 정리 과정이 개입했음을 보여줌
- 같은 경로와 파라미터로 프로덕션 앱을 시크릿 브라우저에서 요청하자 500 server error가 발생함
- 로그에는
URI::InvalidURIError가 남았고, 요청이 도달한 Dyno도 확인할 수 있었음
- 해당 Dyno는 당시 정상 메모리 사용량을 보였지만, 잠시 배포를 멈추고 관찰하자 누수 추세가 나타남
- 로컬에서는
activesupport Gem에 binding.pry와 puts 디버깅을 넣어 같은 상황과 백트레이스를 재현함
실제 원인은 Rails와 Bugsnag 변경의 조합이었음
- 에러 백트레이스는 Ruby 표준 라이브러리의
uri Gem을 가리켰고, 이는 Bugsnag의 Bugsnag.cleaner.clean_url에서 사용되고 있었음
- 이 코드는
ActiveSupport::Notifications.subscribe 블록 안에서 Rails breadcrumb URL을 정리하는 과정에 있었음
- 문제는 두 가지가 결합된 형태였음
- Rails 6.1의
ActiveSupport::Subscriber는 Event#children과 shared Array로 이벤트를 추적함
- Bugsnag 변경은 Rails breadcrumb URL 정리를 위해
URI를 사용했고, 잘못된 URI에서 예외가 발생할 수 있었음
URI가 invalid URI에서 에러를 올리면 Bugsnag의 subscribe 블록이 ActiveSupport::Notifications::Event 처리 중 예외를 발생시킴
- 그 예외 때문에 parent
Event가 Subscriber#event_stack에서 pop되지 않았고, parent Event가 남아 메모리를 누수함
- parent
Event는 #children 배열로 child Event를 계속 참조해 더 많은 메모리를 유지함
- John Hawthorn의 Rails 7.1 수정은
Event#children 개념과 이벤트 추적용 shared Array를 제거해 두 누수 원인을 함께 없앰
해결책은 Bugsnag 업그레이드와 Rails 업그레이드였음
- Rails 최신 버전에서는 John Hawthorn의 수정 때문에 이 문제가 더 이상 발생하지 않음
- 당시 앱은 Rails 6.1이어서 즉시 Rails 수정 효과를 받을 수 없었음
- Bugsnag는 이미
Bugsnag.cleaner.clean_url이 invalid URI에서 예외를 올리지 않도록 수정한 상태였음
- 단기 해결은 해당 수정이 포함된 Bugsnag Gem 버전으로 업그레이드하는 것이었음
- 장기 해결은 Rails 버전을 업그레이드하는 것이었음
- 최초 메모리 스파이크 시점과 맞물린 변경은 Bugsnag
v6.26.0에서 v6.26.1로의 업그레이드였고, 목적은 다른 의존성의 deprecation warning을 고치는 것이었음