openai-news

코어 덤프 역학조사: 18년 묵은 버그 수정

요약

OpenAI 팀이 C++ 서비스에서 발생한 이해하기 어려운 크래시들을 역학조사처럼 분석해서, 18년 된 오픈소스 라이브러리 버그와 하드웨어 오류라는 두 가지 원인을 밝혀내고 해결한 이야기예요.

인사이트

  • 복잡한 시스템에서 발생하는 설명 불가능한 버그는 종종 여러 개의 독립적인 문제들이 하나처럼 보이는 경우가 많으며, 이를 해결하려면 '역학조사'처럼 고품질의 전체 데이터셋을 구축하고 패턴을 분석하는 것이 중요해요.
  • 아주 짧은 시간(단일 명령어) 동안 발생하는 레이스 컨디션도 특정 운영 환경(높은 예외 발생률, 빈번한 시그널 전달, 스택 사용량 증가)이 겹치면 치명적인 버그로 발현될 수 있으며, 이는 수십 년 된 오픈소스 라이브러리에서도 발견될 수 있어요.
  • C++의 저수준 제어 능력은 성능을 극대화하지만 메모리 안전성 부족으로 인해 디버깅이 어려운 크래시를 유발할 수 있으므로, 심층적인 계측과 자동화된 분석 툴이 대규모 인프라의 안정성을 확보하는 데 필수적이에요.

왜 중요한가

이 사례는 C++ 기반의 고성능 대규모 인프라에서 발생하는 난해한 버그를 해결하기 위해 데이터 기반의 체계적인 접근 방식(역학조사)이 얼마나 중요한지 잘 보여줘요. 특히 하드웨어 결함부터 18년 묵은 오픈소스 라이브러리 버그까지, 다양한 원인이 복합적으로 작용하는 문제를 풀어내는 과정에서 '데이터'와 '자동화된 분석'이 핵심적인 역할을 했음을 강조하고 있어요. 이는 모든 개발 팀이 시스템의 신뢰성을 높이기 위해 데이터 수집, 분석 역량을 강화해야 한다는 교훈을 줍니다.

코어 덤프 역학조사: 18년 묵은 버그를 드디어 잡았어요!

OpenAI의 모델과 에이전트들은 이제 추론 시, 즉 모델이 여러분의 질문에 대해 "생각"할 때 필요한 데이터를 찾기 위해 확장 가능한 데이터 인프라에 점점 더 많이 의존하고 있어요. 이런 서비스 중 일부는 C++로 작성되었는데요. C++가 시스템을 저수준에서 제어할 수 있게 해줘서 성능을 최대한으로 끌어올리고 메모리 사용량을 최소화할 수 있거든요. 이런 효율성 덕분에 시스템을 확장할 때 유리하지만, C++는 메모리 안전성이 부족해서 잘못되거나 존재하지 않는 메모리 주소에 쓰는 버그 때문에 크래시가 발생할 수 있어요.

몇 달 전, 우리 팀은 챗GPT 데이터 인프라의 핵심 부분이자 여러 데이터 플러그인과 대화 검색에 중요한 역할을 하는 'Rockset' 서비스 내부에서 몇몇 크래시를 발견했어요. 이 크래시들은 공통적으로 일반적인 C++ 함수가 실행을 마친 뒤 엉뚱한 주소로 반환하려 해서 발생했는데요. 그러면 커널은 프로그램의 명령어 포인터가 더 이상 코드를 가리키지 않기 때문에 프로그램을 중지시켰죠. 스택 프레임의 반환 주소 슬롯이 NULL인 경우도 있었고, %rsp CPU 레지스터 자체가 8바이트 정도 어긋나 보이는 경우도 있었어요. 마치 일반적인 실행 도중에 %rsp가 알 수 없는 이유로 감소한 것 같았죠. 두 경우 모두 반환 시점에서 크래시가 발생했어요.

이런 종류의 오류 모드는 애플리케이션 코드에서는 흔히 볼 수 있는 게 아니에요. 엉뚱한 곳에 쓰는 작업이 우연히 저장된 반환 주소에만 떨어지는 건 가능하지만, 그럴 가능성은 극히 낮죠. 인라인 어셈블리, setcontext, longjmp (우리 팀은 이 중 어떤 것도 사용하지 않아요) 같은 걸 사용하지 않았는데 %rsp를 8바이트만큼 잘못 정렬시키는 버그는 훨씬 더 이상했어요. 컴파일된 코드는 함수 프롤로그(시작 부분)와 에필로그(끝 부분)에서만 해당 레지스터를 직접 조작하거든요. 우리 팀(또는 챗GPT)이 생각해낼 수 있는 모든 가설은 강력한 반대 증거에 부딪혔고, 결국 이 버그는 도저히 해결 불가능한 문제처럼 느껴졌어요.

처음엔 하나의 문제라고 생각했던 것이 결국 두 가지의 전혀 상관없는 버그로 밝혀졌는데, 우연히 같은 시기에 발견된 거예요. 첫 번째는 한 Azure 호스트에서 발생한 조용한 하드웨어 손상이었어요. CPU가 연산을 제대로 수행하지 못하는 문제였죠. 두 번째는 18년 묵은 GNU libunwind의 레이스 컨디션이었어요. 널리 사용되는 오픈소스 라이브러리에 숨어있던, 아무도 알아차리지 못했던 버그였죠.

이 글에서는 어떻게 OpenAI 팀이 역학조사관처럼 생각하고 전체 크래시 데이터를 고품질로 구축해서, 설명 불가능해 보이던 크래시들을 찾아내고 해결했는지 그 이야기를 들려드릴게요.

첫 번째 디버깅 시도: 몇몇 코어 덤프를 꼼꼼히 조사했어요

먼저 Rockset에 대해 좀 더 자세히 알아볼까요? Rockset은 OpenAI 내부의 여러 사용 사례, 예를 들어 동기화 커넥터 등에서 사용되는 검색 및 실시간 분석을 위한 클라우드 네이티브 데이터 시스템이에요 (Rockset은 2024년 OpenAI에 인수되었어요). 스트리밍 업데이트를 사용해서 작업 공간의 지식 베이스를 최신 상태로 유지하고, 챗GPT가 질문에 답하거나 작업을 수행할 때 관련 정보를 검색할 수 있도록 해줘요.

Rockset의 실행 계층은 C++로 작성되어 있어요. C++ 언어는 CPU에 저수준으로 접근할 수 있게 해주는데, 이는 성능과 효율성 면에서 좋지만, 애플리케이션 버그가 잘못된 메모리 접근과 세그멘테이션 폴트(segfault)로 이어질 수 있다는 의미이기도 해요. 이런 문제들을 추적하기 위해 우리 팀은 크래시 발생 시 스택 트레이스를 로깅하도록 folly의 치명적인 시그널 핸들러를 사용하고, 해당 코어 덤프(프로그램이 크래시 났을 때의 상태 스냅샷)를 나중에 분석하기 위해 Azure 블롭 스토리지에 업로드해요. Rockset의 모든 쿼리 처리 리프(leaf)는 복제되어 있어서, 크래시가 발생해도 클라이언트에 미치는 영향은 최소화돼요. 하지만 각 세그멘테이션 폴트는 신뢰성과 품질 목표를 달성하기 위해 반드시 고쳐야 할 버그를 의미하죠.

처음에는 이 코어 덤프들을 전통적인 디버깅 문제처럼 다뤘어요. 몇몇 코어 덤프를 아주 자세히 살펴보고, 가설을 세운 다음 하나씩 제외하는 방식이었죠.

대부분의 크래시는 DocumentTree::updateDocument라는 메서드에서 발생했어요. 이 크래시들을 보면 updateDocument가 어떤 알 수 없는 함수 X를 호출했고, X가 활성화되어 있는 동안 스택이 손상된 다음, X가 실행 가능한 코드가 아닌 주소로 반환한 것처럼 보였어요. 어떤 경우에는 X가 방금 팝된(popped) 프레임이 유효해 보였지만, 저장된 반환 주소가 NULL이었어요. 또 다른 경우에는 스택 포인터 자체가 잘못되어 보였지만, 다음 유효한 프레임은 여전히 updateDocument인 것처럼 보였죠.

스택이 언제 손상되는지 알 수 없었기 때문에 탐색 공간이 엄청나게 넓었어요. updateDocument는 많은 인라이닝(inlining)이 일어나는 큰 메서드라, X가 될 수 있는 후보가 너무 많았어요.

이게 우리 C++ 코드의 버그일까요? 컴파일러나 링커 문제일까요? 런타임 라이브러리 중 하나에 문제가 있는 걸까요? 시그널 전달이나 컨텍스트 스위칭과 관련된 리눅스 커널 버그일까요? 아니면 훨씬 더 드문 문제일까요? 만약 이게 엉뚱한 곳에 쓰는 문제였다면, ASAN 스테이징 환경에서 왜 잡히지 않았을까요?

우리 팀은 애플리케이션 레벨 로그를 사용해 문제의 모든 발생을 식별하려고 했지만, 스택 손상 버그는 로그만으로는 분류하기 어려웠어요. 로깅된 스택 트레이스 자체가 손상되거나 누락되는 경우가 많았거든요. 오탐(false positives)과 미탐(false negatives)이 모두 없는 로그 쿼리를 구성할 수 없었죠. 더 많은 코어 덤프를 수동으로 검사해서 몇 가지 추가 사례를 찾았지만, 그 과정은 너무 노동 집약적이어서 신뢰할 수 있는 데이터 세트를 얻기 어려웠어요.

이 조사 단계에서 우리 팀은 (잘못된 판단이었지만) 하드웨어 버그는 아니라고 판단했어요. 여러 지역과 다양한 하드웨어 유형에서 크래시가 발생했기 때문에, 소프트웨어 문제만 찾아보고 있었죠. 며칠 동안 %rsp 정렬 오류 크래시 하나를 아주 깊게 파고들면서, 스택과 레지스터 내용을 바탕으로 크래시 발생 전 이력을 재구성했어요. 이걸로 몇 가지 단서를 얻었지만, 모든 버그가 같은 원인이라고 처음 결론 내린 것을 놓지 못해서 막힌 상태를 벗어나지 못했어요.

스택에서 얻은 단서

조사의 전환점에 도달하기 전에, 코어 파일에서 어떤 정보를 추출하고 있었는지 먼저 설명하는 게 중요해요.

Rockset은 -fno-omit-frame-pointer 옵션으로 컴파일되기 때문에, 활성 스택 프레임은 항상 %rbp를 통해 접근할 수 있고, 호출자(caller)들은 프레임 포인터의 연결 리스트를 형성해요.

리눅스 x86_64에서는 AMD64 System V ABI가 %rsp 아래 128바이트를 레드 존(red zone)으로 예약해 둬요. 이 영역은 사용자 공간 코드에서 사용할 수 있고, 중요한 점은 커널이 시그널을 전달할 때 이 영역을 훼손하지 않겠다고 ABI 계약의 일부로 약속한다는 거예요.

레드 존은 반환 후 크래시를 디버깅하는 데 핵심적인 역할을 했어요. 반환 전에 일부 정보를 보존해주기 때문이죠. SIGSEGV가 트리거되면 folly의 치명적인 시그널 핸들러가 크래시가 발생한 스레드의 스택에서 실행돼요. 더 이상 활성 상태가 아닌 스택 프레임(함수가 이미 반환되었기 때문에)은 시그널 핸들러에 의해 덮어씌워지지만, 마지막 128바이트는 예외예요. 이것이 우리가 "X가 방금 팝된 스택 프레임은 유효했지만, NULL 반환 주소를 가지고 있었다"와 같이 말할 수 있는 이유죠. 레드 존이 비활성 프레임의 일부 또는 때로는 비활성 프레임의 꼬리 부분을 보존해 주거든요.

우리 팀은 관련된 모든 함수가 매우 작았던 스택 정렬 오류 크래시 하나를 발견했어요. 덕분에 비교적 단순한 함수 실행 중에 %rsp가 잘못 정렬되었고, 그 이후에도 몇 번의 호출이 성공했음을 알 수 있었어요. 프로그램은 활성화된 함수가 마침내 반환하려고 할 때만 크래시가 발생했어요. 이 코드 경로 중 어떤 것도 예외, 인라인 어셈블리, setcontext, longjmp를 사용하지 않았기 때문에, 코어 덤프가 보여주는 대로 스택 포인터가 실제로 변경되었다면 사용자 공간 코드의 어떤 그럴듯한 버그로도 이 문제를 설명할 수 없었어요.

이것은 우리 팀을 커널 쪽으로 이끌었죠.

Rockset은 대부분의 프로그램보다 시그널을 더 적극적으로 사용해요. 쿼리 실행은 데이터를 교환하는 많은 경량 태스크로 분할되어 있어요. 이는 높은 QPS(초당 쿼리 수) 워크로드를 효율적으로 처리하는 데 중요하지만, 여러 쿼리에 대한 작업이 동일한 스레드 풀에서 멀티플렉싱되기 때문에 쿼리당 CPU 계정이 어색해져요.

우리 팀의 해결책은 coarse_thread_cputime_clock이라고 부르는 건데요. 이건 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)를 각 태스크 경계마다 충분히 저렴하게 샘플링할 수 있도록 근사치를 계산해줘요. timer_create API는 CPU 시간 누적을 포함한 여러 시간 경과 개념을 기반으로 주기적인 시그널 전달을 예약하는 데 사용될 수 있어요. 우리 팀은 몇 밀리초의 CPU 시간마다 시그널(SIGUSR2)이 전달되도록 예약하고, 이 시점에서 시그널 핸들러가 스레드 로컬 값을 업데이트해요. 많은 태스크가 실행되는 동안 coarse clock이 진행되는 것을 보지 못하더라도, 모든 델타를 합산하면 쿼리에 대한 실제 CPU 시간의 편향 없는 추정치를 얻을 수 있어요.

우리 팀은 시그널을 매우 자주 전달하기 때문에, 컨텍스트 스위칭이나 시그널 전달과 관련된 드문 커널 버그가 있을 수 있다고 생각했어요. 버그 보고서, 커널 소스 코드, Azure 관련 커널 패치를 읽는 데 시간을 보냈죠. 스트레스 테스트도 시도했어요. 하지만 관련이 있어 보이는 건 아무것도 찾을 수 없었어요.

그 시점에서 우리 팀은 한 발 물러서서 다른 접근 방식을 시도하기로 결정했어요.

의사 vs. 역학조사관?

이런 종류의 문제를 디버깅하는 데는 크게 두 가지 방법이 있어요.

하나는 일종의 의사처럼 행동하는 거예요. 한 환자에게 집중해서 많은 검사를 실행하고, 상세한 증거를 바탕으로 단일 사례를 진단하려고 하는 거죠.

다른 하나는 역학조사관처럼 행동하는 거예요. 전체 발생군을 살펴보고, 단일 사례로는 드러나지 않는 패턴이 있는지 질문하는 거죠. 버그가 특정 릴리스부터 시작되었나? 특정 하드웨어 SKU(특정 CPU 및 서버 모델), 특정 지역, 또는 특정 커널 버전과 상관관계가 있나? 하나의 증후군처럼 보이는 안에 여러 개의 서로 다른 군집이 숨어있나?

우리 팀은 대부분 의사 모드에 있었어요. 결정적인 전환점은 고품질의 '전체 발생 데이터'를 수집해야 한다고 판단한 것이었어요.

데이터 정제

이전에 문제를 자동으로 찾으려고 했던 시도들은 로그에서 텍스트 검색을 사용했기 때문에 실패했어요. 코어 덤프 자체에는 훨씬 더 많은 정보가 있지만, 수동으로 살펴보는 것은 확장성이 없었죠. 우리 팀은 코어 덤프를 자동으로 분석할 수 있는 파이프라인을 구축하는 데 노력을 기울이기로 했어요.

우리 팀은 챗GPT에게 각 코어 파일의 프리픽스(앞부분)를 다운로드하고, 레지스터를 추출하고, 로그를 사용해 알려진 오탐(false positives)을 필터링하고, 크래시를 'return-to-null', 'misaligned-stack' 또는 '기타'로 자동 분류하는 스크립트를 작성하게 했어요. 그리고 지난 1년 동안의 모든 프로덕션 Rockset 코어 덤프에 대해 이 스크립트를 병렬로 실행했어요.

이게 바로 결정적인 전환점이었어요.

깨끗한 데이터 세트를 얻자마자, 상관관계가 즉시 나타났어요. 우리 팀이 하나의 이상한 버그로 취급했던 것이 실제로는 두 가지 별개의 크래시 집단이었던 거죠.

'return-to-null' 코어 덤프는 여러 클러스터와 지리적 지역에 걸쳐 퍼져 있었어요. 최근 빈도가 증가했지만, 명확한 시작 날짜나 깔끔한 인프라 경계는 없었어요.

'misaligned-stack' 크래시들은 완전히 다르게 보였어요. 모두 한 지역에서 발생했고, 명확한 시작 날짜가 있었으며, 오랫동안 실행된 노드에서는 전혀 발생하지 않았어요. 여러 Azure VM(클라우드 호스팅 가상 머신)이 관련되어 있었지만, 그 패턴은 마치 나쁜 하드웨어를 가진 하나의 물리 머신이 그 위에 착륙한 VM들에게 문제를 일으키는 것 같았어요.

그 순간 우리 팀은 두 버그를 정신적으로 혼동하고 있었다는 것을 깨달았어요. 두 버그에서 나온 반례들을 뒤섞어 놓고 있었기 때문에, 단일하고 일관된 설명을 찾을 수 없었던 거죠.

버그 #1: 불량 호스트

이제 깨끗한 쿠버네티스 노드 목록과 타임스탬프를 손에 쥐었으니, '스택 정렬 오류' 크래시들을 단일 물리 호스트로 추적할 수 있었고, 해당 호스트를 차단하는 건 어렵지 않았어요.

우리 팀은 몇 주간의 스트레스 테스트 후에도, 통제된 환경에서 그 호스트에서 레지스터 손상을 재현할 수 없었어요. 하지만 문제가 되는 호스트가 서비스에서 제외되자, '스택 정렬 오류' 크래시는 사라졌어요.

문제가 있는 호스트를 제거하는 것이 영구적인 해결책은 아니었어요. 똑같은 문제가 다시 발생하는 걸 막아주지는 못하니까요. 하지만 소프트웨어를 변경해서 유사한 문제가 재발하더라도 쉽게 감지하고 처리할 수 있게 할 수 있어요. 우리 팀은 치명적인 시그널 핸들러를 개선해서 레지스터 상태를 포함하도록 했고, 이를 통해 로그만으로도 재발을 감지할 수 있게 되었어요(코어 덤프가 필요 없게 된 거죠). 컨트롤 플레인도 변경해서 VM이 재활용되는 대신 주로 재사용되도록 만들었고, 이는 인프라 스택 레벨에서 불량 노드 감지를 훨씬 쉽게 해줬어요. 우리 팀은 런북(운영 가이드)과 팀원들의 사고방식도 이런 가능성을 포함하도록 업데이트했어요.

불량 호스트 크래시를 분리하고 나니, 남아있는 'return-to-null' 코어 덤프를 훨씬 더 쉽게 분석할 수 있었어요. 이전에는 예외 언와인딩(exception unwinding)을 배제했었는데, 예외가 분명히 사용되지 않는 코드 경로에서 크래시가 발생한 반례들이 있다고 생각했기 때문이죠. 하지만 그 반례들은 모두 하드웨어 손상 클러스터에서 나온 것들이었어요.

그 점을 염두에 두고 남아있는 코어 덤프들을 다시 살펴보니, 이 결론이 완전히 뒤바뀌었다는 것을 알게 됐어요. 크래시들은 모두 예외 언와인딩 중에 발생하고 있었어요.

예외 처리는 동적 제어 전송이에요

C++가 예외를 던지면, 런타임은 어떤 catch 블록이 예외를 받아야 할지, 그리고 그 과정에서 어떤 소멸자(destructor)나 정리 핸들러(cleanup handler)가 실행되어야 하는지 알아내야 해요. 컴파일러가 이 메타데이터를 내보내지만, 실제 매칭은 런타임에 동적으로 발생해요.

예외 언와인딩은 실제로 throw를 호출하는 함수가 수행하는 것이 아니라, 컴파일된 코드에 의해 호출되는 헬퍼 함수들이 수행해요. 이 런타임 루틴들은 스택을 검사하고, 스택에서 발견된 함수들에 대한 메타데이터를 가져오고, 정리 핸들러와 catch 블록을 동적으로 찾은 다음, 이 위치 중 하나로 제어를 전송해요. 제어 전송에는 중간의 모든 스택 프레임(헬퍼 함수의 프레임 포함)을 언와인딩하는 것이 포함돼요.

운영적으로 보면, 이는 일반적인 호출 및 반환보다 longjmp나 파이버(fiber) 스위칭에 훨씬 더 가까워요. 호출 수신자(callee)가 저장한 레지스터뿐만 아니라 스택 프레임 레지스터인 %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에 의해 덮어씌워질 가능성이 생기는 거예요.

버그 #2: libunwind 버그

정답은 '예'였어요.

우리 팀이 사용하던 GNU libunwind 버전의 _Ux86_64_setcontext 함수 마지막 6개 명령어는 다음과 같아요. 대부분 메모리에서 대상 레지스터로 로드하는 mov 명령어로 구성되어 있죠.

mov %rbx, UC_MCONTEXT_GREGS_RBX(%rdi)
mov %rbp, UC_MCONTEXT_GREGS_RBP(%rdi)
mov %r12, UC_MCONTEXT_GREGS_R12(%rdi)
mov %r13, UC_MCONTEXT_GREGS_R13(%rdi)
mov %r14, UC_MCONTEXT_GREGS_R14(%rdi)
mov %r15, UC_MCONTEXT_GREGS_R15(%rdi)

(여기서 %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)를 읽기 위해 해당 레지스터를 사용할 수 없어요. 대신, 값을 더 일찍 읽어서 스택에 저장하고, 몇몇 레지스터를 더 복원한 다음, retq를 사용해 저장된 값을 읽고 제어를 전송하죠.

코어 덤프에서 "함수가 NULL로 반환했다"고 보였던 건 사실 "언와인더가 스택에 목표 반환 주소를 만들었지만, 제어가 전환되기 전에 그 목표 주소가 손상된 것"이었어요. 우리 팀은 반환 주소 슬롯의 손상이 제자리에서 발생해야 한다고 가정했었죠. (손상될 수 있는) 데이터가 의도적으로 반환 주소 슬롯에 기록되는 경우를 알지 못했기 때문이에요.

단일 명령어 레이스 윈도우

이 버그가 황당하게 느껴지는 이유는 레이스 윈도우가 얼마나 좁은지에 있어요. 이런 종류의 레이스 컨디션에서는 외부 이벤트(시그널)가 다른 스레드에서 취하는 두 단계 사이에 정확히 발생해야 해요. 그 두 단계가 서로 가까울수록 레이스 컨디션이 발생할 가능성은 낮아지죠.

이 경우 취약한 윈도우는 말 그대로 하나의 명령어 폭이에요! %rsp가 변경된 후, 하지만 다음 명령어가 %rip를 로드하기 전에 시그널이 전달되어야 하죠. 현대의 슈퍼스칼라(super-scalar) 비순차(out-of-order) CPU에서는 이런 간단한 명령어가 사이클당 여러 개 실행될 수 있으므로, 레이스 윈도우는 대략 백 피코초(picoseconds) 정도예요.

이 레이스 컨디션을 발견했을 때, 우리 팀의 첫 반응은 "관찰된 크래시율을 설명하기에는 너무 드문 일이야"였어요. 전체 시스템에서 하루에 십여 건 이상의 'return-to-null' 크래시가 발생하고 있었거든요. 예외 정리(exception cleanup) 도중에 발생하는 한 명령어짜리 레이스가 정말 그걸 설명할 수 있을까요?

우리 팀은 페르마 추정(Fermat estimation)에 의존했어요. 취약한 윈도우가 $10^{-10}$초 정도이고, SIGUSR2가 CPU 시간 0.01초($10^{-2}$초)마다 도착한다면, 각 예외 정리 핸들러 또는 catch 블록은 레이스에서 질 확률이 대략 $10^{-8}$ 정도가 돼요.

Rockset은 내부 수집 백프레셔(backpressure) 메커니즘의 일부로 예외를 사용해요. 과부하된 단일 호스트가 초당 $10^4$개 정도의 예외를 발생시킬 수 있어요. 이는 백프레셔를 사용하는 호스트의 평균 장애 발생 간격(MTBF)이 $10^4$초, 즉 몇 시간마다 한 번씩 크래시가 발생한다는 의미예요. 전체 서비스 규모로 보면, 이건 관찰된 크래시 빈도를 설명하기에 충분하고도 남는 수치예요.

libunwind 버그는 왜 이제 와서 나타난 걸까요?

GNU libunwind 버그는 꽤 오래되었어요. 18년도 더 되었죠. C++ 예외 언와인딩을 지원했던 첫 x86_64 버전부터 존재했어요.

그럼 이 버그가 왜 이제 와서 나타난 걸까요?

크래시율은 얼마나 많은 예외가 발생하고 얼마나 많은 시그널이 전달되는지에 대략적으로 비례해요. 또한 시그널 핸들러가 얼마나 많은 스택을 사용하는지에 따라 달라지기도 하죠.

Rockset은 이 세 가지 측면 모두에서 특별한 경우에 해당했어요. 우리 팀은 정상적인 과부하 제어의 일부로 높은 비율의 예외를 발생시켜요. coarse_thread_cputime_clock 때문에 SIGUSR2를 비정상적으로 자주 전달하고요. 그리고 올해 초에 SIGUSR2 핸들러가 timer_getoverrun 호출을 추가하면서 더 많은 스택을 사용하게 만들었거든요. 이걸로 병합된 시그널도 계산할 수 있게 됐죠.

마지막 변경 사항이 중요했던 것 같아요. 만약 핸들러가 스택을 충분히 적게 사용했다면, 오래된 ucontext_t 메모리에 도달하거나 덮어쓰지 않았을 수도 있었을 거예요. 그 변경 전에는 이런 크래시가 전혀 관찰되지 않았어요. 변경 후에는 부하가 백프레셔 메커니즘을 압박하는 일부 사용 사례로 늘어나기 전까지는 낮은 비율을 유지했죠.

다시 말해, libunwind 버그는 항상 거기에 있었지만, 예외율, 시그널 전달율, 핸들러 스택 사용량의 곱이 최근에야 운영상 눈에 띄는 임계값을 넘어선 거예요.

이 메커니즘은 하드웨어 버그와 libunwind 버그 모두 DocumentTree::updateDocument 내부에서 주로 크래시를 일으켰다는 우연의 일치도 설명해줘요. libunwind로 인한 크래시들은 이 메서드에 집중되어 있었는데, 수집 백프레셔를 적용하기 위해 예외를 던지는 시점에 항상 활성화되어 있기 때문이에요. 또한 해당 불량 하드웨어 노드가 대량 수집에 사용되는 SKU에 속했기 때문에 %rsp 정렬 오류 크래시에서도 이 메서드가 많이 선택되었는데, 해당 SKU는 CPU 시간의 대부분을 이 메서드에서 소비하거든요.

가장 먼저 취한 완화 조치는 GNU libunwind에서 libgcc의 언와인더로 전환하는 것이었어요. 이건 그 자체로 좋은 선택이었죠. libgcc의 구현은 잠금 경합(lock contention)을 줄이기 위한 많은 작업의 혜택을 받아서, 대규모 VM으로 확장할 때 중요한 요소였거든요.

우리 팀은 또한 독립적인 재현 코드와 수정 사항(새 창에서 열림)을 GNU libunwind에 업스트림했고, 다른 언와인더들에는 유사한 문제가 없는지 확인했어요.

전체 집단 진단의 힘

이 디버깅 여정은 동적 링킹, DWARF 언와인드 메타데이터, 리눅스 시그널 전달, System V ABI, C++ 예외 처리 메커니즘의 세부 사항에 대해 많은 것을 가르쳐 주었어요. 하지만 가장 중요한 교훈은 이 모든 것보다 훨씬 단순했어요.

가장 중요한 단계는 영리한 어셈블리 코드 읽기나 세부 사항에 대한 깊은 지식이 아니었어요. 바로 고품질 데이터 세트를 구축하는 것이었죠. 이 데이터 세트가 없었을 때, 우리 팀은 두 가지 서로 다른 현상을 하나의 이야기로 섞어 혼란 속에서 헤매고 있었어요. 정확하고 완벽한 전체 데이터가 생기자, 문제의 구조가 명확해졌어요. 한 크래시 집단은 불량 호스트 때문이었고, 다른 하나는 libunwind의 레이스 컨디션 때문이었던 거죠. 데이터가 좋아지자 디버깅은 더 쉬워졌어요.

Rockset과 같은 인프라 시스템에서는 이것이 매우 중요해요. 이 조사는 심층적인 계측, 자동화된 조사, 그리고 운영 툴링의 지속적인 개선에 대한 우리 팀의 약속을 다시 한번 강화했어요. 신뢰성은 단순히 버그가 발생한 후에 고치는 것에만 국한되지 않아요. 불가능해 보이는 문제들을 진단하고 해결 가능한 문제로 바꾸는 데이터, 워크플로, 그리고 기술을 구축하는 것이 바로 신뢰성이에요.

openai-news · 원문 보기 · 2026-06-30

이 글은 원문을 한국어로 번역한 것입니다. 저작권은 원 저작자에게 있습니다.