앞서 다루었던 내용에서는 올바르게 구현하지 않았을 때 발생할 수 있는 문제점을 시작으로, WebSocket과 SocketIO에 대한 이해와 Flask-SocketIO를 초기화 시키는 방법을 알아보았다. 이번 포스트에서는 , Flask와 Nginx, Gunicorn을 연동하는 방법과 함께 Flask 내에서 소켓 통신을 하기 위한 간단한 예제 소스와 Client 구성 방법, Flask 예제 코드를 알아보도록 하겠다.
3. Nginx + Gunicorn + Flask-SocketIO
3.1 Nginx config
Nginx config of Flask-SocketIO Docs
Flask SocketIO를 위해서는 Nginx 세팅은 아래와 같이 Docs에서 제공되고 있다.
server {
listen 80;
server_name _;
location / {
include proxy_params;
proxy_pass http://127.0.0.1:5000;
}
location /static {
alias <path-to-your-application>/static;
expires 30d;
}
location /socket.io {
include proxy_params;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://127.0.0.1:5000/socket.io;
}
}
Bash
복사
설정 값에서는 proxy_pass를 이용하여 내부에서 구동되는 flask와 연결하고, location /socket.io 로 socket.io에 들어오는 패킷의 헤더를 업그레이드 및 포워딩 시켜주는 내용이 작성되어 있다. 또한 위의 설정은 http로만 작성되어 있기 때문에 https를 설정하기 위해서는 추가적인 설정이 필요하다.
Nginx config + SSL
# Nginx configuration
# /etc/nginx/sites-available/retro.conf
# ln -s /etc/nginx/sites-available/retro.conf /etc/nginx/sites-enabled/retro
# HTTP - Listen 80
server {
listen 80;
# listen [::]:80;
server_name retro.dev.kkamikoon.com;
access_log /var/www/retro/logs/http/access.log;
error_log /var/www/retro/logs/http/error.log;
# include /etc/nginx/snippets/letsencrypt.conf;
location ~ /\.well-known/acme-challenge/ {
allow all;
root /var/www/letsencrypt;
}
location / {
return 301 https://$server_name$request_uri;
# expires epoch;
}
}
# HTTPS - Listen 443
server {
listen 443 ssl;
# listen [::]:443;
server_name retro.dev.kkamikoon.com;
access_log /var/www/retro/logs/https/access.log;
error_log /var/www/retro/logs/https/error.log;
# letsencrypt should add new group.
# And also chgrp /etc/letsencrypt/live, /etc/letsencrypt/archive
# /etc/nginx.conf ==> user [added user name]
ssl_certificate /etc/letsencrypt/live/$server_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$server_name/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3 SSLv3;
ssl_ciphers ALL:!ADH:!EXPORT56:!RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv3:+EXP;
ssl_prefer_server_ciphers on;
location / {
include proxy_params;
# proxy_pass http://unix:/var/www/retro/retro.sock;
proxy_pass http://127.0.0.1:8080;
}
location /socket.io {
include proxy_params;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://127.0.0.1:8080/socket.io;
}
}
Bash
복사
여기서 location /socket.io 부분에서 proxy_set_header의 Upgrade, Connection이 핵심적으로 필요한 부분이다. 소켓 통신을 위해서 http, https가 아닌 ws(혹은 wss)로 통신이 이루어져야 한다. 그렇지 않으면 소켓 통신이 매우 불안정하며, 접속한 Client마다 계속 새로운 연결을 요청하는 상태가 발생하기 때문에 반드시 101 Upgrade 패킷으로 웹 소켓 연결을 수행해야 한다.
3.2 Gunicorn
Gunicorn 위치 확인
service file 내의 내용을 작성하기 위해서는 먼저 gunicorn 설치 위치를 찾아야 한다.
$ which -a gunicorn
/usr/local/bin/gunicorn
/usr/bin/gunicorn
/bin/gunicorn
Bash
복사
서비스 파일 생성
/etc/systemd/system/ 디렉토리안에 /etc/systemd/system/[service_name].service 만들기
sudo vim /etc/systemd/system/myproject.service
Bash
복사
확인했던 Gunicorn 위치를 이용하여 ExecStart 명령어를 사용할 때 실행 위치를 작성해주도록 한다.
# /etc/systemd/system/~~~.service
[Unit]
Description=Gunicorn instance to serve myflask
After=network.target
[Service]
# You can change your own account
User=flaskuser
# You can change your own group
Group=flaskcert
# /home/userme/myproject
WorkingDirectory=/var/www/retro
# "PATH=/home/userme/myproject/myproject-env/bin"
Environment="PATH=/usr/bin"
# You must chown working directory
ExecStart=/usr/bin/gunicorn --worker-class eventlet -w 1 -b 127.0.0.1:8080 wsgi:app
[Install]
WantedBy=multi-user.target
Bash
복사
오류 발생 및 해결 방법
gunicorn —worker-class eventlet 을 사용하기 위해서는 적절한 eventlet 버전을 설치해줘야 한다. 만약 eventlet의 버전이 적절하지 않다면 아래와 같은 에러가 발생할 수 있다.
$ gunicorn --worker-class eventlet -w 1 -b 127.0.0.1:8080 wsgi:app
...
ImportError: cannot import name 'ALREADY_HANDLED' from 'eventlet.wsgi'
...
Bash
복사
이러한 에러가 발생했을 경우 해결 방법도 함께 첨부하도록 하겠다.
$ pip3 install eventlet==0.30.2
Bash
복사
3.3 Flask-SocketIO
sockets/__init__.py 작성
# file : app/sockets/__init__.py
from flask_socketio import SocketIO
socketio = SocketIO()
Python
복사
__init__.py 작성
# file : app/__init__.py
from flask import Flask
from app.sockets import socketio # app/sockets/__init__.py에 선언된 socketio 변수
def create_app():
app = Flask(__name__)
# Your logics...
# Set Config
app.config.from_object(config)
with app.app_context():
# Socket.IO
socketio.init_app( app,
async_mode="eventlet",
cors_allowed_origins="*",
logger=True,
engineio_logger=True)
# Your logics...
return app
Python
복사
4. Client 구성(Bootstrap + Javascript)
클라이언트 구성은 Flask를 Full Stack으로 활용한다는 점에서 Bootstrap + socket.io 라이브러리와 같은 방법을 소개하려고 한다.
4.1 Socket.io javascript예제 코드
// important!! - socket.io version
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js" integrity="sha512-q/dWJ3kcmjBLU4Qc47E4A9kTB4m3wuTY7vkFJDTZKjTs8jhyGQnaUrxa0Ytd0ssMZhbNua9hE+E7Qv1j+DyZwA==" crossorigin="anonymous"></script>
var namespace = "/socket/main";
var socket_main = io.connect(namespace, { transports : ['websocket',],
upgrade: true, // 삭제해도 무관
secure: true }); // 삭제해도 무관
socket_main.on('announce', function(msg) {
console.log(msg);
}); // socket_main.on end
JavaScript
복사
위와 같이 socket.io 라이브러리를 cdn에서 가져와 코드를 작성할 수 있다. 다만, Flask-SocketIO에서 특정 버전 이하의 socket.io 라이브러리를 사용할 경우 버전이 일치하지 않아 오류가 발생할 수 있다는 점을 유의해야 한다. 또한 여기서 upgrade, secure 부분은 http, https 등의 환경에 따라 다르기 때문에 삭제해주어도 무관하다.(Default로 설정되는 내용 때문에)
예제 코드에서 socket_main은 임의의 변수이며, on('~~~', function(msg) { } ) 함수내의 announce 또한 임의의 문자열 값이다. 이는 emit() 함수를 이용하여 특정 값을 전달할 때 지표로 사용된다.
5. Flask 예제 소스
5.1 Flask 내에서 socket 통신
Flask 내에서 소켓 통신을 구현하는 것은 로직을 구현하는 방법에 따라 각양각색으로 달라질 수 있다. 여기서는 간단하게 임의의 예제 소스를 소개해보도록 하겠다.
app/sockets/__init__.py
from flask_socketio import SocketIO
socketio = SocketIO()
Python
복사
test.py
# Your logics...
from app.sockets import socketio
@app.route("/test", methods=['GET'])
def test():
socketio.emit( "announce",
{"test" : "test success"},
namespace='/socket/main',
broadcast=True)
return "Some text"
Python
복사
여기서 test.py 소스 내에서 socketio.emit() 함수를 통해 적절한 arguments 들을 설정하고 /test path로 들어가게 된다면 적절한 피드백이 오게 된다.
마치며...
연구 과정에서 많은 우여곡절이 있었다. 참고할 자료도 매우 부족하였고, 특히 Flask-SocketIO를 구성하는 내용에서 socketio.init_app() 함수를 이용하여 구성했던 내용은 찾을 수 없었다. 때문에 대부분 실험을 통해 이루어진 결과를 바탕으로 포스트를 작성하게 되었다. 특히 Flask-SocketIO를 이용하여 테스트를 해본 경험을 작성한 대부분의 블로그를 살펴봐도 적절한 피드백을 얻을 수 있는 글은 없었다.
죽으라는 법은
성공하기 며칠 전까지도, 101 Upgrade 패킷 요청에 실패하였다. 이때 Flask + Flask-SocketIO + Gunicorn + Nginx와 SSL 적용까지 완료된 상태였다. 그러나 웹에서 웹 소켓 요청시, 계속해서 400 Bad Request가 응답으로 오는 상태였다. 이때의 서버 구동 환경이다.
Gunicorn 명령
$ gunicorn -w 3 -b 127.0.0.1:8080 wsgi:app
Bash
복사
이 때 gevent, eventlet, geventwebsocket.gunicorn.workers.GeventWebSocketWorker와 같은 명령어를 사용하지 않았다. 이때는 아직까지 gunicorn의 문제가 아니라 nginx의 문제라고 생각하고 있었다.
Python 구동
# file : app/__init__.py
from flask import Flask
from app.sockets import socketio # app/sockets/__init__.py에 선언된 socketio 변수
def create_app():
app = Flask(__name__)
# Your logics...
# Set Config
app.config.from_object(config)
with app.app_context():
# Socket.IO
socketio.init_app( app,
async_mode="gevent",
cors_allowed_origins="*")
# Your logics...
return app
Python
복사
Nginx 설정
Nginx의 설정은 앞서 소개한 SSL을 적용한 설정 파일과 동일한 상태다.
계속 실험을 진행하는 도중, 할 수 있는 모든 테스트를 수행해도 400 Bad Request가 동일했기 때문에 Gunicorn에서 문제점을 찾아보고자 했다. 이때 가장 먼저 테스트해봤던 명령어는 다음과 같다.
$ pip3 install gevent
$ gunicorn -w 3 -b 127.0.0.1:8080 wsgi:app
Bash
복사
이때 가장 중요한 변화인 101 Upgrade 패킷이 정상적으로 전송되었고, 응답 또한 정상적으로 돌아왔다. 아래의 그림처러 101 Switching Protocols가 정상적으로 반환된 것을 볼 수 있었다. 이때 진행이 되지 않던 연구가 겨우 전진할 수 있었다고 안심하게 되었다.
다만 이렇게 통신이 정상적으로 이루어진 것은 아니었다. 이유는 아래와 같은 에러가 계속 발생하며, 소켓이 불안정한 상태였고 더불어 서비스가 불가능한 수준이었기 때문이다. 다만 이때 Nginx가 아닌 Gunicorn의 설정을 변경하면 해답이 나올 것 같은 직감이 들어 여러가지 실험을 진행해보았다.
[2021-08-01 18:52:03 +0000] [62584] [ERROR] Socket error processing request.
Traceback (most recent call last):
File "/usr/local/lib/python3.8/dist-packages/gunicorn/workers/sync.py", line 135, in handle
self.handle_request(listener, req, client, addr)
File "/usr/local/lib/python3.8/dist-packages/gunicorn/workers/sync.py", line 191, in handle_request
six.reraise(*sys.exc_info())
File "/usr/local/lib/python3.8/dist-packages/gunicorn/six.py", line 625, in reraise
raise value
File "/usr/local/lib/python3.8/dist-packages/gunicorn/workers/sync.py", line 183, in handle_request
resp.close()
File "/usr/local/lib/python3.8/dist-packages/gunicorn/http/wsgi.py", line 409, in close
self.send_headers()
File "/usr/local/lib/python3.8/dist-packages/gunicorn/http/wsgi.py", line 329, in send_headers
util.write(self.sock, util.to_bytestring(header_str, "ascii"))
File "/usr/local/lib/python3.8/dist-packages/gunicorn/util.py", line 304, in write
sock.sendall(data)
OSError: [Errno 9] Bad file descriptor
Bash
복사
Gunicorn 명령어 실험
먼저 Gunicorn 명령을 실험하기 위해 각종 실험을 진행하였다.
# Failed - 502 Bad Gateway
gunicorn -k gevent -w 1 -b 127.0.0.1:8080 wsgi:app
# Failed - 502 Bad Gateway
$ gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 wsgi:app
# Failed - ImportError: cannot import name 'ALREADY_HANDLED' from 'eventlet.wsgi'
$ gunicorn --worker-class eventlet -w 1 -b 127.0.0.1:8080 wsgi:app
# Success - pip3 install eventlet==0.30.2
$ gunicorn --worker-class eventlet -w 1 -b 127.0.0.1:8080 wsgi:app
Bash
복사
위와 같은 과정에서 eventlet 에서 만족할 결과를 얻을 수 있었다. 다만 eventlet 설치 시에는 버전을 down grade 해줘야 하는 유의사항이 있었다.
후기
위와 같은 실험과정을 통해 연구하게된 건, 참고할 자료가 부족한 것과 더불어 아직 웹 소켓과 Nginx, Gunicorn에 대한 이해가 부족했기 때문이었다. 그러나 생소한 환경 구성 및 라이브러리를 활용함에 있어서 1주일 정도의 연구 기간은 생각보다 짧은 비용으로 만족할 결과를 얻었다는 점은 내심 뿌듯하다. 부족한 연구 시간 때문에 쪽잠을 자며 진행했지만... 막히다가도 가끔 성공적인 결과가 나타나면 잠이 날아가 밤을 새 연구하기도 하였다. 이번 연구를 통해 Python Flask 프레임워크를 활용하여 보다 다양한 기능을 제공할 수 있는 방법을 알게 되었다는 점이 가장 만족스럽다.