우리가 자체 “S3”를 구축해 연간 50만 달러를 절감한 방법

1 week ago 12

  • 나니트(Nanit)는 아기 수면 상태 분석용 비디오 처리 파이프라인에서 AWS S3를 사용했으나, 초당 수천 건의 업로드로 인해 PutObject 요청 비용이 전체 비용의 대부분을 차지함
  • 또한 S3 Lifecycle 규칙의 최소 1일 보존 제한으로, 실제로는 2초 내 처리되는 영상에 대해 24시간 저장 요금을 지불해야 했음
  • 이를 해결하기 위해 Rust 기반 인메모리 스토리지 시스템 N3를 구축, S3는 오버플로 버퍼로만 사용
  • N3는 SQS FIFO를 통해 기존 처리 파이프라인과 완전히 호환되며, 엄격한 순서 보장신뢰성을 유지
  • 결과적으로 연간 약 50만 달러의 비용 절감과 함께, 단순하면서도 안정적인 구조를 확보함

배경 및 기존 구조

  • 기존 파이프라인은 카메라가 영상을 S3에 직접 업로드하고, Lambda → SQS FIFO → 처리 파드로 이어지는 구조
    • 카메라 서비스가 S3 presigned URL을 발급
    • Lambda가 업로드된 객체 키를 SQS FIFO 큐에 게시
    • 처리 파드가 S3에서 영상을 내려받아 수면 상태 추론 수행
  • 이 구조의 장점은 업로드와 처리의 완전한 분리, 자동 확장성, S3의 내구성 및 가용성 보장, SQS를 통한 순서 유지
  • Lifecycle 규칙으로 하루 후 자동 삭제되어, 별도의 GC 관리가 불필요했음

변경 이유

  • PutObject 요청 요금이 가장 큰 비용 요인으로 확인됨
    • 초당 수천 건 업로드 시, 객체당 요청 요금이 누적되어 급격히 증가
    • 영상 조각(chunk)을 더 작게 나누면 지연은 줄지만 비용은 선형 증가
  • 저장 비용도 문제였음
    • 처리 완료까지 2초 미만이지만, Lifecycle 최소 1일로 인해 24시간 요금 부과
  • 목표는 순서 보장과 신뢰성 유지하면서, 객체당 요금 없는 경로(happy path) 를 확보하는 것

설계 원칙 및 요구사항

  • 단순성: 복잡한 구현 대신 아키텍처 수준에서 단순화
  • 정확성: 기존 파이프라인에 완전 호환되는 drop-in 대체재
  • 효율성: 정상 경로는 N3에서 처리, 예외 상황만 S3로 폴백
  • 설계 제약
    • 객체는 수 초 단위 생존, 엄격한 순서 보장, 초당 수천 업로드
    • 카메라 펌웨어 변경 불가, 소규모 손실 허용, 대규모 백로그 허용
    • 비용 최소화: S3 요청 및 대기 저장 비용 제거

N3 아키텍처 개요

  • N3는 메모리 기반 임시 저장소로, 영상이 처리되기 전까지 약 2초간만 보관
  • S3는 N3가 과부하일 때만 사용
  • 구성 요소
    • N3-Proxy (무상태) : 외부 업로드 수신 및 내부 URL 발급
    • N3-Storage (상태 보유) : RAM에 영상 저장 후 SQS에 다운로드 URL 게시
  • 처리 파드는 SQS에서 URL을 받아 N3 또는 S3에서 다운로드

정상 경로 (Happy Path)

  1. 카메라가 업로드 URL 요청
  2. 카메라 서비스가 N3-Proxy 내부 API 호출
  3. 카메라가 N3-Proxy 외부 엔드포인트로 업로드
  4. N3-Proxy가 N3-Storage로 전달
  5. N3-Storage가 영상을 메모리에 저장하고 SQS에 URL 게시
  6. 처리 파드가 N3에서 다운로드 후 처리

2단계 폴백 구조

  • 1단계(요청 단위) : N3-Storage가 업로드를 수용하지 못하면 N3-Proxy가 대신 S3로 업로드
  • 2단계(클러스터 단위) : N3 전체가 비정상일 경우, 카메라 서비스가 직접 S3 URL을 발급

구성 분리 이유

  • 장애 격리: Storage 장애 시 Proxy는 S3로 우회 가능
  • 리소스 분리: Proxy는 CPU/네트워크 중심, Storage는 메모리 중심
  • 보안성: Storage는 외부 네트워크 미노출
  • 배포 안정성: Proxy는 무상태로, 업데이트 시 데이터 손실 없음

설계 검증 및 PoC

  • 주요 불확실성: 용량 산정, RAM만으로 충분한지, 노드 장애 처리, GC 정책, 재시도 동작
  • 두 가지 접근 병행
    • Synthetic Stress Test: 부하·지연·다운타임 시 한계점 측정
    • Production PoC (Mirror Mode) : 실제 트래픽 복제, S3와 N3 병행 기록
      • 특정 카메라 그룹만 테스트, Unleash로 실시간 전환
      • 지표 대시보드로 N3/S3 경로 비교

주요 발견

  1. TLS 종료가 CPU의 주요 병목, AWS 버스트 네트워크 제한 존재
  2. RAM 기반 저장소 충분, 디스크 불필요
  3. Delete-on-GET 안전, 재다운로드 없음
  4. TTL 기반 GC 필요, 처리 누락 세그먼트 정리

구현 세부 사항

DNS 기반 로드밸런싱

  • n3-proxy는 DaemonSet으로 노드당 1개 배치
  • Route53 Multi-Value A 레코드로 저비용 DNS 부하분산 구성
    • 각 노드별 IP 1개, 30초 TTL, 헬스체크 기반 자동 제거
  • 롤아웃 시 SIGTERM → Not Ready → 연결 드레인 → 재시작 절차로 무중단 배포

네트워크 성능 문제

  • 초기 벤치마크에서 1분 후 RPS가 1k → 70으로 급감
  • 원인: AWS의 버스트형 네트워크 크레딧 소진
  • 해결: c8gn.4xlarge (50Gbps) 네트워크 최적화 인스턴스로 전환

HTTPS 최적화

  • 초기 stunnel 사용 시 CPU 병목 발생
  • 개선 사항
    1. rustls로 전환
    2. Graviton4 인스턴스 업그레이드
    3. Rust 컴파일 최적화 플래그 적용
  • 결과: 동일 비용 대비 RPS 30% 향상

아웃바운드 트래픽 분석

  • 업로드만 수행하므로 egress가 적을 것으로 예상했으나, TLS 핸드셰이크 및 ACK로 트래픽 발생
    • TLS 인증서 전송 약 7KB
    • tcpdump 결과, 출력 바이트의 84%가 ACK 프레임
  • TCP 타임스탬프 비활성화로 ACK 크기 12바이트 절감
    • 단, 시퀀스 번호 래핑 위험 존재 → 업로드 단위 소켓 재생성, 1GB 전송 후 소켓 재활용으로 완화

메모리 누수 문제

  • n3-proxy 메모리 지속 증가, jemalloc 분석 결과 hyper BytesMut 버퍼 누적
  • 원인: 중단된 업로드 연결 미정리
  • 해결
    • 10분 이상 유휴 연결 종료
    • keep-alive 비활성화, 타임아웃 강화
    • 업로드 완료 후 즉시 소켓 종료

스토리지 구조

  • 단순한 인메모리 DashMap 사용
    • 업로드 시 bytes_used 증가, 다운로드 시 감소
    • 80% 용량 초과 시 업로드 거부 및 Proxy에 신호 전달
  • Control 핸들로 업로드·GC 일시 중지 가능

그레이스풀 재시작

  • 메모리 기반이라 재시작 시 데이터 손실 방지 필요
    • SIGTERM 수신 → Not Ready → 기존 다운로드만 처리 → 요청 종료 후 재시작
    • 일반적으로 수 초 내 드레인 완료

GC 메커니즘

  • 다운로드 시 즉시 삭제(Delete-on-GET)
  • TTL 기반 GC로 미처리 세그먼트 정리
  • 유지보수 모드에서는 GC 일시 중단 가능

결론 및 교훈

  • N3를 기본 경로, S3를 폴백 버퍼로 사용해 연간 약 50만 달러 절감
  • 핵심 통찰: 단명 객체(2초 내 처리)에 대해 완전한 내구성은 불필요, 인메모리 저장소로 충분
  • S3는 장기 보관·장애 시 신뢰성 보장, 두 시스템의 장점을 결합
  • 명확한 제약 정의Mirror PoC 검증이 과도한 설계 방지에 핵심 역할
  • 직접 구축이 유효한 경우
    • 충분한 트래픽 규모로 비용 절감 효과가 명확할 때
    • 단순한 요구 조건으로 유지보수 부담이 낮을 때
  • 나니트는 현재도 이 시스템을 안정적으로 운영 중이며, 복잡성 없이 신뢰성을 확보한 사례로 평가됨

Read Entire Article