사내 관리자 콘솔(Next.js 16 + Prisma 7)만 컨테이너가 아니라 systemd로 돌고 있었다. 이번에 이미지와 compose를 붙였는데, 정작 어려웠던 건 Dockerfile을 쓰는 일이 아니라 성공한 빌드가 정말 성공한 것인지 판별하는 일이었다.
대상 — 컨테이너로 옮기기 어려운 쪽의 앱
이 콘솔은 문서 RAG 파이프라인(추출 → 청킹 → 임베딩 → 검색 → 리랭크)을 별도 백엔드 없이 TypeScript 인프로세스로 직접 돌린다. 그래서 의존성 목록이 평범한 웹앱과 다르다.
| 패키지 | 용도 | 성격 |
|---|---|---|
onnxruntime-node |
리랭킹 모델 추론 | 네이티브 애드온 + .so |
@napi-rs/canvas |
스캔 PDF 페이지 래스터화 | 네이티브 애드온 |
@node-rs/argon2 |
로그인 비밀번호 해싱 | 네이티브 애드온 |
tesseract.js |
한국어 OCR | wasm + 런타임 다운로드 |
pdfjs-dist |
PDF 파싱 | 워커·폰트 데이터를 파일 경로로 탐색 |
인제스트 큐가 HTTP 응답 이후에도 도는 프로세스를 전제하므로 서버리스에 올릴 수 없고,
next.config.ts에는 이들이 serverExternalPackages로 빠져 있다. 번들러가 묶으려 하면 깨지는
것들이라 Node가 그대로 require하게 두는 설정이다.
반대로 컨테이너화에 유리한 조건도 있었다. 조사에서 확정한 것들이다.
- 업로드 원본이 디스크가 아니라 DB의 blob 컬럼에 들어간다. 업로드용 볼륨이 필요 없다.
- 빌드에 DB가 필요 없다. CI가 접속 문자열 없이
next build를 통과하는 것으로 확인했다. - 런타임에 써야 하는 경로가 둘뿐이다. 리랭커 모델(읽기)과 OCR 언어 데이터(쓰기).
- 설정 로더가
.env.local을 dotenv로 읽지만 dotenv는 기존 환경변수를 덮지 않는다. 컨테이너에 그 파일이 없어도 주입된 값이 그대로 쓰인다. 코드 수정이 필요 없었다.
앱 코드 변경은 결국 healthcheck용 /api/health 파일 하나로 끝났다.
설계에서 먼저 정한 두 가지
Alpine을 쓸 수 없다
onnxruntime-node는 musl 프리빌트를 배포하지 않는다. 다른 두 애드온도 glibc가 기본 경로다.
Debian(bookworm) slim으로 갔다.
같은 이유로 arm64(개발 맥)에서 만든 이미지는 amd64 서버에서 돌지 않는다. 레지스트리를 두지
않고 각 호스트에서 build하기로 했다. 이미 같은 방식으로 도는 형제 서비스가 있어 선례를 따랐다.
output: "standalone"을 켜지 않는다
이건 조금 재미있는 이유다. 앱 코드에 이런 게 있다.
const rerankerRoot =
process.env.RERANKER_DIR ??
path.join(/*turbopackIgnore: true*/ process.cwd(), "models", "reranker");
process.cwd()가 섞인 경로를 번들러의 파일 추적기가 보면 프로젝트 전체를 출력에 끌어넣기 때문에,
일부러 추적에서 숨겨둔 것이다. 그런데 standalone은 바로 그 추적 결과로 node_modules를
재구성한다. 숨긴 것은 복사되지 않는다.
이미지 크기를 포기하고 전체 node_modules를 싣기로 했다. 결과는 1.74GB다. 어차피
onnxruntime과 transformers, tesseract가 용량을 지배해서 dev 의존성 프루닝의 이득이 작기도 했다.
함정 1 — npm ci 실패의 원인은 Node 버전이 아니었다
첫 빌드가 여기서 죽었다.
npm error `npm ci` can only install packages when your package.json and
package-lock.json ... are in sync.
npm error Missing: @swc/helpers@0.5.23 from lock file
메시지를 그대로 읽으면 "lockfile이 깨졌다"다. 그런데 호스트에서 확인하니 멀쩡했다.
| Node | npm | npm ci --dry-run |
|
|---|---|---|---|
| 호스트 | v25 | 11.11.1 | 통과 |
node:22-bookworm-slim |
22 | 10.9.8 | 실패 |
실제 트리를 보니 이랬다.
├─┬ @vitejs/plugin-react-swc
│ └─┬ @swc/core
│ └── @swc/helpers@0.5.15 deduped invalid: ">=0.5.17"
└─┬ next
└── @swc/helpers@0.5.15
next가 0.5.15를 직접 의존으로 고정하는데 @swc/core는 >=0.5.17을 peer로 요구한다.
npm 11은 이 상태를 그냥 허용하고, npm 10은 중첩 설치가 필요하다고 판단한다. 그런데 lockfile에는
그 중첩 항목이 없다. npm ci는 lockfile을 설치 지시서가 아니라 검증 대상으로 다루므로 거부한다.
즉 제약은 Node 런타임이 아니라 번들된 npm 메이저다. lockfile을 만든 npm과 설치하는 npm의
peer 해석이 다르면 어디서든 같은 증상이 난다. npm 11을 싣는 이미지(node:24)로 바꿔 해결했다.
기각한 가설 — "lockfile을 재생성하면 된다"
당연한 첫 시도였다. npm install --package-lock-only를 돌렸더니 66줄이 추가됐는데,
전부 무관한 wasm 번들 항목이었고 문제의 중첩 항목은 추가되지 않았다. npm 11이 현재 트리를
정상으로 보기 때문에 고칠 것이 없다고 판단한 것이다. 되돌리고 방향을 바꿨다.
호스트에서 재현되지 않는 문제를 호스트 도구로 고치려 한 셈이었다.
함정 2 — 실패하지 않은 빌드가 가장 위험했다
두 번째 빌드는 성공했다. 그런데 로그에 이게 섞여 있었다.
npm warn allow-scripts 10 packages have install scripts not yet covered by allowScripts:
npm warn allow-scripts @prisma/engines (postinstall: node scripts/postinstall.js)
npm warn allow-scripts onnxruntime-node (postinstall: node ./script/install)
...
npm 11.17부터 install/postinstall 스크립트가 기본 차단이다. 공급망 공격 방어로는 옳은 기본값인데, 이 프로젝트는 하필 그 스크립트로 네이티브 바이너리를 받는다.
onnxruntime-node→.node애드온과libonnxruntime.so.1다운로드@prisma/engines→ 스키마 엔진 배치
차단된 채로도 이미지는 빌드되고 컨테이너도 정상적으로 뜬다. healthcheck도 초록이다. 리랭킹과 마이그레이션만 런타임에 죽는다. 사용자가 검색을 실행하는 순간에야 드러나는 종류의 고장이다.
경고 한 줄을 놓쳤다면 그대로 서버에 올라갔을 것이다.
실패를 앞당기는 쪽으로 설정했다
허용목록을 빌드 전용 .npmrc에 두고, 빌드 스테이지에서만 복사해 넣었다. 저장소 루트에 두지 않은
이유는 호스트의 npm 버전이 달라 이 키를 모를 수 있고, 호스트 개발 흐름까지 바꾸고 싶지 않아서다.
allow-scripts[]=onnxruntime-node
allow-scripts[]=@prisma/engines
...
strict-allow-scripts=true
핵심은 마지막 줄이다. strict-allow-scripts=true는 목록에 없는 패키지가 스크립트를 들고 오면
빌드를 실패시킨다. 조용히 건너뛰어 반쪽짜리 이미지가 나가는 것보다 낫다는 판단이었다.
이 설정은 곧바로 값을 했다. 세 번째 빌드가 이렇게 죽었다.
npm error code ESTRICTALLOWSCRIPTS
npm error fsevents (install: (install scripts present))
fsevents는 macOS 전용 optional 의존성이라 리눅스에서는 실제로 설치되지도 않는다. 그런데
두 번째 빌드의 경고 목록 10개에는 없던 패키지다. 경고만 보고 목록을 만들었다면 놓쳤을 것이다.
목록에 추가하고 네 번째 빌드에서 통과했다.
함정 3 — slim 이미지에 openssl이 없다
기동은 됐는데 로그에 이게 있었다.
prisma:warn Prisma failed to detect the libssl/openssl version to use,
and may not work as expected. Defaulting to "openssl-1.1.x".
컨테이너 안을 보니 실제로 schema-engine-linux-arm64-openssl-1.1.x를 받아뒀고,
which openssl은 비어 있었다. bookworm은 OpenSSL 3.x다. 엔진 선택이 설치 시점과 실행 시점
양쪽에서 일어나므로 베이스 스테이지에서 설치했다.
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
이것도 "경고일 뿐 동작은 한다" 부류였다. 개발 아키텍처에서는 어쩌다 동작했지만, 아키텍처가 바뀌면 다른 파일을 받는 자리라 남겨둘 이유가 없었다.
함정 4 — compose 파일을 저장소 루트에 두면 안 된다
compose는 자기 디렉터리의 .env를 변수 치환에 쓴다. 그런데 Next.js는 프로젝트 루트의 .env를
앱 환경변수로 자동 로드한다.
루트에 compose용 .env를 두면 어떻게 되나. 컨테이너 지향 값이 호스트 개발까지 새어 들어간다.
DATABASE_URL=postgresql://user:pw@postgres:5432/appdb
컨테이너 안에서는 postgres가 서비스 이름으로 해석되지만, 호스트의 npm run dev에서는
존재하지 않는 호스트명이다. 조용히 깨지고, 원인을 .env에서 찾기까지 시간이 걸린다.
compose 자산을 전부 docker/ 하위로 뺐다. 그러면 compose 변수는 그 디렉터리에 묶이고 Next은
보지 못한다. 두 파일의 역할도 이름으로 구분된다.
| 파일 | 역할 |
|---|---|
docker/.env |
compose 변수 치환 |
.env.local |
호스트에서 직접 실행할 때의 앱 환경변수 |
부수적으로 .gitignore도 손댈 필요가 없었다. .env*와 !.env.example 규칙이 basename 매칭이라
docker/.env는 무시되고 docker/.env.example은 추적된다.
함정 5 — compose 프로젝트 이름을 비우면 남의 컨테이너를 잡는다
처음엔 최상위 name: 키를 썼는데 거부당했다.
(root) Additional property name is not allowed
개발 머신의 compose가 v2.1.1이라 그 키를 모른다. 그냥 지우면 될 것 같지만, 지우면 프로젝트
이름이 디렉터리명이 된다. 그리고 이 저장소의 compose 디렉터리 이름은 docker인데,
같은 머신의 Mattermost compose 디렉터리 이름도 docker다.
두 프로젝트가 하나로 묶인다. 여기서 docker compose down을 치면 Mattermost와 PostgreSQL을
대상으로 잡을 수 있다는 뜻이다.
모든 버전에서 동작하는 방법으로 고정했다.
COMPOSE_PROJECT_NAME=admin-console
compose는 디렉터리 이름과 환경변수로 대상을 정한다. 이름을 명시하지 않으면 암묵적으로
정해지고, 그 암묵값이 다른 프로젝트와 겹칠 수 있다. 나중에 실제로 down → up을 돌려
다른 컨테이너들이 멀쩡한지 확인했다.
함정 6 — named volume은 소유권을 이미지에서 복사해 간다
OCR 언어 데이터(한국어 약 7MB)는 tesseract.js가 런타임에 받아서 캐시한다. 볼륨이 없으면
컨테이너를 다시 만들 때마다 재다운로드하고, 네트워크가 없는 순간 OCR이 죽는다.
그래서 named volume을 붙였는데, 여기에 함정이 하나 더 있다. named volume은 첫 초기화 때 이미지 안 해당 경로의 소유권과 퍼미션을 복사한다. 이미지에 그 경로가 없거나 root 소유면 root 소유 볼륨이 생기고, 비루트로 도는 프로세스가 쓸 수 없다.
RUN mkdir -p /app/models /app/node_modules/.cache/tesseract \
&& chown -R node:node /app/.next /app/node_modules/.cache /app/models
USER node
확인은 컨테이너를 지웠다 다시 만들어서 했다.
$ docker compose down && docker compose up -d
$ ls -la node_modules/.cache/tesseract/
-rw-r--r-- 1 node node 5199098 eng.traineddata
-rw-r--r-- 1 node node 2208378 kor.traineddata
소유권이 node:node고 파일이 살아남았다. 두 번째 OCR은 재다운로드 없이 397ms에 끝났다.
검증 — "떴다"를 검증으로 치지 않는다
이번 작업의 위험은 전부 "뜨지만 일부가 죽어 있는 상태"에 몰려 있었다. 그래서 컨테이너 고유 경로를 하나씩 실제로 태웠다.
healthcheck부터 DB를 본다. 새로 만든 /api/health가 SELECT 1까지 한다. 앱 프로세스만
살아 있고 DB에 못 붙는 반쪽 장애가 이 앱에서 가장 흔하기 때문이다. 이 200 하나로 네트워크,
자격증명, 마이그레이션이 동시에 확인된다.
네이티브 애드온은 컨테이너 안에서 직접 호출했다.
{
"argon2": "OK",
"canvas": "OK (427B png)",
"onnxruntime": "OK (inputs=input_ids,attention_mask)",
"pdfjs": "OK (version=6.2.108)"
}
onnxruntime 항목은 존재 확인이 아니라 마운트된 실제 모델 파일로 InferenceSession을 만든
결과다. 애드온과 읽기 전용 바인드 마운트를 한 번에 검증한다.
로그인은 end-to-end로 태웠다. csrf 토큰을 받고 credentials 콜백을 치고 세션을 조회했다. 성공하면 비밀번호 해싱 애드온, 시크릿 주입, ORM 런타임, 세션 콜백의 권한 재조회가 한 번에 확인된다.
임베딩은 컨테이너에서 호스트로 나가는 경로를 확인했다. 1024차원 벡터가 돌아왔다.
extra_hosts로 host.docker.internal을 host-gateway에 매핑해 리눅스에서도 같은 이름이 잡히게 했다.
마지막으로 문서 한 건을 넣고 검색했다. 임베딩 → 벡터 검색 → 융합 → 리랭크 점수 산출 → 거리 게이트 판정까지 전 단계가 컨테이너 안에서 돌았다. 검증용 데이터는 지웠다.
곁가지로 드러난 것 — CI는 이미 빨간 상태였다
함정 1의 원인을 확정하고 나니 CI 설정이 눈에 들어왔다. NODE_VERSION: "20"이었다.
Node 20은 npm 10을 싣는다. 즉 같은 이유로 CI의 npm ci도 실패한다.
추측하지 않고 재현했다.
$ docker run --rm -v .../package-lock.json:/w/package-lock.json:ro -w /w \
node:20-bookworm-slim sh -c 'npm ci --dry-run'
npm 10.8.2
EXIT=1
npm error code EUSAGE
덤으로 한 의존성이 node>=22를 요구해 엔진 경고도 났다. Node 20은 이래저래 맞지 않는 상태였다.
이 문제는 컨테이너화와 무관하게 이미 존재했고, 컨테이너화가 아니었으면 드러나지 않았을 것이다. 로컬 개발은 npm 11로 하니까 아무도 밟지 않았다. 이미지와 같은 버전으로 올려 같이 고쳤다.
배포 환경을 코드로 고정하는 일의 부수 효과가 이런 것 같다. 그동안 "각자의 로컬"이 가려주던 불일치가 한꺼번에 드러난다.
또 하나 — 컨테이너 회귀처럼 보였던 것
OCR 검증 중에 인식 결과가 빈 문자열로 나왔다. 컨테이너에서 그린 테스트 PNG가 427B밖에 안 됐다. slim 이미지에 시스템 폰트가 없어 백지가 그려진 것이다. 호스트에서 같은 코드로 그리니 9,590B였다. 여기까지는 맞았다.
그런데 실제 문서를 넣었을 때도 한글이 전부 사라졌다. 숫자와 문장부호만 남았다.
1 C ) .
2 ( ) . 1 80 15
3 ( ) 500,000 : 1,000,000 :
폰트 부재가 원인이라고 결론 내리기 전에 호스트에서 같은 PDF를 같은 라이브러리로 읽어봤다.
Warning: loadFont - translateFont failed: "UnknownErrorException:
Ensure that the `cMapUrl` API parameter is provided.".
결과가 동일했다. 컨테이너 문제가 아니었다. PDF의 폰트가 CID 키 방식(CIDFontType0)이라
pdfjs가 디코딩하려면 cMapUrl 파라미터가 필요한데 앱이 넘기지 않고 있었다. 폰트가 임베드되어
있었는데도 그렇다.
두 현상이 같은 원인처럼 보였지만 별개였다. 컨테이너에서 처음 본 문제를 컨테이너 탓으로 돌리기 전에 호스트에서 재현해보는 것이 이번에 가장 값싸게 시간을 아낀 습관이었다.
남은 것
검증은 개발 머신(arm64)에서만 했다. 서버(amd64) 전환은 아직이다. 하필 이번에 걸린 함정들이 아키텍처가 달라지면 다른 파일을 받는 자리라(네이티브 바이너리, Prisma 엔진), 서버에서 한 번 더 눈으로 봐야 한다. 개발기에서 통과한 것이 서버에서 통과한다는 보장이 없다는 걸 이 프로젝트에서 이미 몇 번 겪었다.
전환 자체는 단순하다. systemd 유닛을 멈추고 컨테이너를 띄운 뒤, 리버스 프록시의 upstream을
host.docker.internal:3001에서 컨테이너 이름으로 바꾸면 된다. 그러면 지금 남아 있는 우회 두 개가
사라진다. 호스트 프로세스를 -H 172.17.0.1로 docker0 인터페이스에 묶어둔 것과, 프록시가
host.docker.internal로 호스트를 되짚어 나가는 것. 둘 다 컨테이너가 아닌 프로세스를 컨테이너에서
보이게 하려고 존재하던 것들이다.
정리
되돌아보면 이번에 배운 건 도커 문법이 아니라 "성공"의 정의였다.
- 빌드 성공 ≠ 이미지 온전. 네이티브 바이너리를 받는 install 스크립트가 차단돼도 빌드는 통과한다.
- 컨테이너 기동 ≠ 기능 정상. healthcheck가 앱만 보면 DB가 끊겨도 초록이다.
- 경고 ≠ 무시해도 되는 것. 이번 세 함정 중 둘이 "경고만 남기고 통과"였다.
그래서 대책도 전부 같은 모양이 됐다. 실패를 앞당기는 것.
strict-allow-scripts로 목록 밖 스크립트에서 빌드를 깨고, healthcheck를 DB까지 내리고,
네트워크 이름이 틀리면 즉시 실패하게 두고, 필수 환경변수가 비면 기동 전에 멈추게 했다.
조용한 실패를 시끄럽게 만드는 것 말고는 방법이 없었다.