https://aosabook.org/en/v2/nginx.html
고성능, 높은 동시성, 낮은 메모리 사용
- Apache같은 기존 웹 서버는 요청마다 새로운 스레드나 프로세스를 생성해서 처리했기 때문에, 동시 연결 수가 많아질수록 리소스 소비가 급격히 증가하고 성능이 떨어졌습니다.
- 따라서, nginx는 C10K(10,000 Clients) 문제 해결을 목표로 이벤트 기반 구조로 설계되었다.
실제로 다양한 운영 체제에서 진행 중인 고급 이벤트 기반 메커니즘의 개발에서 영감을 얻었다.
- 아키텍처 특징
- 이벤트 기반
- 싱글 스레드
- 비동기
- 논블로킹
OS 관련
- nginx는 다양한 작업(요청 수락, 처리, 관리 등)을 수행하면서 epoll, kqueue 등 이벤트 알림 시스템을 사용한다.
- OS마다 다름
- Linux: epoll BSD/macOS: kqueue Solaris: event ports
- 이를 통해 비동기적(Asynchronous)이고 논블로킹(Non-blocking)인 방식으로 I/O 작업을 처리
- OS마다 다름
- nginx는 OS에 최대한 많은 힌트(정보)를 제공해서, 입출력(I/O)에 대해 비동기 피드백을 빠르게 받도록 한다.
- 예: 파일이 다 읽혔는지, 클라이언트 소켓에 쓸 수 있는지 등을 OS가 알려줌
- 이런 방식으로 불필요한 대기 없이 효율적인 I/O 처리가 가능
※ nginx가 OS에 주는 주요 “힌트”의 예시
- 읽거나 쓸 준비가 되었는지 알려달라는 요청 (event notification)
- nginx는 epoll, kqueue 등을 통해 다음을 요청합니다:
- “이 파일 디스크립터에 읽을 데이터가 생기면 알려줘”
- “이 소켓이 쓰기 가능한 상태가 되면 알려줘”
- 이걸 레벨 트리거 / 엣지 트리거 방식으로 감지할 수 있어요.
- OS는 이 요청을 처리하고, 이벤트가 발생하면 nginx에게 “지금 처리 가능하다”는 신호를 줍니다.
- nginx는 epoll, kqueue 등을 통해 다음을 요청합니다:
- sendfile() 호출
- 일반적인 파일 전송은: read() → 사용자 공간 → write() → 소켓
- 하지만 sendfile()은 커널 내에서 직접 파일 → 소켓으로 보내므로:
- 사용자 공간 복사 생략
- CPU 사용량 절감
- 훨씬 빠른 전송 가능
- nginx는 OS에게 “sendfile을 사용하라”는 힌트를 줍니다.
- 모듈 기반
nginx의 모듈 기반 아키텍처 덕분에, 핵심 코드를 수정하지 않고도 기능 확장이 가능하다. 즉, nginx는 매우 확장 가능한 구조를 가졌고, 여러 기능을 모듈화해 놓았기 때문에 유연함 1.9.11 이후부터 동적 모듈 로딩(load_module)이 가능
- core modules (핵심 기능)
- event modules (이벤트 처리용)
- phase handlers (요청 단계별 처리기)
- protocols (HTTP, FastCGI 등)
- variable handlers ($변수 처리)
- filters (본문 변형)
- upstreams (백엔드 서버 연결)
load balancers (로드 밸런싱)
"nginx uses multiplexing and event notifications heavily"- Multiplexing : 하나의 프로세스/스레드가 여러 연결(I/O)을 동시에 다루는 기술
- 보통 epoll, kqueue, select, poll 같은 OS 레벨의 이벤트 감시 시스템을 사용
"dedicates specific tasks to separate processes"- 역할을 나눠서 작업(task)을 여러 프로세스에 분리해서 맡깁니다.
- Master Process : 설정 파일 로딩, 워커 관리
- Worker Processes : 클라이언트 요청 처리 (이벤트 루프 돌리는 주체)
- Cache Loader / Cache Manager : (선택적) 캐시 관리 전용 프로세스
epoll이란 ?
Worker Model
worker : 싱글 스레드 기반의 프로세스
- nginx가 시작하면 먼저 listen 소켓들을 설정된 포트로 바인딩함.
- 공유된 listen 소켓에서 새 요청을 받아, 각 워커 내부의 고성능 run-loop를 통해 수천 개의 연결을 처리한다.
- 하나의 포트를 여러 워커가 공유하고, 이벤트 루프(run-loop)를 통해
연결 수신 → 요청 수신 → 응답 생성 → 응답 전송의 전 과정을논블로킹 방식으로 처리.
- 하나의 포트를 여러 워커가 공유하고, 이벤트 루프(run-loop)를 통해
어떤 워커가 요청을 받을지는 OS 커널 수준에서 accept() 처리에 의해 자동 결정됨 (e.g., epoll, SO_REUSEPORT 등).
nginx가 블로킹될 수 있는 유일한 경우는 워커 프로세스가 디스크 성능 부족으로 입출력을 기다리는 상황이다.
- 일반적으로, 코어당 하나의 워커를 두면 멀티코어 구조를 최대한 활용할 수 있고, 스레드 충돌이나 락업(lock-up)을 방지할 수 있다.
이벤트 루프
연결 수신 -> 요청 읽기 -> 응답 생성 -> 응답 보내기 -> 연결 종료
네트워크/디스크 상태를 비동기적으로 감시하고 처리함 ??
이벤트 루프(run-loop)를 통해 논블로킹 방식으로 요청 처리. ?
요청은 이벤트 기반 루프(run-loop) 안에서 비동기적으로 처리 ? 각 워커는 단일 스레드임에도 수천 개의 연결을 논블로킹으로 동시에 처리 가능 ?
- nginx는 다음을 통해 비동기 처리를 구현:
- epoll/kqueue: 네트워크 이벤트 감시
- sendfile: 파일 → 소켓 전송 최적화
- AIO (Asynchronous I/O): 디스크 I/O 비동기 처리
- callback 함수 + 타이머: 비동기 실행 순서 제어
파일 디스크립터 + epoll 구조
nginx가 수만 개의 동시 연결을 안정적으로 처리할 수 있는 핵심 이유는 “FD 기반 + epoll 이벤트 루프 + 논블로킹/비동기 처리”라는 구조적 설계
1. 연결마다 독립적인 FD로 관리되기 때문
- 각 클라이언트 연결은 파일 디스크립터(FD) 하나로 식별됨
- FD는 정수값이라 관리가 매우 빠름 (O(1) 연산)
- 커널 내부에서 FD마다 I/O 상태를 추적하므로, nginx는 FD 번호만 가지고 있으면 됨
- 이 덕분에 수만 개의 연결도 경량화된 구조로 추적 가능
- 동시에 커널이 직접 연결의 상태, 이벤트 발생 여부를 추적해주기 때문에 안정적
2. epoll이 이벤트가 있는 FD만 알려주기 때문
| 시스템 콜 | 처리 방식 | 단점 |
|---|---|---|
select() | FD 집합을 전부 순회 | FD 수 제한 있음 (보통 1024) |
poll() | 배열 순회 | 1만 개면 1만 개 다 순회 (O(N)) |
epoll() | 이벤트 있는 FD만 반환 (O(1)) | 매우 빠름, 스케일 가능 |
3. 논블로킹 + 비동기 처리로 “멈추지 않고 처리” 가능하기 때문
- nginx는 모든 소켓을 non-blocking mode로 설정
- 데이터를 바로 읽거나 쓸 수 없으면 → 그냥 넘기고 다음 작업 수행
- 즉, 작업이 완료될 때까지 블로킹(wait) 하지 않음 → 한 연결이 느려도 다른 연결에는 영향 없음
- 디스크나 네트워크가 준비되면 epoll 이벤트로 다시 알림 받음 (비동기, 즉 작업의 완료를 기다리지 않음)
4. 단일 스레드 구조라 락 경합(lock contention)이 없음
- nginx는 각 워커가 단일 스레드로 동작
- 수많은 연결을 하나의 이벤트 루프에서 비동기적으로 처리
- 다중 스레드가 공유 자원 놓고 싸우지 않음 → 스케일이 잘 되고 안정적
5. epoll + FD + 콜백 구조는 “설계적으로 단순”하면서도 강력함
- 수천 개 연결 중 이벤트 발생한 소수만 핸들러 실행
- 나머지는 커널이 FD 상태만 추적 중 (리소스 사용 안 함)
1
2
3
4
for (int i = 0; i < nevents; i++) {
ngx_event_t *ev = events[i].data.ptr;
ev->handler(ev); // 이벤트 발생한 연결만 처리
}
워커 수 조정
디스크 사용량과 CPU 부하 패턴에 따라 nginx 워커 수를 조정해야 할 수 있다. 이 규칙은 다소 기본적이며, 시스템 관리자는 워크로드에 맞게 몇 가지 설정을 실험해보는 것이 좋다.
- 만약 부하가 CPU 중심이라면(예: TCP/IP 처리, SSL, 압축 등), 워커 수는 CPU 코어 수와 같게 설정하는 것이 좋다.
- 반면 부하가 디스크 I/O 중심이라면(예: 다양한 콘텐츠 제공, 무거운 프록시 처리), 워커 수는 코어 수의 1.5 ~ 2배 정도로 설정할 수 있다.
부록
- 아파치의 이벤트 기반 구조와 다른점 ? => 라인에서 대규모 트래픽 다룰때 아파치 쓴다한거 같은데 (라인개발실록)
리슨 소켓, 클라이언트 통신 소켓
- TCP 서버의 소켓 동작 흐름
- 서버가 먼저
listen()소켓을 열고 포트에 바인딩 - 클라이언트가 연결 시도 →
accept()호출로 연결 수락 - 이때 새로운 소켓이 생성되어, 클라이언트와의 통신에 사용됨
- 서버가 먼저
- 예시 ``` int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // 리슨 전용 소켓 bind(listen_fd, …); listen(listen_fd, SOMAXCONN);
// 클라이언트 연결 수락 → 새로운 FD 생성 int client_fd = accept(listen_fd, NULL, NULL); // 개별 클라이언트와 통신하기 위한 독립적인 소켓
1
1
2
3
4
5
6
7
8
9
[Master Process]
│
┌──────────┴──────────┐
│ │ [Worker #1] [Worker #2]
│ │
│ │ [listen socket] ← 모든 워커가 공유
│
accept()
↓ [client socket] ← 클라이언트 연결 1개당 생성 ```
리버스 프록시
1
2
3
4
[Client] ─────▶ [nginx] ─────▶ [Upstream Server]
▲ ▲
listen FD upstream FD
(클라이언트용) (백엔드용)
| 소켓 종류 | 설명 |
|---|---|
| 리슨 소켓 | 클라이언트 연결 대기용 (공통) |
| 클라이언트 소켓 | 클라이언트와의 실제 데이터 송수신용 FD |
| 업스트림 소켓 | 백엔드 서버와 통신하기 위한 FD (예: proxy_pass) |