2.2.2 컨트롤 그룹 (Control Groups – cgroups)
앞서 우리는 네임스페이스(Namespaces)를 통해 컨테이너가 어떻게 독립된 환경, 즉 격리된 ‘뷰(View)’를 갖게 되는지 살펴보았습니다. 네임스페이스가 컨테이너에게 “무엇을 볼 수 있는지”를 정의한다면, 이번에 살펴볼 컨트롤 그룹(Control Groups, 이하 cgroups)은 컨테이너가 “얼마만큼의 시스템 자원을 사용할 수 있는지”를 제어하는 핵심 기술입니다. 즉, 네임스페이스가 공간의 분리(격리)를 담당한다면, cgroups는 각 공간에 할당된 예산(자원)을 관리하는 역할을 합니다.
클라우드 네이티브 환경에서는 하나의 호스트 시스템 위에서 수많은 컨테이너들이 동시에 실행되는 경우가 일반적입니다. 이때 만약 특정 컨테이너 하나가 CPU나 메모리 같은 공유 자원을 과도하게 점유한다면 어떻게 될까요? 다른 컨테이너들의 성능이 저하되거나, 심한 경우 시스템 전체가 불안정해지는 ‘시끄러운 이웃(Noisy Neighbor)’ 문제가 발생할 수 있습니다. Cgroups는 바로 이러한 문제를 방지하고, 여러 프로세스(또는 컨테이너)들이 시스템 자원을 공정하고 예측 가능하게 공유하도록 관리하는 리눅스 커널 기능입니다. 이를 통해 시스템 관리자는 각 애플리케이션의 중요도나 요구사항에 맞게 자원을 할당하고 제한함으로써, 전체 시스템의 안정성과 효율성을 높일 수 있습니다. 쿠버네티스가 파드(Pod)나 컨테이너별로 자원 사용량을 제한하고 보장(Quality of Service, QoS) 등급을 부여하는 것도 바로 이 cgroups 기술을 기반으로 합니다.

2.2.2.1 리소스 제한 및 할당 (CPU, Memory, I/O 등)
컨트롤 그룹(cgroups)의 진정한 강력함은 시스템의 핵심 자원들을 세밀하게 관리하고 통제할 수 있다는 점에서 드러납니다. 단순히 프로세스들을 그룹화하는 것을 넘어, 각 그룹(즉, 컨테이너 또는 컨테이너 그룹)이 사용할 수 있는 CPU 시간, 메모리 양, 디스크 입출력 대역폭 등을 구체적으로 제한하고 할당할 수 있습니다.
이는 마치 각 팀에게 정해진 예산과 사무 공간을 할당하여 전체 조직이 원활하게 운영되도록 관리하는 것과 유사합니다.
리눅스 커널은 cgroups 기능을 통해 다양한 종류의 자원을 관리하며, 각 자원 유형은 별도의 컨트롤러(Controller) 또는 서브시스템(Subsystem)이라는 모듈에 의해 독립적으로 처리됩니다. 이제 컨테이너 환경에서 가장 중요하게 다루어지는 주요 자원들과, cgroups 컨트롤러가 이를 어떻게 정교하게 제어하는지 구체적인 예를 통해 자세히 살펴보겠습니다.
CPU 자원 제어: 연산 능력의 공정한 분배와 상한선 설정
CPU는 컴퓨터의 두뇌와 같아서, 모든 연산 작업을 처리하는 핵심 요소입니다. 하나의 호스트 시스템에서 여러 컨테이너가 동시에 실행될 때, 각 컨테이너가 필요한 만큼의 CPU 자원을 공정하게 얻고, 동시에 특정 컨테이너가 CPU를 독점하여 다른 컨테이너의 성능을 저해하는 것을 방지하는 것이 중요합니다. Cgroups는 이를 위해 크게 두 가지 방식의 CPU 제어 메커니즘을 제공합니다.
- 상대적 할당 (CPU Shares/Weight – 공정한 경쟁 유도):
이 방식은 시스템에 CPU 자원이 부족하여 여러 컨테이너(cgroup)가 서로 경쟁하는 상황에서 작동합니다. 각 cgroup에게 가중치(weight 또는 shares) 값을 부여하고, 커널의 CPU 스케줄러(주로 CFS: Completely Fair Scheduler)는 이 가중치에 비례하여 각 그룹에게 CPU 시간을 분배합니다.
- Cgroup v1에서는 cpu.shares라는 파일을 사용하며, 기본값은 1024입니다. 만약 A 그룹에 1024, B 그룹에 512의 cpu.shares 값을 설정했다면, CPU 자원이 부족할 때 A 그룹은 B 그룹보다 약 2배 더 많은 CPU 시간을 할당받게 됩니다. 즉, 2:1의 비율로 CPU를 나눠 갖게 되는 것입니다.
- Cgroup v2에서는 cpu.weight 파일을 사용하며, 기본값은 100이고 범위는 1부터 10000까지입니다. 동작 원리는 cpu.shares와 유사하며, cpu.shares 값을 cpu.weight로 변환하는 공식은 weight = (1 + ((shares – 2) * 9999) / 262142)입니다. (기본값 1024는 weight 100에 해당)
중요한 점은 이 가중치 방식이 절대적인 CPU 성능을 보장하지 않는다는 것입니다. 시스템에 CPU 여유가 충분할 때는 가중치와 상관없이 필요한 만큼 CPU를 사용할 수 있습니다. 오직 CPU 경합이 발생할 때만 가중치 비율에 따라 자원을 분배받게 됩니다. 쿠버네티스에서는 spec.containers[].resources.requests.cpu 필드가 컨테이너를 스케줄링할 노드를 찾는 기준이 될 뿐만 아니라, 이 CPU 가중치 값을 설정하는 데에도 간접적으로 사용됩니다. 예를 들어 requests.cpu: “500m” (0.5 CPU 코어)는 대략 512 정도의 cpu.shares 값으로 변환될 수 있습니다. (정확한 변환 방식은 컨테이너 런타임과 설정에 따라 다를 수 있습니다.) 이를 통해 자원 요청량이 많은 컨테이너가 CPU 경합 시 더 많은 CPU 시간을 확보하도록 유도합니다.
- 절대적 제한 (CPU Quota/Period, CPU Max – 사용량 상한선 강제):
이 방식은 특정 컨테이너(cgroup)가 사용할 수 있는 CPU 시간의 절대적인 상한선을 설정합니다. 즉, 시스템에 CPU 여유가 있더라도 설정된 한도를 초과하여 CPU를 사용할 수 없도록 강제합니다. 이는 특정 컨테이너가 예측 불가능하게 CPU 자원을 과도하게 사용하는 것을 막아 시스템 전체의 안정성을 확보하는 데 매우 중요합니다.
- Cgroup v1에서는 두 개의 파일, cpu.cfs_period_us와 cpu.cfs_quota_us를 함께 사용합니다. period는 CPU 할당량을 계산하는 기준 시간(주기)을 마이크로초 단위로 정의하며, 기본값은 보통 100,000 마이크로초(100ms)입니다. quota는 해당 주기(period) 동안 이 cgroup이 사용할 수 있는 총 CPU 시간을 마이크로초 단위로 정의합니다. 예를 들어, period가 100,000이고 quota를 50,000으로 설정하면, 이 cgroup은 매 100ms 동안 최대 50ms의 CPU 시간만 사용할 수 있습니다. 이는 단일 CPU 코어 기준으로 50% 사용률 제한에 해당합니다. 만약 quota를 200,000으로 설정하면 CPU 코어 2개 분량까지 사용할 수 있음을 의미합니다. (단, 실제 사용 가능한 코어 수에 따라 제한됩니다.)
- Cgroup v2에서는 이 방식이 훨씬 직관적인 cpu.max 파일 하나로 통합되었습니다. $QUOTA $PERIOD 형식을 사용하며, 동작 원리는 v1과 동일합니다. 예를 들어 echo “50000 100000” > cpu.max는 v1의 예시와 같이 50% CPU 제한을 설정하는 것입니다. $QUOTA 값으로 max라는 문자열을 사용하면 제한 없음을 의미합니다.
쿠버네티스에서는 spec.containers[].resources.limits.cpu 필드가 바로 이 절대적 제한 기능에 직접 매핑됩니다. 예를 들어 limits.cpu: “1” (CPU 코어 1개)는 컨테이너 런타임에 의해 cpu.max (또는 v1의 quota/period) 값이 CPU 코어 1개에 해당하는 시간만큼으로 설정되도록 지시합니다. 만약 컨테이너가 이 제한을 초과하여 CPU를 사용하려고 하면, 커널은 해당 컨테이너의 실행을 일시적으로 중단(throttling)시켜 제한을 강제합니다. 이는 애플리케이션의 응답 시간에 영향을 줄 수 있으므로, limits.cpu 값은 신중하게 설정해야 합니다.
메모리 자원 제어: 사용량 한계 설정과 OOM Killer 관리
메모리(RAM)는 애플리케이션의 데이터와 코드를 적재하고 실행하는 데 필수적인 공간입니다. 메모리가 부족하면 애플리케이션 성능이 급격히 저하되거나 비정상적으로 종료될 수 있습니다. Cgroups의 메모리 컨트롤러는 각 컨테이너(cgroup)가 사용할 수 있는 메모리 양을 제한하고, 메모리 부족 상황에 대처하는 메커니즘을 제공합니다.
- 메모리 사용량 제한 (Memory Limit):
가장 기본적인 기능은 특정 cgroup이 사용할 수 있는 최대 메모리 양을 제한하는 것입니다. 이 제한에는 일반적으로 사용자 공간 프로세스가 직접 사용하는 메모리(예: 힙, 스택)뿐만 아니라, 커널이 해당 프로세스를 위해 사용하는 페이지 캐시(Page Cache) 등도 포함됩니다. 스왑(Swap) 메모리 사용량까지 포함하여 제한할 수도 있습니다.
- Cgroup v1에서는 여러 파일을 조합하여 사용합니다. memory.limit_in_bytes는 물리 메모리(RAM) 사용량 제한을 설정하고, memory.memsw.limit_in_bytes는 물리 메모리와 스왑 사용량의 합계를 제한합니다. 만약 memsw 제한이 limit_in_bytes보다 크면, 해당 차이만큼 스왑 사용이 허용됩니다.
- Cgroup v2에서는 인터페이스가 단순화되어, memory.max는 물리 메모리 및 커널 메모리(일부) 사용량의 상한선을 설정하고, memory.swap.max는 스왑 사용량의 상한선을 별도로 설정합니다. 기본적으로 두 값 모두 max로 설정되어 제한 없음을 의미합니다.
매우 중요한 점은, 만약 cgroup 내의 프로세스들이 설정된 메모리 제한(memory.max 또는 limit_in_bytes)을 초과하여 메모리를 계속 할당하려고 시도하면, 리눅스 커널은 해당 cgroup 내에서 OOM(Out Of Memory) Killer를 작동시킨다는 것입니다. OOM Killer는 시스템 전체의 메모리 부족을 막기 위해, 메모리를 과도하게 사용하는 것으로 판단되는 프로세스를 강제로 종료시키는 커널 메커니즘입니다. 컨테이너 환경에서는 이 OOM Killer가 cgroup 단위로 작동하여, 제한을 초과한 컨테이너 내의 프로세스(들)를 종료시킵니다. 쿠버네티스에서 컨테이너 상태가 OOMKilled로 표시되는 것이 바로 이 경우입니다. spec.containers[].resources.limits.memory 설정이 이 메모리 제한 값과 직접적으로 연결되므로, 애플리케이션의 실제 메모리 사용량을 고려하여 적절한 limits.memory 값을 설정하는 것은 컨테이너의 안정적인 운영에 필수적입니다.
- 메모리 부족 상황 관리 및 보호:
단순히 제한을 초과했을 때 프로세스를 종료시키는 것 외에도, cgroups는 메모리 압박(memory pressure) 상황에 좀 더 유연하게 대처할 수 있는 기능들을 제공합니다.
- 소프트 제한 (Soft Limit) / 낮은 경계 (Low Boundary): Cgroup v1의 memory.soft_limit_in_bytes나 cgroup v2의 memory.low는 ‘최소 보장’ 또는 ‘보호 경계’와 유사한 역할을 합니다. 시스템 전체의 메모리가 부족해질 때, 커널은 이 설정값보다 낮은 메모리를 사용하는 cgroup의 메모리(주로 페이지 캐시)는 회수하지 않으려고 노력합니다. 즉, 메모리 압박 상황에서 다른 cgroup보다 우선적으로 보호받을 수 있는 기준선을 제공합니다. 이는 중요한 서비스가 메모리 부족으로 인해 성능 저하를 겪는 것을 완화하는 데 도움이 될 수 있습니다.
- OOM 그룹 단위 처리: Cgroup v2의 memory.oom.group 설정을 1로 하면, OOM Killer가 작동할 때 해당 cgroup 내의 특정 프로세스 하나만 죽이는 대신, cgroup 전체를 하나의 단위로 간주하고 cgroup 내의 모든 프로세스를 함께 종료시킵니다. 이는 관련된 프로세스들이 함께 실행되거나 종료되어야 하는 애플리케이션(예: 특정 파드 내 모든 컨테이너)에 유용할 수 있습니다. 기본값은 0이며, 이 경우 커널이 OOM 점수(oom_score) 등을 고려하여 가장 적합하다고 판단되는 프로세스 하나를 선택하여 종료시킵니다.
- 커널 메모리 사용량 고려:
컨테이너를 실행하면 사용자 프로세스뿐만 아니라, 커널도 해당 컨테이너를 지원하기 위해 내부 데이터 구조(예: 네트워크 버퍼, 파일시스템 메타데이터 캐시인 dentry/inode 캐시, TCP 소켓 버퍼 등)를 위한 메모리를 사용합니다. Cgroup v1에서는 memory.kmem.limit_in_bytes 등을 통해 이러한 커널 메모리 사용량을 별도로 제한하려는 시도가 있었으나, 설정이 복잡하고 오용 시 시스템 불안정을 유발할 수 있었습니다. Cgroup v2에서는 커널 메모리 계산 방식이 개선되어 memory.max 제한에 일부 주요 커널 메모리(예: slab 캐시) 사용량이 포함되도록 하여 관리가 더 용이해졌습니다. 하지만 여전히 컨테이너의 총 메모리 사용량을 정확히 예측하고 제한하는 것은 복잡한 측면이 있습니다.
블록 I/O 자원 제어: 디스크 접근 속도와 우선순위 조절
데이터베이스, 로그 수집기, 빌드 작업 등 많은 애플리케이션은 디스크와 같은 블록 저장 장치에 대한 입출력(I/O) 성능에 크게 의존합니다. 특정 컨테이너가 디스크 I/O를 과도하게 점유하면 다른 컨테이너의 디스크 관련 작업이 느려지거나 멈출 수 있습니다. Cgroups의 블록 I/O 컨트롤러(blkio 또는 io)는 이러한 문제를 완화하기 위해 디스크 I/O 대역폭과 처리량(IOPS)을 제어하는 기능을 제공합니다.
- 상대적 가중치 (Block I/O Weight):
CPU shares와 유사하게, 여러 cgroup이 동시에 디스크 I/O를 요청하여 경합이 발생할 때, 각 cgroup에게 부여된 가중치에 따라 I/O 접근 기회를 분배합니다.
- Cgroup v1에서는 blkio.weight 파일을 사용하며, 값의 범위는 100에서 1000 사이입니다. 기본값은 보통 500입니다. 가중치가 높은 cgroup이 디스크 경합 시 더 많은 I/O 대역폭을 할당받게 됩니다. 또한 blkio.weight_device 파일을 사용하면 특정 장치별로 가중치를 다르게 설정할 수도 있습니다.
- Cgroup v2에서는 io.weight 파일을 사용하며, 기본값은 100이고 범위는 1에서 10000까지입니다. 작동 방식은 v1과 유사합니다.
이 가중치 기반 제어는 시스템 부하가 높을 때 중요한 애플리케이션의 I/O 성능을 상대적으로 보호하는 데 사용될 수 있습니다.
- 절대적 제한 (Throttling – BPS/IOPS):
CPU 제한과 마찬가지로, 특정 cgroup이 특정 블록 장치에 대해 사용할 수 있는 최대 I/O 성능(읽기/쓰기 속도 또는 연산 횟수)을 강제로 제한할 수 있습니다.
- Cgroup v1에서는 다양한 파일을 통해 제어합니다. 예를 들어, blkio.throttle.read_bps_device는 특정 장치에 대한 초당 읽기 바이트 수(BPS)를 제한하고, blkio.throttle.write_iops_device는 특정 장치에 대한 초당 쓰기 I/O 연산 수(IOPS)를 제한합니다. 읽기/쓰기, BPS/IOPS 조합별로 별도의 파일이 존재하여 설정이 다소 복잡할 수 있습니다.
- Cgroup v2에서는 io.max 파일 하나로 통합되어 설정이 간편해졌습니다. rbps= wbps= riops= wiops= 와 같은 형식으로 특정 장치에 대한 읽기/쓰기 BPS 및 IOPS 제한을 한 번에 설정할 수 있습니다. (maj:min 장치 번호 필요)
이 절대적 제한 기능은 특정 컨테이너가 디스크 I/O 자원을 독점하여 시스템 전체의 응답성을 저해하는 것을 확실하게 방지할 수 있습니다. 예를 들어, 백업 작업이나 데이터 분석 배치 작업처럼 I/O 부하가 큰 컨테이너의 성능을 의도적으로 제한하여 다른 온라인 서비스에 미치는 영향을 최소화하는 데 유용합니다. (참고: 쿠버네티스 자체에서는 블록 I/O 제한을 직접 설정하는 표준화된 필드는 아직 없습니다. 필요한 경우 노드 레벨 설정이나 특정 스토리지 솔루션의 기능을 활용해야 할 수 있습니다.)
기타 자원 제어: 안정성과 보안 강화
위에서 설명한 핵심적인 CPU, 메모리, 블록 I/O 외에도 cgroups는 시스템의 안정성과 보안을 강화하기 위한 다른 유용한 제어 기능들을 제공합니다.
- PID 수 제한 (pids 컨트롤러):
pids.max 파일을 통해 특정 cgroup 내에서 동시에 존재할 수 있는 최대 프로세스(및 스레드)의 개수를 제한합니다. 리눅스에서 스레드도 일종의 프로세스로 취급되므로, 이 제한은 총 태스크(task) 수를 제어합니다. 이는 ‘fork bomb’ 공격(자신을 계속 복제하여 시스템 자원을 고갈시키는 악성 코드)으로부터 시스템을 보호하는 데 매우 효과적입니다. 또한, 애플리케이션이 비정상적으로 많은 스레드를 생성하여 발생하는 문제를 예방하는 데도 도움이 됩니다. 쿠버네티스에서도 노드 수준 또는 파드 수준에서 PID 제한을 설정하여 안정성을 높이는 기능이 지원됩니다.
- 네트워크 트래픽 제어 (net_cls, net_prio 컨트롤러):
이 컨트롤러들은 cgroup 자체적으로 네트워크 대역폭을 직접 제한하지는 않습니다. 대신, 특정 cgroup에서 발생하는 네트워크 패킷에 특정 표식(mark)이나 우선순위를 부여하는 역할을 합니다.
- net_cls 컨트롤러는 cgroup의 패킷에 트래픽 클래스 식별자(classid)를 할당합니다. 이 classid는 리눅스의 강력한 트래픽 제어 유틸리티인 tc(Traffic Control)와 함께 사용될 수 있습니다. 관리자는 tc 규칙을 설정하여 특정 classid를 가진 트래픽에 대해 대역폭 제한(shaping), 우선순위 큐잉(queuing) 등 다양한 QoS(Quality of Service) 정책을 적용할 수 있습니다.
- net_prio 컨트롤러는 cgroup에 네트워크 우선순위(priority)를 할당하여, 네트워크 인터페이스가 지원하는 경우 해당 우선순위에 따라 패킷 전송 순서를 조절하도록 돕습니다.
쿠버네티스 환경에서는 네트워크 정책과 QoS가 주로 CNI(Container Network Interface) 플러그인(예: Calico, Cilium) 수준에서 구현되는 경우가 많으며, 이 플러그인들이 내부적으로 tc나 eBPF와 같은 기술을 활용하여 cgroups의 net_cls 기능과 유사하거나 더 발전된 형태의 네트워크 제어를 제공하기도 합니다.
- 장치 접근 제어 (devices 컨트롤러):
보안 측면에서 매우 중요한 컨트롤러입니다. devices.allow 및 devices.deny 파일을 통해 특정 cgroup 내의 프로세스들이 접근할 수 있는 장치 파일(device file)을 명시적으로 제어할 수 있습니다. 장치 파일은 /dev 디렉토리 아래에 위치하며, 하드웨어 장치(디스크, 터미널, USB 장치 등)나 커널 기능(예: /dev/null, /dev/random)에 대한 접근 인터페이스입니다.
기본적으로 컨테이너는 매우 제한된 장치에만 접근이 허용됩니다. devices 컨트롤러를 사용하면, 예를 들어 특정 컨테이너에게 특정 시리얼 포트(/dev/ttyS0)나 특정 디스크 파티션(/dev/sda1)에 대한 읽기(r), 쓰기(w), 생성(mknod) 권한을 선택적으로 부여하거나, 반대로 모든 장치 접근을 거부하는 등의 세밀한 정책 설정이 가능합니다. 이는 컨테이너가 호스트 시스템의 민감한 하드웨어나 자원에 무단으로 접근하는 것을 차단하여 컨테이너 탈출(escape) 공격의 위험을 줄이고 시스템 보안을 강화하는 데 핵심적인 역할을 합니다.
이처럼 cgroups는 CPU, 메모리, I/O 등 핵심 자원뿐만 아니라 프로세스 개수, 네트워크 트래픽 마킹, 장치 접근 등 다양한 측면에서 컨테이너 환경의 성능, 안정성, 보안을 확보하기 위한 필수적인 제어 메커니즘을 제공합니다. 쿠버네티스와 같은 오케스트레이션 시스템은 이러한 cgroups의 기능을 활용하여 복잡한 클라우드 네이티브 환경을 효과적으로 관리하고 운영하는 기반을 마련합니다.
2.2.2.2 cgroups 동작 방식: 자원 제어는 어떻게 이루어지나?
앞서 cgroups가 CPU, 메모리, I/O 등 다양한 시스템 자원을 어떻게 제한하고 할당하는지 그 기능들을 살펴보았습니다. 그렇다면 이제 이 기능들이 실제로 어떤 메커니즘을 통해 구현되는지, 그 내부적인 작동 원리를 자세히 들여다볼 차례입니다. Cgroups의 동작 방식을 이해하는 것은 컨테이너의 성능 특성을 파악하고 자원 설정을 최적화하는 데 큰 도움이 될 것입니다. 크게 네 가지 핵심 요소 – 계층 구조, 컨트롤러, 가상 파일시스템 인터페이스, 컨테이너 런타임과의 연동 – 를 중심으로 살펴보겠습니다.
계층 구조 (Hierarchy): 그룹화와 자원 분배의 틀
Cgroups의 가장 근본적인 설계 개념 중 하나는 바로 계층 구조(Hierarchy)입니다. 이는 운영체제의 프로세스들을 단순히 목록으로 관리하는 것이 아니라, 마치 회사의 조직도나 컴퓨터의 폴더 구조처럼 트리(tree) 형태의 계층으로 묶어서 관리하는 방식입니다. 시스템에는 하나 이상의 cgroup 계층이 존재할 수 있으며(특히 cgroup v1의 경우), 각 계층은 최상위 루트(root) cgroup에서 시작하여 하위로 가지를 뻗어 나갑니다. 관리자는 루트 아래에 새로운 cgroup(하위 그룹)을 생성할 수 있고, 그 하위 그룹 아래에 또 다른 cgroup을 중첩하여 생성하는 방식으로 원하는 구조를 만들 수 있습니다. 그리고 시스템에서 실행되는 각 프로세스는 반드시 이 계층 구조 내의 특정 cgroup 하나에 속하게 됩니다.
이 계층 구조는 단순히 프로세스를 그룹화하는 것 이상의 중요한 의미를 갖습니다. 바로 자원 제어의 상속(inheritance)과 위임(delegation)을 가능하게 한다는 점입니다. 일반적으로 부모 cgroup에 설정된 자원 제한이나 설정은 그 아래의 모든 자식 cgroup에게도 영향을 미칩니다. 예를 들어, 특정 부모 cgroup에 메모리 사용량을 1GB로 제한했다면, 그 자식 cgroup들은 아무리 개별적인 제한을 높게 설정하려 해도 모두 합쳐 1GB라는 부모의 한계를 넘을 수 없습니다. 또한, CPU 가중치(cpu.shares 또는 cpu.weight)와 같은 상대적 할당 방식에서는, 자식 cgroup들은 부모 cgroup이 할당받은 자원 내에서 자신들의 가중치 비율에 따라 다시 자원을 나눠 갖게 됩니다.
쿠버네티스 환경에서는 이러한 계층 구조가 매우 유용하게 활용됩니다. 리눅스 시스템 부팅 시 systemd와 같은 초기화 시스템은 서비스나 사용자 세션별로 cgroup 계층을 구성하는 경우가 많습니다. 컨테이너 런타임은 종종 이러한 systemd의 슬라이스(slice)나 스코프(scope) 단위 아래에 컨테이너를 위한 cgroup을 생성합니다. 이를 통해 시스템 전체 자원 -> 특정 서비스(예: 컨테이너 런타임 서비스) -> 특정 파드(Pod) 그룹 -> 개별 컨테이너 순서로 자원을 단계적으로 관리하고 제어하는 것이 가능해집니다. 예를 들어, 특정 노드(Node) 전체의 자원 사용량을 관리하고, 그 안에서 쿠버네티스가 관리하는 파드들이 사용할 총 자원을 제한하며, 다시 각 파드나 컨테이너별로 세부적인 자원 제한을 두는 방식의 정교한 자원 관리가 이 계층 구조 덕분에 가능한 것입니다.
컨트롤러 (Controllers / Subsystems): 자원별 전문가
Cgroups가 다양한 종류의 자원을 제어할 수 있는 이유는 각 자원 유형을 전담하는 컨트롤러(Controller) (cgroup v1에서는 서브시스템이라고도 불렸습니다)가 존재하기 때문입니다. 마치 각기 다른 전문 분야를 가진 부서처럼, cpu 컨트롤러는 CPU 시간 할당 및 제한을, memory 컨트롤러는 메모리 사용량 측정 및 제한을, blkio 또는 io 컨트롤러는 블록 장치 I/O 제어를, pids 컨트롤러는 프로세스 수 제한을, devices 컨트롤러는 장치 접근 권한 관리를 담당합니다.
시스템 관리자는 특정 cgroup 계층에 필요한 컨트롤러들을 ‘연결(attach)’ 또는 ‘활성화(enable)’ 함으로써 해당 계층의 cgroup들에게 해당 자원에 대한 제어 기능을 부여할 수 있습니다. 여기서 cgroup v1과 cgroup v2 사이에 중요한 차이점이 있습니다.
- Cgroup v1: 각 컨트롤러는 독립적인 계층 구조를 가질 수도 있었고, 또는 여러 컨트롤러가 하나의 계층 구조에 함께 연결될 수도 있었습니다. 예를 들어, cpu와 cpuacct 컨트롤러는 보통 같은 계층에 연결되었지만, memory 컨트롤러는 완전히 별개의 계층 구조에 연결될 수 있었습니다. 이는 유연성을 제공했지만, 서로 다른 계층에 속한 동일 프로세스 그룹에 대해 일관된 정책을 적용하기 어렵고, 컨트롤러 간의 상호작용이 복잡해지는 단점이 있었습니다.
- Cgroup v2: 이러한 복잡성을 해결하기 위해 설계되었습니다. Cgroup v2에서는 모든 활성화된 컨트롤러가 반드시 단일 통합 계층(unified hierarchy)에 속하게 됩니다. 즉, 시스템에는 오직 하나의 cgroup v2 계층 구조만 존재하며, 모든 자원 제어는 이 단일 트리를 통해 이루어집니다. 특정 cgroup 아래에서 특정 컨트롤러를 활성화하려면, 부모 cgroup의 cgroup.subtree_control 파일에 해당 컨트롤러 이름을 추가하는 방식을 사용합니다. 이 통합된 접근 방식은 컨트롤러 간의 협력을 용이하게 하고(예: 메모리 부족 상황에서 I/O 속도를 조절하는 등), 시스템 전체의 자원 관리를 더욱 명확하고 일관성 있게 만들어 줍니다. 최신 리눅스 배포판과 쿠버네티스(v1.25 이상에서 점진적으로 기본값으로 전환 중)는 cgroup v2 사용을 적극적으로 권장하고 채택하는 추세입니다.
가상 파일시스템 인터페이스: 사용자와 커널의 소통 창구
Cgroups는 분명 리눅스 커널 내부의 기능이지만, 사용자가 커널 코드를 직접 수정하지 않고도 cgroups를 쉽게 제어하고 상태를 확인할 수 있도록 가상 파일시스템(Virtual Filesystem) 형태의 인터페이스를 제공합니다. 이는 리눅스/유닉스 시스템에서 커널 정보를 사용자 공간에 노출하는 일반적인 방식입니다(마치 /proc 파일시스템처럼). 일반적으로 이 cgroup 가상 파일시스템은 /sys/fs/cgroup 디렉토리에 마운트됩니다.
- Cgroup v1의 경우, /sys/fs/cgroup 아래에 각 컨트롤러(또는 컨트롤러 그룹)별로 하위 디렉토리(예: /sys/fs/cgroup/cpu, /sys/fs/cgroup/memory)가 마운트 포인트로 생성됩니다.
- Cgroup v2의 경우, 통합 계층이므로 보통 /sys/fs/cgroup 자체가 단일 마운트 포인트가 됩니다 (cgroup2 파일시스템 타입으로 마운트됨).
사용자나 시스템 관리 도구(예: 컨테이너 런타임, systemd)는 이 파일시스템 위에서 다음과 같은 표준 파일/디렉토리 연산을 통해 cgroups를 조작합니다.
- Cgroup 생성: 특정 cgroup 아래에 새로운 하위 cgroup을 만들고 싶으면, 간단히 해당 디렉토리 안에서 mkdir 명령어로 새 디렉토리를 생성하면 됩니다. 커널은 이 디렉토리 생성 요청을 가로채어 새로운 cgroup 객체를 내부적으로 생성하고, 필요한 제어 파일들을 해당 디렉토리 안에 자동으로 채워 넣습니다.
- 프로세스 할당: 특정 프로세스를 특정 cgroup으로 이동시키려면, 해당 프로세스의 ID(PID)를 원하는 cgroup 디렉토리 내의 특정 파일에 쓰면 됩니다. Cgroup v1에서는 tasks 파일에 PID를 썼고, cgroup v2에서는 cgroup.procs 파일에 PID를 씁니다. 한 프로세스는 각 계층 내에서 오직 하나의 cgroup에만 속할 수 있습니다. 일반적으로 프로세스가 fork()나 clone()으로 자식 프로세스를 생성하면, 자식 프로세스는 부모 프로세스와 동일한 cgroup 멤버십을 상속받습니다.
- 자원 제어 파라미터 설정 및 모니터링: 각 cgroup 디렉토리 안에는 해당 cgroup에 연결된 컨트롤러들이 제공하는 다양한 제어 파일(interface files)들이 존재합니다. 이 파일들은 텍스트 기반이며, 파일에 특정 값을 쓰는(write) 행위를 통해 자원 제한이나 설정을 변경하고, 파일을 읽는(read) 행위를 통해 현재 설정값이나 자원 사용량 통계를 확인할 수 있습니다. 예를 들어,
- memory.max (v2) 또는 memory.limit_in_bytes (v1) 파일에 메모리 제한 값을 바이트 단위로 써서 설정합니다.
- cpu.weight (v2) 또는 cpu.shares (v1) 파일에 CPU 가중치 값을 씁니다.
- pids.max 파일에 최대 프로세스 수를 씁니다.
- memory.current (v2) 파일을 읽어 현재 메모리 사용량을 확인합니다.
- cpu.stat (v2) 파일을 읽어 CPU 사용 시간, 스로틀링 통계 등을 확인합니다.
- io.stat (v2) 파일을 읽어 블록 I/O 통계를 확인합니다.
이처럼 파일시스템을 통한 직관적인 인터페이스는 셸 스크립트나 프로그래밍 언어를 통해 cgroups를 쉽게 자동화하고 관리할 수 있게 해줍니다.
컨테이너 런타임과의 연동: 쿠버네티스의 자원 관리 구현
쿠버네티스와 같은 고수준의 컨테이너 오케스트레이션 시스템은 cgroups의 세부적인 파일시스템 조작을 직접 수행하지 않습니다. 대신, 이 역할은 각 노드에서 실제로 컨테이너를 생성하고 관리하는 컨테이너 런타임(예: containerd, CRI-O)에게 위임됩니다. 전체적인 흐름은 다음과 같습니다.
- 사용자 정의: 사용자는 파드(Pod)를 정의하는 YAML 파일 내의 spec.containers[].resources 필드에 각 컨테이너가 필요로 하는 자원의 요청량(requests)과 상한선(limits)을 명시합니다. (예: requests: {cpu: “250m”, memory: “512Mi”}, limits: {cpu: “500m”, memory: “1Gi”})
- 스케줄링: 쿠버네티스 스케줄러는 파드의 requests 값을 보고, 해당 자원을 충분히 제공할 수 있는 노드(Node)를 찾아 파드를 배치합니다.
- Kubelet의 지시: 파드가 특정 노드에 할당되면, 해당 노드에서 실행 중인 Kubelet(노드 에이전트)이 파드 명세를 확인하고, 컨테이너 런타임에게 컨테이너 생성을 요청합니다. 이때 Kubelet은 파드 명세에 정의된 requests와 limits 정보를 컨테이너 런타임에게 전달합니다.
- 컨테이너 런타임의 cgroup 관리: 컨테이너 런타임은 Kubelet으로부터 받은 정보를 바탕으로 다음과 같은 cgroup 관련 작업을 수행합니다.
- 해당 컨테이너(또는 파드 전체)를 위한 새로운 cgroup을 /sys/fs/cgroup 아래의 적절한 위치에 생성합니다. (앞서 언급했듯이, 종종 systemd 슬라이스 계층 내부에 생성하여 시스템 전체의 자원 관리와 통합됩니다.)
- 전달받은 limits 값(예: limits.cpu, limits.memory)을 기반으로, 생성된 cgroup 내의 해당 컨트롤러 제어 파일(예: cpu.max, memory.max)에 적절한 제한 값을 설정합니다.
- 전달받은 requests 값(예: requests.cpu)은 주로 CPU 가중치(cpu.weight 또는 cpu.shares) 설정에 영향을 주어, CPU 경합 시 요청한 비율만큼의 자원을 확보할 가능성을 높입니다. (메모리 requests는 주로 스케줄링 결정에 사용되지만, 일부 QoS 관리 로직에 영향을 줄 수도 있습니다.)
- 컨테이너의 초기 프로세스를 시작시킨 후, 해당 프로세스의 PID를 생성된 cgroup의 cgroup.procs (또는 v1의 tasks) 파일에 기록합니다. 이렇게 함으로써 해당 프로세스와 그 자식 프로세스들은 모두 이 cgroup의 자원 제한 및 관리 정책을 적용받게 됩니다.
- 커널의 지속적인 관리: 일단 cgroup 설정이 완료되고 프로세스가 할당되면, 그 이후의 지속적인 자원 사용량 모니터링과 제한 적용은 리눅스 커널이 담당합니다. 커널은 스케줄링 시점, 메모리 할당 시점, I/O 요청 시점 등 자원 접근이 발생할 때마다 해당 프로세스가 속한 cgroup의 설정을 확인하고, 필요한 경우 CPU 시간을 제한(throttling)하거나, 메모리 할당을 거부하거나, OOM Killer를 작동시키는 등의 조치를 취하여 설정된 제한을 강제합니다.
이처럼 cgroups는 계층적인 그룹화를 통해 관리 단위를 정의하고, 자원별 컨트롤러를 통해 전문적인 제어 기능을 제공하며, 가상 파일시스템 인터페이스를 통해 사용자 공간과의 상호작용을 가능하게 합니다. 그리고 컨테이너 런타임은 이러한 저수준 메커니즘을 활용하여 쿠버네티스와 같은 상위 시스템의 자원 관리 정책을 실제로 구현하는 역할을 합니다.
결론적으로, 네임스페이스가 컨테이너에게 독립적인 ‘시야’를 제공한다면, cgroups는 각 컨테이너가 사용할 수 있는 ‘예산’과 ‘자원 한도’를 설정하고 관리하는 핵심적인 기술입니다. 이 두 기술의 긴밀한 협력을 통해 우리는 오늘날 클라우드 네이티브 환경에서 필수적인, 가볍고 효율적이면서도 안정적인 컨테이너 기술의 이점을 누릴 수 있는 것입니다.