스토리지 선택 기준 - (3) 그래서 OpenSearch 를 S3에 올릴 수 있나요?
이 글은 시리즈의 3편입니다. 1편 (티어링) 과 2편 (Kubernetes PV) 을 먼저 읽고 오면 좋습니다. (안 읽어도 이해는 됩니다만...)
1편과 2편에서, 거의 염불 외우듯이
"OpenSearch data path는 S3 위에 올리면 안 된다."
라고 말했던 걸 기억하는가?
2편에서 S3 CSI driver 이야기를 하면서도, EFS 이야기를 하면서도 계속 같은 결론으로 돌아왔다. 그런데 정작 왜 안 되는지는 "network filesystem이라 latency/locking이 병목"이라는 식으로 대~충 넘어갔다.
사실 이걸 제대로 설명하려면 결국 Lucene이 디스크를 어떻게 읽는지, 더 내려가서 OS가 파일을 어떻게 메모리에 붙이는지까지 파고들어야 하기 때문이다.
혹시 시리즈 초반에 스토리지 선택의 가장 깊은 제약은 비용표가 아니라 엔진이 디스크를 읽는 방식에 있다 라고 말했던 것을 기억하는가? 드디어 내부적인 이야기를 할 수 있을 것 같다.
"Lucene의 mmap"은 그 시스템콜의 mmap인가?
OpenSearch/Lucene을 좀 다뤄봤다면 mmap 이라는 단어를 수도 없이 들었을 것이다. index.store.type 에 mmapfs, hybridfs 같은 게 있고, 운영하다 보면 vm.max_map_count 를 올리라는 소리도 듣는다.
그런데 여기서 한 가지 짚고 넘어가야 할 게 있다. Lucene이 말하는 mmap은 "mmap 비슷한 자체 캐시 추상화" 인가, 아니면 진짜 OS의 mmap(2) 시스템콜 인가?
결론부터 말하면 진짜 그 mmap 이 맞다. 다만 Lucene이 직접 C로 mmap(2) 를 호출하는 건 아니고, Java NIO와 JVM native 구현을 거쳐서 결국 OS의 mmap 계열 기능을 사용하는 구조다. 경로를 따라가 보자.
- Lucene의
MMapDirectory는 segment 파일을 열 때FileChannel.map()을 호출한다. FileChannel.map()의 자바 문서를 보면 "파일의 특정 영역을 메모리에 직접 매핑한다" 고 정의되어 있고, 그 결과로MappedByteBuffer를 돌려준다.MappedByteBuffer문서도 이 버퍼의 내용이 memory-mapped file region이라고 설명한다.- OpenJDK의 Unix 구현을 보면 더 직접적이다.
FileChannelImpl.c가<sys/mman.h>를 include하고, 내부map0에서mmap64(...)를 호출하며, unmap 시점엔munmap(...)을 호출하고 있다.
물론 우리가 내부를 알 필요는 없지만, 결국 Memory Mapped I/O 를 쓰고 있다는것에 일단 주목하도록 하자.
참고로, Java 21 이후의 Lucene은 MemorySegment API (소위 Panama, Foreign Function & Memory API) 를 사용한다.. (예전엔 sun.misc.Cleaner 를 강제로 끄집어내는 더러운 unmap hack을 써야 했는데... 이제는 그럴 필요가 없어졌다.) 어쨌거나 "mmap 느낌의 무언가"가 아니라 OS 가상메모리 매핑을 그대로 쓰는 구조 라는 게 포인트다.
내부적으로 어떤일이 벌어지는가?
Lucene segment 하나는 대략 이런 파일들의 묶음이다.
_0.tim // term dictionary
_0.tip // term index
_0.doc // postings (freq)
_0.pos // positions
_0.dvd // doc values data
_0.dvm // doc values metadata
Lucene은 이 파일들을 read() 로 매번 복사해서 읽는 대신, 파일의 영역을 프로세스 주소 공간에 매핑 한다.
Process Virtual Address Space
0x7000_0000 ─────────────────────
_0.tim mapped region
0x7100_0000 ─────────────────────
_0.doc mapped region
0x7200_0000 ─────────────────────
_0.dvd mapped region
0x7300_0000 ─────────────────────
그 다음, Lucene 입장에서 특정 offset의 byte를 읽는 건 대략 이런 느낌이 된다.
byte b = mappedBuffer.get(offset);
코드만 보면 그냥 메모리에서 한 바이트 꺼내는 것 같지만, 이게 Java heap에서 읽는 게 아니라는 점 이 중요하다. 실제 흐름은 이렇다.
- Lucene이 mapped address의 특정 offset에 접근한다.
- 해당 page가 아직 RAM에 없으면 page fault 가 발생한다.
- kernel이 backing file에서 해당 page를 page cache 로 읽어 온다.
- page table을 갱신한다.
- 이후 같은 page에 대한 접근은 그냥 메모리 접근처럼 처리된다.
즉, 애플리케이션이 read() 시스템콜을 매번 날려서 I/O를 요청하는 게 아니라, page fault를 통해 kernel의 VM subsystem이 필요한 page를 끌어오는 방식이다. 한 번 올라온 page는 page cache에 남아 있으니, 두 번째 접근부턴 시스템콜도 디스크 I/O도 없다.
"파일을 메모리에 다 올린다"는게 아니다.
여기서 흔히 오해하는 게 있다. mmap은 파일 전체를 즉시 RAM에 복사하는 게 아니다. 기본적으로는 파일의 주소 범위를 프로세스 virtual address space에 연결만 하는 것이다.
예를 들어 100GB짜리 segment 파일을 mmap하면,
Virtual address space : 100GB 사용
Physical RAM : 실제 접근한 page 중심으로만 사용
Disk I/O : page fault 발생 시 필요한 page 단위로만 수행
그래서 Lucene 문서도 MMapDirectory 가 파일 크기만큼 virtual address space를 잡아먹으므로 64-bit JRE가 중요하다 고 언급하고 있다. (32bit JRE면 주소 공간이 부족해서 큰 인덱스를 mmap할 수가 없다.) OpenSearch에서 vm.max_map_count 나 address space, page cache 이야기가 같이 나오는 이유도 결국 여기에 있다. 수많은 segment 파일을 매핑하다 보면 매핑 개수 자체가 OS 한계에 부딪히기 때문이다.
read() 방식과 mmap 방식의 차이
시스템 콜을 학부에서 배워서 다 까먹었을 수 있으니, 방식의 차이를 잠깐 보도록 하자.
read() 방식은 대략 이렇다.
반면 mmap 방식은 이렇다.
결국 read() 는 "파일에서 사용자 버퍼로 읽어와라" 이고, mmap 은 "파일을 내 주소 공간에 매핑해두고, 접근할 때 OS가 page 단위로 가져오게 하자" 이다.
read() 는 kernel page cache에서 user buffer로의 복사(copy_to_user)가 한 번 더 일어나지만, mmap은 매핑된 가상 주소가 곧 page cache의 그 page를 가리키므로 그 복사가 없다. (물론 모든 상황에서 mmap이 빠르다는 뜻은 절대 아 니다. 이건 뒤에서 다시 이야기한다.)
왜 Lucene/OpenSearch에서 이게 중요하지?
Lucene 검색은 segment 내부의 여러 자료구조를 offset 기반으로 마구 찔러대는 작업이다.
- term dictionary
- posting list
- doc values
- stored fields
- norms
- points index
이런 구조는 "큰 파일을 처음부터 끝까지 순차 scan" 하기보다는, 여러 segment 파일의 여러 offset을 랜덤하게 접근하는 일이 압도적으로 많다.
mmap을 쓰면 Lucene은 이 랜덤 접근을 직접적인 offset read API 호출로 처리하지 않고, OS의 page cache와 가상메모리 시스템에 통째로 맡길 수 있다.
그래서 OpenSearch를 운영할 때 흔히 듣는 그 조언,
"heap을 너무 크게 잡지 마라. 남는 RAM은 page cache로 써야 한다."
가 바로 여기랑 연결된다. Lucene segment 파일은 Java heap 안에 올라가는 게 아니라 mmap + page cache를 통해 접근된다. 따라서 JVM heap을 과하게 크게 잡으면, 그만큼 OS page cache로 쓸 RAM이 줄어들고, 그 결과 segment access가 page cache hit이 아니라 disk I/O로 더 자주 떨어지게 된다. (heap을 늘렸는데 검색이 느려지는 역설이 여기서 나온다.)
단, write path 전체가 mmap이라는 뜻은 아니다
오해를 막기 위해 짚고 넘어가자. OpenSearch/Lucene이 모든 I/O를 mmap으로만 하는 건 아니다.
mmap이 특히 중요한 건 index read path 다. 반면 indexing 도중 새 segment를 쓰거나 translog를 쓰는 경로는 일반적인 file write / fsync 계열이 관여한다. 새로 만들어진 segment가 검색 대상이 되고 나서야 mmap 기반으로 읽히는 식으로 이해하면 된다.
그래서 OpenSearch의 스토리지 요구사항은 사실 두 가지가 섞여 있다.
Write path
- indexing buffer flush
- segment creation
- translog write / fsync
- merge write
→ write throughput, fsync latency 가 중요
Read path
- segment search
- doc values access
- terms / postings random access
→ mmap, page cache, random read latency 가 중요
1편에서 "Hot tier는 IOPS 중심 block storage여야 한다" 고 했던 게 이 두 path를 동시에 만족시켜야 하기 때문이다. write는 throughput/fsync, read는 random access이니까... 둘 다 로컬 블록 디바이스가 잘하는 일이고, 네트워크 너머의 무언가가 잘하는 일이 아니다.
그래서 S3와 근본적으로 안 맞는다
이제 1·2편 내내 미뤄왔던 결론을 낼 수 있다.
mmap은 기본적으로 파일 디스크립터, OS page cache, page fault, page table 을 전제로 한다. 이건 "로컬 파일시스템 위의 블록 디바이스" 라는 모델이 있어야 성립하는 이야기다.
그런데 S3는 그런 파일시스템 블록 디바이스가 아니다. S3는
GET object
GET object (range)
PUT object
이런 식의 HTTP 기반 object storage 다. 물론 S3 FUSE나 2편에서 본 Mountpoint for S3 CSI 같은 걸로 "파일처럼 보이게" 만들 수는 있지만, 그건 어디까지나 POSIX 파일시스템을 흉내 내는 계층일 뿐이다. Lucene이 원하는 mmap / page fault / random page access와는 의미가 완전히 다르다.
Lucene mmap
→ OS가 파일 page를 virtual memory에 매핑
→ page fault 단위로 kernel이 읽음
→ low-latency random access 전제
S3
→ HTTP object / range request
→ ms 단위 네트워크 왕복
→ object 단위 / 큰 range 단위 접근에 적합
page fault 하나가 ms 단위 네트워크 왕복이 되어버리는 순간, Lucene의 랜덤 offset 접근 패턴은 그냥 죽는다. (term dictionary 한 번 타고 posting list 찔러보는 데 매번 HTTP GET이 날아간다고 생각해보자. 신나는 과금폭탄!!) 그래서 "OpenSearch data path를 S3 위에 올리면 안 된다" 는 설명의 기술적 핵심 중 하나가 바로 이 mmap / page cache 모델 인 것이다.
반대로, 그래서 S3는 snapshot repository나 searchable snapshot, 데이터 레이크처럼 object / 큰 range 단위로 접근하는 용도엔 더없이 잘 맞는다. 같은 데이터를 다루더라도 접근 패턴이 다르면 스토리지도 달라져야 한다는, 이 시리즈 1편의 이야기로 다시 돌아오는 셈이다.
정 비용을 아껴서 쿼리하고 싶다면?
물론 이런 경우는 있다.
로그 데이터라서 쿼리 횟수는 극히 적지만, 어쨌든 데이터를 보존하고 싶어요.
대표적으로 감사 로그 (보통 컴플라이언스 정책에 따라 1년 넘게 보관하기도 하고...), 시스템 로그등이 이에 해당할 것이다. 보존 기간은 길고, 그렇다고 쿼리를 많이 하지도 않지만 어쨌거나 저장해야 하고, 가끔 감사가 오거나 장애가 발생했을 때 원인 추적용으로 조회해야 하는 경우.
그럴 땐, S3 Compatiable Storage 에 Parquet 형식으로 파일을 저장하고, Parquet 파일에 대한 쿼리를 지원하는 쿼 리엔진을 사용해보자.
- Trino, Spark/Flink SQL, DuckDB, ClickHouse, Dremio, StarRocks, Apache Doris, ... 등등, 지원하는 것은 많다.
- 다만, 파일로 저장하는 경우 "어떤 데이터가 어디에 있지?" 를 파악하기 어렵다보니, 보통 메타데이터용 테이블을 따로 둔다
- Apache Hive를 초기에 썼었지만, 최근에는 Iceberg 같은 카탈로그를 사용하는 편이다.
관련된 유즈케이스가 궁금하면, 아래 링크를 참고하도록 하자.
결론
세 편에 걸쳐 스토리지 선택을 이야기했지만, 결국 또 이 블로그 식대로 결론을 냈다.
처음엔 그냥 "어떤 스토리지가 싸고 빠른가" 정도의 질문이었지만, 답을 찾다 보니 결국 비용표가 아니라 page fault까지 내려와 버렸다. 늘 그렇듯, 좋은 선택을 하려면 한 겹 더 내려가서 "이게 왜 이렇게 동작하는가" 를 들여다봐야 하는 것 같다. (그리고 그 과정이 제일 재밌기도 하고.)
정리하면, Lucene의 mmap은 진짜 OS memory-mapped file이다. 다만 Lucene이 직접 C로 mmap(2) 를 호출하는 게 아니라, Java NIO와 JVM native 구현을 통해 OS의 mmap 계열 기능을 사용한다고 보면 된다. 그리고 이 한 가지 사실이, 스토리지 계층을 어떻게 설계해야 하는지에 대한 의외로 많은 결정을 좌우한다.
