Docker 다단계 빌드로 더 작은 컨테이너 이미지를 만드는 방법

0

Docker를 사용하여 컨테이너 이미지를 빌드할 때, Dockerfile이 다단계(multi-stage)가 아닌 경우 불필요한 부가 요소들이 프로덕션 환경에 포함될 가능성이 큽니다. 이는 이미지의 크기를 증가시킬 뿐만 아니라 잠재적인 공격 표면도 넓혀 보안 위험을 높입니다.

무엇이 원인이며, 이를 어떻게 피할 수 있을까요?

이 글에서는 프로덕션 컨테이너 이미지에 불필요한 패키지가 포함되는 가장 일반적인 원인을 살펴보고, 다단계 빌드를 사용하여 더 슬림하고 안전한 이미지를 생성하는 방법을 설명합니다. 마지막으로, 몇 가지 인기 있는 소프트웨어 스택의 Dockerfile을 재구성하는 예제를 통해 새로운 지식을 더 잘 이해하고, 약간의 추가 노력으로 훨씬 더 나은 이미지를 얻을 수 있음을 보여줍니다.

내 이미지가 이렇게 큰 이유는 무엇일까요?

거의 모든 애플리케이션(웹 서비스, 데이터베이스, CLI 등)이나 언어 스택(Python, Node.js, Go 등)에는 두 가지 종류의 의존성이 있습니다. 바로 빌드 타임 의존성과 런타임 의존성입니다.

일반적으로 빌드 타임 의존성은 런타임 의존성보다 훨씬 많고 복잡하며(CVE가 더 많이 존재), 대부분의 경우 최종 이미지에는 프로덕션 의존성만 포함하는 것이 바람직합니다.

하지만 빌드 타임 의존성이 프로덕션 컨테이너에 포함되는 경우가 많으며, 그 주요 이유 중 하나는 애플리케이션을 빌드하고 실행하는 데 동일한 이미지를 사용하는 것입니다.

코드를 컨테이너 내에서 빌드하는 것은 일반적이고 좋은 관행입니다. 이는 개발자의 머신, CI 서버 또는 다른 환경에서 빌드 프로세스가 동일한 도구 세트를 사용하도록 보장합니다.

오늘날 애플리케이션을 컨테이너에서 실행하는 것은 사실상 표준 관행입니다. Docker를 사용하지 않더라도, 코드가 컨테이너나 유사한 VM에서 실행될 가능성이 높습니다.

그러나 애플리케이션을 빌드하고 실행하는 것은 완전히 별개의 문제로, 요구 사항과 제약 조건이 다릅니다. 따라서 빌드 이미지와 런타임 이미지는 완전히 분리되어야 합니다! 하지만 이러한 분리의 필요성이 종종 간과되어, 프로덕션 이미지에 린터, 컴파일러 및 기타 개발 도구가 포함되는 경우가 많습니다.

Go 애플리케이션 Dockerfile을 잘못 구성한 경우

더 명확한 예시로 시작해보겠습니다:

# DO NOT DO THIS IN YOUR DOCKERFILE
FROM golang:1.23

WORKDIR /app
COPY . .

RUN go build -o binary

CMD ["/app/binary"]

위 Dockerfile의 문제점은 `golang` 이미지가 프로덕션 애플리케이션의 베이스 이미지로 의도되지 않았다는 점입니다. 이 이미지는 컨테이너 내에서 Go 코드를 빌드할 때 기본 선택지이지만, 소스 코드를 실행 파일로 컴파일하는 Dockerfile을 작성한 후 단순히 `CMD` 명령어를 추가하여 실행 파일을 호출하면, 애플리케이션 자체뿐만 아니라 전체 Go 컴파일러 도구 체인과 그 의존성까지 프로덕션 이미지에 포함됩니다.


예를 들어, `golang:1.23` 이미지는 800MB 이상의 패키지와 유사한 수의 CVE를 포함하고 있습니다.

Node.js 애플리케이션 Dockerfile을 잘못 구성한 경우

비슷하지만 조금 더 미묘한 예시입니다:

# DO NOT DO THIS IN YOUR DOCKERFILE
FROM node:lts-slim

WORKDIR /app
COPY . .

RUN npm ci
RUN npm run build

ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "/app/.output/index.mjs"]

`node:lts-slim` 이미지는 프로덕션 작업 부하의 베이스 이미지로 적합하지만, 이 Dockerfile을 사용하여 이미지를 빌드하면 `node_modules` 폴더에 약 500MB가 포함되고, 실제 프로덕션 애플리케이션은 약 50MB의 “번들된” JavaScript 및 정적 자산에 불과하게 됩니다. 이는 `npm ci` 단계가 프로덕션과 개발 의존성을 모두 설치하기 때문입니다. 단순히 `npm ci –omit=dev`를 사용하면 `npm run build` 명령어가 필요한 의존성을 놓칠 수 있으므로, 더 미묘한 해결책이 필요합니다.

다단계 빌드 이전에 이미지를 슬림하게 만드는 방법

Go와 Node.js 예시 모두에서, 해결책은 원래의 Dockerfile을 두 개의 파일로 분리하는 것입니다.

1. 빌드용 Dockerfile (Dockerfile.build):

FROM node:lts-slim

WORKDIR /app
COPY . .

RUN npm ci
RUN npm run build
docker build -t build:v1 -f Dockerfile.build .

2. 런타임용 Dockerfile (Dockerfile.run):

FROM node:lts-slim

WORKDIR /app
COPY .output .

ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "/app/.output/index.mjs"]
docker build -t app:v1 -f Dockerfile.run .

이 방법은 Builder 패턴으로 알려져 있으며, Docker가 다단계 빌드를 지원하기 이전에 널리 사용되었습니다. 하지만 여러 Dockerfile을 작성하고 빌드 아티팩트를 호스트 간에 복사해야 하는 등의 번거로움이 있었습니다.



다단계 빌드를 쉽게 이해하는 방법

다단계 빌드는 Docker 내부에서 구현된 강화된 Builder 패턴입니다. 다단계 빌드의 작동 방식을 이해하려면 다음 두 가지 Dockerfile 기능을 이해하는 것이 중요합니다:

1. 다른 이미지에서 파일을 COPY할 수 있음:

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

이를 통해 빌드 아티팩트를 직접 다른 이미지에서 복사할 수 있어, 빌더 호스트를 우회할 수 있습니다.

2. 한 Dockerfile에 여러 이미지를 정의할 수 있음:

FROM busybox:stable AS from1
CMD ["echo", "busybox"]

FROM alpine:3 AS from2
CMD ["echo", "alpine"]

FROM debian:stable-slim AS from3
CMD ["echo", "debian"]

이렇게 하면 하나의 Dockerfile에서 여러 단계를 정의할 수 있으며, 각 단계는 독립된 타겟으로 빌드할 수 있습니다.

다단계 Dockerfile의 강력함

다음은 다단계 빌드를 사용한 Node.js 애플리케이션 Dockerfile 예시입니다:

# "빌드" 단계
FROM node:lts-slim AS build

WORKDIR /app
COPY . .

RUN npm ci
RUN npm run build

# "런타임" 단계
FROM node:lts-slim AS runtime

WORKDIR /app
COPY --from=build /app/.output .

ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "/app/.output/index.mjs"]

각 `FROM` 명령어는 별도의 단계를 정의하며, `COPY –from=<stage>`를 통해 특정 단계에서 아티팩트를 복사할 수 있습니다. 이렇게 하면 빌드와 런타임 단계를 하나의 Dockerfile로 관리할 수 있으며, Docker의 BuildKit은 최적의 빌드 순서를 계산하고 불필요한 단계를 건너뛰며 독립적인 단계를 병렬로 실행할 수 있습니다.

다단계 Dockerfile 작성 시 유의할 점

  • 단계의 순서가 중요: 현재 단계 아래에 정의된 단계를 `COPY –from`으로 참조할 수 없습니다.
  • AS 별칭은 선택 사항: 단계를 이름 없이 정의할 경우, 순서 번호로 참조할 수 있습니다.
  • –target 플래그 사용 시: `docker build` 명령어에서 `–target` 플래그를 사용하지 않으면 마지막 단계를 빌드합니다.

다단계 빌드 실습

다양한 언어와 프레임워크에 대해 다단계 빌드를 사용하여 더 작고 안전한 컨테이너 이미지를 생성하는 방법을 예시로 살펴보겠습니다.

Node.js

Node.js 애플리케이션은 개발 및 빌드 단계에서만 Node.js가 필요하거나, 런타임 컨테이너에서도 Node.js가 필요한 경우가 있습니다. 다음은 다단계 Dockerfile 구조의 예시입니다:

# "빌드" 단계
FROM node:lts-slim AS build

WORKDIR /app
COPY . .

RUN npm ci
RUN npm run build

# "런타임" 단계
FROM node:lts-slim AS runtime

WORKDIR /app
COPY --from=build /app/.output .

ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "/app/.output/index.mjs"]

Go

Go 애플리케이션은 항상 빌드 단계에서 컴파일됩니다. 생성된 바이너리는 정적으로 링크되거나 동적으로 링크될 수 있습니다. 런타임 단계의 베이스 이미지는 생성된 바이너리의 종류에 따라 선택됩니다.

  • 정적으로 링크된 바이너리: `gcr.io/distroless/static` 또는 `scratch` 베이스 이미지 선택
  • 동적으로 링크된 바이너리: 표준 공유 C 라이브러리가 포함된 베이스 이미지 선택 (`gcr.io/distroless/cc`, `alpine`, `debian` 등)

Rust

Rust 애플리케이션은 보통 `cargo`를 사용하여 소스 코드에서 컴파일됩니다. 공식 Rust 이미지는 `cargo`, `rustc` 등 많은 개발 도구를 포함하고 있어 이미지 크기가 거의 2GB에 달합니다. 다단계 빌드는 Rust 애플리케이션의 런타임 이미지를 작게 유지하는 데 필수적입니다. 최종 런타임 베이스 이미지는 애플리케이션의 라이브러리 요구 사항에 따라 달라집니다.

Java

Java 애플리케이션은 Maven이나 Gradle 같은 빌드 도구를 사용하여 소스 코드를 컴파일하며, 실행을 위해 Java Runtime Environment (JRE)가 필요합니다.

컨테이너화된 Java 애플리케이션의 경우, 빌드 단계와 런타임 단계를 위해 다른 베이스 이미지를 사용하는 것이 일반적입니다. 빌드 단계는 코드를 컴파일하고 패키징하는 도구가 포함된 Java Development Kit (JDK)가 필요하며, 런타임 단계는 실행을 위한 더 작고 가벼운 JRE만 필요합니다.

PHP

PHP 애플리케이션은 소스 코드를 해석하기 때문에 컴파일이 필요하지 않습니다. 그러나 개발 및 프로덕션에 필요한 의존성이 다를 수 있어, 다단계 빌드를 사용하여 프로덕션 의존성만 설치하고 런타임 이미지로 복사하는 것이 좋습니다.

결론

프로덕션 이미지는 종종 “잊혀진” 개발 패키지로 인해 불필요한 부가 요소와 보안 위험을 초래합니다. 다단계 빌드는 빌드 환경과 런타임 환경을 분리하면서도 단일 Dockerfile로 관리할 수 있게 하여, 더 효율적인 빌드를 가능하게 합니다. 몇 가지 간단한 조정만으로 이미지 크기를 줄이고, 보안을 향상시키며, 빌드 스크립트를 더 깔끔하고 유지 관리하기 쉽게 만들 수 있습니다.

다단계 빌드는 조건부 RUN 명령어, Docker 빌드 단계 중 단위 테스트 등 고급 사용 사례도 가능하게 합니다. 다단계 빌드를 사용하여 컨테이너를 슬림하고 프로덕션 준비가 완료된 상태로 유지하세요!

요약

  • 문제점: 단일 단계 Dockerfile을 사용하면 빌드 도구 및 의존성이 프로덕션 이미지에 포함되어 이미지 크기와 보안 위험이 증가.
  • 해결책: Docker 다단계 빌드를 사용하여 빌드 단계와 런타임 단계를 분리.
  • 장점:
  • ㆍ 이미지 크기 감소
  • ㆍ 보안 향상
  • ㆍ 빌드 스크립트의 간소화 및 유지 관리 용이
  • ㆍ 실제 적용: Node.js, Go, Rust, Java, PHP 등 다양한 언어와 프레임워크에서 다단계 빌드를 활용하여 최적화된 이미지를 생성할 수 있음.

참고 자료: iximiuz.com, “How to Build Smaller Container Images: Docker Multi-Stage Builds”

답글 남기기