서울의 한 월요일.
내가 개발한 기능이 비동기 서버를 다운시켰다. (다행히 development 서버에서...)
이를 해결하는 과정에서 공부한 Ruby 에서 발생하는 memory bloat 와 해결 방법에 대해 공유해 봅니다.
누군가는 또 비슷한 일을 겪고 이 글을 본다면 도움이 되길 바라며...
Memory bloat refers to a situation where a program consumes more memory than is necessary to execute its intended tasks. It typically occurs due to poor memory optimization, excessive data caching, or the accumulation of redundant objects. As a result, the program’s memory usage increases, leading to performance degradation and potential slowdowns. Memory bloat is usually a gradual and incremental process, and its effects may become noticeable over time.
메모리 팽창은 프로그램이 의도한 작업을 실행하는 데 필요한 것보다 더 많은 메모리를 사용하는 상황을 말합니다.
일반적으로 메모리 최적화 불량, 과도한 데이터 캐싱 또는 중복 객체의 축적으로 인해 발생합니다.
결과적으로 프로그램의 메모리 사용량이 증가하여 성능 저하 및 잠재적인 속도 저하로 이어집니다.
메모리 팽창은 일반적으로 점진적이고 점진적으로 진행되며 시간이 지남에 따라 그 영향이 눈에 띄게 나타날 수 있습니다.
ruby 는 malloc arena 를 사용하여 쓰레드에 메모리를 할당해 줍니다.
malloc arena는 여러 개가 될 수 있는데요.
메모리를 할당해 주는 malloc arena가 많으면
malloc arena는 os로부터 가져온 메모리를 웬만하면 os로 반환하지 않습니다.
대신에 잘 가지고 있다가 쓰레드로부터 할당 요청이 오면 할당해 주죠
쓰레드가 malloc arena로 메모리를 반환할 때마다
malloc arena도 os로 메모리를 반환한다면 그때마다 system call 을 사용하는 비싼 비용이 발생하게 됩니다.
이후 또 쓰레드가 메모리 할당 요청을 하고 메모리가 부족하다면 다시 os로부터 할당받아야 하는 문제가 발생하기도 하겠죠?
이 때문에 os로 메모리를 잘 반환하지 않습니다.
Ruby 에는 Malloc Arena Max 값을 설정할 수 있습니다.
Malloc Arena의 수가 적다면 독립적으로 메모리를 관리하는 수가 줄어서 전체적인 메모리 사용량을 줄 수 있습니다.
그러나 멀티쓰레드들이 메모리 할당을 위해 경합이 발생하여 처리 시간이 늘어날 수 있다는 trade off 가 있습니다.
https://www.speedshop.co/2017/12/04/malloc-doubles-ruby-memory.html
Ruby의 기본 malloc 구현체는 glibc의 ptmalloc 입니다.
ptmalloc 은 메모리를 os에 잘 반환하지 않습니다.
다른 malloc 구현체로 jemalloc 가 있습니다.
jemalloc 은 메모리 반환에 보다 적극적이라고 하는데요.
https://medium.com/motive-eng/we-solved-our-rails-memory-leaks-with-jemalloc-5c3711326456
다만 malloc 구현체를 jemalloc 으로 변경 시에 기존의 로직에 영향이 갈 수 있으니 충분한 테스트가 동반되어야 할듯합니다.
저의 경우에는 대용량 데이터에 대해 처리하는 기능이었습니다.
메모리를 500메가 정도 차지했고 동시에 여러 워커가 이 작업을 수행 시에 사용하는 메모리의 총합은 1.8기가까지 찍고 서버 따운! 되었습니다. (쿠버네티스에서 설정된 메모리 limit은 1기가)
저는 이 작업을 batch로 처리하여 메모리 사용량을 30메가 수준으로 줄였습니다.
active record로 db 데이터를 읽어올 때 find_in_batches 를 사용했습니다.
물론 find_in_batches 를 쓰는 것만으로 해결된 것은 아니고요
작업이 끝난 active record 인스턴스를 gc에서 수집해가지 않아서
gc.start(); gc.compact(); 를 직접 호출해 줬습니다.
gc가 실행되는 동안에 애플리케이션의 다른 작업들이 중단되기 때문에 매우 비싼 작업입니다.
다만 해당 기능이 사용되는 빈도가 매우 낮은 점, malloc_arena_max 나 jemallc으로 변경은 기존의 다른 로직에 전체적인 영향을 줄 수 있는 점을 고려하여 batch + gc 실행 방법으로 해결하였습니다.
위 작업은 스와치온에 입사하고 처음으로 받은 테스크를 해결하는 과정에서 겪은 것입니다.
덕분에 ruby 메모리에 대하여 깊게 이해해 볼 수 있었습니다.
원단은? 스와치온!
https://swatchon.com