Загружаем каталог…
Загружаем каталог…
최종 프로젝트 회고 사실 이미 7월에 프로젝트를 마무리하고 포트폴리오도 완성을 했지만, 취준을 핑계로 내팽겨쳤던 프로젝트에 대한 회고를 이제야 해보려한다. Full Architecuter UtterAI — EKS 클러스터 편 언어재활사(SLP)가 상담 음성을 올리면 STT·화자분리 → 언어 지표 계산 → 리포트 생성까지 처리하는 AI 서비스의 인프라를, EKS 위에서 어떻게 설계하고 무엇을 깨뜨려 가며 고쳤는지 정리한다. EKS cluster 들어가며 이 글은 "EKS를 이렇게 구성했다"는 소개보다 " 이렇게 구성했더니 이게 터졌고, 이렇게 고쳤다 "에 가깝다. 아키텍처 문서는 결과만 남지만, 회고에는 그 결과에 도달하기까지 틀렸던 판단이 남아야 한다고 생각한다. 먼저 요약. API·CPU Worker·GPU Worker·Batch Worker를 하나의 EKS 클러스터 에서 운영했다. 고정 capacity는 System Managed Node Group , 변하는 워크로드는 전부 Karpenter NodePool 로 분리했다. Pod 수는 KEDA(SQS backlog 기준) , 노드 수는 Karpenter 가 결정한다. GPU·Batch는 scale-to-zero , CPU Worker는 warm Pod 1개를 유지한다. prod 부하 시험에서 CPU 1→10(168초), GPU 0→4(306초), Batch 0→5(347초) 확장을 확인했다. 인스턴스 사용시간이 47% 늘어난 구간에서도 EC2 비용은 33.8% 줄었다. 그리고 그 과정에서 Pod가 16시간 동안 ContainerCreating 에 멈춰 있기도 했다. 1. 왜 EKS였나 솔직히 처음에는 "쿠버네티스를 써 보고 싶어서"가 없었다고는 못 한다. 하지만 끝까지 EKS를 유지한 이유는 워크로드가 서로 너무 달랐기 때문이다. 워크로드 특성 필요한 자원 확장 기준 Backend API 동기 HTTP, 짧은 응답 일반 CPU/메모리 항상 준비된 replica CPU Worker 오디오 전처리, Bedrock 리포트 CPU/메모리 SQS backlog GPU Worker STT, 화자분리 GPU 1개 SQS backlog, 유휴 시 0 Batch Worker RAG 문서 ingest CPU/메모리 SQS backlog, Spot 우선 처리 경로는 이렇다. 사용자 → Backend API │ audio-preprocess SQS ▼ CPU Worker │ gpu-inference SQS ▼ GPU Worker → transcript │ (사용자 확정 후) report-analysis SQS ▼ CPU Worker → Bedrock → report 한 가지 짚고 넘어갈 점: EKS를 골랐다고 EC2를 안 쓰는 게 아니다. EKS의 노드도 결국 EC2다. 선택지는 "EC2냐 아니냐"가 아니라 운영 모델이었다. EC2 직접 운영 : control plane 비용은 없지만, "SQS가 쌓이면 worker를 늘리고, 그 worker를 담을 서버를 띄우고, 다 끝나면 GPU 서버를 끈다"를 CloudWatch alarm + Lambda + ASG로 직접 조립해야 한다. ECS Fargate : API만 있었다면 가장 단순했을 것이다. 하지만 가장 비싼 GPU 워크로드를 같은 모델로 다룰 수 없었다. ECS on EC2 : 기술적으로는 가능했다. 다만 KEDA·Karpenter·Argo CD·External Secrets가 해 주는 일을 ECS 방식으로 다시 조합해야 했다. 결국 가장 비싼 GPU 워크로드가 플랫폼 선택을 결정했다. 반대로 말하면 GPU가 없고 API 한두 개뿐인 서비스였다면 EKS는 과했을 것이다. control plane 비용, system 노드, add-on 운영, "어느 controller가 이 필드를 소유하는가" 같은 문제를 전부 떠안아야 하기 때문이다. 이 글의 절반은 그 대가에 대한 이야기다. 2. 클러스터 구조 2-1. System은 Managed Node Group, 나머지는 Karpenter 영역 프로비저닝 배치 대상 System Managed Node Group (On-Demand, 고정) CoreDNS, KEDA, Karpenter, Argo CD, ESO 등 api Karpenter NodePool Backend blue/green cpu-worker Karpenter NodePool 오디오 전처리·리포트 Worker batch-worker Karpenter NodePool RAG ingest Worker gpu Karpenter NodePool STT·화자분리 Worker Karpenter controller 자신은 Karpenter가 만든 노드 위에 올리면 안 된다. 그래서 CriticalAddonsOnly taint가 걸린 고정 system 노드에 controller들을 모으고, 수요가 변하는 워크로드만 Karpenter에 맡겼다. NodePool은 워크로드별로 성격을 다르게 줬다. NodePool 인스턴스 capacity consolidation api t3.medium Spot + On-Demand WhenEmptyOrUnderutilized, 30초 cpu-worker m5/m5a/m6i/m6a xlarge Spot + On-Demand WhenEmptyOrUnderutilized, 5분 batch-worker c5/c6i/c6a/m5/m6i large·xlarge Spot + On-Demand WhenEmptyOrUnderutilized, 30초 gpu g4dn/g5 xlarge·2xlarge Spot + On-Demand WhenEmpty, 10분 consolidation 시간이 제각각인 데는 전부 사연이 있다. 4장에서 다룬다. 2-2. 격리는 nodeSelector + taint/toleration으로 GPU 노드에는 dedicated=ai-gpu , nvidia.com/gpu taint를 걸고, GPU Worker만 toleration과 nvidia.com/gpu: 1 request를 갖는다. 이 조합 덕분에 일반 Pod가 비싼 GPU 노드에 올라가 노드 회수를 막는 일이 없다. resources: requests: nvidia.com/gpu: 1 limits: nvidia.com/gpu: 1 nodeSelector: workload: ai-gpu tolerations: - key: dedicated value: ai-gpu effect: NoSchedule 2-3. Terraform은 레이어로, 앱은 GitOps로 00-iam → 01-network → 02-eks → 03-services → 04-addons → 05-agentcore │ ▼ Argo CD (Kustomize overlay dev/prod) VPC·EKS처럼 느리고 파괴 영향이 큰 것과, 하루에도 몇 번씩 바뀌는 Deployment를 같은 state에 넣지 않는 게 핵심이다. Terraform은 클러스터와 controller(Helm)까지만 책임지고, NodePool·ScaledObject·Deployment는 Argo CD가 Git의 Kustomize overlay를 따라가게 했다. 3. 오토스케일링: 두 개의 오토스케일러 3-1. CA + HPA로 갔다가 돌아왔다 지금 구조는 KEDA + Karpenter지만, 직선으로 온 게 아니다. 초기에 KEDA를 도입 비교 기준선을 만들려고 Cluster Autoscaler + CPU 기반 HPA 로 전환 다시 KEDA + Karpenter 로 복귀 CPU 기반 HPA를 직접 써 보고 확실히 알게 된 건, 비동기 worker에서 CPU 사용률은 작업량을 대표하지 못한다 는 점이다. 큐에 메시지가 쌓여도 worker가 Bedrock 응답을 기다리는 중이면 CPU는 낮다. GPU 작업량은 애초에 CPU 사용률로 보이지 않는다. 스케일 신호는 "지금 얼마나 바쁜가"가 아니라 "얼마나 밀려 있는가"여야 했다. 3-2. 제어 루프 SQS backlog → KEDA가 queue depth 조회 → (KEDA가 만든) HPA가 replica 조정 → Pod가 Pending → Karpenter가 NodeClaim 생성 → EC2 기동 → Node Ready → Pod Running KEDA/HPA : Pod가 몇 개 필요한가 Karpenter : 그 Pod를 담을 EC2가 몇 대, 어떤 타입으로 필요한가 이 둘은 별개의 타이머와 별개의 상한 을 가진다. 이걸 머리로는 알았지만 몸으로 이해한 건 장애를 몇 번 겪은 뒤였다. 3-3. 현재 prod 값 Worker min max queueLength cooldown CPU 1 10 5 300초 GPU 0 4 1 600초 Batch 0 5 3 300초 queueLength 는 큐의 최대 길이가 아니라 Pod 하나가 담당하길 기대하는 메시지 수 다. desired = ceil(메시지 수 / queueLength) . GPU queueLength=1 : GPU Worker는 작업을 하나씩 직렬 처리하므로 "메시지 1개 = Pod 1개"가 처리 모델과 일치한다. CPU min=1 : 오디오 전처리와 리포트는 호출 빈도가 높아, 매번 0에서 깨우면 cold start가 사용자 경로에 들어온다. 비용보다 응답 안정성을 택했다. GPU/Batch min=0 : 유휴 GPU 비용을 없애는 대신 첫 요청의 cold start를 감수했다. prod GPU Worker의 ScaledObject는 이렇게 생겼다. apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: utterai-ml-gpu-worker-scaledobject namespace: utterai-ai-gpu spec: scaleTargetRef: name: utterai-ml-gpu-worker minReplicaCount: 0 maxReplicaCount: 4 cooldownPeriod: 600 advanced: horizontalPodAutoscalerConfig: behavior: scaleDown: stabilizationWindowSeconds: 600 triggers: - type: aws-sqs-queue authenticationRef: name: keda-aws-pod-identity kind: ClusterTriggerAuthentication metadata: queueURL: "https://sqs.ap-northeast-2.amazonaws.com/<ACCOUNT_ID>/utterai-prod-gpu-inference-queue" queueLength: "1" activationQueueLength: "0" awsRegion: ap-northeast-2 identityOwner: operator 4. 깨뜨리면서 배운 것들 4-1. 첫 메시지가 GPU를 깨우지 못했다 activationQueueLength: 1 로 뒀더니 메시지 1개로는 scale-to-zero 상태에서 깨어나지 않았다. 활성화 조건이 queue > activationQueueLength 라서 1 > 1 은 거짓이기 때문이다. 0으로 바꿔 해결했다. "1개부터 깨운다"는 의도로 1을 적었는데 정확히 반대로 동작했다. 임계값은 >= 인지 > 인지부터 확인해야 한다. 4-2. KEDA와 Argo CD가 서로를 되돌렸다 KEDA: 큐가 비었다 → replicas 0 Argo CD selfHeal: Git에는 replicas 1 → 다시 1 KEDA: 다시 0 (반복) GitOps는 Git 상태로 되돌리려 하고, 오토스케일러는 런타임에 replica를 바꾼다. 같은 필드의 주인이 둘 이었던 것이다. Argo CD Application에서 replica를 무시하게 해서 소유권을 KEDA에 넘겼다. ignoreDifferences: - group: apps kind: Deployment jsonPointers: - /spec/replicas 부끄러운 후일담: 이 문제를 dev에서 먼저 고쳤는데, prod에는 3일 뒤에야 같은 수정이 들어갔다. dev/prod overlay를 분리해 환경 독립성은 얻었지만, "dev에서 고친 게 prod에 자동으로 따라가지 않는다"는 비용도 같이 얻었다. 4-3. 일하는 중인 노드를 Karpenter가 치워 버렸다 CPU Worker : Bedrock 응답을 기다리는 동안 CPU 사용률이 낮았고, Karpenter는 그 노드를 underutilized로 판단해 30초 뒤 정리했다. 작업 중인 Pod가 eviction되고 SQS 재시도로 이어졌다. consolidateAfter 를 30초 → 5분으로 늘렸다. GPU Worker : 더 미묘했다. GPU Pod terminationGracePeriodSeconds = 300초 GPU NodePool consolidateAfter = 5분 두 타이머가 같은 값이라 동시에 만료되면, kubelet이 Pod를 정리하기 전에 EC2가 먼저 종료되어 Pod가 Terminating 에 고착됐다. consolidateAfter 를 10분으로 올리고 규칙을 하나 만들었다. consolidateAfter > terminationGracePeriodSeconds 여기서 얻은 교훈은, "scale-in이 너무 급하다"는 증상의 원인이 HPA가 아니라 노드 계층 에 있을 수 있다는 것이다. Pod 안정화 값만 계속 만지고 있었다면 못 찾았을 문제다. 4-4. 아무도 요청하지 않는데 10분마다 GPU 노드가 떴다 GPU 처리 시간을 고려해 큐의 visibility timeout을 600초 → 1800초로 올렸다. 그런데도 약 10분 주기로 GPU 노드가 계속 생겼다. 원인은 worker 코드였다. sqs.receive_message(QueueUrl=..., VisibilityTimeout=600) # 큐 기본값을 덮어쓴다 per-call 파라미터가 Terraform으로 설정한 큐 기본값을 이긴다. 처리 중이던 메시지가 10분 뒤 다시 visible이 되고, KEDA는 그걸 새 작업으로 보고 GPU 노드를 또 띄웠다. 하드코딩을 제거해 해결했다. 인프라 설정을 바꿨다고 끝이 아니다. 그 설정을 소비하는 코드가 덮어쓰고 있지 않은지 같이 봐야 한다. 4-5. maxReplicaCount=10인데 4개만 떴다 prod 큐에 메시지 100개를 넣자 KEDA는 CPU Worker 10개를 요청했다. 그런데 4개만 Running, 6개는 계속 Pending이었다. Karpenter 로그: all available instance types exceed limits for nodepool cpu-worker ScaledObject의 maxReplicaCount 는 선언상의 상한 이고, 실제 상한은 NodePool limits 였다. xlarge 노드에 DaemonSet overhead까지 올리면 노드당 Worker Pod가 1개씩만 들어갔고, NodePool 한도가 먼저 소진됐다. 2xlarge로 올려서 bin-packing을 개선하려 했다 → 조직 SCP가 2xlarge launch 자체를 차단 해서 롤백 xlarge를 유지하고 NodePool limits를 10대 수준으로 상향 → 10/10 Running maxReplicaCount × Pod request 가 NodePool limits, 허용 인스턴스 타입, DaemonSet overhead, 계정 쿼터·정책 안에 들어가는지 확인하지 않으면 그 숫자는 그냥 희망 사항이다. 4-6. 16시간 동안 ContainerCreating 이번 프로젝트에서 가장 오래 붙잡고 있었던 장애. CPU Worker Pod가 ContainerCreating 에서 16시간 정지 FailedCreatePodSandBox 이벤트 4,569회 그런데 서브넷에는 가용 IP가 112개 남아 있었다 IP가 남았는데 왜 할당이 안 됐을까. 원인은 Prefix Delegation의 단편화 였다. Prefix Delegation은 ENI에 IP를 하나씩이 아니라 /28 (16개) 블록 단위로 붙여서 노드당 Pod 밀도를 올린다. 문제는 이 블록이 연속·정렬된 16개 여야 한다는 점이다. 당시 private subnet은 AZ별 /24 하나였고, 그 안에서 노드 ENI + Pod IP + VPC Interface Endpoint ENI가 IP를 나눠 쓰고 있었다. 숫자로는 112개가 남았지만 정렬된 /28 블록은 하나도 없었다. 결과는 InsufficientCidr . 해결은 Pod 대역을 통째로 분리하는 것이었다. 노드 primary ENI → 10.20.11.0/24 (private app subnet) Pod secondary ENI → 100.64.0.0/17, 100.64.128.0/17 (AZ별 Pod 전용 subnet) VPC에 secondary CIDR 100.64.0.0/16 추가 AZ별 /17 Pod subnet 생성 (AZ당 usable 32,766개) VPC CNI Custom Networking + AZ별 ENIConfig 적용 두 기능의 역할은 다르다. Prefix Delegation은 노드당 Pod 밀도 를 올리고, Custom Networking + secondary CIDR는 Pod 대역을 분리해 단편화를 막는다. 그런데 이걸 적용하자마자 후속 장애가 두 번 왔다. 1 DNS가 안 됐다. Pod가 RDS hostname도, 클러스터 내부 DNS도 resolve하지 못했다. 라우팅과 iptables는 정상이었다. 노드에서 tcpdump 를 떠 보니 DNS query는 Pod 쪽 secondary ENI에서 나가는데, CoreDNS가 있는 노드에는 패킷이 하나도 도착하지 않았다. Custom Networking에서 Pod는 node SG를 쓰고, 관리형 노드의 CoreDNS는 cluster SG를 쓰는데 두 SG 사이에 허용 규칙이 없었던 것 이다. SG 규칙을 추가하자 Pod 재시작 없이 바로 복구됐다. 2 SQS 요청이 15초 뒤 타임아웃됐다. VPC Endpoint SG가 VPC CIDR만 허용하고 새 Pod CIDR( 100.64.0.0/16 )는 허용하지 않았다. Pod IP 대역을 바꾸는 건 쿠버네티스 매니페스트의 일이 아니다. subnet, CNI 환경 변수, ENIConfig, node SG, cluster SG, Endpoint SG, route table이 하나의 전송 경로 로 묶여 있고, 그중 하나만 빠져도 조용히 끊긴다. 4-7. 모니터링이 자기가 감시하던 노드 정리에 같이 쓸려 나갔다 부하 시험 결과를 Grafana로 보는데 그래프가 5분간 비어 있었다. scale-in 과정에서 Karpenter가 노드를 정리할 때 Prometheus와 Grafana도 같이 축출된 것이다. replica 1, PDB 없음, taint 없는 일반 노드에 배치돼 있었다. system 노드로 고정하고 PDB를 추가했다. 그런데 Prometheus PDB는 여전히 적용되지 않았는데, Helm values에서 PDB 설정을 prometheusSpec 하위에 넣어 조용히 무시 되고 있었기 때문이다. 위치를 고친 뒤 재시험에서 재시작 0회, 결측 0분을 확인했다. 관측 도구도 워크로드다. 그리고 "Helm release 성공"과 "설정이 실제로 적용됨"은 다른 사실이다. 5. 실측 결과 5-1. prod scale-out Worker 변화 투입량 전체 Ready까지 CPU 1 → 10 메시지 100개 168초 (재현 시 212초) GPU 0 → 4 메시지 5개 306초 Batch 0 → 5 메시지 20개 347초 GPU 306초를 뜯어보면 Pod 생성 +50초, 노드 생성 시작 +76초, 첫 Pod Ready +265초다. 노드가 Ready된 뒤 Pod가 Ready되기까지가 가장 길었다 (약 3분). GPU 드라이버 초기화, 큰 이미지 pull, 모델 로딩이 겹치는 구간이다. scale-to-zero는 공짜가 아니라, 유휴 비용을 첫 요청의 5분과 맞바꾼 결정이다. 이 cold start를 줄이려고 EFS에 모델을 캐싱하는 구조를 실제로 구현했다가, 결국 전부 롤백하고 모델을 컨테이너 이미지에 내장 하는 쪽으로 정리했다. 구현까지 한 걸 걷어내는 건 아깝지만, 공유 볼륨이라는 장애 지점을 하나 더 운영하는 것보다 단순함을 택했다. 5-2. 우연히 관찰한 Spot 중단 Batch 부하 시험 도중 실제 Spot interruption이 한 건
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
AWS cloud school 최종 프로젝트. 최종 프로젝트 회고 사실 이미 7월에 프로젝트를 마무리하고 포트폴리오도 완성을 했지만, 취준을 핑계로 내팽겨쳤던 프로젝트에 대한 회고를 이제야 해보려한다. Full Architecuter UtterAI — EKS 클러스터 편 언어재활사(SLP)가 상담 음성을 올리면 STT·화자분리 → 언어 지표 계산 → 리포트 생성까지 처리하는 AI 서비스의 인프라를, EKS 위에서 어떻게 설계하고 무엇을 깨뜨려 가며 고쳤는지 정리한다. EKS cluster 들어가며 이 글은 "EKS를 이렇게 구성했다"는 소개보다 " 이렇게 구성했더니 이게 터졌고, 이렇게 고쳤다 "에 가깝다. 아키텍처 문서는 결과만 남지만, 회고에는 그 결과에 도달하기까지 틀렸던 판단이 남아야 한다고…
Открыть источник