본문 바로가기

Infra/[K8S & Docker]

도커 이미지 레이어와 빌드 캐시 원리 — whiteout·캐시 키

반응형



소스 한 줄만 고쳤는데 npm install 이 처음부터 다시 돕니다. 반대로 패키지를 올렸는데 빌드는 옛 버전을 그대로 씁니다. 둘 다 같은 원리에서 나옵니다. 도커 이미지는 변경분(tar)을 순서대로 쌓은 것이고, 빌드 캐시는 "부모 단계 + 이번 지시문"이 같으면 결과도 같다고 보는 규칙입니다. 이 두 가지를 정확히 알면 Dockerfile 순서를 왜 그렇게 짜야 하는지, rm 을 해도 이미지가 왜 안 줄어드는지가 한 번에 풀립니다.

■ 이 구조가 푸는 문제

컨테이너 이미지는 운영체제 파일 트리 전체를 담습니다. 서비스 이미지 열 개가 같은 기반 이미지를 쓴다면, 그 파일 트리를 열 번 저장하고 열 번 내려받는 것은 낭비입니다. 빌드도 마찬가지입니다. 의존성 설치가 몇 분 걸리는데, 소스 한 줄 바꿀 때마다 그걸 반복할 이유가 없습니다.

도커가 고른 답은 두 가지입니다.

- 저장·전송 단위를 쪼갠다. 이미지를 하나의 큰 덩어리가 아니라 여러 레이어로 나누고, 레이어마다 내용 해시로 이름을 붙입니다. 이름이 같으면 내용도 같으니 한 번만 저장하고, 이미 있는 레이어는 받지 않습니다. 도커 문서도 "두 이미지는 공통 레이어를 공유한다"고 적고 있습니다.
- 빌드 단계를 결정적인 함수로 본다. 같은 입력(부모 상태 + 지시문 + 넣을 파일)이면 같은 출력이 나온다고 가정하고, 이전 결과를 재사용합니다.

두 번째 가정이 늘 참은 아니라는 점이 이 글 후반의 핵심입니다.

■ 내부 동작 원리 1 — 레이어는 "변경분 tar" 입니다

OCI 이미지 스펙(layer.md)은 레이어를 "파일 시스템과, 삭제된 파일 같은 변경 사항을 레이어라는 blob 으로 직렬화한 것"으로 정의합니다. 핵심은 변경분이라는 점입니다. 두 번째 레이어는 전체 파일 트리가 아니라 첫 레이어 대비 추가·수정·삭제된 것만 담습니다.

추가와 수정은 쉽습니다. 그 파일을 tar 에 넣으면 됩니다. 삭제는 tar 로 표현할 방법이 없습니다. 그래서 스펙은 whiteout 이라는 표식을 씁니다. 지워진 경로의 이름 앞에 .wh. 를 붙인 빈 파일입니다. /big.bin 을 지웠다면 그 레이어 tar 에는 .wh.big.bin 이라는 0바이트 파일이 들어갑니다. 디렉터리 안을 통째로 비울 때는 .wh..wh..opq 라는 opaque whiteout 을 둡니다. 스펙은 whiteout 이 "아래(부모) 레이어의 자원에만 적용된다"고 못 박습니다.

이미지를 풀 때는 레이어를 순서대로 덮어씁니다. 아래 레이어부터 풀고, 위 레이어의 파일로 덮고, whiteout 을 만나면 아래 레이어의 해당 경로를 지웁니다. 결과적으로 최종 파일 트리가 나옵니다.

▶ 실행할 때는 풀지 않고 겹칩니다

docker run 마다 레이어를 전부 복사해 하나의 디렉터리로 만들면 느립니다. 그래서 리눅스 도커가 주로 쓰는 스토리지 드라이버 overlay2 는 커널의 OverlayFS 로 레이어를 겹쳐 보이게 합니다. 흐름은 이렇습니다.

1. 이미지의 각 레이어는 /var/lib/docker/overlay2/<id>/diff 에 이미 풀려 있습니다. 이것들이 읽기 전용 lowerdir 이 됩니다.
2. 컨테이너를 만들 때 빈 디렉터리 하나를 upperdir 로 붙입니다. 이것이 컨테이너 쓰기 레이어입니다.
3. 커널이 둘을 합친 merged 뷰를 만들고, 이걸 컨테이너의 / 로 씁니다. 같은 경로가 여러 층에 있으면 위층이 이깁니다.
4. 프로세스가 아래층 파일을 처음 수정하면 OverlayFS 가 copy_up 을 합니다. 도커 문서 표현으로 "copy_up 은 파일 전체를 복사"합니다. 1바이트를 고쳐도 1GB 파일이면 1GB 가 위층으로 복사됩니다. 그 뒤의 쓰기는 위층 사본에 갑니다.
5. 프로세스가 아래층 파일을 지우면 위층에 whiteout 이 생기고, 아래층 원본은 그대로 남되 가려집니다.
6. 컨테이너를 지우면 upperdir 만 사라집니다. 이미지 레이어는 다른 컨테이너와 계속 공유됩니다.

overlay2 디렉터리 안에는 l 이라는 폴더가 있고, 레이어별 짧은 이름의 심볼릭 링크가 들어 있습니다. 문서에 따르면 mount 인자 길이가 페이지 크기 한도를 넘지 않게 하려는 장치입니다. 레이어가 많아지면 lowerdir=a:b:c:... 문자열이 길어지기 때문입니다.

■ 내부 동작 원리 2 — 한 레이어에 해시가 세 개

레이어를 이름으로 찾으려면 해시가 필요합니다. 그런데 OCI 스펙에는 레이어를 가리키는 해시가 세 종류 있습니다. 헷갈리기 쉬우니 따로 짚습니다.


- digest: 레지스트리에 올라가는 압축된 blob 의 해시입니다. 매니페스트의 layers[] 에 적힙니다. docker pull 은 이 목록을 보고 로컬에 없는 digest 만 내려받습니다. 압축 형식은 application/vnd.oci.image.layer.v1.tar+gzip, ...tar+zstd 처럼 미디어 타입으로 구분합니다.
- DiffID: 스펙 정의로 "레이어의 압축하지 않은 tar 아카이브에 대한 digest" 입니다. 이미지 설정 JSON 의 rootfs.diff_ids 에 순서대로 들어 있습니다. 압축 방식이 달라도 내용이 같으면 DiffID 는 같습니다.
- ChainID: "여기까지 쌓은 결과"의 이름입니다. 스펙의 재귀식은 ChainID(L₀|…|Lₙ) = Digest(ChainID(L₀|…|Lₙ₋₁) + " " + DiffID(Lₙ)) 입니다. 같은 변경분이라도 아래에 깔린 레이어가 다르면 결과 파일 트리가 다르니 ChainID 도 달라집니다. 그래서 "풀어 둔 파일 트리"를 재사용할지 판단하는 기준으로는 DiffID 하나가 아니라 ChainID 가 맞습니다.

마지막으로 이미지 ID 는 스펙상 "설정 JSON 의 SHA256" 입니다. 설정 JSON 이 diff_ids 를 담고 있으니, 레이어 하나만 달라져도 이미지 ID 가 바뀝니다.

■ 내부 동작 원리 3 — 빌드 캐시는 이렇게 맞춰 봅니다

Docker Engine 23.0 부터 리눅스에서도 BuildKit 이 기본 빌더입니다(릴리스 노트: "Set Buildx and BuildKit as the default builder on Linux"). BuildKit 은 Dockerfile 을 LLB 라는 중간 표현으로 바꿉니다. 공식 문서 표현으로 LLB 는 "내용 주소 기반(content-addressable) 의존성 그래프" 입니다. 그래프의 각 노드는 "이 부모 상태에 이 연산을 적용한 결과"이고, 노드마다 캐시 키가 붙습니다.

캐시를 맞춰 보는 순서는 다음과 같습니다.

1. 기반 이미지부터 확인합니다. FROM 이 가리키는 이미지가 캐시에 있는지 봅니다.
2. 다음 지시문의 키를 만듭니다. 키는 부모 노드의 키와 지시문 자체로 정해집니다.
3. 지시문 종류별로 입력을 더합니다.
  - COPY·ADD: 복사할 파일들의 메타데이터로 체크섬을 계산해 키에 넣습니다. 문서는 "mtime 은 체크섬 계산에 들어가지 않는다"고 명시합니다.
  - RUN 등 나머지: 문서 표현으로 "명령 문자열 자체만" 비교합니다. 컨테이너 안 파일이나 바깥 네트워크 상태는 보지 않습니다.
  - ARG: 빌드 인자 값은 캐시 무효화를 일으킵니다. 반면 빌드 시크릿의 내용은 캐시에 들어가지 않습니다. 시크릿 값을 바꿔도 캐시는 그대로입니다(ID·마운트 경로는 키에 들어갑니다).
4. 맞는 키가 있으면 CACHED, 없으면 실행합니다. 실행한 순간 그 노드의 결과가 달라지므로, 그 아래 모든 노드의 부모 키도 달라집니다. 문서 표현으로 "캐시가 무효화되면 이후의 모든 Dockerfile 명령은 새 이미지를 만들고 캐시를 쓰지 않습니다."



이 연쇄가 "자주 바뀌는 것을 아래로" 규칙의 근거입니다. 의존성 목록 파일만 먼저 COPY 하고 설치를 돌린 다음 소스 전체를 COPY 하면, 소스가 바뀌어도 설치 단계의 키는 그대로입니다.


■ 직접 해 보기

아래 실험은 Docker 28.3.3, buildx 0.27.0(Docker Desktop, arm64)에서 직접 돌린 결과입니다. 빈 디렉터리에 파일 세 개를 만듭니다.

[코드: dockerfile]
# Dockerfile
FROM alpine:3.20
WORKDIR /app
COPY deps.txt .
RUN echo "installing $(cat deps.txt)" && sleep 3 && echo done > /installed
COPY app.sh .
CMD ["sh", "app.sh"]
[/코드]

[코드: bash]
echo "libfoo" > deps.txt
echo 'echo hello' > app.sh
docker build --progress=plain -t layerlab .
[/코드]

RUN 의 sleep 3 은 "비싼 의존성 설치"를 흉내 냅니다. 이제 세 가지를 바꿔 가며 다시 빌드합니다.

[코드: bash]
# 1) 소스만 수정
echo 'echo hello v2' > app.sh
docker build --progress=plain -t layerlab . 2>&1 | grep -E '^#[0-9]+ (\[|CACHED)'

# 2) 내용은 그대로, 수정 시각만 갱신
touch deps.txt
docker build --progress=plain -t layerlab . 2>&1 | grep -E '^#[0-9]+ (\[|CACHED)'

# 3) 의존성 목록 내용 수정
echo "libbar" > deps.txt
docker build --progress=plain -t layerlab . 2>&1 | grep -E '^#[0-9]+ (\[|CACHED|DONE)'
[/코드]

결과를 표로 옮기면 이렇습니다.

바꾼 것 | WORKDIR | COPY deps.txt | RUN (3초) | COPY app.sh
------------------------------------------------------------
app.sh 내용 | CACHED | CACHED | CACHED | 실행
deps.txt 를 touch 만 | CACHED | CACHED | CACHED | CACHED
deps.txt 내용 | CACHED | 실행 | 실행(3.2초) | 실행

세 번째 줄의 COPY app.sh 는 app.sh 가 그대로인데도 다시 실행됐습니다. 부모 키가 바뀌었기 때문입니다. 두 번째 줄은 mtime 이 체크섬에 안 들어간다는 문서 설명과 맞습니다.

▶ rm 해도 줄지 않는 이미지 확인

[코드: dockerfile]
# Dockerfile.rm
FROM alpine:3.20
RUN head -c 20000000 /dev/urandom > /big.bin
RUN rm /big.bin
[/코드]

[코드: bash]
docker build -q -f Dockerfile.rm -t rmlab .
docker history rmlab --format '{{.Size}}\t{{.CreatedBy}}'
[/코드]

[코드: text]
4.1kB RUN /bin/sh -c rm /big.bin # buildkit
20MB RUN /bin/sh -c head -c 20000000 /dev/urandom…
0B CMD ["/bin/sh"]
9.49MB ADD alpine-minirootfs-3.20.10-aarch64.tar.gz…
[/코드]

rm 레이어는 4.1kB 지만, 그 아래 20MB 레이어는 그대로 이미지에 남습니다. 레이어 tar 를 직접 열어 whiteout 을 확인할 수도 있습니다.

[코드: bash]
mkdir out && docker save rmlab -o out/rm.tar && tar -xf out/rm.tar -C out
for f in out/blobs/sha256/*; do
  tar -tvf "$f" 2>/dev/null | grep -q '\.wh\.' && tar -tvf "$f"
done
[/코드]

[코드: text]
----------  0 0      0           0 Jan  1  1970 .wh.big.bin
[/코드]

스펙 그대로, 0바이트짜리 .wh.big.bin 하나가 삭제를 표현합니다. 같은 out/blobs 안에는 big.bin 20MB 를 담은 이전 레이어도 함께 들어 있습니다.



■ 캐시를 더 오래 살리는 장치들

레이어 캐시는 "전부 맞거나 전부 다시"입니다. 의존성 목록이 한 줄만 바뀌어도 설치 단계 전체가 처음부터 돕니다. 이 빈틈을 메우는 장치가 몇 가지 있습니다.

[코드: dockerfile]
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
CMD ["node", "index.js"]
[/코드]

--mount=type=cache 는 레이어가 아닌 별도 디렉터리를 RUN 동안만 붙입니다. 레퍼런스 문서는 "캐시 디렉터리의 내용은 빌더 실행 사이에 유지되며, 지시문 캐시를 무효화하지 않는다"고 설명합니다. package-lock.json 이 바뀌어 npm ci 가 다시 돌더라도, 이미 받아 둔 패키지는 /root/.npm 에서 꺼내 씁니다. 이 디렉터리는 최종 이미지에 들어가지 않습니다.

장치 | 어디에 저장 | 무효화 기준 | 최종 이미지에 포함
------------------------------------------------------------
레이어 캐시 | 빌더 로컬 | 부모 키 + 지시문 + 파일 체크섬 | 예(그 레이어)
RUN --mount=type=cache | 빌더 로컬, id 별 디렉터리 | 무효화 없음, 누적 | 아니오
RUN --mount=type=bind | 빌드 컨텍스트 | 마운트한 파일이 키에 반영 | 아니오(읽기 전용 기본)
--cache-to type=registry | 레지스트리의 별도 이미지 | 레이어 캐시와 같음 | 아니오
--cache-to type=inline | 결과 이미지 안 | 레이어 캐시와 같음 | 캐시 메타데이터만

CI 처럼 매번 새 머신에서 빌드하면 로컬 캐시가 비어 있습니다. 이때 외부 캐시 백엔드(registry, local, gha 등)로 캐시를 내보내고 다음 빌드에서 가져옵니다. 캐시 모드는 둘입니다. 문서에 따르면 기본값 min 은 "결과 이미지로 내보내지는 레이어만" 캐시하고, max 는 "중간 단계까지 모든 레이어"를 캐시합니다. 멀티 스테이지 빌드에서 빌드 스테이지를 재사용하고 싶다면 max 가 필요합니다. inline 백엔드는 mode 옵션 자체를 지원하지 않습니다.

■ 흔한 오해와 실수

▶ "RUN apt-get update 는 매번 최신 목록을 받는다"

아닙니다. RUN 의 캐시 키는 명령 문자열뿐입니다. 문서도 "RUN 캐시는 빌드 사이에 자동으로 무효화되지 않는다"고 적습니다. 어제 빌드한 RUN apt-get update 레이어가 캐시에 있으면 오늘도 그 결과를 씁니다. 더 나쁜 경우는 update 와 install 을 다른 RUN 으로 나눴을 때입니다. 설치할 패키지를 추가하면 install 줄만 다시 돌고, update 는 오래된 캐시를 그대로 씁니다. 그래서 둘을 한 RUN 에 묶으라는 관행이 생겼습니다. 정말 새로 받아야 하면 --no-cache 나 --no-cache-filter 를 씁니다.

▶ "나중 레이어에서 rm 하면 이미지가 줄어든다"

위 실험처럼 whiteout 이 추가될 뿐입니다. 도커 문서도 지워진 디렉터리가 "이전 레이어에 여전히 남아 이미지 전체 크기에 더해진다"고 설명합니다. 줄이려면 같은 RUN 안에서 만들고 지우거나, 멀티 스테이지 빌드로 필요한 결과물만 다음 스테이지에 COPY 합니다. 같은 이유로 비밀 값을 COPY 했다가 다음 줄에서 지워도, 이전 레이어 tar 에는 그대로 남습니다. 비밀은 --mount=type=secret 으로 넘깁니다.

▶ "git pull 만 했는데 COPY 캐시가 깨진다"

mtime 은 키에 안 들어가니 git pull 자체는 원인이 아닙니다. 대개는 COPY . . 가 .git 디렉터리나 빌드 산출물, 로그까지 컨텍스트에 실어 보내는 것이 원인입니다. 이 파일들이 바뀌면 체크섬이 바뀝니다. .dockerignore 로 빼면 됩니다. 문서는 .dockerignore 규칙이 하위 디렉터리를 포함한 빌드 컨텍스트 전체에 적용된다고 설명합니다.

▶ "SOURCE_DATE_EPOCH 를 커밋 시각으로 두면 재현성이 좋아진다"

재현 가능한 빌드에는 도움이 되지만 캐시에는 독입니다. 문서에 따르면 SOURCE_DATE_EPOCH 를 바꾸면 WORKDIR 과 그 이후 지시문 캐시가 깨집니다. 커밋마다 값이 바뀌면 커밋마다 캐시가 깨집니다.

▶ "컨테이너 안에서 큰 파일을 조금 고치는 건 싸다"

copy_up 은 파일 전체를 복사합니다. 컨테이너 안에서 큰 DB 파일이나 로그 파일을 계속 고치면 첫 쓰기마다 통째 복사가 일어나고, 쓰기 레이어가 불어납니다. 도커 문서는 쓰기가 많은 작업에는 스토리지 드라이버의 copy-on-write 를 거치지 않는 볼륨을 권합니다.

■ 정리

- 이미지 레이어는 변경분 tar 이고, 삭제는 .wh. whiteout 으로 표현하므로 나중에 지워도 크기는 줄지 않습니다.
- 실행 시에는 OverlayFS 가 읽기 전용 레이어 위에 쓰기 레이어를 겹치고, 첫 수정 때 파일을 통째로 위로 복사합니다.
- 빌드 캐시 키는 "부모 키 + 지시문 + (COPY 파일 체크섬)"이라 한 단계가 빗나가면 아래가 전부 다시 돌고, RUN 은 명령 문자열만 봅니다.

■ 참고 자료

- Docker Docs — Docker build cache(https://docs.docker.com/build/cache/)
- Docker Docs — Build cache invalidation(https://docs.docker.com/build/cache/invalidation/)
- Docker Docs — Optimize cache usage in builds(https://docs.docker.com/build/cache/optimize/)
- Docker Docs — Cache storage backends(https://docs.docker.com/build/cache/backends/)
- Docker Docs — BuildKit(https://docs.docker.com/build/buildkit/)
- Docker Docs — Dockerfile reference (RUN --mount)(https://docs.docker.com/reference/dockerfile/)
- Docker Docs — Storage drivers(https://docs.docker.com/engine/storage/drivers/)
- Docker Docs — OverlayFS storage driver(https://docs.docker.com/engine/storage/drivers/overlayfs-driver/)
- Docker Engine 23.0 release notes(https://docs.docker.com/engine/release-notes/23.0/)
- OCI Image Spec — Image Layer Filesystem Changeset(https://github.com/opencontainers/image-spec/blob/main/layer.md)
- OCI Image Spec — Image Configuration(https://github.com/opencontainers/image-spec/blob/main/config.md)

반응형