도커파일 레이어 캐싱 마스터하기: 초고속 컨테이너 빌드를 위한 가이드
도커파일 레이어 캐싱을 마스터하여 도커 빌드를 가속화하고 개발 워크플로우를 효율적으로 개선하세요. 이 포괄적인 가이드는 명령어 순서 최적화, 멀티 스테이지 빌드 활용, 캐시 메커니즘 이해를 통해 빌드 시간을 크게 단축하는 모범 사례를 제시합니다. 도커 빌드를 번개처럼 빠르게 만들고 CI/CD 효율성을 향상시키는 방법을 배워보세요.
도커파일 레이어 캐싱 마스터하기: 초고속 컨테이너 빌드를 위한 가이드
도커파일 레이어 캐싱은 변경 사항을 생각하는 동안 빌드가 완료되는 것과 모든 커밋이 비용이 많이 드는 것의 차이를 만듭니다. 캐시는 복잡하지 않지만 무자비합니다. 잘못된 파일을 너무 일찍 복사하면 도커는 느린 단계를 다시 실행할 수밖에 없습니다.
핵심 습관은 간단합니다. 안정적인 작업을 먼저 배치하고, 자주 변경되는 작업을 나중에 배치하며, 빌드 컨텍스트를 깨끗하게 유지하세요. 이를 이해하면 대부분의 도커파일 성능 문제가 빌드 출력에서 명확히 드러납니다.
도커 레이어 캐싱 이해하기
도커는 컨테이너 이미지를 레이어로 빌드합니다. 도커파일의 각 명령어(RUN, COPY, ADD 등)는 새로운 레이어를 생성합니다. 이미지를 빌드할 때 도커는 이전 빌드에서 동일한 컨텍스트(예: COPY의 동일한 파일)로 해당 명령어를 실행했는지 확인합니다. 캐시 히트가 발생하면 도커는 명령어를 다시 실행하는 대신 캐시에서 기존 레이어를 재사용합니다. 이는 특히 계산 비용이 많이 드는 작업이나 대용량 파일을 복사할 때 상당한 시간을 절약할 수 있습니다.
핵심 개념:
- 레이어: 도커파일 명령어로 생성된 불변 파일 시스템 스냅샷입니다.
- 캐시 히트: 도커가 특정 명령어에 대해 캐시에서 동일한 레이어를 찾은 경우입니다.
- 캐시 미스: 도커가 일치하는 레이어를 찾지 못해 명령어를 실행해야 하며, 이후 모든 명령어의 캐시를 무효화합니다.
도커 캐시 작동 방식: 메커니즘
도커는 명령어 자체와 관련된 파일을 기반으로 캐시 히트를 결정합니다. RUN echo 'hello'와 같은 명령어의 경우 명령어 문자열이 기본 캐시 키입니다. COPY 또는 ADD와 같은 명령어의 경우 도커는 명령어뿐만 아니라 복사되는 파일의 체크섬도 계산합니다. 명령어나 파일의 체크섬이 변경되면 캐시 미스가 발생합니다.
이는 도커파일 명령어나 관련 파일의 변경이 해당 명령어와 이후 모든 명령어의 캐시를 무효화한다는 것을 의미합니다. 이는 최적화의 중요한 포인트입니다.
최대 캐시 활용을 위한 도커파일 최적화
도커 빌드 캐시를 활용하는 기술은 캐시 무효화를 최소화하도록 도커파일을 구성하는 데 있습니다. 특히 자주 변경되는 명령어의 경우 더욱 그렇습니다. 일반적인 원칙은 변경 가능성이 낮은 명령어를 도커파일 앞부분에, 자주 변경되는 명령어를 뒷부분에 배치하는 것입니다.
1. 명령어를 전략적으로 정렬하세요
황금률: 안정적인 명령어를 먼저 배치하세요.
일반적인 웹 애플리케이션 도커파일을 생각해보세요. 종속성을 설치하고, 애플리케이션 코드를 복사한 다음, 빌드를 실행하거나 서버를 시작하는 단계가 있을 수 있습니다.
비효율적인 예 (캐시 무효화):
FROM ubuntu:latest
# 시스템 패키지 설치 (거의 변경되지 않음)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# 애플리케이션 코드 복사 (매우 자주 변경됨)
COPY . .
# Python 종속성 설치 (자주 변경됨)
RUN pip install --no-cache-dir -r requirements.txt
# ... 기타 명령어
이 예에서는 애플리케이션 코드의 한 줄이라도 변경될 때마다 (COPY . .가 실행되므로) COPY . .와 이후 모든 명령어(RUN pip install ...)의 캐시가 무효화됩니다. 즉, requirements.txt가 변경되지 않았더라도 pip install이 다시 실행되어 빌드 시간이 길어집니다.
최적화된 예 (캐시 최대화):
FROM ubuntu:latest
# 시스템 패키지 설치 (거의 변경되지 않음)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# 종속성 파일만 먼저 복사 (덜 자주 변경됨)
COPY requirements.txt .
# Python 종속성 설치 (requirements.txt가 변경되지 않으면 캐시됨)
RUN pip install --no-cache-dir -r requirements.txt
# 나머지 애플리케이션 코드 복사 (매우 자주 변경됨)
COPY . .
# ... 기타 명령어
requirements.txt를 먼저 복사하고 바로 pip install을 실행하면 도커가 종속성 설치 레이어를 캐시할 수 있습니다. 애플리케이션 코드만 변경되고 requirements.txt가 동일하면 pip install 단계가 캐시되어 빌드 속도가 크게 향상됩니다.
2. 멀티 스테이지 빌드 활용
멀티 스테이지 빌드는 이미지 크기를 줄이는 강력한 기술이지만, 중간 빌드 환경을 분리하여 빌드 시간에도 간접적으로 이점을 줍니다. 각 스테이지는 자체 캐시된 레이어를 가질 수 있습니다.
# Stage 1: Builder
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp
# Stage 2: Final image
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/myapp .
CMD ["./myapp"]
이 시나리오에서는 애플리케이션 소스 코드만 변경되고 (go.mod와 go.sum은 변경되지 않은 경우) 빌더 스테이지의 go mod download 단계가 캐시됩니다. 빌더 스테이지가 컴파일을 다시 실행해야 하더라도 최종 스테이지는 여전히 alpine:latest 이미지를 기반으로 하며, 이는 캐시될 가능성이 높고 COPY --from=builder 명령어만 아티팩트 myapp이 변경된 경우 다시 실행됩니다.
3. ADD와 COPY를 현명하게 사용하세요
- **
COPY**는 일반적으로 로컬 파일을 이미지로 복사하는 데 선호됩니다. 직관적이고 예측 가능합니다. - **
ADD**는 tarball 추출 및 원격 URL 가져오기와 같은 추가 기능이 있습니다. 그러나 이러한 추가 기능은 예기치 않은 동작을 유발하고 캐시 무효화에 다르게 영향을 줄 수 있습니다.ADD의 고급 기능이 명시적으로 필요하지 않은 한COPY를 사용하세요.
COPY를 사용할 때는 세분화하세요. COPY . . 대신 위의 최적화된 예제와 같이 변경 속도가 다른 특정 디렉터리나 파일을 복사하는 것을 고려하세요.
4. 동일한 RUN 명령어에서 정리하세요
캐시 팽창을 방지하고 이미지 크기를 줄이려면 항상 생성된 동일한 RUN 명령어 내에서 아티팩트(예: 패키지 관리자 캐시)를 정리하세요.
나쁜 예:
RUN apt-get update && apt-get install -y some-package
RUN rm -rf /var/lib/apt/lists/*
여기서 rm 명령어는 별도의 RUN 명령어입니다. some-package가 업데이트되어 첫 번째 RUN의 캐시 미스가 발생하면 두 번째 RUN이 여전히 실행됩니다. 새 레이어에 정리가 꼭 필요하지 않더라도 말이죠. 더 중요한 것은 첫 번째 RUN으로 생성된 중간 캐시 레이어에 두 번째 RUN으로 정리되기 전에 다운로드된 패키지 목록이 여전히 포함될 수 있다는 점입니다.
좋은 예:
RUN apt-get update && apt-get install -y some-package && rm -rf /var/lib/apt/lists/*
이렇게 하면 패키지 설치 중 생성된 임시 파일이 즉시 제거되고, 생성된 캐시 레이어가 더 깨끗한 파일 시스템 상태를 나타냅니다.
5. 매번 종속성을 설치하지 마세요
앞서 설명한 대로 종속성 정의 파일(requirements.txt, package.json, Gemfile 등)을 복사하고 애플리케이션 소스 코드를 복사하기 전에 종속성을 설치하는 것은 기본적인 캐싱 최적화입니다.
6. 캐시 버스팅 (필요한 경우)
캐시를 최대화하는 것이 목표이지만, 때로는 캐시 재빌드를 강제하고 싶을 수 있습니다. 이를 캐시 버스팅이라고 합니다. 일반적인 기술은 다음과 같습니다:
- 주석 변경: 도커파일 주석(
#)은 무시되므로 작동하지 않습니다. - 더미 인수 추가:
ARG를 사용하여 캐시를 깨기 위해 변경하는 변수를 도입할 수 있습니다.
그런 다음ARG CACHEBUST=1 RUN echo "Cache bust: ${CACHEBUST}" # CACHEBUST가 변경되면 이 명령어가 다시 실행됩니다docker build --build-arg CACHEBUST=$(date +%s) .로 빌드합니다. - 이전
RUN명령어 수정: 도커파일 앞부분의 명령어를 변경하면 이후 모든 명령어의 캐시가 무효화됩니다.
캐시 버스팅은 드물게 사용해야 하며, 일반적으로 외부 리소스의 새 다운로드를 보장하거나 표준 캐싱 메커니즘으로 잘 처리되지 않는 항목의 클린 빌드가 필요할 때 사용합니다.
도커 빌드킷과 향상된 캐싱
최신 버전의 도커는 기본 빌더 엔진으로 빌드킷을 도입했습니다. 빌드킷은 다음과 같은 캐싱의 상당한 개선을 제공합니다:
- 원격 캐싱: 여러 머신과 CI/CD 러너 간에 빌드 캐시를 공유할 수 있습니다.
- 더 세분화된 캐싱: 변경된 사항을 더 잘 식별합니다.
- 병렬 빌드 실행: 캐시 히트 없이도 빌드 속도를 높입니다.
빌드킷은 일반적으로 기본적으로 활성화되어 있으며 종종 즉시 더 나은 캐싱을 제공합니다. 그러나 위에서 설명한 원칙을 이해하면 빌드킷에 대해서도 도커파일을 최적화할 수 있습니다.
효과적인 도커파일 캐싱을 위한 팁
- 도커파일을 깔끔하고 체계적으로 유지하세요: 가독성은 최적화 기회를 식별하는 데 도움이 됩니다.
- 캐시를 테스트하세요: 변경 후 도커 빌드 출력을 관찰하세요. 캐시 히트를 확인하려면
[internal]또는CACHED태그를 찾으세요. .dockerignore를 사용하세요: 불필요한 파일(node_modules,.git, 빌드 아티팩트 등)이 빌드 컨텍스트에 복사되는 것을 방지하여COPY명령어 속도를 높이고 의도하지 않은 캐시 무효화 가능성을 줄입니다.- 정기적으로 도커 캐시를 정리하세요: 시간이 지남에 따라 캐시가 커질 수 있습니다.
docker builder prune을 사용하여 사용되지 않는 빌드 캐시 레이어를 제거하세요.
실제 Node.js 예제
캐싱 문제는 Node.js 프로젝트에서 가장 쉽게 확인할 수 있습니다. 이 도커파일은 작동하지만 시간을 낭비합니다:
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["npm", "start"]
모든 소스 변경이 COPY . .를 무효화하므로 npm ci가 다시 실행됩니다. 더 나은 버전은 종속성 메타데이터를 애플리케이션 코드와 분리합니다:
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]
이제 src/routes/account.ts의 변경이 종속성 설치 레이어를 무효화하지 않습니다. package-lock.json의 변경은 여전히 무효화하며, 이는 정확히 원하는 동작입니다.
프로덕션 이미지의 경우 멀티 스테이지 빌드와 결합하세요:
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
이것은 더 길지만 종속성 다운로드, 애플리케이션 빌드, 런타임 콘텐츠의 세 가지 관심사를 분리합니다. CI 러너는 비용이 많이 드는 부분을 캐시할 수 있으며, 최종 이미지는 전체 소스 트리나 개발 종속성이 필요하지 않습니다.
.dockerignore를 캐시 전략의 일부로 사용하세요
레이어 캐싱은 도커파일 명령어 순서에만 국한되지 않습니다. 빌드 컨텍스트도 중요합니다. .git, 로컬 테스트 출력, 스크린샷, 커버리지 보고서 또는 node_modules를 빌드 컨텍스트로 보내면 도커가 체크섬을 계산할 파일이 더 많아지고 COPY가 무효화될 가능성이 높아집니다.
애플리케이션의 일반적인 .dockerignore는 다음과 같을 수 있습니다:
.git
node_modules
dist
coverage
.env
*.log
tmp
.DS_Store
.env에 주의하세요. 보안 및 재현성을 위해 일반적으로 이미지에서 제외하는 것이 좋습니다. 런타임 구성은 컨테이너가 시작될 때 전달되어야 하며, 진정으로 민감하지 않은 빌드 타임 구성이 아니라면 빌드에 포함시키지 마세요.
캐시가 당신을 속일 때를 알라
캐싱은 실수를 숨길 수 있습니다. 일반적인 예는 외부 저장소에 의존하는 패키지 관리자 명령어입니다. 다음과 같이 작성하면:
RUN apt-get update
RUN apt-get install -y curl
캐시된 apt-get update 레이어는 이후 설치 단계가 최신 패키지 메타데이터를 기대하는 동안 오래될 수 있습니다. 업데이트와 설치를 함께 유지하세요:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
동일한 아이디어가 언어 종속성에도 적용됩니다. 잠금 파일은 빌드를 더 반복 가능하게 만들고 캐싱이 예측 가능하게 동작하도록 돕습니다. package-lock.json, pnpm-lock.yaml, poetry.lock, Gemfile.lock, go.sum 또는 스택에 해당하는 것을 사용하세요. 도커파일이 네트워크에서 유동적인 종속성 버전을 설치하는 경우 캐시 히트는 빠를 수 있지만 재빌드는 나중에 놀라움을 줄 수 있습니다.
패키지 관리자를 위한 빌드킷 캐시 마운트
빌드킷은 패키지 관리자의 캐시를 최종 이미지 레이어에 넣지 않고 속도를 높이는 캐시 마운트를 지원합니다. 예를 들어 npm:
# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
Python의 경우:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
COPY . .
이것이 도움이 되는지 여부는 CI 설정에 따라 다릅니다. 원격 캐시가 없는 수명이 짧은 러너는 여전히 콜드 스타트합니다. 작업 간에 빌드킷 캐시를 보존하는 러너는 많은 시간을 절약할 수 있습니다.
CI의 원격 캐시
로컬 캐싱은 쉽습니다. CI 캐싱은 의도적인 설정이 필요합니다. docker buildx를 사용하면 레지스트리에서 캐시 메타데이터를 푸시하고 풀할 수 있습니다:
docker buildx build \
--cache-from=type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to=type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
-t registry.example.com/myapp:${GIT_SHA} \
--push .
이렇게 하면 새 러너가 이전 러너가 빌드한 레이어를 재사용할 수 있습니다. 이는 종속성 설치 및 대규모 컴파일 단계에 특히 유용합니다. 액세스 제어를 염두에 두세요. 빌드 캐시에는 중간 단계의 파일 시스템 스냅샷이 포함될 수 있으므로 프라이빗 레지스트리 캐시를 공개 스크래치 공간이 아닌 빌드 인프라의 일부로 취급하세요.
느린 도커 빌드 디버깅
빌드가 예기치 않게 느린 경우 추측하지 말고 출력을 읽으세요. 빌드킷은 캐시된 단계와 실행된 단계를 출력합니다. 종속성 설치 단계가 캐시되지 않은 경우 이전 COPY 또는 RUN 줄을 살펴보세요. 그 앞의 무언가가 변경되었습니다.
유용한 명령어:
docker build --progress=plain .
docker buildx du
docker builder prune --filter until=24h
다른 작업이 캐시에 의존하는 경우 공유 빌드 호스트에서 맹목적으로 정리하지 마세요. 정리는 디스크 사용량이 높을 때 유용하지만 다음 빌드를 느리게 만들 수 있습니다.
실용적인 검토 체크리스트
더 빠른 빌드를 위해 도커파일을 검토할 때 위에서 아래로 이동하며 다음을 질문하세요:
- 안정적인 기본 설정 단계가 자주 변경되는 소스 파일보다 먼저 있습니까?
- 종속성 매니페스트가 전체 애플리케이션보다 먼저 복사됩니까?
.dockerignore가 로컬 노이즈와 비밀을 제외합니까?- 패키지 관리자 캐시가 동일한 레이어에서 처리되거나 빌드킷 캐시 마운트로 처리됩니까?
- 최종 이미지에는 실행에 필요한 것만 포함되어 있습니까?
- 러너가 일회용인 경우 CI가 원격 캐시를 사용합니까?
빠른 도커 빌드는 일반적으로 많은 작고 지루한 선택의 결과입니다. 느리고 안정적인 작업을 먼저 배치하세요. 자주 변경되는 작업을 나중에 배치하세요. 빌드 컨텍스트를 깨끗하게 유지하세요. 그런 다음 캐시가 사용되고 있다고 가정하지 말고 빌드 출력으로 확인하세요.