[Nginx] 4-3. 이벤트 루프와 처리 (3)

Park Yeongseo·2025년 4월 5일
0

Nginx

목록 보기
9/10
post-thumbnail

Thundering Herd 문제
여러 워커가 동일한 이벤트를 대기하고 하고 있을 때, 이벤트 발생 시 실제 이벤트를 처리하는 워커는 하나 뿐임에도 대기 중인 모든 워커가 깨어나게 되는 문제

문제 발생 시나리오
여러 워커들이 마스터 프로세스로부터 동일한 리스닝 소켓을 상속받아 가지고 있고, 이 리스닝 소켓으로 들어오는 연결 요청 이벤트를 대기하고 있는 상태.

(분량 채우려고 재탕하는 거? 맞습니다.)

1. 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번 포트에 바인드했다고 해보자.

  1. 80번 포트로 클라이언트의 연결 요청이 들어옴
  2. 커널이 (src IP, src port, dst IP, dst port, protocol)으로 해시 값을 계산
  3. 해시값을 바탕으로 포트 별 SO_REUSEPORT 소켓 그룹에서 하나의 소켓을 선택
  4. 선택된 소켓으로 연결 요청을 라우팅

선택된 소켓에만 연결 요청 이벤트가 발생하고, 나머지 소켓에서는 이벤트가 발생하지 않으므로 Thundering Herd 문제가 원천적으로 해결된다.

SO_REUSEPORT가 해시 값을 바탕으로 소켓을 선택하기 때문에, 어떤 경우에는 한 소켓에 부하가 집중되는 문제가 발생할 수 있다.

이러한 부하 불균형 문제를 해결하기 위해 로드 밸런싱까지 처리하는 SO_REUSEPORT_LB 소켓 옵션을 제공하는 OS(FreeBSD 12+)도 있으며, Nginx에서는 SO_REUSEPORT_LB가 사용 가능한 경우 우선적으로 선택한다.

2. SO_REUSEPORT 리스닝 소켓의 생성

기존의 리스닝 소켓 생성 과정을 리뷰해보자.

  1. nginx.conf 파일을 파싱해서 listen 지시어를 찾는다.
  2. 포트 번호 및 기타 설정 정보들을 리스닝 소켓 리스트에 넣는다.
  3. ngx_open_listening_sockets()으로 소켓을 생성하고 논블록 리스닝.
  4. ngx_configure_listening_sockets()에서 필요한 추가 소켓 설정
  5. 워커 프로세스를 fork()로 생성해, 만들었던 리스닝 소켓을 공유해준다.

SO_REUSEPPORT 리스닝 소켓을 사용하는 경우에는 몇 가지 추가적인 작업들이 필요하다.

  1. nginx.conf 파일을 파싱해서 listen 지시어를 찾는다.
  2. 포트 번호 및 기타 설정 정보들을 리스닝 소켓 리스트에 넣는다.
    • 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;
        }
        
	// ...
		
  1. ngx_events_moduleinit_conf를 호출할 때 리스닝 소켓 클로닝을 준비한다.
    • init_confngx_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;
}

  1. 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
	// ...
}
  1. . ngx_configure_listening_sockets()에서 필요한 추가 소켓 설정
  2. fork()로 워커를 생성해 만들었던 리스닝 소켓을 복사해준다.
  3. 각 워커는 ngx_events_moduleprocess_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;
}

3. Thundering Herd 문제 해결책 정리

  1. epoll + EPOLLEXCLUSIVE
    • 워커가 동일한 리스닝 소켓을 공유
    • 연결 요청 시 커널이 알아서 하나만 깨워줌
  2. accept_mutex
    • 애플리케이션 단의 뮤텍스를 사용.
    • 구형 커널에서도 잘 동작하지만 성능이 낮음
  3. SO_REUSEPORT
    • 각 워커가 독립적인 리스닝 소켓을 가짐
    • 커널이 알아서 연결을 분산시켜 하나만 깨워줌
    • 성능이 가장 좋음

그런데 여기서 한 가지, SO_REUSEPORTEPOLLEXCLUSIVE를 쓰면서 accept_mutex도 함께 사용하면 뮤텍스로 인한 성능 저하가 일어나버릴 수도 있다.

예전 버전에는 accept_mutex on;이 디폴트라 SO_REUSEPORTEPOLLEXCLUSIVE를 사용하는 경우 자동으로 accept_mutex를 꺼버리도록 했다는 것 같은데, 현재 버전에서는 accept_mutex off;가 디폴트라 최신 epoll이나 reuseport를 사용하는 경우에는 굳이 accept_mutex관련 옵션은 명시하지 않기를 권장하는 듯하다.

다음 글에서는 실제로 이벤트 처리가 어떻게 이루어지는지. 또 Nginx는 어떻게 이벤트 처리를 최적화하는지에 대해 알아봅니다.

profile
블로그 이전 준비 중

0개의 댓글