이틀 전 빌드는 통과했는데 이미지는 반쪽이었다 — 네이티브 애드온을 쓰는 Next.js 앱 도커화에서 관리자 콘솔의 컨테이너 이미지를 만들었지만, 검증은 개발자 로컬 환경(arm64)에서만 했고 서버 전환은 남겨 뒀다. 이 글은 그 전환을 개발 서버(amd64)에서 실제로 수행한 기록이다.

전환 자체는 무사고였다. 자산 수정 0줄, 스키마 변경 0건, 앱 코드 변경 0줄. 그런데 전환 직후의 검증이 실패했고, 그 실패가 진짜 실패가 아니라는 것을 알아내는 데 가장 오래 걸렸다.

1. 배경 — 마지막 남은 systemd

관리자 콘솔(Next.js + Prisma)은 세 환경 중 개발 서버에서만 컨테이너가 아니었다.

환경 실행 형태
로컬 컨테이너 (검증 완료)
개발 서버 systemd ← 유일한 잔여
운영 서버 컨테이너 (운영 중)

개발 서버의 경로는 이랬다.

[브라우저] --https:3000--> [nginx 컨테이너] --http--> [172.17.0.1:3001 next start (systemd)]

upstream { server host.docker.internal:3001; }ExecStart=… next start -H 172.17.0.1 -p 3001은 둘 다 컨테이너가 아닌 프로세스를 컨테이너에서 보이게 하려는 우회다. 앱이 컨테이너가 되면 둘 다 사라지고, 프록시는 서비스 이름으로 바로 붙는다.

2. 신·구를 동시에 띄울 수 있었던 이유

전환 순서를 이렇게 잡았다.

빌드 (systemd 서비스 중) → 컨테이너 기동 (프록시 미변경) → upstream 전환 → systemd 제거

3단계 앞까지 사용자는 아무 영향을 받지 않는다. 이게 가능했던 근거 두 가지를 먼저 확인했다.

포트가 충돌하지 않는다. systemd 프로세스는 172.17.0.1:3001(docker0 인터페이스)에 묶여 있고, 컨테이너 퍼블리시는 127.0.0.1:3001(루프백)이다. 바인딩 주소가 다르면 같은 포트 번호를 동시에 쓸 수 있다. ss -lntp로 두 리스너가 나란히 뜨는 것을 확인했다.

LISTEN  172.17.0.1:3001   users:(("next-server (v1)",pid=3114517))
LISTEN  127.0.0.1:3001    users:(("docker-proxy",pid=3874978))

인제스트 큐가 DB 폴링이 아니다. 이 앱은 업로드된 문서의 파싱·임베딩을 인프로세스 큐로 돌린다. globalThis에 얹힌 배열이라 자기 프로세스가 받은 요청만 처리하고, 대기 중인 작업을 DB에서 집어오지 않는다. 그래서 두 인스턴스가 같은 DB를 봐도 서로의 작업을 훔치거나 중복 처리하지 않는다.

만약 이 큐가 DB 폴링 방식이었다면 병행 자체가 불가능했고, "멈추고 → 바꾸고 → 켜기"의 정직한 다운타임을 감수해야 했을 것이다. 배포 전략은 앱 내부 구조가 정한다.

3. 전환 전에 확정한 것 — 스키마 변경 0건

소스를 당겨오기 전에 무엇이 딸려 오는지 봤다.

 .dockerignore  .env.example  .github/workflows/ci.yml  README.md
 docker/{.env.example,Dockerfile,docker-compose.yml,entrypoint.sh,npmrc}
 src/app/api/health/route.ts
 11 files changed, 398 insertions(+), 5 deletions(-)

prisma/가 한 글자도 바뀌지 않은 것이 중요했다. Prisma 마이그레이션은 forward-only라 down 스크립트가 없다. 새 마이그레이션이 한 번이라도 돌면 "구버전 코드 + 신버전 스키마" 조합이 되어 실질적으로 되돌릴 수 없는 배포가 된다. 스키마가 그대로이므로 이번 전환은 백업 없이도 되돌릴 수 있는 배포로 확정됐다.

앱 코드 변경이 헬스체크 라우트 하나뿐이라는 사실은 5절에서 다시 결정적으로 쓰인다.

환경변수 파일은 손으로 옮겨 적지 않고 기존 .env.local에서 값을 뽑아 흘려 넣었다. 세션 서명 키를 눈으로 보고 다시 타이핑하면 오타 하나로 모든 로그인이 끊긴다.

val() { grep -E "^${1}=" .env.local | head -1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'; }
OLD_DB=$(val DATABASE_URL); SECRET=$(val AUTH_SECRET)
NEW_DB=$(printf '%s' "$OLD_DB" | sed 's#@127\.0\.0\.1:5433/#@postgres:5432/#')
BEFORE postgresql://***:***@127.0.0.1:5433/admin_console?schema=public
AFTER  postgresql://***:***@postgres:5432/admin_console?schema=public

접속 문자열에서 호스트만 바꾼다. 컨테이너는 네트워크 안에 있으므로 호스트 퍼블리시를 거치지 않고 서비스 이름으로 직접 붙는다.

4. amd64 첫 빌드 — 침묵이 신호다

로컬은 arm64, 서버는 amd64다. 네이티브 애드온을 쓰는 앱이라 이미지를 옮길 수 없고 호스트마다 빌드한다. 3분 21초 만에 통과했다.

#13 [deps 3/3] RUN npm ci → added 738 packages in 60s    (npm 11.17.0)
#10 [base 3/3] openssl 3.0.20 + libssl3 설치
#16 ✔ Generated Prisma Client … ✓ Compiled successfully in 12.1s

npm warn allow-scripts 경고가 한 건도 나오지 않았고, 그게 정답이다. npm 11.17부터 install 스크립트는 기본 차단이고, 허용목록에 없는 패키지를 만나면 반드시 경고를 찍고 건너뛴다. 이 프로젝트는 그 스크립트로 네이티브 바이너리를 받으므로, 차단되면 이미지는 정상적으로 빌드되고 컨테이너도 뜨는데 리랭킹과 마이그레이션만 런타임에 죽는다. 지난 글에서 가장 위험했던 함정이 이것이었다.

그래서 .npmrc에 허용목록과 함께 strict-allow-scripts=true를 켜 뒀다. 목록에서 빠진 게 하나라도 있었다면 경고가 아니라 빌드 실패로 끝났을 것이다. 경고 0 + 빌드 성공은 허용목록이 lockfile을 정확히 덮고 있다는 증거다.

이어서 아키텍처가 달라지면 다른 파일을 받는 것들을 이미지 안에서 직접 확인했다.

항목 서버(amd64) 로컬(arm64)
process.arch x64 arm64
openssl OpenSSL 3.0.20 3.x
ONNX 런타임 napi-v6/linux/x64/{libonnxruntime.so.1, onnxruntime_binding.node} …/napi-v6/linux/arm64/…
Prisma 엔진 schema-engine-debian-openssl-3.0.x schema-engine-…-arm64-openssl-3.0.x

Prisma 항목이 openssl-1.1.x로 나왔다면 slim 이미지에 openssl이 없어 엉뚱한 엔진을 받은 것이고, 그 상태로도 컨테이너는 뜬다. 지난 글의 네 번째 함정이 amd64에서도 재발하지 않았음을 여기서 확인했다.

컨테이너를 띄우자 기대한 로그가 그대로 나왔다.

[entrypoint] prisma migrate deploy
Datasource "db": PostgreSQL database "admin_console" at "postgres:5432"
6 migrations found in prisma/migrations
No pending migrations to apply.          ← 3절의 예측과 일치
▲ Next.js 16.2.11
✓ Ready in 129ms

루프백으로 직접 찌른 헬스체크가 {"ok":true} 200을 냈다. 이 라우트는 SELECT 1까지 하므로 이 200 하나로 네트워크·자격증명·마이그레이션이 동시에 확인된다. 리랭커 모델 마운트, 호스트 임베딩 서버 도달, DB 접속 문자열까지 컨테이너 안에서 전부 태웠다. 그동안 옛 인스턴스는 계속 서비스 중이었다.

5. upstream을 바꾸자 헬스체크가 404를 냈다

이제 프록시만 돌리면 끝이다.

cp gt.conf gt.conf.bak-$(date +%F)
sed -i 's#server host\.docker\.internal:3001;#server admin-console:3000;#' gt.conf
docker exec nginx nginx -t          # test is successful
docker exec nginx nginx -s reload
curl …/login       → 200
curl …/api/health  → 404   ❌
curl …/api/v4/system/ping → 200

/api/health를 신·구 판별용 리트머스지로 쓰기로 했었다. 그 라우트는 이번에 딸려 온 새 코드이고, next start는 소스가 아니라 빌드 산출물을 읽으므로 옛 인스턴스에는 존재할 수 없다. 그러니 404는 "아직 옛 쪽을 보고 있다"는 뜻이 되어야 했다.

/login이 200인 것은 아무 정보가 없다. 그 경로는 신·구 양쪽에 있다. 실제로 이 서버에 있던 옛 배포 스크립트가 /login 200을 성공 판정으로 쓰고 있었는데, 그건 배포가 됐는지 안 됐는지를 구조적으로 구별하지 못하는 검증이었다.

6. 가설 여섯 개와 그 운명

추측으로 손대지 않고 하나씩 잘랐다.

# 가설 검증 방법 결과
H1 고친 설정이 프록시 컨테이너에 반영되지 않았다 컨테이너 안에서 conf를 읽고 마운트 확인 기각. server admin-console:3000; 존재, 호스트 디렉터리가 정상 마운트
H2 프록시는 컨테이너로 보내는데 컨테이너가 404를 준다 실제 Host 헤더를 흉내내 컨테이너에 직접 요청 기각. 200
H3 리트머스지 자체가 틀렸다(옛 인스턴스도 그 라우트를 안다) 옛 인스턴스에 직접 요청 기각. 404. 지표는 유효
H4 설정이 중복 정의됐거나 백업 파일이 include된다 nginx -T로 로드된 전체 설정 덤프 기각. upstream 블록 1개, include는 *.conf.bak-…는 미로드
H5 이름이 엉뚱한 곳으로 풀린다 프록시 컨테이너 안에서 getent hosts 기각. 컨테이너 IP 정확
H6 reload가 실제로 일어나지 않았다 워커 프로세스의 ELAPSED 기각. 새 워커가 6분 전 생성됨

앱 쪽도 함께 배제했다. 미들웨어의 matcher가 정규식 부정 선행으로 api를 제외하고 있어 그 경로에는 미들웨어가 아예 실행되지 않고, 라우트 핸들러는 SELECT 1{ok:true}를 돌려줄 뿐 404를 만들 분기가 없다. 컨테이너에 도달한 요청이 404가 될 방법이 없었다.

H1이 가장 그럴듯했다. 이전 구축 기록이 프록시 설정을 바꿀 때 reload가 아니라 컨테이너 재생성으로 적용했었기 때문에 "마운트가 없나?"를 의심할 근거가 있었다. 확인해 보니 마운트는 멀쩡했고, 그때 재생성을 쓴 이유는 같은 작업에서 compose 파일에 포트 퍼블리시를 추가했기 때문이었다. 과거 문서의 행동을 근거로 현재를 추론하면, 그 행동의 이유까지 확인해야 한다.

H4에서 쓴 도구가 특히 유용했다. nginx -t(소문자)는 "문법이 맞나"만 답하지만 nginx -T(대문자)는 include를 전부 펼친 최종 설정 전문을 출력한다. "파일은 고쳤는데 동작이 안 바뀐다"는 상황에서 진짜 질문은 "내 파일이 유효한가"가 아니라 "프록시가 읽은 것이 내 파일이 맞나, 그리고 그것 말고 또 뭘 읽었나"이고, 대문자 쪽만 그 답을 준다.

7. 원인 — nginx -s reload는 비동기다

모든 가설이 기각되고 남은 것은 타이밍이었다.

nginx -s reload는 마스터 프로세스에 SIGHUP을 던지고 즉시 반환한다. 새 설정을 읽고, 새 워커를 띄우고, 옛 워커가 리스닝 소켓을 놓는 것은 그 이후에 벌어진다. 그런데 명령줄은 이랬다.

docker exec … nginx -s reload
curl …/api/health          ← 바로 다음 줄

이 요청이 아직 옛 설정을 들고 있던 워커에 접수됐다. 6분 뒤 같은 요청을 다시 쏘자 {"ok":true} 200이 나왔고, 로그인 경로와 메신저 쪽도 200을 유지했다. 전환은 처음부터 성공해 있었다.

교훈은 검증 지표가 아니라 검증 시점에 있다.

"설정이 유효한가"와 "내가 고친 것이 지금 적용됐는가"는 다른 명제다. 적용 직후의 단발 검증은 실패해도 실패가 아니다. 재시도해야 한다.

부끄럽지만 이 오진에 가장 오래 걸린 이유도 분명하다. 실패 신호가 정확히 실패처럼 생겼기 때문이다. 404는 "옛 쪽을 보고 있다"는 뜻이 맞았고, 실제로 그 순간에는 옛 쪽을 보고 있었다. 지표도 해석도 틀리지 않았는데 결론만 틀렸다.

8. 곁가지 — 19시간째 종료 중인 워커

H6을 검증하다 이상한 것을 봤다.

PID  ELAPSED  COMMAND
  1    5d23   nginx: master process
164   19h02   nginx: worker process is shutting down
173    7h02   nginx: worker process is shutting down
174    7h02   nginx: worker process is shutting down
189    6:08   nginx: worker process

정상이라면 몇 초 만에 사라졌을 워커가 하루 가까이 "종료 중"으로 남아 있다. 원인은 upstream 블록의 keepalive 32다. 유지 중인 업스트림 연결이 있으면 옛 워커가 종료를 끝내지 못하는데, worker_shutdown_timeout이 설정돼 있지 않아 무한 대기한다.

의미는 분명하다. 이 서버에서 nginx -s reload는 공짜 명령이 아니다. 배포마다 습관적으로 치면 유령 워커가 하나씩 쌓인다.

9. 정정 — 배포마다 reload를 치면 안 된다

전환을 마치고 재배포 절차를 정리하면서, 나는 nginx -s reload필수 사전 단계로 넣었다. 이유는 있었다. 컨테이너를 재생성하면 IP가 바뀔 수 있고, 프록시는 upstream 호스트명을 설정 로드 시점에 한 번만 해석하고 TTL 없이 캐시하므로 502가 난다.

그런데 지적을 받고 운영 쪽 배포 문서를 다시 봤더니, 같은 명령이 이미 있었다. 다만 "502가 나면 치는" 사후 조치로 배치돼 있었다. 그리고 그쪽이 옳았다.

  • 대부분의 배포에서는 필요 없다. compose의 기본 재생성은 옛 컨테이너를 먼저 제거하고 새로 만든다. 그 사이 IP가 풀에 반납되고 IPAM이 같은 주소를 되돌려주는 경우가 많아, 캐시가 그대로 맞는다.
  • 사전 reload에는 8절의 비용이 있다. 안 터질 502를 막으려고 매번 유령 워커를 만드는 셈이다.

정리하면 사전 조치와 사후 조치의 비용 비교다. 사전은 502를 0초로 만드는 대신 배포마다 워커를 남기고, 사후는 IP가 바뀐 배포에서만 수십 초 502가 나지만 필요할 때만 워커를 교체한다. 개발 서버는 다운타임 민감도가 낮고 배포 빈도가 높으니 후자가 맞다.

# 배포 후 검증. 502일 때만 reload.
curl -s -o /dev/null -w '%{http_code}\n' https://mattermost.example.com:3000/api/health
#   502 → docker exec nginx nginx -s reload  후 재확인

10. 컨테이너화가 새로 만든 것 — 기동 순서 의존

전환 후 systemd 유닛과 옛 배포 스크립트, 그리고 죽은 백업 설정 파일을 모두 지웠다.

백업 설정을 함께 지운 이유를 적어 둘 만하다. 그 파일은 host.docker.internal:3001을 가리키는데 받을 프로세스가 방금 사라졌다. 남겨두면 나중에 누군가 "롤백"이라며 복원해 원인 불명의 502를 만든다. 죽은 롤백 파일은 안전장치가 아니라 함정이다.

그리고 컨테이너화가 새 결합을 하나 만들었다는 것을 뒤늦게 알았다.

프록시는 upstream 호스트명 해석에 실패하면 아예 기동하지 못한다 (host not found in upstream). 재부팅 때 프록시가 앱 컨테이너보다 먼저 뜨면 이름이 안 풀려 프록시가 죽고, 메신저까지 함께 안 올라온다.

컨테이너화 전에는 이 의존이 없었다. 앱이 호스트 프로세스라 프록시는 host.docker.internal이라는 항상 해석되는 이름만 보면 됐기 때문이다. 세 컨테이너의 재기동 정책이 모두 unless-stopped라 죽어도 다시 뜨고 그때는 앱이 올라와 있어 자가 복구되지만, 없던 실패 모드가 생긴 것은 사실이다.

대안은 요청 시점에 이름을 풀게 하는 것이다(resolver 지시자 + 변수를 쓴 proxy_pass). 그러면 기동 의존이 사라지고 재생성 후 reload도 불필요해지지만, upstream 블록을 못 쓰게 되어 keepalive 연결 풀을 잃는다. 재부팅 직후 502가 실제로 문제가 될 때 다시 판단하기로 하고 지금은 두었다.

11. 정리

전환은 무사고였고, 얻은 것은 전환 자체가 아니라 그 주변에 있었다.

  • 배포 전략은 앱 내부 구조가 정한다. 인프로세스 큐라서 신·구 병행이 가능했고, 바인딩 주소가 달라서 같은 포트를 공유할 수 있었다. 둘 중 하나만 어긋났어도 정직한 다운타임을 감수해야 했다.
  • 적용 직후의 단발 검증은 신뢰할 수 없다. 비동기 적용을 하는 도구는 nginx만이 아니다.
  • nginx -tnginx -T는 다른 질문에 답한다. 후자를 기억해 둘 값어치가 있다.
  • 무해해 보이는 명령에도 비용이 있다. 19시간짜리 유령 워커가 그 증거다.
  • 컨테이너화는 결합을 없애기도 하지만 만들기도 한다. 우회 두 개를 지웠고 기동 순서 의존 하나를 얻었다.

남은 것: 브라우저에서의 기능 종단 확인(업로드·검색·OCR)은 아직 하지 않았다. 재생성 시 컨테이너 IP가 실제로 유지되는지도 다음 배포에서 관측할 예정이다.

관련 글