Docker 기초 (6) - Dockerfile: Layers, Cache, and Multi-stage Builds
layer와 cache의 동작부터 build context, .dockerignore, multi-stage build까지 Dockerfile 작성을 실습으로 정리합니다.
Docker 기초 시리즈의 6편입니다. 전체 목차는 0편에 있습니다.
Dockerfile 명령어
3편에서 hello world image를 만들며 Dockerfile을 처음 봤습니다. 이번 편에서는 의존성이 있는 프로젝트를 빌드하면서 각 명령어의 동작과 cache의 원리를 실습으로 확인해보겠습니다. Dockerfile로 환경 구축 절차를 코드로 적으면 git으로 버전 관리할 수 있고, 이 방식을 Infrastructure as Code라고 부릅니다.
먼저 이번 편에서 쓸 명령어들을 한눈에 정리합니다.
| 명령어 | 역할 |
|---|---|
| FROM | base image 지정. 모든 Dockerfile의 시작 |
| WORKDIR | 이후 명령의 작업 디렉토리 |
| COPY | build context의 파일을 image 안으로 복사 |
| RUN | 빌드 시점에 명령 실행. 결과가 layer로 남는다 |
| ENV | 환경 변수 기본값 |
| CMD | container 시작 시점의 기본 명령. run의 마지막 인자로 덮어쓸 수 있다 |
| ENTRYPOINT | 고정 실행 명령. CMD와 run 인자는 그 뒤에 붙는 인자가 된다 |
RUN과 CMD는 이름이 비슷해서 자주 헷갈리는데, 실행 시점이 다릅니다. RUN은 빌드할 때 한 번 실행되어 결과가 image에 남고, CMD는 container가 시작할 때마다 실행됩니다.
Setting
아래와 같이 폴더 구조를 만들고 실습을 진행해보겠습니다. 3편의 hello-docker에서 requirements.txt 하나가 늘었을 뿐입니다.
1
2
3
4
cache-lab/
├── Dockerfile
├── app.py
└── requirements.txt
app.py는 설치된 패키지의 버전을 출력합니다.
1
2
3
4
import requests
print("requests", requests.__version__)
print("cache lab v1")
requirements.txt에는 패키지 하나만 적습니다.
1
requests==2.32.3
Dockerfile은 일부러 흔한 실수 버전으로 시작합니다. 소스 전체를 먼저 복사하고 그다음에 설치하는 순서입니다.
1
2
3
4
5
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]
느린 빌드 재현
cache-lab 폴더 안에서 빌드하고 실행합니다.
1
2
docker build -t cache-lab .
docker run --rm cache-lab
1
2
3
4
5
6
7
8
9
10
$ docker build -t cache-lab .
[+] Building 14.2s
=> [1/4] FROM docker.io/library/python:3.12-slim
=> [2/4] WORKDIR /app
=> [3/4] COPY . .
=> [4/4] RUN pip install --no-cache-dir -r requirements.txt
$ docker run --rm cache-lab
requests 2.32.3
cache lab v1
첫 빌드는 pip install 때문에 십수 초 걸립니다. 여기까지는 정상입니다. 이제 app.py의 마지막 줄만 v2로 고칩니다.
1
print("cache lab v2")
패키지는 하나도 건드리지 않았습니다. 다시 빌드합니다.
1
2
3
4
5
$ docker build -t cache-lab .
[+] Building 13.8s
=> CACHED [2/4] WORKDIR /app
=> [3/4] COPY . .
=> [4/4] RUN pip install --no-cache-dir -r requirements.txt
코드 한 줄을 고쳤는데 pip install이 처음부터 다시 돌았습니다. 프로젝트가 커져서 설치가 수 분짜리가 되면, 코드를 고칠 때마다 수 분을 기다리게 되는 구조입니다.
layer와 cache
Dockerfile의 각 명령은 파일 시스템 변경분의 스냅샷인 layer를 하나씩 쌓고, 다시 빌드할 때 바뀌지 않은 명령까지는 cache를 재사용합니다. 위 출력의 CACHED가 그 표시입니다. 단, 한 layer가 바뀌면 그 이후의 cache는 전부 무효화됩니다. COPY는 복사하는 파일의 내용이 바뀌었는지로 판단합니다.
방금의 빌드를 이 규칙으로 읽으면 이렇게 됩니다.
[3/4] COPY . .은 app.py가 바뀌었으므로 다시 실행됩니다[4/4] RUN pip install은 명령 자체는 그대로지만, 앞 layer가 바뀌었으므로 cache가 무효화되어 다시 실행됩니다
즉 문제는 pip install이 아니라 그 앞에 놓인 COPY . .입니다. 자주 바뀌는 소스 코드가 어쩌다 바뀌는 의존성 설치보다 위에 있어서, 코드 수정이 매번 설치까지 끌고 내려가는 것입니다.
COPY 분리
설치에 필요한 requirements.txt만 먼저 복사하고, 소스 전체는 설치가 끝난 뒤에 복사하도록 Dockerfile을 고칩니다.
1
2
3
4
5
6
7
8
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
한 번 빌드해서 cache를 만들어 두고, app.py를 v3으로 고친 뒤 다시 빌드합니다.
1
2
3
4
5
6
7
8
9
10
$ docker build -t cache-lab .
[+] Building 0.8s
=> CACHED [2/5] WORKDIR /app
=> CACHED [3/5] COPY requirements.txt .
=> CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt
=> [5/5] COPY . .
$ docker run --rm cache-lab
requests 2.32.3
cache lab v3
이번에는 1초 안에 끝납니다. requirements.txt가 안 바뀌었으므로 [3/5] COPY가 cache로 통과하고, 그 뒤의 pip install까지 CACHED로 살아남습니다. 다시 실행되는 것은 마지막 COPY . . 하나뿐입니다. 반대로 requirements.txt에 패키지를 추가하면 그때는 설치가 다시 도는데, 의존성이 바뀌었으니 다시 도는 것이 맞습니다.
자주 바뀌는 것일수록 아래에 적는 것이 Dockerfile 작성의 제1원칙이고, 빌드가 느리다는 문제의 대부분이 이 순서에서 나옵니다.
build context와 .dockerignore
docker build 마지막 인자 .은 build context, 즉 빌드에 쓸 파일들의 범위입니다. 2편에서 본 대로 빌드를 실행하는 주체는 daemon이므로, CLI는 이 디렉토리를 daemon에 전송합니다. 전송량이 얼마나 쉽게 불어나는지 직접 만들어 봅니다.
1
2
3
mkdir data
dd if=/dev/zero of=data/dummy.bin bs=1024 count=102400 # 100MB짜리 더미 파일
docker build -t cache-lab .
1
2
=> [internal] load build context
=> => transferring context: 104.9MB
실습용 더미 파일 하나 때문에 빌드마다 100MB를 전송하고, COPY . .에 그대로 딸려 들어가 image까지 커집니다. 실제 프로젝트에서는 데이터 폴더, .venv, .git이 이 자리를 차지합니다.
.gitignore처럼 .dockerignore 파일로 제외 목록을 선언합니다.
1
2
3
4
.git
.venv
__pycache__/
data/
다시 빌드하면 전송량이 원래대로 돌아옵니다.
1
2
=> [internal] load build context
=> => transferring context: 1.1kB
multi-stage build
C 확장을 컴파일하는 패키지를 쓰면 빌드에는 컴파일러가 필요하지만 실행에는 필요 없습니다. stage를 나눠 빌드 산출물만 최종 image로 가져오면 image가 작아집니다.
1
2
3
4
5
6
7
8
9
10
11
12
FROM python:3.12-slim AS builder
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
ENV PATH="/venv/bin:$PATH"
COPY --from=builder /venv /venv
WORKDIR /app
COPY . .
CMD ["python", "app.py"]
builder stage에서 venv에 의존성을 설치하고, 최종 stage는 그 venv 디렉토리만 복사해 갑니다. 컴파일러와 빌드 캐시는 builder stage에 남고 최종 image에는 들어가지 않습니다. 지금처럼 순수 Python 의존성만 쓴다면 단일 stage로도 충분하고, multi-stage의 효과는 빌드 도구가 무거울수록 커집니다. ML 이미지에 적용하는 사례는 9편에서 다룹니다.
정리
실습을 마치면 3편에서 배운 대로 지웁니다. container는 전부 --rm으로 실행했으므로 image만 지우면 됩니다.
1
2
docker rmi cache-lab
rm -rf cache-lab # 실습 폴더 삭제
다음 편은 container 삭제에도 남아야 하는 데이터와 container 사이의 통신입니다.