Nginx의 구조를 알아보고, 어떻게 만들어지는지 확인해봅시다.
Nginx는 하나의 마스터 프로세스와 여러 개의 자식 프로세스들로 이루어져 있다. 자식 프로세스에는 캐시 매니저, 캐시 로더, 워커 프로세스 등이 있다. 캐시 관련 프로세스들은 간단히 그 역할만 짚고 넘어 가자.
마스터 프로세스는 사용자와의 요청을 직접 처리하지는 않지만, 설정에 따라 서버의 상태를 초기화하고 각 워커 프로세스들을 생성하고 그 생명 주기를 관리하는 등의 역할을 하고, 워커 프로세스는 이벤트들을 처리하는 역할, 즉 직접 사용자와 연결을 맺고, 사용자로부터의 요청을 받고, 요청을 처리해 응답을 보내고, 연결을 끊는 역할을 한다.
각 워커 프로세스는 하나의 스레드로 이 이벤트들을 처리하는데, 시간이 오래 걸리는 블로킹 이벤트가 발생해 이후 이벤트를 처리하지 못하는 문제를 해결하기 위해 스레드 풀을 두고 스레드를 사용하기도 한다.
워커 프로세스를 몇 개 만들지는 nginx.conf
에서 지정할 수 있다. 미지정 시 1개로 설정되고, auto
를 사용하는 경우 CPU 코어의 개수만큼 생성된다.
worker_processes 3;#워커 프로세스를 3개 생성
worker_processes auto;#CPU 코어 개수에 맞게 워커 프로세스를 생성
워커 프로세스를 CPU 코어 개수만큼 만들어서 사용하면 컨텍스트 스위칭으로 인한 오버헤드를 줄일 수 있고, 캐시 일관성도 높일 수 있다. 각 워커 프로세스가 어느 코어에서 돌아가게 할지는 보통 OS 스케줄링에 맡기지만, Nginx Plus에서는 각 프로세스를 특정 CPU에 바인딩하는 옵션도 있다.
worker_processes 4;
worker_cpu_affinity 0001 0010 0100 1000;#Nginx Plus. 비트마스크로 코어 바인딩
오픈 소스인만큼 코드가 공개되어 있다(https://github.com/nginx/nginx). 소스코드를 보면서 어떻게 위와 같은 구조가 형성되는지 살펴보자.
아래 소스 코드에서 계속해서 "이전"이라는 말이 나오는데, Nginx의 무중단 재시작 때문이다. 무중단 재시작의 흐름은 다음과 같다.
Nginx는 무중단 재시작을 통해, 설정 파일을 고친 후에도 굳이 끄고 켜지 않고 새 설정이 반영된 서비스를 지속할 수 있다. 단 이때에는 이전에 사용했던 포트가 남아 있거나 할 수 있기 때문에 처리해줘야 하게 된다.
/src/core/nginx.c
Nginx의 실행이 시작되는 main()
함수가 들어있다. 여러 곁가지 작업들을 제외하면 대충 다음과 같이 진행된다.
int ngx_cdecl
main(int argc, char *const *argv)
{
//...
ngx_cycle_t *cycle, init_cycle;
//...
/**
프로세스 시작을 위한 여러 초기 작업들을 수행
*/
//init_cycle은 환경 초기화를 위한 구조체
ngx_memzero(&init_cycle, sizeof(ngx_cycle_t));
init_cycle.log = log;
//ngx_cycle은 실제 nginx의 실행 상태 관리를 위한 ngx_cycle_t 전역 변수
ngx_cycle = &init_cycle;
/**
초기 설정 및 리소스 초기화 작업
*/
//nginx.conf의 설정에 따른 초기화 작업. src/core/ngx_cycle.c에 있다.
cycle = ngx_init_cycle(&init_cycle);
/**
설정이 제대로 됐는지 확인하기
*/
//ngx_cycle 변경
ngx_cycle = cycle;
/**
PID 파일 생성 및 로그가 파일로 저장될 수 있도록 stderr를 리디렉션
*/
//NGX_PROCESS_SINGLE은 nginx 개발 테스트를 위해서만 쓰임. 프로덕션에서는 사용 X
if (ngx_process == NGX_PROCESS_SINGLE) {
ngx_single_process_cycle(cycle);
} else {
//마스터 프로세스로서의 생명 주기를 시작
ngx_master_process_cycle(cycle);
}
return 0;
}
/src/core/ngx_cycle.c
이 소스 파일의 ngx_init_cycle()
에서는 실행 주기에서 사용할 메모리 풀을 생성하는 등의 여러 초기화 과정과 함께, nginx.conf
같은 설정 파일들 분석해 실행 컨텍스트에 등록한다.
Nginx에서는 프로세스 간 공통 데이터 관리를 위해 공유 메모리를 사용하는데, 공유 메모리 설정도 여기서 한다(/* create shared memory */
이하). 이 공유 메모리는 여러 가지 성능 최적화를 위해 쓰인다.
리스닝 소켓을 여는 작업도 여기서 한다(/* handle the listening sockets */
이하).
ngx_cycle_t *
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
/**
구성 파일 읽기 등의 초기화
공유 메모리 설정
*/
/* handle the listening sockets */
/*
이전에 사용하던 리스닝 소켓이 있으면 재사용할지 결정
*/
//아래 ngx_open_listening_sockets() & ngx_configure_listening_sockets()는 /src/core/ngx_connection.c에 구현되어 있다.
if (ngx_open_listening_sockets(cycle) != NGX_OK) {
goto failed;
}
if (!ngx_test_config) {
ngx_configure_listening_sockets(cycle);
}
//...
}
/src/core/ngx_connection.c
소켓을 만들고, 열고, 구성을 변경하고, 닫는 등의 작업들이 있다.
ngx_int_t
ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
//...
//제대로 소켓이 안 만들어지는 경우를 대비해 5번 재시도
for (tries = 5; tries; tries--) {
failed = 0;
/* for each listening socket */
ls = cycle->listening.elts;
for (i = 0; i < cycle->listening.nelts; i++) {
if (ls[i].ignore) {//이미 열린 소켓인 경우를 제외
continue;
}
/**
SO_REUSEPORT가 아닌 소켓을 SO_REUSEPORT로 변경하는 부분
*/
if (ls[i].fd != (ngx_socket_t) -1) {
continue;//이미 제대로 만들어진 소켓인 경우 패스
}
if (ls[i].inherited) {
continue;//직접 만든 소켓이 아니라, 부모 프로세스로부터 받은 경우
}
//새로운 소켓을 생성. 성공 시 fd, 실패 시 -1 반환
s = ngx_socket(ls[i].sockaddr->sa_family, ls[i].type, 0);
//소켓 생성에 실패하는 경우
if (s == (ngx_socket_t) -1) {
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
ngx_socket_n " %V failed", &ls[i].addr_text);
return NGX_ERROR;
}
//UDP 소켓이 아니면
if (ls[i].type != SOCK_DGRAM || !ngx_test_config) {
//소켓 주소 재사용이 불가능한 경우
if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
(const void *) &reuseaddr, sizeof(int))
== -1)
{
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
"setsockopt(SO_REUSEADDR) %V failed",
&ls[i].addr_text);
if (ngx_close_socket(s) == -1) {
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
ngx_close_socket_n " %V failed",
&ls[i].addr_text);
}
return NGX_ERROR;
}
}
/**
RESUSEPORT인 경우, IPv6만을 사용하는 경우 처리
*/
/*
ngx_event_flags: SELECT, EPOLL, IOCP 등 이벤트 모델 플래그
IOCP가 아닌 경우 소켓을 논블로킹으로 만든다.
실패하면 소켓을 닫고 에러 반환
?왜 IOCP일 때는 논블로킹 설정을 안해도 될까?는 나중에 공부해보겠습니다.
*/
if (!(ngx_event_flags & NGX_USE_IOCP_EVENT)) {
if (ngx_nonblocking(s) == -1) {
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
ngx_nonblocking_n " %V failed",
&ls[i].addr_text);
if (ngx_close_socket(s) == -1) {
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
ngx_close_socket_n " %V failed",
&ls[i].addr_text);
}
return NGX_ERROR;
}
}
ngx_log_debug2(NGX_LOG_DEBUG_CORE, log, 0,
"bind() %V #%d ", &ls[i].addr_text, s);
//소켓 바인딩
if (bind(s, ls[i].sockaddr, ls[i].socklen) == -1) {
//실패한 경우
err = ngx_socket_errno;
//NGX_EADDRINUSE: 이미 사용중인 주소인 경우
if (err != NGX_EADDRINUSE || !ngx_test_config) {
ngx_log_error(NGX_LOG_EMERG, log, err,
"bind() to %V failed", &ls[i].addr_text);
}
if (ngx_close_socket(s) == -1) {
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
ngx_close_socket_n " %V failed",
&ls[i].addr_text);
}
/**
이미 사용 중인 주소인 경우 나중에 다시 연결 시도를 하고,
이외의 오류인 경우는 에러 반환
*/
if (err != NGX_EADDRINUSE) {
return NGX_ERROR;
}
if (!ngx_test_config) {
failed = 1;
}
continue;
}
//TCP 소켓이 아니면 아까 만든 소켓의 파일 디스크립터로 설정하면 됨
if (ls[i].type != SOCK_STREAM) {
ls[i].fd = s;
continue;
}
//논블로킹 소켓으로 리스닝 시작
if (listen(s, ls[i].backlog) == -1) {
//실패하는 경우
err = ngx_socket_errno;
/*
* on OpenVZ after suspend/resume EADDRINUSE
* may be returned by listen() instead of bind(), see
* https://bugs.openvz.org/browse/OVZ-5587
*/
if (err != NGX_EADDRINUSE || !ngx_test_config) {
ngx_log_error(NGX_LOG_EMERG, log, err,
"listen() to %V, backlog %d failed",
&ls[i].addr_text, ls[i].backlog);
}
if (ngx_close_socket(s) == -1) {
ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
ngx_close_socket_n " %V failed",
&ls[i].addr_text);
}
if (err != NGX_EADDRINUSE) {
return NGX_ERROR;
}
if (!ngx_test_config) {
failed = 1;
}
continue;
}
ls[i].listen = 1;
ls[i].fd = s;
}
//원하는 소켓들을 다 만들었음
if (!failed) {
break;
}
/* TODO: delay configurable */
ngx_log_error(NGX_LOG_NOTICE, log, 0,
"try again to bind() after 500ms");
//만들지 못한 소켓이 있으면 잠시 대기 후 재시도
ngx_msleep(500);
}
// 재시도를 해도 불가능한 경우 에러 반환
if (failed) {
ngx_log_error(NGX_LOG_EMERG, log, 0, "still could not bind()");
return NGX_ERROR;
}
return NGX_OK;
}
nginx.conf
에 다음과 같은 서버 블럭이 있으면,
server {
listen 80; # 포트 80에 대한 설정
listen 443 ssl; # 포트 443에 대한 SSL 설정
server_name example.com;
...
}
필요한 리스닝 소켓들을 만들고 나면, ngx_configure_listening_sockets()
를 호출해, 고급 소켓 옵션들을 설정한다.
이후 ngx_init_cycle()
에서 필요한 작업들을 모두 마치고, 나머지 작업들을 하고 나면 ngx_master_process_cycle()
를 호출해, 본격적으로 마스터 프로세스로서의 생명 주기를 시작한다.
분량 조절 실패(2)로, 마스터 프로세스 생명 주기 및 워커 프로세스 생성 + 관리는 다음 시간에.