
transcription 프로젝트의 ASR 서버는 GPU 모델 3개를 JupyterLab 터미널 위에서 persistent worker로 실행한다. 각 worker는 WebSocket으로 통신하며 VRAM에 모델을 상주시키는 구조다. 이 구조를 프로덕션에서 운용하면서 worker 사망 감지, 터미널 재사용, graceful shutdown 등 운영 이슈가 연달아 터졌다. 하루 만에 7개 커밋으로 해결한 기록이다.
원격 GPU 서버에서 venv가 격리된 환경에 모델을 상주시켜야 했다. JupyterLab 터미널은 PTY 기반의 persistent 세션을 제공하고, WebSocket API로 프로그래밍이 가능하다. SSH 터널 대신 HTTP 기반이라 방화벽 이슈도 적다. 그런데 운영하다 보니 PTY 자체의 제약과 프로세스 관리 문제가 쏟아져 나왔다.
JupyterLab 터미널은 내부적으로 PTY를 사용한다. canonical mode에서 한 번에 4096바이트까지만 입력을 받는다. ASR 세그먼트가 수십 개면 JSON이 이 제한을 쉽게 넘긴다.
해결은 입력이 3KB를 넘으면 temp file에 쓰고, 경로만 stdin으로 보내는 방식이다.
# asr_server.py - JupyterTerminalWorker.run()
input_json = json.dumps(input_data)
if len(input_json) > 3072: # PTY 4096 limit에 여유
input_path = Path(tempfile.mktemp(suffix=".json"))
input_path.write_text(input_json)
input_data = {"_input_file": str(input_path)}
input_json = json.dumps(input_data)
# worker __main__.py - 파일에서 읽기
if "_input_file" in data:
with open(data["_input_file"]) as f:
data = json.load(f)
Path(data.pop("_input_file", "")).unlink(missing_ok=True)worker 프로세스가 죽으면 done/error 시그널 없이 600초 타임아웃까지 대기하는 문제가 있었다. 2초 간격으로 프로세스 생존을 확인하는 alive 체크를 추가해서 즉시 감지하도록 했다.
서비스를 재시작할 때 이전에 띄운 worker가 터미널에 살아있는지 확인해야 한다. PID를 직접 확인할 수 없는 원격 환경에서는 최소 요청을 보내고 5초 내 응답이 오면 살아있다고 판단하는 probe 방식이 가장 확실하다.
def _probe_worker(self) -> bool:
self._done_event.clear()
probe = json.dumps({"_output_path": "/tmp/_probe.json"})
self._ws.send(json.dumps(["stdin", f"{probe}\n"]))
if self._done_event.wait(timeout=5):
self._ready = True
Path("/tmp/_probe.json").unlink(missing_ok=True)
return True
return False처음에는 빈 터미널을 랜덤으로 할당했다. 그런데 재시작하면 이전 worker가 어느 터미널에 있는지 찾지 못하는 문제가 생긴다. worker-터미널 1:1 매핑으로 바꿨다.
_WORKER_TERMINAL_MAP과 preferred_terminal 파라미터로 고정 매핑을 구현했다. 재시작 시 같은 터미널에 접속해서 probe로 기존 worker를 찾는다.
터미널을 재사용하는데 이전 프로세스가 남아있을 수 있다. 이 경우 Ctrl+C(\x03)를 2회 전송해서 정리한 뒤 새 worker를 시작한다.
GPU API가 "busy"를 반환해도, 실행 중인 프로세스가 전부 자기 worker이면 사실상 idle이다. 단순 idle/busy 이분법이 아니라 프로세스 소유권까지 확인해야 한다.
_OWN_WORKER_KEYWORDS = ("diarize_worker", "asr_worker", "align_worker")
def _check_gpu_idle(gpu_api_url=None):
status = query_status(gpu_api_url)
if status == GPUStatus.BUSY:
resp = httpx.get(f"{url}/status",
headers={"Authorization": f"Bearer {token}"})
processes = resp.json().get("processes", [])
if processes and all(
any(kw in p.get("command", "") for kw in _OWN_WORKER_KEYWORDS)
for p in processes
):
return True # 자기 worker만 → idle
return False서비스(FastAPI) 재시작 시 worker 프로세스를 죽이면 안 된다. GPU 모델 로딩에 수십 초가 걸리기 때문이다. stop()은 WebSocket 연결만 해제하고 worker 프로세스는 살려둔다. 다음 시작 시 probe로 재연결하면 cold start 없이 즉시 서비스 가능하다.
def stop(self):
"""Disconnect from terminal without killing the worker process."""
if self._ws:
self._ws.close()
self._ws = None
self._ready = False
self._assigned_terminals.discard(self._terminal_name)
logger.info("%s worker disconnected (process left running)", self.name)기존에는 _ensure_workers_ready()로 모든 worker를 한 번에 로딩했다. 파이프라인에서 각 단계 직전에 해당 worker만 로딩하도록 _ensure_worker_ready(name)으로 바꿨다. diarize가 돌아가는 동안 asr은 로딩할 필요가 없으니까.
원격 GPU 서버에서 venv가 격리된 환경에 모델을 상주시켜야 한다. JupyterLab 터미널은 PTY 기반 persistent 세션과 WebSocket API를 제공해서 프로그래밍으로 worker를 제어할 수 있다.
worker는 모델을 VRAM에 올려놓고 요청을 기다리는 구조라 추가 메모리 할당이 거의 없다. 서비스 재시작마다 모델을 다시 로딩하는 수십 초를 아끼는 게 훨씬 이득이다.
PTY canonical mode가 4096바이트 제한을 걸기 때문에 큰 JSON이 잘린다. temp file에 전체 JSON을 쓰고 경로만 stdin으로 보내면 크기 제한 없이 전달할 수 있다.