soobook
BACKEND

cgroup and namespace

컨테이너를 만드는 두 커널 기능 — namespace는 보이는 것을, cgroup은 쓰는 양을 격리한다

컨테이너라는 별도의 실체는 커널에 없다. namespace가 프로세스마다 다른 세계를 보여주고, cgroup이 그 프로세스가 쓸 자원의 양을 묶는다. 이 둘을 합쳐 부르는 이름이 컨테이너다.

Intro

KVM과 QEMU 글에서 컨테이너로는 안 되는 자리에 VM을 쓴다고 했다.

그러면 반대로 컨테이너는 무엇으로 되어 있을까.

docker run을 치면 격리된 리눅스 한 대가 생기는 것처럼 보인다. 프로세스 목록도 따로, 네트워크도 따로, 메모리 한도도 따로다.

하지만 커널 입장에서 그 안의 프로세스는 host의 다른 프로세스와 똑같은, 평범한 프로세스다. 컨테이너라는 오브젝트는 커널 어디에도 없다.

대신 커널은 두 가지 질문을 따로 처리한다.

  • 이 프로세스에게 무엇이 보이는가 — namespace
  • 이 프로세스가 얼마나 쓸 수 있는가 — cgroup

이 글은 이 두 축을 하나씩 뜯어본다.

Two Axes of Isolation

먼저 두 축이 어떻게 갈리는지부터 보자.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "At a glance: namespace isolates the view, cgroup limits the usage",
  "width": 960,
  "height": 645,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "ns-side",
      "label": "namespace\\n무엇이 보이는가 — 뷰 격리",
      "x": 36,
      "y": 146,
      "width": 430,
      "height": 443
    },
    {
      "id": "cg-side",
      "label": "cgroup\\n얼마나 쓰는가 — 자원 제한",
      "x": 494,
      "y": 146,
      "width": 430,
      "height": 443
    }
  ],
  "nodes": [
    {
      "id": "proc",
      "kind": "host",
      "label": "Container process",
      "caption": "커널에게는 평범한 프로세스",
      "x": 480,
      "y": 86,
      "width": 300,
      "height": 60
    },
    {
      "id": "nsq",
      "kind": "note",
      "label": "PID, mount, network 조회",
      "x": 251,
      "y": 284,
      "width": 240,
      "height": 56
    },
    {
      "id": "ns",
      "kind": "switch",
      "label": "Namespace",
      "caption": "종류별 8개",
      "x": 251,
      "y": 398,
      "width": 240,
      "height": 66
    },
    {
      "id": "view",
      "kind": "memory",
      "label": "자기만의 뷰",
      "caption": "PID 1, eth0, / 가 전부 따로",
      "x": 251,
      "y": 512,
      "width": 240,
      "height": 66
    },
    {
      "id": "cgq",
      "kind": "note",
      "label": "CPU, memory, I/O 사용",
      "x": 709,
      "y": 284,
      "width": 240,
      "height": 56
    },
    {
      "id": "cg",
      "kind": "switch",
      "label": "cgroup",
      "caption": "controller 트리",
      "x": 709,
      "y": 398,
      "width": 240,
      "height": 66
    },
    {
      "id": "limit",
      "kind": "memory",
      "label": "그룹 단위 한도",
      "caption": "cpu.max, memory.max ...",
      "x": 709,
      "y": 512,
      "width": 240,
      "height": 66
    }
  ],
  "links": [
    {
      "id": "p-ns",
      "points": [[420, 116], [251, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "p-cg",
      "points": [[540, 116], [709, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "n1",
      "points": [[251, 312], [251, 365]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "n2",
      "points": [[251, 431], [251, 479]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "c1",
      "points": [[709, 312], [709, 365]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "c2",
      "points": [[709, 431], [709, 479]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    }
  ],
  "labels": [
    {
      "text": "다르게 보인다",
      "x": 251,
      "y": 567
    },
    {
      "text": "더 못 쓴다",
      "x": 709,
      "y": 567
    }
  ]
}

왼쪽이 namespace의 몫이다. 프로세스가 PID 목록을 조회하든, 네트워크 인터페이스를 열든, 파일시스템 루트를 보든, 커널은 그 프로세스가 속한 namespace 안의 것만 보여준다.

오른쪽이 cgroup의 몫이다. 프로세스가 CPU를 태우고 메모리를 잡을 때, 커널은 그 프로세스가 속한 그룹의 한도를 확인하고 초과분을 막거나 늦춘다.

요컨대 이렇다.

 

namespace = 무엇이 보이는가 (view)cgroup = 얼마나 쓰는가 (quota)\begin{gathered} \textbf{\text{namespace = 무엇이 보이는가 (view)}} \\[2pt] \textbf{\text{cgroup = 얼마나 쓰는가 (quota)}} \end{gathered}

 

둘은 서로를 모른다. namespace만 쓰면 “다른 세계가 보이지만 자원은 무한정 먹는” 프로세스가 되고, cgroup만 쓰면 “자원은 묶였지만 host의 모든 것이 보이는” 프로세스가 된다. 컨테이너 런타임이 하는 일은 이 둘을 한 프로세스에 같이 걸어 주는 것이다.

Namespaces

namespace란 전역 자원 하나를 감싸서, 그 안의 프로세스에게는 자기만의 격리된 인스턴스가 있는 것처럼 보이게 하는 커널 기능을 말한다.

여기서 전역 자원이란 원래 시스템에 하나뿐인 것들이다. 호스트네임, 프로세스 ID 공간, 네트워크 스택, 마운트 테이블 같은 것들.

리눅스에는 현재 8종류의 namespace가 있다.

Namespaceflag격리 대상도입 커널
MountCLONE_NEWNS마운트 포인트 목록2.4.19
UTSCLONE_NEWUTS호스트네임, NIS 도메인2.6.19
IPCCLONE_NEWIPCSystem V IPC, POSIX 메시지 큐2.6.19
PIDCLONE_NEWPID프로세스 ID 공간2.6.24
NetworkCLONE_NEWNET네트워크 장치, 스택, 포트2.6.24
UserCLONE_NEWUSERUID, GID3.8 (완성 기준)
CgroupCLONE_NEWCGROUPcgroup 루트 디렉터리4.6
TimeCLONE_NEWTIME부팅, monotonic 시계5.6

첫 번째로 만들어진 namespace가 Mount라서 flag 이름이 CLONE_NEWNS(new namespace)다. 그때는 namespace가 더 생길 줄 몰랐기 때문에 이런 일반적인 이름을 가져갔다.

이 표를 다 외울 필요는 없다. 컨테이너를 이해하는 데 중요한 것은 PID, Mount, Network, User 네 개이고, 나머지는 필요할 때 찾아보면 된다.

Three System Calls

namespace를 다루는 시스템 콜은 세 개다.

clone(child_fn, stack, CLONE_NEWPID | SIGCHLD, arg);  // 새 ns에서 자식 생성
unshare(CLONE_NEWNET);                                 // 나를 새 ns로 이동
setns(fd, CLONE_NEWNET);                               // 기존 ns에 합류
  • clone은 자식 프로세스를 만들면서, 지정한 flag의 새 namespace를 만들고 자식을 그 멤버로 넣는다.
  • unshare는 호출한 프로세스 자신을 새 namespace로 옮긴다.
  • setns는 이미 존재하는 namespace에 합류한다. 대상 namespace는 파일 디스크립터로 지정한다.

여기서 PID namespace만 예외가 하나 있다. unshare(CLONE_NEWPID)를 호출해도 호출한 프로세스 자신은 옮겨지지 않는다.

프로세스의 PID namespace 소속은 생성 시점에 결정되고 이후 바꿀 수 없기 때문이다. PID가 바뀌면 getpid() 결과를 캐시해 둔 라이브러리와 프로그램이 전부 깨진다.

그래서 unshare(CLONE_NEWPID) 뒤에 처음 낳는 자식이 새 namespace의 PID 1이 된다. Time namespace도 같은 이유로 자식부터 적용된다.

각 프로세스가 어떤 namespace에 속해 있는지는 /proc/[pid]/ns/에서 볼 수 있다.

$ ls -l /proc/self/ns/
lrwxrwxrwx ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx ... mnt -> 'mnt:[4026531841]'
lrwxrwxrwx ... net -> 'net:[4026531840]'
lrwxrwxrwx ... pid -> 'pid:[4026531836]'
...

두 프로세스의 심볼릭 링크가 같은 inode 번호를 가리키면 같은 namespace에 있는 것이다.

namespace는 보통 마지막 멤버 프로세스가 죽으면 사라진다. 다만 이 심볼릭 링크를 다른 곳에 bind mount 해 두면, 프로세스가 하나도 없어도 namespace가 살아 있다. ip netns가 네트워크 namespace를 이름으로 관리할 때 쓰는 방법이 바로 이것이다.

PID Namespace

컨테이너 안에서 ps를 치면 프로세스가 몇 개 안 보이고, 내 애플리케이션이 PID 1이다. 이걸 만드는 것이 PID namespace다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "One process, two PIDs",
  "width": 960,
  "height": 594,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "hostview",
      "label": "Host PID namespace\\n전체 트리가 보인다",
      "x": 36,
      "y": 64,
      "width": 430,
      "height": 466
    },
    {
      "id": "ctrview",
      "label": "Container PID namespace\\n자기 세계가 전부다",
      "x": 494,
      "y": 64,
      "width": 430,
      "height": 466
    }
  ],
  "nodes": [
    {
      "id": "systemd",
      "kind": "host",
      "label": "systemd — PID 1",
      "x": 251,
      "y": 208,
      "width": 240,
      "height": 60
    },
    {
      "id": "shim",
      "kind": "switch",
      "label": "containerd-shim — PID 4320",
      "x": 251,
      "y": 328,
      "width": 240,
      "height": 60
    },
    {
      "id": "app-h",
      "kind": "memory",
      "label": "app — PID 4321",
      "caption": "host에서 본 모습",
      "x": 251,
      "y": 448,
      "width": 240,
      "height": 66
    },
    {
      "id": "note-c",
      "kind": "note",
      "label": "부모도, 형제도 안 보인다",
      "x": 709,
      "y": 208,
      "width": 240,
      "height": 56
    },
    {
      "id": "app-c",
      "kind": "memory",
      "label": "app — PID 1",
      "caption": "container에서 본 모습",
      "x": 709,
      "y": 448,
      "width": 240,
      "height": 66
    }
  ],
  "links": [
    {
      "id": "t1",
      "points": [[251, 238], [251, 298]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "t2",
      "points": [[251, 358], [251, 415]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "hide",
      "points": [[709, 236], [709, 415]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "same",
      "points": [[371, 448], [589, 448]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    }
  ],
  "labels": [
    {
      "text": "같은 프로세스",
      "x": 480,
      "y": 424
    }
  ]
}

같은 프로세스가 host namespace에서는 PID 4321이고, 자기 namespace 안에서는 PID 1이다. 커널이 namespace마다 PID를 따로 매겨서 이 매핑을 관리한다.

PID namespace는 부모-자식으로 중첩된다. 부모 namespace는 자식 namespace의 프로세스를 볼 수 있지만, 자식은 부모 쪽을 볼 수 없다. 중첩은 최대 32단계까지 가능하다.

새 PID namespace의 첫 프로세스, 즉 PID 1은 그 namespace의 init 역할을 맡는다. 이 자리에는 특별한 규칙이 몇 개 붙는다.

  • namespace 안에서 고아가 된 프로세스는 PID 1에게 재입양된다.
  • PID 1이 죽으면 커널이 namespace의 모든 프로세스에 SIGKILL을 보내고 namespace를 닫는다.
  • PID 1에는 핸들러를 등록하지 않은 시그널이 배달되지 않는다. 같은 namespace 안에서는 SIGKILL조차 무시된다. 예외는 부모 namespace에서 보낸 SIGKILLSIGSTOP뿐이다.

컨테이너에서 애플리케이션이 좀비 프로세스를 쌓는 문제가 여기서 나온다. 평범한 웹 서버는 init처럼 고아를 거둬 wait() 해주는 코드가 없기 때문이다. docker run --init이나 tini 같은 경량 init을 PID 1에 두는 이유다.

User Namespace

User namespace는 UID와 GID를 격리한다. 다른 namespace와 결이 다른 점이 하나 있는데, 권한 검사의 기준점 자체를 바꾼다는 것이다.

이 격리는 매핑 테이블로 동작한다. /proc/[pid]/uid_map에 “namespace 안 UID, 밖 UID, 길이”를 적으면, 안에서 UID 0인 프로세스가 밖에서는 평범한 UID 100000으로 취급된다.

$ cat /proc/[pid]/uid_map
         0     100000      65536
# 안의 0~65535 가 밖의 100000~165535 로 매핑

이 매핑 덕분에 두 가지가 가능해진다.

첫째, 비특권 컨테이너(rootless container)다. User namespace는 8종 중 유일하게 root 권한 없이 만들 수 있고, 만든 프로세스는 그 namespace 안에서 모든 capability를 가진다. 안에서는 root처럼 행동하지만 밖에서는 일반 유저다.

둘째, 다른 namespace들의 소유자 역할이다. user를 제외한 모든 namespace는 생성될 때 자신을 만든 user namespace를 소유자로 기록한다. 이후 그 namespace의 자원에 대한 특권 연산은 소유자 user namespace 안에서의 capability로 검사된다.

capability는 root 권한을 쪼개 놓은 단위다. 네트워크 설정은 CAP_NET_ADMIN, 마운트는 CAP_SYS_ADMIN처럼 권한 하나하나에 이름이 붙어 있어서, 전부 주거나 안 주거나 대신 필요한 것만 줄 수 있다.

그래서 일반 유저가 unshare -Un으로 user와 network namespace를 같이 만들면, 새 네트워크 namespace 안에서는 인터페이스를 만들고 라우팅을 고칠 수 있다. 그 network namespace의 소유자가 방금 만든 user namespace이고, 거기서 나는 CAP_NET_ADMIN을 가지고 있기 때문이다.

Mount Namespace

Mount namespace는 마운트 포인트 목록을 격리한다. 컨테이너가 자기만의 /를 갖는 것, 즉 이미지의 파일시스템이 루트로 보이는 것이 이 namespace와 pivot_root의 조합이다.

새 mount namespace는 빈 상태로 시작하지 않고 부모의 마운트 목록을 복사해서 시작한다. 이후의 mount와 umount가 서로 안 보이게 될 뿐이다.

다만 완전히 단절되는 것은 아니다. 마운트 포인트마다 전파 타입(propagation type)이 있어서, shared면 namespace를 넘어 마운트 이벤트가 전파되고 private이면 안 된다. 커널 기본값은 private이지만 systemd가 부팅 때 전부 shared로 바꿔 둔다. USB를 꽂았을 때 이미 떠 있는 컨테이너에도 보이게 하려면 전파가 필요하기 때문이다.

The Rest

나머지 네 개는 짧게 정리한다.

  • Network: 네트워크 장치, IP 주소, 라우팅 테이블, 포트 공간을 격리한다. 컨테이너마다 자기 eth0과 자기 80번 포트가 있는 이유다. veth pair로 host와 연결한다.
  • UTS: 호스트네임을 격리한다. 컨테이너마다 hostname이 다른 이유이고, 격리 대상이 제일 작은 namespace다.
  • IPC: System V IPC와 POSIX 메시지 큐를 격리한다. 공유 메모리 세그먼트가 컨테이너 밖으로 새지 않게 한다.
  • Time: 부팅 시계(CLOCK_BOOTTIME)와 monotonic 시계(CLOCK_MONOTONIC)에 오프셋을 준다. 벽시계(CLOCK_REALTIME)는 격리하지 않는다. 컨테이너 체크포인트를 다른 호스트에서 복원할 때 uptime이 이어져 보이게 하는 용도다.

Cgroup namespace는 cgroup 개념이 먼저 필요하다.

cgroups

cgroup(control group)은 프로세스들을 묶어서 자원 사용량을 측정하고 제한하는 커널 기능이다.

namespace가 프로세스마다 다른 세계를 보여주는 기능이라면, cgroup은 프로세스 묶음마다 자원 예산을 배정하는 기능이다.

구조는 파일시스템으로 노출된다. /sys/fs/cgroup 아래에 디렉터리를 만들면 그룹이 생기고, 그 안의 파일에 값을 쓰면 한도가 걸리고, 프로세스 PID를 cgroup.procs에 써넣으면 그 그룹에 속하게 된다.

$ mkdir /sys/fs/cgroup/myapp
$ echo "50000 100000" > /sys/fs/cgroup/myapp/cpu.max   # CPU 0.5개
$ echo "1G" > /sys/fs/cgroup/myapp/memory.max          # 메모리 1GiB
$ echo $$ > /sys/fs/cgroup/myapp/cgroup.procs          # 현재 셸을 편입

시스템 콜 API가 아니라 파일 읽기/쓰기가 인터페이스라서, echocat만으로 전부 조작할 수 있다.

자원 종류별 담당자를 controller라고 부른다. cpu, memory, io, pids 같은 것들이 각각 controller다.

v1 vs v2

cgroup에는 v1과 v2 두 버전이 있고, 구조가 다르다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "cgroup v1 vs v2: many trees vs one tree",
  "width": 960,
  "height": 492,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "v1",
      "label": "cgroup v1\\ncontroller마다 트리가 따로",
      "x": 36,
      "y": 64,
      "width": 430,
      "height": 364
    },
    {
      "id": "v2",
      "label": "cgroup v2\\n트리 하나에 controller가 붙는다",
      "x": 494,
      "y": 64,
      "width": 430,
      "height": 364
    }
  ],
  "nodes": [
    {
      "id": "cpu-root",
      "kind": "switch",
      "label": "cpu hierarchy",
      "x": 143,
      "y": 219,
      "width": 184,
      "height": 60
    },
    {
      "id": "cpu-a",
      "kind": "host",
      "label": "/myapp",
      "x": 143,
      "y": 339,
      "width": 184,
      "height": 60
    },
    {
      "id": "mem-root",
      "kind": "switch",
      "label": "memory hierarchy",
      "x": 359,
      "y": 219,
      "width": 184,
      "height": 60
    },
    {
      "id": "mem-a",
      "kind": "host",
      "label": "/myapp",
      "x": 359,
      "y": 339,
      "width": 184,
      "height": 60
    },
    {
      "id": "root2",
      "kind": "switch",
      "label": "unified hierarchy",
      "caption": "cpu + memory + io ...",
      "x": 709,
      "y": 219,
      "width": 240,
      "height": 66
    },
    {
      "id": "slice",
      "kind": "host",
      "label": "/system.slice",
      "x": 601,
      "y": 339,
      "width": 184,
      "height": 60
    },
    {
      "id": "pods",
      "kind": "host",
      "label": "/kubepods",
      "x": 817,
      "y": 339,
      "width": 184,
      "height": 60
    }
  ],
  "links": [
    {
      "id": "v1a",
      "points": [[143, 249], [143, 309]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "v1b",
      "points": [[359, 249], [359, 309]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "v2a",
      "points": [[661, 252], [601, 309]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "v2b",
      "points": [[757, 252], [817, 309]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    }
  ],
  "labels": [
    {
      "text": "같은 그룹을 트리마다 다시 만든다",
      "x": 251,
      "y": 405
    },
    {
      "text": "그룹은 한 번, controller는 켜고 끈다",
      "x": 709,
      "y": 405
    }
  ]
}

v1은 controller마다 hierarchy(트리)가 따로 있다. cpu 트리 따로, memory 트리 따로. 한 애플리케이션의 자원을 관리하려면 같은 그룹을 트리마다 반복해서 만들어야 하고, 트리끼리 구조가 어긋날 수 있다.

이 유연함은 실제로는 독이 됐다. controller마다 동작 규칙이 제각각으로 자랐고, 프로세스 하나가 트리마다 다른 그룹에 속할 수 있으니 “이 프로세스의 자원 상태”를 한 번에 파악할 수 없었으며, controller 간 협력(예: 메모리 압박이 오면 I/O를 줄이는 writeback 제어)이 불가능했다.

그래서 v2는 트리를 하나로 합쳤다. unified hierarchy라고 부른다. 그룹은 한 번만 만들고, 그 그룹에 어떤 controller를 적용할지를 켜고 끈다.

커널 4.5에서 정식 도입됐고, 이행은 사실상 끝났다. systemd는 243부터 unified hierarchy를 빌드 기본값으로 삼았고, 256부터는 v1 환경에서 부팅 자체를 거부하며, 258에서 v1 지원 코드를 제거했다. Kubernetes는 1.25에서 v2 지원이 GA 됐고, 1.31에서 v1을 유지보수 모드로 내린 뒤 1.35에서 공식 deprecated 처리했다. 이제 kubelet은 v1 노드에서 기본적으로 시작하지 않는다.

지금 어느 버전인지는 stat -fc %T /sys/fs/cgroup으로 확인할 수 있다. cgroup2fs가 나오면 v2다.

이하 내용은 v2 기준이다.

Interface Files

그룹 디렉터리 안의 파일 몇 개만 알면 cgroup의 대부분을 쓸 수 있다.

CPU부터 보자.

$ cat /sys/fs/cgroup/myapp/cpu.max
50000 100000        # 100ms 마다 50ms 까지 — CPU 0.5개

cpu.max는 “기간당 사용 한도”다. 두 번째 숫자가 기간(µs), 첫 번째가 그 기간 안에 쓸 수 있는 CPU 시간(µs)이다. 한도에 닿으면 프로세스는 다음 기간까지 멈춘다(throttling).

cpu.weight는 한도가 아니라 경쟁 비율이다. 1부터 10000 사이 값이고 기본 100이다. CPU가 남아돌면 weight가 낮아도 마음껏 쓰고, 경쟁이 생겼을 때만 이 비율로 나눈다.

Kubernetes의 CPU limit이 cpu.max로, CPU request가 cpu.weight로 내려간다. limit은 남는 CPU가 있어도 못 쓰게 막는 상한이고, request는 경쟁 시의 보장 비율이라는 차이가 그대로 이 두 파일의 차이다.

메모리는 한도가 여러 단계다.

파일의미
memory.max넘으면 OOM kill 되는 상한
memory.high넘으면 reclaim 압박으로 느려지는 소프트 상한
memory.low이 밑으로는 되도록 회수하지 않는 보호선
memory.min이 밑으로는 절대 회수하지 않는 보호선
memory.current현재 사용량

memory.max만 알고 있는 경우가 많은데, memory.high와의 차이가 운영에서는 중요하다. max는 넘는 순간 OOM killer가 프로세스를 죽이는 벼랑이고, high는 커널이 그 그룹의 메모리를 적극적으로 회수하면서 할당을 늦추는 완충 지대다.

그 밖에 io.max로 디바이스별 bps와 iops를 제한하고, pids.max로 그룹이 만들 수 있는 프로세스 수를 제한한다. fork bomb을 컨테이너 안에 가두는 것이 이 파일이다.

v2에는 한도 말고 관측 기능도 있다. cpu.pressure, memory.pressure, io.pressure 파일은 PSI(Pressure Stall Information)라고 부르는 지표로, 그 그룹의 프로세스들이 자원을 기다리며 멈춰 있던 시간의 비율을 보여준다.

$ cat /sys/fs/cgroup/myapp/memory.pressure
some avg10=1.53 avg60=0.42 avg300=0.09 total=8123456
full avg10=0.00 avg60=0.00 avg300=0.00 total=123456

사용량은 한도에 안 닿았는데 애플리케이션이 느리다면, pressure 파일이 어느 자원에서 막히고 있는지 알려준다.

Rules of the Tree

v2 트리에는 v1의 혼란을 반복하지 않기 위한 규칙이 있다. 두 개만 기억하면 된다.

첫째, controller는 위에서 아래로 내려준다. 부모의 cgroup.subtree_control에 controller를 등록해야 자식들이 그 controller를 쓸 수 있다.

$ cat /sys/fs/cgroup/cgroup.controllers        # 이 그룹이 쓸 수 있는 controller
cpuset cpu io memory hugetlb pids rdma misc
$ echo "+cpu +memory" > /sys/fs/cgroup/cgroup.subtree_control   # 자식에게 배포

한 단계라도 빠지면 그 아래로는 해당 controller가 보이지 않는다. 자식이 부모보다 많은 자원을 다룰 수 없게 하는 하향식 구조다.

둘째, 내부 프로세스 금지 규칙(no internal process rule)이다. 자식 그룹에 controller를 내려준 그룹에는 프로세스를 직접 넣을 수 없다. 프로세스는 트리의 잎(leaf)에만 둘 수 있다.

부모 그룹에 직접 속한 프로세스와 자식 그룹이 형제처럼 자원을 경쟁하면, 그 배분을 정의할 방법이 없기 때문이다. v1에서는 이게 허용됐고 controller마다 제멋대로 처리해서 혼란의 근원이 됐다.

systemd의 slice / scope / service 구조가 이 규칙 위에 서 있다. 중간 노드(slice)는 폴더 역할만 하고, 프로세스는 항상 잎(scope, service)에만 담긴다.

Cgroup Namespace

미뤄 뒀던 마지막 namespace를 이제 볼 수 있다.

컨테이너 안에서 /proc/self/cgroup을 읽으면 문제가 하나 생긴다. cgroup 경로는 트리 루트부터의 절대 경로라서, 격리를 했는데도 /kubepods/pod3f.../container9a... 같은 host 쪽 구조가 그대로 노출된다.

Cgroup namespace는 이 뷰를 자른다. namespace를 만든 시점의 cgroup이 루트로 보이게 해서, 안에서는 자기 경로가 /로 읽힌다.

# host 에서
$ cat /proc/[pid]/cgroup
0::/kubepods/pod3f.../container9a...

# container 안에서 (cgroup namespace 적용)
$ cat /proc/self/cgroup
0::/

경로를 숨길 뿐 자원 제한과는 무관하다. 한도는 여전히 host 쪽 트리에 걸려 있다.

How Containers Use Them

이제 docker run이 하는 일을 커널 언어로 읽을 수 있다.

  1. 런타임(runc)이 새 프로세스를 만들면서 namespace를 한꺼번에 건다. PID, mount, network, UTS, IPC, cgroup, 설정에 따라 user까지.
  2. cgroup 디렉터리를 만들고 cpu.max, memory.max 등에 --cpus, --memory 옵션 값을 쓴 뒤, 새 프로세스를 cgroup.procs에 넣는다.
  3. mount namespace 안에서 이미지 파일시스템을 준비하고 pivot_root로 루트를 바꾼다.
  4. 준비가 끝나면 컨테이너의 entrypoint를 exec 한다.

docker run --cpus 0.5 --memory 1g nginx 한 줄의 실체는, 위에서 본 unshare류 호출 몇 개와 cgroup 파일 몇 줄 쓰기다.

거꾸로 말하면, docker 없이도 이 조합을 직접 만들 수 있다.

$ sudo unshare --pid --mount --net --uts --fork --mount-proc bash
# ps 치면 프로세스 두 개, hostname 바꿔도 host에 영향 없음

이 한 줄이 만들어 주는 것이 namespace 격리이고, 여기에 cgroup 편입과 루트 파일시스템 교체를 더하면 그게 컨테이너다.

VM과의 경계도 여기서 분명해진다. 컨테이너의 격리는 전부 host 커널이 제공하는 뷰와 예산의 격리다. 커널 자체는 하나뿐이므로, 커널 취약점 앞에서는 모든 컨테이너가 한 배를 탄다. 커널까지 분리해야 하면 KVM 같은 VM으로 가야 한다.

Summary

커널에 컨테이너라는 실체는 없다. 있는 것은 두 가지 축이다.

namespace는 전역 자원을 감싸서 프로세스마다 자기만의 뷰를 준다. 8종류가 있고, clone / unshare / setns로 만들고 옮기고 합류한다. PID namespace가 컨테이너 안의 PID 1을, mount namespace가 자기만의 루트를, user namespace가 rootless를 만든다.

cgroup은 프로세스 묶음에 자원 예산을 배정한다. v2는 트리 하나에 controller를 켜고 끄는 구조이고, cpu.max, memory.max 같은 파일에 값을 쓰는 것이 인터페이스의 전부다. 한도만이 아니라 PSI 같은 관측 지표도 그룹 단위로 나온다.

컨테이너 런타임은 이 둘을 한 프로세스에 같이 걸고 루트 파일시스템을 바꿔 끼운다. 그 결과가 우리가 컨테이너라고 부르는 것이다.

References