아파치 성능향상 - Apache HTTP Server

Apache Server 2.0

Apache HTTP Server Version 2.0

<-

아파치 성능향상

이 문서는 최신판 번역이 아닙니다. 최근에 변경된 내용은 영어 문서를 참고하세요.

아파치 2.0은 기능과 포팅가능성과 성능의 균형이 맞도록 설계한 범용 웹서버이다. 벤치마크 기록을 세우기위해 설계하지 않았지만 아파치 2.0은 실제 많은 경우 높은 성능을 낸다.

아파치 1.3과 비교해서 2.0 버전은 처리량과 확장성(scalability)을 높이기위해 많은 최적화를 했다. 기본값으로 대부분 최적화한 값을 사용한다. 그러나 컴파일시 혹은 실행시 설정이 성능에 큰 영향을 줄 수 있다. 이 문서는 아파치 2.0의 성능을 향상하기위해 서버 관리자가 설정할 수 있는 옵션을 설명한다. 어떤 설정 옵션은 웹서버가 하드웨어와 운영체제의 기능을 더 잘 활용하도록 하는 반면, 어떤 옵션은 속도를 위해 기능을 희생한다.

top

하드웨어와 운영체제에 대해서

웹서버 성능에 가장 큰 영향을 주는 것은 메모리다. 스왑은 요청당 지연시간을 사용자가 "충분히 빠르다고" 생각하지 못하게 늘리기때문에 웹서버는 스왑을 하면 안된다. 느려지면 사용자는 정지하고 다시 접속하여 부하가 계속 증가한다. MaxClients 지시어를 조절하여 웹서버가 스왑을 할 정도로 많은 자식을 만들지않도록 해야 한다. 방법은 간단하다: top과 같은 도구에서 프로세스 목록을 보고 아파치 프로세스의 평균 메모리 사용량을 알아낸후, 전체 사용가능한 메모리에서 다른 프로세스들이 사용할 공간을 뺀 값에서 나눈다.

나머지는 평범하다: 충분히 빠른 CPU, 충분히 빠른 네트웍카드, 충분히 빠른 디스크, 여기서 "충분히 빠른"은 실험을 해서 결정해야 한다.

운영체제는 보통 각자 알아서 선택할 일이다. 그러나 일반적으로 유용하다고 판명된 몇가지 지침이 있다:

  • 선택한 운영체제의 최신 안정 버전과 패치를 실행한다. 많은 운영체제 제작사는 최근 TCP 스택과 쓰레드 라이브러리에 많은 속도향상을 했다.

  • 운영체제가 sendfile(2) 시스템호출을 지원한다면, 이를 사용하기위한 버전이나 패치를 설치하였는지 확인한다. (예를 들어, 리눅스라면 2.4 이상 버전을 뜻한다. Solaris 8 초기 버전은 패치가 필요하다.) 지원하는 시스템이라면 아파치 2는 sendfile을 사용하여 CPU를 덜 사용하며 정적 파일을 더 빨리 전송할 수 잇다.

top

실행시 설정에 대해서

HostnameLookups와 DNS에 대해 고려할 점들

아파치 1.3 이전에 HostnameLookups의 기본값은 On이였다. 요청을 마치기전에 DNS 검색이 끝나야 하므로 요청마다 지연이 생겼다. 아파치 1.3에서 이 설정의 기본값이 Off로 변경되었다. 로그파일의 주소를 호스트명으로 변환하려면 여러 로그처리 프로그램중 하나인, 아파치에 포함된 logresolve 프로그램을 사용하라.

로그처리 작업이 서버 성능에 악영향을 미치므로 실제 사용하는 웹서버가 아닌 다른 컴퓨터에서 로그파일을 후처리하길 바란다.

Allow from domain이나 Deny from domain 지시어를 사용한다면 (즉, IP 주소가 아닌 호스트명이나 도메인명을 사용한다면) 부득이 중복-역 DNS 검색을 (역검색을 한후 악의로 변경되었는지 확인하기위해 다시 검색) 해야 한다. 그러므로 성능을 높이기위해 이런 지시어에는 가능하면 이름대신 IP 주소를 사용한다.

<Location /server-status> 섹션 등으로 지시어의 적용범위를 제한할 수 있음을 기억하라. 이 경우 조건에 맞는 요청에만 DNS 조회를 한다. 다음은 .html.cgi 파일만 DNS 검색을 하는 예제다:

HostnameLookups off
<Files ~ "\.(html|cgi)$">
HostnameLookups on
</Files>

그러나 CGI에서 DNS명이 필요할 뿐이라면, 필요한 특정 CGI에서만 gethostbyname 호출을 하도록 고려해볼 수 있다.

FollowSymLinks와 SymLinksIfOwnerMatch

URL 공간에서 Options FollowSymLinks를 사용하지않고 Options SymLinksIfOwnerMatch를 사용하면 아파치는 심볼링크를 검사하기위해 시스템호출을 한번 더 해야 한다. 파일명의 각 부분마다 한번씩 더 호출을 한다. 예를 들어, 설정이 다음과 같고:

DocumentRoot /www/htdocs
<Directory />
Options SymLinksIfOwnerMatch
</Directory>

/index.html URI에 대한 요청이 있다고 가정하자. 그러면 아파치는 /www, /www/htdocs, /www/htdocs/index.html 각각에 대해 lstat(2)를 호출한다. lstats 결과를 캐싱하지 않기때문에 요청이 들어올 때마다 매번 같은 작업을 한다. 진짜 심볼링크 보안 검사를 원한다면 다음과 같이 할 수 있다:

DocumentRoot /www/htdocs
<Directory />
Options FollowSymLinks
</Directory>

<Directory /www/htdocs>
Options -FollowSymLinks +SymLinksIfOwnerMatch
</Directory>

이 경우 최소한 DocumentRoot 경로는 검사하지 않는다. DocumentRoot 밖에 있는 경로로 AliasRewriteRule을 사용한 경우에도 위와 비슷한 섹션이 필요하다. 심볼링크 보안을 고려하지 않고 최고의 성능을 얻으려면, FollowSymLinks를 설정하고, SymLinksIfOwnerMatch는 절대로 안된다.

AllowOverride

URL 공간에서 overrides를 허용한다면 (보통 .htaccess 파일) 아파치는 파일명의 각 부분마다 .htaccess를 열길 시도한다. 예를 들어,

DocumentRoot /www/htdocs
<Directory />
AllowOverride all
</Directory>

/index.html URI에 대한 요청이 있다고 가정하자. 아파치는 /.htaccess, /www/.htaccess, /www/htdocs/.htaccess를 열려고 시도한다. 해결책은 앞의 Options FollowSymLinks 경우와 비슷하다. 최고의 성능을 얻으려면 파일시스템에 대해서 항상 AllowOverride None을 사용한다.

내용협상

가능하고 진짜 조금의 성능향상에도 관심이 있다면 내용협상을 막는다. 실제로 협상의 이득은 성능저하보다 작다. 서버를 빠르게 할 수 있다. 다음과 같이 와일드카드를 사용하는 대신:

DirectoryIndex index

완전한 목록을 사용한다:

DirectoryIndex index.cgi index.pl index.shtml index.html

가장 흔한 것을 앞에 둔다.

또, 디렉토리에서 파일들을 찾는 MultiViews 보다는, 한 파일만 읽으면 필요한 정보를 얻을 수 있는 type-map 파일을 직접 만드는 것이 더 빠름을 명심하라.

사이트에 내용협상이 필요하다면 협상을 위해 Options MultiViews 지시어를 사용하기보다 type-map 파일을 고려하라. 협상방법에 대한 자세한 설명과 type-map 파일을 만드는 방법은 내용협상 문서를 참고하라.

메모리대응 (memory-mapping)

예를 들어, server-side-include를 처리하는 등 아파치 2.0이 전송할 파일을 읽을때 운영체제가 mmap(2) 등을 지원한다면 파일을 메모리대응한다.

여러 플래폼에서 메모리대응을 성능을 향상한다. 그러나 메모리대응이 서버의 성능을 떨어트리고 심지어 안정성을 해치는 경우가 있다:

  • 어떤 운영체제에서 mmap은 CPU 개수가 많아질때 read(2) 만큼 확장성이 좋지 않다. 예를 들어, 다중프로세서 Solaris 서버에서 아파치 2.0은 종종 mmap을 사용하지 않을때 서버가 처리한 파일을 더 빨리 전송한다.

  • NFS 마운트한 파일시스템에 있는 파일을 메모리대응하는 도중에 다른 NFS 클라이언트에 있는 프로세스가 파일을 지우거나 파일크기를 줄이면, 웹서버 프로세스가 다음 번에 메모리대응한 파일내용을 읽을때 bus error가 발생할 수 있다.

위의 조건에 해당하면 전송하는 파일을 메모리대응하지 않도록 EnableMMAP off를 사용해야 한다. (주의: 이 지시어는 디렉토리별로 변경할 수 있다.)

Sendfile

아파치는 운영체제가 sendfile(2)을 지원하면 커널 sendfile을 사용하여 -- 예를 들어, 정적 파일을 서비스할때 -- 전송할 파일을 직접 읽지않을 수 있다.

여러 플래폼에서 sendfile을 사용하면 read와 send를 따로 할 필요가 없어서 빨라진다. 그러나 sendfile을 사용하면 웹서버의 안정성을 해치게되는 경우가 있다:

  • sendfile 지원이 잘못되었고 컴파일 시스템이 이점을 발견하지 못하는 플래폼이 있다. 특히 다른 컴퓨터에서 실행파일을 컴파일하여 sendfile 지원이 잘못된 컴퓨터로 가져온 경우에 가능하다.

  • 커널은 자신의 캐쉬를 사용하여 NFS로 마운트한 파일을 안정적으로 서비스할 수 없는 경우가 있다.

위의 조건에 해당하면 파일을 sendfile 전송하지 않도록 EnableSendfile off를 사용해야 한다. (주의: 이 지시어는 디렉토리별로 변경할 수 있다.)

프로세스 생성

아파치 1.3 이전에는 MinSpareServers, MaxSpareServers, StartServers 설정이 모두 벤치마크 결과에 큰 영향을 미쳤다. 특히 아파치는 작업을 서비스하기위해 충분한 자식수에 다다를 때까지 "도달" 기간이 필요했다. 처음 StartServers개 자식을 만든후, MinSpareServers 설정값까지 초당 자식을 하나씩 만들었다. 그래서 StartServers 기본값이 5인 서버에 클라이언트 100개가 동시에 접속하면 부하를 처리하기에 충분한 자식을 만들기까지 95초가 걸렸다. 자주 재시작하지 않는 실제 서버에서는 잘 동작하지만, 10분간만 실행하는 벤치마크 결과는 매우 나쁘게 나온다.

초당 한개 규칙은 자식을 새로 시작하면서 서버에 무리를 주지 않으려고 정했다. 컴퓨터가 자식을 시작하느라 바쁘면 요청을 서비스할 수 없다. 그러나 이 규칙이 아파치의 체감 성능에 악영향을 주어 변경하였다. 아파치 1.3에서 초당 한개 규칙은 완화되었다. 코드는 자식 한개를 만들고, 1초 쉬고, 두개를 만들고, 1초 쉬고, 네개를 만들고, 이런 식으로 초당 자식을 32개 만들때까지 지수로 증가한다. 자식수가 MinSpareServers 설정에 다다르면 증가를 중단한다.

이 경우 반응속도가 빨라져서 MinSpareServers, MaxSpareServers, StartServers를 거의 설정할 필요가 없다. 일초에 자식을 4개 이상 생성하면 ErrorLog에 기록한다. 이런 오류문이 많이 보이면 이 설정들을 조절하길 바란다. mod_status 결과가 도움이 될 것이다.

프로세스 생성과 관련하여 MaxRequestsPerChild 설정은 프로세스를 종료한다. 기본값은 자식당 처리할 요청수에 제한이 없다는 0이다. 현재 설정이 30과 같이 매우 작은 값으로 설정되있다면, 값을 상당히 높힐 필요가 있다. SunOS나 오래된 Solaris 버전을 사용한다면, 메모리유출때문에 이 값을 10000 정도로 설정하라.

연결유지(keep-alive)를 사용한다면 자식들은 이미 열린 연결에서 추가 요청을 기다리며 아무것도 하지않기때문에 계속 바쁘다. KeepAliveTimeout의 기본값 15 초는 이런 현상을 최소화한다. 네트웍 대역폭과 서버 자원 간의 균형이 맞게 설정한다. 연결유지의 대부분의 이점이 사라지기때문에 어떤 경우에도 이 값을 60 초 이상으로 올리지 마라.

top

컴파일시 설정에 대해서

MPM 선택

아파치 2.x는 다중처리모듈 (MPMs)이라는 교체할 수 있는 동기화 모델을 지원한다. 아파치를 컴파일할때 MPM을 선택해야 한다. beos, mpm_netware, mpmt_os2, mpm_winnt와 같이 특정 플래폼에서만 사용할 수 있는 MPM도 있다. 일반적인 유닉스류 시스템은 여러 MPM 중에 하나를 선택할 수 있다. 웹서버의 속도와 확장성(scalability)은 어떤 MPM을 선택했냐에 달렸다:

  • worker MPM은 여러 자식 프로세스가 각각 여러 쓰레드를 사용한다. 각 쓰레드는 한번에 한 연결을 담당한다. 일반적으로 worker는 prefork MPM 보다 적은 메모리를 사용하므로 통신량이 많은 서버에 적절하다.
  • prefork MPM은 쓰레드가 한개인 자식 프로세스를 여러개 사용한다. 각 프로세스는 한번에 한 연결을 담당한다. 여러 시스템에서 prefork의 속도는 worker와 비슷하지만, 더 많은 메모리를 사용한다. 다음과 같은 상황에서 쓰레드를 사용하지 않는 prefork 방식이 worker에 비해 이점을 가진다: 쓰레드에 안전하지 (thread-safe) 않은 제삼자가 만든 모듈을 사용할 수 있고, 쓰레드 디버깅 지원이 빈약한 플래폼에서 쉽게 디버깅할 수 있다.

이 MPM들과 다른 MPM에 대해 더 자세한 정보는 MPM 문서를 참고하길 바란다.

모듈

메모리 사용량이 성능에서 가장 중요한 요인이기때문에 실제로 사용하지 않는 모듈을 제거해보자. 모듈을 DSO로 컴파일했다면 간단히 그 모듈에 대한 LoadModule 지시어를 주석처리하면 된다. 그래서 모듈을 제거하고 실행하여 사이트가 모듈없이도 정상적으로 동작하는지 살펴볼 수 있다.

반대로 모듈이 아파치 실행파일에 정적으로 링크되있다면 원하지 않는 모듈을 제거하기위해 아파치를 재컴파일해야 한다.

여기서 당연히 어떤 모듈을 사용하고 사용하지 말지 의문이 생긴다. 정답은 웹사이트마다 다르다. 그러나 아마도 최소한 mod_mime, mod_dir, mod_log_config 모듈은 사용할 것이다. 물론 웹사이트에 로그파일이 필요없다면 mod_log_config는 없어도 된다. 그러나 추천하지 않는다.

Atomic 명령

mod_cache 같은 모듈과 최근 개발중인 worker MPM은 APR의 atomic API를 사용한다. 이 API는 경량급 쓰레드 동기화를 위할 atomic 명령을 제공한다.

기본적으로 APR은 각 운영체제/CPU 플래폼에서 가장 효율적인 방법을 사용하여 이 명령을 구현한다. 예를 들어, 여러 최신 CPU에는 하드웨어로 atomic compare-and-swap (CAS) 연산을 하는 명령어가 있다. 그러나 어떤 플래폼에서 APR은 이런 명령어가 없는 오래된 CPU와 호환성을 위해 더 느린 mutex기반 구현을 기본적으로 사용한다. 이런 플래폼에서 아파치를 컴파일할때 아파치를 최신 CPU에서만 실행할 계획이라면, 아파치를 구성할때 --enable-nonportable-atomics 옵션을 사용하여 더 빠른 atomic 구현을 선택할 수 있다:

./buildconf
./configure --with-mpm=worker --enable-nonportable-atomics=yes

--enable-nonportable-atomics 옵션은 다음과 같은 플래폼에 영향이 있다:

  • SPARC에서 Solaris
    기본적으로 APR은 Solaris/SPARC에서 mutex기반 atomic을 사용한다. 그러나 구성할때 --enable-nonportable-atomics를 사용하면 APR은 빠른 하드웨어 compare-and-swap을 위한 SPARC v8plus 명령어를 사용한다. 이 옵션을 사용하면 atomic 명령이 더 효율적이지만 (CPU를 덜 사용하고 더 높은 동기화가 가능하다), 컴파일한 실행파일은 UltraSPARC 칩에서만 실행할 수 있다.
  • Linux on x86
    기본적으로 APR은 리눅스에서 mutex기반 atomic을 사용한다. 그러나 구성할때 --enable-nonportable-atomics를 사용하면 APR은 빠른 하드웨어 compare-and-swap을 위한 486 명령어를 사용한다. 더 효율적인 atomic 명령이 가능하지만, 컴파일한 실행파일은 486 이상 칩에서만 (386은 안된다) 실행할 수 있다.

mod_status와 ExtendedStatus On

아파치를 컴파일할때 mod_status를 포함하고 실행할때 ExtendedStatus On을 설정하면 아파치는 요청을 받을때마다 gettimeofday(2)(혹은 운영체제에 따라 times(2))를 두번 호출하고 (1.3 이전에는) time(2)도 추가로 여러번 호출한다. 상태 보고서에 동작시간이 필요하기 때문이다. 최상의 성능을 얻으려면 (기본값인) ExtendedStatus off를 설정한다.

accept 직렬화 - 여러 소켓

주의:

아래 문서는 아파치 웹서버 2.0 버전에서 변경된 내용을 담고 있지 않다. 아직도 유효한 정보가 있지만, 주의해서 사용하길 바란다.

유닉스 소켓 API의 단점을 설명한다. 웹서버가 여러 포트 혹은 여러 주소를 기다리기위해 여러 Listen을 사용한다고 가정하자. 연결이 가능한지 각 소켓을 검사하기위해 아파치는 select(2)를 사용한다. select(2)는 소켓에 기다리고 있는 연결이 없는지 혹은 최소한 한개 있는지 알려준다. 아파치에는 여러 자식이 있고, 쉬고 있는 모든 자식은 동시에 새로운 연결을 검사한다. 원래 구현은 다음과 비슷하다 (이 예는 코드에서 가져오지 않았다. 단지 설명하기위한 용도로 만들었다.):

for (;;) {
for (;;) {
fd_set accept_fds;

FD_ZERO (&accept_fds);
for (i = first_socket; i <= last_socket; ++i) {
FD_SET (i, &accept_fds);
}
rc = select (last_socket+1, &accept_fds, NULL, NULL, NULL);
if (rc < 1) continue;
new_connection = -1;
for (i = first_socket; i <= last_socket; ++i) {
if (FD_ISSET (i, &accept_fds)) {
new_connection = accept (i, NULL, NULL);
if (new_connection != -1) break;
}
}
if (new_connection != -1) break;
}
process the new_connection;
}

그러나 위의 단순한 구현에는 심각한 고갈(starvation) 문제가 있다. 여러 자식이 동시에 이 반복문을 실행하면, 요청을 기다리며 모두 select에서 멈춘다. 이때 어떤 소켓에 요청이 하나라도 들어오면 모든 자식이 깨어난다 (깨어나는 자식의 개수는 운영체제와 타이밍에 따라 다르다). 이들은 모두 연결을 accept하길 시도한다. 그러나 (아직도 한 연결만 대기중이라면) 한 자식만 성공하고, 나머지는 accept에서 멈춘다. 그러면 이 자식들은 한 소켓의 요청만을 서비스하도록 묶여서, 그 소켓으로 새로운 요청이 충분히 들어와서 모든 자식을 깨울때까지 정지해있다. 이런 고갈 문제는 PR#467에 처음 보고되었다. 최소한 두가지 해결책이 있다.

한가지는 소켓을 대기하지 않도록 (non-blocking) 만드는 방법이다. 이 경우 자식이 accept를 해도 멈추지 않고, 즉시 진행할 수 있다. 그러나 CPU 시간을 낭비한다. select에서 쉬는 자식이 10개 있고, 새로 연결이 한개 들어왔다고 가정하자. 그러면 이 자식중 9개는 깨어나서 연결을 accept하길 시도하고 실패하면 아무 일도 하지 않고 다시 select를 반복한다. 다시 select로 돌아올 때까지 어떤 자식도 다른 소켓에 들어온 요청을 서비스하지 않는다. (다중프로세서 컴퓨터에서) 쉬는 자식 개수만큼 CPU 개수가 있는 드문 경우가 아니라면 이 해결책은 별로 좋아보이지 않는다.

다른 방법은 아파치가 사용하는 방법으로 내부 반복문에 한 자식만을 들여보낸다. 반복문은 다음과 같다 (차이를 강조했음):

for (;;) {
accept_mutex_on ();
for (;;) {
fd_set accept_fds;

FD_ZERO (&accept_fds);
for (i = first_socket; i <= last_socket; ++i) {
FD_SET (i, &accept_fds);
}
rc = select (last_socket+1, &accept_fds, NULL, NULL, NULL);
if (rc < 1) continue;
new_connection = -1;
for (i = first_socket; i <= last_socket; ++i) {
if (FD_ISSET (i, &accept_fds)) {
new_connection = accept (i, NULL, NULL);
if (new_connection != -1) break;
}
}
if (new_connection != -1) break;
}
accept_mutex_off ();
process the new_connection;
}

accept_mutex_onaccept_mutex_off 함수는 mutex 세마포어를 구현한다. 한번에 오직 한 자식만이 mutex를 가질 수 있다. mutex를 구현하는 방법은 여러가지이다. 구현 방법은 (1.3 이전) src/conf.h나 (1.3과 그 이후) src/include/ap_config.h에 정의되있다. 어떤 아키텍쳐는 잠금(locking) 방법을 선택하지 않기때문에, 이런 아키텍쳐에서 여러 Listen 지시어를 사용하면 위험하다.

실행시 AcceptMutex 지시어를 사용하여 mutex 구현을 변경할 수 있다.

AcceptMutex flock

이 방법은 잠금파일을 잠그기위해 flock(2) 시스템호출을 사용한다 (잠금파일 위치는 LockFile 지시어로 지정).

AcceptMutex fcntl

이 방법은 잠금파일을 잠그기위해 fcntl(2) 시스템호출을 사용한다 (잠금파일 위치는 LockFile 지시어로 지정).

AcceptMutex sysvsem

(1.3과 그 이후) 이 방법을 SysV식 세마포어를 사용하여 mutex를 구현한다. 불행히도 SysV식 세마포어는 나쁜 부작용이 있다. 하나는 아파치가 세마포어를 정리하지 않고 죽을 수 있는 점이다 (ipcs(8) manpage 참고). 다른 하나는 웹서버와 동일한 uid로 실행하는 CGI가 (즉, suexeccgiwrapper를 사용하지않는 한 모든 CGI) 세마포어 API를 사용하여 서비스거부공격을 할 수 있는 점이다. 이런 이유때문에 IRIX를 제외한 아키텍쳐에서 이 방법을 사용하지 않는다 (대부분의 IRIX 컴퓨터에서 앞의 두 방법은 지나치게 버겁다).

AcceptMutex pthread

(1.3과 그 이후) 이 방법은 POSIX mutex를 사용하기때문에 POSIX 쓰레드 규약을 완전히 구현한 아키텍쳐라면 모두 사용가능하지만, (2.5 이후) Solaris에서만 그것도 특정 구성에서만 동작하는 듯하다. 이 방법을 시도해본다면 서버가 멈춰서 응답을 안하는지 살펴봐야 한다. 정적 내용만 서비스하는 서버는 잘 동작하는 것 같다.

AcceptMutex posixsem

(2.0과 그 이후) 이 방법은 POSIX 세마포어를 사용한다. mutex를 가진 프로세스의 쓰레드가 죽는다면(segfault) 세마포어 소유권이 회복되지 않아서 웹서버가 멈춘다.

시스템에 위 목록에 없는 직렬화(serialization) 방법이 있다면 그 방법을 사용하는 코드를 APR에 추가할 가치가 있다.

고려는 해봤지만 구현하지않은 다른 방법은 부분적으로 반복문을 직렬화하는 방법이다. 즉, 프로세서를 몇개만 들여보내는 것이다. 이 방법은 여러 자식을 동시에 실행할 수 있어서 직렬화때문에 전체 대역폭을 활용하지 못하는 다중프로세서 컴퓨터에서만 관심을 가져볼 수 있다. 앞으로 살펴볼 부분이지만, 매우 병렬화된 웹서버가 흔하지 않아서 우선순위가 낮다.

최상의 성능을 얻기위해서는 여러 Listen 문을 사용하지 않는 것이 이상적이다. 그러나 계속 설명한다.

accept 직렬화 - 소켓 한개

앞의 설명은 다중소켓 서버에는 좋지만, 소켓이 한개인 서버는 어떤가? 연결이 도착할때까지 모든 자식이 accept(2)에서 멈춰있기때문에 이론상 같은 문제가 발생하지 않고, 고갈 문제도 없다. 그러나 실제로는 앞에서 말한 대기하지 않는 (non-blocking) 방법에서 발생하는 "공회전(spinning)" 현상을 감추고 있다. 대부분의 TCP 스택은 연결이 도착하면 커널이 accept에서 멈춰있는 모든 자식을 깨우도록 구현되있다. 프로세스중 한개가 연결을 얻고 사용자영역으로 돌아가고, 나머지는 커널에서 공회전하여 연결이 없음을 발견하면 다시 잠을 잔다. 사용자영역 코드에서는 이런 공회전을 알 수 없지만, 분명히 존재한다. 그래서 다중소켓의 대기하지 않는 방법과 동일하게 부하를 높이는 불필요한 행동이 일어난다.

그래서 우리는 여러 아키텍쳐에서 소켓이 한개인 경우에도 직렬화하면 더 "잘" 동작함을 발견했다. 그래서 거의 대부분의 경우 기본적으로 직렬화를 사용한다. 리눅스에서 (커널 2.0.30, 128Mb 메모리에 듀얼 Pentium pro) 실험한 결과 소켓 한개를 직렬화하면 하지 않은 경우에 비해 초당 요청이 3% 미만 줄어들었다. 그러나 직렬화를 하지 않은 경우 요청당 100ms 지연이 발생했다. 이 지연은 아마도 LAN에서 발생하는 긴 연결선때문일 것이다. 소켓이 한개인 경우 직렬화를 사용하지 않으려면 SINGLE_LISTEN_UNSERIALIZED_ACCEPT를 정의한다.

Close 지연(lingering)

draft-ietf-http-connection-00.txt 8절에서 설명하듯이 안정적인 웹서버가 되려면, 통신의 양 방향을 독립적으로 닫을 수 있어야 한다 (TCP 연결은 쌍방향이고, 방향은 서로 독립적이다). 이점을 다른 서버에서는 자주 간과하지만, 아파치는 1.2부터 정확히 구현해왔다.

이 기능을 부주의하게 아파치에 추가했을때 여러 유닉스 버전에서 많은 문제가 발생했다. TCP 규약은 FIN_WAIT_2에 타임아웃이 있다고 정하지 않았지만, 금지하지도 않았다. 타임아웃이 없는 시스템에서 아파치 1.2는 많은 소켓을 영원히 FIN_WAIT_2 상태로 만들었다. 많은 경우 이 문제는 제작사가 제공하는 최신 TCP/IP 패치를 적용하여 해결할 수 있다. 그러나 제작사가 패치를 발표하지 않는 경우가 (즉, SunOS4 -- 소스 라이선스가 있는 사람은 직접 패치할 수 있지만) 있기때문에 이 기능을 사용하지 않기로 결정했다.

방법은 두가지다. 하나는 소켓 옵션 SO_LINGER를 사용하는 방법이다. 그러나 불행히도 대부분의 TCP/IP 스택은 이 옵션을 올바로 구현하지 않았다. 올바로 구현한 스택에서 조차도 (즉, 리눅스 2.0.31) 이 방법은 다음 방법보다 더 cpu를 잡아먹는다.

아파치는 보통 (http_main.c에 있는) lingering_close라는 함수를 사용한다. 이 함수는 대충 다음과 같다:

void lingering_close (int s)
{
char junk_buffer[2048];

/* shutdown the sending side */
shutdown (s, 1);

signal (SIGALRM, lingering_death);
alarm (30);

for (;;) {
select (s for reading, 2 second timeout);
if (error) break;
if (s is ready for reading) {
if (read (s, junk_buffer, sizeof (junk_buffer)) <= 0) {
break;
}
/* just toss away whatever is here */
}
}

close (s);
}

이 코드는 연결을 닫을때 더 CPU를 사용하지만, 안정적인 구현을 위해 필요하다. HTTP/1.1이 더 널리 퍼지고 모든 연결을 유지한다면(persistent), 연결을 받는 비용은 여러 요청을 처리하면서 상쇄될 것이다. 위험하게도 NO_LINGCLOSE를 정의하여 이 기능을 사용하지 않을 수 있지만, 절대로 권하지 않는다. 특히 HTTP/1.1 파이프라인 (역주; 연결유지 상태에서 응답을 기다리지 않고 여러 요청을 보내는 기술) 연결유지에는 lingering_close가 필수적이다 (그리고 파이프라인 연결이 더 빠르기때문에 사용하길 바랄 것이다).

Scoreboard 파일

아파치의 부모와 자식은 scoreboard라는 것을 통해 서로 통신한다. 이상적으로는 scoreboard를 공유메모리로 구현해야 한다. 우리 개발자가 해당 운영체제에 접근할 수 있거나 상세한 포팅 결과를 받은 경우 보통 공유메모리를 사용하여 구현한다. 나머지는 디스크에 있는 파일을 사용하여 구현한다. 디스크에 있는 파일은 느리고 신뢰도가 떨어진다 (기능도 더 적다). src/main/conf.h 파일에서 사용하는 아키텍쳐를 찾아서 USE_MMAP_SCOREBOARD 혹은 USE_SHMGET_SCOREBOARD인지 확인한다. 둘중 하나를 (각각 함께 사용할 HAVE_MMAP이나 HAVE_SHMGET도 같이) 정의하면 공유메모리 코드를 사용한다. 시스템이 다른 종류의 공유메모리를 사용한다면 src/main/http_main.c 파일을 수정하여 아파치에서 공유메모리를 사용할 수 있도록 훅(hook)을 추가하라. (또한 패치를 우리에게 보내주길 바란다.)

역사적 설명: 아파치의 리눅스 버전은 아파치 1.2 버전부터 공유메모리를 사용하기 시작했다. 리눅스에서 초기 아파치 버전이 느리고 신뢰도가 떨어졌기 때문이다.

DYNAMIC_MODULE_LIMIT

모듈을 동적으로 읽어들이지 않는다면 (가능한 조금이라도 성능을 높이기위해 이 글을 읽는다면 아마도 모듈을 동적으로 읽어들이지 않을 것이다), 서버를 컴파일할때 -DDYNAMIC_MODULE_LIMIT=0을 추가한다. 그러면 모듈을 동적으로 읽어들이기위해 할당하는 메모리를 절약한다.

top

부록: 시스템호출 기록을 자세히 분석하기

다음은 Solaris 8에서 worker MPM을 사용한 아파치 2.0.38의 시스템호출 기록(trace)이다. 아래 명령어를 사용하여 기록을 얻었다:

truss -l -p httpd_child_pid.

-l 옵션을 사용하면 truss는 시스템호출을 하는 LWP (lightweight process, 경량급 프로세스--Solaris의 커널수준 쓰레드) ID를 같이 기록한다.

다른 시스템에는 strace, ktrace, par 같은 시스템호출 추적 도구가 있다. 결과는 비슷하다.

클라이언트는 웹서버에게 크기가 10KB인 정적 파일을 요청한다. 정적인 파일을 요청하지 않거나 내용협상하는 요청을 한 경우 기록이 매우 다르다 (때로는 매우 알아보기 힘들다).

/67:    accept(3, 0x00200BEC, 0x00200C0C, 1) (sleeping...)
/67:    accept(3, 0x00200BEC, 0x00200C0C, 1)            = 9

위에서 연결대기(listener) 쓰레드가 LWP #67에서 실행됨을 알 수 있다.

accept(2) 직렬화를 사용하지 않음을 주목하라. 여러 포트를 기다리지않는 경우 이 플래폼의 worker MPM은 기본적으로 직렬화하지 않은 accept를 사용한다.
/65:    lwp_park(0x00000000, 0)                         = 0
/67:    lwp_unpark(65, 1)                               = 0

연결은 받아들이고(accept) 연결대기 쓰레드는 worker 쓰레드를 깨워서 요청을 처리하게 한다. 아래 기록에서 요청을 처리하는 worker 쓰레드가 LWP #65임을 알 수 있다.

/65:    getsockname(9, 0x00200BA4, 0x00200BC4, 1)       = 0

가상호스트를 구현하기위해 아파치는 연결을 받아들인 지역(local) 소켓 주소를 알아야 한다. (가상호스트를 사용하지 않거나 Listen 지시어에 와일드카드 주소를 사용하지 않은 경우 등) 많은 경우 이 호출을 없앨 수 있다. 그러나 아직 이런 최적화 작업이 안되있다.

/65:    brk(0x002170E8)                                 = 0
/65:    brk(0x002190E8)                                 = 0

brk(2) 호출은 힙(heap)에서 메모리를 할당한다. 웹서버는 대부분의 요청 처리시 자체 메모리 할당자(apr_poolapr_bucket_alloc)를 사용하기때문에 시스템호출 기록에서 이 시스템호출을 보기가 드물다. 이 기록에서 웹서버는 시작하자마자 자체 메모리 할당자가 사용할 메모리블록을 얻기위해 malloc(3)을 호출한다.

/65:    fcntl(9, F_GETFL, 0x00000000)                   = 2
/65:    fstat64(9, 0xFAF7B818)                          = 0
/65:    getsockopt(9, 65535, 8192, 0xFAF7B918, 0xFAF7B910, 2190656) = 0
/65:    fstat64(9, 0xFAF7B818)                          = 0
/65:    getsockopt(9, 65535, 8192, 0xFAF7B918, 0xFAF7B914, 2190656) = 0
/65:    setsockopt(9, 65535, 8192, 0xFAF7B918, 4, 2190656) = 0
/65:    fcntl(9, F_SETFL, 0x00000082)                   = 0

다음 worker 쓰레드는 클라이언트의 연결(파일기술자 9)을 대기안함(non-blocking) 상태로 바꾼다. setsockopt(2)getsockopt(2) 호출은 Solaris의 libc가 소켓에 대한 fcntl(2)을 어떻게 처리하는지 보여준다.

/65:    read(9, " G E T   / 1 0 k . h t m".., 8000)     = 97

worker 쓰레드는 클라이언트로 부터 요청을 읽는다.

/65:    stat("/var/httpd/apache/httpd-8999/htdocs/10k.html", 0xFAF7B978) = 0
/65:    open("/var/httpd/apache/httpd-8999/htdocs/10k.html", O_RDONLY) = 10

웹서버 설정은 Options FollowSymLinksAllowOverride None이다. 그래서 요청한 파일경로의 각 디렉토리에 대해 lstat(2)하거나 .htaccess 파일을 검사할 필요가 없다. 파일을 검사하기위해, 1) 파일이 있는지, 2) 디렉토리가 아닌 일반파일인지, stat(2) 호출만 하면 된다.

/65:    sendfilev(0, 9, 0x00200F90, 2, 0xFAF7B53C)      = 10269

이 경우 웹서버는 한번의 sendfilev(2) 시스템호출로 HTTP 응답헤더와 요청한 파일을 전송할 수 있다. Sendfile 지원여부는 운영체제마다 다르다. 다른 시스템이라면 sendfile(2)을 호출하기 전에 헤더를 보내기위해 write(2)writev(2) 호출을 한다.

/65:    write(4, " 1 2 7 . 0 . 0 . 1   -  ".., 78)      = 78

write(2) 호출은 접근로그(access log)에 요청을 기록한다. 이 기록에 time(2) 호출이 없음을 주목하라. 아파치 1.3과 달리 아파치 2.0은 시간을 알기위해 gettimeofday(3)를 사용한다. gettimeofday를 최적화한 리눅스와 Solaris 같은 몇몇 운영체제에서는 일반적인 시스템호출 부담이 없다.

/65:    shutdown(9, 1, 1)                               = 0
/65:    poll(0xFAF7B980, 1, 2000)                       = 1
/65:    read(9, 0xFAF7BC20, 512)                        = 0
/65:    close(9)                                        = 0

worker 쓰레드는 연결을 지연닫기(lingering close)한다.

/65:    close(10)                                       = 0
/65:    lwp_park(0x00000000, 0)         (sleeping...)

마지막으로 worker 쓰레드는 방금 전송한 파일을 닫고, 연결대기(listener) 쓰레드가 다른 연결을 할당할 때까지 정지한다.

/67:    accept(3, 0x001FEB74, 0x001FEB94, 1) (sleeping...)

그동안 연결대기 쓰레드는 연결을 (모든 worker가 작업중이면 연결대기 쓰레드를 멈추는 worker MPM의 흐름제어 기능에 따라) worker 쓰레드에 할당하자마자 다른 연결을 받아들일 수 있다. 이 기록에는 나오지 않지만, worker 쓰레드가 방금 받은 연결을 처리하는 동안 다음 accept(2)가 (요청이 매우 많은 경우 항상) 일어날 수 있다.