🚀 요약
SUMMARY
직접 배포한 매니페스트·개별 helm 설치·kustomize가 섞인 레거시 클러스터를
1.base→2.data→3.core3계층 helmfile 형상으로 이관했다. 핵심은 세 가지다. (1) 서비스를 멈추지 않아도 되는 일(인벤토리, PV Retain 선패치, 이미지 반입 검증, 신 DB 사전 시딩)은 전부 중단창 밖으로 뺀다. (2) DB는 하나로 뭉뚱그리지 않고 각각 재부착 / 백업복원 / 사전시딩+증분동기화로 갈라 정한다. (3) 되돌릴 수 없는 지점(point of no return)을 먼저 긋고, 리허설에서 DB별 데이터량·처리량·소요시간을 실측해 다운타임 병목이 어디인지 정량으로 규명한다.
1. 어디서 출발했나
이관 대상은 이미 운영 중인 클러스터였다. 문제는 형상이 하나가 아니라는 것이었다. 어떤 컴포넌트는 매니페스트를 kubectl apply로 직접 올린 것이고, 어떤 건 개별 helm install, 또 어떤 건 kustomize였다. 누가 언제 무엇을 어떻게 올렸는지 선언적(declarative)으로 한곳에 모여 있지 않다는 게 핵심 통증이었다. 재현이 안 되고, 무엇이 바뀌었는지 diff가 안 나오고, 사고가 나면 “원래 어땠는지”를 아무도 자신 있게 말하지 못한다.
목표 형상은 helmfile 3계층이었다.
| 레이어 | 하는 일 | 이관 시 유의 |
|---|---|---|
1.base | 클러스터 기반: 서비스메시, 각종 operator, NFS 스토리지, 모니터링 | 이미 떠 있는 것(서비스메시 등)은 재설치하지 않고 enabled: false로 두거나 adopt |
2.data | 데이터 서비스: DB(mariadb/postgres/weaviate/clickhouse), 오브젝트 스토리지, 레지스트리 | 데이터 보존이 최우선. DB별로 이관 방식을 갈라 정한다 |
3.core | 제품 본체 | 데이터 레이어가 정상이어야 뜬다. license 등 보존 Secret은 adopt |
배포는 1 → 2 → 3 순서고, 앞 레이어가 뒤 레이어의 전제(네임스페이스, 스토리지, 서비스 계정)를 깔아 준다. 각 레이어 디렉토리에서 TARGET_ENV=<환경> helmfile [template|diff|sync]로 돈다.
한 가지 못을 먼저 박아 둔다. 완전 무중단 전환은 불가능하다. 앱 본체(3.core)를 신형상으로 갈아타는 이상, 앱 재기동만으로도 짧은 중단은 생긴다. 그래서 실질 질문은 “무중단이 되냐”가 아니라 “DB 이관을 중단창 밖으로 얼마나 밀어낼 수 있느냐”가 된다.
전제 환경도 하나 밝혀 둔다. 인터넷이 차단된 폐쇄망(air-gapped) 클러스터를 상정했다. 이미지도 차트도 반입 매체로 들여온 것만 쓸 수 있다는 뜻인데, 이게 뒤에서 “이미지 반입 검증” 이라는 사전 작업 하나를 통째로 만들어 낸다.
2. 작업 절차 구분
전체를 사전 작업 / 본 작업 / 확인·롤백으로 나눴다. 원칙은 단순하다. 서비스를 멈추지 않아도 되는 일은 전부 사전 작업으로 미리 끝내, 중단창을 최대한 짧게 한다.
- 사전 작업(중단 전, 라이브 무영향): 인벤토리 수집 → values 작성 → ConfigMap/Secret 반영 → 렌더 검증 → 이미지 반입 확인 → DB 이관 방식 결정 → (필요 시) 신 DB 선기동+동기화 시작 → 사전 백업 → PV Retain 선패치.
- 본 작업(중단창 안): Retain 확인 게이트 → 자동배포 파이프라인 차단 → scale 0 → 최종 백업 → 레이어 순서대로
helmfile sync→ DB 재부착/복원/전환 → 구 리소스 정리 → scale 복귀. - 확인·롤백: 파드 상태·DB 정합·진입점 스모크 확인. 실패하면 미리 확보한 Retain PV와 최종 백업으로 구형상 복귀.
이 구분에서 사전 작업 목록이 유독 긴 게 의도된 설계다. 중단창 안에 남는 건 정말 멈춰야만 할 수 있는 일뿐이어야 한다.
3. 사전 작업
3-1. 인벤토리
먼저 기존 클러스터의 상태를 읽기 전용 명령으로 싹 긁어 표로 만든다. 이미지 주소·포트·Secret·PV·노드·서비스메시 버전·DB 현황. 이후 모든 판단(무엇을 adopt할지, 무엇을 enabled: false로 둘지, 어느 PV를 Retain으로 바꿀지)이 여기서 나온다.
특히 PV/PVC는 실제 데이터가 어느 디스크·경로에 있는지와 reclaim 정책을 함께 뽑아야 한다.
# 모든 PV의 종류·용량·reclaim 정책·실제 경로(NFS 서버/경로 또는 hostPath)와
# 어느 PVC에 묶여 있는지(claimRef)를 한 표로 뽑는다. 읽기 전용이라 라이브에 무해하다.
kubectl get pv -o custom-columns='NAME:.metadata.name,CLASS:.spec.storageClassName,\
CAP:.spec.capacity.storage,RECLAIM:.spec.persistentVolumeReclaimPolicy,\
NFS_PATH:.spec.nfs.path,HOSTPATH:.spec.hostPath.path,\
CLAIMNS:.spec.claimRef.namespace,CLAIM:.spec.claimRef.name'RECLAIM 열이 Delete인 PV는 전부 “Retain 선패치 대상” 으로 기록해 둔다. 이걸 안 하고 나중에 PVC를 지우면 데이터(PV)가 함께 삭제된다. 재사용할 데이터가 통째로 날아가는, 되돌릴 수 없는 종류의 사고다.
3-2. PV Retain 선패치
인벤토리에서 뽑은 Delete PV들의 회수 정책을 미리 Retain으로 바꾼다. 라이브에 무해한 변경이라 중단 전에 해 둔다.
# 재사용할 PV의 회수 정책을 Delete -> Retain 으로 바꾼다.
# 이렇게 해 두면 나중에 구 PVC 를 지워도 PV 와 그 안의 데이터는 남는다.
kubectl patch pv <PV이름> \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'이 한 줄이 뒤에 나올 롤백 시나리오의 물리적 근거가 된다. 구 리소스를 정리해도 데이터 볼륨은 남아 있으니, 컷오버가 어긋나면 돌아갈 데가 있다.
3-3. 이미지 반입 검증
렌더 결과(helmfile template)에 등장하는 모든 이미지가 로컬 레지스트리에 실제로 반입돼 있는지 컷오버 전에 대조한다. 폐쇄망에서 “이미지 없음”은 파드가 안 뜨는 정도가 아니라, 하필 중단창 한복판에서 발목을 잡아 창을 늘린다.
# 로컬 레지스트리에 반입된 이미지 목록을 조회한다(표준 registry API 기준).
# 렌더에 나온 이미지가 이 목록에 전부 있는지 반입 전에 대조한다.
curl -s http://<레지스트리주소:포트>/v2/_catalog렌더 YAML의 이미지 주소를 뽑아 이 목록과 교집합을 확인하는 게 사전 작업의 마지막 관문 중 하나다. (실제로 이번 리허설에서 registry NFS의 stale handle로 이미지 pull이 전역 실패한 사건이 있었는데, 그건 정상 경로가 아니라 트러블슈팅으로 뺐다. 사전 검증의 필요성을 역으로 증명해 준 셈이다.)
3-4. values / Secret 반영
수집한 실측값으로 환경별 values.yaml을 새로 쓴다. 전 사이트 공통 파일은 건드리지 않는다(다른 사이트에 영향이 간다). 레거시의 ConfigMap/Secret 값은 차트로 옮기는데, 여기서 함정 하나. 레거시에서 ConfigMap에 평문으로 있던 값이 신형상에서는 Secret으로 분류가 바뀐 항목이 있었다(로그인 비밀번호 류). 실값은 base64 그대로 옮기고 로그·문서에 절대 출력하지 않는다. 비밀번호·토큰은 이 글에서도 전부 secretKeyRef 참조 또는 <REDACTED>로만 적는다.
IMPORTANT
자격증명은 “values에 넣었다”로 끝이 아니다. 이번 리허설에서 자격증명이 라이브 서비스에 실제로 인증되는지 검증하는 절차가 빠져 있다는 걸 발견하고 런북에 되먹여 보강했다. 값을 옮긴 것과 그 값으로 로그인이 되는 것은 별개다.
4. DB별 이관 방식
여기가 이 이관의 진짜 몸통이다. DB를 “데이터니까 조심히” 정도로 뭉뚱그리면 중단창이 통제 불능으로 길어진다. DB마다 성질이 다르므로 방식도 갈라야 한다.
이관 방식은 크게 둘이다.
- 방식 A — 중단 후 전체 백업/복원: 단순하고 안전하다. 대신 중단창이 데이터 크기에 비례해 길어진다. 데이터가 작거나 중단 허용 시간이 넉넉하면 이걸 쓴다.
- 방식 B — 사전 시딩 + 증분 동기화: 신 DB를 중단 전에 미리 띄워 데이터를 따라잡게 하고, 컷오버 때 잔여 델타만 반영한다. 중단 기여를 수 분으로 줄일 수 있지만 절차가 복잡하고 사전 조건과 리허설이 필요하다.
DB별 결론은 이렇게 났다.
| DB | 완전 무중단 | 준무중단(수 분) | 성립 조건 / 선택 |
|---|---|---|---|
| mariadb (galera) | 불가 | 가능 (방식 B) | 구측 binlog가 켜져 있거나 활성화 재시작 감수, GTID 복제 버전 호환 |
| postgres (CNPG) | 불가 | 가능 (방식 B) | 구측 wal_level=logical(변경 시 재시작), 논리복제 + 컷오버 시 시퀀스 수동 동기화 |
| weaviate | 불가 | 불가 | 증분 동기화 수단이 없다. 쓰기 정지 창 = backup+restore 시간이 하한 |
| clickhouse | 불가 | 불가 | PV 인플레이스 재부착이 정답. 재시딩하지 않는다 |
| minio(오브젝트 스토리지) | — | — | PV 재사용(정적 PV 재부착) 우선, mc mirror는 폴백 |
판단의 결이 셋으로 갈린다.
첫째, 재사용이 압도적으로 유리한 것은 재시딩하지 않는다. clickhouse가 대표적이다. 규모가 큰데(리허설 실측 약 132.6GB) 굳이 옮겨 담을 이유가 없다. PV를 Retain으로 두고 CHI/CHK 이름을 라이브와 일치시켜 operator가 같은 데이터 경로·복제를 그대로 인계받게 하면, 시딩 시간이 통째로 0이 된다. 대용량일수록 “옮기지 않는 설계”의 이득이 크다. minio도 같은 결로 정적 PV 재부착을 1순위로 두고, 포맷·계보가 안 맞아 안 읽히면 mc mirror로 폴백한다.
둘째, 증분 동기화가 되는 것만 방식 B로 뺀다. mariadb는 binlog/GTID, postgres는 논리복제(logical replication)라는 증분 수단이 있다. 이 둘은 신 클러스터를 중단 전에 세워 따라잡게 해 두면, 컷오버 때는 잔여분만 마감하면 된다. 그만큼 중단창에서 빠진다. postgres 승격 때는 논리복제가 시퀀스를 따라오지 않는다는 함정이 있어서, 컷오버 시점에 시퀀스 setval을 수동으로 맞추고 소유권(테이블·시퀀스·뷰·함수)을 재지정하는 마감 절차를 반드시 넣었다.
셋째, 증분 수단이 없으면 방식 B를 억지로 만들지 않는다. weaviate가 그렇다. 논리복제 같은 표준 증분 스트림이 없어서, 결국 backup/restore가 하한이다. 다만 앱 레벨에서 lastUpdateTimeUnix 기반 델타 스크립트를 만들어 “사전에 backup/restore로 시딩 → 이후 델타를 여러 번 따라잡기 → 컷오버 때 최종 델타 + 삭제 대조”로 준(準)방식B를 흉내 냈다. 표준 수단이 없는 걸 앱 지식으로 메운 셈인데, 삭제 정합(이미 지워진 오브젝트를 신 클러스터에서도 지웠는지)이 가장 까다로웠다.
한 가지 더. mariadb는 galera 이미지가 USER 999로 뜨도록 돼 있어서 values에 podSecurityContext를 명시하지 않아야 non-root(999)로 뜬다. 과거에 runAsUser:0을 명시했다가 root로 떠 버린 적이 있어서(runAsGroup을 빠뜨리면 primary gid가 0이 되는 함정 포함), 컷오버 확인 단계에서 파드의 id로 uid·gid가 모두 0이 아닌지 반드시 검증하는 게이트를 넣었다.
5. 롤백 게이트와 point of no return
되돌릴 수 없는 작업이니, 되돌릴 수 있는 경계선부터 그었다. 이번 이관의 경계선은 이렇게 정했다.
- 방식 B는 소스 DB를 살려둔 채 신규로 복제하므로, 정합 검증이 어긋나거나 컷오버가 실패해도 소스로 돌아가기 쉽다. 이게 방식 B의 진짜 이점이다(다운타임 단축은 부차적).
- 되돌릴 수 있는 한계선은 구 리소스 정리와 원본 데이터 볼륨 회수 직전까지다. 원본(구 PV·PVC)을 지운 뒤에는 되돌릴 수 없다. 이게 point of no return이다.
- 그래서 옛 소스 데이터 볼륨(mariadb/postgres/weaviate의 PV·PVC, 복사형으로 옮긴 소스 폴더)은 컷오버 시점에 정리하지 않는다. 신 클러스터가 정상 동작하는지 1~2주 관찰한 뒤 제거한다. Retain 선패치 덕에 옛 PV를 살려 두는 부담도 없다. 정리를 서두르면 롤백 창을 스스로 앞당겨 닫는 꼴이다.
- DB 스키마 변경(DDL)은 중단창 안에서도 되돌리기 어렵다. 스키마를 바꾸는 단계가 있으면 그 직전 백업을 롤백 경로로 반드시 확보한다.
정합성 검증도 하나의 게이트로 세웠다. 쓰기를 멈춘 뒤 신 클러스터가 구 DB와 정합한지(행수, 시퀀스 값, weaviate 오브젝트 수, 삭제 대조) 확인하고, 통과할 때까지 다운타임을 유지하며 컷오버로 넘어가지 않는다. 불일치가 있으면 원인을 잡아 재동기화·재검증한다. 다운타임을 굳이 짧게 만들기보다, 정합이 맞는지 확실히 확인하는 게 먼저다 — 적어도 리허설 단계에서는 그렇다.
6. 리허설 실측
계획만으로는 중단창 길이를 못 잡는다. 그래서 라이브를 건드리지 않는 리허설 환경에서 방식 B를 실제로 한 번 완주하고, DB별 데이터량·처리량·소요시간을 실측했다. 트러블슈팅·오배치·재시도로 낭비된 시간은 빼고, 무사고 정상 경로의 실측만 남겼다(현장 시간 산정의 근거로 쓰려면 이게 맞다).
6-1. 데이터 규모 (측정 기준일 2026-07-15)
| DB | 형상 | 규모 |
|---|---|---|
| mariadb | galera, 앱 전체 DB | ≈ 30.4 GB (단일 DB 하나가 ≈27GB로 지배) |
| postgres (4개 DB) | CNPG / standalone | ≈ 104 MB (논리복제 대상) |
| weaviate | 3노드 RF=3 | 1,465,573 오브젝트 / 657 클래스 / 22 GiB |
| clickhouse | CHI 3 + keeper 3 | ≈ 132.6 GB (인플레이스 재사용 → 시딩 0) |
대용량 시딩 대상은 mariadb(30GB)와 weaviate(1.47M/22GiB) 둘뿐이라는 게 첫 발견이었다. postgres는 데이터가 워낙 작고, clickhouse는 재사용이라 시딩이 0이다. 병목 후보가 둘로 좁혀졌다.
6-2. 시딩 소요시간 (정상 경로 실측)
| DB | 데이터 | 소요 | 지배 요인 |
|---|---|---|---|
| postgres (4 DB 시딩+검증) | 104 MB | ≈ 12분 | 데이터가 작아 스키마 프리로드·검증 같은 고정 세팅이 지배. 볼륨과 무관, subscription 수에 선형 |
| weaviate (backup+restore+검증) | 22 GiB / 1.47M | ≈ 61분 | backup ≈1 GiB/분. restore는 오브젝트 전송(≈5분)보다 클래스 등록 FINALIZE(≈16분)가 지배 — 용량보다 클래스 수에 민감 |
| mariadb (초기 시딩) | ≈ 30 GB | ≈ 70분 | ≈0.43 GB/분. 인덱스 재구성이 지배하는 논리복원 |
숫자를 보고 나서야 직관이 틀렸다는 걸 알았다. weaviate의 restore 시간은 데이터 용량이 아니라 클래스 수에 민감했다(657클래스의 FINALIZE가 16분). “22GiB니까 오래 걸리겠지”가 아니라 “클래스가 많아서 오래 걸린다”가 맞았다. 반대로 postgres는 104MB밖에 안 되는데도 12분이 드는데, 그 12분은 데이터가 아니라 세팅·검증이라는 고정비였다.
6-3. 컷오버 스위치 (쓰기 정지 후)
쓰기를 멈춘 뒤 실제 컷오버 스위치에 든 시간은 다 합쳐 ≈14분이었다. postgres 승격(시퀀스 setval + subscription 정리) ≈2분, weaviate 델타 확인(프리즈라 델타 0) ≈4분, clickhouse adopt(인플레이스 소유권 인계) ≈4분, minio 재부착 확인 ≈1분, 소량 복사형(npm/pypi) ≈3분. 데이터를 재사용하거나 소량이라 전부 분 단위다.
6-4. 병목: 시딩 vs 스위치
실측이 말해 준 건 분명하다.
- 지배 요인은 mariadb 시딩(≈70분) + weaviate backup/restore(≈61분)이다. 둘이 규모가 비슷하고 병렬이 가능하므로, 병렬로 돌리면 전체 시딩 창은 mariadb의 ≈70분에 수렴한다.
- 쓰기 정지 후 순수 컷오버 스위치는 ≈14분. 즉 중단창의 대부분은 스위치가 아니라 사전 시딩이 차지한다. 시딩을 쓰기 정지 전으로 최대한 밀어내는 게(=방식 B) 다운타임 관점에서 핵심이라는 게 숫자로 확인됐다.
- clickhouse처럼 인플레이스 재사용이 되는 대용량은 재시딩하지 않는 설계가 시간을 가장 크게 줄인다(132.6GB의 시딩 시간이 0).
한 가지 아프게 배운 것. mariadb 초기 시딩은 중간에 실패하면 이어서 할 수 없다. 논리복원이 끊기면 부분 적재분이 정합을 못 이뤄(인덱스·제약 미완, 심하면 InnoDB 손상) 처음부터 전량 재실행해야 한다. 그래서 위 70분은 무사고 1회 값이고, 장애가 나면 그만큼씩 배수로 튄다. 현장 산정에는 그래서 이렇게 반영했다.
- 시작 전 노드 자원(특히 메모리)·스토리지 경로·galera 상태를 확실히 안정화해 한 번에 완주할 조건을 만든다.
- 일정에 최소 1회 재실행 버퍼(≈+70분)를 잡는다.
- 재시작할 때는 부분 데이터를 남기지 말고 타겟 볼륨을 완전 초기화한 뒤 다시 태운다(잔여 데이터가 다음 시도를 오염시킨다).
7. 이관에서 한 판단
helmfile 자체는 도구일 뿐이고, 이 이관에서 실제로 고민한 건 위험을 어디에 배치하느냐였다.
- 무중단이라는 환상을 일찍 버리고, “DB를 얼마나 중단창 밖으로 빼느냐”로 문제를 다시 정의한 것.
- DB를 성질별로 갈라, 재사용(clickhouse/minio) / 증분(mariadb/postgres) / 백업복원(weaviate)으로 방식을 나눈 것.
- point of no return을 먼저 긋고, Retain 선패치와 소스 유예(1~2주)로 롤백 창을 물리적으로 확보한 것.
- 그리고 감이 아니라 리허설 실측으로 병목(시딩 70분 vs 스위치 14분)을 정량화한 것.
이번 작업의 1차 목적 자체가 “다운타임 최소화”가 아니라 “방식 B가 이 형상에서 실제로 성립하는가 + 기존 DB와 정합이 맞는가”를 현장 적용 전에 검증하는 리허설이었다. 그래서 다운타임을 억지로 줄이기보다 정합을 확실히 보는 데 무게를 뒀다. 실제 다운타임 최적화(증분 lag 최소화 등)는 이 방식이 성립함을 확인한 다음, 현장 적용 단계에서 다룰 일이라고 정리했다.
런북은 한 번 쓰고 방치하는 문서가 아니었다. 이관을 돌리며 빠진 절차(자격증명 실인증 검증 등)를 발견할 때마다 원본 런북에 되먹여 보강했다.