0. Contents


1. Overview

10-19자체 러너 kukie-ci상주 컨테이너 3개apply 는 GitHub 러너약점: 재사용 · docker.sock10-21apply → CodeBuild무료 시간 소진으로 멈춤작업마다 버리는 빌드SCP → 개인 액세스 토큰10-22사고 두 건과 수정남은 파일 → 일회용 러너18:36 슬롯 3개 적용429 → Claude Code 고정그대로인 것EC2 한 대(t3.large 스팟) · 들어오는 포트 없음 · 공개 저장소는 GitHub 러너 · docker.sock 공유(7장)
사흘 동안 바뀐 셋 — 그중 일회용 러너는 09편 8장의 약점이 실제 사고가 된 결과다

09편 이후 사흘

  • 09편(10월 19일)에서 비공개 저장소의 CI 작업을 우리 EC2 한 대로 옮겼다. 그 러너의 이름이 kukie-ci 다
  • 그 글의 8장에 “알고는 있지만 일단 둔 약점” 두 가지를 적었다
    • 재사용 컨테이너 — 작업이 끝나도 컨테이너를 지우지 않고 다음 작업에 그대로 쓴다
    • docker.sock — 작업이 서버의 Docker 를 직접 다룰 수 있다
  • 그 뒤 사흘 동안 세 가지를 바꿨다. 그중 하나는 첫째 약점이 실제 사고로 이어져서 바꾼 것이다

바뀐 것 세 가지

  • 10월 21일 — apply 를 CodeBuild 로
    • 무료 시간이 바닥나 인프라를 바꾸는 /apply 작업이 시작되지 않았다
    • 가장 강한 권한을 받는 작업이라, 공용 서버인 kukie-ci 가 아니라 작업마다 새로 띄우고 버리는 CodeBuild 로 옮겼다
  • 10월 22일 — 일회용 러너
    • 리뷰 도구가 남긴 파일 하나 때문에, 예전 PR 의 리뷰 코멘트가 다른 PR 에 다시 달렸다
    • 작업 하나가 끝나면 컨테이너를 지우고 새로 띄우는 방식으로 바꿨다
  • 10월 22일 — Claude Code 를 이미지에
    • 리뷰 도구가 작업마다 Claude Code 를 내려받는데, 그 다운로드 서버가 요청을 거절해(429) 모든 리뷰가 실패했다
    • 러너 이미지에 미리 넣어 두었다

그대로인 것

  • 러너 서버는 여전히 EC2 한 대(t3.large 스팟, ap-northeast-2d)이고, 밖에서 들어오는 포트가 없다
  • 공개 저장소(kukie-server, agent)는 계속 GitHub 러너를 쓴다
  • docker.sock 은 아직 공유한다 — 7장에서 다루고, 다음 편(ARC on EKS)이 이 문제를 푼다

2. Apply on CodeBuild — 가장 강한 권한의 자리

막힌 작업: /apply

  • /apply 는 Terraform 으로 실제 인프라를 바꾸는 작업이고, 가장 강한 배포 권한을 받는다
  • 09편에서 다른 작업은 kukie-ci 로 옮겼지만, /apply 만은 GitHub 러너에 남겨 두었다
    • kukie-ci 는 여러 작업이 돌려 쓰는 서버이고, 아직 검토되지 않은 PR 코드도 그 위에서 돈다
    • 그런 곳에서 가장 강한 권한을 받는 작업을 돌리고 싶지 않았다
  • 그런데 조직의 무료 시간을 다 써서, GitHub 러너에 남은 /apply 가 실패도 아니고 아예 시작되지 않았다(“spending limit”)

선택: CodeBuild 러너

  • CodeBuild 는 AWS 의 빌드 서비스다. GitHub Actions 의 작업을 대신 받아 실행해 주는 기능이 있다
  • 작업이 오면 새 빌드 환경을 하나 띄우고, 끝나면 통째로 버린다
    • GitHub 러너와 같은 방식이라 앞 작업의 흔적이 남지 않는다
    • 비용은 GitHub 이 아니라 우리 AWS 계정에 나온다
  • 워크플로에서 바꾼 것은 이번에도 runs-on 한 줄이다. 이 이름의 작업은 CodeBuild 가 받아 간다
runs-on: codebuild-kukie-terraform-apply-${{ github.run_id }}-${{ github.run_attempt }}
PR 댓글/applymain 의 워크플로가 받음작업 대기runs-on: codebuild-…webhook 이 CodeBuild 에 알림CodeBuild 빌드작업마다 새로 띄움끝나면 통째로 버림OIDC 토큰GitHub 이 서명sub = main 브랜치AWS STSsub 가 main 이면apply 역할을 내줌terraform apply인프라 변경빌드와 함께 자격증명도 사라짐PR 브랜치에서 같은 이름의 워크플로를 만들면그 작업의 sub 는 main 이 아니다 → STS 가 apply 역할을 주지 않는다 → 빌드도 끝나면 버려진다
권한은 러너가 아니라 '어느 브랜치에서 온 작업인가'(OIDC sub)에 묶여 있다 — 러너를 CodeBuild 로 옮겨도 역할은 그대로

CodeBuild 쪽에 만든 것

  • 프로젝트 kukie-terraform-apply — Ubuntu 24.04 이미지(aws/codebuild/standard:8.0), 가장 작은 크기, 최대 60분
  • 서비스 역할 — CodeBuild 자체가 쓰는 역할이다. 자기 로그를 쓰는 권한뿐이고, 비밀 값이나 GitHub 토큰은 읽지 못한다
  • webhook — GitHub 이 “apply 작업이 대기 중” 이라고 알려 주면 그때만 빌드를 시작한다
  • 인프라를 바꾸는 권한은 여기 없다. 그 권한은 작업이 실행될 때 GitHub OIDC 로 따로 받는다(아래)

배포 권한: 바꾼 것 없음

  • apply 역할은 “어느 저장소의 어느 브랜치에서 온 작업인가” 만 보고 권한을 준다. main 브랜치일 때만 준다
  • 그 작업이 GitHub 러너에서 도는지, CodeBuild 에서 도는지는 보지 않는다
    • 그래서 러너를 옮겨도 역할과 정책은 그대로다
  • 누군가 PR 에서 같은 이름의 워크플로를 만들어 이 러너를 쓰더라도, 그 작업은 main 이 아니라서 apply 권한을 받지 못한다
    • 그 빌드도 끝나면 버려진다

GitHub 인증: 개인 액세스 토큰

  • CodeBuild 가 GitHub 의 작업을 받으려면 GitHub 에 로그인할 방법이 필요하다
  • 처음 계획은 AWS 가 제공하는 GitHub 앱 연결(CodeConnections)이었다
  • 그런데 적용 중에 연결 만들기가 상위 조직의 정책(SCP)에 막혔다
    • SCP 는 우리 계정 위의 AWS Organizations 가 거는 규칙이다. 우리 계정의 관리자 권한보다 우선한다
    • 메시지: explicit deny in a service control policy
  • 검토한 방법은 넷이었다
    • 조직 관리자에게 허용을 요청한다 — 가장 깔끔하지만 승인을 기다려야 한다
    • Actions 사용 한도를 올린다 — 예산 승인이 필요하다
    • 다음 달 무료 시간이 채워질 때까지 기다린다 — 그동안 /apply 를 못 한다
    • 팀원의 개인 액세스 토큰을 쓴다 — 승인 없이 바로 된다. 이것을 골랐다
  • 옛 API 이름으로 연결을 만들면 SCP 를 피할 수도 있었다. 하지만 조직 정책을 몰래 돌아가는 일이라 하지 않았다

토큰을 쓰면서 생기는 위험과 대응

  • 팀원 한 명의 계정에 묶인다
    • 그 사람이 빠지거나 토큰이 만료(60일)되면 /apply 가 다시 멈춘다
    • 발급자와 만료일을 기록해 두고, 만료 전에 같은 방법으로 다시 등록한다
  • 저장소 관리 권한이 필요하다(CodeBuild 가 러너를 등록할 때 쓴다)
    • apply 러너가 있는 저장소만 골라 그 권한을 준다
  • 토큰 값이 어딘가에 남을 수 있다
    • aws codebuild import-source-credentials 명령으로 CodeBuild 에만 등록한다
    • Terraform 으로 만들지 않아 state 파일에 남지 않는다
    • CodeBuild 의 역할도 이 값을 읽을 수 없어, AWS API 로 다시 꺼낼 방법이 없다
  • 등록 명령은 토큰이 맞는지 확인하지 않는다
    • 처음 등록한 값으로는 webhook 만들기가 두 번 실패했다. 토큰을 새로 만들어 다시 등록하자 성공했다

첫 실행과 확장

  • 10월 21일 첫 /apply 가 CodeBuild 에서 돌아 OIDC 로 apply 권한을 받고 적용까지 마쳤다(약 2분 25초)
  • 다음 날 kukie-hosting-infra, 26일 kukie-gpu-infra 에도 같은 러너를 붙였다(Terraform for_each 로 저장소마다 하나씩)

3. Incident — 남은 파일과 다시 달린 코멘트

kukie-ci-1 — 계속 다시 쓰는 컨테이너리뷰 — electron #127코멘트 1개를 버퍼에 모아 올림올린 뒤 파일은 그대로다음 리뷰 16번같은 버퍼를 그대로 읽음남은 코멘트를 다시 올림/tmp/inline-comments-buffer.jsonl씀읽음같은 구조로 남던 것/root/.claude — Claude 세션 기록/root/.docker/config.json — ECR 로그인/_work/... — 다른 저장소 체크아웃올라감 — 4번같은 파일을 바꾼 electron PR#128 · #129 · #135(2번)실패 — 12번 (422)경로가 없는 PRinfra 5 · hosting 1 · electron 6kukie-ci-2 · 3 의 버퍼는 늘 비어 있었다
버퍼 파일 하나가 컨테이너에 남아 다음 리뷰 16번에 따라갔다 — GitHub 러너라면 작업마다 새 VM 이라 생기지 않는 일

발견: 엉뚱한 PR 에 달리려던 코멘트

  • 10월 22일, kukie-hosting-infra PR 의 자동 리뷰가 실패했다
  • 실패 이유가 이상했다. kukie-electron 저장소의 파일(Thinking.tsx:75)에 코멘트를 달려고 했다
  • hosting-infra 에는 그런 파일이 없으니 GitHub 이 거절했다(422)

원인: 지우지 않는 파일 + 다시 쓰는 컨테이너

  • 리뷰 도구(anthropics/claude-code-action)는 리뷰하면서 코멘트를 /tmp/inline-comments-buffer.jsonl 파일에 모아 두었다가, 끝에 한꺼번에 PR 에 올린다
  • 이 파일은 작업용 임시 폴더(RUNNER_TEMP) 밖에 있고, 올린 뒤에도 지우지 않는다
  • GitHub 러너에서는 문제가 안 됐다. 작업마다 새 컴퓨터라 파일이 같이 사라지기 때문이다
  • kukie-ci 는 컨테이너를 계속 다시 쓴다. 그래서 다음 리뷰가 앞 리뷰의 파일을 그대로 읽고, 그 코멘트를 또 올리려 했다
  • 09편 8장에 “작업이 남긴 파일을 다음 작업이 볼 수 있다” 고 적은 약점이 그대로 일어난 것이다

피해 범위: 4일 치 리뷰 로그

  • 10월 19~22일의 리뷰 로그를 모두 훑었다
  • 남아 있던 것은 kukie-electron PR #127 의 코멘트 하나였고, 러너 셋 중 kukie-ci-1 에만 있었다
  • 그 뒤 kukie-ci-1 에서 돈 리뷰 16번이 모두 이 코멘트를 다시 올리려 했다
    • 4번은 실제로 올라갔다 — 같은 파일을 고친 kukie-electron PR(#128, #129, #135 두 번)이라 경로가 맞았다
    • 12번은 그 파일이 없는 PR 이라 실패했다(kukie-infra 5, kukie-hosting-infra 1, kukie-electron 6)
  • 다른 저장소 PR 에 올라간 것은 없다. kukie-ci-2·kukie-ci-3 의 파일은 늘 비어 있었다

같은 방식으로 남던 다른 것

  • 리뷰 도구는 아니지만 같은 이유로 다음 작업에 넘어가던 것이 더 있었다
    • /tmp/github-images — 리뷰 도구가 받아 둔 이미지 파일
    • /root/.claude — Claude 세션 기록
    • /root/.docker/config.json — ECR(이미지 저장소) 로그인 정보
    • /_work/... — 다른 저장소에서 받아 온 코드

당장의 조치와 진짜 해결

  • 남은 파일은 SSM 으로 서버에 들어가 손으로 지웠다
  • 하지만 다음 작업부터 다시 쌓이는 구조라, 파일을 지우는 것만으로는 끝나지 않는다
  • 진짜 해결은 컨테이너를 다시 쓰지 않는 것이었다(4장)

4. Ephemeral Runner — 작업 하나에 컨테이너 하나

EC2 kukie-ci — systemd 슬롯 3개kukie-ci@1kukie-ci@2kukie-ci@3① 토큰 발급runner-slot.sh② 등록--ephemeral③ 작업 1개job④ 삭제--rm10초 뒤 다시 — 실패하면 한 주기 최소 60초볼륨 없음09편의 /runner/data 와 러너 키 파일이 작업마다 새로 생기고 함께 사라진다SSM 파라미터/kukie-ci/github-app/*App ID · 비밀 키GitHub APIApp 키 → 설치 토큰→ 등록 토큰(1시간)설치 토큰은 바로 폐기10-22 18:36 ~ 10-27컨테이너 482개성공한 작업 457개① 키 읽기① 토큰 받기
슬롯마다 같은 순환 — 작업 하나가 끝나면 컨테이너와 그 안의 파일·러너 키가 함께 사라진다

바꾼 방식: 작업마다 새 컨테이너

  • 러너 자리(슬롯)를 셋 두고, 각 슬롯을 systemd 서비스 kukie-ci@1, @2, @3 이 맡는다
    • systemd 는 리눅스에서 프로그램을 띄우고, 죽으면 다시 띄워 주는 관리자다
  • 슬롯 하나는 이렇게 돈다
    • ① 러너 등록에 필요한 토큰을 받는다(5장)
    • ② 새 컨테이너를 “작업 하나만 받는 러너”(--ephemeral) 로 GitHub 에 등록한다
    • ③ 작업 하나를 실행한다
    • ④ 작업이 끝나면 러너 등록이 풀리고, 컨테이너가 지워진다(--rm)
    • 10초 뒤 systemd 가 다시 ①부터 시작한다
[Unit]
Description=kukie-ci GitHub Actions runner slot %i (one job per container)
StartLimitIntervalSec=0

[Service]
ExecStart=/opt/kukie-ci/runner-slot.sh %i
ExecStopPost=-/usr/bin/docker rm -f kukie-ci-%i
Restart=always
RestartSec=10
  • 슬롯 스크립트가 컨테이너를 띄우는 부분은 이렇다. EPHEMERAL=true 가 “작업 하나만”, --rm 이 “끝나면 삭제” 다
docker run --rm --name "kukie-ci-$SLOT" \
  -e RUNNER_SCOPE=org -e ORG_NAME="$ORG" -e RUNNER_TOKEN -e RUNNER_NAME="kukie-ci-$SLOT" -e LABELS=kukie-ci \
  -e EPHEMERAL=true -e DISABLE_AUTOMATIC_DEREGISTRATION=true -e UNSET_CONFIG_VARS=true \
  -v /var/run/docker.sock:/var/run/docker.sock "$IMAGE" ./run.sh
09편 — 상주 컨테이너10-19 ~ 10-22 18:36kukie-ci-1 (계속 다시 씀)작업 1작업 2작업 3볼륨 /runner/data — 러너 키를 계속 보관다음 작업에 남는 것/tmp 버퍼 · Claude 세션 · ECR 로그인 · 체크아웃 · 키지금 — 일회용 컨테이너10-22 18:36 ~새 컨테이너 → 작업 1 → 삭제--rm새 컨테이너 → 작업 2 → 삭제--rm새 컨테이너 → 작업 3 → 삭제--rm다음 작업에 남는 것 없음러너 키도 작업마다 새로 생기고 함께 사라짐
같은 슬롯이라도 지금은 작업마다 다른 컨테이너 — 앞 작업의 파일과 키가 다음 작업에 닿지 않는다

09편과 달라진 점

  • 09편에서는 컨테이너마다 볼륨(/runner/data)을 붙여, 러너 정보와 키 파일을 계속 들고 있었다
  • 이제는 볼륨이 없다
    • 러너 정보와 키가 작업마다 새로 만들어지고, 컨테이너와 함께 사라진다
    • 키 하나가 새어도 그 작업이 끝나면 쓸모가 없다
  • 3장의 리뷰 파일, 세션 기록, 로그인 정보도 다음 작업으로 넘어가지 않는다

리뷰에서 다듬은 것

  • 실패하면 최소 60초 쉬기
    • 이미지가 없거나 디스크가 차면 컨테이너가 바로 죽는다. 그러면 systemd 가 10초마다 다시 띄우면서 GitHub API 를 계속 두드린다
    • 그래서 한 바퀴가 최소 60초가 되도록 기다리게 했다
    • 처음에는 모든 바퀴를 60초로 늦췄는데, 그러면 금방 끝난 정상 작업 뒤에도 1분을 쉰다. 1분 넘게 돈 컨테이너는 바로 다시 뜨게 고쳤다
  • 같은 이름으로 덮어쓰기(--replace)
    • 작업을 받기 전에 컨테이너가 멈추면 GitHub 에 등록만 남는다
    • 슬롯마다 이름을 고정하고 덮어쓰게 해, 꺼진 러너가 목록에 쌓이지 않게 했다
  • ./run.sh 로 실행
    • GitHub 이 새 러너 버전을 내면 러너가 스스로 업데이트한다
    • 기본 실행 방식(서비스 모드)은 업데이트 후 그대로 끝나 버려서, 새 컨테이너마다 업데이트만 하다 작업을 못 받는다
    • ./run.sh 는 업데이트 뒤 같은 컨테이너 안에서 다시 떠서 이어서 작업을 받는다

적용과 결과

  • 10월 22일 18:36(KST)에 슬롯 셋을 켰다
  • 10월 27일까지 컨테이너 482개가 떴다가 지워졌고, 그중 457개의 작업이 성공으로 끝났다
  • 예전 컨테이너의 볼륨은 하나도 남아 있지 않다

5. Registration Token — GitHub App 의 발급

문제: 작업마다 등록하려면 토큰이 매번 필요하다

  • 러너를 GitHub 에 붙이려면 등록 토큰이 있어야 한다. 1시간짜리 일회용 값이다
  • 09편에서는 관리자가 이 토큰을 직접 받아 서버에 넘겼다. 러너 셋을 한 번 등록하면 끝이었다
  • 일회용 러너는 작업마다 다시 등록한다. 사람이 매번 토큰을 받아 줄 수 없다
  • 그래서 프로그램이 스스로 토큰을 받게 했다. 09편 8장에서 “GitHub App 이 필요하다” 고 적은 부분이다

GitHub App kukie-ci

  • GitHub App 은 사람 계정이 아니라 프로그램용 계정이다. 필요한 권한만 따로 받을 수 있다
  • 이 App 의 권한은 조직의 “Self-hosted runners: Read and write” 하나뿐이다. 러너를 등록하는 일 말고는 못 한다
  • App 의 번호(ID)와 비밀 키는 AWS SSM 파라미터(/kukie-ci/github-app/*)에 넣고, 내려받은 키 파일은 지웠다
runner-slot.sh서버SSM 파라미터App ID · 키GitHub API조직 durokukie러너 컨테이너작업 1개① App 키 읽기② JWT(9분) → 설치 토큰③ 등록 토큰(1시간) 받고 설치 토큰 폐기④ 등록 토큰만 전달App 키는 안 들어감
사람 대신 슬롯 스크립트가 토큰을 받는다 — 컨테이너가 받는 것은 1시간짜리 등록 토큰 하나뿐

토큰을 받는 순서

  • ① 슬롯 스크립트가 서버의 역할로 SSM 에서 App 키를 읽는다
  • ② App 키로 서명한 짧은 증명(JWT, 9분)을 보내 설치 토큰을 받는다. 이 조직에서 App 이 쓸 수 있는 임시 출입증이다
  • ③ 설치 토큰으로 러너 등록 토큰(1시간)을 받고, 설치 토큰은 바로 폐기한다
  • ④ 러너 컨테이너에는 등록 토큰만 넘긴다. App 키는 컨테이너에 들어가지 않는다
  • 토큰은 명령줄 인자가 아니라 표준 입력으로 넘겨, 서버의 프로세스 목록에 보이지 않게 했다

바뀐 점

  • 러너를 등록하는 데 GitHub 조직 관리자가 필요 없다. 슬롯이 알아서 받는다
  • 토큰이 SSM 명령 기록에 남지 않는다. 서버 안에서 받아 바로 쓰기 때문이다

알고 둔 점: 서버 역할이 읽을 수 있는 범위

  • 러너 서버의 역할에 원래 붙은 AmazonSSMManagedInstanceCore 는 이 계정의 모든 SSM 파라미터를 읽을 수 있다
  • 그러니 러너 작업이 읽으면 안 되는 비밀은 SSM 파라미터에 두지 않는다. 이 역할이 못 읽는 Secrets Manager 같은 곳에 둔다
  • App 키 자체가 위험해질 수 있는 경로는 7장에서 다룬다

6. Runner Image — Claude Code 고정

사고: 설치 서버의 429

  • 리뷰 도구는 작업마다 curl https://claude.ai/install.sh | bash 로 Claude Code 를 새로 내려받는다
  • 10월 22일부터 이 다운로드 서버가 429(“요청이 너무 많다”)를 돌려줬다
  • 내려받지 못하니 모든 리뷰가 시작 단계에서 실패했다

조치: 이미지에 미리 넣기

  • 같은 프로그램이 npm 저장소에도 올라와 있다. 그것을 버전과 해시(SHA-512)를 고정해 러너 이미지에 넣었다
    • 해시를 고정해 두면, 받은 파일이 바뀌었을 때 빌드가 실패해 알아챌 수 있다
  • 리뷰 워크플로는 버전이 맞으면 내려받지 않고 이미지에 든 파일을 쓴다(path_to_claude_code_executable)
ARG CLAUDE_CODE_VERSION=2.1.281
ARG CLAUDE_CODE_SHA512=67b95dec…
RUN curl -fsSL "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-${CLAUDE_CODE_VERSION}.tgz" -o /tmp/claude-code.tgz \
 && echo "${CLAUDE_CODE_SHA512}  /tmp/claude-code.tgz" | sha512sum -c - \
 && tar -xzf /tmp/claude-code.tgz -C /tmp package/claude \
 && install -m 0755 /tmp/package/claude /usr/local/bin/claude
전작업마다claude.ai/install.sh429 → 리뷰 전부 실패후이미지 빌드 때 한 번npm · 버전 + SHA-512작업은 이미지의 파일관리 규칙이미지의 버전 = 리뷰 도구가 기대하는 버전 — 리뷰 도구를 올릴 때 버전과 해시도 같이 올린다
외부 다운로드를 작업마다 하지 않고 이미지 빌드 때 한 번 — 해시가 다르면 빌드가 실패한다

관리 규칙

  • 이미지의 Claude Code 버전은 리뷰 도구가 기대하는 버전과 같아야 한다
  • 리뷰 도구 버전을 올릴 때 이 버전과 해시도 같이 올린다. 안 맞으면 다시 작업마다 내려받는다
  • 09편 6장과 같은 이야기다. GitHub 러너가 말없이 해 주던 것(도구, 그리고 여기서는 외부 다운로드)을 자체 러너에서는 직접 챙겨야 한다

7. Remaining Risk — docker.sock

아직 공유하는 것: 서버의 Docker

  • docker.sock 은 서버의 Docker 를 조종하는 통로다. 이걸 쥐면 서버에서 컨테이너를 마음대로 띄울 수 있다
  • kukie-electron 의 CI 가 이 통로로 이미지를 빌드한다. 그래서 컨테이너를 일회용으로 바꿔도 이 통로는 뺄 수 없었다

남는 공격 경로

  • 비공개 저장소에 브랜치를 올릴 수 있는 사람이라면, 작업 코드로 이렇게 할 수 있다
    • ① docker.sock 으로 서버 네트워크를 그대로 쓰는 컨테이너(--network host)를 띄운다
    • ② 그 컨테이너에서는 서버의 역할 자격증명(IMDS)에 닿을 수 있다. 09편에서 건 hop limit 1 제한이 이 경우엔 듣지 않는다
    • ③ 서버 역할로 SSM 의 App 키를 읽는다
    • ④ App 키로 아무 이름의 러너를 등록해, 다른 비공개 저장소의 작업을 가로챌 수 있다
① 작업 코드docker.sock 사용② host 네트워크--network host③ 서버 역할IMDS hop limit 무력④ SSM 의 App 키모든 파라미터 읽기⑤ 가짜 러너 등록다른 저장소 작업 가로채기공격 가능 범위비공개 저장소에 코드를 올릴 수 있는 사람곧 팀원 — 그래서 공개 저장소는 이 러너를 쓰지 않는다일회용 전환 전에도 같은 위험이 있었다차단 방법소켓 없이 이미지를 빌드하는 도구로 바꾸기또는 ARC — 파드 · 전용 노드 · 네트워크 정책 · IMDS 차단다음 편(ARC on EKS)의 주제
일회용 컨테이너로도 막지 못한 경로 — 시작점이 docker.sock 이라 소켓을 빼거나 ARC 로 옮겨야 끊긴다

이 위험의 범위

  • 이 경로를 쓸 수 있는 사람은 비공개 저장소에 코드를 올릴 수 있는 사람, 곧 팀원뿐이다
  • 일회용으로 바꾸기 전에도 같은 위험이 있었다. 그때도 docker.sock 으로 러너 등록 정보를 읽을 수 있었다
  • 그래서 이 러너는 비공개 저장소에만 쓰고, 공개 저장소는 계속 GitHub 러너에 둔다

막는 방법

  • 작업에서 docker.sock 을 빼는 것 — 소켓 없이 이미지를 빌드하는 도구로 바꾼다
  • 또는 Actions Runner Controller(ARC) 로 옮기는 것
    • 러너를 쿠버네티스 파드로 띄우고, 전용 노드와 네트워크 정책, IMDS 차단으로 격리한다
    • 다음 편의 주제다

8. Discussion

남은 위험

  • docker.sock 공유 (7장)
    • 사용자가 팀원뿐이라는 전제 위에 있다. ARC 로 옮기기 전까지 남는다
  • 개인 액세스 토큰 (2장)
    • 팀원 한 명의 계정에 묶여 있다. 그 사람이 빠지거나 토큰이 만료되면 /apply 가 시작되지 않는다
    • 조직이 CodeConnections 를 허용하면 GitHub App 연결로 바꾸고 토큰을 폐기한다
  • Claude Code 버전 고정 (6장)
    • 리뷰 도구를 올릴 때 같이 올리지 않으면, 다시 작업마다 내려받는다

핵심 메시지

  • 알고 둔 약점은 언젠가 사고가 된다
    • 09편 8장에 적은 “다음 작업이 볼 수 있다” 가 사흘 만에 남의 PR 코멘트로 나타났다
    • 미리 적어 두었기 때문에 원인은 금방 찾았다
  • 일회용으로 바꾸려면 토큰을 사람이 아니라 프로그램이 받아야 한다
    • 등록 토큰은 GitHub App 이 받고, App 키는 서버 안에서만 쓰고, 컨테이너에는 등록 토큰만 넘긴다
  • 가장 강한 권한은 가장 깨끗한 곳에
    • apply 는 공용 러너가 아니라, 작업마다 새로 띄우고 버리는 CodeBuild 에서 돈다
    • 권한은 러너가 아니라 저장소와 브랜치에 묶여 있어서, 러너를 옮겨도 권한 설계는 그대로다

9. References

자체 러너를 작업 하나만 받고 등록이 풀리는 ephemeral 러너로 쓰는 방법. 4장의 근거다.
→ GitHub Docs — Using ephemeral runners for autoscaling
조직 러너 등록 토큰을 받는 API. 5장의 ③ 이 이것이다.
→ GitHub REST API — Create a registration token for an organization
GitHub App 이 JWT 로 설치 토큰을 받는 흐름. 5장의 ①② 의 근거다.
→ GitHub Docs — Authenticating as a GitHub App installation
자체 러너의 보안 주의점. 작업 사이에 남는 흔적과 공개 저장소. 3장과 7장의 근거다.
→ GitHub Docs — Hardening for self-hosted runners
CodeBuild 가 GitHub Actions 작업을 받아 빌드마다 러너를 띄우는 방식과 runs-on 라벨. 2장의 구성이다.
→ AWS CodeBuild — Self-hosted GitHub Actions runners
CodeBuild 에 GitHub 개인 액세스 토큰을 등록하는 명령. 2장의 토큰 등록이 이것이다.
→ AWS CLI — codebuild import-source-credentials
조직의 SCP 가 계정의 관리자 권한보다 우선한다는 점. 2장에서 CodeConnections 가 막힌 이유다.
→ AWS Organizations — Service control policies
GitHub OIDC 토큰의 sub 와 AWS 역할 신뢰 정책. 2장에서 러너를 옮겨도 권한이 그대로인 근거다.
→ GitHub Docs — Configuring OpenID Connect in Amazon Web Services
리뷰 도구. 인라인 코멘트를 /tmp 버퍼에 모아 올리는 동작과 path_to_claude_code_executable 입력. 3장과 6장의 근거다.
→ anthropics/claude-code-action
바탕 이미지와 EPHEMERAL·UNSET_CONFIG_VARS 같은 환경 변수. 4장의 컨테이너 설정이다.
→ myoung34/docker-github-actions-runner
systemd 의 Restart·RestartSec·StartLimitIntervalSec. 4장의 슬롯 서비스 설정이다.
→ systemd.service
IMDSv2 의 hop limit. 7장에서 host 네트워크 컨테이너가 인스턴스 역할에 닿는 이유다.
→ Amazon EC2 — Configure the instance metadata options
이 글의 앞편. 상주 러너의 구성과 8장의 약점.
→ Kukie Infra.09 Self-hosted Actions Runner