GitHub Actions 프라이빗 저장소 무료 분이 부족하다? 하나의 VPS에 이중 Self-hosted Runner: 구성과 격리
기존 이중 Runner 환경과 이전 성공 Job 및 systemd 설정을 기반으로 제작한 그림입니다. 애니메이션은 처음에 작업 분배와 리소스 경계를 시연한 뒤 저빈도 연결 광효만 남깁니다. 시스템 “동작 감소” 설정이 켜져 있으면 정적 아키텍처 다이어그램이 표시됩니다.
프라이빗 저장소에서 CI를 자주 돌리면, GitHub Actions 요금제에 포함된 무료 분이 신경 쓰이는 숫자가 되기 쉽습니다. 이미 상시 켜져 있는 VPS가 있다면, GitHub에는 계속 Workflow 관리를 맡기고 실제 빌드와 테스트만 이 서버에 맡길 수 없을까요?
가능합니다. 제가 관리하는 두 개의 워크플로는 이미 확정적인 작업의 일부를 같은 VPS에 맡기고 있습니다. 하나의 Runner는 블로그의 빌드와 배포를 담당하고, 다른 하나는 엔지니어링 도구의 검사를 돌립니다. GitHub은 트리거와 기록을 담당하고, 실제 연산은 제 서버에서 일어납니다. GitHub은 현재 Self-hosted Runner 작업에 컴퓨팅 분 과금을 부과하지 않습니다. 하지만 그렇다고 VPS가 무료가 되는 것도, 모든 PR을 프로덕션 머신에 던져도 된다는 뜻도 아닙니다.
이렇게 할지 말지는, 저는 세 가지 질문부터 던져 봅니다.
- 무료 분이 정말 거의 다 소진되었나요? 아직 넉넉하다면, GitHub-hosted의 편리함이 직접 구축하는 것보다 가치 있을 수 있습니다.
- VPS가 원래부터 상시 온라인이어야 하나요? 기존 컴퓨팅 자원을 재사용하는 것과, CI용으로 새 서버를 빌리는 것은 서로 다른 장부입니다.
- 실행할 코드가 어느 정도 신뢰할 수 있나요? 보호된 main 브랜치의 고정된 작업은 직접 운영을 검토할 수 있지만, 신뢰할 수 없는 PR은 먼저 격리된 실행 환경에 맡겨야 합니다.
앞의 두 질문은 이 작업이 수고를 들일 만한지를 결정하고, 세 번째 질문은 몇 분을 아끼려다 프로덕션 환경까지 잃게 되는지를 결정합니다.
Self-hosted가 무료인 것은 컴퓨팅 분이지, 전체 청구서가 아닙니다
GitHub Actions 요금 문서의 현재 규칙은 다음과 같습니다. Self-hosted Runner 작업은 무료이며, 프라이빗 저장소의 표준 GitHub-hosted Runner는 먼저 요금제에 포함된 분을 소모하고 초과분은 유료입니다. 현재 공식 할당량 기준 GitHub Free는 월 2,000분, Pro와 Team은 3,000분이 포함되지만 정확한 값은 저장소 소유자의 계정에 따릅니다. 표준 Linux x64, 2 vCPU를 예로 들면, 공식 정가는 분당 $0.006이며, 호스팅 Job의 1분 미만 부분은 올림합니다.
이것은 세 장부로 나누어 봐야 합니다.
| 비용 | VPS로 작업을 옮기면 어떻게 되는가 |
|---|---|
| GitHub-hosted 컴퓨팅 분 | 옮겨 간 Self-hosted Job은 이 호스팅 분을 더 이상 소모하지 않는다 |
| VPS 컴퓨팅과 유지보수 | CPU, 메모리, 디스크, 대역폭, 운영 비용은 직접 부담한다 |
| GitHub 기타 SKU | 초과 Artifact/Cache 스토리지와 기타 호스팅 작업은 별도 정산이 필요하다 |
오래된 뉴스로 헷갈리기 쉬운 시점도 하나 있습니다. GitHub은 원래 Self-hosted Runner에 분당 $0.002의 플랫폼 요금을 부과하겠다고 발표했다가, 이후 공지를 업데이트해 시행을 연기했습니다. 옛 글의 “2026년 3월부터 과금”을 그대로 현행 규칙으로 삼아서는 안 되며, 반대로 영원히 변하지 않으리라고 보장할 수도 없습니다.
저의 세 개의 과거 Job은 좋은 가정의 재료가 됩니다. 표준 2 vCPU Linux 호스팅 Runner에서 정확히 같은 시간이 걸렸다고 가정하면, 올림하여 각각 9분, 13분, 2분, 합계 24분입니다. 이 모든 분이 초과 유료 분이었을 경우에만 참고 가격은 $0.144가 됩니다. 이는 정가 대비 산식일 뿐 실제 절감액이 아니며, 같은 기간의 청구서와 요금제 포함 사용량은 계정의 Usage에서 대조해야 합니다.
하나의 VPS, 두 개의 Runner: runs-on만 바꾼다고 끝나는 일이 아닙니다
Self-hosted Runner는 제 머신에서 동작하면서 GitHub에 연결해 작업을 받아 오는 프로세스이며, GitHub이 SSH로 VPS에 로그인하는 구조가 아닙니다. 공식 Runner 문서에 전체 메커니즘 설명이 있습니다.
기존 GitHub Actions Job 기록에서 성공한 세 개의 실행을 뽑아 왔습니다.
| 실제 작업 | Runner 역할 | 결과 | Job 로그 처음~끝 시간 간격 |
|---|---|---|---|
| 블로그 빌드와 프로덕션 배포 | 프로덕션 Runner | Success | 8분 32초 |
| 블로그 PR 콘텐츠 검증 (3번째 시도) | 동일한 프로덕션 Runner | Success | 12분 12초 |
| 엔지니어링 도구 보안 및 계약 검사 | 독립 CI Runner | Success | 1분 27초 |
두 Runner는 GitHub에서 서로 다른 정체성을 갖고 있고, Job 로그는 같은 VPS를 가리킵니다. 이것만으로도 두 저장소가 실제로 자가 컴퓨팅 자원을 사용하고 있음을 알 수 있습니다. 잘 쓴 YAML 한 줄만 있는 게 아닙니다. 표의 시간은 과거 Job의 로그 시간 간격으로, 실행 기록 대조에 적합합니다. 용량 문제는 리소스 제한과 운영 정책으로 풀어야 합니다.
사양이 크지 않은 VPS에서 가장 자주 문제가 생기는 지점은 오히려 리소스 경합입니다. CI가 큰 빌드를 시작하면 상주하는 프로덕션 서비스가 갑자기 느려집니다. 그래서 Runner 정체성, 호스트 수준 제한, Workflow 허용을 각각 따로 처리했습니다.
Runner 라벨 두 개만으로는 부족합니다: 리소스 제한은 systemd가 실질적으로 담당합니다
두 저장소는 각각 Settings → Actions → Runners에서 자신의 Repo-level Runner를 만들 수 있고, GitHub 현재 페이지에서 다운로드 주소, 체크섬, 단기 등록 토큰을 받아 옵니다. 프로덕션과 CI는 root가 아닌 서로 다른 Linux 계정, 독립된 작업 디렉터리, 최소 권한으로 동작하며, 각각 다른 라벨을 붙입니다. 예를 들면 다음과 같습니다.
# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]
# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]
라벨은 누가 실행할지만 정할 뿐, 격리하지 않습니다. 두 Runner가 모두 프로덕션 비밀을 읽을 수 있거나, 제한 없는 sudo 또는 Docker 소켓 권한을 갖고 있다면, 이름이 달라도 VPS는 보호되지 않습니다.
저우선순위 CI Runner용으로 제가 쓴 설치 스크립트에는 실제로 다음 systemd service drop-in이 들어 있습니다. 이것은 2 vCPU, 약 2.4 GiB 메모리 공유 소형 VPS에서의 초기 제한입니다.
[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10
여기에는 오해하기 쉬운 세부 사항이 있습니다. systemd 리소스 제어 문서에 따르면 CPUQuota=75%는 **단일 CPU 코어의 CPU 시간 75%**를 의미하며, 듀얼 코어 VPS 전체의 75%가 아닙니다. MemoryHigh=700M은 메모리 회수 압박이 커지기 시작하는 소프트 임계값, MemoryMax=1050M은 하드 경계로, 이를 초과하면 OOM이 발생해 CI Job이 실패할 수 있습니다. Nice=10과 OOMScoreAdjust=400은 이 CI가 중요 서비스에 길을 양보하게 하지만, 프로덕션이 절대 영향을 받지 않음을 보장할 수는 없습니다.
전제 조건은 메모리 컨트롤러를 지원하는 cgroup v2와 그에 맞는 systemd 버전을 사용하는 호스트입니다. 먼저 test -f /sys/fs/cgroup/cgroup.controllers && grep -qw memory /sys/fs/cgroup/cgroup.controllers && systemctl --version을 실행해 통합 계층, 메모리 컨트롤러, systemd 버전을 확인하고, 오래된 환경에 그대로 복사해 넣고 제한이 적용되었다고 단정하지 마십시오.
Runner의 ./config.sh는 등록만 담당하며 systemd 서비스를 자동으로 만들지 않습니다. GitHub 공식 Linux 서비스 절차에 따라, 등록된 프로덕션/CI Runner 설치 디렉터리에 각각 들어가 관리자 권한으로 각 svc.sh에 install <전용 사용자>를 실행한 뒤 start합니다. 예를 들어 CI Runner 자신의 설치 디렉터리(등록됨, svc.sh 존재)에서 다음과 같이 실행합니다.
sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status
ci-runner는 예시 계정이며 프로덕션 계정을 재사용하지 마십시오. 이미 실행 중인 서비스가 있다면 install을 중복 실행하지 마십시오. 완료 후 실제 CI unit을 찾아 유지보수 시간대에 제한을 설정합니다.
systemctl list-unit-files 'actions.runner.*.service'
# 替换为上一行显示的真实 CI unit,切勿照抄占位名称
UNIT='actions.runner.EXAMPLE-REPO.ci-01.service'
sudo systemctl edit "$UNIT"
# 在编辑器里保存上面的 [Service] 配置
sudo systemctl daemon-reload
sudo systemctl restart "$UNIT"
systemctl cat "$UNIT"
systemctl show "$UNIT" -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax
restart는 이 Runner가 실행 중인 Job을 중단시킵니다. 먼저 유휴 상태임을 확인하고, 프로덕션 Runner를 실수로 재시작하지 마십시오. 재시작 후 저장소 Runners 페이지에서 Online으로 복귀했는지 확인하고, systemctl show로 적용 값을 대조합니다. 설정을 롤백할 때는 이번에 추가한 CI drop-in만 되돌리고, 기존 서비스 커스터마이징을 함께 비우지 마십시오. 등록 코드, 호스트명, 서비스 계정, 프로덕션 디렉터리는 Workflow에 넣을 필요도, 공개 글에 나올 필요도 없습니다.
Workflow가 시작하기 전 리소스를 한 번 더 확인합니다
systemd는 서비스 프로세스 그룹에 경계를 줄 수 있지만, 빈 디스크 공간을 만들어 낼 수는 없습니다. 제 엔지니어링 CI에는 리소스 허용 단계가 하나 더 있습니다. 호스트의 가용 메모리와, 작업 디렉터리가 있는 파일시스템의 여유 공간을 읽습니다. 아래는 신뢰할 수 있는 main 브랜치를 위한 간결한 Node.js 방안입니다.
name: Trusted main validation
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: trusted-main-validation
cancel-in-progress: false
jobs:
test:
runs-on: [self-hosted, linux, x64, ci-lab]
timeout-minutes: 12
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check available resources
shell: bash
run: |
set -euo pipefail
mem_kib="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
disk_kib="$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}')"
echo "available memory=${mem_kib} KiB, disk=${disk_kib} KiB"
if [ "$mem_kib" -lt 600000 ]; then
echo "::error::Memory below 600000 KiB: ${mem_kib} KiB"
exit 1
fi
if [ "$disk_kib" -lt 5242880 ]; then
echo "::error::Disk below 5242880 KiB: ${disk_kib} KiB"
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm test
이 예제는 actions/checkout@v7와 actions/setup-node@v7를 사용합니다. 이들은 Action 런타임으로 Node.js 24를 사용하며, Actions Runner 버전이 최소 v2.327.1 이상이어야 합니다. 기존 머신을 마이그레이션하기 전에 Runner 버전을 먼저 확인하고 YAML만 올리지 마십시오. checkout v7와 setup-node v7에 관련 설명이 있습니다.
약 586 MiB의 가용 메모리와 5 GiB의 가용 디스크가 이 소형 VPS의 허용 기준입니다. 스크립트는 잔량과 실패 원인을 출력하므로 메모리 부족과 디스크 부족을 구분하기 쉽습니다.
이 단계는 확인할 뿐, 예약할 수 없습니다. 두 저장소의 concurrency 그룹은 저장소를 가로지르는 전역 락이 아닙니다. 제 선택은 엔지니어링 CI를 독립 systemd cgroup의 CPU/메모리 상한 아래에 두어, 리소스가 부족하면 무한히 팽창하는 대신 실패하게 하는 것입니다. 프로덕션 서비스는 자체 권한과 리소스 경계를 유지합니다. 큰 빌드나 두 저장소의 동시 실행을 엄격히 피해야 하는 경우에는, 가장 확실한 방법은 단일 제어 작업 큐나 독립 VPS로 바꾸는 것으로, 단일 스케줄러가 작업을 직렬로 분배합니다. 저장소를 가로지르는 동명의 concurrency를 전역 상호 배제로 착각하지 마십시오. 진정한 호스트 수준 직렬화가 필요하다면 별도 검토된 flock 래퍼 실행기를 설계할 수 있지만, 모든 리소스 집약 작업의 진입점을 잠가야 하며 한 Job 앞에만 락을 거는 것은 효과가 없습니다. 이 방안은 제 환경에 배포되어 있지 않습니다. 대가는 작업이 대기열에 서는 것이며, 두 저장소의 동시 처리량을 그대로 유지할 수는 없습니다.
같은 호스트에서 두 저장소의 무거운 단계를 정말 직렬화해야 한다면, 별도 검토된 공유 파일 락 방안을 선택할 수 있습니다. 먼저 관리자가 락 전용 시스템 그룹을 만들고, 신뢰할 수 있는 두 Runner 사용자를 그룹에 추가한 뒤, 공유 락 파일을 설정합니다(빌드 조율 전용, 그룹에 프로덕션 디렉터리 권한은 주지 않습니다).
sudo groupadd --system ci-queue
sudo usermod -aG ci-queue runner-a
sudo usermod -aG ci-queue runner-b
sudo install -d -o root -g ci-queue -m 3770 /var/lock/ci-queue
sudo install -o root -g ci-queue -m 0660 /dev/null /var/lock/ci-queue/build.lock
여기의 계정명은 플레이스홀더입니다. 새 그룹에 추가한 뒤에는 유지보수 시간대에 관련 Runner 서비스를 재시작해 새로운 보조 그룹을 얻게 하고, 실행 중인 Job을 함부로 중단하지 마십시오. 이어서 두 저장소 모두 각자의 리소스 집약 명령을 같은 락의 범위 안에 넣어야 합니다.
flock -w 900 /var/lock/ci-queue/build.lock \
bash -e -c 'npm ci && npm test'
이렇게 하면 두 번째 참가자는 최대 900초를 기다린 뒤 실패합니다. 무한정 멈추지 않습니다. 같은 락으로 감싼 명령만 보호할 뿐, GitHub Job의 모든 단계를 자동으로 직렬화하지도, 신뢰할 수 없는 워크플로가 락을 고의로 우회하는 것을 막지도 못합니다. 바로 그런 이유로 저의 기존 이중 Runner는 이 락 방식을 채택하지 않았습니다. 이는 독립적인 평가가 필요한 재사용 가능한 방안이지, 이미 돌아가고 있는 실측 결과로 말해서는 안 됩니다.
한 번의 CI를 초록색으로 만들려고 MemoryMax를 무제한으로 올리지 마십시오. 여유를 장기적으로 초과하는 작업은 별도 머신으로 옮겨야 합니다.
PR과 프로덕션이 Runner를 공유하는 것은 또 다른 리스크 계산입니다
블로그의 기존 Workflow를 살펴보니, 콘텐츠 PR 검증과 프로덕션 배포가 여전히 같은 프로덕션 Runner 라벨을 사용하고 있었습니다. 신뢰할 수 있는 관리자만 있는 프라이빗 저장소에서는 당분간 쓸 수 있을지 모르지만, 외부 협업을 여는 보안 튜토리얼로서는 가르칠 수 없습니다.
GitHub의 보안 가이드는 경고합니다. 영속적인 Self-hosted Runner에서 신뢰할 수 없는 코드를 실행하면, Job을 가로질러 영속적인 오염이 남거나, 스크립트가 호스트 파일과 권한에 닿을 수 있습니다. 앞으로 외부 협업자, Fork PR, 기타 신뢰할 수 없는 코드가 검증 파이프라인에 들어온다면 프로덕션 머신과 분리해야 합니다.
不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署
이는 일부 PR 검증이 계속 GitHub-hosted 분을 쓸 수 있음을 의미합니다. 하지만 몇 분을 아끼려고 낯선 스크립트를 배포 자격 증명에 가까이 두는 것은 손해 보는 거래입니다. 저장소가 프라이빗이라도, 실제 협업자 권한, 브랜치 보호, Workflow를 수정할 수 있는 사람이 누군지를 봐야 합니다.
Runner가 끊기거나 VPS에 장애가 생기면 배포를 어떻게 복구할까요
자가 Runner의 비용은 월 임대료만이 아니라 다운타임, 업데이트, 복구 시간도 포함됩니다. 특히 실무적인 차이 하나는, Self-hosted Runner가 오프라인이 되면 그 라벨에 맞는 Job은 대기열에 쌓이며, ubuntu-latest로 자동 폴백되지 않는다는 점입니다. GitHub 문서에 따르면, 사용 가능한 Runner가 없는 작업은 최장 약 24시간 대기 후 실패합니다.
복구 순서는 간단해도 됩니다. 먼저 GitHub 저장소 Runners 페이지에서 Online인지 확인하고, CI에 해당하는 ./svc.sh status, journalctl -u <실제unit> -n 100 --no-pager, 디스크 잔량을 봅니다. cgroup OOM이 발생하면 작업량을 줄이거나 유휴를 기다리는 것을 먼저 하고, 제한을 바로 풀지 마십시오. Runner 애플리케이션은 보통 자동 업데이트되지만, 자동 업데이트를 껐다면 버전을 직접 추적해야 합니다. 공식 규정에 따르면, 업데이트 창을 벗어났거나 중요한 보안 업데이트가 누락된 오래된 Runner는 작업을 더 이상 받지 않을 수 있습니다.
프로덕션 배포는 별도의 명확한 복구 경로를 두는 것이 좋습니다. 예를 들어 보호된 릴리스 진입점에 별도 승인된 GitHub-hosted 빌드 + 통제된 배포 단계를 두거나, 기존 운영 경로로 VPS를 복구한 뒤 재시도하는 식입니다. GitHub-hosted Runner는 기본적으로 VPS 로컬 디렉터리에 접근할 수 없고 원래 프로덕션 배포 자격 증명을 보유하지 않으므로, runs-on: ubuntu-latest로만 바꾸는 것은 동작하는 폴백이 아닙니다. 별도로 훈련하기 전에는 이 백업 방안을 검증된 자동 재해 복구라고 쓰지 마십시오.
정말 절약이 되었는지는, 마지막에 Usage로 확인합니다
초록색 Actions는 Job이 완료되었음을 증명할 뿐, 월별 장부를 대신 정산해 주지 않습니다. GitHub의 Billing & licensing → Usage에서 과금 기간별로 Actions 사용량을 확인하고, 상세 내역이 필요하면 공식 방법으로 보고서를 추출합니다. 초과 호스팅 분, Artifact/Cache 등 스토리지, 추가 VPS 임대료, 유지보수 비용은 따로 계산합니다.
저는 이런 마이그레이션을 볼 때 순서가 항상 같습니다. 먼저 요금제 분이 정말 빠듯한지를 보고, 다음에 VPS가 원래 상주하는지를 보고, 마지막에 실행 코드의 신뢰 경계를 대조합니다. 기존 VPS 위의 안정적이고 빈번한 신뢰할 수 있는 빌드는 Self-hosted를 시도해 볼 만하고, CI가 적고 무료 분을 다 쓰지 못하는 프로젝트는 GitHub-hosted를 계속 쓰는 편이 더 유리할 수 있습니다.
비용 판단은 자신의 숫자를 바로 대입하면 됩니다. 월별 요금제 초과·마이그레이션 가능한 호스팅 분 × 해당 SKU 단가 − 신규 VPS/운영 비용. 예를 들어 1000분이 모두 표준 Linux 2-core 초과 사용량이라면 정가는 $6입니다. 원래 무료 할당량 안이었다면 한계 청구서는 $0일 수도 있습니다. 신뢰할 수 있고, 유지보수할 수 있고, 복구할 수 있는 작업만 옮겨야 손해 보지 않습니다.
이전의 ChatGPT Web + GitHub + Self-hosted Runner 실천은 개발 진입점과 배포 출구에 관한 이야기였고, 이번 글은 다른 질문입니다. GitHub-hosted 분을 어떻게 절약하는지, 그리고 하나의 VPS 위의 여러 Runner가 서로를 방해하지 않게 하려면 어떻게 해야 하는지.
시간 관련 안내: 요금과 공식 발표는 2026년 10월 19일 기준 확인. Job 시간은 기존 실행 기록에서, systemd 수치는 기존 설치 스크립트에서 가져온 것으로, 새로 수행한 호스트 부하 테스트가 아닙니다.