Linux 컨트롤 그룹(cgroups)에서 자원 사용량 회계가 어떤 역할을 하며, cgroups의 설계와 구조가 회계의 요구를 어떻게 충족하는지 살펴본다.
LWN 구독을 고려해 주세요 LWN.net의 생명줄은 구독입니다. 이 콘텐츠가 마음에 들고 더 많은 글을 보고 싶다면, 구독은 LWN이 계속 번창하도록 하는 데 도움이 됩니다. 이 페이지를 방문해 가입하고 LWN을 계속 인터넷에 남겨 주세요.
Linux 컨트롤 그룹을 이해하려는 지속적인 탐구에서(적어도 cgroups가 언급될 때 필연적으로 벌어지는 논쟁을 즐길 수 있을 정도로), 이번에는 회계(accounting)가 수행하는 역할을 살펴보고, cgroups의 설계와 구조가 회계 담당자의 요구를 어떻게 충족하는지 생각해 볼 차례다.
Linux와 Unix는 자원 사용량 회계라는 개념에 낯설지 않다. V6 Unix에서도 각 프로세스의 CPU 시간이 계측되었고, 누적 합계는 times() 시스템 콜로 접근할 수 있었다. 제한적이긴 하지만 이는 프로세스 그룹에도 확장되었다. V6 Unix를 처음 살펴봤을 때 발견한 프로세스 묶음 중 하나는 특정 프로세스의 모든 자손(descendants)으로 이뤄진 그룹이었다. 그 그룹의 모든 프로세스가 종료되면(또는 적어도 탈출하지 않은 것들에 대해) 그들이 사용한 총 CPU 시간이 times()에서 역시 제공되었다. 프로세스가 종료되고 wait되기 전까지는, 그 CPU 시간은 오직 그 프로세스 자신만 알고 있었다.
2.10BSD에서는 회계 대상 자원이 확장되어 메모리 사용량, 페이지 폴트, 디스크 I/O 및 다른 통계가 포함되었다. CPU 시간과 마찬가지로, 이것들은 자식 프로세스를 wait할 때 부모로 모였다. 이 값들은 현대 Linux에도(거의 변경 없이) 그대로 남아 있는 getrusage() 시스템 콜로 접근할 수 있다.
getrusage()와 함께 setrlimit()도 도입되었는데, 이는 CPU 시간과 메모리 사용량 같은 일부 자원의 사용에 한계를 설정할 수 있다. 하지만 이러한 한계는 오직 개별 프로세스에만 적용되었고 그룹에는 적용되지 않았다. 그룹 통계는 프로세스가 종료될 때에만 누적되는데, 그 시점은 한계를 강제하기엔 너무 늦기 때문이다.
지난번에는, 서비스 제공을 위해 그룹 전체에 걸친 회계를 유지할 필요가 없었던 여러 cgroups 서브시스템을 살펴봤다(다만 perf_event는 다소 회색 지대였다). 이번 주에는 남아 있는 다섯 개 서브시스템을 살펴보는데, 이들은 모두 어떤 방식으로든 그룹 전체 회계를 포함하며, 따라서 setrlimit()가 결코 지원할 수 없었던 종류의 제한을 지원한다.
cpuacct — 회계를 위한 회계cpuacct는 회계 서브시스템 중 가장 단순한데, 그 이유는 하는 일이 회계뿐이며 제어를 전혀 하지 않기 때문이다.
cpuacct는 원래 메인라인 포함을 기대하지 않고 cgroups의 능력을 시연하기 위한 예제로 작성된 것으로 보인다. 이것은 2.6.24-rc1에서 다른 cgroups 코드와 함께 메인라인에 슬쩍 포함되었고, 곧바로 이 원래 의도에 대한 설명과 함께 제거되었다가, 실제로 꽤 유용하다는 이유로 2.6.24-final 훨씬 전에 다시 추가되었다. 이런 이력을 고려하면, cpuacct가 전체 설계 안에 깔끔하게 들어맞기를 기대하는 건 무리일지도 모른다.
cpuacct는 두 종류의 회계 정보를 별도로 유지한다. 첫째, 그룹 내 모든 프로세스가 사용한 총 CPU 시간이다. 이는 스케줄러가 가능한 한 정밀하게 측정하며 나노초 단위로 기록된다. 이 정보는 CPU별로 수집되며, CPU별로 또는 전체 CPU 합계로 보고할 수 있다.
둘째, (2.6.30부터) 그룹 내 모든 프로세스가 사용한 총 CPU 시간을 "user" 시간과 "system" 시간으로 나눈 값이다. 이 시간들은 times() 시스템 콜이 반환하는 시간과 동일한 방식으로 계측되며 클록 틱(clock ticks) 또는 "jiffies" 단위로 기록된다. 따라서 나노초로 계측되는 CPU 시간만큼 정밀하지 않을 수 있다.
2.6.29부터는 이 통계가 계층적으로 수집된다. 어떤 사용량이 한 그룹에 추가될 때마다, 그 그룹의 모든 조상(ancestor)에도 동일한 사용량이 더해진다. 그래서 각 그룹에 기록된 사용량은, 하위 그룹 구성원 프로세스까지 포함한 확장된 그룹 전체 프로세스의 사용량 합계가 된다.
이것이 이번에 살펴볼 모든 서브시스템의 핵심 특징이다. 즉, 계층적으로 회계한다. perf_event도 일부 회계를 하기는 하지만, 각 프로세스의 회계는 프로세스가 직접 속한 cgroup에만 남아 있고 조상 그룹으로 전파되지 않는다.
이 두 서브시스템(cpuacct와 perf_event)의 경우, 계층적 회계가 정말 필요한지 명확하지 않다. 수집되는 총합은 커널 내부에서 사용되지 않고 사용자 공간 애플리케이션에만 제공되는데, 그런 애플리케이션이 데이터를 특히 높은 빈도로 읽을 가능성은 낮다. 이는 그룹 전체 회계 정보가 필요한 애플리케이션이라면 특정 그룹에서 시작해 자손 트리를 순회하며 각 하위 그룹의 총합을 더하는 방식이 꽤 효과적일 수 있음을 시사한다. cgroup이 제거될 때 그 사용량을 부모로 누적시키는 것은(프로세스 시간이 exit 시 누적되는 것처럼) 분명 유용할 수 있다. 더 이른 누적이 특별한 이점을 주는지는 명확하지 않다.
cgroups 파일시스템에서 총합을 직접 보여주는 것이 중요하더라도, 커널이 매번 변화가 있을 때마다가 아니라 필요할 때 숫자를 합산하는 방식이 충분히 실용적일 것이다. 모든 CPU에 대한 합계는 이런 방식으로 관리되지만, 하위 그룹에 대한 합계는 그렇지 않다.
데이터가 필요할 때 애플리케이션이 트리를 순회해야 하는 복잡도와, 모든 회계 이벤트마다 대상 프로세스의 모든 조상에 새 부하(charge)를 더하기 위해 커널이 트리를 위로 걸어 올라가야 하는 비용 사이에는 분명한 트레이드오프가 있다. 이 트레이드오프에 대한 제대로 된 비용/편익 분석을 하려면 트리의 깊이와 업데이트 빈도를 고려해야 한다. cpuacct의 경우 업데이트는 스케줄러 이벤트나 타이머 틱에서만 발생하는데, 바쁜 머신에서도 보통 1밀리초 또는 그보다 긴 간격이다. 이것도 꽤 높은 빈도로 보일 수 있지만, 우리가 보게 되겠지만 훨씬 더 자주 일어날 수 있는 이벤트들도 있다.
cpuacct와 perf_event의 회계 접근이 합리적인 선택인지 의심스러운 선택인지 여부는 cgroups를 이해하는 데 그리 중요하지 않다. 중요한 것은 선택지가 존재하고 트레이드오프를 고려해야 한다는 사실이다. 이 서브시스템들은 회계 데이터가 커널 내부에서 사용되지 않기 때문에 선택의 자유가 있다. 남아 있는 서브시스템들은 제어를 수행하기 위해 어떤 대상을 계측하므로, 완전히 정확한 회계 세부사항이 필요하다.
메모리 사용량을 추적하고 제한하는 데 관여하는 cgroup 서브시스템은 두 개가 있다: memory와 hugetlb. 이 둘은 사용량 추적과 제한 강제를 위해 공통 데이터 구조와 지원 라이브러리인 "resource counter" 또는 res_counter를 사용한다.
include/linux/res_counter.h에 선언되고 kernel/res_counter.c에 구현된 res_counter는 임의의 자원에 대한 사용량을 제한값(limit) 및 "소프트 제한"과 함께 저장한다. 또한 사용량이 도달했던 최고 수준을 기록하는 최고 수위(high-water mark)와, 추가 자원 요청이 거부된 횟수를 추적하는 실패 카운트를 포함한다.
마지막으로 res_counter에는 동시 접근을 관리하기 위한 스핀락과 부모 포인터가 들어 있다. 이 포인터는 회계 cgroup 서브시스템들에서 반복적으로 나타나는 주제를 보여주는데, 이들은 종종 cgroups가 직접 제공하는 트리 구조를 정확히 복제하는 별도의 병렬 트리형 데이터 구조를 만든다.
memory cgroup 서브시스템은 세 개의 res_counter를 할당한다. 하나는 사용자 프로세스 메모리 사용량용, 하나는 메모리와 스왑 사용량 합계용, 하나는 프로세스를 대신해 커널이 사용하는 메모리 사용량용이다. 여기에 hugetlb가 할당하는 하나의 res_counter(거대한 페이지로 할당된 메모리를 계측함)가 더해진다. 이는 memory와 hugetlb 서브시스템이 모두 활성화되면 추가로 네 개의 parent 포인터가 생긴다는 뜻이다. 이는 cgroups가 제공하는 계층 구조 구현이 사용자들의 요구를 충분히 충족하지 못한다는 점을 시사하는 듯하지만, 그 이유가 왜 그런지는 즉각적으로 분명하지 않다.
프로세스가 다양한 메모리 자원 중 하나를 요청하면, res_counter 코드는 부모 포인터를 따라 위로 올라가며 제한에 도달했는지 확인하고 각 조상에서 사용량을 업데이트한다. 이는 각 레벨에서 스핀락을 잡아야 하므로 결코 싸지 않은 연산이며, 특히 계층이 조금이라도 깊다면 더 그렇다. 메모리 할당은 일반적으로 Linux에서 매우 최적화된 연산이다. CPU별 free 리스트와 함께, 할당/해제를 배치 처리하여 할당당 비용을 최소화하려 한다. 메모리 할당이 항상 높은 빈도로 일어나는 것은 아니지만, 어떤 경우에는 그렇다. 그런 상황에서도 가능하다면 좋은 성능을 내야 한다. 따라서 단일 메모리 할당마다 여러 중첩된 cgroup에 대한 회계를 업데이트하려고 스핀락을 연달아 잡는 것은 좋은 생각처럼 들지 않는다. 다행히 memory 서브시스템은 그렇게 하지 않는다.
32페이지 미만의 메모리 할당 요청을 승인할 때(대부분의 요청은 1페이지), 메모리 컨트롤러는 res_counter에 전체 32페이지에 대한 승인을 요청한다. 그 요청이 승인되면, 실제로 필요했던 양을 초과한 부분은 CPU별 "stock"에 기록된다. 이 stock에는 각 CPU에서 마지막으로 할당을 수행한 cgroup이 무엇인지와 승인받아 남은 초과량이 얼마인지가 기록된다. 요청이 승인되지 않으면, 실제로 할당되는 페이지 수만큼을 요청한다.
같은 CPU에서 같은 프로세스가 이어서 할당을 수행하면, stock이 0이 될 때까지 남은 stock을 사용한다. 0이 되면 다시 32페이지 승인을 요청한다. 스케줄링 변화로 인해 다른 cgroup의 다른 프로세스가 그 CPU에서 메모리를 할당하게 되면, 기존 stock은 반환되고 새 cgroup에 대한 새 stock이 요청된다.
해제도 배치 처리되지만, 메커니즘은 꽤 다르다. 아마도 해제가 더 큰 배치로 일어나는 경우가 많고, 해제는 실패할 수 없기 때문일 것이다. 해제 배칭은 CPU별이 아니라 프로세스별 카운터를 사용하며, 메모리를 해제하는 코드가 명시적으로 활성화해야 한다. 따라서 다음과 같은 호출 시퀀스는:
mem_cgroup_uncharge_start()
repeat mem_cgroup_uncharge_page()
mem_cgroup_uncharge_end()
uncharge 배칭을 사용하지만, 단독 mem_cgroup_uncharge_page() 호출은 그렇지 않다.
여기서 핵심 관찰은, 자원 사용량을 순진하게 회계하면 상당히 비쌀 수 있지만 비용을 최소화하는 다양한 방법이 있으며, 상황에 따라 더 적합하거나 덜 적합한 접근이 있다는 점이다. 따라서 cgroups는 이 문제에 대해 중립적인 입장을 취하고, 각 서브시스템이 자신의 필요에 가장 맞는 방식으로 문제를 풀 수 있게 하는 것이 적절해 보인다.
CPU가 컴퓨터에서 매우 중심적이기 때문에, CPU와 관련된 cgroup 서브시스템이 여러 개 존재하는 것은 놀랍지 않다. 지난번에는 프로세스가 실행될 수 있는 CPU를 제한하는 cpuset 서브시스템을 보았고, 위에서 cgroup 내 프로세스가 CPU에서 보낸 시간을 계측하는 cpuacct 서브시스템을 보았다. 세 번째이자 마지막 CPU 관련 서브시스템은 단순히 cpu라고 불린다. 이것은 스케줄러가 서로 다른 프로세스 및 서로 다른 cgroup 간에 CPU 시간을 어떻게 나눌지 제어하는 데 사용된다.
Linux 스케줄러는 놀라울 정도로 단순한 설계를 가지고 있다. 이는 임의의 수의 스레드를 동시에 실행할 수 있지만 속도는 비례해 감소하는, 가상의 이상적인 멀티태스킹 CPU를 모델로 삼는다. 이 모델을 사용하면 스케줄러는 각 스레드가 이상적으로는 얼마나 많은 유효 CPU 시간을 받아야 하는지를 계산할 수 있다. 그리고 실제로 사용한 CPU 시간이 이상적 값에 비해 가장 많이 뒤처진 스레드를 선택해 잠시 실행시켜 따라잡게 한다.
모든 프로세스가 동등하다면, 비례성은 실행 가능한 프로세스가 _N_개일 때 각 프로세스가 실제 시간의 1/_N_을 가져야 한다는 뜻이 된다. 물론 프로세스는 종종 동등하지 않다. 스케줄링 우선순위나 nice 값이 각 프로세스에 서로 다른 가중치를 부여해, 실제 시간을 서로 다른 비율로 받게 할 수 있다. 이 비율들의 합은 물론 1(또는 활성 CPU 수)이 되어야 한다.
cpu cgroup 서브시스템을 사용해 그룹 스케줄링을 요청하면, 이 비율들은 그룹 계층에 기반해 계산되어야 한다. 즉 최상위 그룹에 어떤 비율이 할당되고, 그 비율이 해당 그룹의 프로세스들과 하위 그룹들 사이에 다시 공유된다.
게다가 이상적 CPU 시간과 실제 CPU 시간 간의 차이를 추적하는 "가상 런타임(virtual runtime)"도 각 프로세스뿐 아니라 각 그룹에 대해서도 회계되어야 한다. 직관적으로는 그룹의 가상 런타임이 프로세스 시간의 합과 정확히 같아야 할 것 같다. 하지만 프로세스가 종료하면 그 프로세스의 초과분이나 부족분은 사라진다. 이 손실이 그룹 간의 불공정으로 이어지지 않게 하기 위해, 스케줄러는 각 프로세스뿐 아니라 각 그룹이 사용한 시간도 계측한다.
계층 전반에서 이러한 다양한 값을 관리하기 위해 cpu 서브시스템은 스케줄러가 비례 가중치와 가상 런타임을 저장하는 데 쓰는 struct sched_entity 구조체의 병렬 계층을 만든다. 실제로는 CPU마다 하나씩, 이런 계층이 여럿 존재한다. 이는 런타임 값을 락을 잡지 않고도 트리 위로 전파할 수 있다는 뜻이며, 그래서 메모리 컨트롤러의 res_counter보다 훨씬 효율적이다.
하나의 서브시스템이 종종 두 개 이상의 서로 구별되는(하지만 관련된) 기능을 가진다는 반복되는 주제에 맞게, cpu 서브시스템은 각 그룹에 최대 CPU 대역폭을 부과할 수 있게도 한다. 이는 지금까지 논의한 스케줄링 우선순위와는 꽤 별개의 기능이다.
대역폭은 실제 시간당 CPU 시간으로 측정된다. 할당량(quota)이라고 불리는 CPU 시간 제한과, 그 할당량을 사용할 수 있는 실제 시간 구간(period) 둘 다를 지정해야 한다. 각 그룹에 quota와 period를 설정할 때, 서브시스템은 어떤 부모에 부과된 제한이 모든 자식이 자신의 quota를 완전히 사용할 수 있을 만큼 충분히 큰지 확인한다. 이를 충족하지 못하면 변경은 거부된다.
대역폭 제한의 실제 구현은 대부분 sched_entity의 맥락에서 이뤄진다. 스케줄러가 각 sched_entity가 사용한 가상 시간을 업데이트할 때, 대역폭 사용량도 함께 업데이트하고 쓰로틀링(throttling)이 적절한지 검사한다.
어느 정도 이 사례 연구는 우리가 이미 본 몇 가지 생각을 강화한다. 제한은 종종 계층 아래로 내려가고, 회계 데이터는 종종 병렬 계층을 타고 위로 전파된다. 여기서는 왜 병렬 계층이 필요할 수 있는지에 대한 설득력 있는 이유 하나도 볼 수 있다. 이 경우 병렬 계층은 CPU별이어서 락을 잡지 않고 업데이트할 수 있다.
우리가 반복해서 관찰했듯이, 일부 cgroup 서브시스템은 포함된 프로세스의 여러(서로 관련된) 측면을 관리한다. blkio에서는 이 아이디어가 더 공식화된다. blkio는 여러 "정책(policies)"이 등록될 수 있게 하는데, 이는 cgroup 계층 변경을 통지받고 cgroup 가상 파일시스템에 파일을 추가할 수 있다는 점에서 cgroup 서브시스템과 매우 유사하게 동작한다. 하지만 프로세스 이동을 금지할 수는 없고, fork()나 exit()에 대해 통지받지도 못한다.
Linux 3.15에는 두 가지 blkio 정책이 있다: "throttle"과 "cfq-iosched". 이들은 cpu 서브시스템의 두 기능(대역폭과 스케줄링 우선순위)과 놀라울 정도로 닮아 있지만, 세부사항은 꽤 다르다. 이 둘에서 발견되는 많은 아이디어는 다른 서브시스템에서도 이미 봤던 것들이라, 같은 내용을 다른 모습으로 다시 반복할 필요는 없다. 다만 언급할 가치가 있는 아이디어가 두 가지 있다.
첫째는 blkio 서브시스템이 각 cgroup에 새로운 ID 번호를 추가한다는 점이다. 지난번에 cgroup 코어가 각 그룹에 ID 번호를 제공하며 net_prio가 이를 사용해 그룹을 식별한다는 것을 보았다. blkio가 추가한 새 id도 유사한 역할을 하지만 한 가지 중요한 차이가 있다. blkio ID 번호는 64비트를 사용하며 재사용되지 않는 반면, cgroup-core ID 번호는 단지 int(보통 32비트)이고 재사용된다. 유일한 ID 번호는 cgroups 코어가 일반적으로 제공할 수 있는 유용한 기능처럼 보인다. blkio ID가 추가된 지 1년 조금 넘어서, 놀라울 정도로 비슷한 serial_nr이 실제로 cgroup 코어에 추가되었지만, blkio는 이를 사용하도록 개정되지는 않았다. 코드를 읽을 때 참고하자면: blkio는 내부적으로 blkcg로 알려져 있다.
둘째로 blkio(특히 cfq-iosched 정책)에서 발견되는 새로운 아이디어는 어쩌면 더 흥미롭다. 각 cgroup에는 CPU 스케줄러가 계산하는 가중치와 유사한 weight를 부여할 수 있으며, 이를 통해 이 그룹에서 온 요청의 스케줄링을 형제 그룹에서 온 요청과 균형 맞춘다. blkio만의 독특한 점은, 각 cgroup이 leaf_weight도 가질 수 있다는 것이다. 이는 이 그룹에 속한 프로세스에서 온 요청과 자식 그룹에 속한 프로세스에서 온 요청 사이의 균형을 맞추는 데 사용된다.
그 결과, 비-리프(non-leaf) cgroup에 프로세스가 포함되어 있으면 cfq-iosched 정책은 그 프로세스들이 사실상 가상의 자식 그룹에 들어 있다고 가정하고, leaf_weight를 사용해 그 가상 자식에 가중치를 부여한다. 이를 앞서 살펴본 계층의 다양한 측면과 대비해 보면, 이는 cfq-iosched가 "조직(organization)" 계층을 다루고 싶어 하지 않으며, 모든 것이 "분류(classification)" 계층으로 간주되거나 그렇게 처리될 것이라는 명확한 선언처럼 보인다.
CPU 스케줄러는 이 문제를 신경 쓰지 않는 것처럼 보인다. 내부 그룹에 속한 프로세스들은 서로 간에 그리고 자식 그룹 전체와도 함께 스케줄링된다. 어떤 스케줄링 접근이 _옳은지_는 분명하지 않지만, 둘이 일관되면 좋을 것이다. 일관성을 달성하는 한 가지 방법은 비-리프 cgroup이 프로세스를 포함하는 것을 금지하는 것이다. 이 시리즈의 후반에서 보겠지만, 정확히 그 목적을 향한 작업이 진행 중이다.
이번 분석에서 배운 것과 지난번의 첫 번째 서브시스템 묶음에서 찾은 것을 모두 합치면, 세부사항에 파묻히기 쉽다. 어떤 차이는 깊은 개념적 차이일 수 있고, 어떤 것은 중요하지만 피상적인 구현 차이일 수 있으며, 또 어떤 것은 역사적 우연 때문에 생긴 무의미한 차이일 수도 있다.
첫 번째 부류로는, 자원을 나눠 갖게 하는 제어 요소(CPU 또는 블록 I/O 대역폭), 자원에 제한을 부과하는 요소(CPU, 블록 I/O, 메모리), 그리고 나머지(대체로 프로세스를 식별하는 데 관여함) 사이의 강한 구분이 보인다. 첫 번째 부류는 각 가지(branch)가 다른 모든 가지와 경쟁하므로 전체 계층을 보는 관점이 필요하다. 두 번째 부류는 로컬 가지(local branch)만 보면 되는데, 한 가지의 제한은 다른 가지의 사용량에 영향을 줄 수 없기 때문이다. 세 번째 부류는 사실상 계층이 전혀 필요하지 않다. 유용할 수는 있지만, 기능에 본질적으로 내재한 것은 아니다.
여러 컨트롤러가 병렬 계층을 만드는 사실은 대체로 구현 세부사항처럼 보이지만, 다음번에 보겠듯이 그 밑에 더 깊은 개념적 문제가 있을 수도 있다.
기능과 서브시스템 사이의 거의 혼돈스러운 관계는 아마도 무의미한 역사적 우연일 가능성이 크다. 어떤 것이 새 서브시스템을 정당화하는지에 대한 명확한 정책 선언이 없어서, 어떤 경우엔 기존 서브시스템에 새 기능을 추가하고 어떤 경우엔 완전히 새로운 서브시스템으로 제공한다. 그런 정책 선언을 형성할 수 있는 핵심 이슈들은, 우리의 여정이 계속되는 동안 주의 깊게 관찰할 만한 것들이다.
다음 편에서는 이 미세한 디테일들에서 한 걸음 물러나, cgroups와 그 관계에 대한 상위 수준 관점을 구성해 보겠다. cgroups가 제공하는 계층 구조와, 자원 공유, 자원 사용량 제한, 프로세스 식별이라는 세 가지 핵심 요구가 어떻게 상호작용하는지 살펴볼 것이다.
| 이 글의 인덱스 항목 |
|---|
| Kernel |
| GuestArticles |