0. Contents


1. Overview

stateOIDC역할·경계Account Foundation관리자가 직접 적용 · 거의 안 바뀐다노드NATEKSPR 댓글 /apply 로 로봇이 적용ArgoCDLBCESORLDArgoCD + 공통 설비처음 한 번만 관리자가 Terraform 으로webmemberagentredisAppskukie-manifest 를 ArgoCD 가 배포
층을 띄워서 보면 — 아래 층일수록 드물게 바뀌고, 바꿀 수 있는 사람이 적다

Kukie 와 클러스터 구성

  • Kukie 는 세 명이 만드는 서비스다
  • 한 클러스터에 넷이 올라간다
    • 화면(web)
    • 회원 서버(member)
    • 쿠버네티스 운영 에이전트(agent)
    • Redis
  • 셋 다 쿠버네티스 초보다
  • 인프라를 세우면서 정한 원칙은 하나였다 — 리뷰와 기록 없이는 아무것도 바뀌지 않게 한다

이번 편과 이어지는 편

  • 이 글은 그 원칙을 코드로 옮긴 기록의 첫 편이다
  • 2장: 레포를 쓰기 권한 기준으로 나눈다
  • 3장: Terraform 을 PR 댓글로 승인해 적용하는 장치를 만든다
  • 그 로봇이 스스로 권한을 키우지 못하게 IAM 으로 묶은 이야기 → 02편
  • 그 위에 EKS 와 ArgoCD 를 세운 이야기 → 03편

2. Repository Split — 레포는 쓰기 권한의 단위다

레포 세 종류

  • 앱 코드 레포(web · member · agent): 언어도 만드는 사람도 달라서 원래부터 따로였다
  • 이 장에서 다룰 것은 나머지 둘이다
    • 클러스터에 어떤 버전을 띄울지 적는 배포 설정
    • 인프라 코드를 왜 한 레포에 두지 않았느냐
kukie-dev 클러스터앱 코드 레포원래 따로kukie-manifest택배함 · CI 봇kukie-infra집 · 관리자만ECRArgoCDwebmemberagentpushmain 만 읽는다pullCI 봇 열쇠는 택배함만 연다택배함: 이미지 버전 (자주 · 자동)집: Terraform · ArgoCD 지시서(드물게 · 사람이 승인)
GitHub 열쇠는 레포 단위다 — CI 봇이 매일 쓰는 칸만 택배함(kukie-manifest)으로 뗐다

배포 설정: 매일 바뀌는 칸

  • 앱 레포에 코드가 병합되면 그 레포의 CI 봇이 새 이미지를 빌드한다
  • 봇은 “이번 agent 는 이 이미지” 라는 digest 한 줄을 배포 설정에 커밋한다
  • ArgoCD 는 그 줄을 보고 새 버전으로 바꿔 띄운다
  • 택배 기사가 매일 물건을 두고 가는 것과 같다

문제: 레포 전체에 걸리는 열쇠

  • 봇이 커밋하려면 그 레포의 쓰기 권한이 있어야 한다
  • GitHub 의 쓰기 권한은 폴더가 아니라 레포 전체에 걸린다
    • “이 줄만 고쳐도 된다” 는 열쇠는 없다
  • 배포 설정이 kukie-infra 안에 있다면
    • 버전 한 줄을 바꾸라고 준 열쇠가 Terraform 과 ArgoCD 지시서까지 여는 마스터키가 된다

열쇠를 도둑맞았을 때의 피해 범위

  • 봇을 못 믿어서가 아니다
  • 그 열쇠를 누가 훔쳐 가도 피해가 어디서 멈추느냐의 문제다
  • 실제로 흔한 공격은 이런 모양이다
1. agent 가 쓰는 라이브러리 하나에 악성 코드가 섞인다        (공급망 공격)
2. agent CI 가 빌드하면서 그 코드가 실행된다
3. 악성 코드가 CI 봇이 쥔 kukie-infra 열쇠를 쓴다
4. ArgoCD 지시서를 클러스터 전체 권한으로 바꿔 push 한다
5. ArgoCD 가 main 을 보고 반영한다 → 클러스터가 넘어간다

택배함 분리: kukie-manifest

  • 봇이 매일 쓰는 칸만 kukie-manifest 라는 택배함으로 뗐다
  • 봇의 열쇠는 택배함만 연다
  • 같은 일이 벌어져도 악성 코드가 바꿀 수 있는 것은 앱 버전까지다
    • 그마저 03편에서 다룰 좁은 울타리 안에서만 배포된다

두 레포의 변경 주기

  • 나눠 놓고 보니 두 레포는 바뀌는 리듬부터 달랐다
kukie-infra드물게 · 사람이 승인승인plan → /apply승인plan → /apply승인plan → /applyAWS검사: validate · mock 테스트 · plan 댓글 되돌리기: 새 plan 으로kukie-manifest자주 · ArgoCD 가 자동ArgoCDdigest 커밋 = 배포 한 번검사: 렌더링 · 스키마 · 렌더 차이 댓글 되돌리기: 커밋 하나 revert점 하나 = 변경 하나 — 두 줄의 리듬이 다르면 레포도 다르게 둔다
드물게·승인으로 바뀌는 것과 자주·자동으로 바뀌는 것을 한 레포에 섞지 않는다

한 레포에 섞지 않는 이유

  • kukie-infra: 드물게 바뀌고, 바뀔 때마다 사람이 plan 을 보고 승인한다
  • kukie-manifest: 배포할 때마다 바뀌고 ArgoCD 가 알아서 반영한다
  • 리듬이 다른 둘을 한 레포에 섞으면 → 하루에도 여러 번 오는 버전 커밋 사이에 인프라 변경이 묻혀 리뷰하기 어렵다
  • 되돌리는 방법도 다르다
    • 앱: 커밋 하나를 되돌리면 끝이다
    • 인프라: 새 plan 을 만들어야 한다

ArgoCD 지시서의 위치

  • 마지막으로 반대 방향의 질문이 남는다 — 택배함을 뗐으니 ArgoCD 지시서도 택배함에 넣으면 편하지 않을까
  • 지시서에는 그 앱을 어느 울타리(ArgoCD 프로젝트) 안에서 배포할지가 적힌다
  • 울타리는 두 개다
    • kukie-apps: 앱용으로 좁게 만들었다
    • kukie-platform: 설비용으로 클러스터 전체를 다룬다
  • 지시서를 고칠 수 있으면 울타리도 고를 수 있다
  • 지시서가 택배함에 있으면 → 기사가 출입 허가서에 “집 전체” 라고 직접 써넣을 수 있는 셈이다

결론: 지시서는 kukie-infra 에

  • ArgoCD 공식 문서도 같은 경고를 한다 — 다른 지시서를 만드는 상위 지시서의 레포에는 관리자만 push 해야 한다(6장)
  • 출입 허가서인 지시서 → 관리자만 쓰는 kukie-infra 에 둔다
  • 택배함인 kukie-manifest → 울타리 안에 들어갈 내용(이미지 버전, 앱 YAML)만 둔다

3. Comment Approval — 병합과 적용 사이에 사람 한 명

3.1 Flow — 견적서는 두 번 뽑는다

병합과 적용 사이의 사람

  • 각자 노트북에서 apply 하면 → 누가 언제 무엇을 바꿨는지 남지 않는다
  • 병합하자마자 자동으로 apply 하면 → 리뷰가 형식이 된다
  • 그래서 그 사이에 사람을 한 명 끼웠다
PR검토 plan병합최종 plan/applyapplyPR 코드로 돈다 · 읽기 전용 역할main 코드로만 · 24시간 · 한 번만사람의 /apply 가 문을 연다파일로 저장
병합은 적용이 아니다 — 병합된 main 으로 다시 뽑은 견적서를, 사람이 번호로 승인해야 문이 열린다

PR 댓글로 남는 기록

  • 이 흐름은 전부 PR 댓글로 남는다
  • 한 PR 에 달리는 댓글을 순서대로 놓으면 이렇다
사람PR 열기 — terraform/dev/eks-platform 만 바꿈봇Terraform PR plan · 변경 사항 검토용생성 2 · 변경 2 · 삭제 0 — /apply 명령 없음PR 코드로 · plan 명찰견적만 보여 주고 파일은 저장 안 함봇Claude · Codex 리뷰 — 커밋마다 다시사람이 병합 (Merge)봇Terraform 최종 plan · 적용 승인 대기생성 2 · 변경 2 · 삭제 0 — 아래 명령을 새 댓글로/apply 123456789main 코드로 · plan 명찰plan 파일 저장 · 24시간 유효사람/apply 123456789명령 한 줄만 · 새 댓글write 권한자만 · 수정 댓글은 무시봇Terraform 적용 시작봇Terraform 적용 성공생성 2 · 변경 2 · 삭제 0 · 리소스별 결과main 코드로 · apply 명찰같은 번호는 한 번만terraform/dev 밖(foundation, ArgoCD 설치)이나 문서만 바꾼 PR 에는 두 plan 댓글이 없다 — 관리자가 로컬에서 적용
한 PR 의 댓글이 곧 적용 기록이다 — 견적은 두 번, 적용 명령은 최종 견적에만

검토용 plan 과 최종 plan

  • PR 을 열면 검토용 plan 이 댓글로 달린다
    • 리뷰용이라 적용 명령은 없다
  • 병합하면 병합된 main 으로 최종 plan 을 한 번 더 만든다
    • 이 실행만 plan 파일을 artifact 로 저장한다
    • 최종 plan 댓글에는 완성된 명령이 들어 있다

/apply 전 확인 네 가지

  • 그 명령을 같은 PR 에 새 댓글로 붙이면 워크플로가 먼저 확인한다
    • 댓글 작성자의 실제 저장소 권한
    • 그 실행이 main 의 성공한 최종 plan 인지
    • 24시간 안인지
    • artifact 해시가 맞는지
  • 확인이 끝나면 저장된 plan 을 그대로 apply 한다
  • 승인한 것과 적용되는 것이 같은 파일이다

견적서를 두 번 뽑는 이유

  • PR 이 열려 있는 동안 다른 PR 이 main 을 바꿀 수 있다
  • 사람이 승인해야 하는 건 PR 브랜치의 미래가 아니라 지금 main 의 결과다

3.2 Two Roles — 견적 명찰과 시공 명찰

로봇이 차는 명찰(IAM Role)

  • 로봇(GitHub Actions)은 AWS 키를 갖고 있지 않다
  • 일을 할 때마다 AWS 의 역할(IAM Role)을 잠깐 빌려 찬다
  • 역할은 권한을 묶어 놓은 명찰이다 → 명찰에 적힌 일만 할 수 있다
  • 이 레포의 로봇이 차는 명찰은 두 종류다
GitHubAWSplan 명찰 — 견적PR 열기또는 병합 직후↺ 견적서 댓글runnerPR · main 코드① 증명서 pull/N · mainSTSplan 명찰 규칙PR · main 이면 OK② 임시 키 = plan 명찰③ runner 가 그 키로 읽기읽기AWS · stateGitHubAWSapply 명찰 — 시공/apply 댓글사람이 승인↺ 결과 댓글runnermain 코드만① 증명서 mainSTSapply 명찰 규칙main 만 OK② 임시 키 = apply 명찰③ runner 가 그 키로 쓰기쓰기AWS · state✕PR 코드로는 apply 명찰 규칙을 통과 못 한다명찰을 차고 일하는 건 runner — 역할은 AWS 의 규칙, 사람은 PR · 병합 · /apply 댓글로 시킬 뿐
같은 runner, 같은 STS — 다른 것은 증명서의 ref 와 통과하는 규칙, 그리고 받은 키로 AWS 에 손을 대느냐다

명찰별 권한: 읽기와 쓰기

  • plan 명찰: 견적용
    • terraform plan 은 코드를 읽고, AWS 의 지금 상태를 조회하고, 지난번 기록(state)을 읽는다
    • 셋을 비교해 “출입증 1개 생성” 같은 견적서를 만든다
    • 조회와 읽기만 하므로 읽기 권한이면 된다
  • apply 명찰: 시공용
    • 견적서대로 AWS 에 실제로 만들고 바꾸고 지운 뒤 기록을 저장한다
    • 그래서 쓰기 권한이 필요하다
AWSEKSstateS3terraformplan 명찰terraform plan읽기만① 코드 읽기② 지금 상태 조회③ state 읽기④ 견적서AWSEKSstateS3terraformapply 명찰terraform apply실제로 바꾼다① 견적서② 생성 요청③ state 저장점선 파랑 = AWS 에서 가져온다 (읽기) · 실선 주황 = AWS 에 보낸다 (쓰기)
같은 terraform 이라도 plan 은 화살표가 안쪽으로만, apply 는 바깥으로 나간다 — 그래서 명찰이 다르다

빌려주는 조건의 차이

  • plan 명찰: PR 을 열었을 때(리뷰 전 코드)도, 병합한 뒤(main 코드)도 빌려준다
    • 견적은 리뷰에 쓰려고 뽑는 것이기 때문이다
  • apply 명찰: main 코드로 돌 때만 빌려준다
  • 사람은 어느 명찰도 직접 차지 않는다 — 병합과 /apply 댓글로 로봇에게 시킬 뿐이다
    • 그래야 누가 어떤 견적서를 승인했는지가 GitHub 에 남는다
    • 리뷰 안 된 코드를 노트북에서 바로 적용하는 길도 없어진다

root 별 plan 명찰 분리 검토

  • root 가 늘어나면서 plan 명찰도 root 마다 나누자는 생각이 들었다
    • ECR 견적은 ECR 쪽만, EKS 견적은 EKS 쪽만 읽게 하자는 것이다
  • 그런데 나눠 봐야 울타리가 되지 않는다

원인: 증명서에 없는 폴더 정보

  • AWS 가 명찰을 내줄 때 보는 것은 GitHub 이 써 준 증명서(OIDC 토큰)다
  • 거기에는 레포와 브랜치(ref)가 적혀 있을 뿐, 그 PR 이 어느 폴더를 고쳤는지는 없다
  • 그래서 나눈 두 plan 명찰의 조건은 똑같이 “kukie-infra 의 PR 이면 OK” 일 수밖에 없다
  • 어느 명찰을 빌릴지는 워크플로 파일의 한 줄(role-to-assume)이 정한다
    • 그 파일은 PR 이 고칠 수 있다
    • ECR 만 고치는 척하는 PR 도 그 줄을 바꾸면 EKS 쪽 명찰을 빌린다

root 경계는 apply 쪽

  • apply 명찰은 다르다 — 조건이 “main 이면 OK” 라, PR 은 문 앞에서 막힌다
  • main 의 워크플로는 리뷰를 거쳐 병합된 것이다
    • → apply 명찰을 root 마다 나누면 그 나눔이 실제로 지켜진다
  • root 사이의 경계는 plan 이 아니라 apply 쪽에 있다

감수할 점: plan 명찰이 읽는 범위

  • PR 을 열 수 있는 사람은 plan 명찰이 읽는 것을 모두 읽을 수 있다 — state 도 포함이다
  • 그래서 state 에는 비밀 값을 남기지 않는다는 규칙을 같이 둔다
  • 비밀번호 같은 값은 Terraform 밖에서 Secrets Manager 에 직접 넣는다

3.3 Apply Once — 같은 견적서는 한 번만

중복 적용 방지: S3 claim 파일

  • 같은 plan 이 두 번 적용될 수 있는 경우
    • 같은 /apply 댓글을 두 번 달 때
    • 실패한 실행을 Re-run 할 때
  • apply 직전에 S3 에 claims/<root>/<plan 실행 번호>.json 을 조건부로 만든다
  • 이미 있으면 실패든 취소든 상관없이 같은 plan 을 다시 쓰지 않는다

함정: 답글 없는 /apply

  • 처음 /apply 를 달았을 때 아무 일도 일어나지 않았다
    • 워크플로는 성공으로 끝났는데 답글이 없었다
  • 원인: 댓글 끝에 줄바꿈(\r\n)이 붙어 명령으로 인식되지 않았다
  • 지금의 처리
    • 끝의 공백은 허용한다
    • /apply 로 시작하는데 형식이 틀리면 거부 답글로 올바른 형식을 알려 준다
  • 조용히 무시하는 실패가 가장 오래 걸린다

3.4 GitHub Free — 진짜 경계는 쓰기 권한이다

무료 플랜의 한계

  • 조직이 무료 플랜이라 비공개 레포에 브랜치 보호와 Environment 승인을 켤 수 없다
  • 쓰기 권한이 있으면 main 에 바로 push 하고 /apply 를 달 수 있다
  • 이 구조가 막는 것: 실수와 기록 누락
  • 막지 못하는 것: 쓰기 권한자의 악의

README 첫머리의 경고

  • README 첫머리에 적어 두었다 — kukie-infra 의 쓰기 권한은 곧 AWS 인프라 변경 권한이다
  • 유료 플랜으로 올리면 필수 리뷰와 Environment 승인을 더할 수 있다
    • 그때 이 문단을 지운다

4. Discussion

필요했던 장치 세 가지

  • 병합과 적용 사이에 사람 한 명을 끼우는 데 필요한 것은 생각보다 적었다
    • 견적서를 병합된 main 으로 다시 뽑는다
    • 그 실행 번호를 사람이 댓글로 승인한다
    • 같은 번호는 한 번만 쓰게 한다
  • 나머지는 그 셋을 지키는 검사다

남은 약점

  • 무료 플랜이라 브랜치 보호가 없다 → kukie-infra 의 쓰기 권한이 곧 인프라 변경 권한이다
  • 이 구조는 실수와 기록 누락을 막을 뿐, 쓰기 권한자를 막지는 않는다
  • 그 로봇이 들고 다니는 권한 자체를 어떻게 묶었는지는 02편에서 다룬다

핵심 메시지

레포
쓰기 권한의 단위. CI 봇이 매일 쓰는 칸만 택배함으로 뗀다
댓글 승인
병합된 main 으로 다시 뽑은 견적서를, 번호로, 한 번만 적용한다
두 명찰
plan 은 읽기, apply 는 쓰기. apply 는 main 으로 도는 로봇만
한 번만
같은 견적서는 두 번 쓰지 않는다. 실패해도 새 견적서로
GitHub Free
브랜치 보호가 없다. 진짜 경계는 쓰기 권한이다

리뷰 없이는 아무것도 바뀌지 않게 하려면, 병합과 적용을 떼어 놓고 그 사이에 사람을 둬야 한다. 그 사람이 승인하는 것은 PR 의 미래가 아니라 지금 main 으로 뽑은 견적서 한 장이다. 다음 편은 이 로봇이 쥔 명찰이 스스로 커지지 못하게 묶는 이야기다.


5. References

GitHub Actions 가 장기 키 없이 클라우드에 들어가는 방식은 GitHub 의 OIDC 문서에 정리되어 있다.
"With OIDC, your cloud provider issues a short-lived access token that is only valid for a single job, and then automatically expires."
[해석] "OIDC 를 쓰면 클라우드 제공자가 작업 하나에만 유효한 짧은 수명의 액세스 토큰을 발급하고, 그 토큰은 자동으로 만료된다." — 3.2 에서 로봇이 키 없이 명찰을 빌리는 방식의 근거다. 자세한 흐름은 02편.
→ GitHub Docs — OpenID Connect
App of Apps 의 상위 레포를 누가 써야 하는지는 Argo CD 의 Cluster Bootstrapping 문서에 경고로 적혀 있다.
"The ability to create Applications in arbitrary Projects is an admin-level capability. Only admins should have push access to the parent Application's source repository."
[해석] "임의의 프로젝트에 Application 을 만들 수 있는 능력은 관리자 수준의 권한이다. 상위 Application 의 소스 레포에는 관리자만 push 할 수 있어야 한다." — 2장에서 ArgoCD 지시서를 kukie-manifest 가 아니라 kukie-infra 에 둔 근거다(03편 3.4).
→ Argo CD — Cluster Bootstrapping