-
[번역] Kubernetes의 resource memory limit 이해하기SW개발/Kubernetes 2021. 4. 26. 20:47
참고) 글을 좀 더 쉽게 이해하게 하기 위해 의역이 된 내용이 많습니다. 그리고 불필요해 보이는 내용은 빠져있고, 요약된 내용이 많으니 참고해주세요.
내가 쿠버네티스를 사용할 때, 테스트 단계에서 일어나지 않았던 이슈를 만나게 되었다. 그것은 바로 노드에 pod를 운영할만한 충분한 cpu나 memory가 없으면 pod가 pending 상태로 남아있게된다는 것이다. 노드에 cpu나 ram을 추가할 수 없을때, 어떻게 이 문제를 해결해야할까? 가장 단순한 답변은 노드를 하나 추가하는 것이다. 하지만 이것은 쿠버네티스의 가장 강력한 장점 중 하나인 "compute resource를 효율적으로 이용하는 것"을 잘 활용하지 못하는 것이다. 진짜 문제는 노드가 제공할 수 있는 resource가 너무 작은게 아니라 pod에 limit을 지정하지 않았다는 점이다.
Resource Limit이란 쿠버네티스의 장점을 효율적으로 이용하기 위해서 사용되는 값으로, 운영하려는 workload(deployment 등)에 operating parameter로 pod가 운영되기 위해 필요한 자원(request)와 최대로 활용할 수 있는 자원(limit)을 명시해주는 것을 의미한다. pod가 운영되기 위해 필요한 자원 request는 pod를 어떤 노드에서 운영할지 결정하기 위해 scheduler에 전달하는 값이고, limit은 각 노드에 설치된 kubelet을 통해 pod의 health를 책임지기 위해 필요한 파라미터이다. 내가 이 글을 통해 살펴보고자 하는 것은 첫번째로는 memory limit을 좀 더 깊게 들여다보는 것이고, 두 번째는 cpu limit을 다루는 것이다.
Resource Limits
Resource Limit이란 container 마다 containerSpec의 resource property를 사용하여 셋팅되어질 수 있는 값으로 resource property안에 CPU와 Memory에 대한 request, limit값 지정이 가능하다. 예를들면 아래와 같다.
resources: requests: cpu: 50m memory: 50Mi limits: cpu: 100m memory: 100Mi
위의 내용을 조금 해석해보자면, 일반적인 운영상황에서 container는 1core 5% 즉, 0.05core를 요구하고, ram은 50 메비바이트를 요청한다. 그리고 이 컨테이너가 최대로 사용할 수 있는 리소스(limit)는 cpu 0.1core와 100메비바이트의 ram이다. 앞서도 이야기했지만, 나는 request와 limit의 차이에 대해 더 많이 이야기하려고 한다. 그리고 이건 꼭 기억하자. 일반적으로 의미하는 request는 schedule time에 중요하고, limit은 run time에 중요하다는 점을 말이다.
Memory Limit
먼저 memory limit부터 살펴보자. 이 글에서 다루고자 하는 내 목표 중 하나는 "Kubernetes가 container runetime(docker / containerd in this case)으로 해당 작업을 위임하고, container runtime이 linux kernel에 작업을 위임하게 되는지" 그 시스템을 살펴보고자 한다.
일단 어떻게 container process를 컨트롤하는데 사용되는지 살펴보기 위해서 먼저 memory limit 제약이 없는 경우를 살펴보고자 한다.
$ kubectl run limit-test --image=busybox --command -- /bin/sh -c "while true; do sleep 2; done" deployment.apps "limit-test" created
kubectl을 통해 pod의 resource와 limit이 어떻게 적용되었는지 상태를 확인해보자
$ kubectl get pods limit-test-7cff9996fc-zpjps -o=jsonpath='{.spec.containers[0].resources}' map[]
좀 더 디테일하게 pod가 돌고있는 노드에 접속하여 docker에서 돌아가는 container를 확인해보자
$ docker ps | grep busy | cut -d' ' -f1 5c3af3101afb $ docker inspect 5c3af3101afb -f "{{.HostConfig.Memory}}" 0
위에 나온 0이라는 결과는 도커 컨테이너 내에 어떠한 limit도 지정되어있지 않음을 나타낸다. 도커는 이 0을 가지고 무얼 할 수 있을까? 컨테이너 프로세스가 접근할 수 있는 memory의 양을 컨트롤하기 위해서, 도커는 control group(줄여서 cgroup)의 property를 설정한다. cgroup은 linux kernel 2.6.24 버전에 추가된 기능이다. 이건 다루기엔 꽤 큰 주제인데, 이해를 위해 간략하게 소개하고자 한다. cgroup은 커널이 프로세스를 운영하는 방법을 통제하는 것과 관련있는 property들의 집합이다. cgroup은 memory, cpu, devices 등등을 컨트롤 한다. cgroup은 각 cgroup이 property를 부모로부터 상속받는 계층구조로 되어있고, 당연히 root cgroup은 system을 통해 만들어진 cgroup을 의미한다.
cgroup은 파일시스템의 '/proc'과 '/sys'를 통해 쉽게 확인이 가능하다. 도커가 어떻게 우리의 컨테이너를 위해 memory cgroup을 설정했는지 보기 위해 간단한 예제를 보자.
$ ps ax | grep /bin/sh 9513 ? Ss 0:00 /bin/sh -c while true; do sleep 2; done $ sudo cat /proc/9513/cgroup ... 6:memory:/kubepods/burstable/podfbc202d3-da21-11e8-ab5e-42010a80014b/0a1b22ec1361a97c3511db37a4bae932d41b22264e5b97611748f8b662312574
위의 결과로부터 알 수 있는 것은, 내가 위에서 언급한대로 cgroup에 계층구조(hierarchy)가 존재한다는 것이다. 첫번째 path는 kubepods라는 cgroup이고, 이것은 우리의 프로세스가 그 그룹에 있는 모든것을 상속할 것이라는 것이다. 그리고 burstable 그룹으로부터 나오는 것도 마찬가지로 상속받는다. 그리고 그 뒤에 나타나는 것은 pod를 대표하는 구분자 정도로 생각하면 될것 같다. 그리고 마지막 path는 우리 프로세스의 memory cgroup 이다. 이것을 더 자세히 살펴보기 위해서 우리는 그 path에 '/sys/fs/cgroups/memory'를 붙이면 된다. 아래와 같이 말이다.
$ ls -l /sys/fs/cgroup/memory/kubepods/burstable/podfbc202d3-da21-11e8-ab5e-42010a80014b/0a1b22ec1361a97c3511db37a4bae932d41b22264e5b97611748f8b662312574 ... -rw-r--r-- 1 root root 0 Oct 27 19:53 memory.limit_in_bytes -rw-r--r-- 1 root root 0 Oct 27 19:53 memory.soft_limit_in_bytes
나는 이 결과에서 memory.soft_limit_in_bytes는 제쳐두고 memory.limit_in_bytes property를 살펴보고자 한다. 참고로, 이것은 memory limit을 set하는 것을 의미하며, 도커 명령어 실행시 사용되는 --memory argument와 kubernetes의 resource limit과 같은 역할이라고 보면된다.
$ sudo cat /sys/fs/cgroup/memory/kubepods/burstable/podfbc202d3-da21-11e8-ab5e-42010a80014b/0a1b22ec1361a97c3511db37a4bae932d41b22264e5b97611748f8b662312574/memory.limit_in_bytes 9223372036854771712
위의 결과값은 내 시스템에 어떠한 limit도 설정되어있지 않은 것을 의미한다. 이것을 통해 kubernetes에 메모리 limit을 설정하지 않은 것이 도커가 container의 HostConfig.Memory 값을 0으로 만들게 되었고, 이것이 system의 memory cgroup에서 memory.limit_in_bytes값을 통해 기본값인 "no limit"으로 설정되었음을 추측해볼 수 있다.
이제 메모리 limit 을 100 메비바이트로 주고 파드를 생성하면 어떻게 되는지 보자.
$ kubectl run limit-test --image=busybox --limits "memory=100Mi" --command -- /bin/sh -c "while true; do sleep 2; done" deployment.apps "limit-test" created
kubectl을 통해 스펙을 살펴봄으로써 잘 적용되었는지 확인해보자
$ kubectl get pods limit-test-5f5c7dc87d-8qtdx -o=jsonpath='{.spec.containers[0].resources}' map[limits:map[memory:100Mi] requests:map[memory:100Mi]]
결과가 조금 특이하다. 우리는 limit만 설정했는데, request의 값도 설정되어있다. 이건 pod에 limit만 설정하는 경우 kubernetes가 기본적으로 limit과 동일한 값으로 request를 설정해주기 때문이다. 이제 도커가 컨테이너와 프로세스의 memory cgroup을 어떻게 설정했는지 살펴보자.
$ docker ps | grep busy | cut -d' ' -f1 8fec6c7b6119 $ docker inspect 8fec6c7b6119 --format '{{.HostConfig.Memory}}' 104857600 $ ps ax | grep /bin/sh 29532 ? Ss 0:00 /bin/sh -c while true; do sleep 2; done $ sudo cat /proc/29532/cgroup ... 6:memory:/kubepods/burstable/pod88f89108-daf7-11e8-b1e1-42010a800070/8fec6c7b61190e74cd9f88286181dd5fa3bbf9cf33c947574eb61462bc254d11 $ sudo cat /sys/fs/cgroup/memory/kubepods/burstable/pod88f89108-daf7-11e8-b1e1-42010a800070/8fec6c7b61190e74cd9f88286181dd5fa3bbf9cf33c947574eb61462bc254d11/memory.limit_in_bytes 104857600
위의 결과를 살펴보면, 우리가 지정한대로 프로세스의 memory cgroup이 우리가 지정한 값으로 잘 설정되어 있는 것을 확인할 수 있다. 그러나 이것이 runtime 에서 의미하는게 무엇일까? 리눅스 메모리 관리는 복잡한 주제지만, 쿠버네티스 엔지니어라면 이것을 아는게 중요하다. 호스트가 memory pressure를 겪을 때, 커널은 kill 시켜야 할 프로세스를 선택해야한다. (하단의 update 1 참고) kubernetes의 역할은 pod를 node에 할당하는 것이기 때문에, 노드에서 memory pressure가 나는 것은 일반적이지 않다. 만일 컨테이너가 너무 많은 메모리를 사용한다면, OOM-Killed가 되기 쉽다. 만일 도커가 커널로부터 알람을 받으면, 쿠버네티스는 해당 문제를 도커로부터 발견하여 셋팅값에 따라 그 파드를 재시작할 것이다.
이제는 kubernetes에서 pod를 기본값으로 만들었을 때, memory request가 100Mi로 자동 설정되는걸 살펴보자. 100Mi memory request가 cgroup에 영향을 미칠까?
$ sudo cat /sys/fs/cgroup/memory/kubepods/burstable/pod88f89108-daf7-11e8-b1e1-42010a800070/8fec6c7b61190e74cd9f88286181dd5fa3bbf9cf33c947574eb61462bc254d11/memory.soft_limit_in_bytes 9223372036854771712
soft_limit_in_bytes가 역시나 "no limit"값인 9223372036854771712 으로 설정되어있다. docker가 docker run시에 --memory-reservation argument를 통해 soft limit 셋팅값 설정하는 것을 지원함에도 불구하고, kubernetes는 그것을 사용하지 않는다. 컨테이너에 memory request를 정의하는게 중요하지 않다는 의미일까? 당연히 이건 아니다. 만약에 그런 경우가 있다면, reqeust가 limit보다 값이 큰 경우일 것이다. limit은 리눅스 커널에 프로세스가 메모리를 해제하기 위한 후보군으로 고려하는 시점을 말한다. reqeust는 쿠버네티스 스케줄러가 파드를 어떤 노드에서 운영할지 결정하는 것을 돕는다.
예를들면, memory request가 없고 limit만 높은 파드를 생각해보자. 우리가 위에서 보았던 대로 request는 limit값과 동일하게 설정할 것이고, 만일 어떤 노드도 limit과 동일한 값을 가진 request를 만족하지 못한다면, 실제 운영에 필요한 memory 사용량이 limit보다 훨씬 적더라도, 파드는 어떤 노드에도 할당되지 못할 것이다.
반면, request값을 너무 작게주고 파드를 띄우면 커널이 해당 파드를 oom-kill 시킬것이다. 예를들면 pod가 일반적으로 100Mi의 메모리를 사용하는데 request를 50Mi만 준 경우를 생각해보자. 만일 당신이 75Mi 만큼의 공간이 남은 노드를 생각해보면, 스케줄러 입장에서는 거기에 pod를 띄우려고 할 것이다. 나중에 pod의 메모리 소비량이 100Mi로 확장되는 경우, 이것은 노드가 memory pressure에 높이게 되는 상황이 벌어질 것이다. 따라서, pod에 memory request와 limit을 설정하는 것은 중요하다.
이 포스트는 쿠버네티스 컨테이너 메모리 limit이 어떻게 셋팅되고, 실행되는지를 이해하게 돕고, pod의 container에 왜 limit을 설정하는게 중요한지 살펴보았는데, 이 글을 읽는 독자에게 설명한 내용이 잘 전달되었으면 좋겠다.
[update 1]
메모리 cgroup에 없는 프로세스는 전역 oomkiller에 의해 처리되며, 커널이 페이지를 할당 할 수 없는 경우, 중요한 프로세스가 죽는 것을 보호하기 위한 장치인 oom adjust score라고 불리는 요소를 기반으로, 가장 많은 물리 메모리를 사용하는 프로세스를 죽이게 됩니다.
memory cgroup에 있는 프로세스들은 cgroup oomkiller에 영향을 받게됩니다. 참고로 cgroup oomkiller는 설정된 limit 값을 초과하는 프로세스들을 죽이게 됩니다. 이러한 경우에는 'Memory cgroup out of memory: kill process ...'로 시작하는 oomkiller의 log메세지를 볼 수 있습니다.
원문) medium.com/@betz.mark/understanding-resource-limits-in-kubernetes-memory-6b41e9a955f9