Canvas 1 Layer 1

정적 링크 프로그램을 chroot와 도커(Docker) 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를 넘지 않습니다. 실제로는 우분투 환경을 위한 파일들이 대부분의 용량을 차지하고 있습니다. chroothello를 실행해본 경험에 빗대어 볼 때 hello를 실행하기 위한 최소한의 의존 파일만 준비한다면 이미지 크기는 획기적으로 줄어들 것입니다.

도커에서는 이럴 때 사용할 수 있도록 scratch라는 특별한 이미지를 제공하고 있습니다. 이 이미지는 아무런 파일이 존재하지 않는 비어있는 이미지입니다. chroot를 사용해본 입장에서 생각해본다면 루트로 사용할 예정이지만 아직 아무것도 없는 빈 디렉터리에 해당합니다.

FROM scratch
ADD lib /lib
ADD lib64 /lib64
ADD hello /hello
CMD ["/hello"]

이번에는 hello 파일을 비롯해 chroot 실행을 위해 준비했던 liblib64 디렉터리도 함께 복사해줍니다.

$ 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를 사용하기 위한 환경을 구성하는 방법에 대해서 소개하겠습니다.