🚀 요약
SUMMARY
차트는 릴리스 주기로 계속 전진하는데 사이트마다 배포 시점이 달라서, 모든 사이트의 values가 한 트리·한
main에 얹혀 있으면 차트를 앞으로 밀 때 아직 옛 형상을 쓰는 사이트의 렌더 결과가 조용히 바뀐다. 이걸 “움직이는 것(main)“과 “얼어붙은 것(사이트별 타겟 브랜치)“을 브랜치 종류로 가르고, 본사 배포 시점마다 찍는 불변 버전 태그와 버전 라인별 누적 fix-tag를 사이트 브랜치가merge로 흡수하는 GitOps 형상 모델로 풀었다. N:1(차트 시점 ↔ 사이트 overlay)을 “브랜치 N개 × 각 브랜치 안 1:1”로 붕괴시켜, “어느 차트로 어느 사이트를 렌더하나”를git checkout하나로 확정한다.
💡 개요
멀티사이트로 나가는 Helm 차트를 monorepo 하나로 관리하고 있었다. charts/<레이어>/ 안에 차트(벤더링한 OSS + 커스텀)가 있고, 그 옆에 values/common과 사이트별 values/<env>가 같은 트리에 공존한다. 값 병합은 common < <site> 순서로 deep-merge하고, TARGET_ENV로 어느 사이트를 렌더할지 고른다. 사이트별 비밀은 SOPS+age로 암호화해 두고, 배포는 레이어 1 → 2 → 3 순서로 돈다.
구조는 단순하고, 사이트가 다 같은 시점의 차트를 쓰는 동안에는 아무 문제가 없다. 문제는 사이트마다 배포 주기가 다르다는 데서 시작한다. 어떤 사이트는 최신 차트를 계속 따라오고, 어떤 사이트는 특정 시점(예: v1.8.8)에 얼어붙어 있고, 또 어떤 폐쇄망 고객사는 몇 달 전 배포 이후로 손도 안 댔다. 즉 “차트의 특정 시점”과 “각 사이트의 override values”가 N:1로 묶여야 하는데, 단일 트리·단일 main 구조로는 그 매칭을 유지할 방법이 없다.
해법은 하나다. 계속 진행하는 것과 특정 시점에 얼려야 하는 것을 브랜치 종류로 물리적으로 가른다. main은 최신 차트를 추적하며 앞으로 나아가고, 얼려야 하는 형상은 각자 자기 브랜치에서 태그를 base로 삼아 멈춰 있는다. 그러면 “어느 차트로 어느 사이트를 렌더하나”라는 질문이 코드 로직이 아니라 git checkout <브랜치>로 답이 난다.
📋 선정 배경
기존 방식을 왜 못 쓰는지부터 짚는다. 겉으로는 잘 돌아가는데, 아래 지점들에서 형상이 조용히 오염된다.
- 단일 트리에 차트가 한 형상만 존재한다.
main에서 차트를 앞으로 진행시키면, 아직 옛 형상을 써야 하는 사이트의 values가 새 차트와 같은 트리에서 렌더된다. 어느 시점 차트로 어느 사이트를 렌더하는지가 구조적으로 모호하다. - 차트 기본 이미지 태그가 트리에 박혀 있다(예: 어느 차트
values.yaml의tag: v1.8.8). 그 위에 override를 안 달아둔 사이트는, 차트가 진행하면 조용히 최신 이미지로 끌려간다. 아무도 바꾸라고 한 적 없는데. - 값 병합 로직은 항상 HEAD의
common과 HEAD의<site>를 동시에 병합한다. 그래서 고정돼 있어야 할 사이트인데도,common이나 차트를 앞으로 바꾸면 그 사이트의 렌더 결과가 같이 바뀐다. - helmfile의 차트 경로가
./charts/<chart>로TARGET_ENV와 독립이다. “이 사이트만 다른 시점의 차트를 쓰게” 만들 자리가 아예 없다.
세 가지 질문에 이 구조는 답을 못 한다.
- Q1. 차트를 바꿀 때 어떤 사이트는 같이 올리고 어떤 사이트는 그대로 둬야 하는데, 차트와 values가 한 트리에 묶여 있으니 진행/고정을 사이트마다 방어용 override로 흉내 낼 뿐 구조로 보장되지 않는다.
- Q2. override로는 못 푸는 마이너 차트 수정·hotfix가 한 사이트에만 필요할 때, 차트 경로가 사이트 독립이라 “이 사이트만 다른 차트”를 둘 자리가 없다.
- Q3. 현장에서 발견한 차트 보완을 다시
main으로 올리고 싶은데, “이 사이트가 차트를 이렇게 고쳤다”는 상태가 1급으로 존재하지 않아 손수정이 관측되지도, upstream 경로도 없다.
한 가지 전제를 깔아 둔다. 이 판단은 폐쇄망(air-gapped) + 급박한 현장 반영 + 한 번 나가면 다시 방문하기 어려운 사이트라는 특성 위에 있다. 그래서 “drift를 강하게 단속한다”는 접근 자체가 비현실적이었다. 강제할 수 있는 건 CI 렌더 검증 정도고, 나머지는 아무리 규약을 잘 만들어도 현장에서 못 지키는 경우를 전제해야 했다. 형상 모델도 이 전제를 깔고 골랐다.
📊 비교
대안을 6개 놓고 제약(폐쇄망 현장 적용, drift 억제, N:1 명료성, upstream 편의, 기존 툴 호환·전환비용, 다인 유지보수, 단순성)으로 따졌다. 결론부터 말하면 타겟별 브랜치가 최상이었고 나머지는 다 기각했다.
| 대안 | 기각 사유 |
|---|---|
| 릴리스별 브랜치 | 여러 사이트가 한 버전에 모여야 이득인데, 폐쇄망 고객은 각자 독립으로 얼어붙어 이 전제가 약하다. 브랜치 안에서 다중 사이트 values를 동기화하는 부담도 남는다. |
| 버전드 차트 아티팩트(tgz/OCI pin) | 폐쇄망 self-hosted CI가 OCI 접근이 없어 전 사이트 렌더 검증이 옛 버전을 못 당긴다 → required check가 영구 차단된다. tgz 바이너리 정합을 검증할 원장도 없어 drift만 순증한다. |
| 차트 / overlay repo 분리 | 반입 아티팩트가 이원화되고, 현장에 diff 도구가 없어 pin과 vendor가 어긋나도 못 잡는다(조용한 오렌더). repo·권한·SOPS가 파편화된다. |
| 사이트별 벤더 스냅샷 | single source of truth가 사라진다. common 일괄 보안 전파가 끊겨 미방문 사이트가 CVE 패치를 영영 못 받는다. 억제하려던 drift를 오히려 부채로 되살린다. |
| monorepo 버전 슬롯 | 살아있는 슬롯(예: 1.8.8)에 “변경 금지”를 걸 수단이 없어 조용한 형상 변경이 재발한다. 원장·스킬 대개조가 필요하다. |
| post-render 오버레이(kustomize) | 헤드라인 문제(차트 시점 고정)를 구조적으로 못 푼다. strategic-merge는 대상 경로가 바뀌면 조용히 no-op이 되어 필수 hotfix가 누락된다. |
공통적으로 발목을 잡은 건 두 가지였다. 하나는 폐쇄망이라 OCI/외부 접근을 전제하는 방식이 전부 CI에서 막힌다는 것, 다른 하나는 “얼려야 할 형상”에 실제로 변경 금지를 걸 수단이 없다는 것이다. 타겟별 브랜치는 이 둘을 브랜치·태그라는 git 원시 기능만으로 넘는다.
✅ 선정 사유
타겟별 브랜치를 고른 이유는 셋이다. 값 병합 로직과 CI 불변식 같은 코어를 0줄 건드리지 않고(“레포 전역 단일형상” 전제를 “브랜치별 단일형상”으로 그대로 이관하면 된다), 폐쇄망 현장 지시가 “이 브랜치/태그를 checkout” 하나로 단순해지며, N:1을 1:1로 붕괴시킨다. 아래는 모델이 실제로 어떻게 도는지다.
브랜치 세 종류
브랜치를 성격으로 나눈다.
main: 최신 차트 개발선. values는 내부 검증 클러스터 것만 둔다. 항상 최신을 추적한다.internal/<타겟>: 사내 클러스터의 고정 형상(내부 검증 클러스터 제외, prod 포함).project/<타겟>: 고객사 사이트의 설치 형상.
여기서 타겟(target)은 배포 형상 하나를 가리키는 단위다. 브랜치 하나 = 타겟 하나. 그 하위의 env는 values/<env> 폴더이자 TARGET_ENV로 고르는 렌더 단위고, 한 타겟이 레이어마다 다른 이름의 env를 여럿 소유할 수 있다. 현재 매핑을 스냅샷으로 적으면 이렇다.
| 논리 타겟 | 브랜치 | base 태그 | L1 env | L2/L3 env |
|---|---|---|---|---|
| 고객사 A | project/site-a | v1.8.6 | site-a | site-a-app |
| stg(스테이징) | main | (main 추적) | stg | stg-app |
| devtest(사내 테스트) | main | (main 추적) | devtest-1 | devtest-2 |
prod로 분류될 형상은 아직 트리에 없다. 생기면 그 배포 시점 태그(예: v1.8.8)를 base로 internal/<타겟> 브랜치를 만든다.
태그 규약
태그는 세 종류를 쓰고, 전부 불변이다. 이동도 삭제도 안 한다. 폐쇄망 반입본을 대조하는 앵커이자 재현성 기준이라, 태그를 옮기는 순간 이미 반입한 곳과의 대조가 깨지기 때문이다.
- 버전 태그
vX.Y.Z:main을 본사 운영 배포 기준 시점에 찍는다(예:v1.8.6,v1.8.8). monorepo 전체 스냅샷이자 그 시점의 인증된 릴리스 지점이다. - 라인별 누적 fix-tag
v<base>-fix.<YYMMDD>: 한 버전 라인에 누적된 차트 fix를 담는다(YYMMDD= 2자리 연도+월+일, 같은 날 두 개면.1/.2). 예를 들어v1.8.6-fix.260702는 “v1.8.6+ 그 시점까지의 모든 fix”다. 라인별로 체인을 이룬다(v1.8.6→v1.8.6-fix.260702→v1.8.6-fix.260815). - 반출 형상 감사 태그
deploy/<타겟>/<YYMMDD>: 실제로 현장에 반출한 형상의 감사 정본.
fix는 기존 태그를 고쳐서 반영하지 않는다. 항상 새 fix-tag를 신설한다. 이 원칙 하나가 “이미 나간 형상은 절대 안 바뀐다”를 보장한다.
타겟 브랜치 = 태그 base + overlay
internal/*·project/* 브랜치는 선택한 태그를 base로 두고, 그 위에 overlay(사이트 values + 최소한의 사이트 특이 커밋)를 얹은 것이다. 새 태그나 fix-tag가 나오면 브랜치에 merge로 흡수한다.
# 사이트 브랜치가 새 fix-tag를 흡수하는 흐름.
# merge 이력에 "언제 무엇을 흡수했나"가 연대기로 남아 과거 배포 시점을 읽기 쉽다.
git switch project/site-a
git merge v1.8.6-fix.260702 # 충돌은 merge 마커로 명시적으로 드러난다
TARGET_ENV=site-a-app helmfile template # 그 라인 사이트로 렌더 검증
# 검증 통과 후 반입 → deploy/site-a/<YYMMDD> 태그로 반출 형상 고정왜 rebase가 아니라 merge를 기본으로 뒀나. git log --first-parent <브랜치>로 “이 사이트가 어떤 순서로 무엇을 흡수했는가”가 배포 서사로 읽히고, 충돌이 마커로 드러나 규율이 강건하기 때문이다. overlay를 clean stack으로 유지하는 게 특히 중요한 소수 타겟만 예외로 rebase(git rebase --onto <새 태그> <옛 base>)를 허용했다. 단 rebase는 이력을 재작성하므로 배포 시점 deploy 태그가 필수고 force-push가 따른다.
overlay(우리 수정)와 base의 차이는 git diff <최신 base 태그>..<브랜치>로 언제든 뽑을 수 있다.
한 브랜치 = 한 타겟
project/*·internal/* 브랜치는 자기가 소유한 env 폴더만 values/ 아래 남기고, 다른 타겟의 env 폴더는 그 브랜치에서 지운다(프룬). 그러면 CI가 values/*/values.yaml을 글롭으로 열거할 때 그 브랜치의 타겟만 렌더 검증하고, 브랜치 경계가 물리적으로 뚜렷해진다.
env 프룬은 values/<env>/만 지우고 common과 차트 트리는 남긴다. 그래서 common 보안 패치는 프룬한 브랜치에도 그대로 전파된다. (전파를 끊지 않는 게 중요하다. 미방문 사이트가 CVE 패치를 못 받으면 그게 바로 앞에서 기각한 “벤더 스냅샷”의 재앙이다.)
한 가지 함정이 있다. 태그를 merge로 흡수하면 “한 브랜치 = 한 타겟”이 두 방식으로 조용히 깨진다.
- forward 태그가 새로 추가한 타 타겟 env 폴더는 merge base에 없어서, 충돌도 경고도 없이 조용히 재유입된다(merge 단계에서 안 잡히는 silent-wrong).
- 이쪽에서 삭제한 폴더를 태그가 수정하면 deleted-by-us(DU) 충돌로 멈춘다.
그래서 매 흡수 직후 재프룬해서 소유 집합만 남긴다. DU 충돌은 삭제 유지(git rm)로 해소하는 게 표준이고, 전역 merge -X ours/theirs나 게으른 git add -A는 금지했다(각각 변경 손실, 비소유 env 재추가로 이어진다). 이 흡수+재프룬은 뒤에 나올 스킬이 대신 해 준다.
차트 수정의 전파 경로
차트 오류를 여러 버전 라인에 반영하는 절차가 이 모델에서 제일 손이 많이 가는 부분이다. 원칙은 둘이다. 차트 fix는 항상 main이 정본이고(사이트 브랜치에서 차트를 직접 안 고친다), 살아있는 버전 라인마다 누적 fix-tag를 신설한다.
# 0) 중앙 수정(정본) — main
git switch main && git switch -c fix/chart-bug-0702
# 가능하면 override로, 안 되면 최후수단으로 차트를 직접 고친다.
# → PR → main 머지(커밋 F). 이후 릴리스는 fix를 자동으로 포함한다.
# 1) 살아있는 각 버전 라인에 누적 fix-tag 신설(기존 태그는 불변)
# 반드시 그 라인의 "최신 태그"에서 시작한다 — base로 리셋하면 이전 누적 fix가 유실된다.
git switch -c tmp/168 <그 라인의 최신 태그> # fix-tag가 있으면 그것, 없으면 base(v1.8.6)
git cherry-pick F # 충돌 시 그 라인 기준으로 최소 해결
TARGET_ENV=site-a-app helmfile template # 그 라인을 쓰는 사이트로 렌더 검증
git tag -a v1.8.6-fix.260702 -m "v1.8.6 + chart-bug fix (main PR #NNN)"
git push origin v1.8.6-fix.260702
git switch main && git branch -D tmp/168 # 임시 브랜치는 지워도 태그가 커밋을 붙든다
# 2) 사이트 반영: 개별 브랜치 차트를 고치지 않고 fix-tag를 merge로 흡수
git switch project/site-a
git merge v1.8.6-fix.260702임시 브랜치가 기본이다(생성 → cherry-pick → tag → 삭제). 한 라인에 fix가 잦아지면 그때만 사이트 values 없이 차트만 담는 얇은 maint/1.8.6 브랜치를 lazy하게 만든다. fix-tag는 실제로 배포된 사이트가 있는 라인에만 만든다(무배포 라인은 skip). 라인마다 base가 달라 cherry-pick 충돌은 개별로 해결해야 하는데, 이건 백포트의 본질 비용이라 감수한다. 여러 라인을 동시에 backport할 때는 임시 브랜치 하나를 각 라인의 최신 태그로 리셋하며 재사용해서 라인별 fix-tag를 찍는다.
별도의 mutable 원장(타겟 ↔ 시점 매핑표 같은 것)은 두지 않았다. 정본은 git 브랜치와 태그고, fix-tag의 annotated 메시지에 관련 main PR을 적어 두면 “어느 사이트가 어느 fix를 기다리는지”는 git으로 조회하면 된다. 원장을 하나 더 만들면 그 원장이 또 drift한다.
툴링과 CI 게이트
모델을 사람 손 규율에만 맡기면 안 지켜진다. 그래서 라이프사이클을 스킬로 옮겼다.
release-cut: 본사 릴리스 컷. 태그 규약과 릴리스 노트(releases/<tag>.md) 생성, 태그 간·라인 간 변경 추적을 담당한다.target-onboard: 신규 타겟 온보딩. base 태그에서 브랜치를 만들고 기존 env를 전부 프룬해common만 남은 blank slate를 준 뒤, 새 env overlay 저작을 안내한다.target-upgrade: 타겟 브랜치가 새 태그/fix-tag를 흡수하는 업그레이드. merge 후 클린하면 자동으로 재프룬하고(조용히 재유입된 비소유 env 제거), 충돌이 나면 사람이 해결한 뒤 프룬만 다시 돌린다. breaking 변경이나 values 마이그레이션 판단은 사람 몫으로 남긴다.
CI 쪽에서 실제로 강제 가능한 게이트는 브랜치별 렌더 검증 하나다. GitHub ruleset으로 internal/**·project/**로 향하는 PR을 policy/lint/template/namespace-patch-tests 4종으로 fail-closed 게이트했다(main의 required check와 동일). 직접 push는 트리거로 두지 않았다. push는 CI로 막지 못하니(검사는 push 이후에 도는 사후 신호다) PR 경유를 강제하고 그 PR을 ruleset이 잡게 했다.
솔직히 여기까지 해도 drift를 원천봉쇄하진 못한다. fix 전파가 사이트 단위라 backport 대상이 많고, 라이브 반영은 리포 밖 인프라 팀 소관이다. 폐쇄·급박 환경에서 태깅·반영을 강하게 강제하는 건 애초에 무리다. 그래서 이 모델이 실제로 파는 건 “완벽한 통제”가 아니라 대조점(deploy 태그)과 읽히는 서사(merge 이력)다. 형상이 어긋나더라도 “그때 무엇을 내보냈는지”로 되짚을 앵커는 남는다. 파일럿은 고객사 A(project/site-a, base v1.8.6) 하나로 시작했다.