8장. 이미지는 켜켜이 쌓인다 — 레이어·OverlayFS·copy-on-write
컨테이너 둘이 같은 디렉터리 하나를 밑에 깔고 돌고 있었습니다. 한쪽에서 파일을 고쳤는데 옆은 안 바뀌었고요. 고친 내용이 원본이 아니라 그 위에 얹힌 다른 층에 쓰였기 때문입니다.
{/* 삽화 자리: 투명 셀로판 여러 장이 겹쳐져 하나의 그림처럼 보인다. 아래 장들은 여러 사람이 공유하고 있고, 맨 위 한 장만 사람마다 따로다. 한 사람이 맨 위 장에 덧그리자 그 사람 눈에만 그림이 바뀌고, 옆 사람이 보는 그림은 그대로. 아래 장에는 펜이 닿지 않는다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
7장 끝에서 물었습니다. 4장 실측에서 두 이미지가 레이어 아홉 겹 중 여덟 겹을 공유했는데, 한쪽이 그 공유 파일을 고치면 옆도 같이 바뀌느냐고요.
같은 질문이 6장에도 걸려 있습니다. unshare로 선을 다 그어도 루트 파일 시스템은 여전히 바깥 것이었죠. 컨테이너가 자기만의 /를 갖는 건 그 위에 뭔가를 더 얹기 때문인데, 그 “뭔가”가 이 장의 주제입니다.
그리고 1장이 남긴 빚도 여기서 갚습니다. chroot 안이 정말 비어 있어서 동적 로더까지 손수 복사해야 했던 그 장면이요. 컨테이너 이미지는 그 복사를 미리 해둔 결과물이고, 미리 해둔 걸 여러 컨테이너가 나눠 쓰는 방법이 레이어입니다.
스토리
같은 이미지로 컨테이너 둘을 띄우고, 각자 무엇을 루트로 쓰는지 물어봤습니다.
docker run -d --name ch08a alpine:3.20 sleep 300
docker run -d --name ch08b alpine:3.20 sleep 300
docker inspect ch08a --format '{{index .GraphDriver.Data "LowerDir"}}'직접 치면 이렇게 나옵니다.
/var/lib/docker/overlay2/5ff5a4f2dd…-init/diff:/var/lib/docker/overlay2/5aa9740b1d73…/diff콜론으로 이어진 목록이고, 맨 앞 -init이 붙은 한 겹은 컨테이너마다 따로입니다. Docker가 /etc/hosts·/etc/resolv.conf·/etc/hostname·/.dockerenv처럼 컨테이너마다 달라야 하는 몇 개만 담아 끼워 넣는 층이에요. 이미지 층은 그 뒤에 옵니다. 여기서 비교할 건 뒤쪽입니다.
그 뒤쪽만 떼어내 둘을 비교하면 이렇습니다.
ch08a 의 이미지 층: /var/lib/docker/overlay2/5aa9740b1d73…/diff
ch08b 의 이미지 층: /var/lib/docker/overlay2/5aa9740b1d73…/diff
→ 같은 디렉터리 하나를 둘이 같이 본다
각자 따로 갖는 쓰기 층(UpperDir):
ch08a: /var/lib/docker/overlay2/01291b2135…/diff
ch08b: /var/lib/docker/overlay2/a804a879c3…/diff✅ 실측 (Docker Engine 29.7.2 / Docker Desktop 4.87.0 / alpine 3.20 / Apple M1 / macOS 26.6, 2026-09. 해시는 따라 하면 다르게 나옵니다. 전체 소스: docs/book/code/container/ch08/layers.sh)
맥에서는 이 경로가 Docker Desktop의 리눅스 VM 안에 있어 ls가 안 됩니다. 직접 보려면 docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- ls /var/lib/docker/overlay2로 들어가세요. 리눅스 서버라면 그냥 sudo ls입니다.
두 이름이 나옵니다. LowerDir과 UpperDir이요.
아래층은 같은 디렉터리입니다. 두 컨테이너가 알파인 파일들을 한 벌만 두고 함께 보고 있어요. 4장에서 두 번째 이미지가 2바이트만 더 썼던 이유가 이겁니다.
위층은 각자 다릅니다. 컨테이너마다 자기 것을 하나씩 갖고 있어요.
그럼 아래층은 읽기 전용이고 쓰기는 위층으로 간다는 뜻일 텐데, 확인해봤습니다. 한쪽에서만 파일을 고쳐보는 거죠.
docker exec ch08a sh -c 'echo "A가 고쳤다" > /etc/os-release' ch08a 가 보는 값: A가 고쳤다
ch08b 가 보는 값: NAME="Alpine Linux" ← 안 바뀌었다
새 컨테이너가 보는 값: NAME="Alpine Linux"✅ 실측 (같은 환경, 같은 소스)
7장의 질문에 답이 나왔습니다. 옆은 안 바뀝니다.
같은 파일을 가리키고 있었는데도요. 그 일이 어떻게 가능한지가 이 장의 나머지입니다.
핵심
OverlayFS를 손으로 만들어 봅니다
Docker가 쓰는 드라이버 이름은 overlay2고(경로에 그렇게 보였죠), 그 밑에서 쓰는 커널 파일시스템이 OverlayFS입니다. 이름 그대로 디렉터리를 겹쳐(overlay) 하나처럼 보여줘요.
직접 만들어보면 구조가 한눈에 들어옵니다. 필요한 디렉터리는 넷입니다.
docker run --rm -it --privileged alpine:3.20 sh # 여기 안에서 아래를 칩니다
mkdir -p /ov && mount -t tmpfs tmpfs /ov
mkdir -p /ov/lower /ov/upper /ov/work /ov/merged
echo "베이스 이미지의 파일" > /ov/lower/shared.txt
mount -t overlay overlay \
-o lowerdir=/ov/lower,upperdir=/ov/upper,workdir=/ov/work /ov/mergedlower가 읽기 전용 아래층, upper가 쓰기 가능한 위층, merged가 둘을 겹쳐 보여주는 자리입니다. work는 copy-on-write 중간 상태를 두는 자리예요. 커널이 lower의 파일을 여기에 먼저 만들고 다 되면 upper로 옮깁니다. 그래서 upper와 같은 파일시스템에 있어야 하고 비어 있어야 해요.
(/ov를 tmpfs로 먼저 만든 이유가 있습니다. upperdir과 workdir은 OverlayFS 위에 있으면 안 되는데 컨테이너의 루트가 이미 OverlayFS거든요. 그냥 하면 Invalid argument가 나고, 커널이 dmesg에 이유까지 적어줍니다. overlay: filesystem on /ov2/upper not supported as upperdir.)
붙이고 나서 읽어봤습니다.
lower 에 파일 하나를 두고 merged 로 겹쳤다
merged 에서 읽기 : 베이스 이미지의 파일
upper 에 있는 것 : 0개 (읽기만 했으니 비어 있다)✅ 실측 (같은 환경, 같은 소스)
merged에서 읽으면 lower의 내용이 나옵니다. upper는 비어 있고요. 읽기만 할 때는 아무것도 복사되지 않습니다.
쓰면 그때 복사됩니다
이제 merged에서 그 파일을 고쳐봤습니다.
merged 에서 고쳐 쓴다
merged : 고친 내용
lower : 베이스 이미지의 파일 ← 원본은 그대로
upper : 고친 내용 ← 여기에 복사돼 올라왔다✅ 실측 (같은 환경, 같은 소스)
세 줄이 이 장의 전부입니다.
고치는 순간 커널이 그 파일을 lower에서 upper로 복사한 다음, 복사본에 씁니다. 원본은 손대지 않아요. 그 뒤로 merged에서 읽으면 upper 쪽이 이깁니다.
이 동작의 이름이 copy-on-write입니다. 쓸 때 복사한다는 뜻이고, 읽기만 하면 복사 안 합니다.
그럼 얼마나 복사할까요. 부분 수정이란 건 없습니다.
20MB 파일에 1바이트만 고쳐 쓴다
upper (쓰기 전): 0
upper (쓰기 후): 20.0M (20971520 바이트)
lower 원본 : 20971520 바이트 (그대로)✅ 실측 (같은 환경, 같은 소스)
20MB 파일의 1바이트를 고쳤는데 20MB가 통째로 올라왔습니다.
그래서 컨테이너 안에서 큰 파일을 고치는 일은 생각보다 비쌉니다. DB 데이터 파일을 컨테이너 레이어에 두면 안 되는 이유가 이거고(10장), 빌드 중에 큰 파일을 건드리면 레이어가 그만큼 불어나는 이유도 이겁니다(11장).
앞의 두 컨테이너 이야기가 여기서 설명됩니다. ch08a가 고친 내용은 ch08a의 upper에만 쓰였어요. ch08b의 upper는 비어 있으니 ch08b는 여전히 lower의 원본을 봅니다.
그리고 제목의 “켜켜이”도 여기서 확인됩니다. lowerdir은 콜론으로 여러 겹을 받아요.
lowerdir=/ov/l2:/ov/l1 → 위층 (왼쪽이 위)✅ 실측 (같은 환경, 같은 소스. 같은 이름의 파일을 두 층에 두고 겹쳤습니다)
왼쪽이 위입니다. 같은 이름이 여러 층에 있으면 위층이 이겨요. Dockerfile의 아래 줄이 윗줄을 덮는 것도 이 순서 때문입니다(11장).
지우면 어떻게 될까요
읽기 전용 아래층에 있는 파일을 지우라고 하면 난감합니다. 아래층은 못 건드리는데요.
merged 에서 지운다
merged : (없음)
lower : 베이스 이미지의 파일 ← 여전히 있다
upper : c--------- 0, 0
(지운 게 아니라 '지워졌음' 표시를 올려둔 것)✅ 실측 (같은 환경, 같은 소스)
upper에 이상한 게 생겼습니다. c---------는 문자 장치 파일이고 주·부 번호가 0, 0이에요.
이게 whiteout입니다. “이 이름은 지워진 것으로 취급하라”는 표시고, OverlayFS가 이걸 보면 아래층에 같은 이름이 있어도 없는 척합니다.
이건 커널 OverlayFS가 디스크에 쓰는 표현이고, 이미지 레이어 안에서는 모양이 다릅니다. OCI 이미지 규격은 whiteout을 문자 장치가 아니라 .wh. 접두가 붙은 일반 파일로 담으라고 정해뒀어요. 지운 게 shared.txt면 .wh.shared.txt라는 빈 파일이 들어갑니다(📄 문서 기반 — OCI image-spec, layer.md ).
표현은 둘로 갈리지만 뜻은 같습니다. 그리고 어느 쪽이든 아래층은 안 지워져요.
그러니까 이미지에서 파일을 지워도 이미지가 작아지지 않습니다. 아래층엔 그대로 있고 위층에 표시만 하나 더 생기니까요. 그래서 이미지는 커지기만 합니다. 큰 파일을 넣었다가 다음 줄에서 지우는 Dockerfile이 왜 헛수고인지가 여기서 나옵니다. 그 이야기는 11장입니다.
컨테이너를 지우면 위층이 사라집니다
ch08a의 UpperDir 경로를 적어두고, 컨테이너를 지운 뒤 그 디렉터리가 남아 있는지 봤습니다.
=== 3. 컨테이너를 지우면 쓰기 층은 어떻게 되나 ===
삭제 전 UpperDir : /var/lib/docker/overlay2/a4aa002233…/diff
삭제 후 UpperDir : ls: cannot access '…': No such file or directory
같은 이미지 층은 : /var/lib/docker/overlay2/5aa9740b1d73…/diff ← 그대로✅ 실측 (같은 환경, 같은 소스)
세 줄에 다 나와 있습니다. 위층 디렉터리는 없어졌고 아래층은 그대로예요.
컨테이너의 쓰기 층은 그 컨테이너의 수명과 함께 갑니다. 아래층은 이미지 것이라 남고요.
“컨테이너는 일회용”이라는 말이 이 구조에서 나옵니다. 지우면 사라지는 게 정상이에요. 사라지면 안 되는 것을 어디에 둬야 하는지가 10장입니다.
그래서 이미지란 무엇인가
정리하면 이렇습니다.
| 무엇인가 | 누가 쓰나 | 컨테이너를 지우면 | |
|---|---|---|---|
| lower (이미지 레이어) | 읽기 전용, 여러 겹 | 같은 이미지를 쓰는 모두가 공유 | 남는다 |
| upper (컨테이너 레이어) | 쓰기 가능, 한 겹 | 그 컨테이너 혼자 | 사라진다 |
| merged | 둘을 겹쳐 보여주는 화면 | 컨테이너 안에서 보이는 / | — |
4장에서 이미지를 “포장”이라고 불렀습니다. 이제 그 포장의 속을 봤어요. 이미지는 통짜 디스크 이미지가 아니라 읽기 전용 디렉터리 여러 겹이고, 컨테이너는 그 위에 빈 디렉터리 한 겹을 얹은 것입니다.
6장에서 마운트 namespace를 만들었죠. 컨테이너가 자기만의 /를 갖는 건 이 merged를 자기 마운트 namespace에 붙여놓기 때문입니다. 두 장이 여기서 만납니다.
3장에서 남긴 이야기도 여기서 닫습니다. Docker가 초기에 쓴 union 파일시스템은 AUFS였는데 메인라인 커널에 들어간 적이 없습니다. OverlayFS가 커널 3.18에 들어간 건 2014년 12월이고, Docker 공개(2013년 3월)보다 1년 반 넘게 늦었어요.
그럼 2013년 Docker는 뭘로 돌았을까요. AUFS가 우분투 커널에 패치로 실려 있었습니다. 대신 RHEL에는 없었고, 그래서 Docker는 얼마 지나지 않아 devicemapper라는 두 번째 스토리지 드라이버를 따로 붙여야 했어요. 메인라인에 없다는 것의 대가가 이겁니다. 배포판마다 다른 드라이버요.
그러니까 “2013년에 커널이 준비돼 있었다”는 말에는 이 조각이 빠져 있습니다. 격리는 준비돼 있었지만 포장을 받쳐줄 파일시스템은 아직이었습니다.
정리
- 같은 이미지를 쓰는 컨테이너들은 이미지 레이어 디렉터리를 공유합니다. 실측에서 컨테이너 둘의
LowerDir이 같은 경로였고UpperDir만 달랐습니다. - OverlayFS는
lower(읽기 전용)와upper(쓰기 가능)를 겹쳐merged하나로 보여줍니다. - 읽기만 하면 아무것도 복사되지 않습니다. 고치는 순간
lower에서upper로 복사하고 복사본에 씁니다. 이게 copy-on-write예요. 부분 복사는 없습니다. 실측에서 20MB 파일의 1바이트를 고치자 20MB가 통째로 올라왔습니다. - 그래서 한 컨테이너가 파일을 고쳐도 옆 컨테이너는 원본을 봅니다. 7장의 질문에 대한 답입니다.
- 지우는 것도 진짜로 지우는 게 아니라 whiteout을 올려 “없는 척”하게 만듭니다. 런타임에서는 문자 장치
0, 0, 이미지 레이어 안에서는.wh.접두가 붙은 빈 파일이에요. 그래서 이미지에서 파일을 지워도 이미지는 안 작아집니다(11장). - 컨테이너를 지우면
UpperDir디렉터리가 실제로 사라지고 이미지 층은 남습니다. “컨테이너는 일회용”이 여기서 나옵니다(10장). - Docker가 초기에 쓴 AUFS는 메인라인에 없었습니다. 우분투 커널에만 패치로 있었고, RHEL을 위해 devicemapper를 따로 붙여야 했어요. OverlayFS는 2014년 12월 커널 3.18에 들어왔습니다.
생각해볼 질문
이제 컨테이너의 /가 어디서 오는지 알았습니다. 6장에서 선을 긋고, 여기서 그 안에 파일시스템을 넣었어요.
남은 게 하나 있습니다. 6장에서 네트워크 namespace를 만들었더니 eth0이 사라졌죠. 끊기기만 하고 이어지지는 않았습니다.
그런데 실제 컨테이너에는 eth0이 있습니다. 3장 실측에서 172.17.0.2라는 주소를 갖고 있었고, --network host로 봤을 때 호스트에 docker0=172.17.0.1이라는 인터페이스가 있었어요.
둘 사이에 무엇이 놓여 있는 걸까요. 그리고 -p 8080:8080이라고 쓰면 정확히 무슨 일이 일어나는 걸까요.