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>
370 lines
14 KiB
Markdown
370 lines
14 KiB
Markdown
# 후속 — 미측정으로 남겼던 항목을 채운다
|
||
|
||
브랜치 `feature/keycloak-followup-untested-items` ·
|
||
증거 [`docs/evidence/followup/`](evidence/followup/) ·
|
||
2026-09-04 17:35–18: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` 가 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).
|