0. Contents


1. Overview

PC관리자 노트북gh · 조직 관리자AWS 관리자 프로필GitHubdurokukie 조직ORG러너 등록러너 그룹 Default공개키 보관Q작업 큐runs-on: kukie-ciPR · 리뷰 · planKEYSecrets작업과 함께 전달우리 AWS 계정서울 리전SSMRun Command명령 전달STSOIDC 역할작업마다 임시 권한저장소·브랜치 기준전용 VPC 10.70.0.0/24들어오는 규칙 없음EC2스팟 t3.largekukie-ci-1개인키kukie-ci-2개인키kukie-ci-3개인키+ SSM 에이전트 · 도커IMDS 토큰 필수 · hop 1① 등록 토큰 (1시간)② 토큰 전달③ 일 있어요?④ AWS 권한
보라는 등록, 주황은 전달, 파랑은 평소의 묻기와 받기, 초록은 작업이 받는 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분
  • 대부분은 두 가지였다
10월 사용량 (분)포함된 무료 2,000초과 607= 2,607어디에 썼나60%35%PR 자동 리뷰 · 약 60%PR·커밋마다 다시, 한 번에 수 분terraform plan · 약 35%root 별로 PR 마다 여러 개나머지 · 약 5%웹·매니페스트 검사
리뷰와 plan 이 95%. 둘 다 PR 마다, 커밋을 더할 때마다 다시 돈다

시간 소진 시 증상

  • 작업이 실패하지 않고 아예 시작하지 않는다
  • PR 에 검사 항목이 “대기” 로만 남고, 리뷰도 plan 도 오지 않는다
  • 브랜치 보호 규칙이 검사 통과를 요구하므로 머지도 막힌다

3. Options — 결제와 자체 러너의 비교

초과분 결제+바꿀 것이 없다−리뷰·plan 이 늘면−비용도 같이 는다GitHub 대형 러너+빠르다−분당 단가가 더 높다−시간 문제는 그대로자체 러너선택+시간 과금 없음+도구를 미리 설치−컴퓨터를 직접 관리−격리도 직접 책임
자체 러너를 골랐다. 대신 남의 코드가 우리 컴퓨터에서 도는 책임이 생긴다 — 4·7·8장

자체 러너 선택 이유와 책임

  • 이미 AWS 계정과 Terraform 이 있어서 컴퓨터 한 대를 더하는 일이 작았다
  • 작업이 대부분 짧고 가벼워 작은 인스턴스 한 대로 충분했다
  • 대신 “남의 코드가 우리 컴퓨터에서 돈다” 는 책임이 생긴다
    • 4장, 7장, 8장이 그 책임에 대한 이야기다

4. Runner Host — 스팟 인스턴스 한 대

정의 위치와 적용 주체

  • 러너 컴퓨터는 terraform/github-actions-foundation/ci-runner.tf 에 있다
  • 이 root 는 GitHub Actions 의 배포 역할(OIDC)을 만드는 곳이라 관리자가 직접 적용한다
  • 이유: 러너가 Actions 에 기대어 만들어지면, 러너가 없을 때 러너를 만들 수 없다
인스턴스t3.large · 스팟짧은 작업 위주라 남는 용량을 싸게회수될 때정지 (persistent + stop)다시 켜지면 러너가 그대로네트워크전용 VPC 10.70.0.0/24서브넷 하나 · 피어링 없음들어오는 규칙없음러너는 바깥으로만 연결 (5장)인스턴스 역할SSM 접속 권한만S3·ECR 등 다른 권한 없음메타데이터 (IMDS)토큰 필수 · hop 1작업 컨테이너가 역할을 못 읽게디스크gp3 60GB · 암호화이미지와 작업 폴더설치도커만 (user data)러너는 5장의 스크립트가 켬비용·회수네트워크권한기타
주황은 비용, 파랑은 네트워크, 빨강은 권한. 들어오는 문이 없고, 들어와도 쥘 권한이 거의 없다
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 로 옮겼다
ap-northeast-2a · t3a.large스팟 요청 → 남는 용량 없음InsufficientInstanceCapacityapply 20분 대기중단 → 구역과 종류를 바꿈ap-northeast-2d · t3.large같은 시점 재고 있음스팟 가격 가장 낮음바로 생성지금 러너가 있는 곳회수될 때 — persistent 요청 + stop실행작업 처리회수인스턴스 정지대기작업은 큐에 쌓임재시작러너 그대로 복귀
스팟은 가격만이 아니라 재고도 구역마다 다르다. 정지(stop) 방식이라 디스크의 러너 키가 남아 다시 등록할 필요가 없다

인스턴스 구성: 도커만

  • 인스턴스에는 도커만 설치한다(user data)
  • 러너 프로그램과 도구는 이미지에 담는다(6장)
  • 러너를 켜는 것은 5장의 등록 스크립트가 한다

5. Registration — 등록 토큰과 러너 키

관리자 노트북SSM러너 컨테이너GitHub1등록 토큰 주세요 (조직 관리자)토큰 · 1시간 뒤 만료2docker run + 토큰컨테이너 실행3키 쌍 만들기RSA 개인키 · 공개키4토큰 + 공개키러너 번호 · 접근 주소5디스크에 저장.runner · .credentials*여기서 등록 토큰은 끝 · 이후 계속6개인키로 서명한 요청접근 토큰 · 1시간만료되면 개인키로 다시 받음7일 있어요? (long polling)작업 + Secrets개인키는 보내지 않는다
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
우리가 더함 · scripts/ci-runner/Dockerfile워크플로가 바로 부르는 도구ruby스크립트 단계python3 · pip · venv파이썬 검사jq · zip · unzipJSON · 압축build-essential컴파일aws cli v2AWS 명령kubectl · kustomize매니페스트 검사바탕 · myoung34/github-runnerGitHub 러너 프로그램 + 시작 스크립트RUNNER_TOKEN · ORG_NAME 을 읽어 등록부터 대기까지운영체제Ubuntu 24.04 (noble)넣지 않음nodejavaterraformhelmsetup-* 액션이 작업마다원하는 버전을 설치이미지에도 넣으면 버전이두 군데에서 정해진다
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 러너로 되돌렸다
비공개 저장소kukie-infrakukie-electronkukie-manifestkukie-ci (우리 EC2)runs-on: kukie-ci공개 저장소kukie-serveragentGitHub 러너공개 저장소는 시간 제한 없이 무료공개 저장소를 막는 이유 — 포크 PR누구나 포크해 워크플로 파일을 고친 PR 을 올릴 수 있다 → 열어 두면 모르는 사람의 코드가 우리 EC2 에서 돈다
러너 그룹(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장의 근거다.
→ GitHub Docs — About self-hosted runners
조직 러너 등록 토큰을 받는 API. 5장의 등록 토큰이 이것이다.
→ GitHub REST API — Create a registration token for an organization
러너 그룹과 공개 저장소의 사용 제한. 7장의 근거다.
→ GitHub Docs — Managing access to self-hosted runners using groups
자체 러너의 보안 주의점. 공개 저장소와 작업 간 흔적. 7장과 8장의 근거다.
→ GitHub Docs — Hardening for self-hosted runners
러너 프로그램 소스. 등록 시 키 쌍과 자격 증명 파일이 만들어지는 과정을 확인할 수 있다.
→ actions/runner
바탕 이미지와 환경 변수(RUNNER_TOKEN, ORG_NAME, LABELS 등). 6장의 1층이다.
→ myoung34/docker-github-actions-runner
SSM Run Command. 들어오는 포트 없이 인스턴스에 명령을 보내는 방식. 5장의 전달 경로다.
→ AWS Systems Manager — Run Command
스팟 인스턴스의 중단 동작(종료·정지·최대 절전)과 persistent 요청. 4장의 설정이다.
→ Amazon EC2 — Behavior of Spot Instance interruptions
IMDSv2 의 토큰 필수와 hop limit. 4장과 8장에서 컨테이너의 인스턴스 역할 접근을 다룬 근거다.
→ Amazon EC2 — Configure the instance metadata options
GitHub OIDC 토큰의 주장(sub 의 repo · ref)과 AWS 역할 신뢰 정책. 7장의 근거다.
→ GitHub Docs — Configuring OpenID Connect in Amazon Web Services