같은 이미지로 컨테이너를 수십 개 띄워도 디스크가 안 터지는 이유 — overlay2와 copy_up

seonwoo_jung·2일 전

같은 이미지로 컨테이너를 수십 개 띄워도 디스크 사용량은 이미지 크기의 몇 배로 늘지 않는다. 그런데 컨테이너 안에서 큰 파일 하나를 고치는 순간 갑자기 쓰기가 느려지는 경우가 있다. 이 두 현상은 사실 같은 뿌리에서 나온다 — Docker의 기본 스토리지 드라이버 overlay2, 그리고 그 밑의 리눅스 overlayfs가 쓰는 copy-on-write 방식이다. "레이어는 diff를 쌓는 것" 정도로만 알고 있었는데, 삭제를 어떻게 표현하는지나 왜 첫 쓰기만 유독 비싼지는 union mount 레벨에서 따라가 봐야 이해가 됐다.

overlayfs는 디렉터리 세 개를 겹친 것

overlayfs 마운트는 세 종류의 디렉터리로 구성된다. 커널 문서(Overlay Filesystem) 용어를 그대로 옮기면 이렇다.

  • lowerdir — 읽기 전용 하위 트리. 콜론으로 여러 개를 쌓는다: lowerdir=L1:L2:L3. 순서를 헷갈리기 쉬운데, 커널 문서는 "stacked beginning from the rightmost one and going left"라고 못 박는다. 가장 오른쪽이 최하위이고 왼쪽으로 갈수록 위다. Docker 이미지의 각 레이어가 이 lower에 대응한다.
  • upperdir — 쓰기 가능한 최상위 트리. 컨테이너가 만든 변경분이 전부 여기 쌓인다.
  • workdir — upperdir과 같은 파일시스템에 있어야 하는 빈 디렉터리. 뒤에 나올 copy_up을 원자적으로 만들기 위한 준비 공간이다.

이 셋을 겹쳐 만든 합쳐진 뷰가 merged, 컨테이너가 실제로 보는 루트 파일시스템이다.

merged view는 어떻게 계산되나

overlay는 이름을 조회할 때 각 레이어를 위에서 아래로 훑어 첫 매칭을 결정한다.

merged view = 위 → 아래로 첫 매칭:
  upperdir ──┐  여기 있으면 결정 (단 whiteout이면 "없음")
  L1(lower) ─┤  없으면 다음 레이어로
  L2(lower) ─┤
  L3(lower) ─┘  최하위

규칙은 두 가지다. 같은 이름이 일반 파일이면 위 레이어가 아래를 완전히 가린다. 같은 이름이 양쪽 다 디렉터리면 합쳐진(merged) 디렉터리가 만들어진다. 단, 커널 문서 표현으로 "only the lists of names from directories are merged" — 디렉터리는 이름 목록만 합쳐지고, 메타데이터나 확장 속성(xattr)은 upper 것만 노출된다.

copy_up — 첫 쓰기가 비싼 이유

lower에만 있던 파일을 쓰기용으로 열거나, 메타데이터를 바꾸거나, hard-link를 만들면 copy_up이 트리거된다. upper에 상위 경로를 만들고 → 같은 메타데이터로 객체를 생성한 뒤 → 데이터를 복사하고 → xattr를 옮기는 순서다.

Docker 문서는 "the copy_up operation only occurs the first time a given file is written to"라고 설명한다. 한 번 upper로 올라온 뒤의 쓰기는 사본을 대상으로 하니 빠르다. 문제는 그 첫 1회다. overlay의 copy-on-write 단위는 파일 전체여서, 1GB 파일의 1바이트만 바꿔도 최초 한 번은 1GB를 통째로 복사한다. (블록 단위로 잘라 복사하는 CoW는 btrfs·ZFS 계열 이야기고, overlay는 파일 단위다.) DB처럼 큰 파일에 잦은 쓰기가 몰리는 워크로드를 컨테이너 레이어 위에 그냥 올리지 말고 volume을 붙이라는 권고가 여기서 나온다.

이 복사가 도중에 크래시해도 반쪽짜리 파일이 노출되지 않는 건 workdir 덕분이다. 데이터를 workdir에 복사·fsync(2)한 뒤 rename(2)으로 upper에 밀어넣어 copy_up 자체를 원자적 연산으로 만든다.

삭제는 "지우는" 게 아니라 "가리는" 것

lower는 읽기 전용이라 upper 쪽에서 파일을 진짜로 지울 방법이 없다. 그래서 overlay는 "지워진 것처럼 보이게" 하는 마커를 쓴다.

상황upper의 표현merged view 결과
lower 파일 삭제0/0 device의 char device, 또는 trusted.overlay.whiteout xattr 0B 파일해당 이름 "없음" (whiteout 자신도 숨김)
디렉터리 통째 가림디렉터리에 trusted.overlay.opaque="y"하위 레이어 동명 디렉터리 내용 전부 무시
메타데이터만 변경trusted.overlayfs.metacopy xattr(데이터 없음)조회는 upper 메타, 데이터는 lower
파일 내용 수정copy_up된 실제 데이터 사본upper 사본으로 완전 대체

첫 줄이 가장 오해하기 쉽다. "컨테이너에서 파일을 지우면 이미지 용량이 준다"는 틀렸다. 삭제는 upper에 whiteout 마커를 하나 더 얹는 것이라 데이터는 그대로 남고 마커만 늘어난다. Dockerfile에서 큰 파일을 받았다가 다음 RUN에서 지워도 이미지가 줄지 않는 이유다 — 지우려면 같은 레이어(같은 RUN) 안에서 지워야 한다. metacopy는 이 원리의 최적화로, chown/chmod 같은 메타-only 연산은 데이터 복사 없이 xattr만 올리고 실제 copy_up은 write 시점으로 미룬다.

Docker overlay2가 조립하는 법

/var/lib/docker/overlay2/<id>/ 아래에서 diff/는 그 레이어 고유의 실제 파일들, lower는 부모 레이어들을 l/AB..:l/CD..로 참조하는 파일, merged/·work/는 마운트포인트와 workdir다. l/<SHORT> 짧은 링크를 쓰는 건 긴 레이어 ID를 그대로 나열하면 mount 인자 길이 제한에 걸리기 때문이다. 컨테이너를 만들면 이미지 레이어들이 lowerdir, 컨테이너 전용 새 디렉터리가 upperdir로 붙는다. 지원하는 lower 레이어는 최대 128개로 알려져 있다.

밀도의 핵심 — page cache까지 공유된다

디스크만 아끼는 게 아니다. Docker 문서는 "Multiple containers accessing the same file share a single page cache entry for that file"라고 설명한다. 여러 컨테이너가 같은 lower 파일을 읽으면 하위 레이어의 동일 inode를 참조하므로 커널 페이지 캐시를 한 벌만 쓴다. 어느 컨테이너가 그 파일에 쓰기 시작하면(copy_up) upper에 새 inode가 생겨 공유가 깨진다. 디스크(레이어 공유)와 RAM(page cache 공유)을 동시에 아끼니, 같은 이미지로 컨테이너를 대량으로 띄우는 고밀도 배치일수록 이득이 커진다 — 맨 앞의 "수십 개를 띄워도 디스크가 안 터진다"가 이 두 겹의 공유 덕분이다.

정리

overlay2는 읽기 전용 이미지 레이어들을 lowerdir 스택으로 겹치고 그 위에 쓰기 가능한 upperdir을 얹은 union mount이며, 하위 레이어 파일에 첫 쓰기가 발생할 때만 그 파일을 upper로 복사(copy_up)하는 copy-on-write 파일시스템이다.

공유(lower)는 읽는 동안 유지되고, 첫 쓰기의 순간 copy_up으로 깨진다 — 디스크 밀도와 쓰기 지연은 같은 메커니즘의 양면이다.

더 파고들 주제로는 redirect_dir/index 기능과 lower에서의 rename 처리, 그리고 fsync 폭주·copy_up 지연이 DB 컨테이너에서 문제되는 이유와 bind mount로 우회하는 원리가 남는다.

참고 자료

0개의 댓글