0. Contents


1. Overview

develop 머지검사image 잡ECROIDC 출입증실행 요약kukie-manifestArgoCDEKS Pod사람이 옮겨 적는다앱은 아직 안 본다자동 — develop 머지마다아직 사람 손 · 꺼져 있음
굽고 올리기까지는 자동이다 — digest 를 택배함(kukie-manifest)에 넣는 칸부터는 아직 사람 손이다

03편과 이번 편의 범위

  • 03편은 ArgoCD 를 세웠다
  • ArgoCD 는 kukie-manifest 에 적힌 이미지를 가져와 띄울 뿐, 이미지를 만들지는 않는다
  • kukie-manifest 가 택배함이라면, 이번 편은 그 택배가 어디서 포장되어 창고(ECR)까지 가는지다

앱 레포 셋의 이미지 흐름

  • 앱 레포 셋이 각자 이미지를 굽는다
  • develop 에 머지되고 검사가 다 통과하면 GitHub Actions 의 image 잡이 돈다
    • 이미지를 굽는다
    • 그 레포 전용 출입증으로 창고에 올린다
    • 실행 요약에 digest 를 남긴다
  • 2026-10-15 기준 상태는 아래와 같다
agent9월 27일부터image 잡 성공 7번kukie-agentpendingkukie-electron10월 9일부터 · 화면image 잡 성공 2번kukie-webpendingkukie-server회원 · 빌드 검사만올리는 단계 없음kukie-serverpending초록 점 = image 잡 성공 한 번 · 주황 = ECR 창고 · 빨강 = manifest 의 pending굽는 쪽은 돌고 있는데, 받는 쪽은 세 줄 모두 비어 있다
2026-10-15 기준 — 앱은 회원 서버부터 켠다

장별 내용

  • 2장: 상자(이미지)에 무엇을 넣고 무엇을 빼는지
  • 3장: 창고에 올리는 규칙
  • 4장: 아직 사람이 하는 마지막 칸

2. Image — 굽는 도구는 상자에 넣지 않는다

2.1 Two Stages — 설치는 앞 단계, 실행은 뒤 단계

이미지: 실행에 필요한 것만 담는 상자

  • 이미지는 설치까지 끝낸 폴더에 켜는 법을 붙여 묶은 상자다
  • 상자에는 실행에 필요한 것만 들어가야 한다
  • 설치 도구와 내려받은 캐시까지 들어가면 상자가 커진다 → 노드는 그만큼을 받아야 Pod 를 띄운다

에이전트 Dockerfile 의 두 단계

  • 앞 단계: uv 로 .venv 를 만든다
  • 뒤 단계: 그 .venv 와 kubectl 만 옮겨 받는다
uv캐시소스.venvpython:3.14-slim.venvkubectl (버전 + sha256)USER kukie (uid 1000).venv 만 옮긴다앞 단계 — 통째로 버린다빌드 끝: import kukie_agent.server — 안 돌면 여기서 멈춘다화면 이미지도 같은 모양: Node 단계 → dist/web 만 → Caddy (약 95MB)
굽는 도구는 앞 단계에 두고 버린다 — 실행 이미지는 바탕 위에 .venv, kubectl, 실행 사용자만 쌓는다
FROM ${PYTHON_IMAGE} AS build
COPY --from=ghcr.io/astral-sh/uv:0.12.23@sha256:61d393e4… /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock .python-version ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev --extra postgres --no-install-project
COPY kukie_agent ./kukie_agent
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev --extra postgres --no-editable

FROM ${PYTHON_IMAGE}
# kubectl 설치 — 버전 고정 + sha256 대조 (생략)
COPY --from=build /app/.venv /app/.venv
RUN python -c "import kukie_agent.server"   # 옮겨 온 .venv 가 이 바탕에서 도는지
USER kukie

뒤 단계의 안전장치

  • 뒤 단계에는 소스 폴더를 두지 않는다 — 두면 설치된 패키지 대신 그 폴더가 import 된다
  • 대신 빌드 끝에 import kukie_agent.server 를 한 번 돌린다
    • 옮겨 온 .venv 가 그 바탕에서 안 돌면 Pod 를 띄울 때가 아니라 빌드에서 멈춘다
  • 실행 사용자는 root 가 아닌 uid 1000 이다

층 재사용: 로컬에서만 나는 이득

  • 라이브러리를 소스보다 먼저 까는 것은 층(layer) 재사용 때문이다 → 소스만 바뀌면 라이브러리 층을 다시 쓴다
  • 다만 도커가 이전 층을 기억할 때 이야기다
  • GitHub 이 빌려주는 러너는 작업마다 새것이라, 층도 --mount=type=cache 도 매번 비어 있다
  • 그래서 이 이득은 로컬 빌드에서만 난다
  • 10월 14일 실행: 에이전트 이미지는 굽는 데 25초, 올리는 데 25초쯤 걸렸다
바탕 python:3.14-slim재사용다시 받는다uv.lock 라이브러리재사용다시 굽는다소스 kukie_agent다시 굽는다다시 굽는다로컬 빌드GitHub runner소스만 바뀌면 이 층부터 다시 — 아래 층은 이전 것을 쓴다러너는 작업마다 새것 — 층도 --mount=type=cache 도 비어 있다 · 굽기 25초 · 올리기 25초
라이브러리를 먼저 까는 이득은 로컬에서만 난다 — 러너는 매번 빈 상태로 시작한다

화면 이미지: Node 단계에서 Caddy 로

  • 화면 이미지도 같은 모양이다
  • Node 단계에서 정적 파일을 굽고, 결과(dist/web)만 Caddy 이미지로 옮긴다
  • Node 와 node_modules 는 상자에 들어가지 않는다
  • 9월 26일 로컬에서 구웠을 때 약 95MB 였다

2.2 Build Outside — jar 는 러너에서 굽는다

회원 서버: 굽는 단계 없는 Dockerfile

  • 회원 서버는 반대로 갔다
  • Dockerfile 에서 굽는 단계를 아예 뺐다
FROM eclipse-temurin:25-jre-noble
WORKDIR /app
RUN useradd --system --no-create-home --shell /usr/sbin/nologin kukie
COPY build/libs/app.jar /app/app.jar
USER kukie
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

굽기는 러너, 담기는 도커

  • jar 는 러너에서 ./gradlew bootJar 로 굽는다
  • 도커는 JRE 위에 그 jar 하나를 담기만 한다
  • .dockerignore 는 build/ 를 막되 !build/libs/app.jar 하나만 통과시킨다
  • 9월 24일 측정: app.jar 77MB 중 우리 코드와 설정은 0.5MB, 나머지는 라이브러리 133개였다

두 단계에서 바꾼 이유: Gradle 캐시

  • 처음에는 두 단계였다 — JDK 단계에서 굽고 JRE 로 옮겼다
  • Gradle 이 받은 라이브러리는 ~/.gradle 에 쌓인다
    • 컨테이너 안에서 구우면 그 폴더는 컨테이너와 같이 사라진다
    • 러너에서 구우면 setup-gradle 이 그 폴더를 GitHub Actions 캐시에 맡겼다가 다음 실행에 꺼내 준다
  • 리뷰어가 짚은 것도 이 점이었다 — CI 는 끝나면 머신이 꺼지니, 캐시를 둘 곳은 도커 안이 아니라 Actions 다

결과: 한 번도 저장되지 않은 캐시

  • 그런데 이 캐시는 한 번도 저장되지 않았다
Gradle User Home cache not found. Will initialize empty.
BUILD SUCCESSFUL in 1m 26s
##[warning]Failed to save cache entry with path '/home/runner/.gradle/wrapper/dists/gradle-9.6.1-bin/…'
  and key: gradle-wrapper-zips-v1-…: Error: <h2>Our services aren't available right now</h2>…

같은 경고가 남은 실행들

  • develop 에서 돈 9월 27일과 10월 7일 이미지 검사가 똑같다
  • 같은 action 을 쓰는 Gradle 빌드 워크플로(10월 14일)도 같다
  • 꺼낼 캐시가 없고, 저장은 실패하고, bootJar 는 두 번 다 1분 26초였다
  • 레포의 Actions 캐시 목록도 비어 있다
  • 경고라서 잡은 초록불로 끝났다 → 그래서 아무도 몰랐다

원인: SHA 로 고정한 옛 버전

  • 워크플로는 setup-gradle 을 커밋 SHA 로 고정했다
  • 그 커밋(v4.0.0, 2024년 8월)은 @actions/cache 3.2.4 를 품고 있다
  • GitHub 은 캐시 서비스를 새로 바꾸면서 4.0.0 미만이 쓰던 옛 서비스를 2025년 4월 15일에 껐다
  • SHA 고정은 남이 action 을 바꿔치기하지 못하게 막아 준다
    • 하지만 그 action 이 기대는 바깥 서비스가 바뀌면 고정된 코드는 그대로 낡는다
  • 해법: @actions/cache 4.0.0 을 쓰는 v4.2.2 이후로 올리면 된다 — 아직 올리지 않았다
✕runner · bootJar~/.gradleActions 캐시 — 목록이 비어 있다JRE 위에 app.jar 하나app.jar 77MB저장 실패 — 경고만, 잡은 초록불의도캐시를 맡기려고 러너에서 굽는다실제setup-gradle v4.0.0 → @actions/cache 3.2.4옛 캐시 서비스는 2025-04-15 에 꺼졌다bootJar 는 두 번 다 1분 26초
밖에서 구워야 캐시를 맡길 수 있다 — 그런데 맡기는 창구가 이미 닫혀 있었다

2.3 Baked Values — 공개값만 상자에 굽는다

비밀값은 상자 밖에

  • 비밀값은 상자에 넣지 않는다 — 상자는 창고를 거쳐 복사돼 다니는 물건이다
  • 회원 서버와 에이전트의 비밀값은 Pod 가 뜰 때 Secrets Manager 에서 ExternalSecret 으로 들어오게 되어 있다(03편 3.3)
  • .dockerignore 도 .env 를 막는다

리뷰에서 걸린 .env 패턴

  • 화면 레포에서는 이 줄이 리뷰에서 한 번 걸렸다
  • .dockerignore 는 .gitignore 와 달리 슬래시 없는 패턴이 루트에서만 맞는다
  • 그래서 .env 한 줄로는 deploy/.env 가 빠지지 않았다
  • 지금은 **/.env 다
✕코드바탕 이미지공개값 client id.env이미지ECRSecrets ManagerExternalSecretPod레포 Variables → 빌드 인자.dockerignore 가 막는다 (**/.env)굽기 — image 잡켜기 — EKS kukie-devPod 가 뜰 때 비밀값이 들어온다
코드와 공개값은 상자에, 비밀값은 켤 때 — 상자는 창고를 거쳐 복사돼 다니는 물건이다

예외: JS 번들에 박히는 OAuth client id

  • 화면 이미지에는 예외가 하나 있다 — 웹 OAuth 의 client id 는 빌드할 때 JS 번들에 박힌다
  • client id 는 비밀이 아니다 — 브라우저가 로그인 화면으로 갈 때 주소에 그대로 실려 가는 값이다
  • 그래서 GitHub Secrets 가 아니라 레포 Variables 에 두고 build arg 로 넘긴다
env:
  VITE_GITHUB_WEB_CLIENT_ID: ${{ vars.VITE_GITHUB_WEB_CLIENT_ID }}
  VITE_GOOGLE_WEB_CLIENT_ID: ${{ vars.VITE_GOOGLE_WEB_CLIENT_ID }}
steps:
  - run: |
      for name in VITE_GITHUB_WEB_CLIENT_ID VITE_GOOGLE_WEB_CLIENT_ID; do
        [ -n "${!name}" ] || echo "::warning::$name 이 비어 있다 — 이 이미지에서는 해당 웹 로그인이 안 된다"
      done

현재 상태: 비어 있는 두 값

  • 지금 이 두 값은 비어 있다
  • OAuth 앱에 등록할 콜백 주소가 CloudFront 주소인데, CloudFront 가 아직 없다
  • 그래서 비어 있으면 실패 대신 경고만 남기고 굽는다
  • 10월 9일과 14일에 구운 화면 이미지 둘은 소셜 로그인이 안 되는 이미지다

굽는 값의 대가 두 가지

  1. 값을 나중에 넣어도 이미 구운 커밋은 다시 굽지 않는다(3.2) → 다음 커밋부터 들어간다
  2. dev 와 prod 의 OAuth 앱이 다르면 prod 용은 따로 구워야 한다
    • “dev 에서 확인한 digest 를 그대로 prod 로 옮긴다” 는 방침과 맞지 않는다
    • prod 를 만들기 전에 정할 일이다 — 값을 런타임에 읽는 설정 파일로 빼는 방법이 있다

3. Push — 커밋 하나에 이미지 하나

3.1 One Role per Repo — 출입증은 창고 하나만 연다

OIDC 출입증, 레포마다 따로

  • image 잡은 02편의 OIDC 로 출입증을 빌린다
  • 저장된 키는 없다
  • 이번에 더해진 것: 출입증이 레포마다 따로라는 점
공통 경계 — 창고 셋을 다 허용agentdevelop 만출입증kukie-dev-github-agentkukie-agent자기 창고kukie-electrondevelop 만출입증kukie-dev-github-webkukie-web자기 창고kukie-serverdevelop 만출입증kukie-dev-github-memberkukie-server자기 창고✕남의 창고는 implicitDeny — 화면 레포가 털려도 에이전트 이미지는 못 바꾼다
경계는 셋을 다 열어 두고, 역할마다 붙은 정책이 창고 하나로 좁힌다

권한 경계와 역할별 정책

  • 셋 다 같은 권한 경계(kukie-dev-github-ecr-boundary) 아래에 있다
  • 경계는 창고 셋을 다 허용한다
  • 역할마다 붙은 정책이 자기 창고 하나로 좁힌다
  • 정책에는 로그인 토큰 받기 말고는 층과 매니페스트를 올리고 읽는 일곱 가지뿐이다
Action = [
  "ecr:BatchCheckLayerAvailability", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
  "ecr:CompleteLayerUpload", "ecr:PutImage", "ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer",
]
Resource = data.aws_ecr_repository.app[each.key].arn   # 자기 창고 하나

정책 시뮬레이터 확인

  • 에이전트 출입증을 IAM 정책 시뮬레이터에 넣어 보면
    • kukie-agent: PutImage 허용
    • kukie-web · kukie-server: 전부 implicitDeny
  • 화면 레포가 털려도 에이전트 이미지를 바꿔치기할 수 없다

신뢰 조건의 subject 형식

repo:<조직>@<조직 ID>/agent@<레포 ID>:ref:refs/heads/develop

이름 뒤에 붙는 ID

  • 02편에서 말한 바뀌지 않는 레포 ID 가 이름 뒤에 붙는 숫자다
    • 조직 이름 뒤: 조직 ID
    • 레포 이름 뒤: 레포 ID
  • GitHub 이 2026년 4월에 내놓은 형식이다
    • 7월 15일 이후 만든 레포에는 기본으로 쓰인다
    • 그 전 레포는 설정에서 켜야 한다
  • 앱 레포 셋과 kukie-manifest 는 모두 켜져 있다

워크플로의 안전장치 두 줄

  • allowed-account-ids 로 계정을 고정한다 → 변수가 엉뚱한 계정의 역할을 가리키면 거기서 멈춘다
  • 화면 레포의 CI 는 main push 에서도 돌지만 출입증이 develop 만 믿는다
    • 그래서 image 잡에 github.ref == 'refs/heads/develop' 조건을 붙였다

3.2 Immutable Tag — 있으면 굽지 않는다

태그 규칙: 커밋 SHA 하나

  • 태그는 커밋 SHA 하나만 쓴다
  • 창고가 IMMUTABLE 이라 한 번 붙인 태그는 다른 이미지로 옮길 수 없다
  • latest 처럼 움직이는 태그는 두 번째 push 부터 거부된다
  • 같은 이름 아래 내용물이 슬쩍 바뀌는 일이 없다

대가: 다시 돌리면 거부되는 push

  • 같은 커밋으로 잡을 다시 돌리면 push 가 ImageTagAlreadyExistsException 으로 거부된다
  • 실패한 잡을 다시 돌리는 일은 흔하다
  • 그래서 굽기 전에 먼저 묻는다 — 이 커밋이 이미 창고에 있나
태그 조회BatchGetImage이미 있나?커밋 SHA 태그굽지 않는다있는 digest 를 쓴다굽고 올린다build · push다시 조회0·2·6·14·30초모양 검사sha256: 아니면 실패요약에 적는다manifest 에 넣을 값있다없다
같은 커밋을 다시 돌려도 부딪히지 않는다 — 있으면 굽지 않고, 올렸으면 보일 때까지 다시 본다

조회 API: BatchGetImage

  • 묻는 API 가 DescribeImages 가 아니라 BatchGetImage 인 이유: 출입증에 DescribeImages 가 없다
  • BatchGetImage 는 이미지를 받을 때 쓰는 API 라 원래 정책에 있다

함정: 받겠다고 한 매니페스트 종류

  • BatchGetImage 는 받겠다고 한 매니페스트 종류만 돌려준다
  • 여러 아키텍처를 묶은 OCI image index 로 올라간 이미지는, 그 종류를 적지 않으면 있어도 없다고 읽힐 수 있다
  • 그러면 다시 굽고 → push 가 거부되고 → 잡이 죽는다
  • 지금 러너의 docker build 는 단일 매니페스트를 올린다
  • 빌드 방식을 바꿔도 깨지지 않게 네 종류를 다 적는다
aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageTag="$GITHUB_SHA" \
  --accepted-media-types application/vnd.oci.image.index.v1+json application/vnd.oci.image.manifest.v1+json \
    application/vnd.docker.distribution.manifest.list.v2+json application/vnd.docker.distribution.manifest.v2+json \
  --query 'images[0].imageId.imageDigest' --output text

문서와 실제 ECR 의 차이

  • AWS API 문서의 유효값 목록에는 index 두 종류가 없다(6장)
  • 리뷰에서도 “검증에 걸려 첫 조회에서 잡이 죽을 수 있다” 는 지적이 나왔다
  • 실제 ECR 은 받는다
  • 이 조회가 실패하면 shell: bash 의 -e 때문에 잡이 그 자리에서 멈춘다
  • 지금까지 두 레포 합쳐 9번 모두 통과했다

3.3 Read After Push — 올린 직후엔 안 보일 수 있다

push 직후의 digest 재조회

  • 새로 구운 경우에도 digest 는 다시 묻는다
  • push 직후에는 조회에 늦게 잡힐 수 있다 → 간격을 늘려 가며 다섯 번 본다
for wait in 0 2 4 8 16; do
  sleep "$wait"
  existing=$(digest)
  [ "$existing" != "None" ] && break
done
case "$existing" in
  sha256:*) ;;
  *) echo "digest 를 읽지 못했다: $existing" >&2; exit 1 ;;
esac
push0초sleep 22초sleep 46초sleep 814초sleep 1630초초록 = 조회 · 파랑 = 기다림 — 자고 나서 묻는다다섯 번 다 못 읽으면 sha256: 검사에서 잡이 실패0 2 4 8 16 은 간격이다 — 경과 시간으로는 30초
마지막 기다림 뒤에도 조회가 한 번 있다 — 처음에는 그 마지막 16초가 헛돌았다

재조회 열 줄이 거친 리뷰 세 번

  • 결과 검사 — 처음에는 조회 결과를 검사하지 않았다
    • 끝내 못 읽으면 잡은 초록불로 끝나고, 요약에는 digest: None 이 “manifest 에 넣을 값” 으로 찍힌다
    • 사람이 그걸 옮겨 적으면 Pod 는 이미지를 못 받는다
    • 그래서 sha256: 으로 시작하지 않으면 잡을 실패시키는 case 가 들어갔다
  • 순서 — 조회하고 잔 뒤 다시 조회하는 순서였다
    • 마지막 16초는 뒤에 조회가 없어 헛돌았다
    • 자고 나서 조회하도록 바꿨다
  • 주석 — 0 2 4 8 16 은 간격이지 경과 시간이 아니다
    • 실제 조회는 push 뒤 0·2·6·14·30초다

초록불보다 실패가 나은 이유

  • 잡이 실패하는 쪽이 낫다
  • 요약의 digest 는 다음 단계에서 사람이 옮겨 적는 값이다
  • 틀린 값이 초록불로 지나가면 → 틀린 줄을 찾는 곳이 클러스터가 된다

3.4 Lifecycle — 지우는 규칙은 태그 이름만 본다

커밋마다 쌓이는 이미지

  • 태그를 옮길 수 없으니 이미지는 커밋마다 쌓인다
  • 03편 2.1에서 말한 수명 규칙이 이걸 지운다
✕✕prod-*최근 6개 남김 — prod 가 아직 없다dev-*최근 4개 남김 — 다섯 번째부터 지움태그 없음하루 지나면 지움커밋 SHA 그대로어느 규칙에도 안 걸림 — 지금 9개 전부
지금 올라간 이미지는 전부 빨간 칸이다 — 규칙은 걸었지만 아직 지워지는 이미지가 없다

빈칸: 접두사 없는 태그

  • 규칙은 태그 앞부분(dev-, prod-)으로 이미지를 고른다
  • 그런데 지금 image 잡이 붙이는 태그는 커밋 SHA 그대로다
  • 앞부분이 없으니 어느 규칙에도 안 걸린다 → 지금까지 올린 이미지는 전부 남는다
  • 태그를 dev-<커밋 SHA> 로 바꾸는 PR 이 두 레포에 열려 있다(agent #96, kukie-electron #66)
    • 바뀐 뒤에도 그 전에 올린 이미지는 남는다
  • 회원 서버 창고는 Terraform 보다 먼저 만들어져서 이 규칙 밖이다

다른 빈칸: 쓰는 중인 digest 를 모르는 규칙

  • 수명 규칙은 kukie-manifest 가 지금 어느 digest 를 쓰는지 모른다 — 최근 4개를 셀 뿐이다
  • 새 이미지를 넷 넘게 굽는 동안 digest PR 을 미루면
    • 클러스터가 쓰는 이미지가 창고에서 지워진다
    • Pod 가 다시 뜰 때 이미지를 못 받는다
  • 되돌리기도 남은 4개 안에서만 된다
  • 그래서 “digest PR 을 미루지 않는다” 는 조건을 kukie-manifest 문서에 붙이는 중이다(kukie-manifest #10)

4. Digest — 마지막 한 칸은 아직 사람 손이다

image 잡의 실행 요약

### kukie-agent 이미지
- 태그: 6ee4fbb4022d…
- digest: sha256:c1de686ad68d… — kukie-manifest envs/dev 의 kukie-agent 줄에 넣는 값

manifest 에 적는 값: 태그 대신 digest

  • kukie-manifest 에 적는 것은 태그가 아니라 이 digest 다
  • 태그는 창고 설정 하나에 기댄다
  • digest 는 내용물에서 계산한 값이다 → 내용이 다르면 값도 다르다
  • latest 와 태그 없는 이미지는 kukie-manifest 의 검사가 막는다 — 그 검사는 다음 편 이야기다

지금의 방식: 사람이 여는 PR

  • 지금은 사람이 이 값을 옮겨 적는 PR 을 연다
  • 앱은 회원 서버부터 올리기로 해서, 세 줄 모두 아직 pending 이다

자동 PR 잡: GitHub App 토큰

  • 자동으로 PR 을 여는 잡(agent #85)은 초안으로 있다
  • 기본 GITHUB_TOKEN 대신 kukie-manifest 에만 설치한 GitHub App 을 쓴다
  • 이유는 둘이다
    • GITHUB_TOKEN 은 워크플로가 도는 레포 안에서만 쓸 수 있다
    • 그 토큰으로 연 PR 에서는 새 워크플로가 돌지 않는다 → 봇이 연 PR 에서 검사와 렌더링 차이 댓글이 안 돌면, 사람은 검사 없는 PR 을 머지하게 된다
  • 앱 토큰은 kukie-manifest 하나에 contents · pull-requests 쓰기로 좁혀 한 시간짜리로 받는다

머지는 여전히 사람 몫

  • 머지는 그래도 사람이 한다
  • 비공개 레포에 Free 플랜이라 브랜치 보호도 자동 머지도 없다(01편 3.4)
✕validate렌더 차이ClaudeEKS kukie-dev머지되면 ArgoCD 가 클러스터에 반영✕① changes② image③ 최신?digest④ App 토큰⑤ 한 줄⑥ 봇 PR⑦ 옛 PR⑨ 사람 머지ArgoCD지금 방식: 사람⑧ 검사agent 레포 CIkukie-manifestGITHUB_TOKEN① changes — 문서만 바뀌면 굽지 않는다docs/ · *.md 만 바뀐 머지, [skip image] 머지는 건너뛴다② image — 굽고 올려 digest 를 읽는다sha256: 모양이 아니면 여기서 실패해 뒤로 안 간다③ 최신인가 — 옛 실행이면 PR 을 열지 않는다develop 이 더 새 커밋으로 가 있으면 건너뛴다늦게 끝난 실행이 새 PR 을 밀어내지 않게④ App 토큰 — 봇 신분을 1시간 빌린다앱 ID(Variables) + 개인 키(Secrets) 로 발급kukie-manifest 하나 · 파일·PR 쓰기만⑤ 한 줄 바꾸기 — kukie-agent 줄만 digest 로kustomization.yaml 에서 그 앱 덩어리만 pending → sha256:…렌더해 실제로 들어갔나 확인 (이름이 어긋나면 조용히 무시돼서)⑥ 봇 PR — bot/kukie-agent-<커밋 7자리> 브랜치작성자 bot[bot] 이라 사람 PR 과 구분된다본문에 구운 커밋 · 바뀌는 줄 · 실행 링크⑦ 옛 PR 닫기 — 앱마다 열린 버전 PR 은 하나새 버전 PR 이 열리면 "대체한다" 댓글과 함께 닫는다⑧ 검사 — 봇 PR 이 열리면 셋이 함께 돈다렌더링 · 스키마 · 규칙, 렌더 차이 댓글, Claude 리뷰App 토큰으로 연 PR 이라 돈다⑨ 사람 머지 — 이미지 줄만 바뀌었나 본다렌더 차이에 다른 줄이 보이면 머지하지 않는다머지되면 EKS 의 ArgoCD(보라)가 새 digest 로 Pod 를 바꾼다빨강 — GITHUB_TOKEN 이었다면 (흐린 점선 큐브 = 닫히거나 꺼진 것)kukie-manifest 는 남의 레포라 못 쓰고, 그 PR 에선 ⑧ 이 안 돈다회색 실선 큐브 = 사람 · 회색 점선 길 = 지금 정해 둔 방식사람이 digest 를 복사해 ⑤ 파일을 고치고 PR 을 연다세 줄 모두 pending 이라 아직 한 번도 안 썼다
봇이 바꾸는 것은 옮겨 적는 손이다 — 검사는 그대로 붙고, 머지는 여전히 사람이 한다

5. Discussion

태그 고정이 바꾼 주변 규칙

  • 이번 편의 규칙은 대부분 태그를 못 옮기게 한 데서 나왔다
  • 태그가 움직이지 않으니 같은 이름 아래 내용물이 바뀌는 일은 없어졌다
  • 대신 다시 돌리기가 부딪혔고(3.2), 지우는 규칙은 태그 이름에 기대야 했다(3.4)
  • 하나를 고정하면 그 주변이 고정에 맞춰 바뀐다

고정의 반대쪽 얼굴: setup-gradle

  • setup-gradle 을 SHA 로 고정한 덕에 남이 그 action 을 바꿔치기할 수 없다
  • 대신 그 action 이 기대던 캐시 서비스가 꺼지자, 워크플로는 만든 날부터 경고만 남기고 아무것도 저장하지 않았다(2.2)
  • 초록불은 멈춘 곳이 없다는 뜻이지, 의도대로 됐다는 뜻이 아니다

남은 일 다섯

  • 회원 서버 image 잡 — 출입증은 있고 올리는 단계가 없다
  • 태그 dev- 접두사
  • setup-gradle 을 v4.2.2 이후로
  • 웹 client id — CloudFront 주소가 생긴 뒤
  • digest PR 자동화

핵심 메시지

두 단계
굽는 도구는 앞 단계에 두고, 상자에는 실행에 필요한 것만 옮긴다
캐시
밖에서 구워야 맡길 수 있다. 고정한 action 은 바깥 서비스와 함께 낡는다
출입증
레포마다 하나, 창고 하나만. 한 레포가 털려도 남의 이미지는 못 바꾼다
고정 태그
옮길 수 없으니 먼저 묻는다. 있으면 굽지 않고, 올렸으면 보일 때까지 본다
digest
사람이 옮겨 적는 값이라, 모양이 틀리면 잡이 먼저 실패한다

이미지는 커밋마다 하나씩, 출입증은 레포마다 하나씩 생긴다. 태그를 못 옮기게 한 대가로 다시 돌리기와 지우는 규칙을 새로 맞췄다. 다음 편은 그 digest 를 받는 택배함 — kukie-manifest 가 PR 마다 무엇을 검사하는지다.


6. References

태그를 고정한 창고에 같은 태그로 다시 올리면 무슨 일이 생기는지는 ECR 사용 설명서의 태그 불변성 문서 첫 문단에 있다.
"After tag immutability is turned on, the ImageTagAlreadyExistsException error is returned if you push an image with a tag that is already in the repository."
[해석] "태그 불변성을 켜면, 창고에 이미 있는 태그로 이미지를 올릴 때 ImageTagAlreadyExistsException 오류가 돌아온다." — 3.2 에서 굽기 전에 먼저 묻는 근거다.
→ Amazon ECR User Guide — Preventing image tags from being overwritten
BatchGetImage 의 acceptedMediaTypes 유효값은 API 문서에 세 개만 적혀 있다. image index 와 manifest list 는 목록에 없지만, 실제 호출은 받아 준다(3.2). 문서가 실제 동작보다 좁다.
"Valid values: application/vnd.docker.distribution.manifest.v1+json | application/vnd.docker.distribution.manifest.v2+json | application/vnd.oci.image.manifest.v1+json"
[해석] "유효값: Docker 매니페스트 v1, Docker 매니페스트 v2, OCI 이미지 매니페스트" — 3.2 에서 리뷰가 "검증에 걸릴 수 있다" 고 짚은 근거다.
→ Amazon ECR API Reference — BatchGetImage
수명 규칙이 태그로 이미지를 고르는 방식은 ECR 수명 규칙 속성 문서의 tagPrefixList 항목에 있다.
"You must specify a comma-separated list of image tag prefixes on which to take action with your lifecycle policy."
[해석] "수명 규칙이 처리할 이미지 태그 접두사 목록을 쉼표로 구분해 적어야 한다." — 3.4 에서 접두사 없는 커밋 SHA 태그가 어느 규칙에도 안 걸리는 근거다.
→ Amazon ECR User Guide — Lifecycle policy properties
이름 대신 ID 를 넣은 subject 가 왜 생겼는지는 2026년 4월 23일 GitHub 변경 기록에 있다. 같은 글에 "2026년 7월 15일 이후 만든 레포는 자동으로 새 형식을 쓴다" 고 적혀 있다.
"If a repository or organization name was recycled, a new owner could mint tokens with the same subject claim"
[해석] "레포나 조직 이름이 재활용되면, 새 주인이 같은 subject 로 토큰을 받을 수 있었다." — 3.1 의 subject 에 숫자 ID 가 붙은 근거다.
→ GitHub Changelog — Immutable subject claims for GitHub Actions OIDC tokens
@actions/cache 4.0.0 미만이 멈춘 이유는 actions/toolkit 의 cache 패키지 README 맨 앞에 있다. 옛 서비스를 끈 날짜(2025년 4월 15일)는 GitHub 의 2025년 3월 20일 변경 기록에 있다.
"If you do not upgrade, all workflow runs using any of the deprecated @actions/cache packages will fail."
[해석] "올리지 않으면, 지원이 끝난 @actions/cache 패키지를 쓰는 워크플로 실행은 모두 실패한다." — 2.2 의 캐시가 저장되지 않은 근거다. setup-gradle 은 이 실패를 경고로 바꿔 삼켜서 잡은 초록불로 끝났다.
→ actions/toolkit — @actions/cache · GitHub Changelog — Notification of upcoming breaking changes in GitHub Actions
GITHUB_TOKEN 으로 만든 이벤트가 워크플로를 깨우지 않는다는 것은 GitHub Actions 의 GITHUB_TOKEN 문서에 있다.
"events triggered by the GITHUB_TOKEN will not create a new workflow run"
[해석] "GITHUB_TOKEN 이 일으킨 이벤트는 새 워크플로 실행을 만들지 않는다." — 4장에서 digest PR 을 GitHub App 토큰으로 여는 근거다.
→ GitHub Docs — GITHUB_TOKEN