Lobsters

The hidden design compromises of Docker layers

Docker 레이어에 숨은 설계상의 타협

Docker 이미지 레이어는 전체 파일시스템이 아니라 순서대로 적용하는 변경 집합입니다. OCI는 파일 삭제를 `.wh.*`라는 특수 이름으로 표현하는데, 이 때문에 같은 이름의 일반 파일은 이미지에 온전히 담기지 않으며 빌드 직후와 이미지 내보내기·가져오기 뒤의 결과가 달라질 수 있습니다.

AI 요약

Docker 레이어로 파일을 삭제하는 방식은 단순히 다음 레이어에서 파일을 빼는 것과 다릅니다. 각 레이어는 불변인 파일시스템 변경 집합이며, OCI 이미지 형식은 삭제를 tar 자체가 아니라 .wh.*라는 특수 파일 이름 규칙으로 표현합니다. 글은 이 설계가 가진 장점과 한계를 설명하고, Docker 이미지가 로컬 빌드 직후와 내보내기·가져오기 뒤에 다르게 동작하는 사례를 직접 실험합니다.

레이어는 완성된 파일시스템이 아니라 변경 집합입니다

Dockerfile의 모든 명령이 이미지 레이어를 만들지는 않습니다. RUN, COPY, ADD처럼 파일시스템을 바꾸는 명령의 결과가 레이어에 담기며, ENV, CMD, EXPOSE, LABEL은 이미지 설정을 바꿉니다. 이미지에는 매니페스트, 이미지 설정, 순서가 있는 파일시스템 레이어가 들어갑니다. 컨테이너는 각 레이어를 차례로 적용한 결과를 봅니다.

OCI 이미지 사양에서 레이어는 파일시스템 변경 집합입니다. 배포할 때는 보통 gzip이나 zstd로 압축한 tar 형식으로 직렬화하지만, Docker가 실행할 때마다 tar 파일을 풀어 쓰는 것은 아닙니다. 저장 드라이버는 레이어를 디렉터리로 보관하고 유니언 파일시스템으로 결합할 수 있습니다. 따라서 레이어의 배포용 tar 표현과 로컬 저장소의 표현을 구분해야 합니다.

파일 추가와 변경은 tar에 경로를 기록하면 됩니다. 기존 파일을 바꿀 때도 새 레이어에 새 파일 전체를 담습니다. 한 글자만 달라져도 바이너리 차분을 저장하지 않습니다. 이전 레이어의 파일은 불변이므로 그대로 남습니다. 파일을 다음 RUN에서 지워도 이전 레이어의 용량은 줄지 않습니다. 임시 파일이나 패키지 캐시를 이미지에 남기지 않으려면 만든 명령과 같은 RUN에서 삭제해야 합니다. 같은 단계에서 만들고 지운 파일은 최종 변경 집합에 없으므로 레이어에도 들어가지 않습니다.

삭제를 나타내는 whiteout

tar에는 앞선 아카이브의 파일을 삭제하는 일반 기능이 없습니다. OCI는 삭제할 파일이 foo라면 새 레이어에 .wh.foo라는 항목을 넣습니다. 이 항목은 tar 관점에서는 평범한 파일이지만, OCI 레이어를 적용하는 프로그램은 이름을 보고 앞선 레이어의 foo를 지웁니다. whiteout 자체도 최종 파일시스템에서는 보이지 않습니다. 따라서 레이어 tar를 단순히 tar -xf로 풀어서는 레이어의 의미를 재현할 수 없습니다.

whiteout은 앞선 레이어에만 적용됩니다. 같은 레이어에 들어 있는 파일을 그 레이어의 whiteout이 지우지는 않습니다. 디렉터리는 .wh.<디렉터리>로 통째로 삭제할 수 있습니다. 디렉터리는 유지하면서 이전 레이어에서 물려받은 하위 항목만 모두 숨기려면 .wh..wh..opq를 씁니다. 불투명(opaque) 표시와 같은 레이어에 추가한 새 파일은 남고, 앞선 레이어의 자식 항목만 사라집니다.

`.wh.*` 이름이 만드는 표현 한계

Linux 파일시스템은 .wh.foo라는 파일 이름을 허용합니다. 하지만 OCI 레이어에서는 같은 이름이 foo를 삭제하라는 뜻으로 이미 쓰입니다. 일반 파일임을 표시하는 별도 플래그나 이스케이프 규칙이 없으므로, OCI 레이어는 이름이 .wh.로 시작하는 일반 파일을 구별해 담을 수 없습니다. 즉 Linux에서 유효한 경로라도 OCI 이미지 안에서는 일반 파일로 온전히 표현하지 못할 수 있습니다.

글쓴이는 foo, .wh.foo, .wh..wh..opq, .wh. 같은 파일을 만든 Dockerfile을 작성해 실험했습니다. Docker Engine 29.4.0과 여러 BuildKit 버전을 사용했고, 로컬 저장소는 overlay2이며 containerd 이미지 저장소는 비활성화했습니다. 빌드 직후 같은 머신에서 실행하면 .wh.foo도 일반 파일로 보입니다. BuildKit은 tarball 대신 파일시스템 스냅샷을 다루므로, 스냅샷 단계에서는 .wh. 접두사를 특별하게 취급하지 않기 때문입니다.

하지만 이미지를 내보내고 새로 가져와 레이어 tar를 다시 풀면 결과가 달라집니다. tar에는 .wh.foo가 일반 파일 항목으로 기록돼도 OCI 해석기는 이를 whiteout으로 봅니다. foo가 앞선 레이어에 있으면 삭제되고, .wh.foo도 최종 파일시스템에 나타나지 않습니다. 이미지 푸시 뒤 새 Docker 데몬에서 풀어도 같은 결과가 나왔습니다. 반대로 foo와 .wh.foo가 같은 레이어에 있으면 whiteout은 같은 레이어의 파일에 적용되지 않아 foo는 남지만 .wh.foo는 사라집니다.

불투명 whiteout도 같은 문제를 보입니다. 로컬 빌드에서는 마커가 일반 파일로 남지만, 레이어를 새로 풀면 이전 레이어의 디렉터리 자식 항목을 숨깁니다. 이름이 .wh.인 항목은 사양상 잘못된 whiteout입니다. 실험에서는 BuildKit 빌드 자체는 성공했지만, 이미지 가져오기 과정에서 BuildKit과 Docker가 오류를 냈습니다. 글은 이 규칙이 사양에 추가된 지 얼마 되지 않았다는 점도 덧붙입니다.

어디까지가 버그인가

글쓴이는 OCI 해석기가 사양대로 동작하므로 이를 OCI의 버그라고 보지는 않습니다. 별개의 문제는 BuildKit이 .wh.* 파일을 포함한 로컬 상태를 허용하면서도, 직렬화한 이미지가 다시 풀릴 때 의미가 달라질 수 있다는 점입니다. 실험 환경은 제한적이므로 다른 저장 드라이버나 containerd 이미지 저장소 설정에서도 로컬 동작이 같다고 단정하지 않습니다. 다만 직렬화된 OCI 레이어에서 .wh.*가 갖는 의미는 사양에 정해져 있습니다.

이 규칙은 Docker 초기의 AUFS 방식에서 이어졌습니다. 특수 이름을 쓰면 표준 tar 도구를 그대로 활용해 삭제를 표현할 수 있지만, 파일 이름 공간 일부를 예약합니다. PAX 헤더 같은 방식으로 이름 충돌을 피할 수도 있었겠지만, 어떤 방식을 택해도 tar에 없던 의미를 덧붙이는 규칙이 필요합니다. 글쓴이는 whiteout을 다소 해킹처럼 느끼면서도 단순하고 실용적인 선택이라고 평가합니다.

Lobsters 반응

  • @mcherm — 정말 아주 긴 글이네요. 두 문단이면 다룰 수 있었을 것 같습니다. 그래도 Docker 레이어의 흥미로운 구현 세부사항이었습니다.
    • @loige — 하하, 맞는 말입니다! 맨 위에 TL;DR을 두고, “정말 정말 지루한 분만 계속 읽으세요”라는 안내도 붙였어야 했을 것 같습니다.
    • @pronoiac — 약간의 맥락을 더한 ‘요약표’ 부분이면 그 역할을 할 수 있을 것 같습니다. 디렉터리 전체에 whiteout을 적용하는 내용은 빠진 것 같네요. seekable tar 파일과 지연 로딩도 여전히 궁금합니다.
    • @mcherm — 솔직히 그랬다면 정말 웃겼을 것 같습니다! 😆
  • @david_chisnall — @loige, 글을 써줘서 고맙습니다. 잘 쓰였고 유익하네요! 용어와 관련해 한 가지 덧붙이자면, whiteout은 새 레이어에서 이전 레이어의 항목을 지울 방법이 필요할 때 쓰는 병합 사전의 흔한 용어입니다. 파일시스템뿐 아니라 다른 종류의 병합 사전에서도 씁니다. 또 특정 문화권의 표현이 일반적인 기술 용어에 스며든 사례이기도 합니다. Wite-Out은 미국에서 파는 수정액 브랜드이고, 유럽에서는 Tipp-Ex가 그에 해당합니다. 만년필이나 타자기를 써야 했던 시절을 겪지 않은 분을 위해 설명하면, 펜이나 타자기에는 삭제 기능이 없었습니다. 지우려면 흰 액체를 덧칠했습니다. 타자기에서는 리본 앞에 끼우는 작은 흰색 잉크 패드로 검은 글자 위에 흰 글자를 찍어 ‘삭제’했습니다. 이 비유가 컴퓨터 분야에 들어왔고, 미국 상표명이 일반적인 철자로 바뀌었습니다. 이름 짓기는 어렵습니다.

로컬 저장 드라이버가 레이어를 디렉터리로 보관하고 유니언 파일시스템으로 쌓는다는 설명에는 덧붙일 점이 있습니다. 이런 추상화는 containerd와 OCI 표준화가 도입되면서 크게 바뀐 부분 중 하나였습니다. Docker의 파일시스템 추상화는 이런 방식으로 동작했지만, 지금은 둘 다 지원할 수도 있을 것 같습니다. 이후 구현은 유니언 추상화 대신 스냅샷터(snapshotter) 추상화로 모였습니다. 유니언 모델에서는 여러 디렉터리를 만든 뒤 합쳐 레이어를 구성합니다. 스냅샷터 모델에서는 디렉터리 트리를 만든 다음 변경 사항을 적용해 이름을 붙이고, 다시 변경 사항을 적용해 다른 이름을 붙입니다. 유니언 모델은 더 유연합니다. 스냅샷터 모델이 표현하는 것을 모두 나타낼 뿐 아니라, 조합 순서를 바꾸는 방식처럼 다른 것도 표현할 수 있습니다. 이론상 엄격한 순차 차분 대신 믹스인도 제공할 수 있습니다. 하지만 바람직한 구현 기반 중 상당수는 스냅샷을 지원하지 임의의 조합을 지원하지 않습니다. 복잡한 모델로 단순한 모델을 구현하기는 쉽습니다. 유니언 파일시스템으로 스냅샷 모델을 구현하기는 쉽지만, copy-on-write 파일시스템으로 유니언 모델을 구현하기는 어렵습니다.

기존 파일을 바꿀 때 새 레이어에 완전한 파일을 담는다는 설명은, 글 앞부분에서 언급한 스냅샷터와 배포 형식의 차이를 보여주는 사례이기도 합니다. 유니언 파일시스템 드라이버를 쓰면 서로 다른 파일 버전이 두 디렉터리에 각각 있습니다. ZFS 스냅샷터를 쓰면 두 번째 버전을 첫 번째 위에 풀어놓습니다. 변경량이 작을 때 별도 복사본이 생기는지는 데이터셋에서 중복 제거를 켰는지에 따라 달라질 수도 있습니다.

그리고 PAX는 tar가 추가 메타데이터를 표현하기 위해 마련한 공식적인 탈출구라는 점도 덧붙입니다. PAX는 약어가 아니라 라틴어로 평화를 뜻하는 말입니다. Unix 진영에서 tar와 cpio, 그리고 여러 변형이 갈라진 상황을 타협한 결과라서 붙은 이름입니다. 아이러니하게도 POSIX 2001은 현대적인 pax 형식을 정의했지만, GNU와 FreeBSD의 tar는 지원하고 pax 프로그램은 지원하지 않습니다. 합의는 이름 짓기보다도 어렵습니다.

.wh.로 시작하는 파일은 특수 whiteout 표시이므로 그런 이름의 파일이나 디렉터리를 가진 파일시스템을 만들 수 없다는 제한은 이상하게 들릴 수 있습니다. 하지만 OCI 컨테이너 이미지를 만들 때 고려해야 하는 파일시스템별 제한은 또 있습니다. Windows OCI 이미지에 COM1이라는 파일을 넣어 보세요. 어쩌면 그쪽이 더 이상할 수도 있습니다.

  • @icefox — 흠, 좋은 글입니다. 파일시스템의 ‘차분’을 표현하는 방법은 여러 번 다시 만들어졌는데, 이제는 바로 쓸 수 있는 표준 해법이 있어야 하지 않을까 싶습니다.
    • @loige — 맞습니다. 이번 글에서는 전반적인 설계 선택에 더 관심이 있었습니다. 가장 단순하면서도 충분히 효과적인 조합을 택한 것 같네요!
  • @k749gtnc9l3w — 참고로 삭제를 표시할 때 특별한 이름 규칙을 따르는 파일을 쓰는 방식은 Docker 이전부터 여러 UnionFS 변형에서 사용했습니다.
    • @loige — 알려줘서 고맙습니다! Docker 레이어의 현재 설계에 영향을 줬다고 확신하는 이유 중 하나가 된 흥미로운 역사입니다. 조사하면서 이 사실을 알게 됐고, “왜 tar PAX 헤더를 쓰지 않았을까?”라는 생각을 계속했으니 글 어딘가에 언급했을 겁니다.
    • @k749gtnc9l3w — ‘영향을 줬다’는 게 무슨 뜻인가요? 글에 인용한 내용대로라면, 당시 유일한 커널 내 유니언 파일시스템을 쓰려면 그 방식을 택할 수밖에 없었던 것 아닌가요? 기반 레이어를 한 번 풀고 여러 컨테이너에서 쓰려면 그랬어야 했던 것 아닌가요?

원문: Lobsters / 번역·요약: Trawling