Reddit

Breaking down how Cloudflare cut 100TB of memory from their DNS cache with Rust memory layout tricks

Cloudflare가 Rust 메모리 레이아웃 최적화로 DNS 캐시에서 100TB를 줄인 방법 분석

Cloudflare의 Rust 기반 DNS 리졸버 Big Pineapple이 캐시 엔트리의 메모리 레이아웃을 최적화해 엔트리당 메모리를 56% 줄이고, 전체 플릿에서 약 100TB의 메모리를 절감한 사례를 분석한 글입니다. Vec·String을 Box 슬라이스로 교체하고, enum의 대형 variant를 박싱하며, 레코드를 raw bytes로 저장하는 하이브리드 방식이 핵심입니다.

본문

Cloudflare의 공개 DNS 리졸버 1.1.1.1을 비롯한 여러 서비스의 엔진인 Big Pineapple은 2023년에 공개됐지만, 그 이전부터 기존 Knot Resolver를 Rust 기반 엔진으로 점진적으로 교체해 왔습니다. 교체의 동기는 플러그인의 blocking I/O 감소, 효율적인 캐시 사용, 디버깅과 메모리 안전성을 위한 플러그인 격리였습니다. 이 글은 Cloudflare가 공개한 작업 내용을 바탕으로, 캐시 저장 용량을 56% 줄여 약 100TB를 절감한 Rust 최적화 기법을 분석합니다. 참고로 Cloudflare의 DNS 캐시 엔트리는 2,500억 개 규모라, 엔트리당 단 1바이트만 추가돼도 250GB의 오버헤드가 됩니다.

Vec<T>와 String을 Box 슬라이스로

Rust의 Vec<T>는 힙 포인터(ptr), 길이(len), 용량(capacity) 세 요소로 구성돼 64비트 시스템에서 인스턴스당 24바이트의 오버헤드를 갖습니다. 그런데 Big Pineapple 캐시에 저장된 DNS 응답은 한번 저장되면 수정되지 않으므로, capacity 필드와 capacity만큼 미리 할당되는 힙 공간은 두 가지 방식으로 낭비가 됩니다. String도 내부적으로 Vec를 포함하므로 마찬가지입니다. Cloudflare는 이들을 Box<[T]>와 Box<str>로 교체했습니다. 박스드 슬라이스는 포인터와 길이만 담는 fat pointer라 capacity가 없으며, 8개 필드를 이 방식으로 바꿔 엔트리당 64바이트, 플릿 전체로 15TB를 절감했습니다.

세 리스트를 하나로 합치기

value struct의 answers, authority, additional 필드는 원래 세 개의 Vec<Record>였습니다. 캐시된 응답이 수정되지 않는다는 전제 아래, Cloudflare는 세 리스트를 하나의 Box<[Record]>에 합치고 두 개의 u16 오프셋으로 두 번째·세 번째 리스트의 시작 위치를 표시했습니다. u16을 쓴 이유는 RFC 1035가 ANCOUNT, NSCOUNT, ARCOUNT를 부호 없는 16비트 정수로 규정하기 때문입니다. 계산상 세 개의 박스드 슬라이스는 48바이트 오버헤드였지만, 합치면 16바이트(Box 포인터+len)에 4바이트(오프셋)만 더해 20바이트로 줄어 28바이트가 절감됩니다.

bitflag 패킹과 owner 이름 생략

Rust는 각 타입에 정렬(alignment) 요건이 있어 필드 사이에 패딩이 들어가는데, Cloudflare는 여러 불리언을 하나의 bitflag로 패킹해 패딩까지 줄여 실제 필드 크기 이상의 절감을 얻었습니다. 또한 RFC 1035의 Resource Record에는 owner name이 포함되지만, 상당수 캐시 레코드에서 owner 이름이 쿼리된 도메인과 동일하다는 점에 주목했습니다. 일치하면 owner 이름을 저장하지 않고 응답 생성 시 쿼리에서 동적으로 재구성하고, CNAME처럼 다른 경우에만 저장합니다. 코드 레벨에서는 Option<T>를 써서 일치 시 None, 불일치 시 Some(T)로 힙의 이름을 가리키게 했고, 대부분의 경우 힙 할당을 피할 수 있었습니다.

enum의 대형 variant 박싱

Rust의 enum은 가장 큰 variant 크기만큼 메모리를 쓰므로, 작은 variant도 같은 공간을 강제 할당받습니다. 가장 흔한 A와 AAAA 레코드는 각각 12B, 24B에 불과한 반면, 매우 드문 NAPTR은 태그 포함 144B가 필요했습니다. A·AAAA가 전체의 거의 80%를 차지하므로 각각 11배, 5배의 메모리가 패딩으로 낭비됐습니다. Rust 문서가 권장하는 대로 대형 variant를 Box로 감싸 힙에 두고 스택에는 포인터만 두게 했고, 앞의 Option 최적화에서도 Some(T) 크기 때문에 None이 패딩을 강제받지 않도록 Name을 먼저 박싱했습니다.

jemalloc과의 싸움: 할당자 오버헤드와 메모리 지역성

Big Pineapple은 jemalloc을 사용하는데, jemalloc은 할당을 미리 정의된 size class로 묶어 내부·외부 단편화 사이에서 균형을 잡는 설계입니다. 박싱이 늘면 힙 할당이 늘어 size class 라운딩과 배치 결정이라는 할당자 오버헤드가 커지고, 레코드 메모리가 힙 여기저기 흩어져 포인터를 따라다니는 홉이 추가돼 메모리 지역성도 나빠집니다. 중간 대안으로 레코드 전체를 wire format 그대로 저장하는 방법이 있지만, DO(DNSSEC) 플래그에 따라 두 버전을 저장하거나 실시간 필터링이 필요해 이중 저장이나 추가 파싱이라는 문제가 있었습니다.

최종 하이브리드 방식

최종적으로는 레코드 데이터만 raw bytes로 저장하고 나머지는 구조화된 필드로 유지하는 하이브리드를 채택했습니다. 파싱된 enum 리스트 대신, 캐시 엔트리에 연결된 모든 응답을 하나의 Box<[u8]>에 2바이트 길이 오프셋과 바이트열로 이어 붙여 저장합니다. 캐시 삽입 간 재사용 가능한 scratchspace 버퍼에 길이 접두사와 레코드 바이트를 계속 추가한 뒤, 정확한 크기의 Box<[u8]>를 할당해 memcpy하고 다음 삽입에서 버퍼를 재사용합니다. 이로써 내부 단편화, enum variant 오버헤드, 반복 박싱에 따른 다중 힙 할당이 모두 해소되고, 데이터가 연속 저장돼 CPU 캐시 효율도 좋아졌습니다. 대부분의 레코드 타입은 응답 생성 시 바이트를 그대로 복사하면 되며, 남은 비용은 A·AAAA의 라운드 로빈 순환을 위한 순차 순회와 CNAME·MX·NS·SOA 등의 Domain Name Compression 파싱 정도로, 둘 다 전체 레코드 중 일부라 비용이 낮습니다.

결과

하이브리드 전략으로 캐시 조회 지연이 약 5% 줄었고, 재사용 버퍼와 Box<[u8]>로 캐시 처리량이 약 15% 늘었습니다. p99 레지던트 메모리는 9.3GB에서 5.3GB로 43%, p90은 6.5GB에서 3.8GB로 42% 감소했습니다. 벤치마크 기준 엔트리당 메모리는 953바이트에서 420바이트로 56%, 할당량은 1.1KB에서 461바이트로 줄었고, 프로덕션에서는 프로세스 데이터 등이 포함돼 비율은 낮았지만 플릿 전체 작업 메모리가 약 100TB 감소했습니다. 캐시 삽입 처리량은 43% 증가, 조회 지연은 19% 감소했습니다. 개별 최적화는 작아 보여도 Cloudflare 규모에서는 복리처럼 쌓여 저장 공간 외의 성능 이점으로 이어졌습니다.

Reddit 반응

  • @u/VictoryMotel — 원문이 좋은 글이라는 점은 인정하면서도, 왜 같은 내용을 숨겨진 게시 이력의 새 계정으로 자기 사이트에 다시 올리냐고 재게시 행위 자체를 문제 삼았습니다.
  • @schungx — 대기업은 태울 돈이 많다며, 규모가 작은 사업자였다면 이런 최적화는 첫날부터 했을 것이라고 지적했습니다.

원문: Substack / 번역·요약: Trawling