Actions Runner Controller 스케일 세트로 셀프호스트 러너 오토스케일 세팅하는 법
셀프호스트 러너를 k8s 위에 올리려는 팀이라면 이제 사실상 ARC(Actions Runner Controller) 하나로 통일된 분위기다. 우리 팀도 작년까지는 예전 RunnerDeployment + HorizontalRunnerAutoscaler 구조를 유지하고 있었는데, 올해 초에 스케일 세트(gha-runner-scale-set) 방식으로 갈아엎었다. 문서는 있는데 첫 세팅에서 은근히 헤매는 포인트가 있어서 정리해 둔다.
스케일 세트 방식이 뭐가 다르냐면, 예전처럼 러너 파드가 폴링을 하지도 않고, HPA가 GitHub API를 긁어서 스케일 결정을 내리지도 않는다. 리스너 파드 하나가 GitHub Actions 서비스와 long-poll 커넥션을 물고 있다가 "이 라벨로 잡 X개 왔다"고 알려주면 컨트롤러가 그만큼 파드를 만들어 주는 구조다. 이게 훨씬 반응이 빠르고 API rate limit도 안 씹는다.
컨트롤러랑 스케일 세트, 두 개의 Helm 차트
여기서 첫 번째 함정. ARC는 차트가 두 개다. 컨트롤러 차트(gha-runner-scale-set-controller) 하나 깔고, 러너 스케일 세트 차트(gha-runner-scale-set)를 팀/리포별로 여러 개 깐다. 예전 actions-runner-controller 차트랑 이름이 헷갈리니 조심.
# 컨트롤러 - 클러스터당 하나
helm upgrade --install arc \
--namespace arc-systems --create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
# 러너 스케일 세트 - 팀/리포별로 반복
helm upgrade --install arc-runner-set \
--namespace arc-runners --create-namespace \
--set githubConfigUrl="https://github.com/my-org/my-repo" \
--set githubConfigSecret=arc-runner-secret \
--set minRunners=1 \
--set maxRunners=20 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
Helm 릴리스 이름이 곧 러너 라벨이 된다. 위처럼 깔면 워크플로에서 runs-on: arc-runner-set으로 쓴다. 이거 몰라서 "왜 라벨이 안 잡히지" 하고 30분 날린 적 있다.
GitHub 인증 — PAT vs GitHub App
시크릿은 두 방식 중 하나. PAT은 세팅이 간단해서 PoC엔 좋은데, 조직 단위로 붙이려면 애초에 GitHub App으로 시작하는 게 맞다. PAT은 만든 사람이 회사에서 나가면 러너가 통째로 죽는다. 실화다.
kubectl create secret generic arc-runner-secret \
--namespace arc-runners \
--from-literal=github_app_id=123456 \
--from-literal=github_app_installation_id=12345678 \
--from-file=github_app_private_key=./app-key.pem
App으로 붙이면 rate limit도 5000/hr에서 훨씬 여유 있어진다. 스케일 세트 자체는 REST API를 거의 안 쓰지만, 사이드에 붙이는 CI 툴들이 종종 같은 토큰을 재활용하기 때문에 여유는 있는 게 낫다.
minRunners를 무작정 0으로 두지 말 것
문서만 보면 minRunners: 0이 이상적으로 보인다. 잡 없을 때 파드도 없고, 클러스터 리소스 안 먹고. 근데 실제로 돌려보면 콜드 스타트가 은근 눈에 띈다. 컨테이너 이미지가 크기라도 하면(우리는 커스텀 이미지가 3GB 넘는다) 첫 잡이 시작되기까지 40~60초씩 걸린다. PR 하나 올렸는데 러너 뜨는 데만 1분이면 개발자들이 뭔가 이상하다고 느낀다.
우리 팀은 이렇게 조율했다.
# values.yaml
minRunners: 2 # 항상 웜 유지
maxRunners: 30
runnerScaleSetName: "arc-linux-x64"
template:
spec:
containers:
- name: runner
image: ghcr.io/my-org/actions-runner:2.319.1-custom
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "4"
memory: "8Gi"
minRunners: 2는 평일 낮 시간 기준이고, 새벽엔 스케줄로 0으로 낮췄다가 아침에 다시 올린다. 이걸 컨트롤러 자체가 해주진 않아서 CronJob으로 Helm value patch하는 식으로 돌린다. 좀 조잡한데 잘 굴러간다.
Docker-in-Docker가 필요하다면
이게 아마 가장 골치 아픈 부분. 러너에서 docker build 돌리는 워크플로가 있다면 몇 가지 선택지가 있다.
첫 번째는 dind 사이드카. 공식 문서에도 나오는데 privileged 컨테이너를 써야 하고, 클러스터 정책에 따라 아예 못 쓰는 경우가 많다. 우리도 PSA restricted 프로파일 걸어놔서 안 됐다.
두 번째가 우리가 최근에 넘어간 방법인데, Kaniko나 BuildKit rootless를 러너 이미지 안에 넣어두고 워크플로에서 docker build 대신 이걸 쓰게 하는 것. 워크플로 손대야 하는 게 단점인데, privileged를 안 써도 된다는 게 크다. docker/build-push-action이 최근 버전에서 buildkitd endpoint 지정을 잘 받아주기 때문에 팀 워크플로에서 딱 한 줄만 바꾸면 됐다.
리스너 파드가 죽으면 어떻게 되는가
이건 문서에 잘 안 나와 있어서 실제로 켜서 확인해 봤다. 리스너 파드가 죽으면 이미 실행 중인 러너 파드는 죽지 않는다. 잡은 그대로 끝까지 돌아간다. 다만 새 잡은 안 잡히다가 리스너가 다시 뜨는 순간 밀린 큐를 한꺼번에 훑어서 확 스케일업한다.
리스너는 컨트롤러 네임스페이스에 하나만 뜨는데, 여기 리소스 요청은 정말 작게 잡아도 된다(50m CPU, 128Mi memory 정도로 충분). 대신 PDB는 걸어두는 걸 추천. 노드 드레인할 때 리스너가 재기동되면서 위에서 말한 "밀린 큐 몰아치기"가 발생하는데, 이게 오프피크에는 상관없지만 러시아워에는 별로 반갑지 않다.
러너 이미지는 팀에서 관리하자
기본 이미지(ghcr.io/actions/actions-runner)는 정말 최소 구성이다. Node.js 하나 없다. 실제 워크플로 돌리려면 각 잡마다 actions/setup-node 같은 걸로 툴체인을 매번 받는데, 이게 시간이 은근 걸린다.
우리는 팀에서 자주 쓰는 툴체인(Node 20/22, Python 3.11/3.12, JDK 21, terraform, kubectl, helm, aws-cli)을 미리 넣은 커스텀 이미지를 만들어서 쓴다. 이미지 크기는 커지지만(3GB 정도), 노드에서 한 번 pull하면 그 노드에 뜨는 모든 러너가 재활용하니까 실질적으론 이득이다. 이미지 pull을 아예 안 하려면 imagePullPolicy: IfNotPresent도 잊지 말자.
한 가지 더, runs-on: 라벨을 툴체인별로 나누고 싶어질 텐데 스케일 세트 자체가 라벨 하나만 잡아주기 때문에 여러 이미지가 필요하면 스케일 세트 자체를 여러 개 배포해야 한다. 우리는 arc-linux-x64, arc-linux-arm64, arc-gpu 이렇게 셋으로 나눠 놨다.
관측
리스너 파드가 Prometheus 메트릭을 노출하므로 이건 꼭 스크래핑하자. 대충 이런 것들이 나온다.
gha_assigned_jobs— 현재 잡 개수gha_running_jobs— 러너에 붙어서 도는 잡gha_registered_runners— 현재 등록된 러너 파드 수gha_desired_runners— 원하는 러너 수
assigned_jobs랑 running_jobs 차이가 오래 유지되면 러너가 부족하다는 뜻이고, desired랑 registered 차이가 벌어지면 파드 스케줄링이 지연되고 있다는 뜻이다. 알람은 이 두 갭에 걸어놓으면 웬만한 사고는 걸린다.
돌려놓고 두 달쯤 지났는데 예전 구조보다 훨씬 안정적이다. 특히 API rate limit 이슈로 잡이 몇 분씩 밀리던 게 완전히 사라진 게 컸다. 아직 arm64 러너 쪽은 조금 튜닝 중이고, 노드 오토스케일링(Karpenter)이랑 잘 맞물리게 하는 게 그다음 숙제다. 혹시 minRunners를 어떻게 잡고 계신지, 콜드스타트 대응은 어떻게 하시는지 궁금하다.