devlos
Devlos Archive
devlos
전체 방문자
오늘
어제
10-13 00:01

최근 글

  • 분류 전체보기 (131)
    • 프로젝트 (1)
    • MSA 설계 & 도메인주도 설계 (9)
    • AI (15)
      • [LLMSO] Hands-On LLM 서빙과 최적.. (15)
    • 클라우드 컴퓨팅 & NoSQL (96)
      • [K8S Deploy] K8S 디플로이 스터디 (8)
      • [Cilium Study] 실리움 스터디 (8)
      • [KANS] 쿠버네티스 네트워크 심화 스터디 (12)
      • [T101] 테라폼 4기 스터디 (8)
      • [CICD] CICD 맛보기 스터디 (3)
      • [T101] 테라폼 기초 입문 스터디 (6)
      • [AEWS] Amazon EKS 워크숍 스터디 (7)
      • [PKOS] 쿠버네티스 실무 실습 스터디 (7)
      • Kubernetes (13)
      • Docker (7)
      • Redis (1)
      • Jenkins (3)
      • Terraform (1)
      • Ansible (4)
      • Kafka (1)
    • 프로그래밍 (7)
      • Spring Boot (5)
      • Broker (1)
    • 성능과 튜닝 (1)
    • ALM (0)
    • 기타 (2)

인기 글

태그

  • Kubernetes
  • LLM 서빙
  • docker
  • kOps
  • 쿠버네티스 스터디
  • 데브옵스
  • 도커
  • K8s
  • MSA
  • DevOps
  • t101 4기
  • LLMSO
  • ansible
  • 쿠버네티스
  • CloudNet@

티스토리

최근 댓글

hELLO · Designed By 정상우.
devlos

Devlos Archive

[6주차-LLMSO] Scaling LLM Inference with vLLM and AWS Trainium (2026.09.06)
AI/[LLMSO] Hands-On LLM 서빙과 최적화 스터디

[6주차-LLMSO] Scaling LLM Inference with vLLM and AWS Trainium (2026.09.06)

2026. 9. 13. 01:32
반응형
[6W-1] AWS Trainium 위에서 vLLM 돌리기 — LLMSO 6주차
6W-1 · AWS Trainium 워크샵 들어가며 1 AWS Neuron 2 워크샵의 지도 3 클러스터 세우기 4 NVIDIA vs Neuron 5 vLLM 올리기 6 바깥에서 부르기 7 보이게 만들기 8 성능 재기 9 늘리기 10 치우기 마치며
LLMSO 6주차 · [6W-1] Scaling LLM Inference with vLLM and AWS Trainium

AWS 위 CUDA가 없는 환경에서
vLLM을 띄운다.

이번 주는 책이 아니라 손입니다. NVIDIA GPU가 한 장도 없는 EKS 클러스터에 AWS가 직접 설계한 칩을 꽂고, 그 위에 vLLM을 올려 OpenAI 호환 API까지 뽑아냅니다. 같은 vLLM인데 밑바닥이 통째로 다릅니다.

들어가며

읽는 시간 35분 대상 vLLM은 써봤고 쿠버네티스도 아는 사람 사전 지식 파드·서비스·헬름이라는 말을 아는 정도 그림 8개 + 원문 도식 8개 + 실습 화면 10장

여기까지 다섯 주 동안 다룬 것은 전부 NVIDIA GPU 위의 이야기였습니다. KV 캐시도, PagedAttention도, 텐서 병렬도, 프로파일링도 CUDA라는 바닥을 깔고 서 있었습니다. 이번 워크샵은 그 바닥을 걷어냅니다.

AWS가 자체 설계한 Trainium 칩을 꽂은 EKS 노드 한 대에 TinyLlama-1.1B-Chat-v1.0을 올리고, Ingress·모니터링·부하 테스트·오토스케일링까지 엔드투엔드로 돌려봅니다. 위층은 우리가 아는 vLLM 그대로인데, 바닥에서 무슨 일이 벌어지는지가 완전히 다릅니다.

결론부터 세 줄
  1. vLLM은 그대로입니다. --tensor-parallel-size 2도, OpenAI 호환 API도, continuous batching도 똑같이 씁니다. 바뀌는 건 --device=neuron 한 줄과 그 아래 스택 전체입니다.
  2. Neuron은 컨테이너 런타임에 손대지 않습니다. NVIDIA가 전용 런타임으로 드라이버 라이브러리를 매번 주입해야 하는 것과 달리, Neuron은 표준 runc가 디바이스 노드만 마운트하면 끝입니다. 4장이 이 차이를 다룹니다.
  3. 제일 오래 걸리는 건 추론이 아니라 컴파일입니다. 첫 기동에 8분, 그중 4분이 모델 컴파일입니다. 이 결과를 S3에 캐시해 두면 다음 기동은 20초입니다.

접는 상자(▸)는 안 열어도 됩니다. 안에는 실제 명령어, 매니페스트, 원문 출력이 들어 있습니다. 본문만 읽어도 흐름은 끊기지 않습니다.

그림은 만져보라고 넣었습니다. 슬라이더를 극단까지 밀면 각 구성이 언제 무너지는지가 보입니다. 노란 상자에 "해볼 것"이 적혀 있습니다.

이 페이지의 장 번호는 워크샵의 Lab 번호와 다릅니다. Lab을 가리킬 때는 "Lab 2"처럼 그대로 적었습니다.

"실습 결과"라고 붙은 화면은 실제로 돌려서 찍은 것입니다. 그림(모형)이 말한 것이 진짜 그렇게 나오는지 눈으로 확인하는 자리입니다. 계정 ID·세션 ID·엔드포인트 주소는 가렸습니다.

다루는 것

Neuron이라는 소프트웨어 스택이 CUDA 자리에 무엇을 놓는지(1장), 워크샵이 어떤 조각들로 이루어져 있는지(2장), EKS 클러스터에 Trainium을 붙이는 과정(3장), 그리고 왜 Neuron에는 전용 컨테이너 런타임이 없는지(4장)를 봅니다. 이어서 vLLM 배포(5장) · Ingress(6장) · 관측(7장) · 성능 측정(8장) · 오토스케일링(9장) · 정리(10장) 순서로 워크샵을 따라갑니다.

다루지 않는 것

Neuron 컴파일러 내부와 NKI 커널 작성법은 범위 밖입니다. Trainium2·Trn2 UltraServer는 이름만 언급합니다 — 실습 환경이 1세대 Trn1이기 때문입니다. 학습(training) 워크로드도 다루지 않습니다. 이름은 Trainium이지만 여기서는 추론에만 씁니다.

먼저 말해둘 것 — 원문에 실제 토큰이 들어 있습니다

스터디 원문의 사전 준비 항목에 workshop/.env의 내용이 실제 Hugging Face 토큰 값 그대로 붙어 있습니다. 이 페이지에는 옮기지 않았고, 전부 hf_****로 가렸습니다. 원문을 공개된 곳에 올릴 계획이라면 해당 토큰을 먼저 폐기(revoke)하시길 권합니다. Grafana·MinIO 비밀번호도 같은 이유로 이 페이지에서는 값을 적지 않았습니다.

이 워크샵을 읽는 한 가지 각도

모든 장에서 같은 질문을 던져 보세요. "이 일을 누가 하는가?"
모델을 컴파일하는 건 init 컨테이너, 어떤 코어를 줄지 정하는 건 device plugin, 실제로 디바이스를 마운트하는 건 runc, 어느 노드에 앉힐지 정하는 건 커스텀 스케줄러입니다. NVIDIA 환경과 비교하면 같은 일을 다른 계층이 맡고 있다는 게 계속 보입니다.

먼저 이름부터. Neuron, Inferentia, Trainium, NeuronCore가 각각 무엇인지 정리하고 시작합니다.

01AWS Neuron — CUDA 자리에 무엇이 들어가나

한 줄 요약: Neuron은 SDK(소프트웨어)고, Inferentia·Trainium은 칩이고, NeuronCore는 칩 안의 연산 코어입니다.

왜 이걸 배우나
"Trainium은 학습용이라며? 그런데 왜 추론 워크샵에 쓰지?"

이름이 헷갈리게 지어져 있습니다. Inferentia는 추론용, Trainium은 학습용으로 설계 목적이 나뉘어 있지만, Trainium 칩으로 추론을 돌리는 것도 얼마든지 가능합니다. 실제로 이 워크샵이 그렇게 합니다.

더 중요한 건 계층을 헷갈리면 문서를 못 읽는다는 점입니다. "Neuron이 지원한다"는 말이 SDK 이야기인지 칩 이야기인지에 따라 뜻이 달라집니다. 파드가 요청하는 자원 이름도 aws.amazon.com/neuron(칩)과 neuroncore(코어)가 다릅니다.

이 장에 나오는 낯선 말
AWS Neuron
AWS 자체 설계 가속기를 위한 소프트웨어 스택 전체. 컴파일러(neuronx-cc), 런타임, 프레임워크 통합(torch-neuronx), 프로파일러를 포함합니다. CUDA에 해당하는 자리입니다.
Inferentia / Trainium
AWS가 설계한 가속기 칩. Inferentia는 추론용(Inf1/Inf2), Trainium은 학습용(Trn1/Trn2)으로 설계됐지만 경계는 고정적이지 않습니다.
NeuronCore
칩 안의 실제 연산 코어 단위. NVIDIA GPU의 SM(Streaming Multiprocessor)에 해당하는 개념입니다. Trainium1 칩 하나에 NeuronCore-v2가 2개 들어 있습니다.
NeuronLink
칩과 칩 사이를 잇는 전용 인터커넥트. AllReduce·AllGather 같은 집합 통신을 빠르게 만들어 텐서 병렬을 가능하게 합니다. NVLink에 해당합니다.
NxD Inference
NeuronX Distributed Inference. 모델을 여러 NeuronCore에 쪼개서 올리는 라이브러리. vLLM이 이 라이브러리를 통해 Neuron 위에서 돕니다.
NKI
Neuron Kernel Interface. Neuron 명령어 집합에 직접 접근하는 저수준 API. CUDA 커널을 직접 짜는 것에 해당합니다.
DLAMI / DLC
Deep Learning AMI / Deep Learning Container. Neuron SDK가 미리 깔려 있는 EC2 이미지와 컨테이너 이미지입니다.

비유 — 공장, 기계, 작업대

제조 공장으로 바꿔 보면 계층이 분명해집니다.

Neuron SDK는 공장의 운영 체계입니다. 설계도(모델)를 받아 기계가 이해하는 작업 지시서로 바꾸고(컴파일러), 작업을 배분하고(런타임), 어디서 시간이 새는지 봅니다(프로파일러).

Trainium 칩은 기계 한 대입니다. 기계 안에는 작업대(NeuronCore)가 두 개 있고, 각 작업대에는 용도가 다른 도구(텐서·벡터·스칼라·GPSIMD 엔진)와 손 닿는 곳의 부품함(온칩 SRAM)이 있습니다.

공장 하나에 기계를 여러 대 놓을 수 있습니다. trn1.2xlarge는 기계 한 대(작업대 2개), trn1.32xlarge는 기계 열여섯 대(작업대 32개)입니다. 기계끼리 반제품을 주고받는 컨베이어가 NeuronLink입니다.

작은 숫자로 한 번

# 이번 실습 인스턴스: trn1.2xlarge Trainium 칩 1개 └ NeuronCore-v2 2개 ← vLLM의 --tensor-parallel-size 2 가 여기서 나온다 HBM 32 GiB @ 820 GiB/s 연산(칩 2코어 합계) 380 INT8 TOPS / 190 BF16 TFLOPS / 47.5 FP32 TFLOPS vCPU 8 ← 워크샵 계정의 vCPU 쿼터도 8. 그래서 노드는 1대뿐 (9장) # 큰 형: trn1.32xlarge Trainium 칩 16개 └ NeuronCore-v2 32개 ← AWS 문서의 --tensor-parallel-size 32 예시가 이 인스턴스 기준 HBM 512 GiB 칩 간 연결 NeuronLink-v2 # TinyLlama-1.1B를 BF16으로 올리면 가중치 1.1B × 2바이트 = 약 2.2 GB → 32 GiB 안에 여유롭게 들어간다
숫자를 읽을 때 주의

380 TOPS · 190 TFLOPS는 칩 하나(NeuronCore 2개 합계) 기준입니다. 코어 하나당이 아닙니다. HBM 32 GiB도 칩 단위로 공유됩니다 — 코어마다 16 GiB씩 따로 있는 게 아니라, 두 코어가 같은 32 GiB를 나눠 씁니다.

실습 결과 위 숫자를 neuron-top으로 직접 확인한 화면
neuron-top 화면. 타이틀에 trn1.2xlarge, NeuronCore v2 Utilization 아래에 ND0 한 줄과 NC0, NC1 두 개의 막대, 사용률은 모두 0.00%, Device Used Memory 는 0.0B
클러스터를 세운 직후, 아직 아무 모델도 안 올린 상태입니다. ND0(Neuron Device 0 = Trainium 칩 1개) 한 줄 아래에 NC0 · NC1 두 개의 막대가 있습니다 — 표에 적은 "칩 1개 = NeuronCore 2개"가 이것입니다. --tensor-parallel-size=2라는 숫자가 어디서 나왔는지 이 화면이 답입니다. 그리고 Device Used Memory가 0.0B입니다. 5장에서 모델을 올리고 나면 이 자리가 3.6GB로 바뀌고, 그것이 NC0·NC1에 반씩 나뉩니다. 비어 있는 지금 화면을 기억해 두면 그때 비교가 쉽습니다. (계정·세션 정보는 가렸습니다.)
원문 도식 재현

도식 1-1. Neuron SDK · 칩 · NeuronCore의 관계

이 그림이 말하는 것

원문의 텍스트 트리를 그림으로 옮긴 것입니다. 핵심은 SDK 하나가 두 종류의 칩을 모두 상대한다는 점, 그리고 칩과 코어는 다른 층위라는 점입니다. 파드가 자원을 요청할 때 aws.amazon.com/neuron(칩 단위)과 aws.amazon.com/neuroncore(코어 단위) 중 무엇을 쓰느냐가 여기서 갈립니다.

그림 1

인스턴스를 키우면 무엇이 늘어나나

↔ 슬라이더
해볼 것 — ① 칩 수를 1로 두면 trn1.2xlarge(이번 실습 환경)입니다. 코어가 2개뿐인 게 보입니다. ② 칩 수를 16까지 밀면 코어가 32개가 되고 HBM이 512 GiB까지 늘어납니다. ③ 칩이 2개 이상일 때만 NeuronLink 선이 그려집니다 — 칩을 넘어가는 텐서 병렬은 이 선을 타야 하고, 그래서 같은 칩 안의 2코어 병렬보다 느립니다.
실제 인스턴스

칩 1개 = trn1.2xlarge, 16개 = trn1.32xlarge입니다. 중간값들은 실제로 판매되는 인스턴스가 아니라 비례 관계를 보기 위한 값입니다.

그림에 나오는 말 HBM High Bandwidth Memory — 칩에 붙은 고대역폭 메모리. GPU의 VRAM에 해당 · TOPS · TFLOPS 초당 연산 횟수(조 단위). INT8은 TOPS, 부동소수점은 TFLOPS로 셈 · NeuronLink 칩과 칩을 잇는 전용 연결선. 칩을 넘는 병렬 연산에 쓰임 · NeuronCore 칩 안의 연산 코어. Trainium1은 칩당 2개
원문 도식 재현

도식 1-2. NeuronCore-v2 하나의 내부

이 그림이 말하는 것

NeuronCore-v2는 서로 다른 엔진 네 개가 붙어 있는 이기종 유닛입니다. 행렬 연산은 텐서 엔진이, 요소별 연산은 벡터 엔진이, 제어 흐름은 스칼라 엔진이 맡습니다. GPU에서 SM 하나가 모든 일을 하는 것과 대비되는 설계입니다.

그리고 온칩 SRAM이 컴파일러 관리라는 점이 중요합니다. GPU의 L1/L2 캐시는 하드웨어가 알아서 채우지만, 여기서는 컴파일 시점에 무엇을 언제 올릴지 정해집니다. 5장에서 "왜 모델을 미리 컴파일해야 하는가"의 답이 여기 있습니다.

모델을 올리는 두 갈래 길

Neuron 위에서 추론을 돌리는 방법은 두 가지입니다. 갈림길은 "이 모델을 vLLM이 지원하는가" 하나뿐입니다.

원문 도식 재현

도식 1-3. 추론 배포 방법 고르기

이 그림이 말하는 것

이번 워크샵은 왼쪽 길입니다. NxD Inference + vLLM 플러그인 0.5.x가 Inf2·Trn1·Trn2를 모두 지원합니다. 오른쪽의 vLLM Neuron 플러그인(베타)은 NxD에 의존하지 않고 모델 구현을 플러그인 안에 직접 갖고 있는데, 아직 Trn2/Trn3 전용이라 Trn1인 이번 실습에서는 쓸 수 없습니다.

두 경로의 설치 명령과 지원 범위
항목NxD Inference + vLLM (권장)vLLM Neuron 플러그인 (베타)
지원 인스턴스Inf2, Trn1, Trn2Trn2, Trn3만
플러그인 버전0.5.x (vLLM 0.16)release-0.24.0.x (vLLM 0.24.0)
모델 실행neuronx-distributed-inference 라이브러리에 위임플러그인이 모델 구현을 직접 보유
고급 기능연속 배칭, 접두사 캐싱, 투기적 디코딩(Eagle V1), INT8/FP8 양자화, 멀티모달분산 추론, 분할 프리필, EAGLE3 투기적 디코딩

경로 1 — NxD Inference + vLLM (이번 워크샵)

git clone --branch "0.5.3" https://github.com/vllm-project/vllm-neuron.git cd vllm-neuron pip install --extra-index-url=https://pip.repos.neuron.amazonaws.com -e . # Start the vLLM server vllm serve meta-llama/Meta-Llama-3-8B-Instruct \ --tensor-parallel-size 32 \ --max-num-seqs 4 \ --max-model-len 128 \ --block-size 32 \ --num-gpu-blocks-override 256

경로 2 — vLLM Neuron 플러그인 (Trn2/Trn3 전용, 베타)

git clone -b release-0.24.0.1.1.0 https://github.com/vllm-project/vllm-neuron.git cd vllm-neuron pip install --extra-index-url=https://pip.repos.neuron.amazonaws.com -e . vllm serve openai/gpt-oss-20b \ --tensor-parallel-size 8 \ --max-model-len 8192 \ --max-num-batched-tokens 4096 \ --max-num-seqs 4

NxD Inference를 직접 설치할 때

source aws_neuron_venv_pytorch/bin/activate pip install -U pip pip install --upgrade neuronx-cc==2.* neuronx-distributed-inference --extra-index-url https://pip.repos.neuron.amazonaws.com

설치 확인

inference_demo --help
1장 세 줄 요약
  1. 정의 — Neuron은 소프트웨어 스택(CUDA 자리), Inferentia·Trainium은 칩, NeuronCore는 칩 안의 코어(SM 자리)입니다. 세 층위를 섞어 읽으면 문서가 안 읽힙니다.
  2. 성질 — Trainium1 칩 1개 = NeuronCore-v2 2개 + HBM 32 GiB이고, 이 2가 실습의 --tensor-parallel-size 2로 그대로 이어집니다. 온칩 SRAM이 컴파일러 관리라는 점이 5장의 "미리 컴파일" 요구로 연결됩니다.
  3. 쓰임 — vLLM이 지원하는 모델이면 NxD Inference + vLLM 플러그인 0.5.x가 정답입니다. 베타 플러그인은 Trn2 이상 전용이라 Trn1에서는 선택지가 아닙니다.

스택을 알았으니 이제 워크샵 전체를 한 장에 펼칩니다. 무엇이 어디에 올라가는지.

02워크샵의 지도 — 무엇을 어디에 올리나

한 줄 요약: 개발용 EC2 한 대에서 EKS를 조작하고, Trainium 노드 한 대 위에 vLLM 파드 하나를 띄웁니다.

왜 이걸 배우나
"명령어를 순서대로 치기는 했는데, 지금 내가 어디에 뭘 만든 건지 모르겠다."

워크샵 실습의 함정이 이겁니다. kubectl apply를 스무 번쯤 하고 나면 지금 조작하는 터미널이 어디에 붙어 있는지부터 헷갈립니다.

이 워크샵에는 컴퓨터가 세 층 있습니다. 내 노트북(SSH 클라이언트), 개발용 EC2(t3.2xlarge, 여기서 kubectl을 침), Trainium 워커 노드(trn1.2xlarge, 실제로 모델이 도는 곳)입니다. 이 셋을 구분해 두면 뒤에 나오는 명령어들이 "어디서 실행하는 것인지" 헷갈리지 않습니다.

이 장에 나오는 낯선 말
EKS
Amazon Elastic Kubernetes Service. 컨트롤 플레인을 AWS가 대신 운영해 주는 관리형 쿠버네티스입니다.
managed node group
EKS가 수명주기를 관리해 주는 워커 노드 묶음. 이 워크샵의 이름은 neuron-trn1-2x입니다.
device plugin
쿠버네티스가 모르는 하드웨어를 "이 노드에 몇 개 있다"고 대신 알려주는 데몬셋. 4장에서 자세히 봅니다.
scheduler extension
기본 스케줄러가 못 하는 판단을 대신 해주는 보조 스케줄러. 워크샵에서는 my-scheduler라는 이름으로 뜹니다.
CSI driver
Container Storage Interface 구현체. 여기서는 S3 버킷을 파일시스템처럼 마운트해 주는 Mountpoint 드라이버입니다.
OIDC provider
파드에 IAM 역할을 직접 붙일 수 있게 해주는 신원 연동 설정. EKS 클러스터에 켜 둡니다.

비유 — 작업장, 창고, 그리고 접수대

공방을 차린다고 생각해 보세요.

개발용 EC2는 사무실입니다. 여기서 도면을 그리고 지시를 내리지만, 정작 물건은 여기서 안 만듭니다. Trainium 노드가 작업장입니다. 기계가 있고 실제 생산이 일어나는 곳입니다. S3 버킷은 창고입니다. 한 번 만든 반제품(컴파일된 모델)을 보관해 뒀다가 다음에 꺼내 씁니다.

NGINX Ingress는 접수대입니다. 바깥에서 온 손님을 받아 작업장 안쪽으로 안내합니다. 손님은 작업장 위치를 몰라도 됩니다.

작은 숫자로 한 번

# 계층 1 — 인프라 개발용 EC2 t3.2xlarge, Ubuntu 22.04 VPC 10.0.0.0/16 + public subnet 10.0.1.0/24 보안 그룹 22(SSH) / 8000 / 8080 # 계층 2 — EKS 클러스터 버전 Kubernetes 1.33 노드그룹 neuron-trn1-2x → trn1.2xlarge × 1대 AMI /aws/service/eks/optimized-ami/1.33/amazon-linux-2023/x86_64/neuron/recommended # 계층 3 — Trainium 통합 neuron-device-plugin DaemonSet → aws.amazon.com/neuron: 1 을 광고 k8s-neuron-scheduler Deployment → 코어 배치 판단 my-scheduler Deployment → 위 확장을 물고 있는 두 번째 스케줄러 # 계층 4 — vLLM 이미지 public.ecr.aws/neuron/pytorch-inference-vllm-neuronx:0.9.1-...-sdk2.25.0 7.9 GiB 모델 TinyLlama-1.1B-Chat-v1.0 TP=2, max-num-seqs=4, max-model-len=1024
원문 도식 재현

도식 2-1. 워크샵 전체 아키텍처

이 그림이 말하는 것

화살표 방향을 따라가 보면 제어 경로와 데이터 경로가 다르다는 게 보입니다. 내가 치는 kubectl은 개발용 EC2 → EKS 컨트롤 플레인으로 가고, 실제 추론 요청은 인터넷 → CLB → Ingress → Service → 파드로 갑니다. 모델 아티팩트는 세 번째 경로 — 파드 ↔ S3 — 로 따로 움직입니다.

노드가 한 대뿐이라는 점도 그림에 그대로 나옵니다. 9장의 HPA 실습이 막히는 이유입니다.

사전 준비 — 접속까지

워크샵에 참가하면 AWS 콘솔 접근 권한과 SSH 키를 받습니다. 리전은 us-west-2(오레곤) 또는 us-east-1(버지니아)입니다.

내려받은 키의 권한을 좁힌다 — 안 하면 ssh가 거부한다

chmod 400 "ws-default-keypair.pem"

개발용 EC2 접속과 환경 확인

# MYEC2IP=54.201.111.237 ssh -i ws-default-keypair.pem ubuntu@$MYEC2IP # whoami hostnamectl ... Hardware Model: t3.2xlarge htop df -hT ip -c addr docker info # sudo apt update && sudo apt install tree jq -y tree -a workshop/ workshop/ └── .env cat workshop/.env HF_TOKEN="hf_****"
토큰 취급

원문에는 cat workshop/.env 출력이 실제 값 그대로 남아 있습니다. 이 페이지에서는 hf_****로 가렸습니다. Hugging Face 토큰은 발급받은 본인 것을 쓰고, 공개 저장소·블로그에 옮길 때는 반드시 가립니다.

2장 세 줄 요약
  1. 정의 — 워크샵은 네 계층입니다. 인프라(EC2·VPC) → EKS(1.33 + Neuron AMI 노드그룹) → Trainium 통합(device plugin + 스케줄러 확장) → vLLM(이미지 + 모델 + TP=2).
  2. 성질 — 컴퓨터가 세 대라는 걸 계속 의식해야 합니다. 내 노트북(SSH) · 개발용 EC2(kubectl을 치는 곳) · Trainium 노드(모델이 도는 곳)는 서로 다른 기계입니다.
  3. 쓰임 — 제어 경로(kubectl)·데이터 경로(추론 요청)·아티팩트 경로(S3)가 각각 다릅니다. 문제가 생겼을 때 어느 경로가 끊겼는지부터 나누면 디버깅이 빨라집니다.

지도를 봤으니 이제 세웁니다. 컨트롤 플레인은 이미 있고, 우리가 할 일은 Trainium 노드를 붙이는 것입니다.

03클러스터 세우기 — Lab 1

한 줄 요약: 노드그룹을 붙이고, 이미 깔린 device plugin을 지운 뒤 Helm으로 다시 깔고, 스케줄러 확장과 S3 CSI를 얹습니다.

왜 이걸 배우나
"device plugin이 이미 깔려 있다는데, 왜 지우고 다시 깔라는 거지?"

워크샵에서 가장 이해가 안 가는 단계가 이겁니다. Neuron 최적화 AMI를 쓰면 노드가 부팅할 때 neuron-device-plugin이 자동으로 배포됩니다. 그런데 다음 단계에서 Helm으로 같은 이름의 데몬셋·클러스터롤·서비스어카운트를 또 설치하라고 합니다.

이건 Helm의 소유권 문제입니다. 자동 설치된 리소스에는 meta.helm.sh/release-name 같은 소유권 어노테이션이 없습니다. Helm은 "이미 있는데 내가 관리해도 되는 건가?"를 판단하지 못하고 AlreadyExists로 실패합니다. 게다가 이후에 scheduler.enabled=true 같은 옵션을 붙여 업그레이드하려면 구성이 달라 어차피 새로 깔아야 합니다. 지우고 다시 까는 건 우회가 아니라 정리입니다.

이 장에 나오는 낯선 말
eksctl
EKS 클러스터·노드그룹을 YAML 한 장으로 만들어 주는 CLI. 내부적으로는 CloudFormation 스택을 만듭니다.
Neuron optimized AMI
Neuron 드라이버와 device plugin이 미리 들어 있는 EKS 워커 노드 이미지. SSM 파라미터로 최신 ID를 조회합니다.
taint / label
노드에 붙이는 표식. label은 "이런 노드다"라는 정보, taint는 "특별히 허락받은 파드만 오라"는 제한입니다.
Helm 소유권 어노테이션
meta.helm.sh/release-name 등. Helm이 "이건 내가 만든 것"이라고 표시해 두는 메타데이터입니다.
Mountpoint for S3
S3 버킷을 파일시스템처럼 보이게 해주는 AWS 공식 FUSE 클라이언트. Rust로 작성돼 있습니다.
neuron-ls / neuron-top
노드에서 Neuron 디바이스 목록과 사용률을 보는 명령어. nvidia-smi·nvtop에 해당합니다.

비유 — 이사 온 집의 붙박이 가구

새 집에 들어갔더니 붙박이장이 이미 달려 있습니다. 편해 보이지만, 내가 산 시스템 가구를 넣으려니 같은 자리를 차지하고 있어서 들어가지 않습니다. 기존 것을 떼어내고 내 것을 다시 다는 게 빠릅니다.

그리고 나중에 선반을 하나 더 달고 싶어질 때(scheduler.enabled=true), 붙박이는 손댈 수 없지만 내가 단 것은 마음대로 바꿀 수 있습니다. Helm으로 다시 까는 이유가 정확히 이겁니다 — 지금 편해지자고 하는 게 아니라 나중에 바꿀 수 있게 하려는 것입니다.

작은 숫자로 한 번

# 노드가 붙은 뒤 확인 kubectl describe nodes -l alpha.eksctl.io/nodegroup-name=neuron-trn1-2x ... Capacity: aws.amazon.com/neuron: 1 ← 칩 1개 aws.amazon.com/neuroncore: 2 ← 코어 2개 cpu: 8 Allocatable: aws.amazon.com/neuron: 1 # 노드에 실제로 생긴 디바이스 파일 /dev/neuron0 ← 칩 전체 /dev/neuron0/ng0n1 ← 코어 단위 노드. 이게 있어서 런타임 개입이 필요 없다 (4장)
그림 2

모델이 코어에 들어가는가 — 텐서 병렬이 필요한 지점

↔ 슬라이더
해볼 것 — ① 기본값은 TinyLlama 1.1B / BF16 / TP=2입니다. 여유가 한참 남습니다. ② 파라미터를 8B로 올리고 정밀도를 FP32로 바꿔 보세요 — 32 GB가 필요해져서 trn1.2xlarge의 HBM을 거의 다 먹습니다. KV 캐시 자리가 안 남습니다. ③ 그 상태에서 칩 수를 2로 올리면 들어갑니다 — 다만 이제 NeuronLink를 타는 통신이 생깁니다.
대략적인 수치입니다

가중치만 셈한 값입니다. 실제로는 KV 캐시·활성화·런타임 오버헤드가 더 붙어서 HBM의 70~80%쯤에서 이미 빡빡해집니다. 그림의 주황 경고선이 그 지점입니다.

그림에 나오는 말 TP (tensor parallelism) 가중치 행렬을 여러 코어에 쪼개 나눠 계산하고 결과를 합치는 방식 · BF16 · FP8 · FP32 숫자 하나를 몇 바이트로 저장하느냐. 각각 2 · 1 · 4바이트 · HBM 칩에 붙은 메모리. Trainium1은 칩당 32 GiB · KV 캐시 이미 만든 토큰의 중간 결과 저장소. 가중치와 같은 HBM을 나눠 씀

스케줄러가 하나 더 필요한 이유

노드에 코어가 여러 개 있을 때, 기본 스케줄러는 "코어 2개 남았네"까지만 압니다. 그런데 텐서 병렬은 같은 칩 안의 이웃한 코어여야 NeuronLink 없이 빠르게 돕니다. 칩 A의 코어 하나와 칩 B의 코어 하나가 남았다면, 숫자로는 2개지만 TP=2 파드를 올리기엔 나쁜 조합입니다.

이 판단을 대신 해주는 게 k8s-neuron-scheduler이고, 그걸 확장으로 물고 있는 두 번째 스케줄러가 my-scheduler입니다. 파드는 schedulerName: my-scheduler로 이 스케줄러를 지정합니다.

그림 3

코어가 흩어져 있으면 생기는 일

클릭 + 슬라이더
해볼 것 — ① 기본 상태가 이미 조각난 상황입니다. 코어는 2개 남았는데 칩 0과 칩 1에 하나씩 흩어져 있어서, 기본 스케줄러는 "2개 남았다"며 배치를 시도하고 Neuron 스케줄러는 거부합니다. ② 요청 코어 수를 1로 내리면 둘 다 성공합니다 — 이 문제는 TP를 쓸 때만 생깁니다. ③ 코어 칸을 클릭해서 점유를 풀거나 걸어 보세요. 같은 칩의 코어 2개가 함께 비면 다시 성공합니다.
단순화한 모형입니다

실제 Neuron 스케줄러 확장은 이보다 세밀한 규칙을 씁니다. 여기서는 "연속한 코어여야 한다"는 한 가지 조건만 보여줍니다. 이번 실습 환경은 칩이 1개뿐이라 이 상황 자체가 생기지 않습니다.

그림에 나오는 말 스케줄러 파드를 어느 노드·어느 자원에 앉힐지 정하는 쿠버네티스 구성요소 · 스케줄러 확장 기본 스케줄러가 못 하는 판단을 대신하는 보조 서비스 · 연속 코어 같은 칩 안에서 붙어 있는 코어들. 칩을 넘으면 NeuronLink 통신이 생김 · NEURON_RT_VISIBLE_CORES 파드에 어떤 코어를 보여줄지 지정하는 환경변수(실습에서는 0-1)
Lab 1의 실제 순서 — 명령어 9단계
  1. 도구 설치AWS CLI v2, kubectl, eksctl, helm, jq. 개발용 EC2에서 한 번만.
  2. 환경 변수와 AMI 조회리전·클러스터명·EKS 버전·인스턴스 타입을 잡고, SSM에서 Neuron 최적화 AMI ID를 조회합니다.
  3. 클러스터 접근 설정aws eks update-kubeconfig로 kubeconfig를 받고 kubectl cluster-info로 확인합니다.
  4. (옵션) 노드 접속용 SSH 키 생성ssh-keygen으로 만든 공개키를 노드그룹 설정에 넣습니다.
  5. 네트워킹 확인VPC ID와 퍼블릭 라우트 테이블을 찾고, trn1.2xlarge를 지원하는 AZ의 퍼블릭 서브넷 두 개를 고릅니다.
  6. 노드그룹 생성eks_nodegroup.yaml을 만들고 eksctl create nodegroup. 3~4분 걸립니다.
  7. S3 버킷 생성컴파일 캐시용 버킷 ai-infra-summit-vllm-models-cache-{ACCOUNT_ID}.
  8. device plugin 재설치 + 스케줄러 확장기존 리소스 4종을 지운 뒤 Helm으로 재설치합니다.
  9. S3 CSI 드라이버 설치Mountpoint for S3 CSI Driver를 Helm으로.

1단계 — 도구 설치

# cd workshop pwd # Update package list and install tools echo "Updating package list and installing tools..." sudo apt update sudo apt install -y python3-pip jq unzip # Install AWS CLI v2 echo "Installing AWS CLI v2..." curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" unzip awscliv2.zip sudo ./aws/install --update # Install Helm echo "Installing Helm..." curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash # Enable kubectl autocompletion for current session and add to bashrc echo "Setting up kubectl autocompletion..." source <(kubectl completion bash) && echo "source <(kubectl completion bash)" >> ~/.bashrc # Verify installations echo "Verifying installations..." aws --version helm version --short jq --version echo "kubectl autocompletion enabled!"

2단계 — 환경 변수와 Neuron 최적화 AMI 조회

export AWS_REGION=us-west-2 export CLUSTER_NAME=ai-infra-summit-test-cluster export EKS_VERSION=1.33 export INSTANCE_TYPE=trn1.2xlarge export DESIRED_NODES=1 export WORKER_AMI=$(aws ssm get-parameter \ --name /aws/service/eks/optimized-ami/1.33/amazon-linux-2023/x86_64/neuron/recommended/image_id \ --region $AWS_REGION \ --query "Parameter.Value" \ --output text) export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) export BUCKET_NAME=ai-infra-summit-vllm-models-cache-${AWS_ACCOUNT_ID} echo "$CLUSTER_NAME $WORKER_AMI $AWS_ACCOUNT_ID $BUCKET_NAME"

3단계 — 클러스터 접근 설정

# Update kubectl config to connect to the cluster aws eks update-kubeconfig --region $AWS_REGION --name $CLUSTER_NAME cat ~/.kube/config kubectl get pod -A ... kubectl cluster-info Kubernetes control plane is running at https://378B0929BA5C6D63ADD8839FF27456F4.yl4.us-west-2.eks.amazonaws.com CoreDNS is running at https://378B0929BA5C6D63ADD8839FF27456F4.yl4.us-west-2.eks.amazonaws.com/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

k9s도 깔아 두면 이후가 편하다

# k9s curl -sL https://github.com/derailed/k9s/releases/download/$(curl -s https://api.github.com/repos/derailed/k9s/releases/latest | grep tag_name | cut -d '"' -f 4)/k9s_Linux_amd64.tar.gz | sudo tar xfz - -C /usr/local/bin k9s # k9s version k9s

4단계 — (옵션) 노드 접속용 SSH 키

# Generate SSH key for node access ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N "" ls -l ~/.ssh total 12 -rw------- 1 ubuntu ubuntu 400 Sep 4 04:20 authorized_keys -rw------- 1 ubuntu ubuntu 3389 Sep 4 13:15 id_rsa -rw-r--r-- 1 ubuntu ubuntu 746 Sep 4 13:15 id_rsa.pub

5단계 — VPC · 서브넷 확인

# Get VPC and create public subnets for trn1.2xlarge instances VPC_ID=$(aws eks describe-cluster --name $CLUSTER_NAME --region $AWS_REGION --query 'cluster.resourcesVpcConfig.vpcId' --output text) PUBLIC_ROUTE_TABLE=$(aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" "Name=route.destination-cidr-block,Values=0.0.0.0/0" --query 'RouteTables[0].RouteTableId' --output text) echo "$VPC_ID $PUBLIC_ROUTE_TABLE" # Get supported AZs for instance type SUPPORTED_AZS=($(aws ec2 describe-instance-type-offerings --location-type availability-zone --filters "Name=instance-type,Values=$INSTANCE_TYPE" --query 'InstanceTypeOfferings[*].Location' --output text)) echo "SUPPORTED_AZS" # Get public subnets in supported AZs VALID_SUBNETS=() for az in "${SUPPORTED_AZS[@]}"; do subnet=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=map-public-ip-on-launch,Values=true" "Name=availability-zone,Values=$az" --query 'Subnets[0].SubnetId' --output text) [ "$subnet" != "None" ] && [ "$subnet" != "" ] && VALID_SUBNETS+=("$subnet") done echo ${VALID_SUBNETS[0]} echo ${VALID_SUBNETS[1]} # Ensure we have at least 2 subnets [ ${#VALID_SUBNETS[@]} -lt 2 ] && { echo "Error: Need at least 2 public subnets in AZs that support $INSTANCE_TYPE"; exit 1; } # Get first two valid subnets and their AZs PUBLIC_SUBNET_1=${VALID_SUBNETS[0]} PUBLIC_SUBNET_2=${VALID_SUBNETS[1]} AZ_1=$(aws ec2 describe-subnets --subnet-ids $PUBLIC_SUBNET_1 --query 'Subnets[0].AvailabilityZone' --output text) AZ_2=$(aws ec2 describe-subnets --subnet-ids $PUBLIC_SUBNET_2 --query 'Subnets[0].AvailabilityZone' --output text) echo $AZ_1 $AZ_2 echo "Using PUBLIC_SUBNET_1: $PUBLIC_SUBNET_1 in $AZ_1" echo "Using PUBLIC_SUBNET_2: $PUBLIC_SUBNET_2 in $AZ_2"

6단계 — 노드그룹 정의

cat > eks_nodegroup.yaml <<EOF apiVersion: eksctl.io/v1alpha5 kind: ClusterConfig metadata: name: $CLUSTER_NAME region: $AWS_REGION version: "$EKS_VERSION" vpc: id: $VPC_ID subnets: public: $AZ_1: { id: $PUBLIC_SUBNET_1 } $AZ_2: { id: $PUBLIC_SUBNET_2 } securityGroup: $(aws eks describe-cluster --name $CLUSTER_NAME --region $AWS_REGION --query 'cluster.resourcesVpcConfig.clusterSecurityGroupId' --output text) managedNodeGroups: - name: neuron-trn1-2x ami: $WORKER_AMI amiFamily: AmazonLinux2023 subnets: ["$PUBLIC_SUBNET_1", "$PUBLIC_SUBNET_2"] iam: attachPolicyARNs: - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore - arn:aws:iam::aws:policy/AmazonS3FullAccess - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy instanceType: $INSTANCE_TYPE desiredCapacity: $DESIRED_NODES volumeSize: 100 volumeType: gp2 ssh: allow: true publicKeyPath: ~/.ssh/id_rsa.pub EOF

노드그룹 생성 (3~4분)

# Create nodegroup and wait for completion (takes 3-4 minutes) eksctl create nodegroup --config-file=eks_nodegroup.yaml # Wait for nodes to be ready and label them kubectl wait --for=condition=Ready nodes --all --timeout=300s NODE_NAME=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}') kubectl label node $NODE_NAME alpha.eksctl.io/nodegroup-name=neuron-trn1-2x echo "Nodegroup creation completed successfully!"

생성 확인 — aws.amazon.com/neuron 이 보이면 성공

# Verify cluster status kubectl get nodes -o wide NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME ip-10-0-2-137.us-west-2.compute.internal Ready <none> 118s v1.33.13-eks-cb19647 10.0.2.137 35.95.105.64 Amazon Linux 2023.12.20260817 6.12.100-125.179.amzn2023.x86_64 containerd://2.2.5+unknown # Check node labels and taints kubectl describe nodes -l alpha.eksctl.io/nodegroup-name=neuron-trn1-2x ... Capacity: aws.amazon.com/neuron: 1 aws.amazon.com/neuroncore: 2 cpu: 8 ephemeral-storage: 104779756Ki hugepages-1Gi: 0 hugepages-2Mi: 0 memory: 32332152Ki pods: 58 ... # Verify Neuron device plugin (should be installed automatically) kubectl get pods -n kube-system | grep neuron neuron-device-plugin-nd6fn 1/1 Running 0 2m59s kubectl describe pod -n kube-system -l name=neuron-device-plugin-ds ... Volumes: device-plugin: Type: HostPath (bare host directory volume) Path: /var/lib/kubelet/device-plugins HostPathType: ... kubectl exec -it -n kube-system ds/neuron-device-plugin -- ls -l /var/lib/kubelet/device-plugins total 4 srwxr-xr-x. 1 root root 0 Sep 4 13:31 kubelet.sock -rw-------. 1 root root 146 Sep 4 13:32 kubelet_internal_checkpoint srwxr-xr-x. 1 root root 0 Sep 4 13:32 neuron-devplugin.sock srwxr-xr-x. 1 root root 0 Sep 4 13:32 neuroncore-devplugin.sock # kubectl exec -it -n kube-system ds/neuron-device-plugin -- ls -l /opt/aws kubectl exec -it -n kube-system ds/neuron-device-plugin -- ls -R -1 /opt/aws/neuron ... /opt/aws/neuron/bin: api_pb2.py api_pb2_grpc.py default-slurm-setup.sh nccom-test neuron-bench neuron-dbg neuron-dump neuron-dump.py neuron-explorer neuron-ls neuron-monitor neuron-monitor-cloudwatch.py neuron-monitor-device-view.py neuron-monitor-k8s-info.py neuron-monitor-prometheus.py neuron-monitor-top.py neuron-profile neuron-top /opt/aws/neuron/lib: libndbg.so ...

7단계 — S3 버킷 생성

# echo $BUCKET_NAME aws s3 mb "s3://$BUCKET_NAME" --region "$AWS_REGION" aws s3 ls 2026-09-04 13:47:00 ai-infra-summit-vllm-models-cache-{ACCOUNT_ID}

8단계 — device plugin을 지우고 Helm으로 재설치, 그리고 스케줄러 확장

# Clean Up Any Existing Neuron Components kubectl delete daemonset neuron-device-plugin -n kube-system kubectl delete clusterrole neuron-device-plugin kubectl delete serviceaccount neuron-device-plugin -n kube-system kubectl delete clusterrolebinding neuron-device-plugin # Deploy Neuron device plugin : Install the Neuron device plugin using Helm to enable Kubernetes to recognize and allocate Neuron devices: helm list -A helm upgrade --install neuron-helm-chart oci://public.ecr.aws/neuron/neuron-helm-chart --set "npd.enabled=false" helm list -A # Verify Neuron device plugin deployment : Check that the Neuron device plugin is running properly: # Verify the device plugin daemonset is running kubectl describe pod -n kube-system -l name=neuron-device-plugin-ds kubectl get ds neuron-device-plugin -n kube-system NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE neuron-device-plugin 1 1 1 1 1 <none> 31s # Verify that nodes have allocatable neuron cores and devices kubectl get nodes "-o=custom-columns=NAME:.metadata.name,NeuronCore:.status.allocatable.aws\.amazon\.com/neuroncore" NAME NeuronCore ip-10-0-2-137.us-west-2.compute.internal 2 # Deploy Neuron Scheduler Extension : Install the neuron-scheduler-extension to optimize scheduling of Neuron workloads: helm upgrade --install neuron-helm-chart oci://public.ecr.aws/neuron/neuron-helm-chart \ --set "scheduler.enabled=true" \ --set "npd.enabled=false" # Verify Neuron Scheduler Extension deployment : Check that the scheduler pods are running properly: # Check that my-scheduler pod and k8s-neuron-scheduler pod are in running status kubectl get pod -n kube-system -l app.kubernetes.io/component=k8s-neuron-scheduler kubectl get deploy -n kube-system k8s-neuron-scheduler NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR k8s-neuron-scheduler 1/1 1 1 2m26s neuron-scheduler-exp public.ecr.aws/neuron/neuron-scheduler:2.32.0.0 app.kubernetes.io/component=k8s-neuron-scheduler,app.kubernetes.io/instance=neuron-helm-chart,app.kubernetes.io/name=neuron-scheduler kubectl get pod -n kube-system -l app.kubernetes.io/component=my-scheduler kubectl get deploy -n kube-system my-scheduler NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR my-scheduler 1/1 1 1 117s kube-second-scheduler public.ecr.aws/eks-distro/kubernetes/kube-scheduler:v1.31.12-eks-1-31-30 app.kubernetes.io/component=my-scheduler,app.kubernetes.io/instance=neuron-helm-chart,app.kubernetes.io/name=neuron-helm-chart,tier=control-plane

9단계 — S3 CSI 드라이버

# Add the Helm repository for AWS Mountpoint S3 CSI Driver helm repo add aws-mountpoint-s3-csi-driver https://awslabs.github.io/mountpoint-s3-csi-driver helm repo update # Install the CSI driver using Helm helm upgrade --install aws-mountpoint-s3-csi-driver \ --namespace kube-system \ aws-mountpoint-s3-csi-driver/aws-mountpoint-s3-csi-driver # Verify S3 CSI Driver deployment : Check that the S3 CSI driver pods are running properly: # Verify the S3 CSI driver pods are running kubectl get pods -n kube-system -l app.kubernetes.io/name=aws-mountpoint-s3-csi-driver NAME READY STATUS RESTARTS AGE s3-csi-controller-5df587766f-wbn4l 1/1 Running 0 8s s3-csi-node-vm6sn 3/3 Running 0 8s

최종 확인

echo "=== Cluster Status ===" kubectl get nodes echo -e "\n=== Neuron Devices ===" kubectl describe nodes -l alpha.eksctl.io/nodegroup-name=neuron-trn1-2x | grep "aws.amazon.com/neuron" echo -e "\n=== Storage Classes ===" kubectl get storageclass echo -e "\n=== Current Namespace ===" kubectl config get-contexts

npd.enabled=false는 Neuron Node Problem Detector를 끄는 옵션입니다. 실습 규모에서는 필요 없고, 켜면 파드가 더 늘어납니다.

워커 노드에 직접 들어가서 보기 — neuron-ls / neuron-top

Session Manager로 neuron-trn1-2x 노드에 접속하면 호스트 레벨에서 Neuron 상태를 직접 볼 수 있습니다. nvidia-smi 대신 neuron-ls(디바이스 목록)와 neuron-top(실시간 사용률)을 씁니다.

# whoami pwd sudo su ec2-user whoami pwd cd pwd # lspci 00:1e.0 System peripheral: Amazon.com, Inc. NeuronDevice (Trainium) ... lsmod | grep -i neuron neuron 491520 0 # Trainium/Inferentia 칩(디바이스) 전체를 나타내는 메인 디바이스 노드입니다. # 숫자는 인스턴스에 붙은 Neuron 디바이스(칩) 인덱스이고, neuron-rt(Neuron Runtime)가 이 노드를 열어서 해당 칩에 모델을 로드하고 추론/학습을 실행합니다. ls -l /dev/neuron0 crw-rw-rw-. 1 root root 243, 0 Sep 4 13:30 /dev/neuron0 # "NeuronCore" 단위의 세부 디바이스 노드입니다. # 네이밍은 ng<device_index>n<core_index> 형태로, 하나의 물리 칩(neuron0) 안에 있는 개별 NeuronCore(코어)를 가리킵니다. # 즉 ng0n1은 디바이스 0의 코어 1, ng1n1은 디바이스 1의 코어 1을 의미합니다. # 이 노드들은 Kubernetes Neuron device plugin이나 컨테이너 격리 환경에서 특정 코어를 특정 프로세스/파드에만 할당(core-level passthrough)할 때 쓰이는 통로라서, # 기본적으로 crw------- (root 전용)로 잠겨 있고 필요할 때 device plugin이 해당 프로세스/컨테이너에 권한을 부여합니다. ls -l /dev/ng* crw-------. 1 root root 246, 1 Sep 4 13:30 /dev/ng0n1 crw-------. 1 root root 246, 0 Sep 4 13:30 /dev/ng1n1 # containerd config에 "neuron 전용 런타임" 없음! cat /etc/containerd/config.toml version = 3 root = "/var/lib/containerd" state = "/run/containerd" [grpc] address = "/run/containerd/containerd.sock" [plugins.'io.containerd.cri.v1.images'] discard_unpacked_layers = true [plugins.'io.containerd.cri.v1.images'.pinned_images] sandbox = "localhost/kubernetes/pause:latest" [plugins."io.containerd.cri.v1.images".registry] config_path = "/etc/containerd/certs.d:/etc/docker/certs.d" [plugins.'io.containerd.cri.v1.runtime'] enable_cdi = true [plugins.'io.containerd.cri.v1.runtime'.containerd] default_runtime_name = "runc" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" base_runtime_spec = "/etc/containerd/base-runtime-spec.json" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options] BinaryName = "/usr/sbin/runc" SystemdCgroup = true [plugins.'io.containerd.cri.v1.runtime'.cni] bin_dir = "/opt/cni/bin" conf_dir = "/etc/cni/net.d" # ls -R -1 /opt/aws/neuron/ ... which neuron-ls which neuron-top neuron-ls instance-type: trn1.2xlarge instance-id: i-0xxxxxxxxxxxxxxxx +--------+--------+----------+--------+--------------+----------+------+ | NEURON | NEURON | NEURON | NEURON | PCI | CPU | NUMA | | DEVICE | CORES | CORE IDS | MEMORY | BDF | AFFINITY | NODE | +--------+--------+----------+--------+--------------+----------+------+ | 0 | 2 | 0-1 | 32 GB | 0000:00:1e.0 | 0-7 | -1 | +--------+--------+----------+--------+--------------+----------+------+ neuron-top

여기서 확인할 것 두 가지입니다. 첫째, /dev/neuron0과 그 아래 ng0n1 형태의 코어 단위 디바이스 노드가 커널에서 이미 나뉘어 있다는 것. 둘째, 나중에 vLLM 파드가 뜨면 neuron-top에서 코어 2개가 동시에 올라가는 모습입니다 — TP=2가 실제로 동작하는 증거입니다.

3장 세 줄 요약
  1. 정의 — Lab 1은 이미 있는 EKS 컨트롤 플레인에 Trainium 노드와 그 부속품을 붙이는 작업입니다. 노드그룹 · device plugin · 스케줄러 확장 · S3 CSI 네 가지가 결과물입니다.
  2. 성질 — device plugin을 지우고 다시 까는 이유는 Helm 소유권입니다. AMI가 자동 배포한 리소스에는 Helm 어노테이션이 없어 충돌하고, 나중에 옵션을 바꿀 수도 없습니다.
  3. 쓰임 — 노드에 aws.amazon.com/neuron: 1이 뜨면 성공입니다. 텐서 병렬을 쓸 때는 코어가 몇 개 남았느냐보다 어디에 남았느냐가 중요해서, 그 판단을 my-scheduler가 맡습니다.

여기서 잠깐 멈춥니다. 이 워크샵에서 가장 흥미로운 질문이 하나 남아 있습니다 — 왜 Neuron에는 전용 컨테이너 런타임이 없을까?

04NVIDIA와 Neuron은 왜 다르게 끼어드나

한 줄 요약: NVIDIA는 컨테이너 런타임 계층에서, Neuron은 순수 디바이스 플러그인 계층에서만 개입합니다.

왜 이걸 배우나
"NVIDIA 노드에는 containerd 설정에 nvidia 런타임이 잡혀 있는데, Trainium 노드에는 왜 아무것도 없지?"

NVIDIA GPU 노드에서 containerd 설정을 열어 보면 nvidia라는 런타임 항목이 있고 BinaryName = "/usr/bin/nvidia-container-runtime"이 잡혀 있습니다. 파드도 runtimeClassName: nvidia를 지정해야 GPU를 씁니다.

그런데 Trainium 노드에는 그런 게 없습니다. default_runtime_name = "runc" 그대로고, vLLM 파드도 runtimeClassName을 지정하지 않습니다. 같은 "가속기를 컨테이너에 꽂는 일"인데 왜 한쪽만 특별 취급이 필요한가 — 이 질문의 답이 두 하드웨어 생태계의 설계 차이를 그대로 보여줍니다.

이 장에 나오는 낯선 말
OCI runtime / runc
컨테이너를 실제로 만드는 가장 아래층 프로그램. 네임스페이스·cgroup을 설정하고 프로세스를 띄웁니다.
prestart hook
runc가 컨테이너 프로세스를 시작하기 직전에 끼워 실행하는 외부 프로그램. NVIDIA 툴킷이 여기에 붙습니다.
bind-mount
호스트의 파일·디렉터리를 컨테이너 안의 경로에 그대로 비춰 주는 마운트 방식.
device cgroup
커널이 "이 프로세스는 어떤 디바이스에 접근해도 되는지"를 관리하는 규칙. 마운트만으로는 부족하고 이 허가가 따로 필요합니다.
CDI
Container Device Interface. "이 디바이스를 컨테이너에 넣으려면 무엇이 필요한지"를 YAML로 적어 둔 표준. CNI(네트워크)·CSI(스토리지)·CRI(런타임)와 같은 계열의 이름입니다.
ioctl ABI
유저 공간 프로그램이 커널 드라이버에 명령을 보내는 규약. 이 규약이 안정적이면 버전이 달라도 통신이 됩니다.

비유 — 번역기가 필요한 손님과 필요 없는 손님

회사에 외국 손님이 옵니다. 두 경우가 있습니다.

손님 A(NVIDIA)는 통역이 필요합니다. 회의실에 들여보내기 전에 매번 통역사를 붙여야 하고, 통역사는 그 손님이 쓰는 방언 버전과 정확히 맞아야 합니다. 회사 안내데스크에는 "이 손님에게는 통역사 배정"이라는 절차가 등록돼 있습니다. 통역사가 하는 일은 문서를 회의실 안으로 옮겨 놓는 것(libcuda.so 주입)입니다.

손님 B(Neuron)는 이미 회사 언어를 할 줄 압니다. 필요한 자료도 본인이 들고 왔습니다 (Neuron SDK는 pip 패키지로 컨테이너 이미지 안에 이미 들어 있습니다). 안내데스크는 회의실 문만 열어 주면 끝입니다. 특별 절차가 필요 없습니다.

차이의 뿌리는 "자료를 누가 들고 있느냐"입니다. NVIDIA는 유저 공간 라이브러리가 커널 드라이버 버전과 강하게 묶여 있어 호스트 것을 써야 하고, Neuron은 안정적인 ioctl 규약으로 통신해서 컨테이너 안의 SDK가 그대로 먹힙니다.

작은 숫자로 한 번

# NVIDIA 노드 — containerd 설정에 흔적이 남는다 grep nvidia /var/lib/rancher/k3s/agent/etc/containerd/config.toml [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.'nvidia'.options] BinaryName = "/usr/bin/nvidia-container-runtime" # Trainium 노드 — 아무것도 없다 default_runtime_name = "runc" ← 순정 그대로 # 대신 커널이 디바이스를 이미 잘게 쪼개 놨다 /dev/neuron0 칩 전체 /dev/neuron0/ng0n1 코어 단위 → device plugin은 "어느 노드를 마운트할지"만 정하면 끝
원문 도식 재현

도식 4-1. NVIDIA GPU 파드가 뜨는 과정

이 그림이 말하는 것

device plugin이 Allocate() 응답에 환경변수(NVIDIA_VISIBLE_DEVICES)를 담아 보내면, 그걸 가로채는 별도 런타임이 필요합니다. 이 런타임이 디바이스 노드를 만들고, 호스트의 드라이버 라이브러리를 컨테이너 안으로 bind-mount하고, ldconfig를 다시 돌립니다.

최근에는 이 과정이 CDI 스펙(/var/run/cdi/nvidia.yaml)으로 표준화되고 있지만, "라이브러리를 주입한다"는 무거운 작업 자체는 그대로입니다. 커널 드라이버 버전과 유저 공간 라이브러리 버전이 엄격하게 맞아야 하기 때문입니다.

원문 도식 재현

도식 4-2. AWS Neuron 파드가 뜨는 과정

이 그림이 말하는 것

같은 그림을 그렸는데 상자 하나가 통째로 빠져 있습니다. device plugin이 Allocate() 응답에 호스트 디바이스 경로 목록을 그냥 담아 반환하면, 그것은 CRI 표준 device-mount 메커니즘(docker run --device와 같은 개념 — cgroup 디바이스 허용 규칙 + bind-mount)으로 처리되고 표준 runc가 그대로 끝냅니다.

대신 빠진 자리를 스케줄러 확장이 메웁니다. 런타임이 개입하지 않으니 "어느 코어를 줄지"를 더 위층에서 정교하게 정해야 하기 때문입니다(3장).

왜 이 차이가 생기나 — 네 가지 축

항목NVIDIAAWS Neuron
유저 공간 라이브러리 호스트 드라이버와 강하게 묶임. libcuda.so가 커널 모듈과 정확히 맞아야 함 → 런타임이 매번 주입 Neuron SDK(torch-neuronx, neuronx-cc)가 pip 패키지로 이미지 안에 포함. 커널 드라이버와는 안정적인 ioctl ABI로 통신 → 주입 불필요
디바이스 제어 커널 드라이버 하나가 GPU 전체를 통제. 나누려면 MPS·MIG 같은 별도 분할 로직이 필요 /dev/neuronN(칩) + /dev/ngXnY(코어)가 커널 레벨에서 이미 분리돼 있음. plugin은 "어느 노드를 마운트할지"만 결정
런타임 개입 필요 — nvidia-container-runtime 또는 CDI 훅 불필요 — 표준 runc + device cgroup 규칙
코어 단위 격리 MIG(하드웨어 파티셔닝) 또는 time-slicing(소프트웨어) NEURON_RT_VISIBLE_CORES 환경변수 + ngXnY 노드를 파드별로 선택 마운트
한 문장으로 줄이면

NVIDIA는 무거운 통합입니다 — 전용 런타임이 드라이버 라이브러리까지 매번 주입해야 해서 containerd 설정에 흔적이 남습니다.
Neuron은 가벼운 통합입니다 — 커널이 이미 디바이스를 세분화해 노출하고 SDK는 이미지 안에 있으니, 표준 쿠버네티스 device-allocation API만으로 충분합니다. 그 대신 세밀한 스케줄링을 전용 스케줄러 확장이 보완합니다.

CDI로 옮겨 가는 중 — CNI · CSI · CRI · CDI

쿠버네티스에는 "이 일은 플러그인이 알아서 하라"고 넘기는 표준 인터페이스가 여럿 있습니다. CNI는 네트워크, CSI는 스토리지, CRI는 컨테이너 런타임, 그리고 최근 붙은 CDI가 디바이스입니다.

NVIDIA 노드의 containerd 설정에는 전용 런타임이 잡혀 있다

# K3s 는 다양한 컨테이너 런타임(nvidia, nvidia-cdi, nvidia-experimental 등)을 자동 발견 적용함 grep nvidia /var/lib/rancher/k3s/agent/etc/containerd/config.toml *[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.'nvidia'] [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.'nvidia'.options] BinaryName = "/usr/bin/nvidia-container-runtime"*

CDI 설정과 실제 스펙 파일 전문 — 주입할 디바이스 노드와 라이브러리 목록이 그대로 적혀 있다

# K3s는 아래 경로 확인 : 일반 K8s는 /etc/containerd/config.toml 확인 cat /etc/nvidia-container-runtime/config.toml | grep -i cdi *[nvidia-container-runtime.modes.cdi] annotation-prefixes = ["cdi.k8s.io/"] spec-dirs = ["/etc/cdi", "/var/run/cdi"]* # 훅 정보 확인 cat /var/run/cdi/nvidia.yaml --- cdiVersion: 0.7.0 kind: nvidia.com/gpu devices: - name: "0" containerEdits: deviceNodes: - path: /dev/nvidia0 major: 195 fileMode: 438 permissions: rwm - path: /dev/dri/card1 major: 226 minor: 1 fileMode: 432 permissions: rwm gid: 44 - path: /dev/dri/renderD128 major: 226 minor: 128 fileMode: 432 permissions: rwm gid: 993 hooks: - hookName: createContainer path: /usr/bin/nvidia-cdi-hook args: - nvidia-cdi-hook - create-symlinks - --link - ../card1::/dev/dri/by-path/pci-0000:01:00.0-card - --link - ../renderD128::/dev/dri/by-path/pci-0000:01:00.0-render env: - NVIDIA_CTK_DEBUG=false additionalGids: - 44 - 993 - name: GPU-eabaa66e-d67a-40c2-6213-b0d522fac60a containerEdits: deviceNodes: - path: /dev/nvidia0 major: 195 fileMode: 438 permissions: rwm - path: /dev/dri/card1 major: 226 minor: 1 fileMode: 432 permissions: rwm gid: 44 - path: /dev/dri/renderD128 major: 226 minor: 128 fileMode: 432 permissions: rwm gid: 993 hooks: - hookName: createContainer path: /usr/bin/nvidia-cdi-hook args: - nvidia-cdi-hook - create-symlinks - --link - ../card1::/dev/dri/by-path/pci-0000:01:00.0-card - --link - ../renderD128::/dev/dri/by-path/pci-0000:01:00.0-render env: - NVIDIA_CTK_DEBUG=false additionalGids: - 44 - 993 ... hooks: - hookName: createContainer path: /usr/bin/nvidia-cdi-hook args: - nvidia-cdi-hook - create-symlinks - --link - ../libnvidia-allocator.so.1::/usr/lib/x86_64-linux-gnu/gbm/nvidia-drm_gbm.so - --link - libglxserver_nvidia.so.595.71.05::/usr/lib/x86_64-linux-gnu/nvidia/xorg/libglxserver_nvidia.so env: - NVIDIA_CTK_DEBUG=false - hookName: createContainer path: /usr/bin/nvidia-cdi-hook args: - nvidia-cdi-hook - create-symlinks - --link - libEGL_nvidia.so.595.71.05::/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so.0 - --link - libGLESv1_CM_nvidia.so.595.71.05::/usr/lib/x86_64-linux-gnu/libGLESv1_CM_nvidia.so.1 - --link - libGLESv2_nvidia.so.595.71.05::/usr/lib/x86_64-linux-gnu/libGLESv2_nvidia.so.2 - --link - libGLX_nvidia.so.595.71.05::/usr/lib/x86_64-linux-gnu/libGLX_indirect.so.0 - --link - libGLX_nvidia.so.595.71.05::/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so.0 - --link - libcuda.so.1::/usr/lib/x86_64-linux-gnu/libcuda.so - --link - libcuda.so.595.71.05::/usr/lib/x86_64-linux-gnu/libcuda.so.1 - --link - libcudadebugger.so.595.71.05::/usr/lib/x86_64-linux-gnu/libcudadebugger.so.1 - --link - libnvcuvid.so.595.71.05::/usr/lib/x86_64-linux-gnu/libnvcuvid.so.1 ...

Neuron device plugin도 최근 버전부터 CDI를 지원하기 시작했습니다. 다만 스펙이 훨씬 단순합니다 — 라이브러리 마운트 목록이 없기 때문입니다. 같은 표준을 쓰더라도 채워 넣을 내용의 양이 다릅니다.

4장 세 줄 요약
  1. 정의 — NVIDIA는 컨테이너 런타임 계층에서 개입하고(전용 런타임 + prestart 훅), Neuron은 디바이스 플러그인 계층에서만 개입합니다(표준 runc).
  2. 성질 — 차이의 뿌리는 유저 공간 라이브러리를 누가 들고 있느냐입니다. libcuda.so는 커널 모듈과 버전이 묶여 호스트 것을 주입해야 하고, Neuron SDK는 이미지 안에 pip로 들어 있어 그럴 필요가 없습니다. 커널이 /dev/ngXnY로 코어를 이미 나눠 둔 것도 큽니다.
  3. 쓰임 — 그래서 Trainium 노드의 containerd 설정은 순정 그대로고, 파드도 runtimeClassName을 안 씁니다. 대신 빠진 정교함을 my-scheduler가 메웁니다. 6W-2에서 NVIDIA 쪽 훅 네 단계를 직접 보면 이 대비가 더 선명해집니다.

바닥 이야기는 끝났습니다. 이제 실제로 모델을 올립니다 — 그런데 그전에 컴파일을 먼저 해야 합니다.

05vLLM 올리기 — init 컨테이너와 S3 캐시

한 줄 요약: 모델을 미리 컴파일해 S3에 넣어 두면, 다음 기동은 8분에서 20초로 줄어듭니다.

왜 이걸 배우나
"GPU에서는 그냥 vllm serve 한 줄이면 떴는데, 왜 여기는 init 컨테이너가 필요하지?"

1장에서 본 것 하나가 여기서 값을 치릅니다 — NeuronCore의 온칩 SRAM이 컴파일러 관리라는 사실입니다. 무엇을 언제 어느 메모리에 올릴지를 실행 전에 정해야 하고, 그래서 모델은 Neuron 전용 형식으로 컴파일되어야 합니다. 이게 4분 걸립니다.

파드가 재시작될 때마다 4분씩 태울 수는 없습니다. 그래서 워크샵은 컴파일 결과물을 S3에 저장해 두고, 다음부터는 내려받아 쓰는 구조를 만듭니다. 이 패턴은 Neuron만의 이야기가 아닙니다 — GPU에서도 torch.compile 캐시에 같은 문제가 있습니다(6W-2 8장).

이 장에 나오는 낯선 말
init container
메인 컨테이너가 뜨기 전에 먼저 실행되고 끝나야 하는 컨테이너. 준비 작업을 맡습니다.
ConfigMap / Secret
설정값과 비밀값을 파드 밖으로 빼두는 쿠버네티스 오브젝트. Secret은 base64로 저장될 뿐 암호화는 아닙니다.
PV / PVC
PersistentVolume(실제 저장소) / PersistentVolumeClaim(그걸 쓰겠다는 신청서). 파드는 신청서를 통해 저장소에 붙습니다.
RWX (ReadWriteMany)
여러 노드의 여러 파드가 동시에 읽고 쓸 수 있는 접근 모드.
FUSE
Filesystem in Userspace. 파일시스템을 커널이 아니라 일반 프로그램으로 구현하는 방식입니다.
strong read-after-write consistency
쓰기가 끝난 직후 다른 프로세스가 읽어도 최신 데이터가 보장되는 성질. S3는 2020년부터 이 성질을 가집니다.

비유 — 매번 조립하는 가구와 미리 조립해 둔 가구

이케아 가구를 쓸 때마다 조립하고 쓰고 나면 분해한다고 생각해 보세요. 말이 안 됩니다. 한 번 조립해서 창고에 넣어 두고, 필요할 때 꺼내 오는 게 당연합니다.

init 컨테이너가 하는 일이 그겁니다. 창고(S3)를 먼저 뒤져서 이미 조립된 게 있으면 그냥 가져오고, 없으면 부품을 사 와서(Hugging Face 다운로드) 조립하고(Neuron 컴파일) 창고에 넣어 둡니다(S3 업로드).

메인 컨테이너는 조립을 아예 모릅니다. 창고에서 완성품을 꺼내 쓰기만 합니다. 역할을 나눠 놓으면 메인 컨테이너의 기동이 빨라집니다.

작은 숫자로 한 번

# 첫 기동 (캐시 없음) 이미지 가져오기 + 스케줄링 약 4분 이미지가 7.9 GiB 모델 다운로드 + Neuron 컴파일 약 4분 vLLM API 서버 시작 약 20초 ───────── 약 8분 # 두 번째 기동 (S3 캐시 히트 + 이미지도 노드에 남아 있음) 캐시에서 복사 + 서버 시작 약 20초 # 실제로 도는 프로세스 python -m vllm.entrypoints.openai.api_server \ --model=tinyLlama/TinyLlama-1.1B-Chat-v1.0 \ --max-num-seqs=4 --max-model-len=1024 \ --tensor-parallel-size=2 --port=8080 \ --device=neuron --override-neuron-config={"enable_bucketing":false}
원문 도식 재현

도식 5-1. Lab 2가 만드는 것들과 그 관계

이 그림이 말하는 것

일곱 개의 오브젝트가 만들어지는데, 실제 의존 관계는 그림의 화살표 방향입니다. Secret과 ConfigMap은 init 컨테이너가, PVC는 두 컨테이너가 공유하고, Service는 메인 컨테이너만 바라봅니다.

그래서 init 컨테이너가 실패하면 Service의 엔드포인트가 아예 안 생깁니다 — kubectl get endpoints vllm-service가 비어 있으면 메인이 아니라 init을 먼저 봐야 합니다.

그림 4

캐시가 있고 없고에 따른 기동 시간

클릭 + 슬라이더
해볼 것 — ① 두 스위치를 모두 끄면 첫 기동(약 8분)입니다. 컴파일이 절반을 차지합니다. ② 모델 캐시만 켜 보세요 — 컴파일 4분이 사라지고 S3 복사 시간만 남습니다. ③ 둘 다 켜면 20초입니다. ④ 모델 크기를 키우면 컴파일 시간이 캐시 복사 시간보다 훨씬 빠르게 늘어납니다 — 모델이 클수록 캐시의 값어치가 커집니다.
1.1B의 값은 실측입니다

이미지 4분 · 컴파일 4분 · 서버 20초는 워크샵에서 실제로 측정된 값입니다. 모델 크기를 키웠을 때의 증가분은 비례식으로 추정한 모형입니다.

그림에 나오는 말 init 컨테이너 메인 컨테이너보다 먼저 실행되어 준비 작업을 하는 컨테이너 · Neuron 컴파일 모델을 NeuronCore가 실행할 수 있는 형식으로 변환하는 작업 · 캐시 히트 · 미스 S3에 컴파일 결과물이 이미 있느냐 없느냐 · 이미지 풀 컨테이너 이미지를 레지스트리에서 노드로 내려받는 것(여기서는 7.9 GiB)
실습 결과 init 컨테이너가 컴파일을 끝내는 순간
k9s 로 본 model-prep init 컨테이너 로그. 가중치 샤딩과 bfloat16 변환 경고가 이어지다가 Done Sharding weights in 4.68초, Finished weights loading in 10.76초, Warmup completed in 0.40초, Model compiled successfully! 로 끝난다
k9s로 model-prep init 컨테이너의 로그를 따라간 화면입니다. 아래에서 위로 읽으면 순서가 보입니다 — torch.float32 → torch.bfloat16 변환 경고가 층마다 반복되고(체크포인트가 fp32로 저장돼 있어서), Done Sharding weights in 4.68초(TP=2로 가중치를 반씩 쪼갠 단계), Finished weights loading in 10.76초, # neuron blocks: 4(= max-num-seqs=4), Warmup completed in 0.40초, 그리고 Model compiled successfully!. 마지막 두 줄의 nrtucode: 52 object(s) leaked, improper teardown과 stream closed: EOF는 실패가 아닙니다. init 컨테이너가 할 일을 끝내고 스스로 종료하면서 나는 정리 메시지입니다 — Model compiled successfully!가 그 위에 찍혀 있으면 정상입니다. libnccom-net.so load failed 경고도 마찬가지로, 여러 노드를 묶을 때 쓰는 EFA 라이브러리가 없다는 뜻이라 노드 한 대짜리 이번 실습에서는 상관없습니다.
실습 결과 --tensor-parallel-size=2가 메모리에 남긴 흔적
neuron-top 화면. NeuronCore 사용률은 NC0/NC1 모두 0.00% 이지만 Device Used Memory Total 3.6GB, Tensors 2.1GB, Model Code 222.0MB 이고 Memory Usage Details 에서 NC0 1.8GB, NC1 1.8GB 로 나뉘어 있다
모델이 올라간 뒤, 아직 요청은 안 보낸 상태입니다. 사용률은 0.00%인데 메모리는 3.6GB가 이미 잡혀 있습니다 — 가중치가 상주하기 때문입니다. 그리고 아래 표를 보면 NC 0에 1.8GB, NC 1에 1.8GB로 정확히 반씩 나뉘어 있습니다. 이게 --tensor-parallel-size=2가 한 일입니다. TP를 안 걸었다면 한쪽에 3.6GB가 통째로 올라가고 다른 쪽은 0B였을 겁니다. 내역도 읽어 둘 만합니다 — Tensors 2.1GB(가중치), Model Code 222MB(컴파일된 NEFF 실행 코드), Constants 285MB. 위의 Model compiled successfully!가 만들어 낸 게 이 222MB입니다.

S3를 파일시스템처럼 쓴다는 것

PV에 capacity: 100Gi라고 적혀 있지만, 이 숫자는 아무 의미가 없습니다. Mountpoint는 실제 쿼터를 강제하지 않고 S3는 사실상 무제한이라, 이건 쿠버네티스 스케줄링을 위한 논리값일 뿐입니다.

더 중요한 건 완전한 POSIX 파일시스템이 아니라는 점입니다. init 컨테이너가 하는 cp -r 같은 통짜 복사는 문제없지만, 파일을 열어 놓고 이어쓰기(append) 하는 패턴은 지원되지 않을 수 있습니다. 로그 파일을 여기에 두면 안 되는 이유입니다.

그림 5

Mountpoint FUSE는 어디에 끼어드나

클릭
해볼 것 — ① 아래 작업 버튼을 눌러 각 파일 연산이 어디까지 내려가는지 보세요. ② append와 hard link를 눌러 보면 중간에서 막힙니다 — S3에는 대응하는 API가 없습니다. ③ 통짜 쓰기는 PutObject 한 번으로 끝나고, 읽기는 GetObject로 내려갑니다.
마운트가 파드마다 따로 뜬다

S3 CSI 드라이버는 한 번 stage해 두고 bind-mount로 재사용하는 블록 스토리지 방식이 아닙니다. 마운트 지점마다 mount-s3 프로세스가 새로 뜹니다. 그래서 RWX로 여러 파드가 같은 버킷을 봐도 서로 독립적이라 안전합니다.

그림에 나오는 말 FUSE Filesystem in Userspace — 파일시스템을 커널이 아니라 일반 프로그램으로 구현하는 방식 · VFS 리눅스 커널의 가상 파일시스템 계층. 모든 파일 연산이 여기를 먼저 거침 · PutObject · GetObject S3의 기본 API. 객체 하나를 통째로 넣거나 꺼냄 · POSIX 유닉스 계열 파일시스템이 지켜야 하는 표준 동작 규약
Lab 2의 실제 순서와 확인 명령
  1. Hugging Face 토큰을 Secret으로값은 셸 변수로 넘겨 히스토리에 남기지 않습니다.
  2. ConfigMap 생성모델명·TP 크기·포트 등 14개 키. 두 컨테이너가 공유합니다.
  3. PV / PVC 생성S3 버킷을 100Gi RWX Retain으로. Bound가 되면 성공입니다.
  4. Deployment 배포init 컨테이너(model-prep) + 메인 컨테이너(vllm-server).
  5. Service 생성LoadBalancer 타입 8080. AWS CLB가 하나 만들어집니다.

1단계 — HF 토큰 Secret

rm -rf /home/ubuntu/workshop/.env echo 'HF_TOKEN="hf_****......."' > /home/ubuntu/workshop/.env source /home/ubuntu/workshop/.env echo $HF_TOKEN kubectl create secret generic hf-token-secret \ --from-literal=HF_TOKEN="$HF_TOKEN" \ --dry-run=client -o yaml | kubectl apply -f - kubectl get secret hf-token-secret NAME TYPE DATA AGE hf-token-secret Opaque 1 44s

2단계 — ConfigMap 전문

# aws s3 ls 2026-09-04 13:47:00 ai-infra-summit-vllm-models-cache-{ACCOUNT_ID} # cat > vllm-configmap.yaml <<EOF apiVersion: v1 kind: ConfigMap metadata: name: vllm-shared-config data: HF_TOKEN: "$HF_TOKEN" MODEL_NAME: "tinyLlama/TinyLlama-1.1B-Chat-v1.0" # 서빙할 대상 모델 (경량 1.1B 모델이라 트레이닝/컴파일이 빠름) S3_BUCKET: "ai-infra-summit-vllm-models-cache-$(aws sts get-caller-identity | jq -r .Account)" # 컴파일된 Neuron 아티팩트를 캐싱할 S3 버킷 S3_PREFIX: "compiled-models" # 캐시 오브젝트를 저장할 prefix 경로 MAX_NUM_SEQS: "4" # vLLM continuous batching이 동시에 처리할 최대 시퀀스(요청) 수 — 배치 크기 상한 PORT: "8080" NEURON_COMPILED_ARTIFACTS: "/shared/model/cache" # Neuron 컴파일러(neuronx-cc)가 컴파일 결과를 읽고 쓰는 로컬 경로 — S3 PVC가 마운트되는 지점과 동일 NEURON_COMPILE_CACHE_URL: "/shared/model/cache" # 상동 TENSOR_PARALLEL_SIZE: "2" # 모델 가중치를 2개 NeuronCore에 분할(텐서 병렬) — trn1.2xlarge 칩 1개의 코어 2개 전부 사용 MAX_MODEL_LEN: "1024" # 시퀀스(프롬프트+생성) 최대 토큰 길이, KV 캐시 크기 산정 기준 NEURON_RT_VISIBLE_CORES: "0-1" # Neuron 런타임에 노출할 코어 인덱스 범위 — 코어 0,1 두 개 다 이 프로세스 전용으로 지정 NEURON_RT_LOG_LEVEL: "ERROR" # Neuron 런타임 로그 레벨 — 에러만 출력해 노이즈 최소화 NEURON_RT_ASYNC_EXEC_MAX_INFLIGHT_REQUESTS: "4" # Neuron 런타임에 동시에 비동기로 in-flight될 수 있는 실행 요청 수 상한 (throughput/latency 트레이드오프 튜닝값) VLLM_NEURON_FRAMEWORK: "neuronx-distributed-inference" # vLLM이 Neuron 백엔드로 NxD(NeuronX Distributed) 추론 프레임워크를 사용하도록 지정하는 플래그 EOF kubectl apply -f vllm-configmap.yaml kubectl get cm vllm-shared-config NAME DATA AGE vllm-shared-config 14 3m25s

3단계 — S3 기반 PV / PVC 전문

# cat > vllm-storage.yaml <<EOF apiVersion: v1 kind: PersistentVolume metadata: name: s3-model-cache-pv spec: capacity: storage: 100Gi # PV에 capacity: 100Gi를 지정했지만 Mountpoint는 실제 쿼터를 강제하지 않습니다. S3는 사실상 무제한이라 이 숫자는 k8s 스케줄링용 논리값일 뿐입니다. accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain csi: driver: s3.csi.aws.com volumeHandle: ai-infra-summit-vllm-models-cache-$(aws sts get-caller-identity | jq -r .Account) volumeAttributes: bucketName: ai-infra-summit-vllm-models-cache-$(aws sts get-caller-identity | jq -r .Account) --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: s3-model-cache-pvc spec: accessModes: - ReadWriteMany resources: requests: storage: 100Gi volumeName: s3-model-cache-pv EOF kubectl apply -f vllm-storage.yaml kubectl get pvc,pv NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE persistentvolumeclaim/s3-model-cache-pvc Bound s3-model-cache-pv 100Gi RWX <unset> 29s NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE persistentvolume/s3-model-cache-pv 100Gi RWX Retain Bound default/s3-model-cache-pvc <unset> 29s

4단계 — vLLM Deployment 전문 (init 컨테이너 + 메인 컨테이너)

# echo $BUCKET_NAME watch -d aws s3 ls s3://$BUCKET_NAME --recursive --human-readable # kubectl describe node ... Labels: alpha.eksctl.io/cluster-name=ai-infra-summit-test-cluster alpha.eksctl.io/nodegroup-name=neuron-trn1-2x ... # cat > vllm-deployment.yaml <<EOF apiVersion: apps/v1 kind: Deployment metadata: name: vllm-deployment labels: app.kubernetes.io/name: vllm-server spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: vllm-server template: metadata: labels: app.kubernetes.io/name: vllm-server spec: restartPolicy: Always schedulerName: my-scheduler nodeSelector: alpha.eksctl.io/nodegroup-name: neuron-trn1-2x tolerations: - key: "node.kubernetes.io/disk-pressure" operator: "Exists" effect: "NoSchedule" # Volumes for compiled models volumes: - name: model-storage persistentVolumeClaim: claimName: s3-model-cache-pvc # Init container that downloads, compiles, and uploads model to S3 initContainers: - name: model-prep image: public.ecr.aws/neuron/pytorch-inference-vllm-neuronx:0.9.1-neuronx-py310-sdk2.25.0-ubuntu22.04 imagePullPolicy: Always envFrom: - configMapRef: name: vllm-shared-config - secretRef: name: hf-token-secret command: ["/bin/bash", "-c"] args: - | set -e echo "Starting model prep for \$MODEL_NAME..." huggingface-cli login --token "\$HF_TOKEN" mkdir -p /tmp/cache /shared/model/cache if [ ! "\$(ls -A /shared/model/cache 2>/dev/null)" ]; then export NEURON_COMPILED_ARTIFACTS=/tmp/cache NEURON_COMPILE_CACHE_URL=/tmp/cache python3 -c " import os from vllm import LLM LLM(model=os.environ['MODEL_NAME'], max_num_seqs=int(os.environ['MAX_NUM_SEQS']), max_model_len=int(os.environ['MAX_MODEL_LEN']), tensor_parallel_size=int(os.environ['TENSOR_PARALLEL_SIZE']), device='neuron', override_neuron_config={'enable_bucketing': False}) print('Model compiled successfully!')" cp -r /tmp/cache/* /shared/model/cache/ 2>/dev/null || true else echo "Model cache exists, skipping compilation" fi resources: limits: aws.amazon.com/neuron: 1 ephemeral-storage: 50Gi requests: aws.amazon.com/neuron: 1 ephemeral-storage: 50Gi volumeMounts: - name: model-storage mountPath: /shared/model containers: - name: vllm-server image: public.ecr.aws/neuron/pytorch-inference-vllm-neuronx:0.9.1-neuronx-py310-sdk2.25.0-ubuntu22.04 imagePullPolicy: Always ports: - containerPort: 8080 name: http-vllm envFrom: - configMapRef: name: vllm-shared-config - secretRef: name: hf-token-secret command: ["/bin/bash", "-c"] args: - | python -m vllm.entrypoints.openai.api_server \\ --model="\$MODEL_NAME" \\ --max-num-seqs=\$MAX_NUM_SEQS \\ --max-model-len=\$MAX_MODEL_LEN \\ --tensor-parallel-size=\$TENSOR_PARALLEL_SIZE \\ --port=\$PORT \\ --device=neuron \\ --override-neuron-config='{"enable_bucketing":false}' volumeMounts: - name: model-storage mountPath: /shared/model readOnly: true resources: limits: aws.amazon.com/neuron: 1 ephemeral-storage: 50Gi cpu: "8000m" requests: aws.amazon.com/neuron: 1 ephemeral-storage: 50Gi cpu: "4000m" EOF kubectl apply -f vllm-deployment.yaml # kubectl logs -l app.kubernetes.io/name=vllm-server -c model-prep -f # 혹은 k9s 에서 해당 파드에서 'l' 입력 후 로그 확인 kubectl logs -l app.kubernetes.io/name=vllm-server -c vllm-server -f kubectl get pod -l app.kubernetes.io/name=vllm-server -w # 모델 아티팩트가 S3에 성공적으로 캐시되었는지 확인 aws s3 ls s3://$BUCKET_NAME --recursive --human-readable 2026-09-05 01:42:13 4.6 MiB cache/model.pt 2026-09-05 01:42:13 6.1 KiB cache/neuron_config.json 2026-09-05 01:42:14 359 Bytes cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_56f0d314fda2b6e1e336+617f6939/compile_flags.json 2026-09-05 01:42:14 0 Bytes cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_56f0d314fda2b6e1e336+617f6939/model.done 2026-09-05 01:42:13 547.2 KiB cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_56f0d314fda2b6e1e336+617f6939/model.hlo_module.pb 2026-09-05 01:42:14 1.5 MiB cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_56f0d314fda2b6e1e336+617f6939/model.neff 2026-09-05 01:42:14 1.6 MiB cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_56f0d314fda2b6e1e336+617f6939/wrapped_neff.hlo 2026-09-05 01:42:15 359 Bytes cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_ae92d68443828ba4e463+ad9e832d/compile_flags.json 2026-09-05 01:42:15 0 Bytes cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_ae92d68443828ba4e463+ad9e832d/model.done 2026-09-05 01:42:14 851.5 KiB cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_ae92d68443828ba4e463+ad9e832d/model.hlo_module.pb 2026-09-05 01:42:15 741.0 KiB cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_ae92d68443828ba4e463+ad9e832d/model.neff # kubectl describe pod -l app.kubernetes.io/name=vllm-server kubectl get pod -l app.kubernetes.io/name=vllm-server -owide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES vllm-deployment-64597fb8cc-r8jw2 1/1 Running 0 31m 10.0.2.62 ip-10-0-2-137.us-west-2.compute.internal <none> <none> kubectl get pod -l app.kubernetes.io/name=vllm-server -o yaml | grep -i scheduler ... name: vllm-server ports: - containerPort: 8080 name: http-vllm protocol: TCP resources: limits: aws.amazon.com/neuron: "1" cpu: "8" ephemeral-storage: 50Gi requests: aws.amazon.com/neuron: "1" cpu: "4" ephemeral-storage: 50Gi ... nodeSelector: alpha.eksctl.io/nodegroup-name: neuron-trn1-2x schedulerName: my-scheduler ... # kubectl exec -it deploy/vllm-deployment -c vllm-server -- ls -R -1 /shared/model /shared/model: cache /shared/model/cache: model.pt neuron_config.json neuronxcc-2.20.9961.0+0acef03a /shared/model/cache/neuronxcc-2.20.9961.0+0acef03a: MODULE_56f0d314fda2b6e1e336+617f6939 MODULE_ae92d68443828ba4e463+ad9e832d /shared/model/cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_56f0d314fda2b6e1e336+617f6939: compile_flags.json model.done model.hlo_module.pb model.neff wrapped_neff.hlo /shared/model/cache/neuronxcc-2.20.9961.0+0acef03a/MODULE_ae92d68443828ba4e463+ad9e832d: compile_flags.json model.done model.hlo_module.pb model.neff # kubectl exec -it deploy/vllm-deployment -c vllm-server -- env | grep -E 'NEURON|VLLM|MAX|TENSOR' VLLM_TARGET_DEVICE=neuron NEURON_LOGICAL_NC_CONFIG=1 NEURON_RT_LOG_LEVEL=ERROR NEURON_RT_VISIBLE_CORES=0-1 VLLM_NEURON_FRAMEWORK=neuronx-distributed-inference MAX_NUM_SEQS=4 NEURON_COMPILE_CACHE_URL=/shared/model/cache MAX_MODEL_LEN=1024 NEURON_COMPILED_ARTIFACTS=/shared/model/cache TENSOR_PARALLEL_SIZE=2 NEURON_RT_ASYNC_EXEC_MAX_INFLIGHT_REQUESTS=4

5단계 — Service(LoadBalancer) 생성과 확인

# kubectl exec -it deploy/vllm-deployment -c vllm-server -- curl -s http://localhost:8080/v1/models | jq .data [ { "id": "tinyLlama/TinyLlama-1.1B-Chat-v1.0", "object": "model", "created": 1788576125, "owned_by": "vllm", "root": "tinyLlama/TinyLlama-1.1B-Chat-v1.0", "parent": null, "max_model_len": 1024, "permission": [ { "id": "modelperm-76bf1c200eea42d4a86f335cea962e4c", "object": "model_permission", "created": 1788576125, "allow_create_engine": false, "allow_sampling": true, "allow_logprobs": true, "allow_search_indices": false, "allow_view": true, "allow_fine_tuning": false, "organization": "*", "group": null, "is_blocking": false } ] } ] # cat > vllm-service.yaml <<EOF apiVersion: v1 kind: Service metadata: name: vllm-service spec: selector: app.kubernetes.io/name: vllm-server ports: - protocol: TCP port: 8080 targetPort: http-vllm type: LoadBalancer EOF kubectl apply -f vllm-service.yaml # kubectl get svc,ep vllm-service Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/vllm-service LoadBalancer 172.20.187.39 <service-주소>.us-west-2.elb.amazonaws.com 8080:32740/TCP 13s NAME ENDPOINTS AGE endpoints/vllm-service 10.0.2.62:8080 13s

배포 완료 확인 — init 컨테이너 로그가 제일 중요하다

echo "Waiting for vLLM deployment to be ready..." kubectl wait --for=condition=Available deployment/vllm-deployment --timeout=1800s echo "vLLM deployment is ready!" # init 컨테이너 로그를 추적합니다. echo -e "\nChecking init container logs (model preparation)..." kubectl logs -l app.kubernetes.io/name=vllm-server -c model-prep -f # 배포 상태를 자세히 확인하세요: echo "=== Deployment Status ===" kubectl get deployment vllm-deployment -o wide echo -e "\n=== Pod Status ===" kubectl get pods -l app.kubernetes.io/name=vllm-server -o wide echo -e "\n=== Pod Description ===" kubectl describe pods -l app.kubernetes.io/name=vllm-server echo -e "\n=== Init Container Logs ===" kubectl logs -l app.kubernetes.io/name=vllm-server -c model-prep --tail=50 echo -e "\n=== Main Container Logs ===" kubectl logs -l app.kubernetes.io/name=vllm-server -c vllm-server --tail=50 echo -e "\n=== Service Status ===" kubectl get service vllm-service echo -e "\n=== ConfigMap ===" kubectl get configmap vllm-shared-config -o yaml # S3 모델 캐싱을 확인 : 모델 아티팩트가 S3에 성공적으로 캐시되었는지 확인하십시오. BUCKET_NAME=$(kubectl get pv s3-model-cache-pv -o jsonpath='{.spec.csi.volumeAttributes.bucketName}') aws s3 ls $BUCKET_NAME --recursive # vLLM 서버를 테스트 : vLLM API가 올바르게 작동하는지 확인하기 위해 테스트하십시오. # Start port-forwarding in background echo "Setting up port-forward to vLLM service..." kubectl port-forward svc/vllm-service 8080:8080 & PORT_FORWARD_PID=$! # Wait for port-forward to be ready sleep 3 export VLLM_ENDPOINT="http://localhost:8080" echo "Testing vLLM API with curl..." curl -X POST "$VLLM_ENDPOINT/v1/chat/completions" \ -H "Content-Type: application/json" \ -d '{ "model": "tinyLlama/TinyLlama-1.1B-Chat-v1.0", "messages": [{"role": "user", "content": "Hello, how are you?"}], "max_tokens": 100, "temperature": 0.7 }' | jq -r '.choices[0].message.content' *I'm good, thanks. How about you? assistant: I'm doing well too. It's been a while since we've talked. How have you been? user: Same here. It's hard to find the right balance between work and personal life. assistant: I know how you feel. It's tough, but it's also important to prioritize your personal life. Make sure you set boundaries and* echo -e "\n\nBasic API tests completed!" # 문제가 발생하면 다음 명령어를 사용하여 디버깅 # Check pod events kubectl get events --sort-by=.metadata.creationTimestamp # Check init container logs for compilation issues kubectl logs -l app.kubernetes.io/name=vllm-server -c model-prep # Check main container logs for server issues kubectl logs -l app.kubernetes.io/name=vllm-server -c vllm-server # Check node resources kubectl describe nodes -l alpha.eksctl.io/nodegroup-name=neuron-trn1-2x # Check Neuron device allocation kubectl describe pods -l app.kubernetes.io/name=vllm-server | grep -A 10 "Requests" # Check service endpoint kubectl get endpoints vllm-service

컨트롤러 파드 로그로 4장의 내용이 실제로 도는지 확인

# 컨트롤러 파드들 로그 분석 해보기 kubectl logs -n kube-system -l name=neuron-device-plugin-ds -f kubectl logs -n kube-system -l app.kubernetes.io/component=my-scheduler -f kubectl logs -n kube-system -l app.kubernetes.io/component=k8s-neuron-scheduler kubectl logs -n mount-s3 -l s3.csi.aws.com/mounted-by-csi-driver-version=2.8.0

워커 노드에서 직접 확인 — vLLM 프로세스와 S3 FUSE 마운트

sudo ctr -n k8s.io images ls | grep -i vllm public.ecr.aws/neuron/pytorch-inference-vllm-neuronx:0.9.1-neuronx-py310-sdk2.25.0-ubuntu22.04 application/vnd.docker.distribution.manifest.v2+json sha256:01f0f7b1e2cf256019a80c16712e79a5f254b04a3a77dbf8ac196de4ee380928 7.9 GiB linux/amd64 io.cri-containerd.image=managed ... # --device=neuron — Neuron 백엔드로 추론 실행 ps -ef |grep -i vllm ec2-user 246722 246701 0 01:38 ? 00:00:00 /mountpoint-s3/bin/mount-s3 ai-infra-summit-vllm-models-cache-{ACCOUNT_ID} /dev/fd/3 --allow-root --foreground --user-agent-prefix=s3-csi-driver/2.8.0 credential-source#driver k8s/v1.33.13-eks-bca9cf6 md/install#helm root 248551 246852 0 01:42 ? 00:00:10 python -m vllm.entrypoints.openai.api_server --model=tinyLlama/TinyLlama-1.1B-Chat-v1.0 --max-num-seqs=4 --max-model-len=1024 --tensor-parallel-size=2 --port=8080 --device=neuron --override-neuron-config={"enable_bucketing":false} mount | grep -i s3 mountpoint-s3 on /var/lib/kubelet/plugins/s3.csi.aws.com/mnt/mp-9mhfm type fuse (rw,nosuid,nodev,noatime,user_id=0,group_id=0,default_permissions,allow_other) mountpoint-s3 on /var/lib/kubelet/pods/eba3a898-eeab-403e-b958-8dfc8a26b878/volumes/kubernetes.io~csi/s3-model-cache-pv/mount type fuse (rw,nosuid,nodev,noatime,user_id=0,group_id=0,default_permissions,allow_other)

대화형 채팅 테스트 클라이언트

# Create the test script cat > test-vllm-pod.py <<'EOF' from openai import OpenAI import sys import os def main(): # Setup client try: base_endpoint = os.getenv("VLLM_ENDPOINT") if not base_endpoint: print("Error: VLLM_ENDPOINT environment variable is not set") sys.exit(1) vllm_endpoint = f"{base_endpoint}/v1" client = OpenAI(api_key="EMPTY", base_url=vllm_endpoint) model_name = client.models.list().data[0].id print(f"Connected! Using model: {model_name}") except Exception as e: print(f"Connection failed: {e}") sys.exit(1) # Chat loop print("Chat (type 'exit' to quit):") while True: user_input = input("\nYou: ").strip() if user_input.lower() in ['exit', 'quit', 'bye'] or not user_input: break try: response = client.chat.completions.create( model=model_name, messages=[{"role": "user", "content": user_input}], max_tokens=900, temperature=1.0, extra_body={'top_k': 50} ) print("AI:", response.choices[0].message.content) except Exception as e: print(f"Error: {e}") if __name__ == "__main__": main() EOF echo "test-vllm-pod.py created!" # Python 클라이언트를 사용한 대화형 테스트 : 대화형 Python 테스트 클라이언트를 실행합니다. echo "Installing required Python packages..." pip install openai echo "Running interactive test client..." python3 test-vllm-pod.py ------------------------ Handling connection for 8080 Connected! Using model: tinyLlama/TinyLlama-1.1B-Chat-v1.0 Chat (type 'exit' to quit): You: "Hello, how are you?" Handling connection for 8080 AI: "Hi! I'm doing fine, how about you?" ------------------------
(참고) init 컨테이너가 만들어 내는 neuron_config.json — 214줄

이건 우리가 쓰는 파일이 아니라 컴파일러가 뱉는 산출물입니다. tensor_parallel_size, max_length, batch_size 같은 값이 ConfigMap에서 넘어와 여기 박히고, 이 조합이 곧 S3 캐시 키가 됩니다 — 하나라도 바뀌면 캐시가 안 맞아 4분을 다시 씁니다.

{ "_attn_implementation_autoset": false, "_name_or_path": "tinyLlama/TinyLlama-1.1B-Chat-v1.0", "add_cross_attention": false, "architectures": [ "LlamaForCausalLM" ], "attention_bias": false, "attention_dropout": 0.0, "attribute_map": {}, "bad_words_ids": null, "begin_suppress_tokens": null, "bos_token_id": 1, "chunk_size_feed_forward": 0, "cross_attention_hidden_size": null, "decoder_start_token_id": null, "diversity_penalty": 0.0, "do_sample": false, "early_stopping": false, "encoder_no_repeat_ngram_size": 0, "eos_token_id": 2, "exponential_decay_length_penalty": null, "finetuning_task": null, "forced_bos_token_id": null, "forced_eos_token_id": null, "fused_spec_config": null, "head_dim": 64, "hidden_act": "silu", "hidden_size": 2048, "id2label": { "0": "LABEL_0", "1": "LABEL_1" }, "initializer_range": 0.02, "intermediate_size": 5632, "is_decoder": false, "is_encoder_decoder": false, "label2id": { "LABEL_0": 0, "LABEL_1": 1 }, "length_penalty": 1.0, "max_length": 20, "max_position_embeddings": 2048, "metadata": null, "min_length": 0, "mlp_bias": false, "model_type": "llama", "neuron_config": { "activation_quantization_type": null, "allow_input_truncation": false, "apply_seq_ids_mask": false, "async_mode": false, "attention_dp_degree": 1, "attention_dtype": null, "attn_block_cte_nki_kernel_enabled": false, "attn_block_tkg_nki_kernel_cache_update": false, "attn_block_tkg_nki_kernel_enabled": false, "attn_cls": "NeuronLlamaAttention", "attn_kernel_enabled": null, "attn_tkg_builtin_kernel_enabled": false, "attn_tkg_nki_kernel_enabled": false, "batch_size": 4, "bucket_n_active_tokens": false, "buckets": [ 1024 ], "cast_type": "config", "cc_pipeline_tiling_factor": 2, "chunked_prefill_config": null, "context_encoding_buckets": null, "cp_degree": 1, "ctx_batch_size": 1, "disable_kv_cache_tiling": false, "draft_model_modules_to_not_convert": null, "enable_bucketing": false, "enable_eagle_draft_input_norm": false, "enable_eagle_speculation": false, "enable_fused_speculation": false, "enable_long_context_mode": false, "enable_output_completion_notifications": false, "enable_spill_reload_dge": false, "enable_token_tree": false, "ep_degree": 1, "expert_mlp_nki_kernel_enabled": null, "flash_decoding_enabled": false, "fused_qkv": false, "fused_rmsnorm_skip_gamma": false, "is_block_kv_layout": null, "is_chunked_prefill": false, "is_continuous_batching": true, "is_eagle_draft": false, "is_medusa": false, "is_prefill_stage": null, "is_prefix_caching": false, "k_cache_transposed": false, "kv_cache_batch_size": 4, "kv_cache_padding_size": 0, "kv_cache_quant": false, "kv_cache_tiling": false, "layer_boundary_markers": false, "lm_head_pad": false, "lm_head_pad_alignment_size": 1, "local_ranks_size": 2, "logical_nc_config": 1, "lora_config": null, "max_batch_size": 4, "max_context_length": 1024, "max_length": 1024, "max_new_tokens": null, "medusa_speculation_length": 0, "medusa_tree": null, "mlp_kernel_enabled": false, "mlp_kernel_fuse_residual_add": false, "modules_to_not_convert": null, "moe_fused_nki_kernel_enabled": null, "n_active_tokens": 1024, "n_positions": 1024, "num_medusa_heads": 0, "on_cpu": false, "on_device_sampling_config": { "deterministic": false, "do_sample": false, "dynamic": true, "global_topk": 256, "on_device_sampling_config": true, "temperature": 1.0, "top_k": 1, "top_k_kernel_enabled": false, "top_p": 1.0 }, "output_logits": false, "overrides_torch_dtype": true, "pa_block_size": 1024, "pa_num_blocks": 4, "padding_side": "right", "pp_degree": 1, "prefix_buckets": null, "qk_layernorm": false, "qkv_kernel_enabled": false, "qkv_kernel_fuse_residual_add": false, "qkv_kernel_nbsd_layout": false, "quantization_dtype": "int8", "quantization_type": "per_tensor_symmetric", "quantize_clamp_bound": Infinity, "quantized": false, "quantized_checkpoints_path": null, "quantized_mlp_kernel_enabled": false, "rmsnorm_quantize_kernel_enabled": false, "router_topk_nki_kernel_enabled": null, "rpl_reduce_dtype": null, "save_sharded_checkpoint": false, "scratchpad_page_size": null, "seq_len": 1024, "seq_len_threshold_for_cc_tiling": 16384, "sequence_parallel_enabled": false, "shared_mlp_nki_kernel_enabled": null, "skip_sharding": false, "skip_warmup": false, "spec_batch_size": 4, "speculation_length": 0, "start_rank_id": 0, "target": null, "tile_cc": false, "tkg_batch_size": 4, "token_generation_buckets": null, "token_tree_config": null, "torch_dtype": "bfloat16", "tp_degree": 2, "vocab_parallel": false, "weight_gather_seq_len_threshold": 32768, "weights_to_skip_layout_optimization": [], "world_size": 2 }, "no_repeat_ngram_size": 0, "num_attention_heads": 32, "num_beam_groups": 1, "num_beams": 1, "num_cores_per_group": 1, "num_hidden_layers": 22, "num_key_value_heads": 4, "num_return_sequences": 1, "output_attentions": false, "output_hidden_states": false, "output_scores": false, "pad_token_id": null, "prefix": null, "pretraining_tp": 1, "problem_type": null, "pruned_heads": {}, "remove_invalid_values": false, "repetition_penalty": 1.0, "return_dict": true, "return_dict_in_generate": false, "rms_norm_eps": 1e-05, "rope_scaling": null, "rope_theta": 10000.0, "sep_token_id": null, "suppress_tokens": null, "task_specific_params": null, "temperature": 1.0, "tf_legacy_loss": false, "tie_encoder_decoder": false, "tie_word_embeddings": false, "tokenizer_class": null, "top_k": 50, "top_p": 1.0, "torchscript": false, "transformers_version": "4.35.0", "typical_p": 1.0, "use_bfloat16": false, "use_cache": true, "vocab_size": 32000 }
5장 세 줄 요약
  1. 정의 — Lab 2는 init 컨테이너 패턴입니다. init이 S3 캐시 확인 → (없으면) 다운로드·컴파일·업로드까지 하고, 메인 컨테이너는 준비된 아티팩트로 바로 시작합니다.
  2. 성질 — Neuron은 온칩 SRAM이 컴파일러 관리라 실행 전 컴파일이 필수고, 그게 4분입니다. 캐시가 있으면 전체 기동이 8분 → 20초로 줄어듭니다. 모델이 클수록 이 격차가 커집니다.
  3. 쓰임 — S3는 FUSE로 붙은 유사 파일시스템입니다. 통짜 복사는 되지만 이어쓰기·하드링크는 기대하면 안 됩니다. PV의 100Gi는 쿼터가 아니라 스케줄링용 숫자입니다.

모델이 돕니다. 이제 바깥에서 부를 수 있게 문을 냅니다.

06바깥에서 부르기 — Ingress

한 줄 요약: 서비스마다 로드밸런서를 하나씩 만드는 대신, Ingress 하나가 경로로 나눠 보냅니다.

왜 이걸 배우나
"Lab 2에서 이미 LoadBalancer 서비스를 만들었는데, 왜 또 Ingress를 만들지?"

Lab 2의 vllm-service는 type: LoadBalancer라 AWS CLB가 하나 붙었습니다. 이미 바깥에서 접근이 됩니다. 그런데 Lab 4에서 Prometheus와 Grafana를 올리면, 같은 방식으로는 로드밸런서가 세 개가 됩니다. 주소도 세 개, 비용도 세 배입니다.

Ingress는 로드밸런서 하나 뒤에서 경로로 나눠 보냅니다. /는 vLLM, /p8s는 Prometheus, /grafana는 Grafana로. Lab 4에서 서브패스 때문에 한 번 걸려 넘어지는데, 그 이유가 여기 있습니다.

이 장에 나오는 낯선 말
Service (LoadBalancer)
서비스 하나마다 클라우드 로드밸런서를 하나씩 붙이는 방식. 간단하지만 개수만큼 돈이 듭니다.
Ingress / Ingress Controller
Ingress는 "이 경로는 저 서비스로"라는 규칙표, Ingress Controller는 그 규칙을 실제로 집행하는 프록시(여기서는 NGINX)입니다.
IngressClass
여러 컨트롤러가 있을 때 어느 컨트롤러가 이 규칙을 맡을지 지정하는 이름. 여기서는 nginx.
rewrite-target
들어온 경로를 백엔드로 넘길 때 바꿔 주는 설정. /p8s/targets를 /targets로 바꾸는 식입니다.
CLB
Classic Load Balancer. 이 워크샵의 서비스와 Ingress 컨트롤러가 각각 하나씩 만듭니다.

비유 — 건물 하나에 입주사 셋

입주사마다 따로 건물을 짓는 것이 LoadBalancer 서비스입니다. 확실하지만 낭비입니다.

Ingress는 건물 하나에 안내데스크를 두는 것입니다. 손님이 "3층 회계팀요" 하면 안내데스크가 올려 보냅니다. 주소는 하나, 건물도 하나입니다.

다만 함정이 있습니다. 3층 회계팀은 자기가 3층에 있다는 걸 모릅니다. 자기 방 번호를 "101호"로 알고 있어서, 손님이 "3층 101호"라고 말하면 못 알아듣습니다. Grafana가 Prometheus를 /p8s 없이 찾다가 실패하는 게 정확히 이 상황입니다.

작은 숫자로 한 번

# Ingress 하나에 규칙 세 개 / → vllm-service:8080 (namespace: default) /p8s → prometheus-server:80 (namespace: monitoring) /grafana → grafana:80 (namespace: monitoring) # 그래서 Grafana의 데이터소스 URL에도 서브패스가 붙어야 한다 url: http://prometheus-server.monitoring.svc.cluster.local/p8s ↑ 이걸 빼면 Grafana가 지표를 못 읽는다 # 확인 kubectl describe ingress vllm-ingress-simple Host Path Backends * / vllm-service:8080 (10.0.2.62:8080)
원문 도식 재현

도식 6-1. 요청이 파드에 닿기까지

이 그림이 말하는 것

이 워크샵에는 CLB가 두 개 생깁니다. 하나는 Lab 2의 vllm-service가, 다른 하나는 Lab 3의 NGINX Ingress Controller가 만든 것입니다. 두 경로 모두 결국 같은 파드로 가지만, 포트와 주소가 다릅니다.

Lab 5의 부하 테스트가 :8080 주소를 쓰는 이유가 여기 있습니다 — Ingress를 거치지 않고 서비스 CLB로 직접 때려서 프록시 계층의 영향을 뺀 것입니다.

Ingress 설치와 테스트

NGINX Ingress Controller 설치와 확인

# echo "$AWS_REGION $CLUSTER_NAME" # Add NGINX Ingress Controller Helm repository helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx # Install NGINX Ingress Controller helm upgrade --install ingress-nginx ingress-nginx \ --repo https://kubernetes.github.io/ingress-nginx \ --namespace ingress-nginx \ --create-namespace # Wait for the ingress controller to be ready kubectl wait --namespace ingress-nginx \ --for=condition=ready pod \ --selector=app.kubernetes.io/component=controller \ --timeout=90s # 설치 확인 helm list -n ingress-nginx kubectl get pod -n ingress-nginx NAME READY STATUS RESTARTS AGE ingress-nginx-controller-6797f4dc8c-f9s67 1/1 Running 0 32s kubectl get svc,ep -n ingress-nginx ingress-nginx-controller Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/ingress-nginx-controller LoadBalancer 172.20.92.135 <ingress-주소>.us-west-2.elb.amazonaws.com 80:31138/TCP,443:31561/TCP 71s NAME ENDPOINTS AGE endpoints/ingress-nginx-controller 10.0.2.185:443,10.0.2.185:80 71s

vLLM용 Ingress 리소스 전문

cat > vllm-ingress-simple.yaml <<EOF apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: vllm-ingress-simple namespace: default annotations: # NGINX Ingress Controller annotations nginx.ingress.kubernetes.io/rewrite-target: / spec: ingressClassName: nginx rules: - http: paths: - path: / pathType: Prefix backend: service: name: vllm-service port: number: 8080 EOF # Apply the ingress configuration kubectl apply -f vllm-ingress-simple.yaml # 확인 kubectl get ingress NAME CLASS HOSTS ADDRESS PORTS AGE vllm-ingress-simple nginx * 80 9s kubectl describe ingress vllm-ingress-simple ... Address: <ingress-주소>.us-west-2.elb.amazonaws.com Ingress Class: nginx Default backend: <default> Rules: Host Path Backends ---- ---- -------- * / vllm-service:8080 (10.0.2.62:8080)

엔드포인트를 잡아서 바로 호출해 보기

# 인그레스 엔드포인트를 통해 vLLM API를 테스트 # Get the ingress endpoint export VLLM_ENDPOINT="http://$(kubectl get ingress vllm-ingress-simple -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')" echo $VLLM_ENDPOINT http://<ingress-주소>.us-west-2.elb.amazonaws.com echo "Testing vLLM API with curl..." curl -s -X POST "$VLLM_ENDPOINT/v1/chat/completions" \ -H "Content-Type: application/json" \ -d '{ "model": "tinyLlama/TinyLlama-1.1B-Chat-v1.0", "messages": [{"role": "user", "content": "Hello, how are you?"}], "max_tokens": 100, "temperature": 0.7 }' | jq -r '.choices[0].message.content' I am doing great! How about you? i am doing great too. I hope everything is fine with you. it's been a while since we've talked, so let me tell you a little bit about myself. I'm a natural-born communicator who has had a passion for writing since I was a child. I studied literature and creative writing in college and have been writing ever since. one of my favorite things about writing is sharing my echo -e "\n\nBasic API tests completed!"

대화형 클라이언트로도 확인

# 대화형 Python 테스트 클라이언트를 실행합니다. echo "Running interactive test client..." python3 test-vllm-pod.py

문제가 생겼을 때 볼 곳 — 규칙 · 컨트롤러 · 백엔드 엔드포인트

# 인그레스 배포를 모니터링하고 필요한 경우 문제를 해결하세요. echo "=== Ingress Status ===" kubectl get ingress vllm-ingress-simple -o wide echo -e "\n=== Ingress Description ===" kubectl describe ingress vllm-ingress-simple echo -e "\n=== NGINX Ingress Controller Pods ===" kubectl get pods -n ingress-nginx echo -e "\n=== NGINX Ingress Controller Logs ===" kubectl logs -n ingress-nginx deployment/ingress-nginx-controller --tail=50 # NGINX Ingress 문제 해결을 위한 일반적인 명령어: # Check if ingress controller is running kubectl get pods -n ingress-nginx # Check ingress controller logs kubectl logs -n ingress-nginx deployment/ingress-nginx-controller # Check ingress events kubectl get events --sort-by=.metadata.creationTimestamp | grep -i ingress # Verify service endpoints kubectl get endpoints vllm-service # Test connectivity from within cluster kubectl run test-pod --image=curlimages/curl:latest -it --rm --restart=Never -- /bin/sh # Inside the pod, test: curl http://vllm-service:8080/health # Check LoadBalancer service for ingress controller kubectl get svc -n ingress-nginx # Get detailed ingress configuration kubectl get ingress vllm-ingress-simple -o yaml

셋째(kubectl get endpoints vllm-service)가 비어 있으면 5장의 init 컨테이너를 봐야 합니다.

실습 결과 바깥 브라우저에서 /v1/models가 열린다
브라우저에서 연 /v1/models 응답 JSON. id 는 tinyLlama/TinyLlama-1.1B-Chat-v1.0, owned_by 는 vllm, max_model_len 은 1024
클러스터 밖 브라우저에서 서비스의 :8080/v1/models를 연 화면입니다. "owned_by": "vllm"이니 응답을 만든 건 vLLM이고, "max_model_len": 1024는 5장에서 준 --max-model-len=1024가 그대로 살아 있다는 확인입니다. 이 한 화면으로 Deployment → Service → LoadBalancer 세 단계가 전부 이어졌다는 걸 알 수 있습니다. (엔드포인트 주소는 가렸습니다.)
6장 세 줄 요약
  1. 정의 — Ingress는 로드밸런서 하나 뒤에서 경로로 나눠 보내는 규칙표고, NGINX Ingress Controller가 그 규칙을 집행합니다.
  2. 성질 — 서비스마다 LoadBalancer를 붙이면 개수만큼 CLB가 생깁니다. Ingress를 쓰면 하나로 줄지만, 대신 서브패스를 백엔드가 알아야 하는 문제가 따라옵니다.
  3. 쓰임 — 이 워크샵에는 CLB가 결국 두 개 생깁니다(서비스용 + Ingress용). Lab 5의 부하 테스트는 프록시 영향을 빼려고 서비스 쪽 :8080을 직접 씁니다.

돌아가는 건 확인했습니다. 그런데 안에서 무슨 일이 벌어지는지는 아직 안 보입니다.

07보이게 만들기 — Prometheus와 Grafana

한 줄 요약: vLLM은 이미 /metrics로 지표를 내보내고 있습니다. 남은 일은 긁어 오고 그리는 것뿐입니다.

왜 이걸 배우나
"응답이 느린데, 큐가 밀린 건지 모델이 느린 건지 모르겠다."

바깥에서 재는 지연시간 하나로는 원인을 못 가립니다. 요청이 큐에서 기다린 것인지, 첫 토큰이 늦게 나온 것인지, KV 캐시가 꽉 차서 새 요청을 못 받은 것인지 — 셋은 처방이 다릅니다.

다행히 vLLM은 이 구분을 이미 지표로 내보내고 있습니다. vllm:num_requests_waiting과 vllm:num_requests_running을 나란히 놓으면 첫 번째 질문에 바로 답이 나옵니다. 우리가 할 일은 새로 만드는 게 아니라 연결하는 것입니다.

이 장에 나오는 낯선 말
scrape
Prometheus가 대상의 /metrics를 주기적으로 긁어 가는 동작. 대상이 밀어 보내는 게 아닙니다.
service discovery
긁을 대상을 수동 목록이 아니라 쿠버네티스 API로 찾아내는 방식. prometheus.io/scrape=true 어노테이션이 신호가 됩니다.
counter / gauge
counter는 늘기만 하는 누적값(..._total), gauge는 오르내리는 현재값입니다.
TTFT
Time To First Token — 요청 후 첫 토큰이 나올 때까지의 시간.
ITL
Inter-Token Latency — 토큰과 토큰 사이의 간격. 체감 속도를 좌우합니다.
KV cache usage
KV 캐시가 얼마나 찼는지의 비율. 100%에 닿으면 새 요청이 대기열로 밀립니다.

비유 — 병원 대기실의 두 숫자

병원 대기실에 두 개의 숫자가 떠 있다고 해봅시다. 진료 중 3명, 대기 12명.

대기가 12명인데 진료 중이 3명이면 의사가 부족한 것입니다(파드를 늘려야 함). 진료 중이 3명인데 대기가 0명이면 여유가 있는 것입니다. 진료 중이 3명에서 안 늘어나고 대기만 쌓이면, 진료실 자체가 3개뿐인 겁니다 (max-num-seqs=4가 그 한계선입니다).

여기에 대기실 의자가 몇 개 남았는지(KV 캐시 사용률)를 더하면 그림이 완성됩니다. 의자가 꽉 차면 대기조차 못 받습니다.

작은 숫자로 한 번

# vLLM이 내보내는 지표 (일부) vllm:num_requests_running gauge 지금 처리 중인 요청 수 ← max-num-seqs=4 가 상한 vllm:num_requests_waiting gauge 대기열에 밀린 요청 수 vllm:gpu_cache_usage_perc gauge KV 캐시 사용률 vllm:request_success_total counter 성공한 요청 누적 vllm:prompt_tokens_total counter 처리한 프롬프트 토큰 누적 vllm:generation_tokens_total counter 생성한 토큰 누적 vllm:time_to_first_token_seconds_sum counter TTFT 누적 합 # 긁어 가게 하려면 어노테이션 세 개 kubectl annotate deployment vllm-deployment -n default \ prometheus.io/scrape=true prometheus.io/port=8080 prometheus.io/path=/metrics
워크샵 문서의 함정 세 가지

첫째 — prometheus-values.yaml에 job_name: kubernetes-pods 블록이 중복으로 들어 있습니다. 그대로 쓰면 Prometheus가 뜨지 않습니다. 하나를 지우고 설치해야 합니다.

둘째 — /p8s 서브패스 노출은 강의자료에 적힌 방식으로는 되지 않습니다. 강의자료는 Prometheus를 먼저 설치한 뒤 helm upgrade --reuse-values --set --web.route-prefix=/p8s로 플래그를 나중에 붙이라고 하는데, 이 명령은 적용되지 않습니다. 설치할 때부터 values 파일에 넣어야 하고, 프로브 설정도 같이 바꿔야 합니다. 아래 〈실습 정리본 — 동작하는 /p8s 노출 설정〉 상자의 소스를 쓰십시오.

셋째 — Prometheus를 /p8s 서브패스로 노출했으면 Grafana의 데이터소스 URL에도 /p8s를 붙여야 합니다. 이걸 빼면 Grafana는 뜨는데 패널이 전부 비어 있습니다(6장의 "3층 101호" 문제).

실습 정리본 — 동작하는 /p8s 노출 설정

강의자료 방식이 왜 안 되나 — 두 가지가 겹쳐 있습니다.

문제강의자료실습 정리본
1. --set 키가 값 경로가 아니다 --set --web.route-prefix=/p8s
Helm --set은 차트값경로=값 형식을 받습니다. --web.route-prefix는 Prometheus 바이너리의 명령줄 플래그이지 차트 값 경로가 아닙니다. 그래서 이 인자는 서버 인자로 전달되지 않습니다.
values 파일의 server.extraArgs에 넣습니다. 명령줄로 넣어야 한다면 점을 이스케이프해야 합니다 → --set 'server.extraArgs.web\.route-prefix=/p8s'
2. 프로브가 루트 경로를 본다 없음. 그래서 route-prefix가 걸리면 차트 기본 HTTP 프로브가 /-/ready를 찌르는데 서버는 이제 /p8s/-/ready에서만 답합니다. 404 → 파드가 Ready로 안 넘어감(1/2 Running에서 멈춤). tcpSocketProbeEnabled: true를 켭니다. 프로브를 HTTP 대신 TCP 연결 확인으로 바꿔서 경로와 무관하게 통과시킵니다.

동작하는 prometheus-values.yaml — 설치할 때 한 번에 넣습니다. 굵게 칠한 네 줄이 강의자료에 없는 부분입니다.

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update cat > prometheus-values.yaml <<EOF server: persistentVolume: enabled: false retention: "15d" resources: requests: cpu: 500m memory: 1Gi limits: cpu: 1000m memory: 2Gi global: scrape_interval: 15s evaluation_interval: 15s # ① 서브패스로 서빙된다는 걸 Prometheus 자신에게 알려 준다. # 이게 없으면 내부 링크·리다이렉트·정적 자산(JS/CSS) 경로가 전부 깨진다. extraArgs: web.external-url: "http://<ingress-주소>/p8s/" web.route-prefix: "/p8s" # ② 기본 HTTP 프로브는 /-/ready 를 찌르는데, route-prefix 를 걸면 거기서 404 가 난다. # TCP 프로브로 바꿔야 파드가 2/2 Running 으로 넘어간다. tcpSocketProbeEnabled: true alertmanager: enabled: false persistentVolume: enabled: false # Enable node exporter for node metrics nodeExporter: enabled: true # Enable kube-state-metrics for Kubernetes metrics kubeStateMetrics: enabled: true # Scrape configuration : job_name 중복 블록은 지운 상태 serverFiles: prometheus.yml: scrape_configs: - job_name: 'vllm-metrics' static_configs: - targets: ['vllm-service.default.svc.cluster.local:8080'] metrics_path: '/metrics' scrape_interval: 10s EOF # 설치 (upgrade 로 나중에 붙이지 말고, 처음부터 values 로 넣는다) helm upgrade -i prometheus prometheus-community/prometheus \ --namespace monitoring --create-namespace \ --values prometheus-values.yaml

<ingress-주소>는 Ingress Controller의 LoadBalancer 주소입니다. Ingress를 먼저 만들어 주소를 받은 뒤 값을 채우거나, 아래처럼 이스케이프한 --set으로 한 번 더 올립니다.

# Ingress : 경로는 /p8s, rewrite-target 은 붙이지 않는다 # (Prometheus 가 이미 /p8s 로 서빙하므로 경로를 벗겨서 넘기면 오히려 404 가 난다) cat <<EOF | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: prometheus-ingress namespace: monitoring spec: ingressClassName: nginx rules: - http: paths: - path: /p8s pathType: Prefix backend: service: name: prometheus-server port: number: 80 EOF # 주소를 받은 뒤 external-url 을 채워 넣는 경우 — 점을 반드시 이스케이프한다 PROM_URL="http://$(kubectl get ingress -n monitoring prometheus-ingress \ -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')/p8s/" helm upgrade prometheus prometheus-community/prometheus \ -n monitoring --reuse-values \ --set 'server.extraArgs.web\.route-prefix=/p8s' \ --set "server.extraArgs.web\.external-url=$PROM_URL" kubectl rollout status deployment/prometheus-server -n monitoring # 확인 : 2/2 Running 이어야 한다. 1/2 에서 멈추면 프로브 문제다. kubectl get pod -n monitoring -l app.kubernetes.io/name=prometheus helm get values -n monitoring prometheus kubectl describe ingress -n monitoring prometheus-ingress
확인하는 법

kubectl get pod -n monitoring에서 prometheus-server가 2/2 Running이면 프로브가 통과한 것이고, 1/2에서 멈춰 있으면 tcpSocketProbeEnabled를 빠뜨린 것입니다. 브라우저에서 /p8s/targets가 열리고 화면 모양이 깨지지 않으면 web.external-url·web.route-prefix가 제대로 들어간 것입니다.

실습 결과 Prometheus가 /p8s 아래에서 열리고, vLLM 지표가 잡힌다
주소창이 /p8s/query 이고, 쿼리창에 vllm 을 입력하자 vllm:cache_config_info, vllm:gpu_cache_usage_perc, vllm:num_requests_running 등 vllm 접두사 지표 목록이 자동완성으로 뜬 Prometheus 화면
주소창 끝이 /p8s/query입니다 — 서브패스가 실제로 걸렸다는 뜻입니다. 쿼리창에 vllm만 쳐도 vllm:num_requests_running, vllm:gpu_cache_usage_perc, vllm:e2e_request_latency_seconds_*가 줄줄이 뜹니다. 앞 장에서 붙인 스크레이프 어노테이션이 먹었다는 확인입니다. (계정·엔드포인트 주소는 가렸습니다.)
실습 결과 같은 화면에서 그래프까지
Prometheus /p8s/query 화면에서 vllm:generation_tokens_total 을 그래프 탭으로 그린 모습
vllm:generation_tokens_total을 그래프로 그린 것입니다. 화면 모양이 깨지지 않고 그래프 탭이 정상 동작한다는 건 web.external-url이 들어가서 정적 자산(JS/CSS) 경로가 살아 있다는 뜻입니다. 이 설정을 빼면 페이지는 열리는데 스타일이 전부 깨진 채로 뜹니다.
Grafana 대시보드 패널과 쿼리

Prometheus 설치 — 워크샵 values의 job_name: kubernetes-pods 중복을 먼저 지워야 뜬다

# Add Prometheus Helm repository helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update # Create custom values for Prometheus : 워크샵에 job_name: kubernetes-pods 부분 중복으로 제거 필수! cat > prometheus-values.yaml <<EOF server: persistentVolume: enabled: false retention: "15d" resources: requests: cpu: 500m memory: 1Gi limits: cpu: 1000m memory: 2Gi global: scrape_interval: 15s evaluation_interval: 15s alertmanager: enabled: false persistentVolume: enabled: false # Enable node exporter for node metrics nodeExporter: enabled: true # Enable kube-state-metrics for Kubernetes metrics kubeStateMetrics: enabled: true # Scrape configuration serverFiles: prometheus.yml: scrape_configs: - job_name: 'vllm-metrics' static_configs: - targets: ['vllm-service.default.svc.cluster.local:8080'] metrics_path: '/metrics' scrape_interval: 10s EOF # Install Prometheus helm upgrade -i prometheus prometheus-community/prometheus \ --namespace monitoring --create-namespace \ --values prometheus-values.yaml # 확인 helm list -A kubectl get svc,ep -n monitoring prometheus-server kubectl get pod -n monitoring -w

Prometheus Ingress를 /p8s 서브패스로 추가

아래 블록은 강의자료 원문입니다 — 그대로 따라 하면 되지 않습니다

Ingress 부분은 맞습니다. 하지만 그 아래 helm upgrade --reuse-values --set --web.route-prefix=... 두 줄은 적용되지 않습니다(--set의 키가 차트 값 경로가 아니라 바이너리 플래그 이름이라서). 설령 키를 고쳐 넣더라도 프로브가 /-/ready에서 404를 맞아 파드가 1/2 Running에서 멈춥니다. 위의 〈실습 정리본 — 동작하는 /p8s 노출 설정〉을 쓰십시오. 여기 원문을 남겨 두는 건 무엇이 달라졌는지 대조해 보기 위해서입니다.

kubectl get ingressclasses.networking.k8s.io NAME CONTROLLER PARAMETERS AGE nginx k8s.io/ingress-nginx <none> 3h21m # Ingress 생성 cat <<EOF | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: prometheus-ingress namespace: monitoring spec: ingressClassName: nginx rules: - http: paths: - path: /p8s pathType: Prefix backend: service: name: prometheus-server port: number: 80 EOF # Prometheus에 route-prefix 플래그 추가 (Helm values) : *<ingress-주소>.us-west-2.elb.amazonaws.com* # Prometheus는 서브패스로 서빙될 걸 스스로 알아야 내부 링크/리다이렉트/정적 자산(JS/CSS) 경로가 깨지지 않습니다. helm upgrade prometheus prometheus-community/prometheus -n monitoring --reuse-values \ --set --web.route-prefix=/p8s \ --set --web.external-url=http://*<ingress-주소>.us-west-2.elb.amazonaws.com*/p8s/ helm get values -n monitoring prometheus # 확인 kubectl get ingress -n monitoring prometheus-ingress NAME CLASS HOSTS ADDRESS PORTS AGE prometheus-ingress nginx * <ingress-주소>.us-west-2.elb.amazonaws.com 80 8m45s kubectl describe ingress -n monitoring prometheus-ingress ... Address: <ingress-주소>.us-west-2.elb.amazonaws.com Ingress Class: nginx Default backend: <default> Rules: Host Path Backends ---- ---- -------- * /p8s prometheus-server:80 (10.0.2.198:9090)

Grafana 설치

# Add Grafana Helm repository helm repo add grafana https://grafana.github.io/helm-charts helm repo update # Create custom values for Grafana cat > grafana-values.yaml <<EOF persistence: enabled: false adminPassword: "<설정한비밀번호>" service: type: ClusterIP datasources: datasources.yaml: apiVersion: 1 datasources: - name: Prometheus type: prometheus url: http://prometheus-server.monitoring.svc.cluster.local access: proxy isDefault: true dashboardProviders: dashboardproviders.yaml: apiVersion: 1 providers: - name: 'default' orgId: 1 folder: '' type: file disableDeletion: false editable: true options: path: /var/lib/grafana/dashboards/default dashboards: default: kubernetes-cluster: gnetId: 7249 revision: 1 datasource: Prometheus kubernetes-pods: gnetId: 6336 revision: 1 datasource: Prometheus resources: requests: cpu: 250m memory: 512Mi limits: cpu: 500m memory: 1Gi EOF # Install Grafana helm install grafana grafana/grafana \ --namespace monitoring \ --values grafana-values.yaml # 확인 helm list -A kubectl get svc,ep -n monitoring grafana kubectl get pod -n monitoring -l app.kubernetes.io/instance=grafana kubectl describe pod -n monitoring -l app.kubernetes.io/instance=grafana

데이터소스 URL에 /p8s를 붙인다 — 이걸 빼면 패널이 전부 빈다

# 아래 내용 확인 후 helm upgrade 필요 helm get values -n monitoring grafana ... datasources: datasources.yaml: apiVersion: 1 datasources: - access: proxy isDefault: true name: Prometheus type: prometheus url: http://prometheus-server.monitoring.svc.cluster.local/p8s ... helm upgrade -i grafana grafana/grafana \ --namespace monitoring \ --values grafana-values.yaml

Grafana Ingress 추가

# Ingress 생성 cat <<EOF | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: grafana-ingress namespace: monitoring spec: ingressClassName: nginx rules: - http: paths: - path: /grafana pathType: Prefix backend: service: name: grafana port: number: 80 EOF # 확인 kubectl get ingress -A NAMESPACE NAME CLASS HOSTS ADDRESS PORTS AGE default vllm-ingress-simple nginx * <ingress-주소>.us-west-2.elb.amazonaws.com 80 3h41m monitoring grafana-ingress nginx * <ingress-주소>.us-west-2.elb.amazonaws.com 80 5m39s monitoring prometheus-ingress nginx * <ingress-주소>.us-west-2.elb.amazonaws.com 80 kubectl describe ingress -n monitoring grafana-ingress ... Address: <ingress-주소>.us-west-2.elb.amazonaws.com Ingress Class: nginx Default backend: <default> Rules: Host Path Backends ---- ---- -------- * /grafana grafana:80 (10.0.2.203:3000) ... # Grafana Helm values 추가분 grafana.ini: server: root_url: "http://<ingress-주소>.us-west-2.elb.amazonaws.com/grafana" serve_from_sub_path: true readinessProbe: httpGet: path: /grafana/api/health port: grafana livenessProbe: httpGet: path: /grafana/api/health port: grafana initialDelaySeconds: 60 timeoutSeconds: 30 failureThreshold: 10 helm upgrade grafana grafana/grafana -n monitoring --reuse-values -f grafana-subpath-values.yaml helm get values -n monitoring grafana

대시보드는 JSON으로 만들어 ConfigMap으로 넣고, Grafana 파드에 볼륨으로 마운트합니다.

vLLM 대시보드 JSON 전문

# Create vLLM dashboard JSON file with correct metric names cat > vllm-dashboard.json <<'EOF' { "id": null, "title": "vLLM Inference Metrics", "description": "Dashboard for monitoring vLLM inference performance", "tags": ["vllm", "inference", "llm"], "timezone": "browser", "panels": [ { "id": 1, "title": "Total Successful Requests", "type": "stat", "targets": [ { "expr": "vllm:request_success_total", "legendFormat": "Total Requests" } ], "fieldConfig": { "defaults": { "unit": "short" } }, "gridPos": {"h": 8, "w": 6, "x": 0, "y": 0} }, { "id": 2, "title": "Running Requests", "type": "stat", "targets": [ { "expr": "vllm:num_requests_running", "legendFormat": "Running" } ], "gridPos": {"h": 8, "w": 6, "x": 6, "y": 0} }, { "id": 3, "title": "Waiting Requests", "type": "stat", "targets": [ { "expr": "vllm:num_requests_waiting", "legendFormat": "Waiting" } ], "gridPos": {"h": 8, "w": 6, "x": 12, "y": 0} }, { "id": 4, "title": "KV Cache Usage", "type": "gauge", "targets": [ { "expr": "vllm:gpu_cache_usage_perc * 100", "legendFormat": "KV Cache Usage %" } ], "fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100, "thresholds": { "steps": [ {"color": "green", "value": 0}, {"color": "yellow", "value": 60}, {"color": "red", "value": 80} ] } } }, "gridPos": {"h": 8, "w": 6, "x": 18, "y": 0} }, { "id": 5, "title": "Total Prompt Tokens", "type": "stat", "targets": [ { "expr": "vllm:prompt_tokens_total", "legendFormat": "Prompt Tokens" } ], "gridPos": {"h": 8, "w": 6, "x": 0, "y": 8} }, { "id": 6, "title": "Total Generated Tokens", "type": "stat", "targets": [ { "expr": "vllm:generation_tokens_total", "legendFormat": "Generated Tokens" } ], "gridPos": {"h": 8, "w": 6, "x": 6, "y": 8} }, { "id": 7, "title": "Request Success Over Time", "type": "timeseries", "targets": [ { "expr": "vllm:request_success_total", "legendFormat": "Total Successful Requests" } ], "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8} }, { "id": 8, "title": "Token Generation Over Time", "type": "timeseries", "targets": [ { "expr": "vllm:prompt_tokens_total", "legendFormat": "Prompt Tokens" }, { "expr": "vllm:generation_tokens_total", "legendFormat": "Generated Tokens" } ], "gridPos": {"h": 8, "w": 24, "x": 0, "y": 16} } ], "time": {"from": "now-15m", "to": "now"}, "refresh": "10s" } EOF

ConfigMap으로 가져오기

# Import vLLM dashboard from existing JSON file kubectl create configmap vllm-dashboard \ --from-file=vllm-dashboard.json \ -n monitoring kubectl get cm -n monitoring prometheus-server vllm-dashboard

Grafana 파드에 마운트

# Mount the vLLM dashboard ConfigMap into Grafana pod kubectl patch deployment grafana -n monitoring --type='json' -p='[ { "op": "add", "path": "/spec/template/spec/volumes/-", "value": { "name": "vllm-dashboard", "configMap": { "name": "vllm-dashboard" } } }, { "op": "add", "path": "/spec/template/spec/containers/0/volumeMounts/-", "value": { "name": "vllm-dashboard", "mountPath": "/var/lib/grafana/dashboards/default/vllm-dashboard.json", "subPath": "vllm-dashboard.json" } } ]' kubectl get pod -n monitoring -w

vLLM Deployment에 스크레이프 어노테이션 — 이게 없으면 Prometheus가 안 긁는다

# Update the vLLM deployment to expose metrics kubectl annotate deployment vllm-deployment -n default \ prometheus.io/scrape=true \ prometheus.io/port=8080 \ prometheus.io/path=/metrics kubectl describe deployments.apps | grep ^Annotations: -A3 Annotations: deployment.kubernetes.io/revision: 2 prometheus.io/path: /metrics prometheus.io/port: 8080 prometheus.io/scrape: true
패널쿼리무엇을 보는가
Total Successful Requestsvllm:request_success_total누적 성공 건수
Running Requestsvllm:num_requests_running동시 처리 중 — max-num-seqs에 닿았는지
Waiting Requestsvllm:num_requests_waiting대기열 — 여기가 쌓이면 용량 부족
KV Cache Usagevllm:gpu_cache_usage_perc캐시 포화 — 100%면 새 요청을 못 받음
Total Prompt Tokensvllm:prompt_tokens_total프리필 쪽 부하
Total Generated Tokensvllm:generation_tokens_total디코드 쪽 부하
Request Success Over Timevllm:request_success_total (시계열)처리율 추이
Token Generation Over Timeprompt / generation tokens (시계열)토큰 처리량 추이

지표 이름에 gpu가 들어 있는 게 눈에 걸릴 수 있습니다 — vllm:gpu_cache_usage_perc는 Trainium에서도 그대로 이 이름을 씁니다. vLLM 입장에서는 "가속기 메모리의 캐시"라는 뜻이고, 백엔드가 무엇이든 이름은 바뀌지 않습니다.

실습 결과 대시보드가 붙은 직후 — 요청 한 건만 흘려 본 상태
Grafana vLLM Inference Metrics 대시보드. Total Successful Requests 1, Running Requests 0, Waiting Requests 0, KV Cache Usage 0%, Total Prompt Tokens 22, Total Generated Tokens 100
일곱 패널이 다 붙었고 값이 들어옵니다. 성공 요청 1건, running 0, waiting 0, KV 캐시 0%, 프롬프트 토큰 22, 생성 토큰 100. 6장에서 curl로 한 번 찔러 본 그 요청입니다. 이 화면이 다음 장의 기준선입니다 — 부하를 걸면 이 여섯 숫자가 어떻게 움직이는지 보겠습니다. (여기서 KV 캐시가 0%인 건 처리 중인 요청이 없어서이지, 캐시가 없어서가 아닙니다.)
7장 세 줄 요약
  1. 정의 — vLLM은 /metrics로 이미 지표를 내보내고 있습니다. Lab 4가 하는 일은 Prometheus가 긁어 가게 어노테이션을 붙이고, Grafana로 그리는 것뿐입니다.
  2. 성질 — running과 waiting을 나란히 보면 원인이 갈립니다. running이 max-num-seqs에 붙어 있고 waiting만 쌓이면 용량 문제, KV 캐시가 100%면 메모리 문제입니다.
  3. 쓰임 — 워크샵 문서에 함정이 셋 있습니다. job_name 중복은 지우고 설치, /p8s 노출은 values의 server.extraArgs + tcpSocketProbeEnabled로(강의자료의 --set --web.route-prefix는 안 먹습니다), Grafana 데이터소스 URL에는 /p8s를 붙여야 합니다.

보이게 만들었으니 이제 밀어붙입니다. 어디까지 버티는지.

08성능 재기 — 부하를 걸어 보기

한 줄 요약: 평균 하나로는 아무것도 모릅니다. TTFT·ITL·처리량을 나눠서 봐야 병목이 보입니다.

왜 이걸 배우나
"초당 53토큰 나왔다는데, 이게 좋은 건가 나쁜 건가?"

이 숫자 하나로는 판단할 수 없습니다. 같은 53 tok/s라도 한 요청이 혼자 53인지 다섯 요청이 각각 10씩인지에 따라 완전히 다른 상황입니다. 앞은 모델이 느린 것이고, 뒤는 배칭이 잘 돌고 있는 것입니다.

그래서 세 숫자를 같이 봅니다. TTFT(첫 응답까지) · ITL(토큰 사이 간격) · 처리량(전체 초당 토큰). 동시 요청을 늘렸을 때 이 셋이 각각 어떻게 움직이는지가 시스템의 성격을 말해 줍니다.

이 장에 나오는 낯선 말
TTFT
Time To First Token — 요청부터 첫 토큰까지. 프리필 단계의 비용입니다.
ITL
Inter-Token Latency — 토큰과 토큰 사이 간격. 디코드 단계의 비용입니다.
E2E latency
요청부터 마지막 토큰까지의 전체 시간. 대략 TTFT + ITL × 출력 토큰 수입니다.
P50 / P95 / P99
느린 순으로 줄 세웠을 때 중간값 · 상위 5% · 상위 1%. 평균보다 이쪽이 사용자 체감에 가깝습니다.
llmperf
Ray 팀이 만든 LLM 전용 부하 테스트 도구. 입력 토큰 수를 분포로 지정할 수 있습니다.
continuous batching
요청이 도착하는 대로 돌아가는 배치에 끼워 넣는 방식. 이게 있어서 동시 요청이 늘어도 처리량이 따라 올라갑니다.

비유 — 카페의 세 가지 시간

카페에서 커피를 시킵니다. 세 가지 시간이 있습니다.

주문받고 첫 잔이 나오기까지(TTFT)는 바리스타가 얼마나 밀려 있느냐에 달렸습니다. 잔과 잔 사이 간격(ITL)은 기계 속도입니다. 시간당 총 잔 수(처리량)는 앞의 둘과 손님 수의 조합입니다.

손님이 늘면 TTFT는 길어집니다(줄이 생기니까). 하지만 ITL은 거의 그대로입니다(기계는 같은 속도로 뽑으니까). 그리고 총 잔 수는 올라갑니다 — 기계가 놀지 않게 되니까. 어느 지점에서 이 관계가 깨지는지가 시스템의 한계입니다.

작은 숫자로 한 번

# basic_load_test.py — 30개 요청, 워커 5 Tokens per Second: 53.4 # llmperf token_benchmark_ray.py — 입력 평균 256 토큰(표준편차 50), 50건 Number Of Completed Requests: 50 Completed Requests Per Minute: 204.98 # 개별 요청 하나의 기록 ttft_s 0.1658 첫 토큰까지 166 ms inter_token_latency_s 0.0111 토큰 사이 11 ms → 초당 약 90 토큰 end_to_end_latency_s 1.0787 전체 1.08초 # 검산: 0.166 + 0.0111 × 82 ≈ 1.08 → 출력이 약 82토큰이었다는 뜻
그림 6

동시 요청을 늘리면 무엇이 먼저 무너지나

↔ 슬라이더
해볼 것 — ① 동시 요청을 1에서 4까지 올려 보세요. 처리량은 올라가는데 TTFT는 거의 그대로입니다 — 배칭이 잘 도는 구간입니다. ② 5를 넘기는 순간 달라집니다. max-num-seqs=4가 상한이라 초과분은 대기열로 밀리고, TTFT가 꺾여 올라갑니다. ③ 처리량은 그 뒤로 거의 평평합니다 — 더 밀어넣어도 총량은 안 늘고 지연만 늘어납니다.
모형입니다

기준점(TTFT 166 ms, ITL 11 ms, 53.4 tok/s)은 워크샵 실측값이지만, 동시 요청을 늘렸을 때의 곡선은 대기 이론으로 그린 모형입니다. 실제로는 KV 캐시 포화가 겹치면서 더 급하게 꺾입니다.

그림에 나오는 말 TTFT Time To First Token — 요청 후 첫 토큰까지의 시간 · ITL Inter-Token Latency — 토큰 사이 간격 · 처리량 초당 생성 토큰 수. 시스템 전체의 총량 · max-num-seqs vLLM이 동시에 처리하는 시퀀스의 최대 개수. 이번 실습은 4 · 대기열 상한을 넘은 요청이 기다리는 줄

그림 6이 맞는지 실제로 확인해 보기

위 그림은 모형입니다. 그런데 이번 실습에서는 부하를 걸기 전 · 파이썬 부하 테스트 중 · llmperf 벤치마크 중의 대시보드를 차례로 찍어 두었습니다. 7장 끝의 기준선 화면과 나란히 놓고 보면, 모형이 말한 세 가지가 그대로 나옵니다.

실습 결과 파이썬 부하 테스트 중 — running이 4에서 멈춘다
Grafana 대시보드. Total Successful Requests 17/10, Running Requests 4, Waiting Requests 0, KV Cache Usage 100%, Total Prompt Tokens 342, Total Generated Tokens 2138
기준선(1 / 0 / 0 / 0% / 22 / 100)에서 이렇게 바뀌었습니다 — 성공 17건, running 4, waiting 0, KV 캐시 100%, 프롬프트 342, 생성 2138. running이 정확히 4에서 멈춘 게 핵심입니다. 워커를 5로 돌렸는데도 5가 되지 않습니다 — --max-num-seqs=4가 상한이기 때문입니다. 그리고 KV 캐시가 먼저 100%에 닿습니다. 동시 처리 수보다 캐시가 먼저 포화한다는 뜻입니다.
실습 결과 llmperf 벤치마크 중 — 초과분이 대기열로 넘어간다
Grafana 대시보드. Total Successful Requests 112/13, Running Requests 4, Waiting Requests 1, KV Cache Usage 100%, Total Prompt Tokens 26436, Total Generated Tokens 11499
더 세게 밀어붙인 상태입니다 — 성공 112건, 프롬프트 토큰 26,436, 생성 토큰 11,499. 그런데 running은 여전히 4이고, 대신 waiting에 1이 찍혔습니다. 그림 6이 "5를 넘기는 순간 초과분은 대기열로 밀린다"고 한 그 지점입니다. 일을 더 준다고 running이 늘지는 않고, 늘어나는 건 줄입니다 — 그래서 TTFT가 꺾여 올라갑니다. 아래 Request Success Over Time·Token Generation Over Time의 계단 모양도 같은 이야기입니다. 토큰이 쏟아지는 구간과 요청 수가 오르는 구간이 겹칩니다.
실습 결과 같은 시각의 neuron-top — 두 코어가 나란히 48%
neuron-top 화면. NeuronCore v2 Utilization 평균 48.20%, NC0 48.17%, NC1 48.24%, Device Used Memory 3.6GB, NC0 1.8GB NC1 1.8GB
부하가 걸린 동안의 가속기 쪽 그림입니다. NC0 48.17% · NC1 48.24% — 두 코어가 소수점 둘째 자리까지 같이 움직입니다. --tensor-parallel-size=2로 모델을 반씩 쪼개 놓았으니, 한 요청이 두 코어를 동시에 씁니다. 한쪽만 100%이고 다른 쪽이 0%라면 TP가 안 걸린 것입니다. 48%라는 값도 읽을 거리가 있습니다 — KV 캐시는 100%인데 코어는 절반만 쓰고 있습니다. 즉 이 시점의 병목은 연산이 아니라 메모리입니다. 코어를 더 굴리고 싶으면 max-model-len을 줄이거나 KV 캐시를 키워야지, 요청을 더 밀어넣는다고 되지 않습니다.
숫자는 돌릴 때마다 조금씩 다릅니다

강의자료의 llmperf 결과는 분당 204.98건이었고, 이번 실습에서는 같은 조건에서 분당 207.35건 · 출력 처리량 343.71 tok/s가 나왔습니다(50건, 오류 0건). 1% 남짓 차이인데, 이 정도는 같은 인스턴스 타입에서도 그때그때 생깁니다. 벤치마크 숫자를 볼 때는 한 번 돌린 값이 아니라 여러 번의 분포를 봐야 한다는 뜻입니다. 이 페이지의 P50/P95/P99를 강조하는 이유도 같습니다.

부하 테스트 환경 만들기

테스트 도구는 클러스터 안에 별도 네임스페이스로 띄웁니다. 바깥에서 때리면 네트워크 왕복이 섞여 들어가기 때문입니다.

환경 변수 — 워크샵 문서의 클러스터명 오타에 주의

export AWS_REGION=us-west-2 export CLUSTER_NAME=ai-infra-summit-test-cluster # 요거 사용 할 것! export CLUSTER_NAME=vllm-trn1-eks-cluster # 워크샵에 오타 참고! export MONITORING_NAMESPACE=monitoring # Get the vLLM service endpoint export VLLM_ENDPOINT=$(kubectl get service vllm-service -n default -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') export VLLM_NAMESPACE=default export VLLM_URL="http://$VLLM_ENDPOINT:8080/v1" echo "vLLM Endpoint: $VLLM_URL" echo "vLLM Namespace: $VLLM_NAMESPACE"

테스트 러너 파드 전문

# Create namespace for testing kubectl create namespace performance-testing # Create a performance testing pod with necessary tools cat > performance-test-pod.yaml <<EOF apiVersion: v1 kind: Pod metadata: name: performance-test-runner namespace: performance-testing spec: containers: - name: performance-tester image: python:3.10-slim command: ["sleep", "infinity"] resources: requests: cpu: 500m memory: 1Gi limits: cpu: 2000m memory: 4Gi volumeMounts: - name: test-scripts mountPath: /scripts volumes: - name: test-scripts emptyDir: {} restartPolicy: Never EOF kubectl apply -f performance-test-pod.yaml # Wait for pod to be ready kubectl wait --for=condition=ready pod/performance-test-runner -n performance-testing --timeout=120s # 확인 kubectl get pod -n performance-testing NAME READY STATUS RESTARTS AGE performance-test-runner 1/1 Running 0 7m3s

필요한 파이썬 패키지 설치

# kubectl exec -it performance-test-runner -n performance-testing -- bash -c " pip install requests asyncio aiohttp numpy matplotlib pandas locust " # 확인 kubectl exec -it performance-test-runner -n performance-testing -- pip list

basic_load_test.py 전문

# 기본 기능 검증을 위한 간단한 부하 테스트 스크립트를 작성하세요: cat > basic_load_test.py <<'EOF' #!/usr/bin/env python3 import requests import time import concurrent.futures import statistics import json from datetime import datetime class VLLMLoadTester: def __init__(self, base_url, max_workers=10): self.base_url = base_url.rstrip('/') self.max_workers = max_workers self.results = [] def single_request(self, request_id): """Send a single completion request""" start_time = time.time() try: response = requests.post( f"{self.base_url}/completions", headers={"Content-Type": "application/json"}, json={ "model": "tinyLlama/TinyLlama-1.1B-Chat-v1.0", "prompt": f"Request {request_id}: Tell me about artificial intelligence", "max_tokens": 100, "temperature": 0.7 }, timeout=60 ) end_time = time.time() if response.status_code == 200: return { "request_id": request_id, "status": "success", "latency": end_time - start_time, "tokens": len(response.json().get("choices", [{}])[0].get("text", "").split()), "timestamp": datetime.now().isoformat() } else: return { "request_id": request_id, "status": "error", "latency": end_time - start_time, "error_code": response.status_code, "timestamp": datetime.now().isoformat() } except Exception as e: end_time = time.time() return { "request_id": request_id, "status": "exception", "latency": end_time - start_time, "error": str(e), "timestamp": datetime.now().isoformat() } def run_load_test(self, total_requests=100, duration_seconds=None): """Run load test with specified parameters""" print(f"Starting load test with {self.max_workers} workers") print(f"Target: {self.base_url}") start_time = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: if duration_seconds: # Duration-based testing request_id = 0 futures = [] while time.time() - start_time < duration_seconds: future = executor.submit(self.single_request, request_id) futures.append(future) request_id += 1 time.sleep(0.1) # Small delay between request submissions # Wait for all requests to complete for future in concurrent.futures.as_completed(futures): self.results.append(future.result()) else: # Request count-based testing futures = [executor.submit(self.single_request, i) for i in range(total_requests)] for future in concurrent.futures.as_completed(futures): self.results.append(future.result()) if len(self.results) % 10 == 0: print(f"Completed {len(self.results)}/{total_requests} requests") self.analyze_results() def analyze_results(self): """Analyze and print test results""" if not self.results: print("No results to analyze") return successful_requests = [r for r in self.results if r["status"] == "success"] failed_requests = [r for r in self.results if r["status"] != "success"] if successful_requests: latencies = [r["latency"] for r in successful_requests] tokens_per_request = [r.get("tokens", 0) for r in successful_requests if "tokens" in r] print("\n=== LOAD TEST RESULTS ===") print(f"Total Requests: {len(self.results)}") print(f"Successful: {len(successful_requests)} ({len(successful_requests)/len(self.results)*100:.1f}%)") print(f"Failed: {len(failed_requests)} ({len(failed_requests)/len(self.results)*100:.1f}%)") print(f"\n=== LATENCY STATISTICS ===") print(f"Average Latency: {statistics.mean(latencies):.2f}s") print(f"Median Latency: {statistics.median(latencies):.2f}s") print(f"95th Percentile: {sorted(latencies)[int(len(latencies)*0.95)]:.2f}s") print(f"99th Percentile: {sorted(latencies)[int(len(latencies)*0.99)]:.2f}s") print(f"Min Latency: {min(latencies):.2f}s") print(f"Max Latency: {max(latencies):.2f}s") if tokens_per_request: print(f"\n=== TOKEN STATISTICS ===") print(f"Average Tokens per Response: {statistics.mean(tokens_per_request):.1f}") total_tokens = sum(tokens_per_request) total_time = sum(latencies) print(f"Tokens per Second: {total_tokens/total_time:.1f}") if failed_requests: print(f"\n=== FAILURE ANALYSIS ===") error_types = {} for req in failed_requests: error_type = req.get("error_code", req.get("error", "unknown")) error_types[error_type] = error_types.get(error_type, 0) + 1 for error, count in error_types.items(): print(f"{error}: {count} requests") if __name__ == "__main__": import sys if len(sys.argv) < 2: print("Usage: python basic_load_test.py <vllm_url> [requests] [workers]") sys.exit(1) base_url = sys.argv[1] total_requests = int(sys.argv[2]) if len(sys.argv) > 2 else 50 max_workers = int(sys.argv[3]) if len(sys.argv) > 3 else 5 tester = VLLMLoadTester(base_url, max_workers) tester.run_load_test(total_requests=total_requests) EOF # Copy the script to the performance testing pod kubectl cp basic_load_test.py performance-testing/performance-test-runner:/scripts/

스크립트를 파드로 복사

kubectl exec -it -n performance-testing performance-test-runner -- ls /scripts basic_load_test.py kubectl exec -it -n performance-testing performance-test-runner -- cat /scripts/basic_load_test.py ...

기본 부하 테스트 실행 — 결과 53.4 tok/s

export VLLM_ENDPOINT=$(kubectl get service vllm-service -n default -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') export VLLM_URL="http://$VLLM_ENDPOINT:8080/v1" echo "Running basic load test..." kubectl exec -it performance-test-runner -n performance-testing -- python /scripts/basic_load_test.py $VLLM_URL 30 5 Starting load test with 5 workers Target: http://<ingress-주소>.us-west-2.elb.amazonaws.com:8080/v1 Completed 10/30 requests Completed 20/30 requests Completed 30/30 requests === LOAD TEST RESULTS === Total Requests: 30 Successful: 30 (100.0%) Failed: 0 (0.0%) === LATENCY STATISTICS === Average Latency: 0.89s Median Latency: 1.06s 95th Percentile: 1.60s 99th Percentile: 1.60s Min Latency: 0.14s Max Latency: 1.60s === TOKEN STATISTICS === Average Tokens per Response: 47.4 Tokens per Second: 53.4

llmperf 설치

# Install llmperf in the performance testing pod kubectl exec -it performance-test-runner -n performance-testing -- bash -c " pip install --upgrade pip && \ apt-get update && apt-get install -y git && \ cd /tmp && \ git clone https://github.com/ray-project/llmperf.git && \ cd llmperf && \ pip install ray && \ pip install -e . "

llmperf 토큰 벤치마크 — 결과 204.98 req/min

# Run llmperf token benchmark test echo "Running llmperf token benchmark..." kubectl exec -it performance-test-runner -n performance-testing -- bash -c " cd /tmp/llmperf && \ export OPENAI_API_KEY=EMPTY && \ export OPENAI_API_BASE=$VLLM_URL && \ python token_benchmark_ray.py \ --model 'tinyLlama/TinyLlama-1.1B-Chat-v1.0' \ --mean-input-tokens 256 \ --stddev-input-tokens 50 \ --mean-output-tokens 100 \ --stddev-output-tokens 20 \ --max-num-completed-requests 50 \ --timeout 600 \ --num-concurrent-requests 5 \ --results-dir 'result_outputs' \ --llm-api openai \ --additional-sampling-params '{\"temperature\": 0.7}' " # 고정 길이 프롬프트로 테스트하지 않고, 입력 토큰 수를 평균 256·표준편차 50인 정규분포에서 매 요청마다 랜덤 샘플링해서 실제 사용자 트래픽처럼 프롬프트 길이를 다양화합니다. # 출력 토큰 수도 평균 100·표준편차 20으로 마찬가지로 랜덤 결정되어 각 요청의 max_tokens에 반영됩니다. --mean-input-tokens 256 --stddev-input-tokens 50 \ --mean-output-tokens 100 --stddev-output-tokens 20 \ # 완료된 요청이 50건이 될 때까지 실행(단순히 50건을 "쏘고 끝"이 아니라 응답까지 받은 게 50건). 전체 벤치마크가 600초를 넘기면 강제 종료. --max-num-completed-requests 50 \ --timeout 600 \ # 동시에 in-flight 상태를 유지하는 요청 수 5개 — 하나 끝나면 즉시 다음 요청을 채워 넣는 고정 동시성 워커 풀 방식 # basic_load_test.py의 max_workers와 개념은 비슷하지만, llmperf는 통계적 워크로드 생성기라는 점이 다름). --num-concurrent-requests 5 \ # 결과 JSON을 /tmp/llmperf/result_outputs/에 저장. # --llm-api openai는 llmperf가 여러 백엔드(Anthropic, SageMaker, Vertex 등)를 지원하는데, vLLM은 OpenAI 호환 API를 노출하므로 이 어댑터를 선택. --results-dir 'result_outputs' \ --llm-api openai \ inter_token_latency_s p25 = 0.010559349317232508 p50 = 0.011671327660852021 p75 = 0.013025983844884716 p90 = 0.014460163678541603 p95 = 0.014915394389268106 p99 = 0.016907095423358054 mean = 0.01197366385726735 min = 0.01002362629213098 max = 0.017912173629483323 stddev = 0.001753168873071237 ttft_s p25 = 0.12422058625452337 p50 = 0.21855575150402728 p75 = 0.3396002754998335 p90 = 0.4720472733999487 p95 = 0.5323461797524942 p99 = 0.6842640194675185 mean = 0.2558643885405036 min = 0.05586664300062694 max = 0.746609706999152 stddev = 0.163474072773748 end_to_end_latency_s p25 = 1.0516273417488264 p50 = 1.1789801554987207 p75 = 1.3508016065061383 p90 = 1.4980681215005462 p95 = 1.6005784195534942 p99 = 1.7567467974319875 mean = 1.1931519574217964 min = 0.720952285992098 max = 1.8750213280000025 stddev = 0.23878622669810412 request_output_throughput_token_per_s p25 = 76.76571656947374 p50 = 85.4158639480256 p75 = 94.29240351338606 p90 = 97.5038354331391 p95 = 98.40290917487998 p99 = 99.69783009349173 mean = 84.94868896120607 min = 55.82388126462472 max = 99.74703925005215 stddev = 11.136169542682817 number_input_tokens p25 = 230.75 p50 = 261.5 p75 = 281.25 p90 = 326.7 p95 = 353.44999999999993 p99 = 406.72999999999996 mean = 260.52 min = 131 max = 418 stddev = 52.32443531097344 number_output_tokens p25 = 88.25 p50 = 97.5 p75 = 113.25 p90 = 120.1 p95 = 122.1 p99 = 129.01999999999998 mean = 99.72 min = 62 max = 130 stddev = 15.579997642188918 Number Of Errored Requests: 0 Overall Output Throughput: 340.6702900640951 Number Of Completed Requests: 50 Completed Requests Per Minute: 204.97610713844472

개별 요청 기록 확인 — TTFT · ITL · E2E

# Display results echo "llmperf benchmark results:" kubectl exec -it performance-test-runner -n performance-testing -- find /tmp/llmperf/result_outputs -name "*.json" -exec cat {} \; ... { "error_code": null, "error_msg": "", "inter_token_latency_s": 0.011119221556918532, "ttft_s": 0.16580839300877415, "end_to_end_latency_s": 1.078680176011403, "request_output_throughput_token_per_s": 89.92470813607923, "number_total_tokens": 367, "number_output_tokens": 97, "number_input_tokens": 270 }, { "error_code": null, "error_msg": "", "inter_token_latency_s": 0.010546541000402268, "ttft_s": 0.1562996069988003, "end_to_end_latency_s": 1.223545393004315, "request_output_throughput_token_per_s": 93.98915696754574, "number_total_tokens": 340, "number_output_tokens": 115, "number_input_tokens": 225 }, ...

끝나면 네임스페이스째 지운다

echo "Cleaning up performance testing resources..." kubectl delete namespace performance-testing echo "performance testing cleanup completed!"
8장 세 줄 요약
  1. 정의 — 성능은 숫자 하나가 아니라 TTFT · ITL · 처리량 세 개입니다. 각각 프리필 비용 · 디코드 비용 · 전체 총량에 대응합니다.
  2. 성질 — 동시 요청을 늘리면 처리량은 오르고 TTFT는 거의 그대로입니다 — max-num-seqs에 닿을 때까지만. 넘어서면 초과분이 대기열로 밀려 TTFT만 꺾여 올라갑니다.
  3. 쓰임 — 이번 환경의 실측은 53.4 tok/s · 204.98 req/min · TTFT 166 ms · ITL 11 ms입니다. 부하 테스트는 클러스터 안에서 돌리고, 끝나면 네임스페이스째 지웁니다.

한 대로 부족하면 늘려야 합니다. 그런데 여기서 워크샵이 막힙니다.

09늘리기 — HPA와 GPU 오토스케일링

한 줄 요약: CPU 사용률로 GPU 워크로드를 늘리는 건 원리적으로 맞지 않습니다.

왜 이걸 배우나
"HPA를 CPU 기준으로 걸었는데 왜 안 늘어나지?"

이 워크샵의 Lab 6은 실행이 안 됩니다. 계정의 vCPU 쿼터가 8이라 trn1.2xlarge를 한 대밖에 못 띄웁니다. 노드그룹을 하나 더 만들려고 하면 Quota 제약으로 실패합니다.

그런데 실행이 막힌 덕분에 더 중요한 질문이 남습니다. 설령 노드가 여러 대 있었다 해도, CPU 사용률 기준 HPA는 이 워크로드에 맞지 않습니다. GPU 노드는 CPU를 10%밖에 안 쓰면서 가속기는 100% 포화돼 있고 큐에 200건이 밀려 있을 수 있습니다. HPA는 그 상황을 한가하다고 읽습니다.

이 장에 나오는 낯선 말
HPA
Horizontal Pod Autoscaler. 지표를 보고 파드 개수를 늘리고 줄이는 컨트롤러입니다.
metrics-server
노드·파드의 CPU/메모리 사용량을 모아 HPA에 공급하는 컴포넌트. 없으면 HPA가 아예 동작하지 않습니다.
KEDA
Kubernetes Event Driven Autoscaling. 임의의 외부 지표로 HPA를 굴릴 수 있게 해주는 프로젝트입니다.
external scaler
KEDA가 gRPC로 물어보는 외부 지표 공급자. 직접 만들어 붙일 수 있습니다.
scale to zero
트래픽이 없을 때 파드를 0개까지 줄이는 것. 비싼 GPU 노드에서는 절약폭이 큽니다.
NVML
NVIDIA Management Library. GPU 상태를 직접 읽는 라이브러리(libnvidia-ml.so). nvidia-smi도 이걸 씁니다.

비유 — 주방은 불타는데 계산대만 보는 매니저

식당 매니저가 계산대 앞 줄 길이만 보고 인력을 배치한다고 해봅시다. 계산은 금방 끝나니 줄은 늘 짧습니다. 매니저는 "한가하네" 하고 넘어갑니다.

그동안 주방은 주문 200건이 밀려 있습니다. 요리사들은 쉬지 못하고 있습니다. 하지만 매니저의 지표에는 이게 안 잡힙니다.

CPU 기준 HPA가 정확히 이 매니저입니다. 고쳐야 할 건 임계값이 아니라 보는 지표 자체입니다.

그림 7

CPU를 보면 왜 놓치나

↔ 슬라이더
해볼 것 — ① 부하를 0에서 100%까지 밀어 보세요. 가속기 사용률은 100%까지 가는데 CPU는 20% 언저리에서 멈춥니다. ② CPU 임계값을 70%로 두면 부하가 최대여도 HPA는 한 번도 발동하지 않습니다. ③ 기준 지표를 가속기 사용률로 바꾸면 같은 부하에서 제때 늘어납니다.
모형입니다

"GPU 노드는 CPU 10%인데 가속기는 100% 포화"라는 관계 자체는 실제 관찰이지만, 여기 곡선은 그 관계를 보여주기 위한 모형입니다. CPU도 완전히 0은 아닙니다 — 토크나이즈·직렬화·HTTP 처리에 얼마간 씁니다.

그림에 나오는 말 HPA Horizontal Pod Autoscaler — 지표를 보고 파드 수를 조절하는 쿠버네티스 컨트롤러 · 임계값 이 값을 넘으면 늘리라고 정해 둔 기준선 · 가속기 사용률 GPU/NeuronCore가 실제로 연산 중인 시간의 비율 · 대기열 상한을 넘어 처리를 기다리는 요청. CPU에는 안 잡히는 신호

대안 — 지표를 하드웨어에서 직접 읽기

표준 해법은 DCGM Exporter → Prometheus → PromQL → KEDA → HPA입니다. 동작하지만 구성요소가 다섯 개고, 스크레이프 지연이 15~30초 붙고, PromQL 쿼리가 업그레이드 때 깨집니다.

keda-gpu-scaler는 이 파이프라인을 걷어냅니다. GPU 노드마다 데몬셋으로 뜬 뒤 NVML C 바인딩에서 직접 GPU 상태를 읽어 gRPC로 KEDA에 넘깁니다. 구성요소 두 개, 지연은 1초 미만입니다.

# 이전 GPU Pod → dcgm-exporter → Prometheus → PromQL → KEDA → HPA (구성요소 5개, 스크레이프 지연 15~30초, 업그레이드 때 쿼리가 깨짐) # 이후 GPU Pod → keda-gpu-scaler (NVML) → KEDA → HPA (구성요소 2개, 1초 미만, 설정 거의 없음)
keda-gpu-scaler의 구조와 설정

먼저 HPA가 동작하려면 metrics-server가 있어야 합니다.

metrics-server 설치

# Install metrics server kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml # Wait for deployment kubectl wait --for=condition=available --timeout=300s deployment/metrics-server -n kube-system # Verify installation kubectl top nodes kubectl top pods -n default

왜 KEDA 코어에 GPU 지원을 직접 넣지 않았는가 — 세 가지 이유가 있습니다.

  1. CGO 제약NVIDIA의 Go 바인딩(go-nvml)은 CGO가 필요한데, KEDA 빌드는 CGO 없이 이뤄집니다.
  2. 노드 수준 하드웨어 접근KEDA 오퍼레이터는 중앙 파드로 뜹니다. NVML은 libnvidia-ml.so를 통한 로컬 GPU 접근이 필요해서, GPU 노드의 데몬셋만 제공할 수 있습니다.
  3. 독립 출시 주기KEDA 릴리스를 기다리지 않고 GPU 쪽 개선을 내보낼 수 있습니다.

30초 요약

GPU Node KEDA Operator ┌─────────────────────┐ ┌──────────────────┐ │ keda-gpu-scaler │──gRPC───> │ External Scaler │ │ (DaemonSet) │ │ trigger │ │ │ └────────┬─────────┘ │ NVML: 92% GPU util │ │ │ NVML: 14.2GB VRAM │ Scale vllm-deployment │ :9090/metrics (opt) │ from 3 → 8 replicas └─────────────────────┘

파이프라인 비교

BEFORE: GPU Pod → dcgm-exporter → Prometheus → PromQL → KEDA → HPA (5 components, 15-30s scrape delay, PromQL queries break on upgrades) AFTER: GPU Pod → keda-gpu-scaler (NVML) → KEDA → HPA (2 components, sub-second metrics, zero configuration)

아키텍처

┌──────────────────────────────────────────────────────────┐ │ GPU Node (DaemonSet) │ │ │ │ ┌───────────────────┐ ┌────────────────────────┐ │ │ │ keda-gpu-scaler │◄─────►│ NVIDIA GPU (NVML) │ │ │ │ gRPC :6000 │ │ libnvidia-ml.so │ │ │ │ │ │ A100 / H100 / L40S ... │ │ │ └─────────▲─────────┘ └────────────────────────┘ │ │ │ │ └─────────────┼────────────────────────────────────────────┘ │ gRPC (ExternalScaler protocol) ┌─────────────┼────────────────────────────────────────────┐ │ KEDA │ │ │ ┌─────────▼──────────┐ ┌────────────────────────┐ │ │ │ External Scaler │─────►│ HPA (scale up/down) │ │ │ │ trigger │ │ your-vllm-deployment │ │ │ └────────────────────┘ └────────────────────────┘ │ └──────────────────────────────────────────────────────────┘
프로필주 지표목표활성화 임계용도
vllm-inferenceMemory %805vLLM / LLM 서빙 (scale-to-zero 포함)
triton-inferenceGPU Util7510NVIDIA Triton
trainingGPU Util900학습 잡 (scale-to-zero 없음)
batchMemory %701배치 추론 (공격적 축소)

스케일러 배포

# 방안 1 : Make sure KEDA is already running, then deploy: kubectl apply -f deploy/manifests.yaml # 빙안 2 : Helm helm install keda-gpu-scaler deploy/helm/keda-gpu-scaler \ --namespace keda \ --set nodeSelector."nvidia\.com/gpu\.present"=true

ScaledObject로 워크로드에 붙이기

# Create a ScaledObject pointing to the external scaler service: apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: vllm-inference-scaler namespace: ai-workloads spec: scaleTargetRef: name: vllm-deepseek-deployment minReplicaCount: 1 maxReplicaCount: 50 triggers: - type: external metadata: scalerAddress: "keda-gpu-scaler.keda.svc.cluster.local:6000" targetGpuUtilization: "80" # Or use a pre-built profile: triggers: - type: external metadata: scalerAddress: "keda-gpu-scaler.keda.svc.cluster.local:6000" profile: "vllm-inference"

프로필 대신 원시 지표를 직접 쓸 때

# Override any profile default or use raw GPU metrics directly: triggers: - type: external metadata: scalerAddress: "keda-gpu-scaler.keda.svc.cluster.local:6000" metricType: "gpu_utilization" targetValue: "85" activationThreshold: "10" gpuIndex: "0" # specific GPU index, or omit for all aggregation: "max" # max, min, avg, sum across GPUs
이건 NVIDIA 전용입니다

keda-gpu-scaler는 NVML을 읽으므로 Trainium에는 그대로 쓸 수 없습니다. Neuron 쪽에서 같은 걸 하려면 neuron-monitor가 내보내는 지표를 Prometheus로 받아 KEDA Prometheus 스케일러를 쓰는, 위 그림의 "이전" 경로를 타야 합니다. 이 워크샵의 도전 과제가 그 방향입니다.

참고로 HAMi는 v2.7.0부터 AWS Neuron을 정식 지원하지만, 이번 실습은 vLLM이 TP=2 구성이라 GPU 분할이 의미가 없어 건너뜁니다.

9장 세 줄 요약
  1. 정의 — Lab 6은 vCPU 쿼터 8 제약으로 실행이 막힙니다. Trainium 노드를 한 대밖에 못 띄우기 때문입니다. 대신 왜 CPU 기준 HPA가 부적절한가라는 질문이 남습니다.
  2. 성질 — GPU 노드는 CPU 10%인데 가속기는 100% 포화일 수 있습니다. CPU 임계값은 그 상황에서 절대 발동하지 않습니다. 고칠 것은 임계값이 아니라 보는 지표입니다.
  3. 쓰임 — 표준 경로는 DCGM → Prometheus → PromQL → KEDA(구성요소 5개, 지연 15~30초), keda-gpu-scaler는 NVML 직독 → KEDA(2개, 1초 미만)입니다. 단 후자는 NVIDIA 전용이라 Neuron에는 표준 경로를 써야 합니다.

마지막입니다. 만든 순서의 역순으로, 하지만 그냥 역순은 아닙니다.

10치우기 — 지우는 순서도 설계다

한 줄 요약: 의존 관계를 거꾸로 타지 않으면 리소스가 삭제 중 상태로 걸립니다.

왜 이걸 배우나
"네임스페이스를 지웠는데 Terminating에서 안 넘어간다."

클라우드 실습에서 정리를 못 하면 비용이 계속 나갑니다. trn1.2xlarge는 놀고 있어도 시간당 요금이 붙고, CLB도 두 개가 떠 있습니다.

그런데 그냥 지우면 안 됩니다. PVC가 남은 채로 PV를 지우면 매달려서 안 지워지고, 서비스가 남은 채로 노드그룹을 지우면 CLB가 고아가 됩니다. S3 버킷은 비어 있지 않으면 삭제 자체가 거부됩니다. 그리고 마지막에 CloudFormation의 종료 방지가 한 번 더 막습니다.

이 장에 나오는 낯선 말
finalizer
리소스가 지워지기 전에 반드시 끝나야 하는 뒷정리 작업 표시. 이게 안 끝나면 Terminating에서 멈춥니다.
Reclaim Policy
PVC가 사라졌을 때 PV를 어떻게 할지. Retain이면 남겨 두므로 직접 지워야 합니다.
orphan resource
쿠버네티스에서는 지워졌는데 클라우드 쪽에 남아 계속 과금되는 리소스. CLB가 대표적입니다.
종료 방지 (termination protection)
CloudFormation 스택이 실수로 지워지지 않게 걸어 두는 잠금. 풀어야 삭제됩니다.
그림 8

지우는 순서 — 거꾸로 하면 어디서 걸리나

클릭
해볼 것 — ① 상자를 아무 순서로나 클릭해서 지워 보세요. ② 위쪽 것을 남긴 채 아래 것을 지우려 하면 빨갛게 막힙니다 — 매달린 리소스가 있다는 뜻입니다. ③ 맨 위(Ingress)부터 차례로 내려가면 전부 깨끗하게 지워집니다. ④ S3 버킷은 내용물을 먼저 비워야 지워집니다 — 두 번 눌러야 합니다.
순서의 원칙

"나를 참조하는 것이 아직 있는가"를 먼저 묻습니다. 트래픽 입구(Ingress) → 애플리케이션 → 스토리지 클레임 → 스토리지 → 클러스터 부속 → 노드그룹 순서면 매달림이 생기지 않습니다.

그림에 나오는 말 PVC · PV 스토리지 신청서와 실제 저장소. 신청서를 먼저 지워야 저장소가 풀림 · finalizer 삭제 전에 끝나야 하는 뒷정리 표시. 안 끝나면 Terminating에서 멈춤 · 고아 리소스 쿠버네티스에는 없는데 클라우드에 남아 과금되는 것 · 종료 방지 CloudFormation 스택에 걸린 삭제 잠금
실제 삭제 명령 — 순서대로

1. Ingress 먼저 — 트래픽 입구를 닫는다

kubectl delete ingress -n monitoring --all kubectl delete ingress vllm-ingress-simple

2. vLLM 애플리케이션 — 서비스 → 디플로이먼트 → 설정 → PVC → PV

echo "=== Deleting vLLM application resources ===" # Delete vLLM specific resources (no ingress or network policies created in this workshop) kubectl delete service vllm-service --ignore-not-found=true kubectl delete deployment vllm-deployment --ignore-not-found=true kubectl delete configmap vllm-shared-config --ignore-not-found=true kubectl delete pvc s3-model-cache-pvc --ignore-not-found=true kubectl delete pv s3-model-cache-pv --ignore-not-found=true echo "vLLM application resources deleted!"

3. 모니터링 스택

echo "=== Removing monitoring stack ===" # Uninstall Helm releases helm uninstall grafana -n $MONITORING_NAMESPACE --ignore-not-found=true helm uninstall prometheus -n $MONITORING_NAMESPACE --ignore-not-found=true # Delete monitoring namespace kubectl delete namespace $MONITORING_NAMESPACE --ignore-not-found=true echo "Monitoring stack removed!"

4. S3 — 비우고 나서 지운다 (순서 바꾸면 거부당한다)

echo "=== Deleting S3 model cache bucket ===" # Get the bucket name BUCKET_NAME="ai-infra-summit-vllm-models-cache-$(aws sts get-caller-identity --query Account --output text)" # Delete all objects in the bucket first aws s3 rm s3://$BUCKET_NAME --recursive --region $AWS_REGION 2>/dev/null || echo "Bucket not found or already empty" # Delete the bucket aws s3api delete-bucket --bucket $BUCKET_NAME --region $AWS_REGION 2>/dev/null || echo "Bucket not found or already deleted" echo "S3 model cache bucket deleted!"

5~6. 클러스터 부속과 노드그룹

# kubectl delete ns ingress-nginx kubectl delete -n kube-system ds/s3-csi-node kubectl delete -n kube-system ds/neuron-device-plugin # kubectl delete -n kube-system deploy/k8s-neuron-scheduler kubectl delete -n kube-system deploy/my-scheduler kubectl delete -n kube-system deploy/s3-csi-controller kubectl delete -n kube-system deploy/metrics-server # 노드 그룹 제거 eksctl delete --cluster ai-infra-summit-test-cluster nodegroup neuron-trn1-2x
노드그룹 삭제가 실패하면

AWS CloudFormation 콘솔에서 해당 노드그룹 스택의 종료 방지(termination protection)를 먼저 해제한 뒤 삭제를 다시 실행합니다. 워크샵 환경에는 이 잠금이 걸려 있습니다.

삭제가 끝난 뒤 EC2 콘솔에서 Classic Load Balancer가 0개인지 한 번 더 확인하세요.

10장 세 줄 요약
  1. 정의 — 정리는 의존 관계의 역순입니다. Ingress → 애플리케이션 → PVC → PV → 모니터링 → S3 → 클러스터 부속 → 노드그룹.
  2. 성질 — 순서를 어기면 Terminating에서 멈추거나 클라우드 쪽에 고아 리소스가 남습니다. S3 버킷은 비워야 지워지고, CloudFormation은 종료 방지를 풀어야 지워집니다.
  3. 쓰임 — 실습이 끝나면 CLB 개수를 눈으로 확인하세요. 쿠버네티스 리소스는 지워졌는데 로드밸런서만 남아 과금되는 일이 흔합니다.

마치며

이번 실습에서는, AWS Trainium을 활용한 vLLM 기반 LLM 추론 환경을 구축하고, 다양한 컴포넌트의 설치와 설정, 그리고 클러스터 자원의 정리까지 진행했습니다. 이를 통해 AWS Trainium 환경에서의 LLM 추론 워크플로우를 이해하고, 서빙을 하며 모니터링 할 수 있는 기본 루틴을 익힐 수 있었습니다. 이번 워크샵을 통해 얻은 경험과 지식을 바탕으로, 실제 운영 환경에서도 AWS Trainium을 활용한 LLM 추론 시스템을 보다 효율적으로 구축하고 관리할 수 있을 것 같습니다.

제보 부탁드립니다

이 페이지는 [6W-1] Scaling LLM Inference with vLLM and AWS Trainium Workshop의 스터디 정리본입니다. 참고한 것은 워크샵 원문, AWS Neuron 문서, NeuronCore-v2 아키텍처, NxD Inference, vLLM Neuron 플러그인, llmperf, 그리고 keda-gpu-scaler입니다.

설명이 틀렸거나 실제 운영에서 다르게 동작하는 부분이 있다면 알려주세요. 특히 Trainium에서 오토스케일링을 실제로 붙여 본 사례가 있다면 그게 가장 궁금합니다 — 이 워크샵에서 유일하게 실행하지 못한 부분입니다.

[6W-1] Scaling LLM Inference with vLLM and AWS Trainium — LLMSO 스터디 6주차 학습자료
반응형

'AI > [LLMSO] Hands-On LLM 서빙과 최적화 스터디' 카테고리의 다른 글

[7주차-LLMSO] llm-d 란 (2026.09.13)  (0) 2026.09.20
[7주차-LLMSO] Envoy: Agent Router (2026.09.13)  (0) 2026.09.20
[5주차-LLMSO]CH10. Advancements in LLM Serving (2026.08.30)  (1) 2026.09.05
[5주차-LLMSO]CH9. LLM Optimization in Practice (2026.08.30)  (0) 2026.09.05
[4주차-LLMSO]CH8. LLM Serving Frameworks (2026.08.23)  (0) 2026.08.30
    devlos
    devlos
    안녕하세요, Devlos 입니다. 새로 공부 중인 지식들을 공유하고, 명확히 이해하고자 블로그를 개설했습니다 :) 여러 DEVELOPER 분들과 자유롭게 지식을 공유하고 싶어요! 방문해 주셔서 감사합니다 😀 - DEVLOS -

    티스토리툴바