실시간 WebSocket 프록시¶
라우터는 GET /v1/realtime?model=<id> 엔드포인트로 OpenAI Realtime 호환 음성 백엔드를 서빙할 수 있습니다. 짧은 경로를 쓰는 SDK(OpenAI SDK, Pipecat)를 위해 GET /realtime 호환 별칭도 제공합니다. WebSocket 업그레이드 이후 라우터는 텍스트와 바이너리 프레임을 양방향으로 바이트 동일하게 중계합니다. 프로토콜 변환이 없으므로 OpenAI Realtime 이벤트 형식(session.update, input_audio_buffer.append, response.created, response.done, 함수 호출 이벤트)을 사용하는 백엔드라면 수정 없이 동작합니다.
이 엔드포인트는 기본 기능 집합에 포함된 realtime Cargo 기능으로 컴파일되며, realtime 설정 섹션이 enabled: true로 존재할 때만 활성화됩니다. 섹션이 없거나 비활성화되었거나 기능 없이 빌드된 경우 404를 반환합니다.
설정¶
realtime:
enabled: true
max_sessions: 256
handshake_timeout: "10s"
idle_timeout: "60s"
max_session_duration: "0"
backend_path: "/v1/realtime"
| 필드 | 기본값 | 설명 |
|---|---|---|
enabled | false | /v1/realtime와 /realtime 라우트의 마스터 스위치입니다. |
max_sessions | 256 | 동시 세션 상한입니다. 각 세션은 수명 동안 퍼밋 하나를 점유하며, 상한을 넘는 새 업그레이드 요청은 HTTP 503으로 거부됩니다. 최소 1이어야 합니다. |
handshake_timeout | "10s" | 백엔드 WebSocket 연결(TCP 연결, TLS, 핸드셰이크)의 기한입니다. 만료되면 클라이언트 쪽 연결이 종료 코드 1011로 닫히고 서킷 브레이커에 연결 실패가 기록됩니다. 0이 아닌 duration이어야 합니다. |
idle_timeout | "60s" | 이 시간 동안 어느 방향으로도 프레임이 오가지 않으면 양쪽 연결을 종료 코드 1000과 사유 idle timeout으로 닫습니다. "0"은 유휴 타이머를 비활성화합니다. |
max_session_duration | "0" | 세션의 절대 상한 시간입니다. 만료되면 양쪽 연결을 종료 코드 1000으로 닫습니다. 기본값 "0"은 무제한을 의미합니다. |
backend_path | "/v1/realtime" | 백엔드의 실시간 WebSocket 엔드포인트 경로입니다. 스킴 매핑(http는 ws로, https는 wss로) 후 백엔드 기본 URL에 결합됩니다. /로 시작해야 합니다. |
max_session_duration이 기본값 "0"인 상태에서 idle_timeout: "0"까지 설정하면 세션에 기한이 전혀 없어집니다. 이 경우 TCP FIN 없이 죽어버린 클라이언트(half-open 연결)가 max_sessions 퍼밋 하나와 그에 딸린 백엔드 세션을 운영체제의 TCP keepalive가 만료될 때까지 붙잡는데, 이 값은 보통 두 시간이거나 아예 비활성화되어 있습니다. 라우터는 이 조합에 대해 시작 시 경고를 남기며, 이런 클라이언트가 max_sessions개 쌓이면 엔드포인트가 멈춥니다.
리로드 클래스: restart. 이 섹션은 라우트 구성 시점에 스냅샷되므로, max_sessions를 포함해 어떤 값을 수정하더라도 프로세스 재시작이 필요합니다.
HTTP timeouts 섹션은 실시간 세션에 적용되지 않습니다. 전이중 음성 세션에는 첫 바이트나 전체 기한 개념이 없어서, SSE 스트림에서 강제되는 request.streaming.first_byte도 request.standard.total도 이 경로에는 닿지 않고 timeouts.limits도 마찬가지입니다. 위 타이머들이 유일한 세션 기한입니다.
요청 흐름¶
- 업그레이드 요청은 표준 API 인증 미들웨어를 거치므로,
api_keys.mode: blocking환경에서는 HTTP 요청과 동일하게Authorization: Bearer <key>로 인증합니다. Origin헤더를 검사합니다(아래 브라우저 Origin 참고). 라우터와 동일 출처도 아니고server.cors섹션이 허용하지도 않는 브라우저 페이지는 다른 처리를 하기 전에 403으로 거부됩니다.Origin을 보내지 않는 클라이언트는 영향을 받지 않습니다.model쿼리 파라미터는 필수이며(누락 시 400), 별칭과 양자화/날짜 접미사 정규화를 포함한 공유 메타데이터 매칭 파이프라인으로 해석됩니다. 알 수 없는 모델은 404로, 메타데이터에audio기능이 없는 모델은 400으로 거부됩니다.- 백엔드 후보는
/v1/chat/completions와 동일한 모델 조회와 라우팅 필터에서 나오며, 세션 백엔드는 공유 선택·서킷 승인 경로로 결정됩니다. 사용할 수 있는 백엔드가 없으면(모두 비정상이거나 서킷이 열림) 업그레이드가 503으로 거부됩니다. 클라이언트가 보낸 원본 모델 문자열로는 어떤 백엔드도 찾지 못했지만 해석된 정식 카탈로그 id로는 백엔드를 찾은 경우(별칭 조회), 라우터는 별칭이 아니라 그 정식 id를 백엔드의?model=쿼리 파라미터에 실어 보냅니다. 그래야 백엔드가 실제로 서빙하는 모델을 요청받습니다. 클라이언트에게 보이는 404 오류는 여전히 호출자가 보낸 원래 문자열을 그대로 반영합니다. - 인증된 키의
allowed_backends허용 목록이 이 후보군을 좁힙니다. 모든 HTTP 경로와 같은 방식입니다. 허용 목록 안에 그 모델을 서빙하는 백엔드가 하나도 없으면permission_error본문과 함께 403으로 거부합니다. 위의 503과 일부러 구분했는데, '이 키로는 이 모델을 쓸 수 없다'와 '이 모델을 서빙하는 백엔드가 지금 전부 죽어 있다'는 서로 다른 사실이고 재시도할 값어치가 있는 쪽은 하나뿐이기 때문입니다. 목록이 비었거나 아예 없을 때, 그리고api_keys.mode: permissive에서 익명으로 접속할 때는 제한이 걸리지 않습니다. - 업그레이드 요청에 실린
x-backend헤더는 남은 후보 중에서 선호하는 백엔드를 지정합니다. 규칙은 다른 경로와 같습니다. 지정한 백엔드가 가시적이고, 허용 목록을 통과하고, 그 후보 집합에 속하고, 승인 가능하면 반영하며 그렇지 않으면 조용히 무시하므로 쓸 수 없는 값이 핸드셰이크를 실패시키는 일은 없습니다. 다른 점은 적용 범위입니다. 세션은 선택을 딱 한 번만 하므로, 반영된 선호도는 한 번의 시도가 아니라 세션 수명 전체에 걸쳐 백엔드를 묶고 나중에 되돌아갈 선택도 없습니다. 101 업그레이드 응답에는x-served-backend헤더가 붙지 않습니다. - 라우터는 백엔드의 실시간 엔드포인트에 접속하면서 백엔드에 설정된 자격 증명(
api_key, 컨트롤 플레인 환경에서는 허브가 전달한 자격 증명)으로 인증합니다. 클라이언트의Authorization헤더는 백엔드로 전달되지 않습니다. - 프레임은 한쪽이 닫거나, 타이머가 만료되거나, 연결이 끊길 때까지 양방향으로 중계됩니다. 종료 프레임은 원래의 코드와 사유 그대로 전파되며, 한쪽 연결이 비정상 종료되면 다른 쪽은 종료 코드 1011로 닫힙니다. 백엔드 연결 실패와 세션 성립은 HTTP 트래픽과 같은 서킷 브레이커에 기록되므로 WebSocket과 HTTP 디스패치가 백엔드별 페일오버 상태를 공유합니다.
프레임 페이로드에는 사용자의 실제 음성이 담기므로 어떤 로그 레벨에서도 기록하지 않습니다. 로그와 메트릭은 이벤트 종류, 개수, 크기, 지속 시간, 결과만 기록합니다.
브라우저 Origin¶
WebSocket 핸드셰이크는 동일 출처 정책과 CORS를 모두 우회하며, 연결을 연 페이지는 스트림을 그대로 읽을 수 있습니다. 검사가 없다면 운영자가 방문한 아무 웹 페이지나 접근 가능한 라우터(로컬호스트, 사내망 호스트)로 실시간 세션을 열어 음성 백엔드에 오디오를 흘려보내고 모델의 응답을 읽을 수 있습니다. 그래서 라우터는 업그레이드 요청의 Origin을 검사합니다.
요청의 Origin | 결과 |
|---|---|
| 없음 | 허용. 브라우저가 아닌 클라이언트(OpenAI SDK, Pipecat, CLI 도구)는 이 헤더를 보내지 않고, 브라우저는 이 헤더를 없애거나 위조할 수 없습니다. |
요청과 authority가 같음(Host, HTTP/2에서는 :authority) | CORS 설정 없이 허용되므로 라우터가 직접 서빙하는 페이지는 그대로 동작합니다. 스킴은 비교하지 않는데, TLS 종단 장치 뒤에 있는 라우터를 그대로 동작시키기 위함입니다. |
server.cors.enabled: true 상태에서 server.cors.allow_origins에 등재됨 | 허용. 정확한 origin, *, host:* 포트 와일드카드가 CORS 레이어와 똑같이 매칭됩니다. |
그 외 전부, null 리터럴 포함 | HTTP 403과 오류 코드 origin_not_allowed로 거부. |
다른 origin의 브라우저 프런트엔드가 실시간 세션을 열게 하려면 CORS 섹션에 명시합니다.
null origin은 * 패턴에서도 거부됩니다. 샌드박스 iframe이나 data: 문서가 제시하는 불투명 origin이라서 아무것도 식별해 주지 않습니다.
예시: NVIDIA NemotronLabs VoiceChat¶
NemotronLabs VoiceChat 11B 컨테이너는 /v1/realtime의 OpenAI Realtime 호환 WebSocket으로만 추론을 서빙합니다. 백엔드를 컨테이너로 지정하고, 모델을 나열하고, 섹션을 활성화합니다.
backends:
- name: voicechat
url: "http://10.0.0.5:8000"
type: vllm
models:
- nvidia-nemotronlabs-voicechat-11b
realtime:
enabled: true
idle_timeout: "120s"
클라이언트는 OpenAI Realtime 호환 SDK로 접속합니다.
오디오는 base64로 인코딩된 PCM16(모델 방향은 24 kHz 모노 리틀엔디언, 컨테이너가 내부에서 리샘플링)으로 input_audio_buffer.append 이벤트에 실려 이동하고, 서버는 툴 호출을 위한 response.function_call_arguments.done을 포함해 표준 response.* 이벤트를 내보냅니다. 라우터는 이 모든 것을 그대로 중계합니다.
헬스 체크는 백엔드별 HTTP 프로브(health_check.endpoint)를 그대로 사용합니다. WS 전용 추론 컨테이너도 HTTP 헬스 엔드포인트를 노출하므로 그대로 동작합니다.
메트릭¶
metrics 기능이 활성화되면 프록시는 다음을 내보냅니다.
| 메트릭 | 유형 | 레이블 | 설명 |
|---|---|---|---|
realtime_active_sessions | gauge | backend | 현재 열린 세션 수. |
realtime_sessions_total | counter | backend, outcome | 종료 결과별 세션 수: completed, backend_connect_failed, idle_timeout, max_duration, client_abort, backend_abort. |
realtime_frames_relayed_total | counter | direction | 중계된 프레임 수, client_to_backend / backend_to_client. |
realtime_session_duration_seconds | histogram | backend | 성립된 세션의 지속 시간. |
제한과 범위 밖 항목¶
- 메시지와 프레임 크기는 양쪽 모두 4 MiB로 제한됩니다.
max_sessions은 가용 메모리에 맞춰 조정해야 합니다. 4 MiB 메시지 상한이 레그마다 적용되므로 최악의 순간 메모리 사용량은 대략max_sessions * 2 * 4 MiB이며, 기본값max_sessions: 256에서는 약 2 GiB입니다. 이는 세션마다 레그당 초대형 메시지 하나를 버퍼링할 때 도달할 수 있는 상한일 뿐 정상 상태는 아닙니다. 일반적인 실시간 트래픽은 작은 프레임 위주이고, 정상 상태의 읽기/쓰기 버퍼링은 세션당 약 256 KiB 수준입니다.- 브라우저식 서브프로토콜 인증(
openai-insecure-api-key.<key>)은 지원하지 않습니다. 인증이 활성화된 경우 클라이언트는 표준Authorization헤더를 보내야 합니다. - 라우터는 OpenAI Realtime 호환이 아닌 음성 프로토콜을 변환하지 않으며,
response.done에서 토큰 사용량을 추출하지 않고, WebRTC/SIP를 전송하지 않습니다.