이미지 레이어와 빌드 최적화
이미지는 한 덩어리가 아니라 여러 레이어다
Docker 이미지는 하나의 큰 파일처럼 보이지만, 실제로는 여러 개의 레이어가 쌓인 구조다. 레이어는 이전 상태에서 바뀐 내용만 저장하는 계층이다. 그래서 같은 기반 이미지를 사용하는 여러 이미지가 있으면 공통 레이어를 함께 재사용할 수 있다.
Dockerfile을 빌드할 때 Docker는 명령을 위에서 아래로 실행한다. FROM, WORKDIR, COPY, RUN, CMD 같은 명령이 순서대로 처리되고, 그 결과가 이미지에 반영된다. 이때 파일 시스템을 바꾸는 명령은 새 레이어를 만든다. 대표적으로 COPY, ADD, RUN이 그렇다. CMD, EXPOSE, ENV처럼 실행 설정을 바꾸는 명령도 이미지 설정에 기록되며, 캐시 판단에는 영향을 준다.
간단히 말하면 Dockerfile은 "이미지를 만드는 절차"이고, 레이어는 "각 절차가 남긴 결과"다.
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]이 Dockerfile을 보면 node:22-slim 위에 작업 디렉터리 설정, 의존성 파일 복사, 의존성 설치, 소스 코드 복사, 실행 명령 설정이 차례로 쌓인다. 중요한 점은 앞쪽 단계가 바뀌면 그 뒤쪽 단계도 다시 만들어질 수 있다는 것이다.
AI 생성 이미지
빌드 캐시는 왜 빠른가
Docker는 이미 빌드한 결과를 다시 사용할 수 있다. 이것을 빌드 캐시라고 한다. 같은 Dockerfile을 다시 빌드할 때 매번 모든 명령을 실행하지 않고, 이전에 만든 레이어를 재사용한다. 그래서 두 번째 빌드는 첫 번째 빌드보다 훨씬 빠르게 끝나는 경우가 많다.
Docker는 Dockerfile 명령을 순서대로 확인하며 캐시를 쓸 수 있는지 판단한다. 보통 RUN은 명령 문자열이 같으면 캐시를 재사용할 수 있다. 반면 COPY와 ADD는 복사되는 파일의 내용과 메타데이터를 바탕으로 캐시를 판단한다. 다만 파일의 수정 시각, 즉 mtime만 바뀐 경우에는 캐시가 깨지지 않을 수 있다. 어느 한 단계에서 캐시가 깨지면 그 뒤의 단계도 새로 빌드된다.1
이 규칙 때문에 Dockerfile 작성 순서가 중요하다. 자주 바뀌지 않는 작업은 앞에 두고, 자주 바뀌는 파일은 뒤에 복사하는 편이 좋다. 예를 들어 Node.js 프로젝트에서 npm ci는 시간이 오래 걸릴 수 있다. 그런데 소스 파일 하나를 고칠 때마다 npm ci가 다시 실행되면 빌드가 느려진다.
나쁜 예를 먼저 보자.
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]이 Dockerfile은 소스 전체를 먼저 복사한다. server.js 한 줄만 바뀌어도 COPY . . 단계의 캐시가 깨진다. 그러면 그 뒤의 RUN npm ci도 다시 실행된다.
좋은 패턴은 의존성 파일을 먼저 복사하고, 의존성을 설치한 뒤, 마지막에 소스 코드를 복사하는 것이다.
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]이제 server.js만 바뀌면 마지막 COPY . . 이후만 다시 처리된다. package.json이나 package-lock.json이 바뀌지 않았다면 npm ci 레이어는 재사용된다. 작은 차이처럼 보이지만, 실무에서는 빌드 시간을 크게 줄여 주는 습관이다.
실습: 캐시가 재사용되는지 확인하기
빈 디렉터리를 만들고 다음 파일을 준비하자.
mkdir layer-demo
cd layer-demo
npm init -y
npm install expressserver.js를 만든다.
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('hello layer cache');
});
app.listen(3000, () => {
console.log('server is running on port 3000');
});그리고 Dockerfile을 작성한다.
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]불필요한 파일이 빌드 컨텍스트에 포함되지 않도록 .dockerignore도 만든다.
node_modules
npm-debug.log
.git
.DS_Store
.env빌드한다.
docker build -t layer-demo:1.0 .다시 한 번 같은 명령으로 빌드한다.
docker build -t layer-demo:1.0 .두 번째 빌드에서는 대부분의 단계가 캐시를 사용한다. 이제 server.js의 응답 문구만 바꿔 보자.
res.send('hello updated cache');다시 빌드한다.
docker build -t layer-demo:1.1 .package*.json이 바뀌지 않았으므로 RUN npm ci 단계는 보통 재사용된다. 반대로 npm install lodash처럼 의존성을 추가하면 package.json과 package-lock.json이 바뀐다. 이때는 의존성 설치 단계부터 다시 실행된다.
컨테이너도 실행해 보자.
docker run --rm -p 3000:3000 layer-demo:1.1브라우저에서 http://localhost:3000을 열면 수정한 문구가 보인다. 이 실습의 핵심은 컨테이너 실행이 아니라 빌드 과정이다. Dockerfile의 순서를 바꾸는 것만으로도 반복 빌드가 빨라진다는 점을 확인하면 된다.
.dockerignore는 캐시와 보안을 함께 돕는다
빌드할 때 Docker는 현재 디렉터리의 파일을 빌드 컨텍스트로 보낸다. Dockerfile에서 COPY . .를 사용하면 그 컨텍스트 안의 파일이 이미지에 들어갈 수 있다. 따라서 필요 없는 파일은 .dockerignore로 제외하는 것이 좋다.
대표적으로 제외할 파일은 다음과 같다.
node_modules
.git
.env
*.log
dist
coverage
.DS_Storenode_modules는 이미지 안에서 npm ci로 다시 설치하는 편이 재현성이 좋다. .git은 이미지 실행에 필요 없고 용량만 늘린다. .env에는 비밀번호나 토큰이 들어 있을 수 있으므로 이미지에 복사되면 위험하다.
.dockerignore는 단순히 이미지 크기만 줄이지 않는다. 불필요한 파일 변경 때문에 COPY 캐시가 깨지는 일도 줄인다. 즉, 빌드 속도와 보안에 동시에 도움이 된다. Docker 공식 문서도 빌드에 필요 없는 파일을 제외하기 위해 .dockerignore 사용을 권장한다.2
멀티 스테이지 빌드: 빌드용 도구와 실행 환경 분리하기
지금까지의 예제는 서버 파일을 그대로 실행했다. 하지만 실제 프로젝트에서는 TypeScript를 JavaScript로 변환하거나, 프런트엔드 파일을 빌드하거나, Go·Java처럼 컴파일 결과물을 만들어야 할 때가 많다.
이때 빌드 도구까지 최종 이미지에 넣으면 이미지가 커진다. 실행에는 필요 없는 컴파일러, 개발 의존성, 임시 파일이 함께 배포된다. 이 문제를 해결하는 대표적인 방법이 멀티 스테이지 빌드다.
멀티 스테이지 빌드는 하나의 Dockerfile 안에서 FROM을 여러 번 사용한다. 앞 단계에서는 빌드에 필요한 도구를 사용하고, 마지막 단계에는 실행에 필요한 파일만 복사한다. Docker 공식 문서에서도 여러 FROM 단계와 COPY --from을 사용해 필요한 산출물만 최종 이미지로 가져오는 방식을 설명한다.3
예를 들어 TypeScript 애플리케이션이라면 다음과 같은 구조를 사용할 수 있다.
# 1단계: 빌드 단계
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 2단계: 실행 단계
FROM node:22-slim AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]첫 번째 단계인 builder에는 개발 의존성과 빌드 도구가 들어간다. 두 번째 단계인 runner는 실제 실행에 필요한 운영 의존성과 dist 결과물만 가진다. COPY --from=builder /app/dist ./dist는 앞 단계에서 만든 파일을 뒤 단계로 복사한다.
이 방식의 장점은 명확하다.
최종 이미지가 작아진다.
빌드 도구가 운영 이미지에 남지 않는다.
공격자가 사용할 수 있는 도구가 줄어 보안상 유리하다.
빌드 단계와 실행 단계를 분리해 Dockerfile의 의도가 더 분명해진다.
빌드 중 특정 단계까지만 확인하고 싶을 때는 --target을 사용할 수 있다.
docker build --target builder -t layer-demo:builder .이 명령은 builder 단계까지만 빌드한다. 빌드 결과물을 확인하거나, 최종 단계로 넘어가기 전 문제를 찾을 때 유용하다.
베이스 이미지 선택: alpine, slim, distroless
이미지 최적화에서 자주 나오는 선택지가 alpine, slim, distroless다. 모두 이미지를 작게 만들기 위해 사용하지만 성격이 다르다.
slim 계열은 일반적으로 가장 무난한 선택이다. 예를 들어 node:22-slim, python:3.12-slim처럼 사용한다. 기본 이미지보다 작지만, 널리 쓰이는 Linux 라이브러리와의 호환성이 비교적 좋다. 처음 최적화를 시작한다면 slim을 먼저 고려하는 편이 안전하다.
alpine 계열은 매우 작다. 예를 들어 node:22-alpine처럼 사용한다. 이미지 크기를 크게 줄일 수 있지만, Alpine Linux는 일반적인 Debian·Ubuntu 계열과 다른 C 라이브러리인 musl을 사용한다. 그래서 일부 네이티브 모듈이나 바이너리 패키지가 바로 동작하지 않을 수 있다. 크기는 작지만, 빌드 오류나 런타임 호환성 문제를 확인해야 한다.
distroless 계열은 더 극단적인 실행 이미지다. 애플리케이션 실행에 필요한 최소 구성만 남기고 셸이나 패키지 관리자 같은 도구를 포함하지 않는 방향이다. 보안상 장점이 있지만, 컨테이너 안에 들어가서 sh, curl, apt 같은 명령으로 디버깅하기 어렵다. 따라서 로그와 헬스 체크, 별도 디버그 이미지 전략이 준비된 팀에 더 적합하다.
정리하면 다음과 같다.
| 계열 | 장점 | 주의할 점 | 추천 상황 |
|---|---|---|---|
| slim | 용량과 호환성의 균형이 좋다 | 기본 이미지보다는 작지만 alpine보다 크다 | 대부분의 웹 애플리케이션 |
| alpine | 매우 작다 | musl 호환성, 네이티브 모듈 빌드 문제 가능 | 크기가 중요하고 의존성 검증이 끝난 경우 |
| distroless | 실행 표면이 작고 보안에 유리하다 | 셸과 패키지 관리자가 없어 디버깅이 어렵다 | 운영 배포 이미지, 성숙한 팀 환경 |
이미지 크기가 작다고 항상 좋은 것은 아니다. 빌드가 자주 실패하거나 장애 상황에서 원인을 찾기 어렵다면 오히려 비용이 커진다. 학습과 초기 실무에서는 slim으로 시작하고, 필요할 때 alpine이나 distroless를 검토하는 흐름이 좋다.
빌드 최적화 체크리스트
이미지를 잘 만드는 습관은 빌드 시간, 배포 속도, 보안 수준을 동시에 개선한다. Dockerfile을 작성할 때마다 다음 항목을 확인하자.
자주 바뀌지 않는 명령을 위에 둔다.
package*.json,requirements.txt,go.mod같은 의존성 정의 파일을 먼저 복사한다.의존성을 설치한 뒤 소스 코드를 복사한다.
.dockerignore로 불필요한 파일과 민감한 파일을 제외한다.빌드 도구가 최종 이미지에 필요 없다면 멀티 스테이지 빌드를 사용한다.
베이스 이미지는 크기만 보지 말고 호환성과 디버깅 가능성도 함께 본다.
운영 이미지에는 가능한 한 실행에 필요한 파일만 남긴다.
이번 장의 핵심은 "Dockerfile은 순서가 있는 빌드 문서"라는 점이다. 같은 명령을 사용해도 순서에 따라 캐시 효율이 달라지고, 최종 이미지 크기도 달라진다. 다음 장에서는 이렇게 만든 이미지를 Docker Hub 같은 이미지 레지스트리에 올리고 내려받는 방법을 다룬다.
참고
1Build cache invalidation | Docker Docs
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.