앞선 글에서 PostgreSQL 인스턴스 둘을 하나로 합쳤다. 개발기(M1 Mac)에서 검증까지 끝냈으니 서버에는 같은 절차를 옮기기만 하면 될 줄 알았다. 실제로는 여덟 군데에서 걸렸고, 그중 셋은 개발기에서는 원리적으로 드러날 수 없는 것이었다. 관리자 콘솔과 검색 MCP까지 올린 전 과정의 기록이다.

1. 옮긴 것과 확인한 것

서버는 Ubuntu 24.04 / x86_64 / 15GiB RAM이다. 이미 Mattermost와 여러 MCP 서버가 돌고 있었고, nginx가 Let's Encrypt 인증서로 443을 받고 있었다.

올린 것은 셋이다.

구성 요소 방식
pgvector를 얹은 PostgreSQL 기존 인스턴스의 이미지만 교체
RAG 관리자 콘솔 systemd + nginx가 3000/TLS를 종료
검색 MCP 서버 2개 Mattermost 도커 네트워크에 참여

채팅 데이터는 무손실이었다. 작업 전후로 users 17 / posts 1193 / channels 23 / teams 2가 정확히 유지됐다.

2. 시작 전 조사에서 이미 어긋난 것

절차를 그대로 복사했다면 첫 명령에서 죽었을 것이다.

psql: FATAL: role "mmuser" does not exist

개발기와 서버의 DB 계정 이름과 데이터베이스 이름이 달랐다. 저장소 체크아웃 디렉터리 이름도 달라서, 저장소 이름으로 find를 걸었더니 "없음"이 나왔다.

그래서 손대기 전에 읽기 전용 조사부터 했다.

uname -m; lsb_release -ds; node -v; df -h /
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
docker network ls --format '{{.Name}}'

PG=$(docker ps --format '{{.Names}}' | grep -i postgres | head -1)
docker exec "$PG" sh -c 'echo PGDATA=$PGDATA; cat $PGDATA/PG_VERSION'

MMDIR=$(docker inspect nginx_mattermost -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}')
grep -E '^(CERT_PATH|KEY_PATH|DOMAIN|POSTGRES_USER|POSTGRES_DB|POSTGRES_DATA_PATH)=' "$MMDIR/.env"
ss -lntp | grep -E ':3000|:3001'

여기서 확정한 값이 이후 모든 명령에 들어간다. PostgreSQL 메이저 버전이 18이라는 것도 여기서 확인했는데, 이게 다르면 pgvector Dockerfile의 패키지 경로를 바꿔야 했다.

Node도 걸렸다. npm install이 이렇게 죽었다.

npm ERR! command sh -c node scripts/preinstall-entry.js
   Prisma only supports Node.js versions 20.19+, 22.12+, 24.0+.

Node 24 LTS로 올렸다. 네이티브 애드온(ONNX 런타임, 이미지 처리, 해싱)이 전부 N-API 기반이라 메이저가 바뀌어도 ABI가 깨지지 않는다는 걸 확인하고 결정했다.

3. 데이터 보호 — 백업이 아니라 "복원되는 것"을 확인했다

실사용 중인 채팅 데이터라 백업을 세 번 떴다. 그리고 그중 하나를 새 이미지로 실제 부팅해서 데이터가 살아 있는지 확인한 뒤에 실물을 건드렸다.

# 1. 논리 백업 (무중단). 컨테이너가 read_only라 stdout으로 받는다
docker exec <pg> pg_dumpall -U <계정> | gzip > ~/backup/dumpall.sql.gz
gunzip -c ~/backup/dumpall.sql.gz | grep -c "PostgreSQL database cluster dump complete"

# 2. 물리 백업 (무중단)
docker exec <pg> pg_basebackup -U <계정> -h /var/run/postgresql -D /tmp/bb -X stream -c fast
docker exec <pg> tar cf - -C /tmp/bb . > ~/backup/basebackup.tar
docker exec <pg> rm -rf /tmp/bb

덤프 완결 확인을 tail -2 | head -1처럼 줄 위치로 하면 파일 끝 개행 때문에 어긋난다. grep -c로 표식을 세는 쪽이 확실하다.

함정 하나 — docker cp가 tmpfs에서 안 된다

Error: No such container:path: <pg>:/tmp/bb

하드닝된 컨테이너의 /tmp가 tmpfs 마운트인데 docker cp는 tmpfs에서 동작하지 않는다. tar 스트림으로 받아야 한다. 개발기에서도 같은 하드닝을 썼으니 이건 Linux 문제는 아니고, "보안 설정이 백업 절차를 바꾼다"는 종류의 함정이다.

리허설

물리 백업을 도커 볼륨에 풀고 새 이미지로 띄운 뒤 행 수를 대조했다. 하드닝 옵션까지 그대로 재현했다.

시점 users / posts / channels / teams
작업 전 기준값 17 / 1193 / 23 / 2
백업 복원 리허설 17 / 1193 / 23 / 2
컨테이너 교체 후 17 / 1193 / 23 / 2

부팅 로그의 collation 경고는 0건이었다. 이 셋이 통과한 뒤에야 실물을 교체했다.

4. 인스턴스 교체 — compose 파일 세트를 빠뜨리면 네트워크가 끊긴다

nginx 오버레이 파일 끝에 이런 게 있었다.

networks:
  default:
    name: mattermost

오버레이가 네트워크 이름을 바꾼다. 기본 파일만 쓰고 up하면 compose가 다른 이름의 네트워크를 새로 만들어 PostgreSQL을 거기 붙이고, Mattermost 앱이 DB를 못 찾는다.

export COMPOSE_FILE=docker-compose.yml:docker-compose.nginx.yml
docker compose config | grep -E "image:|127.0.0.1:5433|name: mattermost"

docker compose stop postgres
tar czf ~/backup/volumes-db-cold.tar.gz volumes/db
docker compose up -d postgres

중단은 컨테이너 재생성 몇 초가 전부였다. 기존 데이터 디렉터리를 그대로 열기 때문에 재초기화도 인덱스 재구축도 일어나지 않는다.

운영 파일을 스크립트로 고칠 때는 찾지 못하면 아무것도 바꾸지 않고 멈추게 만들었다.

old_img = "    image: postgres:${POSTGRES_IMAGE_TAG}\n"
assert old_img in t, "postgres image 줄을 찾지 못했다"
t = t.replace(old_img, new_img, 1)

5. DB와 롤 — 격리는 DB를 나누는 것만으로 되지 않는다

RAG용 데이터베이스와 롤을 만들기 전에 다른 비특권 롤이 붙어 있는지 먼저 봤다. REVOKE CONNECT ... FROM PUBLIC이 그런 롤을 끊어버리기 때문이다.

docker exec <pg> psql -U <계정> -d postgres -c "\du"
docker exec <pg> psql -U <계정> -d postgres -c \
  "select datname, usename, count(*) from pg_stat_activity where datname is not null group by 1,2;"

슈퍼유저 하나뿐이라 안전했다. 그다음 확장을 슈퍼유저로 미리 설치했다.

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

vector는 trusted 확장이 아니라 슈퍼유저만 만들 수 있다. 마이그레이션은 애플리케이션 롤로 돌기 때문에 이 단계를 빠뜨리면 42501 Must be superuser로 죽는다.

격리 확인:

FATAL: permission denied for database "<채팅DB>"
DETAIL: User does not have CONNECT privilege.

PUBLIC은 기본적으로 모든 데이터베이스에 CONNECT 권한을 갖는다. 데이터베이스를 나누는 것만으로는 격리가 되지 않는다.

6. Ollama — 개발기에서는 드러날 수 없던 함정

여기가 이번 배포에서 가장 흥미로웠다.

Ollama는 기본적으로 127.0.0.1:11434에만 바인딩한다. 그런데 개발기에서는 그 상태로도 컨테이너가 도달했다. macOS의 Docker Desktop이 host.docker.internal 트래픽을 호스트 루프백까지 중계해 주기 때문이다.

Linux에는 그 중계가 없다. host-gateway는 도커 브리지 IP(172.17.0.1)로 해석되고, 루프백에만 묶인 프로세스는 그 주소로 오는 연결을 아예 받지 않는다.

증상이 고약하다. 관리자 콘솔은 호스트 프로세스라 localhost로 잘 붙으니 검색 MCP에서만 임베딩이 실패한다. 설정은 다 맞아 보이는데 한쪽만 안 된다.

0.0.0.0이 아니라 172.17.0.1인가

0.0.0.0으로 열면 인증 없는 LLM API가 인터넷에 노출된다. 그렇다고 방화벽을 새로 켜면 이미 열려 있던 다른 서비스 포트가 함께 막힌다. 그래서 도커 브리지 주소에만 묶었다. 사설 주소라 외부에서 라우팅되지 않으므로 방화벽 없이도 노출이 없고, 호스트 프로세스와 컨테이너가 모두 이 주소로 닿는다.

mkdir -p /etc/systemd/system/ollama.service.d
cat > /etc/systemd/system/ollama.service.d/override.conf <<'EOF'
[Service]
Environment="OLLAMA_HOST=172.17.0.1:11434"
EOF
systemctl daemon-reload && systemctl restart ollama

systemctl edit은 에디터를 열지만, 그게 만드는 파일이 정확히 위 경로다. 직접 쓰면 에디터가 필요 없고 복붙으로 끝난다. 대신 daemon-reload를 직접 불러야 한다.

세 경로를 각각 확인했다.

curl -s --max-time 5 http://172.17.0.1:11434/api/tags >/dev/null && echo "호스트 OK"

curl -s --max-time 5 http://$(curl -s ifconfig.me):11434/api/tags >/dev/null \
  && echo "외부 접근됨" || echo "외부 차단 OK"

docker run --rm --network mattermost --add-host host.docker.internal:host-gateway \
  curlimages/curl -s --max-time 5 http://host.docker.internal:11434/api/tags >/dev/null \
  && echo "컨테이너 OK"

세 번째가 핵심이다. 여기서 실패하면 나중에 검색 MCP만 조용히 임베딩에 실패한다.

함정 안의 함정 — CLI도 같은 변수를 본다

ollama pull bge-m3
Error: could not connect to ollama server, run 'ollama serve' to start it

OLLAMA_HOST서버에게는 "어디에 바인딩할지", 클라이언트에게는 "어디로 접속할지"를 뜻한다. 같은 변수가 프로세스 종류에 따라 반대 의미로 쓰인다. systemd 드롭인은 서비스 프로세스의 환경만 바꾸니 셸에는 전달되지 않고, CLI는 옛 기본값을 본다.

여기서 안내대로 ollama serve를 실행하면 상황이 더 나빠진다. 주소가 달라 포트 충돌은 안 나지만 서버가 두 개 떠서 같은 모델 디렉터리를 공유하게 된다. 셸에도 변수를 내보내는 것이 정답이다.

echo 'export OLLAMA_HOST=172.17.0.1:11434' >> ~/.bashrc
export OLLAMA_HOST=172.17.0.1:11434
ollama pull bge-m3

임베딩 차원이 1024인지 확인했다. 스키마가 그 차원으로 고정돼 있다.

이 서버에는 GPU가 없어 CPU로 돈다. 질의 임베딩은 하나뿐이라 체감 차이가 작지만, 문서 인제스트는 개발기보다 느리다.

7. 관리자 콘솔 — Prisma 7이 .env를 읽지 않는다

환경변수를 .env에 넣었더니 앱은 뜨는데 마이그레이션만 실패했다. ORM 설정 파일이 파일명을 명시적으로 지정하고 있었다.

config({ path: ".env.local" });

Prisma 7은 설정 파일이 있으면 .env를 자동으로 읽지 않는다. 반면 Next.js는 .env도 읽는다. 그래서 앱은 정상이고 CLI만 죽는 형태가 된다.

접속 주소도 localhost가 아니라 127.0.0.1로 적었다. PostgreSQL을 IPv4 루프백에만 묶었는데 localhost가 IPv6로 먼저 해석되면 연결이 거부되기 때문이다.

리랭커 모델 경로는 절대 경로로 못박았다. 기본값이 "현재 디렉터리 기준"이라 systemd로 띄우면 작업 디렉터리가 달라져 조용히 어긋난다.

설치 스크립트 차단 경고

npm warn allow-scripts 11 packages have install scripts not yet covered by allowScripts:
  onnxruntime-node, @prisma/engines, @swc/core, esbuild, ...

npm 11의 새 경고다. 실제로 차단됐다면 리랭킹과 ORM 엔진이 조용히 깨지므로 확인했다.

find node_modules/onnxruntime-node/bin -name '*.node' | head -3
ls node_modules/@prisma/engines/ | grep -E 'schema-engine|libquery'

파일이 나오면 실행된 것이다.

시드 전에 클라이언트를 생성해야 한다

All migrations have been successfully applied.

Error: Cannot find module '../src/generated/prisma/client'

생성된 ORM 클라이언트는 .gitignore 대상이라 clone으로 따라오지 않는다. 평소엔 predev/prebuild 훅이 만들어 주는데, 빌드 전에 시드를 돌리면 비어 있다. prisma generate를 먼저 부르면 된다.

런타임 경로를 따로 확인했다

마이그레이션은 CLI가, 런타임은 드라이버 어댑터가 접속한다. 이 둘이 서로 다른 데이터베이스를 보는 사고를 막으려고 실제 코드 경로로 확인했다.

접속 DB: admin_db | schema: public
User: 2
벡터 연산자: 0.00853986601633272

시드가 만드는 데모 계정은 지웠다. 잘 알려진 비밀번호가 붙어 있어 공개 서버에 남으면 안 된다.

8. HTTPS — next start는 TLS를 직접 못 한다

[브라우저] --https:3000--> [nginx] --http--> [172.17.0.1:3001 next start]

Next.js는 평문 HTTP만 서빙한다. TLS는 nginx가 종료하고 기존 Let's Encrypt 인증서를 재사용했다.

콘솔을 127.0.0.1이 아니라 도커 브리지 주소에 묶은 이유는 Ollama와 같다. nginx가 컨테이너라 호스트 루프백에는 닿지 못한다.

ExecStart=/path/to/node_modules/.bin/next start -H 172.17.0.1 -p 3001

프록시 헤더에서 한 곳이 중요했다.

proxy_set_header Host              $http_host;
proxy_set_header X-Forwarded-Host  $http_host;
proxy_set_header X-Forwarded-Port  3000;

$host는 포트를 뺀 값이라 인증 라이브러리가 콜백 URL을 443 쪽으로 잡는다. $http_host는 클라이언트가 보낸 원본이라 포트가 남는다.

https로 적어 놓고 평문으로 서빙하면 로그인이 조용히 실패한다. __Secure- 접두 쿠키가 저장되지 않아 에러 없이 로그인 화면으로만 되돌아온다. 프록시를 두지 않을 거라면 URL을 http://로 낮춰야 한다.

문서 업로드가 있어 client_max_body_size를 100M으로, CPU 임베딩이라 proxy_read_timeout을 600초로 올렸다.

9. 검색 MCP — 포트 충돌과 셸 오염

COMPOSE_FILE은 셸 전역이다

cd ~/rag-mcp && docker compose config
stat ~/rag-mcp/docker-compose.nginx.yml: no such file or directory

4절에서 export한 값이 디렉터리를 옮겨도 따라온다. unset하거나, 아예 각 프로젝트의 .env에 두는 편이 안전하다. .env에 두면 값이 디렉터리에 묶인다.

포트가 이미 쓰이고 있었다

기본 설정이 잡으려는 포트를 다른 MCP 서버가 이미 점유하고 있었다. 비어 있는 포트로 옮기고 루프백에만 공개했다. 모든 컨테이너가 같은 도커 네트워크에 있어서 Hermes는 컨테이너 이름으로 부르면 되고, 호스트 포트는 디버깅용일 뿐이다.

ss -lntp | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\n' ' '

DB도 같은 이유로 서비스 이름으로 직결했다. 호스트를 경유하지 않으므로 포트 노출 여부와 무관하고, host.docker.internal에도 의존하지 않는다.

10. Hermes 연결 — 도구 이름은 코드에서 확인한다

프로필별 설정 파일에 mcp_servers 블록이 있고, 형식은 컨테이너 이름 + 내부 포트 + /mcp 경로다.

mcp_servers:
  company-rules-rag:
    url: http://<컨테이너>:8765/mcp
    tools:
      include:
        - search_docs
        - list_docs
        - read_article

도구 이름은 문서가 아니라 소스에서 확인했다. 이름을 틀리면 에러 없이 도구가 안 붙어서 원인 찾기가 나쁘다.

grep -rn "mcp.tool\|^def " src/rag_mcp/server.py | head

설정 파일 소유자가 컨테이너 사용자(uid 10000)라, root로 편집한 뒤 소유권을 되돌려야 했다. 안 그러면 Hermes가 설정을 못 읽는다.

한 프로필에 RAG MCP를 둘 붙이면

도구 이름이 겹치지 않으면 기술적 충돌은 없다. 다만 에이전트는 도구 설명문을 읽고 무엇을 부를지 고르므로, 두 검색 도구의 설명이 비슷하면 엉뚱한 코퍼스를 인용할 수 있다.

인스턴스를 프로젝트별로 나눈 목적이 다른 프로젝트 규정을 인용한 그럴듯한 오답을 구조적으로 막는 것이었는데, 서로 다른 MCP 서버 사이에서는 그 보장이 없다. 이번에는 하나만 남겼다.

11. 서버에서 추적 파일을 고쳐야 했던 문제

작업 도중 서버의 docker-compose.yml을 직접 고쳐야 했다. 인스턴스 구성이 환경마다 다른데 추적 파일에 박혀 있었기 때문이다. 그대로 두면 다음 git pull에서 충돌한다.

원칙을 이렇게 세웠다.

추적 파일은 모든 환경에서 동일해야 한다. 환경마다 다른 것은 .env(값) 또는 docker-compose.override.yml(구조)로 뺀다. 둘 다 .gitignore 대상이다.

환경마다 다른 것 어디에
포트, 비밀번호, 주소, 네트워크 이름 .env
인스턴스 추가, 볼륨 마운트 등 구조 docker-compose.override.yml
공통 로직, 기본값 docker-compose.yml (커밋)

검토했다가 버린 두 가지

profiles. profiles: ["cost"]로 기본 기동에서 빼는 방법이다. 동작은 하지만 정의가 docker-compose.yml에 그대로 남아서 "기본값만 둔다"는 목적에 어긋났다.

extends. override에서 기본 서비스를 상속해 복제를 없애는 방법이다. Compose 2.20 이상이 필요하고, ports는 목록이라 상속분에 덧붙으므로 교체하려면 !override 태그(2.24+)까지 필요하다. 개발기의 Compose가 v2.1.1이라 둘 다 쓸 수 없었다.

여기서 한 가지를 분명히 해둘 필요가 있었다.

YAML 앵커를 override 파일에서 참조할 수 없는 것은 버전 문제가 아니다. 앵커는 YAML 명세상 문서(파일) 단위다. Compose를 아무리 올려도 안 된다.

그래서 결론은 "공유 파일에는 오래된 Compose에서도 되는 기능만 쓴다"였다. override 템플릿은 버전 무관한 방식으로 커밋하고, extends 방식은 주석으로만 남겼다.

정리 후에는 이렇게 된다.

git status --short                                    # 비어 있음
git status --short --ignored | grep -E '\.env$|override\.yml$'
!! .env
!! docker-compose.override.yml

앞으로 배포 전에 git status --short가 비어 있는지만 보면 된다.

예외 하나

Mattermost의 compose 디렉터리는 upstream 저장소의 클론이라 push할 수 없다. 우리 변경은 영원히 로컬로 남으므로 전용 브랜치에 격리하고, 업스트림을 따라갈 때 rebase한다. 충돌을 완전히 피할 방법은 없어서 변경을 최소한으로 유지하는 것이 유일한 방어다. 현재 네 곳뿐이라 충돌이 나도 몇 분이면 정리된다.

12. Linux 특유의 함정 여덟 가지

# 함정 증상
1 DB 계정과 DB 이름이 개발기와 다름 role "..." does not exist
2 docker cp가 tmpfs 마운트에서 실패 No such container:path
3 host.docker.internal이 Linux에 없음 컨테이너에서만 임베딩 실패
4 Ollama가 루프백에만 바인딩 위와 동일, 한쪽만 실패
5 OLLAMA_HOST가 CLI에도 적용됨 could not connect to ollama server
6 Prisma 7이 .env를 자동으로 안 읽음 앱은 뜨는데 마이그레이션만 실패
7 클라이언트 생성 없이 시드 실행 Cannot find module '.../client'
8 COMPOSE_FILE 셸 전역 오염 다른 디렉터리에서 없는 파일을 찾음

3, 4, 5는 개발기에서 드러날 수 없는 종류다. Docker Desktop이 루프백까지 중계해 주기 때문에 같은 설정이 맥에서는 그냥 동작한다. 8번도 개발기에서는 compose 파일을 하나만 써서 겪을 일이 없었다.

"개발기에서 통과했으니 서버에서도 통과한다"가 성립하지 않는 지점이 정확히 여기다. 옮기기 전에 환경이 제공하던 편의가 무엇이었는지 목록으로 만들어 두면 좋았을 것이다.

관련 문서