스토 리지 선택 기준 - (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도 없다.
