개발 서버에서 검증을 마친 스택(Mattermost 채널 봇 + 문서 RAG)을 온프레미스 운영서버에 처음부터 세우는 작업이었다. 절차는 이미 문서로 있었다. 그런데 서버에 처음 접속한 순간 계획서의 전제 두 개가 동시에 깨졌다.

1. 계획 — 순서를 강제하는 의존 관계

올릴 것은 다섯이었다. PostgreSQL(pgvector), 채팅 서버, 관리자 콘솔, 검색 MCP 서버, 그리고 에이전트 게이트웨이. 순서를 정하는 기준은 취향이 아니라 무엇이 무엇 없이는 못 도는가였다.

# 단계 다음 단계가 이것을 필요로 하는 이유
0 호스트 준비 (도커 네트워크 생성) 네트워크가 없으면 모든 compose가 network not found
1 PostgreSQL + pgvector 앱 전부의 데이터 저장소
2 채팅 서버 봇·채널을 만들려면 앱이 떠야 한다
3 터널에 공개 호스트명 추가 도메인이 확정돼야 SiteURL·인증 URL을 적을 수 있다
4 봇 계정 · 채널 생성 토큰과 채널 ID가 마지막 단계의 설정값이다
5 관리자 콘솔 마이그레이션이 RAG 스키마의 소유자
6 검색 MCP 그 스키마의 읽기 소비자. 순서를 뒤집으면 테이블 없음으로 죽는다
7 문서 인제스트·검색 검증 에이전트를 붙이기 전에 RAG가 답하는지 확인
8 에이전트 게이트웨이 위 전부에 의존

5→6이 특히 중요했다. 검색 MCP는 스키마를 만들지 않고 읽기만 한다. 먼저 띄우면 테이블이 없어서 죽고, 그 실패는 "MCP 설정이 틀렸나"로 오해하기 쉽다.

7을 8보다 앞에 둔 것도 의도적이다. 에이전트를 먼저 붙이면 봇이 엉뚱한 답을 할 때 원인이 LLM인지 RAG인지 분리가 안 된다.

도커 네트워크는 아무도 소유하지 않게 했다

compose에서 네트워크를 적는 방식이 둘이다. 만드는 쪽과 붙기만 하는 쪽.

# ① 만드는 쪽 (소유자) — up 할 때 없으면 생성한다
networks:
  app-net:
    name: app-net

# ② 붙는 쪽 (참여자) — 없으면 "network not found"로 즉시 실패한다
networks:
  app-net:
    external: true
    name: ${APP_NETWORK:-app-net}

소유자에게는 책임이 따라붙는다. 소유자가 docker compose down 하면 네트워크도 지운다. 다른 컨테이너가 붙어 있으면 도커가 거부해 주지만, 전부 멈춰 있는 상태에서 소유자만 내리면 조용히 사라진다. 그다음 다른 스택을 올리면 뜨지 않는다.

그래서 docker network create로 먼저 만들고 다섯 compose를 전부 참여자로 통일했다. 누락 위험은 절차 0단계에 한 줄 넣으면 사라지고, 그 대가로 어느 스택을 내려도 나머지가 무사하다.


2. 서버를 열자 전제 둘이 동시에 깨졌다

아키텍처가 달랐다

개발 서버는 x86_64였다. 운영서버는 ARM64였고, GPU가 붙은 통합 메모리 장비였다.

메모리 위험(계획의 위험 항목 하나)은 그 자리에서 사라졌다. 대신 이미지 가용성이라는 새 전제 조건이 생겼다. 각 컴포넌트가 arm64에서 도는지가 개별 관문이 됐다.

이미 추론 서버가 돌고 있었다

계획서에는 "호스트에 Ollama만 있다"고 적었는데, docker ps8일째 도는 vLLM 컨테이너가 있었다. OpenAI 호환 엔드포인트에 35B MoE 모델이 올라가 있었다.

이걸 발견한 순간 LLM 선택이 바뀌었다. 받아둔 12B 로컬 모델 대신 이미 떠 있는 것을 쓰기로 했고, Ollama는 임베딩 전용으로 남았다.

부수 효과가 컸다. 클라우드 OAuth를 안 쓰게 되면서 주말에 봇이 조용히 죽었다 — 1시간짜리 토큰을 6시간마다 갱신하는 스케줄러의 메커니즘이 통째로 무관해졌다. 대신 변경 시점을 우리가 정하지 않는 의존성이 생겼는데, 그 대가는 같은 날 청구됐다(5절).


3. 아키텍처 관문 네 개

arm64에서 도는지를 컴포넌트마다 따로 확인해야 했다. libc(musl/glibc) × 언어 런타임 × 배포 방식이 전부 달라, 하나가 통과했다고 다음을 낙관할 근거가 없다.

# 대상 경로 결과
1 pgvector Alpine 패키지 (musl, C 확장)
2 관리자 콘솔 npm 프리빌트 (glibc, 네이티브 애드온 3종)
3 검색 MCP pip 휠 (glibc, Python onnxruntime)
4 채팅 서버 도커 이미지 arm64 태그 없음

유일하게 실패한 것이 코드가 아니라 "배포자가 arm64 이미지를 안 만든 경우"였다. Team·Enterprise 전 태그를 조회해도 amd64뿐이었다.

해법은 QEMU 에뮬레이션(platform: linux/amd64 + binfmt)이었고, 실제로 동작했다. 다만 이 판정을 DB를 붙이기 전에 내리도록 순서를 바꿨다. QEMU의 위험은 "느리다"가 아니라 "안 뜬다"이고, Go 런타임은 시그널 처리가 많아 qemu-user에서 깨진 전례가 있다. 여기서 실패하면 이후 여섯 단계가 통째로 무의미해진다.

빌드는 통과했는데 이미지는 반쪽이었다 — 네이티브 애드온을 쓰는 Next.js 앱 도커화에서 onnxruntime-node가 musl 프리빌트를 배포하지 않아 Alpine을 못 쓴다는 제약을 다뤘다. 이번에 linux/arm64 glibc 프리빌트는 존재한다는 것이 확인됐다. 두 축은 독립이다.


4. 세 번의 오진 — 전부 "우리가 만들지 않은 전제"

증상 처음 짐작 실제
관리자 콘솔 로그인 불가 비밀번호 계정이 아예 없었음 → 이미지에 시드 소스 누락
봇이 채널에서 무응답 인가·토큰 팀 초대와 채널 초대를 혼동
에이전트의 provider 실패 설정·컨텍스트 추론 서버 컨테이너가 CUDA 크래시

셋 다 우리가 방금 만진 설정이 아니었다. 설정부터 고쳤다면 전부 엉뚱한 곳을 팠을 것이다.

4-1. 로그인 불가 — 원인이 셋 쌓여 있었다

빈 DB로 시작하니 로그인이 안 됐다. 순서대로 걷어내야 했다.

① 엔트리포인트가 시드를 돌리지 않는다. prisma migrate deploy만 하고 넘어간다. 환경변수에 관리자 계정 정보를 넣어도 아무 일도 일어나지 않는다. User 테이블이 비어 있었다.

② 시드를 돌리니 모듈을 못 찾는다.

Error: Cannot find module '../src/lib/password'
Require stack: - /app/prisma/seed.ts

런타임 이미지가 src/generated(Prisma 클라이언트)만 복사하는데, prisma/seed.ts../src/lib/password를 import한다. 이미지에 그 파일이 없었다.

흥미로운 건 이 이미지가 시드를 위해 dev 의존성(tsx)을 일부러 남겨뒀다는 점이다. 의도는 문서에 남았는데 소스 복사가 빠졌다. Dockerfile을 고쳐 src 전체를 싣는 것으로 해결했다.

③ 그러고도 안 됐다. 이건 단순 입력 오타였다.

1·2를 걷어내야 3이 보였다. 처음부터 "비밀번호 오타 아닌가"로 접근했다면 계정이 없다는 사실을 못 봤을 것이다.

이 버그가 여태 안 드러난 이유가 이 글의 주제이기도 하다. 컨테이너화 검증은 이미 계정이 있는 DB로 했다. 시드를 돌릴 이유가 없었다. 빈 상태로 처음부터 세우는 경로는 이번이 처음이었다.

4-2. 팀 멤버와 채널 멤버는 다르다

봇이 채널에서 무반응인데 DM은 정상이었다. 팀 메뉴의 Invite People팀 초대다. 봇은 대개 이미 팀 멤버라 "이미 멤버입니다"만 뜨고 채널에는 안 들어간다.

여기서 유용했던 게 DM 테스트다. DM은 채널 멤버십을 요구하지 않는다. DM이 되는데 채널이 안 되면, 인증·WebSocket·인가·LLM이 전부 정상이라는 증명이 된다. 후보가 하나로 좁혀진다.


5. 담당이 다른 컴포넌트가 죽으면 봇이 전부 죽는다

첫 채널 멘션 직후 모든 채널이 무응답이 됐다. 봇은 이 문구만 반복했다.

⚠️ The model provider failed after retries.

이 문구는 원인 여럿을 덮는 일반 경고다. 로그를 보니 Connection refused였고, docker ps에 vLLM이 없었다.

exitCode=0이 사인을 가렸다

Exited (0) 4 minutes ago
container die ... exitCode=0

정상 종료로 보여 "누군가 docker stop 했다"고 결론지었다. 틀렸다.

(EngineCore pid=137) RuntimeError: err 1 != CUDA_SUCCESS :: shimCuLaunchKernelEx(...)
                     code: CUDNN_STATUS_EXECUTION_FAILED_CUDA_DRIVER
(APIServer  pid=1)   EngineDeadError: EngineCore encountered an issue.
(APIServer  pid=1)   Shutting down / Finished server process [1]

멀티프로세스 컨테이너에서 종료 코드는 PID 1의 것이다. 여기서 PID 1은 API 서버였고, 엔진의 죽음에 정상적으로 반응한 것이다. 진짜로 죽은 것은 별도 프로세스였다.

docker logs를 안 봤다면 담당자에게 "누가 껐나요"를 묻고 "안 껐다"는 답을 받으며 왕복했을 것이다.

로그를 더 뒤지니 재발성이었다

같은 크래시가 2주 전에 이미 두 번 있었다. 우리 스택이 존재하기도 전이다. 우리 요청은 트리거일 수는 있어도 원인이 아니었다.

그리고 단서가 하나 더 나왔다. 기동 로그에 이 GPU의 아키텍처가 sm121로 찍히는데, 크래시 함수는 fp8_gemm_sm100이었다. 다른 세대용 커널이 디스패치되고 있었다. 오류 코드도 CUDA_ERROR_INVALID_VALUE(커널 실행 인자 무효)로 이 가설과 맞았다.

우리 쪽에서 통제할 수 있는 것

추론 엔진은 구축·운영 담당이 달라 우리가 단독으로 고칠 수 없었다. 협의 없이 당장 할 수 있는 건 요청 크기를 줄이는 것뿐이었다.

크래시를 맞은 요청은 프롬프트 20,148 토큰max_tokens=65536이었다. 에이전트 로그에는 tokens=~5,728이라 적혀 있었는데 실제와 3.5배 차이가 났다. 에이전트가 보고하는 토큰 수로 용량을 계산하면 안 된다.

max_tokens를 4096으로 내리고, 컨테이너의 재시작 정책을 nounless-stopped로 바꿨다 (docker update는 컨테이너를 재생성하지 않는다).

근본 해결이 아니라 발현 조건을 피하는 완화다. 그리고 이 장애는 모니터링이 아니라 봇이 답하지 않는다는 사실로 알았다. 같은 팀 안이라도 변경 일정이 따로 도는 의존성은 함정 하나를 없애는 대신 다른 함정을 만든다 — 통보 경로를 먼저 정해야 했다.

곁가지 — 숨은 유료 폴백

주 모델이 죽자 로그에 이런 경고가 떴다.

Auxiliary client: PAID lane engaged for auxiliary task —
OpenRouter fallback model '...' is not a :free SKU and may incur real spend.

에이전트는 제목 생성 같은 부가 작업에 별도의 보조 클라이언트를 쓰고, 주 모델이 죽으면 유료 경로로 넘어가려 한다. 지금은 자격증명이 없어 실패하지만 누군가 키를 넣는 순간 조용히 과금된다. 설정으로 선제 차단했다.


6. 도구를 붙여도 모델이 쓰지 않는다

RAG MCP를 연결했는데, "사내 규정에서 찾아줘" 같은 힌트가 없으면 일반 지식으로 답했다. api_calls=1이 그 신호다. 툴을 호출하면 최소 2회가 된다.

원인은 에이전트의 구조에 있었다. 툴 스키마를 전부 프롬프트에 넣지 않고 tool_search로 찾아 쓴다. 모델이 두 단계를 밟아야 한다.

① "문서 검색 도구가 있겠다"고 판단   ← 힌트가 없으면 여기서 멈춘다
② tool_search 로 찾아서 호출

페르소나 파일에 판단 기준(어떤 주제일 때 반드시 검색할지, 도구 이름은 무엇인지)을 명시하자 힌트 없이 호출하기 시작했다. 도구 이름을 지목하니 tool_search 단계도 건너뛰어 응답이 빨라졌다.

지시를 넣을 자리가 둘이고 역할이 다르다.

위치 언제 읽히나 무엇을 결정하나
MCP 도구 설명 도구를 찾은 뒤 어떻게 쓸지, 어떻게 인용할지
페르소나 파일 매 턴, 프롬프트 앞 찾아볼 생각을 할지

MCP 서버의 도구 설명에는 이미 "규정 질문에는 반드시 이 도구를 먼저 호출하라"가 들어 있었다. 하지만 찾지 않으면 읽히지 않는다. 둘은 중복이 아니라 역할 분담이다.


7. 이번 세션 최대의 발견 — 세션이 툴 호출을 억제한다

페르소나를 고쳤는데도 특정 대화에서는 여전히 툴을 안 불렀다. 게이트웨이를 재시작해도 같았다.

단서는 봇이 무심코 흘린 말이었다.

"이미 세 번 답변드렸는데 계속 같은 질문을 하시는 것 같은데… 혹시 특정 사건만 궁금하신 건가요?" 😅

답이 이미 대화 맥락에 있으면 모델은 도구를 부르지 않는다. 검색할 이유가 없으니까. 그래서 처음 한 번 잘못 답하면 그 세션 내내 재생산된다.

봇의 자기 보고("제 시스템 프롬프트에 그런 원칙은 없습니다")는 믿을 게 못 됐다. 모델의 자기 서술보다 부수적으로 흘린 증거가 정확했다.

그런데 비우는 것이 두 번 실패했다

시도 결과
/new (대화 초기화 명령) ❌ 안내 문구만 나오고 이력이 남는다
sessions delete (게이트웨이 실행 중) ❌ 삭제됐다고 출력하고도 다음 턴에 같은 ID로 되살아난다
게이트웨이 정지 → 삭제 → 재기동

세션을 내보내(sessions export) 확인하니 /new 전후가 한 파일에 이어져 있었고, 이후 턴이 이전 툴 결과를 그대로 재사용하고 있었다.

세션이 두 곳에서 관리된다. SQLite가 영속 저장소이고 게이트웨이 프로세스가 메모리에 사본을 들고 있다. 살아 있는 프로세스가 이긴다. DB 행을 지워도 다음 턴에 메모리의 이력이 다시 기록된다.

sessions delete성공했다고 말하는데 실제로는 안 되는 종류라 가장 위험하다. 정기 정리를 스케줄러에 걸 계획이라면 반드시 게이트웨이 재시작과 묶여야 한다.

다만 이게 항상 문제인 것은 아니다

같은 대화에서 같은 질문에 앞의 답을 재사용하는 것 자체는 정상이다. 1분 전에 검색한 것을 또 검색하면 느리고 비싸다. 문제가 되는 조건은 하나뿐이다.

두 질문 사이에 원본 문서가 바뀌었을 때

그러니 관건은 툴 호출 여부가 아니라 세션 수명이다. 대응은 세 층을 겹치는 것으로 잡았다. 문서 갱신 시 정리, 정기 prune(재시작과 묶어서), 그리고 페르소나에 "이전 답이 있어도 재확인" 한 줄.


8. 조사 도구가 거짓말한 두 사례

디버깅 중 도구 자체가 잘못된 답을 준 일이 두 번 있었다. 그 false를 믿었다면 없는 버그를 쫓았을 것이다.

① bash의 history expansion. 비밀번호에 !가 들어 있었는데 큰따옴표로 감싸 검증 명령을 보냈다.

-bash: !@: event not found

값이 깨져서 거짓 실패가 나왔다. 작은따옴표는 안전하고 set +H로 끌 수 있다. 고약한 건 대화형 셸에서만 발동한다는 점이다. 같은 명령을 스크립트로 돌리면 멀쩡하다.

/dev/tcp는 bash 전용이다. 포트 도달을 확인하려고 썼는데 컨테이너의 sh가 dash였다.

sh: 1: cannot create /dev/tcp/host.docker.internal/8000: Directory nonexistent

포트가 열려 있는데 CLOSED가 찍혔다.

실측이 코드와 어긋날 때는 대개 실측 설계가 잘못된 것이다. 실제로 한 번은 grep 하나면 끝날 것을 실험으로 확인하려다, 변수 두 개를 함께 바꿔 결론을 뒤집어야 했다.


9. 배운 것

① 증상 하나에 원인이 여러 개 쌓여 있으면, 위층을 걷어내야 아래층이 보인다. 로그인 실패는 원인이 셋이었고 순서를 지키지 않았다면 아래 둘을 못 봤다.

② 격리를 한 층에서만 하면 다른 층에서 샌다.

나눈 것 새는 지점
DB를 URL로 분리 도구 이름이 같으면 모델이 헷갈린다
네트워크를 분리 별칭이 같으면 DNS가 라운드로빈한다
봇 프로필을 분리 토큰이 같으면 한 봇이 여러 번 응답한다

셋 다 "구조적으로 불가능하게 만들었다"고 생각한 지점에서 이름 하나 때문에 뚫린다.

③ "기존 환경에 이미 있던 것"이 신규 구축의 사각지대다. 관리자 계정, RAG 프로젝트, 그리고 시드 경로 자체 — 전부 개발 환경에는 존재해서 절차에 안 남았고, 빈 상태로 세울 때 드러났다. 신규 구축 경로는 기존 환경 검증으로 대체되지 않는다.

④ 계획서의 가치는 계획이 맞아서가 아니라, 틀린 지점이 어디였는지 알게 해주는 데 있다. 이번 계획은 전제 두 개가 첫날 깨졌고 결정 하나는 두 번 뒤집혔다. 그래도 계획이 없었다면 "무엇이 달라졌는지"를 말할 수 없었을 것이다.


관련 글