scratch 도커 이미지를 활용한 초경량 이미지 만들기
들어가며
chroot를 사용한 프로세스의 루트 디렉터리 격리에서는 chroot의 작동 원리와 기본적인 사용법에 대해서 알아보았습니다. chroot를 통한 프로세스 격리는 이미 존재하는 프로그램은 물론 직접 작성한 프로그램에도 적용할 수 있습니다. 이 글에서는 간단한 C 프로그램을 작성하고 이를 동적 링크 컴파일한 경우와 정적 링크 컴파일한 경우로 나눠서 chroot로 실행해봅니다. 또한 이 방법은 도커 이미지에도 그대로 적용하는 것이 가능합니다. 도커에는 scratch라는 빈 이미지가 존재합니다. scratch 이미지에서 컴파일한 C 프로그램을 실행하는 방법을 알아봅니다.
이 글은 generic/ubuntu1804 베이그런트Vagrant 박스에서 테스트되었습니다. 또한 chroot의 기본적인 사용법에 대해서는 이전에 공개한 chroot를 사용한 프로세스의 루트 디렉터리 격리 글을 참고해주세요.
작업 환경 준비: hello.c
아래에는 Hello, world!
를 출력하는 간단한 C 프로그램이 있습니다. 이 파일을 hello.c
로 저장합니다.
리눅스 환경에서 이 파일을 프로그램으로 컴파일하려면 gcc가 설치되어있어야합니다.
$ apt-get update; apt-get install gcc
$ gcc --version
gcc (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
위의 소스코드를 바로 컴파일해보겠습니다. hello.c
파일을 hello
로 컴파일합니다. 그리고 바로 실행해봅니다.
$ gcc -o hello hello.c
$ ./hello
Hello, world!
정상적으로 Hello, world!
가 출력되는 것을 확인할 수 있습니다.
동적 링크 프로그램을 chroot로 실행
그렇다면 이 프로그램을 chroot로 실행할 수 있을까요? hello
가 존재하는 디렉터리를 루트로 chroot
를 실행해보겠습니다.
$ chroot $(pwd) /hello
chroot: failed to run command ‘/hello’: No such file or directory
여기서 $(pwd)
는 pwd
명령어를 실행한 결과(즉, 현재 디렉터리 경로)로 대체됩니다. hello
파일이 없다는 에러 메시지가 출력됩니다. 이는 앞서 bash
를 실행하면서도 확인했던 에러 메시지입니다. 이 문제를 해결하려면 hello
의 의존성들을 현대 디렉터리 아래에 먼저 복사해두어야합니다. ldd
명령어로 동적 링크된 파일들을 확인합니다.
$ ldd hello
linux-vdso.so.1 (0x00007ffcf3266000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6965db8000)
/lib64/ld-linux-x86-64.so.2 (0x00007f69663ab000)
linux-vdso.so.1
은 가상 라이브러리이므로 무시합니다. 그 아래의 파일 2개를 현재 디렉터리 아래에 같은 구조로 복사합니다.
$ mkdir -p ./lib/x86_64-linux-gnu/ ./lib64
$ cp /lib/x86_64-linux-gnu/libc.so.6 ./lib/x86_64-linux-gnu/
$ cp /lib64/ld-linux-x86-64.so.2 ./lib64
$ tree .
.
├── hello
├── hello.c
├── lib
│ └── x86_64-linux-gnu
│ └── libc.so.6
└── lib64
└── ld-linux-x86-64.so.2
이걸로 hello
를 실행하기 위한 준비는 마쳤습니다. 다시 chroot
를 사용해 hello
를 실행해봅니다.
$ chroot $(pwd) /hello
Hello, World!
이번에는 정상적으로 실행되는 것을 확인할 수 있습니다. 앞선 글에서 이미 확인해 보았던 것과 같이 어떤 프로그램을 실행하려면 이와 같이 이 프로그램에서 의존하고 있는 동적 라이브러리를 루트를 기준으로 같은 구조로 준비를 해줄 필요가 있습니다.
동적 링크 프로그램을 scratch 이미지로 만들기
이번에는 같은 프로그램을 도커 이미지로 만들어보겠습니다. 먼저 이 프로그램을 ubuntu:latest
이미지에 추가하는 Dockerfile을 작성해보겠습니다.
FROM ubuntu:latest
ADD hello /hello
CMD /hello
루트 디렉터리 아래에 hello
를 복사해주는 아주 단순한 Dockerflie입니다. 동적 라이브러리는 이미 ubuntu
이미지에 준비되어있으므로 이 프로그램은 정상적으로 실행될 것입니다. 이미지를 빌드하고 실행해봅니다.
$ docker build -t ubuntu:hello .
$ docker run -it ubuntu:hello
Hello, world!
프로그램이 정상적으로 실행됩니다. 하지만 이 이미지는 용량이 상당히 큽니다. images
명령어로 ubuntu 이미지의 용량을 확인해봅니다.
$ docker images | grep ubuntu
ubuntu hello c02a3e568fa8 About a minute ago 85.9MB
ubuntu latest ea4c82dcd15a 6 weeks ago 85.8MB
ubuntu:latest 이미지는 85.8MB, ubuntu:hello 이미지는 85.9MB를 사용하고 있습니다. 하지만 hello 바이너리와 의존성을 포함하더라도 3MB를 넘지 않습니다. 실제로는 우분투 환경을 위한 파일들이 대부분의 용량을 차지하고 있습니다. chroot
로 hello
를 실행해본 경험에 빗대어 볼 때 hello
를 실행하기 위한 최소한의 의존 파일만 준비한다면 이미지 크기는 획기적으로 줄어들 것입니다.
도커에서는 이럴 때 사용할 수 있도록 scratch라는 특별한 이미지를 제공하고 있습니다. 이 이미지는 아무런 파일이 존재하지 않는 비어있는 이미지입니다. chroot
를 사용해본 입장에서 생각해본다면 루트로 사용할 예정이지만 아직 아무것도 없는 빈 디렉터리에 해당합니다.
FROM scratch
ADD lib /lib
ADD lib64 /lib64
ADD hello /hello
CMD ["/hello"]
이번에는 hello
파일을 비롯해 chroot
실행을 위해 준비했던 lib
과 lib64
디렉터리도 함께 복사해줍니다.
$ docker build -t scratch:hello .
$ docker run -it scratch:hello
Hello, world!
이번에도 의도한 대로 동작하는 것을 확인할 수 있습니다. 이제 앞선 ubuntu:hello
이미지와 용량을 비교해붑니다.
$ docker images | grep hello
scratch hello fc4d00b54a50 32 seconds ago 2.21MB
ubuntu hello c02a3e568fa8 11 minutes ago 85.9MB
2.21MB가 되었습니다. 80MB 이상의 용량이 차이가 나는 것을 확인할 수 있습니다. 이것만으로 hello
를 실행하는 것이 가능합니다. 이 방식은 일반적인 애플리케이션 이미지를 만들 때 활용하기에 좋은 방식은 아닙니다. 하지만 바이너리만으로 실행 가능한 프로그램의 경우 이러한 방식으로 최소한의 구성을 하는 것이 가능합니다.
정적 링크 프로그램을 chroot로 실행
이를 좀 더 개선하는 것도 가능합니다. 동적 링크로 컴파일한 프로그램의 경우 동적 라이브러리 파일을 함께 준비해야하는 번거로움이 있습니다. 이를 스테틱 링크로 컴파일하는 경우 동적 라이브러리들이 바이너리에 모두 포함됩니다. gcc의 --static
옵션 하나면 정적 링크로 컴파일하는 것이 가능합니다. 먼저 기존의 hello
파일과 lib
, lib64
디렉터리를 삭제하도록 하겠습니다.
$ rm -rf hello lib lib64
hello.c
파일을 정적 링크 컴파일하고 실행해봅니다.
$ gcc --static -o hello hello.c
$ ./hello
Hello, world!
이 파일에 대해서 ldd를 실행해봅니다.
$ ldd ./hello
not a dynamic executable
동적 링크한 실행 파일과 달리 dynamic executable
이 아니라는 메시지를 출력합니다. 이 실행파일은 정적 컴파일 되었기 때문에 독립적으로 실행하는 것이 가능합니다. tree
로 현재 디렉터리 구조를 확인하고 chroot
를 사용해 hello
를 실행해봅니다.
$ tree .
.
├── Dockerfile
├── hello
└── hello.c
$ chroot $(pwd) /hello
Hello, world!
lib
이나 lib64
와 같은 디렉터리를 준비하지 않았지만 정상적으로 프로그램이 실행되는 것을 확인할 수 있습니다.
정적 링크 프로그램을 scratch 이미지로 만들기
이 hello 파일을 도커 이미지로 만드는 일은 더 간단합니다. 다음과 같이 Dockerfile을 작성합니다.
FROM scratch
ADD hello /hello
CMD ["/hello"]
이거면 충분합니다. 이 이미지를 빌드하고 실행해봅니다.
$ docker build -t scratch:hello2 .
$ docker run -it scratch:hello2
Hello, world!
정상적으로 실행됩니다.
$ docker images | grep hello2
scratch hello2 805ff1bf59b8 47 seconds ago 845kB
이 이미지의 크기는 845kB입니다. hello
의 크기가 곧 이미지의 용량이 됩니다. 동적 라이브러리들을 직접 준비한 경우보다도 용량이 더 작은 것을 확인할 수 있습니다.
마치며
컨테이너를 사용해보면 어떤 애플리케이션을 실행하기 위해 필요한 환경이라는 게 단순히 파일들의 집합이라는 단순한 이치를 깨달을 수 있습니다. 이미지는 정확히 이러한 파일들의 집합에 해당합니다. 도커는 컨테이너(프로세스) 실행과 이를 위한 잘 준비된 이미지들을 제공해주는 애플리케이션입니다만, chroot
와 같은 날 것을 사용해보면 컨테이너의 파일들이 어떻게 구성되어있는지 알아볼 수 있습니다.
지금까지는 최소한의 환경만으로 chroot를 사용하는 법을 알아보았습니다. 다음 글에서는 도커 이미지와 같이 chroot
를 사용하기 위한 환경을 구성하는 방법에 대해서 소개하겠습니다.