본문으로 건너뛰기

"storage" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

모든 태그 보기

스토리지 선택 기준 - (3) 그래서 OpenSearch 를 S3에 올릴 수 있나요?

· 약 17분
VSFe
블로그 주인장

이 글은 시리즈의 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.typemmapfs, 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에서 읽는 게 아니라는 점 이 중요하다. 실제 흐름은 이렇다.

  1. Lucene이 mapped address의 특정 offset에 접근한다.
  2. 해당 page가 아직 RAM에 없으면 page fault 가 발생한다.
  3. kernel이 backing file에서 해당 page를 page cache 로 읽어 온다.
  4. page table을 갱신한다.
  5. 이후 같은 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 같은 카탈로그를 사용하는 편이다.
image-20260726185416137

관련된 유즈케이스가 궁금하면, 아래 링크를 참고하도록 하자.

결론

세 편에 걸쳐 스토리지 선택을 이야기했지만, 결국 또 이 블로그 식대로 결론을 냈다.

처음엔 그냥 "어떤 스토리지가 싸고 빠른가" 정도의 질문이었지만, 답을 찾다 보니 결국 비용표가 아니라 page fault까지 내려와 버렸다. 늘 그렇듯, 좋은 선택을 하려면 한 겹 더 내려가서 "이게 왜 이렇게 동작하는가" 를 들여다봐야 하는 것 같다. (그리고 그 과정이 제일 재밌기도 하고.)

정리하면, Lucene의 mmap은 진짜 OS memory-mapped file이다. 다만 Lucene이 직접 C로 mmap(2) 를 호출하는 게 아니라, Java NIO와 JVM native 구현을 통해 OS의 mmap 계열 기능을 사용한다고 보면 된다. 그리고 이 한 가지 사실이, 스토리지 계층을 어떻게 설계해야 하는지에 대한 의외로 많은 결정을 좌우한다.

스토리지 선택 기준 - (2) Kubernetes에서 티어를 어떻게 다룰 것인가

· 약 16분
VSFe
블로그 주인장

이 글은 시리즈의 2편입니다. 1편 (티어링) 에서 다룬 Hot/Warm/Cold/Archive 개념을 전제로 합니다. 또한, k8s를 충분히 사용해 본 경험이 있음을 전제로 합니다.

1편에서는 스토리지 티어링에 대해 다뤘다. 간단하게 정리하면 메모리 계층을 클라우드로 그대로 옮겼다고 보면 될 정도로 유사한 점을 많이 갖는다고 했었다. 이번에는 이 내용을 조금 현실적인 문제로 끌고가보자.

k8s 를 학습해 본분들의 대부분은 일반적인 Stateless 애플리케이션 Pod 를 띄웠거나, Stateful 이라고 해도 스토리지가 크게 요구되지 않는 use-case 정도를 경험했을 것이다. 그렇다면 OpenSearch 같은걸 띄워버린다면? 각자 쓰고 있는 클라우드 플랫폼이 있다면 곰곰히 생각해보자. Managed k8s를 쓴다고 하면, Hot/Warm/Cold 스토리지까지 전부 달고 운영해야 한다고 치면, Persistent Volume을 어떻게 관리해야 할까?

StorageClass 몇 개 슥슥 만든다고 끝날 문제는 전혀 아니고, 워크로드별 I/O 특성을 PV 설계에 녹여낼 수 있을까? 를 고민해야 하는 것이다.

결국,

"Hot/Warm/Cold를 전부 PVC로 균일하게 추상화하자."

같은 생각을 하면 안된다는 것이다.

뭘 PV로 관리해야 할까?

Kubernetes의 PV/PVC는 "Pod가 mount해서 쓰는 볼륨" 을 추상화한다. 블록·파일 볼륨 추상화에는 1대1 매칭이 잘 되겠지만, S3-Compatiable Storage/AWS S3Glacier 같은 object/archive 계층의 비용·복구 지연·API semantics라면?

(https://github.com/s3fs-fuse/s3fs-fuse 와 같이, s3 Object Storage 를 일반 파일시스템처럼 마운트 할 수 있는 호환성 유틸을 제공하지만, 사실 성능 측면이나 완벽한 호환성 측면에선 다소 아쉬움이 있을 수 밖에...)

AWS/EKS에서는 EBS, EFS, FSx, S3 계열까지 전부 CSI driver로 PV에 연결할 수는 있다. (https://github.com/kubernetes-sigs/aws-ebs-csi-driver) 하지만 연결할 수 있다 는 것과 그게 좋은 설계다 는 다른 이야기이다.

계층Kubernetes에서의 관리 방식AWS 예시적합한 용도
HotPVC + StatefulSetEBS gp3/io2, local NVMeOpenSearch data node, DB, write-heavy
WarmPVC + 별도 StorageClassEBS gp3 (low IOPS), 일부 st1/sc1덜 비싼 block storage, sequential 로그 처리
SharedPVCEFS, FSxRWX 공유, artifact, shared dataset
ColdPVC를 할 순 있지만 잘 안 씀S3snapshot, log archive, Parquet/Iceberg
ArchivePVC 아님Glacier / Deep Archive장기 보관, 감사, 재처리용 원본

즉, Hot/Warm은 PV로, Cold/Archive는 object storage lifecycle로 관리하는 게 제일 자연스럽지 않을끼? 어떻게든 죄다 PVC로 감싸겠다고 Cold/Archive 까지 건드리는 순간 1편에서 본 retrieval fee와 복구 지연이라는 S3/Glacier의 본질이 PV 추상화 뒤로 숨어버려서 오히려 통제 불능이 된다.

StorageClass는 어떻게 관리하면 좋을까...

Kubernetes의 StorageClass는 class별로 provisioner, parameters, reclaimPolicy 등을 정의하는 리소스다. 그런데 Kubernetes 자체는 class의 구체적인 의미에 대해 언급하고 있진 않다. 즉, hot, warm, shared, scratch 같은 의미는 플랫폼 운영자가 직접 고민하고, 매칭해야 한다.

그런 관점에서 스스로 관리하는 방식을 적어보면... StorageClass를 단순한 프로비저너 설정이 아니라, "이 이름의 볼륨을 쓰면 이 정도 성능을 보장한다" 는 SLA 카탈로그 느낌으로 사용하고 있다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ebs-gp3-hot
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
allowVolumeExpansion: true
parameters:
type: gp3
fsType: xfs
encrypted: "true"
iops: "12000"
throughput: "500"
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ebs-gp3-warm
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
allowVolumeExpansion: true
parameters:
type: gp3
fsType: xfs
encrypted: "true"
iops: "3000"
throughput: "125"

이 관점에선, 특히 중요한 값이 세 가지 있다.

  • volumeBindingMode: WaitForFirstConsumer 는 거의 필수에 가깝다. 이 모드는 Pod가 실제로 스케줄링될 때까지 volume binding/provisioning을 지연시켜서, 스케줄러가 Pod의 node/zone 제약을 함께 고려하도록 유도한다. EBS는 같은 Availability Zone의 instance에만 attach할 수 있기 때문에, 이게 없으면 볼륨은 a존에 만들어졌는데 Pod는 c존으로 스케줄링되는 참사가 벌어질 수 있다.
  • reclaimPolicy: Retain 은 OpenSearch/DB류에 사용하기 위한 안전장치다. PVC 삭제가 곧 EBS 삭제로 이어지면, 장애 대응 중에 데이터까지 날려먹을 수 있다. (반대로 CI scratch volume이나 임시 batch volume은 Delete 가 맞을 수 있을테니, 전반적으로 생각을 해볼 필요는 있다.)
  • allowVolumeExpansion: true 는 운영 중 디스크 증설을 위해 기본으로 켜두는 게 좋다. 단, 늘리는 것과 별개로 줄이는 작업은 절대로 쉽지 않다는 걸 생각해봐야 한다.

Hot tier - OpenSearch data node: EBS gp3/io2 또는 local NVMe

이제 본격적으로 워크로드별로 내려가 보자. OpenSearch hot node는 일반적으로 다음과 같은 특성을 갖는다.

  • ingest write
  • segment flush
  • merge
  • mmap 기반 random read
  • page cache 의존
  • shard recovery
  • replica sync

이 목록을 보면 알겠지만, write도 많고 random read도 많다. 그래서 Hot PV는 IOPS 중심 block storage 여야 한다. (여기서 등장하는 mmappage cache 에 대해서는 길이상 3부로 미룰 예정이다.)

OpenSearch 기준으로는 대략 이렇게 나눌 수 있다.

워크로드권장
일반 hot data nodegp3 + 명시적 IOPS/throughput
대형 shard, 높은 검색/ingest 부하gp3 상향 또는 io2
극저지연 / 초고성능, 데이터 재생성 가능local NVMe instance store
비용 민감한 dev/test낮은 spec의 gp3
NFS/EFSdata path로는 비추천

OpenSearch StatefulSet은 이런 식의 volumeClaimTemplates 를 가진다.

volumeClaimTemplates:
- metadata:
name: opensearch-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: ebs-gp3-hot
resources:
requests:
storage: 2Ti

여기서 ReadWriteOnce 를 쓰는 이유라면? OpenSearch shard는 여러 Pod가 같은 volume에 동시에 write하는 구조가 아니니, 각 data node Pod가 자기 전용 disk를 가지는 구조로 잡기 위해서다.

Warm tier - 단순히 느린 스토리지가 아니라니깐...

1편에서도 언급했지만, Warm node니까 싼 거 쓰자는 생각에 EBS st1 / sc1 으로 바로 보내고 싶을 수 있는데, 생각이 좀 많이 필요하다. HDD 계열인 st1/sc1은 throughput 중심의 large streaming workload에 최적화되어 있다. st1은 big data/data warehouse/log processing, sc1은 infrequently accessed throughput-oriented storage와 lowest storage cost 시나리오가 주 용도다. 이 말을 뒤집으면,

작은 random I/O가 많은 OpenSearch warm searchable index에는 st1/sc1이 항상 좋은 선택은 아니다.

라고 말할 수 있지 않을까?

Warm OpenSearch가 "가끔 검색되지만 그래도 검색 latency는 중요하다" 라면, 차라리 ebs-gp3-warm 같은 스토리지가 나을 수도 있을 것이다.

이 맞을 가능성이 크다. 반대로 warm이 거의 순차적으로 읽는 로그 적재용 목적이거나 Spark 등의 데이터 프로세싱 엔진의 스토리지라면 st1도 검토할 수 있다. 결국 Warm은 "느린 tier" 가 아니라 "덜 비싼 block tier" 로 봐야 한다는 게 핵심이다.

Cold tier - S3를 PVC 로 생각하면 안 된다!

앞에서 말한 fuse나, CSI Driver 이야기를 조금만 더 해보자. 보통 아래와 같은 제약 조건이 같이 붙어있다.

  • static provisioning만 지원 한다. dynamic provisioning이나 새 bucket 생성은 지원하지 않는다.
  • 모든 POSIX filesystem feature를 지원하지 않는다.

그래서 S3 CSI는 이런 용도에는 괜찮다.

  • batch job이 S3 dataset을 file처럼 읽음
  • ML training input dataset mount
  • Spark/Trino 주변 보조 workload
  • migration/reprocessing job

하지만 이런 용도에는 부적합하다.

  • OpenSearch data path
  • MySQL datadir
  • MongoDB dbPath
  • Kafka log.dirs
  • POSIX rename/fsync/lock semantics에 민감한 workload

즉, Cold tier는 PVC로 대충 뭉개는게 아니라, 보통 이렇게 데이터 lifecycle로 관리해야 한다.

또는 로그라면 이런 식이다.

Glacier까지 가면 더더욱 PV가 아니다. Glacier는 "mount해서 쓰는 filesystem" 이 아니라, 1편에서 봤듯이 복구 요청을 통해 다시 접근 가능 상태로 만드는 archive tier에 가깝기 때문이다.

EFS/FSx는 "공유 볼륨" 이지 data path 대체재가 아니다

EFS CSI driver를 쓰면 EFS file system을 PV로 mount할 수 있다. EFS는 serverless하고 여러 곳에서 공유 가능한 file storage다. 그래서 이런 용도엔 좋다.

  • 여러 Pod가 공유하는 config/artifact
  • user upload shared area
  • batch job 간 중간 산출물
  • 작은 규모의 shared filesystem

하지만 OpenSearch data path로는 보통 좋지 않다. OpenSearch/Lucene이 원하는 건...

  • 낮은 latency
  • page cache 친화적인 local/block device
  • random I/O
  • fsync/merge 안정성

정도인데, EFS/NFS 계열은 network filesystem이라 metadata/latency/locking semantics가 병목이 되기 쉽다.

FSx for Lustre는 또 성격이 다르다. S3와 연동해서 S3 object를 파일처럼 보이게 하고, cloud dataset을 고성능 파일시스템으로 처리하는 용도에 가깝다. 정리하면 이렇게 나누는 게 좋다.

EFS            → 범용 shared filesystem
FSx for Lustre → 고성능 병렬 파일시스템, ML/HPC/batch
EBS → Stateful DB/Search engine의 기본 data path
S3 → object lake, snapshot, archive

당연히 Node Pool 도...

OpenSearch 를 써봤으면 알겠지만, 당연히 Node Pool 도 나눠야 할 것이다.

NodePool: os-hot
- instance: storage/network strong
- disk: gp3/io2 또는 local NVMe
- taint: storage-tier=hot:NoSchedule

NodePool: os-warm
- instance: memory/cost balanced
- disk: gp3 lower spec
- taint: storage-tier=warm:NoSchedule

그러니 OpenSearch hot node StatefulSet에는 nodeSelector/tolerations로 명확히 묶어주면 좋을 것이다.

nodeSelector:
storage-tier: hot
tolerations:
- key: storage-tier
operator: Equal
value: hot
effect: NoSchedule

EBS PV가 AZ에 묶이기 때문에, Pod가 다른 AZ로 가려고 하면 volume attach 자체가 안 된다. 그래서 WaitForFirstConsumer, node affinity, topology spread, PDB, StatefulSet rolling policy까지 한 묶음으로 같이 봐야 한다. (이 중 하나라도 빠뜨리면 롤링 배포하다 Pod가 영영 Pending에 빠지는 걸 보게 된다.)

플랫폼 관점에서는 정책을 강제해야 한다

마지막으로, 실제 플랫폼이라면 사용자가 아무 PVC나 만들게 두면 비용과 장애가 터진다. 그래서 정책을 강제하는 게 좋다.

  • default StorageClass는 하나만 두거나, 민감한 클러스터에서는 아예 제거해서 storageClassName 명시를 강제한다.
  • Admission policy(Kyverno/Gatekeeper) 로 위험한 PVC를 막는다.
    • storageClassName 없는 PVC 금지
    • prod namespace에서 dev용 StorageClass 사용 금지
    • OpenSearch namespace에서 efs 사용 금지
    • 5Ti 이상 PVC 생성 시 approval label 요구
    • Retain 없는 prod PVC 금지, encrypted=false 금지
  • Quota 로 namespace별 PVC 총량(requests.storage, persistentvolumeclaims)을 제한한다.
  • Tagging 으로 EBS volume/snapshot/S3에 service, owner, environment, cost-center, data-classification, retention 같은 태그를 강제한다. (이건 나중에 비용 분석할 때 없으면 정말 곤란하다.)

전체 그림

지금까지의 이야기를 OpenSearch 기준으로 한 장에 모으면 대략 이런 구조가 된다.

결론

K8s platform을 AWS에 붙여서 Hot/Warm/Cold까지 운영한다면, PV 관리는 이렇게 가져가는 게 좋다.

  1. StorageClass를 tier별 SLA로 정의 한다. (ebs-gp3-hot, ebs-io2-critical, ebs-gp3-warm, efs-shared ...)
  2. OpenSearch data path는 EBS/local NVMe 중심 으로 둔다.
  3. Cold/Archive를 PVC로 억지 추상화하지 않는다. S3는 snapshot repository/data lake로, Glacier는 lifecycle 계층으로 본다.
  4. EFS/FSx/S3 CSI는 용도를 제한 한다.
  5. PV lifecycle과 data lifecycle을 분리 한다.

그런데 이 글 내내 "OpenSearch data path는 S3/EFS에 올리면 안 된다" 고 반복했으면서, 정작 왜 안 되는지는 "network filesystem이라 병목" 정도로만 넘어갔다. 3편에서는 이쪽을 다시 살펴보고, 다음글을 쓰러 도망을 시리즈를 끝내려고 한다.

클라우드의 스토리지 탐구 - (1) 티어링

· 약 24분
VSFe
블로그 주인장

이 글은 시리즈의 1편입니다. 3부작으로 생각하고는 있는데... 확정 된 부분은 아닙니다.

스토리지 티어링을 이야기하면, 많은 사람들이 이걸 그냥 "저장소를 싸게 바꾸는 기능" 정도로 이해한다. Hot은 비싸고 빠르고, Cold는 싸고 느리고, 그러니 안 쓰는 데이터는 Cold로 내리면 비용이 절감된다 — 딱 이 수준에서 멈춘다.

그런데 이 "싸질수록 느려진다" 라는 직관은 절반만 맞다. 정확히 말하면, 스토리지 티어링은 싸질수록 느려지는 게 아니라 싸질수록 읽기 비용·복구 지연·최소 보관 기간 제약이 커지는 쪽에 가깝다. 이 차이를 이해하지 못하면, "Cold로 내렸더니 오히려 청구 금액이 늘었다" 같은 황당한 일을 겪게 된다.

메모리 계층에 대해 기억하는가? 사실 CS에서는 슥 보고 넘어가지만, 이것을 파고들어보면, 의외로 할 이야기가 많다. 본격적인 클라우드의 스토리지를 바라보기 전, 스토리지 계층 구조부터 먼저 생각해보자.

스토리지 계층구조 다시 살펴보기

컴퓨터 구조를 공부했다면 이 그림이 익숙할 것이다.

위로 갈수록 빠르지만 비싸고 작고, 아래로 갈수록 느리지만 싸고 크다. 그리고 이 계층이 성립하는 단 하나의 이유는, 데이터 접근에 지역성(locality) 이 존재하기 때문이다. 자주 쓰는 건 위쪽 비싼 자리에, 안 쓰는 건 아래쪽 싼 자리에 넣는다 — 이 원칙은 CPU 캐시에서나, S3 스토리지 클래스에서나 완전히 동일하다.

즉, 스토리지 티어링은 새로운 개념이 아니라 바이트당 비용 vs 접근 지연 이라는 오래된 이야기를 디스크 너머 클라우드까지 늘린 것일 뿐이다. 그렇게 보면, "왜 싸질수록 이런저런 제약이 붙는가" 도 자연스럽게 설명된다.

레이턴시 - 읽는데 왜 오래 걸리지?

멀리갈 필요 없이, 매체의 물리적 특성 때문이다.

  • SSD/NVMe: 기계적 이동 부품이 없다. 셀에 전기적으로 접근하므로 random access 지연이 µs 단위로 균일하다.
  • HDD: 헤드를 옮기는 seek time + 플래터가 도는 rotational latency가 붙는다. 그래서 순차 접근은 빠르지만 random 접근은 급격히 느려진다. (나중에 EBS 타입을 언급할 때 다시 등장하니, 기억하도록 하자.)
  • 콜드 아카이브: Glacier 류의 "복구 지연" 은 인위적인 제약이 아니라, 비용을 극단적으로 낮추려고 매체를 평소엔 오프라인/저전력 상태로 둔 결과 다. 테이프든, 전원을 내려둔 디스크든, 읽으려면 일단 매체를 다시 "깨워서" 온라인으로 올려야 한다. 그래서 분~시간 단위의 restore가 필요한 것이다.

그러니까 "Cold는 느리다" 가 아니라, "Cold는 평소에 잠들어 있어서, 깨우는 데 시간이 든다" 가 정확한 표현이다.

왜 retrieval fee와 최소 객체 크기 제한이 있지?

  • retrieval fee (읽기 비용): 싼 티어는 "거의 안 읽는다" 는 가정 위에서 저장 단가를 후려친 것이다. 따라서 자주 읽으면 그 가정이 깨지므로, 읽을 때 비용을 물려서 자주 읽을 거면 위 티어를 쓰라 는 신호를 준다.
  • 최소 객체 크기 / 메타데이터 오버헤드: 오브젝트 스토리지는 객체마다 위치·체크섬·암호화 키 같은 메타데이터를 별도로 들고 있다. 객체가 128KB보다 작아지면 데이터보다 메타데이터 관리 비용이 더 커지는 구간이 생긴다. 그래서 작은 객체에 최소 과금 단위를 두거나, 콜드 티어 전환 시 객체당 오버헤드를 매긴다. 작은 파일 수억 개를 무작정 콜드로 보내면 안 되는 이유가 여기 있다.
    • 운영체제를 공부했다면, 내부 단편화에 대해 들어봤을 것이다. 그림이 그려지지 않는가?

정리하면, 단순히 느리다, 빠르다를 넘어 매체의 물리와 메타데이터 구조에서 나오는 필연 이라고 생각하자.

11-nines?

그런데 지금까지는 "비용은 접근 지연이다" 라는 이야기만 했는데, 한 가지 포인트가 더 있다. 바로 내구성(durability)과 가용성(availability) 이다.

비슷해보이지만, 엄밀히 다른 개념이니 한 번만 다시 보고 넘어가자.

  • 내구성 (durability): 한 번 저장한 데이터가 유실되지 않을 확률. "10년 뒤에도 그 비트가 그대로 있는가."
  • 가용성 (availability): 지금 이 순간 그 데이터에 접근할 수 있는 확률. "장애가 나는 중에도 읽히는가."

S3가 흔히 자랑하는 "11 nines" (99.999999999%) 는 내구성 이야기다. 객체 1천만 개를 저장하면 평균적으로 1만 년에 1개 잃을까 말까 한 수준이라는 소리다.

그런데 디스크는 늘 깨지는데 어떻게 이게 가능할까?

replication vs erasure coding

db를 공부했다면 너무 쉽게 답을 내놓을 수 있을 것이다. 바로 replica 를 따는 것이다. 스토리지에서도 마찬가지로 Replication 을 생각해 볼 수 있다. 같은 데이터를 3벌 복사해 서로 다른 디스크/랙/AZ에 둔다. 하나 깨져도 나머지로 복구하면 되니까. 직관적이지만 한가지 치명적인 문제가 있다면, 저장 공간이 3배 (200% 오버헤드) 든다.

그래서 대규모 오브젝트 스토리지는 보통 erasure coding(EC) 을 쓴다. 데이터를 k 개 조각으로 나누고, 거기에 m 개의 패리티 조각을 더 만든다. (Reed-Solomon 등.) 그러면 k+m 개 조각 중 임의의 m 개가 날아가도 원본을 복구 할 수 있다. 예를 들어 10+4 구성이면 14개 중 4개가 깨져도 멀쩡한데, 저장 오버헤드는 40%에 불과하다. RAID의 패리티를 떠올리면 정확히 같은 원리다.

(여기서도 트레이드오프가 있다. EC는 조각을 나누고 패리티를 붙이는 자체에 고정 비용이 있어서, 작은 객체엔 비효율적 이다. 또 그놈의 작은 객체 문제다...)

AZ는 가용성의 단위다

그럼 One Zone 이 붙은 클래스는 왜 더 싼가? 여기서 AZ(Availability Zone)가 등장한다. AZ는 물리적으로 분리된 데이터센터(전원·냉각·네트워크가 독립)이고, 클라우드의 가용성은 보통 이 AZ 단위로 설계된다.

  • S3 Standard / Standard-IA: 데이터를 여러 AZ에 분산 저장한다. AZ 하나가 통째로 날아가도(화재, 정전...) 데이터가 살아있다.
  • One Zone-IA / Express One Zone: 이름 그대로 단일 AZ 에만 저장한다. 그래서 더 싸다.

즉 One Zone이 싼 이유는 "성능을 깎아서" 가 아니라 AZ 장애에 대한 내성을 포기해서 다. 그 AZ가 사라지면 데이터도 같이 사라진다. 그래서 One Zone은 "원본이 어딘가 따로 있어서 언제든 재생성 가능한 데이터" (예: 원천 데이터를 가공한 캐시성 결과물) 에만 써야 한다.

정리하면, 스토리지를 고를 때 우리는 사실 비용 / 접근 지연 / 내구성·가용성 이라는 최소 3개 축 위에 점을 찍는 것이다.

결국 싼 스토리지라면, 무언가 하나를 희생한다는 이야기라고 할 수 있다.

이제 이 원칙들이 AWS에서 어떻게 구현됐는지 케이스로 보자.

Case Study 1: AWS의 스토리지 계층

흔히 우리는 스토리지를 말할 때 Hot/Warm/Cold/Archive로 4분류를 하는데, 이건 개념적인 틀 이지 AWS의 공식 제품 라인이 아니다. 그리고 이 틀 안에 S3 계열과 EBS 계열이 섞여 있는데, 이 둘은 성격이 완전히 다른 물건이다.

  • S3의 Storage Class객체 저장 비용과 접근 빈도 를 최적화하는 선택지다. → 위에서 언급한 계층 구조에서, 오브젝트 스토리지와 콜드 스토리지 쪽이라고 할 수 있다.
  • EBS의 volume type블록 디바이스의 성능/비용 을 고르는 선택지다. → S3랑은 다른 개념이라고 봐야하는 것이다.

같은 "티어" 라는 단어를 써도, 전자는 "얼마나 자주 꺼낼 거냐", 후자는 "초당 몇 번의 I/O를 견뎌야 하냐" 의 문제다. 분리해서 보자.

S3 Storage Class

S3는 객체마다 Storage Class를 가질 수 있고, 아무것도 지정하지 않으면 기본은 S3 Standard 다.

계층AWS Storage Class성격
FrequentS3 Standard자주 접근, 밀리초 단위 접근
Low-latency objectS3 Express One Zone단일 AZ, 낮은 지연의 object storage
Auto-tierS3 Intelligent-Tiering접근 패턴이 불명확할 때 자동 티어링
InfrequentS3 Standard-IA드물게 접근하지만 즉시 읽어야 하는 데이터
Infrequent / cheaperS3 One Zone-IA단일 AZ, 재생성 가능한 데이터에 적합
ColdS3 Glacier Instant Retrieval거의 안 읽지만 즉시 읽기는 필요
ArchiveS3 Glacier Flexible Retrieval분 ~ 시간 단위 복구 허용
Deep ArchiveS3 Glacier Deep Archive장기 보관, 긴 복구 시간 허용

앞 절의 "retrieval fee" 가 그대로 등장한다. Standard-IAOne Zone-IA 는 밀리초 접근이 가능하지만,

  • 읽을 때마다 retrieval fee 가 GB당 붙고,
  • 128KB 미만의 작은 객체도 128KB로 과금 되며 (위에서 말한 메타데이터 오버헤드),
  • 30일 최소 보관 기간 이 있다.

즉, 자주 읽거나 작은 객체가 많은 데이터를 무작정 IA로 내리면 오히려 비용이 늘어날 수 있다. 작은 데이터가 많다면? 자주 조회한다면?

Glacier 계열은 "그냥 싼 S3" 가 아니다

S3 Glacier Flexible Retrieval 이나 Deep Archive 에 들어간 객체는 실시간으로 바로 읽을 수 없다. 먼저 restore 요청을 해서 임시 복사본을 만들어야 한다. (이게 바로 앞에서 말한 "잠든 매체를 깨우는" 과정이다. Glacier Instant Retrieval 만은 예외적으로 밀리초로 바로 읽힌다.)

복구 시간은 티어별로 천차만별이다.

계층복구 특성
Glacier Instant Retrieval밀리초 접근
Glacier Flexible Retrieval - Expedited보통 1~5분 (250MB 미만 객체 기준)
Glacier Flexible Retrieval - Standard보통 3~5시간
Glacier Flexible Retrieval - Bulk보통 5~12시간
Glacier Deep Archive - Standard보통 12시간 이내
Glacier Deep Archive - Bulk보통 48시간 이내

여기에 최소 보관 기간이 더해진다. Glacier Instant / Flexible Retrieval은 90일, Deep Archive는 180일 이다. 이 기간 전에 삭제·전환하면 조기 삭제 비용이 발생하고, 전환 시 객체당 메타데이터 오버헤드도 붙는다. 그래서 작은 객체를 무작정 Glacier로 보내는 것 도 좋은 선택이 아니다.

결론적으로 Glacier는 "싸지만 느린 S3" 가 아니라 복구라는 절차를 거쳐야 다시 쓸 수 있는 보관소 다. 다시 말하면 복구 작업을 하면 할 수록 청구서에는 높은 금액이 뜰 것이다.

Intelligent-Tiering - 자동화는 또 돈...

접근 패턴을 모를 때 유용한 게 S3 Intelligent-Tiering 이다. 객체가 30일 동안 접근되지 않으면 Infrequent Access tier로, 90일 동안이면 Archive Instant Access tier로 자동으로 내려간다. (옵션을 켜면 Deep Archive까지 내려간다.)

사람이 "이 데이터는 30일 뒤부터 cold다" 라고 미리 정하는 게 Lifecycle 정책 이라면, Intelligent-Tiering은 실제 접근 패턴을 보고 S3가 알아서 비용을 최적화하는 방식이다.

다만 객체당 모니터링 비용이 따로 붙고, 128KB 미만 객체는 애초에 auto-tiering 대상이 아니라 Frequent Access tier에 남는다. 작은 객체가 수억 개면 모니터링 비용도 괴랄해질 것이다.

EBS volume type - HDD 를 다시 살펴보자.

이제 블록 스토리지인 EBS다. HDD 의 특징이었던 Random Access 에 약하고, Sequential 에 강한 특징은 여기서 그대로 적용된다.

계층EBS 타입매체성격
General SSDgp3SSD일반적인 트랜잭션 워크로드
Provisioned IOPS SSDio2 Block ExpressSSD높은 IOPS, 낮은 latency, I/O intensive DB
Throughput HDDst1HDDbig data, log processing, 대용량 순차 처리
Cold HDDsc1HDD낮은 비용, 드물게 접근하는 throughput-oriented 데이터
  • SSD 계열(gp3, io2) 은 작은 I/O가 많은 transactional workload, 즉 IOPS(random 접근) 가 중요할 때 쓴다.
  • HDD 계열(st1, sc1) 은 대용량 streaming workload, 즉 throughput(순차 접근) 이 중요할 때 쓴다.

왜냐고 묻는다면 당연히 HDD의 seek/rotational latency 때문에 random I/O가 비싸고 sequential I/O가 싸다는 것.

구체적 성능 한계는 이렇다. gp3는 baseline 3,000 IOPS / 125 MiB/s에서 볼륨당 최대 16,000 IOPS / 1,000 MiB/s 까지, io2 Block Express는 볼륨당 최대 256,000 IOPS / 4,000 MiB/s 까지다.

그러다보니 HDD인 st1/sc1을 "Warm/Cold tier니까" 라는 이유만으로 고르면 안 된다. 작은 random I/O가 많은 워크로드(예: 검색 인덱스)에는 오히려 독이다. 2-3부에서 Opensearch 이야기를 하면서 좀 더 자세히 다뤄보자.

Case Study 2: Do The Math - 직접 계산기를 뚜드려보자.

앞에서 청구서 이야기를 했으니, 진짜로 계산기를 뚜드려보자. 1TB(≈1,000GB)의 로그 를 한 달간 저장한다고 하고, 세 가지 클래스를 비교한다.

다만, 실제 가격은 리전·시점마다 다르고 자주 바뀐다. 아래 숫자는 정확한 금액이 아니라 구조를 보여주기 위한 대표값 이며, 실제 산정은 AWS Pricing Calculator로 해야 한다. 여기서 봐야 할 건 액수가 아니라 얼마나 빠르게 오르는지 다.

대표 단가를 이렇게 잡자. (GB-월 기준, 그리고 retrieval은 GB당 읽기 비용.)

클래스저장 단가읽기(retrieval) 단가
S3 Standard$0.023$0 (per-GB retrieval 없음)
S3 Standard-IA$0.0125$0.01
S3 Glacier Flexible Retrieval$0.0036$0.01 (+ restore 요청비 · 3~5시간 대기)

저장만 보면 당연히 Glacier가 압도적으로 싸다. 그런데 여기에 "한 달에 이 1TB 전체를 몇 번이나 읽느냐(N)" 를 넣으면 그림이 뒤집힌다.

월 전체 읽기 횟수 NS3 StandardStandard-IAGlacier FR
0회$23.0$12.5$3.6
1회$23.0$22.5$13.6
2회$23.0$32.5$23.6
3회$23.0$42.5$33.6

보다시피, 전체를 한 달에 한 번꼴로만 읽어도 Standard-IA는 이미 Standard보다 비싸진다. 크로스오버 지점을 계산하면 IA는 약 1.05회, Glacier는 약 1.9회다. 즉 IA/Glacier가 이득인 구간은 "전체를 한 달에 한 번도 안 읽다시피 하는" 아주 좁은 영역뿐이다. 게다가 Glacier는 거기에 3~5시간 복구 대기와 90일 최소 보관까지 얹힌다.

이게 retrieval fee가 admission control이라고 했던 말의 정량적 실체다. AWS는 은근 슬쩍 가격을 통해 "자주 읽을 거면 위 티어 쓰라" 고 유혹하고 있따.

작은 객체 - 더 파멸적!

여기에 1편 앞에서 본 최소 객체 크기 과금(128KB) 을 얹어보자. 로그를 10KB짜리 객체 하나하나로 IA에 올린다고 해보자.

  • 10KB 객체도 IA에선 128KB로 과금 된다. → 실제 저장량이 약 12.8배로 부풀어 오른다.
  • 1TB가 10KB 객체 약 1억 개라면, 과금 기준으로는 약 12.8TB 어치 저장료가 나간다.

IA로 내려서 아낀 저장 단가가, 작은 객체 페널티 한 방에 통째로 날아간다. (그래서 로그는 객체 하나하나 올리는 게 아니라, 묶어서 큰 파일(Parquet 등)로 만들어 올리는 것이다. 이건 다음 Case Study로 이어진다.)

Case Study 3: 로그 플랫폼에 대입하면

이 시리즈가 결국 OpenSearch / 로그 플랫폼을 염두에 둔 글이다 보니, 위 원칙을 로그 데이터의 생애주기에 대입해보면 그림이 선명해진다.

핵심은, 같은 "로그" 라는 데이터라도 시간이 지나며 접근 패턴(지역성)이 바뀌고, 따라서 적합한 티어도 바뀐다 는 것이다. 최근 로그는 자주·랜덤하게·빠르게 검색해야 하니 비싼 block storage에, 오래된 로그는 가끔 분석/감사용으로만 꺼내니 object storage에, 더 오래되면 "검색" 이 아니라 "보관" 의 영역이니 Glacier로. 메모리 계층에서 cache line이 evict되어 RAM으로, 다시 디스크로 내려가는 것과 정확히 같은 운동이다.

결론

간단하게 정리하면...

클라우드 스토리지 계층 구조는 단순히 "저장소를 싸게 바꾸는 기능" 이 아니다. 비용 / 접근 지연 / 내구성·가용성 이라는 축 위에서, 데이터의 접근 빈도, 복구 지연 허용치, IOPS 요구사항, 객체 크기, 최소 보관 기간, retrieval fee 를 함께 고려해 Hot / Warm / Cold / Archive로 배치하는, 메모리 계층의 클라우드 버전 이다.

다음 2편에서는, 이 티어링을 Kubernetes 위에 올릴 때 어떤 일이 벌어지는지 살펴보고, 좀 더 많은 이야기를 해보자.