콘텐츠로 건너뛰기

Kubernetes Pod Memory Monitoring — RSS, Working Set

이 글은 Kubernetes Pod의 memory 를 모니터링할 때 어떤 메트릭을 봐야하는가에 대한 글이다. 컨테이너의 memory 와 관련된 메트릭이 여러가지가 있으며 각각은 어떤 것이며 어떤 메트릭을 살펴봐야하는지에 대해서 정리한다.

이 글을 보기 전에 Virtual Memory 에 대해서 먼저 이해를 하고 읽으면 좋다.

Virtual Memory에 대한 글은 사실 이번 포스팅을 위한 빌드업이었다.

cgroup

가장 먼저 살펴볼 부분은 cgroup에 대한 것이다. 리눅스의 프로세스들은 fork를 하면서 자식 프로세스들을 만들어나갈 수 있다. 리눅스는 이렇게 트리구조로 뻗어나가는 프로세스들을 묶어서 하나의 그룹을 만들고 해당 그룹의 리소스 사용량을 모니터링 해야할 필요가 있었다.

대표적인 예로는 Fork bomb이 있는데 프로세스가 fork를 통해서 무한히 자식 프로세스들을 만들어나가며 컴퓨터 자원을 고갈시키는 공격을 말한다. cgroup을 통해서 이렇게 뻗어나가는 한 그룹의 리소스 사용량을 모니터링하고 특정 자원을 많이 사용하는 것이 관찰되면 커널에서 해당 자원 사용을 억제하는 것이 가능해졌다.

정리하면 cgroup은 프로세스들을 하나의 그룹으로 묶어 해당 그룹의 리소스(memory, CPU, 네트워크 I/O)를 제한하고 모니터링하는 도구, 수단이다. 이 때 cgroup이 관리하는 그룹을 control group이라고 하자(cgroup과 control group은 다르고 cgroup이 control group들을 관리한다).

그리고 각각의 리소스 종류마다 control group들로 이루어진 파일 시스템 hierarchy가 있다. 그리고 hierarchy마다 그것을 제어하는 cgroup controller가 존재한다. 모든 리눅스 프로세스는 각 자원 종류마다 그것의 상태를 나타내는 hierarchy가 존재한다. 한 프로세스가 처음 생성될 때 프로세스는 부모 프로세스의 제어 그룹들을 물려받는다.

리눅스 커널은 virtual filesystem을 통해서 control group들과 통신하는데, /sys/fs/cgroup 이 그런 용도로 쓰인다. 이 디렉터리의 내용을 나열하면 여러 종류의 control group들과 각 리소스 종류의 hierarchy들을 볼 수 있다. virtual filesystem에 대한 개념은 좀 더 학습이 필요하다. cgroup에 대해서 더 궁금하면 이 글을 읽어보면 좋다.

각 리소스 종류마다 control group들을 관리한다는 것은 결국 virtual filesystem에 있는 파일들과 디렉터리들을 읽고 쓰는 것에 해당한다.

그 중에 우리가 유심히 살펴봐야될 부분은 메모리 부분이고 메모리에 해당하는 hierarchy는 /sys/fs/cgroup/memory 에 위치해있다. 이 파일 중에 어떤 것은 control group을 조작하기 위해 매개변수로 쓰이기도 하고 어떤 것은 현재 이 control group의 상태를 나타내기도 한다.

g14wnrkim@lab:/sys/fs/cgroup$ ls memory/
 cgroup.clone_children           memory.kmem.tcp.limit_in_bytes      memory.stat
 cgroup.event_control            memory.kmem.tcp.max_usage_in_bytes  memory.swappiness
 cgroup.procs                    memory.kmem.tcp.usage_in_bytes      memory.usage_in_bytes
 cgroup.sane_behavior            memory.kmem.usage_in_bytes          memory.use_hierarchy
 memory.failcnt                  memory.limit_in_bytes               notify_on_release
 memory.force_empty              memory.max_usage_in_bytes           release_agent
 memory.kmem.failcnt             memory.move_charge_at_immigrate     system.slice
 memory.kmem.limit_in_bytes      memory.numa_stat                    tasks
 memory.kmem.max_usage_in_bytes  memory.oom_control                  user.slice
 memory.kmem.slabinfo            memory.pressure_level
 memory.kmem.tcp.failcnt         memory.soft_limit_in_bytes

그런데 왜 곧바로 Kubernetes Pod과 관련된 메트릭에 대한 설명을 하지 않고 cgroup에 대한 설명부터 시작한 것일까?

그것은 바로 Pod의 기본이 되는 Container의 메트릭이 cgroup이 기록한 정보들을 토대로 뽑히기 때문이다. 좀 더 완전한 설명을 하기위해서는 Container가 어떤 것인지부터 설명을 해야겠지만 그 개념에 대해서는 다른 글을 통해서 정리하겠다.

Memory와 관련된 메트릭들

메모리를 모니터링 해야하는 가장 큰 이유는 무엇일까? 메모리는 Kubernetes에서 압축불가능한(incompressible) 리소스이기 때문에 메모리 사용량이 배포시 설정한 메모리 limit 값을 넘어가게 되면 OOM이 발생해서 서비스의 다운타임이 발생하거나 error rate이 높아진다.

그렇다면 어떤 메트릭으로 현재 Pod의 메모리 사용량을 파악해야할까?

Pod에 포함된 Container들의 메모리 사용량과 관련된 메트릭들은 cAdvisor를 통해서 가져올 수 있다. 해당 문서에서 cAdvisor가 제공해주는 Prometheus 형식의 메트릭을 살펴볼 수 있다. 이 중에서 메모리와 관련된 메트릭들은 다음과 같은 것들이 있다.

  • container_memory_usage_bytes
  • container_memory_working_set_bytes
  • container_memory_rss

cAdvisor와 cgroup 의 관계

각각의 메트릭에 대해서 깊게 알아보기 전에 이전에 설명한 cgroup의 control group과 방금 언급된 cAdvisor의 메트릭의 관계에 대해서 짚고 넘어가자.

cAdvisor는 컨테이너의 메트릭을 수집하고 export하는 도구이다. cgroup은 Linux 프로세스 그룹들의 리소스 사용량을 모니터링하는 도구이다. 컨테이너 개념에 대해서 이전에 설명하지 않았기 때문에 조금 직관적이지 않을 수 있는데 컨테이너는 조금 특별한 리눅스의 프로세스이다. 그리고 cAdvisor는 내부적으로 cgroup을 이용하여 자신이 모니터링하는 컨테이너의 메트릭을 찾아내고 이것을 외부로 export 한다.

func setMemoryStats(s *cgroups.Stats, ret *info.ContainerStats) {
    ret.Memory.Usage = s.MemoryStats.Usage.Usage
    ret.Memory.MaxUsage = s.MemoryStats.Usage.MaxUsage
    ret.Memory.Failcnt = s.MemoryStats.Usage.Failcnt
    if s.MemoryStats.UseHierarchy {
        ret.Memory.Cache = s.MemoryStats.Stats["total_cache"]
        ret.Memory.RSS = s.MemoryStats.Stats["total_rss"]
        ret.Memory.Swap = s.MemoryStats.Stats["total_swap"]
        ret.Memory.MappedFile = s.MemoryStats.Stats["total_mapped_file"]
    } else {
        ret.Memory.Cache = s.MemoryStats.Stats["cache"]
        ret.Memory.RSS = s.MemoryStats.Stats["rss"]
        ret.Memory.Swap = s.MemoryStats.Stats["swap"]
        ret.Memory.MappedFile = s.MemoryStats.Stats["mapped_file"]
    }
    ...
}
func (s *MemoryGroup) GetStats(path string, stats *cgroups.Stats) error {
    // Set stats from memory.stat.
    statsFile, err := fscommon.OpenFile(path, "memory.stat", os.O_RDONLY)
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    defer statsFile.Close()
    sc := bufio.NewScanner(statsFile)
    for sc.Scan() {
        t, v, err := fscommon.GetCgroupParamKeyValue(sc.Text())
        if err != nil {
            return fmt.Errorf("failed to parse memory.stat (%q) - %v", sc.Text(), err)
        }
        stats.MemoryStats.Stats[t] = v
    }
    stats.MemoryStats.Cache = stats.MemoryStats.Stats["cache"]
    memoryUsage, err := getMemoryData(path, "")
    if err != nil {
        return err
    }
    ...
}
func getMemoryData(path, name string) (cgroups.MemoryData, error) {
    memoryData := cgroups.MemoryData{}
    moduleName := "memory"
    if name != "" {
        moduleName = "memory." + name
    }
    var (
        usage    = moduleName + ".usage_in_bytes"
        maxUsage = moduleName + ".max_usage_in_bytes"
        failcnt  = moduleName + ".failcnt"
        limit    = moduleName + ".limit_in_bytes"
    )
    value, err := fscommon.GetCgroupParamUint(path, usage)
    if err != nil {
        if moduleName != "memory" && os.IsNotExist(err) {
            return cgroups.MemoryData{}, nil
        }
        return cgroups.MemoryData{}, fmt.Errorf("failed to parse %s - %v", usage, err)
    }
    memoryData.Usage = value
    value, err = fscommon.GetCgroupParamUint(path, maxUsage)
    if err != nil {
        if moduleName != "memory" && os.IsNotExist(err) {
            return cgroups.MemoryData{}, nil
        }
        return cgroups.MemoryData{}, fmt.Errorf("failed to parse %s - %v", maxUsage, err)
    }
    ...
 }

cadvisor와 runc의 코드를 가져왔는데 위에서 부터 아래로 고수준에서 저수준으로 내려가는 코드들이다. cAdvisor가 runc를 사용하고 실제로는 runc에서 cgroup에서 생성한 control group의 파일들을 참조하여 메트릭을 뽑아낸다. 위에 첨부한 GetStats(), getMemoryData() 코드를 살펴보면 moduleName + ".usage_in_bytes" 과 같이 cgroup 섹션에서 설명한 파일들을 읽어 메트릭을 가져오는 것을 살펴볼 수 있다.

그렇기 때문에 cgroup의 메트릭에 대한 정의를 살펴보면 컨테이너가 떠있는 머신 혹은 VM 관점에서 해당 메트릭이 어떤 의미를 가지는지를 생각해볼 수 있다.

이제 각각의 메트릭을 살펴보며 어떤 메트릭으로 메모리를 모니터링해야 하면 좋을지 살펴보자.

container_memory_usage_bytes

처음에는 메트릭의 명칭때문에 이 메트릭을 통해서 메모리 모니터링을 해야한다고 생각할 수 있다. 하지만 그 전에 이 메트릭이 정확히 어떤 값을 보여주는지를 확인해봐야한다.

type MemoryStats struct {
    // Current memory usage, this includes all memory regardless of when it was
    // accessed.
    // Units: Bytes.
    Usage uint64 `json:"usage"`
    ...
}

여러 글(링크1, 링크2, 링크3)들을 통해서 container_memory_usage_bytes 메트릭은 현재 컨테이너가 사용하고 있는 메모리뿐만 아니라 언제든지 kernel에 의해 reclaimed 될 수 있는 캐시에 대한 값도 포함되어있는 것을 알 수 있다. 그렇기 때문에 container_memory_usage_bytes 메트릭이 limit 값에 도달해도 해당 Pod은 OOM으로 다운되지 않는다.

container_memory_working_set_bytes

이 글을 준비하면서 가장 정리가 잘 안되었던 부분이 Working Set 메모리와 RSS 메모리가 OOM-killer와 어떤 관계가 있는지에 대해서 이해하는 것이었다. Stackoverflow (링크1, 링크2)와 Reddit (링크1)과 같은 커뮤니티에 질문을 올려 힌트를 얻었고 결국엔 Linux kernel docs에서 OOM-killer와 관련된 문서를 읽으면서 어느정도 정리가 되었다.

가장 기본적인 Working Set 메모리에 대한 개념은 Virtual Memory에 대한 개념을 정리하면서 살펴보았다.

type MemoryStats struct {
    // The amount of working set memory, this includes recently accessed memory,
    // dirty memory, and kernel memory. Working set is <= "usage".
    // Units: Bytes.
    WorkingSet uint64 `json:"working_set"`
    ...
}

cadvisor에 정리되어있는 Working Set 메모리의 대한 주석을 보면 우리가 기존에 알고있는 Working Set에 대한 개념과 비슷한 것을 알 수 있다. 그리고 Working Set이 정의되는 코드를 살펴보면 이는 해당 컨테이너가 사용하고 있는 전체 메모리 양 중에서 inactive file의 양을 뺀 값과 동일하다.

func setMemoryStats(s *cgroups.Stats, ret *info.ContainerStats) {
    ...
    workingSet := ret.Memory.Usage
    if v, ok := s.MemoryStats.Stats[inactiveFileKeyName]; ok {
        if workingSet < v {
            workingSet = 0
        } else {
            workingSet -= v
        }
    }
}

Inactive file(링크1, 링크2)에 해당하는 메모리는 최근에 사용되지 않았고 캐싱되어있는 메모리의 값을 뜻한다. 이에 해당하는 메모리는 kernel이 메모리가 부족하다고 판단하면 언제든지 evict 하여 메모리 공간을 확보할 수 있다.

여기서 Working Set 메모리가 limit에 도달했다는 사실이 어떻게 OOM-killer가 동작한다는 것과 연관되어있는지 궁금했다.

Linux kernel 문서를 살펴보면 kernel이 새로운 메모리를 확보하고자 할 때 vm_enough_memory() 를 호출하여 현재 얼마나 잠재적으로 메모리 확보가 가능한지를 살펴본다. 여기에는 해당 프로세스가 사용할 수 있는 page, swap pages 그리고 캐싱된 메모리들과 같은 항목들이 포함된다. 자세한 정보는 위의 링크에 자세히 설명되어있다.

그리고 메모리가 부족하다고 판단하면 오랫동안 사용되지 않은 메모리를 reclaim하고 캐싱된 메모리를 evict 한다. 하지만 이렇게 해도 여전히 메모리 할당 요청에 명시된 메모리 양보다 kernel이 할당할 수 있는 메모리양이 작다면 out_of_memory() 를 호출하여 OOM-killer를 동작시킨다.

그렇다면 이제 다시 Working Set 메모리가 limit에 도달했다는 의미를 생각해보자. cadvisor 코드에서 Working Set은 프로세스가 사용하고 있는 전체 메모리 양 중에서 inactive file 양을 뺀 값을 말한다. 그래서 이 값이 limit에 도달하면 캐싱된 메모리를 evict 하더라도 더이상 메모리를 할당할 수 없다는 것을 의미하며 VM의 kernel은 해당 컨테이너의 메모리가 부족하다는 것을 알아채고 OOM-killer를 동작시킬 것이다. 그렇기 때문에 Working Set 메모리가 limit에 도달하면 OOM 이벤트가 발생한다.

Working Set 은 page cache 를 포함할 수 있고 Working Set 이 limit 에 도달한 경우 커널에 의해 page cache 가 비워지기 때문에 메모리 할당이 가능하고 oom 이 발생 안할 수 있다.

inactive file에 대한 정의는 아래 링크에서 더 자세히 살펴볼 수 있다.

https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/deployment_guide/s2-proc-meminfo

container_memory_rss

마지막으로 살펴볼 메트릭은 RSS(Resident Set Size) 메모리와 관련된 메트릭이다. RSS 메모리는 해당 컨테이너가 RAM에서 사용하고 있는 메모리 사이즈이다. RAM에서 사용하고 있는 수치만 계산하기 때문에 swap space로 swap out된 페이지들의 사이즈는 포함되지 않는다. 혹은 아직 전체 바이너리가 RAM에 올라오지 않았다면 아직 파일시스템으로부터 올라오지 않은 바이너리 사이즈도 RSS 크기에 포함되지 않는다. 이 값은 cgroup이 /sys/fs/cgroups/memory/memory.status 라는 파일에 해당 수치를 기록해놓고 cAdvisor가 해당 파일값을 읽어 메트릭으로 내보낸다.

type MemoryStats struct {
    // The amount of anonymous and swap cache memory (includes transparent
    // hugepages).
    // Units: Bytes.
    RSS uint64 `json:"rss"`
    ...
}

cAdvsior에 정의되어 있는 RSS 필드 값이다. anonymous page와 swap cache 메모리가 포함된다. 둘 모두 RAM에 존재하는 페이지들이다.

RSS와 연관지어 살펴봐야할 것은 바로 OOM-killler가 동작할 때 어떤 프로세스를 kill할지 선택하는 과정을 같이 봐야한다. LINE 블로그의 포스팅을 통해서 많은 도움을 받을 수 있었다. OOM-killer는 badness라는 점수를 통해서 가장 높은 점수를 가진 프로세스를 kill의 대상으로 삼는다. badness를 결정짓는 변수는 크게 RSS 메모리와 oom_score_adj라는 수치를 통해 결정된다 (다른 것들도 존재하지만 일단 이 글에서는 제외하고 생각한다).

unsigned long oom_badness(struct task_struct *p, struct mem_cgroup *memcg,
   const nodemask_t *nodemask, unsigned long totalpages)
{
    long points;
    long adj;
    if (oom_unkillable_task(p, memcg, nodemask))
        return 0;
    p = find_lock_task_mm(p);
    if (!p)
        return 0;
    adj = (long)p->signal->oom_score_adj;
    if (adj == OOM_SCORE_ADJ_MIN) {
        task_unlock(p);
        return 0;
    }
    /*
      * The baseline for the badness score is the proportion of RAM that each
      * task's rss, pagetable and swap space use.
      */
    points = get_mm_rss(p->mm) + p->mm->nr_ptes +
                      get_mm_counter(p->mm, MM_SWAPENTS);
    task_unlock(p);
    /*
      * Root processes get 3% bonus, just like the __vm_enough_memory()
      * implementation used by LSMs.
      */
    if (has_capability_noaudit(p, CAP_SYS_ADMIN))
        adj -= 30;
     /* Normalize to oom_score_adj units */
    adj *= totalpages / 1000;
    points += adj;
    /*
      * Never return 0 for an eligible task regardless of the root bonus and
      * oom_score_adj (oom_score_adj can't be OOM_SCORE_ADJ_MIN here).
      */
    return points > 0 ? points : 1;
}
static inline unsigned long get_mm_rss(struct mm_struct *mm)
{
    return get_mm_counter(mm, MM_FILEPAGES) +
            get_mm_counter(mm, MM_ANONPAGES);
}

RSS 메모리가 높을 수록 높은 점수를 받고 oom_score_adj가 높을수록 높은 점수를 받는다. 그렇기 때문에 RSS 메모리가 limit에 가까울 수록 OOM-killer가 kill할 프로세스의 대상이 될 가능성이 매우 높아지는 것이다. oom_score_adj도 살펴보면 재미있다. 이 값은 kubelet이 넣어준다. 어떤 값을 넣어주는지는 해당 Pod의 QoS에 따라 다른 값을 넣어주는데 BestEffort가 가장 큰 값이고 Guaranteed가 가장 작은 값이다. 다시말해 리소스 할당을 제대로 해주지 않은 Pod는 가장 먼저 OOM-killer의 대상이 될 가능성이 크다. QoS에 대해서 궁금하다면 을 살펴보면 좋다.

정리하며

컨테이너 메트릭들은 사실 리눅스의 cgroup과 밀접한 관련이 있다. 컨테이너가 어떻게 만들어졌는지를 살펴보면 이 부분은 더욱 명확하다. 이 부분에 대해서도 쓸 계획이다. 그래서 cgroup의 congrol group들의 정의를 살펴보며 이것이 컨테이너에 어떻게 영향을 미칠지에 대해서 생각해보았다.

  • container_memory_usage_bytes
  • container_memory_working_set_bytes
  • container_memory_rss

세 가지 메트릭이 있었다. 각각의 메트릭의 의미를 정확하게 이해하기 위해서는 Virtual Memory의 개념을 이해하는 것이 좋다고 생각한다. 이 중에 눈여겨 모니터링 해야할 메트릭은 바로 container_memory_working_set_bytescontainer_memory_rss 메트릭이다. container_memory_working_set_bytes 이 limit에 도달하면 OOM-killer가 동작하며 container_memory_rss 가 limit에 가깝다면 OOM-killer의 대상이될 가능성이 높아진다.

각각의 메트릭의 의미를 이해하고 OOM-killer의 관계를 이해하기 위해서 정말 많은 글을 읽어봤고 여러 커뮤니티에도 질문을 많이 올렸다. 여전히 잘못된 부분이 있을 수 있기 때문에 혹시 이 글을 읽는 다른 누군가가 이상한 부분이 있다면 댓글을 남겨주면 좋겠고 또 누군가에게는 이 글이 도움이 되면 좋겠다.

Reference

“Kubernetes Pod Memory Monitoring — RSS, Working Set”의 3개의 댓글

  1. 안녕하세요?

    cadvisor 코드에서 Working Set은 프로세스가 사용하고 있는 전체 메모리 양 중에서 reclaim이 가능한 메모리의 양을 뺀 값을 말한다.

    위 부분에서 Working Set은 프로세스가 사용하고 있는 전체 메모리 양 중에서 inactive file 양을 뺀 값을 말한다. 라고 표현하는게 더 정확할 것 같습니다. 실제 코드상으로도 그렇게 구현이 되어 있구요. inactive file 이 reclaimable 하긴 하지만 page cache 중 일부는 active file 에 존재할 수도 있기 때문입니다.
    즉 working set 은 page cache 를 포함할 수 있고 working set 이 limit 에 도달한 경우 커널에 의해 page cache 가 비워지기 때문에 메모리 할당이 가능하고 oom 이 발생 안할 수 있습니다.

    Active file — The total amount of buffer or page cache memory, in kilobytes, that is in active use. This is memory that has been recently used and is usually not reclaimed for other purposes.

    Inactive file — The total amount of buffer or page cache memory, in kilobytes, that are free and and available. This is memory that has not been recently used and can be reclaimed for other purposes.

    https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/deployment_guide/s2-proc-meminfo

    1. 안녕하세요 david님.

      working set 메모리 중 active file과 관련된 부분이 있는데 active file에 page cache가 있을 수 있다. 그래서 working set 이 limit에 도달해도 working set에서 reclaim이 가능한 메모리가 있기 때문에 oom이 발생하지 않을 수 있다라고 이해하였습니다.

      더 정확한 정보를 알려주셔서 감사합니다. 그리고 이 글을 보시는 다른 분들에게도 잘못된 정보가 더 알려지기 전에 추가 조사를 해서 수정해놓도록 하겠습니다 🙂

Leave a comment

%d 블로거가 이것을 좋아합니다: