ECS Fargate GitHub Actions runner 설정 — 비싸다는 통념을 다시 보다

목차

ECS Fargate GitHub Actions runner 설정은 self-hosted CI/CD를 유휴 비용 없이 돌리고 싶은 팀이 가장 먼저 검토하는 조합 중 하나다. ECS Fargate는 컨테이너 단위로 vCPU/메모리를 초 단위 과금하는 서버리스 컨테이너 런타임이고, GitHub Actions self-hosted runner는 워크플로 작업을 외부 인프라에서 실행하게 해주는 에이전트다. 둘을 ephemeral 모드로 묶으면 작업이 들어올 때만 컨테이너가 뜨고, 끝나면 흔적 없이 사라진다.

그런데 커뮤니티에서 가장 자주 보이는 조언은 EC2 + AutoScaling Group이다. "Fargate는 비싸니까 CI에 쓰면 손해다"라는 말이 거의 정설처럼 돈다. 실제로 그런가? ephemeral 패턴으로 돌려보면 그 결론이 잘 안 맞는 구간이 있다.

"Fargate는 비싸다"는 통념의 출처

그러나, Fargate 시간당 단가만 보면 동급 EC2보다 비싼 게 사실이다. 2026년 5월 기준 us-east-1 on-demand 가격은 vCPU당 시간 $0.04048, GB당 시간 $0.004445다(출처: AWS Fargate 공식 가격 페이지). 2 vCPU + 4GB 컨테이너를 한 시간 돌리면 약 $0.099다. 같은 사양의 t3.medium은 시간당 $0.0416. 단순 곱하기로는 두 배 차이가 난다.

물론, 이 숫자가 "Fargate = 비싸다"의 출처다. 대부분의 비교 글이 여기서 멈춘다. reddit r/aws나 한국 기술 블로그를 검색하면 "CI는 EC2 ASG가 정답"이라는 결론이 압도적으로 많이 보인다.

다만 시간당 단가 비교는 워크로드가 24시간 풀로 돈다는 가정에서만 유효하다. self-hosted runner는 그런 워크로드가 아니다. 잡이 없을 땐 0초 실행이고, 잡이 있을 땐 5분짜리도 있고 30분짜리도 있다. 평균 utilization이 5~15% 사이에서 노는 게 일반적이다. 이 구간에서 시간당 단가 비교는 거의 의미가 없다.

ephemeral runner 구조가 통념을 뒤집는 지점

GitHub Actions self-hosted runner는 v2.300 이상에서 --ephemeral 플래그를 지원한다(출처: actions/runner Release Notes v2.300.0, 2023). 이 모드로 등록하면 runner는 잡 1개를 실행하고 자기 자신을 deregister한 뒤 종료된다. 일회용이다. 컨테이너 상태가 다음 잡에 영향을 주지 않고, 보안 격리도 자연스럽게 잡 단위로 끊긴다.

반면, ECS Fargate와 이 패턴이 잘 맞는 이유는 세 가지다.

유휴 시간이 0에 수렴한다

EC2 ASG로 runner를 운영하면, ASG 스케일 정책은 결국 "최소 1대는 살려둬야 첫 잡을 빠르게 받는다"는 절충에 도달한다. 잡이 없어도 idle 인스턴스 비용이 24시간 누적된다. t3.medium 1대 24시간이면 월 $30 수준이다. 잡이 많을수록 더 띄워야 하니 누적은 더 커진다. warm pool로 줄여보려고 해도 한계가 있다.

따라서, Fargate는 RunTask 호출 시점에 컨테이너가 생성되고, 잡이 끝나면 즉시 소멸한다. 잡 실행 시간만 과금된다. 100 빌드/일 × 5분이면 일평균 8.3시간의 compute다. 2 vCPU + 4GB 기준 약 $0.82/일, 월 $25 수준이다(공식 단가 기준 단순 계산). 비교 가능한 EC2 ASG의 유휴 청구분이 통째로 사라진다.

Spot 가격 적용이 단순하다

Fargate Spot은 capacity provider 설정 한 줄로 켠다. on-demand 대비 약 70% 할인된다(출처: AWS Fargate Spot 공식 페이지, 2026-05 기준). CI 잡은 중단되어도 워크플로 retry로 다시 돌리면 그만이라 spot 친화적인 워크로드다. EC2 spot은 인스턴스 종료 신호를 받아서 graceful하게 빠지는 코드를 작성해야 하고, 잡 실행 중 종료되면 그 잡은 그냥 죽는다. 운영 코드를 따로 짜야 한다. Fargate Spot은 그런 코드가 필요 없다. ECS가 알아서 task를 종료시키고, webhook이 재진입하면 새 task가 뜬다.

스케일 정책이 사라진다

EC2 ASG는 CPU/메모리/큐 길이 기반 스케일 정책을 사람이 튜닝해야 한다. 너무 공격적이면 잡이 끝나기 전에 인스턴스가 사라지고, 너무 보수적이면 idle 비용이 늘어난다. ECS Fargate + ephemeral 패턴에서는 이 정책 자체가 필요 없다. webhook이 들어올 때마다 RunTask 1번, 끝나면 자동 종료. 운영자가 튜닝할 표면이 줄어든다.

Task Definition 작성에서 빠뜨리기 쉬운 항목

예를 들어, ECS Task Definition은 처음 작성하면 의외로 손이 많이 가는 구간이다. 공식 문서의 기본 예제는 자체 컨테이너 띄우기까지만 다루고, GitHub Actions runner 같은 특수 워크로드 설정은 별도 가이드가 부족하다.

결국, 핵심 필드의 골격은 이렇다.

{
  "family": "gha-runner",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "2048",
  "memory": "4096",
  "executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::ACCOUNT_ID:role/gha-runner-task-role",
  "containerDefinitions": [
    {
      "name": "runner",
      "image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/gha-runner:latest",
      "essential": true,
      "environment": [
        {"name": "RUNNER_SCOPE", "value": "repo"},
        {"name": "REPO_URL", "value": "https://github.com/org/repo"},
        {"name": "EPHEMERAL", "value": "true"}
      ],
      "secrets": [
        {"name": "RUNNER_TOKEN", "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:gha-runner-token"}
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/gha-runner",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "runner",
          "awslogs-create-group": "true"
        }
      }
    }
  ]
}

반면, 여기서 처음 만들면 자주 막히는 부분이 네 곳이다.

executionRoleArn과 taskRoleArn 혼동

executionRoleArn은 ECS 에이전트가 ECR 이미지를 pull하고 Secrets Manager에서 값을 끌어오고 CloudWatch에 로그를 쓰는 권한이다. 즉 "task를 띄우기 위한 권한"이다. taskRoleArn은 컨테이너 안에서 실행되는 프로세스가 AWS API를 호출할 때 쓰는 권한이다. 즉 "task가 일하기 위한 권한"이다.

따라서, 두 개를 같은 역할로 묶으면 권한 경계가 무너진다. ECR readonly만 있어도 되는 자리에 S3 fullaccess가 붙어 있는 식의 사고가 생긴다. 분리해야 한다.

특히 GitHub Actions 워크플로가 ECR push, S3 업로드, Parameter Store 조회 같은 작업을 한다면 taskRoleArn에 그 권한을 붙여 두면 된다. workflow yaml에 access key를 박지 않아도 IAM role chain으로 풀린다. 이건 self-hosted runner의 큰 장점이기도 하다.

네트워킹과 VPC endpoint

특히, Fargate는 networkMode가 awsvpc 외엔 안 된다. public subnet에 띄우면 NAT Gateway 없이도 인터넷 접근이 되지만, 보안적으로 권장되지 않는다. private subnet + NAT Gateway가 표준이고, NAT 트래픽 비용이 다시 청구서에 추가된다.

이처럼, 이 비용을 줄이려면 ECR, Secrets Manager, CloudWatch Logs용 VPC Interface Endpoint를 깔아야 한다. Endpoint당 시간 $0.01 + 데이터 처리 비용이 붙지만, 잡이 많은 환경에서는 NAT보다 저렴해진다. 손익분기점은 일 100건 정도부터다(공식 단가 기준 대략적 환산).

Fargate 플랫폼 버전과 ARM 지원

물론, Fargate 1.4.0부터 ARM64(Graviton) 컨테이너가 지원된다(출처: AWS What’s New, 2021-11). runner 이미지를 multi-arch로 빌드하면 동일 단가에 ARM 인스턴스를 쓸 수 있다. 2026년 5월 기준 Graviton Fargate는 x86 대비 약 20% 저렴하다. 단, 일부 third-party GitHub Action이 x86 바이너리만 제공하면 호환성 문제가 생긴다. 사전에 워크플로의 actions/uses 항목들을 점검해야 한다.

로그가 안 찍히는 흔한 원인

awslogs-group이 사전에 생성되어 있지 않으면 task가 시작은 되는데 로그가 안 보인다. ECS는 로그 그룹을 자동 생성하지 않는 게 기본 동작이다. 위 예시에 넣은 awslogs-create-group: "true" 옵션을 명시하거나, CloudWatch Logs에 그룹을 미리 만들어야 한다. 첫 배포에서 거의 한 번씩 헤매는 부분이다.

GitHub Webhook과 ECS RunTask 연결

여기가 전체 아키텍처에서 가장 변형이 많은 구간이다. 정답이 없다. 자주 보이는 패턴 세 가지를 정리하면 이렇다.

첫 번째는 API Gateway + Lambda 방식이다. GitHub repository webhook을 workflow_job 이벤트로 등록하고, queued 상태일 때 Lambda가 ecs:RunTask를 호출한다. 가장 가볍지만 Lambda cold start와 RunTask latency가 누적되면 큰 잡에서는 30초 가까이 대기 시간이 생긴다. 잡이 1분짜리면 이 30초가 전체 시간의 절반이다.

한편, 두 번째는 EventBridge + Step Functions다. Lambda보다 retry 정책과 동시성 제어가 명확하고, 실패 시 SNS 알림 같은 분기를 손쉽게 붙인다. 설정 복잡도는 올라간다.

실제로, 세 번째는 오픈소스 솔루션을 쓰는 방법이다. philips-labs/terraform-aws-github-runner가 가장 유명하다. EC2 기반이 메인이지만 v5.x부터 ECS Fargate 모드가 포함된다(출처: GitHub philips-labs/terraform-aws-github-runner Release v5.0.0). Terraform 모듈 하나로 webhook, Lambda, ECS cluster, IAM 권한까지 한 번에 깐다. 자체 구현하기 전에 이쪽을 먼저 검토하는 게 합리적이다.

JIT 토큰으로 보안 정리

또한, runner registration token은 1시간짜리 임시 토큰이지만, 컨테이너에 주입해서 잡을 받기까지 노출 표면이 있다. 컨테이너가 탈취되면 그 시간 동안 새 runner를 등록할 수 있다.

JIT(Just-in-Time) runner는 잡 1개 단위로 발급되고 한 번만 사용된다. GitHub REST API POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig로 발급하고, 발급 시점에 이미 잡이 매칭되어 있다(출처: GitHub REST API docs, generate-jit-config endpoint). 컨테이너가 이 토큰을 받으면 다른 잡으로 새치기할 수 없다. ephemeral 패턴과 가장 궁합이 좋다.

자체 구현할 거면 처음부터 JIT 방식으로 가는 게 좋다. 일반 registration token으로 만들었다가 나중에 옮기려면 webhook 핸들러부터 컨테이너 entrypoint까지 다 고쳐야 한다.

scale-to-zero에서 생기는 race condition

ephemeral runner는 잡이 끝나면 deregister된다. 그런데 GitHub은 runner pool에 등록된 runner를 보고 잡을 dispatch한다. 한 순간에 runner가 0개가 되는 구간이 생기면, 그 직후 들어온 잡은 "available runner 없음"으로 대기 큐에 들어간다. webhook이 늦게 도착하면 잡이 몇 초간 정체된다.

이걸 막으려면 webhook 큐를 SQS로 받고, 동시성 제어를 ECS service desired count가 아니라 RunTask 호출 측에서 처리해야 한다. 큐 길이를 metric으로 띄워서 모니터링하는 게 좋다. 작은 팀이면 이 정도 race는 무시해도 큰 문제 없지만, 빌드 SLA가 있는 조직에서는 의외로 신경 쓰이는 지점이다.

실제 비용 — EC2 ASG와 Fargate 직접 비교

그래서, 월 잡 수, 평균 실행 시간, idle 정책에 따라 결과가 갈린다. 자주 쓰이는 시나리오 3개로 비교하면 이렇다.

시나리오 월 잡 수 평균 시간 EC2 ASG (t3.medium 1대 상시) Fargate (2vCPU/4GB on-demand) Fargate Spot
소규모 500 5분 $30 + 운영 인건비 약 $4 약 $1.2
중간 3,000 7분 $30 + 스케일 추가 약 $35 약 $11
대규모 10,000 10분 $90+ (3~5대 운영) 약 $165 약 $50

그런데, 수치는 us-east-1 공식 단가 기반 단순 계산이다. 데이터 전송, CloudWatch Logs, ECR storage, NAT Gateway는 제외했다. 실제 청구서는 이 값에 약 10~15% 더 붙는다고 보면 된다.

한편, 소규모 팀은 Fargate Spot이 압도적으로 유리하다. 중간 규모도 spot이면 EC2 ASG보다 저렴하다. 잡 수가 매우 많은 대규모에서만 EC2 ASG + reserved instance + savings plans 조합이 다시 우위를 보인다.

결국, 여기서 통념이 무너지는 지점이 보인다. "Fargate는 비싸다"는 명제는 "잡 수가 매우 많고 24시간 풀로 돌리는 워크로드"일 때만 참이다. 대부분의 팀은 그 구간에 있지 않다. 통념이 잘못된 게 아니라, 통념의 적용 범위가 좁은 것이다.

운영하며 부딪힌 한계

비용은 줄었지만 무료 점심은 아니었다. 운영하면서 만나는 한계 몇 가지를 적는다.

Docker layer 캐시가 휘발성이다. Fargate task는 끝나면 사라지니까 다음 실행에서 캐시가 비어 있다. docker/build-push-actioncache-from: type=gha로 GitHub Actions 캐시를 쓰거나, ECR을 캐시 백엔드로 쓰는 패턴이 필요하다. 캐시 전략을 안 짜면 빌드 시간이 두 배로 늘고, Fargate 과금도 같이 늘어난다. 비용 이점이 절반 정도 깎이는 사례도 있다.

큰 모노레포에서 cold start 시간이 누적된다. Fargate task 시작은 이미지 크기에 따라 평균 30~60초다. 잡이 짧으면 이 시간 비중이 너무 크다. 빌드가 1분짜리면 cold start가 본 작업 시간만큼 차지한다. EC2 ASG의 warm pool 같은 메커니즘이 Fargate에는 없다. 워크플로를 합치거나, 작은 잡들은 GitHub-hosted runner로 남기고 무거운 잡만 Fargate로 보내는 식의 절충이 흔하다.

그런데, 큰 파일 캐시(node_modules, .gradle, .m2 등)는 매번 EFS 마운트나 S3 sync로 끌어와야 한다. ECS Fargate는 EFS를 지원하지만 첫 mount latency가 추가되고, EFS Standard 비용도 별도로 청구된다. 캐시 양이 많을수록 cold start 체감이 커진다. 캐시가 정말 큰 경우엔 EC2 ASG의 로컬 EBS가 더 유리할 수 있다.

GPU가 필요한 워크로드는 안 된다. Fargate는 GPU를 지원하지 않는다(2026년 5월 기준). ML 모델 학습이나 CUDA 빌드, 그래픽 처리가 들어가면 EC2 ASG로 별도 runner pool을 운영해야 한다. 이 경우 두 종류의 runner를 같이 굴리는 하이브리드 구조가 된다.

물론, 이미지 크기 자체가 cold start의 거의 모든 비중을 차지한다. runner 이미지가 2GB를 넘기면 시작 시간이 1분 가까이 늘어난다. base image를 alpine으로 가져가거나, 잡에 필요한 도구를 워크플로 step에서 lazy install하는 식의 최적화가 필요하다. 처음엔 "ubuntu-latest에 다 박아 넣자"고 시작했다가 cold start에 발목 잡혀서 이미지 다이어트를 다시 하는 흐름이 흔하다.

도입을 고려한다면 당장 할 수 있는 것

즉, 지금 EC2 ASG로 runner를 돌리고 있고 청구서가 부담스럽다면, 또는 GitHub-hosted runner 사용량이 free tier를 넘어가고 있다면 아래 순서로 점검해 볼 만하다.

  1. workflow_job 웹훅 이벤트에서 실제 잡 빈도와 평균 실행 시간을 1~2주치 수집한다. 위 비용표에서 어느 시나리오에 가까운지부터 확인한다. 데이터 없이 마이그레이션하면 손익이 안 보인다.
  2. philips-labs/terraform-aws-github-runner v5.x를 staging 환경에 ECS Fargate Spot 모드로 띄워본다. 자체 구현보다 검증 속도가 빠르고, 후에 마음에 안 들면 떼어내기도 깔끔하다.
  3. 캐시 전략을 먼저 설계하고 들어간다. Docker layer 캐시, dependency 캐시, 빌드 산출물 캐시를 어디에 둘지 결정한 다음 마이그레이션한다. 캐시 없이 옮기면 비용 절감이 거의 안 보인다.

다만 cold start latency와 캐시 휘발성은 ECS Fargate 구조 자체의 한계라 완전한 해결은 어려운 지점이라, 워크로드 특성에 따라 EC2 ASG가 여전히 합리적인 경우가 남는다는 점은 더 지켜봐야 한다.

관련 글