
코어 덤프 역학: 18년 된 버그 수정하기
요약
OpenAI가 ChatGPT 데이터 인프라에서 발생한 기이한 C++ 크래시를 해결한 과정을 다룹니다. 하드웨어 손상과 GNU libunwind의 18년 된 레이스 컨디션 버그를 식별하여 시스템 안정성을 확보한 사례를 설명합니다.
핵심 포인트
- C++의 저수준 제어 능력과 메모리 안전성 결여의 트레이드오프
- 하드웨어 손상과 오픈소스 라이브러리 버그의 복합적 원인 분석
- 데이터 기반의 역학적 접근을 통한 미지의 시스템 버그 식별
- Rockset 인프라의 안정성을 위한 디버깅 및 수정 과정
OpenAI의 모델과 에이전트는 추론 시간(inference time), 즉 모델이 사용자의 질문에 대해 생각하는 동안 관련 데이터를 검색하기 위해 확장 가능한 데이터 인프라(data infrastructure)에 점점 더 많이 의존하고 있습니다. 이러한 서비스 중 일부는 C++로 작성되었으며, C++의 저수준 시스템 제어 능력은 성능을 극대화하고 메모리 사용량을 최소화할 수 있게 해줍니다. 이러한 효율성 이점은 규모를 확장할 때 중요하지만, C++의 메모리 안전성(memory safety) 결여는 버그가 잘못되었거나 존재하지 않는 메모리 주소에 기록함으로써 크래시(crash)를 유발할 수 있음을 의미합니다.
몇 달 전, 우리는 많은 데이터 플러그인과 대화 검색의 핵심인 ChatGPT 데이터 인프라의 맞춤형 부분인 Rockset 서비스 내부에서 몇 가지 크래시를 관찰했습니다. 이러한 각 크래시에서 일반적인 C++ 함수가 완료된 것처럼 보이다가 잘못된 주소로 반환되었고, 이로 인해 명령어 포인터(instruction pointer)가 더 이상 코드를 가리키지 않게 되어 커널이 프로그램을 중단시켰습니다. 때로는 스택 프레임(stack frame)의 반환 주소(return address) 슬롯이 NULL인 경우도 있었습니다. 때로는 %rsp가 일반적인 실행 도중에 어떻게 감소한 것처럼 스택 포인터(stack pointer) CPU 레지스터 자체가 8바이트만큼 어긋나 있는 경우도 있었습니다. 두 경우 모두 크래시는 반환(return) 시점에 발생했습니다.
이것들은 애플리케이션 코드에서 발생하는 일반적인 실패 모드(failure modes)가 아닙니다. 저장된 반환 주소에만 영향을 미치는 잘못된 쓰기(stray write)가 발생할 가능성은 있지만, 매우 희박합니다. 인라인 어셈블리(inline assembly), setcontext, 또는 longjmp(우리는 이 중 어느 것도 사용하지 않음)를 포함하지 않고 %rsp를 8바이트만큼 어긋나게 만드는 버그는 훨씬 더 기이합니다. 왜냐하면 컴파일된 코드는 함수의 프롤로그(prologue)와 에필로그(epilogue)에서만 해당 레지스터를 직접 조정하기 때문입니다. 우리(또는 ChatGPT)가 생각할 수 있는 모든 가설은 이에 반하는 강력한 증거를 가지고 있었기에, 이 버그는 불가능해 보였습니다.
우리가 하나의 문제라고 가정했던 것은 결국 우연히 동시에 발견된 두 개의 서로 다른 버그로 밝혀졌습니다. 첫째는 CPU가 수학 계산을 올바르게 수행하지 못하는 Azure 호스트 한 곳에서의 조용한 하드웨어 손상(silent hardware corruption)이었습니다. 둘째는 널리 사용되는 오픈 소스 라이브러리 내에서 발견되지 않았던, GNU libunwind의 18년 된 레이스 컨디션(race condition) 버그였습니다.
이 포스트는 우리가 역학자(epidemiologist)처럼 생각하고, 전체 크래시(crash) 인구에 대한 고품질 데이터 세트를 구축함으로써, 설명 불가능해 보이는 크래시들을 어떻게 식별하고 수정했는지에 대한 이야기입니다.
먼저, Rockset에 대해 더 자세히 살펴보겠습니다. Rockset은 검색 및 실시간 분석을 위한 클라우드 네이티브(cloud-native) 데이터 시스템으로, OpenAI에서 동기화 커넥터(sync connectors)와 같은 다양한 내부 사용 사례를 위해 사용하고 있습니다 (Rockset은 2024년에 OpenAI에 인수되었습니다). 스트리밍 업데이트는 워크스페이스 지식 베이스의 최신 인덱스를 유지하는 데 사용되며, 이를 통해 ChatGPT가 질문에 답하거나 작업을 수행할 때 관련 정보를 검색할 수 있습니다.
Rockset의 실행 계층(execution layer)은 C++로 작성되었습니다. C++ 언어는 CPU에 대한 저수준(low-level) 액세스를 제공하여 성능과 효율성 측면에서 유리하지만, 이는 애플리케이션 버그가 잘못된 메모리 액세스 및 세그폴트(segfault, segmentation fault)로 이어질 수 있음을 의미합니다. 이를 추적하는 데 도움을 얻기 위해, 우리는 folly의 치명적 시그널 핸들러(fatal signal handler)를 사용하여 크래시가 발생할 때 스택 트레이스(stack trace)를 기록하고, 해당 코어 덤프(core dump, 프로그램이 크래시되었을 당시의 상태 스냅샷)를 나중에 분석할 수 있도록 Azure blob storage에 업로드합니다. Rockset의 모든 쿼리 처리 리프(query processing leaves)는 복제되어 있어, 크래시가 발생하더라도 클라이언트에 미치는 영향을 최소화합니다. 하지만 각각의 세그폴트는 우리의 신뢰성 및 품질 목표를 달성하기 위해 반드시 수정해야 하는 버그에 해당합니다.
우리의 초기 접근 방식은 이러한 코어들을 전통적인 디버깅 문제처럼 다루는 것이었습니다. 즉, 몇 개의 코어 덤프를 매우 면밀히 조사하고, 가설을 세운 뒤, 하나씩 배제해 나가는 방식이었습니다.
대부분의 크래시는 DocumentTree::updateDocument라고 불리는 메서드에서 발생했습니다.
이러한 크래시들에서 updateDocument가 알 수 없는 함수 X를 호출한 것으로 보였고, X가 활성화된 동안 스택(stack)이 손상되었으며, 그 후 X가 실행 가능한 코드가 아닌 주소로 반환되었습니다. 어떤 경우에는 X의 방금 팝(pop)된 프레임(frame)이 저장된 반환 주소(saved return address)가 NULL인 것을 제외하고는 유효해 보였습니다. 다른 경우에는 스택 포인터(stack pointer) 자체가 잘못되어 보였지만, 다음 유효한 프레임은 여전히 updateDocument인 것처럼 보였습니다.
스택(stack)이 언제 손상되는지 알 수 없었기에, 탐색해야 할 범위가 매우 넓었습니다. updateDocument는 인라이닝 (inlining)이 많이 발생하는 거대한 메서드였기에, X의 후보군 숫자는 압도적으로 많았습니다.
이것이 우리의 C++ 코드에 있는 버그였을까요? 컴파일러(compiler)나 링크(linkage) 문제였을까요? 우리가 사용하는 런타임 라이브러리(runtime library) 중 하나에 있는 문제였을까요? 시그널 전달(signal delivery)이나 컨텍스트 스위칭(context switching)과 관련된 리눅스 커널(Linux kernel) 버그였을까요? 아니면 훨씬 더 희귀한 무언가였을까요? 만약 이것이 잘못된 쓰기(stray write)였다면, 왜 우리의 ASAN 스테이징 환경에서 포착되지 않았을까요?
우리는 애플리케이션 레벨 로그(application-level logs)를 사용하여 문제의 모든 발생 사례를 식별하려고 시도했지만, 스택 손상(stack-corruption) 버그는 로그만으로는 분류하기 어렵습니다. 로그에 기록된 스택 트레이스(stack traces) 자체가 이미 손상되었거나 누락되어 있기 때문입니다. 우리는 위양성(false positives)과 위음성(false negatives)이 모두 발생하지 않는 로그 쿼리(log query)를 구축할 수 없었습니다. 우리는 더 많은 코어 덤프(cores)를 수동으로 조사하여 몇 가지 추가 사례를 찾아냈지만, 그 과정은 신뢰할 수 있는 데이터 세트를 얻기에는 너무 노동 집약적이었습니다.
조사 이 단계에서, 우리는 (잘못되게도) 하드웨어 버그를 제외했습니다. 여러 지역과 여러 하드웨어 유형에서 크래시(crashes)가 발생하는 것을 보았기 때문에, 여전히 소프트웨어로만 발생하는 원인을 찾고 있었습니다. 며칠 동안 우리는 단 하나의 정렬되지 않은 %rsp 크래시에 대해 매우 깊게 파고들며, 스택과 레지스터(register) 내용을 사용하여 크래시 직전의 이력을 재구성했습니다. 이를 통해 몇 가지 가능한 단서들을 얻었지만, 모든 버그가 동일한 원인을 가지고 있다는 우리의 초기 결론을 버리지 못했기에 이 상황을 타개하지 못했습니다.
조사의 전환점에 도달하기 전에, 우리가 코어 파일(core files)에서 어떤 종류의 정보를 추출하고 있었는지 설명하는 것이 중요합니다.
Rockset은 -fno-omit-frame-pointer 옵션으로 컴파일되므로, 활성 스택 프레임(active stack frame)은 항상 %rbp를 통해 접근할 수 있으며, 호출자(callers)들은 프레임 포인터(frame pointers)의 연결 리스트(linked list)를 형성합니다.
리눅스 x86_64에서 AMD64 System V ABI는 또한 %rsp 아래로 128바이트를 예약합니다.
레드 존 (red zone)으로 지정됩니다. 해당 영역은 유저스페이스 (userspace) 코드가 사용할 수 있으며, 중요한 점은 ABI 계약의 일부로서 커널이 시그널 (signal)을 전달할 때 이 영역을 훼손(clobber)하지 않겠다고 약속한다는 것입니다.
레드 존은 리턴 후 발생하는 크래시 (crash)를 디버깅하는 데 핵심적인 역할을 했는데, 그 이유는 리턴 전의 일부 정보를 보존하기 때문입니다. SIGSEGV가 트리거되면, folly의 치명적 시그널 핸들러 (fatal signal handler)가 크래시가 발생한 스레드의 스택 (stack) 위에서 실행됩니다. 더 이상 활성 상태가 아닌 스택 프레임 (stack frame)들은 (해당 함수가 리턴되었기 때문에) 마지막 128바이트를 제외하고는 시그널 핸들러에 의해 훼손됩니다. 이것이 바로 우리가 "X의 방금 팝(pop)된 스택 프레임은 NULL 리턴 주소를 제외하고는 유효해 보였다"와 같은 말을 할 수 있는 이유입니다. 레드 존은 비활성 프레임의 일부, 또는 때로는 비활성 프레임 하나 중 끝부분만을 보존합니다.
우리는 관련된 모든 함수가 매우 작은, 스택 정렬 불량 (misaligned-stack)으로 인한 크래시 하나를 발견했습니다. 덕분에 상대적으로 단순한 함수를 실행하는 동안 %rsp가 정렬되지 않은 상태가 되었으며, 그 이후의 더 많은 호출은 성공했다는 것을 알 수 있었습니다. 프로그램은 활성 함수가 마침내 리턴을 시도할 때만 크래시가 발생했습니다. 해당 코드 경로 중 그 어느 것도 예외 (exceptions), 인라인 어셈블리 (inline assembly), setcontext, 또는 longjmp를 사용하지 않았으므로, 만약 코어 덤프 (core dump)가 시사하는 방식대로 스택 포인터 (stack pointer)가 실제로 변경되었다면, 유저스페이스 코드 내의 그 어떤 그럴듯한 버그로도 이 문제를 설명할 수 없었습니다.
그것이 우리를 커널 (kernel) 쪽으로 이끌었습니다.
Rockset은 대부분의 프로그램보다 시그널을 더 공격적으로 사용합니다. 쿼리 (query) 실행은 데이터를 교환하는 많은 경량 태스크 (lightweight tasks)로 나뉩니다. 이는 높은 QPS (Queries Per Second) 워크로드를 효율적으로 처리하는 데 중요하지만, 많은 쿼리의 작업이 동일한 스레드 풀 (thread pool)로 멀티플렉싱 (multiplexed)되기 때문에 쿼리당 CPU 계정 (CPU accounting)을 처리하기 까다롭게 만듭니다.
우리의 해결책은 coarse_thread_cputime_clock라고 부르는 것인데, 이는 모든 태스크 경계에서 샘플링할 수 있을 만큼 충분히 저렴하게 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)를 근사합니다. timer_create
API를 사용하여 CPU 시간의 누적을 포함하여 시간의 경과에 대한 여러 개념을 기반으로 주기적인 신호(signal) 전달을 예약할 수 있습니다. 우리는 CPU 시간이 몇 밀리초(ms) 경과할 때마다 신호(SIGUSR2)가 전달되도록 예약하며, 이 시점에 신호 핸들러(signal handler)가 스레드 로컬(thread-local) 값을 업데이트합니다. 많은 작업이 실행되는 동안 거친(coarse) 시계의 전진을 감지하지 못하더라도, 모든 델타(delta) 값을 합산하면 쿼리에 대한 실제 CPU 시간에 대한 편향되지 않은 추정치(unbiased estimate)를 얻을 수 있습니다.
우리는 신호를 매우 빈번하게 전달하기 때문에, 컨텍스트 스위칭(context switching)이나 신호 전달(signal delivery) 주변의 드문 커널 버그가 발생할 가능성이 있어 보였습니다. 우리는 버그 리포트, 커널 소스 코드, 그리고 Azure 전용 커널 패치를 읽는 데 시간을 보냈습니다. 스트레스 테스트(stress tests)도 시도했습니다. 하지만 관련되어 보이는 것은 아무것도 찾을 수 없었습니다.
그 시점에서 우리는 한 걸음 물러나 다른 접근 방식을 시도하기로 결정했습니다.
이와 같은 문제를 디버깅하는 데는 크게 두 가지 방법이 있습니다.
하나는 일종의 의사처럼 행동하는 것입니다. 한 명의 환자에게 집중하고, 많은 테스트를 수행하며, 상세한 증거를 통해 단일 사례를 진단하려고 노력하는 것입니다.
다른 하나는 역학자(epidemiologist)처럼 행동하는 것입니다. 전체 인구 집단을 살펴보고 단일 사례로는 드러낼 수 없는 패턴이 있는지 질문하는 것입니다. 버그가 특정 릴리스(release)에서 시작되었는가? 특정 하드웨어 SKU(특정 CPU 및 서버 모델), 특정 지역, 또는 특정 커널 버전과 상관관계가 있는가? 하나의 증후군처럼 보이는 것 안에 여러 개의 별개 클러스터(cluster)가 숨어 있는가?
우리는 주로 의사 모드에 있었습니다. 핵심적인 전환은 고품질의 인구 집단 데이터(population data)를 수집해야 한다고 결정한 것이었습니다.
문제의 모든 사례를 자동으로 찾으려 했던 이전의 시도들은 로그(log)에 대한 텍스트 검색을 사용하려 했기 때문에 실패했습니다. 코어 덤프(core dumps) 자체에는 훨씬 더 많은 정보가 들어 있지만, 이를 수동으로 살펴보는 것은 확장성(scale)이 없었습니다. 우리는 코어 덤프를 자동으로 분석할 수 있는 파이프라인(pipeline)을 구축하는 데 노력을 투자하기로 결정했습니다.
우리는 ChatGPT를 사용하여 각 코어 파일(core file)의 접두사(prefix)를 다운로드하고, 레지스터(registers)를 추출하며, 로그를 사용하여 알려진 오탐(false positives)을 필터링하고, 크래시(crash)를 return-to-null, misaligned-stack 또는 기타로 자동 레이블링하는 스크립트를 작성했습니다. 그런 다음 이 스크립트를 지난 1년 동안 발생한 모든 운영 환경의 Rockset 코어 덤프(core dump)에 대해 병렬로 실행했습니다.
이것이 전환점이었습니다.
깨끗한 데이터 세트를 확보하자마자 상관관계가 즉시 나타났습니다. 우리가 하나의 이상한 버그로 취급해 왔던 것은 사실 두 개의 별개 크래시 집단(crash populations)이었습니다.
return-to-null 코어들은 여러 클러스터(clusters)와 지리적 영역에 걸쳐 퍼져 있었습니다. 최근 그 빈도가 증가했지만, 명확한 시작 날짜나 깔끔한 인프라 경계는 없었습니다.
misaligned-stack 크래시들은 완전히 달라 보였습니다. 이들은 모두 하나의 지역에서 발생했으며, 명확한 시작 날짜가 있었고, 오랫동안 실행되어 온 노드(nodes)에서는 절대 발생하지 않았습니다. 비록 여러 개의 Azure VM(가상 머신(virtual machines) 클라우드 호스팅)과 관련이 있었지만, 그 패턴은 마치 결함이 있는 하드웨어를 가진 하나의 물리적 장비가 그 위에 배치된 어떤 VM이든 문제를 일으키는 것처럼 보였습니다.
그 순간 우리는 우리가 정신적으로 두 개의 버그를 혼동하고 있었다는 것을 깨달았습니다. 두 버그의 반례(counterexamples)를 섞어서 다루고 있었기 때문에, 단일하고 일관된 설명을 찾을 수 없었던 것입니다.
Kubernetes 노드와 타임스탬프(timestamps)가 포함된 깨끗한 목록을 확보한 덕분에, 우리는 misaligned-stack 크래시를 단일 물리적 호스트(physical host)로 추적할 수 있었고, 이는 차단 목록(denylist)에 추가하기 쉬웠습니다.
몇 주간의 스트레스 테스트(stress testing) 후에도 통제된 환경에서 해당 호스트의 레지스터 오염(register corruption)을 재현할 수는 없었습니다. 하지만 문제가 된 호스트를 서비스에서 제외하자, misaligned-stack 크래시는 사라졌습니다.
문제가 된 호스트를 제거하는 것은 동일한 문제가 다시 발생하는 것을 방지할 수 없다는 점에서 영구적인 해결책은 아닙니다. 하지만 우리는 유사한 문제가 재발할 경우 이를 쉽게 탐지하고 처리할 수 있도록 소프트웨어를 변경할 수 있습니다. 우리는 로그만으로도 재발을 탐지할 수 있도록(코어 덤프(core dump)가 필요 없도록) 레지스터 상태(register state)를 포함하도록 치명적 시그널 핸들러(fatal signal handler)를 개선했습니다. 또한 VM이 재활용(recycled)되는 대신 주로 재사용(reused)되도록 컨트롤 플레인(control plane)을 변경하였으며, 이를 통해 우리 인프라 스택 계층에서 불량 노드(bad-node) 탐지를 훨씬 더 쉽게 만들었습니다. 우리는 또한 이러한 가능성을 포함하도록 런북(runbooks)과 팀의 멘탈 모델(mental models)을 업데이트했습니다.
불량 호스트의 크래시(crash)를 분리해 내자, 남은 return-to-null 코어(cores)들은 추론하기가 훨씬 쉬워졌습니다. 이전에는 예외 언와인딩(exception unwinding)이 원인이 아니라고 배제했었는데, 그 이유는 예외가 확실히 사용되지 않는 코드 경로에서의 크래시라는 반례(counterexamples)가 있다고 생각했기 때문입니다. 하지만 그 반례들은 모두 하드웨어 손상(hardware-corruption) 클러스터에서 발생한 것들이었습니다.
그 점을 염두에 두고 남은 코어들을 다시 검토하자, 우리는 기존의 결론이 정확히 반대였다는 것을 발견했습니다. 즉, 모든 크래시는 예외 언와인딩(exception unwinding) 중에 발생하고 있었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 OpenAI Blog의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기