Thundering Herd 문제
여러 워커가 동일한 이벤트를 대기하고 하고 있을 때, 이벤트 발생 시 실제 이벤트를 처리하는 워커는 하나 뿐임에도 대기 중인 모든 워커가 깨어나게 되는 문제문제 발생 시나리오
여러 워커들이 마스터 프로세스로부터 동일한 리스닝 소켓을 상속받아 가지고 있고, 이 리스닝 소켓으로 들어오는 연결 요청 이벤트를 대기하고 있는 상태.
(분량 채우려고 재탕하는 거? 맞습니다.)
SO_REUSEPORT
Thundering Herd 문제의 두 번째 해결책은 SO_REUSEPORT
소켓을 사용하는 방법이다.
nginx.conf
에서는 아래와 같이 각 리스닝 포트 번호에 reuseport
를 추가해 사용할 수 있다.
http {
server {
listen 80 reuseport;
listen 443 ssl;
}
}
앞서 문제가 발생했던 이유는 워커들이 마스터로부터 동일한 리스닝 소켓을 상속받아서 등록했기 때문이다.
[클라이언트] ---- SYN ----→ [서버 (포트 80)]
│
│ ※ 모든 워커가 마스터로부터 상속받은 리스닝 소켓을 공유
│
├───▶ [Worker A] → accept() 성공
├───▶ [Worker B] → accept() 실패
└───▶ [Worker C] → accept() 실패
그렇다면 워커 별로 서로 다른 리스닝 소켓을 가지면 되지 않을까? SO_REUSEPORT
소켓 옵션은 여러 개의 AF_INET
, AF_INET6
소켓들을 동일한 소켓 주소로, 그러니까 동일한 포트 번호로 바인드할 수 있도록 하는 옵션이다.
[클라이언트] ---- SYN ----→ [서버 (포트 80)]
│
│ ※ OS가 알아서 소켓을 정함
|
├───▶ [Worker A 소켓] → [Worker A] → accept()
├─x─▶ [Worker B 소켓] → [Worker B] → 깨어나지 않음
└─x─▶ [Worker C 소켓] → [Worker B] → 깨어나지 않음
워커들이 각각 SO_REUSEPORT
리스닝 소켓을 만들고 80번 포트에 바인드했다고 해보자.
(src IP, src port, dst IP, dst port, protocol)
으로 해시 값을 계산SO_REUSEPORT
소켓 그룹에서 하나의 소켓을 선택선택된 소켓에만 연결 요청 이벤트가 발생하고, 나머지 소켓에서는 이벤트가 발생하지 않으므로 Thundering Herd 문제가 원천적으로 해결된다.
SO_REUSEPORT
가 해시 값을 바탕으로 소켓을 선택하기 때문에, 어떤 경우에는 한 소켓에 부하가 집중되는 문제가 발생할 수 있다.이러한 부하 불균형 문제를 해결하기 위해 로드 밸런싱까지 처리하는
SO_REUSEPORT_LB
소켓 옵션을 제공하는 OS(FreeBSD 12+)도 있으며, Nginx에서는SO_REUSEPORT_LB
가 사용 가능한 경우 우선적으로 선택한다.
SO_REUSEPORT
리스닝 소켓의 생성기존의 리스닝 소켓 생성 과정을 리뷰해보자.
nginx.conf
파일을 파싱해서 listen
지시어를 찾는다.ngx_open_listening_sockets()
으로 소켓을 생성하고 논블록 리스닝.ngx_configure_listening_sockets()
에서 필요한 추가 소켓 설정fork()
로 생성해, 만들었던 리스닝 소켓을 공유해준다.SO_REUSEPPORT
리스닝 소켓을 사용하는 경우에는 몇 가지 추가적인 작업들이 필요하다.
nginx.conf
파일을 파싱해서 listen
지시어를 찾는다.reuseport
인 경우가 기록된다.// /src/http/ngx_http_core_module.c
static char *
ngx_http_core_listen(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{// listen 지시어에 대한 핸들러
// ...
if (ngx_strcmp(value[n].data, "reuseport") == 0) {
#if (NGX_HAVE_REUSEPORT)
lsopt.reuseport = 1; //여기서 reuseport임을 기록
lsopt.set = 1;
lsopt.bind = 1;
#else
ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
"reuseport is not supported "
"on this platform, ignored");
#endif
continue;
}
// ...
ngx_events_module
의 init_conf
를 호출할 때 리스닝 소켓 클로닝을 준비한다.init_conf
는 ngx_init_cycle()
에서 전역으로 한 번, 설정 초기화를 위해서 호출된다는 사실을 잊지 말자.reuseport
리스닝 소켓 정보를 복사하고, 해당 소켓이 어떤 워커의 것인지를 표시한다.static char *
ngx_event_init_conf(ngx_cycle_t *cycle, void *conf)
{
// ...
#if (NGX_HAVE_REUSEPORT)
ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx, ngx_core_module);
if (!ngx_test_config && ccf->master) {
ls = cycle->listening.elts;
for (i = 0; i < cycle->listening.nelts; i++) {
//reuseport가 아니거나, 이미 워커가 정해졌으면
if (!ls[i].reuseport || ls[i].worker != 0) {
continue;
}
if (ngx_clone_listening(cycle, &ls[i]) != NGX_OK) {
return NGX_CONF_ERROR;
}
/* cloning may change cycle->listening.elts */
ls = cycle->listening.elts;
}
}
#endif
return NGX_CONF_OK;
}
// /src/core/ngx_connection.c
ngx_int_t
ngx_clone_listening(ngx_cycle_t *cycle, ngx_listening_t *ls)
{
#if (NGX_HAVE_REUSEPORT)
ngx_int_t n;
ngx_core_conf_t *ccf;
ngx_listening_t ols;
if (!ls->reuseport || ls->worker != 0) {
return NGX_OK;
}
ols = *ls;
ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx, ngx_core_module);
// 각 워커 수만큼
for (n = 1; n < ccf->worker_processes; n++) {
/* create a socket for each worker process */
// 해당 리스닝 소켓의 정보를 복사할 공간을 할당(마지막 자리의 포인터 받음)
ls = ngx_array_push(&cycle->listening);
if (ls == NULL) {
return NGX_ERROR;
}
// 마지막 자리에 복사하고
*ls = ols;
// 어떤 워커의 것인지를 표시
ls->worker = n;
}
#endif
return NGX_OK;
}
ngx_open_listening_sockets()
으로 소켓을 생성하고 논블록 리스닝.setsockopt()
로 SO_REUSEPORT
옵션을 지정ngx_int_t
ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
/**
이 앞의 코드는 2-1. Nginx의 구조 (1)에서 다뤘습니다.
아래는 해당 글에서 다루지 않았던 'REUSEPORT인 경우'
*/
#if (NGX_HAVE_REUSEPORT)
if (ls[i].reuseport && !ngx_test_config) {
int reuseport;
reuseport = 1;
#ifdef SO_REUSEPORT_LB
if (setsockopt(s, SOL_SOCKET, SO_REUSEPORT_LB,
(const void *) &reuseport, sizeof(int))
== -1)
{
// 안되면 닫고 에러 반환
}
#else
if (setsockopt(s, SOL_SOCKET, SO_REUSEPORT,
(const void *) &reuseport, sizeof(int))
== -1)
{
// 안되면 닫고 에러 반환
}
#endif
}
#endif
// ...
}
ngx_configure_listening_sockets()
에서 필요한 추가 소켓 설정fork()
로 워커를 생성해 만들었던 리스닝 소켓을 복사해준다.ngx_events_module
의 process_init
을 호출할 때, 자신에게 배정된 리스닝 소켓을 이벤트 모델에 등록한다.// /src/event/ngx_event.c
static ngx_int_t
ngx_event_process_init(ngx_cycle_t *cycle)
{
// ...
/* for each listening socket */
ls = cycle->listening.elts;
for (i = 0; i < cycle->listening.nelts; i++) {
//...
#if (NGX_HAVE_REUSEPORT)
// 내가 주인이 아닌 reuseport는 제외
if (ls[i].reuseport && ls[i].worker != ngx_worker) {
continue;
}
#endif
/**
여러 연결 관련 설정들을 진행
*/
#if (NGX_HAVE_REUSEPORT)
if (ls[i].reuseport) {//reuseport 소켓을 이벤트 목록에 등록
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}
continue;
}
#endif
// 아래에는 EPOLLEXCLUSIVE를 사용할 수 있을 때
// ...
}
return NGX_OK;
}
epoll
+ EPOLLEXCLUSIVE
accept_mutex
SO_REUSEPORT
그런데 여기서 한 가지, SO_REUSEPORT
나 EPOLLEXCLUSIVE
를 쓰면서 accept_mutex
도 함께 사용하면 뮤텍스로 인한 성능 저하가 일어나버릴 수도 있다.
예전 버전에는 accept_mutex on;
이 디폴트라 SO_REUSEPORT
나 EPOLLEXCLUSIVE
를 사용하는 경우 자동으로 accept_mutex
를 꺼버리도록 했다는 것 같은데, 현재 버전에서는 accept_mutex off;
가 디폴트라 최신 epoll
이나 reuseport
를 사용하는 경우에는 굳이 accept_mutex
관련 옵션은 명시하지 않기를 권장하는 듯하다.
다음 글에서는 실제로 이벤트 처리가 어떻게 이루어지는지. 또 Nginx는 어떻게 이벤트 처리를 최적화하는지에 대해 알아봅니다.