Apache와 Nginx는 꽤 오래전부터 비교되는 프로그램입니다. Apache는 Connection 당 fork하여 Process(최근에는 Thread로 바뀌었다지만)를 할당하기 때문에 Context switching의 비용이 큰 반면, Nginx는 Event-driven이라 훨씬 가볍고 대규모의 Connection을 처리하는 데에도 부담이 없다고 합니다.
하지만 그 Event-driven이라는 말이 정확히 어떤 의미일까요? 너무 추상적으로만 알고 있는 것은 아닌가 싶어서, 직접 찾아 본 내용을 정리해 둡니다.
시작: Nginx 메인 프로세스
Nginx 프로세스 자체에서는 많은 일을 하지는 않습니다.
- 캐시 메모리 로드 및 관리
- 설정 읽어들이기 등...
- 워커 프로세스 가동
- 본격적으로 연결을 받아들이고 처리하는 역할을 수행합니다.
따라서, 본격적인 작업은 워커 프로세스가 수행한다고 볼 수 있습니다.
물론 전혀 메인 프로세스의 존재 이유가 없는 것은 아닙니다. 나중에 있는 No-downtime 설계에도 이유가 있습니다.
워커 프로세스
워커 프로세스 안을 조금 살펴보면, 흔한 웹 서버의 구조를 보여주고 있습니다. TCP 트래픽이 오면, 간단한 상태 머신을 태워서(대충 코드 태운다는 말), 적당히 라우팅 하거나/콘텐츠를 회신하거나 등의 역할을 해주고 있습니다.
그렇다면, 역시 핵심은 내부의 스테이트 머신이 됩니다. 안에서는 그렇게 유명한 Event-driven 구조를 쓰고 있다고 마침 쓰여 있네요.
Event-Driven
이벤트 드리븐이란, Listen 소켓에서 “이벤트" 를 받아 이를 처리하는 구조를 의미한다고 합니다. 얼핏 보아서는 이전 소켓이랑 무슨 차이가 있는가 싶지만, 기존의 구현은 소켓 인터페이스에 의존하는 반면 이벤트 드리븐의 경우에는 “이벤트"를 통해서 이를 구현합니다. 그리고 그 내면에는 Non-blocking I/O가 숨어 있습니다.
Blocking I/O
대부분의 프로그램들은 위처럼 blocking I/O를 수행합니다.
- 소켓을 열고서 새 연결이 들어오기까지를 기다리고,
- 새 연결이 들어오면, 쓰거나 읽기를 수행합니다.
- 이 과정에서, 상대방이 내용을 읽거나 / 회신할때까지 기다리게 됩니다(blocking).
- 모든 작업이 끝나면 연결이 종료됩니다.
여기에서, 상대방을 “기다리는" 과정이 필연적으로 동반됩니다. 그런데 기다리는 것 자체가 자원 소모가 될까요? 물론 보통의 경우 어떤 스레드/프로세스가 기다려야 한다는 사실을 알고 있으면 무언가를 할 필요가 없기 때문에, 그렇지는 않습니다. 하지만 예를 들어 커넥션이 10000개가 넘어가는 경우, 10000개의 커넥션에 대해서 기다려야 하는지를 확인하는 것 만으로도 엄청난 자원 소모가 됩니다. 보통 그러한 오버헤드를 보다 구체적으로 정리하면,
- Blocking 된 프로세스 및 Thread에 대한 Context Switching 비용
- Connection 당 생성되는 fd (file description)
(fd 정보는 프로세스에 포함됩니다)
정도가 됩니다.
Non-blocking I/O
그렇다면, Nginx가 그렇게 자랑하는 Event-Driven에서는 어떨까요? 앞에서 말한대로 Connection 소켓을 Non-blocking 으로 처리하는 걸 볼 수 있습니다. 대신, 이벤트가 발생할 시 처리하는 구조로 되어 있네요.
Listen과 Connection 소켓에 대해서 각각 “이벤트"가 발생 시 아래와 같은 방식으로 작업을 수행합니다.
- Listen 소켓에서 이벤트가 발생하면?
- 생성된 Connection 소켓을 Non-blocking 으로 설정하고, 소켓 리스트에 저장 및 이벤트 감시
(참고: 이벤트는 select C API를 통해 감지할 수 있습니다. 자세한 내용은 IBM Example 및 Linux manual page - select 참고)
- 생성된 Connection 소켓을 Non-blocking 으로 설정하고, 소켓 리스트에 저장 및 이벤트 감시
- Connection 소켓에서 이벤트가 발생하면?
- 읽고 적당히 처리
- 오류 발생시, connection close
이 일련의 과정들은 모두 하나의 프로세스 안에서 일어나기 때문에, Context switching 오버헤드가 훨씬 적습니다. 이에 따라 훨씬 더 많은 커넥션을 더 유연하게 수용할 수 있게 되는 것입니다.
추가: No-downtime
재미있게도 Nginx는 설정 변경시나 심지어 업데이트시에도 Downtime이 없다고 합니다. 어떤 방식으로 구현했는지 궁금해서 좀 봤는데요...
Configuration 경우에는, SIGHUP
신호를 받을 경우 Master process는 새로운 설정을 읽어들이고, 이 설정을 토대로 새로운 Worker (process) set을 만든다고 합니다. 기존 워커 프로세스들의 작업이 모두 끝나면, 완벽하게 새로운 설정을 포함하는 워커 셋으로 바꿔치기 되는 구조입니다.
마스터 프로세스가 괜히 있는 게 아니죠?
바이너리 교체의 경우, SIGUSR2
신호를 받으면 새로운 마스터 프로세스를 띄우고, 기존 프로세스와 새 프로세스는 소켓으로 자료를 전달한다고 합니다. 이를 통해서 Listening 소켓을 공유할 수 있게 되고, 새로운 마스터 프로세스의 부팅이 끝나면 기존 프로세스는 Gracefully exit를 하게 됩니다.
새로 프로세스를 띄우면서 기존 프로세스와 소통하는 방식으로 no downtime을 구현한 건 새롭네요. 추가로, 이에 대해 보다 자세한 내용은 Controlling NGINX 에서 읽어볼 수 있습니다.
출처
'개발 > Engineering' 카테고리의 다른 글
문자열에 대한 철학 — Go, Rust (0) | 2022.04.28 |
---|---|
Go의 메모리 과다 사용 문제 해결해보기 (0) | 2022.04.26 |
Django는 과연 무거운가? (0) | 2022.04.23 |
Kafka는 어떻게 고성능을 달성하는가? (0) | 2022.04.23 |
[Go] Golang은 어떻게 효율성을 달성하는가 (2) | 2022.04.17 |