[에러 해결] pywebtransport와 Docker] Docker 컨테이너 간 연결 타임아웃 (Session ready/closed timeout) 원인 및 해결

안녕하세요, 개발자 여러분! 오늘은 pywebtransport 라이브러리를 사용하여 웹트랜스포트 클라이언트와 서버를 Docker 컨테이너 환경에서 운영할 때 발생하는 연결 타임아웃 문제에 대해 심층적으로 분석하고 해결 방법을 제시합니다.

특히 Docker 환경에서 컨테이너 간 통신 시 `127.0.0.1` (localhost) 주소 사용과 관련된 오해로 인해 발생하는 `pywebtransport.exceptions.ConnectionError: Session ready/closed timeout` 에러는 많은 개발자를 혼란스럽게 할 수 있습니다. 이 글을 통해 문제를 명확히 이해하고 효과적으로 해결하는 방법을 알아보겠습니다.

1. 에러 발생 상황

개발자는 pywebtransport 기반의 간단한 Echo 서버와 클라이언트를 구현하여, 각각 별도의 Docker 컨테이너에서 실행하려 했습니다. Docker Compose를 사용하여 두 컨테이너를 동일한 브리지 네트워크에 연결했지만, 클라이언트 컨테이너가 서버 컨테이너에 연결하는 데 실패하고 일정 시간(30초) 후 연결 타임아웃 에러를 발생시켰습니다.

클라이언트 코드 (`client.py`)

import asyncio
import ssl

from pywebtransport import ClientConfig, WebTransportClient
from pywebtransport.types import EventType


async def main() -> None:
    config = ClientConfig(verify_mode=ssl.CERT_NONE)

    async with WebTransportClient(config=config) as client:
        session = await client.connect(url="https://127.0.0.1:4433/") # 주목!

        await session.send_datagram(data=b"Hello, Datagram!")

        event = await session.events.wait_for(event_type=EventType.DATAGRAM_RECEIVED)
        if isinstance(event.data, dict) and (data := event.data.get("data")):
            print(f"Datagram: {data!r}")

        stream = await session.create_bidirectional_stream()
        await stream.write_all(data=b"Hello, Stream!", end_stream=True)

        response = await stream.read_all()
        print(f"Stream: {response!r}")

        await session.close()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

서버 코드 (`server.py`)

import asyncio

from pywebtransport import Event, ServerApp, ServerConfig, WebTransportSession, WebTransportStream
from pywebtransport.types import EventType
from pywebtransport.utils import generate_self_signed_cert

generate_self_signed_cert(hostname="127.0.0.1")

app = ServerApp(config=ServerConfig(certfile="127.0.0.1.crt", keyfile="127.0.0.1.key"))


@app.route(path="/")
async def echo_handler(session: WebTransportSession) -> None:
    async def on_datagram(event: Event) -> None:
        if isinstance(event.data, dict) and (data := event.data.get("data")):
            await session.send_datagram(data=b"ECHO: " + data)

    session.events.on(event_type=EventType.DATAGRAM_RECEIVED, handler=on_datagram)

    try:
        async for stream in session.incoming_bidirectional_streams():
            asyncio.create_task(handle_stream(stream))
    finally:
        session.events.off(event_type=EventType.DATAGRAM_RECEIVED, handler=on_datagram)


async def handle_stream(stream: WebTransportStream) -> None:
    try:
        data = await stream.read_all()
        await stream.write_all(data=b"ECHO: " + data, end_stream=True)
    except Exception:
        pass


if __name__ == "__main__":
    print("Running app")
    app.run(host="127.0.0.1", port=4433) # 주목!

Docker Compose 파일 (`docker-compose.yml`)

version: '3.8'

services:
  wt-server:
    build: ./server
    ports:
      - "4433:4433/udp"
    container_name: wt-server
    networks:
      - wt-net
    environment:
      - PYTHONUNBUFFERED=1

  wt-client:
    build: ./client
    depends_on:
      - wt-server
    networks:
      - wt-net
    container_name: wt-client
    environment:
      - PYTHONUNBUFFERED=1

networks:
  wt-net:
    driver: bridge

발생한 에러 로그

wt-client  | Traceback (most recent call last):
...
wt-client  |   File "/app/client.py", line 12, in main
wt-client  |     session = await client.connect(url="https://127.0.0.1:4433/")
wt-client  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
wt-client  |   File "/usr/local/lib/python3.11/site-packages/pywebtransport/connection/connection.py", line 427, in wait_for_ready_session
wt-client  |     raise ConnectionError(message=f"Session ready/closed timeout after {timeout}s.") from None
wt-client  | pywebtransport.exceptions.ConnectionError: [0x2] Session ready/closed timeout after 30.0s.
wt-client exited with code 1

로그에서 볼 수 있듯이, 클라이언트가 `127.0.0.1:4433`으로 연결을 시도하는 부분에서 타임아웃이 발생했습니다.

2. 명확한 발생 원인

이 문제는 Docker 네트워크 환경에 대한 이해 부족에서 비롯된 두 가지 핵심 원인으로 발생합니다.

2.1. 클라이언트의 잘못된 서버 주소 지정

클라이언트 코드(`client.py`)는 `url=”https://127.0.0.1:4433/”`로 서버에 연결을 시도합니다. 그러나 Docker 컨테이너 내부에서 `127.0.0.1`은 해당 컨테이너 자신을 가리키는 `localhost` 주소입니다. 즉, `wt-client` 컨테이너는 `wt-server` 컨테이너가 아닌 자기 자신에게 연결을 시도하고 있었던 것입니다.

Docker Compose는 `networks` 설정을 통해 컨테이너 간에 브리지 네트워크를 생성하고, 이 네트워크 내에서 각 서비스 이름(예: `wt-server`)을 사용하여 다른 컨테이너에 접근할 수 있도록 DNS 확인 기능을 제공합니다. 따라서 `wt-client` 컨테이너가 `wt-server` 컨테이너에 접근하려면 `127.0.0.1` 대신 서비스 이름인 `wt-server`를 사용해야 합니다.

2.2. 서버의 잘못된 바인딩 주소

서버 코드(`server.py`)는 `app.run(host=”127.0.0.1″, port=4433)`로 실행됩니다. 이는 서버 애플리케이션이 컨테이너 내부의 `localhost` 인터페이스(즉, `127.0.0.1`)에서만 연결을 수신하겠다는 의미입니다. 따라서 `wt-server` 컨테이너 내부에서만 이 서버에 접근할 수 있으며, 동일한 Docker 네트워크에 있는 다른 컨테이너(`wt-client`)는 이 서버에 연결할 수 없습니다.

Docker 컨테이너가 컨테이너 외부(호스트 머신)나 동일 네트워크 내의 다른 컨테이너로부터의 연결을 수락하려면, 서버 애플리케이션은 모든 네트워크 인터페이스를 의미하는 `0.0.0.0` 주소에 바인딩되어야 합니다.

요약하자면, 클라이언트는 잘못된 주소로 연결을 시도했고, 서버는 외부로부터의 연결을 수신할 준비가 되어 있지 않았던 것입니다.

3. 해결 방법 및 코드 예시

이 문제를 해결하기 위해서는 클라이언트와 서버 코드 모두에서 네트워크 관련 설정을 수정해야 합니다.

3.1. 서버 바인딩 주소 수정 (`server.py`)

서버가 모든 네트워크 인터페이스에서 연결을 수신하도록 `host` 주소를 `0.0.0.0`으로 변경합니다.

# server.py

# ... (생략) ...

if __name__ == "__main__":
    print("Running app")
    app.run(host="0.0.0.0", port=4433) # 127.0.0.1을 0.0.0.0으로 변경

3.2. 클라이언트 연결 URL 수정 (`client.py`)

클라이언트가 서버 컨테이너의 서비스 이름인 `wt-server`를 사용하여 연결하도록 `url`을 변경합니다.

# client.py

# ... (생략) ...

async def main() -> None:
    config = ClientConfig(verify_mode=ssl.CERT_NONE)

    async with WebTransportClient(config=config) as client:
        # 127.0.0.1을 서버 서비스 이름인 wt-server로 변경
        session = await client.connect(url="https://wt-server:4433/") 

# ... (생략) ...

두 파일을 위와 같이 수정한 후, `docker-compose up –build` 명령으로 컨테이너를 다시 빌드하고 실행하면 클라이언트가 서버에 성공적으로 연결되고 Echo 메시지를 주고받는 것을 확인할 수 있습니다.

참고로, 서버의 `generate_self_signed_cert(hostname=”127.0.0.1″)`는 현재 클라이언트가 `verify_mode=ssl.CERT_NONE`으로 인증서 유효성 검증을 하지 않기 때문에 문제가 되지 않습니다. 하지만 프로덕션 환경에서는 서버의 실제 호스트 이름(이 경우 `wt-server`)과 일치하는 유효한 인증서를 사용하는 것이 보안상 매우 중요합니다.

4. 향후 예방을 위한 팁

  • Docker 네트워크의 기본 이해:
    • 각 컨테이너 내부에서 `127.0.0.1` 또는 `localhost`는 오직 해당 컨테이너 자신만을 의미합니다.
    • Docker Compose 네트워크에서 다른 컨테이너에 접근하려면 해당 서비스의 이름(예: `wt-server`)을 DNS 호스트 이름처럼 사용해야 합니다.
    • 컨테이너 외부(호스트 머신)나 다른 컨테이너로부터 연결을 수신하려면, 애플리케이션은 `0.0.0.0` 주소에 바인딩되어야 합니다.
  • 환경 변수 활용: 서버 주소와 같은 동적인 값은 하드코딩하는 대신 환경 변수를 통해 관리하는 것이 좋습니다. 예를 들어, 클라이언트 컨테이너에 `SERVER_HOST=wt-server` 환경 변수를 전달하고 클라이언트 코드에서 이를 읽어 사용하는 방식입니다.
  • 인증서 관리: `ssl.CERT_NONE`은 개발 및 테스트 시 편리하지만, 실제 서비스에서는 절대 사용해서는 안 됩니다. 보안을 위해 신뢰할 수 있는 CA(인증 기관)에서 발급받은 인증서를 사용하거나, 내부망의 경우 자체 CA를 구축하여 관리해야 합니다. 또한, 인증서의 `Subject Alternative Name (SAN)` 필드에 서버의 도메인 이름(또는 서비스 이름)이 정확히 명시되어야 합니다.
  • 로깅 및 디버깅: 네트워크 관련 문제가 발생했을 때는 컨테이너 로그를 자세히 확인하고, 필요한 경우 컨테이너 내부에 접속하여 `ping`, `telnet`, `curl` 등의 도구로 연결 테스트를 수행하는 것이 유용합니다.

이 글이 pywebtransport와 Docker 환경에서 발생하는 연결 문제를 해결하고, 더 나아가 Docker 네트워크의 기본 개념을 이해하는 데 도움이 되기를 바랍니다. 안전하고 효율적인 개발 환경을 구축하시길 응원합니다!

댓글 남기기