들어가며

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