[Homelab] Immich 머신러닝을 작업별로 CPU/GPU에 나눌 수 있을까? (그리고 Intel·AMD GPU를 한 서비스로 묶기)


Immich 머신러닝(ML) 구성을 이리저리 바꾸던 중에, 스마트 서치(CLIP)만 메모리를 유독 많이 먹고 GPU 메모리에 모델을 못 올려 죽는 일이 있었습니다. 얼굴 인식이나 OCR은 메모리를 별로 안 쓰고요.

그래서 떠올린 게 “ML 컨테이너를 CPU용, GPU용 두 개 띄워서 스마트 서치만 CPU로 보내면 되지 않나?”였습니다. 결론부터 말하면 기본 기능으로는 안 됩니다. 왜 안 되는지가 꽤 재밌어서 정리해 둡니다. (Immich v3 시점 기준)

🤔 하고 싶었던 것

작업보내고 싶은 곳
스마트 서치 (CLIP)CPU 컨테이너
얼굴 검출 / 임베딩GPU 컨테이너
OCRGPU 컨테이너

🚫 안 되는 이유 1: URL 목록은 ‘라우팅’이 아니라 ‘페일오버’

관리자 설정의 Machine Learning URL(설정 JSON으로는 machineLearning.urls)은 여러 개를 넣을 수 있어서 될 것처럼 보입니다. 하지만 이 목록은:

  • 스마트 서치, 얼굴 검출, OCR이 모두 공유하는 하나의 목록이고
  • 앞에서부터 순서대로 시도해서 살아 있는 첫 번째 서버가 전부 처리합니다. 응답 없는 서버는 다시 살아날 때까지 건너뛰고요.

라운드로빈도 아니고 작업별 배정도 아닙니다. [CPU, GPU] 순서로 넣으면 CPU 컨테이너가 살아 있는 한 얼굴도 OCR도 전부 CPU로 갑니다.

🚫 안 되는 이유 2: 엔드포인트가 하나다

그럼 리버스 프록시로 경로별로 나눠볼까 싶었는데, immich-server가 ML 컨테이너에 보내는 요청은 전부 POST /predict 하나입니다. 어떤 모델을 돌릴지는 multipart 바디 안의 entries라는 JSON 필드에 들어 있어서, nginx location 같은 경로 기반 라우팅으로는 구분이 안 됩니다.

참고로 “얼굴 인식”이라고 묶어 부르는 것 중에 ML 컨테이너가 하는 건 얼굴 검출 + 임베딩 추출뿐입니다. 같은 사람끼리 묶는 매칭/클러스터링은 DB에 저장된 임베딩으로 immich-server가 처리합니다.

🛠️ 그래도 꼭 나누고 싶다면: task-aware 프록시

CPU/GPU 컨테이너 앞에 작은 프록시(FastAPI나 OpenResty 등)를 하나 두고, URL 목록에는 프록시 하나만 넣는 방법입니다. 프록시가 multipart 바디를 열어보고:

  • entries에 CLIP 작업만 있으면 → CPU 컨테이너
  • 얼굴 검출/인식, OCR이면 → GPU 컨테이너

로 보내는 식이죠. 원리상으로는 됩니다. 다만 entries의 형식은 공개 API가 아니라 Immich 내부 구현이라, 버전을 올리다 필드가 바뀌면 조용히 깨집니다. 만든다면 Immich 버전을 고정하고, 업그레이드할 때마다 machine-learning 쪽 변경 사항을 확인해야 합니다. 저는 이걸 유지보수할 자신이 없어서 안 만들었습니다.

✅ 현실적인 대안: 나누지 말고 메모리 압박을 줄이기

진짜 문제는 “분리가 안 된다”가 아니라 “스마트 서치 때문에 메모리가 터진다”입니다. 그건 훨씬 적은 노력으로 줄일 수 있습니다.

  • CLIP 모델을 가볍게: SigLIP / ViT-SO400M 계열은 무겁습니다. ViT-B-32 같은 기본급 모델로 내리면 검색 품질은 조금 손해, 메모리는 크게 이득입니다.
  • MACHINE_LEARNING_MODEL_TTL 줄이기: 모델은 첫 요청 때 올라가고 일정 시간(기본 300초) 놀면 내려갑니다. 이걸 줄이면 여러 모델이 동시에 상주하는 시간이 줄어듭니다. 반대로 preload 옵션은 상주를 늘리니 이 상황에선 끄는 게 맞습니다.
  • 작업 동시성 낮추기: Smart Search, Face Detection, OCR 작업의 concurrency를 낮추면 여러 작업이 동시에 각자 모델을 올리다 터지는 일이 줄어듭니다.
  • 얼굴 모델 다운그레이드: buffalo_l → buffalo_s. 더 작고 빠르지만 정확도는 떨어지고, 바꾸면 얼굴 검출을 다시 돌려야 합니다.

📌 제 경우: Intel iGPU 2대 + Radeon 1대를 한 서비스로

저는 모델(ViT-SO400M SigLIP2)은 그대로 두고, 작업을 나누는 대신 GPU 백엔드 자체를 늘리는 쪽으로 갔습니다.

Intel iGPU 쪽은 SR-IOV로 VF를 나눠 Proxmox VM 워커에 붙이고 OpenVINO로 돌립니다. 이 과정은 이전 글에 정리해 두었습니다.

문제는 Radeon이었습니다. Ryzen 7 6800H 내장 Radeon 680M은 SR-IOV가 안 돼서, 원래는 Proxmox 위에서 CPU 전용 노드로만 쓰고 있었습니다. 그러다 문득 “굳이 가상화할 필요가 있나? 그냥 물리 머신을 워커 노드로 두면 되잖아?” 싶더라고요. 그래서 Proxmox 없이 MicroOS를 올린 물리 워커 노드로 클러스터에 붙이고, ROCm 이미지로 ML 파드를 하나 더 띄웠습니다. GPU를 통째로 쓸 거라면 VM을 거칠 이유가 없더라고요. ㅎㅎ

Intel 쪽AMD 쪽
노드agent1, agent2 (Proxmox VM, SR-IOV VF)agent6 (물리 머신)
GPUArrow Lake iGPURadeon 680M (Ryzen 7 6800H)
이미지immich-machine-learning:v3.0.2-openvinoimmich-machine-learning:v3.0.2-rocm
디바이스/dev/dri/dev/dri, /dev/kfd
replicas21
리소스req 9000Mi / limit 15000Mi, CPU 2500m동일

핵심: Deployment는 둘, 라벨은 하나

이미지와 디바이스가 다르니 Deployment는 둘로 나눴지만, Pod 라벨은 똑같이 맞췄습니다.

# 두 Deployment의 Pod 템플릿 공통
labels:
  app.kubernetes.io/name: immich-machine-learning
  workload.cola/name: dep-immich-ML

ML Service의 selector가 이 두 라벨만 보기 때문에, Intel 파드 2개와 AMD 파드 1개가 하나의 엔드포인트 묶음이 됩니다. Immich에는 ML URL을 하나만 넣으면 되고요. 앞에서 얘기한 URL 목록(페일오버) 대신 쿠버네티스 Service가 분산을 맡는 셈입니다.

노드 배치는 GPU 라벨로 갈라 놓고, podAntiAffinity는 두 Deployment가 공유하는 라벨에 걸어서 노드당 ML 파드가 하나만 뜨게 했습니다.

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
      - matchExpressions:
        - key: role.cola/gpu
          operator: In
          values: ["amd"]        # Intel 쪽은 ["intel"]
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
    - labelSelector:
        matchExpressions:
        - key: workload.cola/name
          operator: In
          values: ["dep-immich-ML"]
      topologyKey: kubernetes.io/hostname

ROCm 쪽 설정

  • 680M은 gfx1035라 ROCm 공식 지원 목록에 없습니다. HSA_OVERRIDE_GFX_VERSION=10.3.0으로 gfx1030인 척 인식시켜서 돌립니다. 그래서 파드 안에서 rocminfo를 보면 gfx1030으로 찍힙니다.
  • 안내에는 “override로도 안 되면 HSA_USE_SVM=0도 넣어라”고 되어 있었는데, 저는 처음부터 둘 다 넣었습니다. 그래서 SVM 옵션 없이도 돌아가는지는 솔직히 모릅니다. 둘 다 넣은 상태로는 잘 동작하고 있습니다.
  • ROCm 컴퓨트는 /dev/kfd를 쓰므로 /dev/dri와 함께 둘 다 넣어야 합니다.
  • 모델 TTL(600초), 요청 스레드, 모델 캐시 경로 등 나머지는 Intel 쪽과 똑같이 맞췄습니다.

⚠️ 알고 쓰면 좋은 것

  • 분산은 작업 단위가 아니라 연결 단위입니다. Service(여기선 Cilium)는 TCP 연결을 백엔드에 배정하기 때문에, immich-server가 연결을 재사용하면 한쪽으로 몰릴 수 있습니다.
  • 어떤 작업이 어느 백엔드로 갈지 고를 수는 없습니다. 그러니 모든 백엔드가 모든 모델(CLIP, 얼굴, OCR)을 다 돌릴 수 있어야 하고, 메모리 여유도 백엔드마다 따로 챙겨야 합니다.
  • 백엔드가 달라도 같은 모델이니 임베딩은 같은 공간에 있습니다. 다만 정밀도(OpenVINO는 FP16)나 연산 경로 차이로 아주 미세한 수치 차이는 생길 수 있습니다.

📝 정리

  • Immich의 ML URL 목록은 작업별 라우팅이 아니라 순차 페일오버입니다. 첫 번째로 살아 있는 서버가 모든 작업을 가져갑니다.
  • 요청은 전부 POST /predict 하나라 경로 기반 분리도 불가. 나누려면 바디를 읽는 프록시가 필요하고, 버전마다 깨질 각오를 해야 합니다.
  • 대부분은 모델 크기, TTL, 동시성 조절만으로 메모리 터지는 문제를 잡을 수 있습니다.
  • 나눠 쓰는 대신 늘리는 건 쉽습니다. Intel과 AMD처럼 서로 다른 GPU라도 Pod 라벨을 맞춰 하나의 Service로 묶으면 URL 하나로 씁니다(연결 단위 분산).
  • SR-IOV가 안 되는 GPU라면 굳이 VM에 물리지 말고 물리 워커 노드로 두는 것도 방법입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다