2P by GN⁺ 5일전 | ★ favorite | 댓글 1개
  • Cloudflare는 arm64 플랫폼에서 동작하는 Go 컴파일러에서 발생하는 드문 경쟁 조건(레이스 컨디션) 버그를 대규모 트래픽 감시 중에 발견함
  • 이 버그는 스택 언와인딩(stack unwinding) 과정에서 서비스가 예기치 않게 패닉 상태가 되거나 메모리 접근 오류가 발생하는 방식으로 나타남
  • 원인 추적 과정에서 Go 런타임의 비동기 preemption(강제 선점)컴파일러가 생성한 두 개의 스택 포인터 조정 명령어 사이에서 문제가 발생함을 확인함
  • 최소 재현 코드를 통해 이 버그가 Go 런타임 자체 문제임을 입증하였고, 이로 인해 스택 포인터가 불완전하게 변경되는 한 명령어 크기의 경쟁 상태가 존재함을 밝혀냄
  • 해당 이슈는 go1.23.12, go1.24.6, go1.25.0 버전에서 패치되었고, 새 방식에서는 즉시 변경이 불가능한 스택 포인터 조작을 회피하여 레이스 컨디션이 근본적으로 차단됨

Cloudflare에서 찾은 Go ARM64 컴파일러 버그 분석

Cloudflare의 데이터센터는 전 세계 330여 도시에서 매초 8,400만 건의 HTTP 요청을 처리하는데, 이와 같은 대규모 트래픽 환경은 희귀한 버그조차 자주 노출되는 특징이 있음. 이 글은 arm64 플랫폼의 Go 컴파일러가 생성한 코드에서 발생한 경쟁 조건 문제를 실제 사례와 함께 자세히 분석하고 있음.

이상한 패닉 현상 조사

  • Cloudflare 네트워크 내에서는 Magic TransitMagic WAN 같은 상품 트래픽 처리를 Kernel에 설정하는 서비스가 동작 중임
  • arm64 머신에서 드물지만 반복적으로 fatal panic(치명적 패닉) 메시지가 모니터링 시스템에 감지됨
  • 초기 분석 결과, 스택 언와인딩 과정에서 무결성 위반이 감지됨(panic/recover 패턴을 사용하던 오래된 코드에서 패닉이 빈번하게 발생)
  • 일시적으로 panic/recover 구조를 제거하여 패닉 빈도를 줄였으나, 추후 의심스러운 치명적 패닉이 더 자주 발생하게 됨
  • 이에 따라 단순 패턴 추적 이상의 심층적인 원인 분석이 필요하다고 판단함

Go 런타임 및 스케줄러 자료구조 개요

  • Go는 경량 사용자 공간 스케줄러로 M:N 스케줄링 구조를 채택하고 있음(여러 고루틴을 소수 커널 스레드에 매핑)
  • 스케줄러의 핵심 구조체는 g(고루틴), m(머신/커널 스레드), p(프로세서) 중심으로 이뤄짐
  • 스택 언와인딩 실패나 메모리 접근 오류는 스택 포인터 혹은 리턴 주소가 비정상적으로 변화했을 때 발생함

스택 언와인딩 중 오류의 구조적 원인

  • 여러 백트레이스 분석 결과, 모두 (*unwinder).next 함수의 스택 언와인딩 과정에서 발생함
  • 한 경우는 return address가 null이라 비정상 스택으로 인식하여 치명적 오류로 종료, 다른 경우는 스택 프레임 내 go 스케줄러 구조체 m의 필드(incgo)에 접근하다 세그멘테이션 오류 발생
  • 크래시가 실제 버그 발생 지점에서 상당히 떨어진 위치에서 발생, 원인 추적이 까다로움

관찰된 패턴과 Go Netlink 라이브러리 연관성

  • Stack trace를 검토한 결과, 모두 Go Netlink 라이브러리NetlinkSocket.Receive 함수에서 preemption이 발생한 시점에 크래시가 집중적으로 발생함을 확인
  • 이후 두 가지 가설을 세움
    • Go Netlink의 unsafe.Pointer 사용에서 기인한 버그일 가능성
    • Go 런타임의 비동기 preemption 및 스택 언와인딩 자체에서 발생하는 버그일 가능성
  • 코드 감사를 진행했으나 직접적인 메모리 손상 패턴 등은 발견되지 않아, 문제의 핵심이 런타임과 스택 운용 전략에 있을 것으로 추정

비동기 Preemption과 경쟁 조건

  • Go 1.14부터 도입된 비동기 preemption 기능은 장시간 실행되는 고루틴에 대해 OS 스레드에 시그널(SIGURG)을 보내 강제로 스케줄링 포인트를 생성
  • 이 preemption이 스택 프레임 포인터를 조정하는 두 어셈블리 명령어 사이에서 발생하면, 스택 포인터가 중간 상태에 머무르게 됨
  • 가비지 컬렉션, 패닉 핸들링, 스택 트레이스 생성을 위해 스택을 언와인딩할 때 잘못된 위치를 읽어 잘못된 함수 주소나 데이터 해석이 일어남

최소 재현 코드 제작

  • 스택 프레임 할당 크기를 조절하고, 명시적으로 스택이 조정되는 함수(big_stack)와 상시 가비지 컬렉션 호출 코드를 작성하여 경쟁 조건이 재현됨
  • 실제로 어셈블리 코드에서 두 개의 ADD 명령어로 스택 포인터가 조정되고, 이 사이에서 비동기 preemption이 발생할 경우 스택 언와인딩 과정에서 크래시가 일어남
  • 이 결함은 순수 표준 라이브러리 코드만으로도 재현 가능하였으며, Go 컴파일러가 생성하는 코드에 내재된 횟수 단위(1 인스트럭션 크기)의 취약점임을 증명

ARM64 컴파일러 레벨 경쟁 윈도우의 원인

  • ARM64 아키텍처의 고정 길이 명령어 및 즉시값 제한 때문에 스택 포인터 조정에 두 개 이상의 명령어가 필요할 수 있음
  • Go의 내부 중간 표현(IR)에서는 이러한 즉시값 길이를 인지하지 않고, 실제 머신 코드 변환 시에만 분할 명령어가 삽입됨
  • 이 때문에 스택 프레임 반환(ADD RSP, RSP)에 두 개 명령어가 사용되고, preemption에 취약한 단일 인스트럭션 윈도우가 생김
  • 언와인더가 스택 포인터의 정확성을 절대적으로 필요로 하는데, 인스트럭션 중간에서 멈추면 잘못된 값 해석 및 치명적 실패 초래
  • 실제 크래시 플로우는 다음과 같이 구성:
    1. 두 ADD 명령어 사이에서 비동기 preemption 발생
    2. GC 또는 기타 원인으로 스택 언와인딩 루틴 동작
    3. 특이한 스택 포인터 위치 탐색 및 잘못된 함수 주소 해석
    4. 런타임 크래시

버그 수정 및 근본적 개선

  • Cloudflare 팀은 최소 재현 코드와 상세 분석 내용을 기반으로 Go 공식 저장소에 보고하였으며, 이슈는 신속하게 패치 및 릴리즈됨
  • go1.23.12, go1.24.6, go1.25.0 이후 버전에서는 임시 레지스터에 전체 오프셋을 먼저 계산한 후 단일 명령어로 스택 포인터를 변경, preemption 취약성을 제거
  • 이제 스택 포인터는 항상 유효한 상태로 보장되어, 경쟁 조건이 구조적으로 차단
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

결론 및 시사점

  • 이 버그는 특정 아키텍처의 컴파일러 코드 생성동시성 관리(비동기 preemption) 가 예상하지 못한 방식으로 충돌한 사례임
  • 대규모 환경에서만 발현되는 매우 희귀한 인스트럭션 레벨 경쟁 조건을 실전 데이터 및 과학적 추론으로 추적해낸 점이 매력적인 사례임
  • 최신 Go 환경 및 ARM64 아키텍처 기반 서비스를 운영한다면, 관련 Go 버전으로 업그레이드가 중요함
Hacker News 의견
  • 정말 대단한 발견임을 느꼈고, 어셈블리 코드를 보는 순간 디버깅 경로를 따라가게 됨, 사실 이 방식은 꼭 어셈블리에서만 가능한 게 아님, IR 단계에서도 가능할 수 있지만 여러 이유로 그렇지 않음, ARM 어셈블리를 읽을 수 있다는 점에서 큰 장점임, 명령 줄을 줄이기 위해 스택 크기를 push나 pop해보는 방식도 고려해봤는데 GC가 정확히 뭘 확인하는지 몰라서 확신하진 못함, 다른 의견을 듣고 싶음
    • 일반적으로는 “LDR Rd, =expr”라는 ARM의 의사명령어를 사용함, 바로 만들 수 없는 상수의 경우 PC-relative 위치에 상수를 두고 PC를 기준으로 레지스터로 로드함, 이를 통해 “SP에 상수 더하기” 과정을 2개의 실행 명령으로 바꿀 수 있고 8바이트 코드와 4바이트 데이터 영역(17비트 상수용)으로 총 12바이트가 필요함, 관련 문서: LDR pseudo-instruction 설명
    • 즉시값을 RSP에 더하는 특이 케이스로 어셈블러에서 이 버그가 특수 처리되지 않은 게 의외임, 패치가 컴파일러 쪽에만 적용됐다면 aarch64 어셈블리 다른 곳에도 같은 문제가 남아있을 수 있음
    • ARM 어셈블리 문법에 달러 기호가 들어간 이상한 표현은 AArch64 표준 어셈블리가 아니고, 글에서 “스택은 한 번만 이동해야 한다”는 규칙도 같이 언급했으면 좋았을 것 같음
    • Java나 .NET 같은 런타임에서는 safepoint를 명확하게 두어 명령어 집합 중간에 컨텍스트가 바뀌지 않도록 방지함
    • 컴파일러가 상수를 두 번에 나눠서 레지스터에 넣은 뒤 add 한 번으로 원자적으로 SP를 조정하는 게 맞는 해결책인 것 같음, 물론 명령어 하나가 늘어나지만 원자성이 확보됨, 아니면 임시 레지스터로 연산 후 다시 옮기는 방법도 있음
  • 급한 분들을 위해 수정 커밋 링크를 공유함: golang/go 커밋 링크
    • 이슈에서 살펴봤을 때, Go 팀이 자연어 봇을 쓰는 건지, 아니면 댓글에서 단순히 “backport”라는 키워드만 체크하는 건지 궁금증이 생김, 관련 코멘트: github issue comment
  • 기술적으로 아주 훌륭한 블로그로, 설명이 워낙 명확해 이해하기 쉬워 오히려 더 똑똑해진 느낌임, x86 어셈블리 이후 오랜만에 어셈블리를 접했음에도 따라가기 쉬웠음, 그리고 이런 팀이라면 언제든 이런 이슈를 해결할 능력과 품질 관리가 있다는 신뢰까지 생김, 서버 확장을 위해 Ampere Altra도 고려했었는데 공간이 넉넉해 결국 Epyc을 사용함
  • Go에서 모든 명령을 싱글스텝하며 명령마다 GC 인터럽트를 발생하도록 하는 모드가 있다면, 이런 버그를 더 쉽게 찾을 수 있을 거라 생각함
  • ARM64 서버를 어디에 사용하고 있는지 궁금함, 작년에는 AMD EPYC 기반 Gen 12 서버를 출시한다고 했지만 ARM64 언급은 없었는데, 현재는 ARM64가 프로덕션에 쓰이고 있음
    • 나는 Cloudflare 직원은 아니지만 블로그를 많이 읽어서 아는 바, 보안 부팅 등을 고려했을 때 이미 몇 년 전부터 Ampere를 AMD와 병행 배치 중이었음, 운영 목적은 엣지 효율성 때문으로 보이나 다른 용도도 있을 수 있음, 더 많은 정보는 엣지 서버 설계 글Ampere Altra vs AWS Graviton2 그리고 퀄컴 ARM 평가 등에서 확인 가능함
    • Cloudflare가 일부 non-edge 컴퓨팅을 퍼블릭 클라우드에서 호스팅한다는 얘기가 기억남, 예를 들어 컨트롤 플레인 등, 그럴 수도 있음
  • Cloudflare가 요즘 100% Rust와 x86(EPYC)만 쓴다고 생각했었음, Go와 ARM 사용 중이라는 점이 흥미로움
  • 매번 클라우드플레어 블로그 글은 인프라나 ML 마법 없이 엔지니어링의 본질을 담아내는 멋진 콘텐츠라 생각함, 언젠가는 이곳에 지원해보고 싶음, 컴파일러 버그는 생각보다 흔한데(과거엔 gcc에서 매년 몇 개씩 찾음), 글에서처럼 대규모에서야 드러나는 희귀한 경우가 많음, 대부분은 그 정도 규모까지 들어가 보는 일이 없음
    • 오늘 지원하지 않는 이유가 궁금함
  • 스택 포인터는 항상 원자적으로 조정해야 함을 강조함
    • 프리엠션을 작성한 이들이 x86 기준(여기선 명령어가 상수를 담을 수 있어 원자적으로 이뤄짐)에서 코드를 만들었고, ARM 포팅 과정에서 고수준에서 자동 분할이 되어 이런 버그가 생긴 듯함, 누구 잘못은 아니지만 좋지 않은 결과임
    • 이 생각이 바로 떠올랐음
  • 기계 쓰레드가 어떻게 두 개의 명령 중간에 멈추게 됐는지 잘 이해가지 않음, 베어메탈에서 이런 일이 가능한지 의문임
    • go는 GC 노티를 위해 인터럽트를 사용함
    • 신호(signals)
  • “아주 재미있던 문제였다”는 글에 대해, 이런 근본적 문제를 해결한 건 분명 시원했을 테지만, 미해결 상황에서는 전혀 즐겁지 않았을 거라 생각함, 이런 버그는 모든 신경을 잡아먹는 경험임, 표준 라이브러리나 컴파일러가 문제일 거라 아무도 생각하지 않게 되어 개발자가 본인 코드만 계속 의심하는 문화가 있음, 나도 한 번 표준 라이브러리 버그를 찾은 적 있는데, SDK 쪽이 문제라는 건 가장 마지막에 의심하게 됨, 덕분에 엉뚱한 곳에서 시간 다 쓰게 되고, 게다가 이번 문제처럼 레이스 컨디션이면 재현도 어려워서 항상 사라진 줄 알았다가 다시 튀어나온다는 것임
    • 이 코멘트가 본인의 비슷한 경험담을 덧붙이면서도, 굳이 저자가 느끼는 즐거움에 대해 반박을 내세워 오히려 감동이 반감된 느낌임, 사람마다 다른 걸 재미로 느낄 수 있음
    • 어떤 사람들은 남들이 괴로워할 만한 아주 특이한 디버깅을 오히려 기쁘게 여김, 누군가에겐 좌절이 남에겐 즐거움이 됨
    • 아마 저자가 말하고 싶었던 건 “재미(funny)”가 아니라 “만족(satisfying)”이었다고 생각함, 나도 마감에 쫓기며 Ubuntu GCC ARM 툴체인의 sscanf 버그를 잡은 적 있는데, 그때는 즐겁진 않았지만, 문제를 정확하게 잡고 회귀 테스트까지 쓴 뒤에는 정말 만족스러웠음
    • 깊은 결함을 해결하는 게 풀렸을 땐 엄청난 해방감임, 나도 컴파일러나 CPU 쪽 버그를 해결할 때 가장 큰 즐거움을 느꼈던 적이 많음
    • 관리형 언어에서 Unsafe 류를 전혀 쓰지 않고도 세그폴트가 나면 내 코드 문제가 아닐 가능성이 높다는 신호로 삼는 편임