0. Contents
1. Overview
03편과 이번 편의 범위
- 03편은 ArgoCD 를 세웠다
- ArgoCD 는 kukie-manifest 에 적힌 이미지를 가져와 띄울 뿐, 이미지를 만들지는 않는다
- kukie-manifest 가 택배함이라면, 이번 편은 그 택배가 어디서 포장되어 창고(ECR)까지 가는지다
앱 레포 셋의 이미지 흐름
- 앱 레포 셋이 각자 이미지를 굽는다
- develop 에 머지되고 검사가 다 통과하면 GitHub Actions 의 image 잡이 돈다
- 이미지를 굽는다
- 그 레포 전용 출입증으로 창고에 올린다
- 실행 요약에 digest 를 남긴다
- 2026-10-15 기준 상태는 아래와 같다
장별 내용
- 2장: 상자(이미지)에 무엇을 넣고 무엇을 빼는지
- 3장: 창고에 올리는 규칙
- 4장: 아직 사람이 하는 마지막 칸
2. Image — 굽는 도구는 상자에 넣지 않는다
2.1 Two Stages — 설치는 앞 단계, 실행은 뒤 단계
이미지: 실행에 필요한 것만 담는 상자
- 이미지는 설치까지 끝낸 폴더에 켜는 법을 붙여 묶은 상자다
- 상자에는 실행에 필요한 것만 들어가야 한다
- 설치 도구와 내려받은 캐시까지 들어가면 상자가 커진다 → 노드는 그만큼을 받아야 Pod 를 띄운다
에이전트 Dockerfile 의 두 단계
- 앞 단계: uv 로
.venv를 만든다 - 뒤 단계: 그
.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초쯤 걸렸다
화면 이미지: 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.jar77MB 중 우리 코드와 설정은 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/cache3.2.4 를 품고 있다 - GitHub 은 캐시 서비스를 새로 바꾸면서 4.0.0 미만이 쓰던 옛 서비스를 2025년 4월 15일에 껐다
- SHA 고정은 남이 action 을 바꿔치기하지 못하게 막아 준다
- 하지만 그 action 이 기대는 바깥 서비스가 바뀌면 고정된 코드는 그대로 낡는다
- 해법:
@actions/cache4.0.0 을 쓰는 v4.2.2 이후로 올리면 된다 — 아직 올리지 않았다
2.3 Baked Values — 공개값만 상자에 굽는다
비밀값은 상자 밖에
- 비밀값은 상자에 넣지 않는다 — 상자는 창고를 거쳐 복사돼 다니는 물건이다
- 회원 서버와 에이전트의 비밀값은 Pod 가 뜰 때 Secrets Manager 에서 ExternalSecret 으로 들어오게 되어 있다(03편 3.3)
.dockerignore도.env를 막는다
리뷰에서 걸린 .env 패턴
- 화면 레포에서는 이 줄이 리뷰에서 한 번 걸렸다
.dockerignore는.gitignore와 달리 슬래시 없는 패턴이 루트에서만 맞는다- 그래서
.env한 줄로는deploy/.env가 빠지지 않았다 - 지금은
**/.env다
예외: 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일에 구운 화면 이미지 둘은 소셜 로그인이 안 되는 이미지다
굽는 값의 대가 두 가지
- 값을 나중에 넣어도 이미 구운 커밋은 다시 굽지 않는다(3.2) → 다음 커밋부터 들어간다
- dev 와 prod 의 OAuth 앱이 다르면 prod 용은 따로 구워야 한다
- “dev 에서 확인한 digest 를 그대로 prod 로 옮긴다” 는 방침과 맞지 않는다
- prod 를 만들기 전에 정할 일이다 — 값을 런타임에 읽는 설정 파일로 빼는 방법이 있다
3. Push — 커밋 하나에 이미지 하나
3.1 One Role per Repo — 출입증은 창고 하나만 연다
OIDC 출입증, 레포마다 따로
- image 잡은 02편의 OIDC 로 출입증을 빌린다
- 저장된 키는 없다
- 이번에 더해진 것: 출입증이 레포마다 따로라는 점
권한 경계와 역할별 정책
- 셋 다 같은 권한 경계(
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'조건을 붙였다
- 그래서 image 잡에
3.2 Immutable Tag — 있으면 굽지 않는다
태그 규칙: 커밋 SHA 하나
- 태그는 커밋 SHA 하나만 쓴다
- 창고가
IMMUTABLE이라 한 번 붙인 태그는 다른 이미지로 옮길 수 없다 latest처럼 움직이는 태그는 두 번째 push 부터 거부된다- 같은 이름 아래 내용물이 슬쩍 바뀌는 일이 없다
대가: 다시 돌리면 거부되는 push
- 같은 커밋으로 잡을 다시 돌리면 push 가
ImageTagAlreadyExistsException으로 거부된다 - 실패한 잡을 다시 돌리는 일은 흔하다
- 그래서 굽기 전에 먼저 묻는다 — 이 커밋이 이미 창고에 있나
조회 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
재조회 열 줄이 거친 리뷰 세 번
- 결과 검사 — 처음에는 조회 결과를 검사하지 않았다
- 끝내 못 읽으면 잡은 초록불로 끝나고, 요약에는
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에서 말한 수명 규칙이 이걸 지운다
빈칸: 접두사 없는 태그
- 규칙은 태그 앞부분(
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)
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 자동화
핵심 메시지
이미지는 커밋마다 하나씩, 출입증은 레포마다 하나씩 생긴다. 태그를 못 옮기게 한 대가로 다시 돌리기와 지우는 규칙을 새로 맞췄다. 다음 편은 그 digest 를 받는 택배함 — kukie-manifest 가 PR 마다 무엇을 검사하는지다.
6. References
"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."
"Valid values: application/vnd.docker.distribution.manifest.v1+json | application/vnd.docker.distribution.manifest.v2+json | application/vnd.oci.image.manifest.v1+json"
"You must specify a comma-separated list of image tag prefixes on which to take action with your lifecycle policy."
"If a repository or organization name was recycled, a new owner could mint tokens with the same subject claim"
"If you do not upgrade, all workflow runs using any of the deprecated @actions/cache packages will fail."
"events triggered by the GITHUB_TOKEN will not create a new workflow run"
