글또

DB 장애 분석: Galera Cluster Segfault

Gisungcu 2025. 1. 31. 20:06

들어가며

안녕하세요.
이번에는 DB장애 분석 글을 써보려합니다.
현재 저희 회사에서 가장 큰 이슈는 DB 장애 문제였고, 이를 해결해 보면 좋겠다고 생각했습니다. 
제가 문제를 해결하기 위해 시도한 방법과 과정을 써보고자 합니다.

 
글의 순서는 다음과 같습니다.

  • 문제 상황
  • 의심 사례 분석
    • 메모리 부족, 많은 스레드 부하: 부하 테스트 (Sysbench 활용)
    • 특정 시각의 배치 작업, 시나리오 테스트 (K6 활용)
  • 개선점
  • 마무리

 
 
 

문제 상황

저희 회사는 MariaDB를 EC2에서 구동하며 Galera Cluster를 사용하고 있습니다.
그중 일부 노드가 간헐적으로 다운되는 현상이 발생했습니다.
Cluster 내 3개 노드 중 일부가 장애를 일으켜도 동작은 가능했지만, Segmentation Fault(Segfault) 에러가 자주 발생했습니다.
이 문제를 해결하기 위해 장애 발생 시 로그를 조사했으나 명확한 원인을 찾지 못했습니다.
StackOverflow에 질문 글을 올리는 등 외부 도움을 구했지만 해결하지 못해 장애 복구 매뉴얼 작성에 의존하던 상황이었습니다.
연말에 프로젝트가 마무리된 이후, 문제 원인을 깊이 분석하기로 결정했습니다.

 
 
 

테스트 환경 구축

https://galeracluster.com/products/

 
Galera Cluster는 멀티 마스터 구조로, 모든 노드에서 Read/Write가 가능합니다.

문제 원인을 재현하기 위해 실 DB와 동일한 테스트 환경을 만들었습니다.
평시에는 실서버와 동일한 DB를 구축하는 것은 비용이 들었기 때문에
하나의 DB만 사용중이었습니다. 

EC2 인스턴스: t3.medium 3대 (2 vCPU, 4GB 메모리), 운영 체제: Debian 11
MariaDB 버전: 10.5.13
Galera Cluster: 4.10
모니터링: Grafana를 통해 CPU, 메모리, DB 상태를 실시간으로 모니터링

 
 
 
 

의심한 원인

메모리 부족

  • 장애 발생 시 CPU와 메모리 사용량이 급등했던 점을 확인했습니다.
  • 부하가 높을 시 메모리 누수 가능성을 의심하며 부하 테스트를 계획했습니다.

 
과도한 스레드 부하

  • 에러 메시지를 보고 판단했을 때 max thread 수가 비정상적으로 크게 설정되어있다는 것을 알게되었습니다. (65537)

 
특정 시각의 배치 작업

  • Galera Cluster에서 지원하지 않는 GET_LOCK과 RELEASE_LOCK을 사용하는 로직을 발견했습니다.

    위의 상황을 가정하여 몇가지 시나리오를 작성하였고 이를 토대로 테스트를 진행하였습니다.

 
 
 

의심 1. 메모리 부족, 많은 스레드 부하: 부하 테스트 (Sysbench 활용)

장애 시점의 CPU와 메모리 사용량이 피크를 기록했던 만큼, 서버의 한계점을 확인하고자 다양한 테스트를 진행했습니다. 

이를 위해 Sysbench를 활용하여 부하 테스트를 수행했습니다. 
여러 시나리오(예: oltp_read_only, oltp_write_only, oltp_update_non_index 등)를 통해 CPU를 100%까지 사용하게 만들고, 많은 커넥션을 사용하며 메모리 문제를 재현하려 했지만, 예상과 달리 서버는 정상적으로 동작했고 Segfault는 발생하지 않았습니다.

 
 

테스트 조건 환경 결과
oltp_read_only 단일, 다중 노드 10/10 성공
oltp_update_non_index 단일, 다중 노드 10/10 성공
oltp_read_write 단일 노드 10/10 성공
oltp_read_write 다중 노드 3/10 성공

이 과정에서 눈에 띈 점은 단일 노드에서는 장애가 재현되지 않았지만, 두 개 이상의 노드에서 동시에 write 부하를 줄 때만 Segfault가 발생한다는 것이었습니다.
 
 
 

코어 덤프 분석

장애 원인을 정확히 파악하기 위해 Coredump를 활성화하고 디버깅을 진행했습니다.
Prepared Statement에 쿼리 파라미터를 할당할 때 장애가 발생했습니다.
특정 값이 초기화되지 않은 상태로 남아있었고, 잘못된 메모리를 참조하면서 Segfault가 발생했다는 사실을 발견했습니다.

참조하는 str에 m_charset 변수는 0xa5 값을 가지고 있었는데, 이는 디버그 모드에서 쓰레기 값으로 간주됩니다. 이후 코드가 m_charset을 참조하는 과정에서 Segfault가 발생하며 장애가 유발된 것입니다.

 
 
 

동일한 장애 검색

이후 동일한 증상이 발생한 사례가 있는지 검색 해보았습니다.
그 과정에서, Galera Cluster와 Prepared Statement를 사용할 때 문자열 참조 오류로 인해 Segfault가 발생한다는 JIRA 이슈를 발견했습니다. 

이는 저희 장애와 매우 유사한 상황이었지만, 동일한 문제인지 확실히 확인하기 위해 직접 테스트를 진행하기로 했습니다.
먼저, Docker로 Cluster를 구성해 테스트를 진행했으며, 테스트 환경과 동일하게 맞추기 위해 소스 컴파일로 MariaDB를 구축하였습니다.

DB 버전 / Galera 버전 장애 여부
mariaDB 10.5.13 / Galera 4.10 장애 O
mariaDB 10.5.14 / Galera 4.10 장애 O
mariaDB 10.5.15 / Galera 4.10 장애 O
mariaDB 10.5.16 / Galera 4.10 장애 X

 
10.5.16 버전에서는 문제가 발생하지 않는 것을 확인한 후, 10.5.16과 이전 버전인 10.5.15 간의 소스코드 diff를 비교했습니다.
가장 의심이 가는 sql_prepare와 관련된 파일에서 수정된 커밋을 발견했으며, 해당 커밋의 주석 내용이 이전에 검색했던 JIRA 이슈와 동일한 문제를 해결하기 위한 수정임을 확인했습니다.

더욱 확실한 검증을 위해, 수정된 부분만 주석 처리하여 다시 데이터베이스를 빌드하고 테스트를 진행했습니다. 그 결과, 수정 후 문제가 없던 10.5.16 버전에서도 동일한 장애가 발생하는 것을 확인하였고, 이를 통해 해당 코드가 문제의 해결책임을 파악할 수 있었습니다.

 
 
 

자세한 원인 분석

[정상 상황]
[클라이언트]                          [서버]
1. COM_STMT_PREPARE("SELECT * FROM t WHERE id=?") 
   ------------------------------------------->
                                    2. 핸들 ID=123 반환
3. COM_STMT_EXECUTE(ID=123, 타입=INT, 값=5) 
   ------------------------------------------->
                                    4. 타입 정보 저장 → 실행 → 결과 반환
5. COM_STMT_EXECUTE(ID=123, 값=10)  // 타입 생략
   ------------------------------------------->
                                    6. 저장된 타입(INT) 사용 → 정상 실행

Prepared Statement는 SQL 재사용과 SQL 인젝션 방지를 위해 사용됩니다.
클라이언트는 COM_STMT_PREPARE 명령을 통해 SQL 문을 준비하고, 서버는 해당 SQL에 대한 핸들 ID를 반환합니다.
이후 COM_STMT_EXECUTE 명령을 사용하면, 서버는 전달된 파라미터의 타입을 저장한 후 SQL을 실행합니다.

Prepared Statement는 같은 핸들 ID를 사용하여 여러 번 실행할 수 있으며,
한 번 타입이 저장되면 이후 실행에서는 파라미터의 타입을 생략해도 기존 정보를 사용하여 정상적으로 실행됩니다.

 

[문제 상황]
[클라이언트]                          [서버]
1. COM_STMT_PREPARE("SELECT * FROM t WHERE id=?") 
   ------------------------------------------->
                                    2. 핸들 ID=123 반환
3. COM_STMT_EXECUTE(ID=123, 타입=INT, 값=5) 
   ------------------------------------------->
                                    4. BF Abort 감지 → 즉시 오류 반환!
                                    5. 타입 정보 저장 실패 ⚠️
6. COM_STMT_EXECUTE(ID=123, 값=10)  // 타입 생략
   ------------------------------------------->
                            7. 저장된 타입 정보 없음 → 쓰레기 값 참조 → SIGSEGV 크래시 💥

이 문제는 Prepared Statement가 Galera Cluster 환경에서 동작할 때 발생합니다.
Galera Cluster는 First Committer Wins 전략을 사용하므로, 노드 간 트랜잭션 충돌이 발생하면 첫 번째 트랜잭션만 커밋되고 나머지는 롤백됩니다.
이 과정에서 타입 정보가 저장되지 않는 문제가 발생합니다.

동일한 커넥션에서 같은 핸들 ID를 사용하여 COM_STMT_EXECUTE를 실행할 때, 서버는 이전 실행의 타입 정보를 유지하고 있다고 가정합니다.
그러나 롤백된 트랜잭션에서는 실제로 타입 정보가 저장되지 않았기 때문에, 이후 실행에서 잘못된 메모리를 참조하여 Segfault가 발생합니다.

 

[수정 후]
[클라이언트]                     [서버]
1. COM_STMT_EXECUTE(ID=123, id=5) 
   ---------------------------->
                             2. BF Abort 감지 → "wsrep_delayed_BF_abort" 플래그 ON
                             3. 파라미터 설정 완료 (타입 정보 저장)
                             4. 플래그 확인 → 오류 반환
3. COM_STMT_EXECUTE(ID=123, id=10) 
   ---------------------------->
                             5. 저장된 타입 정보 사용 → 정상 실행!

이를 해결하기 위해 수정된 부분은 트랜잭션이 종료되기 전에 타입 정보가 반드시 저장되도록 보장하는 것입니다. 
즉, 스레드 종료 액션을 받더라도 타입 초기화가 완료된 후에 트랜잭션이 종료되도록 수정된 것입니다. 
이로 인해 트랜잭션이 롤백되더라도 타입 정보가 제대로 저장되어 이후 실행에서 안전하게 타입 정보를 사용할 수 있게 됩니다.
 
 
 

의심 2. 특정 시각의 배치 작업, 시나리오 테스트 (K6 활용)

누적된 장애 기록을 살펴본 결과, 일부 장애는 배치 작업이 실행되는 동안 문제가 발생하는 것으로 나타났습니다. 
이 시점에 배치 작업이 실행되는 로직을 분석한 결과, Galera Cluster에서는 허용하지 않는 GET_LOCK과 RELEASE_LOCK을 사용하는 것을 알게되었습니다.
그러나 배치 작업이 매일 실행되는 데 비해 문제가 매일 발생하는 것은 아니기 때문에, 장애 원인으로 의심은 갔지만 확신을 가질 수는 없었습니다.
그래서 반복적으로 GET_LOCK과 RELEASE_LOCK을 사용하는 시나리오를 테스트하고, 다양한 환경(단일 커넥션, 다중 커넥션)에서 그 동작을 검증해 보았습니다. 

xk6-sql을 사용하여 SQL 실행 테스트를 진행하였습니다. 
다른 여러 도구들이 있을 수 있지만, SQL 실행이라는 목적에 사용법이 간단한 xk6-sql를 선택하였습니다.

 
 

테스트 조건 환경 상세조건 결과
GET_LOCK과 RELEASE_LOCK 반복 실행 Galera Cluster   10/10 성공
  Galera Cluster GET_LOCK-RELEASE_LOCK 사이 RETURN 0/10 성공
  Stand Alone GET_LOCK-RELEASE_LOCK 사이 RETURN 10/10 성공

일반적인 경우에는 정상적으로 실행되었지만, Galera Cluster 환경에서 GET_LOCK 후 RELEASE_LOCK 없이 커넥션을 끊었을 때 Segfault가 발생하는 현상을 확인할 수 있었습니다.
Stand Alone 환경에서는 문제가 발생하지 않았으므로, 장애의 원인이 Cluster 환경에서만 발생하는 특수한 동작과 관련이 있음을 유추할 수 있었습니다.
 
 
 

코어 덤프 분석

정확한 장애 원인 파악을 하기위해 Coredump를 활성화 시키고 디버깅을 해보았습니다. 
GET_LOCK 이후에 커넥션 종료 시 cleanup을 진행하는 과정에서 Segfault가 발생하는 현상을 확인할 수 있었습니다. 
이때 발생한 Segfault는 User Lock을 Release할 때, 값이 없는 곳을 참조하면서 발생하였습니다.

User Lock(리스트 노드)의 prev 값이 null로 초기화되어 있었고, 이 prev 값을 참조하려 했을 때 Segfault가 발생한 것을 확인할 수 있었습니다.

 

 
 
 

동일한 장애 검색

사실 너무나도 이상한하고 심각한 오류라고 생각되었습니다.
MariaDB의 릴리즈 버전에서 유사한 이슈가 있는지 확인했습니다. 하지만 동일한 장애에 대한 사례는 찾을 수 없었습니다.
최신 버전에서 동일한 문제가 발생하는지 테스트해본 결과, 최신 버전에서는 장애가 발생하지 않았습니다.
이로 인해 환경 설정의 문제보다는 MariaDB의 내부 구현에 의한 문제임을 추측할 수 있었습니다.

어느 버전에서 문제가 해결되었는지 명확히 알 수 없었기 때문에 다시 여러 버전을 Docker 환경에서 실행해보며 테스트를 진행했습니다.

 
 

DB 버전 / Galera 버전 장애 여부
mariaDB 10.5.13 / Galera 4.10 장애 O
mariaDB 10.5.14 / Galera 4.10 장애 O
mariaDB 10.5.15 / Galera 4.10 장애 O
mariaDB 10.5.16 / Galera 4.10 장애 X

테스트 결과, 문제가 되었던 버그는 MariaDB 10.5.15까지 존재했으며, 10.5.16에서 수정된 것을 확인할 수 있었습니다. 이전에 이미 10.5.16 버전의 릴리스 노트를 살펴봤지만, 해당 문제 해결에 관한 동일한 사례는 언급이 없었습니다.

따라서 10.5.15에서 10.5.16으로 버전이 업데이트되었을 때의 커밋 기록을 분석하였고, 그 중 Lock 관련 커밋을 집중적으로 살펴보았습니다.
그 결과, Galera의 롤백 처리 부분에서 특정 코드가 추가된 것을 발견했습니다. 이 커밋에서는 Matadata Lock을 Release하기 전에 먼저 User Lock을 Release하도록 수정된 것이 확인되었습니다.

이 커밋이 실제로 문제를 해결했는지 더 확실히 확인하기 위해, 장애가 발생하지 않는 버전에서 해당 코드를 주석 처리한 후 다시 빌드하고 실행해본 결과, 장애가 재발하는 것을 확인할 수 있었습니다.

 
 
 

자세한 원인 분석

[문제 상황]
[클러스터 환경 커넥션 종료]
│
├─ 1. release_explicit_locks() 호출  
│   └─ MDL 락 해제 (User Lock의 MDL 포함)
│
└─ 2. mysql_ull_cleanup() 호출  
    └─ ull_hash 순회 시도  
        ├─ lock_node->prev가 NULL인 노드 발견  
        └─ ❌ lock_node->prev->next 접근 → SIGSEGV 크래시 💥

 
GET_LOCK으로 획득한 User Lock은 내부적으로 Matadata Lock으로 관리됩니다. 
이 User Lock 정보는 별도의 해시 맵인 ull_hash에도 저장되는데, Cluster 환경에서는 커넥션이 끊길 때 해당 스레드의 Matadata Lock을 해제하려 합니다. 
하지만 ull_hash까지는 초기화하지 않아서, 이후에 커넥션 종료 단계에서 mysql_ull_cleanup()이 호출되었을 때 이미 해제된 User Lock을 참조하게 되어 장애가 발생했습니다.

 

[수정 후]
[클러스터 환경 커넥션 종료]
│
├─ 1. mysql_ull_cleanup() 호출  
│   ├─ ull_hash 순회  
│   │   └─ lock_node->prev->next 접근
│   └─ ull_hash 초기화
│
├─ 2. release_explicit_locks() 호출  
│   └─ MDL 락 해제 (User Lock의 MDL 포함)
│
├─ 3. mysql_ull_cleanup() 호출
│   └─ ull_hash length가 0이라 순회 종료       
│
└─ 4. 커넥션 종료

 
수정된 코드에서는 ull_hash를 올바르게 초기화하도록 변경되었습니다. 구체적으로, Matadata Lock이 해제되기 전에 ull_hash를 업데이트하여 이미 해제된 Lock을 참조하는 일이 없도록 처리되었습니다.
이 문제는 다른 이슈를 해결하는 과정에서 함께 해결되었습니다.

그렇기 때문에 저와 동일한 문제를 기존 사례에서 찾을 수 없었던 것입니다.
MariaDB의 기존 문제에서는 정확히 저와 같은 상황을 다루지 않았지만, MDEV-24143 이슈 댓글에 제 사례를 추가하여 공유했습니다.

  • https://jira.mariadb.org/browse/MDEV-24143?focusedCommentId=298056&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-298056

 
 
 

개선할 점

이번 장애의 원인은 결국 MariaDB의 버전에서 치명적인 버그가 발견된 것이었습니다.
이 문제의 대책은 버전 업그레이드였지만, 당장 버전 업그레이드를 진행하기엔 준비가 필요했습니다. 그래서 우선적으로 각 팀에 GET_LOCK과 RELEASE_LOCK 사용을 자제해달라고 당부하며 문제를 일시적으로 해결할 수 있도록 했습니다.

장기적인 해결책은 버전업이었는데, 디버깅을 통해 문제의 원인을 파악한 후 상위 버전에서 해결되었음을 확인했기 때문에, 버전 업그레이드가 필수적이었습니다.

DB 버전 업그레이드를 위한 방법으로는 두 가지를 고려했습니다.

다운타임 없는 롤링 업데이트: 서비스 중단 없이 점진적으로 업그레이드하는 방법입니다. 시스템에 미치는 영향을 최소화할 수 있는 무중단이라는 장점이 있지만 시간이 다른 방법보다 걸린다는 단점이 있습니다.

다운타임이 있는 벌크 업데이트: 한 번에 모든 시스템을 업그레이드하는 방법입니다. 서비스가 잠시 중단될 수 있지만, 한 번에 처리할 수 있는 장점이 있습니다.

저희는 서비스의 지속적인 운영을 위해 롤링 업데이트 방식을 선택했습니다. 
또 버전 업데이트 이전에 이 버전이 안정적인지 추가로 검토한 후 상위 버전으로의 업그레이드 여부와 다른 잠재적인 문제가 없는지와 Graceful하게 업데이트할 수 있는 방법을 확인하고 있습니다.

 
 
 

마무리

이번 테스트를 통해 모든 크래시의 원인을 완전히 파악했다고는 생각하지 않습니다. 따라서 계속해서 의심되는 시나리오를 추가하여 테스트를 진행하고 있습니다.
그럼에도 불구하고, 사내 개발자들의 발목을 잡았던 DB 문제를 점진적으로 개선할 수 있어 긍정적인 경험이었습니다.

장애를 분석하면서 이전에 남겨둔 로그들을 다시 살펴보았습니다.
장애 발생 당시에는 원인을 정확히 알 수 없었지만, 미래를 위해 기록을 남긴다는 생각으로 정리했었고, 그 기록들이 큰 도움이 되었습니다.
여러 장애를 되짚어보는 과정에서 Prepared Statement와 User Lock 사용이 원인이 된 오류들을 발견할 수 있었고, 이 행위가 결코 의미 없지 않았다는 점을 깨달았습니다.
또한, 장애 시 메모리 사용량이 급증했던 이유가 장애 자동 복구 과정에서 리소스가 집중적으로 사용되었기 때문이라는 점도 파악할 수 있었습니다.

Coredump, Sysbench, K6등 처음 해보는 것 투성이었습니다.
처음에는 "로우 레벨을 내가 본다고 해서 과연 뭐가 달라질까?"라는 회의적인 생각도 들었지만, 디버깅과 소스코드를 직접 분석해보니 문제의 실마리를 잡을 수 있었습니다.
또한 Sysbench와 K6등은 여러 시나리오를 세우고 테스트할 때 유용한 도구가 되었습니다.
앞으로도 다양한 장애 상황에서 가상의 시나리오를 세우고 깊게 파고들면 해결을 할 수 있겠다는 자신감을 얻었습니다.

감사합니다.


 

Ref