An audit against the standard the series set — concepts, procedure, commands, architecture diagram, evidence table, terminal output — found the three new experiments met it while twelve of the original ones had no diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2. Each now has one drawn from what that experiment actually found, not filler: A-0 shows sharing going through PostgreSQL rather than between the caches; A-3 the gap between the 200 and the WAL flush, with both failed injections; A-5 the three silent injection failures; A-6 the two places latency is multiplied; B-0 the repository keyed by principal with no session id; B-2 the primary key that causes the overwrite; D-2 why the rolling update stopped the accident halfway. Also corrected the index's stale claim of 11 experiments without a screenshot — it is 14, and the reason is recorded: those experiments were measured from terminals, the database and logs, and the observability stack does not scrape Redis, the BFF or PostgreSQL, so there is no console to photograph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
후속 — 미측정으로 남겼던 항목을 채운다
브랜치 feature/keycloak-followup-untested-items ·
증거 docs/evidence/followup/ ·
2026-09-04 17:35–18:20 KST
23개 실험을 마치며 세 항목을 "못 했다" 로 남겼다. 이제 셋 다 채웠다. 셋째(D-4 강제 갱신)는 호스트 sudo 가 필요해 사람이 함께 실행했고, 결과적으로 이 세 항목 중 가장 큰 발견이 거기서 나왔다.
구조
이 문서는 세 실험의 후속이므로 각자의 구조도를 참조한다.
| 항목 | 구조도 |
|---|---|
| D-2 정방향 업그레이드 | d2-version-upgrade.svg |
| B-4 ③ role 반영 | b4-header-forgery.svg |
| D-4 강제 갱신 | d4-renewal-gap.svg · d4a-hook-timeline.svg |
0. 결론부터
| 항목 | 결과 |
|---|---|
| D-2 정방향 업그레이드 | 무중단. 87회 요청 전부 200 |
| 그리고 D-2 의 결론이 정밀해졌다 | "롤백 불가" 는 조건부다 — 스키마가 바뀌었을 때만 |
| B-4 ③ role 변경 반영 시점 | 요청 횟수와 무관하다. 세션이 새로 만들어져야 한다 |
| B층에 Grafana 증거가 없는 이유 | 관측 대상에 없다. 안 찍은 것이 아니다 |
| D-4 강제 갱신 | ★ 갱신은 성공했는데 38분 25초 동안 반영되지 않았다 |
| D-4 reload 무중단 | 무중단이다. 8856건 전부 200 · 전송 한가운데였던 요청이 전량 수신 |
1. D-2 정방향 업그레이드 — 26.7.0 → 26.7.3
개념 — 왜 이 방향을 못 했었나
D-2 를 처음 할 때 26.7.0 보다 새 이미지를 몰라 역방향(26.0)만 시험했다. 태그 목록을 조회하니 26.7.1 / 26.7.2 / 26.7.3 이 있었다.
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
절차 — D-1 의 교훈대로 백업이 먼저다
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/pre-2673.sql # 396333 bytes
# 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/avail.txt ) &
kubectl -n keycloak-lab set image statefulset/keycloak keycloak=quay.io/keycloak/keycloak:26.7.3
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
결과 — 무중단
200 200 200 ... (87회)
200 응답: 87 회
비200 : 0 회
소요: 15:22:59 → 15:24:26 (87초)
파드가 하나씩 교체되며 cluster_size 가 2 → 1 → 2 를 두 번 반복한다.
각 파드의 up 시계열이 끝나고 새 시계열이 시작되는 것이 함께 보인다.
마이그레이션: 210 → 210 ← 스키마 변경 없음
세션: 3 → 3 ← 유지
Infinispan: 16.0.12 → 16.0.14
restarts=0
2. ★ 그래서 D-2 의 결론을 정밀화한다
"스키마 변경이 없었다면 롤백이 될 것" 이라는 가설이 생겼고, 시험했다.
kubectl -n keycloak-lab set image statefulset/keycloak keycloak=quay.io/keycloak/keycloak:26.7.0
200 응답: 43 회 / 비200: 1
Keycloak 26.7.0
마이그레이션: 210 · 세션: 3
restarts=0
롤백이 성공했다.
| 버전 차 | databasechangelog |
롤백 |
|---|---|---|
| 26.7.0 → 26.0 | 체크섬 불일치 | 불가 (ValidationFailedException) |
| 26.7.0 ↔ 26.7.3 | 210 → 210, 변화 없음 | 가능 |
처음 D-2 에서 "롤백은 안 된다" 고 쓴 것은 과했다. 정확히는 "스키마가 바뀌었으면 안 된다" 이고, 패치 릴리스처럼 스키마가 그대로면 태그를 되돌리는 것으로 충분하다.
판단 기준은 버전 번호가 아니라
databasechangelog의 행 수가 바뀌었는가다.
# 업그레이드 전후로 이것만 비교하면 롤백 가능 여부를 안다
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
전환 순간의 000 1회
200 ×24 000 200 ×19
서버 오류가 아니라 --max-time 3 타임아웃이다. 파드 전환 순간 요청 하나가
3초를 넘겼다. 정방향에서는 0회였다.
"무중단" 은 관측 해상도에 달려 있다. 1초 간격·3초 타임아웃으로는 44회 중 1회가 걸렸다. 더 촘촘히 보면 더 보일 것이다.
3. B-4 ③ — role 변경은 언제 반영되는가
왜 못 했었나
B-4 를 할 때 oauth2-proxy 가 아직 배포되지 않아 "proxy session" 이 존재하지 않았다. B-7 에서 배포했으므로 이제 측정할 수 있다.
방법
X-Auth-Request-Roles 대신 이미 전달되고 있는 x-forwarded-email 을 썼다.
role 을 헤더로 내보내려면 추가 설정이 필요한데, "IdP 의 클레임 변경이
언제 반영되는가" 라는 질문은 어느 클레임이든 같다.
1 · 3 단계는 브라우저 콘솔에서 실행한다. oauth2-proxy 쿠키가 HttpOnly 라 curl 로는 로그인 상태를 재현할 수 없기 때문이다(그래서 셸이 아니라 JS 다).
// 1. 기준선 — 로그인된 app2 탭의 콘솔에서
for (let i = 0; i < 3; i++) {
const r = await (await fetch('/api/echo')).text();
console.log(new Date().toISOString(), r.match(/x-forwarded-email: (.*)/)[1]);
}
// 3. 반복 요청 — 0.5초 간격으로 12번
for (let i = 0; i < 12; i++) {
const r = await (await fetch('/api/echo')).text();
console.log(new Date().toISOString(), r.match(/x-forwarded-email: (.*)/)[1]);
await new Promise(s => setTimeout(s, 500));
}
2 단계는 셸에서:
# 2. IdP 에서 바꾼다
UID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser \
--fields id --format csv --noquotes | tail -1)
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
# 4. Redis 세션을 지워 재인증을 강제한다 (반영 조건 확인)
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'oauth2-proxy*' \
| xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
결과 — 반영되지 않는다
2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com
...
2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com
→ 12회 · 6.4초 동안 전부 옛 값
이 결과는 처음에 증거 파일로 남기지 않았다. 브라우저 콘솔에서 보고 문서에만 옮겨 적었고, 서브에이전트 감사가 "증거 없이 서 있는 결론" 으로 지적했다. 다시 측정해
03-b4-role-propagation.txt에 타임스탬프와 함께 남겼다.재측정 중 두 시계가 107초 어긋나 있다는 것도 드러났다 — 브라우저(개발 머신)와 test-server 의 시각을 그대로 비교하면 안 되고, 증거 파일에 보정값을 적어두었다.
세션을 지우고 재인증시키자
2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com
2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com
2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com
개념 — 세션은 로그인 시점의 스냅샷이다
로그인 → IdP 가 준 클레임을 세션에 담는다
이후 요청 → 세션에서 읽어 헤더로 내보낸다
└─ IdP 를 다시 부르지 않는다
IdP 에서 변경 → 세션은 모른다
oauth2-proxy 에 --cookie-refresh 가 설정되어 있지 않다.
설정하면 그 주기마다 토큰을 갱신하며 클레임을 다시 받는다.
| 설정 | 반영 시점 |
|---|---|
지금 (--cookie-refresh 없음) |
쿠키 만료(1시간) 또는 재인증까지 안 됨 |
--cookie-refresh=5m |
최대 5분 |
Q4 는 "몇 번째 요청부터 반영되는지" 를 물었는데, 답은 "요청으로는 안 된다" 이다. 요청 횟수가 아니라 세션의 나이가 정한다.
이것이 Q4 의 설계 판단 2번(role·tenant 변경이 즉시 반영돼야 하는가)에 직접 답한다 — 즉시가 필요하면 헤더 방식은 맞지 않는다.
4. B층에 Grafana 증거가 없는 이유
문서 감사에서 B-1·B-3·B-4·B-5 에 스크린샷이 없는 것이 드러나 소급해서 찍으려다 원인을 확인했다.
=== Prometheus 가 실제로 긁는 대상 ===
keycloak 2개
kubelet 2개
node-exporter 2개
prometheus 1개
=== B층 구성 요소의 지표가 있는가 ===
redis_up 시계열 0개
redis_connected_clients 시계열 0개
pg_up 시계열 0개
pg_stat_database_numbackends 시계열 0개
Redis 도 PostgreSQL 도 BFF 도 긁는 대상에 없다.
스크린샷을 안 찍은 것이 아니라 지표가 없다. A층이 Grafana 증거를 남길 수 있었던 것은 Keycloak 이
/metrics를 내놓고 그것을 scrape 대상에 넣어뒀기 때문이다.관측은 "나중에 붙이는 것" 이 아니라 실험 설계에 포함되어야 한다. A-2 에서
kube-state-metrics가 없다는 것을, A-6 에서 응답 시간 히스토그램이 없다는 것을 찾았는데, B층 전체가 빠져 있던 것은 문서 감사를 하고서야 드러났다.
보완하려면
| 대상 | 방법 |
|---|---|
| Redis | redis_exporter 사이드카 또는 Deployment |
| PostgreSQL | postgres_exporter |
| BFF | 이미 actuator 가 있다 — /actuator/prometheus 노출 + scrape 추가 |
| 파드 readiness | kube-state-metrics (A-2 에서 이미 찾은 항목) |
5. D-4 강제 갱신 — 셋째 항목, 그리고 가장 큰 발견
왜 못 했었나
$ sudo -n -l
sudo: a password is required
호스트 sudo 가 비밀번호를 요구해 certbot renew --force-renewal 을 실행할 수
없었다. 게스트(kc-lab-1/2)는 무암호라 A층에서 conntrack·tc 를 자유롭게
썼는데 호스트는 다르다. 이 사실 자체가 B-7 에서 처음 드러났던 것이다 —
nginx 설정을 읽으려던 시도가 계속 빈 결과였고, 그게 sudo 의 조용한 실패였다.
방법 — 주입보다 대조군을 먼저
사람이 실행할 명령은 하나뿐이므로, 그 한 번을 헛되게 쓰지 않는 것이 관건이었다. 주입 전에 평시를 재두지 않으면 주입 중에 나온 값을 해석할 수 없다.
| 대조군 | 결과 |
|---|---|
| 새 연결 (0.2초 × 900회 / 180초) | 900 전부 200, 오류 0 · 중앙 98ms · p95 195ms |
| 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · 42.3초 완주 |
두 번째 장치가 따로 필요했던 이유가 중요하다. 첫 번째 폴링은 TLS 핸드셰이크가 900/900 이다 — 매 요청이 새 연결이라는 뜻이고, 그래서 "새 연결을 받아주는가"만 잰다. 계획서가 물은 것은 "진행 중이던 요청은 어떻게 되는가" 이므로, reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜리 번들을 일부러 느리게 받아 요청 하나를 42초간 살려 두었다.
결과 ① — 갱신은 성공했는데 인증서가 바뀌지 않았다
디스크 cert2.pem 2026-09-04 17:22:13 KST 기록됨
네트워크 08:10:51 ~ 08:58:47 serial=0520BB…B853 (옛 것)
08:58:52 serial=06C7CB…EA1D (새 것)
| 시각 (UTC) | |
|---|---|
| 새 인증서 디스크 기록 | 08:22:13 |
실제 서빙 시작 (nginx -s reload) |
08:58:52 |
| 공백 | 2305초 = 38분 25초 (그 사이 428회 관측 · 시계 보정 후) |
그리고 그 36분은 우연히 짧았을 뿐이다 — reload 를 시킨 것은 사람이지 자동화가 아니다. 원인이 셋 겹쳤고 전부 비어 있었다.
| 상태 | |
|---|---|
certbot-renew.service 의 ExecStartPost |
없음 |
renewal-hooks/{deploy,post,pre}/ |
셋 다 비었음 |
| certbot 의 nginx 플러그인 | 없음 |
이 결함은 88일 동안 보이지 않는다. 타이머는 오늘도 두 번 SUCCESS 로
끝났다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 기회가 없고,
발현하는 날의 증상은 인증서 만료다 — 그날에도 로그는 SUCCESS 다.
결과 ② — reload 는 무중단이다
0.2초 간격으로 잰 이유 — 2절에서 1초 간격으로는 전환을 거의 못 잡았다. nginx reload 는 그보다 짧을 것이므로 해상도를 올렸다. 결과적으로 해상도는 충분했고, 끊김 자체가 없었다.
새 연결 8856건 전부 200. p95 는 평시 205.7ms 대 직후 204.3ms.
08:58:40 845KB 요청 시작 (20k/s)
08:58:52 ← nginx -s reload. 전송 12초째, 한가운데
08:59:21 코드=200 바이트=845361(전량) 연결수=1
연결수가 1이라는 것이 핵심이다 — 끊겨서 다시 붙은 것이 아니라, 옛 워커가 그 요청을 끝까지 책임졌다.
곁다리 — 측정 장치가 거짓말할 뻔했다
in-flight 감시에서 76건이 실패했다. 그대로 적었으면 "갱신 중 대규모 요청 실패"라는 오보가 됐을 것이다. 서버 탓이 아니었다.
| 근거 | 값 |
|---|---|
| 같은 순간 폴링 | 49건 전부 200 |
| 연결수 | 0 — TCP 연결 시도조차 못 했다 |
| 소요 시간 | 50µs — DNS 조회보다도 짧다 |
| 재현 | 0/100 |
대조군이 오보를 막았다. 상세는
08-inflight-artifact.txt.
전체 판정은 D-4 문서 와
13-verdict.txt.
증거 파일
증거 수집 시각: 2026-09-04 15:23 – 16:17 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
01-d2-forward-upgrade.txt |
터미널 원문 |
02-d2-rollback-same-schema.txt |
터미널 원문 |
03-b4-role-propagation.txt |
터미널 원문 |
04-observability-gap.txt |
터미널 원문 |
파일별 상세는 evidence/followup/README.md.
