Krew Insight

[컨테이너 인터널 #2] 컨테이너 파일시스템

chedda.choi 2022. 11. 30. 11:00

시작하며

안녕하세요. 카카오엔터프라이즈에서 검색서비스를 개발하고 있는 검색클라우드기술파트의 Sam(김삼영)입니다. 

[컨테이너 인터널 #1] 컨테이너 톺아보기에서 예고해 드린 대로 이번 포스팅에서는 컨테이너 파일시스템을 자세히 다뤄보도록 하겠습니다. 컨테이너 전용 루트파일시스템을 구성하는 방법, 효율적으로 컨테이너를 유통시키기 위한 패키징 방법, 그리고 컨테이너의 레이어 구조를 살펴보고, 실습을 통해 직접 확인해 보겠습니다. 가능하시다면 실습환경을 미리 준비해주세요. 실습환경은 제 GitHub에 자세히 설명해두었습니다.

 

루트파일시스템

[컨테이너 인터널 #1]편에서 컨테이너의 역사를 톺아보며, 그 기원인 chroot를 통해 초창기 컨테이너의 모습을 살펴보았는데요. chroot는 프로세스가 루트디렉터리  밖으로 벗어날 수 없다는 점에 착안하여 특정 유저 프로세스를 지정한 경로에 가둘 수 있었습니다. 하지만 이는 "루트 디렉터리"를 속이는 방식으로 탈옥이 가능했고, 이를 근본적으로 해결하기 위해 pivot_root 명령어로 호스트의 루트파일시스템을 컨테이너에서 사용할 루트파일시스템으로 바꾸는 방법을 알아보았습니다. 

pivot_root의 원래 용도는 OS 부팅 과정에서 임시로 사용되는 부트파일시스템을 실제 커널이 동작할 루트파일시스템으로 교체하는 것입니다. 하지만 pivot_root를 그냥 사용하게 되면 호스트의 루트파일시스템이 바뀌게 되므로 호스트 환경에 크게 영향을 끼치게 됩니다. 루트파일시스템에는 루트디렉터리를 기점으로 커널이 사용하는 파일시스템 (/proc, /sys, /dev, …)과 시스템 라이브러리들이 존재합니다. 또한, 서버에서 제공하는 각 종 애플리케이션과 그에 필요한 바이너리, 라이브러리, 파일시스템들이 포함돼 있습니다. 

루트파일시스템은 이처럼 호스트 환경에서 매우 중요한데요. 호스트에 영향을 주지 않고 컨테이너에 대해 pivot_root를 할 수 있을까요? 고민한 결과 나온 것이 바로 "네임스페이스" 입니다. 파일시스템은 이름(파일명)으로 관리되는 일종의 "네임스페이스" (이름공간)입니다. 최초의 네임스페이스는 루트파일시스템을 안전하게 피벗 하기 위한 격리 방법으로 고안되었는데요. 바로 지금의 "마운트 네임스페이스" 입니다. 당시만 하더라도 컨테이너는 파일시스템만 격리하면 된다고 생각했다고 합니다(다양해질 거라고 생각하지 못함). 하지만 [컨테이너 인터널 #1]편에서도 살펴보았지만, 현재의 네임스페이스에는 다양한 종류가 있고, 각 네임스페이스는 CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWUTS 처럼 CLONE_플래그명을 통해 유추가 가능합니다. 그런데 마운트 네임스페이스는 CLONE_NEWNS 입니다. "NS?" 잘 유추가 안되죠?  NS는 바로 Name Space의 약자입니다. 

마운트 네임스페이스는 "mount point"를 격리 합니다. 여기서 mount란 루트파일시스템에 서브파일시스템을 부착하는 시스템콜이고, mount point는 파일시스템이 부착(mount) 될 위치(디렉터리) 입니다. mount point에 파일시스템을 mount 하면 원래 디렉터리가 포함하고 있던 파일, 하위 디렉터리 등이 보이지 않고, 새로 부착된 파일시스템의 파일들이 보이게 됩니다. 예를 들어 USB 드라이브를 지정한 mount point로 부착을 시키면, 해당 위치에는 부착된 USB의 파일들이 보이게 됩니다. 

마운트 네임스페이스가 mount point를 격리한다는 것은 파일시스템 마운트와 해제 등의 변경사항들이 네임스페이스 밖에서는 보이지 않고, 외부에 전혀 영향을 주지 않음을 의미합니다. 마운트 네임스페이스가 생김으로써 컨테이너를 위해 pivot_root를 안전하게 실행할 수 있게 되었습니다.

 

💻 실습 ① 컨테이너 전용 루트파일시스템 만들기

지난 연재에서 도커 리포지토리의 Nginx 이미지로 chroot하여 컨테이너를 만들었습니다. 하지만, chroot는 "탈옥문제"가 있어서 실제 컨테이너를 만들 때는 쓰이지 않는다고 하였는데요. 이번 실습에서 pivot_root를 사용해서 보다 완전한 컨테이너를 만들어 보겠습니다.

 

마운트 네임스페이스 격리

마운트 네임스페이스를 격리했을 때 처음에는 호스트의 "루트파일시스템"이 그대로 보입니다. 이것은 마운트 네임스페이스를 생성할 때 부모 프로세스의 마운트 트리 사본을 가져와서 만들기 때문인데요. 일단 격리된 이후에 발생하는 마운트 변경사항들은 컨테이너 내부에서만 적용이 됩니다(nginx-root 마운트 전후의 df -h 출력을 호스트와 비교해 보세요). 따라서 마운트 네임스페이스로 격리하면 pivot_root를 실행하더라도 컨테이너 안에서만 변경이 이루어지고 호스트에는 전혀 영향을 주지 않습니다.

# 마운트 네임스페이스를 격리합니다
/tmp# unshare -m /bin/sh

# 격리 후 파일시스템을 호스트와 비교해 보세요
/tmp# df -h

# nginx-root 디렉터리를 생성합니다
/tmp# mkdir nginx-root


# nginx-root로 tmpfs(메모리 기반 파일시스템)를 마운트 합니다.
/tmp# mount -t tmpfs none nginx-root

# 마운트 후 파일시스템을 호스트와 비교해 보세요
/tmp# df -h

# nginx 압축스트림을 nginx-root 디렉터리에 해제합니다
/tmp# docker export $(docker create nginx) | tar -C nginx-root -xvf -

# 호스트와 nginx-root 디렉터리도 비교해 보세요
/tmp# tree -L 1 nginx-root

 

pivot_root 실행

pivot_root 실행 후에 루트파일시스템이 nginx-root로 변경된 것을 확인해 보세요. 그리고 기존 루트파일시스템이 put_old에 마운트 되었는지도 확인해 보세요.

# 기존 루트 파일시스템을 마운트할 디렉터리를 생성합니다.
/tmp# mkdir nginx-root/put_old

# 새로운 루트디렉터리가 될 위치(nginx-root)로 이동합니다
/tmp# cd nginx-root

# pivot_root 실행합니다
/tmp# pivot_root . put_old

# 루트 디렉터리로 이동합니다
/# cd /

# 현재 루트디렉터리를 확인해 보세요
/# ls /

# 기존 루트디렉터리가 put_old에 마운트 되었는지 확인해 보세요
/# ls /put_old

컨테이너에서 Nginx 웹서버를 실행해 봅시다.

# nginx를 실행해보세요
/# /docker-entrypoint.sh nginx -g "daemon off;"

다른 터미널(호스트)에서 컨테이너로 Nginx로 요청을 보내고, 응답이 잘 오는지 확인해 보세요.

# nginx 요청 확인
/tmp# curl localhost

역시 다른 터미널(호스트)에서 컨테이너의 프로세스를 확인해 보세요.(프로세스 및 네임스페이스 inode 값은 각자 실습환경마다 다릅니다.)

# 컨테이너의 nginx 프로세스 확인
/tmp# ps -ef | grep nginx
9235  

# 마운트 네임스페이스 확인
/tmp# lsns -t mnt -p 9235
4026532476
/tmp# lsns 4026532476

[그림 1] 마운트 네임스페이스와 루트파일시스템 격리

컨테이너의 내부 프로세스들은 동일한 마운트 네임스페이스로 격리돼 있고 pivot_root한 컨테이너 전용 루트파일시스템을 바라봅니다. 컨테이너가 전용의 루트파일시스템을 갖게 되는 것은 어떤 의미가 있을까요?
바로 다음의 의미를 갖게 됩니다.

  • 어떤 서버에 띄우더라도 자체적인 루트파일시스템이 보장되므로 서버 환경 (시스템 파일, 의존성 라이브러리 충돌, 경로 설정 등) 으로 부터 자유롭습니다.
  • 서버의 파일시스템으로 부터 완전하게 분리됨으로써 보안상 안전합니다.
  • 컨테이너 내에서 자유롭게 마운트 변경이 가능하고 파일시스템을 확장할 수 있습니다.

결론적으로 컨테이너가 전용의 루트파일시스템을 갖게되면, 서버 환경을 가리지 않아 배포가 쉬워집니다.

 

이미지 중복 문제

컨테이너가 사용할 파일들을 모으고 격리된 환경에서 pivot_root하여 컨테이너의 루트파일시스템이 준비된다는 것을 알았습니다. 그렇다면, 컨테이너가 사용할 환경, 파일들은 어떻게 준비될까요?(매번 한땀한땀 복사해야 할까요?) 

지난 연재에서 본 것처럼 애플리케이션에 필요한 파일들을 모두 모아서 패키징 되는데 이것을 "이미지" 라고 부릅니다. "이미지"에는 애플리케이션뿐만 아니라 실행에 필요한 시스템 바이너리, 의존성 라이브러리, 설정 등이 포함됩니다. 이미지는 서비스에 대한 요구사항에 따라서 다양하게 패키징 될 수 있는데요. 이렇다 보니 서로 다른 이미지라 하더라도 시스템 바이너리, 웹서버, DB, 그 밖의 관련 라이브러리 등 자주 사용되는 파일들이 중복될 가능성이 높습니다. 

예를 들면, 개발자 A가 Ubuntu 환경에서 웹서버를 컨테이너로 서비스한다고 생각해 봅시다. 이때, 이미지에는 Ubuntu 바이너리들과 Nginx 바이너리를 포함하게 됩니다.

[그림 2] 개발자 A의 이미지

개발자 B도 Ubuntu와 nginx로 웹서비스 환경을 갖추려고 하는데 여기에 DB(mysql)를 추가하고 싶습니다. 이 경우, Ubuntu와 Nginx는 동일한 구성이더라도, 개발자 B는 Ubuntu와 Nginx에 DB를 추가하여 이미지를 만들게 됩니다. Ubuntu와 Nginx가 개발자 A와 B의 이미지 양쪽에 모두 포함이 됩니다.

[그림 3] 개발자 B의 이미지

이처럼 단순한 tarball  형태의 패키징은 "중복 문제"가 있습니다. 중복 문제는 저장공간 / 네트워크 비용 / 배포 속도 / 보안 측면에서 비효율과 비용 문제를 야기합니다. ([컨테이너 인터널 #1] 컨테이너 톺아보기에서 컨테이너에서 해결할 여러 문제를 제시하였는데, "중복문제"도 추가해야겠네요.)

[그림 4] 이미지 중복문제

오버레이 파일시스템

지금부터 "중복문제"를 어떻게 해결하는지 살펴보도록 하겠습니다. 

실제로 컨테이너에서 사용할 이미지는 중복을 해결하기 위해 tarball 형태가 아닌 여러 "레이어"들의 조합으로 제공됩니다. 이렇게 조합된 레이어들을 하나의 통합된 뷰로 제공하는 오버레이 파일 시스템이 있습니다.

오버레이 파일시스템의 기원은 Union 파일시스템으로, 일명 '상속파일시스템'이라고도 합니다. "상속"에서 유추해 볼 수 있듯이, 오버레이 파일시스템은 파일시스템 여러 개를 쌓고, 밑에서부터 레이어들을 층층이 쌓아올립니다. 최상위층에서 보면 레이어들이 하나로 합쳐져 보입니다. 이렇게 여러 파일시스템을 하나로 합쳐서 하나의 파일시스템으로 마운트 하는 기능을 'Union 마운트'라고 합니다. 그리고 Union 마운트를 수행하는 시스템을 Union 파일 시스템이라고 합니다.

 

Union 파일 시스템 특징

Union 파일시스템의 특징을 살펴보겠습니다.

  • 첫 번째, Union 파일시스템은 앞서 설명 드린 Union 마운트, 즉, 여러 개의 파일시스템을 하나로 합쳐서 마운트합니다.
  • 두 번째, Union 파일시스템에서는 레이어가 쌓이는 순서가 중요합니다. [그림 5]에서 보듯이  ab와 b'c' 레이어에서 b, b'이 동일한 파일명일 때 나중에 마운트되는 레이어(상위 레이어, 여기서는 b'c')의 파일이 오버레이 됩니다.
  • 세 번째, Union 파일 시스템은 CoW(Copy-On-Write) 입니다. Lower-Layer(읽기전용)에 대한 쓰기 발생 시에 복사본을 생성하여 수행됩니다. (원본유지)

[그림 5] Union 파일시스템

CD-ROM 예를 들어보면, CD-ROM은 한번 구워지면 Read-Only이기 때문에 내용을 변경할 수가 없습니다. 이 경우 CD-ROM의 내용을 Lower Layer로 하고, writable한 디스크(HDD 등) 볼륨을 Upper Layer로 하여 Union 마운트를 합니다. 이렇게 하면 파일 변경이 필요할 때  Upper Layer(디스크)에 write할 수 있습니다. 즉, Lower Layer(CD-ROM)는 읽기 전용으로 쌓고, 상위에 Upper Layer(HDD)에서 변경이나 새로 쓰기가 발생하는 경우를 처리합니다. 

Union 파일시스템의 구현체는 AUFS → OverlayFS → OverlayFS2 순으로 발전하게 됩니다. 도커 등 최근 컨테이너 런타임에서는 OverlayFS2가 주로 사용됩니다.

 

OverlayFS2 구조

그럼 [그림 6]을 보면서 OverlayFS2 구조를 살펴보겠습니다. Lower Dir은 읽기전용(RO, Read Only) 레이어로 우리가 도커 허브 등 이미지 저장소로 부터 내려 받는 "이미지"에 해당하는 부분입니다. Lower Dir이 하나인 경우도 있지만, 보통은 여러 레이어로 나누어져 있어서 각각 내려(pull) 받게 됩니다. Lower Dir은 순서가 있어서 밑에서부터 차곡차곡 쌓입니다. 도커를 사용해 본 분들은 컨테이너 실행 시에 아래 [그림 7]과 같은 출력을 본 적이 한번쯤은 있을 것 같습니다.

Lower Dir을 다 쌓으면 그 위에 Upper Dir을 올립니다. 컨테이너에서 쓰기가 발생하면 Upper Dir에서 처리하게 되는데요. 새로운 파일에 대한 쓰기도 처리하고, Lower Dir의 파일에 대한 변경도 처리합니다. 이때 Lower Dir은 읽기 전용이므로, Lower Dir 파일에 변경이 필요하면 Upper Dir로 파일을 먼저 복사(Copy) 한 뒤에 변경을 처리합니다(CoW, Copy-On-Write). Upper Dir이 있음으로 해서 기존 이미지 레이어, 즉 원본을 수정하지 않고도 신규 쓰기나 수정이 발생한 변경 부분만 새로 커밋하여 레이어를 추가/확장할 수 있습니다.

Merged View는 오버레이 파일시스템이 마운트 되는 디렉터리입니다. 가장 밑의 Lower Dir부터 Upper Dir까지 마치 셀로판지를 겹쳐 놓은 것처럼 "통합된 뷰(Merged View)"를 제공합니다.

[그림 6] OverlayFS2 레이어 구조
[그림 7] 이미지 레이어 단위 내려받기 예시

💻 실습 ② 오버레이 파일시스템 마운트

위에서 Union 마운트의 특징과 최신 구현인 OverlayFS2 구조를 살펴보았는데요. 실습을 통해 확인해보도록 하겠습니다.

먼저, 오버레이 파일시스템으로 마운트 하기 위한 디렉터리를 생성합니다. 

실습 디렉터리는 /tmp/rootfs이고, rootfs 하위로 Lower Dir에 해당하는 rootfs/image1, rootfs/image2를 생성합니다. Upper Dir은 rootfs/container이고, 오버레이 파일시스템이 마운트되는 디렉터리는 rootfs/merge입니다. rootfs/work (WorkDir)은 "atomic action"을 보장하기 위하여 Merged View(rootfs/merge)에 반영되기 전에 파일들을 준비하는 데 사용됩니다.

# 실습을 위한 디렉터리와 파일을 생성합니다
/tmp# mkdir rootfs
/tmp# cd rootfs
/tmp/rootfs# mkdir image1 image2 container work merge
/tmp/rootfs# touch image1/a image1/b image2/c

# rootfs 디렉터리 구조를 확인해 두세요
/tmp/rootfs# tree .

생성한 디렉터리 구조는 [그림 8]과 같습니다.

[그림 8] 오버레이 파일시스템 마운트 전 rootfs 구조

 

오버레이 파일시스템 마운트

이제, 생성한 rootfs 디렉터리를 가지고 오버레이 파일시스템을 마운트 해보도록 하겠습니다. 마운트의 type (-t)은 overlay 타입이고, 옵션(-o)에서 레이어를 지정합니다. lowerdir은 여러 이미지 레이어를 콜론(:)으로 구분하여 적을 수 있는데요. 이때, 레이어를 위에서 아래 "순서"로, 즉 앞에서부터 뒤로 적는 것이 중요합니다. 즉, image2가 image1 위에 쌓이게 됩니다.

# 오버레이 마운트를 실행합니다
/tmp/rootfs# mount -t overlay overlay -o lowerdir=image2:image1,upperdir=container,workdir=work merge

# rootfs 디렉터리 구조를 마운트 전과 비교해 보세요
/tmp/rootfs# tree .

마운트 이후의 디렉터리 구조는 [그림 9]와 같습니다. (마운트 옵션과 그림을 대조해 보세요)

자세히 보면, 앞서 [그림 8]에서는 비어있던 merge 디렉터리가 채워져 있습니다. merge 디렉터리는 오버레이 파일시스템의 마운트 포인트로, a, b, c 파일이 보이는데요. a, b는 image1, c는 image2 디렉터리 파일임을 알 수 있습니다.

[그림 9] 오버레이 파일시스템 마운트 후 rootfs 구조

다음으로, merge 디렉터리에 있는 파일 "a"를 삭제해 보겠습니다. 파일 "a"는 Lower Dir의 image1의 파일입니다. merge에서 파일 "a"를 삭제하면 어떤 일이 벌어질까요?  "ls merge"로 확인해 보면 merge 디렉터리에서는 파일 "a"가 보이지 않습니다. 다시 tree 명령어로 rootfs 디렉터리 구조를 살펴보겠습니다.

# merge/a 를 삭제해 보세요
/tmp/rootfs# rm merge/a 
/tmp/rootfs# ls merge
b c

# merge/a 삭제 이후 rootfs 를 확인해봅시다
/tmp/rootfs# tree .

[그림 10]을 보면, Upper Dir인 container 디렉터리에 파일 "a"가 보이는데, 컬러와 bold 등 폰트가 다른 파일들과는 다르게 표현됩니다. 이것은 whiteout으로 삭제마킹된 파일입니다. 앞서 Union 마운트의 특징에서 살펴보았던 CoW를 떠올려 보세요. Lower Dir은 읽기전용이므로 변경하지 않고, Upper Dir인 container에서 삭제 마킹으로 처리하였습니다. 따라서 Merged View인 merge 디렉터리에서는 "a"를 출력하지 않는 것입니다. 즉, image1의 파일 "a"가 상위 레이어인 컨테이너의 whiteout(삭제마킹) 파일 "a"로 오버레이 되었습니다.

[그림 10] merge/a 삭제 후 rootfs 구조

번외. Lower Dir도 수정이 가능하다?

이번에는 살짝 장난을 쳐보려고 합니다. 앞서 Lower Dir은 "읽기전용"이라고 수차례 강조하였는데요. Lower Dir의 파일은 정말 수정이 안 될까요? 사실, 수정은 가능합니다. Lower Dir의 image1/b를 수정하였더니 merge/b에서도 변경사항이 반영돼 보입니다.

# merge/b 를 확인해보세요
/tmp/rootfs# cat merge/b

# image1/b를 수정합니다
/tmp/rootfs# echo "hello bb" > image1/b

# 수정 후 merge/b
/tmp/rootfs# cat merge/b
hello bb

왜 이럴까요? 이유를 설명하면 다음과 같습니다. 오버레이 파일시스템은 merge에 마운트 되었고, 오버레이 파일시스템 내에서 Lower Dir 에 대한 원본 유지는 보장됩니다. 하지만 image1 디렉터리 자체는 호스트의 루트파일시스템에 속해 있으므로 권한을 가진 유저가 루트파일시스템 상에서 직접 수정을 하면 막을 방법은 없습니다. 그리고 merge/b 파일은 오버레이 파일시스템 상에서 발생한 변경이 아니므로 (Copy-On-Write가 발생하지 않아서) Lower Dir 상의 image1/b를 그대로 출력하게 됩니다.

[그림 11] image 1/b 파일 수정

이번에는 merge/b를 수정해보겠습니다. merge(마운트포인트)에서 수정한다는 것은 오버레이 파일시스템 상에서 변경이 이루어진다는 점에 주목해 주세요.

# merge/b 를 수정합니다.
/tmp/rootfs# echo "hello m" > merge/b
/tmp/rootfs# cat merge/b
hello m
/tmp/rootfs# cat image1/b
hello bb
# 다시 image1/b를 수정합니다.
/tmp/rootfs# echo "hello bbb" > image1/b

# 더 이상 image1/b가 merge/b에 보이지 않습니다
/tmp/rootfs# cat merge/b
hello m

/tmp/rootfs# tree .

[그림 12]는 merge/b 를 수정한 후의 rootfs 구조입니다. Upper Dir인 container 디렉터리 하위에 파일 "b"가 생겼습니다. 앞서 whiteout ("a") 경우처럼 CoW (Copy-On-Write)로 동작하였습니다. 이제부터는 더 이상 Lower Dir의 image1/b를 바라보지 않게 됩니다. 따라서, image1/b를 “hello bbb”라고 직접 수정하더라도 merge/b는 container/b 를 바라보기 때문에 "hello m"을 출력합니다. (각자의 길을 갑니다~)

[그림 12] merge/b 파일 수정

지금까지 실습을 통해 오버레이 파일시스템을 만들어 보았는데요. 오버레이 파일시스템을 이용하면 "이미지"를 여러 개의 레이어로 나누어 관리할 수 있고, 레이어 조합으로 다양한 니즈의 이미지를 구성할 수 있습니다. 

그리고, 컨테이너에서 새로 쓰기나 변경이 발생하는 Upper Layer만 저장하는 방식으로 쉽게 새로운 레이어를 추가하고 확장할 수 있습니다. 오버레이 파일시스템을 활용한 레이어 구조의 도입으로 저장공간 / 네트워크 사용량 / 배포속도 / 보안 등 다양한 측면에서 이미지 패키징을 효율적으로 관리할 수 있게 되었습니다. 

다음 실습을 위해 오버레이 마운트는 해제해 주세요.

# 오버레이 마운트 경로를 확인합니다.
/tmp/rootfs# mount | grep merge
overlay on /tmp/rootfs/merge type overlay (rw,relatime,lowerdir=image2:image1,upperdir=container,workdir=work)

# umount 합니다.
/tmp/rootfs# umount /tmp/rootfs/merge

컨테이너 레이어 구조

앞서 오버레이 파일시스템을 살펴보았는데요. 도커에서도 오버레이 파일시스템을 사용하고 있는데, 다음과 같이 확인해 볼 수 있습니다.

# docker info | grep Storage
...
Storage Driver: overlay2 
...

👉🏻 참고) 도커 스토리지 드라이버

이제, 도커를 이용하여 이미지를 내려받고 이미지를 구성하는 각 레이어들이 어떻게 조합되어 컨테이너의 파일시스템을 마운트 하고 기동이 되는지 살펴보겠습니다.

[그림 13]은 도커의 이미지 레이어 구조입니다. 앞에서 살펴본 오버레이 파일시스템에서 살펴본 구조와 동일합니다. 

Image layers가 Lower Dir, Container layer가 Upper Dir에 해당됩니다. Image layers에는 총 4개의 레이어가 순서대로 쌓여있고 R/O (Read Only, 읽기 전용) 입니다.  Container layer는 컨테이너 기동 시에 Image layers 위에 올라가는데 R/W (Read-Write)로 읽기/쓰기가 모두 가능합니다.

[그림 13] 도커 이미지 레이어 구조

Container layer는 컨테이너를 기동할 때 생성되는데요. 동일한 호스트 상에서 동일한 이미지로 컨테이너를 여러 개 기동할 수 있습니다. [그림 14]에서 처럼 컨테이너들은 Image layers를 공유하고, 각자 Container layer를 가지고 있습니다.

[그림 14] 도커 컨테이너 레이어 구조

이처럼 레이어 구조는 패키징 관점에서 중복도 최소화 하지만, 여러 컨테이너에서 동일한 레이어를 사용하는 경우에  이를 공유할 수 있어 호스트의 저장공간을 절약하는 측면도 있습니다. 이렇게 레이어를 공유할 수 있는 것은 Image layer를 읽기 전용으로 유지하기 때문인데요. 대신 컨테이너에서 Image layer의 파일을 변경해야 할 경우에는  CoW (Copy-On-Write)로 동작합니다. 이 때, 변경할 파일을 Container layer(Thin R/W layer)로 복사해 와서 처리해야 하므로, 일반적인 write와 비교해서 속도도 느리고 오버헤드가 생기게 됩니다. (따라서 Image layer의 파일 변경은 되도록 피하는 게 좋습니다. 대용량 파일일수록 복사 부담이 크기 때문에 더더욱 피해야 합니다.)

Container layer의 특징을 하나 더 살펴보자면 "휘발성"입니다. Container layer는 컨테이너가 삭제되면 함께 지워집니다. 따라서 저장이 필요한 경우에는 볼륨 마운트 등 Persistent를 보장하기 위한 방법을 따로 마련해야 합니다. 혹은 Container layer를 커밋하여 새로운 "이미지 레이어"로 저장할 수도 있습니다. 

다음 실습을 위해서 아래와 같이 기존 컨테이너 및 이미지들은 삭제해 주도록 합니다.

# docker container prune
WARNING! This will remove all stopped containers.
Are you sure you want to continue? [y/N] y
Total reclaimed space: ...B

# docker image prune
WARNING! This will remove all dangling images.
Are you sure you want to continue? [y/N] y
Total reclaimed space: ...B

 

💻 실습 ③ 컨테이너 레이어 스택 쌓기

이번 실습에서는 컨테이너 레이어 스택을 쌓아보고 이미지 컨테이너의 레이어 구조를 상세히 살펴보겠습니다. 

먼저, 아래와 같이 도커 이미지를 pull 받아옵니다. 동일한 Nginx 이미지를 받아오기 위해 nginx@{이미지Digest}를 사용하였습니다. 

docker pull 로그를 살펴보면 Nginx 이미지가 여러 레이어로 나누어 다운로드 되는 것을 알 수 있습니다. Pull complete 앞의 각 해시값은 도커 허브 등 이미지 저장소에서 제공하는 레이어의 배포(distribution) id 입니다. Pull은 위(f7ec5a41d630)에서 부터 아래(0241c68333ef)로 이미지가 쌓이는 순서대로 받습니다. (f7ec5a41d630가 오버레이 마운트 시 제일 하단에 위치하는 베이스 레이어가 됩니다.)

# docker pull nginx@sha256:75a55d33ecc73c2a242450a9f1cc858499d468f077ea942867e662c247b5e412

docker.io/library/nginx@sha256:75a55d33...: Pulling from library/nginx
f7ec5a41d630: Pull complete
aa1efa14b3bf: Pull complete
b78b95af9b17: Pull complete
c7d6bca2b8dc: Pull complete
cf16cd8e71e0: Pull complete
0241c68333ef: Pull complete
Digest: sha256:75a55d33...
Status: Downloaded newer image for nginx@sha256:75a55d33...
docker.io/library/nginx@sha256:75a55d33...

배포id와 별도로, 각 이미지 레이어는 고유의 레이어id를 가지고 있습니다. 다운로드한 Nginx 이미지 레이어를 확인해 보겠습니다. Layers:[ … ]에 들어있는 값들이 이미지를 구성하는 레이어id 리스트로, 이미지를 Pull 받을 때의 배포id 와는 다른 해시값입니다.

# docker images 
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
nginx        <none>    62d49f9bab67   18 months ago   133MB

# docker image inspect 62d49f9bab67 | jq '.[].RootFS'
{
  "Type": "layers",
  "Layers": [
    "sha256:7e718b9c ...",
    "sha256:4dc529e5 ...",
    "sha256:23c959ac ...",
    "sha256:15aac1be ...",
    "sha256:974e9faf ...",
    "sha256:64ee8c6d ..."
  ]
}

배포id, 레이어id 뭔가 복잡한데요. 도커에서 레이어를 관리하기 위해 사용하는 id 체계를 설명해 보겠습니다.

 

레이어 DB

도커는 다운로드한 이미지를 로컬에 저장하고 관리하기 위해 "레이어 DB"를 사용합니다.

# docker info 
...
Storage Driver: overlay2
...
Docker Root Dir: /var/lib/docker
...

# tree -L 2 /var/lib/docker/image/overlay2

레이어 DB는 [그림 15]와 같이 {도커루트}/image/overlay2/layerdb에 있습니다. 도커는 레이어 DB에 레이어들이 저장된 로컬 경로 정보(cache-id)를 기록하고,  컨테이너 기동 시 레이어 DB를 참고하여 해당 이미지의 레이어들을 정해진 순서대로 쌓아서 오버레이 마운트하게 됩니다. 레이어 DB가 관리하는 주요 정보는 다음과 같습니다.

[그림 15] layerdb 경로 조회

layerdb/sha256 디렉터리를 조회해 봅시다.

👉🏻 참고) tree의 -I는 Ignore 옵션(출력제외) 입니다.

# cd /var/lib/docker/image/overlay2/layerdb
/var/lib/docker/image/overlay2/layerdb# tree -L 2 sha256 -I "size|tar-*"

 

레이어 "db id"

[그림 16]에서 파란색 해시값의 디렉터리명이 레이어 "db id"입니다 (레이어id 아님). 레이어 "db id"는 이미지를 pull 받을 때 레이어 별로 생성이 되는데요. 즉, 레이어 "db id"는 pull 받는 호스트에서 생성이 되고 받을 때마다 달라집니다. (각자 환경별로 레이어 "db id" 값은 다릅니다)

레이어 "db id" 디렉터리에는 cache-id(경로식별), diff(레이어식별), parent(적층순서식별) 세 가지 파일이 존재합니다.

[그림 16] layerdb 디렉터리 구성

이미지 레이어별로 cache-id를 조회해 보세요. cache-id는 이미지 레이어의 로컬 저장 경로를 나타내는 해시값으로 레이어가 저장된 경로는 /var/lib/docker/overlay2/{cache-id} 와 같이 접근할 수 있습니다.

아래와 같이 레이어 DB로부터 cache-id를 조회하고, 로컬 저장 경로를 확인해 보세요.

참고로 지면관계상 ../layerdb 프롬프트는  /var/lib/docker/image/overlay/layerdb에서 줄였습니다.

## .../layerdb ⇒ /var/lib/docker/image/overlay/layerdb
.../layerdb# find . -name cache-id -exec cat {} \; -print

## 출력예시 {해시값} {cache-id경로}
ecf07684dc40103cb8814ec9a… ./sha256/7e718b9c0c8c2e6420fe9c…/cache-id
d19a3bfcaa6d45110613ac4ef… ./sha256/d5955c2e658d1432abb023…/cache-id
cc179d5297f50e978ca2f1249… ./sha256/11126fda59f7f4bf9bf08b…/cache-id
1da3396ef95eaf7e9bc55c436… ./sha256/3444fb58dc9e8338f6da71…/cache-id
ba4040fe5835cdab3c9a25f7a… ./sha256/704bf100d7f16255a2bc92…/cache-id
d1c6ac53317fcb7f05e0f3360… ./sha256/f85cfdc7ca97d8856cd4fa…/cache-id

## 레이어의 로컬 저장 경로(/var/lib/docker/overlay2/{cache-id}도 확인해보세요
.../layerdb# ls /var/lib/docker/overlay2/ecf07684dc40103cb8814ec9a*
committed  diff  link

 

레이어 id

"diff" 파일은 레이어id 값을 가지고 있습니다. 앞서 "docker image inspect {이미지ID} | jq '.[].RootFS'" 명령어로 이미지의 레이어id 리스트를 조회해 보았는데요. 다음과 같이 diff로도 조회해 볼 수 있습니다.

## .../layerdb ⇒ /var/lib/docker/image/overlay/layerdb
.../layerdb# find . -name diff -exec cat {} \; -print
sha256:7e718b9c0c8c2e6420… ./sha256/7e718b9c0c8c2e6420…/diff
sha256:23c959acc3d0eb7440… ./sha256/d5955c2e658d1432ab…/diff
sha256:4dc529e519c4390939… ./sha256/11126fda59f7f4bf9b…/diff
sha256:64ee8c6d0de0cfd019… ./sha256/3444fb58dc9e8338f6…/diff
sha256:15aac1be5f02f2188a… ./sha256/704bf100d7f16255a2…/diff
sha256:974e9faf62f1a3c321… ./sha256/f85cfdc7ca97d8856c…/diff

## [그림 17]을 참고하여 docker image inspect 정보와 diff값을 비교해 보세요
# docker images nginx --format='{{.ID}} {{.Repository}}'
62d49f9bab67
# docker images inspect 62d49f9bab67 --format='{{json .RootFS}}' | jq .

[그림 17]과 diff로 조회한 값을 비교하면 7e718b9c…, 4dc529e5…, 23c959ac… 등 순서는 다르지만 동일한 해시값임을 알 수 있습니다.

[그림 17] 도커 레이어id 조회

 

Parent 정보 확인

마지막으로 레이어의 적층 순서를 결정하는 parent 정보를 확인해 보겠습니다. parent부모레이어의 "db id"를 가지고 있는데요. 따라서 가장 밑바닥에 위치하는 베이스 레이어는 parent 파일이 없습니다. 그래서 parent  출력 결과는 cache-id, diff에 비해 개수가 하나 적습니다.

👉🏻 참고) diff, cache-id는 6개. parent 는 5개

아래 출력 결과에서 첫번째 항목 부터 살펴 보면 sha256:11126fda59f7f4bf9b… 값은 ./sha256/d5955c2e658d1432abb023… 의 부모 레이어 "db id" 입니다.

## .../layerdb ⇒ /var/lib/docker/image/overlay/layerdb
.../layerdb# find . -name parent -exec cat {} \; -print
sha256:11126fda59f7f4bf9b… ./sha256/d5955c2e658d1432abb023…/parent
sha256:7e718b9c0c8c2e6420… ./sha256/11126fda59f7f4bf9bf08b…/parent
sha256:f85cfdc7ca97d8856c… ./sha256/3444fb58dc9e8338f6da71…/parent
sha256:d5955c2e658d1432ab… ./sha256/704bf100d7f16255a2bc92…/parent
sha256:704bf100d7f16255a2… ./sha256/f85cfdc7ca97d8856cd4fa…/parent

이해하기 쉽게 그림으로 표현해 보겠습니다. (참고로 레이어 "db id" 값은 각자 로컬 환경 마다 다르다는 점을 다시 한번 강조드립니다.) d5955c2e의 부모레이어 "db id"는 11126fda 이므로 [그림 18]과 같이 표현할 수 있습니다.

[그림 18] 레이어 스택 쌓기 1

레이어 스택의 아래 방향으로 먼저 완성해 보겠습니다. 11126fda 의 부모레이어를 찾아 볼까요. 마침 출력 결과의 두 번째 행이 11126fda의 부모(.../parent) 레이어 "db id" 값이네요 sha256:7e718b9c0c8c2e6420…  

같은 방식으로 7e718b9c의 부모레이어를 찾아보는데 없네요. 이것은 7e718b9c가 베이스 레이어(레이어 스택의 가장 밑바닥)이기 때문입니다.

[그림 19] 레이어 스택 쌓기 2

이번에는 레이어 스택의 윗방향으로 찾아보겠습니다. d5955c2e를 부모레이어로 가지는 레이어 "db id"를 찾아 볼까요. 출력 결과에서 왼쪽 해시값에서 d5955c2e…를 가지는 행을 찾습니다. 바로 ./sha256/704bf100 입니다.

[그림 20] 레이어 스택 쌓기 3

같은 방식으로 704bf100를 parent로 하는 레이어를 찾아보세요. 출력의 왼쪽 해시 리스트에서 704bf100를 찾고 오른쪽(.../parent)의 해시값(f85cfdc7)을 읽으면 됩니다.

[그림 21] 레이어 스택 쌓기 4

한땀한땀 쌓다보니 어느덧 하나 남았네요 :-)  f85cfdc7의 자식레이어도 찾아보세요.

[그림 22] 레이어 스택 쌓기 5

수고하셨습니다 ~ . 레이어DB를 이용하여 블록 쌓기를 해보았는데요 ;-) (재미있으셨나요?)

요약하면, 레이어는 고유ID (layer-id)가 있고, 로컬 호스트 내에서 "db id"로 관리됩니다. 이때 이미지 별로 레이어를 쌓는 순서는 parent "db id"로 결정할 수 있고, 실제 레이어가 호스트에 저장된 경로는 cache-id로 위치를 알 수 있었습니다.

레이어 "db id"를 기준으로 레이어id(diff) 와 cache-id를 매핑해 보세요.

[그림 23] 레이어 스택 쌓기 완성

 

cache-id

layer-id는 앞서 도커 inspect 명령어로 확인해 보았고, cache-id는 [그림 24]과 같이 호스트 상의 저장 경로 (/var/lib/docker/overlay2)와 비교해서 보세요. cache-id에 해당하는 디렉터리가 존재하는 것을 알 수 있습니다. *l 디렉터리에는 레이어 저장소(cache-id) 심볼릭 링크가 있습니다.

[그림 24] 이미지 레이어 로컬 저장소

지금까지 레이어 DB를 살펴보았는데요. 앞에서 다룬 내용들을 활용하여 컨테이너 레이어 구조를 본격적으로 살펴보도록 하겠습니다.

 

💻 실습 ④ 컨테이너 레이어 구조

도커 이미지의 레이어 구조는 "GraphDriver"에 나타나 있으며, 다음과 같이 inspect 명령을 통해 확인할 수 있습니다. 그런데, 출력 결과가 어디서 많이 본 것 같지 않나요? 앞서 오버레이 파일시스템 실습에서 마운트 했던 여러 옵션들이 보입니다. 

오버레이 마운트 할 때 LowerDir 옵션은 ":"(콜론)으로 여러 개의 레이어를 설정할 수 있었습니다. 이때, 중요한 것이 뭐~다? 기억하실까요?🧐네~ 다들 기억하고 계시는군요 :-)

여기서 레이어 순서가 중요한데요. 앞에서부터 뒤로 갈수록 밑에 깔리는 레이어입니다. 그래서 LowerDir의 가장 뒤에 있는 레이어가 바로 "베이스 레이어"입니다. 

더보기

아래 출력 결과에서 LowerDir 항목의 가장 마지막에 있는 /var/lib/docker/overlay2/ecf07684dc40103cb8814ec9a2dfd8ce618cf501f3cf735e464a3c70556abb2c/diff 가 베이스레이어 경로입니다.

# docker images nginx --format '{{.ID}}'
62d49f9bab67

# docker image inspect 62d49f9bab67 | jq '.[].GraphDriver'
{
  "Data": {
    "LowerDir": "/var/lib/docker/overlay2/d1c6ac53317fcb7f05e0f336016d81588c5be4598228e8f5503242792c8696ee/diff:/var/lib/docker/overlay2/ba4040fe5835cdab3c9a25f7a6b6e12b35c59d9738bdc2d8c9de6232c92c97a3/diff:/var/lib/docker/overlay2/d19a3bfcaa6d45110613ac4eff3c6db4a4b200854a12f0deb1179e95b3506441/diff:/var/lib/docker/overlay2/cc179d5297f50e978ca2f1249b65fc4199ce3a0f4e9fa3dc5767aa9fdcac75da/diff:/var/lib/docker/overlay2/ecf07684dc40103cb8814ec9a2dfd8ce618cf501f3cf735e464a3c70556abb2c/diff",
    "MergedDir": "/var/lib/docker/overlay2/1da3396ef95eaf7e9bc55c43608058045adb706249089c3b3350202d46d0e0ec/merged",
    "UpperDir": "/var/lib/docker/overlay2/1da3396ef95eaf7e9bc55c43608058045adb706249089c3b3350202d46d0e0ec/diff",
    "WorkDir": "/var/lib/docker/overlay2/1da3396ef95eaf7e9bc55c43608058045adb706249089c3b3350202d46d0e0ec/work"
  },
  "Name": "overlay2"
}

지금부터 베이스 레이어 경로를 찾아가 보려 합니다. 로컬 저장 경로를 식별하는 "cache-id"는 어떻다? 네~ 여러분 각자의 환경마다 다릅니다. 본문의 경로는 참고만 해주시고 각자 출력한 경로를 사용해주세요.

# tree -L 1 /var/lib/docker/overlay2/ecf07684*/diff

## 출력결과는 [그림 25] 참고

[그림 25]는 베이스 레이어 디렉터리로, chroot나 pivot_root 실습 때 보았던 Nginx 컨테이너의 루트와 많이 비슷합니다(사실 거의 똑같습니다😎). 참고로 Nginx 이미지에서 사용하는 베이스 레이어 이미지는 debian:bullseye-slim 입니다.

👉🏻 참고) https://github.com/nginxinc/docker-nginx/blob/master/stable/debian/Dockerfile

[그림 25] 베이스 레이어 디렉터리

이제 베이스 이미지 레이어를 pull 받아 보겠습니다. debian:bullseye-slim 를 직접 pull해서 확인하면 좋겠지만 해당 이미지가 업데이트 될 수 있기 때문에 앞서 Nginx 이미지처럼 Digest(스냅샷) 정보로 pull을 받도록 하겠습니다. "f7ec5a41d630" 배포(distribution) id가 이미 존재(Already exists) 한다고 출력됩니다.

# docker pull debian@sha256:b586cf8c850cada85a47599f08eb34ede4a7c473551fd7c68cbf20ce5f8dbbf1

f7ec5a41d630: Already exists
Digest: sha256:b586cf8c850cada85a47599f08eb34ede4a7c473551fd7c68cbf20ce5f8dbbf1
Status: Downloaded newer image for debian@sha256:b586cf8c850cada85a47599f08eb34ede4a7c473551fd7c68cbf20ce5f8dbbf1
docker.io/library/debian@sha256:b586cf8c850cada85a47599f08eb34ede4a7c473551fd7c68cbf20ce5f8dbbf1

이미지를 조회해 보면 총 두 개입니다.

# docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
nginx        <none>    62d49f9bab67   18 months ago   133MB
debian       <none>    48e774d3c4f5   18 months ago   69.3MB

한번 두 이미지의 레이어 정보를 비교해 볼까요. (7e718b9c…부분과 같이 동일 레이어가 존재합니다.)

# docker image inspect 62d49f9bab67 | jq '.[].RootFS'
{
  "Type": "layers",
  "Layers": [
    "sha256:7e718b9c0c8c2e6420fe9c4d1d551088e314fe923dce4b2caf75891d82fb227d",
    "sha256:4dc529e519c4390939b1616595683c89465782bb7d9fc7b90b30cc1e95bc723a",
    "sha256:23c959acc3d0eb744031aef67adf6ceb5120a19c8869727d588f7d9dabd75b09",
    "sha256:15aac1be5f02f2188ab40430b28a5f79be1bcb805db315bbe4d70f70aeabaa36",
    "sha256:974e9faf62f1a3c3210e3904420ffec1dc351b756ac33024f2dd2683bf44c370",
    "sha256:64ee8c6d0de0cfd019841b29c8cb18f4ab38e4687f7784866b840d5b2c31c8b9"
  ]
}

# docker image inspect 48e774d3c4f5 | jq '.[].RootFS'
{
  "Type": "layers",
  "Layers": [
    "sha256:7e718b9c0c8c2e6420fe9c4d1d551088e314fe923dce4b2caf75891d82fb227d"
  ]
}

이로써 우린 Nginx와 Debian의 두 이미지가 레이어(7e718b9c)를 공유하는 것을 알 수 있습니다.

정말 공유하는지 확인해 볼까요? 

앞서 다음과 같이 Nginx의 베이스 이미지 경로를 확인하였습니다.

# docker images nginx --format '{{.ID}}'
62d49f9bab67

# docker image inspect 62d49f9bab67 | jq '.[].GraphDriver.Data.LowerDir'
"/var/lib/docker/overlay2/d1c6ac53317fcb7f05e0f336016d81588c5be4598228e8f5503242792c8696ee/diff:/var/lib/docker/overlay2/ba4040fe5835cdab3c9a25f7a6b6e12b35c59d9738bdc2d8c9de6232c92c97a3/diff:/var/lib/docker/overlay2/d19a3bfcaa6d45110613ac4eff3c6db4a4b200854a12f0deb1179e95b3506441/diff:/var/lib/docker/overlay2/cc179d5297f50e978ca2f1249b65fc4199ce3a0f4e9fa3dc5767aa9fdcac75da/diff:/var/lib/docker/overlay2/ecf07684dc40103cb8814ec9a2dfd8ce618cf501f3cf735e464a3c70556abb2c/diff"

이때, Nginx의 베이스 레이어 로컬 경로(cache-id, ecf07684)는 각자 다르므로 직접 출력하신 경로 정보를 사용해 주세요.

# cd /var/lib/docker/overlay2/ecf07684*/diff
.../diff# ls
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

"a" 라는 파일을 하나 추가해보겠습니다.

## .../diff ⇒ /var/lib/docker/overlay2/ecf07684*/diff
.../diff# echo 'lower layer' > a
.../diff# cat a
lower layer

Nginx의 베이스 레이어에 "a"라는 파일을 추가했는데요. 여기서 "Debian" 컨테이너를 기동하여 해당 파일이 보이는지 확인해 보겠습니다. (만약 보이면 레이어를 공유한다는 것이죠!)

새로운 터미널 창에서 컨테이너(con1)를 기동해보겠습니다.

# docker images debian --format={{.ID}}
48e774d3c4f5
# docker run --name=con1 --hostname=con1 -it 48e774d3c4f5
root@con1:/#

컨테이너(con1) 쉘에서 "a" 파일을 출력해보세요.

Nginx 베이스 레이어에서 추가한 파일 "a"가 debian 컨테이너(con1)에서도 보입니다. 즉, 똑같은 레이어를 Debian 컨테이너가 사용하고 있습니다.

root@con1:/# cat a
lower layer

Debian 컨테이너(con2)를 하나 더 실행해 보겠습니다.
이번에도 새로 터미널 창을 하나 더 띄워주시고, 다음과 같이 컨테이너(con2)를 기동하여 파일 "a"를 확인해 보세요. 새로 띄운 컨테이너(con2)에서도 동일하게 파일 "a"의 내용이 출력됩니다.

# docker images debian --format={{.ID}}
48e774d3c4f5
# docker run --name=con2 --hostname=con2 -it 48e774d3c4f5

root@con2:/# cat a
lower layer

[그림 26]와 같이 컨테이너 con1과 con2가 동일한 베이스 레이어를 바라보고 있습니다. 이처럼 컨테이너를 여러 개 띄우더라도 컨테이너에서 사용하는 이미지 간에 공유하는 레이어가 존재하면 동일한 로컬의 레이어 저장 경로를 바라봅니다.

[그림 26] 컨테이너의 레이어 공유

번외. lower layer 수정해보기(권장하지 않음)

이번에도 살짝 장난을 쳐볼까요. 컨테이너가 실행 중일 때 레이어를 수정하면 어떻게 될까요? 베이스 레이어 경로에 파일 "a"를 추가한 것처럼 이번에는 호스트 터미널에서 파일 "b"를 추가해보겠습니다.

## .../diff ⇒ /var/lib/docker/overlay2/ecf07684*/diff
.../diff# echo 'lower layer2' > b
.../diff# cat b
lower layer2

컨테이너 con1, con2에서 베이스 레이어에 추가한 파일 "b"를 확인해 보세요.

root@con1:/# cat b
lower layer2
root@con2:/# cat b
lower layer2

con1과 con2 둘 다 확인이 되나요? (신기하죠? 호스트에서 변경한 내용이 컨테이너 con1, con2에 모두 보입니다.) 이것은 컨테이너에서 해당 레이어의 로컬 경로를 오버레이 마운트에 포함하고 있기 때문인데요. 앞서도 설명드렸지만 lower layer는 오버레이 파일시스템 상에서는 "읽기 전용"입니다만, 호스트의 파일시스템에서 해당 경로로 접근하여 수정하는 것은 막을 수 없습니다. 따라서 이것은 어디까지나 테스트이고 실제로는 이렇게 사용하면 안 됩니다. 절대로 lower layer를 직접 수정하는 일은 없어야합니다. lower layer는 이미지 저장소에서 관리되므로 호스트에서 수정을 가하면 형상관리도 되지 않고 무엇보다 컨테이너 환경이 호스트에 락인(lock-in)되어 다른 문제를 일으킬 수 있습니다.

 

con1, con2 컨테이너의 레이어 구조

이번에는 con1, con2 컨테이너의 레이어 구조를 비교해보겠습니다. 호스트에서 다음과 같이 컨테이너 inspect 명령어를 실행해보세요. (경로는 중요하지 않으므로 프롬프트를 생략하였습니다.)

con1, con2의 레이어 구조는 "GraphDriver" 정보에서 확인할 수 있습니다. 친절하게 오버레이 마운트할 때 사용했던 옵션 정보와 동일하게 필드가 구분되어 있습니다. (앞에서 이미지의 레이어 구조를 확인하기 위해 GraphDriver 정보를 조회했던 방법과 동일합니다.)

먼저, LowerDir에서 con1과 con2가 베이스 레이어(ecf07684)를 공유하고 있음을 알 수 있네요. (다시 말씀 드리지만 저장 경로 해시값인 cache-id는 각자 환경마다 다릅니다.)

## 컨테이너 ID를 확인합니다
# docker ps --format="{{.ID}} {{.Names}}"
4ba8f00205fc con1
36d72c381639 con2

## con1의 레이어 구조를 확인합니다
# docker container inspect 4ba8f00205fc | jq '.[].GraphDriver'
{
  "Data": {
    "LowerDir": "/var/lib/docker/overlay2/008f82b2…-init/diff:/var/lib/docker/overlay2/ecf07684…/diff",
    "MergedDir": "/var/lib/docker/overlay2/008f82b2…/merged",
    "UpperDir": "/var/lib/docker/overlay2/008f82b2…/diff",
    "WorkDir": "/var/lib/docker/overlay2/008f82b2…/work"
  },
  "Name": "overlay2"
}

## con2의 레이어 구조를 확인합니다
# docker container inspect 36d72c381639 | jq '.[].GraphDriver'
{
  "Data": {
    "LowerDir": "/var/lib/docker/overlay2/189e4212…-init/diff:/var/lib/docker/overlay2/ecf07684…/diff",
    "MergedDir": "/var/lib/docker/overlay2/189e4212…/merged",
    "UpperDir": "/var/lib/docker/overlay2/189e4212…/diff",
    "WorkDir": "/var/lib/docker/overlay2/189e4212…/work"
  },
  "Name": "overlay2"
}

[그림 27]은 con1, con2의 레이어 구조를 시각화한 것입니다.(여러분들도 각자 환경에 맞게 그림으로 한 번 표현해 보세요.)

[그림 27] con1, con2 레이어 구조

ecf07684 레이어는 앞에서 살펴본 Debian 이미지입니다. 컨테이너 기동 시 이 위에 lower layer(*-init/diff)가 하나 더 올라가는 것을 알 수 있습니다. 그리고, UpperDir과 Merged View가 올라갑니다. 

아시다시피 LowerDir은 읽기전용으로 원본이 유지됩니다. UpperDir은 컨테이너의 변경 사항이 write되는 경로이고, Merged View는 마운트 포인트로 모든 레이어의 내용들이 병합돼 보이게 됩니다. (* WorkDir 은 "atomic action"을 보장하기 위하여 merged에 반영되기 전에 파일들을 준비하는 데 사용됩니다. 그림에서는 생략하였습니다)

컨테이너 기동 시 생성된 con1(008f82b2)과 con2(189e4212)의 레이어 로컬 경로 해시값으로 mount 정보를 확인해 보겠습니다. 호스트 상에서 마운트를 조회합니다.

## con1의 Merged View 해시값(008f82b2)으로 마운트 정보를 조회해 보세요
# mount | grep 008f82b2
overlay on /var/lib/docker/overlay2/008f82b2…/merged type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/IHAK6D5THBNZFHFIHGWOMYVIHC:/var/lib/docker/overlay2/l/AHHFFFBPVRDRPGYWIDD7VD25JY,upperdir=/var/lib/docker/overlay2/008f82b2…/diff,workdir=/var/lib/docker/overlay2/008f82b2…/work)

## con2의 Merged View 해시값(189e4212)으로 마운트 정보를 조회해 보세요
# mount | grep 189e4212
overlay on /var/lib/docker/overlay2/189e4212…/merged type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/TDS5UVILHB5CUIYDYWDZG2EMTI:/var/lib/docker/overlay2/l/AHHFFFBPVRDRPGYWIDD7VD25JY,upperdir=/var/lib/docker/overlay2/189e4212…/diff,workdir=/var/lib/docker/overlay2/189e4212…/work)

mount 출력에서도 도커 inspect 명령으로 확인한 레이어 정보를 확인할 수 있습니다. lowerdir 정보만 조금 다른데요. "/var/lib/docker/overlay2/l/" 경로를 조회해 보면 lower layer들의 심볼릭 링크가 있습니다. 

lowerdir의 베이스 레이어(/var/lib/docker/overlay2/l/AHHFFFBPVRDRPGYWIDD7VD25JY)를 확인해 볼까요? (각자 환경마다 출력되는 값이 다릅니다.)

# ls -al /var/lib/docker/overlay2/l/AHHFFFBPVRDRPGYWIDD7VD25JY
/var/lib/docker/overlay2/l/AHHFFFBPVRDRPGYWIDD7VD25JY -> ../ecf07684…/diff

심볼릭 링크가 가리키는 경로를 확인해 보세요. 베이스 레이어 경로와 동일합니다. 앞서 만들었던 파일 "a", "b"도 보입니다.

# ls /var/lib/docker/overlay2/l/AHHFFFBPVRDRPGYWIDD7VD25JY
a  b  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

이번에는 컨테이너에서 파일을 수정해 보도록 하겠습니다.  con1에서 파일 "a"를 수정해 보세요.

root@con1:/# echo 'con1' > a
root@con1:/# cat a
con1

con2와 호스트에서 각각 확인해 보세요.(둘 다 변화가 없습니다.)

root@con2:/# cat a
lower layer
/var/lib/docker/overlay2/ecf07684*/diff# cat a
lower layer

컨테이너의 쓰기는 UpperDir에서 이루어진다고 하였습니다. con1의 UpperDir을 확인해 볼까요? 

con1의 마운트 정보를 참고하여 UpperDir을 조회해 보겠습니다. "a"라는 파일이 보입니다. 읽기 전용인 LowerDir의 파일 "a"에 대해 쓰기 요청이 발생하였고, 이를 처리하기 위해 파일 "a"를 UpperDir로 복사한 후에 쓰기가 실행되었습니다.(CoW, Copy-On-Write)

## con1 컨테이너ID 확인
# docker ps --format="{{.ID}} {{.Names}}" | grep "con1"
4ba8f00205fc

## con1 UpperDir 경로 확인
# docker container inspect 4ba8f00205fc | jq '.[].GraphDriver.Data.UpperDir'
"/var/lib/docker/overlay2/008f82b2…/diff"

## con1 UpperDir 조회
# ls /var/lib/docker/overlay2/008f82b2…/diff
a

컨테이너 con2에서 파일 "b"를 수정해 보세요.

root@con2:/# echo 'con2' > b
root@con2:/# cat b
con2

이번엔 con1과 호스트에서 각각 확인해 보세요. 마찬가지로 둘 다 변화가 없는 것을 확인할 수 있습니다. con2 역시 LowerDir의 파일 "b"를 수정하려고 하면 CoW(Copy-on-Write)로 동작하기 때문에 UpperDir로 먼저 복사한 후에 쓰기가 처리됩니다.

root@con1:/# cat b
lower layer2
/var/lib/docker/overlay2/ecf07684*/diff# cat b
lower layer2

[그림 28]을 보면 con1에서 수정한 파일 "a"는 con2 와 베이스 레이어에는 영향을 주지 않습니다. con1의 UpperDir에 변경된 파일 "a"가 위치하기 때문에 con1에서만 보여지게 됩니다. 마찬가지로 con2에서 수정한 파일 "b"도 con2에서만 변경사항이 보여지게 됩니다. 마치 셀로판지를 덧대어 놓은 것처럼 위에서 바라보면 가장 밑의  베이스레이어 파일들까지 다 보이지만 상위 레이어에 동일한 파일이 존재하면 하위 레이어의 파일들은 가려져 보이지 않습니다.

[그림 28] 레이어 구조와 CoW

이런 이유에서, 레이어를 쌓는 순서가 중요합니다. 레이어 간 동일한 파일이 존재하는 경우, 어떤 높이에 있느냐에 따라 Merged View에 파일이 노출이 될 수도 있고 안 될 수도 있습니다. 이처럼 컨테이너는 여러 레이어를 쌓아올린 스택구조의 파일시스템을 사용합니다. 이렇게 함으로써 중복은 최소화하고 변경이 발생하면 해당 부분만 새로운 레이어로 저장하여 관리할 수 있습니다.

[그림 29] 컨테이너 레이어 구조의 셀로판지 비유

다음 실습에서 기존 이미지 레이어 위에 새로운 레이어를 추가해 보도록 하겠습니다.

* 컨테이너 con1, con2는 종료해도 좋습니다. :-)

 

💻 실습 ⑤ 이미지 레이어 추가하기

컨테이너에 변경이 발생하면 Upper layer에 기록이 되지만, Upper layer는 휘발성으로 컨테이너가 종료되면 삭제되는데요. 이번 실습에서는 도커를 이용하여 Upper layer에 변경된 정보를 별도 레이어로 저장해 보도록 하겠습니다.

우선, 레이어를 추가하기 전에 Nginx 이미지의 히스토리 정보부터 확인해 보겠습니다.

## nginx IMAGE ID 확인
# docker images nginx
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
nginx        <none>    62d49f9bab67   18 months ago   133MB

# docker history 62d49f9bab67
## 출력결과는 [그림 30] 참고

이미지 히스토리는 아래에서 위로 갈수록 최근 이력이고, 하이라이팅된 이력은 해당 이미지 레이어가 있습니다. [그림 30]에서는 총 6개네요 (앞에서 살펴본 Nginx 레이어 개수와 같습니다.)

이번 실습에서 새로 레이어를 추가하면 7개가 되겠지요? 실습 말미에 지금 언급했던 내용과 비교해 보겠습니다.

[그림 30] docker history - 이미지 레이어 히스토리 출력

Nginx 컨테이너를 기동하고 파일(/bin/tar)을 삭제해 보겠습니다. 이때 주의할 점은 뒤에 나올 명령어에서 사용하기 위해 컨테이너에 이름(--name=mynginx)을 꼭 부여해주세요.

## nginx IMAGE ID 확인
# docker images nginx
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
nginx        <none>    62d49f9bab67   18 months ago   133MB

## nginx 컨테이너 기동(/bin/bash)
# docker run --name=mynginx --rm -it 62d49f9bab67 /bin/bash

## nginx 쉘 상에서 /bin/tar 삭제
/# rm /bin/tar

새로운 터미널(호스트)에서 컨테이너의 변경사항을 확인(diff)하고 커밋(commit)해봅니다. 커밋 후 새로 이미지ID(d5680e145a29, 각자 다름)가 부여되었습니다.

## 원본 이미지와 비교
# docker container diff mynginx
C /bin
D /bin/tar

## 커밋
# docker container commit mynginx nginx:rm_tar
sha256:d5680e145a2917d64ae530b964aa3734c4fcd21ea5c063cff78e4512300e7b09

이미지 히스토리를 다시 확인해보겠습니다. 호스트 터미널에서 계속 진행합니다.

## 이미지(nginx:rm_tar)가 새로 추가되었네요
# docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
nginx        rm_tar    d5680e145a29   4 minutes ago   133MB
nginx        <none>    62d49f9bab67   18 months ago   133MB
debian       <none>    48e774d3c4f5   18 months ago   69.3MB

## nginx:rm_tar 의 히스토리를 확인해보세요
# docker history nginx:rm_tar
## 출력결과는 [그림 31] 참고

nginx:rm_tar 히스토리를 보면 레이어가 추가되었고 IMAGE 항목에 새로 부여된 이미지ID(d5680.., 각자 다름)가 보이네요.

[그림 31] 커밋 이미지(nginx:rm_tar) 히스토리

 

추가된 레이어 확인하기

추가된 레이어를 확인해 보겠습니다.

 

[1] 레이어 id 확인

아래와 같이 도커 inspect (RootFS)로 조회하면 레이어id를 순서대로 출력해 줍니다. 배열의 0번, 7e718b9c가 베이스 레이어이고, 배열의 끝에 있는 "f7bc3bfc"가 방금 커밋한 레이어입니다.

# docker image inspect nginx:rm_tar | jq '.[].RootFS.Layers'
[
  "sha256:7e718b9c0c8c2e6420fe9c4d1d551088e314fe923dce4b2caf75891d82fb227d",
  "sha256:4dc529e519c4390939b1616595683c89465782bb7d9fc7b90b30cc1e95bc723a",
  "sha256:23c959acc3d0eb744031aef67adf6ceb5120a19c8869727d588f7d9dabd75b09",
  "sha256:15aac1be5f02f2188ab40430b28a5f79be1bcb805db315bbe4d70f70aeabaa36",
  "sha256:974e9faf62f1a3c3210e3904420ffec1dc351b756ac33024f2dd2683bf44c370",
  "sha256:64ee8c6d0de0cfd019841b29c8cb18f4ab38e4687f7784866b840d5b2c31c8b9",
  "sha256:f7bc3bfcb71178a94c357145c29b1fca9ff6cf4aa51327c15d62275ef09f2d14"
]

 

[2] 레이어 "db id" 확인

앞에서 다루었던 레이어DB 기억하시나요? 레이어 정보를 얻기 위해서는 레이어 "db id"를 알아야 합니다. 

레이어id (f7bc3bfc)를 이용해서 레이어 "db id"(eca75629) 를 확인합니다. *find 출력은 왼쪽이 값, 오른쪽이 해당 파일 경로입니다. 레이어id는 레이어DB에서 "diff" 파일에 저장돼 있죠. diff 파일이 포함된 디렉터리명이 레이어 "db id" 입니다.

# find /var/lib/docker/image/overlay2/layerdb -name "diff" -exec cat {} \; -print | grep f7bc3bfc
sha256:f7bc3bfc… /var/lib/docker/image/overlay2/layerdb/sha256/eca75629…/diff

 

[3] 레이어 cache-id 확인

레이어 "db id"를 알았으니 cache-id를 확인할 수 있습니다.

# cat /var/lib/docker/image/overlay2/layerdb/sha256/eca75629*/cache-id
cd88a03e22fd6530925bc2607e8a929cca0c7f26524e2651319458ce05850b0e

 

[4] 레이어 확인

cache-id를 알았으니 레이어가 저장된 경로를 확인할 수 있습니다.

# tree /var/lib/docker/overlay2/cd88a03e*
/var/lib/docker/overlay2/cd88a03e …
├── diff
│   └── bin
│       └── tar
├── link
├── lower
└── work

3 directories, 3 files

새로운 레이어에는 "tar" 파일이 whiteout (삭제마킹) 으로 처리돼 있습니다.  [그림 32]는 앞에서 살펴본 [그림 23]에 추가된 레이어를 반영한 것입니다. (레이어의 해시값은 각자 환경에 따라 다를 수 있습니다. 직접 한 번 그려보시는 걸 추천합니다.)

[그림 32] 새로운 레이어 추가

끝으로 nginx:rm_tar 컨테이너를 직접 띄워서 새로운 레이어, 즉, tar 삭제가 반영되었는지 확인해보겠습니다.

예상대로 /bin/tar 파일은 조회되지 않습니다. (새로운 레이어가 tar를 whiteout 으로 포함하고 있기 때문입니다.)

## nginx:rm_tar 기동
# docker run -it nginx:rm_tar /bin/bash

## 컨테이너
/# ls /bin/tar
ls: cannot access '/bin/tar': No such file or directory

 

마치며

리눅스에서는 "모든 것이 파일(Everything is a file)"이라는 말이 있죠. 장치도 파일로 표현하고 소켓도 파일로 표현하고, 그리고 CPU, 메모리 등 프로세스에 할당되는 자원들도 파일로 관리합니다. 커널이 시스템을 모니터링 하는 방법이나 커널이 프로세스 관련하여 제공하는 메트릭 역시 모두 파일을 통해서 합니다. 그만큼 리눅스에서 파일시스템은 매우 중요한데요. 컨테이너가 서버환경으로 부터 독립하기 위해서는 파일 시스템으로 부터의 독립이 필요합니다. 따라서, 컨테이너가 "전용 루트파일시스템"을 갖는 것은 의미가 있습니다. 컨테이너가 자체적으로 파일시스템을 갖게 됨으로써 어떤 서버에 올려도 호스트 파일시스템에 영향을 받지 않게 되며, 배포가 쉬워집니다.

But, 전용 루트파일시스템이 컨테이너의 완성은 아니었습니다. 많은 사람들이 다양한 니즈로 컨테이너를 패키징 하는 과정에서 중복 문제가 발생합니다. 이로 인해 컨테이너 이미지를 저장하고, 유통하고, 보안에 신경쓰는 과정에서 비용이 발생하게 됩니다. 이러한 중복 문제에 대한 해법으로 패키징을 레이어 단위로 나누어 저장하고, 오버레이 파일시스템으로 이 레이어들을 정해진 순서대로 잘 쌓아서 마운트 함으로써 컨테이너와 패키징의 중복을 최소화 할 수 있었습니다.

이번 포스팅에서는 컨테이너 파일시스템을 집중적으로 다루어 보았는데요. 컨테이너 완성을 위해서는 파일시스템 외에도 추가로 고려해야 할 다양한 격리 요소들과 시스템 자원 할당에 대한 컨트롤, 그리고 네트워크 구성이 남아 있습니다.

 

🍪 아직 잠깐! 다음편 예고

컨테이너 인터널 시리즈 세 번째 편은 "컨테이너 네트워크"로, 가상의 네트워크를 구성해보고 컨테이너 간에 통신을 실습해 보려 합니다.

💻 우리가 다음에 실습해볼 것들

더보기
  • 1:1 통신
  • 여러대 통신
  • 외부와의 통신

다음 편도 기대되신다구요? 여러분이 열심히 복습하고 계시면 곧 다시 찾아오도록 하겠습니다 👨🏻‍🏫(Sam쌤 모드)

그럼 다음에 또 뵙겠습니다 :-) 화이팅!

Sam (김삼영)

‘라이딩’과 ‘공부’를 좋아하는. 둘 다 ‘떼지어' 할 때가 가장 신나는 개발자입니다. 검색을 클라우드와 AI로 확장하는데 관심이 많습니다.