Search

FastAPI의 Websocket과 Uvicorn, NginX(리버스 프록시까지)

생성일
2024/04/03 13:06
수정일
2024/04/03 14:06
태그
리눅스
IT
Python
FastAPI
Nginx
2 more properties
Websocket은 양방향 통신이 가능하기 때문에 넓은 활용 범위를 가질 수 있다. 특히 FastAPI는 로그인을 통해 받은 세션 토큰으로 인증 부터 기능 구현까지를 쉽게 할 수 있기 때문에 넓게 활용해볼 수 있다. 그런데 대부분 Local 환경에서의 예제만 있고, 실제 웹서비스로 올리는 과정에서의 이슈들이 담긴 글은 잘 보이지 않는다.
그래서 이번에 FastAPI로 웹소켓(websocket) 테스트를 해보며 겪은 이슈를 정리해본다.
@app.websocket("/ws/{client_id}") async def websocket_endpoint(websocket: WebSocket, client_id: int): await manager.connect(websocket) try: while True: data = await websocket.receive_text() await manager.send_personal_message(f"You wrote: {data}", websocket) await manager.broadcast(f"Client #{client_id} says: {data}") except WebSocketDisconnect: manager.disconnect(websocket) await manager.broadcast(f"Client #{client_id} left the chat")
Python
복사
웹소켓을 통해 채팅을 구현하는 함수이다. 다중의 접속자들끼리의 ‘채팅’을 구현할 수 있게 해준다. (전체 코드는 FastAPI Websockets Tutorial을 참고하자).
FastAPI의 실행은 Uvicorn을 통해 하였다.
uvicorn main:app --reload INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
Python
복사
튜토리얼 예제에서는 HTML을 통해 ‘웹 브라우저’에서 채팅을 구현하게 해준다. 그런데, 웹소켓을 응용하면 브라우저간 통신 뿐만 아니라, 어플리케이션이나 프로세스와의 통신도 가능해진다.
# websocket_test_script.py import asyncio host = 'localhost:8000' websocket_url = f"ws://{host}/ws/111111" async def connect(): async with websockets.connect(websocket_url) as websocket: await websocket.send("Hello, server!") response = await websocket.recv() print(f"Received message from server: {response}") asyncio.run(connect())
Python
복사
예제를 통해 웹브라우저 채팅창을 열어놓고 위의 스크립트를 로컬에서 실행하면, 브라우저에 ‘Hello, server!’가 찍힌다.
문제는, 이 예제를 그대로 ‘웹서버’에 올리면서부터였다.
uvicorn main:app --host 0.0.0.0 --port 8000
Bash
복사
(0.0.0.0은 ‘전체 접근’을 허용 위한 설정이다. uvicorn은 ‘내부 접근 허용’과 ‘전체 접근 허용’ 두 가지 설정만 가능하다)
웹서버와 http로 통신을 할 때는 문제가 되지 않는다. 문제는 ssl을 적용해 통신을 하는 경우인데, 일단 uvicorn 자체에 인증서 설정을 걸어 ssl로 서버를 구동해보았다.
uvicorn main:app --ssl-keyfile=/path/to/localhost.key --ssl-certfile=/path/to/localhost.crt --host 0.0.0.0 --port 8000
Bash
복사
이렇게 할 경우 api는 ssl이 적용이 된다. 하지만, websocket의 경우는 websocket 핸드쉐이크 중 인증 오류가 발생한다.
... File "/usr/lib/python3.10/ssl.py", line 975, in do_handshake self._sslobj.do_handshake() ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1007)
Bash
복사
이를 해결하는 방법은, Nginx를 통해 통신을 하는 것이다.
uvicorn에 ssl 키 적용을 풀고 앞에서 처럼 http로 접속을 하도록 한다. 그리고 Nginx에서 요청시 해당 ‘포트’로 pass를 해주는 것이다. ssl 인증은 Nginx가 담당한다.
server { listen 443 ssl; ssl_certificate /etc/nginx/key/cert.pem; ssl_certificate_key /etc/nginx/key/privatekey.pem; location / { proxy_pass http://localhost:8000; } }
Bash
복사
그런데 문제는 여기서부터 발생한다. Nginx를 거치자, API는 잘 된다. 그런데 이상하게도 websocket으로 요청을 하면 자꾸만 404 Not Found 애러가 발생하는 것이다.
INFO: 10.0.0.2:0 - "POST /login HTTP/1.0" 200 OK INFO: 10.0.0.2:0 - "GET /ws/111111 HTTP/1.0" 404 Not Found
Bash
복사
/login은 FastAPI를 통한 요청이다. 이는 Nginx를 통한 수행에 문제가 없다. 하지만, 웹소켓을 위해 /ws/111111 을 요청할 때는 ‘경로’ 자체가 없다고 나온다.
이것이 도대체 머선 일이란 말인가….!
문제는, Nginx였다.
location / { proxy_pass http://localhost:8000; } # websocket 프로토콜을 위한 설정 location /ws/ { proxy_pass http://localhost:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }
Bash
복사
websocket은 Http의 업그레이드 헤더를 이용해 기존의 프로토콜에서 websocket 프로토콜로 변경을 한다. 이를 위한 설정이 uvicorn만으로 띄웠을때는 필요가 없으나, Nginx를 이용할 때는 별도의 업그레이드 헤더 설정을 해주어야만 하는 것이다.
여기에서 문제가 하나가 더 있다. 만약 중간에 ‘프록시’ 하나가 더 끼어있다면, 어떻게 해야 할까? 헤더 적용을 앞단의 프록시에 해야 할까? 아님 웹서버에 해야 할까?
정답은 ‘둘 다 한다’ 이다. websocket 경로(여기서는 /ws 이다)로 오는 요청의 해더 세팅을 양쪽에 모두 해 주어야 websocket이 정상 연결이 된다.
아래는 정상 연결이 되었을 때 uvicorn의 ‘상세 로그’이다.
TRACE: 10.0.0.1:43956 - HTTP connection made TRACE: 10.0.0.1:43956 - Upgrading to WebSocket DEBUG: = connection is CONNECTING TRACE: 10.0.0.1:43956 - WebSocket conn DEBUG: < upgrade: websocketection made DEBUG: < GET /ws/111111 HTTP/1.1 DEBUG: < host: 10.0.0.2:8000 DEBUG: < connection: Upgrade DEBUG: < sec-websocket-key: **************== DEBUG: < sec-websocket-version: 13 DEBUG: < sec-websocket-extensions: permessage-deflate; client_max_window_bits DEBUG: < user-agent: Python/3.10 websockets/12.0 TRACE: 10.0.0.1:43956 - ASGI [4] Started scope={'type': 'websocket', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.1', 'scheme': 'ws', 'server': ('10.0.0.2', 8000), 'client': ('10.0.0.1', 43956), 'root_path': '', 'path': '/ws/111111', 'raw_path': b'/ws/111111', 'query_string': b'', 'headers': '<...>', 'subprotocols': []} TRACE: 10.0.0.1:43956 - ASGI [4] Receive {'type': 'websocket.connect'} TRACE: 10.0.0.1:43956 - ASGI [4] Send {'type': 'websocket.accept', 'subprotocol': None, 'headers': '<...>'} INFO: ('10.0.0.1', 43956) - "WebSocket /ws/111111" [accepted] DEBUG: > HTTP/1.1 101 Switching Protocols DEBUG: > Upgrade: websocket DEBUG: > Connection: Upgrade DEBUG: > Sec-WebSocket-Accept: **************= DEBUG: > Sec-WebSocket-Extensions: permessage-deflate DEBUG: > date: Wed, 03 Apr 2024 09:28:02 GMT DEBUG: > server: uvicorn INFO: connection open DEBUG: = connection is OPEN DEBUG: < TEXT 'Hello, server!' [14 bytes] TRACE: 10.0.0.1:43956 - ASGI [4] Receive {'type': 'websocket.receive', 'text': '<14 chars>'} TRACE: 10.0.0.1:43956 - ASGI [4] Send {'type': 'websocket.send', 'text': '<25 chars>'} DEBUG: > TEXT 'You wrote: Hello, server!' [25 bytes] TRACE: 10.0.0.1:43956 - ASGI [4] Send {'type': 'websocket.send', 'text': '<35 chars>'} DEBUG: > TEXT 'Client #111111 says: Hello, server!' [35 bytes] DEBUG: < CLOSE 1000 (OK) [2 bytes] DEBUG: = connection is CLOSING DEBUG: > CLOSE 1000 (OK) [2 bytes] DEBUG: x half-closing TCP connection TRACE: 10.0.0.1:43956 - WebSocket connection lost DEBUG: = connection is CLOSED TRACE: 10.0.0.1:43956 - ASGI [4] Receive {'type': 'websocket.disconnect', 'code': 1000} TRACE: 10.0.0.1:43956 - ASGI [4] Completed INFO: connection closed
Bash
복사
uvicorn에서 의 http에서 websocket으로 프로토콜을 업그레이드 하고, 연결을 열고 닫는 과정이 잘 담겨 있다.
앞으로 FastAPI와 websocket을 통해 활용해볼 범위들이 기대가 된다.

참고

5274
issues