--- kind: CASE slug: the-upgrade-that-would-not-roll-back title: 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다 topic: operations-that-report-success topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 project: keycloak-session-store status: 게시 전 lastVerifiedOn: 2026-09-04 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2 assets: - key: d2-upgrade-direction file: ../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg evidence: - ../../../final/evidence/raw/d2-version-upgrade__02-rollback-attempt.txt - ../../../final/evidence/raw/d2-version-upgrade__03-roll-forward.txt --- # 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다 되돌리기가 옛 버전 파드의 기동 단계에서 막혔다. 새 버전이 스키마에 남긴 체크섬을 Liquibase 가 거부했기 때문이다. 그동안 외부 서비스는 살아 있었는데, 롤링 업데이트가 첫 파드에서 멈춰 남은 파드가 계속 응답했다. 되돌릴 수 있는지는 databasechangelog 의 행 수가 가른다. ## 관계 - **적용됐는지는 로그 문구가 아니라 상태로 판정한다** 되돌릴 수 있는지도 계획서에 적힌 문장이 아니라 databasechangelog 의 행 수로 갈랐다. - **reload 를 사람이 아니라 deploy 훅이 부르게 한다** 운영 절차의 결과를 사람의 기억이 아니라 실행 가능한 검사에 맡긴다는 물음이 여기에도 같이 걸려 있다. ## 문제 버전을 올리는 절차에는 되돌리기가 따라붙는다. 이 실험대에는 그 롤백 계획이 적혀 있지 않았다. 되돌리기를 실제로 걸어 본 적도 없었다. 이미지 태그를 옛 버전으로 되돌리면 파드가 뜨는지, 되돌리는 동안 밖에서 보는 서비스가 어떻게 되는지는 재지 않은 상태였다. ## 결론 되돌리기는 기동 단계에서 막혔고 외부 서비스는 그동안 살아 있었다. 정방향 26.7.0 에서 26.7.3 : 무중단. 요청 87회 전부 200 되돌리기 : 옛 버전 파드가 기동 단계에서 막혔다 막은 것 : Liquibase 의 체크섬 검증 그때 외부 서비스 : 살아 있었다. StatefulSet 롤링 업데이트가 첫 파드에서 멈췄다 되돌릴 수 있는지 가르는 기준 : databasechangelog 의 행 수가 업그레이드 전후로 같은가 스키마 변경 없는 되돌리기 26.7.3 에서 26.7.0 : 성공. 전환 순간 000 이 1회 「롤백 불가」는 조건부다. 새 버전이 changeset 을 추가하지 않았으면 옛 버전으로 되돌아간다. 추가했으면 옛 버전이 그 행의 체크섬을 거부하고 기동에서 멈춘다. 롤백 계획을 적어 두지 않았더라도 이 사고는 전면화되지 않았다. 롤링 업데이트가 첫 파드에서 멈추면서 나머지 파드를 그대로 두었기 때문이다. ## 검증 환경 Keycloak : 26.7.0 에서 26.7.3 으로 올리고 다시 되돌렸다 워크로드 : StatefulSet 2파드 스키마 관리 : Liquibase. 적용한 changeset 을 databasechangelog 테이블에 기록한다 데이터베이스 : PostgreSQL 가용성 측정 : 업데이트가 도는 동안 밖에서 요청을 반복해 상태 코드를 셌다 측정일 : 2026-09-04. 되돌린 파드가 남긴 로그 줄의 날짜다 ## 재현 조건 1. 업그레이드 전에 데이터베이스를 백업한다. 2. databasechangelog 의 행 수를 세어 둔다. 3. 이미지 태그를 새 패치 버전으로 바꿔 StatefulSet 롤링 업데이트를 건다. 4. 업데이트가 도는 동안 밖에서 요청을 반복해 200 과 비200 을 센다. 5. databasechangelog 의 행 수를 다시 세어 업그레이드 전과 같은지 본다. 6. 이미지 태그를 옛 버전으로 되돌리고, 첫 파드가 기동하는지와 그 파드의 로그에 무엇이 찍히는지 본다. 7. 되돌리는 동안에도 밖에서 요청을 반복해 남은 파드가 응답하는지 확인한다. ## 본문 ## 올리는 방향은 아무것도 끊지 않았다 Keycloak 두 파드를 StatefulSet 으로 띄워 두고 이미지 태그를 26.7.0 에서 26.7.3 으로 바꿨다. 업데이트가 도는 동안 밖에서 요청을 계속 보냈고 87회가 전부 200 이었다. 이 방향에서는 파드가 새 이미지로 다시 뜨는 것 말고 걸리는 단계가 없었다. 되돌리기를 시도한 것은 이 상태에서다. ## 되돌리기는 기동 단계에서 막혔다 이미지 태그를 옛 버전으로 되돌리자 새로 뜬 파드가 기동하지 못했다. 로그에 찍힌 것은 애플리케이션 오류가 아니라 스키마 검증이었다. ```text label="옛 버전 파드가 기동하면서 남긴 것" liquibase ValidationFailedException: 1 changesets check sum ``` Liquibase 는 스키마 변경을 changeset 단위로 적용하고 적용한 것을 `databasechangelog` 테이블에 한 행씩 기록하는데, 각 행에는 그 changeset 내용의 체크섬이 함께 들어간다. 기동할 때 자기가 들고 있는 changeset 파일의 체크섬과 테이블에 적힌 체크섬을 대조하고, 다르면 거기서 멈춘다. 새 버전이 남긴 행을 옛 버전이 자기 파일과 대조했더니 맞지 않았고, 그래서 데이터베이스에 손을 대기 전에 기동을 포기했다. 막은 것은 애플리케이션 코드도 이미지도 아니라 데이터베이스에 이미 적힌 한 행이다. 되돌리기를 걸고 20초 간격으로 여덟 번 파드 상태를 읽었는데 그중 다섯 번은 `Running` 이었고 `Error` 가 두 번, `CrashLoopBackOff` 가 한 번이었다. 여덟 번 모두 준비된 컨테이너는 0/1 이었다. 상태 칸만 보면 `Running` 인 때도 있었으므로, 기동하지 못했다는 것은 그 0/1 과 파드 로그를 보고 판단했다. ## 되돌리기가 막힌 동안에도 서비스는 살아 있었다 기동하지 못한 파드는 첫 번째 파드였다. StatefulSet 의 롤링 업데이트는 파드를 하나씩 교체하고 앞의 파드가 준비 상태가 되어야 다음으로 넘어가므로, 첫 파드가 기동하지 못한 시점에 업데이트가 거기서 멈추고 나머지 파드는 옛 이미지 그대로 남았다. 밖에서는 남은 파드가 계속 응답해 외부 진입점이 `HTTP 200` 이었고, Service 의 준비된 엔드포인트에는 주소가 하나 남아 있었다. ![앞으로 가는 경로는 무중단이고 뒤로 가는 경로는 Liquibase 검증에서 막히는 구성. 롤링 업데이트가 그 사고를 절반에서 멈춘다.](../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg) 그림에서 `옛 버전 롤백` 은 `Liquibase 검증` 으로만 이어지고, 거기서 두 갈래가 나간다. 한쪽은 `databasechangelog 행 수` 이고 판단 기준이라는 이름이 붙어 있으며, 다른 쪽은 `StatefulSet 롤링 업데이트` 이고 중단 지점이라는 이름이 붙어 있다. 그 중단 지점에서 `외부 서비스` 로 가는 화살표에는 잔여 파드 응답이 붙는다. 되돌리기를 막은 단계와 사고를 절반에서 멈춘 단계가 같은 검증에서 갈라져 나온다. 롤백 계획을 적어 두지 않은 상태에서 되돌리기를 시도했는데도 전면 중단으로 가지 않았다. 그것은 계획이 좋아서가 아니라 StatefulSet 의 롤링 업데이트가 실패한 파드에서 교체를 멈췄기 때문이다. ## 롤백 불가는 조건부였다 이 실험을 처음 적을 때는 「롤백은 안 된다」고 단정했고, 후속 실험에서 정정했다. 실제로 막은 것은 버전 번호가 아니라 새 버전이 스키마에 행을 더했다는 사실이라, 새 버전이 changeset 을 하나도 더하지 않았으면 옛 버전은 대조에서 걸릴 것이 없다. 그래서 판단 기준이 한 줄로 정해진다. ```sql label="되돌릴 수 있는지 가르는 한 줄" select count(*) from databasechangelog ``` 업그레이드 전후로 이 수가 같으면 되돌아가고, 늘었으면 옛 버전이 기동에서 멈춘다. 이 기준으로 다시 걸어 본 26.7.3 에서 26.7.0 으로의 되돌리기는 성공했고, 전환 순간에 `000` 이 한 번 나왔다. 그 `000` 은 서버가 오류를 돌려준 것이 아니라 `--max-time 3` 을 넘긴 것이다. 끊긴 것과 느린 것은 다르고, 그 구별은 상태 코드가 아니라 타임아웃 값을 알고 있어야 선다. 이 수는 실제로 세었다. 되돌리기가 막힌 뒤 다시 앞으로 올려 놓고 확인했더니 마이그레이션 210 과 세션 4 가 업그레이드 전에 세어 둔 값 그대로였다. 업그레이드를 걸기 전에 이 수를 세어 두면 되돌릴 수 있는지를 사고가 나기 전에 안다. 계획서에 「롤백 가능」이라고 적어 두는 것과 이 쿼리를 전후로 돌려 보는 것은 다른 일이다. 다만 이 기준의 두 갈래를 같은 만큼 확인하지는 않았다. 「같으면 되돌아간다」는 26.7.3 에서 26.7.0 으로 실제로 걸어 본 결과이고, 「늘었으면 멈춘다」는 옛 버전에서 본 기동 실패를 근거로 한 추론이다. 26.7.x 사이에는 스키마 변경이 없어서 행이 늘어난 뒤 태그를 되돌리는 경우는 이 실험대에서 만들지 못했다. ## 확인하지 않은 것 스키마가 크게 바뀌는 메이저 업그레이드에서는 재지 않았다. 두 패치 버전 사이만 확인했다. 되돌리기가 막혔을 때 남은 파드가 얼마나 오래 버티는지도 재지 않았다. 관측한 것은 첫 파드가 기동하지 못하는 동안 밖에서 응답이 계속 왔다는 것까지이고, 그 상태를 길게 두었을 때 무엇이 먼저 깨지는지는 걸어 보지 않았다.