Pod 사망 사건 - (1) 사인은 I/O Bound
부하테스트중 Pod 재시작원인 찾기

backend developer interested in technical problem
NAME READY STATUS RESTARTS AGE
...
sqm-receiver-6bb8858fc7-ctp2h 1/1 Running 5 (55m ago) 59m
...
해당 Pod이외에도 여러 Pod에서 재시작이 발생하고 있었습니다.
k8s로 배포한 프로젝트에서 API서버의 부하 테스트를 진행하던 중 Pod가 간헐적으로 재시작되는 문제가 발생했습니다. kubectl get pods 명령어로 확인해보니 RESTARTS 횟수가 계속 증가하고 있었고, Exit Code는 143이었습니다.
부하테스트에서 발생한 문제니 단순 스케일 업혹은 스케일아웃을 하면 해결될거라는 안일한 생각도 했지만.. 정확한 원인을 파악하고 이에 맞는 해결책을 생각해야합니다. 문제의 원인을 어떻게 파악했고 어떻게 해결했는지 정리해보겠습니다.
단서모으기 : Pod는 어떻게 죽었나
왜 종료됐는지를 찾은 후 당시의 이벤트로그,메트릭을 살펴보았습니다.

우선 왜 종료됐는지 확인했습니다. Reason에 143이 있는걸 확인했습니다.
143 Exit Code
Exit Code 143은 시그널 체계를 이해하면 의미를 파악할 수 있습니다.
Exit Code 143 = 128 + 15 = 128 + SIGTERM
| 시그널 | 번호 | 의미 |
| SIGTERM | 15 | 정상 종료 요청 (graceful shutdown) |
| SIGKILL | 9 | 강제 종료 (즉시 kill) |
Kubernetes는 Pod를 종료할 때 먼저 SIGTERM을 보내고 gracePeriodSeconds(기본 30초) 후에도 종료되지 않으면 SIGKILL을 보냅니다.
즉, Exit Code 143은 "Kubernetes가 이 Pod를 의도적으로 종료했다"는 의미입니다. 그렇다면 왜 종료했을까요? k8s event로그를 찾아봤습니다.
Event Log
bts@Kennen:~/k8s-sqm$ kubectl get events -n sqm-api-namespace --sort-by=.metadata.creationTimestamp
LAST SEEN TYPE REASON OBJECT MESSAGE
8m24s Warning Unhealthy pod/sqm-receiver-6bb8858fc7-xrn8x Readiness probe failed: Get "http://172.30.12.117:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
4m23s Warning Unhealthy pod/sqm-receiver-6bb8858fc7-ctp2h Readiness probe failed: Get "http://172.30.6.212:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
9m23s Warning Unhealthy pod/sqm-receiver-6bb8858fc7-ctp2h Liveness probe failed: Get "http://172.30.6.212:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
3m24s Warning Unhealthy pod/sqm-receiver-6bb8858fc7-xrn8x Liveness probe failed: Get "http://172.30.12.117:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
12m Warning Unhealthy pod/sqm-receiver-6bb8858fc7-h2hvg Liveness probe failed: Get "http://172.29.14.255:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
5m22s Warning Unhealthy pod/sqm-receiver-6bb8858fc7-h2hvg Readiness probe failed: Get "http://172.29.14.255:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
15m Warning Unhealthy pod/sqm-receiver-6bb8858fc7-5jkd5 Liveness probe failed: Get "http://172.29.4.161:10000/actuator/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
원인이 보이기 시작했습니다. Liveness Probe가 타임아웃되면서 Kubernetes가 Pod를 재시작한 것입니다. 하지만 이건 현상이지 근본 원인이 아닙니다. 왜 /actuator/health가 응답하지 못했을까요?
Metrics
우선 스레드 수의 메트릭입니다. 스레드 수가 부하를 준 시점부터 폭증한 뒤 probe가 실패해 pod가 종료되고 메트릭이 끊긴것을 볼 수 있습니다.

Load Average또한 치솟았는데요. 해당 Pod의 cpu resource limit은 1이였습니다.

흥미로운 점은 CPU Usage는 낮았다는 것입니다. CPU Usage 피크시 16% , Load Average 5.47
CPU는 거의 안 쓰는데 Load Average가 높기떄문에 이는 I/O Bound 문제라는 결론에 도달했습니다. 생각해보니 S3에 알고리즘 개발자의 디버깅용으로 데이터를 보내는 기능을 추가했던게 기억났습니다. S3 업로드처럼 네트워크 응답을 기다리는 스레드가 많으면 이런 현상이 발생합니다.

Thread Dump
부하 당시의 스레드 덤프는 없지만 평시의 스레드 덤프에서 이 패턴을 간접적으로나마 확인할 수 있었습니다. 상태는 RUNNABLE이지만 실제로는 Net.poll에서 S3 응답을 기다리고 있습니다. 정상 상태에서도 이런 I/O 대기가 발생하고 있었고, 부하가 증가하면 이 패턴이 반복되면서 스레드가 폭증한 것입니다.
...
"http-nio-10500-exec-768" daemon prio=5 java.lang.Thread.State: RUNNABLE at sun.nio.ch.Net.poll(Native Method) ... at software.amazon.awssdk.http.apache.ApacheHttpClient.execute(...) at software.amazon.awssdk.services.s3.DefaultS3Client.putObject(...) at com.bitsensing.sqm.receive.service.ReceiveServiceImpl.uploadToS3(ReceiveServiceImpl.java:129) at com.bitsensing.sqm.receive.service.ReceiveServiceImpl.processData(...)
...
장애 메커니즘

정리하자면 다음과 같은 흐름이였습니다.
부하 증가
S3 업로드 요청 증가 → 스레드가 I/O 응답 대기
새 요청마다 새 스레드 할당 → 스레드 폭증 (50 → 243)
Load Average 상승 (5.47) - CPU는 낮지만 I/O 대기 스레드 누적
/actuator/health응답 지연Liveness Probe 타임아웃 → Kubernetes가 SIGTERM → Exit Code 143
해결방안
원인을 파악했으니 해결책을 고민했습니다. 생각했던 해결책들을 하나씩 검토해 보았습니다.
워커노드 Scale-up
가장 단순한 방법은 인스턴스 스펙을 올리는 것입니다. 현재 t3.large(2 vCPU, 8GB)에서 t3.xlarge(4 vCPU, 16GB)로 변경하면 여유가 생깁니다.
하지만 이번 문제는 CPU 부족이 아니라 I/O 대기로 인한 스레드 폭증이었습니다. CPU를 늘려도 네트워크 응답을 기다리는 스레드 수는 줄어들지 않습니다. 비용만 증가하고 근본적인 해결이 안 되기 때문에 제외했습니다.
스레드 풀 제한
Tomcat의 스레드 풀 최대값을 제한하는 방법입니다.
yaml
server:
tomcat:
threads:
max: 100 # 기본값 200
스레드 수를 제한하면 리소스 사용량을 예측할 수 있고, 폭증으로 인한 장애를 방지할 수 있습니다. 하지만 제한을 초과하는 요청은 대기하거나 거부됩니다. 트래픽이 증가하면 처리량이 제한되어 서비스 품질이 떨어질 수 있습니다.
Probe 설정 완화
Liveness Probe의 타임아웃과 실패 임계값을 늘리는 방법입니다.
yaml
livenessProbe:
httpGet:
path: /actuator/health
port: 10000
timeoutSeconds: 10 # 기본 1초 → 10초
failureThreshold: 5 # 기본 3회 → 5회
즉시 적용할 수 있고 Pod 재시작은 줄어들겠지만 이건 임시방편입니다. 실제 장애가 발생했을 때 감지가 늦어지는 부작용이 있습니다. 증상을 숨기는 것이지 원인을 해결하는 게 아닙니다.
S3 업로드 비동기 처리
현재 S3 업로드는 동기 방식으로 처리됩니다. 요청 스레드가 S3 응답을 받을 때까지 점유되는 구조입니다. 이를 비동기로 전환하면 스레드 점유 시간을 줄일 수 있습니다.
java
// 비동기 전환 예시
CompletableFuture.runAsync(() -> uploadToS3(data), s3Executor);
하지만 비동기가 만능은 아닙니다. 요청 속도가 처리 속도를 초과하면 작업이 큐에 쌓이는 Backpressure 문제가 발생할 수 있습니다. 코드 수정과 함께 스레드 풀 설계, 예외 처리 등 고려할 사항이 많아 이번에는 보류했습니다.
오토스케일러 적용
부하에 따라 Pod 수를 자동으로 조절하는 오토스케일러를 적용하는 방법입니다. 코드 수정 없이 인프라 설정만으로 적용할 수 있어 이번 케이스에 가장 적절하다고 판단했습니다.
HPA의 한계
CKA시험 당시 HPA관련 문제가 나왔던 게 기억이 나네요..
Kubernetes의 기본 오토스케일러인 HPA(Horizontal Pod Autoscaler)는 CPU, Memory 사용률 기반으로 Pod 수를 조절합니다.
# 일반적인 CPU 기반 HPA
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
하지만 이번 케이스에서는 CPU Usage가 낮았습니다. I/O Bound 워크로드에서는 CPU를 거의 사용하지 않기 때문에 CPU 기반 HPA는 트리거되지 않습니다. Memory 역시 마찬가지입니다.
Custom Metrics 기반 스케일링
이런 경우에는 스레드 수나 요청 수 같은 Custom Metrics를 기준으로 스케일링해야 합니다. 찾아보니 Kubernetes에서 Custom Metrics 기반 스케일링을 구현하는 방법은 크게 두 가지였습니다.
| 방식 | 설명 |
| HPA + Prometheus Adapter | 기존 HPA에 Adapter를 붙여 Custom Metrics 사용 |
| KEDA | 다양한 메트릭 소스를 지원하는 이벤트 기반 오토스케일러 |
KEDA가 설정이 더 간단하고 Prometheus 외에도 다양한 소스(SQS, Kafka 등)를 지원해 확장성이 좋습니다. KEDA를 활용한 스레드 수 기반 오토스케일링 구현은 다음 글에서 다뤄보겠습니다.



