쿠버네티스가 쉬워지는 컨테이너 이야기 — cgroup, cpu편

rex.chun
17 min readOct 3, 2024

--

들어가며,

이 글은 쿠버네티스(이하 k8s)를 접하기 전에 알았다면 좋았을 컨테이너를 이루는 기술들을 설명하고 실무에서 겪을 수 있는 현상들 또는 아직 겪지 못했지만 발생할 수 있는 내용들을 설명하기 위한 시리즈입니다.

이를 통해 기반이 되는 기술들에 대해 이해하고,

  1. k8s가 오케스트레이션 도구로써 제공하는 것이 무엇인지,
  2. 앞으로 나올 또는 지금까지 나온 기능들이 무엇인지,
  3. 컨테이너를 배포할 때 무엇을 고려해야 하는지,
  4. k8s가 갖고 있는 다양한 논쟁거리들(X 유형의 어플리케이션은 올리면 안된다 또는 어울리지 않는다 등)을 스스로 판단하고 더욱 잘 활용할 수 있기를 바랍니다.

시리즈 대상독자

  1. 도커 사용 경험이 있는 사람
  2. 컨테이너는 프로세스라고 말할 수 있는 사람
  3. k8s를 공부하고 있지만 어려운 사람

이번 글의 대상이 아닌 독자

  1. k8s는 CPU를 왜 압축 가능한 자원으로 보는가?
  2. k8s에서 CPU requests/limits는 어떤 의미인가?
  3. 컨테이너의 CPU 사용률은 어떻게 측정되는가?
  4. CPU 사용률이 100%가 아닌데 왜 throttled 상태가 되는가?
  5. 일정량의 RPS 또는 TPS가 목표일 때, 내 어플리케이션에 적용해야할 CPU값은 어떻게 알 수 있는가?
  6. k8s에서 CPU와 관련된 추가 기능은 무엇이 나올 수 있을까?
  7. 컨테이너 환경은 CPU 활용에 있어 어떤 문제가 있는가?
  8. 7번의 문제는 특정 유형의 어플리케이션을 컨테이너로 올리는데 문제가 되는가?

1 ~ 5번에 대한 답과, 6 ~ 8번에 대한 개인적인 생각이 있다면 이번 글은 읽지 않으셔도 됩니다.

최종 결과물

쿠버네티스는 잘 몰라도, 환경은 만들 줄 아는 사람이 되자!

시리즈를 마치게 되면 우리는 위 그림과 같은 아키텍처를 만들 수 있게 될겁니다. 다만, 만들어 보는 것 자체에 의의를 두기 보단 앞서 말씀드린 기술들을 이해하는 시리즈가 되기를 바랍니다.

사전준비

docker run -d --rm --privileged --name [이름] docker:27.3.1-dind-alpine3.20

위와 같이 컨테이너를 띄우고 docker exec -it [이름] sh 로 붙으면 끝입니다.(이름 지정, 별도 이미지 사용 등은 선택) 필요한 툴은 그때 그때 다운로드 받을 예정입니다. 이외에도 실습을 위한 제한이 필요한 경우 추가적인 플래그가 들어갈 예정입니다. (이번 글에서는 결과를 간략히 보여드리기 위해 도커에게 1개의 CPU만 사용 가능하도록 설정 했습니다.)

Control Group(cgroup) v2

cgroup(이하 제어그룹) v2는 프로세스를 계층적으로 구성하고 각 계층 별로 시스템 리소스를 제어할 수 있도록 지원하는 기능입니다. 이러한 제어그룹은 크게 “코어"“컨트롤러"로 나뉘어 있으며, 우리가 주로 살펴볼 내용은 실제 제어를 담당하고 있는 컨트롤러 부분입니다.

v1과 v2? v1의 다양한 문제들을 해결하기 위해 v2가 출시 됐습니다. 자세한 내용은 Issues with v1 and Rationales for v2, Understanding the new control groups API를 참고하세요.

제어그룹 디렉토리 살펴보기

본격적인 설명에 앞서 시스템에서 확인해보면 아래와 같습니다.

/ # cd /sys/fs/cgroup

/sys/fs/cgroup # ls
cgroup.controllers cpu.stat.local ......
cgroup.events cpu.weight ......

(직접 해보시면) 위에 보여드린 결과 보다 훨씬 더 많은 내용들이 나오는 걸 볼 수 있습니다. 자, 그럼 이제 디렉토리를 하나 만들어 보겠습니다.

/sys/fs/cgroup # mkdir rex

/sys/fs/cgroup # ls rex
cgroup.controllers cpu.stat.local ......
cgroup.events cpu.weight ......

디렉토리만 만들었을 뿐인데 신기하게도 거의 비슷한 형태로 파일이 만들어진 것을 볼 수 있습니다. 이는 디렉토리 생성 시 커널이 자동으로 해당 디렉토리 내부에 파일을 생성해주기 때문입니다. 결국 제어그룹은 아래 그림과 같은 계층을 유지하게 됩니다.

최상단의 루트 제어그룹(디렉토리)과 자식 제어그룹(디렉토리)들

각 파일의 용도를 쉽게 파악하기

파일들이 매우 많기 때문에 항상 문서를 참고해야 할까요? 어느 정도는 맞는 말이고, 어느 정도는 틀린 말입니다. 파일들의 자세한 정보를 확인해보면,

/sys/fs/cgroup # ls -l
total 0
-r--r--r-- 1 root root 0 Oct 3 07:57 cgroup.controllers
-r--r--r-- 1 root root 0 Oct 3 07:57 cgroup.events
-rw-r--r-- 1 root root 0 Oct 3 07:57 cgroup.freeze
--w------- 1 root root 0 Oct 3 07:57 cgroup.kill
......

r, rw, w 이렇게 세 가지가(디렉토리 제외) 보일텐데요.

  1. r: 읽기만 가능 (보통 상태나 통계를 보여줌)
  2. w: 쓰기만 가능 (입력 시 특정 행위를 하는 파일)
  3. rw: 읽고 쓰기 가능 (제한을 설정하는 파일)

위와 같이 구분하시면 적어도 각각이 어떤 역할을 하는 것인지 쉽게 파악이 가능합니다.

진짜 들어가기 전에

앞서 말씀드렸다시피 기본적으로 도커에 컨테이너를 띄워서 결과를 보여드릴 예정입니다. 그렇다 보니 실제 문서와는 다르게 보이거나 도커에서는 테스트가 불가능한 경우가 발생할 수 있습니다. 이런 경우에는 별도 VM 또는 직접 서버를 구성하여 보여 드릴 예정이고, 환경이 다른 경우 미리 말씀 드리겠습니다.

  1. 지금 보고 계신 /sys/fs/cgroup은 실제로는 루트 제어그룹이 아닙니다.(루트 제어그룹은 아래와 같이 입력하면 볼 수 있습니다.)
# docker run -it --rm --privileged --pid=host justincormack/nsenter1

# ls /sys/fs/cgroup
01-docker cgroup.stat cpuset.cpus.effective ......
cgroup.controllers cgroup.subtree_control cpuset.cpus.isolated ......

2. 왜 저렇게 명령을 입력해야 하는지는 시리즈 중 네임스페이스 부분에서 설명 드릴 예정입니다.

cgroup

앞에서 살펴본 파일들 중 “cgroup”이라는 이름으로 시작하는 파일들을 “코어 인터페이스 파일”이라고 부릅니다. 말 그대로 ‘코어' 기능을 활용하려면 이 파일들을 이용하라는 뜻 입니다. cgroup 외 다른 파일들은 X(cpu, memory 등) 컨트롤러에 속한 X 인터페이스 파일이라고 부릅니다.(ex. cpu 컨트롤러에 속한 cpu 인터페이스 파일) 그럼 cgroup으로 시작하는 파일들에 대해 알아 보겠습니다.

cgroup.procs

읽기/쓰기가 가능한 파일로 현재 제어그룹에 의해 제어되고 있거나 제어할 PID(Process ID)의 목록이 존재하는 파일입니다. 예를 들어, /sys/fs/cgroup/init에 속한 프로세스를 확인해보면 아래와 같습니다.

/sys/fs/cgroup/init # cat cgroup.procs
1
9
......

추가적으로 해당 파일에 명시된 프로세스들이 자식 프로세스를 만들면 자동으로 동일한 제어그룹에 속하게 됩니다. (변경 가능함)

✅ 잠깐! 특정 프로세스가 어느 제어그룹에 속하는지 찾고 싶다면?

/ # find /sys/fs/cgroup -name cgroup.procs -exec grep -H 9 {} \;
./cgroup.procs:9

cgroup.controllers

읽기 전용 파일로 현재 제어그룹에서 사용 가능한 컨트롤러의 목록이 포함되어 있습니다.

/sys/fs/cgroup # ls -l cgroup.controllers
-r--r--r-- 1 root root 0 Oct 3 07:57 cgroup.controllers

/sys/fs/cgroup # cat cgroup.controllers
cpuset cpu io memory hugetlb pids rdma

cgroup.subtree_control

읽기/쓰기가 가능한 파일로 하위 제어그룹들이 사용 가능한 제어그룹 목록을 지정 할 수 있습니다.

/sys/fs/cgroup # cat cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma

# 하위 제어그룹에서 cpuset, pids 컨트롤러 사용 불가 (제거)
/sys/fs/cgroup # echo "-cpuset -pids" > cgroup.subtree_control

/sys/fs/cgroup # cat cgroup.subtree_control
cpu io memory hugetlb rdma

# 하위 제어그룹에서 cpuset, pids 컨트롤러 사용 가능 (복구)
/sys/fs/cgroup # echo "+cpuset +pids" > cgroup.subtree_control

/sys/fs/cgroup # cat cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma

컨테이너를 이해하기 위해 꼭 필요한 파일은 아니지만, 두 파일의 존재를 모르는 상태로 테스트를 진행하다 보면 ‘왜 안되지?’ 라는 상황이 나올 수 있어 설명 드렸습니다.

cgroup과 관련한 내용은 여기까지 입니다. 사실 이외에도 제어그룹 자체에 대한 규칙이나 안내 사항들이 존재하지만, 해당 내용들은 필요 할 때에 하나씩 설명 드리도록 하겠습니다.

CPU 컨트롤러

아마 서버에서 가장 중요한 두 가지를 뽑으라고 하면 CPU와 메모리를 선택하시는 분들이 많을 것 같습니다.(물론 다른 모든 것들도 중요합니다.) 그런 만큼 CPU 컨트롤러 부분은 조금 더 자세하게, 그리고 깊이있게(?) 알아보려고 합니다.

cpu.weight

루트 제어그룹이 아닌 자식 제어그룹에만 존재하며, 루트 하위의 모든 제어그룹에 존재하는 cpu.weight(가중치, 기본 값 100)를 기반으로 CPU를 분배 받을 수 있도록 하는 파일입니다. 쉽게 표현하면 아래와 같습니다.

[CPU를 100% 활용하는 상황 가정]

A 제어그룹 cpu.weight: 100
B 제어그룹 cpu.weight: 200

A = 100 / (100 + 200) = 약 33% 활용 가능
B = 200 / (100 + 200) = 약 67% 활용 가능

생각보다 어려운 얘기는 아니죠? 그렇다면 B 제어그룹은 CPU를 사용하지 않고, A 제어그룹에서만 CPU를 사용한다면 어떻게 될까요? 먼저 테스트 가능한 환경을 만들어 보겠습니다.

# 1번 터미널
/sys/fs/cgroup # apk add stress-ng htop # 툴 다운로드
/sys/fs/cgroup # mkdir A B # A, B 제어그룹 생성

# 2번 터미널
/ # echo $$ > /sys/fs/cgroup/A/cgroup.procs
/ # echo 100 > /sys/fs/cgroup/A/cpu.weight

# 3번 터미널
/ # echo $$ > /sys/fs/cgroup/B/cgroup.procs
/ # echo 200 > /sys/fs/cgroup/B/cpu.weight

위 작업을 그림으로 나타내면 아래와 같습니다.

먼저, A 제어그룹에 속한 쉘에서만 stress-ng 명령(stress-ng — cpu 1)을 실행해보겠습니다.

cpu.weight이 낮아도 100% 사용 가능!

이제 이 상태에서 B 제어그룹에 속한 쉘에서 동일한 명령을 실행해보면,

CPU가 바빠지는 경우, cpu.weight 비율대로 분배됨!

계산했던 방식대로 CPU가 분배되는 것을 확인할 수 있습니다. k8s에서는 spec.containers[].resources.requests.cpu 값을 통해 이러한 설정을 지원하고 있습니다. (예상과 달리(?) 실제 시간이나 코어의 갯수를 설정하는 것이 아님.) 이를 통해 아래 2가지의 사실을 알 수 있습니다.

  1. CPU를 압축 가능한 자원이라고 하는 이유
  2. k8s에서 CPU는 requests만 지정하면 된다! 라는 주장의 근거

cpu.max

루트 제어그룹이 아닌 자식 제어그룹에만 존재하며, 해당 제어그룹에 속한 프로세스들이 일정 기간(PERIOD) 내 사용 가능한 시간(MAX, QUOTA)을 설정하는 파일입니다. 설정된 값의 형태는 아래와 같습니다.

/sys/fs/cgroup/A # cat cpu.max
max 100000 # MAX(QUOTA) PERIOD

설정 값의 단위는 us(마이크로초)이며, PERIOD(기간) 값은 최소 1000(1ms)/최대 1000000(1초)로 설정 가능합니다. MAX(QUOTA) 값을 max로 두는 경우 throttled 상태가 되지 않습니다. (throttled 상태가 되지 않는다는 것이 기간 내에서 무한히 CPU를 사용한다는 뜻은 아닙니다.)

throttled 상태에 대해서는 cpu.stat 파일을 살펴 볼 때 더욱 자세히 설명 드리겠습니다. 그럼 이제 cpu.max를 설정한 후 사용량을 보겠습니다.

/sys/fs/cgroup/A # echo "10000" > cpu.max

/sys/fs/cgroup/A # cat cpu.max
10000 100000

/sys/fs/cgroup/A # stress-ng --cpu 1

값을 하나만 입력하는 경우 앞의 값(MAX, QUOTA)만 바뀌게 됩니다. 현재 설정한 값을 해석하면 “100ms 당 10ms만 사용 가능" 이라고 볼 수 있습니다. 이제 stress-ng 명령을 실행 후 결과를 살펴보면,

대략 10% 가까이에서 표현됨

대략 10% 가까이에서 결과가 나오는 것을 볼 수 있습니다. k8s에서는 spec.containers[].resources.limits.cpu 값을 통해 이러한 설정을 지원하고 있습니다. (이 또한 예상과 달리 코어의 갯수를 설정하는 것이 아님.)

cpu.weight을 통해 CPU가 분배되는데 왜 굳이 limits를 설정하는 걸까요? 개인적인 생각으론 아래 두 가지 정도라고 생각합니다.

  1. 뒤에서 살펴 볼 throttled 상태를 통한 개선의 기회
  2. cpu.max를 통한 어플리케이션에 적용할 CPU 값 식별

물론, 이 또한 개인적인 생각이고 누군가의 주장이 틀렸다고 생각하진 않습니다. 다만, 이 글을 읽으시는 분들께선 자신의 상황에서 무엇이 더 적절할지 판단 할 수 있다면 그것으로 좋다고 생각합니다. (추가로 고민해봐야 되는 부분이 있는데 이건 memory 편에서 살펴 보겠습니다.)

이것으로 CPU 사용량을 분배하고 제한하는 파일들을 모두 살펴 봤습니다. 이제 마지막 파일인 cpu.stat을 보겠습니다.

cpu.stat

cpu.stat은 모든 제어그룹에 존재하는 읽기 전용 파일로, 각 제어그룹에 속한 프로세스들이 사용한 CPU 관련 통계를 제공합니다. 실제 형태는 아래와 같습니다.

/sys/fs/cgroup/C # cat cpu.stat
usage_usec 0 # CPU 총 사용 시간(마이크로초 단위)
user_usec 0 # 사용자 모드에서 실행된 시간
system_usec 0 # 커널 모드에서 실행된 시간

### 아래 값은 cpu.max에 MAX(QUOTA)가 설정된 경우 증가
nr_periods 0 # 스케줄러에 의해 CPU가 할당된 횟수
nr_throttled 0 # 사용 가능한 CPU 시간을 초과 또는 남은 시간이 부족하여 쓰로틀링에 걸린 횟수
throttled_usec 0 # 쓰로틀링 걸린 시간

### 아래 값은 cpu.max.burst가 설정된 경우 증가
nr_bursts 0 # CPU 버스트 모드로 전환된 횟수
burst_usec 0 # 버스트 모드에서 사용한 시간

위 값 자체의 의미가 크게 중요한건 아니고, 이걸 어떻게 활용할지를 살펴보려고 합니다. 다만, burst 관련 기능은 따로 사용해보시기 바랍니다.

nr_throttled와 throttled_usec 값은 cpu.max에 설정된 CPU 사용 가능 시간을 모두 소모하게 되면 증가하게 됩니다. (각각 throttled가 된 횟수, throttling에 걸렸던 마이크로초)

“이번 글의 대상이 아닌 독자"에서 언급했던 “3. 컨테이너의 CPU 사용률은 어떻게 측정되는가?”를 한 번 살펴보려고 합니다. 사실 엄청나게 간단합니다. 잠깐 생각해보시고 아래 내용을 보시기 바랍니다.

last_cpu_usage_us = [cpu.stat의 usage_usec]
last_time_us = [현재 시간 마이크로초]

N초 휴식

curr_cpu_usage_us = [cpu.stat의 usage_usec]
curr_time_us = [현재 시간 마이크로초]

delta_cpu_usage_us = curr_cpu_usage_us - last_cpu_usage_us
delta_time_us = curr_time_us - last_time_us

print(f"CPU 사용률: {delta_cpu_usage_us / delta_time_us * 100:.2f}%")

생각보다 되게 간단하죠? 결국 1) 현재 CPU 사용 시간과 시간을 이전 데이터로 저장해두고, 원하는 수치 만큼 휴식(sleep) 후에 다시 2) 현재 값을 구해 3) 빼고 나누는게 끝입니다. (글로 설명하니까 오히려 더 어려운 느낌이네요.)

제가 작성한 간단한 코드 한 번 살펴 보겠습니다.

어려운 내용은 아니니 천천히 살펴보시기 바랍니다. 그럼 실제 값은 어떻게 나오는지 살펴보겠습니다.

/ # apk add python3

/ # python3 cpu_stat_only_cpu_usage.py
99.89%
99.86%
......

stress-ng --cpu 1 명령을 통해 실행 중인 제어그룹을 대상으로 CPU 사용률을 계산 해봤습니다. 직접 cadvisor 또는 도커 데스크탑의 CPU 사용률과 비교해보시면 대략 비슷하게 나오는 것을 확인 할 수가 있습니다. (user_usec, system_usec를 통해 어떤 모드에서 더욱 많이 사용 되는지도 계산이 가능하겠죠?)

이제 cpu.max를 설정 했을 때 증가하는 값들도 살펴 보겠습니다.

/sys/fs/cgroup/A # echo 20000 > cpu.max

/sys/fs/cgroup/A # stress-ng --cpu 1 & # 백그라운드에서 실행

/sys/fs/cgroup/A # cat cpu.stat
......
nr_periods 208
nr_throttled 176
throttled_usec 14205095
......

기존엔 오르지 않았던 3개의 값이 증가한 것을 볼 수 있습니다. 이 또한 값 자체가 중요하다기 보다, 남은 질문과 고민에 대해서 해결해보시면 좋을 것 같습니다.

4. CPU 사용률이 100%가 아닌데 왜 throttled 상태가 되는가?

❓CPU 사용률 측정 주기를 5초로 설정하고, 중간에 stress-ng --cpu 1 명령을 2초 정도 실행해보자. 1) CPU 사용률이 100%인가? 2) throttled 비율은 어떻게 되는가?

5. 일정량의 RPS 또는 TPS가 목표일 때, 내 어플리케이션에 적용해야할 CPU값은 어떻게 알 수 있는가?

1) 어플리케이션을 띄우는데 사용될 CPU 시간을 구할 수 있는가? 2) RPS 1, 100, 10000일 때 내 어플리케이션은 CPU를 어느 정도 사용하는가? 3) 실시한 테스트가 현실적인가? 4) 현실적이지 않다면 어떤 부분 때문인가? 5) 결과가 더욱 좋게 나오는가? 나쁘게 나오는가? 예상과 같을까?

이것으로 cgroup과 cpu에 대한 이야기가 끝이 났습니다. 다음 편은 memory와 관련되 얘기로, 여러가지 사실과 오해에 대해서 알아 볼 예정입니다.

참고

  1. https://docs.kernel.org/admin-guide/cgroup-v2.html
  2. https://github.com/google/cadvisor

--

--

Responses (1)