이번 시간에는 Node.js와 TypeScript로 만든 작은 앱 하나를 Dockerfile 여러 가지로 빌드하면서, 이미지 크기와 재빌드 시간을 docker build와 docker history로 측정하며 배워가는 시간입니다.
먼저 이런 상황을 생각해 볼게요. Node.js 앱의 소스 코드 한 줄을 고치고 이미지를 다시 빌드했더니 npm install이 처음부터 다시 돌았고, 그렇게 만든 이미지는 풀린 크기로 1326MB였습니다. 빌드가 느린 이유는 대개 캐시가 어느 줄에서 깨지는지 모르기 때문이고, 이미지가 큰 이유는 빌드할 때만 필요한 것들이 실행 이미지에 그대로 남기 때문입니다.
실습 준비
이 글의 명령은 모두 실습 코드의 docker-image-size/ 디렉터리에서 실행합니다.
app/: 실습 앱 소스,package.json·lock 파일,tsconfig.json,README.md,Dockerfile.v1·Dockerfile.v2·Dockerfile.v3·Dockerfile.v5size.sh: 이미지의 풀린 크기와 압축 크기를 재는 스크립트build.sh: 파일 하나를 바꾸고 빌드 시간을 재는 스크립트buildlog.py:build.sh가 부르는 빌드 실행·로그 정리 스크립트
앱은 Express 서버 하나예요. TypeScript로 작성하고 npm run build로 dist/에 JavaScript를 만든 뒤, GET /에 버전 문자열을 돌려줍니다. app/package.json입니다.
{
"name": "dimg-app",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc"
},
"dependencies": {
"express": "5.2.1"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.19.0",
"typescript": "7.0.2"
}
}
app/src/index.ts는 GET /에 dimg-app <VERSION>을 돌려주는 Express 서버이고, VERSION 상수의 처음 값은 "1.0.0"입니다.
저장소에 테스트용 큰 파일이 들어 있는 상황도 흉내 내 볼게요. fixtures/에 50MB 파일을 하나 둡니다. 0으로 채운 파일은 압축하면 거의 사라져서 압축 크기 비교가 흐려지니, 임의 바이트로 채워요.
mkdir -p app/fixtures
dd if=/dev/urandom of=app/fixtures/sample.bin bs=1m count=50
52428800 bytes transferred in 0.249117 secs (210458540 bytes/sec)
containerd 이미지 저장소를 쓰면 docker images의 SIZE가 압축된 레이어와 풀린 레이어를 더한 값으로 나와서 생각과 다른 값을 보여 줘요. 그래서 size.sh는 이미지 이름을 여러 개 받아서, 이미지마다 풀린 크기(docker history 레이어 크기 합)와 압축 크기(docker image inspect의 Size)를 MB로 찍어요. 풀린 크기는 컨테이너 안에서 보이는 파일 크기와 같은 기준이고, 압축 크기는 레지스트리에서 받을 때 내려받는 양에 가깝습니다.
build.sh는 ./build.sh <버전> <변경>처럼 불러요. 파일 하나를 바꾼 뒤 app/Dockerfile.<버전>으로 docker build를 실행해 걸린 시간을 잽니다. 이 글에서 쓰는 변경은 두 가지예요.
none: 아무것도 바꾸지 않아요.src:src/index.ts의VERSION문자열을 바꿉니다. 코드만 고친 경우예요.
빌드와 측정은 buildlog.py가 맡아요. --progress=plain 로그에서 단계마다 캐시를 썼는지(CACHED), 다시 실행했다면 몇 초 걸렸는지(DONE)를 뽑아 출력하고, 빌드 컨텍스트를 얼마나 보냈는지(context)도 함께 출력해요. 마지막 줄의 elapsed가 빌드 전체에 걸린 시간입니다.
빌드할 때마다 시간이 흔들리기 때문에, 바꾼 파일별로 세 번씩 빌드해 범위로 적을게요. 첫 빌드는 베이스 이미지를 받는 시간이 섞이므로 비교에 쓰지 않습니다.
chmod +x build.sh size.sh
실습 1: 첫 Dockerfile이 크고 느린 이유
첫 번째 Dockerfile은 디렉터리 전체를 복사하고, 의존성을 설치하고, 빌드합니다. app/Dockerfile.v1이에요.
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
한 번 빌드해 두고 크기와 레이어를 볼게요.
./build.sh v1 none
./size.sh dimg-app:v1
docker history --format '{{.Size}}\t{{.CreatedBy}}' dimg-app:v1 | head -7
dimg-app:v1 풀린 크기 1326MB 압축 크기 475MB
0B CMD ["node" "dist/index.js"]
49.2kB RUN /bin/sh -c npm run build # buildkit
53.6MB RUN /bin/sh -c npm install # buildkit
52.5MB COPY . . # buildkit
8.19kB WORKDIR /app
0B CMD ["node"]
0B ENTRYPOINT ["docker-entrypoint.sh"]
docker history는 위에서부터 최근 레이어를 보여 줘요. WORKDIR 아래는 node:24의 레이어입니다.
1326MB 가운데 1220MB가 베이스 이미지이고, 이 Dockerfile이 더한 것은 106MB예요. COPY . .의 52.5MB는 거의 다 fixtures/sample.bin이고, npm install의 53.6MB는 node_modules와 npm이 내려받은 패키지를 보관하는 캐시 디렉터리입니다.
이제 src/index.ts의 문자열 하나만 바꾸고 다시 빌드해 볼게요.
./build.sh v1 src
context: 1.24kB
DONE 0.1s [1/5] FROM docker.io/library/node:24@sha256:64af3819f9275802414d7cdc38
CACHED [2/5] WORKDIR /app
DONE 0.9s [3/5] COPY . .
DONE 5.4s [4/5] RUN npm install
DONE 2.3s [5/5] RUN npm run build
DONE 7.9s [export] exporting to image
elapsed: 19.3s
소스 파일이 바뀌어 COPY . .의 체크섬이 달라졌고, 그 뒤의 npm install과 npm run build가 모두 다시 실행됐습니다. 왜 그럴까요?
Docker는 어떤 파일이 빌드에 필요한지 따지지 않고 캐시를 써요. COPY . .는 디렉터리 전체를 입력으로 받으므로, 디렉터리 안의 파일이 하나라도 바뀌면 그 뒤의 단계가 모두 다시 실행됩니다.
실습 2: COPY 순서만 바꿔도
의존성 목록은 코드보다 훨씬 드물게 바뀌어요. 그래서 package.json과 lock 파일만 먼저 복사해 설치하고, 나머지 파일은 그 뒤에 복사하는 방식으로 가볼게요.
설치에는 lock 파일 그대로 설치하는 npm ci를 씁니다. npm ci는 package.json과 lock 파일이 맞지 않으면 lock을 고치지 않고 오류로 끝나므로, 빌드할 때마다 같은 버전이 설치돼요. app/Dockerfile.v2입니다.
FROM node:24
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
v1과 v2를 번갈아 가며 src 변경을 세 번씩 빌드했어요. v2의 첫 회차 출력입니다.
./build.sh v2 src
context: 1.24kB
DONE 0.2s [1/6] FROM docker.io/library/node:24@sha256:64af3819f9275802414d7cdc38
CACHED [2/6] WORKDIR /app
CACHED [3/6] COPY package*.json ./
CACHED [4/6] RUN npm ci
DONE 0.9s [5/6] COPY . .
DONE 3.6s [6/6] RUN npm run build
DONE 5.9s [export] exporting to image
elapsed: 12.4s
package*.json이 그대로라 npm ci까지 캐시를 썼고, COPY . .부터 다시 실행됐어요. 세 번씩 잰 결과입니다.
src변경, v1: 16.9~22.6초.npm install은 세 번 모두 다시 실행(5.4~5.7초)src변경, v2: 10.5~12.8초.npm ci는 세 번 모두CACHED
이 측정은 다른 컨테이너 작업이 함께 도는 Mac에서 했어요. 같은 src 변경을 먼저 한 차례 더 돌렸을 때는 v2가 8.0~17.7초까지 흔들려서 v1(14.7~18.4초)과 범위가 겹쳤습니다. 그러니 순서를 바꾼 효과는 로그에서 설치 단계가 CACHED인지로 확인하시는 편이 확실합니다.
크기는 순서와 상관없이 같습니다.
dimg-app:v1 풀린 크기 1326MB 압축 크기 475MB
dimg-app:v2 풀린 크기 1326MB 압축 크기 475MB
실습 3: 멀티 스테이지로 필요한 파일만 옮기기
실행에는 TypeScript 컴파일러가 필요 없어요. 그런데 뒤의 RUN에서 지워도 이미지는 줄지 않습니다. 레이어는 한 번 만들어지면 바뀌지 않고, 뒤의 레이어에서 파일을 지우면 "이 파일은 지워졌다"는 표시만 새 레이어에 남기 때문이에요.
이럴 때 쓰는 방법이 멀티 스테이지 빌드입니다. Dockerfile에 FROM을 여러 번 쓰면 스테이지가 여러 개 생기고, 최종 이미지에는 마지막 스테이지의 레이어만 들어가요. 앞 스테이지에서 만든 파일은 COPY --from=<스테이지 이름>으로 필요한 것만 골라 옮깁니다.
v3은 스테이지를 셋으로 나눠요. deps는 npm ci --omit=dev로 실행 의존성만 설치하고, build는 개발 의존성까지 설치해 컴파일합니다. 마지막 스테이지는 두 스테이지에서 node_modules와 dist/만 가져와요. 베이스는 실행에 필요한 최소 패키지만 담은 node:24-slim으로 바꿨습니다. app/Dockerfile.v3이에요.
FROM node:24-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
./build.sh v3 none
./size.sh node:24-slim dimg-app:v2 dimg-app:v3
docker history --format '{{.Size}}\t{{.CreatedBy}}' dimg-app:v3 | head -6
context: 1.01kB
DONE 0.4s [build 1/6] FROM docker.io/library/node:24-slim@sha256:0e0ff40c39bc087
DONE 0.1s [build 2/6] WORKDIR /app
DONE 0.2s [build 3/6] COPY package*.json ./
DONE 6.1s [build 4/6] RUN npm ci
DONE 0.3s [build 5/6] COPY . .
DONE 2.2s [build 6/6] RUN npm run build
DONE 4.9s [deps 4/4] RUN npm ci --omit=dev
DONE 0.2s [stage-2 3/4] COPY --from=deps /app/node_modules ./node_modules
DONE 0.1s [stage-2 4/4] COPY --from=build /app/dist ./dist
DONE 1.0s [export] exporting to image
elapsed: 12.1s
node:24-slim 풀린 크기 268MB 압축 크기 81MB
dimg-app:v2 풀린 크기 1326MB 압축 크기 475MB
dimg-app:v3 풀린 크기 272MB 압축 크기 81MB
0B CMD ["node" "dist/index.js"]
16.4kB COPY /app/dist ./dist # buildkit
4.54MB COPY /app/node_modules ./node_modules # buil…
8.19kB WORKDIR /app
0B CMD ["node"]
0B ENTRYPOINT ["docker-entrypoint.sh"]
1326MB가 272MB로 줄었어요. 어디서 줄었는지 나눠 보면 이렇습니다.
- 베이스 이미지:
node:241220MB에서node:24-slim268MB로 바뀌었어요. 컴파일러, git, 각종 개발용 라이브러리처럼 빌드에 쓰는 패키지가 빠진 차이입니다. - 앱이 더한 부분: 106MB에서 4.6MB로 줄었어요. 실행 이미지에는 실행 의존성(4.54MB)과
dist/(16.4kB)만 들어 있고,typescript와@types, 소스,fixtures/, npm 캐시는 앞 스테이지에만 남았습니다.
실행 스테이지의 베이스를 셸과 패키지 관리자를 뺀 gcr.io/distroless/nodejs24-debian13:nonroot로 바꾼 것이 v5예요. distroless에는 npm이 없어 설치를 할 수 없으므로 앞 두 스테이지는 node:24-slim을 그대로 씁니다. app/Dockerfile.v5입니다.
FROM node:24-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs24-debian13:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/index.js"]
distroless Node.js 이미지는 엔트리포인트가 node로 정해져 있어요. 그래서 CMD에는 실행할 .js 파일 경로만 적습니다. 빌드한 뒤 크기만 볼게요(발췌).
./build.sh v5 none
./size.sh gcr.io/distroless/nodejs24-debian13:nonroot dimg-app:v3 dimg-app:v5
gcr.io/distroless/nodejs24-debian13:nonroot 풀린 크기 166MB 압축 크기 55MB
dimg-app:v3 풀린 크기 272MB 압축 크기 81MB
dimg-app:v5 풀린 크기 170MB 압축 크기 56MB
같은 앱인데 Dockerfile만 바꿔 풀린 크기가 1326MB에서 170MB로 줄었어요. 다만 distroless에는 셸이 없어서 docker exec로 들어가 파일을 보거나 명령을 실행할 수 없습니다. 조사가 필요할 때는 busybox 셸이 들어 있는 :debug 태그(:debug-nonroot)로 바꿔 띄우시면 됩니다.
정리
범위: Apple Silicon Mac의 Docker Desktop(Docker Engine 27.5.1, BuildKit v0.18.2), Node.js 24와 TypeScript 7로 만든 Express 앱, 바꾼 파일마다 빌드 세 번.
COPY . .뒤에npm install을 둔 v1은 소스 한 줄만 바꿔도 설치를 세 번 모두 다시 실행했고,package*.json만 먼저 복사해npm ci를 한 v2는 세 번 모두CACHED였습니다. 자주 바뀌는 파일은 의존성 설치 뒤에 복사하세요.- 같은
src변경을 먼저 한 차례 돌렸을 때 v2가 8.0~17.7초로 흔들려 v1과 범위가 겹쳤습니다. 순서를 바꾼 효과는 시간보다 설치 단계가CACHED인지로 확인하세요. - 멀티 스테이지로 실행 의존성과
dist/만 옮기자 풀린 크기가 1326MB에서 272MB(slim), 170MB(distroless)가 됐습니다. 실행 이미지에는COPY --from으로 필요한 파일만 옮기세요.