fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다

test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

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

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 19:38:12 +09:00
co-authored by Claude Opus 5
parent 4a457afde9
commit 32e39e20aa
150 changed files with 6066 additions and 269 deletions
File diff suppressed because it is too large Load Diff
@@ -165,3 +165,103 @@ SSOT 의 「2026-09-17 재현 — 어디까지 밟았고 무엇이 막았나」
비밀은 어느 파일에도 값으로 들어 있지 않다. 비밀번호는 길이(19·22)만 적었고, 화면에
찍혀 나온 토큰은 <REDACTED n자> 로 가렸다.
--- 2026-09-17 추가 (인증서 대기 중에 잰 것) ---
ext-01..02 밖에서 http 로 닿는지, X-Forwarded-For 가 오는지
ext-03..05, ext-09,11 traefik-forwarded-headers.yaml 적용 전/후/되돌린 뒤 앱이 받은 헤더
ext-06..07 인증서 상태와 발급 전제 (플러그인 O, cloudflare.ini X)
ext-08 http 만으로 어디까지 되는지 (well-known 200 · token 401)
ext-10, ext-12 출발지를 덮는 것이 무엇인지 — 호스트 sudo 가 막아 못 가림
ext-13 BFF 로그인 체인이 https://auth 로 가서 막히는 것
b6-01..09 B-6 키 회전을 문서대로 밟은 기록. 키 조작은 재현, 401/200 판정은 못 냄
ext-14 파드에서 공개 이름이 100.83.212.4 로 풀리고 80·443 이 닫혀 있는 것
ext-15 CoreDNS 를 *.override 로 고치려다 CrashLoopBackOff 난 기록
ext-16 *.server 로 고쳐 넣어 CoreDNS 가 뜬 기록
ext-17 고친 뒤 파드에서 엣지 192.168.122.10:80 이 열리는 것
d4-01 D-4 5·6·7 절. 7 절이 test-server 에서 빈손이고 nginx 는 엣지에 있다
d4a-01 D-4a 5~8 절. 훅 작성·설치·실행과 워커 PID 가 갈리는 것
b7-01, b7-02 CoreDNS 를 고친 뒤 oauth2-proxy 가 두드리는 주소가
100.83.212.4 에서 192.168.122.10 으로 바뀐 것과, 빌린 Grafana Ingress 복구
misc-01 오래 미검증이던 명령 넷 — cookie secret 길이 32·32, R 함수가 빈 출력,
certbot renew --dry-run 이 인증서 0장에서 내는 줄
b2-01 --scan | xargs del 이 tr -d '\r' 없이도 도는 것과, od -c 로 본 줄 끝
b2-02 b2 ②③④ 의 세션 수 — DB 5 와 관리 API 4 가 다른 것
b0-01..03 TLS 뒤 b0 상태 확인 — BFF 가 B-1 상태이고 /actuator/beans 가 밖에서 200
b1-01..05 B-1 을 끝까지 — 로그인, Redis 키 둘, head -1 이 엉뚱한 키를 집는 것
b6-10..18 B-6 을 끝까지 — 대조군 200, 추가는 무중단, 제거 뒤 401/200 이 번갈아 나오는 것
b7-03..08 B-7 을 끝까지 — 클라이언트가 없어 Client not found, 만든 뒤 회전까지
b7a-01 회전 뒤 남은 고아 세션과 TTL 이 갱신 없이 줄어드는 것
d4a-07-dryrun-after-removing-duplicate-staging-account.txt
kc-lab-edge 에서 `sudo certbot renew --dry-run`. staging ACME 계정이 둘이라
"Please choose an account" 로 멎던 것을, 나중에 생긴 계정 하나를 지운 뒤 다시 잰 것.
certbot exit=0 · "Congratulations, all simulated renewals succeeded".
실패했을 때도 0 이 나온 적이 있어 종료 코드로는 성공과 실패가 안 갈린다는 근거.
--- 2026-09-17 저녁 추가 (A-6 관찰·복구를 마저 밟은 기록) ---
a6-05-observe-prep.txt A-6 준비 1~5 — 배치와 파드 IP, a6-probe 파드, PW 길이 19,
구간별 시간 한 번, 두 노드 20회씩 기준선(k1 44ms · k0 40ms),
`grep "^agroal_"` 가 레이블과 값까지 열일곱 줄로 나오는 것
a6-06-observe.txt A-6 주입 재현과 관찰 1~5 — flannel.1 세 줄, 카운터 0 pkt → 18 pkt,
주입 뒤 k1 1865ms · k0 40ms, 동시 20건 1.87→22.25 초 계단 스무 줄,
sort / sort -g 가 같고 sort -g -k2 만 듣는 것, 부하 직후 풀 지표
(blocking_time_max 20000.0 · max_used_count 18.0),
readiness 프로브 context deadline exceeded, 낙관적 락 로그 0
a6-07-revert-checklist.txt A-6 복구와 확인표 여덟 줄 — flannel.1 noqueue, enp1s0 은
기본값 fq_codel, 회복 뒤 k1 42ms · k0 39ms, 엔드포인트 주소 둘,
awaiting/active 0, 탐침 NotFound, 밖에서 auth.hyeonworks.com 이
100.83.212.4 로 풀려 443 이 닫혀 000 이고 --resolve 로 짚으면 200
--- 2026-09-17 저녁 추가 (A-7 을 1번부터 복구까지 다시 밟은 기록) ---
a7-06-prep-and-switch.txt A-7 1~11 — 전환 전 args ["start"], DB 143행, 탐침, 교차 refresh 200,
기능 목록이 두 줄, DELETE 144, patch 가 "patched" 를 내는 것,
전환 뒤 로그인 5회 200 과 DB (0 rows), 캐시 지표 96줄 원문
a7-07-observe.txt A-7 12~15 — 교차 refresh 200, 롤링 재시작 뒤 400 Session not active,
14 ① 블록을 kc-lab-1 에서 문서 그대로 쳤을 때 kubectl 이 막히고
iptables 가 Bad argument 를 내며 ssh 가 Host key verification failed 이고
그래도 "차단" 시각이 찍히는 것, 규칙이 0개인 것, lab host 에서 다시 친
형태와 cluster_size 2→1(+75초)·해제 뒤 1→2(+60초), coord=t 둘,
대조군 200 · 시험군 400, DB 정지 뒤 500 / 200
a7-08-revert-checklist.txt A-7 복구와 확인표 아홉 줄 — args ["start"], git diff 빈 출력,
로그인 뒤 세션 행 1, raw PREROUTING 비어 있음, cluster_size 양쪽 2,
탐침 NotFound, 밖은 000 이고 --resolve 로 짚으면 200
--- 2026-09-17 저녁 추가 (B-5 를 1번부터 복구까지 다시 밟은 기록) ---
b5-04-baseline.txt B-5 준비 1~5 — 파드 배치, --scan 이 빈 출력(키 0), save 가
3600 1 300 100 60 10000 이고 appendonly yes, spec.volumes 에
kube-api-access-* 가 함께 있는 것, PVC 둘 Bound, 공개 이름으로는
세 경로가 000 이고 --resolve 로 짚으면 200/302/200, health 그룹 셋
b5-05-redis-down.txt B-5 주입 1 과 관찰 — Redis 0/0, BFF 로그가 pollConnect 가 아니라
ConnectionWatchdog "Cannot reconnect ... Connection refused",
bff 둘 1/1 RESTARTS 0, 엔드포인트 true,true, 세 경로 200/000/000,
/actuator/health 는 --max-time 10 이면 000 이고 60.046452 초 뒤 503
(redis: QueryTimeoutException), token-boundary 는 90초까지 무응답
b5-06-persistence.txt B-5 주입 2 와 지속성 시험 — 볼륨을 떼도 spec.volumes 가 빈 줄이
아니라 kube-api-access-* 하나, appendonlydir 생성, 파드 삭제 뒤
dbsize 0 인데 appendonly 는 yes(매니페스트 args 가 이미 yes),
PVC 를 되돌린 뒤 dbsize 4 와 written-on-pvc, 네 지표 시계열 0개
b5-07-revert-checklist.txt B-5 복구와 확인표 여덟 줄 — apply 후 redis 1/1, PVC Bound,
appendonly yes, bff 둘 1/1, 엔드포인트 둘, b5:* 삭제 1건이고 남은 키는
bff:session:sessions 셋, 밖은 000 이고 --resolve 로 짚으면 200
d4a-09-dryrun-with-deploy-hook.txt
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 를 755 로 놓은 뒤의
`sudo certbot renew --dry-run`. 시뮬레이션은 성공(exit=0)했는데
"Running deploy-hook command" 줄은 없다 — certbot 2.1.0 의 dry-run 은 deploy 훅을 건너뛴다.
d4a-10-dryrun-dns-propagation-flake.txt
같은 명령이 바로 앞뒤로는 성공했는데 이 판만 실패한 것. 사유는 DNS 전파로,
"During secondary validation: DNS problem: NXDOMAIN looking up TXT for
_acme-challenge.auth.hyeonworks.com" · propagation-seconds 기본값 30. certbot exit=1.
d4a-11-deploy-hook-not-called-in-dry-run.txt
위 판정의 근거 넷 — 훅 파일의 권한·크기, 성공한 출력에서 "deploy-hook" 을 센 값 0,
certbot 2.1.0, `--help all` 에 --run-deploy-hooks 가 없다는 것(센 값 0).
c2-05-probe-reachability-relive.txt
C-2 §5 의 임시 탐침 파드(curlimages/curl:8.11.1)를 2026-09-17 재구성 랩에서 다시 띄운 것.
app1.hyeonworks.com 이 192.168.122.10(엣지 게스트)으로 풀리고 HTTPS 200.
원래 실행의 100.83.212.4(tailnet 헤어핀)와 주소가 다르다 — 지금은 CoreDNS 의
coredns-custom 항목이 그 일을 한다. networkpolicy 는 여전히 없다.
d4-01-before-force-renewal.txt / d4-02-force-renewal.txt / d4-03-after-force-renewal.txt
D-4·D-4a 의 강제 갱신을 2026-09-17 에 kc-lab-edge 에서 실제로 친 것.
01 = 치기 전 archive 목록·nginx worker PID·서빙 인증서의 serial/날짜
02 = `sudo certbot renew --force-renewal` 전문. 배포 훅이 돌았고 certbot exit=0
03 = 친 뒤의 같은 세 가지 + 세 이름의 HTTPS 응답.
serial 06F3E0EF…1373 → 065547991777…3DF1, notAfter Dec 3 → Dec 16,
worker 2629 4712 → 4745 4754. 갱신이 서빙까지 닿았다는 근거.
@@ -0,0 +1,88 @@
=== a6 준비 1 ① 배치와 IP ===
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
bff-5d97897676-nmlqs 1/1 Running 0 150m 10.42.0.22 kc-lab-1 <none> <none>
bff-5d97897676-zvfhp 1/1 Running 0 150m 10.42.1.51 kc-lab-2 <none> <none>
keycloak-0 1/1 Running 0 121m 10.42.1.64 kc-lab-2 <none> <none>
keycloak-1 1/1 Running 0 121m 10.42.0.29 kc-lab-1 <none> <none>
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 55m 10.42.1.78 kc-lab-2 <none> <none>
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 55m 10.42.1.79 kc-lab-2 <none> <none>
postgres-7b474b88c8-48lrr 1/1 Running 0 160m 10.42.1.46 kc-lab-2 <none> <none>
redis-f94bc6dbf-s674z 1/1 Running 0 123m 10.42.1.63 kc-lab-2 <none> <none>
=== a6 준비 1 ② 세 값을 셸 변수에 ===
K0=10.42.1.64 K1=10.42.0.29 PG=10.42.1.46
=== a6 준비 2 ① 탐침 파드를 띄운다 ===
pod/a6-probe created
exit=0
=== a6 준비 2 ② 뜰 때까지 기다린다 ===
pod/a6-probe condition met
exit=0
=== a6 준비 2 ③ 값이 아니라 길이만 확인한다 ===
K0=10.42.1.64 K1=10.42.0.29 PW=19자
=== a6 준비 3 ① 구간별 시간을 함께 찍는다 ===
connect 0.000344 ttfb 0.110990 total 0.111040
=== a6 준비 4 ① keycloak-1 에 20회 ===
=== a6 준비 4 ② 원본을 먼저 본다 (k1) ===
0.044959
0.046268
0.046746
0.041681
0.040931
0.048671
0.040027
0.052310
0.040426
0.039144
0.042341
0.044985
0.042190
0.044052
0.043110
0.044736
0.039418
0.058032
0.040404
0.038562
=== a6 준비 4 ③ 그다음 줄여서 본다 (k1) ===
20회 평균 44 ms
=== a6 준비 4 대조군 — $K1 을 $K0 로, 파일 이름도 /tmp/base-k0 으로 ===
=== 원본 (k0) ===
0.046606
0.069418
0.044654
0.039537
0.037198
0.038300
0.037252
0.039732
0.036486
0.037554
0.036501
0.035420
0.036633
0.036898
0.052301
0.035256
0.036421
0.034673
0.035721
0.040525
=== 평균 (k0) ===
20회 평균 40 ms
=== a6 준비 5 ① 지표 이름 목록 ===
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
@@ -0,0 +1,225 @@
K0=10.42.1.64 K1=10.42.0.29 PG=10.42.1.46
=== 주입 전에 flannel.1 에 무엇이 걸려 있나 ===
qdisc noqueue 0: root refcnt 2
=== 주입 ① 밴드 3개짜리 분류기 ===
exit=0
=== 주입 ② 3번 밴드에 200ms ===
exit=0
=== 주입 ③ 출발지가 postgres 인 패킷을 3번 밴드로 ===
exit=0
=== 주입 ④ 주입 시각 ===
17:56:37 주입
=== 주입 검증 ① 넣은 직후의 카운터 ===
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
=== 주입 검증 ② 요청을 한 번 보낸다 ===
1.855960
=== 주입 검증 ③ 다시 센다 ===
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
Sent 1556 bytes 18 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
=== 관찰 1 ① 구간별 시간 (keycloak-1) ===
connect 0.000552 ttfb 1.864861 total 1.864966
=== 관찰 1 ② 20회 반복 — keycloak-1 ===
1.860758
1.870170
1.872980
1.876086
1.857782
1.859351
1.858796
1.860100
1.864847
1.874546
1.856623
1.870452
1.879456
1.867865
1.861147
1.861699
1.867978
1.862271
1.864539
1.857025
20회 평균 1865 ms 최대 1879 ms
=== 관찰 1 ③ 20회 반복 — keycloak-0 (대조군) ===
0.039154
0.043190
0.052382
0.037677
0.036750
0.037014
0.038348
0.035596
0.036738
0.038740
0.036093
0.040349
0.038105
0.040798
0.035273
0.042598
0.045344
0.038030
0.046025
0.034717
20회 평균 40 ms 최대 52 ms
=== 관찰 2 ① 동시 20건 ===
17:57:39 부하 시작
17:58:02 부하 끝
=== 관찰 3 ① 부하 직후에 읽는다 ===
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
=== 관찰 2 ② 다 모였는지부터 센다 ===
20 /tmp/load.txt
=== 관찰 2 ③ 원본을 본다 ===
200 1.889034
200 1.871238
200 1.967936
200 3.399275
200 4.813854
200 6.247018
200 7.652515
200 9.066673
200 10.495914
200 11.919137
200 13.326469
200 14.736787
200 16.159992
200 17.588220
200 21.854297
200 22.214107
200 22.207834
200 22.216690
200 22.245645
200 22.242271
=== 관찰 2 ④ 상태 코드와 시간을 나눠 본다 ===
20 200
1.871238
1.889034
1.967936
3.399275
4.813854
6.247018
7.652515
9.066673
10.495914
11.919137
13.326469
14.736787
16.159992
17.588220
21.854297
22.207834
22.214107
22.216690
22.242271
22.245645
=== 관찰 2 ⑤ 정렬 방식을 갈라 본다 — sort (사전순) ===
200 1.871238
200 1.889034
200 1.967936
200 10.495914
200 11.919137
200 13.326469
200 14.736787
200 16.159992
200 17.588220
200 21.854297
200 22.207834
200 22.214107
200 22.216690
200 22.242271
200 22.245645
200 3.399275
200 4.813854
200 6.247018
200 7.652515
200 9.066673
=== 관찰 2 ⑤ sort -g ===
200 1.871238
200 1.889034
200 1.967936
200 10.495914
200 11.919137
200 13.326469
200 14.736787
200 16.159992
200 17.588220
200 21.854297
200 22.207834
200 22.214107
200 22.216690
200 22.242271
200 22.245645
200 3.399275
200 4.813854
200 6.247018
200 7.652515
200 9.066673
=== 관찰 2 ⑥ sort -g -k2 ===
200 1.871238
200 1.889034
200 1.967936
200 3.399275
200 4.813854
200 6.247018
200 7.652515
200 9.066673
200 10.495914
200 11.919137
200 13.326469
200 14.736787
200 16.159992
200 17.588220
200 21.854297
200 22.207834
200 22.214107
200 22.216690
200 22.242271
200 22.245645
=== 관찰 4 ① 최근 이벤트 ===
57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled up replica set oauth2-proxy-d9b587458 from 0 to 1
57m Normal Pulled pod/oauth2-proxy-d9b587458-7x9jt Container image "quay.io/oauth2-proxy/oauth2-proxy:v7.7.1" already present on machine and can be accessed by the pod
57m Normal SuccessfulCreate replicaset/oauth2-proxy-d9b587458 Created pod: oauth2-proxy-d9b587458-7x9jt
57m Normal Created pod/oauth2-proxy-d9b587458-7x9jt Container created
57m Normal Started pod/oauth2-proxy-d9b587458-7x9jt Container started
57m Normal Created pod/oauth2-proxy-d9b587458-njxdw Container created
57m Normal Killing pod/oauth2-proxy-6c56c4c94f-gq484 Stopping container oauth2-proxy
57m Normal SuccessfulDelete replicaset/oauth2-proxy-6c56c4c94f Deleted pod: oauth2-proxy-6c56c4c94f-gq484
57m Normal Pulled pod/oauth2-proxy-d9b587458-njxdw Container image "quay.io/oauth2-proxy/oauth2-proxy:v7.7.1" already present on machine and can be accessed by the pod
57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled up replica set oauth2-proxy-d9b587458 from 1 to 2
57m Normal Started pod/oauth2-proxy-d9b587458-njxdw Container started
57m Normal SuccessfulCreate replicaset/oauth2-proxy-d9b587458 Created pod: oauth2-proxy-d9b587458-njxdw
57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled down replica set oauth2-proxy-6c56c4c94f from 2 to 1
57m Normal SuccessfulDelete replicaset/oauth2-proxy-6c56c4c94f Deleted pod: oauth2-proxy-6c56c4c94f-szr6j
57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled down replica set oauth2-proxy-6c56c4c94f from 1 to 0
57m Normal Killing pod/oauth2-proxy-6c56c4c94f-szr6j Stopping container oauth2-proxy
115s Normal Created pod/a6-probe Container created
115s Normal Pulled pod/a6-probe Container image "curlimages/curl:8.11.1" already present on machine and can be accessed by the pod
114s Normal Started pod/a6-probe Container started
19s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.29:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
=== 관찰 4 ② 파드 상태 ===
NAME READY STATUS RESTARTS AGE
a6-probe 1/1 Running 0 115s
bff-5d97897676-nmlqs 1/1 Running 0 152m
bff-5d97897676-zvfhp 1/1 Running 0 152m
keycloak-0 1/1 Running 0 123m
keycloak-1 1/1 Running 0 123m
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 57m
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 57m
postgres-7b474b88c8-48lrr 1/1 Running 0 162m
redis-f94bc6dbf-s674z 1/1 Running 0 125m
=== 관찰 5 ① 미검증 — 충돌 로그를 센다 ===
0
exit=1
@@ -0,0 +1,53 @@
=== 복구 1 ① 해제 시각 ===
17:58:20 해제
=== 복구 1 ② root 를 지운다 ===
exit=0
=== 복구 1 ③ 무엇이 남았는지 본다 ===
qdisc noqueue 0: root refcnt 2
=== 복구 2 회복 확인 — 20회 반복 측정을 그대로 다시 (keycloak-1) ===
keycloak-1 20회 평균 42 ms
=== 복구 2 회복 확인 — keycloak-0 ===
keycloak-0 20회 평균 39 ms
=== 확인표 qdisc (flannel.1) ===
qdisc noqueue 0: root refcnt 2
=== 확인표 (물리 쪽도) enp1s0 ===
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64
=== 확인표 파드 ===
NAME READY STATUS RESTARTS AGE
a6-probe 1/1 Running 0 2m9s
bff-5d97897676-nmlqs 1/1 Running 0 152m
bff-5d97897676-zvfhp 1/1 Running 0 152m
keycloak-0 1/1 Running 0 123m
keycloak-1 1/1 Running 0 123m
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 57m
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 57m
postgres-7b474b88c8-48lrr 1/1 Running 0 162m
redis-f94bc6dbf-s674z 1/1 Running 0 125m
=== 확인표 Service ===
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
keycloak-lxk8h IPv4 8080 10.42.0.29,10.42.1.64 4h22m
=== 확인표 풀 ===
agroal_active_count{datasource="default"} 0.0
agroal_awaiting_count{datasource="default"} 0.0
=== 탐침 파드를 지운다 ===
pod "a6-probe" deleted from keycloak-lab namespace
=== 확인표 탐침 파드 ===
Error from server (NotFound): pods "a6-probe" not found
exit=1
=== 확인표 밖 ===
000
=== 확인표 밖 — 같은 줄을 -s 없이 다시 ===
curl: (7) Failed to connect to auth.hyeonworks.com:443 after 3 ms: Could not connect to server
000
exit=7
=== 이름이 무엇으로 풀리는가 ===
100.83.212.4 auth.hyeonworks.com
=== 엣지 쪽에서 같은 줄 ===
000
exit=7
=== 엣지 주소로 직접 (이름만 헤더로) ===
200
exit=0
=== http 로 ===
301
exit=0
File diff suppressed because one or more lines are too long
@@ -0,0 +1,148 @@
=== a7 12 로그인하고 반대 노드에서 갱신한다 ===
200
=== a7 13 ① 토큰을 담고 access token 의 클레임을 편다 ===
{"exp":1789635852,"iat":1789635792,"jti":"onltro:81f8f0e3-06aa-890c-3b2b-bc1080d617bd","iss":"https://auth.hyeonworks.com/realms/master","typ":"Bearer","azp":"admin-cli","sid":"7HNHaJ1RxIKwxI-CUn86vUSe","scope":"profile email
=== a7 13 ② 시각을 남기고 롤링 재시작을 건다 ===
18:04:47 재시작
statefulset.apps/keycloak restarted
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...
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...
partitioned roll out complete: 2 new pods have been updated...
=== a7 13 ③ 롤아웃 뒤 IP 를 다시 잡는다 ===
10.42.1.84 10.42.0.38
=== a7 13 ④ 재시작 전 토큰으로 갱신을 시도한다 ===
{"error":"invalid_grant","error_description":"Session not active"}
400
=== (참고) 재시작 뒤 sessions 캐시 ===
"pod":"keycloak-1"}
"151"]}
"pod":"keycloak-0"}
"0"]}]}}
######## a7 14 ① 을 kc-lab-1 에서 문서 그대로 친 결과
time="2026-09-17T09:04:49Z" 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
time="2026-09-17T09:04:49Z" 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
Bad argument `7800'
Try `iptables -h' or 'iptables --help' for more information.
Bad argument `57800'
Try `iptables -h' or 'iptables --help' for more information.
Host key verification failed.
Host key verification failed.
09:04:49 차단
remote_exit=0
######## 그 뒤 두 노드의 raw PREROUTING — 규칙이 하나도 안 들어갔다
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
--- kc-lab-2 ---
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
K0=10.42.1.84 K1=10.42.0.38
=== a7 14 ① 양쪽 노드에 raw DROP 을 넣는다 (lab host 에서 두 노드 모두 ssh 한 줄로) ===
18:06:40 차단
=== a7 14 ② 두 노드의 규칙과 카운터를 본다 ===
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.0.38 tcp dpt:57800
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.0.38 tcp dpt:7800
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.84 tcp dpt:57800
2 116 DROP 6 -- * * 0.0.0.0/0 10.42.1.84 tcp dpt:7800
=== a7 14 ③ 두 노드가 각각 아는 멤버 수를 본다 ===
+25초
"pod":"keycloak-1"}
"2"]}
"pod":"keycloak-0"}
"2"]}]}}
+50초
"pod":"keycloak-1"}
"2"]}
"pod":"keycloak-0"}
"2"]}]}}
+75초
"pod":"keycloak-1"}
"1"]}
"pod":"keycloak-0"}
"1"]}]}}
+100초
"pod":"keycloak-1"}
"1"]}
"pod":"keycloak-0"}
"1"]}]}}
+125초
"pod":"keycloak-1"}
"1"]}
"pod":"keycloak-0"}
"1"]}]}}
=== a7 14 ② 다시 — 카운터가 올라갔나 ===
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
20 1184 DROP 6 -- * * 0.0.0.0/0 10.42.0.38 tcp dpt:57800
2 120 DROP 6 -- * * 0.0.0.0/0 10.42.0.38 tcp dpt:7800
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.84 tcp dpt:57800
22 4002 DROP 6 -- * * 0.0.0.0/0 10.42.1.84 tcp dpt:7800
=== a7 14 ④ 디스커버리 테이블의 코디네이터를 센다 ===
name | ip | coord
------------------+-----------------+-------
keycloak-0-41852 | 10.42.1.84:7800 | t
keycloak-1-65118 | 10.42.0.38:7800 | t
(2 rows)
[exited with code 0]
K0=10.42.1.84 K1=10.42.0.38
=== a7 14 ⑤ 대조군 — 로그인한 노드에서 갱신한다 ===
same-node 200
=== a7 14 ⑥ 시험군 — 새로 로그인해서 반대 노드에서 갱신한다 ===
{"error":"invalid_grant","error_description":"Session not active"}
cross-node 400
=== a7 14 차단을 푼다 (두 노드) ===
18:09:09 해제
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
=== 양쪽 cluster_size 가 2 로 돌아오는가 ===
+20초
"pod":"keycloak-1"}
"1"]}
"pod":"keycloak-0"}
"1"]}]}}
+40초
"pod":"keycloak-1"}
"1"]}
"pod":"keycloak-0"}
"1"]}]}}
+60초
"pod":"keycloak-1"}
"2"]}
"pod":"keycloak-0"}
"2"]}]}}
둘 다 2
[exited with code 0]
K0=10.42.1.84 K1=10.42.0.38
=== a7 15 ① 로그인만 해서 /tmp/rt 를 채운다 ===
{"exp":1789636190,"iat":1789636130,"jti":"onltro:66c69a56-72aa-998d-3a65-669945a48ac9","iss":"https://auth.hyeonworks.com/realms/master","typ":"Bearer","azp":"admin-cli","sid":"x8EWkde54QSEnlY-lDKizez3","scope":"profile email
=== a7 15 ② 시각을 남기고 DB 를 0 replica 로 내린다 ===
18:10:24 정지
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-48lrr condition met
=== a7 15 ③ 캐시를 가진 노드에서 refresh ===
500
=== a7 15 ④ 새 로그인 ===
200
=== a7 15 ⑤ DB 를 다시 올린다 ===
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
@@ -0,0 +1,64 @@
=== a7 복구 ① 두 노드의 raw 규칙을 확인한다 ===
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
=== a7 복구 ② DB 파드를 본다 ===
NAME READY STATUS RESTARTS AGE
postgres-7b474b88c8-kvt96 1/1 Running 0 30s
=== a7 복구 ③ args 를 기본값으로 되돌린다 ===
statefulset.apps/keycloak patched
18:10:55 원복
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...
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...
partitioned roll out complete: 2 new pods have been updated...
=== a7 복구 ④ 매니페스트의 변경을 확인한다 (patch 로 바꿨으므로 파일은 안 건드렸다) ===
git diff exit=0 (출력 없음이면 변경 없음)
=== a7 복구 ⑤ 새 IP 로 탐침을 다시 띄운다 ===
pod "a7-probe" deleted from keycloak-lab namespace
K0=10.42.1.86 K1=10.42.0.39
pod/a7-probe created
pod/a7-probe condition met
=== a7 복구 ⑥ 로그인을 한 번 한다 (상태 코드만) ===
200
=== a7 복구 ⑦ 로그인 뒤 온라인 세션 행을 센다 ===
1
=== 확인표 args ===
["start"]
=== 확인표 파드 ===
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
a7-probe 1/1 Running 0 2s 10.42.1.87 kc-lab-2 <none> <none>
bff-5d97897676-nmlqs 1/1 Running 0 166m 10.42.0.22 kc-lab-1 <none> <none>
bff-5d97897676-zvfhp 1/1 Running 0 166m 10.42.1.51 kc-lab-2 <none> <none>
keycloak-0 1/1 Running 0 72s 10.42.1.86 kc-lab-2 <none> <none>
keycloak-1 1/1 Running 0 105s 10.42.0.39 kc-lab-1 <none> <none>
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 72m 10.42.1.78 kc-lab-2 <none> <none>
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 71m 10.42.1.79 kc-lab-2 <none> <none>
postgres-7b474b88c8-kvt96 1/1 Running 0 2m18s 10.42.1.85 kc-lab-2 <none> <none>
redis-f94bc6dbf-s674z 1/1 Running 0 139m 10.42.1.63 kc-lab-2 <none> <none>
=== 확인표 DB ===
NAME READY STATUS RESTARTS AGE
postgres-7b474b88c8-kvt96 1/1 Running 0 2m18s
=== 확인표 iptables ===
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
=== 확인표 클러스터 ===
"pod":"keycloak-1"}
"2"]}
"pod":"keycloak-0"}
"2"]}]}}
=== a7 복구 ⑧ 탐침 파드를 지운다 ===
pod "a7-probe" deleted from keycloak-lab namespace
=== 확인표 탐침 파드 ===
Error from server (NotFound): pods "a7-probe" not found
=== 확인표 밖 ===
000
200
@@ -0,0 +1,3 @@
=== 지금 bff 가 어느 편의 상태인가 ===
2 replicas, image=keycloak-pattern-bff:lab
--- SessionRepository 계열 빈이 있나 (b0 2절과 같은 질문)
@@ -0,0 +1,10 @@
=== actuator/beans 가 응답하나 ===
sh: curl: not found
command terminated with exit code 127
--- 빈 수
sh: curl: not found
command terminated with exit code 127
0
--- session 이 이름에 든 빈
sh: curl: not found
command terminated with exit code 127
@@ -0,0 +1,22 @@
=== b0 1단계 ①②③ (문서 형태) ===
bff-5d97897676-nmlqs
155395 /tmp/beans.json
{"contexts":{"keycloak-bff":{"beans":{"applicationTaskExecutor":{"aliases":["bootstrapExecutor"],"scope":"singleton","type":"org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor","resource
=== 밖에서 /actuator/beans 는 정말 Bad Gateway 인가 (TLS 가 선 지금) ===
code=200 size=155395
{"contexts":{"keycloak-bff":{"beans":{"applicationTaskExecutor":{"aliases":["bootstrapExecutor"],"scope":"singleton","ty
=== b0 2단계 저장소 계열 빈 ===
236
"org.springframework.boot.autoconfigure.session.RedisSessionConfiguration"
"org.springframework.boot.autoconfigure.session.RedisSessionProperties"
"org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration"
"org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration"
"org.springframework.session.data.redis.RedisSessionRepository"
"org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"
"org.springframework.session.web.http.SessionRepositoryFilter"
"sessionRepository"
"sessionRepositoryFilterRegistration"
"springBootSessionRepositoryCustomizer"
"springSessionRepositoryFilter"
@@ -0,0 +1,11 @@
=== ① 로그인 페이지까지 따라간다 ===
도착: https://app1.hyeonworks.com/...
상태: 0 개의 form 단서
form action: ...
=== ② 자격을 제출한다 ===
결과: 000
=== ③ 토큰 경계를 읽는다 (b0 5절 · b1 관찰) ===
=== ④ 쿠키 항아리에 무엇이 남았나 (값은 길이만) ===
@@ -0,0 +1,20 @@
=== ① /oauth2/authorization/keycloak 로 시작 ===
ë„ì°©: https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response
ë°”ì´íЏ: 7021
form action: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_code=ySbuIX9Mo31v8XcKl
=== â‘¡ ìžê²© 제출 ===
ê²°ê³¼: 200 https://app1.hyeonworks.com/
본문 앞: <!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AP3 Â
=== â‘¢ í† í° ê²½ê³„ ===
{"pattern":"AP3-backend-for-frontend","principal":"labuser","accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,"browserTokenCount":0,"csrfProtectionEnabled":true}
=== â‘£ 쿠키 (ê°’ì€ ê¸¸ì´ë§Œ) ===
auth.hyeonworks.com KC_AUTH_SESSION_HASH 66ìž
auth.hyeonworks.com KEYCLOAK_SESSION 64ìž
@@ -0,0 +1,20 @@
=== 쿠키 전부 (HttpOnly 포함 · 값은 길이만) ===
auth.hyeonworks.com KC_AUTH_SESSION_HASH 66자
auth.hyeonworks.com AUTH_SESSION_ID 165자
app1.hyeonworks.com AP3_SESSION 48자
auth.hyeonworks.com KEYCLOAK_IDENTITY 594자
auth.hyeonworks.com KEYCLOAK_SESSION 64자
=== b1 관찰 2 · Redis 를 직접 연다 ===
dbsize=2
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 type=hash ttl=1783
bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c type=hash ttl=1790
=== b1 관찰 3 · 필드와 바이트 ===
키: bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
--- 필드 이름 (값은 안 본다)
sessionAttr:SPRING_SECURITY_SAVED_REQUEST
maxInactiveInterval
creationTime
lastAccessedTime
--- 전체 바이트: 2224
@@ -0,0 +1,28 @@
=== b1 관찰 2 ① 문서 그대로 ===
2
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c
=== b1 관찰 2 ② 문서 그대로 (head -1) ===
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
--- 그 키의 타입과 필드
hash
sessionAttr:SPRING_SECURITY_SAVED_REQUEST
maxInactiveInterval
creationTime
lastAccessedTime
--- TTL: 1771
=== 키가 둘인데 각각 무엇을 담고 있나 ===
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
sessionAttr:SPRING_SECURITY_SAVED_REQUEST
maxInactiveInterval
creationTime
lastAccessedTime
bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c
lastAccessedTime
maxInactiveInterval
sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
creationTime
sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
sessionAttr:SPRING_SECURITY_CONTEXT
@@ -0,0 +1,6 @@
=== SPRING_SECURITY_CONTEXT 를 가진 키를 골라서 관찰 3 ② ===
인증된 세션 키: bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c
TTL: 1767
--- --no-raw hgetall 앞 두 줄
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\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\xa0\xaeW\xad\xa3"
@@ -0,0 +1,20 @@
=== 시험용 키 둘을 넣는다 ===
OK
OK
bff:session:sessions:e22840df-61f2-4126-99a9-7f1dd7fddcc1
bff:session:probe2
bff:session:sessions:8a132645-4322-4b73-b97a-e3604ad1e766
bff:session:sessions:503e5c35-4e15-4d59-a034-5b9decc0ec54
bff:session:probe1
=== b2 ② 를 문서 그대로 (tr -d '
' 없이) ===
5
=== 정말 지워졌나 ===
(위가 비면 지워진 것)
=== 스캔 출력에 CR 이 실제로 붙어 오나 ===
0000000 b f f : s e s s i o n : p r o b
0000020 e 3 \n
0000023
@@ -0,0 +1,21 @@
=== b2 ② 토큰 행 수 ===
count
-------
0
(1 row)
=== b2 ③ Keycloak 세션을 DB 쪽에서 센다 ===
offline_flag | count
--------------+-------
0 | 5
(1 row)
=== b2 ④ 관리 API 로 세는 형태 ===
[ {
"offline" : "0",
"clientId" : "bff-confidential",
"active" : "4",
"id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b"
} ]
@@ -0,0 +1,13 @@
=== B-2 ② 로그인 뒤 토큰 행 수 ===
count
-------
1
(1 row)
=== 빈 목록에 JDBC 계열이 있나 ===
"authorizedClientRepository"
"authorizedClientService"
"org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager"
"org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService"
"org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository"
@@ -0,0 +1,18 @@
=== B-2 7단계 ① 행 수·해시·발급 시각 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-17 08:01:46.958592 | 722769ac041a42164d08060e5446d02b
(1 row)
=== B-2 7단계 ② 두 토큰의 바이트 수 ===
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1437 | 744
(1 row)
=== B-2 관찰 2 ① rt 바이트 수 ===
744
=== B-2 관찰 2 ② 앞 36자만 (자격증명이라 36자에서 끊는다) ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
@@ -0,0 +1,12 @@
=== 대조군 (브라우저 A) ===
행 수 1
at_md5 722769ac041a42164d08060e5446d02b
=== 주입: 새 브라우저(B)로 같은 사용자 로그인 ===
로그인 폼을 거쳐 200
{"pattern":"AP3-backend-for-frontend","principal":"labuser","accessTokenStoredOnServer":tr
=== 관찰 1: 행이 늘었나 덮어써졌나 ===
행 수 1
at_md5 5c38b11f27f490ea1d9ee07e73aef2b2
발급 시각 2026-09-17 08:05:52.156416
@@ -0,0 +1,43 @@
=== b5 1 파드 배치를 본다 ===
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
bff-5d97897676-nmlqs 1/1 Running 0 168m 10.42.0.22 kc-lab-1 <none> <none>
bff-5d97897676-zvfhp 1/1 Running 0 168m 10.42.1.51 kc-lab-2 <none> <none>
keycloak-0 1/1 Running 0 3m 10.42.1.86 kc-lab-2 <none> <none>
keycloak-1 1/1 Running 0 3m33s 10.42.0.39 kc-lab-1 <none> <none>
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 73m 10.42.1.78 kc-lab-2 <none> <none>
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 73m 10.42.1.79 kc-lab-2 <none> <none>
postgres-7b474b88c8-kvt96 1/1 Running 0 4m6s 10.42.1.85 kc-lab-2 <none> <none>
redis-f94bc6dbf-s674z 1/1 Running 0 141m 10.42.1.63 kc-lab-2 <none> <none>
=== b5 2 Redis 의 내용과 영속화 설정을 본다 ===
PONG
save
3600 1 300 100 60 10000
appendonly
yes
=== b5 3 ① 파드에 볼륨이 붙어 있는가 ===
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}},{"name":"kube-api-access-g4scs","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"}]}}]}}]
=== b5 3 ② 어디에 붙었는지와 PVC 상태를 본다 ===
[{"mountPath":"/data","name":"data"},{"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount","name":"kube-api-access-g4scs","readOnly":true}]
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
postgres-data Bound pvc-fe269834-3dd1-4b1f-82f6-a53413310a30 5Gi RWO local-path <unset> 4h38m
redis-data Bound pvc-76a6565c-ca60-4a93-b08f-51400c5680ce 1Gi RWO local-path <unset> 168m
=== b5 4 세 경로의 상태 코드를 뽑는다 ===
/ 000
/bff/token-boundary 000
/actuator/health 000
=== (진단) 이름이 무엇으로 풀리나 ===
100.83.212.4 app1.hyeonworks.com
=== (진단) 엣지 주소를 짚어 같은 셋 ===
/ 200
/bff/token-boundary 302
/actuator/health 200
=== b5 5 ① health 그룹 셋의 본문을 받는다 ===
=== (진단) 같은 셋을 엣지 주소로 ===
{"status":"UP","groups":["liveness","readiness"],"components":{"db":{"status":"UP","details":{"database":"PostgreSQL","validationQuery":"isValid()"}},"diskSpace":{"status":"UP","details":{"total":20922114048,"free":13518041088,"threshold":10485760,"path":"/app/.","exists":true}},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"redis":{"status":"UP","details":{"version":"7.4.11"}},"ssl":{"status":"UP","details":{"validChains":[],"invalidChains":[]}}}}
{"status":"UP"}
{"status":"UP"}
=== b5 5 ② kubelet 이 보는 경로를 확인한다 ===
/actuator/health/readiness
@@ -0,0 +1,56 @@
=== b5 주입 1 시각을 남기고 Redis 를 0대로 내린다 ===
18:14:47 정지
deployment.apps/redis scaled
pod/redis-f94bc6dbf-s674z condition met
=== b5 주입 검증 ① Redis 가 0대인가 ===
No resources found in keycloak-lab namespace.
NAME READY UP-TO-DATE AVAILABLE AGE
redis 0/0 0 0 168m
=== b5 주입 검증 ② BFF 로그에서 연결 시도를 찾는다 ===
2026-09-17T09:09:46.531Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@13f41550 (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2026-09-17T09:09:54.173Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@75a79757 (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2026-09-17T09:09:55.362Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@43aef356 (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2026-09-17T09:09:57.318Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@7be4bd3b (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2026-09-17T09:09:57.955Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@32b0803f (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2026-09-17T09:10:19.741Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@5b2e9f1c (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2026-09-17T09:13:13.527Z INFO 1 --- [keycloak-bff] [xecutorLoop-1-2] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379
2026-09-17T09:13:13.528Z INFO 1 --- [keycloak-bff] [xecutorLoop-1-1] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379
2026-09-17T09:13:13.529Z WARN 1 --- [keycloak-bff] [ioEventLoop-4-2] 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
2026-09-17T09:13:13.530Z WARN 1 --- [keycloak-bff] [ioEventLoop-4-1] 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
=== b5 주입 검증 ③ 엉뚱한 것을 죽이지 않았는지 본다 ===
NAME READY STATUS RESTARTS AGE
bff-5d97897676-nmlqs 1/1 Running 0 168m
bff-5d97897676-zvfhp 1/1 Running 0 168m
keycloak-0 1/1 Running 0 3m17s
keycloak-1 1/1 Running 0 3m50s
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 74m
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 74m
postgres-7b474b88c8-kvt96 1/1 Running 0 4m23s
=== b5 관찰 세 경로를 다시 친다 (문서 그대로) ===
/ 000
/bff/token-boundary 000
/actuator/health 000
=== (진단) 엣지 주소를 짚어 같은 셋 ===
/ 200
/bff/token-boundary 000
/actuator/health 000
=== (진단) token-boundary 응답 본문 ===
[본문 끝]
=== b5 관찰 health 그룹 셋을 코드와 본문으로 본다 (엣지 주소를 짚어서) ===
health 000
health/readiness 200
health/liveness 200
{"status":"UP"}
=== b5 관찰 엔드포인트가 아직 ready 인지 본다 ===
NAME ADDR READY
bff-ptf2d [10.42.1.51],[10.42.0.22] true,true
=== (진단) /actuator/health 를 더 길게 기다려 본다 ===
health 503 total 60.046452
exit=0
{"status":"DOWN","groups":["liveness","readiness"],"components":{"db":{"status":"UP","details":{"database":"PostgreSQL","validationQuery":"isValid()"}},"diskSpace":{"status":"UP","details":{"total":20922114048,"free":13516791808,"threshold":10485760,"path":"/app/.","exists":true}},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}},"ssl":{"status":"UP","details":{"validChains":[],"invalidChains":[]}}}}
=== (진단) /bff/token-boundary 도 길게 ===
token-boundary 000 total 90.001609
exit=28
[exited with code 0]
@@ -0,0 +1,74 @@
=== b5 관찰 ① Redis 를 다시 올린다 ===
18:18:32 복구
deployment.apps/redis scaled
Waiting for deployment "redis" rollout to finish: 0 out of 1 new replicas have been updated...
Waiting for deployment "redis" rollout to finish: 0 of 1 updated replicas are available...
deployment "redis" successfully rolled out
=== b5 관찰 ② 회복했는지와 재시작 횟수를 본다 (엣지 주소를 짚어서) ===
/actuator/health 200
/bff/token-boundary 302
NAME READY STATUS RESTARTS AGE
bff-5d97897676-nmlqs 1/1 Running 0 173m
bff-5d97897676-zvfhp 1/1 Running 0 173m
=== b5 주입 2 ① 볼륨 참조를 떼고 롤아웃을 기다린다 ===
deployment.apps/redis patched
Waiting for deployment "redis" rollout to finish: 0 out of 1 new replicas have been updated...
Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination...
deployment "redis" successfully rolled out
=== b5 주입 검증 ④ 볼륨이 정말 떨어졌는가 ===
NAME READY STATUS RESTARTS AGE
redis-547fb5fcc7-wcqg8 1/1 Running 0 11s
redis-f94bc6dbf-l9q6x 0/1 Completed 0 47s
[{"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 쪽도 ===
[{"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount","name":"kube-api-access-d7bbm","readOnly":true}]
=== b5 주입 2 ② AOF 를 켜고 키를 심는다 ===
OK
appendonly
yes
OK
1
total 12
drwxr-xr-x 3 redis redis 4096 Sep 17 09:17 .
drwxr-xr-x 1 root root 4096 Sep 17 09:17 ..
drwx------ 2 redis redis 4096 Sep 17 09:17 appendonlydir
=== b5 관찰 ③ 볼륨 없이 파드를 지우고 남은 것을 센다 ===
pod "redis-547fb5fcc7-wcqg8" deleted from keycloak-lab namespace
Waiting for deployment "redis" rollout to finish: 0 of 1 updated replicas are available...
deployment "redis" successfully rolled out
NAME READY STATUS RESTARTS AGE
redis-547fb5fcc7-4xv8m 1/1 Running 0 12s
0
appendonly
yes
=== b5 관찰 ④ 볼륨을 되돌리고 같은 시험을 다시 한다 ===
secret/bff-secrets configured
persistentvolumeclaim/redis-data unchanged
deployment.apps/redis configured
service/redis unchanged
deployment.apps/bff unchanged
service/bff unchanged
ingress.networking.k8s.io/bff unchanged
Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination...
deployment "redis" successfully rolled out
appendonly
yes
OK
pod "redis-547fb5fcc7-4xv8m" deleted from keycloak-lab namespace
pod "redis-f94bc6dbf-lqrgp" deleted from keycloak-lab namespace
Waiting for deployment "redis" rollout to finish: 0 of 1 updated replicas are available...
deployment "redis" successfully rolled out
NAME READY STATUS RESTARTS AGE
redis-f94bc6dbf-b7zdk 1/1 Running 0 12s
4
written-on-pvc
=== b5 PVC 의 스토리지 클래스를 본다 ===
local-path
=== b5 B층 구성 요소의 지표가 있는가 ===
redis_up 시계열 0개
redis_connected_clients 시계열 0개
pg_up 시계열 0개
pg_stat_database_numbackends 시계열 0개
@@ -0,0 +1,39 @@
=== b5 복구 1 매니페스트를 다시 적용한다 ===
secret/bff-secrets configured
persistentvolumeclaim/redis-data unchanged
deployment.apps/redis unchanged
service/redis unchanged
deployment.apps/bff unchanged
service/bff unchanged
ingress.networking.k8s.io/bff unchanged
deployment "redis" successfully rolled out
=== b5 복구 2 실험 키만 지우고 남은 키를 본다 ===
1
bff:session:sessions:[REDACTED]
bff:session:sessions:[REDACTED]
bff:session:sessions:[REDACTED]
=== b5 복구 3 확인표 — Redis ===
NAME READY UP-TO-DATE AVAILABLE AGE
redis 1/1 1 1 174m
=== 확인표 볼륨 ===
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}},{"name":"kube-api-access-6mxzf","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"}]}}]}}]
=== 확인표 PVC ===
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
postgres-data Bound pvc-fe269834-3dd1-4b1f-82f6-a53413310a30 5Gi RWO local-path <unset> 4h44m
redis-data Bound pvc-76a6565c-ca60-4a93-b08f-51400c5680ce 1Gi RWO local-path <unset> 174m
=== 확인표 영속화 ===
appendonly
yes
=== 확인표 BFF ===
NAME READY STATUS RESTARTS AGE
bff-5d97897676-nmlqs 1/1 Running 0 174m
bff-5d97897676-zvfhp 1/1 Running 0 174m
=== 확인표 엔드포인트 ===
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
bff-ptf2d IPv4 8083 10.42.1.51,10.42.0.22 174m
=== 확인표 밖 (문서 그대로) ===
000
=== 확인표 밖 (엣지 주소를 짚어서) ===
200
=== (마무리) git 은 안 건드렸다 ===
git status exit=0
@@ -0,0 +1,25 @@
=== b6 ① kcadm 로그인 ===
Logging into http://localhost:8080 as user admin of realm master
exit=0
=== b6 ② 비밀번호 길이만 ===
19
=== b6 2단계 공급자 목록 ===
"id" : "5be506c2-8472-442b-8258-9c6692f45a13", "name" : "Trusted Hosts", "providerId" : "trusted-hosts"
"id" : "e1686b9f-15a2-4979-a302-8fc18a4d6b22", "name" : "Full Scope Disabled", "providerId" : "scope"
"id" : "6a0b8379-b2b1-4d2f-bc3b-fa1673723730", "name" : "Allowed Client Scopes", "providerId" : "allowed-client-templates"
"id" : "7dc6fe06-06c3-4d25-b93a-1fe245e4837c", "name" : "Consent Required", "providerId" : "consent-required"
"id" : "06777327-7c87-4ff4-92da-3335462ccb3b", "name" : "Allowed Client Scopes", "providerId" : "allowed-client-templates"
"id" : "3c9062a0-fc6d-4fef-870e-7bfc10270279", "name" : "Allowed Protocol Mapper Types", "providerId" : "allowed-protocol-mappers"
"id" : "a03ee0cb-765b-4eea-9d6c-e76bcee3adf4", "name" : "Allowed Registration Web Origins", "providerId" : "registration-web-origins"
"id" : "d47b58ee-7c34-4be0-a3f3-30f49b4577c1", "name" : "rsa-enc-generated", "providerId" : "rsa-enc-generated"
"id" : "807e653f-deaf-41cd-bbaa-5b19eaf74a2a", "name" : "hmac-generated-hs512", "providerId" : "hmac-generated"
"id" : "404e38a3-d3c3-4996-9228-200a912de08b", "name" : "Allowed Registration Web Origins", "providerId" : "registration-web-origins"
"id" : "27216d59-950c-4c8c-b0a8-e1d8108e0c0f", "name" : "aes-generated", "providerId" : "aes-generated"
"id" : "142c7871-4556-4faa-b9af-83d2376da5c6", "name" : "Max Clients Limit", "providerId" : "max-clients"
"id" : "a09ec151-6c8c-4e55-84c0-f02781221b83", "name" : "rsa-generated", "providerId" : "rsa-generated"
"id" : "212bde50-f539-4742-85bc-97f4a31a604a", "name" : "Allowed Protocol Mapper Types", "providerId" : "allowed-protocol-mappers"
=== b6 2단계 -q type=... 형태 ===
"name" : "rsa-enc-generated" "name" : "hmac-generated-hs512" "name" : "aes-generated" "name" : "rsa-generated"
@@ -0,0 +1,18 @@
=== b6 3단계를 문서 그대로: [lab host] 에서 공개 URL 로 ===
https 000 (Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server)
http 000 (Failed to connect to auth.hyeonworks.com:80 after 4 ms: Could not connect to server)
=== 같은 것을 dev 에서 http 로 ===
{"keys":[{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg","kty":"RSA","alg":"RS256","use":"sig","x5c":["MIICsTCCAZkC
=== 3단계 ② kid 만 ===
{"keys":[{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
=== 4단계 ① 키 단위로 잘라 RS256 만 센다 ===
1
=== 4단계 ② kcadm get keys ===
"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",
@@ -0,0 +1,8 @@
=== 왜 못 닿나 ===
100.83.212.4 auth.hyeonworks.com
--- 호스트가 80/443 을 듣나
(리스너 없음)
=== 고쳐 치는 형태: --resolve 로 엣지를 직접 가리킨다 ===
{"keys":[{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
@@ -0,0 +1,7 @@
=== b6 5단계: 문서 그대로 [lab host] 에서 (https 공개 URL) ===
0자
=== 같은 것을 --resolve 로 고쳐서 ===
길이 0자
응답(토큰 없음): {"error":"invalid_grant","error_description":"Invalid user credentials"}
헤더:
@@ -0,0 +1,8 @@
=== 비밀번호를 B-6 이 박아 둔 값으로 맞춘다 ===
exit=0
=== b6 5·6단계 다시 ===
5단계 길이: 1406자
6단계 헤더: {"alg":"RS256","typ" : "JWT","kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"}
6단계 kid : HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg
(토큰은 랩 호스트의 /tmp/old.jwt 에만 둔다)
@@ -0,0 +1,30 @@
=== 5. 옛 토큰 ===
1406자 · kid HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg
=== 7. 지금 통하는가 ===
old 401
=== 8. priority 200 공급자 추가 ===
Created new component with id 'a8b5aecc-ffb6-4b17-8d5b-3eef5ecb9b72'
16:19:04 추가
=== 9. JWKS 에 옛 키가 남아 있는가 ===
{"keys":[{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4"
{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
RS256 키 수: 2
=== 10. 새 토큰의 kid ===
1406자 · kid iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4
=== 11. 둘 다 통하는가 ===
old 401
new 401
exp 1789629509 · now 1789629545
=== 12. 지울 대상 ===
지울 id: a09ec151-6c8c-4e55-84c0-f02781221b83
=== 13. 옛 공급자 제거 ===
16:19:07 제거
=== 14. JWKS 에서 사라졌는가 ===
{"keys":[{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
RS256 키 수: 1
=== 15. 두 토큰을 다시 ===
old 401
new 401
exp 1789629509 · now 1789629547
@@ -0,0 +1,47 @@
=== echo 가 왜 401 을 내나 ===
at java.base/sun.nio.ch.Net.connect0(Native Method) ~[na:na]
at java.base/sun.nio.ch.Net.connect(Unknown Source) ~[na:na]
at java.base/sun.nio.ch.Net.connect(Unknown Source) ~[na:na]
at java.base/sun.nio.ch.NioSocketImpl.connect(Unknown Source) ~[na:na]
at java.base/java.net.SocksSocketImpl.connect(Unknown Source) ~[na:na]
at java.base/java.net.Socket.connect(Unknown Source) ~[na:na]
at java.base/sun.security.ssl.SSLSocketImpl.connect(Unknown Source) ~[na:na]
at java.base/sun.security.ssl.BaseSSLSocketImpl.connect(Unknown Source) ~[na:na]
at java.base/sun.net.NetworkClient.doConnect(Unknown Source) ~[na:na]
at java.base/sun.net.www.http.HttpClient.openServer(Unknown Source) ~[na:na]
at java.base/sun.net.www.http.HttpClient.openServer(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.https.HttpsClient.New(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.http.HttpURLConnection.connect(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(Unknown Source) ~[na:na]
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.connect(Unknown Source) ~[na:na]
at org.springframework.http.client.SimpleClientHttpRequest.executeInternal(SimpleClientHttpRequest.java:79) ~[spring-web-6.2.19.jar!/:6.2.19]
at org.springframework.http.client.AbstractStreamingClientHttpRequest.executeInternal(AbstractStreamingClientHttpRequest.java:88) ~[spring-web-6.2.19.jar!/:6.2.19]
at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:81) ~[spring-web-6.2.19.jar!/:6.2.19]
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:900) ~[spring-web-6.2.19.jar!/:6.2.19]
... 101 common frames omitted
=== echo 의 issuer 설정 ===
[{"name":"SERVER_PORT","value":"8081"},{"name":"SERVER_FORWARD_HEADERS_STRATEGY","value":"native"},{"name":"JAVA_TOOL_OPTIONS","value":"-XX:MaxRAMPercentage=70"},{"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"}]
=== 401 의 WWW-Authenticate ===
HTTP/1.1 401 Unauthorized
Server: nginx/1.22.1
Date: Thu, 17 Sep 2026 07:17:43 GMT
Content-Length: 0
Connection: keep-alive
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
=== realm 의 accessTokenLifespan ===
{
"accessTokenLifespan" : 60,
"ssoSessionIdleTimeout" : 1800
}=== 시계 차이 ===
host 1789629559
kc-0 1789629465
@@ -0,0 +1,18 @@
=== 기계마다의 시각 (같은 순간에 친다) ===
lab host 1789629566 07:19:26
kc-lab-1 1789629472 07:17:52
kc-lab-2 1789629473 07:17:53
kc-lab-edge 1789629473 07:17:53
kc-0 pod 1789629473
--- 시간 동기 상태
== kc-lab-1
NTP=yes
NTPSynchronized=yes
TimeUSec=Thu 2026-09-17 07:17:53 UTC
== kc-lab-2
NTP=yes
NTPSynchronized=yes
TimeUSec=Thu 2026-09-17 07:17:54 UTC
== lab host
NTP=no
NTPSynchronized=no
@@ -0,0 +1,15 @@
=== b6 원상복구 확인표 ===
--- 서명 키
{"keys":[{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
--- 파드
echo-cd47dccc6-nq62j 1/1 Running 0 41m
echo-cd47dccc6-q46lk 1/1 Running 0 41m
keycloak-0 1/1 Running 0 24m
keycloak-1 1/1 Running 0 25m
--- 공급자 목록
"name" : "rsa-enc-generated"
"name" : "rsa-rotated"
--- 새 토큰 + /api/me
토큰 1406자
/api/me 401
@@ -0,0 +1,10 @@
=== echo 를 다시 띄워 JWKS 를 받게 한다 ===
deployment "echo" successfully rolled out
=== b6 7단계 대조군: 토큰이 지금 통하는가 ===
토큰 0자
old 000
=== echo 로그에 JWKS 오류가 남아 있나 ===
0
0
@@ -0,0 +1,7 @@
=== b6 5·7단계 (dev 에서) ===
5단계 토큰 1406자
6단계 kid iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4
7단계 대조군:
old 200
(본문과 WWW-Authenticate)
HTTP/2 200
@@ -0,0 +1,18 @@
=== 출발 상태: 지금 있는 RSA 공급자 ===
}, { "id" : "a8b5aecc-ffb6-4b17-8d5b-3eef5ecb9b72", "name" : "rsa-rotated",
=== 5. 옛 토큰 ===
1406자 · kid iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4
=== 7. 대조군 ===
old 200
=== 8. 더 높은 우선순위 공급자를 더한다 (기존이 200 이라 300 을 준다) ===
Created new component with id '958e80bd-3b9f-4b05-9d9a-011b8e4ef54a'
=== 9. JWKS 에 옛 키가 남아 있는가 ===
{"keys":[{"kid":"mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc"
{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
RS256 키 수: 2
=== 10. 새 토큰의 kid ===
kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc
=== 11. 둘 다 통하는가 ===
old 200
new 200
@@ -0,0 +1,14 @@
=== 12. 지울 대상 (rsa-rotated = 옛 키) ===
a8b5aecc-ffb6-4b17-8d5b-3eef5ecb9b72
=== 다시 옛 토큰을 받는다 (수명 안에 끝내려고) ===
옛 kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc
새 kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc
※ 둘 다 지금은 새 키로 서명된다 — 옛 키 토큰은 위 8~11 에서 받은 것이 정본이다
=== 13. 옛 공급자를 지운다 ===
07:52:46 제거
=== 14. JWKS 에서 사라졌는가 ===
{"keys":[{"kid":"mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
RS256 키 수: 1
=== 15. 두 토큰을 다시 ===
새키 토큰 200
@@ -0,0 +1,19 @@
=== 5. 옛 키로 서명된 토큰 (이 값을 끝까지 안 건드린다) ===
1406자 · kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc
=== 7. 대조군 ===
old 200
=== 8. priority 400 공급자를 더한다 ===
Created new component with id '7aa3832c-787a-4e9b-9eda-2ec72c4c0b97'
=== 9·10 ===
RS256 키 수: 2
새 토큰 kid AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM
=== 11. 겹치는 구간 ===
old 200
new 200
=== 13. 옛 공급자(priority 300) 제거 ===
지울 id: 958e80bd-3b9f-4b05-9d9a-011b8e4ef54a
=== 14·15 ===
RS256 키 수: 1
old 200
new 200
경과 6초 (토큰 수명 60초)
@@ -0,0 +1,14 @@
=== 지금 JWKS 에 있는 kid ===
{"keys":[{"kid":"AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM"
{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"
=== 사라진 키로 서명된 토큰을 다시 만들 수는 없다 — 지금 받는 토큰은 살아 있는 키다 ===
지금 토큰 kid AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM
=== 16. echo 를 다시 띄워 캐시를 비운다 ===
deployment "echo" successfully rolled out
재시작 34초
=== 재시작 뒤 현재 토큰 ===
now 200
fresh 200
@@ -0,0 +1,16 @@
5. 옛 토큰 kid AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM
[+0초]
7. 대조군 old 200
8. Created new component with id '066d3957-9b0e-411f-a36c-87ed17a18815'
10. 새 토큰 kid yHLkV7okKrLif-ndb01imOfaHze94lrllFgITCtoQmc
11. old 200 new 200
[+2초]
13. 옛 공급자 제거 (7aa3832c-787a-4e9b-9eda-2ec72c4c0b97)
[+5초]
14. RS256 키 수: 1
15. old 200 new 200
[+6초]
16. echo 재시작
[+39초]
16 뒤. old 401 new 504
[+70초]
@@ -0,0 +1,12 @@
=== echo 가 준비됐는지 먼저 ===
echo-85b6ffd88f-grm5g 1/1 Running 0 75s
echo-85b6ffd88f-nnvpt 1/1 Running 0 59s
옛 토큰 kid yHLkV7okKrLif-ndb01imOfaHze94lrllFgITCtoQmc
Created new component with id '9af4a602-1e5c-47d7-8c52-6fd7667a5261'
제거 완료 [+5초] · JWKS RS256 1개
--- 제거 뒤 옛 토큰을 10초마다
[+6초] old=401
[+16초] old=200
[+26초] old=401
[+36초] old=200
[+47초] old=401
@@ -0,0 +1,9 @@
=== 옛 키로 서명된 토큰을 하나 만들고 즉시 회전+제거, 그다음 파드별로 친다 ===
옛 kid aJCNYKhd4LWm6fTcmllP3dbq_incT5Jww6CzNj92WmU
Created new component with id '6689ab97-c4de-4b23-8ad7-c66ae4759113'
제거 완료
--- 파드마다 직접 (클러스터 안에서, 파드 IP 로)
echo-85b6ffd88f-grm5g 10.42.1.76 up
echo-85b6ffd88f-nnvpt 10.42.0.35 up
--- 같은 토큰으로 8번 연속 (라운드로빈)
401 200 401 200 401 200 401 200
@@ -0,0 +1,20 @@
=== B-7 1단계: Grafana Ingress 를 백업한다 ===
30 /home/donghyeon/grafana-ingress-backup.yaml
=== B-7 2단계 ①② ===
ingress.networking.k8s.io "grafana" deleted from observability namespace
secret/oauth2-proxy-secrets configured
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
Waiting for deployment "oauth2-proxy" rollout to finish: 0 of 2 updated replicas are available...
error: timed out waiting for the condition
=== 파드 상태와 실제 오류 ===
oauth2-proxy-6c56c4c94f-vncmx 0/1 CrashLoopBackOff 4 (6s ago) 91s
oauth2-proxy-6c56c4c94f-zxg52 0/1 CrashLoopBackOff 3 (48s ago) 91s
---
[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: error initialising provider: could not create provider data: error building OIDC ProviderVerifier: could not get verifier builder: error while discovery OIDC configuration: failed to discover OIDC configuration: error performing request: Get "https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration": dial tcp 192.168.122.10:443: connect: connection refused
[2026/09/17 07:36:36] [provider.go:55] Performing OIDC Discovery...
[2026/09/17 07:36:36] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: could not create provider data: error building OIDC ProviderVerifier: could not get verifier builder: error while discovery OIDC configuration: failed to discover OIDC configuration: error performing request: Get "https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration": dial tcp 192.168.122.10:443: connect: connection refused
@@ -0,0 +1,15 @@
=== B-7 되돌리기 ② 빌린 Ingress 를 걷고 Grafana 것을 올린다 ===
ingress.networking.k8s.io "oauth2-proxy" deleted from keycloak-lab namespace
ingress.networking.k8s.io/grafana created
deployment.apps "oauth2-proxy" deleted from keycloak-lab namespace
Error from server (NotFound): deployments.apps "svc" not found
Error from server (NotFound): deployments.apps "oauth2-proxy" not found
=== 돌아왔는지 ===
header-lab echo traefik app1.hyeonworks.com 192.168.122.11,192.168.122.12 80 61m
keycloak-lab bff traefik app1.hyeonworks.com 192.168.122.11,192.168.122.12 80 73m
keycloak-lab keycloak traefik auth.hyeonworks.com 192.168.122.11,192.168.122.12 80 3h3m
observability grafana traefik app2.hyeonworks.com 192.168.122.11,192.168.122.12 80 1s
---
0
oauth2-proxy 파드 0
@@ -0,0 +1,21 @@
=== 쌓인 RSA 공급자 정리 (가장 높은 것 하나만 남긴다) ===
"id" : "6689ab97-c4de-4b23-8ad7-c66ae4759113", "name" : "rsa-rot-6",
"id" : "d47b58ee-7c34-4be0-a3f3-30f49b4577c1", "name" : "rsa-enc-generated",
=== B-7 1단계: Grafana Ingress 백업 ===
백업 30줄
=== B-7 2단계: Grafana 를 내리고 oauth2-proxy 배포 ===
ingress.networking.k8s.io "grafana" deleted from observability namespace
secret/oauth2-proxy-secrets configured
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
Waiting for deployment "oauth2-proxy" rollout to finish: 0 out of 2 new replicas have been updated...
Waiting for deployment "oauth2-proxy" rollout to finish: 0 of 2 updated replicas are available...
Waiting for deployment "oauth2-proxy" rollout to finish: 1 of 2 updated replicas are available...
deployment "oauth2-proxy" successfully rolled out
=== 3단계: replica 와 배치 ===
oauth2-proxy-6c56c4c94f-gq484 1/1 Running 0 12s 10.42.1.77 kc-lab-2 <none> <none>
oauth2-proxy-6c56c4c94f-szr6j 1/1 Running 0 12s 10.42.0.36 kc-lab-1 <none> <none>
@@ -0,0 +1,18 @@
=== B-7 4단계: 진입점 두 곳이 갈라지나 ===
https://app1.hyeonworks.com/ 200
https://app2.hyeonworks.com/ 302
=== B-7 7단계: app2 로 로그인한다 ===
도착: https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?app
form:
로그인 결과: 000
head: cannot open '/tmp/p2b.html' for reading: No such file or directory
본문 앞:
=== 쿠키 (값은 길이만) ===
app2.hyeonworks.com _oauth2_proxy_csrf 176자
=== B-7 8단계: Redis 에 세션이 들어갔나 ===
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c
dbsize: 2
@@ -0,0 +1,13 @@
=== realm 에 등록된 클라이언트 ===
account
account-console
admin-cli
bff-confidential
broker
realm-management
security-admin-console
=== oauth2-proxy 가 쓰는 client_id ===
=== 이 클라이언트를 만드는 setup 문서가 있나 ===
docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md
@@ -0,0 +1,11 @@
=== 어느 문서에도 없는 한 단계: oauth2-proxy 클라이언트를 만든다 ===
클라이언트 비밀 길이: 16자
Created new client with id 'f7bed9b9-8fb5-4b3d-9253-c574c0b09739'
=== 만들어졌나 ===
[ {
"clientId" : "oauth2-proxy",
"enabled" : true,
"redirectUris" : [ "https://app2.hyeonworks.com/oauth2/callback" ],
"publicClient" : false
} ]
@@ -0,0 +1,41 @@
=== B-7 7단계: app2 로 로그인 ===
로그인 폼: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_co
로그인 결과: 401 · 본문 0바이트
=== 업스트림이 받은 헤더 (echo 가 그대로 돌려준다) ===
{
"headers" : {
"host" : [ "app2.hyeonworks.com" ],
"user-agent" : [ "curl/8.5.0" ],
"accept" : [ "*/*" ],
"accept-encoding" : [ "gzip" ],
"cookie" : [ "_oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDFaR05rTURjNVpUQmlaVE5sWWpoaE9UVXdZelptWm1FMVpHTTVZamhpTVEuWDhMSFFnVnYtenRXV0hzTFMzVVN0QQ==|1789631924|yGT15uSP74BddTXAVgSvVGaz65hygKtaju9CDM3lHlc=" ],
"x-forwarded-email" : [ "labuser@example.invalid" ],
"x-forwarded-host" : [ "app2.hyeonworks.com" ],
"x-forwarded-port" : [ "443" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-proto" : [ "https" ],
"x-forwarded-server" : [ "traefik-5d6fcf895-xmqp6" ],
"x-forwarded-user" : [ "07d3be2a-9f22-4779-a211-e530c8b7ff3b" ],
"x-real-ip" : [ "192.168.122.1" ]
},
"remoteAddr" : "192.168.122.1",
"localAddr" : "10.42.0.35",
"scheme" : "https",
"secure" : true,
"serverName" : "app2.hyeonworks.com",
"serverPort" : 443,
"requestUrl" : "https://app2.hyeonworks.com/api/echo"
}
=== 쿠키 (값은 길이만) ===
auth.hyeonworks.com KC_AUTH_SESSION_HASH 64자
auth.hyeonworks.com AUTH_SESSION_ID 165자
auth.hyeonworks.com KEYCLOAK_IDENTITY 594자
auth.hyeonworks.com KEYCLOAK_SESSION 64자
app2.hyeonworks.com _oauth2_proxy 176자
=== B-7 8단계: Redis ===
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c
dbsize=3
@@ -0,0 +1,21 @@
=== 주입 2 ① env 이름 순서 ===
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
=== 회전 전 대조군: 지금 쿠키가 통하는가 ===
회전 전 200
=== 주입 2 ② A → B 로 바꾼다 ===
07:58:58 UTC 회전
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
=== 주입 검증 1·2·3 ===
참조: COOKIE_SECRET_B
Redis 세션: _oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1
파드:
oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 25s
oauth2-proxy-d9b587458-njxdw 1/1 Running 0 12s
=== 관찰: 회전 전 쿠키가 아직 통하는가 ===
회전 뒤 302
(리다이렉트 목적지) https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?approval_prompt=force&client_id=oauth2-proxy&redirect_uri=https%3A%2F%2Fapp2.hyeonworks.com%2Foauth2%2Fcallback&response_type=code&scope=openid+email+profile&state=5V_qNK4dsuw6h4jJTooqFYlaVJ5WarqCuDpsGlpHgAw%3A%2Fapi%2Fecho
@@ -0,0 +1,10 @@
=== B-7a 6단계: R 함수와 스캔 루프 (오래 미검증이던 블록) ===
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 type=string ttl=3551 len=3457
=== B-7a: TTL 이 줄어드는가 · 갱신되는가 (30초 간격 세 번) ===
08:01:07
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 ttl=3551
08:01:37
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 ttl=3521
08:02:07
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 ttl=3490
@@ -0,0 +1,29 @@
=== C-1 주입 1: app1 에 로그인한다 ===
app1 로그인 200
{"pattern":"AP3-backend-for-frontend","principal":"labuser","accessTokenStoredOnServer":true,"refreshTokenStoredOnServer
=== C-1 주입 3: 같은 창의 새 탭에서 app2 를 연다 (재인증이 뜨는가) ===
도착: https://app2.hyeonworks.com/api/echo
로그인 폼이 떴나: 0 (0 이면 재인증 없이 통과)
본문 앞: { "headers" : { "host" : [ "app2.hyeonworks.com" ], "user-agent" : [ "curl/8.5.0
=== C-1 주입검증 2: 어느 클라이언트가 붙었나 ===
[ {
"offline" : "0",
"clientId" : "bff-confidential",
"active" : "15",
"id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b"
}, {
"offline" : "0",
"clientId" : "oauth2-proxy",
"active" : "2",
"id" : "f7bed9b9-8fb5-4b3d-9253-c574c0b09739"
} ]
=== C-1 주입검증 3: 저장소에 무엇이 늘었나 ===
_oauth2_proxy-9aa2915c715c48b09a8916015a6f3801
bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23
bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1
bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c
dbsize=5
@@ -0,0 +1,13 @@
=== C-1 주입검증 1: user session 과 client session 수 ===
YwQBgnWh4dmkiknXlBVdbL6f | keycloak-patterns | 1
1O7AtenktEMp_0MFgwFtev8o | keycloak-patterns | 1
c4GCKTLjyfC52_JjLJuUf2GR | keycloak-patterns | 1
Ur1NWoD6WiHMBX-oi1DvDrEe | keycloak-patterns | 1
q2LCg3GRdG8acMlSYRXdJ-XF | keycloak-patterns | 1
rjpU8hkBoDdgWG6duNzadrgn | keycloak-patterns | 1
tIbyDHzlcDUxD6Ke65juXHlE | keycloak-patterns | 1
zOVsxAt2PbCKF2fIOD8_YMNe | keycloak-patterns | 1
4i6Q7qb3Y_7Ib4mQzzsmTkzQ | keycloak-patterns | 1
V0mkutuu-0tNdmvHBfWucB9j | keycloak-patterns | 2
(16 rows)
@@ -0,0 +1,15 @@
Server: 10.43.0.10
Address: 10.43.0.10:53
Name: app1.hyeonworks.com
Address: 192.168.122.10
--- HTTPS ---
All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt.
If you don't see a command prompt, try pressing enter.
app1 200
curl exit=0
pod "c2probe" deleted from keycloak-lab namespace
=== NetworkPolicy 가 있나 ===
No resources found in keycloak-lab namespace.
@@ -0,0 +1,11 @@
-rw-r--r-- 1 root root 4882 Sep 4 08:22 fullchain2.pem
-rw-r--r-- 1 root root 4882 Sep 4 12:29 fullchain3.pem
-rw------- 1 root root 241 Sep 3 01:47 privkey1.pem
-rw------- 1 root root 241 Sep 4 08:22 privkey2.pem
-rw------- 1 root root 241 Sep 4 12:29 privkey3.pem
--- nginx worker PID ---
2629 4712
--- 지금 서빙하는 인증서의 notBefore ---
notBefore=Sep 4 11:29:18 2026 GMT
notAfter=Dec 3 11:29:17 2026 GMT
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
@@ -0,0 +1,34 @@
=== D-4 5단계: 타이머 ===
0 timers listed.
Pass --all to see loaded but inactive timers, too.
--- 엣지 쪽
Thu 2026-09-17 17:06:05 UTC 9h left - - certbot.timer certbot.service
=== D-4 6단계 ① ② ③ ===
[Unit]
Description=Certbot
Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html
Documentation=https://certbot.eff.org/docs
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew
PrivateTmp=true
---
[Unit]
Description=Run certbot twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true
[Install]
WantedBy=timers.target
---
total 0
=== D-4 7단계를 문서 그대로: [test-server] 에서 ===
(줄 수: 3)
=== 같은 것을 엣지에서 ===
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
@@ -0,0 +1,16 @@
certbot exit=0
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -0,0 +1,15 @@
=== 디스크의 새 파일 ===
-rw------- 1 root root 241 Sep 4 08:22 privkey2.pem
-rw------- 1 root root 241 Sep 4 12:29 privkey3.pem
-rw------- 1 root root 241 Sep 17 10:36 privkey4.pem
=== 지금 서빙하는 인증서 ===
notBefore=Sep 17 09:37:58 2026 GMT
notAfter=Dec 16 09:37:57 2026 GMT
serial=065547991777D11A408CEA90D945DDA03DF1
=== nginx worker PID (갱신 전 2629 4712) ===
4745 4754
auth 302 verify=0
app1 200 verify=0
app2 302 verify=0
@@ -0,0 +1,26 @@
=== 1단계 출발점 워커 PID (문서대로 test-server) ===
(랩 호스트에서는 빈손이다)
--- 엣지에서
1065 1 11186 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
1106 1065 11177 Thu Sep 17 04:27:49 2026 nginx: worker process
=== 5단계 훅 파일을 만든다 ===
#!/bin/sh
nginx -t && nginx -s reload
=== 6단계 설치 ===
exit=0
=== 7단계 제자리에 있나 ===
total 4
-rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh
=== 8단계 손으로 한 번 돌린다 ===
exit=0
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
=== 8단계 뒤 워커 PID 재측정 ===
1065 1 11189 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
2362 1065 0 Thu Sep 17 07:34:08 2026 nginx: worker process
@@ -0,0 +1,19 @@
=== 엣지에 인증서가 생긴 뒤 dry-run 이 무엇을 하나 ===
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Failed to renew certificate auth.hyeonworks.com with error: You should register before running non-interactively, or provide --agree-tos and --email <email_address> flags.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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)
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.
exit=0
=== 엣지에 자격증명 파일이 있나 ===
ls: cannot access '/etc/letsencrypt/cloudflare.ini': No such file or directory
1
@@ -0,0 +1,15 @@
certbot exit=1
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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)
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.
@@ -0,0 +1,6 @@
=== 엣지의 ACME 계정이 왜 둘인가 ===
*
stat: cannot statx '/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/*/': No such file or directory
=== renewal 설정이 계정을 지목하나 ===
8:account = 8d53f9312e2a4c9cdd13620122cd8272
@@ -0,0 +1,10 @@
=== 운영 디렉터리의 계정들 ===
stat: cannot statx '$d': No such file or directory
/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272/
=== renewal 이 지목한 계정이 그중에 있나 ===
/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272
=== 각 계정의 regr.json 이 누구인가 ===
/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272/regr.json
acct/3702274776
@@ -0,0 +1,6 @@
=== staging 디렉터리의 계정들 ===
5df435a72a89118f1699212efd5fe2fe
66d5d86599378a0b07936733587017a6
=== 운영 디렉터리의 계정 수 ===
1
@@ -0,0 +1,13 @@
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)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -0,0 +1,13 @@
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)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -0,0 +1,23 @@
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
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).
Failed to renew certificate auth.hyeonworks.com with error: Some challenges have failed.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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)
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.
@@ -0,0 +1,9 @@
=== 훅 파일 ===
total 4
-rwxr-xr-x 1 root root 38 Sep 17 09:23 reload-nginx.sh
=== 성공한 dry-run 출력에 deploy-hook 이 몇 번 ===
0
=== certbot 판 ===
certbot 2.1.0
=== --run-deploy-hooks 플래그가 있나 ===
0
@@ -0,0 +1,21 @@
=== 밖에서 도메인으로 — 앞서 Host 헤더로 대신했던 것을 진짜 경로로 다시 잰다 ===
--- B-4 주입 (문서의 명령에서 https 만 http 로) ---
대조군 : (비면 정상)
동명 헤더 둘 : "x-auth-request-roles" : [ "admin", "editor" ]
값 안의 쉼표 : "x-auth-request-roles" : [ "role-with,comma" ]
신원 위조 :
"x-auth-request-email" : [ "attacker@evil.invalid" ]
"x-auth-request-user" : [ "attacker" ]
--- x-forwarded-* 가 밖에서 오면 무엇이 되나 ---
"x-forwarded-host" : [ "app1.hyeonworks.com" ]
"x-forwarded-port" : [ "80" ]
"x-forwarded-proto" : [ "http" ]
"x-forwarded-server" : [ "traefik-59b7647586-t8fc7" ]
"x-real-ip" : [ "10.42.0.1" ]
--- C-2 후보 셋 (밖에서) ---
/logout/connect/back-channel/keycloak 302
/backchannel-logout 302
/oauth2/sign_out 302
--- app2 (Grafana) 도 닿나 ---
app2 302
@@ -0,0 +1,41 @@
=== 엣지 nginx 는 무엇을 설정하나 ===
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-Port 80;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
=== 그런데 앱에 도착하는 것 ===
"x-forwarded-host" : [ "app1.hyeonworks.com" ]
"x-forwarded-port" : [ "80" ]
"x-forwarded-proto" : [ "http" ]
"x-forwarded-server" : [ "traefik-59b7647586-t8fc7" ]
"x-real-ip" : [ "10.42.1.0" ]
=== 내 주소는 무엇인가 ===
inet 100.83.212.4/32 scope global tailscale0
=== Traefik 이 신뢰하는 출발지 설정이 있나 ===
(위가 비면 기본값)
=== 저장소에 그 설정 파일이 있다 — 적용돼 있나 ===
# 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:
@@ -0,0 +1,43 @@
=== 적용 전 — 앱이 보는 것 ===
"x-forwarded-host" : [ "app1.hyeonworks.com" ]
"x-forwarded-port" : [ "80" ]
"x-forwarded-proto" : [ "http" ]
"x-forwarded-server" : [ "traefik-59b7647586-t8fc7" ]
"x-real-ip" : [ "10.42.0.1" ]
(x-forwarded-for 가 없다. x-real-ip 는 flannel 게이트웨이다)
=== 저장소의 설정 전문 ===
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:
=== 적용한다 (파일 주석이 적어 둔 두 줄) ===
helmchartconfig.helm.cattle.io/traefik created
Waiting for deployment "traefik" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "traefik" rollout to finish: 1 old replicas are pending termination...
deployment "traefik" successfully rolled out
@@ -0,0 +1,10 @@
=== 적용 뒤 — 앱이 보는 것 ===
"x-forwarded-host" : [ "app1.hyeonworks.com" ]
"x-forwarded-port" : [ "80" ]
"x-forwarded-proto" : [ "http" ]
"x-forwarded-server" : [ "traefik-5d6fcf895-n8p4k" ]
"x-real-ip" : [ "192.168.122.1" ]
=== 내 tailnet 주소와 견준다 ===
"remoteAddr" : "192.168.122.1"
호스트의 tailscale0 = 100.83.212.4 / 엣지 게스트 = 192.168.122.10
@@ -0,0 +1,12 @@
=== 가설: Traefik 이 보는 출발지가 trustedIPs 밖이다 ===
앱이 본 remoteAddr = 192.168.122.1 (호스트의 virbr0)
설정의 trustedIPs = 10.42.0.0/16 (파드 대역)
=== 게스트 대역을 더해서 시험한다 (저장소 파일은 안 고친다) ===
helmchartconfig.helm.cattle.io/traefik configured
deployment "traefik" successfully rolled out
=== 다시 잰다 ===
"x-forwarded-proto" : [ "http" ]
"x-real-ip" : [ "192.168.122.1" ]
"remoteAddr" : "192.168.122.1"
@@ -0,0 +1,18 @@
=== kc-lab-edge 의 인증서 ===
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
No certificates found.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
---
ls: cannot access '/etc/letsencrypt/live/': No such file or directory
=== 엣지 nginx 가 443 을 듣나 ===
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1106,fd=5),("nginx",pid=1065,fd=5))
---
keycloak-lab
=== 밖에서 ===
https://auth.hyeonworks.com/realms/master 000
https://app1.hyeonworks.com/ 000
http://auth.hyeonworks.com/realms/master 200
@@ -0,0 +1,8 @@
=== 04번 1단계: 플러그인이 깔렸나 ===
* dns-cloudflare
* standalone
* webroot
=== 04번 3단계: 자격증명 파일이 있나 (값은 안 본다) ===
ls: cannot access '/etc/letsencrypt/cloudflare.ini': No such file or directory
wc: /etc/letsencrypt/cloudflare.ini: No such file or directory
@@ -0,0 +1,19 @@
=== 엣지 nginx 가 X-Forwarded-For 를 세우나 ===
8: listen 80 default_server;
9: server_name _;
12: proxy_pass http://k3s_traefik;
14: proxy_set_header Host $host;
15: proxy_set_header X-Forwarded-Host $host;
16: proxy_set_header X-Forwarded-Proto http;
17: proxy_set_header X-Forwarded-Port 80;
18: proxy_set_header X-Forwarded-For $remote_addr;
19: proxy_set_header X-Real-IP $remote_addr;
=== app1 은 엣지를 거치나 — 엣지 access log 마지막 3줄 ===
192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0"
192.168.122.1 - - [17/Sep/2026:07:11:33 +0000] "GET /realms/master HTTP/1.1" 200 602 "-" "curl/8.5.0"
192.168.122.1 - - [17/Sep/2026:07:12:01 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0"
=== http 로 토큰 엔드포인트가 도나 (이게 되면 b·c 편은 TLS 없이 돈다) ===
well-known 200
token 401
@@ -0,0 +1,21 @@
=== 앱이 실제로 받은 헤더 전부 (trustedIPs 에 게스트 대역을 더한 상태) ===
{
"headers" : {
"host" : [ "app1.hyeonworks.com" ],
"user-agent" : [ "curl/8.5.0" ],
"accept" : [ "*/*" ],
"x-forwarded-host" : [ "app1.hyeonworks.com" ],
"x-forwarded-port" : [ "80" ],
"x-forwarded-proto" : [ "http" ],
"x-forwarded-server" : [ "traefik-697889c85-jqcjn" ],
"x-real-ip" : [ "192.168.122.1" ],
"accept-encoding" : [ "gzip" ]
},
"remoteAddr" : "192.168.122.1",
"localAddr" : "10.42.0.27",
"scheme" : "http",
"secure" : false,
"serverName" : "app1.hyeonworks.com",
"serverPort" : 80,
"requestUrl" : "http://app1.hyeonworks.com/api/echo"
}
@@ -0,0 +1,6 @@
=== 저장소 판으로 되돌린다 ===
helmchartconfig.helm.cattle.io/traefik configured
deployment "traefik" successfully rolled out
=== 호스트에서 출발지를 바꾸는 규칙이 있나 ===
--- libvirt
@@ -0,0 +1,46 @@
=== 저장소 판만 적용한 상태에서 다시 잰다 ===
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
--- 앱이 받은 것
{
"headers" : {
"host" : [ "app1.hyeonworks.com" ],
"user-agent" : [ "curl/8.5.0" ],
"accept" : [ "*/*" ],
"x-forwarded-host" : [ "app1.hyeonworks.com" ],
"x-forwarded-port" : [ "80" ],
"x-forwarded-proto" : [ "http" ],
"x-forwarded-server" : [ "traefik-5d6fcf895-xmqp6" ],
"x-real-ip" : [ "192.168.122.1" ],
"accept-encoding" : [ "gzip" ]
},
"remoteAddr" : "192.168.122.1",
"localAddr" : "10.42.0.27",
"scheme" : "http",
"secure" : false,
"serverName" : "app1.hyeonworks.com",
"serverPort" : 80,
"requestUrl" : "http://app1.hyeonworks.com/api/echo"
}
@@ -0,0 +1,24 @@
=== 이 curl 은 어디서 어디로 가나 ===
100.83.212.4 app1.hyeonworks.com
100.83.212.4 auth.hyeonworks.com
=== 호스트의 DNAT 파일 (sudo 없이 읽히나) ===
#!/usr/sbin/nft -f
table ip lab_edge
delete table ip lab_edge
table ip lab_edge {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "tailscale0" tcp dport {80, 443} dnat to 192.168.122.10
}
chain forward {
type filter hook forward priority filter - 10; policy accept;
ip daddr 192.168.122.10 tcp dport {80, 443} ct state new accept
}
}
=== lab_edge 테이블 실물 ===
sudo: a password is required
exit=1
@@ -0,0 +1,7 @@
=== http 로 로그인 체인이 도나 ===
oauth2 진입 302 -> https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=Vt28FHS-cuqr7NcdUaR6xjPOs2zreT0crThNPaGjDuQ%3D&redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=s14SFB1opH_jb0_IQKSx65X6FrtD4-8svAS8qfTQQwU&code_challenge=iv0wxqu3JsnSsT-f07hRh1UCwei4WHBZiDNGw-TATvA&code_challenge_method=S256
=== 그 리다이렉트를 따라가 본다 (3홉) ===
final 302 https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=ATjUt4POkZvWTWfeYJHZxH4uYbJGdIcMDjw0yYmXTW8%3D&redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=R7GTKPxRn-hNvjvcsYTH_Zpw32wrlD7h376XQ1d5I2I&code_challenge=C9PP9r8Oze2mzwC6AusVE19CKx6-xHoQ1fxZwHO7PRM&code_challenge_method=S256
=== 등록된 redirectUris ===
@@ -0,0 +1,43 @@
=== 파드 안에서 공개 이름이 무엇으로 풀리나 ===
100.83.212.4 auth.hyeonworks.com
100.83.212.4 app1.hyeonworks.com
=== 파드에서 그 주소의 80·443 에 닿나 ===
100.83.212.4:80 닫힘
100.83.212.4:443 닫힘
192.168.122.10(엣지):80 열림
192.168.122.10(엣지):443 닫힘
=== CoreDNS 에 이 이름을 돌리는 설정이 있나 ===
# File managed by k3s. DO NOT EDIT.
# See: https://docs.k3s.io/advanced#coredns-custom-configuration-imports
.:53 {
errors
health
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
}
hosts /etc/coredns/NodeHosts {
ttl 60
reload 15s
fallthrough
}
prometheus :9153
cache 30
loop
reload
loadbalance
import /etc/coredns/custom/*.override
forward . /etc/resolv.conf
}
import /etc/coredns/custom/*.server
--- NodeHosts
192.168.122.11 kc-lab-1
192.168.122.12 kc-lab-2
=== 노드의 /etc/hosts 에 있나 ===
(없음)
@@ -0,0 +1,11 @@
=== CoreDNS 에 엣지로 보내는 항목을 더한다 (k3s 의 custom override) ===
configmap/coredns-custom created
deployment.apps/coredns restarted
Waiting for deployment "coredns" rollout to finish: 0 of 1 updated replicas are available...
error: timed out waiting for the condition
=== 파드에서 다시 ===
80 닫힘
=== echo 를 다시 띄워 JWKS 를 받게 해 본다 (여전히 https 라 실패해야 정상) ===
deployment "echo" successfully rolled out
@@ -0,0 +1,14 @@
=== 실패 원인 ===
plugin/hosts: this plugin can only be used once per Server Block
k3s Corefile 이 이미 hosts /etc/coredns/NodeHosts 를 쓴다. override 는 같은 블록에 들어간다.
=== 별도 서버 블록(.server)으로 고쳐 넣는다 ===
configmap/coredns-custom configured
coredns-747f7ff557-c8f9q 1/1 Running 0 30s
--- 로그
.:53
hyeonworks.com.:53
[WARNING] No files matching import glob pattern: /etc/coredns/custom/*.override
[INFO] plugin/reload: Running configuration SHA512 = bbef792a18068d14383e2aae31d25d75edca67dfea52ee28e28117264f318981634c941f3626af4eed0f5f0d9866ddac82f13668a8f19b5a353fae28e695f24d
CoreDNS-1.14.6
linux/amd64, go1.26.5, 424d125
@@ -0,0 +1,9 @@
=== 파드에서 다시 푼다 ===
192.168.122.10 auth.hyeonworks.com
192.168.122.10 app1.hyeonworks.com
192.168.122.10:80 열림
192.168.122.10:443 닫힘
=== 클러스터 안에서 realm 을 받아 본다 (http) ===
sh: line 1: curl: command not found
command terminated with exit code 127
@@ -0,0 +1,14 @@
=== B-7 6단계 ② 두 cookie secret 의 길이만 (값은 안 본다) ===
32
32
=== B-7a 6단계 R 함수와 스캔 루프 ===
(위가 비면 세션 키가 없는 것이다) dbsize=3
=== D-4a 8단계 dry-run 으로 훅 호출만 본다 ===
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
No simulated renewals were attempted.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
exit=0
@@ -57,6 +57,8 @@ PostgreSQL 을 정상 종료시키고 refresh 와 새 로그인과 관리 API
| 정지 구간 | 1분 남짓. 그동안 정문이 실제로 `503` 이 된다 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
A-1 에서 룩어사이드 캐시는 읽을 때 데이터베이스와 대조하지 않는다는 것을 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 `200` 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다.
@@ -294,7 +296,8 @@ curl -s -o /dev/null -w 'well-known %{http_code}\n' \
밖에서 정문도 재 둔다.
```bash label="[lab host] 밖에서 본 정문"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
## 주입
@@ -518,8 +521,9 @@ kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
밖에서 본다. 한 번 눈으로 볼 때는 헤더까지 본다.
```bash label="[lab host] 정문을 코드로 한 번, 헤더로 한 번"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -I https://auth.hyeonworks.com/realms/master
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
```
실측은 `https://auth.hyeonworks.com/realms/master HTTP 503` 이다(observed). 이 `503` 은 Keycloak 이 준 것이 아니다. Ready 인 백엔드가 하나도 없어서 그 앞의 프록시가 준 것이고, `200` 이던 JWKS 도 정문으로는 닿지 않는다.
@@ -640,7 +644,8 @@ deployment "postgres" successfully rolled out
```bash label="[lab host] ① Ready 와 정문을 함께 친다"
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' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
```bash label="[lab host] ② 재시작 횟수만 따로 센다"
@@ -56,6 +56,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
| 걸리는 시간 | 전 구간 약 40분. 4a 의 축출을 보는 데만 7분 |
| 되돌리기 | `virsh start`. `--grace-period=0 --force` 로 파드를 지우지 않는다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
A-1 과 A-5 는 네트워크만 끊는다. 파드는 살아 있고 쿠버네티스는 계속 정확한 상태를 안다. 여기서는 기계 자체를 없애므로 상태를 보고할 주체가 사라진다.
@@ -197,11 +199,13 @@ kubectl get pv $(kubectl -n keycloak-lab get pvc postgres-data \
**무엇을 확인하는가** — 정문이 지금 무엇을 답하는지, 그리고 Prometheus 와 Grafana 가 어느 노드에 있는지.
```bash label="[터미널 C] ① 응답을 통째로 읽는 형태"
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
```bash label="[터미널 C] ② 여러 번 비교할 것이므로 코드만 뽑는 형태"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 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
```
```bash label="[터미널 B] ③ 관측 스택이 어느 노드에 있나"
@@ -323,7 +327,8 @@ kubectl get node kc-lab-2
```
```bash label="[터미널 C] ② 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 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
```
손이 아프면 한 줄로 묶는 형태가 있고, 그 루프는 미검증이다. `Ctrl-C` 로 멈춘다.
@@ -333,7 +338,7 @@ 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 \
https://auth.hyeonworks.com/realms/master)"
--resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
sleep 15
done
```
@@ -610,13 +615,33 @@ ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containe
### 9. 4b — 밖에서는 두 주소를 함께 본다
```bash label="[터미널 C] ① 20초 간격으로, 인증 서버"
curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
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
```
```bash label="[터미널 C] ② 같은 간격으로, Grafana"
curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/
```
**★ ② 는 이 배치에서 `000` 밖에 못 낸다 — 노드가 살아 있어도 그렇다**(2026-09-17, observed). 원래 실행은 여기서 `502` 와 `000` 이 갈렸는데 지금은 처음부터 `000` 이라 판정에 못 쓴다. 이유가 이름 해석이 아니라 **인증서**다.
```bash label="[lab host] 왜 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
```
```text label="이 실험대의 값"
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` 으로 본다**고 적어 둔다.
**그래서 ② 는 이 절의 신호가 못 된다.** 판정은 `auth` 쪽 ① 로 한다. 관측 스택이 살아 있는지를 밖에서 보려면 `port-forward` 를 띄운 터미널이 끊기는지로 보거나, 04 의 인증서에 이름을 더하고 엣지에 서버 블록을 올려야 한다(unknown — 이 실험대는 안 했다).
```text
+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
@@ -656,7 +681,8 @@ kubectl -n keycloak-lab get pods
```
```bash label="[터미널 C] ⑤ 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 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
```
**예상 결과**
@@ -59,6 +59,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
| 걸리는 시간 | 전 구간 약 30분 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
A-1 이 답하지 못하고 넘긴 물음에서 출발한다. 가이드는 그 물음을 그대로 인용한다.
@@ -276,11 +278,13 @@ ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
**무엇을 확인하는가** — 정문이 지금 무엇을 답하는지.
```bash label="[lab host] ① 응답을 통째로 읽는 형태"
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
```bash label="[lab host] ② 여러 번 비교할 것이므로 코드만 뽑는 형태"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 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` 이어야 한다.
@@ -584,7 +588,8 @@ kubectl -n keycloak-lab get pods | grep keycloak
```
```bash label="[lab host] ② 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 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
```
```text
@@ -690,7 +695,8 @@ kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
```
```bash label="[lab host] ③ 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 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
```
```text
@@ -263,6 +263,30 @@ agroal_creation_time_total_milliseconds
agroal_destroy_count_total
```
**★ 같은 줄을 2026-09-17 에 쳤더니 모양이 달랐다**(observed). `grep` 은 이름만 남기지 않는다. 지표 한 줄을 통째로 내보내므로 레이블과 값이 함께 나오고, 순서도 알파벳순이 아니다. 열일곱 줄이 나왔고 그 안에 `agroal_max_used_count` 가 이미 있었다.
```text
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).
**출력에서 답이 되는 것** — 열두 줄 가운데 뒤에서 쓰는 넷이다.
| 지표 | 무엇을 말하는가 |
@@ -663,6 +687,21 @@ kubectl -n keycloak-lab exec a6-probe -- sh -c \
agroal_available_count 19.0
```
**★ 이 정규식은 `blocking_time_total` 도 함께 잡는다**(2026-09-17, observed). 같은 줄을 쳤더니 여덟 줄이 나왔다. 위 일곱 줄에 `agroal_blocking_time_total_milliseconds` 가 하나 더 붙고, 지표마다 `{datasource="default"}` 가 달려 있다.
```text
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초를 기다린 요청이 있었다 |
@@ -751,6 +790,8 @@ kubectl -n keycloak-lab logs keycloak-1 --since=20m \
관련 로그 줄수: 0
```
**★ 그 줄을 2026-09-17 에 쳤다**(observed). 출력은 `0` 이고 종료 코드는 `1` 이다 — `grep -c` 는 센 값이 0 이면 1 로 끝난다. 뒤에 `&&` 로 다른 명령을 이어 붙이면 그 명령이 안 돈다.
하나도 없었고 까닭이 명확하다.
```text
@@ -827,7 +868,33 @@ kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
| 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` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master` | `200` |
**★ 이 표의 두 줄은 이 실험대에서 답이 달랐다**(2026-09-17, observed).
`enp1s0` 쪽은 비어 있지 않다. 이 게스트의 기본 qdisc 가 `fq_codel` 이라 아무것도 안 걸었을 때도 한 줄이 나온다. 잔재가 없다는 것은 그 줄에 `netem` 이나 `prio` 가 안 보인다는 뜻이다.
```text
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64
```
마지막 줄에 `--resolve` 가 붙어 있는 까닭이다. 원 가이드는 이름만 쳤는데 `auth.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 그 형태는 `000` 을 찍고 `curl` 은 `7` 로 끝난다. `kc-lab-edge` 에서 쳐도 같은 값이다.
```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 000 이다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```text
curl: (7) Failed to connect to auth.hyeonworks.com:443 after 3 ms: Could not connect to server
000
```
엣지 주소를 짚어 주면 `200` 이 온다. 이름만 친 `000` 은 Keycloak 이 아니라 이름이 가리키는 곳의 문제라 원상복구 판정에는 쓸 수 없다. 지연 주입을 되돌리는 절이라 응답이 늦을 수 있어 시간을 넉넉히 준 형태로 친다.
```bash label="[lab host] 표의 마지막 줄 — 시간을 넉넉히 준 형태"
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
```
## 막히면
@@ -854,7 +921,8 @@ kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
## 무엇이 관측이고 무엇이 아닌가
- (observed) 파드와 postgres 의 노드 배치, 주입 전 평균 `70 ms` 와 `66 ms`, `Cannot find device "eth0"` 네 줄 사이에 낀 `적용완료` 와 주입 시각 `13:14:55`, 그 상태의 「검증」 값 `43 ms` 와 `47 ms`, `ip -brief link` 의 `flannel.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`, 해제 뒤 `noqueue` 와 `43 ms` 와 `51 ms`, `agroal_*` 지표 이름 열두 개.
- (unknown) `enp1s0` 과 `flannel.1` 에 각각 거는 `tcpdump` 두 줄, `tc filter show`, 낙관적 락 로그를 세는 `grep -icE` 줄. 가이드가 셋 다 미검증으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 두 단계 형태도 이 실험대에서 치지 않았다.
- (unknown) `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 두 단계 형태. 이 실험대에서 치지 않았다. 가이드가 미검증으로 표시한 넷 — `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.0` 과 `max_used_count 18.0`, readiness 프로브가 `context deadline exceeded` 로 한 줄, 낙관적 락 로그 `0`, 해제 뒤 `noqueue` 와 `42 ms` 와 `39 ms`, 엔드포인트 주소 둘.
- 구조에서 나온 결론이고 출력이 없는 것 — 「`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` 는 그 목록에 안 보이지만 부하 뒤 출력에는 있다.
@@ -59,6 +59,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
**이 절차에는 스크립트가 없다.** 원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸고, 그래서 증거 파일의 줄에는 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가 붙어 있다. 사람이 치는 형태가 아니다. 그리고 이 실험에서 스크립트는 특히 위험하다 — `DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때 무엇이 실행됐는지 알 수 없다. 파괴를 손으로 치고, 눈으로 확인하고, 복구도 손으로 친다.
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과 그 파일로 실제로 서비스를 되살리는 것 사이에는 시험되지 않은 가정이 여러 개 있고, 이 절차는 그중 둘을 판정한다.
@@ -194,14 +196,16 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
**무엇을 보는가** — 정문과 app1 의 응답. 처음 한 번은 응답을 읽는다.
```bash label="[lab host] ① 헤더를 통째로 본다"
curl -I https://auth.hyeonworks.com/realms/master
curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master
```
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
```bash label="[lab host] ② 코드만 뽑아 둘을 잰다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
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`). 이것도 파괴 직후 값이고, 그게 결과다.
@@ -400,8 +404,10 @@ LINE 1: select count(*) from realm
```bash label="[lab host] ③ 파드와 밖에서 본 상태를 다시 잰다"
kubectl -n keycloak-lab get pods -o wide
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
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`).
@@ -441,8 +447,10 @@ kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
```bash label="[lab host] ① 두 경로를 잰다"
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
```
@@ -458,6 +466,7 @@ curl -s -o /dev/null -w 'well-known %{http_code}\n' \
```bash label="[lab host] ② 토큰 발급을 잰다"
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 \
@@ -594,6 +603,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tA
```bash label="[lab host] ① 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
```
@@ -60,6 +60,8 @@ databasechangelog 행 수를 먼저 세고 이미지 태그를 정방향·롤백
**실측이 두 실행에서 나온다**(observed). 처음 실행은 `15:0015:10` 에 역방향 `26.0` 을 쳤고, 후속 실행은 `15:2215:26``26.7.3` 정방향과 롤백을 쳤다. 아래에서도 어느 쪽인지 매번 적는다.
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다. 그 계획이 언제 동작하고 언제 동작하지 않는지를 가른다.
@@ -278,7 +280,7 @@ curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyA
```bash label="[lab host] ① 폴링을 뒤에서 돌린다"
( for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
--resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
sleep 1
done > /tmp/d2-avail.txt ) &
```
@@ -462,7 +464,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tA
```bash label="[lab host] ⓪ 폴링 창에서 다시 띄운다"
( for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
--resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
sleep 1
done > /tmp/d2-avail.txt ) &
```
@@ -582,7 +584,8 @@ kubectl -n keycloak-lab logs keycloak-1 --previous
### 7. 그런데 서비스는 살아 있다
```bash label="[lab host] 밖과 Service 와 StatefulSet 을 함께 본다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
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
@@ -407,6 +407,21 @@ ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v gr
**문제가 생기면** — 출력이 비면 `grep 'nginx:'` 의 콜론을 빠뜨렸거나 nginx 가 떠 있지 않다. `systemctl status nginx` 부터 본다.
**이 단계도 기계가 틀렸다**(2026-09-17, observed). 위 명령은 `test-server` 에서 nginx 를 찾는데, 기반 가이드 03 이 nginx 를 `kc-lab-edge` 로 옮겼다. 그래서 랩 호스트에서는 **한 줄도 안 나온다** — 5·6 절이 certbot 을 엣지에서 찾는 것과 같은 이유다. 찾는 곳을 엣지로 바꾼다.
```bash label="[kc-lab-edge] nginx 프로세스 두 줄을 본다"
ssh kc-lab-edge "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
```text label="2026-09-17 의 두 줄"
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
```
**이 두 줄은 위 예시와 한 군데가 다르고, 그 다름이 판정표를 그대로 보여 준다.** 위 예시는 마스터와 워커의 `lstart` 와 `etimes` 가 같아서 「기동 이후 reload 가 없었다」였다. 여기서는 워커가 10초 늦게 떴고 `etimes` 도 10 작다 — 03 을 밟으면서 `systemctl reload nginx` 를 친 흔적이다. 마스터는 그대로 두고 워커만 갈아 끼운 것이 숫자로 남는다.
또 하나. 엣지의 마스터 명령줄은 `/usr/sbin/nginx -g daemon on; master_process on;` 이고 위 예시의 `/usr/bin/nginx` 와 경로가 다르다. Arch 호스트와 Debian 게스트의 패키징 차이다.
### 8. 두 기계의 시계 차를 지금 잰다
**목적** — 주입 후에는 되짚을 수 없는 값을 확보한다. SSH(Secure Shell, 원격 셸 접속) 왕복에 걸리는 시간까지 함께 본다. 이 실험은 이걸 나중에 하는 바람에 공백 수치를 한 번 틀렸다.
@@ -670,11 +685,11 @@ sudo: a password is required
ssh -t test-server 'sudo certbot renew --dry-run'
```
**예상 결과** — 끝의 `simulated renewals` 요약이 나온다. 훅을 넣었다면 `Running deploy-hook command` 줄도 나오는데, 이 실험대는 훅이 없는 상태에서 쟀으므로 그 줄은 미검증이다(unknown).
**예상 결과** — 끝의 `simulated renewals` 요약이 나온다. **훅을 넣어도 `Running deploy-hook command` 줄은 안 나온다**(2026-09-17, observed) — certbot 2.1.0 의 dry-run 은 deploy 훅을 건너뛴다. 근거는 D-4a 의 「인증서를 세우고 훅까지 놓아도」 절에 있다. 그리고 같은 명령이 DNS 전파 때문에 한 번 실패하고 다음 번에 성공하기도 한다.
**왜 필요한가** — dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 절차가 도는지, 검증이 통과하는지까지만 말해 준다. 파일이 실제로 바뀌었을 때 nginx 가 그것을 집는지는 dry-run 으로 알 수 없다.
**문제가 생기면** — 여기서 실패하면 강제 갱신도 실패한다. 오류 문구를 읽고 고친 뒤 다시 친다.
**문제가 생기면** — **여기서 실패했다고 강제 갱신도 실패하는 것은 아니다**(2026-09-17, observed). `--dry-run` 은 Let's Encrypt 의 **staging 서버**로 붙는데 `renewal/*.conf` 의 `account =` 은 **운영 계정**을 가리킨다. 그래서 staging 쪽 ACME 계정이 둘 이상이면 dry-run 만 `Please choose an account` 로 멈는다 — 운영 경로에는 그 계정이 하나뿐이라 같은 일이 안 일어난다. 오류 문구에 `account` 가 들어 있으면 D-4a 의 「`--dry-run` 이 보는 계정」 절을 먼저 읽는다. 자격증명 파일이 없거나 DNS-01 검증이 안 되는 경우는 두 경로가 같은 것을 쓰므로 강제 갱신에서도 그대로 날 것으로 보는데, 이 실험대에서 그렇게 재 보지는 않았다(unknown).
### 13. 강제 갱신을 한 번 친다
@@ -698,6 +713,15 @@ ssh -t test-server 'sudo certbot renew --force-renewal'
**문제가 생기면** — 발급 한도에 걸렸으면 주당 중복 인증서 5장을 이미 썼다는 뜻이다. 다음 주까지 기다린다.
**★ 2026-09-17 에 다시 치는 곳은 `test-server` 가 아니라 `kc-lab-edge` 다**(observed). 인증서와 nginx 가 엣지 게스트로 옵기면서 명령을 치는 기계도 바뀜었고, 거기는 NOPASSWD sudo 라 `-t` 가 필요 없다.
```bash label="[kc-lab-edge] 이 실험대가 2026-09-17 에 친 형태"
sudo certbot renew --force-renewal
```
결과는 `Congratulations, all renewals succeeded:` 였고 `certbot exit=0` 이었다. 배포 훅이 돌아 서빙하는 인증서까지 바뀌었는데, 그 대조는 D-4a 의 「엣지에서 다시 치고」 절에 있다 — 일련번호가 `06F3E0EF4D1BB03DE58130EAAD1176101373` 에서 `065547991777D11A408CEA90D945DDA03DF1` 로, `notAfter` 가 `Dec 3` 에서 `Dec 16` 으로 바뀜다.
## 주입 검증
「갱신 실패」와 「갱신은 됐는데 안 집었다」를 가르는 절이다. 이 실험은 처음에 이 둘을 구별하지 못해 두 갈래로 적어 뒀었다.
@@ -1106,8 +1130,8 @@ crt.sh 에 관한 곁다리도 실측이다. 발급 사실은 Certificate Transp
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 인증서 발급과 갱신·reload 판정 전부. Cloudflare API 토큰이 있어야 DNS-01 이 돈다. 지금까지 밟은 것 — 기계·유닛 이름 대조, 기본 유닛에 훅이 없다는 확인.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **남은 것** — 인증서 발급과 갱신·reload 판정 전부(9~22 절). Cloudflare API 토큰이 있어야 DNS-01 이 돈다. 지금까지 밟은 것 — 기계·유닛 이름 대조(5 절), 기본 유닛에 훅이 없다는 확인(6 절 셋 다), nginx 워커 PID 판정 기준(7 절, 기계를 엣지로 고쳐서), 두 기계의 시계 차(8 절).
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
@@ -1116,7 +1140,7 @@ crt.sh 에 관한 곁다리도 실측이다. 발급 사실은 Certificate Transp
- (observed, 호스트 확인) `nginx.service` 의 유효 설정 여덟 값. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트)으로 따로 표시했다.
- **이 편이 잰 reload 는 사람이 쳤다** — `08:58:52` 의 `nginx -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` 형태. 가이드가 전부 미검증으로 표시했다.
- (observed, 2026-09-17) `certbot renew --dry-run` 은 훅을 놓아도 `Running deploy-hook command` 를 안 낸다 — certbot 2.1.0 의 dry-run 이 deploy 훅을 건너뛰고 `--run-deploy-hooks` 플래그도 없다. `openssl … -checkend 2592000` 감시 한 줄, `systemctl reload nginx` 형태는 여전히 미검증이다(unknown).
- **비밀은 옮기지 않았다** — 이 편이 다루는 파일 중 비밀인 것은 `privkey2.pem` 하나이고 크기(`241`)와 권한(`-rw-------`)만 적었다. 내용은 열지 않았고 가이드도 열지 않는다. 일련번호·`Log ID`·호스트명은 식별자라 그대로 적었다.
- **이 실험이 재지 않은 것** — 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 전송 중 아티팩트 76건의 원인, 타이머가 스스로 갱신하는 경로(만료 30일 전에야 조건이 성립한다). 전송 중 감시는 전체 50건이었고 그 이상 반복하지 않았다.
@@ -394,14 +394,232 @@ nginx: configuration file /etc/nginx/nginx.conf test is successful
**문제가 생기면** — `nginx -t` 가 실패하면 `&&` 뒤가 안 돌고 워커도 안 바뀐다. `nginx.conf` 를 고친 뒤 다시 친다.
certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래는 가이드가 미검증으로 표시한 줄이다(unknown).
**5~8 절은 2026-09-17 에 실제로 밟았다. 다만 기계가 `test-server` 가 아니다**(observed). 기반 가이드 03 이 nginx 를, 04 가 certbot 을 `kc-lab-edge` 로 옮겼으므로 `/etc/letsencrypt/renewal-hooks/deploy/` 도 `nginx` 도 그 게스트 안에 있다. 1 절의 `ps` 줄을 랩 호스트에서 치면 **한 줄도 안 나온다.** 아래는 엣지에서 친 것이다 — **따라 하는 순서가 아니라 그날의 기록이다.** 첫 줄의 훅 파일은 5 절 ②의 `nano /tmp/reload-nginx.sh` 로 연다. `printf` 로 찍으면 `\n` 과 `>` 를 먼저 해독한 뒤에야 정작 읽어야 할 두 줄에 닿는데, 그 두 줄은 나중에 열어서 고칠 파일이다.
```bash label="[test-server] dry-run 으로 훅 호출만 본다 (unknown)"
```bash label="[kc-lab-edge] 5~8 절을 엣지에서 이어 친 형태 (observed)"
printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
```text label="7 절의 실측 — 파일 크기가 40 이 아니라 38 이다"
total 4
-rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh
```
```text label="8 절의 실측 — 위 예상 결과에 없는 셋째 줄이 나온다"
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` 가 신호를 보내는 프로세스를 띄웠다고 알리는 것이고, 실패가 아니다. 위 예상 결과는 `nginx -t` 의 두 줄만 실어서 이 줄을 보면 잠깐 멈추게 된다.
**그리고 이 절이 세우려는 판정 기준이 여기서 그대로 작동했다**(observed). 훅을 손으로 돌리기 전과 뒤의 두 줄이 이렇다.
```text label="훅 실행 전후"
전 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` 로 갈렸으며 새 워커의 `etimes` 가 `0` 이다. 판정표의 「마스터 그대로 · 워커 바뀜 = reload 됐다」 그 칸이다. 10 절이 이 방법으로 훅의 효과를 판정하는데, **인증서가 없어도 이 판정 기준 자체는 여기서 검증된다.**
certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래 줄은 오래 미검증이었는데 **2026-09-17 에 쳤다**(observed). 인증서가 한 장도 없는 상태에서도 돌고, 종료 코드는 `0` 이다.
```bash label="[kc-lab-edge] dry-run 으로 훅 호출만 본다"
sudo certbot renew --dry-run
```
```text label="인증서가 없을 때의 출력"
Saving debug log to /var/log/letsencrypt/letsencrypt.log
No simulated renewals were attempted.
```
**갱신할 것이 없으면 훅도 안 불린다.** `Running deploy-hook command` 줄은 안 나오고 `No simulated renewals were attempted.` 한 줄로 끝나며, 그래도 종료 코드는 `0` 이다. 그래서 **이 명령의 성공은 훅이 도는지에 대해 아무 말도 안 한다**.
**★ 그런데 인증서를 세우고 훅까지 놓아도 dry-run 은 훅을 안 부른다**(2026-09-17, observed). 오래 미검증으로 남겨 둔 줄이라 이번에 재 봤다. `/etc/letsencrypt/renewal-hooks/deploy/` 에 실행 권한까지 준 훅을 놓고, 시뮬레이션이 **성공한** 판에서도 `Running deploy-hook command` 는 안 나온다.
```bash label="[kc-lab-edge] 훅을 놓고, 성공한 dry-run 출력에서 그 낱말을 센다"
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
sudo grep -c deploy-hook /tmp/dr.txt
certbot --version
```
```text label="이 실험대의 값"
-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). 세 번을 연달아 쳐는데 성공 · 실패 · 성공이었다. 실패한 판은 앞의 둘과 또 다른 사유다.
```text label="세 번째 실패 사유 — DNS 전파"
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초가 이 도메인에서는 아슬아슬하다. **한 번 실패했다고 설정이 틀린 것이 아니다** — 다시 쳐 보고, 계속 실패하면 `--dns-cloudflare-propagation-seconds` 를 올린다. 이 판의 종료 코드는 `1` 이었다 — 앞에서 본 대로 종료 코드는 사유마다 다르다.
**★ 더 나쁜 것은, 종료 코드가 실패를 일관되게 알려 주지 않는다는 점이다**(2026-09-17, observed). 같은 「전부 실패」 본문을 두 번 받았는데 한 번은 `0`, 한 번은 `1` 로 끝났다.
```bash label="[kc-lab-edge] 종료 코드를 따로 잡아서 본다"
sudo certbot renew --dry-run >/tmp/dr.txt 2>&1; echo "certbot exit=$?"
```
```text label="첫 번째 — 본문은 실패, 종료 코드는 0"
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
```
조금 뒤에 같은 명령을 다시 치니 실패 사유가 바뀜었고, 종료 코드도 같이 바뀜었다.
```text label="두 번째 — 본문은 같은 실패, 종료 코드는 1"
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` 을 기다려도 두 번째에서만 맞는다. 이 명령에 대해 종료 코드는 판정 근거가 못 된다.
**그러므로 `&&` 로 뒤를 잇거나 `$?` 로 갈라선 안 된다.** 판정은 본문의 `renew failure(s)` 수와 `All simulated renewals failed` 줄로 한다. 이 편이 재려는 것이 「성공했다고 보고하는데 실제로는 안 된 일」인데, 사전 점검 명령 자체가 그 성질을 갖고 있다.
```bash label="[kc-lab-edge] 실패를 실패로 읽는 형태"
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` 를 그대로 열어 본다.
판정을 `&&` 나 `||` 로 이어 붙이지 않는 까닭은 바로 위 문단과 같다. 종료 코드가 거짓말하는 명령을 살피는 절에서 분기를 셸에 맡기면 같은 함정을 다시 판다.
**★ 그리고 인증서를 옮겨도 갱신 능력은 따라오지 않는다**(observed). 다른 기계에서 `live/` · `archive/` · `renewal/` 만 가져오면 위 오류가 난다. `renewal/*.conf` 가 가리키는 **ACME(Automatic Certificate Management Environment, 인증서 발급을 주고받는 프로토콜) 계정**(`/etc/letsencrypt/accounts/`)과 **DNS 자격증명 파일**이 없기 때문이다. 오류 문구가 「등록부터 하라」여서 계정을 새로 만들라는 말로 읽히는데, 실제로 빠진 것은 옮겨 오지 않은 디렉터리다.
```text label="/etc/letsencrypt/ 아래에서 함께 와야 하는 것"
live/ 인증서 심볼릭 링크
archive/ 실제 파일
renewal/ 갱신 설정 — authenticator 와 자격증명 경로를 적는다
accounts/ ★ ACME 계정 키. 없으면 "You should register…"
cloudflare.ini ★ renewal 이 가리키는 자격증명. 없으면 DNS-01 이 못 돈다
```
출력에 `Running deploy-hook command` 계열의 줄이 나오는가, 그리고 `simulated renewals` 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다. 그래서 관찰 절이 필요하다.
**★ 그리고 `--dry-run` 이 보는 계정은 `renewal/*.conf` 가 가리키는 계정이 아니다**(2026-09-17, observed). `--dry-run` 은 Let's Encrypt **staging 서버**로 붙는데, `renewal/*.conf` 의 `account =` 은 **운영 계정**의 id 를 적어 둔다. 그래서 staging 쪽 계정이 둘 이상이면 certbot 이 고르지 못하고 앞서 본 `Please choose an account` 로 멈는다.
```bash label="[kc-lab-edge] 두 서버의 계정을 각각 센다"
sudo find /etc/letsencrypt/accounts -mindepth 3 -maxdepth 3 -type d
```
```bash label="[kc-lab-edge] renewal 이 어느 계정을 박아 뇌는가"
sudo grep -E '^(account|server) ' /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
```
```text label="이 실험대의 값"
/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-run` 은 `acme-staging-v02` 아래를 보고, 거기 계정이 하나면 그것을 쓰고 둘이면 묻는다. 앞서 고르라고 나온 `66d5` 와 `5df4` 는 **둘 다 staging 계정**이었다 — `5df4` 는 실패한 dry-run 이 그때 새로 만든 것이라, **한 번 실패하고 나면 다음 dry-run 이 계속 실패한다.**
고르라고 나오는 이름은 계정 폴더의 `meta.json` 에서 온다.
```bash label="[kc-lab-edge] staging 계정을 누가 언제 만들었나"
sudo sh -c "cat /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/*/meta.json"
```
```text label="이 실험대의 값"
{"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` 로 끝난다.
**그래서 `--dry-run` 의 실패와 진짜 갱신의 실패는 같은 것이 아니다.** 다만 이 실험대에서 진짜 갱신을 다시 치는 데까지 가지는 않았다(unknown). 확인한 것은 운영 계정이 하나뿐이고 그것이 `account =` 이 가리키는 바로 그 id 라는 것까지다.
**중복 계정을 지우고 다시 치니 dry-run 이 통과했다**(2026-09-17, observed).
```text label="staging 계정을 하나만 남긴 뒤"
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 가 거기 있기 때문이다.
```text label="[kc-lab-edge] sudo certbot renew --force-renewal"
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
```
**판정은 이 출력이 아니라 서빙하는 인증서로 한다.** 같은 소켓을 갱신 전후로 두 번 열어 일련번호와 날짜를 견준다.
```bash label="[kc-lab-edge] 서빙하는 인증서가 바뀜는가"
echo | openssl s_client -connect 127.0.0.1:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -dates -serial
```
```text label="갱신 전"
notBefore=Sep 4 11:29:18 2026 GMT
notAfter=Dec 3 11:29:17 2026 GMT
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
```
```text label="갱신 후"
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.pem` 이 `Sep 17 10:36` 으로 생겼다.
**원래 실행과 다른 데가 둘 있다**(observed). 첫째, 훅 출력에 `types_hash` 경고 줄이 없다 — 엣지의 nginx 설정이 호스트의 것과 달라서다. 둘째, 원래는 worker 하나가 그대로 남았는데 이번에는 둘 다 교체됐다. **`ran with error output` 이 실패가 아니라는 것은 그대로다** — stderr 로 나간 세 줄이 전부 성공 메시지다.
새 인증서의 `notBefore` 가 훅이 도는 시각(`10:36:31`)보다 약 한 시간 앞이다. 왜 그런지는 이 실험대에서 가르지 않았다(unknown) — 발급자가 앞당긴 것인지 시계 차인지는 안 재 봤다.
세 이름 모두 갱신 뒤에도 검증을 통과한다.
```text label="[lab host] 갱신 뒤 세 이름"
auth 302 verify=0
app1 200 verify=0
app2 302 verify=0
```
**그리고 이것이 종료 코드 이야기를 닫는다.** 성공도 `0` 이고 앞의 첫 번째 실패도 `0` 이었다. 같은 명령이 돼을 때와 안 돼을 때 같은 값을 내므로, `$?` 로는 둔 경우를 가를 수 없다. 본문을 읽는 수밖에 없다.
## 관찰
### 9. 강제 갱신을 친다
@@ -650,8 +868,8 @@ echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonw
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 훅을 설치하고 강제 갱신으로 워커 PID 가 바뀌는지 보는 구간 전부. 인증서가 있어야 갱신할 것이 생긴다. 지금까지 밟은 것 — 훅 디렉터리가 빈 것, 두 기계 시계 차 94초.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **남은 것** — 강제 갱신부터(9~13 절). 인증서가 있어야 갱신할 것이 생긴다. 지금까지 밟은 것 — 훅 디렉터리가 빈 것(3 절), 두 기계 시계 차 94초(4 절), **훅 파일 작성·설치·확인·손으로 실행(5~8 절)과 그 실행이 워커 PID 를 갈아 끼우는 것(10 절의 판정 기준)**. 기계는 전부 `kc-lab-edge` 로 바꿔서 쳤다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
@@ -61,6 +61,8 @@ NetworkPolicy 로 8080 과 9000 만 열어 JGroups 트랜스포트인 TCP 7800
| 걸리는 시간 | 전 구간 약 30분 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
A-0 은 세션이 Infinispan 복제가 아니라 PostgreSQL 로 공유된다고 측정했다. Keycloak 24 이전 자료는 세션이 7800 으로 복제된다고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고 A-0 모델은 안 깨진다고 예측하므로, 7800 만 끊어 보면 둘 중 어느 쪽이 틀렸는지 판정된다.
@@ -684,13 +686,16 @@ kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
```text
NAME ADDR READY
keycloak-lxk8h [10.42.0.13],[10.42.1.17] true,false
``` `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다.
```bash label="[lab host] 밖에서 정문을 친다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
인증서 단계를 건너뛴 실험대라면 443 을 듣는 것이 없어 여기는 `000` 이다. 그때는 TLS 를 빼고 `Host` 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 `200` 을 받았다(observed).
`kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다.
```bash label="[lab host] 밖에서 정문을 친다"
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
`--resolve` 는 위 「읽기 전에」가 적은 까닭으로 붙였다. 인증서 단계를 건너뛴 실험대라면 엣지가 443 을 안 들으므로 그래도 `000` 이고, 그때는 TLS 를 빼고 `Host` 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 `200` 을 받았다(observed).
```bash label="[lab host] TLS 없이 같은 것을 잰다"
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: auth.hyeonworks.com' http://192.168.122.10/realms/master
@@ -311,6 +311,13 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-
persistent-user-sessions[:v1] ← 목록에 있다
```
**★ 2026-09-17 에 쳤더니 두 줄이 나왔다**(observed). 버전 접미사가 붙은 것과 안 붙은 것이 목록에 따로 있다. 하나만 나올 것으로 알고 있으면 두 줄째를 딴 기능으로 읽는다.
```text
persistent-user-sessions[:v1]
persistent-user-sessions
```
`--help-all` 은 출력이 길고 기능 목록이 한 줄에 쉼표로 이어 붙어 나온다. `tr ',' '\n'` 이 그것을 줄로 쪼갠다. 처음 한 번은 `grep` 없이 쳐서 어떤 기능들이 있는지 통째로 본다.
**왜 필요한가** — 목록에 없으면 그 버전에서는 이 절차를 할 수 없다. 기능이 제거돼 기본 동작으로 고정된 것이고, 그 자체가 답이다.
@@ -388,6 +395,14 @@ Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
```
**★ patch 로 바꾸면 `configured` 가 안 나온다**(2026-09-17, observed). `configured` 는 `kubectl apply` 가 내는 말이고, patch 는 `statefulset.apps/keycloak patched` 를 낸다. 아래 「`configured` 가 나와야 한다」는 매니페스트를 고쳐 `apply` 한 경우에만 맞는 판정이다. patch 로 쳤으면 바뀐 것을 args 문자열로 확인한다 — 9번이 그 일을 한다.
```text
statefulset.apps/keycloak patched
```
이 실험대에서 patch 로 쳤을 때 롤아웃은 `18:02:41` 에 시작해 `18:03:47` 에 끝났다. 재빌드까지 66초다.
**왜 필요한가** — `configured` 가 나와야 한다. `unchanged` 면 args 가 안 바뀌었다. 빌드 옵션이라 기동할 때 재빌드가 일어나 평소보다 오래 걸리므로 `--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 전환 시각이 없으면 뒤에서 지표가 언제부터 변했는지 볼 때 인과를 못 붙인다.
**문제가 생기면** — 타임아웃이 나면 `--timeout=500s` 로 다시 치고, `logs keycloak-0` 에 빌드 진행이 보이는지 확인한다.
@@ -503,6 +518,19 @@ kubectl -n observability exec deploy/prometheus -- \
keycloak-1 sessions 캐시 0.0 건
```
**★ 위 두 줄은 이 명령의 출력이 아니다**(2026-09-17, observed). `tr` 과 `grep` 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 96줄이었다. 위의 요약 두 줄은 원 실행의 스크립트가 만든 모양이다(inferred). 앞의 ① 이 찍는 JSON 도 마찬가지로 `sessions` 만이 아니라 캐시 전부를 담고 있다.
```text
"cache":"sessions"
"pod":"keycloak-1"}
"82"]}
"cache":"clientSessions"
"pod":"keycloak-1"}
"82"]}
```
세 줄이 한 묶음이고 마지막 줄의 숫자가 그 캐시의 엔트리 수다. 세로로 읽으면서 캐시 이름과 파드 이름을 눈으로 짝지어야 한다.
**왜 필요한가** — persistent 였을 때와 똑같은 숫자다. `approximate_entries_unique` 는 그 노드가 소유한 엔트리만 세고 백업본을 들고 있어도 0 으로 보인다. 이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 10번의 DB 행 수다.
**문제가 생기면** — 빈 결과가 오면 0건이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.
@@ -637,14 +665,13 @@ kubectl -n keycloak-lab exec a7-probe -- sh -c \
**목적** — 7800 과 57800 을 양방향으로 버리고 교차 노드가 끊기는지 본다.
두 노드에 각각 규칙을 넣는다. 규칙의 `-d` 는 그 노드에 있는 파드의 IP 다.
두 노드에 각각 규칙을 넣는다. 규칙의 `-d` 는 그 노드에 있는 파드의 IP 다. kubeconfig 가 lab host 에만 있으므로 두 노드 모두 거기서 한 줄 `ssh` 로 친다.
```bash label="[kc-lab-1] ① 양쪽 노드에 raw DROP 을 넣는다"
```bash label="[lab host] ① 양쪽 노드에 raw DROP 을 넣는다"
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-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 차단'
@@ -655,7 +682,33 @@ date '+%H:%M:%S 차단'
| `kc-lab-1` | `keycloak-1` | `$K1` |
| `kc-lab-2` | `keycloak-0` | `$K0` |
**뒤의 두 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 가 들어간다. `$K0` `[lab host]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[lab host]` 에서 펴서 보내므로 이 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다.
**네 `ssh` 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` `$K1` 이 들어간다. 둘 다 `[lab host]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[lab host]` 에서 펴서 보내므로 이 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 와 `echo "$K1"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다.
**원 가이드는 앞 두 줄을 `kc-lab-1` 안에서 직접 쳤다.** 지우지 않고 남기는 까닭은 아래 실패 출력이 이 실험대에서 실제로 받은 것이기 때문이다. 따라 칠 형태는 위의 ① 이다.
```bash label="[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}')
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 차단'
```
**★ 이 블록은 `kc-lab-1` 에서 안 돈다**(2026-09-17, observed). 앞의 「읽기 전에」가 적은 그대로다 — 그 기계에는 kubeconfig 가 없어서 첫 두 줄의 `kubectl` 이 막히고, `$K0` 와 `$K1` 이 빈 문자열이 된다. 그 상태로 `iptables` 가 이어지면 `-d` 가 값을 못 받아 규칙이 하나도 안 들어간다. 그리고 `kc-lab-1` 에는 `kc-lab-2` 의 호스트 키가 없어 뒤의 두 줄도 접속 단계에서 끝난다. 여섯 줄이 전부 실패했는데 마지막 `date` 는 그대로 `차단` 을 찍는다.
```text
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` 은 비어 있었다.
NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫어서 이미 붙어 있는 7800 연결이 계속 산다.
@@ -670,7 +723,14 @@ NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. Netwo
주입이 걸렸는지 양쪽 카운터를 둘 다 본다.
```bash label="[kc-lab-1] ② 두 노드의 규칙과 카운터를 본다"
```bash label="[lab host] ② 두 노드의 규칙과 카운터를 본다"
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).
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다"
sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
@@ -704,7 +764,9 @@ kubectl -n observability exec deploy/prometheus -- \
+125초 cluster_size(k0 k1) = [1.0 ]
```
`2.0 2.0` 이 `1.0` 으로 떨어지는 데 50초쯤 걸린다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.
`2.0 2.0` 이 `1.0` 으로 떨어지는 데 50초쯤 걸린다.
**★ 이 실험대에서는 `+75초` 에 떨어졌고 시계열에 빈 값이 없었다**(2026-09-17, observed). 손으로 25초마다 친 다섯 번은 `2 2` · `2 2` · `1 1` · `1 1` · `1 1` 이었다. 차단을 푼 뒤 양쪽이 `2` 로 돌아오는 데는 `+60초` 가 걸렸다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.
split brain 은 DB 한 줄로 확인한다.
@@ -856,7 +918,14 @@ volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm 과 사용
순서가 있다. `iptables` 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, `args` 를 되돌린다.
```bash label="[kc-lab-1] ① 두 노드의 raw 규칙을 확인한다"
```bash label="[lab host] ① 두 노드의 raw 규칙을 확인한다"
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).
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다"
sudo iptables -t raw -L PREROUTING -n
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
```
@@ -937,6 +1006,9 @@ postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s
| 탐침 파드 | `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` |
**★ 마지막 줄의 `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`.
```bash label="[lab host] ⑧ 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
```
@@ -965,7 +1037,7 @@ kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
이 실험대가 실제로 본 것(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` 및 `Session not active`, 재시작 뒤 캐시 `1.0`, 분단 대기 시계열 다섯 줄, 대조군 `200` 과 시험군 `400`, DB 정지 뒤 `① 500` 과 `② 200`, 원복 뒤 `["start"]` 와 `DB 온라인 세션: 1 건` 과 외부 `200`, 비밀번호 길이 `19`, 기능 목록의 `persistent-user-sessions[:v1]` 이다.
가이드가 미검증으로 표시한 것(unknown)은 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다.
가이드가 미검증으로 표시한 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력은 2026-09-17 에 쳤다(observed). 나오는 모양은 위 11번의 ★ 에 적었다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다.
측정이 샌 곳이 하나 있다. `cluster_size` 시계열의 빈 값과 값이 하나뿐인 줄인데, 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다.
@@ -973,4 +1045,6 @@ DB 정지 뒤의 `200 / 500` 은 조건부다. 캐시 온도에 따라 `400 / 40
이 절차가 재지 않은 것은 volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지다. 파드가 둘뿐이라 그것을 볼 수 없다.
2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정값은 전부 같은 답이 나왔다 — 전환 전 args `["start"]` 와 `DELETE 144`, 전환 뒤 args `["start","--features-disabled=persistent-user-sessions"]`, 로그인 5회 `200 200 200 200 200` 뒤 DB `(0 rows)`, 교차 노드 refresh `200`, 재시작 전 `sid` 를 담고 재시작 뒤 `400` 과 `Session not active`, 분단에서 대조군 `same-node 200` 과 시험군 `cross-node 400`, `coord = t` 둘, DB 정지 뒤 `500` 과 `200`, 원복 뒤 `["start"]` 와 세션 행 `1`. 달랐던 것은 명령이 아니라 화면의 모양이고 위의 ★ 다섯이 그것이다. 원문은 `relive-2026-09-17/a7-06..08` 에 있다.
<!-- body:end -->
@@ -57,6 +57,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
| 무중단의 전제 | replica 2 와 readiness 프로브 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
운영에서 가장 자주 겪는 작업이다. 장애가 아니라 정상 배포인데도 사용자가 로그아웃되면 그건 사고다.
@@ -373,7 +375,7 @@ kubectl -n observability exec deploy/prometheus -- \
```bash label="[kc-lab-1 · 두 번째 터미널] ① 5초 간격으로 48번 외부를 친다"
for i in $(seq 1 48); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 \
https://auth.hyeonworks.com/realms/master)"
--resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
sleep 5
done
echo
@@ -493,7 +495,7 @@ echo "$K0 $K1"
```bash label="[kc-lab-1 · 두 번째 터미널] 1초 간격 150회로 더 촘촘히 잰다"
for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
--resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
sleep 1
done
echo
@@ -7,7 +7,7 @@
"revision": "9465582b5d1630eb4ae7c4e078021486919bf6b6",
"verified": "반입할 때 적어 둔 source/.source-revision 이 이 커밋이고 저장소에 실재한다 — 「chore: 실행 환경 구성 문서 추가 및 수정」, 2026-09-10. **전에 이 칸은 cdac9b81 이었고 그것은 틀렸다** (2026-09-17 대조) — 그 커밋은 2026-09-04 이고 반입본 306개 가운데 docs/guides/** 28개가 거기에 아예 없다. 가이드는 그 엿새 뒤 6f6ab86 에서 들어왔다. **그런데 반입한 바이트는 이 커밋과도 같지 않다** — 9465582b 와 같은 것은 276개이고 29개가 다르다. 같은 306개를 저장소의 **작업 트리**와 견주면 297개가 같다. HEAD 에서 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 6f6ab86 도 28개가 어긋났다. **맞는 커밋은 없다** — 반입은 커밋이 아니라 **그 시점의 작업 트리**(미커밋 수정이 있던 상태)에서 떠 온 것이다. 지금도 저장소는 그 파일들을 M 으로 낸다. 작업 트리와 남은 차이 8개 가운데 5개가 그 M 목록에 있고(반입 뒤 저장소가 더 고쳤다), deploy/lab/host/nginx-keycloak-lab.conf 는 저장소에서 deploy/lab/edge/ 로 옮겨져 반입본에만 남았다. **이 커밋은 「반입 시점의 HEAD」라는 뜻이지 「반입한 바이트가 이것이다」가 아니다**"
},
"ssotSha256": "23a3efedf685f954fd69d6c90f1468e258457f7dd61142ef7652c8e8c6fa27e0",
"ssotSha256": "fdb69c6e6e80bb74ee1e066523a799be7c8a0320f81f3d3799a12909762d6387",
"sourceRevision": "keycloak-session-lab@2026-09",
"generatedAt": "2026-09-17",
"candidateScope": {

Some files were not shown because too many files have changed in this diff Show More