
내 홈랩에는 RTX 5090이 딱 한 장 있다. Windows 11 위에 WSL을 올리고 GPU passthrough로 연결한 구성인데, 이 GPU를 논문 번역(vLLM, 27B 모델), 이미지 생성(ComfyUI), 음성 인식(VibeVoice-ASR) 세 서비스가 시분할로 나눠 쓴다.
문제는 간단하다. 서비스 A가 GPU를 쓰는 중에 서비스 B가 올라오면 둘 다 터진다. VRAM이 32GB라 27B 모델 하나만 올려도 거의 꽉 차기 때문이다. 그래서 각 서비스가 GPU를 점유하기 전에 "지금 누가 쓰고 있나?"를 확인해야 하는데, 이걸 어떻게 할까?
nvidia-smi를 치면 되긴 한다. 근데 nvidia-smi는 WSL 안에서만 접근 가능하고, K8s Pod에서 WSL로 SSH 연결을 뚫는 건 배보다 배꼽이 더 크다. 서비스마다 이 로직을 각자 구현하면? 중복이고 관리 포인트만 늘어난다. 결론은 명확했다. GPU 상태를 HTTP API 하나로 제공하자.
WSL 안에서 FastAPI 서버가 nvidia-smi를 호출하고, Windows portproxy를 거쳐 K8s 네트워크에 편입되는 구조다.
FastAPI가 nvidia-smi를 subprocess로 호출해서 GPU 정보를 가져오고, /proc 파일시스템에서 점유 프로세스 정보까지 읽는다. 이 서버가 WSL의 9090 포트에서 돌고, Windows portproxy가 이 포트를 호스트 IP로 포워딩한다. K8s에서는 Pod selector 없는 Service + Endpoints 조합으로 이 외부 IP를 K8s 네트워크 안에 편입시킨 뒤, Traefik IngressRoute로 gpu.xssh.org 도메인을 붙였다.
nvidia-smi에는 --format=csv,noheader,nounits 옵션이 있다. XML을 파싱할 필요 없이 CSV로 깔끔하게 뽑아준다.
def query_gpu_info() -> dict | None:
result = subprocess.run(
["nvidia-smi",
"--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5,
)
parts = [p.strip() for p in result.stdout.strip().split(",")]
return {
"name": parts[0], "utilization": int(parts[1]),
"memory_used_mib": int(parts[2]), "memory_total_mib": int(parts[3]),
"temperature": int(parts[4]), "power_draw": float(parts[5]),
}GPU 이름, 사용률, VRAM, 온도, 전력을 한 번에 가져온다. 여기에 is_idle 플래그를 추가했는데, 로직은 단순하다. nvidia-smi가 보여주는 GPU 프로세스가 0개면 idle. 이 한 비트짜리 플래그가 홈랩 전체의 GPU 스케줄링 기준이 된다.
한 가지 더. PID와 프로세스 이름만으로는 "vLLM이 TranslateGemma-27B를 서빙 중"인지 알 수 없다. 그래서 /proc/PID/cmdline을 읽어서 전체 커맨드라인까지 응답에 포함시켰다. macOS 위젯에서 "지금 뭐가 GPU 쓰는 중"인지 한눈에 보려면 이 정보가 필수다.
엔드포인트는 두 개뿐이다.
/health — 인증 없이 접근 가능. K8s liveness probe용. 서버가 살아있고 nvidia-smi 호출이 되는지만 확인한다./status — Bearer 토큰 인증. GPU 상세 정보 + 프로세스 목록 + is_idle 플래그 전부를 JSON으로 반환한다.인증을 넣은 이유가 있다. 이 API를 Traefik으로 외부에 노출하면 GPU 상태가 인터넷에 공개되는 건데, 프로세스 커맨드라인에 모델 경로나 내부 IP 같은 정보가 담길 수 있어서 Bearer 토큰으로 막아뒀다.
FastAPI 서버는 WSL 안에서 돌지만 소비자 서비스는 K8s Pod다. WSL 서비스를 K8s 네트워크에 넣는 방법은 의외로 간단하다. Pod selector 없는 Service를 만들고, 같은 이름의 Endpoints에 외부 IP를 직접 적으면 된다.
apiVersion: v1
kind: Service
metadata:
name: gpu-api
namespace: gpu-api
spec:
ports:
- port: 9090
---
apiVersion: v1
kind: Endpoints
metadata:
name: gpu-api # Service와 이름 일치 필수
namespace: gpu-api
subsets:
- addresses:
- ip: 192.168.31.2 # Windows Host IP
ports:
- port: 9090핵심은 Service에 selector가 없다는 것이다. selector가 없으면 K8s가 자동으로 Endpoints를 만들지 않는다. 대신 같은 이름으로 Endpoints를 직접 정의하면 K8s가 이 IP를 Service의 백엔드로 인식한다. 여기에 Traefik IngressRoute를 붙이면 gpu.xssh.org로 HTTPS 접근이 가능해진다. 우리 홈랩에서 WSL 서비스 5개 이상이 전부 이 패턴을 쓴다.
WSL 부팅 시 자동 실행을 위해 systemd user service를 만들었는데, 서비스를 올리자마자 nvidia-smi를 못 찾는다. 터미널에서는 잘 되는데? 이유는 PATH 차이였다. 일반 쉘에서는 /usr/lib/wsl/lib/가 PATH에 들어있지만 systemd user service는 다른 PATH를 쓴다.
feat 커밋 올리고 1분 만에 fix 커밋을 올린 이유가 바로 이거다. 해결은 NVIDIA_SMI_PATH 환경변수로 nvidia-smi의 절대 경로를 명시하는 것으로 끝냈다.
systemd 서비스에서 외부 바이너리를 쓸 때는 항상 절대 경로를 쓰거나 Environment=PATH=...로 명시하자. 쉘에서 되는 게 서비스에서도 되리란 보장은 없다.
/proc/PID/cmdline을 읽어서 프로세스 커맨드라인을 가져오는데, nvidia-smi가 보여주는 PID 중에 커널 스레드(:: 포함)가 섞여 있었다. 커널 스레드는 cmdline이 비어있거나 의미 없는 값이 들어있어서 그대로 보여주면 쓸모가 없다.
해결은 /proc/PID/status에서 PPid(부모 프로세스 ID)를 찾아 올라가는 fallback을 넣는 것이다. 대부분의 경우 부모 프로세스가 실제 유저 프로세스라서 거기서 의미 있는 커맨드라인을 가져올 수 있다.
이 API에서 가장 값진 건 사실 is_idle이라는 boolean 하나다. GPU를 쓰는 프로세스가 0개면 true, 아니면 false. 단순하지만 이 플래그 하나로 홈랩 전체의 GPU 스케줄링이 돌아간다.
각 서비스가 서로를 모른 채 이 API 하나만 보고 독립적으로 판단하는 구조다. 공유 자원의 상태를 단일 API로 노출하면 소비자가 각자 알아서 스케줄링할 수 있다는 게 핵심이다.
GPU 하나를 여러 서비스가 나눠 쓰는 환경에서 충돌 없이 운영하려면 상태 조회 API가 필요했다. FastAPI + nvidia-smi CSV 파싱으로 구현은 반나절이면 되고, K8s Endpoints 패턴으로 네트워크 통합도 간단하다. systemd PATH 함정만 조심하면 된다.
홈랩이 아니더라도 "공유 자원 + 상태 API + 소비자" 패턴은 꽤 범용적이다. DB 커넥션 풀 상태를 API로 노출한다거나, 외부 API의 rate limit 잔여량을 공유한다거나. 공유 자원이 있고 여러 서비스가 독립적으로 접근 판단을 내려야 하는 상황이면 같은 구조를 쓸 수 있다.
다음 글에서는 이 API를 macOS 위젯으로 만들어서 데스크톱에서 GPU 상태를 실시간으로 모니터링하는 과정을 다룬다.