AWS Core / Architecture Deep Dive

GPU Spot Lotto 아키텍처 분석 - 멀티 리전 GPU Spot 가격 모니터링과 워크로드 디스패치

서울의 제어 시스템이 3개 미국 리전의 GPU Spot 가격을 감시합니다. 그리고 가장 싼 리전의 EKS에 워크로드를 배치합니다. 이 문서는 그 구조를 소스 코드와 IaC 설정 기준으로 분석합니다.

작성 기준일은 2026-08-09입니다.

분석 대상은 spot-gpu-lotto 저장소(GitHub whchoi98/spot-gpu-lotto)의 소스 코드, Terraform, Helm, Kubernetes 매니페스트입니다.

핵심 설정은 poll_interval, dispatch_mode, k8s_mode입니다.

주 출처는 위 저장소의 코드와 설정 파일이며, README 서술과 다른 지점은 본문에 명시한 코드 기준 값입니다.

요약

01왜 필요한가 - Spot 가격 변동과 리전 간 가격 차이

이 절은 시스템이 풀려는 문제를 설명합니다. GPU를 싸게 쓰려 할 때 어떤 어려움이 생기는지부터 짚습니다.

EC2 Spot 인스턴스는 AWS의 남는 서버 용량을 할인가로 빌려 쓰는 방식입니다. 온디맨드(정가로 쓰는 방식)보다 저렴하지만 두 가지 부담이 따라옵니다. 첫째, 가격이 리전과 가용 영역(리전 안의 독립된 데이터센터 구역)마다 다르게 움직입니다.

둘째, AWS가 용량을 회수할 때는 2분 중단 통지 뒤에 인스턴스가 종료됩니다(EC2 공식 문서 기준). GPU 학습 워크로드를 한 리전에 고정하면 더 싼 리전의 가격을 놓칩니다. 중단이 발생하면 체크포인트 복구와 재배치를 사람이 처리해야 합니다.

GPU Spot Lotto는 이 두 문제를 겨냥합니다. 여러 리전의 가격을 상시 비교해, 제출 시점에 가장 싼 리전에 작업을 배치합니다. 중단이 감지되면 다른 리전으로 다시 스케줄링하는 것까지가 설계 목표입니다(재배치 경로의 현재 구현 상태는 5.1절과 8.1절에서 다룹니다).

그러면 어떤 GPU를 감시할까요? 감시 대상은 src/common/config.py에 정의된 6개 GPU 인스턴스 타입입니다. 아래 표에서는 타입별 GPU 종류와 GPU 메모리(VRAM) 용량을 확인할 수 있습니다.

표 1. 감시 대상 GPU 인스턴스 타입 (src/common/config.py, GPU 정보는 src/agent/system_prompt.py의 매핑 기준)
인스턴스 타입GPUVRAM
g6.xlargeL424GB
g5.xlargeA10G24GB
g6e.xlargeL40S48GB
g6e.2xlargeL40S x296GB
g5.12xlargeA10G x496GB
g5.48xlargeA10G x8192GB
참고 - 프로젝트 이름의 의미

"Lotto"라는 이름처럼 Spot 가격은 매 순간 어느 리전이 저렴할지 예측하기 어렵습니다. 이 시스템은 예측 대신 감시 주기마다 실제 가격을 조회해 그 시점의 최저가를 고르는 접근을 택합니다.

02전체 구조 - 서울에서 제어하고 미국에서 실행

이 절은 시스템 전체가 어떻게 배치되어 있는지 조망합니다. 판단하는 부분과 GPU가 실제로 도는 부분이 지리적으로 분리되어 있다는 점이 핵심입니다.

컨트롤 플레인(작업을 접수하고 배치를 판단하는 제어 계층) 전체가 서울 리전에 있습니다. 사용자는 CloudFront와 WAF, ALB를 거쳐 FastAPI(Python 웹 프레임워크) 기반 API 서버에 도달합니다. API 서버는 Redis(인메모리 데이터 저장소, 여기서는 ElastiCache)를 가격 저장소이자 작업 큐로 사용합니다.

그 위에서 세 개의 독립 프로세스가 하나의 파이프라인을 이룹니다. 가격 수집은 Price Watcher, 큐 소비는 Dispatcher, 완료 정리는 Reaper가 맡습니다. GPU가 실제로 도는 곳은 3개 미국 리전의 EKS 클러스터이고, 노드는 Karpenter(Kubernetes용 노드 자동 증설 도구)가 Spot으로만 프로비저닝합니다.

flowchart LR subgraph Seoul["서울 ap-northeast-2 컨트롤 플레인"] API["API Server
FastAPI"] --> RD[("Redis
가격 Sorted Set + 작업 큐")] PW["Price Watcher
EC2 Spot 가격 폴링"] --> RD RD --> DP["Dispatcher
BRPOP + 최저가 선택"] S3["S3 허브 버킷"] end U["사용자"] --> CF["CloudFront + WAF + ALB"] --> API DP --> EKS["최저가 리전 EKS
us-east-1 / us-east-2 / us-west-2"] S3 <--> FSX["FSx Lustre
리전별 auto-sync"] FSX --> EKS
그림 1. 가격 감시 → 큐 → 디스패치 파이프라인. 제어와 데이터 허브는 서울에, GPU 실행은 3개 미국 리전에 분리되어 있습니다.

상태 저장소는 Redis 하나로 통일되어 있습니다. 가격, 큐, 작업 레코드, 리전별 용량 카운터가 모두 Redis 자료구조 하나씩에 대응합니다. 그래서 컴포넌트 간 통신에 별도의 메시지 브로커가 없습니다.

아래 표에서는 어떤 데이터가 어떤 Redis 자료구조에 담기는지, 각 키의 역할과 함께 확인할 수 있습니다.

표 2. Redis 데이터 구조 (ARCHITECTURE.md 4장, 소스 코드에서 키 사용 확인)
타입역할
gpu:spot:pricesSorted Set{region}:{instance_type} 멤버를 가격 점수로 자동 정렬
gpu:job:queueList작업 페이로드 큐, 디스패처가 BRPOP으로 소비
gpu:jobs:{job_id}Hash작업 레코드(상태, 리전, Pod 이름, 재시도 횟수)
gpu:active_jobsSet활성 작업 ID 목록, Reaper 순회 대상
gpu:capacity:{region}String리전별 GPU 슬롯 카운터, 원자적 DECR/INCR
gpu:jobs:{job_id}:statusPub/SubSSE 실시간 상태 스트리밍 채널

API는 얼마나 될까요? openapi.json 기준으로 17개 경로, 19개 오퍼레이션입니다. 작업 CRUD와 SSE(서버가 상태 변화를 실시간으로 밀어주는 스트리밍 방식) 스트림, 가격 조회, S3 presigned 업로드, 템플릿, 관리자 6종, 헬스 체크와 Prometheus 메트릭으로 구성됩니다.

README는 "18개 엔드포인트"로 기술하고 있으나, 이 문서는 OpenAPI 스펙에서 직접 센 값을 기준으로 합니다. 인프라는 Terraform 모듈 13개(vpc, eks, karpenter, elasticache, cognito, alb, cloudfront, ecr, fsx, s3, pod_identity, github_oidc, monitoring)와 Helm 차트(templates 디렉터리 20개 파일)로 정의됩니다.

03가장 싼 리전 고르기 - 가격 수집과 디스패치

이 절은 시스템의 핵심 로직을 따라갑니다. 가격을 모으고, 가장 싼 리전을 고르고, 그 리전에 작업을 내려보내는 세 단계입니다.

3.1 가격 수집

Price Watcher는 리전별로 describe_spot_price_history API(EC2의 Spot 가격 이력 조회 API)를 병렬 호출해 Linux/UNIX 기준 Spot 가격을 가져옵니다. 리전 안에서는 인스턴스 타입별 최저가 하나만 남깁니다.

결과는 ZADD upsert로 Sorted Set(점수 기준으로 자동 정렬되는 Redis 자료구조)에 기록됩니다. 그래서 저장하는 순간 가격순 정렬이 끝나 있습니다. 수집 주기는 poll_interval로 제어되며 코드 기본값은 60초입니다.

참고 - 문서의 60초와 배포 설정의 30초

README와 ARCHITECTURE.md는 60초 폴링으로 서술하지만, 실제 배포에 쓰이는 helm/gpu-lotto/values.yaml, values-dev.yaml, values-prod.yaml.env.example은 모두 POLL_INTERVAL=30을 지정합니다. 코드 기본값이 60초, 배포 환경의 유효값이 30초입니다.

3.2 리전 선택

가장 싼 리전을 고르는 코드는 얼마나 길까요? 30줄 남짓한 함수 하나입니다. Sorted Set 전체를 가격 오름차순으로 읽고, 요청된 인스턴스 타입과 일치하는 후보를 앞에서부터 순회합니다. 용량 확보에 성공하는 첫 리전이 답입니다.

용량 확보는 리전별 카운터의 원자적 감소(중간에 다른 프로세스가 끼어들 수 없는 연산)입니다. 그래서 디스패처가 여럿이어도 초과 배치가 없습니다.

src/dispatcher/region_selector.py
all_prices = await r.zrange("gpu:spot:prices", 0, -1, withscores=True)  # 가격 오름차순

candidates = []
for member, score in all_prices:
    region, itype = member.rsplit(":", 1)
    if itype == instance_type and region not in exclude:
        candidates.append((region, score))

for region, price in candidates:
    acquired = await acquire_capacity(r, region)   # 원자적 슬롯 확보
    if acquired:
        return (region, price)

exclude_regions 인자가 이 함수의 두 번째 역할입니다. 실패한 작업을 재배치할 때 직전 실패 리전을 제외 목록에 넣습니다. 그러면 "차순위로 저렴한 리전"이 선택된다는 설계입니다.

다만 dispatcher 엔트리포인트(main.py)가 requeue_fn을 연결하지 않습니다. 그래서 현재 코드에서는 이 재배치 경로가 실제로 호출되지 않습니다(8.1절 참고).

3.3 큐 소비와 배치

디스패처 본체는 BRPOP(큐에 항목이 들어올 때까지 기다렸다가 꺼내는 Redis 명령, 타임아웃 5초) 무한 루프입니다. 작업을 꺼내면 리전을 선택합니다. 그다음 Kubernetes API로 대상 리전 EKS의 gpu-jobs 네임스페이스에 Pod를 생성하고, 작업 레코드를 기록한 뒤 webhook과 Pub/Sub으로 사용자에게 알립니다.

가용 리전이 하나도 없으면 어떻게 될까요? 작업을 다시 큐에 넣으며, 재시도 한도는 max_retries 기본값 2회입니다. 리전별 용량 기본값은 16슬롯입니다.

원칙: 가격 정렬은 Redis Sorted Set이 수행하고, 디스패처는 정렬된 결과를 앞에서부터 소비하며 용량을 원자적으로 확보할 뿐입니다.

04데이터는 어디에 두나 - Hub-and-Spoke 스토리지

이 절은 데이터 배치 전략을 다룹니다. 작업이 매번 다른 리전에 떨어지는 구조에서는 "데이터를 어느 리전에 둘 것인가"가 핵심 설계 문제가 되기 때문입니다.

이 시스템의 답은 서울 S3 버킷을 단일 허브로 두는 것입니다. 모델, 데이터셋, 체크포인트, 결과물이 모두 허브에 모입니다. GPU Pod는 기본값인 storage_mode=s3에서 S3 Mountpoint CSI(S3 버킷을 파일시스템처럼 마운트해 주는 Kubernetes 스토리지 드라이버)로 허브 버킷을 직접 마운트합니다.

반복 읽기가 많아 파일시스템 성능이 필요한 작업은 어떻게 할까요? storage_mode=fsx를 명시하면, 각 스팟 리전에 스포크로 배치된 FSx for Lustre(고성능 병렬 파일시스템 관리형 서비스)를 선택할 수 있습니다.

FSx 모드의 동기화는 애플리케이션 코드가 아니라 FSx의 Data Repository Association(S3 경로와 파일시스템을 연결해 자동 동기화하는 기능)이 담당합니다. Terraform 모듈이 S3 경로와 파일시스템의 /data 경로를 연결합니다. 그리고 생성/변경/삭제 이벤트에 대해 양방향 자동 동기화를 설정합니다.

terraform/modules/fsx/main.tf
resource "aws_fsx_lustre_file_system" "this" {
  deployment_type = "SCRATCH_2"        # Spot 워크로드 수명주기에 맞춘 스크래치형
  storage_type    = "SSD"
  ...
}

resource "aws_fsx_data_repository_association" "this" {
  data_repository_path = var.s3_import_path   # 서울 S3 허브
  file_system_path     = "/data"
  s3 {
    auto_export_policy { events = ["NEW", "CHANGED", "DELETED"] }
    auto_import_policy { events = ["NEW", "CHANGED", "DELETED"] }
  }
}

Kubernetes 쪽에서는 FSx CSI 드라이버 기반 PersistentVolume(용량 1,200Gi, ReadWriteMany)이 각 리전 클러스터에 배포되어 있습니다. 그래서 storage_mode=fsx를 명시한 작업의 GPU Pod가 /data 아래에서 모델을 읽고 체크포인트를 씁니다. 코드 기본값은 storage_mode=s3이므로, 짧은 추론처럼 반복 읽기가 없는 작업은 별도 지정 없이 파일시스템 비용 없이 S3에 직접 접근합니다.

이 구조의 효과는 storage_mode=fsx 작업의 Spot 중단 시나리오에서 드러납니다. us-east-1에서 쓴 체크포인트가 자동 내보내기로 서울 허브에 올라갑니다. 작업이 us-west-2로 다시 배치되면 그쪽 FSx가 같은 파일을 자동 가져오기로 받아옵니다.

결과적으로 리전을 옮겨도 체크포인트 경로가 동일합니다. 애플리케이션은 리전 이동을 인지할 필요가 없습니다.

05Spot 중단에 대응하는 방법

이 절은 Spot 인스턴스가 갑자기 회수될 때 시스템이 무엇을 하는지 다룹니다. 감지, 노드 교체, 재배치가 어느 계층에서 일어나는지가 관전 포인트입니다.

5.1 Reaper의 상태 감시와 재배치

중단은 얼마나 빨리 알아챌까요? 감지 주체는 디스패처 안에서 함께 도는 Reaper 루프이며, reap_interval 기본값 10초마다 활성 작업 집합을 순회해 각 Pod의 phase(실행 상태)를 조회합니다. Succeeded면 Pod를 삭제하고 용량을 반환합니다. 생성 후 7,200초(2시간)가 지난 작업은 타임아웃으로 정리합니다.

Failed 처리에는 재배치 설계가 들어 있습니다. reap_job은 재시도 횟수가 한도(2회) 미만일 때 실패 리전을 제외 조건으로 붙여 작업을 다시 큐에 넣도록 requeue_fn 콜백을 받게 설계되어 있습니다. 3.2절의 exclude_regions가 그 짝입니다.

그러나 dispatcher 엔트리포인트(main.py)가 requeue_fn을 연결하지 않습니다. 그래서 현재 코드에서는 Spot 중단으로 Failed가 된 작업이 재배치 없이 실패로 종결됩니다(8.1절 참고).

5.2 Karpenter NodePool

노드 계층에서는 Karpenter NodePool(노드를 어떤 조건으로 만들지 정의하는 리소스)이 GPU Spot 전용으로 제한되어 있습니다. 용량 타입은 spot만 허용하고, 인스턴스는 g 계열의 4세대 초과(g5, g6, g6e), 크기는 xlarge와 2xlarge로 좁혀져 있습니다. GPU 총량 한도는 16개이고, AMI는 GPU 드라이버가 내장된 Bottlerocket(컨테이너 전용 경량 OS)입니다.

k8s/karpenter-gpu-spot.yaml
requirements:
  - karpenter.sh/capacity-type: ["spot"]
  - karpenter.k8s.aws/instance-category: ["g"]
  - karpenter.k8s.aws/instance-generation: > "4"   # g5, g6, g6e
taints:
  - nvidia.com/gpu: NoSchedule
limits:
  nvidia.com/gpu: "16"
disruption:
  consolidationPolicy: WhenEmptyOrUnderutilized
  consolidateAfter: 30s        # 유휴 30초 후 노드 축소
spec.template.spec.expireAfter: 2h   # 노드 최대 수명 2시간

중단 대응은 계층별로 분담됩니다. Karpenter가 대체 Spot 노드를 프로비저닝하고, 애플리케이션은 /data/checkpoints/에 주기적으로 체크포인트를 남깁니다. 디스패처(Reaper)는 Pod 실패를 감지해 정리합니다.

다른 리전으로의 재시도는 5.1절에서 본 대로 requeue_fn 배선이 복구되어야 동작합니다. ARCHITECTURE.md는 여기에 EKS Auto Mode의 Node Monitoring Agent가 GPU 장애 노드를 복구하는 계층을 추가로 서술합니다. 노드 최대 수명 2시간은 오래된 Spot 가격 조건에 노드가 묶이는 것을 막는 비용 장치이기도 합니다.

06AI 에이전트 연동 - AgentCore와 MCP Gateway

이 절은 사람 대신 AI가 이 시스템을 조작하는 두 가지 경로를 소개합니다. 시스템 안에 사는 에이전트와, 밖에서 이 시스템을 도구로 부르는 에이전트입니다.

6.1 Strands 에이전트 (AgentCore Runtime)

규칙 기반 디스패치와 별개로, 자연어로 작업을 관리하는 AI 에이전트 경로가 있습니다. src/agent/app.py가 Bedrock AgentCore Runtime(AWS의 에이전트 실행 관리형 환경)의 엔트리포인트입니다. 여기서 Strands Agents SDK(AWS의 에이전트 개발 프레임워크)의 Agent가 도구 5개와 시스템 프롬프트를 갖고 생성됩니다.

모델은 agent_model 설정의 global.anthropic.claude-sonnet-4-6입니다. 시스템 프롬프트는 표 1의 VRAM-인스턴스 매핑과 판단 지침을 담습니다. 지침의 예는 "최저가이면서 용량이 있는 리전 선호, 최저가 리전에 최근 선점 실패가 2회 이상이면 차순위 권고"입니다.

에이전트가 쓸 수 있는 도구는 무엇일까요? 아래 표에서 도구 5개의 이름과 동작을 확인할 수 있습니다.

표 3. 에이전트 도구 5개 (src/agent/tools.py)
도구동작
check_spot_prices가격 Sorted Set 조회, 리전별 가용 용량을 붙여 가격 오름차순 반환
submit_gpu_job작업 페이로드를 큐에 LPUSH
get_job_status작업 Hash 조회
list_active_jobs활성 작업 Set 조회
get_failure_history완료 작업에서 실패를 리전별, 원인별로 집계

각 도구는 async _impl 함수와 동기 @tool 래퍼로 나뉩니다. _impl은 Redis 클라이언트를 인자로 받으므로 fakeredis(가짜 Redis 테스트 라이브러리)로 단위 테스트가 가능합니다. 래퍼는 호출 시점에 의존성을 해석해 asyncio.run으로 실행합니다. 디스패치 경로 전환은 dispatch_mode 설정(rule 또는 agent)으로 제어합니다.

6.2 MCP Gateway

반대 방향의 통합도 있습니다. AgentCore Gateway가 OpenAPI 스펙을 읽어 REST API를 MCP(AI 에이전트가 외부 도구를 부르는 표준 프로토콜) 도구로 노출합니다. 그래서 외부 AI 에이전트가 GPU Spot Lotto 자체를 도구로 호출할 수 있습니다.

노출 범위는 얼마나 될까요? Gateway에 주입되는 스펙은 전체 API가 아니라 에이전트에게 유의미한 것만 추린 openapi-gateway.json이며, 5개 경로에 6개 오퍼레이션(가격 조회, 작업 제출/조회/취소, 관리자 작업 목록, 통계)을 담습니다. 인증은 Gateway가 프로비저닝하는 Cognito JWT를 사용한다고 ARCHITECTURE.md가 서술합니다.

07실제 운영 흐름

이 절은 배포와 운영이 실제로 어떻게 돌아가는지 다룹니다. 작업 하나가 제출부터 완료까지 지나는 길도 따라갑니다.

배포 단위는 Helm 차트(Kubernetes 배포 패키지) 하나입니다. api-server, dispatcher, price-watcher, frontend 네 서비스가 같은 차트에서 나옵니다. dev와 prod의 차이는 values 파일(환경별 설정값 파일)로 갈립니다.

아래 표에서는 dev와 prod가 어떤 설정에서 갈라지는지 항목별로 확인할 수 있습니다.

표 4. dev와 prod 배포 구성 차이 (helm/gpu-lotto/values-dev.yaml, values-prod.yaml)
항목devprod
k8sModedry-run (Pod 미생성)real (8.1절의 문제 지점)
인증비활성Cognito JWT 활성
API 서버 스케일레플리카 1HPA 2~6, CPU 70% 기준
Ingress / NetworkPolicy비활성활성
폴링 주기30초30초

한 작업의 수명주기는 다음과 같이 흐릅니다. 사용자가 S3 presigned URL(자격 증명 없이 일정 시간 업로드를 허용하는 서명된 주소)로 학습 데이터를 허브 버킷에 직접 올립니다. 그리고 POST /api/jobs로 작업을 제출합니다.

디스패처가 최저가 리전에 Pod를 만들면 Pod가 허브 데이터를 마운트합니다. 기본값 storage_mode=s3는 S3 Mountpoint 직접 마운트이고, storage_mode=fsx는 FSx 자동 가져오기입니다. 학습 중 체크포인트와 결과물은 다시 허브에 모입니다.

사용자는 /api/jobs/{job_id}/stream SSE로 상태 변화를 실시간 수신하거나 webhook 알림을 받습니다. 이 흐름은 저장소의 데모 스크립트 4종(비용 최적화 배치, Spot 중단 복구, 전체 수명주기, AI 에이전트 배치)이 실제 API 호출로 재연합니다.

관측은 Prometheus(오픈소스 지표 수집 시스템) 중심입니다. API 서버 /metrics와 디스패처의 별도 메트릭 포트에서 JOBS_DISPATCHED, QUEUE_DEPTH, SPOT_PRICE 등의 지표를 노출합니다. ServiceMonitor와 Grafana 대시보드 ConfigMap이 차트에 포함되고, 로그는 structlog 기반 JSON입니다.

테스트는 fakeredis 기반 단위 테스트 11개 모듈과 httpx ASGITransport + fakeredis 기반 API 통합 테스트 5개 모듈로 구성됩니다. testcontainers는 dev 의존성에 선언만 되어 있고 실제로는 사용되지 않습니다.

08한계와 결론

이 절은 코드에서 확인한 문제점과 구조적 한계를 정리합니다. 이 시스템을 참고하려는 팀이 무엇을 먼저 고쳐야 하는지도 짚습니다.

8.1 설정과 코드의 불일치

주의 - values의 k8sMode "real"은 코드가 인식하지 못합니다

queue_processor.pyk8s_mode == "live"일 때만 실제 Kubernetes API로 Pod를 생성합니다. 그런데 values.yamlvalues-prod.yamlk8sMode: "real"을 지정합니다. 이 값으로 배포하면 운영 환경에서도 dry-run(실제 생성 없이 흉내만 내는 모드) 분기를 타서 Pod가 생성되지 않습니다. priceMode: "real"은 코드가 mock 여부만 검사하기 때문에 우연히 실가격 수집으로 동작하지만, k8sMode는 배포 전 교정이 필요합니다.

문서와 코드의 어긋남도 몇 곳 있습니다. README의 환경 변수 표는 AUTH_ENABLED 기본값을 false, K8S_MODE 기본값을 dry-run으로 적습니다. 그러나 config.py의 실제 기본값은 각각 Truelive입니다.

ARCHITECTURE.md가 에이전트 런타임 설정으로 참조하는 .bedrock_agentcore.yaml 파일은 저장소에 존재하지 않습니다. 폴링 주기(60초 대 30초)와 엔드포인트 수(18 대 19 오퍼레이션)의 차이는 앞 섹션에서 다뤘습니다.

설계와 배선의 불일치도 있습니다. reap_jobselect_region에는 실패 리전 제외 기반 재배치 인터페이스(requeue_fn, exclude_regions)가 갖춰져 있습니다. 그러나 dispatcher 엔트리포인트인 main.pyrequeue_fn을 연결하지 않습니다.

그 결과 현재 코드에서는 Spot 중단으로 Failed가 된 작업이 재배치 없이 실패로 종결됩니다. 5.1절의 재배치 설계를 실제로 동작시키려면 k8sMode 교정과 함께 이 배선도 복구해야 합니다.

8.2 구조적 한계

  • 규칙 기반 디스패치는 가격과 용량만 봅니다. 리전별 선점 이력은 get_failure_history 도구를 통해 agent 모드에서만 판단에 반영되고, rule 모드의 region_selector에는 들어가지 않습니다.
  • 중단 감지가 Pod phase 폴링(10초 주기) 기반입니다. EC2의 2분 중단 통지나 리밸런스 권고를 직접 구독하는 경로는 소스에 없습니다.
  • ADR-001이 기록하듯 AgentCore Runtime의 PUBLIC 모드는 VPC 내부의 ElastiCache Redis에 접근할 수 없어, 에이전트 경로의 운영 사용에는 VPC 구성이 선행되어야 합니다.
  • 가격 비교 대상이 3개 미국 리전으로 고정 배포되어 있습니다. 설정상 리전 목록은 바꿀 수 있지만 FSx와 EKS를 리전마다 추가로 프로비저닝해야 합니다.

8.3 결론

한 문장으로 요약하면, GPU Spot Lotto는 가격 비교와 데이터 동기화를 각각 Redis와 스토리지 서비스에 맡겨 코드를 최소화한 멀티 리전 GPU Spot 시스템이지만, 재배치 배선과 k8sMode 설정을 고쳐야 설계대로 동작합니다.

이 시스템은 "Redis 자료구조에 문제를 정확히 대응시키는" 설계가 돋보입니다. 가격 정렬은 Sorted Set, 큐는 List와 BRPOP, 동시성 제어는 원자적 카운터로 풉니다. 리전 간 데이터 문제는 애플리케이션이 아니라 S3 허브(기본값)와 FSx 자동 동기화(선택)에 위임합니다.

그 결과 디스패치 핵심 로직이 수십 줄 단위로 유지되고 fakeredis만으로 검증 가능합니다. 멀티 리전 Spot 운영을 검토하는 팀이라면 최저가 선택과 Hub-and-Spoke 스토리지 패턴을 그대로 참고할 만합니다. 다만 제외 목록 기반 재배치는 인터페이스만 설계된 상태이므로, 적용 전에는 8.1절의 k8sMode 값 교정과 requeue_fn 배선 복구가 첫 번째 할 일입니다.

인터랙티브 아키텍처 맵 전체 이미지 - 시스템 구성 요소와 흐름을 한 화면으로 보여줍니다
그림 2. 인터랙티브 아키텍처 맵 전체 보기. 이미지를 클릭하면 노드 탐색, 경로 추적, 다크/라이트 테마를 지원하는 인터랙티브 버전 ↗이 열립니다.

--참고 자료

핵심 출처

  • whchoi98/spot-gpu-lotto - GitHub 저장소. 본문 수치의 근거인 README.md, ARCHITECTURE.md, src/, terraform/, helm/, k8s/, openapi.json (2026-08-09 접근 확인) https://github.com/whchoi98/spot-gpu-lotto

공식 문서

AWS Core / Architecture Deep Dive

GPU Spot Lotto Architecture Analysis - Multi-Region GPU Spot Price Monitoring and Workload Dispatch

A control system in Seoul watches GPU Spot prices across three US regions. It then places workloads on the cheapest region's EKS cluster. This document analyzes that structure based on the source code and IaC configuration.

This document is written as of 2026-08-09.

The analysis covers the source code, Terraform, Helm, and Kubernetes manifests of the spot-gpu-lotto repository (GitHub whchoi98/spot-gpu-lotto).

The key settings are poll_interval, dispatch_mode, and k8s_mode.

The primary sources are the repository's code and configuration files; where the README differs, this document states the code-based values explicitly.

TL;DR

01Why This Exists - Spot Price Swings and Regional Gaps

This section explains the problem the system tries to solve. It starts with why using GPUs cheaply is harder than it sounds.

EC2 Spot instances let you rent AWS's spare server capacity at a discount. They are cheaper than On-Demand (the pay-full-price model), but two burdens come with them. First, prices move differently in every region and Availability Zone (an isolated data center group within a region).

Second, when AWS reclaims capacity, the instance is terminated after a 2-minute interruption notice (per the official EC2 documentation). Pinning a GPU training workload to one region means missing cheaper prices elsewhere. And when an interruption happens, checkpoint recovery and re-placement become manual work.

GPU Spot Lotto targets both problems. It continuously compares prices across regions and places a job in the cheapest region at submission time. Rescheduling work to another region on interruption is also a design goal (the current implementation status of that path is covered in sections 5.1 and 8.1).

So which GPUs does it watch? The watch list is the 6 GPU instance types defined in src/common/config.py. The table below shows each type's GPU model and GPU memory (VRAM) capacity.

Table 1. Monitored GPU instance types (src/common/config.py; GPU details follow the mapping in src/agent/system_prompt.py)
Instance typeGPUVRAM
g6.xlargeL424GB
g5.xlargeA10G24GB
g6e.xlargeL40S48GB
g6e.2xlargeL40S x296GB
g5.12xlargeA10G x496GB
g5.48xlargeA10G x8192GB
Note - What the project name means

As the name "Lotto" suggests, it is hard to predict which region will be cheapest at any given moment. Instead of predicting, the system queries actual prices on every polling cycle and picks the cheapest region at that point in time.

02The Big Picture - Control in Seoul, Execution in the US

This section gives a bird's-eye view of how the system is laid out. The key point is that the part that decides and the part that actually runs GPUs are geographically separated.

The entire control plane (the control layer that accepts jobs and decides placement) lives in the Seoul region. Users reach a FastAPI (a Python web framework) based API server through CloudFront, WAF, and an ALB. The API server uses Redis (an in-memory data store, here ElastiCache) as both the price store and the job queue.

On top of that, three independent processes form one pipeline. The Price Watcher collects prices, the Dispatcher consumes the queue, and the Reaper cleans up completed work. GPUs actually run on EKS clusters in three US regions, and Karpenter (an autoscaling tool that adds Kubernetes nodes on demand) provisions the nodes as Spot only.

flowchart LR subgraph Seoul["Seoul ap-northeast-2 control plane"] API["API Server
FastAPI"] --> RD[("Redis
price Sorted Set + job queue")] PW["Price Watcher
polls EC2 Spot prices"] --> RD RD --> DP["Dispatcher
BRPOP + cheapest-region pick"] S3["S3 hub bucket"] end U["User"] --> CF["CloudFront + WAF + ALB"] --> API DP --> EKS["Cheapest-region EKS
us-east-1 / us-east-2 / us-west-2"] S3 <--> FSX["FSx Lustre
per-region auto-sync"] FSX --> EKS
Figure 1. The price-watch → queue → dispatch pipeline. Control and the data hub sit in Seoul, while GPU execution is spread across three US regions.

State is unified in a single Redis. Prices, the queue, job records, and per-region capacity counters each map to exactly one Redis data structure. So there is no separate message broker between components.

The table below shows which data lives in which Redis structure, along with each key's role.

Table 2. Redis data structures (ARCHITECTURE.md chapter 4; key usage verified in the source code)
KeyTypeRole
gpu:spot:pricesSorted SetAuto-sorts {region}:{instance_type} members by price score
gpu:job:queueListJob payload queue, consumed by the dispatcher via BRPOP
gpu:jobs:{job_id}HashJob record (status, region, Pod name, retry count)
gpu:active_jobsSetActive job ID list, iterated by the Reaper
gpu:capacity:{region}StringPer-region GPU slot counter, atomic DECR/INCR
gpu:jobs:{job_id}:statusPub/SubChannel for SSE real-time status streaming

How big is the API? Per openapi.json, the surface is 17 paths and 19 operations: job CRUD and an SSE (a streaming method where the server pushes status changes in real time) stream, price queries, S3 presigned uploads, templates, six admin operations, health checks, and Prometheus metrics.

The README says "18 endpoints", but this document uses the counts taken directly from the OpenAPI spec. The infrastructure is defined by 13 Terraform modules (vpc, eks, karpenter, elasticache, cognito, alb, cloudfront, ecr, fsx, s3, pod_identity, github_oidc, monitoring) and one Helm chart (20 files in the templates directory).

03Picking the Cheapest Region - Price Collection and Dispatch

This section follows the system's core logic. There are three steps: collect prices, pick the cheapest region, and send the job there.

3.1 Price collection

The Price Watcher calls the describe_spot_price_history API (EC2's Spot price history query API) in parallel per region to fetch Linux/UNIX Spot prices. Within each region, it keeps only the single lowest price per instance type.

Results are written with a ZADD upsert to a Sorted Set (a Redis structure that keeps members automatically ordered by score). So the data is already sorted by price the moment it is stored. The collection cycle is controlled by poll_interval, with a code default of 60 seconds.

Note - 60 seconds in the docs vs 30 seconds in the deployment config

The README and ARCHITECTURE.md describe 60-second polling, but the files actually used for deployment - helm/gpu-lotto/values.yaml, values-dev.yaml, values-prod.yaml, and .env.example - all set POLL_INTERVAL=30. The code default is 60 seconds; the effective value in deployed environments is 30 seconds.

3.2 Region selection

How much code does it take to pick the cheapest region? A single function of roughly 30 lines. It reads the entire Sorted Set in ascending price order and walks the candidates that match the requested instance type from the front. The first region where capacity acquisition succeeds is the answer.

Capacity acquisition is an atomic decrement (an operation no other process can interrupt midway) of the per-region counter. So there is no over-placement even with multiple dispatchers.

src/dispatcher/region_selector.py
all_prices = await r.zrange("gpu:spot:prices", 0, -1, withscores=True)  # ascending price order

candidates = []
for member, score in all_prices:
    region, itype = member.rsplit(":", 1)
    if itype == instance_type and region not in exclude:
        candidates.append((region, score))

for region, price in candidates:
    acquired = await acquire_capacity(r, region)   # atomic slot acquisition
    if acquired:
        return (region, price)

The exclude_regions argument is this function's second role. When a failed job is rescheduled, the region it just failed in goes on the exclusion list. The design intent is that the "next-cheapest region" then gets picked.

However, the dispatcher entrypoint (main.py) never wires up requeue_fn. So in the current code this rescheduling path is never actually invoked (see section 8.1).

3.3 Queue consumption and placement

The dispatcher body is an infinite BRPOP (a Redis command that waits for an item to arrive on a queue and then pops it; 5-second timeout) loop. When it pops a job, it selects a region. It then creates a Pod in the gpu-jobs namespace of the target region's EKS via the Kubernetes API, writes the job record, and notifies the user via webhook and Pub/Sub.

What if no region is available at all? The job is pushed back onto the queue, and the retry limit is the max_retries default of 2. The per-region capacity default is 16 slots.

Principle: the Redis Sorted Set does the price sorting; the dispatcher merely consumes the sorted result from the front while acquiring capacity atomically.

04Where the Data Lives - Hub-and-Spoke Storage

This section covers the data placement strategy. In a structure where each job may land in a different region, "which region holds the data" becomes the central design problem.

This system's answer is a single hub: an S3 bucket in Seoul. Models, datasets, checkpoints, and outputs all converge on the hub. Under the default storage_mode=s3, the GPU Pod mounts the hub bucket directly via the S3 Mountpoint CSI driver (a Kubernetes storage driver that mounts an S3 bucket like a filesystem).

What about jobs with heavy repeated reads that need filesystem performance? They can explicitly set storage_mode=fsx to use FSx for Lustre (a managed high-performance parallel filesystem) deployed as a spoke in each Spot region.

In FSx mode, synchronization is handled not by application code but by FSx's Data Repository Association (a feature that links an S3 path to the filesystem and syncs them automatically). The Terraform module links the S3 path to the filesystem's /data path. It then configures bidirectional automatic sync for create/change/delete events.

terraform/modules/fsx/main.tf
resource "aws_fsx_lustre_file_system" "this" {
  deployment_type = "SCRATCH_2"        # scratch type matching the Spot workload lifecycle
  storage_type    = "SSD"
  ...
}

resource "aws_fsx_data_repository_association" "this" {
  data_repository_path = var.s3_import_path   # the Seoul S3 hub
  file_system_path     = "/data"
  s3 {
    auto_export_policy { events = ["NEW", "CHANGED", "DELETED"] }
    auto_import_policy { events = ["NEW", "CHANGED", "DELETED"] }
  }
}

On the Kubernetes side, an FSx CSI driver-based PersistentVolume (1,200Gi, ReadWriteMany) is deployed to each regional cluster. So the GPU Pods of jobs that explicitly set storage_mode=fsx read models and write checkpoints under /data. Since the code default is storage_mode=s3, jobs without repeated reads - short inference runs, for example - access S3 directly with no filesystem cost.

The payoff of this structure shows in the Spot interruption scenario for storage_mode=fsx jobs. A checkpoint written in us-east-1 is auto-exported to the Seoul hub. When the job is re-placed in us-west-2, that region's FSx auto-imports the same files.

As a result, the checkpoint path stays identical across regions. The application never needs to know it moved.

05How Spot Interruptions Are Handled

This section covers what the system does when a Spot instance is suddenly reclaimed. The thing to watch is which layer handles detection, node replacement, and rescheduling.

5.1 The Reaper's status watch and rescheduling

How quickly is an interruption noticed? Detection is done by the Reaper loop that runs inside the dispatcher, which iterates the active job set every reap_interval (default 10 seconds) and checks each Pod's phase (execution state). If Succeeded, it deletes the Pod and returns the capacity. Jobs older than 7,200 seconds (2 hours) are cleaned up as timeouts.

The Failed path carries the rescheduling design. reap_job is designed to take a requeue_fn callback that re-enqueues the job with the failed region attached as an exclusion when the retry count is under the limit (2). It pairs with exclude_regions from section 3.2.

However, the dispatcher entrypoint (main.py) never wires up requeue_fn. So in the current code, a job that becomes Failed due to a Spot interruption ends as a failure without rescheduling (see section 8.1).

5.2 Karpenter NodePool

At the node layer, the Karpenter NodePool (the resource that defines what kind of nodes may be created) is restricted to GPU Spot only. The capacity type allows spot only, and the instances are narrowed to the g family above generation 4 (g5, g6, g6e) in xlarge and 2xlarge sizes. The total GPU limit is 16, and the AMI is Bottlerocket (a minimal container-focused OS) with GPU drivers built in.

k8s/karpenter-gpu-spot.yaml
requirements:
  - karpenter.sh/capacity-type: ["spot"]
  - karpenter.k8s.aws/instance-category: ["g"]
  - karpenter.k8s.aws/instance-generation: > "4"   # g5, g6, g6e
taints:
  - nvidia.com/gpu: NoSchedule
limits:
  nvidia.com/gpu: "16"
disruption:
  consolidationPolicy: WhenEmptyOrUnderutilized
  consolidateAfter: 30s        # consolidate the node after 30s idle
spec.template.spec.expireAfter: 2h   # node max lifetime 2 hours

Interruption handling is split across layers. Karpenter provisions replacement Spot nodes, and the application periodically writes checkpoints to /data/checkpoints/. The dispatcher (Reaper) detects Pod failures and cleans up.

Retrying in another region only works once the requeue_fn wiring from section 5.1 is restored. ARCHITECTURE.md additionally describes a layer where EKS Auto Mode's Node Monitoring Agent recovers nodes with GPU faults. The 2-hour node lifetime cap is also a cost control that keeps nodes from staying pinned to stale Spot pricing.

06AI Agent Integration - AgentCore and the MCP Gateway

This section introduces two paths where an AI operates this system instead of a person. One is an agent living inside the system; the other is outside agents calling this system as a tool.

6.1 The Strands agent (AgentCore Runtime)

Separate from rule-based dispatch, there is an AI agent path that manages jobs in natural language. src/agent/app.py is the entrypoint for Bedrock AgentCore Runtime (AWS's managed environment for running agents). There, a Strands Agents SDK (AWS's agent development framework) Agent is created with 5 tools and a system prompt.

The model is global.anthropic.claude-sonnet-4-6 from the agent_model setting. The system prompt carries the VRAM-to-instance mapping from Table 1 plus judgment guidance. One example: "prefer the cheapest region that has capacity; if the cheapest region has 2 or more recent preemption failures, recommend the runner-up".

What tools does the agent get? The table below shows the 5 tools and what each one does.

Table 3. The agent's 5 tools (src/agent/tools.py)
ToolBehavior
check_spot_pricesReads the price Sorted Set; returns it in ascending price order with per-region available capacity attached
submit_gpu_jobLPUSHes a job payload onto the queue
get_job_statusReads the job Hash
list_active_jobsReads the active job Set
get_failure_historyAggregates failures from completed jobs by region and cause

Each tool is split into an async _impl function and a synchronous @tool wrapper. _impl takes a Redis client as an argument, so it can be unit-tested with fakeredis (a fake Redis testing library). The wrapper resolves dependencies at call time and runs with asyncio.run. Switching the dispatch path is controlled by the dispatch_mode setting (rule or agent).

6.2 MCP Gateway

The integration also runs in the opposite direction. AgentCore Gateway reads an OpenAPI spec and exposes the REST API as MCP (a standard protocol that lets AI agents call external tools) tools. So external AI agents can call GPU Spot Lotto itself as a tool.

How much is exposed? The spec injected into the Gateway is not the full API but openapi-gateway.json, trimmed to what matters to agents: 5 paths with 6 operations (price query, job submit/get/cancel, admin job list, statistics). ARCHITECTURE.md states that authentication uses a Cognito JWT provisioned by the Gateway.

07Operations in Practice

This section covers how deployment and operations actually work. It also follows the road a single job travels from submission to completion.

The deployment unit is a single Helm chart (a Kubernetes deployment package). Four services - api-server, dispatcher, price-watcher, and frontend - come out of the same chart. dev and prod diverge only through values files (per-environment configuration files).

The table below shows, item by item, where dev and prod differ.

Table 4. dev vs prod deployment differences (helm/gpu-lotto/values-dev.yaml, values-prod.yaml)
Itemdevprod
k8sModedry-run (no Pods created)real (the problem spot in section 8.1)
AuthdisabledCognito JWT enabled
API server scale1 replicaHPA 2-6, at 70% CPU
Ingress / NetworkPolicydisabledenabled
Polling interval30s30s

A job's lifecycle flows as follows. The user uploads training data straight to the hub bucket via an S3 presigned URL (a signed address that permits uploads for a limited time without credentials). The job is then submitted with POST /api/jobs.

The dispatcher creates a Pod in the cheapest region, and the Pod mounts the hub data. The default storage_mode=s3 mounts S3 Mountpoint directly, while storage_mode=fsx relies on FSx auto-import. Checkpoints and outputs flow back to the hub during training.

The user receives status changes in real time over the /api/jobs/{job_id}/stream SSE endpoint or via webhook notifications. The repository's four demo scripts (cost-optimized placement, Spot interruption recovery, full lifecycle, AI agent placement) replay this flow with real API calls.

Observability centers on Prometheus (an open-source metrics collection system). The API server's /metrics and the dispatcher's separate metrics port expose indicators such as JOBS_DISPATCHED, QUEUE_DEPTH, and SPOT_PRICE. A ServiceMonitor plus a Grafana dashboard ConfigMap ship with the chart, and logs are structlog-based JSON.

Tests consist of 11 fakeredis-based unit test modules and 5 API integration test modules built on httpx ASGITransport + fakeredis. testcontainers is declared as a dev dependency but never actually used.

08Limitations and Conclusion

This section sums up the problems found in the code and the structural limits. It also points out what a team referencing this system should fix first.

8.1 Where configuration and code disagree

Warning - the code does not recognize k8sMode "real" from values

queue_processor.py creates Pods through the real Kubernetes API only when k8s_mode == "live". But values.yaml and values-prod.yaml set k8sMode: "real". A deployment with these values takes the dry-run (simulate-only, no actual creation) branch even in production, and no Pods are created. priceMode: "real" happens to work for real price collection because the code only checks for mock, but k8sMode must be corrected before deployment.

There are a few more places where documentation and code diverge. The README's environment variable table lists the AUTH_ENABLED default as false and the K8S_MODE default as dry-run. But the actual defaults in config.py are True and live respectively.

The .bedrock_agentcore.yaml file that ARCHITECTURE.md references as the agent runtime configuration does not exist in the repository. The polling interval (60 vs 30 seconds) and the endpoint count (18 vs 19 operations) were covered in earlier sections.

There is also a gap between design and wiring. reap_job and select_region carry a rescheduling interface based on failed-region exclusion (requeue_fn, exclude_regions). But the dispatcher entrypoint main.py never connects requeue_fn.

As a result, in the current code a job that fails due to a Spot interruption ends as a failure without rescheduling. Making the section 5.1 rescheduling design actually work requires restoring this wiring along with the k8sMode fix.

8.2 Structural limits

  • Rule-based dispatch looks only at price and capacity. Per-region preemption history feeds into decisions only in agent mode via the get_failure_history tool; it never enters rule mode's region_selector.
  • Interruption detection is based on Pod phase polling (10-second cycle). There is no path in the source that directly subscribes to EC2's 2-minute interruption notice or rebalance recommendations.
  • As ADR-001 records, AgentCore Runtime's PUBLIC mode cannot reach the ElastiCache Redis inside the VPC, so production use of the agent path requires VPC configuration first.
  • Price comparison is deployed against a fixed set of three US regions. The region list is configurable, but each added region needs its own FSx and EKS provisioning.

8.3 Conclusion

In one sentence: GPU Spot Lotto is a multi-region GPU Spot system that minimizes code by delegating price comparison to Redis and data synchronization to storage services, but the rescheduling wiring and the k8sMode setting must be fixed before it works as designed.

The system stands out for mapping each problem precisely onto a Redis data structure. Price sorting is a Sorted Set, the queue is a List with BRPOP, and concurrency control is an atomic counter. The cross-region data problem is delegated not to the application but to the S3 hub (default) and FSx auto-sync (optional).

As a result, the core dispatch logic stays within a few dozen lines and can be verified with fakeredis alone. Teams considering multi-region Spot operations can adopt the cheapest-region selection and the Hub-and-Spoke storage pattern as they are. But since exclusion-list-based rescheduling exists only as an interface, the first tasks before adopting it are the k8sMode fix from section 8.1 and restoring the requeue_fn wiring.

Full image of the interactive architecture map showing the system components and flows in one view
Figure 2. Full view of the interactive architecture map. Click the image to open the interactive version ↗ with node search, route tracing, and dark/light themes.

--References

Primary source

  • whchoi98/spot-gpu-lotto - The GitHub repository. README.md, ARCHITECTURE.md, src/, terraform/, helm/, k8s/, and openapi.json, the basis for the figures in this document (access verified 2026-08-09) https://github.com/whchoi98/spot-gpu-lotto

Official documentation