[CS300 #225] Dockerfile 작성법 — 캐시 순서, 멀티스테이지, 작은 이미지
컴퓨터공학 300 주제 시리즈의 225번째 글이다. 전체 지도는 여기.
한 줄 요약
좋은 Dockerfile 은 “자주 안 바뀌는 것을 위에, 자주 바뀌는 것을 아래에” 두어 빌드 캐시를 살리고, 멀티스테이지로 빌드 도구를 최종 이미지에서 빼고, root 가 아닌 사용자로 실행한다.
왜 필요한가
Dockerfile 은 몇 줄만 써도 돌아간다. 그래서 대충 쓰기 쉽다. 대충 쓴 Dockerfile 은 이런 결과를 낳는다.
- 코드 한 줄 고쳤는데 의존성 설치를 처음부터 다시 해서 빌드가 몇 분씩 걸린다.
- 컴파일러와 소스, 캐시 파일이 그대로 남아 이미지가 수백 MB 더 크다.
- root 로 실행되어 컨테이너 탈출 시 피해가 커진다.
- 비밀값이 레이어에 남아 이미지를 받은 누구나 꺼낼 수 있다.
전부 작성 순서와 몇 가지 습관으로 막을 수 있다.
핵심 개념
주요 명령어
| 명령 | 역할 | 레이어 생성 |
|---|---|---|
FROM |
베이스 이미지 지정, 스테이지 시작 | - |
RUN |
빌드 시 명령 실행 | 예 |
COPY / ADD |
빌드 컨텍스트의 파일을 이미지로 복사 | 예 |
WORKDIR |
작업 디렉터리 | (설정) |
ENV / ARG |
런타임 환경 변수 / 빌드 시 인자 | (설정) |
USER |
이후 명령과 실행 사용자 | (설정) |
EXPOSE |
문서용 포트 표시. 실제로 포트를 열지 않는다 | (설정) |
ENTRYPOINT / CMD |
컨테이너 시작 명령 / 기본 인자 | (설정) |
COPY 와 ADD 중에서는 COPY 를 기본으로 쓴다. ADD 는 원격 URL·로컬 tar 자동 해제 같은 부가 동작이 있어 의도가 흐려진다.
빌드 캐시의 규칙
빌더는 명령을 위에서부터 차례로 실행하며, 각 단계마다 “이전과 같은가”를 판단해 같으면 캐시를 재사용한다.
RUN은 명령 문자열이 같으면 캐시 적중으로 본다. 명령이 외부에서 받는 내용이 바뀌었는지는 보지 않는다.COPY/ADD는 복사 대상 파일의 내용(체크섬)까지 비교한다.- 한 단계가 캐시를 놓치면, 그 아래 모든 단계가 다시 실행된다.
마지막 규칙 때문에 순서가 모든 것이다.
나쁜 예와 좋은 예
# 나쁜 예
FROM python:3.12
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 --no-cache-dir -r requirements.txt # requirements 가 같으면 캐시
COPY . .
RUN useradd --create-home --uid 10001 app
USER app
CMD ["python", "main.py"]
차이는 세 가지다.
- 의존성 목록만 먼저 복사해 설치하고, 소스는 나중에 복사한다. 코드를 고쳐도 의존성 설치 단계는 캐시를 탄다.
-slim베이스와--no-cache-dir로 불필요한 파일을 줄였다.CMD를 JSON 배열(exec form)로 썼다. 문자열(shell form)로 쓰면/bin/sh -c가 PID 1 이 되어,docker stop이 보내는 SIGTERM 이 애플리케이션에 전달되지 않을 수 있다. 그러면 유예 시간이 끝날 때까지 기다렸다가 SIGKILL 로 죽는다.
.dockerignore
COPY . . 는 빌드 컨텍스트 전체를 대상으로 한다. .git, node_modules, 로컬 가상환경, .env 같은 파일이 들어가면 이미지가 커지고 캐시가 쓸데없이 깨지며 비밀이 새어 나간다. .dockerignore 로 제외한다.
.git
__pycache__
.venv
.env
*.log
멀티스테이지 빌드
컴파일 언어는 빌드에 필요한 도구(컴파일러, 헤더, 빌드 캐시)와 실행에 필요한 것(바이너리 하나)이 크게 다르다. 멀티스테이지는 한 Dockerfile 안에 여러 FROM 을 두고, 앞 스테이지의 결과물만 뒤로 복사한다.
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
최종 이미지에는 Go 컴파일러도 소스도 없다. 셸조차 없으므로 공격 표면이 작다. 대신 컨테이너 안에 들어가 디버깅하기는 어려워진다. 이 트레이드오프를 알고 고른다.
레이어를 줄이는 RUN
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
apt-get update 와 install 을 한 RUN 에 묶는 이유는 두 가지다. 따로 두면 update 단계가 캐시되어 오래된 패키지 목록으로 설치하게 될 수 있다. 그리고 목록 파일 삭제를 같은 RUN 안에서 해야 레이어에서 실제로 빠진다. 다른 RUN 에서 지우면 앞 레이어에 바이트가 남는다.
비밀값
ENV TOKEN=... 이나 COPY .env 로 넣은 비밀은 이미지 레이어·설정에 남는다. ARG 도 이미지 이력에 드러날 수 있다. BuildKit 의 비밀 마운트(RUN --mount=type=secret,...)를 쓰면 해당 RUN 동안만 파일로 보이고 레이어에는 남지 않는다. 런타임 비밀은 이미지가 아니라 실행 환경(쿠버네티스 Secret 등)에서 주입한다.
직접 해 보기
빌드 캐시 규칙을 시뮬레이션해서 순서의 효과를 숫자로 보자. 각 단계의 캐시 키는 “이전 단계 키 + 명령 + (COPY 라면 파일 내용)”의 해시로 만든다.
import hashlib
def build(steps, files, cache):
parent, rebuilt = "", 0
for kind, arg, cost in steps:
content = ""
if kind == "COPY":
names = sorted(files) if arg == "." else [arg]
content = "".join(n + files[n] for n in names)
key = hashlib.sha256((parent + kind + arg + content).encode()).hexdigest()
if key in cache:
status = "CACHED"
else:
status, rebuilt = "RUN ", rebuilt + cost
cache.add(key)
parent = key
print(f" {status} {kind} {arg}")
print(f" -> rebuild cost: {rebuilt}s")
bad = [("COPY", ".", 1), ("RUN", "pip install -r requirements.txt", 60)]
good = [("COPY", "requirements.txt", 1), ("RUN", "pip install -r requirements.txt", 60),
("COPY", ".", 1)]
v1 = {"requirements.txt": "flask==3.0", "main.py": "v1"}
v2 = {"requirements.txt": "flask==3.0", "main.py": "v2"} # 코드만 수정
for name, steps in [("bad", bad), ("good", good)]:
cache = set()
print(f"[{name}] first build")
build(steps, v1, cache)
print(f"[{name}] after editing main.py")
build(steps, v2, cache)
결과는 다음과 같다.
[bad] first build
RUN COPY .
RUN RUN pip install -r requirements.txt
-> rebuild cost: 61s
[bad] after editing main.py
RUN COPY .
RUN RUN pip install -r requirements.txt
-> rebuild cost: 61s
[good] first build
RUN COPY requirements.txt
RUN RUN pip install -r requirements.txt
RUN COPY .
-> rebuild cost: 62s
[good] after editing main.py
CACHED COPY requirements.txt
CACHED RUN pip install -r requirements.txt
RUN COPY .
-> rebuild cost: 1s
같은 수정에 61초와 1초. 실제 빌드에서도 차이는 이 구조에서 나온다.
현업에서는
- CI 빌드 시간. CI 러너는 매번 깨끗한 환경에서 시작하는 경우가 많아 로컬 캐시가 없다. 레지스트리에 캐시를 내보내고 가져오는 설정(BuildKit 의 cache export/import)을 함께 쓴다.
- 이미지 스캔. 베이스 이미지가 클수록 취약점 스캐너가 보고하는 패키지도 많다. slim·distroless 베이스로 바꾸는 것만으로 보고 건수가 크게 줄어드는 경우가 흔하다.
- non-root 와 쿠버네티스. 파드의
securityContext.runAsNonRoot: true를 켜면 root 로 실행하려는 이미지는 시작이 거부된다. Dockerfile 에서 숫자 UID 로USER를 지정해 두면 이 검사를 통과한다. - 헬스체크는 오케스트레이터에. Dockerfile 의
HEALTHCHECK는 쿠버네티스가 쓰지 않는다. 쿠버네티스에서는 liveness·readiness 프로브로 정의한다.
확인 문제
- 빌드 캐시에서 한 단계가 캐시를 놓치면 그 뒤 단계는 어떻게 되는가?
COPY requirements.txt와RUN pip install을COPY . .보다 먼저 두는 이유는?CMD python app.py와CMD ["python", "app.py"]의 차이가 종료 동작에 미치는 영향은?- 멀티스테이지 빌드의 장점과 단점을 하나씩 들라.
apt-get update와apt-get install을 별도RUN으로 나누면 생길 수 있는 문제는?
풀이
- 그 뒤의 모든 단계가 다시 실행된다.
- 소스 코드가 바뀌어도 의존성 목록이 같으면 설치 단계가 캐시를 타기 때문이다.
- shell form 은
/bin/sh -c가 PID 1 이 되어 SIGTERM 이 앱에 전달되지 않을 수 있다. 정상 종료가 안 되고 유예 후 강제 종료된다. exec form 은 앱이 PID 1 이 되어 신호를 직접 받는다. - 장점: 빌드 도구가 최종 이미지에서 빠져 작고 안전하다. 단점: 셸·도구가 없어 컨테이너 안 디버깅이 어렵다.
update단계가 캐시되어 오래된 패키지 목록으로install하게 된다. 또 목록 파일을 지워도 앞 레이어에 남는다.
더 읽을거리 (References)
- Docker Docs, Dockerfile reference
- Docker Docs, Building best practices
- Docker Docs, Multi-stage builds
- Docker Docs, Docker build cache