Backend/장고 Django

Nginx / Gunicorn 개념

Study-note 2022. 4. 20. 16:52

https://velog.io/@jimin_lee/Nginx%EC%99%80-Gunicorn-%EB%91%98-%EC%A4%91-%ED%95%98%EB%82%98%EB%A7%8C-%EC%8D%A8%EB%8F%84-%EB%90%A0%EA%B9%8C

 

Nginx와 Gunicorn 둘 중 하나만 써도 될까?

Flask나 Django로 만든 파이썬 앱을 배포할 때, 보통 Nginx와 Gunicorn과 자기 앱을 연결해서 배포하는 게 정석처럼 여겨

velog.io

https://moondol-ai.tistory.com/467

 

Nginx, Gunicorn이란 무엇인가? feat. Django

본 글에서는 Django를 실제 서버로 배포하면서 필요했던 Nginx, Gunicorn의 개념에 대해 알아보고자 합니다. 참고로 저는 Django로 백엔드를 구축했다 보니 아래에서 소개되는 웹서비스 구조는 Django를

moondol-ai.tistory.com

 

 


Web server(Nginx, Gunicorn, Dramatiq) SOFTWARE

참고로 저는 Django로 백엔드를 구축했다 보니 아래에서 소개되는 웹서비스 구조는 Django를 중심으로 한 것임을 미리 알려드립니다. 특히 WAS에 대한 정의가 스프링의 경우 톰캣이라고 알려져 있지만 다른 진영(Django, .NET 등)에선 딱히 뭐다라고 정의되지 않는다고 하네요.

웹서버와 WAS

결론적으로 Nginx는 웹서버(Web Server)이고, 또 다른 웹서버로는 그 유명한 Apache도 있죠. 웹서버는 클라이언트가 (웹사이트에서) 무언가 요청하면 그에 대한 적절한 반응을 해주는 존재라고 생각하면 됩니다. 이런 웹서버와 항상 같이 등장하는 개념이 WAS(Web Application Server)입니다. 웹서버와 WAS는 비슷해 보이지만 약간의 차이가 있는데 일단 아래 그림을 봅시다.

웹서버는 단순히 정적 파일(static file)을 응답하는 존재라면, WAS는 동적 사이트를 전문적으로 처리하는 존재입니다. 정적 파일은 말 그대로 멈춰있는 파일, 즉 이미지, html, css 등 변하지 않는 파일 그 자체를 가리키는 것입니다. 반면, 동적인 처리는 클라이언트가 아이디, 패스워드를 넘겼을 때 하는 로그인 처리 또는 클라이언트 쿼리 파라미터에 따라 달라지는 DB 값 표시 등을 말합니다.

웹서버를 별도로 운영하는 이유

웹서버를 별도로 운영하는 이유는 WAS의 부담을 줄여주기 위함입니다. 생각해보면 이미지, html, css 등 웹 사이트 운영을 함에 있어서 기본적으로 변하지 않는 파일들은 굳이 동적인 처리를 하는 WAS에서 담당할 필요가 없겠죠. 다시 말해 클라이언트의 요청 중 웹서버가 처리할 수 있다면 WAS까지 굳이 전달하지 않으면서 WAS의 업무 비중을 줄여주는 것입니다.

많은 웹서버 중에서도 Nginx를 사용하는 이유는 다음과 같습니다.

1. 빠른 속도
- 동시요청(concurrent connections)이 많아도 메모리 사용량이 현저히 적음
- 초당 처리 요청 역시 많음

2. 리버스 프록시(Reverse proxy)로 사용 가능
- 리버스 프록시란 인터넷과 백엔드 사이에 있는 서버를 가리킴(포워드 프록시는 클라이언트와 인터넷 사이)
- 여러 WAS가 존재하면 클라이언트 요청을 분산시키는 역할 수행 -> 로드 밸런싱(load balancing)
- 캐싱 가능(WAS까지 요청하지 않아도 클라이언트 요청에 빠르게 응답)
- 민감한 WAS 정보(기기 id, MAC 주소 등)를 숨기는 보안 역할 수행

3. SSL 지원
- HTTPS의 인증서 제공

4. 웹페이지 접근 인증
- 로그인 정보(관리자, 사용자)를 WAS에서 하지 않고 Nginx에서도 가능

5. 압축
- gzip을 사용하면 클라이언트가 보낸 텍스트 파일을 압축

6. 비동기 처리
- Event loop 기반으로 상당히 많은 트래픽을 동시에 처리 가능

이외에도 다양한 기능이 많지만 특히 2번과 6번의 기능이 가장 중요하다고 할 수 있습니다. 아래 내용을 참고했으니 시간이 가능한 분들은 직접 봐도 좋을 것 같네요^^

WSGI란?

결론부터 말씀드리면 Gunicorn은 WSGI(Web Server Gateway Interface)의 일종이고(uWSGI 역시 파이썬의 대표적인 WSGI지만 Gunicorn의 퍼포먼스가 좀 더 좋고 가볍다는 의견이 대다수), Django로 서버 배포를 하기 위해서 필요로 하는 존재입니다. 그렇다면 WSGI는 무슨 개념일까요?

WSGI는 CGI(Common Gateway Interface)의 일종1으로 CGI는 정적인 웹에서 동적인 웹으로 발전함에 따라 각기 다른 프로그래밍 언어가 사용되어 "공통적인 형태"로 주고받기 위해 만든 규약(specification)입니다. 다시 말해 여러 언어 사용자들의 다양한 요청을 이해할 수 있도록 이를 공통된 규칙으로 변환하는 관문 역할을 하는 것입니다. 따라서 웹서버가 클라이언트 요청을 받으면 CGI에 의해 일관된 형태로 해석되어 이를 WAS에서 처리할 수 있는 것이죠.

CGI 기본 동작 과정
1. input으로 HttpRequest를 받음
2. 요청에 대한 정보를 환경변수 형식으로 만들어 파이썬 스크립트의 stdin 형식의 input으로 받음
3. 스크립트가 print와 같은 stdout 형식으로 응답하면 HTTP 형식으로 변환

 

WSGI는 파이썬 스크립트가 웹서버와 효율적으로 통신하기 위해 만들어진 인터페이스입니다. 따라서 Gunicorn이나 uWSGI 등은 Nginx로 들어오는 HttpRequest를 파이썬이 이해할 수 있게 동시통역하는 존재들입니다. 정리하자면 server/gateway side(Nginx 쪽)와 application/framework side(Django 쪽)를 둘 다 구현하고 있는 하나의 프로그램입니다. 서버에 대해선 어플리케이션 역할을 수행하고, 어플리케이션에 대해선 서버의 역할을 수행하는 셈이죠.

Nginx와 Gunicorn 둘 중 하나만 써도 될까? (velog.io)

(추가)
CGI의 문제점: 요청이 들어올 때마다 파이썬 스크립트를 처음부터 실행하게 되어 서버가 느려지고 비효율적
이를 해결하기 위해 등장한 것이 WSGI로 스크립트 전체를 실행하는 것이 아니라 필요한 로직 하나만 실행한 후 결과 응답

WSGI 동작 과정
클라이언트 요청 -> server side에서 middleware component 호출 -> middleware component가 application side의 application 호출

 

Django에선 개발 목적으로 python manage.py runserver 명령어를 통해 웹 사이트를 띄울 수 있습니다. 하지만 보안이나 성능적으로 검증되지 않았기 때문에 배포 환경에선 Gunicorn을 사용하는 것입니다. 특히 WSGI는 멀티 쓰레드(multi-thread)를 생성할 수 있어 클라이언트 요청이 많아도 효율적으로 처리할 수 있다고 합니다.

참고로 Django에는 이미 WSGI 파일이 내포되어 있습니다. wsgi.py 는 프로젝트 디렉토리에 위치해 있고, django.core.wsgi.py, django.core.handlers.wsgi.py 역시 내부적으로 구현이 되어 있는 상태입니다.

그리고 참조글에서 의미있는 결론을 발견했습니다. Nginx와 Gunicorn 둘 중 하나만 써도 될까? 라는 질문에 대한 것으로 답은 모두 'Yes' 입니다.

- Gunicorn이 WSGI middleware로서 웹서버 역할을 수행하므로 Gunicorn만 써도 되지만, Nginx가 제공하는 추가적 혜택을 받지 못합니다.

- Django는 WSGI interface를 이미 어느 정도 구현했기 때문에 Nginx만 써도 되지만, seesion / cookie / routing / authentication 등의 기능을 수행하는 middleware가 없어서 하드코딩 해야 합니다.
참조
https://this-programmer.tistory.com/345
Nginx와 Gunicorn 둘 중 하나만 써도 될까? (velog.io)

 

  1. 표현하기 쉽게 WSGI가 CGI의 일종이라 했지만 사실 WSGI는 CGI에서 파생된 개념 [본문으로]

 

1. WSGI의 등장 배경

3계층 시스템과 CGI

3계층 서버 시스템에 대해 들어본 적 있을 것이다. DB 서버는 차치하고 웹 서버와 어플리케이션 서버만 살펴보자. 초기에는 웹 서버만 있었다. 하드웨어에 파일과 이미지를 저장해두었다가 클라이언트로부터 요청이 들어오면 요청한 파일을 화면에 띄워주는 형식이었다.

이런 방식은 빠르고 편했지만 Only Static, 정적인 파일밖에 건네줄 수가 없었다. 그런데 클라이언트로부터 오는 요청이 다양하고 복잡해지면서 로직으로 구현해야 하는 동적인 파일에 대한 요청이 생겨나게 되고, 이런 정적인 파일만으로는 한계를 느끼게 되었다.

그래서 등장한 게 웹 어플리케이션 서버이다. 데이터 파일을 저장해두는 대신, 소스 스크립트를 서버에 저장해놓고 요청이 올 때마다 스크립트를 실행시켜 결과를 반환해주는 것이다. 쉽게 말해 소스 스크립트가 있는 웹 앱 자체를 클라이언트의 요청을 받는 웹 서버로 사용하는 것이다.

그런데, 여기서 파이썬 스크립트가 어떻게 HTTP 요청을 받을 것인가, 하는 문제가 발생한다.

HTTP 요청은 기본적으로 GET /home.html HTTP/1.1 과 같은 텍스트이고, 이는 파이썬 앱이 받는 request object의 형식과는 다르기 때문이다.

그래서 클라이언트로부터 오는 HTTP 요청을 파이썬 스크립트가 요구하는 데이터 형식으로 변환하고 응답을 돌려줄 때도 파이썬 데이터를 HTTP 형식으로 바꿔주는 작업이 필요한데, 이 때 파이썬 앱 서버가 동작하는 기본적인 방식이 CGI, Common Gateway Interface이다.

즉, CGI란 파이썬 어플리케이션 서버의 동작 방식에 대한 specification(사양, 매뉴얼)이라고 할 수 있고 그 기본적인 동작 과정은 다음과 같다.

  • 인풋으로 HTTP 요청을 받는다.
  • 요청에 대한 정보를 환경변수의 형식으로 만들어서 파이썬 스크립트의 stdin 형식의 인풋으로 받는다.
  • 스크립트가 print와 같은 stdout 형식으로 응답하면 HTTP 형식으로 변환된다.

WSGI의 등장

그런데 CGI는 한 가지 문제점이 있었는데, 바로 요청이 들어올 때마다 파이썬 스크립트를 처음부터 실행한다는 것이었다. 이렇게 되면 서버가 너무 느리고 효율도 좋지 않았다.

이 때 등장한 게 WSGI(Web Server Gateway Interface)이다. CGI처럼 WSGI 또한 웹 어플리케이션 서버의 동작 방식에 대한 specification인데, 기본적인 아이디어는 다음과 같다.

웹 서버와 파이썬 스크립트를 분리하고 웹 서버가 클라이언트의 요청을 받아서 스크립트에 전달해주면 스크립트는 스크립트 전체를 실행시키는 게 아니라 필요한 로직 하나만 실행한 후 결과를 응답해주는 식으로 동작함으로써 동적인 콘텐츠에 대한 요청에 빠르게 응답할 수 있게 한 것이다.

이러한 WSGI가 표준적인 파이썬 어플리케이션 서버의 동작 방식이 됨에 따라, WSGI 서버가 클라이언트의 요청을 받는 웹 서버의 역할을 하게 되고 WSGI compatible한 파이썬 앱이 WSGI 서버와 합쳐져서 웹 어플리케이션 서버가 되게 된다.

즉, 여기서 WSGI 서버의 역할을 수행하는 애가 Gunicorn이고, Django는 파이썬 앱이라고 할 수 있으며, 얘네가 함께 웹 어플리케이션 서버가 되는 것이다. Nginx는 여기서 Gunicorn+Django의 앞단에 위치하고 있는데, WSGI의 프로세스와는 별 관련 없이 buffering, reverse proxy나 load balance와 같은 별도의 일을 수행하게 되는 것이다.

2. WSGI의 동작 과정

WSGI에 대해 좀 더 감을 잡기 위해 PEP333에 나와있는 WSGI의 동작 과정을 한 번 살펴보려고 한다. 다시 한 번 말하지만 WSGI는 별도의 프레임워크 같은 게 아니라, 동적인 데이터에 대응하기 위해서 웹 서버와 파이썬 웹 앱이 어떻게 서로 동작해야 하는지에 대한 내용을 담고 있는 specification이라고 할 수 있다.

공식 문서에서는 WSGI를 "simple and universal interface between web servers and web applications or frameworks"라고 설명하고 있으며, WSGI의 목적은 "to facilitate easy interconnection of existing servers and applications or frameworks, not to create a new web framework"라고 서술하고 있다.

WSGI는 server/gateway side와 application/framework side를 가지는데, server side에서 들어온 요청이 application side의 callable object를 호출(invoke)하게 된다. 여기서 callable object란 곧 파이썬 스크립트에서 정의한 application을 의미하고, object라고는 했지만 callable한 어떤 형태든지 상관은 없다.

각각의 코드를 한 번 살펴보자.

Application/Framework Side

다음은 함수 형태의 application이다.

def application(environ, start_response):
    """Simplest possible application object"""
    status = '200 OK'
    response_headers = [('Content-type', 'text/plain')]
    start_response(status, response_headers)
    return ['Hello world!\n']
  • application은 environ 객체와 start_response라는 콜백함수를 인자로 받는다.
  • environ은 method나 url 등 HTTP 요청에 대한 정보를 CGI 환경 변수의 형식으로 담고 있는 dictionary 형태의 객체라고 할 수 있다.
  • start_response 콜백 함수는 status와 response_headers라는 두 가지 인자를 받는다.
  • status에는 200 OK 와 같은 HTTP status 코드가 들어가고, response_headers에는 HTTP 헤더가 들어간다.

Server/Gateway Side

server side에서는 클라이언트의 요청이 올 때마다 application을 호출한다.

import os, sys

def run_with_cgi(application): # application을 인자로 받음
    # environ 객체
    environ = dict(os.environ.items())
    environ['wsgi.input']        = sys.stdin  # stdin의 형태로 input을 받음
    environ['wsgi.errors']       = sys.stderr
    environ['wsgi.version']      = (1, 0)
    environ['wsgi.multithread']  = False
    environ['wsgi.multiprocess'] = True
    environ['wsgi.run_once']     = True

    if environ.get('HTTPS', 'off') in ('on', '1'):
        environ['wsgi.url_scheme'] = 'https'
    else:
        environ['wsgi.url_scheme'] = 'http'

    headers_set = []
    headers_sent = []

    def write(data):
        if not headers_set:
             raise AssertionError("write() before start_response()")

        elif not headers_sent:
             # Before the first output, send the stored headers
             status, response_headers = headers_sent[:] = headers_set
             sys.stdout.write('Status: %s\r\n' % status)
             for header in response_headers:
                 sys.stdout.write('%s: %s\r\n' % header)
             sys.stdout.write('\r\n')

        sys.stdout.write(data)
        sys.stdout.flush()

		# status와 response_headers를 받는 start_response 콜백함수 
    def start_response(status, response_headers, exc_info=None):
        if exc_info:
            try:
                if headers_sent:
                    # Re-raise original exception if headers sent
                    raise exc_info[0], exc_info[1], exc_info[2]
            finally:
                exc_info = None     # avoid dangling circular ref
        elif headers_set:
            raise AssertionError("Headers already set!")

        headers_set[:] = [status, response_headers]
        return write

    result = application(environ, start_response)
    try:
        for data in result:
            if data:    # don't send headers until body appears
                write(data)
        if not headers_sent:
            write('')   # send headers now if body was empty
    finally:
        if hasattr(result, 'close'):
            result.close() 

참고 WSGI Middleware

  • WSGI server/gateway side와 application/framework side를 둘 다 구현하고 있는 하나의 프로그램
  • 서버에 대해서는 어플리케이션의 역할을 수행하고, 어플리케이션에 대해서는 서버의 역할을 수행.
  • 동작 과정 : 클라이언트 요청 → server side에서 middleware component를 호출 → middleware component가 application side의 application을 호출
  • Gunicorn, uWSGI

3. Framework의 WSGI application

이번에는 대표적인 파이썬 프레임워크인 Django와 Flask에 내장된 WSGI application을 한 번 살펴보려고 한다.

Django

📁 django project를 생성하면 자동으로 만들어지는 wsgi.py 파일

"""
WSGI config for movie_project project.

It exposes the WSGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
"""

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'movie_project.settings')

application = get_wsgi_application()

📁 django.core.wsgi.py

import django
from django.core.handlers.wsgi import WSGIHandler

def get_wsgi_application():
    """
    The public interface to Django's WSGI support. Return a WSGI callable.
    Avoids making django.core.handlers.WSGIHandler a public API, in case the
    internal WSGI implementation changes or moves in the future.
    """
    django.setup(set_prefix=False)
    return WSGIHandler()

📁 django.core.handlers.wsgi.py

class WSGIHandler(base.BaseHandler):
    request_class = WSGIRequest

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.load_middleware()

    def __call__(self, environ, start_response):
        set_script_prefix(get_script_name(environ))
        signals.request_started.send(sender=self.__class__, environ=environ)
        request = self.request_class(environ)
        response = self.get_response(request)

        response._handler_class = self.__class__

        status = '%d %s' % (response.status_code, response.reason_phrase)
        response_headers = [
            *response.items(),
            *(('Set-Cookie', c.output(header='')) for c in response.cookies.values()),
        ]
        start_response(status, response_headers)
        if getattr(response, 'file_to_stream', None) is not None and environ.get('wsgi.file_wrapper'):
            # If `wsgi.file_wrapper` is used the WSGI server does not call
            # .close on the response, but on the file wrapper. Patch it to use
            # response.close instead which takes care of closing all files.
            response.file_to_stream.close = response.close
            response = environ['wsgi.file_wrapper'](response.file_to_stream, response.block_size)
        return response

Flask

📁 flask app.py

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

if __name__ == "__main__":
    app.run()

📁 flask.src.flask.app.py

class Flask(Scaffold):
    """The flask object implements a WSGI application and acts as the central
    object.  It is passed the name of the module or package of the
    application.  Once it is created it will act as a central registry for
    the view functions, the URL rules, template configuration and much more.
    The name of the package is used to resolve resources from inside the
    package or the folder the module is contained in depending on if the
    package parameter resolves to an actual python package (a folder with
    an :file:`__init__.py` file inside) or a standard module (just a ``.py`` file).
    """

	def wsgi_app(self, environ, start_response):
	        """The actual WSGI application. This is not implemented in
	        :meth:`__call__` so that middlewares can be applied without
	        losing a reference to the app object. Instead of doing this::
	            app = MyMiddleware(app)
	        It's a better idea to do this instead::
	            app.wsgi_app = MyMiddleware(app.wsgi_app)
	        Then you still have the original application object around and
	        can continue to call methods on it.
	        
	        :param environ: A WSGI environment. 
	        :param start_response: A callable accepting a status code,
	            a list of headers, and an optional exception context to
	            start the response.
	        """
	        ctx = self.request_context(environ)
	        error = None
	        try:
	            try:
	                ctx.push()
	                response = self.full_dispatch_request()
	            except Exception as e:
	                error = e
	                response = self.handle_exception(e)
	            except:  # noqa: B001
	                error = sys.exc_info()[1]
	                raise
	            return response(environ, start_response)
	        finally:
	            if self.should_ignore_error(error):
	                error = None
	            ctx.auto_pop(error)
	
        def __call__(self, environ, start_response):
            """The WSGI server calls the Flask application object as the
            WSGI application. This calls :meth:`wsgi_app`, which can be
            wrapped to apply middleware.
            """
            return self.wsgi_app(environ, start_response)

4. 정리

 Gunicorn은 왜 필요한가? 웹 앱에 HTTP 요청을 전달하고 응답을 되돌려주는 일을 할 WSGI server의 역할을 하기 위해서 → WSGI middleware

 Nginx는 왜 필요한가? reverse proxy server, load balancer 등의 역할을 수행하기 위해서

 Django/Flask는 왜 필요한가? WSGI compatible server를 알아서 제공해주기 때문이다. raw WSGI application을 만들 수는 있지만 기능을 일일이 다 구현하기 번거롭고 너무 많은 코너 케이스가 존재하기 때문에 권장하지는 않는다. 그래서 이미 session이나 cookie와 같은 많은 부분이 구현되어 있는 프레임워크를 쓰는 것이다.

 그럼 그냥 프레임워크 서버를 웹 서버로 사용하면 되지 않을까? 그래도 안될 건 없다. 하지만 보통은 그러지 않는다. 프레임워크가 제공하는 development server는 실제 트래픽에 대응할 수 없고 여러 부분에서 빈약하기 때문이다. Flask 앱을 만들 때 flask run을 통해 서버를 실행시키면 터미널에 production server용으로는 쓰지 말라는 메세지가 나오는 이유도 이 때문이다.

그래서 결론은?

 Gunicorn만 써도 돼? YES.

Gunicorn이 WSGI middleware로서 웹 서버의 역할을 수행하기 때문에 Gunicorn만 써도 된다. 다만, Nginx가 제공하는 추가적인 혜택을 받지 못할 뿐이다.

 Nginx만 써도 돼? YES.

Flask나 Django 같은 프레임워크는 WSGI interface를 이미 어느 정도 구현해놓았기 때문에 프레임워크를 사용한다면 Nginx만 써도 된다. 다만 session, cookie, routing, authentication 등의 기능을 수행해주는 middleware의 역할을 하는 애가 없기 때문에 이 부분은 자기가 하드 코딩해야 한다. 결국, Gunicorn/uWSGI를 사용하는 것도 편리한 기능들을 제공해주는 라이브러리를 가져다 쓰는 것과 똑같다고 할 수 있고, 반드시 써야만 하는 건 아니다.


참고 자료

더 알아보고 싶은 내용