OpenAI의 모델과 에이전트는 추론 시점, 즉 모델이 사용자의 질문을 처리하는 동안 관련 데이터를 검색하기 위해 점점 더 확장 가능한 데이터 인프라에 의존하고 있습니다. 이러한 서비스 가운데 일부는 C++로 구현되어 있습니다. C++는 시스템을 저수준에서 제어할 수 있기 때문에 성능은 극대화하고 메모리 사용량은 최소화할 수 있습니다. 이러한 효율성은 시스템 규모가 커질수록 더욱 중요해집니다. 하지만 C++는 메모리 안전성을 보장하지 않기 때문에, 버그가 잘못된 메모리 주소나 존재하지 않는 메모리 주소에 데이터를 기록하면서 크래시를 일으킬 수 있습니다.
몇 달 전, ChatGPT 데이터 인프라의 핵심 구성 요소인 Rockset 서비스에서 크래시가 발생하는 것을 확인했습니다. Rockset은 다양한 데이터 플러그인과 대화 검색 기능을 지원하기 위해 OpenAI가 맞춤형으로 구축한 시스템입니다. 크래시가 발생한 사례를 살펴보면 정상적으로 실행되던 C++ 함수가 작업을 마친 뒤 잘못된 주소로 복귀하면서, 명령어 포인터가 더 이상 실행 가능한 코드를 가리키지 않아 커널이 프로그램을 종료하고 있었습니다. 어떤 경우에는 스택 프레임의 반환 주소가 NULL로 바뀌어 있었고, 또 어떤 경우에는 %rsp가 정상 실행 중간에 감소한 것처럼 스택 포인터 CPU 레지스터가 8바이트만큼 어긋나 있었습니다. 두 경우 모두 함수가 반환되는 시점에 크래시가 발생했습니다.
이러한 현상은 일반적인 애플리케이션 버그에서는 거의 나타나지 않습니다. 저장된 반환 주소만 우연히 덮어쓰는 잘못된 메모리 쓰기가 발생할 가능성은 있지만, 그럴 확률은 매우 낮습니다. 또한 OpenAI에서는 사용하지 않는 inline assembly, setcontext, longjmp 없이 %rsp가 8바이트만큼 어긋나는 버그는 더욱 이해하기 어려웠습니다. 컴파일된 코드는 함수의 시작과 끝 부분에서만 해당 레지스터를 직접 수정하기 때문입니다. 저희와 ChatGPT가 세운 모든 가설은 하나같이 이를 부정하는 강력한 증거와 충돌했고, 결국 이 버그는 설명이 불가능한 것처럼 보였습니다.
하나의 문제라고 생각했던 현상은 결국 우연히 같은 시기에 발견된 서로 관련 없는 두 개의 버그였습니다. 첫 번째는 Azure 호스트 한 대에서 발생한 조용한 하드웨어 오류(silent hardware corruption)로, CPU가 계산을 잘못 수행하고 있었습니다. 두 번째는 널리 사용되는 오픈소스 라이브러리인 GNU libunwind에 18년 동안 숨어 있던 경쟁 상태(race condition) 버그였습니다.
이 글에서는 역학 연구자의 관점으로 접근해 크래시 전체를 대상으로 한 고품질 데이터세트를 구축하고, 원인을 설명하기 어려웠던 크래시를 어떻게 찾아내고 해결했는지 설명합니다.
먼저 Rockset을 조금 더 자세히 살펴보겠습니다. Rockset은 검색과 실시간 분석을 위한 클라우드 네이티브 데이터 시스템으로, OpenAI에서는 동기화 커넥터를 비롯한 다양한 내부 서비스에 활용하고 있습니다(Rockset은 2024년에 OpenAI에 인수되었습니다). 스트리밍 업데이트를 통해 워크스페이스 지식 기반의 인덱스를 항상 최신 상태로 유지하며, 이를 바탕으로 ChatGPT는 질문에 답하거나 작업을 수행할 때 필요한 정보를 검색할 수 있습니다.
Rockset의 실행 계층은 C++로 작성되어 있습니다. C++는 CPU를 낮은 수준에서 제어할 수 있어 높은 성능과 효율을 얻을 수 있지만, 애플리케이션 버그가 잘못된 메모리 접근이나 세그멘테이션 오류를 일으킬 수 있다는 단점도 있습니다. 이러한 문제를 추적하기 위해 크래시가 발생하면 folly의 치명적 시그널 핸들러(fatal signal handler)를 사용해 스택 트레이스를 기록하고, 당시 프로그램의 상태를 담은 코어 덤프를 Azure Blob Storage에 업로드해 이후 분석에 활용합니다. 또한 Rockset에서는 모든 쿼리 처리 리프 노드를 복제해 두기 때문에 크래시가 발생하더라도 클라이언트에 미치는 영향을 최소화할 수 있습니다. 하지만 세그멘테이션 오류 하나하나는 모두 해결해야 할 버그이며, 신뢰성과 품질 목표를 달성하려면 반드시 원인을 찾아 수정해야 합니다.
처음에는 일반적인 디버깅과 같은 방식으로 접근했습니다. 몇 개의 코어 덤프를 면밀히 분석하고, 가능한 가설을 세운 뒤 하나씩 검증하며 배제해 나갔습니다.
크래시 대부분은 DocumentTree::updateDocument라는 메서드에서 발생했습니다. 크래시가 발생한 사례를 분석해 보면 updateDocument가 이름을 알 수 없는 함수 X를 호출했고, X가 실행되는 동안 스택이 손상된 뒤, X가 반환하는 과정에서 실행 가능한 코드가 아닌 주소로 복귀하면서 크래시가 발생한 것으로 보였습니다. 어떤 경우에는 X가 반환한 뒤 스택에서 막 제거된 프레임을 살펴보면 저장된 반환 주소만 NULL이었고, 나머지 내용은 정상처럼 보였습니다. 또 다른 경우에는 스택 포인터 자체가 잘못된 값을 가리키고 있었지만, 그다음으로 유효한 스택 프레임은 여전히 updateDocument였습니다.
스택이 정확히 언제 손상됐는지를 알 수 없었기 때문에 조사 범위가 크게 넓어졌습니다. updateDocument는 인라인 최적화가 많이 적용되는 큰 메서드였고, 그 결과 X가 될 수 있는 후보 함수가 너무 많았습니다.
원인이 C++ 코드의 버그였을까? 컴파일러나 링크 과정의 문제였을까? 런타임 라이브러리 가운데 하나에 문제가 있었을까? Linux 커널의 시그널 전달이나 컨텍스트 스위칭 과정에서 발생한 버그였을까? 아니면 그보다 더 드문 원인이 있었을까? 만약 의도하지 않은 메모리 쓰기 때문이었다면, 왜 ASAN 스테이징 환경에서는 이를 발견하지 못했을까?
애플리케이션 로그를 이용해 이 문제가 발생한 모든 사례를 찾아보려 했지만, 스택 손상 버그는 로그만으로 분류하기가 매우 어려웠습니다. 로그에 남은 스택 트레이스 자체가 이미 손상되어 있거나 아예 누락된 경우가 많았기 때문입니다. 거짓 양성과 거짓 음성을 모두 피할 수 있는 로그 쿼리는 끝내 만들지 못했습니다. 이후 더 많은 코어 덤프를 직접 분석해 추가 사례를 찾아냈지만, 이 방법은 너무 많은 수작업이 필요해 신뢰할 수 있는 데이터세트를 구축하기에는 적합하지 않았습니다.
조사 초기에는(결과적으로는 잘못된 판단이었지만) 여러 리전과 다양한 하드웨어에서 동일한 크래시가 발생하는 것을 확인했기 때문에 하드웨어 문제는 배제하고 소프트웨어 원인만 추적하고 있었습니다. 이후 며칠 동안은 %rsp가 어긋난 단일 크래시 사례 하나를 집중적으로 분석하며, 스택과 레지스터 정보를 바탕으로 크래시 직전까지의 실행 과정을 재구성했습니다. 그 과정에서 몇 가지 단서를 얻기는 했지만, 모든 크래시의 원인이 하나라고 처음부터 가정했던 생각을 끝내 버리지 못한 탓에 문제 해결의 돌파구를 찾지는 못했습니다.
조사의 전환점을 이야기하기에 앞서, 먼저 코어 파일에서 어떤 정보를 추출해 분석했는지 설명하겠습니다.
Rockset은 -fno-omit-frame-pointer 옵션으로 컴파일되므로 현재 활성 스택 프레임은 항상 %rbp를 통해 찾을 수 있으며, 호출자들의 스택 프레임은 프레임 포인터로 연결된 리스트 형태를 이룹니다.
Linux x86_64 환경에서 AMD64 System V ABI는 %rsp 아래 128바이트를 레드 존으로 예약합니다. 이 영역은 사용자 공간 코드가 자유롭게 사용할 수 있으며, ABI 규약에 따라 커널은 시그널을 전달할 때도 이 영역을 덮어쓰지 않도록 보장합니다.
레드 존은 함수가 반환된 뒤 발생하는 크래시를 디버깅하는 데 중요한 역할을 했습니다. 함수가 반환되기 전의 일부 정보가 그대로 남아 있기 때문입니다. SIGSEGV가 발생하면 folly의 치명적 시그널 핸들러는 크래시가 발생한 스레드의 스택에서 실행됩니다. 이미 함수가 반환되어 더 이상 활성 상태가 아닌 스택 프레임은 대부분 시그널 핸들러에 의해 덮어써지지만, 마지막 128바이트는 예외입니다. 이 때문에 "X가 막 반환한 스택 프레임은 저장된 반환 주소만 NULL이었을 뿐, 나머지는 정상처럼 보였다"와 같은 판단을 내릴 수 있었습니다. 즉, 레드 존에는 비활성 스택 프레임의 일부가 그대로 남아 있으며, 경우에 따라서는 비활성 스택 프레임 하나의 마지막 부분만 보존되기도 합니다.
팀은 관련된 함수들이 모두 매우 작은, 한 건의 스택 정렬 오류 크래시를 발견했습니다. 덕분에 비교적 단순한 함수가 실행되는 도중 %rsp의 정렬이 어긋났고, 그 이후에도 여러 함수 호출이 정상적으로 수행되었다는 사실을 확인할 수 있었습니다. 프로그램은 활성 함수가 마지막으로 반환을 시도하는 순간에야 비로소 크래시했습니다. 해당 코드 경로에서는 예외 처리, inline assembly, setcontext, longjmp를 전혀 사용하지 않았습니다. 따라서 코어 덤프가 보여주는 것처럼 스택 포인터가 실제로 그렇게 변경되었다면, 사용자 공간 코드에서 발생할 수 있는 그럴듯한 버그만으로는 이 현상을 설명할 수 없었습니다.
이로 인해 조사의 초점은 커널로 옮겨갔습니다.
Rockset은 대부분의 프로그램보다 시그널을 훨씬 적극적으로 활용합니다. 쿼리 실행은 데이터를 주고받는 여러 개의 경량 태스크로 나뉘어 처리됩니다. 이러한 구조는 높은 QPS 환경을 효율적으로 처리하는 데는 유리하지만, 여러 쿼리의 작업이 동일한 스레드 풀에서 번갈아 실행되기 때문에 쿼리별 CPU 사용량을 집계하기는 쉽지 않습니다.
팀은 이를 해결하기 위해 coarse_thread_cputime_clock이라고 부르는 방식을 사용합니다. 이 방식은 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)를 충분히 낮은 비용으로 근사하여 모든 태스크 경계에서 샘플링할 수 있도록 해줍니다. timer_create API를 사용하면 CPU 시간의 누적을 비롯한 여러 시간 기준에 따라 주기적으로 시그널을 전달하도록 예약할 수 있습니다. 팀은 CPU 시간이 몇 밀리초 누적될 때마다 시그널(SIGUSR2)이 전달되도록 예약하고, 시그널 핸들러는 전달될 때마다 스레드 로컬 값을 갱신합니다. 많은 태스크는 실행되는 동안 근사 클록이 증가하는 것을 직접 관찰하지 못하지만, 모든 증가분을 합산하면 쿼리의 실제 CPU 시간을 편향 없이 추정할 수 있습니다.
시그널을 매우 자주 전달하는 구조였기 때문에, 컨텍스트 스위칭이나 시그널 전달 과정에서 발생하는 드문 커널 버그일 가능성도 충분히 있어 보였습니다. 팀은 버그 리포트와 커널 소스 코드, Azure 전용 커널 패치를 살펴보고 스트레스 테스트도 수행했습니다. 그러나 관련이 있어 보이는 원인은 끝내 찾지 못했습니다.
그 시점에서 팀은 한발 물러나 문제를 다시 바라보고, 접근 방식을 바꿔 보기로 했습니다.
이런 문제를 디버깅하는 방법은 크게 두 가지입니다.
첫 번째는 의사처럼 접근하는 방법입니다. 한 명의 환자에게 집중해 여러 검사를 실시하고, 상세한 증거를 바탕으로 개별 사례의 원인을 진단하는 방식입니다.
두 번째는 역학자처럼 접근하는 방법입니다. 개별 사례가 아니라 전체 집단을 살펴보며, 한 건의 사례만으로는 드러나지 않는 패턴이 있는지 확인하는 것입니다. 버그는 특정 릴리스부터 발생하기 시작했는가? 특정 하드웨어 SKU(특정 CPU 및 서버 모델), 특정 리전 또는 특정 커널 버전과 상관관계가 있는가? 하나의 동일한 증상처럼 보이는 현상 속에 서로 다른 여러 문제군이 숨어 있는 것은 아닐까?
그때까지 팀은 주로 '의사처럼 접근하는 방식'으로 문제를 분석하고 있었습니다. 전환점이 된 것은, 무엇보다 먼저 고품질의 집단 데이터를 확보해야 한다고 판단한 것이었습니다.
이전에는 로그의 텍스트 검색만으로 이 문제가 발생한 모든 사례를 자동으로 찾아내려고 했지만, 그 방법으로는 한계가 있었습니다. 코어 덤프에는 훨씬 더 많은 정보가 담겨 있었지만, 이를 하나씩 수동으로 분석하는 방식으로는 규모를 감당할 수 없었습니다. 그래서 팀은 코어 덤프를 자동으로 분석하는 파이프라인을 구축하기로 했습니다.
팀은 ChatGPT를 활용해 각 코어 파일의 앞부분을 다운로드하고, 레지스터 정보를 추출한 뒤, 로그를 이용해 알려진 거짓 양성을 걸러내고, 크래시를 return-to-null, misaligned-stack, 또는 그 밖의 유형으로 자동 분류하는 스크립트를 작성했습니다. 이후 이 스크립트를 병렬로 실행해 지난 1년간 운영 환경에서 수집된 모든 Rockset 코어 덤프를 분석했습니다.
바로 이 지점이 전환점이었습니다.
신뢰할 수 있는 데이터세트가 갖춰지자 상관관계가 곧바로 드러났습니다. 그동안 하나의 이상한 버그라고 생각했던 현상은, 실제로는 서로 다른 두 종류의 크래시 집단이었습니다.
return-to-null 코어 덤프는 여러 클러스터와 지역에 걸쳐 분산되어 있었습니다. 최근 들어 발생 빈도는 증가했지만, 명확한 발생 시점도 없었고 특정 인프라 경계를 기준으로 구분되는 패턴도 보이지 않았습니다.
반면 misaligned-stack 크래시는 전혀 다른 양상을 보였습니다. 모두 동일한 리전에서 발생했고, 발생 시점도 명확했으며, 오랫동안 실행 중이던 노드에서는 한 번도 발생하지 않았습니다. 여러 Azure VM(클라우드에서 호스팅되는 가상 머신)에서 발생했지만, 패턴을 살펴보면 하드웨어에 결함이 있는 하나의 물리 서버에 VM이 배치될 때마다 문제가 발생하는 것처럼 보였습니다.
바로 그 순간 팀은 그동안 서로 다른 두 버그를 하나의 문제로 잘못 묶어서 생각하고 있었다는 사실을 깨달았습니다. 두 버그에서 나온 반례를 뒤섞어 해석하고 있었기 때문에, 모든 현상을 일관되게 설명할 수 있는 하나의 가설을 찾지 못했던 것입니다.
Kubernetes 노드와 타임스탬프를 정확하게 정리한 목록을 확보한 덕분에, 팀은 misaligned-stack 크래시의 원인을 하나의 물리적 호스트까지 추적할 수 있었습니다. 해당 호스트는 차단 목록에 추가하는 것만으로도 쉽게 대응할 수 있었습니다.
몇 주 동안 스트레스 테스트를 수행했지만, 통제된 환경에서는 해당 호스트에서 레지스터 손상을 재현하지 못했습니다. 그러나 문제가 있던 호스트를 서비스에서 제외하자 misaligned-stack 크래시는 완전히 사라졌습니다.
결함이 있는 호스트를 제거하는 것만으로는 근본적인 해결책이 될 수 없습니다. 같은 문제가 다시 발생하는 것을 막지는 못하기 때문입니다. 하지만 소프트웨어를 개선하면 비슷한 문제가 다시 발생하더라도 쉽게 감지하고 대응할 수 있습니다. 팀은 치명적 시그널 핸들러가 레지스터 상태까지 함께 기록하도록 개선해, 이제는 코어 덤프 없이도 로그만으로 동일한 문제가 다시 발생했는지 확인할 수 있게 했습니다. 또한 VM을 폐기하고 새로 생성하는 대신 가능한 한 재사용하도록 컨트롤 플레인을 변경해, 현재 인프라 계층에서 결함이 있는 노드를 훨씬 쉽게 찾아낼 수 있도록 했습니다. 마지막으로 이러한 가능성을 운영 절차 문서와 팀의 문제 해결 방식에도 반영했습니다.
결함이 있는 호스트에서 발생한 크래시를 따로 분리하고 나자, 남아 있는 return-to-null 코어 덤프는 훨씬 쉽게 분석할 수 있었습니다. 앞서 팀은 예외 언와인딩을 원인 후보에서 제외했는데, 예외가 분명히 사용되지 않는 코드 경로에서도 크래시가 발생한 사례를 반례로 보고 있었기 때문입니다. 하지만 그 반례라고 생각했던 사례들은 모두 하드웨어 손상으로 발생한 크래시였습니다.
이러한 점을 염두에 두고 남아 있던 코어 덤프를 다시 분석해 보니, 기존의 결론은 정반대였습니다. 모든 크래시는 예외 언와인딩 과정에서 발생하고 있었던 것입니다.
C++에서 예외가 발생하면 런타임은 어떤 캐치 블록이 해당 예외를 처리해야 하는지, 그리고 그 과정에서 어떤 소멸자나 정리 핸들러를 실행해야 하는지를 찾아야 합니다. 이러한 정보를 담은 메타데이터는 컴파일러가 생성하지만, 실제로 어떤 처리 경로를 선택할지는 런타임에서 동적으로 결정됩니다.
예외 언와인딩은 throw를 호출한 함수가 직접 수행하는 것이 아닙니다. 대신 컴파일된 코드가 호출하는 보조 함수들이 이 작업을 담당합니다. 이러한 런타임 루틴은 스택을 살펴보고, 스택에 있는 함수들의 메타데이터를 가져온 뒤, 실행해야 할 정리 핸들러와 캐치 블록을 동적으로 찾아 그중 하나로 제어를 넘깁니다. 이 과정에서 제어가 넘어가는 경로에 있는 모든 스택 프레임(보조 함수의 스택 프레임 포함)에 대해 언와인딩이 수행됩니다.
실제 동작 방식만 놓고 보면, 이는 일반적인 함수 호출과 반환보다는 longjmp나 파이버 전환에 훨씬 더 가깝습니다. 이 과정에서는 callee-save 레지스터뿐만 아니라 스택 프레임 레지스터인 %rbp와 %rsp도 함께 복원해야 합니다.
팀이 사용하는 바이너리는 C++ 예외 언와인딩을 수행하는 함수를 구현한 두 라이브러리, 즉 libgcc와 GNU libunwind에 링크되어 있습니다. 실제로는 동적 링커가 GNU libunwind의 구현을 선택하고 있었습니다. 이는 팀에게도 예상 밖의 결과였습니다. 심볼 버전 관리 규칙에 따라 libgcc의 구현이 선택될 것으로 생각했기 때문입니다. 하지만 실행 중인 바이너리를 확인해 보니 실제 동작은 예상과 달랐습니다.
이 시점에서 팀의 작업 가설은 다시 바뀌었습니다. 하나의 버그만 있다고 생각했을 때 세웠던 또 하나의 가정을 내려놓았기 때문입니다.
어쩌면 팀이 보고 있던 것은 함수가 NULL로 반환되는 일반적인 상황이 아니었을지도 모릅니다. 대신 언와인드 과정에서 제어가 넘어가는 순간, 즉 setcontext처럼 레지스터를 복원하는 과정에서 제어가 넘어가기 전에 대상 명령어 포인터가 NULL로 바뀌는 상황이었을지도 모릅니다. 다시 말해, 문제는 스택의 반환 주소 슬롯이 잘못된 것이 아니라 언와인드 라이브러리가 제공한 데이터가 잘못된 것이었을 가능성이 있었습니다.
이렇게 되자 문제의 범위는 크게 좁혀졌습니다. 가능성은 두 가지뿐이었습니다. GNU libunwind가 처음부터 잘못된 대상 상태를 계산했거나, 올바른 상태를 계산했더라도 실제로 적용되기 전에 어딘가에서 그 내용이 손상되고 있었던 것입니다.
팀은 GNU libunwind의 소스 코드를 살펴보다가, 이 라이브러리가 스택에 ucontext_t 구조체를 생성한 뒤, 정리 핸들러의 스택 프레임에 필요한 레지스터 상태를 채워 넣고, 마지막으로 그 구조체의 포인터를 내부 어셈블리 루틴인 _Ux86_64_setcontext에 전달한다는 사실을 확인했습니다.
이 시점에서 모든 퍼즐 조각이 맞춰졌습니다.
스택에 생성된 ucontext_t는 _Ux86_64_setcontext가 실행되는 동안 언와인딩되는 스택 프레임 가운데 하나에 위치합니다. 그렇다면 _Ux86_64_setcontext는 %rsp를 변경한 뒤, 이미 해당 구조체가 활성 스택에서 벗어난 상태에서도 계속 그 구조체를 읽고 있었던 것은 아닐까요? 만약 그렇다면 팀에서 자주 전달하던 SIGUSR2 같은 시그널에 의해 해당 메모리가 덮어써질 가능성이 생깁니다.
정답은 '그렇다'였습니다.
아래는 팀이 사용하던 GNU libunwind 버전의 _Ux86_64_setcontext 마지막 여섯 개 명령어입니다. 대부분 메모리에서 값을 읽어 대상 레지스터로 옮기는 mov 명령어로 이루어져 있습니다.
(%rdi는 스택에 생성된 ucontext_t를 가리키며, UC_MCONTEXT_* 매크로는 특정 레지스터가 저장된 고정 오프셋으로 확장됩니다.)
첫 번째 명령어가 바로 경쟁 상태 구간의 시작입니다. 이 명령어는 %rsp를 활성 스택의 새로운 바닥을 가리키도록 변경합니다. 이 순간부터 %rdi가 가리키는 구조체는 더 이상 활성 스택(또는 레드 존)의 일부가 아니며, 커널이 건드려서는 안 되는 영역에서도 벗어나게 됩니다.
평소에는 이것이 문제가 되지 않습니다. 하지만 시그널이 정확히 그 순간(어쩌면 최악의 순간)에 도착하면, 커널은 %rsp-128 위치에 시그널 프레임을 생성합니다. 그러면 %rdi가 가리키는 메모리가 덮어써질 수 있습니다.
다음 명령어가 UC_MCONTEXT_GREGS_RIP(%rdi)를 읽기 전에 이런 상황이 발생하면, 복원될 명령어 포인터가 손상될 수 있습니다. 실제로 팀이 분석한 크래시에서는 그 값이 NULL로 바뀌었습니다.
이것이 바로 버그였습니다.
이 어셈블리 코드를 보면 팀을 혼란스럽게 했던 또 다른 현상도 설명할 수 있습니다. 바로 함수 X의 이전 스택 프레임에서 반환 주소 슬롯이 NULL로 보였던 이유입니다.
setcontext는 %rdi를 포함한 모든 레지스터를 복원하도록 구현되어 있기 때문에, 제어를 넘기는 마지막 순간에는 UC_MCONTEXT_GREGS_RIP(%rdi)를 읽기 위해 %rdi를 사용할 수 없습니다. 대신 그보다 앞선 시점에 해당 값을 읽어 스택에 저장한 뒤, 몇 개의 레지스터를 더 복원하고 마지막으로 retq를 사용해 저장해 둔 값을 읽어 제어를 넘깁니다.
코어 덤프만 보면 '함수가 NULL로 반환했다'는 것처럼 보였지만, 실제로는 '언와인더가 스택에 대상 반환 주소를 만들어 놓았고, 제어가 넘어가기 전에 그 주소가 손상된 것'이었습니다. 팀은 반환 주소 슬롯이 손상되려면 반드시 원래 위치에서 직접 덮어써졌을 것이라고 가정했습니다. 반환 주소 슬롯에 변경될 수 있는 데이터를 의도적으로 기록하는 코드가 존재한다고는 생각하지 못했기 때문입니다.
이 버그가 믿기 어려울 정도로 희귀해 보였던 이유는 경쟁 상태가 발생할 수 있는 구간이 극도로 짧았기 때문입니다. 이러한 경쟁 상태에서는 외부 이벤트(시그널)가 다른 스레드의 두 동작 사이에 정확히 끼어들어야 합니다. 두 동작 사이의 간격이 짧을수록 경쟁 상태가 발생할 가능성도 그만큼 낮아집니다.
이 경우 취약 구간은 문자 그대로 명령어 하나에 불과했습니다. 시그널은 %rsp가 변경된 직후, 다음 명령어가 %rip를 로드하기 전에 정확히 전달되어야 합니다. 최신 슈퍼스칼라 기반의 비순차 실행 CPU는 이러한 단순한 명령어를 한 사이클에 여러 개 실행할 수 있기 때문에, 이 경쟁 상태 구간은 약 100피코초에 불과합니다.
이 경쟁 상태를 발견했을 때 팀의 첫 반응은 '이 정도로 드문 현상이 실제 크래시 발생 빈도를 설명할 수 있을 리 없다'는 것이었습니다. 운영 환경 전체에서는 하루에 10건이 넘는 return-to-null 크래시가 발생하고 있었습니다. 예외 처리 과정에서 명령어 하나 사이에 발생하는 경쟁 상태만으로 정말 이런 수준의 크래시를 설명할 수 있을까요?
이를 확인하기 위해 팀은 페르미 추정을 적용했습니다. 취약 구간이 약 초이고 SIGUSR2가 CPU 시간 기준으로 초마다 한 번씩 전달된다면, 각 예외 정리 핸들러나 캐치 블록이 이 경쟁 상태에서 실패할 확률은 대략 입니다.
Rockset은 내부적인 데이터 수집 백프레셔 메커니즘의 일부로 예외를 사용합니다. 과부하가 걸린 호스트 한 대에서는 초당 약 개의 예외가 발생할 수 있습니다. 이를 기준으로 계산하면 백프레셔를 사용하는 호스트의 평균 고장 간격은 약 초, 즉 몇 시간에 한 번꼴로 크래시가 발생한다는 의미입니다. 운영 환경 전체 규모로 보면 이는 실제 관측된 크래시 발생 빈도를 충분히 설명할 수 있는 수준이었습니다.
GNU libunwind의 이 버그는 새로운 문제가 아닙니다. C++ 예외 언와인딩을 지원한 최초의 x86_64 버전부터 존재해 온, 18년이 넘은 버그입니다.
그렇다면 왜 이제 와서 문제가 드러난 것일까요?
크래시 발생률은 발생하는 예외의 수와 전달되는 시그널의 수에 거의 비례합니다. 또한 시그널 핸들러가 얼마나 많은 스택을 사용하는지도 영향을 미칩니다.
Rockset은 이 세 가지 조건을 모두 충족하는 드문 사례였습니다. 팀은 정상적인 과부하 제어 과정에서 많은 예외를 발생시키고 있었고, coarse_thread_cputime_clock 때문에 SIGUSR2도 매우 자주 전달하고 있었습니다. 또한 올해 초에는 병합된 시그널을 계산할 수 있도록 timer_getoverrun 호출을 추가하면서 SIGUSR2 핸들러가 사용하는 스택의 크기도 늘어났습니다.
결과적으로 마지막 변경 사항이 중요한 역할을 했던 것으로 보입니다. 시그널 핸들러가 사용하는 스택이 충분히 작다면 오래된 ucontext_t 메모리 영역까지 도달해 이를 덮어쓰지 않을 수도 있습니다. 실제로 이 변경 이전에는 이러한 크래시가 전혀 발생하지 않았습니다. 변경 이후에도 발생 빈도는 낮은 수준을 유지했지만, 백프레셔 메커니즘에 큰 부하를 주는 일부 워크로드의 트래픽을 늘린 뒤부터 크래시가 눈에 띄기 시작했습니다.
다시 말해 libunwind 버그는 오래전부터 존재했지만, 예외 발생 빈도와 시그널 전달 빈도, 그리고 시그널 핸들러의 스택 사용량이 서로 맞물리면서 최근에야 운영 환경에서 눈에 띄기 시작한 것입니다.
이 메커니즘은 하드웨어 버그와 libunwind 버그가 모두 주로 DocumentTree::updateDocument에서 크래시를 일으킨 이유도 설명해 줍니다. libunwind로 인한 크래시는 데이터 적재 백프레셔를 적용하기 위해 예외를 발생시키는 시점마다 항상 이 메서드가 실행되고 있었기 때문에 대부분 이곳에서 발생했습니다. 또한 %rsp 정렬 오류 크래시도 주로 이 메서드에서 발생했는데, 문제가 있던 하드웨어 노드가 대량 데이터 적재에 사용하는 SKU였고, 이 작업은 CPU 시간을 대부분 이 메서드에서 사용했기 때문입니다.
팀은 우선 GNU libunwind 대신 libgcc의 언와인더로 전환했습니다. 이 결정은 그 자체로도 충분한 이점이 있었습니다. libgcc 구현은 잠금 경합을 줄이기 위한 최적화가 많이 이루어져 있어, 대규모 VM 환경으로 확장할 때 특히 유리했기 때문입니다.
또한 팀은 독립적으로 실행 가능한 재현 코드와 수정 사항(새 창에서 열기)을 GNU libunwind 업스트림에 반영했으며, 다른 언와인더에는 동일한 문제가 없다는 사실도 확인했습니다.
이번 디버깅 과정을 통해 팀은 동적 링킹, DWARF 언와인드 메타데이터, Linux의 시그널 전달 방식, System V ABI, C++ 예외 처리 메커니즘 등 다양한 기술의 내부 동작을 깊이 이해하게 되었습니다. 하지만 이번 경험에서 얻은 가장 큰 교훈은 그보다 훨씬 단순했습니다.
가장 중요한 것은 어셈블리 코드를 정교하게 분석하거나 세부 구현을 깊이 이해하는 일이 아니었습니다. 핵심은 신뢰할 수 있는 고품질 데이터세트를 구축하는 것이었습니다. 그런 데이터가 없을 때는 서로 다른 두 현상을 하나의 문제로 묶어 해석한 채, 그 혼란을 논리만으로 풀어보려 했습니다. 하지만 정확하고 완전한 집단 데이터를 확보하자 문제의 구조가 분명하게 드러났습니다. 한쪽 크래시는 결함이 있는 호스트에서 비롯된 것이었고, 다른 한쪽은 libunwind의 경쟁 상태에서 발생한 것이었습니다. 데이터의 품질이 높아질수록 디버깅도 훨씬 쉬워졌습니다.
Rockset과 같은 인프라 시스템에서는 이러한 점이 특히 중요합니다. 이번 조사를 통해 팀은 더욱 정교한 계측 체계와 자동화된 조사 프로세스, 그리고 운영 도구를 지속적으로 개선해 나가는 것이 얼마나 중요한지 다시 한번 확인했습니다. 신뢰성은 문제가 발생한 뒤 버그를 수정하는 것만으로 확보되지 않습니다. 겉으로는 해결이 불가능해 보이는 문제도 진단하고 해결할 수 있도록, 이를 뒷받침하는 데이터와 워크플로, 그리고 역량을 갖추는 것이 중요합니다.
작성자
By Nathan Bronson 및 Member of Technical Staff


