왜 러너 부팅이 병목이 되는가
GitHub Actions에서 setup-node, setup-python 같은 액션이나 apt-get install은 매 잡(job)마다 네트워크에서 툴체인을 내려받는다. 표준 GitHub-hosted 러너는 이미 많은 도구를 담고 있지만, 프로젝트 고유의 의존성(사내 패키지, 특정 CUDA 버전, 대용량 도커 베이스 이미지)은 매번 다시 설치된다. 셀프 호스티드 러너를 쓰면 상황은 더 심각해서, 깨끗한 VM으로 부팅할 때마다 수 분이 apt·pip·docker pull에 소모된다. 하루 수백 회 도는 파이프라인에서는 이 반복 설치가 실질적인 비용이자 리드타임이다.
해결 전략: 이미지에 미리 굽는다
핵심은 "잡 실행 시점의 설치"를 "이미지 빌드 시점의 설치"로 옮기는 것이다. 러너가 뜨자마자 필요한 도구가 이미 디스크에 있고, 도커 레이어가 캐시되어 있으면 잡은 곧바로 실제 작업으로 진입한다. 접근은 크게 세 가지다.
| 방식 | 부팅 반영 | 유지보수 | 적합한 경우 |
|---|---|---|---|
| golden AMI/이미지 프리베이크 | 즉시(설치 0) | 이미지 재빌드 필요 | 안정된 툴셋, 대규모 셀프호스팅 |
| 워크플로 캐시(actions/cache) | 복원 시간 소요 | 낮음 | 변동 큰 의존성 |
| 컨테이너 잡(pre-pull) | 레이어 캐시 히트 | 중간 | 재현성 중시 |
Packer로 golden 이미지 굽기
셀프 호스티드 러너라면 Packer로 도구가 내장된 AMI를 만드는 것이 가장 효과가 크다. 자주 바뀌지 않는 시스템 패키지, 언어 런타임, 자주 쓰는 도커 베이스 이미지를 미리 넣어 둔다.
source "amazon-ebs" "runner" {
region = "ap-northeast-2"
instance_type = "c6i.xlarge"
source_ami = "ami-0abcd1234ubuntu2204"
ssh_username = "ubuntu"
ami_name = "gha-runner-{{timestamp}}"
}
build {
sources = ["source.amazon-ebs.runner"]
provisioner "shell" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y build-essential jq",
# 언어 런타임을 이미지에 고정
"curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -",
"sudo apt-get install -y nodejs",
# 자주 쓰는 베이스 이미지를 미리 pull 해 레이어 캐시
"sudo systemctl start docker",
"sudo docker pull python:3.12-slim",
"sudo docker pull node:20-alpine"
]
}
}
이렇게 구운 이미지를 러너 오토스케일링 그룹의 런치 템플릿에 지정하면, 새 러너는 부팅과 동시에 도구를 갖춘 상태가 된다.
GitHub-hosted 러너에서의 대안
GitHub-hosted 러너는 이미지를 바꿀 수 없으므로 캐시 전략으로 근접한 효과를 낸다. 설치 결과 디렉터리를 키 기반으로 캐싱하면 재설치를 건너뛴다.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
id: apt-cache
uses: actions/cache@v4
with:
path: ~/apt-cache
key: apt-${{ runner.os }}-${{ hashFiles('.github/apt-packages.txt') }}
- name: Install if cache miss
if: steps.apt-cache.outputs.cache-hit != 'true'
run: |
sudo apt-get update
xargs -a .github/apt-packages.txt sudo apt-get install -y
mkdir -p ~/apt-cache
cp /var/cache/apt/archives/*.deb ~/apt-cache/
도커 빌드가 핵심이라면 docker/build-push-action의 GitHub Actions 캐시(cache-from: type=gha)로 레이어를 재사용하는 편이 apt 캐싱보다 효과가 크다.
측정 없이는 최적화도 없다
어떤 스텝이 실제로 오래 걸리는지 먼저 재야 한다. 워크플로 실행 로그의 스텝별 소요 시간을 API로 뽑아 상위 항목만 이미지로 옮기면 투자 대비 효과가 명확하다.
import requests, os
owner, repo = "myorg", "myrepo"
h = {"Authorization": f"Bearer {os.environ['GH_TOKEN']}"}
runs = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/actions/runs?per_page=20",
headers=h).json()["workflow_runs"]
for run in runs:
jobs = requests.get(run["jobs_url"], headers=h).json()["jobs"]
for job in jobs:
for step in job.get("steps", []):
if not step["completed_at"]:
continue
# 오래 걸리는 설치 스텝을 골라 이미지 프리베이크 후보로
print(job["name"], step["name"],
step["started_at"], step["completed_at"])
주의점: 최신성과 재현성의 균형
이미지에 도구를 굽는 순간 그 버전은 고정된다. 보안 패치가 늦어질 수 있으므로 이미지 재빌드를 정기 스케줄(예: 주 1회)로 자동화하고, 이미지 ID에 타임스탬프를 남겨 롤백 가능하게 해야 한다. 또 모든 것을 굽지 말 것 — 자주 바뀌는 애플리케이션 의존성까지 이미지에 넣으면 이미지 재빌드가 병목이 된다. 변동성이 낮고 무거운 것만 이미지로, 변동성이 높은 것은 캐시로 나누는 것이 원칙이다.
정리
러너 부팅 단축의 본질은 반복 설치를 빌드 시점으로 이동시키는 것이다. 셀프 호스티드라면 Packer로 golden 이미지를 굽고, GitHub-hosted라면 캐시와 도커 레이어 재사용으로 접근한다. 무엇을 옮길지는 스텝별 소요 시간 측정으로 결정하고, 최신성 확보를 위해 이미지 재빌드를 자동화하면 재현성과 속도를 동시에 얻을 수 있다.