1바이트가 250GB가 될 때: Cloudflare DNS 캐시 메모리 최적화
Cloudflare가 1.1.1.1 DNS 캐시의 엔트리 구조를 바꿔 메모리 100TB를 줄이면서 처리 성능까지 높인 과정을 정리한다.
Cloudflare의 DNS 플랫폼에는 항상 2,500억 개가 넘는 캐시 엔트리가 저장되어 있다. 이 규모에서는 엔트리마다 1바이트만 낭비해도 전체 시스템에서 250GB가 넘는 메모리를 사용하게 된다.
Cloudflare는 1.1.1.1을 포함한 DNS 서비스의 캐시 구조를 다섯 번에 걸쳐 개선했다. 그 결과 엔트리당 메모리 사용량은 953바이트에서 420바이트로 줄었고, 전체 서버에서 약 100TB의 메모리를 확보했다. 더 흥미로운 점은 메모리만 줄어든 것이 아니라는 것이다. 캐시 삽입 처리량은 43% 증가했고 조회 지연 시간은 19% 감소했다.
이 글은 Cloudflare의 How we saved 100 terabytes of memory by optimizing 1.1.1.1’s DNS cache를 읽고, 어떤 데이터 구조의 선택이 이런 차이를 만들었는지 정리한 글이다.
먼저 캐시의 특성을 확인했다
Cloudflare의 DNS 플랫폼인 Big Pineapple은 DNS 질의가 들어오면 응답을 캐시에 저장한다. 캐시 키에는 질의 도메인, 레코드 타입, 인증 여부 같은 정보가 들어가고, 값에는 DNS 응답과 TTL, 생성 시각, 조회 횟수 등이 저장된다.
중요한 특성은 다음 한 문장으로 정리할 수 있다.
DNS 응답은 캐시에 삽입된 뒤 수정되지 않는다.
기존 구조는 데이터를 만드는 동안에는 편리했지만, 저장이 끝난 뒤에도 변경 가능성을 위한 비용을 계속 지불하고 있었다. Cloudflare의 최적화는 이 불일치를 하나씩 제거하는 과정이었다.
1. 변경되지 않는 Vec를 Box<[T]>로 바꾸기
Rust의 Vec<T>는 다음 세 정보를 가진다.
- 힙 데이터의 주소
- 현재 원소 수인
length - 재할당 없이 담을 수 있는 원소 수인
capacity
capacity는 원소를 계속 추가할 때 필요하다. 하지만 캐시에 저장된 DNS 응답은 더 이상 커지지 않는다. 저장 이후에는 사용되지 않는 8바이트짜리 필드와 여분으로 할당된 공간만 남는다.
1
2
3
4
5
// 생성 중에는 크기가 변할 수 있다.
let records: Vec<Record> = build_records(response);
// 캐시에 넣을 때는 정확한 크기로 고정한다.
let cached_records: Box<[Record]> = records.into_boxed_slice();
문자열도 같은 원리로 String 대신 Box<str>로 고정할 수 있다. Cloudflare는 캐시 엔트리 안의 Vec와 String 필드 8개를 고정 크기 구조로 바꿨다. 필드마다 8바이트, 엔트리마다 64바이트를 줄였고 과도하게 예약된 힙 공간까지 제거했다. 전체 규모에서는 이 변경만으로 15TB가 넘는 메모리가 절약됐다.
여기서 핵심은 Box가 Vec보다 항상 낫다는 것이 아니다. 수정 가능한 생성 단계와 불변인 저장 단계를 서로 다른 타입으로 표현한 것이 핵심이다.
2. 세 개의 목록을 하나의 연속된 목록으로 합치기
DNS 응답은 answer, authority, additional이라는 세 영역으로 나뉜다. 기존 캐시는 각 영역을 별도의 목록으로 저장했다. 목록마다 포인터와 길이가 필요하므로, 데이터가 적어도 관리 정보는 세 벌이 필요했다.
Cloudflare는 모든 레코드를 하나의 연속된 목록에 넣고 각 영역이 시작되는 위치만 저장했다.
1
2
3
4
5
struct CachedRecords {
records: Box<[Record]>,
authority_start: u16,
additional_start: u16,
}
각 영역의 레코드 수는 u16 범위에 들어가기 때문에 2바이트 오프셋이면 충분했다. 두 개의 목록을 제거하고 두 개의 오프셋으로 바꾸면서 엔트리당 28바이트를 줄였다.
여러 bool 필드도 하나의 bit flag로 묶었다. 이 변경은 단순히 필드 크기만 줄인 것이 아니다. Rust 구조체가 메모리 정렬을 위해 삽입하는 padding까지 줄어들면서 실제 구조체 크기가 더 작아졌다.
3. 반복되는 도메인 소유자 이름 저장하지 않기
DNS 레코드에는 해당 레코드가 속한 도메인인 owner가 있다. 그런데 일반적인 A 레코드 응답에서는 owner와 질의한 도메인이 같다.
1
2
3
4
질의: example.com A
example.com. 300 IN A 198.51.100.1
example.com. 300 IN A 198.51.100.2
기존 캐시는 각 레코드에 example.com을 반복해서 저장했다. Cloudflare는 owner가 캐시 키의 질의 도메인과 같으면 저장하지 않고, 조회할 때 이미 가지고 있는 캐시 키에서 복원하도록 바꿨다.
1
2
3
4
5
struct Record {
// None이면 캐시 키의 질의 도메인을 사용한다.
owner: Option<Box<Name>>,
data: RecordData,
}
CNAME처럼 실제 owner가 다른 경우에만 전체 이름을 저장한다. 레코드 하나만 보면 자기완결성이 줄어들지만, 캐시 조회 경로에는 항상 키가 함께 존재하므로 별도 조회나 추가 할당은 필요하지 않다.
이 변경은 모든 중복 문자열을 전역에서 관리하는 복잡한 interning 구조를 도입하지 않았다. 이미 조회 경로에 존재하는 값을 재사용하는 것으로 문제를 끝냈다.
4. 가장 큰 enum variant에 맞춰지는 비용 줄이기
Rust의 enum은 가장 큰 variant를 담을 수 있는 크기로 만들어진다. DNS 레코드 데이터를 하나의 enum으로 표현하면 작은 A 레코드도 가장 큰 레코드 타입의 영향을 받는다.
Cloudflare의 기존 구조에서 가장 큰 NAPTR 데이터는 136바이트였고, variant 구분 값과 padding을 포함한 enum 전체는 144바이트였다. 반면 IPv4 주소를 담는 A 레코드는 4바이트, AAAA 레코드는 16바이트면 충분했다. 실제 트래픽에서 두 타입이 80% 이상을 차지했기 때문에 대부분의 레코드가 120바이트가 넘는 빈 공간을 가지고 있었다.
첫 번째 해결 방법은 작고 자주 사용되는 variant만 enum 안에 두고, 큰 variant를 Box로 힙에 옮기는 것이었다.
1
2
3
4
5
6
enum RecordData {
A(Ipv4Addr),
Aaaa(Ipv6Addr),
Txt(Box<Txt>),
Naptr(Box<Naptr>),
}
이렇게 하면 A와 AAAA는 레코드마다 120바이트를 절약한다. 드물게 등장하는 가장 큰 타입은 포인터와 별도 할당 비용이 늘지만, 실제 데이터 분포를 기준으로 보면 전체 시스템에는 이득이었다.
하지만 boxing에도 비용이 있다.
- 작은 할당이 늘어나 allocator의 크기 반올림과 관리 비용이 발생한다.
- 데이터가 힙의 여러 위치에 흩어져 CPU cache locality가 나빠진다.
- 조회할 때 포인터를 한 번 더 따라가야 한다.
그래서 Cloudflare는 이 구조를 최종 답으로 두지 않았다.
5. 파싱된 enum 대신 wire format을 연속된 바이트로 저장하기
DNS 응답 전체를 wire format 그대로 저장하면 조회할 때마다 DNSSEC 포함 여부나 메시지 ID 같은 클라이언트별 차이를 다시 처리해야 한다. 반대로 모든 레코드를 파싱된 enum으로 저장하면 앞에서 본 크기와 할당 문제가 생긴다.
Cloudflare는 두 방식의 중간 지점을 선택했다. 캐시 엔트리의 메타데이터는 구조화된 필드로 유지하고, 레코드 데이터만 다음과 같은 하나의 Box<[u8]>에 저장했다.
1
[2바이트 길이][레코드 바이트][2바이트 길이][레코드 바이트]...
이 구조에는 세 가지 효과가 있다.
- enum의 최대 variant 크기에 맞춘 빈 공간이 사라진다.
- 레코드마다 만들었던 힙 할당이 하나로 합쳐진다.
- 데이터가 연속해서 배치되어 CPU cache locality가 좋아진다.
대신 임의 인덱스로 특정 레코드에 바로 접근할 수 없고 처음부터 순차 탐색해야 한다. 하지만 캐시 엔트리 하나에 들어가는 레코드 수가 작기 때문에 실제 비용은 무시할 수 있었다.
조회 과정도 빨라졌다. A, AAAA, TXT, DNSSEC 레코드는 이미 인코딩된 바이트를 응답 버퍼에 바로 복사할 수 있다. 도메인 이름 압축이 필요한 CNAME, NS, MX, SOA 등만 다시 파싱한다. 이 변경으로 조회 지연 시간은 벤치마크에서 5% 줄었다.
삽입 경로에서는 재사용 가능한 scratch buffer에 레코드를 먼저 직렬화한 뒤, 정확한 크기의 Box<[u8]>를 한 번만 할당해 복사했다. 이 변경만으로 캐시 삽입 처리량이 13% 증가했다.
메모리를 줄였는데 성능도 좋아진 이유
메모리 절약과 성능 향상이 동시에 일어난 이유는 저장하는 데이터만 줄인 것이 아니라 할당 횟수와 포인터 추적도 함께 줄였기 때문이다.
| 지표 | 변경 전 | 변경 후 | 변화 |
|---|---|---|---|
| 엔트리당 실사용 크기 | 953 bytes | 420 bytes | -56% |
| 엔트리당 할당량 | 1.1 KB | 461 bytes | -58% |
| 캐시 삽입 처리량 | 625,000 entries/s | 893,000 entries/s | +43% |
| 캐시 조회 지연 시간 | 828 ns | 670 ns | -19% |
운영 환경의 p99 프로세스 메모리는 9.3GB에서 5.3GB로 43% 감소했고, p90은 6.5GB에서 3.8GB로 42% 감소했다. 벤치마크의 엔트리당 감소율보다 작은 이유는 프로세스 메모리에는 DNS 캐시 외의 데이터도 포함되기 때문이다.
Cloudflare는 벤치마크 숫자만으로 결과를 확정하지 않았다. 실제 트래픽 비율에 가깝게 A 56%, AAAA 25%, TXT 19%로 캐시를 채우고, custom allocator로 엔트리별 할당을 측정했다. 동시에 삽입 처리량과 조회 지연 시간을 확인하고, 배포 후에는 운영 인스턴스의 resident memory까지 비교했다.
이 사례에서 배운 점
평균보다 분포가 중요하다
enum boxing은 가장 큰 타입만 보면 손해다. 하지만 작은 A와 AAAA가 트래픽의 80% 이상이라는 분포를 반영하면 전체로는 큰 이득이 된다. 자료구조를 최적화할 때는 가능한 값의 목록보다 실제로 어떤 값이 얼마나 자주 등장하는지 먼저 봐야 한다.
데이터의 생명주기가 타입 선택을 결정한다
생성 중에는 Vec가 적합하지만 저장 후 불변인 값에는 Box<[T]>가 더 정확하다. 같은 데이터를 처리하더라도 생성 단계와 조회 단계가 요구하는 자료구조는 다를 수 있다.
메모리 사용량은 필드 크기의 합이 아니다
구조체 padding, allocator의 size class, 여분의 capacity, 힙 할당 메타데이터, CPU cache miss까지 포함해야 실제 비용이 보인다. size_of만 줄였다고 운영 메모리가 같은 비율로 줄어드는 것은 아니다.
큰 규모에서는 작은 낭비가 설계 문제가 된다
일반적인 서비스에서는 엔트리당 8바이트가 중요하지 않을 수 있다. 하지만 2,500억 번 반복되면 약 2TB가 된다. 최적화의 우선순위는 절약 가능한 바이트 수가 아니라 다음 식으로 판단해야 한다.
1
전체 효과 = 엔트리당 절약량 × 엔트리 수 × 복제 수
이번 사례에서 가장 인상적인 부분은 특별한 압축 알고리즘이 아니다. 캐시에 들어간 뒤 변하지 않는 값에서 capacity를 없애고, 반복되는 이름을 저장하지 않고, 여러 할당을 하나의 연속된 버퍼로 합쳤다. 데이터의 실제 생명주기와 분포에 맞게 표현을 바꾼 결과가 100TB였다.