
홈랩 K8s 클러스터를 운영하면서 가장 골치 아팠던 문제가 하나 있다. GPU다. Proxmox VM 위에 K8s 노드를 올렸는데 GPU 패스스루가 안 된다. 정확히 말하면 Hyper-V 위의 Proxmox라서 중첩 가상화 환경이고, 이 구조에서 GPU를 VM에 넘기는 건 현실적으로 불가능하다.
그런데 GPU가 필요한 서비스는 계속 늘어났다. 음성인식(ASR), 이미지 생성(ComfyUI), LLM 추론(vLLM), 논문 번역(TranslateGemma), GPU 상태 모니터링까지. RTX 5090 하나를 이 서비스 전부가 시분할로 나눠 써야 한다. GPU는 WSL에서만 접근 가능하고, 나머지 인프라(n8n 워크플로, Traefik 라우팅, Cloudflare Tunnel)는 전부 K8s에 있다.
이 간극을 메우려고 만든 게 "WSL-K8s 브릿지" 패턴이다. 2주 동안 10개 프로젝트에서 반복 적용하면서 자연스럽게 정착된 구성인데, 나름 범용적이라 정리해둔다.
WSL2는 Hyper-V 경량 VM으로 돌아가서 자체 네트워크 인터페이스를 갖는다. 문제는 이 IP가 호스트 전용이라는 점이다. K8s 노드(Proxmox VM)에서 WSL 내부 IP로 직접 접근할 수 없다. WSL 안에서 FastAPI 서버를 띄워봤자 같은 LAN에 있는 다른 장비에서는 접근이 안 된다.
그래서 중간 다리가 필요하다. Windows가 그 역할을 한다.
패턴은 세 단계로 나뉜다.
WSL 내부에서 돌아가는 서비스(예: GPU API, 포트 9090)를 Windows LAN IP로 노출한다.
netsh interface portproxy add v4tov4 \
listenport=9090 listenaddress=0.0.0.0 \
connectport=9090 connectaddress=localhost이 한 줄이면 LAN의 어디에서든 192.168.31.2:9090으로 WSL 서비스에 접근할 수 있다. 방화벽 인바운드 규칙도 추가해야 한다는 걸 잊으면 한참 삽질하게 된다.
K8s에서 외부 서비스를 내부 서비스처럼 쓰려면 Pod selector가 없는 Service와 직접 IP를 지정한 Endpoints를 만들면 된다.
apiVersion: v1
kind: Service
metadata:
name: gpu-api
namespace: gpu-api
spec:
ports:
- port: 9090
targetPort: 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를 수동으로 생성해야 한다. 여기에 Windows LAN IP를 넣으면 K8s 내부 DNS(gpu-api.gpu-api.svc.cluster.local)로 WSL 서비스에 접근 가능해진다.
외부 접근이 필요한 서비스는 Traefik IngressRoute를 추가하면 된다.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: gpu-api
namespace: gpu-api
spec:
entryPoints:
- websecure
routes:
- match: Host(`gpu.xssh.org`)
kind: Rule
services:
- name: gpu-api
port: 9090
tls: {}이 세 단계(Namespace + Service + Endpoints + IngressRoute)가 하나의 세트다. 서비스를 추가할 때마다 이 매니페스트를 복사해서 이름과 포트만 바꾸면 된다.
2주간 진행한 프로젝트에서 이 패턴을 적용한 서비스 목록이다.
is_idle 플래그를 보고 GPU 점유 여부를 판단한다.HTTPS가 필요한 건 IngressRoute를 추가하고 그렇지 않은 건 K8s 내부 DNS로만 쓴다. 똑같은 매니페스트 구조에서 IngressRoute 유무만 다르다.
K8s에는 외부 서비스를 등록하는 공식 방법으로 ExternalName Service가 있다. 하지만 이건 DNS CNAME을 반환하는 거라서 IP 주소를 직접 지정할 수 없다. 홈랩 LAN IP(192.168.31.2)에 도메인이 없으니 ExternalName은 못 쓴다.
수동 Endpoints는 IP를 직접 넣으니 DNS 의존성이 없다. 장비가 고정 IP라면 이쪽이 더 간단하고 확실하다.
처음 ASR 서버를 연결할 때 Endpoints IP를 Tailscale 주소(100.x.x.x)로 넣었다. 내 노트북에서는 Tailscale이 있어서 잘 되는데 K8s 노드에는 Tailscale이 없다. 당연히 접근 불가. GPU 서버에서도 같은 실수를 반복해서 fix 커밋만 두 개 남겼다. K8s 노드가 접근할 수 있는 네트워크가 뭔지 먼저 확인하자.
portproxy를 설정해도 방화벽 인바운드 규칙이 없으면 외부에서 접근이 안 된다. OpenClaw을 배포할 때 포트 18789에 대한 방화벽 규칙을 빼먹어서 30분을 날렸다. portproxy 추가할 때 방화벽도 같이 추가하는 습관을 들이자.
Service와 Endpoints의 metadata.name이 정확히 같아야 한다. namespace도 같아야 한다. 오타 하나로 연결이 안 되는데 에러 메시지가 명확하지 않아서 디버깅이 귀찮다. kubectl get endpoints -n <ns>로 Endpoints가 제대로 연결됐는지 확인하는 게 첫 번째 디버깅 단계다.
GPU API를 systemd user service로 등록했는데 nvidia-smi를 못 찾았다. 일반 쉘에서는 /usr/lib/wsl/lib/가 PATH에 있지만 systemd는 다른 PATH를 쓴다. feat 커밋 직후 1분 만에 fix 커밋이 나온 이유다. 환경변수로 절대 경로를 명시해서 해결했다.
이 패턴에서 "연결이 안 돼요"가 나오면 아래 순서로 확인한다.
curl localhost:9090/healthnetsh interface portproxy show allkubectl run tmp --rm -it --image=curlimages/curl -- curl 192.168.31.2:9090kubectl get endpoints -n <namespace>kubectl run tmp --rm -it --image=busybox -- nslookup gpu-api.gpu-api.svc.cluster.local대부분 2번(portproxy 누락)이나 3번(방화벽)에서 걸린다. K8s 쪽 문제인 경우는 거의 없었다.
이 패턴이 만능은 아니다. 단점도 분명하다.
매니페스트 복사-붙여넣기가 심하다. 5개 서비스에 동일한 구조를 반복 적용하면서 YAML이 거의 복사 수준이 됐다. Helm 차트나 Kustomize 컴포넌트로 추상화하면 관리가 훨씬 편해질 텐데 아직 손을 못 댔다.
IP가 바뀌면 Endpoints를 수동으로 고쳐야 한다. Windows IP가 DHCP로 바뀌면 모든 Endpoints를 업데이트해야 한다. 지금은 공유기에서 고정 IP를 할당해놓아서 괜찮지만 구조적으로 취약한 부분이다.
헬스체크가 없다. 수동 Endpoints는 K8s가 헬스체크를 하지 않는다. WSL 서비스가 죽어도 Endpoints IP는 그대로 남아있어서 요청이 계속 들어간다. 서비스별로 헬스체크 로직을 직접 구현하거나 외부 모니터링을 붙여야 한다.
모든 것을 K8s 안에 넣으려 하기보다는 외부 서비스를 K8s 서비스 디스커버리에 등록하는 방식이 하이브리드 환경에서 더 현실적이다. GPU처럼 컨테이너화가 어려운 리소스가 있을 때 특히 그렇다.
이 패턴은 홈랩이 아니더라도 적용할 수 있다. 온프레미스 GPU 서버를 K8s 클러스터에 연동하거나 레거시 서비스를 K8s 네트워크에 편입시킬 때 동일한 접근이 통한다. 핵심은 간단하다. selector 없는 Service + 수동 Endpoints. 나머지는 평소 K8s 쓰던 대로 하면 된다.