헤드리스 모드¶
"헤드리스 모드"는 그래픽 사용자 인터페이스(GUI)에 의존하지 않고, Backend.AI GO를 주로 백그라운드 서비스나 서버로 실행하는 방식을 말합니다. 이는 유휴 장비를 전용 추론 서버로 설정하거나 원격에서 애플리케이션을 관리할 때 특히 유용합니다.
개념¶
Backend.AI GO는 데스크톱 애플리케이션으로도 제공되지만, 핵심 런타임은 공용으로 설계되어 있습니다:
- 공용 Rust 런타임: 모델 추론, 프로세스 오케스트레이션, Management API, Continuum Router를 처리합니다.
- 데스크톱 전송 계층: 내장 WebView에서 Tauri IPC를 사용합니다.
- 헤드리스 전송 계층:
aigo-server가 제공하는 REST 및 SSE, 그리고 HTTP로 제공되는 WebUI를 사용합니다.
즉, 실제 데스크톱 통합(시스템 트레이, 네이티브 창 등)이 필요한 기능을 제외하면, 헤드리스 WebUI와 데스크톱 UI는 동일한 런타임 로직을 실행합니다.
운영 방법¶
시스템 트레이 (System Tray)¶
가장 기본적인 "헤드리스 스타일" 운영은 메인 창을 닫는 것입니다. * 기본적으로 창을 닫으면 Backend.AI GO는 시스템 트레이(macOS의 경우 메뉴 막대)로 최소화됩니다. * 이 상태에서도 API 서버와 모델 추론 프로세스는 백그라운드에서 계속 실행됩니다.
CLI 제어¶
함께 제공되는 aigo CLI를 사용하면 창을 열지 않고도 애플리케이션을 관리할 수 있습니다.
자세한 내용은 CLI 참조(CLI Reference) 문서를 확인하세요.
전용 헤드리스 서버 (aigo-server)¶
Backend.AI GO는 독립 실행형 헤드리스 바이너리를 제공합니다:
이 모드에서는 다음과 같이 동작합니다:
aigo-server의 dependency graph에tauricrate가 포함되지 않습니다.- Management API가 주요 제어 평면이 됩니다.
- WebUI는 Tauri IPC 대신 HTTP/SSE로 연결됩니다.
- 모델 풀, 라우터 관리, 스케줄링, 에이전트, 메모리, provider/runtime coordination은 데스크톱 앱과 동일한 공용 런타임 매니저를 재사용합니다.
원격 접속 (서버 모드)¶
로컬 컴퓨터를 다른 사용자를 위한 헤드리스 노드로 전환하려면 aigo-server를 외부 주소에 바인드합니다. Management API 기본 포트는 8001입니다.
명시적인 bind 주소에는 --host를 사용합니다. 현재 플래그는 aigo-server --help에서 확인하고, 호스트 방화벽에는 필요한 포트만 허용하세요.
Continuum Router는 Management API와 별개이며 자체 리스너를 가집니다. 기본값은 Unix 도메인 소켓으로만 로컬 클라이언트와 통신하는 것이고(Continuum Router & API 참고) TCP 포트는 열지 않으므로, 방화벽에서 라우터용으로 열어야 할 포트도 없습니다. v1.13부터는 TCP 리스너를 열 수 있습니다.
--router-tcp(환경 변수 AIGO_ROUTER_TCP, 설정 키 [router] api_server_enabled)는 OpenAI 호환 /v1 표면을 TCP로도 노출합니다. 포트는 --router-port(환경 변수 AIGO_ROUTER_PORT, 설정 키 [router] api_server_port)로 지정하며, 지정하지 않으면 settings.json에 저장된 포트를 쓰고 새로 설치한 경우 39080입니다. --external을 함께 주지 않으면 127.0.0.1에 바인드하고, 함께 주면 0.0.0.0에 바인드하면서 라우터가 /v1에서 API 키를 요구하기 시작합니다. 추론 API에 접근할 수 있는 주체을 참고하세요. 같은 세 가지 설정은 WebUI의 API 페이지에도 있고, 어느 쪽으로 바꾸든 저장되므로 재시작해도 유지됩니다.
알아둘 점이 둘 있습니다. --router-port는 시작 시 포트 충돌 검사와 배너에도 계속 쓰이지만, 이제 둘 다 없는 리스너를 있다고 말하지 않습니다. 배너는 리스너를 명시적으로 켰을 때만 URL을 적고, 포트가 사용 중이어서 시작을 거부하는 것도 그때뿐입니다. 그 외에는 라우터가 그 포트를 바인드하지 않을 수도 있기 때문입니다. 그리고 설정 파일의 [router] api_server_enabled는 API 페이지에서 저장한 값보다 우선하지만, --external은 리스너를 열기만 합니다. 끄는 형태가 없는 opt-in 플래그이므로, 이미 열린 리스너를 닫으려면 UI에서 끄거나 설정을 지워야 합니다.
이제 다른 Backend.AI GO 인스턴스, WebUI, API 클라이언트가 호스트 주소에 연결할 수 있습니다.
웹 UI¶
배포되는 aigo-server 바이너리는 웹 UI를 내장하고 있으며, REST API와 /api/docs가 차지하지 않은 모든 경로에서 이를 제공합니다. 패키지 설치본은 이를 위해 아무 설정도 필요하지 않습니다. deb, systemd 유닛, 컨테이너 이미지 모두 AIGO_STATIC_DIR을 설정하지 않은 채로 두며, UI는 그대로 동작합니다.
deb가 레거시 1.12 시스템 설치 프로그램이 남긴 정확한 활성 할당 AIGO_STATIC_DIR=/var/lib/aigo/static을 발견하면, 설치 후 단계에서 해당 줄을 주석 처리해 내장 프런트엔드가 적용되게 합니다. 다른 값은 모두 보존합니다. /var/lib/aigo/static이 의도적으로 지정한 사용자 빌드라면 업그레이드 후 다시 활성화하십시오. 외부 디렉터리가 내장 번들을 재정의할 때마다 두 빌드가 서로 독립적으로 달라질 수 있으므로 시작 로그는 해당 경로와 aigo-server 버전을 함께 경고합니다.
이 동작을 바꾸는 설정은 두 가지입니다. --static-dir(환경 변수 AIGO_STATIC_DIR, 설정 키 server.static_dir)은 내장 UI 대신 디스크에 있는 직접 빌드한 프런트엔드를 제공합니다. 존재하지 않는 경로를 지정하면 서버가 기동을 거부합니다. 컨테이너나 유닛 파일 환경에서 이는 대개 마운트가 이루어지지 않았다는 뜻이고, 그 자리에 내장 빌드를 조용히 제공하면 불일치가 가려지기 때문입니다. 디렉터리는 있지만 index.html이 없는 경우는 내용 문제이므로 경고만 남기고 내장 UI가 계속 응답합니다. server.enable_static = false는 두 경로를 모두 끄고 REST API와 /api/docs만 남깁니다. 이때 시작 배너는 브라우저로 접속하라고 안내하는 대신 /api/docs를 가리킵니다.
두 경우 모두 /login, /models, /chat 같은 딥 링크는 단일 페이지 애플리케이션으로 연결됩니다. /api/, /admin/, 그리고 레거시 /setup 접두사는 예외입니다. 매칭되지 않은 경로는 JSON 404를 반환하므로, JSON을 기대하는 클라이언트가 HTML을 받는 일은 없습니다.
최초 실행 로그인 (aigo-server)¶
기존 데이터 없이 aigo-server가 처음 시작되면 http://<host>:8001에서 초기 설정 화면을 제공합니다. 브라우저로 접속한 뒤 관리자 계정의 사용자 이름과 비밀번호를 설정하면 즉시 로그인됩니다. 이후 방문 시에는 로그인 화면이 표시됩니다.
서버가 로컬이 아닌 인터페이스에 바인딩되어 있으면 최초 설정에 설정 토큰도 필요합니다. per-user 관리 서비스는 임의의 AIGO_SETUP_TOKEN을 생성해 서버를 시작하고, 사용자의 브라우저를 /#setupToken=<token> 형태로 열어 설정 폼이 자동으로 채워지게 해야 합니다. 운영자는 aigo-server 시작 로그에 한 번 출력되는 설정 토큰을 직접 복사할 수도 있습니다. localhost 전용 최초 설정에는 이 토큰이 필요하지 않습니다.
SDK 클라이언트(curl, Python 등)는 로그인 후 API > 액세스 키에서 생성한 액세스 키와 X-API-Key 또는 Authorization: Bearer 헤더를 사용합니다.
문제 해결¶
systemd 환경에서 모델 디렉터리 옮기기¶
config.toml의 [models] directory와 환경 파일의 AIGO_MODELS_DIR은 모델 저장 위치를 옮깁니다. deb와 install.sh --system은 /etc/aigo/environment에 AIGO_MODELS_DIR=/var/lib/aigo/models를 활성 상태로 넣어 배포하므로 새로 설치하면 모델이 그곳에 쌓입니다. rpm은 이 파일 자체를 설치하지 않으므로, /usr/share/doc/aigo-server/examples/environment의 템플릿을 직접 복사하기 전까지는 서비스 계정의 홈 디렉터리 기본값을 씁니다. 모델을 다른 곳으로 옮기려면 한 단계가 더 필요합니다. 유닛이 애플리케이션 설정을 읽지 않기 때문입니다.
[engines] directory와 AIGO_ENGINES_DIR은 엔진에 대한 같은 설정이고 같은 방식으로 동작합니다. 지정한 디렉터리로 엔진을 내려받고 그곳에서 불러옵니다. deb와 install.sh --system은 AIGO_ENGINES_DIR=/var/lib/aigo/engines도 활성 상태로 배포하며, 이 설정이 없던 패키지에서 올라오는 업그레이드는 홈 디렉터리 기본값에 있던 기존 엔진 트리를 서비스를 멈춘 채 그 경로로 한 번 옮깁니다. 환경 파일이든 [engines] directory든 이미 다른 디렉터리를 직접 지정해 두었다면 그 값도 트리도 건드리지 않습니다. 업그레이드가 두 경로를 모두 적어 알리고 이름 바꾸기는 사용자에게 맡깁니다. 지정한 경로가 패키지 설정 시점에 아직 마운트되지 않은 마운트 지점일 수 있기 때문입니다. 시작 배너와 로그는 서버가 실제로 결정한 모델·엔진 디렉터리를 찍으므로, 설정에서 추측하지 말고 그쪽을 보십시오.
이미 엔진이 들어 있는 디렉터리를 옮길 때는 서비스를 멈추고 이름만 바꾸면 됩니다. 패키지 업그레이드가 하는 일이 바로 이것입니다. 변수를 다른 곳으로 지정하는 경우, 또는 업그레이드가 이동을 건너뛰었다고 알린 경우에는 직접 하십시오.
sudo systemctl stop aigo-server
sudo mv /var/lib/aigo/.local/share/ai.backend.go/engines /var/lib/aigo/engines
sudo chown -R aigo:aigo /var/lib/aigo/engines
sudo systemctl start aigo-server
installed.json은 각 엔진의 절대 경로를 기록하므로, 그대로 두면 이름을 바꾼 뒤 모든 기록이 사라진 디렉터리를 가리키게 됩니다. 다음 기동 때 서버는 기록된 디렉터리가 없어진 항목을 현재 엔진 디렉터리 아래의 대응 디렉터리로 다시 연결하고, 옮긴 항목마다 로그를 한 줄 남긴 뒤 고쳐진 레지스트리를 디스크에 씁니다. 그래서 같은 복구가 매번 반복되지 않습니다. 새 경로 아래에 대응하는 디렉터리가 없는 항목은 다른 엔진에 잘못 연결하지 않고 그대로 둡니다.
패키지 마이그레이션은 네 경우에 이동을 건너뛰며, 어느 경우에도 아무것도 지우지 않습니다. /etc/aigo/environment나 config.toml의 [engines] 아래에 이미 다른 디렉터리를 직접 지정해 둔 경우, 두 디렉터리가 모두 데이터를 담고 있을 때, 대상이 운영자가 만든 심볼릭 링크일 때, 서비스가 돌고 있는데 멈추지 못했을 때입니다. 건너뛸 때 두 경로를 모두 적어 알리고, 그다음 기동에서도 같은 경고가 나옵니다. 디렉터리만 지정하고 엔진은 옮기지 않은 경우도 마찬가지로 두 경로를 모두 적은 경고가 나옵니다. 트리가 클 수 있고 두 경로가 서로 다른 파일시스템일 수 있어서, 어느 쪽도 서버가 대신 옮겨 주지는 않습니다. 이름을 바꾸기 전까지는 설치된 엔진이 없는 상태로 기동합니다. 경로는 절대 경로여야 하며, 상대 경로는 엔진 하위 시스템이 조용히 꺼진 채로 뜨지 않도록 기동 단계에서 거부합니다.
이 서비스는 ProtectSystem=strict로 동작합니다. ReadWritePaths=에 적힌 경로를 뺀 파일시스템 전체가 읽기 전용이 되고, 배포되는 유닛에 적힌 것은 /var/lib/aigo와 /var/log/aigo 둘뿐입니다. 그 밖의 디렉터리는 소유자가 누구든 읽기 전용으로 남으므로 chown으로는 해결되지 않습니다. 서비스는 RequiresMountsFor=로 그 두 경로를 담은 마운트 뒤에 순서를 잡는데, 별도 볼륨으로 옮긴 디렉터리는 그 순서도 받지 못합니다.
유닛을 직접 고치지 말고 drop-in으로 둘 다 부여합니다. 패키지가 업그레이드 때 유닛을 교체하기 때문입니다.
# /etc/systemd/system/aigo-server.service.d/10-storage.conf
[Unit]
RequiresMountsFor=/mnt/models
[Service]
ReadWritePaths=/mnt/models
drop-in이 부여하는 것은 샌드박스 예외뿐이고, 그 위에 일반 권한이 그대로 적용됩니다. 이 서비스는 aigo 사용자로 동작하고 항목에는 - 접두사가 없으므로, 재시작 전에 경로가 존재하고 그 사용자가 쓸 수 있어야 합니다. 아직 없는 경로는 ExecStart 이전 네임스페이스 설정 단계에서 226/NAMESPACE로 실패하는데, 이는 서버가 아니라 systemd가 내는 오류입니다.
sudo mkdir -p /mnt/models
sudo chown aigo:aigo /mnt/models
sudo systemctl daemon-reload
sudo systemctl restart aigo-server
drop-in의 ReadWritePaths=는 유닛의 목록을 대체하지 않고 덧붙이므로 /var/lib/aigo와 /var/log/aigo는 유지됩니다.
--data-dir / AIGO_DATA_DIR이 /var/lib/aigo 밖을 가리킬 때도 같은 drop-in이 필요합니다. 이 설정은 애플리케이션 데이터 디렉터리 전체, 곧 설정과 액세스 키, 설치된 엔진, 모델 디렉터리의 기본 위치를 함께 옮깁니다.
drop-in으로 닿지 않는 디렉터리 셋
유닛에는 ProtectHome=true와 PrivateTmp=true도 있고, ReadWritePaths=는 둘 중 어느 것도 무력화하지 못합니다. systemd 257에서 실측한 결과입니다.
/home이나/root아래 경로는 drop-in을 넣든 넣지 않든 똑같이 실패합니다.ProtectHome=true가 그 트리를 서비스 안에서 빈 tmpfs로 바꾸기 때문입니다. 오류는ls로 보이는 디렉터리에 대해No such file or directory입니다. 디렉터리를 옮기거나 drop-in에ProtectHome=false를 더하십시오./tmp나/var/tmp아래 경로는 양방향으로 더 나쁩니다. drop-in이 없으면 서비스가 자기만의 사설/tmp에 쓰므로 아무것도 실패하지 않고, 서비스가 멈추면 파일이 사라져 호스트 경로는 비어 있습니다. 다른 어떤 것도 이를 알려주지 않으므로 서버가 기동 시 경고합니다.ReadWritePaths=만 넣으면 네임스페이스 설정 단계에서 아예 실패해226/NAMESPACE가 나고,PrivateTmp=false만 넣으면ProtectSystem=strict아래에서 그 경로가 읽기 전용으로 남습니다. 디렉터리를 옮기거나 drop-in에PrivateTmp=false와ReadWritePaths=를 함께 더하십시오.
어느 보호든 끄면 서비스가 닿을 수 있는 범위가 넓어지므로, 선택할 수 있다면 이 트리 밖의 경로가 더 나은 답입니다. 서버가 이 중 하나에 부딪히면 어느 지시자가 맞는지 알려줍니다.
읽기 전용 모델 저장소도 지원합니다
의도적으로 읽기 전용으로 둔 마운트에서 모델을 서빙하는 것은 정상 동작합니다. 서버는 기동 시 다운로드가 실패할 것이라는 경고를 남기고, 이미 있는 모델로 그대로 동작합니다. 치명적인 것은 아예 만들 수 없는 디렉터리뿐입니다.
systemd 환경에서 GPU 접근¶
패키지로 설치한 유닛에서 동작하는 aigo-server는 drop-in 없이는 GPU를 보지 못하며, 그 증상은 오해하기 쉽습니다. 엔진 페이지는 No GPU detected on this host라고 표시하지만 셸에서 직접 실행한 nvidia-smi는 정상이고, 장치 노드는 누구나 읽고 쓸 수 있습니다.
crw-rw-rw- 1 root root 195, 0 /dev/nvidia0
crw-rw-rw- 1 root root 195, 255 /dev/nvidiactl
crw-rw-rw- 1 root root 500, 0 /dev/nvidia-uvm
권한 문제도, 드라이버 문제도 아닙니다. 유닛에 설정된 PrivateDevices=true가 서비스에 의사 장치만 담긴 전용 /dev를 제공하므로, /dev/nvidia*, /dev/kfd, /dev/dri는 마운트 네임스페이스에 아예 존재하지 않습니다. 같은 명령을 두 가지 방식으로 실행하면 확인할 수 있습니다.
sudo systemd-run --quiet --wait --pipe --uid=aigo -p PrivateDevices=true /bin/sh -c 'nvidia-smi -L'
sudo systemd-run --quiet --wait --pipe --uid=aigo -p PrivateDevices=false /bin/sh -c 'nvidia-smi -L'
.deb, .rpm 패키지와 --system 설치 관리자는 아래 drop-in을 대신 설치해 줍니다. 다만 설치 시점에 식별 가능한 가속기가 있는 호스트에서만 설치합니다. NVIDIA 또는 ROCm 연산 노드가 있거나, 드라이버가 바인딩된 NVIDIA·AMD·Intel display 클래스 PCI 장치에 딸린 DRM render 노드가 있는 경우입니다. VM의 가상 디스플레이는 여기에 해당하지 않고, 드라이버가 올라오지 않은 카드도 마찬가지입니다. GPU가 없던 장비에 나중에 GPU를 추가했거나, 이미지로 설치한 뒤에 GPU를 붙였거나, 패키지를 먼저 설치하고 드라이버를 나중에 넣었다면 직접 넣어야 합니다.
# /etc/systemd/system/aigo-server.service.d/10-gpu.conf
[Service]
PrivateDevices=false
DevicePolicy=closed
SystemCallFilter=~@raw-io
DeviceAllow=char-nvidia rw
DeviceAllow=char-nvidia-frontend rw
DeviceAllow=char-nvidia-uvm rw
DeviceAllow=char-nvidia-caps r
DeviceAllow=char-drm rw
DeviceAllow=/dev/kfd rw
PrivateDevices=true는 세 가지 통제를 함께 켭니다. 전용 /dev 네임스페이스, cgroup의 DevicePolicy=closed, 그리고 @raw-io 계열 시스템 호출을 막는 seccomp 필터입니다. GPU 접근을 막는 것은 첫 번째 하나뿐이므로 drop-in은 그것만 끄고 나머지 둘을 다시 명시합니다. 그래서 위 허용 목록이 형식이 아니라 실제 경계가 됩니다. PrivateDevices=가 함께 적용하던 나머지는 이미 유닛에 그대로 적혀 있고 이 drop-in은 건드리지 않습니다. NoNewPrivileges=true, 그리고 CAP_NET_BIND_SERVICE 하나만 담은 CapabilityBoundingSet이 그것입니다. 즉 서비스에는 CAP_MKNOD도 CAP_SYS_RAWIO도 없으므로 목록에 없는 장치의 노드를 스스로 만들 수도 없습니다.
항목을 경로가 아니라 장치 클래스로 적은 것은 의도적입니다. char-nvidia는 /proc/devices를 통해 해석되므로 다중 GPU 호스트에서 /dev/nvidia0부터 /dev/nvidiaN까지를 한 줄로 덮습니다. char-nvidia-uvm과 char-nvidia-caps는 호스트마다 달라지는 NVIDIA의 동적 할당 major를 따라갑니다. char-drm은 ROCm, Vulkan, Intel SYCL 엔진이 사용하는 /dev/dri/renderD*를 덮고, /dev/kfd는 ROCm의 연산 노드로 클래스 이름이 없는 misc 장치라 경로로 적습니다. 이 호스트에 없는 장치 클래스를 가리키는 항목은 systemd가 무시합니다.
목록을 줄이더라도 char-nvidia-uvm은 남겨 두는 편이 좋습니다. 일부 드라이버에서는 이것 없이도 nvidia-smi -L이 GPU를 보고하므로 준비 상태 패널은 정상으로 보이지만, 모델을 올리는 순간 CUDA 컨텍스트 할당이 모두 실패합니다.
AMD와 Intel은 한 단계가 더 필요합니다: 파일 소유권
drop-in은 네임스페이스와 cgroup 장벽을 걷어냅니다. NVIDIA는 장치 노드가 0666이라 그것으로 충분하지만, ROCm과 Intel은 다릅니다. /dev/dri/renderD*와 /dev/kfd는 0660 root:render이고 aigo 서비스 계정은 그 그룹에 속해 있지 않으므로, 일반 파일 권한이 여전히 접근을 막습니다. 계정을 그룹에 넣고 다시 시작하세요.
이것을 대신 해 주지 않는 것은 의도적입니다. 배포되는 drop-in에 SupplementaryGroups=render를 넣으면 render 그룹이 없는 배포판에서 유닛이 216/GROUP으로 아예 실패하는데, 이는 고치려던 문제보다 나쁜 실패입니다. 먼저 getent group render로 확인하세요. 배포판에 따라 이 노드들에 video 그룹을 쓰기도 합니다.
헷갈리는 중간 상태에 주의하세요. drop-in을 넣고 나면 /dev/kfd가 다시 보이므로, 그룹이 아직 없어도 준비 상태 보고는 "장치 노드가 가려졌다"는 말을 멈추고 "No GPU detected on this host"로 되돌아갑니다.
두 상황을 구분하기
v1.13부터 준비 상태 보고는 두 경우를 구분합니다. "No GPU detected on this host"는 이제 호스트에 실제로 GPU가 없다는 뜻입니다. 하드웨어는 있는데 장치 노드에 닿지 못하는 경우에는 그렇게 표시하고, 드라이버가 아니라 샌드박스를 가리킵니다. 이 판단은 /proc/driver/nvidia/gpus/와 /sys/bus/pci/devices/ 아래의 PCI vendor 및 class를 읽어서 내리는데, PrivateDevices=는 둘 중 어느 쪽도 가리지 않습니다.
소켓 위치¶
aigo-server는 라우터와 모든 로컬 모델 서버에 Unix 도메인 소켓으로 연결하며, 소켓은 데이터 디렉터리가 아니라 짧은 사용자별 런타임 디렉터리에 만들어집니다(로컬 소켓 위치 참고). systemd 환경에서는 기본 유닛이 RuntimeDirectory=aigo를 선언하므로 systemd가 서버 시작 전에 /run/aigo를 만들고 $RUNTIME_DIRECTORY로 내보내며, 서버는 /run/aigo/sockets에 소켓을 둡니다. 유닛을 직접 작성한다면 이 지시어를 유지하세요. 이 지시어가 없고 $XDG_RUNTIME_DIR를 제공하는 로그인 세션도 없으면 서버는 /tmp/aigo-{uid}를 사용합니다(PrivateTmp=true이면 유닛 전용 /tmp이며, 소켓 용도로는 문제가 없습니다).
서버는 이 런타임 루트의 소유자와 접근 권한도 함께 검사합니다. aigo 사용자 소유여야 하고 다른 사용자의 쓰기 접근은 없어야 하는데 기본 유닛의 RuntimeDirectoryMode=0755는 이미 이 조건을 만족합니다. 권한을 더 넓게 연 커스텀 유닛이나 다른 사용자와 공유하는 디렉터리를 가리키는 $XDG_RUNTIME_DIR는 이유가 로그에 남은 채 건너뛰어집니다. 서버는 그 자리에서 멈추지 않고 위 순서의 다음 후보로 넘어갑니다.
/tmp에 쓸 수 없고 런타임 디렉터리도 없는 컨테이너 이미지에서는 루프백 TCP만 사용합니다. 컨테이너화된 서버의 클라이언트는 같은 커널에 있지 않으므로 이 동작이 오히려 적합합니다. 시작 로그에는 선택된 디렉터리, 또는 거부된 모든 후보와 그 이유가 기록됩니다.
업로드된 파일 위치¶
기본 포함된 continuum-router는 /v1/files에서 OpenAI 호환 Files API를 제공하며, 클라이언트가 업로드한 파일을 디스크에 보관합니다. aigo-server는 그 위치를 절대 경로로 라우터 설정에 기록합니다. 생성된 router_config.yaml 옆의 router-files 디렉터리이며, --system 설치에서는 /var/lib/aigo/.local/share/ai.backend.go/router-files입니다. files.enabled와 files.storage_path는 저장할 때마다 다시 기록되므로 router_config.yaml에서 직접 고친 값은 유지되지 않습니다.
v1.13 이전에는 files: 항목을 아예 쓰지 않았고, 라우터는 자체 기본값 ./data/files를 사용했습니다. 이 상대 경로는 systemd가 서비스에 주는 작업 디렉터리를 기준으로 해석되는데 그 값이 /이고, ProtectSystem=strict가 /를 읽기 전용으로 만들므로 파일 저장소 초기화가 매 기동마다 실패했고 그 부팅 동안 Files API는 비활성 상태였습니다.
ERROR continuum_router::server::init: Failed to initialize File service: storage error: failed to create storage directory ./data/files: Read-only file system (os error 30). File resolution will be disabled.
배포되는 유닛에는 WorkingDirectory=/var/lib/aigo도 추가되었습니다. 이 경로는 이미 유닛의 ReadWritePaths=에 들어 있으므로, 라우터가 해석하는 다른 상대 경로도 / 아래가 아니라 쓰기 가능한 위치에 놓입니다. 유닛을 직접 작성한다면 둘 다 유지하세요. 절대 경로 files.storage_path는 생성된 설정에서 오고, 작업 디렉터리는 ReadWritePaths=가 허용하는 경로여야 합니다.
바뀌지 않는 것이 둘 있습니다. Files API는 files 스코프를 가진 Authorization: Bearer <key>를 요구합니다. 생성된 설정에 이제 라우터 API 키가 들어가지만(아래 참고) 어떤 액세스 키에도 이 스코프를 주지 않으므로, /v1/chat/completions가 열려 있는 환경에서도 /v1/files는 여전히 401을 반환합니다. 이번 변경으로 달라진 것은 파일 저장소가 초기화되고 라우트가 등록된다는 점입니다. 그리고 업로드된 파일은 아무것도 지우지 않습니다. 라우터의 retention_days 기본값이 0이라 파일이 영구 보관되므로, 위 디렉터리는 직접 정리하기 전까지 계속 커집니다.
추론 API에 접근할 수 있는 주체¶
같은 모델로 통하는 문이 둘 있고, 보호 방식이 서로 다릅니다.
Management API의 추론 프록시(8001 포트의 /api/v1/inference/*)는 액세스 키를 요구하고 스코프까지 확인합니다. 모델 목록에는 Inference Read, 응답 생성에는 Inference Write가 필요합니다. 액세스 키 페이지가 말하는 문이 이쪽이고, 스코프 이름 그대로 동작합니다.
라우터 자체의 OpenAI 호환 표면(/v1/models, /v1/chat/completions)이 나머지 한 문입니다. aigo-server는 라우터에 유닉스 도메인 소켓만 주고 TCP 포트는 열지 않으므로, 기본 설치에서는 소켓 파일의 권한이 /v1을 지키는 유일한 장치입니다. 여기에 도달하려면 이미 그 파일을 열 수 있는 계정이 그 머신에 있어야 합니다. 소켓은 소켓 위치에서 설명한 런타임 디렉터리에 있고, 배포되는 유닛은 이를 aigo 사용자 소유의 /run/aigo로 만듭니다.
v1.13부터 aigo-server는 라우터 설정에 api_keys 섹션도 씁니다. 액세스 키 페이지가 발급한 키를 라우터도 알게 하기 위해서입니다.
api_keys:
mode: permissive
api_keys_file: /var/lib/aigo/.local/share/ai.backend.go/router-api-keys.yaml
router-api-keys.yaml은 router_config.yaml 옆에 놓이며, Inference Read 또는 Inference Write를 가진 액세스 키마다 항목이 하나씩 들어갑니다. 이 파일에는 키 비밀값이 평문으로 들어갑니다. 라우터가 들어오는 Authorization: Bearer와 비교하는 형태가 평문이기 때문입니다. Linux와 macOS에서는 0600으로 생성되고, Windows에서는 router_config.yaml과 마찬가지로 데이터 디렉터리의 ACL을 그대로 물려받습니다. 그리고 그 설정 파일에는 이미 제공자 API 키가 들어 있습니다. 두 파일을 똑같이 다루세요. 데이터 디렉터리 밖으로 복사하지 말고, 권한을 넓히지 마세요. api_keys의 두 필드는 라우터가 시작할 때마다, 그리고 설정을 저장할 때마다 다시 쓰이므로 router_config.yaml에서 직접 고쳐도 남지 않습니다.
모드는 바인드 주소를 따라 정해지며, 그 외에 이를 바꾸는 설정은 없습니다. 소켓이나 루프백 TCP 리스너는 permissive가 됩니다. 유효한 키를 제시한 요청은 인식하고 사용량을 그 키에 귀속시키지만, 키가 없는 요청도 그대로 처리합니다. 그 리스너에 닿는다는 것 자체가 이미 이 머신의 계정을 가졌다는 뜻이기 때문입니다. 잘못된 키를 제시한 요청도 거부되지 않고 처리됩니다. 라우터 입장에서 알 수 없는 토큰과 토큰 없음은 같습니다.
다른 호스트에서 닿을 수 있는 리스너는 blocking이 됩니다. --router-tcp --external로 띄우거나 API 페이지의 "외부 접근 허용"을 켜면 이렇게 되고, 이는 따로 끌 수 있는 스위치가 아닙니다. 그런 리스너에 permissive를 쓰면 추론 API를 인증 없이 공개하게 되고, 그것이 바로 이 절이 다루는 문제입니다. blocking에서 /v1은 유효한 키가 없으면 401, 잘못되었거나 만료된 키에도 401을 반환하고, /health는 계속 열려 있으며, Claude Code 연동이 사용하는 /anthropic/v1/messages를 포함해 라우터가 제공하는 모든 경로가 대상이 됩니다. 그러니 리스너를 열기 전에 Inference Read 또는 Inference Write를 가진 액세스 키를 발급해서 클라이언트에 전달하세요.
여기 포함된 라우터 버전이 하지 않는 일이 둘 있습니다. /v1에서 읽기와 쓰기를 구분하지 않으므로 Inference Read만 가진 키로도 응답 생성을 호출할 수 있습니다. 스코프는 사용량 귀속과 라우터가 경로별 검사를 갖추게 될 때를 위해 기록됩니다. 그리고 루프백이나 Unix 소켓에 대한 예외가 없습니다. blocking은 모든 전송 방식에서 차단한다는 뜻입니다. 그래서 앱은 자체 크레덴셜 aigo-internal-inference를 가지며, router-api-keys.yaml에서 비밀값이 아니라 환경 변수 자리표시자 형태로 보입니다. 설치마다 새로 만들어져 라우터 프로세스 시작 시 주입되고, Management API의 추론 프록시와 에이전트 런타임, 데스크톱 채팅이 이를 제시합니다. 덕분에 리스너를 켜도 제품 자신의 추론이 멈추지 않습니다. 이 항목은 지우지 말고, 클라이언트에 주지도 마세요.
router_config.yaml에서 mode를 직접 고쳐도 오래가지 않습니다. api_keys 블록은 라우터가 시작할 때마다, 그리고 설정을 저장할 때마다 처음부터 다시 쓰이므로 그 수정은 길어야 다음 재시작까지만 유지되고, 서버는 교체했다는 사실을 로그에 남깁니다. 대신 리스너 설정을 바꾸세요.
v1.13 이전에 다른 머신에서 /v1에 접근하는 유일한 방법이던 소켓 수동 브리지도 여전히 가능하지만, 여전히 인증이 없습니다.
# 소켓 파일 이름에는 인스턴스별 접미사가 붙습니다. 현재 경로는 API > 일반에
# 표시되고 시작 로그에도 기록됩니다.
socat TCP-LISTEN:39080,fork,reuseaddr UNIX-CONNECT:/run/aigo/sockets/continuum-router-<접미사>.sock
이런 브리지는 그 포트에 닿을 수 있는 모두에게 추론 API를 키 없이 공개합니다. 라우터가 보기에는 여전히 로컬 소켓 연결이므로 permissive가 그대로 적용됩니다. 파일 권한이라는 경계는 사라지는데 그 자리를 대신하는 것이 없습니다. 대신 --router-tcp를 쓰세요. 포트를 열면서 그 포트에 키를 요구하는 일이 한 번에 이루어집니다. 브리지를 유지한다면 그 포트 앞에 인증하는 리버스 프록시를 두거나, 신뢰하는 네트워크로 제한하거나, 둘 다 하세요.
키 변경은 라우터 Admin API로 실행 중인 라우터에 밀어 넣으므로, 액세스 키를 만들거나 교체하거나 비활성화하거나 삭제하면 보통 재시작 없이 적용됩니다. 다만 이 전달은 최선 노력입니다. 라우터가 꺼져 있거나 시작 중이거나 응답하지 않으면 변경은 키 파일에만 기록되고 라우터가 다음에 시작할 때 적용되며, 그 이유는 로그에 남습니다.
API 키 저장 위치¶
헤드리스 모드에서는 GUI가 필요한 OS 키체인 대신 암호화된 파일(encrypted_keys.json)에 API 키를 저장하며, 서버를 재시작해도 키는 그대로 유지됩니다. 이 파일이 저장소이고, 위에서 설명한 router-api-keys.yaml은 라우터가 필요로 하는 부분만 복사한 파생본이며 암호화되어 있지 않습니다. 라우터가 제시된 베어러 토큰을 값 자체와 비교해야 하기 때문입니다.