ports 와 expose 차이

  • ports 는 컨테이너와 로컬머신 사이에 포트마운트를 실제로 진행함

  • 도커 컨테이너끼리는 애초에 통신 제약이 없다. 따라서 expose 가 명시되어 있지 않더라도 통신은 가능함

    • expose 인자가 있는 이유는 유저에게 해당 포트를 사용하라고 알리기 위함 (명세 목적)
    • compose / Dockerfile 어느쪽에서 expose / EXPOSE 를 하든 동작은 동일하다
    • 네트워크는 당연히 공유되어야 함

http 헤더 → Websocket

프로토콜 구조상 HTTP 헤더가 유실되는 것은 아니다.

웹소켓(WebSocket) 통신은 최초에 HTTP GET 요청을 통해 시작되며, 이는 웹소켓 핸드셰이크(Handshake)

  • RFC 6455 표준에 따르면 핸드셰이크 과정은 완벽하게 유효한 HTTP 요청이어야 한다.
  • 따라서 클라이언트는 일반적인 HTTP 통신과 동일하게 HTTP 헤더(Cookie, User-Agent, 리버스 프록시가 주입한 X-Forwarded-For 등)를 서버에 정상적으로 전달
  • 서버가 이 요청을 수락하고 101 Switching Protocols 상태 코드로 응답하면 프로토콜이 전환
  • 그 이후 데이터 통신 단계에서만 HTTP 헤더를 생략하고 웹소켓 프레임 단위로 데이터를 교환
  • 즉, 초기 연결 시점의 헤더는 서버에 도달한다.

하지만 Streamlit 등의 프레임워크 환경에서 개발자는 “헤더가 유실되었다”고 체감한다.

브라우저 WebSocket API의 제약

브라우저에서 제공하는 표준 WebSocket JavaScript API는 개발자가 커스텀 HTTP 헤더(예: Authorization: Bearer token)를 직접 세팅하는 것을 허용하지 않는다.

  • 이는 보안 상의 이유

즉, 중간에 헤더가 네트워크 상에서 유실된 것이 아니라, 클라이언트(브라우저) 측에서 애초에 커스텀 헤더를 전송하지 못한 상태로 핸드셰이크 요청을 보낸 것

Streamlit의 컨텍스트 분리

Streamlit은 내부적으로 Tornado 비동기 웹 서버를 사용해 웹소켓 통신을 관리

  • 핸드셰이크 요청에 포함된 헤더는 웹 서버 단에 정상적으로 도착
  • 과거 버전의 Streamlit은 이를 Python 앱의 실행 컨텍스트로 직접 노출하지 않음
  • 현재 버전에서는 공식적으로 st.context.headers 객체를 제공하여 웹소켓 핸드셰이크 시점의 초기 HTTP 헤더를 읽는 것이 가능
    • Nginx / 인증 프록시 서버가 주입한 커스텀 헤더 확인에 사용

해결책

이때, 주요하게 발생하는 실수는

  • 일반 HTTP 페이지 요청에는 헤더를 붙이고, WebSocket Upgrade 요청에는 안 붙이는 설정 실수
  • WebSocket Upgrade 요청에도 주입해야 함
  • WebSocket Upgrade 경로의 프록시 설정을 유의해야 한다

앞단에 리버스 프록시를 설치한 후, http 헤더를 엔진 변수로 보존하고, 이 보존한 엔진 변수를 웹소켓 쪽에 넘겨야 참조가 가능하다.

인증정보를 http 헤더에 담아서 보낼 경우 추가적인 처리가 필요하다. 처리로 고려할 수 있는 사항은 아래 정도

방식요약예시/비고
Cookie 기반 인증브라우저가 웹소켓 핸드셰이크 요청 시 동일 출처 쿠키를 자동 포함Streamlit의 st.context.cookies로 서버단 접근 가능
Query string token웹소켓 연결 URL 자체에 데이터를 포함해서 통신ws://server.com/stream?token=…
Sec-WebSocket-Protocol 활용서브프로토콜 헤더에 토큰성 값을 실어 전송 (헤더를 우회적 활용)new WebSocket(url, [“access_token_123”])
연결 후 메시지 전송웹소켓 연결 수립 직후 첫 번째 프레임 메시지 페이로드로 인증 데이터 전송웹소켓 연결 수립 (101 응답) 이후 처리
브라우저가 아닌 Node/Python 클라이언트 사용브라우저가 아닌 클라이언트에서 웹소켓 요청 구성Node/Python 클라이언트 사용 (브라우저 불가)
리버스 프록시 기반 인증 헤더 주입리버스 프록시에서 인증 헤더를 주입별도 방식으로 구분
 
# Streamlit의 WebSocket 엔드포인트
# 이 경로에서 404 또는 WebSocket 오류가 나면
# 프록시 라우팅, baseUrlPath, Upgrade/Connection 헤더 전달을 확인한다.
 
location /_stcore/stream {
    proxy_pass http://streamlit:8501/_stcore/stream;
 
    # WebSocket Upgrade에 필요
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
 
 
    # 클라이언트/앞단 프록시가 보낸 요청 헤더 값을 Nginx 변수로 읽고,
    # 이를 Streamlit upstream 요청 헤더로 다시 전달한다.
    proxy_set_header X-Debug-val1 $http_val1;
    proxy_set_header X-Debug-val2 $http_val2;
 
  
    # add_header는 브라우저로 나가는 응답 헤더 추가용이다.
    # Streamlit으로 넘기는 용도가 아니다.
    # add_header X-Debug-val1 $http_val1 always;
    # add_header X-Debug-val2 $http_val2 always;
}

로드밸런싱

docker-compose 에서는 replica 구문으로 컨테이너를 횡적으로 복제할 수 있으며, 이는 로드밸런싱용 목적이다.

이때, 리버스 프록시로 nginx 를 사용하고 있을 경우, docker-container 이름 기반으로 자동 라우팅을 하면 에러가 날 수 있다. (ex: streamlit)

docker-compose의 replicas 옵션으로 컨테이너를 다중화하면, Docker 내장 DNS는 해당 서비스 이름 호출에 대해 라운드로빈(Round-Robin) 방식으로 트래픽을 각 컨테이너로 분산시킨다. Nginx에서 리버스 프록시 대상을 단일 서비스 이름으로 지정할 경우, 클라이언트의 연속된 요청이 무작위 워커로 분산된다.

stramlit 기준으로 설명하면, streamlit은 웹소켓(WebSocket) 통신과 인메모리(In-Memory) 세션에 강하게 의존하는 프레임워크다. 따라서

  • app.py는 st.navigation 단일 진입점 구조라 항상 단일 프로세스 안에서 페이지가 pg.run() 으로 실행된다. 이런 구조에서 단일 프로세스라면, 페이지가 한 번이라도 렌더되면 컴포넌트가 그 프로세스에 등록되고 그 다음에 iframe이 에셋을 요청하므로 404가 날 구조적 틈이 없다. 따라서 단일 프로세스에서는 멀쩡하다.
  • 그러나 다중 프로세스일 경우, 컴포넌트 요청이 그 컴포넌트를 등록하지 않은 워커로 가거나, 프록시가 그 경로를 Streamlit으로 안 넘겨서 404/네트워크 실패가 날 수 있다.

이 문제를 해결하려면 동일한 클라이언트(IP)의 요청이 항상 처음 연결된 동일한 컨테이너로 향하도록 강제하는 세션 고정(Sticky Session)이 필수적이다.

docker-compose 말고 k8s를 쓴다고 이 문제가 해결되지는 않으며, 에러의 근본적인 원인은 리버스 프록시 쪽에 있다.

nginx 의 경우

Nginx가 직접 각 복제본 컨테이너로 트래픽을 분배하도록 upstream 블록을 구성하거나, ip_hash 지시어를 사용하여 IP 기반 로드밸런싱을 구성해야 한다. 이때

  1. replica 구문으로 3개를 올림 (app1-1, app1-2, app1-3)
  2. 서비스 자체를 별도로 올림 (app1-1, app2-1, app3-1)

1번은 해결이 불가능하며, 2번인 경우에만 해결이 가능하다.

1번의 경우, replicas 구문으로 배포하면, 보통 Nginx는 단일 서비스 이름(예: app1)으로 트래픽을 넘기게 된다. 이때 치명적인 두 가지 문제가 발생한다.

  1. Docker 내장 DNS의 개입: Nginx가 app1로 트래픽을 보내면, Nginx의 ip_hash와 무관하게 Docker 내장 DNS가 3개의 복제본 IP로 라운드로빈(Round-Robin) 분산을 강제해버린다. 결과적으로 Nginx 단에서 해싱을 해도 그 뒤에서 트래픽이 다시 흩어진다.
  2. 동적 IP와 Nginx 캐싱의 한계: 오픈소스 Nginx는 구동 시점에만 DNS를 해석하고 캐싱한다. 컨테이너가 재시작되어 IP가 바뀌면 Nginx는 바뀐 IP를 추적하지 못하고 502 에러를 뱉는다.

이 상황에서 Nginx로 세션을 고정하려면 동적으로 변하는 replica 컨테이너들의 개별 IP를 감지해서 Nginx upstream 블록을 실시간으로 다시 써주는 도구(예: jwilder/nginx-proxy, docker-gen 등)를 추가로 붙여야 한다. Nginx 단독으로는 불가능하다.

1번과 달리 2번 상황에서는 Nginx 설정의 upstream 블록에 고정된 각 컨테이너의 호스트명(또는 IP)을 명확하게 지정할 수 있으므로 문제가 없다. 이 구성에서는 로드밸런싱의 주체가 완벽하게 Nginx가 되며, Nginx가 클라이언트의 IP를 해싱(ip_hash)하여 3개의 컨테이너 중 한 곳으로 세션을 안정적으로 고정한다.

upstream streamlit_cluster {
    ip_hash;
    server app1-1:8501;
    server app2-1:8501;
    server app3-1:8501;
}

traefik

Traefik 은 자체적으로 컨테이너 환경에서 기본적으로 Sticky Session 기능을 제공하므로, 로드밸런싱이 상대적으로 간단하다.

else

포트 연결 = port publishing / port mapping 파일·디렉터리 연결 = mount / volume mount / bind mount