Kubernetes 업그레이드
지원 창과 worker pin을 보존하며 chart를 교체한다
무엇을 하게 되나
직전 worker pin과 지원 창을 보존하고 업그레이드 검사 결과를 읽은 뒤 게시 chart를 교체한다.
준비물
변경 기록에서 대상 버전의 운영자 작업을 먼저 읽는다.
설치별 values, jq·helm·kubectl, 현재 deployed revision과 worker-previous.json, DB·S3 백업이 필요하다. kind에서 승인을 기다리는 세션과 checkpoint를 남기고 멈춘 세션을 둔 채, 이미지와 schema는 그대로 두고 extraEnv만 바꿔 Pod를 다시 띄우는 helm upgrade와 helm rollback(migrate는 할 일이 없었다)을 실행해 hook 순서와 세션 상태·이벤트·checkpoint 이어짐을 확인했다(deploy/kind/upgrade.sh). control-host 이미지를 바꾸거나 새 migration이 있는 upgrade·rollback(rollback 뒤의 새 schema와 옛 코드), worker 이미지가 바뀌는 upgrade·rollback, 영향 세션을 승인하는 흐름은 실행 확인하지 않았다. fixture chart render와 게시 chart 설치를 구분한다.
공통 실행 전 게이트의 활성화 순서와 롤백 하한을 먼저 대조한다. Compose 전용 검사 명령 대신 아래 Helm hook과 직접 검사를 사용한다.
설치의 context·namespace·fullname을 확인한다. 새 control-host·직전 worker 조합은 시험 설치에서 turn 1 완료 → worker Pod 제거 → checkpoint에서 turn 2 완료와 API의 worker 요청 400 부재를 확인한다. deploy/kind/up.sh는 images.worker를 --set으로 덮으므로 EXTRA_VALUES로 직전 worker 이미지를 시험하지 말고 직접 helm upgrade한다.
단계
1. 대상 chart와 제어 영역 우선 교체
VERSION은 정확한 대상 chart 버전이다. --reuse-values를 쓰지 않고 설치별 values만 넘긴다. protocol/manifest/usage 변경이 있으면 control-host와 worker를 같은 upgrade에서 바꾸지 않는다.
VERSION=<new-version>
DEPLOYED="$(helm -n werft history werft -o json | jq '[.[] | select(.status == "deployed")] | last.revision')"
helm -n werft get values werft --all --revision "$DEPLOYED" -o json \
| jq '{images: {worker: .images.worker, workerPrevious: .images.workerPrevious, egressProxy: .images.egressProxy}}' \
> control-phase.json
helm upgrade --install werft oci://ghcr.io/jeongjaesoon/werft --version "$VERSION" \
--namespace werft -f values-<installation>.yaml -f control-phase.json \
--set-json 'upgrade.approvedSessions=[]' --timeout 10m
kubectl -n werft rollout status deploy/werft-api --timeout=600s중간 upgrade는 새 chart의 control-host를 적용하고 worker·egress proxy는 현재 것을 유지한다. migration과 모든 API 교체 및 ready를 확인한 뒤 다음 단계로 진행한다. 이미 떠 있는 worker는 이미지 변경만으로 교체되지 않으며 다음 launch부터 새 이미지를 사용한다.
2. 최종 worker와 proxy 교체
게시 chart는 images.worker.runtime을 담고 images.workerPrevious는 비워 둔다. 직전 이미지는 chart가 알 수 없고 설치마다 다르기 때문이다. worker digest가 바뀌는 upgrade는 대상의 images.workerPrevious가 지금 배포된 이미지가 아니면 refused로 멈추고, 승인으로도 넘길 수 없다(업그레이드 전 검사). 그래서 지금 이미지를 파일로 저장해 함께 넘긴다.
VERSION=<new version>
DEPLOYED="$(helm -n werft history werft -o json | jq '[.[] | select(.status == "deployed")] | last.revision')"
CURRENT="$(helm -n werft get values werft --all --revision "$DEPLOYED" -o json | jq -r '.images.worker.digest')"
TARGET="$(helm show values oci://ghcr.io/jeongjaesoon/werft --version "$VERSION" \
| awk '/^ worker:/ {found = 1} found && /digest:/ {print $2; exit}')"
echo "deployed ${CURRENT}"
echo "target ${TARGET}"
PREVIOUS=worker-previous.json
if [ -n "$TARGET" ] && [ "$CURRENT" != "$TARGET" ]; then
PREVIOUS=worker-previous.next.json
helm -n werft get values werft --all --revision "$DEPLOYED" -o json \
| jq '{images: {workerPrevious: .images.worker}}' > "$PREVIOUS"
fi
set -- -f values-<installation>.yaml
if [ -f "$PREVIOUS" ]; then set -- "$@" -f "$PREVIOUS"; fi
if helm upgrade --install werft oci://ghcr.io/jeongjaesoon/werft --version "$VERSION" \
--namespace werft "$@" --timeout 10m; then
if [ "$PREVIOUS" != worker-previous.json ]; then mv "$PREVIOUS" worker-previous.json; fi
fi--revision을 빼면 실패한 upgrade가 있을 때 그 revision의 값, 즉 올리려던 이미지가 나온다. worker digest가 다르면 지금 이미지를 후보 파일에 쓰고, upgrade가 성공한 뒤에만 worker-previous.json을 그 파일로 바꾼다. 실패한 upgrade는 보존 파일을 건드리지 않는다. digest가 같으면 있던 파일을 그대로 넘긴다. 처음 설치한 뒤 worker가 그대로인 upgrade에는 아직 넘길 파일이 없고, 그래도 된다.
worker-previous.json은 설치 옆에 보관하고 다음 upgrade에도 넘긴다. worker digest가 같은 upgrade에서 이 파일을 빠뜨리면 배포된 직전 이미지가 빠진다. 업그레이드 전 검사는 그때 세션을 비교해, 대상 이미지(현재 이미지와, 있으면 직전 이미지) 어느 쪽과도 조합이 맞지 않거나 조합이 기록되지 않은 세션이 있으면refused로 멈춘다(승인할 수 있다). 실수로 빠뜨렸다면 승인하지 말고 파일을 넣어 다시 돌린다. 고른 세션이 없고upgrade.approvedSessions가 비어 있으면 통과한다(지난 upgrade의 승인이 남아 있으면refused다). 검사가 보지 못하는 세션은 아래 "검사가 보지 않는 것"을 본다. 같은 날 머지된mainchart는 worker digest가 같을 수 있다.--reuse-values를 쓰지 않는다. 옛 chart의 값을 기본값으로 쓰므로 새 chart 버전인데 이미지는 그대로 남는다.helm get values --all전체를 values 파일로 넘기지 않는다. 옛 digest가 새 chart의 값을 덮는다..images.worker만 꺼낸다.v*태그에서 게시한 chart도 같은 절차를 따른다.
최종 upgrade에는 control-phase.json을 넘기지 않는다. worker-previous.json은 성공 뒤에만 교체하고 다음 upgrade에도 계속 보관한다. 승인 목록은 매 upgrade마다 비우고 새 affected 목록을 확인한다.
3. 검사 결과와 예외
업그레이드 전 검사
helm upgrade는 migrate보다 먼저(hook weight -5) upgrade-check Job을 대상 릴리스의 api 이미지로 돌린다. Job은 수거되지 않은 checkpoint가 있는 세션마다 포인터의 조합을 읽고, 대상 릴리스의 images.worker와 images.workerPrevious 어느 쪽으로도 재개할 수 없는 세션을 고른다. 조합을 기록하기 전에 커밋된 포인터도 고른다. 결과는 Job 로그의 JSON 한 줄이다.
kubectl -n werft logs job/werft-upgrade-checkverdict | exit | upgrade | 뜻 |
|---|---|---|---|
proceed | 0 | 진행 | upgrade.approvedSessions가 affected의 sessionId와 정확히 같다. 고른 세션이 없거나, worker 이미지가 그대로이고 배포된 직전 이미지가 없거나 그대로이거나, 배포된 적이 없으면 승인 목록이 비어 있어야 한다 |
refused | 2 | 멈춤 | 승인 목록이 하나라도 다르다(지난 upgrade의 승인이 values에 남은 경우 포함). worker 이미지가 그대로여도 배포된 직전 이미지가 대상에서 빠지거나 바뀌면 세션을 비교하므로, 대상 이미지(현재 이미지와, 있으면 직전 이미지) 어느 쪽과도 조합이 맞지 않거나 조합이 기록되지 않은 세션이 목록에 오른다. 또는 worker 이미지가 바뀌는데 대상의 images.workerPrevious가 지금 배포된 worker 이미지가 아니다. 이 경우는 승인으로 넘길 수 없다 |
check_failed | 1 | 멈춤 | DB를 읽지 못했거나, 이미지 조합이 읽히지 않거나 아래 표대로 필요한데 없거나, 배포된 worker 이미지를 알 수 없다. 배포된 worker 이미지가 있는데 UPGRADE_DEPLOYED_WORKER_IMAGE_PREVIOUS가 없어도 여기다(직전 이미지가 없으면 빈 값을 준다). scheduler Deployment가 없는데 DB에 세션이나 일부 schema가 있어도 여기다. 넘기는 설정은 없다 |
images.worker.runtime은 첫 설치에는 없어도 된다. upgrade에서 필요한지는 배포된 상태와 대상 값에 달렸다.
| 배포된 상태 | 대상 | runtime 없을 때 |
|---|---|---|
| scheduler Deployment 없음, DB가 비어 있거나 세션 없음(첫 설치 재시도) | 어떤 worker 이미지든, images.workerPrevious 없음 | proceed |
| worker 이미지 하나, runtime·직전 이미지 없음 | 같은 worker 이미지, images.workerPrevious 없음 | proceed |
| worker 이미지 하나, runtime·직전 이미지 없음 | 다른 worker 이미지 | check_failed. 대상에 images.worker.runtime과, 지금 이미지를 담은 images.workerPrevious를 둔다 |
| runtime이나 직전 이미지가 있음 | images.worker.runtime을 뺀 값 | check_failed. 조합 없이는 세션을 검사할 수 없다 |
runtime 값을 얻는 방법은 설정 레퍼런스의 API 설정에 있다. 최소 values 설치는 runtime 없이 시작하고, worker 이미지를 처음 바꾸는 upgrade에서 두 이미지의 runtime을 함께 넣는다.
images.worker를 그대로 두고 배포된 images.workerPrevious를 빼거나 바꾸는 upgrade는 세션을 비교한다. 대상 이미지(현재 이미지와, 있으면 직전 이미지) 어느 쪽과도 조합이 맞지 않거나 조합이 기록되지 않은 세션이 있으면 refused이고, 그 목록을 승인하면 통과한다. 조합이 기록되지 않은 세션은 이미지를 바꾸는 upgrade와 같게 어느 경우든 고른다. 배포된 직전 이미지가 없었거나 그대로면 세션을 비교하지 않고, 승인 목록이 비어 있을 때 proceed다(남은 승인은 refused다). 이미지만 다르고 조합이 같은 재빌드는 직전 이미지를 빼도 세션이 현재 이미지에서 재개되므로 목록에 오르지 않는다.
고른 세션이 재개하지 못해도 되면 그 목록을 그대로 values에 넣고 같은 upgrade를 다시 돌린다. 승인은 세션 상태를 바꾸지 않는다. no_image로 고른 세션은 재개할 때 recovery_required로 멈춘다. unrecorded로 고른 세션은 새 이미지로 뜨고, 복원이 판정해 이어지거나 recovery_required로 멈춘다.
helm upgrade --install ... --set-json 'upgrade.approvedSessions=["<sessionId>", ...]'승인 목록은 release values에 남는다. 다음 upgrade마다 --set-json 'upgrade.approvedSessions=[]'로 비우고, 그 upgrade의 목록을 다시 읽고 승인한다. 남은 목록은 그 upgrade의 affected와 다르면 refused로 멈추지만, 같고 지원 창 조건도 맞으면 그대로 통과한다.
Job은 승인 목록을 UPGRADE_APPROVED_SESSIONS(JSON 배열)로, 배포된 worker 이미지를 UPGRADE_DEPLOYED_WORKER_IMAGE로, 배포된 직전 이미지를 UPGRADE_DEPLOYED_WORKER_IMAGE_PREVIOUS(없으면 빈 값)로 받는다. 배포된 이미지는 chart가 지금 scheduler Deployment의 WORKER_IMAGE와 WORKER_IMAGE_PREVIOUS를 lookup으로 읽어 넘긴다. kind에서 실제 helm upgrade가 lookup으로 읽은 배포된 worker 이미지는 대상 이미지와 같았고 판정은 proceed였다. 직전 이미지를 읽는 lookup 분기와 직전 이미지를 빼거나 바꾸는 실제 helm upgrade는 클러스터에서 실행해 확인하지 않았다. 검사 진입점만 실제 PostgreSQL에 대고 돌려 확인했다. runtime 표의 예외를 열 수 있는 것은 chart가 함께 넘기는 UPGRADE_DEPLOYED_STATE뿐이다. absent는 scheduler Deployment가 없고 namespace의 default ServiceAccount는 읽혔다는 뜻이다(그래서 이 경로에는 그 ServiceAccount의 get 권한이 필요하다). unpinned는 Deployment가 runtime·직전 이미지 없이 도는 상태다. helm template과 클라이언트 --dry-run에서는 lookup이 아무것도 읽지 않아 세 값이 비므로 그 렌더로 돌린 Job은 check_failed가 된다. --dry-run=server는 클러스터를 읽지만 hook을 실행하지 않으므로 판정을 대신하지 않는다. 검사는 DATABASE_URL이 이 설치의 DB라고 믿는다. 다른 빈 DB를 가리키면 absent 경로가 통과한다. 첫 설치(pre-install)와 scheduler.enabled: false에서는 Job을 만들지 않는다. Job과 scheduler가 같은 이미지를 보도록 WORKER_IMAGE·WORKER_IMAGE_PREVIOUS·WORKER_IMAGE_RUNTIME·WORKER_IMAGE_PREVIOUS_RUNTIME은 *.extraEnv, config.limits, secrets.catalogEnv로 줄 수 없다. 주면 render가 실패한다. images.worker와 images.workerPrevious로 준다.
helm rollback 전. rollback은 hook을 돌리지 않으므로 같은 명령을 api Pod에서 직접 돌린다. 값은 되돌릴 revision의 것(helm get values --revision <revision>)을 넣는다.
kubectl -n werft exec deploy/werft-api -- env \
UPGRADE_DEPLOYED_WORKER_IMAGE='<WORKER_IMAGE of the running scheduler>' \
UPGRADE_DEPLOYED_WORKER_IMAGE_PREVIOUS='<WORKER_IMAGE_PREVIOUS of the running scheduler, empty if none>' \
WORKER_IMAGE='<worker image of the target revision>' WORKER_IMAGE_RUNTIME='<its runtime JSON>' \
WORKER_IMAGE_PREVIOUS='<workerPrevious of that revision>' WORKER_IMAGE_PREVIOUS_RUNTIME='<its runtime JSON>' \
bun run apps/control-host/src/api/upgrade-check.tsworker 이미지를 되돌리는 보통의 rollback은 refused다. 되돌릴 revision의 images.workerPrevious가 지금 배포된 이미지가 아니기 때문이다(검사는 조합이 아니라 이미지 참조를 비교한다). 설정이나 DB를 읽지 못하면 check_failed다. 지금 이미지로 도는 worker는 rollback 뒤에도 끝날 때까지 그 이미지의 조합으로 checkpoint를 남기는데, 검사는 그 checkpoint를 보지 못한다. 출력의 affected는 검사 시점에 이미 고른 세션이다. 롤포워드가 우선이다. 그래도 되돌리면 재개할 때 scheduler가 조합으로 이미지를 고른다. 되돌린 revision의 worker 이미지와 직전 이미지(있다면) 어느 쪽과도 조합이 맞지 않는 세션(지금 이미지가 새 SDK·CLI를 실었다면 rollback 동안 지금 이미지로 checkpoint를 남긴 세션)은 recovery_required로 멈춘다. images.worker를 그대로 두는 rollback은 지금 배포된 직전 이미지가 되돌릴 revision에도 그대로 있으면 승인 목록이 비어 있을 때 proceed이고(남은 승인은 refused), 빠지거나 바뀌면 이 페이지의 upgrade 검사처럼 세션을 비교한다. 이 명령은 hook이 아니어서 rollback을 막아 주지 않는다. refused가 나오면 affected를 읽고 되돌릴지 정한다. 배포된 worker 이미지가 있는데 UPGRADE_DEPLOYED_WORKER_IMAGE_PREVIOUS를 빼면 check_failed다.
검사가 보지 않는 것. 단일 서버의 업그레이드 검사와 달리 이 검사는 대상 이미지를 실행하지 않는다. 포인터에 기록된 조합(engine·SDK·CLI·profile digest 방식)만 비교하고, 실제 profile digest는 복원 때 판정된다. 검사 뒤 writer를 멈추고 다시 검사하지도 않는다. worker 이미지가 바뀌는 upgrade는 대상의 images.workerPrevious가 지금 배포된 이미지와 같아야 통과하므로, 검사 뒤에 지금 이미지의 worker가 남기는 checkpoint는 대상 지원 창 안에 있다. worker 이미지를 그대로 두고 직전 이미지를 빼는 upgrade에는 이 보장이 없다. 직전 이미지로 도는 worker는 검사 뒤에도 그 조합으로 checkpoint를 남길 수 있다. 첫 checkpoint 전인 세션도 마찬가지다. 그러니 직전 이미지를 빼기 전에 그 worker가 끝났는지 먼저 본다. 그보다 옛 이미지로 도는 worker의 세션은, 포인터가 이미 그 조합이면 목록에 오른다. 첫 checkpoint 전인 세션은 목록에 없다. 이 한계는 지원 창에서 빠지는 모든 이미지, 그리고 직전 이미지를 바꾸는 upgrade에도 같다. 대상 이미지를 registry에서 받을 수 있는지도 보지 않는다. 받지 못하면 launch 실패로 나타난다.
4. 파일 카탈로그 설치에서 오는 경우
catalog 값을 쓰던 chart의 ConfigMap은 지금 chart에 없다. Helm은 hook이 끝난 뒤에 옛 리소스를 지우므로, pre-upgrade migrate Job이 같은 ConfigMap을 /legacy-config에 선택 마운트해 settings import --from-dir /legacy-config --if-present로 가져온다. 다음 upgrade부터는 ConfigMap이 없어 아무 일도 하지 않는다. 일부 파일만 있거나 값이 깨졌으면 Job이 실패하고 Deployment는 바뀌지 않는다.
PLATFORM_SECRET_KEY를 Secret에 먼저 넣는다. 이미 있으면 같은 값을 그대로 둔다.- values에서
catalog절을 지우고helm upgrade한다.api.extraEnv에PLATFORM_CONFIG_DIR을 두었다면 지운다. - 설치 버전의 저장소 checkout과 호스트 Bun·kubectl을 준비한 뒤
bun run settings:k8s export로 더해진 항목을 본다.installation_profiles에 행이 이미 있으면 파일의 상한은 다시 넣지 않는다. 절차와 되돌리기 하한은 api-server.md에 있다.
확인
kubectl -n werft get job werft-migrate
for d in api egress-proxy reconciler scheduler; do
kubectl -n werft rollout status deploy/werft-$d --timeout=600s
donekubectl -n werft port-forward svc/werft-api 13000:3000 &
PF=$!
trap 'kill "$PF" 2>/dev/null || true' EXIT INT TERM
READY=0
for attempt in $(seq 1 30); do
if curl --max-time 2 -fsS -o /dev/null http://127.0.0.1:13000/readyz; then READY=1; break; fi
sleep 1
done
if [ "$READY" != 1 ]; then
kill "$PF" 2>/dev/null || true
trap - EXIT INT TERM
false
else
curl --max-time 10 -fsS -w '\n%{http_code}\n' http://127.0.0.1:13000/readyz
curl --max-time 10 -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:13000/docs
kill "$PF" 2>/dev/null || true
trap - EXIT INT TERM
fiJob 로그의 실제 verdict와 rollout, ready 응답을 남긴다. 기존 세션 재개와 checkpoint context는 별도 확인한다. 지표 이름 변경이 있으면 수집기와 규칙을 갱신한다.
자주 막히는 곳
workerPrevious가 현재 배포 이미지가 아니면 승인으로 넘길 수 없는 refused다. helm get values의 revision을 빼면 실패한 upgrade의 후보 값이 나올 수 있다. lookup 없는 helm template이나 client dry-run은 실제 upgrade 검사를 대신하지 않는다.
되돌리기
helm -n werft history werft
helm -n werft rollback werft <revision> --wait --timeout 10m롤백은 migrate hook을 돌리지 않는다(pre-rollback이 아니다). DB는 새 schema에 남고, 옛 빌드는 자기보다 앞선 schema에서도 ready가 된다(/readyz의 schema 검사는 자기 migration 목록이 적용된 목록의 앞부분과 같은지만 본다). 그래서 롤백할 수 없는 migration은 배포하지 않는다. 어느 이미지까지만 되돌릴 수 있는 migration(예: 0243)은 그 하한을 Compose 업그레이드 표에 이미지별로 적고, 하한 아래로는 롤백하지 않는다. worker 이미지를 되돌릴 때 checkpoint 호환은 worker·control-host 롤백과 incremental checkpoint를 먼저 본다.
링크한 공통 실행 전 게이트와 직접 검사에서 rollback 가능성을 확인한 뒤에만 되돌린다. hook이 없으므로 rollback 명령이 검사를 자동으로 막아 주지 않는다. 롤포워드가 우선이다.