15 분 소요


Demystifying Containers - Part I: Kernel Space를 정리한 글입니다.




일련의 블로그 게시물은 컨테이너에 대한 실용적인 관점을 제공하는 것을 목표로 합니다.


1부: 커널 공간(Kernel Space)

이 글은 Linux 커널에 대한 설명으로 컨테이너 이해에 필요한 기초를 소개합니다.
UNIX, Linux의 역사와 chroot, namespacescgroups와 같은 솔루션에 대해 이야기하고, 컨테이너의 내부를 확인할 것입니다.




소개

컨테이너란 구체적으로 무엇일까요? 쿠버네티스에는 컨테이너를 사용하는 이유Docker 에 대한 참고자료 만 찾을 수 있습니다.

Docker 에서는 컨테이너를 소프트웨어의 표준 단위로 설명하지만, 이 설명은 일반적인 개요일 뿐, 근본적인 설명은 아닙니다.
결국 사람들을 컨테이너를 기술적으로 실제와 가깝지 않은, 저렴한 가상 머신으로 상상할 수 있습니다.

컨테이너라는 단어가 정확히 어떤 의미도 아니기 때문에 이렇게 추론할 수 있습니다. 컨테이너 오케스트레이션 생태계의 Pod 라는 단어도 같습니다.

컨테이너란 “공통” 기능 세트를 충족하는 단일 호스트에서 실행되는 프로세스들의 격리된 그룹입니다.
이러한 멋진 기능 중 일부는 Linux 커널에 직접 내장되어 있으며 대부분 다른 역사적인 기원들을 가지고 있습니다.

컨테이너는 다음 네 가지 요구 사항을 충족해야 합니다:

  1. 협상 불가(Not negotiable): 단일 호스트에서 실행해야 합니다.
    따라서 두 대의 컴퓨터는 단일 컨테이너를 실행할 수 없습니다.
  2. 분명하게(Clearly): 그들은 프로세스 그룹입니다.
    Linux 프로세스는 트리 구조 내부에 있으므로 컨테이너는 루트 프로세스가 있습니다.
  3. OK: 그들은 격리되어야 합니다.
  4. 명확하지 않음(Not so clear): 공통 기능을 충족해야 합니다.
    일반적으로 기능은 시간이 지남에 따라 변경되는 것처럼 보이므로 가장 일반적인 기능이 무엇인지 주목해야 합니다.

그림. 컨테이너

이러한 요구 사항만으로는 혼란을 야기할 수 있으며 아직 그림이 명확하지 않습니다.
따라서 역사적 시작부터 설명하여 단순하게 정리하겠습니다.




chroot

대부분의 UNIX OS는 현재 실행 중인 프로세스(와 그 하위)의 루트 디렉토리를 변경할 수 있습니다.

Linux에서는 chroot(2)를 시스템 호출(커널 API 함수 호출) 또는 독립형 래퍼 프로그램으로 사용할 수 있습니다.
Chroot는 1991년에 보안 해커를 모니터링하기 위해 허니팟(honeypot)으로 사용했기 때문에 “감옥(jail)”이라고도 합니다.
Chroot는 현재 다양한 배포판을 위한 빌드 서비스와 같은 광범위한 앱에서 사용됩니다.

다음과 같은 것들이 이미 작동하기 때문에 자체 chroot 환경을 실행하기 위해 필요한 것은 그다지 많지 않습니다:

> mkdir -p new-root/{bin,lib64}
> cp /bin/bash new-root/bin
> cp /lib64/{ld-linux-x86-64.so*,libc.so*,libdl.so.2,libreadline.so*,libtinfo.so*} new-root/lib64
> sudo chroot new-root

새 루트 디렉터리를 만들고 bash 셸과 해당 종속성을 복사한 다음 chroot를 실행합니다.
우리가 얻을 수 있는 것은 bash와 cdpwd와 같은 내장 함수뿐이므로 이 감옥은 많은 도움이 되지 않습니다:

그림. chroot

감옥에서 정적으로 연결된 바이너리를 실행하는 것이 가치가 있고 컨테이너 이미지를 실행하는 것과 동일하다고 생각할 수도 있지만, 절대 아닙니다.
감옥은 실제로 독립형 보안 기능이 아니라 컨테이너 세계에 더 좋은 추가 기능입니다.

현재 작업 디렉토리는 syscall을 통해 chroot를 호출할 때 변경되지 않은 상태로 유지되는 반면 상대 경로는 여전히 새 루트 외부의 파일을 참조할 수 있습니다.

네트워크 격리도 없습니다. 감옥을 떠날 수 있는 능력과 짝을 이루는 이 격리 누락은 교도소가 때때로 잘못된(보안 관련) 목적으로 사용되기 때문에 많은 보안 관련 문제로 이어집니다. 이 문제를 해결하기 위해 Linux Namespace를 사용합니다.




Linux Namespaces

네임스페이스는 2002년 Linux 2.4.19와 함께 도입된 Linux 커널 기능입니다. 네임스페이스의 아이디어는 특정 전역 시스템 리소스를 추상화 계층에서 래핑하는 것입니다. 이렇게 하면 네임스페이스 내의 프로세스에 리소스의 자체 격리 인스턴스가 있는 것처럼 보입니다. 커널 네임스페이스 추상화를 통해 서로 다른 프로세스 그룹이 시스템에 대해 서로 다른 뷰를 가질 수 있습니다.

네임스페이스가 처음부터 모두 구현되지는 않았습니다. 우리가 지금 “컨테이너 준비(Container Ready)“로 이해하는 것에 대한 완전한 지원은 user namespace의 도입과 함께 2013년 커널 버전 3.8에서 완료되었습니다. 현재는 mnt, pid, net, ipc, uts, user 및 cgroup 으로 7개의 고유한 네임스페이스가 구현되었습니다. 2016년 9월에는 아직 완전히 구현되지 않은 두 개의 추가 네임스페이스(time 및 syslog)가 제안되었습니다.  특정 네임스페이스를 살펴보기 전에 네임스페이스 API를 살펴보겠습니다.




API

Linux 커널의 네임스페이스 API는 세 가지 주요 시스템 호출로 구성됩니다.

clone

clone(2) API 함수는 fork(2)와 유사한 방식으로 새 하위 프로세스를 만듭니다. fork(2)와 달리 clone(2)API를 사용하면 하위 프로세스가 메모리 공간, 파일 설명자 테이블 및 시그널 핸들러 테이블과 같은 실행 컨텍스트(execution context)의 일부를 호출 프로세스와 공유할 수 있습니다. 다른 네임스페이스 플래그를 clone(2)에 전달하여 하위 프로세스에서 사용할 새 네임스페이스를 만들 수 있습니다.

그림. namespace api clone


unshare

unshare(2)함수를 사용하면 프로세스가 현재 다른 사람과 공유되고 있는 실행 컨텍스트의 일부를 분리(disassociate)할 수 있습니다.

그림. namespace api unshare


setns

setns(2)함수는 호출 스레드를 제공된 네임스페이스 파일 설명자와 다시 연결(reassociates)합니다.  이 함수는 기존 네임스페이스를 결합하는 데 사용할 수 있습니다.

그림. namespace api setns


proc

사용 가능한 syscall 외에도 proc파일 시스템은 추가적인 네임스페이스 관련 파일들을 채웁니다. Linux 3.8부터 /proc/$PID/ns 내의 각 파일은 참조된 네임스페이스에 대한 작업(예: setns(2))을 수행하기 위한 핸들로 사용할 수 있는 “마법의” 링크입니다.

> ls -Gg /proc/self/ns/
total 0
lrwxrwxrwx 1 0 Feb  6 18:32 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 0 Feb  6 18:32 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 0 Feb  6 18:32 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 0 Feb  6 18:32 net -> 'net:[4026532008]'
lrwxrwxrwx 1 0 Feb  6 18:32 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 0 Feb  6 18:32 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 0 Feb  6 18:32 user -> 'user:[4026531837]'
lrwxrwxrwx 1 0 Feb  6 18:32 uts -> 'uts:[4026531838]'

이를 통해 특정 프로세스가 있는 네임스페이스를 추적할 수도 있습니다. 프로그래밍 방식과 별도로 네임스페이스를 사용하는 또 다른 방법은 util-linux 패키지에 있는 도구를 사용하는 것입니다. 여기에는 syscall에 대한 전용 래퍼 프로그램이 포함되어 있습니다. 이 패키지 내의 네임스페이스와 관련된 편리한 도구 중 하나는 lsns입니다. 현재 접근할 수 있는 모든 네임스페이스 또는 지정된 단일 네임스페이스에 대한 유용한 정보를 나열합니다.

이제 직접 사용해보겠습니다.




사용가능한 Namespaces

mnt

mnt네임스페이스는 2002년에 처음으로 구현되었습니다.
그 시기에는 아무도 여러 네임스페이스가 필요할 것으로 생각하지 않았기 때문에 네임스페이스 복제 플래그 CLONE_NEWNS를 호출했습니다.

이로 인해 다른 네임스페이스 복제 플래그와 일치하지 않아서 고통을 겪고 있습니다!  mnt 네임스페이스를 사용하여 Linux는 프로세스 그룹 별로 마운트 포인트 세트를 분리할 수 있습니다.

mnt 네임스페이스는 감옥과 유사하지만 더 안전한 방식으로 환경을 만듭니다.
API 함수 호출 또는 unshare 커맨드라인 도구를 통해 쉽게 만들 수 있습니다:

sudo unshare -m
# mkdir mount-dir
# mount -n -o size=10m -t tmpfs tmpfs mount-dir
# df mount-dir
Filesystem     1K-blocks  Used Available Use% Mounted on
tmpfs              10240     0     10240   0% <PATH>/mount-dir
# touch mount-dir/{0,1,2}

호스트 시스템 수준에서 사용할 수 없는, 성공적으로 마운트된 tmpfs가 있는 것 같습니다.

> ls mount-dir
> grep mount-dir /proc/mounts

마운트 포인트에 사용되는 실제 메모리는 VFS(가상 파일 시스템)라는 추상화 계층에 배치되며, 이 계층은 커널의 일부이며 다른 모든 파일 시스템의 기반이 됩니다.
네임스페이스가 파괴되면 마운트 메모리가 복구 불가능하게 손실됩니다. 마운트 네임스페이스 추상화는 루트 권한 없이도 루트 사용자인 전체 가상 환경을 생성할 수 있는 가능성을 제공합니다.

호스트 시스템에서 proc 파일 시스템 내부의 mountinfo 파일을 통해 마운트 포인트를 볼 수 있습니다:

> grep mount-dir /proc/$(pgrep -u root bash)/mountinfo
349 399 0:84 / /mount-dir rw,relatime - tmpfs tmpfs rw,size=1024k

소스 코드 수준에서 이러한 마운트 포인트로 작업하는 방법은 무엇일까요? 프로그램은 사용된 네임스페이스를 참조하는 해당 /proc/$PID/ns/mnt 파일에 파일 핸들을 유지하는 경향이 있습니다.

결국 mnt 네임스페이스 관련 구현 시나리오는 매우 복잡할 수 있지만 유연한 컨테이너 파일 시스템 트리를 생성할 수 있는 능력을 제공합니다. 마지막으로 언급하고 싶은 것은 마운트가 서로 다른 특징(shared, slave, private, unbindable)을 가질 수 있다는 것입니다.  이는 Linux 커널의 공유 하위 트리 문서 에 가장 잘 설명되어 있습니다.


UNIX Time-sharing System (uts)

UTS 네임스페이스는 Linux 2.6.19(2006)에 도입되었으며 현재 호스트 시스템에서 도메인 및 호스트 이름을 unshare 할 수 있습니다. 시도해 보겠습니다:

> sudo unshare -u
# hostname
nb
# hostname new-hostname
# hostname
new-hostname

그리고 시스템 레벨에서 보면 아무 것도 변경되지 않았습니다. 만세:

> hostname
nb

UTS Namespace는 특히 컨테이너 네트워킹 주제와 관련하여 컨테이너화의 또 다른 멋진 추가 기능입니다.


Interprocess Communication (ipc)

IPC 네임스페이스도 Linux 2.6.19(2006)에 제공되었으며 IPC(프로세스 간 통신) 리소스를 격리합니다.
이들은 System V IPC 객체와 POSIX 메시지 대기열입니다. 이 네임스페이스의 한 가지 사용 사례는 오용을 방지하기 위해 두 프로세스 간에 공유 메모리(SHM)를 분리하는 것입니다. 각 프로세스는 공유 메모리 세그먼트에 대해 동일한 식별자를 사용하고 두 개의 개별 영역을 생성할 수 있습니다. IPC네임스페이스가 소멸되면 네임스페이스의 모든 IPC 개체도 자동으로 소멸됩니다.


Process ID (pid)

PID 네임스페이스는 Linux 2.6.24(2008)에 도입되었으며 프로세스에 독립적인 PID(프로세스 식별자) 집합을 제공합니다.   이는 다른 네임스페이스에 있는 프로세스가 동일한 PID를 소유할 수 있음을 의미합니다.   결국 프로세스에는 두 개의 PID가 있습니다. 하나는 네임스페이스 내부의 PID이고 다른 하나는 호스트 시스템의 네임스페이스 외부에 있는 PID입니다. PID 네임스페이스는 중첩될 수 있으므로 새 프로세스가 생성되면 현재 네임스페이스에서 초기 PID 네임스페이스까지 각 네임스페이스에 대한 PID가 있습니다.

그림. namespace api pid

PID 네임스페이스에서 생성된 첫 번째 프로세스는 숫자 1을 얻고 일반적인 초기화 프로세스와 동일한 특수 처리를 얻습니다. 예를 들어, 네임스페이스 내의 모든 프로세스는 호스트 PID 1이 아닌 네임스페이스의 PID 1로 다시 부모가 지정됩니다. 또한 이 프로세스가 종료되면 PID 네임스페이스 및 모든 하위 항목의 모든 프로세스가 즉시 종료됩니다. 새 PID 네임스페이스를 생성해 보겠습니다:

> sudo unshare -fp --mount-proc
# ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.4  0.6  18688  6608 pts/0    S    23:15   0:00 -bash
root        39  0.0  0.1  35480  1768 pts/0    R+   23:15   0:00 ps aux

격리된 것 같나요? --mount-proc 플래그는 새 네임스페이스에서 proc 파일 시스템을 다시 마운트하는 데 필요합니다.  그렇지 않으면 네임스페이스에 해당하는 PID 하위 트리가 표시되지 않습니다. 또 다른 옵션은 mount -t proc proc /proc을 통해 proc 파일 시스템을 수동으로 마운트하는 것이지만, 이것은 나중에 다시 마운트해야 하는 호스트의 마운트도 무시합니다.


Network (net)

Network Namespace는 Linux 2.6.29(2009)에서 완료되었으며 네트워크 스택을 가상화하는 데 사용할 수 있습니다. 각 Network Namespace/proc/net 내에 자체 리소스 속성을 포함합니다. 또한 Network Namespace에는 초기 생성 시 루프백 인터페이스만 포함됩니다. 하나를 만들어 보겠습니다.

> sudo unshare -n
# ip l
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

모든 네트워크 인터페이스(물리적 또는 가상)는 네임스페이스당 정확히 한 번만 존재합니다. 인터페이스는 네임스페이스 간에 이동할 수 있습니다. 각 네임스페이스에는 개인용 IP 주소 세트, 자체 라우팅 테이블, 소켓 목록, 연결 추적 테이블, 방화벽 및 기타 네트워크 관련 리소스가 포함되어 있습니다.

Network 네임스페이스를 파괴하면 가상 인터페이스가 파괴되고 그 안에 있는 물리적 인터페이스가 초기 Network 네임스페이스로 다시 이동합니다.

Network 네임스페이스의 가능한 사용 사례는 가상 이더넷(veth) 인터페이스 쌍을 통해 소프트웨어 정의 네트워크(SDN)를 생성하는 것입니다. 네트워크 쌍의 한쪽 끝은 브리지 인터페이스에 연결되고 다른 쪽 끝은 대상 컨테이너에 할당됩니다. 이것이 flannel 과 같은 Pod 네트워크 가 일반적으로 작동하는 방식입니다.

p3.namespace.network

그림. namespace api pid

어떻게 작동하는지 보겠습니다. 먼저 ip를 사용하여 수행할 수 있는 새 Network 네임스페이스를 만들어야 합니다:

> sudo ip netns add mynet
> sudo ip netns list
mynet

mynet이라는 새로운 Network 네임스페이스를 만들었습니다.
ipNetwork 네임스페이스를 생성하면 /var/run/netns 아래에도 이에 대한 바인드 마운트가 생성됩니다.
이렇게 하면 네임스페이스에서 실행 중인 프로세스가 없는 경우에도 네임스페이스를 유지할 수 있습니다.

ip netns exec를 사용하면 Network Namespace를 더 자세히 검사하고 조작할 수 있습니다.

> sudo ip netns exec mynet ip l
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
> sudo ip netns exec mynet ping 127.0.0.1
connect: Network is unreachable

네트워크가 다운된 것 같습니다. 다시 시작하겠습니다:

> sudo ip netns exec mynet ip link set dev lo up
> sudo ip netns exec mynet ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.016 ms

만세! 이제 나중에 통신을 허용해야 하는 ves 쌍을 생성해 보겠습니다:

> sudo ip link add veth0 type veth peer name veth1
> sudo ip link show type veth
11: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether b2:d1:fc:31:9c:d3 brd ff:ff:ff:ff:ff:ff
12: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether ca:0f:37:18:76:52 brd ff:ff:ff:ff:ff:ff

즉, veth0 으로 보낸 패킷은 veth1 에서 수신되고 그 반대의 경우도 마찬가지입니다.
이제 ve 쌍의 한쪽 끝을 Network 네임스페이스에 연결합니다:

> sudo ip link set veth1 netns mynet
> ip link show type veth
12: veth0@if11: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether ca:0f:37:18:76:52 brd ff:ff:ff:ff:ff:ff link-netns mynet

우리의 네트워크 인터페이스에는 다음과 같은 몇 가지 주소가 필요합니다:

> sudo ip netns exec mynet ip addr add 172.2.0.1/24 dev veth1
> sudo ip netns exec mynet ip link set dev veth1 up
> sudo ip addr add 172.2.0.2/24 dev veth0
> sudo ip link set dev veth0 up

이제 양방향 통신이 가능해야 합니다:

> ping -c1 172.2.0.1
PING 172.2.0.1 (172.2.0.1) 56(84) bytes of data.
64 bytes from 172.2.0.1: icmp_seq=1 ttl=64 time=0.036 ms

--- 172.2.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.036/0.036/0.036/0.000 ms
> sudo ip netns exec mynet ping -c1 172.2.0.2
PING 172.2.0.2 (172.2.0.2) 56(84) bytes of data.
64 bytes from 172.2.0.2: icmp_seq=1 ttl=64 time=0.020 ms

--- 172.2.0.2 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.020/0.020/0.020/0.000 ms

이제 작동하지만 Network 네임스페이스에서 인터넷에 액세스할 수는 없습니다.  이를 위해 네트워크 브리지 또는 이와 유사한 것이 필요하고 네임스페이스의 기본 경로가 필요합니다.  이 작업은 여러분에게 맡기고 다음 네임스페이스로 넘어가겠습니다.


User ID (user)

Linux 3.5(2012)에서는 네임스페이스를 통해 사용자 및 그룹 ID를 분리할 수 있었습니다.  Linux 3.8(2013)에서는 실제로 권한이 없어도 User 네임스페이스를 생성할 수 있었습니다. User 네임스페이스를 사용하면 프로세스의 사용자 및 그룹 ID가 네임스페이스 내부와 외부에서 다를 수 있습니다. 흥미로운 사례는 프로세스가 User 네임스페이스 외부에서 권한이 없는 일반 사용자 ID를 가질 수 있는 반면 내부에서는 완전한 권한을 가질 수 있다는 것입니다.

시도해 보겠습니다:

> id -u
1000
> unshare -U
> whoami
nobody

네임스페이스 생성 후 파일 /proc/$PID/{u,g}id_map은 PID에 대한 사용자 및 그룹 ID에 대한 매핑을 노출합니다. 이러한 파일은 매핑을 정의하기 위해 한 번만 쓸 수 있습니다.

일반적으로 이러한 파일의 각 줄에는 두 User 네임스페이스 간의 연속 사용자 ID 범위에 대한 일대일 매핑이 포함되며 다음과 같을 수 있습니다:

> cat /proc/$PID/uid_map
0 1000 1

위의 결과를 설명하면, 시작 사용자 ID가 0인 네임스페이스는 ID 1000에서 시작하는 범위에 매핑됩니다.
정의된 길이가 1이므로 ID가 1000인 사용자에게만 적용됩니다.

이제 프로세스가 파일에 접근하려고 하면 권한 확인을 위해 해당 사용자 및 그룹 ID가 초기 사용자 네임스페이스에 매핑됩니다. 프로세스가 stat(2)를 통해 파일 사용자 및 그룹 ID를 검색할 때 ID는 반대 방향으로 매핑됩니다.

앞에서 수행한 unshare 예제에서 적절한 사용자 매핑을 작성하기 전에 getuid(2)를 암시적으로 호출하여 매핑되지 않은 ID가 생성됩니다. 매핑되지 않은 이 ID는 오버플로 사용자 ID(65534 또는 의 값 /proc/sys/kernel/overflow{g,u}id)로 자동 변환됩니다.

파일 /proc/$PID/setgroups에는 User 네임스페이스 내에서 syscall 을 호출할 수 있는 권한을 활성화하거나 비활성화하는 allow 또는 deny 중 하나가 포함되어 있습니다.  이 파일은 User Namespace의 보안 문제를 해결하기 위해 추가되었습니다: 권한이 없는 프로세스가 사용자에게 모든 권한이 있는 새 네임스페이스를 생성할 수 있습니다. 이전에 권한이 없었던 이 사용자는 setgroups(2)를 통해 그룹을 삭제하여 이전에 소유하지 않은 파일에 액세스할 수 있습니다.

결국 User 네임스페이스는 root 없이(rootless) 컨테이너를 실행하는 데 필수적인 컨테이너 세계에 대한 강력한 보안을 추가해 줍니다.


Control Group (cgroup)

Cgroups는 2008년 Linux 2.6.24를 Linux 전용 커널 기능으로 시작되었습니다.  cgroups의 주요 목표는 리소스 제한, 우선 순위 지정, 어카운팅 및 제어를 지원하는 것입니다. 버전 2의 주요 재설계는 2013년에 시작되었고 cgroup 네임스페이스는 호스트 정보가 네임스페이스로 누출되는 것을 방지하기 위해 Linux 4.6(2016)에 추가되었습니다. cgroups의 두 번째 버전도 그 때 출시되었으며 그 이후로 주요 기능이 추가되었습니다. 최근의 예로는 작업 부하의 전체 무결성을 보장하기 위해 단일 단위로 cgroup을 종료하는 기능을 하는 메모리 부족(OOM, Out-of-Memory) 킬러입니다.

cgroup을 사용하여 새로운 것을 만들어 봅시다.
커널은 /sys/fs/cgroup 으로 cgroup을 노출합니다.  새 cgroup을 만들려면 해당 위치에 새 하위 디렉터리를 만들기만 하면 됩니다.

> sudo mkdir /sys/fs/cgroup/memory/demo
> ls /sys/fs/cgroup/memory/demo
cgroup.clone_children
cgroup.event_control
cgroup.procs
memory.failcnt
memory.force_empty
memory.kmem.failcnt
memory.kmem.limit_in_bytes
memory.kmem.max_usage_in_bytes
memory.kmem.slabinfo
memory.kmem.tcp.failcnt
memory.kmem.tcp.limit_in_bytes
memory.kmem.tcp.max_usage_in_bytes
memory.kmem.tcp.usage_in_bytes
memory.kmem.usage_in_bytes
memory.limit_in_bytes
memory.max_usage_in_bytes
memory.move_charge_at_immigrate
memory.numa_stat
memory.oom_control
memory.pressure_level
memory.soft_limit_in_bytes
memory.stat
memory.swappiness
memory.usage_in_bytes
memory.use_hierarchy
notify_on_release
tasks

이미 일부 기본값이 노출되어 있습니다. 이제 해당 cgroup에 대한 메모리 제한을 설정할 수 있습니다.   예제 구현이 작동하도록 스왑을 끕니다:

> sudo su
# echo 100000000 > /sys/fs/cgroup/memory/demo/memory.limit_in_bytes
# echo 0 > /sys/fs/cgroup/memory/demo/memory.swappiness

cgroup에 프로세스를 할당하기 위해 해당 PID를 cgroup.procs 파일에 쓸 수 있습니다:

# echo $$ > /sys/fs/cgroup/memory/demo/cgroup.procs

이제 샘플 앱을 실행하여 허용된 100MB 이상의 메모리를 사용할 수 있습니다. 내가 사용한 앱은 Rust로 작성되었으며 다음과 같습니다:

pub fn main() {
    let mut vec = vec![];
    loop {
        vec.extend_from_slice(&[1u8; 10_000_000]);
        println!("{}0 MB", vec.len() / 10_000_000);
    }
}

프로그램을 실행하면 설정된 메모리 제약으로 인해 PID가 종료되는 것을 볼 수 있습니다. 따라서 호스트 시스템은 여전히 사용할 수 있습니다:

# rustc memory.rs
# ./memory
10 MB
20 MB
30 MB
40 MB
50 MB
60 MB
70 MB
80 MB
90 MB
Killed




Namespace 구성하기

네임스페이스도 구성할 수 있습니다!
이것은 그들의 진정한 힘을 드러내고 쿠버네티스 Pod 에서와 같이 동일한 네트워크 인터페이스를 공유하는 격리된 PID 네임스페이스를 가질 수 있게 합니다.

이를 시연하기 위해 분리된 PID를 사용하여 새 네임스페이스를 생성해 보겠습니다:

> sudo unshare -fp --mount-proc
# ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.1  0.6  18688  6904 pts/0    S    23:36   0:00 -bash
root        39  0.0  0.1  35480  1836 pts/0    R+   23:36   0:00 ps aux

setns(2) syscall과 래퍼 프로그램 nsenter를 사용하여 네임스페이스에 가입(Join)할 수 있습니다.
이를 위해 우리는 가입할 네임스페이스를 찾아야 합니다:

> export PID=$(pgrep -u root bash)
> sudo ls -l /proc/$PID/ns

이제 nsenter를 통해 네임스페이스에 쉽게 가입할 수 있습니다:

> sudo nsenter --pid=/proc/$PID/ns/pid unshare --mount-proc
# ps aux
root         1  0.1  0.0  10804  8840 pts/1    S+   14:25   0:00 -bash
root        48  3.9  0.0  10804  8796 pts/3    S    14:26   0:00 -bash
root        88  0.0  0.0   7700  3760 pts/3    R+   14:26   0:00 ps aux

이제 동일한 PID Namespace의 구성원임을 알 수 있습니다! nsenter를 통해 이미 실행 중인 컨테이너를 입력하는 것도 가능합니다.




데모 앱

네임스페이스 API를 통해 간단한 격리 환경을 만드는 작은 데모 앱을 작성할 수 있습니다:

#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/msg.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define STACKSIZE (1024 * 1024)
static char stack[STACKSIZE];
void print_err(char const * const reason)
{
    fprintf(stderr, "Error %s: %s\n", reason, strerror(errno));
}
int exec(void * args)
{
    // Remount proc
    if (mount("proc", "/proc", "proc", 0, "") != 0) {
        print_err("mounting proc");
        return 1;
    }
    // Set a new hostname
    char const * const hostname = "new-hostname";
    if (sethostname(hostname, strlen(hostname)) != 0) {
        print_err("setting hostname");
        return 1;
    }
    // Create a message queue
    key_t key = {0};
    if (msgget(key, IPC_CREAT) == -1) {
        print_err("creating message queue");
        return 1;
    }
    // Execute the given command
    char ** const argv = args;
    if (execvp(argv[0], argv) != 0) {
        print_err("executing command");
        return 1;
    }
    return 0;
}
int main(int argc, char ** argv)
{
    // Provide some feedback about the usage
    if (argc < 2) {
        fprintf(stderr, "No command specified\n");
        return 1;
    }
    // Namespace flags
    const int flags = CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWNS | CLONE_NEWIPC |
                      CLONE_NEWPID | CLONE_NEWUSER | SIGCHLD;
    // Create a new child process
    pid_t pid = clone(exec, stack + STACKSIZE, flags, &argv[1]);
    if (pid < 0) {
        print_err("calling clone");
        return 1;
    }
    // Wait for the process to finish
    int status = 0;
    if (waitpid(pid, &status, 0) == -1) {
        print_err("waiting for pid");
        return 1;
    }
    // Return the exit code
    return WEXITSTATUS(status);
}

앱의 목적은 다른 네임스페이스에서 새 하위 프로세스를 생성하는 것입니다.
실행 파일에 제공된 모든 명령은 새 하위 프로세스로 전달됩니다. 명령 실행이 완료되면 앱이 종료됩니다.

다음 명령을 통해 테스트하고 확인할 수 있습니다:

> gcc -o namespaces namespaces.c
> ./namespaces ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
> ./namespaces ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
nobody       1  0.0  0.1  36524  1828 pts/0    R+   23:46   0:00 ps aux
> ./namespaces whoami
nobody

이것은 실제로 작동하는 컨테이너는 아니지만 컨테이너 런타임이 네임스페이스를 활용하여 컨테이너를 관리하는 방법에 대해 약간의 느낌을 줄 것입니다.
이 예제를 네임스페이스 API에 대한 작은 실험의 시작점으로 자유롭게 사용하십시오.




정리하기

chroot 섹션의 이미지에서 추출한 rootfs를 기억하십니까?
runc와 같은 저수준 컨테이너 런타임을 사용하여 rootfs에서 컨테이너를 쉽게 실행할 수 있습니다:

> sudo runc run -b bundle container

이제 시스템 네임스페이스를 검사하면 runc가 이미 mnt, uts, ipc, pidnet을 생성했음을 알 수 있습니다:

> sudo lsns | grep bash
4026532499 mnt         1  6409 root   /bin/bash
4026532500 uts         1  6409 root   /bin/bash
4026532504 ipc         1  6409 root   /bin/bash
4026532505 pid         1  6409 root   /bin/bash
4026532511 net         1  6409 root   /bin/bash

여기서 멈추고 컨테이너 런타임과 컨테이너 런타임이 하는 일에 대해 다음 블로그 게시물과 강연에서 자세히 알아볼 것입니다.




결론

이 글을 통해 컨테이너에 대한 미스터리를 이제 조금 더 이해할 수 있기를 바랍니다.
Linux를 사용하면 처음부터 다양한 격리 기술을 사용하는 것이 쉽습니다. 결국 컨테이너 런타임은 컨테이너를 위한 안정적이고 강력한 개발 및 프로덕션 플랫폼을 제공하기 위해 다양한 추상화 수준에서 이러한 모든 격리 기능을 훌륭하게 사용합니다.

안정적인 세부 수준을 유지하고 싶었기 때문에 여기에서 다루지 않은 많은 주제가 있습니다.
확실히 Linux 네임스페이스 주제를 더 깊이 파고들 수 있는 훌륭한 리소스는 Linux 프로그래머 매뉴얼인 NAMESPACES(7)입니다.

다음 블로그 게시물에서는 최신 컨테이너 기술과 관련된 컨테이너 런타임, 보안 및 전체 에코시스템을 다룹니다. 계속 지켜봐 주세요!

댓글남기기