이미지 안에는 분명히 있는데 Cannot find module, 바인드 마운트가 node_modules 를 덮는 자리
환경 Docker Compose v2 · Node.js 컨테이너 · 2026-09-28 확인
Dockerfile 에서 npm ci 까지 끝난 이미지를 docker run 으로 띄우면 잘 돈다. 그런데 코드 수정을 바로 보려고 compose 파일에 소스 폴더를 마운트하는 한 줄을 넣자마자, 같은 이미지가 첫 require 에서 Cannot find module 을 뱉고 죽는다. 이미지를 다시 빌드해도, 캐시를 지워도 그대로다. 이미지는 멀쩡하다. 문제는 그 위에 얹은 마운트다.
빌드는 통과했는데 올리는 순간 모듈이 사라진다
증상은 늘 같은 모양으로 온다. docker compose build 로그에는 added 214 packages 같은 줄이 또렷하게 찍혀 있다. 그런데 docker compose up 을 하면 컨테이너가 몇 초 만에 Exited 로 떨어지고, 로그 맨 위에 Error: Cannot find module 'express' 가 남는다.
헷갈리게 만드는 건 docker run 으로 같은 이미지를 띄우면 멀쩡하다는 점이다. 이미지가 망가졌다면 둘 다 죽어야 한다. 한쪽만 죽는다면 차이는 이미지 바깥, 컨테이너를 띄울 때 붙이는 설정에 있다. compose 쪽에만 있는 게 무엇인지 보면 대개 volumes 에 적힌 .:/app 한 줄이 나온다.
컨테이너 안에 들어가 보면 더 분명해진다. /app 에는 호스트 폴더의 파일이 그대로 보이고, node_modules 는 아예 없거나 호스트에서 예전에 깔아 둔 엉뚱한 버전이 들어 있다. 이미지를 만들 때 넣은 그 node_modules 가 아니다.
docker compose run --rm app ls /app # 호스트 폴더 내용이 보인다
docker compose run --rm app ls /app/node_modules # 없다고 나온다
docker run --rm myapp ls /app/node_modules | head # 이미지만 띄우면 있다마운트를 붙였을 때와 뺐을 때를 나란히 놓고 본다
docker run 으로는 되고 compose 로만 죽는다면, 이미지가 아니라 compose 가 붙인 마운트를 먼저 의심한다.
마운트는 합치지 않고 덮는다
바인드 마운트는 호스트 폴더를 컨테이너 경로 위에 그대로 얹는다. 그 경로에 원래 파일이 있었다면 둘을 합쳐 주지 않는다. 원래 내용은 마운트에 가려서 보이지 않게 된다. USB 를 꽂으면 마운트 지점에 있던 파일이 안 보이는 것과 같은 이치다. 지워진 게 아니라 가려진 것이라 마운트를 빼면 다시 나타난다.
그래서 이미지 안의 /app 은 멀쩡히 node_modules 를 품고 있지만, compose 가 호스트의 현재 폴더를 /app 에 얹는 순간 컨테이너가 보는 /app 은 호스트 폴더가 된다. 호스트에 node_modules 가 없으면 모듈이 없다고 나오고, 호스트에서 따로 npm install 을 해 둔 적이 있으면 그 폴더가 대신 읽힌다.
두 번째 경우가 더 고약하다. 맥에서 설치한 node_modules 에는 맥용으로 컴파일된 네이티브 모듈이 들어 있을 수 있다. 리눅스 컨테이너는 그 바이너리를 읽지 못하니, 에러가 Cannot find module 이 아니라 네이티브 모듈 로드 실패로 바뀌어 원인을 더 멀리서 찾게 만든다.
지금 컨테이너에 무엇이 어디에 붙어 있는지는 docker inspect 가 보여 준다. Type 이 bind 면 호스트 폴더, volume 이면 도커가 관리하는 볼륨이다. /app 한 줄만 bind 로 나오고 /app/node_modules 에 대한 줄이 없다면, 이미지의 node_modules 를 살려 둘 장치가 하나도 없다는 뜻이다.
docker inspect -f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' $(docker compose ps -q app)
# bind /Users/me/proj -> /app ← 이 줄 하나뿐이면 node_modules 는 가려진다컨테이너에 실제로 붙은 마운트를 경로별로 찍는다
비어 있지 않은 경로에 바인드 마운트를 걸면, 그 경로에 있던 원래 파일은 마운트에 가려진다. 합쳐지지 않는다.
안쪽 경로 하나만 볼륨으로 비켜 둔다
고치는 방법은 /app 전체를 마운트하되 /app/node_modules 자리에만 다른 마운트를 하나 더 얹는 것이다. compose 파일 volumes 에 경로만 적은 줄, 즉 - /app/node_modules 를 추가하면 그 자리에 익명 볼륨이 붙는다. 더 안쪽 경로에 걸린 마운트가 바깥 바인드 마운트 위를 다시 덮으니, 컨테이너는 소스는 호스트에서, node_modules 는 볼륨에서 읽는다.
여기서 볼륨의 성질 하나가 일을 해 준다. 비어 있는 볼륨을 파일이 들어 있는 컨테이너 경로에 처음 붙이면, 도커는 그 경로에 있던 이미지 내용을 볼륨 안으로 복사해 넣는다. 그래서 처음 올릴 때 이 익명 볼륨에는 이미지를 빌드하며 깔아 둔 node_modules 가 그대로 담긴다. 호스트 쪽 node_modules 는 아예 쓰이지 않는다.
익명 볼륨이 싫으면 이름을 붙인 볼륨을 써도 된다. 동작은 같다. 이름이 있으면 docker volume ls 에서 찾기 쉽고, 여러 서비스가 같은 볼륨을 나눠 쓸 수 있다. 다만 이름 붙인 볼륨은 compose 파일 최상위 volumes 에도 선언해 둬야 한다.
하나 조심할 옵션이 있다. 긴 문법의 volume.nocopy 를 true 로 두면 볼륨을 만들 때 이미지 내용을 복사하지 않는다. 다른 설정을 옮겨 오다 이 값이 딸려 오면, 볼륨은 붙었는데 속이 텅 빈 채로 시작해서 처음 증상이 그대로 되살아난다.
services:
app:
build: .
volumes:
- .:/app # 소스는 호스트에서
- /app/node_modules # 이 자리만 익명 볼륨으로 비켜 둔다
# 이름 붙인 볼륨으로 쓰려면
# - node_modules:/app/node_modules
# volumes:
# node_modules:바깥은 바인드 마운트, 안쪽 node_modules 는 볼륨
고쳤더니 이번엔 새로 넣은 패키지만 없다
익명 볼륨으로 비켜 두고 며칠 잘 쓰다 보면 두 번째 함정이 온다. package.json 에 패키지를 하나 추가하고 이미지를 다시 빌드했는데, 방금 넣은 그 패키지만 Cannot find module 로 나온다. 빌드 로그에는 설치가 찍혀 있으니 다시 한번 이미지를 의심하게 된다.
이번 원인은 볼륨이 이미 비어 있지 않다는 점이다. 이미지 내용을 볼륨에 복사하는 건 빈 볼륨을 처음 붙일 때 한 번뿐이다. 한번 채워진 볼륨을 다시 붙이면 반대로 볼륨 쪽 내용이 이미지 경로를 가린다. 새 이미지의 node_modules 는 볼륨 아래 깔려서 안 보이고, 컨테이너는 지난번에 복사해 둔 낡은 node_modules 를 계속 읽는다.
익명 볼륨이면 사라지지 않느냐고 생각하기 쉽다. 그렇지 않다. docker compose up 은 설정이나 이미지가 바뀐 서비스의 컨테이너를 다시 만들 때 마운트된 볼륨을 유지하고, 익명 볼륨도 이전 컨테이너에서 이어받는다. 이름이 없을 뿐 매번 새로 생기는 게 아니다.
그래서 의존성을 바꾼 날에는 볼륨을 새로 받아야 한다. docker compose up 에 -V, 긴 이름으로 --renew-anon-volumes 를 붙이면 이전 컨테이너에서 데이터를 이어받지 않고 익명 볼륨을 새로 만든다. 새 볼륨은 비어 있으니 다시 새 이미지의 node_modules 로 채워진다. 이름 붙인 볼륨을 썼다면 -V 로는 안 바뀌니, 그 볼륨을 docker volume rm 으로 지우고 올린다.
docker compose build app
docker compose up -d -V app # 익명 볼륨을 새로 만들어 새 이미지 내용으로 채운다
# 이름 붙인 볼륨이라면
docker compose down
docker volume rm proj_node_modules # 이름은 docker volume ls 로 먼저 확인
docker compose up -d app의존성이 바뀐 날엔 볼륨도 새로 받는다
이미지 내용을 볼륨으로 복사하는 건 빈 볼륨일 때 한 번뿐이다. 한번 채워진 볼륨은 새 이미지를 오히려 가린다.
다시 헤매지 않으려면 확인 한 줄을 붙여 둔다
재발을 막는 가장 싼 방법은 볼륨 속 내용이 지금 package-lock.json 과 맞는지를 컨테이너가 스스로 확인하게 하는 것이다. npm ls 를 컨테이너 안에서 돌리면 설치되지 않았거나 버전이 어긋난 패키지를 짚어 준다. 문제가 있으면 0 이 아닌 코드로 끝나니, 개발용 시작 스크립트 맨 앞에 붙여 두면 낡은 볼륨으로 올라가는 일을 첫 줄에서 막는다.
팀에서 쓰는 compose 파일이라면 - /app/node_modules 줄 옆에 왜 있는지 한 줄 주석을 남겨 둔다. 모르는 사람 눈에는 쓸모없는 줄처럼 보여서, 정리한다고 지웠다가 처음 증상을 다시 불러오는 일이 흔하다.
운영 배포용 설정에는 애초에 소스 바인드 마운트를 넣지 않는다. 이 구성은 코드를 고치면 바로 보이게 하려는 개발용 장치다. 운영 이미지는 빌드할 때 넣은 파일만으로 돌아야 어느 서버에서 띄워도 같은 결과가 나온다.
docker compose exec app npm ls --depth=0 >/dev/null \
&& echo 'node_modules OK' \
|| echo '볼륨이 낡았다: docker compose up -d -V app'볼륨 속 node_modules 가 락파일과 맞는지 컨테이너 안에서 확인한다
조치 후 확인할 것
- docker inspect 로 Mounts 를 찍어 /app 은 bind, /app/node_modules 는 volume 으로 따로 붙어 있는지 본다
- compose 파일의 볼륨 설정에 volume.nocopy: true 가 딸려 오지 않았는지 확인한다
- package.json 을 바꾼 뒤엔 docker compose up -d -V 로 익명 볼륨을 새로 받고, 이름 붙인 볼륨이면 docker volume rm 뒤 올린다
- 컨테이너 안에서 npm ls --depth=0 이 오류 없이 끝나는지 확인한다
- 운영용 compose 파일에는 소스 바인드 마운트가 들어가 있지 않은지 본다
이 함정이 오래 헤매게 만드는 건 이미지가 정말로 멀쩡하기 때문이다. 멀쩡한 것을 계속 다시 빌드하니 아무것도 안 바뀐다. 마운트는 무엇도 합치지 않고 위에서 덮을 뿐이고, 볼륨은 비어 있을 때 한 번만 이미지를 받아 적는다. 이 두 가지를 기억해 두면 Cannot find module 이 떴을 때 빌드 로그보다 docker inspect 를 먼저 열게 된다.