Files
keycloak-pattern/docs/experiment-followup-untested-items.md
T
DongHyeonkaandClaude Opus 5 905392947d docs: close the three untested items — all three are now measured
D-2 forward upgrade and B-4 role propagation were filled earlier. D-4 forced
renewal was the third, and it needed a person at the keyboard because the
host asks for a sudo password.

It turned out to hold the largest finding of the three: the renewal
succeeded and went unserved for 36 minutes 39 seconds, because nothing in
the setup reloads nginx — no ExecStartPost, three empty hook directories,
no nginx plugin. And the half of the plan's question that was still an
expectation is now a measurement: the reload is graceful, 8856 connections
with zero failures and an in-flight request surviving intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:10:04 +09:00

370 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 후속 — 미측정으로 남겼던 항목을 채운다
브랜치 `feature/keycloak-followup-untested-items` ·
증거 [`docs/evidence/followup/`](evidence/followup/) ·
2026-09-04 17:3518:20 KST
23개 실험을 마치며 **세 항목을 "못 했다" 로 남겼다. 이제 셋 다 채웠다.**
셋째(D-4 강제 갱신)는 호스트 sudo 가 필요해 사람이 함께 실행했고,
결과적으로 **이 세 항목 중 가장 큰 발견**이 거기서 나왔다.
---
## 0. 결론부터
| 항목 | 결과 |
|---|---|
| **D-2 정방향 업그레이드** | **무중단.** 87회 요청 전부 200 |
| **그리고 D-2 의 결론이 정밀해졌다** | **"롤백 불가" 는 조건부다** — 스키마가 바뀌었을 때만 |
| **B-4 ③ role 변경 반영 시점** | **요청 횟수와 무관하다.** 세션이 새로 만들어져야 한다 |
| **B층에 Grafana 증거가 없는 이유** | **관측 대상에 없다.** 안 찍은 것이 아니다 |
| **D-4 강제 갱신** | **★ 갱신은 성공했는데 36분 39초 동안 반영되지 않았다** |
| **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** 이 있었다.
```bash
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
```
### 절차 — D-1 의 교훈대로 백업이 먼저다
```bash
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 와 up](evidence/d2-version-upgrade/d2-upgrade-window.png)
**파드가 하나씩 교체되며 `cluster_size` 가 2 → 1 → 2 를 두 번 반복한다.**
각 파드의 `up` 시계열이 끝나고 새 시계열이 시작되는 것이 함께 보인다.
```
마이그레이션: 210 → 210 ← 스키마 변경 없음
세션: 3 → 3 ← 유지
Infinispan: 16.0.12 → 16.0.14
restarts=0
```
---
## 2. ★ 그래서 D-2 의 결론을 정밀화한다
**"스키마 변경이 없었다면 롤백이 될 것" 이라는 가설이 생겼고, 시험했다.**
```bash
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` 의 행 수가 바뀌었는가**다.
```bash
# 업그레이드 전후로 이것만 비교하면 롤백 가능 여부를 안다
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 다).
```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 단계는 셸에서:
```bash
# 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`](evidence/followup/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 |
| **공백** | **2199초 = 36분 39초** (그 사이 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`](evidence/d4-certificate-renewal/08-inflight-artifact.txt).
전체 판정은 [D-4 문서](experiment-d4-certificate-renewal.md) 와
[`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt).
---
## 증거 파일
**증거 수집 시각: 2026-09-04 15:23 16:17 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
| [`01-d2-forward-upgrade.txt`](evidence/followup/01-d2-forward-upgrade.txt) | 터미널 원문 |
| [`02-d2-rollback-same-schema.txt`](evidence/followup/02-d2-rollback-same-schema.txt) | 터미널 원문 |
| [`03-b4-role-propagation.txt`](evidence/followup/03-b4-role-propagation.txt) | 터미널 원문 |
| [`04-observability-gap.txt`](evidence/followup/04-observability-gap.txt) | 터미널 원문 |
파일별 상세는 [`evidence/followup/README.md`](evidence/followup/README.md).