이미지가 커지는 건 앱이 무거워서가 아니라 빌드 도구·소스·중간 산출물을 최종 이미지에 그대로 넣기 때문이다. 멀티스테이지 빌드로 실행에 필요한 것만 복사하고, 베이스를 distroless로 바꾸면 대부분 수백 MB가 수십 MB로 줄고 보안 표면도 같이 줄어든다.
여기서는 왜 커지는지, 멀티스테이지·distroless·alpine의 실제 트레이드오프, 레이어 순서로 캐시를 살리는 법, .dockerignore와 표면 축소까지 실제로 돌아가는 Dockerfile 예시로 정리한다. 숫자는 스택마다 다르지만 방향은 거의 항상 같다.
몇 년 전에 Node 서비스 하나를 컨테이너로 말아서 배포했는데, 이미지가 1.2GB였다. 코드는 고작 몇 MB인데. CI에서 push하는 데만 2분 넘게 걸리고, 노드가 새로 뜰 때마다 pull이 느려서 오토스케일이 굼떴다. 그때 처음으로 "이미지 안에 대체 뭐가 들었나"를 진지하게 뜯어봤다. 결론부터 말하면, 앱보다 빌드 부스러기가 훨씬 무거웠다.
이미지는 왜 이렇게 커지나
이미지가 커지는 가장 흔한 원인은 빌드에만 필요한 것들이 실행 이미지에 그대로 남아 있기 때문이다. 컴파일러, 헤더 파일, node_modules의 devDependencies, npm/pip 캐시, 소스 트리, .git 폴더까지. 이것들은 앱을 만들 때만 필요하고 돌릴 때는 하나도 안 쓴다. 그런데 단일 스테이지 Dockerfile은 이 전부를 최종 레이어에 박아버린다.
두 번째 원인은 베이스 이미지 자체다. ubuntu:22.04는 그 자체로 70~80MB, node:20(Debian 기반)은 1GB에 육박한다. 셸, 패키지 매니저, 온갖 시스템 유틸리티가 다 들어 있으니까. 앱은 그중 극히 일부만 쓰는데도.
세 번째는 레이어 특성이다. Docker 이미지는 레이어의 누적이라, 한 레이어에서 100MB짜리 파일을 만들고 다음 레이어에서 지워도 이미지 크기는 안 줄어든다. 지운 게 아니라 위에 "삭제됨" 표시를 덮은 것뿐이라, 아래 레이어에 원본이 그대로 남는다. 이거 모르고 RUN rm -rf 한 줄 넣어놓고 왜 안 줄지 하는 사람 많다. 나도 그랬고.
| 이미지 안에 든 것 | 빌드에 필요? | 실행에 필요? | 대략 크기 |
|---|---|---|---|
| 컴파일러/빌드툴(gcc, make) | O | X | 200~400MB |
| devDependencies | O | X | 100~500MB |
| 패키지 캐시(apt/npm/pip) | △ | X | 50~200MB |
| 소스 트리 + .git | O | X | 가변 |
| 런타임 + 앱 산출물 | - | O | 필요한 것만 |
표를 보면 답이 이미 나온다. 마지막 줄만 최종 이미지에 남기면 된다. 그게 멀티스테이지 빌드의 전부다.
멀티스테이지 빌드가 핵심이다
멀티스테이지 빌드는 하나의 Dockerfile 안에 여러 개의 FROM을 두고, 빌드 스테이지에서 만든 산출물만 최종 스테이지로 복사하는 기법이다. 빌드 스테이지는 이미지에 포함되지 않고 버려진다. 컴파일러도, node_modules 통째로도, 소스도 최종 이미지엔 안 남는다. 오직 COPY --from=...로 명시적으로 가져온 것만 남는다.
Node 앱을 예로 들면 이렇게 된다. 빌드 스테이지에서 전체 의존성 설치 후 번들을 만들고, 실행 스테이지에서는 production 의존성과 빌드 결과물만 가져온다.
# ---- build 스테이지: 여기서 컴파일/번들 ----
FROM node:20-slim AS build
WORKDIR /app
# 캐시를 위해 매니페스트 먼저 복사
COPY package.json package-lock.json ./
RUN npm ci # devDependencies 포함 전체 설치
COPY . .
RUN npm run build # dist/ 생성
# ---- deps 스테이지: production 의존성만 따로 ----
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # 런타임 의존성만
# ---- 최종 실행 이미지: 빌드 도구 없음 ----
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node # root로 돌리지 말 것
CMD ["node", "dist/server.js"]
여기서 스테이지를 build와 deps로 나눈 게 포인트다. 빌드 스테이지의 node_modules에는 devDependencies가 섞여 있으니 그걸 그대로 복사하면 안 된다. production 의존성만 깨끗하게 다시 설치한 deps 스테이지에서 가져와야 최종 이미지가 가볍다. 이 디테일을 놓치고 --from=build로 node_modules를 통째 복사하는 경우가 진짜 흔하다.
Go나 Rust 같은 정적 컴파일 언어는 효과가 더 극적이다. 실행 파일 하나만 남기면 되니까.
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO 끄고 정적 링크 → 실행 파일 하나로
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# scratch = 완전 빈 이미지 (0 바이트 베이스)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
이렇게 하면 Go 서비스는 보통 10~30MB 선으로 떨어진다. golang:1.22 베이스가 800MB 넘는 걸 생각하면 40배 차이다. -ldflags="-s -w"는 디버그 심볼을 빼서 바이너리를 몇 MB 더 줄여준다.
distroless와 alpine, 뭘 골라야 하나
결론부터: 정적 바이너리나 인터프리터 앱이면 distroless가 기본값으로 좋고, 셸이나 시스템 패키지가 꼭 필요하면 alpine이 낫다. 둘 다 이미지를 크게 줄이지만 성격이 다르다.
distroless는 구글이 만든 이미지로, 앱 실행에 필요한 최소한(런타임, libc, CA 인증서, tzdata 정도)만 담고 셸도 패키지 매니저도 없다. bash도 sh도 없다. 그래서 이미지가 작고, 공격자가 셸을 못 잡으니 보안 표면이 확 준다. 대신 docker exec ... sh로 들어가서 디버깅하는 게 안 된다. 이게 처음엔 불편한데, :debug 태그를 쓰면 busybox 셸이 붙은 버전이 있어서 급할 땐 그걸로 바꿔 넣으면 된다.
alpine은 musl libc 기반의 초경량 배포판(베이스 약 5~8MB)에 apk 패키지 매니저와 sh가 있다. 셸이 있어서 디버깅이 편하고 필요한 패키지를 깔 수 있다. 문제는 musl libc다. glibc 전제로 빌드된 바이너리나 네이티브 애드온(예: 일부 npm 패키지, Python wheel)에서 미묘한 호환성 이슈가 난다. Python은 특히 조심해야 하는데, alpine에서는 manylinux wheel을 못 써서 소스 컴파일로 빠지는 바람에 오히려 빌드가 느려지고 이미지가 커지는 역설이 생긴다. 개인적으로 Python은 alpine 대신 python:3.12-slim(Debian slim) + 멀티스테이지를 쓴다.
| 항목 | distroless | alpine | debian-slim |
|---|---|---|---|
| 베이스 크기 | 약 2~20MB | 약 5~8MB | 약 30~75MB |
| 셸/패키지매니저 | 없음 | sh + apk | bash + apt |
| libc | glibc | musl | glibc |
| 디버깅 편의 | 낮음(:debug 태그) | 높음 | 높음 |
| 보안 표면 | 가장 작음 | 작음 | 중간 |
| 잘 맞는 경우 | Go/Rust/Java/Node 프로덕션 | 셸/패키지 필요, C 기반 | Python, 네이티브 확장 많은 앱 |
레이어 순서만 바꿔도 빌드가 빨라진다
Docker는 각 명령을 레이어로 캐싱하는데, 한 레이어라도 바뀌면 그 아래(뒤) 레이어는 전부 다시 빌드한다. 그래서 자주 안 바뀌는 것을 위에, 자주 바뀌는 것을 아래에 두는 게 캐시 전략의 핵심이다. 소스 코드는 커밋마다 바뀌지만 의존성 목록은 그렇게 자주 안 바뀐다. 그러니 의존성 설치를 소스 복사보다 먼저 해야 한다.
가장 흔한 실수가 이거다.
# 나쁜 예: 소스 한 줄만 고쳐도 npm ci 전체 재실행
COPY . .
RUN npm ci
# 좋은 예: package*.json만 먼저 → 의존성 캐시 유지
COPY package.json package-lock.json ./
RUN npm ci
COPY . . # 소스는 이 뒤에
차이가 크다. 위쪽은 README 오타 하나 고쳐도 npm ci가 처음부터 다시 돈다(수십 초~수 분). 아래쪽은 package-lock.json이 안 바뀌는 한 의존성 레이어가 캐시에서 그대로 재사용되고, 바뀐 소스 레이어만 다시 만든다. 나는 이 순서 하나로 CI 빌드 시간이 3분대에서 40초대로 준 경험이 있다.
BuildKit(DOCKER_BUILDKIT=1, 요즘 Docker는 기본)을 쓰면 --mount=type=cache로 패키지 매니저 캐시를 레이어 밖에 둘 수 있다. 캐시는 재사용하되 이미지엔 안 남는다.
# syntax=docker/dockerfile:1
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
# npm 캐시를 빌드 캐시에 마운트 → 재설치가 빨라지고 이미지엔 미포함
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
apt를 쓸 때도 같은 원리로, 한 RUN 안에서 설치하고 캐시를 지워야 한다. 명령을 나누면 삭제해도 아래 레이어에 남는다는 걸 기억하자.
# apt: 설치와 정리를 한 RUN에서 끝내기
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends 빼먹으면 추천 패키지가 줄줄이 딸려 와서 수십 MB가 그냥 붙는다. 이거 은근히 놓치기 쉽다.
.dockerignore를 안 쓰면 다 헛수고
.dockerignore는 빌드 컨텍스트에서 제외할 파일 목록이다. docker build .를 하면 현재 디렉터리 전체가 데몬으로 전송되는데, 여기 node_modules나 .git이 포함되면 전송도 느리고 COPY . .에 딸려 들어간다. .gitignore와 비슷하지만 별개 파일이라 따로 만들어야 한다.
# .dockerignore
.git
node_modules
npm-debug.log
dist
build
coverage
.env
.env.*
*.md
.github
Dockerfile
docker-compose*.yml
node_modules를 반드시 빼야 하는 이유가 하나 더 있다. 로컬에서 깔린 node_modules에는 네이티브 모듈이 호스트 OS/아키텍처용으로 컴파일돼 있을 수 있는데, 그게 컨테이너 안으로 복사되면 런타임에 invalid ELF header 같은 에러로 터진다. 그래서 컨테이너 안에서 npm ci로 새로 깔되, 컨텍스트에서는 호스트 것을 확실히 제외하는 게 맞다.
.env나 비밀 파일을 .dockerignore에 안 넣으면 COPY . .로 자격증명이 이미지에 그대로 박힌다. 이미지는 여러 곳으로 퍼지고 레이어 히스토리에 남아서, 나중에 지워도 이전 레이어에 남아 있다. 비밀은 빌드 시 secret mount나 런타임 환경변수로 넘기고, 컨텍스트에는 절대 넣지 말 것.이미지 안에 뭐가 들었는지 직접 보기
크기를 줄이려면 먼저 무엇이 어느 레이어에서 얼마나 차지하는지를 봐야 한다. 감으로 하지 말고 측정부터. 기본 명령으로도 레이어별 크기를 볼 수 있다.
# 레이어별 크기와 만든 명령 확인
docker history --no-trunc myimage:latest
# 이미지 목록을 크기순으로
docker images --format "{{.Size}}\t{{.Repository}}:{{.Tag}}" | sort -h
더 깊게 파려면 dive라는 도구가 좋다. 레이어를 하나씩 열어서 어떤 파일이 어디서 추가/변경됐는지 트리로 보여주고, "낭비되는 공간"까지 계산해준다. 나는 이미지 다이어트할 때 항상 이걸 먼저 돌린다.
# dive로 레이어 분석 (효율 점수까지 보여줌)
dive myimage:latest
# CI에서 낭비 비율 임계값으로 실패시키기
CI=true dive myimage:latest --highestUserWastedPercent 0.1
작은 이미지는 곧 작은 보안 표면이다
이미지를 줄이면 크기만 주는 게 아니라 취약점에 노출되는 표면 자체가 준다. 안 들어 있는 패키지는 CVE가 날 수도 없고, 없는 셸은 탈취당할 수도 없다. distroless로 가면 docker scout나 trivy로 스캔했을 때 잡히는 취약점 개수가 눈에 띄게 줄어드는 걸 볼 수 있다. 셸·패키지 매니저·수백 개 시스템 라이브러리가 통째로 빠지니 당연하다.
여기에 몇 가지를 더하면 표면이 더 준다. root로 실행하지 않기(USER nonroot), 파일시스템을 읽기 전용으로 마운트하기, 필요 없는 setuid 바이너리 제거. distroless의 :nonroot 태그는 기본 사용자가 이미 root가 아니라서 이 부분을 알아서 챙겨준다.
# 취약점 스캔 (Trivy)
trivy image --severity HIGH,CRITICAL myimage:latest
# Docker 내장 스캐너
docker scout cves myimage:latest
| 스택 예시 | 단순 단일 스테이지 | 멀티스테이지 + 경량 베이스 |
|---|---|---|
| Go API 서버 | 약 800MB~1GB | 약 10~30MB (distroless static) |
| Node.js 서비스 | 약 1~1.2GB | 약 120~180MB (distroless nodejs) |
| Python 웹앱 | 약 900MB~1.1GB | 약 120~200MB (slim/distroless) |
| Java(Spring) | 약 500~700MB | 약 200~250MB (distroless java) |
숫자는 앱·의존성에 따라 달라지지만, 몇 배에서 수십 배 차이가 나는 방향성은 거의 항상 성립한다. 특히 정적 컴파일 언어는 distroless static과 궁합이 최고다.
Python 실전 예시: slim + 멀티스테이지
Python은 앞서 말한 alpine 함정 때문에 python:3.12-slim을 베이스로 두고, 의존성을 별도 스테이지에서 설치해 --from으로 가져오는 패턴이 안정적이다. 가상환경을 통째로 복사하는 방식이 깔끔하다.
# ---- 의존성 빌드 스테이지 ----
FROM python:3.12-slim AS build
WORKDIR /app
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
# 컴파일이 필요한 패키지를 위한 빌드 도구 (이 스테이지에만 존재)
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt
# ---- 실행 스테이지: 빌드 도구 없음 ----
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PATH="/venv/bin:$PATH" PYTHONUNBUFFERED=1
# 컴파일된 의존성이 담긴 venv만 복사
COPY --from=build /venv /venv
COPY . .
RUN useradd -m app && chown -R app /app
USER app
CMD ["python", "-m", "gunicorn", "-b", "0.0.0.0:8000", "app:app"]
build-essential 같은 컴파일 도구가 build 스테이지에만 있고 runtime엔 안 넘어가는 게 핵심이다. 이 하나로 200~300MB가 빠진다. 더 조이고 싶으면 runtime 베이스를 gcr.io/distroless/python3-debian12로 바꿀 수 있는데, 그러면 셸이 없으니 디버깅 편의와 저울질해서 결정하면 된다.
정리하면, 순서대로 이것만 하면 된다
한 번에 다 바꾸려 하지 말고 효과 큰 것부터 하나씩 적용하는 걸 추천한다. 경험상 이 순서가 투자 대비 효과가 좋다.
- .dockerignore 작성 — 제일 싸고 즉효. node_modules, .git, .env 제외.
- 레이어 순서 정리 — 매니페스트 먼저 복사, 소스는 뒤. 캐시가 살아난다.
- 멀티스테이지 도입 — 빌드 도구/devDeps를 최종 이미지에서 축출. 여기서 가장 크게 준다.
- 베이스 교체 — 정적/인터프리터 앱은 distroless, 셸이 꼭 필요하면 alpine, Python은 slim.
- 비root 실행 + 스캔 — USER nonroot, trivy/scout로 CVE 확인.
나는 새 서비스를 만들 때 이제 처음부터 멀티스테이지로 시작한다. 나중에 이미지가 커진 걸 발견하고 뜯어고치는 것보다, 처음부터 골격을 잡아두는 게 훨씬 편하거든. 한 번 템플릿 만들어두면 스택별로 복붙이라 부담도 없다.
자주 묻는 질문
멀티스테이지 빌드를 쓰면 빌드가 느려지나요?
아니요, 오히려 캐시가 잘 걸리면 빨라집니다. 스테이지가 늘어도 각 스테이지가 독립적으로 캐싱되고, 병렬로 처리될 수 있는 스테이지는 BuildKit이 동시에 빌드합니다. 최종 이미지가 작아져 push/pull이 빨라지는 이득이 더 큽니다.
distroless 이미지는 어떻게 디버깅하나요?
:debug 태그를 쓰면 busybox 셸이 포함된 버전이 있어서 docker exec로 들어갈 수 있습니다. 또는 쿠버네티스라면 kubectl debug로 임시 디버그 컨테이너를 같은 프로세스 네임스페이스에 붙여 조사할 수 있습니다. 프로덕션은 distroless로 두고 디버깅 순간에만 태그를 바꾸는 방식을 권합니다.
alpine과 distroless 중 뭐가 더 안전한가요?
일반적으로 distroless가 보안 표면이 더 작습니다. 셸과 패키지 매니저가 없어 공격자가 발판을 잡기 어렵고, 포함 패키지가 적어 CVE 노출도 적습니다. 다만 alpine도 셸이 있을 뿐 표면 자체는 작은 편이라, musl 호환성과 디버깅 편의를 함께 저울질해 고르면 됩니다.
레이어에서 파일을 지웠는데 왜 이미지 크기가 안 줄어드나요?
Docker 이미지는 레이어의 누적이라, 나중 레이어에서 rm 해도 이전 레이어에 원본이 남아 있기 때문입니다. 파일을 만든 것과 지우는 것을 같은 RUN 명령 안에서 처리하거나, 애초에 멀티스테이지로 그 파일을 최종 이미지에 안 넣는 방식으로 해결합니다.
node_modules를 로컬에서 복사하면 안 되는 이유가 뭔가요?
네이티브 모듈이 호스트의 OS/아키텍처용으로 컴파일돼 있어 컨테이너 안에서 invalid ELF header 같은 오류로 실패할 수 있습니다. .dockerignore로 제외하고 컨테이너 안에서 npm ci로 새로 설치하는 것이 안전합니다.
이미지가 작으면 컨테이너 실행 속도도 빨라지나요?
실행(프로세스 시작) 자체가 빨라지는 건 아니지만, 노드에 이미지가 없을 때 pull 시간이 줄어 콜드 스타트와 오토스케일 반응이 빨라집니다. 레지스트리 저장 비용과 네트워크 전송량도 줄어드는 부수 효과가 있습니다.
alpine에서 Python을 쓰면 왜 더 커질 수 있나요?
alpine은 musl libc라 PyPI의 manylinux wheel(사전 컴파일 바이너리)을 쓰지 못하고 소스에서 컴파일하는 경우가 많습니다. 그러면 빌드 도구가 필요해지고 빌드가 느려지며 결과 이미지가 오히려 커질 수 있어, Python은 python:3.12-slim을 권합니다.

댓글 0