garbage-collection

5 개의 포스트

불변 블롭 저장소 Magic Pocket의 저장 효율성 개선 (새 탭에서 열림)

Dropbox의 엑사바이트급 불변(Immutable) 블록 저장소인 'Magic Pocket'은 데이터 무결성과 효율적인 확장을 위해 설계된 핵심 인프라입니다. 최근 새로운 데이터 배치 서비스인 'Live Coder' 도입 과정에서 볼륨이 채 5%도 채워지지 않는 심각한 파편화 문제가 발생하여 스토리지 오버헤드가 급격히 증가하는 위기를 맞았습니다. 이를 해결하기 위해 Dropbox 엔지니어링 팀은 기존의 안정 상태 압축 방식을 넘어, 저밀도 볼륨을 집중적으로 재구성하는 다각적인 전략을 통해 스토리지 효율을 이전 수준보다 더욱 개선하는 데 성공했습니다. **불변 스토리지 구조와 공간 회수 메커니즘** * Magic Pocket은 데이터를 한 번 쓰면 수정하지 않는 불변 방식을 채택하고 있어, 사용자 데이터가 삭제되거나 수정되어도 기존 데이터는 즉시 지워지지 않고 볼륨 내에 그대로 남습니다. * 이러한 특성상 시간이 지남에 따라 실제 데이터보다 더 많은 디스크 공간을 점유하는 '파편화'가 발생하며, 이를 관리하지 않으면 스토리지 오버헤드가 기하급수적으로 늘어납니다. * 공간 회수는 가비지 컬렉션(참조되지 않는 데이터 식별)과 압축(Compaction)이라는 두 단계로 이루어지며, 압축은 유효한 데이터만 골라 새 볼륨에 옮겨 적고 기존 볼륨을 퇴거시키는 방식으로 수행됩니다. * 데이터 가용성을 위해 에러저 코딩(Erasure Coding)을 사용하여 복제 방식보다 적은 비용으로 결함 허용 능력을 유지합니다. **신규 서비스 도입으로 인한 저밀도 볼륨 문제** * 배경 쓰기 증폭을 줄이기 위해 도입된 'Live Coder' 서비스가 예상치 못하게 할당량의 5% 미만만 채워진 수많은 저밀도 볼륨을 생성하는 부작용을 일으켰습니다. * Magic Pocket의 볼륨은 고정된 크기를 가지기 때문에, 데이터가 적게 들어있어도 전체 용량을 점유하게 되어 실제 저장된 바이트 대비 물리적 디스크 소비량이 급등했습니다. * 기존의 압축 방식은 데이터 분포가 비교적 균일한 상태를 가정하고 설계되었기에, 엑사바이트 규모에서 발생한 대량의 저밀도 볼륨을 빠르게 정리하기에는 역부족이었습니다. **기존 L1 압축 전략의 동작과 한계** * 기존의 L1 전략은 거의 다 채워진 '호스트 볼륨'의 빈 공간에 파편화된 '공여 볼륨(Donor)'의 데이터를 채워 넣는 방식입니다. * 이 방식은 단순하고 메타데이터 업데이트 비용이 적지만, 한 번의 실행으로 회수되는 공간이 제한적이라는 단점이 있습니다. * 데이터 밀도가 극도로 낮은 볼륨이 대량으로 존재하는 상황에서는 L1 전략의 처리 속도가 파편화 속도를 따라잡지 못해 스토리지 비용을 실시간으로 절감하기 어려웠습니다. **실용적인 결론 및 권장 사항** 대규모 불변 스토리지 시스템을 운영할 때는 데이터의 배치 로직 변화가 스토리지 효율에 미치는 영향을 실시간으로 모니터링하는 것이 필수적입니다. 특히 데이터 밀도가 급격히 변하는 이례적인 상황에 대비하여, 단순히 빈 공간을 채우는 방식의 압축뿐만 아니라 저밀도 볼륨들을 한데 묶어 대량으로 정리할 수 있는 유연한 압축 전략을 병행 구축하는 것이 인프라 비용 최적화의 핵심입니다.

How we tracked down a Go 1.24 memory regression across hundreds of pods (새 탭에서 열림)

Go 1.24로의 업그레이드 이후, 새로운 맵 구현인 스위스 테이블(Swiss Tables)에 대한 기대와 달리 일부 서비스에서 메모리 사용량(RSS)이 약 20% 증가하는 현상이 발견되었습니다. 조사 결과, Go 런타임 내부의 메모리 관리 지표는 안정적이었으나 시스템 레벨의 실제 물리 메모리 점유가 늘어난 것으로 확인되었습니다. 이는 Go 1.24에서 진행된 `mallocgc` 함수의 리팩토링 과정에서 발생한 미묘한 메모리 할당자 회귀(Regression) 현상이 원인이었습니다. ### 런타임 지표와 시스템 지표의 불일치 * Go 1.24 업그레이드 후 데이터 처리 서비스의 RSS(Resident Set Size)가 눈에 띄게 증가했으나, Go 런타임 지표와 힙 프로파일상에는 아무런 변화가 기록되지 않았습니다. * 이는 Go 런타임 입장에서는 메모리를 더 사용하고 있지 않다고 판단하지만, 운영체제(Linux) 입장에서는 프로세스가 더 많은 물리 메모리를 점유하고 있는 상태임을 의미합니다. * Kubernetes의 메모리 제한(Limit)이나 OOM 킬러는 시스템 지표인 RSS를 기준으로 작동하기 때문에, 런타임 지표에 나타나지 않는 이러한 증가는 서비스 안정성에 치명적일 수 있습니다. ### 주요 변경 사항에 대한 가설 검증 * 먼저 Go 1.24의 핵심 변화인 '스위스 테이블'과 '스핀 비트 뮤텍스(Spin bit mutex)'를 원인으로 의심하고 실험을 진행했습니다. * `GOEXPERIMENT=noswissmap` 및 `GOEXPERIMENT=nospinbitmutex` 플래그를 사용하여 해당 기능들을 각각 비활성화한 후 빌드하여 배포했으나, 메모리 증가 현상은 해결되지 않았습니다. * 이를 통해 이번 문제는 새로운 기능 자체가 아니라, 런타임의 더 깊은 곳에서 발생한 변화 때문임을 확인했습니다. ### 가상 메모리와 물리 메모리의 매핑 분석 * 리눅스의 `/proc/[pid]/smaps` 파일을 분석하여 프로세스의 메모리 영역별 가상 메모리(Size)와 물리 메모리(RSS)의 차이를 추적했습니다. * 분석 결과, Go 1.23에서는 힙 영역의 RSS가 가상 메모리 크기보다 약 300 MiB 낮게 유지되었으나, Go 1.24에서는 가상 메모리 크기와 RSS가 거의 일치하는 현상이 발견되었습니다. * 결과적으로 Go 1.24의 런타임이 이전 버전보다 가상 메모리를 실제 물리 RAM에 더 공격적으로 할당(Commit)하고 있다는 사실을 밝혀냈습니다. ### mallocgc 리팩토링과 할당자 이슈 * Go 1.24 변경 로그를 정밀 분석한 결과, 메모리 할당의 핵심 로직인 `mallocgc` 함수에 대대적인 리팩토링이 있었음을 확인했습니다. * 이 과정에서 발생한 의도치 않은 로직 변화가 할당된 메모리를 실제 물리적 공간에 매핑하는 방식에 영향을 주어 RSS 상승을 유도한 것으로 파악되었습니다. * 작성자는 이 문제를 Go 개발 팀과 공유하여 원인을 확인했으며, 이는 런타임 리팩토링으로 인한 성능 회귀의 일종으로 결론지어졌습니다. Go 1.24 업그레이드를 고려 중인 팀은 런타임 내부 지표(Heap usage)뿐만 아니라 시스템 레벨의 RSS 지표를 면밀히 모니터링해야 합니다. 비록 메모리 할당자에서 미묘한 RSS 증가가 관측되었지만, 동시에 도입된 스위스 테이블은 대규모 인메모리 맵을 사용하는 서비스에서 수백 기가바이트의 메모리를 절약할 수 있는 잠재력을 가지고 있으므로 서비스 특성에 따른 비교 분석이 필요합니다.

How Go 1.24's Swiss Tables saved us hundreds of gigabytes (새 탭에서 열림)

Go 1.24에서 도입된 새로운 맵(map) 구현체인 '스위스 테이블(Swiss Tables)'은 대규모 인메모리 데이터를 다루는 서비스에서 획기적인 메모리 절감 효과를 제공합니다. Datadog의 실제 서비스 적용 사례에 따르면, 특정 고부하 환경에서 라이브 힙(Live Heap) 사용량이 500 MiB 감소했으며, 가비지 컬렉터(GOGC)의 영향을 고려할 때 전체 물리 메모리(RSS)는 약 1 GiB까지 절약되었습니다. 이는 Go 1.24의 다른 런타임 오버헤드를 상쇄하고도 남는 수준의 성능 향상을 보여줍니다. **실서비스에서의 메모리 절감 수치** * `ShardRouter` 패키지 내의 `shardRoutingCache`라는 대형 맵에서 약 500 MiB의 라이브 힙 사용량이 감소했습니다. * Go의 기본 GOGC 설정(100)을 기준으로 계산하면, 힙 사용량 감소는 실제 물리 메모리(RSS)에서 약 1 GiB(500 MiB x 2)의 절감으로 이어집니다. * Go 1.24의 다른 회귀 문제(mallocgc 이슈)로 인해 예상되는 400 MiB의 RSS 증가를 고려하더라도, 결과적으로 600 MiB의 순 메모리 감소가 확인되었습니다. **데이터 구조와 메모리 추정** * 해당 맵은 `string`을 키로, `Response` 구조체를 값으로 가집니다. * `Response` 구조체는 `ShardID`(int32), `ShardType`(int), `RoutingKey`(string header), `LastModified`(*time.Time)로 구성됩니다. * 64비트 아키텍처 기준으로 키-값 쌍 하나당 패딩을 포함해 약 56바이트를 차지하며, 서비스 시작 시 대량으로 생성된 후 런타임 중에는 거의 변경되지 않는 특성을 보입니다. **Go 1.23의 버킷 기반 맵 방식과 한계** * 기존 Go 1.23은 8개의 슬롯을 가진 '버킷' 배열로 해시 테이블을 관리했으며, 버킷 수는 항상 2의 거듭제곱으로 유지되었습니다. * 데이터 삽입 시 버킷 내부의 모든 요소를 순차적으로 스캔해야 하므로 CPU 오버헤드가 발생하며, 버킷이 가득 차면 '오버플로우 버킷'을 체이닝 방식으로 추가했습니다. * 평균 로드 팩터(Load Factor)가 13/16(약 81%)을 초과하면 버킷 배열의 크기를 2배로 늘리는 재할당이 발생하는데, 이 과정에서 점진적 복사(Evacuation) 방식을 사용하여 지연 시간을 관리했습니다. **결론 및 권장사항** 대규모 맵 데이터를 메모리에 유지하는 Go 애플리케이션은 Go 1.24로의 업그레이드만으로도 상당한 메모리 효율성 개선을 기대할 수 있습니다. 특히 읽기 중심의 거대 캐시 시스템이나 데이터 라우팅 테이블을 운영하는 경우, 스위스 테이블 기반의 최적화된 메모리 레이아웃이 비용 절감과 성능 향상에 큰 기여를 할 것입니다.

.NET Continuous Profiler: Memory usage (새 탭에서 열림)

Datadog의 .NET 프로파일러는 가비지 컬렉션(GC)의 효율성과 메모리 할당 패턴을 분석하여 애플리케이션의 성능 병목 현상을 진단합니다. 이 시스템은 모든 할당을 추적하는 대신 `AllocationTick` 이벤트를 활용한 샘플링 방식을 채택하여 운영 환경에서의 오버헤드를 최소화하면서도 정밀한 데이터를 제공합니다. 특히 .NET 7의 최신 API를 통해 객체의 생존 주기를 추적함으로써, CPU 부하의 원인이 되는 과도한 GC 작업과 잠재적인 메모리 누수 지점을 정확히 찾아내는 데 결론적인 도움을 줍니다. ### 가비지 컬렉터가 CPU에 미치는 영향 측정 * **전용 스레드 모니터링**: 서버 GC 설정 시 CLR이 생성하는 코어당 전용 스레드(.NET Server GC 및 .NET BGC)의 CPU 소비량을 운영체제로부터 직접 수집합니다. * **Pull 모델 채택**: GC 발생 시마다 이벤트를 받는 Push 방식과 달리, 프로파일러가 1분마다 주기적으로 GC 스레드의 CPU 사용 통계를 가져와 'Garbage Collector'라는 단일 프레임을 가진 네이티브 콜 스택 샘플로 기록합니다. * **버전별 차이**: .NET 5 이상에서는 GC 스레드 식별이 가능하여 정확한 측정이 가능하지만, 이전 버전에서는 스레드 ID 정보 부족으로 인해 이 기능을 완벽히 지원하기 어렵습니다. ### AllocationTick을 활용한 효율적인 할당 추적 * **샘플링 기반 추적**: 모든 객체 할당을 기록하는 `ObjectAllocated` 방식은 성능 저하가 극심하므로, 약 100KB의 할당이 누적될 때마다 발생하는 `AllocationTick` 이벤트를 사용하여 데이터를 수집합니다. * **상세 정보 수집**: 이벤트 페이로드에서 클래스 ID(ClassID), 타입 이름, 메모리 주소, 객체 크기뿐만 아니라 해당 객체가 할당된 힙의 종류(SOH, LOH, POH)까지 식별합니다. * **동기적 콜 스택 캡처**: 해당 이벤트는 할당을 수행한 스레드에서 동기적으로 발생하므로, 즉시 콜 스택을 워킹(Stack Walking)하여 어떤 비즈니스 로직이 메모리 압박을 유발하는지 특정할 수 있습니다. ### Weak Handle을 이용한 생존 객체 및 누수 탐지 * **객체 이동 대응**: GC의 컴팩션(Compaction) 단계에서 객체 주소가 변경되는 문제를 해결하기 위해, 샘플링된 객체에 대해 Weak 핸들을 생성하여 관리합니다. * **ICorProfilerInfo13 활용**: .NET 7에서 추가된 이 프로파일링 API를 통해, GC 이후에도 핸들이 가리키는 객체가 여전히 메모리에 살아있는지(`IsAllocated`)를 확인합니다. * **생명 주기 분석**: GC가 끝날 때마다 참조되지 않는 객체의 핸들은 제거하고, 생존한 객체들은 다음 프로필에 포함시켜 어떤 데이터가 메모리에 오래 머무르며 누수를 유발하는지 추적합니다. 운영 환경에서 메모리 문제를 분석할 때는 단순한 할당량 확인을 넘어, GC로 인한 CPU 점유율과 객체의 생존 기간을 함께 살펴야 합니다. 특히 .NET 7 이상의 최신 런타임을 활용하면 프로파일러의 Weak 핸들 추적 기능을 통해 메모리 누수 탐지의 정확도를 대폭 높일 수 있습니다.

.NET Continuous Profiler: 내부 동작 원리 (새 탭에서 열림)

Datadog의 .NET 프로파일러는 운영 환경에서 24시간 내내 실행될 수 있도록 설계된 상시(continuous) 프로파일링 도구로, 애플리케이션 성능에 미치는 영향을 최소화하면서 CPU, 메모리, 잠금 경합 등 다양한 런타임 지표를 수집합니다. 이를 통해 개발자는 별도의 재현 환경을 구축하지 않고도 실제 운영 상황의 성능 병목 현상을 정밀하게 분석할 수 있으며, 수집된 데이터는 효율적인 집계와 가독성 높은 호출 스택 변환 과정을 거쳐 백엔드로 전달됩니다. ## .NET 프로파일러의 구조와 데이터 수집 * 개별 리소스(CPU, 예외, 잠금 경합 등)를 담당하는 여러 독립적인 프로파일러와 샘플러, 데이터를 노출하는 프로바이더로 구성된 모듈형 아키텍처를 가집니다. * 수집된 샘플은 호출 스택(Call Stack), 컨텍스트 정보(Labels), 수치 데이터 벡터를 포함하며, 동일한 스택과 레이블을 가진 샘플은 하나로 병합하여 데이터 크기를 최적화합니다. * 샘플 집계 및 `.pprof` 형식으로의 직렬화 로직은 성능 향상과 타 언어 프로파일러와의 공유를 위해 Rust 언어로 구현되었습니다. ## 분산 추적 연동 및 백엔드 처리 * 'Runtime ID'를 사용하여 하나의 프로세스 내에서 여러 서비스(예: IIS AppDomain)가 실행되더라도 각 서비스를 정확히 식별하고 분산 추적(Traces/Spans) 데이터와 프로파일을 정교하게 연결합니다. * 트레이서(Tracer)가 런타임 ID와 서비스 이름(DD_SERVICE)의 매핑 정보를 프로파일러에 전달함으로써, 백엔드에서 특정 서비스에 해당하는 프로파일을 정확히 찾아낼 수 있게 합니다. ## 호출 스택(Call Stack) 가독성 개선 * 닷넷 런타임 API가 제공하는 로우(raw) 데이터를 개발자가 소스 코드 수준에서 즉시 이해할 수 있도록 정제(Clean-up)하는 과정을 거칩니다. * 생성자(`.ctor`)는 실제 클래스 이름으로, 익명 메서드나 람다식은 원래 메서드명에 `_AnonymousMethod`나 `_Lambda` 접미사를 붙여 변환합니다. * 로컬 메서드나 컴파일러가 생성한 비동기 상태 머신의 `MoveNext` 메서드 등 복잡한 이름들도 소스 코드 구조를 반영하도록 가공하여 분석의 혼선을 줄입니다. ## 네이티브 구현을 통한 성능 최적화 * 프로파일러 자체의 메모리 할당이 대상 애플리케이션의 가비지 컬렉션(GC)에 압박을 주지 않도록 핵심 로직을 C#(Managed code)이 아닌 네이티브 수준에서 구현하는 방향을 선택했습니다. * 이러한 설계는 운영 환경에서 성능 저하를 무시할 수 있는 수준으로 유지하면서도 상세한 런타임 성능 데이터를 지속적으로 수집할 수 있는 기반이 됩니다. 운영 환경의 부하를 최소화하면서 실제 트래픽 상황의 성능 문제를 정확히 진단하고 싶다면, 네이티브 수준에서 최적화되고 소스 코드 가독성까지 고려된 상시 프로파일링 도구를 도입하는 것이 가장 효과적인 전략입니다.