GitHub-hosted runner 비용이 슬슬 부담스러워서 self-hosted로 갈아탈 때, 요즘은 거의 ARC(Actions Runner Controller) + Karpenter 조합이 정석처럼 굳어가는 분위기다. 우리 팀도 작년 말부터 이 구성으로 전환했고, 그 과정에서 정리해둔 내용을 가이드로 풀어본다.
작년 6월에 ARC 0.12가 나오면서 ephemeral runner 설치가 큐잉되고 실패 시 5번까지 재시도하는 식으로 바뀌었다. 노드 스케일 다운 도중에 러너 파드가 휘말려 죽는 케이스에서 체감이 꽤 좋아졌다. 이 글은 0.12 이상 기준이다.
왜 Karpenter랑 묶나
처음엔 Cluster Autoscaler로도 충분하지 않을까 했었다. 결론부터 말하면, CI 워크로드 특성상 Karpenter가 훨씬 잘 맞는다. 작업이 산발적으로 들어오고, 한번에 수십 개씩 큐에 쌓였다가 끝나면 다 사라지기 때문이다. ASG 기반 CA는 이런 패턴에서 노드 풀을 미리 정의해둬야 하고, 인스턴스 타입 결정도 굼뜨다. Karpenter는 파드 스펙 보고 그때그때 적합한 인스턴스를 띄우다.
특히 CI 러너는 메모리/CPU/디스크 요구가 작업마다 천차만별이라, NodePool 하나에 인스턴스 카테고리를 넓게 열어두는 게 효율이 좋다. 솔직히 EKS Auto Mode 쓰면 이거 다 알아서 해주긴 하는데, 운영 가시성 때문에 Karpenter 직접 굴리는 팀이 아직 많을 거다.
NodePool 분리 — 이게 제일 중요하다
CI 러너용 NodePool은 일반 워크로드랑 반드시 분리한다. 이유가 몇 가지 있다.
첫째, CI 작업은 디스크 IO가 험하다. docker build, npm ci, 테스트 컨테이너 띄우기 같은 게 한 노드에서 동시에 돌면 일반 서비스 파드까지 같이 느려진다. 둘째, 작업 끝나면 노드를 적극적으로 비워야 비용이 떨어지는데, 일반 워크로드 NodePool에서 이 정책을 적용하면 다른 파드까지 휘말린다. 셋째, 스팟 인스턴스를 쓸 때 CI는 중단 허용이 되지만 일반 서비스는 그렇지 않다.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: ci-runners
spec:
template:
metadata:
labels:
workload: ci
spec:
taints:
- key: workload
value: ci
effect: NoSchedule
requirements:
- key: kubernetes.io/arch
operator: In
values: [amd64]
- key: karpenter.sh/capacity-type
operator: In
values: [spot, on-demand]
- key: karpenter.k8s.aws/instance-category
operator: In
values: [m, c]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["6"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: ci-runners
expireAfter: 2h
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 30s
budgets:
- nodes: "100%"
여기서 두 가지 포인트. consolidationPolicy: WhenEmpty로 둬야 ephemeral runner가 작업 중에 강제로 옮겨지는 사고를 막는다. WhenEmptyOrUnderutilized는 끌리지만 러너에는 안 맞다. 그리고 expireAfter: 2h는 노드를 주기적으로 갈아치우는 안전장치다. CI 노드는 빌드 캐시, 임시 컨테이너, 누수된 프로세스 같은 게 쌓이기 쉽다. 2시간이면 너무 짧지 않냐고 할 수 있는데, 어차피 작업이 끝나면 비기 때문에 실제로 만료되는 경우는 드물다.
EBS — 17GiB로는 절대 안 된다
기본 EC2NodeClass의 루트 볼륨이 20GiB짜리인데, CI 러너에선 이걸로는 어림도 없다. 한 작업이 도커 이미지 두세 개 받고 빌드 컨텍스트 풀고 의존성 설치하면 금방 80% 넘긴다. 우리는 200GiB로 잡았다.
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: ci-runners
spec:
amiSelectorTerms:
- alias: al2023@latest
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 200Gi
volumeType: gp3
iops: 4000
throughput: 250
deleteOnTermination: true
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "your-cluster"
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "your-cluster"
gp3 IOPS는 4000 정도면 충분하더라. 더 높이면 비용이 가파르게 올라간다. 한번 IO bound 의심되는 작업이 있어 8000까지 올려봤는데, 빌드 시간 차이가 5%도 안 났다. 대신 디스크 사이즈 자체를 늘리는 게 캐시 hit 측면에서 훨씬 도움됐다.
AutoscalingRunnerSet 설정
ARC 쪽에서는 gha-runner-scale-set 차트로 러너 셋을 띄운다. 여기서 자주 놓치는 게 runnerScaleSetName은 GitHub 레포에서 라벨로 사용되는 이름이고, Helm release 이름과 일치할 필요는 없다는 점이다.
# values.yaml
githubConfigUrl: https://github.com/your-org/your-repo
githubConfigSecret: arc-runner-token
runnerScaleSetName: ci-large
minRunners: 0
maxRunners: 60
template:
spec:
tolerations:
- key: workload
operator: Equal
value: ci
effect: NoSchedule
nodeSelector:
workload: ci
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
command: ["/home/runner/run.sh"]
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
memory: "8Gi"
minRunners: 0을 권장한다. idle 러너 둬봤자 라이선스 비용은 안 들지만 EC2가 계속 떠 있다. 작업이 들어오면 Karpenter가 30~60초 안에 노드를 띄우니까, 평균 큐잉 시간을 모니터링해서 정말 짧게 만들어야 할 큐만 따로 minRunners를 줘도 늦지 않다.
resource request는 너무 빡빡하게 잡지 말 것. CPU 2 / Memory 4Gi 정도가 일반적인 빌드 작업에 무난하다. 8Gi limit을 둔 건 OOMKill을 방지하기 위함인데, 사실 많은 팀이 limit 자체를 빼버리기도 한다. 우리는 메모리 누수가 있는 작업이 가끔 있어서 안전장치로 둔다.
시크릿과 토큰
GitHub App 인증을 강력히 권장한다. PAT는 발급한 사람이 퇴사하거나 권한이 바뀌면 한 번에 다 죽는다. 작년에 우리도 그렇게 한번 새벽에 호출받은 적이 있다.
kubectl create secret generic arc-runner-token \
--namespace arc-systems \
--from-literal=github_app_id=123456 \
--from-literal=github_app_installation_id=78901234 \
--from-file=github_app_private_key=app-private-key.pem
External Secrets Operator로 Vault나 AWS Secrets Manager에서 동기화시키는 게 다음 단계다. 이건 다음에 별도로 다뤄볼 만한 주제다.
한 가지 함정 — registration timeout
ARC를 처음 올리고 나면 가끔 러너가 GitHub에 등록되지 못하고 파드가 CrashLoopBackOff에 빠진다. 로그 보면 Runner registration failed: TimeoutError 같은 게 뜬다. 이게 EKS 클러스터에서 GitHub API로 나가는 egress 경로 문제인 경우가 90%다.
확인할 것: NAT Gateway 라우팅이 정상인지, VPC endpoint를 강제로 쓰고 있다면 GitHub API는 인터넷으로 나가야 하니까 라우팅 테이블이 잘 분리돼 있는지, 마지막으로 보안 그룹에서 443 outbound가 허용돼 있는지. 세 가지 체크하면 거의 다 잡힌다.
끝으로
이 구성으로 우리 팀은 한 달 CI 비용을 GitHub-hosted 대비 60% 정도 줄였다. 다만 운영 부담이 늘어난 건 사실이다. ARC controller 자체 OOM, Karpenter NodePool 튜닝, 러너 이미지 보안 패치 — 매달 한두 번씩은 손이 간다. 비용 절감 vs 운영 부담의 트레이드오프인데, 빌드 분량이 어느 선을 넘으면 self-hosted가 명확히 이긴다.
다음에는 러너 컨테이너 이미지 자체를 슬림하게 만드는 이야기도 정리해볼까 한다. 기본 이미지가 의외로 무거워서 cold start에서 손해를 본다.
'IT > CI CD' 카테고리의 다른 글
| ArgoCD ApplicationSet PR Generator로 PR별 preview 환경 만들기 (0) | 2026.05.05 |
|---|---|
| GitHub Actions의 concurrency, 배포 race 막는 한 줄 (1) | 2026.04.29 |
| ARC ephemeral runner로 갈아탔다가 새벽에 깬 이야기 (0) | 2026.04.27 |
| ArgoCD ApplicationSet matrix generator로 N×M 배포를 정리하는 법 (0) | 2026.04.26 |
| ArgoCD ApplicationSet으로 멀티 클러스터 GitOps 자동화하기 (0) | 2026.04.25 |