Node.js

Docker 빌드 재현성: lockfile과 npm ci가 필요한 이유

Docker 빌드가 매번 다른 npm 의존성을 설치해 장애를 만든 원인과 package-lock.json, npm ci로 검증한 조합을 재현하는 방법.

이 글의 목차package.json과 lockfile은 역할이 다르다5
  1. package.json과 lockfile은 역할이 다르다
  2. 빌드할 때마다 영수증을 버리고 있었다
  3. 먼저 서비스를 살린 뒤 설치 규칙을 고쳤다
  4. 128건의 오류를 멈추고, 다음 빌드에서 같은 원인을 차단했다
  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.31.2.3 이상, 1.3.0 미만
^1.2.31.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을 지워 설치 재현성을 버린다.
호스트 저장소의 파일을 직접 지우지는 않지만 다른 빌드에 복사할 패턴은 아니다.

문제가 됐던 설치 방식DOCKERFILE단순화 예시삭제·만료 가능
RUN rm -f package-lock.json
RUN npm install --force

package.json이 장보기 목록이라면 package-lock.json은 실제로 검증한 상품명과 규격이 적힌 영수증에 가깝다.
목록에는 “우유 1개”라고만 적혀도 영수증에는 어느 회사의 몇 mL 제품인지 남는다.
lockfile은 직접 설치한 패키지뿐 아니라 그 패키지가 다시 필요로 하는 하위 패키지의 정확한 버전까지 기록한다.

첫 줄은 그 영수증을 매번 지웠다.
npm은 package.json의 허용 범위 안에서 당시 설치할 수 있는 조합을 새로 계산했다.
두 번째 줄의 --force는 일부 보호 장치와 충돌 경고를 무시하고 설치를 계속하게 했다.

그 결과 아직 검증하지 않은 추적 라이브러리 버전이 이미지에 들어갔고 현재 런타임과 맞지 않아 시작 오류를 냈다.
컨테이너 관리 시스템은 종료된 작업을 다시 띄웠지만 같은 이미지로 시작하니 같은 지점에서 다시 종료됐다.

같은 증상을 다시 추적한다면 정상 이미지와 실패 이미지 안에서 Node·npm 버전, 의심한 패키지의 실제 설치 버전, 그 패키지를 끌어온 상위 의존성을 나란히 확인한다.
아래 명령은 설치 트리를 바꾸지 않는 조회 명령이며, 실제 패키지명으로 바꿔 두 이미지에서 각각 실행한다.

정상 이미지와 실패 이미지의 설치 결과 비교Shell후속 설계조회 전용
node --version
npm --version
npm ls "<suspected-package>" --all
npm explain "<suspected-package>"

npm ls는 실제 설치된 버전과 상위·하위 관계를 보여 주고, npm explain은 해당 패키지가 왜 설치됐는지 거슬러 올라간다.
시작 오류의 모듈 경로와 이 결과를 맞춰 본 뒤, 문제가 없었던 버전으로 고정한 이미지에서 같은 오류가 사라지는지 확인해야 버전 차이를 원인으로 연결할 수 있다.

먼저 서비스를 살린 뒤 설치 규칙을 고쳤다

장애 중에는 빌드 체계를 한꺼번에 바꾸지 않았다.
문제가 없었던 라이브러리 버전을 명시적으로 고정한 핫픽스 이미지를 만들고 배포했다.

배포 명령의 성공 메시지만 보지 않고 실제 실행 수, 헬스 상태, 같은 시작 오류가 다시 쌓이는지를 확인했다.
관련 오류는 128건에서 최신 배포 후 0건이 됐고, 실행 작업 2개가 모두 HEALTHY 상태에 들어왔다.

이 수치를 만든 직접 조치는 버전을 고정한 핫픽스와 재배포였다.
이어서 같은 문제가 다음 빌드에서 다시 들어오지 않도록 서버 빌드의 설치 규칙을 바꿨다.

lockfile을 사용하는 설치 방식DOCKERFILE단순화 예시변경 발생
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

npm ci는 기존 node_modules를 치우고 lockfile에 기록된 의존성 트리를 설치한다.
package.json과 lockfile이 맞지 않으면 새 조합을 만드는 대신 빌드를 실패시킨다.
운영 배포에서는 이 실패가 안전장치다.
확인하지 않은 버전이 이미지에 들어간 뒤 운영에서 멈추는 것보다 빌드 단계에서 차이를 드러내는 편이 낫다.

위 순서는 .dockerignore가 호스트의 node_modules와 비밀 설정을 빌드 컨텍스트에서 제외한다는 조건이 필요하다.
그렇지 않으면 뒤의 COPY . .npm ci로 만든 의존성 위에 로컬 파일을 덮거나 .env를 이미지에 포함할 수 있다.

.dockerignore에서 제외할 최소 항목Text후속 설계
node_modules
npm-debug.log*
.git
.env
.env.*
!.env.example

공개해도 되는 예제 값은 .env.example처럼 비밀이 없는 별도 파일로 관리한다.
npm ci는 현재 디렉터리의 node_modules를 지우므로 운영 중인 애플리케이션 폴더에서 즉석 진단 명령으로 실행하지 않고 CI 작업 공간이나 Docker 빌드 단계에서 사용한다.

lockfile을 지워 새 의존성 조합을 설치하는 흐름과 lockfile을 보존해 불일치 시 빌드를 중단하는 흐름 비교
운영에서 처음 실패하게 두는 대신, 검증한 의존성 조합과 다르면 이미지 빌드 단계에서 멈추게 바꿨다.

패키지 파일 두 개를 소스보다 먼저 복사한 이유도 있다.
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 --forcenpm ci로 바꿔 막았다.
npm 공식 문서에 따르면 npm cipackage.json과 lockfile이 맞지 않으면 종료하고, 기존 node_modules를 지운 뒤 lockfile의 전체 의존성 트리를 설치하며 두 파일을 수정하지 않는다.
따라서 같은 lockfile을 사용하는 빌드 사이에서 중간에 새 하위 패키지가 배포돼 설치 결과가 달라지는 경우는 차단할 수 있다.
이 효과는 문서로 보장되는 재현성이지, 이번 장애 뒤 별도로 측정한 오류 감소분은 아니다.

같은 npm 버전, 설치 옵션, OS·CPU 조건에서 npm cipackage.json의 허용 범위를 매번 다시 해석해 다른 버전을 고르는 경로를 막는다.
플랫폼별 선택적 의존성, 베이스 이미지와 네이티브 모듈까지 같아진다는 뜻은 아니다.
네이티브 모듈은 JavaScript 외에 운영체제와 CPU에 맞춘 바이너리 코드를 포함하는 패키지다.
재현 범위를 넓히려면 Node·npm 버전과 베이스 이미지도 고정하고, CI에서 새 이미지를 실제로 시작해 헬스 검사까지 통과시킨다.

고정하는 항목막을 수 있는 차이
package-lock.jsonnpm ci같은 npm 옵션·플랫폼에서 package.json 범위를 다시 해석해 다른 버전을 고르는 경로
Node·npm 버전런타임과 패키지 설치 알고리즘 차이
베이스 이미지 digestOS 패키지와 시스템 라이브러리 변화
이미지 시작·헬스 검사빌드는 성공하지만 실행 단계에서 종료되는 문제

베이스 이미지 digest는 같은 이미지 버전을 다시 받게 하지만 보안 업데이트도 자동으로 따라오지 않게 한다.
재현성을 위해 digest를 고정한다면 새 digest를 검토해 갱신하는 자동화와 취약점 점검을 함께 운영해야 한다.

npm ci는 애플리케이션을 CI나 Docker에서 반복 빌드하고, 검증한 의존성 조합을 그대로 배포해야 할 때 기본값으로 삼을 만하다.
개발자가 의존성을 추가하거나 lockfile을 갱신하는 단계에서는 npm install을 쓰고, 변경된 package.jsonpackage-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은 어떤 코드를 설치할지 고정할 뿐 그 코드가 안전하다고 판정하지는 않으므로, 의존성 검토와 취약점 점검은 별도로 유지한다.

참고 자료

소신을 상징하는 고래 일러스트
작성자소신 · Soshin

직접 부딪힌 문제와 해결 과정을 다음에 다시 꺼내 볼 수 있도록 기록합니다.