구독해서 새 게시물에 대한 알림을 받으세요.

Rust 및 Wasm으로 Cloudflare의 1.1.1.1을 구동하는 방법

2023-02-28

16분 읽기
이 게시물은 English, 繁體中文, Français, Deutsch, 日本語, Español简体中文로도 이용할 수 있습니다.

2018년 4월 1일, Cloudflare는 1.1.1.1 공용 DNS 확인자를 발표했습니다. 이후 수년간 문제 해결을 위한 디버그 페이지, 전역 캐시 제거, Cloudflare 영역의 0 TTL, Uptream TLS, 가정용 1.1.1.1을 플랫폼에 추가해 왔습니다. 이 게시물에서는 이면의 자세한 내용과 일부 변경 사항을 알려드리겠습니다.

프로젝트를 시작했을 때, Cloudflare에서는 Knot Resolver를 DNS 확인자로 선택하여 사용 방식에 적절한 전체 시스템을 그 위에 구축했습니다. DNSSEC 검사기뿐만 아니라 실전에서 검증된 DNS 재귀 확인자가 있으면 DNS 프로토콜의 구현을 걱정하지 않고 다른 곳에 집중할 수 있어 좋았습니다.

Knot Resolver는 Lua를 기반으로 하는 플러그인 시스템이라는 점에서 상당히 유연합니다. Knot Resolver를 사용하면 핵심 기능을 확장해 DoH/DoT, 로깅, BPF 기반 공격 완화, 캐시 공유, 반복 로직 재정의와 같이 다양한 제품의 기능을 지원할 수 있었습니다. 그러나 트래픽이 늘어나자 한계에 부딪히게 되었습니다.

얻은 교훈

더 깊이 들어가기 전에, 이후에 다룰 내용을 이해하는 데 도움이 되도록 Cloudflare 데이터 센터 구성을 단순화하여 전체적으로 살펴보겠습니다. Cloudflare에서는 모든 서버가 동일합니다. 한 서버에서 실행되는 소프트웨어 스택은 다른 서버의 것과 완전히 동일하며 구성에서만 차이가 있을 수 있습니다. 이렇게 구성하면 제품군을 관리하는 복잡성이 크게 줄어듭니다.

그림 1 데이터 센터 레이아웃

확인자는 데몬 프로세스인 kresd로 실행되며 단독으로 작동하지 않습니다. 요청, 특히 DNS 요청은 Unimog를 통해 데이터 센터 내부 서버에 부하 분산됩니다. DoH 요청은 Cloudflare의 TLS 종료기에서 종료됩니다. Quicksilver를 통해 구성 및 기타 작은 데이터 조각을 수 초 내에 전 세계로 전달할 수 있습니다. 확인자는 이러한 지원을 통해 DNS 쿼리를 해결하겠다는 목적에 집중하고, 자세한 프로토콜 전달 과정에는 관여하지 않습니다. 이제 개선하고자 했던 세 가지 핵심 영역, 즉 플러그인의 I/O 차단, 더 효율적인 캐시 공간 사용, 플러그인 격리에 관해 이야기해 봅시다.

이벤트 루프 차단 콜백

Knot Resolver에는 핵심 기능을 넓혀주는 유연한 플러그인 시스템이 있습니다. 이 플러그인을 모듈이라고 하며 모듈은 콜백에 기반합니다. 콜백은 요청을 처리하던 특정 시점에서 현재의 쿼리 컨텍스트에 따라 호출됩니다. 그러면 모듈이 요청 / 응답을 검사하고, 수정하고, 심지어 생성할 수도 있게 됩니다. 기반이 되는 이벤트 루프가 차단되지 않도록, 콜백은 일부러 단순하게 만들어졌습니다. 서비스가 단일 스레드이고 이벤트 루프에서는 동시에 많은 요청을 처리해야 하기 때문에 이 점은 중요합니다. 그래서 콜백에서 요청이 단 하나만 지연되더라도 동시에 발생한 다른 요청은 콜백 종료 시까지 진행되지 않습니다.

(예를 들어) 클라이언트에게 응답하기 전 Quicksilver에서 데이터를 가져오려는 경우와 같이, 차단이 필요하기 전까지 이러한 설정은 잘 작동했습니다.

캐시 효율성

한 도메인에 대한 요청은 데이터 센터 내부에 있는 모든 노드에 도달할 수 있으므로, 다른 노드에 이미 응답이 있을 때 쿼리를 반복 해결하는 것은 낭비입니다. 직관적으로 생각해보면 서버 간에 캐시가 공유될 경우 대기 시간은 줄어듭니다. 그래서 새로 추가된 캐시 항목을 멀티캐스트하는 캐시 모듈을 만들었습니다. 그러면 동일한 데이터 센터 내부의 노드에서 이벤트를 확인하여 로컬 캐시를 업데이트할 수 있습니다.

Knot Resolver에서 기본적으로 캐싱을 구현하는 방식은 LMDB입니다. 작거나 중간급 규모로 배포할 때 빠르고 안정적인 방식입니다. 하지만 얼마 지나지 않아 Cloudflare에서는 캐시 만료 때문에 곤란해졌습니다. 캐시 그 자체는 TTL, 인기도 등을 추적하지 않습니다. 캐시가 차면 모든 항목을 삭제하고 다시 시작합니다. 영역 열거와 같은 경우에는 이후에 검색되지 않을 가능성이 높은 데이터로 캐시를 채울 수 있습니다.

뿐만 아니라 멀티캐스트 캐시 모듈을 통해 유용성이 떨어지는 데이터가 모든 노드에 증폭되어 상황이 악화되었고, 동시에 캐시는 하이 워터마크 상태가 되었습니다. 그 결과 모든 노드가 캐시를 삭제하고 동시에 다시 시작하면서 대기 시간이 급증했습니다.

모듈 격리

Lua 모듈 목록이 늘어나면서 디버깅 문제가 점점 더 어려워졌습니다. 모든 모듈에서 하나의 Lua 상태를 공유하기 때문이었습니다. 모듈이 하나라도 잘못되면 다른 모듈에 영향을 미칠 수 있었습니다. 예를 들어 동시 실행 루틴이 너무 많다거나 메모리가 부족한 경우 등 Lua 상태 내에 잘못된 게 있을 경우, 프로그램이 단순히 고장 나기만 한다면 다행이지만 스택 추적을 확인하기 어려워지는 결과가 발생했습니다. 또한 모듈은 Lua 런타임의 상태일 뿐만 아니라 FFI이기도 하기에 모듈을 강제로 나누거나, 업그레이드하거나, 실행하기 어려워 메모리 안전이 보장되지도 않습니다.

BigPineapple의 탄생

기존의 소프트웨어 중에서는 Cloudflare의 까다로운 요건을 충족하는 제품을 찾을 수가 없어, 결국 소프트웨어를 직접 만들기 시작했습니다. 처음에는 Rust로 작성한 씬 서비스(edgedns 수정)를 사용해 Knot Resolver의 코어를 감싸 보았습니다.

이 작업은 저장소, C/FFI 유형 사이를 계속 전환해야 한다는 점과 일부 특징(예를 들어 캐시에서 레코드를 찾는 ABI는 반환된 레코드가 다음 호출 또는 읽기 트랜잭션까지 변하지 않기를 기대함) 때문에 어려웠습니다. 하지만 호스트(서비스)가 게스트(확인자 코어 라이브러리)에게 리소스를 일부 제공하는 유형의 분리 기능을 구현해보려는 과정에서 많은 것을 배웠고, 인터페이스를 어떻게 개선해야 할지 알 수 있었습니다.

이후 반복 작업에서 전체 재귀 라이브러리를 비동기식 런타임에 기반한 새로운 라이브러리로 교체하고 다시 디자인한 모듈 시스템을 추가하면서 갈수록 더 많은 컴포넌트를 바꾸게 되어 서비스를 Rust로 다시 작성하게 되었습니다. 이 비동기 런타임은 tokio였는데, 다른 크레이트(Rust 라이브러리)와 작업하기 좋은 생태계뿐만 아니라 비차단 작업과 차단 작업 모두를 실행하기 좋은 스레드 풀 인터페이스를 제공했습니다.

이후 futures 결합자가 번거로워지면서 모든 것을 async/await로 전환했습니다. Rust 1.39에 async/await 기능이 적용되기 전이어서 한동안은 nightly(Rust 베타 버전)를 사용했는데, 약간의 어려움이 있었습니다. async/await가 안정화되자 요청 처리 루틴을 Go와 비슷하게 인체 공학적으로 작성할 수 있게 되었습니다.

모든 작업을 동시에 실행할 수 있으며, I/O가 무거운 특정 작업은 작은 단위로 나누어 더 세밀하게 일정을 조정할 수 있게 되었습니다. 런타임이 단일 스레드 대신 스레드풀에서 작업을 실행하므로 작업 가로채기(Work Stealing)를 이용할 수도 있습니다. 이렇게 하면 하나의 요청이 이벤트 루프에서 다른 모든 요청을 차단해 처리 시간이 많이 소요되었던 이전의 문제를 방지할 수 있습니다.

그림 2 컴포넌트 개요

결국 Cloudflare는 마음에 드는 플랫폼을 만들어 BigPineapple이라고 불렀습니다. 위의 그림에는 주요 컴포넌트와 컴포넌트 사이 데이터 흐름이 간략히 표현되어 있습니다. BigPineapple에서 서버 모듈은 클라이언트로부터 인바운드 요청을 받아 검증하고 통합된 프레임 흐름으로 바꿔줍니다. 그러면 작업자 모듈로 통합 프레임 흐름을 처리할 수 있습니다. 작업자 모듈에는 작업자 세트가 있고, 요청에 있는 질문의 답을 알아냅니다. 각각의 작업자는 캐시 모듈과 상호 작용하여 답이 있는지, 여전히 유효한지를 확인하고, 그렇지 않은 경우엔 재귀적으로 쿼리를 반복하도록 재귀자 모듈을 작동시킵니다. 재귀자는 무언가 필요할 때 I/O를 실행하지 않고 하위 작업을 컨덕터 모듈에 위임합니다. 그러면 컨덕터가 아웃바운드 쿼리를 사용해 업스트림 이름 서버에서 정보를 가져옵니다. 전체 프로세스에서 일부 모듈은 Sandbox 모듈과 상호 작용하여 내부에서 플러그인을 실행해 기능을 넓힐 수 있습니다.

부분적으로 자세히 살펴보고 이전에 나타났던 문제를 해결하는 데 어떤 도움이 되었는지 알아봅시다.

업데이트된 I/O 아키텍처

DNS 확인자는 클라이언트와 여러 권한 있는 네임서버 간의 에이전트로 간주할 수 있습니다. DNS 확인자는 클라이언트로부터 요청을 수신하고, 재귀적으로 업스트림 이름 서버에서 데이터를 가져온 다음 응답을 구성하고 클라이언트에서 되돌려 보냅니다. 따라서 DNS 확인자에는 인바운드 트래픽과 아웃바운드 트래픽이 모두 있고 서버와 컨덕터 컴포넌트가 트래픽을 각각 처리합니다.

서버는 다양한 전송 프로토콜을 사용하여 인터페이스 목록에서 보내는 요청을 기다립니다. 이는 이후에 "프레임" 스트림으로 요약됩니다. 각 프레임은 DNS 메시지를 높은 수준으로 표현한 것이며, 추가적인 메타데이터 몇 가지가 있습니다. 그 아래에는 TCP 스트림의 세그먼트인 UDP 패킷이나 HTTP 요청의 페이로드가 있을 수 있지만 둘 다 같은 방식으로 처리됩니다. 그러면 프레임이 비동기 작업으로 전환되어 이를 해결하는 역할의 작업자 세트에 주어집니다. 완료된 작업은 다시 응답으로 전환되어 클라이언트에게 전송됩니다.

프로토콜 및 프로토콜 인코딩을 "프레임"으로 만들면서 프레임 소스를 통제하기 위해 사용하는 로직이 간소화되었습니다. 예를 들어, 서버가 압도되지 않도록 보호하기 위해 공정성을 시행해 고갈과 흐름 제어를 방지할 수 있습니다. 이전에 구현하면서 한 가지 배웠던 것은, 어떤 서비스를 대중에 공개할 때에 I/O의 최고 성능은 클라이언트에게 공정하게 보조를 맞추는 능력보다 중요하지 않다는 점입니다. 재귀 요청 각각의 시간과 연산량이 크게 다르고(예: 캐시 누락과 캐시 적중의 차이) 미리 예측하기 어렵다는 게 주된 이유입니다. 재귀 서비스에서 캐시 누락이 일어나면 Cloudflare의 리소스뿐만 아니라 쿼리되고 있는 권한 있는 네임서버의 리소스가 소모되므로 주의가 필요합니다.

서버의 반대편에는 모든 아웃바운드 연결을 관리하는 컨덕터가 있습니다. 컨덕터는 업스트림에 도달하기 전 몇 가지 질문에 대답하는 데 도움을 줍니다. 대기 시간의 측면에서 연결하기에 가장 빠른 이름 서버는 무엇인가? 모든 이름 서버에 액세스할 수 없는 상태라면 어떻게 해야 하는가? 어떤 프로토콜을 연결에 사용해야 하며, 더 나은 옵션이 있는가? 컨덕터는 RTT, QoS 등과 같은 업스트림 서버의 메트릭을 추적해 이러한 결정을 내릴 수 있습니다. 컨덕터는 이러한 정보로 업스트림 용량, UDP 패킷 손실 등을 추정하여, 이전 UDP 패킷이 업스트림에 도달하지 않았다고 판단했을 때 재시도를 하는 등 필요한 조치를 취할 수도 있습니다.

그림 3 I/O 컨덕터

그림 3에는 컨덕터의 데이터 흐름이 단순하게 표현되어 있습니다. 컨덕터는 업스트림 요청과 함께 앞서 언급한 교환자로 호출됩니다. 먼저, 중복되는 요청은 제거됩니다. 작은 창에서 다수의 요청이 컨덕터로 이동해 같은 질문을 한다면 하나만 통과시키고 나머지는 대기열로 보내는 것입니다. 이 작업은 캐시 항목이 만료되었을 때 흔히 수행되며 불필요한 네트워크 트래픽을 줄여줍니다. 그런 다음 연결 안내자는 요청과 업스트림 메트릭에 기반하여 (가능한 경우) 열려 있는 연결을 선택하거나 일련의 매개 변수를 생성합니다. I/O 실행자는 이러한 매개 변수를 통해 업스트림에 직접 연결하거나 Argo Smart Routing 기술을 이용해 다른 Cloudflare 데이터 센터를 통해 라우팅할 수도 있습니다!

캐시

재귀적 서비스에서 서버가 캐시 누락에 응답하는 데는 수백 밀리초가 걸리지만, 캐시된 응답은 1밀리초 미만으로 반환할 수 있기 때문에 캐싱은 매우 중요합니다. 메모리는 유한한 리소스이므로(Cloudflare의 아키텍처에서는 공유 리소스이기도 합니다), 캐시 공간을 더욱 효율적으로 사용하는 방법 역시 핵심 개선 영역 중 하나였습니다. 새로운 캐시는 KV 스토어 대신 캐시 교환 데이터 구조(ARC)로 구현되었습니다. 이렇게 하면 인기가 덜한 항목이 순차적으로 삭제되기 때문에 단일 노드에서 공간을 적절하게 사용할 수 있고 스캔이 데이터 구조에 미치는 영향도 줄어듭니다.

또한 이전처럼 데이터 센터 전역의 캐시를 멀티캐스트로 복제하는 대신, BigPineapple은 같은 데이터 센터에 있는 피어 노드를 인식하여 캐시에서 항목을 찾을 수 없다면 다른 노드로 쿼리를 전달합니다. 각 데이터 센터 내 상태가 좋은 노드에 지속적으로 쿼리를 해싱하는 방식으로 이루어집니다. 예를 들어, 등록 도메인이 같은 쿼리는 같은 노드의 하위 집합을 통과합니다. 그러면 캐시 적중률을 높일 뿐만 아니라 이름 서버의 성능과 기능 정보를 저장하는 인프라 캐시에도 도움이 됩니다.

그림 4 업데이트된 데이터 센터 레이아웃

비동기 재귀 라이브러리

재귀 라이브러리는 쿼리의 질문에 대한 대답을 찾는 방법을 알고 있기 때문에 BigPineapple의 DNS 브레인이라고 할 수 있습니다. 루트에서 시작해 클라이언트 쿼리를 하부 쿼리로 세분화하고 이를 이용해 인터넷 상의 다양한 권한 있는 네임서버에서 재귀적으로 정보를 수집합니다. 이 프로세스를 통한 결과물이 바로 응답입니다. async/await를 통해 다음과 같은 함수로 요약할 수 있습니다.

함수에는 주어진 요청에 대한 응답을 생성하는 데 필요한 모든 논리가 있지만, 단독으로는 I/O를 수행하지 않습니다. 그래서 업스트림의 권한 있는 네임서버와 비동기식으로 DNS 메시지를 교환할 방법을 알고 있는 Exchanger(교환자) 트레잇(Rust 인터페이스)을 전달합니다. 보통 교환자는 다양한 대기 시점에서 호출됩니다. 예를 들어, 재귀가 시작되었을 때 교환자는 가장 먼저 도메인에 가장 가깝게 캐시된 위임을 찾습니다. 캐시에 최종 위임이 없다면 더 진행하기 전에 이 도메인을 담당하는 이름 서버를 요청하여 응답을 기다립니다.

async fn resolve(Request, Exchanger) → Result<Response>;

재귀적 DNS 논리에서 "응답을 기다리는" 부분을 분리하여 설계한 덕분에, 교환자를 모킹 구현하여 테스트를 진행하기가 훨씬 수월합니다. 또한 다수의 콜백에 흩어져 있지 않고 연속적으로 작성되어 있어 재귀적 반복 코드(및 특히 DNSSEC 유효성 검사 논리)를 확인하기가 더 쉽습니다.

완전히 처음부터 DNS 재귀 확인자를 작성하기란 쉽지 않습니다!

DNSSEC 유효성 검사가 복잡성하기도 하고, 다양한 RFC 비호환 서버, 포워더, 방화벽 등에 필수 "회피책"이 필요하기 때문입니다. 그래서 테스트하기 수월하도록 deckard를 Rust로 가져왔습니다. 이와 더불어 새로운 비동기 재귀 라이브러리로 마이그레이션을 시작했을 때, "섀도우" 모드로 먼저 실행했습니다. 프로덕션 서비스의 실제 쿼리 샘플을 처리하여 차이를 비교한 것입니다. 이전에 Cloudflare의 권한 있는 DNS 서비스에서도 이렇게 했던 적이 있는데, 재귀 서비스에서는 조금 더 어려웠습니다. 재귀 서비스에서는 인터넷의 모든 데이터를 찾아야 했고, 현지화나 부하 분산 등의 이유로 권한 있는 네임서버가 동일한 쿼리에 다르게 응답해 긍정 오류가 많이 발생했기 때문이었습니다.

2019년 12월, 프로덕션 엔드포인트를 새로운 서비스로 천천히 마이그레이션하기 전에 남은 문제를 해결할 수 있도록 마침내 공개 테스트 엔드포인트에서 새로운 서비스를 제공(발표 참조)했습니다. 그 이후에도 DNS 재귀(및 특히 DNSSEC 유효성 검사)의 엣지 케이스가 계속 나타났지만 라이브러리의 새 아키텍처 덕분에 문제를 해결하고 재현하기가 훨씬 쉬워졌습니다.

샌드박스를 적용한 플러그인

핵심 DNS 기능을 상황에 맞게 확장하는 능력을 갖추는 일은 중요하기 때문에 BigPineapple의 플러그인 시스템을 다시 설계했습니다. 이전에 Lua 플러그인은 서비스와 동일한 메모리 공간에서 실행되었고, 보통 원하는 대로 자유롭게 실행할 수 있었습니다. 이렇게 하면 C/FFI를 사용하여 서비스와 모듈 간에 메모리 참조를 자유롭게 전달할 수 있어 편리합니다. 예를 들면 버퍼에 먼저 복사하지 않더라도 캐시에서 직접 응답을 확인할 수 있습니다. 하지만 모듈이 초기화되지 않은 메모리를 읽거나, 잘못된 함수 서명을 사용하는 호스트 ABI를 호출하거나, 로컬 소켓에서 차단하거나, 기타 의도하지 않은 작업을 수행할 수 있는 데다 이러한 동작을 제한할 방법이 서비스에 없으므로 위험할 수도 있습니다.

그래서 임베디드 Lua 런타임을 JavaScript로 교체하거나 네이티브 모듈로 교체할지 고려하고 있었는데, 비슷한 시기에 WebAssembly(줄여서 Wasm)의 임베디드 런타임이 등장했습니다. WebAssembly 프로그램의 두 가지 뛰어난 특징은 서비스의 나머지 부분과 동일한 언어로 작성할 수 있다는 점, 분리된 메모리 공간에서 실행된다는 점이었습니다. 그래서 WebAssembly 모듈이 어떻게 작동하는지 살펴보기 위해 모듈의 한계에 맞춰 게스트/호스트 인터페이스를 모델링하기 시작했습니다.

BigPineapple의 Wasm 런타임은 현재 Wasmer로 구동되고 있습니다. 처음에는 Wasmtime, WAVM과 같은 여러 런타임을 시도했고 현재 상황에서는 Wasmer가 더 사용하기 간편했습니다. Wasmer 런타임을 이용하면 모듈이 분리된 메모리와 시그널 트랩으로 각각의 인스턴스를 실행할 수 있어, 앞서 기술한 모듈 격리 문제가 자연스럽게 해결되었습니다. 동일한 모듈의 여러 인스턴스를 동시에 실행할 수도 있었습니다. 앱을 세심하게 제어하여 단 하나의 요청도 놓치지 않고 한 인스턴스에서 다른 인스턴스로 핫 스왑할 수 있습니다! 서버를 다시 시작하지 않고도 앱을 바로 업그레이드할 수 있어서 아주 편리합니다. Wasm 프로그램이 Quicksilver를 통해 배포된다는 점을 고려하면, 수 초 내에 전 세계에서 BigPineapple의 기능을 안전하게 바꿀 수 있습니다!

WebAssembly 샌드박스를 더 잘 이해할 수 있도록 먼저 몇 가지 용어를 소개해 드리겠습니다.

  • 호스트: Wasm 런타임을 실행하는 프로그램. 커널과 유사하게 런타임을 통해 게스트 애플리케이션에 완전한 통제권이 주어집니다.

  • 게스트 애플리케이션: 샌드박스 내부의 Wasm 프로그램입니다. 제한된 환경에서 런타임이 제공하는 자체 메모리 공간에만 액세스하고 불러온 호스트 콜을 호출합니다. 줄여서 앱이라고 부릅니다.

  • 호스트 콜: 호스트에 지정된 함수로 게스트가 불러올 수 있습니다. Syscall과 비교했을 때, 게스트 앱이 샌드박스 외부 리소스에 액세스할 수 있는 유일한 방법입니다.

  • 게스트 런타임: 게스트 애플리케이션에서 호스트와 쉽게 상호 작용할 수 있는 라이브러리입니다. 앱이 기본적인 세부 사항을 모르더라도 비동기, 소켓, 로그, 추적을 사용할 수 있도록 공통적인 인터페이스를 구현합니다.

이제 샌드박스에 대해 알아볼 시간입니다. 잠시 시간을 들여 읽어 주세요. 먼저 게스트 측에서 일반적인 앱의 수명에 관해 알아봅시다. 게스트 런타임의 도움으로 평범한 프로그램과 유사하게 게스트 앱을 작성할 수 있습니다. 그래서 앱은 다른 실행 파일처럼 로드 시 호스트에서 호출하는 시작 함수를 엔트리 포인트로 사용해 시작됩니다. 또한 명령줄과 마찬가지로 인수가 제공됩니다. 이때 인스턴스는 정상적으로 초기화를 일부 진행하며 더욱 중요한 과정으로, 다양한 쿼리 단계에서 콜백 함수를 등록합니다. 응답을 생성할 만큼 정보를 충분히 수집하기 전에 쿼리가 재귀 확인자에서 여러 단계(예: 캐시 검색, 도메인의 위임 사슬을 해결할 하부 요청 만들기)를 거쳐야 하기 때문입니다. 따라서 이러한 단계를 연결할 수 있어야 다양한 활용 사례에서 앱을 유용하게 사용할 수 있습니다. 또한 시작 함수가 백그라운드 작업을 일부 실행하여 단계 콜백을 보완하고 전역 상태를 저장할 수도 있습니다. 예를 들면 메트릭을 보고하거나, 외부 소스의 공유 데이터를 미리 불러올 수 있습니다. 마찬가지로 일반 프로그램을 작성하는 방법과 같습니다.

그런데 프로그램 인자는 어디서 나올까요? 게스트 앱은 로그와 메트릭스를 어떻게 전송할까요? 정답은 바로 외부 함수입니다.

그림 5 Wasm 기반 샌드박스

그림 5를 보면 중앙에 차단벽이 있는데, 호스트에서 게스트를 분리하는 샌드박스 경계입니다. 한쪽에서 다른 쪽으로 가는 유일한 방법은 사전에 피어에서 내보낸 함수를 통하는 수밖에 없습니다. 그림에서 보이는 것처럼 "호스트콜"을 내보내는 것은 호스트이고, 불러오고 호출하는 것은 게스트입니다. "트램펄린"은 호스트가 정보를 알고 있는 게스트 함수입니다.

이 함수는 내보내지 않은 게스트 인스턴스 내부에서 함수나 클로저를 호출하는 데 사용되기 때문에 트램펄린이라 불립니다. 단계 콜백은 트램펄린 함수가 필요한 이유를 나타내는 예시입니다. 각 콜백은 클로저를 반환하므로 인스턴트화 시 내보낼 수 없습니다. 따라서 게스트 앱에서는 콜백을 등록하려고 하고, 콜백 주소 "hostcall_register_callback(pre_cache, #30987)"로 호스트 콜을 호출합니다. 콜백을 호출해야 할 때는 포인터가 게스트의 메모리 공간을 가리키고 있기 때문에 호스트에서 간단하게 호출할 수는 없습니다. 그 대신 앞서 언급된 트램펄린 중 하나를 활용해 콜백 클로저의 주소 "trampoline_call(#30987)"을 제공합니다.

격리 오버헤드동전에 양면이 있는 것처럼 새로운 샌드박스에는 추가적인 오버헤드가 있습니다. WebAssembly의 휴대성과 격리성에 따르는 대가가 있습니다. 두 가지 대가를 알려드리겠습니다.

먼저, 게스트 앱에서 호스트 메모리를 읽을 수 없습니다. 게스트 앱은 게스트가 호스트 콜을 통해 메모리 영역을 제공하면 호스트가 게스트 메모리 공간에 데이터를 작성하는 방식으로 작동합니다. 샌드박스 외부에 있었다면 필요하지 않을 메모리 복사가 도입됩니다. 아쉬운 점은, Cloudflare의 게스트 앱은 쿼리 및/또는 응답에서 무언가를 해야 하기 때문에 거의 항상 모든 요청이 이루어질 때 호스트에서 데이터를 읽어야 한다는 것입니다. 반대로 요청의 수명 주기 중에는 데이터가 변경되지 않는다는 점은 장점입니다. 그래서 Cloudflare에서는 게스트 앱이 인스턴스화하는 즉시 게스트 메모리 공간에서 대량의 메모리를 사전 할당합니다. 할당된 메모리가 사용되지는 않지만 주소 공간의 공백을 차지하는 역할을 합니다. 호스트가 세부 주소 정보를 받으면, 게스트에 필요한 공통 데이터가 있는 공유 메모리 영역을 게스트 공간에 배치합니다. 게스트 코드가 실행을 시작하면, 공유 메모리 오버레이에 있는 데이터에 액세스할 수 있으며 복사가 필요하지 않습니다.

BigPineapple에 최신 프로토콜인 oDoH 지원을 추가하려고 했을 때 또 다른 문제가 있었습니다. oDoH의 주요 목적은 클라이언트 쿼리를 해독하고 확인한 다음, 다시 전송하기 전에 암호화하는 것입니다. 설계상 핵심 DNS에 속하지 않으므로 Wasm 앱을 통해 확장해야 했습니다. 하지만 WebAssembly 명령어 집합에서 AES와 SHA-2 등 일부 크립토 프리미티브(Crypto Primitives)가 제공되지 않았고, 호스트 하드웨어의 이점을 누릴 수 없었습니다. 현재 WASI-crypto를 통해 이 기능을 Wasm으로 가져오려는 작업을 진행하고 있습니다. 그때까지의 해결 방법은 단순히 호스트 콜을 통해 HPKE를 호스트에 위임하는 것입니다. 이렇게 하면 Wasm 내부에서 실행하는 것과 비교해 성능이 4배 개선되는 것을 확인했습니다.

Wasm의 비동기앞서 콜백이 이벤트 루프를 차단할 수도 있다는 문제를 언급했습니다. 근본적으로 샌드박스를 적용한 코드를 비동기식으로 실행하는 방식과 관련된 문제입니다. 요청 처리 콜백이 아무리 복잡하더라도 중지할 수 있다면, 차단 기간의 상한선을 정할 수 있습니다. 다행히 Rust의 비동기 프레임워크는 명쾌하고 가볍습니다. Rust 프레임워크를 사용하면 "Future"를 구현하는 게스트 호출 집합을 사용할 수 있습니다.

Rust에서 Future는 비동기식 연산을 위한 구성 요소입니다. 사용자의 관점에서 비동기식 프로그램을 만들려면 두 가지를 해결해야 합니다. 상태 전이를 유도하는 폴링 가능 함수를 구현하는 것과, 어떤 외부 이벤트(예: 시간 경과, 소켓 확인 가능 등)로 인해 폴링 가능 함수를 다시 호출해야 할 때 스스로를 깨울 콜백으로 웨이커를 배치하는 것입니다. 첫 번째를 해결하면 프로그램을 점진적으로 진행할 수 있습니다. 예를 들면, 버퍼된 I/O 데이터를 읽고 작업이 완료되었는지, 중단된 상태인지 나타내는 새로운 상태를 반환할 수 있습니다. 두 번째를 해결하면 작업을 중단할 때 유용합니다. 완료될 때까지 바쁘게 루프를 반복하는 대신 작업에서 기다리고 있던 조건이 이행될 때 Future 폴링을 트리거하기 때문입니다.

샌드박스에서는 어떻게 구현되는지 살펴봅시다. 게스트가 어떤 I/O를 수행해야 하는 경우, 제한된 환경 내부에 있기 때문에 게스트는 호스트 콜을 이용해야 합니다. 호스트가 기본 소켓 작업(열기, 읽기, 쓰기, 닫기)을 반영하여 단순한 호스트 콜 집합을 제공한다고 가정해 봅시다. 게스트는 다음과 같은 의사 폴러를 정의할 수 있습니다.

여기서 호스트 콜은 반환 값에 따라 소켓에서 버퍼로 데이터를 읽고, 함수는 앞서 언급한 상태인 완료(준비) 상태, 또는 중단(보류 중) 상태 중 하나로 직접 이동할 수 있습니다. 호스트 콜 안에서 마법이 벌어집니다. 그림 5에서 리소스에 액세스하는 유일한 방법은 호스트 콜이었습니다. 게스트 앱은 소켓을 소유하지는 않지만, "hostcall_socket_open"을 통해 "핸들"을 얻을 수 있고, 차례로 호스트 측에 소켓을 생성하고 핸들을 반환합니다. 이론적으로 핸들은 무엇이든 가능하지만 실제로는 정수 소켓 핸들을 사용해야 호스트 측의 파일 설명자, 또는 벡터나 슬랩의 인덱스에 잘 매핑될 수 있습니다. 게스트 앱은 반환된 핸들을 참조하여 실제 소켓을 원격으로 제어할 수 있습니다. 호스트 측은 완전히 비동기식이므로 게스트로 소켓 상태를 간단히 전달할 수 있습니다. 위에서 웨이커 함수가 사용되지 않았다는 것을 눈치채셨다면, 훌륭합니다! 호스트 콜을 호출할 때 소켓이 열리기 시작하기도 하고, 현재 웨이커가 호출된 다음에 소켓이 열리도록(또는 그러지 못하도록) 등록하기 때문입니다. 따라서 소켓이 준비되면 호스트 작업이 깨어나 컨텍스트에서 상응하는 게스트 작업을 찾아 그림 5처럼 트램펄린을 사용해 작업을 깨웁니다. 비동기식 뮤텍스와 같이 게스트 작업이 다른 게스트 작업을 기다려야 하는 경우도 있습니다. 구조는 비슷합니다. 호스트 콜로 웨이커를 등록하는 것입니다.

fn poll(&mut self, wake: fn()) -> Poll {
	match hostcall_socket_read(self.sock, self.buffer) {
    	    HostOk  => Poll::Ready,
    	    HostEof => Poll::Pending,
	}
}

이 모든 복잡한 내용이 사용하기 쉬운 API를 통해 게스트 비동기식 런타임에 캡슐화됩니다. 그래서 게스트 앱은 근본적인 세부 정보를 신경 쓰지 않고도 정규 비동기 함수에 액세스할 수 있습니다.

끝(이 아님)

이 블로그 포스팅을 통해 1.1.1.1을 구동하는 혁신적인 플랫폼의 일반 개념이 전달되었기를 바랍니다. 이 플랫폼은 계속 진화하고 있습니다. 현재, 가정용 1.1.1.1, AS112, Gateway DNS와 같은 Cloudflare의 일부 제품을 BigPineapple에서 실행되는 게스트 앱으로 뒷받침하고 있습니다. 여기에 새로운 기술을 도입하려고 합니다. 아이디어가 있다면 커뮤니티 또는 이메일을 통해 알려 주세요.

Cloudflare에서는 전체 기업 네트워크를 보호하고, 고객이 인터넷 규모의 애플리케이션을 효과적으로 구축하도록 지원하며, 웹 사이트와 인터넷 애플리케이션을 가속화하고, DDoS 공격을 막으며, 해커를 막고, Zero Trust로 향하는 고객의 여정을 지원합니다.

어떤 장치로든 1.1.1.1에 방문해 인터넷을 더 빠르고 안전하게 만들어 주는 Cloudflare의 무료 앱을 사용해 보세요.

더 나은 인터넷을 만들기 위한 Cloudflare의 사명을 자세히 알아보려면 여기에서 시작하세요. 새로운 커리어 경로를 찾고 있다면 채용 공고를 확인해 보세요.
DNS (KO)Resolver (KO)1.1.1.1 (KO)

X에서 팔로우하기

Cloudflare|@cloudflare

관련 게시물

2024년 9월 24일 오후 1:00

Cloudflare partners with Internet Service Providers and network equipment providers to deliver a safer browsing experience to millions of homes

Cloudflare is extending the use of our public DNS resolver through partnering with ISPs and network providers to deliver a safer browsing experience directly to families. Join us in protecting every Internet user from unsafe content with the click of a button, powered by 1.1.1.1 for Families....