메뉴

문서정보

목차

NginX를 이용한 static 컨텐츠 서비스

NginX를 리버스 프락시 서버로 사용하는 이유는 다음과 같다.
  1. 로드밸런싱 : 유저의 요청을 웹 애플리케이션 서버(WAS)로 분산 할 수 있다.
  2. 유저 요청에 대한 선 처리 : 유저의 요청이 WAS에 도달하기 전에 다양한 처리를 할 수 있다. 웹 애플리케이션 방화벽(WAF)를 설치하거나, 유저의 요청을 다른 위치로 보내도록 제어 할 수 있다.
  3. 캐싱 : 웹 서비스는 이미지, CSS, 자바스크립트 같은 정적인 페이지를 가지고 있다. 이런 정적 컨텐츠들을 NginX에서 대신 처리하는 것으로 응답 속도를 높일 수 있으며, WAS에 대한 부담을 줄일 수 있다. 컨텐츠들을 메모리에 캐시할 경우 서비스 할 경우 고성능의 웹 서비스를 만들 수도 있다.
이중 3번, Nginx를 이용해서 스태틱 페이지를 캐싱해서 서비스하는 방법을 테스트한다.

Web Contents 캐시에 대해서

서버 캐시

웹 서비스의 성능을 높일 수 있는 가장 확실하고 손쉬운 방법은 컨텐츠 캐시다. 요즘 웹 서버는 매우 바쁘다. 유저의 요청을 받아서 데이터베이스를 조회해서 즉석에서 HTML 페이지를 만들고 이미지와 CSS, 자바스크립트 등 다양한 오브젝트들을 함께 응답해야 한다. 웹 서버가 해야 하는 일이 늘어나면서 리버스 프락시를 이용, 두 개 이상의 WAS(Web Application Server)가 요청을 분산해서 처리하도록 구성을 한다.

 Reverse proxy server

유저가 요청을 내리면 WAS로 바로가지 않고, 리버스 프락시 서버를 한번 거치게 된다. 요청을 분산하기 위해서 프락시 서버를 한번 더 거쳐야 하는데, 모든 컨텐츠가 굳이 프락시 서버를 거칠 필요가 있을까 ? 변하지 않는 컨텐츠의 경우에는 그냥 프락시 서버에서 직접 응답을 하게 하면 더 빠른 서비스를 제공 할 수 있지 않을까 ? 요즘 왠만한 웹 서비스들은 동적으로 컨텐츠를 서비스 한다. 하지만 조금만 들여다 보면, 많은 컨텐츠가 정적(변하지 않음)이라는 것을 알 수 있다. 이미지, CSS, Javascript, 동영상, PDF 뿐만 아니라 HTML 문서의 상당수도 정적이다. 유저 정보를 표시하는 HTML 문서는 (데이터베이스 등을 조회해서) 동적으로 만들어져야 하겠으나, 메뉴얼, 사이트 소개, 메뉴 등은 변하지 않는 컨텐츠들이다. 이들 컨텐츠들을 프락시 서버에서 서비스 한다면 더 빠르고 효율적으로 서비스 할 수 있을 것이다. 아래 그림을 보자.

 Reverse proxy cache

리버스 프락시에서 정적인 페이지를 대신 응답한다. 이렇게 구성해서 얻을 수 있는 성능상의 잇점을 살펴보자.
  1. 네트워크 홉 이 줄어든다. WAS까지 요청을 보내지 않아도 된다. 1만큼의 네트워크 홉을 줄일 수 있다.
  2. 파일처리에 최적화 할 수 있다. 캐시 서버는 파일에 대한 "읽기/쓰기" 연산, 그 중에서도 읽기 연산을 주로 한다. 목적이 특정되므로 이에 맞게 최적화 할 수 있다. 당연하지만 NginX나 Apache 같은 전용 웹서버는 다양한 일을 하는 WAS(Django, RoR, Node)보다 빠르고 효율적으로 작동한다.
  3. 최적화가 쉽다. 정적 컨텐츠와 동적 컨텐츠를 분리 함으로써, 그에 맞는 최적화 방법을 사용 할 수 있다. 정적 컨텐츠를 Redis나 memcache, 램디스크 등으로 캐시할 수 있다.

NginX 캐시 매커니즘

NginX 서버의 캐시 매커니즘은 아래와 같다.

 NginX 캐시 매커니즘

  1. NginX 캐시 서버가 컨텐츠 요청을 받는다.
  2. 요청 URL을 Key로 컨텐츠가 캐시돼 있는지 확인한다.
  3. 처음 요청이므로 캐시 실패(MISS) 한다.
  4. WAS서버로 요청을 보낸다.
  5. WAS서버로 부터 응답이 오면 캐시를 할지를 검사한다.
  6. 응답을 디스크에 저장한다.
  7. 다음 번에 같은 URI로 요청이 오면, 캐시 적중(HIT)한다. 요청은 WAS로 전달되지 않으며, 캐시서버가 대신 응답한다.

테스트 구성

아래와 같은 서비스를 구성하기로 했다.

 Static File service

WAS 서버는 CSS, 이미지, Javascript를 포함한 동적인 컨텐츠를 서비스 한다. WAS 앞에는 Nginx가 WAS 서버들을 로드밸런싱 한다. WAS는 로드밸런서의 역할 뿐만 아니라 static 페이지들을 캐시하고 있다가 대신 서비스하는 캐시서버 역할을 한다.

WAS 서버 구성

테스트를 위해서 WAS 서버를 구성했다. 이 서버는 HTML, CSS, 자바스크립트, 이미지등을 서비스한다. NginX로 만들기로 했다. 아래는 설정파일이다.
# cat /etc/nginx/sites-available/default
server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /var/www/html;

    # Add index.php to the list if you are using PHP
    index index.html index.htm index.nginx-debian.html;

    server_name _;

    location / {
        try_files $uri $uri/ =404;
    }
    location ~* \.(?:manifest|appcache|html?|xml|json)$ {
        expires -1;
    }

    location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
        expires 1M;
        access_log off;
        add_header Cache-Control "public";
    }

    location ~* \.(?:css|js)$ {
        expires 1y;
        access_log off;
        add_header Cache-Control "public";
    }
}
이 설정파일은 기본적인 캐시 설정을 포함하고 있다. manifest, appcache, html, xml, json 파일들은 캐시를 하지 않는다. jpg,gif,gz 과 같은 파일들은 한달의 만료기간동안 캐시를 한다. css와 js 파일들은 1년을 캐시한다.

curl로 index.html을 요청해보자.
$ curl -XGET -I 192.168.56.30/index.html
HTTP/1.1 200 OK
Server: nginx/1.9.3 (Ubuntu)
Date: Mon, 09 May 2016 14:59:36 GMT
Content-Type: text/html
Content-Length: 7261
Last-Modified: Mon, 09 May 2016 14:54:30 GMT
Connection: keep-alive
ETag: "5730a4a6-1c5d"
Expires: Mon, 09 May 2016 14:59:35 GMT
Cache-Control: no-cache
Accept-Ranges: bytes
ExpiresDate가 같은 시간임을 알 수 있다. 요청시간과 동시에 컨텐츠가 만료됐다. 따라서 클라이언트는 요청 결과를 캐시하지 않는다. 또한 Cache-Control: no-cache로 이 컨텐츠를 캐시하지 말라고 지시하고 있다.

css파일을 요청해 보자.
# curl -XGET -I 192.168.56.30/css/foundation.css
HTTP/1.1 200 OK
Server: nginx/1.9.3 (Ubuntu)
Date: Mon, 09 May 2016 15:10:52 GMT
Content-Type: text/css
Content-Length: 105995
Last-Modified: Fri, 15 Apr 2016 07:10:18 GMT
Connection: keep-alive
ETag: "571093da-19e0b"
Expires: Tue, 09 May 2017 15:10:52 GMT
Cache-Control: max-age=31536000
Cache-Control: public
Accept-Ranges: bytes
Expires시간이 현재시간으로 부터 1년 후임을 알 수 있다. 이 규칙에 의해서 foundation.css는 최대 1년 동안 캐시가 된다.

캐시 서버 설정

WAS 서버 설정이 끝났다. 이제 캐시서버를 만든다. 이 캐시서버는 WAS 서버를 로드밸런싱하면서 동시에 주요 컨텐츠들을 캐싱하는 역할을 한다. 유저의 요청은 캐시서버를 거치는데, 요청 결과가 캐시되어 있다면 (WAS까지 요청을 보내지 않고)캐시서버가 즉시 결과를 응답한다.

역시 NginX 서버를 설치하고 아래와 같이 설정했다.
# cat /etc/nginx/site-available/default
server {
    listen 80 default_server;
    root /var/www/;
    index index.html index.htm;

    charset utf-8;

    location / {
        include proxy_params;
        proxy_pass http://192.168.56.30;
    }
}

우선은 간단한 reverse proxy로 설정했다. 캐시서버는 192.168.56.31:80에서 유저 요청을 기다린다. 만약 유저 요청이 도착하면, proxy_pass에 설정된 192.168.56.30으로 요청을 전달한다. curl을 이용해서 /css/foundation.css를 요청해 보자. 캐시서버가 컨텐츠를 캐시하지 않고 있기 때문에, 이 요청은 WAS까지 전달된다.
# curl -XGET -I 192.168.56.31/css/foundation.css
HTTP/1.1 200 OK
Server: nginx/1.9.3 (Ubuntu)
Date: Tue, 10 May 2016 14:05:56 GMT
Content-Type: text/css
Content-Length: 105995
Connection: keep-alive
Last-Modified: Fri, 15 Apr 2016 07:10:18 GMT
ETag: "571093da-19e0b"
Expires: Wed, 10 May 2017 14:05:56 GMT
Cache-Control: max-age=31536000
Cache-Control: public
Accept-Ranges: bytes

이제 NginX가 WAS의 컨텐츠를 캐시하도록 설정을 바꿔보자.
proxy_cache_path /tmp/nginx levels=1:2 keys_zone=my_zone:10m inactive=60m;
proxy_cache_key "$scheme$request_method$host$request_uri";
server {
    listen 80 default_server;
    root /var/www/;
    index index.html index.htm;

    charset utf-8;

    location / {
        proxy_cache my_zone;
        add_header X-Proxy-Cache $upstream_cache_status;

        include proxy_params;
        proxy_pass http://192.168.56.30;
    }
}

proxy_cache_path

캐시파일이 저장되는 위치와 저장 방식을 설정하기 위해서 사용한다. levels는 캐시파일을 어떻게 저장할지를 결정한다. 만약 levels를 설정하지 않는다면, 캐시파일은 현재 디렉토리에 저장이 된다. level을 설정할 경우, 서브디렉토리에 md5 해시된 파일이름으로 컨텐츠를 저장한다. level=1:2는 첫번째 단계의 디렉토리는 한글자, 두번째 단계의 디렉토리는 두 글자로 명명하라는 의미다. 예를 들어 /tmp/nginx/a/bc 디렉토리에 캐시 파일이 저장된다.

keys_zone은 이 캐시를 가리키는 이름이다. my_zone은 캐시와 메타파일을 저장하기 위해서 10M의 공간을 할당 했다.

inactive지시어를 사용 하면, 일정 시간동안 접근이 없는 캐시파일을 할 수 있다. 위 설정에서는 한시간(60 minutes)동안 사용하지 않는 캐시파일은 삭제하도록 설정했다.

proxy_cache_key

각 캐시파일을 구분하기 위한 Key 규칙을 설정한다. 기본 설정 값은 $scheme$proxy_host$uri$is_args$args이다. "$host$request_uri $cookie_user"와 같이 사용 할 경우 유저 쿠키 별로 캐시를 구성할 수도 있다.

proxy_cache

location블럭내에서 사용한다. my_zone 캐시를 사용하도록 설정했다. add_header X-Proxy-Cache $upstream_cache_status 헤더를 추가했다. 이 헤더에는 HIT, MISS, MYPASS와 같은 캐시 적중 상태정보가 설정된다.

curl를 이용해서 컨텐츠를 요청해 보자.
$ curl -XGET -I 192.168.56.31/css/foundation.css
HTTP/1.1 200 OK
Server: nginx/1.9.3 (Ubuntu)
Date: Tue, 10 May 2016 14:09:35 GMT
Content-Type: text/css
Content-Length: 105995
Connection: keep-alive
Last-Modified: Fri, 15 Apr 2016 07:10:18 GMT
ETag: "571093da-19e0b"
Expires: Wed, 10 May 2017 14:09:35 GMT
Cache-Control: max-age=31536000
Cache-Control: public
X-Proxy-Cache: MISS
Accept-Ranges: bytes

아직 캐시가 없어서 MISS가 설정된 걸 확인 할 수 있다. 캐시를 하기 위해서는 WAS로 부터 최소한 한 번 이상의 요청이 이루어져야 한다. 다시 한번 요청을 해보자.
$ curl -XGET -I 192.168.56.31/css/foundation.css
HTTP/1.1 200 OK
Server: nginx/1.9.3 (Ubuntu)
Date: Tue, 10 May 2016 14:09:42 GMT
Content-Type: text/css
Content-Length: 105995
Connection: keep-alive
Last-Modified: Fri, 15 Apr 2016 07:10:18 GMT
ETag: "571093da-19e0b"
Expires: Wed, 10 May 2017 14:09:35 GMT
Cache-Control: max-age=31536000
Cache-Control: public
X-Proxy-Cache: HIT
Accept-Ranges: bytes
캐시가 성공(HIT)한 것을 알 수 있다. 이 요청은 WAS로 전달되지 않고, 캐시 서버가 대신 응답한다. 캐시서버 디렉토리에서 캐시파일을 확인 할 수 있다.
# cd /tmp/nginx/c/6f
# ls
d34ee92169a4e9dbb366919f06d756fc

이제 Ngix는 클라이언트의 Cache-Control 헤더 요청을 무시한다. 만약 클라이언트의 Cache-Control 요청을 허용하고 싶다면, 아래와 같이 설정을 바꿔야 한다.
server {
    listen 80 default_server;
    root /var/www/;
    index index.html index.htm;

    charset utf-8;

    location / {
        proxy_cache my_zone;
        proxy_cache_bypass $http_cache_control;
        add_header X-Proxy-Cache $upstream_cache_status;

        include proxy_params;
        proxy_pass http://192.168.56.30;
    }
}
curl로 Cache-Control을 테스트해보자.
$ curl -XGET -I -H "Cache-Control: no-cache" 192.168.56.31/css/foundation.css
HTTP/1.1 200 OK
Server: nginx/1.9.3 (Ubuntu)
Date: Tue, 10 May 2016 15:07:59 GMT
Content-Type: text/css
Content-Length: 105995
Connection: keep-alive
Last-Modified: Fri, 15 Apr 2016 07:10:18 GMT
ETag: "571093da-19e0b"
Expires: Wed, 10 May 2017 15:07:59 GMT
Cache-Control: max-age=31536000
Cache-Control: public
X-Proxy-Cache: BYPASS
Accept-Ranges: bytes
X-Proxy-Cache가 BYPASS로 설정된 걸 알 수 있다. 이 요청은 WAS 서버로 직접 전달된다. tcpdump로 Cache-Control을 설정했을 때와 그렇지 않았을 때를 테스트 해보자.

캐시 상태

앞서 X-Proxy-Cache를 이용해서 캐시의 상태를 반환하고 있다. MISS, HIT, BYPASS외에 몇 가지 상태들이 더 있다.
MISS 캐시를 찾을 수 없다. 요청은 WAS 까지 전달된다. WAS 응답이 끝나면 캐시가 만들어질 것이다.
BYPASS 캐시서버의 캐시를 무시하고, WAS까지 요청을 전달한다. 캐시서버는 아마도 캐시를 가지고 있을 것이다.
EXPIRED 캐시 만료시간이 초과했다. 캐시서버는 WAS로 요청을 전달해서 캐시를 업데이트 한다.
STALE 서버가 제대로 응답하지 않아서, 낡은(stale) 캐시를 서비스하고 있다.
UPDATING 정확한 목적을 모르겠다. 좀 더 살펴봐야 할 듯
REVALIDATED 캐시가 여전히 유효하다. 클라이언트가 if-modified-since를 설정했을 때 리턴한다.
HIT 성공적으로 캐시를 서비스 했다.

Joinc에 적용

Joinc는 Go로 직접 개발한 CMS(자칭 gowiki)로 운영하고 있다. 모니위키를 기반으로 하고 있기 때문에, 위키문법의 문서를 HTML로 변환하는데 상당한 자원이 들어간다. 성능을 올리기 위해서 gowiki 서버 앞에 nginx 캐시서버를 두기로 했다.

 gowiki cache 적용

/w 로 시작하는 URL의 컨텐츠는 정적인 컨텐츠이므로 캐시하기로 했다. 예를 들어 처음 /w/FrontPage를 요청하면, Gowiki로 요청이 전달된다. Gowiki는 FrontPage 문서를 HTML로 변환해서 응답한다. 이 응답은 NginX 서버가 캐시를 하므로, 다음번 요청 부터는 캐시에 있는 데이터를 응답한다.

동적인 컨텐츠는 /api로 요청을 한다. 코드 실행기가 대표적인 경우인데, 이런 컨텐츠는 캐시하면 안 된다. 그리고 css, js, png, jpg 등도 캐시하기로 했다. NginX의 설정은 다음과 같다.
# cat /etc/nginx/site-available/default
proxy_cache_path /tmp/nginx levels=1:2 keys_zone=my_zone:100m inactive=1440m;
proxy_cache_key "$scheme$request_method$host$request_uri";
server {
    listen 80 default_server;
    root /var/www/;
    index index.html index.htm;

    charset utf-8;

    location / {

        include proxy_params;
        proxy_pass http://127.0.0.1:8000;
    }

    location /w/ {
        proxy_cache my_zone;
        proxy_cache_valid any 30m;
        add_header X-Proxy-Cache $upstream_cache_status;
        add_header Cache-Control "public";
        expires 1y;

        include proxy_params;
        proxy_pass http://127.0.0.1:8000;
    }

    location ~* \.(?:css|js|html)$ {
        proxy_cache my_zone;
        proxy_cache_valid any 30m;
        add_header X-Proxy-Cache $upstream_cache_status;
        add_header Cache-Control "public";
        expires 1y;

        include proxy_params;
        proxy_pass http://127.0.0.1:8000;
    }
}

캐시 삭제

일단 캐시가된 컨텐츠의 경우, 컨텐츠의 내용이 바뀌더라도 여전히 캐시의 내용을 읽어오는 문제가 있다. 위키 특성상 문서를 자주 수정하게 되는데, 수정한 내용이 보이지 않는 것은 심각한 문제가 될 수 있다. 문제를 해결하기 위해서는 문서의 수정 후 해당 컨텐츠의 캐시를 찾아서 삭제하는 코드를 만들어야 했다. 직접 만들기 귀찮아서 nginx-cache-purge라는 프로그램을 다운로드 해서 사용했다. 그럭저럭 잘 작동하는 것 같다. 예를 들어서 /w/man/12/nginx 문서를 수정했다면 ./nginx-cache-purge /w/man/12/nginx /tmp/nginx 수행하면 된다.

혹은 컨텐츠의 key를 md5 한 값을 데이터베이스에 저장해서 문서 수정시 삭제하는 방법이 있다. 위 설정에 의하면 /w/man/12/nginx의 key는 "httpGETwww.joinc.co.kr/w/man/12/nginx/static"이다. 이걸 md5 한 값은 아래와 같다.
# echo -n "httpGETwww.joinc.co.kr/w/man/12/nginx/static" | md5sum
0c9aa652815494e45573c077c9015c60  -
이 캐시파일의 정확한 위치는 "/tmp/nginx/0/c6/0c9aa652815494e45573c077c9015c60"다. 디렉토리는 md5 해시의 마지막 값으로 알아낼 수 있다.

성능 테스트

크롬 개발자도구를 이용해서 응답속도만 테스트 했다. static 의 응답시간을 확인하자.

캐시 적용 전

 cache 사용전

캐시 적용 후

 cache 적용후

캐시 적용 된 후 47msec 에서 5msec로 줄어든 걸 확인 할 수 있다.

하나의 HTML 문서에 정적 컨텐츠와 동적 컨텐츠가 함께 있을 때

Joinc 사이트는 아래와 같이 구성된다.

 joinc 사이트 구성

모든 페이지들이 메뉴와 wiki 본문 으로 구성이 된다. 메뉴는 유저의 상태에 따라서 구성이 달라진다. 로그인 하기 전이라면 로그인 관련 메뉴를 출력하고,로그인 후라면 문서 편집과 같은 메뉴들이 출력된다. 컨텐츠가 동적으로 바뀌는 셈이다. 반면 Wiki 문서 내용은 정적 컨텐츠다. 만약 페이지 전체를 캐시를 해버리면, 메뉴 출력에 문제가 생길 것이다. 이 문제를 해결해야 했다.

나는 쿠기(cookie)를 이용해서 이 문제를 해결 했다. 아래와 같이 proxy_cache_key에 cookie_login을 설정했다.
proxy_cache_key "$scheme$request_method$host$request_uri$cookie_login";
유저가 로그인에 성공하면 SetCookie("login", "true")로 login 쿠키를 설정한다. 로그아웃 하면 SetCookie("login","")로 쿠키를 삭제한다. 이렇게 하면 동일한 페이지라고 하더라도 로그인과 로그아웃 상태별로 캐시파일을 만들 수 있다. 간단하기는 하지만 캐시파일이 두배가 된다는 단점이 있다. 메뉴부분을 모두 자바스크립트로 처리하는 방법도 있는데, "개인 사이트에서 캐시공간이 늘어나 봤자 얼마나 늘어나겠나 ?"라는 생각에 패스하기로 했다(귀찮아서 패스했다는 얘기다).