0. Contents
1. Overview
보라는 등록, 주황은 전달, 파랑은 평소의 묻기와 받기, 초록은 작업이 받는 AWS 권한. 모든 연결은 EC2 에서 바깥으로 나간다
무료 실행 시간 소진과 러너 이전
- durokukie 조직의 GitHub Actions 무료 실행 시간이 10월 중순에 바닥났다
- PR 검사, 자동 리뷰, terraform plan 이 돌지 않게 됐다
- 같은 작업을 우리 AWS 계정의 EC2 한 대에서 돌리도록 옮겼다
워크플로 변경: runs-on 한 줄
runs-on: ubuntu-latest # 전
runs-on: kukie-ci # 후
GitHub 과 EC2 의 역할 분담
- GitHub: 여전히 일거리(작업 큐)와 화면을 맡는다
- EC2: 일을 실제로 실행하는 컴퓨터만 바뀌었다
- 러너는 GitHub 에 한 번 등록한 뒤, 계속 “일이 있는지” 물어 일을 받아 가고 결과를 돌려준다
- 그래서 EC2 에는 들어오는 포트가 하나도 없다
장별 내용
- 4장: EC2 한 대의 구성
- 5장: 러너가 GitHub 에 붙는 방식
- 6장: 이미지
- 7장: 누가 이 러너를 쓸 수 있는지
- 8장: 아직 약한 곳 — 다음 편(ARC on EKS)의 출발점이다
2. Background — 무료 실행 시간의 소진
10월 사용량
- 비공개 저장소의 GitHub 러너 사용 시간은 조직 요금제에 포함된 만큼만 무료다
- 10월 사용량: 포함된 2,000분을 넘어 2,607분
- 대부분은 두 가지였다
리뷰와 plan 이 95%. 둘 다 PR 마다, 커밋을 더할 때마다 다시 돈다
시간 소진 시 증상
- 작업이 실패하지 않고 아예 시작하지 않는다
- PR 에 검사 항목이 “대기” 로만 남고, 리뷰도 plan 도 오지 않는다
- 브랜치 보호 규칙이 검사 통과를 요구하므로 머지도 막힌다
3. Options — 결제와 자체 러너의 비교
자체 러너를 골랐다. 대신 남의 코드가 우리 컴퓨터에서 도는 책임이 생긴다 — 4·7·8장
자체 러너 선택 이유와 책임
- 이미 AWS 계정과 Terraform 이 있어서 컴퓨터 한 대를 더하는 일이 작았다
- 작업이 대부분 짧고 가벼워 작은 인스턴스 한 대로 충분했다
- 대신 “남의 코드가 우리 컴퓨터에서 돈다” 는 책임이 생긴다
- 4장, 7장, 8장이 그 책임에 대한 이야기다
4. Runner Host — 스팟 인스턴스 한 대
정의 위치와 적용 주체
- 러너 컴퓨터는
terraform/github-actions-foundation/ci-runner.tf에 있다 - 이 root 는 GitHub Actions 의 배포 역할(OIDC)을 만드는 곳이라 관리자가 직접 적용한다
- 이유: 러너가 Actions 에 기대어 만들어지면, 러너가 없을 때 러너를 만들 수 없다
주황은 비용, 파랑은 네트워크, 빨강은 권한. 들어오는 문이 없고, 들어와도 쥘 권한이 거의 없다
instance_market_options {
market_type = "spot"
spot_options {
spot_instance_type = "persistent"
instance_interruption_behavior = "stop"
}
}
metadata_options {
http_tokens = "required"
http_put_response_hop_limit = 1
}
스팟 재고 부족
- 처음에는 ap-northeast-2a 의 t3a.large 스팟으로 시작했다
- 적용이 20분 동안 끝나지 않았다 — 원인은 그 구역에 남는 용량이 없다는
InsufficientInstanceCapacity - 스팟은 가격만이 아니라 재고도 구역마다 다르다
- 같은 시점에 재고가 있고 스팟 가격이 가장 낮던 2d 의 t3.large 로 옮겼다
스팟은 가격만이 아니라 재고도 구역마다 다르다. 정지(stop) 방식이라 디스크의 러너 키가 남아 다시 등록할 필요가 없다
인스턴스 구성: 도커만
- 인스턴스에는 도커만 설치한다(user data)
- 러너 프로그램과 도구는 이미지에 담는다(6장)
- 러너를 켜는 것은 5장의 등록 스크립트가 한다
5. Registration — 등록 토큰과 러너 키
1~5 는 처음 한 번, 6~7 은 계속 반복. GitHub 이 가진 것은 공개키뿐이고, 러너의 신원은 디스크의 개인키다
러너 인증 열쇠 세 단계
- 등록 토큰 — 처음 한 번 쓰고 버린다
- 개인키 — 러너가 계속 들고 있다
- 접근 토큰 — 개인키로 그때그때 받아 쓰는 짧은 토큰이다
등록 토큰
- “이 조직에 러너를 하나 붙이겠다” 는 요청에 GitHub 이 내주는 일회용 코드다
- 조직 관리자만 받을 수 있고, 1시간 뒤 만료된다
- 미리 저장해 두는 값이 아니라 등록하는 순간 새로 받는다
gh api -X POST orgs/durokukie/actions/runners/registration-token
{
"token": "AABF3JGZDX3P5PMEXLND6TS6FCWO4",
"expires_at": "2026-10-19T15:30:00.000+09:00"
}
- 값은 예시다
- 이 토큰은 조직 단위로 발급된다 → 이것으로 등록한 러너는 특정 저장소가 아니라 조직의 러너가 된다
SSM 으로 전달
- 토큰은
scripts/ci-runner/register.sh가 받아 EC2 에 넘긴다 - EC2 에 들어오는 포트가 없으므로 SSH 가 아니라 SSM Run Command 를 쓴다
- EC2 안의 SSM 에이전트가 AWS 에 “나에게 온 명령이 있는지” 계속 묻다가, 명령이 오면 실행한다
관리자 노트북 ──(AWS 관리자 권한)──▶ SSM ──▶ EC2 의 SSM 에이전트 ──▶ docker run
- 보내는 명령은 러너 컨테이너를 켜면서 토큰을 환경 변수로 넣는 것이다
- 러너 셋을 같은 토큰으로 한 번에 등록한다
docker run -d --name kukie-ci-1 --restart unless-stopped \
-e RUNNER_SCOPE=org -e ORG_NAME=durokukie \
-e RUNNER_TOKEN=<등록 토큰> \
-e RUNNER_NAME=kukie-ci-1 -e LABELS=kukie-ci \
-e DISABLE_AUTOMATIC_DEREGISTRATION=true \
-e CONFIGURED_ACTIONS_RUNNER_FILES_DIR=/runner/data \
-v kukie-ci-1:/runner/data \
-v /var/run/docker.sock:/var/run/docker.sock \
kukie-ci-runner:local
- 이 스크립트를 실행하려면 GitHub 조직 관리자 권한 + AWS 관리자 권한이 둘 다 필요하다
- 둘 다 있는 사람만 러너를 새로 붙일 수 있다
키 쌍과 디스크의 세 파일
등록 과정: 키 쌍 생성
- 컨테이너가 켜지면 러너 프로그램(
config.sh)이 등록을 한다 - 러너는 자기 컴퓨터에서 RSA 키 쌍을 만든다
- 등록 토큰과 함께 공개키만 GitHub 에 보낸다
- GitHub 은 공개키를 보관하고 러너 번호를 정해 준다
- 개인키는 GitHub 에도 가지 않는다
등록 후 디스크의 세 파일
- 위치:
/runner/data(컨테이너마다 따로인 도커 볼륨) - 아래 값은 모두 예시다
// .runner — 나는 누구인가
{
"agentId": 23,
"agentName": "kukie-ci-1",
"poolName": "Default",
"gitHubUrl": "https://github.com/durokukie",
"workFolder": "_work"
}
// .credentials — 접근 토큰을 어디서 받는가
{
"scheme": "OAuth",
"data": {
"clientId": "8f3a2b1c-0000-0000-0000-000000000000",
"authorizationUrl": "https://vstoken.actions.githubusercontent.com/_apis/oauth2/token/..."
}
}
// .credentials_rsaparams — 개인키
{ "modulus": "vT2c...", "exponent": "AQAB", "d": "kX9f...", "p": "...", "q": "...", "dp": "...", "dq": "...", "inverseQ": "..." }
등록 이후의 열쇠: 개인키 파일
- 등록 토큰은 더 쓰이지 않는다
- 러너가 GitHub 에 붙는 열쇠는 이 개인키 파일이다 — GitHub Secrets 에도 Terraform 상태에도 없다
- 등록 토큰은 SSM 명령 기록에 남지만 1시간 뒤에는 쓸 수 없다
일을 받아 오는 방식
- 러너는 개인키로 서명한 요청을
authorizationUrl에 보내 1시간짜리 접근 토큰을 받는다 - 그 토큰으로 GitHub 에 “일이 있는지” 묻고, 응답이 올 때까지 연결을 열어 둔다(long polling)
- 만료되면 다시 서명해 새로 받는다
러너: 일 있어요? GitHub: (잠시 기다렸다가) 없음 ← Idle
러너: 일 있어요? GitHub: 있음. 이 저장소, 이 단계들, 이 Secrets
러너: 실행 · 로그 전송 · 결과 보고 ← Active
러너: 일 있어요?
작업 배분
- GitHub 은 워크플로의
runs-on: kukie-ci라벨을 보고, 같은 라벨을 단 이 조직의 러너에게만 작업을 준다 - 러너가 셋이라 작업 셋까지 동시에 돈다
- 넷째부터는 셋 중 하나가 빌 때까지 대기열(Queued)에 있다
- 작업에 필요한 Secrets 는 GitHub 에 그대로 있고, 작업을 줄 때 같이 건넨다 — 로그에서는 가려진다
연결 방향: 러너 → GitHub
- GitHub 이 EC2 로 들어올 일이 없다
- 그래서 4장에서 들어오는 규칙을 하나도 두지 않을 수 있었다
6. Runner Image — 바탕 이미지와 추가 도구
이미지 구성: 바탕 + 도구 층
- 바탕:
myoung34/github-runner- 우분투 위에 GitHub 의 공식 러너 프로그램이 들어 있다
- 환경 변수(
RUNNER_TOKEN,ORG_NAME등)를 읽어 등록부터 대기까지 해 주는 시작 스크립트도 있다
- 위층: 우리 워크플로가 쓰는 도구를 더한
scripts/ci-runner/Dockerfile
바탕 이미지만 쓴 결과: exit 127
- 워크플로 몇 개가
exit 127로 끝났다 — “그런 명령이 없다” 는 뜻이다 - GitHub 러너 이미지에는 수많은 도구가 미리 깔려 있어서, 워크플로가 설치 단계 없이
ruby,kubectl같은 명령을 바로 부르고 있었다 - 자체 러너로 옮기면 그 전제가 사라진다
FROM myoung34/github-runner:2.338.0-ubuntu-noble
RUN apt-get update \
&& apt-get install -y --no-install-recommends ruby python3 python3-venv python3-pip jq zip unzip build-essential \
&& rm -rf /var/lib/apt/lists/*
# + aws cli v2 (--update), kubectl, kustomize
GitHub 러너에는 처음부터 있던 도구들이라, 없으면 워크플로가 exit 127 로 끝난다
이미지에서 뺀 도구
- node, java, terraform, helm
- 워크플로가
setup-node같은 단계로 작업마다 원하는 버전을 받아 설치하기 때문이다 - 이미지에 같이 넣으면 버전이 두 군데에서 정해진다
빌드와 반영
- 이미지는 EC2 에서 직접 빌드한다
- 등록 스크립트가 Dockerfile 을 SSM 으로 보내
docker build를 하고, 그 이미지로 러너를 다시 켠다 - Dockerfile 을 고치면 스크립트를 한 번 다시 돌려야 반영된다
7. Scope — 공개 저장소의 제외와 배포 권한
누가 이 러너를 쓰나
사용 범위: 조직과 러너 그룹
- 조직: 러너는 durokukie 조직에 등록되어 있다 → 다른 조직이나 개인의 저장소가
runs-on: kukie-ci라고 적어도 이 러너를 찾지 못한다 - 러너 그룹: 조직 안에서는 러너 그룹이 범위를 정한다 → 기본 그룹(Default)은 공개 저장소의 사용을 막는다
공개 저장소 차단 이유: 포크 PR
- 공개 저장소에는 누구나 포크해서 PR 을 올릴 수 있다
- 그 PR 의 워크플로 파일은 올린 사람이 고칠 수 있다
- 자체 러너를 열어 두면 모르는 사람의 코드가 우리 EC2 에서 돈다
막힌 저장소: kukie-server, agent
- kukie-server 와 agent 는 공개 저장소라 옮긴 뒤 작업이 대기열에서 움직이지 않았다
- 다행히 공개 저장소의 GitHub 러너는 시간 제한 없이 무료다
- 두 저장소는 GitHub 러너로 되돌렸다
러너 그룹(Default)이 공개 저장소를 막는다. 옮긴 뒤 두 공개 저장소의 작업이 대기열에 멈춘 것이 이 때문이다
배포 권한은 러너와 별개
- AWS 권한은 러너 컴퓨터가 아니라 작업마다 GitHub OIDC 로 받는다
- 역할의 신뢰 정책이 보는 것은 “어느 저장소의 어느 브랜치(또는 환경)인가” 이다
- 그 작업이 GitHub 러너에서 도는지 우리 EC2 에서 도는지는 보지 않는다
- 그래서 러너를 옮기면서 역할이나 신뢰 정책을 바꾼 것은 없다
작업 ──(GitHub 이 서명한 OIDC 토큰: repo · ref)──▶ AWS STS ──▶ 그 저장소·브랜치에 허용된 역할만
작업별 실행 위치
kukie-ci로 옮긴 것: 리뷰, 웹·매니페스트 검사, terraform plan- GitHub 러너에 남긴 것: terraform apply
- apply 는 가장 강한 배포 역할을 맡는다
- 횟수가 적어 시간도 거의 쓰지 않는다
- 같은 컴퓨터에서 다른 작업이 남긴 흔적(8장) 옆에서 가장 강한 권한을 받을 이유가 없었다
8. Weak Points — 재사용 컨테이너와 docker.sock
알고 둔 약점 두 가지
1. 재사용 컨테이너
- 러너 컨테이너는 작업마다 새로 만들지 않고 계속 쓴다
- 저장소 폴더는 checkout 단계가 매번 새로 받는다
- 하지만 작업이 홈 디렉터리나 도구 캐시에 남긴 파일은 다음 작업이 볼 수 있다
- 어떤 작업이 받은 Secrets 를 파일로 적어 두면, 같은 러너의 다음 작업이 읽을 수 있다
2. docker.sock
- 작업이 도커를 쓸 수 있도록 EC2 의 도커 소켓을 컨테이너에 연결했다
- 소켓을 쥐면 EC2 의 도커 전체를 다룰 수 있다
- 다른 러너 컨테이너의 볼륨(5장의 개인키 파일)을 읽을 수 있다
- 호스트 네트워크로 컨테이너를 띄우면 4장의 hop limit 을 지나 인스턴스 역할도 읽을 수 있다
- 피해 범위는 좁다 — 인스턴스 역할이 SSM 접속 권한뿐이고 VPC 가 따로라, 이 EC2 와 러너 등록까지다
- 그래도 작업 하나가 러너 전체를 쥘 수 있다는 점은 그대로다
전제: 사용자는 팀원뿐
- 이 러너를 쓸 수 있는 사람이 팀원으로 한정되어 있다(조직의 비공개 저장소에 워크플로를 올릴 수 있는 사람)
해결 방향: 일회용 러너(ephemeral runner)
- 작업 하나에 러너 하나를 새로 만들고 끝나면 버린다
- 남는 흔적이 없다
- 러너 키도 작업마다 새로 발급되어, 하나가 새도 다음 작업에는 쓸 수 없다
- 등록 토큰을 사람이 아니라 프로그램이 받아야 하므로 GitHub App 이 필요하다
- 쿠버네티스에서 이 일을 해 주는 것이 Actions Runner Controller(ARC) 이고, 다음 편의 주제다
이 글을 쓰고 사흘 뒤(10월 22일), 재사용 컨테이너 약점이 실제 사고로 이어졌다. 같은 날 EC2 위에서 일회용 러너로 바꿨고, docker.sock 은 남았다 → Kukie Infra.11
9. Discussion
남은 위험
- 재사용 컨테이너와 docker.sock (8장)
- 작업마다 새 러너로 바꾸기 전까지 남는다
- 스팟 회수
- 회수되면 인스턴스가 정지하고, 그동안 작업은 대기열에 쌓인다
- 용량이 돌아오면 다시 켜진다
- 한 대
- 인스턴스가 하나라 이것이 멈추면 비공개 저장소의 CI 전체가 멈춘다
- 비상 경로:
runs-on을 GitHub 러너로 되돌린다
핵심 메시지
- 자체 러너는 GitHub 에 붙는 것이 아니라 GitHub 에 묻는 것이다
- 연결을 러너가 시작하므로 들어오는 문 없이 운영할 수 있다
- 러너의 열쇠는 등록할 때 러너가 만든 개인키다
- 1시간짜리 등록 토큰이 아니다
- 그 파일이 있는 디스크가 곧 러너의 신원이다
- 배포 권한은 러너가 아니라 저장소와 브랜치에 묶여 있다
- 러너를 옮겨도 권한 설계는 그대로다
- 가장 강한 권한(apply)은 가장 깨끗한 곳에 둔다
- 자체 러너로 옮기면 GitHub 러너가 말없이 채워 주던 것이 드러난다
- 도구, 격리, 일회용 환경
- 도구는 이미지로 채웠고, 격리와 일회용 환경은 다음 편의 과제로 남았다
10. References
자체 러너의 개념과 GitHub 러너와의 차이, 통신 방식. 1장과 5장의 근거다.
조직 러너 등록 토큰을 받는 API. 5장의 등록 토큰이 이것이다.
러너 그룹과 공개 저장소의 사용 제한. 7장의 근거다.
자체 러너의 보안 주의점. 공개 저장소와 작업 간 흔적. 7장과 8장의 근거다.
러너 프로그램 소스. 등록 시 키 쌍과 자격 증명 파일이 만들어지는 과정을 확인할 수 있다.
바탕 이미지와 환경 변수(RUNNER_TOKEN, ORG_NAME, LABELS 등). 6장의 1층이다.
SSM Run Command. 들어오는 포트 없이 인스턴스에 명령을 보내는 방식. 5장의 전달 경로다.
스팟 인스턴스의 중단 동작(종료·정지·최대 절전)과 persistent 요청. 4장의 설정이다.
IMDSv2 의 토큰 필수와 hop limit. 4장과 8장에서 컨테이너의 인스턴스 역할 접근을 다룬 근거다.
GitHub OIDC 토큰의 주장(sub 의 repo · ref)과 AWS 역할 신뢰 정책. 7장의 근거다.
