[Dev] Pod CPU Limit과 FFmpeg Thread 최적 조정 - 1. 배경지식: CPU와 스케줄링

7 분 소요


이전 글에서 동일 노드에서 Docker 컨테이너(4.5초)와 K8s Pod(148초)의 37배 성능 차이를 확인했다. 이 차이를 이해하려면 먼저 CPU가 어떤 성격의 자원인지, 그 자원을 커널이 어떻게 관리하는지, 그리고 특정 프로세스 그룹에 상한을 걸면 어떤 일이 일어나는지 알아야 한다.

CPU 스케줄링은 그 자체로 깊이 파볼 수 있는 주제다. 하지만 여기서는 이 문제를 이해하는 데 필요한 수준으로만 짚고 넘어간다.


TL;DR

  • CPU는 시간을 나눠쓰는 자원(compressible)이다. 초과하면 죽지 않고 느려진다(throttling)
  • 물리 코어와 논리 프로세서는 다르다. 하이퍼스레딩으로 물리 코어 1개가 논리 프로세서 2개로 보인다. 이 시리즈에서 “코어”는 논리 프로세서를 의미한다
  • 멀티코어 시스템에서는 여러 스레드가 물리적으로 동시에 실행될 수 있다(병렬성)
  • CPU 시간은 user/sys/real로 측정되며, (user + sys) / real 비율로 실제 사용 코어 수를 추정할 수 있다
  • 리눅스 기본 스케줄러 CFS는 모든 프로세스에 공정하게 CPU 시간을 배분한다
  • CFS bandwidth control로 그룹 단위 상한(quota/period)을 설정할 수 있으며, quota를 초과하면 throttling(강제 대기)이 발생한다


CPU는 시간 자원이다

자원 성격: compressible vs. incompressible

CPU와 메모리는 둘 다 컴퓨팅 자원이지만, 성격이 다르다.

  CPU 메모리
자원 성격 시간을 나눠쓰는 자원 (compressible) 공간을 점유하는 자원 (incompressible)
초과 시 throttling (느려짐, 대기) OOM Kill (프로세스 강제 종료)
회수 가능? 다음 period에 자연스럽게 회수 프로세스가 반환하지 않으면 kill해야 함

CPU는 “시간”이다. 1초 동안 CPU를 0.5초 쓸 수 있다면, 나머지 0.5초는 다른 프로세스가 쓸 수 있다. 초과하면 죽지 않고 대기한다. 반면 메모리는 “공간”이다. 한 프로세스가 점유한 메모리는 다른 프로세스가 쓸 수 없고, 한계를 넘으면 커널이 프로세스를 강제로 종료한다.

이 차이가 중요한 이유는, K8s에서 CPU limit을 설정했을 때 발생하는 현상이 “프로세스가 죽는 것”이 아니라 “느려지는 것”이기 때문이다. 이전 글에서 본 148초라는 처리 시간이 바로 이 “느려짐”의 결과다. 메모리 limit 초과 시에는 OOMKilled가 뜨니까 금방 알아챌 수 있는데, CPU throttling이 발생할 때는 “그냥 느린 것”으로 보이기 때문에 발견이 더 어렵다.


싱글코어와 멀티코어

CPU 코어가 1개인 시스템에서는 한 시점에 하나의 스레드만 실행할 수 있다. 여러 스레드가 있으면 커널이 빠르게 전환(context switch)하면서 번갈아 실행한다. 동시에 실행되는 것처럼 보이지만, 실제로는 시간을 나눠쓰는 것이다.

코어가 여러 개인 시스템에서는 각 코어가 독립적으로 스레드를 실행할 수 있다. 4코어 시스템이라면 4개의 스레드가 물리적으로 동시에 실행된다.


물리 코어, 논리 프로세서, 하이퍼스레딩

여기서 “코어”라는 용어를 짚고 넘어가야 한다. 실제 하드웨어에서는 두 가지 단위가 있다.

구분 의미
물리 코어 (Physical Core) CPU 칩에 실제로 존재하는 연산 유닛. 독립적인 ALU, 레지스터 등을 갖는다
논리 프로세서 (Logical Processor) OS가 인식하는 실행 단위. 물리 코어 1개가 논리 프로세서 1개 이상에 대응한다

물리 코어와 논리 프로세서의 수가 다른 이유는 하이퍼스레딩(Hyper-Threading, Intel) 또는 SMT(Simultaneous Multi-Threading, AMD)라는 기술 때문이다. 이 기술은 하나의 물리 코어가 2개의 하드웨어 스레드를 동시에 처리할 수 있게 한다. 완전한 병렬은 아니고(실행 유닛을 공유하므로), 대략 물리 코어 1개 대비 1.2~1.5배 정도의 처리량 향상을 기대할 수 있다.

10 물리 코어 + 하이퍼스레딩 → 20 논리 프로세서
 4 물리 코어 + 하이퍼스레딩 →  8 논리 프로세서
 4 물리 코어 (SMT 비활성화) →  4 논리 프로세서

이 시리즈의 실험 환경인 20 core 서버도 실제로는 10 물리 코어 + 하이퍼스레딩 = 20 논리 프로세서 구성이다. nproc이나 /proc/cpuinfo로 확인되는 숫자, 그리고 ffmpeg이 -threads auto에서 참조하는 숫자가 모두 이 논리 프로세서 수다.

이 시리즈에서 별도의 구분 없이 “코어”라고 쓰면, 논리 프로세서(logical processor)를 의미한다. K8s의 CPU limit, cgroup의 quota, ffmpeg의 스레드 결정 — 모두 논리 프로세서 단위로 동작하기 때문이다.


동시성과 병렬성

  동시성 (Concurrency) 병렬성 (Parallelism)
정의 여러 작업이 논리적으로 동시에 진행 여러 작업이 물리적으로 동시에 실행
필요 조건 코어 1개로도 가능 멀티코어 필요
동작 방식 시분할(time-slicing)로 번갈아 실행 각 코어에서 동시 실행

CPU limit 환경에서의 의미

Go의 goroutine은 동시성을 제공한다. goroutine 3개를 만들면 논리적으로 동시에 진행되지만, 실제로 물리 코어 위에서 병렬로 실행되는지는 코어 수와 런타임 스케줄러에 달려 있다.

CPU limit 1000m(1 core) 환경에서는:

  • 동시성은 유지된다: 한 goroutine이 I/O 대기에 들어가면 다른 goroutine이 실행될 수 있다
  • 병렬성은 제약을 받는다: 1 core 분량의 CPU 시간만 있으므로, 한 시점에 물리적으로 하나의 스레드만 실행된다

goroutine뿐 아니라, 프로세스가 내부적으로 생성하는 스레드에도 같은 원리가 적용된다. CPU-intensive한 프로세스가 호스트 코어 수 기반으로 많은 스레드를 만들면, 제한된 quota를 두고 경쟁하게 된다.


CPU 시간 측정

user, sys, real

리눅스에서 프로세스의 CPU 사용 시간은 세 가지로 측정된다.

시간 의미 측정 대상
real 벽시계 시간(wall-clock time) 프로세스 시작부터 종료까지 실제 경과 시간
user 사용자 모드 CPU 시간 애플리케이션 코드 실행에 소비한 시간 (전체 코어 합산)
sys 커널 모드 CPU 시간 시스템 콜 처리에 소비한 시간 (전체 코어 합산)

핵심은 user와 sys가 모든 코어의 작업 시간을 합산한다는 것이다. 코어 2개가 각각 20초씩 일했다면, user 시간은 40초로 기록된다. 반면 real은 벽시계 기준이므로 20초다.


코어 수 추정: (user + sys) / real

이 성질을 이용하면, 프로세스가 실제로 몇 개의 코어를 활용했는지 추정할 수 있다.

실제 사용 코어 수 ≈ (user + sys) / real

Case 1: 싱글 코어 사용 (비율 ≈ 1.0)

시간 흐름 (Real Time):
[0초────10초────20초────30초────40초────50초]

Core 1: [████░████████░███████████░██████████████████] user=48s, sys=2s
Core 2: [----------------------------------------------] 대기
Core 3: [----------------------------------------------] 대기
Core 4: [----------------------------------------------] 대기

Legend: █ = user time, ░ = sys time

real: 50초  |  user: 48초  |  sys: 2초
비율: (48 + 2) / 50 = 1.0 → 1개 코어 사용

Case 2: 2코어 병렬 사용 (비율 ≈ 2.0)

시간 흐름 (Real Time):
[0초────10초────20초────25초]

Core 1: [████░████████░██████████] user=23s, sys=1s
Core 2: [████████░████████░██████] user=23s, sys=1s
Core 3: [-------------------------] 대기
Core 4: [-------------------------] 대기

Legend: █ = user time, ░ = sys time

real: 25초  |  user: 46초  |  sys: 2초
비율: (46 + 2) / 25 ≈ 2.0 → 2개 코어 사용

같은 총 작업량(user 48초)이라도, 코어를 2개 쓰면 real이 절반으로 줄어든다. 비율이 높을수록 더 많은 코어를 활용하고 있다는 의미다.

비율 해석
≈ 1.0 싱글 스레드
≈ 2.0 2코어 활용
≈ 4.0 4코어 활용
< 1.0 I/O 대기 등으로 CPU가 놀고 있음

이 비율은 나중에 ffmpeg이 실제로 몇 개의 코어를 사용하는지 확인할 때 활용한다.


CPU 스케줄링

커널 스케줄러

CPU 시간을 어떻게 나눠쓸지 결정하는 것은 커널의 스케줄러가 담당한다. 어떤 프로세스에 얼마만큼의 시간을 할당할지, 언제 다른 프로세스로 전환할지를 결정한다.

리눅스에는 여러 스케줄링 정책이 있다.

스케줄러 용도 특징
CFS (Completely Fair Scheduler) 일반 프로세스 공정한 CPU 시간 배분
RT (Real-Time) 실시간 프로세스 우선순위 기반, 지연 최소화
Deadline 데드라인 프로세스 마감 시간 보장

일반적인 애플리케이션(ffmpeg 포함)은 CFS의 관리를 받는다.


CFS: Completely Fair Scheduler

CFS는 이름 그대로 완전히 공정한 스케줄러다. 모든 프로세스에 CPU 시간을 균등하게 배분하는 것을 목표로 한다.

동작 원리를 간단히 요약하면:

  1. 각 프로세스의 가상 실행 시간(vruntime)을 추적한다
  2. vruntime이 가장 적은 프로세스에 CPU를 할당한다
  3. 프로세스가 실행되면 vruntime이 증가한다
  4. vruntime이 다른 프로세스보다 커지면 전환한다

결과적으로 모든 프로세스가 비슷한 양의 CPU 시간을 받게 된다. CFS의 내부 구현(red-black tree, time slice 계산 등)까지 깊이 들어갈 필요는 없다. 중요한 것은 다음이다:

CFS는 기본적으로 모든 프로세스에 공정하게 CPU를 배분하되, bandwidth control을 통해 특정 그룹에 상한을 설정할 수 있다.


CFS Bandwidth Control

CFS가 “공정하게” CPU를 나눠준다고 했다. 그런데 특정 프로세스 그룹이 CPU를 무한정 쓰지 못하도록 상한을 걸고 싶다면? CFS bandwidth control이 이 역할을 한다.

두 가지 파라미터로 제어한다.

파라미터 의미 기본값
period 시간 측정 주기 100ms (100,000 us)
quota period 내 사용 가능한 CPU 시간 제한 없음

동작 방식:

  • period마다 quota만큼의 CPU 시간이 충전된다
  • 프로세스가 CPU를 사용하면 quota가 차감된다
  • quota가 0이 되면, 해당 period가 끝날 때까지 실행할 수 없다

예를 들어 quota = 200ms, period = 100ms이면, 100ms마다 200ms의 CPU 시간을 쓸 수 있다. 이는 2코어 분량의 CPU 시간에 해당한다.

period = 100ms, quota = 200ms (2코어 분량)

시간 흐름:
[0ms ────── 100ms ────── 200ms ────── 300ms]

  Period 1          Period 2          Period 3
  quota=200ms       quota=200ms       quota=200ms
  [사용 → 소진]     [충전 → 사용]     [충전 → 사용]


Throttling: quota 소진 시 강제 대기

quota를 다 쓰면 어떻게 될까? 해당 그룹의 모든 프로세스(스레드 포함)는 다음 period가 시작되어 quota가 다시 충전될 때까지 강제로 대기한다. 이것이 throttling이다.

period = 100ms, quota = 100ms (1코어 분량)

스레드가 1개일 때:
[0ms ────── 100ms ────── 200ms]
 [██████████████████████████████████████████]  ← quota를 period 전체에 걸쳐 사용
 실행              실행

스레드가 4개일 때:
[0ms ── 25ms ────── 100ms ────── 125ms ────── 200ms]
 [████████]                       [████████]
 4스레드가 동시에 실행             다시 4스레드 실행
 → 25ms만에 quota 100ms 소진!    → 25ms만에 소진
          [대기 75ms...]                   [대기 75ms...]

핵심은 멀티스레드가 상황을 악화시킨다는 것이다. 스레드 4개가 동시에 실행되면, 각각 25ms씩만 돌아도 합산 100ms의 quota를 소진한다. 나머지 75ms는 모든 스레드가 대기해야 한다. 스레드가 많을수록 quota를 더 빠르게 소진하고, 대기 시간도 더 길어진다.

여기서 이전 글의 질문에 대한 실마리가 보인다. “1 core 제한이면 1 core 속도로 돌아가야지, 왜 그보다도 훨씬 느린 걸까?” ffmpeg이 멀티스레드로 동작하면서 quota를 순식간에 소진하고, 대부분의 시간을 대기하며 보내고 있을 가능성이 있다. 하지만 이것은 아직 가설이다.

처음 이 개념을 접했을 때, “아, 그래서 그랬구나” 하고 머리를 탁 쳤다. 스레드를 많이 만드는 것이 항상 좋은 게 아니라, CPU quota가 제한된 환경에서는 오히려 독이 될 수 있다는 점이 인상 깊었다.


정리

지금까지의 내용을 이 문제와 연결하면:

개념 이 문제에서의 의미
CPU는 시간 자원 limit 초과 시 죽지 않고 느려진다 (148초의 원인)
멀티코어와 병렬성 ffmpeg은 여러 코어를 동시에 활용하려 한다
(user+sys)/real 비율 ffmpeg이 실제로 몇 코어를 쓰는지 측정할 수 있다
CFS bandwidth control K8s CPU limit은 이 메커니즘으로 구현된다
throttling quota 소진 시 강제 대기, 멀티스레드일수록 악화

그런데 CFS bandwidth control의 quota/period는 어디에 설정하는 걸까? 프로세스 “그룹”이라고 했는데, 그 그룹은 어떻게 정의되는 걸까? 다음 글에서는 이 그룹을 관리하는 리눅스 커널 기능인 cgroup과, 이를 활용하는 컨테이너와 쿠버네티스의 리소스 관리를 살펴본다.



hit count

댓글남기기