본문 바로가기
프로그래밍

4코어 서버가 2.75코어만 쓰고 있었다: 컨테이너 CPU 한도와 스로틀 읽기

by me_in_sk 2026. 8. 24.
반응형
이미지 편집 엔진

4코어 서버가 2.75코어만 쓰고 있었다: 컨테이너 CPU 한도와 스로틀 읽기

사양을 올리기 전에 1분이면 확인되는 것, 그리고 한도가 만드는 파생 오류
핵심 요약
  • 처리가 느릴 때 후보는 셋이다 — 코드가 느리거나, 서버가 작거나, 한도가 막고 있거나. 밖에서 응답 시간만 보면 셋이 똑같이 보인다.
  • 구분하는 값은 커널이 세어 준다. cgroup v2라면 컨테이너 안에서 nr_throttled가 그냥 읽히고, 0이 아니면 한도가 실제로 작업을 막고 있다는 뜻이다.
  • 이 서버는 4코어인데 한도 합이 2.75였다. 목표 사양이 2 vCPU이던 시절 값이 그대로 남아 1.25코어가 아무에게도 할당되지 않은 채 놀고 있었다.
  • 한도를 풀자 같은 부하에서 1.8배 빨라지고 메모리 피크가 494MiB 내려갔다. CPU와 메모리는 겹침을 통해 붙어 있다.
  • 더 중요한 건 파생 오류였다 — 큐 깊이를 잘못된 서비스 시간으로 계산해 한 번 잘못 좁혔다. 파생값이 답답하면 값이 아니라 입력을 의심한다.

1.느리다는 신호는 세 가지를 가린다

서비스 요청이 느리다. 원인 후보를 떠올리면 보통 둘이다 — 코드가 느리거나, 서버가 작거나. 컨테이너 위에서 돌고 있다면 세 번째가 있다.

코드가 느리다 프로파일러로 잡는다 서버가 작다 사양을 올리면 나아진다 한도가 막고 있다 사양을 올려도 아무 일도 안 일어난다

이 글의 예로 쓰는 구성은 흔한 세 컨테이너다 — 앞단 proxy, 페이지를 주는 web, 그리고 무거운 작업을 도맡는 worker. CPU를 실제로 먹는 것은 마지막 하나다.

세 번째가 성가신 이유는 겉으로 두 번째와 구분이 안 된다는 데 있다. 응답 시간이 늘어나고, CPU 사용률은 높고, 사양을 키우면 나아질 것 같다. 그런데 한도가 그대로면 더 큰 서버를 사도 그대로다.

다행히 이건 추측할 필요가 없는 종류의 질문이다. 커널이 세어 놓은 값이 있다.

2.커널이 세어 준 값으로 구분하기

한도가 막고 있는지는 cgroup 카운터가 알려준다. cgroup v2를 쓰는 리눅스라면 컨테이너 안에서 그냥 읽힌다 — 별도 도구도, 호스트 접근도 필요 없다.

추측할 필요가 없다bash
$ cat /sys/fs/cgroup/cpu.max
175000 100000       # 100ms 주기마다 175ms 어치 = 1.75코어가 상한

$ cat /sys/fs/cgroup/cpu.stat
nr_throttled   ...       # 한도에 걸려 정지당한 주기 수
throttled_usec ...       # 정지당한 총 시간

읽는 법은 간단하다. 주기가 100ms이므로 nr_throttled가 초당 10에 가까우면 거의 모든 주기에서 정지당했다는 뜻이다. 이건 해석이 아니라 커널이 세어 준 횟수다. 0이면 한도는 결백하고, 0이 아니면 한도가 실제로 작업을 막고 있다.

그리고 이 값을 보기 전에 확인할 것이 하나 더 있다. 한도가 실제 하드웨어와 맞는지다.

1분이면 끝나는 확인bash
$ nproc
4                              # 실제 코어

$ grep -r cpus: compose.yml
proxy:      "0.25"
web:        "0.75"
worker:     "1.75"            # 합 2.75. 1.25코어가 아무에게도 안 갔다

이 프로젝트에서 한도는 목표 사양이 2 vCPU이던 시절에 맞춰진 값이었다. 실제로 산 서버는 4 vCPU였고 메모리만 맞았는데, 그 사이 아무도 nproc을 다시 치지 않았다. 카운터를 보니 답이 바로 나왔다.

지표한도 1.75코어한도 3.5코어
부하 중 실사용 CPU1.75~1.80 (한도 밀착)3.50~3.56 (한도 밀착)
nr_throttled초당 ~10회초당 ~10회
throttled_usec2초당 ~4,400ms2초당 ~900ms
같은 작업 소요27.8 / 32.1s16.9 / 16.2s
메모리 피크5809MiB5315MiB
Compose CPU 한도 vs 실제 코어 — 4 vCPU 서버 (worker = 무거운 작업을 도맡는 컨테이너) 한도는 예약이 아니라 상한이다. 위: 문서상 2 vCPU에 맞춰진 값 · 아래: 실측 후 worker 1.75 web 0.75 proxy 0.25 이전 아무에게도 할당되지 않음 1.25 worker가 매 스케줄링 주기마다 스로틀당하는 동안(nr_throttled 초당 ~10회) 1.25코어가 놀았다 worker 3.5 web 0.75 proxy 0.25 이후 실제 코어 4 한도 합 4.5 > 4. 셋이 동시에 최대로 요구할 때만 경쟁이 생기고, 실제로 CPU를 원하는 것은 worker뿐이다
병목은 하드웨어가 아니라 설정이었다. nr_throttled가 0이 아니면 한도가 실제로 작업을 막고 있다는 뜻이다.
가져갈 규칙

사양을 올리기 전에 nprocnr_throttled를 본다. 앞의 것은 한도가 하드웨어와 맞는지, 뒤의 것은 그 한도가 실제로 막고 있는지를 알려준다. 둘 다 1분이면 끝나고, 둘 다 통과해야 비로소 "서버가 작다"가 후보가 된다.

3.CPU를 풀면 메모리도 같이 내려갈 수 있다

CPU 한도와 메모리 한도는 별개 손잡이처럼 보인다. 그런데 작업이 겹쳐서 도는 시스템에서는 둘이 붙어 있다.

1.75코어 ──▶ 같은 작업 29.9s 평균 메모리 피크 5809MiB 3.50코어 ──▶ 같은 작업 16.6s 평균 메모리 피크 5315MiB 1.8배 빠름 494MiB 낮음

더 많은 CPU를 줬는데 메모리를 덜 쓴다. 이유는 단순하다 — 피크는 겹친 순간에만 생기고, 각 작업이 빨리 끝나면 겹치는 구간이 짧아진다. 앞 작업이 메모리를 놓은 뒤에 다음 작업이 들어오면 두 작업분이 동시에 잡히는 일이 없다.

실무적으로는 이렇게 쓸 수 있다. 메모리가 빠듯한데 CPU에 여유가 있다면, 메모리 한도를 올리기 전에 CPU 한도를 먼저 볼 만하다. 물론 반대도 성립한다 — CPU를 조이면 처리가 늘어지면서 겹침이 길어지고 메모리 피크가 올라간다.

부수적으로, 같은 상향이 단독 요청도 빠르게 만들었다. 웜 상태의 최대 크기 작업 하나가 7.59초로, 1.75코어 시절의 11.1~19.8초 대비 1.5~2.6배다. 단독 실행 시 메모리 피크는 3632MiB로 경합 시(5315MiB)보다 한참 낮다.

한도를 푼 뒤의 속도

Reflatten — 평평한 이미지를 편집 가능한 텍스트로

한 장 올려 보면 검출이 7.6초 근처다. 1.75코어에 묶여 있던 시절에는 같은 작업이 11~20초였다.

4.큐 깊이 같은 파생값은 무엇의 함수인가

동시 요청을 몇 개까지 받을지, 타임아웃을 몇 초로 둘지 같은 값을 정해야 할 때가 온다. 이런 파생값은 서비스 시간에서 역산되는데, 그 서비스 시간이 한도에 눌린 값이면 파생값도 같이 틀린다. 한도가 만든 진짜 손해가 여기 있었다.

동시 요청을 몇 개까지 받을지 정해야 한다고 하자. 이 값은 감으로 정하는 게 아니라 타임아웃에서 역산된다. 단, 그러려면 큐가 실제로 어떻게 동작하는지를 먼저 알아야 한다. 여기서 자주 어긋난다.

이 시스템의 큐는 동시 실행 수가 아니라 입장 제어였다. 워커가 하나라 작업은 직렬 처리되고, 타임아웃 시계는 제출 시점부터 돈다. 그래서 대기 시간이 타임아웃 예산을 갉아먹는다.

역산text
워커 1개 · 타임아웃 240s

k번째로 입장한 작업은 약 k x S 후에 끝난다
  ──▶  큐 깊이 x S_worst <= 240s

# 넘기면 즉시 429보다 나쁘다 — 브라우저를 4분 붙잡고 실패한다

직렬이라는 전제는 실측으로 확인할 수 있다. 같은 요청 5건을 동시에 던지고 개별 처리 시간과 클라이언트가 본 시간을 따로 보면 된다. 개별 시간은 6초 근처로 일정한데 클라이언트 시간만 9.05 / 14.05 / 20.38 / 25.87 / 32.41초로 계단처럼 쌓였다. 겹쳐 돈 것이 아니라 줄을 선 것이다.

여기까지는 산수다. 그런데 식에 넣은 S_worstCPU 한도를 고치기 전 값이었다.

S_worst깊이 8일 때 최악 대기240s 대비당시 판단
31.4s (한도가 막던 시절)251s초과깊이를 6으로 내림
16.9s (한도를 푼 뒤)135s105s 남음깊이 8이 성립

기본값이던 8로 돌아왔지만 근거는 완전히 달라졌고, 중간의 6은 틀린 전제 위에서 정확하게 계산한 값이었다. 파생값이 예상보다 빡빡하게 나오면, 그 값을 받아들이기 전에 입력을 의심하는 편이 낫다. 여기서는 입력이 서비스 시간이었고, 서비스 시간은 자원 한도의 함수였다.

워커를 늘릴지 판별하는 기준 하나

"코어를 더 줬으니 워커도 늘리면 되지 않나"는 자연스러운 다음 수다. 판별 기준은 하나다 — 작업 하나가 이미 한도를 혼자 다 쓰는가. 배경 작업 없이 하나만 넣고 CPU를 샘플링했더니 +2초에 3.40코어, +4초에 3.32코어로 혼자서 한도에 붙어 스로틀당했다. 여유가 없으므로 워커 2는 같은 한도를 반씩 나눌 뿐이다 — 처리량은 그대로이고 각 작업만 느려지며, 두 작업의 작업 세트가 겹쳐 메모리 압박만 늘어난다.

5.이 결론이 안 통하는 조건

한도 상향이 답이 되려면 남는 코어가 실제로 있어야 한다. 여기서는 문서와 실물이 달라서 1.25코어가 놀고 있었을 뿐, 이미 코어를 다 쓰고 있다면 같은 조치가 아무것도 바꾸지 않는다. 그때는 설정이 아니라 사양이나 알고리즘의 문제다.

그리고 고친 뒤에도 편한 상태는 아니다.

선택비용그래도 이 쪽인 이유
한도 합이 코어 수를 넘음
(3.5 + 0.75 + 0.25 = 4.5 > 4)
셋이 동시에 최대로 요구하면 서로 느려진다 한도는 예약이 아니라 상한이라 설정만으로는 아무 일도 없다. 메모리 초과는 커널이 프로세스를 죽이지만 CPU 초과는 스케줄러가 시간을 나눠 각자 조금씩 느려질 뿐이다. 성격이 다르므로 다르게 다뤄도 된다
3.5코어에서도 스로틀이 남음 작업은 더 원한다 줄 곳이 없다. 여기서부터는 설정이 아니라 사양의 문제다
웹·프록시 몫을 예약하지 않음 이론적으로 굶을 수 있다 Docker cpus:는 상한만 설정하고 가중치는 건드리지 않아 모든 컨테이너가 동등하다. 웹이 CPU를 원하면 자기 상한까지는 밀리지 않는다

실측으로도 확인했다. 무거운 작업이 도는 동안 웹 페이지와 헬스체크를 초당 2회씩 찔러 전후를 비교했더니 p50·p95가 노이즈 안이었다(1202→1186ms, 실패 0). 다만 정확한 해석은 "부하 중에도 빠르다"가 아니라 "웹이 애초에 CPU를 거의 쓰지 않아 경쟁이 생기지 않는다"이다 — 실측 유휴 CPU가 거의 0이고 메모리도 한도의 16%만 쓴다. 경쟁을 걱정하기 전에 각 컨테이너가 실제로 CPU를 원하는지부터 재 보는 것이 순서다.

6.정리

느린 원인을 좁힐 때 쓸 수 있는 순서로 묶으면 이렇다.

  1. nproc과 한도 설정을 나란히 놓는다. 합이 코어 수보다 한참 작으면 남는 코어가 놀고 있다. 1분이면 끝난다.
  2. nr_throttled를 본다. 주기가 100ms이므로 초당 10에 가까우면 거의 모든 주기에서 정지당한 것이다. 0이면 한도는 결백하다.
  3. 둘 다 통과해야 "서버가 작다"가 후보가 된다. 그 전에 사양을 올리면 돈만 든다.
  4. 메모리가 빠듯한데 CPU에 여유가 있으면 CPU부터 본다. 작업이 빨리 끝나면 겹침이 짧아져 메모리 피크도 같이 내려간다.
  5. 워커를 늘릴지는 "작업 하나가 한도를 혼자 다 쓰는가"로 판별한다. 이미 붙어 있으면 워커를 늘려도 같은 한도를 나눌 뿐이다.
  6. 파생값이 답답하면 값이 아니라 입력을 의심한다. 큐 깊이는 서비스 시간의 함수이고, 서비스 시간은 자원 한도의 함수다.

이 중 처음 두 개는 명령 한 줄이다. 그런데 이 프로젝트에서는 그걸 치기 전에 "2 vCPU가 포화됐다"는 결론을 먼저 내렸고, 그 위에서 큐 깊이까지 조정했다. 틀린 전제 위에서는 계산이 정확할수록 더 그럴듯하게 틀린다.

이미지 편집 엔진 · 성능 측정

반응형

댓글