Published on

Docker 빌드 70% 단축 - 멀티스테이지·캐시 전략

Authors

서버 배포 파이프라인에서 체감 성능을 가장 크게 갉아먹는 구간은 의외로 Docker 이미지 빌드입니다. 특히 Node.js, Python, Java처럼 의존성 설치가 무거운 스택은 작은 코드 변경에도 매번 npm cipip install 이 다시 돌면서 빌드가 몇 분씩 늘어나는 일이 흔합니다.

이 글은 “빌드 속도 70% 단축”을 목표로, 멀티스테이지 빌드캐시 레이어 설계, 그리고 BuildKit 캐시 마운트CI 캐시 연동까지 한 번에 정리합니다. 핵심은 단순합니다.

  • 변경이 잦은 파일은 뒤로, 변경이 드문 파일은 앞으로
  • 의존성 설치는 캐시 가능한 단위로 분리
  • 빌드 산출물만 런타임 이미지로 복사
  • 로컬뿐 아니라 CI에서도 캐시가 “지속”되도록 설계

BuildKit 캐시가 왜 계속 미스 나는지 더 깊게 파고들고 싶다면, 아래 글도 함께 보면 좋습니다.

1) 70% 단축이 가능한 이유: 병목은 대부분 “의존성”과 “컨텍스트”

Docker 빌드가 느려지는 대표 원인은 크게 3가지입니다.

  1. 의존성 설치가 매번 재실행
    • package-lock.json 이나 poetry.lock 이 안 바뀌었는데도 레이어가 재빌드되는 구조
  2. 빌드 컨텍스트가 너무 큼
    • .git, node_modules, dist, target 등을 컨텍스트에 포함해 전송/해시 비용 증가
  3. 런타임 이미지에 빌드 도구까지 포함
    • 이미지가 커지고 pull/push 시간이 증가, 취약점 스캔도 느려짐

멀티스테이지와 캐시 전략은 이 3가지를 동시에 해결합니다.

2) 기본기: .dockerignore 로 컨텍스트부터 줄이기

아무리 레이어 캐시를 잘 설계해도 컨텍스트가 크면 매번 “보이지 않는 비용”이 발생합니다. 먼저 .dockerignore 를 정리하세요.

.git
.github
node_modules
dist
build
coverage
*.log
.env
.DS_Store

# Python
__pycache__
.venv
.pytest_cache

# Java
.target
**/target

컨텍스트 축소만으로도 CI에서 수십 초가 줄어드는 경우가 많습니다.

3) 멀티스테이지 빌드: 빌드와 런타임을 분리하라

멀티스테이지의 목적은 “빌드 도구가 들어있는 무거운 환경”과 “실행에 필요한 최소 환경”을 분리하는 것입니다.

3.1 Node.js 예시: 의존성 캐시 + 빌드 산출물만 복사

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm-slim AS deps
WORKDIR /app

# 의존성 설치는 lockfile 기반으로 캐시되도록 먼저 복사
COPY package.json package-lock.json ./

# BuildKit 캐시 마운트로 npm 캐시를 유지
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-bookworm-slim AS build
WORKDIR /app

# deps 레이어 재사용
COPY --from=deps /app/node_modules ./node_modules

# 소스는 그 다음에 복사 (자주 바뀌는 영역)
COPY . .

RUN npm run build

FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

# 런타임에 필요한 것만
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json

# 필요하면 production deps만 별도 구성하는 방식도 가능
# 여기서는 단순화를 위해 dist만 실행한다고 가정

EXPOSE 3000
CMD ["node", "dist/server.js"]

포인트는 다음과 같습니다.

  • COPY package.json package-lock.json 을 먼저 해서 의존성 레이어 캐시를 최대한 유지
  • RUN --mount=type=cachenpm 다운로드 캐시를 지속
  • 최종 runner 스테이지에는 dist 같은 산출물만 포함

3.2 Python 예시: wheels 캐시로 설치 시간을 줄이기

Python은 빌드 시 컴파일이 필요한 패키지(예: psycopg2, numpy)가 섞이면 속도가 급격히 느려집니다. 이때는 wheels 를 미리 빌드해 캐시하는 패턴이 강력합니다.

# syntax=docker/dockerfile:1.7

FROM python:3.12-slim AS build
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

COPY pyproject.toml poetry.lock ./

# pip/poetry 캐시 마운트
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -U pip wheel \
    && pip wheel --wheel-dir=/wheels .

FROM python:3.12-slim AS runner
WORKDIR /app

COPY --from=build /wheels /wheels
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-index --find-links=/wheels /wheels/* \
    && rm -rf /wheels

COPY . .

CMD ["python", "app.py"]

이 방식은 다음 상황에서 특히 효과적입니다.

  • CI에서 네트워크가 불안정하거나 느릴 때
  • 소스 변경이 잦지만 의존성 변경은 드문 서비스

추가로, Python 환경이 venv/poetry/conda 혼용으로 꼬이면 “설치는 됐는데 실행 시 모듈을 못 찾는” 상황이 생깁니다. 이런 경우는 아래 체크리스트가 빠릅니다.

4) 캐시가 잘 깨지는 Dockerfile 패턴과 해결법

4.1 COPY . . 를 너무 일찍 하는 실수

아래처럼 작성하면 소스 파일 하나만 바뀌어도 의존성 설치 레이어가 깨집니다.

COPY . .
RUN npm ci

해결은 “의존성 정의 파일만 먼저 복사”입니다.

COPY package.json package-lock.json ./
RUN npm ci
COPY . .

4.2 RUN apt-get update 가 잦은 레이어에 섞임

OS 패키지 설치는 레이어 캐시를 깨기 쉬워서, 가능한 한 안정적인 위치에 두고 자주 바뀌는 레이어와 분리하세요. 그리고 설치 후 rm -rf /var/lib/apt/lists/* 로 레이어 크기를 줄이세요.

4.3 빌드 시각/커밋 해시를 레이어에 주입

예를 들어 ARG BUILD_TIME 을 매번 바꾸고 이를 RUN echo 로 파일에 쓰면 해당 레이어 이후가 전부 재빌드됩니다. 메타데이터는 가능하면 이미지 라벨(LABEL)로 관리하거나, 정말 필요할 때만 최종 레이어에 최소 영향으로 넣으세요.

5) BuildKit 캐시 마운트: “다운로드 캐시”를 레이어 밖으로 빼기

레이어 캐시는 Dockerfile 명령과 파일 해시에 민감합니다. 반면 다운로드 캐시는 “레이어 밖”에 두면 더 잘 재사용됩니다. BuildKit의 --mount=type=cache 가 그 역할을 합니다.

자주 쓰는 대상은 다음과 같습니다.

  • npm: /root/.npm
  • pnpm: /pnpm/store 또는 /root/.local/share/pnpm/store
  • pip: /root/.cache/pip
  • apt: /var/cache/apt

예시(apt 캐시 마운트):

# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

주의할 점은 캐시 마운트는 “빌드 머신”에 캐시가 남아야 효과가 있다는 것입니다. 로컬에서는 체감이 크지만, CI에서는 아래 섹션처럼 캐시를 내보내고 가져와야 합니다.

6) CI에서 70% 단축을 만드는 핵심: 원격 캐시(cache-to, cache-from)

로컬에서만 빠르고 CI가 느리면 의미가 없습니다. GitHub Actions 기준으로는 docker/build-push-action 의 캐시 기능을 적극 사용하세요.

name: build
on:
  push:
    branches: ["main"]

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/your-org/your-app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

여기서 type=gha 는 GitHub Actions 캐시 스토리지를 사용합니다. 조직/리포 정책에 따라 캐시가 자주 날아가거나 용량 제한에 걸릴 수 있으니, 상황에 따라 레지스트리 기반 캐시(type=registry)도 고려하세요.

Node.js CI 캐시 자체(예: ~/.npm)를 별도로 잡는 방법도 있지만, Docker 빌드 캐시와 중복될 수 있습니다. CI 캐시 설계를 더 넓게 보고 싶다면 아래 글도 참고할 만합니다.

7) 실전 체크리스트: 빌드 시간 70% 단축을 만드는 순서

아래 순서대로 적용하면 “효과가 큰 것부터” 안전하게 최적화할 수 있습니다.

  1. .dockerignore 로 컨텍스트 축소
  2. 멀티스테이지로 빌드/런타임 분리
  3. 의존성 설치 레이어를 lockfile 기반으로 분리
  4. BuildKit 캐시 마운트로 다운로드 캐시 유지
  5. CI에서 cache-to/cache-from 로 원격 캐시 지속
  6. 캐시 미스가 나면 원인을 로그로 확인하고(빌드 단계별 재실행 여부), Dockerfile을 다시 쪼개기

8) 자주 묻는 질문

8.1 멀티스테이지면 무조건 빨라지나요?

빌드 자체가 빨라진다기보다, 재빌드 범위가 줄어들어 평균 시간이 크게 단축됩니다. 최종 이미지가 작아져 pull/push 도 빨라지는 부수 효과가 큽니다.

8.2 캐시가 갑자기 안 먹을 때 가장 먼저 볼 것은?

  • Dockerfile에서 COPY . . 위치
  • lockfile이 의도치 않게 변경되는지
  • CI에서 캐시 저장/복원이 실제로 되는지
  • 빌드 컨텍스트에 불필요한 파일이 포함되는지

캐시 미스 원인을 체계적으로 추적하는 방법은 아래 글에서 더 자세히 다룹니다.

9) 결론: “레이어 캐시”와 “다운로드 캐시”를 분리하라

Docker 빌드 최적화는 감으로 하는 튜닝이 아니라, 캐시가 유지되는 경계면을 설계하는 작업에 가깝습니다. 멀티스테이지로 빌드 산출물만 남기고, 의존성 레이어를 lockfile 중심으로 고정하고, BuildKit 캐시 마운트와 CI 원격 캐시로 다운로드 비용까지 줄이면 체감상 50%에서 70% 이상까지도 충분히 가능합니다.

다음 단계로는 “캐시가 깨지는 정확한 지점”을 로그와 비교로 좁혀가며, Dockerfile을 서비스 특성에 맞게 더 잘게 분해해보세요. 빌드 시간은 대부분 그 지점에서 결정됩니다.