Files
document-haness/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d2-version-upgrade.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:42 +09:00

38 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
e53c5947-e1df-400a-ad79-e9d55b1da452 SETUP reproduce-d2-version-upgrade 이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다 operations-that-report-success 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/e53c5947-e1df-400a-ad79-e9d55b1da452/edit
name version
Keycloak (시작·복귀 태그) 26.7.0
name version
Keycloak (정방향) 26.7.3
name version
Keycloak (역방향 대조) 26.0
name version
Infinispan (26.7.3 에 실린 판) 16.0.14
final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2
cdac9b8178391311d8eca1ebc6cac15bb62d79af

이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다

databasechangelog 행 수를 먼저 세고 이미지 태그를 정방향·롤백·역방향으로 세 번 바꾸는 절차다. 마지막 한 번은 파드를 CrashLoopBackOff 로 만들므로 백업 없이 시작하지 않는다.

관계

  • 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다 이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
  • 롤링 재시작은 세션을 남기고 캐시만 지웠다 태그를 바꾸면 파드가 하나씩 갈린다. 그때 세션과 캐시가 어떻게 갈리는지를 그쪽이 다룬다.
  • readiness 가 깨진 노드를 시야에서 먼저 치운다 Running 인데 0/1 인 파드가 왜 트래픽을 안 받는지, 그것이 왜 사고를 절반에서 멈추는지 다룬다.
  • 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다 먼저 해 둬야 하는 편이다. 여기서 쓰는 되돌리기 수단이 태그가 아니라 그 편이 남긴 덤프일 수 있다.
  • 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다 롤링 재시작이 무중단이라는 것이 이 절차의 전제다. 그 전제를 그 편에서 쟀다.

본문

읽기 전에 — 어디서 치는가

명령은 [lab host] 에서 친다. kubectlsudo 를 붙이지 않는다. 호스트로 넘어가는 단계가 없어 전 구간을 게스트 안에서 끝낸다. 터미널을 두 개 열어 두면 편하다 — 하나는 가용성 폴링용, 하나는 관찰용이다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

무엇
네임스페이스 keycloak-lab
주입 수단 set image statefulset/keycloak — 태그를 세 번 바꾼다
되돌리는 수단 태그 한 줄. 단, 행 수가 안 바뀌었을 때만
전 구간 약 20분
대조군 1초 간격 150회 폴링. --max-time 3
jq 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다

실측이 두 실행에서 나온다(observed). 처음 실행은 15:0015:10 에 역방향 26.0 을 쳤고, 후속 실행은 15:2215:2626.7.3 정방향과 롤백을 쳤다. 아래에서도 어느 쪽인지 매번 적는다.

이 실험이 가르는 것

「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다. 그 계획이 언제 동작하고 언제 동작하지 않는지를 가른다.

Keycloak 은 Liquibase 로 스키마를 관리한다. 적용한 변경 하나하나가 databasechangelog 테이블에 행으로 쌓이고, 각 행에는 그 변경 정의의 체크섬(md5sum)이 들어 있다.

   컨테이너가 뜬다
      └─▶ Liquibase 가 databasechangelog 를 읽는다
             └─▶ 자기가 아는 changeset 의 체크섬과 대조한다
                    ├─ 같다   → 기동
                    └─ 다르다 → ValidationFailedException. 기동 거부

「모르는 변경이 있다」가 아니라 「아는 변경인데 정의가 다르다」이며, 더 엄격한 실패다. 그래서 판정 기준이 이렇게 바뀐다.

이렇게 묻지 말고 이렇게 묻는다
26.7.3 에서 26.7.0 으로 내려도 되나 databasechangelog 의 행 수가 바뀌었나

이 절차는 정정된 결론을 따른다. 해설 문서는 처음에 「롤백은 안 된다」고 단정했다가 후속 실험에서 정정했다.

버전 차 databasechangelog 롤백
26.7.0 → 26.0 체크섬 불일치 불가
26.7.0 ↔ 26.7.3 210 → 210, 변화 없음 가능

판단 기준은 버전 번호가 아니라 행 수의 변화다. 그래서 그 숫자를 재는 법부터 배운다.

가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. 업그레이드 전후로 databasechangelog 행 수가 그대로인 것, 파드가 하나씩 갈리는 동안 정문이 계속 200 인 것, 같은 스키마에서는 롤백이 되는 것, 전환 순간의 000 이 서버 오류가 아닌 것, 스키마가 바뀐 방향에서 ValidationFailedException 으로 기동이 거부되는 것, 그때도 서비스가 살아 있는 것, 실패한 기동이 스키마를 안 건드린 것.

전제와 되돌리기

  • D-1 이 끝나 있고 덤프가 손에 있다. 이 실험의 되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
  • A-8 — 롤링 재시작이 무중단이라는 것이 전제다.
  • 네임스페이스는 keycloak-lab 이다.
  • 터미널 두 개를 열어 둔다.

이 실험은 실제로 버전을 바꾼다. 이미지 태그를 세 번 바꾸고(정방향 → 롤백 → 그리고 선택적으로 실패하는 방향) 마지막 것은 파드를 CrashLoopBackOff 로 만든다. 전 구간 약 20분이다. 그리고 이 실험은 백업 없이 시작하지 않는다 — 스키마가 움직이는 방향으로 가면 태그로는 못 돌아온다.

되돌리기는 전부 태그 한 줄이고, 각 단계 앞에서 먼저 읽는다.

kubectl -n keycloak-lab set image statefulset/keycloak \
  keycloak=quay.io/keycloak/keycloak:26.7.0

이 되돌리기가 유효한 것은 databasechangelog 가 안 바뀌었을 때뿐이다. 바뀌었으면 되돌리기는 「덤프 복구 + 태그 되돌리기」가 된다.

주입 전에 같은 명령으로 먼저 본다

여기서 안 재면 나중에 다시 못 재는 값이 하나 있다 — 업그레이드 전의 databasechangelog 행 수다. 올린 뒤에는 그 값이 지워지기 때문에 「롤백해도 되는가」를 판정할 근거가 사라진다. 넓은 것부터 좁혀 간다.

백업 → 현재 태그 → ★ 마이그레이션 수 → 세션 → 클러스터 뷰 → 가용성 대조군

1. 백업이 먼저다

목적 — 태그로 못 돌아오는 경우의 되돌리기 수단을 손에 쥔다. D-1 의 절차 그대로다.

① 덤프를 뜨고 끝까지 쓰였는지 본다.

kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
  --clean --if-exists > /tmp/pre-upgrade.sql
ls -l /tmp/pre-upgrade.sql
tail -3 /tmp/pre-upgrade.sql

예상 결과 — 실측은 이렇다(observed, 01-pre-upgrade.txtfollowup/01-d2-forward-upgrade.txt).

  백업: 396333 bytes
  백업: 395375 bytes

크기와 taildump complete 를 본다. 두 값은 두 실행의 것이라 서로 다르다.

왜 필요한가 — 이 파일이 없으면 이 실험을 하지 않는다.

문제가 생기면dump complete 가 안 보이면 덤프가 잘린 것이고, D-1 의 덤프 검증 넷으로 돌아간다.

2. 지금 어떤 태그로 돌고 있는가

무엇을 보는가 — StatefulSet 에 적힌 태그.

kubectl -n keycloak-lab get statefulset keycloak \
  -o jsonpath='{.spec.template.spec.containers[0].image}'; echo

어디를 보나 — 실측은 이렇다(observed, 01-pre-upgrade.txt).

quay.io/keycloak/keycloak:26.7.0

이 값이 뜻하는 것latest 로 되어 있으면 무엇에서 무엇으로 가는지 말할 수 없기 때문에 이 실험이 성립하지 않는다. 그리고 StatefulSet 에 적힌 것과 파드가 실제로 돌리고 있는 것은 다를 수 있다 — 적용 중이거나 롤아웃이 멈춰 있으면 그렇다.

kubectl -n keycloak-lab get pods \
  -o "custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \
  | grep keycloak

두 파드의 IMAGE 가 서로 같고 StatefulSet 과도 같은지, READY 가 둘 다 true, RESTARTS0 인지를 본다.

grep keycloak 은 BFF 파드도 잡는다. B층을 먼저 밟아 keycloak-pattern-bff 가 떠 있으면 그 두 줄이 같이 나온다(2026-09-17, observed). 이 편이 보려는 것은 Keycloak 파드 둘이므로 라벨로 거르는 편이 낫다.

kubectl -n keycloak-lab get pods -l app=keycloak \
  -o "custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount"

-o 값을 따옴표로 묶고 한 줄로 둔다. 묶지 않으면 zsh 가 [0] 을 글로브로 읽어 명령이 아예 안 돌고, 중간에서 줄을 접으면 따옴표가 먼저 닫혀 뒤쪽 칸이 명령 인자로 떨어진다.

3. 마이그레이션 수 — 이 실험의 전부다

무엇을 보는가databasechangelog 의 행 수. 처음 한 번은 읽는 형태로 친다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from databasechangelog"

어디를 보나 — 모양은 이렇다(observed).

 count
-------
   210
(1 row)

비교용으로 값만 뽑는 형태도 익혀 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from databasechangelog"

어디를 보나 — 두 실행 모두 같은 값이 나왔다(observed).

  총 마이그레이션 수: 210

이 값을 화면 밖에 적어 둔다. 무엇이 마지막으로 적용됐는지도 한 번 본다. 나중에 「스키마가 언제 움직였나」를 물을 때 여기를 본다. 이 실험대는 개수만 셌고(observed), 아래는 가이드가 미검증으로 표시한 형태다(unknown).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select id, author, orderexecuted, dateexecuted from databasechangelog
      order by orderexecuted desc limit 5"

dateexecuted 의 가장 최근 값이 이 데이터베이스의 스키마가 마지막으로 움직인 시각이다. 210 은 「이 데이터베이스는 여기까지 올라갔다」는 기록이고, 업그레이드 후에 211 이상이 되면 스키마가 움직였으며 그 순간부터 태그만으로는 못 돌아온다.

4. 세션도 센다

무엇을 보는가 — 판올림이 세션을 건드리는지 판정할 재료.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session"

어디를 보나 — 실측은 이렇다(observed).

  현재 세션: 4        (첫 실행)
  세션 전:   3        (후속 실행)

이 값이 뜻하는 것 — 0 이면 관리 콘솔에 한 번 로그인해서 만든다. 0인 채로 업그레이드하면 「세션이 유지되는가」를 판정할 수 없다.

5. 클러스터 뷰와 Infinispan 판

무엇을 보는가 — 멤버 수와 괄호 안의 판 번호.

kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1

어디를 보나 — 실측은 이렇다(observed, followup/01-d2-forward-upgrade.txt 의 업그레이드 후 값).

  cluster: [keycloak-1-11418(v=16.0.14)|47] (2) [keycloak-1-11418(v=16.0.14), keycloak-0-58996(v=16.0.14)]

이 값이 뜻하는 것 — 괄호 안의 판과 멤버 수 (2) 를 본다. Keycloak 태그를 바꾸면 함께 실린 Infinispan 판도 같이 바뀐다 — 후속 실행에서 16.0.12 에서 16.0.14 로 올라갔다. 클러스터 프로토콜 호환성 문제가 있다면 여기서 드러나므로, 업그레이드 후에 이 줄이 멤버 2로 다시 서는지 보는 것이 판정 항목 하나다.

6. 새 태그가 레지스트리에 있는지 본다

무엇을 보는가 — 올라갈 곳이 실제로 있는가. 처음 한 번은 응답을 그대로 본다.

curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"

한 줄짜리 JSON 이 통째로 나온다. 어떤 필드가 있는지 보고 나서 자른다. 이 실험대는 jq 가 없어 이렇게 읽었고(observed), 가이드가 그 줄을 미검증으로 표시했다(unknown).

curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true" \
  | tr ',' '\n' | grep '"name"'

이 값이 뜻하는 것26.7.1 · 26.7.2 · 26.7.3 이 있는지 본다. 처음 D-2 를 할 때 이걸 안 해서 정방향을 시험하지 못했다. 「26.7.0 보다 새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.

7. 가용성 대조군을 먼저 띄운다

목적 — 주입 중에 나오는 비200 을 귀속할 수 있게 평시 오류율을 잡는다.

① 1초 간격으로 150회, 뒤에서 돌린다.

( for i in $(seq 1 150); do
    printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
      https://auth.hyeonworks.com/realms/master)"
    sleep 1
  done > /tmp/d2-avail.txt ) &

그만 재려면 kill %1 이다. & 로 붙인 작업은 그것을 띄운 창의 것이라 kill %1 도 그 창에서만 듣는다. 그래서 이 한 줄은 폴링용 창에서 치고, 아래 ② 부터 관찰 절까지는 다른 창에서 친다. 두 창 다 kc-lab-1 이다. 30초쯤 두고 먼저 평시를 센다.

tr ' ' '\n' < /tmp/d2-avail.txt | grep -c 200
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c

예상 결과uniq -c 의 줄이 하나다.

왜 필요한가 — 줄이 하나면 전부 같은 코드였다는 뜻이고, 두 줄 이상이면 평시에 이미 오류가 있기 때문에 그 상태로 주입하면 주입 중의 오류를 귀속할 수 없다. --max-time 3 을 기억해 둔다 — 관찰 절에서 나오는 000 이 이 값 때문이다.

문제가 생기면 — 파일이 비어 있으면 백그라운드 작업이 죽은 것이고 jobs 로 본다.

주입

1. 태그를 26.7.3 으로 올린다

목적 — 패치 릴리스로 한 칸 올리고 스키마가 움직이는지 본다.

① 시각을 남기고 태그를 바꾼다.

date '+%H:%M:%S 태그 변경'
kubectl -n keycloak-lab set image statefulset/keycloak \
  keycloak=quay.io/keycloak/keycloak:26.7.3

예상 결과 — 실측은 이렇다(observed, followup/01-d2-forward-upgrade.txt).

  시작: 15:22:59
statefulset.apps/keycloak image updated

image updated 한 줄을 본다. 이건 「적용됐다」가 아니라 「접수됐다」다. 실제 교체는 지금부터 일어난다.

② 롤아웃을 기다린다.

kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
date '+%H:%M:%S 롤아웃 완료'

예상 결과 — 실측은 이렇다(observed).

partitioned roll out complete: 2 new pods have been updated...
  완료: 15:24:26

87초 걸렸다.

왜 필요한가2 new pods have been updated 가 나와야 교체가 끝난다.

문제가 생기면rollout status 가 안 끝나고 매달려 있으면 그게 신호다. StatefulSet 은 파드 하나가 Ready 가 되기 전에는 다음 파드를 안 건드리기 때문에, 매달림은 곧 첫 파드가 안 뜬다는 뜻이다. 다른 터미널에서 get pods -w 로 본다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다.

kubectl -n keycloak-lab get pods \
  -o "custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \
  | grep keycloak

실측은 이렇다(observed, followup/01-d2-forward-upgrade.txt).

quay.io/keycloak/keycloak:26.7.3
  keycloak-0   1/1 Running restarts=0
  keycloak-1   1/1 Running restarts=0

RESTARTS0 인 것이 중요하다. 교체는 새 파드를 만드는 것이지 같은 파드를 재시작하는 것이 아니다. RESTARTS 가 올라가 있으면 새 파드가 기동에 실패해서 재시작을 반복하고 있다.

실제로 새 파드인지는 나이로 본다.

kubectl -n keycloak-lab get pods -o wide | grep keycloak

실측은 첫 실행의 롤포워드 직후 값이다(observed).

keycloak-0                     1/1   Running   0     10m
keycloak-1                     1/1   Running   0     28s

AGE 를 본다. 하나씩 갈리므로 나이가 다르다. 둘 다 방금 생긴 나이면 동시에 갈린 것이고, 그건 무중단이 아니다.

버전은 파드가 자기 입으로 말하게 한다.

kubectl -n keycloak-lab logs keycloak-0 | grep -i 'Keycloak 26' | tail -1

실측은 이렇다(observed).

  Keycloak 26.7.3

이미지 태그와 다르면 태그가 재사용됐다 — 같은 태그가 다른 내용을 가리킨다.

관찰

2026-09-17 에 세 번을 이어서 쳤다(observed). 네 값이 이 편의 판정 전부다.

언제 무엇 파드 databasechangelog 정문
15:32:1515:33:40 (85초) 26.7.0 → 26.7.3 둘 다 true · RESTARTS 0 210 → 210 200
15:33:4815:34:55 (67초) 26.7.3 → 26.7.0 (롤백) 둘 다 true · RESTARTS 0 210 200
15:34:55 26.7.0 → 26.0 (역방향) keycloak-1false · RESTARTS 2 210 200

스키마가 한 번도 안 움직였다. orderexecuted 210 의 26.7.0-cluster-event 이 끝까지 마지막 줄이었다. 패치 판올림이라 롤백이 막히지 않은 것이고, 역방향이 막힌 까닭은 판올림이 남긴 스키마가 아니라 체크섬이다.

liquibase.exception.ValidationFailedException: Validation Failed:
     1 changesets check sum

그런데 정문은 세 번 내내 200 이었다. StatefulSet 이 파드를 하나씩 갈아 끼우고 keycloak-0 이 26.7.0 에 남아 있었기 때문이다. 한 파드가 CrashLoopBackOff 인데 밖에서는 아무 일도 없어 보인다 — 이 층이 「조용한 실패」라고 부르는 것이 이 모양이다. 실패한 기동은 스키마도 안 건드렸다(210 그대로).

1. 정방향은 무중단이었는가

tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c

실측은 이렇다(observed, followup/01-d2-forward-upgrade.txt).

  200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
  ...
  200 응답: 87 회
  비200   : 0

줄이 하나뿐이고 그 값이 200 인지 본다. 87150 과 견주지 않는다 — 루프는 150회지만 여기서 세는 것은 그 시점까지 파일에 쌓인 만큼이고, 판정은 「줄이 하나인가」로 선다. 이 숫자는 §3 을 시작하기 전에 적어 둔다. 정방향 업그레이드는 무중단이었다. 87회 요청이 전부 200 이고, 파드가 하나씩 갈리는 동안 남은 파드가 받았다. 다만 「무중단」은 관측 해상도에 달려 있다 — 이건 1초 간격·3초 타임아웃으로 잰 결과이고, 더 촘촘히 보면 더 보일 수 있다. D-4 에서 0.2초 간격으로 재니 다른 것이 보였다.

그림으로도 남아 있다 — 증거의 d2-upgrade-window.png 다. cluster_size 가 2 → 1 → 2 를 두 번 반복하고 파드별 up 시계열이 끝나고 새 시계열이 시작된다. 두 번인 것을 본다. 파드가 둘이므로 교체도 두 번이고 그때마다 클러스터가 잠시 한 명이 된다. 한 번만 보이면 두 파드가 동시에 갈렸다.

2. 스키마가 움직였는가 — 이 실험의 판정

주입 전에 친 것과 똑같은 명령이다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from databasechangelog"

실측은 이렇다(observed, followup/01-d2-forward-upgrade.txt).

  마이그레이션 후: 210  (전: 210)
  세션 후:        3  (전: 3)

전과 후가 같은가를 본다.

결과 되돌리는 법
행 수가 그대로 스키마가 안 움직였다 태그만 되돌리면 된다
행 수가 늘었다 새 changeset 이 적용됐다 덤프 복구 + 태그 되돌리기

26.7.0 에서 26.7.3 은 패치 릴리스이므로 스키마가 그대로였다. 그래서 롤백이 가능하다는 가설이 섰고, 바로 시험한다.

3. 롤백을 시험한다

목적 — 같은 스키마에서 태그만 되돌렸을 때 파드가 뜨는지 본다.

폴링을 다시 띄우고 시작한다. 주입 전 §7 의 루프는 1초 간격 150회라 150초면 끝난다. 정방향 롤아웃 하나가 87초였으니 여기까지 오는 동안 그 루프는 이미 끝나 있고, 다시 띄우지 않으면 §4 가 세는 것은 롤백 구간이 아니라 정방향 구간이다. 그러면 롤백에서 나온 000 한 건이 안 잡히고 화면은 「비200 0」으로 나온다 — 주입이 조용히 안 잡히는 모양이다.

⓪ §1 에서 센 200 응답: 87 회 / 비200: 0 을 먼저 손으로 적어 둔다. 루프가 > 로 파일을 잘라 쓰기 때문에 다시 띄우면 그 값은 화면에서 사라진다. 폴링 창에서 jobs 를 쳐 아직 돌고 있으면 kill %1 로 멈춘 뒤, 같은 창에서 친다.

( for i in $(seq 1 150); do
    printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
      https://auth.hyeonworks.com/realms/master)"
    sleep 1
  done > /tmp/d2-avail.txt ) &

① 시각을 남기고 태그를 내린다.

date '+%H:%M:%S 롤백'
kubectl -n keycloak-lab set image statefulset/keycloak \
  keycloak=quay.io/keycloak/keycloak:26.7.0
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s

예상 결과 — 실측은 이렇다(observed, followup/02-d2-rollback-same-schema.txt).

  시작: 15:25:08
partitioned roll out complete: 2 new pods have been updated...
  완료: 15:25:53

  keycloak-0   1/1 Running restarts=0
  keycloak-1   1/1 Running restarts=0
  Keycloak 26.7.0
  마이그레이션: 210
  세션:        3

왜 필요한가 — 파드가 뜬다. 이게 가설의 답이고, 스키마가 안 바뀌었으면 태그를 되돌리는 것으로 충분하다. 마이그레이션 210 그대로, 세션 3 그대로, 재시작 0 이다.

4. 전환 순간의 000 을 읽는다

tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
grep -n '000' /tmp/d2-avail.txt

판정은 첫 줄이 한다. 둘째 줄의 grep -n 은 「있다/없다」까지만이다 — 루프가 printf '%s ' 로 쓰기 때문에 이 파일은 개행이 없는 한 줄이고, 그래서 1: 하나에 전부 붙어 나온다. 몇 번째 요청이었는지는 이 명령으로 안 나온다.

실측은 이렇다(observed, followup/02-d2-rollback-same-schema.txt). 아래는 줄바꿈을 넣어 읽기 좋게 옮긴 것이고 화면은 한 줄이다.

  200 응답: 43 회 / 비200: 1

  200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
  200 200 200 200 000 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
  200 200 200 200
  비200 값: 000

=== 대조: 정방향 업그레이드 때는 ===
  200: 87 / 비200: 0

000 이다. 500502503 도 아니다. 000 은 curl 이 HTTP 상태 코드를 하나도 못 받았다는 뜻이며, 여기서는 --max-time 3 을 넘겼다. 서버가 오류를 돌려준 것이 아니라 3초 안에 응답이 안 왔다. 파드 전환 순간 요청 하나가 3초를 넘겼고, 정방향에서 0회 역방향에서 1회다. 끊긴 것과 느린 것은 다르고, 그 구별은 코드가 아니라 --max-time 을 알고 있어야 선다.

5. 일부러 실패시킨다 — 선택 단계

목적 — 행 수가 바뀌었을 때가 실제로 어떤 모양인지 본다. 위까지로 판정은 끝났으므로 가이드가 이 단계를 선택으로 둔다. 되돌리기는 태그 한 줄이고 먼저 읽는다.

건너뛸 거면 §6·§7·§8 도 같이 건너뛰고 복구 절로 간다. 그 셋은 전부 여기서 만든 실패한 기동을 읽는다 — §6 은 그 파드의 로그, §7 은 ready 주소가 하나로 줄어든 상태, §8 은 실패한 기동이 스키마를 건드렸는지다. §5 를 안 치면 §6 은 빈 출력이고 §7 의 ready 주소는 둘이며, 그 화면은 「아무 문제 없음」이 아니라 「볼 것이 없음」이다.

① 시각을 남기고 26.0 으로 내린다.

date '+%H:%M:%S 26.0 으로 내린다'
kubectl -n keycloak-lab set image statefulset/keycloak \
  keycloak=quay.io/keycloak/keycloak:26.0

② 이번에는 rollout status 로 기다리지 말고 눈으로 본다.

kubectl -n keycloak-lab get pods -w

예상 결과 — 실측은 이렇다(observed, 02-rollback-attempt.txt).

  시각: 15:02:20
statefulset.apps/keycloak image updated
  +20초  keycloak-0:Running(1/1) keycloak-1:Running(0/1)
  +40초  keycloak-0:Running(1/1) keycloak-1:Running(0/1)
  +60초  keycloak-0:Running(1/1) keycloak-1:Running(0/1)
  +80초  keycloak-0:Running(1/1) keycloak-1:Error(0/1)
  +100초  keycloak-0:Running(1/1) keycloak-1:Running(0/1)
  +120초  keycloak-0:Running(1/1) keycloak-1:Error(0/1)
  +140초  keycloak-0:Running(1/1) keycloak-1:CrashLoopBackOff(0/1)
  +160초  keycloak-0:Running(1/1) keycloak-1:Running(0/1)

두 가지를 본다. keycloak-1Running(0/1)ErrorCrashLoopBackOff 를 오가는 것과, keycloak-0 이 내내 1/1 인 것이다. Running 인데 0/1 인 상태를 「떴다」로 읽지 않는다 — 컨테이너 프로세스는 살아 있지만 readiness 를 통과하지 못했고 곧 죽는다. Ctrl-C 로 빠져나온다.

왜 필요한가 — 이 모양을 봐 두면 롤아웃이 멈춘 것과 느린 것을 구별할 수 있다.

문제가 생기면 — 파드가 이미 죽어 로그가 안 나오면 직전 컨테이너의 로그를 본다.

6. 왜 실패했는지 물어본다

kubectl -n keycloak-lab logs keycloak-1 | grep -iE 'liquibase|changeset|validation'

실측은 이렇다(observed, 03-roll-forward.txt).

2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
     1 changesets check sum
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Validation Failed:
     1 changesets check sum

1 changesets check sum 이고 개수가 1이다. 26.7.0 이 적용한 changeset 하나를 26.0 도 알고 있는데 정의가 다르다. 같은 changeset 이 버전 사이에 수정됐고, Liquibase 는 스키마를 반쯤 아는 상태로 서비스하느니 기동 자체를 거부한다.

kubectl -n keycloak-lab logs keycloak-1 --previous

7. 그런데 서비스는 살아 있다

curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
  -o "custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready"
kubectl -n keycloak-lab get statefulset keycloak

실측은 이렇다(observed, 03-roll-forward.txt).

  https://auth.hyeonworks.com/realms/master  HTTP 200
  ready 주소: [10.42.1.140]                   ← 한 파드만
  statefulset desired/ready/updated: 2 / 1 / 1

ready 주소가 하나, desired/ready/updated2 / 1 / 1 이다. StatefulSet 의 롤링 업데이트가 사고를 절반에서 멈춰 줬다.

   keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
     └─ StatefulSet 은 keycloak-0 을 건드리지 않는다
          └─ keycloak-0 (26.7.0) 이 계속 서비스한다
replica 1 이었다면
유일한 파드가 CrashLoopBackOff 전면 장애
되돌리려면 사람이 개입 그동안 계속 다운

A-8 에서 「무중단은 replica ≥ 2 와 readiness 의 조합」이라고 썼는데, 여기서는 그 조합이 잘못된 배포를 절반에서 멈춰 줬다.

8. 실패한 기동이 스키마를 건드렸는지 센다

같은 명령을 세 번째로 친다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from databasechangelog"

실측은 이렇다(observed). 네 칸짜리 이 줄은 원래 실행이 돌린 집계 스크립트의 출력이고, 위 명령은 그중 migrations 자리의 210 하나만 낸다. 나머지 셋을 한 줄로 내는 형태는 원본 가이드에 없다(unknown).

  realms|clients|migrations|sessions = 2|15|210|4

210 그대로다. Liquibase 가 검증 단계에서 멈췄으므로 스키마를 건드리지 못했고, 그래서 이 사고는 「태그만 되돌리면 되는」 쪽에 남았다. 여기가 두 경우를 가른다.

   ✔ Liquibase 가 검증에서 멈췄다  →  이미지만 되돌리면 끝
   ✘ 이미 적용한 뒤였다            →  DB 복구(D-1)까지 해야 한다

그래서 업그레이드 계획을 어떻게 쓰는가가 이 실험의 산출물이 된다.

   ✘ "문제가 생기면 이미지 태그를 되돌린다"
        └─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다

   ✔ "업그레이드 전에 databasechangelog 를 세어 두고,
       바뀌었으면 백업에서 DB 를 되돌린 뒤 태그를 되돌린다"
단계
1 백업(D-1). 스키마가 움직인 뒤에는 이것만이 되돌리기 수단이다
2 databasechangelog 행 수를 적어 둔다 — 나중에는 못 잰다
3 태그 변경
4 첫 파드만 관찰 — StatefulSet 이 멈춰 준다
5 행 수를 다시 센다. 그대로면 태그만 되돌려도 된다
6 늘었으면 DB 복구 + 태그 되돌리기

복구와 원상복구 확인표

1. 시작할 때의 태그로 되돌린다

목적 — 실험대를 26.7.0 으로 돌려놓는다.

① 시각을 남기고 태그를 되돌린 뒤 롤아웃을 기다린다.

date '+%H:%M:%S 복귀'
kubectl -n keycloak-lab set image statefulset/keycloak \
  keycloak=quay.io/keycloak/keycloak:26.7.0
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s

예상 결과 — 실측은 이렇다(observed, 03-roll-forward.txt).

statefulset.apps/keycloak image updated
partitioned roll out complete: 2 new pods have been updated...
keycloak-0                     1/1   Running   0     10m
keycloak-1                     1/1   Running   0     28s

  realms|clients|migrations|sessions = 2|15|210|4
  외부 진입점 HTTP 200

왜 필요한가kubectl rollout undo statefulset/keycloak 도 있다. 이 실험은 쓰지 않았고(unknown), 쓰더라도 되돌아가는 것은 이미지뿐이다 — 스키마가 움직였다면 undo 도 같은 벽에 부딪힌다.

문제가 생기면 — 폴링이 아직 돌고 있으면 jobs 로 보고 kill %1 로 멈춘다.

2. 아홉 항목을 대조한다

항목 명령 돌아왔을 때
태그 get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].image}' 시작할 때의 태그
파드 get pods -o wide | grep keycloak 둘 다 1/1 Running, RESTARTS 0
클러스터 logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2)
마이그레이션 psql -tAc "select count(*) from databasechangelog" 210 — 시작할 때와 같다
세션 psql -tAc "select count(*) from offline_user_session" 시작할 때와 같다
Service get endpointslice -l kubernetes.io/service-name=keycloak ready 주소 둘
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
폴링 jobs 남아 있으면 kill %1
덤프 ls -l /tmp/pre-upgrade.sql 남겨 둔다

/tmp/d2-avail.txt 는 이 표에 없다. 처분을 정하는 줄이 원본 가이드에 없어서(unknown) 여기에도 없다. 다음 런이 같은 루프를 띄우면 > 가 덮어쓰지만, 그때까지는 이 파일을 손으로 치우는 사람이 없다.

막히면

증상 원인 확인
rollout status 가 안 끝난다 첫 파드가 안 뜬다. StatefulSet 이 기다린다 다른 터미널에서 get pods -w
파드가 Running 인데 0/1 프로세스는 살아 있고 readiness 미통과 logs 를 본다. 「떴다」로 읽지 않는다
로그가 안 나온다 파드가 이미 죽었다 logs keycloak-1 --previous
업그레이드 전 행 수를 안 적었다 그 값은 이제 데이터베이스에 없다 덤프에서 복원한다 — 아래
비200 이 000 이다 서버 오류가 아니라 --max-time 타임아웃 --max-time 값을 늘려 다시 재 본다
kubectl get endpoints 가 경고를 찍는다 v1.33+ 에서 deprecated. 실측으로 이 경고를 봤다 get endpointslice -l kubernetes.io/service-name=...
두 파드가 동시에 갈렸다 podManagementPolicy: Parallel get statefulset keycloak -o yaml | grep podManagement
새 태그를 못 찾는다 레지스트리에서 확인 안 했다 주입 전 절차의 태그 목록

업그레이드 전 행 수를 안 적었을 때는 덤프 안에 그 테이블이 통째로 들어 있다. 가이드가 미검증으로 표시한 줄이다(unknown).

sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' /tmp/pre-upgrade.sql | wc -l

나온 수에서 2를 뺀다 — COPY 줄과 \. 줄이다. 이게 백업 시점의 행 수이고, D-1 의 덤프가 여기서 한 번 더 값을 한다.

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 15:0015:26 KST 에 돈 두 번의 실행에서 나왔다(observed).

  • (observed) 두 실행의 백업 크기 396333 bytes · 395375 bytes, 시작 태그 quay.io/keycloak/keycloak:26.7.0, 마이그레이션 210, 세션 4(첫 실행)와 3(후속 실행), 업그레이드 후의 ISPN000094 줄과 판 16.0.14, 태그 변경 시각 15:22:59 와 롤아웃 완료 15:24:26, 새 파드의 restarts=0 과 나이 10m·28s, 로그의 Keycloak 26.7.3, 정방향 폴링 200 응답: 87 회 / 비200 0, 업그레이드 후 마이그레이션 후: 210 (전: 210), 롤백의 15:25:0815:25:53Keycloak 26.7.0 · 마이그레이션 210 · 세션 3, 롤백 폴링 43 회 / 비200: 1 과 그 1이 000 인 것, 역방향 시각 15:02:20 과 20초 간격 상태 여덟 줄, 1 changesets check sum 두 줄, ready 주소: [10.42.1.140]desired/ready/updated: 2 / 1 / 1, 실패한 기동 뒤에도 210 인 것.
  • (observed) Grafana 화면 d2-upgrade-window.pngcluster_size 가 2 → 1 → 2 를 두 번 반복한 것.
  • (unknown) databasechangelog 의 마지막 다섯 줄을 뽑는 쿼리, 레지스트리 태그 목록을 tr·grep 으로 자르는 줄, 덤프에서 행 수를 되찾는 sed 줄. 가이드가 전부 미검증으로 표시했다. kubectl rollout undo 도 이 실험은 쓰지 않았다.
  • 비밀은 이 편에 나오지 않는다 — 이 실험이 다루는 값은 이미지 태그와 행 수라 옮길 비밀이 없다. 파드 이름·엔드포인트 주소·클러스터 멤버 이름은 식별자라 그대로 적었다.
  • 가장 중요한 미검증이 첫 줄이다. 「행 수가 늘면 태그로 못 돌아온다」는 역방향(26.0)에서 관측한 실패를 근거로 한 추론이며(inferred), 실제로 행 수가 늘어난 뒤 되돌려 본 적은 없다. 26.7.x 사이에는 스키마 변경이 없어 이 실험대에서는 재현하지 못했다(unknown). 메이저 업그레이드를 할 때 이 실험을 다시 한다고 가이드는 적는다.
  • 이 실험이 재지 않은 것 — 마이그레이션 도중에 죽으면 어떻게 되는지, 대규모 마이그레이션에 걸리는 시간. 데이터가 작아 순식간이라 잴 것이 없었다.