Files
document-haness/docs/keycloak-session-store/final/document.md
T
DongHyeonkaandClaude Opus 5 da774648dc fix(b0): 주입 1~2 절을 실제로 밟고 ⑤ 의 점검이 언제 성립하는지 적는다
B-0 의 주입 1 절(파일 넷 편집)과 2 절의 이미지 빌드를 2026-09-17 에 밟았다.
두 노드에 이미지를 밀어 넣는 단계부터는 안 밟았고 그대로 unknown 으로 적었다.

- ⑤ 의 `git diff --stat` 점검은 작업 트리가 깨끗할 때만 성립한다. 그 줄은
  덜 지운 것을 잡으려는 것인데, keycloak-pattern 에 이 실험과 무관한 변경이
  39개(미추적 3개 더) 있으면 아무것도 못 가린다. 따로 떼어낸 작업 트리에서
  고치면 점검이 다시 살아난다 — git worktree 한 줄을 적었다
- 넷을 다 뺀 뒤 무엇이 남는지를 숫자로 확인해 적었다. pom.xml 은 XML 로
  유효하고, SecurityConfig 에는 bffSecurity 하나만 남고, application.yml 의
  spring 아래는 둘뿐이며, 매니페스트의 Redis Deployment·Service·PVC 는 남는다
- 빌드는 한 번에 통과했다. 이 편이 적어 둔 processDuplicateKeys 실패는
  재현되지 않았다 — Tests run: 4, Failures: 0 · exit=0
- 앞서 「남은 걸림돌은 인증서 하나」라고 적힌 것을 해결됨으로 고쳤다.
  인증서는 엣지로 옮기고 강제 갱신까지 끝났다

관문: check_body PASS · check_prose error 0 · check_evidence 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

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

1.1 MiB
Raw Blame History

세션은 어디에 있는가 — Keycloak 다중 노드 실험 26건의 기록

Keycloak 을 두 대로 늘리면 세션은 어떻게 되고, 저장소를 Redis 로 옮기면 무엇이 달라지며, 그 저장소가 죽으면 어떻게 복구하는가. 이 문서는 그 물음들에 설계가 아니라 측정으로 답한 기록이다.

실험 26건과 증거 파일 147개가 들어 있고, 그 안에는 아홉 번의 조용한 주입 실패틀린 예측 다섯 개도 지우지 않고 남겨 두었다. 왜 지우지 않았는지는 마지막 절에서 따로 적는다.


코드보다 먼저 드러난 문제

답할 수 없던 질문 네 개

앞선 작업(인증 패턴 네 가지)은 네 가지 인증 패턴의 경계를 설계한 뒤 끝에 열린 질문 네 개를 남겼다. 넷 다 설계로는 답이 나오지 않고 실제로 돌려봐야 아는 것들이었다.

질문
Q1 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
Q2 Refresh Token Rotation 과 다중 Replica 경쟁을 어떻게 처리할 것인가
Q3 BFF 의 Session 과 OAuth2AuthorizedClient 를 어디에 저장할 것인가
Q4 Forward-Auth 구조에서 Application Authorization 을 어디까지 Edge 에 둘 것인가

네 질문은 같은 전제 위에 서 있다. 인스턴스가 둘 이상이고, 요청이 어느 쪽으로 갈지 모른다는 것인데, 인스턴스가 하나면 이 질문들은 애초에 생기지 않으므로 전제를 갖춘 환경이 먼저 있어야 했다. 그래서 인스턴스를 둘로 만들고 그 사이를 끊어 보고 저장소를 죽여 보는 실험대를 세웠다.

네 질문은 밖에 게시된 글이고 게시일이 있다 (open-questions-coverage.md).

질문 게시
Q1 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가 2026.08.29
Q2 Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가 2026.08.26
Q3 BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가 2026.08.30
Q4 Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가 2026.08.31

실험 순서는 이 질문들을 대조하다가 한 번 뒤집혔다. 로드맵은 처음에 refresh 경쟁(Q2)을 저장소 결정(Q3)보다 에 두었는데, Q2 가 스스로 이렇게 적고 있었다.

이 경쟁은 저장소를 공유한 뒤에야 재현되기 때문에 저장소 결정을 하고 나서 해당 문제를 이어서 풀어보자.

Q2 를 먼저 시도하면 재현 자체가 불가능하다 — 저장소가 process-local 이면 두 replica 가 같은 refresh token 항목을 보지 않기 때문이다. 그래서 순서를 Q3 → Q2 로 고쳤고, 그것이 이 문서의 B-1·B-2 가 B-3 보다 앞에 오는 이유다.

Q4 는 로드맵에 아예 없었다. 축을 A층(Keycloak)·B층(앱 세션) 중심으로 잡으면서 엣지 인가 범위 질문을 빠뜨렸고, 대조하고 나서야 B-4 로 더해졌다. 제외가 아니라 누락이었다 — 네 질문 중 하나에 답할 계획이 없는 채로 실험을 시작할 뻔했다.

저장소 결정도 같은 대조에서 넓어졌다. 질문은 「Redis 로 간다」가 아니라 「Redis 와 JDBC 중 무엇이 이 접근 패턴에 맞는가」 를 물었고, 이 실험대에는 PostgreSQL 이 이미 있어 JDBC 후보를 같은 조건에서 견줄 수 있었다. B-1(Redis)과 B-2(JDBC)가 나뉜 것이 그 결과다.

그런데 첫 실험에서 전제가 무너졌다

실험대를 세우고 가장 먼저 확인한 것은 「한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가」였고, 답은 그렇다였다. 그런데 그렇게 되는 이유가 예상과 달랐다. 로그만 보면 클러스터가 형성됐고 그것이 원인처럼 보인다.

ISPN000094: Received new cluster view for channel ISPN:
  [keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]

JGROUPS_PING 테이블에도 둘 다 등록되어 있으니 「Infinispan 이 세션을 복제한다」고 읽기 쉬운데, 확인해 보니 그렇지 않았다.

노드 A 로 로그인하고 노드 B 로 refresh 했을 때 노드 B 가 PostgreSQL 로 날린 SQL 을 문장 로깅으로 직접 잡았다. 세션 엔트리는 노드 사이를 건너가지 않으며 각 노드는 자기가 처리한 로그인만 캐시하므로, 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다.

세션 공유가 실제로 지나는 경로

두 노드는 JGROUPS_PING 으로 서로를 발견해 클러스터 뷰를 만들지만 sessions 캐시 사이에는 경로가 없어서, 같은 답이 나오는 것은 둘 다 OFFLINE_USER_SESSION 을 읽기 때문이다.

클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다.

이 하나가 이후 실험 전체의 해석을 바꿔 놓았다. 「클러스터를 끊으면 세션 공유가 깨질 것」이라는 예측이 A-1 에서 빗나간 이유도 여기에 있었다.

그리고 이 결론에는 버전 조건이 붙어 있었다

Keycloak 26 은 persistent-user-sessions 가 기본값이라 세션을 DB 에 쓰는데, 24 이전은 그렇지 않아서 메모리에 두고 Infinispan 으로 복제했다.

같은 실험을 --features-disabled=persistent-user-sessions 로 다시 돌리자 세 결과가 정반대로 뒤집혔다.

실험 persistent (26 기본) volatile (24 이전)
A-1 · 7800 차단 후 교차 노드 refresh 200 — 안 깨진다 400 Session not active — 깨진다
A-8 · 롤링 재시작 후 refresh 200 — 세션 생존 400 Session not active — 전원 로그아웃
A-2 · DB 정지 중 새 로그인 500 200 — 된다

그래서 「Keycloak 은 이렇다」고 쓸 수 있는 문장이 거의 없다. 버전과 설정을 같이 적지 않으면 절반은 틀린 말이 되기 때문이다.

설정에 따라 갈리는 세션의 거처

설정 하나가 세션의 거처를 바꾸고, 그 거처가 장애 결과를 결정한다.


문제를 어렵게 만든 제약

실험대

베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위에 VM 두 대를 올렸다.

역할
kc-lab-1 k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 k3s agent · keycloak-0 · PostgreSQL · Redis
호스트 nginx Let's Encrypt TLS 종단 → traefik 으로 프록시

실험대의 구성

저장소가 kc-lab-2 한 곳에 몰려 있어서 A-4 의 두 결과가 이 배치에서 갈린다.

이름 셋(auth · app1 · app2)이 한 인증서의 SAN 에 들어 있고 와일드카드가 아닌데, 이 제약은 나중에 실제 비용을 청구했다. oauth2-proxy 실험(B-7)을 할 때 네 번째 이름이 없어 Grafana 의 app2 를 빌려야 했다.

oauth2-proxy 는 인증서가 서기 전에는 못 뜬다(2026-09-17, observed). 기동할 때 OIDC 디스커버리를 먼저 하는데 그 주소가 https://auth.hyeonworks.com/... 이라, 인증서 단계를 안 끝낸 실험대에서는 CrashLoopBackOff 로 끝난다.

[provider.go:55] Performing OIDC Discovery...
[main.go:59] ERROR: Failed to initialise OAuth2 Proxy: ... error while discovery OIDC configuration:
  Get "https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration":
  dial tcp 100.83.212.4:443: connect: connection refused

oauth2-proxy-secrets 가 있고 이미지도 받아지는데 기동 자체가 안 된다 — B-7·B-7a·C-1 과 B-4 의 5절이 배포가 아니라 TLS 를 전제로 선다. 같은 상태에서 Redis 쪽 명령은 정상이었다 — redis-cli dbsize0, --scan --pattern '_oauth2_proxy-*' 가 빈 출력이다(observed).

그런데 위 오류의 주소가 원인을 하나 가리고 있었다(2026-09-17, observed). 100.83.212.4 는 랩 호스트의 tailnet 주소이고, 가상화 쪽 03 이 nginx 를 엣지 게스트로 옮긴 뒤로 그 주소에는 443 도 80 도 없다. 인증서를 받아 엣지에 얹었더라도 파드는 여전히 이 주소를 두드렸을 것이다. 클러스터 DNS 가 이 이름을 엣지로 보내게 고친 뒤 같은 배포를 다시 하니 주소만 바뀌고 증상은 같았다.

[2026/09/17 07:37:18] [provider.go:55] Performing OIDC Discovery...
[2026/09/17 07:37:18] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: ... dial tcp 192.168.122.10:443: connect: connection refused

이제 두드리는 곳이 엣지이고, 거기 443 이 안 열린 것은 인증서가 없어서다. 같은 connection refused 인데 앞의 것은 원인이 둘이었고 뒤의 것은 하나다.

그 12GB 를 어떻게 나눠 썼나

계획서는 게스트를 kc-lab-1 5120MB · kc-lab-2 4096MB 로 그렸지만 (experiment-plan.md), 실제로 준 것은 kc-lab-1 3584M · kc-lab-2 2560M 이다(observed). 계획값이 아니라 이 값이 실험 내내 유지된 배치다.

호스트만 보면 절망적으로 보인다. 2026-09-03, Keycloak 배포 전의 실측이다 (session-lab-operations.md).

lab host      총 7628MB · 사용 7189MB · 여유 439MB
  ├ qemu #1   RSS 3765MB   kc-lab-1 (할당 3584MB)  → 상한 도달
  └ qemu #2   RSS 2633MB   kc-lab-2 (할당 2560MB)  → 상한 도달

RSS 가 할당량보다 큰 이유는 QEMU 자체의 에뮬레이션 오버헤드(장치 모델, 버퍼)가 더해지기 때문이다. 게스트는 호스트 입장에서 qemu-system-x86_64 프로세스 하나이므로 VM 의 메모리 사용량이 곧 그 프로세스의 RES 다. 같은 문서의 htop 절이 다른 시점에 잰 값은 이렇다(observed).

  pid=4677   RSS=3765MB   qemu-system-x86     ← kc-lab-1 (할당 3584M)
  pid=4770   RSS=2670MB   qemu-system-x86     ← kc-lab-2 (할당 2560M)

kc-lab-2 의 RSS 는 두 번 재서 2633MB 와 2670MB 로 나왔다. 둘 다 원문에 있는 값이고 어느 쪽도 오타가 아니다 — 게스트가 터치한 페이지만큼만 RSS 로 잡히므로 시점에 따라 움직인다.

그런데 게스트 안을 보면 여유가 있다(observed).

kc-lab-1   총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
kc-lab-2   총 2480MB · used  580 · buff/cache 1714 · available 1899MB
                                                     ─────────────────
                                            게스트 여유 합계 약 3.8GB

왜 이런가 — QEMU 의 RSS 는 게스트가 터치한 페이지만큼이다. 게스트가 메모리를 페이지 캐시로 다 채우면 QEMU RSS 도 할당 상한까지 올라간다. 지금이 그 상태다. 그래서 앞으로 워크로드를 올려도 호스트 압박은 늘지 않는다 — 게스트 안의 페이지 캐시가 밀려날 뿐이고, QEMU RSS 는 이미 천장이다.

세 값이 다른 것을 보는 것이 이 실험대의 메모리 감각이고, 세 값을 각각 뽑는 명령은 이렇다.

ps -eo rss,args --sort=-rss | grep '[q]emu-system'   # 호스트에서 본 VM
ssh kc-lab-1 free -m                                  # 게스트 안 실제
kubectl top nodes                                     # working set

그 위에서 세운 배포 예산은 이렇다.

워크로드 예상 배치
Keycloak × 2 각 700Mi 노드당 1개
PostgreSQL 300Mi kc-lab-1
Redis 100Mi kc-lab-2
BFF × 2 각 400Mi 노드당 1개
합계 약 2600Mi

게스트 여유 3.8GB 중 2.6GB 라 들어가지만, 여기에 Prometheus/Grafana 를 얹을 여유는 없다고 적혀 있다. 그래서 관측성이 메모리 증설 뒤로 밀렸다. B층을 배포할 때 다시 센 예산도 같은 결론이다 — 「호스트 12GB 중 여유 약 4GB. Redis(~64Mi) + BFF 2개(~512Mi) + resource-server(~256Mi)는 들어가지만, A층을 마친 뒤 배포하는 편이 안전하다」(experiment-plan.md).

모든 워크로드에 resources.limits 를 반드시 건다. 안 걸면 한 파드가 게스트 메모리를 다 먹고 다른 파드까지 OOMKilled 된다. Keycloak 은 기본값이 넉넉해 그냥 두면 1GB 를 넘기므로 힙을 명시적으로 제한했다.

env:
  - name: JAVA_OPTS_KC_HEAP
    value: "-Xms256m -Xmx512m"
resources:
  limits:
    memory: 768Mi

Java 힙 상한은 컨테이너 limit 의 70%(-XX:MaxRAMPercentage=70)이므로 512Mi limit 이면 약 358Mi 다.

swap 은 쓰지 않는다. 호스트에는 8GB swap 이 있지만 게스트에는 0MB 이고, 그것이 맞다.

이유
k3s/kubelet 기본적으로 swap 을 거부한다
성능 호스트 swap 으로 QEMU 페이지가 밀리면 급락한다
측정 오염 이 실험대는 타이밍(refresh 경쟁, Infinispan 복제 지연)을 잰다. swap 이 끼면 측정이 통째로 무의미해진다

마지막 줄이 이 실험대에서 swap 을 끈 진짜 이유다. A-6 이 재는 것이 바로 그 타이밍이다.

증설을 검토한 기록도 남아 있다. 슬롯 상태는 dmidecode 로 본다.

sudo pacman -S dmidecode
sudo dmidecode -t memory | grep -E "Maximum Capacity|Number Of Devices|Size:|Locator:|Type:|Speed:"
슬롯 상태 조치
2슬롯 중 1개만 사용 동일 규격 8GB 추가 → 16GB
온보드 8GB + 슬롯 1개 16GB 추가 → 24GB
2슬롯 모두 사용 8GB × 2 를 16GB × 2 로 교체

i5-1135G7(Tiger Lake)은 DDR4-3200 SO-DIMM 을 쓰며 최대 용량은 보드마다 다르다. 실제로 간 길은 8GB → 12GB 였고, 그 증설이 관측성을 올릴 수 있게 만들었다(observed).

게스트와 호스트의 sudo 가 다르다

kc-lab-1/2 는 무암호 sudo 라 conntrack·tc·iptables 를 자유롭게 썼지만 호스트는 비밀번호를 요구한다. 이 차이를 모르고 한동안 nginx 설정을 읽으려 했는데 계속 빈 결과가 나왔고, sudo 가 조용히 실패하고 있었다. 하마터면 「빈 로그」를 「아무 일도 없음」으로 읽을 뻔했다.

호스트에서 해야 하는 일(인증서 강제 갱신, nginx reload)은 결국 사람이 직접 쳐야 했으므로, D-4 에서는 「명령 한 줄을 헛되이 쓰지 않는 것」이 설계의 일부가 됐다.

주입이 먹지 않는다 — 아홉 번, 전부 조용히

이 실험대에서 가장 많은 시간을 쓴 곳인데, 주입이 실패하면 「아무 일도 없었다」로 보이고 그 모습은 「영향이 없다」와 구별되지 않기 때문이다.

# 무엇을 했나 왜 안 먹었나
1 NetworkPolicy 로 7800 차단 conntrack — ESTABLISHED 연결은 규칙 평가를 건너뛴다. cluster_size 가 25분간 2 로 남았다
2 kubectl delete --grace-period=0 --force 크래시가 아니다. 런타임이 SIGTERM 을 보내 PostgreSQL 이 정상 플러시했다
3 kill -9 1 PID 1 은 자기 네임스페이스의 SIGKILL 을 무시한다
4 iptables -I FORWARD 1 kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0)
5 raw 규칙을 한쪽 노드에 방향이 뒤집혀 있었다. JGroups 의 client/server 역할은 재시작마다 바뀐다
6 tc ... dev eth0 Debian 은 enp1s0 이고, flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다
7 spring.sql.init 로 스키마 생성 기본 DDL 이 blob 인데 PostgreSQL 은 bytea 다. continue-on-error: true 가 삼켰다
8 호스트에서 sudo 비밀번호를 요구한다. 빈 출력이 곧 실패였다
9 kubectl run --rm -i 로 동시 20건 일회성 파드의 stdout 이 유실된다. 20줄 중 일부만 도착하거나 아예 끊긴다

여기서 배운 규칙이 하나 있고, 이후 모든 실험에 적용했다.

주입했다고 걸린 것이 아니다. 주입 뒤에는 「대상이 실제로 그 상태인가」를 따로 확인한다. cluster_size, 워커 PID, conntrack 표, 패킷 카운터 — 결과가 아니라 상태를 본다.

주입이 걸렸는지 따로 확인한다

아홉 번의 실패가 모두 같은 단계에서 생겼다 — 주입과 관측 사이가 비어 있었다.


검토한 선택지와 막힌 지점

관측을 어디에 둘 것인가

처음에는 밖에서만 쟀다. curl 로 외부 진입점을 찍고 상태 코드를 셌는데 A-1 에서 그 방식이 무너졌다. 7800 을 끊었는데도 외부 응답이 전부 200 이었기 때문이다. 장애가 없어서가 아니라 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기 때문이었고, 밖에서만 보면 이 실험은 「아무 일도 없음」으로 끝난다. 그래서 관측 지점을 셋으로 늘렸다.

지점 무엇을 보는가
외부 curl 사용자가 겪는 것
Prometheus 지표 vendor_cluster_size · vendor_jgroups_* · agroal_*
PostgreSQL 직접 조회 실제로 무엇이 저장됐는가

up 지표를 신뢰할 수 없다는 것도 여기서 나왔다. A-2 에서 503 이 나는 동안에도 up 은 1 이었는데, 프로세스가 살아 있고 /metrics 가 응답하기만 하면 1 이 되므로 「살아 있지만 쓸모없는」 상태를 못 보기 때문이다.

관측을 어디에 둘 것인가

세 지점이 서로 다른 층을 보기 때문에, 하나만 두면 그 층의 사각이 그대로 사각으로 남는다.

관측 스택은 직접 썼다 — Helm 차트를 쓰지 않은 이유

관측을 어디에 둘지와 별개로 무엇으로 세울지도 골라야 했다. 근거는 lab-observability.md 에 있다.

먼저 순서가 정해져 있었다. 가이드는 관측성을 장애 주입 실험보다 먼저 세운다고 적는다 — 나중에 세우면 이미 지나간 장애의 지표를 볼 수 없고, 「클러스터가 1분쯤 뒤에 복구됐다」는 측정이 아니라 인상이기 때문이다. 그리고 그것이 가능해진 것은 호스트 메모리를 8GB → 12GB 로 증설한 뒤였다(observed).

kube-prometheus-stack Helm 차트 하나로 끝내는 방법이 있었지만 평범한 매니페스트를 직접 썼다.

kube-prometheus-stack 직접 작성
설치 Helm 한 줄 매니페스트 400줄
메모리 1.5GB 이상 245Mi
포함 Operator, Alertmanager, 대시보드 다수, kube-state-metrics 필요한 것만
보이는 것 추상화 뒤에 숨음 스크레이프 설정·RBAC·relabel 이 눈에 보임

세 번째 줄이 결정적이다. 이 실험대의 목적은 인과를 직접 확인하는 것이므로 「어떻게 타깃을 찾는가」가 YAML 에 드러나 있어야 한다. Operator 를 쓰면 ServiceMonitor 하나만 보이고 그 아래는 감춰진다. 이 판단은 앞 절의 「스크립트를 쓰지 않는다」와 같은 이유에서 나왔다 — 감싸면 무엇을 했는지가 감싼 것 안으로 숨는다.

실측 메모리는 이렇다(observed). 매니페스트는 observability.yaml 이다.

구성요소 역할 실측 메모리
Prometheus 수집·저장·질의 164Mi
node-exporter (DaemonSet) 노드당 하나, 머신 지표 8Mi × 2
Grafana 시각화 65Mi
합계 약 245Mi

예상(550Mi)보다 훨씬 적었다. 실험대 규모에서는 관측성 비용이 거의 무시할 수준이라는 것이 이 수의 뜻이다.

수집 주기는 15초이고 TSDB 보존은 7일이다. 보존 7일은 실험 기간보다 넉넉하면서 볼륨이 노드를 채우는 원인이 되지 않을 크기로 고른 값이다. local-path 는 노드에 고정되므로 Prometheus 도 kc-lab-1 에 묶인다.

이미지는 세 개를 못박았다(observed).

image: prom/prometheus:v3.1.0
image: prom/node-exporter:v1.8.2
image: grafana/grafana:11.4.0

관측을 올린 뒤 잰 노드 사용량은 이랬다(observed).

grafana         65Mi
prometheus     164Mi
node-exporter    8Mi × 2
────────────────────────
합계         약 245Mi

kc-lab-1      2045Mi (41%)
kc-lab-2      1131Mi (28%)
호스트         여유 3957MB

메모리 증설(8GB → 12GB) 전이었다면 kc-lab-1 이 60% 를 넘겼을 것이다 — 증설이 이 항목을 가능하게 했다.

Grafana 비밀번호가 매니페스트에 평문이다. 가이드는 그것을 감추지 않고 「지금 드러내 두는 것은 의도이며, 감춰두면 잊어버린다」고 적었다. D-3 이 그 자리를 다시 연다.

크립트를 쓰지 않는다

절차를 스크립트로 감싸면 「무엇을 했는지」가 스크립트 안으로 숨는다. 그래서 모든 절차를 셸에 그대로 붙여넣을 수 있는 명령으로 적었다.

이 결정에는 대가가 따랐다. 나중에 재현 절차를 점검해 보니 ( curl ... ) & 를 20개 띄우고 wait 처럼 측정 장치 자체를 산문으로 적어 둔 곳이 여럿 있었고, 22.2초라는 헤드라인 수치를 만든 부하 생성기부터가 실행 가능한 형태가 아니었다.

전부 셸 표현식으로 바꾸고 실제로 돌려서 확인했더니 그 확인에서 한 건이 깨졌다(위 표의 #9). 문법은 멀쩡한데 실행하면 조용히 실패하는 종류였다.

「명령을 실행 가능하게 고쳤다」와 「고친 명령이 동작한다」는 다른 주장이다.


선택의 이유와 지킨 경계

A층 — Keycloak 자체가 깨질 때

여덟 개 실험을 같은 모양으로 돌렸다. 예측을 먼저 문서에 적어 두고 주입한 뒤 관측하고, 마지막에 그 예측과 대조하는 순서였다.

그 예측을 적어 둔 자리가 A-0 의 「9. 다음 실험에 대한 예측」이다 (experiment-00-session-replication.md). A-0 이 기준선을 만든 직후, 아직 아무것도 주입하기 전에 쓴 표다. 아래 절들의 「맞다 / 틀렸다」는 전부 이 표와의 대조다.

기준선이 생겼으므로 틀릴 수 있는 예측을 세울 수 있다. 예측이 빗나가면 그것이야말로 배울 거리다.

실험 예측 근거
A-1 TCP 7800 차단 세션 공유는 안 깨진다. 대신 무효화 전파와 work 캐시가 깨진다 세션은 7800으로 오가지 않는다
A-2 DB 손실 즉시 전면 장애. 캐시에 있는 세션도 못 쓴다 DB가 진실의 원천
A-2' DB 강제 종료 직전 수백 ms 의 세션 갱신이 사라진다 synchronous_commit OFF
B-5 동시 갱신 경쟁 한쪽이 VERSION 검사에서 지고 재시도한다 낙관적 락
A-3 노드 상실 (kc-lab-2) 세션은 살아남는다. 죽은 노드의 캐시만 사라진다 룩어사이드
A-4 volatile 비교 7800 차단이 A-1과 정반대로 치명적이 된다 그때는 캐시가 진실의 원천

특히 A-1은 직관과 어긋나는 예측이다. "클러스터 포트를 막으면 세션이 깨진다"가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다.

이 표의 실험 번호는 옛 로드맵의 것이다(observed). 당시의 B-5 가 최종 B-3(동시 갱신 경쟁)이고, A-3 노드 상실 이 최종 A-4, A-4 volatile 비교 가 최종 A-7 이다. 번호를 고쳐 적지 않고 원문 그대로 둔다 — 예측을 언제 썼는지가 번호에 남아 있기 때문이다.

A-2 는 예측이 둘 다 맞은 드문 경우라 원문의 결론표를 그대로 옮긴다.

예측 결과
즉시 전면 장애 맞다. 외부 진입점 503, 양쪽 노드 NotReady
캐시에 있어도 못 쓴다 맞다. 캐시를 가진 노드도 500

같은 표의 나머지 두 행(up = 1 인 채로 전면 장애 · DB 복귀 15초 만에 자동 회복)은 예측 칸이 비어 있다 — 예측한 적 없이 튀어나온 관측이라 아래 「틀린 예측 다섯」에도 넣지 않았다.

A-1 · JGroups 전송(TCP 7800) 차단

예측을 둘 세웠고 하나는 맞고 하나는 틀렸다.

예측 결과
세션 공유는 안 깨진다 맞다. 교차 노드 refresh 가 200
로그아웃 전파는 안 깨진다 틀렸다. 400 이어야 할 것이 200

세션은 DB 에 있으니 7800 과 무관한데, 로그아웃 무효화 통지는 7800 을 타기 때문에 끊으면 반대편 노드가 「이 세션은 죽었다」를 알 방법이 없다.

NetworkPolicy 는 허용목록이라 「deny 7800」 같은 규칙을 쓸 수 없으므로, 8080·9000 만 열고 7800 을 목록에서 빼는 방식으로 막았다.

발견과 전송은 다른 경로다

발견은 DB 를 쓰고 전송은 7800 을 쓴다 — 예측이 하나만 맞은 이유가 이 분기에 있다. 이 두 포트가 하중을 진다 — 9000(health·metrics)을 빠뜨리면 kubelet 이 파드를 죽여서 분단이 아니라 죽은 Keycloak 을 재게 된다.

A-2 · A-3 — DB 가 멈출 때와 죽을 때

A-2 정지 A-3 강제 종료
새 로그인 500
관측된 것 up 이 계속 1 RPO 가 0 이 아니다

A-3 이 이 실험대에서 가장 값진 숫자를 냈다.

클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 DB 에 실제로 존재                 : 149 건
★ 유실                                 :   4 건

로그인이 성공했다고 응답받았는데 세션이 존재하지 않는다. 버그는 아니고, Keycloak 이 트랜잭션마다 SET LOCAL synchronous_commit TO OFF 를 걸기 때문에 COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그 사이(측정된 wal_writer_delay 200ms)에 프로세스가 죽으면 그만큼이 사라진다.

의도된 설계이므로, 이 실험은 그 대가가 얼마인지만 숫자로 확인했다.

200 과 디스크 사이의 빈 구간

성공 응답과 영속화가 다른 사건이어서, RPO 가 0 이 아닌 이유가 그 사이에 있다.

A-4 · 노드 상실 — 둘 다 전면 장애지만 이유가 다르다

4a 워커 상실 4b 컨트롤 플레인 상실
외부 응답 503 000 (연결 자체가 안 됨)
kubectl 정상 불통
원인 DB 가 같이 죽었다 들어갈 길이 없다
복구 virsh start 이후 60초 virsh start 이후 60초

4b 에서 keycloak-0 은 계속 돌고 있었는데도 도달할 수 없어서 장애였다. 진입점이 단일 노드에 있으면 워크로드를 이중화해도 소용이 없다.

그리고 예상하지 못한 것이 셋 더 나왔다.

  1. 죽은 파드가 산 파드보다 건강해 보인다 — kubelet 이 사라져 상태가 갱신되지 않으니 Running 으로 남는다

  2. StatefulSet 은 Terminating 파드의 대체를 만들지 않는다 — 이름이 같아야 하므로 지워지기를 기다린다

  3. 아무 일도 일어나지 않는 구간이 길다. 이 실험이 잰 것과 계산한 것을 갈라 적는다.

    어디서 왔나
    노드가 NotReady 로 넘어간 때 +30초Ready, +45초NotReady 폴링으로 봤다 (observed)
    tolerationSeconds not-ready·unreachable 둘 다 300 kubectl 로 읽었다 (observed)
    축출이 시작된 때 +240초 까지 Running, +270초Terminating 폴링으로 봤다 (observed)
    node-monitor-grace-period 40초 조회하지 않았다 — 쿠버네티스 기본값이다 (unknown)

    계산과 관측을 견주려면 먼저 확인할 것이 있다. 설정값을 더하면 40 + 300 = 340초인데 축출은 +240~270초 에 시작됐다. 두 수가 같은 기준점에서 온 것이라면 70초 이상 어긋나는 것이고, 그렇지 않다면 견줄 수 없는 두 값이다. 두 폴링이 같은 +0 을 쓰는지 이 실험은 적어 두지 않았다 (unknown) — 02 는 차단 시각을 머리말에 적었고 04 는 적지 않았다. 그래서 이 실험이 말할 수 있는 것은 「축출까지 4~5분 가까이 아무 일도 일어나지 않았다」까지이고, 「5분 40초였다」도 「340초와 어긋난다」도 아니다.

장애 시간의 대부분은 복구가 아니라 「누가 죽은 것을 알아채는 데」 걸린 시간이었다. 이 문장은 방향에 대한 것이다 — 알아채는 구간이 복구 구간보다 길었다는 것이지, 그 길이가 5분 40초였다는 뜻이 아니다.

노드를 잃는 두 가지

저장소 상실과 진입 경로 상실은 복구 시간이 같아도 대비하는 방법이 다르다.

A-5 · 비대칭 분단 — 전면 장애 경로가 없다

한 방향만 막으면 열린 방향으로 재연결하므로 클러스터가 갈라지지 않는다. 양방향을 다 막으면 갈라지기는 하는데 한쪽만 DOWN 이 되어서, 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 로드밸런서에서 빠지며 서비스는 이어진다.

이 실험에서 주입을 세 번 실패했다(위 표의 #4·#5·#6). 세 번 모두 다른 이유였고, 셋 다 「아무 일도 없었다」로 보였다.

비대칭 차단은 가르지 못한다

한 방향을 막을 때와 둘을 다 막을 때의 차이가 여기서 갈린다.

A-6 · 지연 주입 — 200밀리초가 22초가 된다

측정
주입한 네트워크 지연 200 ms
로그인 응답 시간 66 ms → 1,872 ms (28배)
동시 20건에서 최대 응답 22.2 초
커넥션 획득 대기 최대 20,000 ms
readiness 프로브 타임아웃으로 실패

두 단계로 곱해지는데, 로그인 한 번이 DB 왕복을 여러 번 하므로 왕복 횟수만큼 더해지고 요청이 커넥션을 오래 붙들어 뒤의 요청이 풀에서 대기하므로 거기서 한 번 더 곱해진다.

마지막에는 파드가 죽는다. readiness 가 타임아웃으로 실패해 느린 노드가 로드밸런서에서 빠지므로, 느림이 곧바로 장애로 승격된다.

지연이 곱해지는 두 단계

왕복 누적과 풀 경합을 하나로 보면 28배가 어디서 왔는지 설명되지 않는다.

A-8 · 롤링 재시작 — 세션은 살아남고 캐시만 사라진다

확인 결과
재시작 중 서비스 중단 없음. 전 구간 200
재시작 전 발급한 refresh token 여전히 200
DB 세션 수 151 → 151 그대로
세션 캐시 0 으로 초기화

persistent-user-sessions 를 켜는 진짜 이유가 여기에 있다.

★ 2026-09-17 에 다시 쟀다(observed). 세션 수 대신 행의 두 시각을 견줘서 같은 것을 더 좁게 보였다.

재시작 전 대조군            200
재시작 (파드 둘 교체)       AGE 31s · 63s · IP 10.42.1.47→.49, 10.42.0.20→.21
재시작 뒤 같은 refresh      200 · session_state 가 같은 sid
DB 행                       created_on 1789625835 그대로
                            last_session_refresh 1789625835 → 1789625952
캐시                        재시작 전후 모두 sessions 0

created_on 은 안 바뀌고 last_session_refresh 만 117초 뒤로 갔다. 새로 만든 세션이 아니라 남아 있던 행을 새 파드가 읽어서 갱신한 것이고, 「세션은 살아남고 캐시만 사라진다」의 가장 좁은 증거다.

재시작이 지우는 것과 남기는 것

캐시와 세션을 분리하지 않으면 재시작 후 로그인이 유지되는 이유를 설명할 수 없다.

A-7 · A-7a — 전부 뒤집는 설정 하나, 그리고 그 표에도 조건이 있었다

A-7 은 --features-disabled=persistent-user-sessions 로 A층을 다시 돌려 세 결과가 뒤집히는 것을 보였다. 그리고 refresh 가 500 인 이유를 가설로 남겼다REVOKED_TOKEN 테이블일 것이라고.

A-7a 에서 문장 로깅으로 확정했더니 가설이 틀렸다.

로그인은 SQL 을 0개 쏜다. refresh 는 딱 한 문장을 쏘는데, 그것이었다.

select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
 where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
 parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'

REVOKED_TOKEN 은 한 번도 나오지 않으며, DEFAULT_SCOPE='f' 이므로 선택적 클라이언트 스코프 조회다. refresh 는 새 access token 에 어떤 스코프를 담을지 다시 계산하는데 그 목록이 이 테이블에 들어 있다.

그 다음이 더 중요하다. 그 조회는 첫 refresh 에서 한 번만 일어나고 캐시된다. 그래서 같은 설정이 캐시 온도만으로 세 가지 답을 낸다.

캐시 상태 로그인 refresh 실패한 SQL
완전 냉시동 400 400 select ce1_0.ID from CLIENT ...
CLIENT 만 더움 ← A-7 이 본 것 200 500 CLIENT_SCOPE_CLIENT ...
완전히 더움 200 200 없음 (SQL 0건)

셋 다 재현해 보니 A-7 이 적은 「volatile 이면 DB 없이 로그인된다」도 조건부였는데, 냉시동에서는 클라이언트 조회조차 캐시에 없어 400 이 나온다.

volatile 에서 DB 정지 시의 동작은 「무엇을 하느냐」가 아니라 「그 경로가 이미 캐시를 채웠느냐」 로 결정된다. 이런 종류는 한 번 재고 표로 적으면 안 된다.

해설 문서는 뒤집힌 결과 옆에 두 모드가 무엇을 맞바꾸는지의 축을 따로 적어 두었다(experiment-a7-volatile-comparison.md). 위의 세 줄이 측정한 것이라면 아래는 그 측정에서 끌어낸 축이다.

persistent volatile
재시작 내구성 있다 없다
7800 의존 낮다 (무효화만) 높다 (세션 자체)
DB 부하 로그인·refresh 마다 쓰기 세션 관련 없음
노드 확장 DB 가 병목 복제 트래픽이 N² 로 증가
지연 민감도 DB 왕복에 민감 (A-6) 클러스터 왕복에 민감

26 이 기본을 바꾼 이유가 이 표에 있다 — 운영에서 가장 아픈 것이 "배포하면 로그아웃"이었기 때문이다.

이 표에서 잰 것은 위 두 줄뿐이다. 「재시작 내구성」과 「7800 의존」은 A-8 과 A-1 의 재실행으로 관측했고, DB 부하·노드 확장·지연 민감도 세 줄은 이 실험이 재지 않았다(inferred). 파드가 둘뿐이라 는 볼 수 없다.

캐시 온도가 결과를 가른다

세 결과를 만드는 것은 조회 두 개이고, 캐시가 그 조회를 삼키는 순간 결과가 바뀐다.


선택이 코드와 흐름에 반영되는 방식

B층 — 열린 질문 네 개에 대한 답

A층이 Keycloak 자체를 다뤘다면 B층은 애플리케이션 쪽이다. BFF(Spring Boot) 두 인스턴스와 Redis, 그리고 oauth2-proxy 두 replica 를 올리고 잰다.

B-0 · 아무것도 설정하지 않으면 무엇이 선택되는가

저장소를 붙이기 전에 먼저 들여다봤고, 추측으로 두면 안 되는 이유가 여기서 나왔다.

authorizedClientService      → InMemoryOAuth2AuthorizedClientService
authorizedClientRepository   → AuthenticatedPrincipalOAuth2AuthorizedClientRepository
SessionRepository            → 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session       → 없음

둘째 줄이 핵심인데, AuthenticatedPrincipalOAuth2AuthorizedClientRepository 는 principal 이름으로 찾기 때문에 조회 키에 session id 가 없다. 그래서 이름은 비슷해도 서로 다른 것을 저장하는 두 개가 따로 굴러간다.

무엇을 담나 조회 키
Application Session 누가 로그인했는지 세션 id
OAuth2AuthorizedClient access · refresh token principal 이름

이 둘을 하나로 생각하면 다음 실험의 결과를 해석할 수 없다.

BFF 가 저장하는 두 가지와 그 조회 키

같은 요청이 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회되기 때문에, B-1 에서 세션만 Redis 로 옮겼을 때 토큰이 따라오지 않았고 B-2 에서 따로 PostgreSQL 로 옮겨야 했다.

B-1 · 세션만 Redis 로 옮기면 — 반쪽만 옮겨진다

SPRING_SESSION_STORE_TYPE=redis 로 Application Session 을 Redis 로 옮기자 파드를 재시작해도 로그인이 유지됐지만 토큰은 같이 살아남지 못했다. 조회 키가 다르므로 세션 저장소를 바꿔도 OAuth2AuthorizedClient 는 따라오지 않는다 — B-0 에서 확인한 그대로다.

Redis 를 열어 보니 키는 하나였고 타입은 hash 였다 (observed).

bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
  총 키 수: 1
  타입: hash
    필드: sessionAttr:SPRING_SECURITY_CONTEXT
    필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
    필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
    필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
    필드: lastAccessedTime
    필드: maxInactiveInterval
    필드: creationTime
  TTL: 1772 초

일곱 필드 어느 이름도 access token 이나 refresh token 을 가리키지 않는다. 값까지 꺼내 본 것은 sessionAttr:SPRING_SECURITY_CONTEXT 하나이고, 그 값은 \xac\xed 두 바이트로 시작한다 — Java 기본 직렬화의 매직 넘버다. 이름에 OAuth2 가 들어간 …AUTHORIZATION_REQUEST 는 값 안을 열어 보지 않았다 (unknown). 원문은 evidence/raw/b1-redis-session-store__03-redis-contents.txt 다.

B-2 · 저장소를 나눠 풀자 다른 두 문제가 남았다

토큰은 JdbcOAuth2AuthorizedClientService 로 PostgreSQL 에 옮겼고, 이것이 Q3 가 말한 「각각 설계한다」를 실제로 해 본 모습이다.

Q1 검증 결과 증거
① 다른 인스턴스로 요청해도 되는가 된다 없다 (unknown) — 이 판정을 낸 출력이 evidence/raw/ 에 남지 않았다
② 재시작 후 로그인 유지 된다 없다 (unknown) — 위와 같다
③ 같은 사용자의 다른 브라우저가 덮어쓰는가 ★ 덮어쓴다 b2-multi-instance-session__04-overwrite-test.txt (observed)
④ 로그아웃하면 두 저장소가 다 정리되는가 ★ 아니다. 한쪽만 b2-multi-instance-session__05-logout-cleanup.txt (observed)

①② 는 판정만 남고 출력이 없다. b2 증거 다섯 개는 배포·스키마·평문 토큰·덮어쓰기· 로그아웃 정리이고, 교차 인스턴스 요청이나 재시작 뒤 로그인을 확인한 화면은 그중에 없다. ③④ 와 같은 무게로 읽지 않는다.

③④ 의 뿌리는 저장소 선택이 아니라 DDL 한 줄이다.

PRIMARY KEY (client_registration_id, principal_name)

세션 id 가 키에 없어서 같은 사용자의 두 세션이 같은 행을 쓰게 되고, 나중 로그인이 앞의 토큰을 덮어쓴다. 로그아웃한 뒤에는 이렇게 남는다.

Redis 세션      : 0 키    ← 정리됨
PostgreSQL 토큰 : 1 행    ← 평문 refresh token 이 그대로 남는다

덮어쓰기를 만드는 기본키

저장소를 무엇으로 골랐느냐가 아니라 스키마가 원인이다.

B-3 · Refresh Token Rotation 경쟁 (Q2)

revokeRefreshToken=true · refreshTokenMaxReuse=0 에서 같은 refresh token 으로 동시에 5건을 보냈다. 순차로 돌리면 재현되지 않는다 — &wait 이 있어야 경합이 생긴다.

경쟁이 감지되면 Keycloak 이 client session 을 지우기 때문에, 이긴 요청이 받은 새 토큰조차 쓸 수 없다. 「하나는 성공하고 나머지가 실패한다」가 아니라 전부 못 쓰게 되는 쪽이다.

회전 경쟁에서 이긴 요청도 진다

실패가 진 요청에만 오는 것이 아니어서, 재시도를 어떻게 설계할지가 여기서 갈린다.

B-4 · Edge 인가의 범위 (Q4)

nginx → oauth2-proxy → 앱의 2홉 구조에서 헤더를 위조해 봤다.

위조를 잰 경로는 app1.hyeonworks.com/api 의 echo 앱이다 (observed). header-lab 네임스페이스에 있고 도착한 헤더를 그대로 되돌려주며, 그 경로는 permitAll 이라 oauth2-proxy 를 거치지 않는다.

여기서도 예측이 틀렸다. nginx 는 자기가 설정하지 않은 동명 헤더를 덮어쓰지 않기 때문에 위조 헤더가 앱까지 그대로 도착한다.

도착한 것과 인가를 뚫은 것은 다르다 (observed). 같은 위조 헤더를 토큰을 요구하는 경로에 보내면 거기서 막힌다.

  대조 — JWT 를 요구하는 경로:
    /api/echo        HTTP 200   (permitAll)
    /api/me          HTTP 401
    /api/protected   HTTP 401

그러므로 위험한 것은 「위조 헤더가 도착한다」가 아니라 헤더만 읽어 인가하는 앱이 그 뒤에 있을 때다. proxy_set_header X-Auth-Request-Roles "" 로 먼저 지우는 것이 그 처방인데, 이 실험대는 그 수정을 적용한 적이 없다 (unknown) — 원본 가이드가 「아래는 미검증이며, 적용하려면 랩 호스트에서 사람이 직접 친다」로 못박고 있다.

그리고 IdP 에서 값을 바꿔도 반영되지 않는다. 12회 · 약 6.4초 동안 옛 값이 갔고, Redis 세션을 지워 재인증시킨 뒤에야 새 값이 왔다.

세션은 로그인 시점의 스냅샷이어서, --cookie-refresh 가 없으면 요청을 몇 번 보내든 쿠키 만료나 재인증까지 옛 값이 그대로 간다.

지우지 않으면 통과한다

위조 경로와 정상 경로가 같은 헤더 이름을 쓰므로, 지우는 단계가 없으면 둘을 구별할 수 없다.

B-5 · B-6 — 저장소 상실과 키 회전

B-5 에서 redis-cli config set appendonly yes 를 켜도 아무것도 달라지지 않았는데, /data 가 컨테이너 파일시스템이라 컨테이너와 함께 죽기 때문이다. 볼륨 없는 영속화 설정은 장식에 그친다.

B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했는데 없었다. NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다.

★ 2026-09-17 에 다시 재 보니 그 「없었다」는 절반만 맞았다(observed). 옛 키를 지운 직후 같은 토큰으로 여덟 번 연속 쳤더니 401 200 401 200 401 200 401 200 이 나왔다. echo 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로여서, 한쪽은 목록을 새로 받아 옛 키를 잃었고 다른 쪽은 아직 들고 있다. Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐로 결과가 갈린다.

그래서 판정은 **「유예가 없다」가 아니라 「인스턴스마다 다르다」**다. 운영에서는 더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「없었다」로 닫은 것은 한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred).

볼륨 없는 영속화와 유예 없는 회전

설정과 매체를 나눠서 보아야 한다. 설정만 보면 둘 다 되어 있는 것으로 읽히기 때문이다.

B-7 · B-7a — 쿠키에 담는 세션, 그리고 그 대가

★ 이 세 편을 밟으려면 어디에도 없는 한 단계가 먼저다(2026-09-17, observed). realm keycloak-patternsoauth2-proxy 클라이언트를 만드는 곳이 없다. 기반 가이드 05 도, 저장소의 realm 임포트(keycloak/import/keycloak-patterns-realm.json)도 만들지 않는다 — 임포트에 있는 것은 bff-confidential · edge-proxy · mock-google-broker · spa-public · token-mediating-confidential 다섯이다.

파드는 멀쩡히 뜨고 app2302 를 내므로 배포는 다 된 것처럼 보이고, 로그인 화면까지 가서야 드러난다.

We are sorry...
Client not found.

만드는 한 줄은 이렇다. 값은 매니페스트가 읽는 Secret 에서 그대로 꺼내 넘기고, redirectUris--redirect-url 과 한 글자도 달라선 안 된다.

CS=$(kubectl -n keycloak-lab get secret oauth2-proxy-secrets -o jsonpath='{.data.CLIENT_SECRET}' | base64 -d)
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create clients -r keycloak-patterns \
  -s clientId=oauth2-proxy -s enabled=true -s protocol=openid-connect \
  -s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false \
  -s "secret=$CS" \
  -s 'redirectUris=["https://app2.hyeonworks.com/oauth2/callback"]' \
  -s 'webOrigins=["https://app2.hyeonworks.com"]'

그 단계를 밟고 나니 B-7 과 B-7a 는 문서 그대로 나왔다(observed). 로그인 뒤 쿠키가 _oauth2_proxy 176자짜리 티켓이고 세션은 Redis 에 들어갔으며, 업스트림이 x-forwarded-user · x-forwarded-email · x-forwarded-preferred-username 을 받았다. secret 을 A 에서 B 로 바꾸자 회전 전 200 이던 같은 쿠키가 302 로 바뀌었고, Redis 의 세션 키는 그대로 남아 ttl3551 → 3521 → 3490 으로 갱신 없이 줄었다.

oauth2-proxy 는 BFF 와 정반대로 서버 상태를 갖지 않는다. 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 만 읽으므로, 공유할 것이 없어서 콜백이 다른 replica 로 가도 문제가 없다.

대신 --cookie-secret 이 단수라서 겹침 구간을 만들 수 없다. 「옛 secret 도 당분간 받아준다」가 불가능하므로, 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다.

Redis 세션 저장소를 켜면 쿠키에는 티켓만 남는데, 그러면 문제의 성격이 바뀐다. secret 을 바꾸면 티켓을 못 풀고, 티켓 안에 세션 id 가 있으므로 어느 Redis 키를 지울지도 모른다.

Error removing session: error decoding ticket to clear session

B-7 은 여기서 「지우지 못했다」로 멈췄다. B-7a 가 이어받아 잰 결과 — oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.

물음
고아는 정말 사라지는가 사라진다. 생성 후 정확히 1시간. TTL 이 갱신되지 않는다
운영자가 지울 수 있는가 있다. redis-cli del 후에도 산 세션은 200
어느 것이 고아인지 아는가 Redis 값으로는 모른다. 이름·타입·크기(3510바이트)가 같고 값은 암호화
그럼 어떻게 고르는가 TTL 로 생성 시각을 역산한다

TTL 이 요청으로 갱신되지 않으므로(refresh:disabled) TTL 은 생성 시각의 정확한 함수다.

생성시각 = 지금  (cookie-expire  TTL)

이 값이 회전 시각보다 이르면 고아인데, 역산한 11:30:26 과 로그의 AuthSuccess 11:30:271초 차였다. 그 기준으로 실제로 골라 지웠고 산 세션만 남았다.

전제도 같이 적는다 — --cookie-refresh 를 켜면 이 역산이 무너진다. 그때는 FLUSHDB 로 전부 지우고 모두 재인증시키는 편이 정직하다.

쿠키에 담으면 공유할 것이 없다

쿠키 저장과 Redis 저장을 나란히 두면, 옮기는 순간 지울 수 없는 상태가 생기는 것이 보인다.

C층 — SSO 와 로그아웃 전파

C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인하면서 로그아웃이 다른 앱으로 퍼지지 않는 것도 함께 관측했고, C-2 에서 들여다본 원인은 단순했다.

확인 결과
백채널 로그아웃이 설정되어 있었는가 아니다. 두 클라이언트 모두 backchannelLogoutUrl 없음
앱에 그 엔드포인트가 있는가 아니다. 소스에 oidcLogout 설정이 없다
IdP 쪽만 설정하면 되는가 ★ 안 된다. 앱 세션이 그대로 남았다
Keycloak 이 앱 URL 에 닿기는 하는가 닿는다 (HTTP 200) — 네트워크 문제가 아니다

아무도 구현하지 않았다. 그리고 「설정이 빠졌다」와 「기능이 없다」는 고치는 방법이 다른데 여기는 둘 다였으므로, 확인 순서를 바꿨다면 한쪽만 고치고 끝냈을지도 모른다.

백채널 로그아웃은 양쪽이 있어야 한다

IdP 쪽 결손과 앱 쪽 결손이 한 경로 위에 있어서, 하나만 고쳐서는 여전히 되지 않는다.

D층 — 운영

D-1 · D-2 — 백업과 업그레이드

D-2 에서 26.7.0 → 26.7.3 은 무중단이었지만(87회 요청 전부 200) 되돌리기는 막혔다.

liquibase ValidationFailedException: 1 changesets check sum

새 버전이 남긴 체크섬을 옛 버전이 거부한다. 그런데 서비스는 살아 있었다 — StatefulSet 롤링 업데이트가 첫 파드에서 멈추고 나머지를 건드리지 않았기 때문이다. 「롤백 계획」이 없어도 사고가 전면화되지 않았다.

이 결론은 나중에 더 정밀해졌다. 「롤백 불가」는 조건부여서 스키마가 움직였을 때만 해당하며 판단 기준도 하나로 정해진다.

select count(*) from databasechangelog

업그레이드 전후로 이 수가 같으면 롤백이 되고 늘었으면 안 되는데, 26.7.3 → 26.7.0 을 스키마 변경 없이 되돌리는 것은 실제로 성공했다(전환 순간 000 1회).

방향에 따라 갈리는 업그레이드

체크섬 검증은 되돌리기를 막고, 롤링 업데이트는 그 사고의 피해를 줄인다.

D-3 · 비밀

kubectl get secret -o yaml 의 base64 는 암호화가 아니라 인코딩이므로 etcd 에는 평문으로 있고, 파드 안에서 env | grep -i secret 을 치면 그대로 나온다.

base64 는 암호화가 아니다

인코딩과 암호화는 다르고, 드러나는 두 경로 모두 끝이 평문이다.

D-4 · D-4a — 인증서, 그리고 이 실험대 최대의 발견

계획서의 물음은 「nginx reload 중 진행 중이던 요청은 어떻게 되는가」였고, 답하기 전에 대조군부터 잡았다.

대조군 결과
새 연결 (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 기록됨
네트워크  일련번호 564표본 내내 옛 것. 08:58:52 에야 바뀜
시각 (실제 UTC)
새 인증서 디스크 기록 08:20:27
실제 서빙 시작 (nginx -s reload) 08:58:52
공백 2305초 = 38분 25초 (그 사이 428회 관측)

그 38분은 우연히 짧았을 뿐인데, reload 를 시킨 것이 자동화가 아니라 사람이었기 때문이다. 아무도 치지 않았다면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나갔을 터였다.

원인이 셋 겹쳤고 셋 다 비어 있었다.

상태
certbot-renew.serviceExecStartPost 없음
/etc/letsencrypt/renewal-hooks/{deploy,post,pre}/ 셋 다 비었음
certbot 의 nginx 플러그인 없음 (dns-cloudflare, manual, null, standalone, webroot)

nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있는데 certbot 은 경로가 아니라 live/ 심볼릭 링크를 갈아끼우므로, 설정은 멀쩡해 보이는데 옛 인증서가 나간다. 설정을 고칠 일이 아니라 reload 를 걸 일이다.

인증서 갱신이 서빙에 닿기까지

live/ 는 심볼릭 링크라 경로가 그대로이고 가리키는 대상만 바뀐다. 그래서 nginx 설정을 고칠 필요가 없고, 바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다. 필요한 것은 설정 변경이 아니라 reload 이며, 그 reload 를 부르는 경로가 이 실험대에서는 셋 다 비어 있었다.

reload 가 실제로 일어났는지 가리는 방법도 여기서 나왔다. 마스터 PID 는 유지되고 워커 PID 만 바뀌면 reload 된 것이다.

585    1  80529  Thu Sep  3 19:00:39  nginx: master process
586  585  80529  Thu Sep  3 19:00:39  nginx: worker process

워커가 마스터 기동 직후의 첫 fork(585→586) 그대로 22.4시간째다.

이 결함은 88일 동안 드러나지 않는다. 타이머는 정상이고 매번 SUCCESS 로 끝나는데, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없기 때문이다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있을 것이다.

D-4a 에서 처방(deploy/ 훅 하나)을 실제로 넣고 검증했다.

훅 없음 훅 있음
갱신 → 서빙 2305초 = 38분 25초 1~2초
무엇이 reload 했나 사람 certbot deploy 훅

여기에도 함정이 하나 더 있었다. certbot 이 Hook 'deploy-hook' ran with error output 이라고 찍지만 실패는 아니고, nginx 의 types_hash 경고가 stderr 로 나갔을 뿐이어서 내용은 test is successful · signal process started 다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.

reload 자체는 무중단이었다 — 새 연결 8856건 전부 200, p95 205.7 → 204.3ms. 그리고 전송 12초째에 reload 를 맞은 42초짜리 요청이 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임졌기 때문이다.

이 수치는 D-4 에서 사람이 손으로 건 reload 를 잰 것이다 (observed). D-4 시점에는 훅이 없었고, 그게 D-4 의 진단이다. 훅이 거는 reload 가 무중단인지는 따로 재지 않았다 (unknown) — D-4a 가 잰 것은 갱신에서 서빙까지의 공백이 1~2초로 줄었다는 것이고, 그때 워커가 갈린 것은 PID 로 확인했다(사람이 걸었을 때 28829, 훅이 걸었을 때 37252).

훅 하나가 만드는 차이

훅이 있고 없고가 이 차이를 만든다 — 판정은 로그 문구가 아니라 워커 PID 로 한다.


결정이 지켜지는지 확인하는 방법

측정이 거짓말할 때

이 실험대가 남긴 것 중 결과표보다 오래 갈 것은 어디서 측정이 틀리는가다.

대조군 없이는 아무것도 귀속할 수 없다

D-4 에서 갱신 중 비200 이 한 번 나왔다고 하면, 평시 오류율을 모르는 채로는 그게 갱신 탓인지 알 수 없다. 그래서 주입 전에 900건을 재서 오류가 0 이라는 것부터 확인해 두었다.

이 규칙을 어긴 사례가 이미 있었다. A-6 에서 41% 인 대조군을 「영향 없음」 이라고 적었고 A-8 에서는 표본 9개로 무중단을 주장했다. 둘 다 나중에 고쳤다.

반대로 이 규칙이 작동한 사례도 있다 — D-4 의 in-flight 감시다. 위의 A-6·A-8 과 같은 줄에 세지 않는다. 76건이 실패했고 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다. 확인해 보니 서버 탓이 아니었다.

근거
같은 순간 폴링 49건 전부 200
연결수 0 — TCP 연결 시도조차 못 했다
소요 시간 50µs — DNS 조회보다 짧다
재현 0/100

대조군이 있었기에 오보를 내지 않을 수 있었다.

대조군 없이는 귀속할 수 없다

대조군이 관측과 귀속 사이에 있어서, 그것이 없으면 같은 관측이 두 가지로 읽힌다.

두 시계에서 온 값을 빼면 안 된다

D-4a 에서 1~2초를 재려다 걸렸다. test-server 는 NTP 가 꺼져 있어 106초 빠른 반면 dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였다.

그 사실을 적지 않고 계산한 D-4 의 공백은 106초 짧았다(2199 → 2305초). 그리고 1~2초를 재는 D-4a 에서는 보정 없이 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나와 물리적으로 성립하지 않는다.

보정은 독립 기준으로 교차검증했다 — 새 인증서의 SCT(Sep 4 12:27:49.054 GMT, CT 로그가 자체 시계로 서명)가 보정한 훅 시각의 정확히 1초 앞에 놓인다.

두 시계에서 온 값을 그냥 빼면 자릿수가 아니라 방향까지 틀릴 수 있다.

관측 도구는 진실의 부분집합만 본다

도구 못 보는 것
up 지표 「살아 있지만 쓸모없는」 상태. 503 이 나는 동안에도 1이었다
crt.sh 서빙 중인 인증서에 SCT 가 두 개 박혀 있는데도 0건으로 답한다
Prometheus Redis·BFF·PostgreSQL 을 아예 긁지 않는다

마지막 것 때문에 B층 실험 대부분에 Grafana 스크린샷이 없는데, 안 찍어서가 아니라 지표 자체가 없어서다. 그래서 이 사실을 「스크린샷 누락」이 아니라 측정된 공백으로 기록했다.

문서가 자기 증거와 어긋난 곳

기록을 다 쓴 뒤 증거와 하나씩 대조했더니 어긋난 곳이 여럿 나왔다.

어디 무엇이 어긋났나
C-1 본문은 「세션 0」인데 증거는 4
C-2 exit code 1 인 명령의 성공 읽기를 실었다
A-1 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸다
A-2 첫 측정의 000000{"error":"HTTP 401"}401 을 숨겼다
A-3 wal_writer_delay 를 재지 않고 단언했다 (실측 200ms, 로그인율도 19/s 가 아니라 14/s)
D-1 본문은 RTO 30초, 자기 타임라인은 41초

전부 고치면서 무엇이 어긋났는지를 표로 남겼다. 지워 버리면 다음 사람이 같은 방식으로 틀리기 때문이다.

재현 가능성을 어떻게 보장했나

절차를 명령으로 적는 것만으로는 부족했다. 측정 장치 자체를 산문으로 적어 둔 곳이 남아 있었는데, 하필 그것들이 헤드라인 수치를 만든 바로 그 명령이었다.

어디 산문이던 것
A-6 ( curl ... ) & 를 20개 띄우고 wait ← 22.2초의 출처
A-3 <로그인 반복, sid 를 /tmp/sids 에> ← RPO 측정 전체
A-8 /tmp/tok 에 쓰고 /tmp/rt 를 읽는다 ← 빈 토큰을 보내고 있었다
A-1 conntrack 튜플을 손으로 적는다 ← 방향이 재시작마다 바뀐다

전부 셸 표현식으로 바꾸고 실제로 실행해 확인했다. 그 확인에서 A-6 의 부하 생성기가 깨졌다 — 일회성 파드의 출력 스트림이 유실됐다. 상주 탐침 + 파드 안 파일 수집으로 고쳐 20/20 을 확인했다.

명령으로 적는 것과 도는 것

산문에서 명령으로, 다시 실행 확인으로 두 단계를 거쳤고 두 번째에서 한 건이 깨졌다.


얻은 것, 잃은 것, 적용하지 않을 때

열린 질문 네 개에 대한 답

질문
Q1 다중 인스턴스 세션 운영 저장소를 밖으로 빼면 ①② 는 풀린다. ③④ 는 저장소가 아니라 스키마 문제다 — PRIMARY KEY (client_registration_id, principal_name) 에 세션 id 가 없다
Q2 Refresh Rotation 경쟁 이긴 요청의 토큰조차 못 쓴다. 경쟁이 감지되면 client session 이 지워진다
Q3 Session 과 AuthorizedClient 를 어디에 둘은 조회 키가 다르므로 각각 결정해야 한다. 세션을 Redis 로 옮겨도 토큰은 따라오지 않는다
Q4 Edge 인가의 범위 nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다 — 위조 헤더가 permitAll 인 echo 앱까지 그대로 도착했다. 다만 같은 헤더로 JWT 를 요구하는 경로를 찔렀을 때는 401 이라, 도착한 것과 인가를 뚫은 것은 다르다. 먼저 지우는 처방은 이 실험대가 적용한 적이 없다 (unknown). 그리고 IdP 의 클레임 변경은 재인증 전까지 반영되지 않는다

열린 질문 네 개가 닿은 곳

네 질문이 공통 원인으로 모이면서, 저장소 선택만으로는 풀리지 않는 것들이 함께 드러난다.

이 기록이 적용되지 않는 조건

  • Keycloak 26 미만. persistent-user-sessions 가 기본이 아니면 A층 결론 중 셋이 뒤집힌다. A-7·A-7a 가 그 대조군이다
  • 캐시가 더운지 찬지. volatile 에서 DB 정지 시의 동작은 캐시 온도로 갈린다
  • 단일 인스턴스. B층 질문은 인스턴스가 둘 이상일 때만 생긴다
  • --cookie-refresh 를 켠 oauth2-proxy. B-7a 의 TTL 역산 정리 규칙이 무너진다
  • NTP 가 동기된 환경. 이 실험대는 106초 왜곡이 있었고 그것을 보정한 수치다

이 기록이 적용되지 않는 조건

적용 조건을 목록이 아니라 무엇을 무효로 만드는가로 이었다.

재보지 않은 것

항목
certbot-renew.timer실제 갱신을 하는가 만료 30일 전에야 조건이 성립한다 — 증거의 VALID: 89 days만료까지이므로 갱신은 약 59일 뒤

결국 지키려던 것은 무엇이었나

이 실험대에서 예측 다섯 개가 틀렸고 주입이 아홉 번 조용히 실패했다. 그것을 지우지 않고 남긴 이유가 이 기록의 요지다.

틀린 예측 실제
A-1 로그아웃 전파는 안 깨진다 깨졌다 — A-0 의 인과 설명을 고쳐야 했다
A-6 낙관적 락 충돌이 보인다 0건 — 로그인은 INSERT 라 경합하지 않는다
B-4 nginx 가 동명 헤더를 덮어쓴다 덮어쓰지 않는다
B-6 JWKS 캐시가 유예를 준다 인스턴스마다 다르다 — replica 둘이 401200 으로 갈렸다 (2026-09-17 재측정)
A-7 refresh 500 은 REVOKED_TOKEN 때문 CLIENT_SCOPE_CLIENT 였다

A-2 의 up = 1 은 이 표에 넣지 않는다. 전에는 「up 이 장애를 보여준다」를 틀린 예측으로 적어 여섯 줄이었고 본문의 「다섯 개」와 맞지 않았다. 원본 가이드는 그 줄의 예측 칸을 **「—」로 비워 두고 「관측의 함정」**이라고 적는다 — 미리 적어 둔 예측이 빗나간 것이 아니라 예측한 적 없이 튀어나온 관측이다. 그래서 다섯 줄이 맞다.

틀린 예측이 맞은 예측보다 많은 것을 가르쳤는데, A-1 이 틀리지 않았다면 A-0 의 인과 설명이 잘못된 채로 남았을 것이고 A-7 의 가설이 확정되지 않았다면 「volatile 이면 이렇다」는 표가 조건 없이 유통됐을 것이기 때문이다.

그래서 이 기록을 쓰면서 지킨 규칙은 셋이다.

  1. 예측을 먼저 적는다. 결과를 보고 나면 무엇을 예상했는지 정직하게 쓸 수 없다
  2. 주입이 걸렸는지를 결과와 따로 확인한다. 「아무 일도 없었다」는 「영향이 없다」와 구별되지 않는다
  3. 대조군 없이 귀속하지 않는다. 평시를 모르면 이상을 해석할 수 없다

세 번째가 가장 자주 어겨졌고 치른 값도 가장 컸다.

틀린 예측이 남긴 것

세 규칙을 순서대로 놓으면, 각 단계가 빠졌을 때 어떻게 틀리는지가 실제 이력으로 남는다.


자료

원본 ../source/docs/ — 실험 문서 29편 · 계획서 · 개념 문서 · 선수지식 문서
매니페스트 ../source/deploy/ — 실험이 쓰는 k8s 매니페스트 8개
증거 원문 evidence/raw/ — 125건. 정본이다
실행 메타 evidence/meta/ — 125건
브라우저 캡처 evidence/browser/ — 22건
그림 assets/ — techviz 로 만든 28건. 정본은 .techviz/ 의 VizSpec
재현 가이드 ../source/docs/guides/experiments/26편. 「무엇을 발견했나」가 아니라 「다시 만들려면 무엇을 어떤 순서로 치는가」
실험 목록 ../source/docs/experiment-index.md
로드맵 ../source/docs/experiment-plan.md — 실험별 예측·판정 규칙
개념 ../source/docs/session-lab-concepts.md · ../source/docs/session-lab-prerequisites.md

원본 저장소의 리비전은 ../source/.source-revision 에 적어 두었다.

실험이 쓴 설정 원본

위 표의 ../source/deploy/경로일 뿐 내용이 아니었다. 실험 결과는 이 문서가 전부 담았지만 그 실험대를 무엇으로 세웠는지는 링크 너머에만 있었고, source/ 가 사라지면 같이 사라진다. 그래서 아래에 원문을 그대로 옮긴다.

비밀 값은 옮기지 않는다. 실험대의 매니페스트는 비밀번호를 평문으로 담고 있는데(그 자체가 D-3 이 다루는 사실이다), 여기에는 길이와 자리만 남기고 값은 <…> 로 가린다. 나머지는 한 글자도 바꾸지 않았다.

k8s 매니페스트 여덟 개

deploy/lab/k8s/keycloak-cluster.yaml — A층 전체가 이 위에서 돈다. Keycloak StatefulSet 2노드 · PostgreSQL · headless Service · Ingress. 비밀 값 2곳을 가렸다.

# Keycloak multi-node cluster with PostgreSQL.
#
# Goal of this manifest: two Keycloak pods on two different nodes must discover
# each other and form one Infinispan cluster. Keycloak 26 discovers peers through
# the database (jdbc-ping) rather than multicast, writing to a JGROUPS_PING table,
# but the cluster traffic itself runs over TCP 7800 between the pods. Those are
# two separate mechanisms, which is why "registered in the DB but not clustered"
# is a real failure mode — and one that a single node cannot reproduce.
#
#   kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
#   kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
#
# Secrets are plain here. Proper secret handling is roadmap item 11; keeping it
# visible for now is deliberate so the gap is obvious rather than forgotten.
apiVersion: v1
kind: Namespace
metadata:
  name: keycloak-lab
---
apiVersion: v1
kind: Secret
metadata:
  name: keycloak-lab-secrets
  namespace: keycloak-lab
type: Opaque
stringData:
  POSTGRES_PASSWORD: <평문 비밀번호 22자>
  KC_BOOTSTRAP_ADMIN_PASSWORD: <평문 비밀번호 19자>
---
# PostgreSQL. local-path binds the volume to whichever node the pod lands on, so
# the database is effectively pinned to one node. That is not a flaw here: it is
# what makes "the database node dies" a meaningful experiment later.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: keycloak-lab
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: keycloak-lab
spec:
  replicas: 1
  strategy:
    type: Recreate          # RWO volume cannot be mounted by two pods at once
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
              name: postgres
          env:
            - name: POSTGRES_DB
              value: keycloak
            - name: POSTGRES_USER
              value: keycloak
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: keycloak-lab-secrets
                  key: POSTGRES_PASSWORD
            # The image refuses to initialise into a non-empty mount, and
            # local-path volumes are clean, but this keeps the data one level
            # down so a lost+found or similar never blocks initdb.
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          readinessProbe:
            exec:
              command: ["sh", "-c", "pg_isready -U keycloak -d keycloak"]
            initialDelaySeconds: 10
            periodSeconds: 5
          resources:
            requests:
              memory: 192Mi
              cpu: 50m
            limits:
              memory: 512Mi
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: postgres-data
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: keycloak-lab
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: postgres
---
# Keycloak. A StatefulSet rather than a Deployment so each pod keeps a stable
# name (keycloak-0, keycloak-1); cluster membership is far easier to read in
# logs and in the JGROUPS_PING table when the identities do not churn.
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: keycloak
  namespace: keycloak-lab
spec:
  serviceName: keycloak-headless
  replicas: 2
  podManagementPolicy: Parallel   # both pods start together, so they race to
                                  # register — which is the interesting case
  selector:
    matchLabels:
      app: keycloak
  template:
    metadata:
      labels:
        app: keycloak
    spec:
      # One pod per node. Two pods on one node would share a kernel and make the
      # 7800 blocking experiment meaningless.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: keycloak
      containers:
        - name: keycloak
          image: quay.io/keycloak/keycloak:26.7.0
          # "start", not "start-dev". Dev mode forces cache=local and there is
          # no cluster to form at all.
          args: ["start"]
          ports:
            - containerPort: 8080
              name: http
            - containerPort: 9000
              name: management
            - containerPort: 7800
              name: jgroups
          env:
            - name: KC_DB
              value: postgres
            - name: KC_DB_URL
              value: jdbc:postgresql://postgres:5432/keycloak
            - name: KC_DB_USERNAME
              value: keycloak
            - name: KC_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: keycloak-lab-secrets
                  key: POSTGRES_PASSWORD

            # Settings confirmed by the two-hop header measurement.
            # KC_HOSTNAME carries the full external URL, which pins scheme and
            # host for issuer and redirect URLs regardless of headers.
            # KC_PROXY_HEADERS is the separate opt-in that lets the forwarded
            # client address through — the same kind of switch as Spring's
            # forward-headers-strategy. See docs/two-hop-proxy-header-contract.md.
            - name: KC_HOSTNAME
              value: https://auth.hyeonworks.com
            - name: KC_HOSTNAME_STRICT
              value: "true"
            - name: KC_PROXY_HEADERS
              value: xforwarded
            - name: KC_HTTP_ENABLED
              value: "true"

            - name: KC_HEALTH_ENABLED
              value: "true"
            - name: KC_METRICS_ENABLED
              value: "true"

            # Without an explicit cap the JVM sizes its heap from the container
            # limit and this lab has roughly 3.8GB of guest headroom in total.
            - name: JAVA_OPTS_KC_HEAP
              value: "-Xms256m -Xmx512m"

            - name: KC_BOOTSTRAP_ADMIN_USERNAME
              value: admin
            - name: KC_BOOTSTRAP_ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: keycloak-lab-secrets
                  key: KC_BOOTSTRAP_ADMIN_PASSWORD

          # Keycloak serves health and metrics on the management port (9000),
          # not on 8080, since version 25.
          startupProbe:
            httpGet:
              path: /health/started
              port: management
            periodSeconds: 10
            failureThreshold: 60      # first boot runs an implicit build
          readinessProbe:
            httpGet:
              path: /health/ready
              port: management
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health/live
              port: management
            periodSeconds: 30
          resources:
            requests:
              memory: 640Mi
              cpu: 100m
            limits:
              memory: 900Mi
---
# Headless service. Not required for jdbc-ping discovery, which goes through the
# database, but it gives each pod a stable DNS name for direct inspection.
apiVersion: v1
kind: Service
metadata:
  name: keycloak-headless
  namespace: keycloak-lab
spec:
  clusterIP: None
  selector:
    app: keycloak
  ports:
    - port: 8080
      targetPort: http
      name: http
    - port: 9000
      targetPort: management
      name: management
---
apiVersion: v1
kind: Service
metadata:
  name: keycloak
  namespace: keycloak-lab
spec:
  selector:
    app: keycloak
  ports:
    - port: 8080
      targetPort: http
      name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak
  namespace: keycloak-lab
spec:
  ingressClassName: traefik
  rules:
    - host: auth.hyeonworks.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080

deploy/lab/k8s/bff-redis.yaml — B층. BFF 2 replica · Redis · 두 저장소 설정. 비밀 값 1곳을 가렸다.

# BFF (2 replicas) + Redis, for the B-layer experiments.
#
# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
# configured, and the only honest way to answer is to look at a running instance
# that has been given nothing. Redis is deployed alongside but left unused until
# B-1 turns it on.
#
#   kubectl apply -f deploy/lab/k8s/bff-redis.yaml
#
# Image comes from the workstation, not a registry:
#   docker build -t keycloak-pattern-bff:lab bff/
#   docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
#   (repeat for kc-lab-2)
# so imagePullPolicy must stay Never on both replicas.
apiVersion: v1
kind: Secret
metadata:
  name: bff-secrets
  namespace: keycloak-lab
type: Opaque
stringData:
  # Matches the client created with kcadm in the keycloak-patterns realm.
  # Base64 in etcd is not encryption — see D-3.
  KEYCLOAK_CLIENT_SECRET: <평문 client secret 14자>
---
# Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
# nothing here, because /data is the container filesystem and dies with the
# container — the appendonlydir was created and then thrown away. Persistence
# configuration without a volume is decoration.
#
# So the volume comes first, and only then does `--appendonly yes` mean anything.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-data
  namespace: keycloak-lab
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: keycloak-lab
spec:
  replicas: 1
  selector:
    matchLabels: { app: redis }
  template:
    metadata:
      labels: { app: redis }
    spec:
      # Same node as postgres so a node-loss experiment takes both stores at
      # once, matching how A-4 was set up.
      nodeSelector:
        kubernetes.io/hostname: kc-lab-2
      containers:
        - name: redis
          image: redis:7.4-alpine
          # appendfsync everysec 이 기본값이다 — 1초 분량을 잃을 수 있다.
          # Keycloak 의 synchronous_commit OFF(A-3)와 같은 모양의 트레이드오프다.
          args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
          ports:
            - containerPort: 6379
              name: redis
          readinessProbe:
            exec: { command: ["redis-cli", "ping"] }
            initialDelaySeconds: 3
          volumeMounts:
            - name: data
              mountPath: /data
          resources:
            requests: { memory: 32Mi, cpu: 20m }
            limits:   { memory: 128Mi }
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: keycloak-lab
spec:
  selector: { app: redis }
  ports:
    - port: 6379
      targetPort: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bff
  namespace: keycloak-lab
spec:
  # Two replicas is the whole point: Q1 and Q2 only exist because a request can
  # land on an instance that did not handle the login.
  replicas: 2
  selector:
    matchLabels: { app: bff }
  template:
    metadata:
      labels: { app: bff }
    spec:
      # Spread across both nodes so "the other instance" is genuinely another
      # machine, not another process on the same kernel.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: { app: bff }
      # 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의
      # 환경변수를 자동 주입한다: REDIS_PORT=tcp://10.43.57.116:6379.
      # 그것이 application.yml 의 ${REDIS_PORT:6379} 를 덮어써서 기동이 실패했다.
      #   Failed to bind properties under 'spring.data.redis.port' to int:
      #     Value: "tcp://10.43.57.116:6379"
      # 이 주입 자체를 끄는 것이 근본 처방이다. 이름을 바꿔 피하면 다음 사람이
      # 같은 함정에 다시 빠진다.
      enableServiceLinks: false
      containers:
        - name: bff
          image: keycloak-pattern-bff:lab
          imagePullPolicy: Never
          ports:
            - containerPort: 8083
              name: http
          env:
            # The browser is redirected to the public name; the BFF calls the
            # token endpoint over the cluster network. Getting these two the same
            # way round is what the 2-hop header experiment was about.
            - name: KC_ISSUER_EXTERNAL
              value: https://auth.hyeonworks.com/realms/keycloak-patterns
            - name: KC_ISSUER_INTERNAL
              value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
            # echo 는 header-lab 네임스페이스의 8081 이다. 다른 네임스페이스의
            # 서비스는 <svc>.<ns>.svc 로 부른다. 이름을 틀리면 500 이 나는데
            # 원인은 UnresolvedAddressException 이지 토큰 문제가 아니다.
            - name: RESOURCE_API_BASE_URL
              value: http://echo.header-lab.svc:8081
            - name: KEYCLOAK_CLIENT_SECRET
              valueFrom:
                secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET }
            # Spring needs to know it is behind TLS termination, for the same
            # reason Keycloak needs KC_PROXY_HEADERS. Without it the redirect_uri
            # it builds comes back as http:// and Keycloak rejects it.
            - name: SERVER_FORWARD_HEADERS_STRATEGY
              value: native
            # B-1: Application Session 을 Redis 로 옮긴다.
            # OAuth2AuthorizedClient 는 이것으로 옮겨지지 않는다 — 조회 키가
            # 다르기 때문이며, B-0 에서 확인한 사실이다.
            - name: SPRING_SESSION_STORE_TYPE
              value: redis
            - name: REDIS_HOST
              value: redis.keycloak-lab.svc
            - name: REDIS_PORT
              value: "6379"
            # B-2: authorized client 는 PostgreSQL 로. 세션(Redis)과 다른
            # 저장소를 쓰는 것이 Q3 가 말한 "각각 설계한다"의 실물이다.
            - name: BFF_DB_URL
              value: jdbc:postgresql://postgres.keycloak-lab.svc:5432/keycloak
            - name: BFF_DB_USER
              value: keycloak
            - name: BFF_DB_PASSWORD
              valueFrom:
                secretKeyRef: { name: keycloak-lab-secrets, key: POSTGRES_PASSWORD }
            - name: JAVA_TOOL_OPTIONS
              value: "-Xms128m -Xmx320m"
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: http }
            initialDelaySeconds: 20
            failureThreshold: 30
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: http }
            initialDelaySeconds: 60
          resources:
            requests: { memory: 320Mi, cpu: 100m }
            limits:   { memory: 512Mi }
---
apiVersion: v1
kind: Service
metadata:
  name: bff
  namespace: keycloak-lab
spec:
  selector: { app: bff }
  ports:
    - port: 8083
      targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: bff
  namespace: keycloak-lab
spec:
  ingressClassName: traefik
  rules:
    - host: app1.hyeonworks.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: bff
                port:
                  number: 8083

★ 2026-09-17 에 Redis 를 내리기 전과 후를 같은 세 줄로 쟀다(observed). 세 그룹이 어디서 갈리는지가 이 절의 전부다.

                            Redis 살아 있을 때   Redis 0대일 때
/actuator/health                 UP                 DOWN   ← redis: RedisConnectionFailureException
/actuator/health/readiness       UP                 UP     ← kubelet 이 보는 경로
/actuator/health/liveness        UP                 UP

파드                             1/1 Running        1/1 Running (둘 다)
Service 엔드포인트                ready true,true    ready true,true

Redis 가 통째로 사라졌는데 쿠버네티스는 아무것도 안 한다. 합산 healthDOWN 이고 kubelet 이 보는 readinessUP 이라 Service 가 두 파드로 트래픽을 계속 보낸다. /actuator/health 를 프로브로 걸었다면 두 파드가 동시에 빠져 전면 장애가 됐을 것이고, readiness 로 건 지금은 아무 신호도 안 난다 — 어느 쪽이 맞는지가 아니라 무엇을 고르면 무엇을 못 보게 되는지가 이 세 줄에 있다. Redis 를 되살리자 health 가 다시 UP 이 됐다(observed).

deploy/lab/k8s/b7-oauth2-proxy.yaml — B-7 · B-7a. oauth2-proxy 와 cookie secret 둘. 비밀 값 3곳을 가렸다.

# Experiment B-7 — oauth2-proxy, to measure how replicas share a cookie secret
# and what happens when it is rotated (Q1, unknown 7).
#
# This is a different shape of problem from the BFF. The BFF keeps state on the
# server, so the question was "which store". oauth2-proxy keeps no server state
# at all: the whole session rides in a cookie that is signed and encrypted with
# --cookie-secret. So there is nothing to share and nothing to lose on restart —
# instead, every replica must hold the *same* secret, and changing it invalidates
# every cookie at once.
#
#   kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
#
# app2.hyeonworks.com is borrowed from Grafana for the duration of this
# experiment; the certificate only covers auth / app1 / app2, so a fourth name
# is not available. Grafana's Ingress is restored afterwards.
apiVersion: v1
kind: Secret
metadata:
  name: oauth2-proxy-secrets
  namespace: keycloak-lab
type: Opaque
stringData:
  # oauth2-proxy requires exactly 16, 24 or 32 bytes. This is the value whose
  # rotation the experiment is about.
  COOKIE_SECRET_A: "<평문 cookie secret 32자 — A>"
  COOKIE_SECRET_B: "<평문 cookie secret 32자 — B>"
  CLIENT_SECRET: <평문 client secret 16자>
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy
  namespace: keycloak-lab
spec:
  # Two replicas is the point: Q1 asks how they share the secret.
  replicas: 2
  selector:
    matchLabels: { app: oauth2-proxy }
  template:
    metadata:
      labels: { app: oauth2-proxy }
    spec:
      # See B-1: Kubernetes injects <SVCNAME>_PORT as a tcp:// URL and it
      # collides with ordinary configuration names.
      enableServiceLinks: false
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: { app: oauth2-proxy }
      containers:
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
          args:
            - --provider=oidc
            - --oidc-issuer-url=https://auth.hyeonworks.com/realms/keycloak-patterns
            - --client-id=oauth2-proxy
            - --redirect-url=https://app2.hyeonworks.com/oauth2/callback
            - --email-domain=*
            - --http-address=0.0.0.0:4180
            # The upstream is the same echo app the B-4 header experiment used,
            # so what the proxy forwards can be read straight off the response.
            - --upstream=http://echo.header-lab.svc:8081
            # ★ 이 옵션을 켜면 세션(=쿠키)에 access token 이 들어간다.
            # 그러면 Set-Cookie 가 커져 프록시 앞단에서 502 가 났다.
            # B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타난 것이다.
            # - --pass-authorization-header=true
            - --set-xauthrequest=true
            - --reverse-proxy=true
            - --cookie-secure=true
            # One hour, matching the value Q1 records for the current setup.
            - --cookie-expire=1h
            - --skip-provider-button=true
            # ★ 쿠키에 세션 전체를 담으면 Set-Cookie 가 커지고, 그 응답이
            # 앞단 nginx 의 proxy_buffer 를 넘겨 502 가 났다(측정됨).
            # Redis 로 옮기면 쿠키에는 티켓만 남는다 — 그리고 그 순간
            # "replica 가 secret 을 공유해야 한다"는 문제의 성격도 바뀐다.
            - --session-store-type=redis
            - --redis-connection-url=redis://redis.keycloak-lab.svc:6379
          env:
            - name: OAUTH2_PROXY_CLIENT_SECRET
              valueFrom:
                secretKeyRef: { name: oauth2-proxy-secrets, key: CLIENT_SECRET }
            # Which of the two secrets is in use is switched here. Both replicas
            # read the same key, which is exactly the sharing Q1 asks about.
            - name: OAUTH2_PROXY_COOKIE_SECRET
              valueFrom:
                secretKeyRef: { name: oauth2-proxy-secrets, key: COOKIE_SECRET_A }
          ports:
            - containerPort: 4180
              name: http
          readinessProbe:
            httpGet: { path: /ping, port: http }
            initialDelaySeconds: 5
          resources:
            requests: { memory: 32Mi, cpu: 20m }
            limits:   { memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
  name: oauth2-proxy
  namespace: keycloak-lab
spec:
  selector: { app: oauth2-proxy }
  ports:
    - port: 4180
      targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: oauth2-proxy
  namespace: keycloak-lab
spec:
  ingressClassName: traefik
  rules:
    - host: app2.hyeonworks.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: oauth2-proxy
                port:
                  number: 4180

deploy/lab/k8s/echo.yaml — B-4 가 쓰는 echo 앱. header-lab 네임스페이스.

# Header echo workload for the two-hop proxy contract measurement.
#
#   browser -> host nginx (TLS termination) -> Traefik -> this pod
#
# The image is built from backend/ and imported straight into each node's
# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
apiVersion: v1
kind: Namespace
metadata:
  name: header-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo
  namespace: header-lab
spec:
  replicas: 2
  selector:
    matchLabels:
      app: echo
  template:
    metadata:
      labels:
        app: echo
    spec:
      # One replica per node so the sticky-session switch on the host nginx
      # upstream has something observable to route between.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: echo
      containers:
        - name: echo
          image: keycloak-pattern-api:lab
          imagePullPolicy: Never
          ports:
            - containerPort: 8081
              name: http
          env:
            - name: SERVER_PORT
              value: "8081"
            # "none" makes the app report the raw connection, so scheme/secure/
            # requestUrl show what arrives without any forwarded-header handling.
            # Set to "native" and redeploy to see the same request interpreted
            # with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
            # same opt-in, which is why measuring both sides matters here.
            - name: SERVER_FORWARD_HEADERS_STRATEGY
              value: "native"
            # The JVM sizes its heap from the container limit, not the host.
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=70"
            # /api/echo is permitAll, so the JWT decoder is never exercised.
            # These stay pointed at the future Keycloak service name.
            - name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
              value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
            - name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
              value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            initialDelaySeconds: 15
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 45
            periodSeconds: 15
          resources:
            requests:
              memory: 320Mi
              cpu: 100m
            limits:
              memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
  name: echo
  namespace: header-lab
spec:
  selector:
    app: echo
  ports:
    - port: 8081
      targetPort: http
      name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echo
  namespace: header-lab
spec:
  # k3s ships Traefik as the default ingress controller. Keeping it is what
  # makes this lab a faithful two-hop replica.
  ingressClassName: traefik
  rules:
    - host: app1.hyeonworks.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: echo
                port:
                  number: 8081

deploy/lab/k8s/echo-network-policy.yaml — 2홉 헤더 실험이 우회 경로를 닫은 방법.

# Restrict who may reach the echo pods.
#
# Traefik is configured to trust X-Forwarded-* from the whole pod CIDR, and the
# app's Tomcat valve trusts every private range by default. Both are IP-range
# decisions, so any pod in the cluster can forge those headers by talking to the
# Service directly and bypassing Traefik entirely. Measured, not hypothetical:
#
#   kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
#     curl -s http://echo:8081/api/echo -H 'X-Forwarded-Host: evil.example.com'
#   → serverName evil.example.com, remoteAddr 1.2.3.4
#
# A NetworkPolicy closes that path. It selects by label rather than IP, so it
# survives pod restarts and rescheduling — unlike the trustedIPs list, which
# could not name Traefik because its IP changes.
#
# "Trusting forwarded headers" and "guaranteeing a proxy sits in front" are a
# pair. Doing only the first leaves this hole.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: echo-allow-traefik-only
  namespace: header-lab
spec:
  podSelector:
    matchLabels:
      app: echo
  policyTypes:
    - Ingress
  ingress:
    # The proxy itself. namespaceSelector and podSelector in one list item are
    # ANDed, so this is "traefik pods in kube-system" and nothing else.
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              app.kubernetes.io/name: traefik
      ports:
        - protocol: TCP
          port: 8081

    # kubelet readiness/liveness probes originate from the node, not from a pod,
    # so they need their own rule. Without it the probes fail and the pods are
    # restarted in a loop.
    #
    # The probe's source address is the node's flannel bridge (cni0), which
    # holds the first address of that node's /24:
    #   kc-lab-1  10.42.0.1     kc-lab-2  10.42.1.1
    # Listing them as /32 keeps this rule from re-admitting arbitrary pods,
    # which a broader 10.42.0.0/16 block would do and would undo the policy.
    #
    # Adding a node means adding its gateway here. Verify with:
    #   kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{end}'
    - from:
        - ipBlock:
            cidr: 10.42.0.1/32
        - ipBlock:
            cidr: 10.42.1.1/32
      ports:
        - protocol: TCP
          port: 8081

deploy/lab/k8s/observability.yaml — 관측 스택 전문. 위 「관측 스택은 직접 썼다」가 고른 400줄이 이것이다. 비밀 값 1곳을 가렸다.

# Prometheus + node-exporter + Grafana.
#
# Purpose: during a fault-injection experiment, know *which signal moved first*.
# Without a metrics store the only record is whatever scrolled past in a terminal,
# and "the cluster recovered in about a minute" is not a measurement.
#
#   kubectl apply -f deploy/lab/k8s/observability.yaml
#   kubectl -n observability rollout status deployment/prometheus --timeout=300s
#
# Placement decision — Prometheus and Grafana are pinned to the control-plane
# node (kc-lab-1). An observability stack must not share a failure domain with
# the thing it observes. With only two nodes that cannot be fully avoided, so the
# rule here is: the node that gets killed in experiments is the *agent*
# (kc-lab-2, holding keycloak-0 and postgres), and everything needed to watch
# that happen lives on the server node.
apiVersion: v1
kind: Namespace
metadata:
  name: observability
---
# Prometheus discovers scrape targets by querying the Kubernetes API, so it
# needs read access to nodes, services, endpoints and pods. Without this the
# kubernetes_sd_configs below silently return no targets.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: prometheus
  namespace: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: prometheus
rules:
  - apiGroups: [""]
    # nodes/proxy is required in addition to nodes/metrics: the kubelet job
    # reaches each node through the API server's proxy subresource
    # (/api/v1/nodes/<name>/proxy/metrics). Without it every kubelet target
    # fails with 403 Forbidden while the other jobs stay green — a partial
    # failure that is easy to miss unless the target list is checked.
    resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
    verbs: [get, list, watch]
  - nonResourceURLs: ["/metrics"]
    verbs: [get]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: prometheus
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: prometheus
subjects:
  - kind: ServiceAccount
    name: prometheus
    namespace: observability
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: prometheus-config
  namespace: observability
data:
  prometheus.yml: |
    global:
      # 15s is short for production but right here: a node loss should show up
      # within a couple of samples, not a minute later.
      scrape_interval: 15s
      evaluation_interval: 15s

    scrape_configs:
      # Prometheus scraping itself. Useful as a control: if this target is down,
      # the problem is Prometheus, not the thing being measured.
      - job_name: prometheus
        static_configs:
          - targets: ['localhost:9090']

      # Keycloak. Metrics live on the management port 9000, not 8080 — the same
      # split that the health probes use. KC_METRICS_ENABLED=true is already set
      # on the StatefulSet.
      #
      # Discovery is by endpoints rather than a static list because pod IPs
      # change on every restart; that was observed directly when the lab was
      # power-cycled and every pod came back with a new address.
      - job_name: keycloak
        kubernetes_sd_configs:
          - role: endpoints
            namespaces:
              names: [keycloak-lab]
        relabel_configs:
          - source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
            action: keep
            regex: keycloak-headless;management
          - source_labels: [__meta_kubernetes_pod_name]
            target_label: pod
          - source_labels: [__meta_kubernetes_pod_node_name]
            target_label: node

      # node-exporter, one per node via DaemonSet. This is what answers
      # "did the machine die or did the process die".
      - job_name: node-exporter
        kubernetes_sd_configs:
          - role: endpoints
            namespaces:
              names: [observability]
        relabel_configs:
          - source_labels: [__meta_kubernetes_service_name]
            action: keep
            regex: node-exporter
          - source_labels: [__meta_kubernetes_pod_node_name]
            target_label: node

      # The kubelet's own metrics, reached through the API server proxy so no
      # extra port needs opening.
      - job_name: kubelet
        scheme: https
        tls_config:
          ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
          insecure_skip_verify: true
        bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
        kubernetes_sd_configs:
          - role: node
        relabel_configs:
          - action: labelmap
            regex: __meta_kubernetes_node_label_(.+)
          - target_label: __address__
            replacement: kubernetes.default.svc:443
          - source_labels: [__meta_kubernetes_node_name]
            regex: (.+)
            target_label: __metrics_path__
            replacement: /api/v1/nodes/${1}/proxy/metrics
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: prometheus-data
  namespace: observability
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: prometheus
  namespace: observability
spec:
  replicas: 1
  strategy:
    type: Recreate            # RWO volume; two pods cannot mount it at once
  selector:
    matchLabels:
      app: prometheus
  template:
    metadata:
      labels:
        app: prometheus
    spec:
      serviceAccountName: prometheus
      # See the placement note at the top of this file.
      nodeSelector:
        node-role.kubernetes.io/control-plane: "true"
      securityContext:
        fsGroup: 65534        # the image runs as nobody and must own the volume
      containers:
        - name: prometheus
          image: prom/prometheus:v3.1.0
          args:
            - --config.file=/etc/prometheus/prometheus.yml
            - --storage.tsdb.path=/prometheus
            # 7 days is far more than an experiment needs and keeps the volume
            # small enough that it never becomes the reason a node fills up.
            - --storage.tsdb.retention.time=7d
            - --web.enable-lifecycle
          ports:
            - containerPort: 9090
              name: http
          volumeMounts:
            - name: config
              mountPath: /etc/prometheus
            - name: data
              mountPath: /prometheus
          readinessProbe:
            httpGet: { path: /-/ready, port: http }
            initialDelaySeconds: 10
          livenessProbe:
            httpGet: { path: /-/healthy, port: http }
            initialDelaySeconds: 30
          resources:
            requests: { memory: 256Mi, cpu: 50m }
            limits:   { memory: 640Mi }
      volumes:
        - name: config
          configMap:
            name: prometheus-config
        - name: data
          persistentVolumeClaim:
            claimName: prometheus-data
---
apiVersion: v1
kind: Service
metadata:
  name: prometheus
  namespace: observability
spec:
  selector:
    app: prometheus
  ports:
    - port: 9090
      targetPort: http
---
# node-exporter. A DaemonSet so every node reports, including one that is about
# to be killed — the last samples before it goes silent are the interesting part.
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-exporter
  namespace: observability
spec:
  selector:
    matchLabels:
      app: node-exporter
  template:
    metadata:
      labels:
        app: node-exporter
    spec:
      # Host namespaces: the point is to measure the machine, not the container.
      hostNetwork: true
      hostPID: true
      tolerations:
        - operator: Exists      # must also run on tainted nodes
      containers:
        - name: node-exporter
          image: prom/node-exporter:v1.8.2
          args:
            - --path.procfs=/host/proc
            - --path.sysfs=/host/sys
            - --path.rootfs=/host/root
            - --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+)($|/)
          ports:
            - containerPort: 9100
              name: metrics
              hostPort: 9100
          volumeMounts:
            - { name: proc,   mountPath: /host/proc, readOnly: true }
            - { name: sys,    mountPath: /host/sys,  readOnly: true }
            - { name: rootfs, mountPath: /host/root, readOnly: true, mountPropagation: HostToContainer }
          resources:
            requests: { memory: 32Mi, cpu: 20m }
            limits:   { memory: 96Mi }
      volumes:
        - { name: proc,   hostPath: { path: /proc } }
        - { name: sys,    hostPath: { path: /sys } }
        - { name: rootfs, hostPath: { path: / } }
---
apiVersion: v1
kind: Service
metadata:
  name: node-exporter
  namespace: observability
spec:
  clusterIP: None             # headless: Prometheus wants each pod, not a VIP
  selector:
    app: node-exporter
  ports:
    - port: 9100
      targetPort: metrics
      name: metrics
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: grafana
  namespace: observability
spec:
  replicas: 1
  selector:
    matchLabels:
      app: grafana
  template:
    metadata:
      labels:
        app: grafana
    spec:
      nodeSelector:
        node-role.kubernetes.io/control-plane: "true"
      containers:
        - name: grafana
          image: grafana/grafana:11.4.0
          ports:
            - containerPort: 3000
              name: http
          env:
            - name: GF_SECURITY_ADMIN_USER
              value: admin
            - name: GF_SECURITY_ADMIN_PASSWORD
              value: <평문 비밀번호 21자>
            # Grafana builds absolute URLs for redirects and asset paths. Behind
            # the nginx -> Traefik chain it must be told the external address,
            # for exactly the reason Keycloak needs KC_HOSTNAME. Without it,
            # login redirects come back as http://<pod-ip>:3000.
            - name: GF_SERVER_ROOT_URL
              value: https://app2.hyeonworks.com
          volumeMounts:
            - name: datasources
              mountPath: /etc/grafana/provisioning/datasources
          readinessProbe:
            httpGet: { path: /api/health, port: http }
            initialDelaySeconds: 15
          resources:
            requests: { memory: 128Mi, cpu: 50m }
            limits:   { memory: 320Mi }
      volumes:
        - name: datasources
          configMap:
            name: grafana-datasources
---
# Provisioning the datasource as a file means Grafana comes up already wired to
# Prometheus. Clicking through the UI would leave the configuration only in
# Grafana's own database, which is emptyDir here and disappears on restart.
apiVersion: v1
kind: ConfigMap
metadata:
  name: grafana-datasources
  namespace: observability
data:
  prometheus.yaml: |
    apiVersion: 1
    datasources:
      - name: Prometheus
        type: prometheus
        access: proxy
        url: http://prometheus.observability.svc:9090
        isDefault: true
---
apiVersion: v1
kind: Service
metadata:
  name: grafana
  namespace: observability
spec:
  selector:
    app: grafana
  ports:
    - port: 3000
      targetPort: http
---
# Grafana is published on app2.hyeonworks.com because that name is already in
# the wildcard-free certificate (auth / app1 / app2) and is otherwise unused.
# It moves when app2 is needed for the SSO experiment.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: grafana
  namespace: observability
spec:
  ingressClassName: traefik
  rules:
    - host: app2.hyeonworks.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: grafana
                port:
                  number: 3000

deploy/lab/k8s/traefik-forwarded-headers.yaml — Traefik 이 어느 대역의 forwarded 헤더를 믿는가.

# Make Traefik trust the X-Forwarded-* headers that the host nginx sets.
#
# Without this, Traefik rewrites every forwarded header from its own connection,
# which is plain HTTP on port 80. The application then sees scheme=http even
# though the browser connected over TLS. See docs/two-hop-proxy-header-contract.md.
#
# k3s installs Traefik through its bundled HelmChart, so values are overridden
# with a HelmChartConfig rather than by editing the deployment. k3s reconciles
# the chart and recreates the Traefik pod.
#
#   kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
#   kubectl -n kube-system rollout status deploy/traefik --timeout=180s
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    ports:
      web:
        forwardedHeaders:
          # Requests arriving from these sources keep their existing
          # X-Forwarded-* values instead of having them rewritten.
          #
          # 10.42.0.0/16 is the pod CIDR. It is required because the traefik
          # Service uses externalTrafficPolicy: Cluster, so svclb SNATs the
          # traffic and Traefik sees a pod-network address rather than the
          # host nginx address.
          #
          # The node/host range is deliberately absent. Because svclb SNATs,
          # the host nginx address never reaches Traefik — measured, not assumed.
          # Trusting a range that cannot appear only widens the surface.
          #
          # Trusting the whole pod CIDR still means any pod in the cluster could
          # forge these headers, which is why echo-network-policy.yaml restricts
          # who may reach the application at all.
          trustedIPs:
            - 10.42.0.0/16
      websecure:
        forwardedHeaders:
          trustedIPs:
            - 10.42.0.0/16

deploy/lab/k8s/a1-block-jgroups-transport.yaml — A-1 의 주입. 본문 A-1 절에도 같은 것이 실려 있다.

# Experiment A-1 — cut the JGroups transport (TCP 7800) while leaving discovery alone.
#
# The point is to separate two things that are easy to conflate:
#
#   discovery   how the nodes FIND each other   -> PostgreSQL JGROUPS_PING table
#   transport   how they actually TALK          -> TCP 7800
#
# Blocking only the transport produces a state that cannot happen on a single
# node: both members stay registered in the database, so each believes the other
# exists, yet no message gets through.
#
#   kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
#   kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
#
# NetworkPolicy is an ALLOWLIST, not a firewall with deny rules. There is no way
# to write "deny 7800". The moment a pod is selected by a policy carrying
# policyTypes: [Ingress], every inbound port is denied unless a rule permits it.
# So 7800 is blocked by *omission*: 8080 and 9000 are listed, 7800 is not.
#
# That makes the two allow rules load-bearing — get them wrong and the experiment
# measures a dead Keycloak instead of a partitioned cluster:
#
#   8080  the HTTP endpoint. Traefik, the other pod's REST calls, and the probe
#         traffic all arrive here.
#   9000  the management port: /health/started, /health/ready, /health/live and
#         /metrics. Losing it means the kubelet fails the readiness probe and
#         kills the pod — the cluster would break for the wrong reason.
#
# Both rules deliberately omit `from:`, which allows those ports from any source.
# Narrowing the source is not the subject here; the 2-hop experiment already
# established how to do that by label when it matters.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: a1-block-jgroups-transport
  namespace: keycloak-lab
spec:
  podSelector:
    matchLabels:
      app: keycloak
  policyTypes: [Ingress]
  ingress:
    - ports:
        - { port: 8080, protocol: TCP }   # HTTP — must stay open
        - { port: 9000, protocol: TCP }   # health + metrics — must stay open
    # 7800 is absent on purpose. That is the whole experiment.

게스트와 호스트 설정

deploy/lab/cloud-init/kc-lab.yaml.example — 게스트가 어떤 사용자·sudo 정책으로 뜨는지. 본문이 여러 번 기대는 「게스트는 무암호 sudo」가 여기서 온다.

#cloud-config
# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
# and bakes this into a CIDATA seed image.
#
# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
# git because plain_text_passwd is a credential, however disposable.
#
# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
# on a parse error: the guest boots as "localhost" with no user and no way in.
hostname: kc-lab-__NODE__
fqdn: kc-lab-__NODE__
manage_etc_hosts: true

users:
  - name: donghyeon
    groups: [sudo]
    shell: /bin/bash
    # NOPASSWD is required: the k3s installer and the fault-injection scripts
    # run non-interactively and would block on a password prompt.
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    # Console-only escape hatch. Without it, a cloud-init failure leaves a guest
    # that cannot be logged into at all, so its own failure log is unreadable.
    # ssh_pwauth stays false, so this never widens SSH exposure.
    lock_passwd: false
    plain_text_passwd: CHANGE_ME
    ssh_authorized_keys:
      # Lab host key: needed because automation runs from the lab host, where
      # agent forwarding is not available.
      - CHANGE_ME_LAB_HOST_PUBLIC_KEY
      # Workstation key: lets ProxyJump reach the guest directly.
      - CHANGE_ME_WORKSTATION_PUBLIC_KEY

ssh_pwauth: false
package_update: true
packages:
  - curl
  - nftables

deploy/lab/host/nginx-keycloak-lab.conf — 호스트 nginx. 2홉의 첫 홉이다.

# Lab entry point. Deployed on the lab host as
#   /etc/nginx/sites-available/keycloak-lab
# and symlinked from sites-enabled/.
#
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
#   include /etc/nginx/sites-enabled/*;
# inside its http { } block before this file has any effect.
#
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
# the Traefik instance running on each k3s node.

upstream k3s_traefik {
    # Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
    # ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
    # the interesting case: Infinispan still routes correctly, only slower.
    # ip_hash;
    server 192.168.122.11:80;
    server 192.168.122.12:80;
}

server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl default_server;
    http2 on;
    server_name _;

    # fullchain.pem, never cert.pem: omitting the intermediates passes on
    # desktop browsers and fails on mobile and curl.
    ssl_certificate     /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://k3s_traefik;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Port  443;

        # $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
        # boundary: a client-supplied X-Forwarded-For must be discarded, not
        # extended, or nothing downstream can rely on the value.
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Real-IP         $remote_addr;

        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

실험대를 세우고 점검하는 스크립트 네 개

deploy/lab/scripts/verify-lab.sh — 구축 완료 판정. lab is healthy 를 찍는다.

#!/usr/bin/env bash
# Confirm the lab infrastructure is intact. Run on the lab host.
#
# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
# request reached Traefik, which simply had no matching ingress rule. A 502 or a
# refused connection means the chain is broken somewhere.
set -uo pipefail

export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
fail=0

check() { # description, expected, actual
  if [ "$2" = "$3" ]; then printf '  ok    %-34s %s\n' "$1" "$3"
  else printf '  FAIL  %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
}

echo "== guests =="
for name in kc-lab-1 kc-lab-2; do
  check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
done

echo "== k3s =="
ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
check "nodes Ready" 2 "$ready"
lb="$(kubectl -n kube-system get svc traefik \
      -o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
check "traefik node IPs" 2 "$lb"

echo "== host nginx =="
check "service" active "$(systemctl is-active nginx)"
check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
for ip in $NODE_IPS; do
  check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
done

echo "== public entry point =="
for h in $HOSTS; do
  check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
  check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
done
check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"

echo
[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
exit "$fail"

deploy/lab/scripts/rebuild-seed.sh — 시드 ISO 를 다시 구워 풀에 올린다.

#!/usr/bin/env bash
# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
# Run on the lab host.
#
#   ./rebuild-seed.sh 1
#
# The same content lives in three places: the source YAML, the ISO, and the
# uploaded pool volume. Editing the YAML alone changes nothing, which is why
# this is a script and not a set of remembered commands.
#
# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
# per-instance modules once per instance-id, so an existing guest ignores it.
set -euo pipefail

N="${1:?usage: rebuild-seed.sh <1|2>}"
CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
POOL="${POOL:-default}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"

cd "$CLOUD_DIR"
src="kc-lab-${N}.yaml"
iso="seed-kc-lab-${N}.iso"
meta="meta-kc-lab-${N}"

[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }

# A fresh instance-id makes cloud-init treat the guest as new and re-run the
# per-instance modules.
printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
  "$N" "$(date +%s)" "$N" > "$meta"

# NoCloud looks for a volume labelled cidata holding files named exactly
# user-data and meta-data. -graft-points renames them inside the image so no
# staging directory is needed.
xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
  "/user-data=${src}" "/meta-data=${meta}"

size="$(stat -c%s "$iso")"
virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
virsh vol-upload --pool "$POOL" "$iso" "$iso"

echo "$iso published to pool '$POOL' ($size bytes)"
echo "attach it as a virtio disk, not a SATA cdrom:"
echo "  --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
echo "to the guest and cloud-init fails with no error anywhere."

deploy/lab/scripts/build-and-import.sh — 이미지를 두 노드의 containerd 로 반입한다.

#!/usr/bin/env bash
# Build the API image on this workstation and import it into each lab node's
# containerd.
#
# k3s does not run Docker and the lab has no registry, so images are shipped as
# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
# Every node needs its own copy because the scheduler may place the pod anywhere.
#
#   ./deploy/lab/scripts/build-and-import.sh
#   IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
set -euo pipefail

IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
NODES="${NODES:-kc-lab-1 kc-lab-2}"
LAB_HOST="${LAB_HOST:-test-server}"
CONTEXT="${CONTEXT:-backend}"

repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"

echo "==> building ${IMAGE} from ${CONTEXT}/"
docker build -t "$IMAGE" "$CONTEXT"

for node in $NODES; do
  echo "==> importing into ${node}"
  # Nested ssh: the workstation cannot reach the guests directly because they
  # sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
  # the kc-lab-* aliases.
  docker save "$IMAGE" \
    | ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
done

echo "==> verifying"
for node in $NODES; do
  printf '  %-10s ' "$node"
  ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
    | grep -c "$IMAGE" \
    | xargs -I{} echo "{} match(es)"
done

echo
echo "next: kubectl rollout restart -n header-lab deployment/echo"

deploy/lab/scripts/measure-proxy-headers.sh — 2홉 헤더 계약을 재는 장치.

#!/usr/bin/env bash
# Measure what the nginx -> Traefik chain actually delivers to the application.
#
# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
# runs two hops, so the forwarded headers are measured rather than assumed.
# Run from anywhere that can resolve the lab hostnames.
#
#   ./deploy/lab/scripts/measure-proxy-headers.sh
set -euo pipefail

HOST="${HOST:-app1.hyeonworks.com}"
URL="https://${HOST}/api/echo"

jqf() {
  if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
}

echo "=== 1. baseline: what the app sees for a normal request ==="
curl -s "$URL" | jqf '{
  scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
  forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
}' 2>/dev/null || curl -s "$URL"

echo
echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
echo "    a trusted boundary must overwrite these, not append to them"
curl -s "$URL" \
  -H 'X-Forwarded-For: 1.2.3.4' \
  -H 'X-Forwarded-Proto: http' \
  -H 'X-Forwarded-Host: evil.example.com' \
  -H 'X-Real-IP: 1.2.3.4' \
  | jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null

echo
echo "=== 3. which pod answered (host nginx upstream distribution) ==="
for _ in 1 2 3 4; do
  curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
done

echo
echo "=== 4. plain HTTP is redirected, not proxied ==="
curl -s -o /dev/null -w '  http  -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"

2026-09-11 추가 측정 — 워크로드 종류가 클러스터에 미치는 영향

실험 26건을 끝낸 뒤, 재현 가이드를 다시 따라가다 experiment-plan.md 에 미해결로 남아 있던 항목 하나가 풀렸다. 「JGROUPS_PING 의 유령 행이 어떻게 정리되는가 — 자동인가 수동인가」다.

무엇을 쟀나

Keycloak 2노드를 StatefulSet 과 Deployment 두 형태로 각각 띄우고, 각 형태에서 파드 하나를 정상 종료(kubectl delete pod)와 강제 종료(--grace-period=0 --force)로 한 번씩 죽였다. 매번 JGROUPS_PING 테이블을 조회했다. Deployment 는 strategy.rollingUpdatemaxSurge: 0 · maxUnavailable: 1 로 명시해 StatefulSet 의 순차 교체를 흉내 냈다.

관측 (observed)

StatefulSet Deployment
클러스터 형성 2행, 코디네이터 선출 같음
정상 종료 후 죽은 행 사라짐, 새 행 생성 같음
강제 종료(SIGKILL) 후 유령 행 없음 같음
복구 후 뷰 2명 같음
address …0002 → …0003 → …0004 …0005 → …0007 → …0008
name keycloak-0-60375 keycloak-85469cb4d-cfzkt-24175

코디네이터 로그가 정리 시점을 그대로 보여준다.

07:40:28  ISPN000094: new cluster view ... |4] (1) [keycloak-1-36736]
07:40:28  ISPN100001: Node keycloak-0-60375 left the cluster
07:40:48  ISPN000094: new cluster view ... |5] (2) [keycloak-1-36736, keycloak-0-16105]
07:40:48  ISPN100000: Node keycloak-0-16105 joined the cluster

결론 (observed → inferred)

(1) 유령 행은 자동으로 정리된다. 정리 주체는 떠나는 노드가 아니라 남아 있는 코디네이터이고, SIGKILL 로 죽여도 동작한다(observed). 뷰 변경 시점과 행 소멸 시점이 같은 초에 찍힌다. 미해결 항목은 「자동」으로 닫힌다.

(2) StatefulSet 이라도 같은 행을 덮어쓰지 않는다. address 는 순번으로 매번 새로 발급되고 name 의 접미사도 바뀐다(observed). 안정적인 것은 keycloak-0 이라는 접두사뿐이다.

(3) 그래서 StatefulSet 의 근거는 둘로 좁혀진다 (inferred) — 로그와 JGROUPS_PING 을 접두사로 대조할 수 있다는 것, 그리고 A-4·A-8 이 「keycloak-0 을 죽인다」로 써질 수 있다는 것. 둘 다 클러스터 동작이 아니라 사람이 읽고 지목하기 위한 성질이다. 세션을 DB 에 두고 롤링 정책을 명시하면 Deployment 도 성립한다.

한계 — 정상 종료와 SIGKILL 만 쟀다. 노드 상실(A-4 형태)에서 코디네이터 자신이 죽는 경우는 이번에 재지 않았다(unknown). 그 경우 정리 주체가 사라지므로 결과가 다를 수 있다.


2026-09-17 재현 — 어디까지 밟았고 무엇이 막았나

기반 가이드 7단계로 실험대를 철거하고 새로 세운 뒤 26편을 순서대로 밟았다. 밟은 범위를 편마다 적어 둔다 — 못 밟은 것을 밟은 것처럼 읽히지 않게 하려는 것이다.

밟은 정도
끝까지 A-0 · A-1 · A-2 · A-3 · A-4 · A-5 · A-6 · A-7 · A-7a · A-8 · B-3 · B-4 · B-5 · C-2 · D-1 · D-2 · D-3
되는 데까지 (각 편의 「이 실험대에서 아직 못 밟은 단계」가 남은 것을 적는다) B-0 · B-1 · B-2 · B-6 · B-7 · B-7a · C-1 · D-4 · D-4a

막은 것은 하나로 모인다 — https://auth.hyeonworks.com 이 서지 않는다. 처음에는 셋이 필요했고, 같은 날 둘이 풀려 지금 남은 것은 인증서 하나다.

무엇 지금
와일드카드 인증서 아직 없다. DNS-01 에 Cloudflare API 토큰이 필요하고, 그 값은 이 저장소에 두지 않는다. 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않으므로 다른 길이 없다
밖에서 실험대에 닿는 길 열렸다. 호스트의 libvirt guest_input 구멍과 유닛의 ExecStartPost 가 들어갔고, 밖에서 친 http200 이다
클러스터 안에서 그 이름에 닿는 길 놓았다. 03 이 nginx 를 엣지로 옮기면서 끊겼던 것을 이날 처음 쟀고, CoreDNS 에 서버 블록 하나를 더해 파드에서 엣지로 풀리게 했다

가운데와 아래 둘은 이날 실측으로 드러났다. 아래 것은 인증서를 받아도 따로 놓아야 하는 단계라, 그것을 모른 채 토큰만 넣었으면 같은 자리에서 다시 막혔다.

그 둘이 없으면 인가 코드 흐름(브라우저 로그인)이 본체인 구간과 인증서 발급 자체가 본체인 두 편이 성립하지 않는다. 대신 그 구간이 왜 막히는지는 추측이 아니라 실측으로 적었다oauth2-proxyCrashLoopBackOff 원문, echoissuer=000, certbot certificatesNo certificates found 가 각 편에 있다.

재현 가이드 26편과, 그것을 따라가다 드러난 결함

발견을 적은 문서와 별개로, 직접 쳐서 다시 만드는 가이드가 ../source/docs/guides/experiments/ 에 26편 있다. 각 편은 기준선 → 주입 → 주입 검증 → 관찰 → 복구 구조이고, 주입 검증이 핵심인 편이 많다 — 이 실험대에서 주입은 아홉 번 조용히 실패했고, 실패한 주입은 「아무 일도 없었다」로 보여 「영향이 없다」와 구별되지 않기 때문이다.

2026-09-11 에 가이드를 실제로 따라가며 감사한 결과, 재현을 막는 결함이 계열로 발견됐다(observed).

결함 규모 왜 막히나
실험 가이드 전편이 sudo kubectl 을 쓴다 905건 기반 가이드는 kubeconfig 를 lab host 의 ~/.kube/config 에 둔다. sudo 는 root 환경이라 그 파일을 못 본다 — 기반 7단계를 끝낸 독자가 첫 명령부터 막힌다
그중 게스트에서 도는 것(sudo 가 맞는 것) 0건 전부 잘못된 것이었다
기반 가이드가 저장소를 lab host 에 클론하지 않는다 05·06 kubectl apply -f deploy/... 가 상대경로인데 클론 단계가 없었다
해당 단계에 없는 리소스를 조회한다 05 -l app=bff — BFF 는 B-0 에서 처음 뜬다. 출력도 나중 단계에서 복사된 것이었다

공통 원인은 하나다 (inferred) — 가이드가 순서대로 따라갔을 때 도는지를 검증하지 않고, 나중 시점의 환경에서 확인한 명령과 출력을 그 자리에 적었다. 개별 명령은 전부 실제로 돌았던 것이라 틀려 보이지 않는다. 틀린 것은 명령이 아니라 그 명령이 놓인 위치다.

가이드가 스스로 정한 읽기 규약

26편 위에는 규약을 적은 README 가 두 장 있다 (guides/README.md · guides/experiments/README.md). 앞의 결함 표가 「규약을 안 지킨 자리」를 센 것이라면, 이 절은 그 규약이 무엇이었는지를 옮긴다. 규약을 안 적으면 결함 표의 「왜 막히나」가 무엇을 기준으로 막혔다고 말하는지가 사라진다.

두 종류의 명령을 구별해 적는다

실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도 다르다. 가이드는 둘을 섞지 않는다.

표시 무엇인가
하기 · 확인 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩
근거를 재려면 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다

README 가 든 예는 nginx 에러 로그가 잘렸을 때다 — 실무자는 잘린 걸 보고 access 로그로 넘어가고, 길이를 재서 2048 인지 확인하는 것은 몰라서 재는 것이며 알면 재지 않는다.

자리표시자를 두지 않는다

<토큰> 처럼 적어 두면 그 값을 어디서 가져오는지가 문서 밖으로 나간다. 가이드는 값을 찾는 명령을 함께 적는다.

TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN} 자"      # 값이 아니라 길이만 확인한다

비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 터미널 스크롤백과 화면 공유에 남기 때문이다. 이 규약이 D-3 에서 다시 쓰인다.

어느 기계에서 치는가 — 그리고 거기서 나오는 조용한 실패

이 실험대에는 셸이 네 개 있고 같은 명령이 어디서 도느냐에 따라 결과가 달라진다. 그래서 모든 코드 블록 앞에 어디서 치는지를 붙인다.

표시 어느 기계 어떻게 들어가나
[워크스테이션] 평소 쓰는 개발 머신
[lab host] test-server. virsh 가 도는 곳 ssh test-server
[kc-lab-edge] 엣지 게스트 — nginx · certbot ssh kc-lab-edge (lab host 에서만)
[kc-lab-1] k3s server 게스트 ssh kc-lab-1 (lab host 에서만)
[kc-lab-2] k3s agent 게스트 ssh kc-lab-2 (lab host 에서만)

기본은 [lab host] 다. 게스트는 libvirt NAT(192.168.122.0/24) 안에 있어서 워크스테이션에서 직접 닿지 않는다.

[워크스테이션] $ ping -c1 192.168.122.11
1 packets transmitted, 0 received, 100% packet loss     # 경로가 없다

게스트 안에 들어가서 다음 단계를 치지 않는다. 게스트에는 lab host 의 개인키도 ~/.ssh/config 도 없으므로 게스트 안에서 ssh kc-lab-1 을 치면 이렇게 끝난다.

[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
Host key verification failed.

이 실패가 조용한 이유 — 위 명령을 TOKEN=$(...) 로 감싸면 오류는 stderr 로 흘러가고 TOKEN 에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다. 그래서 게스트에 로그인한 채 다음 단계를 치면 몇 단계 뒤에 가서야 증상이 나타난다.

이것이 이 실험대의 열 번째 조용한 실패다. 앞의 아홉은 주입이 안 걸린 것이었고 이것은 명령이 엉뚱한 기계에서 돈 것인데, 「아무 말도 안 하고 빈 값이 담긴다」는 모양은 같다. sudo kubectl 905건도 같은 종류다.

그래서 가이드는 게스트에 로그인하지 않고 lab host 에서 ssh kc-lab-1 '...' 형태로 원격 실행한다. 셸이 하나뿐이면 「지금 어디 있더라」가 생기지 않는다.

기반 7단계와 그 통과 조건

앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고 그 상태를 확인하는 명령이 있다. 그것이 통과해야 다음으로 넘어간다.

단계 무엇을 세우나 끝나면 확인되는 것
00 lab host 가상화 준비 virsh list 가 돈다
01 VM 세 대 (엣지 + k3s 2노드) 세 게스트에 SSH 가 붙는다
02 k3s server + agent kubectl get nodes 에 둘 다 Ready
03 엣지 nginx 라우팅 + 호스트 DNAT 밖에서 요청이 파드까지 닿는다
04 Let's Encrypt https:// 가 열리고 체인이 4단계
05 Keycloak 2노드 + PostgreSQL 관리 콘솔 로그인이 된다
06 Prometheus · Grafana vendor_cluster_size 가 2
experiments 실험 26건 각 실험의 판정 기준

실험 26편의 공통 전제는 05 까지이고, 지표를 보는 실험은 06 도 필요하다.

이 가이드가 검증된 방식

읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다. 버전·IP·메모리 같은 값은 지어내지 않았다.

만드는 명령은 다르다. VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는 실험대가 없어지므로, 그 명령들은 실제로 구축할 때 쓴 것을 그대로 옮겼고 결과 상태를 확인하는 것으로 대신했다. 어느 쪽인지 각 단계에 표시한다.

각 단계 끝의 「막히면」 표에 적힌 증상은 전부 이 실험대가 실제로 겪은 것이고 원문은 evidence/ 에 있다. 지어낸 실패 사례는 없다.

각 편의 구조와 순서

이 가이드가 끝나면 · 전제 · 주의 · 표시 규약
0  왜 이 실험인가
1  기준선          ← 주입 전에 평시를 잡는다
2  주입
3  주입 검증        ← 여기가 대부분의 편에서 가장 중요하다
4  관찰
5  복구
막히면 · 다음

3번이 가장 중요한 이유는 앞에서 적은 그대로다 — 이 실험대에서 주입은 아홉 번 조용히 실패했다.

순서에는 의존 관계가 있다. A-0 을 먼저 한다 — 나머지 A층 결론이 전부 거기서 확인한 「세션이 어디 있는가」 위에 서 있다.

A-0 ─┬─ A-1 ─┬─ A-5
     │        └─ A-6
     ├─ A-2 ── A-3 ── D-1 ── D-2
     ├─ A-4
     ├─ A-8
     └─ A-7 ── A-7a          ← A층을 다 한 뒤 설정 하나만 바꿔 재실행한다

B-0 ── B-1 ─┬─ B-2 · B-3 · B-4 · B-5 · B-6
            └─ B-7 ── B-7a

C-1 ── C-2                    D-3 · D-4 ── D-4a  (언제든 독립적으로)

A-7 을 A층 마지막에 두는 이유 — 앞의 실험을 다 마친 뒤 설정 하나만 바꿔 재실행하면 같은 주입에 대한 정반대 결과를 한 벌로 얻는다.

★ B-0 은 판정이 아니라 배포다. BFF 와 Redis 를 여기서 처음 띄우고, B-1~B-7 전부가 이것을 전제로 한다. 기반 7단계(00~06)에는 BFF·Redis 가 없다 — 그래서 05 를 끝낸 시점에 kubectl get pods -l app=bff 를 쳐도 아무것도 안 나오는 것이 정상이다. A 층은 BFF 없이 Keycloak 만으로 돈다. 앞의 결함 표가 센 「해당 단계에 없는 리소스를 조회한다」가 바로 이 자리를 어긴 것이다.

안전

각 편의 2번(주입)부터 상태가 바뀐다. 모든 편이 되돌리는 명령을 주입보다 먼저 보여 주고, 5번에서 원상복구를 확인한다.

호스트(test-server)에서 하는 일은 sudo 비밀번호가 필요해 사람이 직접 쳐야 한다. D-1 과 D-4 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다.

이 기록에 아직 없는 것

그림은 28건 모두 techviz 로 다시 만들었다. 원본 저장소의 손그림 28개는 ../source/docs/diagrams/ 에 그대로 있다.

형식만 바꾼 것이 아니라 처음부터 다시 그렸다. 이 저장소는 발행 SVG 안에 제목·부제· 설명 밴드를 넣지 못하게 하는데 손그림은 전부 캔버스 안에 제목과 설명 문단을 담고 있었다. 그래서 그림 안에는 이름만 남기고 문장은 <desc> 와 옆 문단으로 옮겼으며, 각 그림마다 문서의 줄 범위를 인용하는 VizSpec 을 쓰고 lint 를 통과시켰다.

lint 가 잡아낸 것 중 사람이 놓치기 쉬운 것 둘을 적어 둔다.

검사 무엇을 막았나
edge-through-node 화살표가 무관한 노드를 관통해 잘못된 인접을 암시하는 것
evidence-outside-prepared-context 그림이 다른 절의 내용을 근거로 대는 것

두 번째 때문에 그림 하나는 앵커를 옮겨야 했다. B-0 절에 앵커를 두고 B-2 의 내용을 인용하려다 막혔고, B-1 로 옮겨 세 절이 문맥에 들어오게 했다.

Studio 기록은 아직 쓰지 않았다. 이 문서까지가 SSOT 이고 tech-log-studio/ 아래 글감 추출과 기록 작성은 다음 단계이기 때문이다.


실험대가 쓴 개념 — 조사한 것

26건을 돌리면서 쓴 용어와, 그 용어를 실제로 설명한 곳을 대조했다. 이름만 쓰고 넘어간 것이 24개나 됐는데, conntrack 은 실험 문서에 35번, refresh token rotation 은 17번, JWKSLiquibase 는 각각 11번 나오는데 무엇인지 설명한 곳이 없었다.

여기서 그것들을 조사해 채우되, 시스템에서 확인할 수 있는 것은 확인하고 확인할 수 없는 외부 사실에는 출처를 달았다.

여덟 층이 받치는 것

이 프로젝트에는 주제 여섯과 글감 서른셋으로 분해 계약이 서 있다 (tech-log-studio/tech-log-tree.json). 위 여덟 층은 그것과 나란한 별개 구조가 아니라 각 주제가 서 있는 바닥이다.

계약의 주제 그 아래에 깔린 층
session-custody-across-nodes 5층 — 디스커버리 대 트랜스포트, 세션 두 겹
losing-a-node-or-the-store 1층 전체 · 3층(WAL·synchronous_commit) · 4층(축출 타이머)
where-application-state-lives 6층 전체 · 5층(refresh token rotation)
trust-handed-over-at-the-edge 2층(conntrack·netfilter) · 7층(oauth2-proxy 티켓)
operations-that-report-success 1층(systemd 일체) · 7층(SCT·백데이트) · 3층(Liquibase)
when-the-measurement-lies 8층 전체

operations-that-report-success 의 글감 둘(「새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다」, 「deploy 훅 하나가 그 공백을 1~2초로 줄였다」)은 systemd 를 설명하지 않고는 쓸 수 없는데, 1층이 그 바닥을 채운다.


0층. 가상화 — 「바닥」 아래에 있는 것

1층을 이 실험대의 바닥이라고 적었는데, 정확히는 호스트의 바닥이다. 위 여덟 층 중 2층부터 위는 전부 게스트 두 대 안에서 돌고, 게스트는 호스트에서 프로세스다. 번호를 다시 매기는 대신 아래에 한 층을 더한다.

이 층을 건너뛰면 호스트에서 읽은 숫자를 게스트의 숫자로 읽게 되고, 그 착각은 8층까지 그대로 올라간다.

게스트는 호스트에서 프로세스 하나다

무엇인가. libvirtd 가 게스트마다 qemu-system-x86_64 를 하나씩 띄운다. 호스트에서 보면 VM 은 특별한 무엇이 아니라 프로세스 두 개이고, VM 이 쓰는 메모리는 그 프로세스의 RSS 다. vCPU 도 마찬가지로 그 프로세스의 스레드라서 htop 에서 스레드를 켜 두면 게스트마다 두 줄씩 더 나온다.

export LIBVIRT_DEFAULT_URI=qemu:///system
ps -eo rss,args --sort=-rss | grep '[q]emu-system'   # 호스트에서 본 VM

htop 에서는 F5 트리 뷰가 libvirtd 아래 qemu-system 이 달린 모양을 보여 주고, ulibvirt-qemu 를 고르면 VM 만 남는다.

호스트에서 본 게스트 두 대

호스트 경계 안에 있는 것은 libvirtd 와 프로세스 두 개뿐이고, 게스트가 보는 디스크와 인터페이스는 자기를 담은 프로세스가 내준다. 프로세스에 적힌 RSS 와 게스트에 적힌 available 은 같은 메모리를 다른 껍질에서 읽은 값이다. 그림에 machine.slicevirbr0 는 넣지 않았다 — 앞의 것은 1층에, 뒤의 것은 다음 항목에 있다.

왜 여기 나오나. A-4 의 노드 상실이 이 층에서 일어난다. virsh destroy 는 게스트에 ACPI 신호를 보내지 않고 프로세스를 끊으므로, 게스트 입장에서는 예고가 없다.

=== 워커 노드(kc-lab-2) 전원 차단 — virsh destroy 는 종료 신호가 없다 ===
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
  +45초  node=NotReady | keycloak-0=Running | 외부 HTTP 503

없거나 틀리면. 호스트에서 프로세스가 사라진 것과 게스트 안에서 서비스가 죽은 것을 같은 사건으로 읽게 된다. 4층의 축출 타이머가 45초 뒤에 움직이는 이유는 게스트가 죽었다고 말한 적이 없기 때문이다.

디스크와 네트워크는 virtio 로 붙는다

무엇인가. 게스트는 실재하는 하드웨어 대신 반가상화 장치를 본다.

virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
  --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
  --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
  --network network=default,mac=52:54:00:aa:bb:11 \
  --import --os-variant debian12 --noautoconsole

--network network=default 는 게스트를 virbr0 에 붙인다. libvirt 의 NAT 네트워크이고 대역은 192.168.122.0/24 이며, kc-lab-1 이 .11, kc-lab-2 가 .12 다. 「주입이 먹지 않는다」의 여섯 번째, tceth0 에 걸었는데 아무 일도 없었던 것도 장치 이름이 이 층에서 정해지기 때문이다 — Debian 게스트의 인터페이스는 enp1s0 다.

없거나 틀리면 — 조용히 실패한다. 시드 ISO 를 virtio 디스크가 아니라 SATA CD-ROM 으로 붙이면(virt-install --cloud-init 의 기본값이다) Debian genericcloud 이미지는 그 장치를 못 본다. 크기를 줄이려고 물리 하드웨어 드라이버를 뺀 이미지라 AHCI 가 없다. cloud-init 은 데이터소스를 찾지 못한 채 오류를 남기지 않고 끝나고, 밖에서 보이는 증상은 hostname 이 localhost 로 남고 SSH 가 Permission denied (publickey) 로 거부되는 것뿐이다.

확인. 게스트에 들어갈 수 없을 때는 화면을 뜬다.

virsh domblklist kc-lab-1                 # 붙은 디스크
virsh net-dhcp-leases default             # 게스트 IP
virsh screenshot kc-lab-1 /tmp/kc1.ppm    # 확장자와 무관하게 PNG 로 저장된다

localhost login: 이면 cloud-init 이 안 돌았고 kc-lab-1 login: 이면 돌았다.

같은 메모리가 세 곳에서 다르게 보인다

무엇인가. 이 실험대에서 메모리를 읽는 곳은 셋이고, 셋이 다른 값을 내는 것이 정상이다.

어디서 무엇을 보나
호스트 htop QEMU 프로세스의 RSS = 게스트 전체
게스트 free -m 게스트 커널이 나눠 쓰는 값
kubectl top 파드·노드 단위 working set

2026-09-03, Keycloak 을 올리기 전 호스트만 보면 남은 것이 없어 보였다.

lab host      총 7628MB · 사용 7189MB · 여유 439MB
  ├ qemu #1   RSS 3765MB   kc-lab-1 (할당 3584MB)  → 상한 도달
  └ qemu #2   RSS 2633MB   kc-lab-2 (할당 2560MB)  → 상한 도달

같은 시각 게스트 안에는 여유가 있었다.

kc-lab-1   총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
kc-lab-2   총 2480MB · used  580 · buff/cache 1714 · available 1899MB

왜 이런가. QEMU 의 RSS 는 게스트가 터치한 페이지만큼이다. 게스트가 페이지 캐시로 메모리를 채우면 RSS 도 할당 상한까지 올라가고, 상한에 닿으면 거기서 멈춘다. 위 두 프로세스가 그 상태였다. 그래서 게스트 안에 워크로드를 더 올려도 호스트 압박은 늘지 않는다 — 게스트의 페이지 캐시가 밀려날 뿐이다.

없거나 틀리면. 「호스트 여유 439MB」를 자원이 없다는 뜻으로 읽는다. 게스트 여유를 합치면 약 3.8GB 였고, 배포 예산은 약 2600Mi 였다.

확인.

ps -eo rss,args --sort=-rss | grep '[q]emu-system'   # 호스트에서 본 VM
ssh kc-lab-1 free -m                                  # 게스트 안 실제
kubectl top nodes                                     # working set

상한을 바꾸려면 껐다 켜야 한다

무엇인가. 호스트 메모리를 8GB 에서 12GB 로 물리 증설한 뒤, 게스트를 다시 만들지 않고 할당만 옮겼다.

virsh setmaxmem kc-lab-1 5120M --config
virsh setmem    kc-lab-1 5120M --config

setmaxmem 이 상한이고 setmem 이 현재 할당이다. 현재값을 상한보다 크게 줄 수 없으므로 setmaxmem 이 먼저다. --config 는 다음 부팅부터, --live 는 실행 중인 도메인에 즉시 적용된다. 다만 setmaxmem --live 는 대개 거부된다 — 게스트가 부팅할 때 메모리 맵을 정하기 때문이다.

왜 여기 나오나. 이 재배분이 1층의 machine.slice 아래에서 일어난다. 그리고 증설 전에는 관측 스택을 올릴 만큼 남지 않았다. 증설 뒤 kc-lab-1 이 2045Mi(41%), kc-lab-2 가 1131Mi(28%), 호스트 여유가 3957MB 였다.

확인.

virsh dominfo kc-lab-1 | grep -i memory
ssh kc-lab-1 free -m        # 게스트가 실제로 인식한 값

swap 은 게스트에 두지 않는다

호스트에는 8GB 의 swap 이 있고 게스트에는 0MB 다. 이유는 셋이다. k3s 와 kubelet 은 기본적으로 swap 을 거부하고, 호스트 swap 으로 QEMU 의 페이지가 밀리면 게스트 성능이 급락하며, 무엇보다 이 실험대가 재는 것이 타이밍이다. refresh 경쟁과 복제 지연을 재는 동안 swap 이 끼면 8층의 측정이 통째로 뜻을 잃는다.

이 층 아래의 구조 — 조사한 것

여기까지는 이 실험대에서 읽은 값이다. 아래는 그 아래에 무엇이 있는지를 공식 문서에서 확인한 것이고 이 실험대에서 잰 것이 아니다. 세 갈래 중 앞의 둘은 이 실험대가 쓰고 셋째는 쓰지 않는다.

게스트가 하드웨어에 닿는 세 갈래

왼쪽부터 CPU · virtio I/O · 패스스루다. 셋의 차이는 호스트 유저공간을 지나는가와 몇 번 지나는가에 있다.

CPU — 유저공간이 커널에 들어갔다 나온다. open("/dev/kvm") 으로 KVM 핸들을 얻고, 시스템 ioctl 로 VM 을, VM ioctl 로 vCPU 를 만든다 (KVM_CREATE_VM · KVM_CREATE_VCPU). 게스트를 돌리는 것은 vCPU ioctl KVM_RUN 이고, 커널은 vcpu fd 를 offset 0 으로 mmap 한 공유 메모리 (struct kvm_run)로 왜 나왔는지를 알린다. 크기는 KVM_GET_VCPU_MMAP_SIZE 로 묻는다. 문서에 이런 문장이 있다 — 「vcpu ioctl 은 그 vcpu 를 만든 스레드에서 내야 한다」. 앞에서 본 「vCPU 는 QEMU 프로세스의 스레드」가 여기서 나온다. (커널 KVM API 문서)

하드웨어 쪽 이름은 VMX 다. 프로세서는 VMX root 와 VMX non-root 로 나뉘어 돌고, VM entry 때 guest-state 영역에서 상태를 싣고 VM exit 때 그리로 저장한다. (Intel SDM Vol. 3C)

I/O — 게스트가 보는 장치는 규격이다. virtio 는 「서로 다른 종류의 드라이버와 장치가 통신하는 규약을 정한 공개 표준」이고, 주고받는 통로는 virtqueue 라는 링 버퍼다. 게스트에 장치를 내보이는 전송 계층은 PCI · MMIO · CCW 이고 리눅스에서는 virtio-pci 와 virtio-mmio 가 그 드라이버다. (커널 virtio 문서)

앞의 「시드를 virtio 디스크로 붙인다」가 이 규격이다. Debian genericcloud 이미지가 AHCI 를 못 보는 것은 그 이미지에 물리 하드웨어 드라이버가 없기 때문이지 virtio 가 특별해서가 아니다.

virtqueue 를 QEMU 밖과 나누는 길이 따로 있다. vhost-user 문서는 그 규약이 「리눅스 커널의 vhost 구현을 제어하는 ioctl 인터페이스를 보완」하며 「같은 호스트의 유저공간 프로세스와 virtqueue 를 공유하는 제어 평면」이라고 적는다. 앞쪽이 QEMU 이고 뒤쪽이 virtqueue 를 소비하는 쪽이다. (QEMU vhost-user 규약)

패스스루 — 이 실험대는 쓰지 않는다. VFIO 는 「IOMMU 로 보호되는 환경에서 장치 접근을 유저공간에 안전하게 여는, IOMMU 와 장치에 중립인 프레임워크」다. 소유의 단위는 장치가 아니라 IOMMU 그룹인데, 「시스템의 다른 모든 장치로부터 격리할 수 있는 장치 묶음」이 그룹이고 격리가 늘 장치 하나 단위로 되지는 않기 때문이다.

/dev/vfio/vfio            컨테이너를 연다
/dev/vfio/$GROUP          그룹을 열어 VFIO_GROUP_SET_CONTAINER 로 붙인다
VFIO_GROUP_GET_DEVICE_FD  장치 fd 를 받는다
VFIO_IOMMU_MAP_DMA        장치가 닿을 주소 범위를 매핑한다

호스트 드라이버에서 떼어 vfio-pci 에 묶는 것이 장치를 넘기는 방법이고, IOMMU 가 DMA 와 인터럽트 리매핑으로 장치가 아무 메모리나 건드리지 못하게 막는다. (커널 VFIO 문서)

세 갈래의 차이는 깊이다. CPU 는 KVM_RUN 으로 들어갔다 struct kvm_run 으로 나오는 왕복이 있고, virtio 는 virtqueue 를 누가 소비하느냐에 따라 왕복하는 곳이 달라지며, 패스스루는 유저공간 드라이버가 장치에 직접 닿는다. 이 실험대가 잰 값은 앞의 두 갈래에서만 나온 것이다. 패스스루는 이 실험대에 없으므로 여기 적은 것은 문서를 읽은 결과이고 측정이 아니다.


1층. 리눅스와 systemd — 이 실험대의 바닥

systemctl 을 명령으로만 여덟 번 썼고 무엇인지 설명한 적이 없다. 그런데 호스트 nginx·certbot 타이머·libvirtd·k3s 가 전부 이 위에서 돈다.

유닛 파일 — 서비스의 정의

무엇인가. systemd 가 관리하는 대상 하나를 기술한 파일이다. .service 말고도 .timer·.socket·.target 이 있고, 이 실험대에는 앞의 셋이 다 있다.

[Unit]     의존 관계와 순서 —  After= · Wants= · Requires=
[Service]  무엇을 어떻게 실행하나 —  ExecStart= · Type= · Restart=
[Install]  enable 했을 때 어디에 걸리나 —  WantedBy=

왜 여기 나오나. D-4 에서 갱신이 반영되지 않은 원인 셋 중 하나가 certbot-renew.serviceExecStartPost 가 없다는 것이었고, 그 판정은 유닛 파일을 읽어서 내렸다.

없거나 틀리면. 배포판이 준 기본 유닛을 그대로 쓰면서 그 안에 무엇이 있는지 모르면, D-4 처럼 「타이머는 도는데 아무 일도 안 일어나는」 상태를 88일 동안 못 본다.

확인. 아래 두 명령이 서로 다른 것을 보여 준다.

systemctl cat nginx      # 파일에 적힌 것
systemctl show nginx     # 기본값까지 합쳐 실제로 적용되는 것

systemctl catRestart=on-failure 만 있어도 systemctl showRestartUSec=100ms·StartLimitBurst=5 같은 기본값을 함께 보여 준다. 적용값을 알려면 두 번째를 봐야 한다.

Type= — systemd 가 「떴다」고 판단하는 방식

무엇인가. 시작이 끝난 시점을 systemd 가 어떻게 아는지 정하며, 이 호스트에서만 네 가지 값이 쓰인다.

Type 언제 「떴다」고 보나 이 호스트에서
simple ExecStart 프로세스를 띄운 즉시
forking 부모가 끝나고 자식이 남았을 때 nginx
notify 프로세스가 sd_notify(READY=1) 를 보냈을 때 tailscaled
notify-reload notify + reload 신호도 알림 sshd · libvirtd · journald

왜 여기 나오나. nginx 의 systemctl status 를 읽을 때 이 값이 출력을 설명한다.

Process: 584 ExecStart=/usr/bin/nginx (code=exited, status=0/SUCCESS)
Main PID: 585 (nginx)

forking 이라 시동 프로세스 584 는 끝나고(exited) 실제 데몬은 585 로 남았다. simple 이었다면 584 가 그대로 Main PID 로 남는다. 어느 프로세스를 추적할지는 PIDFile=/run/nginx.pid 로 알려 준다.

없거나 틀리면. forking 데몬을 simple 로 적으면 systemd 가 부모가 끝난 것을 죽은 것으로 보고 재시작을 반복한다. 반대로 simple 데몬을 forking 으로 적으면 영원히 시작을 기다린다.

확인.

systemctl show nginx -p Type -p MainPID -p PIDFile --value

Restart= — 죽으면 어떻게 되는가

왜 여기 나오나. 호스트 nginx 가 죽으면 어떻게 되는지가 이 한 줄로 정해진다. 이 실험대에는 진입점이 하나뿐이라, 그것이 스스로 살아나는지가 전체 가용성의 마지막 방어선이다.

Restart=on-failure    RestartUSec=100ms
StartLimitBurst=5     StartLimitIntervalUSec=10s

무엇인가. 프로세스가 끝났을 때 systemd 가 다시 띄울지 정한다.

다시 띄우는 경우
no 없다 (기본값)
on-failure 0 아닌 종료 코드 · 시그널 사망 · 타임아웃
on-abnormal 시그널 사망과 타임아웃만. 종료 코드는 무시
always 정상 종료를 포함해 언제나

이 호스트에서도 갈린다 — nginx·tailscaled·libvirtd 는 on-failure, sshd 와 journald 는 always 다. 접속 경로와 로그 수집은 어떤 이유로 꺼져도 되살아나야 하기 때문이다.

없거나 틀리면 — 이쪽이 중요하다. on-failure 라도 무한히 되살리지는 않는다. StartLimitIntervalUSec=10s 안에 StartLimitBurst=5 번 실패하면 systemd 가 포기하고 failed 로 둔다. 설정이 깨져 기동이 반복 실패하는 상황이 정확히 여기에 해당하며, 그때는 자동 복구를 기다려도 오지 않는다.

systemctl reset-failed nginx && systemctl start nginx    # 상한에 걸린 뒤 되살리는 법

확인.

systemctl show nginx -p Restart -p RestartUSec -p StartLimitBurst -p StartLimitIntervalUSec
systemctl is-failed nginx        # failed 면 상한에 걸렸을 수 있다

이것은 설정을 읽은 것이지 측정한 것이 아니다. 이 실험대가 스물여섯 번 배운 것이 「설정이 그렇다고 그렇게 동작하지는 않는다」이므로, 실제로 죽여 봐야 아는데, 아직 하지 않았다.

KillMode= · KillSignal= — 멈출 때

무엇인가. 정지 신호를 누구에게 보낼지(KillMode)와 무엇을 보낼지(KillSignal)를 정한다.

KillMode 신호를 받는 대상 이 호스트에서
control-group cgroup 안 모든 프로세스 (기본값) tailscaled · journald
mixed 주 프로세스에 먼저, 남으면 그룹 전체에 SIGKILL nginx
process 주 프로세스만 sshd · libvirtd

nginx 는 KillSignal=SIGQUIT 을 쓰는데, nginx 에서 SIGQUIT 은 graceful shutdown — 진행 중 요청을 끝내고 종료하라는 뜻이고, SIGTERM(즉시 종료)과 다르다. mixed 와 짝이 되어 「마스터에게 곱게 끝내라고 하고, 5초 (TimeoutStopSec=5) 안에 안 끝나면 그룹 전체를 SIGKILL」이 된다.

왜 여기 나오나. D-4a 에서 잰 reload 무중단(진행 중이던 42초 요청이 845361바이트를 온전히 받았다)과 같은 성질이 종료에도 걸려 있는데, 종료 쪽은 재보지 않았다.

확인.

systemctl show nginx -p KillMode -p KillSignal -p TimeoutStopUSec --value

없거나 틀리면. KillMode=control-group 에 SIGTERM 을 쓰면 마스터와 워커가 동시에 죽어 진행 중이던 요청이 잘린다. 반대로 process 로 두면 마스터만 죽고 워커가 고아로 남는다. nginx 가 mixed + SIGQUIT 인 것은 그 사이를 고른 결과다.

cgroup v2 — 프로세스를 묶어 재고 제한한다

무엇인가. 커널이 프로세스를 계층 구조로 묶어 자원을 측정하고 제한하는 기능이며, 이 호스트는 v2(통합 계층)를 쓴다.

$ stat -fc %T /sys/fs/cgroup
cgroup2fs
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc dmem

왜 여기 나오나. systemd 는 서비스마다 cgroup 을 하나 만들고 그 안에 프로세스를 넣기 때문에, systemctl status 가 그 그룹을 그대로 보여 준다.

CGroup: /system.slice/nginx.service
        ├─  585 "nginx: master process /usr/bin/nginx"
        └─37252 "nginx: worker process"

D-4 와 바로 이어진다. 그때 「마스터 PID 유지 + 워커 PID 교체 = reload」를 ps 로 판정했는데, 이 블록이 같은 것을 바로 보여 준다 — 마스터 585 는 9월 3일 그대로이고 워커만 37252 로 바뀌어 있다.

status 의 숫자는 전부 cgroup 파일에서 읽은 값이다.

/sys/fs/cgroup/system.slice/nginx.service/
  cgroup.procs     585 37252         → status 의 CGroup 블록
  pids.current     2                 → Tasks: 2
  pids.max         13938             → (limit: 13938)
  memory.current   7376896           → Memory: 7M
  memory.max       max               → 제한 없음
  cpu.stat         usage_usec 23222723 → CPU: 23.222s

없거나 틀리면. cgroup 없이 데몬을 관리하면 fork 한 자식을 놓친다. PID 파일 하나만 보고 kill 하던 옛 init 스크립트가 좀비 워커를 남기던 문제가 이것이고, 쿠버네티스의 컨테이너 자원 제한도 같은 메커니즘이다. A-6 에서 파드에 건 메모리 제한이 결국 이 파일들에 쓰인다.

확인.

systemd-cgls /system.slice/nginx.service
cat /sys/fs/cgroup/system.slice/nginx.service/memory.current

slice — cgroup 의 계층

무엇인가. systemd 는 cgroup 트리를 세 갈래로 나눠 쓴다.

slice 무엇이 들어가나
system.slice 시스템 서비스 — nginx 는 여기
user.slice 로그인 사용자 세션
machine.slice VM 과 컨테이너 — kc-lab-1/2 가 여기 들어간다

자원 제한은 계층을 따라 상속되므로, slice 에 제한을 걸면 그 아래 서비스 전부에 걸린다. VM 두 대의 메모리 재배분이 machine.slice 아래에서 일어난다.

왜 여기 나오나. VM 두 대의 메모리를 실행 중에 재배분할 때 그 조정이 machine.slice 아래에서 일어난다. 호스트가 12GB 뿐이라 이 실험대에서는 게스트 메모리를 몇 번 옮겼다.

없거나 틀리면. slice 에 제한을 걸어 두고 그 아래 서비스만 보면 원인을 못 찾는다. 서비스의 MemoryMaxinfinity 인데도 OOM 이 나면 상위 slice 쪽을 봐야 한다.

확인.

systemd-cgls                                  # 전체 트리
systemctl show nginx -p Slice --value
cat /sys/fs/cgroup/machine.slice/memory.max   # VM 들이 받은 상한

journald — 로그는 어디로 가나

무엇인가. systemd 의 로그 수집기로, 서비스의 stdout·stderr 와 syslog 를 한곳에 모으면서 어느 유닛에서 나왔는지를 메타데이터로 붙인다. 그 덕분에 -u 로 유닛별 조회가 된다.

journalctl -u nginx -f                       # 실시간
journalctl -u nginx --since '1 hour ago' -p err   # 에러만
journalctl -u nginx -o json-pretty | head     # 메타데이터까지

왜 여기 나오나 — 그리고 이 실험대가 치른 대가. systemctl status nginx 가 하단에 최근 로그를 붙여 주는데, 거기 이것이 있다.

Sep 04 14:37:44 nginx[586]: [error] upstream sent too big header while reading
  response header from upstream, ... request: "GET /oauth2/callback?state=..."

B-7 이 502 의 원인으로 지목한 것을 호스트 nginx 가 문장으로 적어 두었다. B-7 은 계층을 나눠(traefik 을 직접 불러 nginx 를 우회) 원인을 좁혔다고 기록했는데, 증거는 저널에 있었고 증거 파일 147개 중 이것을 담은 것은 없다.

no live upstreams 라인도 함께 찍혀 있다 — 노드를 잃었을 때 호스트에서 그렇게 보인다.

없거나 틀리면. 파드 로그와 클러스터 지표만 보면 호스트 계층에서 잘린 요청을 놓친다. B-7 의 502 가 정확히 그 경우였다.

확인.

journalctl -u nginx --since '1 hour ago' -p err   # 에러만
journalctl --disk-usage                            # 얼마나 쌓였나
journalctl -u nginx --no-pager | grep 'too big header'   # B-7 이 놓친 줄

PID 1 의 시그널 보호

무엇인가. 커널은 PID 1 에게 핸들러를 등록하지 않은 시그널을 전달하지 않는다. SIGKILL·SIGSTOP 도 같은 네임스페이스 안에서는 무시된다.

$ ps -p 1 -o comm,args
systemd  /usr/lib/systemd/systemd --switched-root --system --deserialize=56

왜 여기 나오나. A-3 에서 PostgreSQL 을 크래시시키려고 컨테이너 안에서 kill -9 1 을 보냈는데 아무 일도 없었다. 컨테이너의 PID 1 이 postmaster 였고, 자기 네임스페이스 안에서 온 SIGKILL 을 무시했기 때문이다.

없거나 틀리면. 「죽였는데 안 죽었다」를 「영향이 없다」로 읽게 되는데, A-3 의 아홉 실패 중 하나가 그렇게 생겼다.

확인. 백엔드 프로세스를 죽여 postmaster 가 reinitialize 하게 만들면 비로소 크래시 복구가 일어난다.

kubectl exec deploy/postgres -- pkill -9 -f 'postgres: keycloak'
kubectl logs deploy/postgres | grep -i 'not properly shut down\|redo starts'

PrivateTmp=true

무엇인가. 서비스에 자기만의 /tmp 를 주는 설정으로, 마운트 네임스페이스를 따로 만들어 다른 프로세스의 /tmp 와 격리한다.

왜 여기 나오나. nginx 와 certbot-renew.service 양쪽 다 켜져 있어서, D-4 에서 certbot 출력을 /tmp 로 받아 읽으려 했다면 찾지 못했을 텐데, 실제로는 사람이 대화형으로 실행해 파일이 진짜 /tmp 에 떨어졌다.

확인.

systemctl show nginx -p PrivateTmp --value

없거나 틀리면. 서비스가 /tmp 에 쓴 파일을 밖에서 찾다가 없어서 헤맨다. 반대로 이 격리가 없으면 서로 다른 서비스가 /tmp 에서 충돌하거나, 예측 가능한 파일 이름을 통한 공격이 가능해진다.


2층. 네트워크 — netfilter 와 conntrack

A층 실험의 주입이 전부 이 층에서 이루어졌고, 아홉 번의 조용한 실패 중 넷이 이 층을 몰라서 생겼다.

conntrack — 연결을 기억하는 표

무엇인가. 커널이 지나간 연결을 기억하는 표로, 패킷 하나하나를 따로 보지 않고 어느 연결에 속하는지를 먼저 판정한다. 그 판정 결과가 ctstate 다.

상태
NEW 이 연결의 첫 패킷
ESTABLISHED 양방향이 오간 연결의 후속 패킷
RELATED 기존 연결에 딸린 새 연결 (FTP 데이터 채널 등)
INVALID 어디에도 속하지 않음

kc-lab-1 에서 뽑은 실물이다.

$ sudo conntrack -L | awk '{print $4}' | sort | uniq -c
    229 TIME_WAIT
     54 ESTABLISHED
      5 CLOSE
총 311 / 상한 131072

왜 여기 나오나 — A-1 의 주입이 25분 동안 안 먹은 이유가 이것이다. 쿠버네티스 규칙이 전부 NEW 에만 걸려 있기 때문이다.

$ sudo iptables -S FORWARD
-A FORWARD -m comment --comment "kube-router netpol ..." -j KUBE-ROUTER-FORWARD
-A FORWARD -m conntrack --ctstate NEW -j KUBE-PROXY-FIREWALL
-A FORWARD -m conntrack --ctstate NEW -j KUBE-SERVICES

이미 성립한 JGroups 연결의 패킷은 ESTABLISHED 라서 NetworkPolicy 평가 자체에 도달하지 않는다. 규칙은 정확히 걸렸고 패킷이 그 앞에서 지나갔다.

그 표가 얼마나 오래 버티는지는 이 값이 정한다.

$ cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established
86400

24시간. 오가는 패킷이 있으면 타이머가 계속 갱신되므로, JGroups 처럼 주기적으로 통신하는 연결은 사실상 영원히 표에 남는다. cluster_size 가 25분간 2로 남은 것이 이상한 일이 아니라 정상 동작이었다.

없거나 틀리면. 「규칙을 넣었는데 아무 일도 없다」를 「영향이 없다」로 읽는다. A-1 에서 실제로 그럴 뻔했다.

확인.

sudo conntrack -L | grep 7800          # 항목이 남아 있으면 주입이 안 걸린다
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>

netfilter 처리 순서 — raw 가 먼저인 이유

무엇인가. 패킷 하나가 커널을 지나며 여러 테이블을 순서대로 통과한다. 들어오는 패킷 기준이다.

   패킷 도착
     ↓
   raw    PREROUTING     ← conntrack 보다 먼저
     ↓
   [ conntrack 이 여기서 상태를 붙인다 ]
     ↓
   mangle PREROUTING
     ↓
   nat    PREROUTING     ← DNAT
     ↓
   라우팅 판정
     ↓
   filter FORWARD        ← NetworkPolicy · iptables 규칙 대부분

왜 여기 나오나. A-5 에서 filter 로는 ESTABLISHED 를 막을 수 없다는 것이 확인된 뒤, conntrack 이 상태를 붙이기 전에 잡으려고 raw 테이블 PREROUTING 으로 옮겼다. 그러자 걸렸다.

sudo iptables -t raw -I PREROUTING 1 -p tcp -d <파드IP> --dport 7800 -j DROP

없거나 틀리면. filter 에서 아무리 정확한 규칙을 써도 이미 성립한 연결에는 닿지 않는다. 테이블을 바꾸는 것이 규칙을 고치는 것보다 먼저다.

확인.

sudo iptables -t raw -S PREROUTING
sudo iptables -t raw -L PREROUTING -v -n     # 패킷 카운터가 오르는지

kube-router 의 체인 재삽입

무엇인가. k3s 의 NetworkPolicy 컨트롤러가 자기 체인을 FORWARD 맨 위에 유지한다. 사람이 그 위에 규칙을 넣어도 컨트롤러가 조정할 때 다시 밀려난다.

-A FORWARD -m comment --comment "kube-router netpol - TEMCG2JMHZYE7H7T" -j KUBE-ROUTER-FORWARD

왜 여기 나오나. A-5 에서 iptables -I FORWARD 1 로 넣은 규칙의 패킷 카운터가 0 이었다. 규칙이 사라진 것이 아니라 kube-router 체인 아래로 내려가 도달하지 않았다.

확인. 규칙을 넣은 뒤 위치와 카운터를 둘 다 본다.

sudo iptables -S FORWARD | head -3        # 내 규칙이 몇 번째인가
sudo iptables -L FORWARD -v -n | head -5  # 카운터가 0 이면 도달하지 않았다

없거나 틀리면. 규칙이 목록에 보이므로 「넣었다」로 판단하고 결과를 읽는다. 목록에 있는 것과 패킷이 도달하는 것은 다르다.

flannel VXLAN — 파드 IP 가 물리 인터페이스에 안 보이는 이유

무엇인가. 파드 사이 통신을 UDP 로 감싸 노드 사이를 건넌다. 물리 인터페이스(enp1s0)에서 보면 노드 IP 사이의 UDP 패킷이고 안쪽 파드 IP 는 캡슐 안에 있다.

왜 여기 나오나. A-5 에서 tc ... dev enp1s0 로 파드 IP 를 필터하려 했는데 불가능했다. 캡슐화가 이미 끝난 뒤라 그 IP 가 헤더에 없기 때문이다. flannel.1(VXLAN 인터페이스)에서 필터해야 안쪽 주소가 보인다.

여기에 배포판 차이가 겹쳤다 — Debian 게스트의 인터페이스는 eth0 이 아니라 enp1s0 이다. 실패 하나에 원인이 둘이었다.

없거나 틀리면. 물리 인터페이스에서 파드 IP 로 필터를 걸고 「걸렸다」고 믿게 된다. 규칙은 문법상 유효하고 매칭만 영원히 0 이다.

확인.

ip -d link show flannel.1        # vxlan id 와 물리 인터페이스
sudo tcpdump -i enp1s0 -n udp port 8472 -c 3    # 캡슐 바깥 — 노드 IP 만 보인다
sudo tcpdump -i flannel.1 -n -c 3               # 캡슐 안 — 파드 IP 가 보인다

3층. PostgreSQL — 성공 응답과 디스크 사이

A-3 이 잰 「200 을 받은 로그인 153건 중 4건이 DB 에 없다」가 이 층의 개념 셋으로 설명된다.

WAL — 데이터 파일보다 로그를 먼저 쓴다

무엇인가. Write-Ahead Logging. 데이터 파일을 고치기 전에 변경 기록을 로그에 먼저 쓴다. 크래시 후에는 그 로그를 재생(redo)해 복구한다. A-3 에서 본 not properly shut down · redo starts 가 그 재생이다.

순서는 이렇다.

   INSERT
     ↓  WAL 레코드를 공유 버퍼에 쓴다        ← 아직 메모리
     ↓  COMMIT
     ↓  WAL 을 디스크에 flush               ← synchronous_commit 이 정하는 지점
     ↓  클라이언트에 성공 응답
   (데이터 파일 자체는 나중에 체크포인트에서)

왜 여기 나오나. A-3 에서 PostgreSQL 을 크래시시킨 뒤 로그에 나온 것이 이 재생 과정이다.

database system was not properly shut down; automatic recovery in progress
redo starts at 0/...

없거나 틀리면. 「COMMIT 이 반환됐다」를 「디스크에 남았다」로 읽는다. 둘 사이에 flush 라는 단계가 있고, 그 단계를 건너뛸 수 있게 하는 설정이 바로 다음 항목이다.

확인.

kubectl logs deploy/postgres | grep -i 'redo starts\|not properly shut down'

synchronous_commit — 그 flush 를 기다릴 것인가

무엇인가. COMMIT 이 WAL 디스크 flush 를 기다린 뒤 반환할지 정한다.

COMMIT 이 반환되는 시점 잃을 수 있는 것
on (기본) WAL 이 디스크에 닿은 뒤 없음
off 즉시. flush 는 나중에 최근 트랜잭션 일부
local 로컬 flush 만 기다림 (복제 대기 안 함) 복제본 지연분

왜 여기 나오나. 서버 전역은 on 인데, Keycloak 이 트랜잭션마다 SET LOCAL synchronous_commit TO OFF 를 건다. 세션 쓰기를 빠르게 하려는 의도된 설계이고, A-3 은 그 대가를 숫자로 쟀다.

없거나 틀리면. RPO 를 0 으로 가정하게 된다. 실제로는 「200 을 받았는데 없는」 세션이 생기고, 사용자에게는 방금 로그인했는데 다시 로그인하라는 화면으로 나타난다.

확인.

SHOW synchronous_commit;                    -- 전역값
-- 세션별로 다를 수 있으므로 문장 로깅으로 SET LOCAL 을 잡는다

wal_writer_delay — 그 사이가 얼마나 되나

무엇인가. WAL writer 프로세스가 깨어나 버퍼를 flush 하는 주기다. synchronous_commit=off 일 때 잃을 수 있는 시간의 크기를 정한다.

이 실험대의 실측값은 200ms(기본값)였고, A-3 은 그것을 단언하지 않고 재서 확인했다. 처음 문서는 재지 않고 적었다가 나중에 고쳤다.

왜 여기 나오나. synchronous_commit=off 에서 잃을 수 있는 양이 이 값으로 정해진다. A-3 의 유실 4건이 이 창 안에 들어온 로그인들이다.

없거나 틀리면. RPO 를 재지 않고 「거의 0」으로 가정하게 된다. 값을 알면 「최악의 경우 200ms 분량」이라는 상한을 말할 수 있다.

확인.

kubectl exec deploy/postgres -- psql -U keycloak -tAc 'show wal_writer_delay'

fsync 와 페이지 캐시

무엇인가. write() 는 커널 페이지 캐시까지만 쓰고 끝난다. 디스크에 닿게 하려면 fsync() 가 따로 필요하다. 「썼다」와 「남았다」가 다르다.

왜 여기 나오나. B-5 에서 Redis 의 appendonly yes 가 아무것도 바꾸지 못한 것도 같은 계열이다. 거기서는 한 단계 더 밖 — /data 가 컨테이너 파일시스템이라 fsync 를 해도 컨테이너와 함께 사라졌다.

없거나 틀리면. 벤치마크에서 「쓰기가 빠르다」는 결과를 얻고 실제 내구성은 그만큼 없는 상태로 운영에 올린다. B-5 의 Redis 가 그 모양이었다.

확인.

kubectl exec deploy/redis -- redis-cli config get appendfsync   # everysec 이 기본
kubectl exec deploy/redis -- sh -c 'ls -la /data'               # 볼륨인지 컨테이너 fs 인지
kubectl get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}'

낙관적 락과 VERSION 컬럼

무엇인가. 행을 잠그지 않고 읽은 뒤, 갱신할 때 VERSION 이 그대로인지 확인한다. 바뀌었으면 누가 먼저 고친 것이므로 충돌로 처리한다.

왜 여기 나오나. A-6 에서 낙관적 락 충돌이 보일 것으로 예측했는데 0건이었다. 로그인은 세션 행을 INSERT 하지 UPDATE 하지 않아 경합할 대상이 없기 때문이다. 예측이 틀린 이유가 락 구현이 아니라 연산의 종류에 있었다.

없거나 틀리면. 「동시 로그인이 많으면 락 충돌이 난다」로 예측하고 그것을 튜닝하려 든다. A-6 에서 예측이 빗나간 뒤에야 연산이 INSERT 라는 것이 보였다.

확인.

select count(*) from pg_stat_database_conflicts;
-- 그리고 무엇이 UPDATE 되는지는 문장 로깅으로 본다

Liquibase 와 databasechangelog

무엇인가. 스키마 변경을 changeset 단위로 적용하고, 적용한 것을 databasechangelog 테이블에 기록한다. 각 행에는 그 changeset 내용의 체크섬이 들어간다. 기동할 때 파일의 체크섬과 테이블의 체크섬을 대조해 다르면 멈춘다.

왜 여기 나오나. D-2 에서 26.7.3 으로 올렸다가 되돌릴 때 이것이 막았다.

liquibase ValidationFailedException: 1 changesets check sum

새 버전이 남긴 행을 옛 버전이 자기 파일과 대조했는데 맞지 않았다. 그래서 판정 기준이 하나로 정해진다.

select count(*) from databasechangelog;   -- 업그레이드 전후로 같으면 롤백된다

이것이 「롤백 불가」를 「스키마가 움직였을 때만 불가」로 정밀화한 근거다.

없거나 틀리면. 「롤백 계획이 있다」고 적어 두고 실제로는 동작하지 않는다. D-2 에서 그 계획이 기동 단계에서 막혔다.

확인.

select count(*) from databasechangelog;                 -- 업그레이드 전후 비교
select id, author, md5sum from databasechangelog order by orderexecuted desc limit 3;

4층. 쿠버네티스 — 죽은 것을 알아채기까지

노드 축출 타이머 두 개

무엇인가. 노드가 죽었을 때 파드가 옮겨지기까지 두 단계를 거친다.

설정 어디서 왔나 무엇을 정하나
node-monitor-grace-period 40초 쿠버네티스 기본값 (unknown — 이 실험대에서 조회하지 않았다) 컨트롤러가 노드를 NotReady 로 판정하기까지
tolerationSeconds 300초 kubectl 로 읽었다 (observed) NotReady taint 를 파드가 견디는 시간

두 값을 더한 340초는 계산이지 측정이 아니다. A-4 에서 실제로 잰 것은 축출이 시작된 시점이고, 그것은 240~270초 사이였다. 두 수를 견주려면 두 폴링이 같은 기준점을 쓴다는 것이 먼저 서야 하는데 그것을 적어 두지 않았다. 모순되는지 아닌지를 이 실험은 말할 수 없다.

노드가 NotReady 로 넘어간 때는 쟀다 — +30초Ready, +45초NotReady 다 (observed). 안 한 것은 둘이다. node-monitor-grace-period 를 조회하지 않았고 (evidence 0건, 쿠버네티스 기본값을 인용했다), 두 폴링이 같은 +0 을 쓰는지 적어 두지 않았다 — 02 는 차단 시각을 머리말에 적었고 04 는 적지 않았다.

없거나 틀리면. 노드가 죽은 순간 파드가 옮겨질 것으로 기대하게 된다. 이 실험대에서 잰 것은 축출이 시작되기까지 +240~270초 동안 아무 일도 일어나지 않았다는 것이고, 설정값을 더한 계산으로는 340초다.

왜 여기 나오나. A-4 에서 노드를 죽인 뒤 아무 일도 일어나지 않는 구간이 길었다. 고장이 아니라 이 두 타이머가 도는 중이었다.

확인.

kubectl get nodes -w                       # NotReady 로 바뀌는 시점
kubectl get pod <pod> -o jsonpath='{.spec.tolerations}'   # tolerationSeconds

죽은 파드가 더 건강해 보이는 이유

무엇인가. 파드 상태는 그 노드의 kubelet 이 보고한다. 노드가 죽으면 보고하는 주체가 사라지므로 아무도 그 상태를 갱신하지 못한다.

그래서 A-4 에서 죽은 노드의 파드가 Running 으로 보였다. 살아 있는 쪽은 Running 이되 READYfalse 였다 — 03-state-during-loss.txtkeycloak-1 Running false 가 그것이다. 화면이 진실의 역순이었다 — 죽은 쪽이 더 건강해 보인다. CrashLoopBackOff 는 A-4 에서 나오지 않았다; 이 줄은 전에 그렇게 적혀 있었고 증거와 맞지 않아 고쳤다.

왜 여기 나오나. A-4 에서 화면을 그대로 믿었다면 살아 있는 쪽을 장애로, 죽은 쪽을 정상으로 판단했을 뻔했다.

없거나 틀리면. kubectl get pods 의 STATUS 를 실시간 진실로 읽는다. 그 값은 마지막으로 보고된 것이지 지금 상태가 아니다.

확인.

kubectl get pods -o wide                       # NODE 열을 함께 본다
kubectl get nodes                              # 그 노드가 Ready 인가
kubectl describe pod <pod> | grep -i 'last transition'

StatefulSet 이 대체 파드를 만들지 않는 것

무엇인가. StatefulSet 은 파드 이름이 안정적이어야 한다(keycloak-0). 같은 이름을 두 개 띄울 수 없으므로, Terminating 인 파드가 완전히 지워지기 전에는 대체를 만들지 않는다. 노드가 죽어 지워지지 못하면 무한히 기다린다.

Deployment 는 이름이 임의라 이 제약이 없다. 워크로드 종류를 고른 것이 장애 시 동작을 고른 것이기도 하다.

왜 여기 나오나. A-4 에서 대체 파드를 기다렸는데 오지 않았다. 컨트롤러가 고장 난 것이 아니라 이름 규약을 지키고 있었다.

없거나 틀리면. 「자동 복구가 안 된다」로 오진하고 컨트롤러를 의심한다. 실제로는 Terminating 을 풀어 주어야 한다.

확인.

kubectl get pods -l app=keycloak            # Terminating 이 남아 있는가
kubectl delete pod keycloak-0 --grace-period=0 --force   # 강제로 이름을 비운다

NetworkPolicy 는 허용 목록이다

무엇인가. 「무엇을 막을지」가 아니라 **「무엇을 허용할지」**를 쓴다. 파드가 policyTypes: [Ingress] 를 가진 정책에 선택되는 순간, 규칙에 적힌 것 말고는 전부 막힌다. deny 7800 같은 규칙은 쓸 수 없다.

왜 여기 나오나. A-1 은 8080·9000 만 허용하고 7800 을 목록에서 빼는 방식으로 막았다. 그래서 그 두 포트가 하중을 진다 — 9000(health·metrics)을 빠뜨리면 kubelet 이 파드를 죽여서 분단이 아니라 죽은 Keycloak 을 재게 된다.

없거나 틀리면. 「7800 을 막는 규칙」을 찾다가 없어서 NetworkPolicy 로는 안 된다고 결론 내린다. 실제로는 빼는 방식으로 막는다.

확인.

kubectl -n keycloak-lab describe networkpolicy <name>    # 허용된 포트만 나온다

무엇인가. 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의 환경변수를 자동 주입한다.

REDIS_PORT=tcp://10.43.57.116:6379

왜 여기 나오나. B-1 에서 이것이 application.yml${REDIS_PORT:6379} 를 덮어써 기동이 실패했다.

Failed to bind properties under 'spring.data.redis.port' to int:
  Value: "tcp://10.43.57.116:6379"

이름을 바꿔 피하면 다음 사람이 같은 함정에 빠진다. 주입 자체를 끄는 것이 근본 처방이다 — enableServiceLinks: false.

없거나 틀리면. 설정 이름과 겹치는 Service 를 만드는 순간 기동이 깨지는데, 원인이 애플리케이션 설정처럼 보인다.

확인.

kubectl exec <pod> -- env | grep '_PORT='       # tcp:// 로 시작하면 주입된 값이다

5층. Keycloak — 세션과 토큰

refresh token rotation — 재사용이 감지되면 세션이 사라진다

무엇인가. 토큰 갱신에 성공하면 Keycloak 이 새 refresh token 을 발급하고 옛것을 소비된 것으로 표시한다. 소비된 토큰이 다시 오면 재사용으로 본다.

설정
revokeRefreshToken 회전을 켠다. 옛 토큰을 소비 처리
refreshTokenMaxReuse=0 한 번만 쓸 수 있다 (가장 엄격)
refreshTokenMaxReuse=1 같은 토큰을 두 번까지 허용 — 네트워크 재시도를 견디려는 값

왜 여기 나오나 — B-3 의 결과가 여기서 설명된다. 재사용이 감지되면 Keycloak 은 그 토큰만 막는 것이 아니라 SSO 세션 전체를 무효화한다. 그래서 동시 5건 중 이긴 요청이 받은 새 토큰조차 쓸 수 없었다. 새 토큰이 잘못된 것이 아니라 그 토큰이 속한 세션이 방금 지워졌기 때문이다.

「하나는 성공하고 나머지가 실패한다」가 아니라 전부 못 쓰게 된다는 관측이 규격대로였다.

없거나 틀리면. 재시도 로직을 「실패한 요청만 다시 보내면 된다」로 설계하게 된다. 실제로는 전부 재인증해야 한다.

확인.

kcadm get realms/<realm> --fields revokeRefreshToken,refreshTokenMaxReuse

refreshTokenMaxReuse 를 0 보다 크게 두는 것은 재시도를 견디려는 절충이며, 그만큼 탈취된 토큰의 재사용 창도 열린다.

세션은 두 겹이다

무엇인가. Keycloak 이 갖는 세션과 애플리케이션이 갖는 세션이 다르다.

어디에 무엇을 담나
SSO 세션 Keycloak 이 브라우저가 누구로 로그인했는가. KEYCLOAK_IDENTITY 쿠키
클라이언트 세션 Keycloak 그 SSO 세션이 어느 클라이언트에 대해 발급했는가
애플리케이션 세션 앱 (BFF·oauth2-proxy) 앱이 자기 사용자를 기억하는 것

sid 는 SSO 세션의 식별자이고 JWT·DB 행·관리 API 를 잇는 키다. A-3 에서 RPO 를 잴 때 클라이언트가 받은 sid 를 DB 의 OFFLINE_USER_SESSION.user_session_id 와 대조할 때 이 연결을 썼다.

왜 여기 나오나. B-7 에서 secret 을 바꿨을 때 사용자가 로그인 화면을 보지 않고 통과한 이유가 이 구조다. 애플리케이션 세션(oauth2-proxy 쿠키)은 죽었지만 SSO 세션은 살아 있어 조용히 재인증됐다.

없거나 틀리면. 앱 세션을 지우고 「로그아웃됐다」고 판단한다. SSO 세션을 함께 끝내지 않으면 다음 요청에서 조용히 다시 로그인된다.

확인.

# SSO 세션 쪽
kubectl exec deploy/postgres -- psql -U keycloak -tAc \
  "select count(*) from offline_user_session where offline_flag='0'"
# 앱 세션 쪽
kubectl exec deploy/redis -- redis-cli dbsize

CLIENT_SCOPE_CLIENTDEFAULT_SCOPE

무엇인가. 클라이언트가 갖는 스코프가 두 종류다.

DEFAULT_SCOPE 언제 붙나
default scope t 항상
optional scope f 요청이 scope= 로 달라고 할 때만

왜 여기 나오나. A-7a 에서 volatile 모드의 refresh 가 쏘는 SQL 이 딱 이 한 문장이었다.

select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
 where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2   -- $2 = 'f'

refresh 는 새 access token 에 어떤 스코프를 담을지 다시 계산하는데, 그 목록이 이 테이블에 있다. 로그인은 이미 결정된 것을 쓰므로 조회하지 않는다. 그래서 로그인은 SQL 0개, refresh 는 1개가 된다.

없거나 틀리면. refresh 가 DB 를 본다는 사실을 모르면 A-7a 의 500 을 세션 문제로 오진한다. 실제로는 스코프 조회가 막혀 있다.

확인.

select client_id, default_scope, count(*)
  from client_scope_client group by 1,2;

디스커버리와 트랜스포트

무엇인가. 노드가 서로를 찾는 경로와 실제로 이야기하는 경로가 다르다.

디스커버리   PostgreSQL 의 JGROUPS_PING 테이블
트랜스포트   TCP 7800

왜 여기 나오나. A-1 이 7800 만 막았을 때 둘 다 DB 에 등록된 채로 남아 서로 존재한다고 믿지만 메시지는 오가지 않는 상태가 만들어졌다. 그리고 세션은 DB 에 있으므로 교차 노드 refresh 는 200 을 유지했고, 로그아웃 무효화 통지만 끊겼다.

없거나 틀리면. 「클러스터가 형성됐다」를 「메시지가 오간다」로 읽는다. JGROUPS_PING 에 둘 다 있어도 7800 이 막혀 있으면 통지가 끊긴다.

확인.

kubectl exec deploy/postgres -- psql -U keycloak -c 'select name, ip from jgroups_ping'
curl -s localhost:9000/metrics | grep vendor_cluster_size

백채널 로그아웃

무엇인가. 사용자가 한 앱에서 로그아웃하면 IdP 가 다른 앱에 서버 대 서버로 통지해 그쪽 세션도 끝내는 규격이다. 양쪽이 다 있어야 한다.

필요한 것
IdP 클라이언트의 backchannelLogoutUrl
그 URL 을 받아 세션을 지우는 엔드포인트

왜 여기 나오나. C-2 에서 둘 다 없었다. IdP 쪽만 채워도 앱 세션이 남는 이유가 이것이고, Keycloak 파드에서 앱 URL 로 요청하면 200 이 오므로 네트워크 문제와 구별된다.

없거나 틀리면. IdP 쪽만 채우고 「설정했다」로 끝낸다. C-2 에서 그렇게 해 보니 앱 세션을 아무도 지우지 않았다.

확인.

kcadm get clients -r <realm> --fields clientId,attributes | grep -i backchannel
# 그리고 앱 쪽에 수신 엔드포인트가 있는지 소스에서 확인한다

6층. Spring — 두 저장 대상

세션과 인가된 클라이언트는 조회 키가 다르다

무엇인가. Spring Security 가 로그인 뒤 유지하는 것이 둘이고, 찾는 방법이 다르다.

담는 것 조회 키 기본 구현
Application Session 누가 로그인했는가 세션 id 서블릿 컨테이너 메모리
OAuth2AuthorizedClient access · refresh token principal 이름 InMemoryOAuth2AuthorizedClientService

B-0 에서 /actuator/beans 로 확인한 실물이다.

authorizedClientService      → InMemoryOAuth2AuthorizedClientService
authorizedClientRepository   → AuthenticatedPrincipalOAuth2AuthorizedClientRepository
SessionRepository            → 없음

이름이 곧 설명이다AuthenticatedPrincipal…Repository 는 principal 기준으로 찾으므로 조회 키에 세션 id 가 없다.

왜 여기 나오나. B-1 에서 SPRING_SESSION_STORE_TYPE=redis 로 세션만 옮겼을 때 토큰이 따라오지 않은 이유가 여기 있다. 저장소를 바꿔도 조회 경로가 다르므로 함께 움직이지 않는다.

없거나 틀리면. 세션 저장소만 옮기고 「무상태가 됐다」고 판단한다. 토큰은 아직 인스턴스 메모리에 있어 다른 인스턴스로 가면 사라진다.

확인.

kubectl exec <bff-pod> -- wget -qO- localhost:8083/actuator/beans \
  | grep -o '"[a-zA-Z]*OAuth2AuthorizedClient[a-zA-Z]*"' | sort -u

인가 클라이언트 테이블의 기본키

무엇인가. JdbcOAuth2AuthorizedClientService 의 기본 스키마다.

PRIMARY KEY (client_registration_id, principal_name)

세션 id 가 키에 없다. 같은 사용자가 다른 브라우저로 로그인하면 같은 행을 쓰게 되고, 나중 로그인이 앞의 토큰을 덮어쓴다.

왜 여기 나오나. B-2 의 ③④ 가 저장소 선택으로 풀리지 않았던 이유다. Redis 로 옮기든 PostgreSQL 로 옮기든 이 키가 그대로면 같은 일이 일어난다. 저장소가 아니라 스키마 문제였다.

그리고 로그아웃이 애플리케이션 세션(Redis)만 지우고 이 행은 남기므로, 평문 refresh token 이 한 행 남는다.

없거나 틀리면. 「같은 사용자가 두 브라우저를 쓰면 왜 한쪽이 풀리는가」를 세션 만료나 캐시 문제로 오진한다. 원인은 스키마다.

확인.

\d oauth2_authorized_client
select principal_name, count(*) from oauth2_authorized_client group by 1;

Java 직렬화 \xac\xed

무엇인가. Java 기본 직렬화의 매직 넘버가 0xACED 다. Redis 에 들어간 세션 값을 꺼내 보면 이 두 바이트로 시작한다.

왜 여기 나오나. Spring Session 의 기본 직렬화가 Java 네이티브라, Redis 안의 값이 사람이 읽을 수 없고 다른 언어에서도 못 읽는다. 값을 확인하려면 같은 클래스패스의 JVM 이 필요하다. JSON 직렬화로 바꾸면 읽을 수 있게 되지만 클래스 구조 변경에 더 민감해진다.

없거나 틀리면. Redis 안을 들여다보고 「값이 깨졌다」고 판단한다. 깨진 것이 아니라 Java 바이트다.

확인.

kubectl exec deploy/redis -- redis-cli --no-raw hget <key> sessionAttr:...
# \xac\xed 로 시작하면 Java 네이티브 직렬화다

agroal 커넥션 풀

무엇인가. Quarkus(따라서 Keycloak)의 JDBC 커넥션 풀이다. 지표 이름이 agroal_ 로 시작한다.

지표
agroal_active_count 지금 쓰이는 커넥션
agroal_max_used_count 최대로 썼던 수
agroal_awaiting_count 커넥션을 기다리는 요청 수
agroal_blocking_time_average_milliseconds 획득까지 걸린 평균 시간

왜 여기 나오나. A-6 에서 200ms 지연이 22.2초가 된 두 번째 단계가 이 풀이다. 요청이 커넥션을 오래 붙들면 뒤의 요청이 기다리고, 획득 대기가 최대 20,000ms 까지 올랐다. 지연이 왕복 횟수만큼 더해진 뒤 여기서 한 번 더 곱해진다.

평시에는 병목이 아니다 — 무주입 상태의 동시 20건에서 max_used 가 4, awaiting 이 0 이었다.

없거나 틀리면. 응답이 느려질 때 DB 자체를 의심하는데, 실제로는 커넥션을 기다리는 시간일 수 있다. 둘은 대처가 다르다.

확인.

curl -s <keycloak>:9000/metrics \
  | grep -E '^agroal_(active|awaiting|max_used|blocking_time)'

7층. TLS 와 인증서

fullchain.pem vs cert.pem

무엇인가. certbot 이 네 파일을 만든다.

파일 담긴 것
cert.pem 서버 인증서 하나만
chain.pem 중간 인증서들
fullchain.pem cert + chain — nginx 가 써야 하는 것
privkey.pem 개인키

왜 여기 나오나. cert.pem 만 쓰면 중간 인증서가 빠져 체인이 끊긴다. 그런데 브라우저 대부분은 중간 인증서를 캐시하거나 AIA 로 보완하므로 정상으로 보인다. 캐시가 없는 클라이언트(일부 모바일·curl·서버 간 호출)에서만 깨지므로 발견이 늦다.

이 실험대의 설정은 fullchain.pem 이고 체인이 4단계로 나온다.

0 s:CN = auth.hyeonworks.com      i:Let's Encrypt YE2
1 s:Let's Encrypt YE2             i:ISRG Root YE
2 s:ISRG Root YE                  i:ISRG Root X2
3 s:ISRG Root X2                  i:ISRG Root X1
Verify return code: 0 (ok)

확인. 단계가 1개로 나오면 cert.pem 을 쓰고 있다.

echo | openssl s_client -connect <host>:443 -servername <host> 2>/dev/null \
  | grep -E '^ *[0-9]+ s:|Verify return code'

없거나 틀리면. 브라우저에서는 정상으로 보이는데 서버 간 호출이나 오래된 클라이언트에서만 깨진다. 발견이 늦고 재현이 어렵다.

certbot 훅 — deploypost 는 다르다

디렉터리 언제 실행되나
pre/ 갱신 시도
deploy/ 실제로 갱신된 인증서가 있을 때만
post/ 갱신 여부와 무관하게 매번

무엇인가. certbot 이 갱신 과정의 세 시점에 사용자가 넣은 스크립트를 실행해 준다. 셋의 차이는 실행 조건이다.

왜 여기 나오나. 타이머는 하루 두 번 돈다. post/ 에 reload 를 넣으면 갱신이 없는 날에도 하루 두 번 워커를 갈아치운다. deploy/RENEWED_LINEAGE 가 있을 때만 돌므로 그것이 맞다.

D-4a 에서 이 훅 하나로 갱신에서 서빙까지가 38분 25초에서 1~2초가 됐다.

없거나 틀리면. post/ 에 넣으면 갱신이 없는 날에도 하루 두 번 reload 가 돈다. 워커가 매번 교체되므로 keep-alive 연결이 그때마다 끊긴다.

확인.

sudo ls -la /etc/letsencrypt/renewal-hooks/{pre,deploy,post}/
sudo certbot renew --dry-run     # 훅이 도는지 먼저 본다

Let's Encrypt 의 notBefore 백데이트

무엇인가. Let's Encrypt 는 발급 시각보다 정확히 1시간 앞선 시각을 notBefore 로 넣는다. 클라이언트 시계가 조금 느려도 「아직 유효하지 않은 인증서」로 거부되지 않게 하려는 여유다. (Let's Encrypt 커뮤니티)

왜 여기 나오나. D-4a 에서 notBefore 를 발급 시각으로 읽었다가 훅이 발급보다 먼저 실행된 것 같은 모순이 나왔다. 1시간을 되돌리고 나서야 시계 왜곡 106초가 드러났다.

이 실험대가 시계 왜곡 때문에 겪은 일을, 인증서 규격은 이미 알고 설계에 넣어 두었다. 백데이트의 이유가 바로 그 문제다.

없거나 틀리면. notBefore 를 발급 시각으로 읽고 계산하면 1시간이 어긋난다. D-4a 에서 훅이 발급보다 먼저 실행된 것처럼 보인 이유가 그것이었다.

확인. 발급 시각의 기준으로는 SCT 를 쓴다 — 다음 항목.

SCT 와 Certificate Transparency

무엇인가. 발급된 인증서를 공개 로그에 올려 누구나 감사할 수 있게 하는 체계다. CA 가 사전 인증서(precertificate)를 로그에 제출하면 로그가 SCT (Signed Certificate Timestamp)를 서명해 돌려주고, CA 가 그것을 최종 인증서에 박아 넣는다. 브라우저는 SCT 가 없는 인증서를 거부한다.

D-4a 의 새 인증서에 두 개가 박혀 있다.

Signed Certificate Timestamp:
    Timestamp : Sep  4 12:27:49.054 2026 GMT
Signed Certificate Timestamp:
    Timestamp : Sep  4 12:27:49.048 2026 GMT

왜 여기 나오나. SCT 는 CT 로그의 시계로 찍힌다 — dev 머신도 test-server 도 아닌 제3의 기준이다. D-4a 의 106초 보정이 맞는지를 이것으로 교차검증했고, 보정한 훅 시각이 SCT 의 1초 뒤에 정확히 놓였다.

그런데 crt.sh 는 이 이름을 0건으로 답했다. 인증서에 SCT 가 박혀 있다는 것과 특정 검색 사이트가 그것을 색인했다는 것은 다르다. crt.sh 는 자기가 감시하는 로그만 수집하므로 모든 로그를 덮지 않는다.

없거나 틀리면. 「crt.sh 에 없으니 발급되지 않았다」로 읽는다. A-2 의 up 지표와 같은 계열의 함정이다 — 관측 도구가 진실의 부분집합만 본다.

확인.

echo | openssl s_client -connect <host>:443 -servername <host> 2>/dev/null \
  | openssl x509 -noout -ext ct_precert_scts

JWKS 와 kid

무엇인가. IdP 가 서명에 쓴 공개키를 JSON 으로 공개하는 엔드포인트가 JWKS 이고, 토큰 헤더의 kid 가 어느 키로 서명했는지 가리킨다.

/realms/<realm>/protocol/openid-connect/certs

왜 여기 나오나. B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했는데 없었다. NimbusJwtDecoder 는 모르는 kid 를 만나면 캐시 만료를 기다리지 않고 곧바로 JWKS 를 다시 가져온다. 예측이 틀렸고, 결과적으로 회전이 더 매끄러웠다.

키 회전은 우선순위로 한다 — 새 키를 더 높은 priority 로 추가하면 새 토큰은 새 키로 서명되고, 옛 키는 남아 있어 옛 토큰 검증이 계속된다.

없거나 틀리면. 키 회전 후 옛 토큰이 갑자기 거부될 것을 걱정해 회전을 미룬다. 실제로는 옛 키를 남겨 두면 검증이 이어진다.

확인.

curl -s https://<host>/realms/<realm>/protocol/openid-connect/certs | jq '.keys[].kid'
# 토큰 쪽 kid 는 헤더에 있다
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq .kid

oauth2-proxy 의 티켓

무엇인가. Redis 세션 저장소를 쓰면 쿠키에 세션 전체가 아니라 티켓이 담긴다.

티켓 = <세션 ID>.<암호화 키>
         │            └─ 값을 복호화할 키
         └─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>

티켓 전체가 --cookie-secret 으로 암호화되어 있다.

왜 여기 나오나. secret 을 바꾸면 티켓을 열 수 없고, 세션 ID 조차 읽지 못하므로 어느 Redis 키를 지울지도 모른다. B-7 의 「지우지 못했다」가 이 구조 때문이다.

B-7a 가 그 앞을 재서, 프록시가 못 지울 뿐 운영자는 지울 수 있고 TTL 로 생성 시각을 역산해 고아만 골라낼 수 있다는 것을 확인했다.

없거나 틀리면. secret 회전을 「사용자 재로그인」 비용으로만 계산한다. 서버 쪽에 지울 수 없는 세션이 쌓이는 비용을 따로 계산해야 한다.

확인.

kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
kubectl logs -l app=oauth2-proxy --since=2m | grep stored_session

8층. 측정 — 시계와 지표

NTP 와 시계 왜곡

무엇인가. 시스템 시계는 그냥 두면 어긋난다. NTP 데몬이 외부 서버와 맞춘다. systemd-timesyncd 가 그 역할을 하며 상태를 이렇게 본다.

timedatectl show -p NTP -p NTPSynchronized

왜 여기 나오나. test-server 는 NTP=no · NTPSynchronized=no 이고 106초 빠르다. dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차다.

그 사실을 적지 않고 계산한 D-4 의 공백은 106초 짧았고(2199 → 2305초), 1~2초를 재는 D-4a 에서는 뺀 값이 참값보다 약 106초 어긋난 것이 되어 물리적으로 불가능해졌다.

없거나 틀리면. 두 시계에서 온 값을 빼면서 그 사실을 적지 않으면 자릿수가 아니라 방향까지 틀린다.

확인. 왕복 시간을 감안해 재고, 어느 쪽이 맞는지는 외부 기준으로 가른다.

A=$(date -u +%s.%N); B=$(ssh <host> 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B  (A+C)/2

# 어느 쪽이 맞나 — HTTP Date 헤더를 외부 기준으로
curl -sI https://www.google.com | grep -i '^date:'

up — 가장 중요하고 가장 오해받는 지표

무엇인가. Prometheus 가 스크레이프에 성공했는지를 스스로 만들어 붙이는 합성 지표다. 대상이 응답하면 1, 못 하면 0 이다.

왜 여기 나오나. A-2 에서 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 /metrics 가 응답하기만 하면 1 이므로, 「살아 있지만 쓸모없는」 상태를 보지 못한다.

없거나 틀리면. up == 0 만 경보로 걸면 이 실험대의 A-2 같은 장애를 놓친다. 기능 지표(로그인 성공률·에러율)를 함께 봐야 한다.

확인.

curl -s 'localhost:19090/api/v1/query?query=up' | jq '.data.result[].value'
# up 만 보지 말고 기능 지표를 함께 본다
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/realms/master

exporter 패턴 — 긁어오지 않으면 보이지 않는다

무엇인가. Prometheus 는 대상이 보내오는 것을 받지 않고 자기가 긁어 온다(pull). 지표를 내보내지 않는 대상은 exporter 를 앞에 두어야 한다.

왜 여기 나오나. 이 실험대의 Prometheus 는 keycloak·kubelet· node-exporter·prometheus 만 긁는다. Redis·BFF·PostgreSQL 은 대상에 없다. 그래서 B층 실험 대부분에 Grafana 스크린샷이 없는데, 안 찍은 것이 아니라 띄울 화면이 없다.

이것을 「스크린샷 누락」이 아니라 측정된 공백으로 기록했다.

없거나 틀리면. 「Grafana 에 안 보인다」를 「문제가 없다」로 읽는다. 대상에 없으면 문제가 있어도 안 보인다.

확인.

curl -s localhost:19090/api/v1/targets | jq -r '.data.activeTargets[].labels.job' | sort -u

이 조사가 선 근거

여기 적은 값은 대부분 시스템에서 직접 읽었다. conntrack 의 24시간 타임아웃도, FORWARD 1번에 앉아 있는 KUBE-ROUTER-FORWARD 도, nginx 의 재시작 정책도 kc-lab-1 과 test-server 에서 그대로 뽑은 것이다. 유닛 다섯 개의 TypeKillMode 를 나란히 놓은 표도 마찬가지다.

직접 읽을 수 없는 두 가지는 출처를 달았다 — Let's Encrypt 가 notBefore 를 1시간 앞당기는 이유와, Keycloak 이 refresh token 재사용을 감지하면 SSO 세션 전체를 무효화한다는 규격이다. 뒤엣것이 B-3 의 「이긴 요청의 토큰도 못 쓴다」를 설명한다.

0층은 성격이 다르다. 「이 층 아래의 구조」에 적은 것은 이 실험대에서 잰 것이 아니라 커널·Intel·QEMU 문서를 읽은 결과다. KVM 의 ioctl 층과 struct kvm_run, VMX root 와 non-root, virtio 의 virtqueue 와 전송 계층, vhost-user 가 무엇을 보완하는지, VFIO 의 IOMMU 그룹과 DMA 리매핑이 그렇다. 그중 패스스루는 이 실험대에 아예 없다 — 쓴 적이 없으므로 잰 값도 없고, 거기 적은 것은 전부 문서에서 옮긴 것이다. 반면 같은 0층의 앞쪽, qemu-system-x86_64 의 RSS 와 게스트의 availablevirsh 로 옮긴 메모리 할당은 이 실험대에서 읽은 값이다.

한 항목만은 재지 않았다. 1층의 Restart=on-failure 는 유닛 파일을 읽어 적었지 nginx 를 죽여 확인하지 않았다. 이 기록이 스물여섯 번 배운 것이 「설정이 그렇다고 그렇게 동작하지는 않는다」이므로 그대로 적어 둔다. 재려면 호스트 sudo 로 마스터를 죽이고 100ms 안에 살아나는지, 워커 PID 가 어떻게 바뀌는지, 그동안 외부 요청이 몇 건 떨어지는지를 보면 된다.


A층 재현 절차 — 열 편을 직접 치는 순서

앞의 절들은 무엇을 발견했는지를 적었다. 여기부터는 그 발견을 다시 만들려면 무엇을 어떤 순서로 치는가다. 근거는 ../source/docs/guides/experiments/ 의 A층 열 편이고, 파일 하나가 아래 절 하나에 대응한다.

근거 파일 무엇을 가르나
A-0 세션 공유 경로 a0-session-replication.md 1261 세션을 공유하는 것이 Infinispan 인가 PostgreSQL 인가
A-1 7800 차단 a1-jgroups-transport-block.md 1092 트랜스포트를 끊으면 무엇이 깨지는가
A-2 DB 정지 a2-database-loss.md 956 캐시를 가진 노드가 DB 없이 버티는가
A-3 DB 크래시 a3-database-crash.md 993 커밋했다고 응답한 것 중 몇 건이 사라지는가
A-4 노드 상실 a4-node-loss.md 978 기계가 없어진 것을 쿠버네티스가 언제 아는가
A-5 비대칭 분단 a5-asymmetric-partition.md 919 한 방향만 끊으면 왜 안 갈라지는가
A-6 지연 주입 a6-latency-injection.md 926 200ms 가 어디서 몇 배로 곱해지는가
A-7 휘발 설정 비교 a7-volatile-comparison.md 1072 옛 기본값으로 되돌리면 결론이 어디까지 뒤집히는가
A-7a 그 원인 a7a-volatile-cause.md 891 500 을 낸 SQL 문장이 무엇인가
A-8 롤링 재시작 a8-rolling-restart.md 753 배포할 때마다 로그아웃되는가

열 편은 뼈대가 같다. 가이드가 붙인 이름 그대로 기준선주입주입 검증관찰복구 이고, 아래 절들도 그 순서로 적는다.

주입 검증 이 따로 서 있는 까닭이 이 묶음의 요점이다. 이 실험대에서 주입은 아홉 번 조용히 실패했고, 실패한 주입은 「아무 일도 없었다」로 보여 「영향이 없다」와 구별되지 않는다. A-3 은 세 번 죽여 두 번 실패했는데 그 두 번이 모두 「유실 0건」이라는 깨끗한 결과를 냈다. 신호를 미리 정해 두지 않았다면 첫 번째 결과를 그대로 발표했을 것이고 결론은 정반대가 됐을 것이다.

가이드는 출력마다 표시를 붙인다. 그 셋을 이 문서의 표기로 옮긴다.

가이드의 표시 가이드가 적은 뜻 이 문서에서
실측 증거 파일에 있는 출력 원문. 그대로 나온다 (observed)
형태 모양만 같고 값은 환경마다 다르다 모양은 (observed), 숫자는 환경마다 다르다
미검증 손으로 치기 좋게 고쳐 쓴 형태. 원래 실행에서 그대로 쓰이지는 않았다 (unknown)

어느 기계에서 치는가가 편과 폴더 README 사이에서 어긋난다. 열 편 중 아홉 (A-4 를 뺀 전부)의 전제는 「명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다」인데, 같은 폴더의 README.md 는 반대로 적는다.

이 실험대는 이렇게 적었다(각 편의 전제, observed)

- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
  (kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).

따라 하는 사람은 폴더 README 를 따른다. 거기 적힌 이유가 명령이 아니라 설정 파일에 있기 때문이다.

kubectl -n keycloak-lab get pods        # 이렇게
sudo kubectl -n keycloak-lab get pods   # 이렇게 치면 안 된다

README 의 설명은 이렇다 — sudo 를 붙이면 root 환경으로 돌아 그 kubeconfig 를 못 본다.」 root 홈에는 ~/.kube/config 가 없으므로 localhost:8080 으로 붙으려다 connection refused 로 끝난다. 클러스터 문제가 아니라 누구의 설정 파일을 읽느냐의 문제다.

★ 그런데 그 규칙은 kc-lab-1 이 아니라 lab host 의 규칙이다. 2026-09-17 에 기반 가이드 7단계를 그대로 밟아 실험대를 새로 세운 뒤 양쪽에서 쳐서 확인했다(observed).

어디서 kubectl … sudo kubectl …
lab host 된다 안 된다 (root 홈에 kubeconfig 가 없다)
kc-lab-1 안 된다 된다

kc-lab-1 에서 sudo 없이 치면 이렇게 끝난다.

level=warning msg="Unable to read /etc/rancher/k3s/k3s.yaml, please start server with --write-kubeconfig-mode or --write-kubeconfig-group to modify kube config permissions"
error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied

connection refused 가 아니라 permission denied 다. k3s 의 kubectl~/.kube/config 가 없으면 /etc/rancher/k3s/k3s.yaml 로 떨어지는데 그 파일은 600 root 라 일반 사용자가 못 읽는다. 기반 가이드는 그 파일을 lab host 의 ~/.kube/config 로만 복사하고 게스트의 사용자 홈에는 두지 않는다 — 워커 한 대가 털리면 클러스터가 통째로 털리는 구성을 피하려고 일부러 그렇게 했다.

그래서 고칠 것은 sudo 가 아니라 기계 이름이다. kubectlkubectl exec 로만 이루어진 블록은 [kc-lab-1] 이 아니라 [lab host] 에서 친다. 게스트 셸이 필요한 것은 아래 문단이 적는 nft·tc·systemctl·virsh·crictl 뿐이고, 그 블록만 [kc-lab-1]·[kc-lab-2] 로 남는다. kc-lab-1 에서 굳이 치겠다면 sudo kubectl 이 맞고, 그때는 README 의 규칙이 반대로 뒤집힌다. 이 문서 앞쪽 「재현 가이드 26편과, 그것을 따라가다 드러난 결함」이 센 sudo kubectl 905건이 같은 고장이고, 반입한 source/ 의 A층 열 편은 본문 명령 블록에 sudo kubectl 을 한 번도 쓰지 않는다(observed) — 전제 한 줄만 옛 형태로 남았다.

게스트 셸이 필요한 것은 nft·tc·systemctl 같은 노드 자체를 건드리는 명령뿐이라고 README 는 적는다. A-1 의 conntrack, A-4 의 virsh·crictl, A-5 와 A-7 의 iptables, A-6 의 tc 가 그 경우이고, 아래에서 어디서 치는지를 그때마다 밝힌다.

아래 열 절은 절차만 옮긴 것이다. 무엇을 발견했는지는 이 문서 앞쪽에 이미 있고, 여기 실린 명령과 출력은 전부 가이드 원문에서 왔다. 가이드에 없는 명령은 넣지 않았고, 가이드가 규범을 어긴 곳은 두 형태를 나란히 적었다.

A-0 — 세션을 공유하는 것이 Infinispan 인가 PostgreSQL 인가

근거: a0-session-replication.md (1261줄). 실행 기록은 2026-09-04 09:5210:14 KST(observed).

이 실험이 가르는 것

앞 단계에서 Keycloak 2노드 클러스터를 세우고 로그에서 ISPN000094 멤버 2개를 확인했다. 가이드는 거기서 멈추면 「클러스터가 떴다」까지만 아는 것이라고 적는다. 그 위에 장애를 주입해도 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.

갈라야 할 것은 둘이다.

   두 노드가 같은 답을 한다
        │
        ├── (a) Infinispan 이 세션을 복제했다        ← 통념
        │
        └── (b) 두 노드가 같은 PostgreSQL 을 본다    ← 확인할 것

(a) 와 (b) 는 겉보기 결과가 같다. 「반대편에서도 된다」만 보면 구별이 안 된다. 그래서 시험을 넷으로 나눈다.

시험 무엇을 가르나
0 교차 노드 사용 반대편이 그 세션을 쓸 수 있는가 (여기까지는 (a)·(b) 구별 안 됨)
0b 캐시 계수기 델타 로그인 하나에 반대편 캐시가 움직이는가
0c 엔트리 소유 엔트리가 어느 노드에 생기는가
0d SQL 포획 반대편이 정말 DB 를 읽는가 — 추론을 관측으로 바꾼다

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 한 노드에서 만든 세션을 반대편이 갱신하는 것, 반대편에서 로그아웃하면 원래 노드가 400 을 주는 것, 로그인을 받은 노드의 캐시만 늘고 반대편은 +0 인 것, 캐시 합계와 DB 총계가 정확히 맞는 것(7 + 5 = 12), 반대편 노드가 실제로 날린 SELECT·UPDATE 문장, 그 트랜잭션 안의 SET LOCAL synchronous_commit TO OFF.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • 네임스페이스는 전부 keycloak-lab 이다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 탐침 파드 셸용(붙잡고 있어야 한다), 하나는 관찰용.
  • jq 는 이 실험대 어디에도 없다. 이 가이드는 jq 를 쓰지 않는다.

이건 상태를 부수는 실험이다. 가이드의 경고를 그대로 옮긴다 — 세션 테이블을 비우고, StatefulSet 을 재시작하고, PostgreSQL 의 문장 로깅을 켠다. 실험대에서만 한다. 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적혀 있다. 중간에 그만두려면 복구 절의 두 명령이면 된다.

지운 세션은 돌아오지 않는다. 문장 로깅만 되돌릴 수 있고, 그 되돌리기는 켜기 전에 먼저 읽어 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
  -c "select pg_reload_conf()"

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

넓은 것부터 좁혀 간다.

노드 → 파드 → 클러스터 뷰(로그) → 디스커버리(DB) → DB 세션 수 → 노드별 캐시 → 탐침 고르기
kubectl get nodes
kubectl -n keycloak-lab get pods -o wide

READY 가 둘 다 1/1, RESTARTS0, 그리고 NODE 가 서로 다르다. 같은 노드에 있으면 이 실험은 성립하지 않는다. postgres 가 어느 노드에 있는지도 적어 둔다 — A-2·A-3 에서 그것이 중요해진다.

원래 실행에서는 keycloak-0kc-lab-2, keycloak-1kc-lab-1 이었다(observed). 파드 번호와 노드 번호가 어긋난다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

실측(observed) — 01-cross-node-session.txt

=== 대상 ===
  keycloak-0  10.42.1.43     kc-lab-2
  keycloak-1  10.42.0.35     kc-lab-1

클러스터 뷰는 로그가 말한다.

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

실측(observed)

2026-09-04 00:52:09,294 INFO  [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]

읽는 법은 이렇다.

[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
 └── 코디네이터 ──┘ │  │   └────── 멤버 목록 ──────┘
                   │  └─ 멤버 수
                   └─ 뷰 ID (바뀔 때마다 1 증가)

멤버가 2 다. 그리고 이 줄이 이 실험에서 증명하는 것은 거기까지다. 「멤버가 둘」은 「세션이 오간다」가 아니다.

디스커버리는 DB 가 말한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"

실측(observed)

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-1-48749 | 10.42.0.35:7800 | t
 keycloak-0-30843 | 10.42.1.43:7800 | f
(2 rows)

coord 열에 t 가 정확히 하나여야 한다. 이 테이블은 **「지금 등록되어 있다」**만 말한다.

세션이 사는 테이블을 먼저 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt"

실측(observed)

 public | auth_session                  | table | keycloak
 public | jgroups_ping                  | table | keycloak
 public | offline_client_session        | table | keycloak
 public | offline_user_session          | table | keycloak
 public | revoked_token                 | table | keycloak
 public | root_auth_session             | table | keycloak

USER_SESSION 테이블이 없다. persistent-user-sessions(Keycloak 26 기본값)는 새 테이블을 만들지 않고 기존 오프라인 세션 테이블을 재사용한다. offline_flag 컬럼으로 구분하고, '0' 이 온라인 세션(일반 로그인), '1' 이 오프라인 세션(offline_access)이다. 기본키가 (user_session_id, offline_flag) 복합키인 까닭이 그것이다. 이 가이드의 모든 질의는 offline_flag='0' 이다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

노드별 캐시 엔트리가 이 실험의 주 계기(計器)다. Keycloak 컨테이너에는 curlwget 도 없어(exit 127) 밖에서 Prometheus 에 묻는 것이 가장 짧다. 15초마다 이미 긁고 있다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'

한 줄짜리 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다. 어떤 라벨이 붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 라벨을 보고 나면 읽기 좋게 자른다 — 이 줄은 가이드가 미검증으로 표시했다(unknown).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
  | tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'

cachesessions 인 두 줄과 그 값을 본다. clientSessions work 등 다른 캐시도 같이 나오므로 cache 라벨을 반드시 본다. 가이드는 중괄호를 URL 에 그대로 넣으면 wget 이 싫어할 수 있어 쿼리에 라벨 필터를 걸지 않고 받은 뒤에 거른다고 적는다.

탐침을 잘못 고르면 뒤의 숫자를 잘못 읽는다. 원래 실행이 실제로 잘못 읽었다. 첫 판본은 userinfo 로 재고 http_code=403「복제 실패」로 읽을 뻔했다. 발급 노드에도 같은 요청을 보내 보니 이랬다(observed).

--- userinfo, scope 없음 ---
  k0(발급노드) 403
  k1(반대편)   403
--- 403 본문 ---
WWW-Authenticate: Bearer realm="master", error="insufficient_scope",
                  error_description="Missing openid scope"

양쪽 다 403 이었고, 원인은 복제가 아니라 요청에 openid scope 가 없다는 것이었다. 가이드가 여기서 뽑은 원칙은 이렇다 — 「반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지 아무 의미가 없다. 시험군만 재는 측정은 측정이 아니다.」

탐침도 바꿨다.

탐침 하는 일 적합한가
userinfo 서명 검증 + scope 확인 아니다. 세션을 몰라도 통과할 수 있다
refresh_token 그랜트 세션을 찾고, 살아 있는지 보고, 갱신 시각을 쓴다 그렇다

그리고 refresh token 은 회전한다. 한 번 쓰면 옛 것이 무효가 되므로 반대편 노드에 먼저 써야 한다. 발급 노드에 먼저 쓰면 시험군에 쓸 토큰이 사라진다.

판정은 개수가 아니라 sid 로 한다. 관리 API 의 active=2 를 보고 판정하려던 첫 시도는 스크립트 자체가 로그인을 두 번 했기 때문에(시험용 + 관리 API 호출용) 실패했다. sid 는 세 곳에서 같은 문자열이다.

JWT access_token 의 sid   jiv3rVZi1VeaO07oVJkL_MYW
                          ↕ 같은 값
DB user_session_id        jiv3rVZi1VeaO07oVJkL_MYW
                          ↕ 같은 값
Admin API 세션 목록의 id  jiv3rVZi1VeaO07oVJkL_MYW

주입

주입은 둘이고, 첫째는 출발점을 비우는 것이다. 세션이 전부 지워지고 두 파드가 재시작된다. 되돌릴 수 없다.

DB 만 지우면 안 된다. 원래 실행에서 정리하려고 delete from offline_user_session 만 했더니 캐시 엔트리는 그대로 있어 캐시 합계(19)와 DB 총계(15)가 어긋났다(observed).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

rollout status끝날 때까지 블록한다. 돌아오면 두 파드가 새로 떠 있다. 시각을 적어 둔다 — 나중에 Grafana 로 시계열을 볼 때 그 시각이 「캐시가 0 으로 떨어진 절벽」이다.

두 번째 주입은 관찰 단계 안에 있다. PostgreSQL 문장 로깅을 몇 초만 켠다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system set log_statement='all'" \
  -c "alter system set log_line_prefix='%m [%p] %h '" \
  -c "select pg_reload_conf()"

%h 가 핵심이다. 클라이언트 IP 를 로그 줄 앞에 남긴다. 이게 없으면 어느 파드가 보낸 질의인지 구별할 수 없다.

주입 검증

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

kubectl -n keycloak-lab get pods -o wide | grep keycloak
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

AGE 가 방금이고 RESTARTS0(새 파드다), 그리고 IP 가 아까 적어 둔 값과 다르다. 가이드는 별표를 붙여 적는다 — IP 를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿고, 그걸 「복제 실패」로 읽는 것이 이 실험에서 가장 하기 쉬운 실수다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

한 행도 없어야 한다. 남아 있으면 delete 가 실패했거나 그 사이 누가 로그인했다.

캐시는 양쪽 다 본다. 위 미검증 형태의 tr·grep 줄을 다시 치고, cache":"sessions"두 줄이 다 0 인지 본다. 한쪽만 확인하고 넘어가면, 원래 있던 값을 나중에 「복제가 왔다」로 읽는다. Prometheus 는 15초마다 긁으므로 재시작 직후에 물으면 옛 값이 나올 수 있다 — 30초쯤 기다렸다가 다시 친다.

문장 로깅 쪽 검증은 따로다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement" -c "show log_line_prefix"

실측(observed) — 04-read-path-sql.txt

  log_statement    = all
  log_line_prefix  = %m [%p] %h

log_statement 가 아직 none 이면 pg_reload_conf() 가 안 돈 것이다. alter systempostgresql.auto.conf 에 쓸 뿐이고, reload 를 해야 적용된다.

관찰

상주 탐침 파드를 띄운다. Keycloak 이미지에 curl 이 없고, 토큰을 단계 사이로 넘겨야 하며, Service 로 가면 어느 노드가 처리했는지 알 수 없다 — 이 실험의 질문 자체가 「어느 노드인가」라서 파드 IP 로 직접 친다.

kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
  --image=curlimages/curl:8.11.1 \
  --env="K0=$K0" --env="K1=$K1" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sh

셸에서 exit 하면 --rm 이 파드를 지운다. 비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하려면 밖에서 이렇게 한다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측(observed) — 19.

파드 안에서 환경변수가 들어왔는지 본다. PW길이=0 이면 --env 가 빈 값을 넘긴 것이므로 나가서 다시 띄운다.

echo "K0=$K0 K1=$K1 PW길이=${#PW}"

시험 0 — 교차 노드 세션 사용. keycloak-0 에서 로그인한다.

TOK=/realms/master/protocol/openid-connect/token
curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d "password=$PW"

한 줄 JSON 이 나온다. 한 번은 통째로 보고 expires_inrefresh_expires_in 을 본다.

실측(observed) — 01-cross-node-session.txt

=== [1] keycloak-0 에서 로그인 ===
  sid          jiv3rVZi1VeaO07oVJkL_MYW
  sub          None
  iss          https://auth.hyeonworks.com/realms/master
  access 수명  60초
  refresh 수명 1800초   typ=Refresh
  refresh jti  7669cc49-4778-851f-3c49-65f76964ae8e

access token 은 60초짜리고 그동안은 서버에 안 물어본다. 그래서 탐침이 access token 이면 안 된다. sub 이 없는 것은 admin-cliscope 없이 direct grant 를 하면 나오는 클레임이 azp, exp, iat, iss, jti, scope, sid, typ 뿐이기 때문이고(observed), 위 userinfo 403 과 같은 원인이다.

토큰과 sid 를 변수에 담는다.

R=$(curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 access=${#AT}자"

refresh=1187자 access=2043자 같은 모양이 나온다.

★ 그 두 수를 기준값으로 삼지 않는다. 2026-09-17 에 다시 세운 실험대에서는 refresh=612자 access=758자 였다(observed). 담긴 클레임과 서명 길이에 따라 달라지므로 보는 것은 0자 가 아니라는 것 하나다.

길이가 0자 면 로그인이 실패한 것이고 echo "$R" 로 에러 본문을 본다.

JWT 의 가운데 토막이 클레임이다. 가이드는 먼저 통째로 디코드해 눈으로 보고 그 다음에 sid 만 잘라낸다. 두 줄 다 2026-09-17 에 쳐서 확인했다(observed).

echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
      | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "SID=$SID"

base64 패딩 때문에 끝이 깨져 보일 수 있고(2>/dev/null 이 그 불평을 지운다), sid 는 앞쪽에 있어서 대개 보인다. 2026-09-17 실측에서는 마지막 "} 두 글자가 잘렸다(observed).

0EPyjFf8PwNJ-1q7","scope":"profile email

끝까지 보려면 패딩을 채운다. 가이드에 없는 형태이고 2026-09-17 에 만들어 쳤다(observed).

P=$(echo "$AT" | cut -d. -f2)
case $(( ${#P} % 4 )) in 2) P="$P==";; 3) P="$P=";; esac
echo "$P" | tr '_-' '/+' | base64 -d; echo
{"exp":1789620347,"iat":1789620287,"jti":"onltro:ba0c11cf-5e4a-a378-7268-4efd5cef32b8","iss":"https://auth.hyeonworks.com/realms/master","typ":"Bearer","azp":"admin-cli","sid":"jbFsOn6E0EPyjFf8PwNJ-1q7","scope":"profile email"}

클레임 이름이 azp exp iat iss jti scope sid typ 여덟이고 sub 이 없다는 가이드의 관측도 이 형태로 다시 확인했다(observed).

같은 sid 가 두 노드 모두에서 보이는지 묻는다. 먼저 admin-cli 의 내부 id 가 필요한데, 가이드는 응답을 한 번 그대로 보고 무엇을 자르는지 눈으로 본 다음 잘라낸다. 잘라내는 줄은 미검증이다(unknown).

curl -s -H "Authorization: Bearer $AT" \
  "http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
CID=$(curl -s -H "Authorization: Bearer $AT" \
  "http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
  | tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
echo "CID=$CID"

sed -n 's/.*"id":"\([^"]*\)".*/\1/p' 로 뽑으면 뒤쪽의 다른 id 를 잡을 수 있다. .* 가 탐욕적이라 줄에서 마지막 "id":" 를 고르기 때문이고, tr ',' '\n' | grep -m1첫 번째 것을 고르므로 안전하다.

같은 질문을 두 노드에 던진다. 미검증(unknown).

for H in "$K0" "$K1"; do
  echo -n "$H : "
  curl -s -H "Authorization: Bearer $AT" \
    "http://$H:8080/admin/realms/master/clients/$CID/user-sessions?max=100" \
    | grep -c "$SID"
done

실측(observed) — 01-cross-node-session.txt

=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
  keycloak-0 (발급 노드)       세션 2개 중 대상 sid → 보임 ✔
      ipAddress=10.42.1.44  start=1788483164000  lastAccess=1788483164000
  keycloak-1 (반대편)         세션 2개 중 대상 sid → 보임 ✔
      ipAddress=10.42.1.44  start=1788483164000  lastAccess=1788483164000

세션이 2개인 것은 실험 도구가 만든 잡음이고 판정에 안 쓴다. 여기까지는 (a) 와 (b) 를 구별하지 못한다.

시험군은 회전 때문에 반대편에 먼저 쓴다.

R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
echo "$R" | tail -1
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')

200, 그리고 새 토큰의 sid 가 같은 값이어야 한다. sid 가 바뀌었다면 세션을 이어받은 게 아니라 새로 만든 것이다. 매번 RT 를 다시 담는다 — 옛 것을 계속 쓰면 나중에 나오는 400 이 무효화 때문인지 재사용 때문인지 알 수 없게 된다.

무효화가 반대 방향으로도 가는지 본다.

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  "http://$K1:8080/realms/master/protocol/openid-connect/logout" \
  -d client_id=admin-cli -d "refresh_token=$RT"

curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"

실측(observed)

=== [6] keycloak-1 을 통해 로그아웃 ===
  http_code=204

=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
  HTTP 400   ← 기대대로
  error             invalid_grant
  error_description Session not active

가이드는 400 을 기억해 두라고 적는다. A-1 에서 7800 을 막으면 같은 곳이 200 으로 바뀌고, 그게 A-1 의 결론이다.

마지막으로 그 sid 를 DB 에서 직접 본다. JWT 안의 값이 컬럼에 문자 그대로 들어 있는지, 그리고 로그아웃과 함께 사라졌는지를 한 번에 확인하는 단계다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, offline_flag, created_on, last_session_refresh
      from offline_user_session where user_session_id='jiv3rVZi1VeaO07oVJkL_MYW'"

실측(observed) — 01-cross-node-session.txt

=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
  대상 sid: jiv3rVZi1VeaO07oVJkL_MYW
  행 없음 — 로그아웃으로 삭제되었다

  전체 세션 수: 1

이 결과가 의미하는 것sid 는 JWT 안에만 있는 값이 아니다. OFFLINE_USER_SESSION.user_session_id 컬럼에 문자 그대로 들어 있다. 로그아웃과 함께 행이 사라졌다. 가이드의 확인표는 이 항목을 「영속 — 로그아웃과 함께 DB 행이 사라졌다」 로 적는다.

시험 0b — 복제인가, 같은 DB 를 본 것인가. 로그인 한 번을 사이에 두고 양쪽 노드의 캐시 계수기를 잰다. 복제라면 반대편도 같이 늘고, 같은 DB 를 보는 것뿐이라면 반대편은 안 움직인다. 전값을 재고, keycloak-0 에만 로그인 한 번을 넣고, 30초 기다렸다가 후값을 같은 명령으로 잰다.

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  "http://$K0:8080/realms/master/protocol/openid-connect/token" \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d "password=$PW"
exit

실측(observed) — 02-cache-delta.txt

=== keycloak-0 (로그인을 받은 노드) ===
  계수기                                        캐시                     전        후      증가
  rpc.replication_count                      clientSessions         1        1      +0
  rpc.replication_count                      sessions               1        1      +0
  approximate_entries_unique                 clientSessions         1        2      +1  ←
  approximate_entries_unique                 sessions               1        2      +1  ←
  hits                                       clientSessions         2        2      +0
  hits                                       sessions               2        2      +0
  misses                                     clientSessions         2        3      +1  ←
  misses                                     sessions               3        4      +1  ←
  stores                                     clientSessions         2        3      +1  ←
  stores                                     sessions               2        3      +1  ←

=== keycloak-1 (아무 요청도 받지 않은 노드) ===
  계수기                                        캐시                     전        후      증가
  rpc.replication_count                      clientSessions         7        7      +0
  rpc.replication_count                      sessions               7        7      +0
  approximate_entries_unique                 clientSessions         0        0      +0
  approximate_entries_unique                 sessions               0        0      +0
  hits                                       clientSessions         4        4      +0
  hits                                       sessions               4        4      +0
  misses                                     clientSessions         0        0      +0
  misses                                     sessions               0        0      +0
  stores                                     clientSessions         1        1      +0
  stores                                     sessions               1        1      +0

keycloak-1 열이 전부 +0. 엔트리도 0, 저장도 0 이고, keycloak-1sessions 엔트리는 처음부터 끝까지 0 이다. rpc.replication_count1·7 로 0 이 아닌 것에 속으면 안 된다 — 이 계수기는 세션 캐시만의 것이 아니라 클러스터가 다른 용무로 주고받은 것까지 센다. 판정은 증가분이 0 이라는 사실로 한다.

시험 0c — 엔트리는 어느 노드에 있는가. 반대편 노드에 로그인을 몰아주면 분산 캐시(owners=1)와 로컬 캐시가 갈린다.

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w '%{http_code} ' -X POST \
    "http://$K1:8080/realms/master/protocol/openid-connect/token" \
    -d grant_type=password -d client_id=admin-cli \
    -d username=admin -d "password=$PW"
done; echo

30초 기다렸다가 엔트리를 재고, $K1$K0 로 바꿔 5회 더 하고 다시 잰다.

실측(observed) — 03-cache-ownership.txt

  keycloak-0 = 10.42.1.43  (kc-lab-2)
  keycloak-1 = 10.42.0.35  (kc-lab-1)

단계                             k0 entries   k1 entries
시작                                    2.0          0.0
keycloak-1 에 로그인 5회             2.0          5.0
keycloak-0 에 로그인 5회             7.0          5.0

=== 대조: PostgreSQL 에는 몇 건인가 ===
  online 세션 12

대각선이다. 한 번에 한 쪽만 는다. 그리고 7 + 5 = 12 로 DB 총계와 정확히 맞으므로 어느 엔트리도 두 번 세어지지 않았다.

★ 2026-09-17 에 같은 모양이 다시 나왔다(observed). 로그인 수만 달라 숫자가 다르다.

단계                         k0 entries   k1 entries
시작                              4            0
keycloak-1 에 로그인 5회          4            5
keycloak-0 에 로그인 5회          9            5

대조: PostgreSQL 의 online 세션   14      ← 9 + 5 = 14

캐시 합계와 DB 총계가 맞으므로 어느 엔트리도 두 번 세어지지 않았다. 판정에 쓰는 것은 숫자가 아니라 「한 번에 한 쪽만 늘고, 두 캐시의 합이 DB 총계와 같다」 두 가지다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from offline_user_session where offline_flag='0'"

캐시 설정은 파일에서 읽을 수 없다. 파드의 /opt/keycloak/conf/cache-ispn.xml<cache-container name="keycloak"><transport/></cache-container> 뿐이고, Keycloak 26 은 캐시를 코드에서 만든다. 위 결론은 설정을 읽어서가 아니라 동작을 측정해서 얻은 것이다.

시험 0d — SQL 을 직접 잡는다. 0b·0c 까지는 추론이다. 문장 로깅을 켠 채로 요청을 딱 한 번 보낸다. 여러 번 보내면 로그에서 어느 트랜잭션이 어느 요청인지 구별하기 어려워진다.

TOK=/realms/master/protocol/openid-connect/token
R=$(curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
      | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
      | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "SID=$SID"

curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://$K1:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"

실측(observed)

=== 요청 ===
  SID=jSt9GEPVQLJsO-1CeJjVgltg
  K1_ENTRIES_BEFORE=5.0
  REFRESH_ON_K1=200
  K1_ENTRIES_AFTER=5.0

%h 가 남긴 IP 로 걸러 keycloak-1 이 보낸 것만 본다.

kubectl -n keycloak-lab logs deploy/postgres --since=60s \
  | grep "$K1" | grep 'LOG:  execute'

★ 요청을 보내자마자 이 명령을 치면 한 줄도 안 나온다. 이 트랜잭션은 마지막에서 두 번째 문장이 SET LOCAL synchronous_commit TO OFF 라 커밋이 미뤄지고 로그 줄도 그만큼 늦게 나온다. 2026-09-17 에 요청 직후에 쳤더니 keycloak-1 쪽에서는 디스커버리 질의 한 줄만 나왔고, 같은 필터를 몇 초 뒤에 다시 치니 아래 여덟 줄이 다 나왔다(observed). 그 첫 화면을 그대로 읽으면 「반대편은 DB 를 안 읽었다 = 복제였다」는 정반대 결론이 나온다 — 이 절차가 가르려는 바로 그 둘이 뒤집힌다. 몇 초 기다렸다 친다.

실측(observed) — 04-read-path-sql.txt

  select puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID,...,puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where (puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID) in (($1,$2))
  select puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where puse1_0.USER_SESSION_ID=$1 and puse1_0.OFFLINE_FLAG=$2 for no key update of puse1_0 skip locked
  select pcse1_0.CLIENT_ID,...,pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where (...) in (($1,$2,$3,$4,$5))
  select pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where ... for no key update of pcse1_0 skip locked
  update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where CLIENT_ID=$3 and ... and VERSION=$8
  update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
  SET LOCAL synchronous_commit TO OFF
  COMMIT

추론이 관측이 되었다. keycloak-1 은 세션을 DB 에서 읽고, DB 에 쓴다.

파라미터는 DETAIL 줄에 있다.

kubectl -n keycloak-lab logs deploy/postgres --since=60s \
  | grep 'jSt9GEPVQLJsO-1CeJjVgltg' | cut -c1-120

실측(observed)

2026-09-04 01:12:32.851 UTC [81407] [keycloak-0] DETAIL:  parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
...
01:12:34.934 pid=81376 | BEGIN
2026-09-04 01:12:34.934 UTC [81376] [keycloak-1] DETAIL:  parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:34.936 UTC [81376] [keycloak-1] DETAIL:  parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0'
2026-09-04 01:12:34.944 UTC [81376] [keycloak-1] DETAIL:  parameters: $1 = '1788484354', $2 = '1', $3 = '131a9912-...', ...
2026-09-04 01:12:34.946 UTC [81376] [keycloak-1] DETAIL:  parameters: $1 = '1788484354', $2 = '1', $3 = '0', $4 = 'jSt9GEPVQLJsO-1CeJjVgltg', $5 = '0'
01:12:34.947 pid=81376 | COMMIT

맨 앞과 맨 뒤의 두 줄 — 01:12:34.934 pid=81376 | BEGIN01:12:34.947 pid=81376 | COMMIT — 이 아래 §에서 말하는 13밀리초의 근거다. .934 에서 .947 까지가 13 이고, 중간의 DETAIL 줄은 .946 에서 끝나므로 그것만 보고 세면 12 가 나온다. 이 두 줄은 sid 를 파라미터로 달고 있지 않아 위의 grep 출력에는 안 잡히고, 같은 pid 81376 연결의 트랜잭션 경계를 실험 기록이 pid=… | 꼴로 옮겨 적은 것이다(observed). 04-read-path-sql.txt 에 보존된 것은 sid 가 걸린 DETAIL 줄과 시각 없는 문장 목록뿐이라, 경계 시각의 출처는 그 실험 기록 하나다 — 원문 보존 범위가 거기까지다.

★ 자기 화면에서 그 두 줄을 보는 명령이 이제 있다. 가이드에는 없어서 2026-09-17 에 만들어 쳤다(observed). sid 필터를 빼고 DETAIL 줄의 대괄호 안 pid 로 다시 거른다.

kubectl -n keycloak-lab logs deploy/postgres --since=5m \
  | grep '{{PID}}' | grep -E 'BEGIN|COMMIT|synchronous_commit'

그날의 {{PID}}[915] 였고, 같은 pid 의 BEGINCOMMIT 사이에 위 여덟 문장이 들어 있는 것이 그대로 보였다.

증거 파일에는 IP 대신 [keycloak-0] [keycloak-1] 이 적혀 있다. 원래 실행 스크립트가 sed 로 IP 를 파드 이름으로 바꿔 놓은 것이고, 당신 화면에는 10.42.0.35 같은 IP 가 그대로 나온다. pid 가 다른 것도 본다 — 81407keycloak-0 의 연결, 81376keycloak-1 의 연결이며 pid 가 트랜잭션의 경계다.

파드별 질의 건수는 미검증 형태로 센다(unknown).

kubectl -n keycloak-lab logs deploy/postgres --since=60s \
  | grep 'jSt9GEPVQLJsO-1CeJjVgltg' | grep -c "$K0"
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
  | grep 'jSt9GEPVQLJsO-1CeJjVgltg' | grep -c "$K1"

실측(observed) — 6 [keycloak-1]6 [keycloak-0]. sid 하나에 대해 keycloak-0 이 6건(로그인), keycloak-1 이 6건(갱신)을 날렸다.

캐시는 읽어도 채워지지 않는다. 위 실측의 K1_ENTRIES_BEFORE=5.0K1_ENTRIES_AFTER=5.0 이 그것이다 — keycloak-1 은 남의 세션을 DB 에서 읽어 처리하고도 캐시에 담지 않았다. 캐시에 담기는 것은 그 노드가 로그인시켜 만든 세션뿐이고, 남의 세션은 매번 DB 에서 읽는다. 세션 어피니티가 정확성이 아니라 성능 문제인 까닭이 이것이다.

같은 로그에 jdbc-ping 하트비트도 그대로 보인다(observed).

01:12:37.551 pid=81369 | BEGIN
01:12:37.551 pid=81369 | DELETE from JGROUPS_PING WHERE address=$1
01:12:37.552 pid=81369 | INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values (...)
01:12:37.553 pid=81369 | COMMIT

디스커버리는 별도 연결(pid 가 다르다)에서 주기적으로 자기 행을 지우고 다시 넣는다. 「디스커버리와 트랜스포트는 다른 경로」가 로그에서 눈으로 확인되고, A-1 이 그 둘을 갈라 끊는 실험이다.

그 13밀리초짜리 트랜잭션에는 셋이 들어 있었다. 낙관적 락(VERSION 컬럼), FOR NO KEY UPDATE ... SKIP LOCKED, 그리고 SET LOCAL synchronous_commit TO OFF 다. 전역 설정은 다르다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show synchronous_commit"

전역은 on 이고, Keycloak 이 세션 트랜잭션에만 SET LOCAL 로 끈다. SET LOCAL 은 그 트랜잭션이 끝나면 되돌아간다. 그래서 PostgreSQL 이 갑자기 죽으면 직전 수백 밀리초의 세션 쓰기가 사라질 수 있고, A-3 이 그 숫자를 잰다.

문장 로깅은 곧바로 끈다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
  -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"

켜 둔 채로 다음 실험에 들어가면 안 된다. 로그인 루프를 도는 A-3 에서 log_statement='all' 을 켜 두면 로그가 폭주한다.

복구와 원상복구 확인표

이 실험은 세션을 만들 뿐 클러스터를 부수지 않는다. 되돌릴 것은 둘이다. 먼저 show log_statementshow log_line_prefixnone 인지 보고, 아니면 위 reset 세 줄을 다시 친다. 그 다음 실험이 만든 세션을 정리한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

재시작을 빼면 안 된다. DB 만 지우면 캐시가 남아 다음 실험의 출발값이 어긋난다.

항목 명령 돌아왔을 때
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
클러스터 뷰 kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2)
디스커버리 psql -c "select name, ip, coord from jgroups_ping order by name" coord = t하나
DB 세션 psql -c "select count(*) from offline_user_session" 0
캐시 vendor_statistics_approximate_entries_unique sessions 두 줄 다 0
문장 로깅 psql -c "show log_statement" none
탐침 파드 kubectl -n keycloak-lab get pod kc-probe NotFound (없어야 정상)
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

탐침 파드가 남아 있으면 (--rm 이 안 먹은 경우) 직접 지운다.

kubectl -n keycloak-lab delete pod kc-probe --ignore-not-found

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 띄우거나 Prometheus 에 묻는다
재시작 뒤 아무 데도 안 닿는다 파드 IP 가 바뀌었다 get pod -o jsonpath='{.status.podIP}' 를 다시
로그인이 401/400 비밀번호가 안 넘어갔다 파드 안에서 echo ${#PW}0 이면 --env 가 빈 값
반대편 응답만 보고 「복제 실패」로 읽었다 대조군이 없다 발급 노드에 같은 요청을 나란히
userinfo 가 양쪽 다 403 openid scope 가 없다. 복제와 무관 본문의 insufficient_scope
세션 개수가 계속 어긋난다 관리 API 호출도 세션을 만든다 개수 말고 sid 로 본다
캐시 합계와 DB 총계가 안 맞는다 DB 만 지우고 파드를 재시작 안 했다 rollout restart statefulset/keycloak
로그인했는데 지표가 안 움직인다 Prometheus 스크레이프는 15초 간격 30초 기다렸다 다시
rpc.replication_count 가 0 이 아니라 당황 세션 캐시만의 계수기가 아니다 절대값이 아니라 증가분으로 본다
CID 가 엉뚱한 값이다 sed.* 가 탐욕적이라 마지막 "id" 를 잡는다 tr ',' '\n' | grep -m1 '"id"'
문장 로깅을 켰는데 SQL 이 안 보인다 pg_reload_conf() 를 안 했다 show log_statementall 인지
로그에 어느 파드인지 안 나온다 log_line_prefix%h 가 없다 show log_line_prefix
다음 실험에서 postgres 로그가 폭주한다 문장 로깅을 껐는지 확인 안 했다 show log_statementnone

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

  • (observed) 파드 IP 10.42.1.43·10.42.0.35, 뷰 ID 5 와 멤버 (2), jgroups_pingcoord = t 하나, 캐시 델타 전량, 7 + 5 = 12, keycloak-1 이 날린 SQL 여덟 줄, pid 81407/81376, 비밀번호 길이 19.
  • (unknown) tr ',' '\n' | grep -E 로 자른 Prometheus 출력, JWT 를 디코드해 sid 를 뽑는 sed 줄, CID 를 뽑는 줄, 두 노드에 grep -c 를 도는 for 루프, 파드별 질의 건수를 세는 두 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 했다.
  • (observed) 가이드가 스크립트를 안 쓰는 까닭도 측정 실패 기록에서 나왔다. kubectl run --rm -i ... | grep 로 받았더니 중간 조각이 통째로 사라져 keycloak-1 의 스냅샷과 다음 마커가 함께 없어졌고, 전값이 0 으로 잡히면서 가짜 델타가 만들어졌다. 그때 리포트는 keycloak-1+9, +7 증가한 것처럼 보였다 — 없는 복제가 있는 것처럼 보이는 오류다. 다른 하나는 중첩 인용이다. ssh host '... $VAR ...' 안에 다시 sh -c "..." 를 넣으면 인용이 세 겹이 되어 치환이 조용히 깨졌고, 첫 시도에서 파드 IP 가 빈 문자열이 되어 아무 출력도 나오지 않았다.

A-1 — 7800 을 막으면 무엇이 깨지는가

근거: a1-jgroups-transport-block.md (1092줄). 실행 기록은 2026-09-04 11:3811:52 KST(observed).

이 실험이 가르는 것

A-0 이 「세션은 Infinispan 복제가 아니라 PostgreSQL 로 공유된다」를 측정했다. 그런데 Keycloak 24 이전 자료는 「세션은 7800 으로 복제된다」고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고, A-0 모델은 안 깨진다고 예측한다. 둘 중 하나는 틀렸고, 7800 만 끊어 보면 판정된다.

핵심은 두 가지를 분리해서 끊는 것이다.

   디스커버리   노드가 서로를 어떻게 찾는가   →  PostgreSQL 의 JGROUPS_PING 테이블
   트랜스포트   실제로 어떻게 말하는가        →  TCP 7800

트랜스포트만 막으면 DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태가 된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 까닭이 그것이다.

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — NetworkPolicy 를 걸었는데 클러스터가 안 깨지는 상태, coord = t 가 두 줄인 split brain, 분단인데도 교차 노드 refresh 가 200, 로그아웃했는데 반대편이 200 을 주는 상태, 분단된 노드가 스스로 Service 에서 빠지는 것, 90초 만에 자동으로 다시 붙는 것.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • kc-lab-2 에는 ssh kc-lab-2 로 붙는다. conntrack 은 두 노드 모두에서 봐야 한다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 임시 curl 파드용, 하나는 관찰용.

이건 상태를 부수는 실험이다. Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. 실험대에서만 한다. 전 구간 약 30분이고, 중간에 그만두려면 복구 절의 첫 명령 하나면 된다.

kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport

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

시험군만 재는 측정은 측정이 아니다. 차단 후에 볼 것을 차단 전에 똑같은 명령으로 먼저 봐 둔다.

노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험
kubectl get nodes
kubectl -n keycloak-lab get pods -o wide

READY 가 둘 다 1/1, RESTARTS0(뒤에서 이 값이 오르면 주입이 엉뚱한 것을 건드린 것이다), NODE 가 서로 다르다. IP 두 개를 적어 둔다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

실측(observed) — 10.42.1.43 10.42.0.35.

기존 정책이 없다는 것도 확인한다. NetworkPolicy 는 합집합으로 허용되므로 두 개가 겹치면 무엇이 열려 있는지 한눈에 안 보인다.

kubectl -n keycloak-lab get networkpolicy

실측(observed) — 01-baseline-cluster.txt

No resources found in keycloak-lab namespace.

클러스터 뷰는 양쪽이 완전히 같은 줄을 찍고 있어야 한다.

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

실측(observed)

  keycloak-0: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
  keycloak-1: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]

keycloak-0-30843 의 뒤 숫자는 JGroups 가 붙인 것이고 파드가 재시작되면 바뀐다. 나중에 keycloak-0-26403 이 나오면 같은 파드의 새 인스턴스다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"

실측(observed)

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-0-30843 | 10.42.1.43:7800 | f
 keycloak-1-48749 | 10.42.0.35:7800 | t
(2 rows)

가이드는 여기서 셋이 서로 다른 것을 본다고 적는다 — 로그는 「그때 그렇게 보였다」, 테이블은 「지금 등록되어 있다」, 지표는 「지금 그 노드가 그렇게 안다」. A-1 에서 이 셋이 갈린다.

지표는 각 노드가 아는 멤버 수를 말한다. 한 줄짜리 JSON 을 처음 한 번은 그대로 본다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

라벨을 보고 나면 읽기 좋게 자른다. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown). 둘째 줄은 jq 가 깔려 있는 환경용이고, 이 실험대에는 jq 가 없다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | jq -r '.data.result[] | "\(.metric.pod) \(.metric.node) \(.value[1])"'

결과가 두 줄이고 값이 둘 다 2 여야 한다. 분단되면 한쪽만 1 이 될 수도 있어서 한 노드만 보면 분단을 놓친다.

JGroups 카운터도 지금 0 인 것을 봐 둔다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'

실측(observed) — 02-control-before-block.txt

vendor_jgroups_merge3_get_num_merge_events 0.0        (양쪽 노드)
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 (양쪽 노드)

대조군 시험은 차단 전에 한 번 그대로 돌린다. 임시 파드를 띄우고 그 안에서 A-0 과 같은 순서로 로그인·refresh·로그아웃을 친다.

kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
  --image=curlimages/curl:8.11.1 \
  --env="K0=$K0" --env="K1=$K1" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sh
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
echo "$R" | tail -1
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')

실측(observed) — 02-control-before-block.txt

  sid                    tAWs2gCPr6SOcD4jDR9-_CzB
  keycloak-1 에서 refresh: 200

이 200 이 대조군이다. 차단 후에도 200 이면 「원래 되던 게 그대로 되는 것」이고, 차단 후 400 이면 「내가 깨뜨린 것」이다.

로그아웃 전파도 대조군을 잡는다.

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  "http://$K1:8080/realms/master/protocol/openid-connect/logout" \
  -d client_id=admin-cli -d "refresh_token=$RT"

curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"

정상 클러스터에서는 204 다음에 이 두 줄이 나온다.

{"error":"invalid_grant","error_description":"Session not active"}
400

가이드는 이 400 이 A-0 에서 측정한 값이고 A-1 의 대조군 기록에는 refresh 200 만 있고 로그아웃 단계는 없다고 밝힌다 — 그래서 직접 재 두는 편이 낫다고 적는다.

주입

NetworkPolicy 로 7800 만 막는다. 매니페스트를 먼저 읽는다.

cat deploy/lab/k8s/a1-block-jgroups-transport.yaml

파일은 앞 31행이 영어 주석이고 그 아래가 매니페스트다. 주석을 뺀 본문 전문은 이렇다 — 주석도 파일에 적힌 영어 그대로다.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: a1-block-jgroups-transport
  namespace: keycloak-lab
spec:
  podSelector:
    matchLabels:
      app: keycloak
  policyTypes: [Ingress]
  ingress:
    - ports:
        - { port: 8080, protocol: TCP }   # HTTP — must stay open
        - { port: 9000, protocol: TCP }   # health + metrics — must stay open
    # 7800 is absent on purpose. That is the whole experiment.

metadata.namespace 가 파일 안에 keycloak-lab 으로 적혀 있다. 아래 kubectl apply-n 이 없으니 네임스페이스는 파일 안에 적힌 값을 따른다. 이 매니페스트를 손으로 옮겨 적으면서 그 줄을 빠뜨리면 정책이 default 에 걸리고, 그러면 아무 파드도 안 잡혀 정책은 걸렸는데 아무 일도 안 일어난다.

NetworkPolicy 는 방화벽이 아니라 허용 목록이다. 「7800 을 거부」라고 쓸 방법이 없고, 파드가 policyTypes: [Ingress] 를 가진 정책에 선택되는 순간 모든 인바운드가 거부되고 규칙에 적힌 것만 통과한다. 7800 은 빠뜨림으로써 막힌다.

그 구조 때문에 두 허용 규칙이 결정적이다. 8080 을 빼면 Traefik·상대 노드의 REST 호출이 전부 끊기고, 9000 을 빼면 readiness 프로브가 실패해 kubelet 이 파드를 죽인다 — 엉뚱한 이유로 클러스터가 깨진다. 덤으로 57800 도 막힌다. FD_SOCK2(장애 감지 채널)는 bind_port + 50000 을 쓰는데, 허용 목록 방식은 8080·9000 외 전부 거부이므로 자동으로 같이 막힌다.

kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
date '+%H:%M:%S 적용'

실측(observed) — 03-block-applied.txt

networkpolicy.networking.k8s.io/a1-block-jgroups-transport created
적용 시각: 11:38:08

시각을 반드시 적어 둔다. 실제로 이 실험은 시각이 겹친 것을 인과로 잘못 읽었다가 나중에 정정했다.

주입 검증

정책이 어떤 파드를 잡았는지부터 본다.

kubectl -n keycloak-lab get networkpolicy
kubectl -n keycloak-lab describe networkpolicy a1-block-jgroups-transport

To Port 목록에 7800 이 없는 것, 그리고 PodSelectorapp=keycloak 인 것을 본다. 오타로 아무 파드도 안 잡히면 정책은 걸렸는데 아무 일도 안 일어난다.

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

실측(observed) — keycloak-0 ready=true restarts=0, keycloak-1 ready=true restarts=0. RESTARTS 가 여전히 0 이면 9000 을 제대로 열어 둔 것이다.

열어 둔 포트는 살아 있고 막은 포트는 죽었는지 파드 안에서 본다.

kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
  --image=curlimages/curl:8.11.1 --env="K0=$K0" --env="K1=$K1" --command -- sh
curl -s -o /dev/null -w '9000 %{http_code}\n' --max-time 5 "http://$K0:9000/health/ready"
curl -s -o /dev/null -w '8080 %{http_code}\n' --max-time 5 "http://$K0:8080/realms/master"
curl -s -o /dev/null -w '7800 %{http_code}\n' --max-time 5 "http://$K0:7800/" ; echo "exit=$?"

실측(observed)

9000 도달:  10.42.1.43:9000 health=200 / 10.42.0.35:9000 health=200
8080 도달:  10.42.1.43:8080 root=200   / 10.42.0.35:8080 root=200
7800:       curl exit=7   (연결 실패)

curl 종료코드로 읽는다 — 7 은 연결 자체가 안 됨, 28--max-time 초과로 SYN 이 조용히 버려지고 있음, 0 은 닿았다는 뜻이고 정책이 안 걸린 것이다.

그런데 클러스터가 안 깨졌다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

실측(observed) — 07-cluster-size.txt

=== vendor_cluster_size — 지난 25분 (차단 11:38:08) ===
  keycloak-0: 11:38=2  11:39=2  11:40=2  11:41=2  11:42=2  11:43=2  11:44=2  11:45=2
  keycloak-1: 11:38=2  11:39=2  11:40=2  11:41=2  11:42=2  11:43=2  11:44=2  11:45=2

여기서 「실험 실패」라고 결론 내리면 틀린다. 파드 안 소켓을 본다. Keycloak 이미지에는 ss 도 없으므로 /proc 을 직접 읽는다.

kubectl -n keycloak-lab exec keycloak-0 -- cat /proc/net/tcp6 | grep 1E78

실측(observed)

=== /proc/net/tcp6 · 7800 = 0x1E78 ===
keycloak-0: ...2B012A0A:1E78  ...23002A0A:9C57  01   ← 01 = ESTABLISHED
keycloak-1: ...23002A0A:9C57  ...2B012A0A:1E78  01
            (10.42.0.35:40023 → 10.42.1.43:7800)

포트는 16진수다. 7800 = 0x1E78 이고 세 번째 열 01 이 TCP 상태이며 01 = ESTABLISHED 다. 기존 연결이 멀쩡히 살아 있다.

까닭은 conntrack 이다.

   패킷 도착
      │
      ├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ]  ← 여기서 통과해버린다
      │
      └─▶ [ NetworkPolicy 규칙 평가 ]                     ← 여기까지 오지 않는다

conntrack 은 두 노드 모두에서 본다.

★ 그런데 게스트에 conntrack 이 깔려 있지 않다. cloud-init 이 까는 것은 curlnftables 뿐이라 이 명령은 sudo: conntrack: command not found 로 끝나는데, 2>/dev/null 이 그 한 줄을 지우고 종료 코드도 0 이다. 화면에는 아무것도 안 나오고, 그 빈 화면은 「7800 연결이 없다 = 정책이 먹었다」로 읽힌다 — 이 절이 보여 주려는 것과 정반대다. 2026-09-17 에 갓 만든 게스트에서 그대로 겪었다(observed).

두 노드 각각에 먼저 깐다. 깔고 나면 아래 두 줄이 그대로 나왔다(observed).

sudo apt install -y conntrack

패키지를 안 깔고 보려면 커널 표를 직접 읽는다. 같은 내용이고 앞에 ipv4 2 두 칸과 zone=0 이 더 붙는 것만 다르다(observed).

sudo grep 7800 /proc/net/nf_conntrack

이 실험대는 이렇게 했다(observed)

sudo conntrack -L 2>/dev/null | grep 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'

따라 하는 사람은 둘째 줄을 나눌 수 있다. 한 줄에 SSH 접속과 원격 셸의 인용을 겹쳐 놓지 않고, 붙어서 친다. 명령 수는 하나에서 셋으로 늘고 행동 하나가 명령 하나가 된다. 이 세 줄 형태는 이 실험대에서 치지 않았다(unknown).

ssh kc-lab-2
sudo conntrack -L 2>/dev/null | grep 7800
exit

폴더 README 는 게스트 셸이 필요한 것은 nft·tc·systemctl 같은 노드 자체를 건드리는 명령뿐이라고 적는데, conntrack 이 정확히 그 경우다.

실측(observed) — 05-conntrack-problem.txt

--- kc-lab-1 ---
  tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1
  tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 src=10.42.1.43 dst=10.42.0.35 sport=57800 dport=50477 [ASSURED] mark=0 use=1
--- kc-lab-2 ---
  tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 ...
  tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] ...

2>/dev/nullconntrack 이 stderr 로 찍는 「N flow entries have been shown」 요약을 지우려는 것이고, 가이드는 처음에는 빼고 쳐서 그 줄도 한번 보라고 적는다. 상태 열을 읽는다 — ESTABLISHED 는 양방향 통신이 성립해 규칙 평가를 건너뛰고, [ASSURED] 는 표가 꽉 차도 안 지워지는 오래된 연결이며, SYN_SENT [UNREPLIED]정책이 동작하고 있다는 증거다. dport=57800 도 ESTABLISHED 로 살아 있다.

NetworkPolicy 는 이미 붙어 있는 것을 떼어내지 못한다. 보안 사고 대응으로 「지금 당장 이 통신을 끊어라」에 NetworkPolicy 를 걸면 새 연결만 막히고 진행 중인 연결은 계속된다.

conntrack 항목은 위 출력의 값을 그대로 넣어 지운다. 튜플이 정확해야 지워진다.

sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 40023 --dport 7800
sudo conntrack -D -p tcp -s 10.42.1.43 -d 10.42.0.35 --sport 7800 --dport 40023
sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 50477 --dport 57800

kc-lab-2 에서도 같은 일을 한다. 서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있다. 삭제 건수를 본다 — 0 flow entries have been deleted 면 튜플이 틀린 것이고, --dport 7800 만 주면 0 건이 나온다. 실제로 원래 실행에서 그렇게 나왔다.

지운 뒤에는 다시 세어 확인한다. 「지웠다」와 「없어졌다」는 다른 주장이다.

sudo conntrack -L 2>/dev/null | grep -c 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep -c 7800'

삭제가 걸렸을 때 나오는 형태는 이렇다(observed).

conntrack v1.4.7 (conntrack-tools): 1 flow entries have been deleted.

가이드가 여기서 정직하게 적어 둔 것이 있다(observed) — 원래 실행에서 conntrack 을 지운 뒤에도 vendor_cluster_size 는 계속 2 였다. 해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고 정정했고, 실제 하락은 파드가 재시작된 4초 뒤에 일어났다. 이 단계만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다.

확실하게 분단을 만드는 방법은 정책이 걸린 채 파드를 재시작하는 것이다.

가이드는 여기에 단서를 하나 붙인다 — 정책이 걸린 채 파드가 스스로 재시작하는 일도 있고, 원래 실행에서 실제로 그랬다(startTime 11:44:23). RESTARTSstartTime 이 이미 바뀌어 있으면 delete 를 칠 필요도 없다.

date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab delete pod keycloak-0

실측(observed) — 08-restart-forced-partition.txt

재시작 시각: 11:46:07
pod "keycloak-0" deleted from keycloak-lab namespace
keycloak-0   false   10.42.1.67   2026-09-04T02:44:23Z

StatefulSet 이 같은 이름으로 곧바로 다시 만든다. 새 IP 를 반드시 다시 잡는다 — 10.42.1.43 에서 10.42.1.67 로 바뀌었다.

kubectl -n keycloak-lab get pods -o wide | grep keycloak
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
echo "$K0"

관찰

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

실측(observed)

  keycloak-0: 11:45:27=1  11:45:57=1  11:46:27=1  11:46:57=1  11:47:27=1
  keycloak-1: ... 11:43:57=2  11:44:27=1  11:44:57=1  ... 11:47:27=1

양쪽 다 1. 서로를 멤버로 안 세고 있다. 로그가 까닭을 말한다.

kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20

실측(observed)

GMS: JOIN(keycloak-0-26403) sent to keycloak-1-48749 timed out   ← 10회
GMS: too many JOIN attempts (10): becoming singleton             ← 포기
ISPN000094: new cluster view [keycloak-0-26403|0] (1) [keycloak-0-26403]

새로 뜬 keycloak-0 은 DB 에서 keycloak-1찾았고 주소도 안다. 그런데 JOIN 메시지가 7800 으로 안 간다. 디스커버리는 살아 있고 트랜스포트만 죽은 상태다.

split brain 은 DB 한 줄로 확인된다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"

실측(observed) — 06-partition-observed.txt

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-0-26403 | 10.42.1.67:7800 | t     ← 코디네이터
 keycloak-1-48749 | 10.42.0.35:7800 | t     ← 코디네이터

coord = t 가 둘. 서로를 못 보니까 각자 자기가 대장이라고 생각한다. 분단을 확인하는 가장 짧은 명령이 이것이다.

분단된 노드는 스스로 빠진다.

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

-o 값을 따옴표로 묶는다. 묶지 않으면 zsh 가 [0] 을 글로브로 읽어 명령이 아예 안 돈다 — 이 실험대의 lab host 가 zsh 다(2026-09-17, observed).

zsh: no matches found: custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,...

실측(observed) — 11-service-impact.txtkeycloak-0 false 0, keycloak-1 true 0. 2026-09-17 에도 같은 두 줄이 나왔다(observed).

★ 그 false 는 파드를 지우자마자 나오지 않는다. readiness 프로브가 periodSeconds: 10 · failureThreshold: 3 이라 헬스가 먼저 503 이 되고 그로부터 최대 30초 뒤에 조건이 뒤집힌다. 2026-09-17 에 지운 지 60초 시점에는 헬스가 이미 503 인데 파드는 아직 1/1 Ready=True 였다(observed). 그 시점 화면만 보고 「분단이 서비스에 안 잡혔다」로 읽지 않는다 — 1~2분 두고 다시 본다.

까닭은 헬스 본문에 있다.

kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions

이 명령이 내는 것은 조건표다(2026-09-17, observed).

Conditions:
  Type                        Status
  PodReadyToStartContainers   True
  Initialized                 True
  Ready                       False
  ContainersReady             False

★ 아래 JSON 은 이 명령의 출력이 아니다. 헬스 엔드포인트를 직접 쳐야 나온다. 그 명령이 가이드에 없어서 2026-09-17 에 만들어 쳤다(observed).

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run hp --rm -i --restart=Never \
  --image=curlimages/curl:8.11.1 --quiet --env="K0=$K0" \
  --command -- sh -c 'curl -s "http://$K0:9000/health/ready"'

실측(observed)

{ "status": "DOWN",
  "checks": [
    { "name": "Keycloak cluster health check", "status": "DOWN",
      "data": { "Failing since": "2026-09-04 02:45:14,251" } },
    { "name": "Keycloak database connections async health check", "status": "UP" } ] }

Keycloak 은 클러스터 분단을 readiness 로 신고한다. DB 는 UP 인데 클러스터가 DOWN 이고, 쿠버네티스가 그 신고를 받아 처리한다.

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"

여기도 -o 값을 따옴표로 묶는다. [*] 가 앞의 [0] 과 같은 이유로 글로브다.

실측(observed) — ready 주소: [10.42.0.35], notReady : [10.42.1.67]. 2026-09-17 에는 이렇게 나왔다(observed).

NAME             ADDR                        READY
keycloak-lxk8h   [10.42.0.13],[10.42.1.17]   true,false

kubectl get endpoints 는 쓰지 않는다 — v1.33 부터 deprecated 라 경고가 뜬다.

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

★ 인증서 단계를 건너뛴 실험대라면 443 을 듣는 것이 없어 여기는 000 이다. 그때는 TLS 를 빼고 Host 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 200 을 받았다(observed).

curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: auth.hyeonworks.com' http://192.168.122.10/realms/master

실측(observed) — 정문은 HTTP 200, 토큰 발급도 HTTP 200. 분단된 노드가 스스로 로드밸런서에서 빠졌고 서비스는 계속됐다. liveness 였다면 재시작을 반복했을 텐데 재시작해도 안 나아지는 문제이므로 readiness(격리)가 맞는 신호다. 다만 비대칭이라서 살았다 — keycloak-1 은 「멤버가 하나 줄어든」 정상적인 사건이라 Ready 를 유지했고, keycloak-0합류 자체를 못 해 DOWN 이 됐다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애이고, 그것이 A-5 의 주제다.

본 시험은 Service 를 쓰면 안 된다. keycloak-0 이 NotReady 라 Service 로 보내면 전부 keycloak-1 로 간다. 새 IP 로 임시 파드를 다시 띄우고 파드 IP 로 직접 친다.

TOK=/realms/master/protocol/openid-connect/token

# [1] keycloak-0 에서 로그인
R=$(curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo   # sid 를 적어 둔다

# [2] keycloak-1 에서 refresh
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
echo "$R" | tail -1
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')

# [3] keycloak-1 에서 로그아웃
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  "http://$K1:8080/realms/master/protocol/openid-connect/logout" \
  -d client_id=admin-cli -d "refresh_token=$RT"

# [4] keycloak-0 에서 재갱신 시도
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"

★ [4] 는 응답 본문을 통째로 찍는다. 분단 중에는 그 요청이 성공하므로 access_tokenrefresh_token 이 화면에 그대로 나오고, 화면을 갈무리하면 증거 파일에도 그대로 들어간다. 2026-09-17 실행에서 실제로 그렇게 나와 길이만 남기고 가렸다(observed). 판정에 필요한 것은 코드와 error 뿐이므로, 갈무리할 생각이면 아래 형태로 친다.

curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT" \
  -w '\n%{http_code}\n' -o /tmp/r
sed -n 's/.*"error":"\([^"]*\)".*/error=\1/p' /tmp/r

200 이면 error 줄이 안 나오고, 그 없음이 곧 「로그아웃이 전파되지 않았다」다.

실측(observed) — 09-cross-node-under-partition.txt

  [1] keycloak-0 로그인          sid=nShl5TaBrZnKStDqaspjgmJB
  [2] keycloak-1 에서 refresh     HTTP 200      ← 예측대로
  [3] keycloak-1 에서 로그아웃    HTTP 204
  [4] keycloak-0 에서 재갱신 시도 HTTP 200      ← 400 이어야 했다

2026-09-17 에 이 네 단계를 다시 쳐서 같은 네 값을 받았다(observed) — sid=XliiKcW3DYw4hY_a3m2FX6e4 · 200 · 204 · 200. 정책을 지우자 30~60초 안에 두 노드가 다시 2 를 보고했고 두 파드 모두 1/1 로 돌아왔다(observed).

[2] 세션 공유는 예측이 맞았다. 클러스터가 갈라졌는데도 한쪽에서 만든 세션을 반대쪽이 갱신했다 — 세션은 7800 으로 다니지 않는다. [4] 로그아웃 전파는 예측이 틀렸다. 대조군에서 400 이던 곳이 200 이다.

[4] 의 200 이 「로그아웃이 아예 안 됐다」는 뜻인지 확인해야 한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, offline_flag, last_session_refresh
      from offline_user_session where user_session_id='nShl5TaBrZnKStDqaspjgmJB'"

실측(observed) — 10-logout-not-propagated.txt

 user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows)                                            ← DB 행은 삭제되었다
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique{cache="sessions"}'

실측(observed) — keycloak-1 kc-lab-1 = 0, keycloak-0 kc-lab-2 = 1. 캐시에는 남아 있다.

   keycloak-1 로그아웃
        │
        ├──▶ PostgreSQL 행 삭제                     ✔ 되었다
        │
        └──▶ keycloak-0 에게 "캐시에서 지워라"       ✗ 7800 이 막혀 못 갔다
                                                        │
   keycloak-0 은 자기 캐시로 200 을 준다  ◀────────────┘

룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다. 세션 조회는 PostgreSQL 을 타고 세션 무효화는 클러스터 메시지(7800)를 타므로, 7800 을 막으면 조회는 정상이고 무효화만 전파되지 않는다.

가이드는 실제 사용자도 로그아웃이 안 되는지를 따로 답한다 — 아니다. 파드 IP 로 직접 쳤기 때문이고, 실제 사용자는 nginx → Traefik → Service 를 거치는데 NotReady 인 keycloak-0 은 거기서 빠져 있다.

복구와 원상복구 확인표

date '+%H:%M:%S 해제'
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport

실측(observed) — 12-recovery.txt

해제 시각: 11:49:58
networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace

30초 간격으로 vendor_cluster_size 를 몇 번 친다.

실측(observed)

  +30초   keycloak-0=1 keycloak-1=1   | Ready 파드 2 개
  +60초   keycloak-0=1 keycloak-1=1   | Ready 파드 2 개
  +90초   keycloak-0=2 keycloak-1=2   ← 재형성

해설 문서는 같은 회복을 절대 시각이 붙은 계열로도 남겼다(observed) — vendor_cluster_size 가 양쪽에서 2 → 1 → 2 로 움직인 자리다.

=== vendor_cluster_size ===
keycloak-1: 11:43:57=2  11:44:27=1  ...  11:51:28=2
keycloak-0: 11:43:57=2  (파드 교체)  11:45:27=1  ...  11:51:28=2

90초 만에 자동으로 다시 붙었고 사람 손이 필요 없었다. 누가 붙였는지는 카운터가 말한다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'

실측(observed) — merge_events keycloak-0 = 1, merge_events keycloak-1 = 1. 주입 전에 0.0 이던 값이 1 이다. MERGE3 는 split brain 을 감지해 갈라진 뷰를 병합하는 JGroups 프로토콜이고, 지표가 0 → 1 로 오른 것이 「MERGE3 가 실제로 일했다」는 증거다.

코디네이터도 하나로 돌아온다.

실측(observed)

 keycloak-0-26403 | 10.42.1.67:7800 | t
 keycloak-1-48749 | 10.42.0.35:7800 | f     ← 코디네이터가 하나로 돌아왔다

코디네이터가 keycloak-1 에서 keycloak-0 으로 넘어갔다. 코디네이터는 특권이 아니라 역할이며 병합 시 재선출되므로 주입 전과 달라도 정상이다.

항목 명령 돌아왔을 때
정책 kubectl -n keycloak-lab get networkpolicy No resources found
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
클러스터 뷰 kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2), 양쪽 동일
디스커버리 psql -c "select name, ip, coord from jgroups_ping order by name" coord = t하나
지표 vendor_cluster_size 양쪽 2
임시 파드 kubectl -n keycloak-lab get pod kc-probe NotFound (없어야 정상)
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

conntrack 은 지운 채로 두면 된다. 표는 새 패킷이 오면 다시 채워진다.

막히면

증상 원인 확인
정책을 걸었는데 지표가 안 변한다 conntrack 의 ESTABLISHED 가 먼저 통과시킨다 sudo conntrack -L 2>/dev/null | grep 7800
conntrack -D0 flow entries 튜플이 틀렸다. --dport 만으로는 0건 -L 출력의 src/dst/sport/dport 를 그대로 옮긴다
conntrack 을 지웠는데도 계속 2 이 실험은 그것만으로 분단되는지 판정 못 했다 정책이 걸린 채 파드를 재시작한다
파드가 재시작을 반복한다 (RESTARTS 증가) 9000 을 안 열었다. readiness 실패 → kubelet 이 죽인다 describe pod 의 Events. 매니페스트에 9000 이 있는지
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 임시 curl 파드를 띄우거나 Prometheus 에 묻는다
refresh 가 계속 keycloak-1 로만 간다 Service 로 보냈다. NotReady 파드는 빠진다 파드 IP 로 직접
재시작 뒤 아무 데도 안 닿는다 파드 IP 가 바뀌었다 (10.42.1.43 → 10.42.1.67) get pod -o jsonpath='{.status.podIP}' 다시
kubectl get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=...
로그인이 401/400 비밀번호가 안 넘어갔다 파드 안에서 echo ${#PW} — 0 이면 --env 가 빈 값
값이 빈 문자열인데 「변했다」로 읽힌다 원래 실행이 이 실수를 했다 빈 값은 「측정 실패」다. 판정 조건에서 빼고 다시 잰다
복구 후 2~3분이 지나도 1 MERGE3 주기 밖이거나 정책이 안 지워졌다 get networkpolicy 로 먼저 확인

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

  • (observed) 차단 11:38:08·재시작 11:46:07·해제 11:49:58, 25분 내내 2 이던 vendor_cluster_size, /proc/net/tcp601, conntrack 네 줄, coord = t 가 둘, 분단 중 교차 refresh 200 과 로그아웃 후 200, DB 행 0 과 캐시 1, 90초 재형성, merge_events0 → 1.
  • (unknown) tr ',' '\n' | grep -E 로 자른 Prometheus 출력과 jq 형태. jq 는 이 실험대에 아예 없다.
  • (unknown) ssh kc-lab-2 로 들어가서 conntrack -L 을 따로 치는 세 줄 형태. 이 실험대는 ssh kc-lab-2 '...' 한 줄로 쳤다.
  • 이 실험이 판정하지 못한 것 — conntrack 삭제만으로 분단이 만들어지는지. 해설 문서가 「3분 뒤 분단」이라고 썼다가 정정했고, 실제 하락은 파드 재시작 4초 뒤였다.
  • 이 실험이 재지 않은 것keycloak-0 캐시에 있던 낡은 엔트리가 병합 후 어떻게 되는지. 궁금하면 재형성 뒤에 vendor_statistics_approximate_entries_unique{cache="sessions"} 를 다시 본다.
  • (observed) 가이드가 스크립트를 안 쓰는 까닭 — 원래 실행은 임시 파드를 20초마다 띄워 지표를 긁었고, +20초 suspected(k0 k1) = [] 처럼 빈 값과 개수가 안 맞는 값이 섞였다. 판정 조건이 [ "$R" != "0.0 0.0 " ] 이어서 빈 문자열을 「변화」로 읽고 즉시 빠져나왔다.

A-2 — PostgreSQL 을 내리면 살아남는 노드가 있는가

근거: a2-database-loss.md (956줄). 실행 기록은 2026-09-04 11:5311:58 KST(observed).

이 실험이 가르는 것

A-1 에서 룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 200 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다.

A-1 과의 대비가 이 실험의 값이다.

   A-1  7800 차단   →  한쪽만 빠지고 서비스는 계속됐다   (용량 저하)
   A-2  DB 정지     →  ?                                 (여기서 판정)

네 경로를 구분해서 본다. 하나만 재면 무엇 때문에 죽었는지 모른다.

# 경로 무엇을 보는가
캐시를 가진 노드에서 refresh 캐시가 DB 를 대신할 수 있는가
캐시가 없는 노드에서 refresh 완전한 DB 의존
새 로그인 쓰기 경로
이미 발급된 토큰으로 관리 API 조회 서명만으로 되는 경로가 있는가

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 캐시에 세션을 가진 노드도 refresh 가 500 인 것, JWKS 와 .well-known200 으로 살아 있는 것, Ready 파드가 0개이고 ready 주소가 빈 목록인 것, 정문이 503 을 주는 것, database connections 만 DOWN 인 헬스 본문, up = 1 인 채로 전면 장애가 나 있는 것, 15초 만에 재시작 0회로 스스로 돌아오는 것.

전제와 되돌리기

  • A-0 을 먼저 한다. 「세션은 DB 가 공유한다」를 손으로 확인해 두지 않으면 이 실험의 500 을 해석할 수 없다.
  • 네임스페이스는 keycloak-lab, Prometheus 는 observability 다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용.
  • jq 는 이 실험대 어디에도 없다. 이 가이드는 jq 를 쓰지 않는다.

이건 전면 장애를 만드는 실험이다. 가이드의 경고를 그대로 옮긴다 — 정문(https://auth.hyeonworks.com)이 실제로 503 이 된다. 이 실험대를 쓰는 다른 작업이 있으면 멈춘다. 정지 구간은 1분 남짓으로 짧게 잡는다. 되돌리는 명령은 하나뿐이다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1

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

파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험
kubectl -n keycloak-lab get pods -o wide

실측(observed) — 01-baseline.txt

keycloak-0                  true   10.42.1.67   kc-lab-2
keycloak-1                  true   10.42.0.35   kc-lab-1
postgres-7b474b88c8-sn9ff   true   10.42.1.24   kc-lab-2

postgres 가 어느 노드에 있는지를 본다. 원래 실행에서는 kc-lab-2, 즉 keycloak-0같은 노드였다. 이 실험에서는 상관없지만 A-4(노드 상실)에서는 결정적이다 — 그 노드를 죽이면 A-2 가 함께 일어난다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

실측(observed) — keycloak-0=10.42.1.67 keycloak-1=10.42.0.35.

클러스터 크기는 한 줄짜리 JSON 을 처음 한 번은 그대로 보고, 그다음에 자른다. 자르는 줄은 미검증이다(unknown).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'

실측(observed) — cluster_size keycloak-1 = 2, cluster_size keycloak-0 = 2. 여기가 1 이면 A-1 의 분단이 남은 것이고, 그 위에서 재면 두 실험이 섞인다.

계측 도구를 A-1 에서 바꾼다. --rm 임시 파드는 매번 만들고 지우므로 느리고 경합이 있고, 토큰을 단계 사이로 넘길 수 없다. 이 실험은 DB 정지 전에 발급한 토큰을 정지 후에 써야 하므로 파드를 하나 띄워 두고 exec 로 단계를 이어간다.

가이드가 이 자리에 원칙으로 적어 둔 문장이 있다.

임시 파드는 계측 도구가 아니다. 15초마다 이미 긁고 있는 Prometheus 가 그러라고 있는 것이고, 사람이 손으로 묻는 것은 상주 파드가 낫다.

A-1 의 계측 실패(#9, 일회성 파드의 stdout 유실)가 이 규칙을 만들었다.

kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" --env="K1=$K1" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s

되돌리기--rm 이 없으므로 직접 지운다.

kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found

비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 확인한다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측(observed) — 19.

kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'

PW길이=0 이면 --env 가 빈 값을 넘긴 것이므로 파드를 지우고 다시 띄운다. 이제부터는 파드 셸에 들어가 친다. 나올 때는 exit 이고 파드는 안 지워진다.

kubectl -n keycloak-lab exec -it a2-probe -- sh

① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야 한다. A-0 에서 확인한 성질을 그대로 쓴다 — 각 노드는 자기가 로그인시킨 세션만 캐시한다.

TOK=/realms/master/protocol/openid-connect/token
for H in "$K0" "$K1"; do
  echo -n "$H : "
  curl -s -X POST "http://$H:8080$TOK" \
    -d grant_type=password -d client_id=admin-cli \
    -d username=admin -d "password=$PW" \
    | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
    | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
    | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p'
done

실측(observed) — 02-setup-sessions.txt

=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
  keycloak-0 에서 로그인  sid=EAXV5HcG2J1BZ3vnwONf64AQ  토큰길이=613
  keycloak-1 에서 로그인  sid=McyTj5lj3n_JqApCXeuAHExc  토큰길이=613

빈 줄이 나오면 로그인이 실패했거나 base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
  | tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'

실측(observed) — 02-setup-sessions.txt

=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
  keycloak-1   = 0 건
  keycloak-0   = 1 건

가이드는 keycloak-10 인 것은 스크레이프 지연 때문이라고 밝힌다. 방금 로그인했으므로 다음 15초 스크레이프에서 1 이 될 수 있고, 원래 실행 기록에도 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」으로 적혀 있다. 판정에 필요한 것은 「양쪽이 다르다」가 아니라 「keycloak-0 이 확실히 가지고 있다」뿐이다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

실측(observed) — 온라인 세션 2. 복구 후에 세션이 살아남았는지 볼 대조군이므로 적어 둔다.

④ 에 쓸 클라이언트 id 를 지금 뽑아 둔다. DB 가 죽은 뒤에는 이 조회 자체가 실패한다. 응답을 한 번 그대로 보고, 잘라내는 줄은 미검증으로 친다(unknown).

AT=$(curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d "password=$PW" \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
curl -s -H "Authorization: Bearer $AT" \
  "http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
CID=$(curl -s -H "Authorization: Bearer $AT" \
  "http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
  | tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
echo "CID=$CID"

네 경로를 정상 상태에서 한 번 돌린다.

R0=$(curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
RT0=$(echo "$R0" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
R1=$(curl -s -X POST "http://$K1:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
RT1=$(echo "$R1" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')

curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
  -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
  "http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"

정상 상태에서는 네 줄이 전부 200 이다. -o /dev/null 을 빼면 안 된다 — 빼면 본문과 상태코드가 한 줄에 섞여 나오고, 원래 실행이 정확히 그것을 당했다.

상태가 필요 없는 경로도 미리 재 둔다.

curl -s -o /dev/null -w 'JWKS      %{http_code}\n' \
  "http://$K0:8080/realms/master/protocol/openid-connect/certs"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
  "http://$K0:8080/realms/master/.well-known/openid-configuration"

밖에서 정문도 재 둔다.

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

주입

scale --replicas=0 을 쓴다. 다른 두 방법으로는 이 실험이 성립하지 않는다.

방법 무엇이 일어나나
scale --replicas=0 파드가 정상 종료되고 다시 만들어지지 않는다
delete pod Deployment 가 곧바로 새로 만든다 — 몇 초 만에 돌아온다
노드 정지 Keycloak 도 같이 죽는다 — 두 장애가 섞인다

이것은 정상 종료다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤 내려가므로 데이터는 하나도 잃지 않는다. 강제로 죽였을 때 무엇을 잃는지는 A-3 이 잰다.

date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
date '+%H:%M:%S 삭제완료'

실측(observed) — 03-four-paths.txt

=== [2] PostgreSQL 정지 ===
  정지 시각: 11:56:04
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-sn9ff condition met
  삭제 완료: 11:56:04

두 시각이 같다. 즉시 사라진다.

access token 수명이 60초다. 위에서 발급한 AT 로 ④ 를 재려면 발급 → 정지 → 시험을 60초 안에 끝내야 한다. 60초를 넘기면 ④ 의 401 이 「DB 때문」인지 「토큰 만료」인지 구별되지 않는다. 시간이 지났으면 토큰을 다시 받아 두되, 그건 DB 가 있어야 되는 일이므로 순서는 「토큰 발급 → 정지」다.

주입 검증

kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get deploy postgres

postgres 로 시작하는 줄이 한 개도 없어야 하고, Deployment 는 0/0 이어야 한다. 0/1 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.

kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'

실측(observed) — 04-health-and-service.txt

	at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664)
	at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645)
Caused by: java.net.ConnectException: Connection refused
	at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219)
	at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365)

Connection refusedagroal 을 본다. agroal 은 Quarkus 의 커넥션 풀이고, 풀이 새 커넥션을 만들지 못한다. 이 줄이 없으면 Keycloak 은 아직 옛 커넥션으로 버티고 있거나, 애초에 DB 가 안 죽은 것이다. timed out 이 아니라 Connection refused 가 나오는 것은 Service 는 남아 있고 뒤에 파드가 없어 연결이 즉시 거부되기 때문이다.

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

실측(observed) — keycloak-0 false 0, keycloak-1 false 0. 2026-09-17 에도 같은 두 줄을 받았다(observed).

★ 그 false 는 DB 를 내리자마자 나오지 않는다. readiness 프로브가 periodSeconds: 10 · failureThreshold: 3 이라 헬스가 먼저 실패하고 그로부터 최대 30초 뒤에 조건이 뒤집힌다. 2026-09-17 에 내린 직후에는 네 경로가 이미 전부 500 인데 파드는 아직 true true 였고 정문도 200 이었다. 약 50초 뒤에 false false · 정문 503 이 됐다(observed).

정지 직후      네 경로 500 500 500 500 · ready true,true  · 정문 200
약 50초 뒤     ready false,false · 엔드포인트 false,false · 정문 503

그 50초 사이의 화면을 보고 「DB 가 없어도 멀쩡하다」로 읽지 않는다. 이미 500 인데 아직 트래픽에서 안 빠졌을 뿐이고, 이 절이 잡으려는 것도 그 어긋남이다.

READYfalse 인데 RESTARTS 가 여전히 0 이다. 파드는 죽지 않았고 트래픽에서 빠졌을 뿐이다. RESTARTS 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을 재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다. restarts=0 이 자동 회복이라는 결론을 가능하게 하는 조건이다.

관찰

탐침 파드 안에서 주입 전과 똑같은 명령을 다시 친다. 파드 셸에서 나갔다 들어오면 RT0 RT1 AT CID 가 사라지므로 셸을 붙잡고 있는 편이 낫다.

kubectl -n keycloak-lab exec -it a2-probe -- sh

실측(observed) — 03-four-paths.txt04-health-and-service.txt

  ① 캐시를 가진 노드(keycloak-0)에서 refresh   HTTP 500
  ② 캐시가 없는 노드(keycloak-1)에서 refresh   HTTP 500
  ③ 새 로그인                                  HTTP 500
  ④ 관리 API (세션 조회 필요)                   HTTP 500

본문도 한 번 그대로 본다.

curl -s -X POST "http://$K0:8080$TOK" \
  -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"

실측(observed)

{"error":"unknown_error","error_description":"For more on this error consult the server log."}

본문이 아무것도 말해 주지 않는다. 원인은 주입 검증에서 본 서버 로그에만 있다.

④ 의 첫 측정은 오염됐다. 원래 실행의 증거 파일에는 이렇게 남아 있다.

실측(observed) — 03-four-paths.txt

  ④ 이미 발급된 access token 으로 관리 API   HTTP 000000{"error":"HTTP 401 Unauthorized"}401

세 가지가 한 줄에 뭉쳐 있다.

HTTP 000000{"error":"HTTP 401 Unauthorized"}401
     ─┬────  ──────────┬─────────────────── ─┬─
      │                │                     └─ 마지막 시도의 상태코드
      │                └─ 응답 본문이 그대로 섞였다
      └─ 재시도가 세 번 "000" 을 찍었다 (연결 실패)

curl -w '%{http_code}' 를 쓰면서 -o /dev/null 을 빼면 본문이 표준출력으로 같이 나오고, 거기에 --retry 까지 걸려 있어 실패한 시도의 000 이 앞에 쌓였다. 위 표의 ④ 500 은 복구 절에서 다시 잰 값이고, 첫 측정은 그대로 쓰지 않았다. 오염된 측정은 버리고 다시 잰다.

① 이 500 인 것이 이 실험의 핵심이다. 캐시에 세션을 들고 있어도 refresh 는 실패한다.

   refresh 처리
     ├── 세션이 존재하는가        →  캐시로 답할 수 있다
     └── LAST_SESSION_REFRESH 갱신 →  DB 쓰기가 필요하다   ← 여기서 죽는다

A-0 에서 잡은 SQL 그대로다.

update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...

캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다. refresh 는 이름과 달리 쓰기 연산이다.

상태가 필요 없는 경로는 살아남는다.

curl -s -o /dev/null -w 'JWKS      %{http_code}\n' \
  "http://$K0:8080/realms/master/protocol/openid-connect/certs"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
  "http://$K0:8080/realms/master/.well-known/openid-configuration"

실측(observed) — 04-health-and-service.txt

=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
  JWKS 엔드포인트(realm 공개키)  HTTP 200
  realm 메타데이터(.well-known)  HTTP 200
  관리 API(세션 조회 필요)       HTTP 500

같은 파드, 같은 포트인데 경로에 따라 200500 이 갈린다. realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답하고, 이론적으로는 이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다는 뜻이다. 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 거기까지가 말할 수 있는 범위라고 가이드는 적는다.

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"

실측(observed) — 04-health-and-service.txt

=== Service 엔드포인트 ===
  ready   : []                              ← 비었다
  notReady: [10.42.0.35 10.42.1.67]

ready 가 빈 목록이다. kubectl get endpoints 는 v1.33 부터 deprecated 라 경고가 뜨고, 원래 실행 기록에도 그 경고가 두 줄 남아 있다(observed).

Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice

사람이 눈으로 볼 때는 이쪽이 더 짧다고 가이드는 덧붙인다.

kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints

밖에서 본다. 한 번 눈으로 볼 때는 헤더까지 본다.

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master

실측(observed) — https://auth.hyeonworks.com/realms/master HTTP 503. 503 은 Keycloak 이 준 것이 아니다. Ready 인 백엔드가 하나도 없어서 그 앞의 프록시가 준 것이고, 200 이던 JWKS 도 정문으로는 닿지 않는다.

A-1 (7800 차단) A-2 (DB 정지)
Ready 인 파드 keycloak-1 1개 생존 0개
Service ready [10.42.0.35] []
외부 응답 200 503
성격 용량 저하 전면 장애

노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다. Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.

헬스 본문이 까닭을 말한다. Keycloak 이미지에는 curl 이 없으므로 파드 밖에서 묻는다.

kubectl -n keycloak-lab exec a2-probe -- \
  curl -s "http://$K0:9000/health/ready"

실측(observed) — 04-health-and-service.txt

=== health/ready 상세 ===
  전체: DOWN
    Graceful Shutdown                                    UP
    Keycloak cluster health check                        UP
    Keycloak database connections async health check     DOWN
    Keycloak Initialized                                 UP

네 항목 중 하나만 DOWN 인데 전체가 DOWN 이다. 헬스체크는 모든 항목이 UP 이어야 UP 이다. 그리고 cluster health 는 UP 이다 — A-1 에서는 정확히 반대였다(cluster DOWN, database UP). **같은 503 이라도 어느 체크가 DOWN

인지가 장애를 구별한다.**

같은 것을 파드 밖에서 조건으로 보는 방법도 가이드에 있다.

kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions

관측의 함정이 여기 있다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up' \
  | tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]'

실측(observed) — 05-recovery.txt

=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
  up{pod=keycloak-1} = 1   ← 1 인데 서비스는 503 이다
  up{pod=keycloak-0} = 1   ← 1 인데 서비스는 503 이다

Grafana Explore 에서 up{job="keycloak"} 을 그려 보면 전 구간 평평하다. 원래 실행의 그림이 a2-up-stayed-1-during-outage.png 이고, 11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다. upPrometheus 가 /metrics 를 긁는 데 성공했는가만 말한다 — 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었고, 기능은 전멸했다.

지표 이 장애에서
up 1 — 아무것도 알려주지 않는다
파드 Ready false — 여기서 드러난다
외부 HTTP 코드 503 — 사용자가 겪는 것

가이드는 A-0 이 up 을 「가장 중요한 합성 지표」라고 쓴 것을 절반만 맞다고 정정한다. up대상이 사라진 것을 잡지만 대상이 살아서 못 쓰는 것은 못 잡고, 후자가 운영에서 훨씬 흔하다. 알림은 up 이 아니라 readiness 와 외부 응답 코드에 건다.

그럼 readiness 를 지표로 볼 수 있는지 물어본다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \
  | head -c 300; echo

결과가 비어 있다.

{"status":"success","data":{"resultType":"vector","result":[]}}

이 실험대에는 아직 kube-state-metrics 가 없어 파드 readiness 가 지표로 남지 않는다. 지금 이 장애는 Prometheus 만 보고 있으면 알 수 없고, 관측 스택에 빠진 것을 이 실험이 찾아냈다.

복구와 원상복구 확인표

date '+%H:%M:%S 재기동'
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s

실측(observed) — 05-recovery.txt

=== 복구 — PostgreSQL 재기동 ===
  재기동 시각: 11:57:09
deployment.apps/postgres scaled
Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated...
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out

여기서 Keycloak 을 재시작하고 싶어진다. 참는다. 재시작하면 이 실험이 답하려던 물음(「사람 개입이 필요한가」)이 사라진다. 15초 간격으로 몇 번 친다.

kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \
  | grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

실측(observed)

=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
  +15초  keycloak-0                  true keycloak-1                  true  | 외부 HTTP 200
  → 서비스 복귀

재시작 없이 회복한 것이 맞는지 따로 센다.

kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount" | grep keycloak

실측(observed) — keycloak-0 0, keycloak-1 0. 주입 검증에서 본 값 그대로다. 커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에 복귀했고, 사람이 한 일은 DB 를 켠 것뿐이다. 회복 시간은 DB Ready 이후 약 15초, Keycloak 재시작은 불필요(restarts=0)였다.

가이드는 여기서 readiness 와 liveness 를 가르는 기준을 적는다 — liveness 는 실패하면 재시작이라 재시작하면 나아지는 문제(교착, 메모리 누수)에 쓰고, readiness 는 실패하면 트래픽에서 격리라 재시작해도 안 나아지는 문제(의존 대상이 죽음)에 쓴다. DB 장애에 liveness 를 걸면 재앙이다 — 모든 파드가 무한 재시작하고, DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어지며, 재시작하면 캐시까지 날아간다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

실측(observed) — online 세션 5. 주입 전에 적어 둔 값보다 크거나 같다. 실험 중에 로그인을 여러 번 했으므로 늘어나 있다. 세션은 DB 에 있으므로 DB 가 돌아오면 같이 돌아오고, 정상 종료였기 때문에 하나도 잃지 않았다.

항목 명령 돌아왔을 때
DB kubectl -n keycloak-lab get deploy postgres 1/1
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running, RESTARTS 0
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
헬스 exec a2-probe -- curl -s "http://$K0:9000/health/ready" 전체 UP
클러스터 vendor_cluster_size 양쪽 2
탐침 파드 kubectl -n keycloak-lab get pod a2-probe 지웠으면 NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

탐침 파드는 직접 지운다. sleep 7200 이 끝나면 파드는 Completed 로 남고 자동으로 사라지지 않는다 — 다음 실험에서 a2-probe 이름이 이미 있다고 거절당하는 원인이 그것이다.

kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found

막히면

증상 원인 확인
출력이 HTTP 000000{...}401 처럼 뭉쳐 나온다 -o /dev/null 을 뺐다. 본문과 코드가 섞였다 오염된 측정은 버리고 다시 잰다
000 이 앞에 붙어 나온다 --retry 가 걸려 실패 시도의 코드까지 찍었다 재시도를 빼고 --max-time 만 쓴다
DB 를 내렸는데 몇 초 만에 돌아온다 delete pod 를 썼다. Deployment 가 새로 만든다 scale --replicas=0
④ 가 401 이다 access token 이 만료됐다 (수명 60초) 토큰 발급 → 정지 → 시험을 60초 안에
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드로 치거나 Prometheus 에 묻는다
파드 셸에 다시 들어갔더니 변수가 없다 exec 세션이 끝나면 셸 변수는 사라진다 셸을 붙잡고 있는다. 터미널 두 개
kubectl get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=...
up 이 1 이라 정상인 줄 알았다 up 은 스크레이프 성공만 말한다 readiness 와 외부 코드를 본다
kube_pod_status_ready 결과가 비었다 kube-state-metrics 가 이 실험대에 없다 보완 항목이다. 지금은 kubectl 로 본다
복구했는데 계속 503 Keycloak 이 아직 재연결 중이다 15~30초 더 기다린다. 재시작하지 않는다
a2-probe 를 다시 못 만든다 옛 파드가 Completed 로 남아 있다 delete pod a2-probe --ignore-not-found
로그인이 401/400 비밀번호가 안 넘어갔다 exec a2-probe -- sh -c 'echo ${#PW}'0 이면 --env 가 빈 값

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

  • (observed) 정지 11:56:04·재기동 11:57:09, 네 경로 전부 500, JWKS 와 .well-known200, ready : [], 정문 503, 헬스 네 항목 중 database connections 만 DOWN, up 이 양쪽 1, kube_pod_status_ready 결과가 빈 배열, restarts=0, 복구 세션 5, 비밀번호 길이 19.
  • (unknown) tr ',' '\n' | grep -E 로 자른 Prometheus 출력 셋과 CID 를 뽑는 줄. 가이드가 미검증으로 표시했다.
  • 한 번은 버린 측정이 있다 — ④ 의 첫 측정 HTTP 000000{"error":"HTTP 401 Unauthorized"}401-o /dev/null 을 빼고 --retry 를 걸어 나온 오염된 값이라 쓰지 않았다. 표의 500 은 복구 절에서 다시 잰 값이다.
  • 말할 수 있는 범위가 여기까지인 것이 하나 있다 — JWKS 가 살아 있으므로 「이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다」는 이론이고, 이 실험대에 독립 리소스 서버가 없어 확인하지 못했다. B층에서 확인한다.

A-3 — DB 를 강제 종료하면 몇 건이 사라지는가

근거: a3-database-crash.md (993줄). 실행 기록은 2026-09-04 11:5812:05 KST(observed).

이 실험이 가르는 것

A-2 는 DB 를 정상 종료시켰고 세션은 하나도 안 없어졌다. PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려가기 때문이다. 그런데 A-0 에서 이 한 줄을 잡았다.

SET LOCAL synchronous_commit TO OFF

COMMIT 직전, 같은 트랜잭션 안에서 나온다.

   COMMIT
     │
     ├─ WAL 버퍼(메모리)에 기록          ← 항상 한다
     │
     ├─ synchronous_commit = on  : 디스크 플러시를 기다렸다가 응답
     └─ synchronous_commit = off : 기다리지 않고 즉시 응답   ← Keycloak
                                     │
                                     └─ 크래시 시 이 구간이 사라진다

「사라질 수 있다」와 「몇 건 사라졌다」는 다르다. 이 실험은 뒤쪽이고, RPO(Recovery Point Objective, 복구 시점 목표)를 숫자로 만든다.

그리고 이 실험의 절반은 「죽이는 데 실패하는 이야기」다. 세 번 시도해서 세 번째에 성공했고, 앞의 둘은 「손실 0건」으로 보였지만 실제로는 죽인 적이 없었다.

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 로그인 트랜잭션에 붙은 SET LOCAL synchronous_commit TO OFF, --grace-period=0 --force크래시가 아니라는 것, 컨테이너 안에서 PID 1 이 SIGKILL 을 무시하는 것, not properly shut down / redo starts / redo done, 200 과 토큰을 받았는데 DB 에 없는 sid, wal_writer_delay = 200ms 가 기본값이라는 것.

전제와 되돌리기

  • A-0 과 A-2 를 먼저 한다. A-0 이 SET LOCAL synchronous_commit TO OFF 를 발견했고, 이 실험은 그 대가가 몇 건인지를 잰다.
  • 터미널 두 개가 반드시 필요하다. 하나는 로그인 루프를 돌리고(붙잡고 있어야 한다), 하나는 그 사이에 DB 를 죽인다.
  • jq 는 이 실험대 어디에도 없다. 이 가이드는 jq 를 쓰지 않는다.

이건 데이터를 잃는 실험이다. 가이드의 경고를 그대로 옮긴다 — PostgreSQL 을 강제로 죽이고, 세션 테이블을 두 번 비운다. 실제로 커밋됐다고 응답한 데이터가 사라진다. 실험대에서만 한다. 전 구간 약 40분이다.

지운 세션은 돌아오지 않는다. 되돌릴 수 있는 것은 문장 로깅뿐이고, 켜기 전에 먼저 읽어 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"

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

측정 설계가 성립하는지부터 본다. 여기서 하나라도 어긋나면 뒤의 숫자는 아무 의미가 없다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "\d offline_user_session"

실측(observed) — 01-crash-injection.txt

  LAST_SESSION_REFRESH 는 integer(초) — 200ms 손실은 보이지 않는다
 created_on           | integer                 |           | not null |
 last_session_refresh | integer                 |           | not null | 0
    "idx_user_session_expiration_created" btree (realm_id, offline_flag, remember_me, created_on, user_session_id, user_id)
    "idx_user_session_expiration_last_refresh" btree (realm_id, offline_flag, remember_me, last_session_refresh, user_session_id, user_id)

두 시각 컬럼의 타입이 integer 다. 손실 창은 수백 밀리초인데 눈금이 1초라 보일 리가 없고, 「세션 갱신 시각이 되감기는지」 보려던 설계는 버렸다. 대신 행 존재 여부로 잰다.

   로그인 1회  =  OFFLINE_USER_SESSION 행 1개
   클라이언트가 sid 를 받았다  =  서버가 COMMIT 했다고 응답했다
   크래시 후 그 sid 가 없다    =  잃은 것

있거나 없거나이므로 눈금 문제가 없다. 이 실험이 로그인 수백 건을 도는 까닭이 그것이다 — 이진 판정을 여러 번 해서 비율로 만든다.

로그인도 비동기 커밋인지 확인해야 한다. A-0 에서 잡은 것은 refresh 트랜잭션이었고, 로그인(INSERT)도 그런지는 확인하지 않았다. 아니라면 로그인은 안 사라지고 이 측정 설계 자체가 성립하지 않는다. 문장 로깅을 켠다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system set log_statement='all'" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"

none 이면 pg_reload_conf() 가 안 돈 것이다. alter systempostgresql.auto.conf 에 쓸 뿐이고 reload 를 해야 적용된다.

탐침 파드를 띄우고 로그인 한 번을 보낸다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s

가이드는 여기에 별표를 붙인다 — 명령줄에 비밀번호를 직접 쓰지 않는다. 원래 실험의 재현 절차에는 평문 비밀번호가 그대로 적혀 있는데 파드 안 ps 에도 셸 히스토리에도 남는다. --env 로 넘긴 값은 그 파드 안에서만 산다. 존재와 길이만 확인한다 — 실측(observed)으로 19 다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
kubectl -n keycloak-lab exec a3-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
  | grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20

실측(observed) — 02-design-check.txt

=== [설계 확인] 로그인 트랜잭션도 synchronous_commit 을 끄는가 ===
  --- 로그인 트랜잭션 (INSERT 가 있는 것) ---
2:BEGIN
5:COMMIT
6:BEGIN
9:insert into OFFLINE_USER_SESSION (BROKER_SESSION_ID,CREATED_ON,DATA,LAST_SESSION_REFRESH,REALM_ID,REMEMBER_ME,USER_ID,VERSION,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
10:insert into OFFLINE_CLIENT_SESSION (DATA,REALM_ID,TIMESTAMP,VERSION,CLIENT_ID,CLIENT_STORAGE_PROVIDER,EXTERNAL_CLIENT_ID,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
11:SET LOCAL synchronous_commit TO OFF
12:COMMIT

BEGINCOMMIT 사이에 insert into OFFLINE_USER_SESSIONSET LOCAL synchronous_commit TO OFF 가 같이 들어 있다. 앞의 BEGIN/COMMIT (2·5줄)은 다른 트랜잭션이다. 확인됐고, 함의가 refresh 보다 훨씬 무겁다 — refresh 갱신 시각을 잃으면 세션 수명이 조금 짧아질 뿐이고 사용자는 모르지만, 로그인 자체를 잃으면 토큰은 손에 있는데 세션이 없고 다음 요청부터 실패한다.

곧바로 끈다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"

켜 둔 채로 주입에 들어가면 안 된다. 주입 단계는 수백 건의 로그인을 최대한 빨리 도는데, log_statement='all' 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고 로그가 폭주하며 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.

WAL 설정은 재기 전에 잰다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, setting, unit, source from pg_settings
      where name in ('commit_delay','synchronous_commit','wal_writer_delay','wal_writer_flush_after')"

실측(observed) — 08-wal-settings.txt

=== A-3 이 가정만 하고 재지 않은 값 ===
          name          | setting | unit | source
------------------------+---------+------+---------
 commit_delay           | 0       |      | default
 synchronous_commit     | on      |      | default
 wal_writer_delay       | 200     | ms   | default
 wal_writer_flush_after | 128     | 8kB  | default
(4 rows)

source 열이 전부 default 이고 전역 synchronous_commiton 이다. 전역 설정만 보면 「우리는 동기 커밋」이라고 믿게 되는데, Keycloak 이 자기 트랜잭션에만 SET LOCAL 로 뒤집는다. DBA 가 서버 설정만 보고 판단하면 틀린다.

가이드는 여기에도 별표를 붙인다 — 원래 실험은 결과를 먼저 쓰고 「wal_writer_delay 기본값(200ms)과 맞는다」고 주장했는데 그 시점에 이 값을 조회한 적이 없었다. 나중에 재서 맞기는 했지만 그때는 추정이었다. 가정한 값은 재기 전에 재 둔다. 결과를 본 뒤에 재면 「맞춰 보는」 것이 된다.

마지막으로 세션 테이블을 비우고 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from offline_user_session where offline_flag='0'"

실측(observed) — 05-true-crash.txtDELETE 375남은 세션: 0. 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히 놓고 볼 것이므로 시작이 0 이어야 그 둘이 읽힌다. 캐시는 안 비워도 된다 — 이 실험의 판정은 DB 행의 존재 여부이고 캐시는 판정에 안 들어간다.

kubectl -n keycloak-lab get pods -o wide

keycloak-0 keycloak-1 postgres 가 전부 1/1 Running 이고 RESTARTS0 이어야 한다. RESTARTS 값을 적어 둔다 — 주입 판정의 일부다.

주입

세 번 시도한다. 순서대로 따라가면 죽이는 데 실패하는 두 가지 방법을 직접 보게 되고, 건너뛰고 세 번째만 하면 왜 그것이 유일한 방법인지 모른다.

루프는 한 줄로 칠 물건이 아니다. 원래 실행은 이걸 kubectl exec ... sh -c "..." 한 줄에 욱여넣었고 인용이 세 겹이 되어 두 번 깨졌다.

실측(observed) — 01-crash-injection.txt

=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
  루프 시작
  6초 경과 — 지금까지 성공한 로그인: 0
...
  클라이언트가 200 을 받은 로그인 수: 0

0건이다. 파드 안에서 ( ... ) & 로 띄운 루프가 exec 세션이 끝날 때 같이 죽었고, 측정 자체가 없었던 것이다. 그래서 편집기로 파일을 연다.

vim /tmp/a3-login-loop.sh
# file: /tmp/a3-login-loop.sh   — 탐침 파드 안에서 돈다
#!/bin/sh
# K0 · PW 는 파드 환경변수에서 온다. 여기에 비밀번호를 적지 않는다.
TOK=/realms/master/protocol/openid-connect/token
: > /tmp/sids
i=0
while [ "$i" -lt 400 ]; do
  AT=$(curl -s --max-time 5 -X POST "http://$K0:8080$TOK" \
         -d grant_type=password -d client_id=admin-cli \
         -d username=admin -d "password=$PW" \
       | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
  if [ -n "$AT" ]; then
    SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
          | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
    [ -n "$SID" ] && echo "$SID" >> /tmp/sids
  fi
  i=$((i + 1))
done
echo "루프 종료: $(wc -l < /tmp/sids) 건"

sed 의 출력을 파일로 바로 흘리지 않는다. 변수에 받아 echo 로 다시 쓴다. 탐침 이미지의 셸이 busybox 이고 그 sed입력에 끝 개행이 없으면 출력에도 안 붙인다. base64 -d 가 내놓는 JSON 에는 끝 개행이 없으므로, 바로 >> 로 흘리면 sid 가 줄바꿈 없이 이어 붙어 파일 한 줄이 된다. 2026-09-17 에 그대로 겪었다(observed) — 로그인 217건이 DB 에 들어갔는데 파일은 이랬다.

0 /tmp/sids
9384 /tmp/sids

wc -l0 이고 wc -c 는 9384 다. 데이터는 다 있는데 줄이 하나도 없다. 이 파일을 wc -l·sort -u·comm 으로 세는 것이 이 실험의 측정 전부이므로, 이 상태로는 손실 건수가 언제나 0 으로 나온다. echo 로 감싼 형태로 열 번 돌려 10 줄 / 250 바이트 를 받아 고쳤다(observed).

busybox 의 sed 가 그렇게 동작하는 것을 따로 확인했다(observed).

printf "%s" '{"sid":"ABC"}' | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' | od -c
0000000   A   B   C
0000003

원래 실행이 낸 「클라이언트가 200 을 받은 로그인 수: 0」 도 이 모양과 구별되지 않는다. 그때는 배경 서브셸이 죽은 것으로 적었고 그것도 사실이지만, 이 결함은 지금도 그대로 있고 배경 서브셸을 안 써도 같은 0 이 나온다(observed).

/tmp/sids 에는 클라이언트가 200 과 토큰을 실제로 받은 것만 쌓인다. AT 가 비면 아무것도 안 적으므로 이 파일이 「서버가 COMMIT 했다고 응답한 것」의 목록이고, 그게 이 실험의 시험군이다.

파일을 파드 안으로 넣는다.

이 실험대는 이렇게 했다(observed)

kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \
  < /tmp/a3-login-loop.sh
kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh

따라 하는 사람은 가이드가 같은 곳에 적어 둔 대안을 쓸 수 있다 — kubectl cp 도 되지만 컨테이너에 tar 가 있어야 한다. 이 실험대의 curlimages/curl:8.11.1tar 가 있는지는 재지 않았다(unknown). 그래서 가이드는 cat > 로 밀어 넣는 쪽이 어디서나 통한다고 적고 그쪽을 골랐다. 줄 수가 17 /tmp/a3-login-loop.sh 로 나오면 들어간 것이다.

루프는 터미널 ① 에서 앞으로 두고 돌린다. 이 터미널은 붙잡힌다.

kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh

& 로 배경에 보내지 않는다. 그게 원래 실행이 실패한 까닭이고, 터미널을 하나 통째로 이 루프에 쓴다. 이 앞으로 두고 돌리는 형태는 미검증이다(unknown) — 원래 실행은 호스트에서 배경 exec 로 했다.

터미널 ② 에서 얼마나 쌓였는지 본다.

kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids

실측(observed) — 06-backend-kill-crash.txt8초 후: 112 건. 8초에 112건이면 초당 약 14건이고, 이 속도를 적어 둔다 — 손실 건수를 시간으로 환산할 때 쓴다. 0건이면 루프가 안 도는 것이므로 터미널 ① 을 본다.

시도 ① — --grace-period=0 --force. 「강제 삭제」라는 이름이 붙어 있으니 크래시일 것 같아 확인해 본다.

date '+%H:%M:%S.%3N 종료'
kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force
date '+%H:%M:%S.%3N 반환'

실측(observed) — 01-crash-injection.txt

=== [2] PostgreSQL 강제 종료 (SIGKILL) ===
  종료 시각: 12:00:26.511
pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace
  삭제 반환: 12:00:26.586

시도 ② — 컨테이너 안에서 kill -9 1. postmaster 는 컨테이너의 PID 1 이므로 직접 SIGKILL 을 보내면 될 것 같다.

date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1

시도 ③ — 백엔드 프로세스를 죽인다. PostgreSQL 은 postmaster(부모) + 연결마다 백엔드(자식) 구조이고, 자식 하나가 비정상 종료하면 postmaster 는 공유 메모리가 오염됐다고 보고 전체를 재초기화한다. 그게 곧 crash recovery 다. 먼저 무엇을 죽일지 눈으로 본다.

kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20

형태 — 값은 환경마다 다르다.

UID    PID  PPID  C STIME TTY   TIME CMD
postgres  1     0  0 02:59 ?    00:00:00 postgres
postgres 40     1  0 02:59 ?    00:00:00 postgres: keycloak keycloak 10.42.1.67(41234) idle
postgres 41     1  0 02:59 ?    00:00:00 postgres: keycloak keycloak 10.42.0.35(52118) idle
...

PID 1 이 postmaster 이고 postgres: keycloak keycloak ...Keycloak 이 붙어 있는 백엔드다. 터미널 ① 에서 루프를 다시 돌려 8초쯤 쌓이면, 터미널 ② 에서 죽인다.

이 실험대는 이렇게 했다(observed)

date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- \
  sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)'

따라 하는 사람은 바로 위 ps -ef 가 이미 PID 를 보여 줬으므로 그 값을 그대로 넣을 수 있다. 한 줄에 원격 셸·명령 치환·pgrep·head 를 겹쳐 놓지 않고, 본 것을 옮겨 적는다. 이 두 줄 형태는 이 실험대에서 치지 않았다(unknown).

위 출력의 40·41 은 이 실험대의 값이라 그대로 치면 안 된다. 방금 친 ps -ef 가 보여 준 PID 를 읽어서 <PID> 자리에 넣는다 — 예시 숫자를 그대로 치면 그 파드의 엉뚱한 프로세스를 죽인다.

date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- kill -9 <PID>

백엔드가 여럿이면 이름으로 고르는 쪽이 한 번에 전부 끊는다. 이 형태도 이 실험대가 쳤다(observed).

kubectl exec deploy/postgres -- pkill -9 -f 'postgres: keycloak'

실측(observed) — 06-backend-kill-crash.txt

=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
  시각: 12:04:22.063
  최종 성공 로그인: 153 건

터미널 ① 의 루프를 Ctrl-C 로 멈춘다.

주입 검증

결과를 세기 전에 주입 성공 신호를 본다. 이 실험은 그 신호를 미리 정해 뒀다.

   PostgreSQL 이 정상 종료했다  →  pg_control 에 "깨끗하게 종료됨" 표시
                                    →  다음 기동에 아무 말 없이 뜬다

   PostgreSQL 이 즉사했다       →  표시가 없다
                                    →  "database system was not properly shut down"
                                    →  "redo starts at ..." / "redo done at ..."

시도 ① 의 검증.

kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept'

실측(observed) — 02-design-check.txt

=== crash recovery 가 실행되었는가 (강제 종료의 흔적) ===
2026-09-04 02:58:41.036 UTC [1] LOG:  database system is ready to accept connections

ready to accept connections 한 줄뿐이다. not properly shut downredo 도 없으므로 crash recovery 가 돌지 않았다 = 깨끗하게 내려갔다.

그런데도 손실을 세어 보면 이렇게 나온다.

kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from offline_user_session where offline_flag='0'"

실측(observed) — 04-comparison.txt

=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
  클라이언트 성공: 291 건
  DB 에 존재:      291 건
  ★ 유실:          0 건

0건이다. 그런데 이건 「안 잃었다」가 아니라 **「죽인 적이 없는 것」**이다. 시그널 셋이 다르게 동작한다 — SIGTERM 은 fast shutdown 으로 진행 중 트랜잭션을 롤백하고 WAL 을 플러시한 뒤 종료하고, SIGINT 는 smart shutdown 으로 연결이 끊기길 기다리며, SIGKILL 은 즉사라 플러시가 없고 다음 기동에 crash recovery 가 돈다. --force --grace-period=0API 오브젝트를 즉시 지운다. 그것뿐이다. 컨테이너 런타임은 여전히 정상 종료 절차를 밟고, PostgreSQL 은 SIGTERM 을 받고 얌전히 플러시했다.

가이드가 여기서 뽑은 운영 함의는 이렇다 — 장애 훈련이 훈련이 안 될 수 있다. 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 아무것도 죽이지 않은 것일 수 있고, 훈련에는 주입 성공 신호가 있어야 한다.

시도 ② 의 검증 — 아무 일도 일어나지 않았다.

kubectl -n keycloak-lab get pods -l app=postgres
kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3

실측(observed) — 05-true-crash.txt

=== [재주입] postmaster(PID 1)에 SIGKILL — 진짜 크래시 ===
  8초 후 성공 로그인: 110 건
  SIGKILL: 12:03:21.441
  최종 성공 로그인: 139 건

=== [검증] 이번엔 crash recovery 가 돌았는가 ===
  2026-09-04 02:59:48.427 UTC [1] LOG:  database system is ready to accept connections

두 가지를 같이 본다. RESTARTS 가 안 올랐다 — 파드는 재시작하지 않았다. 그리고 로그의 마지막 줄 시각이 02:59:48 인데, 시도 ① 때 뜬 그 시각 그대로다. 가이드는 별표를 붙여 적는다 — ready to accept connections 줄이 있다」로 판정하면 안 된다. 줄의 존재가 아니라 시각을 본다.

까닭은 PID 1 의 시그널 보호다. 리눅스 커널은 PID 1 을 특별 취급해서 자기 PID 네임스페이스 안에서 온 시그널은 핸들러가 등록된 것만 전달하고 SIGKILL 도 예외가 아니다.

   같은 네임스페이스 안에서  →  PID 1 은 등록하지 않은 시그널을 무시한다
   조상 네임스페이스에서     →  전달된다 (노드에서 kill -9 하면 죽는다)

부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데, 컨테이너에서는 **「안에서는 PID 1 을 못 죽인다」**로 나타난다. 그래서 크래시 재현은 두 갈래이고, (a) 자식 프로세스를 죽이거나 (b) 노드에서 ssh kc-lab-2 'sudo kill -9 <호스트 PID>' 로 죽인다. 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했다 — (b) 는 치지 않았다(unknown).

시도 ③ 의 검증 — 이번엔 걸렸다.

kubectl -n keycloak-lab logs deploy/postgres --since=5m \
  | grep -E 'terminated by signal|reinitializing|not properly shut down|redo|checkpoint complete|ready to accept'

실측(observed) — 06-backend-kill-crash.txt

  2026-09-04 03:02:35.807 UTC [1] LOG:  server process (PID 40) was terminated by signal 9: Killed
  2026-09-04 03:02:35.807 UTC [1] LOG:  terminating any other active server processes
  2026-09-04 03:02:35.814 UTC [1] LOG:  all server processes terminated; reinitializing
  2026-09-04 03:02:35.896 UTC [2585] LOG:  database system was not properly shut down; automatic recovery in progress
  2026-09-04 03:02:35.899 UTC [2585] LOG:  redo starts at 0/23CAB68
  2026-09-04 03:02:35.904 UTC [2585] LOG:  redo done at 0/2529E40 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
  2026-09-04 03:02:35.923 UTC [2586] LOG:  checkpoint complete: wrote 113 buffers (0.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.004 s, sync=0.004 s, total=0.015 s; sync files=27, longest=0.003 s, average=0.001 s; distance=1405 kB, estimate=1405 kB; lsn=0/252A048, redo lsn=0/252A048
  2026-09-04 03:02:35.926 UTC [1] LOG:  database system is ready to accept connections

여섯 줄이 순서대로 나온다. terminated by signal 9 는 내가 죽인 그 백엔드이고, all server processes terminated; reinitializing 은 postmaster 가 전체를 갈아엎기로 한 것이며, not properly shut down 이 주입 성공 신호다 — 이게 없으면 결과를 해석하지 않는다. redo startsredo done 이 재생된 WAL 구간, checkpoint complete 가 재생 결과를 디스크에 고정한 것, 그리고 ready to accept connections시각이 새로 찍혔다.

kubectl -n keycloak-lab get pods -l app=postgres -o "custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount"

RESTARTS0 이다. 컨테이너의 PID 1 인 postmaster 는 살아 있고 자식만 갈아치웠다. 쿠버네티스 관점에서는 아무 일도 없었지만 데이터 관점에서는 전원이 나간 것과 같다.

관찰

클라이언트가 받은 sid 목록을 꺼낸다.

kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt
wc -l /tmp/client-sids.txt
head -3 /tmp/client-sids.txt

실측(observed) — 07-loss-result.txt클라이언트가 200 과 토큰을 받은 로그인 : 153 건. 눈으로 한 번 보는 까닭은 빈 줄이 섞여 있으면 유실 건수가 부풀려지기 때문이다.

CQUfg9HLH29xvhiu6pVlfWOo
5gLP4fqmpZBbjhH_d-0TPMMr
hkcOv1QskUFmYveMLB6Hljra

DB 쪽은 먼저 총계를 보고, 그다음 목록으로 뽑는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from offline_user_session where offline_flag='0'"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select user_session_id from offline_user_session where offline_flag='0'" \
  > /tmp/db-sids.txt
wc -l /tmp/db-sids.txt

실측(observed) — DB 전체 온라인 세션 : 150 건. 가이드는 psql 의 두 얼굴을 적는다 — -c 는 표를 그려서 사람이 읽기 좋고 -tAc 는 값만 줘서 파이프에 넣기 좋으므로, 한 번은 -c 로 눈으로 보고 셀 때만 -tAc 를 쓴다.

차집합은 comm 으로 낸다. 정렬부터 한다 — 이 세 줄은 미검증이다(unknown).

LC_ALL=C sort -u /tmp/client-sids.txt > /tmp/a.txt
LC_ALL=C sort -u /tmp/db-sids.txt     > /tmp/b.txt
comm -23 /tmp/a.txt /tmp/b.txt

comm -23 은 왼쪽 파일에만 있는 줄을 내므로 클라이언트는 받았는데 DB 에는 없는 sid 다. -1 은 왼쪽 전용을, -2 는 오른쪽 전용을, -3 은 양쪽에 다 있는 줄을 감추므로 -23 은 왼쪽 전용만 남긴다. LC_ALL=C 를 빼면 안 된다comm 은 두 파일이 같은 정렬 순서임을 전제하는데, 로케일이 다르면 대소문자·기호 순서가 달라져 멀쩡한 sid 가 「없는 것」으로 잡힌다. sid 는 대소문자와 - _ 가 섞인 base64url 이라 정확히 그 문제에 걸린다.

실측(observed) — 07-loss-result.txt

=== 크래시 전후 대조 ===
  클라이언트가 200 과 토큰을 받은 로그인 : 153 건
  그중 DB 에 실제로 존재                 : 149 건
  ★ 유실                                 : 4 건

=== 유실된 sid 목록 ===
    ★ CQUfg9HLH29xvhiu6pVlfWOo  ← 토큰은 발급됐는데 세션이 없다
    ★ 5gLP4fqmpZBbjhH_d-0TPMMr  ← 토큰은 발급됐는데 세션이 없다
    ★ hkcOv1QskUFmYveMLB6Hljra  ← 토큰은 발급됐는데 세션이 없다
    ★ p5XybeQIYmAs818gO4Vl_5ea  ← 토큰은 발급됐는데 세션이 없다

로그인이 성공했다고 응답받았는데 세션이 존재하지 않는다. 153건 중 4건, 약 2.6% 다.

★ 2026-09-17 에 새 실험대에서 다시 재니 395건 중 3건이었다(observed). 세 시도를 순서대로 밟았고 앞의 둘은 그때도 0건이었다.

시도 ① --grace-period=0 --force   유실 0건 — 새 파드 로그가 `database system was shut down at …`
시도 ② kill -9 1                  유실 0건 — RESTARTS 0, 파드 이름도 그대로
시도 ③ 백엔드 PID 39 에 SIGKILL   유실 3건 — 클라이언트 395 · DB 392

시도 ③ 의 로그가 남긴 것도 같았다.

server process (PID 39) was terminated by signal 9: Killed
all server processes terminated; reinitializing
database system was not properly shut down; automatic recovery in progress
redo starts at 0/1F80A00

건수는 실행마다 다르다. 크래시 순간에 아직 디스크로 못 내려간 트랜잭션만 사라지므로 로그인 속도와 wal_writer_delay 와 크래시 타이밍이 그 수를 정한다. 판정에 쓰는 것은 숫자가 아니라 0 이 아니라는 것이다.

comm -23 /tmp/a.txt /tmp/b.txt | wc -l

사라지지 않은 것도 하나 본다.

tail -1 /tmp/client-sids.txt
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, created_on, last_session_refresh
      from offline_user_session where user_session_id='8do0Bw6tkVLDVxgxotE7GosH'"

실측(observed)

=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
  마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
     user_session_id      | created_on | last_session_refresh
--------------------------+------------+----------------------
 8do0Bw6tkVLDVxgxotE7GosH | 1788490958 |           1788490958
(1 row)

대부분은 멀쩡하다. 그래서 손실이 잘 안 보인다.

숫자를 읽을 때 성급하게 결론을 붙이지 않는다. 원래 문서는 「초당 19건 … wal_writer_delay 기본값(200ms)과 맞는다」고 썼는데 그 시점에 wal_writer_delay 를 조회한 적이 없었고 로그인 속도도 틀렸다. 증거를 다시 읽으면 8초에 112건 ≈ 초당 14건이고 4건 ≈ 약 0.29초 분량이다.

측정한 손실 4건 ≈ 약 0.29초 분량
wal_writer_delay (주입 전에 잰 값) 200 ms
관계 같은 자릿수이되 정확히 일치하지는 않는다

「같은 자릿수」까지가 이 실험이 말할 수 있는 것이다. wal_writer_delay 하나가 손실 창을 정하는 것도 아니고 wal_writer_flush_after(128 × 8kB)와 체크포인트 타이밍이 함께 작용한다. 재현하면 로그인 속도·디스크·죽인 순간이 전부 다르므로 중요한 것은 「4」가 아니라 「0 이 아니다」이고, 그 크기가 WAL 플러시 주기와 같은 자릿수라는 것이다.

사용자에게는 이렇게 보인다.

   로그인 성공  →  access token + refresh token 을 받음
        │
        │  (크래시)
        ▼
   다음 요청     →  access token 은 60초간 통한다
        │            (서명만 보는 경로라면)
        ▼
   60초 후 refresh  →  "Session not active"  →  다시 로그인

즉시 드러나지 않는다. access token 수명 동안은 정상으로 보이다가 갱신 시점에 끊기므로 장애와 증상 사이에 최대 60초의 시차가 있다. 그래서 모니터링은 갱신 실패율을 봐야 한다 — 로그인 성공률만 보면 이 장애는 안 보인다. 로그인은 200 을 줬기 때문이다.

이 손실이 「허용된」 까닭은 세션 쓰기가 매우 잦고(로그인마다, refresh 마다), 잃어도 사용자가 다시 로그인하면 되며, 동기 커밋의 비용은 모든 요청에 붙는데 크래시는 드물기 때문이다. 드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택이고, 합리적이지만 선택했다는 사실을 알고 있어야 한다.

바꿀 수 있는지도 가이드가 답한다.

-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
ALTER DATABASE keycloak SET synchronous_commit = on;

SET LOCAL 이 우선하므로 이것으로는 못 막는다. Keycloak 설정이나 소스 수준의 문제이고, RPO 0 이 필요하면 복제(streaming replication)로 푸는 것이 맞다고 적는다.

복구와 원상복구 확인표

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement" -c "show log_line_prefix"

none 이 아니면 위 reset 을 다시 친다. 그다음 실험이 만든 세션을 정리한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found

재시작을 빼면 안 된다. DB 만 지우면 캐시 엔트리가 남아 캐시 합계와 DB 총계가 어긋난다. A-0 이 겪은 함정이고 다음 실험의 출발값을 망친다.

DB 가 건강한지도 본다.

kubectl -n keycloak-lab get pods -l app=postgres
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select id, dateexecuted from databasechangelog order by dateexecuted desc limit 3"

질의가 그냥 되고 마이그레이션 이력 세 줄이 나오면 된다. 건수는 Keycloak 버전마다 다르므로 숫자를 외울 필요가 없다. crash recovery 는 커밋되지 않은 것만 버리므로 스키마와 마이그레이션 이력은 멀쩡하고, 이 실험은 「데이터 일부 손실」이지 「DB 파손」이 아니다.

항목 명령 돌아왔을 때
DB kubectl -n keycloak-lab get pods -l app=postgres 1/1 Running
문장 로깅 psql -c "show log_statement" none
WAL 설정 psql -c "show synchronous_commit" on (전역은 원래 on)
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
클러스터 vendor_cluster_size 양쪽 2
DB 세션 psql -c "select count(*) from offline_user_session" 0
탐침 파드 kubectl -n keycloak-lab get pod a3-probe NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

로컬 임시 파일도 치운다.

rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh

막히면

증상 원인 확인
유실이 0건 이다 죽인 적이 없다. 대개 --forcekill -9 1 을 썼다 not properly shut down 이 로그에 있나
ready to accept connections 가 있으니 크래시인 줄 알았다 아까 뜰 때 찍힌 줄이다 줄의 존재가 아니라 시각을 본다
kill -9 1 을 했는데 아무 일도 없다 컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다 백엔드 프로세스를 죽인다
루프가 0건 을 모았다 파드 안에서 & 로 띄우면 exec 종료와 같이 죽는다 터미널 하나를 루프에 통째로 쓴다
로그가 폭주하고 크래시 타이밍이 이상하다 log_statement='all' 을 켠 채로 루프를 돌렸다 show log_statementnone 인지
멀쩡한 sid 가 「없음」으로 잡힌다 comm 두 파일의 정렬 순서가 다르다 LC_ALL=C sort 를 양쪽에
유실 건수가 부풀려진다 /tmp/sids 에 빈 줄이 섞였다 head -3 으로 눈으로 본다
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드로 친다
로그인이 401/400 비밀번호가 안 넘어갔다 exec a3-probe -- sh -c 'echo ${#PW}'0 이면 --env 가 빈 값
a3-probe 를 다시 못 만든다 옛 파드가 Completed 로 남아 있다 delete pod a3-probe --ignore-not-found
pgrep 이 아무것도 못 찾는다 Keycloak 이 아직 연결을 안 만들었다 ps -ef 로 먼저 본다
손실 건수를 시간으로 환산했더니 문서와 다르다 원래 문서가 속도를 잘못 썼다가 정정했다 초당 14건이 실측이다

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

  • (observed) offline_user_session 의 시각 컬럼이 integer, 로그인 트랜잭션에도 붙은 SET LOCAL synchronous_commit TO OFF, WAL 설정 네 줄 (wal_writer_delay 200 ms default 포함), 시도 ①·②·③ 의 시각 12:00:26.511·12:03:21.441·12:04:22.063, crash recovery 로그 여섯 줄, 153 / 149 / 4, 유실 sid 네 개, 초당 14건, DELETE 375, 비밀번호 길이 19.
  • (unknown) LC_ALL=C sortcomm -23 세 줄, 루프를 앞으로 두고 돌리는 형태(원래 실행은 호스트에서 배경 exec 로 했다), kubectl exec ... kill -9 40 으로 PID 를 옮겨 적는 형태, 노드에서 호스트 PID 를 죽이는 (b) 갈래, curlimages/curl:8.11.1tar 가 있는지.
  • 이 실험이 두 번 틀렸다가 고친 것 — 「--force 로 죽였다」와 「kill -9 1 로 죽였다」가 둘 다 유실 0건이라는 깨끗한 결과를 냈다. 주입 성공 신호를 미리 정해 두지 않았다면 결론은 **「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」**가 됐을 것이다.
  • 추정이었다가 나중에 잰 것wal_writer_delay 200ms. 원래 문서는 결과를 먼저 쓰고 그 값과 맞는다고 주장했는데 그때는 조회한 적이 없었고, 로그인 속도도 초당 19건으로 잘못 적었다가 14건으로 정정했다.

A-4 — 기계 전원을 뽑으면 쿠버네티스는 언제 알아채는가

근거: a4-node-loss.md (978줄). 실행 기록은 2026-09-04 12:0512:23 KST(observed).

이 실험이 가르는 것

A-1 과 A-5 는 네트워크만 끊었다. 파드는 살아 있었고 쿠버네티스는 계속 정확한 상태를 알고 있었다. 여기서는 기계 자체를 없앤다. 그러면 상태를 보고할 주체가 사라진다.

두 판본으로 나눈다. 어느 노드를 죽이느냐가 전부이기 때문이다.

죽이는 노드 그 노드에 있는 것 묻는 것
4a kc-lab-2 (워커) keycloak-0 · postgres · postgres PVC Keycloak 과 DB 를 동시에 잃으면
4b kc-lab-1 (k3s 서버) keycloak-1 · Traefik · 컨트롤 플레인 · 관측 스택 들어갈 문을 잃으면

세 가지를 확인한다.

   쿠버네티스는 언제 알아채는가      →  40초 (그동안 거짓말을 한다)
   무엇을 스스로 고치는가            →  축출. 단 5분 뒤
   무엇을 못 고치는가                →  PVC 가 묶인 재배치, StatefulSet 이름

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 기계는 없는데 쿠버네티스가 40초 동안 Ready 라고 말하는 것, 죽은 파드가 ready=true, 산 파드가 ready=false 인 것, 그 와중에 up 은 정확히 0 인 것, 축출이 5분 뒤에야 시작되는 것, 새 파드가 영원히 Pending 인 것, StatefulSet 이 대체 파드를 안 만드는 것, kubectl 이 죽어도 컨테이너는 도는 것, 관측자가 같이 죽으면 0 이 아니라 구멍이 남는 것.

전제와 되돌리기

명령을 치는 곳이 세 군데다. 이 실험은 그 구별이 곧 내용이다.

터미널 어디 무엇을
A test-server (VM 호스트) virsh — 전원을 뽑고 다시 넣는다
B kc-lab-1 kubectl — 관찰. 4b 에서는 이 터미널이 죽는다
C test-server 밖에서 curl. 사용자 시점

터미널 A 에서 virsh 가 시스템 하이퍼바이저를 보고 있어야 한다. qemu:///system 이 아니면 VM 이 안 보인다.

export LIBVIRT_DEFAULT_URI=qemu:///system
virsh uri

4b 에서는 kc-lab-2 에도 붙는다. 터미널 A 와 같은 기계에서 ssh kc-lab-2 로 붙고, 이름이 안 풀리면 ssh 192.168.122.12 다.

이건 기계를 끄는 실험이다. 가이드의 경고를 그대로 옮긴다 — virsh destroy종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같다. 게스트 파일시스템이 더러운 채로 멈춘다. 실험대에서만 한다. 전 구간 약 40분이고, 4a 에서 축출을 보려면 그것만 7분을 기다려야 한다. 어느 시점에서든 그만두려면 터미널 A 에서 한 줄이면 된다.

virsh start kc-lab-2 ; virsh start kc-lab-1

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

VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나
virsh list --all

실측(observed) — 01-baseline.txt

--------------------------
 1    kc-lab-1   running
 2    kc-lab-2   running

앞의 숫자는 도메인 ID 이며 VM 을 껐다 켜면 바뀌므로 이름으로 다룬다.

kubectl get nodes
kubectl -n keycloak-lab get pods -o wide

실측(observed) — 01-baseline.txt

kc-lab-1   Ready   true
kc-lab-2   Ready   <none>

a2-probe                    true   kc-lab-2
keycloak-0                  true   kc-lab-2
keycloak-1                  true   kc-lab-1
postgres-7b474b88c8-2gf27   true   kc-lab-2

NODE 열을 본다. 이 실험은 배치가 전부다. kc-lab-2keycloak-0 과 postgres 가 함께 있으므로 4a 는 「Keycloak 한 대를 잃는 실험」이 아니라 **「Keycloak 한 대와 DB 를 동시에 잃는 실험」**이다. a2-probe 는 A-2 에서 띄워 두고 안 지운 상주 파드라 당신 환경에는 없을 수 있고, 없어도 지장이 없다.

볼륨이 어느 노드에 못박혀 있는지가 뒤의 결과를 이미 정한다.

kubectl -n keycloak-lab get pvc

PV 가 어느 노드를 요구하는지는 읽는 형태로 먼저 본다.

kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
kubectl describe pv $(kubectl -n keycloak-lab get pvc postgres-data \
  -o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'

형태 — 값은 환경마다 다르다.

Node Affinity:
  Required Terms:
    Term 0:  kubernetes.io/hostname in [kc-lab-2]

값만 필요하면 뽑는 형태로 줄인다.

kubectl get pv $(kubectl -n keycloak-lab get pvc postgres-data \
  -o jsonpath='{.spec.volumeName}') \
  -o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo

실측(observed) — 01-baseline.txtpersistentvolumeclaim/postgres-data → kc-lab-2. local-path PVC 는 그 노드의 로컬 디렉터리(/var/lib/rancher/k3s/storage/...)이므로 노드가 죽으면 볼륨도 같이 죽는다. 스케줄러는 그것을 nodeAffinity 로 알고 있어서 다른 노드에 파드를 만들지 않는다. 결함이 아니라 이 실험대의 조건이다.

밖에서 보이는 상태도 잡아 둔다. 눈으로 한 번 볼 때는 -I 로 충분하고, 여러 번 재서 비교할 것이므로 그다음에는 코드만 뽑는다.

curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

--max-time 을 반드시 준다. 4b 에서 이 값이 없으면 curl 이 몇 분씩 매달리고, 타임아웃이 곧 결과다 — 뒤에서 000 이 나오는 까닭이 그것이다.

kubectl -n observability get pods -o wide

형태grafanaprometheuskc-lab-1 에 있다. 4a(kc-lab-2 살해)에서는 Prometheus 가 살아남아 관측이 정확하고, 4b 에서는 관측자가 같이 죽는다. 미리 알아 두지 않으면 나중에 그래프의 빈 구간을 「값이 0」으로 잘못 읽는다.

주입

shutdowndestroy 는 다르다. virsh shutdown 은 ACPI 종료 신호를 보내 kubelet 이 정상 종료하고 파드가 정리되므로 쓰면 안 된다. virsh destroy전원 차단이고 신호가 없어 마지막 상태가 그대로 얼어붙는다. shutdown 을 쓰면 쿠버네티스가 정상적인 노드 이탈로 처리해서 이 실험의 발견 두 개가 통째로 안 나온다.

date '+%H:%M:%S 차단'
virsh destroy kc-lab-2

실측(observed) — 02-worker-node-killed.txt

차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed

시각을 반드시 적어 둔다. 40초·5분 같은 숫자는 이 시각에서 뺀 값이고, 기준점이 없으면 뒤의 관찰은 그냥 나열이다.

4b 의 주입은 컨트롤 플레인 쪽이다. 먼저 인벤토리를 뽑는데, 그게 곧 영향 범위다.

kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
kubectl -n kube-system get deploy traefik

실측(observed) — 06-control-plane-inventory.txt

  keycloak-lab     keycloak-1
  kube-system      coredns-54996dc9b4-8k8fj
  kube-system      helm-install-traefik-crd-q29b5
  kube-system      local-path-provisioner-77b9867795-g27z8
  kube-system      metrics-server-6dc596dfb8-7xxq4
  kube-system      svclb-traefik-5eb6a9a1-qwwk5
  kube-system      traefik-5d6fcf895-wpfhr
  observability    grafana-845b5678cf-b6gvc
  observability    node-exporter-9qk9w
  observability    prometheus-6774f94f7c-pzr2t

traefik 이 여기 있고 replicas1 이다. 진입점이 단일 장애점이므로 이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다.

date '+%H:%M:%S 차단'
virsh destroy kc-lab-1

실측(observed) — 07-control-plane-loss.txt차단 시각: 12:18:08Domain 'kc-lab-1' destroyed. 터미널 B 가 여기서 죽는다. SSH 세션이 그대로 끊기고, 놀랄 일이 아니다.

주입 검증

A-5·A-6 에서는 「규칙을 넣었는데 카운터가 0」이 실패였다. 이 실험의 검증 대상은 다르다. 여기서 믿을 수 있는 것은 하이퍼바이저뿐이고, 쿠버네티스가 뭐라고 하든 그것은 결과이지 검증이 아니다.

virsh list --all

shut off 이면 꺼진 것이다. ID 가 - 로 바뀐 것도 같은 말이다.

ping -c 2 -W 2 192.168.122.12

ping미검증이다(unknown) — 원 실행에는 이 확인이 없다. 0 received 가 나오면 꺼진 것이다.

그런데 쿠버네티스는 아직 Ready 라고 말한다.

kubectl get node kc-lab-2

여기서 「주입이 안 걸렸다」고 결론 내리면 틀린다. 기계는 꺼져 있고 쿠버네티스가 아직 모를 뿐이다. 노드 상태와 사용자 경험을 나란히 봐야 그것이 보인다.

이 실험대는 이렇게 했다 — 두 줄을 15초 간격으로 몇 번 친다.

kubectl get node kc-lab-2
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

손이 아프면 한 줄로 묶는다고 가이드가 대안을 함께 적는데, 이 루프는 미검증이다(unknown). Ctrl-C 로 멈춘다.

while true; do
  printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
    "$(kubectl get node kc-lab-2 --no-headers | awk '{print $2}')" \
    "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
        --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
  sleep 15
done

실측(observed) — 02-worker-node-killed.txt

  +15초  node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
  +30초  node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
  +45초  node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
  +60초  node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503

★ 2026-09-17 에 새 실험대에서 다시 재니 같은 모양이었다(observed). 차단은 14:39:28 이다.

  14:39:56 (+28초)  kc-lab-2=Ready    | keycloak-0=Running postgres=Running | 정문 000
  14:40:34 (+66초)  kc-lab-2=NotReady | keycloak-0=Running postgres=Running | 정문 503
  14:42:41 (+3분)   kc-lab-2=NotReady | keycloak-0=Running postgres=Running | 정문 503

넘어가는 대목은 같고 초는 실행마다 다르다. 30초 간격으로 쟀으므로 이번 판에서는 +28초+66초 사이 어디쯤이고, node-monitor-grace-period 40초가 그 범위에 들어간다. 그 뒤로 3분이 지나도 두 파드는 계속 Running 이었다. 이벤트에도 NodeNotReady 두 줄과 keycloak-1context deadline exceeded 가 문서대로 나왔다(observed).

+30초 줄과 +45초 줄 사이에서 노드 상태가 넘어간다. kube-controller-manager 는 kubelet 의 하트비트가 node-monitor-grace-period(이 실험대에서 40초) 동안 없어야 NotReady 로 바꾸고, 그 40초 동안 쿠버네티스는 거짓말을 한다. 사용자는 그 40초에도 이미 장애를 겪고 있고 000 이 그 증거다. 노드 상태를 알림 근거로 삼으면 항상 늦는다. 사용자가 먼저 안다.

처음 40초가 503 이 아니라 000 인 까닭은 층이 다르기 때문이다 — 000 은 curl 이 응답 자체를 못 받은 것(타임아웃 또는 연결 실패)이고, 503 은 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없는 것이다. 엣지 nginx(kc-lab-edge) 의 upstream 에는 두 노드가 다 들어 있다.

upstream k3s_traefik {
	server 192.168.122.11:80;
	server 192.168.122.12:80;
}

죽은 쪽으로 배분된 요청은 응답도 거절도 못 받고 --max-time 8 에 걸린다.

여기는 이 실험이 답을 못 남긴 곳이다. 증거 파일 03-state-during-loss.txt 의 마지막 절 제목이 「진입점이 처음 40초간 000 이었던 이유 — nginx upstream」인데 그 아래가 비어 있고 명령이 아무것도 찍지 못했다. 가이드는 지금 직접 볼 수 있다며 터미널 C 에서 칠 줄을 적고 미검증으로 표시한다(unknown). upstream timed out192.168.122.12 에 대해 찍히면 그것이 답이고, nginx 에러 로그는 2048바이트에서 잘리므로 잘려 보이면 access 로그를 본다.

sudo tail -f /var/log/nginx/error.log

4b 의 주입 검증은 kubectl 이 죽은 것 자체다.

kubectl get nodes

실측(observed) — kubectl: Unable to connect to the server: dial tcp. API 서버가 kc-lab-1:6443 에 있었으므로 당연한 결과다. 4a 에서는 「쿠버네티스가 뭐라고 하는가」를 물을 수 있었지만 여기서는 물어볼 상대 자체가 없고, 이 실험의 관찰 도구가 통째로 바뀐다.

관찰

죽은 파드가 산 파드보다 건강해 보인다.

kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName"

실측(observed) — 03-state-during-loss.txt

a2-probe                    Running   true    kc-lab-2   <none>
keycloak-0                  Running   true    kc-lab-2   <none>
keycloak-1                  Running   false   kc-lab-1   <none>
postgres-7b474b88c8-2gf27   Running   true    kc-lab-2   <none>

keycloak-0꺼진 기계 위에서 ready=true, keycloak-1살아 있는데 ready=false 다. keycloak-0 은 kubelet 이 없어 상태를 갱신할 수 없어 마지막으로 보고한 값이 얼어 있고, keycloak-1 은 살아서 정직하게 보고한다 — DB 가 없으니 readiness 실패다. 파드 상태는 「지금 어떤가」가 아니라 「마지막으로 그렇게 들었다」이고, 노드가 죽으면 그 노드 파드의 상태는 화석이 된다.

kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20

실측(observed) — 같은 파일

10m         Warning   Unhealthy           pod/keycloak-0                   Readiness probe failed: Get "http://10.42.1.67:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
3m15s       Warning   NodeNotReady        pod/postgres-7b474b88c8-2gf27    Node is not ready
3m15s       Warning   NodeNotReady        pod/keycloak-0                   Node is not ready
3m15s       Warning   NodeNotReady        pod/a2-probe                     Node is not ready
2m27s       Warning   Unhealthy           pod/keycloak-1                   Readiness probe failed: Get "http://10.42.0.35:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2s          Warning   Unhealthy           pod/keycloak-1                   Readiness probe failed: HTTP probe failed with statuscode: 503

keycloak-1 의 실패가 두 종류다. 처음에는 프로브 자체가 타임아웃되고(context deadline exceeded), 나중에는 503 을 받는다. Keycloak 이 DB 없음을 스스로 판단해 답할 수 있게 된 것이고, 같은 「Unhealthy」라도 층이 다르다.

Age 를 반드시 같이 본다. 노드를 뽑은 것은 3m15s 전인데 맨 위 줄은 10m 짜리라 주입보다 앞선 사건이고 앞 실험의 잔재다. 이벤트 목록은 시간대가 섞여 있으므로 Age 로 먼저 걸러야 내가 만든 일을 고를 수 있다. 그리고 주입 이후 keycloak-0 에 붙은 이벤트는 NodeNotReady 하나뿐인데 그것은 컨트롤러가 쓴 것이지 kubelet 이 쓴 것이 아니다 — kubelet 이 없으니 그 파드에 대해 말해 줄 주체가 없다.

Prometheus 는 정확했다. 한 줄짜리 JSON 을 처음 한 번은 그대로 보고, 자르는 줄은 미검증이다(unknown).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up'
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up' \
  | tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'

실측(observed) — 03-state-during-loss.txt

  up{job=keycloak       pod=keycloak-1                } = 1
  up{job=keycloak       pod=keycloak-0                } = 0
  up{job=kubelet        pod=-                         } = 1
  up{job=kubelet        pod=-                         } = 0
  up{job=node-exporter  pod=kc-lab-1                  } = 1
  up{job=node-exporter  pod=kc-lab-2                  } = 0
  up{job=prometheus     pod=-                         } = 1

kc-lab-2 쪽이 전부 0 이고, kubelet job 이 두 줄인 것도 본다 — 노드마다 하나씩이라 하나는 1, 하나는 0 이다. A-2 와 정반대다 — 대상이 사라진 노드 상실은 up 이 잡고, 대상이 살아서 못 쓰는 DB 상실은 못 잡는다.

kubectl describe node kc-lab-2 | grep -A3 Taints
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo

실측(observed) — node.kubernetes.io/unreachable=:NoSchedulenode.kubernetes.io/unreachable=:NoExecute. NoSchedule 은 새 파드를 여기 보내지 말라는 뜻이고 NoExecute 는 이미 있는 파드도 쫓아내라는 뜻인데, 그런데도 아무 일이 안 일어나는 까닭은 관용에 있다.

kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations

실측(observed) — 04-eviction-timing.txt

=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
  node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
  node.kubernetes.io/unreachable NoExecute tolerationSeconds=300

tolerationSeconds=300당신이 쓴 적 없는 값이고 쿠버네티스가 모든 파드에 자동으로 붙인다.

   기계 정지
      │
      │  40초    node-monitor-grace-period       → 노드 NotReady
      │
      │ +300초   tolerationSeconds (NoExecute)   → 파드 축출 시작
      ▼
   총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다

위 5분 40초는 두 설정값을 더한 계산이다. 40초 는 조회하지 않은 쿠버네티스 기본값이고, 이 실험대의 폴링이 실제로 축출을 본 것은 +240~270초 다. 기다리는 시간을 잡는 데는 이 계산으로 충분하지만, 결과로 적을 때는 잰 쪽을 적는다.

그 5분을 실제로 기다린다. 30초 간격으로 보고, 손으로 치기 싫으면 watch 를 쓴다.

watch -n 30 'kubectl -n keycloak-lab get pods -o wide'

실측(observed) — 04-eviction-timing.txt

  +240초  a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
  +270초  a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
  +300초  a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending

+240초+270초 사이에 두 가지가 동시에 일어난다 — kc-lab-2 의 파드들이 Terminating 으로 바뀌고, 새 이름의 postgres 파드(...-9cmsv)가 생기며 Pending 이다. 축출이 시작됐는데 Terminating 이 안 끝나고 새 파드는 뜨지 못하며, 두 문제는 원인이 다르다.

★ 2026-09-17 에 새 실험대에서 다시 재니 같은 모양이었다(observed). 차단 05:39:28 에 대해 두 파드의 deletionTimestamp05:44:17 로 찍혔다 — 4분 49초다.

keycloak-0                  Terminating  kc-lab-2   deletionTimestamp=2026-09-17T05:44:17Z
postgres-7b474b88c8-prlxx   Terminating  kc-lab-2   deletionTimestamp=2026-09-17T05:44:17Z
postgres-7b474b88c8-5zp6v   Pending      <none>     deletionTimestamp=<none>
keycloak-1                  Running      kc-lab-1   deletionTimestamp=<none>

StatefulSet 과 Deployment 의 처지가 그대로 갈린다. 새 이름으로 생긴 것은 postgres 쪽뿐이고 keycloak-0 은 대체 파드 없이 Terminating 으로만 남는다. StatefulSet 은 이름이 고정이라 같은 이름의 파드를 둘로 만들 수 없고, 죽은 노드의 kubelet 이 「지웠다」고 답해 주지 못하므로 그 이름이 영원히 안 비워진다.

kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events

이름은 매번 다르므로 위 get 으로 먼저 확인하고 옮겨 적는다.

실측(observed) — 05-recovery.txt

Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  4m45s  default-scheduler  0/2 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.

0/2 nodes are available 뒤에 이유가 노드 수만큼 나열된다. 이 줄 하나에 두 노드의 사연이 다 들어 있다.

   kc-lab-2  →  had untolerated taint(s)                    (죽은 노드)
   kc-lab-1  →  didn't match PersistentVolume's node affinity

주입 전에 이미 알고 있던 것이 그대로 벌어졌다. 볼륨이 kc-lab-2 에 못박혀 있어 살아 있는 노드로 못 가고, 죽은 노드에는 taint 때문에 못 간다. 갈 곳이 없다. 결함이 아니라 조건이고, 운영이라면 네트워크 스토리지나 DB 복제가 그 몫을 맡아야 한다. 노드가 영영 안 돌아오면 남는 길은 백업 복원(D-1) 뿐이다.

kubectl -n keycloak-lab get statefulset keycloak
kubectl -n keycloak-lab get pods | grep keycloak

실측(observed) — 05-recovery.txt

keycloak   2     <none>   1
keycloak-0                  1/1   Terminating   0     30m
keycloak-1                  0/1   Running       0     143m

DESIRED=2 인데 CURRENT=1 이고, keycloak-030분째 Terminating 이다. StatefulSet 의 계약은 같은 이름의 파드는 클러스터에 하나뿐이어야 한다는 것이고, 컨트롤 플레인은 노드가 안 보이니 파드가 죽었는지 확신할 수 없어 옛 파드를 확실히 지우기 전엔 새 keycloak-0 을 못 만든다.

   파드 삭제 요청
     └─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
          └─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating

Deployment 였다면 즉시 새 파드를 만든다. 이름이 아무래도 되기 때문이고, postgres 가 실제로 그랬다. StatefulSet 의 「안정된 이름」이라는 이득의 반대편 비용이 여기서 나온다. 강제로 진행시키는 명령이 있지만 가이드는 치지 않는다.

kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force

그것은 컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것이라, 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 같은 이름의 파드 둘이 동시에 존재하게 된다. 그게 split brain 이고, 이 실험대에서는 virsh start 가 훨씬 안전하고 빠르다.

4b 에서는 워크로드가 살아 있다. kubectl 이 없으니 노드의 컨테이너 런타임에 직접 묻는다.

이 실험대는 이렇게 했다(observed)

ssh kc-lab-2 'sudo crictl ps --name keycloak'

따라 하는 사람은 붙어서 친다. 한 줄에 SSH 접속과 원격 셸의 인용을 겹쳐 놓지 않는다. 이 두 단계 형태는 이 실험대에서 치지 않았다(unknown).

ssh kc-lab-2
sudo crictl ps --name keycloak

실측(observed) — 07-control-plane-loss.txt

  CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD                 NAMESPACE
  e5f777900b762       60e153026e8f5       4 minutes ago       Running             keycloak            0                   640d4dafaefb3       keycloak-0          keycloak-lab

STATERunning, ATTEMPT0 이다. API 서버가 없는데도 컨테이너는 돌고 있다.

   죽은 것:  API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
   산 것:    keycloak-0 · postgres · containerd
   문제:     들어갈 문(Traefik)이 없다

컨트롤 플레인 상실은 워크로드 상실과 다르다. 이미 떠 있는 것은 계속 돌고, 새로 뜨거나 옮기거나 고치는 것이 안 될 뿐이다. 전체 목록은 sudo crictl ps 로 본다. crictl 이 소켓을 못 찾으면 k3s 의 것을 직접 주는데, 그 줄은 미검증이다(unknown).

ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'

밖에서는 20초 간격으로 두 주소를 본다.

curl -s -o /dev/null -w 'auth=%{http_code}\n'    --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/

★ 이 탐침은 지금 배치에서 000 밖에 못 낸다 — 노드가 살아 있어도 그렇다(2026-09-17, observed). 원래 실행은 502000 이 갈렸는데 지금은 처음부터 000 이라 판정에 못 쓴다. 이유는 이름 해석이 아니라 인증서다.

curl -s -o /dev/null --max-time 8 --resolve grafana.hyeonworks.com:443:192.168.122.10 https://grafana.hyeonworks.com/ ; echo "curl exit=$?"
curl -sk -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 --resolve grafana.hyeonworks.com:443:192.168.122.10 https://grafana.hyeonworks.com/
echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -ext subjectAltName
curl exit=60
grafana=404
X509v3 Subject Alternative Name: 
    DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com

curl exit=60 은 인증서 검증 실패다. 엣지의 인증서가 덮는 이름이 셋뿐이라 grafana.hyeonworks.com 은 TLS 단계에서 끝나고 HTTP 는 시작도 안 한다. -k 로 검증을 끄면 nginx 가 404 를 돌려준다 — 그 이름으로 갈 곳이 없다. 가이드 06 도 Grafana 는 밖에 열지 않고 port-forward svc/grafana 3000:3000 으로 본다고 적어 둔다. 그래서 이 탐침은 A-4 의 신호가 못 된다. 판정은 auth 쪽 탐침로 한다.

실측(observed) — 07-control-plane-loss.txt

  +20초  외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
  +60초  외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
  +120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server: dial tcp
  +160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp

+120초grafana=502 한 줄만 다르다. 503(4a)은 nginx·Traefik 이 살아 있고 뒤에 보낼 파드가 없는 것, 502(4b, 한 번)는 nginx 가 연결 실패를 제때 판정해 자기 힘으로 만든 것, 000(4b, 대부분)은 nginx 가 죽은 주소를 기다리다 --max-time 8 이 먼저 끝난 것이다. 502 가 한 번이라도 찍혔다는 것이 nginx 는 살아 있었다는 증거이고, 같은 고장인데 코드가 흔들리는 까닭은 타임아웃 경주다.

관측자가 같이 죽으면 0 이 아니라 구멍이 남는다. 4b 구간은 지금 확인할 수 없고 — Prometheus 도 Grafana 도 kc-lab-1 에 있었다 — 그것이 이 발견이다.

   대상이 죽음        →  up = 0        →  "언제 죽었는지" 알 수 있다
   관측자가 죽음      →  데이터 없음    →  "그때 무슨 일이 있었는지" 모른다

복구와 원상복구 확인표

date '+%H:%M:%S 재기동'
virsh start kc-lab-2

실측(observed) — 05-recovery.txt재기동 시각: 12:16:31Domain 'kc-lab-2' started. 30초 간격으로 본다.

kubectl get nodes
kubectl -n keycloak-lab get pods
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

실측(observed)

  +30초  node=Ready | Running 파드 3 개 | 외부 HTTP 503
  +60초  node=Ready | Running 파드 3 개 | 외부 HTTP 200
  → 서비스 복귀

60초. 사람 개입 없이 전부 제자리로 돌아왔다. Terminating 이던 파드도 Pending 이던 파드도 kubelet 이 돌아오자 정리됐다. 가이드는 여기에 단서를 단다 — 이 60초는 MTTR 이 아니다. virsh start 를 친 의 시간이고, 실제 장애 구간은 12:07:43(차단) → 12:17:31(서비스 복귀) ≈ 10분이며 그 대부분은 사람이 관찰하고 결정하는 데 쓴 시간이다.

실측(observed) — 06-control-plane-inventory.txt

=== 복구 확인 ===
keycloak-0                  1/1   Running   0     68s
keycloak-1                  1/1   Running   0     144m
postgres-7b474b88c8-9cmsv   1/1   Running   0     4m20s

postgres 의 이름이 바뀌어 있다(-2gf27-9cmsv). 축출 때 생겼다가 Pending 이던 그 파드가 노드가 살아나자 그대로 뜬 것이고, keycloak-0 은 이름이 그대로다 — StatefulSet 이라 그렇다. 두 컨트롤러의 차이가 이름에 남는다.

항목 명령 돌아왔을 때
VM virsh list --all 둘 다 running
노드 kubectl get nodes 둘 다 Ready
파드 kubectl -n keycloak-lab get pods 전부 1/1 Running, Pending 없음
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

여기까지 안 돌아왔으면 4b 로 넘어가지 않는다. 두 고장이 겹치면 무엇이 원인인지 못 가린다.

4b 의 복구도 같다.

date '+%H:%M:%S 재기동'
virsh start kc-lab-1

실측(observed) — 08-control-plane-recovery.txt

재기동: 12:23:39
Domain 'kc-lab-1' started

  +30초  외부=502 | kc-lab-1=Ready kc-lab-2=Ready
  +60초  외부=200 | kc-lab-1=Ready kc-lab-2=Ready
  → 서비스 복귀 (총 60초)

여기서도 60초다. +30초502nginx 가 먼저 살아나고 Traefik 이 아직 안 뜬 중간 상태이고, 4b 내내 보던 000 과 층이 다르다.

kubectl -n keycloak-lab get pods

실측(observed) — 같은 파일

keycloak-0                  1/1   Running   0                   7m57s
keycloak-1                  1/1   Running   1 (<invalid> ago)   151m
postgres-7b474b88c8-9cmsv   1/1   Running   0                   11m

세 가지가 한 줄에 있다. keycloak-1RESTARTS1 인 것은 kc-lab-1 위에 있었으니 당연하고, AGE151m 인데 재시작은 방금인 것은 AGE 가 파드가 만들어진 시각이지 컨테이너가 시작한 시각이 아니기 때문이며, (<invalid> ago) 는 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다. 그 원인은 이 실험이 확정하지 않았다(unknown) — 잠시 뒤 다시 치면 정상 값으로 바뀐다.

복구 뒤에 Grafana 에서 up{job="keycloak"} 그래프를 12:1512:30 으로 열어 12:1812:23 구간이 0 이 아니라 빈칸인 것을 확인한다.

실측(observed) — a4-up-dropped-per-node.png 에서 그 구간은 선이 0 으로 내려간 것이 아니라 아예 끊겨 있다. 그리고 Grafana 로그인이 풀려 있다 — Grafana 데이터가 emptyDir 이라 파드 재시작에 사라지고, Prometheus 는 PVC 라 지표가 남았지만 관측자가 죽어 있던 구간의 데이터는 애초에 수집되지 않았다. 의도한 설계대로 동작했고 그 설계의 한계도 함께 드러났다. Prometheus 를 port-forward 로 보고 있었다면 다시 연결해야 한다.

항목 명령 돌아왔을 때
VM virsh list --all 둘 다 running
노드 kubectl get nodes 둘 다 Ready
파드 kubectl -n keycloak-lab get pods -o wide 전부 1/1 Running
진입점 kubectl -n kube-system get deploy traefik 1/1
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
클러스터 뷰 kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2)
관측 Prometheus up 전부 1
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

막히면

증상 원인 확인
virsh 가 도메인을 못 찾는다 qemu:///session 을 보고 있다 virsh urisystem 이어야 한다
VM 을 껐는데 노드가 Ready 정상. node-monitor-grace-period 40초 virsh list --all 로 전원을 먼저 본다
5분이 지나도 축출이 안 온다 40초 + tolerationSeconds=300 으로 계산하면 5분 40초(계산값이다 — 이 실험대의 폴링은 +240~270초 에 축출을 봤다) describe pod | grep -A4 Tolerations
새 파드가 계속 Pending PVC 가 죽은 노드에 못박혀 있다 describe podFailedScheduling
keycloak-0 이 30분째 Terminating StatefulSet + kubelet 없음. 정상이다 get statefulsetCURRENT
--force 로 지우고 싶다 노드가 살아 있으면 중복 실행이 된다 치지 말고 virsh start
kubectl 이 전혀 안 된다 (4b) API 서버가 죽은 노드에 있었다. 정상 ssh kc-lab-2 'sudo crictl ps'
crictl 이 소켓을 못 찾는다 k3s 는 자기 containerd 소켓을 쓴다 --runtime-endpoint unix:///run/k3s/containerd/containerd.sock
503 을 기대했는데 000 층이 다르다. nginx 가 죽은 주소를 기다린다 --max-time 을 늘려 보면 502 가 나온다
curl 이 몇 분씩 안 끝난다 --max-time 을 안 줬다 모든 외부 확인에 --max-time 8
그래프의 그 구간이 0 으로 보인다 0 이 아니라 데이터 없음이다 점 사이가 이어져 있는지 본다
Grafana 로그인이 풀렸다 데이터가 emptyDir 재시작마다 그렇다. PVC 로 바꾸면 남는다
Prometheus 가 갑자기 안 보인다 port-forward 가 끊겼다 다시 연다
RESTARTS1 (<invalid> ago) 재시작 직후에 나온다. 원인 미확정 잠시 뒤 다시 친다
4b 결과가 4a 와 섞인다 4a 복구를 확인하지 않고 넘어갔다 4a 확인표를 통과한 뒤 시작

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

  • (observed) 차단 12:07:43·재기동 12:16:31·2차 차단 12:18:08·2차 재기동 12:23:39, +30초 까지 Ready 이고 +45초NotReady, 그 동안 외부가 000 에서 503 으로, 죽은 노드 파드의 ready=true, up 일곱 줄, taint 두 종류, tolerationSeconds=300, +270초 의 축출과 Pending, 0/2 nodes are available 줄, DESIRED=2 / CURRENT=1 과 30분째 Terminating, crictl 출력 한 줄, grafana=502 한 번, 양쪽 복구 60초, postgres 이름이 -2gf27 에서 -9cmsv 로.
  • 인용한 값이고 잰 값이 아닌 것 — 본문의 40초5분쿠버네티스 기본값을 인용한 것이고 값 자체를 측정하지는 않았다. 관측된 전이 시점 (+45초, +270초)과 견주려면 두 폴링이 같은 기준점을 쓴다는 것이 먼저 서야 하는데 그것을 적어 두지 않았다. 모순되는지 아닌지를 이 실험은 말할 수 없다.
  • (unknown) ping -c 2 -W 2 192.168.122.12(원 실행에 없다), 노드 상태와 외부 코드를 한 줄로 묶는 while 루프, tr ',' '\n' | grep -E 로 자른 up 출력, sudo tail -f /var/log/nginx/error.log, crictl 에 소켓을 직접 주는 줄, ssh kc-lab-2 로 들어가서 crictl ps 를 따로 치는 두 단계 형태.
  • 이 실험이 답을 못 남긴 곳 — 진입점이 처음 40초간 000 이었던 까닭을 nginx 로그로 확인하려던 절이 증거 파일에서 제목만 있고 아래가 비어 있다. 명령이 아무것도 찍지 못했다.
  • 원인을 확정하지 않은 것RESTARTS(<invalid> ago).
  • 재지 않은 것 — 노드가 영영 안 돌아오는 경우. local-path PVC 가 그 노드와 함께 사라진 상태에서의 복구는 D-1(백업·복원)의 주제다.
  • 이 실험이 남기는 구성 숙제 셋 — Traefik replicas=1 이라 진입점이 단일 장애점인 것(replicas=2 로 늘리거나 DaemonSet 으로), Grafana 가 emptyDir 이라 재시작마다 세션이 사라지는 것(PVC 를 붙인다), 관측 스택이 실험 대상 노드에 함께 있는 것(노드가 둘뿐이라 완전히는 못 피한다).

A-5 — 한 방향만 끊으면 왜 안 갈라지는가

근거: a5-asymmetric-partition.md (919줄). 실행 기록은 2026-09-04 12:2812:46 KST(observed).

이 실험이 가르는 것

A-1 이 답하지 못하고 넘긴 물음에서 출발한다. 가이드는 그 물음을 그대로 인용한다.

keycloak-1 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, keycloak-0 은 합류 자체를 못 해 DOWN 이 됐다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애다.

그 경로를 찾으려고 이 실험을 한다. A-1 은 도구도 하나 남겼다.

A-1 이 배운 것
NetworkPolicy 기존 연결을 못 끊는다. conntrack 의 ESTABLISHED 가 먼저 통과시킨다
그래서 이번엔 iptables 로 직접 간다

그런데 iptables 에도 벽이 세 개 있었다. 가이드는 그 세 번의 실패를 일부러 다시 밟게 한다. 셋 다 화면에는 **「아무 일도 없었다」**로 보이므로, 겪어 보지 않으면 다음에도 똑같이 속는다.

   실패 ①   filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다
   실패 ②   raw 로 옮겼는데도 0 패킷        → 연결 방향을 잘못 짚었다
   성공     수신측 노드의 raw PREROUTING     → 19 패킷
   그런데   그래도 안 갈라진다               → 반대 방향으로 재연결한다

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 규칙을 넣었는데 0 패킷인 상태를 iptables -L -n -v 의 카운터에서, kube-router 가 내 규칙을 아래로 밀어내는 것을 FORWARD 체인의 줄 번호에서, JGroups 연결 방향이 A-1 때와 반대인 것을 conntrack -L 에서, 단방향 차단이 스스로 낫는 것을 뒤집힌 연결에서, coord = t 가 둘인 split brain 을 PostgreSQL JGROUPS_PING 에서, 그런데 한쪽만 DOWN 이고 외부는 200 인 것을 health/readyendpointslice 에서, MergeView 로 50초 만에 합쳐지는 것을 Keycloak 로그에서.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • A-1 을 먼저 해 두면 훨씬 이해가 빠르다. 이 실험은 A-1 이 실패한 곳에서 시작한다.
  • kc-lab-2 에는 ssh kc-lab-2 로 붙는다. iptables 는 두 노드에 각각 넣어야 하고, 어느 노드에 넣느냐가 이 실험의 핵심이다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 상주 탐침 파드용, 하나는 관찰용.

이건 상태를 부수는 실험이다. 가이드의 경고를 그대로 옮긴다 — Keycloak 클러스터를 실제로 분단시킨다. 실험대에서만 한다. 전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적혀 있다.

중간에 그만두는 명령을 가이드는 두 줄로 적는다. 둘째 줄에 SSH 접속과 원격 셸의 인용과 세미콜론으로 이은 명령 둘이 한꺼번에 들어 있다.

이 실험대는 이렇게 했다(observed)

sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD'

★ 2026-09-17 에는 lab host 에서 양쪽을 같은 형태로 쳤다(observed). kubectl 이 lab host 에서 도니 게스트에 들어갈 일이 iptables 뿐이고, 그러면 두 노드를 같은 모양으로 다룰 수 있다.

ssh kc-lab-1 'sudo iptables -S FORWARD'
ssh kc-lab-1 'sudo iptables -t raw -F PREROUTING'
ssh kc-lab-1 'sudo iptables -F FORWARD'

따라 하는 사람은 반대 노드 쪽을 나눌 수 있다. 먼저 붙고, 붙은 다음에 두 줄을 따로 친다. 행동 하나가 명령 하나가 된다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

ssh kc-lab-2
sudo iptables -t raw -F PREROUTING
sudo iptables -F FORWARD
exit

폴더 README 가 적은 대로 iptables 는 노드 자체를 건드리는 명령이라 게스트 셸이 필요하다.

-F FORWARD 에는 가이드가 따로 단서를 붙인다 — 그 체인 전체를 비운다. 이 실험대의 FORWARD 정책은 ACCEPT 이고 실제 규칙은 kube-router·kube-proxy 가 자기 체인에 두므로 잠시 뒤 스스로 복구된다. 그래도 지우기 전에 sudo iptables -S FORWARD 로 무엇이 있었는지 한 번 보고 지운다.

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

주입이 걸리기 전과 후가 화면상 똑같이 보이는 실험이다. 먼저 본 것이 없으면 실패를 성공으로 읽는다.

파드 IP·노드 → 디스커버리(DB) → 클러스터 뷰(로그) → 지표 → 연결 방향 → 밖
kubectl -n keycloak-lab get pods -o wide

실측(observed) — 01-injection.txt

  keycloak-0=10.42.1.77 (kc-lab-2)   keycloak-1=10.42.0.42 (kc-lab-1)
keycloak-0                  1/1   Running   0               11m
keycloak-1                  1/1   Running   1 (2m48s ago)   155m
postgres-7b474b88c8-9cmsv   1/1   Running   0               14m

NODE 와 파드 번호가 어긋난다keycloak-0kc-lab-2 에 있다. iptables 를 어느 노드에 넣을지 정할 때 이걸 헷갈리면 규칙은 걸리는데 패킷은 안 걸린다. 그리고 IP 가 A-1 때와 다르다(10.42.1.4310.42.1.77). 파드가 재시작되면 바뀌므로 여기 적힌 값을 쓰지 말고 지금 뽑는다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "K0=$K0 K1=$K1"

keycloak-1RESTARTS1 인 것도 보인다. A-4 에서 노드를 껐다 켠 흔적이고, 앞 실험의 잔재가 남아 있는지 여기서 함께 확인한다.

디스커버리는 DB 가 말한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"

모양은 이렇고 숫자는 환경마다 다르다(observed).

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-0-24309 | 10.42.1.77:7800 | f
 keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)

coord 열에 t 가 정확히 하나. 둘이면 이미 갈라져 있고, 그 상태에서 주입해 봐야 아무것도 판정하지 못한다.

kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)]

뷰 ID(|13)를 적어 둔다. 이 실험의 판정 기준이 이 숫자의 변화다.

지표는 밖에서 Prometheus 에 묻는다. Keycloak 컨테이너에는 curlwget 도 없다(exit 127).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

한 줄짜리 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다. 모양은 이렇고 값은 환경마다 다르다(observed).

{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-1","node":"kc-lab-1"},"value":[1757037600.123,"2"]},
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-0","node":"kc-lab-2"},"value":[1757037600.123,"2"]}]}}

라벨을 보고 나면 읽기 좋게 자른다. jq 는 이 실험대에 없다. 가이드는 이 줄을 미검증으로 표시했다(unknown).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'

두 줄이고 값이 둘 다 2 다.

★ 원 실행에서는 이 값이 안 남았다. 값을 뽑으려고 붙인 파이썬 한 줄이 죽었다(observed) — 01-injection.txt.

Traceback (most recent call last):
  File "<string>", line 3, in <module>
    for r in json.load(sys.stdin)["data"]["result"]: print(f"  cluster_size {r["metric"].get("pod"):12} = {r["value"][1]}")
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

입력이 비어 있었다. 그런데 파서가 죽으면서 원본도 같이 사라졌다 — 화면에 남은 것은 파이썬 스택트레이스뿐이고, Prometheus 가 무엇을 돌려줬는지는 아무도 모른다. 가이드가 wget 원문을 먼저 보여 주는 까닭이 이것이고, 원문을 먼저 보고 나중에 자르면 같은 일이 안 생긴다.

★ 연결 방향이 이 실험에서 가장 중요한 사전 관측이다. 어느 쪽이 클라이언트이고 어느 쪽이 서버인지 모르면 규칙을 엉뚱한 노드에 넣게 된다.

이 실험대는 이렇게 했다(observed)

sudo conntrack -L 2>/dev/null | grep 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'

따라 하는 사람은 둘째 줄을 나눌 수 있다. 붙고 나서 원격 셸에서 친다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

ssh kc-lab-2
sudo conntrack -L 2>/dev/null | grep 7800
exit

실측(observed) — 해설 문서 1절, 실패 ② 에서 인용된 원 실행의 연결

ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
            ────────────            ────────────────────
            keycloak-0 가 클라이언트   keycloak-1 이 서버

dport=7800 인 쪽이 서버src 가 클라이언트다. A-1 때와 방향이 반대다. A-1 에서는 10.42.0.35:40023 → 10.42.1.43:7800, 즉 keycloak-1 이 걸었다. 지금은 keycloak-0 이 건다.

JGroups 의 TCP 연결 방향은 고정이 아니다. 먼저 뜬 쪽, 먼저 JOIN 을 건 쪽에 따라 달라지고 파드가 재시작될 때마다 바뀔 수 있다. 가정하지 말고 매번 conntrack -L 로 본다.

2>/dev/nullconntrack 이 stderr 로 찍는 「N flow entries have been shown」 요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다.

밖에서 보이는 상태는 읽는 형태로 한 번 보고 나서 코드만 뽑는다.

curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

200 이어야 한다.

주입

주입은 네 번이고 앞의 둘은 일부러 실패한다. 가이드는 건너뛰지 말라고 적는다 — 이 실패의 모양을 봐 둬야 다음에 자기 규칙을 의심할 수 있다.

시도 ① filter 테이블 최상단. A-1 의 NetworkPolicy 는 conntrack 에 막혔으니 FORWARD 최상단에 넣으면 conntrack 승인보다 먼저 평가되리라는 가설이다. 되돌리기는 ssh kc-lab-2 'sudo iptables -F FORWARD' 다.

ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800  -j DROP"
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'

57800 도 같이 막는다. FD_SOCK2(장애 감지 채널)는 bind_port + 50000 을 쓴다. 7800 만 막으면 장애 감지는 계속 통해서 분단이 어정쩡해진다.

치우는 명령은 넣을 때와 인자가 같아야 한다.

ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 7800  -j DROP"
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 57800 -j DROP"
ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5'

안 지워지면 줄 번호로 지운다 — sudo iptables -D FORWARD 3.

시도 ② raw 테이블. netfilter 의 처리 순서가 그 근거다.

   패킷 도착
     │
     ├─▶ raw     PREROUTING     ← conntrack 보다 먼저. NOTRACK·DROP 용
     │
     ├─▶ conntrack 조회/생성    ← 여기서 ESTABLISHED 가 결정된다
     │
     ├─▶ mangle  PREROUTING
     ├─▶ nat     PREROUTING
     ├─▶ filter  FORWARD        ← NetworkPolicy·kube-router 가 여기 있다
     └─▶ 목적지 파드
어디에 넣는가 기존 연결을 끊는가 CNI 와 경쟁하는가
NetworkPolicy (filter) 못 끊는다 — conntrack 이 먼저 통과시킨다 (A-1) 없음
filter FORWARD 직접 순서에 따라 경쟁한다 (kube-router 가 밀어낸다)
raw PREROUTING 끊는다 없다CNI 가 안 쓰는 테이블

그래서 테이블만 바꾸고 노드와 목적지는 그대로 둔다.

ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800  -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'

지우기 전에 -L 로 무엇이 있는지 본다. -F 는 체인 전체를 비운다.

ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers'
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'

성공한 주입은 수신측 노드의 raw PREROUTING 이다. kc-lab-1(keycloak-1 이 있는 노드)에 keycloak-1 의 IP 를 목적지로 넣는다.

sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800  -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
date '+%H:%M:%S 주입'

★ 그 두 줄을 게스트 셸에서 치면 안 된다. $K1[lab host] 셸의 변수이고 ssh kc-lab-1 로 들어간 셸에는 없다. 빈 문자열이 들어가면 -d 없는 규칙이 걸려 7800 으로 가는 모든 패킷이 끊긴다. 2026-09-17 에 게스트 셸에서 확인했다(observed).

게스트 셸의 K1=[]

lab host 에서 한 줄 형태로 친다. 큰따옴표가 값을 여기서 펴서 보낸다.

ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800  -j DROP"
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP"
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
ssh kc-lab-1 'sudo conntrack -L 2>/dev/null | grep 7800'

실측(observed) — 04-correct-direction.txt

=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
  주입: 12:33:58

네 번째 주입이 양방향이다. kc-lab-1 의 규칙은 그대로 두고 kc-lab-2 에 반대 방향을 더한다.

ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800  -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'

실측(observed) — 07-bidirectional-block.txt

=== 양방향 차단 — 두 노드 모두에 raw DROP ===
  주입: 12:40:25

주입 검증

카운터가 유일한 판정 기준이다. 규칙이 목록에 보이는 것은 검증이 아니다.

시도 ① 은 넣은 직후에 맞게 보인다.

ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'

실측(observed) — 01-injection.txt

Chain FORWARD (policy ACCEPT)
num  target     prot opt source               destination
1    DROP       6    --  0.0.0.0/0            10.42.1.77           tcp dpt:57800
2    DROP       6    --  0.0.0.0/0            10.42.1.77           tcp dpt:7800
  주입 시각: 12:28:23

여기서 만족하고 넘어가면 속는다. 1~2분 뒤 같은 명령을 다시 친다.

실측(observed) — 해설 문서 1절, 실패 ①

num   pkts bytes target
1      232  377K KUBE-ROUTER-FORWARD  /* kube-router netpol */   ← 다시 1번이 되었다
2        0     0 DROP  tcp dpt:57800
3        0     0 DROP  tcp dpt:7800                              ← 0 패킷

두 열을 동시에 본다.

무엇을 말하는가
num 내 규칙이 1번이 아니다. kube-router 체인이 위로 돌아왔다
pkts 0. 이 규칙에는 패킷이 단 한 개도 도달하지 않았다

kube-router 가 주기적으로 자기 체인을 FORWARD 최상단에 다시 삽입한다. 1번에 넣어도 곧 2번, 3번으로 밀려나고 kube-router 체인이 패킷을 먼저 처리한다. 직접 넣은 iptables 규칙은 CNI 가 관리하는 체인과 경쟁하므로, 넣는 것으로 끝이 아니라 패킷 카운터로 확인해야 한다.

가이드는 이 대목에 증거의 한계를 함께 적는다 — 이 확인을 담았어야 할 02-injection-verify.txt원 실험 시점에 0바이트로 저장됐다(observed). 리다이렉션이 stdout 만 받았는데 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 사후에 다시 수집한 것이고, 원 시점의 DROP 규칙은 이미 없어서 재현되지 않는다. 남아 있는 사실은 하나 — kube-router 체인이 FORWARD 1번을 차지하고 있다는 것뿐이다. 따라 하는 사람은 실제 카운터를 볼 수 있다.

시도 ② 는 CNI 와 경쟁하지도 않는데 0 이다.

ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'

실측(observed) — 03-raw-table-injection.txt

=== [검증] 이번엔 패킷이 걸렸는가 ===
  Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
   pkts bytes target     prot opt in     out     source               destination
      0     0 DROP       6    --  *      *       0.0.0.0/0            10.42.1.77           tcp dpt:57800
      0     0 DROP       6    --  *      *       0.0.0.0/0            10.42.1.77           tcp dpt:7800

앞에서 본 conntrack 이 답이다. 10.42.1.77(keycloak-0)은 이 연결의 출발지다. -d 10.42.1.77 --dport 7800존재하지 않는 패킷을 노린 규칙이었다. 7800 으로 들어가는 패킷은 10.42.0.42(keycloak-1) 쪽으로 간다.

   내가 막은 것:  → 10.42.1.77:7800    (그런 패킷이 없다)
   실제 흐름:     → 10.42.0.42:7800    (여기를 막아야 한다)

규칙을 넣은 노드도 틀렸다. 목적지 파드가 있는 노드에서 잡아야 한다.

성공한 주입에서 처음으로 숫자가 올라간다.

sudo iptables -t raw -L PREROUTING -n -v

실측(observed) — 04-correct-direction.txt

   pkts bytes target     prot opt in     out     source               destination
      0     0 DROP       6    --  *      *       0.0.0.0/0            10.42.0.42           tcp dpt:57800
     19  2938 DROP       6    --  *      *       0.0.0.0/0            10.42.0.42           tcp dpt:7800

7800 규칙의 pkts 가 19. 드디어 걸린다. 57800 이 아직 0 인 것도 정보다 — FD_SOCK2 는 이미 붙어 있는 연결을 쓰고 있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다.

실측(observed) — 05-reconnect-observed.txt

=== 차단 규칙 누적 카운터 ===
     19  1096 DROP       6    --  *      *       0.0.0.0/0            10.42.0.42           tcp dpt:57800
     21  3058 DROP       6    --  *      *       0.0.0.0/0            10.42.0.42           tcp dpt:7800

가이드의 카운터 판정표를 그대로 옮긴다.

pkts 할 일
0 아무것도 측정하지 않았다 해석 금지. 방향과 테이블을 다시 본다
조금씩 는다 재연결 시도가 막히고 있다 관찰로 넘어간다
폭증한다 대상이 너무 넓다 -d·--dport 를 좁힌다

양방향 주입에서는 양쪽 카운터를 다 본다. 한쪽만 걸리면 그건 여전히 단방향이다.

sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'

관찰

시도 ① 로 주입한 뒤 25초 간격으로 몇 번 본다.

kubectl -n keycloak-lab get pods | grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

실측(observed) — 01-injection.txt

  +25초   | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
  +50초   | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
  ...
  +200초   | keycloak-0:1/1 keycloak-1:1/1 | 외부 200

★ 여기서 「비대칭 차단은 클러스터를 안 가른다」고 결론 내리면 틀린다. 결론이 우연히 맞더라도 근거가 없다. 규칙에 패킷이 0 개 왔으니 이 관찰은 아무것도 측정하지 않았다.

성공한 단방향 주입 뒤에는 같은 두 줄이 다른 답을 낸다.

실측(observed) — 04-correct-direction.txt

  +25초  - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
  +50초  - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
  +75초  - | keycloak-0:1/1 keycloak-1:0/1 | 외부 200
  +100초  - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
  +125초  - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200

+75초keycloak-1한 번 0/1 로 흔들렸다가 +100초 에 돌아온다. 주입이 닿기는 했고(시도 ① 의 아무 일 없음과 다르다) 스스로 나았다.

뷰는 로그가 말한다.

kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094
kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094

여기서 시각을 비교하려다 대부분 한 번은 틀린다.

   당신 셸의 date          12:33:58 KST
   컨테이너 로그의 시각    03:33:58        ← 같은 순간이다. UTC 다

Keycloak 컨테이너는 UTC(Coordinated Universal Time, 협정 세계시)로 찍는다. KST 는 UTC+9 이므로 9시간을 빼서 맞춰 본다. 이걸 모르면 「주입 전 로그」와 「주입 후 로그」를 정반대로 가른다.

실측(observed) — 06-view-history-and-cleanup.txt

  2026-09-04 03:33:49 | MergeView::[keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)],

13, 멤버 (2), 그리고 MergeView.

★ 이 실험에서 가장 미묘한 대목이다. 해설 문서는 처음에 「주입 이후 뷰 변화가 하나도 없었다」고 썼다. 맞는 말이다. 그런데 그 「주입 전부터 그대로」의 「전」이 9초였다.

   03:33:49   MergeView 로 뷰 13 이 만들어짐   ← 그 직전에는 |12] (1), 즉 분단 상태였다
   03:33:58   내 주입                          ← 9초 뒤

앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였다. 로그 한 줄만 보고 「변화 없음」이라고 말하면 안 되고, 그 줄이 언제 생겼는지를 함께 본다. 결론 자체(주입 이후 뷰가 변하지 않았다)는 유지되지만, 먼저 본 상태가 9초짜리 였다는 사실을 함께 적어야 정직하다.

★ 왜 안 갈라졌나 — 연결이 뒤집혔다. 앞에서 친 것과 똑같은 명령을 다시 친다. 그게 대조하는 방법이다.

sudo conntrack -L 2>/dev/null | grep 7800

실측(observed) — 05-reconnect-observed.txt

  tcp      6 299 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=48473 dport=7800 src=10.42.1.77 dst=10.42.0.42 sport=7800 dport=48473
  tcp      6 86232 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=44205 dport=57800 src=10.42.1.77 dst=10.42.0.42 sport=57800 dport=44205

srcdst 를 앞의 관측과 나란히 놓는다.

차단 전:  src=10.42.1.77 → dst=10.42.0.42:7800     ← 내가 막은 방향
차단 후:  src=10.42.0.42 → dst=10.42.1.77:7800     ← 열린 방향으로 다시 붙었다

JGroups 는 막힌 연결이 죽자 반대 방향으로 새로 연결했다. 그리고 FD_SOCK2 가 상대를 의심하기 전에 복구가 끝났다. 의심 카운터가 그것을 뒷받침한다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members'
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'

실측(observed) — 06-view-history-and-cleanup.txt

  keycloak-0  merge_events=1.0  suspected=0.0
  keycloak-1  merge_events=1.0  suspected=0.0
  keycloak-0  cluster_size=2.0
  keycloak-1  cluster_size=2.0

suspected = 0. 아무도 상대를 의심하지 않았다 — 끊긴 적이 없는 것과 같다. (merge_events = 1 은 9초 전 병합의 것이다.)

한 방향만 막는 것으로는 JGroups 를 가를 수 없다. 두 노드는 서로에게 연결을 걸 수 있으므로 한쪽 길이 막히면 다른 길로 간다. 운영에서는 좋은 소식이다 — 단방향 방화벽 오설정은 자가 치유된다. 분단을 재현하려는 실험자에게는 함정이다.

양방향으로 막으면 이번에는 갈라진다. 25초 간격으로 셋을 함께 본다.

kubectl -n keycloak-lab get pods | grep keycloak
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
  -o "custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

실측(observed) — 07-bidirectional-block.txt

  +75초  keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
  +100초  keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
  +125초  keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
  ...
  +225초  keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200

keycloak-10/1 로 내려가서 안 돌아온다(단방향 때와 다르다), ready 주소가 둘에서 하나로 줄었다, 외부는 계속 200 이다. kubectl get endpoints 는 v1.33 부터 deprecated 라 경고가 뜨므로 endpointslice 를 본다.

뷰도 갈린다.

실측(observed) — 같은 파일

  keycloak-0  [keycloak-0-24309(v=16.0.12)|14] (1) [keycloak-0-24309(v=16.0.12)]
  keycloak-1  [keycloak-1-45480(v=16.0.12)|14] (1) [keycloak-1-45480(v=16.0.12)]

뷰 ID 는 둘 다 14 인데 멤버는 각자 1 명이다. 같은 번호의 다른 세계다.

split brain 은 DB 한 줄로 확인한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"

실측(observed) — 08-coordinator-and-recovery.txt

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-0-24309 | 10.42.1.77:7800 | t
 keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)

coord = t 가 둘. 앞에서 하나였던 것과 대조한다. 분단을 확인하는 가장 짧은 명령이 이것이다 — 로그를 두 번 긁는 것보다 빠르고 지표보다 정확하다.

★ 그런데 한쪽만 DOWN 이다. Keycloak 컨테이너에 curl 이 없으므로 상주 파드를 띄운다.

kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --command -- sleep 1800
kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s

가이드는 일회용 파드를 안 쓰는 까닭을 증거로 댄다. 원 실행이 --rm -it 로 했다가 붙지 못했다(observed) — 08-coordinator-and-recovery.txt.

warning: couldn't attach to pod/a5-h, falling back to streaming logs: Internal error occurred: error attaching to container: container is in CONTAINER_EXITED state

파드가 만들어지고 명령이 끝나 버리기 전에 붙어야 하는 경주가 된다. 관찰을 여러 번 반복할 것이라면 상주 파드가 항상 낫다.

kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready"
kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready"

실측(observed) — 같은 파일

--- keycloak-0 ---
{"status":"UP","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
{"name":"Keycloakclusterhealthcheck","status":"UP"}

--- keycloak-1 ---
{"status":"DOWN","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"Keycloakdatabaseconnectionsasynchealthcheck","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}

맨 앞의 "status" 를 본다. keycloak-0 은 UP, keycloak-1 은 DOWN. 그리고 keycloak-1 쪽에서 DB 체크는 UP 이다 — DB 때문이 아니라 클러스터 때문이다.

A-1 의 열린 질문에 대한 답이 여기서 나온다.

keycloak-0 keycloak-1
분단 전 역할 코디네이터 (뷰 13 의 발행자) 일반 멤버
분단 후 자기 인식 「멤버가 하나 나갔다」 — 정상 사건 「코디네이터를 잃었다」 — 비정상
헬스체크 UP DOWN
Service 엔드포인트 남는다 빠진다

Keycloak 의 클러스터 헬스체크는 비대칭이다. 코디네이터였던 쪽은 자기가 정상이라고 보고, 잃은 쪽만 DOWN 이 된다. 그래서 완전 분단조차 용량 저하로 끝나고 전면 장애가 되지 않는다. A-2(DB 상실)에서는 양쪽이 동시에 DOWN 이었다. 차이는 이것이다 — DB 는 모두가 의존하는 하나지만, 클러스터 멤버십은 서로 상대적이다.

같은 것을 그림으로 본 화면이 증거에 있다 — a5-cluster-size-bidirectional-block.png.

복구와 원상복구 확인표

지우기 전에 무엇이 있는지 먼저 본다.

sudo iptables -t raw -L PREROUTING -n -v --line-numbers
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
date '+%H:%M:%S 해제'
sudo iptables -t raw -F PREROUTING
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'

실측(observed) — 08-coordinator-and-recovery.txt

=== 차단 해제 ===
  해제: 12:44:37

25초 간격으로 파드를 본다.

실측(observed) — 같은 파일

  +25초  keycloak-0:1/1 keycloak-1:0/1
  +50초  keycloak-0:1/1 keycloak-1:1/1
  → 복구 완료

50초. 사람 개입 없음. 누가 붙였는지는 MergeView 가 말한다.

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

실측(observed) — 같은 파일

  keycloak-0  MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
  keycloak-1  MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(

뷰 ID 가 15, 멤버 (2), 양쪽이 같은 줄이다.

[keycloak-0-24309|13] (2)   ← 정상
[keycloak-0-24309|14] (1)   ← 분단. 양쪽이 각자 14 를 발행
MergeView::[...|15] (2)     ← 병합. 뷰 ID 는 계속 증가한다

뷰 ID 는 단조 증가하므로 「언제 몇 번 갈라졌는지」를 로그만으로 셀 수 있다.

캐시별 재분배 로그도 남는다.

kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6

실측(observed) — 해설 문서 4절, 원문은 06-view-history-and-cleanup.txt

[Context=work]            ISPN100007: After merge (or coordinator change) ...
[Context=clientSessions]  ISPN100007: After merge ...
[Context=offlineSessions] ISPN100007: After merge ...
[Context=loginFailures]   ISPN100007: After merge ...
[Context=actionTokens]    ISPN100007: After merge ...

증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 모양도 한 번 본다.

  2026-09-04 03:32:59,874 INFO  [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo

ISPN100007 은 병합(또는 코디네이터 변경) 후 캐시별 토폴로지 재계산이다. 캐시가 여럿이므로 로그도 캐시 수만큼 나온다. 한 줄만 보고 「한 번 재분배됐다」고 세면 틀린다 — work·clientSessions·offlineSessions· loginFailures·actionTokens 가 각각 찍는다.

항목 명령 돌아왔을 때
raw 규칙 sudo iptables -t raw -S PREROUTING -P PREROUTING ACCEPT
filter 규칙 sudo iptables -S FORWARD | head -5 내가 넣은 DROP 이 없음
(반대 노드) ssh kc-lab-2 'sudo iptables -t raw -S PREROUTING' 같음
파드 kubectl -n keycloak-lab get pods keycloak 둘 다 1/1 Running
디스커버리 psql -c "select name, ip, coord from jgroups_ping order by name" coord = t 가 하나
logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2), 양쪽 동일
지표 vendor_cluster_size 양쪽 2
Service get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
탐침 파드 kubectl -n keycloak-lab get pod a5-probe 지웠으면 NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found

conntrack 은 건드리지 않아도 된다. 차단이 풀리면 새 연결이 스스로 성립한다.

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
규칙을 넣었는데 아무 일도 없다 카운터가 0 이면 아무것도 측정 안 된 것 iptables -L -n -vpkts
내 규칙이 1번이 아니다 kube-router 가 자기 체인을 재삽입한다 --line-numbers 로 순서
raw 인데도 0 패킷 연결 방향을 잘못 짚었다 conntrack -L | grep 7800
conntrack 에 아무것도 안 보인다 반대 노드에서 봤다 두 노드 모두에서 본다
단방향인데 안 갈라진다 정상이다. 열린 방향으로 재연결한다 conntracksrc/dst 뒤집힘
로그에 변화가 없어 보인다 컨테이너 로그는 UTC. KST 와 9시간 차 logs 의 시각에서 9를 뺀다
「주입 전부터 그대로」인데 미심쩍다 그 「전」이 9초일 수 있다 앞 뷰가 언제 생겼는지 본다
-D 로 규칙이 안 지워진다 넣을 때와 인자가 다르다 줄 번호로 지운다: -D FORWARD 3
임시 파드에 attach 실패 --rm -it 는 경주가 된다 상주 파드를 쓴다
kubectl exec keycloak-0 -- curlexit 127 이미지에 curl 도 wget 도 없다 탐침 파드나 Prometheus
지표를 파이썬으로 자르다 죽었다 원본까지 같이 사라진다 wget 원문을 먼저 본다
get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=...
57800 카운터만 0 이다 FD_SOCK2 가 아직 재연결을 안 했다 조금 기다렸다 다시 본다
해제했는데 2~3분째 안 붙는다 반대 노드 규칙이 남아 있다 두 노드 모두 -t raw -S PREROUTING

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

  • (observed) 파드 IP 10.42.1.77·10.42.0.42 와 노드 배치, 뷰 ID 131415, 시도 ① 의 pkts 0 과 kube-router 가 되찾은 num 1, 시도 ② 의 pkts 0, 성공한 주입의 19 2938 과 이어서 19 1096 · 21 3058, 주입 12:28:23·12:33:58·12:40:25 와 해제 12:44:37, 단방향에서 +75초0/1+100초 의 복귀, 양방향에서 +100초 이후 0/1 고정과 ready 주소 하나, coord = t 둘, 양쪽 헬스체크의 UP/DOWN, suspected=0.0merge_events=1.0, 복구 50초, MergeView15, ISPN100007 다섯 캐시, MergeView03:33:49 에 생기고 주입이 03:33:58 인 9초 간격.
  • (unknown) tr ',' '\n' | grep -E 로 자른 Prometheus 출력. 가이드가 미검증으로 표시했다. ssh kc-lab-2 로 들어가 원격 셸에서 conntrackiptables -F 를 따로 치는 두 단계 형태도 이 실험대에서 치지 않았다.
  • 증거가 비어 있는 곳 — 시도 ① 의 카운터를 담았어야 할 02-injection-verify.txt원 시점에 0바이트로 저장됐다. 지금 그 파일에 있는 것은 사후 수집이고 원 시점의 DROP 규칙은 재현되지 않는다.
  • 원 실행에 안 남은 것 — 주입 전 vendor_cluster_size 값. 파이썬 한 줄이 죽으면서 Prometheus 원본까지 함께 사라졌다.
  • 이 실험이 재지 않은 것 — 분단 중에 세션이 어떻게 되는지는 재지 않았다 (그건 A-1 의 주제다). 여기서는 누가 살아남는가만 봤다.

A-6 — 200ms 를 넣으면 22초가 되는 경로

근거: a6-latency-injection.md (926줄). 실행 기록은 2026-09-04 13:1013:35 KST(observed).

이 실험이 가르는 것

A-2 는 DB 를 완전히 세웠고 A-4 는 기계를 통째로 껐다. 둘 다 즉시 드러났다. 503 이 나오고 up 이 0 이 됐다. 실제 장애의 대부분은 그렇지 않다. 느려지기만 한다. 그리고 느려짐은 사망보다 진단하기 어렵다 — 헬스체크가 통과하기 때문이다.

가이드가 묻는 것은 하나다.

   200밀리초를 넣으면 애플리케이션은 200밀리초 느려지는가?

답은 아니다. 두 군데에서 곱해진다. 가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — eth0 이라는 인터페이스가 없다는 것을 ip -brief link 에서, 스크립트가 「적용완료」를 찍었는데 아무것도 안 걸린 것tc -s qdisc 카운터에서, enp1s0 에서는 파드 IP 가 안 보이는 것을 VXLAN 캡슐화에서, 200ms 가 1,872ms 가 되는 것을 두 노드 응답 시간 비교에서, 동시 20건이 22.2초까지 계단으로 늘어나는 것을 상주 탐침이 모은 파일에서, 커넥션 획득에 20초를 기다린 요청을 agroal_blocking_time_max_milliseconds 에서, readiness 프로브가 같은 줄에 서서 타임아웃되는 것을 kubectl get events 에서, 예측했던 낙관적 락 충돌이 0건인 것을 Keycloak 로그에서.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • A-5 를 먼저 해 두면 좋다. 「주입을 넣은 것과 걸린 것은 다르다」가 여기서 세 번째로 나온다.
  • tckc-lab-2 에서 친다(ssh kc-lab-2). postgres 가 그 노드에 있다.
  • 터미널 두 개면 편하다. 하나는 부하·측정, 하나는 이벤트 관찰.

이건 상태를 부수는 실험이다. 가이드의 경고를 그대로 옮긴다 — Keycloak 한 대를 느려지게 만든다. 파드가 재시작될 수 있고 readiness 가 빠진다. 실험대에서만 한다. 전 구간 약 30분이다.

중간에 그만두는 명령은 한 줄이다.

ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'

이 한 줄이 세 가지를 다 지운다prio qdisc, 그 아래 netem, 그리고 filter. root 를 지우면 자식이 함께 사라진다.

이 실험에서 tc 는 노드 자체를 건드리는 명령이라 폴더 README 가 말하는 게스트 셸의 경우에 해당한다. 따라 하는 사람은 ssh kc-lab-2 로 먼저 붙고 원격 셸에서 sudo tc ... 를 칠 수 있다. 그러면 한 줄에 SSH 접속과 원격 셸의 인용이 겹치지 않는다. 이 두 단계 형태는 이 실험대에서 치지 않았다(unknown). 아래 명령들은 가이드가 실제로 친 한 줄 형태 그대로 옮긴다.

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

배치를 먼저 확인해야 이 실험이 성립한다. 대조군이 같은 클러스터 안에 있는 설계이기 때문이다.

파드 배치 → 상주 탐침 → 단일 요청 → 20회 반복 → 커넥션 풀 지표
kubectl -n keycloak-lab get pods -o wide

실측(observed) — 01-baseline.txt

  postgres  10.42.1.76  (kc-lab-2)
  keycloak-0 10.42.1.77 (kc-lab-2) → DB 와 같은 노드, cni0 로 직행
  keycloak-1 10.42.0.42 (kc-lab-1) → DB 와 다른 노드, VXLAN 을 건넌다  ← 여기에 지연을 건다

postgres 와 keycloak-0 이 같은 노드인지를 본다.

   kc-lab-2                        kc-lab-1
   ┌──────────────────┐            ┌──────────────────┐
   │ postgres         │            │ keycloak-1       │
   │ keycloak-0       │            │                  │
   │   └─ cni0 로 직행 │◀─ VXLAN ──▶│   └─ 오버레이 경유 │
   └──────────────────┘            └──────────────────┘
        지연 없음                        여기만 느려진다

postgres 가 보내는 패킷 중 노드를 건너가는 것만 지연시키면 keycloak-1 의 DB 접근만 느려지고 keycloak-0 은 그대로다. 대조군이 같은 실험 안에 있다. 파드를 두 개 더 띄울 필요도, 다른 시간대와 비교할 필요도 없다. 배치가 다르면 이 실험은 성립하지 않는다 — 두 Keycloak 이 모두 DB 와 다른 노드에 있으면 대조군이 없고, 모두 같은 노드에 있으면 시험군이 없다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].status.podIP}')
echo "K0=$K0 K1=$K1 PG=$PG"

Keycloak 컨테이너에는 curlwget 도 없고(exit 127) 같은 요청을 수십 번 반복해야 하므로 상주 탐침 파드를 먼저 띄운다.

kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" --env="K1=$K1" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 1800
kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s

비밀번호는 명령 치환으로 넘어가므로 값이 화면에 안 나온다. 확인할 때도 길이만 본다.

kubectl -n keycloak-lab exec a6-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW=${#PW}자"'

모양은 이렇고 값은 환경마다 다르다(observed).

K0=10.42.1.77 K1=10.42.0.42 PW=32자

PW=0자 면 시크릿이 안 넘어간 것이고, 그 상태로 재면 전부 401 을 재게 된다.

kubectl run --rm -i 로 부하를 주면 안 된다. 원 실행이 그렇게 했다가 동시 20건의 출력을 잃었다. 파드가 만들어지고 지워지는 사이에 stdout 을 붙잡는 경주가 되고, 20줄 중 일부만 도착하거나 아예 끊긴다. 결과는 파드 안 파일에 모으고 끝나면 한 번에 꺼낸다. 그리고 탐침의 K0/K1 은 만들 때 고정된다. Keycloak 파드가 재시작되면 IP 가 바뀌고 탐침의 값은 낡는다. 그때는 탐침을 지우고 다시 만든다. 이걸 놓치면 「아무 데도 안 닿음」을 「지연」으로 착각한다.

요청 하나를 읽는 형태로 먼저 친다. 시간이 어디서 드는지 봐야 나중에 무엇이 변했는지 안다.

kubectl -n keycloak-lab exec a6-probe -- sh -c '
  curl -s -o /dev/null \
    -w "connect %{time_connect}  ttfb %{time_starttransfer}  total %{time_total}\n" \
    -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
    -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'

모양은 이렇고 숫자는 환경마다 다르다(observed).

connect 0.001  ttfb 0.065  total 0.066
무엇의 시간인가
time_connect 탐침 → Keycloak TCP 연결. 이 실험에서 거의 안 변한다
time_starttransfer 첫 바이트까지 = Keycloak 이 DB 와 대화한 시간. 여기가 폭발한다

지연은 탐침과 Keycloak 사이가 아니라 Keycloak 과 DB 사이에 넣는다. 그래서 connect 는 그대로고 ttfb 만 는다. 주입 후에 이 두 값을 다시 보면 어디에 지연이 걸렸는지 한눈에 판정된다. 응답이 401 이나 400 이면 -o /dev/null 을 빼고 본문을 본다.

20회를 반복해 원본을 파일에 모은다.

kubectl -n keycloak-lab exec a6-probe -- sh -c '
  rm -f /tmp/base-k1 ; i=0
  while [ $i -lt 20 ]; do
    curl -s -o /dev/null -w "%{time_total}\n" \
      -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
      -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
      >> /tmp/base-k1
    i=$((i+1))
  done'

원본을 먼저 본다. 평균만 보면 한 건이 튄 것을 놓친다.

kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1

그 다음 줄여서 본다.

kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1 \
  | awk '{s+=$1} END {printf "%d회 평균 %.0f ms\n", NR, s*1000/NR}'

$K1$K0 로 바꿔 대조군도 똑같이 잰다.

실측(observed) — 01-baseline.txt

=== 기준선 지연 — 각 노드에서 로그인 20회 ===
  keycloak-0  평균 70 ms
  keycloak-1  평균 66 ms

두 값이 비슷하다. 지금 keycloak-1 이 오히려 4ms 빠르다. VXLAN 을 건너는 쪽이 더 빠를 수도 있는 수준의 차이이고, 그래서 뒤에 나올 28배가 의심의 여지 없이 주입 탓이 된다. 가이드는 여기에 단서를 하나 붙인다 — 횟수를 주입 전후로 똑같이 맞춘다. 앞의 측정은 20회로 쟀는데 해설 문서의 재현 절차에는 15회로 적혀 있다. 횟수가 다르면 평균도 달라지므로, 비교할 두 값은 같은 명령으로 만든다.

커넥션 풀 지표에 무엇이 있는지도 미리 본다.

kubectl -n keycloak-lab exec a6-probe -- sh -c \
  'curl -s "http://$K1:9000/metrics" | grep "^agroal_"'

실측(observed) — 01-baseline.txt

agroal_acquire_count_total
agroal_active_count
agroal_available_count
agroal_awaiting_count
agroal_blocking_time_average_milliseconds
agroal_blocking_time_max_milliseconds
agroal_blocking_time_total_milliseconds
agroal_creation_count_total
agroal_creation_time_average_milliseconds
agroal_creation_time_max_milliseconds
agroal_creation_time_total_milliseconds
agroal_destroy_count_total

agroal_*JDBC 커넥션 풀 지표다(Agroal 은 Quarkus 의 풀 구현이다). 이 실험의 핵심 증거가 여기서 나온다. 가이드는 당신 출력은 이보다 길 것이라고 적는다 — 위 목록은 알파벳순으로 destroy_count_total 에서 끊겨 있고, 원 실행이 앞부분만 남겼다. 실제로는 뒤에 agroal_max_used_count 같은 것이 더 있고 뒤에서 그 값을 쓴다. 증거 파일이 짧다고 지표가 없는 것이 아니다.

★ 같은 줄을 2026-09-17 에 쳤더니 모양이 달랐다(observed). grep 은 이름만 남기지 않는다. 지표 한 줄을 통째로 내보내므로 레이블과 값이 함께 나오고, 순서도 알파벳순이 아니다. 열일곱 줄이 나왔고 그 안에 agroal_max_used_count 가 이미 있었다.

agroal_invalid_count_total{datasource="default"} 0.0
agroal_flush_count_total{datasource="default"} 0.0
agroal_leak_detection_count_total{datasource="default"} 0.0
agroal_reap_count_total{datasource="default"} 3.0
agroal_blocking_time_max_milliseconds{datasource="default"} 112.0
agroal_max_used_count{datasource="default"} 3.0
agroal_acquire_count_total{datasource="default"} 1908.0
agroal_active_count{datasource="default"} 0.0
agroal_awaiting_count{datasource="default"} 0.0
agroal_blocking_time_average_milliseconds{datasource="default"} 0.0
agroal_creation_time_total_milliseconds{datasource="default"} 160.0
agroal_creation_time_average_milliseconds{datasource="default"} 32.0
agroal_destroy_count_total{datasource="default"} 3.0
agroal_available_count{datasource="default"} 2.0
agroal_creation_count_total{datasource="default"} 5.0
agroal_creation_time_max_milliseconds{datasource="default"} 105.0
agroal_blocking_time_total_milliseconds{datasource="default"} 285.0

이름만 늘어놓고 보려면 | awk '{print $1}' | sort 를 붙인다. 위의 열두 줄이 이름뿐인 것은 원 실행이 그렇게 다듬어 남겼기 때문이다(inferred).

지표 무엇을 말하는가
blocking_time_max 커넥션을 받으려고 가장 오래 기다린 시간
max_used_count 풀이 최대 몇 개까지 늘었나
awaiting_count 지금 줄 서 있는 요청 수
active_count 지금 쓰이고 있는 커넥션 수

awaiting_countactive_count 는 순간값이다. 부하가 끝나면 0 으로 돌아가므로 부하 중에 읽어야 보인다. blocking_time_max 는 누적이라 나중에 읽어도 남아 있다. 지금 값을 적어 둔다.

주입

주입은 세 번이고 앞의 둘은 일부러 실패한다.

시도 ① eth0. 인터넷 예제가 전부 쓰는 이름이다.

ssh kc-lab-2 'sudo tc qdisc add dev eth0 root handle 1: prio'

실측(observed) — 02-delay-injected.txt

Cannot find device "eth0"

한 줄이면 끝날 일이다. 그런데 원 실행은 이걸 스크립트로 돌렸다.

실측(observed) — 같은 파일, 원문 그대로

=== 주입: postgres(10.42.1.76) 가 보내는 패킷만 200ms 지연 (kc-lab-2 eth0) ===
  prio qdisc 로 밴드를 나누고, u32 필터로 출발지 IP 가 postgres 인 것만 3번 밴드로 보낸다
Cannot find device "eth0"
Cannot find device "eth0"
적용완료
Cannot find device "eth0"
Cannot find device "eth0"
  주입: 13:14:55

적용완료 가 에러 사이에 끼어 있다. 「적용완료」는 스크립트가 찍은 글자이지 커널이 한 말이 아니다. tc 는 네 번 다 실패했는데 스크립트는 그대로 다음 절로 넘어갔고 문서에는 시각까지 찍혔다. 명령의 성공을 「에러가 안 보인다」로 판정하면 안 된다. 손으로 한 줄씩 치면 이 실수를 할 수 없고, 가이드에 스크립트가 없는 까닭이 그것이다.

인터페이스 이름을 확인한다.

ssh kc-lab-2 'ip -brief link'

실측(observed) — 03-flannel-injection.txt

flannel.1        UNKNOWN        a6:b2:62:04:c1:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
cni0             UP             5a:77:1a:e2:b0:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>

게스트의 물리 인터페이스는 enp1s0 이다. eth0 이 없다.

이름 무엇
enp1s0 게스트의 물리(가상) NIC. 노드 간 실제 트래픽이 나가는 곳
flannel.1 VXLAN 터널. 노드를 건너는 파드 트래픽이 여기로 들어간다
cni0 노드 안 브리지. 같은 노드 파드끼리는 여기서 끝난다

Debian 클라우드 이미지는 예측 가능한 인터페이스 이름을 쓴다.

   enp1s0
   │ │  └─ s0 : slot 0
   │ └──── p1 : PCI bus 1
   └────── en : ethernet

이름이 하드웨어 위치에서 나오므로 NIC 순서가 바뀌어도 이름이 안 바뀐다. 그 대신 eth0 이라고 적힌 인터넷의 모든 예제가 안 돈다. flannel.1 의 상태가 UNKNOWN 인 것은 정상이다 — 터널 장치는 캐리어 개념이 없어서 UP 대신 UNKNOWN 으로 보고한다.

시도 ② enp1s0. 이름만 고치면 될 것 같지만 안 된다. 노드 간 파드 통신은 flannel VXLAN 으로 캡슐화된다.

   원래 패킷:   src=10.42.1.76(postgres)  dst=10.42.0.42(keycloak-1)
        │
        ▼  flannel.1 에서 캡슐화
   실제 패킷:   src=192.168.122.12(노드)   dst=192.168.122.11(노드)  UDP 8472
                └─ 안쪽에 원래 패킷이 통째로 들어 있다
        │
        ▼
   enp1s0 로 나간다

enp1s0 에서 match ip src 10.42.1.76 은 절대 일치하지 않는다. 그 IP 는 페이로드 안에 있고 헤더에는 노드 IP 만 있다. 눈으로 확인하는 두 줄을 가이드는 미검증으로 표시했다(unknown).

ssh kc-lab-2 'sudo tcpdump -i enp1s0 -n -c 5 udp port 8472'
ssh kc-lab-2 'sudo tcpdump -i flannel.1 -n -c 5 host 10.42.1.76'

앞쪽에서는 노드 IP 사이의 UDP 8472 만 보이고 10.42.x.x 는 안 보인다. 뒤쪽 터널에서는 파드 IP 가 보인다. 원 실행에는 이 확인이 없다. eth0 실패 뒤 곧바로 flannel.1 로 갔으므로 「enp1s0 에 걸면 0 패킷」이라는 출력 원문은 이 실험에 없고, 구조에서 나온 결론이다.

인터페이스 파드 IP 가 보이나 무엇을 지연시키게 되나
cni0 보인다 같은 노드 안 통신만
flannel.1 보인다 (캡슐화 직전) 노드를 건너는 파드 통신
enp1s0 안 보인다 노드 간 모든 것 (SSH·k3s 포함)

enp1s0netem 을 root 로 걸면 kubectl 도 SSH 도 같이 느려진다. 그러면 무엇이 원인인지 못 가린다.

성공한 주입은 flannel.1 이다. 한 줄씩 친다 — 앞 줄이 실패하면 뒤 줄은 붙을 곳이 없어서 다른 에러를 낸다.

ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 root handle 1: prio"
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 parent 1:3 handle 30: netem delay 200ms"
ssh kc-lab-2 "sudo tc filter add dev flannel.1 protocol ip parent 1:0 prio 3 \
     u32 match ip src $PG/32 flowid 1:3"
date '+%H:%M:%S 주입'

세 줄이 나뉘어 있는 까닭은 tc 의 계층 구조다.

   qdisc (큐 규율)     인터페이스에 붙는 패킷 스케줄러
     ├─ prio           우선순위 밴드 3개로 나눈다
     │    ├─ 1:1 (기본)
     │    ├─ 1:2 (기본)
     │    └─ 1:3  ← 여기에 netem 을 붙인다
     └─ filter         어떤 패킷을 어느 밴드로 보낼지
하는 일
qdisc ... root handle 1: prio 밴드 3개짜리 분류기를 만든다
qdisc ... parent 1:3 handle 30: netem delay 200ms 3번 밴드에 200ms 지연을 붙인다
filter ... match ip src $PG/32 flowid 1:3 출발지가 postgres 인 패킷을 3번 밴드로 보낸다

netem 을 root 에 바로 붙이면 모든 트래픽이 느려진다. prio + filter 를 쓰면 고른 트래픽만 느려지고, 이 실험은 postgres 가 보내는 것만 골라야 하므로 세 단계가 필요하다.

주입 검증

시도 ① 은 에러를 냈는데도 그대로 넘어갔고, 그 상태에서 잰 「검증」이 이랬다.

실측(observed) — 02-delay-injected.txt

=== [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 ===
  keycloak-0  평균 43 ms   최대 64 ms
  keycloak-1  평균 47 ms   최대 70 ms

두 노드가 여전히 같다. 이것이 「안 걸렸다」는 신호였다. 검증 절이 값을 찍기만 하고 판정하지 않으면 이렇게 그냥 지나간다.

성공한 주입 뒤에는 카운터를 본다.

ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1'

실측(observed) — 03-flannel-injection.txt, 넣은 직후

qdisc prio 1: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
 Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
 backlog 0b 0p requeues 0
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
 Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
 backlog 0b 0p requeues 0

Sent 0 pkt 이다. 그런데 이건 실패가 아니다. A-5 에서 pkts 0 은 「규칙이 안 걸렸다」였다. 여기서는 다르다 — 아직 아무 패킷도 지나가지 않았을 뿐이다. postgres 는 요청이 있어야 답한다. 트래픽을 한 번 만든다.

kubectl -n keycloak-lab exec a6-probe -- sh -c '
  curl -s -o /dev/null -w "%{time_total}\n" \
    -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
    -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'

실측(observed) — 같은 파일

=== [검증] 필터에 패킷이 걸리는가 ===
  qdisc netem 30: parent 1:3 limit 1000 delay 200ms
   Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)
   backlog 0b 0p requeues 0

150 pkt. 실제로 지연 밴드를 통과했다. 가이드의 판정표를 그대로 옮긴다.

상태 할 일
부하 전 0 pkt 아직 트래픽이 없다 요청을 한 번 보내고 다시 센다
부하 후에도 0 pkt 필터가 아무것도 못 잡았다 IP·인터페이스·방향을 다시 본다
pkt 이 는다 걸렸다 관찰로 넘어간다
dropped 가 는다 limit 1000 을 넘겼다 부하를 줄이거나 limit 을 올린다

A-1·A-5 와 같은 교훈이 세 번째로 나왔다. 주입을 넣은 것과 걸린 것은 다르다. 필터 자체를 보는 줄은 가이드가 미검증으로 표시했다(unknown).

ssh kc-lab-2 'sudo tc filter show dev flannel.1'

★ 2026-09-17 에 그 줄을 쳐서 이렇게 나왔다(observed).

filter parent 1: protocol ip pref 3 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 *flowid 1:3 not_in_hw
  match 0a2a011d/ffffffff at 12

0a2a011d10.42.1.29 를 16진수로 적은 것이고, 그 값이 지금 postgres 의 IP 와 같은지가 필터가 제 대상을 고르고 있다는 증거다. 같은 날 netem 카운터는 83 pkt 이었고 로그인 한 번이 0.067s 에서 1.87s 로 늘었다 — 문서가 적은 1,872ms 와 28배가 그대로였다(observed).

관찰

단일 요청부터 본다. 앞에서 친 것과 똑같은 명령이다.

kubectl -n keycloak-lab exec a6-probe -- sh -c '
  curl -s -o /dev/null \
    -w "connect %{time_connect}  ttfb %{time_starttransfer}  total %{time_total}\n" \
    -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
    -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'

connect 는 그대로인데 ttfb 만 폭발하면 지연이 의도한 구간에 걸린 것이다. 그 다음 20회 반복으로 두 노드를 잰다.

실측(observed) — 03-flannel-injection.txt

=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
  keycloak-0  평균 41 ms   최대 57 ms
  keycloak-1  평균 1872 ms   최대 1887 ms

keycloak-166 → 1,872 ms, 28배. 그리고 ★ 대조군도 변했다. keycloak-0 은 70ms 에서 41ms 로 41% 빨라졌다. 주입과 무관한 변동(JIT 워밍업, 캐시)이며, 해설 문서가 처음에 「영향 없음」이라고 쓴 것은 부정확했다. 자릿수가 달라 결론은 유지되지만 대조군이 안 변한다고 가정하면 안 된다.

왜 200ms 가 1,872ms 가 되는가는 A-0 에서 잡은 로그인 트랜잭션의 SQL 이 답한다.

BEGIN
select ... from OFFLINE_USER_SESSION ...
select VERSION ... for no key update skip locked
select ... from OFFLINE_CLIENT_SESSION ...
select VERSION ... for no key update skip locked
insert into OFFLINE_USER_SESSION ...
insert into OFFLINE_CLIENT_SESSION ...
SET LOCAL synchronous_commit TO OFF
COMMIT

왕복이 아홉 번이다.

   200 ms × 9 왕복 ≈ 1,800 ms      실측 1,872 ms

9 는 SQL 목록을 센 것이고 패킷을 추적한 값이 아니다. 자릿수가 맞는다는 것까지가 이 계산이 말할 수 있는 범위이며, 왕복 수를 확정하려면 tc -s 의 패킷 수를 요청 수로 나누거나 패킷 캡처가 필요하다. 그래도 네트워크 지연이 왕복 횟수만큼 증폭된다는 것까지는 이 측정이 뒷받침한다. 「DB 가 200ms 느려졌다」는 「애플리케이션이 200ms 느려졌다」가 아니고, 쿼리 수를 줄이는 것이 지연 환경에서 결정적인 까닭이 여기 있다.

동시 부하가 이 실험의 본 시험이다. 순차로 20번 돌리면 큐잉이 재현되지 않는다. 백그라운드로 띄우고 wait 하며, 결과는 파드 안 파일에 모은다.

kubectl -n keycloak-lab exec a6-probe -- sh -c '
  rm -f /tmp/load ; i=0
  while [ $i -lt 20 ]; do
    ( curl -s -o /dev/null -w "%{http_code} %{time_total}\n" --max-time 60 \
        -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
        -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
      >> /tmp/load ) &
    i=$((i+1))
  done
  wait'

다 모였는지부터 센다.

kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt
wc -l /tmp/load.txt

20 이 아니면 수집이 샌 것이다. 그 상태의 숫자는 해석하지 않는다. 원본을 보고 나서 상태 코드와 시간을 나눠 본다.

cat /tmp/load.txt
awk '{print $1}' /tmp/load.txt | sort | uniq -c
awk '{print $2}' /tmp/load.txt | sort -g

실측(observed) — 04-pool-under-load.txt

=== 동시 부하 20건을 keycloak-1 에 — 커넥션 풀이 견디는가 ===
        1 200 1.911191
        1 200 1.913766
        1 200 1.958374
        1 200 1.981620
        1 200 10.539402
        1 200 11.951943
        1 200 13.351102
        1 200 14.785832
        1 200 16.189533
        1 200 17.625166
        1 200 19.053724
        1 200 20.495883
        1 200 21.905932
        1 200 22.228466
        1 200 22.230871
        1 200 3.441366
        1 200 4.841075
        1 200 6.257489
        1 200 7.704608
        1 200 9.104792

두 가지를 본다. ① 순서가 이상하다. 10.53.4 보다 앞에 있다. 원 실행이 sort사전순으로 썼기 때문이다(맨 앞의 1uniq -c 가 붙인 개수다). 문자열로 정렬하면 "10.5" < "3.4" 다.

sort  /tmp/load.txt      # 사전순 — 10.5 가 3.4 앞에 온다
sort -g /tmp/load.txt    # 수치순 — 이걸 써야 한다

그런데 원 가이드의 이 처방은 이 파일에서 안 듣는다 (2026-09-17 에 같은 모양의 줄로 직접 돌려 확인했다). /tmp/load.txt 의 각 줄이 200 1.911191 처럼 전부 같은 200 으로 시작해서, sort -g 는 줄 앞머리 수치를 비교하다 전부 동률이 되고 마지막에 바이트 순으로 떨어진다. 그래서 sort 와 출력이 똑같다.

정렬할 열을 짚어 주어야 듣는다.

sort -g -k2 /tmp/load.txt

이 부에 이미 있는 형태를 쓰는 편이 낫다 — 위 §6-2 의 awk '{print $2}' /tmp/load.txt | sort -gawk 가 둘째 열만 남기므로 제대로 돈다. 최대값 하나만 볼 때는 거기에 | tail -1 을 붙인다.

시간 값을 정렬할 때는 열을 짚는다. 이걸 놓치면 「최대값」을 잘못 읽고, sort -g 를 붙였다는 것만으로 고쳤다고 믿게 된다.

② 숫자를 순서대로 놓으면 계단이다.

   1.9 → 3.4 → 4.8 → 6.2 → 7.7 → 9.1 → 10.5 → ... → 22.2
        ────  ────  ────  ────
        약 1.4초 간격 — 앞 요청이 커넥션을 놓아줄 때까지 줄을 선다

전부 성공(200)했지만 응답 시간이 1.9초에서 22.2초까지 늘어난다. 커넥션 수는 유한하고 각 요청이 커넥션을 1.9초씩 붙잡으므로 뒤에 온 요청은 그만큼 기다린다. 200 만 보는 감시는 이 장애를 못 본다.

커넥션 풀 지표는 부하가 끝나자마자 읽는다. 늦으면 순간값이 0 으로 돌아간다.

kubectl -n keycloak-lab exec a6-probe -- sh -c \
  'curl -s "http://$K1:9000/metrics" | grep -E "^agroal_(blocking_time|max_used|acquire|active|available|awaiting)"'

실측(observed) — 04-pool-under-load.txt

=== 부하 직후 커넥션 풀 ===
  agroal_blocking_time_max_milliseconds 20000.0
  agroal_max_used_count 19.0
  agroal_acquire_count_total 672.0
  agroal_active_count 0.0
  agroal_awaiting_count 0.0
  agroal_blocking_time_average_milliseconds 281.0
  agroal_available_count 19.0

★ 이 정규식은 blocking_time_total 도 함께 잡는다(2026-09-17, observed). 같은 줄을 쳤더니 여덟 줄이 나왔다. 위 일곱 줄에 agroal_blocking_time_total_milliseconds 가 하나 더 붙고, 지표마다 {datasource="default"} 가 달려 있다.

agroal_blocking_time_max_milliseconds{datasource="default"} 20000.0
agroal_max_used_count{datasource="default"} 18.0
agroal_acquire_count_total{datasource="default"} 2020.0
agroal_active_count{datasource="default"} 0.0
agroal_awaiting_count{datasource="default"} 0.0
agroal_blocking_time_average_milliseconds{datasource="default"} 114.0
agroal_available_count{datasource="default"} 18.0
agroal_blocking_time_total_milliseconds{datasource="default"} 230680.0

blocking_time_max 는 여기서도 20000.0 이었다.

읽는 법
blocking_time_max 20000.0 커넥션을 받으려고 20초를 기다린 요청이 있었다
max_used_count 19.0 풀이 19개까지 늘어났다
blocking_time_average 281.0 평균은 0.3초. 평균만 보면 아무 일도 없어 보인다
active_count 0.0 · awaiting_count 0.0 순간값. 부하가 끝나서 0 이다

평균과 최대의 간격이 이 장애의 모양이다. 평균 281ms 짜리 그래프에서는 아무도 20초를 보지 못한다. 같은 것을 그림으로 본 화면이 증거에 있다 — a6-connection-pool-blocking.png.

그리고 헬스체크가 무너진다.

kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
kubectl -n keycloak-lab get pods

실측(observed) — 04-pool-under-load.txt

keycloak-0                  1/1   Running   0             60m
keycloak-1                  1/1   Running   1 (51m ago)   3h24m
52m         Normal    TaintManagerEviction   pod/keycloak-1                   Cancelling deletion of Pod keycloak-lab/keycloak-1
32m         Warning   Unhealthy              pod/keycloak-1                   Readiness probe failed: HTTP probe failed with statuscode: 503
89s         Warning   Unhealthy              pod/keycloak-1                   Readiness probe failed: Get "http://10.42.0.42:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

89s 짜리 줄이 지금 주입의 결과다. 32m·52m 짜리는 A-4 의 잔재다 (노드를 껐다 켠 흔적). 이벤트를 볼 때는 Age 를 먼저 본다 — 목록에 한 시간 전 것까지 섞여 있다.

두 실패의 차이가 중요하다.

메시지 무슨 일
HTTP probe failed with statuscode: 503 Keycloak 이 답은 했다. 스스로 DOWN 이라고 말했다
context deadline exceeded 답 자체를 못 했다. 프로브가 줄에서 기다리다 끝났다

readiness 프로브 자체가 타임아웃됐다. 헬스체크도 같은 커넥션 풀 줄에 선다. 그래서 연쇄가 이렇게 된다.

   DB 가 느려진다
        ↓
   요청이 커넥션을 오래 붙잡는다
        ↓
   커넥션 풀이 고갈된다
        ↓
   새 요청이 줄을 선다 (최대 20초)
        ↓
   헬스체크도 줄에 선다 → 타임아웃 → NotReady
        ↓
   그 노드가 로드밸런서에서 빠진다
        ↓
   ★ 남은 노드로 트래픽이 몰린다 → 그 노드도 같은 길을 간다

마지막 화살표가 무서운 부분이다. 느려짐은 전파된다. A-2(DB 완전 정지)는 즉시 503 으로 드러나 오히려 명확했지만, 느려짐은 살아 있는 노드를 하나씩 무너뜨린다.

빗나간 예측도 하나 남았다. 계획서에는 이렇게 적혀 있었다.

낙관적 락 충돌 증가 — 트랜잭션이 길어져 VERSION 충돌이 늘어야 한다

지연 구간의 로그를 세는 줄을 가이드는 미검증으로 표시했다(unknown). 원 실행의 정확한 패턴이 기록에 없다.

kubectl -n keycloak-lab logs keycloak-1 --since=20m \
  | grep -icE 'optimistic|StaleState|version.*conflict'

실측(observed) — 05-recovery.txt

=== 낙관적 락 충돌이 늘었는가 — 지연 중 로그 ===
  관련 로그 줄수: 0

★ 그 줄을 2026-09-17 에 쳤다(observed). 출력은 0 이고 종료 코드는 1 이다 — grep -c 는 센 값이 0 이면 1 로 끝난다. 뒤에 && 로 다른 명령을 이어 붙이면 그 명령이 안 돈다.

하나도 없었다. 까닭이 명확하다.

   로그인   →  매번 새 세션 행을 INSERT      →  다툴 상대가 없다
   refresh  →  같은 세션 행을 UPDATE         →  여기서 다툰다

충돌은 같은 행을 동시에 고칠 때만 일어난다. 로그인 부하로는 재현되지 않는다. B-3(refresh 토큰 경쟁)의 영역이고, 거기서 지연을 함께 주면 충돌률이 올라갈 것이라고 가이드는 적는다. 예측을 적어 두지 않았다면 「충돌이 없네」 하고 넘어갔을 것이다.

복구와 원상복구 확인표

date '+%H:%M:%S 해제'
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'

실측(observed) — 05-recovery.txt

=== 지연 해제 ===
해제완료
qdisc noqueue 0: root refcnt 2

noqueue. prionetem 도 없다. root 를 지우면 그 아래 자식 qdisc 와 filter 가 같이 사라진다.

회복은 20회 반복 측정 명령을 그대로 다시 쳐서 본다. 그 명령의 첫 줄이 rm -f /tmp/base-k1 이므로 파일은 새로 만들어진다. 두 노드 다 잰다. 같은 명령이어야 비교가 된다.

실측(observed) — 같은 파일

=== 회복 확인 ===
  keycloak-0  평균 43 ms
  keycloak-1  평균 51 ms
keycloak-0                  1/1   Running   0             61m
keycloak-1                  1/1   Running   1 (52m ago)   3h24m

파드 재시작 없이 즉시 회복. RESTARTS 가 안 늘었다 — 이 실험은 readiness 를 흔들었을 뿐 파드를 죽이지는 않았다. 커넥션 풀도 스스로 정상화됐다.

항목 명령 돌아왔을 때
qdisc ssh kc-lab-2 'sudo tc qdisc show dev flannel.1' noqueue
(물리 쪽도) ssh kc-lab-2 'sudo tc qdisc show dev enp1s0' 시도 ① 잔재가 없어야 한다
응답 시간 20회 반복 측정 주입 전과 같은 자릿수
파드 kubectl -n keycloak-lab get pods 둘 다 1/1 Running
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
agroal_awaiting_count · agroal_active_count 0
탐침 파드 kubectl -n keycloak-lab get pod a6-probe 지웠으면 NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found

★ 이 표의 두 줄은 이 실험대에서 답이 달랐다(2026-09-17, observed).

enp1s0 쪽은 비어 있지 않다. 이 게스트의 기본 qdisc 가 fq_codel 이라 아무것도 안 걸었을 때도 한 줄이 나온다. 잔재가 없다는 것은 그 줄에 netem 이나 prio 가 안 보인다는 뜻이다.

qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64

마지막 줄의 200 은 이 실험대에서 안 나온다. auth.hyeonworks.com 이 lab host 에서 100.83.212.4 로 풀리고 그 주소의 443 이 닫혀 있어, 같은 줄이 000 을 찍고 curl7 로 끝난다. kc-lab-edge 에서 쳐도 같은 값이다.

curl: (7) Failed to connect to auth.hyeonworks.com:443 after 3 ms: Could not connect to server
000

이름 대신 엣지 주소를 짚어 주면 200 이 온다. 그래서 이 줄이 000 인 것은 Keycloak 이 아니라 이름이 가리키는 곳의 문제이고, 원상복구 판정에는 쓸 수 없다.

curl -s -o /dev/null -w '%{http_code}\n' --max-time 20 \
  --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master

agroal_blocking_time_max_milliseconds누적이라 20000 인 채로 남는다. 파드를 재시작해야 0 이 되고, 가이드는 그대로 두는 편이 낫다고 적는다 — 「이 노드가 한 번 20초를 기다린 적이 있다」는 기록이다.

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
Cannot find device "eth0" 이 게스트의 NICenp1s0 이다 ip -brief link
스크립트가 「적용완료」인데 지연이 없다 성공 메시지는 스크립트가 찍은 것 tc -s qdisc 카운터
enp1s0 에 걸었는데 안 걸린다 VXLAN 안에 파드 IP 가 숨어 있다 flannel.1 에 건다
Sent 0 pkt 부하 이면 정상. 부하 면 필터가 틀렸다 요청 한 번 보내고 다시 센다
지연이 양쪽 다 늘었다 netemroot 에 직접 붙였다 prio + filter 로 골라 낸다
kubectl 이나 SSH 까지 느려졌다 enp1s0 에 걸었다 tc qdisc del dev enp1s0 root
20줄 중 몇 줄만 온다 kubectl run --rm -i 로 동시 실행하면 stdout 이 샌다 상주 파드 + 파일
최대값이 9.1 로 보인다 sort사전순이고, sort -g 만 붙여도 줄이 전부 200 으로 시작해 안 듣는다 sort -g -k2, 또는 §6-2 의 awk '{print $2}' … | sort -g
blocking_time 이 0 이다 부하가 끝나고 한참 뒤에 읽었다 부하 직후에 읽는다
awaiting_count 가 늘 0 이다 순간값이다 부하가 도는 중에 읽는다
로그인이 전부 401 PW 가 안 넘어갔다 exec a6-probe -- sh -c 'echo ${#PW}'
갑자기 아무 데도 안 닿는다 파드 IP 가 바뀌었다 탐침을 지우고 다시 만든다
이벤트가 과거 것과 섞인다 이벤트는 한 시간 전 것도 남는다 Age 를 먼저 본다
대조군도 값이 변했다 정상이다. JIT·캐시 변동 자릿수로 판정한다
dropped 가 늘어난다 netemlimit 1000 을 넘겼다 부하를 줄이거나 limit 을 올린다

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

  • (observed) 파드와 postgres 의 노드 배치, 주입 전 평균 70 ms/66 ms, Cannot find device "eth0" 네 줄 사이에 낀 적용완료 와 주입 시각 13:14:55, 그 상태의 「검증」 값 43 ms/47 ms, ip -brief linkflannel.1·cni0, 넣은 직후의 Sent 0 bytes 0 pkt 와 부하 뒤의 Sent 18388 bytes 150 pkt, 주입 뒤 41 ms/1872 ms, 동시 20건의 스무 줄 전부와 22.230871 까지의 계단, blocking_time_max 20000.0 · max_used_count 19.0 · acquire_count_total 672.0 · blocking_time_average 281.0, 이벤트 세 줄과 89s/32m/52m, 낙관적 락 로그 0, 해제 뒤 noqueue43 ms/51 ms, agroal_* 지표 이름 열두 개.
  • (unknown) ssh kc-lab-2 로 들어가 원격 셸에서 tc 를 치는 두 단계 형태. 이 실험대에서 치지 않았다. 가이드가 미검증으로 표시한 넷 — enp1s0flannel.1 에 각각 거는 tcpdump 두 줄과 tc filter show 와 낙관적 락 로그를 세는 grep -icE — 은 2026-09-17 에 전부 쳤다.
  • (observed, 2026-09-17) 관찰 다섯 절과 복구를 다시 밟은 값 — 주입 전 44 ms/40 ms, 주입 뒤 1865 ms/40 ms, connect 0.000552 ttfb 1.864861, 동시 20건이 1.871238 에서 22.245645 까지 스무 줄 전부 200, blocking_time_max 20000.0max_used_count 18.0, readiness 프로브가 context deadline exceeded 로 한 줄, 낙관적 락 로그 0 과 종료 코드 1, 해제 뒤 noqueue42 ms/39 ms, 엔드포인트 주소 둘. 원문은 relive-2026-09-17/a6-05..07.
  • 구조에서 나온 결론이고 출력이 없는 것 — 「enp1s0 에 걸면 0 패킷」. 원 실행은 eth0 실패 뒤 곧바로 flannel.1 로 갔다.
  • 센 것이고 잰 것이 아닌 것 — 왕복 9 는 A-0 이 잡은 SQL 목록을 센 값이고 패킷을 추적한 값이 아니다. 200 ms × 9 ≈ 1,800 ms 와 실측 1,872 ms 의 자릿수가 맞는다는 것까지가 이 계산의 범위다.
  • 증거 파일이 잘려 있는 것agroal_* 목록이 알파벳순으로 destroy_count_total 에서 끊겨 있다. 뒤에 쓰는 agroal_max_used_count 는 그 목록에 안 보이지만 부하 뒤 출력에는 있다.
  • 처음 쓴 것이 부정확했던 곳 — 해설 문서의 「대조군 영향 없음」. 대조군은 70 ms 에서 41 ms 로 41% 빨라졌다.
  • 이 실험이 재지 않은 것 — 응답 시간 분포. 관측 스택에 히스토그램 지표가 없어 평균 281ms 와 최대 20,000ms 사이에 무엇이 있었는지는 모른다. 낙관적 락 충돌도 로그인 부하로는 재현되지 않아 B-3 으로 넘겼다.

해설 문서는 이 연쇄에서 구성 규칙 두 줄을 끌어냈다 (experiment-a6-latency-injection.md 「7. 운영에 주는 것」).

알게 된 것 함의
커넥션 풀에서 한 번 더 곱해진다 풀 크기와 타임아웃이 장애 반경을 정한다
헬스체크도 줄에 선다 프로브 타임아웃이 풀 대기보다 짧아야 격리가 제때 된다

둘째 줄이 이 실험에서 실제로 일어난 일이다 — agroal_blocking_time_max 가 20,000ms 까지 올라간 동안 readiness 프로브가 같은 줄에 서서 타임아웃했다.

가이드는 있었으면 좋았을 쿼리를 그대로 남겨 두었다.

# 있으면 좋았을 것
histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m]))

A-7 — 옛 기본값으로 되돌리면 A층 결론이 어디까지 뒤집히는가

근거: a7-volatile-comparison.md (1072줄). 수집 기록은 2026-09-04 13:2213:32 KST(observed).

이 실험이 가르는 것

A층은 여섯 개의 결론을 냈고, 그 여섯이 전부 하나의 전제 위에 있다.

   Keycloak 26 은 persistent-user-sessions 가 기본으로 켜져 있다
        │
        ├─ A-0  세션은 PostgreSQL 에 있다
        ├─ A-1  7800 을 끊어도 세션 공유가 안 깨진다
        ├─ A-2  DB 를 내리면 로그인이 실패한다
        └─ A-8  롤링 재시작을 해도 세션이 산다

전제를 뒤집으면 결론도 뒤집히는지를 잰다.

A-1 이 본 것 인터넷 자료가 말하는 것
7800 차단 세션 공유가 안 깨진다 세션 공유가 깨진다

A-1 은 통념과 어긋난 결과를 냈고 그 까닭을 「26 이 기본값을 바꿨기 때문」이라고 설명했다. 그 설명이 맞는지는 옛 기본값으로 되돌려 같은 실험을 다시 해 봐야 판정된다. 자료가 틀린 게 아니라 버전이 다른 것이라면, 옛 설정에서는 통념이 맞아야 한다.

   persistent (KC 25+, 26 기본)      volatile (KC 24 이전)
     로그인 ─▶ PostgreSQL (진실)       로그인 ─▶ Infinispan (진실)
     조회   ─▶ 캐시 없으면 DB          조회   ─▶ 클러스터에서 찾는다
     공유   ─▶ 같은 DB 를 본다         공유   ─▶ 7800 을 통한 복제

설정 한 줄로 왼쪽에서 오른쪽으로 간다. 가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 로그인했는데 DB 세션 테이블이 0건인 상태를 PostgreSQL OFFLINE_USER_SESSION 에서, 그런데도 교차 노드 refresh 가 200 인 것을 탐침 파드에서, 롤링 재시작 한 번에 전원 로그아웃되는 것을 재시작 전 토큰의 400 에서, 7800 을 끊으면 이번에는 세션 공유가 깨지는 것iptables -t raw 와 교차 노드 400 에서, DB 를 내렸는데 새 로그인이 되는 것scale deployment/postgres --replicas=0 에서, 같은 명령이 A-1·A-8 과 정반대 답을 내는 것을 그 넷 전부에서.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • A-1 · A-2 · A-8 을 먼저 해 두면 좋다. 이 실험은 그 셋의 대조군이고, 먼저 잰 값을 몸으로 알고 있어야 「뒤집혔다」가 보인다.
  • kc-lab-2 에는 ssh kc-lab-2 로 붙는다. iptables 는 두 노드에 각각 넣는다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 관찰용, 하나는 대기용.

이건 클러스터의 동작 모드를 바꾸는 실험이다. 가이드의 경고를 그대로 옮긴다 — persistent-user-sessions 를 끈다. 전환하는 순간 기존 세션이 전부 사라지고, 되돌릴 때 또 한 번 사라진다. 빌드 옵션이라 기동 시 재빌드가 일어나 롤아웃이 평소보다 오래 걸린다(--timeout=500s 를 주는 까닭이다). 실험대에서만 한다. 전 구간 약 40~60분이다.

★ 원복을 잊으면 이후 실험이 전부 오염된다. A-0 부터 A-6 까지의 결론은 전부 「persistent 기본값」 조건이다. volatile 로 둔 채 다른 실험을 하면 그 실험이 무엇을 재고 있는지 아무도 모른다.

중간에 그만두려면 복구 절의 두 개면 된다. args 되돌리기는 이렇다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

iptables 쪽은 두 줄이고, 둘째 줄이 SSH 접속과 원격 셸의 인용을 한 줄에 겹친다.

이 실험대는 이렇게 했다(observed)

sudo iptables -t raw -F PREROUTING
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'

따라 하는 사람은 반대 노드 쪽을 나눌 수 있다. 먼저 붙고, 원격 셸에서 친다. 이 두 단계 형태는 이 실험대에서 치지 않았다(unknown).

ssh kc-lab-2
sudo iptables -t raw -F PREROUTING
exit

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

전환 후에 볼 것을 전환 전에 똑같은 명령으로 먼저 봐 둔다.

노드 → 파드 → 지금 args → DB 세션 행 → 대조군 시험 → 이 버전에서 끌 수 있는가
kubectl get nodes
kubectl -n keycloak-lab get pods -o wide

모양은 이렇고 값은 환경마다 다르다(observed).

NAME                        READY  STATUS   RESTARTS  AGE  IP           NODE
keycloak-0                  1/1    Running  0         2d   10.42.1.94   kc-lab-2
keycloak-1                  1/1    Running  0         2d   10.42.0.45   kc-lab-1
postgres-7b474b88c8-t6rrf   1/1    Running  0         5d   10.42.0.22   kc-lab-1

READY 가 둘 다 1/1, RESTARTS0, NODE 가 서로 다르다 — 같은 노드면 뒤의 노드 간 차단이 성립하지 않는다. 그리고 파드 번호와 노드 번호가 어긋난다. keycloak-0kc-lab-2 에 있다.

IP 는 변수로 잡아 두되 이 실험은 롤아웃을 세 번 하므로 세 번 다시 잡는다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

실측(observed) — 02-a0-rerun.txt

10.42.1.94 10.42.0.45

지금 args 가 되돌릴 값이다.

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

실측(observed) — 06-restore-persistent.txt

["start"]

플래그가 없다. 기능 플래그를 아무것도 주지 않았으므로 26 의 기본값으로 돌고 있고 persistent-user-sessions 가 켜져 있다. 이 문자열을 적어 둔다.

DB 에 세션 행이 있는 것이 persistent 의 증거다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

모양은 이렇고 숫자는 환경마다 다르다(observed).

 offline_flag | count
--------------+-------
 0            |   151

offline_flag = '0' 이 온라인 세션이고 '1' 은 offline token 이라 이 실험과 무관하다. 전환 후 같은 질의가 (0 rows) 를 내놓는지가 첫 판정이다. 관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다 — 여기서 중요한 것은 0 이 아니라는 점뿐이다.

대조군으로 교차 노드 refresh 가 지금은 되는 것을 본다. 이 관측을 건너뛰면 뒤의 400 이 아무 의미가 없다. Keycloak 컨테이너에는 curlwget 도 없으므로(exit 127) 탐침 파드를 띄우고 실험 내내 살려 둔다 — 롤링 재시작을 넘어 토큰을 들고 있어야 한다.

kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" --env="K1=$K1" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면 이렇게 센다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측(observed) — 19

kubectl -n keycloak-lab exec a7-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'

PW길이=0 이면 --env 가 빈 값을 받은 것이다. 파드를 지우고 다시 띄운다.

로그인 응답을 한 번은 통째로 본다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'

모양은 이렇고 값은 환경마다 다르다(observed).

{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
 "refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"}

expires_in 이 60 이다. access token 은 60초짜리고 그동안은 서버에 안 물어본다. 그래서 이 실험의 탐침은 access token 이 아니라 refresh 다 — refresh 는 노드가 세션 저장소를 실제로 뒤져야 답할 수 있다.

토큰을 파드 안 파일에 담고 반대 노드에서 갱신한다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'
rt 1188 bytes

★ 길이가 1 bytes 면 빈 문자열에 개행만 들어간 것이다. 파싱이 실패했거나 로그인이 실패한 것이고, cat /tmp/tok 으로 본문을 본다. 이걸 놓치고 진행하면 빈 토큰을 보내고 그 응답을 「세션이 죽었다」로 읽게 된다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K1:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측(observed) — 02-a0-rerun.txt

  keycloak-0 로그인 → keycloak-1 에서 refresh  HTTP 200

지금은 교차 노드가 된다. 이 200 이 견줄 값이다. refresh token 은 회전하므로 이어서 또 쓰려면 /tmp/rt 를 다시 채워야 하고, 가이드는 시험마다 새로 로그인해서 그 문제를 피한다.

마지막으로 이 버전에서 정말 끌 수 있는지를 확인한다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
  | tr ',' '\n' | grep -i persistent

실측(observed) — 01-switch-to-volatile.txt 의 전환이 성립한 근거

  persistent-user-sessions[:v1]      ← 목록에 있다

★ 2026-09-17 에 쳤더니 두 줄이 나왔다(observed). 버전 접미사가 붙은 것과 안 붙은 것이 목록에 따로 있다. 하나만 나올 것으로 알고 있으면 두 줄째를 딴 기능으로 읽는다.

                       persistent-user-sessions[:v1]
                       persistent-user-sessions

이름이 목록에 있으므로 이 버전(quay.io/keycloak/keycloak:26.7.0)에서는 아직 끌 수 있다. 목록에 없으면 그 버전에서는 이 실험을 할 수 없고, 기능이 제거돼 기본 동작으로 고정된 것이며 그 자체가 답이다. --help-all 은 출력이 길고 tr ',' '\n' 은 한 줄에 쉼표로 이어 붙은 기능 목록을 줄로 쪼개려는 것이다. 처음 한 번은 grep 없이 쳐서 어떤 기능들이 있는지 통째로 본다.

주입

먼저 세션을 비운다. 되돌리는 방법은 없다 — 지운 세션은 돌아오지 않는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"

실측(observed) — 01-switch-to-volatile.txt

DELETE 151

전환 후 「DB 가 0건」을 확인할 텐데 테이블에 옛 행이 남아 있으면 0건이 될 수 없다. volatile 은 새로 쓰지 않을 뿐 옛 행을 지우지도 않는다. 이 한 줄을 빼먹으면 뒤에서 「전환이 안 됐다」고 잘못 읽는다. 가이드는 단서를 붙인다 — 이건 실험대라서 하는 일이고, 운영에서 이 명령은 전원 로그아웃이다. 어차피 전환 자체가 세션을 날리므로 순서만 앞당기는 것이지만 명령 자체가 파괴적이라는 것은 알고 친다.

args 를 바꾸는 방법은 둘이고 가이드는 매니페스트를 고치는 쪽을 권한다 — 무엇이 바뀌었는지 파일에 남는다.

vim deploy/lab/k8s/keycloak-cluster.yaml
# 149번째 줄 근처
args: ["start", "--features-disabled=persistent-user-sessions"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml

파일을 안 건드리고 싶으면 patch 를 쓴다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
        "value":["start","--features-disabled=persistent-user-sessions"]}]'
date '+%H:%M:%S 전환'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

실측(observed) — 01-switch-to-volatile.txt

statefulset.apps/keycloak configured
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...

★ patch 로 바꾸면 configured 가 안 나온다(2026-09-17, observed). configuredkubectl apply 가 내는 말이고, patch 는 statefulset.apps/keycloak patched 를 낸다. 아래 「configured 가 나와야 한다」는 매니페스트를 고쳐 apply 한 경우에만 맞는 판정이다. patch 로 쳤으면 바뀐 것을 args 문자열로 확인한다 — 9번이 그 일을 한다.

statefulset.apps/keycloak patched

이 실험대에서 patch 로 쳤을 때 롤아웃은 18:02:41 에 시작해 18:03:47 에 끝났다. 재빌드까지 66초다.

configured 가 나와야 한다. unchangedargs 가 안 바뀐 것이다. --features-disabled 는 빌드 옵션이라 기동 시 재빌드가 일어나 평소보다 오래 걸린다. --timeout=60s 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 시각을 반드시 적어 둔다 — 뒤에서 지표가 「언제부터 변했나」를 볼 때 이 시각이 없으면 인과를 못 붙인다.

두 번째 주입은 관찰 단계 안에 있다. 양방향 raw DROP 과 PostgreSQL 정지다. 7800·57800 을 각 노드에서 그 노드에 있는 파드로 들어가는 방향으로 버린다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')

sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800  -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800  -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 차단'
노드 그 노드에 있는 파드 규칙의 -d
kc-lab-1 keycloak-1 $K1
kc-lab-2 keycloak-0 $K0

★ 이 블록은 kc-lab-1 에서 안 돈다(2026-09-17, observed). 앞의 「읽기 전에」가 적은 그대로다 — 그 기계에는 kubeconfig 가 없어서 첫 두 줄의 kubectl 이 막히고, $K0$K1 이 빈 문자열이 된다. 그 상태로 iptables 가 이어지면 -d 가 값을 못 받아 규칙이 하나도 안 들어간다. 그리고 kc-lab-1 에는 kc-lab-2 의 호스트 키가 없어 뒤의 두 줄도 접속 단계에서 끝난다. 여섯 줄이 전부 실패했는데 마지막 date 는 그대로 차단 을 찍는다.

error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied
Bad argument `7800'
Bad argument `57800'
Host key verification failed.
Host key verification failed.
09:04:49 차단

찍힌 시각을 주입 시각으로 적으면 A-6 의 적용완료 와 같은 함정이다. 그때 두 노드의 raw PREROUTING 은 비어 있었다.

kubeconfig 가 있는 lab host 에서 두 노드 모두 한 줄 ssh 로 치면 된다. 큰따옴표가 값을 lab host 에서 펴서 보내는 것은 아래 설명과 같고, 대상만 kc-lab-1 이 하나 더 는다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800  -j DROP"
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800  -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 차단'

NetworkPolicy 가 아니라 iptables 인 까닭은 A-1 에서 배운 것이다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫는다 — 이미 붙어 있는 7800 연결은 계속 산다. A-5 가 그 벽을 넘는 방법을 확립했다.

   패킷 도착
      ├─▶ raw PREROUTING        ← conntrack 보다 먼저. 여기서 끊는다
      ├─▶ conntrack: ESTABLISHED 면 통과
      └─▶ NetworkPolicy 평가    ← 여기까지 오지 않는다

raw 테이블은 CNI 가 안 쓰는 테이블이라 규칙이 밀려나지도 않는다. 그리고 57800 도 같이 막는다 — FD_SOCK2(장애 감지 채널)는 bind_port + 50000 을 쓰고, 7800 만 막으면 장애 감지가 살아 있어 분단이 어중간해진다.

DB 정지는 scale 로 한다.

date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s

scale --replicas=0 인 까닭delete pod 은 Deployment 가 곧바로 새로 만든다. DB 가 없는 구간을 원하는 만큼 유지할 수 있어야 두 경로를 다 잰다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.

kubectl -n keycloak-lab get statefulset keycloak \
  -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
kubectl -n keycloak-lab get pods -o wide | grep keycloak

실측(observed) — 01-switch-to-volatile.txt

["start","--features-disabled=persistent-user-sessions"]

args 문자열이 바뀐 것과 파드가 실제로 새것인 것(AGE 가 방금이고 RESTARTS0)을 함께 본다. StatefulSet 의 spec 은 바뀌었는데 파드가 옛 것이면 선언만 바뀌고 프로세스는 그대로다. 그 상태에서 재면 persistent 를 재면서 volatile 이라고 적게 된다.

IP 가 바뀌었으므로 다시 잡고 탐침 파드도 지우고 새 IP 로 다시 띄운다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

★ args 문자열만으로는 부족하다. 동작이 바뀐 것을 봐야 한다. keycloak-0 에만 로그인 5회를 한다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'for i in 1 2 3 4 5; do
     curl -s -o /dev/null -w "%{http_code} " -X POST \
       "http://$K0:8080/realms/master/protocol/openid-connect/token" \
       -d grant_type=password -d client_id=admin-cli \
       -d username=admin -d "password=$PW"
   done; echo'
200 200 200 200 200
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

실측(observed) — 02-a0-rerun.txt

=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
 offline_flag | count
--------------+-------
(0 rows)

(0 rows). 이것이 전환의 유일한 확실한 증거다. 로그인 5회가 성공했는데 DB 에 아무것도 안 남았다. 세션이 메모리에만 있다.

★ 캐시 엔트리 수로는 두 모드를 구별할 수 없다. 여기가 이 실험에서 가장 헷갈린다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'

한 줄짜리 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다. 모양은 이렇고 값은 환경마다 다르다(observed).

{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757046000.1,"5"]},
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757046000.1,"0"]}]}}

라벨을 보고 나면 읽기 좋게 자른다. 가이드가 미검증으로 표시한 줄이다(unknown).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
  | tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'

실측(observed) — 02-a0-rerun.txt

  keycloak-0  sessions 캐시 5.0 건
  keycloak-1  sessions 캐시 0.0 건

★ 위 두 줄은 이 명령의 출력이 아니다(2026-09-17, observed). trgrep 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 96줄이었다. 위의 요약 두 줄은 원 실행의 스크립트가 만든 모양이다(inferred). 앞의 ① 이 찍는 JSON 도 마찬가지로 sessions 만이 아니라 캐시 전부를 담고 있다.

"cache":"sessions"
"pod":"keycloak-1"}
"82"]}
"cache":"clientSessions"
"pod":"keycloak-1"}
"82"]}

세 줄이 한 묶음이고 마지막 줄의 숫자가 그 캐시의 엔트리 수다. 세로로 읽으면서 캐시 이름과 파드 이름을 눈으로 짝지어야 한다.

persistent 였을 때와 똑같은 숫자다. approximate_entries_unique그 노드가 소유한 엔트리만 센다. 백업본을 들고 있어도 0 으로 보인다.

persistent volatile
로그인 5회 후 캐시 5 / 0 5 / 0
로그인 5회 후 DB 5건 0건

이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 DB 행이 있느냐이고, 그다음은 7800 을 끊어 보는 것이다.

차단 쪽 검증은 양쪽 카운터를 둘 다 본다.

ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'

원 가이드는 앞 줄을 kc-lab-1 안에서 직접 치고 뒤 줄만 ssh 로 보냈다. 그 기계에는 kc-lab-2 의 호스트 키가 없어 뒤 줄이 Host key verification failed 로 끝난다(2026-09-17, observed).

sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'

모양은 이렇고 숫자는 환경마다 다르다(observed).

Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target  prot opt in  out  source     destination
   19  1140 DROP    tcp  --  *   *    0.0.0.0/0  10.42.0.46  tcp dpt:7800
    0     0 DROP    tcp  --  *   *    0.0.0.0/0  10.42.0.46  tcp dpt:57800

pkts 카운터를 본다. 규칙이 목록에 있는데 pkts 가 0 이면 패킷이 그 경로로 안 오는 것이고 분단은 안 만들어졌다. A-5 가 이 함정에 두 번 빠졌다.

분단이 성립했는지는 25초 간격으로 몇 번 친다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'

실측(observed) — 04-a1-rerun-partition.txt

  차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향)
  분단이 성립할 때까지 대기...
    +25초  cluster_size(k0 k1) = [2.0 2.0 ]
    +50초  cluster_size(k0 k1) = [1.0 ]
    +75초  cluster_size(k0 k1) = [1.0 ]
    +100초  cluster_size(k0 k1) = []
    +125초  cluster_size(k0 k1) = [1.0 ]

2.0 2.01.0 으로 떨어지는 데 50초쯤 걸린다.

★ 이 실험대에서는 +75초 에 떨어졌고 시계열에 빈 값이 없었다(2026-09-17, observed). 손으로 25초마다 친 다섯 번은 2 2 · 2 2 · 1 1 · 1 1 · 1 1 이었다. 차단을 푼 뒤 양쪽이 2 로 돌아오는 데는 +60초 가 걸렸다.

[] 와 값이 하나뿐인 줄은 측정 실패다. 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼는데, 파드 생성이 느리고 경합이 있어 빈 응답이 섞였다. A-1 가이드가 지적한 그 문제가 여기서도 그대로 보인다. 손으로 치면 빈 값이 나온 것이 그 즉시 보이고 다시 치면 된다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.

split brain 은 DB 한 줄로 확인한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"

모양은 이렇고 값은 환경마다 다르다(observed).

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-0-30843 | 10.42.1.99:7800 | t
 keycloak-1-48749 | 10.42.0.46:7800 | t

coord = t 가 둘이면 분단이다. 정상일 때는 하나다.

관찰

A-0 을 다시 돌린다. 앞에서 친 것과 완전히 같은 명령이다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K1:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측(observed) — 02-a0-rerun.txt

=== 교차 노드 세션은 되는가 ===
  keycloak-0 로그인 → keycloak-1 에서 refresh  HTTP 200

겉보기 결과가 persistent 때와 같다. 그런데 DB 는 0건이다. 즉 경로가 완전히 달라졌다.

   persistent :  keycloak-1 이 PostgreSQL 을 읽어서 답했다
   volatile   :  keycloak-1 이 7800 을 통해 keycloak-0 에게 물어서 답했다

★ 2026-09-17 에 새 실험대에서 그 둘을 이어서 쳤다(observed). 같은 명령 한 벌이 전환 전후로 정반대 답을 냈다.

volatile · 분단 없음    교차 노드 refresh 200 · DB 세션 행 (0 rows)
                        캐시 keycloak-0 sessions 1 · keycloak-1 sessions 0
volatile · 7800 차단    교차 노드 refresh 400 · 로그아웃 400
                        발급 노드 재갱신 400 invalid_grant / Invalid refresh token

persistent 에서는 같은 것을 재면 200 이 나왔다. A-1 에서 7800 을 막고 잰 값이 200·204·200 이었고 여기서는 400·400·400 이다. 그 한 칸이 두 모드의 차이를 전부 말한다.

같은 200 인데 다른 까닭이다. 겉보기 결과만으로는 구별이 안 되고, 구별하려면 그 경로를 끊어 봐야 한다.

A-8 을 다시 돌리면 롤링 재시작이 곧 로그아웃이다. 재시작 전에 로그인해서 토큰을 파드 안에 보관한다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
     | cut -d. -f2 | base64 -d 2>/dev/null; echo'

access token 의 가운데 토막이 클레임이다. 모양은 이렇고 값은 환경마다 다르다(observed).

{"exp":1757046060,"iat":1757046000,"jti":"...","typ":"Bearer","azp":"admin-cli",
 "sid":"aVwYnzKZFFvMqD3bpSeiILuM",...}

실측(observed) — 03-a8-rerun-restart.txt

=== [A-8 재실행] 재시작 전 로그인 ===
  sid = aVwYnzKZFFvMqD3bpSeiILuM

sid 를 적어 둔다. base64 패딩 때문에 끝이 깨져 보일 수 있고(2>/dev/null 이 그 불평을 지운다) sid 는 앞쪽에 있어서 대개 보인다. ★ 탐침 파드가 StatefulSet 밖에 있어야 한다. 토큰이 재시작을 넘어 살아 있어야 이 시험이 성립하고, a7-probe--restart=Never 로 띄운 단독 파드라 Keycloak 롤아웃과 무관하다.

date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

실측(observed) — 같은 파일

statefulset.apps/keycloak restarted
partitioned roll out complete: 2 new pods have been updated...

파드 IP 가 또 바뀌므로 다시 잡는다. ★ 그런데 탐침 파드는 다시 띄우면 안 된다/tmp/rt 가 같이 사라진다. 그래서 가이드는 새 IP 를 명령줄에 직접 넘긴다. 셸 인용이 세 겹이 되는 형태이고, 가이드는 이 상황에서 다른 형태를 제시하지 않는다.

이 실험대는 이렇게 했다(observed)

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

따라 하는 사람은 그 인용이 무엇을 하는지 먼저 읽는다 — 바깥 작은따옴표를 닫고, 셸이 $K0 를 펴게 큰따옴표로 감싸고, 다시 작은따옴표를 연다. 파드 안 셸에는 이미 펴진 IP 문자열이 들어간다. echo 로 한 번 찍어 보면 무엇이 들어가는지 보인다. 이 확인은 이 실험대에서 치지 않았다(unknown).

실측(observed) — 03-a8-rerun-restart.txt

=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
  keycloak-0 에서 refresh  HTTP 400
  --- 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}

400본문의 Session not active 를 본다. A-8 의 결과가 정확히 뒤집혔다. 같은 명령, 같은 순서, 반대 답이다.

persistent (A-8) volatile (지금)
재시작 전 토큰으로 refresh 200 400 Session not active
배포 자유롭다 모든 사용자가 다시 로그인
파드 재시작(OOM·노드 교체) 무해 그 노드가 처리하던 세션 소멸

본문을 반드시 본다. 400 만 보면 「토큰이 이상한가」로 읽히지만 Session not active서버가 그 세션을 모른다는 뜻이다. 토큰은 멀쩡하다.

캐시도 함께 본다.

실측(observed) — 같은 파일

=== 캐시 상태 ===
  keycloak-1  sessions 캐시 1.0 건

재시작으로 캐시가 비었고 방금 실패한 요청이 새 세션을 하나 만든 것이 1건 이다. 옛 세션 5건은 어디에도 없다. 24 이전 버전을 쓰는 곳에서 「배포하면 로그아웃된다」가 당연하게 여겨졌던 까닭이 이것이고, A-8 이 「이것이 persistent 를 켜는 진짜 이유」라고 쓴 문장이 여기서 증명된다.

A-1 을 다시 돌리면 이번에는 세션 공유가 깨진다. 이 대목이 이 실험의 핵심이다. 같은 주입, 같은 관측, 정반대 결과다. 대조군을 반드시 같이 잰다 — 차단이 모든 것을 망가뜨린 게 아니라 교차 노드만 끊었다는 것을 보여야 한다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -X POST "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   curl -s -o /dev/null -w "same-node %{http_code}\n" -X POST \
     "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

시험군은 새로 로그인해서 새 토큰으로 한다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -X POST "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   curl -s -w "\ncross-node %{http_code}\n" -X POST \
     "http://'"$K1"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측(observed) — 04-a1-rerun-partition.txt

=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
  keycloak-0 로그인 → keycloak-0 에서 refresh  HTTP 200  ← 대조군
  keycloak-0 로그인 → keycloak-1 에서 refresh  HTTP 400  ← 시험군
  --- 시험군 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}

이 한 쌍이 A층 전체의 근거다.

   persistent :  세션 ── PostgreSQL ──▶ 양쪽이 본다      7800 무관
   volatile   :  세션 ── 클러스터(7800) ─▶ 상대에게 간다   7800 필수

A-1 이 통념과 어긋난 까닭이 확정됐다. 통념은 24 이전에서 맞다. 틀린 것은 자료가 아니라 버전을 확인하지 않고 적용하는 것이다.

다음으로 넘어가기 전에 반드시 차단을 푼다. 양쪽이 2 로 돌아와야 한다 — 분단이 남아 있으면 다음 결과가 DB 때문인지 분단 때문인지 구별되지 않는다.

A-2 를 다시 돌리면 새 로그인은 되는데 refresh 가 안 된다. DB 를 내리기 전에 로그인해서 토큰을 확보하고, 내린 뒤에 둘을 잰다.

kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'
kubectl -n keycloak-lab exec a7-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'

실측(observed) — 05-a2-rerun-db-loss.txt

  ① 캐시를 가진 노드에서 refresh  HTTP 500
  ② 새 로그인                     HTTP 200

순서가 거꾸로다. persistent 에서는 새 로그인이 500 이었다. 세션을 DB 에 써야 했기 때문이다. 그 쓰기가 없어지니 로그인이 통과한다.

   로그인에 필요한 것
     ├─ realm 설정   → Infinispan `realms` 캐시에 있다
     ├─ 사용자 자격  → `users` 캐시에 있다
     └─ 세션 저장    → volatile 이므로 메모리
   → DB 없이 완결된다

★ 이 두 숫자를 그대로 표로 옮기면 안 된다. 이 결과는 조건부다. 후속 실험 A-7a 가 확정한 것이 이렇다.

캐시 상태 로그인 refresh
완전 냉시동 (재시작 직후) 400 400
CLIENT 만 더움 ← 위에서 잰 것 200 500
완전히 더움 200 200

같은 설정에서 캐시 온도만으로 셋으로 갈린다. 위에서 잰 200 / 500 은 그중 한 상태다 — 마침 롤아웃 뒤 로그인을 몇 번 했고 refresh 는 안 한 상태였기 때문에 그 값이 나왔다. 그리고 A-7 이 남긴 「refresh 가 500 인 이유는 REVOKED_TOKEN 조회일 것」이라는 가설은 틀렸다. 실제 원인은 CLIENT_SCOPE_CLIENTDEFAULT_SCOPE='f' 로 조회하는 한 문장이고, 그것은 문장 로깅을 켜야 보인다.

한 번 재고 표로 적으면 안 되는 종류의 측정이다. 상태가 결과를 바꾸는데 그 상태가 안 보인다. A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의 함정이다.

DB 를 되살린다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s

실측(observed) — 05-a2-rerun-db-loss.txt

deployment.apps/postgres scaled
deployment "postgres" successfully rolled out

volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm·사용자·클라이언트· 취소 토큰은 여전히 DB 에 있다. 세션만 메모리로 옮긴 것이다.

복구와 원상복구 확인표

순서가 있다. iptables 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, args 를 되돌린다.

ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n'
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'

원 가이드는 앞 줄을 kc-lab-1 안에서 직접 치고 뒤 줄만 ssh 로 보냈다. 그 기계에는 kc-lab-2 의 호스트 키가 없어 뒤 줄이 Host key verification failed 로 끝난다(2026-09-17, observed).

sudo iptables -t raw -L PREROUTING -n
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
kubectl -n keycloak-lab get pods -l app=postgres

Running 이 아니면 scale deployment/postgres --replicas=1 을 친다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

매니페스트를 고쳤다면 파일도 같이 되돌린다. 안 그러면 다음에 apply 할 때 volatile 로 다시 간다.

git diff deploy/lab/k8s/keycloak-cluster.yaml
git checkout -- deploy/lab/k8s/keycloak-cluster.yaml

실측(observed) — 06-restore-persistent.txt

=== persistent 모드로 원복 ===
statefulset.apps/keycloak configured
partitioned roll out complete: 2 new pods have been updated...

args 문자열만 보고 끝내지 않는다. 새 IP 로 탐침을 다시 띄우고 로그인을 한 번 한 다음 DB 행을 센다.

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

실측(observed) — 06-restore-persistent.txt

["start"]
로그인
  DB 온라인 세션: 1 건  (1 이면 persistent 복귀)
keycloak-0                  1/1   Running   0     67s
keycloak-1                  1/1   Running   0     89s
postgres-7b474b88c8-t6rrf   1/1   Running   0     2m8s
  외부 진입점 HTTP 200

1 건. 앞에서 테이블을 비웠으므로 여기서 세는 값은 방금 만든 세션 하나뿐이다. 0 이면 아직 volatile 이다.

항목 명령 돌아왔을 때
args kubectl -n keycloak-lab get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].args}' ["start"]
매니페스트 git diff deploy/lab/k8s/keycloak-cluster.yaml 출력 없음
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
DB kubectl -n keycloak-lab get pods -l app=postgres 1/1 Running
동작 로그인 뒤 select count(*) ... 세션 행이 생긴다
iptables sudo iptables -t raw -L PREROUTING -n (두 노드) 규칙 없음
클러스터 vendor_cluster_size 양쪽 2
탐침 파드 kubectl -n keycloak-lab get pod a7-probe NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found

★ 마지막 줄의 200 은 이 실험대에서 안 나온다(2026-09-17, observed). auth.hyeonworks.com 이 lab host 에서 100.83.212.4 로 풀리고 그 주소의 443 이 닫혀 있어 000 이 나온다. 엣지 주소를 짚으면 200 이고, 까닭은 A-6 의 같은 줄에 적었다. 나머지 여덟 줄은 이 실험대에서 그대로 통과했다 — args ["start"], git diff 출력 없음, 파드 둘 다 1/1 Running, postgres 1/1 Running, 로그인 뒤 세션 행 1, 두 노드 raw PREROUTING 비어 있음, vendor_cluster_size 양쪽 2, 탐침 NotFound.

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
rollout status 가 타임아웃 빌드 옵션이라 재빌드가 일어난다. 평소보다 오래 걸린다 --timeout=500s 로 다시. logs keycloak-0 에 빌드 진행이 보인다
applyunchanged args 를 안 고쳤거나 다른 파일을 고쳤다 get statefulset ... -o jsonpath='{...args}' 로 실제 값
전환했는데 DB 에 행이 그대로 delete 를 건너뛰었다. 옛 행은 안 지워진다 delete from offline_user_session 후 다시 로그인
캐시가 5 / 0 이라 전환이 안 된 것 같다 두 모드가 같은 값을 낸다 판정은 DB 행 수로 한다
차단했는데 cluster_size 가 계속 2 규칙이 안 걸렸거나 pkts 가 0 iptables -t raw -L PREROUTING -n -v 의 카운터
cluster_size 결과가 [] 측정 실패다. 원래 실행의 스크립트가 빈 값을 뱉었다 손으로 다시 친다. 빈 값은 판정에서 뺀다
교차 노드가 계속 200 차단이 한쪽만 걸렸다 = 단방향 두 노드 카운터를 둘 다 본다
재시작 뒤 아무 데도 안 닿는다 파드 IP 가 바뀌었다 get pod -o jsonpath='{.status.podIP}' 다시
refresh 가 400 인데 이유를 모르겠다 본문을 안 봤다 -o /dev/null 을 빼고 본문을 본다. Session not active 인지
A-2 재실행이 200 / 200 이 나온다 캐시가 이미 더워졌다. 틀린 게 아니다 조건부다 — A-7a
로그인이 400 unauthorized_client 완전 냉시동이다. 클라이언트 조회조차 캐시에 없다 이것도 조건부 — A-7a
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다
다음 실험 결과가 이상하다 원복을 안 했다 확인표를 전부 통과시킨다

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

  • (observed) 전환 전 args ["start"] 와 파드 IP 10.42.1.94·10.42.0.45, DELETE 151, 전환 뒤 args ["start","--features-disabled=persistent-user-sessions"], 로그인 5회 뒤 (0 rows) 와 캐시 5.0/0.0, 교차 노드 refresh HTTP 200, 재시작 전 sid = aVwYnzKZFFvMqD3bpSeiILuM 와 재시작 뒤 HTTP 400 · {"error":"invalid_grant","error_description":"Session not active"}, 재시작 뒤 캐시 1.0, 분단 대기 시계열 다섯 줄, 대조군 200 과 시험군 400, DB 정지 뒤 ① 500 · ② 200, 원복 뒤 ["start"]DB 온라인 세션: 1 건 과 외부 200, 비밀번호 길이 19, 기능 목록의 persistent-user-sessions[:v1].
  • (observed, 2026-09-17) 가이드가 미검증으로 표시한 tr ',' '\n' | grep -E 로 자른 Prometheus 출력은 이날 쳤다. 나오는 모양은 위 캐시 엔트리 절의 ★ 에 적었다.
  • (unknown) ssh kc-lab-2 로 들어가 원격 셸에서 iptables 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 echo 로 찍어 보는 확인. 이 실험대에서 치지 않았다.
  • (observed, 2026-09-17) 1번부터 복구까지 다시 밟은 값 — DELETE 144, 로그인 5회 200 200 200 200 200 뒤 DB (0 rows), 교차 노드 refresh 200, 재시작 뒤 400Session not active, 분단에서 same-node 200cross-node 400, coord = t 둘, DB 정지 뒤 500200, 원복 뒤 ["start"] 와 세션 행 1. 원문은 relive-2026-09-17/a7-06..08.
  • 측정이 샌 곳cluster_size 시계열의 [] 와 값이 하나뿐인 줄. 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다.
  • 조건부인 것 — DB 정지 뒤의 200 / 500. 캐시 온도에 따라 400 / 400 이나 200 / 200 도 나온다. 셋을 가르는 절차는 A-7a 에 있다.
  • 틀린 것으로 확정된 것 — 「refresh 가 500 인 이유는 REVOKED_TOKEN 조회일 것」이라는 가설. 실제 문장은 CLIENT_SCOPE_CLIENT 조회다.
  • 이 실험이 재지 않은 것 — volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지. 파드가 둘뿐이라 N² 를 볼 수 없다.

A-7a — DB 에게 직접 물어서 그 500 의 원인을 확정한다

근거: a7a-volatile-cause.md (891줄). 수집 기록은 2026-09-04 11:1811:24 UTC(observed).

이 편만 시각이 UTC 다. 증거 파일의 11:18:49 는 KST 로 20:18 이고, 가이드 상단의 20:1820:24 KST 와 같은 순간이다. PostgreSQL 컨테이너가 UTC 로 로그를 찍기 때문이라고 가이드가 적는다. 로그 시각과 date 를 비교할 때 이걸 잊으면 9시간을 헤맨다.

이 실험이 가르는 것

A-7 은 이렇게 끝났다.

측정은 확실하지만 원인은 확정하지 못했다. 유력한 후보는 REVOKED_TOKEN 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.

그럴듯하다. 그리고 틀렸다.

   가설을 세우는 것       →  괜찮다
   가설을 표에 적는 것    →  다음 사람이 사실로 읽는다
   확정하는 방법이 있는데 안 하는 것  →  이 실험이 고치는 것

「refresh 가 어느 테이블 때문에 실패하는가」는 추측으로 답할 문제가 아니다. Keycloak 소스를 읽는 대신 DB 가 실제로 받은 문장을 보면 된다. 확정해 보니 원인만 틀린 게 아니었다 — A-7 의 표 자체가 조건부였다. 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다.

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 로그인이 SQL 을 0개 쏘는 것을 PostgreSQL 문장 로그에서, refresh 가 쏘는 딱 한 문장의 이름을 같은 로그의 CLIENT_SCOPE_CLIENT 에서, 그 문장이 첫 refresh 에만 나오는 것을 표식 사이 SQL 0건에서, A-7 이 지목한 REVOKED_TOKEN한 번도 안 나오는 것을 같은 로그에서, 같은 설정에서 400 · 500 · 200 셋이 다 나오는 것을 캐시 온도 세 상태에서, 실패한 SQL 을 Keycloak 로그가 직접 지목하는 것JDBC exception executing SQL [...] 에서.

전제와 되돌리기

  • 05-keycloak 이 끝나 있다.
  • A-7 을 먼저 한다. 이 실험은 A-7 이 남긴 가설을 확정하는 것이고, A-7 에서 본 500 이 출발한 곳이다.
  • A-3 의 문장 로깅을 해 봤으면 익숙할 것이다. 같은 기법이다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.

주입이 세 개다. 복구도 세 개다. 가이드의 경고를 그대로 옮긴다.

  1. PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
  2. Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
  3. PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다

실험대에서만 한다. 전 구간 약 40분이고, 중간에 그만두려면 복구 절을 위에서부터 그대로 친다.

표식을 넣는 방식이 이 가이드가 원 실행과 갈라지는 곳이다. 원 실행은 표식을 셸 함수로 감쌌다.

이 실험대는 이렇게 했다(observed)

m() { kubectl -n keycloak-lab exec deploy/postgres -- \
        psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }

짧고 편하다. 그런데 출력을 /dev/null 로 버린다. 표식이 실제로 로그에 들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이다. 로깅이 안 켜져 있었다면 표식 없는 로그를 한참 뒤에 awk 로 자르다가 알게 된다.

따라 하는 사람은 표식을 한 줄씩 손으로 넣는다. 느리지만 그 즉시 보이고, 안 보이면 그 즉시 안다. 아래 절차가 전부 그 형태다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_TEST'"

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

파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
kubectl -n keycloak-lab get pods -o wide

모양은 이렇고 값은 환경마다 다르다(observed).

NAME                        READY  STATUS   RESTARTS  AGE  IP           NODE
keycloak-0                  1/1    Running  0         2d   10.42.1.94   kc-lab-2
keycloak-1                  1/1    Running  0         2d   10.42.0.45   kc-lab-1
postgres-7b474b88c8-t6rrf   1/1    Running  0         5d   10.42.0.22   kc-lab-1

셋 다 Running 이고 postgres 가 있어야 한다 — 이 실험은 그것을 내렸다 올렸다 한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"
 log_statement
---------------
 none

all 이면 앞 실험이 켜 둔 채 끝낸 것이고 지금 쌓인 로그가 어느 실험 것인지 구별할 수 없다. 그때는 먼저 끄고 로그가 한 바퀴 돌 때까지 기다린다.

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

이 값을 적어 둔다. 복구에서 이대로 되돌린다.

Keycloak 컨테이너에는 curlwget 도 없다(exit 127). 탐침 파드를 띄우되, 이 실험은 Keycloak 을 여러 번 재시작하므로 탐침은 반드시 StatefulSet 밖에 있어야 한다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측(observed) — 19

★ 명령줄에 평문 비밀번호를 쓰지 않는다. 가이드는 원래 실험의 재현 절차에 그대로 적혀 있다고 지적하며, 파드 안 ps 에도 셸 히스토리에도 남는다고 적는다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'

로그가 지금 무엇으로 차 있는지를 켜기 전에 한 번 본다.

kubectl -n keycloak-lab logs deploy/postgres --tail=20

조용하다. 여기까지는 에러만 찍힌다. 다음 절에서 켜면 JGroups 가 5초마다 하는 JGROUPS_PING 폴링이 로그를 계속 채운다. 그것이 소음이고 나중에 grep -v JGROUPS_PING 으로 거른다. 소음을 먼저 봐 두면 거르는 이유를 안다.

주입

주입 ① PostgreSQL 문장 로깅. log_statement = 'all' 을 켜면 서버가 받은 모든 SQL 을 로그에 찍는다. 애플리케이션을 고치지 않고 「이 요청이 DB 를 어떻게 쓰는지」를 밖에서 볼 수 있다. 「refresh 가 어느 테이블 때문에 실패하는가」를 확정하려면 DB 가 실제로 받은 문장을 봐야 하고, Keycloak 안을 들여다볼 필요가 없다. 이것 없이 하면 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.

되돌리는 명령을 먼저 읽어 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system set log_statement='all'" -c "select pg_reload_conf()"

주입 ② volatile 전환. 되돌리는 명령을 먼저 읽어 둔다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
        "value":["start","--features-disabled=persistent-user-sessions"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

빌드 옵션이라 기동 시 재빌드가 일어나 오래 걸린다. --timeout=500s 를 주는 까닭이다. ★ 파드 IP 가 바뀌었으므로 탐침 파드를 다시 띄운다.

kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s

주입 ③ PostgreSQL 정지. 세 재현마다 한 번씩, 모두 세 번 내린다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s

주입 검증

로깅이 실제로 켜졌는지.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"
 log_statement
---------------
 all

none 이면 pg_reload_conf() 가 안 돈 것이다. alter systempostgresql.auto.conf 에 쓸 뿐이고 reload 를 해야 적용된다.

로그가 실제로 차기 시작했는지.

kubectl -n keycloak-lab logs deploy/postgres --tail=10
2026-09-04 11:17:40.112 UTC [214] LOG:  execute <unnamed>: select ... from JGROUPS_PING ...

JGROUPS_PING 이 계속 나온다. 앞에서 예고한 소음이고, 이게 안 보이면 로깅이 안 켜진 것이다.

표식이 로그에 들어가는지.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_TEST'"
kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
2026-09-04 11:18:40.102 UTC [301] LOG:  statement: select 'MARK_TEST'

statement: select 'MARK_TEST' 가 보이면 이제 표식과 표식 사이만 잘라 볼 수 있다. 안 보이면 로깅으로 돌아간다.

volatile 전환은 args 와 동작을 둘 다 본다.

kubectl -n keycloak-lab get statefulset keycloak \
  -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select count(*) from offline_user_session where offline_flag='0'"

실측(observed) — 01-cause-determined.txt

volatile 전환 확인
  args: ["start","--features-disabled=persistent-user-sessions"]
  로그인 200 · offline_user_session 행수 = 0        ← volatile 맞다

세 가지가 다 맞아야 한다. 로그인이 200 인데 행이 안 생기는 것이 volatile 의 증거다. 행 수가 0 이 아니면 옛 행이 남아 있는 것이고, A-7 처럼 delete from offline_user_session 을 먼저 하고 다시 잰다.

문장 로그가 지금 요청을 잡고 있는지도 본다.

kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20

이 시점에서는 거의 JGROUPS_PING 뿐일 것이다. 그게 이 실험의 첫 발견인데, 지금은 「내 요청이 어디 있는지 모르겠다」로만 보인다. 구간을 나눠야 보인다.

관찰

로그인이 무슨 SQL 을 쏘는가. 표식 → 로그인 → 표식 순으로 세 명령을 붙여서 친다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_LOGIN_START'"
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_LOGIN_END'"
rt 1188 bytes

1 bytes 면 파싱이 실패한 것이다. 그 상태로 다음을 하면 빈 토큰을 보내고 엉뚱한 오류를 보게 된다. cat /tmp/tok 으로 본문을 본다.

kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING

실측(observed) — 01-cause-determined.txt

  11:18:49.461 statement: select 'MARK_LOGIN_START'
  11:18:49.743 statement: select 'MARK_LOGIN_END'
                                   ↑ 사이에 아무것도 없다

두 줄뿐이다. 로그인은 SQL 을 0개 쏜다. realm·사용자·클라이언트가 전부 Infinispan 캐시에 있고 volatile 이라 세션 쓰기도 없다. DB 없이 완결된다 — A-7 이 적은 그대로다.

awk '/A/,/B/'A 가 나온 줄부터 B 가 나온 줄까지 출력한다. 로그를 구간으로 자를 때 이보다 짧게 쓰는 방법은 없다. 파일로 먼저 받는 것은 같은 로그를 여러 구간으로 반복해서 잘라 볼 것이기 때문이다.

★ refresh 는 딱 한 문장을 쏘고, 그것은 가설이 지목한 테이블이 아니다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_REFRESH_START'"
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_REFRESH_END'"
kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING

실측(observed) — 01-cause-determined.txt

  11:18:52.009 statement: select 'MARK_REFRESH_START'
  11:18:52.137 statement: BEGIN
  11:18:52.137 execute <unnamed>/C_107:
       select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
        where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
       parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
  11:18:52.148 execute S_2: COMMIT
  11:18:52.253 statement: select 'MARK_REFRESH_END'

세 가지를 본다 — 문장이 하나뿐이다(BEGIN / COMMIT 사이에 select 한 개), 테이블 이름이 CLIENT_SCOPE_CLIENT 다, parameters 줄의 $2 = 'f'.

가설이 지목한 테이블이 정말 없는지 직접 센다.

awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token

실측(observed) — 01-cause-determined.txt

REVOKED_TOKEN 은 **한 번도 나오지 않는다.**

A-7 의 가설은 틀렸다. 그럴듯했지만 로그가 아니라고 말한다. 그리고 이제 로그가 지목하는 문장이 있다.

DEFAULT_SCOPE='f' 가 무슨 뜻인지가 그 문장을 읽는 열쇠다. Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.

DEFAULT_SCOPE
default scope 항상 붙는다 t
optional scope 요청이 scope= 로 달라고 해야 붙는다 f

refresh 는 새 access token 을 만든다. 그 토큰에 어떤 스코프를 담을지 정하려면 「이 클라이언트가 요청 가능한 optional 스코프가 무엇인가」를 알아야 하고, 그 목록이 CLIENT_SCOPE_CLIENT 에 있다. 로그인 때는 이미 결정된 것을 쓰지만 refresh 는 다시 계산한다. 이 조회가 실패하면 토큰을 만들 수 없어 500 이다. 400 Session not active 와 달리 세션 문제가 아니다 — 그래서 A-7 이 세션 계열 테이블을 의심한 것이 자연스러웠지만 틀렸다.

그 UUID 가 어느 클라이언트인지 궁금하면 물어본다. 당신 환경에서는 UUID 가 다르므로 위 로그의 $1 값을 그대로 넣는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select id, client_id from client where id='131a9912-b578-4b9c-b16a-97518704077e'"

admin-cli 가 나오면 방금 친 요청의 클라이언트가 맞다.

그 조회는 한 번뿐이다. refresh 를 연속 3회 하며 사이사이에 표식을 넣는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_R1'"
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_R2'"

★ 매번 /tmp/rt 를 다시 채운다. refresh token 은 회전하고, 옛 것을 계속 쓰면 나오는 오류가 무효화 때문인지 재사용 때문인지 구별되지 않는다. 같은 모양으로 MARK_R3 · MARK_R_END 까지 두 번 더 한다.

kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING

실측(observed) — 01-cause-determined.txt

연속 refresh 3회, 전부 200. 표식 사이 SQL:
  statement: select 'MARK_R1'
  statement: select 'MARK_R2'
  statement: select 'MARK_R3'
  statement: select 'MARK_R_END'
                          ↑ SQL 0건

표식 네 줄만 있고 그 사이에 아무것도 없다. 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않는다. 그러면 DB 를 언제 내리느냐에 따라 답이 달라진다.

★ 같은 설정에서 답이 셋으로 갈린다. 셋 다 재현한다.

캐시 상태 로그인 refresh 실패한 SQL
완전 냉시동 (재시작 직후) 400 400 select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?
CLIENT 만 더움 ← A-7 이 본 것 200 500 select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …
완전히 더움 200 200 없음 (SQL 0건)

★ 한 번만 재고 넘어가면 반드시 틀린 표를 쓰게 된다. A-7 이 그렇게 했다. 세 재현 모두 공통의 되돌리기가 하나 있고, 어느 단계에서 멈추든 이것부터 친다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s

캐시를 식히는 방법은 Keycloak 재시작이 유일하다.

   Infinispan 캐시 = 프로세스 메모리
        │
        └─ 파드가 살아 있는 한 안 식는다
             └─ 그래서 세 재현 사이마다 rollout restart 를 한다

이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다. 이미 더워진 캐시에서 계속 재게 되므로 A·B 를 재도 C 의 답(200/200)이 나오고 「A-7 이 틀렸다」는 엉뚱한 결론에 도달한다.

재현 A — 완전 냉시동이면 로그인부터 400.

kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s

★ 재시작과 DB 정지 사이에 아무 요청도 보내지 않는다. 한 번이라도 로그인하면 캐시가 더워져서 이건 재현 B 가 된다. 파드 IP 가 바뀌었으므로 탐침을 다시 띄운 다음 로그인을 본문까지 본다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'

실측(observed) — 01-cause-determined.txt

    로그인 400 {"error":"unauthorized_client",
                "error_description":"Unexpected error when authenticating client"}

unauthorized_client 다. invalid_grant 가 아니다. 세션 문제가 아니라 클라이언트를 못 찾은 것이다. 왜인지는 Keycloak 로그가 직접 말한다.

kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
  | grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'

★ 그 패턴은 속 예외가 그 문구일 때만 잡는다. 속 예외는 DB 를 언제 끊었느냐에 따라 둘로 갈리고, 2026-09-17 에는 --tail=150 과 이 패턴으로 한 줄도 안 나왔다(observed).

끊는 중에 걸린 요청   GenericJDBCException: JDBC exception executing SQL [FATAL: terminating connection due to administrator command]
이미 사라진 뒤의 요청 org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection [Connection to postgres:5432 refused. …]

둘 다 앞머리가 KC-SERVICES0015: Unexpected error when authenticating client 이므로 그것으로 걸고 --tail 도 늘린다 — 재시작 직후에는 기동 로그가 길어 150 줄로는 이 줄까지 못 거슬러 올라간다.

kubectl -n keycloak-lab logs keycloak-0 --tail=400 \
  | grep -oE 'KC-SERVICES0015[^[]*\[[^]]*\]'

실측(observed) — 같은 파일

  ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
    authenticating client: org.hibernate.exception.GenericJDBCException:
    JDBC exception executing SQL [FATAL: terminating connection due to
    administrator command]
    [select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]

대괄호가 두 쌍이다. 앞은 DB 가 준 오류, 뒤는 실패한 SQL 원문이고 grep -oE 로 그 두 쌍만 뽑는 까닭이 이것이다. 아무것도 안 나오면 --tail 을 늘리거나 grep -i 'JDBC exception' 으로 먼저 넓게 본다 — 정규식이 안 맞는 것과 로그에 없는 것은 다르다.

A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었다. 냉시동에서는 아니다. 클라이언트 조회조차 캐시에 없기 때문이다.

재현 B — A-7 이 본 그 조건. DB 를 살리고, 재시작하고, 로그인만 한 번 하고, DB 를 내린다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

탐침을 새 IP 로 다시 띄운 뒤 로그인 한 번만 한다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'

★ 여기서 refresh 를 하면 안 된다. 하는 순간 재현 C 가 된다. DB 를 내린 다음에 refresh 한다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측(observed) — 01-cause-determined.txt

    로그인   200
    refresh  500 {"error":"unknown_error"}

실패한 SQL 을 같은 grep -oE 로 뽑는다.

실측(observed) — 같은 파일

  JDBC exception executing SQL [FATAL: terminating connection due to
    administrator command]
    [select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
      where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]

앞에서 문장 로깅으로 본 그 문장이다. 로깅이 「이 문장을 쏜다」를 보여줬고 여기서는 「이 문장이 실패했다」를 보여준다. 두 개가 만나면 가설이 아니라 확정이다. 500 unknown_error 인 까닭도 이제 안다 — 세션은 멀쩡하다. 토큰을 조립하다가 DB 가 없어서 못 만든 것이고, Keycloak 은 그걸 사용자 오류로 분류할 방법이 없어서 unknown_error 를 준다.

재현 C — 완전히 더우면 둘 다 200. DB 를 살리고, 재시작하고, refresh 를 3회 미리 돌린 뒤 DB 를 내린다. 탐침을 새 IP 로 다시 띄운 뒤 로그인 1회 + refresh 3회를 하되 /tmp/rt 를 매번 갱신한다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
   curl -s -o /dev/null -w "refresh %{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측(observed) — 01-cause-determined.txt

  refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
    로그인   200
    refresh  200        ← A-7 의 표와 정반대다

같은 설정, 같은 명령, 세 개의 답. 무엇이 다른지는 kubectl get 어디에도 안 나온다. 캐시 온도는 보이지 않는 상태다.

   volatile + DB 정지의 결과
     = "무엇을 하느냐"가 아니라
       "그 경로가 이미 캐시를 채웠느냐"

A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의 함정이다. 상태가 결과를 바꾸는데 그 상태가 안 보인다. persistent(기본값)에는 해당하지 않는다 — 세션 자체를 DB 에 쓰므로 DB 가 없으면 캐시 온도와 무관하게 실패한다. 이 조건부성은 volatile 고유의 성질이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.

복구와 원상복구 확인표

세 개를 순서대로 되돌린다. DB 가 살아 있어야 나머지가 된다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s

★ 문장 로깅을 끈다. 잊으면 다음 실험이 전부 오염된다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"
 log_statement
---------------
 none

왜 급한가 — A-3 은 수백 건의 로그인을 최대한 빨리 돈다. log_statement='all' 이면 로그인 하나에 SQL 열 몇 줄씩 쌓인다. 로그가 폭주하고 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.다음 실험의 측정값이 이 설정 때문에 바뀐다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

args 문자열만 보고 끝내지 않는다. 탐침을 새 IP 로 띄우고 로그인을 한 번 한 다음 행을 센다.

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

0 이 아니어야 한다. 로그인 후 행이 생기면 persistent 다. 원래 재현 절차가 마지막에 이 한 줄을 두는 까닭이 이것이다.

항목 명령 돌아왔을 때
문장 로깅 psql -c "show log_statement" none
args get statefulset keycloak -o jsonpath='{...containers[0].args}' ["start"]
DB kubectl -n keycloak-lab get pods -l app=postgres 1/1 Running
동작 로그인 뒤 select count(*) ... 세션 행이 생긴다
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
클러스터 vendor_cluster_size 양쪽 2
탐침 파드 kubectl -n keycloak-lab get pod a7a-probe NotFound
임시 파일 ls /tmp/pg.log 지워도 된다
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
rm -f /tmp/pg.log

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
표식이 로그에 안 보인다 pg_reload_conf() 를 안 했다 show log_statementall 인지
표식 사이가 JGROUPS_PING 으로 가득하다 정상이다. 5초마다 폴링한다 grep -v JGROUPS_PING
표식이 두 번 나온다 로그를 여러 번 받아 구간이 겹쳤다 --tail 을 줄이거나 새 표식 이름을 쓴다
로그 시각이 9시간 어긋난다 컨테이너 로그가 UTC 다 date -u 와 비교한다
refresh 가 400 Session not active 옛 refresh token 을 재사용했다 매번 /tmp/rt 를 갱신
rt 1 bytes 파싱 실패. 빈 토큰을 보내게 된다 cat /tmp/tok 으로 본문 확인
세 재현이 전부 200/200 재시작을 건너뛰어 캐시가 계속 더웠다 재현마다 rollout restart
재현 A 가 200 이 나온다 재시작 후 요청을 한 번이라도 보냈다 재시작 → 바로 DB 정지
재현 B 가 200/200 로그인 뒤 refresh 를 미리 했다 로그인 한 번만 하고 DB 정지
JDBC exception grep 이 빈 출력 --tail 이 짧거나 정규식이 안 맞는다 grep -i 'JDBC exception' 으로 먼저 넓게
재시작 뒤 아무 데도 안 닿는다 파드 IP 가 바뀌었다 탐침을 지우고 새 IP 로 다시 띄운다
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다
다음 실험의 postgres 로그가 폭주한다 문장 로깅을 끄지 않았다 show log_statementnone
다음 실험의 세션이 안 살아남는다 volatile 로 둔 채 끝냈다 로그인 뒤 행 수 확인

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

  • (observed) volatile 전환 확인의 세 줄(args · 로그인 200 · 행수 0), 로그인 구간의 표식 두 줄 11:18:49.461·11:18:49.743 과 그 사이 SQL 0건, refresh 구간의 다섯 줄과 CLIENT_SCOPE_CLIENT 문장 전문 · 파라미터 $1 = '131a9912-b578-4b9c-b16a-97518704077e' · $2 = 'f', REVOKED_TOKEN 0건, 연속 refresh 3회의 표식 네 줄과 SQL 0건, 재현 A 의 400 unauthorized_clientselect ce1_0.ID from CLIENT ... 실패 SQL, 재현 B 의 200 · 500 unknown_errorCLIENT_SCOPE_CLIENT 실패 SQL, 재현 C 의 200 · 200, 비밀번호 길이 19, 표식 시험의 statement: select 'MARK_TEST'.
  • (unknown) 이 편에는 가이드가 미검증으로 표시한 명령이 하나도 없다. 표식을 감싼 셸 함수 m() 은 원 실행이 실제로 썼고(observed), 그것을 한 줄씩 손으로 푸는 형태가 가이드의 권고다.
  • 시각 표기 — 증거 파일과 위 인용이 UTC 다. KST 로는 20:1820:24 이며 PostgreSQL 컨테이너가 UTC 로 찍기 때문이다.
  • A-7 에서 틀린 것으로 확정된 것 — 원인 테이블(REVOKED_TOKEN 가설), 그리고 「volatile 이면 DB 없이 로그인된다」는 서술. 냉시동에서는 로그인부터 실패한다.
  • 이 실험이 재지 않은 것 — 캐시가 얼마나 오래 더운지. CLIENT_SCOPE_CLIENT 결과의 캐시 만료 시간을 모르므로 한참 뒤에 다시 재면 또 다른 답이 나올 수도 있다. 그것까지 확인하려면 재현 C 뒤에 시간을 두고 같은 시험을 반복해야 한다.

A-8 — 배포할 때마다 로그아웃되는가

근거: a8-rolling-restart.md (753줄). 수집 기록은 2026-09-04 13:1913:20 KST(observed).

이 실험이 가르는 것

운영에서 가장 자주 겪는 일이다. 장애가 아니라 정상 작업인데도 사용자가 로그아웃되면 그건 사고다.

   배포한다  →  파드가 교체된다  →  프로세스 메모리가 사라진다
                                        │
                                        └─ 세션이 거기 있었다면?

A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을 세웠다. 그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 한다. 틀리다면 배포가 곧 전원 로그아웃이다.

예측
A-0 모델 (persistent) 재시작해도 세션 생존
옛 방식 (volatile) 재시작하면 전원 로그아웃

둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다. 그리고 이 실험은 가용성도 같이 잰다 — 세션이 살아도 재시작 중에 서비스가 끊기면 그것대로 문제다.

가이드의 「이 가이드가 끝나면」 표는 이렇게 적는다 — 파드가 전부 교체되는 동안 외부가 계속 200 인 것을 5초 간격 curl 시계열에서, 재시작 전에 발급한 토큰이 재시작 후에도 통하는 것을 상주 탐침 파드에서, DB 세션 수가 그대로인 것을 PostgreSQL OFFLINE_USER_SESSION 에서, 캐시만 0 으로 비워지는 것을 Prometheus approximate_entries_unique 에서, 클러스터가 스스로 다시 붙는 것을 vendor_cluster_size 에서, 「무중단」이 관측 해상도에 달려 있다는 것을 표본이 9개뿐인 시계열에서.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • A-0 을 먼저 하면 좋다. 「세션은 DB 에 있고 캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
  • 터미널 두 개를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다), 하나는 재시작·관찰용.

이건 파괴적이지 않다. 그래서 더 조심한다. 가이드의 경고를 그대로 옮긴다 — rollout restart정상 작업이고 되돌릴 것이 없으며 잘못돼도 클러스터가 스스로 회복한다. 전 구간 약 15~20분이다.

그래서 함정이 다르다. 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고, 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다. 실제로 원래 실행이 그랬다. 그리고 다른 실험과 겹치지 않게 한다 — 롤링 재시작 중에 다른 주입이 들어가 있으면 무엇 때문에 무엇이 일어났는지 구별되지 않는다.

정말 되돌려야 하면 이 명령이 있다. 다만 중간에 rollout statusCtrl-C 로 끊어도 롤아웃 자체는 계속 진행되므로 끝날 때까지 두는 편이 낫다.

kubectl -n keycloak-lab rollout undo statefulset/keycloak

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

파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
kubectl -n keycloak-lab get pods -o wide

모양은 이렇고 값은 환경마다 다르다(observed).

NAME                        READY  STATUS   RESTARTS  AGE  IP           NODE
keycloak-0                  1/1    Running  0         2d   10.42.1.94   kc-lab-2
keycloak-1                  1/1    Running  0         2d   10.42.0.45   kc-lab-1
postgres-7b474b88c8-t6rrf   1/1    Running  0         5d   10.42.0.22   kc-lab-1

READY 가 둘 다 1/1, RESTARTS0, 그리고 AGE — 이 값을 적어 둔다. 재시작 후 이 값이 초 단위로 바뀌는 것이 「정말 재시작됐다」의 증거다. replica 가 2 인 것도 본다. 무중단의 전제이고 1 이면 반드시 끊긴다.

rollout restart 는 파드를 삭제하고 새로 만든다. 그래서 RESTARTS안 오른다. 재시작 여부를 RESTARTS 로 보면 「아무 일도 안 일어났다」로 읽는다. AGE 로 본다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

args 가 ["start"] 인 것이 이 실험의 전제다.

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

플래그가 없으므로 persistent-user-sessions 가 기본으로 켜져 있다. --features-disabled=persistent-user-sessions 가 붙어 있으면 이 실험은 정반대 결과를 낸다 — 그건 A-7 이다. 앞 실험이 되돌리지 않고 끝냈다면 여기서 잡힌다.

DB 세션 수를 적어 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

실측(observed) — 01-restart-availability.txt

  DB 세션 수: 151

이 숫자를 적어 둔다. 재시작 후 같은 값이 나오는 것이 뒤의 판정이다. 숫자는 환경마다 다르고 관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다. 그래서 이 실험은 개수 말고 특정 sid 하나를 따로 추적한다.

상주 탐침 파드는 StatefulSet 밖에 있어야 한다. 토큰을 재시작 전에 받아서 재시작 후에 써야 하기 때문이다.

   토큰을 어디에 두나
     ├─ Keycloak 파드 안        → 같이 죽는다. 못 쓴다
     ├─ 내 셸 변수              → 되지만 화면·히스토리에 남는다
     └─ 단독 탐침 파드의 /tmp   → StatefulSet 과 무관하게 산다   ★
kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" --env="K1=$K1" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s

비밀번호를 화면에 찍지 않는다. 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측(observed) — 19

kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'

PW길이=0 이면 --env 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.

★ 토큰을 파드 안에 보관하는 이 단계가 이 실험의 함정이다.

kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p"  /tmp/tok \
     | cut -d. -f2 | base64 -d 2>/dev/null \
     | sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
   echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'

실측(observed) — 01-restart-availability.txt

=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
  sid = XLcgQWRiJrTkuNZcJsNeT_2j

두 값이 다 채워졌는지를 본다.

출력
rt 1188 bytes / sid XLcg... 정상
rt 1 bytes 빈 문자열 + 개행. 파싱 실패
sid 가 비어 있음 base64 패딩 때문에 잘렸다. sid 없이 진행하고 판정은 개수로 본다

가이드는 원래 실행이 실제로 빠진 함정을 적어 둔다 — 처음 재현 절차는 /tmp/tok 에 쓰고 /tmp/rt 를 읽었다. /tmp/rt 를 만드는 줄이 빠져 있었다. 그러면 빈 문자열이 refresh_token= 으로 전송되는데, 그래도 400 이 아니라 통과한 것처럼 보였다. 이 실험의 판정이 「재시작 후 refresh 가 200 인가」이므로 빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로 거짓이 된다. 그리고 그 오류는 아무 에러도 안 낸다. 그래서 길이를 찍는다. wc -c 한 번이 이 실험 전체를 지킨다.

못 미더우면 파일을 직접 본다.

kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo

모양은 이렇고 값은 환경마다 다르다(observed).

-rw-r--r--    1 curl_use curl_gro      1188 Sep  4 13:19 /tmp/rt
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU

/tmp/rt 의 크기가 네 자리이고 내용이 eyJ 로 시작한다. eyJ 는 base64 로 인코딩된 {" 이고 JWT 는 전부 이렇게 시작한다.

대조군은 재시작 전에 refresh 가 되는 것이다. 이 관측을 건너뛰면 뒤의 200 이 아무 의미가 없다.

kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'
200

★ 이 refresh 로 토큰이 회전했다. /tmp/rt 의 값은 이제 쓰인 토큰이다. 다시 채워 둔다. 안 그러면 뒤의 400 이 「재시작 때문」인지 「재사용 때문」인지 구별되지 않는다. 위의 로그인 명령을 그대로 다시 쳐서 /tmp/rt/tmp/sid 를 새로 만들고, 이 sid 가 최종 추적 대상이다. 적어 둔다.

그 세션이 DB 에 실제로 있는지 지금 본다. 가이드의 질의는 sid 를 셸 치환으로 집어넣는다 — psql -c 문자열 안에 kubectl exec 이 한 번 더 들어간다.

이 실험대는 이렇게 했다(observed)

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, created_on, last_session_refresh from offline_user_session
      where offline_flag='0' and user_session_id='$(kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"

따라 하는 사람은 방금 적어 둔 sid 를 그대로 친다. 앞 명령이 이미 sid = XLcgQWRiJrTkuNZcJsNeT_2j 를 화면에 보여 줬으므로 값을 새로 뽑을 필요가 없고, 명령 하나가 한 가지 일만 한다. 이 형태는 이 실험대에서 치지 않았다(unknown).

kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, created_on, last_session_refresh from offline_user_session
      where offline_flag='0' and user_session_id='XLcgQWRiJrTkuNZcJsNeT_2j'"

모양은 이렇고 값은 환경마다 다르다(observed).

     user_session_id      | created_on | last_session_refresh
--------------------------+------------+----------------------
 XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 |           1788495513
(1 row)

행이 1개 있고 created_onlast_session_refresh같다. 아직 갱신한 적이 없다. 재시작 후에 이 행이 그대로 있고 last_session_refresh 만 올라가는 것이 뒤의 판정이다.

캐시와 클러스터 크기도 미리 본다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

한 줄짜리 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다. 모양은 이렇고 값은 환경마다 다르다(observed).

{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757040000.1,"2"]},
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757040000.1,"2"]}]}}

라벨을 보고 나면 읽기 좋게 자른다. 가이드가 미검증으로 표시한 줄이다(unknown).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'

결과가 두 줄이고 값이 둘 다 2 다. 세션 캐시 엔트리 수도 같은 형태로 본다. 0 이 아닌 값이 나오고, 재시작 후 0 이 되는 것이 뒤의 판정이다.

주입

가용성 감시를 먼저 띄운다. 두 번째 터미널에서 돌리고, 재시작보다 먼저 시작해야 끊김 구간을 놓치지 않는다.

for i in $(seq 1 48); do
  printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 \
    --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
  sleep 5
done
echo

숫자가 5초마다 하나씩 붙는다. 200 이 아닌 값이 보이면 거기가 끊김이다. 여기서 -w '%{http_code}' 를 쓰는 까닭은 48번 반복해서 비교할 값만 필요하기 때문이다. 무엇이 잘못됐는지 알아보려면 그때 curl -v 로 한 번 보면 된다. --max-time 4 는 5초 간격보다 짧게 잡은 것이다 — 타임아웃이 간격보다 길면 요청이 밀려 시계열이 어긋난다.

첫 번째 터미널에서 재시작한다.

date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s

실측(observed) — 01-restart-availability.txt

statefulset.apps/keycloak restarted
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...

위 원문은 두 터미널의 출력이 한 파일에 섞여 기록된 것이다. 200 이 가용성 루프, Waiting for...rollout status 다. 0 out of 21 out of 2complete 로 한 번에 하나씩 가고 그 사이사이에 200 이 계속 찍힌다. 시각을 반드시 적어 둔다.

주입 검증

「세션이 살아남았다」는 결론은 파드가 진짜 바뀌었을 때만 의미가 있다.

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

실측(observed) — 02-session-survival.txt

=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0                  1/1   Running   0     44s
keycloak-1                  1/1   Running   0     66s

세 가지를 본다.

  • AGE 가 초 단위다 — 앞에서 2d 였던 것이 44s 다. 진짜 새 파드다
  • 두 나이가 다르다(44s vs 66s) — 한 번에 하나씩 내렸다는 증거다. 22초 차이가 롤링의 간격이다. 둘이 같으면 동시에 내려간 것이고 무중단이 아니다
  • RESTARTS여전히 0 — 파드가 재시작된 게 아니라 교체됐다

RESTARTS 를 판정에 쓰면 안 된다는 것이 여기서 보인다. rollout restart 는 파드를 지우고 새로 만들므로 재시작 카운터는 새 파드에서 0 부터 시작한다.

★ 파드 IP 가 바뀌었다. 다시 잡는다. ★ 탐침 파드는 다시 띄우면 안 된다/tmp/rt/tmp/sid 가 같이 사라진다. 탐침 안의 K0 환경변수는 낡았으므로 새 IP 를 명령줄에 직접 넘긴다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"

가용성 시계열도 이때 읽는다. 두 번째 터미널의 출력이다.

실측(observed) — 01-restart-availability.txt

  (위 숫자열이 재시작 중 외부 응답 코드의 시계열)
200  200 200 200 200  200 200 200 200

200 이 9개. 비200 이 없다. ★ 그런데 「무중단」이라고 쓰기 전에 표본 수를 본다.

   9개 표본 × 5초 간격  =  약 45초를 9번 들여다본 것
        │
        └─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다

실제로 더 촘촘히 재니 끊김이 나왔다. 후속 작업에서 1초 간격·3초 타임아웃으로 D-2 롤백 전환을 재본 값이 이렇다.

실측(observed) — experiment-followup-untested-items.md 2절

200 ×24  000  200 ×19

000 은 서버 오류가 아니라 --max-time 3 타임아웃이다. 파드 전환 순간 요청 하나가 3초를 넘겼다.

쓰면 안 되는 문장 정확한 문장
「무중단이었다」 5초 해상도에서 끊김이 관측되지 않았다

더 촘촘히 보고 싶으면 루프를 이렇게 바꾼다. 가이드가 미검증으로 표시한 형태다(unknown).

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

관찰

본 시험은 재시작 전 토큰이 아직 통하는가다. 새 파드 IP 로, 파드 안에 보관해 둔 토큰을 쓴다. 셸 인용이 세 겹이 되는 형태이고, 가이드는 여기에 다른 형태를 제시하지 않는다 — 탐침을 다시 띄우면 토큰이 사라지기 때문이다.

kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측(observed) — 02-session-survival.txt

=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
  대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
  keycloak-0 에서 refresh  HTTP 200

200, 그리고 본문에 새 토큰이 들어 있는 것을 본다. 파드가 통째로 바뀌었는데 세션이 그대로다. 새로 뜬 프로세스는 이 세션을 메모리에서 알던 것이 아니다. DB 에서 읽었다.

400 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다. 대조군 시험 뒤에 /tmp/rt 를 다시 안 채웠거나(이미 쓴 토큰이다), rt 1 bytes 를 놓쳤거나(빈 문자열을 보내고 있다), args 에 --features-disabled=persistent-user-sessions 가 있거나(그건 A-7 이다). 셋 다 아니면 그때 결론을 의심한다.

DB 에 그 세션이 남아 있는지 sid 로 정확히 본다. 질의는 앞에서 쓴 것과 같고, 적어 둔 sid 를 그대로 넣는다.

실측(observed) — 02-session-survival.txt

=== [4] DB 에 그 세션이 남아 있는가 ===
     user_session_id      | created_on | last_session_refresh
--------------------------+------------+----------------------
 XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 |           1788495577
(1 row)

두 숫자의 차이를 본다.

   1788495577 - 1788495513 = 64초
        │            │
        │            └─ 재시작 전에 세션이 만들어진 시각
        └─ 재시작 후의 refresh 가 기록된 시각

응답 코드만 200 인 게 아니라 쓰기까지 정상이다. 새 파드가 DB 에서 세션을 읽었고 갱신 시각을 DB 에 되썼다. 200 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다 — A-1 에서 실제로 그런 일이 있었다. 여기서는 DB 행이 갱신됐으므로 그 가능성이 없다. 두 값은 유닉스 시각(초)이라 사람이 읽는 형태로 보려면 이렇게 친다.

date -d @1788495513 ; date -d @1788495577

전체 세션 수도 함께 본다.

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

실측(observed) — 02-session-survival.txt

  전체 온라인 세션: 151 (재시작 전 151)

앞에서 적어 둔 값과 같다. 한 건도 안 잃었다. sid 하나가 살아남은 것과 전체가 살아남은 것은 다른 주장이고 둘 다 봐야 한다. 관리 API 호출이 세션을 만들기 때문에 몇 건 늘어날 수는 있고, 크게 줄었다면 그게 문제다.

캐시는 사라진다. 그게 정상이다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
  | tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'

실측(observed) — 02-session-survival.txt

=== [5] 캐시는 어떻게 되었는가 ===
  keycloak-0  sessions 캐시 0.0 건 / cluster_size 2.0
  keycloak-1  sessions 캐시 1.0 건 / cluster_size 2.0

세 가지를 본다.

  • 캐시가 0 이다 — 프로세스 메모리라 재시작에 사라졌다
  • keycloak-1 의 1건 — 방금 refresh 를 처리하며 새로 담은 것이다. 0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다
  • cluster_size 가 다시 2 — 클러스터가 스스로 재형성됐다

A-0 의 모델이 그대로 확인된다.

   재시작 전:  캐시 N건  +  DB 151건
   재시작 후:  캐시 0건  +  DB 151건      ← 진실은 DB 에 있다

캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다. 룩어사이드 캐시의 성질이다. 같은 것을 그림으로 본 화면이 증거에 있다 — a8-cache-reset-cluster-reformed.png.

왜 무중단이 되는가는 엔드포인트의 움직임이 답한다.

   StatefulSet 롤링 재시작
     │
     ├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
     │     └─ 이 동안 keycloak-0 이 전부 받는다
     ├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
     │
     └─ keycloak-0 종료 → ... (반복)

실제로 그렇게 움직였는지는 재시작 중에 봐야 보인다.

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 get endpoints 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜨고 endpointslice 를 본다.

한 번에 하나씩 내리므로 항상 최소 하나는 Ready 이고, readiness 프로브가 이 전환을 정확히 맞춰준다. A-2 에서 「장애를 격리하는 장치」로 본 그 메커니즘이 여기서는 정상 작업을 안전하게 만든다.

무중단의 조건 빠지면
replica ≥ 2 하나뿐이면 내리는 동안 아무도 안 받는다
readiness 프로브 아직 기동 중인 파드로 트래픽이 간다

둘 다 있어야 성립한다. 이 실험대는 파드가 2개라서 됐다.

복구와 원상복구 확인표

주입이 정상 작업이었으므로 되돌릴 것이 없다. 정리만 한다.

kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found

남겨 두면 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 네임스페이스에 정체 모를 파드가 하나 있는 상태가 된다. 지운다.

항목 명령 돌아왔을 때
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
클러스터 뷰 kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2)
지표 vendor_cluster_size 양쪽 2
세션 psql -tAc "select count(*) from offline_user_session where offline_flag='0'" 재시작 전과 비슷한 값
탐침 파드 kubectl -n keycloak-lab get pod a8-probe NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
refresh 가 200 인데 뭔가 이상하다 /tmp/rt 가 비어 있다. 빈 토큰인데 통과한 것처럼 보인다 wc -c < /tmp/rt
refresh 가 400 Session not active 대조군 시험 뒤에 /tmp/rt 를 안 채웠다. 이미 쓴 토큰이다 새로 로그인해서 다시 담는다
refresh 가 400 인데 토큰은 맞다 args 가 volatile 이다 get statefulset ... args. 그건 A-7
재시작 후 아무 데도 안 닿는다 파드 IP 가 바뀌었다 get pod -o jsonpath='{.status.podIP}' 다시
탐침을 다시 띄웠더니 토큰이 없다 /tmp/rt 가 파드와 함께 사라졌다 탐침은 재시작 내내 유지한다
RESTARTS 가 0 이라 재시작이 안 된 것 같다 rollout restart 는 파드를 교체한다 AGE 로 본다
rollout status 가 타임아웃 파드가 Ready 를 못 받는다 describe pod 의 Events, logs --previous
가용성 루프에 000 이 섞인다 --max-time 초과. 서버 오류가 아니다 간격보다 짧은 타임아웃인지
가용성 루프가 전부 000 루프가 잘못된 URL 을 친다 curl -v 로 한 번 본다
세션 수가 크게 줄었다 다른 실험이 세션을 지웠거나 volatile 이다 args 와 DB 세션 수를 다시
DB 행의 last_session_refresh 가 안 올랐다 본 시험을 하기 전에 조회했다 순서: refresh → 조회
kubectl get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=...
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다

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

  • (observed) 재시작 전 DB 세션 151sid = XLcgQWRiJrTkuNZcJsNeT_2j, 비밀번호 길이 19, rollout status 와 가용성 루프가 섞인 출력 전문, 재시작 뒤 파드 나이 44s/66sRESTARTS 0, 가용성 시계열 200 아홉 개, 재시작 전 토큰의 HTTP 200, DB 행의 17884955131788495577, 전체 세션 151 (재시작 전 151), 캐시 0.0/1.0cluster_size 2.0, 후속 작업의 200 ×24 000 200 ×19.
  • (unknown) tr ',' '\n' | grep -E 로 자른 Prometheus 출력과 1초 간격·3초 타임아웃 루프. 가이드가 둘 다 미검증으로 표시했다. sid 를 화면에서 읽어 질의에 직접 넣는 두 단계 형태도 이 실험대에서 치지 않았다.
  • 원래 실행이 실제로 빠졌던 곳 — 첫 재현 절차에 /tmp/rt 를 만드는 줄이 없었다. 빈 문자열이 refresh_token= 으로 전송됐는데 400 이 아니라 통과한 것처럼 보였고 아무 에러도 안 났다.
  • 해상도에 걸린 주장 — 「무중단」이 아니라 **「5초 해상도에서 끊김이 관측되지 않았다」**이다. 표본은 9개다. 1초 간격으로 잰 후속 작업은 다른 조건(D-2 롤백 전환)에서 000 을 하나 잡았다.
  • 이 실험이 재지 않은 것 셋 — replica 1 에서 어떻게 되는지(반드시 끊긴다고 적었지만 재지 않았다), 5초보다 짧은 끊김, 캐시가 0 에서 다시 차는 데 걸리는 시간(「첫 접근만 느려진다」고 썼지만 그 느림을 재지 않았다. A-6 이 인접한 주제다).

B층 재현 절차 — 아홉 편을 직접 치는 순서

앞의 B층 절들은 무엇을 발견했는지를 적었다. 여기부터는 그 발견을 다시 만들려면 무엇을 어떤 순서로 치는가다. 근거는 ../source/docs/guides/experiments/ 의 B층 아홉 편이고, 파일 하나가 아래 절 하나에 대응한다.

근거 파일 무엇을 가르나
B-0 BFF·Redis 배포 b0-bff-redis-deploy.md 830 아무것도 주지 않으면 Spring 이 무엇을 고르는가
B-1 세션 저장소 전환 b1-redis-session-store.md 852 Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가
B-2 다중 인스턴스 b2-multi-instance-session.md 869 저장소를 옮겨도 안 고쳐지는 것이 무엇인가
B-3 refresh 경쟁 b3-refresh-token-contention.md 835 같은 refresh token 을 동시에 던지면 무엇이 부서지는가
B-4 Edge 인가 범위 b4-edge-authorization-scope.md 914 신원 헤더를 위조해 보내면 그대로 도착하는가
B-5 Redis 상실 b5-redis-loss-persistence.md 883 Redis 를 내려도 파드가 Ready 인 채로 계속 실패하는가
B-6 키 회전 b6-key-rotation.md 708 서명 키를 회전하고 옛 키를 버리면 무엇이 끊기는가
B-7a 고아 세션 b7a-orphan-session.md 699 고아 세션을 TTL 로 골라내 지울 수 있는가
B-7 쿠키 시크릿 회전 b7-cookie-secret-rotation.md 767 cookie secret 을 갈아치우면 로그인해 있던 사람에게 무슨 일이 나는가

A층과 뼈대가 같다. 기준선주입주입 검증관찰복구 이고, 아래 절들도 그 순서로 적는다. 주입 검증 을 따로 세우는 까닭도 A층과 같다 — 주입이 조용히 실패하면 「아무 일도 없었다」가 「영향이 없다」와 구별되지 않는다. B층에서는 이 실패가 다른 모습으로 온다. B-1 은 의존성 두 개 중 하나만 넣으면 오류 없이 in-memory 로 남고, B-3 은 & 를 빼면 다섯 요청이 전부 200 으로 나오는데 그것은 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.

가이드가 출력에 붙인 표시는 A층과 같은 셋이고, 뜻은 각 편의 표시 규약 표에 있다.

가이드의 표시 가이드가 적은 뜻 이 문서에서
실측 수집 기록의 출력 원문. 증거 파일에 그대로 있다 (observed)
형태 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 모양은 (observed), 숫자는 환경마다 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 (unknown)

어느 기계에서 치는가가 A층과 똑같이 어긋난다. 아홉 편 중 여덟(B-4 를 뺀 전부)의 전제가 「명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다」인데, 같은 폴더의 README.md 는 반대로 적는다. 아래 절들은 README 를 따른다 — sudo 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 보고 localhost:8080 으로 붙으려다 끝난다. 반입한 B층 아홉 편은 본문 명령 블록에 sudo kubectl 을 한 번도 쓰지 않는다(observed) — 전제 한 줄만 옛 형태로 남았고, 그 점도 A층과 같다.

B층은 A층과 다른 것이 넷 있다.

무엇 A층 B층
무엇을 건드리나 클러스터·네트워크·DB 애플리케이션 소스와 매니페스트
되돌리기 주입을 되돌린다 git checkout 한 뒤 다시 빌드해 두 노드에 다시 밀어 넣는다
브라우저 필요 없다 B-3 을 뺀 셋은 브라우저가 있어야 한다. 인가 코드 흐름은 왕복이 두 번이라 curl 로 대신할 수 없다
이미지 이미 떠 있다 레지스트리가 없어 imagePullPolicy: Never 다. 두 노드에 각각 import 해야 두 replica 가 다 뜬다

jq 가 이 실험대에 없는 것은 A층과 같다. B층은 JSON 을 많이 읽는데도 grepredis-cli 로만 읽고, 그 대신 길고 미검증인 grep·sed이 여러 번 나온다. 가이드가 그것을 전부 미검증으로 표시해 두었으므로 아래에서도 두 형태를 나란히 적는다.

아래 절들은 절차만 옮긴 것이다. 무엇을 발견했는지는 이 문서 앞쪽에 이미 있고, 여기 실린 명령과 출력은 전부 가이드 원문에서 왔다. 가이드에 없는 명령은 넣지 않았고, 가이드가 규범을 어긴 곳은 두 형태를 나란히 적었다.

버전 문자열은 아홉 편 중 다섯 편에만 있다 (observed). 출력에 판 번호가 찍히는 명령을 그 편이 쳤을 때만 남았기 때문이고, 나머지 넷은 원본 가이드에도 없다.

그 편의 출력에 찍힌 것
B-0 · B-1 · B-2 keycloak-pattern-bff:lab (빌드 태그)
B-1 redis_version:7.4.x
B-3 curlimages/curl:8.11.1
B-4 curl/8.5.0 — echo 앱이 되돌려준 user-agent
B-5 netty-transport-4.1.135.Final — 스택트레이스
B-6 · B-7 · B-7a 없다

아홉 편은 같은 실험대에서 이어 돌았다. 편마다 전제가 「B-0 가 끝나 BFF 가 떠 있다」로 앞 편을 요구하고, kc-lab-1curl 과 같은 Redis 를 계속 쓴다. 그래서 판 번호가 안 찍힌 편의 버전을 물을 때는 같은 실험대의 다른 편이 찍은 값을 본다 — 그 편이 직접 잰 값이 아니라는 뜻이다 (inferred).

Keycloak 26.7.0 은 A층에만 글자로 있고 B층 아홉 편 어디에도 찍히지 않았다.

B-0 — 아무것도 주지 않으면 Spring 이 무엇을 고르는가

근거: b0-bff-redis-deploy.md (830줄). 실행 기록은 2026-09-04 13:3913:46 KST(observed).

이 실험이 가르는 것

Q1 이 직접 요구한 확인이다. 가이드는 Q1 의 문장을 그대로 인용해 시작한다.

코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로 사용되는지는 Spring Boot 의 자동구성 결과까지 확인해야 정확하게 알 수 있다.

   빈을 직접 만들지 않으면
        └─ Spring Boot 가 조건에 따라 고른다
             └─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
                  └─ 돌아가는 인스턴스에 물어봐야 안다

추측으로도 답은 나온다. 「저장소를 안 붙였으니 메모리겠지.」 맞다. 그런데 빈 이름 하나가 이 층 전체의 문제를 담고 있고, 그 이름은 추측으로 안 나온다. 찍어 봐야 나온다.

가이드의 「이 가이드가 끝나면」 표는 여섯을 적는다 — 돌고 있는 인스턴스가 실제로 고른 구현체 이름, Redis 도 Spring Session 도 하나도 구성되지 않은 것, 조회 키에 session ID 가 없다는 것, replica 2 에서 로그인 자체가 실패하는 것, replica 를 1 로 줄이면 되는 것, 브라우저에 토큰이 0개인 것.

★ 저장소를 먼저 붙이면 이 실험은 성립하지 않는다. Redis 를 먼저 연결하면 잴 것이 없어진다. Redis 는 배포만 하고 BFF 에 연결하지 않는다. 연결은 B-1 에서 한다.

전제와 되돌리기

  • 05-keycloak 이 끝나 있다. A층 실험은 안 해도 된다.
  • 브라우저가 필요하다. https://app1.hyeonworks.com/ 이 열려야 한다.
  • BFF 이미지는 워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.
  • jq 는 이 실험대에 깔려 있지 않다. 이 가이드는 grep 으로 읽는다.

★ 저장소의 현재 소스는 이미 B-1·B-2 를 거친 뒤 상태다. bff-redis.yaml 에는 SPRING_SESSION_STORE_TYPE=redis 가 있고, SecurityConfig 에는 JdbcOAuth2AuthorizedClientService 빈이 있다. 그대로 배포하면 B-2 의 결과를 재게 된다. 가이드는 어느 브랜치에도 B-0 시점의 파일이 없다고 적는다 — 그래서 주입 절의 첫 단계가 손으로 되돌리는 일이다.

전 구간 약 40~60분(빌드 시간 포함). 되돌리기는 둘이고, 둘 다 먼저 읽어 둔다.

git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
  bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
  deploy/lab/k8s/bff-redis.yaml
kubectl delete -f deploy/lab/k8s/bff-redis.yaml

★ PVC 는 delete -f 로 같이 지워진다. Redis 데이터도 사라진다.

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

넓은 것부터 좁혀 간다.

노드 자원 → 네임스페이스에 무엇이 있나 → 이미지가 두 노드에 있나 → Keycloak realm
free -m
kubectl top nodes

실측(observed) — 01-deploy.txt

=== 배포 전 자원 ===
Mem:           11648        7329         280           4        4377        4319
NAME       CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
kc-lab-1   115m         5%       2192Mi          44%
kc-lab-2   121m         6%       1324Mi          33%

노드 메모리 사용률이 44% · 33% 다. BFF 는 JVM 이고 replica 가 2 이며, 매니페스트는 requests: 320Mi · limits: 512Mi 로 잡혀 있다. 여유가 없으면 파드가 Pending 이거나 OOM 으로 죽는데, 그것을 「Spring 설정 문제」로 읽게 된다.

kubectl -n keycloak-lab get all
kubectl -n keycloak-lab get secret,ingress

keycloak StatefulSet 과 postgres 가 있고 bff · redis 는 없어야 한다. 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면 「내가 만든 것」과 「원래 있던 것」이 섞인다. 줄을 둘로 나눈 까닭은 get all 이 워크로드 계열만 보여 주기 때문이다 — Secret·PVC·Ingress 는 안 나온다.

realm 과 클라이언트가 없으면 배포는 성공하는데 로그인에서 막힌다. kcadm 은 Keycloak 이미지 안에 있다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측(observed) — 19

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create clients -r keycloak-patterns \
  -s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
  -s 'redirectUris=["https://app1.hyeonworks.com/*"]'

★ B-0 을 그대로 밟으면 B-3 이 못 돈다(2026-09-17, observed). 이 클라이언트와 사용자에 두 가지가 빠져 있고, B-0 의 로그인은 인가 코드 흐름이라 B-0 을 끝까지 밟아도 안 드러난다.

B-3 의 5번에서 나오는 것 무엇이 빠졌나 어디서 고치나
unauthorized_client · Client not allowed for direct access grants 클라이언트의 directAccessGrantsEnabledfalse create clients-s directAccessGrantsEnabled=true

고친 뒤의 클라이언트 생성은 이 줄이 하나 더 붙는다.

  -s directAccessGrantsEnabled=true \

| invalid_grant · Account is not fully set up | labuseremailVerifiedfalse 이고 이메일·이름 칸이 비었다 | 사용자 생성 뒤 아래 한 묶음 |

U=$(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 | tr -d '\r')
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$U -r keycloak-patterns \
  -s emailVerified=true -s email=labuser@example.invalid \
  -s firstName=Lab -s lastName=User -s 'requiredActions=[]'

변수 이름을 UID 로 짓지 않는다 — zsh 에서 읽기 전용 숫자 변수라 bad math expression 으로 끝난다(observed).

그리고 B-0 이 「없다」고 적어 둔 대조를 만들었다. 매니페스트의 KEYCLOAK_CLIENT_SECRET 과 Keycloak 쪽 클라이언트 비밀이 같은지를 값을 안 찍고 본다. 2026-09-17 실측은 매니페스트 14자 · Keycloak 14자두 값이 같다 였다(observed).

A=$(kubectl -n keycloak-lab get secret bff-secrets \
      -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
C=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
      get clients -r keycloak-patterns -q clientId=bff-confidential \
      --fields id --format csv --noquotes | tr -d '\r')
S=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
      get clients/$C/client-secret -r keycloak-patterns \
      --fields value --format csv --noquotes | tr -d '\r')
echo "매니페스트 ${#A}자 · Keycloak ${#S}자"
[ "$A" = "$S" ] && echo "두 값이 같다" || echo "두 값이 다르다"

accessTokenLifespan=60B-3 을 위해 미리 짧게 잡는 것이다. 만료를 기다리는 시간이 짧아야 refresh 경쟁이 재현된다. 여기서 정해 두면 나중에 realm 을 다시 안 만든다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan

로그인할 사용자도 하나 만든다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create users -r keycloak-patterns -s username=labuser -s enabled=true
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  set-password -r keycloak-patterns --username labuser --new-password 'lab-user-change-me'

★ 이 비밀번호는 브라우저에 직접 칠 것이므로 따라 하는 사람이 정한다. 위 값은 예시이고, 실제로 쓸 값을 셸 히스토리에 안 남기려면 kcadm.sh 를 대화식으로 쓰거나 나중에 관리 콘솔에서 바꾼다고 가이드는 적는다.

★ 뒤의 편들이 이 값을 그대로 쓴다. 이 실험대는 labpass 를 썼고, B-3 과 B-6 의 토큰 요청이 -d password=labpass 로 그 값을 박아 놓고 있다. 여기서 다른 값을 정했으면 그 자리들도 같이 바꿔야 한다 — 안 바꾸면 B-3 의 첫 토큰 요청이 401 로 떨어지고, 그것이 주입이 안 걸린 것처럼 보인다.

realm 을 통째로 지우는 것이 이 단계의 되돌리기다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete realms/keycloak-patterns

주입

주입은 둘이다. 첫째는 소스를 B-0 상태로 되돌리는 것이고, 둘째가 배포다.

되돌릴 파일은 넷이고, 넷 다 편집기로 연다. 무엇을 왜 지우는지 읽으면서 고쳐야 하는 파일이라 셸로 만들지 않는다.

vim bff/pom.xml
지울 의존성
spring-session-data-redis 있으면 SessionRepository 가 Redis 로 갈린다 (B-1)
spring-boot-starter-data-redis 있으면 Redis 연결 빈이 잔뜩 생긴다 (B-1)
spring-boot-starter-jdbc · postgresql · h2 B-2 의 JdbcOAuth2AuthorizedClientService
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
@Bean
OAuth2AuthorizedClientService authorizedClientService(...) { ... }

// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }

관련 import(JdbcOAuth2AuthorizedClientService, JdbcOperations, 매니저 계열)도 같이 지운다. bffSecurity 빈은 남긴다/actuator/** 를 열어 주는 것이 그 안에 있고, 그게 없으면 관찰 절이 전부 로그인 페이지를 받는다.

vim bff/src/main/resources/application.yml
지울 블록
spring.session store-type 기본값이 redis 다. 남겨 두면 의존성만 빼도 경고가 난다
spring.data.redis Redis 연결 설정
spring.datasource · spring.sql.init B-2 의 JDBC 용
vim deploy/lab/k8s/bff-redis.yaml
# 지운다 — B-1 · B-2 가 넣은 것
- name: SPRING_SESSION_STORE_TYPE
- name: REDIS_HOST
- name: REDIS_PORT
- name: BFF_DB_URL
- name: BFF_DB_USER
- name: BFF_DB_PASSWORD

Redis Deployment·Service·PVC 는 그대로 둔다. 배포는 하되 연결만 안 하는 것이 B-0 의 구성이다. 무엇을 지웠는지는 눈으로 본다.

git diff --stat
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java

빌드는 전체 로그를 파일로 받는다. docker build 기본 출력은 마지막 몇 줄만 보여주고, Maven 스택트레이스는 그 위에 있다.

docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log

실측(observed) — 원래 실행이 만난 것

org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys

management: 아래에 endpoint: 블록을 하나 더 넣어서 난 오류였다. 이미 있는데 또 넣은 것이다. yamllint 는 이 실험대에 없고, YAML 중복 키는 빌드가 잡아 준다 — 다만 그 메시지를 보려면 위처럼 전체 로그를 받아야 한다.

이미지는 레지스트리 없이 두 노드에 각각 들어가야 한다.

이 실험대는 이렇게 했다(observed)

docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"

따라 하는 사람은 한 줄에 두 겹 ssh 와 원격 셸의 인용을 겹쳐 놓지 않을 수 있다. 다만 가이드는 나눈 형태를 적어 두지 않았다 — 여기 없는 명령을 지어내지 않으므로 그 형태는 이 문서에도 없다(unknown). 겹친 인용이 실제로 어떻게 깨지는지는 A-0 의 측정 실패 기록에 남아 있다.

ssh kc-lab-1 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'

★ 원 가이드는 앞 줄을 kc-lab-1 안에서 직접 쳤다(2026-09-17, observed). 그 기계에는 kc-lab-2 의 호스트 키가 없어 뒤 줄이 Host key verification failed 로 끝난다. 두 줄 다 lab host 에서 보내면 양쪽이 다 나온다.

sudo k3s ctr images ls | grep keycloak-pattern-bff
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'

한쪽에만 있으면 그 노드에 스케줄된 replica 만 뜨고, 나머지는 ErrImageNeverPull 로 나타난다.

두 번째 주입이 배포다.

kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s

실측(observed) — 01-deploy.txt

=== 배포 ===
secret/bff-secrets created
deployment.apps/redis created
service/redis created
deployment.apps/bff created
service/bff created
ingress.networking.k8s.io/bff created

deployment "redis" successfully rolled out
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
deployment "bff" successfully rolled out

배포한 구성은 이렇게 생겼다.

   브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
                                              │
                                              ├──▶ Keycloak  (realm: keycloak-patterns)
                                              └──▶ echo      (resource server 대역)

   redis  ── kc-lab-2 (postgres 와 같은 노드)  ← 아직 연결하지 않았다

브라우저가 가는 주소와 BFF 가 서버끼리 부르는 주소를 나눠 둔 것도 이 매니페스트다.

authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth   # 브라우저가 간다
token-uri:         ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token  # BFF 가 서버끼리
- name: KC_ISSUER_EXTERNAL
  value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
  value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns

섞으면 리다이렉트가 깨진다. SERVER_FORWARD_HEADERS_STRATEGY=native 도 같은 이유로 들어 있다 — 없으면 Spring 이 redirect_urihttp:// 로 만들고 Keycloak 이 거부한다.

주입 검증

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

kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab get pods -o wide -l app=redis

실측(observed) — 01-deploy.txt

bff-574c6d658b-8cz4x        true   kc-lab-1
bff-574c6d658b-zpkbp        true   kc-lab-2
redis-568bd7c4-5c5vc        true   kc-lab-2

BFF 두 개가 서로 다른 노드에 있어야 한다. topologySpreadConstraints 가 그 일을 한다. 「다른 인스턴스」가 진짜 다른 기계여야 이 층의 질문이 성립하고, 같은 노드의 다른 프로세스면 재는 의미가 절반이다. Pending 이면 describe pod 의 Events 를 보고, ErrImageNeverPull 이면 이미지 import 로 돌아간다.

curl -I --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/

형태(모양은 observed)

HTTP/2 200
content-type: text/html

실측(observed) — 02-autoconfiguration.txt

=== 외부 진입점 ===
  https://app1.hyeonworks.com/  HTTP 200

상태 줄과 content-type 을 같이 본다. 200 이 왔다고 그게 이 애플리케이션의 HTML 이라는 보장이 없기 때문이다. 원래 실행은 /actuator/beans 를 불렀을 때 200 을 받았는데 내용은 Keycloak 로그인 페이지였다-L 로 리다이렉트를 따라간 결과다. 이 함정은 -o /dev/null -w '%{http_code}' 만 쓰면 안 보인다. 그래서 여기서는 읽는 형태인 -I 를 쓰고, 여러 번 재서 비교할 때만 뽑는 형태로 바꾼다.

관찰

/actuator/beans117KB 다. nginx → Traefik 을 거치면서 실패했다.

실측(observed) — 해설 문서 1절

$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway

★ 지금은 밖에서도 받아진다(2026-09-17, observed). TLS 를 세우고 같은 주소를 쳤더니 200155395 바이트가 그대로 왔다. 위 Bad Gateway 는 엣지 nginx 의 버퍼나 프록시 체인이 지금과 다른 상태에서 잰 값으로 보인다(inferred).

curl -s -o /tmp/beans-out.json -w 'code=%{http_code} size=%{size_download}\n' https://app1.hyeonworks.com/actuator/beans
code=200 size=155395

그래도 아래 「파드 안에서 받는다」를 그대로 쓴다 — 밖에서 받는 것이 목적이 아니고 프록시 설정이 바뀌면 다시 막힐 수 있다. 다만 Bad Gateway 를 「이 주소는 원래 밖에서 안 된다」로 읽지는 않는다.

B-0 의 주입은 2026-09-17 에 1 절(파일 넷 편집)과 2 절의 빌드까지 밟았다 (observed). 두 노드에 이미지를 밀어 넣는 단계부터는 안 밟았다(unknown).

★ ⑤ 의 git diff --stat 점검은 작업 트리가 깨끗할 때만 성립한다(2026-09-17, observed). 그 줄은 「네 파일만 나오는가」로 덜 지운 것을 잡으려는 것인데, keycloak-pattern 에 이 실험과 무관한 변경이 39개 있어(미추적 3개 더) 그 점검이 무의미해졌다. 따로 떼어낸 작업 트리에서 고치면 그 점검이 다시 살아난다.

git worktree add --detach /tmp/kp-b0 HEAD

넷을 다 뺀 뒤에 무엇이 남는지도 숫자로 확인했다(observed). pom.xml 은 XML 로 유효하고 starter 셋과 시험 의존 둘만 남는다. SecurityConfig.java 에는 bffSecurity 하나만 남고 import 일곱이 같이 빠진다. application.ymlspring 아래는 applicationsecurity 둘뿐이고 server.servlet.session 은 손대지 않는다 — 그것은 쿠키 이름 설정이라 저장소 선택과 무관하다. 매니페스트에서는 env 여섯만 빠지고 Redis Deployment · Service · PVC 는 남는다.

빌드는 한 번에 통과했고, 앞서 적은 processDuplicateKeys 실패는 다시 나오지 않았다(observed).

[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
naming to docker.io/library/keycloak-pattern-bff:lab done
exit=0

배포를 안 했으므로 지금 실험대의 BFF 는 여전히 B-1·B-2 상태이고(빈 목록에 RedisSessionRepositoryJdbcOAuth2AuthorizedClientService 가 다 있다), 그 상태에서 replica 2 로 로그인하면 성공한다. B-0 은 같은 조건에서 실패한다고 적으므로, 두 편을 가르는 것이 세션 저장소 하나임이 반대편에서 확인된다.

그래서 파드 안에서 직접 받는다. alpine 기반 JRE 이미지에는 wget 이 있다 — Keycloak 이미지와 다른 점이다.

kubectl -n keycloak-lab get pods -l app=bff
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
        --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
echo "$BFF"
kubectl -n keycloak-lab exec "$BFF" -- \
  wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
wc -c /tmp/beans.json

형태(모양은 observed)

119552 /tmp/beans.json

크기가 10만 바이트 대여야 한다. 0 이면 못 받은 것이고, 몇 백 바이트면 로그인 페이지나 오류 본문이다. 앞부분을 열어 확정한다.

head -c 200 /tmp/beans.json ; echo

형태(모양은 observed)

{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework

{"contexts":{"keycloak-bff" 로 시작해야 한다. <!DOCTYPE html 로 시작하면 로그인 페이지를 받은 것이다.

빈 하나는 이런 모양이고, 이름과 타입이 한 덩어리 안에 같이 있다.

"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}

이 실험대는 이렇게 했다(observed) — jq 가 없어 grep 으로 덩어리를 뽑았다. 가이드는 두 줄 다 미검증으로 표시한다(unknown).

grep -o '"aliases":\[' /tmp/beans.json | wc -l
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
  | sed 's/{"aliases".*"type":"/ -> /' \
  | grep -i authorizedclient

따라 하는 사람은 jq 가 깔려 있으면 그것을 쓴다고 가이드가 적는다. 다만 어떤 jq 표현을 쓰라고는 적지 않았으므로 그 형태는 이 문서에도 없다(unknown). 없는 도구를 전제로 한 명령은 진단 도중에 패키지를 깔러 나가게 만들고, 그러지 않으려고 위 형태를 쓴다.

형태(모양은 observed)

"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService

★ 원래 실행은 여기서 한 번 넘어졌다.

실측(observed) — 02-autoconfiguration.txt

  File "<stdin>", line 9
    print(f"    {name:46} {t.rsplit(\".\",1)[-1]}")
                                     ^
SyntaxError: unexpected character after line continuation character

JSON 을 파이썬 한 줄로 파싱하려다 따옴표 이스케이프에서 깨졌다. 빈 목록은 다음 시도에서 나왔고 그 결과가 03-beans-analysis.txt 다.

실측(observed) — 03-beans-analysis.txt

  컨텍스트: keycloak-bff
  전체 빈 수: 321

★ 2026-09-17 에 저장소를 붙인 상태에서 같은 두 줄을 쳤다(observed) — 그때까지 미검증이던 형태다.

빈 개수: 437
authorizedClientService     -> org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService
authorizedClientRepository  -> …AuthenticatedPrincipalOAuth2AuthorizedClientRepository
RedisSessionConfiguration   -> org.springframework.boot.autoconfigure.session.RedisSessionConfiguration

321 이 아니라 437 인 것이 이 측정이 쓸모 있다는 증거다. 321 은 저장소를 하나도 안 붙였을 때의 수이고, Redis 세션과 JDBC 토큰 저장소가 붙으면 빈이 그만큼 늘어난다 — 빈 수 하나로 지금 어느 상태인지 알 수 있다. 같은 날 B-2 의 표도 봤다: 표는 생겨 있고 행은 0 이었다(observed). \d 가 정의를 돌려주므로 「배선이 됐다」로 읽기 쉬운데, 행은 브라우저 로그인이 한 번 끝나야 생긴다.

  --- 세션 · 토큰 저장소 관련 ---
    authorizedClientManager      -> AuthorizedClientServiceOAuth2AuthorizedClientManager
    authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
    authorizedClientRepository   -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
    authorizedClientService      -> InMemoryOAuth2AuthorizedClientService
    clientRegistrationRepository -> InMemoryClientRegistrationRepository

  --- Redis / Spring Session 이 구성되었는가 ---
    ★ 없음 — Redis 도 Spring Session 도 구성되지 않았다

없다는 것은 세어서 확인한다.

grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json

형태(모양은 observed)

0

의존성 자체가 없으니 자동구성이 걸릴 조건이 없다. 세션은 서블릿 컨테이너(Tomcat)의 기본 StandardSession 에 있고, 그것이 인스턴스 메모리다.

구현체
authorizedClientService InMemoryOAuth2AuthorizedClientService 프로세스 메모리. 재시작하면 사라진다
authorizedClientRepository AuthenticatedPrincipalOAuth2AuthorizedClientRepository principal 기준 조회. session ID 가 없다
authorizedClientManager AuthorizedClientServiceOAuth2AuthorizedClientManager service(공유)를 쓴다
clientRegistrationRepository InMemoryClientRegistrationRepository 설정에서 읽은 것
SessionRepository 없음 Tomcat 의 기본 StandardSession
Redis / Spring Session 없음 의존성 자체가 없다

AuthenticatedPrincipalOAuth2AuthorizedClientRepository 는 이름이 곧 설명이다.

   요청이 인증되어 있으면
     └─▶ OAuth2AuthorizedClientService 에 위임
            └─▶ 키: (clientRegistrationId, principalName)
                        └─ session ID 가 없다   ★
   인증되어 있지 않으면
     └─▶ HttpSession 에 임시 보관

같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을 보고, 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다. Redis 를 붙여도 이건 안 고쳐진다 — 저장소를 공유해도 키에 session ID 가 없기 때문이다. 「메모리겠지」까지는 추측으로 맞혀도 조회 키가 무엇인지는 빈 이름을 봐야 안다.

★ 여기서부터는 브라우저로 한다. 가이드가 「예상 못 한 것」으로 적어 둔 부분이다. 브라우저에서 https://app1.hyeonworks.com/ 을 열고 로그인한다.

형태(모양은 observed) — 주소창이 이렇게 끝난다

https://app1.hyeonworks.com/login?error
kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix

아무 오류도 없다. Spring Security 는 로그인 실패를 DEBUG 로만 남긴다. 「로그에 아무것도 없으니 애플리케이션 문제가 아니다」로 읽으면 틀린다. 증상은 있는데 로그가 없고, 그럴 때는 가설을 세워 시험한다.

   ① 브라우저 → 앱 → IdP 로 리다이렉트   (state·PKCE verifier 를 저장)
   ② IdP → 브라우저 → 앱의 콜백          (저장한 것을 꺼내 검증)

인가 코드 흐름은 왕복이 두 번이고 두 번 다 같은 인스턴스로 가야 한다. 저장 위치가 HttpSession 이고 그게 인스턴스 메모리이므로, 콜백이 다른 replica 로 가면 저장된 인가 요청이 없어 실패한다. 앞에서 본 「SessionRepository 없음」이 이 가설의 근거다.

kubectl -n keycloak-lab scale deployment/bff --replicas=1
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
kubectl -n keycloak-lab get pods -l app=bff

브라우저에서 쿠키를 먼저 지우고 다시 로그인한다. 앞선 실패의 세션이 남아 있으면 결과가 섞인다.

실측(observed) — b0-bff-login-success-single-replica.png

   replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
   replica 1              → 로그인 성공

가설 확정. 「다중 인스턴스에서 어떻게 운영할 것인가」는 로그인한 뒤의 문제가 아니라 로그인 자체의 문제이고, B-2 의 검증 1번(「한쪽에서 로그인한 뒤 다른 인스턴스로 요청」) 보다 앞선 단계다. 로그인이 끝나야 그 검증을 하는데 로그인부터 막힌다.

마지막으로 토큰 경계를 본다. 브라우저에서 https://app1.hyeonworks.com/bff/token-boundary 를 연다.

실측(observed) — b0-bff-token-boundary.png

{"pattern":"AP3-backend-for-frontend","principal":"labuser",
 "accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
 "browserTokenCount":0,"csrfProtectionEnabled":true}
필드
accessTokenStoredOnServer true 서버가 access token 을 들고 있다
refreshTokenStoredOnServer true refresh token 도 서버에 있다
browserTokenCount 0 브라우저에는 토큰이 하나도 없다

BFF 패턴이 성립한다. 브라우저는 세션 쿠키만 들고 있고 토큰은 전부 서버에 있다. 이 세 값을 적어 둬야 B-1 에서 무엇이 바뀌는지 읽을 수 있다.

복구와 원상복구 확인표

replica 를 되돌린다.

kubectl -n keycloak-lab scale deployment/bff --replicas=2
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s

B-1 로 이어서 갈 것이라면 배포는 그대로 둔다. 거기서 같은 파드에 Redis 를 붙인다. 소스 변경은 되돌린다.

git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
  bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
  deploy/lab/k8s/bff-redis.yaml
git status --short

★ 잊으면 다음에 apply 할 때 B-0 구성이 다시 배포된다. 전부 지울 때는 둘을 친다.

kubectl delete -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete realms/keycloak-patterns
항목 명령 돌아왔을 때
replica kubectl -n keycloak-lab get deploy bff 2/2
소스 git status --short 출력 없음
파드 kubectl -n keycloak-lab get pods -o wide -l app=bff 두 노드에 하나씩
Keycloak kubectl -n keycloak-lab get pods -o wide | grep keycloak 둘 다 1/1 Running
curl -I https://app1.hyeonworks.com/ 200
임시 파일 rm -f /tmp/beans.json /tmp/build.log

★ actuator 를 열어 둔 채로 두지 않는다. /actuator/beans/actuator/env 는 내부 구조와 설정값을 그대로 드러낸다. 실험대라서 여는 것이고, 운영이라면 health 만 남긴다고 가이드는 적는다.

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
빈 목록에 RedisSessionRepository 가 있다 B-1·B-2 배선이 남아 있다 주입 절의 네 파일을 다시. git diff 로 확인
authorizedClientServiceJdbc... SecurityConfig 의 명시 빈을 안 지웠다 같은 절의 둘째 파일
/actuator/beansBad Gateway 응답이 117KB 라 프록시가 못 넘긴다 파드 안에서 받는다
/actuator/beans200 인데 HTML Keycloak 로그인 페이지다 head -c 200 으로 내용 확인
beans.json 이 0 바이트 파드 이름이 틀렸거나 포트가 다르다 get pods -l app=bff, 포트는 8083
빌드가 실패하는데 원인이 안 보인다 마지막 15줄에 없다 --progress=plain + 파일
테스트에서 컨텍스트가 안 뜬다 환경변수에 기본값이 없다 grep -n 'KC_ISSUER' bff/src/main/resources/application.yml
파드가 ErrImageNeverPull 그 노드에 이미지가 없다 두 노드에 각각 import
파드가 Pending 노드 메모리 부족 describe pod Events, top nodes
브라우저가 /login?error replica 2 + 스티키 없음 replica 1 로 줄여 확인
BFF 로그에 오류가 없다 Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 로그 없음을 「문제 없음」으로 읽지 않는다
로그인 후 리다이렉트가 http:// 로 간다 SERVER_FORWARD_HEADERS_STRATEGY 가 없다 매니페스트 env 확인
jq: command not found 이 실험대에 jq 가 없다 grep 으로 읽는다
kcadm401 config credentials 를 안 했거나 만료됐다 realm 준비 단계를 다시

원래 실행이 겪은 것 중 둘은 소스 쪽 사고였다. 하나는 bff/target/classes/... 9개 파일만 커밋되어 있고 bff/src/ 가 없던 상태다 — .gitignoretarget/ 이 없어 클래스 파일만 들어갔고 소스는 다른 브랜치에 있었다. 빌드 산출물이 커밋되어 있으면 「빌드가 되는데 바꿔도 안 바뀐다」가 된다.

ls bff/src/main/java/com/example/keycloakpattern/bff/
git checkout origin/develop-keycloak-pattern3 -- bff/

다른 하나는 환경변수에 기본값이 없어 테스트가 죽은 것이다. 테스트는 그 환경변수를 모른다.

# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth

# 기본값을 준다
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth

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

  • (observed) 배포 전 노드 자원 44%·33%, 배포 출력 전문, 파드 세 줄과 그 노드 배치, 외부 진입점 HTTP 200, 전체 빈 수 321, 저장소 관련 빈 다섯 줄과 「★ 없음」, /actuator/beans117KB 이고 프록시에서 Bad Gateway 인 것, 비밀번호 길이 19, token-boundary 의 세 값, replica 2 에서 /login?error 이고 replica 1 에서 로그인이 되는 것.
  • (unknown) grep -o '"aliases":\[' 로 빈을 세는 줄과, 이름·타입을 한 줄로 뽑는 grep·sed 줄. 가이드가 미검증으로 표시했다. jq 로 같은 것을 읽는 형태는 가이드에 없다.
  • (observed) 파이썬 한 줄로 JSON 을 파싱하려다 난 SyntaxError 도 측정 기록에 그대로 있다. 그 시도가 깨진 뒤 grep 형태로 다시 받았고, 지금 실린 빈 목록이 그 결과다.
  • (observed) 빌드 로그의 processDuplicateKeysmanagement: 아래에 endpoint: 를 한 번 더 넣어서 난 것이다. yamllint 가 이 실험대에 없어 빌드가 그 오류를 처음 알렸다.
  • 이 실험이 재지 않은 것 — 스티키 세션(세션 어피니티)을 켜면 replica 2 에서 로그인이 되는지는 재지 않았다. 「같은 인스턴스로 보내면 된다」는 추론이지 측정이 아니다.

B-1 — Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가

근거: b1-redis-session-store.md (852줄). 실행 기록은 2026-09-04 13:5914:03 KST(observed).

이 실험이 가르는 것

B-0 이 답을 냈다. 세션도 토큰도 인스턴스 메모리에 있고, 그래서 replica 2 에서는 로그인 조차 안 된다. 처방은 뻔해 보인다 — 공유 저장소를 붙이면 된다.

   Redis 를 붙인다  →  상태가 공유된다  →  다중 인스턴스가 된다
                            ↑
                     정말 그런가?

이 실험이 재는 것은 「붙였다」와 「공유된다」 사이의 거리다. 묻는 것이 넷이고, 그중 둘째가 요점이다.

물어볼 것
무엇이 옮겨졌나 /actuator/beans 를 다시 찍는다
무엇이 안 옮겨졌나 같은 곳. 안 바뀐 것을 확인하는 게 더 중요하다
옮겨진 것 안에 무엇이 들었나 Redis 를 직접 연다
사용자에게는 어떻게 보이나 브라우저

그리고 배포 첫 시도에서 쿠버네티스가 매니페스트에 없는 환경변수를 넣어 파드를 죽이는 함정을 만난다. 가이드는 그 함정을 일부러 한 번 겪게 해 두었다.

가이드의 「이 가이드가 끝나면」 표는 일곱을 적는다 — 쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것, 그것이 enableServiceLinks: false 로 고쳐지는 것, 빈이 321 → 402 (+81) 로 늘어나는 것, 그런데 authorized client 는 하나도 안 바뀐 것, Redis 안의 키·필드·TTL 과 토큰이 없는 것, 세션이 Java 네이티브 직렬화인 것, 「로그인은 되어 있는데 아무것도 못 하는」 상태.

전제와 되돌리기

  • B-0 이 끝나 있고, B-0 의 답(빈 세 개의 이름)을 손에 들고 시작한다. 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다.
  • 브라우저가 필요하다. 인가 코드 흐름은 왕복이 두 번이라 curl 로 대신할 수 없다.
  • 명령은 kc-lab-1 에서 친다.
  • jq 는 이 실험대에 깔려 있지 않다. 이 가이드는 grepredis-cli 로 읽는다.

이건 애플리케이션 구성을 바꾸는 실험이다. 의존성과 설정을 바꿔 다시 빌드하고 다시 배포한다. 되돌리려면 소스 변경을 되돌리고 다시 빌드해야 하므로 git status 가 깨끗한 상태에서 시작한다. 전 구간 약 40분(빌드 시간 포함).

되돌리기는 먼저 읽어 둔다.

git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
  deploy/lab/k8s/bff-redis.yaml

★ 주입 절의 첫 배포는 일부러 고장 난 상태로 한다. 함정을 직접 보기 위해서이고, 건너뛰고 enableServiceLinks: false 부터 시작해도 결과는 같다고 가이드는 적는다.

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

넓은 것부터 좁혀 간다.

BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab get pods -o wide -l app=redis

형태(모양은 observed)

bff-574c6d658b-8cz4x   1/1   Running   0   20m   10.42.0.51   kc-lab-1
bff-574c6d658b-zpkbp   1/1   Running   0   20m   10.42.1.52   kc-lab-2
redis-568bd7c4-5c5vc   1/1   Running   0   20m   10.42.1.53   kc-lab-2

BFF 두 개가 서로 다른 노드에 있고 Redis 가 떠 있다. Redis 는 배포만 되어 있고 아직 연결되지 않았다 — B-0 이 그렇게 만들어 뒀다.

B-0 의 답을 before 값으로 다시 잡는다.

BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
        --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n keycloak-lab exec "$BFF" -- \
  wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
wc -c /tmp/beans-before.json

이 실험대는 이렇게 했다(observed) — 가이드는 이 두 줄을 미검증으로 표시한다(unknown).

grep -o '"aliases":\[' /tmp/beans-before.json | wc -l
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \
  | sed 's/{"aliases".*"type":"/ -> /' \
  | grep -iE 'authorizedclient|sessionRepository'

따라 하는 사람은 B-0 과 같은 형태를 쓴다. 가이드는 여기서도 jq 판본을 적어 두지 않았다(unknown).

실측(observed) — 02-autoconfig-after.txt

  빈 수: 321 → 402  (+81)
    authorizedClientService
      before: InMemoryOAuth2AuthorizedClientService
    authorizedClientRepository
      before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
    authorizedClientManager
      before: AuthorizedClientServiceOAuth2AuthorizedClientManager

빈 수가 321 이고 sessionRepository 는 아예 없다. ★ 이 세 줄과 숫자를 적어 둔다 — 관찰 절의 비교 대상이 이것이고, 견줄 것이 없으면 「안 바뀌었다」를 말할 수 없다.

Redis 가 살아 있는지, 그리고 비어 있는지 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head

형태(모양은 observed)

PONG
# Server
redis_version:7.4.x
...
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan

형태(모양은 observed)

(integer) 0

0 이어야 뒤에서 찾은 키를 「내가 만든 것」이라고 말할 수 있다. KEYS * 대신 --scan 을 쓰는 것은 KEYS 가 서버를 블로킹하기 때문이다. 지금은 키가 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.

★ 여기가 이 실험의 함정이다. 아직 아무것도 안 바꿨는데 파드 안에 Redis 관련 환경변수가 이미 있다. 한 번은 통째로 보고, 그다음 걸러 본다.

kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis

형태(모양은 observed)

REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
REDIS_PORT_6379_TCP_PORT=6379
REDIS_PORT_6379_TCP_PROTO=tcp

REDIS_PORT 의 값이 포트 번호가 아니라 URL 이다.

쿠버네티스는 같은 네임스페이스의 모든 Service 마다 Docker link 시절의 환경변수를 파드에 자동으로 넣는다. 옛 Docker 링크 호환을 위한 기능이고 기본값이 켜짐이다. Service 이름이 redis 이므로 REDIS_* 가 들어오고, 애플리케이션 설정도 ${REDIS_PORT:6379} 를 읽는다. 이름이 겹친다.

   Service 이름이 redis 이면
     REDIS_SERVICE_HOST=10.43.57.116
     REDIS_SERVICE_PORT=6379
     REDIS_PORT=tcp://10.43.57.116:6379        ← 이게 문제

매니페스트에 REDIS_PORT: "6379" 를 명시하면 그게 이긴다. 명시를 안 하면 자동 주입이 이기고, 오류 메시지는 쓰지도 않은 값을 지목한다. REDIS, POSTGRES, MYSQL 처럼 흔한 Service 이름일수록 위험하다고 가이드는 적는다.

지금은 아무 일도 안 일어난다. 애플리케이션이 그 변수를 안 읽기 때문이고, 읽기 시작하는 순간 파드가 죽는다.

주입

의존성 두 개를 함께 넣는다. 무엇을 왜 넣는지 읽으면서 고쳐야 하는 파일이라 편집기로 연다.

vim bff/pom.xml
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
     spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
     둘 다 있어야 자동구성이 걸린다 -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

★ 하나만 넣으면 조용히 in-memory 로 남는다. 오류도 안 난다. 주입 검증 절에서 찍어서 확인하는 절차가 그래서 필요하다.

vim bff/src/main/resources/application.yml
spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
  session:
    store-type: ${SPRING_SESSION_STORE_TYPE:redis}
    timeout: ${SPRING_SESSION_TIMEOUT:30m}
    redis:
      namespace: bff:session

spring-session-data-redis 를 넣으면 컨텍스트 기동 시 Redis 에 붙으려 한다. 테스트에는 Redis 가 없다.

vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
@SpringBootTest(properties = {
    "KEYCLOAK_CLIENT_SECRET=test-only-secret",
    // 테스트는 Redis 를 띄우지 않는다
    "spring.session.store-type=none",
})

이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다. 그 실패 메시지는 Redis 연결 오류라서 「배포 환경 문제」로 읽히기 쉬운데, 실패한 곳은 빌드다.

매니페스트는 일부러 enableServiceLinks 없이 먼저 적용한다.

vim deploy/lab/k8s/bff-redis.yaml
spec:
  # enableServiceLinks: false     ← 아직 넣지 않는다
  containers:
    - name: bff
      env:
        - name: SPRING_SESSION_STORE_TYPE
          value: redis
        - name: REDIS_HOST
          value: redis.keycloak-lab.svc
        # REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다

이 실험대는 이렇게 했다(observed) — 빌드하고 두 노드에 밀어 넣고 배포한다.

docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff

따라 하는 사람은 가운데 두 줄에서 두 겹 ssh 와 원격 셸의 인용을 겹쳐 놓지 않을 수 있다. 가이드는 나눈 형태를 적어 두지 않았으므로 이 문서에도 없다(unknown). 지금 멈추려면 롤아웃을 되돌린다.

kubectl -n keycloak-lab rollout undo deployment/bff

무엇이 일어나는지 넓은 것부터 본다.

kubectl -n keycloak-lab get pods -l app=bff

형태(모양은 observed)

NAME                   READY   STATUS             RESTARTS      AGE
bff-695646ddb-kzs9k    0/1     CrashLoopBackOff   3 (20s ago)   90s

로그보다 먼저 이벤트를 본다.

kubectl -n keycloak-lab describe pod -l app=bff | tail -20
kubectl -n keycloak-lab logs -l app=bff --tail=40
kubectl -n keycloak-lab logs -l app=bff --previous --tail=40

실측(observed) — 해설 문서 1절

Failed to bind properties under 'spring.data.redis.port' to int:
    Property: spring.data.redis.port
    Value: "${REDIS_PORT:6379}"
    Reason: failed to convert java.lang.String to int
            (caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")

마지막 줄의 "tcp://10.43.57.116:6379"매니페스트 어디에도 쓰지 않은 값이다. 주입하기 전에 printenv 로 미리 본 그 환경변수이고, 쿠버네티스가 넣었다. 이 오류를 「Redis 가 안 떠서」로 읽기 쉬운데 Redis 는 멀쩡하다. 파드가 Redis 에 붙어 보지도 못하고 설정 바인딩에서 죽었고, 메시지가 Failed to bind properties 라고 말하고 있다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping

형태(모양은 observed)

PONG

처방은 둘인데 하나만 근본 처방이다.

처방 문제
환경변수 이름을 바꾼다 (BFF_REDIS_PORT 등) 다음 사람이 같은 함정에 다시 빠진다
주입 자체를 끈다 근본 처방
vim deploy/lab/k8s/bff-redis.yaml
spec:
  enableServiceLinks: false      # 근본 처방
  containers:
    - name: bff
      env:
        - name: SPRING_SESSION_STORE_TYPE
          value: redis
        - name: REDIS_HOST
          value: redis.keycloak-lab.svc
        - name: REDIS_PORT
          value: "6379"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s

실측(observed) — 01-servicelinks-trap.txt

deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-576d869c6d-bshvl   true   kc-lab-2
bff-695646ddb-kzs9k    true   kc-lab-1
bff-695646ddb-vjqzf    true   kc-lab-2

세 줄이다. replica 는 2인데 파드가 3개 보이는 것은 롤아웃 전환 중에 찍어서이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다. 잠시 뒤 두 개가 된다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다. 자동 주입이 정말 사라졌는지부터다.

BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
        --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis

형태(모양은 observed)

REDIS_HOST=redis.keycloak-lab.svc
REDIS_PORT=6379

REDIS_SERVICE_HOST 계열이 전부 사라졌고 넘겨준 두 개만 남았다. REDIS_PORT6379 다.

kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab exec "$BFF" -- \
  wget -qO- http://localhost:8083/actuator/health

형태(모양은 observed)

{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}

redis 컴포넌트가 있고 UP 이다. B-0 에서는 이 컴포넌트가 아예 없었다. spring-boot-starter-data-redis 가 헬스 인디케이터를 같이 들고 왔고, 건강 체크에 새 항목이 생긴 것 자체가 자동구성이 걸렸다는 신호다.

브라우저에서 쿠키를 먼저 지우고 https://app1.hyeonworks.com/ 로 로그인한다.

실측(observed) — b1-login-works-two-replicas.png

로그인이 된다. B-0 에서 replica 2 로는 /login?error 였던 그 부분이다. 인가 요청(state·PKCE verifier)이 이제 Redis 에 있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. B-0 이 replica 를 1로 줄여야 했던 문제는 고쳐졌다. 가이드는 곧바로 경고를 붙인다 — 여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝나고, 그게 이 실험이 막으려는 결론이다.

관찰

B-0 의 방법을 그대로 다시 쓴다.

kubectl -n keycloak-lab exec "$BFF" -- \
  wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json
grep -o '"aliases":\[' /tmp/beans-after.json | wc -l

실측(observed) — 02-autoconfig-after.txt

  빈 수: 321 → 402  (+81)

이 실험대는 이렇게 했다(observed) — 두 줄 다 가이드가 미검증으로 표시한다(unknown).

grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
  | sed 's/{"aliases".*"type":"/ -> /' \
  | grep -iE 'session|redis'

실측(observed) — 같은 파일

  --- 세션 저장소 관련 (새로 생긴 것) ---
    ★ cookieSerializer                           -> DefaultCookieSerializer
    ★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
    ★ sessionRepository                          -> RedisSessionRepository
    ★ springSessionRepositoryFilter              -> SessionRepositoryFilter
    ★ redisConnectionFactory                     -> LettuceConnectionFactory
    ★ redisTemplate                              -> RedisTemplate

★ 안 바뀐 것을 보는 쪽이 핵심이다.

grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
  | sed 's/{"aliases".*"type":"/ -> /' \
  | grep -i authorizedclient

실측(observed) — 같은 파일

  --- OAuth2 authorized client — 바뀌었는가? ---
    authorizedClientService
      before: InMemoryOAuth2AuthorizedClientService
      after : InMemoryOAuth2AuthorizedClientService    그대로 — Redis 로 안 옮겨졌다
    authorizedClientRepository
      before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
      after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository    그대로 — Redis 로 안 옮겨졌다
    authorizedClientManager
      before: AuthorizedClientServiceOAuth2AuthorizedClientManager
      after : AuthorizedClientServiceOAuth2AuthorizedClientManager    그대로 — Redis 로 안 옮겨졌다

빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.

   Application Session      ──▶ Redis        (인증 상태, principal, 인가 요청)
   OAuth2AuthorizedClient   ──▶ 프로세스 메모리  (access token, refresh token)

spring.session.store-typeHttpSession 을 갈아끼우는 설정이고 OAuth2AuthorizedClient 는 그 설정과 무관한 다른 저장소다. 찍어서 확인하지 않으면 이 사실을 알 방법이 없다 — 로그인은 되고, 화면도 뜨고, 파드도 건강하다. B-0 을 실험으로 만든 까닭이 이것이다. before 가 있어야 after 를 읽는다.

Redis 를 직접 연다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan

실측(observed) — 03-redis-contents.txt

=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
  총 키 수: 1

네임스페이스가 bff:session 이다. application.ymlspring.session.redis.namespace 가 그대로 접두어가 됐다.

이 실험대는 이렇게 했다(observed) — 키 이름을 변수로 잡는 이 줄에는 걸러 내는 조각이 둘 붙어 있다.

KEY=$(kubectl -n keycloak-lab exec deploy/redis -- \
        redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r')
echo "$KEY"

grep -v expires 가 필요한 까닭 — Spring Session 은 만료 추적용 키 (bff:session:expirations:* · bff:session:sessions:expires:*)도 만들고, 그것을 잡으면 다음 명령이 빈 결과를 낸다. tr -d '\r'redis-cli 출력이 CR 을 달고 올 수 있고, 그대로 쓰면 키가 안 맞는데 오류는 안 난다.

따라 하는 사람은 --scan 출력을 눈으로 보고 키 하나를 그대로 쳐 넣을 수 있다 — 바로 위 redis-cli --scan 이 이미 전체 키를 보여 줬다. 가이드는 그 두 단계 형태를 적어 두지 않았다(unknown).

★ 그런데 head -1 이 엉뚱한 세션을 집는다(2026-09-17, observed). 원래 실행은 키가 하나였지만 둘 이상일 때가 있다. TLS 를 세우고 로그인 한 번을 한 뒤 키가 둘이었고, head -1 이 고른 쪽에는 SPRING_SECURITY_CONTEXT 가 없었다.

bff:session:sessions:b66634a2-…   SPRING_SECURITY_SAVED_REQUEST · maxInactiveInterval · creationTime · lastAccessedTime
bff:session:sessions:c5f7b0c7-…   SPRING_SECURITY_CONTEXT · …AUTHORIZATION_REQUEST · SPRING_SECURITY_LAST_EXCEPTION · lastAccessedTime · maxInactiveInterval · creationTime

앞엣것은 로그인 화면으로 보내기 전에 만들어진 세션이라 인증 정보가 없다. 그것을 잡으면 아래 필드 목록에 SPRING_SECURITY_CONTEXT 가 안 나오고 \xac\xed 도 못 본다 — 이 편이 보여 주려는 것이 통째로 안 보인다. 오류는 안 나므로 화면만으로는 「토큰이 없네」와 「인증 세션이 아니네」가 갈리지 않는다. 키를 내용으로 고르면 몇 개가 있든 맞는 것을 잡는다.

KEY=
for K in $(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | tr -d '\r'); do
  HAS=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli hexists "$K" 'sessionAttr:SPRING_SECURITY_CONTEXT' | tr -d '\r')
  echo "$HAS  $K"
  if [ "$HAS" = 1 ]; then KEY=$K; fi
done
echo "고른 키: $KEY"

첫 줄의 KEY= 가 없으면 이 검사는 실패할 수가 없다. 앞 단계의 head -1 이 이미 $KEY 를 채워 뇌기 때문에, 루프가 한 건도 못 맞혀도 마지막 echo 는 잘못 고른 그 키를 그대로 찍는다 — 화면은 정상인데 고쳐진 것이 없는 상태다. 키마다 1/0 을 앞에 찍는 것도 같은 까닭이다. grep -q 로 조용히 거르면 어느 키가 왜 떨어졌는지가 화면에 안 남는다.

1  bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23
1  bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688
고른 키: bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688

2026-09-17 에는 두 키가 다 인증 세션이었고(observed) 루프는 마지막 것을 잡았다. 「맞는 키가 하나뿐」 을 전제하는 절차가 아니다. 같은 날 hkeys 는 필드 여섯을 내서 sessionAttr:SPRING_SECURITY_SAVED_REQUEST 하나가 없었는데, 토큰이 없다는 결론은 그대로다.

★ 그리고 필드 순서를 기대하면 안 된다(observed). 아래 hgetall | head -4SPRING_SECURITY_CONTEXT 가 첫 필드로 나온다고 보고 자른 것인데 Redis 해시의 필드 순서는 보장되지 않는다. 다시 쳤을 때는 lastAccessedTime 이 먼저 나왔다.

 1) "lastAccessedTime"
 2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number…"

요점은 오히려 더 세게 확인된다. \xac\xedSPRING_SECURITY_CONTEXT 에만 붙는 것이 아니라 lastAccessedTimeLong 하나에도 붙는다 — 이 해시의 값은 전부 Java 직렬화다. 특정 필드를 보려면 head 로 자르지 말고 이름을 대서 hget 한다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"

실측(observed) — 03-redis-contents.txt

  타입: hash
    필드: sessionAttr:SPRING_SECURITY_CONTEXT
    필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
    필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
    필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
    필드: lastAccessedTime
    필드: maxInactiveInterval
    필드: creationTime

필드 목록에 토큰이 없다. 「저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다」가 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에, 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"

실측(observed) — 같은 파일

=== TTL (Q3 검증 3번 — session TTL) ===
  TTL: 1772 초

spring.session.timeout=30m(1800초)에서 방금 지난 만큼 줄어든 값이다. 세션 TTL 1772초와 access token 수명 60초가 처음부터 어긋나 있다. 어느 쪽에 맞출지는 선택이 아니라 이미 어긋나 있고 그 간극을 누가 메우는가의 문제이며, B-3 이 그 주제다.

값의 바이트를 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4

실측(observed) — 03-redis-contents.txt

 1) "sessionAttr:SPRING_SECURITY_CONTEXT"
 2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken...

\xac\xed 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. --no-raw 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다 — 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다.

결과
사람이 못 읽는다 운영 중 디버깅이 어렵다
클래스 버전에 묶인다 애플리케이션을 올리면 기존 세션이 역직렬화에 실패할 수 있다
역직렬화 취약점 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다

D-2(버전 업그레이드)에서 이것이 다시 나온다 — Spring Security 버전이 바뀌면 Redis 에 남아 있는 세션이 깨질 수 있다.

사용자에게 어떻게 보이는지가 이 실험에서 가장 중요한 부분이다. 파드를 전부 교체해 Redis 덕을 보는지 확인한다. 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.

kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s

로그인은 그대로 둔 채 브라우저에서 https://app1.hyeonworks.com/bff/token-boundary 를 연다.

실측(observed) — b1-token-boundary-after-redis.png

{"pattern":"AP3-backend-for-frontend",
 "principal":"labuser",                   세션은 Redis 에서 복원되었다
 "accessTokenStoredOnServer":false,       토큰은 사라졌다
 "refreshTokenStoredOnServer":false,
 "browserTokenCount":0,
 "csrfProtectionEnabled":true}

principal 은 살아 있는데 두 토큰이 false 다.

   사용자 관점:  로그인되어 있다고 나온다
   실제:        BFF 가 사용자를 대신해 아무것도 못 한다

파드가 전부 교체됐는데 로그인 상태는 살아남았다 — Redis 덕분이다. 토큰은 같이 살아남지 못했다 — 인스턴스 메모리에 있었으니까. 가이드는 이것을 「부분적으로만 공유했을 때」의 실패 모양이라고 부르고, 완전히 로그아웃되는 편이 차라리 낫다고 적는다. 적어도 사용자가 다시 로그인하는데, 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.

B-0 (Redis 없음, replica 1) B-1 (Redis 세션, replica 2)
principal labuser labuser
accessTokenStoredOnServer true false
파드 재시작 후 로그아웃 로그인 상태만 남고 토큰은 소실

★ 스크린샷으로 시점을 구별하지 않는다. 증거 폴더의 README.md 가 적어 둔 대로 b1-login-works-two-replicas.pngb1-token-boundary-after-redis.png동일 파일이다. 세 시점 모두 accessTokenStoredOnServer: false 인 같은 화면이었기 때문이고, 시점 구별은 터미널 출력과 Redis·DB 조회가 한다.

그래서 무엇을 해야 하는가 — OAuth2AuthorizedClientService 를 공유 저장소로 옮기는 구현이 따로 필요하다.

후보
JdbcOAuth2AuthorizedClientService Spring Security 기본 제공. PostgreSQL 이 이미 있다
직접 구현 (Redis) OAuth2AuthorizedClientService 인터페이스를 Redis 로 구현
세션 안에 넣기 HttpSessionOAuth2AuthorizedClientRepository 를 쓰면 세션과 함께 Redis 로 간다

세 번째는 조회 키 문제(principal 기준)까지 같이 푼다. 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않고, 대신 세션이 커진다. B-2 가 이 선택지를 비교한다.

복구와 원상복구 확인표

B-2 로 이어갈 것이면 이 구성이 B-2 의 출발점이므로 아무것도 안 되돌린다.

B-0 상태로 되돌릴 때는 소스를 되돌리고 다시 빌드해서 다시 밀어 넣어야 한다. 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 돈다.

git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
  bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \
  deploy/lab/k8s/bff-redis.yaml
git status --short
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s

Redis 를 비우는 것은 되돌릴 수 없다. 지운 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다 — 실험대라서 하는 일이다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize

세션 하나만 지우려면 이쪽이다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
항목 명령 돌아왔을 때
소스 git status --short 출력 없음 (B-0 로 되돌릴 때)
파드 kubectl -n keycloak-lab get pods -o wide -l app=bff 2/2, 두 노드에 하나씩
Redis kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping PONG
Redis 키 ... redis-cli dbsize 의도한 값
Keycloak kubectl -n keycloak-lab get pods | grep keycloak 둘 다 1/1 Running
curl -I https://app1.hyeonworks.com/ 200
임시 파일 rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
파드가 CrashLoopBackOff, 오류에 tcp://...:6379 쿠버네티스가 REDIS_PORT 를 주입했다 printenv | grep -i redis
위 오류를 「Redis 가 죽어서」로 읽었다 메시지가 Failed to bind properties redis-cli ping 으로 Redis 를 따로 확인
enableServiceLinks 를 넣었는데 그대로 파드가 아직 옛 것이다 rollout restartprintenv 다시
빌드가 Redis 연결 오류로 죽는다 테스트가 Redis 를 찾는다 spring.session.store-type=none
sessionRepository 가 안 생긴다 의존성을 하나만 넣었다. 오류 없이 in-memory 로 남는다 두 개 다 있는지 pom.xml
redis-cli --scan 이 비어 있다 아직 로그인 안 했다 브라우저로 로그인 후 다시
hkeys 가 빈 결과 만료 추적 키를 잡았다 grep -v expires
키가 맞는데 명령이 안 먹는다 출력에 CR 이 붙었다 tr -d '\r'
값이 깨져서 터미널이 이상해진다 바이너리를 그대로 찍었다 --no-raw
API 호출이 500 인데 토큰은 멀쩡 DNS 다. 다른 네임스페이스의 서비스 로그의 UnresolvedAddressException
/actuator/beansBad Gateway 응답이 커서 프록시가 못 넘긴다 파드 안에서 받는다
jq: command not found 이 실험대에 jq 가 없다 grep 으로 읽는다
로그인은 되는데 API 가 전부 실패 이게 이 실험의 결론이다 token-boundary 의 두 false
스크린샷으로 시점을 구별하려다 헷갈린다 두 파일이 동일하다 터미널 출력과 Redis 조회로 구별

500 쪽은 원인을 찾는 데 한 번 헛짚었다. 로그를 보니 토큰이 아니라 DNS 였다.

kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'

실측(observed) — 해설 문서 1절

java.nio.channels.UnresolvedAddressException

RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080 이었는데 echoheader-lab 네임스페이스의 8081 이었다. 배포조차 되어 있지 않았다.

# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
- name: RESOURCE_API_BASE_URL
  value: http://echo.header-lab.svc:8081
kubectl -n header-lab get svc echo

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

  • (observed) 자동 주입된 REDIS_* 일곱 줄과 REDIS_PORT=tcp://10.43.57.116:6379, Failed to bind properties 오류 전문, enableServiceLinks: false 뒤의 롤아웃 출력 세 줄, 빈 수 321 → 402 (+81), 새로 생긴 세션 저장소 빈 여섯 줄, 안 바뀐 authorized client 세 개의 before·after, Redis 키 bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae 와 필드 일곱 개, TTL: 1772 초, \xac\xed 로 시작하는 바이트, 재시작 뒤 token-boundaryprincipal 생존과 두 토큰 false, UnresolvedAddressException.
  • (unknown) grep -o '"aliases":\[' 로 빈을 세는 줄과 이름·타입을 뽑는 grep·sed 줄. 가이드가 미검증으로 표시했다. jq 판본과, --scan 출력에서 키를 눈으로 골라 치는 두 단계 형태도 가이드에 없다.
  • (observed) b1-login-works-two-replicas.pngb1-token-boundary-after-redis.png동일 파일이라는 것은 증거 폴더의 README.md 가 적어 둔 사실이다. 같은 화면이 세 시점에 나왔기 때문이고, 그래서 시점은 터미널 출력과 저장소 조회로만 갈린다.
  • 이 실험이 재지 않은 것 셋 — Redis 를 끊었을 때 무엇이 나는지(B-5 의 주제), 로그아웃 뒤 두 저장소에 무엇이 있는지(B-2 로 넘긴다), 저장소 지연이 화면 지연으로 얼마나 번역되는지(B-2 이후).

B-2 — 저장소를 옮겨도 안 고쳐지는 것이 무엇인가

근거: b2-multi-instance-session.md (869줄). 실행 기록은 2026-09-04 14:0914:13 KST(observed).

이 실험이 가르는 것

B-1 이 Application Session 만 Redis 로 옮겼고, 그러자 사용자는 로그인 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다. 세션과 토큰이 서로 다른 것에 들어 있고 한쪽만 옮겼기 때문이다. 토큰도 공유 저장소로 옮기면 그건 고쳐진다. 문제는 무엇이 같이 고쳐지고 무엇이 안 고쳐지는가다.

예측
통념 공유 저장소로 옮기면 다중 인스턴스 문제가 해결된다
B-2 모델 인스턴스 간 공유만 해결되고 브라우저 간 격리와 로그아웃 정리는 그대로

「어디에 두는가」와 「어떻게 찾는가」는 독립이다.

   저장소 (where)   메모리 → PostgreSQL → Redis …   ← 옮기면 인스턴스 간 공유가 된다
   조회 키 (how)    (clientRegistrationId, principalName)  ← 옮겨도 그대로다

이 실험이 판정하는 것은 두 번째이고, 키는 코드가 아니라 스키마에 박혀 있다. 그래서 「구현을 바꾸면 되겠지」로 넘어갈 수 없고, 주입하기 전에 그 줄을 직접 읽는다.

같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데 셋이 서로 다른 시스템에 있다.

   ① HttpSession           Redis          Spring Security 가 지운다
   ② OAuth2AuthorizedClient PostgreSQL     ★ 아무도 안 지운다
   ③ IdP SSO 세션           Keycloak       ★ RP-initiated logout 을 보내야 한다

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • B-0 · B-1 이 끝나 BFF 가 replica 2개로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
  • 명령은 kc-lab-1 에서 친다.
  • 브라우저가 필요하다. BFF 는 authorization code 흐름이라 로그인을 curl 로 만들 수 없다. https://app1.hyeonworks.com/ 에 붙어 labuser / labpass 로 들어간다. realm 은 keycloak-patterns.
  • 터미널 하나와 브라우저 창 하나를 나란히 둔다. 브라우저에서 버튼을 누르고 터미널에서 저장소를 세는 왕복이 이 실험의 전부다.

이건 상태를 바꾸는 실험이다. DDL 을 태우고, Redis 세션을 지우고, 로그아웃한다. 실험대에서만 한다. 전 구간 약 25분이고, 중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.

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

시험군만 재는 측정은 측정이 아니다. 덮어쓰기를 보려면 덮어쓰이기 전의 행이 있어야 하고, 로그아웃 정리를 보려면 로그아웃 전의 세 숫자가 있어야 한다.

파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
kubectl -n keycloak-lab get pods -o wide

형태(모양은 observed) — IP 와 해시는 환경마다 다르다

NAME                   READY  STATUS   RESTARTS  AGE  IP           NODE
bff-555df79c97-6j86w   1/1    Running  0         44s  10.42.0.52   kc-lab-1
bff-555df79c97-vgg6g   1/1    Running  0         22s  10.42.1.124  kc-lab-2
postgres-...           1/1    Running  0         5d   ...          kc-lab-2
redis-...              1/1    Running  0         3d   ...          kc-lab-2

10.42.0.5210.42.1.124 는 B-5 의 증거에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가 nodeSelectorkc-lab-2 에 고정해 둔다.

  • bff두 개이고 READY 가 둘 다 1/1
  • NODE 가 서로 다르다 — 같은 노드에 몰려 있으면 「다른 인스턴스」가 같은 커널 위의 다른 프로세스일 뿐이다. topologySpreadConstraints 가 이걸 벌려 놓는다
  • RESTARTS0 — 뒤에서 이 값이 오르면 건드린 것이 엉뚱한 데 닿은 것이다

replica 가 하나면 이 실험의 질문(Q1)이 성립하지 않는다. 파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.

kubectl -n keycloak-lab get pods -l app=bff

실측(observed) — 01-jdbc-store-deploy.txt

deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-555df79c97-6j86w   1/1   Running       0     44s
bff-555df79c97-vgg6g   1/1   Running       0     22s

위 두 줄은 배포 명령이 같이 찍은 것이다. 파드 줄만 나오면 정상이다.

이 실험은 원래 여기서 한 번 넘어졌다. 파드는 떴고 Hikari 도 붙었는데 테이블이 없었고, 아무도 그것을 신고하지 않았다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'

실측(observed) — 01-jdbc-store-deploy.txt

=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1

이 두 줄이 나오면 아직 아무것도 저장되지 않는 상태다. 스키마 초기화가 조용히 실패했고, 원인은 타입 이름 하나다. Spring Security 는 DDL 을 두 벌 번들한다.

파일 토큰 컬럼 타입
oauth2-client-schema.sql access_token_value blob NOT NULL
oauth2-client-schema-postgres.sql access_token_value bytea NOT NULL

기본 판본을 그대로 태우면 blob 에서 문법 오류가 나고, spring.sql.init.continue-on-error: true 가 켜져 있으면 그 실패가 삼켜지고 파드는 정상으로 보인다. continue-on-error 는 없어도 되는 초기화에만 쓰는 것인데 여기서는 없으면 안 되는 초기화였다.

정정 노트가 가이드에 붙어 있다. 해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언 차이" 였다가 정정됐다. Liquibase 가 아니다. 스키마를 태우는 것은 Spring Boot 의 spring.sql.init 이고, DDL 은 spring-security-oauth2-client jar 가 번들한 파일이다. Liquibase 는 Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.

DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 파일로 만든다. 터미널에 붙여 넣는 명령과 프로그램 원문을 섞지 않는다.

vim /tmp/oauth2-pg.sql
-- file: /tmp/oauth2-pg.sql
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
CREATE TABLE oauth2_authorized_client (
  client_registration_id varchar(100) NOT NULL,
  principal_name varchar(200) NOT NULL,
  access_token_type varchar(100) NOT NULL,
  access_token_value bytea NOT NULL,
  access_token_issued_at timestamp NOT NULL,
  access_token_expires_at timestamp NOT NULL,
  access_token_scopes varchar(1000) DEFAULT NULL,
  refresh_token_value bytea DEFAULT NULL,
  refresh_token_issued_at timestamp DEFAULT NULL,
  created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY (client_registration_id, principal_name)
);

위 DDL 은 02-schema.txt=== PostgreSQL 전용 스키마 === 절 원문이다(observed).

kubectl -n keycloak-lab exec -i deploy/postgres -- \
  psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql

실측(observed) — 02-schema.txt

=== 적용 ===
CREATE TABLE

-i 를 빼면 < 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안 일어나고 오류도 안 난다kubectl exec 는 stdin 을 기본으로 연결하지 않는다.

되돌리기는 있지만 평소에는 쓰지 않는다. 이 표는 B-3 이후로도 계속 쓴다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'

이제 기본키를 눈으로 본다. 이 실험의 답이 여기 박혀 있다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'

실측(observed) — 02-schema.txt

                                Table "public.oauth2_authorized_client"
         Column          |            Type             | Collation | Nullable |         Default         
-------------------------+-----------------------------+-----------+----------+-------------------------
 client_registration_id  | character varying(100)      |           | not null | 
 principal_name          | character varying(200)      |           | not null | 
 access_token_type       | character varying(100)      |           | not null | 
 access_token_value      | bytea                       |           | not null | 
 access_token_issued_at  | timestamp without time zone |           | not null | 
 access_token_expires_at | timestamp without time zone |           | not null | 
 access_token_scopes     | character varying(1000)     |           |          | NULL::character varying
 refresh_token_value     | bytea                       |           |          | 
 refresh_token_issued_at | timestamp without time zone |           |          | 
 created_at              | timestamp without time zone |           | not null | CURRENT_TIMESTAMP
Indexes:
    "oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)

맨 아래 Indexes: 하나가 답이다.

PRIMARY KEY, btree (client_registration_id, principal_name)
                    └── "keycloak" ──┘   └── "labuser" ──┘
                    세션 id 가 없다

같은 사용자가 어떤 브라우저에서 로그인하든 (keycloak, labuser) 라는 한 행을 쓴다. B-0 에서 빈 이름(AuthenticatedPrincipalOAuth2AuthorizedClientRepository)으로 짐작했던 것이 테이블 정의로 확정된다. 저장소를 Redis 로 바꿔도, 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.

세 저장소를 세는 명령을 여기서 확정한다. 관찰 절에서 이 세 숫자를 로그아웃 전후로 비교하는데, 다른 명령으로 재면 비교가 아니다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'

형태(모양은 observed) — B-1 측정과 같은 모양이다

bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae

KEYS * 대신 --scan 을 쓰는 것은 KEYS 가 Redis 를 잡아 두고 전 키를 훑기 때문이다. 그리고 dbsize 는 이 실험에서 부정확하다 — Redis 하나를 BFF 와 oauth2-proxy(B-7)가 나눠 쓰므로 dbsize 에는 _oauth2_proxy-… 키도 섞인다. 접두어로 걸러 세는 것이 맞다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak \
  -c 'select offline_flag, count(*) from offline_user_session group by 1'

온라인 세션도 offline_user_sessionoffline_flag = 0 으로 들어 있다 — B-3 에서 확인된 성질이다. 이 실험대는 이렇게 했다(observed) — 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. 위 DB 질의는 같은 숫자를 DB 쪽에서 보는 형태이고 가이드가 미검증으로 표시했다(unknown). 관리 API 로 보려면 이쪽이다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get client-session-stats -r keycloak-patterns

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 보려면 base64 -d | wc -c 로 센다.

2026-09-17 에 이 셋을 나란히 쳤고, 두 수가 안 맞는다(observed).

② select count(*) from oauth2_authorized_client     →  0
③ select offline_flag, count(*) … group by 1        →  offline_flag 0 · count 5
④ kcadm get client-session-stats                    →  clientId bff-confidential · active 4

DB 는 5 인데 관리 API 는 4 다. 둘이 세는 단위가 다르다offline_user_session 은 사용자 세션의 행이고 client-session-stats 는 클라이언트 세션을 클라이언트마다 센다. 사용자 세션 하나에 클라이언트 세션이 0개일 수도 여럿일 수도 있으므로 두 수는 맞을 이유가 없다. 대조군으로 쓸 때는 둘 중 하나를 골라 끝까지 그것만 쓴다.

그리고 --scan | xargs … deltr -d '\r' 이 없어도 된다(observed). 오래 의심만 하고 안 가렸던 것인데, 시험용 키를 넣고 그 형태를 그대로 쳤더니 del5 를 돌려주고 스캔이 빈손이 됐다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' | od -c | head -3
0000000   b   f   f   :   s   e   s   s   i   o   n   :   p   r   o   b
0000020   e   3  \n

줄 끝이 줄바꿈 하나다. 갈리는 것은 redis-cli 가 아니라 kubectl exec 에 tty 가 붙었는지다 — 파이프로 받는 이 형태에는 tty 가 없어 CR 도 없다. B-1 은 tty 가 붙는 형태로 쳤고 그쪽에서는 그 조각이 필요하다.

브라우저에서 https://app1.hyeonworks.com/ 를 열고 Keycloak 로그인 을 눌러 labuser / labpass 로 들어간 뒤 token 경계 확인 을 누른다.

실측(observed) — 해설 문서 3절

{"principal":"labuser",
 "accessTokenStoredOnServer":true,       B-1 에서는 false 였다
 "refreshTokenStoredOnServer":true,
 "browserTokenCount":0}

accessTokenStoredOnServertrue 다. B-1 에서는 authorized client 가 프로세스 메모리에 있어 로그인을 처리하지 않은 replica 가 답하면 아무것도 못 찾았고, 지금은 두 replica 가 같은 PostgreSQL 행을 본다. 이 값이 아직 false 로 나온다면 테이블은 만들었는데 옛 세션을 쓰고 있는 것이다 — 증거의 b2-before-relogin.png 가 정확히 그 상태다.

대조군 행을 잡는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select client_registration_id, principal_name, access_token_issued_at,
          md5(access_token_value) as at_md5
     from oauth2_authorized_client"

실측(observed) — 04-overwrite-test.txt

=== [현재] 같은 사용자의 항목 ===
 client_registration_id | principal_name |   access_token_issued_at   |              at_md5              
------------------------+----------------+----------------------------+----------------------------------
 keycloak               | labuser        | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
(1 row)

  행 수: 1

(1 row)at_md5 둘 다 적어 둔다. 토큰 값이 아니라 md5 를 보는 까닭은, 값 자체가 지금 쓸 수 있는 자격증명이라 터미널 스크롤백에 남기면 안 되기 때문이다. md5 는 「같은가 다른가」만 답하고 그게 이 단계가 묻는 전부다.

크기도 같이 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select client_registration_id, principal_name, access_token_type,
          length(access_token_value) as at_len, length(refresh_token_value) as rt_len
     from oauth2_authorized_client"

실측(observed) — 해설 문서 3절. 이 표는 .txt 증거에는 없고 문서에만 남아 있다

 client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
 keycloak               | labuser        | Bearer            |   1431 |    744

주입

「두 번째 브라우저」를 만든다. 되돌리기를 먼저 읽어 둔다 — 지운 세션은 되살릴 수 없고, 브라우저에서 다시 로그인하면 새 세션이 만들어져 원래 상태로 돌아온다.

증거 04-overwrite-test.txt 는 실제로 한 일을 이렇게 적었다.

실측(observed)

=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
  (브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
  Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다

「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」이었다. 조회 키가 (clientRegistrationId, principalName) 이므로 브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가다. 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, 측정하지 않은 것을 측정한 것처럼 적었다고 정정했다. 진짜로 두 브라우저를 쓰려면 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 되고, 결과는 같아야 하며 다르면 그게 더 중요한 발견이다.

지우기 전에 무엇을 지울지 눈으로 본다. 이 Redis 는 BFF 혼자 쓰는 것이 아니다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan

형태(모양은 observed)

bff:session:sessions:c63c39ee-...
bff:session:expires:c63c39ee-...
_oauth2_proxy-f6a9201fd534a047998278452001ccbf

_oauth2_proxy- 로 시작하는 키가 섞여 있으면 FLUSHALL 을 치면 안 된다 — B-7 의 oauth2-proxy 세션까지 날아가 그쪽 실험이 오염된다. 접두어로 골라 지운다.

이 실험대는 이렇게 했다(observed) — 원래 실행은 스크립트였고, 아래 형태는 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 것이다(unknown). 후속 문서 3절이 oauth2-proxy 세션을 지울 때 쓴 것과 같은 모양이다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
  | xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
date '+%H:%M:%S 세션 삭제'

형태(모양은 observed)

(integer) 2
16:21:03 세션 삭제

시각을 적어 둔다. 뒤에서 access_token_issued_at 이 이 시각 뒤인지로 「새 로그인이 실제로 일어났는가」를 판정한다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'

첫 명령은 아무것도 안 나와야 하고 두 번째는 아까와 같아야 한다. 두 번째까지 비었으면 FLUSHALL 을 친 것이고 B-7 세션을 날린 것이다.

kubectl -n keycloak-lab get pods -l app=bff

RESTARTS 가 여전히 0 이어야 한다. 세션을 지우는 것은 BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고, 그 상태로 재면 「덮어쓰기」가 아니라 「파드 재시작」을 재게 된다.

브라우저에서 https://app1.hyeonworks.com/ 를 새로고침하고 token 경계 확인 을 누른다. 로그인 화면이 뜨지 않고 그냥 들어가진다. Redis 세션은 지워졌지만 Keycloak SSO 세션은 살아 있어서, BFF 가 /oauth2/authorization/keycloak 으로 보내면 Keycloak 이 화면 없이 즉시 코드를 돌려주고 새 로그인 한 벌이 조용히 만들어진다. 이것이 「모의 두 번째 브라우저」다. 같은 조용한 재인증이 로그아웃 뒤에는 「로그아웃했는데 다시 들어가진다」로 보인다 — 같은 성질의 양면이다.

관찰

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

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select client_registration_id, principal_name, access_token_issued_at,
          md5(access_token_value) as at_md5
     from oauth2_authorized_client"

실측(observed) — 04-overwrite-test.txt

=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
 client_registration_id | principal_name |   access_token_issued_at   |              at_md5              
------------------------+----------------+----------------------------+----------------------------------
 keycloak               | labuser        | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
(1 row)

  행 수: 1

  ★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다

세 가지를 한꺼번에 본다.

대조군 지금 읽는 법
행 수 (1 row) (1 row) INSERT 가 아니다
at_md5 675af228… e19a63fc… 내용은 바뀌었다
issued_at 05:10:46 05:12:13 삭제 시각 뒤 = 새 로그인 맞다

셋 중 하나만 보면 틀린다. 행 수만 보면 「아무 일도 없었다」로, md5 만 보면 「새 행이 생겼나?」로 읽힌다. UPDATE 다.

   브라우저 A 로그인  →  (keycloak, labuser) 행 생성
   브라우저 B 로그인  →  같은 행을 덮어쓴다
                          └─ A 의 토큰은 사라진다

A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다. 같은 사용자이므로 당장은 아무 증상이 없고, 증상은 나중에 나온다.

언제 문제가 되는가
B 가 로그아웃하면 A 도 같이 끊긴다 (행이 지워지므로)
refresh 회전이 켜져 있으면 A 와 B 가 같은 refresh token 을 다툰다 → B-3
스코프가 다른 로그인이면 나중 것이 이긴다

저장소를 바꾸면 고쳐지는가 — 안 고쳐진다. PRIMARY KEY 줄이 답이다.

   InMemory   →  PostgreSQL  →  Redis  →  직접 구현
        └────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘

고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라 OAuth2AuthorizedClientRepository 쪽 이야기다.

후보 컨트롤러 변경 조회 키 문제
JdbcOAuth2AuthorizedClientService 불필요 (같은 인터페이스) 안 고쳐짐
Redis 직접 구현 불필요 안 고쳐짐
HttpSessionOAuth2AuthorizedClientRepository 필요 (Repository 로 바꿔야) 고쳐짐

이 실험이 세 번째를 고르지 않은 것은 Q3 가 "Redis 와 JDBC 중 무엇"을 물었기 때문이고, 그 대가로 조회 키 문제가 풀리지 않은 채로 있다. 선택이 남긴 자국을 측정한 것이지 실수가 아니다.

저장된 것이 평문인지 본다. 값을 찍기 전에 무엇을 찍게 될지 길이로 먼저 안다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select length(refresh_token_value) from oauth2_authorized_client"

실측(observed) — 해설 문서 3절의 rt_len

744

744 바이트다. 암호화된 덩어리라면 여기서 알 수 없으므로 앞 몇 글자만 본다.

이 실험대는 이렇게 했다(observed) — 원래 실행은 앞 200자 남짓을 통째로 찍었다. 따라 하는 사람은 아래 형태로 화면에 남는 양을 줄인다. 가이드가 이 줄을 미검증으로 표시했다(unknown).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"

실측(observed) — 03-plaintext-tokens.txt. 원래 실행이 찍은 문자열의 앞 36자만 옮긴다. 그 뒤는 지금 쓸 수 있는 자격증명이라 증거 파일에만 둔다

=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU

eyJ 로 시작한다. 그것이 {" 의 base64 이고, JWT 는 예외 없이 이렇게 시작한다. convert_from 이 성공한다는 것 자체가 답이다 — 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 난다. 읽힌다는 것은 텍스트라는 뜻이다.

★ 2026-09-17 에 이 구간을 처음 밟았고, 값이 문서와 거의 그대로 나온다(observed). TLS 를 세우고 로그인한 뒤 7 절과 관찰 1·2 를 전부 쳤다.

 client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
 keycloak               | labuser        | Bearer            |   1437 |    744

rt_len744 로 같고 at_len1431 대신 1437 이다 — access token 은 클레임에 따라 길이가 조금씩 달라진다. 미검증이던 left(convert_from(...), 40) 형태도 그대로 돌고, 나온 문자열이 위에 실린 36자와 한 글자도 다르지 않다.

덮어쓰기도 재현됐다(observed). 같은 사용자로 새 브라우저에서 한 번 더 로그인했더니 행 수는 1 그대로인데 at_md5 가 바뀌었다 — 앞 로그인의 토큰이 그 순간 사라진다. PRIMARY KEY (clientRegistrationId, principalName) 가 그렇게 만든다.

전   행 1 · at_md5 722769ac041a42164d08060e5446d02b
뒤   행 1 · at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 · 발급 2026-09-17 08:05:52

정말 JWT 인지 헤더를 풀어 본다. 가이드는 이 줄도 미검증으로 표시한다(unknown).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
  | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

실측(observed) — 03-plaintext-tokens.txt

=== 저장된 바이트를 그대로 디코드한 결과 ===
  refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
  refresh_token 페이로드(앞부분):
    {"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
  access_token 헤더  : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}

  → bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
    DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.

DB 읽기 권한만 있으면 쓸 수 있는 토큰을 얻는다. 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든 그대로 쓸 수 있다. Spring Security 기본 구현은 저장 시 암호화하지 않는다. 암호화하려면 JdbcOAuth2AuthorizedClientService 를 감싸거나 직접 구현해야 한다.

원래 실행은 여기서 한 번 넘어졌고 증거 파일에 그 실패가 그대로 있다.

실측(observed) — 03-plaintext-tokens.txt

=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
  File "<string>", line 3
    h=open(/tmp/hdr.txt).read().strip()
           ^
SyntaxError: invalid syntax

파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨린 것이다. 셸 안에 프로그램을 밀어 넣으면 문법 오류가 측정 결과 칸에 남는다. cutbase64 -d 로 충분하고, 그 둘은 문법이 틀릴 곳이 없다.

로그아웃 전에 세 숫자를 먼저 잡는다. 주입 전에 정해 둔 명령 그대로다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'

실측(observed) — 04-overwrite-test.txt

=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
  로그아웃 전
    Redis: 1 키
    PostgreSQL: 1 행

화면에 로그아웃 버튼이 없다index.html 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은 CSRF 토큰이 붙은 POST /logout 이므로 브라우저 콘솔에서 친다 (F12 → Console, 로그인된 app1 탭에서). 가이드가 미검증으로 표시한 조각이다(unknown).

const csrf  = await (await fetch('/bff/csrf')).json();
const token = decodeURIComponent(
  document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
console.log(r.status, r.url);

셸이 아니라 브라우저인 까닭 — 세션 쿠키가 HttpOnlycurl 로 로그인 상태를 재현할 수 없다. XSRF-TOKEN 쿠키만 JS 가 읽을 수 있게 되어 있고 (CookieCsrfTokenRepository.withHttpOnlyFalse()), 그래서 이 조각이 성립한다. 해설 문서 8절은 같은 일을 form 파라미터 _csrf 로 적었다 — 어느 쪽이든 SpaCsrfTokenRequestHandler 가 받아 준다. 되돌리기는 브라우저에서 다시 로그인하는 것이다.

로그아웃 후, 같은 세 명령을 친다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select principal_name, access_token_issued_at, access_token_expires_at
     from oauth2_authorized_client"
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'

실측(observed) — 05-logout-cleanup.txt

=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
  Redis 세션      : 0 키
  PostgreSQL 토큰 : 1 행

 principal_name |   access_token_issued_at   |  access_token_expires_at   
----------------+----------------------------+----------------------------
 labuser        | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
(1 row)


  ★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것

=== Keycloak 쪽 SSO 세션은? ===
  Keycloak 온라인 세션: 2

세 숫자를 나란히 놓는다.

   로그아웃 후:
     Redis 세션      : 0 키    ← 정리됨
     PostgreSQL 토큰 : 1 행    ← 평문 refresh token 이 그대로 남는다
     Keycloak SSO    : 2 세션  ← 남아 있다

셋 중 하나만 지워졌다.

   로그아웃
     ├─▶ HttpSession 무효화        ✔ Redis 키 삭제됨
     ├─▶ authorized client 삭제    ✗ 아무도 안 지운다
     └─▶ Keycloak SSO 종료         ✗ RP-initiated logout 을 안 보낸다

남은 행의 access_token_expires_atissued_at 의 60초 뒤인 것도 같이 본다 (accessTokenLifespan=60). access token 은 이미 만료됐지만 같은 행의 refresh token 은 아직 쓸 수 있고, 그건 평문이다.

브라우저에서 https://app1.hyeonworks.com/ 를 다시 열면 로그인 화면이 안 뜨고 그냥 들어가진다. 주입 검증에서 본 것과 같은 조용한 재인증이다. 애플리케이션 세션은 지웠는데 IdP 세션은 살아 있으므로 IdP 가 화면 없이 새 세션을 만들어 주고, 사용자 입장에서는 로그아웃이 안 된 것이다.

필요한 것 방법
authorized client 삭제 LogoutSuccessHandler 에서 removeAuthorizedClient 호출
Keycloak 세션 종료 RP-initiated logoutOidcClientInitiatedLogoutSuccessHandler
두 곳을 원자적으로 한쪽이 실패하면? — 정리 순서와 실패 처리를 정해야 한다

Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이 바로 이 지점이며, 답은 「지금은 하나도 안 지운다」이다.

복구와 원상복구 확인표

남은 행을 지운다. 브라우저에서 다시 로그인하면 행이 다시 만들어지고, 표 자체는 지우지 않는다 — B-3 이 이 표를 쓴다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "delete from oauth2_authorized_client where principal_name = 'labuser'"

형태(모양은 observed)

DELETE 1

Keycloak SSO 세션은 사람이 직접 끊는다. RP 가 안 보내 주기 때문이다. 브라우저에서 아래 주소를 열고 확인 화면이 뜨면 승인한다. 이 실험은 여기까지 재지 않았다(unknown).

https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'

offline_flag = 0 의 개수가 줄어드는지 본다. 관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다 — 0 이 안 되어도 놀랄 일이 아니다. 지운 Redis 세션은 되돌아오지 않고, 브라우저에서 다시 로그인하는 것이 복구다.

항목 명령 돌아왔을 때
파드 kubectl -n keycloak-lab get pods -l app=bff 둘 다 1/1 Running, RESTARTS 0
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client' 컬럼 표가 나온다 (지우면 안 된다)
BFF 세션 … redis-cli --scan --pattern 'bff:session:*' 다시 로그인했으면 키가 있다
B-7 세션 … redis-cli --scan --pattern '_oauth2_proxy-*' 주입 전과 같아야 한다
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ 200

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
Did not find any relation named "oauth2_authorized_client" 스키마 초기화가 조용히 실패했다. 기본 DDL 의 blob 은 PostgreSQL 에 없는 타입 -postgres.sql 판본을 태운다
파드는 정상인데 토큰이 저장되지 않는다 같은 원인. continue-on-error: true 가 실패를 삼켰다 파드 로그에서 Did not find any relation 을 찾는다
token-boundary 가 계속 false 테이블은 만들었는데 옛 세션을 쓰고 있다 로그아웃 후 재로그인 — b2-before-relogin.png 가 그 상태다
psql ... < file 이 아무 일도 안 한다 kubectl exec-i 가 없다 exec -i deploy/postgres
행 수가 2 로 늘었다 principal 이 다르다(다른 사용자로 로그인) select principal_name from oauth2_authorized_client
md5 가 안 바뀌었다 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 access_token_issued_at 이 삭제 시각 뒤인지
B-7 실험이 갑자기 깨진다 FLUSHALL 을 쳤다. 같은 Redis 를 나눠 쓴다 접두어로만 지운다
파이썬 한 줄로 디코드하다 SyntaxError 원래 실행이 이 실수를 했다 cut -d. -f1 | base64 -d 로 충분하다
로그아웃 POST 가 403 CSRF 토큰이 없거나 이름이 틀렸다 /bff/csrfheaderName 을 그대로 쓴다
로그아웃했는데 다시 들어가진다 버그가 아니다. Keycloak SSO 세션이 살아 있다 RP-initiated logout 을 사람이 연다
dbsize 와 세어 본 키 수가 다르다 oauth2-proxy 키가 섞여 있다 --scan --pattern 으로 나눠 센다

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

  • (observed) 테이블이 없을 때의 Did not find any relation ...exit code 1, CREATE TABLE, 컬럼 표 전문과 PRIMARY KEY, btree (client_registration_id, principal_name), 대조군 행의 2026-09-04 05:10:46.927192 · 675af2286bfc2fd9d2bab7bc8f391df7, 재로그인 뒤의 2026-09-04 05:12:13.018828 · e19a63fc5aa18bd0a68b3e19dff16b3b(1 row), at_len 1431 · rt_len 744, 디코드한 두 JWT 헤더와 페이로드 앞부분, 로그아웃 뒤 Redis 0 키 · PostgreSQL 1 행 · Keycloak 온라인 세션 2, access_token_expires_atissued_at 의 60초 뒤인 것.
  • (unknown) Keycloak 세션을 DB 쪽에서 세는 질의, --scan | xargs ... redis-cli del 로 BFF 세션만 지우는 줄, left(convert_from(...), 40) 으로 앞 40자만 찍는 줄, cut·tr·base64 -d 로 헤더를 푸는 줄, 브라우저 콘솔의 로그아웃 조각, RP-initiated logout 주소. 가이드가 전부 미검증으로 표시했다.
  • 원래 실행과 다르게 적은 곳 — refresh token 값은 증거 파일에 200자 남짓이 그대로 있지만, 이 문서에는 앞 36자만 옮겼다. 나머지는 지금 쓸 수 있는 자격증명이라 옮기지 않는다. at_md5 두 개는 해시라 그대로 적었다.
  • (observed) 파이썬 한 줄로 JWT 헤더를 디코드하려다 난 SyntaxError 도 증거 파일에 그대로 있다. 그 시도가 깨진 뒤 cutbase64 -d 로 다시 받았다.
  • 스크린샷으로는 판정하지 못한다b2-tokens-shared-across-instances.png 는 B-0 의 b0-bff-token-boundary.png동일 파일이다(md5 9ed00537…). 두 시점 모두 accessTokenStoredOnServer: true 인 같은 화면이라 바이트가 같다. 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다.
  • 이 실험이 재지 않은 것 — 진짜 두 브라우저를 열어 같은 결과가 나오는지는 재지 않았다. 「세션을 지우고 다시 로그인」이 등가인 것은 조회 키가 같기 때문이라는 추론이지 측정이 아니다. RP-initiated logout 을 열었을 때 세션 수가 실제로 줄어드는지도 재지 않았다.

B-3 — 같은 refresh token 을 동시에 던지면 무엇이 부서지는가

근거: b3-refresh-token-contention.md (835줄). 실행 기록은 2026-09-04 14:1614:17 KST(observed).

이 실험이 가르는 것

B-2 가 토큰을 PostgreSQL 로 옮겼고 두 replica 가 같은 행을 본다. 조회 키에 session id 가 없으니 같은 사용자의 두 브라우저도 같은 행을 본다. 그 행에는 refresh token 이 하나 들어 있다. 둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.

예측
통념 하나는 성공하고 하나는 실패한다. 실패한 쪽이 새 토큰을 다시 읽어 재시도하면 된다
B-3 이 재는 것 진짜 그런가. 그리고 이긴 쪽은 멀쩡한가

이 구별이 설계를 가른다.

   실패가 사용자에게 안 보인다  →  재시도로 덮으면 된다
   실패가 사용자에게 보인다     →  애초에 겹치지 않게 lock 을 걸어야 한다

재시도로 회복되면 lock 이 필요 없고, 회복이 안 되면 lock 말고 답이 없다. 그러니 재야 할 것은 「몇 개가 성공했나」가 아니라 **「이긴 요청의 토큰을 다시 쓸 수 있나」**다.

재사용 탐지(reuse detection)가 이 실험의 배경이다. 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화하는데, 무효화된 옛 토큰이 다시 들어오면 두 가지 중 하나다.

   ① 정상 클라이언트가 응답을 못 받아 재시도했다        (무해)
   ② 토큰이 유출되어 공격자가 쓰고 있다                 (치명)

서버는 둘을 구별할 수 없다. 그래서 OAuth 2.0 보안 권고는 안전한 쪽으로 가정하고 세션 전체를 무효화하라고 말한다. 이 실험이 보는 파괴는 버그가 아니라 그 규격이 시키는 대로 동작한 결과이고, 그래서 "고쳐 달라"가 아니라 "겹치지 않게 하라"가 답이 된다.

가이드의 「이 가이드가 끝나면」 표는 다섯을 적는다 — 다섯 중 하나만 200 이고 나머지는 400 인 것, 오류 메시지가 두 종류인 것, 이긴 요청이 받은 토큰조차 못 쓰는 것, user session 은 남고 client session 만 사라진 것, refreshTokenMaxReuse 를 올려도 안 되는 것.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • B-2 가 끝나 있다. 토큰이 공유되어야 경쟁이 성립한다 — 다만 이 실험은 Keycloak 쪽 동작만 갈라 보려고 BFF 를 거치지 않고 토큰 엔드포인트를 직접 친다.
  • 명령은 kc-lab-1 에서 친다.
  • 브라우저는 필요 없다. direct grant(grant_type=password)로 토큰을 만들므로 전 구간을 터미널에서 한다.
  • realm 은 keycloak-patterns, 사용자는 labuser / labpass, 클라이언트는 bff-confidential.

이건 realm 설정을 바꾸는 실험이다. revokeRefreshToken 을 켜면 realm 전체에 걸린다 — 그 realm 을 쓰는 다른 실험(B-2 의 BFF 로그인 포함)이 이 설정의 영향을 받는다. 실험대에서만 한다. 전 구간 약 20분이고, 중간에 그만두려면 아래 한 줄이면 된다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0

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

시험군만 재는 측정은 측정이 아니다. 회전이 꺼진 상태에서 같은 명령을 먼저 돌려 두어야, 나중에 나오는 400 이 「원래 그런 것」인지 「내가 켠 것」 때문인지 갈린다.

파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
kubectl -n keycloak-lab get pods -o wide

형태(모양은 observed)

NAME         READY  STATUS   RESTARTS  AGE  IP           NODE
keycloak-0   1/1    Running  0         2d   10.42.1.43   kc-lab-2
keycloak-1   1/1    Running  0         2d   10.42.0.35   kc-lab-1
postgres-... 1/1    Running  0         5d   ...          kc-lab-2

Keycloak 이 둘 다 1/1 이어야 한다. 하나가 NotReady 면 Service 가 전부 한쪽으로 보내고, 그러면 동시성이 한 노드 안에서만 생긴다. 그래도 재현은 되지만 「replica 를 넘는 경쟁」이라고 말할 수 없게 된다.

kcadm 은 먼저 로그인해야 쓸 수 있고, 한 번 하면 파드 안에 세션이 남는다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

비밀번호를 화면에 찍지 않는다. 존재와 길이만 보려면 base64 -d | wc -c 로 센다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns \
  --fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan

실측(observed)

{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
지금
revokeRefreshToken 회전 스위치 false꺼져 있다
refreshTokenMaxReuse 회전이 켜졌을 때 몇 번까지 봐줄 것인가 0
accessTokenLifespan access token 수명(초) 60

기본값은 회전이 꺼져 있다. Q2 는 "realm 이 refresh token rotation 과 재사용 허용 0회를 쓰게 되어서" 를 전제로 하므로, 그 전제를 만드는 것이 이 실험의 주입이다. 지금 그대로 재면 Q2 와 다른 것을 재게 된다. accessTokenLifespan=60 은 B-0 에서 이 실험을 위해 넣어 둔 값이고, 만료를 기다리는 시간이 짧아야 재현이 된다.

Keycloak 이미지에는 curlwget 도 없다(exit 127). 그리고 이 실험은 발급받은 토큰을 뒤 단계에서 써야 하므로 --rm 임시 파드로는 안 된다. 파드를 하나 띄워 두고 exec 로 이어간다.

kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
  --env="CS=$(kubectl -n keycloak-lab get secret bff-secrets \
              -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s

형태(모양은 observed)

pod/b3-probe condition met

되돌리기는 파드를 지우는 것이다.

kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found

환경변수가 들어갔는지는 값이 아니라 길이로 본다.

kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC  CS길이=${#CS}"'

형태(모양은 observed)

KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token  CS길이=15

CS길이=0 이면 --env 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.

왜 Service 로 가는가. A-1·A-2 는 「어느 노드가 답했나」가 질문이라 파드 IP 로 직접 쳤다. 여기는 반대로 replica 를 넘는 경쟁이 질문이므로 Service 가 요청을 흩는 것이 오히려 필요한 조건이다.

이제부터는 이 파드 안에서 친다. 셸에 들어가는 편이 편하다.

kubectl -n keycloak-lab exec -it b3-probe -- sh

프롬프트가 / $ 로 바뀐다. 나올 때는 exit 이고 파드는 안 지워진다(--rm 이 없다).

토큰 하나를 발급받는다. 처음 한 번은 응답을 통째로 본다.

curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential \
  -d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid

형태(모양은 observed) — 한 줄 JSON 이 나온다

{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
 "refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}

expires_in 이 60 이다 — 앞에서 본 accessTokenLifespan 그대로다. 여기가 {"error":"unauthorized_client"} 면 클라이언트에 direct grant 가 꺼진 것이고, {"error":"invalid_grant"} 면 사용자 이름이나 비밀번호다.

이 실험대는 이렇게 했다(observed) — 변수에 담고 sid 를 뽑는다.

R=$(curl -s -X POST "$KC" \
      -d grant_type=password -d client_id=bff-confidential \
      -d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
      | sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자  SID=$SID"

실측(observed) — 01-concurrent-refresh.txt

=== [1] refresh token 하나 확보 ===
  토큰 길이: 811
  jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
  sid: BvFiB01Rntz1FcLdf7zG4BNt

SID 를 종이에 적어 둔다. 관찰 절에서 DB 를 뒤질 때 이 값이 필요하고, 그때는 파드 밖이라 변수가 안 넘어간다.

따라 하는 사람은 sid 가 빈 줄로 나오면 base64 패딩이나 base64url 문자(- _) 때문이므로 아래 형태로 페이로드 전체를 찍고 그 안에서 "sid" 를 눈으로 찾는다. 가이드가 이 줄을 미검증으로 표시했다(unknown) — 원래 실행은 위쪽 형태를 썼다.

echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
  | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

대조군은 둘이다. 첫째는 순차로 다섯 번 갱신하는 것이고, 파드 안에서 & 없이 친다.

for i in 1 2 3 4 5; do
  R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
        -d grant_type=refresh_token -d client_id=bff-confidential \
        -d "client_secret=$CS" -d "refresh_token=$RT")
  echo "순차 $i: $(echo "$R" | tail -1)"
  RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
done

증거 파일에는 순차 실행 기록이 없다(unknown). 해설 문서가 "순차 실행이면 재현되지 않는다" 고 말하고, 이 단계는 따라 하는 사람이 자기 손으로 확인하는 순서다. 다섯 줄 전부 200 이어야 한다.

회전이 켜지면 RT 를 매번 다시 담아야 한다. 위 루프가 그렇게 되어 있다. 옛 것을 계속 쓰면 뒤에 나오는 400 이 「경쟁」 때문인지 「옛 토큰을 썼기」 때문인지 갈리지 않는다 — 이 실험에서 가장 흔한 자기오염이다.

둘째 대조군은 경쟁을 겪지 않은 세션의 모양이다. 이것이 없으면 나중에 나오는 0 이 「경쟁 때문」인지 「원래 그런 표」인지 모른다. 파드 밖(kc-lab-1)에서 친다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, us.offline_flag,
          (select count(*) from offline_client_session cs
            where cs.user_session_id = us.user_session_id) as client_sessions
     from offline_user_session us
    where us.user_session_id = 'JT-XuepgutWcE273QwAnIXta'"

실측(observed) — 03-client-session-removed.txt 의 대조군 부분

=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
  새 sid: JT-XuepgutWcE273QwAnIXta
     user_session_id      | client_sessions 
--------------------------+-----------------
 JT-XuepgutWcE273QwAnIXta |               1
(1 row)

client_sessions = 1. 정상 세션은 이렇게 생겼다. 위 질의의 sid 는 원래 실행의 대조군 세션 것이고, 따라 하는 사람은 자기 SID 를 넣는다.

user session 과 client session 은 다른 것이다.

   user session      "이 브라우저는 labuser 로 로그인함"
     ├─ client session : bff-confidential
     └─ client session : oauth2-proxy

사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 user session 하나 아래에 client session 이 여럿 달린다. 그게 SSO 다. 재사용 탐지는 이 중 client session 만 제거한다. 온라인 세션인데 표 이름이 offline_user_session 인 것이 헷갈리는데, offline_flag 열이 그것을 가른다 — 위 출력의 offline_flag = 0 이 「온라인 세션」이다.

주입

revokeRefreshToken 이 회전 스위치다. 이름이 「회전(rotation)」이 아니라 **「취소(revoke)」**다. 켜면 새 토큰을 줄 때 옛 토큰을 무효화하고, 그 결과가 회전이다.

설정
revokeRefreshToken 회전 스위치. 켜면 새 토큰 발급 시 옛 토큰을 무효화
refreshTokenMaxReuse 그 위에서 몇 번까지 봐줄 것인가

refreshTokenMaxReuserevokeRefreshToken 이 켜져야 의미가 있다. 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니 「봐줄 횟수」를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색인 까닭이다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 켬'

형태(모양은 observed) — 성공하면 아무 말도 안 한다

14:16:12 회전 켬

시각을 적어 둔다. 관찰 절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다. 켜기 전에 발급한 토큰으로 재면 안 된다 — 발급 시점의 정책이 아니라 검증 시점의 정책이 적용되므로 섞여서 해석이 안 된다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다. 설정은 똑같은 명령으로 다시 읽는다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns \
  --fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan

형태(모양은 observed)

{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }

revokeRefreshTokentrue 여야 한다. false 그대로면 update 가 다른 realm 에 갔거나 kcadm 세션이 만료된 것이다. kcadm 은 실패해도 조용할 때가 있다 — 반드시 다시 읽어서 확인한다.

kubectl -n keycloak-lab get pods -l app=keycloak

RESTARTS 가 여전히 0 이어야 한다. realm 설정 변경은 재시작을 일으키지 않는다. 여기서 재시작이 올랐다면 다른 것을 건드린 것이고, 그 상태로 재면 「경쟁」이 아니라 「재시작」을 재게 된다.

동시성을 넣기 전에, 회전 자체가 도는지 확인한다. 파드 안에서 새 토큰을 하나 받고 한 번 갱신한 뒤 옛 것을 다시 쓴다. 가이드는 이 단계를 증거 파일에 없는 사전 확인이라고 적는다(unknown).

R=$(curl -s -X POST "$KC" \
      -d grant_type=password -d client_id=bff-confidential \
      -d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')

curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
  -d grant_type=refresh_token -d client_id=bff-confidential \
  -d "client_secret=$CS" -d "refresh_token=$OLD"

curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
  -d grant_type=refresh_token -d client_id=bff-confidential \
  -d "client_secret=$CS" -d "refresh_token=$OLD"

1회차 200, 2회차 400. 옛 토큰이 무효화된다는 것이 회전이 켜졌다는 뜻이다. 2회차도 200 이면 회전이 안 켜진 것이고, 그 상태로 관찰 절을 돌리면 다섯 개가 전부 200 으로 나온다 — 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.

관찰

사전 확인에서 쓴 토큰은 이미 무효다. 깨끗한 토큰을 하나 새로 받는다.

R=$(curl -s -X POST "$KC" \
      -d grant_type=password -d client_id=bff-confidential \
      -d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
      | sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자  SID=$SID"

SID 를 다시 적어 둔다.

★ 동시에 다섯 개 — &wait 이 없으면 재현되지 않는다. 순차로 돌리면 아무 일도 안 일어난다. 진짜로 겹쳐야 한다.

이 실험대는 이렇게 했다(observed) — 원래 실행은 스크립트였다. 따라 하는 사람은 아래 형태를 친다. 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시했고(unknown), 본문과 응답 코드를 파일로 갈라 순서대로 다시 읽을 수 있게 했다.

i=1
while [ $i -le 5 ]; do
  ( curl -s -o /tmp/b$i -w '%{http_code}' -X POST "$KC" \
      -d grant_type=refresh_token -d client_id=bff-confidential \
      -d "client_secret=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
  i=$((i+1))
done
wait
for i in 1 2 3 4 5; do
  echo "요청 $i: HTTP $(cat /tmp/c$i)  $(head -c 100 /tmp/b$i)"
done

셸 문법 세 조각이 전부다.

   (  ...  ) &     서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
   wait            띄운 것이 전부 끝날 때까지 기다린다
   > /tmp/c$i      각자 자기 파일에 쓴다 → 출력이 안 섞인다

& 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다. wait 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다. 다섯 개를 동시에 띄우면 출력이 뒤섞여 어느 줄이 어느 요청인지 알 수 없어서 파일로 받고 wait 뒤에 순서대로 읽는다.

실측(observed) — 01-concurrent-refresh.txt

=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
  요청 1: HTTP 400  {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
  요청 2: HTTP 400  {"error":"invalid_grant","error_description":"Session doesn't have required client"}
  요청 3: HTTP 400  {"error":"invalid_grant","error_description":"Session doesn't have required client"}
  요청 4: HTTP 400  {"error":"invalid_grant","error_description":"Session doesn't have required client"}
  요청 5: HTTP 200  {"access_token":"...(발급됨)

★ 2026-09-17 에 같은 모양이 다시 나왔다(observed). 손으로 치기 좋게 고친 5-way 루프도 그대로 돌았다 — 그때까지 미검증이던 형태다.

요청 1: HTTP 400  Session doesn't have required client
요청 2: HTTP 400  Session doesn't have required client
요청 3: HTTP 400  Maximum allowed refresh token reuse exceeded
요청 4: HTTP 200  (발급됨)
요청 5: HTTP 400  Session doesn't have required client
이긴 요청의 새 refresh 길이: 810
그 토큰을 다시 쓰면: 400  Session doesn't have required client

몇 번 요청이 이기는지는 실행마다 다르다. 위 실측은 5번이고 2026-09-17 은 4번이다. 판정에 쓰는 것은 번호가 아니라 200 이 하나이고 그 하나가 받은 토큰도 안 통한다는 두 가지다.

성공 개수가 아니라 오류 메시지가 두 종류인 것을 본다.

메시지
Maximum allowed refresh token reuse exceeded 재사용 탐지가 발동
Session doesn't have required client 그 여파 — client session 이 이미 없다

「하나만 이기고 나머지는 진다」였다면 지는 쪽 메시지는 전부 같아야 한다. 두 종류라는 것은 중간에 상태가 바뀌었다는 뜻이다. 성공한 번호는 환경마다 다르고 증거에서는 5번이었지만 순서는 스케줄링에 달렸다 — 몇 번이 이겼는가는 아무 의미가 없다.

★ 이긴 요청의 토큰을 다시 써 본다. 여기서 진짜 답이 나온다. 다섯 응답 중 refresh_token 이 들어 있는 것을 꺼낸다.

NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
      | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
echo "새 refresh token 길이: ${#NEW}"

curl -s -w '\n%{http_code}\n' -X POST "$KC" \
  -d grant_type=refresh_token -d client_id=bff-confidential \
  -d "client_secret=$CS" -d "refresh_token=$NEW"

실측(observed) — 02-session-impact.txt

=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
  새 refresh token 길이: 810
  그 토큰으로 다시 갱신:  HTTP 400
    {"error":"invalid_grant","error_description":"Session doesn't have required client"}

이긴 요청조차 쓸 수 없는 토큰을 받았다.

   애플리케이션이 본 것 :  HTTP 200 + 새 토큰   →  "성공했다"
   실제 상태          :  세션이 이미 없다      →  다음 요청에서 끊긴다

오류가 지연되어 나타난다. 200 을 받은 코드는 성공했다고 믿고 토큰을 저장하고, 끊긴 것은 그다음 요청에서 안다. 로그를 볼 때 원인 시각과 증상 시각이 어긋나 보이는 까닭이 이것이다. 「재시도하면 되지 않나」가 여기서 무너진다 — 새 토큰을 다시 읽어 재시도해도 그 토큰이 이미 무효라 재시도할 대상이 없다.

무엇이 사라졌는지는 DB 가 말한다. 파드 밖에서 치고, sid 는 앞에서 적어 둔 값이다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, us.offline_flag, us.last_session_refresh
     from offline_user_session us
    where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"

실측(observed) — 02-session-impact.txt

=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
     user_session_id      | offline_flag | last_session_refresh 
--------------------------+--------------+----------------------
 BvFiB01Rntz1FcLdf7zG4BNt | 0            |           1788498996
(1 row)

행이 있다. 세션이 통째로 지워진 것이 아니다. 그러면 왜 Session doesn't have required client 인가 — client session 을 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, us.offline_flag,
          (select count(*) from offline_client_session cs
            where cs.user_session_id = us.user_session_id) as client_sessions
     from offline_user_session us
    where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"

실측(observed) — 03-client-session-removed.txt

=== user session 과 client session 을 나눠서 본다 ===
     user_session_id      | offline_flag | client_sessions 
--------------------------+--------------+-----------------
 BvFiB01Rntz1FcLdf7zG4BNt | 0            |               0
(1 row)

client_sessions = 0. 대조군은 1 이었다. 같은 명령, 다른 결과 — 그것이 이 실험의 판정이다.

   user session        "이 브라우저는 labuser 로 로그인함"     ← 남는다
     └─ client session "그중 bff-confidential 에 대한 상태"   ← 지워졌다

오류 문구가 정확히 그 말을 한다 — 세션은 있는데 그 클라이언트 몫이 없다. 메시지를 오해해서 「세션이 만료됐다」로 읽으면 엉뚱한 곳을 고치게 된다.

폐기 목록에 실린 것도 아니다.

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

실측(observed) — 02-session-impact.txt

=== [5] revoked_token 테이블 ===
 revoked_count 
---------------
             0
(1 row)

0 이다. 「토큰을 블랙리스트에 올려서 막는다」가 아니라 client session 이 사라져서 검증할 대상이 없어진 것이다. 토큰을 지우는 방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 그 client 에 대한 모든 토큰이 한꺼번에 죽는다.

왜 이긴 쪽도 죽는지는 시간선이 말한다.

   t0  5개가 동시에 도착
   t1  하나가 처리를 시작 → 새 토큰 발급 준비
   t2  다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
   t3  ★ client session 제거
   t4  t1 의 응답이 나간다 → HTTP 200, 새 토큰
   t5  그 토큰을 쓰면 → client session 이 없다 → 400

t3 와 t4 의 순서가 전부다. 응답을 만들던 요청은 이미 「성공」이 확정된 상태로 나가고, 그 사이 바닥이 빠진다.

정책을 바꿔 비교한다. 한 번 더 재기 전에 세션을 새로 만든다 — 파괴된 세션으로 재면 전부 400 이다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update realms/keycloak-patterns -s revokeRefreshToken=false

파드 안에서 새 토큰 발급 → 동시 다섯 개 → 이긴 토큰 재사용 → client session 세기를 그대로 반복한다.

실측(observed) — 04-policy-comparison.txt

=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
    sid=iW1CGyO7COdyJLryIrCt3njk
    1: 200 
    2: 200 
    3: 200 
    4: 200 
    5: 200 
    성공 5 / 5
    이긴 토큰 재사용: HTTP 200
    남은 client_session: 1

전부 200 이고 세션도 멀쩡하다. 같은 refresh token 을 계속 쓸 수 있으므로 경쟁 자체가 성립하지 않는다. 대신 잃는 것 — 토큰이 유출되면 만료까지 계속 쓸 수 있고, 회전의 목적이 그 창을 좁히는 것이었다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1

실측(observed) — 04-policy-comparison.txt

=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
    sid=72c04JCdr0NpCHGQmXWW2wM8
    1: 200 
    2: 400 "error_description":"Session doesn't have required client"
    3: 200 
    4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
    5: 400 "error_description":"Session doesn't have required client"
    성공 2 / 5
    이긴 토큰 재사용: HTTP 400
    남은 client_session: 0

성공이 1에서 2로 늘었지만 남은 client_session: 0 은 그대로다.

구성 성공 이긴 토큰 재사용 client_session
A 회전 ON · maxReuse=0 1 / 5 400 0 — 파괴
B 회전 OFF 5 / 5 200 1 — 생존
C 회전 ON · maxReuse=1 2 / 5 400 0 — 파괴

refreshTokenMaxReuse 를 올리는 것은 해법이 아니다. 동시 요청이 N 개면 maxReuse ≥ N-1 이어야 하는데, 그러면 회전의 보안 목적이 사라진다. 값을 올려 버티려는 시도는 "몇 개까지 동시에 올 것인가"를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.

그래서 답은 lock 이다. 그리고 lock 은 저장소 쪽에 있어야 한다 — 프로세스 안의 synchronized 는 replica 를 넘지 못한다.

후보
PostgreSQL 행 잠금 SELECT ... FOR UPDATEA-0 에서 Keycloak 자신이 쓰는 방식
Redis 분산 lock SET NX PX — TTL 로 스스로 풀린다
갱신 전용 인스턴스 단일 지점. 그 인스턴스가 죽으면?

잠금의 수명이 연결의 수명과 묶이는 것이 DB 잠금의 이점이다. 프로세스가 죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis lock 은 TTL 이 짧으면 중복 갱신, 길면 정지이고, 그 약점은 B-5 에서 다시 만난다.

복구와 원상복구 확인표

realm 설정을 되돌린다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 끔'

똑같은 명령으로 다시 읽는다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns \
  --fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan

형태(모양은 observed)

{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }

처음에 읽은 세 값과 전부 같아야 한다. accessTokenLifespan 이 60 이 아니면 다른 것도 건드린 것이다.

kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found

파괴된 세션의 user_session_id 행은 TTL 로 스스로 사라진다. 바로 치우고 싶으면 브라우저에서 아래를 연다. 이 실험은 여기까지 재지 않았다(unknown).

https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'

관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다. 0 이 안 되어도 놀랄 일이 아니다.

항목 명령 돌아왔을 때
realm get realms/... revokeRefreshToken : false
파드 kubectl -n keycloak-lab get pods -l app=keycloak 둘 다 1/1 Running, RESTARTS 0
탐침 kubectl -n keycloak-lab get pod b3-probe NotFound (없어야 정상)
BFF 로그인 브라우저에서 https://app1.hyeonworks.com/ 로그인이 되고 token 경계 확인 이 답한다
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns 200

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 그 기록에서 곧바로 따라 나오는 것이라고 적는다.

증상 원인 확인
다섯 개가 전부 200 & 를 빼서 순차로 돌았다 — 경합이 안 생긴다 루프에 ( ... ) &wait 이 있는지
다섯 개가 전부 200 (& 는 있는데) 회전이 안 켜졌다 주입 검증을 다시. 사전 확인이 200/400 이어야 한다
결과 파일이 비어 있다 wait 이 없다. 아직 안 끝난 요청을 읽었다 wait 뒤에 cat
출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 다섯 개가 같은 터미널에 동시에 쓴다 파일로 받고 나중에 읽는다
전부 400 invalid_grant 인데 메시지가 한 종류 RT 를 계속 썼다 (자기오염) 갱신마다 RT 를 다시 담는다
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다
CS길이=0 secret 이름이나 키가 틀렸다 get secret bff-secrets -o jsonpath='{.data}' 로 키 이름만 본다
{"error":"unauthorized_client"} 클라이언트에 direct grant 가 꺼져 있다 kcadm 으로 directAccessGrantsEnabled 확인
kcadm 이 401 / 아무 말 없이 실패 로그인 세션이 만료됐다 config credentials 를 다시
sid 가 빈 줄 base64 패딩 또는 base64url 문자 tr '_-' '/+' 를 넣어 다시
DB 질의에서 행 자체가 없다 다른 SID 를 넣었다 파드 안에서 echo "$SID" 를 다시 본다
회전을 켠 뒤 브라우저 로그인이 이상하다 realm 전체에 걸린 설정이다. BFF 도 영향받는다 실험이 끝나면 반드시 realm 을 되돌린다

부하 도구가 없는 것도 설계다. 동시성 5는 abk6 도 필요 없고 셸의 &wait 이면 충분하며, 그 편이 무엇이 일어났는지 더 잘 보인다 — 요청 다섯 개의 본문을 전부 파일로 갖고 있으니 나중에 다시 읽는다. 부하 도구는 개수를 늘려야 할 때 쓴다. 이 실험이 묻는 것은 개수가 아니라 **「겹치면 무엇이 부서지는가」**이고, 그건 둘만 겹쳐도 답이 나온다. 다섯 개를 쓴 것은 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지 다섯이 필요해서가 아니다.

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

  • (observed) 주입 전 realm 의 세 값 { "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }, 토큰 길이 811 과 jti 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c 와 sid BvFiB01Rntz1FcLdf7zG4BNt, 동시 다섯 요청의 상태 코드와 오류 문구 두 종류, 이긴 토큰의 길이 810 과 그 토큰으로 다시 갱신했을 때의 HTTP 400, 그 sid 의 offline_flag 0 · last_session_refresh 1788498996 · client_sessions 0, 대조군 세션 JT-XuepgutWcE273QwAnIXtaclient_sessions 1, revoked_count 0, 구성 B·C 의 sid 와 다섯 코드와 남은 client_session 값, CS길이=15.
  • (unknown) &wait 으로 다섯을 동시에 띄우는 while 루프, sid 를 base64url 로 다시 푸는 줄, 순차 다섯 번 갱신 루프, 회전이 도는지 보는 사전 확인 두 줄. 가이드가 전부 미검증으로 표시했고, 원래 실행은 스크립트로 했다. 순차 실행은 증거 파일에 기록 자체가 없다.
  • 이 실험이 재지 않은 것 — BFF 를 거쳐 같은 경쟁이 나는지는 재지 않았다. 여기서는 Keycloak 쪽 동작만 갈라 보려고 토큰 엔드포인트를 직접 쳤다. RP-initiated logout 으로 파괴된 세션을 치우는 것도 재지 않았고, 동시성을 5보다 늘리면 어떻게 되는지도 재지 않았다.
  • 추론이지 측정이 아닌 것refreshTokenMaxReuse ≥ N-1 이어야 한다는 것은 A·C 두 구성에서 관측한 결과에서 따라 나온 것이고, N 을 바꿔 가며 재 보지는 않았다. lock 후보 셋도 어느 것을 넣어 재현이 사라지는지 재지 않았다 — 이 실험은 무엇이 부서지는가까지다.

B-4 — 신원 헤더를 위조해 보내면 그대로 도착하는가

근거: b4-edge-authorization-scope.md (914줄). 실행 기록은 2026-09-04 14:23 KST(①②④)와 07:5107:53 UTC(③)(observed).

이 실험이 가르는 것

Edge(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. X-Auth-Request-User, X-Auth-Request-Roles 같은 것들이고, 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다. 그러면 그 헤더는 무엇을 보증하는가. Q4 는 확인한 사실로 이렇게 적어 두었다.

"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"

가이드는 넷을 따로 잰다.

   ① 여러 값을 어떻게 넣는가      쉼표? 헤더를 여러 개?     → 구별할 수 있나
   ② 커지면 어떻게 되는가         잘리나? 거부되나?
   ③ IdP 에서 바꾸면 언제 반영되나
   ④ 위조하면 통하는가            ★ 여기가 권한의 문제다

★ 무엇을 재는 경로인지 먼저 못박는다. 위조 헤더를 보내는 곳은 https://app1.hyeonworks.com/api/echo 이고, 그 경로는 header-lab 네임스페이스의 echo 앱으로 간다. 도착한 헤더를 그대로 되돌려주는 앱이며 그 경로는 permitAll 이라 oauth2-proxy 를 거치지 않는다. 이 실험이 재는 것은 「edge 가 인증을 끝낸 뒤의 인가」가 아니라 헤더를 받아 쓰는 upstream 이 그 값을 검증하는가다. 같은 위조 헤더를 JWT 를 요구하는 경로에 보내면 거기서 막히고, 그 대조를 3-1 이 잰다.

헤더가 누구인지만 말하면 위조는 인증 우회다. 헤더가 무엇을 할 수 있는지(role)까지 말하면 위조는 권한 상승이 된다. 로그인한 일반 사용자가 자기 요청에 X-Auth-Request-Roles: admin 을 한 줄 더 붙이는 것으로 끝난다. 그래서 이 구조는 세 곳이 동시에 성립해야만 안전하다고 가이드는 적는다.

   ① 외부 → upstream 직접 경로 차단   (NetworkPolicy)
   ② edge 에서 동명 헤더 덮어쓰기      (proxy_set_header)
   ③ upstream 에서 내부 credential 검증 (공통 경계)

하나라도 빠지면 나머지 둘이 무의미하다. 이 실험은 ②가 빠져 있다는 것을 재고, 그 결과로 ④가 성립한다는 것을 재고, ③이 한 곳에만 있다는 것을 확인한다.

전제와 되돌리기

  • 03-nginx · 04-tls · 05-keycloak 이 끝나 있다.

  • B-0 이 끝나 BFF 와 Redis 가 떠 있다.

  • app1.hyeonworks.com 이 경로에 따라 둘로 갈린다. / 는 BFF, /apiheader-lab 네임스페이스의 echo 앱이다. 이 실험은 /api/echo 만 쓴다.

  • 4절부터는 app2.hyeonworks.com 을 Grafana 에서 잠시 빌린다. 인증서가 auth · app1 · app2 만 덮으므로 네 번째 이름을 만들 수 없다.

  • 4절은 브라우저가 필요하다. oauth2-proxy 세션 쿠키가 HttpOnlycurl 로 로그인 상태를 재현할 수 없다.

    HttpOnly 는 짐작이 아니라 기동 로그에 적혀 있다.

    kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1
    

    실측01-orphan-lifecycle.txt

      기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
                 httponly:true expiry:1h0m0s ... refresh:disabled
    

    원래 실행은 그래서 Playwright 로 연 브라우저를 썼다.

  • 5-1 의 nginx 설정만 랩 호스트(test-server) 에서 한다. 다른 기계다. 앞의 curl 은 어디서 쳐도 되고, 밖에서 치는 편이 공격자 관점에 가깝다.

앞부분은 안전하고 뒷부분이 상태를 바꾼다.

무엇을 하나 되돌릴 것
1~3 요청만 보낸다. 클러스터 상태가 안 바뀐다 없음
4 Grafana 에서 app2 를 빌리고 IdP 의 사용자 속성을 바꾼다 Ingress · email 값 · 세션
5 nginx 설정을 바꾼다 (호스트) 설정 파일

전 구간 약 30분. 1~3 만 하고 멈춰도 이 실험의 결론 대부분이 나온다. 되돌리기는 셋이고, 셋 다 먼저 읽어 둔다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f /tmp/grafana-ingress-backup.yaml
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$UID -r keycloak-patterns -s email=labuser@example.com

UID 는 bash·zsh 에서 읽기 전용이다. 위 대입은 UID: readonly variable 로 실패하고, 그 다음 echo "uid=$UID"로그인 사용자의 uid(보통 1000)를 찍어 성공처럼 보인다. 그대로 이어 치면 update users/1000 이 되어 엉뚱한 것을 고치려 든다. 따라 하는 사람은 이름을 바꿔 쓴다 — 명령은 같고 변수 이름만 다르다.

USER_ID=$(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)
echo "uid=$USER_ID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$USER_ID -r keycloak-patterns -s email=labuser@example.com
sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx

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

시험군만 재는 측정은 측정이 아니다. 「위조 헤더가 도착했다」고 말하려면 아무것도 안 붙였을 때 무엇이 도착하는지를 먼저 봐야 한다. 넓은 것부터 좁혀 간다.

경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나
kubectl get ingress -A

형태(모양은 observed)

NAMESPACE      NAME           CLASS     HOSTS                   ADDRESS   PORTS  AGE
header-lab     echo           traefik   app1.hyeonworks.com               80     5d
keycloak-lab   bff            traefik   app1.hyeonworks.com               80     3d
keycloak-lab   keycloak       traefik   auth.hyeonworks.com               80     6d
observability  grafana        traefik   app2.hyeonworks.com               80     6d

app1 이 두 줄이다. 같은 호스트에 Ingress 가 둘이고 경로로 갈린다. 어느 경로가 어디로 가는지는 눈으로 본다.

kubectl -n header-lab describe ingress echo | grep -A5 Rules

형태(모양은 observed)

Rules:
  Host                 Path  Backends
  ----                 ----  --------
  app1.hyeonworks.com  
                       /api   echo:8081 (10.42.0.61:8081,10.42.1.72:8081)

https://app1.hyeonworks.com/api/echo 는 BFF 가 아니라 echo 앱으로 간다.

kubectl get endpoints 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜬다. 위처럼 describe ingress · describe svc 를 보거나 get endpointslice -l kubernetes.io/service-name=echo 를 본다.

나중에 걸러 보려면 먼저 통째로 봐야 한다. 어떤 키가 있는지 알아야 무엇으로 거를지 정한다.

curl -s https://app1.hyeonworks.com/api/echo

형태(모양은 observed) — 한 줄 JSON 이 통째로 나온다

{"headers":{"host":["app1.hyeonworks.com"],"x-forwarded-host":["app1.hyeonworks.com"],
"x-forwarded-proto":["https"],"x-forwarded-port":["443"],"x-forwarded-for":["..."],
"x-real-ip":["..."],"user-agent":["curl/8.5.0"],"accept":["*/*"]},
"remoteAddr":"...","localAddr":"10.42.1.72","scheme":"https","secure":true,
"serverName":"app1.hyeonworks.com","serverPort":443,
"requestUrl":"https://app1.hyeonworks.com/api/echo"}

headers 의 값이 전부 배열이다. HTTP 가 같은 이름의 헤더를 여러 번 허용하기 때문이고, 동명 헤더를 두 개 보냈을 때 무엇이 도착했는지도 이 배열이 말해 준다. x-forwarded-protohttps 인 것은 nginx 가 proxy_set_header설정한 헤더라서다. scheme · secure · serverName 은 Keycloak 이 iss 클레임과 리다이렉트를 만들 때 쓰는 값들이다.

jq 는 이 실험대에 깔려 있지 않다. 걸러 볼 때는 grep -o 를 쓰고, 가이드는 이 줄을 미검증으로 표시한다(unknown).

curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]'

그 형태는 못 잡는다. 공백을 허용한다.

curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto" *: *\[[^]]*\]'

★ 콜론을 붙여 쓴 형태는 한 줄도 못 잡는다(2026-09-17, observed). echo 가 응답을 들여쓰고 콜론 양옆에 공백을 넣어 찍기 때문이고, 이 문서의 실측은 줄바꿈을 없애고 옮겨 적은 것이라 그 공백이 안 보인다. 실제 화면과 각 주입의 도착 값은 이렇다(observed).

  "x-forwarded-proto" : [ "http" ]
  "x-auth-request-roles" : [ "admin", "editor" ]        ← 동명 헤더 둘
  "x-auth-request-roles" : [ "admin,editor,viewer" ]    ← 쉼표로 구분한 값 하나
  "x-auth-request-roles" : [ "role-with,comma" ]        ← 값 안에 쉼표
  "x-auth-request-email" : [ "attacker@evil.invalid" ]  ← 위조한 신원이 그대로 닿았다
  "x-auth-request-user" : [ "attacker" ]

뒤의 두 줄은 대조군에서 비어 있던 칸이므로, 보낸 것이 엣지를 지나 앱까지 닿았다는 뜻이다. 가운데 두 줄은 도착 시점에 구별되지 않는다 — 쉼표로 자르는 방식이 성립하지 않는 근거다. "x-auth-request[^]]*\] 처럼 이름 뒤에 : 를 요구하지 않는 형태는 그대로 써도 걸린다.

형태(모양은 observed)

"x-forwarded-proto":["https"]

tr ',' '\n' | grep 은 여기서 쓰면 안 된다. 값 배열이 ["admin","editor"] 처럼 쉼표를 품고 있어서 배열이 두 줄로 잘린다. 첫 줄만 보고 「하나만 도착했다」로 읽게 되는데, 그것이 이 실험에서 가장 조심할 오독이다. grep -o '…\[[^]]*\]' 는 대괄호 안을 통째로 뽑는다.

대조군으로는 아무것도 안 붙이고 x-auth-request-* 를 찾아본다. 가이드는 이 줄도 미검증으로 표시한다(unknown).

curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]'

아무것도 안 나와야 한다. x-auth-request-* 는 edge 가 붙이는 헤더인데 app1 앞에는 oauth2-proxy 가 없으므로 지금은 없다. 이 칸이 비어 있다는 것이 대조군이다. 주입 뒤 여기에 값이 나타나면 그건 내가 보낸 것이 도착한 것이고, 이 확인을 건너뛰면 「원래 있던 것」과 「내가 넣은 것」이 구별되지 않는다.

마지막으로 nginx 가 지금 무엇을 설정하는지 본다. 랩 호스트(test-server) 에서 친다.

sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab

형태(모양은 observed) — 03-nginx 가 세운 설정 그대로다

		proxy_set_header Host              $host;
		proxy_set_header X-Forwarded-Host  $host;
		proxy_set_header X-Forwarded-Proto https;
		proxy_set_header X-Forwarded-Port  443;
		proxy_set_header X-Forwarded-For   $remote_addr;
		proxy_set_header X-Real-IP         $remote_addr;

X-Auth-Request-* 가 목록에 없다. 그리고 그것이 주입 결과를 전부 설명한다.

proxy_set_header X-Forwarded-Proto https;   # 설정한 것 → 덮어쓴다
# X-Auth-Request-Roles 설정 없음            # 안 한 것  → 그대로 흘려보낸다

nginx 는 자기가 proxy_set_header 로 설정한 헤더만 덮어쓴다. 설정하지 않은 헤더는 손대지 않고 통과시킨다. 「nginx 가 덮어쓴다」는 명제는 조건부이고, 그 조건이 빠지면 틀린 문장이 된다.

sudo 가 아무 결과도 안 주면 실패한 것이다. 랩 호스트의 sudo 는 비밀번호를 요구한다(sudo -n -lsudo: a password is required). D-4 후속 작업이 이 사실을 늦게 발견해 시간을 버렸다. 빈 출력을 「설정이 없다」로 읽지 말고 비밀번호를 넣어 다시 친다.

주입

주입은 둘이다. 첫째는 요청에 헤더를 붙여 보내는 것이고, 둘째는 IdP 에서 클레임을 바꾸는 것이다. 첫째는 클러스터 상태를 바꾸지 않는다 — 되돌릴 것이 없고, 그 사실 자체가 이 실험의 무게다. 아무것도 설치하지 않고 아무 권한도 없이 curl 한 줄로 여기까지 간다.

동명 헤더 두 개를 보낸다. 가이드는 미검증으로 표시한다(unknown) — 원래 실행은 스크립트가 응답을 정리했고, 아래는 같은 값을 grep 으로 뽑는 형태다.

curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles" *: *\[[^]]*\]'

값 안의 쉼표를 구분자와 구별할 수 있는지 본다.

curl -s -H 'X-Auth-Request-Roles: admin,editor,viewer' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles" *: *\[[^]]*\]'
curl -s -H 'X-Auth-Request-Roles: role-with,comma' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles" *: *\[[^]]*\]'

크기를 키울 때는 먼저 한 번 읽는 형태로 본다. 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다. 가이드는 두 줄 다 미검증으로 표시한다(unknown) — 원래 실행은 python3 -c "print('r'*$n)" 로 값을 만들었고, 아래는 파이썬 없이 만드는 형태다.

V=$(head -c 8000 /dev/zero | tr '\0' 'r'); echo "만든 길이 ${#V}"
curl -i -s -H "X-Auth-Request-Roles: $V" https://app1.hyeonworks.com/api/echo | head -20

여러 크기를 비교할 때는 코드만 뽑는 형태가 맞다.

for n in 1000 4000 8000 16000 32000; do
  V=$(head -c "$n" /dev/zero | tr '\0' 'r')
  curl -s -o /dev/null -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
    https://app1.hyeonworks.com/api/echo
done

신원 자체를 위조한다. 로그인하지 않는다. 쿠키도 토큰도 없고 헤더 세 줄이 전부다.

curl -s \
  -H 'X-Auth-Request-User: administrator' \
  -H 'X-Auth-Request-Email: admin@example.com' \
  -H 'X-Auth-Request-Roles: realm-admin,superuser' \
  https://app1.hyeonworks.com/api/echo

여기까지가 요청만으로 되는 부분이다. 둘째 주입은 ③ 「클레임 변경은 언제 반영되는가」를 재려고 edge 세션을 실제로 만든다. 그러려면 oauth2-proxy 가 필요하고, 그것이 app2 를 쓴다. 백업이 먼저다.

kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
wc -l /tmp/grafana-ingress-backup.yaml
kubectl -n observability delete ingress grafana
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s

실측(observed) — 01-deploy.txt 의 첫 줄

  grafana ingress 삭제

wc -l 을 왜 치나. 백업 파일이 비어 있는데 삭제부터 하는 사고를 막는다. 0 줄이면 그 자리에서 멈춘다. 파일이 생겼는지 확인하지 않고 원본을 지우는 것이 이런 작업에서 가장 흔한 사고다.

브라우저에서 https://app2.hyeonworks.com/ 를 열고 labuser / labpass 로 로그인한다. 그 다음 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)
echo "uid=$UID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'

UID 는 bash·zsh 에서 읽기 전용이다. 위 대입은 UID: readonly variable 로 실패하고, 그 다음 echo "uid=$UID"로그인 사용자의 uid(보통 1000)를 찍어 성공처럼 보인다. 그대로 이어 치면 update users/1000 이 되어 엉뚱한 것을 고치려 든다. 따라 하는 사람은 이름을 바꿔 쓴다 — 명령은 같고 변수 이름만 다르다.

USER_ID=$(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)
echo "uid=$USER_ID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$USER_ID -r keycloak-patterns -s email=CHANGED-labuser@example.com

주입 검증

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

첫째 주입은 대조군 칸에 값이 나타났는가로 확인한다.

실측(observed) — 01-header-handling.txt

=== Q4 ④ upstream 이 검증하는가 ===
  아무 인증 없이 보냄:
    x-auth-request-user        ['administrator']
    x-auth-request-email       ['admin@example.com']
    x-auth-request-roles       ['realm-admin,superuser']
    remoteAddr                 100.123.124.30
  → 그대로 도착. 검증 없음.

주입 전에 비어 있던 칸에 값이 들어와 있다. 그리고 remoteAddr내 주소다 — 숨지도 않았다. 증거 파일의 ['admin', 'editor'] 같은 표기는 스크립트가 정리한 것이고, curl 로 직접 보면 같은 값이 JSON 배열 ["admin","editor"] 로 온다.

★ 여기서 「도착했다」를 「통했다」로 옮기면 틀린다. 도착해도 아무도 안 읽으면 무해하다. 읽는 쪽이 검증을 하는지를 대조군으로 확인한다.

for p in /api/echo /api/me /api/protected; do
  curl -s -o /dev/null -w "$p  %{http_code}\n" \
    -H 'X-Auth-Request-User: administrator' \
    -H 'X-Auth-Request-Roles: realm-admin' \
    "https://app1.hyeonworks.com$p"
done

실측(observed) — 01-header-handling.txt

  대조 — JWT 를 요구하는 경로:
    /api/echo        HTTP 200   (permitAll)
    /api/me          HTTP 401
    /api/protected   HTTP 401

같은 위조 헤더인데 결과가 갈린다. 위조 헤더가 /api/echo 를 열어 준 것이 아니다. 거기는 원래 permitAll 이라 열려 있었다. /api/me401 이고, 헤더로는 인증이 안 된다.

가이드는 이것을 앞선 실험과 이어 붙인다.

2홉 실험에서 헤더 위조로 serverName: evil.example.com 을 만든 것과 같은 종류다. 거기서는 쿠키 속성이었지만 여기서는 신원 그 자체다.

실측(observed) — 같은 파일의 SecurityConfig 발췌

  backend SecurityConfig:
    .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
    .anyRequest().authenticated()
    .oauth2ResourceServer(oauth2 -> oauth2.jwt(...))

둘째 주입은 IdP 쪽이 정말 바뀌었는지세션이 그대로인지를 같이 본다. 바뀌지 않은 것을 「반영 안 됨」으로 읽지 않으려면 반드시 본다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get users/$UID -r keycloak-patterns --fields email

실측(observed) — 03-b4-role-propagation.txt

=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
  변경 시각(UTC): 2026-09-04T07:53:32.000Z
  IdP 의 값:
    [ {
      "email" : "changed-labuser@example.com"
    } ]
  oauth2-proxy 세션: 1 개 (그대로 살아 있다)

IdP 값은 바뀌었고 세션은 하나다. 이 두 줄이 있어야 다음 절의 「옛 값」을 「반영 안 됨」 이라고 말할 수 있다. 세션 목록은 지우기 전에 항상 먼저 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'

실측(observed) — 01-orphan-lifecycle.txt기준선 블록

  _oauth2_proxy-f6a9201fd534a047998278452001ccbf
     type=string  ttl=3568초  크기=3510바이트

키 이름이 _oauth2_proxy- 로 시작한다. 밑줄로 시작하고 안쪽은 밑줄이다. 'oauth2-proxy*' 같은 패턴은 하나도 안 맞고, 그러면 「세션이 없다」로 오독한 뒤 이어서 지우는 명령이 조용히 아무것도 안 지운다.

관찰

① 동명 헤더 둘은 덮어쓰이지도 합쳐지지도 않는다.

실측(observed) — 01-header-handling.txt

(b) 동명 헤더 두 개
    보냄:   X-Auth-Request-Roles: admin
            X-Auth-Request-Roles: editor
    도착:   ['admin', 'editor']              ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다

curl 로 직접 보면 "x-auth-request-roles":["admin","editor"] 로 보인다. 셋 중 어느 것도 아니었다.

가설 도착했을 모양 실제
덮어쓴다 ["editor"] 하나
합친다 ["admin, editor"] 한 문자열
통과시킨다 ["admin","editor"]

Edge 가 X-Auth-Request-Roles: viewer 를 붙여도, 공격자가 같은 헤더를 admin 으로 함께 보내면 둘 다 upstream 에 도착한다.

   edge 가 붙인 것:     X-Auth-Request-Roles: viewer
   공격자가 보낸 것:     X-Auth-Request-Roles: admin
   upstream 이 받는 것:  ["viewer","admin"]  또는  ["admin","viewer"]
                        └─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다

Spring 의 request.getHeader() 는 첫 번째를 돌려준다. 그 순서는 프록시가 정한다. 애플리케이션 코드 어디에도 이 결정이 안 적혀 있다.

② 값 안의 쉼표는 구분자와 구별되지 않는다.

실측(observed) — 01-header-handling.txt

(a) 쉼표 구분 한 개 헤더
    보냄:   X-Auth-Request-Roles: admin,editor,viewer
    도착:   ['admin,editor,viewer']          ← 문자열 하나 그대로
...
(c) 값 안에 구분자가 들어간 경우
    보냄:   X-Auth-Request-Roles: role-with,comma
    도착:   ['role-with,comma']              ← (a) 와 구별 불가

(a)와 (c)가 도착 시점에 똑같이 생겼다. 둘 다 값이 하나인 배열이고 그 안에 쉼표가 있다.

   "admin,editor,viewer"   쉼표로 자르면 → [admin, editor, viewer]   맞다
   "role-with,comma"       쉼표로 자르면 → [role-with, comma]        ★ 틀렸다

role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다. Keycloak 의 role 이름은 임의 문자열이므로 애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 조건이 아니다.

대안
동명 헤더 여러 개 HTTP 가 허용하고 실제로 도착한다. 다만 위조와 구별이 안 된다
Base64 로 감싼 JSON 배열 구분자 문제가 사라진다. 대신 크기가 커진다
헤더를 안 쓰고 JWT 를 넘긴다 서명이 있어 위조도 구분자도 해결된다 → BFF 구조

③ 크기는 절벽에서 떨어진다. 8000 에서는 Tomcat 의 HTML 오류 페이지가 온다. JSON 이 아니라 HTML 이라는 것 자체가 「애플리케이션까지 갔는데 파싱 전에 잘렸다」는 신호다.

실측(observed) — 01-header-handling.txt

=== Q4 ② 헤더 크기 상한 ===
  보낸 길이   1000 → HTTP 200, 도착 길이 1000
  보낸 길이   4000 → HTTP 200, 도착 길이 4000
  보낸 길이   8000 → HTTP 400  (Tomcat 의 HTML 오류 페이지)
  보낸 길이  16000 → HTTP 000  (응답을 못 받음 = 연결이 끊김)
  보낸 길이  32000 → HTTP 000

  → 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.

000400 이 다른 값이다.

curl 이 찍는 값
400 응답을 받았다. 서버가 거부했다
000 응답 자체를 못 받았다. 연결이 끊겼거나 아예 안 열렸다
크기 누가 거부하나 클라이언트가 보는 것
~8KB Tomcat (maxHttpHeaderSize 기본 8KB) 400 + HTML 오류 페이지
~16KB 이상 nginx (large_client_header_buffers) 응답 없음 / 연결 끊김

두 실패가 전혀 다르게 보인다. 앞의 것은 애플리케이션 오류처럼, 뒤의 것은 네트워크 장애처럼 보인다. 원인은 같은데 진단이 갈린다 — 앞의 것은 앱 로그를 뒤지게 하고 뒤의 것은 방화벽을 뒤지게 한다.

   role 이 늘어난다  →  헤더가 커진다  →  8KB 를 넘는 순간 전면 400

점진적으로 나빠지지 않는다. 그리고 그 절벽은 사용자마다 다르다 — role 이 많은 사용자만 깨지고 테스트 계정으로는 영원히 안 보인다.

④ 위조한 신원은 검증 없이 도착한다. 주입 검증에 실은 네 줄이 그 결과이고, 같은 헤더가 /api/me 에서 401 인 것도 거기 같이 적었다.

   JWT 경로   →  서명이 있다   →  검증할 대상이 있다   →  위조가 안 된다
   헤더 경로  →  서명이 없다   →  검증할 대상이 없다   →  ★ 위조를 구별할 방법이 없다

request.getHeader("X-Auth-Request-User") 는 그 값이 어디서 왔는지 모른다. edge 가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다. Q4 가 확인한 사실로 적어 둔 "upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다"정확하고, 그것이 이 구조의 본질적 한계다.

③ 클레임 변경은 요청 횟수로는 반영되지 않는다. 먼저 바꾸기 전 값을 브라우저에서 잰다. X-Auth-Request-Roles 대신 x-forwarded-email 을 쓰는데, role 을 헤더로 내보내려면 추가 설정이 필요하고 「IdP 의 클레임 변경이 언제 반영되는가」는 어느 클레임이든 같은 질문이라서다. 로그인된 app2 탭에서 F12 → Console 이다.

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[^,]*/)[0]);
}

실측(observed) — 03-b4-role-propagation.txt

=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
2026-09-04T07:51:23.862Z  req#1  HTTP 200  x-forwarded-email=labuser@example.com  x-forwarded-preferred-username=labuser
2026-09-04T07:51:24.304Z  req#2  HTTP 200  x-forwarded-email=labuser@example.com  x-forwarded-preferred-username=labuser
2026-09-04T07:51:24.722Z  req#3  HTTP 200  x-forwarded-email=labuser@example.com  x-forwarded-preferred-username=labuser

바꾼 뒤 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[^,]*/)[0]);
  await new Promise(s => setTimeout(s, 500));
}

실측(observed) — 03-b4-role-propagation.txt

=== [3] 변경 후 12회 반복 (브라우저 fetch) ===
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:51:57.489Z  req#3   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:58.018Z  req#4   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:58.602Z  req#5   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:59.217Z  req#6   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:59.743Z  req#7   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:00.342Z  req#8   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:00.964Z  req#9   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:01.574Z  req#10  HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:02.187Z  req#11  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초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다.

12줄이 전부 같다. Q4 는 「몇 번째 요청부터 반영되는지」를 물었는데 **답은 「요청으로는 안 된다」**이고, 요청 횟수가 아니라 세션의 나이가 정한다. 가이드가 적은 값은 12회 · 약 6.4초다.

★ 이 결론은 시계를 보정해야 성립한다.

실측(observed) — 03-b4-role-propagation.txt

=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
  개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z
  test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z
  → test-server 가 약 107초 앞선다.
     브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다.

이 「약 107초」와 D 층의 「106초」는 같은 왜곡을 두 번 잰 것이다. 둘 다 test-server 가 dev 머신보다 앞선 양이고, 여기서는 타임스탬프 둘의 차(07:54:07 07:52:20)로 어림했고 D-4a 에서는 ACME 응답을 제3의 기준으로 두고 다시 쟀다. 보정에 쓸 값은 D-4a 의 106초다 — 여기 107초는 이 절의 12회를 읽기 위한 어림이다. 두 값을 섞어 빼지 않는다.

보정 전에는 12회의 타임스탬프(07:51:56~)가 변경 시각(07:53:32)보다 앞서 보인다. 그대로 읽으면 「변경 전에 잰 것」이 되어 결론이 통째로 무너진다. 보정하면 12회는 변경 11초 뒤이고, 그래야 「변경 후에도 옛 값」이 선다. 자기 환경의 어긋남은 date -u '+%Y-%m-%dT%H:%M:%SZ' 와 브라우저 콘솔의 new Date().toISOString() 을 견줘서 잰다. 두 기계의 로그를 나란히 놓기 전에 시계를 확인한다 — D-4 도 이 확인을 안 해서 인증서 공백을 처음에 잘못 계산했고 나중에 38분 25초로 정정했다.

세션을 지우고 재인증시키면 새 값이 온다. 지우기 전에 목록을 본다. 가이드는 두 번째 줄을 미검증으로 표시한다(unknown) — 후속 문서 §3 에 실린 형태를 실제 키 이름에 맞춰 고쳤다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
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

실측(observed) — 03-b4-role-propagation.txt

=== [6] 재인증 후 (브라우저 fetch) ===
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

새 값이 나오고, 로그인 화면은 안 떴다. Keycloak SSO 가 살아 있어 조용히 재인증됐다.

   변경 후 12회 요청(6.4초)  →  labuser@example.com          (옛 값)
   세션 삭제 후 재인증        →  changed-labuser@example.com  (새 값)

세션은 로그인 시점의 스냅샷이다. 로그인할 때 IdP 가 준 클레임을 세션에 담고, 이후 요청은 세션에서 읽어 헤더로 내보내며 IdP 를 다시 부르지 않는다. 그래서 IdP 에서 바꿔도 세션은 모른다. 지금 구성(--cookie-refresh 없음)에서는 쿠키 만료(1시간) 또는 재인증까지 안 되고, --cookie-refresh=5m 이면 최대 5분이라고 가이드가 적는다. 다만 그 5분은 설정의 정의이지 이 실험대에서 잰 값이 아니다(unknown). 권한을 뺏는 변경이 최대 1시간 늦게 반영된다는 것이 Q4 의 설계 판단에 직접 답한다 — 즉시 반영이 필요하면 헤더 방식은 맞지 않는다.

nginx 에서 동명 헤더를 먼저 지우는 것이 그 처방이고, 이 실험대는 그 수정을 적용한 적이 없다(unknown). 해설 문서 6절이 「남긴 것」으로 분류한 항목이고, 가이드는 「아래는 미검증 이며, 적용하려면 랩 호스트에서 사람이 직접 친다」로 못박는다. 적용한다면 랩 호스트 (test-server) 에서, 백업을 먼저 뜬다.

sudo cp /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-available/keycloak-lab.b4-backup
ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup
sudo vi /etc/nginx/sites-available/keycloak-lab

location / { ... } 안, 기존 proxy_set_header 들 옆에 넣는다.

	# B-4 — 클라이언트가 보낸 X-Auth-Request-* 를 먼저 지운다.
	# 빈 값으로 set 해야 "설정한 헤더"가 되어 통과가 아니라 덮어쓰기가 된다.
	proxy_set_header X-Auth-Request-User   "";
	proxy_set_header X-Auth-Request-Email  "";
	proxy_set_header X-Auth-Request-Roles  "";

"" 로 먼저 지우는 것이 핵심이다. nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않으니 덮어쓰게 하려면 먼저 설정해야 하고, 붙일 값이 없을 때 설정하는 방법이 빈 문자열이다. proxy_set_header X-Auth-Request-Roles ""; 는 nginx 에서 그 헤더를 upstream 으로 보내지 않는다는 뜻이다. edge 가 진짜 값을 붙여야 한다면 지운 뒤에 다시 설정한다 — 순서가 반대면 클라이언트 값이 살아남는다.

sudo nginx -t && sudo systemctl reload nginx

nginx -t 의 마지막 줄에서 syntax is oktest is successful 두 마디가 다 나와야 통과다. 앞의 [warn] 은 통과를 막지 않는다. 실패면 && 가 reload 를 막아 준 것이고 지금 돌고 있는 nginx 는 옛 설정 그대로다. 고쳐졌는지는 동명 헤더 두 개를 보낸 명령을 똑같이 다시 쳐서 보고, 대조군과 같아지면(아무것도 안 나오면) 고쳐졌다. 이 실험대는 여기까지 재지 않았다(unknown).

가이드는 판정 규칙을 하나 더 붙인다 — 값이 그대로 나오면 reload 가 안 갔거나 다른 server 블록을 고친 것이고, 워커 PID 가 바뀌었는지로 reload 여부를 판정한다.

systemctl status nginx --no-pager | head -20

D-4a 가 같은 판정법을 인증서 갱신에 쓴다.

복구와 원상복구 확인표

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=labuser@example.com
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get users/$UID -r keycloak-patterns --fields email

"email" : "labuser@example.com" 이 나와야 한다. 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다 — 세션을 한 번 더 지우면 확실하다.

Grafana Ingress 를 되돌린다. oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f /tmp/grafana-ingress-backup.yaml
kubectl get ingress -A | grep app2
curl -s -o /dev/null -w '%{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \
  https://app2.hyeonworks.com/

app2 를 잡고 있는 Ingress 가 observability/grafana 하나여야 한다. 둘이면 어느 쪽이 이길지는 컨트롤러가 정하므로 되돌린 것이 아니라 경합을 만든 것이다. oauth2-proxy Deployment 자체는 놔둬도 된다 — Ingress 만 떼면 app2 로는 안 들어가고, B-7 을 이어서 할 거라면 그편이 낫다고 가이드가 적는다.

항목 명령 돌아왔을 때
app2 kubectl get ingress -A | grep app2 observability/grafana 하나만
Grafana curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/ Grafana 가 답한다 (200 또는 로그인 302)
app1 curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/echo 200
IdP … kcadm.sh get users/$UID -r keycloak-patterns --fields email labuser@example.com
nginx sudo nginx -t (호스트) test is successful
nginx 백업 ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup 되돌렸으면 지워도 된다
Redis … redis-cli --scan --pattern '_oauth2_proxy-*' 로그아웃했으면 없거나, 새 세션 하나

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 그 기록에서 곧바로 따라 나오는 것이라고 적는다.

증상 원인 확인
동명 헤더가 하나만 도착한 것처럼 보인다 tr ',' '\n' 으로 잘랐다. 값 배열이 두 줄로 쪼개진다 grep -o '…\[[^]]*\]' 로 대괄호째 뽑는다
jq: command not found 이 실험대에 jq 가 없다 grep -o 로 뽑거나 응답을 통째로 본다
python3 -m json.tool 을 쓰라고 되어 있다 해설 문서 7절의 형태다. 값 생성도 python3 -c 였다 head -c N /dev/zero | tr '\0' 'r'
16000 에서 000 이 나온다 오류가 아니라 측정 결과다. nginx 가 연결을 끊는다 400(Tomcat)과 000(nginx)을 구별한다
sudo grep 이 빈 결과 호스트 sudo 는 비밀번호를 요구한다 sudo -n -l 로 확인
Redis 에서 세션이 안 보인다 패턴이 틀렸다. 키는 _oauth2_proxy- 로 시작한다 먼저 --scan 만 쳐서 이름을 눈으로 본다
xargs … del 이 아무것도 안 지운다 같은 원인. 패턴이 안 맞으면 조용히 0건 목록 개수와 del 반환 개수를 대조
curl -b 로 로그인 상태가 재현이 안 된다 쿠키가 HttpOnly 다. 꺼낼 수 없다 브라우저 콘솔에서 잰다
12회가 변경 시각보다 앞서 보인다 두 시계가 107초 어긋나 있었다 보정값을 먼저 잰다
값이 안 바뀐다 버그가 아니다. 세션이 새로 만들어져야 한다 --cookie-refresh
app2 가 Grafana 도 프록시도 아닌 것을 준다 Ingress 가 둘 다 남아 있다 get ingress -A | grep app2
/api/me200 이다 위조가 통한 것이 아니라 진짜 JWT 를 보낸 것이다 헤더만 보냈는지 다시 본다

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

  • (observed) 동명 헤더 두 개가 ['admin', 'editor'] 로 둘 다 도착한 것, 쉼표 구분 (a)와 값 안 쉼표 (c)가 도착 시점에 구별되지 않는 것, 크기 훑기 다섯 줄(1000·4000200, 8000400, 16000·32000000), 인증 없이 보낸 위조 신원 세 줄과 remoteAddr 100.123.124.30, JWT 를 요구하는 경로의 /api/echo 200 · /api/me 401 · /api/protected 401, IdP 변경 시각 2026-09-04T07:53:32.000Z 와 바뀐 값, 변경 후 12회의 타임스탬프와 값 전부, 두 시계가 약 107초 어긋난 것, 세션 삭제 후 3회의 새 값, _oauth2_proxy-f6a9201fd534a047998278452001ccbfttl=3568초 · 크기=3510바이트.
  • 경로를 혼동하지 않는다(observed) — 위조 헤더를 잰 곳은 app1.hyeonworks.com/api 의 echo 앱이고 그 경로는 permitAll 이라 oauth2-proxy 를 거치지 않는다. 헤더가 도착한 것과 인가가 뚫린 것은 다른 사건이고, 같은 헤더를 /api/me 에 보내면 401 이다.
  • (unknown) proxy_set_header X-Auth-Request-* ""; 수정. 이 실험대는 그것을 적용한 적이 없다. 가이드가 「적용하려면 랩 호스트에서 사람이 직접 친다」로 못박았고, 적용한 뒤 다시 재는 절도 미검증이다. 그러므로 위조가 막히는지는 이 문서 어디에도 측정으로 없다.
  • (unknown) grep -o 로 헤더 배열을 뽑는 줄들, head -c N /dev/zero | tr '\0' 'r' 로 긴 값을 만드는 줄, 크기 훑기 루프, xargs -r … redis-cli del 로 세션을 지우는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트와 python3 -c 를 썼다.
  • (unknown) --cookie-refresh=5m 을 켰을 때의 「최대 5분」. 설정의 정의이지 이 실험대에서 잰 값이 아니다.
  • 이 실험이 재지 않은 것X-Auth-Request-Roles 자체의 반영 시점은 재지 않았다. role 을 헤더로 내보내려면 추가 설정이 필요해 x-forwarded-email 로 대체했고, 「클레임 변경이 언제 반영되는가」는 어느 클레임이든 같다는 것이 그 근거다. upstream 의 내부 credential 검증을 공통 경계로 옮기는 것도 코드 변경이라 이 실험 밖이다.

B-5 — Redis 를 내려도 파드가 Ready 인 채로 계속 실패하는가

근거: b5-redis-loss-persistence.md (883줄). 실행 기록은 2026-09-04 14:2414:28 KST(observed).

이 실험이 가르는 것

A-2 에서 Keycloak 의 PostgreSQL 을 내렸을 때는 이렇게 됐다.

   DB 정지  →  헬스체크 실패  →  파드 NotReady  →  Service 에서 빠짐  →  밖에서 503

명확한 실패였다. 503 은 「지금 안 된다」고 말하고, 클라이언트는 재시도든 포기든 결정할 수 있다. 통념은 의존 저장소가 죽으면 헬스체크가 알아서 파드를 빼 준다는 것이고, 이 실험은 진짜 그런지와 이번에는 무엇을 보고 판단하는지를 잰다.

두 번째 질문이 붙는다.

   Redis 를 다시 띄우면  →  세션이 남아 있나?

「영속화를 켜 두면 된다」가 통념이다. 이 실험은 그 통념이 쿠버네티스에서 어떻게 어긋나는지를 잰다. 그래서 영속화를 논하기 전에 /data 가 무엇인지부터 보는 절이 이 가이드에서 가장 중요하다.

★ 이 실험대는 그 뒤로 바뀌었다. 지금 매니페스트(bff-redis.yaml)에는 B-5 의 결론이 이미 반영되어 PVC 와 --appendonly yes 가 들어 있다. 그래서 두 번째 주입은 볼륨 없는 상태를 다시 만드는 단계부터 시작한다.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • B-1 · B-2 가 끝나 세션은 Redis, 토큰은 PostgreSQL 로 나뉘어 있다. 나뉘어 있어야 각각 죽여볼 수 있고, 이 실험은 Redis 만 죽인다.
  • 브라우저로 https://app1.hyeonworks.com/로그인해 둔다(labuser / labpass). Redis 에 세션이 하나는 있어야 잃는 것이 보인다.
  • Redis 는 redis.keycloak-lab.svc:6379, 파드는 kc-lab-2 에 고정되어 있다.

이건 저장소를 지우는 실험이다. Redis 를 0대로 내리고 나중에 볼륨 없이 파드를 지운다. 그 안의 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 실험대에서만 한다. 전 구간 약 30분이고, 중간에 그만두려면 한 줄이면 된다.

kubectl -n keycloak-lab scale deployment/redis --replicas=1

볼륨을 뗀 뒤에는 매니페스트를 다시 적용해 되돌린다.

kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s

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

파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹
kubectl -n keycloak-lab get pods -o wide

형태(모양은 observed)

NAME                   READY  STATUS   RESTARTS  AGE  IP           NODE
bff-555df79c97-6j86w   1/1    Running  0         17m  10.42.0.52   kc-lab-1
bff-555df79c97-vgg6g   1/1    Running  0         16m  10.42.1.124  kc-lab-2
postgres-...           1/1    Running  0         5d   ...          kc-lab-2
redis-...              1/1    Running  0         3d   ...          kc-lab-2

bff 가 둘 다 1/1 이고 RESTARTS0 이다. Redis 는 하나다 — replica 가 없으니 0으로 내리면 전면 정지다. 그리고 Redis 와 PostgreSQL 이 같은 노드(kc-lab-2)인데, 매니페스트가 nodeSelector 로 고정한다. A-4(노드 상실)에서 두 저장소가 한꺼번에 없어지게 하려는 배치다. 10.42.0.5210.42.1.12403-health-groups.txt 에 남은 실제 BFF 파드 IP 이고, 관찰 절에서 이 두 주소가 다시 나온다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly

실측(observed) — 01-baseline.txt

=== 기준선 ===
  Redis 키: 1
  PostgreSQL 토큰: 1 행
  Redis 영속화 설정:
    save = save	
    appendonly	no
그때
키 수 1 로그인 세션 하나
save 빈 값 RDB 스냅샷이 꺼져 있다
appendonly no AOF(append-only file, 쓰기를 순서대로 적어 두는 파일)도 꺼져 있다

그때는 영속화가 아예 꺼져 있었다.

★ 2026-09-17 에 쳤더니 save 도 비어 있지 않았다(observed). 이 이미지의 기본 스냅샷 조건 셋이 그대로 들어 있다. 그래서 둘째 주입의 출발 조건은 「AOF 만 켠 상태」가 아니라 「AOF 와 스냅샷이 둘 다 켜진 상태」이고, 그래도 결과는 같다 — 볼륨이 없으면 둘 다 컨테이너와 함께 사라진다.

save
3600 1 300 100 60 10000
appendonly
yes

같은 날 redis-cli --scan 은 아무것도 안 찍었다. 브라우저 로그인을 안 한 실험대라 세션 키가 0개였고, 그 상태에서는 「잃는 것」이 안 보인다. 로그인을 먼저 해 두라는 전제는 이 때문이다. 지금 환경은 다를 것이다 — 매니페스트가 --appendonly yes 로 시작하므로 appendonly yes 가 나오고, 그 차이가 두 번째 주입의 출발 조건이다.

save 출력의 값이 비어 있는 것과 키가 없는 것은 다르다. config get save 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 「스냅샷 조건 없음」이다. 증거의 save = save 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다.

★ 영속화를 말하기 전에 /data 가 볼륨인지부터 본다. 이 확인을 건너뛰면 「AOF(append-only file, 쓰기를 순서대로 적어 두는 파일)를 켰는데 안 남는다」를 「Redis 가 이상하다」로 읽게 된다.

kubectl -n keycloak-lab get pod -l app=redis \
  -o jsonpath='{.items[0].spec.volumes}'; echo

형태(모양은 observed) — 지금 매니페스트 기준

[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
kubectl -n keycloak-lab get pod -l app=redis \
  -o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
kubectl -n keycloak-lab get pvc

형태(모양은 observed)

NAME         STATUS   VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS   AGE
redis-data   Bound    pvc-...      1Gi        RWO            local-path     3d

셋이 전부 성립해야 한다.

   ① volumes 에 항목이 있다              ← 없으면 컨테이너 파일시스템이다
   ② volumeMounts 의 mountPath 가 /data  ← 다른 데 붙었으면 소용없다
   ③ PVC 가 Bound                        ← Pending 이면 파드가 안 뜬다

하나라도 빠지면 appendonly yes 는 장식이다. 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.

   /data 가 볼륨이 아니다  →  이미지 위의 쓰기 가능 레이어에 쓴다
                           →  컨테이너가 없어지면 그 레이어도 없어진다

Redis 는 이것을 모른다. appendonly yes 를 켜면 성실히 /dataappendonlydir 을 만들고 매 쓰기를 기록한다. 거짓말이 아니라 정말로 기록한다. 다만 그 디렉터리가 어디 있는지를 모를 뿐이다. emptyDir 도 마찬가지다 — 컨테이너 재시작은 견디지만 파드가 없어지면 같이 없어진다. 「볼륨을 붙였다」와 「영속 볼륨을 붙였다」는 다르다.

주입 후에 볼 세 경로를 주입 전에 똑같은 명령으로 먼저 본다.

for p in / /bff/token-boundary /actuator/health; do
  curl -s -o /dev/null -w "$p  %{http_code}  total %{time_total}\n" --max-time 90 \
    --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
done

실측(observed) — 01-baseline.txt 는 첫 줄만 남겼다

=== 외부 진입점 정상 확인 ===
  https://app1.hyeonworks.com/  HTTP 200

000 이 아닌 것이 판정 기준의 전부다.

경로 정상일 때
/ 200 permitAll 정적 페이지. Redis 를 안 탄다
/bff/token-boundary 200 또는 로그인으로 보내는 3xx 세션이 필요하다 — Redis 를 탄다
/actuator/health 200 모든 지표의 합

셸의 curl 에는 로그인 쿠키가 없으므로 두 번째는 보통 3xx 이고, 가이드는 이 대목을 미검증으로 표시한다(unknown). 200 이든 3xx 든 상관없다 — 이 실험이 보는 것은 응답이 오는가이고, 3xx 를 만드는 과정에서도 BFF 는 세션을 만들려고 Redis 를 건드린다.

--max-time 을 반드시 붙인다. 주입 뒤 이 요청은 응답이 안 온다. 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 「멈춤」이 아니라 「내 터미널이 이상함」으로 읽게 된다.

health 그룹도 미리 본다. /actuator/** 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다).

curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/actuator/health; echo
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/actuator/health/readiness; echo
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/actuator/health/liveness; echo

첫 번째 응답의 본문에 redis 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다.

실측(observed) — 03-health-groups.txt 의 정지 후 값. 그 본문 항목 칸은 비어 있다

=== /actuator/health 본문 (Redis 항목이 있는가) ===


=== /actuator/health/readiness 본문 ===
{"status":"UP"}

첫 칸이 비어 있는 것은 측정 실패다. 파드 안에서 본문을 받아오려다 못 받았다. 밖에서 직접 재 두는 편이 낫다 — 뒤에서 이 값을 비교하게 된다.

   /actuator/health              모든 지표의 합   ← redis 지표가 여기 있다
   /actuator/health/readiness    readiness 그룹   ← 기본값은 readinessState 뿐
   /actuator/health/liveness     liveness 그룹

redis 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다. 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로다. 전체는 DOWN 인데 readiness 는 UP 인 상태가 성립한다.

kubectl -n keycloak-lab get deploy bff \
  -o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo

형태(모양은 observed)

/actuator/health/readiness

주입

주입은 둘이다. 첫째는 Redis 를 0대로 내리는 것이고, 둘째는 볼륨을 떼고 영속화만 켠 채 파드를 지우는 것이다. 둘째는 첫째를 되돌린 뒤에 한다.

내리는 방법을 고른 이유부터 본다.

방법 만들어지는 상태
delete pod Deployment 가 즉시 새로 만든다. 몇 초짜리 공백이라 관찰할 시간이 없다
scale --replicas=0 없는 상태가 유지된다. 내가 되돌릴 때까지
NetworkPolicy 로 6379 차단 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 기존 연결은 안 끊긴다

「저장소가 없어진 상태」를 안정적으로 유지하는 것이 목적이므로 두 번째를 쓴다. 그리고 이 방법은 파드가 사라지므로 주입 여부를 눈으로 확인하기 쉽다.

date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/redis --replicas=0

실측(observed) — 02-redis-down.txt

=== ① Redis 정지 ===
  정지: 14:26:30
deployment.apps/redis scaled
  삭제 완료

시각을 반드시 적어 둔다. 「언제부터 회복됐나」를 붙일 때 쓴다.

둘째 주입은 볼륨을 떼는 것부터다. 지금 실험대에는 이미 PVC 가 붙어 있고 원래 측정 당시에는 없었으므로, 볼륨을 떼야 그때가 재현된다. 가이드는 이 방향을 미검증으로 표시한다(unknown) — 원래 실행은 반대 순서였다(볼륨 없는 상태에서 시작해 PVC 를 붙였다).

kubectl -n keycloak-lab patch deployment redis --type=json \
  -p '[{"op":"remove","path":"/spec/template/spec/containers/0/volumeMounts"},
       {"op":"remove","path":"/spec/template/spec/volumes"}]'
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s

★ PVC 자체는 지우지 않는다. Deployment 에서 참조만 뗐고, 나중에 apply 로 되돌리면 같은 PVC 에 다시 붙는다. PVC 를 지우면 local-path 프로비저너가 노드의 디렉터리까지 지운다.

그 다음 AOF 를 켜고 키를 심는다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- ls -la /data

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.

kubectl -n keycloak-lab get pods -l app=redis
kubectl -n keycloak-lab get deploy redis

형태(모양은 observed)

No resources found in keycloak-lab namespace.

NAME    READY   UP-TO-DATE   AVAILABLE   AGE
redis   0/0     0            0           3d

0/0 이어야 한다. 1/1 이면 스케일이 안 먹었거나 다른 네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.

응답이 없는 것과 붙지 못하는 것은 다르다. 로그가 이유를 말한다.

kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10

실측(observed) — 02-redis-down.txt

=== BFF 로그 ===
	at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
	at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
	at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
	at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
	at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]

★ 이 실험대의 로그는 다른 줄을 냈다(2026-09-17, observed). 스택이 아니라 Lettuce 의 재연결 로그다. Service 는 남아 있고 뒤에 파드가 없으므로 Connection refused 이고, 위의 「맺는 중」과 달리 여기서는 즉시 거절당한 뒤 다시 시도한다. 그래도 요청 쪽은 똑같이 매달린다.

i.l.core.protocol.ConnectionWatchdog     : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379
i.l.core.protocol.ConnectionWatchdog     : Cannot reconnect to [redis.keycloak-lab.svc/<unresolved>:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379

그리고 grep -iE 'redis|connect|netty'connect 때문에 Hikari 의 PostgreSQL 커넥션 경고까지 잡는다. tail -10 의 앞쪽 여섯 줄이 그것이었고 Redis 와 무관하다.

pollConnect · finishConnect. 연결을 맺는 중이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 000 이 여기서 나온다.

엉뚱한 것을 죽이지 않았는지도 본다.

kubectl -n keycloak-lab get pods

실측(observed) — 02-redis-down.txt

=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w   1/1   Running   0     17m
bff-555df79c97-vgg6g   1/1   Running   0     16m

bff 두 개의 RESTARTS 가 여전히 0 이고 postgres 가 살아 있어야 한다. postgres 까지 내렸다면 B-5 가 아니라 전면 장애를 재게 된다. 여기서 이미 답이 절반 나와 있다 — Redis 가 없는데 1/1 이다.

둘째 주입도 걸렸는지 본다. 볼륨 확인은 주입 전과 똑같은 명령이다.

kubectl -n keycloak-lab get pod -l app=redis \
  -o jsonpath='{.items[0].spec.volumes}'; echo

빈 줄이 나와야 한다. 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 「영속화가 잘 된다」로 오독한다.

★ 빈 줄은 안 나온다(2026-09-17, observed). 쿠버네티스가 서비스 계정 토큰을 kube-api-access-… 라는 projected 볼륨으로 자동으로 붙이므로, volumes 배열을 통째로 지워도 새 파드에는 그 항목 하나가 다시 들어 있다. 길이가 아니라 이름을 본다 — persistentVolumeClaim 이 안 보이면 떨어졌다.

[{"name":"kube-api-access-d7bbm","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}]

volumeMounts 쪽으로 보면 더 짧다. /data 가 없고 /var/run/secrets/kubernetes.io/serviceaccount 하나만 남는다. 그리고 이 명령이 고르는 .items[0] 은 방금 지운 파드일 수 있다 — 같은 라벨에 Completed 파드가 남아 있는 것을 이 실험대에서 봤다.

실측(observed) — 04-persistence.txt

  --- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
    appendonly	yes
    total 12
    drwxr-xr-x    3 redis    redis         4096 Sep  4 05:26 .
    drwxr-xr-x    1 root     root          4096 Sep  4 05:26 ..
    drwx------    2 redis    redis         4096 Sep  4 05:26 appendonlydir

appendonlydir 이 실제로 만들어졌다. Redis 는 시킨 대로 했다 — 설정도 yes 고 디렉터리도 있고 파일도 쓰인다. 여기서 「영속화가 켜졌다」고 결론 내리면 틀린다. 어디에 쓰는지를 안 봤기 때문이고, 지금 /data 는 컨테이너 파일시스템이다.

관찰

000 은 오류가 아니라 멈춤이다. 주입 전과 똑같은 명령을 친다.

for p in / /bff/token-boundary /actuator/health; do
  curl -s -o /dev/null -w "$p  %{http_code}  total %{time_total}\n" --max-time 90 \
    --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
done

실측(observed) — 02-redis-down.txt

=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
  /                      HTTP 200
  /bff/token-boundary    HTTP 000
  /actuator/health       HTTP 503
코드
200 정적 페이지는 산다 — Redis 를 안 타는 경로
000 응답 자체를 못 받았다. curl 이 기다리다 포기했다
503 헬스 엔드포인트는 대답은 한다 — 다만 DOWN 이라고

★ 위 블록의 --max-time 90 은 원 가이드의 --max-time 10 을 고친 것이다(2026-09-17, observed). 10초에서 끊으면 /actuator/health000 이 되어 세 줄이 200·000·000 으로 나오고, 바로 위의 실측과 안 맞는다. 이 실험대에서 그 줄은 60.046452 초 뒤에 503 을 줬고 /bff/token-boundary 는 90초까지 기다려도 안 왔다. 그래서 90초까지 기다리게 하고 %{time_total} 로 걸린 시간을 같이 찍는다.

health 503  total 60.046452
token-boundary 000  total 90.001609

그러니까 헬스 엔드포인트도 빨리 실패하지 않는다. 60초는 Redis 명령 타임아웃이고, 그때 돌아온 본문의 redis 항목은 「붙지 못했다」가 아니라 「명령이 시간을 넘겼다」였다.

"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}}

오류를 돌려주는 것이 아니라 매달려 있다.

   빠른 실패:  요청 →  즉시 503  →  사용자는 오류 화면을 본다. 재시도할지 정할 수 있다
   느린 실패:  요청 →  …………     →  사용자는 멈춘 화면을 본다. 아무것도 정할 수 없다

「빨리 실패하기(fail fast)」가 안 되어 있다. A-6(지연 주입)에서 본 것과 같은 문제이고, 브라우저 탭도 그 앞의 로드밸런서도 그 앞의 사용자도 전부 붙잡힌다. 응답 본문도 비어 있다.

실측(observed) — 같은 파일

  --- token-boundary 응답 본문 ---


본문이 없다는 것은 오류 페이지조차 못 만들었다는 뜻이다. 고치려면 클라이언트에 타임아웃을 건다. Lettuce 의 연결·명령 타임아웃을 짧게 잡으면 000500 이 되고, 500 이 000 보다 낫다 — 적어도 말은 하기 때문이다.

★ 그런데 파드는 Ready 를 유지한다. 이것이 이 실험의 가장 중요한 발견이다.

curl -s -o /dev/null -w 'health      %{http_code}  total %{time_total}\n' --max-time 90 \
  --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health
curl -s -o /dev/null -w 'readiness   %{http_code}\n' --max-time 10 \
  --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/readiness
curl -s -o /dev/null -w 'liveness    %{http_code}\n' --max-time 10 \
  --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/liveness
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/actuator/health/readiness; echo

실측(observed) — 03-health-groups.txt

=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
  /actuator/health             HTTP server
  /actuator/health/readiness   HTTP 200
  /actuator/health/liveness    HTTP 200

=== /actuator/health 본문 (Redis 항목이 있는가) ===


=== /actuator/health/readiness 본문 ===
{"status":"UP"}

readiness200 이고 {"status":"UP"} 이다.

첫 줄의 HTTP server 는 상태 코드가 아니라 측정이 실패한 것이다. 값이 들어와야 할 칸에 엉뚱한 문자열이 들어와 있고, 503 이라는 값은 02-redis-down.txt 쪽 측정에서 나왔다. 빈 값이나 이상한 값을 「측정 결과」로 읽지 않는다 — 그건 측정 실패다. A-1 에서도 빈 문자열을 「변화」로 읽어 판정이 틀어진 적이 있다. 이상하면 그 칸을 다시 친다.

   /actuator/health              redis: DOWN  →  전체 DOWN  →  503
   /actuator/health/readiness    readinessState 만  →  UP  →  kubelet: "정상"

그래서 Service 에서 파드를 빼지 않는다.

kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=bff \
  -o "custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready"

실측(observed) — 03-health-groups.txt

=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
  ready: [10.42.0.52 10.42.1.124]

두 주소가 그대로 ready 다. 주입 전에 본 그 두 IP 이고, 두 파드가 계속 트래픽을 받으며 계속 실패한다. 어느 replica 로 가도 결과가 같으므로 재시도해도 소용없다. kubectl get endpoints 는 쓰지 않는다 — v1.33 부터 deprecated 라 경고가 뜨고, 해설 문서 5절의 재현 절차에는 옛 형태(get endpoints bff)가 실려 있다.

A-2 와의 대비가 이 실험의 결론이다.

A-2 (Keycloak · DB 상실) B-5 (BFF · Redis 상실)
의존 대상 헬스 지표 readiness 에 포함 포함 안 됨
파드 상태 NotReady Ready 유지
Service 엔드포인트 비었다 둘 다 남는다
외부 응답 503 (즉시, 명확) 000 (멈춤)

Keycloak 은 자기 의존성을 readiness 에 넣었고, 이 BFF 는 안 넣었다. 어느 쪽이 옳은지는 상황에 달렸다.

readiness 에 넣으면 넣지 않으면
의존 대상이 죽으면 전 파드가 빠진다 → 전면 장애 파드가 남아 실패를 계속 서빙한다
부분 기능이라도 살릴 수 없다 부분 기능(정적 페이지 등)은 살아 있다
A-2 처럼 명확한 503 멈춤 — 진단이 어렵다

의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다. 넣기로 정했다면 명시한다.

management:
  endpoint:
    health:
      group:
        readiness:
          include: readinessState, redis     # 넣으려면 명시해야 한다

liveness 에는 넣지 않는다. liveness 가 실패하면 kubelet 이 파드를 죽인다. Redis 가 없어서 죽인 파드는 다시 떠도 Redis 가 없으므로 또 죽는다 — 재시작해도 안 나아지는 문제에 재시작을 거는 것이다.

되돌리고 나면 손대지 않아도 회복한다. BFF 를 재시작하고 싶은 충동을 참는다 — 재시작하면 「스스로 회복하는가」를 영영 알 수 없다.

date '+%H:%M:%S 복구'
kubectl -n keycloak-lab scale deployment/redis --replicas=1
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
for p in /actuator/health /bff/token-boundary; do
  curl -s -o /dev/null -w "$p  %{http_code}  total %{time_total}\n" --max-time 90 \
    --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
done
kubectl -n keycloak-lab get pods -l app=bff

실측(observed) — 04-persistence.txt

  /actuator/health       HTTP 200
  /bff/token-boundary    HTTP 302
  BFF 재시작 필요했나: 0,0 회 재시작

재시작 0,0. Lettuce 가 스스로 재연결했다. A-2 에서 Keycloak 의 커넥션 풀이 그랬던 것과 같고, liveness 를 Redis 에 걸었다면 파드가 재시작됐을 것이며 회복이 더 늦어졌을 것이다. 302 는 실패가 아니다 — 세션이 사라졌으므로 로그인으로 보내는 것이고, Redis 가 비었으니 사용자는 로그아웃된다. 여기서 다음 질문이 나온다 — 「Redis 를 다시 띄웠는데 왜 세션이 없나.」 답은 「영속화가 없었으니까」다. 그럼 켜면 되나.

둘째 주입의 결과가 그 답이다. 볼륨 없이 AOF 만 켠 채 파드를 지운다.

kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly

실측(observed) — 04-persistence.txt

  --- 파드를 지운다 ---
deployment "redis" successfully rolled out
  재기동 후:
    dbsize:    0
    b5:probe   
    b5:aof     
    appendonly no

★ 설정이 되돌아가는 것은 이 실험대에서 안 보인다(2026-09-17, observed). 데이터는 그대로 사라져 dbsize0 이고 b5:aof 는 빈 값인데, config get appendonlyno 가 아니라 yes 다. 지금 매니페스트의 args 가 이미 --appendonly yes 라서, 「재기동하면 매니페스트가 이긴다」는 규칙이 이번에는 켜진 값을 되살린다. 규칙은 그대로 맞다. 매니페스트가 이미 yes 라서 되돌아가도 값이 안 바뀔 뿐이다.

0

appendonly
yes

볼륨을 되돌리고 같은 시험을 다시 했을 때는 b5:pvcwritten-on-pvc 로 살아남았고 dbsize4 였다. 나머지 셋은 그 사이에 BFF 가 만든 bff:session:sessions:… 키다.

두 가지가 같이 사라졌다.

사라진 것
데이터 /data 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다
설정 CONFIG SET런타임 전용이다. 재기동하면 매니페스트의 args 가 이긴다

쿠버네티스에서 영속화 설정만 켜는 것은 장식이다. appendonly yes 를 켜고 안심하는 것이 가장 위험하다 — 파일은 만들어지고 로그도 정상이며, 사라지는 것은 재시작 순간뿐이다. 그리고 재시작은 노드 정비·이미지 갱신·OOM(out of memory, 메모리가 모자라 커널이 프로세스를 죽이는 일) 어느 것으로든 일어난다. 설정이 되돌아간 것도 따로 중요하다. CONFIG SET 으로 고친 값은 CONFIG REWRITE 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가 안 남는다. 런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.

볼륨을 되돌리고 같은 시험을 다시 하면 결과가 갈린다.

kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc

실측(observed) — 04-persistence.txt

=== 영속 볼륨 위에서 다시 시험 ===
  appendonly	yes
  키 심음: written-on-pvc
sed: -e expression #1, char 8: unknown option to 's'

  --- 파드를 지운다 ---
deployment "redis" successfully rolled out
  재기동 후:
    dbsize:  1
    b5:pvc   written-on-pvc

dbsize: 1written-on-pvc. 살아남았다. 중간의 sed: -e expression #1, char 8: unknown option to 's'원래 실행의 스크립트가 낸 오류이고 측정과는 무관하다. 값에 / 가 들어간 문자열을 sed 's/.../.../' 에 그대로 넣으면 이렇게 된다. 증거 파일에 남은 오류를 지우지 않은 것은 그것이 「이 줄은 스크립트가 만든 것」이라는 표시이기 때문이다.

구성 파드 삭제 후
AOF , 볼륨 없음 전부 소실
AOF , 볼륨 없음 전부 소실 (설정은 켰는데)
AOF , PVC 생존

볼륨이 먼저고 설정이 나중이다. 순서를 바꾸면 두 번째 줄이 되고, 두 번째 줄은 첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서 더 나쁘다.

appendfsync 는 그래도 맞바꿈이다. 기본값은 appendfsync everysec 이다.

설정 잃는 양 비용
always 없음 쓰기마다 fsync — 느리다
everysec 최대 1초 기본값
no OS 에 맡김 가장 빠름

세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시 로그인해야 한다는 뜻이다. A-3 에서 본 PostgreSQL 의 synchronous_commit OFF 와 같은 모양의 맞바꿈이고, 거기서 Keycloak 이 같은 판단을 했다.

PVC 도 노드에 못박힌다.

kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo

형태(모양은 observed)

local-path

local-path 는 노드의 디렉터리다. A-4 에서 본 것과 같다 — 노드가 죽으면 볼륨도 함께 접근 불가가 되고 파드는 다른 노드로 못 옮겨간다. 영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.

이 실험은 관측에 숙제를 남겼다. Grafana 에 이 실험의 그래프가 없는데, 안 찍은 것이 아니라 지표가 없다.

실측(observed) — 04-observability-gap.txt(후속 조사)

=== B층 구성 요소의 지표가 있는가 ===
  redis_up                         시계열 0개
  redis_connected_clients          시계열 0개
  pg_up                            시계열 0개
  pg_stat_database_numbackends     시계열 0개

Prometheus 가 긁는 대상에 Redis·PostgreSQL·BFF 가 애초에 없다. A층이 Grafana 증거를 남길 수 있었던 것은 Keycloak 이 /metrics 를 내놓고 그것을 scrape 대상에 넣어 뒀기 때문이다. 관측은 나중에 붙이는 것이 아니라 실험 설계에 포함되어야 한다 — 「Redis 가 언제 끊겼고 언제 붙었나」를 초 단위로 보고 싶다면 redis_exporter 가 먼저 있어야 하고, 그건 실험이 끝난 뒤에는 못 만든다.

가이드는 무엇을 어떻게 붙일지까지 적어 두었다.

대상 방법
Redis redis_exporter 사이드카 또는 Deployment
PostgreSQL postgres_exporter
BFF 이미 actuator 가 있다 — /actuator/prometheus 노출 + scrape 추가
파드 readiness kube-state-metrics (A-2 에서 이미 찾은 항목)

복구와 원상복구 확인표

실험이 심은 키를 지운다. FLUSHALL 은 치지 않는다 — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
항목 명령 돌아왔을 때
Redis kubectl -n keycloak-lab get deploy redis 1/1
볼륨 … get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}' persistentVolumeClaim 이 보인다
PVC kubectl -n keycloak-lab get pvc redis-data Bound
영속화 … exec deploy/redis -- redis-cli config get appendonly yes
BFF kubectl -n keycloak-lab get pods -l app=bff 둘 다 1/1, RESTARTS 0
엔드포인트 … get endpointslice -l kubernetes.io/service-name=bff ready 주소
실험 키 … redis-cli --scan b5:* 없음
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/ 200

★ 이 편의 curl--resolve 가 붙어 있는 까닭이다(2026-09-17, observed). app1.hyeonworks.com 이 lab host 에서 100.83.212.4 로 풀리고 그 주소의 443 이 닫혀 있어, 이름만 치면 이 줄도 앞의 세 경로도 전부 000 이다. 그러면 주입 뒤와 견줄 값이 없다. 원 가이드가 적은 형태는 아래와 같고, 지우지 않고 남긴다.

for p in / /bff/token-boundary /actuator/health; do
  curl -s -o /dev/null -w "$p  %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
done

나머지 일곱 줄은 이 실험대에서 그대로 통과했다 — Redis 1/1, 볼륨에 persistentVolumeClaim, PVC redis-data Bound, appendonly yes, BFF 둘 다 1/1 이고 RESTARTS 0, 엔드포인트 주소 둘, --scanb5:* 없음.

로그인 세션은 돌아오지 않는다. 브라우저에서 다시 로그인하는 것이 복구다.

Redis 를 되살리는 쪽의 실측은 이렇다(observed).

실측04-persistence.txt

=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 그 기록에서 곧바로 따라 나오는 것이라고 적는다.

증상 원인 확인
curl 이 안 끝나고 터미널이 붙잡힌다 그게 이 실험의 결과다. 000 이 되는 과정이다 --max-time 을 붙인다
000 을 서버 오류로 읽는다 000응답을 못 받았다는 curl 의 표기다 400/503 과 구별한다
AOF 를 켰는데 안 남는다 /data 가 볼륨이 아니다 결론 내리기 전에 spec.volumes 를 먼저
파드를 지웠는데 데이터가 살아남는다 볼륨 제거 패치가 안 먹었다 get pod … spec.volumes비어야 한다
config set 한 값이 재기동 후 사라진다 런타임 전용이다. 매니페스트 args 가 이긴다 재기동 후 config get appendonly
/actuator/health 응답 칸에 이상한 문자열 측정 실패다. 값이 아니다 (HTTP server) 그 칸을 다시 친다
파드가 NotReady 가 되기를 기다린다 안 된다. redis 지표가 readiness 그룹에 없다 health 그룹별 응답
kubectl get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=bff
회복 후 로그인이 풀려 있다 정상이다. Redis 가 비었으니 세션이 없다 302 는 실패가 아니다
BFF 를 재시작해 버렸다 「스스로 회복하는가」를 못 재게 된다 주입부터 다시. 손대지 않고 기다린다
PVC 가 Pending local-path 프로비저너가 없거나 노드가 안 맞는다 describe pvc redis-data 의 Events
다른 실험이 갑자기 깨진다 FLUSHALL 을 쳤다. 같은 Redis 를 나눠 쓴다 접두어로만 지운다

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

  • (observed) 주입 전 Redis 키 1 개 · PostgreSQL 토큰 1 행 · save 빈 값 · appendonly no, 정지 시각 14:26:30, 정지 후 세 경로의 200 · 000 · 503 과 빈 응답 본문, BFF 로그의 pollConnect · finishConnect 스택, 정지 중에도 bff 두 개가 1/1 · RESTARTS 0 인 것, health 그룹의 readiness 200{"status":"UP"}, 엔드포인트에 남은 10.42.0.52 · 10.42.1.124, 복구 후 HTTP 200 · HTTP 3020,0 회 재시작, appendonlydir 이 만들어진 ls -la /data 출력, 볼륨 없이 파드를 지운 뒤의 dbsize: 0 · appendonly no, PVC 위에서 지운 뒤의 dbsize: 1 · written-on-pvc, 후속 조사의 네 지표가 전부 시계열 0개인 것.
  • 측정 실패를 값으로 읽지 않는다(observed) — 03-health-groups.txt/actuator/health HTTP server 는 상태 코드가 아니고, 같은 파일의 /actuator/health 본문 칸도 비어 있다. 50302-redis-down.txt 쪽 측정에서 나온 값이다.
  • (observed) 04-persistence.txt 에 섞인 sed: -e expression #1, char 8: unknown option to 's' 는 원래 실행의 스크립트가 낸 오류이고 측정과 무관하다. 증거 파일에서 지우지 않았다.
  • (unknown) 볼륨을 떼는 patch deployment redis --type=json 줄. 원래 실행은 반대 순서로 했다 — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 curl 에 로그인 쿠키가 없어 /bff/token-boundary3xx 로 나오는 것도 가이드가 미검증으로 표시했다.
  • (observed, 2026-09-17) 1번부터 복구까지 다시 밟은 값 — Redis 를 0대로 내려도 bff 둘이 1/1 이고 엔드포인트가 true,true, /actuator/health60.046452 초 뒤 503QueryTimeoutException: Redis command timed out, /bff/token-boundary 는 90초까지 무응답, 볼륨 없이 파드를 지운 뒤 dbsize 0appendonly yes, 볼륨을 되돌린 뒤 dbsize 4written-on-pvc, 네 지표는 여전히 시계열 0개. 원문은 relive-2026-09-17/b5-04..07.
  • (unknown) 브라우저 로그인 전제를 2026-09-17 에는 밟지 않았다. Redis 키가 0개인 채로 쟀으므로 「로그인한 사용자가 로그아웃된다」는 그 실행에서 확인하지 않았다.
  • 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 000500 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. readiness 그룹에 redis 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다.

B-6 — 서명 키를 회전하고 옛 키를 버리면 무엇이 끊기는가

근거: b6-key-rotation.md (708줄). 실행 기록은 2026-09-04 14:3014:32 KST(observed). 해설 문서 머리의 15:5016:00 KST 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.

이 실험이 가르는 것

Q3 의 미지수 3 은 이렇게 물었다.

"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가."

질문이 두 갈래로 갈린다.

상태
① 토큰 저장소의 암호화 key 존재하지 않는다. B-2 에서 bytea 안이 JWT 문자열 그대로임을 확인했다
② 토큰 서명 key (Keycloak realm) 존재하고 회전 가능하다 — 이 실험이 잰다

①이 없으므로 교체할 것도 없다. 그래서 이 가이드는 ②만 치고, 거기서 본 모양이 나중에 ①을 설계할 때 쓰인다.

그리고 이 실험은 예측이 틀린 실험이다.

예측 리소스 서버가 JWKS 를 캐시하니, 옛 키를 지워도 한동안은 통할 것
실측 유예가 없다. 제거 직후 바로 401 이다

이 실험은 두 동작을 갈라서 본다.

   키 추가   →  무중단.  JWKS 에 옛 키와 새 키가 함께 남는다
   키 제거   →  ★ 즉시 파괴적.  옛 키로 서명된 토큰이 곧바로 401

「교체」라는 한 단어가 실제로는 성질이 정반대인 두 조작이다. 회전이 위험한 것이 아니라 옛 키를 언제 버리느냐가 위험하다.

2026-09-17 에 이 실험을 새 실험대에서 다시 밟았다 — 절반은 재현됐고 절반은 못 쟀다(observed).

키 쪽은 그대로 나왔다. priority 200 짜리 공급자를 추가하니 JWKS 의 RS256 이 1 에서 2 로 늘고 새 토큰의 kid 가 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 kid 가 목록에서 사라졌다.

판정 쪽은 못 쟀다. 리소스 서버 echo 는 JWKS 를 SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI 가 가리키는 https://auth.hyeonworks.com/... 에서 받는데 그 443 에 듣는 것이 없다. 그래서 JWKS 를 아예 못 받고 회전과 무관하게 모든 토큰이 401 이다. 로그에 이렇게 남는다.

sun.security.ssl.SSLSocketImpl.connect …
org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:900)

대조군인 「회전 전 옛 토큰이 200」이 안 나오므로, 제거 뒤의 401 을 키 제거에 귀속할 수 없다. 이 실험의 결론 자체가 그 401 이라, 인증서가 설 때까지 이 편의 판정은 못 낸다.

막는 것은 두 겹이었다(observed). 인증서가 없는 것이 하나이고, 그 이름이 클러스터 안에서 엣지를 안 가리키는 것이 또 하나다. 파드에서 풀면 auth.hyeonworks.com100.83.212.4 로 가는데 그 80·443 은 둘 다 닫혀 있고, 실제로 열려 있는 192.168.122.10:80 을 가리키는 것이 클러스터 DNS 어디에도 없다. 가상화 쪽 03 이 nginx 를 엣지로 옮기면서 끊긴 경로이고, 거기에 적어 둔 CoreDNS 한 단계가 그것을 놓는다.

그리고 echo 에는 클러스터 안 주소가 아예 없다(observed). 같은 실험대의 BFF 매니페스트는 밖으로 보이는 주소와 안에서 쓰는 주소를 나눠 갖는다.

KC_ISSUER_EXTERNAL   https://auth.hyeonworks.com/realms/keycloak-patterns
KC_ISSUER_INTERNAL   http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns

echo.yaml 에는 그 짝이 없고 ISSUER_URIJWK_SET_URI 둘 다 바깥 https 주소다. 그래서 토큰을 받는 쪽은 클러스터 안 길로 돌아가는데 검증하는 쪽만 바깥 길에 묶인다. B-6 이 인증서에 걸리는 진짜 이유가 그 비대칭이고, B-0·B-1·B-2·C-1 도 같은 리소스 서버를 쓴다.

그리고 이 실험대에서는 「만료인지 아닌지」를 가르는 두 줄이 틀린 답을 낸다(observed). exp 는 Keycloak 이 게스트 시계로 찍고 date +%s 는 랩 호스트 시계로 찍는데, 같은 순간에 재 보니 93초 어긋나 있다.

lab host     1789629566   NTP=no    NTPSynchronized=no
kc-lab-1     1789629472   NTP=yes   NTPSynchronized=yes
kc-lab-2     1789629473   NTP=yes   NTPSynchronized=yes
kc-lab-edge  1789629473

게스트 셋은 서로 맞고 랩 호스트만 앞선다. 방금 받은 60초짜리 토큰도 랩 호스트에서 exp 를 재면 이미 33초 전에 만료된 것으로 읽힌다. §D-4·§D-4a 가 같은 호스트에서 NTPSynchronized=no+106.1 을 이미 재 두었는데, B-6 은 그것을 모르는 채로 exp 비교를 시킨다. 두 값을 같은 기계에서 뽑아야 갈린다.

realm 의 수명 두 값은 이제 실측이 있다(observed) — accessTokenLifespan60, ssoSessionIdleTimeout1800. ssoSessionMaxLifespan 은 이 realm 이 안 내놓는다.

전제와 되돌리기

  • 05-keycloak 이 끝나 있고 realm keycloak-patterns 에 클라이언트 bff-confidential 과 사용자 labuser 가 있다.
  • B-0 이 끝나 BFF 가 떠 있다.
  • 리소스 서버(echo, 네임스페이스 header-lab)가 떠 있다. 이 실험의 401/200 은 전부 그 앱이 판정한다.
  • Keycloak 이미지에는 curlwget 도 없다(exit 127). 그래서 JWKS 와 토큰은 kc-lab-1 호스트에서 공개 이름으로 치고, kcadm.sh 만 파드 안에서 돈다 — 항상 kubectl exec 로 감싼다.
  • 이 실험대에는 jq 가 없다. JSON 은 trgrep 으로 자른다.

★ 이건 되돌릴 수 없는 실험이다. 서명 키 공급자를 실제로 지우고, 지운 키는 돌아오지 않는다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 kid 가 다르다. 그러니 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 실험대에서만 한다. 전 구간 약 15분이고 주입 검증까지는 아무것도 안 깨진다.

되돌릴 수 있는 것은 주입 하나뿐이다. 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 주입이 출력하는 값이고 그 줄을 그대로 옮겨 친다 — 원래 실행에서는 7902af43-a0cc-4ebd-ad25-04d563854d16 이었다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns

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

시험군만 재는 측정은 측정이 아니다. 제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.

kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가

kcadm.sh파드 안 파일에 세션을 저장한다. 파드가 재시작되면 사라지고 그 뒤 모든 명령이 401 로 떨어지므로 맨 앞에서 한 번 해 둔다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

아무것도 안 나오면 성공이다. 실패하면 한 줄 오류가 뜬다. 비밀번호를 화면에 찍지 않는다 — 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 안 남고, 존재를 확인하고 싶으면 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

키 공급자 목록은 통째로 받아서 눈으로 본다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

JSON 배열이 여러 줄로 나온다. providerIdrsa-generated 인 항목이 서명 키 공급자이고 hmac-generated · aes-generated 등이 함께 나온다. "name" : "rsa-generated" 인 항목의 "id" 를 지금 적어 둔다 — 관찰 절에서 지울 대상이다.

★ 여기서 조용한 실패를 하나 만난다. 「키 공급자만 걸러 보자」는 자연스러운 시도가 빈 결과를 준다. 가이드는 이 줄을 미검증으로 표시한다(unknown) — 원래 실행에서 이렇게 쳤고 아무것도 안 나왔다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider

오류도 종료코드도 없이 비어 있다. 「키 공급자가 하나도 없구나」로 읽으면 이 실험 전체가 무너진다. -q 필터를 믿지 말고 --fields 로 전체를 받는다. A층 내내 반복해 만난 유형이고, 빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있다.

JWKS 원문도 한 번은 통째로 본다. 줄바꿈 없이 한 줄로 길게 나오지만, 어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸를지 안다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

--resolve 는 2026-09-17 에 붙인 것이다(observed). 원 가이드는 이름만 쳤는데 랩 호스트에서는 그 이름이 호스트 자신의 tailnet 주소로 풀리고 거기에는 443 을 듣는 것이 없어 000 이 된다.

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

실측(observed) — 첫머리. 01-before-rotation.txt 에 남은 조각 그대로다

{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

그 뒤로 kty · alg · use · n · e 가 이어지고 다음 키가 온다. kid 마다 alg 가 따로 붙는다. 읽을 만하게 자를 때는 jq 가 없으므로 tr 로 쉼표를 줄바꿈으로 바꾼다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측(observed) — 01-before-rotation.txt

  JWKS kid 목록:
    {"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

kid 는 두 개인데 이 실험이 세는 RS256 키는 하나다. 같은 파일의 바로 윗줄이 그렇게 말한다.

실측(observed)

  JWKS 의 RS256 키 수: 1

세는 단위가 다르다. JWKS 에는 서명 키만 실리는 게 아니다. 이 realm 에서는 암호화용 키(RSA-OAEP 계열)가 함께 실려 있고 그것도 kid 를 갖는다. grep kid | wc -l 로 세면 서명 키 수를 과다 계산한다. 알고리즘까지 보고 세려면 키 단위로 잘라야 하는데, JWKS 는 키 하나가 } 로 끝나므로 tr '}' 로 자르면 한 줄이 한 키가 된다. 가이드는 이 줄을 미검증으로 표시한다(unknown).

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr '}' '\n' | grep -c RS256

Keycloak 자신에게 묻는 편이 확실하고 그쪽이 1순위 도구인데, 가이드는 이 줄도 미검증으로 표시한다(unknown).

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get keys -r keycloak-patterns

키마다 붙는 algorithmstatus 를 보고, RS256 이면서 ACTIVE 인 것이 지금 서명에 쓰이는 키다. 원래 실행 기록에는 이 두 명령의 출력이 없었는데, 2026-09-17 에 둘 다 쳐 봤고 둘 다 돈다(observed).

1
"kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917"   "status" : "ACTIVE"   "algorithm" : "AES"
"kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c"   "status" : "ACTIVE"   "algorithm" : "HS512"
"kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"   "status" : "ACTIVE"   "algorithm" : "RS256"
"kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"   "status" : "ACTIVE"   "algorithm" : "RSA-OAEP"

tr '}' 쪽이 내는 1 과 위의 kid 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 RS256 이 하나, 암호화용 RSA-OAEP 가 하나이며 JWKS 에는 그 둘만 실린다. AESHS512 는 JWKS 에 안 나온다.

다만 위 네 줄의 curl 은 랩 호스트에서 안 돈다(2026-09-17, observed). 엣지 nginx 가 게스트로 옮겨 간 뒤로 auth.hyeonworks.com 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 듣는 것이 없다. tailnet 에 붙은 다른 기계에서 치거나, 랩 호스트에서 쳐야 하면 이름은 두고 주소만 엣지로 못박는다.

curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \
  http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

토큰을 하나 받고 그 토큰의 kid 를 본다. direct grant 로 받고, 클라이언트 비밀은 Secret 에서 꺼내 변수로만 넘긴다.

KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(kubectl -n keycloak-lab get secret bff-secrets \
     -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(curl -s -X POST --resolve auth.hyeonworks.com:443:192.168.122.10 "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${#OLD}자"

형태(모양은 observed)

2043자

0자 로 나오면 토큰을 못 받았다. 변수에 담지 말고 응답을 그대로 찍어 본문을 읽는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않는다 — 길이만 본다. 이 토큰이 이 실험의 시험체다. 변수 이름이 OLD 인 것은 회전이 끝난 뒤에도 이것이 「옛 키로 서명된 토큰」으로 있어야 하기 때문이고, 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다.

JWT 의 첫 토막이 헤더이고 거기 kid 가 있다.

echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

형태(모양은 observed)

{"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}

★ 콜론 양옆의 공백을 눈여겨본다. Keycloak 은 토큰 헤더"typ" : "JWT" 처럼 공백을 넣어 찍고 페이로드는 "sid":"…" 처럼 붙여 찍는다. 그래서 페이로드에서 되던 sed 가 헤더에서는 빈손으로 돌아온다. 2026-09-17 에 같은 토큰 하나로 두 형태를 나란히 쳤다(observed).

헤더: {"alg":"RS256","typ" : "JWT","kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"}
'"kid":"' 로 뽑으면   : []
'"kid" *: *"' 로 뽑으면: [HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg]

헤더에서 값을 뽑을 때는 공백을 허용한다.

echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \
  | sed -n 's/.*"kid" *: *"\([^"]*\)".*/\1/p'

실측(observed) — 01-before-rotation.txt

  발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM

kid 가 JWKS 목록에 있는 값과 같은지 본다. 이것이 이 실험의 뼈대다 — 토큰이 자기 서명 키를 스스로 밝히고 있다. base64 패딩 때문에 끝이 깨져 보일 수 있고(2>/dev/null 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.

kid 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지를 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 kid 를 찾아 공개키를 얻는다. kid 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고 키가 바뀌는 순간 옛 토큰이 전부 죽는다 — kid 가 겹침 구간을 가능하게 한다. 겹침이 불가능해진 사례는 B-7 에 있다. oauth2-proxy 의 쿠키에는 kid 에 해당하는 표시가 없고 그래서 --cookie-secret 도 단수다.

그 토큰이 지금 통하는지가 대조군이다. 이 확인을 건너뛰면 뒤의 401 은 아무 의미가 없다. 먼저 응답을 통째로 한 번 본다.

curl -s -i -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/api/me

상태줄과 본문을 본다. 200 이면 subject 같은 클레임이 돌아오고, 401 이면 WWW-Authenticate 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 「왜」를 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me

실측(observed) — 01-before-rotation.txt

=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
  /api/me  HTTP 200

회전 전에는 통한다. 원래 실행은 클러스터 안에서 http://echo.header-lab.svc:8081/api/me 를 쳤다. 이 가이드가 공개 이름을 쓰는 것은 kc-lab-1 에서는 클러스터 DNS 가 안 풀리기 때문이고, app1.hyeonworks.com/api 는 Ingress 가 같은 echo 로 보내므로 도달하는 앱은 같다. 인용한 HTTP 200 은 원래 실행의 값이다.

★ access token 은 60초짜리다(이 realm 은 accessTokenLifespan=60). 1분을 넘기면 회전과 무관하게 401 이 나므로, 뒤에서 401 을 만나면 먼저 「만료인가 키 문제인가」를 갈라야 한다.

Keycloak 의 키 회전은 「바꾸기」가 아니라 「더 높은 우선순위로 추가하기」다. 기존 공급자는 그대로 두고 priority 가 더 큰 공급자를 하나 더 만든다. 그러면 발급은 새 키로 가고 검증은 둘 다 받는다. 옛 키는 아무 데도 안 갔다.

   t0   키 A 만 있다.  발급: A,  검증: A
   t1   키 B 추가.     발급: B,  검증: A + B      ← 겹치는 구간
   t2   키 A 제거.     발급: B,  검증: B

이 실험이 재는 것은 t1 이 무중단인가t2 가 언제 안전한가다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create components -r keycloak-patterns \
  -s name=rsa-rotated -s providerId=rsa-generated \
  -s providerType=org.keycloak.keys.KeyProvider \
  -s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
date '+%H:%M:%S 추가'

실측(observed) — 02-rotation.txt

=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'

돌아온 id 를 적어 둔다. 되돌릴 때 쓴다. 그리고 config.priority 가 기존 공급자보다 큰지 본다 — 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 주입 검증에서 kid 가 안 바뀐다. config.* 값이 대괄호로 감싼 배열인 것에도 주의한다. -s config.priority=200 처럼 쓰면 형이 안 맞는다 — Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다. 주입 전과 똑같은 명령을 다시 친다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측(observed) — 02-rotation.txt

=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
  RS256 키 수: 2
  kid 목록:
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

kid(OY-caYDN…)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. 「회전」이라는 말과 달리 아무것도 교체되지 않았다 — JWKS 는 「지금 검증에 쓸 수 있는 키 전부」를 싣는 목록이고, 추가는 그 목록을 늘릴 뿐이다.

새 토큰은 어느 키로 서명되는지 본다. OLD 은 건드리지 않는다.

NEW=$(curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

실측(observed) — 02-rotation.txt

=== [5] 새 토큰은 어느 키로 서명되는가 ===
  새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84

kid우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.

★ 그리고 둘 다 통해야 t1 이 무중단이다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me

실측(observed) — 02-rotation.txt

=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
  옛 토큰  /api/me  HTTP 200
  새 토큰  /api/me  HTTP 200

둘 다 200. 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.

여기서 old 가 401 이면 둘 중 하나다. ① 토큰이 만료됐다(60초) ② 뭔가 다른 것을 건드렸다. 가르는 법은 옛 토큰의 exp 를 보는 것이고, JWT 의 가운데 토막이 클레임이다.

echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
date +%s

exp 가 지금보다 작으면 만료다. 다시 받되 이번에는 추가 전에 받아야 「옛 키로 서명된 토큰」이 된다.

관찰

★ 여기서부터 되돌릴 수 없다. 지우는 것은 키 공급자이고 그 안의 개인키가 함께 사라진다. 같은 이름으로 다시 만들어도 다른 키 쌍이 생긴다. 계속하기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가, 주입 검증의 old 200 을 실제로 봤는가. 마지막 것을 안 봤다면 401 이 나와도 원인을 못 가른다.

지울 대상을 정확히 고른다. -q 는 여전히 안 먹는다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

"name" : "rsa-generated" 인 항목의 id 를 고른다. 방금 만든 것은 "name" : "rsa-rotated" 이고 둘을 바꿔 지우면 실험이 뒤집힌다. 목록이 길면 그 항목 둘레만 잘라 본다. "id""name" 보다 에 있다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId \
  | grep -B2 '"name" : "rsa-generated"'

실측(observed) — 03-old-key-removed.txt

=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
  제거 완료

980ee9b7… 로 시작하는 것이 옛 공급자의 id 였고 그것이 OY-caYDN… 키를 갖고 있었다. 환경마다 id 가 다르고, 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 명령의 출력에서 그대로 옮겨 온다.

OLDID=980ee9b7-...        # ← 위 출력에서 그대로 옮긴다. 환경마다 다르다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'

조용히 끝나면 성공이고 시각을 적어 둔다. JWKS 에서 사라졌는지는 또 같은 명령으로 본다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측(observed) — 03-old-key-removed.txt

=== [8] JWKS 에서 사라졌는가 ===
  RS256 키 수: 1
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

OY-caYDN… 이 없다. RS256 은 다시 1개이고, gokjn0… 은 처음부터 끝까지 목록에 있다 — 서명 키가 아니기 때문이다. 이제 두 토큰을 주입 검증과 똑같은 두 줄로 다시 친다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me

실측(observed) — 03-old-key-removed.txt

=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
  옛 토큰  /api/me  HTTP 401  (캐시가 살아 있으면 아직 통할 수 있다)
  새 토큰  /api/me  HTTP 200

옛 토큰 401, 새 토큰 200. 제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 있는 셈이다. 새 토큰도 401 이면 제거를 잘못했다 — 새 공급자를 지운 것이거나 그냥 만료다.

「리소스 서버가 아직 JWKS 를 캐시하고 있어서 우연히 401 인가?」라는 의심이 남는데, 캐시를 비워 보면 갈린다.

kubectl -n header-lab rollout restart deploy/echo
kubectl -n header-lab rollout status deploy/echo --timeout=180s

실측(observed) — 03-old-key-removed.txt

=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
  옛 토큰  /api/me  HTTP 401
  새 토큰  /api/me  HTTP 200

재시작 전과 후가 같다. 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다.

Spring 의 NimbusJwtDecoder모르는 kid 를 만나면 JWKS 를 다시 가져온다. 캐시는 「이미 아는 키를 다시 안 받으려는」 장치이지 「옛 키를 붙잡아 두는」 장치가 아니다.

   옛 토큰 도착
      │
      ├─▶ kid = OY-caYDN…  →  캐시에 없다
      │                          │
      │                          └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
      │
      └─▶ 새로 받은 JWKS 에도 없다  →  401

캐시가 오히려 제거를 빨리 반영시킨다. 예측이 정확히 뒤집힌 까닭이 여기 있다. 유예는 캐시로 만드는 것이 아니라 옛 키를 JWKS 에 남겨 두는 기간으로 만들어야 한다.

그러면 겹치는 구간은 얼마나 길어야 하나. 최소 길이는 옛 키로 서명된 것 중 가장 오래 사는 것의 수명이다.

이 실험대에서
access token 60초
refresh token 1800초 (30분)
필요한 겹침 최소 30분

이 값들은 realm 설정이므로 직접 본다. 가이드는 이 줄을 미검증으로 표시한다(unknown).

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan

「교체하는 동안」의 길이를 정하는 것은 key 가 아니라 그 key 로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽이는 것이다. ①에 적용하면 이렇게 된다.

   쓰기: 새 key 하나로만
   읽기: 새 key + 옛 key(들)      ← key 에도 식별자가 필요하다
   제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤

저장된 값에 kid 에 해당하는 표시가 없으면 회전이 불가능하다. 암호화를 설계할 때 key 식별자를 값과 함께 저장해야 하는 까닭이고, 그것이 없을 때 어떻게 되는지가 B-7 이다.

복구와 원상복구 확인표

이 실험에는 원상복구가 없다. 지운 키 공급자는 개인키와 함께 사라졌고, 같은 이름으로 다시 만들면 새 키 쌍이 생기고 kid 가 다르므로 옛 토큰은 그래도 401 이다. 정상 상태는 「새 키 하나만 남은 상태」다 — 실험 전과 다르지만 깨진 상태가 아니다.

남은 것 어떻게
rsa-rotated 공급자 그냥 둔다. 지금 유일한 RS256 서명 키다 지우면 realm 이 서명할 키를 잃는다
셸 변수 OLD NEW CS 터미널을 닫으면 사라진다 unset OLD NEW CS
실험 중 발급한 토큰 60초 뒤 만료된다 별도 조치 없음

이름이 거슬리면 새 공급자를 하나 더 만들고 rsa-rotated 를 지우면 된다. 다만 그것 역시 또 한 번의 회전이고 또 하나의 새 키다.

항목 명령 돌아왔을 때
서명 키 curl -s …/protocol/openid-connect/certs | tr ',' '\n' | grep kid RS256 이 하나
새 토큰 토큰 발급 + /api/me 200
리소스 서버 kubectl -n header-lab get pods echo1/1 Running
Keycloak kubectl -n keycloak-lab get pods 둘 다 1/1 Running
공급자 목록 kcadm get components --fields id,name,providerId rsa-generated 가 없고 rsa-rotated 가 있다

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
kcadm get components -q type=... 가 빈 결과 -q 필터가 안 먹는다. 오류도 없다 --fields id,name,providerId 로 전체를 받는다
kcadm 이 전부 401/Unauthorized 파드가 재시작되어 kcadm 세션이 사라졌다 config credentials 를 다시
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 JWKS·토큰은 kc-lab-1 호스트에서 친다
jq: command not found 이 실험대에는 jq 가 없다 tr ',' '\n' | grep 로 자른다
kid 를 세니 2개인데 문서는 1개라고 한다 RS256 이 아닌 암호화 키가 섞여 있다 tr '}' '\n' | grep -c RS256 또는 kcadm get keys
공급자를 추가했는데 새 토큰의 kid 가 그대로 config.priority 가 기존보다 낮다 값이 ["200"] 처럼 배열인지
추가만 했는데 옛 토큰이 401 추가가 아니라 토큰이 만료됐다(60초) 클레임의 expdate +%s 비교
제거했는데 토큰이 401 지운 것이 새 공급자다 kid 를 다시 확인하고 남은 공급자 목록을 본다
제거했는데 옛 토큰이 200 지운 것이 그 토큰의 키가 아니다 토큰 헤더의 kid 와 지운 공급자의 키를 대조
「캐시 때문일 것」이라 재시작을 기다린다 캐시는 유예를 주지 않는다 재시작 전후가 같다
지운 키를 되살리려 한다 되살릴 수 없다. 같은 이름 ≠ 같은 키 새 키 하나만 남은 상태가 정상이다

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

  • (observed) 회전 전 JWKS 의 kid 두 개(gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4 · OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM)와 RS256 키 수 1, 발급 토큰의 kidOY-caYDN… 인 것, 그 토큰의 /api/me HTTP 200, 추가한 공급자의 id 7902af43-a0cc-4ebd-ad25-04d563854d16, 회전 후 RS256 키 수 2 와 늘어난 kid 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84, 새 토큰의 kid 가 그것인 것, 겹침 구간의 옛 200 · 새 200, 제거 후 RS256 키 수 1옛 401 · 새 200, echo 를 재시작한 뒤에도 옛 401 · 새 200 인 것.
  • 비밀은 길이와 존재만 적는다 — 클라이언트 비밀은 CS 변수에 명령 치환으로만 넘겨 화면에 찍지 않고, admin 비밀번호도 wc -c 로 길이만 본다. 토큰은 2043자 라는 길이만 옮겼고 값은 증거 파일에 둔다. kid 와 공급자 id 는 공개 식별자라 그대로 적었다.
  • (unknown) -q type=org.keycloak.keys.KeyProvider 로 거르는 줄(조용히 빈 결과를 준다), tr '}' '\n' | grep -c RS256 로 RS256 만 세는 줄, kcadm get keys 로 알고리즘과 상태를 보는 줄, realm 의 수명 세 값을 한 번에 받는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다.
  • 추론이지 측정이 아닌 것 — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초 라는 설정에서 따라 나온 값이다. 겹침을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다. 재려면 추가와 제거 사이를 30분 이상 벌리고 그 사이에 받은 refresh token 으로 제거 뒤에 갱신을 시도한다.

B-7a — 고아 세션을 TTL 로 골라내 지울 수 있는가

근거: b7a-orphan-session.md (699줄). 실행 기록은 2026-09-04 11:2911:34 UTC(observed). 시각은 전부 UTC 로 다룬다 — 이 실험의 결론이 시각 계산이라 KST 와 섞이면 9시간이 틀어진다.

이 실험이 가르는 것

B-7 은 여기서 멈췄다.

[stored_session.go:97] Error removing session:
    error decoding ticket to clear session: session ticket cookie failed validation

티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다. 그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. 그런데 못 지우는 주체가 누구인지를 안 갈랐다.

B-7 이 남긴 말 「★ 지우지 못했다」
B-7a 가 묻는 것 그건 oauth2-proxy 의 한계인가, Redis 의 한계인가

답은 oauth2-proxy 의 한계다. 프록시는 티켓을 못 풀어 키를 계산 못 하지만 운영자는 키를 직접 안다--scan 하면 다 보인다. 그러면 다음 물음이 생긴다. 보이긴 하는데 어느 것이 고아인가. 이 실험이 실제로 재는 것은 그 판별이고 답은 TTL 하나다.

   (1) 고아의 TTL 은 정말 줄어드는가     — 사라지기는 하는가
   (2) 운영자가 지울 수 있는가            — 지우면 산 세션이 다치는가
   (3) ★ 어느 키가 고아인지 구분되는가   — 이것이 진짜 질문이다

전제와 되돌리기

  • B-7 이 끝나 있다. oauth2-proxy 가 app2.hyeonworks.com 에서 돌고 있고 세션 저장소가 Redis 여야 한다. 이 실험은 B-7 이 「지우지 못했다」로 멈춘 데서 시작한다.
  • 브라우저가 필요하다. 고아는 사람이 옛 쿠키를 들고 와야 생긴다.
  • 이 실험대에는 jq 가 없다. Redis 는 자기 CLI 로 묻는다.

이건 남의 세션을 실제로 지우는 실험이다. redis-cli del 로 세션 키를 지우고, 산 사람의 세션을 잘못 지우면 그 사람은 재로그인해야 한다(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는 측정되지 않았지만 실험대에서만 한다. B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다. 전 구간 약 20분이고 그중 TTL 을 세 번 재는 데 1분이 그대로 든다.

되돌리기는 secret 참조를 A 로 되돌리는 한 줄이다.

kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_A"}]'

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

프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계

refresh:disabled 를 먼저 확인한다. 이 한 단어가 정리 규칙 전체의 전제다. 여기가 disabled 가 아니면 이 가이드의 결론은 그 환경에서 성립하지 않는다.

kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'

실측(observed) — b7-cookie-secret/03-rotation.txt

[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
이 실험에서
expiry:1h0m0s 3600초 역산식에 그대로 들어간다
refresh:disabled TTL 이 요청으로 갱신되지 않는다 이게 enabled 면 역산이 무너진다

TTL 이 고정이면 TTL 은 생성 시각의 정확한 함수다. 기동 로그가 잘려 나갔으면 인자에서 직접 본다.

kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie

형태(모양은 observed)

"--cookie-secure=true"
"--cookie-expire=1h"

--cookie-refresh 가 목록에 없어야 한다. 없으면 refresh:disabled 다.

세션을 하나 만든다. 브라우저에서 https://app2.hyeonworks.com/api/echo 를 열고 labuser / labpass 로 로그인하면, upstream 의 JSON 이 보이는 것으로 세션이 생긴 것을 안다.

한 번은 통째로, 필드를 하나씩 본다. 나중에 루프로 묶더라도 처음에는 type · ttl · strlen 이 각각 무엇을 답하는지 봐 두어야 한다.

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize

실측(observed) — 01-orphan-lifecycle.txt

[기준선] 회전 전 — 11:29:42 UTC
  secret = COOKIE_SECRET_A
  _oauth2_proxy-f6a9201fd534a047998278452001ccbf
     type=string  ttl=3568초  크기=3510바이트
  dbsize=1

그 키 하나에 대해 셋을 묻는다. 키 이름은 위 출력에서 가져온다.

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf
kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf
kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf
명령 답하는 질문 이 실험에서
type 무슨 자료형인가 전부 string구분에 못 쓴다
strlen 몇 바이트인가 전부 3510구분에 못 쓴다
ttl 몇 초 남았나 유일하게 다른 값

ttl-1 이면 만료가 안 걸린 키이고(이 실험의 대상이 아니다) -2 면 키가 없다 — 이름을 잘못 옮겼다.

키가 여럿이 되면 손으로 세 번씩 치기 번거로우니 짧은 함수를 하나 둔다. 한 줄짜리고 하는 일이 이름 그대로다.

R() { kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
done

형태(모양은 observed)

_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510

이 루프는 키 하나마다 kubectl exec 를 세 번 한다. 느리다. 키가 수백 개면 그대로 쓰지 말고 --scan 결과를 파일로 받아 두고 필요한 것만 묻는다.

마지막으로 시계를 맞춘다. 이 실험은 시각 계산이 결론이다. 프록시 로그는 UTC 이고 셸의 date 는 KST 일 것이라 섞이면 9시간이 틀어진다.

date; date -u
timedatectl show -p NTP -p NTPSynchronized

형태(모양은 observed)

NTP=yes
NTPSynchronized=yes

NTPSynchronized=yes 를 보고, 앞으로 date 는 전부 -u 를 붙여 친다. 그래야 로그의 [2026/09/04 05:42:18] 과 회전 시각을 같은 축에 놓을 수 있고, 뒤에 나오는 「1초 오차」도 이 축이 맞아야 나온다.

주입

주입은 1차 회전 A → B 이고, 회전 시각을 반드시 기록한다. ★ 이 값이 정리 규칙의 절반이다. 안 적어 두면 나중에 고아를 못 고른다.

ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"

실측(observed) — 원래 실행의 1차 회전 시각

11:29:56 UTC
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_B"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

successfully rolled out 을 본다. env 배열의 인덱스가 그 매니페스트와 맞는지는 B-7 에서 확인했다 — 안 했으면 지금 한다.

주입 검증

kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo

형태(모양은 observed)

COOKIE_SECRET_B

★ 그런데 Redis 는 그대로다. 주입 전과 똑같은 명령으로 본다.

R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K ttl=$(R ttl $K)"
done
R dbsize

실측(observed) — 01-orphan-lifecycle.txt

[주입] 1차 회전 A → B — 11:29:56 UTC
  회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)

키 수가 회전 전과 같다. 여기서 「실험 실패」라고 결론 내리면 틀린다. 회전은 방아쇠가 아니라 조건이고, 실제로 벌어지는 것은 누군가 옛 쿠키를 들고 오는 순간이다. A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 모양이다 — 주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.

그래서 브라우저로 다시 연다. 로그인했던 그 브라우저 그대로 https://app2.hyeonworks.com/api/echo 를 열고, 그 순간의 로그를 본다.

kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session

실측(observed) — 01-orphan-lifecycle.txt

  브라우저가 접근한 순간(11:30:27) 로그:
    [stored_session.go:94] Error loading cookied session:
        session ticket cookie failed validation: <nil>, removing session
    [stored_session.go:97] Error removing session:
        error decoding ticket to clear session: session ticket cookie failed validation
    [oauthproxy.go:1024] No valid authentication in request. Initiating login.
    [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}

AuthSuccess 의 시각을 적어 둔다. 11:30:27. 뒤에서 이 숫자와 역산값을 맞춰 본다.

R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K ttl=$(R ttl $K)"
done
R dbsize

실측(observed)

  Redis:
    _oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593  ttl=3588  ← 새 세션
    _oauth2_proxy-f6a9201fd534a047998278452001ccbf  ttl=3511  ← ★ 고아
    dbsize=2

키가 둘이고, 로그인 화면은 안 봤다. Keycloak SSO 가 살아 있어 조용히 재인증됐고 B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.

관찰

★ Redis 값만 보고는 구분할 수 없다. 두 키를 나란히 놓는다.

R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)"
done

실측(observed) — 01-orphan-lifecycle.txt

[측정 1] ★ Redis 만 보고는 구분할 수 없다
  키                                                type    strlen  ttl
  _oauth2_proxy-87faa1c9…(새)                       string   3510   3558
  _oauth2_proxy-f6a9201f…(고아)                     string   3510   3480

열을 하나씩 지운다.

신호 새 세션 고아 쓸 수 있나
이름 접두사 _oauth2_proxy- 같다
이름 뒷부분 불투명한 32자 hex 같은 성질 ✗ — 사용자·시각·상태 어느 것도 안 담긴다
type string string
strlen 3510 3510 ✗ — 바이트 단위로 같다
ttl 3558 3480 ✓ 이것뿐이다

값을 직접 봐도 소용없다. 암호화되어 있다.

R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo

실측(observed) — 세션 값은 암호화된 바이너리라 해시와 이스케이프된 앞머리만 옮긴다

      새   "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
      고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
      md5  f9ad43cc6bbb2db4  /  9b31f7c4138e6472   (다르지만 뜻을 읽을 수 없다)

--no-raw 를 안 붙이면 터미널이 깨진다. 세션 값은 바이너리이고, 붙이면 \xNN 로 이스케이프해서 보여 준다. 두 값이 다르다는 것은 알 수 있지만 어느 쪽이 고아인지는 말해 주지 않는다 — 뜻을 읽을 수 없기 때문이다. 다른 것은 TTL 하나뿐이다.

TTL 을 신호로 쓰려면 그것이 믿을 만한지부터 재야 한다. 둘을 확인한다 — ① 실제로 줄어드는가 ② 요청을 보내면 되살아나는가. 30초 간격으로 세 번이고, 여기에 1분이 그대로 든다.

for i in 1 2 3; do
  date -u '+%H:%M:%S'
  R --scan --pattern '_oauth2_proxy-*' | while read K; do
    printf "  %s ttl=%s\n" "$K" "$(R ttl $K)"
  done
  sleep 30
done

실측(observed) — 01-orphan-lifecycle.txt

[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
  30초 간격 3회:
     t+00초  새=3557  고아=3479
     t+30초  새=3526  고아=3448
     t+60초  새=3494  고아=3417

30초에 30초씩 준다. 그리고 두 값의 차가 거의 고정이다 — 35573479 = 78, 35263448 = 78, 34943417 = 77. 차이가 (1초 안에서) 고정이라는 것이 「둘 다 생성 시각에만 달렸다」는 뜻이다. 그 1초의 흔들림은 TTL 이 초 단위 정수라서 생기는 반올림이고, 뒤에 나오는 「1초 오차」와 같다.

②는 브라우저로 요청을 몇 번 보낸 뒤 다시 재서 확인한다.

실측(observed)

  요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
     살아있는 세션 ttl=3464   ← 계속 줄어든다
  기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.

쓰고 있어도 TTL 이 안 늘어난다. 앞에서 본 refresh:disabled 가 여기서 값으로 확인됐고, 따라서 고아는 생성 후 정확히 1시간에 사라진다. 무한정 쌓이지 않는다.

--cookie-refresh 를 켜면 요청마다 세션이 갱신되고 TTL 이 연장된다. 끄면 생성 시점부터 고정된 시간이 흐른다. TTL 이 고정이면 이 식이 성립한다.

   생성시각 = 지금 - (cookie-expire - TTL)

이 한 줄이 정리 규칙 전체를 만든다. cookie-expire 는 앞에서 1h0m0s = 3600 으로 확인했다. --cookie-refresh 를 켜는 순간 이 역산이 무너진다 — 활발히 쓰는 세션일수록 TTL 이 크게 남아 「방금 만들어진 것」처럼 보이고, 오래 안 쓴 산 세션은 TTL 이 작아 고아로 오판되어 지워진다. 그때는 회전 후 _oauth2_proxy-* 를 전부 지우고 모두 재인증시키는 편이 오히려 정직하다. 아래 정리 규칙은 refresh:disabled 일 때만 유효하다.

운영자는 지울 수 있고 산 세션은 다치지 않는다. 되돌리기가 없는 조작이니 지우기 전에 어느 키인지 두 번 확인한다. 지금은 TTL 이 작은 쪽이 고아다.

R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'

실측(observed) — 01-orphan-lifecycle.txt

[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
  redis-cli del _oauth2_proxy-f6a9201f…   → 반환 1
  dbsize  2 → 1
  남은 키: _oauth2_proxy-87faa1c9…

반환값 1. 0 이면 그 키가 없었던 것이다(이름을 잘못 옮겼다). 산 세션이 멀쩡한지는 브라우저로 다시 열어서 본다.

kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser

실측(observed)

  삭제 직후 브라우저 요청 (11:32:49):
    app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108

200. 산 세션은 영향이 없다. 「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키를 계산 못 하고, 운영자는 키를 직접 안다.

같은 사실을 화면으로 찍은 것이 함께 있다.

고아 삭제 후 살아있는 세션

★ 고아는 회전할 때마다 누적한다. 한 번 더 회전해 보면 일회성인지 누적인지가 갈린다.

ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)"
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_A"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

그리고 브라우저로 다시 연 뒤 본다.

R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K ttl=$(R ttl $K)"
done

실측(observed) — 01-orphan-lifecycle.txt

[측정 4] ★ 누적한다 — 회전할 때마다
  2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:

  키                              TTL    생성시각(추정)  판정
  _oauth2_proxy-dad9c9fb…         3581   11:33:54       살아있음
  _oauth2_proxy-87faa1c9…         3373   11:30:26       ★ 고아
  dbsize=2

87faa1c9… 의 신분이 바뀌었다. 주입 검증에서 「새 세션」이던 것이 여기서는 고아다. 1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다. 회전 1회 = 그 시점 로그인 사용자 수만큼의 고아이고, 고아는 사건이 아니라 회전의 고정 비용이다.

규칙을 쓰기 전에 규칙 자체를 검증한다. 위 표의 「생성시각(추정)」은 역산식으로 나온 값이고, 대조할 실측이 하나 있다 — 주입 검증에서 적어 둔 AuthSuccess 시각이다.

실측(observed)

[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
  **1초 오차.** 추정이 아니라 사실상 정확하다.

1초. TTL 이 초 단위 정수라 반올림에서 나올 수 있는 크기다. TTL 역산은 추정이 아니라 측정에 가깝고, 그래서 다음 규칙을 안심하고 쓴다.

   생성시각 < 회전시각   →   그 키는 고아다

회전 이후에 만들어진 세션은 새 secret 으로 만들어졌으므로 반드시 유효하다. 그러니 회전 이전 생성분만 고르면 된다.

복구와 원상복구 확인표

del 을 바로 붙이지 않는다. 같은 루프를 echo 로 한 번 돌려 무엇이 지워질지 읽는다. ROT 은 주입(또는 2차 회전의 ROT2)에서 담아 둔 값이다.

NOW=$(date -u +%s); EXP=3600
R --scan --pattern '_oauth2_proxy-*' | while read K; do
  T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
  if [ "$C" -lt "$ROT" ]; then
    echo "고아  $K (생성 $(date -u -d @$C +%H:%M:%S))"
  else
    echo "산것  $K (생성 $(date -u -d @$C +%H:%M:%S))"
  fi
done

「산것」이 정확히 지금 로그인해 있는 사람 수만큼 있는가를 본다. 아니면 ROT 이 틀렸거나 EXP 가 3600 이 아니다. NOW 를 루프 밖에서 한 번만 잡는 것이 중요하다 — 안에서 잡으면 키마다 기준 시각이 달라진다. 확인한 뒤에 지운다.

NOW=$(date -u +%s); EXP=3600
R --scan --pattern '_oauth2_proxy-*' | while read K; do
  T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
  if [ "$C" -lt "$ROT" ]; then
    echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"
  fi
done
R dbsize

실측(observed) — 01-orphan-lifecycle.txt

  실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
  산 세션은 남고 고아만 사라졌다.

지운 뒤 브라우저로 한 번 더 열어 본다. 열리면 산 세션이 안 다쳤다. dbsize 는 이 Redis 전체를 센다 — BFF 세션과 B-5 가 남긴 키도 들어 있고, 여기서 dbsize=1 이 나온 것은 당시 다른 키가 없었기 때문이라 환경마다 다르다. 세션만 세려면 --scan --pattern 을 쓴다.

전제가 깨졌을 때(--cookie-refresh 가 켜져 있을 때)는 위 규칙을 쓰면 안 된다. 전부 지우고 모두 재인증시킨다. FLUSHDB 를 쓰지 않는다 — 이 Redis 에는 BFF 세션도 들어 있어 패턴으로 좁히는 것이 이 실험대에서는 필수다.

R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done

secret 참조를 확인하고 A 로 되돌린다. 2차 회전에서 이미 A 로 돌아왔다면 그대로 둔다.

kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo

B-7 에서 Grafana Ingress 를 빌렸다면 여기서 돌려준다. C-1 을 이어서 할 생각이면 아직 돌려주지 않고, C-1 이 끝난 뒤에 반드시 복구한다고 가이드가 적는다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
항목 명령 돌아왔을 때
secret 참조 get deploy oauth2-proxy -o jsonpath='{...env[1]...key}' COOKIE_SECRET_A
파드 kubectl -n keycloak-lab get pods -l app=oauth2-proxy 둘 다 1/1 Running
세션 R --scan --pattern '_oauth2_proxy-*' 지금 로그인한 사람 수만큼만
다른 키 R --scan --pattern '*' b5:pvc·BFF 세션이 살아 있다 (안 지웠어야 한다)
Ingress kubectl -n observability get ingress grafana 있다 (돌려줬다면)
셸 변수 unset ROT ROT2 NOW EXP

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
회전했는데 Redis 가 그대로 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 생긴다 브라우저로 접근
고아와 산 세션이 구분이 안 간다 이름·타입·크기가 같다. 값은 암호화 TTL 만이 신호다
get 했더니 터미널이 깨진다 값이 바이너리다 redis-cli --no-raw get
ttl-1 만료가 안 걸린 키다 이 실험의 대상이 아니다
ttl-2 · del0 그 키가 없다 키 이름을 --scan 출력에서 다시 옮긴다
역산 생성시각이 미래거나 엉뚱하다 EXP 가 3600 이 아니다 --cookie-expire 를 확인
역산이 9시간 어긋난다 date 를 로컬로 쳤다 전부 date -u
산 세션이 고아로 잡힌다 --cookie-refresh 가 켜져 있다 기동 로그의 refresh:disabled 확인
산 세션을 지워 버렸다 되돌릴 수 없다 재로그인하면 된다. SSO 가 살아 있으면 조용히 지나간다
dbsize 와 세션 수가 안 맞는다 b5:pvc·BFF 세션이 섞인다 --scan --pattern '_oauth2_proxy-*'
FLUSHDB 로 지웠더니 app1 도 끊겼다 같은 Redis 에 BFF 세션이 있다 패턴으로 좁혀 지운다
루프가 너무 느리다 키마다 kubectl exec 를 한다 --scan 결과를 먼저 받아 두고 필요한 것만 묻는다
로그 시각이 9시간 어긋난다 프록시 로그는 UTC 표시 규약

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

  • (observed) 기동 로그의 expiry:1h0m0srefresh:disabled, 회전 전 _oauth2_proxy-f6a9201fd534a047998278452001ccbftype=string · ttl=3568초 · 크기=3510바이트 · dbsize=1, 1차 회전 시각 11:29:56 UTC 와 회전 직후 키가 1개인 것, 브라우저 접근 시각 11:30:27 과 그때의 로그 네 줄, 새 세션 87faa1c9… ttl=3588 과 고아 f6a9201f… ttl=3511, 두 키의 strlen둘 다 3510 인 것, 30초 간격 세 번의 TTL 여섯 값과 요청 후 ttl=3464, del 반환 1dbsize 2 → 1, 삭제 직후 /oauth2/userinfo200 108, 2차 회전 11:33:27 UTC 와 그 뒤의 dad9c9fb… 3581 · 87faa1c9… 3373, 역산한 11:30:26 과 로그의 AuthSuccess 11:30:271초 차인 것.
  • 비밀은 길이·존재만 적는다 — cookie secret 은 Deployment 가 참조하는 키 이름 (COOKIE_SECRET_A · COOKIE_SECRET_B)으로만 나오고 값은 어디에도 안 적었다. 세션 값은 암호화된 바이너리라 증거 파일이 남긴 md5 두 개와 이스케이프된 앞머리만 옮겼다. Redis 키 이름은 운영자가 --scan 으로 보는 식별자라 그대로 적었다.
  • (unknown) R() 함수와 그것을 쓰는 while 루프들, 30초 간격 TTL 루프, 역산으로 고아를 고르는 if 루프. 가이드가 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다. 루프가 만든 값 자체(TTL 숫자들)는 증거 파일에 있으므로 실측이고, 그 값을 뽑아낸 형태가 미검증이다.
  • 전제가 깨지면 성립하지 않는 것 — 역산 규칙은 refresh:disabled 에서만 유효하다. --cookie-refresh 를 켠 뒤에 어떻게 어긋나는지는 재지 않았다(unknown). 전부 지우는 대안을 쓴다는 것까지가 가이드가 적은 내용이다.
  • 이 실험이 재지 않은 것 — 고아를 1시간 내내 두고 실제로 만료되는 것을 끝까지 지켜보지는 않았다. TTL 이 정직하게 줄고 갱신되지 않는다는 것까지가 측정이고, 「정확히 1시간에 사라진다」는 거기서 따라 나온 것이다. 산 세션을 잘못 지웠을 때의 피해도 재로그인 말고는 측정되지 않았다.

근거: b7-cookie-secret-rotation.md (767줄). 실행 기록은 2026-09-04 14:3514:42 KST(observed). kubectl 로 보는 시각은 KST 인데 oauth2-proxy 가 찍는 로그 타임스탬프는 UTC 다. 증거의 로그가 [2026/09/04 05:41:46] 인 것과 수집 시각이 14:3514:42 KST 인 것은 같은 순간이고 (KST = UTC+9), 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.

이 실험이 가르는 것

Q1 의 미지수 7 은 이렇게 물었다.

"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."

B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다. kid 가 있어서 「읽기는 여러 키, 쓰기는 하나」가 됐기 때문이다.

예측
B-6 의 모양대로라면 oauth2-proxy 도 겹침 구간을 만들 수 있을 것
실측 ★ 없다. --cookie-secret 은 단수이고 쿠키에 key 식별자가 없다

그리고 예측하지 않았던 것이 하나 더 나온다 — 사용자는 아무것도 못 느끼는데 서버 쪽에 지워지지 않는 세션이 생긴다. 그 「지우지 못한다」를 이어서 재는 것이 B-7a 다.

핵심은 상태를 어디에 두었는가다.

   BFF          인가 요청을 서버 메모리(HttpSession)에 둔다  →  replica 를 넘으면 실패
   oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다  →  replica 를 넘어도 성공
                                                                  대신 secret 이 단일 지점

공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.

전제와 되돌리기

  • 05-keycloak 이 끝나 있고 realm keycloak-patterns 에 클라이언트 oauth2-proxy 와 사용자 labuser(비밀번호 labpass)가 있다.
  • B-0 이 끝나 있어야 한다 — Redis 는 거기서 띄운다. redis.keycloak-lab.svc:6379 로 떠 있다.
  • 브라우저가 필요하다. 쿠키가 HttpOnly 이고 OIDC(OpenID Connect, OAuth2 위에 신원 확인을 얹은 규격) 흐름을 끝까지 걸어야 세션이 생긴다. curl 로 완주하려던 시도는 실패했다.
  • 이 실험대에는 jqyamllint 도 없다.

이건 남의 도메인을 빌리고 남의 세션을 끊는 실험이다. 둘을 건드린다.

  1. app2.hyeonworks.com 은 평소 observability 네임스페이스의 Grafana 로 간다. 인증서가 auth · app1 · app2 세 이름만 덮고 있어서 네 번째 이름을 못 만든다. 그래서 Grafana 의 Ingress 를 잠시 내리고 빌리며 반드시 되돌린다.
  2. secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다. 실험대에서만 한다.

전 구간 약 20분이다. 되돌리기는 둘이고 먼저 읽어 둔다.

kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_A"}]'
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml

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

Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양

Grafana Ingress 백업을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다. 지금 app2 가 무엇인지 먼저 본다.

curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3

형태(모양은 observed) — Grafana 로 가고 있으면 302/login 을 가리킨다.

kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml

줄 수가 0 이 아니고 app2.hyeonworks.com1회 이상 잡혀야 한다. 0 이면 백업이 빈 파일이고, 그 상태로 진행하면 복구할 것이 없다.

kubectl -n observability delete ingress grafana

실측(observed) — 01-deploy.txt

=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
  grafana ingress 삭제
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

실측(observed) — 01-deploy.txt

secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out
kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide

실측(observed) — 01-deploy.txt

oauth2-proxy-c76b49c59-8p5hl   true   kc-lab-1
oauth2-proxy-c76b49c59-b9928   true   kc-lab-2

파드 두 개, 서로 다른 노드. 그리고 파드 이름의 끝 다섯 글자를 적어 둔다 — 관찰 절에서 「어느 replica 가 무엇을 했는지」를 이 글자로 가른다. replica 가 둘이라는 것이 Q1 의 질문 자체이고, 하나면 「공유」라는 말이 성립하지 않는다.

curl -s -o /dev/null -w '/     %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \
  https://app2.hyeonworks.com/
curl -s -o /dev/null -w '/ping %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \
  https://app2.hyeonworks.com/ping

실측(observed) — 01-deploy.txt

=== 진입점 확인 ===
  https://app2.hyeonworks.com/  HTTP 302
  /ping                         HTTP 200
경로 정상
/ 302 인증이 없으니 Keycloak 으로 보낸다 — 프록시가 일하고 있다
/ping 200 인증을 거치지 않는 헬스 경로 — 프록시 자체는 살아 있다

두 값이 갈라지는 것이 중요하다. /ping 도 안 되면 프록시가 안 떴고, /ping 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있다.

★ 원래 구성에서는 콜백이 계속 502 였다. 502 는 누가 냈는지를 안 알려 준다 — 앞단 nginx 인지, 그 뒤 Traefik 인지, 파드인지. 한 겹씩 벗겨서 좁힌다.

curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/

실측(observed)

curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping   →  200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/       →  302

Traefik 직접은 정상이다. 그러면 502 를 내는 것은 그 앞의 nginx 이고, 502 는 쿠키를 설정하는 응답에서만 났다. oauth2-proxy 는 기본적으로 세션 전체를 쿠키에 담는데Set-Cookie 가 nginx 의 proxy_buffer_size 를 넘겼다. B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다 — 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시 버퍼를 넘겨 502 가 됐다. 해결은 세션을 Redis 로 옮기는 것이고 매니페스트에 이미 들어 있다.

kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session

형태(모양은 observed)

"--session-store-type=redis"
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"

★ 그리고 여기서 조용한 실패를 하나 만난다. nginx 설정을 보려던 시도가 계속 빈 결과였다.

실측(observed)

$ sudo -n true
sudo: a password is required

test-server(호스트)의 sudo 는 비밀번호를 요구한다. 게스트(kc-lab-1/2)는 무암호라 A층에서 conntrack · tc 를 문제없이 썼는데 호스트는 다르다. 앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가 조용히 실패한 것이었다. 호스트에서 무언가가 빈 결과를 주면 먼저 sudo -n true 를 쳐 본다.

secret 은 키 이름만 본다.

kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
  -o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'

형태(모양은 observed)

"CLIENT_SECRET"
"COOKIE_SECRET_A"
"COOKIE_SECRET_B"

길이도 본다. 값은 절대 찍지 않는다. 가이드는 이 두 줄을 미검증으로 표시한다(unknown) — 원래 실행 기록에 이 명령의 출력이 없다.

kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
  -o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
  -o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c

oauth2-proxy 는 정확히 16·24·32 바이트만 받는다. 매니페스트의 값은 32바이트짜리이고, 다른 수가 나오면 프록시가 기동에서 죽는다. 회전 대상이 미리 두 개 준비되어 있고, 그것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.

브라우저에서 https://app2.hyeonworks.com/api/echo 를 열고 labuser / labpass 로 로그인한다. Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의 JSON 이 보인다.

실측(observed) — b7-oauth2proxy-login-success.png

"x-forwarded-email"              : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user"               : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto"              : [ "https" ]

B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더를 oauth2-proxy 가 붙인다. Forward-Auth 구조의 신원 전달 방식이고 B-4 의 결론이 그대로 적용된다 — edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.

세션이 Redis 에 들어갔는지는 먼저 통째로 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize

실측(observed) — 03-rotation.txt

=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
  dbsize: 2

dbsize 는 2 인데 세션은 하나다. b5:pvc 는 B-5 가 남긴 키이고 이 실험과 무관하다. dbsize 로 세션을 세면 틀린다 — 이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다. KEYS 대신 --scan 을 쓴다KEYS 는 Redis 를 블로킹한다. 실험대에서는 티가 안 나지만 습관을 여기서 들인다.

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

마지막으로 쿠키가 「티켓」인지 확인한다. 세션 저장소를 Redis 로 옮기면 쿠키에는 세션 전체가 아니라 티켓만 담긴다. 브라우저 개발자 도구 → Application/저장소 → Cookies → _oauth2_proxy 로 본다.

실측(observed) — 값은 옮기지 않는다. 지금 쓸 수 있는 세션 자격증명이라 모양과 길이만 적고 원문은 해설 문서에 둔다

_oauth2_proxy=<ticket>|<timestamp>|<mac>
                └─ Redis 키를 여기서 계산한다
   세션 전체가 아니라 티켓이다 (약 180자)

| 로 나뉜 세 토막과 전체 길이를 본다. 쿠키가 짧아졌고(그래서 502 가 사라졌고) Redis 키 이름은 이 티켓에서 계산된다. 관찰 절의 「지우지 못한다」가 여기서 결정된다.

주입

바꾸기 전에 「겹칠 수 있는가」부터 묻는다. B-6 의 무중단 회전이 여기서도 되는지가 Q1 의 질문 자체이기 때문이다.

kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
  /bin/oauth2-proxy --help 2>&1 | grep cookie-secret

실측(observed) — 03-rotation.txt

        --cookie-secret string                                the seed string for secure cookies (optionally base64 encoded)

string. 복수형이 아니다. --cookie-secrets--old-cookie-secret 도 목록에 없다. 겹침 구간을 만들 수단이 아예 없다. B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 kid 가 있어서였고, oauth2-proxy 의 쿠키에는 그런 식별자가 없다.

   식별자 있음  →  읽기는 여러 key, 쓰기는 하나  →  겹침 가능   (B-6)
   식별자 없음  →  전부 한 번에 바뀐다           →  겹침 불가   (B-7)

이 한 줄이 이 실험의 답이다. 나머지는 「그래서 실제로 무슨 일이 나는가」다.

patch 는 env/1 을 지목하는데 매니페스트의 순서에 달린 값이다. 그대로 믿지 말고 확인한다.

kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo

형태(모양은 observed)

OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET

OAUTH2_PROXY_COOKIE_SECRET몇 번째인가(0부터 센다). 위 형태에서는 두 번째이므로 env/1 이고, 순서가 다르면 patch 의 숫자를 고친다. 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.

date -u '+%H:%M:%S UTC 회전'
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_B"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

실측(observed) — 03-rotation.txt

=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out

시각을 UTC 로 적어 둔다. 프록시 로그가 UTC 이고 B-7a 의 정리 규칙이 이 시각을 기준으로 고아를 고른다.

주입 검증

kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo

실측(observed) — 03-rotation.txt

  현재 secret 키: COOKIE_SECRET_B

Deployment 의 참조가 바뀐 것이지 Secret 의 내용이 바뀐 것이 아니다. 두 값 다 그대로 있고 어느 쪽을 읽을지만 바뀌었다 — 그래서 되돌리기가 한 줄이다.

★ 그런데 Redis 는 그대로다.

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

실측(observed) — 03-rotation.txt

  Redis 세션은 그대로인가: 2 키

세션 수가 회전 전과 같다. 회전 자체는 아무 일도 일으키지 않는다. 여기서 「실험 실패」라고 결론 내리면 틀린다 — 무슨 일이 나려면 누군가 옛 쿠키를 들고 와야 한다. 이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이 B-7a 의 출발 상태다.

파드가 실제로 새로 떴는지도 본다.

kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide

파드 이름이 주입 전과 다르다. 같으면 patch 가 아무 필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다).

관찰

로그인했던 그 브라우저 그대로 https://app2.hyeonworks.com/api/echo 를 연다. 볼 것은 로그인 화면이 뜨는가이다.

실측(observed) — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.

Keycloak SSO 세션이 살아 있어서 조용히 재인증이 일어났다. 쿠키는 분명히 무효가 됐는데 사용자 눈에는 아무 일도 없었다. ★ 여기가 이 실험에서 가장 오해하기 쉬운 대목이다. 「로그인 화면이 안 떴으니 교체가 무중단이구나」로 읽으면 정확히 뒤집어 읽게 된다. 쿠키는 죽었고 사용자는 실제로 재인증을 거쳤다. SSO 가 그 사실을 가려 준 것뿐이고, IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.

로그가 무슨 일이 났는지 말한다. 먼저 최근 로그를 통째로 본다.

kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix

--prefix 는 각 줄 앞에 파드 이름을 붙여 준다 — replica 가 둘이므로 이게 없으면 누가 무엇을 했는지 못 가린다. 그 다음 좁힌다.

kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session

실측(observed) — 03-rotation.txt

[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>

두 줄이 다른 말을 하고 있다.

stored_session.go:94 쿠키를 열 수 없다 → 세션을 지우겠다
stored_session.go:97 그 지우기가 실패했다error decoding ticket to clear session

94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다. 이어지는 줄이 사용자 쪽 이야기다.

실측(observed)

[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...

Initiating loginAuthSuccess같은 초에 있다. 로그인 흐름이 실제로 돌았고 사람 손이 안 들어갔다. 그 두 줄 사이에 화면이 깜빡였다.

★ Redis 에 고아가 생긴다.

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

실측(observed) — 03-rotation.txt

=== Redis 세션 수 (옛 세션이 남아 있는가) ===
  _oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
  _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
  총: 2 개

키가 둘이다. 뒤엣것(b26111f…)은 회전 전의 세션이고 앞엣것은 방금 새로 생겼다. 사용자는 하나인데 서버 세션이 둘이다. 옛 것은 아무도 쓸 수 없고 프록시도 지우지 못한다.

못 지우는 까닭은 티켓과 키의 관계에 있다. Redis 세션 저장소를 쓰면 쿠키에는 티켓만 담기고, 티켓은 두 부분이다.

   티켓 = <세션 ID>.<암호화 키>
            │            └─ 값을 복호화할 키
            └─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>

티켓 전체가 cookie secret 으로 봉인되어 있다. secret 을 바꾸면 티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다. 프록시는 「이 세션은 못 쓴다」까지는 알지만 「그 세션이 Redis 어디에 있다」를 모른다. 그래서 removing session 을 시도하고 실패한다.

   secret 교체
     └─ 옛 티켓을 못 푼다
          ├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
          └─ ★ 서버 세션은 TTL 만료까지 고아로 남는다

로그인한 사용자 수만큼 고아가 생긴다. 여기서 이 실험은 멈췄고, 「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를 B-7a 가 이어서 잰다. 답은 「지울 수 있다」 이고, 「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.

덤으로, 로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.

kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
  | grep -E 'Initiating login|AuthSuccess'

실측(observed) — 해설 문서에 남은 형태

--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo"                                   ← 흐름을 시작한 replica

--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..."                  ← 콜백을 받은 replica

시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.

인가 요청(state, CSRF)을 어디에 두는가 replica 간
BFF 서버 메모리(HttpSession) 콜백이 다른 인스턴스로 가면 실패 (B-0)
oauth2-proxy 쿠키 (secret 으로 봉인) secret 만 같으면 성공

Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다 — 공유할 상태가 없고 공유할 것은 k8s Secret 하나뿐이다. 대신 그 하나가 단일 지점이 된다.

복구와 원상복구 확인표

secret 을 A 로 되돌린다. 이것도 회전이다 — B 로 만든 세션이 이번에는 고아가 된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.

date -u '+%H:%M:%S UTC 되돌림'
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_A"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

고아를 정리하는 선택지는 셋이다.

언제
그냥 둔다 실험대 TTL(1시간)이 지나면 사라진다
TTL 로 골라 지운다 산 세션을 살리고 싶을 때 B-7a 의 규칙
전부 지운다 어차피 다 무효일 때 아래

전부 지울 때는 b5:pvc 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다. FLUSHDB 를 쓰지 않는다 — 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인).

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
    kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
  done

★ Grafana Ingress 를 되돌리지 않으면 Grafana 가 안 열린다. oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가 컨트롤러 판단에 맡겨진다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
kubectl -n observability get ingress grafana
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3

Ingress 가 observability 에 다시 있고 app2 응답이 처음 본 모양으로 돌아왔는지 본다. oauth2-proxy 전체를 걷어내려면 kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml 인데, B-7a 와 C-1 이 이 배포를 그대로 쓴다. 이어서 할 생각이면 남겨 두고, 그때는 Grafana Ingress 복구도 그 실험이 끝난 뒤로 미룬다.

항목 명령 돌아왔을 때
secret 참조 get deploy oauth2-proxy -o jsonpath='{...env[1]...key}' COOKIE_SECRET_A
파드 kubectl -n keycloak-lab get pods -l app=oauth2-proxy 둘 다 1/1 Running
Redis redis-cli --scan --pattern '_oauth2_proxy-*' 남기기로 한 만큼만
Ingress (빌린 것) kubectl -n keycloak-lab get ingress oauth2-proxy 것이 없다 (걷어냈다면)
Ingress (Grafana) kubectl -n observability get ingress grafana 있다
curl -sI https://app2.hyeonworks.com/ | head -3 Grafana 로 간다

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
콜백이 502 Bad Gateway 쿠키가 크다. Set-Cookie 가 nginx 버퍼를 넘겼다 Traefik 직접이 200 인지. Redis 세션 저장소로 옮긴다
호스트에서 nginx 설정·로그가 빈 결과 sudo 가 조용히 실패했다 sudo -n truesudo: a password is required
--cookie-secrets 를 찾는데 없다 단수다. 겹침 구간이 애초에 없다 --help | grep cookie-secret
patch 뒤 프록시가 기동에서 죽는다 env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다 env[*].name 순서 확인
secret 을 바꿨는데 Redis 가 그대로 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 브라우저로 접근
로그인 화면이 안 떠서 「무중단」이라 읽었다 SSO 가 재인증을 가렸다. 쿠키는 죽었다 로그의 Initiating login + AuthSuccess
로그가 파드마다 섞여 못 읽겠다 replica 가 둘이다 logs -l app=oauth2-proxy --prefix
dbsize 로 세션을 셌더니 안 맞는다 b5:pvc 등 다른 키가 섞인다 --scan --pattern '_oauth2_proxy-*'
파드 IP 로 /oauth2/auth 를 쳤더니 HTTP 000 호스트에서 파드 IP 는 안 닿는다 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다
curl 로 OIDC 흐름을 완주하려다 실패 쿠키가 HttpOnly 이고 폼을 거쳐야 한다 브라우저를 쓴다
로그 시각이 9시간 어긋난다 프록시 로그는 UTC KST = UTC+9
app2 가 Grafana 로 간다 Ingress 를 안 만들었거나 이미 복구했다 kubectl -n keycloak-lab get ingress
실험이 끝났는데 Grafana 가 안 열린다 Ingress 복구를 안 했다 oauth2-proxy Ingress 를 먼저 지우고 백업을 올린다

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

  • (observed) Grafana Ingress 삭제 한 줄과 배포 출력 여섯 줄, 파드 두 개 (oauth2-proxy-c76b49c59-8p5hl @ kc-lab-1 · oauth2-proxy-c76b49c59-b9928 @ kc-lab-2), 진입점 / HTTP 302 · /ping HTTP 200, Traefik 직접의 200 · 302, sudo -n truesudo: a password is required, 로그인 뒤 upstream 이 받은 헤더 네 줄, 회전 전 Redis 의 b5:pvc · _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a · dbsize: 2, --cookie-secret string 도움말 한 줄, 교체 출력 두 줄과 현재 secret 키: COOKIE_SECRET_B, 교체 직후 Redis 세션은 그대로인가: 2 키, stored_session.go:94 · 97 두 줄과 Initiating login · AuthSuccess, 회전 뒤 Redis 의 키 둘과 총: 2 개, replica 를 갈라 본 로그.
  • 비밀은 이름과 길이만 적는다 — Secret 의 키 이름 셋(CLIENT_SECRET · COOKIE_SECRET_A · COOKIE_SECRET_B)과 16·24·32 바이트라는 제약만 옮겼고 값은 어디에도 안 적었다. 쿠키도 <ticket>|<timestamp>|<mac> 이라는 모양과 약 180자라는 길이만 옮긴다 — 지금 쓸 수 있는 세션 자격증명이라 원문은 해설 문서에 둔다. Redis 키 이름과 파드 이름은 식별자라 그대로 적었다.
  • (unknown) COOKIE_SECRET_A·B 의 길이를 재는 두 줄. 가이드가 미검증으로 표시했고 원래 실행 기록에 이 명령의 출력이 없다. 16·24·32 바이트라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다.
  • 예상이 빗나간 대목(observed) — 브라우저에서 로그인 화면이 안 떴다. 그것을 「무중단」으로 읽으면 뒤집어 읽은 것이고, 로그의 Initiating login + AuthSuccess 가 재인증이 실제로 돌았다는 값이다. IdP SSO 가 없거나 만료된 경우에 전원이 로그인 화면을 보는지는 재지 않았다(unknown).
  • 이 실험이 재지 않은 것 — 고아가 정말 사라지는지, 운영자가 지울 수 있는지, 어느 것이 고아인지는 여기서 재지 않고 B-7a 로 넘겼다. 502 를 고치는 다른 길(nginx 의 proxy_buffer_size 를 키우는 것)도 재지 않았다 — 세션을 Redis 로 옮기는 쪽만 쟀다.

C층 재현 절차 — 두 편을 직접 치는 순서

앞의 「C층 — SSO 와 로그아웃 전파」는 C-2 가 가른 네 줄을 적었다. C-1 이 무엇을 어떻게 쟀는지는 그 절 첫 문장 한 줄로만 들어와 있다. 여기부터는 그 두 편을 다시 만들려면 무엇을 어떤 순서로 치는가이고, C-1 쪽은 관측도 여기서 처음 적는다. 근거는 ../source/docs/guides/experiments/ 의 C층 두 편이고, 파일 하나가 아래 절 하나에 대응한다.

근거 파일 무엇을 가르나
C-1 다중 앱 SSO c1-multi-app-sso.md 716 IdP 세션을 죽여도 두 앱이 계속 열리는가
C-2 백채널 로그아웃 c2-backchannel-logout.md 734 로그아웃이 왜 다른 앱으로 안 퍼지는가

뼈대는 앞의 두 층과 같다. 기준선주입주입 검증관찰복구 이고, 아래 절들도 그 순서로 적는다. 주입 검증 이 C층에서는 한 겹 더 앞으로 온다 — A·B층에서는 「주입이 걸렸는가」였는데, C-2 는 주입할 대상이 있는가에서 한 번 헛돌았다. 로그아웃을 걸기 전 keycloak-patterns 세션이 이미 0 이었고, 그래서 「앱 세션이 안 지워졌다」가 나왔는데 그것은 끊을 것이 없었다는 뜻이었다. 주입은 정상으로 돌았고 출력도 그럴듯했고 결론도 원하던 방향이었다. 틀린 것은 전제뿐이다.

가이드가 출력에 붙인 표시는 앞의 두 층과 같은 셋이고, 뜻은 각 편의 표시 규약 표에 있다.

가이드의 표시 가이드가 적은 뜻 이 문서에서
실측 수집 기록의 출력 원문. 증거 파일에 그대로 있다 (observed)
형태 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 모양은 (observed), 값은 환경마다 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 (unknown)

어느 기계에서 치는가가 앞의 두 층과 똑같이 어긋난다. 두 편 다 전제가 「명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다」인데, 같은 폴더의 README.md 는 반대로 적는다. 아래 절들은 README 를 따른다 — sudo 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 본다. 반입한 C층 두 편도 본문 명령 블록에 sudo kubectl 을 한 번도 쓰지 않는다(observed).

C층이 앞의 두 층과 다른 것이 셋 있다.

무엇 A·B층 C층
앱이 몇 개 필요한가 하나 둘. B-2 의 app1(BFF)과 B-7 의 app2(oauth2-proxy)가 둘 다 떠 있어야 성립한다
이름 자기 것만 쓴다 app2.hyeonworks.comGrafana 에서 빌린 이름이라 끝나면 Ingress 를 돌려준다
브라우저 B-3 을 뺀 셋에 필요하다 두 편 다 필요하다. SSO 는 브라우저 쿠키가 만드는 현상이라 curl 로는 「로그인 화면이 안 떴다」를 못 본다

jq 가 이 실험대에 없는 것은 앞의 두 층과 같다. C층에서는 대신 세션을 세는 SQL 이 길어진다. offline_user_session 에는 모든 realm 의 세션이 들어 있어서 realm 을 조인해야 하고, 그 조인 쿼리를 가이드가 전부 미검증으로 표시했다 — 원래 실행은 스크립트로 돌렸고 증거에 SQL 원문이 없다. 아래에서도 두 형태를 나란히 적는다.

두 편 다 파괴적으로 시작한다. Keycloak 세션 테이블을 직접 지우고 Redis 를 flushall 로 비우고 StatefulSet 을 재시작한다. 그 순간 로그인해 있던 사람이 전부 끊긴다. 실험대에서만 한다.

아래 두 절은 절차를 옮긴 것이다. C-2 가 무엇을 발견했는지는 이 문서 앞쪽에 있고, 여기 실린 명령과 출력은 전부 가이드 원문에서 왔다. 가이드에 없는 명령은 넣지 않았고, 가이드가 규범을 어긴 곳은 두 형태를 나란히 적었다.

버전 문자열은 두 편 중 한 편에만 있다(observed). C-2 가 도달성을 재려고 띄운 임시 파드의 curlimages/curl:8.11.1 뿐이고, C-1 의 출력에는 판 번호가 한 번도 안 찍혔다. C층은 B층 위에서 이어 돌았으므로 판을 물을 때는 같은 실험대의 B층 출력을 본다 — 그 편이 직접 잰 값이 아니라는 뜻이다 (inferred).

C-1 — IdP 세션을 죽여도 두 앱이 계속 열리는가

근거: c1-multi-app-sso.md (716줄). 실행 기록은 2026-09-04 14:4414:48 KST(observed).

이 실험이 가르는 것

원래 질문은 한 줄이었다. 가이드는 그 문장을 그대로 인용해 시작한다.

"SSO 를 추가하게 되면 어떻게 달라지는지"

「달라진다」에는 방향이 둘 섞여 있다 — 편해지는 쪽과 위험해지는 쪽. 위험 쪽의 통념을 가이드는 이렇게 적는다.

예측
통념 SSO 를 붙이면 IdP 가 단일 장애점이 된다. IdP 가 죽으면 다 죽는다
실측 절반만 맞다. 로그인 경로는 그렇고, 이미 로그인한 사용자는 아니다

둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 갈린다. 그것이 이 실험이다.

핵심은 수명이 세 층으로 나뉘어 있다는 데 있다.

   ① IdP 세션 (Keycloak)           ssoSessionIdleTimeout
   ② 앱 세션  (BFF / oauth2-proxy)  각자 30분 / 1시간
   ③ access token                  60초

   ①을 지워도 ②는 자기 수명을 산다

로그아웃이 지우는 것은 ① 뿐이다. 이 실험대에는 세션을 서로 완전히 다르게 다루는 앱이 둘 있어서, ②가 어떻게 살아남는지를 두 형태로 동시에 볼 수 있다.

   app1.hyeonworks.com  →  BFF          서버 세션 (Redis)  +  토큰 (PostgreSQL)
   app2.hyeonworks.com  →  oauth2-proxy 쿠키 티켓         +  세션 (Redis)

                    둘 다 realm keycloak-patterns

가이드는 이것을 우연히 좋은 실험대라고 적는다. B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이 같은 IdP 를 쓰면서 세션을 정반대로 다룬다.

가이드의 「이 가이드가 끝나면」 표는 여섯을 적는다 — 두 번째 앱이 로그인 화면 없이 열리는 것, user session 1client session 2 가 매달린 구조, 구조가 다른 두 앱이 같은 user session 을 공유하는 것, IdP 세션을 죽여도 두 앱이 열리는 것, logout-all 이 오류 없이 아무것도 안 하는 것, realm 을 안 보고 세면 master 의 admin 세션에 속는 것.

전제와 되돌리기

  • B-2 의 app1(BFF) 과 B-7 의 app2(oauth2-proxy)둘 다 떠 있다. 없으면 SSO 가 아니라 로그인 한 번이다.
  • app2.hyeonworks.comGrafana 에서 빌린 이름이다(B-7 의 주의). 이 실험이 끝나면 Ingress 를 돌려준다.
  • 브라우저가 필요하다. curl 로는 「로그인 화면이 안 떴다」를 볼 수 없다.
  • Keycloak 이미지에는 curlwget 도 없다(exit 127). kcadm.sh 는 파드 안에 있으므로 항상 kubectl exec 로 감싼다.
  • jq 는 이 실험대에 깔려 있지 않다.

★ 이 실험은 세션을 전부 지우고 시작한다. 비교할 상태를 만들려고 Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고, Keycloak StatefulSet 을 재시작한다. 그 순간 지금 로그인해 있는 모든 사람이 끊긴다.

전 구간 약 20분이고 Keycloak 재시작에 1~2분이 든다. 되돌리기는 둘이고, 둘 다 먼저 읽어 둔다. 하나는 세션을 다시 깨끗하게 만드는 것이라 주입 전 절차와 같은 명령이다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

다른 하나는 빌린 이름을 돌려주는 것이다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml

★ C-2 를 이어서 할 생각이면 아직 돌려주지 않는다. C-2 가 두 앱을 그대로 쓴다.

★ 지운 세션은 안 돌아온다. 이 실험의 파괴에는 되돌리기가 없고, 다시 로그인하는 것이 복구다.

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

넓은 것부터 좁혀 간다. 이 층에서는 그 경로가 한 번 꺾인다 — 세션을 지우려다 안 지워지는 것을 먼저 보고, 그 다음에 세는 법을 고친다.

앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get ingress

bffoauth2-proxy둘 다 Running 이고 Ingress 에 app1.hyeonworks.comapp2.hyeonworks.com둘 다 있어야 한다.

curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
curl -s -o /dev/null -w 'app2 %{http_code}\n' https://app2.hyeonworks.com/

실측(observed) — 01-baseline.txt

  app1 HTTP 200 / app2 HTTP 200

app2 가 Grafana 로 가면 B-7 의 Ingress 가 없는 것이다. 그쪽 백업과 적용을 먼저 한다.

kcadm 을 로그인시킨다. 파드가 재시작되면 이 세션이 사라지고 이후 모든 명령이 401 이 된다. 아래에서 실제로 재시작하므로 그때 다시 친다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이를 확인하는 명령은 B-0 절에 있다.

가장 자연스러운 방법부터 친다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create realms/keycloak-patterns/logout-all

★ 2026-09-17 에 이 절을 그대로 쳤다(observed). logout-all 을 친 뒤에도 세션이 그대로 6 건이었다 — 이 방법으로는 안 지워진다.

logout-all 뒤   select count(*) from offline_user_session where offline_flag='0'  →  6

delete from offline_client_session   DELETE 1633
delete from offline_user_session     DELETE 6
redis-cli flushall                   OK
rollout restart 뒤 두 저장소         0 · Redis 스캔도 빈 출력

자식 쪽이 훨씬 많다. 부모부터 지우려 했다면 외래키에 걸렸을 것이고, 그래서 순서가 정해져 있다. 그리고 롤아웃 직후 kcadmNo server specified 로 끝났다 — 세션 파일이 파드와 함께 사라지는 것을 이 문서가 미리 적어 둔 그대로다(observed).

세션이 지워졌는지 센다.

이 실험대는 이렇게 했다(observed) — 스크립트로 돌렸고 증거에 이 SQL 의 원문이 없다. 따라 하는 사람은 가이드가 손으로 치기 좋게 고친 아래 형태를 친다. 가이드가 미검증으로 표시한 줄이다(unknown).

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

실측(observed) — 01-baseline.txt

=== 깨끗한 상태로 초기화 ===
DELETE 1

=== 기준선 ===
  Keycloak 온라인 세션: 4
  Redis 키: 0

4 다. 0 이 아니다. logout-all 이 안 먹었고 오류도 안 났다. 세션이 그대로 넷 있다. 캐시 때문이다 — A-1 에서 확인했듯 Keycloak 은 세션을 DB 에서 읽되 캐시로 답한다. 관리 API 가 무효화를 걸어도 각 노드의 캐시가 안 바뀌면 세션은 살아 있는 것처럼 보인다.

★ 해설 문서의 이 값은 한 번 정정됐다. 처음에는 0 으로 인쇄됐는데 증거 01-baseline.txt4 이고, 0 은 그 다음 단계(DB 직접 삭제 + 재시작)의 값이었다. 가이드는 증거를 따른다 — 여기서 4 가 나오는 것이 정상이다.

그래서 DB 를 직접 지운다. 자식 테이블부터다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_client_session" -c "delete from offline_user_session"

형태(모양은 observed)

DELETE 2
DELETE 4

앱 세션도 비운다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall

flushall 은 이 Redis 전체를 지운다. BFF 세션·oauth2-proxy 세션·B-5 가 남긴 b5:pvc 까지 전부다. 깨끗한 상태를 만드는 단계라서 의도한 것이고, 실험 도중에는 절대 쓰지 않는다고 가이드는 적는다(B-7a 참고).

캐시를 비우려면 프로세스를 새로 띄우는 수밖에 없다.

kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session where offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 01-baseline.txt

  Redis 키: 0

Redis 키 0, 세션 수 0 이어야 한다. 여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다. ★ 그리고 kcadm 세션이 날아갔다. 위의 config credentials 를 다시 친다.

★ 세는 법을 여기서 고친다. 이 단계를 건너뛰면 관찰 절의 결론을 반대로 읽는다. offline_user_session 에는 모든 realm 의 세션이 들어 있고, kcadm 을 쓰는 순간 master realm 에 admin 세션이 생긴다. 그냥 세면 내가 만든 잡음을 남의 세션으로 읽는다.

이 실험대는 이렇게 했다(observed) — 전체를 세는 쪽이 틀린 방법이다.

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

따라 하는 사람은 realm 을 조인한다. 가이드가 미검증으로 표시한 형태다(unknown).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0'"

realm 열을 본다. keycloak-patterns 만이 이 실험의 대상이고 master 는 방금 kcadm 을 쳐서 생긴 것이다. 이 한 열 때문에 원래 실행은 「안 지워졌다」로 오독할 뻔했다. 여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다 — 「세션 1개」가 아니라 「keycloak-patterns 세션 0개, master 1개」다.

주입

주입은 브라우저로 한다. 두 앱에 차례로 들어가는 것이 SSO 상태를 만드는 방법이다. 파괴적인 조작은 관찰 절에 있고, 여기까지는 초기화를 다시 하면 되돌아온다.

첫째는 app1 로그인이다. 브라우저에서 https://app1.hyeonworks.com/ 을 열고 B-0 에서 만든 계정 labuser 로 로그인한다. 가이드는 이 줄에 계정 이름과 비밀번호를 나란히 적지만 여기에는 이름만 옮긴다 — 그 비밀번호는 B-0 의 set-password 로 따라 하는 사람이 정하는 값이다.

Keycloak 로그인 화면이 뜨는가를 본다. 주소창이 이렇게 바뀐다.

실측(observed) — 해설 문서에 남은 형태

https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
  ?client_id=bff-confidential&...
→ Sign in to keycloak-patterns

첫 앱에서는 당연히 로그인 화면이 나온다. 이것이 둘째 단계의 대조군이고, 이걸 안 보면 「app2 에서 안 뜬 것」이 특별한 일인지 알 수 없다.

로그인 직후 상태를 잰다. 위에서 고친 조인 쿼리에 realm 조건을 붙인 형태이고, 같은 이유로 미검증이다(unknown).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0' and r.name='keycloak-patterns'"

실측(observed) — 02-after-app1-login.txt

=== app1 로그인 직후 Keycloak 세션 ===
     user_session_id      | client_sessions 
--------------------------+-----------------
 oqOjHekin4JU-BZjgQLjUByW |               1
(1 row)

user_session_id 를 적어 둔다. 뒤에서 계속 쓴다. 그리고 client_sessions1 이다.

★ 2026-09-17 에 이 구간을 처음 밟았고 셋이 드러났다(observed).

첫째, 이 질의는 미검증이 아니라 돈다. 다만 출력의 칸 이름이 질의와 안 맞는다 — 질의는 as clients 인데 실린 출력의 머리는 client_sessions 다. client_sessions 는 B-3 이 쓰는 다른 질의의 별칭이고 그쪽 출력이 여기로 섞여 들어왔다. C-1 의 질의를 그대로 치면 머리는 clients 로 나온다.

둘째, 행이 하나만 나오지 않는다. direct grant 로 토큰을 여러 번 받아 본 실험대에서는 그만큼 user session 이 쌓인다. 16행이 나왔고 그중 clients2 인 한 줄이 SSO 세션이다. 「한 줄이 나온다」를 통과 조건으로 삼으면 안 된다.

 4i6Q7qb3Y_7Ib4mQzzsmTkzQ | keycloak-patterns |       1
 V0mkutuu-0tNdmvHBfWucB9j | keycloak-patterns |       2
(16 rows)

셋째, SSO 자체는 문서 그대로다. app1 에 로그인한 쿠키로 app2 를 열자 로그인 화면이 다시 뜨지 않았고(login-actions/authenticate 0건) 곧바로 업스트림 응답이 왔다. client-session-statsbff-confidentialoauth2-proxy 가 나란히 잡히고, 저장소 쪽도 _oauth2_proxy-*bff:session:sessions:* 가 각각 하나씩 늘었다.

다만 이 편도 oauth2-proxy 클라이언트가 없으면 로그인 화면에서 Client not found. 로 끝난다 — 만드는 한 줄은 B-7 절에 적어 두었다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed)

  Redis 키: 1
    bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
  PostgreSQL authorized client: 1 행

Redis 키 이름의 접두사 bff:session:sessions: 를 본다. 「BFF 가 만든 세션」이라는 뜻이고, 뒤에서 프록시 것과 갈라진다. authorized client 는 BFF 가 토큰을 넣어 둔 PostgreSQL 행이고, 그 수를 세는 줄도 미검증이다(unknown).

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

한 번 로그인했는데 상태가 세 곳에 생겼다 — Keycloak 세션 · Redis 세션 · PostgreSQL 토큰. 관찰 절에서 이 셋의 운명이 갈린다.

둘째가 app2 방문이고, 여기가 SSO 다. 같은 브라우저의 새 탭에서 https://app2.hyeonworks.com/api/echo 를 연다.

실측(observed) — c1-sso-app2-no-login-screen.png. 로그인 화면이 뜨지 않았다.

app2 는 Keycloak 으로 리다이렉트했지만 Keycloak 에 이미 세션이 있어서 묻지 않고 바로 돌려보냈다. 다른 브라우저나 시크릿 창에서 열면 안 된다 — SSO 를 만드는 것은 auth.hyeonworks.com 에 붙은 브라우저 쿠키이고, 창이 다르면 쿠키가 없어 로그인 화면이 뜨는 것이 정상이다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다. 여기서는 「두 번째 로그인」이 아니라 「같은 로그인에 앱이 하나 붙은 것」인지를 가른다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0' and r.name='keycloak-patterns'"

실측(observed) — 03-after-app2-visit.txt

=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
     user_session_id      | client_sessions 
--------------------------+-----------------
 oqOjHekin4JU-BZjgQLjUByW |               2
(1 row)

user_session_id 가 앞과 같고 client_sessions 만 1 → 2 로 늘었다. 이것이 SSO 의 데이터 구조다.

   user session  (사용자 · 브라우저 하나당 하나)
     ├─ client session : bff-confidential
     └─ client session : oauth2-proxy

어느 클라이언트가 붙었는지는 조인해야 나온다. 가이드는 이 줄도 미검증으로 표시한다 — 증거에 SQL 원문이 없고 출력만 있다(unknown).

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select cs.client_id, c.client_id as name
     from offline_client_session cs join client c on c.id = cs.client_id
    where cs.user_session_id = 'oqOjHekin4JU-BZjgQLjUByW'"

실측(observed) — 03-after-app2-visit.txt

=== 어느 클라이언트가 붙었는가 ===
              client_id               |       name       
--------------------------------------+------------------
 9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
 80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
(2 rows)

client_id 열은 UUID 이고 사람이 아는 이름은 client 테이블에 있다. 조인 없이 보면 UUID 두 개만 나와서 어느 앱인지 알 수 없다. 구조가 완전히 다른 두 앱이 같은 user session 아래에 나란히 있고, Keycloak 은 앱이 세션을 어떻게 다루는지 모른다.

저장소 쪽도 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 03-after-app2-visit.txt

=== 저장소 상태 ===
  Redis 키:
    _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
    bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
  PostgreSQL authorized client: 1 행

같은 Redis 에 접두사가 다른 두 세션이 있다. bff:session:sessions: 는 Spring Session 이 쓰는 이름이고 _oauth2_proxy- 는 프록시가 쓰는 이름이다. 「세션 저장소를 공유한다」는 말이 「같은 Redis 를 쓴다」일 뿐 「같은 세션을 본다」가 아니다. 둘은 서로의 키를 모른다. B-7a 에서 FLUSHDB 를 금지한 이유가 여기 있다.

왜 두 층으로 나뉘어 있는가. Keycloak 은 세션을 user session(사람 하나)과 client session(그 사람이 쓰는 앱 하나)으로 나눠 둔다. A층·B층에서 본 두 사건이 서로 다른 층을 건드렸다.

무엇이 사라졌나 결과
A-3 DB 크래시 user_session 행이 통째로 모든 앱이 끊긴다
B-3 refresh 재사용 탐지 client_session 그 앱만 끊긴다

두 층이 나뉘어 있는 까닭이 SSO 다. 앱 하나의 사고가 다른 앱으로 번지지 않게 하려면 client session 이 따로 있어야 한다. 한 층뿐이었다면 B-3 의 재사용 탐지 한 번이 모든 앱을 끊었을 것이다.

관찰

지우려는 것은 ①(IdP 세션)뿐이다. ②(앱 세션)와 ③(토큰)은 손대지 않는다. 그 구분이 이 실험의 전부다. 되돌리기는 다시 로그인하는 것이라 파괴적이지만 회복은 쉽다.

지우는 방법을 고르는 데서 두 개가 걸러진다. 첫째는 세션 id 를 지목하는 것이고 안 먹는다. 가이드가 미검증으로 표시했다(unknown).

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns

오류도 안 나고 세션도 안 줄어든다. 앞의 logout-all 과 같은 유형이다.

둘째는 사용자 단위로 끊는 것이고 이건 먹는다.

USERID=$(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)
echo "$USERID"

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create "users/$USERID/logout" -r keycloak-patterns

echo "$USERID"UUID 한 줄인지 본다. 비어 있거나 여러 줄이면 --format csv --noquotes | tail -1 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다. 가이드는 자리표시자를 두지 않으려고 두 단계로 나눴다고 적는다 — 한 줄로 이어 붙이면 $USERID 가 비었을 때 그 사실이 안 보인다.

IdP 쪽이 끊겼는지는 realm 을 보고 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0'"

실측(observed) — 04-sso-session-killed.txt

=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
  남은 Keycloak 세션: 1

=== 남은 세션의 realm 과 client ===
     user_session_id      | realm  | clients 
--------------------------+--------+---------
 E1q5xI7tt4U_WhZpW7rEPIF2 | master |       1
(1 row)

「남은 세션 1」과 「그 1의 realm 이 master」를 같이 본다. keycloak-patterns 세션은 0 이고, 남은 하나는 kcadm 을 쳐서 생긴 admin 세션이다. ★ 이 실험에서 가장 잘 틀리는 곳이 여기다. 「1이 남았네, 로그아웃이 안 먹었구나」로 읽으면 결론이 통째로 뒤집힌다. 숫자 옆에 realm 을 안 붙이면 그 숫자는 아무 뜻이 없다.

앱 세션은 어떻게 됐는지 주입 검증에서 친 것과 똑같은 명령으로 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 04-sso-session-killed.txt

=== 두 앱의 애플리케이션 세션은 그대로인가 ===
  _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
  bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
  PostgreSQL authorized client: 1 행

  → IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다

키 이름이 앞과 글자 하나까지 같다. 아무것도 안 지워졌다. 로그아웃은 ①만 지웠고 ②도 ③도 아무도 안 건드렸다.

브라우저에서 두 앱을 다시 연다 — 아까 그 브라우저에서 https://app1.hyeonworks.com/https://app2.hyeonworks.com/api/echo 다.

실측(observed) — c1-apps-alive-after-idp-logout.png. 둘 다 로그인 화면 없이 그대로 열렸다.

★ 증거의 정직성에 관한 주의를 가이드가 붙여 두었다. 이 스크린샷과 app2 첫 방문 때의 스크린샷은 바이트 단위로 동일한 파일이다(md5 2c703176…). 두 시점의 화면이 실제로 같은 내용이었기 때문이고 조작은 아니지만, 그래서 두 시점을 구별하는 증거가 되지 못한다. 구별은 03-after-app2-visit.txt04-sso-session-killed.txt 의 터미널 출력이 한다 — client_sessions 1→2, 그리고 IdP 세션 삭제 뒤에도 Redis 키가 남은 것. 화면이 같아 보인다는 것 자체가 이 실험의 결론이라 화면만으로는 증명이 안 된다.

왜 그런지는 앞에 적은 세 수명으로 돌아간다.

   ① IdP 세션 (Keycloak)          ssoSessionIdleTimeout 1800초
   ② 앱 세션  (BFF / oauth2-proxy) 각자 30분 / 1시간
   ③ access token                 60초

   ①을 지워도 ②는 자기 수명을 산다

앱은 매 요청마다 IdP 에 물어보지 않는다. 자기 세션이 살아 있으면 그걸로 답하고, 그래서 ①이 사라진 것을 모른다. 그러면 언제 알게 되는가.

언제 끊기는가
BFF access token 이 만료되어 refresh 를 시도할 때Session not active
oauth2-proxy 쿠키 만료(1시간) 또는 토큰 갱신을 시도할 때

즉시가 아니라 지연되어 끊긴다. 최대 지연은 access token 수명(60초)이 아니라 앱이 다음에 IdP 를 부를 때까지다. B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이고 — 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐다 — 두 방향 모두 「한쪽만 지우면 다른 쪽이 안 지워진다」다.

실제로 끊기는 순간을 보려면 수명 두 값을 읽고 기다린다. 가이드는 이 줄을 미검증으로 표시했고 기다려서 확인하지도 않았다(unknown).

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout

그래서 SSO 의 대가가 무엇인지가 원래 질문의 답이 된다.

앱이 하나일 때 SSO 일 때
로그인 앱마다 한 번
IdP 가 죽으면 그 앱만 로그인 불가 모든 앱이 로그인 불가
이미 로그인한 사용자 ★ 영향 없다 (앱 세션이 살아 있으므로)
로그아웃 그 앱만 전 앱을 끊으려면 백채널 로그아웃이 필요
세션 수명 하나 세 층이 각자 — 어긋나면 예측이 어렵다

IdP 는 「로그인 경로」의 단일 장애점이지 「이미 로그인한 사용자」의 단일 장애점이 아니다. A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.

   Keycloak DB 죽음  →  새 로그인 불가 (전 앱)
                     →  이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
                     →  그 뒤 갱신 시점에 한꺼번에 끊긴다

장애가 즉시 전면화되지 않고 앱 세션 수명만큼 지연되어 몰려온다. 그리고 마지막 줄이 다음 실험을 부른다 — 전 앱을 끊으려면 백채널 로그아웃이 필요하고, 그게 되는지는 C-2 가 잰다.

복구와 원상복구 확인표

세션 정리는 주입 전 절차와 같다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

그냥 둬도 된다. 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고 IdP 세션은 이미 없다. 정리는 다음 실험을 깨끗하게 시작하려는 것뿐이다.

브라우저 쿠키도 지운다 — auth.hyeonworks.com·app1·app2 의 쿠키를 지우거나 시크릿 창을 새로 연다. 서버 세션을 다 지워도 브라우저에 낡은 쿠키가 남고, 다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 그것이다.

빌린 이름을 돌려준다. C-2 를 이어서 하지 않을 때만이다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
항목 명령 돌아왔을 때
Keycloak 세션 realm 조인 쿼리 keycloak-patterns 0 (master 는 있을 수 있다)
앱 세션 redis-cli --scan --pattern '*' 비었거나 남기기로 한 것만
토큰 select count(*) from oauth2_authorized_client 0
파드 kubectl -n keycloak-lab get pods 전부 Running, keycloak 둘 다 1/1
Ingress kubectl -n observability get ingress grafana 있다 (돌려줬다면)
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ 200

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
logout-all 이 오류 없이 아무 일도 안 한다 캐시. DB 를 지워도 노드 캐시가 답한다 DB 직접 삭제 + rollout restart
kcadm delete sessions/<id> 가 조용히 안 먹는다 같은 유형 users/<id>/logout 을 쓴다
기준 세션이 0 이 아니라 4 다 원래 실행도 4 였다. 해설의 0 은 정정됐다 위의 정정 문단
로그아웃했는데 세션이 1 남았다 master 의 admin 세션이다. kcadm 을 쳐서 생겼다 realm 을 조인한다
kcadm 이 전부 401 재시작으로 kcadm 세션이 날아갔다 config credentials 를 다시
$USERID 가 비었다 --format csv --noquotes 출력이 예상과 다르다 echo "$USERID" 로 먼저 확인
app2 에서 로그인 화면이 뜬다 다른 브라우저·시크릿 창이다. SSO 쿠키가 없다 같은 창의 새 탭에서 연다
app2 가 Grafana 로 간다 B-7 의 Ingress 가 없다 B-7 의 백업·적용을 먼저
client_id 가 UUID 뿐이라 어느 앱인지 모른다 client 테이블을 조인해야 이름이 나온다 주입 검증의 조인 쿼리
Redis 를 비웠더니 app1 도 끊겼다 flushall 은 BFF 세션도 지운다 깨끗한 상태를 만들 때만 쓴다
스크린샷 두 장이 똑같다 실제로 같은 파일이다. 조작이 아니다 구별은 터미널 출력이 한다
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 밖에서 치거나 임시 curl 파드

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

  • (observed) 앱 둘의 app1 HTTP 200 / app2 HTTP 200, logout-all 뒤의 「Keycloak 온라인 세션: 4 · Redis 키: 0」, DB 삭제와 재시작 뒤의 「Redis 키: 0」, app1 로그인 직후의 user_session_idclient_sessions 1, app2 방문 뒤의 같은 id 와 client_sessions 2, 클라이언트 UUID 둘과 이름, Redis 키 두 줄, 사용자 단위 로그아웃 뒤의 「남은 세션 1」과 그 세션의 realm master, 그 뒤에도 Redis 키 두 줄이 글자 하나까지 같은 것.
  • (observed) 브라우저 화면 둘 — app2 가 로그인 화면 없이 열린 것과 IdP 세션을 지운 뒤에도 두 앱이 열린 것. 두 파일은 md5 2c703176… 로 동일하다. 그래서 두 시점을 구별하는 증거로는 못 쓰고, 구별은 터미널 출력이 한다.
  • (unknown) realm 을 조인해 세션을 세는 쿼리, 클라이언트 이름을 조인하는 쿼리, oauth2_authorized_client 를 세는 줄, kcadm delete sessions/<id>, 수명 두 값을 읽는 get realms … --fields. 가이드가 전부 미검증으로 표시했다 — 원래 실행은 스크립트로 돌렸고 증거에 SQL 원문이 없다.
  • 비밀은 옮기지 않았다 — 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 labuser 만 옮겼고 비밀번호는 안 옮겼다. 세션 id·Redis 키 이름·클라이언트 UUID 는 식별자라 그대로 적었다.
  • 이 실험이 재지 않은 것 — 「언제 끊기는가」를 실제로 기다려서 확인하지 않았다. IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고 app1 을 새로고침하면 Session not active 가 나와야 한다는 것은 추론이고, 재려면 그렇게 한다고 가이드는 적는다.

C-2 — 로그아웃이 왜 다른 앱으로 안 퍼지는가

근거: c2-backchannel-logout.md (734줄). 실행 기록은 2026-09-04 14:5014:53 KST(observed).

이 실험이 가르는 것

C-1 이 관측한 것에서 출발한다.

   IdP 세션을 죽였다  →  앱 세션은 그대로  →  두 앱이 계속 열린다

왜 안 퍼졌는지는 안 물었다. 후보가 셋 있고, 각각 판정하는 방법이 다르다.

후보 판정하는 법
① IdP 에 보낼 주소가 설정되어 있지 않다 클라이언트 속성을 본다
② 앱에 받을 엔드포인트가 없다 소스와 실제 경로를 본다
③ IdP 가 앱에 못 닿는다 (네트워크) 클러스터 안에서 앱 URL 을 쳐 본다
예상 셋 중 하나가 원인일 것
실측 ①과 ②가 둘 다 없었다. ③은 문제가 아니었다(HTTP 200)

원인은 단순했다 — 아무도 구현하지 않았다. 그리고 이 실험이 실제로 증명하는 것은 그 다음이다.

   ①만 고친다  →  여전히 안 퍼진다

양쪽이 다 있어야 동작한다. 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는 것이 이 주제에서 가장 흔한 실패이고, 이 가이드는 그 실패를 일부러 재현한다.

★ 출처 하나에 주의가 붙어 있다. 해설 문서 2절이 인쇄한 「설정이 들어갔다」 확인 출력은 02-configure-idp.txt 에서 나온 것이 아니다. 그 파일에는 command terminated with exit code 1 이 남아 있다 — 점 표기로 시도한 실패한 첫 시도다. 성공 출력은 그 뒤 별도로 실행한 조회에서 나왔다. 실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은 잘못이었고, 아래 주입 검증 절에서 그 둘을 갈라 적는다.

전제와 되돌리기

  • C-1 이 끝나 있다. app1(BFF)· app2(oauth2-proxy)가 둘 다 살아 있고 IdP 로그아웃이 앱에 전파되지 않는다를 이미 관측했다. 이 실험은 그 원인을 찾는 실험이다.
  • app2.hyeonworks.comGrafana 에서 빌린 이름이다. 끝나면 되돌린다.
  • BFF 소스 트리(bff/src/main/java/)를 볼 수 있어야 한다.
  • 브라우저가 필요하다. 살아 있는 세션을 만들어야 시험이 성립한다.
  • Keycloak 이미지에는 curlwget 도 없다(exit 127). 도달성 시험은 임시 curl 파드로 한다.
  • jq 는 이 실험대에 깔려 있지 않다.

★ 이 실험은 클라이언트 설정을 바꾼다. bff-confidential 클라이언트의 attributes 를 통째로 교체한다. JSON 으로 주는 방식이라 기존 속성이 같이 날아갈 수 있다. 그래서 주입 절의 첫 명령이 백업이다. 세션도 지운다. 전 구간 약 20분.

되돌리기는 백업한 값으로 다시 update 하는 것이고, 백업이 { } 처럼 비어 있었다면 빈 객체로 되돌린다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update "clients/$CID" -r keycloak-patterns -s 'attributes={}'

그대로 둬도 무방하다고 가이드는 적는다. 받을 엔드포인트가 없으므로 이 설정 하나로는 아무 일도 안 일어나고, 그것이 이 실험의 결론이었다. 다만 나중에 앱을 고쳤을 때 왜 갑자기 동작하는지 모르게 되므로, 실험이 남긴 설정이라는 것을 기억하거나 지운다.

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

넓은 것부터 좁혀 간다. 마지막 한 칸이 이 편에서 새로 붙은 것이다.

IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가

kcadm 을 먼저 로그인시킨다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

IdP 쪽부터다. 두 클라이언트를 각각 본다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes

실측(observed) — 01-current-state.txt

=== 현재 클라이언트의 백채널 로그아웃 설정 ===
--- bff-confidential ---
    "frontchannelLogout" : false,
--- oauth2-proxy ---
    "frontchannelLogout" : false,

있는 것이 아니라 없는 것을 본다. backchannel.logout.url 이 목록에 없다. 나온 것은 frontchannelLogout 뿐이다.

「없다」를 확인하는 방법이 따로 있다. grep backchannel 로 걸러서 빈 출력을 보면 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다 — B-6 에서 kcadm get components -q type=… 이 정확히 그렇게 조용히 실패했다. --fields attributes 로 통째로 받아 눈으로 훑고, 다른 값(frontchannelLogout)이 보이는 것을 「명령은 먹었다」의 증거로 쓴다.

앱 쪽은 소스를 본다.

grep -rn "oidcLogout\|backchannel" bff/src/main/java/

실측(observed) — 01-current-state.txt

=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===

아무것도 안 나온다. 헤더 아래가 비어 있다. Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 명시적으로 켜야 한다.

.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))

이 설정이 없으면 /logout/connect/back-channel/{registrationId} 경로가 생기지 않는다. 소스에 없으니 경로도 없다. grep 이 빈 출력을 줄 때는 경로가 맞는지 먼저 의심한다ls bff/src/main/java/ 로 디렉터리가 실재하는지 본다. 없는 디렉터리를 뒤져도 grep 은 조용히 0건을 준다.

소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이다. 직접 친다. 먼저 응답을 통째로 한 번 본다.

curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12

상태줄과 Location 헤더를 본다. 302 라면 어디로 보내는가. 로그인 페이지로 보내면 「인증이 필요한 요청으로 처리됐다」는 뜻이고, 그런 핸들러가 없어서 기본 규칙에 걸린 것이다. 그 다음에 후보 셋을 나란히 잰다.

for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
  curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
done

★ 2026-09-17 에도 세 경로가 전부 302 였다(observed). 워크스테이션의 grep -rn "oidcLogout\|backchannel" bff/src/main/java/ 도 그대로 빈 출력이었고, 그 디렉터리는 있으므로 경로를 잘못 짚은 것이 아니라 정말 없다.

HTTP/1.1 302 Found
Location: http://app1.hyeonworks.com/oauth2/authorization/keycloak

/logout/connect/back-channel/keycloak 302
/backchannel-logout 302
/oauth2/sign_out 302

302 는 「그 경로가 있다」가 아니라 「인증이 필요한 요청으로 처리됐다」이고, 그런 핸들러가 없어 기본 규칙에 걸린 것이다. Location 이 로그인 시작점을 가리켜 그것을 증명한다.

실측(observed) — 01-current-state.txt

=== 실제로 그 경로가 있는가 ===
  /logout/connect/back-channel/keycloak      HTTP 302
  /backchannel-logout                        HTTP 302
  /oauth2/sign_out                           HTTP 302

셋 다 302 다.

응답
302 그런 핸들러가 없어서 인증 요구로 떨어졌다
200 / 400 엔드포인트가 있고 logout token 을 읽으려 했다
404 라우팅 자체가 없다

302 는 「없다」의 증거다. 엔드포인트가 있었다면 POST 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다. 후보 ②가 확정됐고, ①은 앞에서 확정됐다.

★ 마지막 칸이 이 편에서 새로 붙은 것이고, 원래 실행이 여기서 한 번 헛돌았다. C-1 에서 배운 대로 realm 을 조인해서 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session us join realm r on r.id=us.realm_id
    where r.name='keycloak-patterns' and us.offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 03-logout-attempt.txt

=== 로그아웃 전 상태 ===
  Redis: 2 키
  keycloak-patterns 세션: 0

IdP 세션이 0 이다. Redis 에는 키가 2개 있는데 Keycloak 쪽은 비어 있다. 이 상태에서 로그아웃을 걸면 아무 일도 안 난다 — 끊을 대상이 없기 때문이다. 그리고 「앱 세션이 안 지워졌다」를 보고 「전파가 안 되는구나」로 결론지을 뻔했다. 주입은 정상적으로 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. 틀린 것은 전제뿐이다.

그러니 세션을 만든다. 브라우저에서 https://app1.hyeonworks.com/ 을 열고 labuser 로 로그인한다. 그리고 다시 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session us join realm r on r.id=us.realm_id
    where r.name='keycloak-patterns' and us.offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 03-logout-attempt.txt 의 두 번째 시험

=== 로그아웃 전 — 실제 세션이 있는가 ===
  keycloak-patterns 세션: 1
  Redis: 1 키

세션 수가 1 이상이어야 한다. 여기서 0 이면 로그인이 안 된 것이고, 0 인 채로 주입 절로 넘어가지 않는다.

주입

의도적으로 한쪽만 고친다. 「①만 있으면 되는가」가 이 실험의 질문이다.

먼저 지금 attributes 를 저장해 둔다. 되돌리기가 이 백업에 달렸다 — JSON 으로 통째로 넣는 방식이라 기존 속성이 덮인다.

CID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
  --format csv --noquotes | tail -1)
echo "$CID"

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get "clients/$CID" -r keycloak-patterns --fields attributes \
  | tee ~/c2-bff-attributes-backup.json

실측(observed) — 02-configure-idp.txt

=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
  client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d

echo "$CID"UUID 한 줄인지 본다. 비었으면 --format csv --noquotes | tail -1 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 클라이언트를 고친다. 이 UUID 는 C-1 에서 bff-confidential 로 확인한 바로 그 값이고, 따라 하는 사람의 환경에서는 다르다.

★ 점 표기는 안 먹는다. 원래 실행이 처음에 친 것이 이것이고 실패한다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update "clients/$CID" -r keycloak-patterns \
  -s "attributes.backchannel.logout.url=https://app1.hyeonworks.com/logout/connect/back-channel/keycloak"

실측(observed) — 02-configure-idp.txt

command terminated with exit code 1

종료코드 1 이다. 이건 조용한 실패가 아니다 — 실패했다고 말해 준다. 다만 kubectl exec 를 거치면서 오류 본문이 잘려 「왜」는 안 보인다. 속성 이름 자체에 점이 들어 있어서(backchannel.logout.url) kcadm 의 점 표기와 충돌한다. JSON 으로 통째로 준다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update "clients/$CID" -r keycloak-patterns \
  -s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
                  "backchannel.logout.session.required":"true"}'

이 실험대는 설정 JSON 을 명령줄에 직접 줬다(observed). 사람이 내용을 읽으면서 고쳐야 하는 값을 셸 한 줄에 담은 형태이고, 따라 하는 사람이 파일로 만들어 넣는 형태는 가이드에 없다(unknown) — 여기 없는 명령은 지어내지 않으므로 이 문서에도 없다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다. 이 편에서는 그 확인 자체에 출처 문제가 붙어 있다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes

실측(observed) — 해설 문서 2절이 인쇄한 값

  backchannel.logout.session.required = true
  backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak

두 속성이 둘 다 있는지 본다. url 만 있고 session.required 가 없으면 logout token 에 sid 가 안 실린다.

★ 이 출력은 02-configure-idp.txt 에 없다. 그 파일은 점 표기 실패로 끝나고, 위 값은 그 뒤 별도로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 곳이므로 따라 하는 사람은 지금 직접 재 두는 편이 낫다고 가이드는 적는다.

이 시점의 앱 상태도 적어 둔다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 02-configure-idp.txt

=== 로그인 상태를 만든다 ===
  (브라우저에 이미 세션이 있다)
  Keycloak 세션: 2
  Redis: 2 키

키 이름을 그대로 적어 둔다. 관찰 절에서 글자 하나까지 같은지를 본다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.

관찰

시각을 적고 로그아웃한다.

date '+%H:%M:%S 로그아웃'
USERID=$(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)
echo "$USERID"

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create "users/$USERID/logout" -r keycloak-patterns

실측(observed) — 03-logout-attempt.txt

=== ★ IdP 로그아웃 → 백채널 알림 ===
  시각: 14:54:21

시각이 필요한 까닭은 뒤에서 로그를 뒤질 때 「이 순간 전후」로 좁히기 위해서다. --since 만으로는 어느 시도인지 안 갈린다.

IdP 쪽이 끊겼는지 먼저 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session us join realm r on r.id=us.realm_id
    where r.name='keycloak-patterns' and us.offline_flag='0'"

실측(observed) — 04-reachability.txt

=== IdP 세션은 실제로 끊겼는가 ===
  keycloak-patterns 세션: 0

0 이다. 로그아웃 자체는 동작했다. 이제 앱 쪽을 볼 자격이 생겼다. 여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 안 지워져 있어도 그건 당연한 결과라 아무것도 판정하지 못한다.

앱 세션은 주입 검증에서 친 것과 똑같은 명령으로 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측(observed) — 03-logout-attempt.txt

=== 앱 세션이 정리되었는가 ===
  Redis: 2 키
    _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
    bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679

그리고 두 번째 시험(세션이 실제로 1개 있던 판)에서도 이렇다.

실측(observed) — 03-logout-attempt.txt

=== 앱 세션 ===
  Redis: 1 키

개수도 이름도 그대로다. ①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과이고, IdP 쪽만 설정해도 소용없다.

로그에 흔적이 있는지 본다. Keycloak 양쪽 노드와 앱 쪽이다.

kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel

실측(observed) — 04-reachability.txt

=== Keycloak 로그 전체에서 backchannel 흔적 ===
  keycloak-0: 0 줄
  keycloak-1: 0 줄
kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'

실측(observed) — 03-logout-attempt.txt

=== BFF 로그 — 백채널 요청이 도착했는가 ===

양쪽 다 비어 있다. ★ 그런데 여기서 결론을 넓히면 안 된다.

이 출력이 말하는 것 말하지 않는 것
로그에 backchannel 문자열이 없다 Keycloak 이 요청을 안 보냈다
BFF 로그에 도착 흔적이 없다 요청이 아예 안 왔다

로그 레벨이 DEBUG 였다면 안 찍혔을 수 있다. 「0줄」은 「안 보냈다」의 증거가 아니라 **「기본 로그 레벨에서는 안 보인다」**일 뿐이다. 확실한 것은 앱 세션이 안 지워졌다는 관측이고 그것은 직접 봤다. 로그 0줄을 근거로 「Keycloak 이 안 보냈다」고 쓰면, 나중에 DEBUG 를 켜서 보냈다는 게 밝혀졌을 때 결론 전체의 신뢰가 무너진다.

후보 ③을 판정한다. 앱 세션이 안 지워지는 까닭이 「요청이 못 닿아서」일 수도 있고, 그러면 구현이 아니라 네트워크를 고쳐야 한다. Keycloak 파드에는 curl 이 없으므로 같은 네임스페이스에 임시 파드를 띄운다. 가이드가 미검증으로 표시한 줄이고 원래 실행의 명령 원문은 기록에 없다(unknown). 출력은 실측이다.

kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
  --image=curlimages/curl:8.11.1 --command -- sh

파드 안에서 두 줄을 친다.

nslookup app1.hyeonworks.com
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/

실측(observed) — 04-reachability.txt

=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
  DNS 해석:
    Address: 100.83.212.4
    
    Non-authoritative answer:
    
  HTTPS 도달:
    HTTP 200  (0 이면 못 닿음)
Address: 100.83.212.4 클러스터 안에서 공개 이름이 풀린다
HTTP 200 실제로 닿는다
HTTP 000 curl 이 연결조차 못 했다 = 네트워크가 원인

★ 지금 배치에서는 주소가 다르고, 닿는 까닭도 다르다(2026-09-17, observed). 판정(200)은 같은데 nslookup 이 내놓는 주소가 100.83.212.4 가 아니라 엣지 게스트의 192.168.122.10 이다.

Server:		10.43.0.10
Address:	10.43.0.10:53


Name:	app1.hyeonworks.com
Address: 192.168.122.10
--- HTTPS ---
app1 200
curl exit=0

「tailnet 과 split DNS 의 헤어핀」은 이제 이 실험대의 사정이 아니다. 가이드 03 이 CoreDNS 에 coredns-custom 항목을 넣어 세 이름을 엣지로 보내기 때문에 풀린다. 저절로 풀리지 않고 넣어야 풀리며, 안 넣으면 이 절이 000 으로 끝나 「네트워크가 원인이 아니다」를 못 보인다. 같은 날 get networkpolicy 는 다시 쳐도 No resources found in keycloak-lab namespace. 였다.

후보 ③은 원인이 아니다. 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. exit 으로 파드에서 나오면 --rm 이 지워 준다.

임시 파드는 Keycloak 파드의 완전한 대역이 아니다. 같은 네임스페이스라 DNS 와 대체로 같은 경로를 타지만, NetworkPolicy 나 사이드카가 걸려 있으면 결과가 갈릴 수 있다. 이 실험대에는 그런 것이 없어서 대역이 성립했고, 확인은 kubectl -n keycloak-lab get networkpolicy 가 비어 있는지로 한다.

그리고 이 200 은 이 실험대의 특수 사정이다. 이 실험대는 tailnet + split DNS 구성이라 클러스터 안에서 공개 이름을 불러도 되돌아온다(헤어핀). 운영에서는 안 되는 경우가 흔하다. 백채널 로그아웃에는 숨은 전제가 하나 있다 — IdP 가 앱의 공개 URL 로 서버에서 서버로 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면 설정을 해도 도달하지 못하고, 그때는 로그도 안 남고 조용히 실패한다.

그래서 왜 안 퍼졌는지가 세 단계로 정리된다.

   IdP 로그아웃
     ├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다   (주입 절에서 설정함)
     ├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다             ★ 없다
     └─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다  ★ 없다

②와 ③이 없다. ①만 설정해도 받을 사람이 없다. 「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서 확인한 것이 이 가이드의 값이라고 가이드는 적는다.

구조는 이렇게 생겼다.

   사용자가 어느 앱에서든 로그아웃
        │
        ▼
   Keycloak 이 SSO 세션에 붙은 client session 목록을 본다   (C-1 의 그 구조)
        │
        ├──POST──▶ app1 의 backchannel.logout.url
        └──POST──▶ app2 의 backchannel.logout.url
                     본문: logout_token (JWT)
                       { "sid": "...", "sub": "...", "events": {...} }

sid 는 Keycloak 의 user session 식별자이고, A-0 에서 확인한 그 sid 다 — JWT·DB·관리 API 에서 같은 문자열이었던. logout token 에 실려 오는 것이 sid 이고, 앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.

   logout_token 의 sid  →  앱이 자기 세션 저장소에서 그 세션을 찾아 지운다

그래서 앱은 sid → 자기 세션 ID 역인덱스를 갖고 있어야 한다. Spring Security 는 이를 위해 OidcSessionRegistry 를 쓴다. 엔드포인트가 있어도 그 역인덱스가 없으면 어느 세션을 지울지 모른다. 그리고 인스턴스가 여럿이면 그 레지스트리도 공유 저장소여야 한다 — B-1·B-2 에서 겪은 것과 같은 문제가 한 겹 더 있고, BFF 는 replica 2개다.

부분 실패도 이 구조에서 나온다.

   app1 로그아웃 성공, app2 는 응답 없음
     └─ Keycloak 은 재시도하는가? 얼마나?
     └─ 사용자는 app2 에서 여전히 로그인 상태다

로그아웃은 원자적이지 않다. 앱이 늘어날수록 「일부만 로그아웃된 상태」가 생길 확률이 올라간다. 이 실험은 그 재시도 동작을 측정하지 않았다.

구현하려면 무엇이 필요한지도 가이드가 표로 적는다.

계층 할 일 이 실험대의 상태
IdP 클라이언트마다 backchannel.logout.url 설정 완료
.oidcLogout(oidc -> oidc.backChannel(...)) 활성화 없음
OidcSessionRegistry공유 저장소로 (인스턴스가 여럿) 없음
네트워크 IdP → 앱 공개 URL 도달 . 운영은 확인 필요
oauth2-proxy 지원하지 않는다. 별도 방안이 필요하다

마지막 줄이 C-1 과 맞물린다 — app1(BFF)은 구현할 수 있지만 app2(oauth2-proxy)는 못 한다. 한 SSO 안에서 로그아웃 전파가 앱마다 다르게 동작하게 된다. C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데, 그 공유가 로그아웃까지는 안 간다.

복구와 원상복구 확인표

클라이언트 속성을 되돌린다. 백업을 먼저 읽는다.

cat ~/c2-bff-attributes-backup.json

원래 무엇이 있었는지 보고, 비어 있었으면 빈 객체로, 값이 있었으면 그 값으로 되돌린다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update "clients/$CID" -r keycloak-patterns -s 'attributes={}'

사라졌는지는 주입 전에 친 get clients … --fields attributes 로 본다. 세션 정리는 C-1 과 같다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

브라우저 쿠키(auth·app1·app2)도 지우거나 시크릿 창을 새로 연다. C 층이 끝났으면 빌린 이름을 여기서 돌려준다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3

백업 파일이 없으면 B-7 의 백업 단계를 다시 읽는다 — 그때 떠 뒀어야 하는 파일이다. oauth2-proxy 배포까지 걷어내려면 한 줄이 더 있다.

kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
항목 명령 돌아왔을 때
클라이언트 속성 get clients … --fields attributes backchannel.logout.url없다 (지웠다면)
Keycloak 세션 realm 조인 카운트 0
앱 세션 redis-cli --scan --pattern '*' 비었다
임시 파드 kubectl -n keycloak-lab get pod c2probe NotFound (없어야 정상)
파드 kubectl -n keycloak-lab get pods 전부 Running
Ingress kubectl -n observability get ingress grafana 있다
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ 200

임시 파드가 --rm 으로 안 지워졌으면 직접 지운다.

kubectl -n keycloak-lab delete pod c2probe --ignore-not-found

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
로그아웃했는데 아무 변화가 없다 로그아웃 전 세션이 이미 0 이었다 realm 조인해서 먼저 센다
kcadm -s "attributes.backchannel.logout.url=…"exit 1 점 표기가 안 먹는다 JSON 으로 통째로
JSON 으로 넣었더니 다른 속성이 사라졌다 attributes=통째로 교체한다 먼저 백업
$CID 가 비었다 --format csv --noquotes 출력이 예상과 다르다 echo "$CID" 로 먼저 확인
세션 수가 안 맞는다 master 의 admin 세션이 섞인다 realm 을 조인한다 (C-1 과 같은 실수)
grep -rn … bff/src/main/java/ 가 빈 출력 정말 없거나, 경로가 틀렸다 ls 로 디렉터리 존재 확인
후보 경로가 404 가 아니라 302 핸들러가 없어 인증 요구로 떨어진 것 302 도 「없다」의 신호다
로그의 backchannel 0줄을 근거로 삼고 싶다 DEBUG 레벨이면 안 찍힌다 판정 근거로 쓰지 않는다
임시 파드에서 HTTP 000 클러스터 안에서 공개 이름이 안 풀린다 운영에서는 그게 정상일 수 있다
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 임시 curl 파드
kcadm 이 전부 401 파드 재시작으로 kcadm 세션이 날아갔다 config credentials 를 다시
Keycloak 재시작 후 로그인 폼이 안 넘어간다 인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다 새 탭에서 주소부터 다시 연다
실험이 끝났는데 Grafana 가 안 열린다 Ingress 복구를 안 했다 빌린 이름을 돌려준다

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

  • (observed) 두 클라이언트의 attributesfrontchannelLogout 만 있는 것, grep -rn 이 헤더 아래를 비워 둔 것, 후보 경로 셋이 전부 HTTP 302 인 것, 로그아웃 전 「Redis: 2 키 · keycloak-patterns 세션: 0」과 두 번째 시험의 「세션: 1 · Redis: 1 키」, 점 표기 시도의 command terminated with exit code 1, 클라이언트 UUID 9055fa46-6abb-4d6d-a339-8a9183bbf26d, 로그아웃 시각 14:54:21, 로그아웃 뒤 keycloak-patterns 세션: 0, 그 뒤에도 Redis 키 두 줄이 같은 것, Keycloak 두 노드의 backchannel 0줄과 BFF 로그의 빈 출력, 임시 파드에서 본 Address: 100.83.212.4HTTP 200.
  • (observed·출처 주의) 설정이 들어간 것을 확인한 두 줄은 02-configure-idp.txt 에 없다. 그 파일은 점 표기 실패로 끝나고, 그 값은 뒤에 따로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 곳이다.
  • (observed, 2026-09-17) 임시 curl 파드를 띄우는 kubectl run c2probe … 한 줄을 이번에 쳐서 Address: 192.168.122.10app1 200 을 받았다. 원래 실행의 명령 원문은 여전히 기록에 없다(unknown). 설정 JSON 을 파일로 만들어 넣는 형태도 가이드에 없다 — 이 실험대는 명령줄에 직접 줬다.
  • 로그 0줄로는 아무것도 단정하지 않았다. 「Keycloak 이 요청을 안 보냈다」는 이 출력으로 나오지 않는다 — 기본 로그 레벨에서 안 보이는 것과 구별되지 않기 때문이고, DEBUG 를 켜서 다시 재지는 않았다(unknown).
  • 비밀은 옮기지 않았다 — 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 labuser 만 옮겼다. 클라이언트 UUID 와 Redis 키 이름은 식별자라 그대로 적었고, backchannel.logout.url 은 설정값이라 원문대로 적었다.
  • 이 실험대의 HTTP 200 은 구성 덕이다. 원래 실행은 tailnet + split DNS 의 헤어핀이었고, 2026-09-17 의 재구성에서는 CoreDNS 의 coredns-custom 항목이 그 일을 한다. 둘 다 이 실험대의 사정이고, 운영에서 같은 값이 나온다고 볼 근거는 없다.
  • 이 실험이 재지 않은 것 — ②·③을 실제로 구현한 뒤 전파가 되는지(코드를 고쳐야 한다), Keycloak 이 요청을 보내기는 했는지(DEBUG 로그를 안 켰다), 부분 실패 시의 재시도 정책.

D층 재현 절차 — 다섯 편을 직접 치는 순서

앞의 「D층 — 운영」은 무엇을 발견했는지를 적었다. 여기부터는 그 발견을 다시 만들려면 무엇을 어떤 순서로 치는가다. 근거는 ../source/docs/guides/experiments/ 의 D층 다섯 편이고, 파일 하나가 아래 절 하나에 대응한다.

근거 파일 무엇을 가르나
D-1 백업·복구 d1-backup-restore.md 831 스키마를 통째로 지우고 나면 그 백업으로 정말 돌아오는가
D-2 버전 업그레이드 d2-version-upgrade.md 738 태그를 되돌리는 계획이 언제 동작하고 언제 안 하는가
D-3 비밀 관리 d3-secret-management.md 562 Secret 이 어디까지 감춰지는가
D-4 인증서 갱신 d4-certificate-renewal.md 1163 갱신은 성공했는데 왜 옛 인증서가 나가는가
D-4a 배포 훅 d4a-deploy-hook.md 627 훅 파일 하나가 그 공백을 얼마로 줄이는가

뼈대는 앞의 세 층과 같다. 기준선주입주입 검증관찰복구 이고, 아래 절들도 그 순서로 적는다. 주입 검증 을 따로 세우는 까닭도 같다 — 주입이 조용히 실패하면 「아무 일도 없었다」가 「영향이 없다」와 구별되지 않는다. D층에서 이 실패는 복구 쪽에서 온다. D-1 의 kubectl exec-i 를 빼면 파드 안의 psql 이 빈 입력을 받고 정상 종료하는데, 오류도 안 나고 종료 코드도 0 이고 시각 두 줄은 「1초 만에 끝났다」로 찍힌다. 복구한 것과 구별되지 않는다.

가이드가 출력에 붙인 표시는 앞의 세 층과 같은 셋이고, 뜻은 각 편의 표시 규약 표에 있다.

가이드의 표시 가이드가 적은 뜻 이 문서에서
실측 실행 기록의 출력 원문. 증거 파일에 그대로 있다 (observed)
형태 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 모양은 (observed), 숫자는 환경마다 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 (unknown)

어느 기계에서 치는가가 앞의 세 층과 똑같이 어긋난다. 아래 다섯 편의 전제도 「명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다」인데, 같은 폴더의 README.md 는 반대로 적는다. 아래 절들은 README 를 따른다. 다만 D-1 은 그 괄호를 스스로 달아 두었다(observed) — 「kubeconfig 를 사용자 홈에 복사해 뒀다면 sudo 는 빼도 된다」.

D층이 건드리는 것은 운영 절차 자체다. A층은 클러스터·네트워크·DB 를, B층은 애플리케이션 소스와 매니페스트를, C층은 두 앱의 세션을 건드렸다. 백업(D-1)과 판올림(D-2)이 앞의 세 층과 이렇게 다르다.

무엇 앞의 세 층 D-1 · D-2
되돌리기 수단 주입을 되돌린다 덤프 파일 하나(D-1)와 이미지 태그 한 줄(D-2). 태그 쪽에는 조건이 붙는다
주입 전에 재는 값 나중에 다시 잴 수 있다 지금 안 재면 다시 못 잰다 — 업그레이드 전 databasechangelog 행 수
스크립트 편에 따라 썼다 D-1 은 일부러 안 쓴다. DROP SCHEMA 와 복구가 한 파일에 있으면 중간에 멈췄을 때 무엇이 실행됐는지 모른다
호스트 게스트에서만 친다 D-1 의 마지막 단계만 호스트가 필요하고, 호스트의 sudo 는 비밀번호를 묻는다

jq 가 이 실험대에 없는 것은 앞의 세 층과 같고, 두 편 다 전제에 그렇게 적는다. D-2 는 레지스트리 태그 목록을 읽을 때 그래서 trgrep 으로 자르고, 가이드가 그 줄을 미검증으로 표시했다. 아래에서도 두 형태를 나란히 적는다.

버전 문자열은 다섯 편 중 셋에만 있다 (observed). 판 번호가 찍히는 명령을 그 편이 쳤을 때만 남았기 때문이고, D-1 과 D-3 은 자기 절에 한 줄도 없다.

그 편의 출력에 찍힌 것
D-2 quay.io/keycloak/keycloak:26.7.0 · 26.7.3 · 26.0, Infinispan 16.0.14
D-4 · D-4a certbot 5.7.0
D-1 · D-3 없다

D-1 은 D-2 의 시작 태그를 본다 — D-2 의 전제가 「D-1 이 끝나 있고」이고 그 사이에 태그를 바꾸지 않았다. 그 편이 직접 잰 값이 아니다 (inferred).

D-3 은 다른 편 값을 끌어오지 않는다. D-3 을 친 시각(15:0515:06)이 D-2 의 첫 실행 (15:0015:10, 역방향 26.0 으로 keycloak-1 이 CrashLoop)과 겹쳐, 그때 어느 판이 돌고 있었는지가 정해지지 않는다. D-3 이 재서 적은 것은 k3s secrets-encrypt statusDisabled 하나다 (observed).

아래 절들은 절차만 옮긴 것이다. 무엇을 발견했는지는 이 문서 앞쪽에 이미 있고, 여기 실린 명령과 출력은 전부 가이드 원문에서 왔다. 가이드에 없는 명령은 넣지 않았고, 가이드가 규범을 어긴 곳은 두 형태를 나란히 적었다.

D-1 — 스키마를 통째로 지우고 나면 그 백업으로 정말 돌아오는가

근거: d1-backup-restore.md (831줄). 실행 기록은 2026-09-04 14:5715:00 KST(observed).

이 실험이 가르는 것

「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과 그 파일로 실제로 서비스를 되살리는 것 사이에는 시험되지 않은 가정이 여러 개 있고, 이 실험은 그중 둘을 판정한다.

# 질문 어떻게 가르나
덤프에 필요한 것이 다 들어가는가 특히 세션. 안 들어가면 복구 후 전원 재로그인이다
복구 절차가 실제로 도는가 오류 없이 끝나고 데이터가 일치하는가

그리고 부수 질문이 하나 붙는다 — DB 가 비면 무엇이 깨지는가. 이게 A-2 와 대비되는 곳이고, 가이드는 여기서 가장 놀라운 결과가 나왔다고 적는다.

   A-2  DB 프로세스 정지  →  커넥션 실패  →  readiness DOWN  →  파드가 Service 에서 빠짐
   D-1  스키마만 삭제      →  커넥션 정상  →  readiness UP    →  ?

커넥션은 되는데 테이블이 없는 상태는 단일 장애 주입으로는 잘 안 만들어진다. 그래서 이 실험이 따로 있다.

가이드의 「이 가이드가 끝나면」 표는 일곱을 적는다 — 덤프 파일 안에 세션 행이 실제로 들어 있는 것, 데이터베이스를 통째로 비웠는데 정문이 200 인 것, 파드가 1/1 Running 인 채로 테이블이 0개인 것, certs 200 · well-known 500 · 토큰 400 으로 부분만 깨지는 것, 복구가 1초 만에 오류 0건으로 끝나는 것, 세션까지 되살아나는 것, 덤프가 DB 와 같은 기계 위에 놓여 있는 것.

복구가 이 편에서는 관찰의 일부다. 질문 ②의 답이 복구 절에서 나오므로, 아래 「복구와 원상복구 확인표」는 원상복구만이 아니라 이 실험의 판정을 함께 싣는다.

전제와 되돌리기

  • A-2 를 먼저 하면 좋다. 「DB 프로세스가 죽었을 때」의 모양을 봐 둬야 이 실험의 200 이 얼마나 이상한지 안다.
  • A-3 도 먼저다. 실제 RPO 의 두 번째 겹이 거기서 나온다.
  • 네임스페이스는 keycloak-lab 이다.
  • jq 는 이 실험대 어디에도 없고, 이 가이드는 jq 를 쓰지 않는다.
  • 덤프를 다른 기계로 옮기는 마지막 단계만 호스트(test-server)가 필요하고, 호스트의 sudo 는 비밀번호를 묻는다. 그 부분은 사람이 직접 친다.

★ 이 실험은 데이터베이스를 비운다. DROP SCHEMA public CASCADErealm·client·user·세션을 전부 지운다. 되돌리는 수단은 방금 뜬 덤프 파일 하나뿐 이고, 그래서 가이드는 덤프를 검증하기 전에는 주입 절로 넘어가지 않는다. 전 구간 약 20분이고 파괴 구간 자체는 1분 안쪽으로 잡는다.

되돌리기는 한 줄이고, 파괴하기 전에 읽어 둔다.

kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
  < /tmp/keycloak-backup.sql

-i 가 이 명령의 전부다. 빠뜨리면 아무 일도 안 일어나고 오류도 안 난다. 왜 그런지는 복구 절에 있다.

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

시험군만 재는 측정은 측정이 아니다. 파괴 후에 볼 것을 파괴 전에 똑같은 명령으로 먼저 봐 둔다. 복구가 「완전 일치」인지 판정하려면 일치시킬 상대가 있어야 한다. 넓은 것부터 좁혀 가고, 이 편에서는 마지막 두 칸이 덤프 자체를 향한다.

파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
kubectl -n keycloak-lab get pods -o wide

실측(observed) — 02-destruction.txt 의 파괴 직후 목록이지만, 파괴 전후가 같다는 것이 이 실험의 결과이므로 파괴 전 값으로도 읽는다

bff-555df79c97-6j86w           1/1   Running   0     49m
bff-555df79c97-vgg6g           1/1   Running   0     49m
keycloak-0                     1/1   Running   0     4m15s
keycloak-1                     1/1   Running   0     4m38s

READY 가 전부 1/1 이고 RESTARTS0 인지, postgres 파드가 어느 노드에 있는지를 본다. 뒤에서 RESTARTS 가 오르면 파괴가 엉뚱한 것을 건드린 것이다.

처음 한 번은 읽는 형태로 친다. 값만 뽑는 형태부터 배우면 psql 이 무엇을 돌려주는지 모르게 된다.

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

형태(모양은 observed)

 count
-------
     2
(1 row)

숫자 하나와 (1 row) 를 본다. 여기서 오류가 나면 뒤의 모든 단계가 무의미하다. psql: error: connection to server ... failed 면 DB 가 아직 안 붙은 것이고, relation "realm" does not exist이미 스키마가 없는 것이다.

이제 넷을 한 줄로 모은다. 비교할 값이 필요할 때만 이 형태를 쓴다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select (select count(*) from realm), (select count(*) from client),
          (select count(*) from user_entity),
          (select count(*) from offline_user_session where offline_flag='0')"

실측(observed) — 01-backup.txt

  realms|clients|users|sessions|authclients = 2|15|2|3|1

이 실험대는 이렇게 했다(observed) — 원래 실행은 스크립트로 돌렸고 「인가된 클라이언트(authclients)」를 하나 더 셌다. 그래서 증거 줄에는 값이 다섯이고 이름표가 붙어 있다. 따라 하는 사람은 위 명령으로 넷을 뽑고, 손으로 치면 이름표 없이 2|15|2|3 만 나온다. 다섯째 쿼리는 해설 문서의 재현 절차에 남아 있지 않아 가이드가 넷으로 뒀다 — 없는 컬럼을 지어내지 않고, 다섯째가 필요하면 세는 쿼리를 정해서 양쪽에 같이 쓴다. -tAc 는 헤더 없이(-t) 정렬 없이(-A) 한 줄만이라는 뜻이다.

이 줄을 그대로 복사해 둔다. 복구 후에 같은 명령을 쳐서 문자 단위로 같은지 본다. 하나라도 다르면 복구가 부분적으로만 된 것이다.

세션이 DB 에 있는지가 질문 ①의 재료다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, offline_flag, realm_id from offline_user_session"

형태(모양은 observed) — 행이 몇 개 있고 id 가 어떻게 생겼는지만 본다

     user_session_id      | offline_flag |               realm_id
--------------------------+--------------+--------------------------------------
 E1q5xI7tt4U_WhZpW7rEPIF2 | 0            | 7845f394-723a-4d07-b530-c7416b2e1d31
 ...

행이 0개면 안 된다. 0개면 질문 ①을 판정할 수 없고, 관리 콘솔에 한 번 로그인해서 세션을 만들고 다시 본다.

offline_flag 때문에 두 쿼리가 다른 것을 센다. 위의 개수 쿼리는 offline_flag='0' 만 셌고 이 쿼리는 전부 나열한다. 실제로 원래 실행에서도 개수는 3, 나열은 4 rows 였다(03-restore.txt). 두 숫자가 다른 것을 이상하게 여기지 말고 복구 전후에 같은 쿼리끼리 비교한다.

세션이 DB 테이블에 있다는 것은 persistent-user-sessions 가 켜져 있다는 뜻이고(A-0), 그래서 세션이 백업 대상이 된다. volatile 이었다면 세션은 애초에 DB 에 없고 복구해도 전원 재로그인이라 백업의 가치가 달라진다.

밖에서도 본다. 처음 한 번은 응답을 읽는다.

curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master

헤더가 통째로 나온다. HTTP/2 200, content-type: application/json 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/

실측(observed) — 02-destruction.txt (이것도 파괴 직후 값이고, 그게 결과다)

  https://auth.hyeonworks.com/realms/master   HTTP 200
  https://app1.hyeonworks.com/                HTTP 200

지금은 당연히 200 이다. 문제는 파괴 뒤에도 이 값이 200 이라는 것이고, 그래서 이 두 줄은 「정상 판정에 쓸 수 없는 지표」의 예시로 남는다.

백업을 뜬다.

date '+%H:%M:%S 백업 시작'
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
  --clean --if-exists > /tmp/keycloak-backup.sql
date '+%H:%M:%S 백업 완료'

실측(observed) — 01-backup.txt

  시작: 14:59:30
  완료: 14:59:30
  크기: 394945 bytes  (6956 줄)

시각 두 줄과 파일 크기를 본다. 이 규모에서는 1초 미만이다. 두 옵션은 짝이다.

옵션 무엇을 하나 없으면
--clean 복구 시 기존 객체를 DROP 하고 다시 만든다 already exists 오류가 쏟아진다
--if-exists 없는 객체를 DROP 할 때 오류를 안 낸다 깨끗한 DB 에 복구할 때 오류가 쏟아진다

--clean 만 주면 「빈 DB 에 복구」가 깨지고, --if-exists 만 주면 아무 효과가 없다 — DROP 문 자체가 안 만들어진다. 이 실험은 어차피 빈 DB 에 복구하는데도 두 옵션이 필요한 까닭을 가이드가 적는다. 실제 사고는 대개 그렇지 않고, 반쯤 남은 DB 에 덤프를 밀어 넣는 상황이 훨씬 흔하며 그때 이 둘이 있고 없고가 갈린다. 이 단계는 읽기만 하므로 파일이 마음에 안 들면 지우고 다시 뜬다.

rm -f /tmp/keycloak-backup.sql

★ 덤프를 검증한다. 이 단계를 건너뛰면 주입 절은 자살행위라고 가이드는 적는다. 「파일이 생겼다」는 「복구할 수 있다」가 아니다 — pg_dump 가 중간에 실패해도 파일은 남고 크기도 0 이 아니다. 확인이 넷이다.

ls -l /tmp/keycloak-backup.sql
wc -l /tmp/keycloak-backup.sql

실측(observed)

  크기: 394945 bytes  (6956 줄)
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql

실측(observed)

  포함된 테이블 수: 101

101 이라는 절대값이 중요한 게 아니라 앞에서 본 DB 와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘린 것이다.

tail -3 /tmp/keycloak-backup.sql

형태(모양은 observed)

--
-- PostgreSQL database dump complete
--

dump complete 를 본다. 이 줄이 없으면 덤프가 중간에 끊긴 것이고 그 파일로는 복구가 안 된다. 이 한 줄이 「파일이 생겼다」와 「덤프가 끝났다」를 가른다.

넷째가 질문 ① 자체다.

grep -c 'offline_user_session' /tmp/keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110

실측(observed) — 01-backup.txt

  offline_user_session 언급: 13
    COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre
    E1q5xI7tt4U_WhZpW7rEPIF2	48b37d33-8419-49aa-9b5b-7731975be50c	7845f394-723a-4d07-b530-c7416b2e1d31	1788500836	0	{"ipAddr
    2ap3DyRiBF8OdMiqCodsJ0mp	48b37d33-8419-49aa-9b5b-7731975be50c	7845f394-723a-4d07-b530-c7416b2e1d31	1788501263	0	{"ipAddr

COPY 줄 다음에 실제 데이터 행이 붙어 있는가를 본다. COPY ... FROM stdin; 바로 뒤에 \. 만 있으면 테이블 정의만 들어가고 행은 비어 있는 것이고, 그건 세션을 백업하지 못한 덤프다. cut -c1-110data 열의 JSON 이 화면을 뒤덮는 것을 막으려는 것이라, 처음 한 번은 cut 없이 쳐서 한 행이 얼마나 긴지 봐 둔다.

세션이 덤프에 들어간다. 질문 ①의 답은 「들어간다」이고 근거가 이 COPY 블록이다. 복구 절에서 이 id 들이 되살아나는 것을 확인한다.

마지막으로 덤프가 지금 어디에 있는지 본다.

ls -l /tmp/keycloak-backup.sql
df -h /tmp

경로가 /tmp 다. 이 파일은 지금 kubectl 을 친 그 기계의 디스크에 있다. A-4 에서 local-path PVC 가 노드에 못박혀 있는 것을 봤고, 그 노드가 안 돌아오면 DB 볼륨도 안 돌아온다. 그때 유일한 길이 덤프인데 덤프도 같은 기계에 있으면 같이 사라진다. 같은 장애 도메인에 있는 백업은 백업이 아니다. 원래 실행에서도 덤프는 test-server:/tmp 에 있었고, 해설 문서는 그것을 **「가장 중요한 미검증 항목」**으로 기록했다. 옮기는 절차는 복구 절에 있고, 파괴 전에는 읽어만 두고 실제 이동은 복구가 끝난 뒤에 한다.

주입

여기부터 데이터가 사라진다. 되돌리는 명령은 전제 절에 있고, 덤프 검증 넷을 통과하지 않았으면 지금 돌아가서 한다.

date '+%H:%M:%S 파괴'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"

실측(observed) — 02-destruction.txt

=== ★ 파괴 — 스키마를 통째로 지운다 ===
  시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA

DROP SCHEMACREATE SCHEMA 두 줄을 본다. NOTICE: drop cascades to 101 other objects 같은 줄이 함께 나오는 것이 정상이다. 시각을 반드시 적어 둔다 — 복구 절의 RTO 가 이 시각에서 시작한다.

CREATE SCHEMA public 을 붙이는 까닭public 스키마 자체를 지우면 복구 스크립트가 들어갈 곳이 없기 때문이다. 지우는 것은 안의 객체이고, 빈 스키마는 남겨 둬야 pg_dump 출력이 그대로 들어간다.

주입 검증

결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from pg_tables where schemaname='public'"

이 실험대는 스크립트로 셌다(observed). 따라 하는 사람은 위 형태를 친다 — 가이드가 미검증으로 표시한 줄이다(unknown). 결과는 이렇다.

실측(observed)

  남은 테이블: 0

★ 2026-09-17 에 이 절차를 처음부터 끝까지 쳤다(observed).

덤프        101 테이블 · 976873 bytes · 8569 줄
파괴 15:31:02   DROP SCHEMA · CREATE SCHEMA → 남은 테이블 0 · select count(*) from realm 이 relation does not exist
그동안 밖에서는  200   ← 스키마가 통째로 없는데 정문은 멀쩡했다
복구 15:31:02 → 15:31:08 (6초) · 복구 로그 ERROR 0
복구 뒤      101 테이블 · 세션 6건  — 파괴 전과 같다

크기와 줄 수는 실험대마다 다르다. 이 문서가 적은 394945 bytes · 6956 줄 은 그 실험대의 값이고, BFF 쪽 테이블이 있는 실험대에서는 976873 bytes · 8569 줄 이었다. 같아야 하는 것은 테이블 수 101 과 복구 전후의 행 수다. 남은 테이블을 세는 select count(*) from pg_tables … 형태도 그때 쳐서 0 을 받았다 — 미검증이던 줄이다(observed).

0 이어야 한다. 여기서 101 이 그대로 나오면 DROP 이 다른 데이터베이스에 걸린 것이고 -d 인자를 본다. 애플리케이션 테이블이 정말 없는지 직접 물어본다.

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

형태(모양은 observed)

ERROR:  relation "realm" does not exist
LINE 1: select count(*) from realm
                             ^

커넥션은 성립하고 SQL 도 파싱된다. 테이블만 없다. 이 구별이 이 실험의 전부다. A-2 에서는 여기가 connection to server ... failed 였다.

★ 그런데 밖은 멀쩡하다.

kubectl -n keycloak-lab get pods -o wide
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/

실측(observed) — 02-destruction.txt

  https://auth.hyeonworks.com/realms/master   HTTP 200
  https://app1.hyeonworks.com/                HTTP 200
keycloak-0                     1/1   Running   0     4m15s
keycloak-1                     1/1   Running   0     4m38s

1/1, RESTARTS 0, 그리고 200 이다. 데이터베이스가 통째로 비었는데 정문이 200 이다. 여기서 「파괴가 실패했다」고 읽으면 틀린다 — 테이블이 0개인 것을 바로 앞에서 봤다. 파괴는 성공했고 관측 지점이 그것을 못 본다. Keycloak 이 realm 정보를 Infinispan realms 캐시에서 서빙하기 때문이고(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다), 캐시는 읽을 때 DB 와 대조하지 않는다. A-1 에서 로그아웃한 세션이 반대편에서 200 을 받았던 것과 같은 성질이다.

엉뚱한 것을 죽이지 않았는지도 본다.

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"

ready 주소가 여전히 둘이다. 아무 파드도 Service 에서 빠지지 않았다. A-2 에서는 여기가 빈 목록이었다. readiness 프로브가 통과하고 있다는 뜻이고 그 까닭은 관찰 절에 있다. kubectl get endpoints 는 v1.33+ 에서 deprecated 이고, 이 실험대에서 실제로 그 경고를 봤다.

관찰

전부 깨지지는 않는다. 세 경로를 나눠서 친다.

curl -s -o /dev/null -w 'certs      %{http_code}\n' \
  --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
  --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration

실측(observed) — 03-restore.txt

  /.well-known/openid-configuration      HTTP 500
  /protocol/openid-connect/certs         HTTP 200
  토큰 발급 (DB 쓰기 필요)       HTTP 400

토큰 발급은 값이 필요하므로 따로 친다. 이 실험대는 스크립트로 돌렸고(observed), 따라 하는 사람은 가이드가 미검증으로 표시한 아래 형태를 친다(unknown).

curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \
  --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \
  -d grant_type=password -d client_id=admin-cli -d username=admin \
  -d "password=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
      -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 길이만 확인하려면 한 줄을 더 친다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

세 값이 다 다르다는 것을 본다.

경로 코드
certs (JWKS) 200 realm 키가 캐시에 있다. DB 를 안 본다
.well-known 500 이 응답을 만들려면 DB 를 본다
토큰 발급 400 세션을 써야 한다

부분적으로만 깨진다. 헬스체크는 통과하고, 일부 엔드포인트는 정상이며, 로그인만 안 된다. 운영에서 이 모양이 고약한 까닭은 「사이트가 떴는가」를 재는 감시(정문 200, JWKS 200)가 전부 초록인데 사용자만 못 들어오기 때문이다. 이 사고의 감시 항목은 /realms/master 가 아니라 토큰 발급이어야 한다.

로그가 이유를 말한다.

kubectl -n keycloak-lab logs keycloak-0 --tail=50

실측(observed) — 02-destruction.txt

  2026-09-04 05:58:02,598 WARN  [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut
  	at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)

WARN 이지 ERROR 가 아니다. 내용은 「클러스터 멤버를 못 가져온다」이고, JGROUPS_PING 테이블도 같이 지워졌기 때문이다(A-1 에서 그 테이블을 봤다). 디스커버리가 깨졌는데도 로그 레벨이 WARN 이라 대시보드의 에러 카운터에 안 잡힐 수 있다. 정문의 200, 부분 정상, 여기의 WARN세 관측이 전부 「괜찮다」 쪽으로 기운다.

「DB 가 살아 있다」와 「데이터가 있다」는 다르고, 그 차이가 이 실험의 모양을 만든다.

   A-2  DB 프로세스 정지  →  커넥션 실패  →  readiness DOWN  →  파드가 Service 에서 빠진다
   D-1  스키마만 삭제      →  커넥션 정상  →  readiness UP    →  ★ 파드가 그대로 트래픽을 받는다

헬스체크는 커넥션만 본다. 그래서 빈 데이터베이스를 통과시킨다. Keycloak 의 버그가 아니다 — 「DB 에 붙을 수 있는가」는 프로브가 답할 수 있는 질문이고 「데이터가 온전한가」는 프로브가 답할 수 없는 질문이다. 뒤엣것을 재려면 업무 트랜잭션 하나를 실제로 돌리는 감시(예: 토큰 발급)가 따로 있어야 한다.

재는 것 이 사고에서
파드 Ready 초록
정문 200 초록
JWKS 200 초록
토큰 발급 400 ← 유일하게 정직한 지표

복구와 원상복구 확인표

date '+%H:%M:%S 복구 시작'
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
  < /tmp/keycloak-backup.sql > /tmp/restore.log 2>&1
date '+%H:%M:%S 복구 완료'

실측(observed) — 03-restore.txt

  시작: 15:00:12
  완료: 15:00:13
  오류 줄: 0

-i 를 빠뜨리면 아무 일도 안 일어나고 오류도 안 난다.

kubectl -n keycloak-lab exec    deploy/postgres -- psql ... < dump.sql   # ✘
kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql   # ✔

-i표준입력을 파드 안으로 연결하라는 뜻이다. 없으면 파드 안의 psql 은 빈 입력을 받고 정상 종료한다. 셸은 오류를 내지 않고, 종료 코드도 0 이며, date 두 줄은 「1초 만에 끝났다」로 찍힌다. 복구된 것과 구별되지 않는다. 구별하는 유일한 방법이 다음 단계의 데이터 대조이고, 그래서 대조는 선택이 아니다.

grep -ci '^ERROR' /tmp/restore.log
tail -5 /tmp/restore.log

0 이어야 한다. 0 이 아니면 어떤 줄이 실패했는지 본다. --clean --if-exists 로 뜬 덤프를 빈 DB 에 넣으면 오류가 0 인 것이 정상이다.

여기가 진짜 판정이다. 주입 전에 친 것과 똑같은 명령을 친다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select (select count(*) from realm), (select count(*) from client),
          (select count(*) from user_entity),
          (select count(*) from offline_user_session where offline_flag='0')"

실측(observed) — 03-restore.txt

  복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
  백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1

두 줄이 문자 단위로 같은가를 본다. 완전 일치이고, 질문 ②의 답이 「돈다」인 근거가 이 두 줄이다. 여기가 다르면 그 앞의 모든 「성공」 표시는 무의미하고, -i 를 빠뜨렸는지 먼저 의심한다.

손대지 않고 기다린다. 여기서 파드를 재시작하면 「자가 회복하는가」라는 질문 자체가 사라진다. 15초쯤 뒤에 본다.

curl -s -o /dev/null -w 'well-known %{http_code}\n' \
  --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
kubectl -n keycloak-lab get pods -o wide | grep keycloak

실측(observed) — 03-restore.txt

  +15초  well-known=200  토큰발급=200
  → 재시작 없이 회복

  keycloak-0 restarts=0
  keycloak-1 restarts=0

500 이던 well-known200 이 된 것과 RESTARTS 가 여전히 0 인 것을 같이 본다. 커넥션 풀이 이미 붙어 있었으므로 테이블이 돌아오자마자 동작했다. A-2 에서 본 것과 같은 자가 회복이고, 파드를 만질 필요가 없다 — 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가 문서에 남았을 것이다.

세션이 살아났는지는 주입 전에 친 나열 쿼리로 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, offline_flag from offline_user_session"

실측(observed) — 원래 실행은 realm 이름을 함께 뽑았다

     user_session_id      |       realm
--------------------------+-------------------
 E1q5xI7tt4U_WhZpW7rEPIF2 | master
 2ap3DyRiBF8OdMiqCodsJ0mp | master
 Zsk4QcgXf_qgyMKzde5AG-Fz | master
 vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)

덤프 검증에서 봤던 id 가 그대로 있는가를 본다. E1q5xI7tt4U_WhZpW7rEPIF2 가 덤프의 COPY 블록에도 복구된 테이블에도 있다 — 파일에서 DB 로 실제로 넘어온 것을 눈으로 잇는다. 세션이 백업에서 복원되고 로그인 상태가 유지된다.

적어 둔 시각 셋을 나란히 놓는다.

   14:59:47  파괴
   15:00:12  복구 시작
   15:00:13  복구 완료
   ~15:00:28 서비스 정상 확인

   RTO = 41초

41초 중 복구 명령 자체는 1초다. 나머지는 「파괴를 알아채고 무엇을 할지 정하는 시간」이며, 이 실험에서는 이미 알고 있었으므로 25초였다. 실제 사고에서는 이 부분이 대부분을 차지한다.

RPO 는 두 겹이다.

   ① 마지막 덤프 이후의 모든 변경         ← 백업 주기가 정한다
   ② A-3 에서 측정한 synchronous_commit 손실  ← 수백 ms

   실제 RPO = ① + ②

A-3 은 클라이언트가 200 을 받은 로그인 153건 중 4건이 DB 에 없었다는 것을 측정했다. 백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.

그리고 이 실험대의 규모는 현실적이지 않다.

이 실험대 운영
덤프 크기 395KB GB~TB
복구 시간 1초 분~시간
세션 수 3~4 수만

복구가 1초인 것은 데이터가 작기 때문이고, 이 실험이 확인한 것은 절차가 맞다는 것뿐이다. 시간은 규모에 따라 완전히 달라진다.

★ 마지막 단계는 이 실험이 「못 했다」로 남긴 것이다. 덤프는 아직 DB 와 같은 기계에 있다. 사람이 쳐야 하는 부분이 여기서 갈린다.

하는 일 어디서 sudo
덤프 뜨기 · 복구 kc-lab-1 게스트는 무암호 — 스크립트로도 된다
덤프를 호스트의 사용자 홈에 두기 test-server 필요 없다
덤프를 root 소유 경로(/var/backups 등)에 두기 test-server 비밀번호를 묻는다 — 사람이 친다

호스트의 sudo 는 비대화 실행이 반드시 실패한다. 그 벽에 부딪힌 기록이 D-4 의 증거에 남아 있다.

실측(observed) — d4-certificate-renewal/01-certificate-state.txt

$ sudo -n -l
sudo: a password is required

-n 은 「비밀번호를 물어보지 말라」는 뜻이고 호스트에서는 그게 곧 실패다. 그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다. ssh -t 로 붙어 사람이 비밀번호를 쳐야 하고, -t 가 없으면 sudo 가 비밀번호를 읽을 tty 가 없다.

이 실험대는 여기까지 하지 않았다(unknown). 가이드가 미검증으로 표시한 두 줄이고, 호스트 이름과 경로는 따라 하는 사람의 배치에 맞춘다.

# ① kc-lab-1 에서 호스트로 — sudo 없이 사용자 홈에
scp /tmp/keycloak-backup.sql test-server:~/keycloak-backup-2026-09-04.sql

# ② 보호된 경로로 옮기는 것은 호스트에서 사람이 친다 (비밀번호 프롬프트)
ssh -t test-server 'sudo install -m600 -o root -g root \
  ~/keycloak-backup-2026-09-04.sql /var/backups/keycloak-backup-2026-09-04.sql'

옮긴 파일이 온전한지는 크기를 양쪽에서 세서 비교한다.

wc -c /tmp/keycloak-backup.sql
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'

두 숫자가 다르면 전송이 잘린 것이다. 이것으로도 부족하다 — 호스트는 VM 두 대를 품고 있는 기계이므로 호스트가 죽으면 게스트도 덤프도 같이 간다. 진짜 요건은 「다른 기계」가 아니라 **「다른 장애 도메인」**이다.

항목 명령 돌아왔을 때
테이블 psql -c "select count(*) from pg_tables where schemaname='public'" 101
데이터 복구 대조의 -tAc 한 줄 백업 시점과 문자 단위로 동일
세션 select count(*) from offline_user_session 파괴 전과 같은 수
파드 kubectl -n keycloak-lab get pods -o wide 1/1 Running, RESTARTS 0
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
로그인 관찰 절의 토큰 발급 200 ← 이것이 진짜 판정
덤프 ls -l /tmp/keycloak-backup.sql 남겨 둔다. D-2 의 전제다

덤프는 지우지 않는다. D-2 가 이 파일을 전제로 한다.

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 이 절차에서 실제로 갈리는 곳이라고 적는다.

증상 원인 확인
복구가 1초 만에 끝났는데 데이터가 없다 exec-i 가 없다. 오류도 안 난다 데이터 대조. -i 를 붙여 다시
복구에서 already exists 가 쏟아진다 덤프를 --clean --if-exists 없이 떴다 grep -c '^DROP TABLE' /tmp/keycloak-backup.sql — 0 이면 그것이다
덤프 파일은 있는데 복구가 중간에 멈춘다 덤프가 잘렸다 tail -3dump complete 가 있는가
파괴했는데 정문이 계속 200 정상이다. realm 캐시가 서빙한다 토큰 발급으로 판정
psql: relation "realm" does not exist 파괴가 걸린 것이다 그게 주입 검증의 기대 출력이다
kubectl get endpoints 가 경고를 찍는다 v1.33+ 에서 deprecated get endpointslice -l kubernetes.io/service-name=...
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 밖에서 curl 로 친다
세션 개수가 나열한 행 수와 다르다 개수 쿼리에 offline_flag='0' 필터가 있다 같은 쿼리끼리 비교
백업이 0바이트다 pg_dump 가 인증에서 막혔다 -U keycloak -d keycloak 를 확인. 파일을 지우고 다시 뜬다
호스트에서 sudo 가 안 먹는다 호스트 sudo 는 비밀번호를 요구한다 ssh -t 로 붙어 사람이 친다

이 가이드에 스크립트가 없는 까닭을 가이드가 따로 한 절로 적는다. 원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸고, 그래서 증거 파일의 줄이 realms|clients|users|sessions|authclients = 2|15|2|3|1 처럼 이름표가 붙은 형태다. 그 형태는 사람이 치는 형태가 아니다. 그리고 이 실험에서는 스크립트가 특히 위험하다 — DROP SCHEMA 와 복구가 한 파일에 있으면 중간에서 멈췄을 때 무엇이 실행됐는지 알 수 없다. 파괴는 손으로 치고, 그 직후에 눈으로 확인하고, 복구도 손으로 친다. 각 단계 사이에 사람이 서 있어야 한다.

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

  • (observed) 파괴 직후 파드 네 줄과 RESTARTS 0, 백업의 시작: 14:59:30 · 완료: 14:59:30 · 크기: 394945 bytes (6956 줄), 테이블 수 101, offline_user_session 언급: 13COPY 블록에 붙은 세션 행, 파괴 시각 14:59:47DROP SCHEMA · CREATE SCHEMA, 남은 테이블 0, 파괴 뒤에도 정문과 app1 이 전부 HTTP 200 인 것, certs 200 · .well-known 500 · 토큰 발급 400, KEYCLOAK_JDBC_PING2WARN 두 줄, 복구의 시작: 15:00:12 · 완료: 15:00:13 · 오류 줄: 0, 복구 전후 대조 두 줄이 같은 것, +15초 well-known=200 토큰발급=200restarts=0, 복구된 세션 네 행, RTO = 41초.
  • (observed) A-3 이 잰 로그인 153건 중 4건 소실은 그 실험의 값이고, 여기서는 실제 RPO 의 두 번째 겹으로 인용만 한다.
  • (unknown) 남은 테이블을 세는 pg_tables 쿼리와 토큰 발급 curl 한 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 돌렸다. 덤프를 호스트로 옮기는 두 줄도 미검증이고, 이 실험대는 그 단계를 하지 않았다 — 덤프는 DB 와 같은 기계에 남았다.
  • 다섯째 컬럼은 지어내지 않았다. 증거 줄에는 authclients 까지 다섯 값이 있는데 해설 문서의 재현 절차에 그 쿼리가 없어서, 가이드도 이 문서도 넷만 센다.
  • 비밀은 옮기지 않았다 — 관리자 비밀번호는 명령 치환으로만 넘어가고, 길이를 재는 줄만 따로 있다. 세션 id 와 realm UUID 는 식별자라 그대로 적었다. 덤프 파일 자체가 realm·client·user·세션을 통째로 담고 있고, 그 파일을 어디에 두는가가 이 실험의 마지막 질문이다.
  • 이 실험이 확인하지 않은 것 — 백업 자동화, 보존 주기, 복구 리허설의 정기 실행. 이번엔 손으로 한 번 떴고 한 번 되돌렸다. 그것만 참이다.

D-2 — 태그를 되돌리는 계획이 언제 동작하고 언제 안 하는가

근거: d2-version-upgrade.md (738줄). 실행 기록은 2026-09-04 15:0015:26 KST(observed).

실측이 두 실행에서 나온다 — 처음 D-2 실행(15:0015:10, 역방향 26.0)과 후속 실행(15:2215:26, 26.7.3 정방향과 롤백)이다. 아래에서도 어느 쪽인지 매번 적는다.

이 실험이 가르는 것

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

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

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

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

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

★ 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 에 남아 있었기 때문이다. 한 파드가 못 뜨는데 밖에서는 아무 일도 없어 보인다. 실패한 기동은 스키마도 안 건드렸다(210 그대로).

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

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

판단 기준은 버전 번호가 아니라 행 수의 변화다. 이 가이드는 그 숫자를 재는 법부터 가르친다.

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

전제와 되돌리기

  • D-1 이 끝나 있고 덤프가 손에 있다. 이 실험의 되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
  • A-8 — 롤링 재시작이 무중단이라는 것이 전제다.
  • 네임스페이스는 keycloak-lab 이다.
  • jq 는 이 실험대 어디에도 없고, 이 가이드는 jq 를 쓰지 않는다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 가용성 폴링용, 하나는 관찰용.

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

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

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

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

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

여기서 안 재면 나중에 다시 못 재는 값이 하나 있다 — 업그레이드 databasechangelog 행 수다. 올린 뒤에는 그 값이 지워지고, 「롤백해도 되는가」를 판정할 근거가 사라진다.

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

백업이 먼저다. 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.txt(첫 실행) · followup/01-d2-forward-upgrade.txt(후속 실행)

  백업: 396333 bytes
  백업: 395375 bytes

크기와 taildump complete 를 본다. 이 파일이 없으면 이 실험을 하지 않는다.

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

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

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"

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

★ 마이그레이션 수가 이 실험의 전부다.

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

세션도 센다.

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

실측(observed)

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

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

클러스터 뷰는 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)]

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

새 태그가 실제로 있는지도 확인한다. 처음 한 번은 그대로 본다.

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 보다 새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.

가용성 대조군을 먼저 띄운다. 주입 중에 나온 000 한 건을 해석하려면 평시 오류율을 알아야 한다. 1초 간격으로 150회, 뒤에서 돌린다.

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

그만 재려면 kill %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 이 이 값 때문이다.

주입

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

2 new pods have been updated 를 본다. 87초 걸렸다. 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

로그가 말하는 판을 본다. 이미지 태그와 다르면 태그가 재사용된 것이다 — 같은 태그가 다른 내용을 가리키는 경우다.

관찰

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 인지 본다. 정방향 업그레이드는 무중단이었다. 87회 요청이 전부 200 이고, 파드가 하나씩 갈리는 동안 남은 파드가 받았다. 「무중단」은 관측 해상도에 달려 있다 — 이건 1초 간격·3초 타임아웃으로 잰 결과이고, 더 촘촘히 보면 더 보일 수 있다. 실제로 D-4 에서 0.2초 간격으로 재니 다른 것이 보였다.

그림으로도 남아 있다 — 증거의 d2-upgrade-window.png 다. cluster_size2 → 1 → 2 를 두 번 반복하고 파드별 up 시계열이 끝나고 새 시계열이 시작된다. 2 → 1 → 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 은 패치 릴리스라 스키마가 그대로였다. 그래서 롤백이 가능하다는 가설이 섰고, 바로 시험한다.

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 이다.

전환 순간의 000 한 번은 오해하기 쉽다.

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

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

★ 여기부터는 일부러 실패시킨다. 위까지로 이 실험의 판정은 끝났고, 이 대조는 「행 수가 바뀌었을 때」가 실제로 어떤 모양인지 보려는 것이라 가이드가 선택으로 둔다. 되돌리기는 태그 한 줄이고 먼저 읽는다.

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) → Error → CrashLoopBackOff 를 오가는 것과, keycloak-0 이 내내 1/1 인 것이다. Running 인데 0/1 인 상태를 「떴다」로 읽지 않는다 — 컨테이너 프로세스는 살아 있지만 readiness 를 통과하지 못한 것이고 곧 죽는다. Ctrl-C 로 빠져나온다.

왜 실패했는지 물어본다.

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

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

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  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 의 조합」이라고 썼는데, 여기서는 그 조합이 잘못된 배포를 절반에서 멈춰줬다.

실패한 기동이 스키마를 건드렸는지 세 번째로 같은 명령을 친다.

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

실측(observed)

  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 복구 + 태그 되돌리기

복구와 원상복구 확인표

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 도 같은 벽에 부딪힌다.

항목 명령 돌아왔을 때
태그 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 남겨 둔다

막히면

증상 원인 확인
rollout status 가 안 끝난다 첫 파드가 안 뜬다. StatefulSet 이 기다린다 다른 터미널에서 get pods -w
파드가 Running 인데 0/1 프로세스는 살아 있고 readiness 미통과 logs 를 본다. 「떴다」로 읽지 않는다
로그가 안 나온다 파드가 이미 죽었다 logs keycloak-1 --previous
업그레이드 전 행 수를 안 적었다 그 값은 이제 DB 에 없다 덤프에서 복원한다 — 아래
비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 의 덤프가 여기서 한 번 더 값을 한다.

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

  • (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). 메이저 업그레이드를 할 때 이 실험을 다시 한다고 가이드는 적는다.
  • 이 실험이 재지 않은 것 — 마이그레이션 도중에 죽으면 어떻게 되는지, 대규모 마이그레이션에 걸리는 시간. 데이터가 작아 순식간이라 잴 것이 없었다.

D-3 — Secret 이 어디까지 감춰지는가

근거: d3-secret-management.md (562줄). 실행 기록은 2026-09-04 15:0515:06 KST(observed).

이 실험이 가르는 것

「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이라고 가이드는 적는다. 이 실험은 그 문장이 실제로 무엇을 막아 주는지를 네 경로로 나눠 판정한다. 가이드는 예측 칸을 넷 다 ? 로 비워 두고 시작한다.

# 경로 누가 쓰나
쿠버네티스 API (get secret) 클러스터에 접근하는 사람
노드 디스크의 저장 파일 디스크·백업·스냅샷을 얻은 사람
파드 안의 프로세스 exec 권한이 있는 사람, 크래시 덤프
RBAC 권한이 없는 주체

판정에 앞서 개념 둘을 가른다.

목적 되돌리기
인코딩 (base64) 바이너리를 텍스트로 안전하게 옮기기 키 없이 누구나
암호화 키 없이는 못 읽게 하기 키가 있어야

Secret 이 base64 를 쓰는 까닭은 감추려는 것이 아니라 YAML 에 임의 바이트를 담기 위해서다. 그런데 kubectl describe 가 값을 가려서 보여 주므로 「가려져 있구나」라는 인상이 남는다. 이 실험은 그 인상과 사실 사이의 거리를 잰다.

가이드의 「이 가이드가 끝나면」 표는 일곱을 적는다 — describe14 bytes 만 보여 주는 것, 같은 값이 한 줄로 평문이 되는 것, 저장소 암호화가 꺼져 있는 것, 노드 디스크의 저장 파일 안에 평문이 있는 것, grep0 을 돌려주는데도 안전하지 않은 것, 파드 안에서는 그냥 환경변수인 것, RBAC 은 실제로 막는 것.

다섯째가 이 편의 요점이다. 같은 파일에 같은 명령을 걸었는데 키에 따라 20 이 나왔고, 0 을 「없다」로 읽으면 틀린다는 것을 가이드가 따로 한 절로 적는다.

전제와 되돌리기

  • 명령은 kc-lab-1 에서 친다. k3s 서버의 저장 파일도 이 노드에 있고, 그래서 ②를 여기서 칠 수 있다.
  • 게스트(kc-lab-1/kc-lab-2)의 sudo무암호다. 호스트와 다르다.
  • 네임스페이스는 keycloak-lab 이다.
  • jq 는 이 실험대 어디에도 없고, 이 가이드는 jq 를 쓰지 않는다.
  • B-6(key 회전)와 B-7(쿠키 비밀 회전)을 이미 했다면 이 실험의 결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이라고 가이드는 적는다.

전제와 본문이 여기서도 어긋난다. 가이드의 전제는 「kubectlsudo 로 쓴다」인데 본문의 kubectl 줄에는 sudo 가 없고 k3s·ls·grep 에만 붙어 있다. 아래는 본문의 형태를 그대로 옮긴다.

★ 이건 비밀을 화면에 띄우는 실험이다. 몇 개의 명령은 비밀번호를 터미널에 그대로 찍는다. 그게 결론이라 피할 수 없지만, 그 값은 스크롤백·화면 공유·터미널 로그에 남는다. 가이드는 그래서 셋을 정해 두고 시작한다.

  • 남의 진짜 비밀은 길이(wc -c)와 키 이름까지만 본다.
  • 값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다. 지워도 되는 값이므로 찍어도 된다.
  • 실측으로 실린 값들은 이 저장소의 매니페스트와 문서에 이미 적혀 있는 실험대 전용 값이고, 값 이름에 change-me 가 들어 있는 까닭이 그것이다.

파괴적인 단계가 없는 편이다. 만드는 것은 카나리아 Secret 하나뿐이고 복구 절에서 지운다. 전 구간 약 15분. 되돌리기는 한 줄이다.

kubectl -n keycloak-lab delete secret d3-canary

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

무엇이 있는지부터 본다. 카나리아를 심기 전에 목록과 describe 화면을 봐 둬야, 심은 뒤의 describe같은 화면이라는 것이 보인다.

Secret 목록 → describe 가 감추는 화면 → 키 이름만 → 길이만
kubectl -n keycloak-lab get secret

실측(observed) — 01-base64-not-encryption.txt

  bff-secrets                Opaque       keys=1
  keycloak-lab-secrets       Opaque       keys=2
  oauth2-proxy-secrets       Opaque       keys=3

이 실험대는 스크립트로 정리해 찍었다(observed). 따라 하는 사람은 위 명령을 그대로 치고, 그러면 NAME · TYPE · DATA · AGE 네 칸이 나온다. DATA 열이 실측 줄의 keys= 에 해당한다.

이름과 DATA 열(키 개수)을 본다. TYPEOpaque 인 것도 본다 — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는 뜻이지 감춘다는 뜻이 아니다.

kubectl -n keycloak-lab describe secret bff-secrets

실측(observed) — 01-base64-not-encryption.txt

  Type:  Opaque

  Data
  ====
  KEYCLOAK_CLIENT_SECRET:  14 bytes

키 이름과 바이트 수만 나온다. 값이 없다. 이 화면이 「Secret 은 감춰진다」는 인상의 출처다. describe일부러 값을 안 찍는데, 그건 describe 라는 명령의 동작이지 저장이나 전송의 성질이 아니다. 이 구별이 이 편 전체의 축이다.

남의 비밀을 다룰 때의 기본 자세를 여기서 배워 둔다. 키 이름과 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
  | tr ',' '\n' | grep -o '"[A-Z_]*"'

형태(모양은 observed)

"KC_BOOTSTRAP_ADMIN_PASSWORD"
"POSTGRES_PASSWORD"

jq 가 없어서 trgrep 으로 자른다. D-2 가 레지스트리 태그 목록을 자를 때 쓴 것과 같은 수법이고, 이 실험대에 jq 가 없다는 전제가 여기서도 형태를 정한다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c

형태(모양은 observed)

22

숫자 하나. 값이 화면에 없다. 「Secret 이 제대로 들어갔는가」를 확인하는 데는 길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개 **「비었는가 아닌가」**이지 값 자체가 아니다.

wc -c 는 개행까지 세므로 base64 -d 결과에 개행이 없으면 실제 길이와 같다. 값이 비었으면 0 이 나오고, 0 은 「Secret 은 있는데 그 키가 비었다」는 뜻이며 배포 실패의 흔한 원인이라고 가이드는 적는다.

주입

여기부터 상태가 바뀐다. 바뀌는 것은 Secret 하나뿐이다.

왜 카나리아를 쓰는지가 먼저다. 관찰 절에서 저장 파일 안을 grep 해야 하는데, 그러려면 찾을 문자열을 알고 있어야 한다. 진짜 비밀번호를 grep 인자로 쓰면 그 값이 셸 히스토리와 프로세스 목록(ps 로 다른 사용자에게도 보인다)에 남는다. 그래서 찾아도 아무 피해가 없는 값을 하나 심는다. 실험 대상이 값 자체가 아니라 경로이므로 이렇게 해도 결론은 같다.

kubectl -n keycloak-lab create secret generic d3-canary \
  --from-literal=CANARY=d3-canary-zq7v-do-not-use

형태(모양은 observed)

secret/d3-canary created

created 를 본다. 이미 있으면 AlreadyExists 가 나오고, 그럼 지우고 다시 만든다.

이 값은 아무 데도 쓰이지 않는다. 어떤 파드도 참조하지 않으므로 지워도 아무것도 안 깨진다. 값에 do-not-use 를 넣어 둔 까닭은 나중에 저장 파일 어딘가에서 이 문자열을 다시 만났을 때 무엇인지 알아보기 위해서다.

주입 검증

kubectl -n keycloak-lab get secret d3-canary
kubectl -n keycloak-lab describe secret d3-canary

형태(모양은 observed)

Data
====
CANARY:  25 bytes

이 화면은 이 실험대가 본 적이 없다(unknown) — 카나리아를 심지 않고 실제 값으로 쟀기 때문이다. 25--from-literal 이 개행을 붙이지 않으므로 d3-canary-zq7v-do-not-use 의 글자 수를 그대로 센 것이다. 26 으로 적혀 있던 것을 고쳤다 — 자기 리터럴과도 맞지 않는 수였다.

여기서도 describe 는 바이트 수만 준다. 주입 전에 본 화면과 같다. 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다 — 저장 파일에서 이 문자열을 찾았을 때 그것이 무엇인지 아는 사람이 당신 하나이기 때문이다.

관찰

네 경로를 하나씩 연다. ①은 API, ②는 노드 디스크, ③은 파드 안, ④는 RBAC 이다.

① API — 한 줄로 읽힌다. 값을 아는 카나리아로 먼저 해 본다.

kubectl -n keycloak-lab get secret d3-canary \
  -o jsonpath='{.data.CANARY}' | base64 -d; echo

형태(모양은 observed)

d3-canary-zq7v-do-not-use

주입 절에서 심은 값이 그대로 나온다. 같은 명령이 실제 비밀에도 그대로 듣고, 원래 실행이 네 개를 뽑은 결과가 증거 파일에 있다.

실측(observed) — 01-base64-not-encryption.txt. 값은 옮기지 않는다 — 네 줄 전부 <Secret>/<키> = <평문> 꼴로 나왔고, 값 자리에 있던 것은 이름에 change-me 가 들어간 실험대 전용 문자열이다. 원문은 증거 파일에 둔다

  keycloak-lab-secrets/POSTGRES_PASSWORD            = <평문 22자>
  keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD  = <평문>
  bff-secrets/KEYCLOAK_CLIENT_SECRET                = <평문 14자>
  oauth2-proxy-secrets/COOKIE_SECRET_A              = <평문>

실험대의 모든 비밀이 명령 네 줄로 나온다. describe14 bytes 라고 했던 그 키의 값이 정확히 14자다 — 같은 값을 명령 둘이 다르게 보여 주고 있었던 것이고, 감춘 쪽은 describe 뿐이다.

①은 막지 않는다. base64 는 인코딩이고 base64 -d 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 누가 이 명령을 칠 수 있는가이며, 그건 ④로 넘어가는 질문이다.

이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다고 가이드는 적는다. 카나리아로 한 번 확인했으면 기제는 같고, 진짜 비밀은 앞에서 한 길이 확인으로 충분하다.

② 저장소 — 노드 디스크에 평문이 있다. 암호화 설정부터 본다.

sudo k3s secrets-encrypt status

실측(observed) — 02-at-rest.txt

  Encryption Status: Disabled, no configuration file found

Disabled, 그리고 no configuration file found 를 본다. 설정 파일이 아예 없다 — 껐다기보다 켠 적이 없다는 뜻이고, 이게 기본값이다.

sudo ls -l /var/lib/rancher/k3s/server/db/

실측(observed) — 02-at-rest.txt

  total 23336
  drwx------ 2 root root     4096 Sep  2 09:12 .
  drwx------ 8 root root     4096 Sep  4 03:23 ..
  -rw-r--r-- 1 root root 13078528 Sep  4 06:05 state.db
  -rw-r--r-- 1 root root    32768 Sep  4 06:06 state.db-shm
  -rw-r--r-- 1 root root 10769712 Sep  4 06:06 state.db-wal

파일이 이다.

파일 무엇인가
state.db 본체
state.db-wal 아직 본체에 합쳐지지 않은 최근 쓰기
state.db-shm 공유 메모리 인덱스

k3s 는 etcd 대신 SQLite 를 쓴다. 「저장소(at rest)」의 자리는 같다 — etcd 를 쓰는 클러스터라면 여기가 etcd 의 데이터 디렉터리다. -wal 이 10MB 나 되는 것을 봐 둔다 — 방금 만든 카나리아는 아직 본체에 없을 가능성이 높고, 그게 바로 아래에서 함정이 된다.

★ 파일 안을 찾아본다. 카나리아부터다.

sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal

이 실험대는 카나리아 대신 실제 값으로 쟀다(observed). 위의 카나리아 형태는 가이드가 미검증으로 표시했다(unknown). 실제로 나온 결과가 이것이다.

실측(observed) — 02-at-rest.txt

=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
  state.db 안의 평문 일치: 2
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
  client secret 평문 등장 횟수: 0

두 줄의 값이 다르다. 20 이다. 같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다. 가이드가 이 절에서 제일 중요하다고 적은 문장이 그다음에 온다.

grep0 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다.

2 가 나온 순간 ②의 답은 이미 정해졌다 — 저장 파일에 평문이 있다. 0 이 나온 키를 두고 「그건 안전한가 보다」라고 읽으면, 같은 파일에 평문이 들어 있는 것을 이미 본 뒤에 그러는 것이다.

0 이 나왔을 때 다음에 볼 곳을 가이드가 적어 두긴 했는데, 이 실험은 원인을 가리지 않았다(unknown). 아래 두 줄과 표가 전부 미검증이다.

가이드는 이 두 줄의 인자에 클라이언트 비밀 평문을 적어 두었다. 값은 옮기지 않는다 — 그리고 가이드 자신이 주입 절에서 「진짜 비밀번호를 grep 인자로 쓰면 셸 히스토리와 ps 에 남는다」고 적었으므로, 따라 하는 사람은 카나리아 문자열로 친다.

sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
sudo sh -c 'strings /var/lib/rancher/k3s/server/db/state.db | grep -c d3-canary-zq7v-do-not-use'

★ 게스트에 strings 가 없다. binutils 가 안 깔려 있어 그대로 치면 sh: 1: strings: not found 로 끝난다(2026-09-17, observed). sudo apt install -y binutils 로 깔거나 strings 없이 grep -c 만 써도 같은 수가 나온다 — 둘 다 쳐서 확인했다(observed).

★ 그리고 삭제 후를 쟀다(observed). 그때까지 이 실험이 안 밟은 단계다.

             state.db   state.db-wal
삭제 전         0            1
삭제 뒤         0            7

지운 뒤에 오히려 늘었다. kubectl delete secret 은 API 에서 그 객체를 없앨 뿐이고, 그 삭제 자체가 같은 값을 담은 레코드를 -wal 에 더 쓴다. 비밀이 유출됐을 때 해야 하는 일이 삭제가 아니라 회전인 까닭이 이 표에 그대로 있다.

왜 안 나올 수 있나 확인
아직 -wal 에만 있다 -wal 을 같이 grep
값이 페이지 경계를 넘어 잘렸다 strings 로 한 번 더
그 키가 그 시점에 없었다 get secret 으로 존재 확인

grep -c 는 바이너리 파일에도 듣는다. 평소의 grep 은 바이너리를 만나면 Binary file ... matches 한 줄만 찍고 내용을 안 보여 주는데, -c 는 개수만 세므로 그대로 숫자가 나온다. 값 자체를 화면에 안 띄운다는 점에서도 이 형태가 맞다 — 여기서 궁금한 것은 「있는가」이지 「무엇인가」가 아니다.

그래서 무엇이 위험한지를 가이드가 넷으로 적는다.

노드 디스크를 얻으면 전 클러스터의 비밀
노드 백업/스냅샷 같은 것을 복사한다
A-4 에서 본 local-path PVC 같은 디스크에 있다
D-1 의 덤프 같은 기계에 뒀다면 거기도 같이

D-1 에서 「덤프를 같은 장애 도메인에 두면 백업이 아니다」라고 했는데, 여기서는 「노드 디스크 하나가 모든 비밀」이다. 백업을 잘 챙길수록 비밀도 잘 복사된다.

k3s 는 --secrets-encryption 플래그로 켤 수 있다. 지금은 안 켜져 있고 이 가이드는 켜지 않는다 — 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 이 실험대에서 시험하지 않았다(unknown).

③ 파드 안 — 평범한 환경변수다. 어느 파드를 볼지 먼저 정한다.

kubectl -n keycloak-lab get pods -l app=bff

이 실험대는 파드 이름을 직접 지정했다(observed). 따라 하는 사람은 아래 형태를 치고, 가이드가 그 줄을 미검증으로 표시했다(unknown).

kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'

실측(observed) — 02-at-rest.txt. 값은 옮기지 않는다 — 두 줄 다 <환경변수>=<평문> 꼴이고, 오른쪽에 있던 것이 ①에서 API 로 뽑은 바로 그 값이다

  KEYCLOAK_CLIENT_SECRET=<평문 14자>
  BFF_DB_PASSWORD=<평문 22자>

env 한 번이면 나온다. 그리고 클라이언트 비밀은 ①에서 API 로 뽑은 값과 같다 — 두 경로가 같은 평문에 닿는다.

execdeploy/bff 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라 친다.

kubectl -n keycloak-lab get pod -l app=bff \
  --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo

같은 파드 안의 다른 프로세스도 본다. 이게 「환경변수」의 진짜 성질이다. 아래도 가이드가 미검증으로 표시한 형태다(unknown).

kubectl -n keycloak-lab exec deploy/bff -- \
  sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'

같은 값이 나오는가를 본다. /proc/<pid>/environ 은 그 프로세스의 환경변수를 그대로 담고 있고, 같은 UID 의 아무 프로세스나 읽는다.

새는 경로
kubectl exec 권한이 있는 사람 바로 본다
같은 파드의 다른 프로세스 /proc/<pid>/environ
크래시 덤프 · 오류 리포트 환경변수를 함께 담는 도구가 많다
자식 프로세스 상속된다

볼륨으로 마운트하면 이 중 몇 가지가 줄어든다 — 파일 권한으로 제한할 수 있고, 환경변수 덤프에 안 들어간다.

volumeMounts:
  - name: secrets
    mountPath: /etc/secrets
    readOnly: true

줄어드는 것이지 없어지는 것이 아니다. exec 권한이 있으면 파일도 읽는다.

④ RBAC — 유일하게 막는다.

kubectl auth can-i get secrets -n keycloak-lab \
  --as=system:serviceaccount:keycloak-lab:default

실측(observed) — 02-at-rest.txt

  default SA: no

no 한 단어다. 기본 서비스계정은 Secret 을 못 읽는데, 명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문이다. RBAC 은 기본이 거부이고 Role 을 붙여야 할 수 있게 된다.

어떤 권한이 있는지 통째로 보는 형태도 가이드에 있고, 미검증이다(unknown).

kubectl auth can-i --list -n keycloak-lab \
  --as=system:serviceaccount:keycloak-lab:default
kubectl -n keycloak-lab get role,rolebinding

네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다. 그러므로 실질적인 방어선은 「누가 get secrets 를 할 수 있는가」이며, 관리자 권한을 가진 사람에게는 아무 방어가 없다. A-0 의 관측 스택에서 nodes/proxy 서브리소스를 따로 줘야 했던 것처럼 Secret 접근도 리소스 단위로 나눌 수 있다고 가이드는 덧붙인다.

네 경로를 한 표로 모으면 이렇다.

# 경로 감춰지는가 무엇이 뚫나
get -o jsonpath | base64 -d 아니다 클러스터 접근 권한
describe secret 값을 숨긴다 그래서 안전하다고 착각한다
저장 파일(state.db) 아니다. 암호화 꺼짐 노드 디스크·백업·스냅샷
파드 안 아니다. 평범한 환경변수 exec · /proc · 크래시 덤프
RBAC 막는다 관리자 권한

「Secret 이니까 안전하다」는 네 가지 중 하나(RBAC)만 맞다. 그리고 ②·③ 은 쿠버네티스 API 를 한 번도 거치지 않고 평문에 닿는다.

그래서 무엇을 해야 하는가를 가이드가 다섯 단계로 적고, 이 실험대는 그중 아무것도 하고 있지 않다고 같은 표에 적는다.

단계 얻는 것 이 실험대
① 매니페스트에서 값을 빼고 .example 만 커밋 git 유출을 막는다 안 함
k3s --secrets-encryption 활성화 노드 디스크 유출을 막는다 안 함 (unknown)
③ 환경변수 대신 볼륨 마운트 프로세스·덤프 유출을 줄인다 안 함
SealedSecret / 외부 KMS 매니페스트에 암호문만 남는다 안 함
RBAC 최소화 유일하게 이미 동작하는 방어선을 좁힌다 기본값 그대로

실험 목적으로는 의도적이지만 그 사실을 기록해 두지 않으면 그대로 운영에 옮겨간다고 가이드는 적는다. 값 이름에 change-me 를 넣어 둔 것이 그 최소한의 표시다.

복구와 원상복구 확인표

kubectl -n keycloak-lab delete secret d3-canary
kubectl -n keycloak-lab get secret

형태(모양은 observed)

secret "d3-canary" deleted

주입 전에 본 목록으로 돌아왔는가를 본다. 세 개다.

★ 지웠다고 파일에서 없어지지는 않는다. 아래는 미검증이고, 이 실험은 삭제 후를 재지 않았다(unknown).

sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal

0 이 나오면 「이 시점에 이 형태로는 안 보인다」이고, 0 이 아니면 지운 Secret 의 평문이 아직 파일에 남아 있는 것이다. 어느 쪽이든 관찰 절의 결론은 안 바뀐다 — 판정은 이미 2 에서 났다.

데이터베이스 파일은 지운 행의 자리를 즉시 0으로 덮어쓰지 않는다. 「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는 다른 사건이고, 비밀이 유출됐을 때 실제로 해야 하는 일이 삭제가 아니라 **회전(rotation)**인 까닭이 여기 있다 — B-6·B-7 의 주제다.

항목 명령 돌아왔을 때
카나리아 kubectl -n keycloak-lab get secret d3-canary NotFound
Secret 목록 kubectl -n keycloak-lab get secret 세 개
파드 kubectl -n keycloak-lab get pods 전부 Running (아무것도 안 건드렸다)
터미널 history | tail -40 비밀번호가 찍힌 줄이 어디까지 남았는지 본다

이 실험의 진짜 뒷정리는 스크롤백이다. ①을 실제 비밀로 쳤다면 그 값이 터미널 버퍼와 셸 히스토리에 남아 있다. 실험대 값이라 지금은 상관없지만, 같은 절차를 운영에서 하면 그게 유출 경로가 된다.

막히면

증상 원인 확인
grep0 인데 안전하다고 읽힌다 0 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」 -walstrings 로 한 번 더
grepBinary file matches 만 찍는다 바이너리 파일이다 -c 를 쓴다(개수만). 값을 안 띄우는 이점도 있다
k3s secrets-encrypt 가 없다 서버 노드가 아니다 kc-lab-1(control-plane)에서 친다
state.dbPermission denied root 전용 디렉터리 게스트 sudo 는 무암호다. sudo 를 붙인다
exec deploy/bff 가 실패한다 파드가 종료 중이거나 여럿이다 --field-selector=status.phase=Running 으로 이름을 고른다
auth can-iyes 라고 한다 그 SA 에 Role 이 붙어 있다 get rolebinding -o wide 로 누가 줬는지 본다
값이 0 bytes 로 나온다 Secret 은 있는데 키가 비었다 describe 의 바이트 수를 본다 — 배포 실패의 흔한 원인
비밀번호를 화면에 찍어 버렸다 ①을 실제 값으로 쳤다 스크롤백·히스토리를 지우고, 운영이면 회전한다

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

  • (observed) Secret 세 개의 목록과 keys=1 · keys=2 · keys=3, KEYCLOAK_CLIENT_SECRET: 14 bytes, 카나리아의 CANARY: 25 bytes, Encryption Status: Disabled, no configuration file found, db/ 의 파일 셋과 크기 (13078528 · 32768 · 10769712), 저장 파일에서 평문 일치 2 와 같은 명령의 다른 키 0, 파드 안 env 두 줄, default SA: no.
  • 비밀은 길이·존재·키 이름만 적는다 — API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 평문이 그대로 찍힌 줄이라 이 문서로 옮기지 않았다. 키 이름과 14 bytes · 22 라는 길이, 그리고 값 이름에 change-me 가 들어 있다는 모양까지가 옮긴 전부다. 원문은 증거 파일에 그대로 있다.
  • 카나리아 값은 그대로 적었다d3-canary-zq7v-do-not-use 는 이 실험이 grep 인자로 쓰려고 직접 만든 문자열이고 복구 절에서 지운다. 값을 알아야 명령이 성립하므로 명령과 함께 남겼다. 찾아도 아무 피해가 없다는 것이 이 값의 목적이다. 그리고 가이드가 0 이 나온 키를 다시 찾을 때 쓴 두 줄에는 클라이언트 비밀 평문이 인자로 적혀 있었는데, 그 인자를 카나리아 문자열로 바꿔 적었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.
  • (unknown) 카나리아를 저장 파일에서 찾는 두 줄, 0 이 나왔을 때 -wal·strings 로 다시 보는 두 줄, exec deploy/bff 형태, /proc/1/environ 을 읽는 줄, auth can-i --list, 삭제 뒤에 다시 grep 하는 두 줄. 가이드가 전부 미검증으로 표시했다. 이 실험대는 카나리아 대신 실제 값으로 쟀고, 삭제 후는 재지 않았다.
  • 이 실험이 확인하지 않은 것 — k3s --secrets-encryption 을 켠 뒤의 상태. 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 여기서는 시험하지 않았다. 0 이 나온 키의 원인도 가리지 않았다. 볼륨 마운트·SealedSecret·외부 KMS 도 전부 안 했다.

D-4 — 갱신은 성공했는데 왜 옛 인증서가 나가는가

근거: d4-certificate-renewal.md (1163줄). 실행 기록은 2026-09-04 감시 구간 08:10:5109:02 UTC(observed).

이 실험이 가르는 것

인증서 갱신 자동화의 확인은 대개 두 줄에서 끝난다 — 타이머가 도는가, 로그가 SUCCESS 인가. 이 실험은 그 뒤를 묻는다. 갱신된 인증서를 누가 서버에 읽히는가.

   ① certbot 이 새 인증서를 받는다        ← 타이머가 책임진다
   ② 파일이 디스크에 써진다               ← certbot 이 한다
   ③ nginx 가 그 파일을 다시 읽는다        ← ★ 누가?

「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다. ③ 을 하는 것이 아무것도 없으면 ①②는 매번 성공하고 사용자는 만료된 인증서를 본다.

그리고 이 결함은 88일 동안 보이지 않는다. 타이머는 매일 두 번 돌고 매번 SUCCESS 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 기회가 없고, 발현하는 날의 증상은 인증서 만료다 — 그날에도 로그는 SUCCESS 라고 적혀 있다.

부수 질문이 하나 더 있다. ③ 을 실제로 하면, 즉 nginx 를 reload 하면 진행 중이던 요청은 어떻게 되는가. 「nginx reload 는 무중단」이라고 다들 말하지만 이 실험대는 그것을 재 본 적이 없었고, 재 보지 않은 명제는 쓰지 않는다는 규칙에 따라 유보해 뒀다고 가이드는 적는다. 여기서 잰다.

가이드의 「이 가이드가 끝나면」 표는 여덟을 적는다 — 이름 세 개가 한 인증서에 들어 있는 것, 체인이 4단계이고 Verify return code: 0 인 것, 타이머는 SUCCESS 인데 reload 를 부르는 것이 아무 데도 없는 것, nginx 워커가 22.4시간째 그대로인 것, 두 기계 시계가 106초 어긋나 있는 것, 갱신에 성공했는데 밖에서 본 일련번호가 안 바뀌는 것, 사람이 reload 한 그 순간 바뀌는 것, reload 가 정말 무중단인 것.

전제와 되돌리기

  • 이 실험만은 클러스터가 아니라 호스트를 본다. kubectl 은 한 번도 안 쓴다.
  • 관찰은 당신 개발 머신(dev)에서 한다. 밖에서 본 것이 이 실험의 답이고, dev 의 시계가 이 실험대에서 유일하게 정확한 시계이기 때문이다.
  • 호스트(test-server)에는 ssh test-server 로 붙는다.
  • 호스트의 sudo 는 비밀번호를 요구한다. 게스트(kc-lab-1/2)는 무암호지만 호스트는 다르다. 그래서 몇 단계는 사람이 직접 쳐야 한다.
  • 이 호스트의 certbot 은 5.7.0, 플러그인은 dns-cloudflare · manual · null · standalone · webroot 다. nginx 플러그인은 없다.

★ 이건 진짜 인증서를 발급하는 실험이다. certbot renew --force-renewal되돌릴 수 없다. 새 인증서가 실제로 발급되고 Let's Encrypt 의 발급 한도(주당 중복 인증서 5장)를 한 장 깎는다. 그래서 순서가 정해져 있다.

  • 먼저 --dry-run 으로 절차만 확인한다.
  • 강제 갱신은 이 실험 전체에서 한 번만 쓴다.
  • 그 한 번을 헛되게 쓰지 않도록 대조군을 먼저 잡는다.

옛 인증서는 무효가 되지 않는다. 만료 전까지 그대로 유효하므로 서비스가 깨지지는 않는다. 밖에서 보이는 인증서를 디스크와 다시 맞추는 것은 복구 절의 nginx -s reload 한 줄이고, 감시 셋을 멈추는 것도 한 줄이다.

touch /tmp/d4-stop

★ 시각 표기 규약이 이 편에만 따로 있다. 이 실험은 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀렸고, 그래서 가이드는 모든 시각에 어느 시계인지를 붙인다. 아래 절들도 그 표기를 그대로 쓴다.

표기
08:58:52 (dev) 개발 머신 시계. 외부 기준과 일치한다
17:22:13 KST (ts) test-server 시계. 106초 빠르다
08:20:27 (실제) 보정한 값

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

사람이 칠 수 있는 명령은 사실상 한 번뿐이다(강제 갱신). 그 한 번을 헛되게 쓰지 않으려면 주입 전에 잴 것을 전부 재 둬야 한다. 여덟 칸이고, 뒤로 갈수록 이 실험만의 것이 된다.

인증서 → 체인 → 이름 → 타이머 → ★ 누가 reload 하나 → 워커 PID → ★ 시계 → 대조군

처음 한 번은 협상 과정을 통째로 읽는다.

curl -v https://auth.hyeonworks.com/realms/master -o /dev/null

* 로 시작하는 줄에서 TLS 판·subject·issuer·SSL certificate verify ok. 를 본다. TLS 에서 막힐 때 봐야 할 것이 전부 여기 있다. 값만 뽑는 형태부터 배우면 인증서가 왜 거절됐는지 물어볼 데가 없어진다.

이제 인증서 자체를 뜯는다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -serial -dates -subject -ext subjectAltName

실측(observed) — 01-certificate-state.txt

subject=CN = auth.hyeonworks.com
issuer=C = US, O = Let's Encrypt, CN = YE2
notBefore=Sep  3 00:47:23 2026 GMT
notAfter=Dec  2 00:47:22 2026 GMT
X509v3 Subject Alternative Name:
    DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com

serial 을 적어 둔다. 이 값이 바뀌는 것이 「새 인증서를 서빙한다」의 정의이고, 감시 전체가 이 값을 본다. notAfter 는 만료이고, SAN 이 세 줄이며 와일드카드가 아니라는 것도 같이 본다.

notBefore 를 발급 시각으로 읽지 않는다. Let's Encrypt 는 notBefore정확히 한 시간 백데이트한다 — 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다. 그렇다고 여기에 한 시간을 더한 값을 발급 시각으로 그대로 쓰지도 않는다. 이 실험대의 두 인증서에서 CT 로그의 SCT 가 그보다 약 89초 앞선다.

시각의 외부 기준이 필요하면 SCT 를 본다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -ext ct_precert_scts | grep Timestamp

실측(observed) — 07-renewal-hook-missing.txt

    Log ID: C2:31:7E:57:...:52:CD   Timestamp: Sep  3 01:45:53.183 2026 GMT
    Log ID: 46:AF:86:3D:...:50:5F   Timestamp: Sep  3 01:45:53.352 2026 GMT

Timestamp 두 개를 본다. CT 로그가 자기 시계로 찍은 시각이고 이 실험대의 어느 기계와도 무관한 제3의 기준이다. 시계가 어긋난 것이 드러났을 때 이 값이 심판이 된다.

체인이 완전한지는 따로 본다. 여기서 흔한 실수 하나가 갈린다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'

실측(observed) — 01-certificate-state.txt

 0 s:CN = auth.hyeonworks.com
 1 s:C = US, O = Let's Encrypt, CN = YE2
 2 s:C = US, O = ISRG, CN = Root YE
 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
Verify return code: 0 (ok)

번호가 몇까지 가는가와 마지막 줄을 본다. 이 실험대는 4단계로 정상이다.

파일 내용 nginx 에 넣으면
cert.pem 리프만 일부 클라이언트에서 검증 실패
fullchain.pem 리프 + 중간 정상

단계가 1개면 cert.pem 을 쓴 것이다. 브라우저는 중간 인증서를 캐시하거나 AIA 로 보완해서 대개 정상으로 보이고, 캐시가 없는 클라이언트(모바일 앱, curl, 다른 서버)에서만 깨진다. 그래서 발견이 늦다. 이 명령이 유일하게 믿을 수 있는 판정이라고 가이드는 적는다.

이름 셋이 한 장인지도 본다.

for H in auth app1 app2; do
  echo "-- $H.hyeonworks.com"
  echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
    | openssl x509 -noout -serial
done

세 일련번호가 서로 같은가만 본다. 값 자체는 의미가 없다. 같으면 SAN 하나에 이름 셋이 든 한 장이고 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 훅도 장마다 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다.

이 제약이 B-7 에서 실제 비용을 만들었다. oauth2-proxy 를 올릴 네 번째 호스트명이 없어 Grafana 가 쓰던 app2 를 빌려야 했고, 그동안 관측 스택의 웹 UI 가 내려가 있었다. 「인증서에 이름을 몇 개 넣을 것인가」는 TLS 설정이 아니라 나중에 무엇을 배포할 수 있는가를 정하는 결정이다.

갱신 자동화가 도는지는 sudo 없이 읽힌다. 실제로 이 실험대가 그 범위에서 다 읽었다.

ssh test-server 'systemctl list-timers certbot-renew.timer'

실측(observed) — 01-certificate-state.txt

NEXT                            LEFT      LAST                         PASSED   UNIT
Fri 2026-09-04 17:03:46 KST     1h 54min  Fri 2026-09-04 03:19:39 KST  11h ago  certbot-renew.timer
타이머 enabled: enabled
타이머 active: active

NEXT/LEFT 가 채워져 있는가, LAST/PASSED 가 하루 안쪽인가를 본다. 표가 통째로 비면 타이머가 없는 것이고 이름이 배포판마다 다르므로 systemctl list-timers --all | grep -i certbot 으로 찾는다.

ssh test-server 'systemctl status certbot-renew.service'
ssh test-server 'journalctl -u certbot-renew.service --since today'

실측(observed) — 07-renewal-hook-missing.txt

  Active: inactive (dead) since Fri 2026-09-04 17:04:11 KST
  Process: 28452 ExecStart=/usr/bin/certbot -q renew (code=exited, status=0/SUCCESS)

  Sep 04 03:19:39 Starting Renew certificates acquired via Certbot...
  Sep 04 03:19:41 Finished Renew certificates acquired via Certbot.
  Sep 04 17:04:09 Starting Renew certificates acquired via Certbot...
  Sep 04 17:04:11 Finished Renew certificates acquired via Certbot.

status=0/SUCCESS, 그리고 오늘 두 번 돌았다는 것을 본다. 여기서 확인을 멈추면 「괜찮다」로 끝난다. 대부분의 문서가 여기까지다. 그런데 남은 기간을 보면 아직 갱신은 하지도 않았다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -enddate

실측(observed)

만료: Dec  2 00:47:22 2026 GMT
남은 일수: 88일

Let's Encrypt 는 90일 발급이고 certbot 은 30일 남았을 때 갱신한다. 즉 실제 갱신까지 약 58일 남았고, 그때까지 이 절차는 한 번도 시험되지 않는다. 「타이머가 active 니까 괜찮다」가 확인이 아닌 까닭이 이것이다.

★ 그런데 무엇이 nginx 를 reload 하는가. 갱신된 인증서를 서버에 읽히는 경로는 셋뿐이고, 셋을 하나씩 연다. 아직 아무것도 주입하지 않았는데 이 실험의 원인 진단이 여기서 이미 끝난다.

ssh test-server 'systemctl cat certbot-renew.service'
ssh test-server 'systemctl cat certbot-renew.timer'

★ 이 실험대에서는 기계 이름도 유닛 이름도 다르다(2026-09-17, observed).

첫째, certbot 은 엣지 게스트에 있다. 기반 가이드 03 이 엣지 nginx 를 호스트에서 kc-lab-edge 로 옮겼고 04 가 거기 certbot 을 깔았다. test-server 에도 실행 파일은 있지만 타이머가 없다.

[test-server]    systemctl list-timers certbot-renew.timer  →  0 timers listed.
[kc-lab-edge]    systemctl list-timers --all | grep -i certbot
                 Thu 2026-09-17 17:06:05 UTC  10h left  certbot.timer  certbot.service

둘째, Debian 12 의 유닛 이름은 certbot.timer · certbot.service 다. 그래서 아래 명령은 이름을 바꿔 친다.

ssh kc-lab-edge 'systemctl cat certbot.service'
ssh kc-lab-edge 'systemctl cat certbot.timer'
ssh kc-lab-edge 'sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/'

이름은 달라도 이 절이 찾는 것은 그대로 없다(observed).

# /lib/systemd/system/certbot.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew
PrivateTmp=true

/etc/letsencrypt/renewal-hooks/deploy/   total 0

ExecStartPost--deploy-hook 도 없고 훅 디렉터리도 비어 있다. 갱신은 돌지만 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다 — 이 결론이 유닛 이름과 무관하게 성립한다.

실측(observed) — 07-renewal-hook-missing.txt

  # /usr/lib/systemd/system/certbot-renew.service
  [Unit]
  Description=Renew certificates acquired via Certbot
  [Service]
  Type=oneshot
  ExecStart=/usr/bin/certbot -q renew
  PrivateTmp=true

  OnCalendar=*-*-* 00/12:00:00
  RandomizedDelaySec=12h
  Persistent=true

ExecStart= 한 줄, 그리고 그 아래에 ExecStartPost= 가 있는지 없는지를 본다. ExecStart 의 인자에 --deploy-hook 이 붙어 있는지도 본다. 여기 없는 것을 보는 것이 이 명령의 목적이다. ExecStart 가 전부다 — 배포판(Arch)이 넣어 준 기본 유닛이 그렇고, 이 유닛은 인증서를 새로 받는 데까지만 책임진다.

systemctl cat유닛 파일에 적힌 것을, systemctl show기본값까지 합쳐 실제 적용되는 것을 보여 준다. 여기서는 「적혀 있지 않다」가 답이므로 cat 이 맞다.

훅 디렉터리부터는 root 가 필요하다. sudo 없이 쳐 보면 이렇게 나온다.

ssh test-server 'ls -laR /etc/letsencrypt/renewal-hooks/'

실측(observed) — 07-renewal-hook-missing.txt

ls: cannot access '/etc/letsencrypt/renewal-hooks/': Permission denied

이 빈 출력을 「비어 있다」로 읽으면 틀린다. 이 실험대는 B-7 에서 같은 실수를 했다 — nginx 설정을 읽으려던 시도가 계속 빈 결과였는데, 그게 sudo 의 조용한 실패였다는 것을 한참 뒤에 알았다.

ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ \
  /etc/letsencrypt/renewal-hooks/post/ /etc/letsencrypt/renewal-hooks/pre/'

실측(observed) — 12-certbot-state.txt

/etc/letsencrypt/renewal-hooks/deploy/:
total 8
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..

/etc/letsencrypt/renewal-hooks/post/:
total 8
...
/etc/letsencrypt/renewal-hooks/pre/:
total 8
...

total 8. .. 뿐이다. 셋 다 비었다. ssh -t-t 가 필요하다 — tty 를 붙여 줘야 sudo 가 비밀번호를 물어볼 수 있고, 없으면 「비밀번호가 필요하다」에서 끝난다.

ssh -t test-server 'sudo certbot plugins'

실측(observed) — 13-verdict.txt

         Discovered plugins: dns-cloudflare, manual, null, standalone, webroot
         (certbot 5.7.0)

목록에 nginx 가 없다. certbot --nginx 로 받은 인증서라면 certbot 이 nginx 설정을 직접 만지고 reload 까지 하는데, 이 호스트는 webroot 로 받았고 nginx 플러그인 자체가 설치되어 있지 않다.

# 경로 상태
1 certbot-renew.serviceExecStartPost 없다
2 renewal-hooks/{deploy,post,pre}/ 셋 다 비었다
3 certbot 의 nginx 플러그인 없다

하나라도 있었으면 자동으로 반영됐을 것이다.

★ nginx 워커 PID 로 판정 기준을 여기서 세운다. 「reload 됐는가」를 로그 문구로 판정하지 않고 프로세스로 판정한다.

ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측(observed) — 07-renewal-hook-missing.txt

    585    1  80529  Thu Sep  3 19:00:39 2026  nginx: master process /usr/bin/nginx
    586  585  80529  Thu Sep  3 19:00:39 2026  nginx: worker process

네 칸을 다 본다.

    585    1  80529  Thu Sep  3 19:00:39   nginx: master process
    586  585  80529  Thu Sep  3 19:00:39   nginx: worker process
     │     │     │            │
     │     │     │            └─ lstart: 이 프로세스가 뜬 시각
     │     │     └─ etimes: 떠 있는 초 (80529초 = 22.4시간)
     │     └─ ppid: 부모. 워커의 부모가 마스터다
     └─ pid

이 명령은 지금 배치에서 기계가 틀렸다(2026-09-17, observed). 03 이 nginx 를 kc-lab-edge 로 옮겨서 test-server 에서는 한 줄도 안 나온다. 5·6 절이 certbot 을 엣지에서 찾는 것과 같은 이유이고, 찾는 곳만 바꾸면 된다.

ssh kc-lab-edge "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
   1065       1   11145 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
   1106    1065   11135 Thu Sep 17 04:27:49 2026 nginx: worker process

이 두 줄은 위 예시와 한 군데가 다르고 그 다름이 판정표를 그대로 보여 준다. 위 예시는 마스터와 워커의 lstartetimes 가 같아서 「기동 이후 reload 가 없었다」였다. 여기서는 워커가 10초 늦게 떴고 etimes 도 10 작다 — 03 을 밟으며 systemctl reload nginx 를 친 흔적이다. 마스터 명령줄의 경로도 /usr/sbin/nginx 로 다른데, Arch 호스트와 Debian 게스트의 패키징 차이다.

reload 는 마스터를 유지한 채 워커만 새로 띄운다.

마스터 PID 워커 PID 판정
그대로 바뀜 reload 됐다
그대로 그대로 reload 가 없었다
바뀜 바뀜 reload 가 아니라 재시작이다

마스터 585, 워커 586. 번호가 붙어 있다 — 마스터 기동 직후의 첫 fork 그대로이고 둘의 lstart 가 같고 etimes 도 같다. 즉 22.4시간 동안 reload 가 한 번도 없었다. 이 두 줄을 적어 둔다. 관찰 절과 복구 절이 이 값과 비교한다.

★ 시계를 먼저 잰다. 나중에 재면 늦는다. 두 기계의 로그를 나란히 놓기 전에 확인한다. 이 실험은 이걸 나중에 하는 바람에 공백 수치를 한 번 틀렸다.

A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
echo "$A"; echo "$B"; echo "$C"

★ 재는 대상은 엣지 게스트다(2026-09-17, observed). 이 절차의 판정은 엣지 nginx 가 언제 reload 됐나이고, 기반 가이드 03 이 그 nginx 를 kc-lab-edge 로 옮겼다. test-server 에는 nginx 프로세스가 아예 없다.

[test-server]   ps -ef | grep '[n]ginx'   →  아무것도 안 나온다
[kc-lab-edge]   root 1065 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
                www-data 1106 nginx: worker process
A=$(date -u +%s.%N); B=$(ssh kc-lab-edge 'date -u +%s.%N'); C=$(date -u +%s.%N)

같은 날 lab host 와 엣지를 나란히 찍어 보니 엣지가 94초 느렸다(observed).

lab host    2026-09-17T06:51:28.799Z
kc-lab-edge 2026-09-17T06:49:54.782Z

106초 는 그때 그 짝의 값이다. 수를 옮겨 쓰지 말고 지금 자기 실험대에서 다시 잰다 — 게스트는 호스트와 따로 시계를 맞추므로 새로 만들 때마다 값이 다르다. 훅 디렉터리는 2026-09-17 에도 비어 있었다(total 8, ... 뿐, observed).

세 수를 눈으로 뺀다. AC 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이므로 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이고, B 가 그보다 크면 저쪽이 빠른 것이다. 계산을 명령에 넣지 않는 것이 이 형태의 요점이다 — 두 값의 차를 셸이 대신 빼 주면 어느 시계에서 온 값인지가 출력에서 사라진다.

어느 쪽이 맞는지는 외부 기준으로 가른다.

curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
date -u
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'

실측(observed) — d4a-deploy-hook/01-hook-verified.txt

    dev         → Google  차이 +0초
    dev         → Let's Encrypt ACME  차이 +0초
    test-server → Google  차이 -105초   (즉 test-server 가 105초 빠르다)
    ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초  (안정적)

NTPSynchronized 를 본다. 이 호스트는 no 다. 그리고 세 번 재서 값이 흔들리지 않는 것도 같이 본다 — 흔들리면 네트워크 지연이 섞인 것이고, 안정적이면 진짜 왜곡이다.

   실제 시각 = test-server 시계  106초
   실제 시각 = dev 시계               (보정 불필요)

그러므로 이 실험의 모든 관측은 dev 에서 한다. 호스트에서만 알 수 있는 값(파일 mtime, 훅 로그)은 보정해서 쓴다. 왜 이걸 주입 전에 하는가 — 주입 후에는 「그때 저 시계가 얼마나 어긋나 있었나」를 되짚을 수 없다. 그리고 이 실험은 실제로 보정 없이 뺀 값 2199초를 문서에 적었다가 나중에 2305초로 정정했다.

대조군은 근거를 재려고 할 때만 잡는다. 일련번호가 언제 바뀌는지만 보려면 위의 openssl … -serial 을 손으로 두 번 치면 된다. 그런데 주입 중에 오류가 한 번 나왔을 때 평시 오류율을 모르면 아무것도 증명하지 못한다.

i=0
while [ $i -lt 900 ]; do
  curl -s -o /dev/null -w '%{http_code} %{time_total} %{time_appconnect}\n' \
    --max-time 5 https://auth.hyeonworks.com/realms/master
  i=$((i+1)); sleep 0.2
done > /tmp/d4-control.txt
awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c

실측(observed) — 05-control-no-injection.txt

표본 900 개

[상태코드 분포]
      900 200

[응답시간 ms]
  최소 67  중앙 98  p95 195  최대 1121  평균 106.9

[TLS 핸드셰이크 ms — 0 이면 연결 재사용, >0 이면 새 핸드셰이크]
  핸드셰이크 발생 900회 / 900  평균 83 ms  최대 1100 ms

[비정상 응답 원문 — 있으면 아래에 전부]
  비200 총 0

uniq -c 의 줄이 하나이고 그 값이 900 200 인가, 그리고 핸드셰이크가 900/900 인가를 본다. 대조군이 깨끗하므로 주입 중 비200 이 한 번만 나와도 주입 탓으로 귀속할 수 있다. 대조군에 이미 오류가 섞여 있으면 주입을 하지 않는다 — 판정할 수 없기 때문이다.

그리고 핸드셰이크 900/900 은 매 요청이 새 연결이라는 뜻이다. 즉 이 장치는 「새 연결을 받아주는가」만 잰다. 계획서가 물은 것은 「진행 중이던 요청은 어떻게 되는가」이므로 장치가 하나 더 필요하다.

reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜리 관리 콘솔 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 둔다. 먼저 큰 파일의 경로를 찾는다(버전마다 달라진다).

JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
     | grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
echo "$JS"

실측(observed) — 06-inflight-control.txt

  대상: https://auth.hyeonworks.com/resources/55yjq/admin/keycloak.v2/assets/main-BbID33M6.js
curl -s --limit-rate 20k -o /tmp/inflight.bin \
  -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
  "https://auth.hyeonworks.com$JS"

실측(observed) — 06-inflight-control.txt

[대조군: 주입 없이 1회]
  코드=200  받은바이트=845361  총시간=41.392198s  연결수=1  실효속도=20423B/s
  기대 크기 845361 / 실제 845361 bytes

판정 기준 (주입 시 이 값들과 비교한다)
  · 코드 200 + 크기 845361 = 진행 중이던 요청이 끝까지 살아남았다(graceful)
  · 코드 000 또는 크기 부족  = reload 가 진행 중이던 연결을 끊었다
  · 연결수 2 이상            = 중간에 끊겨 curl 이 다시 붙었다

연결수=1 이 판정의 핵심이다. 끊겼다가 curl 이 다시 붙었으면 2 가 된다.

감시 셋은 파일로 쓴다. 각각 루프와 종료 조건이 있고, 이쯤 되면 한 줄 명령이 아니라 프로그램이다. 가이드도 여기서 한 줄짜리 형태를 버리고 파일 셋으로 간다.

vim /tmp/d4-watch-serial.sh
#!/bin/sh
# file: /tmp/d4-watch-serial.sh
# 5초마다 밖에서 본 인증서의 일련번호와 만료일을 찍는다.
# /tmp/d4-stop 파일이 생기면 멈춘다.
HOST=auth.hyeonworks.com
while [ ! -f /tmp/d4-stop ]; do
  S=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null \
      | openssl x509 -noout -serial -enddate | tr '\n' ' ')
  echo "$(date -u +%H:%M:%S) $S"
  sleep 5
done
vim /tmp/d4-poll.sh
#!/bin/sh
# file: /tmp/d4-poll.sh
# 0.2초마다 새 연결 하나. 상태코드와 소요 시간만 남긴다.
while [ ! -f /tmp/d4-stop ]; do
  echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null \
    -w '%{http_code} %{time_total}' --max-time 5 \
    --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
  sleep 0.2
done
vim /tmp/d4-inflight.sh
#!/bin/sh
# file: /tmp/d4-inflight.sh
# 42초짜리 요청을 끊김 없이 연달아 돌린다 — reload 순간에 반드시 하나가 떠 있게.
# ★ curl 의 종료 코드를 반드시 남긴다. 안 남기면 측정 장치의 실패와
#   서버의 실패를 구별할 수 없다 (08-inflight-artifact.txt).
URL="https://auth.hyeonworks.com$1"
while [ ! -f /tmp/d4-stop ]; do
  R=$(curl -s --limit-rate 20k -o /dev/null \
      -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}' \
      "$URL"); E=$?
  echo "$(date -u +%H:%M:%S) $R curl종료=$E"
  [ $E -ne 0 ] && sleep 1
done
chmod +x /tmp/d4-watch-serial.sh /tmp/d4-poll.sh /tmp/d4-inflight.sh
rm -f /tmp/d4-stop
setsid /tmp/d4-watch-serial.sh > /tmp/d4-serial.txt   2>&1 < /dev/null &
setsid /tmp/d4-poll.sh         > /tmp/d4-poll.txt     2>&1 < /dev/null &
setsid /tmp/d4-inflight.sh "$JS" > /tmp/d4-inflight.txt 2>&1 < /dev/null &

setsid 가 필요하다. 그냥 & 로 띄우면 부모 셸이 끝날 때 같이 죽는다 — A-3 에서 파드 안 &exec 종료와 함께 죽은 것과 같은 함정이다. 이 실험은 사람이 다른 창에서 sudo 를 치는 동안 감시가 살아 있어야 한다.

tail -3 /tmp/d4-serial.txt
tail -3 /tmp/d4-poll.txt
tail -3 /tmp/d4-inflight.txt

세 파일 다 줄이 늘고 있는가를 30초쯤 두고 본다. 여기서 비어 있으면 주입해도 아무것도 안 남는다.

주입

무엇을 사람이 쳐야 하는지가 먼저다. 이 편에서 sudo 가 갈리는 곳이 넷이다.

하는 일 어디서 sudo
밖에서 인증서·체인·SAN 읽기 dev 필요 없다
타이머·유닛·journal 읽기 test-server 필요 없다 (이 실험대에서 확인)
nginx 워커 PID 읽기 test-server 필요 없다
nginx 설정에서 인증서 경로 찾기 test-server 필요 없다
훅 디렉터리 보기 test-server 비밀번호
certbot certificates · archive/ 보기 test-server 비밀번호
certbot renew --force-renewal test-server 비밀번호
nginx -s reload test-server 비밀번호

호스트에서 비대화 sudo 는 반드시 실패한다.

실측(observed) — 01-certificate-state.txt

$ sudo -n -l
sudo: a password is required
$ sudo -n systemctl reload nginx
sudo: a password is required

그러므로 이 네 줄은 자동화할 수 없다. ssh -t 로 tty 를 붙여 사람이 비밀번호를 친다. 이 실험이 처음에 강제 갱신을 못 하고 「미측정」으로 남긴 까닭이 정확히 이것이다.

ssh -t test-server 'sudo certbot renew --dry-run'

끝의 simulated renewals 요약을 본다. 훅을 넣었다면 Running deploy-hook command 줄도 나오는데, 이 실험대는 훅이 없는 상태에서 쟀으므로 그 줄은 미검증이다(unknown). dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 절차가 도는지, 검증이 통과하는지까지만 말해 준다 — 파일이 실제로 바뀌었을 때 nginx 가 그것을 집는지는 dry-run 으로 알 수 없다.

되돌리기가 없는 한 줄이 다음이다. 새 인증서는 되돌릴 수 없고 한도를 한 장 깎는다. 감시 세 개가 돌고 있는지 다시 확인하고 친다.

date -u '+%H:%M:%S 갱신 시작 (dev)'
ssh -t test-server 'sudo certbot renew --force-renewal'

Congratulations, all renewals succeeded: 와 그 아래 fullchain.pem (success) 를 본다. 시각은 dev 시계로 적어 둔다. 호스트가 찍는 시각은 106초 빠르다.

주입 검증

「갱신 실패」와 「갱신은 됐는데 안 집었다」를 가르는 절이다. 이 실험은 처음에 이 둘을 구별하지 못해 두 갈래로 적어 뒀었다.

ssh -t test-server 'sudo certbot certificates'

실측(observed) — 12-certbot-state.txt

Found the following certs:
  Certificate Name: auth.hyeonworks.com
    Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d
    Key Type: ECDSA
    Identifiers: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com
    Expiry Date: 2026-12-03 07:21:52+00:00 (VALID: 89 days)
    Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem

Serial NumberExpiry Date 를 본다. 주입 전에 적어 둔 값과 다르고 만료일도 하루 밀렸다(Dec 2Dec 3). certbot 쪽에서는 갱신이 끝났다.

파일이 언제 써졌는지도 본다.

ssh -t test-server 'sudo ls -la --time-style=full-iso /etc/letsencrypt/archive/auth.hyeonworks.com/'

실측(observed) — 12-certbot-state.txt

-rw-r--r-- 1 root root 1359 2026-09-03 10:47:40.915923507 +0900 cert1.pem
-rw-r--r-- 1 root root 1359 2026-09-04 17:22:13.508494637 +0900 cert2.pem
-rw-r--r-- 1 root root 3523 2026-09-03 10:47:40.916215769 +0900 chain1.pem
-rw-r--r-- 1 root root 3523 2026-09-04 17:22:13.508658811 +0900 chain2.pem
-rw-r--r-- 1 root root 4882 2026-09-03 10:47:40.916339551 +0900 fullchain1.pem
-rw-r--r-- 1 root root 4882 2026-09-04 17:22:13.508821612 +0900 fullchain2.pem
-rw------- 1 root root  241 2026-09-03 10:47:40.916079294 +0900 privkey1.pem
-rw------- 1 root root  241 2026-09-04 17:22:13.507717972 +0900 privkey2.pem

번호가 1 과 2 두 벌이라는 것, 그리고 2 번들의 mtime 2026-09-04 17:22:13 을 본다. ★ 이 시각은 (ts) 다. test-server 시계이고 106초 빠르다. 실제로는 08:20:27 (실제) 이고, 관찰 절에서 이 보정을 쓴다.

privkey2.pem 의 권한이 -rw------- 인 것도 본다. 개인키는 D-3 의 주제와 같은 문제를 안고 있다 — 파일 하나를 얻으면 끝이다.

관찰

★ 그런데 밖에서는 아무것도 안 바뀌었다.

tail -3 /tmp/d4-serial.txt

실측(observed) — 07-renewal-hook-missing.txt

  serial=0520BB6416D569E26697B1691440F523B853
  notBefore=Sep  3 00:47:23 2026 GMT     ← 어제 것 그대로
  notAfter=Dec  2 00:47:22 2026 GMT

일련번호 감시 161표본(약 13분) 동안 단 한 번도 바뀌지 않았다.

일련번호가 주입 전에 적어 둔 값 그대로인가를 본다. 주입 검증에서 본 디스크의 6c7cb6df…다르다.

   디스크    새 인증서   (6c7cb6df…)
   네트워크  옛 인증서   (0520BB…)

두 사건이 갈라졌다. 여기서 「갱신이 실패했다」고 결론 내리면 틀린다 — 주입 검증에서 성공을 이미 봤다.

ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측(observed)

    585    1  80529  Thu Sep  3 19:00:39 2026  nginx: master process /usr/bin/nginx
    586  585  80529  Thu Sep  3 19:00:39 2026  nginx: worker process

워커 PID 586 이 그대로다. etimes 도 계속 늘고 있을 뿐 리셋되지 않았다. reload 가 없었다. 주입 전에 정한 판정 기준이 여기서 답을 낸다 — 로그를 뒤질 필요가 없다.

왜 파일이 바뀌어도 nginx 는 모르는가. nginx 는 ssl_certificate 가 가리키는 파일을 기동 시점에 한 번 읽어 메모리에 들고 있다. 요청마다 디스크를 다시 보지 않는다. nginx 가 무엇을 물고 있는지는 sudo 없이 읽힌다.

ssh test-server 'grep -rn ssl_certificate /etc/nginx/'

실측(observed) — 07-renewal-hook-missing.txt

/etc/nginx/sites-available/keycloak-lab:18: ssl_certificate     /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
/etc/nginx/sites-available/keycloak-lab:19: ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;

live/심볼릭 링크다. certbot 은 갱신하면 이 링크가 새 archive/ 파일을 가리키도록 바꾼다.

   /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
        └─▶ (전) ../../archive/auth.hyeonworks.com/fullchain1.pem
        └─▶ (후) ../../archive/auth.hyeonworks.com/fullchain2.pem

경로는 그대로인데 내용만 바뀐다. 그래서 nginx 설정을 고칠 필요가 없고, 바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다. 필요한 것은 설정 변경이 아니라 reload 다. 없거나 틀리면 인증서가 만료되어 브라우저가 NET::ERR_CERT_DATE_INVALID 를 띄우는데, 그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도 SUCCESS 다 — 그래서 원인을 찾는 데 오래 걸린다.

주입 전에 본 표가 여기서 판정이 된다. 세 경로 전부가 비어 있다.

# 경로 상태
1 certbot-renew.serviceExecStartPost 없다
2 renewal-hooks/{deploy,post,pre}/ 셋 다 비었다
3 certbot 의 nginx 플러그인 없다

★ 공백을 계산한다. 여기가 이 실험이 한 번 틀린 대목이다. 감시에서 언제 바뀌었는지를 찾는다 — 바뀌는 사건 자체는 복구 절에서 사람이 reload 를 친 뒤에 일어난다.

grep -v '0520BB' /tmp/d4-serial.txt | head

실측(observed) — 09-serial-timeline.txt · 13-verdict.txt

  08:10:51 ~ 08:58:47   serial=0520BB...B853  notAfter=Dec  2   ← 옛 것
  08:58:52              serial=06C7CB...EA1D  notAfter=Dec  3   ← 바뀐 순간

  08:22:13 ~ 08:58:52 구간에서 옛 인증서로 관측된 횟수: 428회

두 시각을 나란히 놓는다. 그런데 시계가 다르다.

시각 어느 시계
새 인증서 디스크 기록 17:22:13 KST08:22:13 UTC (ts) — 106초 빠르다
실제 서빙 시작 08:58:52 (dev) — 정확

틀린 계산은 그대로 뺀 것이다.

   08:58:52  08:22:13 = 2199초 (36분 39초)   ✘

맞는 계산은 디스크 기록 시각을 실제 시각으로 보정한 뒤 빼는 것이다.

   디스크 기록 : 08:22:13 (ts)  106초 = 08:20:27 (실제)
   서빙 시작   : 08:58:52 (dev)          = 08:58:52 (실제)
   ────────────────────────────────────────────
   공백        : 2305초 = 38분 25초              ✔

106초는 두 값의 차이(2199)에 비하면 5% 도 안 된다. 그래서 D-4 에서는 결론이 안 바뀌었다. 하지만 D-4a 는 1~2초를 재는 실험이고, 거기서는 같은 106초가 결과를 완전히 뒤집는다 — 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나와 물리적으로 성립하지 않는다. 두 시계에서 온 값을 빼면서 그 사실을 적지 않으면 자릿수가 아니라 방향까지 틀릴 수 있다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.

그리고 이 38분은 우연히 짧았을 뿐이다. reload 를 시킨 것은 사람이지 자동화가 아니다. 아무도 안 했다면 다음 nginx 재시작까지, 즉 사실상 무기한 옛 인증서를 서빙했을 것이다.

왜 88일 동안 안 보이나.

   오늘        타이머 두 번 SUCCESS         (갱신할 것이 없으므로 아무 일도 안 한다)
   +58일쯤     만료 30일 전 → 실제 갱신     ← 여기서 처음으로 절차가 시험된다
   +88일       만료                         ← 증상이 나타나는 날

발현하는 날의 증상은 「인증서 만료」이고, 그날에도 로그는 SUCCESS 다. 그래서 이 결함은 로그 감시로는 못 잡는다. 잡으려면 밖에서 notAfter 를 재야 한다. 감시로 쓸 만한 한 줄이 가이드에 있고, 미검증이다(unknown).

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -checkend 2592000

Certificate will not expire 인가 Certificate will expire 인가를 본다. 2592000 은 30일(초)이다. 서버에 로그인하지 않고, 밖에서, 실제로 서빙 중인 것을 본다 — 이 셋이 이 실험의 교훈이라고 가이드는 적는다.

부수 질문의 답은 감시를 멈추고 센다. 판정은 복구 절에서 사람이 reload 를 친 뒤에 한다.

touch /tmp/d4-stop
grep -vE ' 200 ' /tmp/d4-poll.txt | head
grep -v '코드=200' /tmp/d4-inflight.txt | head

실측(observed) — 13-verdict.txt. 새 연결, 0.2초 폴링, 08:10:51 ~ 09:02

  전체 표본 8856건 / 비200 0건

  응답시간                     n      중앙     p95      최대
  ─────────────────────────────────────────────────────────
  장기 평시 08:20~08:50      5398   98.0ms  205.7ms  1942.9ms
  reload 직전 2분56초         489  116.0ms  200.8ms   387.7ms
  reload 직후 2분08초         342  132.5ms  204.3ms   475.0ms

p95 가 205.7 → 204.3 으로 사실상 같고 최대값은 오히려 낮다. 10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오가는데 WiFi 잡음이지 reload 의 흔적이 아니다.

진행 중이던 요청이 계획서가 정확히 물은 지점이다.

실측(observed) — 13-verdict.txt

08:58:40  요청 시작 (845KB @ 20k/s)
08:58:52  ← nginx -s reload.  요청 시작 12초 뒤, 전송 한가운데
08:59:21  종료: 코드=200  바이트=845361(전량)  연결수=1  curl종료=0
관측 읽는 법
바이트가 전량이다 잘리지 않았다
연결수가 1이다 중간에 끊겨 재연결한 게 아니다
코드 200 옛 워커가 이 요청을 끝까지 책임졌다

reload 는 무중단이다. 옛 인증서로 시작한 연결이 새 워커 전환을 관통해 끝까지 갔다. in-flight 전체 50건 중 종료코드 ≠ 0 은 0건이다.

★ 그런데 이 편이 잰 reload 는 사람이 건 것이다. 08:58:52nginx -s reload 는 복구 절에서 사람이 ssh -t 로 붙어 친 한 줄이고, certbot 이 부르는 자동 reload 는 이 실험대에 아직 없다 — 훅 디렉터리 셋이 비어 있다는 것이 바로 이 편의 진단이기 때문이다. 훅이 부르는 reload 는 D-4a 에서 넣고 거기서 따로 쟀다. 「reload 가 무중단이다」는 명제는 두 경우에 같은 기제로 성립하지만, 이 편의 8856건과 845361바이트가 잰 것은 사람이 건 reload 다.

★ 측정 장치가 거짓말할 뻔했다. in-flight 감시에서 76건이 실패했는데 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다. 서버 탓이 아니었다.

실측(observed) — 08-inflight-artifact.txt

  08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0     ← 여기부터
  ... (76건, 전부 08:15:04)
  08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1   ← 곧바로 복귀
근거
같은 순간 폴링 49건 전부 200
연결수 0 — TCP 연결 시도조차 못 했다
소요 시간 50µs — DNS 조회보다도 짧다
재현 0/100
nginx 그 시각에 아무 일도 안 했다(워커 22.4시간째)

대조군이 오보를 막았다. 그리고 원인은 특정하지 못했다curl-s 로 돌려 오류 메시지를 버렸고 종료 코드도 안 남겼기 때문이다. 감시 스크립트에 curl종료=$E 가 들어 있는 것이 그 수정이다. 측정 장치가 실패했을 때 왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로 돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.

복구와 원상복구 확인표

이 절이 곧 관찰 절의 공백을 닫는 사건이다. 순서상 관찰을 다 끝낸 뒤에 친다.

date -u '+%H:%M:%S reload (dev)'
ssh -t test-server 'sudo nginx -t && sudo nginx -s reload'

test is successful 두 줄이 먼저 나오고 그다음 아무 말 없이 끝난다(-s reload 는 조용하다).

reload 이고 restart 가 아닌가. 이 호스트의 nginx.service 유효 설정이 답이다.

실측(호스트)(observed) — 증거 파일이 아니라 이 호스트에서 확인된 설정값

Type=forking          Restart=on-failure    RestartUSec=100ms
StartLimitBurst=5     StartLimitIntervalUSec=10s
KillMode=mixed        KillSignal=SIGQUIT    PrivateTmp=true

이 값들은 유닛 파일이 아니라 실제 적용값이라 show 로 본다.

ssh test-server 'systemctl show nginx -p Type -p Restart -p RestartUSec \
  -p StartLimitBurst -p StartLimitIntervalUSec -p KillMode -p KillSignal -p PrivateTmp'
설정 읽는 법
KillSignal=SIGQUIT 정지 신호가 nginx 의 graceful shutdown 신호다 — stop 도 연결을 끊지 않고 빠진다
Restart=on-failure + RestartUSec=100ms 죽으면 0.1초 뒤 다시 띄운다
StartLimitBurst=5 / StartLimitIntervalUSec=10s 10초 안에 5번 실패하면 systemd 가 포기한다. 설정이 깨진 채 restart 를 반복하면 nginx 가 내려간 채로 멈춘다
PrivateTmp=true 이 서비스의 /tmp자기만의 것이다. 여기 뭔가를 쓰면 밖에서 안 보인다

그래서 nginx -t 를 먼저 친다. 설정이 깨진 상태에서 reload 를 보내면 마스터가 새 워커를 못 띄우지만 옛 워커는 그대로 서비스를 계속한다 — 인증서는 안 바뀌어도 서비스는 안 죽는다. restart 는 그 안전장치가 없다.

이 실험대가 실제로 친 것은 nginx -s reload 이고(observed) D-4a 의 훅도 그것을 쓴다. systemctl reload nginx 도 같은 일을 하지만(유닛에 ExecReload 가 있을 때) 그쪽은 미검증이다(unknown).

바뀌었는지는 두 곳을 본다.

ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측(observed) — d4a-deploy-hook/01-hook-verified.txt 가 D-4a 첫머리에 찍은 값이다. D-4 에서 사람이 reload 한 결과가 이 워커다

      585    1  ...  Thu Sep  3 19:00:39  nginx: master process
    28829  585  ...  Fri Sep  4 18:00:35  nginx: worker process   ← D-4 에서 사람이 reload 한 것

마스터 585 는 그대로, 워커는 586 → 28829 다. 주입 전에 세운 판정 기준 그대로다.

tail -3 /tmp/d4-serial.txt

실측(observed) — 09-serial-timeline.txt

  08:58:52              serial=06C7CB...EA1D  notAfter=Dec  3   ← 바뀐 순간

일련번호가 주입 검증에서 본 디스크의 값과 같아졌는가를 본다. 디스크와 네트워크가 다시 일치하고, 그 사이의 2305초가 이 실험의 답이다.

항목 명령 돌아왔을 때
서빙 인증서 주입 전에 친 openssl … -serial 주입 검증의 새 일련번호와 같다
체인 주입 전에 친 Verify return code 한 줄 4단계, Verify return code: 0
이름 셋 주입 전에 친 for H in auth app1 app2 세 일련번호가 서로 같다
nginx ps -eo pid,ppid,etimes,lstart,args | grep nginx: 마스터 그대로, 워커 새것
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
감시 ls /tmp/d4-stop 있어야 한다(멈춘 상태). 없으면 touch
남은 프로세스 ps -ef | grep d4- 없어야 한다
임시 파일 ls -l /tmp/d4-*.txt /tmp/inflight.bin 근거로 남기거나 지운다

인증서는 원상복구되지 않는다. 새것이 정상이고 옛것으로 돌아갈 이유도 없다.

진짜 고치는 법은 이 절차 밖에 있다. 이 절차가 38분에서 끝난 것은 사람이 reload 를 쳤기 때문이고, 자동으로 되게 하려면 훅이 필요하다.

# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload

deploy/실제로 갱신된 인증서가 있을 때만 실행된다. post/ 는 갱신 여부와 무관하게 매번 돌므로 하루 두 번 쓸데없이 워커를 갈아치우게 된다.

★ 이 처방은 D-4a 에서 실제로 넣고 검증했다. 훅 파일 하나로 발급 → 서빙이 38분 25초에서 1~2초가 됐다. 처방을 적고 시험하지 않는 것이야말로 이 실험대가 계속 경계해 온 실수라서 별도 실험으로 분리했다고 가이드는 적는다.

막히면

가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이라고 적는다.

증상 원인 확인
호스트에서 아무 명령이나 빈 결과 sudo 가 조용히 실패했다 sudo -n -la password is required. ssh -t 로 다시
ssh test-server 'sudo …' 가 멈춰 있다 tty 가 없어 비밀번호를 못 묻는다 ssh -t
갱신했는데 일련번호가 그대로 그게 이 실험의 결과다 워커 PID 를 본다
워커 PID 로 판정이 안 선다 마스터까지 바뀌었다 reload 가 아니라 재시작이다. lstart 를 본다
훅 디렉터리가 Permission denied root 전용 「비었다」로 읽지 않는다
감시가 셸을 닫으면 죽는다 & 만 붙였다 setsid
in-flight 에 실패가 무더기로 로컬 아티팩트일 수 있다 같은 시각 폴링·연결수·소요 시간·재현
공백이 음수로 나온다 두 시계를 그대로 뺐다 시계 재는 절차로 돌아간다
notBefore 로 발급 시각을 계산했다 LE 는 정확히 한 시간 백데이트한다 SCT 를 본다
crt.sh 에 인증서가 안 나온다 색인이 진실의 부분집합이다 SCT 는 인증서 안에 있다. -ext ct_precert_scts
nginx 에러 로그가 중간에 잘린다 한 항목이 2048바이트에서 잘린다(NGX_MAX_ERROR_STR) 저널 포맷을 바꿔도 안 늘어난다. access 로그를 본다
체인이 1단계 cert.pem 을 썼다 ssl_certificate 한 줄
발급 한도에 걸렸다 주당 중복 인증서 5장 --dry-run 으로 먼저

crt.sh 에 관한 곁다리도 실측이다. 발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것 같았고, 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다. 그런데 색인 쪽은 달랐다.

실측(observed) — 07-renewal-hook-missing.txt

  $ curl -s 'https://crt.sh/?q=auth.hyeonworks.com&output=json'
  []                                            ← 0건
  $ curl -s 'https://crt.sh/?q=hyeonworks.com&output=json'
  13건, 최신 not_before=2026-08-11              ← auth 는 없다

인증서에 SCT 가 박혀 있다는 것과 crt.sh 가 그것을 색인했다는 것은 다르다. 관측 도구가 진실의 부분집합만 본다는, A-2 의 up 지표와 같은 종류의 함정이다.

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

  • (observed) 주입 전 인증서의 subject·issuer·notBefore=Sep 3 00:47:23 2026 GMT· notAfter=Dec 2 00:47:22 2026 GMT 와 SAN 세 이름, SCT 두 줄 (Sep 3 01:45:53.183 · Sep 3 01:45:53.352), 체인 네 줄과 Verify return code: 0 (ok), 타이머 표 한 줄과 enabled·active, status=0/SUCCESS 와 오늘 두 번 돈 journal 네 줄, 남은 일수: 88일, 유닛 본문과 ExecStart=/usr/bin/certbot -q renew, 훅 디렉터리의 Permission denied 와 sudo 로 본 total 8 셋, Discovered plugins: dns-cloudflare, manual, null, standalone, webrootcertbot 5.7.0, 워커 두 줄(585·586·80529), 시계 측정 네 줄과 +106.1 세 번, 대조군 900건의 분포와 응답시간 다섯 값과 핸드셰이크 900/900, in-flight 대조군의 845361·41.392198s·20423B/s, sudo -n -l 두 줄, certbot certificates 블록과 Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d, archive/ 여덟 줄과 2026-09-04 17:22:13 mtime, 감시의 serial=0520BB6416D569E26697B1691440F523B853 과 161표본, ssl_certificate 두 줄, 일련번호가 바뀐 08:58:52 와 그 앞 구간의 428회, 폴링 8856건과 구간 셋(5398·489·342)의 중앙·p95·최대, in-flight 세 줄 (08:58:40·08:58:52·08:59:21)과 845361·연결수=1·curl종료=0, 아티팩트 76건과 50µs·0/100·같은 시각 폴링 49건, reload 뒤의 워커 28829, crt.sh 의 [] 와 13건.
  • (observed, 호스트 확인) nginx.service 의 유효 설정 여덟 값. 증거 파일이 아니라 이 호스트에서 확인한 것이라 가이드가 실측(호스트) 로 따로 표시했다.
  • 이 편이 잰 reload 는 사람이 건 것이다08:58:52nginx -s reload 는 복구 절에서 사람이 ssh -t 로 쳤다. 훅이 부르는 자동 reload 는 이 실험대에 아직 없었고(그것이 이 편의 진단이다) D-4a 에서 넣어 따로 쟀다. 무중단 판정의 8856건과 845361바이트는 사람이 건 reload 를 잰 값이다.
  • 2199초는 이 실험이 스스로 정정한 값이다 — 처음에 archive/cert2.pem 의 mtime (test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서 2199초로 적었고, 시계 왜곡 106초를 보정한 뒤 2305초로 고쳤다. 틀린 값과 맞는 값을 둘 다 위에 적어 둔 까닭은 어느 쪽이 왜 틀렸는지가 이 편의 교훈이기 때문이다. 보정을 자기 검증한 것은 D-4a 이고, 거기서는 같은 106초가 결과를 뒤집는다.
  • (unknown) certbot renew --dry-run 에서 Running deploy-hook command 줄이 나오는지 — 이 실험대는 훅이 없는 상태에서 쟀다. openssl … -checkend 2592000 감시 한 줄, systemctl reload nginx 형태. 가이드가 전부 미검증으로 표시했다.
  • 비밀은 옮기지 않았다 — 이 편이 다루는 파일 중 비밀인 것은 privkey2.pem 하나이고, 크기(241)와 권한(-rw-------)만 적었다. 내용은 열지 않았고 가이드도 열지 않는다. 일련번호·Log ID·호스트명·파드 이름은 식별자라 그대로 적었다.
  • 이 실험이 재지 않은 것 — 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, in-flight 아티팩트 76건의 원인, 타이머가 스스로 갱신하는 경로(만료 30일 전에야 조건이 성립한다). in-flight 감시는 전체 50건이었고 그 이상 반복하지 않았다.

D-4a — 훅 파일 하나가 그 공백을 얼마로 줄이는가

근거: d4a-deploy-hook.md (627줄). 실행 기록은 2026-09-04 12:27 UTC(실제)(observed).

이 실험이 가르는 것

D-4 는 결함을 찾고 처방을 적어 두고 검증하지 않았다.

D-4 가 남긴 항목 상태
deploy 훅을 넣으면 자동 반영되는가 미측정. 훅은 아직 넣지 않았다

처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것은 이 실험대가 스물세 번 경계해 온 바로 그 실수라고 가이드는 적는다. 그래서 별도 실험으로 분리했다.

판정할 것은 셋이다.

# 질문 무엇으로 가르나
훅이 실행되는가 certbot 출력
nginx 가 정말 reload 되는가 워커 PID (문구가 아니라)
얼마나 빠른가 SCT ↔ 보정한 훅 시각

②가 이 편의 방법이고 ③이 이 편의 난점이다. 판정을 문구로 하면 certbot 이 찍는 ran with error output 에 걸려 성공을 실패로 읽고, 시각을 보정하지 않으면 훅이 발급보다 먼저 돈 것이 되어 물리적으로 불가능한 값이 나온다.

가이드의 「이 가이드가 끝나면」 표는 일곱을 적는다 — 훅 디렉터리가 비어 있는 것에서 파일 하나를 넣는 것, certbot 이 ran with error output 이라고 찍는데 실패가 아닌 것, 마스터는 그대로고 워커만 자동으로 갈리는 것, 서빙 인증서가 곧바로 바뀌는 것, 발급에서 서빙까지 1~2초인 것, 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나는 것, notBefore발급 시각이 아닌 것.

전제와 되돌리기

  • D-4 를 먼저 한다. 특히 두 가지가 없으면 이 실험은 성립하지 않는다 — 「reload 판정은 워커 PID 로 한다」는 기준, 그리고 두 기계 시계의 왜곡을 미리 재 둔 값.
  • 관찰은 dev 에서, 주입은 test-server 에서 사람이 친다.
  • 호스트의 sudo 는 비밀번호를 요구한다. 이 실험의 주입은 전부 그쪽이다.
  • 이 호스트의 certbot 은 5.7.0, nginx 플러그인은 없다.

★ 인증서를 한 장 더 쓴다. certbot renew --force-renewal 한 번 치므로, D-4 에서 한 번 썼다면 이번이 두 번째이고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다. 세어 두고, 절차만 확인하려면 --dry-run 을 먼저 쓴다.

되돌리기는 한 줄인데, 되돌리지 않는 편이 낫다. 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아간다.

ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'

★ 시각 표기 규약은 D-4 와 같고, 여기서는 훨씬 무겁다. 이 실험은 1~2초를 재고, 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.

표기
12:27:49 (실제) 보정한 값. 외부 기준과 일치
21:29:36 KST (ts) test-server 시계. 106초 빠르다
12:29:05 (dev) 개발 머신 시계. 보정 불필요

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

네 칸이고, 마지막 칸이 이 편의 답을 지킨다.

워커 PID → 서빙 인증서와 SCT → 훅 디렉터리가 비었나 → ★ 시계 왜곡
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측(observed) — 01-hook-verified.txt

      585    1  ...  Thu Sep  3 19:00:39  nginx: master process
    28829  585  ...  Fri Sep  4 18:00:35  nginx: worker process   ← D-4 에서 사람이 reload 한 것

마스터 PID 와 워커 PID 두 숫자, 그리고 워커의 lstart 를 본다. 이 세 값을 적어 둔다 — 관찰 절의 판정이 이 값과의 비교다.

워커 28829 는 D-4 에서 사람이 nginx -s reload 를 쳐서 생긴 것이다. 마스터는 여전히 585, 어제 19:00:39 에 뜬 그대로다. 마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명이라는 것을 D-4 에서 확인했고, 이 실험은 그 기준을 그대로 쓴다. 그러니까 이 편의 출발점 자체가 「사람이 건 reload 의 결과」이고, 이 편이 재려는 것은 「훅이 거는 reload」다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -serial -dates

serial적어 둔다. 관찰 절에서 이 값이 바뀐다.

발급 시각의 외부 기준도 지금 봐 둔다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -ext ct_precert_scts | grep Timestamp

Timestamp 두 줄을 본다. CT 로그가 자기 시계로 서명한 시각이고 이 실험대의 두 기계와 무관한 제3의 기준이다. 관찰 절에서 이 값이 심판이 된다.

ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'

실측(observed) — d4-certificate-renewal/12-certbot-state.txt

/etc/letsencrypt/renewal-hooks/deploy/:
total 8
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..

total 8. .. 뿐이다. sudo 없이 치면 Permission denied 이고, 그 빈 출력을 「비어 있다」로 읽는 것이 D-4 에서 실제로 걸렸던 함정이다.

★ 시계 왜곡을 먼저 잰다. 나중에 재면 값을 해석할 수 없다. 이 실험의 답은 1~2초인데 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다. 그리고 왜곡은 사후에 되짚을 수 없다.

for i in 1 2 3; do
  A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
  echo "A=$A  B=$B  C=$C"
done

세 줄 각각에서 B(A+C)/2 의 차이를 눈으로 뺀다. 그리고 세 번의 값이 서로 비슷한가 — 흔들리면 네트워크 지연이 섞인 것이고, 안정적이면 진짜 왜곡이다.

date -u
curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'

실측(observed) — 01-hook-verified.txt

    dev         → Google  차이 +0초
    dev         → Let's Encrypt ACME  차이 +0초
    test-server → Google  차이 -105초   (즉 test-server 가 105초 빠르다)
    ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초  (안정적)

NTPSynchronized 를 본다. 이 호스트는 no 다. 그리고 세 번 다 +106.1 로 흔들리지 않았다는 것도 같이 본다.

   실제 시각 = test-server 시계  106초

왜 Let's Encrypt 의 Date: 도 보나. 이 실험이 재는 사건의 한쪽 끝이 Let's Encrypt 의 발급이기 때문이다. 그쪽 기준과 dev 가 일치한다는 것을 확인해 두면 관찰 절의 비교가 같은 시간축 위에서 성립한다.

주입

바꾸는 것은 파일 하나, 두 줄이다. 어느 디렉터리에 넣는가가 먼저 정해져야 한다.

디렉터리 언제 실행되나
pre/ 갱신 시도
deploy/ 실제로 갱신된 인증서가 있을 때만
post/ 갱신 여부와 무관하게 매번

deploy/ 인가. 타이머는 하루 두 번 돈다. post/ 에 넣으면 갱신이 없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만 갈아치우는 셈이다. deploy/ 는 certbot 이 RENEWED_LINEAGE 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다. 없거나 틀리면 D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀌며, 그 상태로 타이머는 SUCCESS 를 찍는다.

nginx -t && 를 앞에 두는 까닭도 같은 종류의 안전장치다.

nginx -t && nginx -s reload

설정이 깨진 상태에서 nginx -s reload 를 보내면 마스터가 새 워커를 못 띄운다. -t 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 옛 워커가 그대로 서비스를 계속한다 — 인증서는 안 바뀌지만 서비스는 죽지 않는다. 이 순서 하나가 「인증서가 안 바뀐다」와 「사이트가 내려간다」를 가른다.

restart 를 쓰지 않는 까닭도 같다. 실측(호스트)(observed) 로 확인한 nginx.service 의 유효 설정은 Restart=on-failure · RestartUSec=100ms · StartLimitBurst=5 · StartLimitIntervalUSec=10s 다. 설정이 깨진 채 restart 를 걸면 10초 안에 5번 실패하고 systemd 가 포기한다 — nginx 가 내려간 채로 멈춘다.

파일 내용은 sudo 가 필요 없는 곳에서 미리 만들어 둔다. 사람이 비밀번호를 치며 실행할 명령은 짧을수록 좋기 때문이다.

이 실험대는 셸로 파일을 만들었다(observed).

ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
ssh test-server 'cat /tmp/reload-nginx.sh'

따라 하는 사람은 편집기로 연다(unknown — 이 형태로는 실행하지 않았다). 훅은 읽고 고칠 파일이지 한 번 찍고 마는 출력이 아니고, printf 형태는 %s 없는 \n> 를 먼저 해독한 뒤에야 두 줄에 닿게 한다. 그리고 같은 절차를 두 번 밟았을 때 > 는 덮어쓰지만 >> 로 잘못 치면 줄이 두 번 들어간다 — 파일을 열면 이미 무엇이 있는지 보인다.

ssh test-server

호스트의 셸에서:

nano /tmp/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload

형태(모양은 observed) — 어느 쪽으로 만들었든 내용이 이 두 줄이면 같다

#!/bin/sh
nginx -t && nginx -s reload

두 줄이 맞게 들어갔는가를 본다. #!/bin/sh 가 첫 줄이어야 한다.

/tmp 를 여기서 쓰는 것은 괜찮다 — 이건 당신의 대화형 셸이 쓰는 /tmp 이기 때문이다. 다만 certbot-renew.servicePrivateTmp=true(실측(호스트), observed)라 그 서비스가 보는 /tmp 은 다른 곳이다. 훅이 나중에 /tmp 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 없다(unknown — 이 실험은 훅에 로그를 넣지 않았다). 훅의 로그는 logger 로 저널에 보내거나 /var/log 아래에 쓴다.

여기부터 사람이 친다.

ssh -t test-server

호스트의 셸에서:

sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/

이 실험대는 이 전부를 한 줄로 쳤다(observed). 사람이 비밀번호를 한 번만 치게 하려는 것이다.

ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \
  /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal \
  > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'

읽기는 어렵다고 가이드가 스스로 적는다. 처음 할 때는 한 줄씩 치고, 익숙해지면 합친다. 한 줄로 합치면 설치와 강제 갱신이 한 명령 안에 들어가서, 중간에서 멈췄을 때 훅이 깔린 상태인지 아닌지를 따로 봐야 한다.

5~8 절은 2026-09-17 에 실제로 밟았고, 기계가 test-server 가 아니었다(observed). 03 이 nginx 를, 04 가 certbot 을 kc-lab-edge 로 옮겼으므로 /etc/letsencrypt/renewal-hooks/deploy/nginx 도 그 게스트 안에 있다. 1 절의 ps 줄을 랩 호스트에서 치면 한 줄도 안 나온다.

total 4
-rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh

파일 크기가 위 예시의 40 이 아니라 38 이다. 훅을 손으로 돌리면 nginx -t 의 두 줄 뒤에 예상 결과에 없는 셋째 줄이 따라온다.

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/09/17 07:34:08 [notice] 2361#2361: signal process started

셋째 줄은 nginx -s reload 가 신호 보낼 프로세스를 띄웠다는 알림이고 실패가 아니다.

그리고 이 편이 세우려는 판정 기준이 여기서 그대로 작동했다(observed).

전   1065  1  11186  Thu Sep 17 04:27:39 2026  nginx: master process
     1106  1065  11177  Thu Sep 17 04:27:49 2026  nginx: worker process
뒤   1065  1  11189  Thu Sep 17 04:27:39 2026  nginx: master process
     2362  1065      0  Thu Sep 17 07:34:08 2026  nginx: worker process

마스터는 1065 로 그대로이고 워커가 1106 에서 2362 로 갈렸으며 새 워커의 etimes0 이다. 판정표의 「마스터 그대로 · 워커 바뀜 = reload 됐다」 그 칸이고, 인증서가 없어도 이 판정 기준 자체는 검증된다.

오래 미검증이던 줄 셋도 같은 날 쳤다(observed). 셋 다 인증서 없이 돌고, 값이 생겼다.

어디 명령 2026-09-17 의 값
D-4a 8 절 sudo certbot renew --dry-run No simulated renewals were attempted. · 종료 0
B-7 6 절 두 cookie secret 의 wc -c 32 · 32
B-6 겹침 절 realm 의 수명 세 값 accessTokenLifespan 60 · ssoSessionIdleTimeout 1800 · ssoSessionMaxLifespan 은 안 나온다
Saving debug log to /var/log/letsencrypt/letsencrypt.log
No simulated renewals were attempted.

갱신할 것이 없으면 훅도 안 불린다. Running deploy-hook command 줄은 안 나오고 종료 코드는 그래도 0 이다. 그래서 이 명령의 성공은 훅이 도는지에 대해 아무 말도 안 한다.

★ 그런데 인증서를 세우고 훅까지 놓아도 dry-run 은 훅을 안 부른다(2026-09-17, observed). /etc/letsencrypt/renewal-hooks/deploy/ 에 실행 권한까지 준 훅을 놓고 시뮬레이션이 성공한 판에서도 Running deploy-hook command 는 안 나온다.

sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
sudo grep -c deploy-hook /tmp/dr.txt
certbot --version
-rwxr-xr-x 1 root root 38 Sep 17 09:23 reload-nginx.sh
0
certbot 2.1.0

certbot --help all--run-deploy-hooks 도 없다(2.1.0 기준, 센 값 0). 이 판의 certbot 에서는 dry-run 으로 훅을 확인할 방법이 없다. 훅이 도는 것은 진짜 갱신에서만 보인다.

★ 그리고 같은 명령이 성공하기도 실패하기도 한다(2026-09-17, observed). 세 번을 연달아 쳐는데 성공 · 실패 · 성공이었고, 실패한 판은 앞의 둘과 또 다른 사유다.

Certbot failed to authenticate some domains (authenticator: dns-cloudflare). The Certificate Authority reported these problems:
  Domain: auth.hyeonworks.com
  Type:   dns
  Detail: During secondary validation: DNS problem: NXDOMAIN looking up TXT for _acme-challenge.auth.hyeonworks.com - check that a DNS record exists for this domain

Hint: The Certificate Authority failed to verify the DNS TXT records created by --dns-cloudflare. Ensure the above domains are hosted by this DNS provider, or try increasing --dns-cloudflare-propagation-seconds (currently 30 seconds).
certbot exit=1

기본값 30초가 이 도메인에서는 아슬아슬하다. 한 번 실패했다고 설정이 틀린 것이 아니다. 이 판의 종료 코드는 1 이었다.

★ 더 나쁜 것은, 종료 코드가 실패를 일관되게 알려 주지 않는다는 점이다(2026-09-17, observed). 같은 「전부 실패」 본문을 두 번 받았는데 한 번은 0, 한 번은 1 로 끝났다.

Failed to renew certificate auth.hyeonworks.com with error: You should register before running non-interactively, …
All simulated renewals failed. The following certificates could not be renewed:
  /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure)
1 renew failure(s), 0 parse failure(s)
certbot exit=0

조금 뒤에 같은 명령을 다시 치니 실패 사유가 바뀜었고, 종료 코드도 같이 바뀜었다.

Failed to renew certificate auth.hyeonworks.com with error: Missing command line flag or config entry for this setting:
Please choose an account
Choices: ['test-server@2026-09-03T01:50:44Z (66d5)', 'kc-lab-edge@2026-09-17T08:16:31Z (5df4)']

All simulated renewals failed. The following certificates could not be renewed:
  /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure)
1 renew failure(s), 0 parse failure(s)
certbot exit=1

두 번 다 All simulated renewals failed 이고 1 renew failure(s) 인데 종료 코드만 갈렸다. 0 을 성공으로 읽으면 첫 번째를 놓치고, 그렇다고 1 을 기다려도 두 번째에서만 맞는다. 이 명령에 대해 종료 코드는 판정 근거가 못 된다.

종료 코드를 따로 잡아서 보면 이렇다.

sudo certbot renew --dry-run >/tmp/dr.txt 2>&1; echo "certbot exit=$?"

그러므로 && 로 뒤를 잇거나 $? 로 갈라선 안 된다. 판정은 본문의 renew failure(s) 수로 한다. 실패를 실패로 읽는 형태는 이렇다.

sudo certbot renew --dry-run >/tmp/dr.txt 2>&1
grep -E 'Failed to renew|renew failure' /tmp/dr.txt

두 줄이다. 먼저 파일로 받아 두고, 그 파일에서 판정에 쓰는 줄만 골라 눈으로 읽는다. renew failure(s) 앞의 숫자가 0 이고 Failed to renew 줄이 없으면 통과고, 위 실측처럼 1 renew failure(s)Failed to renew 가 같이 나오면 실패다. 갱신할 인증서가 한 장도 없으면 grep 은 아무것도 안 찍는다 — 그건 통과가 아니라 시뮬레이션할 것이 한 장도 없었다는 뜻이라 /tmp/dr.txt 를 그대로 열어 본다. 이 판정을 &&|| 로 이어 붙이지 않는 까닭도 같다 — 종료 코드가 거짓말하는 명령을 살피는 절에서 종료 코드로 갈라지는 문장을 쓰면 같은 함정을 다시 판다.

D-4a 가 재려는 것이 「성공했다고 보고하는데 실제로는 안 된 일」인데, 사전 점검 명령 자체가 그 성질을 갖고 있다.

★ 그리고 인증서를 옮겨도 갱신 능력은 따라오지 않는다(observed). 다른 기계에서 live/ · archive/ · renewal/ 만 가져오면 위 오류가 난다. renewal/*.conf 가 가리키는 ACME 계정(/etc/letsencrypt/accounts/)과 DNS 자격증명 파일(cloudflare.ini)이 없기 때문이다. 오류 문구가 「등록부터 하라」여서 계정을 새로 만들라는 말로 읽히는데, 실제로 빠진 것은 옮겨 오지 않은 디렉터리다. §철거 절이 /etc/letsencrypt 를 통째로 묶으라고 적은 것이 이 점에서 옳다 — 골라 담으면 갱신이 죽는다.

★ 그리고 --dry-run 이 보는 계정은 renewal/*.conf 가 가리키는 계정이 아니다(2026-09-17, observed). --dry-run 은 Let's Encrypt staging 서버로 붙는데, renewal/*.confaccount = 은 운영 계정의 id 를 적어 둔다. 그래서 staging 쪽 계정이 둘 이상이면 certbot 이 고르지 못하고 앞서 본 Please choose an account 로 멈는다.

sudo find /etc/letsencrypt/accounts -mindepth 3 -maxdepth 3 -type d
sudo grep -E '^(account|server) ' /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
/etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/66d5d86599378a0b07936733587017a6
/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272
account = 8d53f9312e2a4c9cdd13620122cd8272
server = https://acme-v02.api.letsencrypt.org/directory

account = 이 가리키는 8d53…acme-v02(운영) 아래에만 있다. --dry-runacme-staging-v02 아래를 보고, 거기 계정이 하나면 그것을 쓰고 둘이면 묻는다. 고르라고 나온 66d55df4 는 둘 다 staging 계정이었다 — 5df4 는 실패한 dry-run 이 그때 새로 만든 것이라, 한 번 실패하고 나면 다음 dry-run 이 계속 실패한다.

고르라고 나오는 이름은 계정 폴더의 meta.json 에서 온다.

sudo sh -c "cat /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/*/meta.json"
{"creation_dt": "2026-09-03T01:50:44Z", "creation_host": "test-server"}

Choices: 에 찍힌 test-server@2026-09-03T01:50:44Z (66d5) 가 바로 이 두 칸과 폴더 이름 앞 네 글자다. 둘 이상 나오면 나중에 생긴 쪽의 폴더를 sudo rm -rf 로 지워 하나만 남긴다. 이 실험대에서는 kc-lab-edge 가 만든 쪽을 지워 test-server 가 만든 하나만 남겨 두었다(observed). 지우는 것은 staging 계정이라 운영 인증서에는 영향이 없다.

위 명령에 sudo sh -c 가 붙은 까닭은 경로에 * 가 들어 있기 때문이다. sudo cat …/*/meta.json 은 셀이 먼저 * 를 푸는데, 그 셀은 root 가 아니라 accounts/ 안을 못 읽고 No such file or directory 로 끝난다. §04 의 renewal/*.conf 글로브 함정과 같은 자리다.

그래서 --dry-run 의 실패와 진짜 갱신의 실패는 같은 것이 아니다. 다만 이 실험대에서 진짜 갱신을 다시 치는 데까지 가지는 않았다(unknown). 확인한 것은 운영 계정이 하나뿐이고 그것이 account = 이 가리키는 바로 그 id 라는 것까지다.

중복 계정을 지우고 다시 치니 dry-run 이 통과했다(2026-09-17, observed).

certbot exit=0
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains
Waiting 30 seconds for DNS changes to propagate
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)

★ 2026-09-17 에 엣지에서 다시 치고 갱신이 서빙까지 닿는 것을 봤다(observed). 이번엔 [test-server] 가 아니라 [kc-lab-edge] 에서 쳤다 — 인증서와 nginx 가 거기 있기 때문이다.

Renewing an existing certificate for auth.hyeonworks.com and 2 more domains
Hook 'deploy-hook' ran with error output:
 nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
 nginx: configuration file /etc/nginx/nginx.conf test is successful
 2026/09/17 10:36:31 [notice] 4744#4744: signal process started

Congratulations, all renewals succeeded: 
  /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
certbot exit=0

판정은 이 출력이 아니라 서빙하는 인증서로 한다. 같은 소켓을 갱신 전후로 두 번 열어 일련번호와 날짜를 견준다.

echo | openssl s_client -connect 127.0.0.1:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -dates -serial
notBefore=Sep  4 11:29:18 2026 GMT
notAfter=Dec  3 11:29:17 2026 GMT
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
notBefore=Sep 17 09:37:58 2026 GMT
notAfter=Dec 16 09:37:57 2026 GMT
serial=065547991777D11A408CEA90D945DDA03DF1

일련번호가 바뀌었으므로 훅이 돌았고 nginx 가 새 파일을 집었다. 같은 순간 worker 프로세스도 바뀜다 — 갱신 전 2629 4712, 뒤 4745 4754. 디스크에는 privkey4.pemSep 17 10:36 으로 생겼다.

원래 실행과 다른 데가 둘 있다(observed). 첫째, 훅 출력에 types_hash 경고 줄이 없다 — 엣지의 nginx 설정이 호스트의 것과 달라서다. 둘째, 원래는 worker 하나가 그대로 남았는데 이번에는 둘 다 교체됐다. ran with error output 이 실패가 아니라는 것은 그대로다 — stderr 로 나간 세 줄이 전부 성공 메시지다.

새 인증서의 notBefore 가 훅이 도는 시각(10:36:31)보다 약 한 시간 앞이다. 왜 그런지는 이 실험대에서 가르지 않았다(unknown) — 발급자가 앞당긴 것인지 시계 차인지는 안 재 봤다.

세 이름 모두 갱신 뒤에도 검증을 통과한다.

auth 302 verify=0
app1 200 verify=0
app2 302 verify=0

그리고 이것이 종료 코드 이야기를 닫는다. 성공도 0 이고 첫 번째 실패도 0 이었다. 같은 명령이 돼을 때와 안 돼을 때 같은 값을 내므로 $? 로는 둔 경우를 가를 수 없다. 본문을 읽는 수밖에 없다.

주입 검증

갱신을 걸기 전에 훅이 제자리에, 실행 가능한 상태로 있는지 본다. 한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.

sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/

형태(모양은 observed)

total 4
-rwxr-xr-x 1 root root 40 Sep  4 21:2x reload-nginx.sh

세 가지를 본다.

  • x 비트(-rwxr-xr-x). 없으면 certbot 이 그냥 건너뛴다
  • 디렉터리가 deploy/ 인가. post/ 에 들어가면 매번 돈다
  • 소유자가 root

손으로 한 번 돌려 보는 것이 가장 확실한 사전 점검이다.

sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

형태(모양은 observed)

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

test is successful 을 본다. 이때 워커 PID 도 바뀐다 — 이 스크립트는 실제로 reload 한다. 그러므로 앞에서 적어 둔 워커 PID 를 다시 재서 새 값으로 바꿔 둔다. 이 단계를 건너뛰면 관찰 절의 「워커가 바뀌었다」가 훅이 한 것인지 손으로 돌린 것이 한 것인지 갈리지 않는다.

certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래는 가이드가 미검증으로 표시한 줄이다(unknown).

sudo certbot renew --dry-run

출력에 Running deploy-hook command 계열의 줄이 나오는가, 그리고 simulated renewals 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 — 그래서 관찰 절이 필요하다.

관찰

되돌리기가 없는 한 줄부터다. 인증서 한 장을 실제로 발급한다.

date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
sudo certbot renew --force-renewal

시각을 기록하되 어느 시계인지 반드시 적는다. 호스트에서 찍은 것은 (ts) 이고 106초 빠르다.

★ certbot 출력에 함정이 있다.

실측(observed) — 02-certbot-with-hook.txt

Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Renewing an existing certificate for auth.hyeonworks.com and 2 more
Hook 'deploy-hook' ran with error output:
 2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
 nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
 nginx: configuration file /etc/nginx/nginx.conf test is successful
 2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
 2026/09/04 21:29:37 [notice] 37251#37251: signal process started

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewals succeeded:
  /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)

다섯 줄을 하나씩 읽는다.

실제 의미
Hook 'deploy-hook' ran with error output: 훅이 실행됐고, stderr 에 뭔가 있었다
[warn] could not build optimal types_hash nginx 의 일반 경고. 갱신과 무관
nginx: … test is successful nginx -t 통과
[notice] … signal process started nginx -s reload 가 신호를 보냈다
Congratulations, all renewals succeeded 갱신 성공

ran with error output 은 실패가 아니다. certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙이는데, 종료 코드를 말하는 것이 아니다. 여기서 stderr 로 나간 것은 nginx 의 types_hash 경고뿐이고 내용은 전부 성공이다.

로그에서 error 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로 오독한다. 그리고 반대 방향도 위험한데, 이 실험은 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지 재지 않았다(unknown). 그래서 판정은 문구가 아니라 워커 PID 로 한다.

ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측(observed) — 03-after-state.txt

    585       1   95412 Thu Sep  3 19:00:39 2026 nginx: master process /usr/bin/nginx
  37252     585      74 Fri Sep  4 21:29:36 2026 nginx: worker process
판정
마스터 585 585 그대로
워커 28829 37252 바뀌었다
워커 lstart Fri Sep 4 18:00:35 (ts) Fri Sep 4 21:29:36 (ts) 방금 떴다
워커 etimes 74 74초 전

마스터 PID 는 유지되고 워커만 바뀌었다. D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 그대로 작동한다. 그리고 이번에는 사람이 아니라 훅이 했다 — 왼쪽 칸의 워커 28829 는 D-4 에서 사람이 친 nginx -s reload 가 만든 것이고, 오른쪽 칸의 37252 는 deploy/ 훅이 만든 것이다.

etimes 74 를 같이 보는 까닭 — PID 는 우연히 재사용될 수 있다. lstartetimes 가 「방금」을 가리켜야 진짜 새 워커다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -serial -dates -ext subjectAltName

실측(observed) — 03-after-state.txt

serial=06F3E0EF4D1BB03DE58130EAAD1176101373
notBefore=Sep  4 11:29:18 2026 GMT
notAfter=Dec  3 11:29:17 2026 GMT
X509v3 Subject Alternative Name:
    DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com

serial 이 주입 전에 적어 둔 값과 다른가를 본다. D-4 의 인증서(06C7CB…EA1D)에서 바뀌었고 SAN 은 세 이름 그대로다. 훅 하나로 ①②가 끝났고 남은 것은 「얼마나 빨랐나」다.

★ 시계 보정이 여기서 결과를 정한다. 가진 시각은 셋이고 두 개는 다른 시계에서 왔다.

사건 원래 값 어느 시계
인증서 발급 SCT Sep 4 12:27:49.054 GMT CT 로그 (독립)
훅의 nginx -t 로그 2026/09/04 21:29:36 (ts)
새 워커 기동 lstart Fri Sep 4 21:29:36 (ts)
훅의 nginx -s reload 로그 2026/09/04 21:29:37 (ts)
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -ext ct_precert_scts | grep Timestamp

실측(observed) — 01-hook-verified.txt

    Signed Certificate Timestamp: Sep  4 12:27:49.054 2026 GMT
    Signed Certificate Timestamp: Sep  4 12:27:49.048 2026 GMT

보정한다(ts) 값에서 106초를 뺀다.

  12:27:49.05   인증서 발급          ← SCT (외부 권위 기준)
  12:27:50      훅 nginx -t          ← 로그 21:29:36 KST(ts)  106초
  12:27:50      새 워커 37252 기동   ← lstart 21:29:36 KST(ts)  106초
  12:27:51      훅 nginx -s reload   ← 로그 21:29:37 KST(ts)  106초

발급에서 서빙까지 1~2초다.

보정이 자기 검증된다. 독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 놓인다.

보정하지 않으면 어떻게 되는가 — 이 절은 두 가지를 한 문장에 붙여 놓고 있었고, 갈라 적는다.

어떻게 계산하나 나오는 값 무엇이 틀렸나
그냥 뺀다 (12:29:36 12:27:49) +107초 훅이 발급보다 107초 로 보인다. 참값 1~2초보다 약 106초 크다
106초를 반대쪽에 건다 훅이 발급보다 음수 지연이다. 훅은 갱신이 끝나야 도니 성립하지 않는다

원문은 앞 칸의 수치(+107초)에 뒷 칸의 결론(「104초 먼저」)을 이어 붙이고 있었다. +107초 는 「뒤」이므로 거기서 「먼저」가 나오지 않고, 104 라는 수가 어느 계산에서 나왔는지도 이 문서에 남아 있지 않다 (unknown). 고쳐 쓰지 않고 어긋남을 적어 둔다 — 규칙이 서는 근거는 두 계산 어느 쪽이든 같다.

음수 지연이 나오면 계산이 아니라 시계를 의심한다. 그 의심을 가르는 것은 제3의 시계다 — 여기서는 CT 로그의 SCT 였다.

notBefore 로는 계산하지 않는다. 인증서에는 notBefore=Sep 4 11:29:18 이라고 적혀 있지만 이건 발급 시각이 아니다. Let's Encrypt 는 notBefore정확히 한 시간 백데이트한다 — 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다. 그리고 한 시간을 더한 값(12:29:18)을 발급 시각으로 그대로 쓰지도 않는다. 이 실험대의 두 인증서에서 SCT 는 그보다 일관되게 약 89초 앞섰다.

인증서 notBefore notBefore + 1시간 SCT 차이
D-4 이전 것 Sep 3 00:47:23 01:47:23 01:45:53.18 약 89.8초
D-4a 새것 Sep 4 11:29:18 12:29:18 12:27:49.05 약 88.9초

이 차이의 원인은 이 실험이 규명하지 않았다(unknown). 다만 시각의 기준으로는 SCT 를 쓴다 — 그것이 보정을 자기 검증한 값이기 때문이다. notBefore 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.

D-4 와 나란히 놓으면 이렇다.

훅 없음 (D-4) 훅 있음 (D-4a)
갱신 → 서빙 2305초 = 38분 25초 1~2초
무엇이 reload 했나 사람이 친 nginx -s reload certbot deploy 훅
아무도 안 했다면 다음 nginx 재시작까지 = 사실상 무기한 해당 없음
차이 약 1150배

바뀐 것은 파일 하나, 두 줄이다.

부수 정정이 하나 딸려 나왔다 — D-4 의 2199초는 틀렸다. 이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다. D-4 에서 적은 2199초(36분 39초)archive/cert2.pem 의 mtime (test-server 시계)과 일련번호 관측(dev 시계)을 그대로 뺀 값이었다.

시각 (실제 UTC)
새 인증서 디스크 기록 08:20:27 ← mtime 17:22:13 KST (ts) 106초
실제 서빙 시작 08:58:52 ← dev 관측, 보정 불필요
공백 2305초 = 38분 25초

두 시계에서 온 값을 빼면서 그 사실을 적지 않으면 자릿수가 아니라 방향까지 틀릴 수 있다. D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만 1~2초를 재는 여기서는 결과를 완전히 뒤집었다.

복구와 원상복구 확인표

이 주입은 고장이 아니라 고침이라 남긴다. 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다. 정말 지워야 한다면 두 줄이다.

ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'

다시 total 8 인가를 본다.

남은 미검증이 하나 있고, 시간이 지나야 시험할 수 있다.

항목 상태
certbot-renew.timer실제 갱신을 하는가 미측정. 만료 30일 전에야 조건이 성립한다 — 증거의 VALID: 89 days만료까지이므로 갱신은 약 59일 뒤

훅은 --force-renewal 로 검증했다. 타이머가 스스로 갱신하는 경로는 시간이 지나야 시험할 수 있는데, 그 경로도 같은 certbot renew 를 부르고 같은 deploy/ 훅을 실행하므로 남은 미지수는 「타이머가 뜨는가」 하나이고 그것은 D-4 에서 이미 확인했다(오늘 두 번 status=0/SUCCESS).

그날이 오면 두 줄이면 된다.

ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -serial -enddate

워커 lstart갱신 시각 근처인가, 그리고 notAfter 가 밀렸는가를 본다. 문구가 아니라 이 둘이다.

항목 명령 이렇게 되어 있어야 한다
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ -rwxr-xr-x … reload-nginx.sh (남긴다)
nginx ps -eo pid,ppid,etimes,lstart,args | grep nginx: 마스터 그대로, 워커 새것
서빙 인증서 openssl … -serial -dates 관찰 절의 새 일련번호
체인 D-4 의 체인 확인 한 줄 4단계, Verify return code: 0
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
임시 파일 ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt' 지워도 된다. 훅은 /etc 에 설치됐다
발급 한도 이번 주에 몇 장 썼는지 세어 둔다

막히면

증상 원인 확인
ran with error output 을 보고 실패로 판단했다 stderr 에 뭔가 있으면 무조건 붙는 문구다 워커 PID
훅이 아예 안 불렸다 x 비트가 없거나 deploy/ 가 아니다 sudo ls -l …/deploy/
훅은 돌았는데 워커가 그대로 nginx -t 가 실패해 && 뒤가 안 돌았다 훅을 손으로 실행
워커도 마스터도 바뀌었다 reload 가 아니라 재시작됐다 lstart 두 줄을 본다
지연이 음수로 나온다 두 시계를 그대로 뺐다 시계 재는 절차로 돌아간다
발급 시각이 한 시간 어긋난다 notBefore 를 발급 시각으로 읽었다 SCT 를 본다
시계 왜곡을 지금 재려는데 값이 흔들린다 네트워크 지연이 섞였다 3회 이상 재서 안정적인지 본다
호스트 명령이 조용히 빈 결과 sudo 가 비밀번호를 못 물었다 ssh -t 로 다시
훅 로그를 /tmp 에 썼는데 안 보인다 certbot-renew.servicePrivateTmp=true logger 로 저널에 보내거나 /var/log 아래에 쓴다(unknown)
nginx 경고가 계속 거슬린다 types_hash_max_size 기본값 갱신과 무관하다. 고치려면 nginx.conf 를 손본다

이 편이 남기는 한 문장은 「처방을 적었으면 시험한다」이다. D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었고, 그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다. 확인하지 않은 채로 문서에 남았다면 「고치는 법」 항목은 다음 갱신일까지 아무도 시험하지 않은 문장으로 남았을 것이다 — 그리고 그날이 바로 시험할 수 없는 날이다.

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

  • (observed) 주입 전 워커 두 줄(58528829, lstart Fri Sep 4 18:00:35), 훅 디렉터리의 total 8, 시계 측정 네 줄과 +106.1 세 번, certbot 출력 전문(ran with error output · types_hash 경고 두 줄 · test is successful · signal process started · Congratulations, all renewals succeeded · fullchain.pem (success)), 주입 뒤 워커 두 줄(585 · 37252 · etimes 74 · lstart Fri Sep 4 21:29:36 2026), 새 인증서의 serial=06F3E0EF4D1BB03DE58130EAAD1176101373 · notBefore=Sep 4 11:29:18 2026 GMT · notAfter=Dec 3 11:29:17 2026 GMT 와 SAN 세 이름, SCT 두 줄(Sep 4 12:27:49.054 · Sep 4 12:27:49.048), notBefore ↔ SCT 표의 약 89.8초 · 약 88.9초.

  • (observed, 호스트 확인) nginx.serviceRestart=on-failure · RestartUSec=100ms · StartLimitBurst=5 · StartLimitIntervalUSec=10s, certbot-renew.servicePrivateTmp=true. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트) 로 따로 표시했다.

  • 이 편이 잰 reload 는 훅이 건 것이다 — 워커 37252 를 만든 것은 deploy/ 훅이고, 주입 전 워커 28829D-4 에서 사람이 친 nginx -s reload 가 만든 것이다. 표의 「전 / 후」 두 칸이 사람과 훅이다. 1~2초는 훅이 건 reload 를 잰 값이고, D-4 의 2305초는 사람이 건 reload 까지의 공백이다.

  • 2199 → 2305 는 이 실험이 앞 실험을 정정한 것이다 — D-4 가 archive/cert2.pem 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서 2199초로 적었고, 여기서 시계 왜곡 106초를 재고 나서 2305초로 고쳤다. D-4 에서는 106초가 결론을 안 바꿨지만 여기서는 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나 결과가 뒤집힌다. 정정한 값과 정정 전 값을 둘 다 남겨 둔 까닭이 그것이다.

  • (unknown) certbot renew --dry-run 에서 Running deploy-hook command 줄이 나오는지 — 이 실험대는 곧바로 강제 갱신을 했다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 훅이 /tmp 에 남긴 로그가 PrivateTmp 때문에 안 보이는지, notBefore+1시간과 SCT 사이 약 89초 차이의 원인. 가이드가 전부 미검증으로 표시했다.

  • 두 형태로 적은 곳이 둘이다 — 훅 파일을 만드는 것과 설치·갱신을 한 줄로 합치는 것. 이 실험대는 printf … > /tmp/reload-nginx.sh 로 만들고 설치와 강제 갱신을 한 줄로 쳤다(observed). 편집기로 여는 형태와 한 줄씩 치는 형태는 이 형태로 실행하지 않았다(unknown). 실제로 친 줄을 지우지 않고 나란히 적었다.

  • 비밀은 이 편에 나오지 않는다 — 다루는 값이 훅 파일 두 줄과 PID 와 시각이라 옮길 비밀이 없다. 일련번호·PID·호스트명은 식별자라 그대로 적었다. 새 privkey2.pem 도 D-3 의 문제를 그대로 안고 있지만 이 편은 그 파일을 열지 않는다.

  • 이 실험이 확인하지 않은 것 — 타이머가 스스로 갱신하는 경로. 만료 30일 전에야 조건이 성립하고, 그때 볼 두 줄만 적어 두었다.

  • 가이드가 「다음」에 적은 한 줄이 이 편의 결론이기도 하다 — 「04-TLS 구축 단계 | 이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다」. D-4 가 잰 38분 25초의 공백은 훅이 없어서 생긴 것이고, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.