Stan 기술블로그

GPU 없는 서버에서 TabbyML이 CUDA를 찾다 죽었다

사내망 안에서 도는 코딩 어시스턴트로 TabbyML을 올렸다. docker compose up을 하자마자 컨테이너가 끝없이 재시작했다. 로그는 libcuda.so.1을 못 찾는다고 했고, 서버에는 GPU가 없다. 공식 이슈에 나온 우회도 같은 에러로 막혔다. 결국 임베딩까지 사내 Ollama 서버로 넘겨서 띄웠다.

(사내 인프라 특성상 IP·서버 이름은 일반화했다.)


배경 — 왜 사내망 안에서 돌려야 했나

회사가 세무 데이터를 다루는 곳이라, 망분리 정책과 컴플라이언스 때문에 GitHub Copilot 같은 외부 클라우드 코딩 어시스턴트를 쓸 수 없다. 짜는 코드가 벤더 클라우드로 나가기 때문이다. 그래서 완전히 사내망 안에서 도는 코딩 어시스턴트가 필요했고, 오픈소스 self-hosted 도구인 TabbyML을 골랐다.

사내에는 이미 Ollama로 LLM을 서빙하는 서버가 두 대 있었다. Tabby는 화면과 API만 맡고, 실제 추론은 그 두 대에 맡기는 구도로 잡았다.

역할 어디
Tabby 서버 self-hosted Sentry가 도는 기존 서버에 같이 올림 (RAM 30Gi 중 여유 10Gi — Tabby는 가벼워서 충분)
LLM 서버 A Ollama — 자동완성 모델 qwen2.5-coder:14b
LLM 서버 B Ollama — 채팅 모델 qwen3-coder:latest

docker pull tabbyml/tabby를 받고 ~/.tabby/config.toml에 자동완성·채팅 설정만 채우면 될 줄 알았다.

크래시 루프에서 빠져나오기까지

[1차] libcuda.so.1을 못 찾는다

docker compose up -d 직후부터 컨테이너가 재시작을 반복했다. curl localhost:8080은 연결조차 안 됐다. 서버가 아예 뜨지 못한 것이다. 로그의 핵심은 이 두 줄이다.

llama-server <embedding> exited with status code 127
error while loading shared libraries: libcuda.so.1: cannot open shared object file

--model도 --chat-model도 주지 않고 serve만 실행했는데, <embedding> 프로세스가 뜨다가 죽는다. Tabby는 임베딩 설정이 없으면 기본 내장 임베딩 모델(Nomic-Embed-Text)을 스스로 띄우는데, 그 실행기(llama-server)가 CUDA 전용으로 빌드돼 있었다. 이 서버에는 NVIDIA GPU가 없으니 계속 실패했다.

[2차] 공식 이슈의 우회 — 같은 에러

같은 증상이 TabbyML 저장소 이슈 #4377에 있었다. 거기 나온 우회는 진입점을 CPU 전용 바이너리로 바꾸는 것이다.

services:
  tabby:
    image: tabbyml/tabby
    entrypoint: /opt/tabby/bin/tabby-cpu   # 이슈에서 제안된 우회
    command: serve

결과는 똑같은 libcuda.so.1 에러였다. 이슈는 “closed as not planned”로 닫혀 있었다. 적어도 이 이미지 버전에서는 통하지 않는 길이었다.

[3차] 임베딩을 빼고 띄울 수는 없다

확인해 보니 Tabby는 자동완성·채팅·임베딩 세 모델 설정을 전제로 서버를 띄우고, 임베딩만 빼고 시작하는 옵션은 없었다. 로컬에서 돌리는 길은 1·2차에서 막혔고, 빼는 길도 없다. 남은 건 임베딩도 밖으로 넘기는 것이었다.

[4차] 사내 Ollama에 임베딩 모델이 있나

두 LLM 서버에 어떤 모델이 있는지 물어봤다.

curl -s http://<LLM 서버 A>:11434/api/tags | jq '.models[].name'
curl -s http://<LLM 서버 B>:11434/api/tags | jq '.models[].name'

둘 다 bge-m3, qwen3-embedding 같은 임베딩용 모델을 갖고 있었다. 자동완성도 서버 A가 맡고 있으니, 의존하는 서버를 늘리지 않으려고 서버 A의 bge-m3:latest를 쓰기로 했다.

[5차] 세 모델 모두 원격으로

config.toml의 세 섹션을 모두 원격(http)으로 채웠다.

[model.completion.http]
kind = "openai/completion"
model_name = "qwen2.5-coder:14b"
api_endpoint = "http://192.168.x.x:11434/v1"   # LLM 서버 A
prompt_template = "<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>"

[model.chat.http]
kind = "openai/chat"
model_name = "qwen3-coder:latest"
api_endpoint = "http://192.168.x.x:11434/v1"   # LLM 서버 B

[model.embedding.http]
kind = "openai/embedding"
model_name = "bge-m3:latest"
api_endpoint = "http://192.168.x.x:11434/v1"   # LLM 서버 A

재기동하자 CUDA 에러 없이 떴고, curl localhost:8080도 응답했다. 크래시 루프가 끝났다.

이 설정에서 두 군데를 짚고 넘어간다.

  • kind는 ollama/*가 아니라 openai/*. Ollama는 자체 API와 OpenAI 호환 API(/v1)를 둘 다 연다. /v1로 붙일 거면 kind도 OpenAI 쪽이어야 한다.
  • api_endpoint에 남의 Docker 별칭을 쓰지 않는다. 처음 받은 채팅 모델 설정 예시에는 주소가 http://ollama:11434/v1로 돼 있었다. ollama는 그 설정을 만든 쪽의 docker-compose 네트워크 안에서만 통하는 서비스 이름이라, 우리 서버에서는 아무 의미가 없다. 실제로 닿는 IP로 바꿨다.

그 뒤 — 임베딩 서버가 없어져도 떴다

한 달쯤 뒤(8월 20일) Tabby를 세 대로 나눌 때, 이 임베딩 서버 주소는 이미 응답하지 않고 있었다. 새 인스턴스들은 기존 config.toml을 복사해 썼고(자동완성·채팅은 그사이 vLLM으로 바뀌어 있었다), 그 안의 임베딩 주소도 죽은 채로 따라갔다. 그런데도 세 대 모두 잘 떴고, 자동완성과 채팅도 문제없었다. 코드 인덱싱(임베딩)은 쓰지 않고 있고 자동완성·채팅에는 필요 없어서, 이 임베딩 구성은 나중에 정식으로 폐기했다.

돌아보면 5차에서 넣은 임베딩 설정이 실제로 한 일은 “임베딩을 쓸 수 있게 한 것”보다 “Tabby가 CUDA 전용 실행기를 로컬에서 띄우지 않게 막은 것”에 가까웠다. 3차에서 “세 모델이 다 있어야 뜬다”고 정리했는데, 더 정확히 말하면 임베딩 설정이 비어 있으면 로컬 기본값을 띄우려다 죽는다는 쪽이다.

마치며

이렇게 띄운 서버로 사내 파일럿을 시작했고, 그때 쓴 온보딩 가이드 이야기는 따로 남겼다. 자동완성이 정작 안 뜨던 문제는 다음 글에서 다룬다.

진짜로 배운 것

  1. libcuda.so.1 에러는 “임베딩에는 GPU가 필요하다”는 뜻이 아니었다. 임베딩 모델은 작아서(nomic-embed-text 137M, bge-m3 567M 정도) CPU로도 충분히 돈다. 문제는 Tabby 이미지에 들어 있는 임베딩 실행기가 CUDA 전용으로 빌드돼 있다는 구현이었다. 로그에 찍힌 라이브러리 이름을 요구 사항으로 읽으면 결론이 뒤집힌다.
  2. 같은 서버라도 붙는 입구에 따라 설정이 갈린다. Ollama를 /v1로 붙이면 kind는 openai/*다.
  3. 남이 준 설정의 호스트 이름은 “어디서 해석되는 이름인가”부터 본다. Docker 네트워크 안의 서비스 이름은 그 네트워크 밖에서는 아무것도 가리키지 않는다.

사내 코딩 어시스턴트 카테고리의 글