ARC ephemeral runner로 갈아탔다가 새벽에 깬 이야기
지난주 화요일 새벽 2시쯤, 슬랙에 멘션이 떴다. "프론트엔드 빌드 파이프라인이 30분 넘게 안 끝나는데요?" 평소 7~8분이면 끝나던 잡이었다. 멘탈이 한 번 흔들렸고, 노트북을 열었다. 이게 이번 글 주제다.
배경부터 짧게 말하면, 우리 팀은 지난달에 GitHub-hosted runner에서 ARC(Actions Runner Controller) 기반 self-hosted runner로 전환했다. 비용이랑 사내망 접근 때문에 어차피 가야 할 길이었고, 동료가 헬름 차트로 깔끔하게 셋업해놨다. ARC의 기본 모드인 ephemeral runner를 그대로 썼다. 잡 하나 끝나면 파드는 폐기되고 새 파드가 뜬다. 보안적으로도 깔끔하니 의심할 이유가 없었다.
근데 그게 함정이었다.
처음에 의심한 것들
새벽 2시에 잡힌 첫 가설은 노드 스케줄링이었다. ARC가 잡을 받자마자 새 러너 파드를 띄우는데, 클러스터 캐파가 부족해서 Pending에 걸려있나 싶었다. 근데 kubectl get pods -n arc-runners로 보니 다들 Running이었다. 잡 자체도 시작은 잘 됐다. 시간을 잡아먹는 구간은 정확히 npm ci였다. 평소 1분 걸리던 게 14분을 넘기고 있었다.
두 번째 가설은 사내 npm 미러 장애. Verdaccio 쪽 메트릭 봤더니 멀쩡했다. P99 응답시간 70ms대. 정상.
세 번째 가설에 와서야 깨달았다. actions/cache가 한 번도 hit하지 않고 있었다. 모든 잡이 cache miss로 처음부터 의존성을 받고 있던 것이다.
왜 캐시가 안 먹었을까
상황을 정리해보자. ephemeral runner의 정의는 명확하다. 잡 하나 끝나면 파드는 디레지스터되고 사라진다. 이건 보안 측면에서는 좋지만, 캐시 측면에서는 의미가 좀 다르다.
actions/cache는 GitHub 호스팅 캐시 백엔드(Azure blob)에 객체를 푸시한다. 그러니까 러너가 ephemeral인지 아닌지는 사실 본질적으로 캐시 hit/miss와 무관하다. 캐시 키만 같으면 다른 러너에서도 받아올 수 있어야 한다. 그런데 왜 miss가 났을까?
원인은 두 군데였다.
첫째, 우리 캐시 키가 ${{ runner.os }}-${{ hashFiles('package-lock.json') }} 였는데, GitHub-hosted에서 runner.os는 보통 Linux로 떨어진다. self-hosted에서는 우리가 라벨을 어떻게 줬느냐에 따라 다르고, 우리 셋업에서는 runner.os가 그대로 Linux이긴 한데 — runner.arch가 달랐다. GitHub-hosted는 X64지만 우리 클러스터 일부 노드풀이 ARM64 기반 Graviton이었다. 캐시 키에 arch를 넣어둬서 분리가 됐다.
여기까지는 자연스러운 이슈다. 진짜 황당한 건 둘째였다.
둘째, ARC는 잡을 어느 노드풀로 보낼지 결정할 때 라벨 매칭만 본다. 우리가 워크플로우 runs-on에 [self-hosted, linux]로만 적어놨는데, 클러스터에는 X64 풀과 ARM64 풀이 같이 있었다. ARC는 어느 쪽이든 라벨이 맞으면 보낸다. 결과적으로 같은 잡이 어떤 날은 X64로, 어떤 날은 ARM64로 갔다. 캐시 키가 둘로 나뉘어서, X64 잡이 ARM64 캐시를 못 받고, 그 반대도 마찬가지. 둘 다 매번 cache miss가 떴다.
새벽에 봤던 30분 빌드는 그래서 발생했다. ARM64 풀에 처음 잡이 떨어진 날이었고, 캐시가 비어있었으니까 풀 빌드를 돈 거다. 평소엔 X64로 가니까 X64 캐시는 있었지만, ARM64 입장에서는 매일이 첫날이었다.
임시방편 → 진짜 해결
새벽엔 일단 워크플로우의 runs-on을 [self-hosted, linux, x64]로 고정해서 ARM64 풀로 안 가게 했다. 이걸로 30분 빌드는 멈췄다. 멘탈은 진정됐고, 자러 갔다.
다음날 아침에 제대로 정리했다. 두 가지 변경.
# .github/workflows/frontend.yml
jobs:
build:
runs-on: [self-hosted, linux, x64] # 노드풀 명확하게 고정
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
# arch까지 키에 명시해서 풀이 섞여도 안전하게
key: ${{ runner.os }}-${{ runner.arch }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-node-
- run: npm ci
- run: npm run build
그리고 ARC 쪽에는 RunnerScaleSet을 X64/ARM64로 분리해서 라벨도 다르게 줬다. 라벨이 명확하면 워크플로우 작성자가 실수할 여지가 줄어든다.
# arc x64 scale set values
runnerScaleSetName: linux-x64
template:
spec:
nodeSelector:
kubernetes.io/arch: amd64
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
캐시 자체를 다른 방식으로
워크플로우 수정으로 새벽 사고는 막았는데, 사실 더 본질적인 고민이 있다. ephemeral runner에서는 매 잡마다 빈 파드로 시작하니까, OS 레벨의 디스크 캐시(예: ~/.cache/yarn, apt 패키지 캐시 등)는 무조건 휘발이다. actions/cache는 도와주지만 매번 압축/해제가 필요해서 그것만으로 부족할 때가 있다.
우리 팀에서는 두 가지 보완책을 도입 중이다.
하나는 빌드용 base 이미지에 자주 쓰는 의존성을 미리 굽는 거다. Node 의존성처럼 자주 바뀌는 건 어차피 못 박지만, OS 패키지나 도구 바이너리(kubectl, helm, aws-cli)는 base 이미지에 박아두면 매번 인스톨할 필요가 없다. ARC template.spec.containers[0].image만 갈아주면 된다.
또 하나는 BuildKit 원격 캐시. 도커 이미지 빌드는 잡마다 BuildKit을 새로 띄우면 캐시가 없으니까, S3나 ECR 백엔드로 push/pull하게 해놨다. 이건 따로 글로 다룰 만큼 디테일이 있어서 다음에 정리해볼 생각이다.
교훈 같은 것
이번 일로 머리에 박힌 게 두 개 있다.
첫째, ephemeral이라는 단어를 보안 관점으로만 받아들이면 안 된다. 잡 사이에 상태가 안 남는다는 건 캐시도 같이 사라진다는 뜻이다. GitHub-hosted runner를 쓸 때는 GitHub이 알아서 잘 처리해줘서 의식할 일이 없었는데, 셀프 호스팅으로 가면 이 부분이 직접 설계 대상이 된다.
둘째, runs-on 라벨은 명확할수록 좋다. [self-hosted, linux]처럼 느슨하게 매칭되게 두면, 인프라 쪽에서 노드풀을 추가하거나 변경하는 순간 워크플로우가 의도치 않은 곳으로 간다. 특히 우리처럼 ARM64를 비용 절감용으로 끼워넣은 팀은 더 그렇다.
비슷한 함정 겪으신 분 있으면 어떻게 푸셨는지 댓글로 남겨주시면 좋겠다. 우리 팀도 아직 BuildKit 원격 캐시 셋업은 검증 중이라, 더 나은 패턴이 있으면 받아들일 준비는 돼있다.