Docker 이미지 크기 줄이기: 더 빠른 빌드를 위한 실용 가이드

멀티 스테이지 빌드, 캐시 친화적인 Dockerfile, 깨끗한 빌드 컨텍스트를 사용하여 Docker 이미지를 더 작게 만들고 빌드를 더 빠르게 만듭니다.

Docker 이미지 크기 줄이기: 더 빠른 빌드를 위한 실용 가이드

Docker 이미지 크기를 줄이는 것은 docker image ls에서 가장 작은 숫자를 쫓는 것이 아닙니다. 유용한 목표는 빌드가 빠르고, 반복 가능하며, 패치하기 쉽고, CI, 배포 또는 로컬 개발 속도를 늦추지 않을 만큼 작은 것입니다.

비대한 이미지는 일반적으로 몇 가지 흔한 습관에서 비롯됩니다: 저장소 전체를 너무 일찍 복사하거나, 런타임 이미지에 빌드 도구를 설치하거나, 패키지 관리자 캐시를 남겨두거나, latest 태그를 사용하거나, Docker에 거대한 빌드 컨텍스트를 보내는 것입니다. 이러한 습관을 고치면 대부분의 팀이 특별한 조치 없이도 더 깔끔한 빌드를 볼 수 있습니다.

현재 이미지를 측정하는 것으로 시작하세요:

docker image ls my-app
docker history --no-trunc my-app:latest

docker history는 어떤 Dockerfile 명령어가 크기를 추가했는지 보여줍니다. 하나의 RUN 단계가 많은 양을 추가한다면, 다른 모든 것을 변경하기 전에 해당 단계를 검사하세요. 더 깊이 살펴보려면 dive와 같은 도구가 각 레이어에서 추가 및 제거된 파일을 보여줄 수 있지만, 첫 번째 패스에는 내장 명령어로 충분합니다.

호환성을 위해 기본 이미지를 먼저 선택하고, 크기는 그 다음으로

기본 이미지 선택은 중요하지만, 가장 작은 이미지가 항상 최고의 이미지는 아닙니다. Alpine은 musl libc와 최소한의 사용자 공간을 사용하기 때문에 작습니다. 이는 많은 Go, Node, Python 및 유틸리티 이미지에 잘 작동하지만, 네이티브 확장, 사전 빌드된 바이너리, DNS 동작 또는 glibc를 기대하는 패키지와 마찰을 일으킬 수 있습니다.

실용적인 순서는 다음과 같습니다:

  1. 런타임에 공식 이미지를 사용하세요.
  2. python:latest보다는 python:3.12-slim과 같은 버전 태그를 선호하세요.
  3. 앱에 네이티브 종속성이 있는 경우 Alpine으로 바로 넘어가기 전에 slim을 시도해보세요.
  4. 런타임에 정확히 무엇이 필요한지 이미 알고 있다면 distroless 또는 scratch를 사용하세요.

예를 들어, psycopg, 이미지 라이브러리 또는 암호화 패키지를 사용하는 Python 서비스는 python:3.12-alpine보다 python:3.12-slim에서 유지 관리하기 더 쉬울 수 있습니다. Alpine 이미지는 처음에는 더 작을 수 있지만, 종속성을 빌드하기 위해 컴파일러와 호환성 패키지를 추가하면 최종 결과가 더 단순하거나 훨씬 작지 않을 수 있습니다.

Go 서비스의 경우, 정적으로 컴파일된 바이너리가 런타임에 거의 필요하지 않을 수 있기 때문에 scratch 또는 distroless가 탁월할 수 있습니다. 그렇더라도 TLS 인증서, 시간대 데이터 및 비루트 사용자를 기억하세요. HTTPS 요청을 할 수 없는 작은 이미지는 유용한 프로덕션 이미지가 아닙니다.

멀티 스테이지 빌드를 사용하여 빌드 도구를 프로덕션에서 분리하세요

멀티 스테이지 빌드는 컴파일된 앱 및 프론트엔드 빌드의 Docker 이미지 크기를 줄이는 가장 신뢰할 수 있는 방법입니다. 빌더 스테이지는 클 수 있습니다. 컴파일러, 패키지 관리자, 테스트 도구 및 소스 파일을 포함할 수 있습니다. 최종 스테이지는 앱 실행에 필요한 것만 포함해야 합니다.

Go 예제:

FROM golang:1.22 AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

중요한 부분은 정확한 기본 이미지가 아닙니다. 중요한 부분은 경계입니다: 소스 코드, 모듈 캐시 및 컴파일러는 builder에 남고; 최종 이미지는 바이너리를 가져옵니다.

Node 앱도 비슷한 형태를 가지지만, 개발 종속성과 프로덕션 종속성을 분리해야 합니다:

FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

nginx가 제공하는 프론트엔드 자산의 경우, 최종 이미지에 Node가 전혀 필요하지 않습니다:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

이 패턴은 node_modules, 소스 트리 및 빌드 도구를 런타임 이미지에서 제거합니다.

Docker 캐시를 활용하세요

Docker 캐시 무효화는 간단하지만 냉혹합니다. 명령어가 변경되면 Docker는 해당 레이어와 그 이후의 모든 레이어를 다시 빌드합니다. 종속성을 설치하기 전에 전체 프로젝트를 복사하면 모든 코드 편집이 종속성 재설치를 강제할 수 있습니다.

이것은 느립니다:

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

이것은 일반적으로 더 빠릅니다:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

종속성 레이어는 requirements.txt가 변경될 때만 다시 빌드됩니다. 소스 변경은 이후의 COPY . . 레이어만 다시 빌드합니다.

동일한 규칙이 Maven, Gradle, npm, pnpm, Cargo 및 Go 모듈에 적용됩니다. 종속성 매니페스트를 먼저 복사하고, 종속성을 설치하거나 다운로드한 다음, 나머지 소스를 복사하세요.

.dockerignore로 빌드 컨텍스트를 작게 유지하세요

Docker는 빌드를 시작하기 전에 빌드 컨텍스트를 데몬으로 보냅니다. 컨텍스트에 .git, 로컬 node_modules, 커버리지 보고서, 비디오, 스크린샷, 테스트 데이터베이스 및 빌드 아티팩트가 포함되어 있으면 Dockerfile이 실행되기도 전에 빌드가 느려집니다.

기본적인 .dockerignore는 다음과 같을 수 있습니다:

.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store

광범위한 무시 항목에 주의하세요. dist를 무시했지만 Dockerfile이 사전 빌드된 dist를 복사할 것으로 예상하면 빌드가 실패합니다. .env를 무시하는 것은 일반적으로 비밀 정보에 좋지만, 빌드가 이에 의존해서는 안 됩니다. 빌드는 명시적인 빌드 인수 또는 CI 구성을 통해 필요한 값을 받아야 합니다.

클래식 Docker 빌드 출력 시작 부분에서 컨텍스트 크기를 볼 수 있으며, BuildKit 출력도 큰 컨텍스트 전송을 표시합니다. Dockerfile 명령어가 실행되기 전에 첫 번째 단계가 오래 걸리면 .dockerignore를 확인하세요.

동일한 레이어에서 패키지 관리자 캐시 정리

한 레이어에서 패키지를 설치하고 이후 레이어에서 캐시를 삭제하는 것은 흔한 실수입니다. 파일은 최종 파일 시스템 보기에서 사라지지만 이미지 기록에 여전히 존재할 수 있습니다.

Debian 및 Ubuntu 이미지의 경우:

RUN apt-get update &&     apt-get install -y --no-install-recommends ca-certificates curl &&     rm -rf /var/lib/apt/lists/*

Alpine의 경우:

RUN apk add --no-cache ca-certificates curl

Python의 경우:

RUN pip install --no-cache-dir -r requirements.txt

npm의 경우:

RUN npm ci --omit=dev && npm cache clean --force

정리 명령을 맹목적으로 복사하지 마세요. 패키지 관리자와 기본 이미지에 맞는 정리 방법을 사용하세요. 또한 대부분의 애플리케이션 Dockerfile에서 apt-get upgrade를 피하세요. 이는 빌드를 덜 예측 가능하게 만들고 의도한 것보다 더 많은 변경을 초래할 수 있습니다. 유지 관리되는 기본 이미지에서 정기적으로 재빌드하고 예상하는 이미지 제품군을 고정하여 패치하세요.

ADD가 필요하지 않은 한 COPY를 선호하세요

COPY는 빌드 컨텍스트에서 파일을 복사합니다. ADD는 추가 동작이 있습니다: 경우에 따라 로컬 tar 아카이브를 풀고 원격 URL을 가져올 수 있습니다. 이 추가 동작은 유지 관리자를 놀라게 하고 캐시 동작을 추론하기 어렵게 만들 수 있습니다.

일반 애플리케이션 파일에는 COPY를 사용하세요:

COPY ./src ./src

아카이브 추출 동작을 의도적으로 원할 때만 ADD를 사용하세요. 원격 다운로드의 경우, 체크섬을 확인하고 명확하게 실패할 수 있는 RUN 단계에서 curl 또는 wget을 선호하세요.

삭제하는 것뿐만 아니라 설치하는 것을 줄이세요

최고의 캐시 정리는 처음부터 불필요한 파일을 설치하지 않는 것입니다. apt-get과 함께 --no-install-recommends를 사용하세요. 이유가 없는 한 프로덕션 이미지에 편집기, 셸, 패키지 관리자 및 디버깅 도구를 설치하지 마세요.

그렇다고 해서 운영을 더 어렵게 만든다면 모든 도구를 제거하지 마세요. 프로덕션 이미지는 관찰 가능하면서도 최소화될 수 있습니다. 일부 팀의 경우, 온콜 디버깅이 더 간단하기 때문에 distroless 이미지보다 셸이 있는 slim 이미지가 더 나은 절충안입니다. 강력한 로깅, 추적 및 임시 디버그 컨테이너를 사용하는 다른 팀의 경우 distroless가 적합합니다.

정직한 답변은 워크로드에 따라 다릅니다. 더 작은 이미지는 일반적으로 더 빠르게 풀리고 패치할 패키지가 더 적지만, 유지 관리성은 여전히 중요합니다.

종속성 다운로드를 위해 BuildKit 캐시 마운트 사용

BuildKit은 패키지 캐시를 최종 이미지 외부에 유지하면서 빌드 간에 재사용할 수 있습니다. 이는 이미지 내부에 캐시를 남겨두는 것과 다릅니다.

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 . .

apt의 경우, 캐시 마운트는 동일한 빌더에서 반복 빌드가 발생하는 CI 환경에서 도움이 될 수 있지만, 많은 CI 시스템이 영구 캐시를 구성하지 않는 한 새 워커를 사용합니다. BuildKit 기능은 강력하지만, 유지 관리해야 하는 팀을 위해 Dockerfile을 읽기 쉽게 유지하세요.

최종 이미지 내용 확인

변경 후, 개선된 점을 증명하세요:

docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized

그런 다음 프로덕션에서 실행하는 것과 동일한 방식으로 이미지를 실행하세요. 인증서, 로케일 데이터, 시간대 파일 또는 쓰기 가능한 디렉토리가 없어서 실패하는 더 작은 이미지는 개선이 아닙니다.

유용한 스모크 테스트는 다음과 같습니다:

docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health

앱이 파일을 쓰는 경우 해당 경로를 테스트하세요. HTTPS 서비스를 호출하는 경우 TLS를 테스트하세요. 비루트로 실행되는 경우 권한을 테스트하세요.

실용적인 최적화 순서

비대한 Docker 이미지를 검토할 때 다음 순서를 사용합니다:

  1. .dockerignore를 추가하거나 수정합니다.
  2. 종속성 설치가 캐시될 수 있도록 Dockerfile을 재정렬합니다.
  3. 멀티 스테이지 빌드를 도입합니다.
  4. 전체 기본 이미지에서 호환되는 slim, Alpine, distroless 또는 scratch로 전환합니다.
  5. 패키지 관리자 캐시와 개발 종속성을 생성된 동일한 레이어에서 제거합니다.
  6. docker history 또는 dive로 검사하고 증거가 가리키는 곳에서만 반복합니다.

이 순서는 조기 영리함을 피합니다. 또한 Dockerfile을 이해하기 쉽게 유지하며, 이는 마지막 몇 메가바이트를 짜내는 것보다 더 중요합니다.