IT

쿠버네티스 자원 할당에 대한 고려

kunypony 2025. 4. 20. 22:13
728x90

쿠버네티스에서 Pod의 cpu, memory, hugepages를 포함한 리소스에 대해서 Request, Limit을 지정할 수 있습니다. 말 그대로 자원의 최소, 최댓값을 주어 파드마다 리소스를 보장할 수 있습니다. 

 

적용 방법은 k8s ko 공식 문서에서 손 쉽게 확인할 수 있습니다. 참고

 

그런데 실제로 쿠버네티스를 운영하다보면 cpu를 지정한 limit만큼 사용하지 않아도 throttling이 발생하는 것을 확인되었습니다.

throttling은 쉽게 설명드리면 컨테이너가 limit 이상으로 사용될 경우 커널이 해당 cpu를 사용하지 못하게 일정 시간 동안 강제로 중단시키는 것을 말합니다. 이렇게 스스로 정의했었기에 throttling이 발생하는 것이 의아했습니다. 이것을 이해하기 위해서는 쿠버네티스가 어떻게 파드에게 cpu를 할당하는지 원리와 동작 과정을 알아야 합니다. 이 부분은 밑에서 살펴보겠습니다.

 

쿠버네티스에서는 리소스를 compressible 리소스와 imcompressible 리소스로 나눌 수 있습니다. compressible 리소스의 대표 예는 cpu, imcompressible 리소스는 memory를 둘 수 있습니다. cpu는 사용량을 늘리거나 줄일 수 있지만 memory의 경우는 강제로 줄이다보면 oom(out of memory)가 발생할 수 있기 때문입니다. 공식 문서를 참고하면 cgroup v1 및 이전 기능에서는 spec.containers[].resources.requests["memory"]를 전혀 고려하지 않고 사실상 무시하는 것과 다름없다고 설명합니다. 하지만 다행히도 cgroup v2에서는 메모리도 cpu처럼 QoS(Quality-of-Service)를 줄 수 있다고 합니다. 궁금하시는 분은 해당 글을 참고해주세요.

 

QoS에 대해 간략히 정리하면 다음과 같습니다. 

쿠버네티스에서는 지정된 Limit와 Request에 따라 내부적으로 우선 순위를 계산 후 안정성을 구분합니다. 쉽게 생각하시면 중요도가 높은 서비스의 품질을 보증하는 시스템이라고 생각하시면 됩니다. 이 우선 순위로 노드의 자원 부족으로 인해 종료되는 파드, 프로세스가 결정됩니다.

 

총 세가지 클래스로 파드를 구분합니다.

BestEffort
- 파드 안의 어떤 컨테이너에도 Resource Requests와 Resource Limit이 설정되어 있지 않았을 때 설정 된다.
- 노드의 자원이 없어 노드 축출이 발생 할 경우 가장 먼저 축출되는 Class 이다. 
- 노드의 자원은 사용할 수 있으나, 다른 Pod QoS Class에서 선점한 자원은 사용하지 못한다.

Bustable
- BestEffort와 Guaranteed 이외의 경우에 설정된다.
- 요청에 따라 하한선의 자원은 보장하지만 특정한 제약을 요구 하지 않는다. 
- 만약 제약(limit)을 설정하지 않을 경우 쿠버네티스는 노드의 상태에 따라 제약을 정의 한다. 
- 노드의 가용 자원이 많을 경우 유연하게 리소스를 늘릴 수 있다.
- Pod 축출이 발생 할 경우 BestEffort Pod가 모두 제거 된 뒤에 Burstable Pod가 추축 된다.

Guaranteed
- CPU와 메모리 둘 다에 Resource Requests와 Resource Limits가 설정되어 있는 경우 
- 파드 안의 각각의 컨테이너에 설정되어 있는 경우
- Resource Requests와 Resource 값이 각각 똑같은 경우에 설정된다.
- 가장 엄격한 자원 제약을 적용받으며, Pod 제거 우선순위가 가장 낮다.

/proc/<pid>/oom_score_adj
QoS | oom_score_adj
Guaranteed | -997 (최저, 거의 안 죽음)
Burstable | 0 ~ 양수
BestEffort | +1000 (최고, 제일 먼저 죽음)

 

oom이 발생하지 않도록 자원을 할당하고 모니터링하는 것이 중요하겠지만 언제나 이런 상황을 고려하여 작업 해야합니다.

해당 깃허브에서 메모리 QoS에 대해 cgroup v2에서 어떻게 동작하는지 확인할 수 있습니다. 

memory.high의 경우 heavy reclaim pressure를 계속 받게 되고 이를 넘어 memory.max가 되면 oom이 발생한다고 합니다. 

 

물론 oom은 memory cgroup 레벨에서 limit 이상 메모리 사용 시 동작하는 cgroup OOM Killer와 우리가 흔히 알고 있는 커널 레벨에서의 Kernel oom을 구분하여 알 필요가 있습니다. 위 QoS의 oom_score_adj의 값은 경우는 후자에 포함됩니다. 

Kernel OOM Killer는 시스템(OS)의 memory 사용률이 높아져 가용 메모리가 부족해지는 경우, 프로세스를 종료합니다.

가용 메모리가 /proc/sys/vm/min_free_kbytes 에 설정된 값보다 적어지면, Kernel OOM Killer가 동작하고, 이때 oom_score를 고려하여 종료할 프로세스를 선별합니다. oom_score는 프로세스의 메모리 사용량과 중요도를 기반으로 계산되며, 점수가 높은 프로세스가 종료 대상이 됩니다. oom_score 계산 시에는 oom_score_adj 값이 영향을 미칩니다. 

 

실제로 워커노드로 들어가 cgroup을 확인해보면 QoS 클래스 별로 구분되어있는 것을 확인할 수 있었습니다. 상위에서 노드 전체·QoS별 총량을 조절하고, 하위에서 파드/컨테이너별로 세밀하게 제한하는 것으로 보입니다. OOM 시 부모가 먼저 살고, 자식부터 정리하는 느낌으로도 보입니다. 

/sys/fs/cgroup/   ← 루트
 ├─ system.slice/               ← OS‑서비스(sshd 등)
 ├─ user.slice/                 ← 사용자 세션
 └─ kubepods.slice/             ← 쿠버네티스 전용
      ├─ kubepods-besteffort.slice/
      ├─ kubepods-burstable.slice/
      │    └─ pod<UID>/         ← Burstable 파드

 

 

따라서 oom이 발생하지 않기 위해 파드에게 적절한 limit을 제공할 필요가 있습니다. 만약 Java의 경우라면 heap 공간과 non heap 영역을 고려해서 제한해야 합니다. heap의 min/max를 지정하고 그것보다 여유로운 pod의 limit을 지정해도 되고, pod의 limit을 먼저 지정하고 -XX:MaxRAMPercentage, -XX:MinRAMPercentage JVM option을 주어 제한된 리소스 중 비율로 heap 공간을 할당할 수 있습니다. 물론 gc 발생 빈도나 pause time을 모니터링하면서 적정 수치를 찾는 것이 중요할 것으로 생각됩니다. 메모리는 request와 limit을 동일하게 가져가는 것이 좋을 것으로 예상됩니다. request는 파드가 스케줄 될 때 적정 노드를 필터링하고 스코어링하는데 사용되는 중요한 지표입니다. 이를 동일하게 가져가는 것이 추후 애플리케이션 동작 과정에서 노드의 memory 부족으로 인한 oom을 방지할 수 있을 것이라 생각됩니다. 

 

cpu에 대한 할당도 중요합니다. 이전에 말씀드린 거처럼 쿠버네티스가 어떻게 cpu를 스케줄링하는지 동작 과정을 이해하는 것이 적절한 request와 limit을 설정하는데 도움을 줄 것입니다. 

 

spec.containers[].resources.requests.cpu로 설정을 하게 됩니다.  cpu는 cpu 단위로 지정하게 되는데 1 이라고 한다면 1 물리 cpu 코어나 1 vcpu(가상 코어)를 의미합니다. AWS와 같은 퍼블릭 클라우드에서는 VM이 할당되고 보통 1 vcpu가 될 것입니다. 1, 0.5로 지정할 수도 있지만 1000m, 500m으로 지정할 수도 있습니다. 1000m은 1 cpu를 의미합니다. 이 requests는 기본적으로 Pod을 스케줄링하는 데 사용하는데 1000m으로 설정했다면 노드 중 1 cpu의 여유가 있는 노드에 배치됩니다.

 

cpu requests의 값은 스케쥴링된 후에는 cgroup에 cpu share라는 단위로 변환됩니다. 500m을 설정했다면 1024를 곱해서 1024 x 0.5로 512 cpu share가 적용됩니다.

cpu share라는 값으로 변환하는지 의아할 수 있는데 이는 노드 내에서 cpu를 얼마나 사용할지 계산하기 위해서입니다. 노드 내에서 cpu 스케줄링은 CFS(Completely Fair Scheduler)가 담당하게 되는데 cpu가 부족할 때 CFS가 이 cpu share의 값을 이용해서 더 많은 share를 가진 프로세스에 cpu time을 할당하게 됩니다. (requests를 아예 설정하지 않으면 2 CPU share가 설정됩니다.)

이 말은 즉 cpu share에 정의된 값들로 cpu 사용 비율이 결정된다는 것입니다.

 

예시를 들면 이해가 더 쉬울 것입니다. 

하나의 노드에 파드 4개를 띄우고 cpu.request를 500m을 할당합니다. 그렇다면 0.5 * 1024 = 512 share 값이 cpu.share에 적용될 것입니다. 그리고 각 파드들이 모든 cpu를 사용하기위해 부하를 줬다고 가정해봅니다. 

cpu 500m 파드 4개가 cpu를 최대로 쓸 경우

 

각 파드들은 모두 cpu time을 25%씩 노드에 할당된 모든 cpu를 사용하려고 합니다. 노드에 cpu가 4core 또는 16core 여도 모든 core를 사용하려 할 것이고 share 값이 동일하기 때문에 모두 동일한 비율로 사용을 합니다.

 

만약 하나의 파드에 request를 1500m으로 변경하면 1536share 값을 가지게 되고 나머지 파드들은 모두 동일하게 512share라고 가정해봅니다.

하나의 파드가 1500m request를 주었을 경우

위 그림처럼 기존 512share의 파드는 16.6%, 1536share은 50%의 cpu time을 사용할 것입니다. 

 

물론 위 모든 가정은 각 파드들이 cpu를 최대로 쓰려고 할 때를 나타냅니다. 만약 cpu를 최대로 쓰는 환경이 아니라면 비율이 높은 1536share을 가진 파드도 낮은 cpu time을 사용합니다.

1536share 파드의 cpu 사용률이 적은 경우

위 그림 처럼 더 많이 비율을 차지하고 있는 파드일지라도 사용률이 없다면 다른 파드들이 더 사용할 수 있게 됩니다.

따라서 request는 최대 사용할 경우 비율을 나타낼 수도 있습니다. 이는 최소 cpu를 보장하는 것으로 해석할 수도 있습니다.

 

예를 들어 10core의 cpu가 있는 노드에 1core(1024 share)의 request를 할당한 파드가 배포되었다고 가정해봅니다. 이 노드에는 최대 9core(9216 share)의 파드가 할당될 수 있을 것입니다. 만약 다른 파드가 없으면 10core를 모두 사용 가능하고 9core(9216 share) 파드가 배치되어도 최대 사용시 1core(1024 share)를 사용할 수 있습니다.

 

따라서 limit을 설정하지 않으면 최소 할당한 request 만큼의 cpu time을 보장하고 남은 cpu 자원까지 활용할 수 있습니다. 위에서 살펴본 Bustable이 해당 경우의 파드가 속하며 왜 이름이 Bustable인지 알 수 있습니다.

 

이번에는 request가 아닌 limit의 동작 방식을 알아보겠습니다. limit에는 쿼타(quota)라는 것이 동작합니다. 

위에서 말씀드린 cpu를 스케줄링하는 CFS에는 cpu.cfs_peroid_us와 cpu.cfs_quota_us 두 가지 파라미터가 있습니다. 이전부터 계속 cpu를 cpu time으로 설명을 드렸습니다. 하나의 cpu 코어는 병렬처럼 보여도 시간을 작게 쪼개서 나눠쓰게 됩니다. cpu.cfs_quota_us는 쉽게 설명하면 cpu를 쓸 수 있는 시간입니다. cpu.cfs_quota_us를 10000us(=10ms)으로 설정할 경우 해당 cgroup은 10ms 시간만큼만 CPU를 사용할 수 있습니다. 당연하게도, cpu.cfs_quota_us를 통해 설정된 사용가능한 cpu time은 특정 주기로 복구됩니다. 이cpu time을 cpu.cfs_period_us라고 합니다. cpu.cfs_period_us의 기본 값은 100ms입니다.

 

그림으로 설명하면 이해가 더 쉬울 것 입니다.

cpu limit을 1000m으로 지정한 경우 1 cpu를 사용한다는 의미로 100ms를 사용한다는 의미입니다.  

따라서 cfs_quota_us가 100ms로 설정되고 다음처럼 1 core의 100ms 동안 모두 사용 가능할 것입니다. 

cfs_quota_us가 100ms로 설정된 경우

Node js와 같이 싱글 스레드로 동작하게 된다면 위 처럼 하나의 core를 모두 사용할 것입니다.

 

하지만 동일하게 cfs_quota_us가 100ms이지만 4 core를 모두 사용하는 경우는 나머지 75ms 동안은 throttling이 발생합니다.

4 core를 모두 사용할 경우

위 그림은 Java/Spring과 같이 멀티 스레드로 동작하는 애플리케이션에서 여러 코어를 사용하는 경우로 예상할 수 있습니다.  

쿼타를 모두 소진했지만 cpu.cfs_period_us인 100ms가 지나면 다시 쿼타만큼 동작이 가능해집니다.  

 

정리하자면 cpu.cfs_period_us 동안  cpu.cfs_quota_us를 모두 소진하면 throttling이 발생하게 되는 것입니다.   

1초(1000ms)는 10번의 cpu.cfs_period_us(100ms)가 동작하게 됩니다. 순간 트래픽이 발생한 경우 throttling이 발생할 수 있지만 초 단위의 평균 cpu를 확인하면 현재 cpu가 안정적인데 throttling이 발생한 것을 확인할 수 있습니다. 물론 limit을 지정하지 않으면 쿼타가 동작하지 않습니다. cpu.cfs_period_us 또한 조정이 가능합니다. 

 

그렇다면 limit을 걸지 않은게 좋은 건가 생각이 들 수 있습니다. 

갑작스런 트래픽에 유용할 것 같지만 단점도 존재합니다. 예를 들어 모든 파드가 모든 cpu를 사용하려고 할 것입니다. cpu A에게 모든 파드들에 대한 컨텍스트 스위칭이 발생할 것입니다. 또한 Guaranteed보다 QoS 우선순위가 밀립니다. 성능 또한 예측이 안되는 것도 단점일 것 같습니다. 낮에는 다른 파드들에게 요청이 없어서 cpu를 많이 사용 가능하고 밤에는 전반적으로 요청이 많은 경우 cpu를 request 만큼만 사용 가능할 수도 있습니다. 

 

자원 할당에 관련해서는 다양한 의견들이 충돌하는 것을 볼 수 있었습니다.

해당 글에서는 쿠버네티스위에 스프링 애플리케이션을 올릴 때 메모리 설정에 관련된 참고 글들을 볼 수 있는데 의견이 정말 다양했습니다. 

 

쿠버네티스 초기 엔지니어 중 한명인 tim hockin은 이렇게 말했습니다. 해당 링크를 클릭하시면 tim hockin에 소개를 볼 수 있습니다.

https://x.com/thockin/status/1134193838841401345

 

tim hockin은 memory의 경우 limit과 request를 동일하게 설정하고 cpu limit을 설정하지 않는 방향으로 조언하기도 했습니다. 

 

request 기반으로 동작하는 hpa를 통해 스케일아웃하여 cpu usage를 안정화하는 것도 방법이 될 수 있습니다. 

스스로 정답을 내리기보다 주어진 환경에 맞춰서 계속 변경하는게 좋지 않을까 생각이 듭니다. 추가 개발 건에 대해서 cpu bounded가 급증할 수도 있고 아키텍쳐가 변경될 수도 있습니다. 

 

필자가 잘못 해석한 부분이 있을 수 있으니 편하게 댓글로 피드백 주시면 확인 후 반영하도록 하겠습니다. 또는 좋은 의견이 있다면 남겨주시면 감사하겠습니다.