GitHub CI

이 문서는 tirosh-home site에서 GitHub Actions self-hosted runner controller와 warm worker pool을 운영하는 방법을 설명합니다. 일반 workflow는 provider를 알 필요 없이 tirosh-ubuntu-lite, tirosh-ubuntu-browser, tirosh-ubuntu-heavy 같은 capability label만 선택합니다.

장기 고정 runner는 controller/bootstrap/가벼운 smoke 용도로만 남기고, package/image/browser/heavy job은 controller가 필요할 때 worker VM을 만들거나 stopped worker를 다시 시작하는 방향으로 운영합니다. 기존 github-ci VM 직접 등록 절차는 legacy/manual fallback으로만 취급합니다.

1. 전체 흐름

1-1. 기본 runner 흐름

GitHub CI runner 구성은 네 단계로 나뉩니다.

Proxmox VM 준비
  github-ci VM 생성
  |
  v
VM 접속 준비
  SSH password 또는 SSH key
  sudo 권한
  |
  v
GitHub runner 등록
  organization registration token 발급
  actions-runner 설치
  systemd service 등록
  |
  v
CI workflow 실행
  self-hosted labels 확인
  smoke workflow 실행
  기존 workflow 전환

1-2. on-prem runner 실행 순서

처음 구성할 때는 아래 순서로 진행합니다.

make proxmox/vms/plan SITE=tirosh-home
make proxmox/vms/apply SITE=tirosh-home

gh auth refresh -h github.com -s admin:org

make github-runner/doctor SITE=tirosh-home

make github-runner/apply SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

make github-runner/register SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

make github-runner/status SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

github-runner/apply는 v2 public interface이고, 기존 github-runner/prepare는 같은 VM 준비 작업을 수행하는 상세 target으로 남아 있습니다.

1-3. cloud runner pool 실행 순서

Cloud runner pool은 아래처럼 시작합니다.

make github-runner/pool/doctor SITE=aws-ci
make github-runner/pool/plan SITE=aws-ci
make github-runner/pool/apply SITE=aws-ci
make github-runner/pool/status SITE=aws-ci

make github-runner/pool/doctor SITE=ncloud-ci
make github-runner/pool/plan SITE=ncloud-ci
make github-runner/pool/apply SITE=ncloud-ci
make github-runner/pool/status SITE=ncloud-ci

1-4. cloud runner pool 종료 순서

Cloud runner pool을 제거할 때는 GitHub runner registration과 cloud resource를 같은 lifecycle로 다룹니다.

make github-runner/pool/destroy/plan SITE=ncloud-ci
make github-runner/pool/destroy SITE=ncloud-ci CONFIRM_DESTROY=ncloud-ci

make github-runner/pool/destroy/plan SITE=aws-ci
make github-runner/pool/destroy SITE=aws-ci CONFIRM_DESTROY=aws-ci

destroy/plan은 site의 pool.vars.yml 기준으로 삭제될 GitHub runner registration 후보와 OpenTofu destroy plan을 함께 보여줍니다. 실제 destroy는 먼저 GitHub runner registration을 삭제한 뒤 OpenTofu destroy를 실행합니다. 실수 방지를 위해 CONFIRM_DESTROY 값은 반드시 SITE와 같아야 합니다.

GitHub cleanup은 controller 이름, worker name prefix, worker label 전체 일치 조건으로 후보를 고릅니다. 그래서 github-ci-01처럼 다른 site의 장기 runner가 self-hosted label을 공유해도 삭제 후보가 되지 않습니다.

Site 전체가 아니라 확인된 stale registration만 정리할 때는 exact name 또는 ID 필터를 반복해서 지정합니다. 필터는 site selector로 확인된 managed runner와 교집합으로 적용되므로, 다른 site의 runner 이름이나 ID를 실수로 넘겨도 삭제 후보가 되지 않습니다. 먼저 같은 필터로 dry-run 결과를 확인한 뒤 --apply를 추가합니다.

uv run infra-tools github-runner cleanup \
  --site aws-ci \
  --runner-name aws-ci-controller-01 \
  --runner-name awslite-<timestamp>-<token>

uv run infra-tools github-runner cleanup \
  --site aws-ci \
  --runner-name aws-ci-controller-01 \
  --runner-name awslite-<timestamp>-<token> \
  --apply

--runner-id도 반복할 수 있으며 --runner-name과 함께 쓰면 두 필터의 합집합을 대상으로 삼습니다. 아무 필터도 지정하지 않은 기존 호출은 site 전체 managed runner를 대상으로 하므로 pool destroy 경로의 동작은 바뀌지 않습니다.

1-5. managed worker cleanup

Controller가 만든 cloud worker만 정리해야 할 때는 pool foundation destroy가 아니라 worker cleanup target을 사용합니다.

make github-runner/workers/cleanup/plan SITE=aws-ci
make github-runner/workers/cleanup/plan \
  SITE=aws-ci \
  CLEANUP_WORKER_NAMES=awslite-<timestamp>-<token>
make github-runner/workers/cleanup/apply \
  SITE=aws-ci \
  CLEANUP_WORKER_NAMES=awslite-<timestamp>-<token> \
  CONFIRM_CLEANUP=aws-ci

cleanup/plan은 controller VM에서 github-runner-controller cleanup-workers --dry-run을 실행합니다. cleanup/apply는 같은 명령을 --apply로 실행합니다. CONFIRM_CLEANUP 값은 반드시 SITE와 같아야 하고, 이 target은 기존처럼 [DANGER]입니다.

Apply는 exact worker name 없이 broad deletion을 수행하지 않습니다. CLEANUP_WORKER_NAMES가 비어 있으면 Make는 mutation 전에 실패하고, controller도 --worker-name 또는 --confirm-all-managed가 없으면 apply mutation 0으로 실패합니다. prefix, glob, 다른 pool 이름은 exact name이 아닙니다.

전체 managed worker 삭제가 꼭 필요하면 site 이름 확인과 직전 plan digest를 함께 요구합니다. cleanup/plan JSON의 plan_digest는 eligible delete target의 pool, name, provider id, lifecycle, registration, busy를 안정 정렬한 뒤 SHA-256으로 계산합니다. unscoped apply는 현재 snapshot digest를 다시 계산하고, 값이 다르거나 digest가 없거나 형식이 틀리면 어느 pool도 삭제하지 않습니다.

make github-runner/workers/cleanup/plan SITE=aws-ci
make github-runner/workers/cleanup/apply \
  SITE=aws-ci \
  CONFIRM_CLEANUP=aws-ci \
  CONFIRM_CLEANUP_ALL=aws-ci \
  CLEANUP_PLAN_DIGEST=<plan_digest>

exact-name apply는 이름이 intent이므로 digest를 요구하지 않습니다. 다만 busy, owner, GitHub status 재검증은 그대로 유지합니다.

cleanup/plan은 worker name, provider id, GitHub registration, busy, owner, lifecycle를 가능한 만큼 표시합니다. GitHub에서 busy=true인 runner, busy를 판정할 수 없는 unknown worker, 이 site/pool prefix에 속하지 않는 owner mismatch worker는 삭제하지 않습니다. GitHub 상태를 조회할 수 없으면 apply 자체를 실패시켜 cloud mutation보다 안전을 우선합니다.

1-6. pool/apply 책임

pool/apply는 cloud VM, controller config, runner baseline을 배포합니다. GitHub queue를 보고 추가 worker VM을 만들지 말지는 배포된 controller service의 책임입니다.

2. 관리 경계

GitHub CI runner는 VM lifecycle, runner 설치, GitHub organization 상태가 함께 엮입니다. 그래서 한 파일에 모든 설정을 두지 않습니다.

영역 관리 위치 역할
site profile sites/tirosh-home/profile.toml GitHub runner target과 vars 파일 경로 선언
VM catalog sites/tirosh-home/vms.tfvars github-ci VM CPU, memory, disk, IP 선언
Ansible inventory sites/tirosh-home/inventory.toml github-ci-01 SSH 접속 대상 선언
prepare vars sites/tirosh-home/github-runner/prepare.vars.yml CI package, Docker, runner binary 준비 값
register vars sites/tirosh-home/github-runner/register.vars.yml GitHub organization runner 등록 값
prepare playbook infra/ansible/playbooks/github-runner/prepare.yml CI package, Docker, runner user, runner binary 준비
register playbook infra/ansible/playbooks/github-runner/register.yml GitHub runner 등록과 service 관리
Make target make/areas/github-runner.mk prepare/register/install/status 실행 entrypoint
GitHub org tirosh-chain self-hosted runner 등록 상태

Cloud runner pool은 같은 원칙을 cloud site에 적용합니다.

영역 관리 위치 역할
site profile sites/aws-ci/profile.toml, sites/ncloud-ci/profile.toml provider, inventory, cloud vars, runner vars 경로 선언
site inventory sites/<site>/inventory.toml controller+worker VM의 Ansible 접속 대상 선언
rendered inventory sites/<site>/inventory.yml Make/Ansible 실행 대상
cloud vars sites/<site>/cloud/vars.tfvars cloud VM, network, security group, instance shape 입력
pool vars sites/<site>/github-runner/pool.vars.yml controller/worker/autoscaler 정책 값과 provider별 worker cloud spec
prepare vars sites/<site>/github-runner/prepare.vars.yml runner host baseline 준비 값
register vars sites/<site>/github-runner/register.vars.yml GitHub organization runner 등록 값
OpenTofu module infra/opentofu/<provider>/github-runner-pool provider별 cloud foundation과 controller VM provisioning
controller playbook infra/ansible/playbooks/github-runner/controller.yml controller config, secret directory, Python runtime, systemd service 배포

Cloud site는 site.areas = ["github_runner_pool"]를 선언합니다. 그래서 aws-ci, ncloud-citirosh-home처럼 Proxmox, k3s, Argo CD, Nexus 기본값을 갖지 않습니다.

2-0. Cloud controller ownership

Cloud controller는 provider-local로 소유권을 나눕니다. AWS와 NCloud 사이에 VPN/peering이 없으므로 NCloud worker는 AWS controller의 private status API(:8080)로 callback할 수 없습니다. 그래서 한 controller가 다른 provider worker를 재조정하면 GitHub demand는 보이지만 worker heartbeat/job event는 도달하지 않습니다.

Site Controller 소유 pool Backend Worker name_prefix GitHub demand
aws-ci aws-ci-controller-01 arm64, arm64-nightly aws only awsarm, awsnight tirosh-ubuntu-arm64, arm64-nightly
ncloud-ci ncloud-ci-controller-01 lite, browser, heavy ncloud only nclite, ncbrw, ncheavy tirosh-ubuntu-lite, tirosh-ubuntu-browser, tirosh-ubuntu-heavy

이 두 controller는 동시에 켜 둘 수 있습니다. pool 이름, worker name_prefix, demand label이 겹치지 않기 때문입니다. 같은 GitHub organization queue를 보는 controller가 같은 capability label 또는 같은 name_prefix를 공유하면 중복 생성/정리가 발생하므로, github_runner_pool site 사이와 한 site 안에서는 아래를 금지합니다.

  • 같은 cloud worker name_prefix를 선언하는 것
  • name_prefix가 다른 prefix의 접두사가 되는 것 (awsarm vs awsarmnightly)
  • 같은 (pool name, backend)를 선언하는 것
  • cloud.provider와 다른 backend를 pool에 넣는 것

AWS tag:Name 필터, NCloud serverName 조회, GitHub runner 조회, status API pool 추론은 모두 {prefix}- 경계를 사용합니다. naive startswith(prefix)awsarmjunk-*awsarmnightly-*awsarm pool에 넣습니다. Worker 이름은 prefix 자체가 아니라 {prefix}-{timestamp}-{token}입니다. status API는 명시 pool이 있어도 worker 이름이 그 pool prefix와 일치해야 하고, 생략 시 {prefix}- longest unique match만 허용하며 동률이면 거부합니다.

make repo/doctormake github-runner/doctor가 이 계약을 검사합니다. aws-ci 렌더링/배포는 NCloud OpenTofu output과 NCLOUD_* secret을 요구하지 않습니다. NCloud secret은 pools[].backendsncloud가 있는 site에만 배포합니다.

이 소유권 분리를 운영에 반영할 때는 cloud resource를 지우지 말고 controller config만 바꿉니다. 먼저 aws-ci controller를 배포해서 browser/heavy 재조정을 멈추고, 기존 ncbrw/ncheavy worker는 ncloud-ci가 이어서 관리하게 둡니다. ncloud-ci pool 자체는 그대로 두고, unused AWS backend를 제거한 경우에만 ncloud-ci controller도 이어서 배포합니다.

ARM nightly name_prefixawsarmnightly에서 awsnight로 바꿉니다. awsarmawsarmnightly의 접두사여서 AWS Name=awsarm* 필터와 status API startswith가 nightly VM/callback을 일반 arm64 pool에 넣을 수 있습니다. 이 변경은 live/stale registration을 자동 이전하지 않습니다.

  • 이미 존재하는 awsarmnightly-* EC2 Name tag와 GitHub runner 이름은 새 prefix와 더 이상 일치하지 않습니다.
  • awsarm-* 필터도 awsarmnightly-*를 가져오지 않으므로 해당 VM은 어느 ARM pool에도 속하지 않는 stale 상태가 됩니다.
  • controller apply 전까지는 production이 기존 prefix를 그대로 사용합니다. apply 이후 다음 nightly job은 awsnight-* worker를 만듭니다.
  • 이전 awsarmnightly-* VM/registration은 이 커밋이 삭제하지 않습니다. 운영자가 확인한 뒤에 exact --runner-name 또는 CLEANUP_WORKER_NAMES로만 정리합니다.
  • GitHub site cleanup selector도 새 prefix만 보므로, stale 이름은 명시 필터 없이는 destroy/cleanup 후보가 되지 않습니다.

NCloud controller VM은 일반 CI job을 직접 받지 않고, GitHub queue polling, cloud worker lifecycle, worker status API, orphan cleanup만 담당합니다. Controller가 self-hosted runner까지 겸하면 GitHub runner _diag, _work, Docker layer, controller state, Python runtime이 한 VM에 섞여 root disk와 권한 문제가 반복되기 쉽습니다. 그래서 cloud pool controller는 작은 VM spec에 controller data volume을 붙이고, CI capacity는 worker pool이 공급하게 둡니다.

# sites/ncloud-ci/cloud/vars.tfvars
server_spec_code = "mi1-g3"
fee_system_type_code = "FXSUM"
root_volume_size_gib = 50
controller_data_volume_enabled = true
controller_data_volume_name = "ncloud-ci-controller-data"
controller_data_volume_size_gib = 30
controller_data_volume_type_code = "CB1"

NCloud Micro spec(mi1-g3)는 time plan으로 생성할 수 없으므로 controller VM에는 fee_system_type_code = "FXSUM"을 사용합니다.

# sites/ncloud-ci/github-runner/pool.vars.yml
controller:
  runner_enabled: false
  data_mount_enabled: true
  data_device: /dev/vdb
  data_mount_path: /mnt/tirosh/github-runner
  swap_enabled: true
  swap_file: /mnt/tirosh/github-runner/controller.swap
  swap_size_mib: 2048
  swappiness: 10

mi1-g3는 1 vCPU, 1 GiB RAM이므로 controller data volume 위에 2 GiB swap file을 둡니다. controller.data_device는 실제 OS에서 보이는 attached block storage device path입니다. NCloud KVM image에서 다르게 보이면 이 값만 수정하고 github-runner/pool/apply 또는 controller apply를 다시 실행합니다. 기존 controller data volume을 더 작은 크기로 줄일 때는 filesystem shrink를 시도하지 않습니다. Controller data는 재생성 가능한 runtime/state/cache로 보고, 기존 block storage를 명시적으로 replace한 뒤 controller apply로 다시 구성합니다.

기존 controller가 이미 /opt/actions-runner, /var/lib/actions-runner, /opt/tirosh/github-runner-controller를 root disk에 만든 상태라면 ncloud-ci처럼 legacy cleanup vars를 켭니다. 이 cleanup은 명시된 legacy path만 대상으로 하고, 새 mount path와 같으면 삭제하지 않습니다.

Cloud IaC credential은 site profile에 넣지 않습니다. AWS는 AWS CLI profile을 사용하고 .env에는 profile 이름만 둡니다.

AWS_PROFILE=tirosh-infra
AWS_DEFAULT_REGION=ap-northeast-2

IaC 실행에 쓰는 실제 AWS access key와 secret key는 ~/.aws/credentials에 둡니다. controller runtime은 controller VM에 연결된 IAM instance profile을 우선 사용하며, profile이 없는 AWS controller만 배포 시 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY를 secret file로 전달합니다. worker에 연결하는 worker_iam_instance_profile과 controller runtime 인증에 쓰는 controller IAM profile은 서로 다른 설정입니다. NCloud OpenTofu provider는 AWS-style profile을 지원하지 않고, provider 문서 기준으로 NCLOUD_ACCESS_KEY, NCLOUD_SECRET_KEY, NCLOUD_REGION 환경 변수를 사용합니다.

controller runtime credential은 VM 내부에서 .env 파일로 관리하지 않습니다. Make target은 local .env를 입력원으로 사용할 수 있지만, controller VM에는 key별 secret file로 배포합니다.

/etc/tirosh/github-runner/secrets/
  github_token
  aws_access_key_id
  aws_secret_access_key
  proxmox_api_token_id
  proxmox_api_token_secret
  ncloud_access_key
  ncloud_secret_key
  ncloud_region

secret directory는 0700, 각 secret file은 0600으로 생성합니다. GitHub API token 입력은 GITHUB_RUNNER_CONTROLLER_GITHUB_TOKEN만 사용합니다. pools[].backendsaws가 있고 controller IAM instance profile이 없을 때만 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY를 사용합니다. ncloud가 있으면 NCLOUD_ACCESS_KEY, NCLOUD_SECRET_KEY, NCLOUD_REGION을 사용합니다. aws-ci는 ARM pool만 소유하므로 NCloud secret을 controller에 넣지 않습니다. proxmox backend는 controller runtime용 PROXMOX_API_TOKEN_ID, PROXMOX_API_TOKEN_SECRET을 별도로 받습니다.

pool.vars.ymlgithub 항목은 controller가 queue demand를 읽고 worker를 runner로 등록할 GitHub 범위입니다. github.owner는 organization 또는 owner입니다. runner_scope: organization에서는 github.repositories를 비워 두면 controller가 organization repository 목록을 GitHub API로 조회한 뒤 전체 repository의 queued/waiting/pending workflow run을 확인합니다. 특정 repository만 스캔해야 하는 예외 상황에서는 github.repositories를 필터로 지정할 수 있습니다. Controller는 GitHub REST API로 workflow run을 찾은 뒤, 각 run의 job 목록에서 statuslabels를 확인합니다. 기본적으로 workers.labels를 모두 포함한 queued job만 해당 pool의 demand로 계산합니다. Workflow가 cloud provider를 알 필요 없도록 운영하려면 github.demand_labels를 capability label만 포함하도록 지정해서 worker 등록 label과 demand matching label을 분리합니다.

여러 pool은 같은 GitHub API client를 공유하고 GET 결과를 한 reconcile tick 동안 캐시합니다. 운영 site의 기본 poll interval은 300초이며 cache TTL은 30초이므로 같은 tick의 pool별 중복 조회만 제거하고 다음 tick에는 새 상태를 읽습니다. 이 설정은 추가 worker 증설과 min: 0 pool의 기동에 최대 5분 지연을 허용하는 대신 shared GitHub token의 정상 상태 조회량을 줄입니다. Organization 전체 repository 스캔은 API 호출량이 repository 수에 비례하므로 운영 pool은 가능한 한 github.repositories를 명시합니다.

github:
  owner: tirosh-chain
  demand_labels:
    - self-hosted
    - tirosh-ubuntu-lite
  runner_scope: organization
  runner_group_id: 1
  work_folder: _work
  runner_os: linux
  runner_architecture: x64

runner_scope: organization은 GitHub organization runner로 worker를 등록합니다. 이 방식은 여러 repository의 queued workflow를 한 pool에서 받을 수 있어 cloud runner pool 기본값으로 둡니다. repository 단위 runner가 필요하면 runner_scope: repositoryregistration_repository를 명시합니다.

pool.vars.ymlbackends 항목은 worker를 만들 backend와 region을 선언합니다. cloud 항목은 worker VM을 만들 때 controller가 사용할 provider별 substrate입니다. 예를 들어 NCloud는 cloud.ncloud.server_image_name, server_spec_code, login_key_name을 두고, AWS는 cloud.aws.ami_id, instance_type, security_group_ids를 둡니다. Proxmox는 cloud.proxmox.api_url, node, template_vmid, storage, snippet_storage, bridge, vmid_start, vmid_end 같은 VM clone substrate를 사용합니다. 사람이 관리하기 어려운 VPC/Subnet/ACG/VMID range 같은 topology 값은 pool.vars.yml에 직접 쓰지 않고, controller 배포 단계에서 OpenTofu output 또는 gitignored github_runner.backend_vars 파일을 통해 pool.yml에 주입합니다. tirosh-home의 기본 private backend vars 경로는 sites/tirosh-home/github-runner/backend.local.yml이고, placeholder 예시는 sites/tirosh-home/github-runner/backend.local.example.yml입니다.

profile의 github_runner.target_name은 기존 long-lived runner 설치/상태 확인 대상입니다. controller service 배포 대상은 github_runner.controller_runtime_target으로 분리합니다. tirosh-home에서는 기존 runner target을 github_runners로 유지하고, controller target은 host alias인 argocd로 둡니다.

controller runtime은 한 프로세스가 여러 worker pool을 순회하고, 각 pool이 지정한 backends 후보로 worker를 생성합니다. 각 pool은 backends, workers.name_prefix, label, dependency group, internal runner_profile, autoscaler 정책, GitHub demand matcher, runner status filter, activity state를 따로 가집니다.

예를 들어 자주 쓰는 Python/TypeScript job은 tirosh-ubuntu-lite pool로 두고, container 기반 browser/E2E job은 tirosh-ubuntu-browser, firmware/Rust/AWS CLI 같은 무거운 도구가 필요한 job은 tirosh-ubuntu-heavy label을 요구하도록 나눕니다. 자주 쓰는 lite pool은 min: 1로 warm worker를 유지하고, heavy/browser pool은 min: 0으로 해당 label을 가진 queued job이 생길 때만 controller가 VM을 만들거나 stopped worker를 시작합니다. Worker 재사용 한도는 lite 10 jobs, heavy 3 jobs, browser 1 job으로 둡니다.

AWS Graviton 기반 native ARM64 CI는 tirosh-ubuntu-arm64 capability pool로 분리합니다. 이 pool은 기존 AWS backend, private worker subnet, security group, IAM profile을 그대로 사용하고, pool별 cloud.aws override로 Canonical Ubuntu 24.04 ARM64 AMI와 t4g.medium, 50 GiB root volume만 지정합니다. min: 0, max: 1, idle_ttl_minutes: 0으로 두어 ARM label의 queued job이 있을 때만 worker를 실행하고 job 완료 후 다음 reconcile에서 stop합니다. 현재 poll interval이 300초이므로 실제 stop까지 최대 약 5분이 걸릴 수 있습니다.

ARM64 job은 runs-on: [self-hosted, tirosh-ubuntu-arm64]를 사용합니다. runs-on: self-hosted만 지정하면 architecture가 보장되지 않습니다. ARM pool은 AMD64 artifact, QEMU emulation, multi-architecture manifest 또는 CI 외 workload를 처리하지 않습니다. 기본 dependency group은 base, hosted이고, native ARM64 container image를 빌드하는 workflow가 생길 때만 docker를 추가합니다. ARM AMI를 갱신할 때는 Canonical Ubuntu EC2 AMI locator에서 ap-northeast-2, Noble 24.04 LTS, arm64, hvm:ebs-ssd-gp3 항목을 확인한 뒤 pool의 ami_id만 변경합니다.

aws-ci-controller-01는 AWS ARM64 pool만 관리합니다. x64 lite/browser/heavy는 ncloud-ci-controller-01 소유이며, AWS controller에 NCloud backend/credential을 넣지 않습니다. Controller 자체에는 CI job을 배정하지 않습니다. 현재 AWS controller는 x86 t3.micro와 2 GiB swap을 사용하고, worker 기본값은 별도의 worker_instance_type으로 t3.medium을 유지합니다. t3.nano는 메모리 여유가 너무 작아 controller 프로세스와 배포 작업의 OOM 위험이 있으므로 운영 최저선으로 사용하지 않습니다. 기존 controller EBS는 축소할 수 없어서 현재 100 GiB를 유지하며, 다음 controller 교체 시에만 더 작은 root volume을 검토합니다.

Controller는 worker가 job lifecycle을 직접 보고할 수 있도록 private worker status API를 열 수 있습니다. MVP에서는 별도 인증 없이 private network 보안 규칙으로만 보호합니다. Cloud foundation은 worker subnet에서 controller TCP 8080으로만 접근을 허용하고, controller systemd command는 아래 옵션으로 API를 시작합니다.

--status-api-bind 0.0.0.0
--status-api-port 8080

Controller 배포 시 OpenTofu controller_private_ip output이 있으면 Ansible이 controller.status_api_urlhttp://<controller_private_ip>:8080으로 렌더링합니다. 직접 지정해야 하는 site는 pool.vars.yml 또는 gitignored backend vars에서 아래처럼 덮어씁니다.

controller:
  status_api_url: http://10.0.1.10:8080

Worker bootstrap은 GitHub Actions runner hook을 설치합니다. Job 시작 시 job_started, job 완료 cleanup 성공 시 job_completed, cleanup 실패 시 cleanup_failed/v1/worker-events로 전송합니다. clean-state-ci만 cleanup 후 여유 공간이 16 GiB 미만이면 low_disk를 추가로 보냅니다. 완료 cleanup은 runner worktree와 미사용 Docker container, network, image, BuildKit cache를 회수하며, container profile은 미사용 volume도 회수합니다. clean-state-ci는 한 번에 한 GitHub job만 받는 전용 cloud worker 계약입니다. Job 완료 때와 runner service가 다음 job을 받기 전 boot ExecStartPre에서 실행 중 container를 포함해 job 잔여 container를 제거하고, 그 다음에 orphan volume/network/image/build cache와 runner work/temp, 전용 temp 경로 /opt/actions-runner/_temp를 비웁니다. Docker daemon이 짧게 대기한 뒤에도 준비되지 않으면 정리를 건너뛰지 않고 cleanup_failed로 보고합니다. /tmp 전체 삭제나 Docker tmpfs는 사용하지 않습니다. GitHub tool cache _tool은 8 GiB를 넘으면 제거하고, 그 아래에서는 유지합니다. 16 GiB 미만 recycle 판정은 clean-state-ci에만 적용합니다. Worktree 정리는 workflow가 만든 root 소유 파일도 제거할 수 있도록 인자와 외부 경로를 받지 않는 root 소유 helper를 사용하고, runner에는 그 helper 한 개만 passwordless sudo로 허용합니다. Controller는 이 값을 activity state에 저장하고, cleanup_failed, low_disk, 또는 job 재사용 한도 초과 worker는 다음 idle tick에서 recycle 대상으로 취급합니다.

Controller VM 자체를 장기 실행 runner로 등록하는 site도 github_runner_cleanup_enabled: true일 때 job 완료 hook으로 미사용 Docker container, network, image와 BuildKit cache를 회수합니다. 전용 CI runner의 실행 중 container와 사용 중 volume은 이 cleanup 대상에 포함되지 않습니다.

github_runner_cleanup_cron_enabled: true인 장기 실행 runner는 job hook 실패에 대비해 github_runner_cleanup_cron_schedule 주기로 오래된 미사용 image와 BuildKit cache를 추가 정리합니다. 기본 보존 기간은 github_runner_cleanup_retention으로 지정하며, 동시 실행은 flock으로 방지합니다.

Workflow는 provider/site label을 요구하지 않습니다. 사용자는 필요한 toolchain capability만 선택하고, 어떤 cloud provider가 worker를 공급할지는 runner pool 운영 정책으로 숨깁니다. 예를 들어 lite job은 [self-hosted, tirosh-ubuntu-lite]를 사용합니다.

2-1. Workflow runner label 선택 가이드

Workflow 작성자는 기본적으로 tirosh-ubuntu-lite를 사용합니다. browserheavy는 job이 명확한 추가 capability를 요구할 때만 선택합니다. Label은 "어떤 cloud에서 돌릴지"가 아니라 "이 job이 runner host에 어떤 capability를 요구하는지"로 판단합니다.

Job 조건 선택 label 판단 근거
Python/TypeScript/Go 같은 일반 application lint, unit test, typecheck tirosh-ubuntu-lite 기본 package manager와 Docker 정도면 충분합니다. 가장 자주 쓰는 빠른 feedback path입니다.
일반 container image build/push tirosh-ubuntu-lite Docker build가 필요하지만 host toolchain은 크지 않습니다.
Make/uv/npm/pnpm/pip 기반 package build tirosh-ubuntu-lite 일반 build dependency는 job 내부 setup step에서 해결합니다.
Kubernetes/Argo/Nexus 같은 가벼운 infra smoke tirosh-ubuntu-lite CLI 호출과 API smoke 중심이면 heavy runner가 필요 없습니다.
Playwright/Cypress를 container image 안에서 실행 tirosh-ubuntu-browser Browser binary와 OS dependency를 host가 아니라 container image가 제공합니다.
Browser smoke/E2E가 Docker service나 container network를 요구 tirosh-ubuntu-browser Host에는 Docker runtime만 기대하고 browser stack은 container에 격리합니다.
Host에 browser binary를 직접 설치해야 함 우선 job 구조 재검토, 필요 시 tirosh-ubuntu-heavy 현재 browser pool은 host browser 설치용 profile이 아닙니다. 가능하면 container 기반으로 바꿉니다.
Rust toolchain, firmware toolchain, Zephyr/west, ARM GCC가 host에 필요 tirosh-ubuntu-heavy 설치 비용과 상태 오염 가능성이 큰 host toolchain 작업입니다.
AWS CLI나 cloud CLI를 host toolchain으로 전제하는 긴 infra/package 작업 tirosh-ubuntu-heavy 단순 API smoke가 아니라 host CLI/toolchain baseline을 요구합니다.
긴 integration test, 큰 dependency cache, CPU/memory를 많이 쓰는 작업 tirosh-ubuntu-heavy lite feedback path를 막지 않도록 분리합니다.
단일 workflow 안에 lint, unit, e2e, firmware build가 섞여 있음 job을 나누고 각 job에 별도 label label은 workflow 단위가 아니라 job 단위로 선택합니다.

선택 규칙은 아래처럼 둡니다.

  1. 먼저 tirosh-ubuntu-lite로 실행 가능한지 본다.
  2. Job이 browser를 사용하면 host 설치인지 container 기반인지 먼저 확인한다. Container 기반이면 tirosh-ubuntu-browser를 쓴다.
  3. Host에 큰 toolchain을 설치해야 하거나, container 밖의 system package/toolchain에 의존하면 tirosh-ubuntu-heavy를 쓴다.
  4. Provider 이름(ncloud-ci, aws-ci)은 workflow runs-on에 넣지 않는다. Provider 선택은 pool 운영 정책이 결정한다.
  5. 하나의 job이 여러 성격을 섞고 있다면 job을 나눈다. 예를 들어 lint/unitlite, e2ebrowser, firmware build는 heavy로 분리한다.

판단이 애매하면 아래 질문 순서로 결정합니다.

1. Docker, language runtime, package manager 정도면 충분한가?
   yes -> tirosh-ubuntu-lite
   no  -> 2로 이동

2. browser가 필요한가?
   yes, browser/dependency가 container image 안에 있음 -> tirosh-ubuntu-browser
   yes, host browser 설치가 필요함 -> job을 container 기반으로 바꾸거나 heavy 후보로 검토
   no  -> 3로 이동

3. firmware/Rust/cloud CLI/대형 system toolchain이 host에 필요한가?
   yes -> tirosh-ubuntu-heavy
   no  -> tirosh-ubuntu-lite로 시작

예시는 아래와 같습니다.

jobs:
  unit:
    runs-on: [self-hosted, tirosh-ubuntu-lite]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e:
    runs-on: [self-hosted, tirosh-ubuntu-browser]
    container:
      image: mcr.microsoft.com/playwright:v1.53.0-noble
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test

  firmware:
    runs-on: [self-hosted, tirosh-ubuntu-heavy]
    steps:
      - uses: actions/checkout@v4
      - run: west build -b native_sim app

pool별 backends는 ordered placement 후보입니다. Cloud site의 운영 배치는 provider-local입니다. aws-ciarm64/arm64-nightly -> [aws], ncloud-cilite/browser/heavy -> [ncloud]입니다. tirosh-home on-prem controller config는 여전히 여러 backend 후보를 가질 수 있지만, cloud site는 다른 provider worker를 소유하지 않습니다. 첫 backend의 substrate나 credential이 아직 준비되지 않았거나 생성이 실패하면 다음 backend를 시도합니다. 같은 GitHub organization queue를 보는 cloud controller는 demand label과 worker name_prefix가 겹치지 않을 때만 동시에 켭니다.

운영 cloud site 예시는 아래처럼 provider별로 나눕니다.

# sites/aws-ci/github-runner/pool.vars.yml
backends:
  aws:
    region: ap-northeast-2
pools:
  - name: arm64
    backends: [aws]
    workers:
      name_prefix: awsarm
  - name: arm64-nightly
    backends: [aws]
    workers:
      name_prefix: awsnight
# sites/ncloud-ci/github-runner/pool.vars.yml
backends:
  ncloud:
    region: KR
pools:
  - name: lite
    backends: [ncloud]
    workers:
      name_prefix: nclite
  - name: browser
    backends: [ncloud]
    workers:
      name_prefix: ncbrw
  - name: heavy
    backends: [ncloud]
    workers:
      name_prefix: ncheavy

tirosh-home on-prem controller는 여러 backend 후보를 남길 수 있습니다. 이 형태는 cloud site ownership 계약이 아닙니다.

backends:
  aws:
    region: ap-northeast-2
  ncloud:
    region: KR
  proxmox:
    region: tirosh-home
pools:
  - name: lite
    backends: [aws, ncloud]
    workers:
      name_prefix: lite
      min: 1
      max: 3
      runner_profile: standard-ci
      dependency_groups: [base, docker, docker-compose, hosted]
      labels: [self-hosted, linux, x64, tirosh-ubuntu-lite]
    github:
      demand_labels: [self-hosted, tirosh-ubuntu-lite]
  - name: browser
    backends: [ncloud]
    workers:
      name_prefix: browser
      min: 0
      max: 1
      runner_profile: container-ci
      dependency_groups: [base, docker, docker-compose, hosted]
      labels: [self-hosted, linux, x64, tirosh-ubuntu-browser]
    github:
      demand_labels: [self-hosted, tirosh-ubuntu-browser]
  - name: heavy
    backends: [ncloud]
    workers:
      name_prefix: heavy
      min: 0
      max: 2
      runner_profile: host-toolchain-ci
      dependency_groups:
        - base
        - docker
        - docker-compose
        - hosted
        - firmware
        - west
        - aws-cli
        - rustup
      labels: [self-hosted, linux, x64, tirosh-ubuntu-heavy]
    github:
      demand_labels: [self-hosted, tirosh-ubuntu-heavy]

OpenTofu output 이름은 worker substrate 계약입니다. 예전 output 이름으로 우회하지 않으며, 아래 값이 없으면 Make target은 실패합니다.

따라서 controller service를 시작하기 전에 pools[].backends에 들어 있는 backend의 cloud foundation이 plan/apply, refresh, 또는 private backend vars 입력을 통해 substrate를 제공해야 합니다. aws-ci는 AWS worker subnet/security group/AMI만 필요하고, ncloud-ci는 NCloud VPC/subnet/ACG/image만 필요합니다. 한 cloud site의 pool.yml 렌더링이 다른 provider output이나 credential에 의존하지 않게 둡니다. Proxmox를 on-prem fallback으로 추가하는 시점에는 Proxmox API endpoint/node/template/VMID range도 함께 제공해야 합니다.

Provider 필수 output
NCloud vpc_no, worker_subnet_no, worker_access_control_group_no, worker_server_image_no
AWS worker_subnet_id, worker_security_group_id, worker_ami_id, worker_instance_type
Proxmox private backend.local.ymlcloud.proxmox.api_url, node, template_vmid, storage, snippet_storage, bridge, vmid_start, vmid_end

AWS의 worker_iam_instance_profile은 worker에 instance profile을 붙일 때만 사용하는 선택 output입니다.

Worker apply 시 controller는 per-worker bootstrap init script를 만들고, cloud backend에 전달합니다. AWS는 user-data, NCloud는 init script, Proxmox는 cloud-init user-data snippet과 cicustom을 사용합니다. Bootstrap은 GitHub runner, Docker, Docker Compose와 함께 CI baseline command인 cmake, ninja를 준비합니다. workers.ephemeral: true이면 GitHub JIT config를 사용해서 job 1개 처리 후 runner 등록이 제거됩니다. workers.ephemeral: false이면 registration token으로 persistent runner를 등록해서 VM이 stop/start를 반복해도 여러 job을 받을 수 있습니다. Warm worker 재사용을 위해 기본 pool은 persistent runner를 사용합니다. 실제 service command는 아래 옵션을 포함해야 합니다.

github-runner-controller run \
  --cloud-source config \
  --demand-source github \
  --bootstrap-source github-jit \
  --runner-status-source github \
  --config /etc/tirosh/github-runner/pool.yml \
  --secrets-dir /etc/tirosh/github-runner/secrets \
  --activity-state /var/lib/tirosh/github-runner-controller/activity-state.json

Cloud worker는 workers.min: 0이면 controller VM만 상시 실행하고, GitHub queue demand가 생길 때 stopped worker를 먼저 start합니다. 재사용할 stopped worker가 없고 workers.max에 여유가 있을 때만 새 worker VM을 생성합니다. persistent worker는 기본 7분의 idle_ttl_minutes 동안 추가 job을 받을 수 있고, TTL 이후 controller가 VM을 terminate하지 않고 stop합니다. TTL 기준 시간은 VM 생성 시간이 아니라 job 완료 시점입니다. 새 worker가 package 설치와 runner 등록을 끝내기 전까지는 startup_grace_minutes 동안 pending capacity로 봅니다. NCloud 기본값은 idle_ttl_minutes: 7, startup_grace_minutes: 15입니다. controller는 이전 tick의 GitHub runner busy 상태를 --activity-state로 지정한 파일에 저장하고, busy: true에서 busy: false로 바뀐 시점을 job 완료 시점으로 기록합니다. data volume을 사용하는 controller는 해당 volume의 state directory를 지정해야 합니다. busy: true인 worker는 TTL과 관계없이 stop/delete하지 않습니다. ephemeral worker는 job 1개 처리 후 GitHub runner 등록이 제거되므로, VM 재사용이 필요한 pool에는 적합하지 않습니다.

Running 상태지만 GitHub에 등록되지 않은 worker가 startup_grace_minutes를 넘기면 bootstrap 실패로 간주합니다. Controller는 해당 worker를 삭제 대상으로 만들고 pool capacity 계산에서는 제외하여, 수요가 있으면 같은 tick에 replacement worker를 생성할 수 있게 합니다. JIT registration token은 worker 생성 시점에 발급되므로 오래된 미등록 VM을 그대로 재등록하지 않습니다.

NCloud의 FSTOP, SD_FL, RS_FL, ST_FL 상태는 진행 중인 start/stop operation으로 보지 않고 provider failure로 격리합니다. 실패 VM은 reusable capacity에서는 빠지지만, 아직 provider에 남아 있는 물리 VM은 workers.max 안전 상한에 포함됩니다. 이 상한에 도달하면 create는 fail-closed로 막고 정상 online/busy worker를 stop/delete하지 않습니다. controller는 stop 재시도 없이 public IP와 non-root block storage를 먼저 정리한 뒤 terminate를 시도합니다. NCloud가 실패 VM에서 operation lock 3001008로 storage cleanup을 거부하면 공개 API만으로 풀리지 않는 console Force stop 상태로 분류하고, 같은 destructive API를 매 tick 호출하지 않습니다. 근거는 NCloud 실패 VM + 3001008이 기존 cleanup 경로에서 이미 console Force stop 필요로 확정되어 있다는 점입니다. 그 외 delete 오류는 bounded retry/backoff 후 manual_intervention_required로 전환합니다. 콘솔 Force stop 뒤에 provider 상태가 failed에서 벗어나면 cleanup을 다시 시도합니다. 공개 API만으로 회수할 수 없는 경우 NCloud 콘솔의 Force stop/Force terminate 또는 지원 절차로 정리해야 합니다. 반대로 serverInstanceOperation=START인 worker는 GitHub runner가 아직 offline이어도 pending capacity로 유지하여 start 도중 stop/delete 요청이 충돌하지 않게 합니다.

Persistent worker recycle은 단계적으로 실행됩니다. Running VM에 대한 첫 delete tick은 stop만 요청하고 GitHub runner 등록을 유지합니다. 다음 tick에서 cloud가 stopped 상태를 확인하고 terminate를 접수한 뒤에만 GitHub 등록을 삭제합니다. Controller 상태 출력은 삭제 대상으로 선정됐지만 provider가 아직 terminating 상태를 보고하지 않은 worker에 awaiting_termination: true를 표시하고, 같은 이름을 awaiting_termination_worker_names에도 노출합니다. 한 worker의 lifecycle API가 실패해도 같은 plan의 다른 worker 작업은 계속 시도하고, 실패 목록은 tick 마지막에 하나의 오류로 보고합니다.

Stopped worker의 GitHub busy 값은 stop 요청과 job 배정 사이의 race 때문에 일시적으로 stale할 수 있습니다. max_jobs_per_worker를 이미 초과했거나 recycle intent가 기록된 stopped worker는 이 busy 값과 관계없이 재시작하지 않고 종료 대상으로 유지합니다.

Persistent worker는 VM을 재사용하므로 bootstrap 단계에서 GitHub Actions ACTIONS_RUNNER_HOOK_JOB_COMPLETED hook을 등록합니다. 이 cleanup은 workflow 사용자가 별도 step을 작성하지 않아도 job이 끝날 때마다 best-effort로 실행됩니다. runner_profile: standard-ci는 workspace/temp와 dangling Docker resource를 정리하고, container-ci는 container/volume/network 정리를 더 강하게 수행하며, host-toolchain-ci는 Rust/firmware 같은 toolchain cache를 살리기 위해 workspace 중심으로 정리합니다. clean-state-ci는 한 job만 실행하는 cloud worker에서 실행 중 container와 사용 중 volume까지 회수하고, EC2 EBS가 stop/start 사이에 남는 상태를 boot ExecStartPre에서도 같은 정책으로 비웁니다. 이 profile은 운영 내부 계약이며 workflow의 runs-on label에는 드러내지 않습니다. AWS ARM warm pool(tirosh-ubuntu-arm64)과 nightly pool은 clean-state-ci를 사용합니다. 50 GiB root volume의 반복 실패 원인은 disk 크기보다 job 잔여 Docker state이므로, cleanup이 근본 조치입니다.

Worker 내부 cleanup 결과는 cloud API만으로 알 수 없습니다. 따라서 다음 단계의 상태 관리는 Kubernetes node/kubelet 모델을 작게 가져옵니다. Worker VM은 controller VM의 private status API로 heartbeat와 job lifecycle event만 보고하고, controller가 이 상태를 reconcile loop에서 해석합니다. Worker는 cloud lifecycle 권한을 갖지 않으며 VM stop/delete/create는 계속 controller의 cloud adapter만 수행합니다.

초기 API는 control API가 아니라 status ingest API로 제한합니다.

GET  /healthz
POST /v1/worker-events
PUT  /v1/worker-leases/{worker_name}

worker-events는 job hook이나 cleanup hook에서 발생한 이벤트를 기록합니다.

{
  "worker_name": "nclite-20260625120000-abc123",
  "pool": "lite",
  "event": "cleanup_failed",
  "job_id": "123456789",
  "run_id": "987654321",
  "occurred_at": "2026-06-25T12:00:00Z",
  "message": "docker prune failed"
}

worker-leases는 worker가 주기적으로 살아있고 재사용 가능한지 알리는 가벼운 heartbeat입니다.

{
  "worker_name": "nclite-20260625120000-abc123",
  "pool": "lite",
  "status": "ready",
  "busy": false,
  "cleanup_ok": true,
  "observed_at": "2026-06-25T12:00:00Z"
}

controller activity state에는 이 보고를 아래 필드로 저장합니다.

worker_name
pool
backend
last_heartbeat_at
last_job_started_at
last_job_completed_at
job_count
cleanup_status
recycle_requested

cleanup_failed 또는 low_disk가 보고되면 controller는 해당 worker를 reusable capacity에서 제외하고 recycle_requested=true로 표시합니다. 다음 reconcile tick에서 해당 worker는 start 후보가 아니라 delete/recycle 대상으로만 다룹니다. GitHub completed hook은 job이 완전히 끝나기 전에 실행되므로 hook 안에서 VM을 직접 삭제하지 않습니다.

worker package는 처음부터 만들지 않습니다. 초기 구현은 bootstrap이 설치하는 shell hook에서 curl로 status API를 호출하는 방식으로 시작합니다. 별도 tirosh-runner-agent 패키지는 heartbeat retry/backoff, structured log, local health probe, Docker/cache 상태 측정, cleanup 로직 분리가 필요해진 뒤 도입합니다. 이 경우 VM bootstrap 의존성을 줄이기 위해 Go/Rust 단일 바이너리를 우선 검토합니다.

status API는 worker private subnet에서만 접근 가능해야 합니다. controller security group 또는 NCloud ACG는 worker subnet inbound만 허용하고, worker는 bootstrap 때 받은 pool/worker token으로 Authorization: Bearer ...를 보냅니다. 초기 단계에서는 private network allowlist와 bearer token을 기본 보안 경계로 두고, 여러 controller나 외부 worker를 허용해야 하는 시점에 mTLS를 검토합니다.

이 방향은 아래 레퍼런스를 기준으로 잡았습니다.

레퍼런스 반영한 판단
Kubernetes Node Status worker가 무거운 status와 가벼운 lease/heartbeat를 controller에 보고
Kubernetes Probes liveness/readiness/startup 상태를 분리해서 recycle 판단에 사용
Kubernetes Controllers worker 보고는 actual state이고, lifecycle 변경은 controller reconcile 책임
Buildkite Agent Hooks agent/job lifecycle hook으로 job 전후 cleanup과 상태 보고를 수행
GitHub runner job hooks ACTIONS_RUNNER_HOOK_JOB_COMPLETED는 cleanup/status reporting에만 사용하고 VM 삭제에는 사용하지 않음

Cloud runner autoscaling은 즉시 병렬성을 최대화하는 방향보다 비용을 먼저 줄이는 방향으로 둡니다. GitHub queue에 job이 있다는 사실만으로 같은 수의 worker VM을 바로 만들면 짧은 queue spike에도 VM 생성 비용과 stopped storage 비용이 누적됩니다. 기본 정책은 stopped worker를 먼저 start하고, 새 VM 생성은 tick당 제한하며, queue가 일정 시간 지속될 때만 두 번째/세 번째 worker를 늘리는 절약 모드입니다.

권장 정책값은 아래처럼 해석합니다.

항목 권장 방향 이유
workers.min 0 평소에는 controller VM만 유지
workers.max 작게 시작 cloud 비용 상한을 명확히 유지
idle_ttl_minutes 7 job 완료 직후 재사용 여지를 주되 오래 켜두지 않음
startup_grace_minutes 15 bootstrap/package 설치 중 worker를 pending capacity로 인정
scale_out_delay_minutes queue 지속 시 확장 순간적인 queue spike에 VM을 만들지 않음
capacity_steps queue 구간별 worker 수 job 1개당 VM 1대 생성 방지
stopped_retention_hours 별도 cleanup stopped VM도 storage 비용이 발생하므로 TTL과 분리
warm_pool_min provisioned worker 하한 active는 0대로 내리되 재시작 가능한 stopped VM 수를 보장

현재 cloud runner pool의 기본 autoscaler 정책은 아래 형태입니다.

autoscaler:
  idle_ttl_minutes: 7
  startup_grace_minutes: 15
  max_create_per_tick: 1
  scale_out_delay_minutes: 3
  stopped_retention_hours: 6
  warm_pool_min: 2
  capacity_steps:
    - queued_jobs: 1
      workers: 1
    - queued_jobs: 4
      workers: 2
    - queued_jobs: 9
      workers: 3

warm_pool_min은 실행 중인 worker 하한이 아니라 provider에 남겨 둘 재사용 가능 worker의 총수 하한입니다. 따라서 scale-to-zero pool은 workers.min: 0을 유지하면서 warm_pool_min: 2를 설정합니다. Controller는 max_create_per_tick 범위에서 하한까지 VM을 보충하고, 수요가 없으면 일반 idle TTL 정책으로 VM을 정지시킵니다. Retention이 만료되어도 하한 아래의 stopped VM은 삭제하지 않습니다. 실패, 미등록, recycle 대상은 하한에 포함하지 않고 교체하며, warm_pool_minworkers.max 이하여야 합니다. 이 정책은 workers.ephemeral: false인 persistent pool에서만 사용할 수 있습니다. Stopped VM도 disk/storage 비용은 계속 발생합니다.

GitHub의 Offline은 등록이 삭제됐다는 뜻이 아니라 stopped VM의 runner process가 연결되지 않은 상태입니다. Controller는 GitHub registration 존재 여부와 online 여부를 별도 증거로 사용합니다. 등록된 warm runner는 아직 job/heartbeat 이력이 없더라도 stopped 상태에서 보존하며, registration 자체가 없는 stopped VM만 startup grace 이후 실패한 bootstrap 후보로 정리합니다.

autoscaler.enabled: false는 해당 pool의 cloud worker 및 GitHub runner registration 변경을 멈추는 운영 pause입니다. Controller는 demand와 provider/GitHub 상태를 계속 읽고 would-be reconcile plan을 출력하지만 create/start/stop/delete와 bootstrap 준비는 실행하지 않습니다. 이때 tick 로그는 mutations_enabled: false, mutation_suppressed_reason: autoscaler_disabled, reconcile.dry_run: true를 기록합니다. CLI --dry-run과 달리 운영 pause 중에는 관측된 activity state를 계속 저장합니다.

Controller는 lifecycle mutation 전에 create/start intent를 pool별 lifecycle-ledger.<pool>.json sidecar에 먼저 저장합니다. Sidecar schema v3는 delete attempt count, last error code/time, next retry, manual intervention 상태와 마지막 provider phase를 추가로 저장합니다. v1/v2 sidecar는 새 필드를 비운 상태로 호환 로드하고, 저장은 항상 현재 schema로 올립니다. 더 높은 schema는 거부하므로 미래 포맷을 구버전이 오해하지 않습니다. 기존 activity-state.<pool>.json 형식과 파일은 그대로 유지하므로 새 controller에서 구버전으로 롤백해도 lifecycle metadata 때문에 기존 activity state가 깨지지 않습니다. Worker event와 reconcile은 pool별 revision으로 조정되며, provider와 GitHub API 호출 중에는 activity lock을 잡지 않습니다. 한 worker의 delete 실패는 같은 tick의 다른 worker 작업과 activity 저장을 막지 않으며, delete 결과는 별도 lifecycle persist로 남깁니다. dry-run/status JSON은 create_blocked, create_blocked_reason, managed_physical_count, manual_intervention_workers와 각 worker의 retry/manual 필드를 출력합니다. 아직 provider에서 확인되지 않은 create intent는 startup_grace 동안 pending capacity로 취급해 중복 VM 생성을 막고, 기한이 지나면 새 create를 허용합니다. Activity 파일 저장 후 ledger 저장이 실패하는 부분 저장도 외부 mutation 전에 중단되며, 실패 로그의 activity_state_persistedlifecycle_ledger_persisted가 각각 실제 저장 결과를 표시합니다. 다음 tick 또는 재시작에서는 두 파일을 다시 관측 상태와 조정합니다.

Warm worker를 다시 시작한 뒤 provider가 RUN으로 바뀌었지만 GitHub runner가 아직 online이 아닌 구간은 VM의 오래된 created_at 대신 provider RUN 최초 관측 시각으로 startup_grace를 계산합니다. 따라서 느린 provider START가 bootstrap 시간을 소모하지 않습니다. Provider START 상태도 무기한 보호하지 않으며, start intent 또는 START 최초 관측 시각에서 2 * startup_grace가 지나면 stuck startup으로 판정해 삭제 및 replacement 대상으로 전환합니다. 기존 controller에서 처음 관측한 legacy START/RUN worker는 관측 시각을 먼저 저장한 뒤 deadline을 적용합니다. GitHub online 증거가 확인되면 해당 lifecycle entry는 정리됩니다.

queued_jobs 1-3개에서는 worker 1대, 4-8개가 3분 지속되면 2대, 9개 이상이 3분 지속되면 3대까지 허용합니다. 이 값은 즉시 처리 속도보다 월 비용 예측 가능성을 우선하는 기본값이며, release 직전처럼 queue 대기가 더 비싼 상황에서는 site별 pool.vars.yml에서 더 공격적으로 조정합니다.

이 정책은 아래 레퍼런스를 기준으로 잡았습니다.

레퍼런스 반영한 판단
GitHub Actions Runner Controller GitHub demand 감시와 runner capacity 조절을 controller 책임으로 분리
KEDA ScaledObject scale-to-zero와 실제 scale-out threshold를 분리
Kubernetes HPA behavior scale policy, stabilization window, tolerance로 flapping 방지
Kubernetes Cluster Autoscaler pending workload age, scale-down unneeded time, scale-down delay 개념을 worker VM에 맞게 적용

Worker VM에 Public IP가 반드시 필요한 것은 아닙니다. 다만 runner 프로세스는 worker VM 안에서 GitHub, package registry, Nexus, container registry로 outbound 연결을 직접 만들어야 합니다. private subnet에 NAT Gateway나 egress proxy가 없으면 worker에 Public IP를 붙여야 bootstrap과 job 실행이 안정적으로 동작합니다. controller VM이 worker 대신 GitHub job을 처리해 주지는 못합니다.

NCloud worker는 base OS root disk가 작을 수 있으므로 controller가 worker 생성 직후 별도 data volume을 붙입니다. Worker bootstrap은 이 volume을 기다렸다가 필요하면 포맷하고, /var/lib/docker, /opt/actions-runner, /home/runner, /opt/ms-playwright만 data volume에 bind mount합니다. /usr 전체 overlay는 STOP→START 때 systemd Booting/FSTOP을 만들 수 있어 사용하지 않습니다. OS /tmp/var/tmp도 overlay하지 않으며, 재부팅이 /tmp를 비운다고 가정하지 않습니다. Job temp는 data volume 위의 /opt/actions-runner/_temp를 쓰고, clean-state-ci는 이 경로를 정리하지만 /tmp 전체를 지우지 않습니다. bind mount는 fstab에 x-systemd.requires-mounts-for=/mnt/runner-data만 넣고, docker drop-in과 github-runner unit은 절대 경로 RequiresMountsFor를 씁니다. systemd.unit 계약에서 RequiresMountsFor는 해당 경로 mount unit에 대한 Requires=After=를 자동 생성하므로, path component의 -\x2d로 escape되는 수동 *.mount 이름은 쓰지 않습니다. github-runner unit의 After=에는 docker.service 순서만 유지합니다. Cleanup hook 로그는 runner가 쓸 수 있는 /opt/actions-runner/_diag/tirosh-cleanup.log에 기록합니다.

NCloud heavy apt toolchain(build-essential, gcc-arm-none-eabi, nodejs 등)은 root /usr에 남습니다. 50 GiB root는 OS와 이 toolchain을 담을 여유는 있어 보이지만, job이 root에 큰 extra package를 설치하거나 /var/cache/apt가 다시 커지면 root가 부족해질 수 있습니다. 이 경우 /usr overlay가 아니라 /usr/local 또는 /opt/tirosh-toolchain 같은 좁은 경로만 data volume으로 옮기는 후속 작업을 검토합니다.

NCloud worker 삭제는 idle TTL 경로에서는 수행하지 않습니다. 명시적인 retention cleanup을 나중에 추가할 때만 stopped worker를 terminateServerInstances로 종료하고, Public IP 삭제는 응답의 associated server id가 대상 worker id와 명확히 일치할 때만 수행해야 합니다. 이렇게 해야 worker cleanup이 controller VM의 Public IP를 건드리지 않습니다.

GitHub에는 VM이 이미 사라졌는데 runner 등록만 offline으로 남는 경우가 있습니다. controller는 runner-status-source=github일 때 cloud worker 목록에 없는 offline runner registration을 orphan으로 보고 삭제 대상에 포함합니다. --dry-run에서는 삭제하지 않고 orphan_runner_names에만 표시합니다.

OpenTofu destroy 경로에서는 controller가 먼저 내려갈 수 있으므로 controller orphan cleanup에 의존하지 않습니다. github-runner/pool/destroyinfra-tools github-runner cleanup --apply를 먼저 실행해서 site에 속한 controller/worker runner registration을 정리하고, 그 다음 cloud resource를 제거합니다.

AWS worker apply도 같은 bootstrap 흐름을 사용합니다. 차이는 bootstrap script를 EC2 user data로 전달한다는 점입니다. EC2 user data는 SDK가 base64로 인코딩하기 전 raw UTF-8 기준 16 KiB 상한이 있으므로, AWS adapter는 실제 payload가 16,384 bytes를 넘으면 RunInstances 호출 전에 worker 이름, 실제 크기, 상한을 포함해 fail-closed합니다. ASCII-only인 NCloud init script 제약을 AWS에 적용하지 않고 UTF-8 byte 길이로 판단합니다. 따라서 AWS worker도 GitHub runner, Docker, Docker Compose, cmake, ninja baseline을 동일하게 받습니다. AWS credential은 controller VM의 IAM instance profile 또는 AWS SDK 기본 credential chain으로 해석합니다. 운영 경로에서는 static AWS access key를 controller secret directory에 두는 것보다 worker lifecycle 권한을 가진 IAM instance profile을 controller VM에 붙이는 방식을 우선합니다.

AWS controller IAM role/profile도 OpenTofu로 관리합니다. 따라서 AWS_PROFILE이 가리키는 IaC deployer는 VPC/EC2 권한 외에 최소한 아래 IAM 권한을 가져야 합니다.

iam:CreateRole
iam:GetRole
iam:ListRolePolicies
iam:ListInstanceProfilesForRole
iam:PutRolePolicy
iam:DeleteRolePolicy
iam:DeleteRole
iam:CreateInstanceProfile
iam:GetInstanceProfile
iam:AddRoleToInstanceProfile
iam:RemoveRoleFromInstanceProfile
iam:DeleteInstanceProfile
iam:PassRole
ec2:AssociateIamInstanceProfile
ec2:ReplaceIamInstanceProfileAssociation
ec2:DescribeIamInstanceProfileAssociations

권한이 부족하면 OpenTofu가 role을 생성한 직후 provider refresh 단계에서 실패할 수 있습니다. 예를 들어 iam:ListRolePolicies가 없으면 aws_iam_role.controller 상태를 읽지 못해 이후 pool/plan도 실패합니다. 이 경우 deployer policy에 누락 권한을 추가한 뒤 make github-runner/pool/plan SITE=aws-ci를 다시 실행합니다.

destroy 경로에서는 role에 연결된 instance profile을 조회하고 분리해야 하므로 아래 권한도 필요합니다.

iam:ListInstanceProfilesForRole
iam:RemoveRoleFromInstanceProfile
iam:DeleteInstanceProfile
iam:DeleteRolePolicy
iam:DeleteRole

AWS 콘솔에서 tirosh-infra-deployer user의 identity-based policy에 이 action을 추가합니다. resource scope는 현재 OpenTofu가 만든 role인 arn:aws:iam::152334826073:role/aws-ci-controller-01-controller와 instance profile인 arn:aws:iam::152334826073:instance-profile/aws-ci-controller-01-controller를 포함해야 합니다.

이미 VPC/EC2 권한은 있고 IAM role/profile 단계만 막힌 상황이라면 sites/aws-ci/cloud/deployer-iam-policy.json의 policy를 deployer user에 추가합니다. iam:PassRole은 controller EC2에 role을 붙이는 데 필요하고, ec2:*IamInstanceProfile* 권한은 생성된 instance profile을 기존 controller instance에 연결하는 데 필요합니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ManageAwsCiControllerIamRole",
      "Effect": "Allow",
      "Action": [
        "iam:GetRole",
        "iam:CreateRole",
        "iam:ListRolePolicies",
        "iam:ListInstanceProfilesForRole",
        "iam:PutRolePolicy",
        "iam:DeleteRolePolicy",
        "iam:DeleteRole",
        "iam:CreateInstanceProfile",
        "iam:TagInstanceProfile",
        "iam:GetInstanceProfile",
        "iam:AddRoleToInstanceProfile",
        "iam:RemoveRoleFromInstanceProfile",
        "iam:DeleteInstanceProfile"
      ],
      "Resource": [
        "arn:aws:iam::152334826073:role/aws-ci-controller-01-controller",
        "arn:aws:iam::152334826073:instance-profile/aws-ci-controller-01-controller"
      ]
    },
    {
      "Sid": "PassAwsCiControllerRole",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::152334826073:role/aws-ci-controller-01-controller",
      "Condition": {
        "StringEquals": {
          "iam:PassedToService": "ec2.amazonaws.com"
        }
      }
    },
    {
      "Sid": "AttachAwsCiControllerInstanceProfile",
      "Effect": "Allow",
      "Action": [
        "ec2:AssociateIamInstanceProfile",
        "ec2:ReplaceIamInstanceProfileAssociation",
        "ec2:DescribeIamInstanceProfileAssociations"
      ],
      "Resource": "*"
    }
  ]
}
github-runner-controller run \
  --cloud-source config \
  --demand-source github \
  --bootstrap-source github-jit \
  --runner-status-source github \
  --config /etc/tirosh/github-runner/pool.yml \
  --secrets-dir /etc/tirosh/github-runner/secrets

controller runtime만 별도로 점검하거나 배포할 수 있습니다.

make github-runner/controller/plan SITE=ncloud-ci
make github-runner/controller/apply SITE=ncloud-ci
make github-runner/controller/status SITE=ncloud-ci

controller/status는 단일 pool의 activity-state.json과 multi-pool의 activity-state.<pool>.json을 모두 열거하고, controller VM 내부에서 GET http://127.0.0.1:8080/healthz를 read-only로 확인합니다. State file이나 status API가 아직 없으면 status 실행 자체를 실패시키지 않고 구조화된 필드로 상태를 보고합니다. State 진단은 directory_exists, scan_ok, present, absent_reason, scan_error를 사용하고, health 진단은 healthy, http_status, response, error를 사용합니다. Lifecycle sidecar는 lifecycle-ledger.json 또는 lifecycle-ledger.<pool>.json으로 별도 열거됩니다. 아직 새 controller가 한 번도 실행되지 않은 환경에서는 ledger가 없다는 사실만 보고하며 status 자체는 실패하지 않습니다.

GitHub token 준비와 runner 등록을 분리해야 할 때는 cloud foundation만 먼저 적용할 수 있습니다.

make github-runner/pool/cloud/apply SITE=ncloud-ci

controller 상태와 cloud 상태가 어긋나 managed worker를 정리해야 할 때는 Make target을 우선 사용합니다. controller VM 내부에서 직접 확인해야 한다면 controller CLI를 사용할 수 있습니다. 먼저 대상만 확인합니다.

github-runner-controller cleanup-workers \
  --dry-run \
  --config /etc/tirosh/github-runner/pool.yml \
  --secrets-dir /etc/tirosh/github-runner/secrets

대상이 맞으면 명시적으로 apply 합니다.

github-runner-controller cleanup-workers \
  --apply \
  --config /etc/tirosh/github-runner/pool.yml \
  --secrets-dir /etc/tirosh/github-runner/secrets

cleanup은 일반 reconcile과 같은 provider 삭제 정책을 사용합니다. NCloud worker가 running 상태면 먼저 stop만 수행하고, stopped 상태가 된 뒤 다음 pass에서 public IP, non-root block storage, server 순서로 정리합니다.

2-1. NCloud IaC 경계

NCloud runner pool은 sites/ncloud-ci/cloud/vars.tfvars에서 기존 cloud resource 번호를 받지 않습니다. IaC용 API key가 준비된 뒤에는 OpenTofu가 runner pool foundation을 생성합니다.

NCloud API credential
  |
  v
OpenTofu
  VPC
  Subnet
  Access Control Group
  Access Control Group Rule
  Network Interface
  Init Script
  Login Key
  Server
  Public IP

vars.tfvars에는 vpc_cidr, subnet_cidr, zone, server_image_name, server_spec_code처럼 만들 리소스의 의도를 둡니다. subnet_no, access_control_group_no_list 같은 provider resource 번호를 사람이 콘솔에서 찾아 입력하는 흐름은 기본 운영 경로가 아닙니다.

NCloud login key는 OpenTofu로 생성할 수 있지만 provider state에 private key material이 남을 수 있습니다. 이 repo는 SSH 접속 자체를 login key에 의존하지 않고, init script가 local SSH public key를 controller VM의 admin_user에 등록합니다. 기본값은 ~/.ssh/id_ed25519.pub입니다.

NCloud controller는 초기 bootstrap 편의를 위해 public IP와 SSH ingress를 사용할 수 있습니다. GitHub Actions runner 자체는 inbound 연결이 필요 없고, SSH는 Ansible 운영용입니다. SSH를 넓게 열어야 하는 초기 단계에서는 아래 방어선을 함께 적용합니다.

NCloud ACG
  TCP/22 inbound 허용

controller init script
  PubkeyAuthentication yes
  PasswordAuthentication no
  KbdInteractiveAuthentication no
  PermitRootLogin no
  AllowUsers <admin_user>
  MaxAuthTries 3

runner prepare playbook
  fail2ban sshd jail

ssh_allowed_cidrs = ["0.0.0.0/0"]는 bootstrap 편의 설정입니다. 운영 경로가 안정화되면 관리 IP /32 또는 VPN CIDR로 줄입니다.

2-2. Cloud runner pool 경계

pool은 CI capacity allocator가 아니라 runner infrastructure bundle입니다.

tirosh-infra
  cloud infra provisioning
  controller+worker VM 준비
  GitHub runner 등록
  controller config/service 배포

controller VM
  자기 자신도 runner로 동작
  GitHub queue와 runner 상태 감시
  max_workers 안에서 worker-only VM 생성
  idle/finished worker 정리

Cloud runner pool은 controller VM과 worker VM을 분리합니다. controller VM은 GitHub queue와 cloud worker 상태를 감시하고, worker VM만 CI job을 받습니다. 이 방식은 idle 비용을 줄이면서도 scale-out 판단을 tirosh-infra가 아니라 controller process에 맡기기 위한 구조입니다.

Cloud VM의 IP나 DNS는 OpenTofu apply 이후 확정됩니다. inventory.tomlansible_host는 장기적으로 안정적인 DNS 또는 고정 private IP를 가리키는 source로 유지합니다. 다만 첫 bootstrap 시점에는 DNS가 아직 준비되지 않았을 수 있으므로, pool/applypool/status는 OpenTofu output의 controller_public_ip를 우선 사용하고 없으면 controller_private_ip를 Ansible 접속 주소로 사용합니다.

Controller config는 cloud/on-prem mode를 따로 갖지 않습니다. tirosh-infra는 OpenTofu state에서 worker substrate output을 읽어 /etc/tirosh/github-runner/pool.yml을 렌더링하고, controller는 렌더링된 config만 읽습니다. 따라서 cloud에 controller를 배포하는 경우와 on-prem controller가 cloud worker를 제어하는 경우 모두 같은 config 계약을 사용합니다.

pool/statuscontroller/status는 현재 OpenTofu state에 저장된 output을 사용합니다. NCloud public IP가 provider 쪽에서 재연결되었거나 state가 오래되어 SSH가 예전 IP로 향하면 먼저 plan을 실행해 state를 refresh합니다.

make github-runner/pool/plan SITE=ncloud-ci
make github-runner/controller/status SITE=ncloud-ci

plan 결과가 No changes여도 refresh 과정에서 controller_public_ip output이 갱신될 수 있습니다.

OpenTofu apply
  |
  v
controller_public_ip / controller_private_ip output
  |
  v
site inventory render
  |
  v
Ansible runner install and controller config

2-3. site profile

GitHub runner 관련 site 기본값은 sites/tirosh-home/profile.toml에 명시합니다.

[github_runner]
target_name = "github_runners"
prepare_vars = "sites/tirosh-home/github-runner/prepare.vars.yml"
register_vars = "sites/tirosh-home/github-runner/register.vars.yml"

Make target은 이 값을 읽어 Ansible에 전달합니다. github_runnersworkloadsgithub-runner 또는 기존 github-actions가 붙은 host에서 생성됩니다. controller 대상 그룹은 workloadsgithub-runner-controller 또는 기존 runner-controller가 붙은 host에서 생성됩니다. runner vars 경로를 Makefile 내부에서 암묵적으로 조립하지 않습니다.

2-4. VM catalog

첫 runner VM은 sites/tirosh-home/vms.tfvars에서 관리하고, Ansible inventory hostname은 github-ci-01로 둡니다.

현재 기준 주요 값은 다음입니다.

항목
Proxmox VM name github-ci
inventory hostname github-ci-01
VM ID 1002
CPU 4 cores
Memory 8192 MB
Disk 80 GB
IP 172.31.0.11/24
SSH user tirosh

Proxmox 반영 전에는 반드시 plan을 먼저 봅니다.

make proxmox/vms/plan SITE=tirosh-home

github-ci를 추가하면서 capacity를 확보하기 위해 argocd VM memory도 8192 MB에서 6144 MB로 조정합니다.

2-5. prepare vars

VM 내부를 GitHub Actions runner host로 준비하는 구현값은 sites/tirosh-home/github-runner/prepare.vars.yml에 둡니다. Host가 실제로 어떤 GitHub runner 역할을 갖는지는 workloads에 두고, site의 runner dependency capability와 label 정책은 inventory.toml[github_runner]에 둡니다.

[github_runner]
runner_dependency_groups = [
  "base",
  "docker",
  "docker-compose",
  "hosted",
  "firmware",
  "west",
  "aws-cli",
  "rustup",
]
runner_labels = ["tirosh-home", "ci", "docker", "tirosh-ubuntu-latest"]

Cloud controller는 일반 CI runner 역할을 맡지 않는 것을 기본 운영 모델로 둡니다. Controller host는 github-runner-controller workload만 갖고, 실제 CI capability label은 controller가 생성하는 worker pool에 둡니다. Cloud site inventory의 github_runner.workers도 provider-local입니다. ncloud-ci는 NCloud worker만, aws-ci는 AWS worker만 선언합니다.

[vms.ncloud-ci-controller-01]
ansible_user = "tirosh"
ansible_become_user = "root"
workloads = ["ci", "github-runner-controller"]

[github_runner.workers.ncloud]
dependency_groups = [
  "base",
  "docker",
  "docker-compose",
  "hosted",
  "firmware",
  "west",
  "aws-cli",
  "rustup",
]
labels = ["self-hosted", "linux", "x64", "ncloud-ci", "tirosh-ubuntu-lite"]
min = 0
max = 3
[vms.aws-ci-controller-01]
ansible_user = "tirosh"
ansible_become_user = "root"
workloads = ["ci", "github-runner-controller"]

[github_runner.workers.aws]
dependency_groups = [
  "base",
  "docker",
  "hosted",
]
labels = [
  "self-hosted",
  "linux",
  "arm64",
  "aws-ci",
  "tirosh-ubuntu-arm64",
  "arm64-nightly",
]
min = 0
max = 5

runner_*는 단독 long-lived runner VM이 GitHub runner로 등록될 때의 설정입니다. Cloud pool controller는 pool.vars.ymlcontroller.runner_enabled: false를 사용해서 controller VM 자체의 runner install/register 단계를 건너뜁니다. [github_runner.workers.<platform>]는 controller가 생성, 시작, 정지할 cloud worker VM들의 platform별 설정입니다. ansible_host는 inventory에 있으면 그 값을 우선하고, cloud site에서는 OpenTofu output으로 주입할 수 있습니다.

prepare.vars.yml에는 runner version, install directory, installer 세부값처럼 선택된 group을 어떻게 설치할지에 대한 값만 둡니다.

github_runner_version: "2.335.1"
github_runner_user: github-runner
github_runner_install_dir: /opt/actions-runner
github_runner_work_dir: /var/lib/actions-runner

그룹 catalog는 infra/ansible/playbooks/github-runner/prepare.ymlstatus.yml에 명시되어 있고, inventory는 필요한 group 이름만 선택합니다.

group 의미
base runner host 공통 패키지입니다. git, curl, jq, make, python3, 압축 도구 등을 포함합니다.
docker Docker daemon과 buildx를 설치하고 runner user를 docker group에 넣습니다.
docker-compose Docker Compose CLI plugin을 설치합니다. docker group과 함께 사용해야 합니다.
uv 고정 버전 uv를 시스템 경로와 Actions tool cache에 설치합니다.
hosted GitHub-hosted Ubuntu runner에 가까운 기본 build/runtime입니다. build-essential, cmake, ninja, node, npm, pkg-config, shellcheck, Python pip/venv를 포함합니다.
firmware firmware workflow가 bare-metal 산출물을 만들 수 있게 Arm GNU Toolchain과 32-bit host build 패키지를 준비합니다.
west Zephyr workflow가 west build를 바로 실행할 수 있게 runner user에 west를 설치합니다.
aws-cli apt repository에 의존하지 않고 AWS CLI v2 installer로 aws 명령을 준비합니다.
playwright host에서 직접 browser test를 돌리는 선택 runner profile에만 Playwright browser와 OS dependency를 미리 설치합니다. Container 기반 browser workload에는 사용하지 않습니다.
rustup Rust workflow가 rustup, cargo, rustc를 바로 사용할 수 있게 runner user에 rustup을 설치합니다.
변수 의미
github_runner_docker_compose_version docker-compose group에서 설치할 Docker Compose release version
github_runner_west_package pipx로 설치할 Zephyr west package 이름
github_runner_rust_toolchain rustup으로 준비할 기본 Rust toolchain
github_runner_extra_apt_packages site에서 일회성으로 더 얹는 패키지

base, docker, docker-compose, hosted 조합은 controller처럼 항상 켜져 있지만 disk가 작은 runner에 적합합니다. Worker처럼 무거운 CI job을 받아야 하는 host는 여기에 firmware, west, aws-cli, rustup을 추가합니다. playwright는 browser runtime을 host에 직접 설치해야 하는 선택 runner profile에서만 사용합니다.

Full worker baseline은 actions/checkout, actions/setup-node, actions/setup-python, Docker 기반 action, Docker Compose, AWS CLI, npm ci, pip, make, shellcheck 같은 hosted runner에서 흔히 기대하는 명령을 최대한 자연스럽게 실행하기 위한 것입니다. C package workflow를 위해 build-essential, cmake, ctest, ninja도 prepare 단계에서 설치합니다. firmware workflow는 여기서 한 단계 더 나아가 .elf, .hex, .bin, .map 같은 산출물을 만들기 때문에 Arm GNU Toolchain과 Zephyr west를 runner dependency group에 포함합니다.

Zephyr native_sim은 host gcc를 사용하지만 일부 build 단계에서 -m32로 32-bit host object를 생성합니다. 그래서 firmware package baseline에는 Arm cross compiler뿐 아니라 gcc-multilib, libc6-dev-i386, device-tree-compiler도 포함합니다. 이 패키지가 없으면 bits/libc-header-start.h 또는 DTC 관련 오류로 west build -b native_sim 단계가 실패할 수 있습니다.

React, Vite, Next 같은 framework package는 runner에 전역 설치하지 않고 각 repository의 package.json과 lockfile이 관리합니다. Node.js major version, corepack, pnpm, yarn까지 hosted runner처럼 맞춰야 하면 actions/setup-node를 workflow에서 사용하거나 별도 installer task로 분리합니다.

Container 기반 browser/E2E workflow는 browser runtime을 job container image에서 제공합니다. 그래서 cloud browser pool은 host에 Playwright를 설치하지 않고 Docker baseline만 요구합니다. Host에서 직접 browser test를 돌리는 별도 runner profile이 필요할 때만 playwright dependency group을 사용합니다.

Rust는 apt 패키지 대신 rustup installer로 runner user 홈에 설치합니다. prepare playbook은 rustup, cargo, rustc/usr/local/bin에 노출하므로 workflow는 GitHub-hosted runner처럼 rustup show, cargo test, cargo package를 바로 실행할 수 있습니다. repository별 Rust version은 각 repo의 rust-toolchain.toml에서 고정합니다.

기존 workflow가 host에서 npx playwright install --with-deps를 그대로 실행하면 GitHub-hosted runner처럼 passwordless sudo를 기대할 수 있습니다. 기본 cloud runner baseline은 이 경로를 지원하지 않습니다. workflow 호환을 위해 host Playwright 설치를 반드시 유지해야 한다면 별도 runner profile과 sudo 정책을 검토합니다.

2-6. register vars

GitHub organization에 runner를 등록하는 값은 sites/tirosh-home/github-runner/register.vars.yml에 둡니다.

github_runner_url: https://github.com/tirosh-chain
github_runner_org: tirosh-chain

github_runner_url은 repository가 아니라 organization URL입니다. 이렇게 해야 tirosh-infra뿐 아니라 tirosh-chain organization의 다른 repository에서도 runner를 사용할 수 있습니다. Runner 이름은 기본적으로 inventory_hostname이고, label은 site가 [github_runner].runner_labels를 제공하면 그 값을 사용합니다. 기존 site는 register.vars.ymlgithub_runner_labels로 override할 수 있습니다.

workflow에서는 hosted runner와 혼동되지 않도록 ubuntu-latest를 custom label로 쓰지 않습니다. Cloud runner pool은 provider label을 숨기고 capability label만 지정합니다. 장기 runner는 controller/bootstrap/smoke fallback으로만 취급하고 일반 CI workflow에서는 tirosh-ubuntu-lite, tirosh-ubuntu-heavy, tirosh-ubuntu-browser 중 하나를 사용합니다.

runs-on: [self-hosted, tirosh-ubuntu-lite]

2-7. secret 관리

VM password와 GitHub registration token은 repository에 커밋하지 않습니다.

secret 사용 방식
VM SSH password Ansible --ask-pass prompt
sudo password Ansible --ask-become-pass prompt
runner registration token install playbook이 controller에서 gh api로 발급
runner remove token reconfigure 시 install playbook이 controller에서 gh api로 발급

VM password를 cloud-init이나 Proxmox UI에서 설정했다면 repo에는 따로 적지 않습니다.

3. VM 접속 확인

runner 설치 전에 github-ci VM에 Ansible로 접속할 수 있어야 합니다.

3-1. password SSH

password SSH를 사용할 때는 prompt를 켭니다.

make github-runner/status SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

아직 runner가 설치되지 않았다면 service status task가 실패할 수 있습니다. 이 단계에서 중요한 것은 SSH 인증과 sudo prompt가 정상인지 확인하는 것입니다.

SSH key로 접속할 수 있으면 prompt를 끕니다.

make github-runner/status SITE=tirosh-home \
  ASK_PASS=false \
  ASK_BECOME_PASS=false

3-2. SSH key 등록

password prompt를 반복하고 싶지 않으면 로컬 public key를 VM에 등록합니다.

ssh-copy-id tirosh@172.31.0.11

그 뒤에는 ASK_PASS=false로 실행할 수 있습니다. sudo가 passwordless가 아니라면 ASK_BECOME_PASS=true는 유지합니다.

4. GitHub 권한 준비

organization runner registration token을 발급하려면 GitHub CLI에 organization runner 관리 권한이 있어야 합니다.

4-1. gh 인증 scope

현재 인증 상태를 확인합니다.

gh auth status

organization runner token 발급이 403으로 실패하면 admin:org scope를 추가합니다.

gh auth refresh -h github.com -s admin:org

권한이 준비되면 token 발급을 확인합니다.

gh api -X POST /orgs/tirosh-chain/actions/runners/registration-token --jq .expires_at

registration token은 짧은 시간만 유효합니다. token은 파일에 저장하지 않고, 기본적으로 install playbook이 controller에서 gh api를 호출해 발급합니다.

4-2. token 발급

일반적인 설치 흐름에서는 token을 직접 발급하지 않습니다. 아래 명령은 Make target을 통해 register playbook을 실행하고, playbook은 GITHUB_RUNNER_REGISTRATION_TOKEN이 비어 있으면 controller에서 gh api를 호출해 token을 발급합니다.

make github-runner/register SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

기본 organization은 tirosh-chain입니다. 다른 organization runner를 등록해야 하면 vars 파일에서 github_runner_orggithub_runner_url을 함께 조정합니다.

github_runner_url: https://github.com/tirosh-chain
github_runner_org: tirosh-chain

이미 발급한 token을 명시적으로 넘길 수도 있습니다.

GITHUB_RUNNER_REGISTRATION_TOKEN="..." \
make github-runner/register SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

GitHub UI에서도 발급할 수 있습니다.

GitHub organization
  -> Settings
  -> Actions
  -> Runners
  -> New runner

5. runner 준비와 등록

runner 준비와 GitHub 등록은 분리된 Ansible playbook으로 실행합니다.

5-1. prepare

먼저 CI에 필요한 package, Docker, runner user, runner binary를 준비합니다.

password SSH 기준 설치 명령은 다음입니다.

make github-runner/prepare SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

이 target은 내부적으로 아래 playbook을 실행합니다.

infra/ansible/playbooks/github-runner/prepare.yml

prepare playbook은 다음 작업을 수행합니다.

  • runner OS package 설치
  • Docker service 활성화
  • github-runner user 생성
  • GitHub Actions runner package 다운로드

5-2. register

VM 준비가 끝나면 runner를 GitHub organization에 등록하고 service를 시작합니다.

make github-runner/register SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

이 target은 내부적으로 아래 playbook을 실행합니다.

infra/ansible/playbooks/github-runner/register.yml

register playbook은 다음 작업을 수행합니다.

  • GitHub runner registration token 자동 발급
  • runner를 tirosh-chain organization에 등록
  • systemd service 설치와 시작
  • runner service status 출력

5-3. install

prepareregister를 한 번에 실행하려면 install target을 사용합니다.

make github-runner/install SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

이 target은 내부적으로 아래 playbook을 실행합니다.

infra/ansible/playbooks/github-runner/install.yml

install.ymlprepare.ymlregister.yml을 순서대로 import합니다.

5-4. 재설치와 재등록

기본값은 이미 runner가 구성되어 있으면 재등록하지 않습니다.

github_runner_reconfigure: false

runner 등록 정보를 강제로 갱신해야 하면 sites/tirosh-home/github-runner/register.vars.yml에서 일시적으로 true로 바꾼 뒤 install을 다시 실행합니다.

github_runner_reconfigure: true

재등록 후에는 다시 false로 돌립니다.

runner 이름을 github-ci에서 github-ci-01처럼 바꿀 때도 같은 절차를 사용합니다. 기본 runner 이름은 inventory_hostname이므로 inventory hostname을 바꾼 뒤 github_runner_reconfigure: true로 한 번 등록하면 GitHub organization runner 이름이 새 hostname으로 바뀝니다. 특정 이름이 필요하면 register.vars.ymlgithub_runner_name으로 override합니다.

6. 상태 확인

6-1. Make target 확인

설치 후 runner service와 Docker 상태를 확인합니다.

status target은 runner service 위치를 알아야 하므로 profile.tomlgithub_runner.register_vars를 사용합니다.

make github-runner/status SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

정상이라면 runner service status, Docker server version, Docker Compose version, Rust toolchain 상태가 출력됩니다. 또한 hosted runner 호환성을 위해 prepare 단계에서 설치한 주요 apt package와 command를 확인하고, 누락된 항목이 있으면 missing runner baseline packages, missing runner baseline commands에 표시합니다.

Cloud runner pool에서 controller VM은 GitHub runner로 직접 등록하지 않습니다. 동적으로 생성되는 worker VM이 GitHub UI에 보이는 시점은 controller가 queued job을 보고 worker VM을 생성하거나 warm worker를 유지한 뒤입니다. pools[].workers.min: 0이고 queued job이 없으면 해당 capability label의 runner가 GitHub UI에 보이지 않는 상태가 정상입니다. 항상 worker 한 대를 대기시키고 싶으면 대상 pools[].workers.min1로 둡니다.

6-2. GitHub UI 확인

GitHub UI에서는 organization runner 목록을 확인합니다.

GitHub organization
  -> Settings
  -> Actions
  -> Runners

6-3. 기대 label

github-ci runner가 online이고 label이 아래처럼 보이면 등록이 완료된 것입니다.

label
self-hosted
Linux
X64
tirosh-home
ci
docker
tirosh-ubuntu-latest

7. smoke test

runner 등록이 끝나면 self-hosted label로 간단한 workflow를 실행해 봅니다.

7-1. workflow 예시

아래 workflow는 github-ci-01 runner에서 checkout, Docker, Python command가 동작하는지 확인합니다.

name: github-ci-smoke

on:
  workflow_dispatch:

jobs:
  smoke:
    runs-on: [self-hosted, tirosh-ubuntu-latest]
    steps:
      - uses: actions/checkout@v4
      - run: hostname
      - run: docker version
      - run: python3 --version

smoke workflow가 통과하면 tirosh-home 장기 runner가 준비된 것입니다. package/image workflow는 cloud runner pool을 기본으로 사용하므로 별도의 cloud smoke도 확인합니다.

7-2. cloud runner smoke

Cloud runner pool은 capability label을 지정해서 on-prem runner와 분리해서 검증합니다.

.github/workflows/cloud-runner-smoke.yml

Cloud runner lifecycle을 검증할 때는 아래 workflow를 수동 실행합니다.

gh workflow run cloud-runner-smoke.yml -f pool=tirosh-ubuntu-lite

browser/heavy pool을 검증할 때는 pool=tirosh-ubuntu-browser 또는 pool=tirosh-ubuntu-heavy를 지정합니다.

이 workflow는 self-hosted와 pool label을 요구합니다. 그래서 active cloud provider의 해당 pool runner가 실제로 GitHub에 online으로 등록되어야만 job이 시작됩니다.

7-3. 기존 workflow 전환

package/image publish workflow는 기본적으로 [self-hosted, tirosh-ubuntu-lite] label을 가진 cloud runner pool에서 실행합니다. 내부 장기 runner 검증이 필요한 작업은 수동 self-hosted validation workflow부터 사용합니다.

issue별 검증을 self-hosted에서 실행해야 할 때는 아래 workflow를 수동 실행합니다.

.github/workflows/nexus-backup-self-hosted-validation.yml

workflow의 기준 label은 다음입니다.

runs-on: [self-hosted, tirosh-ubuntu-lite]

8. runner pool 확장

8-1. 확장 기준

github-ci-01 한 대로 시작하지만 image build, package publish, frontend E2E가 겹치면 queue가 쉽게 생깁니다. github-ci-02, github-ci-03, github-ci-04처럼 VM을 2-3대 더 추가할 때도 같은 prepare/register vars를 재사용합니다.

8-2. 추가 VM 변경 파일

추가 VM마다 맞춰야 하는 값은 다음입니다.

파일 변경
sites/tirosh-home/vms.tfvars 고유 VM key/name/vm_id/IP 추가
sites/tirosh-home/inventory.toml 같은 host명, SSH 정보, workloads, 필요 시 [github_runner] 정책 추가
sites/tirosh-home/inventory.yml make site/inventory/render SITE=tirosh-home로 재생성

8-3. 공통 label 전략

기본 runner 이름은 inventory_hostname을 사용하므로 각 VM은 GitHub organization runner에 고유 이름으로 등록됩니다. 장기 runner는 site 공통 label을 inventory.toml[github_runner].runner_labels에 둡니다. Cloud runner pool은 운영자 식별용 provider label(ncloud-ci/aws-ci)을 runner에 붙일 수 있지만 workflow는 capability label(tirosh-ubuntu-lite, tirosh-ubuntu-browser, tirosh-ubuntu-heavy)만 요구합니다.

9. 문제 해결

문제가 생기면 VM 접속, GitHub 권한, runner service, Docker 순서로 확인합니다.

9-1. SSH 인증

SSH 인증이 실패하면 password prompt를 켜서 다시 실행합니다.

make github-runner/status SITE=tirosh-home \
  ASK_PASS=true \
  ASK_BECOME_PASS=true

Permission denied (publickey,password,keyboard-interactive)가 보이면 VM password가 설정되어 있는지 확인합니다.

9-2. GitHub 권한

registration token 발급이 403으로 실패하면 gh scope를 갱신합니다.

gh auth refresh -h github.com -s admin:org

그래도 실패하면 현재 GitHub 계정이 tirosh-chain organization runner를 관리할 권한이 있는지 확인합니다.

9-3. runner service

VM 안에서 직접 확인해야 할 때는 아래 command를 사용합니다.

ssh tirosh@172.31.0.11
sudo /opt/actions-runner/svc.sh status
sudo journalctl -u actions.runner.* -n 100 --no-pager

9-4. Docker

runner가 Docker workflow를 실행하려면 github-runner user가 docker group에 있어야 합니다.

ssh tirosh@172.31.0.11
id github-runner
docker version

Ansible install playbook은 Docker를 설치하고 github-runner user를 docker group에 추가합니다.