이 글은 실제 프로덕션 환경에서 Docker 빌드 시간을 197초에서 50초로 단축한 경험을 바탕으로 작성되었습니다.
목차
- Docker 빌드가 느린 이유
- BuildKit과 레이어 캐시
- 최적화 기법 1: 레이어 캐시 순서 최적화
- 최적화 기법 2: 캐시 마운트
- 최적화 기법 3: 원격 캐시 (CI/CD)
- 최적화 기법 4: 네이티브 빌드
- 실전 적용: pnpm 모노레포 Dockerfile
- docker-compose 최적화
- 결과 및 정리
Docker 빌드가 느린 이유
Docker 빌드가 느린 주요 원인들:
| 원인 | 설명 |
|---|---|
| 캐시 미활용 | 매번 처음부터 빌드 |
| 비효율적인 레이어 순서 | 자주 변경되는 파일이 앞에 위치 |
| 에뮬레이션 빌드 | 다른 아키텍처로 빌드 시 QEMU 사용 |
| 불필요한 파일 복사 | node_modules, .git 등 포함 |
이 문제들을 해결하기 위한 핵심 전략은 캐시 활용과 네이티브 빌드입니다.
BuildKit과 레이어 캐시
BuildKit이란?
BuildKit은 Docker의 차세대 빌드 엔진으로, 캐시를 더 효율적으로 사용할 수 있게 해줍니다.
# BuildKit 활성화 (Docker 18.09+)
DOCKER_BUILDKIT=1 docker build .
# 또는 Dockerfile 최상단에 명시
# syntax=docker/dockerfile:1레이어 캐시의 원리
Docker는 Dockerfile의 각 명령어마다 레이어를 생성하고, 캐시 키를 통해 재사용 여부를 판단합니다.
FROM node:lts-slim # 레이어 1
COPY package.json ./ # 레이어 2 (package.json 내용 기반 캐시 키)
RUN pnpm install # 레이어 3 (레이어 2의 캐시 키 + 명령어)
COPY . . # 레이어 4 (모든 파일 내용 기반 캐시 키)
RUN pnpm build # 레이어 5핵심 포인트: 특정 레이어의 캐시가 무효화되면, 그 이후의 모든 레이어도 무효화됩니다.
최적화 기법 1: 레이어 캐시 순서 최적화
문제: 비효율적인 Dockerfile
# ❌ 나쁜 예: 소스 코드 변경 시 의존성도 재설치
FROM node:lts-slim
WORKDIR /app
COPY . . # 소스 코드 포함
RUN pnpm install --frozen-lockfile # 매번 재설치
RUN pnpm build소스 코드가 변경될 때마다 COPY . .의 캐시가 무효화되어, pnpm install도 매번 다시 실행됩니다.
해결: 의존성 파일 먼저 복사
# ✅ 좋은 예: 의존성과 소스 코드 분리
FROM node:lts-slim
WORKDIR /app
# 1단계: 의존성 파일만 먼저 복사 (변경 빈도 낮음)
COPY package.json pnpm-lock.yaml ./
# 2단계: 의존성 설치 (package.json 변경 시에만 재실행)
RUN pnpm install --frozen-lockfile
# 3단계: 소스 코드 복사 (변경 빈도 높음)
COPY . .
# 4단계: 빌드
RUN pnpm build효과: 소스 코드만 변경 시 1~2단계는 캐시 히트 → 빌드 시간 50-70% 단축
pnpm 모노레포에서의 적용
모노레포에서는 각 워크스페이스의 package.json도 먼저 복사해야 합니다:
# 루트 의존성 파일
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
# 각 워크스페이스의 package.json만 복사
COPY packages/utils/package.json ./packages/utils/
COPY packages/ui/package.json ./packages/ui/
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/
# 의존성 설치
RUN pnpm install --frozen-lockfile
# 소스 코드 복사
COPY packages/ ./packages/
COPY apps/ ./apps/
# 빌드
RUN pnpm build최적화 기법 2: 캐시 마운트
캐시 마운트란?
RUN 명령 실행 시 생성되는 임시 데이터(패키지 캐시 등)를 빌더 내부에 저장해두고 재사용하는 기법입니다.
# 일반적인 RUN 명령
RUN pnpm install # 매번 패키지 다운로드
# 캐시 마운트 사용
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install # 캐시된 패키지 재사용작동 원리
┌─────────────────────────────────────────────────┐
│ BuildKit Builder │
│ ┌─────────────────────────────────────────┐ │
│ │ 캐시 볼륨 디렉토리 │ │
│ │ /root/.local/share/pnpm/store │ │
│ └──────────────────┬──────────────────────┘ │
│ │ 마운트 │
│ ┌──────────────────▼──────────────────────┐ │
│ │ 임시 빌드 컨테이너 │ │
│ │ RUN pnpm install 실행 │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
pnpm 캐시 마운트 적용
# syntax=docker/dockerfile:1
FROM node:lts-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# pnpm store 캐시 마운트
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
# turbo 캐시도 마운트 가능
RUN --mount=type=cache,target=/app/.turbo \
pnpm build권한 문제 해결
non-root 유저로 실행할 경우, 캐시 마운트에 uid/gid를 지정해야 합니다:
# node 유저의 uid는 보통 1000
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,uid=1000,gid=1000 \
pnpm install --frozen-lockfile캐시 마운트의 한계
| 장점 | 단점 |
|---|---|
| 패키지 다운로드 시간 절감 | 로컬 빌드에서만 효과 |
| 이미지 크기에 영향 없음 | CI 환경에서는 매번 새 VM → 캐시 없음 |
| 설정이 간단함 | Docker 18.09+ 필요 |
최적화 기법 3: 원격 캐시 (CI/CD)
왜 원격 캐시가 필요한가?
GitHub Actions 같은 CI 환경에서는 매번 새로운 VM이 생성되므로, 로컬 캐시를 재사용할 수 없습니다.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Build 1 │ │ Build 2 │ │ Build 3 │
│ (새 VM) │ │ (새 VM) │ │ (새 VM) │
│ 캐시 없음 │ │ 캐시 없음 │ │ 캐시 없음 │
└─────────────┘ └─────────────┘ └─────────────┘
원격 캐시 적용
레이어 캐시를 외부 저장소(Docker Hub, ECR, GitHub Actions Cache)에 저장합니다:
docker buildx build \
--cache-from type=registry,ref=myregistry/myapp:cache \
--cache-to type=registry,ref=myregistry/myapp:cache,mode=max \
-t myregistry/myapp:latest \
.GitHub Actions 캐시 사용
# .github/workflows/build.yml
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myregistry/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max캐시 타입 비교
| 타입 | 저장 위치 | 용량 제한 | 속도 |
|---|---|---|---|
type=registry | Docker Registry | 무제한 | 중간 |
type=gha | GitHub Actions | 10GB | 빠름 |
type=s3 | AWS S3 | 무제한 | 중간 |
최적화 기법 4: 네이티브 빌드
에뮬레이션 빌드의 문제
GitHub Actions의 ubuntu-latest는 AMD64(x86_64) 아키텍처입니다. ARM64 서버(AWS Graviton 등)용 이미지를 빌드하려면 QEMU 에뮬레이션을 사용해야 합니다.
# ❌ 느린 방법: 에뮬레이션 빌드
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build
run: docker buildx build --platform linux/arm64 .문제: 에뮬레이션으로 인해 빌드 시간이 3-10배 증가
네이티브 빌드 적용
# ✅ 빠른 방법: ARM 러너에서 네이티브 빌드
runs-on: ubuntu-24.04-arm # ARM64 러너 사용
steps:
- name: Build
run: docker buildx build . # 에뮬레이션 불필요러너 선택 가이드
| 타겟 서버 | 추천 러너 | 비고 |
|---|---|---|
| AMD64 (x86_64) | ubuntu-latest | 기본값 |
| ARM64 (Graviton) | ubuntu-24.04-arm | GitHub 제공 ARM 러너 |
| 멀티 플랫폼 | Self-hosted 또는 각각 빌드 | 비용/시간 트레이드오프 |
실전 적용: pnpm 모노레포 Dockerfile
최적화 전 (197초 소요)
FROM node:lts-slim
RUN npm install -g pnpm
WORKDIR /opt/app
# 모든 파일 한 번에 복사
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm build최적화 후 (50~77초 소요)
# syntax=docker/dockerfile:1
FROM node:lts-slim
RUN npm install -g pnpm
WORKDIR /opt/app
# 1단계: 의존성 파일만 먼저 복사 (COPY --chown으로 권한 설정)
COPY --chown=node:node pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
# 2단계: 각 워크스페이스의 package.json 복사
COPY --chown=node:node packages/utils/package.json ./packages/utils/
COPY --chown=node:node packages/ui/package.json ./packages/ui/
COPY --chown=node:node apps/web/package.json ./apps/web/
COPY --chown=node:node apps/api/package.json ./apps/api/
# 3단계: 의존성 설치 (캐시 마운트 + 권한 설정)
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,uid=1000,gid=1000 \
pnpm install --frozen-lockfile
# 4단계: 소스 코드 복사
COPY --chown=node:node packages/ ./packages/
COPY --chown=node:node apps/ ./apps/
# non-root 유저로 전환
USER node
# 5단계: 빌드 (turbo 캐시 마운트)
RUN --mount=type=cache,target=/opt/app/.turbo,uid=1000,gid=1000 \
pnpm build최적화 포인트 정리
| 기법 | 적용 위치 | 효과 |
|---|---|---|
| 레이어 순서 최적화 | package.json 먼저 복사 | 소스 변경 시 install 스킵 |
| 캐시 마운트 | pnpm store, turbo 캐시 | 패키지 다운로드 생략 |
| COPY —chown | 모든 COPY 명령 | chown -R 제거로 속도 향상 |
| uid/gid 지정 | 캐시 마운트 옵션 | 권한 문제 방지 |
docker-compose 최적화
문제: 컨테이너 시작 시 중복 빌드
# ❌ 나쁜 예: 이미 빌드된 이미지인데 또 빌드
services:
app:
image: myapp:latest
command: /bin/sh -c "pnpm install && pnpm build && pnpm start"해결: 이미지 내 빌드 결과 사용
# ✅ 좋은 예: 빌드된 이미지 그대로 실행
services:
app:
image: myapp:latest
command: pnpm startVolume Bind 고려사항
개발 환경: Volume bind로 hot reload 활용
# 개발용 docker-compose.yml
services:
app:
image: myapp:latest
command: pnpm dev
volumes:
- ./apps/web:/opt/app/apps/web # 소스 코드 바인딩프로덕션 환경: 이미지 내 빌드 결과 사용 (불변 인프라)
# 프로덕션용 docker-compose.yml
services:
app:
image: myapp:latest
command: pnpm start
volumes:
# 로그, 업로드 파일 등만 바인딩
- ./logs:/app/logs빌드 순서 보장
services:
base:
build:
context: .
dockerfile: Dockerfile
image: myapp:latest
web:
image: myapp:latest
depends_on:
base:
condition: service_completed_successfully
command: pnpm start결과 및 정리
실제 측정 결과
| 항목 | 최적화 전 | 최적화 후 | 개선율 |
|---|---|---|---|
| 첫 이미지 빌드 | 197초 | 77초 | 61% |
| 이미지 재사용 시 | 198초 | 50초 | 75% |
| 이미지 용량 | 7.73GB | 5.45GB | 30% |
최적화 체크리스트
-
# syntax=docker/dockerfile:1추가 (BuildKit 활성화) - package.json 등 의존성 파일 먼저 COPY
- 소스 코드는 마지막에 COPY
-
--mount=type=cache로 pnpm store 캐시 -
COPY --chown으로 권한 설정 (chown -R 제거) - CI 환경에서 원격 캐시 사용
- 타겟 서버와 동일한 아키텍처에서 빌드
- docker-compose에서 중복 빌드 제거
추가 고려사항
- .dockerignore 활용: node_modules, .git, logs 등 제외
- 멀티스테이지 빌드: 빌드 도구를 최종 이미지에서 제외
- 베이스 이미지 선택: alpine vs slim vs 일반 이미지
댓글 (0)