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차에서 “세 모델이 다 있어야 뜬다”고 정리했는데, 더 정확히 말하면 임베딩 설정이 비어 있으면 로컬 기본값을 띄우려다 죽는다는 쪽이다.
마치며
이렇게 띄운 서버로 사내 파일럿을 시작했고, 그때 쓴 온보딩 가이드 이야기는 따로 남겼다. 자동완성이 정작 안 뜨던 문제는 다음 글에서 다룬다.
진짜로 배운 것
libcuda.so.1에러는 “임베딩에는 GPU가 필요하다”는 뜻이 아니었다. 임베딩 모델은 작아서(nomic-embed-text137M,bge-m3567M 정도) CPU로도 충분히 돈다. 문제는 Tabby 이미지에 들어 있는 임베딩 실행기가 CUDA 전용으로 빌드돼 있다는 구현이었다. 로그에 찍힌 라이브러리 이름을 요구 사항으로 읽으면 결론이 뒤집힌다.- 같은 서버라도 붙는 입구에 따라 설정이 갈린다. Ollama를
/v1로 붙이면kind는openai/*다. - 남이 준 설정의 호스트 이름은 “어디서 해석되는 이름인가”부터 본다. Docker 네트워크 안의 서비스 이름은 그 네트워크 밖에서는 아무것도 가리키지 않는다.