0. Contents
1. Overview
사흘 동안 바뀐 셋 — 그중 일회용 러너는 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 }}
권한은 러너가 아니라 '어느 브랜치에서 온 작업인가'(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가 다시 멈춘다 - 발급자와 만료일을 기록해 두고, 만료 전에 같은 방법으로 다시 등록한다
- 그 사람이 빠지거나 토큰이 만료(60일)되면
- 저장소 관리 권한이 필요하다(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 — 남은 파일과 다시 달린 코멘트
버퍼 파일 하나가 컨테이너에 남아 다음 리뷰 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 — 작업 하나에 컨테이너 하나
슬롯마다 같은 순환 — 작업 하나가 끝나면 컨테이너와 그 안의 파일·러너 키가 함께 사라진다
바꾼 방식: 작업마다 새 컨테이너
- 러너 자리(슬롯)를 셋 두고, 각 슬롯을 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편과 달라진 점
- 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/*)에 넣고, 내려받은 키 파일은 지웠다
사람 대신 슬롯 스크립트가 토큰을 받는다 — 컨테이너가 받는 것은 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 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 이라 소켓을 빼거나 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장의 근거다.
조직 러너 등록 토큰을 받는 API. 5장의 ③ 이 이것이다.
GitHub App 이 JWT 로 설치 토큰을 받는 흐름. 5장의 ①② 의 근거다.
자체 러너의 보안 주의점. 작업 사이에 남는 흔적과 공개 저장소. 3장과 7장의 근거다.
CodeBuild 가 GitHub Actions 작업을 받아 빌드마다 러너를 띄우는 방식과 runs-on 라벨. 2장의 구성이다.
CodeBuild 에 GitHub 개인 액세스 토큰을 등록하는 명령. 2장의 토큰 등록이 이것이다.
조직의 SCP 가 계정의 관리자 권한보다 우선한다는 점. 2장에서 CodeConnections 가 막힌 이유다.
GitHub OIDC 토큰의 sub 와 AWS 역할 신뢰 정책. 2장에서 러너를 옮겨도 권한이 그대로인 근거다.
리뷰 도구. 인라인 코멘트를 /tmp 버퍼에 모아 올리는 동작과 path_to_claude_code_executable 입력. 3장과 6장의 근거다.
바탕 이미지와 EPHEMERAL·UNSET_CONFIG_VARS 같은 환경 변수. 4장의 컨테이너 설정이다.
systemd 의 Restart·RestartSec·StartLimitIntervalSec. 4장의 슬롯 서비스 설정이다.
IMDSv2 의 hop limit. 7장에서 host 네트워크 컨테이너가 인스턴스 역할에 닿는 이유다.
이 글의 앞편. 상주 러너의 구성과 8장의 약점.
