Kubernetes
수정·

Container는 어떻게 동작하는가[1] - 프로세스 격리

by Dohoon Kim · 24년 07월 13일 15:52:44

Container는 어떻게 동작하는가 1편 - 프로세스 격리

컨테이너 기술이 도입된지 10년이 훨씬 넘은 현재, 컨테이너 기술은 거의 모든 분야에서 사용되고 있다. 2014년 학부생 시절 사용하던 맥북에서 오라클이었는지 MySQL이었는지 해당 Database가 지원되지를 않아서 최초로 도커를 사용한 경험이 있다. 이후에도 여러가지로 사용하다가 2020년 첫 직장에서 임베디드 시스템 설계를 하던 당시에도 임베디드 보드 안에서 도는 서비스들까지 Docker를 이용했었고, 현대 계열사와 같이 일을 했을 때도 공장 내부의 모니터링 시스템까지 Kubernetes를 이용하여 동작하고 있었다.

이렇듯 리눅스 컨테이너(LXC)는 컴퓨터로 밥 벌어먹고 사는 사람들에게는 매우 익숙한 요소가 되었지만 여전히 인터넷들에서는 굉장히 모호한 말로 LXC를 설명하고 있는 것을 볼 수 있다. 터미널만 떠서 리소스를 많이 잡아 먹지 않는 VM이다 라는 식으로 설명하는 글도 확인할 수 있었다.

이 글에서는 내가 이해한 수준에서 LXC 기술이 정확히 VM과 어떻게지 설명하고자 한다.

Linux Container 그리고 Virtual Machine의 차이

위 그림은 인터넷에서 흔히 Docker와 같은 LXC 기술과 VM의 차이를 설명하면서 많이들 사용하는 그림이다. 좌측은 일반적인 Virtual Machine의 형태를 나타낸다. 가장 큰 차이점이라고 하면 Hypervisor의 유무가 될 것이다. 우리는 컴퓨터를 하드웨어 리소스에 대한 관리 및 추상화, 그리고 이를 위한 인터페이스 제공하는 운영체제를 이용하여 사용한다. 한 운영체제에서 다른 운영체제를 그 위에서 사용하기 위해 이미 운영체제에 의해 한번 추상화되어 소프트웨어로 제공된 하드웨어 자원을 다시 하드웨어로 추상화하는 작업을 수행해주는 것이 Hypervisor의 역할이다. Hypervisor에 의해 다시 하드웨어로 추상화된 자원들을 이용하여 새로운 운영체제를 그 위에 다시 올리는 것이다. 이 때 하드웨어 장치에 대한 I/O 작업을 Hypervisor가 수행해주는 구조이다. 이런 구조이니 당연하게도 본래 하드웨어의 성능이 나오지 않는 것은 당연하다.

그럼 LXC는 무엇이 다른가?

그림에서 볼 수 있듯 Container Runtime(containrd, cri-o 등) 에 의해 호스트 리눅스에서 프로세스 형태로 실행된다. 이 말은 즉 Host Linux의 커널을 그대로 사용한다는 말이 된다. 하드웨어 자원으로 다시 추상화 하는 과정도 없으니 당연히 성능 하락이 거의 없다.

그래서 Container가 뭔데?

핵심만 놓고 말하면 그냥 리눅스 프로세스이다. 호스트 리눅스 위에서 동작하는 하나의 프로세스가 컨테이너의 실체이다.
Virtual Box를 포함한 OS 가상화 프로그램을 이용하여 새로운 리눅스를 위에 구동시키고
프로세스 목록을 살펴보면 Virtual Box 프로세스가 보일 뿐이지만 Linux 컨테이너는 다르다.

아래 터미널 출력은 블로그의 백엔드 서버를 Docker를 통해 구동시킨 뒤, Host Linux에서 process 목록을 조회한 결과이다.

$ clarinux# ps
 ps
PID   USER     COMMAND
    1 root     init
    ... 수없이 많은 프로세스
    494 root     {docker-compose} /usr/bin/python /usr/bin/docker-compose -f /mnt/extension/docker-compose.yml up # docker compose 
    1446 root     java -jar /app/blog.restapi.jar
    ... 기타 실행 옵션들 

호스트 리눅스 에서 Docker로 실행시킨 Java 어플리케이션의 프로세스가 명확하게 보인다. 이를 통해 우리는 컨테이너가 그저 하나의 호스트 리눅스 상의 프로세스 에 불과함을
알 수 있다.

이제 Ubuntu 이미지를 가지고 컨테이너를 생성하고 Docker 컨테이너에 접속하여 프로세스 목록을 확인해보자.

$ clarinux# docker run -it --rm ubuntu bash
$ root@719a7116ad7c:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 04:46 pts/0    00:00:00 bash
root         9     1  0 04:47 pts/0    00:00:00 ps -ef

해당 컨테이너는 프로세스 목록이 bash 쉘 프로세스 하나와 현재 실행한 명령어 ps -ef 에 관한 프로세스밖에 존재하지 않음을 알 수 있다. 왜 호스트 리눅스에서는 컨테이너의 프로세스가 확인되는데 컨테이너 내부에서는 호스트의 프로세스들이 조회되지 않는걸까?

그건 컨테이너가 호스트 리눅스의 격리된 프로세스이기 때문이다. 컨테이너가 컨테이너인 이유는 컨테이너에 둘러 쌓여 외부와 단절된 것 처럼 해당 프로세스릘 호스트 리눅스와 격리시켜두었기에 그렇게 부르는 것이다.

Container Runtime은 어떻게 프로세스를 격리시키는가?

2013년 Docker가 세상에 모습을 드러냈지만, 사실 기술적 혁신이라기 보다는 있는 기술을 잘 포장한 그런 것에 가깝다고 생각한다. 왜냐하면 컨테이너화를 위한 기술, 즉 프로세스 격리는 리눅스 자체에서 지원하는 기술이기 때문이다. Docker를 비롯한 컨테이너화 소프트웨어들에서 핵심으로 등장하는 키워드들이 있다.

  1. namespace
  2. cgroup

차근차근 하나씩 살펴보도록 하자

Namespace

리눅스 네임스페이스는 프로세스를 격리하고 시스템 리소스들을 분리하여 제공하는 리눅스 커널의 기능이다. 하나의 프로세스는 시스템 리소스(네트워크, IPC, Process ID, Mount, cgroup 등)에 대한 네임스페이스를 지정할 수 있고 이를 통해 호스트 리눅스 상에 동작하는 프로세스와 리소스들에 대한 별도의 뷰를 가지는 형태로 동작한다. 이런 Namespace 정보는 /proc/{pid}에 해당 프로세스가 사용중인 네임스페이스 정보가 담겨있다.

네임 스페이스 중 프로세스 ID, 네트워크 네임스페이스를 살펴보자. 이 홈페이지의 백엔드 서버 프로세스의 네임스페이스를 확인해보면 아래와 같다.

clarinux ps -ef | grep java
  787 root     grep java
 1446 root     java ...대충 옵션들 -jar /app/blog.restapi.jar

clarinux# ls -al /proc/1446/ns
total 0
dr-x--x--x    2 root     root             0 Jul 13 06:23 .
dr-xr-xr-x    9 root     root             0 Jul 13 06:12 ..
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 cgroup -> cgroup:[4026531835]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 ipc -> ipc:[4026532430]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 mnt -> mnt:[4026532428]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 net -> net:[4026532433]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 pid -> pid:[4026532431]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 pid_for_children -> pid:[4026532431]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 time -> time:[4026531834]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 time_for_children -> time:[4026531834]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 user -> user:[4026531837]
lrwxrwxrwx    1 root     root             0 Jul 13 06:23 uts -> uts:[4026532429]

PID 네임스페이스

PID의 번호 공간을 분리하여 호스트와 컨테이너 내부의 프로세스 번호를 다르게 가져간다. 또한 컨테이너 내부에서
외부의 프로세스를 조회할 수 없게 만든다. 또한 컨테이너 내부의 프로세스의 PID가 호스트 프로세스에서의 PID가 달라진다.

위에 컨테이너 내부에서 호스트 리눅스의 프로세스 목록을 조회할 수 없는 이유가 이 PID 네임스페이스로 해당 프로세스가
격리되었기 때문이다.

위에 보인 출력에서 이 서버의 컨테이너가 사용하는 PID 네임스페이스는 4026532431에 해당한다.

네트워크 네임스페이스

PID 뿐만 아니라 네트워크 역시 컨테이너 별로 네트워크 공간을 분리하여 일종의 Private Network를 구성할 수 있게 해준다. Docker 컨테이너를 띄울 때 port를 호스트 리눅스의 특정 포트와 매칭시켜 오픈하지 않으면 네트워크 통신이 되지 않고, 컨테이너 내부에서 IP 주소를 찍어보면 호스트 IP가 할당받은 IP와 다른 주소가 나오는 이유가 이것 때문이다.

cgroup

Cgroup은 시스템 자원을 관리/제어하기 위해 사용되는 제공되는 리눅스 커널의 기능인데
주요 기능은 특정 프로세스 그룹에게 할당되는 시스템 리소스(CPU 사용량, 메모리 사용량 등)을 제한하고
이를 모니터링 하는 등의 기능을 제공한다.

cgroup을 통해 달성할 수 있는 기능들은 아래와 같다.

  1. 리소스 제한 - 특정 프로세스 그룹이 사용하는 시스템 리소스를 제한한다.
  2. 리소스 모니터링 - 특정 프로세스 그룹이 사용하는 시스템 리소스 현황을 모니터링 할 수 있다.
  3. 리소스 격리 - 특정 프로세스 그룹이 사용하는 리소스들을 격리하여 다른 프로세스 그룹에 의한 성능 저하를 방지하고
    안정성을 높일 수 있다.
  4. 리소스 우선순위 관리 - 특정 프로세스 그룹에 대해 리소스 우선순위를 할당함으로써 중요 작업들에 대한 리소스를 우선적으로
    할당할 수 있다.

추후 올릴 포스트에서 다루겠지만 cgroup은 계층구조를 가지고 있어서 한 Cgroup 내에 하위 cgroup이 존재한다. root cgroup이 호스트 리눅스에 해당한다. 또한 시스템 리소스를 컨트롤하는 컨트롤러가 존재한다.

chroot를 이용한 유사한 사용

chroot는 특정 프로세스가 실행될 때 입력한 경로를 새로운 "/" 로 인식하게 해주는 명령어이다. 이 때 특정 프로세스의 하위 프로세스들 역시 이 새로운 경로를 루트로 인식하게 된다.

새로운 루트에서 bash를 실행시켜보자

$clarinux ls /
S999services.log  blog-react        etc               lib               linuxrc           nginx             redis             sbin              tmp
app               crond.reboot      home              lib64             media             opt               root              sys               usr
bin               dev               init              libexec           mnt               proc              run               timesync.log      var

현재 루트는 위와 같은 파일들이 생성되어 있다. 여기에 새로운 루트를 생성해보자
-참고- 현재 서버는 buildroot를 통해 생성한 커스텀 리눅스라서 root로 접속해서 이용중이라 sudo가 필요없다.

$clarinux mkdir -p /home/newroot
$clarinux cp -r /etc /home/newroot
$clarinux cp -r /bin /home/newroot
$clarinux cp -r /lib /home/newroot
$clarinux cp -r /lib64 /home/newroot
$clarinux cp -r /usr /home/newroot
$clarinux mkdir -p /home/newroot/proc /home/newroot/sys /home/newroot/tmp /home/newroot/dev
$clarinux mount --bind /dev /home/newroot/dev
$clarinux mount --bind /proc /home/newroot/proc
$clarinux mount --bind /sys /home/newroot/sys

$clarinux ls /home/newroot
bin    dev    etc    lib    lib64  proc   sys    tmp    usr

자 이제 chroot를 사용하여 /home/newroot를 새로운 루트로 하는 프로세스 bash를 실행시켜보자.

$clarinux chroot /home/newroot bash
bash-5.1# ls
bin    dev    etc    lib    lib64  proc   sys    tmp    usr
bash-5.1# pwd
/

보는바와 같이 새로운 /에서 실행이 되고 있고 실제 호스트 리눅스의 루트가 아닌것을 확인할 수 있다. 이 예시를 드는 이유는 namespace와 cgroup을 이용하여 프로세스를 격리하는 컨테이너 런타임들의 기술을 중
파일 시스템을 격리하는 간단한 예시를 보여주기 위함이다. chroot는 오직 파일시스템만을 격리하기 때문에
완전한 레벨의 격리가 아니니 참고만 하자. 컨테이너 런타임에서는 chroot를 사용하지 않는다.

1편을 마치며

우선은 LXC의 기술이 어떻게 동작하는지 기본적으로 알아야 할 것들을 간단하게 서술해보았다. 이 글을 이해했다면 LXC는 VM과의 차이점은 확실하다. 호스트 리눅스의 커널을 그대로 사용하는 것은 컨테이너가 단순한 프로세스이기 때문에 당연한 것이며 파일시스템, 네트워크, PID, IPC등이 네임스페이스에 의해 분리되어 격리되어 있음을 알 수 있다.

요약하자면 이렇다.

컨테이너화가 VM과 다른점이 뭐에요? 컨테이너는 호스트에서 프로세스로 해당 어플리케이션이 실행돼요.

이후 포스트에서는 격리된 프로세스들의 네트워크 통신이 어떻게 가능한지 알아볼 것이다.