GitHub Actions 러너에 스팟 인스턴스 활용
출처: Dev.to
Part 1은 기반이었습니다 – 코드형 Jenkins, 일시적인 워커, 전체 흐름. Part 2는 고통스러운 플랫폼 – macOS. 이번은 옆길입니다: CI 작업량의 일부를 Jenkins에서 완전히 떼어내어 GitHub Actions로 옮기고, EC2 스팟 인스턴스를 러너 플릿으로 사용했습니다.
이것이 “Jenkins는 죽었으니 GitHub Actions를 쓰라”는 이야기가 아닙니다. Jenkins 설정은 여전히 대형 빌드 – macOS, Windows, 맞춤 오케스트레이션이 필요하거나 몇 시간씩 오래 걸리는 작업 – 의 주축입니다. GitHub Actions는 그와 병행해서, 더 적합한 특정 종류의 워크로드에 사용됩니다.
이 글은 셀프‑호스팅 스팟 러너 패턴에 대해 다룹니다 – GitHub Actions를 GitHub이 관리하는 러너가 아니라 여러분이 직접 운영하는 일시적인 EC2 플릿에 연결하는 방법과, 그렇게 했을 때 마주치는 문제점들을 소개합니다.
GitHub이 기본 제공하는 관리형 러너는 작은 팀에게는 충분합니다. 셀프‑호스팅을 고민하게 만드는 몇 가지 이유는 다음과 같습니다:
-
대량 사용 시 비용
GitHub은 관리형 Linux 러너를 분당 $0.008(시간당 약 $0.48)에 청구합니다. 하루에 몇 번 정도 빌드를 실행한다면 큰 문제가 되지 않죠. 하지만 우리 팀은 월 약 80,000 러너‑시간(29,000 개의 잡) 정도를 사용합니다. 같은 워크로드를 관리형 러너로 돌리면 월 $38,000 정도가 나올 겁니다. 반면 지난달 우리 EC2 스팟 플릿 비용은 $120 정도였고, 여기에 Lambda, SQS, EBS 등 오케스트레이션에 드는 몇 달러가 추가됐을 뿐입니다. 시간당 차이가 핵심인데, 스팟은 러너‑시간당 $0.001‑0.002, 관리형은 $0.48 정도 차이입니다. -
인스턴스 형태
GitHub 관리형 러너는 고정된 사이즈만 제공합니다. 빌드에 16 vCPU와 64 GB RAM, 특정 GPU, 혹은 arm64가 필요하면 가장 큰 티어를 사야 하거나, 원하는 구성을 사용할 수 없습니다. 셀프‑호스팅이면 실제 빌드에 필요한 EC2 인스턴스 타입을 자유롭게 선택할 수 있습니다. -
네트워크·의존성 접근
사설 리소스(내부 아티팩트 레지스트리, RDS, VPC 내부 서비스 등)와 통신해야 하는 빌드는 관리형 러너에서 다루기 번거롭습니다. 셀프‑호스팅 러너는 여러분의 VPC 안에 존재하므로 프록시나 터널 없이 직접 접근할 수 있습니다.
우리에게는 이 세 가지가 모두 적용됐습니다. 비용이 전환을 시도하게 만든 주된 요인이었고, 인스턴스 형태 유연성과 VPC 접근성은 기대 이상의 장점이었습니다.
셀프‑호스팅 GitHub Actions 러너란?
GitHub 레포 혹은 조직에 자신을 등록하고, 라벨이 일치하는 잡을 폴링(poll)해서 실행하고, 결과를 다시 보고하는 작은 에이전트입니다. 에이전트는 어디에 있든 상관없습니다 – 베어 메탈, VM, 컨테이너 등 러너 바이너리를 실행할 수 있는 환경이면 됩니다.
러너는 두 가지 형태가 가능합니다:
- Persistent(영구): 한 번 등록하고 영원히 남아 잡이 들어올 때마다 처리합니다.
- Ephemeral(일시): 단일 사용 토큰으로 등록하고, 정확히 하나의 잡을 처리한 뒤 등록을 해제하고 종료합니다.
우리는 일시형을 선택했습니다. Part 1에서와 같은 이유죠. 오래 살아있는 셀프‑호스팅 러너는 호스트를 계속 관리해야 하고, 공유 에이전트에서 발생하는 빌드 오염에 노출되며, 보안 위험 영역이 닫히지 않는 단점이 있습니다.
따라서 각 GitHub Actions 잡마다 새로운 EC2 스팟 인스턴스를 Packer로 만든 AMI에서 새로 띄웁니다. 잡이 끝나면 인스턴스는 바로 종료됩니다. 이는 Jenkins 플릿에서 “한 워커당 한 빌드”와 동일한 개념이지만, 제어 평면이 다를 뿐입니다.
구현 방식
“셀프‑호스팅 스팟 러너”라는 단일 플러그인이나 서비스는 없습니다. 흐름을 직접 연결하거나, 몇 가지 오픈소스 모듈 중 하나를 사용해 자동화합니다. 저는 terraform-aws-github-runner를 선택했는데, 가장 검증된 옵션이며 Terraform으로 관리되는 AWS 계정에 별다른 절차 없이 바로 적용할 수 있습니다. (예전 이름을 기억한다면: 원래는 philips-labs/terraform-aws-github-runner였고, 이후 github-aws-runners 조직으로 옮겨졌습니다. 같은 프로젝트, 새 홈.)
전체 흐름
- PR이 열리면 워크플로우가 트리거됩니다.
- GitHub이 웹훅을 발송하고, 워크플로우 잡이 대기열에 들어가면
workflow_job이벤트를 웹훅 URL로 전송합니다. - API Gateway + Lambda가 이를 수신합니다. Lambda는 페이로드(HMAC 서명)를 검증하고, 관심 있는 러너 라벨을 필터링한 뒤 SQS 큐에 메시지를 넣습니다.
- “scale‑up” Lambda가 큐를 비웁니다. 대기 중인 각 잡마다 지정된 AMI와 단일 사용 등록 토큰을 담은 사용자 데이터를 이용해 EC2 스팟 인스턴스를 시작합니다.
- 인스턴스가 올라오면 cloud‑init이 실행되고, 러너 바이너리가 토큰을 사용해 GitHub에 자신을 등록한 뒤 잡을 폴링합니다.
- GitHub이 해당 잡을 러너에 할당합니다. 라벨이 일치하므로 잡이 바로 실행됩니다.
- 잡이 끝나면 러너는 —ephemeral 옵션 덕분에 자동으로 종료됩니다.
- “scale‑down” Lambda가 정해진 스케줄에 실행돼, 이미 등록 해제된 러너가 남긴 인스턴스를 찾아 종료합니다.
Lambda 코드, SQS 큐, IAM 연결 등은 모두 러너 모듈 안에 포함돼 있어 사용자가 직접 작성할 필요가 없습니다. 사용자는 Terraform 설정만 작성하면 됩니다 – 어떤 러너가 존재하고, 어떤 AMI를 쓰며, 어떤 인스턴스 타입이 허용되는지 선언합니다.
📌 IMAGE TODO (architecture): 왼쪽에서 오른쪽으로 흐르는 선형 다이어그램 – GitHub 웹훅 → API Gateway + Lambda → SQS → Scale‑up Lambda → EC2 스팟 인스턴스 (Packer AMI) → 잡 실행 → 인스턴스 종료. “scale‑down Lambda가 감시하고 정리한다”는 점선 루프가 포함된 손그림 스타일.
라벨 기반 티어링
러너를 라벨에 따라 티어별로 구분하면 비용 효율을 크게 높일 수 있습니다. 워크플로우에서는 runs-on:에 원하는 티어 라벨을 지정하면 됩니다.
제가 만든 티어는 대략 세 가지였습니다:
| 티어 | 인스턴스 종류 | 용도 |
|---|---|---|
| Default (small) | t3.medium / m5.large 수준 | 가벼운 작업 – 린터, 포매터, 문서 빌드 등 CPU 부하가 적은 경우. 스팟 가용성이 높고 빠르게 스폰됩니다. |
| Large | m5.xlarge / c5.xlarge 수준 | 일반적인 빌드/테스트 워크플로우 – 어느 정도 CPU가 필요하지만 과도하게 요구하지는 않음. |
| Compute‑intensive | c7a.4xlarge / c8a.8xlarge 수준 | 다코어 활용이 큰 작업 – 컴파일 중심 빌드, 방대한 테스트 스위트 등 병렬성을 크게 활용하는 경우. |
각 티어는 Terraform에서 같은 모듈을 여러 번 호출하면서 라벨과 인스턴스 타입 리스트만 다르게 지정합니다. 아래는 정리된 예시(민감 정보는 삭제)입니다:
module "github-runners" {
source = "github-aws-runners/github-runner/aws//modules/multi-runner"
version = "~> 6.0"
multi_runner_config = {
"linux-x64-small" = {
runner_config = {
runner_extra_labels = "linux,x64,small"
instance_types = local.default_instances
ami_filter = { name = ["*ci-runner-x64*"] }
enable_ephemeral_runners = true
enable_spot_instances = true
}
}
"linux-x64-compute-intensive" = {
runner_config = {
runner_extra_labels = "linux,x64,compute-intensive"
instance_types = local.compute_intensive
ami_filter = { name = ["*ci-runner-x64*"] }
enable_ephemeral_runners = true
enable_spot_instances = true
}
}
# ...다른 티어도 동일하게 정의
}
# 공통 설정: 웹훅 시크릿, GitHub 앱 인증, VPC 설정 등
github_app = { ... }
webhook_secret = random_id.webhook_secret.hex
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
워크플로우 파일에서 티어를 선택하는 방법