[ kt cloud AI플랫폼팀 최지우 님 ]
cgroup(Control Group)은 Linux 커널에서 제공하는 기능으로, 프로세스 그룹 단위로 시스템 자원의 사용을 제한하고 모니터링할 수 있도록 해줍니다. CPU, 메모리, 디스크 I/O, 네트워크 등 다양한 자원에 대해 세밀한 제어가 가능합니다.
예를 들어, A라는 컨테이너의 CPU 사용률을 최대 20%로 제한하거나, B 컨테이너가 특정 GPU 디바이스 하나만 사용하도록 설정할 수 있습니다. 또한, 특정 프로세스 그룹이 특정 디바이스에 접근하지 못하도록 차단하는 것도 가능합니다.
이처럼 cgroup은 Docker, systemd, Kubernetes와 같은 컨테이너 기술에서 자원 격리 및 제어를 위한 핵심 기술로 널리 활용되고 있습니다.
cgroup의 두 가지 버전
cgroup v1은 컨트롤러마다 각기 다른 계층 구조를 사용하는 방식이었습니다. 이로 인해 설정이 복잡해지고, 중복되거나 충돌이 발생할 가능성이 크다는 단점이 있었습니다. 또한, 컨테이너 기술이 빠르게 성장하면서 일관성과 보안 측면에서도 한계를 드러내게 되었습니다.
이러한 문제를 해결하기 위해 등장한 것이 cgroup v2입니다. 단일 계층 구조를 채택하고, eBPF와의 통합, 표준화된 인터페이스 제공 등을 통해 구조를 단순화하고 보안성과 일관성을 강화했습니다.
- cgroup v1: 컨트롤러별로 별도 계층 구조를 사용 (예: CPU, 메모리, 디바이스 각각 따로 관리)
- cgroup v2: 단일 계층 구조 기반, 일관된 인터페이스 제공, eBPF 통합 등으로 보다 현대적이고 안정적인 구조
현재 대부분의 최신 Linux 배포판에서는 cgroup v2가 기본으로 적용되고 있습니다.
특히 컨테이너 환경에서는 디바이스 접근 제어가 보안과 자원 격리 측면에서 매우 중요한 요소입니다. Docker는 기본적으로 컨테이너가 호스트의 장치에 무분별하게 접근하지 못하도록 제한하고 있습니다. 이전에는 cgroup v1의 devices 컨트롤러를 통해 이러한 제어가 이루어졌지만, cgroup v2에서는 이 방식이 크게 변경되었습니다.
이번 글에서는 cgroup v2 환경에서 디바이스 접근 제어가 어떻게 동작하는지, Docker는 이를 어떤 방식으로 구현하고 있는지, 그리고 이를 eBPF 프로그램을 통해 어떻게 분석할 수 있는지 정리해보겠습니다.
cgroup v1 vs v2: 디바이스 제어 방식의 변화
cgroup v1에서는 디바이스 접근 제어를 위해 /sys/fs/cgroup/devices/.../devices.allow와 같은 인터페이스 파일을 사용했습니다. 이 파일을 통해 어떤 디바이스가 허용되었는지 명시적으로 확인하고 설정할 수 있었죠.
하지만 cgroup v2에서는 이러한 인터페이스 파일이 아예 존재하지 않습니다. 커널 문서에서도 “Cgroup v2의 디바이스 컨트롤러는 별도의 인터페이스 파일을 제공하지 않으며, 대신 cgroup BPF를 기반으로 구현되어 있습니다”라고 명시하고 있습니다.
여기서 말하는 BPF는 커널 안에서 작은 프로그램을 안전하게 실행할 수 있게 해주는 기술입니다. 조금 풀어 말하면, 리눅스 커널은 원래 사용자 프로그램이 직접 커널 안쪽을 건드리는 걸 제한하였는데, BPF는 이런 상황에서 “커널에 작은 코드를 주입하고 실행하는 방법”을 제공합니다.
즉, cgroup v2에서는 디바이스 접근 제어가 eBPF(Extended Berkeley Packet Filter)를 기반으로 이루어지며, 모든 정책은 eBPF 프로그램으로 커널에 적용되고 컨테이너 런타임(Docker 등)이 이를 자동 생성합니다. 전통적인 텍스트 기반의 설정 파일 대신, 유연하고 동적인 제어가 가능한 eBPF를 통해 보안성과 확장성을 강화한 것입니다.
Docker 컨테이너의 cgroup 및 eBPF 프로그램 찾기
eBPF를 처음 접한다면 낯설고 어렵게 느껴질 수 있습니다. 생소한 문법에다, 일반적인 사용자 공간에서 벗어난 작동 방식까지 쉽게 다가가기엔 장벽이 있는 것이 사실입니다.
이번 섹션에서는 간단한 eBPF 프로그램을 분석해보면서, 어떤 식으로 작성되고 해석되는지 하나씩 풀어보겠습니다.
0. (선택) Docker 컨테이너 실행
이 예제에서는 GPU Server에서 NVIDIA GPU 1장이 포함된 pytorch 이미지를 사용합니다.
docker run --rm --runtime nvidia -e NVIDIA_VISIBLE_DEVICES=0 \
-it pytorch/pytorch:2.3.1-cuda12.1-cudnn8-devel bash
1. 컨테이너 ID 확인
모든 컨테이너에는 Container ID가 부여되는데, 일반적인 docker ps 명령은 축약된 ID를 출력합니다. docker ps 명령에 --no-trunc 옵션을 붙이면 긴 ID를 출력합니다.
# docker ps --no-trunc
CONTAINER ID IMAGE
da01364bb9502ba138b7eb46f19f97d22dd71770ddd58e813ea6c5d3e07420ed pytorch/pytorch:2.3.1-cuda12.1...
2. 해당 컨테이너의 cgroup 경로 확인
일반적으로 컨테이너의 cgroup은 /sys/fs/cgroup/system.slice/docker-[Container ID].scope/ 에 위치합니다. 방금 확인한 컨테이너의 긴 ID를 이용하여 이동할 수 있습니다.
/sys/fs/cgroup/system.slice/docker-da01364bb9502ba1....scope# ll
total 0
drwxr-xr-x 2 root root 0 Mar 31 13:03 ./
drwxr-xr-x 38 root root 0 Mar 31 13:20 ../
-r--r--r-- 1 root root 0 Mar 31 13:03 cgroup.controllers
-r--r--r-- 1 root root 0 Mar 31 13:03 cgroup.events
-rw-r--r-- 1 root root 0 Mar 31 13:03 cgroup.freeze
--w------- 1 root root 0 Mar 31 13:27 cgroup.kill
-rw-r--r-- 1 root root 0 Mar 31 13:27 cgroup.max.depth
-rw-r--r-- 1 root root 0 Mar 31 13:27 cgroup.max.descendants
-rw-r--r-- 1 root root 0 Mar 31 13:27 cgroup.pressure
-rw-r--r-- 1 root root 0 Mar 31 13:03 cgroup.procs
-r--r--r-- 1 root root 0 Mar 31 13:27 cgroup.stat
-rw-r--r-- 1 root root 0 Mar 31 13:03 cgroup.subtree_control
...
3. cgroup에 연결된 eBPF 프로그램 ID 확인
다음으로, 이 경로에 포함된 eBPF 프로그램 ID를 가져오기 위해 bpftool
GitHub - libbpf/bpftool: Automated upstream mirror for bpftool stand-alone build. 을 사용합니다.
sudo bpftool cgroup list /sys/fs/cgroup/system.slice/docker-...scope/
다음과 같이 ID가 69, 70인 eBPF PROG에 연결되어 있으며 프로그램 유형은 cgroup_device 입니다.
ID AttachType AttachFlags Name
69 cgroup_device multi
70 cgroup_device multi
AttachFlages는 ‘multi’라고 출력되는데요, 같은 장치 접근 필터라도 Docker나 Containerd 같은 런타임은 보안이나 기능 분리를 위해 eBPF 프로그램을 중복해서 attach 할 수 있습니다.
Docker가 기본 필터 + 사용자 정의 필터를 동시에 attach했기 때문입니다. 두 필터는 다른 목적을 가지며, 둘 중 하나라도 허용하면 접근이 가능합니다.
4. eBPF 코드 덤프
이제 본격적으로 실제 eBPF 코드를 들여다볼 차례입니다. cgroup에서 실행 중인 프로세스가 디바이스에 접근하려고 시도할 때마다, 커널은 연결된 eBPF 프로그램을 실행시킵니다.
특정 프로그램 ID를 기준으로, 다음과 같은 명령어를 사용해 코드를 덤프할 수 있습니다. 이렇게 하면 eBPF가 어떤 논리로 동작하는지, 조금 더 명확하게 이해할 수 있습니다.
sudo bpftool prog dump xlated id 69 > bpf1.txt
sudo bpftool prog dump xlated id 70 > bpf2.txt
이제 사람이 읽을 수 있는 포맷의 eBPF 프로그램이 되었습니다.
eBPF 코드 해석하기
아무리 사람이 읽을 수 있는 형태로 변환되었다고 해도, eBPF 코드라는 게 여전히 낯설게 느껴지는 건 어쩔 수 없습니다. 그래서 이번에는 덤프한 eBPF 프로그램을 예제로 삼아, 그 구조와 동작을 하나씩 뜯어보며 해석해보겠습니다.
먼저 알아두어야 할 점은, eBPF 프로그램이 디바이스 접근 여부를 판단할 때 사용하는 핵심 도구들이 있다는 것입니다. 대표적으로 몇 가지 레지스터가 등장하는데, 이들이 실제로 어떤 정보를 담고 있는지부터 살펴보겠습니다.
- r2: 디바이스 타입 (1 = block, 2 = char)
- r3: 접근 권한 비트 (read, write, mknod 등, 상위 16비트)
- r4: 메이저 번호
- r5: 마이너 번호
예제에서 변환한 Prog ID 70번의 내용은 아래와 같습니다.
0: (69) r2 = *(u16 *)(r1 +0) ; 디바이스 타입
1: (61) r3 = *(u32 *)(r1 +0) ; 접근 권한 포함
2: (74) w3 >>= 16 ; 상위 16비트 = 접근 권한
3: (61) r4 = *(u32 *)(r1 +4) ; major 번호
4: (61) r5 = *(u32 *)(r1 +8) ; minor 번호
5: (55) if r2 != 0x2 goto pc+7 ; char 디바이스만
...
9: (55) if r4 != 0xc3 goto pc+3 ; major == 195 인 경우에만
10: (55) if r5 != 0x0 goto pc+2 ; minor == 0 인 경우에만
11: (b4) w0 = 1 ; 조건 만족 → 허용
9, 10 행에서 r4, r5의 16진법 수치를 통해 major 번호와 minor 번호를 추정할 수 있습니다. 이 예제의 경우 195, 0 이므로 nvidia0 장치임을 유추할 수 있습니다.
접근 권한은 다음과 같이 확인할 수 있습니다.
6: (bc) w6 = w3 ; w3 = 접근 비트
7: (54) w6 &= 6 ; 0b110 → READ(2) | WRITE(4)
8: (5d) if r6 != r3 goto pc+4
리눅스 커널에서는 cgroup device controller용 접근 권한을 다음과 같이 정의하고 있습니다.
<include/uapi/linux/bpf.h>
#define BPF_DEVCG_ACC_MKNOD (1 << 0) // 0x1
#define BPF_DEVCG_ACC_READ (1 << 1) // 0x2
#define BPF_DEVCG_ACC_WRITE (1 << 2) // 0x4
즉, 각각의 권한은 다음과 같은 비트값으로 나타납니다.
권한 | 비트 값 (16진수) | 비트 값 (2진수) |
mknod | 0x1 | 0001 |
read | 0x2 | 0010 |
write | 0x4 | 0100 |
따라서 w6 &= 6 → read (0x2), write (0x4)만 필터링하는 부분은 read (0x2) | write (0x4) 만 남기고,
mknod (0x1) 은 제외시키는 마스크입니다.
이와 같이 분석한 결과, crw-rw-rw- 1 195, 0 nvidia0 의 권한이 부여되어 있다는 것을 유추할 수 있습니다.
Docker 옵션이 eBPF에 미치는 영향
이번에는 컨테이너를 실행시킬 때 --device-cgroup-rule 옵션을 통해 장치를 추가해보겠습니다. 이 예제에서는 호스트의 253, 4 vda4 장치를 추가합니다.
docker run --rm --device-cgroup-rule='c 253:4 r' ...
docker run --rm --device-cgroup-rule='c 253:4 rwm' ...
r2 장치유형이 2(char)이고, r4/r5 메이저/마이너 번호가 253/4 인 Docker 컨테이너를 실행하였습니다. 양쪽에서 다음과 같은 블록을 동일하게 확인할 수 있습니다.
9: (55) if r2 != 0x2 goto pc+3 ; r2 = 2
10: (55) if r4 != 0xfd goto pc+2 ; r4 = 253
11: (55) if r5 != 0x4 goto pc+1 ; r5 = 4
다만, 먼저 실행시킨 컨테이너에는 read 권한만 부여하였습니다. 이 경우에는 앞서 실행한 컨테이너에서만 다음과 같은 블록이 추가됩니다.
6: (bc) w1 = w3 ; 권한 비트 복사
7: (54) w1 &= 2 ; READ 비트만 남김
8: (5d) if r1 != r3 goto pc+4
즉, READ 요청만 허용하는 조건이 추가되어 있습니다. 이 블록이 두 번째로 생성한 컨테이너에서는 확인되지 않습니다. 이는 READ 외의 권한(WRITE, MKNOD 등)도 처리할 수 있음을 의미합니다.
--device 옵션의 경우
이전에는 --device-cgroup-rule 옵션을 통해 장치를 추가하였지만, 일반적으로는 --device 옵션을 통해 장치를 추가할 수 있습니다. 다음과 같이 docker run 옵션을 주어 컨테이너를 실행합니다.
docker run --rm --device=/dev/vda4:/data ...
이 경우에는 eBPF 프로그램이 --device-cgroup-rule='c 253:4 rwm' 를 통해 실행한 컨테이너와 완전히 동일합니다. 대신 컨테이너에 장치 파일을 컨테이너 안에 바인드 마운트하고, 동시에 접근을 허용하는 과정이 추가됩니다.
이와 같이 --device 옵션은 장치 파일도 붙이고 접근 권한도 열어주지만, --device-cgroup-rule은 접근 권한만 열어주는 역할을 하게 됩니다.
--privileged 옵션의 경우
52: (61) r2 = *(u32 *)(r1 +0)
53: (54) w2 &= 65535
54: (61) r3 = *(u32 *)(r1 +0)
55: (74) w3 >>= 16
56: (61) r4 = *(u32 *)(r1 +4)
57: (61) r5 = *(u32 *)(r1 +8)
58: (b4) w0 = 1
59: (95) exit
이 블록은 특정 디바이스나 권한을 검사하지 않습니다. 오로지 ctx.access_type을 다시 읽고, major/minor를 읽고 조건 검사 없이 곧바로 return 1 합니다.
즉, 앞의 블록들이 허용 조건을 못 만족했더라도, 여기에 도달하면 무조건 접근 허용한다는 뜻입니다. 이처럼 --privileged 옵션은 모든 장치 접근을 무조건 허용하기 때문에, 보안이 중요한 환경에서는 사용을 매우 신중히 고려해야 합니다.
결론
cgroup v2 환경에서의 디바이스 접근 제어는 이전보다 훨씬 유연하고 강력해졌지만, 동시에 구조가 복잡해지고 투명성이 낮아졌다는 점에서 운영자 입장에서는 부담이 될 수 있습니다. 특히 eBPF 기반으로 구현된 디바이스 컨트롤러는 기존처럼 단순한 설정 파일만으로는 제어할 수 없기 때문에, 실제 어떤 정책이 적용되고 있는지를 확인하려면 bpftool과 같은 도구를 활용한 분석이 필수적입니다.
Docker에서 제공하는 --device, --device-cgroup-rule, --privileged 옵션은 각각 내부적으로 eBPF 프로그램에 다른 형태의 정책을 반영하며, 그 효과 역시 다릅니다. 보안이 중요한 환경이라면, 단순히 옵션을 사용하는 것에 그치지 않고 해당 옵션이 실제로 어떤 커널 수준 정책을 적용하는지까지 확인하는 것이 좋습니다.
운영 중인 시스템에서 디바이스 격리가 중요한 경우, cgroup v2의 eBPF 기반 디바이스 제어 방식에 대한 충분한 이해와 주기적인 정책 검증이 요구됩니다. 이 글에서 소개한 방법을 통해 실제 시스템에 적용된 정책을 직접 들여다보며, 보다 신뢰할 수 있는 컨테이너 환경을 구성해보시기 바랍니다.
[관련/출처]
'Tech Story > DevOps & Container' 카테고리의 다른 글
[기술가이드] Kubernetes 환경에서 App of Apps로 구현하는 GitOps 실전 전략 (0) | 2025.05.15 |
---|---|
[기술가이드] 2025년 Kubernetes 관리의 미래: kt cloud Cluster API 아키텍처 완벽 해설 (2) | 2025.04.16 |
Harbor, 어떻게 쓸 것인가: Replication Rule (24) | 2024.11.18 |
What is DevOps? - Helm Chart (11) | 2024.11.13 |
What is DevOps? - Slack으로 협업하기 (3) | 2024.11.13 |