TrueAsync Server프로덕션

프로덕션

좋은 서버는 어떻게 동작하느냐로 데모와 구별되지 않습니다. 어떻게 실패하느냐로 구별됩니다. 기분이 좋을 때는 누구나 잘 돌아갑니다. 프로덕션은 나쁜 날이 찾아오는 곳에서 시작됩니다. 트래픽 급증, 느린 클라이언트, 가장 부적절한 순간의 배포. 이 장은 온전히 나쁜 날들에 관한 것입니다.

타임아웃: 아무도 영원히 기다리지 않는다

첫 번째 시리즈에서 우리는 철칙 하나를 가져왔습니다. 모든 대기에는 제한이 있어야 한다. 연결에 대체 어디에 대기가 있는지 살펴봅시다. 클라이언트가 요청을 보낸다, 하나. 응답을 받는다, 둘. keep-alive로 요청과 요청 사이에 머문다, 셋. 각 단계마다 자신만의 제한이 붙습니다:

php
$config
    ->setReadTimeout(30)       // 요청을 보내는 데 30초 넘게 걸리면? 안녕
    ->setWriteTimeout(60)      // 그리고 응답을 가져가는 데 1분 넘게 걸리면? 그것도 안녕
    ->setKeepAliveTimeout(15); // 유휴 연결은 길어야 15초 산다

읽기 타임아웃은 느린 인터넷에 관한 것만이 아닙니다. 멋진 이름을 가진 공격이 있습니다. slowloris: 클라이언트가 1분에 1바이트씩 요청을 보내며 연결을 영원히 점유합니다. 이런 클라이언트 백 개면 타임아웃 없는 서버는 끝장입니다. 타임아웃이 있으면 그저 백 개의 끊긴 연결일 뿐이죠.

이미 SSE 장에서 알고 있는 유일한 정당한 예외: 스트림에서는 쓰기 타임아웃을 끕니다, setWriteTimeout(0).

제한: 값싸게 거절하기

이제 과부하에 대해서입니다. 질문은 "만약"이 아니라 "언제"입니다. 그리고 그 순간 서버에게는 정확히 두 갈래 길이 있습니다. 첫 번째 길: 모두를 받아들이고, 큐를 쌓고, 느려지다가, 결국 처리할 수 있었던 이들을 포함해 모두가 타임아웃을 받습니다. 두 번째 길: 남는 이들에게 즉시 "바쁘다"고 말하고, 나머지는 아무 일 없었다는 듯 처리합니다.

두 번째 길은 이렇게 설정합니다:

php
$config
    ->setMaxConnections(10_000)          // 연결의 하드 제한
    ->setMaxInflightRequests(1_000)      // 병행으로 처리되는 요청
    ->setMaxBodySize(10 * 1024 * 1024);  // 본문이 10 MiB보다 크면? 413

setMaxInflightRequests를 자세히 보세요. 알아보겠나요? 우리의 오랜 친구, 동시성 제한입니다. POOL_MAX, 그룹의 concurrency, 이제는 서버 전체 수준에서죠. 초과된 요청은 즉각 503 Retry-After: 1을 받습니다. 즉각이 핵심어입니다. 클라이언트는 진실을 1밀리초 만에 알고 나중에 재시도할 겁니다. 큐에 1분간 매달려 타임아웃을 기다리는 대신에요.

세 번째 방어선도 있습니다. 가장 우아한 것이죠. 서버 자신이 요청들이 큐에서 얼마나 오래 대기하는지 측정합니다. 대기 시간이 지속적으로 늘어나고 있나요? 그렇다면 우리가 감당하지 못하는 것이고, 서버는 따라잡을 때까지 새 연결 받기를 일시적으로 멈춥니다. 알고리즘의 이름은 CoDel이지만, 당신은 그것을 다른 이름으로 알고 있습니다. 채널 장에서 다룬 배압을 accept()에 적용한 것이죠. 인터넷은 생산자, 핸들러는 소비자이고, 생산자가 느려집니다. 설정은 하나입니다:

php
$config->setBackpressureTargetMs(20); // 큐에서 어느 정도의 대기 시간을 정상으로 볼 것인가

압축

좋은 소식: 응답 압축은 이미 동작합니다. 서버는 Accept-Encoding을 통해 클라이언트와 협상하고 사용 가능한 코덱 중 최선의 것을 고릅니다: zstd, brotli, gzip. 보통 여기서 할 일은 없지만, 세 개의 레버는 알아둘 가치가 있습니다:

php
$config
    ->setCompressionMinSize(1024)   // 작은 것은 압축하지 마라: 오버헤드가 더 비싸다
    ->setZstdLevel(3)               // 코덱별 속도/압축률 균형
    ->setCompressionMimeTypes([...]); // 화이트리스트: 텍스트는 압축, jpeg는 이미 압축돼 있다

여기에 응답 쪽 레버 하나 더: 압축이 해로운 엔드포인트를 위한 $res->setNoCompression(). 참고로 SSE 헬퍼들은 이것을 스스로 호출합니다. 버퍼링을 하는 gzip은 전달의 모든 즉각성을 죽여버리니까요.

로그

모든 게 잘 돌아가는 동안 서버는 침묵합니다. 동의하겠지만, 문제에 대해서는 사용자에게서 듣지 않는 편이 낫습니다:

php
use TrueAsync\LogSeverity;

$config
    ->setLogSeverity(LogSeverity::WARN)
    ->setLogStream(STDERR);

WARN은 실패한 TLS 핸드셰이크, 끊긴 클라이언트, 삼켜진 예외를 아우릅니다. INFO는 생명주기를 더합니다: 시작, 포트, 워커. 싱크는 평범한 PHP 스트림입니다. systemd와 Docker를 위한 stderr, 파일, 무엇이든요.

우아한 종료

그리고 마지막 나쁜 날, 사실은 좋은 날이기도 한 것: 배포입니다. 유화로 그릴 만한 장면이죠. 서버에 천 개의 살아 있는 연결이 있고, 그중 백 개는 데이터베이스에 한창 쓰는 중인데, 여기 새 버전이 찾아옵니다. kill -9? 백 개의 중단된 트랜잭션과 업로드가 깨진 사용자들의 무리를 즐기시죠.

우아한 버전은 이렇게 생겼습니다:

php
$config->setShutdownTimeout(30);

stop()이나 SIGTERM을 받으면 서버는 먼저 새 연결 받기를 멈추고, 현재의 것들이 최대 30초까지 제 수명을 다하도록 둡니다. 그때까지 못 끝낸 것은 취소됩니다. 그리고 여기서, 이 시리즈에서 마지막으로, 스코프 규율이 그 값어치를 합니다. 취소는 각 요청의 트리를 따라 협력적으로 전파되고, finally 블록이 자원을 해제하며, 풀이 닫히지 않은 트랜잭션을 롤백합니다. 우아한 종료는 기능으로 작성된 것이 아닙니다. 그것은 아키텍처에서 저절로 떨어져 나온 것입니다.

로드 밸런서 뒤의 오래 사는 연결을 위해서는 손질 하나가 더 있습니다, setMaxConnectionAgeMs(): 주어진 수명보다 오래된 연결은 우아하게 닫힙니다(Connection: close, GOAWAY). 그래서 클라이언트들이 한 기계에 몇 년씩 매달리는 대신 여러 기계로 재분배됩니다.

이제 이것이 프로덕션입니다. 과부하는 빠른 거절을 만나고, 느린 클라이언트는 타임아웃에 부딪히며, 배포는 트랜잭션을 찢어놓지 않고, 서버는 자신의 문제를 스스로 보고합니다. 전체 시리즈에 마지막 관문 하나가 남았습니다. 지금까지는 브라우저와 curl만이 우리 서버와 대화했습니다. 이제 다른 대화 상대들, 이웃한 서비스들을 그들의 모국어로 들여보낼 때입니다.