엣지의 TLS 세션 재개와 HTTP/3
세션 재개를 사용하면 재방문자가 전체 TLS 핸드셰이크를 생략할 수 있습니다. 재시작 후에도 티켓 키를 유지해야 하는 이유, 안전한 키 교체 방법, 엣지의 HTTP/3가 기존 요청 파이프라인을 재사용하는 방식을 설명합니다.
CDN에서는 데이터를 전송하는 작업보다 연결을 수립하는 작업에 더 많은 비용이 드는 경우가 많습니다. 전체 TLS 핸드셰이크에는 비대칭 암호 연산과 추가 네트워크 왕복이 필요하며, 멀리 떨어진 방문자에게는 이 수십 또는 수백 밀리초가 첫 바이트 수신 시간에 그대로 반영됩니다. 이 글에서는 Rust 엣지에서 진행한 핸드셰이크 관련 작업 두 가지, TLS 세션 재개와 HTTP/3를 다룹니다.
세션 재개란
브라우저가 사이트에 처음 연결하면 전체 핸드셰이크를 수행하고 세션 키를 협상합니다. 이후 새 탭을 열거나, 기존 연결을 교체하거나, 모바일 네트워크를 전환하는 등의 이유로 곧바로 다른 연결이 필요해져도 비용이 큰 키 교환을 반복할 이유는 없습니다. TLS는 바로 이런 상황을 위해 세션 재개 메커니즘을 제공합니다.
- TLS 1.2에는 두 가지 방식이 있습니다. 세션 ID 방식은 서버가 세션 상태를 메모리에 보관하고, 세션 티켓 방식은 서버가 세션 상태를 암호화해 클라이언트에 전달하면 클라이언트가 다음 연결에서 이를 다시 제시합니다.
- TLS 1.3에서는 이를 PSK(사전 공유 키) 기반 재개로 통합합니다. 핸드셰이크 후 서버가
NewSessionTicket을 전송하고, 클라이언트는 다음 연결에서 이를 제시합니다.
티켓은 서버 측의 티켓 키로 암호화됩니다. 서버가 클라이언트가 다시 제시한 티켓을 복호화할 수 있으면 전체 핸드셰이크를 생략합니다. 방문자는 지연 시간이 줄어들고, 엣지는 암호 연산에 사용하는 CPU 자원을 절약합니다.
놓치기 쉬운 문제: 재시작할 때마다 모든 티켓이 무효화되었습니다
Rust 엣지의 초기 버전에서 이상한 현상을 발견했습니다. 세션 재개 성공률이 예상보다 훨씬 낮았고, 배포할 때마다 더 떨어졌습니다. 원인은 단순하지만 놓치기 쉬웠습니다. TLS 계층에 티켓 키를 설정하지 않았던 것입니다.
별도로 설정하지 않으면 BoringSSL과 OpenSSL은 프로세스 시작 시 임의의 티켓 키를 생성합니다. 오랫동안 실행되는 단일 프로세스라면 문제가 없지만, 엣지 프로세스는 배포와 업그레이드를 위해 재시작됩니다. 재시작할 때마다 새 프로세스에 새 키가 생성되면서 클라이언트가 보관한 모든 티켓이 한꺼번에 쓸모없어졌고, 클라이언트는 전체 핸드셰이크를 수행해야 했습니다. 코드베이스에 키 링 구현은 있었지만 TLS 연결 수락 처리에 연결되지 않은 상태였습니다.
해결 방법: 영속화, 교체, 제한
이제 티켓 키를 프로세스와 함께 버려지는 부산물이 아니라 신중하게 관리해야 하는 상태로 취급합니다.
- 디스크에 영속화합니다. 시작 시 엣지는 노드의 인증서 디렉터리에서 키 링을 불러옵니다. 파일이 없으면 CSPRNG로 새 키를 생성합니다. 임시 파일에 쓴 뒤 원자적으로 이름을 변경하며, 처음부터
0600권한으로 파일을 생성하므로 다른 사용자가 읽을 수 있는 순간이 생기지 않습니다. - 실제 콜백을 연결합니다. 소스에 포함해 관리하는 TLS 바인딩에 티켓 키 콜백(BoringSSL의
SSL_CTX_set_tlsext_ticket_key_cb)을 추가했습니다. 새 티켓은 현재 키로 암호화하고, 복호화할 때는 티켓에 포함된 키 이름으로 키 링에서 키를 찾습니다. - 일정에 따라 교체합니다. 기본적으로 키를 6시간마다 교체합니다. 키 링은 현재 키와 이전 키 몇 개를 보관하며, 이전 키는 복호화에만 사용하고 티켓 발급에는 사용하지 않습니다. 최근에 발급한 티켓은 계속 사용할 수 있고 각 키의 노출 기간은 제한됩니다. 따라서 키가 유출되더라도 순방향 비밀성에 미치는 영향을 제한할 수 있습니다.
- 오류가 발생해도 서비스를 계속합니다. 키 파일을 읽을 수 없으면 엣지는 임시 키를 생성하고 서비스를 계속 제공합니다. 해당 실행 중 발급한 티켓만 다음 재시작 후에 사용할 수 없게 됩니다. 세션 재개는 최적화 기능이므로 서비스 중단의 원인이 되어서는 안 됩니다.
이와 별도로 TLS 1.2 세션 ID에 사용하는 서버 측 세션 캐시는 기본 한도가 20,480개 항목인 크기 제한 LRU입니다. 가득 차면 가장 오래 사용되지 않은 세션을 제거하므로 메모리 사용량이 무제한으로 늘어나지 않습니다.
검증 방법도 소개할 만합니다. 테스트에서는 동일한 키 파일로 새로운 SSL_CTX를 생성해 재시작을 재현한 뒤, 이전 컨텍스트에서 발급한 티켓으로 연결하여 session_reused == true인지 확인합니다. 실제 노드에서는 티켓을 확보한 뒤 엣지 서비스를 재시작하고, 기존 티켓으로 다시 연결해 세션이 재개되는지 확인합니다. 한 가지 주의할 점은 실제 요청을 보내고 잠시 기다려야 한다는 것입니다. TLS 1.3에서는 NewSessionTicket이 핸드셰이크 후에 도착하므로, 너무 일찍 연결을 끊으면 티켓을 받지 못해 모든 연결이 새 연결처럼 보입니다.
적용 범위에 관한 참고 사항입니다. 현재 티켓 키는 노드별로 관리합니다. 방문자의 다음 연결이 다른 노드로 향하면 여전히 전체 핸드셰이크를 수행합니다. 노드 간에 키를 배포하면 노출 범위가 넓어지므로, 이를 고려해 의도적으로 선택한 방식입니다.
함께 해결한 문제: 정상적인 재시작
조사 중 엣지 재시작이 정상적으로 완료되지 않는다는 문제도 발견했습니다. SIGTERM 이후 Pingora의 기본 유예 종료 시간은 긴 반면 systemd의 중지 제한 시간은 훨씬 짧아서, 매번 재시작이 SIGKILL로 끝났습니다. 유예 시간과 종료 제한 시간을 수초 수준으로 줄였고, 이제 재시작이 수초 내에 정상적으로 완료됩니다. 티켓 키 영속화와 함께 적용한 결과, 배포가 방문자에게 거의 드러나지 않게 되었습니다. 기존 연결은 자연스럽게 처리를 마치고, 재방문자의 티켓도 계속 유효합니다.
HTTP/3: 같은 파이프라인, 다른 전송 방식
HTTP/3는 UDP 위에서 동작하고 TLS 1.3을 내장한 QUIC를 사용합니다. 그 장점은 주로 불안정한 네트워크에서 나타납니다. TCP 계층의 선두 차단이 없고, 연결 수립에 필요한 왕복 횟수가 적으며, 네트워크가 바뀌어도 연결을 이전할 수 있습니다.
Pingora 자체의 HTTP/3 지원은 아직 업스트림에 병합되지 않았습니다. 데이터 플레인 로드맵이 업스트림 릴리스 주기에 종속되지 않도록, quiche를 기반으로 하는 Cloudflare의 tokio-quiche로 QUIC 및 HTTP/3 종단 처리를 구현했습니다.
핵심 설계 결정은 다음과 같습니다. HTTP/3는 전송만 담당하고, 모든 요청 로직은 기존 파이프라인을 재사용합니다. 구체적인 내용은 다음과 같습니다.
- tokio-quiche는 UDP 443에서 QUIC와 HTTP/3 연결을 종단 처리하고, 핸드셰이크 중 SNI에 따라 인증서를 선택합니다. TCP 443과 정확히 같은 저장소에서 인증서를 가져오므로 별도의 인증서 시스템이 필요하지 않습니다.
- 디코딩한 HTTP/3 요청은 로컬 루프백 연결을 통해 같은 프로세스의 Pingora로 전달됩니다. 따라서 라우팅, 캐시, WAF, 규칙, 원본 서버 요청을 모두 재사용합니다. HTTP/3를 위해 다시 작성해야 하는 기능 모듈은 없었습니다.
- 루프백 요청에는 프로세스 메모리에만 존재하는 임의의 비밀값이 포함되며, 실제로 루프백에서 온 요청만 신뢰합니다. 이를 통해 실제 클라이언트 IP와 프로토콜을 복원합니다. 외부에서 전달된 같은 이름의 헤더는 항상 제거합니다.
- H1/H2와 H3는 한 프로세스에서 단일 BoringSSL을 공유하므로 QUIC 핸드셰이크 중에도 클라이언트 핑거프린트를 계산할 수 있습니다.
제공할 수 있을 때만 알립니다: Alt-Svc 전송 조건
브라우저는 응답 헤더 Alt-Svc: h3=":443"; ma=86400를 통해 사이트의 HTTP/3 지원 여부를 파악합니다. 서버가 UDP에서 수신하지 않으면서 이 헤더를 보내면, 브라우저는 불필요한 연결 시도를 한 번 거친 뒤 HTTP/2로 전환합니다. 따라서 Alt-Svc 지원 안내에는 노드 수준과 도메인 수준의 조건을 모두 적용합니다. 노드에서 실제로 QUIC 리스너를 실행하고 해당 도메인에 HTTP/3가 활성화되어 있을 때만 헤더를 보냅니다. 또한 원본 서버가 반환한 Alt-Svc 헤더도 제거하여 원본 서버의 안내가 클라이언트를 잘못 유도하지 않도록 합니다.
현재 0-RTT는 활성화하지 않습니다. 왕복을 한 번 더 줄일 수 있지만 초기 데이터는 재전송 공격에 사용될 수 있으며, 안전하게 활성화하려면 어떤 요청이 멱등성을 갖는지 판단해야 합니다. 이에 대한 확신이 생기면 다시 검토할 예정입니다.
정리
세션 재개와 HTTP/3는 단순히 켜기만 하면 되는 기능이 아닙니다. 세션 재개에는 티켓 키의 수명 주기 관리가 필요하고, HTTP/3에는 요청 로직이 분기되지 않는 별도의 전송 스택이 필요합니다. 저희는 키를 영속화하고 교체하는 상태로 관리하고, HTTP/3를 전송만 담당하는 프런트엔드로 취급하며, 각 기능은 한 번만 구현하는 방식을 선택했습니다.
Yeap Cloud에서 서비스 구축
모든 신규 계정에 DNS와 CDN 무료 요금제를 제공합니다.
계속 읽기
캐시 삭제 실무 가이드: 캐시 키부터 변형 단위 삭제까지
캐시를 삭제했는데도 계속 적중하나요? 대부분의 경우 답은 캐시 키에 있습니다. Yeap CDN의 캐시 키 구성 방식, 쿼리 문자열 모드의 동작, 쿼리 문자열 유무에 따른 URL별 캐시 삭제 범위를 자세히 설명합니다.
OpenAI SDK로 Yeap LLM API 시작하기
Yeap LLM API는 OpenAI와 호환됩니다. 기존 SDK를 유지하고 base_url을 https://llm.yeap.cloud/v1로 지정하면 됩니다. 키 생성, Python 및 Node.js 예제, 스트리밍, 임베딩, 키별 할당량과 모델 및 IP 제한을 다룹니다.
Yeap CDN의 새로운 엣지: Rust를 선택한 이유
CDN 엣지를 Pingora 기반의 Rust 자체 구현으로 교체했습니다. 웹사이트 연결 속도 개선, 수초 내 설정 적용, 격리 및 보안 강화 등 이번 변화가 사이트에 어떤 의미가 있는지 소개합니다.