GitHub CI¶
이 문서는 tirosh-home site의 github-ci VM을 GitHub Actions self-hosted runner로 등록하고 운영하는 방법을 설명합니다. Cloud 기반 CI runner pool은 aws-ci, ncloud-ci site로 분리해서 관리합니다.
github-ci VM 자체는 Proxmox/OpenTofu로 관리합니다. VM 내부의 runner 설치, Docker 설치, runner service 등록은 tirosh-infra의 Ansible playbook과 Make target으로 관리합니다.
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을 공유해도 삭제 후보가 되지 않습니다.
1-5. 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-ci는 tirosh-home처럼 Proxmox, k3s, Argo CD, Nexus 기본값을 갖지 않습니다.
Cloud IaC credential은 site profile에 넣지 않습니다. AWS는 AWS CLI profile을 사용하고 .env에는 profile 이름만 둡니다.
AWS_PROFILE=tirosh-infra
AWS_DEFAULT_REGION=ap-northeast-2
실제 access key와 secret key는 ~/.aws/credentials에 둡니다. 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
ncloud_access_key
ncloud_secret_key
ncloud_region
secret directory는 0700, 각 secret file은 0600으로 생성합니다. GitHub API token 입력은 GITHUB_RUNNER_CONTROLLER_GITHUB_TOKEN만 사용합니다. NCloud controller는 NCLOUD_ACCESS_KEY, NCLOUD_SECRET_KEY, NCLOUD_REGION을 사용합니다.
pool.vars.yml의 github 항목은 controller가 queue demand를 읽고 worker를 runner로 등록할 GitHub 범위입니다. github.owner는 organization 또는 owner이고, github.repositories에는 queue를 볼 repository 이름을 둡니다. repository 목록이 비어 있으면 GitHub demand는 0으로 계산됩니다. Controller는 GitHub REST API로 queued/waiting/pending workflow run을 찾은 뒤, 각 run의 job 목록에서 status와 labels를 확인합니다. 기본적으로 workers.labels를 모두 포함한 queued job만 해당 pool의 demand로 계산하므로, ncloud-ci pool이 aws-ci label job 때문에 확장되지 않습니다. 공통 label job도 pool 확장 대상으로 삼아야 하면 github.demand_labels를 지정해서 worker 등록 label과 demand matching label을 분리합니다.
github:
owner: tirosh-chain
repositories:
- tirosh-infra
demand_labels:
- self-hosted
- tirosh-ubuntu-latest
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: repository와 registration_repository를 명시합니다.
pool.vars.yml의 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를 둡니다. 사람이 관리하기 어려운 VPC/Subnet/ACG 같은 resource id는 pool.vars.yml에 직접 쓰지 않고, controller 배포 단계에서 OpenTofu output을 읽어 pool.yml에 주입합니다.
controller runtime은 한 프로세스가 한 provider의 worker pool 하나를 reconcile하는 모델입니다. 한 controller VM이 여러 platform을 맡을 수는 있지만, 그 경우 platform별 pool.yml과 systemd service instance를 따로 렌더링해야 합니다. inventory.toml의 [github_runner.workers.<platform>]와 profile.toml의 [github_runner.worker_platforms.<platform>]는 이 platform 의도를 명시하는 운영 source이고, 현재 단일 service 배포 경로에서는 site의 cloud.provider와 일치하는 platform만 활성 pool config로 렌더링됩니다.
OpenTofu output 이름은 worker substrate 계약입니다. 예전 output 이름으로 우회하지 않으며, 아래 값이 없으면 Make target은 실패합니다.
| 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 |
AWS의 worker_iam_instance_profile은 worker에 instance profile을 붙일 때만 사용하는 선택 output입니다.
NCloud worker apply 시 controller는 per-worker bootstrap init script를 만들고, 그 init script 번호를 createServerInstances에 전달합니다. 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을 받을 수 있습니다. NCloud는 warm worker 재사용을 위해 persistent runner를 기본으로 둡니다. 실제 service command는 NCloud worker에 대해 아래 옵션을 포함해야 합니다.
github-runner-controller run \
--cloud-source ncloud \
--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
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 상태를 /var/lib/tirosh/github-runner-controller/activity-state.json에 저장하고, busy: true에서 busy: false로 바뀐 시점을 job 완료 시점으로 기록합니다. busy: true인 worker는 TTL과 관계없이 stop/delete하지 않습니다. ephemeral worker는 job 1개 처리 후 GitHub runner 등록이 제거되므로, VM 재사용이 필요한 pool에는 적합하지 않습니다.
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과 분리 |
현재 절약 모드 기본 설정은 아래 형태입니다.
autoscaler:
mode: economical
idle_ttl_minutes: 7
startup_grace_minutes: 15
max_create_per_tick: 1
scale_out_delay_minutes: 3
stopped_retention_hours: 6
capacity_steps:
- queued_jobs: 1
workers: 1
- queued_jobs: 4
workers: 2
- queued_jobs: 9
workers: 3
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 disk가 작을 수 있으므로 controller가 worker 생성 직후 50GiB data volume을 붙입니다. Worker bootstrap은 이 volume을 기다렸다가 필요하면 포맷하고, /var/lib/docker와 /opt/actions-runner를 bind mount합니다. Docker layer, image cache, runner work directory가 data volume을 사용하므로 root filesystem이 CI 작업 중 쉽게 가득 차지 않습니다.
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/destroy가 infra-tools github-runner cleanup --apply를 먼저 실행해서 site에 속한 controller/worker runner registration을 정리하고, 그 다음 cloud resource를 제거합니다.
AWS worker apply도 같은 bootstrap 흐름을 사용합니다. 차이는 bootstrap script를 EC2 user data로 전달한다는 점입니다. 따라서 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 aws \
--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
GitHub token 준비와 runner 등록을 분리해야 할 때는 cloud foundation만 먼저 적용할 수 있습니다.
make github-runner/pool/cloud/apply SITE=ncloud-ci
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.toml의 ansible_host는 장기적으로 안정적인 DNS 또는 고정 private IP를 가리키는 source로 유지합니다. 다만 첫 bootstrap 시점에는 DNS가 아직 준비되지 않았을 수 있으므로, pool/apply와 pool/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/status와 controller/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_runners는 workloads에 github-runner 또는 기존 github-actions가 붙은 host에서 생성됩니다. controller 대상 그룹은 workloads에 github-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",
"playwright",
"rustup",
]
runner_labels = ["tirosh-home", "ci", "docker", "tirosh-ubuntu-latest"]
Cloud controller가 runner 역할도 같이 맡으면 같은 [github_runner] 안에 controller 자체 runner 설정과 controller가 제어할 worker platform 설정을 같이 둡니다.
[vms.ncloud-ci-controller-01]
ansible_user = "tirosh"
ansible_become_user = "root"
workloads = ["ci", "github-runner", "github-runner-controller"]
[github_runner]
runner_dependency_groups = ["base", "docker", "docker-compose", "hosted"]
runner_labels = ["ncloud-ci", "ci", "docker", "tirosh-ubuntu-latest"]
[github_runner.workers.ncloud]
dependency_groups = [
"base",
"docker",
"docker-compose",
"hosted",
"firmware",
"west",
"aws-cli",
"playwright",
"rustup",
]
labels = ["self-hosted", "linux", "x64", "tirosh-ubuntu-latest", "ncloud-ci"]
min = 0
max = 3
[github_runner.workers.aws]
dependency_groups = [
"base",
"docker",
"docker-compose",
"hosted",
"firmware",
"west",
"aws-cli",
"playwright",
"rustup",
]
labels = ["self-hosted", "linux", "x64", "tirosh-ubuntu-latest", "aws-ci"]
min = 0
max = 2
runner_*는 controller VM 자체나 단독 runner VM이 GitHub runner로 등록될 때의 설정입니다. [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.yml과 status.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과 함께 사용해야 합니다. |
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 |
Playwright browser와 OS dependency를 VM prepare 단계에서 미리 설치합니다. |
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, playwright, rustup을 추가합니다.
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로 분리합니다.
Playwright는 E2E workflow에서 npx playwright install --with-deps가 매번 OS dependency를 설치하지 않아도 되도록 VM prepare 단계에서 browser와 dependency를 사전 설치합니다. browser cache는 PLAYWRIGHT_BROWSERS_PATH로 공유 경로를 사용합니다.
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가 npx playwright install --with-deps를 그대로 실행하면 GitHub-hosted runner처럼 passwordless sudo를 기대할 수 있습니다. 이 runner baseline은 OS dependency와 browser를 미리 설치하지만, runner user에 passwordless sudo를 자동으로 열지는 않습니다. workflow 호환을 위해 --with-deps를 반드시 유지해야 한다면 보안 범위를 확인한 뒤 별도 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.yml의 github_runner_labels로 override할 수 있습니다.
workflow에서는 hosted runner와 혼동되지 않도록 ubuntu-latest를 custom label로 쓰지 않습니다. self-hosted runner를 직접 지정할 때는 tirosh-ubuntu-latest label을 사용합니다.
runs-on:
- self-hosted
- tirosh-ubuntu-latest
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_org와 github_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-runneruser 생성- 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-chainorganization에 등록 - systemd service 설치와 시작
- runner service status 출력
5-3. install¶
prepare와 register를 한 번에 실행하려면 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.yml은 prepare.yml과 register.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.yml의 github_runner_name으로 override합니다.
6. 상태 확인¶
6-1. Make target 확인¶
설치 후 runner service와 Docker 상태를 확인합니다.
status target은 runner service 위치를 알아야 하므로 profile.toml의 github_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도 workloads에 github-runner가 있으면 GitHub runner로 직접 등록됩니다. 동적으로 생성되는 worker VM이 GitHub UI에 보이는 시점은 controller가 queued job을 보고 worker VM을 생성한 뒤입니다. workers.min: 0이고 queued job이 없으면 controller runner만 보이고 cloud worker가 없는 상태가 정상입니다. 항상 worker 한 대를 대기시키고 싶으면 해당 site의 [github_runner.workers.<platform>] 또는 platform별 pool.vars.yml에서 min을 1로 둡니다.
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가 통과하면 package workflow와 image workflow가 사용할 self-hosted runner pool이 준비된 것입니다.
7-2. cloud runner smoke¶
Cloud runner pool은 site label을 함께 지정해서 on-prem runner와 분리해서 검증합니다.
.github/workflows/cloud-runner-smoke.yml
NCloud controller lifecycle을 검증할 때는 아래 workflow를 수동 실행합니다.
gh workflow run cloud-runner-smoke.yml -f site=ncloud-ci
AWS controller까지 배포된 뒤에는 같은 workflow를 aws-ci label로 실행합니다.
gh workflow run cloud-runner-smoke.yml -f site=aws-ci
이 workflow는 self-hosted, tirosh-ubuntu-latest, site label을 모두 요구합니다. 그래서 ncloud-ci 또는 aws-ci worker가 실제로 GitHub에 online으로 등록되어야만 job이 시작됩니다.
7-3. 기존 workflow 전환¶
package/image publish workflow는 tirosh-ubuntu-latest label을 가진 self-hosted 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-latest]
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가 tirosh-ubuntu-latest label을 공유하면 workflow는 runner 개수를 의식하지 않고 같은 selector를 사용할 수 있습니다. Site 공통 label은 inventory.toml의 [github_runner].runner_labels에 둡니다.
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에 추가합니다.