컨테이너를 다루다 보면 “왜 내 이미지는 1GB가 넘어가지?”라는 의문이 생기곤 합니다. 답은 Dockerfile이 어떻게 레이어로 변환되는지 이해하는 데서 시작합니다. 이 글에서는 이미지가 만들어지는 원리부터 캐시 활용, 최종 이미지 크기를 줄이는 구조적 접근까지 다룹니다
이미지 = 읽기 전용 레이어의 스택
Docker 이미지는 단일 파일이 아닙니다. 레이어(layer)라 부르는 파일시스템 스냅샷들이 쌓여 있는 구조입니다. 각 레이어는 이전 레이어 위에 추가된 파일의 차분(diff)만 담고 있고, 컨테이너 실행 시 UnionFS(OverlayFS)로 하나의 파일시스템처럼 보입니다
flowchart BT
BASE["Layer 0: ubuntu:22.04<br/>(base image)"]
L1["Layer 1: apt-get install python3"]
L2["Layer 2: COPY requirements.txt"]
L3["Layer 3: pip install"]
L4["Layer 4: COPY app/"]
RW["Layer 5: 컨테이너 쓰기 가능 레이어"]
BASE --> L1 --> L2 --> L3 --> L4 --> RW
classDef base fill:#475569,stroke:#334155,color:#ffffff
classDef image fill:#2563eb,stroke:#1e40af,color:#ffffff
classDef writable fill:#059669,stroke:#047857,color:#ffffff
class BASE base
class L1,L2,L3,L4 image
class RW writable
| 특성 | 의미 |
|---|---|
| 불변성 | 한 번 생성된 레이어는 수정 불가 — 변경이 필요하면 새 레이어 추가 |
| 공유 | 같은 해시의 레이어는 여러 이미지가 공유하여 디스크 절약 |
| 캐시 가능 | 변경되지 않은 레이어는 재빌드 시 재사용 |
Dockerfile 명령과 레이어 매핑
Dockerfile의 모든 명령이 레이어가 되지는 않습니다. 파일시스템을 변경하는 명령만 레이어를 생성합니다
| 명령 | 레이어 생성? | 비고 |
|---|---|---|
FROM |
베이스 레이어 참조 | 새 레이어 생성은 아님 |
RUN |
✅ | 가장 큰 레이어가 생성되는 지점 |
COPY·ADD |
✅ | 파일 크기만큼 레이어 증가 |
WORKDIR |
❌ | 메타데이터만 변경 |
ENV·LABEL |
❌ | 메타데이터만 변경 |
CMD·ENTRYPOINT |
❌ | 실행 시점 설정 |
EXPOSE |
❌ | 문서화 목적 |
RUN과 COPY가 핵심입니다. 이 두 명령이 이미지 크기와 빌드 시간을 대부분 결정합니다
빌드 캐시의 작동 원리
Docker는 각 명령의 실행 결과를 해시로 캐싱합니다. 명령 문자열과 컨텍스트 파일의 내용이 같으면 캐시를 사용합니다
flowchart TB
S["빌드 시작"]
C1{"Layer 1 캐시 있음?<br/>(명령·컨텍스트 동일)"}
C2{"Layer 2 캐시 있음?"}
C3{"Layer 3 캐시 있음?"}
H1["재사용"]
H2["재사용"]
M["이 레이어부터<br/>전부 재빌드"]
S --> C1
C1 -->|"Hit"| H1
C1 -->|"Miss"| M
H1 --> C2
C2 -->|"Hit"| H2
C2 -->|"Miss"| M
H2 --> C3
C3 -->|"Miss"| M
classDef primary fill:#2563eb,stroke:#1e40af,color:#ffffff
classDef success fill:#059669,stroke:#047857,color:#ffffff
classDef danger fill:#dc2626,stroke:#991b1b,color:#ffffff
classDef info fill:#0891b2,stroke:#0e7490,color:#ffffff
class S primary
class C1,C2,C3 info
class H1,H2 success
class M danger
한 레이어가 캐시 미스되면 그 이후 레이어는 재빌드됩니다. 그래서 명령 순서가 빌드 속도에 큰 영향을 줍니다
자주 바뀌는 설정은 뒤로
나쁜 예와 좋은 예를 비교해 보겠습니다
# 안 좋은 예: 소스 코드가 앞에 있어 코드 변경 시 pip install이 재실행됩니다.
FROM python:3.12-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python", "main.py"]
# 좋은 예: 의존성을 먼저 설치하고 소스는 마지막에 복사합니다.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
의존성이 바뀌지 않는 한 pip install 레이어가 캐시되므로 소스 코드 수정만으로는 재빌드가 금방 끝납니다
이미지 크기를 키우는 원인
수백 MB 수준의 이미지가 생성되는 이유는 대체로 다음 세 가지 때문입니다
RUN apt-get install 다음 레이어에서 RUN apt-get remove를 수행해도 이미지는 줄어들지 않습니다. 이전 레이어는 불변이기 때문입니다. 패키지가 설치된 스냅샷은 남고 다음 레이어는 삭제 마커만 추가합니다. 최종 시점에는 보이지 않지만 파일 크기에는 두 상태가 모두 포함됩니다
| 원인 | 상세 | 해결 |
|---|---|---|
| 패키지 매니저 캐시 | apt·pip 캐시가 레이어에 잔류 |
동일 RUN 내에서 설치와 정리 완결 |
| 빌드 도구 | 컴파일에 필요한 gcc·make 등 | Multi-stage build 활용 |
| 베이스 이미지 | 불필요하게 무거운 베이스 선택 | 최소한의 베이스 이미지 사용 |
RUN 명령 통합
# 좋지 않음: 3개의 레이어가 생성되고 캐시가 남습니다.
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# 권장: 1개의 레이어로 처리하며 캐시를 즉시 제거합니다.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
동일한 레이어 내에서 설치와 정리를 마치면 불필요한 파일이 이미지에 남지 않습니다
Multi-stage Build 전략
컴파일러나 빌드 도구는 실행 시점에는 필요하지 않습니다. Multi-stage build는 빌드와 실행 단계를 분리하여 최종 이미지에는 결과물만 담는 방식입니다
# 1단계: 빌드
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
# 2단계: 실행
FROM alpine:3.19
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]
flowchart LR
subgraph stage1 [Build Stage]
GO["golang:1.22"]
SRC["source"]
BIN["binary 20MB"]
GO --> SRC --> BIN
end
subgraph stage2 [Runtime Stage]
ALP["alpine:3.19"]
APP["/app"]
ALP --> APP
end
BIN -.->|"COPY --from=builder"| APP
FINAL["최종 이미지: 25MB"]
stage2 --> FINAL
classDef danger fill:#dc2626,stroke:#991b1b,color:#ffffff
classDef success fill:#059669,stroke:#047857,color:#ffffff
classDef primary fill:#2563eb,stroke:#1e40af,color:#ffffff
class stage1,GO,SRC,BIN danger
class stage2,ALP,APP success
class FINAL primary
Go 기준 빌드 이미지가 800MB라 하더라도 최종 이미지는 25MB 수준으로 줄어들 수 있습니다
베이스 이미지 선택
베이스 이미지 선택에 따라 수백 MB의 차이가 발생합니다
| 베이스 | 크기 | 특징 |
|---|---|---|
*-full |
1GB+ | 모든 도구 포함, 디버깅 용이 |
*-slim |
~150MB | glibc 기반, 높은 호환성 |
*-alpine |
~50MB | 매우 작음, musl libc 사용 |
distroless |
~20MB | 쉘 미포함, 공격 표면 최소화 |
프로덕션에서는 slim 또는 distroless 사용을 권장합니다. Python의 경우 alpine에서 호환성 이슈가 생길 수 있으므로 slim이 안전한 선택입니다
정리
- 이미지는 불변 레이어의 스택이며
RUN,COPY가 레이어를 만듭니다 - 자주 바뀌는 명령은 Dockerfile 하단에 배치하여 캐시 효율을 높입니다
- 단일 레이어 내에서 설치와 정리를 완결하여 크기를 줄입니다
- Multi-stage build로 빌드 도구를 최종 이미지에서 제거합니다
다음 글에서는 컨테이너가 외부와 소통하는 방식인 네트워크와 볼륨 구조를 정리합니다