4코어 서버가 2.75코어만 쓰고 있었다: 컨테이너 CPU 한도와 스로틀 읽기
- 처리가 느릴 때 후보는 셋이다 — 코드가 느리거나, 서버가 작거나, 한도가 막고 있거나. 밖에서 응답 시간만 보면 셋이 똑같이 보인다.
- 구분하는 값은 커널이 세어 준다. 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를 쓰는 리눅스라면 컨테이너 안에서 그냥 읽힌다 — 별도 도구도, 호스트 접근도 필요 없다.
$ 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이 아니면 한도가 실제로 작업을 막고 있다.
그리고 이 값을 보기 전에 확인할 것이 하나 더 있다. 한도가 실제 하드웨어와 맞는지다.
$ 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코어 |
|---|---|---|
| 부하 중 실사용 CPU | 1.75~1.80 (한도 밀착) | 3.50~3.56 (한도 밀착) |
nr_throttled | 초당 ~10회 | 초당 ~10회 |
throttled_usec | 2초당 ~4,400ms | 2초당 ~900ms |
| 같은 작업 소요 | 27.8 / 32.1s | 16.9 / 16.2s |
| 메모리 피크 | 5809MiB | 5315MiB |
사양을 올리기 전에 nproc과
nr_throttled를 본다. 앞의 것은 한도가 하드웨어와 맞는지,
뒤의 것은 그 한도가 실제로 막고 있는지를 알려준다. 둘 다 1분이면 끝나고, 둘 다 통과해야
비로소 "서버가 작다"가 후보가 된다.
3.CPU를 풀면 메모리도 같이 내려갈 수 있다
CPU 한도와 메모리 한도는 별개 손잡이처럼 보인다. 그런데 작업이 겹쳐서 도는 시스템에서는 둘이 붙어 있다.
더 많은 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.큐 깊이 같은 파생값은 무엇의 함수인가
동시 요청을 몇 개까지 받을지, 타임아웃을 몇 초로 둘지 같은 값을 정해야 할 때가 온다. 이런 파생값은 서비스 시간에서 역산되는데, 그 서비스 시간이 한도에 눌린 값이면 파생값도 같이 틀린다. 한도가 만든 진짜 손해가 여기 있었다.
동시 요청을 몇 개까지 받을지 정해야 한다고 하자. 이 값은 감으로 정하는 게 아니라 타임아웃에서 역산된다. 단, 그러려면 큐가 실제로 어떻게 동작하는지를 먼저 알아야 한다. 여기서 자주 어긋난다.
이 시스템의 큐는 동시 실행 수가 아니라 입장 제어였다. 워커가 하나라 작업은 직렬 처리되고, 타임아웃 시계는 제출 시점부터 돈다. 그래서 대기 시간이 타임아웃 예산을 갉아먹는다.
워커 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_worst가
CPU 한도를 고치기 전 값이었다.
| S_worst | 깊이 8일 때 최악 대기 | 240s 대비 | 당시 판단 |
|---|---|---|---|
| 31.4s (한도가 막던 시절) | 251s | 초과 | 깊이를 6으로 내림 |
| 16.9s (한도를 푼 뒤) | 135s | 105s 남음 | 깊이 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.정리
느린 원인을 좁힐 때 쓸 수 있는 순서로 묶으면 이렇다.
nproc과 한도 설정을 나란히 놓는다. 합이 코어 수보다 한참 작으면 남는 코어가 놀고 있다. 1분이면 끝난다.nr_throttled를 본다. 주기가 100ms이므로 초당 10에 가까우면 거의 모든 주기에서 정지당한 것이다. 0이면 한도는 결백하다.- 둘 다 통과해야 "서버가 작다"가 후보가 된다. 그 전에 사양을 올리면 돈만 든다.
- 메모리가 빠듯한데 CPU에 여유가 있으면 CPU부터 본다. 작업이 빨리 끝나면 겹침이 짧아져 메모리 피크도 같이 내려간다.
- 워커를 늘릴지는 "작업 하나가 한도를 혼자 다 쓰는가"로 판별한다. 이미 붙어 있으면 워커를 늘려도 같은 한도를 나눌 뿐이다.
- 파생값이 답답하면 값이 아니라 입력을 의심한다. 큐 깊이는 서비스 시간의 함수이고, 서비스 시간은 자원 한도의 함수다.
이 중 처음 두 개는 명령 한 줄이다. 그런데 이 프로젝트에서는 그걸 치기 전에 "2 vCPU가 포화됐다"는 결론을 먼저 내렸고, 그 위에서 큐 깊이까지 조정했다. 틀린 전제 위에서는 계산이 정확할수록 더 그럴듯하게 틀린다.
'프로그래밍' 카테고리의 다른 글
| 서버 한 대로 무중단 배포하기: Caddy 헬스체크가 못 막는 502 (0) | 2026.08.24 |
|---|---|
| Docker 메모리 한도를 감으로 정하지 않는 법: 최악 입력과 RSS 기울기 (0) | 2026.08.24 |
| CI가 초록인데 라이브가 깨졌다: 배포 검증을 어디서 끝낼 것인가 (0) | 2026.08.24 |
| GitHub Actions 러너로 VPS 사양 재는 법: 36% 편차와 측정 함정 (0) | 2026.08.09 |
| 병목은 OCR이 아니었다: 요청의 53%를 쓰던 폰트 추론 2.2배 줄이기 (0) | 2026.08.09 |
댓글