문제가 생겼을 때 가장 먼저 해야 할 일
운영자는 종종 탐정과 비슷한 일을 한다. 서비스가 느려졌다는 제보가 들어오고, 사용자는 "아까부터 답변이 안 나와요"라고 말한다. 그런데 클러스터 안에서는 수십 개의 Pod가 뜨고 지고, 모델 서버는 GPU 메모리를 가득 채우고 있으며, 오토스케일러는 방금 노드를 하나 더 붙이려다 실패했을 수도 있다. 이때 가장 위험한 습관은 "아마 메모리 문제겠지", "GPU가 부족한가 보다"처럼 추측으로 바로 수정에 들어가는 것이다.
쿠버네티스 운영에서 좋은 디버깅은 추측을 줄이는 과정이다. "무엇이 일어났는가"를 먼저 확인하고, "언제부터 일어났는가"를 좁힌 뒤, "어떤 상태 변화와 함께 일어났는가"를 찾는다. 쿠버네티스는 생각보다 많은 증거를 남긴다. Pod의 상태, 컨테이너 종료 코드, 이벤트, 스케줄러의 실패 메시지, kubelet이 남긴 재시작 기록, 컨테이너 로그, 노드의 리소스 압박 상태가 모두 단서다.
AI 워크로드에서는 이 습관이 더 중요하다. 일반적인 웹 API는 CPU 사용률과 HTTP 지연 시간만 봐도 큰 흐름을 파악할 수 있다. 하지만 LLM 추론 서버는 CPU가 한가해 보여도 GPU VRAM이 꽉 차 있거나, 요청 큐가 길어져 첫 토큰이 늦게 나올 수 있다. 학습 Job은 애플리케이션 로그상으로는 정상처럼 보여도 NCCL 통신, 스토리지 I/O, GPU 온도, 특정 노드의 ECC 오류 때문에 전체 작업이 멈출 수 있다. 그래서 AI 시대의 쿠버네티스 운영자는 kubectl logs만 보는 사람이 아니라, 메트릭·로그·트레이스·이벤트를 함께 읽는 사람이 되어야 한다.
이번 장의 목표는 화려한 도구 이름을 많이 외우는 것이 아니다. 최소한의 관찰 가능성 스택을 만들고, 문제가 생겼을 때 항상 같은 순서로 증거를 확인하는 습관을 익히는 것이다. 나중에 AI 기반 SRE 도구나 자동 원인 분석 도구를 붙이더라도, 그 도구들이 결국 읽는 원천 데이터는 메트릭, 로그, 트레이스, 이벤트다. 원천 데이터가 부실하면 AI도 그럴듯한 추측만 내놓는다.

모니터링과 관찰 가능성은 다르다
모니터링은 대개 "정해진 지표를 보고 있다가 임계값을 넘으면 알림을 보내는 것"에 가깝다. CPU가 90%를 넘었는지, Pod가 죽었는지, HTTP 500이 늘었는지를 감시한다. 반면 관찰 가능성, 즉 옵저버빌리티는 알 수 없는 문제가 생겼을 때 시스템 내부 상태를 외부 신호로 추론할 수 있는 능력이다.
예를 들어 LLM API의 p95 응답 시간이 12초까지 올랐다고 하자. 모니터링은 "응답 시간이 느리다"는 사실을 알려준다. 관찰 가능성은 여기서 한 걸음 더 들어간다. 요청이 큐에서 오래 기다렸는지, prefill 단계가 느렸는지, decode 중 토큰 생성 속도가 떨어졌는지, 벡터 DB 검색이 지연됐는지, 특정 GPU 노드에서만 문제가 발생했는지까지 추적할 수 있어야 한다.
전통적으로 관찰 가능성은 세 가지 신호로 설명한다. 메트릭은 시간에 따라 변하는 숫자다. CPU 사용률, 메모리 사용량, 요청 수, 오류율, GPU 사용률처럼 대시보드와 알림에 적합하다. 로그는 특정 시점에 애플리케이션이나 시스템이 남긴 문장형 기록이다. 왜 실패했는지, 어떤 입력을 처리하고 있었는지, 어떤 예외가 발생했는지 확인할 때 필요하다. 트레이스는 하나의 요청이 여러 서비스와 컴포넌트를 지나가는 경로를 기록한다. API 서버, 벡터 DB, 모델 서버, 외부 도구 호출이 연결된 에이전트 시스템에서는 트레이스가 없으면 지연의 위치를 찾기 어렵다.
여기에 쿠버네티스 이벤트를 반드시 더해야 한다. 이벤트는 쿠버네티스가 남기는 상태 변화 기록이다. 이미지 풀 실패, 스케줄링 실패, 볼륨 마운트 실패, 컨테이너 재시작, readiness probe 실패 같은 정보가 이벤트에 남는다. 운영 초보자는 로그부터 뒤지는 경우가 많지만, 쿠버네티스 장애의 상당수는 이벤트를 먼저 보면 훨씬 빨리 좁혀진다.
2026년의 관찰 가능성 스택은 단순히 세 신호를 각각 저장하는 수준을 넘어, 신호 사이의 연결을 중요하게 본다. 메트릭 알림에서 관련 트레이스로 이동하고, 트레이스 안의 span ID로 해당 요청의 로그를 바로 여는 식이다. OpenTelemetry Collector, Loki, Tempo, Mimir 같은 도구가 널리 쓰이는 이유도 각 신호에 공통 컨텍스트를 붙여 서로 연결하기 위해서다.1
최소 관찰 가능성 스택: Prometheus, Grafana, Loki, OpenTelemetry
처음부터 거대한 상용 관찰 가능성 플랫폼을 도입할 필요는 없다. 작은 AI 팀이라면 다음 네 가지 축만 갖춰도 운영 품질이 크게 올라간다. Prometheus는 메트릭을 수집하고 저장한다. Grafana는 대시보드와 알림을 제공한다. Loki는 컨테이너 로그를 중앙에서 모아 검색하게 해준다. OpenTelemetry는 애플리케이션에서 발생하는 트레이스, 메트릭, 로그를 표준 방식으로 수집하고 전달한다.
여기에 분산 트레이싱 저장소로 Tempo를 붙이면 Grafana 안에서 메트릭, 로그, 트레이스를 함께 탐색할 수 있다. 장기 메트릭 저장이 필요해지면 Mimir나 Thanos 같은 원격 저장소를 붙일 수 있지만, 처음부터 복잡하게 시작할 필요는 없다. 중요한 것은 "모든 문제가 생긴 뒤에야 급히 로그 수집기를 설치하는 상황"을 피하는 것이다.
가장 기본적인 구성은 다음 흐름으로 이해하면 된다.
Application Pods
├─ /metrics endpoint ───────▶ Prometheus ──▶ Grafana
├─ stdout/stderr logs ──────▶ Loki ────────▶ Grafana
└─ OTLP traces/metrics/logs ▶ OTel Collector ▶ Tempo/Loki/Prometheus
Kubernetes objects/events ───▶ kube-state-metrics / event exporter / kubectl
Node metrics ────────────────▶ node-exporter / metrics-server
GPU metrics ─────────────────▶ DCGM Exporter ─▶ Prometheusmetrics-server도 자주 등장한다. 이것은 kubectl top이나 HPA가 사용할 수 있는 CPU·메모리 사용량을 제공하는 기본 애드온에 가깝다. 하지만 장기 저장, 복잡한 쿼리, 대시보드, 알림에는 Prometheus가 필요하다. 즉 metrics-server는 "지금 얼마나 쓰고 있나"를 빠르게 보는 도구이고, Prometheus는 "지난 6시간 동안 어떤 추세였나", "배포 직후 p95 지연 시간이 얼마나 변했나", "GPU 메모리가 찬 시점과 오류율 증가가 겹치는가"를 분석하는 도구다.
실습용 클러스터라면 Helm으로 빠르게 시작할 수 있다. 실제 운영 환경에서는 스토리지, 보존 기간, 인증, 백업, 리소스 제한을 반드시 조정해야 하지만, 구조를 이해하기 위한 출발점은 다음과 같다.
kubectl create namespace monitoring
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm repo update
helm upgrade --install kube-prometheus-stack \
prometheus-community/kube-prometheus-stack \
-n monitoring
helm upgrade --install loki grafana/loki \
-n monitoring
helm upgrade --install tempo grafana/tempo \
-n monitoring
helm upgrade --install otel-collector \
open-telemetry/opentelemetry-collector \
-n monitoring이 명령이 완벽한 운영 구성을 만들어 주지는 않는다. 하지만 핵심 컴포넌트의 역할을 익히기에는 충분하다. 운영으로 가져갈 때는 Prometheus와 Loki의 저장소를 PVC나 오브젝트 스토리지에 연결하고, Grafana 접근을 사내 SSO나 OAuth와 묶고, 네임스페이스별 권한을 분리해야 한다.
Grafana와 함께 쓰는 Loki, Tempo, Mimir 조합은 흔히 LGTM 스택이라고 부른다. Loki는 로그, Grafana는 시각화, Tempo는 트레이스, Mimir는 장기 메트릭 저장을 담당한다. 이 조합이 인기 있는 이유는 각 도구가 쿠버네티스 환경에서 수평 확장과 오브젝트 스토리지 연동을 염두에 두고 설계되었고, Grafana에서 신호 간 이동이 자연스럽기 때문이다.2
이벤트부터 읽는 디버깅 루틴
장애가 났을 때 가장 먼저 할 일은 대시보드에 들어가는 것일 수도 있고, 알림 메시지를 보는 것일 수도 있다. 하지만 개별 Pod나 Deployment 문제로 좁혀졌다면 다음 순서를 거의 반사적으로 실행할 수 있어야 한다.
kubectl get pod -n llm-serving -o wide
kubectl describe pod -n llm-serving vllm-7b-6f9d8c7f9b-hx2pc
kubectl get events -n llm-serving --sort-by=.lastTimestamp
kubectl logs -n llm-serving vllm-7b-6f9d8c7f9b-hx2pc --tail=200
kubectl logs -n llm-serving vllm-7b-6f9d8c7f9b-hx2pc --previous --tail=200kubectl get pod -o wide는 Pod가 어느 노드에 배치되었는지 보여준다. AI 워크로드에서는 이 정보가 중요하다. 같은 모델 서버라도 A100 노드에서는 정상이고 T4 노드에서는 느릴 수 있다. 특정 노드에서만 ImagePull이 실패하거나, 특정 GPU 노드에서만 DCGM 오류가 발생할 수도 있다.
kubectl describe pod는 상태와 이벤트를 함께 보여준다. 여기서 봐야 할 것은 State, Last State, Reason, Exit Code, Events다. 컨테이너가 Waiting인지, Terminated인지, 마지막 종료 이유가 OOMKilled인지, readiness probe가 실패했는지, 스케줄러가 어떤 이유로 배치를 못 했는지 확인한다.
kubectl get events --sort-by=.lastTimestamp는 최근 사건부터 시간순으로 볼 때 유용하다. 이벤트는 시간이 지나면 사라지고, 클러스터 설정에 따라 보존 기간도 길지 않다. 장애가 발생한 직후에 이벤트를 저장해 두는 습관이 필요하다.
로그는 그다음이다. 현재 컨테이너 로그는 kubectl logs로 보고, 재시작 이전 컨테이너 로그는 --previous로 본다. CrashLoopBackOff에서는 현재 컨테이너가 막 시작되어 아직 의미 있는 로그를 남기지 않았을 수 있다. 진짜 단서는 직전 컨테이너 로그에 있는 경우가 많다.
여러 컨테이너가 한 Pod 안에 있다면 컨테이너 이름을 지정한다.
kubectl logs -n llm-serving vllm-7b-6f9d8c7f9b-hx2pc -c model-server --tail=200
kubectl logs -n llm-serving vllm-7b-6f9d8c7f9b-hx2pc -c sidecar --tail=200실시간 로그가 필요하면 -f를 붙인다.
kubectl logs -n llm-serving deploy/vllm-7b -f --tail=100여기서 중요한 것은 명령어를 많이 아는 것이 아니라 순서다. 상태를 보고, 이벤트를 보고, 로그를 본다. 그다음에 컨테이너 안으로 들어가 파일시스템과 네트워크를 확인한다. 마지막으로 일반 컨테이너에 도구가 없을 때 debug 컨테이너를 붙인다.
exec와 debug: 컨테이너 안으로 들어가기
컨테이너가 살아 있고 셸이 들어 있다면 kubectl exec가 가장 빠르다.
kubectl exec -n llm-serving -it vllm-7b-6f9d8c7f9b-hx2pc -- sh들어가서 확인할 수 있는 것은 많다. 모델 파일이 실제로 마운트되었는지, 환경 변수가 맞는지, 서비스 DNS가 해석되는지, 로컬 포트가 열려 있는지, /tmp나 캐시 디렉터리에 쓰기 권한이 있는지 확인할 수 있다.
printenv | sort
ls -lh /models
ls -lh /cache
cat /etc/resolv.conf
wget -qO- http://127.0.0.1:8000/health
wget -qO- http://vector-db.vector.svc.cluster.local:8080/health문제는 운영 컨테이너 이미지가 점점 더 작아지고 있다는 점이다. 보안을 위해 curl, wget, ps, netstat, nslookup 같은 도구가 없는 이미지가 많다. 이때 사용하는 것이 kubectl debug와 ephemeral container다. ephemeral container는 이미 실행 중인 Pod에 임시 디버깅 컨테이너를 붙이는 기능이다. 원래 컨테이너를 재시작하지 않고도 네트워크 네임스페이스를 공유하며 조사할 수 있다.
kubectl debug -n llm-serving -it vllm-7b-6f9d8c7f9b-hx2pc \
--image=nicolaka/netshoot \
--target=model-server \
-- bashnetshoot 이미지는 네트워크 디버깅 도구가 많이 들어 있어 DNS, TCP 연결, TLS, 라우팅 문제를 볼 때 편하다. --target은 어떤 컨테이너의 프로세스 네임스페이스를 대상으로 할지 지정한다. 클러스터 정책에 따라 프로세스 네임스페이스 공유가 제한될 수 있으므로, 보안 정책과 RBAC 권한도 함께 확인해야 한다.
Pod 자체가 뜨지 않거나 노드 수준 문제를 봐야 할 때는 노드 디버깅을 사용할 수 있다.
kubectl debug node/gpu-node-01 -it --image=ubuntu -- bash이 방식은 노드의 호스트 네임스페이스에 접근하는 디버깅 Pod를 만들고, 호스트 파일시스템을 /host 아래에 마운트한다. GPU 노드에서 컨테이너 런타임 설정, kubelet 로그, NVIDIA 드라이버 파일, 디바이스 파일을 확인해야 할 때 강력하다. 다만 그만큼 위험하다. 운영 클러스터에서는 아무에게나 허용해서는 안 되고, 별도 관리자 권한과 감사 로그를 붙여야 한다. kubectl debug는 실행 중인 Pod에 ephemeral container를 추가하거나, 기존 Pod의 복사본을 만들거나, 노드 디버깅 세션을 만드는 등 여러 작업을 지원한다.3
OOMKilled: 메모리가 죽였는가, 모델이 죽였는가
OOMKilled는 가장 흔하면서도 가장 오해하기 쉬운 장애다. 이름 그대로 메모리 부족으로 컨테이너가 종료된 상태다. 하지만 여기서 말하는 메모리는 일반적으로 컨테이너의 시스템 메모리다. GPU VRAM 부족으로 CUDA Out of Memory가 발생한 경우에는 애플리케이션 로그에 예외가 남고 프로세스가 종료될 수 있지만, 쿠버네티스의 Reason이 항상 OOMKilled로 표시되는 것은 아니다.
먼저 상태를 확인한다.
kubectl describe pod -n llm-serving vllm-7b-xxx출력에서 다음과 비슷한 부분을 찾는다.
Last State: Terminated
Reason: OOMKilled
Exit Code: 137Exit Code 137은 프로세스가 SIGKILL로 종료되었음을 뜻하는 대표적인 신호다. 그다음에는 컨테이너의 메모리 limit를 확인한다.
kubectl get pod -n llm-serving vllm-7b-xxx -o jsonpath='{.spec.containers[*].resources}' | jqLLM 서버는 시작 시점에 모델 가중치, 토크나이저, KV 캐시 관련 구조, CUDA 컨텍스트, Python 런타임 메모리를 한꺼번에 올린다. 이전 장에서 이야기한 readiness와 startup probe가 중요한 이유도 여기에 있다. 모델 로딩 중 메모리 사용량이 순간적으로 치솟는데, startup probe가 너무 짧거나 메모리 limit가 낮으면 정상적인 워밍업 과정이 장애처럼 보일 수 있다.
OOMKilled를 보면 다음 질문을 순서대로 던진다. 컨테이너 memory limit가 모델 크기와 런타임에 비해 너무 낮은가? 요청이 몰린 시점에 KV 캐시나 배치 크기가 늘어났는가? 로그에 CUDA OOM과 시스템 OOM이 함께 보이는가? 최근 배포에서 기본 max sequence length나 max batch tokens가 커졌는가? 노드 전체에 MemoryPressure가 걸렸는가?
Prometheus에서는 Pod 메모리 사용량과 재시작 횟수를 함께 본다.
container_memory_working_set_bytes{namespace="llm-serving", pod=~"vllm-7b.*"}
increase(kube_pod_container_status_restarts_total{namespace="llm-serving", pod=~"vllm-7b.*"}[15m])해결책은 단순히 limit를 올리는 것만이 아니다. 모델 양자화, 동시 요청 수 제한, max model length 조정, KV 캐시 정책 변경, 더 큰 메모리 노드 배치, Pod 분산 배치가 함께 검토되어야 한다. AI 워크로드에서 메모리 문제는 "얼마나 주느냐"뿐 아니라 "얼마나 동시에 처리하게 하느냐"의 문제다.
CrashLoopBackOff: 계속 죽는 컨테이너 읽기
CrashLoopBackOff는 컨테이너가 시작했다가 죽고, 쿠버네티스가 다시 시작하고, 다시 죽는 상태다. BackOff는 재시작 사이의 대기 시간이 점점 늘어난다는 뜻이다. 이 상태에서 현재 로그만 보면 놓치는 경우가 많으므로 --previous가 필수다.
kubectl logs -n llm-serving vllm-7b-xxx --previous --tail=200
kubectl describe pod -n llm-serving vllm-7b-xxx원인은 다양하다. 명령어 또는 인자가 잘못되었을 수 있다. ConfigMap이나 Secret이 빠졌을 수 있다. 모델 경로가 틀렸거나 PVC가 비어 있을 수 있다. 애플리케이션은 정상인데 liveness probe가 너무 공격적으로 설정되어 모델 로딩 중인 프로세스를 죽이고 있을 수도 있다.
AI 모델 서버에서 자주 보는 패턴은 "서버는 뜨는 중인데 probe가 먼저 실패한다"는 상황이다. 70GB 모델을 로딩하는 데 2분이 걸리는데 liveness probe가 30초 후부터 /health를 때리고, 3번 실패했다고 컨테이너를 죽이면 서버는 영원히 준비될 수 없다. 이런 경우에는 liveness probe보다 startup probe를 먼저 설정한다.
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet:
path: /ready
port: 8000
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 300
periodSeconds: 30startup probe는 "처음 뜨는 데 오래 걸리는 애플리케이션"을 위한 안전장치다. 모델 로딩, 캐시 예열, CUDA 컨텍스트 초기화가 끝나기 전에 liveness probe가 프로세스를 죽이지 않도록 막아준다. readiness probe는 트래픽을 받을 준비가 되었는지를 판단한다. 모델 파일이 로딩되었고, 워밍업 요청을 한 번 처리했으며, 필요한 외부 의존성에 연결되었을 때만 ready가 되어야 한다.
ImagePullBackOff: 이미지를 못 가져오는 이유
ImagePullBackOff는 컨테이너 이미지 다운로드에 실패했을 때 나타난다. GPU나 LLM과 무관해 보이지만 AI 팀에서 매우 자주 겪는다. 모델 서버 이미지는 크고, CUDA 기반 이미지는 수 GB가 넘으며, 사설 레지스트리를 쓰는 경우가 많기 때문이다.
먼저 이벤트를 본다.
kubectl describe pod -n llm-serving vllm-7b-xxx이벤트에 다음과 같은 메시지가 나온다.
Failed to pull image "registry.example.com/llm/vllm:prod": unauthorized
Failed to pull image "...": not found
Failed to pull image "...": no matching manifest for linux/amd64unauthorized라면 imagePullSecrets 문제일 가능성이 높다. not found라면 태그가 틀렸거나 이미지가 아직 푸시되지 않았을 수 있다. no matching manifest는 아키텍처가 맞지 않는 이미지일 때 나온다. Apple Silicon에서 빌드한 ARM 이미지를 amd64 GPU 노드에서 당겨오려 할 때 이런 문제가 생긴다.
사설 레지스트리 인증은 다음처럼 Secret을 만들고 ServiceAccount나 Pod에 연결한다.
kubectl create secret docker-registry regcred \
-n llm-serving \
--docker-server=registry.example.com \
--docker-username=YOUR_USER \
--docker-password=YOUR_PASSWORDspec:
imagePullSecrets:
- name: regcredAI 워크로드에서는 이미지 크기 자체도 운영 이슈다. 10GB가 넘는 이미지가 매번 새 노드에서 pull되면 콜드스타트가 길어진다. 모델 가중치를 이미지에 넣을지, PVC나 오브젝트 스토리지에서 받을지, 노드 로컬 캐시를 둘지 설계해야 한다. ImagePullBackOff는 단순한 인증 오류일 수도 있지만, 더 넓게 보면 배포 아키텍처의 신호일 수 있다.
Pending: 스케줄러가 말하는 배치 실패
Pending은 Pod가 아직 노드에 배치되지 못했다는 뜻이다. 컨테이너가 죽은 것이 아니라 시작조차 못 한 상태다. 이때는 로그가 없다. 로그가 없다고 당황하지 말고 이벤트를 보면 된다.
kubectl describe pod -n llm-serving vllm-70b-xxx대표 메시지는 다음과 같다.
0/5 nodes are available: 3 Insufficient nvidia.com/gpu, 2 node(s) had untolerated taint
0/5 nodes are available: 5 Insufficient memory
0/5 nodes are available: 1 node(s) didn't match Pod's node affinity/selector
pod has unbound immediate PersistentVolumeClaims
exceeded quota: gpu-quota이 메시지는 스케줄러의 설명이다. 운영자는 이 설명을 그대로 믿고 다음 증거로 넘어가면 된다. Insufficient nvidia.com/gpu는 요청한 GPU가 부족하다는 뜻이다. GPU가 물리적으로 없을 수도 있고, 이미 다른 Pod가 점유하고 있을 수도 있다. untolerated taint는 GPU 노드에 taint가 있는데 Pod에 toleration이 없다는 뜻이다. node affinity 문제는 특정 GPU 모델이나 노드 라벨을 요구했는데 맞는 노드가 없다는 뜻이다. PVC가 바인딩되지 않았으면 스토리지 문제가 먼저다. Quota 초과라면 네임스페이스별 GPU 제한에 걸린 것이다.
GPU Pod라면 리소스 요청을 확인한다.
resources:
limits:
nvidia.com/gpu: 1NVIDIA GPU는 전통적인 extended resource로 노출될 때 limits에 지정해야 한다. CPU처럼 requests만 적고 기대하면 스케줄링이 되지 않는다. 또한 GPU는 기본적으로 정수 단위로 요청한다. fractional GPU, time-slicing, MIG 같은 공유 방식은 뒤의 GPU 스케줄링 장에서 자세히 다루겠지만, 기본 모델에서는 nvidia.com/gpu: 0.5 같은 요청은 통하지 않는다고 생각하는 편이 안전하다.
노드의 GPU 자원은 다음 명령으로 확인한다.
kubectl describe node gpu-node-01 | grep -A8 -E "Capacity|Allocatable"
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"t"}{.status.allocatable.nvidia.com/gpu}{"n"}{end}'Pending은 실패가 아니다. 스케줄러가 "이 조건으로는 배치할 수 없다"고 알려주는 상태다. 이 메시지를 잘 읽으면 불필요한 로그 검색을 줄일 수 있다.
GPU 워크로드는 GPU 메트릭을 따로 봐야 한다
CPU와 메모리 대시보드만으로 GPU 워크로드를 운영하는 것은 자동차 계기판에서 속도계를 떼고 운전하는 것과 비슷하다. LLM 서버의 병목은 CPU가 아니라 GPU 연산, VRAM, KV 캐시, PCIe 전송, 모델 로딩 I/O에서 발생하는 경우가 많다. 그래서 GPU 노드에는 DCGM Exporter를 설치해 Prometheus 형식으로 GPU 메트릭을 수집해야 한다.
DCGM은 NVIDIA Data Center GPU Manager의 약자다. DCGM Exporter는 GPU 사용률, 메모리 사용량, 온도, 전력, 오류 정보를 /metrics 엔드포인트로 노출한다. GPU Operator를 설치하면 DCGM Exporter가 함께 배포되는 경우가 많고, 별도 DaemonSet으로도 설치할 수 있다. DCGM Exporter는 DCGM_FI_DEV_GPU_UTIL, DCGM_FI_DEV_FB_USED, DCGM_FI_DEV_GPU_TEMP, DCGM_FI_DEV_POWER_USAGE, DCGM_FI_DEV_XID_ERRORS 같은 지표를 Prometheus가 수집할 수 있게 제공한다.4
간단한 DaemonSet 형태는 다음과 같다.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: dcgm-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: dcgm-exporter
spec:
selector:
matchLabels:
app.kubernetes.io/name: dcgm-exporter
template:
metadata:
labels:
app.kubernetes.io/name: dcgm-exporter
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9400"
prometheus.io/path: "/metrics"
spec:
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- name: dcgm-exporter
image: nvcr.io/nvidia/k8s/dcgm-exporter:4.2.3-4.1.3-ubuntu22.04
ports:
- name: metrics
containerPort: 9400
securityContext:
runAsUser: 0
capabilities:
add: ["SYS_ADMIN"]
volumeMounts:
- name: pod-gpu-resources
readOnly: true
mountPath: /var/lib/kubelet/pod-resources
volumes:
- name: pod-gpu-resources
hostPath:
path: /var/lib/kubelet/pod-resources운영 대시보드에서는 GPU 메트릭을 따로 보지 말고, 노드·Pod·모델 서버 메트릭과 함께 봐야 한다. 예를 들어 GPU 사용률은 95%인데 tokens/sec가 떨어진다면 배치 구성이나 메모리 대역폭을 의심할 수 있다. GPU 사용률은 낮은데 요청 큐가 길다면 CPU 토크나이징, 라우팅, 동시성 제한, 외부 의존성 문제가 병목일 수 있다. VRAM 사용량이 한계에 붙어 있고 TTFT가 급증한다면 KV 캐시 압박이나 과도한 max sequence length를 의심할 수 있다.
Grafana 대시보드는 최소한 다음 흐름을 한 화면에서 볼 수 있어야 한다.
클러스터와 노드 수준: 노드 Ready 상태, CPU·메모리 사용률, 디스크 압박, 네트워크 트래픽, GPU allocatable과 allocated
GPU 수준: GPU 사용률, VRAM 사용량, 온도, 전력, XID 오류, ECC 오류, GPU별 Pod 매핑
모델 서버 수준: 요청 수, 실패율, TTFT, ITL, tokens/sec, queue depth, batch size, KV cache 사용률
쿠버네티스 수준: Pod 재시작 수, Pending Pod 수, probe 실패, HPA/KEDA 스케일 이벤트
이 네 층을 함께 봐야 "사용자가 느리다"고 말한 순간에 실제로 어느 층에서 병목이 생겼는지 좁힐 수 있다.
LLM 추론 서비스의 메트릭: CPU 사용률만 보면 늦다
웹 서비스 시대에는 CPU 사용률이 오토스케일링의 기본 지표였다. 요청이 많아지면 CPU가 올라가고, HPA가 Pod를 늘리는 방식이다. LLM 추론에서는 이 방식이 자주 빗나간다. GPU는 바쁘지만 CPU는 낮을 수 있고, CPU는 높은데 GPU는 놀고 있을 수도 있다. 사용자가 체감하는 느림은 전체 응답 시간보다 첫 토큰이 나오기까지의 시간, 그리고 이후 토큰이 얼마나 부드럽게 생성되는지에 더 가깝다.
LLM 추론 서비스에서 반드시 봐야 할 대표 지표는 다음과 같다.
TTFT(Time To First Token): 요청 후 첫 토큰이 나오기까지 걸린 시간
ITL(Inter-Token Latency): 출력 토큰 사이의 지연 시간
tokens/sec: 초당 생성 토큰 수, GPU당 처리량을 볼 때 중요
queue depth: 모델 서버 내부 대기열 길이
request failure rate: 타임아웃, 취소, 서버 오류 비율
input/output tokens: 요청당 입력·출력 토큰 수, 비용과 용량 계획의 기준
KV cache usage 또는 cache hit rate: 긴 문맥과 재사용이 성능에 미치는 영향
vLLM, TGI, Triton 같은 추론 서버는 Prometheus 형식의 /metrics 엔드포인트를 제공하거나 별도 exporter와 연동할 수 있다. vLLM은 time to first token 같은 지표를, TGI는 queue size, batch size, generated tokens 같은 지표를 노출한다. OpenTelemetry도 GenAI 의미 체계에서 time to first token, time per output token, token usage 같은 표준화된 지표 이름을 다루기 시작했다.5
예를 들어 TTFT p95를 보고 싶다면 histogram 지표를 다음처럼 쿼리한다. 실제 메트릭 이름은 사용하는 서버 버전에 따라 다를 수 있으므로 /metrics 출력에서 먼저 확인해야 한다.
histogram_quantile(
0.95,
sum(rate(vllm:time_to_first_token_seconds_bucket{namespace="llm-serving"}[5m])) by (le, model_name)
)요청 성공률은 다음처럼 볼 수 있다.
sum(rate(vllm:request_success_total{namespace="llm-serving"}[5m])) by (model_name)GPU당 토큰 처리량을 같이 보면 비용 효율을 판단할 수 있다.
sum(rate(vllm:generation_tokens_total{namespace="llm-serving"}[5m])) by (pod)이 지표들은 나중에 HPA나 KEDA 기반 오토스케일링을 설계할 때도 중요하다. CPU 사용률이 30%라서 스케일 아웃하지 않았는데, TTFT가 8초를 넘고 queue depth가 계속 쌓인다면 사용자는 이미 느리다고 느낀다. 반대로 CPU가 높아도 queue가 비어 있고 TTFT가 안정적이라면 성급한 스케일 아웃이 비용 낭비일 수 있다.
AI 서비스의 SLO도 기존 API와 다르게 잡아야 한다. "p95 응답 시간 2초" 같은 단일 기준은 스트리밍 LLM에는 적합하지 않을 수 있다. "p95 TTFT 1.5초 이하", "p95 ITL 100ms 이하", "5xx 오류율 1% 이하", "요청 취소율 3% 이하"처럼 사용자 경험에 가까운 지표를 잡는 편이 낫다.
로그는 적게, 구조적으로, 추적 가능하게
로그를 많이 남기면 언젠가 도움이 될 것 같지만, 운영에서는 너무 많은 로그가 오히려 장애다. 비용이 올라가고, 검색이 느려지고, 중요한 로그가 묻힌다. 특히 LLM 서비스에서 프롬프트와 응답 전문을 그대로 로그에 남기는 것은 보안과 비용 양쪽에서 위험하다. 사용자의 개인정보, 회사 내부 문서, API 키가 프롬프트에 섞여 들어올 수 있다.
좋은 로그는 구조화되어 있고, 요청을 추적할 수 있으며, 민감 정보를 포함하지 않는다. JSON 로그를 사용하면 Loki나 다른 로그 시스템에서 필드를 파싱하기 쉽다.
{
"timestamp": "2026-05-08T10:15:23.120Z",
"level": "INFO",
"service": "llm-gateway",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"model": "llama-3.1-8b",
"input_tokens": 1200,
"output_tokens": 340,
"ttft_ms": 820,
"status": "success"
}여기서 핵심은 trace_id다. 로그에 trace ID가 들어 있으면 Grafana에서 로그를 보다가 해당 요청의 트레이스로 이동할 수 있다. 반대로 트레이스에서 느린 span을 찾은 뒤 같은 trace ID를 가진 로그만 열 수도 있다. 분산 시스템에서 이 연결이 없으면 운영자는 시간 범위와 사용자 ID, Pod 이름으로 로그를 수동 검색해야 한다. 장애 중에는 이런 수동 상관분석이 MTTR을 크게 늘린다.
다만 로그 라벨과 메트릭 라벨에는 조심해야 한다. Prometheus나 Loki에서 라벨은 검색과 인덱싱의 기준이 된다. user_id, request_id, conversation_id, prompt_hash처럼 값의 종류가 무한히 늘어나는 필드를 라벨로 넣으면 카디널리티가 폭발한다. 카디널리티는 시계열의 종류 수를 뜻한다. 값이 너무 다양하면 저장 비용과 쿼리 비용이 급증한다. 이런 값은 라벨이 아니라 로그 본문이나 trace attribute로 제한적으로 남기고, 필요하면 샘플링한다.
프롬프트와 응답은 기본적으로 저장하지 않는 정책을 권장한다. 디버깅을 위해 필요하다면 명시적 동의, 마스킹, 짧은 보존 기간, 접근 권한 분리, 샘플링을 함께 적용해야 한다. AI 서비스의 로그는 운영 데이터인 동시에 잠재적인 개인정보 저장소다.
트레이스: 에이전트와 RAG의 길을 따라가기
단순한 모델 서버 하나만 운영한다면 트레이스의 가치가 덜해 보일 수 있다. 하지만 실제 AI 애플리케이션은 금방 복잡해진다. API Gateway가 요청을 받고, 인증을 확인하고, 벡터 DB에서 문서를 검색하고, reranker를 호출하고, LLM 서버로 요청을 보내고, 응답 중간에 도구 호출을 수행하고, 결과를 다시 저장한다. 에이전트 워크로드는 여기에 반복 루프와 외부 API 호출이 더해진다.
이때 "전체 응답이 20초 걸렸다"는 메트릭만으로는 부족하다. 20초 중 12초가 벡터 검색인지, LLM decode인지, 외부 웹 검색 API인지, 도구 호출 재시도인지 알아야 한다. 트레이스는 이 시간을 span 단위로 나눠 보여준다.
OpenTelemetry를 쓰면 서비스 간 요청에 trace context를 전파할 수 있다. HTTP라면 traceparent 헤더가 전달되고, 메시지 큐나 작업 큐를 쓴다면 메시지 헤더에 컨텍스트를 넣어야 한다. 에이전트 시스템에서는 하나의 사용자 요청이 여러 비동기 작업으로 갈라질 수 있으므로, trace context 전파를 초기에 설계해 두는 것이 좋다.
트레이스 span 이름은 사람이 읽을 수 있어야 한다.
POST /v1/chat/completions
├─ auth.verify_token
├─ rag.retrieve_documents
│ ├─ pinecone.query
│ └─ reranker.score
├─ llm.prefill
├─ llm.decode_stream
├─ tool.call:web_search
└─ response.persist이런 트레이스가 있으면 병목 위치를 빠르게 찾을 수 있다. llm.decode_stream이 길다면 모델 서버나 GPU 상태를 본다. pinecone.query가 길다면 벡터 DB와 네트워크를 본다. tool.call:web_search가 길다면 외부 도구 호출 타임아웃과 재시도 정책을 본다. 이것이 관찰 가능성이 운영자의 시간을 절약하는 방식이다.
eBPF와 라이브 디버깅: 코드 밖에서 보는 세계
애플리케이션에 계측을 심는 것이 가장 정확할 때가 많지만, 항상 가능한 것은 아니다. 서드파티 바이너리, 레거시 서비스, 계측이 빠진 사이드카, 갑자기 느려진 네트워크 문제는 코드 안에서만 보기 어렵다. 이때 eBPF 기반 관찰 가능성 도구가 도움이 된다.
eBPF는 리눅스 커널 안에서 안전하게 작은 프로그램을 실행해 시스템 콜, 네트워크 패킷, 파일 접근, 프로세스 동작 같은 정보를 수집하는 기술이다. Pixie 같은 도구는 애플리케이션 코드를 수정하지 않고도 쿠버네티스 클러스터의 네트워크 요청, 지연 시간, 오류, 프로세스 정보를 실시간으로 보여준다. 복잡한 클러스터에서 "어떤 서비스가 누구와 통신하고 있는가", "어느 DNS 요청이 실패하는가", "어떤 TCP 연결이 지연되는가"를 빠르게 보는 데 유용하다.6
다만 eBPF 도구도 마법은 아니다. 결국 커널과 네트워크, 프로세스 수준의 텔레메트리를 읽기 쉽게 보여주는 것이다. 운영자가 기본 신호를 해석할 줄 모르면, 도구가 보여주는 풍부한 정보도 또 다른 소음이 된다. 반대로 기본기가 있으면 eBPF 도구는 매우 강력한 확대경이 된다.
AI 클러스터에서는 다음 상황에서 특히 유용하다. 모델 서버 Pod는 정상인데 외부 벡터 DB 연결이 간헐적으로 느릴 때, DNS 지연 때문에 첫 요청만 느릴 때, 서비스 메시나 Gateway 설정 문제로 특정 경로만 실패할 때, GPU 노드의 커널 수준 네트워크 드롭이 의심될 때다.
AI 기반 운영 도구를 믿되, 증거로 검증하라
2026년의 쿠버네티스 운영 도구들은 AI를 적극적으로 사용한다. K8sGPT 같은 CLI는 클러스터 리소스를 스캔해 사람이 읽기 쉬운 설명을 제공한다. KubePilot 같은 도구는 이벤트, Pod 진단, 서비스 토폴로지, AI 기반 원인 분석을 한 화면에 모으려 한다. 상용 AI SRE 도구들은 로그, 메트릭, 트레이스, 이벤트, 배포 변경 이력을 상관분석해 원인 후보를 제시한다.7
이 흐름은 분명 유용하다. 특히 쿠버네티스에 익숙하지 않은 팀원에게 0/5 nodes are available 같은 메시지를 설명해 주고, CrashLoopBackOff의 흔한 원인을 정리해 주며, 최근 배포와 오류율 증가를 연결해 주는 것은 큰 도움이 된다.
하지만 AI 도구의 답을 그대로 실행해서는 안 된다. 운영에서 중요한 것은 "그럴듯한 설명"이 아니라 "검증 가능한 설명"이다. AI가 "메모리 부족으로 보입니다"라고 말한다면 describe pod의 OOMKilled, Prometheus의 메모리 그래프, 로그의 종료 시점, 이벤트의 재시작 기록으로 확인해야 한다. AI가 "이미지 인증 문제입니다"라고 말한다면 이벤트의 unauthorized, Secret 존재 여부, ServiceAccount 연결 여부를 확인해야 한다.
좋은 사용법은 AI를 1차 분석 보조자로 두는 것이다. AI가 원인 후보와 확인 명령을 제시하게 하고, 운영자는 실제 클러스터 증거로 맞는지 검증한다. 특히 수정 명령, 리소스 삭제, 스케일 변경, 네트워크 정책 변경은 사람이 한 번 더 확인해야 한다. AI 시대의 운영자는 명령어를 덜 외워도 되지만, 증거를 읽는 능력은 더 중요해진다.
작은 팀을 위한 운영 대시보드 설계
작은 팀에서는 대시보드가 너무 많으면 아무도 보지 않는다. 처음에는 세 장이면 충분하다. 클러스터 건강 대시보드, 워크로드 대시보드, LLM/GPU 대시보드다.
클러스터 건강 대시보드에는 노드 Ready 상태, CPU·메모리·디스크 사용률, Pending Pod 수, 재시작 증가량, 네임스페이스별 리소스 사용량을 둔다. 이 화면은 "클러스터 전체가 괜찮은가"를 보는 용도다.
워크로드 대시보드에는 Deployment별 replica 상태, Pod readiness, 오류율, HTTP 지연 시간, 최근 배포 시점, 로그 오류량을 둔다. 이 화면은 "어느 서비스가 문제인가"를 보는 용도다.
LLM/GPU 대시보드에는 모델별 요청 수, TTFT, ITL, tokens/sec, queue depth, GPU 사용률, VRAM 사용량, GPU 온도와 전력, XID 오류를 둔다. 이 화면은 "사용자 경험과 GPU 자원이 어떤 관계인가"를 보는 용도다.
알림은 처음부터 많이 만들지 않는다. 알림이 너무 많으면 팀은 알림을 믿지 않게 된다. 처음에는 실제 사용자 영향이 있는 것부터 만든다. 예를 들어 모델 API 5xx 오류율이 5분 동안 3%를 넘거나, p95 TTFT가 10분 동안 목표치를 넘거나, Pending GPU Pod가 10분 이상 지속되거나, 같은 Pod가 15분 안에 3번 이상 재시작되는 경우다.
increase(kube_pod_container_status_restarts_total{namespace="llm-serving"}[15m]) > 3sum by (pod) (kube_pod_status_phase{namespace="llm-serving", phase="Pending"}) > 0DCGM_FI_DEV_XID_ERRORS > 0GPU 오류 알림은 특히 중요하다. XID 오류나 ECC 오류는 애플리케이션 문제가 아니라 하드웨어나 드라이버 계층 문제일 수 있다. 이 경우 Pod를 아무리 재시작해도 해결되지 않을 수 있고, 노드 cordon/drain, 드라이버 확인, 하드웨어 점검으로 이어져야 한다.
디버깅 플레이북: 같은 순서로 반복하기
장애 상황에서는 사람이 흥분한다. 그래서 플레이북이 필요하다. 플레이북은 천재적인 해결책이 아니라, 당황했을 때도 같은 순서로 확인하게 해주는 체크리스트다.
가장 기본적인 Pod 장애 플레이북은 다음과 같다.
# 1. 현재 상태와 노드 확인
kubectl get pod -n <namespace> -o wide
# 2. 상세 상태와 이벤트 확인
kubectl describe pod -n <namespace> <pod>
# 3. 네임스페이스 이벤트 시간순 확인
kubectl get events -n <namespace> --sort-by=.lastTimestamp
# 4. 현재 로그와 이전 로그 확인
kubectl logs -n <namespace> <pod> --tail=200
kubectl logs -n <namespace> <pod> --previous --tail=200
# 5. 리소스 사용량 확인
kubectl top pod -n <namespace>
kubectl top node
# 6. 컨테이너 내부 확인
kubectl exec -n <namespace> -it <pod> -- sh
# 7. 도구가 없으면 ephemeral container 붙이기
kubectl debug -n <namespace> -it <pod> --image=nicolaka/netshoot --target=<container>GPU Pod라면 여기에 다음을 추가한다.
# GPU 리소스가 노드에 보이는지 확인
kubectl describe node <gpu-node> | grep -A8 -E "nvidia.com|Capacity|Allocatable"
# GPU Operator 또는 Device Plugin 상태 확인
kubectl get pods -n gpu-operator
kubectl get pods -n kube-system | grep nvidia
# DCGM Exporter 메트릭 확인
kubectl port-forward -n monitoring svc/dcgm-exporter 9400:9400
curl -s http://127.0.0.1:9400/metrics | grep -E "DCGM_FI_DEV_GPU_UTIL|DCGM_FI_DEV_FB_USED|DCGM_FI_DEV_XID_ERRORS"LLM 추론 서비스라면 애플리케이션 메트릭도 확인한다.
kubectl port-forward -n llm-serving svc/vllm-7b 8000:8000
curl -s http://127.0.0.1:8000/metrics | head이 과정을 반복하면 장애 대응이 빨라진다. 더 중요한 것은 팀 전체의 언어가 맞춰진다는 점이다. 누군가 "Pod가 안 떠요"라고 말하는 대신 "Pending이고 이벤트에 Insufficient nvidia.com/gpu가 나옵니다"라고 말하게 된다. 누군가 "서버가 느려요"라고 말하는 대신 "TTFT p95가 1.2초에서 6초로 올랐고, queue depth가 함께 증가했습니다. GPU 사용률은 낮아서 토크나이저나 라우터 병목을 의심합니다"라고 말하게 된다. 이것이 관찰 가능성이 만드는 운영 문화다.
마무리: 보이는 만큼 고칠 수 있다
쿠버네티스는 복잡하다. AI 워크로드는 그 복잡성 위에 GPU, 모델 가중치, KV 캐시, 토큰 지연, 외부 도구 호출을 더한다. 그래서 운영자는 모든 것을 머릿속으로 추측할 수 없다. 대신 시스템이 남기는 신호를 잘 수집하고, 잘 연결하고, 같은 순서로 읽어야 한다.
Prometheus, Grafana, Loki, OpenTelemetry는 그 출발점이다. kubectl describe, logs, events, exec, debug는 손에 익어야 하는 기본 도구다. DCGM Exporter는 GPU 클러스터의 계기판이고, TTFT와 ITL은 LLM 서비스의 사용자 경험을 보여주는 온도계다. eBPF와 AI 기반 SRE 도구는 강력한 보조 수단이지만, 그 위에 올라가는 판단은 결국 증거를 읽는 운영자의 몫이다.
다음 장부터는 이 관찰 가능성의 기반 위에서 GPU 스케줄링을 본격적으로 다룬다. GPU가 노드에 어떻게 노출되고, Pod는 어떻게 GPU를 요청하며, DRA, MIG, time-slicing 같은 기술이 어떤 문제를 해결하는지 살펴볼 것이다. 그때도 원칙은 같다. 먼저 보이게 만들고, 그다음에 최적화한다.
참고
1Kubernetes Observability Stack 2026 — From Logs to OpenTelemetry
2End-to-End Observability with Prometheus, Grafana, Loki, OpenTelemetry and Tempo
4쿠버네티스에서 NVIDIA GPU 모니터링하기 - DCGM-Exporter 지표 수집
5Observability for LLM Systems: Metrics, Traces, Logs, and Testing in Production
6Pixie 2026: How eBPF-Based Live Debugging Is Revolutionizing Kubernetes Observability
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.