회사에서 개발중인 AI 서버의 프로토타입을 급하게 확인하고 싶다고 해서, FastAPI + React.js를 이용한 서버 개발이 다 되지 않았기에 Streamlit으로 서버를 올린 뒤 사내 솔루션에 업로드하고자 했다.

그런데, 문제는 다음과 같았다. 사내 솔루션의 iframe에 스트림릿 서버를 제공하려고, 솔루션은 HTTPS를 사용했고 스트림릿은 기본적으로 HTTP환경에서 제공되어, HTTPS 서버로 TLS를 얹어 제공해야 했었다.

그래서 부랴부랴 사내의 리버스프록시망에 서버를 얹었으나, 웹소켓쪽에서 연결이 안된다는 오류 메세지가 발생했다. Streamlit은 기본적으로 WebSocket을 이용해 동작하므로, 무조건 이 문제를 해결하고 가야 했다. 웹소켓은 한번도 공부해본적이 없었기에, 최대한 빠르게 공부하고 문제를 해결하러 갔다. 웹소켓의 기본 구조부터 확인해보자.

웹소켓의 동작 원리

웹소켓(wss:// 프로토콜)은 클라이언트와 서버 간의 양방향 통신을 지원하는 프로토콜이며, 기본 동작 과정은 다음과 같다.

핸드셰이크(Handshake)

  • 웹소켓은 기존의 HTTP프로토콜을 활용하여 핸드셰이크를 수행한 후, 지속적인 연결을 유지하는 방식이다.
  • 클라이언트가 HTTP Upgrade 요청을 보내서 웹소켓으로의 전환을 요청한다.
  • 서버가 이를 승인하면 연결이 확립되고, 이후에는 계속해서 양방향 데이터 전송이 가능하다.

데이터 전송

  • HTTP처럼 요청/응답 모델이 아니라, 서버가 클라이언트에 데이터를 자유롭게 푸시할 수 있다.
  • 클라이언트와 서버는 한 번 연결되면 계속 유지되고, 지속적인 메시지 교환이 가능하다.

연결 종료

  • 클라이언트와 서버 중 한쪽이 CLOSE 프레임을 전송하면 연결이 종료된다.

웹소켓 통신

웹소켓(WebSocket) 통신을 시작하기 위해서는 기존의 HTTP 프로토콜을 이용하여 연결을 업그레이드(Upgrade)해야 한다. 이는 웹소켓이 기존 HTTP 프로토콜을 활용해서 연결을 설정하지만, 이후에는 HTTP의 요청 - 응답이 방식이 아니라, 지속적인 양방향 통신이 가능한 프로토콜로 변경되기 때문이다.

핸드셰이크

웹소켓 연결을 시작하기 위해 클라이언트는 기존 HTTP 프로토콜을 사용하여 서버에 Upgrade 요청을 보낸다.
이를 위해서는 헤더에 Upgrade와 Connection 필드를 포함해 웹소켓 연결을 요청한다. 예로는 다음과 같다.

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ~
Sec-WebSocket-Version: 13

이 헤더 필드의 의미는 다음과 같다.

  • Upgrade: Websocket -> 웹소켓으로 프로토콜을 업그레이드하고 싶다는 의미
  • Connection: Upgrade -> 클라이언트가 프로토콜 업그레이드를 요청함.
  • Sec-WebSocket-Key: 보안용 난수 값(서버에서 응답을 검증하는 데 사용)
  • Sec-WebSocket-Version: 웹소켓 프로토콜 버전

서버의 Upgrade 응답(HTTP 응답)

서버는 클라이언트의 요청을 확인한 후, 적절한 응답을 반환해야 한다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ~
  • 101 Switching Protocols : HTTP 프로토콜에서 웹소켓으로 변경됨을 의미한다.
  • Upgrade , Connection : Upgrade -> 클라이언트의 요청을 수락했음을 나타낸다.
  • Sec-WebSocket-Accept : 클라이언트가 보낸 Secure Websocket key값을 서버가 변환해서 반환한다.

Upgrade 과정이 필요한 이유

웹소켓은 HTTP 80/443 포트에서 동작하기 때문에, 방화벽 및 프록시 서버에서 차단 없이 사용 가능하다. 많은 네트워크에서 방화벽은 비표준 프로토콜을 차단하기 때문에 첫 메시지는 HTTP 요청처럼 보이도록 만든 후, 연결을 웹소켓으로 전환하는 방식을 사용해야 한다. 그러나 웹소켓은 일반적인 HTTP 요청-응답 모델이 아닌, 지속적인 양방향 통신을 지원해야 하기 때문에 기존 HTTP 프로토콜과는 다른 방식으로 동작해야 한다. 그래서 기존 HTTP 프로토콜을 유지한 채 웹소켓을 사용하려면, Upgrade 요청을 통해 프로토콜을 변경하는 과정이 필요하다.

Streamlit

Streamlit은 기본적으로 HTTPS를 지원하지 않는다. 그러나 두 가지 방법으로 Streamlit을 HTTPS 환경에서 구동할 수 있다.

  • config.toml 파일에 인증서 추가
  • reverse proxy로 환경관리

config.toml

./streamlit/config.toml에서 SSL 인증서의 경로를 추가하여 해당 인스턴스를 HTTPS 환경에서 구동할 수 있다.

[server]
sslCertFile='my/cert/path'
sslKeyFile='my/key/path'

Streamlit + Reverse Proxy

Streamlit 자체는 웹소켓을 활용하는 구조를 가지고 있다.
특히, 프론트엔드(UI)에서 백엔드(Python)와의 통신을 위해 웹소켓(wss://)를 이용해서 데이터를 주고받는다.

클라이언트가 서버에 접속할 때의 흐름

  1. HTTPS 연결 시작
  • 사용자가 브라우저에서 https://my_ai_server로 접속한다.
  • 브라우저는 먼저 HTTPS 연결을 수립하기 위해 Reverse Proxy와 통신한다.
  1. Reverse Proxy가 Streamlit 서버로 요청 전달
  • Reverse Proxy는 HTTPS 요청을 내부의 Streamlit 서버(http://localhost:8501)로 요청 전달한다.
  1. Streamlit은 웹소켓(wss://) 연결을 시도
  • 브라우저에서 Streamlit의 UI가 로딩되면서, wss://my_ai_server/_stcore/stream URL로 웹소켓 연결을 시도함
  • 이 때, 웹소켓 연결이 Proxy 설정에 의해 차단되거나, SSL/TLS 문제로 인해 정상적으로 작동하지 않을 수 있음
  1. 웹소켓 통신 시작
  • 성공하면 웹소켓을 통해 Streamlit과 실시간으로 데이터를 주고받는다.
  • 실패하면 콘솔에서 WebSocket handshake failed등의 에러가 발생한다.

문제 발생

웹소켓 + HTTPS 조합에서 발생하는 대부분의 문제는, wss://my_ai_server에서 거부반응을 일으키는 문제이다.

WebsocketConnection to `wss://my_ai_server/_stcore/stream` failed:

wss 관련 에러가 발생하는 원인과 해결 방법

  1. Reverse Proxy에서 웹소켓 지원 미설정
    Reverse Proxy(Nginx, Apache 등)에서 웹소켓 요청을 제대로 전달하지 못하면 wss:// 연결이 실패할 수 있음. 예를 들어, Nginx 사용 시 다음 설정이 필요하다.여기서 proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "Upgrade"; 설정이 없으면 웹소켓 연결이 정상적으로 되지 않을 수 있음
server {
    listen 443 ssl;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8501;  # Streamlit 서버로 연결
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
    }
}
  1. Streamlit이 wss://를 지원하지 못하는 경우
    기본적으로 Streamlit은 ws:// (HTTP 환경)에서만 웹소켓을 실행하는데, wss://(HTTPS 환경) 에서 실행하려면 HTTPS 트래픽을 처리할 수 있어야 한다. 따라서 Streamlit 서버를 직접 HTTPS로 실행하려면 --server.enableCORS false--server.enableWebsocketCompression false옵션을 추가해야 한다.
streamlit run app.py --server.enableCORS false --server.enableWebsocketCompression false

문제 파악

HTTP 리버스 프록시 설정이 잘못되어 있을 수 있다. 현재 설정은 Nginx의 Reverse Proxy 설정 중 "HTTP(80)에서 내부 Streamlit 서버(localhost:8501)로 요청을 전달하는 부분"이다.
현재)

server {
    server_name my_ai_server;
    listen 80; # 이 서버 블록은 HTTP(포트 80)에서 실행됨
    location / {
        allow 0.0.0.0/24; # 내부 네트워크 대역에서만 접근을 허용함
        deny all; # 이외에는 모두 차단함
        proxy_pass http://localhost:8501; # 클라이언트가 https://my_ai_server에 접속하면 내부의 streamlit 서버(http://localhost:8501)로 요청을 전달함
        proxy_set_header Host $host; # 원본 요청의 Host 헤더를 유지함 / 웹소켓 및 CORS 관련 문제를 방지하는 설정
        proxy_pass_request_headers on;
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;

        proxy_set_header Upgrade $http_upgrade; # 웹소켓(wss://) 연결을 지원하기 위한 설정
        proxy_set_header Connection "upgrade"; # 웹소켓을 정상적으로 처리하기 위해 Connection : upgrade 헤더 추가
        proxy_http_version 1.1; # 웹소켓을 지원하는 HTTP/1.1 사용

        add_header 'Access-Control-Allow-Origin' 'https://dev.egene.io' always; # https://dev.egene.io에서 오는 요청만 CORS를 허용
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; # 허용되는 HTTP 메서드 정의
        add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization' always; # 요청에서 허용되는 헤더를 명시
    }
}

이 설정은 nginx에서 my_ai_server 서버의 HTTP 포트에서 실행된다. HTTPS 처리는 이 설정에 포함되지 않았다. 또한 Nginx의 Reverse Proxy 설정 중 "HTTP(80번 포트)에서 내부 Streamlit 서버(localhost:8501)로 요청을 전달하는 부분"이다.

80포트와, 443포트도 확인해야 한다.

server{
    server_name my_ai_server;
    listen 443 ssl; #http2 써놓으면 http/2 활성화

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; 
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8501;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-Ip $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

설정을 완료했다면, Nginx와 AI 서버에 대해서도 웹소켓이 열려 있는지를 확인해야 한다.

Nginx 및 서버 확인

Nginx 서버 및 AI서버에서는 Curl 요청으로 다음의 3가지를 확인해야 한다.

  • Upgrade로 Websocket 요청이 갔는지
  • http 1.1요청이 갔는지
  • Connection으로 Upgrade가 갔는지

요청

    curl -v -H "Upgrade: websocket" \\
-   H "Connection: Upgrade" \\
-   H "Sec-WebSocket-Version: 13" \\
-   H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \\
-   \-http1.1 \\  
    [http://localhost/\_stcore/stream](http://localhost/_stcore/stream)

답변

root@nginx-reverse:/etc/nginx/sites-available# curl -v -H "Upgrade: websocket" \ -H "Connection: Upgrade" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \ --http1.1 \ http://localhost:8501/_stcore/stream`
-   Trying ...
-   Connected to ... port 8501 (#0)

> GET /\_stcore/stream HTTP/1.1  
> Host: localhost:8501  
> User-Agent: curl/7.81.0  
> Accept: **/**  
> Upgrade: websocket  
> Connection: Upgrade  
> Sec-WebSocket-Version: 13  
> Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==

-   Mark bundle as not supporting multiuse

< HTTP/1.1 101 Switching Protocols  
< Server: TornadoServer/6.4.2  
< Date: Mon, 17 Feb 2025 05:02:28 GMT  
< Upgrade: websocket  
< Connection: Upgrade  
< Sec-Websocket-Accept: qGEgH3En71di5rrssAZTmtRTyFk=  
< Vary: Accept-Encoding  
<  
Warning: Binary output can mess up your terminal. Use "--output -" to tell  
Warning: curl to output it to your terminal anyway, or consider "--output  
Warning: " to save to a file.

-   Failure writing output to destination
-   Closing connection 0

위와 같이 101 Switching Protocol 응답이 나와야 정상이다.

실패의 경우 다음과 같다.
예1) 400코드

root@nginx-reverse:/etc/nginx/sites-available# 
curl -i -N -H "Connection: Upgrade"     
-H "Upgrade: websocket"     
-H "Host: http://localhost"     
-H "Origin: https://my_ai_server"     
-H "Sec-WebSocket-Version: 13"     
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ=="     
https://my_ai_server/_stcore/stream  
HTTP/2 400 Bad Request  
Server: openresty  
Date: Fri, 14 Feb 2025 03:48:29 GMT  
Content-Type: text/html; charset=UTF-8  
Content-Length: 34  
Vary: Accept-Encoding  

Can "Upgrade" only to "WebSocket"

이 경우 Nginx에서 Server에 HTTP/2요청을 보내므로 연결 실패가 나온다. 이 경우 Nginx config를 찾아봐야 한다.

예2) 200코드

root@nginx-reverse:/etc/nginx/sites-available# curl -i -N -H "Connection: Upgrade" \  
    -H "Upgrade: websocket" \  
    -H "Host: [ai.cloud.egene.io](http://ai.cloud.egene.io)" \  
    -H "Origin: [https://ai.cloud.egene.io](https://ai.cloud.egene.io)" \  
    -H "Sec-WebSocket-Version: 13" \  
    -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \  
    --http1.1 \  
    https://my_ai_server  
HTTP/1.1 200 OK  
Server: openresty  
Date: Mon, 17 Feb 2025 04:44:39 GMT  
Content-Type: text/html  
Content-Length: 1837  
Connection: keep-alive  
Accept-Ranges: bytes  
Etag: "~"  
Last-Modified: Mon, 10 Feb 2025 10:56:15 GMT  
...

<!--  
 Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)  

 Licensed under the Apache License, Version 2.0 (the "License");  
 you may not use this file except in compliance with the License.  
 You may obtain a copy of the License at  

     [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)  

 Unless required by applicable law or agreed to in writing, software  
 distributed under the License is distributed on an "AS IS" BASIS,  
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  
 See the License for the specific language governing permissions and  
 limitations under the License.  
-->  

<!DOCTYPE html>  
<html lang="en">  
  <head>  
    <meta charset="UTF-8" />  
    <meta  
      name="viewport"  
      content="width=device-width, initial-scale=1, shrink-to-fit=no"  
    />  
    <link rel="shortcut icon" href="./favicon.png" />  
    <link  
      rel="preload"  
      href="./static/media/SourceSansPro-Regular.DZLUzqI4.woff2"  
      as="font"  
      type="font/woff2"  
      crossorigin  
    />  
    <link  
      rel="preload"  
      href="./static/media/SourceSansPro-SemiBold.sKQIyTMz.woff2"  
      as="font"  
      type="font/woff2"  
      crossorigin  
    />  
    <link  
      rel="preload"  
      href="./static/media/SourceSansPro-Bold.-6c9oR8J.woff2"  
      as="font"  
      type="font/woff2"  
      crossorigin  
    />  

    <title>Streamlit</title>  

    <!-- initialize window.prerenderReady to false and then set to true in React app when app is ready for indexing -->  
    <script>  
      window.prerenderReady = false  
    </script>  
    <script type="module" crossorigin src="./static/js/index.NJJ4tUjP.js"></script>  
    <link rel="stylesheet" crossorigin href="./static/css/index.mUTQuMqR.css">  
  </head>  
  <body>  
    <noscript>You need to enable JavaScript to run this app.</noscript>  
    <div id="root"></div>  
  </body>  
</html>

200코드가 나온다고 해서 안심할 수도 없다. HTML이 나온다는 것은, Websocket 업그레이드가 차단되면서 정적 페이지가 반환되기 때문이다.

웹소켓은 왜 HTTP/2 연결을 받지 않을까?

웹소켓은 HTTP/1.1에서만 Upgrade가 가능하며, HTTP/2 이상에서는 기본적으로 지원되지 않는다. 그 이유는 HTTP/1.1이 Connection : Upgrade를 지원하는데에 있다. Connection: Upgrade를 지원하면, 현재 연결을 다른 프로토콜로 변경할 수 있기 때문이다.

이에 비해, HTTP/2는 WebSocket Upgrade를 지원하지 않는데, 그 이유는 다음과 같다.

  • HTTP/2는 단일 연결을 여러 스트림으로 처리한다.
    • HTTP/2는 멀티플렉싱(Multiplexing)을 지원해서 하나의 TCP연결에서 여러개의 스트림(요청)을 동시에 처리한다.
    • 그러나 웹소켓은 단일 연결에서 지속적인 데이터 교환이 필요하기 때문에 HTTP/2의 기본 개념과 충돌한다.
  • HTTP/2는 Connection: Upgrade를 허용하지 않는다.
    • HTTP/2는 헤더 압축(HPACK)과 멀티플렉싱 구조로 인해 Upgrade 매커니즘을 제공하지 않는다.

Nginx Proxy Manager

NPM이라고도 불리는 Nginx Proxy Manager는 위와 같이 Nginx만 이용 시 CLI에서 해줘야 했던 설정들을 GUI에서 지원하고 있다.

Edit Proxy Host

그동안, 각각의 Proxy Host에 대해 해 주어야 했던 설정들을 Edit Proxy Host라는 작은 팝업을 통해 지원한다. 결과적으로 말하자면, 여기서 Websockets support 옵션을 켜 주기만 해도, 위의 코드블럭을 활성화하는 것과 같은 효과를 보여준다.

Details

Detailes에서는 인스턴스에 대한 기본 설정을 해줄 수 있다.

  • Domain Names : 프록시할 도메인 이름을 정해줄 수 있다.
  • Scheme : 프록시할 프로토콜을 선택할 수 있다.(http 또는 https)
  • Forward Hostname / IP : 내부 서버의 IP 주소 또는 호스트명( 예 : 192.168.0.100 또는 backend.example.local)
  • Forward Port : 내부 서버의 포트 번호( 예 : 80, 443, 5000)
  • Cache Assets : 정적 자산(이미지, CSS, JS 등)의 캐싱 여부
  • Block Common Exploits : 일반적인 보안 취약점 차단 옵션
  • Websockets Support : Websocket 지원 여부
  • Access List : 접근 제어 리스트 활성화 여부 및 특정 IP 또는 사용자 그룹에 대한 접근 제한 설정 가능

Custom Location

Custom Location에서는 특정 URL 경로(location)를 지정하여 지정한 경로만 프록시할 서버를 설정할 수 있다.

SSL

SSL에서는 보안 정보를 설정할 수 있다.

  • SSL Certificate : SSL 인증서를 적용할 도메인
  • Force SSL : HTTP 요청을 HTTPS로 강제 리디렉션
  • HTTP/2 Support : HTTP/2 활성화
  • HSTS Enabled : HTTP Strict Transport Security 활성화
  • HSTS Subdomains : 서브도메인에도 HSTS 적용

HSTS란?

HSTS(HTTP Strict Transport Security)는 웹사이트가 HTTPS를 강제하도록 브라우저에게 지시하는 보안 기능이다. 즉, 한 번 HTTPS로 접속한 사용자는 이후 모든 요청을 자동으로 HTTPS로 전환하도록 설정하는 기능이다. 이를 통해 중간자 공격(MITM, Man-in-the-Middle Attack)이나 SSL Stripping같은 공격을 방지할 수 있다.

HSTS의 주요 기능

  • 강제 HTTPS 연결
  • 초기 HTTP 연결 허용
  • 중간자 공격 방지
  • 서브도메인 적용 가능
  • HSTS Preload List : 브라우저에 미리 등록 가능, 등록하면 무조건 HTTPS

Advanced

Advanced에서는 nginx의 변수를 이용한 추가 설정을 입력해줄 수 있다.
예를 들어 proxy_set_header X-Real_IP $remote_addr; 등을 입력한다.

+ Recent posts