본문 바로가기
Nginx

Nginx Architecture 분석

아래 작성된 글은 개인적으로 Nginx에 대하여 분석하기 위하여 여러 정보들을 수집  및 테스트한 결과를 정리한 것으로 잘못된 부분이 있거나, 인용이 잘못된 부분이 있을 수도 있습니다. 
피드백 주시면 수정 하겠습니다 :) 

Traditional Web Server

전통적인 웹서버에서는  threads/process 기반으로 사용하는 경우가 많다. 이러한 경우 Client 요청당 하나의 Thread 가 처리하는 구조로 요청하는 량이 많아 짐에 따라서 생성되는 Thread 가 많아지게 된다.

이러한 방법은 메모리에 TCB나 PCB를 생성 하기 위한 공간이 많아지고 , Process 나 Thread를 관리하기 위한  추가 적인 CPU 작업이 많아지게 되고 이는 잦은 Context Switching으로 연결된다.

그래서 만일 단일 Thread 처리시 아래와 같이 sync가 완료되지 않은 Request이 있을 경우 다음 Request에 대하여 대기(Blocking) 상태로 되어 처리하는데 시간이 오래 걸릴 수밖에 없다.

위 그림은 간단하게 설명 하기 위하여 표현을 했지만 사실 Thread는 kernel level에서 진행되는 IO 과정을 나타 낸 것이며, 이는 synchronous blocking I/O model라고도 불린다.

synchronous blocking I/O model 에 대하여 자세하게 이야기하면 아래와 같은 그림으로 표현할 수 있다.

https://developer.ibm.com/articles/l-async/

Read()라는 synchronous blocking I/O model의 함수를 실행하여 Application에서 Kernel로 진행되는 과정을 살펴보면 ,

system call이 kernel 에 들어오면 kernel에서 완료되기 전까지 응답을 하지 않는다. 그 상태에서 Application 은 Blocking으로 빠지고 다른 작업을 하지 못한다. 

Kernel에서 완료되었다는 응답과 함께 데이터가 Kernel에서 User Space의 Application으로 가는 형태를 보인다.

wait queue 에 들어가고, 시스템 콜이 완료된 후에 응답을 보냄.

C10k problem

여기서 위의 문제점에 대하 c10k 문제라는 이름으로  apache의 한계가 있음을 탐구하고자 나온 이론으로 아래 설명을 통하여 대략적으로 이해가 가능하다.

인터넷이 발전하고 서비스가 거대화 되면서, 서버 대당 처리할 수 있는 동시접속자 수에 대한 한계가 재기되었고, 이를 정의한 문제가 C10K (Connection 10,000) 문제이다.
즉 서버에서 10,000 개 이상의 소켓을 생성하고 처리를 할 수 있느냐 에 대한 문제이다.
인터넷 전이나 초기 같으면, 동시에 하나의 서버에서 10,000개의 connection을 처리한 것은 아주 초대용량의 서비스였지만,
요즘 같은 SNS 시대나, 게임만 해도 동접 수만을 지원하는 시대에, 동시에 많은 클라이언트를 처리할 수 있는 능력이 요구되었다.
메모리나 CPU가 아무리 높다 하더라도 많은 수의 소켓을 처리할 수 없다면, 동시에 많은 클라이언트를 처리할 수 없다는 문제이다.
Unix의 IO 방식이 이 문제의 도마 위에 올랐는데, 기존의 Unix System Call인 select() 함수를 이용하더라도, 프로세스당 최대 2048개의 소켓 fd (file descriptor) 밖에 처리를 할 수 없었다.
이를 위한 개선안으로 나온 것이 비동기 IO를 지원하는 API인데, Windows의 iocp와 같은 비동기 시스템 호출이다.
출처: https://bcho.tistory.com/tag/Non blocking [조대협의 블로그]

 

즉, C10k Problem는 Process 당 Socket의 한계를 느끼고 이를 해결하고자 나온 개선한 것이 Async IO와 Non-Blocking 으로의 개선이다.

먼저 알아볼 것이 Async IO Model 중 하나인 Async-Blocking IO Model이다.

IO Model에서 Sync(동기)와 비동기(Async)의 가장 큰 차이점이라면 Kernel Space로 부터 데이터를 User Space 로  전달하는 것을 Kernel 완료 시점에서 (Sync) 하는 것인가 아니면 완료 응답을 Kernel Space에서 받고 User Space 에서 System Call 하여 요청하여 완료된 데이터를 받는 것(Async)이라 볼 수 있다.

즉 이렇게 되면 한 개의 Process 가 다중으로 Socket에 대한 다중 처리 가 가능하다. 이때 IO의 다중화를 위해서 poll(), select(), epoll 시스템 호출을 이용해 여러 FD를 하나의 Process로 관리하여 공유하는 것이 가장  특징이다.

하지만, 이 구조에서는 Blocking이라는 큰 함정이 있다. Blocking IO 모델이기에 대기 큐에서 Blocking 이 되는 현상이 아직 남아 있다.

더 자세하게 이야기하면 poll() epoll()과 같은 Multiplexing 관련된 system call에 대한 kernel의 응답이 Blocking 된다. (User Space의 Process의 r/w io 차단이 아님) 

그래서 다음으로 확인할 부분이 Asynchronous non-blocking I/O (AIO) Model 은 아래 그림과 같이 표현이 가능하다.

https://developer.ibm.com/articles/l-async/

앞서 이야기한 Async-Blocking IO Model와 같이 Kernel Space로 부터 데이터를 User Space 로  전달하는 것의 완료 응답을 Kernel Space에서 받고 User Space 에서 System Call 하여 요청하여 완료된 데이터를 받는고

Blocking 하지 않고 대기 큐로 가지 않는다. 그렇기 때문에 응답이 오기 전까지  User Space는 Kernel의 IO와 독립 적응으로 Processing 이 가능하다. 

Asynchronous non-blocking I/O (AIO) Model를 하기 위해서는 I/IO Multiplexing 이 필요로 하는데 , Nginx에서는 이를 Connection processing methods라고 지칭하며  http://nginx.org/en/docs/events.html에 나와 있는 것처럼 OS 따라서 kqueue (BSD)나 select(Linux) 혹은 epoll(Linux)을 사용한다.

이전에는 linux 환경에서 select를 사용했지만 , 대부분  linux 커널 2.6 이상부터는 epoll를 통하여 관리된다.

epoll은 select와 다르게 커널 공간이 FD를 관리하여 select 보다 빠른 event processing을 가능하도록 한다.

  • epoll_create를 통해 epoll 구조체를 생성하
  • epoll_ctl을 통해 파일 디스크립터를 등록, 수정, 삭제
  • epoll_wait를 통해 파일 디스크립터의 변화를 탐지

처음에 Master Process에서 Worker Process를 생성하면 kernel 공간에 있는 Epoll Structure에 epoll_create를 실행하여 사용한 epoll 데이터 구조를 생성하고. epoll_create를 사용하여  listen socket descriptor를 등록한다.

listen socket에 이벤트가 있는지 알도록 커널에 요청한다.

이렇게 되면 , 다른 모든 Worker Process는 Listen Socket에  액세스 할 수 있으며 동시에 들어오는 요청을 공유한다.

결국 Worker Process가 수행할 다른 작업이 없으면 epoll_wait를 사용하여 대기 상태로 빠진다.

Listen Socket에 새로운 Client Request가 들어오면 Kernel은 수신 대기열에서 대기 중인 epoll 중에 가장 최근에 추가된 Worker Process를 선택하여 Event를 전송한다.

Worker Process는 Non-Blocking으로 accept system call을 Listen Socket Queue에 전송하여  Client와 서버 사이에 Connection Socket을 만들 기 위해 호출한다.

Worker는 Listening Socket과  마찬가지로 Connection Socket에서도 같은 일을 한다. 

epoll_ctl을 통해 해당 소켓에서 발생하는 IO 이벤트에 대한 관심을 등록하고 달리 할 일이 없다면 epoll_wait로 빠진다.

epoll_wait의 끝에서 Worker Process는 처리할 준비가 된 여러 개의 소켓을 얻을 수 있다. 그런 다음 각 요청을 처리한다.

즉 여러 요청이 오면 아래와 같은 표현이 될 것이다. 단일 Worker Process에서 새로운 Request 가 오게 되면, 새로운  Epoll Event로 등록되어 임의의 Worker Process에 전달하여 해당 Worker Process 가 Listen Socket Queue에 Non-blocking으로  Aceept()을 호출하여

새로운 Connection Socket을 생성 하는 구조로 된다. 해당 Connection Socket 의 작업이 완료가 다 안되어도 Non-Blocking 이기에 Worker Process 는 다른 Request 에 대한 Connection Socket 을 생성할 수도 있으며, 다른 Worker Process에서도 사용할 수 있는 구조로 

Sync-Blocking 기반의 Network I/O Model 보다 유연하게 Socket 관리를 할 수 있는 것이다.

하지만 이러한 Socket에 대한 처리는 신속하고 잘 처리되겠지만 문제가 한 가지 있다.

File에 대한 Read/Write 는 Sync IO Model를 사용하고 있다는 점이다. Nginx 가 많은 File에 대하여 Access 할 때 Memory에 Cache 될 경우는 문제가 안되지만, Cache 가 안되어 직접적으로 Disk에서 해당 File을 읽으라고 요청할 때 문제가 생긴다.

특히나, 대용량의 File이나 Disk의 성능이 느린 회전 Disk를 사용할 경우 대기열에서 Blocking 되어 느릴 수밖에 없고 다른 Request 에도 영향이 가게 된다.

물론 이러한 File을 Read/Write 하는 데 있어서 Sync IO Model 이 아닌 Async Model을 선택할 수 있지만, FreeBSD에서만 가능하며, 리눅스에서는 Async를 위해서는 Async Interface인 O_DIRECT를 이용하여 진행되는데, 이는 

파일에 대한 모든 액세스가 메모리의 캐시를 바이 패스하기 때문에 비효율적인 방법이다.

이를 해결하고자 Nginx에서는 Thread Pool이라는 방법을 사용하여 이러한 Blocking으로 인한 응답 시간의 증가를 줄일 수 있도록 한다.

아래 그림과 같이 Main Thread가 있는 상태에서 File에 대한 Read/Write 가 없는 Request는 동일하게 Main Thread에서 처리 하고, File 에 대한 Read/Write 가 있는 경우는 Thread Pool 에 있는 Worker Thread 에서 처리 하게끔 한다.

이때 Worker Thread 에서 Blocking 이 발생 하기에 Main Thread 에 대한 File Read/Write로 인한 Blocking을 방지할 수 있다.

그렇 다면 Disk Cache는 어떨까?  Filesystem Cache로 Page Cach를 기본적으로 사용한다.

그렇기 때문에 결국 Miss 되는 부분에서 Device에 Access 할 수밖에 없기에 Blocking 이 발생한다. 그래서 Static Contents Cache 가 많거나 대용량의 Streaming Contents를 사용할 경우  위에서 언급한 Nginx Thread Pool을 이용한 방식을 이용해야 할 것으로 보인다.

 

Nginx Process Model 

Nginx는 아래 그림과 같이 Master Process 그리고 fork 된 Child Process로 Worker 와 Cache Manager , Cache Load Process 로 구분이 된다.

Master Process

Master Process에서는 가장 먼저 실행되는 Process로 nginx.conf 파일 및 conf.d 디렉터리에 저장된 설정들로부터 Worker/Cache Manager/Cache Load로 설정된 내용을 기반으로 전달 및 Process 생성을 관리해주는 역할을 한다.

좀 더 자세하게 확인하면 아래와 같이 나열할 수 있다.

  • 구성 읽기 및 검증
  • 소켓 생성, 바인딩 및 닫기
  • 구성된 수의 시작, 종료 및 유지 관리 wokrer 과정
  • 서비스 중단 없이 재구성
  • 무중단 이진 업그레이드 제어(새 이진 시작 및 필요한 경우 롤백)
  • 로그 파일 다시 열기
  • 포함된 Perl 스크립트 컴파일

Worker Process

Worker Process에서는 Master Process 에 설정에 따라 병렬로 생성되는 Process로 실제 Client 요청을 처리와 같은 관련된 일들을 모두 처리한다.

예를 들어 Reverse Proxy , 압축 ssl 등 모든 Connection과 관련된 일들이 수행되는 Process이다. Worker Process는 Master Process에서 확인한 설정 값에 따라 개수가 나눠지며 생성은 아래와 같이 Master Process의 Clone(forking ) 되어 Process 가 동작한다.

Cache Process

Cache Process는 Cache Loader와 Cache Manager두개의 Process 로 구성 되며, Worker Process와 동일하게 Master Process에서 Fork 되어 동작 된다. 기본적으로는 두 Cache Process 는 동작을 하지 않으나, proxy_cache_path 설정이 있을 경우에만 실행되는 Process이다.

우선 Cache에 대한 저장은 Key와  Meta를  Shared Memory에 올려 두고 Disk에 있는 File을 찾는 구조이다.

만약 backend의 요청이 192.168.40.248으로 이면  $scheme$proxy_host$request_uri 형태로 확인하면 http192.168.40.248/test-s5/1.csv이라는 key를 확인할 수 있다.

# printf http192.168.40.248/test-s5/1.csv | md5sum
c07a7d5a514ecbb1f2af91efdc55a4c7 -

이 key 를 md5로 hash 하면 c07 a 7 d5 a514 ecbb1 f2 af91 efdc55 a 4 c7로 나온다.

# tree /data001
/data001
├── 2
│   └── 2d
├── 5
│   └── c1
├── 7
│   └── 4c
│       └── c07a7d5a514ecbb1f2af91efdc55a4c7
└── f
    └── cc

"c07 a 7 d5 a514 ecbb1 f2 af91 efdc55 a 4 c7" 이름의 경우 마지막 4c7라는 문자가 있으며 이름 4c7=> /7/4c 형태로 나눠서 디렉터리를 구분한다.

# ls -al /data001/7/4c/c07a7d5a514ecbb1f2af91efdc55a4c7
-rw------- 1 systemd-resolve 82 6714 Dec 27 10:55 /data001/7/4c/c07a7d5a514ecbb1f2af91efdc55a4c7
 
# strings /data001/7/4c/c07a7d5a514ecbb1f2af91efdc55a4c7 | grep KEY
KEY: http192.168.40.248/test-s5/1.csv

해당 cache 된 데이터를 확인하면 동일한 key를 가진 cache 된 파일을 확인할 수 있다.

proxy_cache_path path keys_zone=name;size [inactive_time=time->10m] [max_size=size] ;

즉 , 위의 정보를 Nginx 구동 시 Shared Memory에 올리는 작업하는 것이 Cache Load이다. 이때 기준은 Nginx가 기동 전에 이전 기동전에 Cache 된 항목을 기준을 한다.

Cache Loader는 Nginx에 다른 Process와 비교하여 매우 보수적으로 스케줄링되므로, 이 프로세스의 리소스 요구사항은 낮은 편이다.

해당 설정은 아래와 같이 proxy_cache_path 설정 시 정해진다

proxy_cache_path keys_zone=name:size [loader_files=numbers->100] [loader_threshold_time=time->200ms] [load_sleep_time=time->50ms];

Cache Loader는 반복적으로 수행하는데, 한번 반복할 동안 loader_files에 있는 기본값인 100개의 항목을 Load 한다. 또한, 한 번의 반복은 load_thresload_time에 있는 200ms로 제한되며, 반복 사이는 load_sleep_time 시간인 50ms 사이를 두고 작업한다.

이 반복적인 것을 통하여 전체 캐시를 통해 작동하고 공유 메모리 세그먼트를 채울 때까지 반복한다. 만약 Disk의 성능이 좋거나 CPU 사양이 높다면 이 기본 설정을 올릴 필요가 있다.  기본값이 매우 보수 적이기 때문이다.

Cache Manager는 Loop를 돌면서 Cache 된 항목에 대한 만료, 사이즈 등 정책에 따라서 Cache를 삭제하는 일을 수행한다.

아래가 Cache Manager의 설정중 하나로 Cache Loader와 마찬가지로 proxy_cache_path 지시자의 옵션으로 들어간다.

proxy_cache_path path keys_zone=name;size [inactive_time=time->10m] [max_size=size] ;

inactive_time에 있는 시간이 지난 Contents에 대하여 삭제를 하고, max_size 설정이 있다면 해당 크기가 넘어가는 Content에 대한 삭제를 수행한다.

이때 삭제는  LRU(Least Recently Used) Algorithmd을 통해서 가장 나중에 Access 된 Content부터 삭제가 진행된다. 

그렇기 때문에 Cache 가 되는 Content 가 해당 옵션을 넘어가는 수치라고 하더라도 Cache  Manager 가 수행하지 않는 Content의 경우 남아 있을 수도 있다.

최종 적으로는 아래와 같은 구성으로 Nginx Process들이 수행되는 것을 알 수 있다.

References

반응형