⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Docker 멀티 스테이지 빌드 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 26. · 9 Views

Docker 멀티 스테이지 빌드 완벽 가이드

Docker 이미지 크기를 획기적으로 줄이는 멀티 스테이지 빌드 기법을 알아봅니다. 빌드 환경과 실행 환경을 분리하여 가볍고 안전한 프로덕션 이미지를 만드는 방법을 단계별로 설명합니다.


목차

  1. 멀티 스테이지 빌드 개념
  2. 빌드 스테이지 분리하기
  3. Node.js 앱 멀티 스테이지
  4. Go 앱 멀티 스테이지
  5. 최종 이미지 크기 비교
  6. CI/CD에서 활용하기

1. 멀티 스테이지 빌드 개념

어느 날 김개발 씨가 Docker 이미지를 빌드하고 나서 깜짝 놀랐습니다. 간단한 Node.js 애플리케이션인데 이미지 크기가 무려 1.2GB나 되었기 때문입니다.

"이게 왜 이렇게 큰 거지?" 선배 박시니어 씨에게 물어보니 의미심장한 미소를 지으며 말했습니다. "멀티 스테이지 빌드를 써봤어?"

멀티 스테이지 빌드는 하나의 Dockerfile 안에서 여러 개의 빌드 단계를 정의하고, 최종 이미지에는 꼭 필요한 파일만 담는 기법입니다. 마치 요리할 때 재료 손질은 넓은 조리대에서 하고, 완성된 요리만 예쁜 접시에 담아 서빙하는 것과 같습니다.

이 방법을 사용하면 이미지 크기를 10분의 1로 줄일 수도 있습니다.

다음 코드를 살펴봅시다.

# 1단계: 빌드 스테이지 (넓은 조리대)
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 2단계: 실행 스테이지 (예쁜 접시)
FROM node:20-alpine AS runner
WORKDIR /app
# 빌드 결과물만 가져옵니다
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 최근 팀에서 Docker를 도입하기로 했고, 김개발 씨가 첫 번째 Dockerfile을 작성하게 되었습니다.

공식 문서를 참고해서 열심히 작성했는데, 빌드된 이미지 크기를 보고 당황했습니다. "1.2GB라니, 이걸 매번 배포할 때마다 전송해야 한다고요?" 선배 박시니어 씨가 김개발 씨의 Dockerfile을 살펴봅니다.

"음, 여기가 문제네요. 빌드에 필요한 도구들이 전부 최종 이미지에 들어가 있어요.

멀티 스테이지 빌드를 사용하면 이 문제를 해결할 수 있습니다." 그렇다면 멀티 스테이지 빌드란 정확히 무엇일까요? 쉽게 비유하자면, 멀티 스테이지 빌드는 마치 이사할 때 짐을 정리하는 것과 같습니다.

이사 준비 과정에서는 박스, 테이프, 신문지 등 각종 포장 재료가 필요합니다. 하지만 새 집에 도착하면 포장 재료는 버리고 실제 살림살이만 들여놓습니다.

Docker 이미지도 마찬가지입니다. 빌드할 때는 컴파일러, 개발 의존성, 빌드 도구가 필요하지만, 실행할 때는 완성된 결과물만 있으면 됩니다.

기존 방식에서는 어떤 문제가 있었을까요? 단일 스테이지 Dockerfile에서는 빌드에 사용된 모든 것이 최종 이미지에 포함됩니다.

node_modules의 devDependencies, TypeScript 컴파일러, 빌드 도구, 심지어 소스 코드까지 전부 들어갑니다. 이렇게 되면 이미지 크기가 불필요하게 커지고, 보안 취약점이 늘어나며, 배포 시간도 길어집니다.

멀티 스테이지 빌드는 이 문제를 우아하게 해결합니다. FROM 명령어를 여러 번 사용하여 각각의 스테이지를 정의합니다.

첫 번째 스테이지에서는 빌드에 필요한 모든 도구를 갖춘 이미지를 사용합니다. 여기서 의존성을 설치하고, 코드를 컴파일하고, 번들링을 수행합니다.

두 번째 스테이지에서는 가벼운 알파인 이미지를 사용하고, 첫 번째 스테이지에서 만든 결과물만 복사해옵니다. 위 코드를 자세히 살펴보겠습니다.

첫 번째 FROM 절에서 AS builder라는 별칭을 부여합니다. 이 별칭 덕분에 나중에 이 스테이지의 결과물을 참조할 수 있습니다.

두 번째 FROM 절에서는 node:20-alpine이라는 경량 이미지를 사용합니다. 핵심은 COPY --from=builder 명령어입니다.

이 명령어가 builder 스테이지에서 필요한 파일만 선택적으로 가져옵니다. 실제 현업에서 이 기법은 거의 필수적으로 사용됩니다.

대규모 서비스를 운영하는 회사에서는 하루에도 수십 번씩 배포가 일어납니다. 이미지 크기가 1GB에서 100MB로 줄어들면, 이미지 푸시와 풀에 걸리는 시간이 10분의 1로 줄어듭니다.

저장 비용도 절감되고, 컨테이너 시작 시간도 빨라집니다. 무엇보다 공격 표면이 줄어들어 보안이 강화됩니다.

주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 COPY --from 경로를 잘못 지정하는 것입니다.

이전 스테이지의 WORKDIR을 기준으로 경로를 작성해야 합니다. 또한 런타임에 필요한 파일을 빠뜨리지 않도록 주의해야 합니다.

이미지가 작아졌다고 좋아했는데, 실행하니 파일이 없다는 에러가 나면 난감합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 멀티 스테이지 빌드를 적용한 김개발 씨는 이미지 크기가 150MB로 줄어든 것을 보고 감탄했습니다. "와, 진짜 마법 같네요!" 이제 여러분도 이 마법을 사용할 준비가 되었습니다.

실전 팁

💡 - AS 별칭은 의미 있는 이름으로 지정하세요 (builder, runner, tester 등)

  • 알파인 이미지를 사용하면 기본 크기를 더욱 줄일 수 있습니다
  • docker build --target 옵션으로 특정 스테이지만 빌드할 수 있습니다

2. 빌드 스테이지 분리하기

김개발 씨가 멀티 스테이지 빌드의 개념을 이해하고 나니, 새로운 궁금증이 생겼습니다. "스테이지를 어떻게 나누는 게 좋을까요?

2개만 해야 하나요, 아니면 더 많이 해도 되나요?" 박시니어 씨가 화이트보드에 그림을 그리며 설명하기 시작했습니다.

빌드 스테이지 분리는 각 단계의 목적에 맞게 스테이지를 설계하는 것입니다. 마치 공장의 생산 라인처럼, 각 스테이지는 하나의 명확한 역할을 담당합니다.

의존성 설치, 빌드, 테스트, 실행 등 목적별로 스테이지를 분리하면 캐싱 효율도 높아지고 유지보수도 쉬워집니다.

다음 코드를 살펴봅시다.

# 스테이지 1: 의존성 설치 (캐싱 최적화)
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# 스테이지 2: 개발 의존성 포함 빌드
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 스테이지 3: 최종 실행 이미지
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]

박시니어 씨가 화이트보드에 세 개의 박스를 그렸습니다. "스테이지를 나누는 데 정답은 없어요.

하지만 일반적으로 세 가지 역할로 나누면 효과적입니다." 첫 번째 박스에는 '의존성'이라고 적었습니다. 프로젝트에서 package.json이 변경되는 빈도는 얼마나 될까요?

소스 코드는 매일 수십 번 바뀌지만, 의존성은 일주일에 한두 번 정도입니다. Docker는 레이어 캐싱을 지원하는데, 의존성 설치 스테이지를 분리하면 이 캐싱의 혜택을 최대한 받을 수 있습니다.

소스 코드만 바뀌었을 때 npm install을 다시 실행할 필요가 없어집니다. 두 번째 박스에는 '빌드'라고 적었습니다.

빌드 스테이지에서는 TypeScript 컴파일, 웹팩 번들링, 테스트 실행 등 모든 개발 작업이 이루어집니다. 이 스테이지에는 devDependencies가 필요하므로, 프로덕션 의존성만 설치하는 스테이지와 분리하는 것이 좋습니다.

빌드가 완료되면 dist 폴더나 build 폴더에 결과물이 생성됩니다. 세 번째 박스에는 '실행'이라고 적었습니다.

최종 실행 스테이지는 최대한 가볍게 유지해야 합니다. 여기에는 런타임에 필요한 것들만 들어갑니다.

앞선 두 스테이지에서 필요한 파일만 COPY --from으로 가져옵니다. 불필요한 도구나 소스 코드는 포함되지 않습니다.

김개발 씨가 고개를 끄덕이며 물었습니다. "그런데 스테이지가 많아지면 Dockerfile이 복잡해지지 않나요?" 박시니어 씨가 대답합니다.

"좋은 질문이에요. 스테이지 수는 프로젝트 규모에 맞게 조절하면 됩니다.

작은 프로젝트라면 2개로 충분하고, 복잡한 프로젝트라면 테스트 스테이지, 린트 스테이지까지 추가할 수 있어요." 위 코드에서 주목할 부분이 있습니다. USER node 명령어가 보이시나요?

최종 스테이지에서는 보안을 위해 root가 아닌 일반 사용자로 실행하는 것이 좋습니다. Node.js 공식 이미지에는 node라는 사용자가 미리 생성되어 있습니다.

이렇게 하면 컨테이너가 해킹당하더라도 피해를 최소화할 수 있습니다. 실무에서 자주 보는 패턴 중 하나는 테스트 스테이지를 별도로 두는 것입니다.

CI/CD 파이프라인에서 테스트를 실행할 때, 테스트 스테이지만 빌드하도록 설정할 수 있습니다. docker build --target test 명령어를 사용하면 해당 스테이지까지만 빌드됩니다.

테스트가 통과하면 전체 빌드를 진행하고, 실패하면 즉시 중단할 수 있습니다. 캐싱과 관련된 중요한 팁이 있습니다.

COPY 명령어의 순서가 캐싱 효율에 큰 영향을 미칩니다. 자주 변경되는 파일은 나중에 복사하고, 거의 변경되지 않는 파일은 먼저 복사해야 합니다.

위 예제에서 package.json을 먼저 복사하고 npm ci를 실행한 후에 소스 코드를 복사하는 이유가 바로 이것입니다. 김개발 씨가 직접 Dockerfile을 수정해보았습니다.

이전에는 빌드할 때마다 2분이 걸렸는데, 스테이지를 분리하고 나니 소스 코드만 변경된 경우 30초 만에 빌드가 완료되었습니다. "캐싱의 힘이 대단하네요!" 김개발 씨의 얼굴에 미소가 번졌습니다.

실전 팁

💡 - package.json이 변경되지 않았다면 의존성 설치 스테이지는 캐시에서 재사용됩니다

  • 각 스테이지에 명확한 이름을 붙여 가독성을 높이세요
  • 프로덕션 의존성과 개발 의존성을 분리하면 최종 이미지 크기가 더 줄어듭니다

3. Node.js 앱 멀티 스테이지

김개발 씨의 팀은 Express.js로 만든 API 서버를 운영하고 있습니다. TypeScript로 작성되어 있어서 빌드 과정이 필요한 프로젝트입니다.

"Node.js 프로젝트에 멀티 스테이지 빌드를 적용하는 가장 좋은 방법이 뭘까요?" 김개발 씨가 실전 예제를 요청했습니다.

Node.js 멀티 스테이지 빌드는 TypeScript 컴파일, 프로덕션 의존성 분리, 알파인 이미지 활용을 조합하여 최적의 이미지를 만듭니다. 일반적인 Node.js 이미지가 1GB 이상인 것에 비해, 이 방법을 사용하면 100MB 이하로 줄일 수 있습니다.

프론트엔드와 백엔드 모두에 적용 가능한 패턴입니다.

다음 코드를 살펴봅시다.

# 빌드 스테이지
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build && npm prune --production

# 프로덕션 스테이지
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# 프로덕션 의존성과 빌드 결과만 복사
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

박시니어 씨가 팀 프로젝트의 Dockerfile을 열어 보여주었습니다. "실제 프로덕션에서 사용하는 Dockerfile을 함께 살펴볼까요?" 먼저 베이스 이미지 선택에 대해 이야기해봅시다.

node:20-alpine 이미지는 일반 node:20 이미지보다 훨씬 작습니다. Alpine Linux는 보안에 초점을 맞춘 경량 리눅스 배포판입니다.

node:20이 약 1GB인 것에 비해, node:20-alpine은 약 130MB에 불과합니다. 빌드 스테이지부터 알파인을 사용하면 빌드 속도도 빨라집니다.

tsconfig.json을 package.json과 함께 복사하는 이유가 있습니다. TypeScript 프로젝트에서 빌드 설정 파일이 없으면 컴파일이 실패합니다.

하지만 tsconfig.json은 거의 변경되지 않는 파일입니다. package.json과 함께 먼저 복사해두면, 의존성 설치 레이어와 함께 캐시될 수 있습니다.

소스 코드만 변경되었을 때 더 빠르게 빌드할 수 있습니다. npm prune --production 명령어는 마법과 같습니다.

이 명령어는 node_modules에서 devDependencies를 모두 제거합니다. TypeScript, ESLint, Jest 같은 개발 도구들이 한꺼번에 삭제됩니다.

빌드가 완료된 후에는 이런 도구들이 필요 없기 때문입니다. 이 한 줄로 node_modules 크기가 절반 이하로 줄어들기도 합니다.

김개발 씨가 질문했습니다. "왜 마지막에 package.json을 다시 복사하나요?" 박시니어 씨가 설명합니다.

"좋은 관찰이에요. 일부 Node.js 프레임워크는 런타임에 package.json의 정보를 참조합니다.

예를 들어 버전 정보를 API로 노출하거나, 특정 설정값을 읽어오는 경우가 있어요. 꼭 필요하지 않다면 생략해도 됩니다." NODE_ENV=production 설정도 중요합니다.

이 환경 변수가 설정되면 Express.js는 프로덕션 모드로 동작합니다. 불필요한 에러 스택 노출을 막고, 성능 최적화가 활성화됩니다.

또한 npm install을 실행할 때 devDependencies를 자동으로 건너뛰기도 합니다. USER node 명령어로 보안을 강화합니다.

컨테이너 내부에서 root 권한으로 프로세스를 실행하는 것은 보안상 좋지 않습니다. 공식 Node.js 이미지에는 node라는 비특권 사용자가 미리 생성되어 있습니다.

이 사용자로 애플리케이션을 실행하면, 만약 보안 취약점이 발견되더라도 시스템 전체에 대한 접근이 제한됩니다. 실제로 이 Dockerfile을 빌드해보면 놀라운 결과가 나옵니다.

기존 1.2GB였던 이미지가 약 150MB로 줄어듭니다. 빌드 시간도 캐시가 적용되면 30초 이내로 단축됩니다.

Docker Hub에 푸시하는 시간, 서버에서 풀링하는 시간 모두 크게 줄어듭니다. 김개발 씨가 직접 빌드해보고 감탄했습니다.

"전에는 이미지 푸시하는 데만 5분 걸렸는데, 이제 30초면 끝나네요!" 팀의 배포 파이프라인이 한결 빨라졌습니다.

실전 팁

💡 - .dockerignore 파일을 만들어 node_modules, .git, dist 폴더를 제외하세요

  • npm ci는 npm install보다 빠르고 재현 가능한 설치를 보장합니다
  • 멀티코어 활용을 위해 npm run build 시 병렬 빌드 옵션을 고려하세요

4. Go 앱 멀티 스테이지

어느 날 박시니어 씨가 흥미로운 제안을 했습니다. "우리 팀 마이크로서비스 중 일부를 Go로 다시 작성하면 어떨까요?" 김개발 씨가 궁금해합니다.

"Go도 멀티 스테이지 빌드를 사용하나요?" 박시니어 씨의 눈이 반짝였습니다. "Go야말로 멀티 스테이지 빌드의 진정한 힘을 보여줄 수 있어요."

Go 멀티 스테이지 빌드는 컴파일 언어의 장점을 극대화합니다. Go는 단일 바이너리로 컴파일되므로, 최종 이미지에는 실행 파일 하나만 있으면 됩니다.

심지어 scratch 이미지를 사용하면 운영체제조차 없는, 오직 실행 파일만 담긴 초경량 이미지를 만들 수 있습니다.

다음 코드를 살펴봅시다.

# 빌드 스테이지
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 정적 바이너리로 컴파일
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

# 최종 스테이지: 비어있는 이미지
FROM scratch
# SSL 인증서 복사 (HTTPS 요청용)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/main /main
EXPOSE 8080
ENTRYPOINT ["/main"]

박시니어 씨가 화면에 Go 프로젝트의 Dockerfile을 띄웠습니다. "Node.js와는 완전히 다른 세계가 펼쳐질 거예요." Go 언어의 특별한 점을 먼저 이해해야 합니다.

Go는 컴파일 언어입니다. JavaScript나 Python처럼 런타임 인터프리터가 필요하지 않습니다.

go build 명령어를 실행하면 모든 의존성이 하나의 실행 파일로 합쳐집니다. 이 실행 파일만 있으면 Go 런타임 없이도 프로그램이 동작합니다.

마치 완성된 조립 가구처럼, 추가 부품 없이 바로 사용할 수 있습니다. CGO_ENABLED=0의 의미를 알아봅시다.

CGO는 Go에서 C 라이브러리를 호출할 수 있게 해주는 기능입니다. 하지만 CGO를 사용하면 C 라이브러리에 대한 동적 링크가 필요해지고, 이는 운영체제의 라이브러리가 있어야 한다는 것을 의미합니다.

CGO_ENABLED=0으로 설정하면 순수 Go만으로 컴파일되어, 어떤 외부 라이브러리도 필요 없는 정적 바이너리가 생성됩니다. scratch 이미지가 무엇인지 설명하겠습니다.

Docker에는 scratch라는 특별한 이미지가 있습니다. 이 이미지는 말 그대로 비어있습니다.

운영체제도, 쉘도, 아무것도 없습니다. 파일 시스템 자체가 비어있는 상태에서 시작합니다.

Go의 정적 바이너리는 이 빈 이미지 위에서도 완벽하게 동작합니다. 이것이 Go와 Docker의 환상적인 조합입니다.

하지만 주의할 점이 있습니다. HTTPS 요청을 보내는 애플리케이션이라면 SSL 인증서가 필요합니다.

scratch 이미지에는 인증서가 없으므로, 빌드 스테이지에서 인증서 파일을 복사해와야 합니다. 위 코드에서 ca-certificates.crt를 복사하는 이유가 바로 이것입니다.

이 파일이 없으면 HTTPS 요청 시 인증서 검증 에러가 발생합니다. 김개발 씨가 이미지 크기를 확인하고 놀랐습니다.

"8MB요? 진짜 8MB가 맞나요?" 박시니어 씨가 웃으며 답합니다.

"맞아요. Go 바이너리와 인증서 파일, 그게 전부니까요." Node.js 이미지가 150MB였던 것과 비교하면 20배나 작습니다.

이렇게 작은 이미지의 장점은 무엇일까요? 첫째, 보안입니다.

이미지에 쉘이 없으므로 컨테이너에 침입해도 할 수 있는 것이 거의 없습니다. 둘째, 시작 시간입니다.

컨테이너가 거의 즉시 시작됩니다. 셋째, 저장 비용입니다.

수백 개의 마이크로서비스를 운영한다면 이미지 크기 차이가 저장소 비용에 큰 영향을 미칩니다. scratch 대신 distroless 이미지를 사용할 수도 있습니다.

Google이 만든 distroless 이미지는 scratch보다는 약간 크지만, 기본적인 CA 인증서와 시간대 정보가 포함되어 있습니다. 디버깅이 필요할 때를 위한 debug 태그도 제공합니다.

scratch가 너무 극단적으로 느껴진다면 distroless가 좋은 대안입니다. 김개발 씨가 팀 동료들에게 이 내용을 공유했습니다.

"Go로 마이크로서비스를 만들면 Docker 이미지가 정말 작아지더라고요." 팀에서 Go 도입에 대한 논의가 활발해졌습니다. 성능과 이미지 크기, 두 마리 토끼를 잡을 수 있는 매력적인 선택지가 생긴 것입니다.

실전 팁

💡 - scratch 이미지는 디버깅이 어려우므로, 개발 중에는 alpine을 사용하세요

  • 시간대 정보가 필요하면 /usr/share/zoneinfo도 복사해야 합니다
  • distroless 이미지는 보안과 편의성의 균형점을 제공합니다

5. 최종 이미지 크기 비교

김개발 씨가 지난 몇 주간 다양한 프로젝트에 멀티 스테이지 빌드를 적용해보았습니다. 어느 날 팀 회의에서 발표 기회가 생겼습니다.

"멀티 스테이지 빌드의 효과를 숫자로 보여드릴게요." 화면에 표가 나타났고, 동료들의 눈이 휘둥그레졌습니다.

이미지 크기 비교를 통해 멀티 스테이지 빌드의 효과를 명확히 확인할 수 있습니다. 동일한 애플리케이션도 Dockerfile 작성 방법에 따라 이미지 크기가 10배 이상 차이날 수 있습니다.

이 차이는 배포 시간, 저장 비용, 보안 취약점 수에 직접적인 영향을 미칩니다.

다음 코드를 살펴봅시다.

# 크기 비교를 위한 이미지 빌드 명령어
docker build -t app:single-stage -f Dockerfile.single .
docker build -t app:multi-stage -f Dockerfile.multi .

# 이미지 크기 확인
docker images | grep app

# 결과 예시:
# app   single-stage   1.2GB
# app   multi-stage    145MB

# 더 자세한 분석: dive 도구 사용
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest app:multi-stage

김개발 씨가 화면에 비교 표를 띄웠습니다. 회의실이 조용해졌습니다.

먼저 Node.js 프로젝트의 변화를 살펴봅시다. 단일 스테이지로 빌드한 Express.js 애플리케이션은 1.2GB였습니다.

여기에는 Node.js 전체 런타임, npm, 개발 의존성, 소스 코드, 빌드 도구가 모두 포함되어 있었습니다. 멀티 스테이지 빌드를 적용하자 145MB로 줄었습니다.

88%가 감소한 것입니다. Go 프로젝트의 결과는 더욱 극적이었습니다.

golang:1.22 이미지를 그대로 사용하면 약 800MB입니다. 하지만 scratch 이미지에 바이너리만 담으면 8MB가 됩니다.

99%의 감소율입니다. 회의실에서 감탄의 탄성이 터져나왔습니다.

이 크기 차이가 실제로 어떤 영향을 미칠까요? 박시니어 씨가 계산을 보여주었습니다.

팀에서 하루에 20번 배포한다고 가정합니다. 이미지 푸시와 풀에 각각 1분씩 걸린다면, 하루에 40분을 이미지 전송에 사용합니다.

이미지 크기가 10분의 1로 줄면, 이 시간도 4분으로 줄어듭니다. 한 달이면 12시간 이상을 절약할 수 있습니다.

저장 비용도 무시할 수 없습니다. AWS ECR이나 Docker Hub 같은 레지스트리는 저장 용량에 따라 비용을 청구합니다.

100개의 마이크로서비스가 각각 1GB 이미지를 가지고 있다면, 저장 비용이 상당합니다. 같은 서비스가 100MB 이미지를 사용한다면, 비용이 10분의 1로 줄어듭니다.

보안 관점에서도 중요한 차이가 있습니다. 이미지에 포함된 패키지가 많을수록 잠재적인 보안 취약점도 많아집니다.

1GB 이미지에는 수천 개의 파일과 수백 개의 패키지가 있습니다. 각각이 CVE(보안 취약점)를 포함할 수 있습니다.

반면 scratch 기반 8MB 이미지에는 취약점이 있을 만한 곳 자체가 거의 없습니다. dive라는 도구로 이미지를 분석할 수 있습니다.

dive는 Docker 이미지의 각 레이어를 시각적으로 보여주는 도구입니다. 어떤 레이어가 가장 많은 공간을 차지하는지, 불필요한 파일이 포함되어 있는지 확인할 수 있습니다.

이미지 최적화를 할 때 매우 유용합니다. 위 코드 예제에서 dive 사용법을 확인할 수 있습니다.

한 동료가 질문했습니다. "작은 이미지에 단점은 없나요?" 박시니어 씨가 답합니다.

"scratch 이미지는 디버깅이 어렵습니다. 쉘이 없어서 컨테이너에 접속해도 아무것도 할 수 없어요.

그래서 개발 환경에서는 alpine 기반 이미지를 사용하고, 프로덕션에서만 scratch를 사용하기도 합니다. 또는 distroless의 debug 태그를 활용할 수 있어요." 회의가 끝나고 팀원들의 반응이 뜨거웠습니다.

"우리 프로젝트에도 당장 적용해봐야겠어요." "Go 마이크로서비스 검토해볼 만하네요." 김개발 씨의 발표는 팀 전체의 Docker 최적화 열풍을 불러일으켰습니다.

실전 팁

💡 - docker images 명령어로 이미지 크기를 정기적으로 모니터링하세요

  • dive 도구로 각 레이어를 분석하여 최적화 포인트를 찾으세요
  • CI/CD에 이미지 크기 제한을 두어 불필요한 비대화를 방지하세요

6. CI/CD에서 활용하기

멀티 스테이지 빌드를 마스터한 김개발 씨에게 새로운 임무가 주어졌습니다. "우리 CI/CD 파이프라인에 멀티 스테이지 빌드를 통합해줄 수 있어요?" 팀장의 요청입니다.

김개발 씨는 고개를 끄덕이며 GitHub Actions 설정 파일을 열었습니다.

CI/CD에서 멀티 스테이지 빌드를 활용하면 테스트, 빌드, 배포를 효율적으로 자동화할 수 있습니다. --target 옵션으로 특정 스테이지만 빌드하거나, 캐시를 적극 활용하여 빌드 시간을 단축할 수 있습니다.

GitHub Actions, GitLab CI, Jenkins 등 대부분의 CI/CD 도구에서 동일한 패턴을 적용할 수 있습니다.

다음 코드를 살펴봅시다.

# .github/workflows/docker-build.yml
name: Docker Build
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          # 캐시 활용으로 빌드 속도 향상
          cache-from: type=gha
          cache-to: type=gha,mode=max
          push: true
          tags: myapp:${{ github.sha }}

김개발 씨가 팀의 GitHub Actions 워크플로우 파일을 열어보았습니다. 기존에는 단순한 docker build 명령어만 사용하고 있었습니다.

먼저 Docker Buildx가 무엇인지 알아봅시다. Buildx는 Docker의 차세대 빌드 도구입니다.

기존 docker build보다 많은 기능을 제공합니다. 멀티 플랫폼 빌드, 향상된 캐싱, 빌드 병렬화 등이 가능합니다.

GitHub Actions에서 Buildx를 설정하면 이런 고급 기능들을 활용할 수 있습니다. 캐싱은 CI/CD에서 특히 중요합니다.

로컬에서는 Docker 레이어 캐시가 자동으로 유지됩니다. 하지만 CI 환경은 매번 새로운 러너에서 시작합니다.

아무 조치를 하지 않으면 매번 처음부터 빌드해야 합니다. cache-fromcache-to 옵션을 사용하면 이전 빌드의 캐시를 저장하고 불러올 수 있습니다.

type=gha는 GitHub Actions 캐시를 의미합니다. GitHub Actions는 워크플로우 간에 공유할 수 있는 캐시 저장소를 제공합니다.

이 캐시에 Docker 레이어를 저장해두면, 다음 빌드에서 재사용할 수 있습니다. mode=max 옵션은 모든 레이어를 캐시하라는 의미입니다.

이렇게 하면 빌드 시간이 절반 이하로 줄어들 수 있습니다. 테스트 스테이지를 별도로 실행하는 패턴도 유용합니다.

Dockerfile에 테스트 스테이지를 추가하고, CI에서 --target test로 해당 스테이지만 빌드할 수 있습니다. 테스트가 실패하면 파이프라인이 즉시 중단되고, 프로덕션 이미지 빌드는 진행되지 않습니다.

이렇게 하면 빌드 실패를 더 빨리 발견할 수 있습니다. 박시니어 씨가 추가 팁을 알려주었습니다.

"멀티 플랫폼 빌드도 고려해보세요. ARM 기반 서버가 늘어나고 있어요." Buildx를 사용하면 하나의 Dockerfile로 amd64와 arm64 이미지를 동시에 빌드할 수 있습니다.

AWS Graviton이나 Apple Silicon 맥에서도 같은 이미지를 사용할 수 있게 됩니다. GitLab CI에서도 비슷한 패턴을 적용할 수 있습니다.

GitLab은 자체 컨테이너 레지스트리를 제공하고, CI/CD 설정에서 Docker-in-Docker를 쉽게 사용할 수 있습니다. 캐싱 전략만 약간 다를 뿐, 멀티 스테이지 빌드의 원리는 동일합니다.

Jenkins, CircleCI 등 다른 도구에서도 마찬가지입니다. 보안을 위한 이미지 스캔도 파이프라인에 추가하면 좋습니다.

Trivy, Snyk 같은 도구로 빌드된 이미지의 취약점을 검사할 수 있습니다. 멀티 스테이지 빌드로 이미지 크기를 줄이면, 스캔 시간도 줄어들고 발견되는 취약점 수도 감소합니다.

일석이조의 효과입니다. 김개발 씨가 새로운 워크플로우를 커밋하고 푸시했습니다.

파이프라인이 실행되는 것을 지켜보던 팀원들이 환호했습니다. "빌드 시간이 8분에서 3분으로 줄었어요!" 캐싱과 멀티 스테이지 빌드의 조합이 만들어낸 마법이었습니다.

이제 팀의 배포 프로세스는 한층 더 효율적으로 변했습니다.

실전 팁

💡 - GitHub Actions의 캐시는 7일 후 자동 삭제되므로, 정기적인 빌드가 필요합니다

  • --target 옵션으로 테스트 스테이지만 빌드하면 빠른 피드백이 가능합니다
  • 이미지 스캔 도구를 파이프라인에 추가하여 보안을 강화하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Docker#MultiStageBuild#Dockerfile#ContainerOptimization#ImageSize#Docker,Container,DevOps

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.