This is a Korean translation of an existing post by Nick Jones, translated by Junho Choi.


소개

우리 Cloudflare 프로토콜 팀은 Cloudflare 네트워크의 엣지서버에서 HTTP 트래픽을 처리하는 일을 맡고 있습니다. 우리는 TCP, QUIC, TLS, 안전한 인증서 관리, HTTP/1 과 HTTP/2 에 관련된 기능을 담당하고 있습니다. 지난 1분기에는 Speed Week 기간 동안 발표된 HTTP/2 우선순위 지정 방법 개선을 맡고 있었습니다.

이 프로젝트를 진행하게 되어 매우 흥분되었고 그 결과를 보고 더 즐거웠습니다. 프로젝트를 진행하는 동안 Cloudflare가 현재 운영중인 HTTP 서버 NGINX에 관련된 몇가지 흥미로운 점을 깨닫게 되었습니다. HTTP/2 우선순위 지정 방법 개선 프로젝트를 시작한 후 얼마 되지 않아 NGINX의 내부 동작 방식을 바꾸지 않으면 이 프로젝트를 성공적으로 달성할 수 없다는 사실을 확신하게 된 것입니다.

이러한 인식을 바탕으로 핵심인 우선순위 지정 제품과 더불어 NGINX의 내부 구조를 바꾸는 몇가지 중요한 변경에 착수하였습니다. 이 블로그 글에서는 구조 변경의 동기, 접근 방법 및 미치는 영향에 대해서 설명합니다. 또한 우리는 추후의 더 나은 성능 개선을 기대하며 로드맵에 추가할 몇가지 변경 사항도 찾아 내었습니다.

배경

HTTP/2 우선순위 지정 방법 개선은 클라이언트와 서버간에 흐르는 웹 트래픽에 대해 다음과 같은 일을 하고자 합니다: 업스트림(서버 또는 오리진 측)에서 내려오는 데이터를 여러 HTTP/2 스트림으로 만들고 다운스트림(클라이언트 측)에 하나의 HTTP/2 연결로 내려 보내는 수단을 제공하는 것입니다.

HTTP/2 우선순위 지정 방법 개선은 사이트 소유자와 Cloudflare 엣지 시스템에게 여러 개체가 하나의 HTTP/2 연결로 통합될 때 규칙을 지정할 수 있도록 합니다. 특정 개체가 우선권을 갖고 연결을 모두 사용하여 클라이언트에게 최대한 빨리 전송되도록 하거나, 복수의 개체가 한 연결의 가용한 용량을 균등하게 나누어 사용하여 병렬성을 높이도록 하는 것입니다.

결과적으로 HTTP/2 우선순위 지정 방법 개선 프로젝트에서는 사이트 소유자가 클라이언트와 서버 간에 존재하는 두가지 문제를 해결할 수 있도록 합니다. 하나는 개체의 우선 순위와 순서를 제어하는 것이고, 또 하나는 연결의 여러 경로에서 존재하는 대역폭, 트래픽 용량과 CPU 워크로드와 같은 여러가지 제약 요소 하에서 제한된 연결의 자원을 어떻게 최대한 이용할 것인지에 대해서 입니다.

관찰 사항

우선순위 지정의 요점은 두개의 이상의 HTTP/2 스트림이 있을 때 다음번에 전송되어야 할 프레임이 어느 것인지 결정하도록 하는 것 입니다. HTTP/2 우선순위 지정 방법 개선 프로젝트를 진행하면서 필요에 의해 NGINX 코드 베이스를 보게 되었는데, 우리의 의도는 NGINX가 클라이언트에게 전송할 때 HTTP/2 데이터 프레임을  비교하고 순위를 정하는 방법을 근본적으로 바꾸는 것이기 때문입니다.

초기 분석 단계에서 제안된 기능을 조사하기 위해 NGINX 내부 구조를 들여다보는 동안 NGINX 구조 자체의 몇가지 단점을 알게 되었습니다. 구체적으로 이야기하면 각 단계에서 업스트림 (서버 측) 데이터를 다운스트림 (클라이언트 측)으로 이동하고 중간에서 일시적으로 저장 (버퍼링)하는지에 대한 것입니다. NGINX 초기 분석에서의 주된 결론은 NGINX는 스트림 데이터 프레임에 "인접성"을 부여하는데 대체적으로 실패하였다는 것입니다. NGINX HTTP/2 계층에서 스트림은 격리된 형태로 처리되거나 다른 스트림의 프레임은 공유 큐와 같은 장소에서 거의 시간을 보내지 않습니다.  따라서 유용한 비교 기회가 줄어듭니다.

우리는 아주 과학적이지는 않지만 유용한 측정 방법인 잠재성을 시도 하였습니다. 이는 개선된 HTTP/2 우선순위 지정 방법 (또는 NGINX 기본의 우선순위 지정 방법)이 얼마나 효과적으로 큐에 있는 데이터 스트림에 적용될 수 있는지를 알려주는 것입니다. 잠재성은 본질적으로 우선 순위 지정의 유효성을 측정하는 것은 아닙니다. 측정 방법은 이후에 다루게 될 것 입니다만 알고리즘을 적용하는 동안의 참여도를 측정하는 것에 가깝습니다. 간단히 설명하면 우선순위를 한번 처리할 때 관련된 스트림과 프레임을 생각해 볼 때 더 많은 스트림과 프레임이 관련되어 있다면 잠재성 수치가 높아 집니다.

초기 관찰에서 기본적으로 NGINX는 낮은 잠재성을 보여 주었습니다. 즉 기존의 HTTP/2 우선순위 모델에 따라 브라우저 또는 우리의 HTTP/2 우선순위 지정 방법 개선 제품에서 보내 오는 우선 순위를 잘 처리 하지 못하였습니다.

개선 방법

잠재성에 관련된 문제를 개선하고 시스템의 일반 성능도 개선하기 위해서 NGINX의 몇가지 핵심적인 문제를 찾아 내였습니다. 다음에서 설명할 점들은 HTTP/2 우선순위 지정 방법 개선 프로젝트의 초기 릴리즈의 일부로 개선 작업이 이루어졌으며, 또한 추후 몇달간 엔지니어링 자원을 투입해야 하는 별도의 프로젝트로 나누어지게 되었습니다.

HTTP/2 프레임 쓰기 큐 반환

쓰기 큐 반환은 개선된 HTTP/2 우선순위 지정 방법 프로젝트 릴리즈에 포함되어 있습니다. 원본 NGINX에 대한 수정이라기 보다는 실제로 HTTP/2 우선순위 지정 방법 구현을 개선하는 도중에 생겨난 변경 사항입니다. 또한 잠재성을 향상시키기 위한 좋은 방법인 데이터 보존의 좋은 예이기도 합니다.

원본 NGINX와 유사하게, 우리의 개선된 HTTP/2 우선순위 지정 방법 알고리즘은 우선순위 지정 전략을 반복적으로 적용한 결과로 복수의 HTTP/2 데이터 프레임을 쓰기 큐에 배치하는 방법입니다. 쓰기 큐의 내용은 최종적으로 다운스트림 TLS 계층으로 보내지게 됩니다. 또한 원본 NGINX와 유사하게 네트워크 연결이 일시적으로 쓰기 용량을 초과할 때에는 쓰기 큐는 TLS 계층에 전체가 아닌 일부만을 보내기도 합니다.

프로젝트 초기에는 원본 NGINX와 유사하게 쓰기 큐가 TLS 계층에 일부만을 보내게 되는 경우 쓰기 큐에 남은 내용이 처리될 때 까지 프레임들을 그대로 남겨 두었고 이후 쓰기 동작이 다시 일어나게 되는 경우 남은 데이터 쓰기를 재시도하였습니다.

원본 NGINX는 이런 방식을 취하고 있는데, 쓰기 큐만이 대기중인 데이터 프레임을 저장하는 유일한 장소이기 때문입니다. 하지만 개선된 HTTP/2 우선순위 지정을 위해 변경된 우리의 NGINX에는 원본 NGINX에는 없는 독특한 구조를 갖고 있습니다: 우선순위 지정 알고리즘을 적용 하기 전에 일시적으로 데이터 프레임을 저장하는 스트림별 데이터 프레임 큐 입니다.

일부 쓰기가 일어나는 경우에 아직 쓰지 못한 프레임을 스트림별 큐로 반환할 수 있도록 하는 것입니다. 만약 다 쓰지 못한 데이터 뒤에 또 다른 데이터 그룹이 도착하는 경우 아직 쓰지 못하고 남아 있는 프레임도 우선순위 지정을 할때 다시 포함되도록 하여 우리의 알고리즘의 잠재성을 높이도록 하는 것입니다.

다음 도표는 이러한 과정을 나타냅니다:

이것 반환 기능 하나만으로도 잠재성을 크게 증대할 수 있게 개선된 HTTP/2 우선순위 지정 기능을 릴리즈 하였다는 점을 기쁘게 생각합니다. 또한 이를 통해서 더 섬세하다는 이유로 Speed Week에 릴리즈하지 않고 미루어 두었던 기능을 위한 시간을 확보할 수 있었습니다.

HTTP/2 프레임 쓰기 이벤트 순서 변경

Cloudflare 인프라에서는 사용자가 보내는 단일 HTTP/2 연결 안의 복수의 스트림을 복수의 HTTP/1.1 연결로 만들어서 업스트림의 Cloudflare 제어부로 보냅니다.

주의할 점은, 이렇게 프로토콜을 다운그레이드하는 것은 직관적이지 못해 보이며, 게다가 업스트림으로 보낼 때 HTTP 킵얼라이브를 끄고 연결당 하나의 요청만을 보낸다는 점을 이야기하면 더욱 직관적이지 못해 보일 것입니다만, 이런 구조는 CPU 작업 분산을 향상시키는 등 몇가지 이점이 있습니다.

NGINX가 업스트림 HTTP/1.1 연결에서 읽기 동작을 감시하고 있을 때 복수의 연결에서 읽기 대기중이라는 것을 감지하고 한번에 처리하려고 할 수도 있습니다. 하지만 이런 경우 각각의 업스트림 연결은 하나씩 처음부터 끝까지 순차적으로 처리됩니다: HTTP/1.1 연결에서 데이터를 읽고 HTTP/2 스트림의 프레임을 만들어서 HTTP/2 연결에 쓰고 다시 TLS 계층으로 전달 됩니다.

기존의 NGINX 작업 흐름은 다음 그림과 같습니다.

한번에 하나의 스트림씩 프레임을 만들어 TLS 계층에 보내는 방식으로는 다운스트림 연결의 대역폭을 다 쓰기 전에 한 스트림의 많은 프레임이 NGINX 시스템을 통과해서 큐에 쌓이게 되며 이때 인접성을 갖는 프레임들에 우선순위 로직이 적용될 수 있도록 해 줍니다. 이 방식은 잠재성을 해치며 우선순위 지정의 효과를 감소 시킵니다.

Cloudflare의 개선된 HTTP/2 우선순위 지정 방법에서는 NGINX를 다음과 같은 모델에서 설명하는 내부 작업 흐름으로 변경 하였습니다:

각각의 업스트림 연결에 대해서 데이터를 프레임으로 나누어 HTTP/2 데이터 프레임으로 계속 만들어 내지만 프레임들이 업스트림 마다 모두 하나의 쓰기 큐에 들어가도록 하지 않으며 그 대신에 프레임을 스트림별 큐로 저장 합니다. 그리고 나서 연결당 하나의 쓰기 이벤트를 만들어서 우선순위를 적용하고 모든 스트림의 HTTP/2 데이터 프레임을 큐에 넣어서 쓰도록 합니다.

이러한 단일 이벤트에서는 각각의 스트림별 큐에 저장되어 있는 데이터들이 모두 인접성을 갖도록 해서 우선순위 지정 알고리즘의 잠재성을 크게 높여주게 됩니다.

실제 코드에 가까운 형태로 보면, 원래 코드는 다음과 같은 것이었습니다:

ngx_http_v2_process_data(ngx_http_v2_connection *h2_conn,
                         ngx_http_v2_stream *h2_stream,
                         ngx_buffer *buffer)
{
    while ( ! ngx_buffer_empty(buffer) ) {
        ngx_http_v2_frame_data(h2_conn,
                               h2_stream->frames,
                               buffer);
    }

    ngx_http_v2_prioritise(h2_conn->queue,
                           h2_stream->frames);

    ngx_http_v2_write_queue(h2_conn->queue);
}

이것을 다음과 같이 바꾸었습니다:

ngx_http_v2_write_streams(ngx_http_v2_connection *h2_conn)
{
    ngx_http_v2_stream *h2_stream;

    while ( ! ngx_list_empty(h2_conn->active_streams) ) {
        h2_stream = ngx_list_pop(h2_conn->active_streams);

        ngx_http_v2_prioritise(h2_conn->queue,
                               h2_stream->frames);
    }

    ngx_http_v2_write_queue(h2_conn->queue);
}

생각보다 작아 보이지만 이런 변경에는 큰 위험이 있습니다. 잘 만들어져 있고 디버깅되어 있는 NGINX의 이벤트 흐름을 상당 부분 수정하였기 때문입니다. 마치 젠가 게임에서 블럭을 빼서 다른 위치에 놓는것과 같은 위험입니다: 경쟁 상태, 잘못된 이벤트나 이벤트 블랙홀이 트랜젝션 처리 중에 시스템 정지를 초래할 수도 있습니다.

이러한 위험 때문에 이번 Speed Week 에서는 이 변경 사항은 포함하지 않도록 하였습니다. 더 테스트 하고 개선한 뒤에 향후 릴리즈할 계획 입니다.

업스트림 버퍼 일부 재사용

NGINX에는 업스트림에서 데이터를 읽을 때 저장해 두는 내부 버퍼 구역이 있습니다. 처음에는 이 버퍼 전체가 준비(Ready) 상태에 놓이게 됩니다. 업스트림에서 데이터를 읽어서 준비 버퍼에 저장되면, 버퍼 내용의 일부가 다운스트림 HTTP/2 계층으로 전달 됩니다. HTTP/2 계층은 그 데이터를 전달할 책임을 가지므로 이 버퍼 내용은 바쁨(Busy)으로 표시되고 HTTP/2 계층에서 TLS 계층으로 전달될 시간 동안 (컴퓨터의 관점에서 약간의 시간) 계속 바쁨 상태로 남아 있게 됩니다.

이런 시간 동안 업스트림 계층은 데이터를 계속 읽어서 버퍼의 남아있는 준비 구역에 저장하고 준비 중인 지역이 남아 있지 않을 때 까지 점차적으로 HTTP/2 계층으로 데이터를 전달 합니다.

바쁨으로 표시된 데이터가 최종적으로 HTTP/2 계층에 전달이 완료 되면 데이터를 갖고 있었던 버퍼 영역은 이용 가능(Free)로 표시 됩니다.

이 과정을 표현하면 다음 그림과 같습니다:

그러면 여러분은 다음과 같은 질문을 할 수 있습니다: 업스트림 버퍼의 앞부분 (위 그림에서 청색으로 표시된 부분)이 이용 가능으로 표시되어 있을 때 업스트림 버퍼의 뒷 부분이 아직 바쁨으로 표시 되어 있어도 업스트림에서 데이터를 더 읽어 저장하도록 이용 가능 부분이 재사용될 수 있나요?

이 질문에 대한 답은: 아니오 입니다.

그 이유는 버퍼의 일부가 아직 바쁨 상태라면 NGINX는 읽기를 위해서 버퍼 내용의 일부가 재사용 되는 것을 허용하지 않기 때문입니다. 버퍼 전체가 이용 가능 상태이어야만 버퍼는 준비 상태로 바뀌게 되고 업스트림에서 데이터를 읽어올 수 있게 됩니다. 요약하자면 업스트림 데이터는 버퍼 뒤에 준비 상태 지역으로 읽어들일 수 있지만 버퍼 앞부분의 이용 가능 상태 지역으로는 저장할 수 없습니다.

이는 NGINX의 단점이며 시스템의 데이터 흐름을 방해한다는 점에서 바람직하지 않습니다. 그래서 다음과 같은 질문을 해 봅니다: 버퍼 지역을 한바퀴 돌아 이용 가능 상태가 되는 버퍼 앞부분을 재사용 할 수 있을까? 이에 대한 대답으로 NGINX의 다음과 같은 버퍼링 모델을 조만간 테스트 하고자 합니다:

TLS 계층의 버퍼링

이 글에서 여러번 TLS 계층에 대해 언급하고 HTTP/2 계층에 어떻게 데이터를 전달하는지 언급한 바 있습니다. OSI 네트워크 모델에서 TLS는 프로토콜(HTTP/2) 계층 바로 아래에 놓이고 NGINX와 같이 의식적으로 설계된 많은 네트워킹 소프트웨어들은 이러한 계층화를 반영하도록 소프트웨어 인터페이스가 분리되어 있습니다.

NGINX의 HTTP/2 계층은 현재의 데이터 프레임을 모아서 출력 큐에 우선순위 대로 배치하고 이 큐를 TLS 계층으로 전달 합니다. TLS 계층은 데이터의 실제 암호화를 수행하기 이전에 연결 당 버퍼를 이용하여 HTTP/2 계층의 데이터를 수집 합니다.

이 버퍼의 목적은 TLS 계층에게 암호화하기에 의미있는 양의 데이터를 전달하기 위함 입니다. 만약 버퍼 크기가 너무 작거나 TLS 계층이 HTTP/2 계층의 데이터를 버퍼링 없이 바로바로 전달 한다면 여러개의 작은 블럭을 암호화해서 전송하는 부하가 생기게 되며 시스템 성능에 안좋은 영향을 미칠 수 있습니다.

다음 그림은 버퍼 크기가 너무 작을 때 입니다:

만약 버퍼가 너무 크다면 너무 많은 양의 HTTP/2 데이터를 암호화해야 하고 네트워크 용량 때문에 더 쓸 수 없다면 데이터가 TLS 계층에 계속 남아 있게 되며 반환 과정에서 HTTP/2 계층으로 돌아오지 않게 되므로, 반환 효과를 반감 시키게 됩니다. 다음 그림은 버퍼 크기가 너무 클 때 입니다:

향후에는 TLS 버퍼링의 "적당한" 지점을 찾고자 하는 노력을 시작하고자 합니다. 암호화와 네트워크 쓰기 효율을 유지할 수 있을 정도로 충분히 크며, 불완전한 네트워크 쓰기에 의한 응답성과 반환의 효율을 저하하지 않을 정도의 TLS 버퍼 크기를 찾고자 합니다.

감사합니다 - 다음으로!

HTTP/2 우선순위 지정 방법 개선 프로젝트에는 Cloudflare 엣지에서 사용자에게 데이터를 보내는 방법을 근본적으로 개선한다는 높은 목표가 있었습니다. 테스트 결과와 고객이 보여주는 피드백을 본다면 이 목표는 분명히 달성한 것입니다! 하지만 이 프로젝트에서 얻고자 하는 중요한 점 중 하나는 우리의 NGINX 소프트웨어 인프라에서 내부 데이터 흐름 구조가 사용자가 관찰하는 트래픽 관점에서 핵심적인 역할을 한다는 것입니다. 또한 일부의 (때로는 매우 중요한) 코드 변경이 우리의 우선순위 지정 알고리즘의 효율과 성능에 중대한 영향을 미친다는 점을 알게 되었습니다. 또 다른 긍정적인 결과로 HTTP/2 를 개선하는 것에 더불어 새롭게 배운 이 기술을 QUIC상의 HTTP/3에도 적용할 수 있을 것이라는 기대하고 있습니다.

우리는 이러한 NGINX 변경점을 커뮤니티와 같이 공유하고자 하며 이 티켓을 만들었고 이를 통해서 이벤트 순서 변경과 버퍼 일부 재사용을 NGINX에 어떻게 반영할 지 NGINX 팀과 논의하고자 합니다.

Cloudflare가 성장함에 따라 우리의 소프트웨어 인프라의 요구사항도 변화 합니다. Cloudflare는 이미 TCP상의 HTTP/1 전송 프록시 및 UDP, TCP 트래픽의 레이어 3, 4의 공격 방어 단계를 넘어서 나아가고 있습니다. 이제 QUIC과 HTTP/3과 같은 새로운 기술과 더불어 메시징이나 스트리밍 미디어와 같은 폭 넓은 프로토콜의 프록시로 나아가고 있습니다.

이러한 노력 속에서 확장성, 지역적인 성능, 전체 성능, 내부 모니터링, 디버깅, 빠른 릴리즈, 유지보수 등과 같은 의문에 답할 수 있는 새로운 방법을 찾고 있습니다.

여러분이 이러한 질문에 답할 수 있도록 돕고 싶고 하드웨어, 소프트웨어 확장성, 네트워크 프로그래밍, 비동기 이벤트 및 퓨처 기반 소프트웨어 설계, TCP, TLS, QUIC, HTTP, RPC 프로토콜, Rust 등등에 대해서 잘 아신다면 여기를 봐 주세요.