Docker 빌드 재현성: lockfile과 npm ci가 필요한 이유
Docker 빌드가 매번 다른 npm 의존성을 설치해 장애를 만든 원인과 package-lock.json, npm ci로 검증한 조합을 재현하는 방법.
이 글의 목차package.json과 lockfile은 역할이 다르다5
새 Docker 이미지는 만들어졌는데 컨테이너가 시작 단계에서 계속 종료됐다.
Docker 이미지는 애플리케이션과 실행 환경을 묶은 배포용 원본이고, 컨테이너는 그 이미지를 실제로 실행한 단위다.
새 이미지 안의 라이브러리 버전은 이전 이미지와 달랐다.
필요한 패키지와 허용 버전 범위를 적는 package.json만으로는 그 차이를 설명하기 어려웠다.
패키지 해석 결과가 왜 달라졌는지 확인하려고 이미지를 만드는 순서를 적은 Dockerfile부터 열었다.
package.json과 lockfile은 역할이 다르다
npm 프로젝트에는 비슷해 보이지만 역할이 다른 세 가지가 있다.
| 항목 | 담는 내용 | Git에 남기는가 |
|---|---|---|
package.json | 직접 사용하는 패키지와 허용 버전 범위, 실행 스크립트 | 남긴다 |
package-lock.json | 실제로 해석된 직접·간접 의존성의 정확한 트리 | 애플리케이션은 보통 남긴다 |
node_modules | 현재 컴퓨터에 설치된 실제 파일 | 보통 남기지 않는다 |
간접 의존성은 내가 직접 설치하지 않았지만 사용하는 라이브러리가 다시 필요로 하는 패키지다.
애플리케이션은 직접 의존성보다 훨씬 많은 간접 의존성을 함께 실행하는 경우가 많다.
lockfile을 없애면 npm은 package.json의 범위 안에서 이 전체 조합을 다시 계산한다.
npm 패키지는 보통 메이저.마이너.패치 형태의 SemVer(Semantic Versioning)를 사용한다.
1.2.3이라면 호환되지 않는 변경의 신호인 메이저가 1, 호환 기능 추가인 마이너가 2, 호환 버그 수정인 패치가 3이다.
이는 게시자가 지키기로 한 규칙이므로 버전 번호만 믿지 않고 변경 기록과 테스트도 함께 봐야 한다.
| 선언 예 | npm이 선택할 수 있는 범위 |
|---|---|
1.2.3 | 정확히 1.2.3 |
~1.2.3 | 1.2.3 이상, 1.3.0 미만 |
^1.2.3 | 1.2.3 이상, 2.0.0 미만 |
따라서 ^1.2.3이면 어제는 1.4.0, 오늘은 새로 공개된 1.5.0이 선택될 수 있다.
메이저가 0인 개발 초기 패키지에서는 ^ 범위가 더 좁게 계산되므로 실제 허용 범위는 npm의 SemVer 규칙으로 확인한다.
이런 범위는 개발 중 업데이트를 받아 보기에는 유용하지만, 동일한 소스 커밋으로 만든 운영 이미지가 날짜마다 달라져도 된다는 뜻은 아니다.
CI(Continuous Integration)는 커밋마다 설치·빌드·테스트를 자동으로 실행하는 환경이다.
운영 이미지를 만드는 CI에서는 이미 검증한 lockfile을 그대로 설치하고, 의존성을 바꾸려는 작업에서만 npm install로 lockfile을 갱신하는 편이 역할에 맞다.
빌드할 때마다 영수증을 버리고 있었다
문제가 된 설치 단계는 짧았다.
아래 단계는 Docker 빌드 안에서 복사된 lockfile을 지워 설치 재현성을 버린다.
호스트 저장소의 파일을 직접 지우지는 않지만 다른 빌드에 복사할 패턴은 아니다.
RUN rm -f package-lock.json
RUN npm install --forcepackage.json이 장보기 목록이라면 package-lock.json은 실제로 검증한 상품명과 규격이 적힌 영수증에 가깝다.
목록에는 “우유 1개”라고만 적혀도 영수증에는 어느 회사의 몇 mL 제품인지 남는다.
lockfile은 직접 설치한 패키지뿐 아니라 그 패키지가 다시 필요로 하는 하위 패키지의 정확한 버전까지 기록한다.
첫 줄은 그 영수증을 매번 지웠다.
npm은 package.json의 허용 범위 안에서 당시 설치할 수 있는 조합을 새로 계산했다.
두 번째 줄의 --force는 일부 보호 장치와 충돌 경고를 무시하고 설치를 계속하게 했다.
그 결과 아직 검증하지 않은 추적 라이브러리 버전이 이미지에 들어갔고 현재 런타임과 맞지 않아 시작 오류를 냈다.
컨테이너 관리 시스템은 종료된 작업을 다시 띄웠지만 같은 이미지로 시작하니 같은 지점에서 다시 종료됐다.
같은 증상을 다시 추적한다면 정상 이미지와 실패 이미지 안에서 Node·npm 버전, 의심한 패키지의 실제 설치 버전, 그 패키지를 끌어온 상위 의존성을 나란히 확인한다.
아래 명령은 설치 트리를 바꾸지 않는 조회 명령이며, 실제 패키지명으로 바꿔 두 이미지에서 각각 실행한다.
node --version
npm --version
npm ls "<suspected-package>" --all
npm explain "<suspected-package>"npm ls는 실제 설치된 버전과 상위·하위 관계를 보여 주고, npm explain은 해당 패키지가 왜 설치됐는지 거슬러 올라간다.
시작 오류의 모듈 경로와 이 결과를 맞춰 본 뒤, 문제가 없었던 버전으로 고정한 이미지에서 같은 오류가 사라지는지 확인해야 버전 차이를 원인으로 연결할 수 있다.
먼저 서비스를 살린 뒤 설치 규칙을 고쳤다
장애 중에는 빌드 체계를 한꺼번에 바꾸지 않았다.
문제가 없었던 라이브러리 버전을 명시적으로 고정한 핫픽스 이미지를 만들고 배포했다.
배포 명령의 성공 메시지만 보지 않고 실제 실행 수, 헬스 상태, 같은 시작 오류가 다시 쌓이는지를 확인했다.
관련 오류는 128건에서 최신 배포 후 0건이 됐고, 실행 작업 2개가 모두 HEALTHY 상태에 들어왔다.
이 수치를 만든 직접 조치는 버전을 고정한 핫픽스와 재배포였다.
이어서 같은 문제가 다음 빌드에서 다시 들어오지 않도록 서버 빌드의 설치 규칙을 바꿨다.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run buildnpm ci는 기존 node_modules를 치우고 lockfile에 기록된 의존성 트리를 설치한다.
package.json과 lockfile이 맞지 않으면 새 조합을 만드는 대신 빌드를 실패시킨다.
운영 배포에서는 이 실패가 안전장치다.
확인하지 않은 버전이 이미지에 들어간 뒤 운영에서 멈추는 것보다 빌드 단계에서 차이를 드러내는 편이 낫다.
위 순서는 .dockerignore가 호스트의 node_modules와 비밀 설정을 빌드 컨텍스트에서 제외한다는 조건이 필요하다.
그렇지 않으면 뒤의 COPY . .가 npm ci로 만든 의존성 위에 로컬 파일을 덮거나 .env를 이미지에 포함할 수 있다.
node_modules
npm-debug.log*
.git
.env
.env.*
!.env.example공개해도 되는 예제 값은 .env.example처럼 비밀이 없는 별도 파일로 관리한다.
npm ci는 현재 디렉터리의 node_modules를 지우므로 운영 중인 애플리케이션 폴더에서 즉석 진단 명령으로 실행하지 않고 CI 작업 공간이나 Docker 빌드 단계에서 사용한다.
패키지 파일 두 개를 소스보다 먼저 복사한 이유도 있다.
Docker는 각 빌드 단계를 재사용 가능한 레이어로 저장한다.
애플리케이션 코드만 바뀌고 패키지 파일은 같다면 의존성 설치 단계를 다시 사용할 수 있다.
한 가지 조건은 함께 남겨야 한다.
lockfile을 만들 때 --legacy-peer-deps처럼 의존성 트리 계산을 바꾸는 옵션을 사용했다면 npm 공식 문서상 npm ci에도 같은 조건이 필요하다.
이 옵션은 충돌을 해결하는 기능이 아니라 현재 설치 규칙을 재현하는 우회다.
필요하다면 프로젝트 .npmrc에 이유와 함께 기록하고, 관련 패키지를 정리한 뒤 제거해야 한다.
빌드 도구가 devDependencies에 있다면 npm run build 전에는 개발 의존성도 필요하다.
앞 단계에서 NODE_ENV=production을 설정해 설치 대상에서 빠지게 만들지 않고, 실행 이미지를 작게 만들고 싶다면 멀티 스테이지 빌드에서 빌드 결과와 운영 의존성만 다음 단계로 가져간다.
위 Dockerfile은 단일 패키지 앱을 단순화한 예시다.
npm workspace를 사용한다면 lockfile 해석에 필요한 각 workspace의 package.json도 의존성 설치 전에 복사해야 한다.
128건의 오류를 멈추고, 다음 빌드에서 같은 원인을 차단했다
복구는 두 단계로 나눴다.
먼저 문제가 없었던 추적 라이브러리 버전을 고정한 핫픽스로 서비스를 살렸다.
확인 시점의 관련 시작 오류는 128건에서 최신 배포 후 0건으로 바뀌었고, 실행해야 할 작업 2개가 모두 HEALTHY였다.
이 값은 배포 직후 복구 확인에 사용한 건수이며 장기 오류율과는 구분한다.
재발 경로는 Docker 빌드에서 lockfile을 보존하고 npm install --force를 npm ci로 바꿔 막았다.
npm 공식 문서에 따르면 npm ci는 package.json과 lockfile이 맞지 않으면 종료하고, 기존 node_modules를 지운 뒤 lockfile의 전체 의존성 트리를 설치하며 두 파일을 수정하지 않는다.
따라서 같은 lockfile을 사용하는 빌드 사이에서 중간에 새 하위 패키지가 배포돼 설치 결과가 달라지는 경우는 차단할 수 있다.
이 효과는 문서로 보장되는 재현성이지, 이번 장애 뒤 별도로 측정한 오류 감소분은 아니다.
같은 npm 버전, 설치 옵션, OS·CPU 조건에서 npm ci는 package.json의 허용 범위를 매번 다시 해석해 다른 버전을 고르는 경로를 막는다.
플랫폼별 선택적 의존성, 베이스 이미지와 네이티브 모듈까지 같아진다는 뜻은 아니다.
네이티브 모듈은 JavaScript 외에 운영체제와 CPU에 맞춘 바이너리 코드를 포함하는 패키지다.
재현 범위를 넓히려면 Node·npm 버전과 베이스 이미지도 고정하고, CI에서 새 이미지를 실제로 시작해 헬스 검사까지 통과시킨다.
| 고정하는 항목 | 막을 수 있는 차이 |
|---|---|
package-lock.json과 npm ci | 같은 npm 옵션·플랫폼에서 package.json 범위를 다시 해석해 다른 버전을 고르는 경로 |
| Node·npm 버전 | 런타임과 패키지 설치 알고리즘 차이 |
| 베이스 이미지 digest | OS 패키지와 시스템 라이브러리 변화 |
| 이미지 시작·헬스 검사 | 빌드는 성공하지만 실행 단계에서 종료되는 문제 |
베이스 이미지 digest는 같은 이미지 버전을 다시 받게 하지만 보안 업데이트도 자동으로 따라오지 않게 한다.
재현성을 위해 digest를 고정한다면 새 digest를 검토해 갱신하는 자동화와 취약점 점검을 함께 운영해야 한다.
npm ci는 애플리케이션을 CI나 Docker에서 반복 빌드하고, 검증한 의존성 조합을 그대로 배포해야 할 때 기본값으로 삼을 만하다.
개발자가 의존성을 추가하거나 lockfile을 갱신하는 단계에서는 npm install을 쓰고, 변경된 package.json과 package-lock.json을 함께 검토한다.
--legacy-peer-deps나 --install-links처럼 트리 모양을 바꾸는 옵션을 썼다면 프로젝트 .npmrc에 같은 조건을 남겨야 CI도 같은 결과를 낸다.
| 하고 싶은 일 | 사용할 명령 | 이유 |
|---|---|---|
| 새 패키지를 추가하거나 버전을 갱신 | npm install <package> | 선언과 lockfile을 함께 바꾼다 |
| 개발 PC에서 현재 lockfile을 설치 | npm ci 또는 팀이 정한 설치 명령 | CI와 같은 조합을 확인한다 |
| CI·Docker에서 검증한 트리를 재현 | npm ci | 불일치 시 조용히 다시 계산하지 않고 실패한다 |
| 취약점 수정 버전을 검토 | 별도 업데이트 PR 후 npm ci와 테스트 | 재현성과 안전성 판단을 분리한다 |
이 장애의 결론은 설치를 억지로 통과시키는 것보다 차이가 생긴 빌드를 일찍 실패시키는 편이 안전하다는 것이다.
운영에서 확인하지 않은 조합을 처음 만나는 대신, 빌드 단계에서 lockfile 불일치를 수정하도록 흐름을 바꿨다.
다만 lockfile은 어떤 코드를 설치할지 고정할 뿐 그 코드가 안전하다고 판정하지는 않으므로, 의존성 검토와 취약점 점검은 별도로 유지한다.