
홈랩 K8s 클러스터를 운영하면서 한 가지 골치 아픈 문제가 있었다. GPU 워크로드를 돌릴 수가 없다는 거다. K8s 노드가 VM으로 돌아가고 있어서 GPU 패스스루가 안 되기 때문이다. RTX 5090은 Windows 호스트의 WSL에서만 접근 가능하다.
그런데 문제는 이 GPU 서비스를 K8s 안의 다른 서비스에서 갖다 써야 한다는 점이다. n8n 워크플로우에서 ASR(음성인식) API를 호출하고 싶고, 블로그 자동화에서 ComfyUI 이미지 생성을 쓰고 싶은데 전부 WSL에서 돌아가고 있으니까.
결론부터 말하면 "수동 Endpoints" 패턴으로 해결했다. 같은 패턴을 5개 서비스에 반복 적용했고 꽤 깔끔하게 동작한다.
먼저 전체 그림을 보자. 요청 흐름은 아래처럼 3단계를 거친다.
이 구조에서 K8s 입장에서 보면 gpu-api, comfyui, zotero-server 같은 서비스가 마치 클러스터 안에 있는 것처럼 보인다. 실제로는 WSL에서 돌아가고 있지만 K8s 서비스 디스커버리에 등록되어 있으니 클러스터 DNS로 접근이 된다.
보통 K8s Service를 만들면 selector로 Pod을 지정하고 Endpoints가 자동 생성된다. 그런데 selector를 아예 안 넣으면? Service는 생기지만 Endpoints가 비어 있다. 여기에 수동으로 Endpoints 리소스를 만들어서 외부 IP를 지정해주면 K8s 네트워크에서 그 IP로 트래픽이 흘러간다.
공식 문서에도 나오는 패턴이긴 한데 실제로 써본 사람은 많지 않은 것 같다. 나도 이번에 처음 적용해봤다.
한 서비스의 매니페스트는 이렇게 생겼다.
# Service (selector 없음)
apiVersion: v1
kind: Service
metadata:
name: gpu-api
namespace: gpu-api
spec:
ports:
- port: 9090
targetPort: 9090
---
# Endpoints (수동 IP 지정)
apiVersion: v1
kind: Endpoints
metadata:
name: gpu-api
namespace: gpu-api
subsets:
- addresses:
- ip: 192.168.31.2 # Windows Host LAN IP
ports:
- port: 9090핵심은 Service에 selector가 없다는 것이다. selector가 없으면 K8s가 Endpoints를 자동 생성하지 않으므로 우리가 직접 만든 Endpoints가 쓰인다. Endpoints의 addresses.ip에 Windows 호스트의 LAN IP를 넣어주면 끝이다.
여기에 Traefik IngressRoute까지 붙이면 외부에서 HTTPS로도 접근할 수 있다.
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(selector 없음) + Endpoints(수동 IP) + IngressRoute. 이 네 가지가 한 세트다. 이걸 서비스마다 복사해서 포트 번호랑 이름만 바꿔주면 된다.
WSL의 네트워크 인터페이스는 NAT 뒤에 있어서 외부에서 직접 접근이 안 된다. K8s 노드에서 WSL 내부 IP(보통 172.x.x.x)로 직접 패킷을 보낼 수 없다는 뜻이다.
그래서 Windows의 netsh interface portproxy 기능을 중간 다리로 쓴다. Windows LAN IP(192.168.31.2)의 특정 포트로 들어오는 트래픽을 WSL 내부 IP로 포워딩해주는 규칙을 추가하면 된다.
# Windows PowerShell (관리자)
netsh interface portproxy add v4tov4 \
listenport=9090 listenaddress=0.0.0.0 \
connectport=9090 connectaddress=172.28.x.x이렇게 하면 K8s Endpoints에서 192.168.31.2:9090으로 보낸 트래픽이 Windows portproxy를 거쳐 WSL 내부의 서비스까지 도달한다.
이 패턴을 다음 서비스에 적용했다.
gpu-api와 ComfyUI는 Traefik IngressRoute를 붙여서 외부 HTTPS 접근을 열어뒀고 나머지 셋은 K8s 내부에서만 접근하도록 IngressRoute 없이 구성했다.
5개 서비스에 같은 패턴을 반복하다 보니 매니페스트가 거의 복사-붙여넣기 수준이 되었다. 이름과 포트 번호만 다를 뿐 구조가 동일하다. 나중에 Helm 차트나 Kustomize 컴포넌트로 추상화하면 좋겠다는 생각이 들었다.
처음에 Endpoints IP를 Tailscale IP(100.x.x.x)로 넣었다. 내 맥북에서 Tailscale로 잘 붙으니까 K8s에서도 되겠지 싶었던 거다.
당연히 안 됐다. K8s 노드에는 Tailscale이 설치되어 있지 않으니 100.x.x.x 대역을 라우팅할 방법이 없었다. K8s 노드가 접근할 수 있는 건 LAN 대역(192.168.31.x)뿐이다.
웃긴 건 이 실수를 GPU API에서 한 번 하고 ASR 서버에서 또 한 번 했다는 거다. fix 커밋을 두 번이나 남기고 나서야 "Endpoints IP는 반드시 K8s 노드가 접근 가능한 IP여야 한다"는 걸 몸으로 외웠다.
수동 Endpoints 패턴은 GPU 워크로드 말고도 다양한 상황에서 쓸 수 있다.
모든 걸 K8s 안에 넣으려고 애쓰기보다 외부 서비스를 K8s 서비스 디스커버리에 등록하는 방식이 하이브리드 환경에서는 훨씬 현실적이다. Pod 안에서 curl gpu-api.gpu-api.svc.cluster.local:9090 치면 WSL의 FastAPI 서버가 응답하는 게 꽤 만족스럽다.
K8s 외부에서 돌아가는 서비스를 클러스터 네트워크에 통합하는 건 생각보다 간단했다. selector 없는 Service + 수동 Endpoints + (필요하면) IngressRoute. 이 조합이면 거의 모든 외부 서비스를 K8s DNS에 등록할 수 있다.
WSL + Windows portproxy 조합은 GPU가 있는 Windows 머신에서만 쓰는 특수한 상황이지만 수동 Endpoints 패턴 자체는 범용적이다. K8s를 운영하면서 "이건 컨테이너에 못 넣겠는데" 하는 서비스가 있다면 한번 시도해보길 추천한다.
다만 매니페스트가 서비스 수만큼 복제되는 건 좀 거슬린다. 다음에는 Kustomize 컴포넌트로 템플릿화해서 이름과 포트만 파라미터로 바꿀 수 있게 만들어볼 생각이다.