TrueAsync Server워커와 HTTP/3

워커와 HTTP/3

부하가 걸린 서버에서 htop 같은 걸 열어 보세요. 우리 프로세스는 수천 건의 요청을 처리하며 열심히 일하고 있는데... 여덟 개의 코어 중 하나만 바쁩니다. 일곱 개는 놀고 있어요. 짜증나나요? 짜증납니다.

여기에 새로운 것은 없습니다. 스레드를 다룬 장에서 이미 짚었죠. 작업들이 I/O를 기다리는 동안에는 코어 하나로 모두에게 충분합니다. 하지만 실제 트래픽 아래에서 서버는 기다리기만 하지 않습니다. HTTP 파싱, TLS 핸드셰이크, JSON 직렬화, 이것들은 계산이고, 그 하나뿐인 코어에 부딪힙니다.

바로 그 장의 처방: 계산에 스레드를 주어라. 서버는 이를 단 한 줄로 적용합니다:

php
$config = (new HttpServerConfig())
    ->addListener('0.0.0.0', 8080)
    ->setWorkers(Async\available_parallelism());

setWorkers(N)은 N개의 워커를 띄우며, 이것은 말 그대로 열네 번째 장의 Async\ThreadPool입니다. "비슷한 메커니즘"이 아니라 바로 그 동일한 것입니다. 즉 규칙도 이미 알고 있는 셈이죠. 각 워커는 자신만의 PHP 환경, 자신만의 이벤트 루프, 자신만의 풀을 가진 별개의 운영체제 스레드입니다. 설정과 핸들러는 스레드 간 전달의 일반 규칙에 따라 각 워커로 복사됩니다. 부모의 start()는 그들 모두를 기다립니다.

한 가지 질문이 남습니다. 들어오는 연결을 누가 워커들에게 나눠주는가? 그리고 여기가 가장 멋진 부분입니다. 아무도 안 합니다. 각 워커가 SO_REUSEPORT 플래그로 같은 포트를 열고, 그때부터는 리눅스 커널 자신이 연결을 그들 사이에 분배합니다. 디스패처도, 큐도, 락도 없습니다. 하나의 포트 뒤에 숨은 여덟 개의 독립적인 서버죠.

부트로더: 각 워커를 예열하기

첫 번째 시리즈에서 ThreadPool에는 부트로더가 있었고, 거기서는 선택적인 편의 기능처럼 보였습니다. 여기서는 그것이 중심 인물이 됩니다. 이유는 이렇습니다. 우리가 첫 장에서 "start() 전에 한 번" 했던 모든 것이 이제는 모든 워커에서 일어나야 합니다. 각자가 자신만의 메모리를 갖고 있으니까요.

php
$config
    ->setWorkers(8)
    ->setBootloader(function () {
        require __DIR__ . '/vendor/autoload.php';

        Database::initPool(min: 4, max: 16); // 각 워커마다 자신만의 PDO Pool
        Router::compile();
    });

클로저는 워커마다 한 번씩, 첫 요청 전에 실행됩니다. 그 안에서 예외가 발생하면 풀 전체가 멈춥니다. 가혹한가요? 옳은 일입니다. 여덟 개 중 하나가 덜 예열된 워커를 가진 서버는 여덟 번째 클라이언트마다 오류를 내뱉는 기계입니다. 차라리 아예 시작하지 않는 편이 낫습니다.

채팅이 워커를 만나다

이제 약속했던 폭발입니다. 이전 장에서 우리는 "프로세스 메모리 안의 공유 상태"라는 공식 위에 채팅을 지었습니다. 그 공식을 천천히 다시 읽어 보세요. 메모리. 안의. 프로세스.

여덟 개 중 어느 것?

커널은 연결을 제멋대로 흩뿌립니다. Alice는 워커 3에, Bob은 워커 5에 떨어졌습니다. 각 워커는 자신만의 메모리를 갖고 있으니, 따라서 자신만의 $room을 갖습니다. 같은 이름의 두 채팅방이 서로에 대해 결코 알지 못하는 거죠. Alice는 허공에 대고 쓰고, Bob은 다른 허공 속에서 침묵합니다. 경쟁 조건도, 오류도 없습니다. 채팅은 그저 조용히 채팅이 아니게 되었습니다.

어떻게 해야 할까요? 표준적인 탈출구는 이렇습니다. 작은 시스템이라면 정직하게 setWorkers(1): 하나의 워커는 수천 개의 WebSocket 연결을 너끈히 붙들 수 있습니다. 그것들은 대부분 기다리고 있으니까요. 큰 시스템이라면 공유 상태를 밖으로, 보통은 Redis pub/sub로 옮기고, 워커들이 그것을 통해 소통하게 하세요. 기억할 규칙 하나: 요청 상태는 요청 스코프에, 프로세스 상태는 워커에, 공유 상태는 외부 저장소에.

HTTP/3: 같은 핸들러, 다른 전송 계층

이왕 스케일링하는 김에, 스택을 현대적으로 끌어올려 봅시다. HTTP/3에 대해서는 세 가지만 알면 충분합니다. TCP가 아니라 UDP 위의 QUIC에서 동작합니다. 연결을 더 빨리 수립하고, 하나의 손실된 패킷이 모든 스트림을 한꺼번에 멈춰 세우지 못하게 합니다. 그리고 반드시 익혀야 합니다. 브라우저들이 이미 그것을 선호하기 때문이죠.

거대한 공사처럼 들리나요? 보세요:

php
$config = (new HttpServerConfig())
    ->setWorkers(Async\available_parallelism())
    ->setCertificate('/etc/tls/profile.crt')
    ->setPrivateKey('/etc/tls/profile.key')
    ->addListener('0.0.0.0', 443, tls: true)  // TCP: HTTP/1.1과 HTTP/2
    ->addHttp3Listener('0.0.0.0', 443);       // UDP: HTTP/3

한 줄, addHttp3Listener입니다. 같은 포트이지만 충돌은 없습니다. 443/TCP는 HTTP/1.1과 HTTP/2를 대기하고, 443/UDP는 QUIC로 갑니다. 여기에는 별도의 TLS 플래그가 없습니다. 명세상 QUIC는 TLS 없이는 존재하지 않기 때문이죠. 인증서는 서버에서 가져옵니다.

클라이언트는 UDP 입구에 대해 어떻게 알까요? 스스로 알아냅니다. TCP를 통한 모든 응답에 서버는 Alt-Svc: h3=":443" 헤더를 추가합니다. 브라우저가 그것을 보고 다음 요청들을 HTTP/3으로 보냅니다. 첫 방문은 HTTP/2로, 그다음은 QUIC로, 그리고 아무도 아무것도 설정하지 않았습니다.

bash
$ curl --http3 -I https://profile.example.com/
HTTP/3 200
alt-svc: h3=":443"; ma=86400

이 장에서 제가 가장 좋아하는 게 뭔지 아세요? 이 장에 없는 것입니다. 우리는 여덟 개의 스레드와 세 번째 버전의 HTTP를 켰지만, 핸들러에서는 단 한 줄도 바뀌지 않았습니다. 2장의 라우팅, 6장의 SSE, 7장의 채팅, 그 어느 것도 주변 세계가 멀티스레드가 되고 QUIC를 말하기 시작했다는 것을 알지 못합니다. 스케일링은 그것이 있어야 할 곳, 설정 속으로 빠져나갔습니다.

서버가 빨라졌습니다. 다음 단계는 그것을 침몰하지 않는 것으로 만드는 일입니다. 과부하에 대해, 느린 클라이언트에 대해, 트래픽이 한창일 때의 배포에 대해 무엇을 할 것인가. 나쁜 날들에 관한 한 장입니다.