Linux 7.0이 PostgreSQL을 망가뜨린 방법
(read.thecoder.cafe)- Linux 7.0에서 기존 서버 기본값이던 PREEMPT_NONE 선점 모드가 제거되면서, 동일 하드웨어에서 PostgreSQL 처리량이 절반으로 급감하는 심각한 성능 회귀가 발생
- AWS 엔지니어가 96-vCPU Graviton4 머신에서 pgbench를 실행한 결과, Linux 6.x 대비 Linux 7.0에서 초당 트랜잭션이 98,565건에서 50,751건으로 떨어지며 CPU의 55%가 단일 스핀락 함수에서 소모
- PostgreSQL의 공유 버퍼 풀(shared buffer pool) 접근을 보호하는 스핀락이 4KB 메모리 페이지의 마이너 페이지 폴트와 결합되어, 락 보유 중 스케줄러에 의한 선점이 발생하면 대기 중인 모든 백엔드가 CPU를 낭비하며 회전
- Huge Pages(2MB 또는 1GB)를 활성화하면 잠재적 페이지 폴트 수가 3,100만 건에서 수만~수백 건으로 감소하여 회귀 현상 해소
- 커널 측에서는 Restartable Sequences(rseq) 채택을 제안했으나, PostgreSQL 커뮤니티는 커널 업그레이드로 인한 성능 저하 자체가 "유저스페이스를 깨뜨리지 않는다"는 원칙에 위배된다는 입장
문제 현상
- AWS 엔지니어 Salvatore Dipietro가 96-vCPU Graviton4 프로세서에서 pgbench를 실행, scale factor 8,470(약 8억 4,700만 행 테이블), 1,024 클라이언트, 96 스레드 구성의 고병렬 부하 테스트 수행
- Linux 6.x에서 98,565 TPS, Linux 7.0에서 50,751 TPS로 처리량이 거의 절반으로 하락
perf프로파일링 결과, CPU 시간의 55.60%가s_lock함수 내부에서 소모- 호출 경로:
StartReadBuffer→GetVictimBuffer→StrategyGetBuffer→s_lock
- 호출 경로:
선점(Preemption)이란
- OS 스케줄러가 실행 중인 스레드를 중단하고 다른 스레드에 CPU를 넘기는 결정이 선점
- Linux 7.0 이전에는 세 가지 옵션 존재
- PREEMPT_NONE: 스레드가 자발적으로 CPU를 양보할 때까지(syscall, I/O 블록, sleep) 거의 중단하지 않음. 전통적인 서버 기본값으로 컨텍스트 스위치가 적고 처리량이 높음
- PREEMPT_FULL: 안전한 거의 모든 지점에서 실행 중인 스레드를 중단 가능. 응답 시간은 줄지만 컨텍스트 스위치 오버헤드 증가. 전통적인 데스크톱 기본값
- PREEMPT_LAZY: Linux 6.12에서 도입된 절충안으로, 자연스러운 경계를 기다리면서도 필요시 선점 허용. PREEMPT_NONE의 처리량 특성을 근사하도록 설계
- Linux 7.0에서 PREEMPT_NONE이 최신 CPU 아키텍처에서 제거되어 PREEMPT_FULL과 PREEMPT_LAZY만 남음
- PREEMPT_LAZY가 대부분의 서버 소프트웨어에서는 대체재로 작동하지만, PostgreSQL에서는 치명적 차이 발생
PostgreSQL 메모리 관리
- PostgreSQL은 고정 크기의 데이터 페이지(기본 8KB)를 기본 저장 단위로 사용하며, 테이블 행, B-tree 인덱스 노드, 메타데이터 등 모두 이 페이지에 저장
- 디스크 읽기를 줄이기 위해 공유 버퍼 풀(shared buffer pool)이라는 대규모 공유 메모리 영역에 최근 읽은 데이터 페이지를 캐싱
- 클라이언트가 접속하면 전용 백엔드 프로세스가 생성되며, 버퍼 풀에 없는 페이지는 디스크에서 읽은 뒤 빈 버퍼 또는 축출 가능한 버퍼를 찾아야 함
- 이 버퍼 선택 작업을 담당하는 함수가
StrategyGetBuffer
- 이 버퍼 선택 작업을 담당하는 함수가
PostgreSQL의 스핀락
- 스핀락은 잠금을 기다리며 잠들지 않고 루프를 돌며 계속 확인하는 잠금 메커니즘
- 매우 짧은 임계 구간에서는 스레드를 재우고 깨우는 비용보다 회전이 더 효율적
- 핵심 가정: 락을 보유한 스레드가 아주 빠르게 해제할 것
StrategyGetBuffer는 버퍼 선택을 보호하기 위해 단일 전역 스핀락 사용- 96-vCPU, 1,024 클라이언트 환경에서 모든 백엔드가 동일 락을 놓고 경쟁
가상 메모리와 TLB
- 모든 프로세스는 가상 메모리 주소를 사용하며, 하드웨어가 페이지 테이블(다단계 트리 구조)을 통해 물리 주소로 변환
- 매번 페이지 테이블을 순회하면 느리므로, CPU는 최근 변환 결과를 캐싱하는 TLB(Translation Lookaside Buffer) 보유
- TLB 히트 시 빠른 접근, TLB 미스 시 페이지 테이블 워크가 필요하여 시간 소요
- Linux는 지연 할당(lazy allocation) 원칙을 사용하여, 가상 메모리 할당 시 실제 물리 페이지는 첫 접근 시점에 매핑
- 첫 접근 시 마이너 페이지 폴트 발생: 커널이 물리 페이지를 할당하고 매핑을 저장하며, 일반 읽기/쓰기보다 수 마이크로초 단위로 느림
4KB 페이지의 문제
- 벤치마크에서
shared_buffers를 120GB로 설정, 4KB 메모리 페이지 기준 약 3,100만 개의 메모리 페이지, 즉 3,100만 건의 잠재적 첫 접근 페이지 폴트 - 120GB 공유 버퍼 풀을 사용하는 장시간 벤치마크에서는 새로운 메모리 영역이 계속 워킹셋에 진입하므로 페이지 폴트가 시작 시에만이 아니라 지속적으로 발생
StrategyGetBuffer내에서 스핀락을 보유한 상태로 공유 메모리에 접근할 때 해당 영역이 아직 매핑되지 않았으면 마이너 페이지 폴트 발생- PREEMPT_NONE(Linux 7.0 이전): 백엔드 A가 페이지 폴트 핸들러에 진입해도 자발적 재스케줄링 포인트를 회피하므로, 폴트 해결 전에 스케줄 아웃될 가능성이 낮음. 대기 시간이 예상보다 길어지지만 피해 제한적
- PREEMPT_LAZY(Linux 7.0 이후): 스케줄러가 페이지 폴트 핸들러 내부에서 백엔드 A를 선점하여 다른 프로세스를 스케줄링할 수 있음. 폴트 처리가 완료되어도 스케줄러가 제어권을 돌려줄 때까지 추가 대기 시간
t발생- 이 추가 대기 시간은 단순
t가 아니라 현재 회전 중인 모든 백엔드 수 × t의 CPU 낭비로 증폭 - 96-vCPU에 수백 개 백엔드 환경에서 이 승수 효과가 치명적이며, 결과적으로 CPU의 56%가
s_lock에서 소모
- 이 추가 대기 시간은 단순
Huge Pages를 통한 해결
shared_buffers120GB 기준, 메모리 페이지 크기를 변경하면 잠재적 페이지 폴트 수가 극적으로 감소- 4KB 페이지: ~31,000,000건의 잠재적 페이지 폴트
- 2MB Huge Pages: ~61,440건
- 1GB Huge Pages: ~120건
- 페이지 크기 증가는 페이지 폴트 수 감소뿐 아니라 TLB 압력도 완화: 훨씬 적은 TLB 엔트리로 동일 메모리를 커버하므로 TLB 미스와 페이지 테이블 워크 감소
StrategyGetBuffer가 락 보유 중 폴트를 발생시키지 않게 되어 락 보유자가 빠르게 완료, 다른 백엔드는 밀리초 대신 마이크로초만 대기. 회귀 현상 해소- PostgreSQL에서 huge pages 설정은
huge_pages파라미터로 제어off,on,try(기본값) 세 가지 값 지원try는 huge pages가 가능하면 사용하고 불가능하면 4KB로 조용히 폴백하므로, 잘못된 구성을 인지하지 못할 위험on으로 설정하면 huge pages를 사용할 수 없을 때 PostgreSQL이 시작에 실패하여 문제를 즉시 인지 가능
- 트레이드오프: huge pages는 사전 할당·예약 방식이므로 PostgreSQL이 전부 사용하지 않더라도 해당 메모리를 시스템 나머지 부분에서 사용 불가. 페이지 일부만 사용될 경우 나머지가 낭비. 대규모
shared_buffers를 사용하는 프로덕션 환경에서는 대체로 트레이드오프 감수 가치 있음
향후 전개
- 선점 변경을 설계한 Intel 커널 엔지니어 Peter Zijlstra는 PostgreSQL이 Restartable Sequences(rseq) 를 채택할 것을 제안
rseq는 유저스페이스 코드가 임계 구간 중 선점 또는 마이그레이션 여부를 감지하고 해당 구간을 재시작할 수 있게 하는 Linux 커널 기능- PostgreSQL의 스핀락 경로에
rseq를 적용하면 선점된 락 보유자가 모든 대기 백엔드를 지연시키는 시나리오 회피 가능
- PostgreSQL 커뮤니티의 반응은 부정적
- Linux 7.0 이전에는 무료로 확보되던 성능을 되찾기 위해 별도 커널 기능을 채택해야 한다는 점이 수용하기 어려움
- 커널의 오랜 원칙인 "유저스페이스를 깨뜨리지 않는다"(커널 업그레이드 전 정상 작동하던 소프트웨어는 업그레이드 후에도 정상 작동해야 함)에 위배된다는 입장
댓글과 토론
이건 아무리 생각해도 제목이 잘못됐는데요.
https://news.hada.io/comment?id=54772
커널 메인테이너가 postgres에게 아주 오래 전부터 권고해왔던 사안이라고 하니 오히려 "Postgres가 Linux 7.0에서 느려지는 이유"가 맞지 7.0이 Postgres를 망가트린 게 아니죠.
아무리 커널이 엄밀하게는 semver를 따라가지 않더라도 메이저 버전업인데, 스불재를 이렇게 프레이밍한다고요??
rseq가 대안으로 제시되긴 했지만, Linux 전용 코드를 도입해야 한다는 점에서 크로스 플랫폼을 고려해야 하는 오픈소스 프로젝트 입장에서는 쉽게 받아들이기 어려운 제안일 것 같습니다.
메이저 버전업에서 동작 변경이 생기는 건 이해할 수 있지만, 결과적으로 50% 성능 하락이 발생했다면 인프라를 운영하는 입장에서는 커널 업그레이드 자체를 조심스럽게 바라볼 수밖에 없을 것 같네요.
워우 출장 중에 댓글 달고 호텔 와서 보니 세 분이나 의견을 주셨네요. 감사합니다.
말씀주신 관점도 저는 충분히 이해합니다만, 저는 그래도 이걸 postgres 측의 tech debt로 보고 있어서, 결국 postgres가 결자해지 해야 하는 건으로 보고 있습니다. (당장 성능을 위해 hack 등을 써오다가 피본 경험은 spectre로 충분한 듯하여...)
결국 이 건은 당분간 두고 보아야 할 듯합니다.
좋은 하루 보내세요. :)
동의합니다. 인텔의 리눅스 엔지니어들이 퇴직하는 소식들을 종종 접하는데 기존 behavior들을 계속 방치하기엔 언젠간 윈도우 같이 될겁니다 o,o..
이미 해당 구현은 최적의 성능을 위해 플랫폼 별 전용 어셈블리 코드가 가득한 부분이라
특정 플랫폼 전용 코드가 추가된다는 것 때문에 못한다는 건 이유가 될 수 없을 것 같네요.
(제미나이에게 물어보고 요약 했습니다.)
코드 봤는데 그정도로 어셈블리 코드가 가득한 함수도 아니고, 어셈블리 코드가 가득하다는 것이 특정 플랫폼 전용 코드를 추가해도 문제가 된다는 것이 아니라고 생각됩니다. 어셈블리 코드라고 하는 게 아토믹 연산 함수 (GCC의 빌트인 atomic 함수들)말하는 것 같은데 함수 안만 보면 리눅스만 특별하게 코드를 추가할 수는 없는데요.
확실히 리누스갓이 옛날에 WE DO NOT BREAK USERSPACE! 라며 꾸짖을 喝! 한적이 있긴 해서 옵션을 줘야 하지 않나 싶기도 하고
글타고 유저스페이스에서 굳이 꿋꿋하게 스핀락 쓰겠다는것도 좀 말이 안되는거 같고
그런 느낌이네요
30년 동안 OS 기능을 일부 재발명하면서 누구도 한계와 이유를 문서화할 생각을 안했다는 건 좀 이해가 안되네요. 30년전에 자체 동기화, 자체 메모리 관리, 자체 프로세스 모델까지 만든 합리적인 이유가 분명히 있었을텐데요