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:
co-authored by
Claude Opus 5
parent
4a457afde9
commit
32e39e20aa
+9
-4
@@ -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] ② 재시작 횟수만 따로 센다"
|
||||
|
||||
+32
-6
@@ -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
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
+70
-2
@@ -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` 는 그 목록에 안 보이지만 부하 뒤 출력에는 있다.
|
||||
|
||||
+15
-5
@@ -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
|
||||
```
|
||||
|
||||
+6
-3
@@ -60,6 +60,8 @@ databasechangelog 행 수를 먼저 세고 이미지 태그를 정방향·롤백
|
||||
|
||||
**실측이 두 실행에서 나온다**(observed). 처음 실행은 `15:00–15:10` 에 역방향 `26.0` 을 쳤고, 후속 실행은 `15:22–15: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
|
||||
|
||||
+29
-5
@@ -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건이었고 그 이상 반복하지 않았다.
|
||||
|
||||
|
||||
+222
-4
@@ -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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+10
-5
@@ -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
|
||||
|
||||
+84
-10
@@ -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 -->
|
||||
|
||||
+4
-2
@@ -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": {
|
||||
|
||||
+4
-1
@@ -53,6 +53,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
|
||||
**무엇을 재는 경로인지 먼저 못박는다.** 위조 헤더를 보내는 `https://app1.hyeonworks.com/api/echo` 는 `header-lab` 네임스페이스의 echo 앱으로 가고, 그 경로는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. 이 절차가 재는 것은 엣지가 인증을 끝낸 뒤의 인가가 아니라, 헤더를 받아 쓰는 업스트림이 그 값을 검증하는가다. 같은 위조 헤더를 토큰이 필요한 경로에 보내면 거기서 막히고, 그 대조를 주입 검증 절이 같이 친다.
|
||||
|
||||
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
엣지(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이고, 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다. 그러면 그 헤더는 무엇을 보증하는가. Q4 는 확인한 사실로 이렇게 적어 두었다.
|
||||
@@ -763,7 +765,8 @@ kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||
|
||||
```bash label="[lab host] ② app2 를 잡고 있는 Ingress 를 센다"
|
||||
kubectl get ingress -A | grep app2
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app2.hyeonworks.com/
|
||||
```
|
||||
|
||||
**예상 결과** — `app2` 를 잡고 있는 Ingress 가 `observability/grafana` 하나여야 한다.
|
||||
|
||||
+50
-10
@@ -58,6 +58,8 @@ oauth2-proxy 의 cookie secret 을 A 에서 B 로 바꿨을 때 로그인해 있
|
||||
| 전 구간 | 약 20분 |
|
||||
| 도구 | `jq` 도 `yamllint` 도 이 실험대에 없다 |
|
||||
|
||||
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
앞선 작업이 남긴 열린 질문 Q1 의 미지수 7 이 이렇게 물었다.
|
||||
@@ -86,6 +88,27 @@ B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
|
||||
|
||||
**★ 그 클라이언트를 만드는 단계가 어디에도 없다**(2026-09-17, observed). 기반 가이드 05 도, 저장소의 realm 임포트(`keycloak/import/keycloak-patterns-realm.json`)도 `oauth2-proxy` 를 만들지 않는다. 임포트에 있는 것은 `bff-confidential` · `edge-proxy` · `mock-google-broker` · `spa-public` · `token-mediating-confidential` 다섯이다. 새로 세운 실험대에서 이 편을 밟으면 로그인 화면 대신 이 문장을 만난다.
|
||||
|
||||
```text label="클라이언트가 없을 때 Keycloak 이 내는 화면"
|
||||
We are sorry...
|
||||
Client not found.
|
||||
```
|
||||
|
||||
`oauth2-proxy` 파드는 멀쩡히 뜨고 `app2` 도 `302` 를 내므로 **배포는 다 된 것처럼 보이고**, 로그인 화면까지 가서야 드러난다. 만드는 한 줄은 이렇다 — 값은 매니페스트가 읽는 Secret 에서 그대로 꺼내 넘긴다.
|
||||
|
||||
```bash label="[lab host] 이 편을 밟기 전에 클라이언트를 만든다"
|
||||
CS=$(kubectl -n keycloak-lab get secret oauth2-proxy-secrets -o jsonpath='{.data.CLIENT_SECRET}' | base64 -d)
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create clients -r keycloak-patterns \
|
||||
-s clientId=oauth2-proxy -s enabled=true -s protocol=openid-connect \
|
||||
-s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false \
|
||||
-s "secret=$CS" \
|
||||
-s 'redirectUris=["https://app2.hyeonworks.com/oauth2/callback"]' \
|
||||
-s 'webOrigins=["https://app2.hyeonworks.com"]'
|
||||
```
|
||||
|
||||
`redirectUris` 는 매니페스트의 `--redirect-url` 과 한 글자도 달라선 안 된다. **B-7a 와 C-2 도 같은 클라이언트를 쓰므로 똑같이 막힌다.**
|
||||
- B-0 이 끝나 있어야 한다. Redis 를 거기서 띄우고 `redis.keycloak-lab.svc:6379` 로 떠 있다.
|
||||
- 브라우저가 있어야 한다.
|
||||
|
||||
@@ -100,6 +123,15 @@ B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다
|
||||
|
||||
`oauth2-proxy-secrets` 가 있고 이미지도 받아지는데 기동 자체가 안 된다 — **이 편의 전제는 배포가 아니라 TLS 다.**
|
||||
|
||||
**그런데 위 오류의 주소가 원인을 하나 가리고 있었다**(2026-09-17, observed). `100.83.212.4` 는 랩 호스트의 tailnet 주소이고, 기반 가이드 03 이 nginx 를 엣지 게스트로 옮긴 뒤로 **그 주소에는 443 도 80 도 없다.** 그러니 인증서를 받아 엣지에 얹었더라도 파드는 여전히 이 주소를 두드렸을 것이다. 클러스터 DNS 가 그 이름을 엣지로 보내게 고친 뒤 같은 배포를 다시 했더니 주소만 바뀌고 증상은 같았다.
|
||||
|
||||
```text label="CoreDNS 를 고친 뒤 같은 오류"
|
||||
[2026/09/17 07:37:18] [provider.go:55] Performing OIDC Discovery...
|
||||
[2026/09/17 07:37:18] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: ... dial tcp 192.168.122.10:443: connect: connection refused
|
||||
```
|
||||
|
||||
이제 두드리는 곳이 엣지(`192.168.122.10`)이고, 거기 443 이 안 열린 것은 인증서가 없어서다. **같은 `connection refused` 인데 앞의 것은 원인이 둘이었고 뒤의 것은 하나다.** 03 의 「클러스터 안에서도 공개 이름에 못 닿는다」를 먼저 밟고 와야 이 편의 전제가 TLS 하나로 좁혀진다.
|
||||
|
||||
**이건 남의 도메인을 빌리고 남의 세션을 끊는 실험이다.** 둘을 건드린다. 인증서가 `auth` · `app1` · `app2` 세 이름만 덮어서 네 번째 이름을 못 만들기 때문에 Grafana 의 Ingress 를 잠시 내리고 `app2` 를 빌린다. 그리고 secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.
|
||||
|
||||
되돌리기는 둘이고 먼저 읽어 둔다.
|
||||
@@ -133,7 +165,7 @@ Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인
|
||||
**행동** — 먼저 지금 상태를 보고, 백업을 뜨고, 그 백업이 비어 있지 않은지 확인한다.
|
||||
|
||||
```bash label="[test-server] ① 지금 app2 가 어디로 가는지 본다"
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**②를 치기 전에 읽는다.** 셸은 `>` 를 kubectl 보다 먼저 처리한다. `~/grafana-ingress-backup.yaml` 은 kubectl 이 돌기도 전에 0바이트가 되고, `get` 이 실패하면 앞서 떠 둔 백업이 그때 없어진다. 뒤따르는 `wc -l` 과 `grep -c` 는 이미 비어 버린 파일을 센다. 그래서 이 절을 두 번째로 치는 사람은 — B-4 로 app2 를 먼저 빌렸거나 실험을 중간에 다시 시작했다면 — `wc -l ~/grafana-ingress-backup.yaml` 을 먼저 쳐서 쓸 만한 백업을 이미 갖고 있는지 보고, 갖고 있으면 ②를 건너뛴다.
|
||||
@@ -206,8 +238,10 @@ oauth2-proxy-c76b49c59-b9928 true kc-lab-2
|
||||
**무엇을 보는가** — 인증을 거치는 경로와 안 거치는 경로.
|
||||
|
||||
```bash label="[test-server] 두 경로의 상태 코드를 뽑는다"
|
||||
curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/
|
||||
curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping
|
||||
curl -s -o /dev/null -w '/ %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app2.hyeonworks.com/
|
||||
curl -s -o /dev/null -w '/ping %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app2.hyeonworks.com/ping
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`).
|
||||
@@ -287,15 +321,20 @@ kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||
"COOKIE_SECRET_B"
|
||||
```
|
||||
|
||||
길이도 본다. 가이드가 아래 두 줄을 미검증으로 표시했다(unknown) — 원래 실행 기록에 이 명령의 출력이 없다.
|
||||
길이도 본다. 원래 실행 기록에는 이 명령의 출력이 없어 미검증이었는데, **2026-09-17 에 쳐서 값이 생겼다**(observed). 둘 다 `32` 다 — 프록시가 안 떠 있어도 Secret 만 읽으면 되므로 인증서 없이 칠 수 있다.
|
||||
|
||||
```bash label="[lab host] ② 두 cookie secret 의 길이만 센다 (unknown)"
|
||||
```bash label="[lab host] ② 두 cookie secret 의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
|
||||
kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||
-o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
```text label="② 의 출력"
|
||||
32
|
||||
32
|
||||
```
|
||||
|
||||
**어디를 보나** — oauth2-proxy 는 정확히 16 · 24 · 32 바이트만 받는다. 매니페스트의 값은 32바이트짜리이고, 다른 수가 나오면 프록시가 기동에서 죽는다.
|
||||
|
||||
**이 값이 뜻하는 것** — 회전 대상이 미리 두 개 준비되어 있고, 그래서 이 실험이 한 번 바꾸고 되돌릴 수 있는 형태가 된다. 16 · 24 · 32 라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다.
|
||||
@@ -634,12 +673,12 @@ kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1 → test-server] ② Ingress 와 밖에서 본 응답을 함께 본다"
|
||||
```bash label="[lab host] ② Ingress 와 밖에서 본 응답을 함께 본다"
|
||||
kubectl -n observability get ingress grafana
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
첫 줄은 `kc-lab-1` 에서 치고, `curl` 은 1 절 ①과 같게 `test-server` 에서 친다. 기계가 다르면 1 절에서 본 응답과 견줄 수 없다.
|
||||
원 가이드는 첫 줄을 `kc-lab-1` 에서 치라고 적었는데 그 기계에는 kubeconfig 가 없어 `kubectl` 이 막힌다(2026-09-17, observed). 두 줄 다 lab host 에서 친다 — `curl` 도 1 절 ①과 같은 기계라야 그때 본 응답과 견줄 수 있다.
|
||||
|
||||
**예상 결과** — Ingress 가 `observability` 에 다시 있고 `app2` 응답이 1 절에서 처음 본 모양으로 돌아온다.
|
||||
|
||||
@@ -680,8 +719,9 @@ curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — 쿠키 비밀 회전 전부. `oauth2-proxy` 가 기동 시 OIDC 디스커버리를 `https` 로 하므로 인증서가 서야 뜬다.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 위 클라이언트를 만든 뒤 전 구간을 밟았다. 결과는 문서 그대로다 — 로그인 뒤 쿠키가 `_oauth2_proxy` 176자짜리 티켓이고 세션은 Redis 에 `_oauth2_proxy-<32자>` 로 들어갔으며, 업스트림이 `x-forwarded-user` · `x-forwarded-email` · `x-forwarded-preferred-username` 을 받았다. secret 을 A 에서 B 로 바꾸자 **회전 전 200 이던 같은 쿠키가 302 로 바뀌어** 로그인 시작점으로 되돌아갔다. 겹침 구간이 없다는 이 편의 결론이 그대로 성립한다.
|
||||
- **그리고 Redis 의 세션 키는 회전 뒤에도 그대로 남았다**(observed). 가리키던 쿠키가 못 쓰게 됐을 뿐 값은 살아 있다 — B-7a 가 다루는 고아가 여기서 생긴다.
|
||||
- **막는 것** — `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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+14
-3
@@ -55,6 +55,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
| 전 구간 | 약 20분. 그중 TTL 을 세 번 재는 데 1분이 그대로 든다 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. Redis 는 자기 CLI 로 묻는다 |
|
||||
|
||||
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
B-7 은 여기서 멈췄다.
|
||||
@@ -220,6 +222,8 @@ done
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
|
||||
```
|
||||
|
||||
**2026-09-17 에 이 블록을 그대로 쳤고 오류 없이 돌았다**(observed). 다만 출력이 비었다 — `oauth2-proxy` 가 안 떠서 세션 키가 하나도 없기 때문이고, 같은 순간 `R dbsize` 는 `3` 이었다(BFF 가 쓰는 키다). **그래서 이 절에서 확인된 것은 「명령이 맞다」까지이고 「값이 이렇게 나온다」는 아직 아니다.** 빈 출력을 보고 함수가 틀렸다고 되짚지 않도록 여기 적어 둔다 — `R: command not found` 가 아니라 **아무것도 안 나오는 것**이 이 상태의 정상이다.
|
||||
|
||||
**이 값이 뜻하는 것** — 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. 느리다. 키가 수백 개면 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다.
|
||||
|
||||
### 4. 시계를 맞춘다
|
||||
@@ -623,7 +627,7 @@ grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
|
||||
```bash label="[lab host] ③ 빌린 Ingress 를 걷고 백업을 올린 뒤 밖에서 본다"
|
||||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**예상 결과** — `app2` 응답이 B-7 을 시작하기 전 모양으로 돌아온다. 첫 줄의 상태 코드와 이어지는 `location` 헤더를 본다 — 여기서 갈라야 하는 것은 app2 가 아직 oauth2-proxy 로 가는지 Grafana 로 넘어갔는지다. Grafana 자체가 섰는지는 아래 표의 `get ingress grafana` 가 답한다.
|
||||
@@ -667,8 +671,15 @@ curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — 고아 세션 관찰 전부. 같은 이유로 `oauth2-proxy` 가 안 뜬다. 지금까지 밟은 것 — Redis 쪽 명령(`dbsize`·`--scan`)이 도는 것까지.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **고아 세션 관찰까지 밟았다**(2026-09-17). TLS 를 세우고 B-7 의 전제에 빠져 있던 `oauth2-proxy` 클라이언트를 만든 뒤, B-7 의 회전을 실제로 걸어 고아를 만들었다. 6 절의 `R` 함수와 스캔 루프가 값을 냈다.
|
||||
|
||||
```text label="회전 뒤 남은 고아 하나"
|
||||
_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 type=string ttl=3551 len=3457
|
||||
```
|
||||
|
||||
**TTL 이 정직하게 줄고 갱신되지 않는 것도 확인했다**(observed). 30초 간격으로 세 번 재니 `3551 → 3521 → 3490` 이었다. 가리키던 쿠키가 못 쓰게 된 뒤에도 값은 남은 수명만큼 그대로 산다.
|
||||
- **남은 것** — 12 절의 회전 시각 기준 정리와 14·15 절의 판정·삭제 루프. 고아가 하나뿐이라 「여럿 가운데 고르는」 형태는 아직 시험하지 못했다.
|
||||
- **막는 것** — `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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+18
-3
@@ -65,6 +65,8 @@ app1 과 app2 를 한 번의 로그인으로 묶은 뒤 IdP 세션만 끊고,
|
||||
| 도구 | `jq` 는 이 실험대에 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없다 |
|
||||
| 전 구간 | 약 20분. Keycloak 재시작에만 1~2분 |
|
||||
|
||||
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
원래 질문은 한 줄이었다.
|
||||
@@ -435,6 +437,18 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c
|
||||
|
||||
`user_session_id` 가 앞과 같고 `client_sessions` 만 1 에서 2 로 늘었다. SSO 의 데이터 구조가 이렇게 생겼다.
|
||||
|
||||
**★ 위 출력의 칸 이름이 질의와 다르다**(2026-09-17, observed). 질의는 `as clients` 로 별칭을 주는데 실린 출력의 머리는 `client_sessions` 다. **질의 쪽이 C-1 원본 가이드 그대로이고 틀린 것은 출력 쪽이다** — `client_sessions` 라는 별칭은 B-3 이 쓰는 다른 질의의 것이고, 그쪽 출력이 여기로 섞여 들어왔다. 위 질의를 그대로 치면 머리는 `clients` 로 나온다.
|
||||
|
||||
**★ 그리고 행이 하나만 나오지 않는다.** 위 출력은 한 줄인데, direct grant 로 토큰을 여러 번 받아 본 실험대에서는 그만큼 user session 이 쌓여 있다. 2026-09-17 에는 16행이 나왔고 **그중 `clients` 가 `2` 인 한 줄이 이 편이 찾는 세션**이다.
|
||||
|
||||
```text label="2026-09-17 의 끝 두 줄"
|
||||
4i6Q7qb3Y_7Ib4mQzzsmTkzQ | keycloak-patterns | 1
|
||||
V0mkutuu-0tNdmvHBfWucB9j | keycloak-patterns | 2
|
||||
(16 rows)
|
||||
```
|
||||
|
||||
**`clients` 가 `2` 인 한 줄이 이 편이 찾는 세션이고 나머지는 direct grant 로 받은 토큰들이 남긴 것이다.** 행이 많으면 그 줄을 눈으로 고른다 — 세션 수가 실험 순서에 따라 달라지므로 「한 줄이 나온다」를 통과 조건으로 삼지 않는다.
|
||||
|
||||
```text
|
||||
user session (사용자 · 브라우저 하나당 하나)
|
||||
├─ client session : bff-confidential
|
||||
@@ -682,7 +696,7 @@ grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
|
||||
```bash label="[lab host] C-2 를 이어서 하지 않을 때만 친다"
|
||||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**예상 결과** — `app2` 응답이 Grafana 로 돌아간다.
|
||||
@@ -723,8 +737,9 @@ curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — app1·app2 로그인과 두 앱의 Redis 키 대조. 지금까지 밟은 것 — 세션 정리 구간 전부(`logout-all` 이 안 듣는 것, 자식 1633·부모 6, 롤아웃이 kcadm 세션을 날리는 것).
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 B-7 의 전제에 빠져 있던 `oauth2-proxy` 클라이언트를 만든 뒤 app1 에 로그인하고 같은 쿠키로 app2 를 열었다. **로그인 화면이 다시 뜨지 않았다** — 응답이 곧바로 업스트림의 것이었고 `login-actions/authenticate` 는 0건이다. `client-session-stats` 에 `bff-confidential` 과 `oauth2-proxy` 가 나란히 잡히고, user session 한 줄의 `clients` 가 `2` 다. 저장소 쪽도 `_oauth2_proxy-*` 와 `bff:session:sessions:*` 가 각각 하나씩 늘었다.
|
||||
- **이 편도 `oauth2-proxy` 클라이언트가 없으면 로그인 화면에서 `Client not found.` 로 끝난다.** 만드는 한 줄은 B-7 의 전제 절에 적어 두었다.
|
||||
- **막는 것** — `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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+22
-1
@@ -60,6 +60,8 @@ C-1 이 세운 두 앱을 그대로 쓴다. app1 은 BFF(Backend for Frontend,
|
||||
| 임시 파드 | `curlimages/curl:8.11.1` · 이름 `c2probe` · `--rm` 으로 띄운다 |
|
||||
| 전 구간 | 약 20분 |
|
||||
|
||||
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
C-1 이 관측한 것에서 출발한다.
|
||||
@@ -574,6 +576,25 @@ curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||||
| `HTTP 200` | 실제로 닿는다 |
|
||||
| `HTTP 000` | curl 이 연결조차 못 했다 = 네트워크가 원인 |
|
||||
|
||||
|
||||
**★ 지금 배치에서는 주소가 다르고, 닿는 까닭도 다르다**(2026-09-17, observed). 판정(`200`)은 같은데 `nslookup` 이 내놓는 주소가 `100.83.212.4` 가 아니라 엣지 게스트의 `192.168.122.10` 이다.
|
||||
|
||||
```text label="탐침 파드에서 받은 그대로"
|
||||
Server: 10.43.0.10
|
||||
Address: 10.43.0.10:53
|
||||
|
||||
|
||||
Name: app1.hyeonworks.com
|
||||
Address: 192.168.122.10
|
||||
--- HTTPS ---
|
||||
app1 200
|
||||
curl exit=0
|
||||
```
|
||||
|
||||
아래 :::warning 이 적은 「tailnet 과 split DNS 의 헤어핀」은 이제 이 실험대의 사정이 아니다. [03] 이 CoreDNS 에 `coredns-custom` 항목을 넣어 세 이름을 엣지로 보내기 때문에 풀린다. **저절로 풀리지 않는다. 넣어야 풀린다** — 그 항목을 안 넣으면 클러스터 안에서 이 이름이 안 풀리고, 이 절은 `000` 으로 끝난다. 그러면 「네트워크가 원인이 아니다」를 못 보이고 후보 ③을 배제하지 못한다.
|
||||
|
||||
`kubectl -n keycloak-lab get networkpolicy` 도 같은 날 다시 쳐 `No resources found in keycloak-lab namespace.` 를 받았다(observed) — 대역이 성립한다는 근거는 그대로다.
|
||||
|
||||
후보 ③은 원인이 아니다. 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. 두 줄을 읽었으면 파드에서 나온다 — `--rm` 이 나가는 순간 파드를 지운다.
|
||||
|
||||
```sh label="[탐침 파드] ③ 파드에서 나온다"
|
||||
@@ -706,7 +727,7 @@ grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
|
||||
```bash label="[lab host] ① 빌린 것을 걷고 백업을 올린다"
|
||||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**예상 결과** — `app2` 응답이 Grafana 로 돌아간다.
|
||||
|
||||
+27
-4
@@ -79,6 +79,8 @@ error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/
|
||||
| 도구 | `jq` 가 이 실험대에 없다. 빈 목록은 `grep` 으로 읽는다 |
|
||||
| 걸리는 시간 | 약 40~60분. 빌드 시간이 들어 있다 |
|
||||
|
||||
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
코드에 저장소를 직접 만드는 빈이 없으면 무엇이 실제로 쓰이는지는 Spring Boot 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다.
|
||||
@@ -390,7 +392,14 @@ docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s c
|
||||
|
||||
두 노드에 들어갔는지 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 두 노드의 이미지 목록을 본다"
|
||||
```bash label="[lab host] ④ 두 노드의 이미지 목록을 본다"
|
||||
ssh kc-lab-1 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
|
||||
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
|
||||
```
|
||||
|
||||
원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). 두 줄 다 lab host 에서 보내면 둘 다 나온다.
|
||||
|
||||
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다"
|
||||
sudo k3s ctr images ls | grep keycloak-pattern-bff
|
||||
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
|
||||
```
|
||||
@@ -488,7 +497,7 @@ BFF 두 개가 서로 다른 노드에 있어야 한다. `topologySpreadConstrai
|
||||
### 외부 진입점이 이 애플리케이션의 HTML 을 주는가
|
||||
|
||||
```bash label="[lab host] 상태 줄과 헤더를 읽는 형태로 친다"
|
||||
curl -I https://app1.hyeonworks.com/
|
||||
curl -I --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/
|
||||
```
|
||||
|
||||
실측은 `https://app1.hyeonworks.com/ HTTP 200` 이고(observed, `02-autoconfiguration.txt`), 응답 머리는 이렇게 생겼다.
|
||||
@@ -508,6 +517,18 @@ content-type: text/html
|
||||
|
||||
`/actuator/beans` 는 117KB 이고 nginx 와 Traefik 을 거치면서 실패한다(observed, 해설 문서 1절).
|
||||
|
||||
**★ 지금은 밖에서도 받아진다**(2026-09-17, observed). TLS 를 세우고 같은 주소를 쳤더니 `200` 에 `155395` 바이트가 그대로 왔다. 위 `Bad Gateway` 는 엣지 nginx 의 버퍼나 프록시 체인이 지금과 다른 상태에서 잰 값으로 보인다(inferred).
|
||||
|
||||
```bash label="[dev] 밖에서 받아 크기를 센다"
|
||||
curl -s -o /tmp/beans-out.json -w 'code=%{http_code} size=%{size_download}\n' https://app1.hyeonworks.com/actuator/beans
|
||||
```
|
||||
|
||||
```text label="그 출력"
|
||||
code=200 size=155395
|
||||
```
|
||||
|
||||
**그래도 아래 「파드 안에서 받는다」를 그대로 쓴다.** 밖에서 받는 것이 이 편의 목적이 아니고, 프록시 설정이 바뀌면 다시 막힐 수 있다. 다만 `Bad Gateway` 를 「이 주소는 원래 밖에서 안 된다」로 읽지는 않는다.
|
||||
|
||||
```text
|
||||
$ curl https://app1.hyeonworks.com/actuator/beans
|
||||
Bad Gateway
|
||||
@@ -808,8 +829,10 @@ authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-pa
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — 브라우저 로그인(3절 뒤 전부)과 그 로그인이 만드는 `/actuator/beans` 재측정. 지금까지 밟은 것 — 이미지 빌드·반입, realm·클라이언트·사용자 생성, 매니페스트 적용, 빈 437개 확인.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **이 편의 주입은 2026-09-17 에도 밟지 않았다.** 막은 것이 인증서가 아니라 **소스**다 — 주입 1 절이 `keycloak-pattern` 의 파일 넷을 고치라고 하는데, 그 저장소에 이 작업과 무관한 커밋 안 된 변경이 이미 여러 개 있어 손대지 않았다. 이미지를 다시 만들지 않으면 3~5 절의 판정이 성립하지 않는다.
|
||||
- **그래서 지금 실험대의 BFF 는 B-0 이 아니라 B-1·B-2 상태다**(observed). 빈 목록에 `RedisSessionRepository` · `RedisHttpSessionConfiguration` · `JdbcOAuth2AuthorizedClientService` 가 다 있다. 이 상태에서 replica 2 로 로그인하면 **성공한다** — B-0 은 같은 조건에서 실패한다고 적는다. 두 편을 가르는 것이 세션 저장소 하나임을 반대편에서 확인한 셈이다.
|
||||
- **밟은 것** — 관찰 1·2(파드 안에서 빈 목록 받기, 저장소 계열 빈 뽑기)와 5 절의 토큰 경계. `bff/token-boundary` 가 `accessTokenStoredOnServer=true` · `refreshTokenStoredOnServer=true` · `browserTokenCount=0` 을 내는 것은 이 상태에서도 같다(observed).
|
||||
- **막는 것** — `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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+46
-2
@@ -579,6 +579,39 @@ echo "$KEY"
|
||||
|
||||
바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬으므로 그 출력에서 키 하나를 눈으로 골라 쳐도 되는데, 원 가이드가 그 두 단계 형태를 적어 두지 않았다(unknown).
|
||||
|
||||
**★ `head -1` 이 엉뚱한 세션을 집는다**(2026-09-17, observed). 원래 실행은 키가 하나였지만 둘 이상일 때가 있다. 2026-09-17 에 로그인 한 번 뒤 키가 둘이었고, `head -1` 이 고른 쪽에는 **`SPRING_SECURITY_CONTEXT` 가 없었다.**
|
||||
|
||||
```text label="키 둘이 담고 있는 것"
|
||||
bff:session:sessions:b66634a2-… sessionAttr:SPRING_SECURITY_SAVED_REQUEST · maxInactiveInterval · creationTime · lastAccessedTime
|
||||
bff:session:sessions:c5f7b0c7-… sessionAttr:SPRING_SECURITY_CONTEXT · …AUTHORIZATION_REQUEST · SPRING_SECURITY_LAST_EXCEPTION · lastAccessedTime · maxInactiveInterval · creationTime
|
||||
```
|
||||
|
||||
앞엣것은 로그인 화면으로 보내기 전에 만들어진 세션이고 인증 정보가 없다. 그것을 잡으면 아래 ③ 의 필드 목록에 `SPRING_SECURITY_CONTEXT` 가 안 나오고, 관찰 3 의 `\xac\xed` 도 못 본다 — **이 편이 보여 주려는 것이 통째로 안 보인다.** 오류는 안 나므로 「토큰이 없네」가 아니라 「인증 세션이 아니네」를 먼저 의심해야 하는데, 화면만 봐서는 갈리지 않는다.
|
||||
|
||||
키를 내용으로 고르면 몇 개가 있든 맞는 것을 잡는다.
|
||||
|
||||
```bash label="[lab host] ②-a 인증된 세션을 내용으로 고른다"
|
||||
KEY=
|
||||
for K in $(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | tr -d '\r'); do
|
||||
HAS=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli hexists "$K" 'sessionAttr:SPRING_SECURITY_CONTEXT' | tr -d '\r')
|
||||
echo "$HAS $K"
|
||||
if [ "$HAS" = 1 ]; then KEY=$K; fi
|
||||
done
|
||||
echo "고른 키: $KEY"
|
||||
```
|
||||
|
||||
**첫 줄의 `KEY=` 를 뺀 채로 치면 이 절이 통째 무의미해진다.** ② 가 이미 `$KEY` 를 채워 뇌기 때문에, 루프가 한 건도 못 맞혀도 마지막 `echo` 는 **② 가 잘못 고른 그 키를 그대로 찍는다.** 화면은 정상으로 보이는데 고쳐진 것이 없다. 비우고 시작해야 「못 찾았다」 가 빈 줄로 보인다.
|
||||
|
||||
키마다 `1` 과 `0` 을 앞에 찍는 것도 같은 까닭이다. 걸러 내는 일을 `grep -q` 에 맡기면 어느 키가 왜 떨어졌는지가 화면에 안 남는다.
|
||||
|
||||
```text label="2026-09-17 의 출력 (observed)"
|
||||
1 bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23
|
||||
1 bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688
|
||||
고른 키: bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688
|
||||
```
|
||||
|
||||
**둘 다 `1` 이면 루프는 마지막 것을 잡는다.** 어느 쪽을 잡아도 `SPRING_SECURITY_CONTEXT` 는 있으니 이 날은 상관이 없었지만, 「맞는 키가 하나뿐」 을 전제하고 읽으면 안 된다.
|
||||
|
||||
```bash label="[lab host] ③ 타입과 필드 이름을 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
|
||||
@@ -597,6 +630,8 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
|
||||
필드: creationTime
|
||||
```
|
||||
|
||||
**2026-09-17 에 다시 치니 필드가 여섯이었다**(observed) — `sessionAttr:SPRING_SECURITY_SAVED_REQUEST` 하나가 없었고 나머지 여섯은 같았다. 로그인 전에 뭐를 요청했는지에 따라 붙었다 말았다 하는 칸이라, 이 절이 보려는 것—토큰이 없다—은 그대로다.
|
||||
|
||||
**이 값이 뜻하는 것** — 필드 목록에 토큰이 없다. 저장소를 직접 열어 refresh token 이 평문으로 남는지 확인하는 것이 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.
|
||||
|
||||
### 3. TTL 과 값의 바이트를 본다
|
||||
@@ -621,6 +656,15 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" |
|
||||
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken...
|
||||
```
|
||||
|
||||
**★ 필드 순서를 기대하면 안 된다**(2026-09-17, observed). 위 `head -4` 는 `SPRING_SECURITY_CONTEXT` 가 첫 필드로 나온다고 보고 자른 것인데, Redis 해시의 필드 순서는 보장되지 않는다. 같은 명령을 다시 쳤을 때는 이렇게 나왔다.
|
||||
|
||||
```text label="2026-09-17 의 앞 두 줄"
|
||||
1) "lastAccessedTime"
|
||||
2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number…"
|
||||
```
|
||||
|
||||
**그래도 요점은 더 세게 확인된다.** `\xac\xed` 가 `SPRING_SECURITY_CONTEXT` 에만 붙는 것이 아니라 `lastAccessedTime` 의 `Long` 하나에도 붙는다. **이 해시의 값은 전부 Java 직렬화다.** 특정 필드를 보려면 `head` 로 자르지 말고 `hget "$KEY" 'sessionAttr:SPRING_SECURITY_CONTEXT'` 로 이름을 대서 꺼낸다.
|
||||
|
||||
`\xac\xed` 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. `--no-raw` 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다. 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다.
|
||||
|
||||
| 결과 | |
|
||||
@@ -786,8 +830,8 @@ kubectl -n header-lab get svc echo
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — 브라우저 로그인 뒤의 Redis 키·필드·TTL 관찰. 지금까지 밟은 것 — 이미지 빌드·반입, 배포, `/actuator/beans` 로 `RedisSessionConfiguration` 확인.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 로그인해서 Redis 키·필드·TTL 까지 봤다. 로그인은 브라우저 대신 쿠키 항아리를 쓴 `curl` 로 인가 코드 흐름을 돌렸고, 왕복 두 번과 쿠키가 그대로 재현된다. replica 2 에서 로그인이 됐고(B-0 은 여기서 실패한다) `bff/token-boundary` 가 `accessTokenStoredOnServer=true` · `browserTokenCount=0` 을 냈다. 브라우저 쪽 쿠키는 `AP3_SESSION` 48자 하나뿐이다.
|
||||
- **막는 것** — `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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+62
-4
@@ -348,7 +348,30 @@ kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
-c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
|
||||
온라인 세션도 `offline_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 원 가이드가 미검증으로 표시했다(unknown). 관리 API 로 보려면 이쪽이다.
|
||||
온라인 세션도 `offline_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 오래 미검증이었는데, **2026-09-17 에 ②③④ 를 나란히 쳤다**(observed).
|
||||
|
||||
```text label="② 의 출력 — 브라우저 로그인 전이라 0 이다"
|
||||
count
|
||||
-------
|
||||
0
|
||||
```
|
||||
|
||||
```text label="③ 의 출력 — 온라인 세션이 offline_flag 0 으로 들어 있다"
|
||||
offline_flag | count
|
||||
--------------+-------
|
||||
0 | 5
|
||||
```
|
||||
|
||||
```text label="④ 의 출력"
|
||||
[ {
|
||||
"offline" : "0",
|
||||
"clientId" : "bff-confidential",
|
||||
"active" : "4",
|
||||
"id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b"
|
||||
} ]
|
||||
```
|
||||
|
||||
**③ 과 ④ 는 같은 숫자가 아니다.** DB 는 `5` 인데 관리 API 는 `4` 라고 답했다. 둘이 세는 단위가 다르기 때문이다 — `offline_user_session` 은 **사용자 세션**의 행이고, `client-session-stats` 는 **클라이언트 세션**을 클라이언트마다 센다. 사용자 세션 하나에 클라이언트 세션이 0개일 수도 여럿일 수도 있으므로 두 수는 맞을 이유가 없다. 「③ 은 같은 숫자를 DB 쪽에서 보는 형태」라는 위 문장은 그래서 정확하지 않다. **대조군으로 쓸 때는 둘 중 하나를 골라 끝까지 그것만 쓴다.** 관리 API 로 보려면 이쪽이다.
|
||||
|
||||
```bash label="[lab host] ④ 관리 API 로 세는 형태"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
@@ -460,7 +483,20 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:ses
|
||||
date '+%H:%M:%S 세션 삭제'
|
||||
```
|
||||
|
||||
이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. `redis-cli` 출력은 CR 을 달고 오므로 `xargs` 가 넘기는 키 이름이 실제 키와 안 맞을 수 있고, 그때 `del` 은 오류 없이 `(integer) 0` 을 돌려준 뒤 `date` 줄은 그대로 「세션 삭제」를 찍는다. 원 가이드의 이 줄에 그 조각이 없어 여기에도 안 넣었다(unknown).
|
||||
이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. 그것이 문제가 되는지를 오래 미검증으로 두었는데, **2026-09-17 에 가렸다 — 문제가 안 된다**(observed).
|
||||
|
||||
시험용 키 둘을 넣고 위 블록을 한 글자도 안 바꾸고 쳤더니 `del` 이 `5` 를 돌려주고 스캔이 빈손이 됐다. 키가 실제로 지워졌다. 출력에 CR 이 붙는지를 `od -c` 로 직접 봤더니 줄 끝이 `\n` 하나다.
|
||||
|
||||
```bash label="[lab host] 스캔 출력의 줄 끝을 바이트로 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' | od -c | head -3
|
||||
```
|
||||
|
||||
```text label="그 출력 — 줄바꿈 하나로 끝난다"
|
||||
0000000 b f f : s e s s i o n : p r o b
|
||||
0000020 e 3 \n
|
||||
```
|
||||
|
||||
**갈리는 것은 `redis-cli` 가 아니라 `kubectl exec` 에 tty 가 붙었는지다.** 위 블록은 `-it` 없이 파이프로 받으므로 tty 가 없고 CR 도 없다. B-1 은 tty 가 붙는 형태로 쳤고 그쪽에서는 `tr -d '\r'` 이 필요하다. **그러니 이 줄은 그대로 두는 것이 맞다.**
|
||||
|
||||
**예상 결과** — 모양은 이렇다(observed).
|
||||
|
||||
@@ -784,8 +820,30 @@ JDBC 배선까지 걷어낼 것이면 전제와 되돌리기 절의 두 블록
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — 브라우저 로그인 뒤의 `oauth2_authorized_client` 행 관찰과 `accessTokenStoredOnServer` 판정. 지금까지 밟은 것 — 표가 생겨 있고 행이 `0` 인 것까지.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 로그인한 뒤 7 절과 관찰 1·2 를 전부 밟았다. **문서의 값과 거의 그대로 나온다.**
|
||||
|
||||
```text label="7 절 ② 의 실측"
|
||||
client_registration_id | principal_name | access_token_type | at_len | rt_len
|
||||
------------------------+----------------+-------------------+--------+--------
|
||||
keycloak | labuser | Bearer | 1437 | 744
|
||||
```
|
||||
|
||||
`rt_len` 이 `744` 로 문서와 같고 `at_len` 만 `1431` 대신 `1437` 이다 — access token 은 클레임에 따라 길이가 조금씩 달라진다.
|
||||
- **오래 미검증이던 두 줄도 값을 냈다**(observed). 관찰 2 의 `left(convert_from(...), 40)` 형태가 그대로 돌고, 나온 문자열이 문서에 실린 36자와 **한 글자도 다르지 않다.**
|
||||
|
||||
```text label="관찰 2 ② 의 실측 (앞 36자에서 끊는다)"
|
||||
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
|
||||
```
|
||||
|
||||
- **덮어쓰기도 재현됐다**(observed). 같은 사용자로 새 브라우저에서 한 번 더 로그인했더니 **행 수는 `1` 그대로인데 `at_md5` 가 바뀌었다.** 앞 로그인의 토큰은 그 순간 사라졌다.
|
||||
|
||||
```text label="덮어쓰기 전후"
|
||||
전 행 1 · at_md5 722769ac041a42164d08060e5446d02b
|
||||
뒤 행 1 · at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 · 발급 2026-09-17 08:05:52
|
||||
```
|
||||
|
||||
- **남은 것** — 관찰 3(로그아웃하면 세 저장소가 다 정리되는가). 브라우저 없이 `POST /logout` 을 치려면 CSRF 토큰이 필요한데 그 구간은 밟지 않았다.
|
||||
- **막는 것** — `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 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
+85
-11
@@ -150,6 +150,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
|
||||
**이 값이 뜻하는 것** — 그때는 영속화가 아예 꺼져 있었다. 지금 환경은 아마 다르다 — 매니페스트가 `--appendonly yes` 로 시작하므로 `appendonly yes` 가 나오고, 그 차이가 둘째 주입의 출발 조건이다.
|
||||
|
||||
**★ 2026-09-17 에 쳤더니 `save` 도 비어 있지 않았다**(observed). 이 이미지의 기본 스냅샷 조건 셋이 그대로 들어 있다. 그래서 둘째 주입의 출발 조건은 「AOF 만 켠 상태」가 아니라 「AOF 와 스냅샷이 둘 다 켜진 상태」이고, 그래도 결과는 같다 — 볼륨이 없으면 둘 다 컨테이너와 함께 사라진다.
|
||||
|
||||
```text
|
||||
save
|
||||
3600 1 300 100 60 10000
|
||||
appendonly
|
||||
yes
|
||||
```
|
||||
|
||||
같은 날 `redis-cli --scan` 은 아무것도 안 찍었다. 브라우저 로그인을 안 한 실험대라 세션 키가 0개였고, 그 상태에서는 「잃는 것」이 안 보인다. 로그인을 먼저 해 두라는 전제는 이 때문이다.
|
||||
|
||||
`save` 출력의 값이 비어 있는 것과 그 설정 자체가 없는 것은 다르다. `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 스냅샷 조건이 없다는 뜻이다. 증거의 `save = save` 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다.
|
||||
|
||||
### 3. `/data` 가 볼륨인가
|
||||
@@ -201,7 +212,8 @@ Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에
|
||||
|
||||
```bash label="[lab host] 세 경로의 상태 코드를 뽑는다"
|
||||
for p in / /bff/token-boundary /actuator/health; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||
curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \
|
||||
--resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
|
||||
done
|
||||
```
|
||||
|
||||
@@ -229,9 +241,12 @@ done
|
||||
**무엇을 보는가** — 세 응답의 본문. `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다).
|
||||
|
||||
```bash label="[lab host] ① health 그룹 셋의 본문을 받는다"
|
||||
curl -s https://app1.hyeonworks.com/actuator/health; echo
|
||||
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
|
||||
curl -s https://app1.hyeonworks.com/actuator/health/liveness; echo
|
||||
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/actuator/health; echo
|
||||
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/actuator/health/readiness; echo
|
||||
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/actuator/health/liveness; echo
|
||||
```
|
||||
|
||||
**어디를 보나** — 첫 번째 응답의 본문에 `redis` 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다.
|
||||
@@ -392,6 +407,15 @@ kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|nett
|
||||
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||
```
|
||||
|
||||
**★ 이 실험대의 로그는 다른 줄을 냈다**(2026-09-17, observed). 스택이 아니라 Lettuce 의 재연결 로그다. Service 는 남아 있고 뒤에 파드가 없으므로 `Connection refused` 이고, 위의 「맺는 중」과 달리 여기서는 즉시 거절당한 뒤 다시 시도한다. 그래도 요청 쪽은 똑같이 매달린다.
|
||||
|
||||
```text
|
||||
i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379
|
||||
i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [redis.keycloak-lab.svc/<unresolved>:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379
|
||||
```
|
||||
|
||||
그리고 `grep -iE 'redis|connect|netty'` 는 `connect` 때문에 Hikari 의 PostgreSQL 커넥션 경고까지 잡는다. `tail -10` 의 앞쪽 여섯 줄이 그것이었고 Redis 와 무관하다.
|
||||
|
||||
`pollConnect` 와 `finishConnect` 는 연결을 맺는 중이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 `000` 이 여기서 나온다.
|
||||
|
||||
```bash label="[lab host] ③ 엉뚱한 것을 죽이지 않았는지 본다"
|
||||
@@ -415,6 +439,14 @@ kubectl -n keycloak-lab get pod -l app=redis \
|
||||
|
||||
**빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 영속화가 잘 된다고 오독한다.
|
||||
|
||||
**★ 빈 줄은 안 나온다**(2026-09-17, observed). 쿠버네티스가 서비스 계정 토큰을 `kube-api-access-…` 라는 projected 볼륨으로 자동으로 붙이므로, `volumes` 배열을 통째로 지워도 새 파드에는 그 항목 하나가 다시 들어 있다. 길이가 아니라 이름을 본다 — `persistentVolumeClaim` 이 안 보이면 떨어졌다.
|
||||
|
||||
```text
|
||||
[{"name":"kube-api-access-d7bbm","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}]
|
||||
```
|
||||
|
||||
`volumeMounts` 쪽으로 보면 더 짧다. `/data` 가 없고 `/var/run/secrets/kubernetes.io/serviceaccount` 하나만 남는다. 그리고 이 명령이 고르는 `.items[0]` 은 방금 지운 파드일 수 있다 — 같은 라벨에 `Completed` 파드가 남아 있는 것을 이 실험대에서 봤다.
|
||||
|
||||
**빈 줄에는 뜻이 둘이다.** 이 명령은 `app=redis` 라벨이 붙은 파드 중 첫째를 골라 그 파드의 `spec.volumes` 를 찍는다. Redis 가 0대면 고를 파드가 없어 아무것도 안 나오고, 그 화면은 볼륨을 뗐을 때와 구별되지 않는다. 그래서 이 줄을 읽기 전에 위 ① 의 `get pods -l app=redis` 로 Redis 파드가 하나 `Running` 인지부터 본다. 파드가 있는데 빈 줄이면 볼륨이 떨어졌고, 파드가 없으면 이 명령은 아직 아무 말도 하지 않았다.
|
||||
|
||||
```text
|
||||
@@ -434,7 +466,8 @@ kubectl -n keycloak-lab get pod -l app=redis \
|
||||
|
||||
```bash label="[lab host] 세 경로를 다시 친다"
|
||||
for p in / /bff/token-boundary /actuator/health; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||
curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \
|
||||
--resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
|
||||
done
|
||||
```
|
||||
|
||||
@@ -453,6 +486,19 @@ done
|
||||
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
|
||||
| `503` | 헬스 엔드포인트는 대답은 한다 — 다만 DOWN 이라고 |
|
||||
|
||||
**★ 위 블록의 `--max-time 90` 은 원 가이드의 `--max-time 10` 을 고친 것이다**(2026-09-17, observed). 10초에서 끊으면 `/actuator/health` 도 `000` 이 되어 세 줄이 `200`·`000`·`000` 으로 나오고, 바로 위의 실측과 안 맞는다. 이 실험대에서 그 줄은 `60.046452` 초 뒤에 `503` 을 줬고 `/bff/token-boundary` 는 90초까지 기다려도 안 왔다. 그래서 90초까지 기다리게 하고 `%{time_total}` 로 걸린 시간을 같이 찍는다.
|
||||
|
||||
```text
|
||||
health 503 total 60.046452
|
||||
token-boundary 000 total 90.001609
|
||||
```
|
||||
|
||||
그러니까 헬스 엔드포인트도 빨리 실패하지 않는다. 60초는 Redis 명령 타임아웃이고, 그때 돌아온 본문의 `redis` 항목은 「붙지 못했다」가 아니라 「명령이 시간을 넘겼다」였다.
|
||||
|
||||
```text
|
||||
"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}}
|
||||
```
|
||||
|
||||
오류를 돌려주는 것이 아니라 매달려 있다.
|
||||
|
||||
```text
|
||||
@@ -473,10 +519,14 @@ done
|
||||
**그런데 파드는 `Ready` 를 유지한다.** 이 절차의 가장 중요한 발견이다.
|
||||
|
||||
```bash label="[lab host] health 그룹 셋을 코드와 본문으로 본다"
|
||||
curl -s -o /dev/null -w 'health %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health
|
||||
curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/readiness
|
||||
curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/liveness
|
||||
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
|
||||
curl -s -o /dev/null -w 'health %{http_code} total %{time_total}\n' --max-time 90 \
|
||||
--resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health
|
||||
curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 \
|
||||
--resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/readiness
|
||||
curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 \
|
||||
--resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/liveness
|
||||
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/actuator/health/readiness; echo
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-health-groups.txt`).
|
||||
@@ -565,7 +615,8 @@ deployment "redis" successfully rolled out
|
||||
|
||||
```bash label="[lab host] ② 회복했는지와 재시작 횟수를 본다"
|
||||
for p in /actuator/health /bff/token-boundary; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||
curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \
|
||||
--resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
|
||||
done
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
@@ -602,6 +653,17 @@ deployment "redis" successfully rolled out
|
||||
appendonly no
|
||||
```
|
||||
|
||||
**★ 설정이 되돌아가는 것은 이 실험대에서 안 보인다**(2026-09-17, observed). 데이터는 그대로 사라져 `dbsize` 가 `0` 이고 `b5:aof` 는 빈 값인데, `config get appendonly` 는 `no` 가 아니라 `yes` 다. 지금 매니페스트의 `args` 가 이미 `--appendonly yes` 라서, 「재기동하면 매니페스트가 이긴다」는 규칙이 이번에는 켜진 값을 되살린다. 규칙은 그대로 맞다. 매니페스트가 이미 `yes` 라서 되돌아가도 값이 안 바뀔 뿐이다.
|
||||
|
||||
```text
|
||||
0
|
||||
|
||||
appendonly
|
||||
yes
|
||||
```
|
||||
|
||||
볼륨을 되돌리고 같은 시험을 다시 했을 때는 `b5:pvc` 가 `written-on-pvc` 로 살아남았고 `dbsize` 는 `4` 였다. 나머지 셋은 그 사이에 BFF 가 만든 `bff:session:sessions:…` 키다.
|
||||
|
||||
두 가지가 같이 사라졌다(observed, 같은 파일).
|
||||
|
||||
| 사라진 것 | 왜 |
|
||||
@@ -738,7 +800,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
| BFF | `kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1`, `RESTARTS 0` |
|
||||
| 엔드포인트 | `… get endpointslice -l kubernetes.io/service-name=bff` | ready 주소 **둘** |
|
||||
| 실험 키 | `… redis-cli --scan` | `b5:*` 없음 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://app1.hyeonworks.com/` | `200` |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/` | `200` |
|
||||
|
||||
**★ 이 편의 `curl` 에 `--resolve` 가 붙어 있는 까닭이다**(2026-09-17, observed). `app1.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 이름만 치면 이 줄도 앞의 세 경로도 전부 `000` 이다. 그러면 주입 뒤와 견줄 값이 없다. 원 가이드가 적은 형태는 아래와 같고, 지우지 않고 남긴다.
|
||||
|
||||
```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 세 줄이 다 000 이다"
|
||||
for p in / /bff/token-boundary /actuator/health; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||
done
|
||||
```
|
||||
|
||||
나머지 일곱 줄은 이 실험대에서 그대로 통과했다 — Redis `1/1`, 볼륨에 `persistentVolumeClaim`, PVC `redis-data` `Bound`, `appendonly yes`, BFF 둘 다 `1/1` 이고 `RESTARTS 0`, 엔드포인트 주소 둘, `--scan` 에 `b5:*` 없음.
|
||||
|
||||
**로그인 세션은 돌아오지 않는다.** 브라우저에서 다시 로그인하는 것이 복구다.
|
||||
|
||||
@@ -771,4 +843,6 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
- (unknown) 볼륨을 떼는 `patch deployment redis --type=json` 줄. **원래 실행은 반대 순서로 했다** — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 `curl` 에 로그인 쿠키가 없어 `/bff/token-boundary` 가 `3xx` 로 나오는 것도 가이드가 미검증으로 표시했다.
|
||||
- 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. `readiness` 그룹에 `redis` 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다.
|
||||
|
||||
2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정은 전부 같았다 — Redis 를 0대로 내려도 `bff` 둘이 `1/1` 이고 엔드포인트 주소 둘이 그대로 `true,true` 였고, 볼륨 없이 파드를 지우면 `dbsize 0`, 볼륨을 되돌리면 `written-on-pvc` 가 살아남았고, 네 지표는 여전히 시계열 0개였다. 화면이 달랐던 다섯 자리는 위의 ★ 다섯이다. 원문은 `relive-2026-09-17/b5-04..07` 에 있다. 브라우저 로그인 전제는 이날 밟지 않아 세션 키가 0개인 채로 쟀다(unknown) — 「로그인한 사용자가 로그아웃된다」는 이 실행에서 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
+98
-19
@@ -53,6 +53,8 @@ realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공
|
||||
| 전 구간 | 약 15분. 주입 검증까지는 아무것도 안 깨진다 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `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` 가 필요 없다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.
|
||||
@@ -172,9 +174,23 @@ No server specified. Use --server, or 'kcadm.sh config credentials'.
|
||||
**무엇을 보는가** — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.
|
||||
|
||||
```bash label="[lab host] ① JWKS 를 자르지 않고 본다"
|
||||
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
```
|
||||
|
||||
**`--resolve` 가 붙은 까닭이다**(2026-09-17, observed). 원 가이드는 이름만 쳤는데 `auth.hyeonworks.com` 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 듣는 것이 없다. 엣지 nginx 가 게스트로 옮겨 간 뒤로 그렇다.
|
||||
|
||||
```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 000 이다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
```
|
||||
|
||||
```text label="원 가이드 형태를 랩 호스트에서 쳤을 때"
|
||||
000
|
||||
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server
|
||||
```
|
||||
|
||||
tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿는다. 랩 호스트에서 쳐야 해서 `--resolve` 를 붙였고, 이 편의 `[lab host]` 블록 열두 줄에 전부 같은 것이 걸린다.
|
||||
|
||||
줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, `01-before-rotation.txt`).
|
||||
|
||||
```text
|
||||
@@ -184,7 +200,8 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con
|
||||
그 뒤로 `kty` · `alg` · `use` · `n` · `e` 가 이어지고 다음 키가 온다. `kid` 마다 `alg` 가 따로 붙는다. 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
|
||||
|
||||
```bash label="[lab host] ② kid 만 뽑아 본다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
|
||||
@@ -210,21 +227,35 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con
|
||||
|
||||
JWKS 는 키 하나가 `}` 로 끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다.
|
||||
|
||||
```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다 (unknown)"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다"
|
||||
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr '}' '\n' | grep -c RS256
|
||||
```
|
||||
|
||||
Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.
|
||||
|
||||
```bash label="[lab host] ② Keycloak 에 직접 묻는다 (unknown)"
|
||||
```bash label="[lab host] ② Keycloak 에 직접 묻는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get keys -r keycloak-patterns
|
||||
```
|
||||
|
||||
**어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다.
|
||||
|
||||
**이 값이 뜻하는 것** — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.
|
||||
**2026-09-17 에 둘 다 쳐 봤고 둘 다 돈다**(observed). 원래 실행 기록에는 출력이 없어 미검증으로 두었던 것인데, 이제 값이 있다.
|
||||
|
||||
```text label="① 의 출력"
|
||||
1
|
||||
```
|
||||
|
||||
```text label="② 의 출력에서 kid·status·algorithm 만 뽑은 것"
|
||||
"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"
|
||||
```
|
||||
|
||||
① 이 내는 `1` 과 3단계의 `kid` 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 RS256 이 하나, 암호화용 `RSA-OAEP` 가 하나이며 JWKS 에는 그 둘만 실린다. `AES` 와 `HS512` 는 JWKS 에 안 나온다.
|
||||
|
||||
### 5. 시험체가 될 옛 토큰을 하나 받아 둔다
|
||||
|
||||
@@ -236,7 +267,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
|
||||
CS=$(kubectl -n keycloak-lab get secret bff-secrets \
|
||||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
|
||||
OLD=$(curl -s -X POST "$KC" \
|
||||
OLD=$(curl -s -X POST --resolve auth.hyeonworks.com:443:192.168.122.10 "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
|
||||
-d username=labuser -d password=labpass -d scope=openid \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
@@ -295,14 +326,16 @@ echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||
**무엇을 보는가** — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.
|
||||
|
||||
```bash label="[lab host] ① 상태줄과 본문을 함께 본다"
|
||||
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
curl -s -i -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.
|
||||
|
||||
```bash label="[lab host] ② 상태 코드만 뽑는다"
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
-H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
|
||||
@@ -359,7 +392,8 @@ Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
|
||||
### 9. JWKS 에 옛 키가 남아 있는가
|
||||
|
||||
```bash label="[lab host] 3 절과 똑같은 줄을 다시 친다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
|
||||
@@ -401,9 +435,11 @@ echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
|
||||
```bash label="[lab host] 두 토큰을 같은 두 줄로 친다"
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
-H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/api/me
|
||||
curl -s -o /dev/null -w 'new %{http_code}\n' \
|
||||
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
|
||||
-H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-rotation.txt`).
|
||||
@@ -480,7 +516,8 @@ date '+%H:%M:%S 제거'
|
||||
### 14. JWKS 에서 사라졌는지 본다
|
||||
|
||||
```bash label="[lab host] 또 같은 줄을 친다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
|
||||
@@ -501,9 +538,11 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con
|
||||
|
||||
```bash label="[lab host] 11 절과 똑같은 두 줄"
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
-H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/api/me
|
||||
curl -s -o /dev/null -w 'new %{http_code}\n' \
|
||||
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
|
||||
-H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
|
||||
https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-old-key-removed.txt`).
|
||||
@@ -516,6 +555,27 @@ curl -s -o /dev/null -w 'new %{http_code}\n' \
|
||||
|
||||
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
|
||||
|
||||
**★ 2026-09-17 에 다시 재 보니 그 401 은 절반만 맞았다**(observed). 같은 토큰으로 여덟 번 연속 쳤더니 이렇게 나왔다.
|
||||
|
||||
```text label="제거 직후, 같은 토큰으로 여덟 번"
|
||||
401 200 401 200 401 200 401 200
|
||||
```
|
||||
|
||||
**`echo` 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로이기 때문이다.** 한쪽은 목록을 새로 받아 옛 키를 잃었고(401) 다른 쪽은 아직 들고 있다(200). Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐에 따라 결과가 갈린다. 파드가 둘 다 `1/1 Running` 인 것은 같은 순간에 확인했다.
|
||||
|
||||
한 번만 쳐서는 이것이 안 보인다. 처음 두 번을 쳤을 때 이렇게 갈렸다.
|
||||
|
||||
```text label="같은 상태를 한 번씩 쳤을 때"
|
||||
제거 6초 뒤 처음 친 것 old 200 ← 캐시가 살아 있는 replica
|
||||
제거 6초 뒤 다시 친 것 old 401 ← 목록을 새로 받은 replica
|
||||
```
|
||||
|
||||
**그래서 이 절의 판정은 「즉시 401」이 아니라 「인스턴스마다 다르다」다.** 운영에서 더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「유예가 없다」로 닫은 것은 한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred).
|
||||
|
||||
**replica 를 1 로 줄이면 이 흔들림이 사라진다.** 무엇을 재려는지에 따라 고른다 — 「제거가 반영되는가」를 보려면 1 로, 「운영에서 무엇이 보이는가」를 보려면 2 로 둔다.
|
||||
|
||||
**아래 16 절이 이것을 가르는 단계인데, 재시작이 34초 걸려 60초짜리 토큰으로는 전후를 같은 토큰으로 못 견준다**(observed). 재시작 뒤의 `old 401` 은 캐시가 비워져서인지 토큰이 만료돼서인지 갈리지 않는다. 가르려면 `accessTokenLifespan` 을 늘리거나, 위처럼 **재시작 없이 연속으로 쳐서** 두 replica 의 답이 갈리는 것을 보는 편이 빠르다.
|
||||
|
||||
**`$OLD` 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다.** 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).
|
||||
|
||||
### 16. 리소스 서버를 재시작해 캐시를 비운다
|
||||
@@ -566,12 +626,12 @@ deployment "echo" successfully rolled out
|
||||
|
||||
수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[lab host] realm 의 수명 세 값을 받는다 (unknown)"
|
||||
```bash label="[lab host] realm 의 수명 세 값을 받는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
|
||||
```
|
||||
|
||||
겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
|
||||
**2026-09-17 에 쳤더니 두 값만 돌아왔다**(observed) — `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이고 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다. 겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
|
||||
|
||||
```text
|
||||
쓰기: 새 key 하나로만
|
||||
@@ -623,9 +683,28 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
|
||||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||||
|
||||
- **남은 것** — 키 공급자 추가·삭제와 `echo` 가 내는 `401`/`200` 판정 전부. `echo` 가 JWKS 를 `https://auth.hyeonworks.com` 에서 받으므로 인증서가 서야 한다. 지금까지 밟은 것 — realm·사용자·`echo` 배포, JWKS 와 `kid` 읽기, 공급자 목록.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||||
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
- **밟았다** — 키 공급자 추가와 삭제, 그리고 그 둘이 JWKS 와 토큰 `kid` 를 어떻게 바꾸는지 전부. 8·9·10·12·13·14 절이 적어 둔 대로 나왔다(observed). 공급자를 추가하니 RS256 이 1 에서 2 로 늘고 새 토큰의 `kid` 가 `priority` 200 짜리 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 `kid` 가 목록에서 사라졌다.
|
||||
- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 `echo` 가 JWKS 를 받게 되어 7 절의 대조군 `old 200` 이 나왔고, 8~15 절을 토큰 수명 안(6초)에 끝냈다. 결과는 **11 절까지 문서 그대로**(추가는 무중단, `old 200` · `new 200`)이고 **15 절에서 갈렸다** — 위 ★ 를 본다.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)가 있어야 한다. 밖에서 닿는 길은 열렸다 — 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 2026-09-17 에 들어갔고 `http` 는 밖에서 `200` 이다(observed).
|
||||
|
||||
**11·15 절이 만료를 가르라고 준 두 줄은 이 실험대에서 틀린 답을 낸다**(2026-09-17, observed). `exp` 는 Keycloak 이 게스트 시계로 찍고 `date +%s` 는 랩 호스트 시계로 찍는데, **그 둘이 93초 어긋나 있다.**
|
||||
|
||||
```bash label="[lab host] 기계마다 같은 순간에 친다"
|
||||
date +%s
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
|
||||
```text label="같은 순간의 값"
|
||||
lab host 1789629566 NTP=no NTPSynchronized=no
|
||||
kc-lab-1 1789629472 NTP=yes NTPSynchronized=yes
|
||||
kc-lab-2 1789629473 NTP=yes NTPSynchronized=yes
|
||||
kc-lab-edge 1789629473
|
||||
```
|
||||
|
||||
게스트 셋은 서로 맞고 랩 호스트만 93초 앞선다. 그래서 방금 받은 60초짜리 토큰도 랩 호스트에서 `exp` 를 재면 **이미 33초 전에 만료된 것으로 읽힌다.** D-4 와 D-4a 가 같은 호스트에서 `NTPSynchronized=no` 와 `+106.1` 을 이미 재 두었는데, 이 편은 그것을 모르는 채로 `exp` 비교를 시킨다. 가르려면 두 값을 같은 기계에서 뽑는다 — `kubectl -n keycloak-lab exec keycloak-0 -- date +%s` 로 Keycloak 쪽 시각을 받아 견준다.
|
||||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
|
||||
- **realm 의 수명 세 값은 이제 실측이 있다**(observed). 위에서 미검증으로 표시한 줄을 쳐 보니 `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이다. 세 번째 값 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다.
|
||||
- **그때까지 이 편의 `(observed)` 2026-09-17 값은 위 「밟았다」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
|
||||
Reference in New Issue
Block a user