Skip to main content

쿠버네티스 워크로드를 다시 보는 기준

쿠버네티스를 처음 배울 때 워크로드 오브젝트는 보통 이렇게 설명된다. Pod는 컨테이너 실행 단위, Deployment는 웹 서버 배포, StatefulSet은 데이터베이스, DaemonSet은 노드마다 하나씩 띄우는 에이전트, Job은 한 번 실행하고 끝나는 작업, CronJob은 정해진 시간마다 반복되는 작업.

틀린 설명은 아니다. 하지만 AI 인프라를 운영하기 시작하면 이 분류만으로는 부족하다. "이건 웹 서버인가?"보다 더 중요한 질문이 생긴다. 이 작업은 계속 떠 있어야 하는가, 끝나면 사라져야 하는가? GPU를 얼마나 오래 점유하는가? 중간에 죽어도 다시 시작하면 되는가, 아니면 체크포인트에서 복구해야 하는가? Pod 이름과 저장소가 바뀌어도 괜찮은가? 트래픽을 받기 전에 모델 가중치를 완전히 로딩해야 하는가?

AI 시대의 쿠버네티스에서 워크로드 오브젝트를 고르는 기준은 결국 다음 세 가지로 압축된다.

질문적합한 오브젝트AI 예시
항상 떠 있으면서 요청을 받아야 하는가?DeploymentLLM 추론 API, 임베딩 서버, 이미지 생성 API
한 번 실행하고 끝나는가?Job모델 평가, 배치 추론, 대량 임베딩 생성
정해진 시간마다 반복되는가?CronJob야간 인덱싱, 일일 리포트, 주기적 모델 품질 평가
Pod마다 고유한 이름과 저장소가 필요한가?StatefulSet벡터 DB, Redis Cluster, 에이전트 세션 저장소
모든 노드 또는 특정 노드마다 하나씩 떠야 하는가?DaemonSetGPU 드라이버 구성요소, 로그 수집기, 노드 모니터링 에이전트

이 장에서는 이 표를 몸에 익히는 것이 목표다. YAML을 외우는 장이 아니다. 어떤 AI 작업을 어떤 형태로 운영해야 하는지 판단하는 감각을 만드는 장이다.

워크로드 관리: Pod부터 Job까지 AI 관점으로 보기

Pod는 실행 단위이지 운영 단위가 아니다

Pod는 쿠버네티스에서 컨테이너가 실행되는 가장 작은 단위다. 하나의 Pod 안에는 하나 이상의 컨테이너가 들어갈 수 있고, 같은 Pod 안의 컨테이너들은 네트워크와 일부 볼륨을 공유한다. 그래서 사이드카 패턴, 예를 들어 메인 추론 서버 옆에 로그 전송 컨테이너나 프록시 컨테이너를 붙이는 구성이 가능하다.

하지만 프로덕션에서 Pod를 직접 만드는 일은 드물다. Pod는 쉽게 죽고, 쉽게 새로 만들어진다. 노드가 재부팅되거나, 이미지 Pull에 실패하거나, 메모리를 초과하거나, 스케줄러가 더 이상 해당 노드에 둘 수 없다고 판단하면 Pod의 운명은 바뀐다. 특히 AI 워크로드에서는 Pod 하나가 단순한 프로세스가 아니다. 수십 GB 모델 가중치를 읽고, GPU 메모리를 잡고, KV 캐시를 만들고, 외부 벡터 DB와 연결한다. Pod가 한 번 재시작되는 비용이 일반 웹 서버보다 훨씬 크다.

따라서 Pod는 "직접 운영할 대상"이라기보다 Deployment, Job, StatefulSet 같은 상위 오브젝트가 만들어내는 실행 단위로 이해하는 편이 좋다. 우리는 Pod를 직접 사랑하지 않는다. Pod가 언제든 사라질 수 있다는 사실을 받아들이고, 그 사라짐을 견디는 구조를 만든다.

AI 워크로드에서 Pod 스펙을 읽을 때는 특히 resources, volumes, probes, nodeSelector 또는 affinity, restartPolicy를 먼저 봐야 한다. 여기에 이 Pod가 어떤 성격의 작업인지 대부분 드러난다.

apiVersion: v1
kind: Pod
metadata:
  name: embedding-test
  labels:
    app: embedding
spec:
  containers:
    - name: server
      image: ghcr.io/example/embedding-server:1.4.2
      ports:
        - containerPort: 8080
      resources:
        requests:
          cpu: "2"
          memory: "8Gi"
        limits:
          cpu: "4"
          memory: "16Gi"
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 8080
        periodSeconds: 10

이 예시는 GPU를 쓰지 않는 임베딩 서버처럼 보인다. CPU와 메모리 요청량이 있고, 준비 상태를 확인하는 readinessProbe가 있다. 그러나 kind: Pod이므로 이 Pod가 죽었을 때 새로 만들어줄 상위 컨트롤러가 없다. 테스트라면 괜찮지만, 실제 서비스라면 Deployment로 감싸야 한다.

GPU를 쓰는 Pod라면 리소스 선언도 달라진다. 쿠버네티스에서 GPU는 일반 CPU나 메모리와 달리 확장 리소스로 취급된다. NVIDIA 환경에서는 보통 nvidia.com/gpu라는 리소스를 사용한다. 공식 문서에서도 GPU는 limits에 지정해야 하며, requests와 함께 지정하는 경우 두 값이 같아야 한다고 설명한다1.

resources:
  limits:
    nvidia.com/gpu: 1
    memory: "32Gi"
    cpu: "8"

여기서 중요한 점은 nvidia.com/gpu: 1이 "GPU 메모리 1GB"가 아니라 "GPU 한 장"이라는 사실이다. 80GB A100에서 2GB만 쓰더라도 기본 모델에서는 한 장을 통째로 점유한다. MIG나 time-slicing 같은 공유 기술은 7장에서 다루겠지만, 워크로드 오브젝트를 설계하는 지금 단계에서도 이 점을 기억해야 한다. GPU Pod 하나는 비용이 큰 예약이다.

Deployment: 계속 떠 있어야 하는 추론 서버의 기본값

Deployment는 지정한 개수의 Pod를 계속 유지하고, 새 버전 배포와 롤백을 관리한다. 웹 API, 백엔드 서버, 프론트엔드 서버처럼 항상 떠 있어야 하는 서비스에 가장 많이 쓰인다. AI 인프라에서는 LLM 추론 API, 임베딩 서버, reranker 서버, 이미지 분류 API, 에이전트 API 서버가 Deployment의 대표적인 대상이다.

추론 서버가 Deployment와 잘 맞는 이유는 명확하다. 추론은 사용자의 요청을 기다리는 상시 서비스다. Pod 하나가 죽으면 새 Pod가 떠야 하고, 새 모델 버전이 나오면 점진적으로 교체되어야 하며, 장애가 나면 이전 버전으로 되돌릴 수 있어야 한다. 이 모든 것이 Deployment의 기본 역할이다.

다음은 vLLM 기반 OpenAI 호환 API 서버를 단순화한 Deployment 예시다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-api
  namespace: ai-prod
  labels:
    app: llm-api
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: llm-api
  template:
    metadata:
      labels:
        app: llm-api
        model: llama-3-8b
        version: v2026-05-01
    spec:
      terminationGracePeriodSeconds: 120
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          args:
            - --model
            - /models/llama-3-8b
            - --host
            - 0.0.0.0
            - --port
            - "8000"
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: 1
              cpu: "8"
              memory: "32Gi"
          volumeMounts:
            - name: model-cache
              mountPath: /models
          startupProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 10
            failureThreshold: 60
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 30
            failureThreshold: 3
      volumes:
        - name: model-cache
          persistentVolumeClaim:
            claimName: llama-3-8b-cache

이 YAML에서 가장 먼저 볼 것은 replicas: 2다. 실시간 추론 API를 운영한다면 최소 2개 이상의 복제본이 기본이다. Pod 하나가 죽거나 업데이트되는 동안에도 나머지 Pod가 요청을 받을 수 있어야 하기 때문이다. 물론 GPU가 비싸기 때문에 개발 환경에서는 1개로 시작할 수 있다. 하지만 프로덕션에서 replicas: 1인 추론 서버는 "장애가 나면 잠시 멈춰도 된다"는 비즈니스 판단이 숨어 있는 설정이다.

두 번째로 볼 것은 롤링 업데이트 전략이다. maxUnavailable: 0은 업데이트 중에도 기존 복제본 수를 줄이지 않겠다는 뜻이다. 새 Pod가 먼저 뜨고 readinessProbe를 통과한 뒤에야 기존 Pod를 내린다. 일반 웹 서버에서는 자연스러운 설정이지만, GPU 추론 서버에서는 조심해야 한다. maxSurge: 1 때문에 일시적으로 GPU가 하나 더 필요할 수 있기 때문이다. GPU 여유가 없는 클러스터에서 이 설정을 적용하면 새 Pod가 Pending 상태에 머물고 배포가 멈출 수 있다.

그래서 LLM 추론 Deployment의 롤링 업데이트에는 두 가지 전략이 있다. 첫 번째는 여유 GPU를 확보하고 maxSurge: 1, maxUnavailable: 0으로 무중단 배포를 하는 것이다. 두 번째는 여유 GPU가 없을 때 maxSurge: 0, maxUnavailable: 1로 기존 Pod 하나를 먼저 내리고 새 Pod를 띄우는 것이다. 후자는 비용은 절약하지만 순간적으로 처리 용량이 줄어든다. 둘 중 무엇이 맞는지는 기술 문제가 아니라 서비스 수준 목표, 즉 SLO의 문제다.

세 번째로 볼 것은 Probe다. LLM 서버는 컨테이너가 시작되었다고 곧바로 요청을 받을 수 있는 상태가 아니다. 모델 가중치를 다운로드하거나 PVC에서 읽어야 하고, GPU 메모리에 올려야 하며, 런타임이 내부 캐시를 준비해야 한다. 이 과정이 5분, 10분 걸릴 수 있다. startupProbe 없이 livenessProbe만 설정하면 모델 로딩 중인 Pod를 쿠버네티스가 "죽었다"고 판단해 계속 재시작시키는 악순환이 생긴다.

readinessProbe는 더 중요하다. Service는 Ready 상태인 Pod에만 트래픽을 보낸다. 모델 로딩이 끝나지 않았거나, 내부 큐가 꽉 찼거나, 외부 의존성 연결이 끊어진 Pod는 Ready가 아니어야 한다. 좋은 추론 서버는 단순히 프로세스가 살아 있는지뿐 아니라 "지금 요청을 받아도 되는지"를 구분해서 알려준다.

KServe의 LLMInferenceService 같은 고수준 CRD도 내부적으로는 이러한 쿠버네티스 워크로드 패턴을 기반으로 한다. KServe는 LLM을 위해 단일 노드, 멀티 노드, prefill-decode 분리, 지능형 라우팅 같은 구성을 제공하지만, 그 아래에는 결국 Pod, Deployment, Service, Gateway, 스케줄링 정책이 있다2. 고수준 도구를 쓰더라도 기본 워크로드의 성질을 이해해야 장애를 해석할 수 있다.

Deployment가 어울리지 않는 경우

상시 서비스라고 해서 모든 AI 작업을 Deployment로 만들면 안 된다. 대표적인 실수는 학습 작업을 Deployment로 띄우는 것이다. 학습 컨테이너는 언젠가 끝나야 한다. 그런데 Deployment는 Pod가 종료되면 "원하는 상태가 깨졌다"고 보고 다시 띄운다. 학습이 성공적으로 끝났는데도 같은 학습이 반복 실행될 수 있다.

배치 추론도 마찬가지다. 예를 들어 1,000만 개 문서에 대해 임베딩을 생성하는 작업은 사용자의 HTTP 요청을 기다리는 서버가 아니다. 시작해서, 처리하고, 끝나야 한다. 실패하면 재시도하되, 성공하면 멈춰야 한다. 이런 작업은 Deployment가 아니라 Job이다.

또 하나의 경계는 상태다. Deployment의 Pod 이름은 임의로 붙고, 재생성될 때 바뀐다. Pod마다 고유한 디스크와 정체성이 필요 없다면 Deployment가 좋다. 하지만 "이 Pod는 shard-0이고, 이 Pod는 shard-1이며, 각자 자기 디스크를 계속 가져야 한다"면 StatefulSet을 봐야 한다.

Job: 끝나야 정상인 AI 작업

Job은 하나 이상의 Pod를 생성하고, 지정한 수의 Pod가 성공적으로 종료될 때까지 실행을 관리한다. 공식 문서에서도 Job은 완료될 때까지 실행되는 일회성 작업을 표현하며, Pod가 실패하거나 노드 장애로 사라지면 다시 실행할 수 있다고 설명한다3.

AI 인프라에서 Job은 매우 중요하다. 모델 학습, 모델 평가, 배치 추론, 대량 임베딩 생성, 데이터 마이그레이션, 벡터 인덱스 재구성, 벤치마크 실행이 모두 Job의 영역이다. 이들은 서버처럼 "계속 살아 있음"이 목표가 아니다. "정확히 끝남"이 목표다.

다음은 모델 평가를 Job으로 실행하는 예시다.

apiVersion: batch/v1
kind: Job
metadata:
  name: eval-llama-3-8b-20260508
  namespace: ai-batch
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 86400
  activeDeadlineSeconds: 14400
  template:
    metadata:
      labels:
        app: model-eval
        model: llama-3-8b
    spec:
      restartPolicy: Never
      containers:
        - name: evaluator
          image: ghcr.io/example/model-evaluator:2.3.0
          command:
            - python
            - evaluate.py
            - --model
            - llama-3-8b
            - --dataset
            - /data/eval-set.jsonl
            - --output
            - /results/report.json
          resources:
            limits:
              nvidia.com/gpu: 1
              cpu: "8"
              memory: "32Gi"
          volumeMounts:
            - name: eval-data
              mountPath: /data
              readOnly: true
            - name: eval-results
              mountPath: /results
      volumes:
        - name: eval-data
          persistentVolumeClaim:
            claimName: eval-dataset
        - name: eval-results
          persistentVolumeClaim:
            claimName: eval-results

여기서 backoffLimit: 2는 실패 시 최대 재시도 횟수다. 재시도가 무조건 좋은 것은 아니다. 일시적인 네트워크 오류나 노드 장애라면 재시도가 도움이 된다. 하지만 코드 버그나 데이터 포맷 오류라면 재시도는 GPU 시간을 태우는 기계가 된다. 비싼 GPU Job일수록 backoffLimit를 낮게 잡고, 로그와 알림을 빨리 보는 편이 낫다.

ttlSecondsAfterFinished는 완료된 Job과 Pod를 일정 시간 후 정리한다. 배치 작업을 많이 돌리는 팀은 이 값을 반드시 고민해야 한다. 완료된 Pod를 영원히 남기면 kubectl get pods가 쓰레기장처럼 변한다. 반대로 너무 빨리 지우면 실패 원인을 확인하기 어렵다. 프로덕션에서는 보통 실패 로그가 중앙 로그 시스템으로 전송된다는 전제하에 TTL을 설정한다.

activeDeadlineSeconds는 전체 실행 시간 제한이다. 예를 들어 모델 평가가 보통 1시간 안에 끝나는데 4시간이 지나도 끝나지 않는다면, 무언가 잘못되었을 가능성이 크다. 이때 Job을 강제로 실패 처리해 GPU를 반환하게 만들 수 있다.

restartPolicy는 Job에서 특히 중요하다. Job의 Pod 템플릿에서는 Never 또는 OnFailure를 사용할 수 있다3. Never는 컨테이너가 실패하면 같은 Pod 안에서 재시작하지 않고, Job 컨트롤러가 새 Pod를 만들 수 있게 한다. OnFailure는 같은 Pod 안에서 컨테이너를 다시 시작한다. 디버깅과 로그 추적을 단순하게 하려면 Never를 선호하는 경우가 많고, 같은 작업 디렉터리 안에서 빠르게 재시도해도 되는 작업은 OnFailure도 사용할 수 있다.

병렬 Job: 대량 데이터와 평가를 나누어 처리하기

AI 팀은 늘 "많은 것"과 싸운다. 문서가 많고, 이미지가 많고, 평가 케이스가 많고, 모델 조합이 많다. Job은 병렬 실행을 지원하므로 이런 작업을 여러 Pod로 나누어 처리할 수 있다.

가장 단순한 방식은 고정된 완료 수를 지정하는 것이다.

apiVersion: batch/v1
kind: Job
metadata:
  name: batch-embedding
  namespace: ai-batch
spec:
  completions: 100
  parallelism: 10
  completionMode: Indexed
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: worker
          image: ghcr.io/example/embedding-worker:1.8.0
          command:
            - python
            - embed_shard.py
          env:
            - name: JOB_COMPLETION_INDEX
              valueFrom:
                fieldRef:
                  fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
          resources:
            limits:
              cpu: "4"
              memory: "16Gi"

completions: 100은 총 100개의 성공적인 실행이 필요하다는 뜻이고, parallelism: 10은 동시에 최대 10개까지 실행하겠다는 뜻이다. completionMode: Indexed를 사용하면 각 Pod가 자기 인덱스를 알 수 있다. 이 인덱스를 이용해 "0번 Pod는 0번 shard, 1번 Pod는 1번 shard"처럼 데이터를 나누어 처리할 수 있다.

또 다른 방식은 작업 큐를 사용하는 것이다. 이 경우 각 Pod가 Redis, Kafka, SQS, RabbitMQ 같은 큐에서 작업을 가져와 처리한다. 어떤 Pod가 어떤 문서를 처리할지 쿠버네티스가 정하지 않고, 애플리케이션이 정한다. 데이터 크기가 균등하지 않을 때는 큐 방식이 더 유연하다. 어떤 문서는 1초 만에 끝나고 어떤 문서는 2분 걸린다면, 고정 shard 방식은 느린 shard가 전체 작업을 붙잡을 수 있다. 큐 방식은 빠른 워커가 더 많은 작업을 가져가므로 전체 시간이 줄어든다.

다만 병렬 Job에서 가장 조심할 것은 중복 처리다. Pod가 실패해서 재시도되면 같은 shard가 다시 실행될 수 있다. 따라서 배치 추론이나 임베딩 생성 작업은 가능하면 멱등적으로 만들어야 한다. 멱등적이라는 말은 같은 작업을 두 번 실행해도 최종 결과가 망가지지 않는다는 뜻이다. 예를 들어 결과 테이블에 document_id를 고유 키로 두고 upsert를 사용하면 같은 문서를 두 번 처리해도 하나의 결과만 남길 수 있다.

CronJob: 반복되는 데이터 작업의 시계

CronJob은 정해진 스케줄에 따라 Job을 만든다. Linux의 crontab과 비슷하다고 보면 된다. AI 서비스에서는 CronJob이 생각보다 자주 등장한다. 매일 새벽 신규 문서를 임베딩하고, 매시간 오래된 캐시를 정리하고, 매주 모델 품질 리포트를 만들고, 매일 밤 벡터 인덱스를 재구성한다.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-vector-index
  namespace: ai-batch
spec:
  schedule: "0 3 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 10800
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: indexer
              image: ghcr.io/example/vector-indexer:4.1.0
              command:
                - python
                - rebuild_index.py
                - --source
                - mongodb
                - --target
                - pinecone
              resources:
                requests:
                  cpu: "2"
                  memory: "8Gi"
                limits:
                  cpu: "4"
                  memory: "16Gi"

이 예시는 매일 새벽 3시에 벡터 인덱스를 재구성한다. 여기서 핵심은 concurrencyPolicy: Forbid다. 이전 Job이 아직 끝나지 않았으면 새 Job을 만들지 않는다. 데이터 인덱싱이나 마이그레이션처럼 순서가 중요한 작업에서는 이 설정이 안전하다. 어제 작업이 아직 돌고 있는데 오늘 작업이 동시에 시작되면 같은 데이터를 두 프로세스가 서로 다른 기준으로 갱신할 수 있다.

concurrencyPolicy에는 세 가지 선택지가 있다.

값동작어울리는 AI 작업
Allow이전 Job이 실행 중이어도 새 Job을 만든다서로 완전히 독립적인 리포트 생성
Forbid이전 Job이 실행 중이면 새 실행을 건너뛴다데이터 인덱싱, 정기 평가, 백업
Replace이전 Job을 중단하고 새 Job으로 교체한다최신 상태만 의미 있는 캐시 갱신

CronJob은 편리하지만, "시간이 되면 반드시 실행된다"는 착각을 하면 안 된다. 클러스터가 바쁘거나, 컨트롤 플레인에 문제가 있거나, 이미지 Pull이 느리면 실제 시작 시각은 밀릴 수 있다. 중요한 정산 작업이나 규정 준수 작업이라면 CronJob의 성공 여부를 모니터링하고 알림을 붙여야 한다. AI 데이터 파이프라인에서 "어제 인덱싱이 안 됐는데 아무도 몰랐다"는 장애는 생각보다 치명적이다. 사용자 입장에서는 검색 결과가 낡아지고, 추천 품질이 떨어지고, RAG 답변이 어제의 세계에 갇힌다.

StatefulSet: 이름과 디스크가 중요한 AI 구성요소

StatefulSet은 각 Pod에 안정적인 이름, 순서, 저장소를 제공한다. Deployment가 "똑같은 Pod 여러 개"를 관리한다면, StatefulSet은 "각자 정체성이 있는 Pod 여러 개"를 관리한다.

AI 인프라에서 StatefulSet이 필요한 대표 사례는 벡터 DB, 캐시 클러스터, 메시지 큐, 에이전트 세션 저장소다. 예를 들어 Weaviate, Milvus, Elasticsearch, Redis Cluster, Kafka 같은 시스템은 Pod마다 역할과 데이터가 있다. vector-db-0과 vector-db-1은 단순히 서로 바꿔도 되는 복제본이 아닐 수 있다. 각 Pod가 자기 디스크를 가지고 있고, 클러스터 멤버십 안에서 고유한 이름으로 인식된다.

에이전트 워크로드에서도 StatefulSet의 필요성이 커진다. 단순 챗봇은 모든 상태를 외부 DB에 저장하고 Deployment로 운영할 수 있다. 하지만 장시간 실행되는 에이전트가 로컬 작업 디렉터리, 세션별 캐시, 중간 산출물, 도구 실행 상태를 디스크에 저장한다면 이야기가 달라진다. Pod가 재스케줄링되어도 같은 PVC를 다시 붙여야 한다. 이때 StatefulSet이 선택지가 된다.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: agent-session-store
  namespace: ai-prod
spec:
  serviceName: agent-session-store
  replicas: 3
  selector:
    matchLabels:
      app: agent-session-store
  template:
    metadata:
      labels:
        app: agent-session-store
    spec:
      containers:
        - name: redis
          image: redis:7.2
          ports:
            - containerPort: 6379
          volumeMounts:
            - name: data
              mountPath: /data
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              cpu: "2"
              memory: "4Gi"
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 50Gi

이 예시에서 agent-session-store-0, agent-session-store-1, agent-session-store-2는 안정적인 이름을 가진다. 각 Pod는 data라는 PVC를 따로 받는다. Pod가 삭제되어 다시 만들어져도 자기 PVC를 다시 붙인다. 이것이 Deployment와 가장 큰 차이다.

그렇다고 StatefulSet을 남용해서는 안 된다. 상태가 있다는 것은 운영 부담이 있다는 뜻이다. 백업, 복구, 스토리지 성능, 업그레이드 순서, 데이터 마이그레이션을 모두 고민해야 한다. 가능하다면 추론 서버와 에이전트 API는 Stateless하게 만들고, 상태는 관리형 DB나 전용 저장소로 빼는 것이 단순하다. StatefulSet은 "정말 Pod의 정체성과 디스크가 필요할 때" 꺼내는 카드다.

DaemonSet: 노드마다 필요한 보이지 않는 일꾼

DaemonSet은 조건에 맞는 모든 노드에 Pod를 하나씩 실행한다. 사용자가 직접 호출하는 서비스보다는 클러스터의 기반 기능을 담당하는 경우가 많다. AI 클러스터에서는 DaemonSet이 특히 중요하다. GPU 노드는 그냥 비싼 VM이 아니라 드라이버, 런타임, 디바이스 플러그인, 모니터링 에이전트가 맞물려야 비로소 쿠버네티스에서 GPU 노드가 된다.

대표적인 DaemonSet 패턴은 다음과 같다.

용도예시왜 DaemonSet인가
GPU 장치 노출NVIDIA Device Plugin각 GPU 노드의 GPU를 kubelet에 알려야 함
GPU 메트릭 수집DCGM Exporter각 노드의 GPU 사용률, 메모리, 온도를 수집해야 함
로그 수집Fluent Bit, Vector각 노드의 컨테이너 로그를 중앙으로 보내야 함
노드 모니터링node-exporter각 노드의 CPU, 메모리, 디스크 상태를 수집해야 함
보안/네트워크 에이전트CNI, 보안 센서노드 단위로 네트워크와 보안을 제어해야 함

NVIDIA Device Plugin이나 GPU Operator의 구성요소는 DaemonSet의 전형적인 예다. 쿠버네티스는 장치 플러그인을 통해 GPU 같은 특수 하드웨어를 노드의 스케줄 가능한 리소스로 노출한다1. 이 플러그인은 GPU가 있는 각 노드에서 실행되어야 하므로 DaemonSet이 자연스럽다.

DaemonSet을 볼 때는 nodeSelector, affinity, tolerations를 함께 봐야 한다. GPU 관련 DaemonSet은 GPU 노드에만 떠야 한다. 반대로 로그 수집기나 node-exporter는 거의 모든 노드에 떠야 한다. GPU 노드에 NoSchedule taint가 걸려 있다면, 해당 DaemonSet에는 그 taint를 견딜 수 있는 toleration이 필요하다. 그렇지 않으면 정작 GPU 노드에서 GPU 메트릭이 수집되지 않는 황당한 일이 생긴다.

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: gpu-node-agent
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: gpu-node-agent
  template:
    metadata:
      labels:
        app: gpu-node-agent
    spec:
      nodeSelector:
        accelerator: nvidia-gpu
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      containers:
        - name: agent
          image: ghcr.io/example/gpu-node-agent:1.0.0
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

이런 DaemonSet은 애플리케이션 코드와 직접 관련이 없어 보인다. 하지만 실제 장애 대응에서는 매우 중요하다. GPU가 갑자기 보이지 않을 때, 추론 지연이 늘었을 때, 특정 노드에서만 OOM이 발생할 때, 우리는 DaemonSet으로 배포된 에이전트들이 남긴 메트릭과 로그를 본다. 보이지 않는 일꾼이 없으면 클러스터는 침묵한다.

PodDisruptionBudget: 자발적 중단으로부터 추론을 지키기

쿠버네티스에서 Pod가 사라지는 이유는 크게 두 가지다. 하나는 비자발적 중단이다. 노드 장애, 커널 패닉, OOM, 하드웨어 문제처럼 피하기 어려운 사건이다. 다른 하나는 자발적 중단이다. 노드 드레인, 클러스터 업그레이드, 오토스케일러의 노드 축소, 운영자가 직접 수행한 유지보수처럼 계획된 사건이다.

PodDisruptionBudget, 줄여서 PDB는 자발적 중단 상황에서 최소한의 가용성을 지키기 위한 장치다. 예를 들어 추론 API Pod가 3개 있는데, 노드 드레인 때문에 한 번에 2개가 내려가면 서비스 용량이 급격히 줄어든다. PDB를 설정하면 쿠버네티스는 지정한 최소 가용 Pod 수를 깨는 방식의 자발적 중단을 막는다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: llm-api-pdb
  namespace: ai-prod
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: llm-api

이 설정은 app: llm-api Pod 중 최소 2개는 사용 가능해야 한다는 뜻이다. 추론 API처럼 실시간 요청을 받는 서비스에는 PDB가 거의 필수다. 특히 GPU 노드 업그레이드나 드라이버 교체 작업에서는 노드 드레인이 발생할 수 있다. 이때 PDB가 없으면 운영 작업이 서비스 장애로 이어질 수 있다.

긴 학습 작업에도 PDB가 의미가 있다. 다만 방식은 조금 다르다. 학습 Job은 Deployment처럼 여러 복제본이 항상 요청을 받는 구조가 아닐 수 있다. 대신 노드 드레인으로 학습 Pod가 중간에 종료되면 수 시간의 계산이 날아갈 수 있다. 체크포인트가 잘 되어 있더라도 재시작 비용은 크다. 분산 학습에서는 Pod 하나만 사라져도 전체 학습이 실패할 수 있다. 이런 경우에는 PDB와 함께 학습 프레임워크의 체크포인트, Job 재시도 정책, 우선순위 정책을 함께 설계해야 한다.

다만 PDB는 만능 방패가 아니다. 노드가 실제로 죽는 비자발적 장애는 막지 못한다. 또한 PDB를 너무 강하게 설정하면 클러스터 업그레이드나 노드 축소가 막힐 수 있다. "서비스를 지키는 설정"이 "운영을 멈추는 설정"이 되지 않도록 균형을 잡아야 한다.

PriorityClass: 실시간 추론과 실험 Job의 줄 세우기

GPU 클러스터에서는 모든 작업이 똑같이 중요하지 않다. 프로덕션 LLM 추론 API가 Pending 상태인데, 실험용 노트북이 GPU를 잡고 있다면 팀 전체가 불행해진다. 반대로 연구팀의 대규모 학습이 매번 프로덕션 워크로드에 밀려 영원히 시작되지 않아도 문제다. PriorityClass는 이런 우선순위를 쿠버네티스 스케줄러에 알려주는 방법이다.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: ai-prod-critical
value: 100000
preemptionPolicy: PreemptLowerPriority
globalDefault: false
description: "Production AI inference workloads"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: ai-batch-normal
value: 1000
preemptionPolicy: Never
globalDefault: false
description: "Batch training and evaluation workloads"

그리고 Pod 템플릿에서 다음처럼 지정한다.

spec:
  priorityClassName: ai-prod-critical

PriorityClass를 사용하면 스케줄러가 더 중요한 Pod를 먼저 배치할 수 있다. PreemptLowerPriority가 설정되어 있으면 낮은 우선순위 Pod를 쫓아내고 높은 우선순위 Pod를 배치할 수도 있다. 이것을 선점이라고 한다.

선점은 강력하지만 위험하다. 실험용 Pod를 쫓아내는 것은 괜찮을 수 있다. 하지만 12시간째 돌고 있던 학습 Job을 예고 없이 종료하면 연구자가 울 수 있다. 따라서 낮은 우선순위 Job에는 반드시 체크포인트와 재시작 전략이 있어야 한다. 반대로 실시간 추론에는 높은 우선순위를 주되, 필요한 최소 GPU 용량을 별도 노드풀이나 쿼터로 확보하는 것이 좋다.

이전 장에서 다룬 네임스페이스, ResourceQuota, LimitRange와 PriorityClass는 함께 봐야 한다. 예를 들어 ai-prod 네임스페이스에는 최소한의 GPU 쿼터를 보장하고, ai-experiment 네임스페이스에는 남는 자원을 쓰게 할 수 있다.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: gpu-quota
  namespace: ai-experiment
spec:
  hard:
    requests.nvidia.com/gpu: "2"
    limits.nvidia.com/gpu: "2"
    requests.cpu: "32"
    requests.memory: "128Gi"

이 설정은 실험 네임스페이스가 GPU를 최대 2개까지만 쓰도록 제한한다. GPU는 팀의 공용 냉장고와 비슷하다. 아무 규칙 없이 두면 누군가는 한 칸만 쓰고, 누군가는 냉장고 전체를 차지한다. 쿼터와 우선순위는 싸움을 줄이는 최소한의 라벨링이다.

restartPolicy: 다시 시작해야 하는가, 실패로 남겨야 하는가

restartPolicy는 작아 보이지만 AI 워크로드의 성격을 크게 바꾼다. Deployment로 관리되는 Pod는 일반적으로 Always를 사용한다. 서버는 죽으면 다시 살아나야 하기 때문이다. 하지만 Job에서는 Never 또는 OnFailure만 허용된다3. 작업은 성공하면 끝나야 하고, 실패했을 때만 재시도 여부를 판단해야 한다.

실시간 추론 Deployment에서 재시작은 대체로 좋은 일이다. 프로세스가 데드락에 빠지거나 메모리 누수가 생겼다면 livenessProbe가 실패하고 Pod가 재시작된다. 물론 재시작 동안 해당 Pod는 트래픽을 받을 수 없으므로 replicas와 PDB가 필요하다.

반면 긴 학습 Job에서 무작정 재시작은 애매하다. 컨테이너가 죽었을 때 처음부터 다시 학습을 시작한다면 GPU 비용이 크게 낭비된다. 이 경우 애플리케이션 레벨 체크포인트가 중요하다. Pod가 새로 시작되더라도 마지막 체크포인트를 읽고 이어서 학습해야 한다. 쿠버네티스의 restartPolicy는 프로세스를 다시 띄워줄 수는 있지만, 학습 상태를 마법처럼 복구해주지는 않는다.

배치 추론에서도 비슷하다. 1,000만 문서 중 700만 문서를 처리한 뒤 Pod가 죽었을 때 처음부터 다시 시작하면 비용도 시간도 낭비된다. 따라서 작업 단위를 작게 나누고, 처리 완료 상태를 외부 저장소에 기록해야 한다. 쿠버네티스는 "다시 실행"을 도와준다. "어디서부터 다시 시작할지"는 애플리케이션이 알아야 한다.

AI 작업별 선택 가이드

이제 실제 상황으로 돌아가 보자. 어떤 오브젝트를 선택해야 할까?

LLM 추론 API는 보통 Deployment로 시작한다. 사용자 요청을 계속 받아야 하고, 롤링 업데이트와 롤백이 필요하며, Service와 Gateway 뒤에 붙기 쉽다. 모델이 작고 단일 Pod로 충분하면 Deployment가 단순하다. 모델이 커져서 여러 Pod가 하나의 추론 단위를 이루거나 prefill과 decode를 분리해야 한다면 LeaderWorkerSet, KServe LLMInferenceService, llm-d 같은 고수준 추상화를 검토한다. NVIDIA가 설명하는 disaggregated inference도 prefill, decode, router를 분리하고 각 역할을 독립적으로 스케일링하는 방향으로 발전하고 있다4. 하지만 그 단계로 가기 전, 기본 추론 서버는 Deployment로 이해하는 것이 출발점이다.

임베딩 서버도 대부분 Deployment가 맞다. 요청이 들어올 때마다 텍스트를 벡터로 바꿔 반환하는 상시 서비스이기 때문이다. 다만 대량 문서 전체를 한 번에 임베딩하는 작업은 Job이다. 같은 "임베딩"이라는 단어가 붙어도 온라인 API인지, 오프라인 배치인지에 따라 오브젝트가 달라진다.

모델 학습은 기본적으로 Job이다. 학습은 시작과 끝이 있다. 단일 GPU 학습이라면 Job으로 충분하다. 여러 노드와 여러 GPU를 묶는 분산 학습이라면 Job 하나만으로는 부족할 수 있고, JobSet, Kubeflow Training Operator, Kueue 같은 도구가 필요해진다. 이것은 8장에서 자세히 다룬다.

모델 평가는 Job 또는 CronJob이다. 새 모델이 나올 때마다 한 번 실행하는 평가는 Job이다. 매일 같은 벤치마크를 돌려 품질 추이를 보는 작업은 CronJob이다. 평가 결과는 로그에만 남기지 말고, S3, DB, MLflow 같은 외부 저장소에 기록해야 한다.

벡터 DB는 StatefulSet 또는 관리형 서비스를 선택한다. 직접 클러스터 안에서 운영한다면 StatefulSet이 자연스럽다. 그러나 작은 팀이라면 Pinecone, Weaviate Cloud, MongoDB Atlas Vector Search 같은 관리형 서비스를 쓰는 편이 운영 부담이 적을 수 있다. 쿠버네티스에서 돌릴 수 있다는 것과 직접 운영해야 한다는 것은 다르다.

에이전트 API는 보통 Deployment로 시작한다. 세션 상태를 외부 DB나 Redis에 저장하면 Pod는 Stateless하게 유지할 수 있다. 그러나 에이전트가 장시간 작업 디렉터리를 유지해야 하거나, Pod별 로컬 상태가 중요하다면 StatefulSet을 검토한다. 이 주제는 12장에서 더 깊게 다룬다.

로그 수집기, GPU 메트릭 수집기, 노드 보안 에이전트는 DaemonSet이다. 노드마다 있어야 하기 때문이다.

작은 팀을 위한 현실적인 기본값

소규모 AI 팀이라면 처음부터 모든 오브젝트를 복잡하게 쓰려고 하지 않는 편이 좋다. 다음 기본값으로 시작하면 대부분의 상황에서 크게 틀리지 않는다.

작업첫 선택나중에 확장할 방향
LLM/임베딩 온라인 APIDeploymentKServe, LLMInferenceService, KEDA 오토스케일링
모델 평가JobKueue, Argo Workflows, MLflow 연동
대량 임베딩 생성JobIndexed Job, 작업 큐, Argo Workflows
정기 데이터 인덱싱CronJobAirflow, Argo Workflows
벡터 DB/Redis/Kafka관리형 서비스 또는 StatefulSetOperator 기반 운영
GPU/로그/메트릭 에이전트DaemonSetGPU Operator, 표준 관찰 가능성 스택

처음에는 Deployment와 Job만 잘 써도 충분하다. 많은 팀의 AI 인프라는 "온라인 추론은 Deployment, 오프라인 처리는 Job"이라는 단순한 구분에서 시작한다. 여기에 시간이 붙으면 CronJob, 상태가 붙으면 StatefulSet, 노드 단위 기능이 붙으면 DaemonSet이 들어온다.

중요한 것은 오브젝트 이름을 외우는 것이 아니라 작업의 생애주기를 보는 것이다. 계속 살아 있어야 하는가? 끝나야 하는가? 반복되어야 하는가? 이름과 디스크를 유지해야 하는가? 모든 노드에 있어야 하는가? 이 질문에 답할 수 있으면 YAML의 절반은 이미 쓴 셈이다.

마무리: 쿠버네티스는 작업의 성격을 묻는다

쿠버네티스는 "AI냐 아니냐"를 모른다. 스케줄러는 이 Pod가 Llama 모델을 서빙하는지, 고객 문의를 처리하는 에이전트인지, 주말에 몰래 돌린 실험 코드인지 관심이 없다. 쿠버네티스가 보는 것은 선언된 상태다. 몇 개가 떠 있어야 하는지, 어떤 리소스를 요구하는지, 실패하면 다시 시작해야 하는지, 어느 노드에 놓을 수 있는지, 중단되어도 되는지다.

그래서 AI 인프라 운영자는 모델만 보는 사람이 아니라 작업의 성격을 번역하는 사람이어야 한다. "이 모델을 배포해주세요"라는 요청을 받으면, 그것이 Deployment인지 Job인지 StatefulSet인지 판단해야 한다. "이 데이터 매일 새벽에 갱신해주세요"라는 요청을 받으면 CronJob인지 워크플로 엔진인지 판단해야 한다. "GPU 노드 상태가 안 보여요"라는 이야기를 들으면 DaemonSet으로 배포된 에이전트부터 확인해야 한다.

이번 장에서 배운 오브젝트들은 이후 장의 토대가 된다. GPU 스케줄링을 다룰 때도 결국 Pod 스펙에 리소스를 어떻게 선언하는지가 출발점이다. Kueue로 학습 큐를 만들 때도 기본 단위는 Job이다. KServe로 LLM을 서빙할 때도 그 아래에는 Deployment와 Service, Gateway, Pod가 있다. 에이전트 상태를 다룰 때도 Stateless Deployment와 StatefulSet 사이의 선택이 핵심이 된다.

쿠버네티스는 거대한 시스템처럼 보이지만, 매번 같은 질문으로 돌아온다. 이 작업은 어떤 삶을 살아야 하는가. 계속 살아야 하는가, 끝나야 하는가, 기억해야 하는가, 모든 노드에 있어야 하는가. 그 질문에 답하는 순간, AI 워크로드의 운영 설계가 시작된다.

참고

1Kubernetes GPU Scheduling

2KServe LLMInferenceService Overview

3Kubernetes Jobs

4NVIDIA: Deploying Disaggregated LLM Inference Workloads on Kubernetes

책 목록

AI 시대의 쿠버네티스: LLM 추론과 에이전트를 운영하는 인프라 전략

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기