글 작성자: 모두의 근삼이

컨테이너 이미지의 빌드 및 배포에 관한 성능을 개선하기 위해 리서치를 하다보니, 혼자 알기에 너무 재밌는 배경들이 많아서 정리해 보기로 했다.
오늘은 컨테이너와 이미지의 구조 및 원리(특히 파일 시스템과 관련한 부분), 관련 컴포넌트들을 딥다이브하여 파헤쳐 보고, 오픈소스 생태계에서 이들을 활용하기 위해 어떤 움직임들을 보였는지 살펴보는 시간을 가져볼까 한다.

컨테이너

컨테이너 이미지가 뭔지 알려면 먼저 컨테이너에 대해서 알아야 한다. 흔히 컨테이너를 이야기 하면 도커를 엮어서 생각하는데, 사실 도커는 단일 기술이 아니다. 컨테이너의 빌드, 실행, repo관리, 네트워크 관리 등등 컨테이너와 관련한 온갖 기술들을 다 때려다 박아서 docker라는 하나의 커멘드 라인만으로 사용자들이 컨테이너 세상에 쉽게 접근하고 관리할 수 있도록 만들어진 종합예술 툴. 혹은 그 툴을 만들어 낸 기업이기도 하다.

도커가 컨테이너 시대를 불러온 것은 맞지만, 사실 컨테이너와 관련한 기술은 도커 이전에도 꾸준히 연구되어 왔고, 지금도 컨테이너 생태계는 도커라는 이름과 강결합 되어 있지 않다. 각각의 기능들을 충분히 다른 툴들을 통해서 대체할 수 있다.

컨테이너라는 것은 결국 리눅스에 개발되어 내장된 여러 격리 기능들과 규칙들이 잘 조화를 이루어 만들어진 가상작업 공간이고, 도커를 포함한 모든 컨테이너 런타임들은 재료들을 잘 버무려서 만들어낸 고수준 툴들이라고 할 수 있을 것이다.

(이미지 출처 : 카카오테크블로그 )

mount 네임스페이스와 pivot_root

검증을 위해 파일시스템을 간단하게 컨테이너화 시켜보자. 편의를 위해 컨테이너의 기능 중 파일 시스템에 관한 부분에 집중하여 설명한다.
리눅스 파일시스템이란 굉장히 중요한 의미를 가지는데, 디바이스, 네트워크 소켓, 커널의 다양한 정보, 프로세스 등 대부분의 것들이 다 파일 형태로 관리된다.

파일 시스템 격리에는 마운트 네임스페이스와 pivot_root라는 개념을 활용한다.

(이미지 출처 : gyullbb velog)

pivot_root는 root 파일시스템의 마운트 포인트를 다른 마운트 포인트와 바꿔치기해서 특정 디렉토리를 새로운 루트로 만들어주는 명령어이다.
그런데 이 명령어를 그냥 사용해 버리면, 당연히 host os에 무시무시한 영향을 줘버린다. 이 기능을 host os에 영향 없이 격리된 환경에서 이용하기 위한 개념이 mount namespace다. (컨테이너의 다양한 설정에 대한 격리를 위해 여러 종류의 namespace들이 등장 했는데, 그 중 가장 먼저 개발 된 것이 mount ns인 이유이다.)
mount namespace는 프로세스들에 서로 다른 파일시스템 마운트 포인트를 제공하는 격리 기술이다.

기술적으로는 /(루트)디렉토리도 마운트 된 디렉토리이기 때문에 mount namespace를 통해 프로세스를 격리하면 파일 시스템을 격리할 수 있다.

리눅스의 네임스페이스 란?
네임 스페이스는 특정 프로세스에 대해 시스템 리소스를 논리적으로 격리하는 리눅스의 가상화 기술이다. mount네임스페이스를 비롯해, uts, ipc, net, pid, user, cgroup 등의 총 7가지 격리 기술로 구성되어 있으며 리눅스에서는 unshare라는 명령어로 실행해 볼 수 있다.

그럼 간단하게 가상 환경을 위한 루트 디렉토리를 생성하고 격리 해보자.
우선 unshare명령으로 마운트 네임스페이스를 활용해 마운트 포인트를 격리하고, 격리된 공간에서만 보이게 될 새로운 루트 공간을 마운트 해주자.

unshare --mount /bin/bash
mkdir new_root
mount -t tmpfs -o size=10M none new_root

처음 실행에 필요한 최소한의 쉘과 명령어는 있어야 하니, hostos에서 쉘 및 ls명령어와 의존 라이브러리를 복사해서 생성하도록 한다.

which bash #명령어 위치 확인
which ls
ldd /usr/bin/bash #의존 라이브러리 확인
ldd /usr/bin/ls

mkdir -p new_root/{lib64,bin}
cp /usr/bin/{bash,ls} new_root/bin/
cp /lib64/{libtinfo.so.6,libdl.so.2,libc.so.6,ld-linux-x86-64.so.2,libselinux.so.1,libcap.so.2,libacl.so.1,libpcre.so.1,libattr.so.1,libpthread.so.0} new_root/lib64/ #의존 라이브러리들 복사

제대로 격리가 되었는지 확인해 보고 싶다면, 다른 쉘에서 확인해 볼 수 있을 것이다.
이제 new_root 내부에 기존 root를 위치시킬 old_root디렉토리를 생성하고, pivot_root를 실행해서 루트 디렉토리 바꿔치기를 수행하자.

cd new_root && mkdir old_root
pivot_root . old_root

내부를 탐색해 보면, 격리된 공간인 것을 알 수 있다. 탐색이 끝나면 exit로 빠져나오자.

사실 위에서 해본 실습은 chroot라는 명령어를 활용하면 namespace등에 대한 명령어를 쓰면 더 쉽게 체험해 볼 수 있는데, chroot만으로는 프로세스 격리, 루트 권한 문제, 탈옥 가능 등의 이유로 실제로는 컨테이너 기술을 구현하는데 사용되지 않는 기술이므로 소개하지 않는다.

컨테이너 이미지의 내용

리눅스의 기능을 활용해 간단하게 컨테이너를 만들어 봤다. 그렇다면, 이 글을 작성하게 된 계기인 컨테이너 이미지를 다시 생각해보자.
컨테이너를 활용하는 체계의 많은 장점들 중 하나는 이미지 레지스트리를 통한 쉽고 빠른 이미지 공유이다.
기본적으로 컨테이너 이미지는 독립적으로 실행되었을 때 필요한 파일과 설정들로 구성된 파일시스템의 압축 덩어리이다.
그럼 이미지에 압축되어 기록된 파일 시스템을 풀어서 위에서 테스트 해본 방식대로 격리해서 프로그램을 실행하면 의도대로 동작하는걸까?
그렇다. 한번 테스트 해보자.

다시한번 격리 후, 마운트 포인트를 생성해 주고

unshare --mount /bin/bash
mkdir nginx_root
mount -t tmpfs -o size=200M none nginx_root

docker명령어를 사용해 nginx이미지에 담긴 내용물을 nginx_root로 덤프뜬다.

docker export $(docker create nginx:latest) | tar -C nginx_root -xvf -

pivot_root를 수행하여 내부에서 nginx 바이너리를 실행해 보자

cd nginx_root && mkdir old_root
pivot_root . old_root
nginx -g "daemon off;"

내부의 다른 쉘에서 curl로 요청을 보내보자.

OverlayFS

그런데 뭔가 의문점이 있다. 분명 컨테이너 이미지는 여러겹의 layer들로 구성되어 있고, 이게 큰 장점이라고 들었는데, 방금 해본 테스트에서는 그러한 내용들을 전혀 살펴볼 수 없었다. 그 이유는 docker 명령어를 통해 running되고 있는 컨테이너의 merged view에 반영된 내용물을 그대로 덤프떠와서 테스트를 진행했기 때문에 오버레이 파일시스템에 대해 설명할 기회가 없었던 탓이다. (merged view가 뭘까에 대한 의문은 유지한 채로 다음을 보자)

컨테이너 이미지는 설정에 따라 작게는 5MB에서 크면 2~3GB는 거뜬히 넘길 수도 있다. 컨테이너 생태계는 OverlayFS의 개념을 적극적으로 활용하여 컨테이너 이미지를 만들고 주고 받을 때, 필요한 부분만 주고 받아서, 공통되는 레이어는 작업하지 않도록 하여 작업량을 최대한 줄일 수 있도록 하였다.
증분되는 레이어를 활용해 작업을 최소화 한다는 개념은 알겠는데, 대체 컨테이너와 이미지는 무슨 외계 기술을 쓰길래 저런 엄마손 파이같은 생소한 구조의 파일시스템을 활용하고, 공유할 수 있는건지 궁금하지 않은가?

기왕 딥다이브 해보기로 했으니 OverlayFS가 어떻게 동작하는지 한번 살펴보자.
지금껏 그래왔듯 OverlayFS라는 개념도 컨테이너만을 위한 생소한 개념이 아니다. 리눅스에서 하나의 디렉터리 지점에 여러개의 디렉터리를 마운트하는 방식으로, 마치 하나의 통합된 디렉터리처럼 보이게 하는 마운트 방식을 유니온 마운트 파일 시스템이라고 부르는데, OverlayFS는 그에 대한 구현체 중 하나이다.

(이미지 출저 : alice블로그)

Overlay는 크게 4가지 디렉터리 레이어로 구성된다.

  • lower dir : 아래쪽에 깔려 있는 수정이 불가능한 R/O 형태의 디렉터리. 여러개일 수 있다.
  • upper dir : lower dir들의 위에 위치하는 수정 사항이 반영되는 R/W 형태의 디렉터리. 하나다.
  • merge dir: 위의 그림에서, 모든 레이어를 겹쳐서 보는 통합 뷰 (View) 에 해당하는 디렉터리. 사용자의 실질적인 작업영역이 된다.
  • work dir : 통합 뷰의 원자성을 보장하기 위해 존재하는 중간 계층. 사용자에게는 중요하지 않다.
    실제로 마운트해서 사용해보자.

OverlayFS를 활용하여 마운트 하기 전에 각 역할별로 디렉터리를 생성하고, lower dir들을 위해 미리 파일들을 준비해 보자.

mkdir lower1 lower2 upper merge work
echo GeunSam2 > lower1/file1
echo Gray > lower2/file2

두개의 lower dir을 준비했고, 각 디렉터리에 file1, file2를 생성해 두었다.

mount -t overlay overlay -o lowerdir=lower1/:lower2/,upperdir=upper/,workdir=work/ merge/

이제 merge 디렉터리에서 파일들을 수정해보면서 변화들을 관찰해 보자.

컨테이너 이미지의 구조

컨테이너가 실행될 때 활용되는 파일시스템인 OverlayFS의 원리에 대해서는 대충 이해가 된 것 같다.
그렇다면 이제 대망의 이미지의 구조를 까볼 시간이다. 어떻게 생겨먹었길래 이러한 겹겹의 레이어들을 효율적으로 주고 받을 수 있는걸까?
docker savedocker load 명령어를 활용하면 이미지를 파일형태로 쉽게 형체화 해볼 수 있다.
분석의 편의를 위해 alpine이미지를 베이스이미지로 하는 매우 간단한 이미지를 만들어보자.

FROM alpine:3.16.3

RUN echo "Hellow GeunSam2" > /tmp/test1

/tmp 디렉토리에 test1이라는 텍스트 파일을 만드는 매우 간단한 Dockefile이다.
이미지를 빌드하고 로컬에 이미지를 풀어서 살펴보자.

docker build -t geunsam2/parseimage:v1 . # 이미지 빌드
docker save -o parseimage.tar geunsam2/parseimage:v1
mkdir contents
tar xf parseimage.tar -C ./contents

압축을 풀어보니, 위와 같은 구조가 보인다. 다음과 같이 구조화 할 수 있었다. 더욱 자세한 정보는 문서를 참고하자.

  • manifest.json : 최상위 이미지에 관한 정보
  • [sha256해시].json : Image JSON file이라고 불리고, 이미지와 관련한 메타데이터들이 들어 있었다.
  • repository : 이미지의 이름 및 테그와 관련한 메타데이터가 기록된다.
  • layer디렉토리 : 각각의 디렉토리들이 레이어를 의미한다.
    • VERSION : json file에 관한 스키마 버전
    • json : 레이어에 관한 메타데이터
    • layer.tar : 파일시스템 변경사항이 반영된 tar파일. 실질적인 layer.
  • manifest.json
    //❯ cat manifest.json | jq .
    [
    {
    "Config": "f41387449ef2326dd6285ff289af67ab04c97cc2966db57a53c15dfc115e8966.json",
    "RepoTags": [
      "geunsam2/parseimage:v1"
    ],
    "Layers": [
      "72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556/layer.tar",
      "878794ba68aeeb4197f3a8e82b9cecd6e2d5f44bfd6eabd48b6feb14d4a38f31/layer.tar"
    ]
    }
    ]
  • [sha256해시].json
    //❯ cat f41387449ef2326dd6285ff289af67ab04c97cc2966db57a53c15dfc115e8966.json | jq .
    {
    "architecture": "arm64",
    "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh"
    ],
    "OnBuild": null
    },
    "created": "2023-04-10T16:19:28.050827879Z",
    "history": [
    {
      "created": "2022-11-12T03:39:38.442492606Z",
      "created_by": "/bin/sh -c #(nop) ADD file:57d621536158358b14d15155826ef2dd4ca034278044111ec0aaf6717016e569 in / "
    },
    {
      "created": "2022-11-12T03:39:38.551417892Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
      "empty_layer": true
    },
    {
      "created": "2023-04-10T16:19:28.050827879Z",
      "created_by": "RUN /bin/sh -c echo \"Hellow GeunSam2\" > /tmp/test1 # buildkit",
      "comment": "buildkit.dockerfile.v0"
    }
    ],
    "os": "linux",
    "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:17bec77d7fdc6988cd96b3051b4ad4d3cd6031b2faf0581468be64aac0acc20b",
      "sha256:f3316cf9ca8d22fee8ed31a901811c1cdcc8486297d2ae3a1fd206701a87e232"
    ]
    },
    "variant": "v8"
    }
  • repository
    //❯ cat repositories| jq .
    {
    "geunsam2/parseimage": {
    "v1": "878794ba68aeeb4197f3a8e82b9cecd6e2d5f44bfd6eabd48b6feb14d4a38f31"
    }
    }
  • 각 디렉토리 내부의 json
    //❯ cat json | jq .
    {
    "id": "878794ba68aeeb4197f3a8e82b9cecd6e2d5f44bfd6eabd48b6feb14d4a38f31",
    "parent": "72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556",
    "created": "2023-04-10T16:19:28.050827879Z",
    "container_config": {
    //중략//
    },
    "config": {
    //중략//
    },
    "architecture": "arm64",
    "variant": "v8",
    "os": "linux"
    }

Image JSON file의 diff_ids는 인덱스 순서가 layer의 순서와 같다. 또한 각 layer폴더의 json파일의 내용을 통해서 상위 layer에 대한 정보도 알 수 있다.
layer 파일의 sha256hash 값을 확인해서 새로 빌드하면서 RUN echo "Hellow GeunSam2" > /tmp/test1 구문에 의해 추가된 레이어를 찾아보자.

find ./ -name layer.tar -exec sha256sum {} \;

레이어의 내용을 확인해보면 새로 빌드하면서 증분된 부분에 대한 파일을 찾을 수 있다.

mkdir layerdump
tar xf .//{layer폴더명}/layer.tar -C layerdump
tree layerdump
cat layerdump/tmp/test1

규칙을 알았으니, 툴 없이 이미지에 레이어를 추가해 볼 수 도 있을 것이다.

레이어를 생성하고, hash값을 확인한다.

mkdir -p newlayer/tmp/
echo "Did it work?" > newlayer/tmp/test2
cd newlayer && tar cf ../layer.tar . && cd ../ && rm -rf newlayer
sha256sum layer.tar

확인한 hash값 대로 폴더를 만들고(폴더 이름도 나름의 규칙에 의해 생성된 해시이지만, 중요하지 않으니 layer의 hash), VERSION파일에는 1.0이라는 값을 넣어주고, json파일은 id, parent 필드만 규격대로 작성해 주고 나머지는 구색만 갖추어 본다.
뒤에서 설명하겠지만, 구체적인 값들은 지금 굳이 이해할 필요 없다.

{
  "id": "폴더명",
  "parent": "마지막 레이어의 폴더명",
  "created": "2023-04-10T16:19:28.050827879Z",
  "container_config": {},
  "config": {},
  "architecture": "arm64",
  "variant": "v8",
  "os": "linux"
}

편의상 Image JSON file의 diff_ids에 다른 id의 값을 참고하여 hash를 추가한다. 이 파일의 이름은 사실 파일 자체의 sha256 해시값인데, 우리가 검증할 POC에서는 이 값이 중요하지 않으니 파일명은 무시해도 된다.

...
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:17bec77d7fdc6988cd96b3051b4ad4d3cd6031b2faf0581468be64aac0acc20b",
      "sha256:f3316cf9ca8d22fee8ed31a901811c1cdcc8486297d2ae3a1fd206701a87e232",
      "sha256:c9c4a59822075f6ac3de7bd78fa831dfd534494615b53708e42542dd2108a63d"
    ]
  },
...

manifest.json에도 마찬가지로 새로 추가한 layer.tar의 경로를 넣어주고, 새 태그를 달아준다.

[
  {
    "Config": "f41387449ef2326dd6285ff289af67ab04c97cc2966db57a53c15dfc115e8966.json",
    "RepoTags": [
      "geunsam2/parseimage:v2"
    ],
    "Layers": [
      "72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556/layer.tar",
      "878794ba68aeeb4197f3a8e82b9cecd6e2d5f44bfd6eabd48b6feb14d4a38f31/layer.tar",
      "c9c4a59822075f6ac3de7bd78fa831dfd534494615b53708e42542dd2108a63d/layer.tar"
    ]
  }
]

마지막으로 repositories파일의 태그도 바꿔주고, hash값도 바꿔주자.

{"geunsam2/parseimage":{"v2":"c9c4a59822075f6ac3de7bd78fa831dfd534494615b53708e42542dd2108a63d"}}

이제 전체 폴더의 내용을 tar로 묶으면 완성이다.

tar cf ../parseimage2.tar .
cd ..
file parseimage2.tar

도커로 load해서 새로 추가한 레이어가 잘 반영되었는지 확인해 보자.

docker load < parseimage2.tar
docker run -it geunsam2/parseimage:v2 
docker run -it geunsam2/parseimage:v2 cat /tmp/test2

이제 도구 하나 없이 새로운 컨테이너 이미지까지 만들어 봤다.

CRI와 OCI

OCI라는 단어를 들어본 적이 있는가? CRI는? docker schema2? docker-shim, CRI-O, containerd, OCI Image Spec 등등..
위의 단어들이 어디선가 많이 들어봤는데 명확하게 이해가 안되고, 지금 갑자기 이 이야기를 왜 꺼내는지 이해가 안간다면 아래 내용들을 더 살펴봐야 한다.

사실 지금까지 실습해본 과정들은 오직 docker진영이 발전해 오며 밟은 역사의 일부만을 간접 체험해 본 것이었다.

그게 무슨 말이냐고? 컨테이너 생태계는 docker출범 이후에 폭발적으로 넓어지기 시작했다.
그리고 그 변화의 중심에는 도커와 쿠버네티스가 있었는데, 쿠버네티스에서 docker뿐 아니라 다른 컨테이너 런타임들도 지원해야 하게 되면서 부터 변화가 시작되었다.

CRI와 OCI라는 개념을 이해하기 위한 일들을 빠르게 요약해 보자면

  1. 쿠버네티스는 런타임이 나올 때 마다 kubelet을 수정해야하는 문제를 해결하기 위해 CRI라는 API규격을 만들게 된다.
  2. 컨테이너 런타임이 CRI스펙만 맞춰서 개발하면 kubelet 코드 변화 없이 새로운 런타임을 사용할 수 있게 된다. (Docker의 경우 docker shim이라는 CRI 인터페이스 구현체를 통해 별도 소통하게 되었음)
  3. 동시에 런타임 자체에 대한 표준도 만들기 위한 움직임이 있었고 그 중심에는 Docker가 이었다.
  4. 그래서 생긴 것이 OCI.
  5. docker진영은 dockershim을 버리고, contianerd(도커의 실질적인 컨테이너 런타임이다)를 CRI-Containerd와 결합하여 CRI와 OCI 표준을 완벽하게 지원하도록 발전 시켰다.
  6. 한편 Red Hat / IBM / Intel / SUSE / Hyper ... 등은 연합하여 CRI-O라는 구현체를 개발했다.

참고로 처음에 언급했다시피, 도커는 단순한 컨테이너 런타임이 아니다. 굉장히 다양한 관점의 툴들이 종합예술로 버무려진 툴이다. 그 중, containerd는 OCI 스펙을 준수하며 컨테이너 런타임의 목적에 집중한 컴포넌트이다.

OCI와 CRI를 조금더 자세히 정리해 보자면 다음과 같다.

  • **Open Container Initiative (OCI) :** 컨테이너에 관한 표준
    • runtime-spec : 실행되는 컨테이너의 구성, 환경, 수명주기에 관한 규격
    • image-spec : 컨테이너 이미지에 대한 규격
    • distribution-spec : 콘텐츠의 배포등을 고려한 API프로토콜 스펙에 관한 규격
    • runc : container를 실행/생성 하는 linux레벨의 low level 런타임 <- (참고로 이거 Docker진영에서 OCI스펙 만들면서 기부한거임)
    • go-digest : OCI에서 공통적으로 사용되는 hash
    • artifact : 이미지 레지스트리에 관한 규격
  • Container Runtime Interface (CRI) in Kubernetes: 여러 런타임들을 위한 쿠버네티스 API규격
    • 대표적인 CRI스펙을 따르는 구현체로는 아래 것들이 있음
    • ORI-O
    • containerd

이러한 내용들을 알기 전까지는 아래와 같은 글만 읽고 겉핥기 식으로만 쿠버네티스의 도커 지원 중단에 관한 내용들을 잘못 이해하고 있었어서, 나는 도커가 홍대병 걸려서 누구보다 빠르게 남들과는 다르게 자기만의 길을 걷고 돈만 밝히는 욕심쟁이들인줄 알았는데,
알고보니 컨테이너 생태계에 성장만 촉진한 것이 아니라 생태계 자체를 통합하는 선두 주자로써도 독보적인 엄청난 대인배였던 것이다.

참고로 도커가 OCI프로젝트를 위해 기부한 runc라는 저수준 런타임 도구는 위에서 했던 실습 중, 네임스페이스 격리에 관한 실습에서 했던 pivot_root 등의 기능들을 OCI스펙에 맞추어 좀더 추상화시킨 명령어이다. 찍먹해보자.

mkdir /mycontainer
cd /mycontainer
mkdir rootfs
docker export $(docker create nginx) | tar -C rootfs -xvf -

# config.json 템플릿 생성
runc spec

# container 생성, 시작, 삭제 라이프사이클을 한번에 체험해 볼 수 있는 명령어
runc run mytestcontainer

그럼 다시 본론으로 돌아와서 갑자기 왜 OCI와 CRI 등을 설명했는지 밝히겠다.
사실 docker save, load 명령어를 통해 확인한 도커 이미지는 OCI표준을 만들고 따르기 이전에 Docker에서 사용하던 v1 형태의 스키마였다.

OCI표준을 따르는 컨테이너 이미지 스키마는 전혀 다른 형태를 하고 있는데, 당연히 layer 아키텍쳐를 지원한다는 점은 동일하다.
docker의 내장 툴로는 OCI 표준 이미지 스펙을 까보기 어렵고, skopeo라는 유틸을 활용하면 까볼 수 있다.

mkdir -p parseimage
docker run -it -v ${PWD}/parseimage:/parseimage quay.io/skopeo/stable:latest copy docker://geunsam2/parseimage:v2 oci:parseimage:v2

v1에 비해 훤씬 간결하고 각 레이어를 추적하기 용이해 졌다. 파일의 위치와 구성 내용만 조금 달라졌고, 자세히 들여다보면 담고 있는 내용 자체는 큰 차이가 없다.

  • oci-layout
    {"imageLayoutVersion": "1.0.0"}
  • index.json다음으로 보아야 하는 파일은 index.json에 기록된 digest 해시가 가리키는 파일이다. blobs/sha256 디렉터리에 위치한다. 이 파일이 이미지 메니페스트 파일이다.
    {
    "schemaVersion": 2,
    "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:74dbf31e7bf04c68d3a629ef630cf084744f1bc9ada09cc04e7ad85f56303f03",
      "size": 713,
      "annotations": {
        "org.opencontainers.image.ref.name": "v2"
      }
    }
    ]
    }
  • 이미지 메니페스트 파일이미지 메니페스트 파일이미지 매니페스트 파일까지 열어보고 나면 config와 layers를 구분하여 hash들을 표시해 놓을 것을 볼 수 있다. OCI의 이미지 스펙에서는 각 layer가 tar+gzip 압축이 되어 있는 것도 알 수 있다.
    확인해보자.
    {
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:7c47662f43212a825b796200fd4c5a14c8cf868fe66b3413dacae9d777b4f8d5",
    "size": 912
    },
    "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:6875df1f535433e5affe18ecfde9acb7950ab5f76887980ff06c5cdd48cf98f4",
      "size": 2707756
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:fe9f897b0c15ad82a5bec936c44452d3ead6644c535075d56cb4c02d301e470e",
      "size": 162
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:b4614809e2df2b16e3ceba463d6b71d1b57e7e66ebf0ac5b641cc27bece5fa7e",
      "size": 177
    }
    ]
    }

도커에서는 뭔가 본인들의 시그니처를 남기고 싶었는지 일부 mediaType 필드들에 docker라는 테그를 남기는 걸로 영역 표시를 하는데, 그 이외에는 모든 스펙이 OCI공식 스펙과 동일하다.
mediaType - application/vnd.docker.container.image.v1+json for a container configuration or application/vnd.docker.image.rootfs.diff.tar.gzip for a layer

다양한 이미지 빌드 배포 툴

위의 내용들에서 살펴 본것과 같이 도커는 처음부터 지금까지 컨테이너 생태계에 막대한 영향을 끼치고 있다. docker 런타임 자체를 쓰지 않는다고 해도 도커의 후광에서 벗어나는 것은 쉽지 않다.
컨테이너 이미지를 어떻게 만드는지 상상해보자. 첫 단계가 무엇일까?

Dockerfile이 먼저 생각났다면 당신은 아직 도커 제국의 충신이다.

위의 내용들을 실습하면서 느꼈겠지만, 지금에 와서는 컨테이너 이미지를 빌드 하는 방법에는 Dockerfile을 활용하는 것만 있는 것이 아니다.
손으로도 쉽게(?) 만들어 볼 수 있었던 것 처럼 OCI Image spec을 준수 할 수 있다면, 어떤 규칙을 가진 툴이든 Dockerfile을 만들 수 있는 것이다.

컨테이너 이미지를 만들고 배포하는 목적을 가진 툴들은 docker 외에도 여러가지가 있다.
(당연한 말이지만, 아래 있는 목록이 전부가 아니다.)

툴들의 종류가 많은 만큼 Dockerfile과 비슷한 추상화 수준을 가진 다른 형식의 규칙들도 많다.

예를들면 java프로젝트에서는 jib라는 플러그인을 활용하면 build.gradle 파일에 아래와 같이 설정만 해주면 jar파일을 빌드함과 동시에 컨테이너 이미지로 빌드 후 배포 과정까지 ./gradlew jib 명령어 한번에 할 수도 있게 해주기도 한다.

plugins {
  id 'java'
  id 'com.google.cloud.tools.jib' version '3.3.1'
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
  mavenCentral()
}

dependencies {
  implementation 'com.google.guava:guava:23.6-jre'
}

jib.to.image = 'gcr.io/{REPLACE-WITH-YOUR-GCP-PROJECT}/{IMAGENAME}'

그런가 하면 kaniko와 같은 툴은

  • remote context(git, s3, ...)로 부터 빌드에 필요한 저장소를 불러와서
  • 컨테이너 이미지를 빌드하고
  • 캐시를 remote registry에 관리
    하는 클라우드 환경에서의 빌드에 최적화된 모습을 보여주기도 한다.

그 중에서도 캐시를 remote registry를 통해 관리할 수 있는 기능은 각각의 빌드가 독립적인 환경에서 수행되는 요즘의 클라우드 네이티브한 환경에서는 그 기능이 빌드 시간 단축에 크게 관여하기 때문에 눈여겨 볼 만하다.

사실 이 리서치를 시작하게 된 이유가 내가 개발하고 있는 시스템에서 활용하기에 가장 적합한 빌드 툴을 선정하기 위해 시작한 리서치인데, 정말 멀리도 왔다.

buildkit

그중에서 특별하게 소개하고 싶은 툴이 있다.
눈치 챘을지 모르겠지만 사실 위에서 이미 잠깐 여러 툴들과 함께 소개한 툴 중 하나인데, 바로 builkit이라는 도구이다.
도커는 역시 모듈화의 대장 답게 18.09버전부터 docker는 이미지 빌드 기능만 별도로 분리하여 buildkit이라는 moby(docker진영 오픈소스 프로젝트) 산하 프로젝트 도구를 만들어 관리하고 있다.
그렇다. buildkit은 사실 docker에 포함되어있다.

여러분이 docker로 빌드를 할 때, 옛날에는 그렇게 안보였는데 갑자기 위의 화면처럼 빌드 로그가 스텝별로 트렁킹 되어 보이고, 동시에 여러 스테이지가 빌드 되는 경험을 한 적이 있다면, 당신은 본인도 모르는 사이 buildkit이라는 툴을 사용하고 있었던 것이다.

만약 당신이 도커 18.09버전 이상의 도커를 사용하고 있고, docker명령어를 사용할 때 buildkit을 활용한 빌드를 켜고 끄고 싶다면 DOCKER_BUILDKIT 환경변수를 확인하자.

export DOCKER_BUILDKIT=1 #빌드킷을 활용한 빌드를 활성화

그런데 이 툴이 제공하는 기능이 굉장히 압도적이다.

아이러니 하게도 다른 비교되는 툴들은 최대 한 두개 정도의 프론트엔드만 지원하고, 그 중 일부 툴들은 Dockerfile만을 지원하는 경우도 있는데(ex. kaniko, buildah) 사실 이게 굉장히 웃긴 일이다.

말도 그럴 것이 Dockerfile이라는 규격 자체가 Docker진영에서 자체 개발하고 발전시켜온 것이고, 여기에는 별도의 표준 조차도 없기 때문이다.

# syntax=docker/dockerfile:1
# syntax=docker.io/docker/dockerfile:1
# syntax=example.com/user/repo:tag@sha256:abcdef...

FROM IMAGENAME:TAG
...

도커 진영은 빌드 기능을 buildkit프로젝트로 별도 분리하기 시작하면서 Dockerfile에 버전을 부여하고, 그 버전에 따라 새로운 피쳐들을 공개하기 시작했는데, buildkit을 활용하여 Dockerfile을 빌드할 때 위와 같이 syntax Parser directives를 활용해서 frontend를 파싱할 때 활용할 Dockerfile 버전을 동적으로 지정할 수 있는 기능을 제공한다.

예를들면

  • RUN --mount=... 옵션은 Dockerfile 1.2 이상부터 지원하고,
    (빌드 할때 파일을 COPY하지 않고 다양한 타입으로 마운트하여 이용하는 방식)
  • Dockerfile내에서 Here-Documents를 사용할 수 있는 것은 Dockerfile 1.4.0 이상 부터 가능하다.보다 자세한 내용과 버전에 따른 특별한 피쳐들이 궁금하면 여기를 참고하자.
    # syntax=docker/dockerfile:1.4
    FROM debian
    RUN <<eot bash
    apt-get update
    apt-get install -y vim
    eot

그런데 buildkit을 제외한 다른 툴들은 Dockerfile을 파싱하여 이미지를 빌드하는 방식이 근본적으로 buildkit과 동일하지 않기 때문에, syntax Parser directives 를 통한 Dockerfile 버전에 대한 동적인 지원을 제공하지 않는다.

다음은 Dockerfile을 프론트엔드로 사용하는 다른 오픈소스들의 RUN --mount 옵션에 대한 대응이다.

  • kaniko #1568 <- 참고로 얘는 이 문서 발행일 기준으로 1년째 이슈만 열려있고 고치지도 못했다.
  • buildah #3548

그렇기 때문에 빌드를 위한 frontend로 Dockerfile을 사용하는 이상 docker와의 관계를 정리할 수 없는 것이다.

도커

사실 이 리서치를 시작하는 시점에 나는 도커에 대해서 그렇게 우호적인 입장이 아니었다. 내가 개발하고 있는 배포 시스템에 kaniko라는 툴을 사용하려고 했고, 그 판단을 객관화 하기 위한 리서치였으나 리서치 과정에서 나의 생각은 점점 바뀌었다.

지금껏 이루어진 도커의 행보를 보아 감히 판단하건데, 도커라는 기업은 상당히 그릇이 큰 단체이다.
컨테이너 시대를 본격적으로 이끌었을 뿐 아니라, 지금에 와서는 표준 인프라에 가까운 쿠버네티스와의 긴밀한 호환을 위한 OCI라는 컨테이너 표준을 만들어낸 1등 공신 중 한명이며,
OCI에 runc를 기부하면서 다른 컨테이너 런타임들의 발전에 크게 기여하며 다시 한번 시장을 진두했고,
지금도 컨테이너 생태계에서 다양한 기여를 하며 기술을 이끌고 있다.

그들이 만들어낸 수많은 것들 중 호환되지 않고 독점적인 장악력을 가지고 있는 몇 안되는 규격인 Dockerfile이라는 프론트엔드의 새로운 피쳐가, 다른 툴들과 호환되지 않는것은 그들의 잘못이 아니다. 또는 언젠가 도커의 도움으로 Dockerfile이라는 이름의 프론트엔드가 Containerfile이라는 새로운 프론트엔트 표준으로 발전할지도 모르는 일이다.

앞으로 컨테이너 생태계가 어떻게 될지 모르겠으나, 현재 Dockerfile을 기반으로 동작하는 배포 플랫폼을 개발하고 있는 입장에서 당분간은 docker를 믿고 사용하게 될 듯 하다고 마무리를 지어야 할듯 하다.

참고문헌

반응형