효율적인 Docker 이미지 구축: 성능 최적화를 위한 모범 사례

가벼운 베이스 이미지, .dockerignore, 캐시 친화적인 Dockerfile, 멀티 스테이지 빌드를 통해 더 작은 Docker 이미지를 구축하세요.

효율적인 Docker 이미지 구축: 성능을 위한 모범 사례

효율적인 Docker 이미지는 빌드를 더 빠르게 하고, 배포를 더 가볍게 하며, 프로덕션 컨테이너를 더 쉽게 보호할 수 있게 합니다. 비대한 이미지는 CI를 느리게 하고, 레지스트리 저장 공간을 낭비하며, 종종 런타임에 앱이 필요로 하지 않는 도구를 포함합니다.

목표는 어떤 대가를 치르더라도 가능한 가장 작은 이미지를 만드는 것이 아닙니다. 목표는 앱, 런타임 종속성 및 거의 없는 기타 항목을 포함하는 예측 가능한 이미지를 구축하는 것입니다.

이미지 효율성이 중요한 이유

최적화된 Docker 이미지는 전체 소프트웨어 개발 수명 주기에 걸쳐 다양한 이점을 제공합니다.

  • 더 빠른 빌드: 더 작은 컨텍스트와 더 적은 작업으로 이미지 생성이 빨라져 CI/CD 파이프라인이 가속화됩니다.
  • 저장 비용 절감: 레지스트리와 호스트 시스템에서 더 적은 디스크 공간을 소비하여 인프라 비용을 낮춥니다.
  • 더 빠른 배포: 더 작은 이미지가 네트워크를 통해 더 빠르게 전송되어 프로덕션 환경에서 빠른 배포와 확장이 가능합니다.
  • 성능 향상: 로드할 데이터가 적어 컨테이너가 더 효율적으로 시작되고 실행됩니다.
  • 향상된 보안: 종속성과 도구가 적은 더 작은 이미지는 공격 표면이 줄어들어 악용 가능한 취약점이 줄어듭니다.
  • 더 나은 개발자 경험: 더 빠른 피드백 루프와 대기 시간 감소는 더 생산적인 개발 환경에 기여합니다.

성능을 위한 Dockerfile 모범 사례

Dockerfile은 이미지의 청사진입니다. 이를 최적화하는 것이 효율성을 위한 첫 번째이자 가장 영향력 있는 단계입니다.

1. 최소 베이스 이미지 선택

FROM 명령어는 이미지의 기반을 설정합니다. 더 작은 베이스 이미지로 시작하면 최종 이미지 크기가 획기적으로 줄어듭니다.

  • Alpine Linux: 매우 작으며 musl libc와 잘 작동하는 애플리케이션에 유용합니다. 앱이나 네이티브 종속성이 glibc 동작을 기대하는 경우 신중하게 테스트하세요.
  • Distroless 이미지: Google에서 제공하는 이러한 이미지는 애플리케이션과 런타임 종속성만 포함하고 셸, 패키지 관리자 및 기타 OS 유틸리티를 제거합니다. 뛰어난 보안과 최소 크기를 제공합니다.
  • 특정 배포판 버전: ubuntu:latest 또는 node:latest와 같은 일반 태그를 피하세요. 대신 ubuntu:22.04 또는 node:18-alpine과 같은 특정 버전을 고정하여 재현성과 안정성을 보장하세요.
# 나쁨: 큰 베이스 이미지, 잠재적으로 일관성 없음
FROM ubuntu:latest

# 좋음: 더 작고 일관된 베이스 이미지
FROM node:18-alpine

# 컴파일된 앱의 경우 더 좋음 (해당되는 경우)
FROM gcr.io/distroless/static

2. .dockerignore 활용

.gitignore와 마찬가지로 .dockerignore 파일은 불필요한 파일이 빌드 컨텍스트에 복사되는 것을 방지합니다. 이는 Docker 데몬이 처리해야 하는 데이터를 줄여 docker build 프로세스의 속도를 크게 높입니다.

프로젝트 루트에 .dockerignore라는 파일을 만듭니다.

# Git 관련 파일 무시
.git
.gitignore

# Node.js 종속성 무시 (컨테이너 내부에 설치됨)
node_modules
npm-debug.log

# 로컬 개발 파일 무시
.env
*.log
*.DS_Store

# 컨테이너 내부에서 생성될 빌드 아티팩트 무시
build
dist

3. RUN 명령어 결합으로 레이어 최소화

Dockerfile의 각 RUN 명령어는 새 레이어를 만듭니다. 레이어는 캐싱에 필수적이지만 너무 많으면 이미지가 비대해질 수 있습니다. 관련 명령어를 단일 RUN 명령어로 결합하고 &&를 사용하여 연결하세요.

# 나쁨: 여러 레이어 생성
RUN apt-get update
RUN apt-get install -y --no-install-recommends git curl
RUN rm -rf /var/lib/apt/lists/*

# 좋음: 단일 레이어를 생성하고 한 번에 정리
RUN apt-get update && \
    apt-get install -y --no-install-recommends git curl && \
    rm -rf /var/lib/apt/lists/*

: Debian 및 Ubuntu의 경우 패키지를 설치하는 동일한 RUN 명령어에 rm -rf /var/lib/apt/lists/*와 같은 정리 명령어를 항상 포함하세요. Alpine의 경우 /var/cache/apk를 수동으로 정리하는 대신 apk add --no-cache를 사용하는 것이 좋습니다.

4. Dockerfile 명령어를 최적으로 정렬

Docker는 명령어 순서에 따라 레이어를 캐시합니다. Dockerfile에서 가장 안정적이고 변경 빈도가 낮은 명령어를 먼저 배치하세요. 이렇게 하면 Docker가 이전 빌드의 캐시된 레이어를 재사용하여 후속 빌드 속도를 크게 높일 수 있습니다.

일반적인 순서:

  1. FROM (베이스 이미지)
  2. ARG (빌드 인수)
  3. ENV (환경 변수)
  4. WORKDIR (작업 디렉토리)
  5. 종속성을 위한 COPY (예: package.json, pom.xml, requirements.txt)
  6. 종속성 설치를 위한 RUN (예: npm install, pip install)
  7. 애플리케이션 소스 코드를 위한 COPY
  8. EXPOSE (포트)
  9. ENTRYPOINT / CMD (애플리케이션 실행)
FROM node:18-alpine
WORKDIR /app

# 이러한 파일은 소스 코드보다 덜 자주 변경되므로 먼저 배치
COPY package.json package-lock.json ./ 
RUN npm ci --omit=dev

# 애플리케이션 소스 코드는 더 자주 변경됨
COPY . . 

CMD ["node", "server.js"]

5. 특정 패키지 버전 사용

RUN 명령어(예: apt-get install mypackage=1.2.3)를 통해 설치된 패키지의 버전을 고정하면 재현성을 보장하고 새 패키지 버전으로 인한 예기치 않은 문제나 크기 증가를 방지할 수 있습니다.

6. 불필요한 도구 설치 방지

애플리케이션 실행에 엄격히 필요한 것만 설치하세요. 개발 도구, 디버거 또는 텍스트 편집기는 프로덕션 이미지에 들어갈 자리가 없습니다.

멀티 스테이지 빌드 활용

멀티 스테이지 빌드는 효율적인 Docker 이미지 생성의 핵심입니다. 단일 Dockerfile에서 여러 FROM 문을 사용할 수 있으며, 각 FROM은 새 빌드 스테이지를 시작합니다. 그런 다음 한 스테이지에서 최종적인 가벼운 스테이지로 아티팩트를 선택적으로 복사하여 모든 빌드 타임 종속성, 중간 파일 및 도구를 남겨둘 수 있습니다.

이렇게 하면 런타임에 필요한 것만 포함하여 최종 이미지 크기가 획기적으로 줄어들고 보안이 향상됩니다.

멀티 스테이지 빌드 작동 방식

  1. 빌더 스테이지: 이 스테이지에는 애플리케이션을 컴파일하는 데 필요한 모든 도구와 종속성(예: 컴파일러, SDK, 개발 라이브러리)이 포함됩니다. 실행 파일 또는 배포 가능한 아티팩트를 생성합니다.
  2. 러너 스테이지: 이 스테이지는 최소 베이스 이미지에서 시작하여 빌더 스테이지에서 필요한 아티팩트만 복사합니다. 빌더 스테이지의 다른 모든 것은 폐기하여 최종 이미지 크기를 크게 줄입니다.

멀티 스테이지 빌드 예제 (Go 애플리케이션)

Go 애플리케이션을 고려해 보세요. 빌드하려면 Go 컴파일러가 필요하지만 최종 실행 파일은 런타임 환경만 있으면 됩니다.

# 스테이지 1: 빌더
FROM golang:1.20-alpine AS builder

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

COPY . . 
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s -w' -o myapp .

# 스테이지 2: 러너
FROM alpine:3.20
WORKDIR /root/

# 빌더 스테이지에서 컴파일된 실행 파일만 복사
COPY --from=builder /app/myapp .

EXPOSE 8080
CMD ["./myapp"]

이 예제에서:

  • builder 스테이지는 golang:1.20-alpine을 사용하여 Go 애플리케이션을 컴파일합니다.
  • runner 스테이지는 작은 Alpine 이미지에서 시작하여 builder 스테이지에서 myapp 실행 파일만 복사하고 Go SDK 및 빌드 종속성은 폐기합니다.

고급 최적화 기술

1. COPY --chown 사용 고려

파일을 복사할 때 --chown을 사용하여 소유자와 그룹을 루트가 아닌 사용자로 설정하세요. 이는 보안 모범 사례이며 권한 문제를 방지할 수 있습니다.

RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

# 루트가 아닌 사용자로 직접 파일 복사
COPY --chown=appuser:appgroup ./app /app

2. 민감한 정보 추가 금지

Dockerfile이나 이미지에 API 키, 비밀번호와 같은 비밀을 하드코딩하지 마세요. 환경 변수, Docker Secrets 또는 외부 비밀 관리 시스템을 사용하세요. 빌드 인수(ARG)는 이미지 기록에 표시되므로 비밀에 사용하는 것도 위험합니다.

3. BuildKit 기능 사용 (가능한 경우)

Docker 빌드가 BuildKit을 사용하는 경우 종속성 캐시를 위한 RUN --mount=type=cache 또는 이미지에 포함되어서는 안 되는 빌드 타임 비밀을 위한 RUN --mount=type=secret과 같은 기능을 사용할 수 있습니다.

# npm용 BuildKit 캐시 예제
FROM node:18-alpine

WORKDIR /app
COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

COPY . . 
CMD ["node", "server.js"]

핵심 요점

효율적인 Docker 이미지 구축은 간단한 습관에서 시작됩니다. 모든 파일과 패키지가 최종 이미지에서 그 위치를 정당화하도록 만드세요. 가벼운 베이스 이미지를 사용하고, 빌드 컨텍스트를 작게 유지하며, 캐싱을 위해 명령어 순서를 지정하고, 컴파일러나 SDK를 빌더 스테이지로 이동하세요.

핵심 요점:

  • 작게 시작: 가능한 가장 작은 베이스 이미지(Alpine, Distroless)를 선택하세요.
  • 레이어를 현명하게 사용: RUN 명령어를 결합하고 효과적으로 정리하세요.
  • 현명하게 캐시: 캐시 적중을 최대화하기 위해 명령어 순서를 지정하세요.
  • 빌드 아티팩트 분리: 멀티 스테이지 빌드를 사용하여 빌드 타임 종속성을 폐기하세요.
  • 가볍게 유지: 런타임에 절대적으로 필요한 것만 포함하세요.

이미지 크기와 빌드 시간을 지속적으로 모니터링하세요. docker history와 같은 도구는 각 명령어가 최종 이미지 크기에 어떻게 기여하는지 이해하는 데 도움이 될 수 있습니다. 애플리케이션이 발전함에 따라 Dockerfile을 정기적으로 검토하고 리팩토링하여 최적의 효율성과 성능을 유지하세요.