Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9465582b5d | ||
|
|
6f6ab86345 | ||
|
|
8062cc9a19 | ||
|
|
88b7bd4bf0 | ||
|
|
cdac9b8178 | ||
|
|
a2f749292a | ||
|
|
e9baac21d5 | ||
|
|
3d7778bd3c | ||
|
|
919547a025 | ||
|
|
b9f4ef7bc2 |
@@ -0,0 +1 @@
|
||||
[ 14441ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 296ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 158ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 67ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 40ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/oauth2/userinfo:0
|
||||
[ 162ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=e3]:
|
||||
- banner [ref=e4]:
|
||||
- generic [ref=e5]: keycloak-patterns
|
||||
- main [ref=e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=e8]
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- generic [ref=e14]: Username or email
|
||||
- textbox "Username or email" [active] [ref=e17]
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]: Password
|
||||
- generic [ref=e21]:
|
||||
- textbox "Password" [ref=e24]
|
||||
- button "Show password" [ref=e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=e27]:
|
||||
- button "Sign In" [ref=e30] [cursor=pointer]
|
||||
@@ -0,0 +1,6 @@
|
||||
- generic [ref=f1e3]:
|
||||
- generic [ref=f1e6]:
|
||||
- heading "This page isn’t working" [level=1] [ref=f1e7]
|
||||
- paragraph [ref=f1e8]: If the problem continues, contact the site owner.
|
||||
- generic [ref=f1e9]: HTTP ERROR 401
|
||||
- button "Reload" [ref=f1e12] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f3e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f4e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f5e1]: Unauthorized
|
||||
@@ -18,15 +18,23 @@
|
||||
브라우저 / SSH (tailnet)
|
||||
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
|
||||
▼
|
||||
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
|
||||
│ nginx :80 301 → https
|
||||
lab host ── nftables DNAT :80,:443 → 192.168.122.10
|
||||
│ (물리 호스트가 실험대를 위해 하는 일의 전부)
|
||||
│
|
||||
│ virbr0 192.168.122.0/24 (libvirt NAT)
|
||||
├──▶ kc-lab-1 .11 k3s server Traefik :80
|
||||
└──▶ kc-lab-2 .12 k3s agent Traefik :80
|
||||
└──▶ Pod
|
||||
├──▶ kc-lab-edge .10 nginx :443 TLS 종료 · X-Forwarded-* 주입
|
||||
│ │ nginx :80 301 → https
|
||||
│ │ certbot · 갱신 타이머 · deploy 훅
|
||||
│ ├──▶ kc-lab-1 .11 Traefik :80 ──▶ Pod
|
||||
│ └──▶ kc-lab-2 .12 Traefik :80 ──▶ Pod
|
||||
├──▶ kc-lab-1 .11 k3s server
|
||||
└──▶ kc-lab-2 .12 k3s agent
|
||||
```
|
||||
|
||||
**L7 홉은 두 겹 그대로다**(엣지 nginx → Traefik). 앞에 늘어난 것은 커널이
|
||||
하는 L4 전달 한 번뿐이고, 그 대가로 **인증서·nginx 설정·certbot 이 전부
|
||||
일회용 게스트 안**으로 들어갔다.
|
||||
|
||||
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
|
||||
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
|
||||
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
|
||||
@@ -36,7 +44,11 @@ L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx
|
||||
| 경로 | 역할 |
|
||||
|---|---|
|
||||
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
|
||||
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
|
||||
| `edge/nginx-keycloak-lab.conf` | `kc-lab-edge` 의 `sites-available/keycloak-lab` |
|
||||
| `edge/reload-nginx.sh` | certbot deploy 훅. 없으면 갱신이 서빙에 반영되지 않는다 (D-4) |
|
||||
| `edge/lab-edge-dnat.nft` | 물리 호스트의 유일한 트래픽 규칙 |
|
||||
| `edge/lab-edge-dnat.service` | 위 규칙을 부팅 때 적용 |
|
||||
| `scripts/migrate-to-edge.sh` | 엣지 계층을 호스트에서 게스트로 옮긴다 |
|
||||
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
|
||||
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
|
||||
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
|
||||
|
||||
@@ -17,7 +17,12 @@ users:
|
||||
shell: /bin/bash
|
||||
# NOPASSWD is required: the k3s installer and the fault-injection scripts
|
||||
# run non-interactively and would block on a password prompt.
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||
#
|
||||
# A string, not a list. The list form still boots, but `cloud-init schema -c`
|
||||
# (22.4.2 on the guests) rejects it and prints the whole users.0 block with
|
||||
# "is not valid under any of the given schemas" — naming no key. That makes
|
||||
# the guide's own validation step look broken when it is not.
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
|
||||
# that cannot be logged into at all, so its own failure log is unreadable.
|
||||
# ssh_pwauth stays false, so this never widens SSH exposure.
|
||||
@@ -35,3 +40,9 @@ package_update: true
|
||||
packages:
|
||||
- curl
|
||||
- nftables
|
||||
# kc-lab-edge only. The k3s nodes do not need these, and the edge does not need
|
||||
# anything else — nginx terminates TLS and certbot renews the certificate, both
|
||||
# inside this disposable guest.
|
||||
# - nginx
|
||||
# - certbot
|
||||
# - python3-certbot-dns-cloudflare
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/sbin/nft -f
|
||||
# Forward the tailnet entry point to the edge guest.
|
||||
#
|
||||
# This is the ONLY lab traffic rule the physical host carries. Everything else
|
||||
# that used to live here — nginx config, certificates, certbot, the deploy hook
|
||||
# — now lives on kc-lab-edge and is destroyed with it.
|
||||
#
|
||||
# DNAT only, never SNAT. The guests' default route is the host, so replies come
|
||||
# back through here and conntrack reverses the translation on its own. Adding a
|
||||
# masquerade would rewrite the source and the edge would see 192.168.122.1 for
|
||||
# every client — which would silently invalidate the X-Forwarded-For contract
|
||||
# that this lab measures.
|
||||
#
|
||||
# PREROUTING nat runs before the routing decision, so this wins over any local
|
||||
# socket on :80/:443. That makes the cutover atomic and the rollback a single
|
||||
# `nft delete table ip lab_edge`.
|
||||
|
||||
table ip lab_edge
|
||||
delete table ip lab_edge
|
||||
|
||||
table ip lab_edge {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||||
}
|
||||
|
||||
# libvirt's own forward rules accept RELATED,ESTABLISHED into the guest
|
||||
# subnet but not a NEW inbound connection. This runs ahead of them.
|
||||
chain forward {
|
||||
type filter hook forward priority filter - 10; policy accept;
|
||||
ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new accept
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
|
||||
After=network-online.target libvirtd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
|
||||
ExecStop=/usr/sbin/nft delete table ip lab_edge
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -25,8 +25,10 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
http2 on;
|
||||
# The http2 parameter of listen, not the separate `http2 on;` directive:
|
||||
# that directive needs nginx >= 1.25.1 and the edge guest is Debian 12
|
||||
# (nginx 1.22). This form works on both and is what the lab actually runs.
|
||||
listen 443 ssl http2 default_server;
|
||||
server_name _;
|
||||
|
||||
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# certbot deploy hook. Install as
|
||||
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
|
||||
#
|
||||
# deploy/ runs only when a certificate was actually renewed (RENEWED_LINEAGE is
|
||||
# set). post/ would run twice a day whether or not anything changed, reloading
|
||||
# nginx for nothing.
|
||||
#
|
||||
# Without this, D-4 measured the failure exactly: the renewal succeeds, the
|
||||
# timer reports SUCCESS, and the old certificate keeps being served for 38m25s
|
||||
# — with no error anywhere.
|
||||
nginx -t && nginx -s reload
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remove the lab's host layer from test-server. Packages stay.
|
||||
#
|
||||
# sudo bash deploy/lab/host/teardown-host.sh
|
||||
#
|
||||
# The host's sudo asks for a password, so run this in a terminal — not over a
|
||||
# non-interactive ssh, where sudo fails silently into an empty result.
|
||||
#
|
||||
# ★ Certificates are BACKED UP, NOT DELETED. Let's Encrypt allows 5 duplicate
|
||||
# certificates per week for the same name set, and this lab's names resolve to
|
||||
# a tailnet address (100.64.0.0/10, not routable from the public internet), so
|
||||
# an HTTP-01 reissue cannot be validated from here. Deleting the files turns a
|
||||
# free restore into a problem that has to be solved first. Restoring is:
|
||||
#
|
||||
# sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
|
||||
set -u
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
HOME_DIR="${SUDO_USER:+/home/$SUDO_USER}"
|
||||
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||
|
||||
echo "===== 1) 인증서 백업 (지우지 않는다) ====="
|
||||
if [ -d /etc/letsencrypt ]; then
|
||||
out="$HOME_DIR/letsencrypt-backup-$STAMP.tgz"
|
||||
tar czf "$out" -C /etc letsencrypt
|
||||
chown "${SUDO_USER:-root}" "$out"
|
||||
echo "백업: $out ($(du -h "$out" | cut -f1))"
|
||||
echo "현재 인증서:"
|
||||
certbot certificates 2>/dev/null | grep -E "Certificate Name|Domains|Expiry Date" || true
|
||||
echo "검증 방식 (재발급이 되는지의 답):"
|
||||
grep -H authenticator /etc/letsencrypt/renewal/*.conf 2>/dev/null || echo " (renewal 설정 없음)"
|
||||
else
|
||||
echo "/etc/letsencrypt 없음 — 건너뜀"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "===== 2) nginx 실험대 설정 제거 ====="
|
||||
if [ -f /etc/nginx/sites-available/keycloak-lab ]; then
|
||||
cp /etc/nginx/sites-available/keycloak-lab "$HOME_DIR/keycloak-lab.nginx.$STAMP.bak"
|
||||
echo "백업: $HOME_DIR/keycloak-lab.nginx.$STAMP.bak"
|
||||
fi
|
||||
rm -fv /etc/nginx/sites-enabled/keycloak-lab
|
||||
rm -fv /etc/nginx/sites-available/keycloak-lab
|
||||
systemctl disable --now nginx
|
||||
echo
|
||||
|
||||
echo "===== 3) certbot 갱신 타이머 정지 ====="
|
||||
# 인증서 파일은 남기지만, 갱신 시도는 멈춘다. 지금 DNS 로는 HTTP-01 검증이
|
||||
# 실패하고, 실패가 로그에만 쌓이면서 「왜 안 되지」의 원인이 된다.
|
||||
systemctl disable --now certbot-renew.timer 2>/dev/null || true
|
||||
echo
|
||||
|
||||
echo "===== 4) 엣지 DNAT (있으면) ====="
|
||||
systemctl disable --now lab-edge-dnat.service 2>/dev/null || true
|
||||
rm -fv /etc/systemd/system/lab-edge-dnat.service /etc/nftables.d/lab-edge-dnat.nft
|
||||
systemctl daemon-reload
|
||||
nft delete table ip lab_edge 2>/dev/null || true
|
||||
echo
|
||||
|
||||
echo "===== 5) 확인 ====="
|
||||
echo "-- nginx: $(systemctl is-active nginx) / $(systemctl is-enabled nginx 2>&1)"
|
||||
echo "-- certbot timer: $(systemctl is-active certbot-renew.timer 2>&1) / $(systemctl is-enabled certbot-renew.timer 2>&1)"
|
||||
echo "-- 80/443 리스너:"; ss -tlnp | grep -E ':(80|443) ' || echo " (없음 — 정상)"
|
||||
echo "-- sites-enabled:"; ls -A /etc/nginx/sites-enabled 2>/dev/null || echo " (비었음 — 정상)"
|
||||
echo "-- letsencrypt:"; ls /etc/letsencrypt/live 2>/dev/null || echo " (없음)"
|
||||
echo "-- libvirt 도메인:"; virsh list --all 2>/dev/null | tail -n +3 | grep -v '^$' || echo " (없음 — 정상)"
|
||||
echo
|
||||
echo "완료. 패키지(nginx · libvirt · qemu · certbot · kubectl)와 base.qcow2 는 남아 있다."
|
||||
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-0 — 세션을 공유하는 것은 Infinispan 이 아니라 PostgreSQL 이다</text>
|
||||
<text x="20" y="44" class="s">클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다</text>
|
||||
<rect x="30" y="76" width="330" height="120" class="box"/>
|
||||
<text x="46" y="98" class="tb">keycloak-0 (kc-lab-2)</text>
|
||||
<rect x="46" y="112" width="298" height="34" class="box"/>
|
||||
<text x="58" y="134" class="t">Infinispan sessions 캐시</text>
|
||||
<text x="46" y="166" class="s">자기가 처리한 로그인만 들어 있다</text>
|
||||
<text x="46" y="184" class="s">entries_unique 는 노드마다 다르다</text>
|
||||
<rect x="500" y="76" width="330" height="120" class="box"/>
|
||||
<text x="516" y="98" class="tb">keycloak-1 (kc-lab-1)</text>
|
||||
<rect x="516" y="112" width="298" height="34" class="box"/>
|
||||
<text x="528" y="134" class="t">Infinispan sessions 캐시</text>
|
||||
<text x="516" y="166" class="s">여기에도 자기 것만 있다</text>
|
||||
<text x="516" y="184" class="s">상대 세션은 들어오지 않는다</text>
|
||||
<path d="M362 130 L498 130" class="r" stroke-dasharray="5 3" marker-end="url(#ar)"/>
|
||||
<path d="M498 146 L362 146" class="r" stroke-dasharray="5 3" marker-end="url(#ar)"/>
|
||||
<text x="430" y="122" class="tb" text-anchor="middle" fill="#cf222e">복제 없음</text>
|
||||
<text x="430" y="170" class="s" text-anchor="middle" fill="#cf222e">세션 엔트리는 노드 사이를 건너가지 않는다</text>
|
||||
<rect x="300" y="250" width="260" height="76" class="ok"/>
|
||||
<text x="316" y="272" class="tb">PostgreSQL</text>
|
||||
<text x="316" y="292" class="s">OFFLINE_USER_SESSION (offline_flag='0')</text>
|
||||
<text x="316" y="310" class="s">두 노드가 같은 행을 본다</text>
|
||||
<path d="M150 198 L330 246" class="g" marker-end="url(#ag)"/>
|
||||
<path d="M710 198 L530 246" class="g" marker-end="url(#ag)"/>
|
||||
<text x="180" y="232" class="s">SELECT / INSERT</text>
|
||||
<text x="600" y="232" class="s">SELECT / INSERT</text>
|
||||
<rect x="30" y="346" width="800" height="40" class="box"/>
|
||||
<text x="46" y="370" class="s">근거 — 노드 A 로 로그인하고 노드 B 로 refresh 했을 때, 반대편 노드가 날린 SQL 을 문장 로깅으로 직접 잡았다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 430" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-1 — 7800 을 막아도 세션 공유는 안 깨진다. 깨지는 것은 로그아웃 전파다</text>
|
||||
<text x="20" y="44" class="s">예측 하나가 빗나갔고, 예상 못 한 것이 둘 나왔다</text>
|
||||
<rect x="30" y="72" width="300" height="104" class="box"/>
|
||||
<text x="46" y="94" class="tb">keycloak-0</text>
|
||||
<text x="46" y="116" class="s">로그인 처리 · 세션을 DB 에 쓴다</text>
|
||||
<rect x="530" y="72" width="300" height="104" class="box"/>
|
||||
<text x="546" y="94" class="tb">keycloak-1</text>
|
||||
<text x="546" y="116" class="s">DB 를 읽어 같은 세션을 안다</text>
|
||||
<rect x="352" y="86" width="156" height="34" class="dead"/>
|
||||
<text x="430" y="107" class="t" text-anchor="middle" fill="#cf222e">TCP 7800 차단</text>
|
||||
<text x="430" y="140" class="s" text-anchor="middle">NetworkPolicy 는 허용목록이다</text>
|
||||
<text x="430" y="156" class="s" text-anchor="middle">8080·9000 만 열고 7800 은 누락시킨다</text>
|
||||
<rect x="300" y="206" width="260" height="64" class="ok"/>
|
||||
<text x="316" y="228" class="tb">PostgreSQL</text>
|
||||
<text x="316" y="248" class="s">세션은 여기 있다 → 교차 노드 refresh 200</text>
|
||||
<path d="M150 178 L320 202" class="g" marker-end="url(#ag)"/>
|
||||
<path d="M710 178 L540 202" class="g" marker-end="url(#ag)"/>
|
||||
<rect x="30" y="290" width="390" height="76" class="dead"/>
|
||||
<text x="46" y="312" class="tb">깨진 것 — 로그아웃 전파</text>
|
||||
<text x="46" y="332" class="s">무효화 통지가 7800 을 탄다</text>
|
||||
<text x="46" y="350" class="s">400 이어야 할 refresh 가 200 을 반환했다</text>
|
||||
<rect x="440" y="290" width="390" height="76" class="warn"/>
|
||||
<text x="456" y="312" class="tb">★ 주입이 먹지 않았다 — conntrack</text>
|
||||
<text x="456" y="332" class="s">ESTABLISHED 연결은 규칙 평가를 건너뛴다</text>
|
||||
<text x="456" y="350" class="s">cluster_size 가 25분간 2 로 남았다 → 삭제 후에야 갈렸다</text>
|
||||
<text x="30" y="392" class="s">그리고 예상 못 한 둘째 — 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠진다</text>
|
||||
<text x="30" y="410" class="s">덕분에 외부에서는 장애가 보이지 않는다. 관측 지점을 밖에만 두면 이 실험은 「아무 일도 없음」으로 보인다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,55 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-3 — 200 을 받은 로그인 153건 중 4건이 DB 에 없다</text>
|
||||
<text x="20" y="44" class="s">버그가 아니라 synchronous_commit OFF 의 대가를 실측한 것이다</text>
|
||||
<rect x="30" y="74" width="180" height="70" class="box"/>
|
||||
<text x="46" y="96" class="tb">클라이언트</text>
|
||||
<text x="46" y="116" class="s">로그인 요청</text>
|
||||
<text x="46" y="134" class="s">200 + 토큰 수신</text>
|
||||
<rect x="250" y="74" width="200" height="70" class="box"/>
|
||||
<text x="266" y="96" class="tb">Keycloak</text>
|
||||
<text x="266" y="116" class="s">트랜잭션마다</text>
|
||||
<text x="266" y="134" class="s">SET LOCAL synchronous_commit OFF</text>
|
||||
<rect x="490" y="74" width="180" height="70" class="warn"/>
|
||||
<text x="506" y="96" class="tb">PostgreSQL</text>
|
||||
<text x="506" y="116" class="s">COMMIT 을 즉시 반환</text>
|
||||
<text x="506" y="134" class="s">WAL 은 아직 메모리에</text>
|
||||
<rect x="710" y="74" width="120" height="70" class="box"/>
|
||||
<text x="726" y="96" class="tb">디스크</text>
|
||||
<text x="726" y="116" class="s">wal_writer_delay</text>
|
||||
<text x="726" y="134" class="s">200ms 뒤 기록</text>
|
||||
<path d="M212 108 L246 108" class="k" marker-end="url(#a)"/>
|
||||
<path d="M452 108 L486 108" class="k" marker-end="url(#a)"/>
|
||||
<path d="M672 108 L706 108" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||
<text x="676" y="164" class="s" fill="#cf222e">이 구간이 비어 있다</text>
|
||||
<rect x="620" y="180" width="210" height="40" class="dead"/>
|
||||
<text x="636" y="205" class="t" fill="#cf222e">✂ 여기서 강제 종료</text>
|
||||
<rect x="30" y="250" width="380" height="96" class="dead"/>
|
||||
<text x="46" y="272" class="tb">결과 — RPO 는 0 이 아니다</text>
|
||||
<text x="46" y="296" class="t">클라이언트가 200 받은 로그인 153 건</text>
|
||||
<text x="46" y="316" class="t">그중 DB 에 실제로 존재 149 건</text>
|
||||
<text x="46" y="336" class="t" fill="#cf222e">★ 유실 4 건</text>
|
||||
<rect x="430" y="250" width="400" height="96" class="box"/>
|
||||
<text x="446" y="272" class="tb">주입도 두 번 실패했다</text>
|
||||
<text x="446" y="294" class="s">kubectl delete --force 는 크래시가 아니다 — 런타임이 SIGTERM 을 보내</text>
|
||||
<text x="446" y="310" class="s">PostgreSQL 이 정상 플러시했고 유실이 0 이었다</text>
|
||||
<text x="446" y="332" class="s">kill -9 1 도 무시된다 — PID 1 은 자기 네임스페이스의 SIGKILL 을 받지 않는다</text>
|
||||
<text x="30" y="372" class="s">백엔드 프로세스를 죽여 postmaster 가 reinitialize 하게 만들자 비로소 "not properly shut down / redo starts" 가 찍혔다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-4 — 둘 다 전면 장애지만 이유가 다르다</text>
|
||||
<text x="20" y="44" class="s">그리고 죽은 파드가 산 파드보다 건강해 보인다</text>
|
||||
<rect x="30" y="70" width="390" height="150" class="box"/>
|
||||
<text x="46" y="92" class="tb">4a — 워커(kc-lab-2) 상실</text>
|
||||
<rect x="46" y="104" width="170" height="46" class="dead"/>
|
||||
<text x="58" y="124" class="t">keycloak-0</text>
|
||||
<text x="58" y="142" class="s">PostgreSQL 도 여기 있었다</text>
|
||||
<rect x="236" y="104" width="170" height="46" class="warn"/>
|
||||
<text x="248" y="124" class="t">keycloak-1 살아있음</text>
|
||||
<text x="248" y="142" class="s">그러나 DB 가 없다</text>
|
||||
<text x="46" y="172" class="t">외부 응답 <tspan fill="#cf222e" font-weight="600">503</tspan> · kubectl 정상</text>
|
||||
<text x="46" y="194" class="s">DB 가 같이 죽어서 장애다. 남은 파드는 돌지만 아무것도 못 한다</text>
|
||||
<text x="46" y="212" class="s">PVC 가 local-path 라 다른 노드로 재배치되지 않는다</text>
|
||||
<rect x="440" y="70" width="390" height="150" class="box"/>
|
||||
<text x="456" y="92" class="tb">4b — 컨트롤 플레인(kc-lab-1) 상실</text>
|
||||
<rect x="456" y="104" width="170" height="46" class="ok"/>
|
||||
<text x="468" y="124" class="t">keycloak-0</text>
|
||||
<text x="468" y="142" class="s">★ 계속 돌고 있다</text>
|
||||
<rect x="646" y="104" width="170" height="46" class="dead"/>
|
||||
<text x="658" y="124" class="t">API 서버 · traefik</text>
|
||||
<text x="658" y="142" class="s">들어갈 길이 없다</text>
|
||||
<text x="456" y="172" class="t">외부 응답 <tspan fill="#cf222e" font-weight="600">000</tspan> · kubectl <tspan fill="#cf222e">불통</tspan></text>
|
||||
<text x="456" y="194" class="s">워크로드는 멀쩡한데 도달할 수 없어 장애다</text>
|
||||
<text x="456" y="212" class="s">진입점이 단일 노드에 있으면 워크로드 이중화는 의미가 없다</text>
|
||||
<rect x="30" y="242" width="800" height="86" class="warn"/>
|
||||
<text x="46" y="264" class="tb">★ 예상하지 못한 것 셋</text>
|
||||
<text x="46" y="286" class="s">1. 죽은 파드가 산 파드보다 건강해 보인다 — kubelet 이 사라져 상태가 갱신되지 않으니 Running 으로 남는다</text>
|
||||
<text x="46" y="304" class="s">2. StatefulSet 은 Terminating 파드의 대체를 만들지 않는다 — 이름이 같아야 하므로 지워지기를 기다린다</text>
|
||||
<text x="46" y="322" class="s">3. node-monitor-grace-period 40초 + tolerationSeconds 300초 = 축출까지 5분 40초</text>
|
||||
<rect x="30" y="348" width="800" height="46" class="ok"/>
|
||||
<text x="46" y="370" class="tb">복구는 둘 다 virsh start 이후 60초</text>
|
||||
<text x="46" y="388" class="s">장애 시간의 대부분은 복구가 아니라 "누가 죽은 것을 알아채는 데" 걸린 시간이다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-5 — 비대칭 차단은 클러스터를 가르지 못한다. 그리고 갈라도 서비스는 계속된다</text>
|
||||
<text x="20" y="44" class="s">주입을 세 번 실패했고, 세 번 모두 「아무 일도 없었다」로 보였다</text>
|
||||
<rect x="30" y="72" width="390" height="112" class="box"/>
|
||||
<text x="46" y="94" class="tb">① 한 방향만 차단</text>
|
||||
<rect x="46" y="106" width="130" height="34" class="box"/>
|
||||
<text x="58" y="128" class="t">keycloak-0</text>
|
||||
<rect x="290" y="106" width="116" height="34" class="box"/>
|
||||
<text x="302" y="128" class="t">keycloak-1</text>
|
||||
<path d="M178 116 L286 116" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||
<path d="M286 132 L178 132" class="g" marker-end="url(#ag)"/>
|
||||
<text x="46" y="164" class="s" fill="#1a7f37">열린 방향으로 재연결한다 → cluster_size 2 유지. 가르지 못한다</text>
|
||||
<rect x="440" y="72" width="390" height="112" class="box"/>
|
||||
<text x="456" y="94" class="tb">② 양방향 완전 차단</text>
|
||||
<rect x="456" y="106" width="130" height="34" class="dead"/>
|
||||
<text x="468" y="128" class="t">keycloak-0</text>
|
||||
<rect x="700" y="106" width="116" height="34" class="ok"/>
|
||||
<text x="712" y="128" class="t">keycloak-1</text>
|
||||
<path d="M588 116 L696 116" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||
<path d="M696 132 L588 132" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||
<text x="456" y="164" class="s">양쪽 모두 멤버 1개. 그런데 <tspan fill="#cf222e" font-weight="600">한쪽만 DOWN</tspan> 이 된다 — 코디네이터 쪽이 살아남는다</text>
|
||||
<rect x="30" y="204" width="800" height="48" class="ok"/>
|
||||
<text x="46" y="226" class="tb">그래서 전면 장애 경로가 없다 — 외부 응답 200 유지</text>
|
||||
<text x="46" y="244" class="s">분단된 쪽이 스스로 readiness 를 떨어뜨려 로드밸런서에서 빠지고, 남은 쪽이 계속 응답한다</text>
|
||||
<rect x="30" y="272" width="800" height="130" class="warn"/>
|
||||
<text x="46" y="294" class="tb">★ 세 번의 주입 실패 — 전부 「조용히」 실패했다</text>
|
||||
<text x="46" y="318" class="s">1. iptables -I FORWARD 1 이 무시됐다 — kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0)</text>
|
||||
<text x="46" y="338" class="s">2. 방향이 뒤집혀 있었다 — JGroups 의 client/server 역할은 재시작마다 바뀐다. raw 규칙이 엉뚱한 노드에 걸렸다</text>
|
||||
<text x="46" y="358" class="s">3. dev eth0 이 없다 — Debian 은 enp1s0 이고, 게다가 flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다</text>
|
||||
<text x="46" y="382" class="s" fill="#cf222e">해결: raw 테이블 PREROUTING(conntrack 보다 먼저) + flannel.1 인터페이스에서 필터</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-6 — 200밀리초가 22초가 된다</text>
|
||||
<text x="20" y="44" class="s">지연은 왕복 횟수만큼 곱해지고, 커넥션 풀에서 한 번 더 곱해진다</text>
|
||||
<rect x="30" y="76" width="180" height="60" class="ok"/>
|
||||
<text x="46" y="98" class="tb">대조군 keycloak-0</text>
|
||||
<text x="46" y="120" class="s">DB 와 같은 노드 · 66ms</text>
|
||||
<rect x="250" y="76" width="180" height="60" class="dead"/>
|
||||
<text x="266" y="98" class="tb">시험군 keycloak-1</text>
|
||||
<text x="266" y="120" class="s">tc netem delay 200ms</text>
|
||||
<text x="230" y="112" class="tb" fill="#cf222e">✂</text>
|
||||
<rect x="470" y="76" width="180" height="60" class="warn"/>
|
||||
<text x="486" y="98" class="tb">단일 요청</text>
|
||||
<text x="486" y="120" class="t">1,872 ms <tspan class="s">(28배)</tspan></text>
|
||||
<rect x="690" y="76" width="140" height="60" class="dead"/>
|
||||
<text x="706" y="98" class="tb">동시 20건</text>
|
||||
<text x="706" y="120" class="t" fill="#cf222e">22.2 초</text>
|
||||
<path d="M432 106 L466 106" class="k" marker-end="url(#a)"/>
|
||||
<path d="M652 106 L686 106" class="r" marker-end="url(#ar)"/>
|
||||
<text x="20" y="176" class="tb">왜 200ms 가 1,872ms 가 되는가 — 왕복 횟수</text>
|
||||
<rect x="30" y="190" width="800" height="46" class="box"/>
|
||||
<text x="46" y="210" class="s">로그인 한 번은 DB 왕복을 여러 번 한다: 클라이언트 조회 · 사용자 조회 · 세션 INSERT · 커밋 …</text>
|
||||
<text x="46" y="228" class="s">왕복마다 200ms 가 더해진다. 애플리케이션은 아무것도 잘못하지 않았다</text>
|
||||
<text x="20" y="266" class="tb">왜 1,872ms 가 22.2초가 되는가 — 커넥션 풀</text>
|
||||
<rect x="30" y="280" width="390" height="76" class="dead"/>
|
||||
<text x="46" y="302" class="s">요청이 커넥션을 오래 붙들고 있으면</text>
|
||||
<text x="46" y="320" class="s">뒤의 요청은 풀에서 대기한다</text>
|
||||
<text x="46" y="342" class="t" fill="#cf222e">agroal 커넥션 획득 대기 최대 20,000 ms</text>
|
||||
<rect x="440" y="280" width="390" height="76" class="warn"/>
|
||||
<text x="456" y="302" class="tb">그리고 파드가 죽는다</text>
|
||||
<text x="456" y="322" class="s">readiness 프로브가 타임아웃으로 실패 →</text>
|
||||
<text x="456" y="342" class="s">느린 노드가 로드밸런서에서 빠진다. 느림이 장애로 승격된다</text>
|
||||
<text x="30" y="384" class="s">측정 장치 주의 — 동시 20건을 kubectl run --rm -i 로 돌리면 출력 스트림이 유실된다. 상주 탐침에 파일로 모아야 한다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,70 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 470" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-7a — volatile + DB 정지의 결과는 「무엇을 하느냐」가 아니라 「캐시가 더운가」로 갈린다</text>
|
||||
<text x="20" y="44" class="s">문장 로깅(log_statement=all)으로 측정 · 세 상태 모두 재현</text>
|
||||
|
||||
<rect x="20" y="66" width="380" height="120" class="box"/>
|
||||
<text x="34" y="88" class="tb">로그인이 쏘는 SQL</text>
|
||||
<rect x="36" y="100" width="348" height="34" class="ok"/>
|
||||
<text x="48" y="122" class="t">0개 — realm·사용자·클라이언트가 전부 캐시</text>
|
||||
<text x="36" y="152" class="s">MARK_LOGIN_START / MARK_LOGIN_END 사이가 비어 있다</text>
|
||||
<text x="36" y="170" class="s">volatile 이라 세션 쓰기도 없다</text>
|
||||
|
||||
<rect x="440" y="66" width="400" height="120" class="box"/>
|
||||
<text x="454" y="88" class="tb">refresh 가 쏘는 SQL</text>
|
||||
<rect x="456" y="100" width="368" height="34" class="warn"/>
|
||||
<text x="466" y="115" class="t">select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT</text>
|
||||
<text x="466" y="129" class="s">where CLIENT_ID=$1 and DEFAULT_SCOPE=$2 ('f' = optional)</text>
|
||||
<text x="454" y="152" class="s">★ REVOKED_TOKEN 은 한 번도 나오지 않는다 — A-7 의 가설은 틀렸다</text>
|
||||
<text x="454" y="170" class="s">그리고 첫 refresh 만 쏜다. 이후 3회는 SQL 0건 = 캐시됨</text>
|
||||
|
||||
<text x="20" y="222" class="tb">그래서 같은 설정이 세 가지 답을 낸다 — PostgreSQL 정지 시</text>
|
||||
|
||||
<rect x="20" y="240" width="266" height="120" class="dead"/>
|
||||
<text x="34" y="262" class="tb">① 완전 냉시동</text>
|
||||
<text x="34" y="282" class="s">재시작 직후, 아무것도 안 함</text>
|
||||
<text x="34" y="306" class="t">로그인 <tspan fill="#cf222e" font-weight="600">400</tspan> refresh 400</text>
|
||||
<text x="34" y="328" class="s">select ce1_0.ID from CLIENT</text>
|
||||
<text x="34" y="344" class="s">클라이언트 조회조차 캐시에 없다</text>
|
||||
|
||||
<rect x="298" y="240" width="266" height="120" class="warn"/>
|
||||
<text x="312" y="262" class="tb">② CLIENT 만 더움</text>
|
||||
<text x="312" y="282" class="s">로그인 1회 뒤 · ← A-7 이 본 상태</text>
|
||||
<text x="312" y="306" class="t">로그인 200 refresh <tspan fill="#bf8700" font-weight="600">500</tspan></text>
|
||||
<text x="312" y="328" class="s">CLIENT_SCOPE_CLIENT 조회 실패</text>
|
||||
<text x="312" y="344" class="s">Keycloak 로그가 이 SQL 을 직접 지목</text>
|
||||
|
||||
<rect x="576" y="240" width="264" height="120" class="ok"/>
|
||||
<text x="590" y="262" class="tb">③ 완전히 더움</text>
|
||||
<text x="590" y="282" class="s">refresh 3회로 캐시를 채운 뒤</text>
|
||||
<text x="590" y="306" class="t">로그인 200 refresh <tspan fill="#1a7f37" font-weight="600">200</tspan></text>
|
||||
<text x="590" y="328" class="s">SQL 0건 — DB 를 아예 안 본다</text>
|
||||
<text x="590" y="344" class="s">A-7 의 표와 정반대</text>
|
||||
|
||||
<path d="M286 300 L294 300" class="k" marker-end="url(#a)"/>
|
||||
<path d="M564 300 L572 300" class="k" marker-end="url(#a)"/>
|
||||
<text x="292" y="290" class="s">로그인 1회</text>
|
||||
<text x="570" y="290" class="s">refresh 3회</text>
|
||||
|
||||
<rect x="20" y="384" width="820" height="66" class="box"/>
|
||||
<text x="34" y="406" class="tb">남기는 것</text>
|
||||
<text x="34" y="426" class="s">· A-7 의 표에는 조건이 빠져 있었다 — 「volatile 이면 DB 없이 로그인된다」도 냉시동에서는 거짓이다</text>
|
||||
<text x="34" y="442" class="s">· 상태가 결과를 바꾸는데 그 상태가 안 보인다. A-1 의 conntrack 과 같은 계열의 함정이다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 380" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">A-8 — 세션은 살아남고 캐시만 사라진다</text>
|
||||
<text x="20" y="44" class="s">이것이 persistent-user-sessions 를 켜는 진짜 이유다</text>
|
||||
<rect x="30" y="74" width="800" height="30" class="box"/>
|
||||
<text x="46" y="94" class="tb">kubectl rollout restart statefulset/keycloak — 한 번에 한 파드씩</text>
|
||||
<rect x="30" y="124" width="250" height="110" class="dead"/>
|
||||
<text x="46" y="146" class="tb">사라지는 것 — 메모리</text>
|
||||
<text x="46" y="168" class="s">Infinispan sessions 캐시</text>
|
||||
<text x="46" y="188" class="t">entries_unique → <tspan fill="#cf222e" font-weight="600">0</tspan></text>
|
||||
<text x="46" y="212" class="s">파드와 함께 없어진다</text>
|
||||
<rect x="305" y="124" width="250" height="110" class="ok"/>
|
||||
<text x="321" y="146" class="tb">남는 것 — PostgreSQL</text>
|
||||
<text x="321" y="168" class="s">OFFLINE_USER_SESSION</text>
|
||||
<text x="321" y="188" class="t">151 → <tspan fill="#1a7f37" font-weight="600">151</tspan> 그대로</text>
|
||||
<text x="321" y="212" class="s">재시작과 무관하다</text>
|
||||
<rect x="580" y="124" width="250" height="110" class="ok"/>
|
||||
<text x="596" y="146" class="tb">그래서 사용자는</text>
|
||||
<text x="596" y="168" class="s">재시작 전 발급한 refresh token</text>
|
||||
<text x="596" y="188" class="t">여전히 <tspan fill="#1a7f37" font-weight="600">200</tspan></text>
|
||||
<text x="596" y="212" class="s">전 구간 중단 없음</text>
|
||||
<path d="M282 178 L301 178" class="k" marker-end="url(#a)"/>
|
||||
<path d="M557 178 L576 178" class="k" marker-end="url(#a)"/>
|
||||
<rect x="30" y="254" width="800" height="52" class="box"/>
|
||||
<text x="46" y="276" class="tb">클러스터는 자동 재형성된다</text>
|
||||
<text x="46" y="296" class="s">JGROUPS_PING 테이블에 다시 등록되고 cluster_size 가 2 로 돌아온다 — 사람이 할 일이 없다</text>
|
||||
<rect x="30" y="322" width="800" height="44" class="warn"/>
|
||||
<text x="46" y="344" class="s">★ A-7 과 대조 — volatile 이면 같은 재시작에서 refresh 가 400 Session not active 가 된다.</text>
|
||||
<text x="46" y="360" class="s">세션이 메모리에만 있었으므로 캐시가 사라지는 순간 세션도 사라진다. 이 실험의 결론은 버전 조건부다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">B-0 — 아무것도 설정하지 않으면 Spring 이 무엇을 고르는가</text>
|
||||
<text x="20" y="44" class="s">추측이 맞았지만, 추측으로 두면 안 되는 이유가 둘째 줄에 있다</text>
|
||||
<rect x="30" y="72" width="800" height="118" class="box"/>
|
||||
<text x="46" y="94" class="tb">/actuator/beans 가 실제로 답한 것</text>
|
||||
<text x="46" y="118" class="t">authorizedClientService → InMemoryOAuth2AuthorizedClientService</text>
|
||||
<text x="46" y="138" class="t">authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository</text>
|
||||
<text x="46" y="158" class="t">SessionRepository → 없음 (서블릿 컨테이너 in-memory)</text>
|
||||
<text x="46" y="178" class="t" fill="#cf222e">Redis / Spring Session → 없음</text>
|
||||
<rect x="30" y="212" width="390" height="150" class="warn"/>
|
||||
<text x="46" y="234" class="tb">★ 이름이 곧 설명이다</text>
|
||||
<text x="46" y="256" class="s">AuthenticatedPrincipal…Repository 는</text>
|
||||
<text x="46" y="276" class="t">principal 이름으로 찾는다</text>
|
||||
<rect x="46" y="288" width="358" height="30" class="dead"/>
|
||||
<text x="58" y="308" class="s">조회 키에 session id 가 없다</text>
|
||||
<text x="46" y="336" class="s">그래서 세션 저장소를 Redis 로 옮겨도</text>
|
||||
<text x="46" y="354" class="s">토큰은 따라오지 않는다 — B-1 이 겪는 문제의 뿌리</text>
|
||||
<rect x="440" y="212" width="390" height="150" class="box"/>
|
||||
<text x="456" y="234" class="tb">두 개가 서로 다른 것을 저장한다</text>
|
||||
<rect x="456" y="248" width="358" height="40" class="box"/>
|
||||
<text x="468" y="266" class="t">Application Session</text>
|
||||
<text x="468" y="282" class="s">누가 로그인했는지 · 세션 id 로 찾는다</text>
|
||||
<rect x="456" y="298" width="358" height="40" class="box"/>
|
||||
<text x="468" y="316" class="t">OAuth2AuthorizedClient</text>
|
||||
<text x="468" y="332" class="s">access / refresh token · principal 이름으로 찾는다</text>
|
||||
<text x="456" y="356" class="s">이 둘을 하나로 생각하면 B-1·B-2 의 결과를 해석할 수 없다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,55 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 440" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">B-2 — 저장소를 나눠 문제를 풀자 다른 두 문제가 남았다</text>
|
||||
<text x="20" y="44" class="s">Q1 의 네 항목 중 둘이 통과, 둘이 실패</text>
|
||||
<rect x="30" y="72" width="240" height="90" class="box"/>
|
||||
<text x="46" y="94" class="tb">bff 인스턴스 2개</text>
|
||||
<text x="46" y="116" class="s">어느 쪽으로 요청이 가도 된다</text>
|
||||
<text x="46" y="136" class="s">enableServiceLinks: false</text>
|
||||
<text x="46" y="154" class="s">(REDIS_PORT 충돌 회피)</text>
|
||||
<rect x="330" y="72" width="230" height="90" class="ok"/>
|
||||
<text x="346" y="94" class="tb">Redis</text>
|
||||
<text x="346" y="116" class="s">Application Session</text>
|
||||
<text x="346" y="136" class="s">세션 id 로 조회</text>
|
||||
<text x="346" y="154" class="s">로그아웃 시 정리됨</text>
|
||||
<rect x="600" y="72" width="230" height="90" class="warn"/>
|
||||
<text x="616" y="94" class="tb">PostgreSQL</text>
|
||||
<text x="616" y="116" class="s">OAuth2AuthorizedClient</text>
|
||||
<text x="616" y="136" class="s">principal 이름으로 조회</text>
|
||||
<text x="616" y="154" class="s">로그아웃해도 남는다</text>
|
||||
<path d="M272 108 L326 108" class="g" marker-end="url(#ag)"/>
|
||||
<path d="M562 108 L596 108" class="g" marker-end="url(#ag)"/>
|
||||
<rect x="30" y="188" width="390" height="100" class="ok"/>
|
||||
<text x="46" y="210" class="tb">통과</text>
|
||||
<text x="46" y="234" class="t">① 다른 인스턴스로 요청해도 된다</text>
|
||||
<text x="46" y="256" class="t">② 재시작 후에도 로그인 유지</text>
|
||||
<text x="46" y="278" class="s">저장소를 밖으로 뺐으니 당연한 결과다</text>
|
||||
<rect x="440" y="188" width="390" height="100" class="dead"/>
|
||||
<text x="456" y="210" class="tb">실패</text>
|
||||
<text x="456" y="234" class="t" fill="#cf222e">③ 같은 사용자의 다른 브라우저가 덮어쓴다</text>
|
||||
<text x="456" y="256" class="t" fill="#cf222e">④ 로그아웃해도 한쪽만 정리된다</text>
|
||||
<text x="456" y="278" class="s">둘 다 저장소 선택의 문제가 아니라 스키마의 문제다</text>
|
||||
<rect x="30" y="308" width="800" height="70" class="warn"/>
|
||||
<text x="46" y="330" class="tb">★ 뿌리는 DDL 한 줄이다</text>
|
||||
<text x="46" y="352" class="t">PRIMARY KEY (client_registration_id, principal_name)</text>
|
||||
<text x="46" y="370" class="s">세션 id 가 키에 없다 → 같은 사용자의 두 세션이 같은 행을 쓴다 → 나중 로그인이 앞의 토큰을 덮어쓴다</text>
|
||||
<rect x="30" y="392" width="800" height="40" class="dead"/>
|
||||
<text x="46" y="416" class="s">로그아웃 후: Redis 세션 0 키 (정리됨) · PostgreSQL 토큰 1 행 — 평문 refresh token 이 그대로 남는다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">B-7 — BFF 와 정반대의 성질: 공유할 상태가 없고, 대신 겹침 구간도 없다</text>
|
||||
<text x="20" y="44" class="s">oauth2-proxy 는 세션을 쿠키에 담는다</text>
|
||||
<rect x="30" y="72" width="380" height="120" class="box"/>
|
||||
<text x="46" y="94" class="tb">replica 는 무엇을 공유하나</text>
|
||||
<rect x="46" y="106" width="160" height="34" class="box"/>
|
||||
<text x="58" y="128" class="t">oauth2-proxy A</text>
|
||||
<rect x="250" y="106" width="146" height="34" class="box"/>
|
||||
<text x="262" y="128" class="t">oauth2-proxy B</text>
|
||||
<rect x="106" y="152" width="230" height="30" class="ok"/>
|
||||
<text x="118" y="172" class="s">같은 k8s Secret 을 읽는다</text>
|
||||
<path d="M126 142 L180 150" class="g"/>
|
||||
<path d="M316 142 L262 150" class="g"/>
|
||||
<text x="46" y="196" class="s"> </text>
|
||||
<rect x="440" y="72" width="390" height="120" class="ok"/>
|
||||
<text x="456" y="94" class="tb">그래서 얻는 것</text>
|
||||
<text x="456" y="118" class="s">공유할 서버 상태가 없다 → 콜백이 다른 replica 로 가도 된다</text>
|
||||
<text x="456" y="140" class="s">replica 를 늘려도 세션 저장소 고민이 없다</text>
|
||||
<text x="456" y="164" class="s">BFF 가 Redis·PostgreSQL 로 풀던 문제가 아예 생기지 않는다</text>
|
||||
<rect x="30" y="212" width="380" height="106" class="dead"/>
|
||||
<text x="46" y="234" class="tb">★ 대신 겹침 구간을 만들 수 없다</text>
|
||||
<text x="46" y="258" class="t">--cookie-secret 은 단수다</text>
|
||||
<text x="46" y="280" class="s">「옛 secret 도 당분간 받아준다」가 불가능하다</text>
|
||||
<text x="46" y="300" class="s">교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다</text>
|
||||
<rect x="440" y="212" width="390" height="106" class="warn"/>
|
||||
<text x="456" y="234" class="tb">교체하면 벌어지는 일</text>
|
||||
<text x="456" y="258" class="s">옛 쿠키 → session ticket cookie failed validation</text>
|
||||
<text x="456" y="278" class="s">Keycloak SSO 가 살아 있으면 로그인 화면 없이 조용히 재인증</text>
|
||||
<text x="456" y="300" class="s" fill="#cf222e">서버 쪽 세션은 고아로 남는다 — 티켓을 못 풀어 지우지도 못한다</text>
|
||||
<rect x="30" y="338" width="800" height="66" class="ok"/>
|
||||
<text x="46" y="360" class="tb">→ B-7a 가 이어받았다</text>
|
||||
<text x="46" y="382" class="s">「지울 수 없다」는 oauth2-proxy 의 한계일 뿐이었다. 운영자는 지울 수 있고, TTL 역산으로 고아만 골라낼 수 있다</text>
|
||||
<text x="46" y="398" class="s">그리고 고아는 생성 후 정확히 1시간에 사라진다 — TTL 이 요청으로 갱신되지 않기 때문이다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 500" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">B-7a — 고아 세션은 지울 수 있다. oauth2-proxy 가 못 할 뿐이다</text>
|
||||
<text x="20" y="44" class="s">회전 2회 측정 · Redis + oauth2-proxy 로그 + 브라우저</text>
|
||||
|
||||
<text x="20" y="76" class="tb">왜 프록시는 못 지우는가 — 티켓의 구조</text>
|
||||
<rect x="20" y="88" width="400" height="86" class="box"/>
|
||||
<text x="34" y="110" class="t">쿠키에 담기는 것 = 티켓</text>
|
||||
<rect x="36" y="120" width="180" height="30" class="box"/>
|
||||
<text x="48" y="140" class="s">세션 ID → Redis 키 이름</text>
|
||||
<rect x="224" y="120" width="180" height="30" class="box"/>
|
||||
<text x="236" y="140" class="s">암호화 키 → 값 복호화</text>
|
||||
<text x="34" y="166" class="s">티켓 전체가 cookie secret 으로 암호화되어 있다</text>
|
||||
|
||||
<rect x="444" y="88" width="396" height="86" class="dead"/>
|
||||
<text x="458" y="110" class="tb">secret 을 바꾸면</text>
|
||||
<text x="458" y="130" class="s">티켓을 못 연다 → 세션 ID 조차 못 읽는다</text>
|
||||
<text x="458" y="148" class="s">→ 어느 Redis 키를 지울지 모른다</text>
|
||||
<text x="458" y="166" class="s">Error removing session: error decoding ticket to clear session</text>
|
||||
<path d="M424 131 L440 131" class="r" marker-end="url(#ar)"/>
|
||||
|
||||
<text x="20" y="208" class="tb">회전할 때마다 누적한다</text>
|
||||
<line x1="70" y1="256" x2="800" y2="256" class="k"/>
|
||||
<line x1="130" y1="244" x2="130" y2="268" stroke="#cf222e" stroke-width="2"/>
|
||||
<line x1="430" y1="244" x2="430" y2="268" stroke="#cf222e" stroke-width="2"/>
|
||||
<text x="92" y="288" class="s">1차 회전 11:29:56</text>
|
||||
<text x="392" y="288" class="s">2차 회전 11:33:27</text>
|
||||
|
||||
<rect x="70" y="224" width="60" height="16" class="ok"/>
|
||||
<text x="76" y="236" class="s">세션 A</text>
|
||||
<rect x="130" y="224" width="300" height="16" class="dead"/>
|
||||
<text x="150" y="236" class="s" fill="#cf222e">A 는 고아 · TTL 만료까지 남는다</text>
|
||||
<rect x="130" y="224" width="0" height="0"/>
|
||||
<rect x="150" y="304" width="280" height="16" class="ok"/>
|
||||
<text x="156" y="316" class="s">세션 B (1차 회전 후 생성)</text>
|
||||
<rect x="430" y="304" width="370" height="16" class="dead"/>
|
||||
<text x="450" y="316" class="s" fill="#cf222e">B 도 고아가 된다</text>
|
||||
<rect x="450" y="224" width="350" height="16" class="ok"/>
|
||||
<text x="456" y="236" class="s">세션 C (2차 회전 후 생성)</text>
|
||||
<text x="70" y="338" class="s">회전 1회 = 그 시점 로그인 사용자 수만큼의 고아</text>
|
||||
|
||||
<rect x="20" y="356" width="400" height="126" class="dead"/>
|
||||
<text x="34" y="378" class="tb">Redis 만 보고는 구분할 수 없다</text>
|
||||
<text x="34" y="400" class="s">이름 _oauth2_proxy-<불투명한 32자 hex> (동일 형식)</text>
|
||||
<text x="34" y="418" class="s">type string (동일)</text>
|
||||
<text x="34" y="436" class="s">크기 3510 바이트 (바이트까지 동일)</text>
|
||||
<text x="34" y="454" class="s">값 암호화됨 (읽을 수 없다)</text>
|
||||
<text x="34" y="474" class="s" fill="#cf222e">→ 다른 것은 TTL 하나뿐이다</text>
|
||||
|
||||
<rect x="444" y="356" width="396" height="126" class="ok"/>
|
||||
<text x="458" y="378" class="tb">그래서 TTL 이 정리 규칙이 된다</text>
|
||||
<text x="458" y="398" class="s">--cookie-refresh 가 없어 TTL 이 갱신되지 않는다 (refresh:disabled)</text>
|
||||
<text x="458" y="416" class="s">→ TTL 은 생성 시각의 정확한 함수다</text>
|
||||
<rect x="460" y="424" width="364" height="26" class="box"/>
|
||||
<text x="470" y="442" class="t">생성시각 = 지금 − (cookie-expire − TTL)</text>
|
||||
<text x="458" y="466" class="s" fill="#1a7f37">이 값이 회전 시각보다 이르면 고아. 검증: 역산 11:30:26 vs 로그 11:30:27 — 1초 오차</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">C-2 — 로그아웃이 안 퍼지는 이유는 단순했다. 아무도 구현하지 않았다</text>
|
||||
<text x="20" y="44" class="s">C-1 이 관측한 현상의 원인</text>
|
||||
<text x="20" y="76" class="tb">백채널 로그아웃이 동작하려면 양쪽이 다 있어야 한다</text>
|
||||
<rect x="30" y="94" width="380" height="110" class="dead"/>
|
||||
<text x="46" y="116" class="tb">① IdP 쪽 — Keycloak 클라이언트 설정</text>
|
||||
<text x="46" y="140" class="t">backchannelLogoutUrl</text>
|
||||
<text x="46" y="162" class="t" fill="#cf222e">두 클라이언트 모두 비어 있다</text>
|
||||
<text x="46" y="186" class="s">Keycloak 은 부를 주소를 모른다</text>
|
||||
<rect x="440" y="94" width="390" height="110" class="dead"/>
|
||||
<text x="456" y="116" class="tb">② 앱 쪽 — 수신 엔드포인트</text>
|
||||
<text x="456" y="140" class="t">/logout/connect/back-channel/{registrationId}</text>
|
||||
<text x="456" y="162" class="t" fill="#cf222e">BFF 소스에 oidcLogout 설정이 없다</text>
|
||||
<text x="456" y="186" class="s">주소를 알려줘도 받을 곳이 없다</text>
|
||||
<rect x="30" y="228" width="800" height="60" class="warn"/>
|
||||
<text x="46" y="250" class="tb">★ 그래서 IdP 쪽만 설정하면 되는 줄 알면 틀린다</text>
|
||||
<text x="46" y="272" class="s">backchannelLogoutUrl 을 넣어 봤지만 앱 세션은 그대로 남았다. 받는 쪽이 없기 때문이다</text>
|
||||
<rect x="30" y="308" width="380" height="72" class="ok"/>
|
||||
<text x="46" y="330" class="tb">네트워크 문제가 아니라는 확인</text>
|
||||
<text x="46" y="352" class="s">Keycloak 파드에서 앱 URL 로 요청 → HTTP 200</text>
|
||||
<text x="46" y="372" class="s">닿기는 한다. 닿아도 처리할 코드가 없을 뿐이다</text>
|
||||
<rect x="440" y="308" width="390" height="72" class="box"/>
|
||||
<text x="456" y="330" class="tb">남기는 것</text>
|
||||
<text x="456" y="352" class="s">「설정이 빠졌다」와 「기능이 없다」는 다르게 고쳐야 한다</text>
|
||||
<text x="456" y="372" class="s">여기는 둘 다였고, 확인 순서를 바꿨다면 한쪽만 고치고 끝냈을 것이다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">D-2 — 롤백은 안 된다. 다만 롤링 업데이트가 사고를 절반에서 멈춰줬다</text>
|
||||
<text x="20" y="44" class="s">그리고 이 결론은 조건부였다 — 스키마가 안 바뀌면 롤백된다(후속에서 정밀화)</text>
|
||||
<rect x="30" y="72" width="380" height="126" class="box"/>
|
||||
<text x="46" y="94" class="tb">앞으로 (26.7.0 → 26.7.3)</text>
|
||||
<text x="46" y="118" class="t" fill="#1a7f37">된다. 무중단</text>
|
||||
<text x="46" y="140" class="s">87회 요청 전부 200</text>
|
||||
<text x="46" y="160" class="s">databasechangelog 210 → 210 (스키마 변경 없음)</text>
|
||||
<text x="46" y="180" class="s">세션 유지 · Infinispan 16.0.12 → 16.0.14</text>
|
||||
<rect x="440" y="72" width="390" height="126" class="dead"/>
|
||||
<text x="456" y="94" class="tb">뒤로 (스키마가 이미 움직였을 때)</text>
|
||||
<text x="456" y="118" class="t" fill="#cf222e">안 된다</text>
|
||||
<text x="456" y="140" class="s">liquibase ValidationFailedException:</text>
|
||||
<text x="456" y="158" class="s">1 changesets check sum</text>
|
||||
<text x="456" y="180" class="s">새 버전이 남긴 체크섬을 옛 버전이 거부한다</text>
|
||||
<rect x="30" y="222" width="800" height="72" class="ok"/>
|
||||
<text x="46" y="244" class="tb">★ 그런데 서비스는 살아 있었다 — StatefulSet 롤링 업데이트 덕분이다</text>
|
||||
<text x="46" y="266" class="s">한 번에 한 파드씩 바꾼다. 첫 파드가 기동에 실패하면 거기서 멈추고 나머지는 건드리지 않는다</text>
|
||||
<text x="46" y="286" class="s">그래서 남은 파드가 외부 200 을 계속 냈다. 「롤백 계획」이 없어도 사고가 전면화되지 않았다</text>
|
||||
<rect x="30" y="314" width="380" height="90" class="warn"/>
|
||||
<text x="46" y="336" class="tb">그러면 무엇으로 판단하나</text>
|
||||
<text x="46" y="358" class="t">select count(*) from databasechangelog</text>
|
||||
<text x="46" y="380" class="s">업그레이드 전후 이 수가 같으면 롤백 가능</text>
|
||||
<text x="46" y="398" class="s">늘었으면 스키마가 움직였다 = 롤백 불가</text>
|
||||
<rect x="440" y="314" width="390" height="90" class="box"/>
|
||||
<text x="456" y="336" class="tb">전제 — 백업이 먼저다 (D-1)</text>
|
||||
<text x="456" y="358" class="s">롤백이 막히는 상황에서 되돌릴 방법은 덤프 복원뿐이다</text>
|
||||
<text x="456" y="380" class="s">D-1 이 그 절차를 재고, D-2 가 그 절차가 필요한 이유를 만든다</text>
|
||||
<text x="456" y="398" class="s">순서를 바꾸면 되돌릴 수 없는 상태에서 백업을 배우게 된다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -17,7 +17,7 @@
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
|
||||
<text x="20" y="26" class="ttl">D-4 — 「갱신 성공」과 「새 인증서 서빙」 사이의 36분 39초</text>
|
||||
<text x="20" y="26" class="ttl">D-4 — 「갱신 성공」과 「새 인증서 서빙」 사이의 38분 25초</text>
|
||||
<text x="20" y="44" class="s">측정: 2026-09-04 · 일련번호 5초 간격 564표본 · 새 연결 0.2초 간격 8856건</text>
|
||||
|
||||
<!-- 좌: 디스크 -->
|
||||
@@ -81,7 +81,7 @@
|
||||
<text x="730" y="342" class="s" text-anchor="middle" fill="#1a7f37">06C7CB…EA1D</text>
|
||||
<path d="M300 416 L660 416" class="r" marker-end="url(#ar)"/>
|
||||
<path d="M660 416 L300 416" class="r" marker-end="url(#ar)"/>
|
||||
<text x="480" y="410" class="tb" text-anchor="middle" fill="#cf222e">36분 39초</text>
|
||||
<text x="480" y="410" class="tb" text-anchor="middle" fill="#cf222e">38분 25초</text>
|
||||
<text x="480" y="434" class="s" text-anchor="middle">사람이 reload 했기에 36분이다. 아무도 안 했다면 다음 재시작까지 — 무기한</text>
|
||||
|
||||
<!-- reload 판정 -->
|
||||
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,57 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 430" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||
<style>
|
||||
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||
.t{font-size:12px;fill:#1f2328}
|
||||
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||
.s{font-size:10.5px;fill:#656d76}
|
||||
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||
</defs>
|
||||
<text x="20" y="26" class="ttl">D-4a — deploy 훅 하나로 38분 25초가 1~2초가 된다</text>
|
||||
<text x="20" y="44" class="s">시각은 전부 실제 UTC (test-server 시계에서 106초 보정)</text>
|
||||
|
||||
<rect x="20" y="68" width="820" height="112" class="dead"/>
|
||||
<text x="34" y="90" class="tb">D-4 — 훅이 없을 때</text>
|
||||
<line x1="60" y1="130" x2="800" y2="130" class="k"/>
|
||||
<line x1="90" y1="120" x2="90" y2="140" stroke="#cf222e" stroke-width="2"/>
|
||||
<line x1="740" y1="120" x2="740" y2="140" stroke="#1a7f37" stroke-width="2"/>
|
||||
<rect x="90" y="122" width="650" height="16" class="dead"/>
|
||||
<text x="415" y="134" class="s" text-anchor="middle" fill="#cf222e">옛 인증서를 계속 서빙 — 428회 관측</text>
|
||||
<text x="62" y="158" class="s">08:20:27 디스크 기록</text>
|
||||
<text x="660" y="158" class="s">08:58:52 사람이 nginx -s reload</text>
|
||||
<text x="415" y="112" class="tb" text-anchor="middle" fill="#cf222e">2305초 = 38분 25초</text>
|
||||
<text x="34" y="174" class="s">아무도 reload 하지 않았다면 다음 nginx 재시작까지 = 사실상 무기한</text>
|
||||
|
||||
<rect x="20" y="196" width="820" height="112" class="ok"/>
|
||||
<text x="34" y="218" class="tb">D-4a — deploy 훅을 넣었을 때</text>
|
||||
<line x1="60" y1="258" x2="800" y2="258" class="k"/>
|
||||
<line x1="90" y1="248" x2="90" y2="268" stroke="#1a7f37" stroke-width="2"/>
|
||||
<line x1="104" y1="248" x2="104" y2="268" stroke="#1a7f37" stroke-width="2"/>
|
||||
<rect x="90" y="250" width="14" height="16" class="ok"/>
|
||||
<text x="62" y="286" class="s">12:27:49.05 발급 (SCT — CT 로그의 독립 시계)</text>
|
||||
<text x="360" y="286" class="s">12:27:50 nginx -t · 새 워커 37252 · 12:27:51 reload</text>
|
||||
<text x="150" y="244" class="tb" fill="#1a7f37">1~2초, 자동</text>
|
||||
<text x="34" y="302" class="s">certbot 이 파일을 쓴 직후 같은 실행 안에서 deploy 훅을 부른다 — 사람이 개입할 자리가 없다</text>
|
||||
|
||||
<rect x="20" y="324" width="400" height="92" class="box"/>
|
||||
<text x="34" y="346" class="tb">판정은 문구가 아니라 워커 PID 로</text>
|
||||
<text x="34" y="366" class="s">master 585 → 585 (유지)</text>
|
||||
<text x="34" y="384" class="s">worker 28829 → 37252 (교체 = reload 됨)</text>
|
||||
<text x="34" y="404" class="s">nginx 는 인증서를 기동 시 읽어 메모리에 든다</text>
|
||||
|
||||
<rect x="444" y="324" width="396" height="92" class="warn"/>
|
||||
<text x="458" y="346" class="tb">★ 함정 — 성공을 실패로 오독한다</text>
|
||||
<text x="458" y="366" class="s">Hook 'deploy-hook' ran with error output:</text>
|
||||
<text x="458" y="384" class="s"> [warn] could not build optimal types_hash …</text>
|
||||
<text x="458" y="402" class="s">nginx 경고가 stderr 로 나갔을 뿐. 내용은 test is successful</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,117 @@
|
||||
A-7a — volatile 모드에서 refresh 가 500 인 진짜 이유
|
||||
=======================================================
|
||||
수집: 2026-09-04 11:18 ~ 11:24 UTC · kc-lab-1 에서 sudo kubectl
|
||||
|
||||
A-7 이 남긴 가설
|
||||
----------------
|
||||
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
||||
> `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지
|
||||
> 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||
|
||||
★ 이 가설은 틀렸다.
|
||||
|
||||
방법 — PostgreSQL 문장 로깅 (A-3 기법)
|
||||
---------------------------------------
|
||||
ALTER SYSTEM SET log_statement = 'all'; SELECT pg_reload_conf();
|
||||
|
||||
표식을 넣어 로그인과 refresh 가 각각 어떤 SQL 을 쏘는지 구분했다.
|
||||
select 'MARK_LOGIN_START' ... 로그인 ... select 'MARK_LOGIN_END'
|
||||
select 'MARK_REFRESH_START' ... refresh ... select 'MARK_REFRESH_END'
|
||||
|
||||
volatile 전환 확인
|
||||
args: ["start","--features-disabled=persistent-user-sessions"]
|
||||
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
|
||||
|
||||
[측정 1] 로그인은 SQL 을 0개 쏜다
|
||||
----------------------------------
|
||||
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||
↑ 사이에 아무것도 없다
|
||||
|
||||
realm·사용자·클라이언트가 전부 Infinispan 캐시에 있어 DB 를 안 본다.
|
||||
|
||||
[측정 2] ★ refresh 는 CLIENT_SCOPE_CLIENT 를 본다
|
||||
--------------------------------------------------
|
||||
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||
11:18:52.137 statement: BEGIN
|
||||
11:18:52.137 execute <unnamed>/C_107:
|
||||
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||
11:18:52.148 execute S_2: COMMIT
|
||||
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||
|
||||
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
|
||||
`DEFAULT_SCOPE='f'` 이므로 **선택적(optional) 클라이언트 스코프** 조회다.
|
||||
|
||||
[측정 3] 그 조회는 한 번뿐이다 — 캐시에 들어간다
|
||||
-------------------------------------------------
|
||||
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||
statement: select 'MARK_R1'
|
||||
statement: select 'MARK_R2'
|
||||
statement: select 'MARK_R3'
|
||||
statement: select 'MARK_R_END'
|
||||
↑ SQL 0건
|
||||
|
||||
첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지 않는다.
|
||||
|
||||
════ 그래서 A-7 의 표 자체가 조건부였다 ════
|
||||
|
||||
같은 설정에서 **캐시 온도만으로 결과가 셋으로 갈린다.** 전부 측정했다.
|
||||
|
||||
┌──────────────────────┬────────┬─────────┬──────────────────────────────┐
|
||||
│ 캐시 상태 │ 로그인 │ refresh │ 실패한 SQL │
|
||||
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
||||
│ 완전 냉시동 │ 400 │ 400 │ select ce1_0.ID from CLIENT │
|
||||
│ (재시작 직후) │ │ │ where CLIENT_ID=? REALM_ID=?│
|
||||
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
||||
│ CLIENT 만 더움 │ 200 │ ★ 500 │ select cscme1_0.SCOPE_ID │
|
||||
│ ← A-7 이 본 상태 │ │ │ from CLIENT_SCOPE_CLIENT │
|
||||
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
||||
│ 완전히 더움 │ 200 │ 200 │ 없음 (SQL 0건) │
|
||||
└──────────────────────┴────────┴─────────┴──────────────────────────────┘
|
||||
|
||||
[재현 A] 완전 냉시동 — 로그인부터 400
|
||||
keycloak 재시작 → postgres 정지 → 로그인
|
||||
로그인 400 {"error":"unauthorized_client",
|
||||
"error_description":"Unexpected error when authenticating client"}
|
||||
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
|
||||
authenticating client: org.hibernate.exception.GenericJDBCException:
|
||||
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||
administrator command]
|
||||
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||
|
||||
→ A-7 은 "volatile 이면 DB 없이 로그인된다"고 적었지만,
|
||||
**냉시동에서는 그것도 안 된다.**
|
||||
|
||||
[재현 B] ★ A-7 이 본 그 조건 — 로그인 200, refresh 500
|
||||
keycloak 재시작 → (DB 살아있을 때) 로그인 1회 → postgres 정지 → refresh
|
||||
로그인 200
|
||||
refresh 500 {"error":"unknown_error"}
|
||||
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||
administrator command]
|
||||
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||
|
||||
★ 로그가 실패한 SQL 을 직접 지목한다. CLIENT_SCOPE_CLIENT 다.
|
||||
|
||||
[재현 C] 완전히 더움 — 둘 다 200
|
||||
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
|
||||
로그인 200
|
||||
refresh 200 ← A-7 의 표와 정반대다
|
||||
|
||||
════ 결론 ════
|
||||
|
||||
1. **원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회다.**
|
||||
`CLIENT_SCOPE_CLIENT` 를 `DEFAULT_SCOPE='f'` 로 조회하는 그 한 문장이다.
|
||||
|
||||
2. **A-7 의 "volatile → refresh 500" 은 캐시가 차가울 때만 참이다.**
|
||||
더워지면 200 이 된다. A-7 은 그 조건을 몰랐고, 그래서 표에 적지 않았다.
|
||||
|
||||
3. **"volatile 이면 DB 없이 로그인된다" 도 조건부다.**
|
||||
냉시동에서는 400 이다. 클라이언트 조회조차 캐시에 없기 때문이다.
|
||||
|
||||
4. **일반화** — volatile 모드에서 DB 정지 시의 동작은 "무엇을 하느냐"가
|
||||
아니라 **"그 경로가 이미 캐시를 채웠느냐"** 로 결정된다. 그래서 같은
|
||||
명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는
|
||||
**한 번 재보고 표로 적으면 안 된다** — A-7 이 그렇게 했다.
|
||||
@@ -0,0 +1,15 @@
|
||||
# A-7a — volatile refresh 500 의 원인 확정 증거
|
||||
|
||||
2026-09-04 11:18 – 11:24 UTC
|
||||
해설: [`docs/experiment-a7a-volatile-cause.md`](../../experiment-a7a-volatile-cause.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-cause-determined.txt` | **가설이 틀렸다.** `REVOKED_TOKEN` 이 아니라 `CLIENT_SCOPE_CLIENT` 조회다. 문장 로깅으로 잡고, 실패 로그가 그 SQL 을 직접 지목한다. 그리고 **같은 설정에서 캐시 온도만으로 400/500/200 셋이 나온다** |
|
||||
|
||||
## 핵심 네 줄
|
||||
|
||||
1. **원인은 선택적 클라이언트 스코프 조회다.** `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT where CLIENT_ID=? and DEFAULT_SCOPE=?` — A-7 이 지목한 `REVOKED_TOKEN` 은 로그에 한 번도 안 나온다.
|
||||
2. **로그인은 SQL 을 0개 쏜다.** refresh 만 이 한 문장을 쏘고, 그것도 **첫 번째만** 쏜다. 이후로는 캐시된다.
|
||||
3. **A-7 의 표가 조건부였다.** 완전 냉시동이면 로그인부터 400, 캐시가 완전히 더우면 refresh 도 200. A-7 이 본 「로그인 200 · refresh 500」은 그 사이의 한 상태다 — 셋 다 재현했다.
|
||||
4. **교훈** — volatile + DB 정지의 동작은 "무엇을 하느냐"가 아니라 **"그 경로가 캐시를 채웠느냐"** 로 갈린다. 한 번 재보고 표로 적으면 안 되는 종류의 측정이다.
|
||||
@@ -0,0 +1,147 @@
|
||||
B-7a — cookie secret 회전이 남기는 고아 세션의 수명과 정리
|
||||
=============================================================
|
||||
수집: 2026-09-04 11:29 ~ 11:34 UTC · Redis + oauth2-proxy 로그 + Playwright
|
||||
|
||||
B-7 이 남긴 것
|
||||
--------------
|
||||
| 관찰 | |
|
||||
|---|---|
|
||||
| 옛 쿠키 | 검증 실패 — session ticket cookie failed validation |
|
||||
| 사용자 경험 | Keycloak SSO 가 살아 있어 조용히 재로그인 |
|
||||
| **서버 쪽 세션** | **★ 지우지 못했다** |
|
||||
|
||||
`Error removing session: error decoding ticket to clear session`
|
||||
→ **티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||
|
||||
B-7 은 여기서 멈췄다. 남은 물음 셋을 잰다.
|
||||
(1) 고아의 TTL 은 실제로 줄어드는가 — 정말 사라지긴 하는가
|
||||
(2) 운영자가 직접 지울 수 있는가 · 지우면 산 세션이 다치는가
|
||||
(3) ★ 어느 키가 고아인지 구분할 수 있는가
|
||||
|
||||
[기준선] 회전 전 — 11:29:42 UTC
|
||||
------------------------------------
|
||||
secret = COOKIE_SECRET_A
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
type=string ttl=3568초 크기=3510바이트
|
||||
dbsize=1
|
||||
|
||||
설정: --cookie-expire=1h --session-store-type=redis
|
||||
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
|
||||
httponly:true expiry:1h0m0s ... refresh:disabled
|
||||
|
||||
[주입] 1차 회전 A → B — 11:29:56 UTC
|
||||
--------------------------------------
|
||||
kubectl patch deployment oauth2-proxy ... COOKIE_SECRET_B
|
||||
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
|
||||
|
||||
브라우저가 접근한 순간(11:30:27) 로그:
|
||||
[stored_session.go:94] Error loading cookied session:
|
||||
session ticket cookie failed validation: <nil>, removing session
|
||||
[stored_session.go:97] Error removing session:
|
||||
error decoding ticket to clear session: session ticket cookie failed validation
|
||||
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
|
||||
|
||||
→ B-7 의 관찰 그대로 재현. Keycloak SSO 가 살아 있어 로그인 화면 없이 통과했다.
|
||||
|
||||
Redis:
|
||||
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||
dbsize=2
|
||||
|
||||
[측정 1] ★ Redis 만 보고는 구분할 수 없다
|
||||
-------------------------------------------
|
||||
키 type strlen ttl
|
||||
_oauth2_proxy-87faa1c9…(새) string 3510 3558
|
||||
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
|
||||
|
||||
· 이름 접두사가 같다 (_oauth2_proxy-)
|
||||
· 뒤는 불투명한 32자 hex — 사용자·시각·상태 어느 것도 안 담긴다
|
||||
· 타입이 같다, 크기가 **바이트 단위로 같다** (3510)
|
||||
· 값은 암호화되어 있다
|
||||
새 "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
|
||||
고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
|
||||
md5 f9ad43cc6bbb2db4 / 9b31f7c4138e6472 (다르지만 뜻을 읽을 수 없다)
|
||||
|
||||
→ **다른 것은 TTL 뿐이다.**
|
||||
|
||||
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
|
||||
-----------------------------------------------------------
|
||||
30초 간격 3회:
|
||||
t+00초 새=3557 고아=3479
|
||||
t+30초 새=3526 고아=3448
|
||||
t+60초 새=3494 고아=3417
|
||||
|
||||
1초에 1초씩. 고아는 **생성 후 정확히 1시간에 사라진다.**
|
||||
|
||||
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
|
||||
살아있는 세션 ttl=3464 ← 계속 줄어든다
|
||||
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
|
||||
|
||||
★ 이것이 다음 측정의 열쇠가 된다 — TTL 이 갱신되지 않으므로
|
||||
TTL 은 **생성 시각의 정확한 함수**다.
|
||||
|
||||
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
|
||||
----------------------------------------------------------
|
||||
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
|
||||
dbsize 2 → 1
|
||||
남은 키: _oauth2_proxy-87faa1c9…
|
||||
|
||||
삭제 직후 브라우저 요청 (11:32:49):
|
||||
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
|
||||
|
||||
→ **200. 산 세션은 영향이 없다.**
|
||||
oauth2-proxy 는 못 지우지만 **운영자는 지울 수 있다.**
|
||||
|
||||
[측정 4] ★ 누적한다 — 회전할 때마다
|
||||
-------------------------------------
|
||||
2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:
|
||||
|
||||
키 TTL 생성시각(추정) 판정
|
||||
_oauth2_proxy-dad9c9fb… 3581 11:33:54 살아있음
|
||||
_oauth2_proxy-87faa1c9… 3373 11:30:26 ★ 고아
|
||||
dbsize=2
|
||||
|
||||
**1차 회전에서 살아남았던 세션이 2차 회전에서 고아가 됐다.**
|
||||
회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.
|
||||
|
||||
[측정 5] ★ 그래서 정리 규칙을 유도할 수 있다
|
||||
----------------------------------------------
|
||||
TTL 이 갱신되지 않으므로(측정 2):
|
||||
|
||||
생성시각 = 지금 - (cookie-expire - TTL)
|
||||
|
||||
이 값이 **회전 시각보다 이르면 그 키는 고아다.** 회전 이후에 만들어진
|
||||
세션은 새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.
|
||||
|
||||
검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
|
||||
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||
|
||||
실행:
|
||||
NOW=$(date -u +%s); ROT=<회전 시각 epoch>
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(redis-cli ttl "$K")
|
||||
C=$(( NOW - (3600 - T) ))
|
||||
[ $C -lt $ROT ] && redis-cli del "$K"
|
||||
done
|
||||
|
||||
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
|
||||
산 세션은 남고 고아만 사라졌다.
|
||||
|
||||
════ 결론 ════
|
||||
|
||||
1. **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.**
|
||||
프록시는 티켓을 못 풀어 키를 계산할 수 없다. 운영자는 키를 직접 안다.
|
||||
|
||||
2. **고아는 반드시 사라진다 — 생성 후 1시간.** TTL 이 갱신되지 않기 때문에
|
||||
"쓰고 있으면 안 지워진다" 같은 일이 없다. 다만 그 1시간 동안은 남는다.
|
||||
|
||||
3. **어느 것이 고아인지는 Redis 값으로 알 수 없다.** 이름·타입·크기가
|
||||
같고 값은 암호화되어 있다. **TTL 만이 신호다.**
|
||||
|
||||
4. **그 TTL 로 정리 규칙이 유도된다.** 회전 시각 이전에 생성된 키는 전부
|
||||
고아다. 1초 오차로 정확히 골라낼 수 있고, 실제로 골라내 지웠다.
|
||||
|
||||
5. **전제가 하나 있다 — `--cookie-refresh` 를 켜면 이 규칙이 깨진다.**
|
||||
TTL 이 갱신되면 생성 시각을 역산할 수 없기 때문이다. 그때는 회전 후
|
||||
`FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 오히려 정직하다.
|
||||
@@ -0,0 +1,17 @@
|
||||
# B-7a — 고아 세션의 수명과 정리 증거
|
||||
|
||||
2026-09-04 11:29 – 11:34 UTC
|
||||
해설: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-orphan-lifecycle.txt` | 회전 2회로 고아가 **누적**하는 것 · TTL 이 1초/초로 줄고 **요청으로 갱신되지 않는 것** · Redis 만으로는 **구분 불가**(이름·타입·크기 동일, 값 암호화) · `redis-cli del` 로 지워도 산 세션은 **200** · TTL 역산 정리 규칙이 **1초 오차**로 맞는 것 |
|
||||
| `b7a-live-session-after-orphan-delete.png` | 고아를 지운 직후 살아있는 세션이 `/oauth2/userinfo` 를 정상 응답하는 브라우저 화면 |
|
||||
|
||||
## 핵심 다섯 줄
|
||||
|
||||
1. **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.** 프록시는 티켓을 못 풀어 키를 계산 못 한다. 운영자는 키를 직접 안다 — `del` 반환 1, dbsize 2→1, 산 세션은 그대로 200.
|
||||
2. **고아는 반드시 사라진다 — 생성 후 정확히 1시간.** TTL 이 요청으로 갱신되지 않기 때문이다(`refresh:disabled`). 다만 그 1시간은 남는다.
|
||||
3. **회전할 때마다 누적한다.** 1차 회전을 살아남은 세션이 2차 회전에서 고아가 됐다. 회전 1회 = 그 시점 로그인 사용자 수만큼.
|
||||
4. **Redis 값으로는 고아를 못 고른다.** 이름 접두사·타입·크기(3510바이트)가 같고 값은 암호화되어 있다. **TTL 만이 신호다.**
|
||||
5. **그 TTL 로 정리 규칙이 유도된다.** `생성시각 = 지금 − (cookie-expire − TTL)` 이 회전 시각보다 이르면 고아다. 추정 11:30:26 대 로그 11:30:27 — **1초 오차**. 실제로 골라 지웠고 산 세션만 남았다.
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -1,4 +1,5 @@
|
||||
D-4 판정 — 갱신은 성공했고, 36분 39초 동안 반영되지 않았다
|
||||
D-4 판정 — 갱신은 성공했고, 38분 25초 동안 반영되지 않았다
|
||||
(시계 보정 후. 처음 적은 36분 39초는 서로 다른 시계를 뺀 값이었다)
|
||||
=============================================================
|
||||
수집: 2026-09-04 · 감시 08:10:51 ~ 09:02 UTC (KST-9)
|
||||
|
||||
@@ -30,7 +31,8 @@ D-4 판정 — 갱신은 성공했고, 36분 39초 동안 반영되지 않았다
|
||||
새 인증서 디스크 기록 : 08:22:13 UTC (= 17:22:13 KST, archive mtime)
|
||||
실제 서빙 시작 : 08:58:52 UTC (= 17:58:52 KST, nginx -s reload)
|
||||
────────────────────────────────────────────
|
||||
공백 : 2199초 = 36분 39초
|
||||
공백 : 2305초 = 38분 25초 ← test-server 가 106초 빠른 것을 보정한 값
|
||||
(보정 전 2199초로 적었던 것을 정정. D-4a 참조)
|
||||
|
||||
그리고 이 36분은 **우연히 짧았을 뿐이다.** reload 를 시킨 것은 사람이지
|
||||
자동화가 아니다. 아무도 안 했다면 다음 nginx 재시작까지 — 즉 무기한 —
|
||||
@@ -86,7 +88,7 @@ D-4 판정 — 갱신은 성공했고, 36분 39초 동안 반영되지 않았다
|
||||
|
||||
════ 세 줄 요약 ════
|
||||
|
||||
1. **"갱신 성공"과 "새 인증서 서빙"은 다른 사건이다.** 36분 39초 동안
|
||||
1. **"갱신 성공"과 "새 인증서 서빙"은 다른 사건이다.** 38분 25초 동안
|
||||
디스크에는 새 인증서가, 네트워크에는 옛 인증서가 있었다. 428번 관측했다.
|
||||
|
||||
2. **reload 는 정말 무중단이었다.** 8856건 전부 200, p95 변화 없음,
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
| `10-reload-poll-window.txt` | reload 전후 60초 새 연결 원문 — **비200 0건**, 최대 373ms |
|
||||
| `11-inflight-full.txt` | in-flight 전체 50건. **08:58:40 시작 요청이 08:58:52 reload 를 관통**해 845361바이트 전량 수신 |
|
||||
| `12-certbot-state.txt` | `cert2.pem` **09-04 17:22:13 기록됨** · `renewal-hooks/{deploy,post,pre}/` **셋 다 비었음** · 플러그인 목록에 nginx 없음 |
|
||||
| `13-verdict.txt` | **판정 전문** — 36분 39초 공백(428회 관측) + reload 무중단(8856건 0실패) |
|
||||
| `13-verdict.txt` | **판정 전문** — 38분 25초 공백(428회 관측) + reload 무중단(8856건 0실패) |
|
||||
|
||||
## 핵심 다섯 줄
|
||||
|
||||
1. **「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다.** 새 인증서가 디스크에 있는 채로 **36분 39초** 동안 옛 인증서를 서빙했고, 그 구간에서 **428번** 관측했다.
|
||||
1. **「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다.** 새 인증서가 디스크에 있는 채로 **38분 25초** 동안 옛 인증서를 서빙했고, 그 구간에서 **428번** 관측했다.
|
||||
2. **그 36분은 우연히 짧았다.** reload 를 시킨 것은 사람이다. 아무도 안 했다면 다음 nginx 재시작까지 무기한이었다.
|
||||
3. **원인이 셋 겹쳤다.** 유닛에 `ExecStartPost` 없음 · 훅 디렉터리 3개 전부 비었음 · certbot 에 nginx 플러그인 없음. 하나라도 있었으면 자동 반영됐다.
|
||||
4. **reload 자체는 무중단이었다.** 새 연결 8856건 전부 200, p95 205.7 → 204.3ms, 그리고 **전송 12초째에 reload 를 맞은 42초 요청이 845361바이트를 온전히** 받았다(연결수 1).
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
D-4a — deploy 훅이 실제로 nginx 를 reload 하는가
|
||||
==================================================
|
||||
수집: 2026-09-04 12:27 UTC (실제) · test-server
|
||||
|
||||
D-4 가 남긴 것
|
||||
--------------
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||||
|
||||
D-4 는 처방을 적어두고 **검증하지 않았다.** 처방이 듣는지 모르는 채로
|
||||
"이렇게 고치면 된다"고 쓰는 것은 이 실험대가 계속 경계해 온 바로 그 실수다.
|
||||
|
||||
[주입] 훅 하나를 넣고 강제 갱신
|
||||
--------------------------------
|
||||
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
#!/bin/sh
|
||||
# D-4a: certbot deploy hook. 갱신이 실제로 일어났을 때만 실행된다.
|
||||
nginx -t && nginx -s reload
|
||||
|
||||
install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||
certbot renew --force-renewal
|
||||
|
||||
[결과] certbot 출력 — 훅이 실행됐다
|
||||
------------------------------------
|
||||
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||
Hook 'deploy-hook' ran with error output:
|
||||
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, …
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, …
|
||||
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||
|
||||
Congratulations, all renewals succeeded:
|
||||
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||
|
||||
★ 함정 — "Hook 'deploy-hook' ran with error output" 이라고 적혀 있지만
|
||||
실패가 아니다. nginx 가 types_hash 경고를 stderr 로 내보내서 certbot 이
|
||||
그것을 "error output" 으로 표시한 것뿐이다. 내용은 전부 성공이다:
|
||||
· nginx -t → "test is successful"
|
||||
· nginx -s reload → "signal process started"
|
||||
로그에서 'error' 를 grep 하면 성공한 훅을 실패로 오독하게 된다.
|
||||
|
||||
[검증 1] nginx 워커가 교체됐다
|
||||
-------------------------------
|
||||
전 (D-4a 기준선)
|
||||
585 1 ... Thu Sep 3 19:00:39 nginx: master process
|
||||
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
|
||||
|
||||
후
|
||||
585 1 95412 Thu Sep 3 19:00:39 nginx: master process ← 마스터는 그대로
|
||||
37252 585 74 Fri Sep 4 21:29:36 nginx: worker process ← ★ 자동 교체됨
|
||||
|
||||
마스터 PID 는 유지되고 워커만 바뀌었다 = reload 의 서명. D-4 에서 세운
|
||||
진단 방법이 그대로 작동한다.
|
||||
|
||||
[검증 2] 서빙 인증서가 바뀌었다
|
||||
--------------------------------
|
||||
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||
notBefore=Sep 4 11:29:18 2026 GMT
|
||||
notAfter=Dec 3 11:29:17 2026 GMT
|
||||
SAN: app1 / app2 / auth .hyeonworks.com
|
||||
|
||||
D-4 의 인증서(06C7CB…EA1D)에서 바뀌었다.
|
||||
|
||||
[검증 3] ★ 얼마나 빨랐나 — 시계 보정이 필요하다
|
||||
--------------------------------------------------
|
||||
훅 로그와 워커 lstart 는 **test-server 시계**다. 그런데 test-server 는
|
||||
NTP 동기가 꺼져 있다(NTPSynchronized=no).
|
||||
|
||||
외부 기준으로 두 시계를 검증했다.
|
||||
dev → Google 차이 +0초
|
||||
dev → Let's Encrypt ACME 차이 +0초
|
||||
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
|
||||
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
|
||||
|
||||
→ **dev 가 정확하고 test-server 가 106초 빠르다.**
|
||||
|
||||
독립 검증 — 새 인증서의 SCT (CT 로그가 서명한 시각, 자체 정확 시계):
|
||||
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||||
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||||
|
||||
보정한 타임라인 (전부 실제 UTC):
|
||||
12:27:49.05 인증서 발급 ← SCT. 외부 권위 기준
|
||||
12:27:50 훅 nginx -t ← 로그 21:29:36 KST − 106초
|
||||
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST − 106초
|
||||
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST − 106초
|
||||
|
||||
★ 발급 → 서빙까지 **1~2초.**
|
||||
★ 그리고 보정이 자기 검증된다 — 독립 시계인 SCT 가 보정한 훅 시각의
|
||||
1초 앞에 정확히 놓인다. 보정하지 않으면 훅이 발급보다 104초 먼저
|
||||
실행된 것이 되어 물리적으로 불가능해진다.
|
||||
|
||||
════ D-4 와의 대조 ════
|
||||
|
||||
훅 없음 (D-4) 훅 있음 (D-4a)
|
||||
갱신 → 서빙 2305초 = 38분 25초 1~2초
|
||||
무엇이 reload 했나 사람 (`nginx -s reload`) certbot deploy 훅
|
||||
안 했다면 다음 nginx 재시작까지 해당 없음
|
||||
= 사실상 무기한
|
||||
차이 약 1150배
|
||||
|
||||
════ 부수 정정 — D-4 의 2199초는 틀렸다 ════
|
||||
|
||||
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||||
(test-server 시계)과 일련번호 관측 시각(dev 시계)을 **그대로 뺀** 값이었다.
|
||||
106초 보정을 적용하면:
|
||||
|
||||
새 인증서 디스크 기록 08:20:27 UTC (실제) ← mtime 17:22:13 KST − 106초
|
||||
실제 서빙 시작 08:58:52 UTC (실제) ← dev 관측, 보정 불필요
|
||||
공백 2305초 = 38분 25초
|
||||
|
||||
관련 문서를 전부 정정했다. **두 시계에서 온 값을 빼면서 그 사실을 적지
|
||||
않으면, 자릿수가 아니라 방향까지 틀릴 수 있다** — 여기서는 그 오차가
|
||||
100초 단위였고, 1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.
|
||||
|
||||
════ 결론 ════
|
||||
|
||||
1. **D-4 의 처방은 듣는다.** 훅 파일 하나로 발급 → 서빙이 38분에서 1~2초가 됐다.
|
||||
2. **certbot 출력의 "ran with error output" 을 실패로 읽으면 안 된다.**
|
||||
nginx 의 경고가 stderr 로 나갔을 뿐이며, 내용은 전부 성공이다.
|
||||
3. **진단 방법이 재확인됐다** — 마스터 PID 유지 + 워커 PID 교체 = reload.
|
||||
4. **시계를 섞어 빼면 안 된다.** test-server 는 NTP 가 꺼져 106초 빠르고,
|
||||
그 사실을 적지 않은 채 계산한 D-4 의 공백은 106초 짧았다.
|
||||
@@ -0,0 +1,17 @@
|
||||
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||
Hook 'deploy-hook' ran with error output:
|
||||
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Congratulations, all renewals succeeded:
|
||||
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
@@ -0,0 +1,13 @@
|
||||
=== 실행 후 상태 (dev 머신에서 관측) ===
|
||||
수집 2026-09-04T12:29:05Z UTC
|
||||
|
||||
-- nginx 프로세스
|
||||
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||||
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
|
||||
|
||||
-- 서빙 인증서
|
||||
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||
notBefore=Sep 4 11:29:18 2026 GMT
|
||||
notAfter=Dec 3 11:29:17 2026 GMT
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
|
||||
@@ -0,0 +1,17 @@
|
||||
# D-4a — deploy 훅 검증 증거
|
||||
|
||||
2026-09-04 12:27 UTC (실제 시각)
|
||||
해설: [`docs/experiment-d4a-deploy-hook.md`](../../experiment-d4a-deploy-hook.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-hook-verified.txt` | **판정 전문.** 훅 실행 로그 · 워커 PID 교체 · 시계 보정과 SCT 교차검증 · D-4 와의 대조 |
|
||||
| `02-certbot-with-hook.txt` | `certbot renew --force-renewal` 원문. `Hook 'deploy-hook' ran` 과 `all renewals succeeded` |
|
||||
| `03-after-state.txt` | 실행 후 nginx 프로세스와 서빙 인증서 |
|
||||
|
||||
## 핵심 네 줄
|
||||
|
||||
1. **D-4 의 처방은 듣는다.** 훅 파일 하나로 발급 → 서빙이 **38분 25초에서 1~2초**가 됐다. 약 1150배.
|
||||
2. **`Hook 'deploy-hook' ran with error output` 을 실패로 읽으면 안 된다.** nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 전부 성공이다 — `test is successful`, `signal process started`. 로그에서 `error` 를 grep 하면 성공한 훅을 실패로 오독한다.
|
||||
3. **진단 방법이 재확인됐다.** 마스터 585 는 그대로, 워커만 28829 → **37252** 로 교체. D-4 에서 세운 「마스터 유지 + 워커 교체 = reload」가 그대로 작동한다.
|
||||
4. **★ 시계를 섞어 빼면 안 된다.** test-server 는 NTP 가 꺼져 **106초 빠르다**(외부 기준 3회 측정). 이 사실을 적지 않고 계산한 D-4 의 공백은 106초 짧았고, 1~2초를 재는 D-4a 에서는 보정 없이는 **훅이 발급보다 104초 먼저 실행된 것**이 되어 물리적으로 불가능해진다. 보정은 새 인증서 SCT(`12:27:49.054 GMT`, CT 로그의 독립 시계)로 교차검증된다.
|
||||
@@ -16,6 +16,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 물음 | 답 |
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 예측 | 결과 |
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
```
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| | 4a 워커 상실 | 4b 컨트롤 플레인 상실 |
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 물음 | 답 |
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 측정 | 값 |
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
|
||||
**세 개가 정반대로 뒤집혔다.** 예측한 그대로다.
|
||||
|
||||
> **★ 이 표에는 조건이 빠져 있다 (A-7a).** volatile 행의 A-2 결과는
|
||||
> **캐시 온도에 따라 달라진다.** 완전 냉시동이면 로그인도 `400` 이고,
|
||||
> 캐시가 완전히 더우면 refresh 도 `200` 이다. 여기 적힌 값은 그 사이의
|
||||
> 한 상태다 — 셋 다 [A-7a](experiment-a7a-volatile-cause.md) 에서 재현했다.
|
||||
|
||||
> **"세션 공유는 7800 을 안 탄다"는 A-1 의 결론은 버전에 달린 사실이다.**
|
||||
> 인터넷 자료 대부분이 24 이전 기준이므로 **거기서는 통념이 맞다.**
|
||||
> 틀린 것은 자료가 아니라 **버전을 확인하지 않고 적용하는 것**이다.
|
||||
@@ -164,6 +169,13 @@ partitioned roll out complete: 2 new pods have been updated...
|
||||
|
||||
### refresh 가 500 인 이유 — 가설
|
||||
|
||||
> **★ 정정 (A-7a)** — 이 가설은 **틀렸다.** 문장 로깅으로 확정한 결과
|
||||
> 원인은 `REVOKED_TOKEN` 이 아니라 **`CLIENT_SCOPE_CLIENT` 조회**였다.
|
||||
> 더 중요하게는 **위 표 자체가 캐시 온도에 따라 400/500/200 으로 갈린다.**
|
||||
> [A-7a 문서](experiment-a7a-volatile-cause.md) 참조.
|
||||
|
||||
아래는 정정 전 원문이다 — 무엇을 어떻게 잘못 짚었는지 남긴다.
|
||||
|
||||
**측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
||||
`REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
|
||||
확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||
@@ -174,7 +186,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c
|
||||
```
|
||||
|
||||
**확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.** 여기서는
|
||||
**가설로 남긴다.**
|
||||
**가설로 남긴다.** → **A-7a 에서 그대로 실행해 확정했고, 가설은 틀렸다.**
|
||||
|
||||
> **volatile 이 "DB 없이 돌아간다"는 뜻은 아니다.**
|
||||
> realm·사용자·클라이언트·취소 토큰은 **여전히 DB 에 있다.**
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# A-7a — volatile 에서 refresh 가 500 인 진짜 이유
|
||||
|
||||
브랜치 `feature/keycloak-a7a-volatile-cause` ·
|
||||
증거 [`docs/evidence/a7a-volatile-cause/`](evidence/a7a-volatile-cause/) ·
|
||||
2026-09-04 20:18–20:24 KST
|
||||
|
||||
A-7 이 **가설로 남긴 것**을 확정하는 실험이다. 확정해 보니 가설이 틀렸고,
|
||||
더 중요하게는 **A-7 의 표 자체가 조건부였다**는 것이 드러났다.
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 확인 | 결과 |
|
||||
|---|---|
|
||||
| A-7 의 가설 (`REVOKED_TOKEN`) | **틀렸다.** 로그에 한 번도 나오지 않는다 |
|
||||
| **진짜 원인** | **`CLIENT_SCOPE_CLIENT` 조회** — 선택적 클라이언트 스코프 |
|
||||
| 로그인이 쏘는 SQL | **0개** |
|
||||
| refresh 가 쏘는 SQL | **1개, 그리고 첫 번째만.** 이후 캐시된다 |
|
||||
| **★ A-7 의 표** | **캐시 온도에 따라 400/500/200 으로 갈린다.** 셋 다 재현했다 |
|
||||
|
||||
---
|
||||
|
||||
## 1. A-7 이 무엇을 남겼나
|
||||
|
||||
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
||||
> `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
|
||||
> 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||
> **확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.**
|
||||
|
||||
그 지시대로 했다.
|
||||
|
||||
### 개념 — PostgreSQL 문장 로깅
|
||||
|
||||
**무엇인가.** `log_statement = 'all'` 을 켜면 서버가 받은 모든 SQL 을 로그에
|
||||
찍는다. 애플리케이션을 고치지 않고 **"이 요청이 DB 를 어떻게 쓰는지"** 를
|
||||
밖에서 볼 수 있다.
|
||||
|
||||
**왜 여기 나오나.** "refresh 가 어느 테이블 때문에 실패하는가"는 추측으로
|
||||
답할 문제가 아니다. Keycloak 내부를 읽는 대신 **DB 가 실제로 받은 문장**을
|
||||
보면 된다. A-3 에서 RPO 를 잴 때 쓴 것과 같은 기법이다.
|
||||
|
||||
**없거나 틀리면.** 여기서 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한
|
||||
테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.
|
||||
|
||||
**확인.**
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "ALTER SYSTEM SET log_statement = 'all';" -c "SELECT pg_reload_conf();"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "show log_statement" # all 이면 켜졌다
|
||||
```
|
||||
|
||||
### 표식으로 구간을 나눈다
|
||||
|
||||
로그는 JGroups 의 `JGROUPS_PING` 폴링으로 계속 채워진다. 어느 문장이
|
||||
로그인이고 어느 것이 refresh 인지 가르려면 **경계를 찍어야 한다.**
|
||||
|
||||
```bash
|
||||
psql -tAc "select 'MARK_LOGIN_START'" # 이 문장 자체가 로그에 남는다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 측정 — 로그인은 DB 를 안 본다
|
||||
|
||||
```
|
||||
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||
↑ 사이에 아무것도 없다
|
||||
```
|
||||
|
||||
realm·사용자·클라이언트가 전부 Infinispan 캐시에 있고, volatile 이라
|
||||
세션 쓰기도 없다. **DB 없이 완결된다** — A-7 이 적은 그대로다.
|
||||
|
||||
## 3. ★ refresh 는 한 문장을 쏜다 — 그리고 그게 아니다
|
||||
|
||||
```
|
||||
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||
11:18:52.137 statement: BEGIN
|
||||
11:18:52.137 execute <unnamed>/C_107:
|
||||
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||
11:18:52.148 execute S_2: COMMIT
|
||||
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||
```
|
||||
|
||||
**`REVOKED_TOKEN` 은 한 번도 나오지 않는다.**
|
||||
|
||||
### 개념 — `DEFAULT_SCOPE='f'` 가 무슨 뜻인가
|
||||
|
||||
**무엇인가.** Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
|
||||
|
||||
| | 뜻 | `DEFAULT_SCOPE` |
|
||||
|---|---|---|
|
||||
| default scope | 항상 붙는다 | `t` |
|
||||
| **optional scope** | **요청이 `scope=` 로 달라고 해야 붙는다** | **`f`** |
|
||||
|
||||
**왜 여기 나오나.** refresh 는 새 access token 을 만든다. 그 토큰에 어떤
|
||||
스코프를 담을지 정하려면 **"이 클라이언트가 요청 가능한 optional 스코프가
|
||||
무엇인가"** 를 알아야 한다. 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다.
|
||||
로그인 때는 이미 결정된 것을 쓰지만, refresh 는 다시 계산한다.
|
||||
|
||||
**없거나 틀리면.** 이 조회가 실패하면 토큰을 만들 수 없어 **500** 이다.
|
||||
`400 Session not active` 와 달리 **세션 문제가 아니다** — 그래서 A-7 이
|
||||
세션 계열 테이블(`REVOKED_TOKEN`)을 의심한 것이 자연스러웠지만 틀렸다.
|
||||
|
||||
## 4. 그 조회는 한 번뿐이다
|
||||
|
||||
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||
|
||||
```
|
||||
statement: select 'MARK_R1'
|
||||
statement: select 'MARK_R2'
|
||||
statement: select 'MARK_R3'
|
||||
statement: select 'MARK_R_END'
|
||||
↑ SQL 0건
|
||||
```
|
||||
|
||||
**첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지 않는다.**
|
||||
여기서 A-7 의 표가 흔들리기 시작한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. ★ 그래서 A-7 의 표는 조건부였다
|
||||
|
||||
같은 설정에서 **캐시 온도만으로 결과가 셋으로 갈린다.** 전부 재현했다.
|
||||
|
||||
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|
||||
|---|---|---|---|
|
||||
| **완전 냉시동** (재시작 직후) | **400** | 400 | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
|
||||
| **CLIENT 만 더움** ← A-7 이 본 것 | 200 | **500** | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
|
||||
| **완전히 더움** | 200 | **200** | 없음 (SQL 0건) |
|
||||
|
||||
### 재현 A — 완전 냉시동이면 로그인부터 400
|
||||
|
||||
```
|
||||
keycloak 재시작 → postgres 정지 → 로그인
|
||||
400 {"error":"unauthorized_client",
|
||||
"error_description":"Unexpected error when authenticating client"}
|
||||
|
||||
ERROR KC-SERVICES0015: Unexpected error when authenticating client:
|
||||
JDBC exception executing SQL [FATAL: terminating connection …]
|
||||
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||
```
|
||||
|
||||
**A-7 은 "volatile 이면 DB 없이 로그인된다"고 적었다. 냉시동에서는 아니다.**
|
||||
클라이언트 조회조차 캐시에 없기 때문이다.
|
||||
|
||||
### 재현 B — A-7 이 본 그 조건
|
||||
|
||||
```
|
||||
keycloak 재시작 → (DB 살아있을 때) 로그인 1회 → postgres 정지 → refresh
|
||||
로그인 200
|
||||
refresh 500 {"error":"unknown_error"}
|
||||
|
||||
JDBC exception executing SQL [FATAL: terminating connection …]
|
||||
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||
```
|
||||
|
||||
**로그가 실패한 SQL 을 직접 지목한다.** 가설이 아니라 확정이다.
|
||||
|
||||
### 재현 C — 완전히 더우면 둘 다 200
|
||||
|
||||
```
|
||||
refresh 3회로 캐시를 채운 뒤 postgres 정지
|
||||
로그인 200
|
||||
refresh 200 ← A-7 의 표와 정반대
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 이 실험이 A층에 남기는 것
|
||||
|
||||
1. **원인 확정** — `REVOKED_TOKEN` 이 아니라 **선택적 클라이언트 스코프
|
||||
조회**다. A-7 의 가설은 틀렸고, 이제 로그가 지목하는 문장이 있다.
|
||||
|
||||
2. **A-7 의 표에 조건을 붙여야 한다.** 「volatile + DB 정지 → 로그인 200,
|
||||
refresh 500」은 **캐시가 반쯤 더울 때만** 참이다.
|
||||
|
||||
3. **일반화 — 이런 종류는 한 번 재고 표로 적으면 안 된다.**
|
||||
volatile 에서 DB 정지 시의 동작은 "무엇을 하느냐"가 아니라
|
||||
**"그 경로가 이미 캐시를 채웠느냐"** 로 결정된다. 그래서 같은 명령이
|
||||
재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다.
|
||||
|
||||
> 이것은 A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과
|
||||
> 같은 계열의 함정이다. 상태가 결과를 바꾸는데 그 상태가 안 보인다.
|
||||
|
||||
4. **persistent(기본값)에는 해당하지 않는다.** 세션 자체를 DB 에 쓰므로
|
||||
DB 가 없으면 캐시 온도와 무관하게 실패한다. **이 조건부성은 volatile
|
||||
고유의 성질**이고, 옛 방식이 "DB 의존이 적다"고 말할 때 놓치는 부분이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 재현 절차 (명령어)
|
||||
|
||||
```bash
|
||||
# ── 1. 문장 로깅을 켠다
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "ALTER SYSTEM SET log_statement = 'all';" -c "SELECT pg_reload_conf();"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "show log_statement" # all 이어야 한다
|
||||
|
||||
# ── 2. volatile 로 전환
|
||||
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
|
||||
"value":["start","--features-disabled=persistent-user-sessions"]}]'
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||
TE=/realms/master/protocol/openid-connect/token
|
||||
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --command -- sleep 3600
|
||||
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||
|
||||
# ── 3. 표식을 넣어가며 로그인 → refresh
|
||||
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
|
||||
|
||||
m LOGIN_START
|
||||
R=$(kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
"curl -s -X POST http://$K0:8080$TE -d grant_type=password \
|
||||
-d client_id=admin-cli -d username=admin -d password=$PW")
|
||||
m LOGIN_END
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
m REFRESH_START
|
||||
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
"curl -s -o /dev/null -w '%{http_code}\n' -X POST http://$K0:8080$TE \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d refresh_token='$RT'"
|
||||
m REFRESH_END
|
||||
|
||||
# ── 4. 표식 사이의 SQL 만 뽑는다
|
||||
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl -n keycloak-lab logs $PG --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
|
||||
# ── 5. 세 가지 캐시 상태를 각각 재현한다
|
||||
# A. 완전 냉시동 — 재시작 직후 곧바로 DB 정지
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
kubectl -n keycloak-lab scale deploy/postgres --replicas=0
|
||||
# → 로그인 400
|
||||
|
||||
# B. CLIENT 만 더움 — 재시작 후 로그인 1회, refresh 는 하지 않고 DB 정지
|
||||
kubectl -n keycloak-lab scale deploy/postgres --replicas=1
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
# (로그인 1회 실행)
|
||||
kubectl -n keycloak-lab scale deploy/postgres --replicas=0
|
||||
# → 로그인 200 · refresh 500
|
||||
|
||||
# C. 완전히 더움 — refresh 를 3회 미리 돌린 뒤 DB 정지
|
||||
# → 로그인 200 · refresh 200
|
||||
|
||||
# ── 6. 실패 지점은 로그가 직접 말해준다
|
||||
kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||
|
||||
# ── 7. 복구 — 잊으면 다음 실험이 전부 오염된다
|
||||
kubectl -n keycloak-lab scale deploy/postgres --replicas=1
|
||||
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "ALTER SYSTEM RESET log_statement;" -c "SELECT pg_reload_conf();"
|
||||
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
|
||||
kubectl -n keycloak-lab delete pod a7a-probe
|
||||
# 확인: DB 에 세션 행이 다시 생기면 persistent 로 돌아온 것이다
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 증거 파일
|
||||
|
||||
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||
|---|---|---|
|
||||
| [`01-cause-determined.txt`](evidence/a7a-volatile-cause/01-cause-determined.txt) | 터미널 | 문장 로그 원문 · 세 캐시 상태의 재현 · 실패 SQL 을 지목하는 Keycloak 로그 |
|
||||
|
||||
파일별 상세는 [`evidence/a7a-volatile-cause/README.md`](evidence/a7a-volatile-cause/README.md).
|
||||
@@ -9,6 +9,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 확인 | 결과 |
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
```
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.**
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 물음 | 답 |
|
||||
@@ -172,7 +181,7 @@ kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
|---|---|
|
||||
| 옛 쿠키 | **검증 실패** — `session ticket cookie failed validation` |
|
||||
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
|
||||
| **서버 쪽 세션** | **★ 지우지 못했다** |
|
||||
| **서버 쪽 세션** | **★ 지우지 못했다** → **B-7a 에서 이어받았다** |
|
||||
|
||||
### 고아 세션이 남는다
|
||||
|
||||
@@ -196,6 +205,13 @@ _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있
|
||||
|
||||
**로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다.
|
||||
|
||||
> **★ 이어짐 (B-7a)** — 여기서 멈춘 세 물음을 [B-7a](experiment-b7a-orphan-session.md) 가 잰다.
|
||||
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||
> · TTL 은 요청으로 갱신되지 않아 고아는 **생성 후 정확히 1시간**에 사라진다
|
||||
> · `redis-cli del` 로 지워도 산 세션은 `200` — 운영자는 지울 수 있다
|
||||
> · 다만 **Redis 값으로는 고아를 못 고른다.** 이름·타입·크기(3510바이트)가 같고 값은 암호화됨
|
||||
> · **TTL 로 생성 시각을 역산**하면 1초 오차로 골라낼 수 있다 — 회전 시각 이전 생성분이 전부 고아다
|
||||
|
||||
---
|
||||
|
||||
## 5. Q1 미지수 7 에 대한 답
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# B-7a — 고아 세션은 지울 수 있는가
|
||||
|
||||
브랜치 `feature/keycloak-b7a-orphan-session` ·
|
||||
증거 [`docs/evidence/b7a-orphan-session/`](evidence/b7a-orphan-session/) ·
|
||||
2026-09-04 20:29–20:34 KST
|
||||
|
||||
B-7 이 **「★ 지우지 못했다」** 로 남긴 자리를 잰다.
|
||||
결론부터 — **oauth2-proxy 가 못 지우는 것이지, 지울 수 없는 것이 아니다.**
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 물음 | 답 |
|
||||
|---|---|
|
||||
| 고아는 정말 사라지는가 | **사라진다.** 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 |
|
||||
| 운영자가 지울 수 있는가 | **지울 수 있다.** `redis-cli del` 로 지워도 산 세션은 `200` |
|
||||
| **어느 것이 고아인지 아는가** | **Redis 값으로는 모른다.** 이름·타입·크기가 같고 값은 암호화됨 |
|
||||
| **그럼 어떻게 고르는가** | **★ TTL 로 생성 시각을 역산한다.** 1초 오차로 맞는다 |
|
||||
| 회전할 때마다 누적하는가 | **누적한다.** 회전 1회 = 그 시점 로그인 사용자 수 |
|
||||
|
||||
---
|
||||
|
||||
## 1. B-7 이 어디서 멈췄나
|
||||
|
||||
```
|
||||
[stored_session.go:97] Error removing session:
|
||||
error decoding ticket to clear session: session ticket cookie failed validation
|
||||
```
|
||||
|
||||
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||
|
||||
### 개념 — 티켓과 키의 관계
|
||||
|
||||
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 세션 전체가 아니라
|
||||
**티켓(ticket)** 만 담긴다. 티켓은 두 부분이다.
|
||||
|
||||
```
|
||||
티켓 = <세션 ID>.<암호화 키>
|
||||
│ └─ 값을 복호화할 키
|
||||
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
|
||||
```
|
||||
|
||||
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 서명·암호화되어 있다.
|
||||
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
|
||||
값을 못 읽는 게 아니라 **어느 키를 지워야 하는지를 모른다.**
|
||||
|
||||
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 "이 세션은 못 쓴다"까지는
|
||||
알지만 "그 세션이 Redis 어디에 있다"를 모른다. 그래서 `removing session` 을
|
||||
시도하고 실패한다.
|
||||
|
||||
**확인.**
|
||||
```bash
|
||||
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 재현 — 회전이 고아를 만드는 순간
|
||||
|
||||
### 기준선 (11:29:42 UTC)
|
||||
|
||||
```
|
||||
secret = COOKIE_SECRET_A
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
type=string ttl=3568초 크기=3510바이트
|
||||
dbsize=1
|
||||
설정: --cookie-expire=1h --session-store-type=redis
|
||||
```
|
||||
|
||||
### 주입 — 1차 회전 A → B (11:29:56 UTC)
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_B"}]'
|
||||
```
|
||||
|
||||
**회전 직후 Redis 는 그대로 1개다.** 회전 자체는 아무 일도 일으키지 않는다 —
|
||||
**누군가 옛 쿠키를 들고 오는 순간**에 비로소 벌어진다.
|
||||
|
||||
브라우저가 접근한 11:30:27:
|
||||
|
||||
```
|
||||
[stored_session.go:94] Error loading cookied session: … removing session
|
||||
[stored_session.go:97] Error removing session: error decoding ticket to clear session
|
||||
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com …}
|
||||
```
|
||||
|
||||
```
|
||||
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||
```
|
||||
|
||||
Keycloak SSO 가 살아 있어 **로그인 화면 없이** 통과했다 — B-7 의 관찰 그대로다.
|
||||
|
||||
---
|
||||
|
||||
## 3. ★ Redis 만 보고는 구분할 수 없다
|
||||
|
||||
| | 새 세션 | 고아 |
|
||||
|---|---|---|
|
||||
| 이름 | `_oauth2_proxy-87faa1c9…` | `_oauth2_proxy-f6a9201f…` |
|
||||
| type | `string` | `string` |
|
||||
| **크기** | **3510바이트** | **3510바이트** |
|
||||
| 값 | `\xcb\xb3h\xfa\x98\xedc\xe4@<…` | `N\xf5\x0e=\xe1N\xfc|\xa2qE\xde…` |
|
||||
|
||||
**바이트 단위로 크기가 같다.** 이름 뒤쪽은 불투명한 32자 hex 이고 사용자도
|
||||
시각도 상태도 담지 않는다. 값은 암호화되어 있어 뜻을 읽을 수 없다.
|
||||
|
||||
> **다른 것은 TTL 하나뿐이다.** 이것이 4절의 열쇠가 된다.
|
||||
|
||||
---
|
||||
|
||||
## 4. TTL 은 정직하고, 갱신되지 않는다
|
||||
|
||||
30초 간격 3회:
|
||||
|
||||
| | 새 세션 | 고아 |
|
||||
|---|---|---|
|
||||
| t+00초 | 3557 | 3479 |
|
||||
| t+30초 | 3526 | 3448 |
|
||||
| t+60초 | 3494 | 3417 |
|
||||
|
||||
1초에 1초씩. **고아는 생성 후 정확히 1시간에 사라진다.**
|
||||
|
||||
요청을 두 번 보낸 뒤에도 산 세션의 TTL 은 `3464` 로 계속 줄었다.
|
||||
기동 로그의 `refresh:disabled` 와 일치한다 — `--cookie-refresh` 가 없다.
|
||||
|
||||
### 개념 — TTL 갱신 여부가 왜 중요한가
|
||||
|
||||
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
|
||||
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
|
||||
|
||||
**왜 여기 나오나.** TTL 이 고정이면 **TTL 은 생성 시각의 정확한 함수**다.
|
||||
|
||||
```
|
||||
생성시각 = 지금 - (cookie-expire - TTL)
|
||||
```
|
||||
|
||||
이 한 줄이 5절의 정리 규칙 전체를 만든다.
|
||||
|
||||
**없거나 틀리면.** `--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.
|
||||
그때는 고아를 골라낼 수단이 사라지고, 회전 후 `FLUSHDB` 로 전부 지워
|
||||
모두 재인증시키는 편이 오히려 정직하다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 운영자는 지울 수 있다
|
||||
|
||||
```bash
|
||||
redis-cli del _oauth2_proxy-f6a9201f… # 반환 1
|
||||
```
|
||||
|
||||
```
|
||||
dbsize 2 → 1
|
||||
남은 키: _oauth2_proxy-87faa1c9…
|
||||
```
|
||||
|
||||
삭제 직후 브라우저 요청:
|
||||
|
||||
```
|
||||
app2.hyeonworks.com GET - "/oauth2/userinfo" … labuser@example.com 200 108
|
||||
```
|
||||
|
||||
**200. 산 세션은 다치지 않는다.**
|
||||
|
||||

|
||||
|
||||
> **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. 운영자는 키를 직접 안다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 누적한다 — 회전할 때마다
|
||||
|
||||
2차 회전 B → A (11:33:27) 후 브라우저 재접근:
|
||||
|
||||
| 키 | TTL | 생성시각(역산) | 판정 |
|
||||
|---|---|---|---|
|
||||
| `_oauth2_proxy-dad9c9fb…` | 3581 | 11:33:54 | 살아있음 |
|
||||
| `_oauth2_proxy-87faa1c9…` | 3373 | 11:30:26 | **★ 고아** |
|
||||
|
||||
**1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다.**
|
||||
회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.
|
||||
|
||||
---
|
||||
|
||||
## 7. ★ 그래서 정리 규칙이 유도된다
|
||||
|
||||
TTL 이 갱신되지 않으므로(4절), 생성 시각을 역산할 수 있다.
|
||||
그 값이 **회전 시각보다 이르면 고아다** — 회전 이후에 만들어진 세션은
|
||||
새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.
|
||||
|
||||
**정확도 검증** — 역산 `11:30:26` 대 로그의 `AuthSuccess 11:30:27`.
|
||||
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +%s)
|
||||
ROT=<회전 시각 epoch> # date -u -d '2026-09-04 11:33:27' +%s
|
||||
EXP=3600 # --cookie-expire 를 초로
|
||||
|
||||
kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$K")
|
||||
C=$(( NOW - (EXP - T) ))
|
||||
[ $C -lt $ROT ] && kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
|
||||
done
|
||||
```
|
||||
|
||||
실제 실행: `삭제: _oauth2_proxy-87faa1c9…` · 남은 `dbsize=1`.
|
||||
**산 세션은 남고 고아만 사라졌다.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Q1 미지수 7 에 남기는 보완
|
||||
|
||||
B-7 은 **"고아가 남는다"** 까지 답했다. B-7a 가 덧붙이는 것:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 얼마나 남는가 | **1시간.** TTL 이 갱신되지 않으므로 무한정 쌓이지 않는다 |
|
||||
| 지울 수 있는가 | **있다.** 프록시가 못 할 뿐이다 |
|
||||
| 어떻게 고르는가 | **TTL 역산.** 회전 시각 이전 생성분이 전부 고아다 |
|
||||
| 전제 | **`--cookie-refresh` 를 켜면 이 규칙이 깨진다.** 그때는 `FLUSHDB` 가 정직하다 |
|
||||
|
||||
> **secret 회전의 진짜 비용은 "재로그인"이 아니라 "저장소에 남는 것"이다.**
|
||||
> 그리고 그 비용은 **저장소를 쿠키로 쓰면 0** 이다 — 지울 서버 상태가
|
||||
> 애초에 없기 때문이다. B-7 이 Redis 로 옮긴 대가가 여기서 청구된다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 재현 절차 (명령어)
|
||||
|
||||
```bash
|
||||
# ── 0. app2 를 oauth2-proxy 로 잠시 빌린다 (Grafana 를 되돌릴 것)
|
||||
kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
|
||||
kubectl -n observability delete ingress grafana
|
||||
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml # Ingress 포함
|
||||
|
||||
# ── 1. 로그인해서 세션을 만든다 (브라우저 필요 — 쿠키가 HttpOnly 다)
|
||||
# https://app2.hyeonworks.com/ → labuser / labpass
|
||||
|
||||
# ── 2. 기준선
|
||||
R() { kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
|
||||
done
|
||||
|
||||
# ── 3. 회전. ★ 회전 시각을 반드시 기록한다 — 7절 규칙이 이걸 쓴다
|
||||
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S))"
|
||||
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_B"}]'
|
||||
kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
|
||||
|
||||
# ── 4. 브라우저로 다시 접근 → 이때 고아가 생긴다
|
||||
# 로그에서 확인:
|
||||
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||
|
||||
# ── 5. TTL 이 갱신되지 않는지 확인 (요청을 보낸 뒤에도 줄어야 한다)
|
||||
for i in 1 2 3; do R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
printf "%s ttl=%s\n" "$K" "$(R ttl $K)"; done; sleep 30; done
|
||||
|
||||
# ── 6. 정리 — 회전 시각 이전 생성분이 고아다
|
||||
NOW=$(date -u +%s); EXP=3600
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||
if [ $C -lt $ROT ]; then echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"; fi
|
||||
done
|
||||
|
||||
# ── 7. 복구
|
||||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_A"}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 증거 파일
|
||||
|
||||
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||
|---|---|---|
|
||||
| [`01-orphan-lifecycle.txt`](evidence/b7a-orphan-session/01-orphan-lifecycle.txt) | 터미널 | 회전 2회 · TTL 추이 · 구분 불가 근거 · 삭제 후 `200` · 정리 규칙 검증 |
|
||||
| [`b7a-live-session-after-orphan-delete.png`](evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png) | 스크린샷 | 고아 삭제 직후 살아있는 세션의 응답 |
|
||||
|
||||
파일별 상세는 [`evidence/b7a-orphan-session/README.md`](evidence/b7a-orphan-session/README.md).
|
||||
@@ -8,6 +8,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 확인 | 결과 |
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 확인 | 결과 |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||

|
||||
|
||||
**핵심 발견** — 갱신은 성공했는데 36분 39초 동안 반영되지 않았다.
|
||||
**핵심 발견** — 갱신은 성공했는데 **38분 25초** 동안 반영되지 않았다.
|
||||
|
||||

|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| 인증서 구성 | **SAN 3개** (`auth`/`app1`/`app2`), 와일드카드 아님 |
|
||||
| 체인 완결성 | **정상.** `Verify return code: 0 (ok)`, 4단계 |
|
||||
| 갱신 자동화 | 타이머는 **동작한다.** 오늘 두 번 `status=0/SUCCESS` |
|
||||
| **★ 갱신 반영** | **안 된다.** 새 인증서가 디스크에 있는 채로 **36분 39초** 동안 옛 인증서를 서빙했다 |
|
||||
| **★ 갱신 반영** | **안 된다.** 새 인증서가 디스크에 있는 채로 **38분 25초** 동안 옛 인증서를 서빙했다 |
|
||||
| **★ reload 무중단** | **무중단이다.** 8856건 전부 200 · 전송 한가운데였던 42초 요청이 845361바이트 전량 수신 |
|
||||
|
||||
### 한 문장으로
|
||||
@@ -161,7 +161,15 @@ reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜
|
||||
|---|---|
|
||||
| 새 인증서 디스크 기록 | 08:22:13 |
|
||||
| 실제 서빙 시작 (`nginx -s reload`) | 08:58:52 |
|
||||
| **공백** | **2199초 = 36분 39초** |
|
||||
| **공백** | **2305초 = 38분 25초** (시계 보정 후 — 아래 참조) |
|
||||
|
||||
> **★ 시계 보정 (D-4a 에서 발견)** — 처음 적은 **2199초(36분 39초)** 는
|
||||
> `archive/` 파일 시각(test-server 시계)과 일련번호 관측(dev 시계)을 **그대로 뺀**
|
||||
> 값이었다. 외부 기준(Google · Let's Encrypt `Date` 헤더)으로 재보니
|
||||
> **dev 가 정확하고 test-server 가 106초 빠르다**(3회 측정 전부 +106.1초,
|
||||
> `NTPSynchronized=no`). 보정하면 **2305초 = 38분 25초**다.
|
||||
> 보정값은 D-4a 새 인증서의 SCT(`Sep 4 12:27:49.054 GMT`, CT 로그가 서명한
|
||||
> 독립 시계)가 보정한 훅 시각의 1초 앞에 놓이는 것으로 교차 검증된다.
|
||||
|
||||
그 사이 **428번** 옛 인증서로 관측됐다. 그리고 이 36분은 **우연히 짧았을
|
||||
뿐이다** — reload 를 시킨 것은 사람이지 자동화가 아니다. 아무도 안 했다면
|
||||
@@ -216,7 +224,13 @@ sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 reload 하게 된다.
|
||||
|
||||
**확인 방법** — 훅을 넣었다고 끝이 아니다. 다음 강제 갱신 때 `nginx` 워커
|
||||
PID 가 바뀌는지 봐야 한다. 이 실험이 그 확인 절차다.
|
||||
PID 가 바뀌는지 봐야 한다.
|
||||
|
||||
> **★ 검증됨 ([D-4a](experiment-d4a-deploy-hook.md))** — 이 처방을 실제로
|
||||
> 넣고 강제 갱신했다. 워커가 `28829 → 37252` 로 자동 교체됐고,
|
||||
> **발급에서 서빙까지 1~2초**였다. 다만 certbot 출력의
|
||||
> `Hook 'deploy-hook' ran with error output` 은 **실패가 아니다** —
|
||||
> nginx 의 경고가 stderr 로 나갔을 뿐이다. 판정은 문구가 아니라 워커 PID 로 한다.
|
||||
|
||||
### 4-3. reload 는 무중단이다 — 측정됨
|
||||
|
||||
@@ -260,8 +274,8 @@ in-flight 전체 50건 중 종료코드 ≠ 0 은 **0건**이다.
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 약 58일 뒤에야 조건이 성립한다 |
|
||||
| deploy 훅을 넣으면 자동 반영되는가 | **미측정.** 훅은 아직 넣지 않았다 |
|
||||
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 약 89일 뒤에야 조건이 성립한다(갱신으로 만료일이 밀렸다) |
|
||||
| deploy 훅을 넣으면 자동 반영되는가 | **측정 완료 →** [D-4a](experiment-d4a-deploy-hook.md). **듣는다** — 발급에서 서빙까지 **1~2초**(여기 38분 25초의 약 1150분의 1) |
|
||||
|
||||
---
|
||||
|
||||
@@ -280,7 +294,7 @@ in-flight 전체 50건 중 종료코드 ≠ 0 은 **0건**이다.
|
||||
| [`10-reload-poll-window.txt`](evidence/d4-certificate-renewal/10-reload-poll-window.txt) | 터미널 | reload 전후 60초 새 연결 원문 (비200 0건) |
|
||||
| [`11-inflight-full.txt`](evidence/d4-certificate-renewal/11-inflight-full.txt) | 터미널 | in-flight 전체 — reload 를 관통한 08:58:40 요청 포함 |
|
||||
| [`12-certbot-state.txt`](evidence/d4-certificate-renewal/12-certbot-state.txt) | 터미널 | `certbot certificates` · `archive/` mtime · **훅 3개 디렉터리 전부 비었음** |
|
||||
| [`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt) | 터미널 | **판정** — 36분 39초 공백 + reload 무중단, 수치 전량 |
|
||||
| [`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt) | 터미널 | **판정** — 38분 25초 공백 + reload 무중단, 수치 전량 |
|
||||
|
||||
파일별 상세는 [`evidence/d4-certificate-renewal/README.md`](evidence/d4-certificate-renewal/README.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# D-4a — deploy 훅은 정말 듣는가
|
||||
|
||||
브랜치 `feature/keycloak-d4a-deploy-hook` ·
|
||||
증거 [`docs/evidence/d4a-deploy-hook/`](evidence/d4a-deploy-hook/) ·
|
||||
2026-09-04 12:27 UTC (실제 시각)
|
||||
|
||||
D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
|
||||
처방이 듣는지 모르는 채 "이렇게 고치면 된다"고 쓰는 것은,
|
||||
이 실험대가 스물세 번 경계해 온 바로 그 실수다.
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 확인 | 결과 |
|
||||
|---|---|
|
||||
| 훅이 실행되는가 | **된다.** certbot 이 `Hook 'deploy-hook' ran` 을 찍는다 |
|
||||
| nginx 가 reload 되는가 | **된다.** 마스터 585 유지, 워커 28829 → **37252** |
|
||||
| **얼마나 빠른가** | **발급 → 서빙 1~2초** (D-4 는 38분 25초) |
|
||||
| 함정 | **`ran with error output` 은 실패가 아니다** |
|
||||
| **★ 부수 발견** | **test-server 시계가 106초 빠르다.** D-4 의 공백 수치를 정정했다 |
|
||||
|
||||
---
|
||||
|
||||
## 1. D-4 가 무엇을 남겼나
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||||
|
||||
D-4 는 원인을 셋으로 특정했고(유닛에 `ExecStartPost` 없음 · 훅 3경로 비었음 ·
|
||||
nginx 플러그인 없음) 처방을 적었다. **그 처방을 실행해 본 적이 없다.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 주입 — 파일 하나
|
||||
|
||||
```bash
|
||||
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
### 개념 — `deploy/` 와 `post/` 는 다르다
|
||||
|
||||
| 디렉터리 | 언제 실행되나 |
|
||||
|---|---|
|
||||
| `pre/` | 갱신 **시도** 전 |
|
||||
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
|
||||
| `post/` | 갱신 여부와 **무관하게** 매번 |
|
||||
|
||||
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 갱신이
|
||||
없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만
|
||||
갈아치우는 셈이다. `deploy/` 는 **`RENEWED_LINEAGE` 가 있을 때만** 돈다.
|
||||
|
||||
**없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다.
|
||||
그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다.
|
||||
|
||||
**확인.**
|
||||
```bash
|
||||
sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ # 비어 있으면 아무도 안 부른다
|
||||
```
|
||||
|
||||
### 왜 `nginx -t &&` 를 앞에 두는가
|
||||
|
||||
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 새 워커를 못 띄운다.
|
||||
`-t` 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 **옛 워커가 그대로
|
||||
서비스를 계속한다** — 인증서는 안 바뀌지만 서비스는 죽지 않는다.
|
||||
|
||||
### 실행
|
||||
|
||||
```bash
|
||||
install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||
certbot renew --force-renewal
|
||||
```
|
||||
|
||||
> **이 스크립트는 실험 자동화가 아니라 시험 대상 자체다.**
|
||||
> certbot 은 훅을 파일로만 받는다. 절차는 전부 명령어로 되어 있다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 결과 — certbot 이 훅을 실행했다
|
||||
|
||||
```
|
||||
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||
Hook 'deploy-hook' ran with error output:
|
||||
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, …
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, …
|
||||
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||
|
||||
Congratulations, all renewals succeeded:
|
||||
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||
```
|
||||
|
||||
### ★ 함정 — "ran with error output" 은 실패가 아니다
|
||||
|
||||
certbot 은 훅이 **stderr 에 무엇이라도 쓰면** 이 문구를 붙인다.
|
||||
여기 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고, 내용은 전부 성공이다.
|
||||
|
||||
| 줄 | 실제 의미 |
|
||||
|---|---|
|
||||
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. 갱신과 무관 |
|
||||
| `nginx: … test is successful` | **`nginx -t` 통과** |
|
||||
| `[notice] signal process started` | **`nginx -s reload` 가 신호를 보냄** |
|
||||
|
||||
> 로그에서 `error` 를 grep 하는 감시를 걸어두면 **성공한 훅을 실패로 오독한다.**
|
||||
> 실패를 확인하려면 문구가 아니라 **워커 PID 를 봐야 한다.**
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 — 워커가 교체됐다
|
||||
|
||||
| | PID | 기동 시각 (test-server 시계) |
|
||||
|---|---|---|
|
||||
| 마스터 (전·후 동일) | **585** | Thu Sep 3 19:00:39 |
|
||||
| 워커 (전) | 28829 | Fri Sep 4 18:00:35 ← D-4 에서 **사람이** reload |
|
||||
| **워커 (후)** | **37252** | Fri Sep 4 21:29:36 ← **훅이 자동으로** |
|
||||
|
||||
**마스터 PID 는 유지되고 워커만 바뀌었다.** D-4 에서 "reload 되었는가"를
|
||||
판정하려고 세운 방법이 그대로 작동한다.
|
||||
|
||||
서빙 인증서도 바뀌었다.
|
||||
|
||||
```
|
||||
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||
notBefore=Sep 4 11:29:18 2026 GMT
|
||||
notAfter=Dec 3 11:29:17 2026 GMT
|
||||
SAN: app1 / app2 / auth .hyeonworks.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. ★ 얼마나 빨랐나 — 그리고 시계 문제
|
||||
|
||||
훅 로그와 워커 `lstart` 는 **test-server 시계**다. 그런데 그 시계는
|
||||
NTP 동기가 꺼져 있다.
|
||||
|
||||
```
|
||||
dev → Google +0초
|
||||
dev → Let's Encrypt ACME +0초
|
||||
test-server → Google −105초 (test-server 가 빠르다)
|
||||
ssh 왕복 3회 측정 +106.1 / +106.1 / +106.1초
|
||||
```
|
||||
|
||||
**dev 가 정확하고 test-server 가 106초 빠르다.**
|
||||
|
||||
### 독립 검증 — SCT
|
||||
|
||||
새 인증서에 박힌 Certificate Transparency 서명 시각은 **CT 로그의 자체
|
||||
시계**로 찍힌다. 양쪽 어느 것과도 무관한 제3의 기준이다.
|
||||
|
||||
```
|
||||
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||||
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||||
```
|
||||
|
||||
보정한 타임라인 (전부 실제 UTC):
|
||||
|
||||
```
|
||||
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
|
||||
12:27:50 훅 nginx -t ← 로그 21:29:36 KST − 106초
|
||||
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST − 106초
|
||||
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST − 106초
|
||||
```
|
||||
|
||||
**발급에서 서빙까지 1~2초.**
|
||||
|
||||
그리고 **보정이 자기 검증된다** — 독립 시계인 SCT 가 보정한 훅 시각의 1초
|
||||
앞에 정확히 놓인다. 보정하지 않으면 훅이 발급보다 **104초 먼저** 실행된 것이
|
||||
되어 물리적으로 불가능해진다.
|
||||
|
||||
### 부수 정정 — D-4 의 2199초는 틀렸다
|
||||
|
||||
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||||
(test-server 시계)과 일련번호 관측(dev 시계)을 **그대로 뺀** 값이었다.
|
||||
|
||||
| | 시각 (실제 UTC) |
|
||||
|---|---|
|
||||
| 새 인증서 디스크 기록 | **08:20:27** ← mtime 17:22:13 KST − 106초 |
|
||||
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
|
||||
| **공백** | **2305초 = 38분 25초** |
|
||||
|
||||
관련 문서를 전부 정정했다.
|
||||
|
||||
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라
|
||||
> 방향까지 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만,
|
||||
> 1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 대조
|
||||
|
||||
| | 훅 없음 (D-4) | **훅 있음 (D-4a)** |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** |
|
||||
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | certbot deploy 훅 |
|
||||
| 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 |
|
||||
| 차이 | | **약 1150배** |
|
||||
|
||||
---
|
||||
|
||||
## 7. 남는 것
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 |
|
||||
|
||||
훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는
|
||||
시간이 지나야 시험할 수 있다. 다만 그 경로도 같은 `certbot renew` 를 부르고
|
||||
같은 `deploy/` 훅을 실행하므로, 남은 미지수는 "타이머가 뜨는가" 하나다 —
|
||||
그리고 그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
|
||||
|
||||
---
|
||||
|
||||
## 8. 재현 절차 (명령어)
|
||||
|
||||
```bash
|
||||
# ── 0. 기준선 — 이 워커 PID 가 바뀌는지가 판정이다
|
||||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -dates
|
||||
|
||||
# ── 1. ★ 시계 왜곡을 먼저 잰다. 나중에 재면 값을 해석할 수 없다
|
||||
for i in 1 2 3; do
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
python3 -c "print(f'왜곡 {$B-($A+$C)/2:+.1f}초')"
|
||||
done
|
||||
# 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
for H in https://www.google.com https://acme-v02.api.letsencrypt.org/directory; do
|
||||
A=$(date -u +%s)
|
||||
D=$(curl -sI --max-time 10 "$H" | grep -i '^date:' | sed 's/^[Dd]ate: *//' | tr -d '\r')
|
||||
C=$(date -u +%s)
|
||||
python3 -c "print(f'$H 차이 {$(date -u -d \"$D\" +%s)-($A+$C)//2:+d}초')"
|
||||
done
|
||||
|
||||
# ── 2. 훅을 sudo 없는 곳에 미리 만들어 둔다 (사람이 칠 명령을 짧게)
|
||||
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
|
||||
|
||||
# ── 3. 주입 — 여기만 sudo 가 필요하다
|
||||
ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
|
||||
|
||||
# ── 4. 판정 — 문구가 아니라 워커 PID 로 본다
|
||||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||
# 마스터 PID 그대로 + 워커 PID 바뀜 = reload 됨
|
||||
|
||||
# ── 5. 얼마나 빨랐나 — SCT 가 발급 시각의 외부 기준이다
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||
-servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||||
# 훅 로그 시각에서 왜곡을 빼고 SCT 와 비교한다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 증거 파일
|
||||
|
||||
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||
|---|---|---|
|
||||
| [`01-hook-verified.txt`](evidence/d4a-deploy-hook/01-hook-verified.txt) | 터미널 | 판정 전문 · 시계 보정과 SCT 교차검증 · D-4 대조 |
|
||||
| [`02-certbot-with-hook.txt`](evidence/d4a-deploy-hook/02-certbot-with-hook.txt) | 터미널 | `certbot renew --force-renewal` 원문 |
|
||||
| [`03-after-state.txt`](evidence/d4a-deploy-hook/03-after-state.txt) | 터미널 | 실행 후 nginx 프로세스 · 서빙 인증서 |
|
||||
|
||||
파일별 상세는 [`evidence/d4a-deploy-hook/README.md`](evidence/d4a-deploy-hook/README.md).
|
||||
@@ -10,6 +10,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||
이 문서는 세 실험의 후속이므로 각자의 구조도를 참조한다.
|
||||
|
||||
| 항목 | 구조도 |
|
||||
|---|---|
|
||||
| D-2 정방향 업그레이드 | [`d2-version-upgrade.svg`](diagrams/d2-version-upgrade.svg) |
|
||||
| B-4 ③ role 반영 | [`b4-header-forgery.svg`](diagrams/b4-header-forgery.svg) |
|
||||
| D-4 강제 갱신 | [`d4-renewal-gap.svg`](diagrams/d4-renewal-gap.svg) · [`d4a-hook-timeline.svg`](diagrams/d4a-hook-timeline.svg) |
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 항목 | 결과 |
|
||||
@@ -18,7 +30,7 @@
|
||||
| **그리고 D-2 의 결론이 정밀해졌다** | **"롤백 불가" 는 조건부다** — 스키마가 바뀌었을 때만 |
|
||||
| **B-4 ③ role 변경 반영 시점** | **요청 횟수와 무관하다.** 세션이 새로 만들어져야 한다 |
|
||||
| **B층에 Grafana 증거가 없는 이유** | **관측 대상에 없다.** 안 찍은 것이 아니다 |
|
||||
| **D-4 강제 갱신** | **★ 갱신은 성공했는데 36분 39초 동안 반영되지 않았다** |
|
||||
| **D-4 강제 갱신** | **★ 갱신은 성공했는데 38분 25초 동안 반영되지 않았다** |
|
||||
| **D-4 reload 무중단** | **무중단이다.** 8856건 전부 200 · 전송 한가운데였던 요청이 전량 수신 |
|
||||
|
||||
---
|
||||
@@ -303,7 +315,7 @@ nginx 설정을 읽으려던 시도가 계속 빈 결과였고, 그게 sudo 의
|
||||
|---|---|
|
||||
| 새 인증서 디스크 기록 | 08:22:13 |
|
||||
| 실제 서빙 시작 (`nginx -s reload`) | 08:58:52 |
|
||||
| **공백** | **2199초 = 36분 39초** (그 사이 428회 관측) |
|
||||
| **공백** | **2305초 = 38분 25초** (그 사이 428회 관측 · 시계 보정 후) |
|
||||
|
||||
그리고 그 36분은 **우연히 짧았을 뿐이다** — reload 를 시킨 것은 사람이지
|
||||
자동화가 아니다. 원인이 셋 겹쳤고 **전부 비어 있었다.**
|
||||
|
||||
@@ -26,8 +26,11 @@
|
||||
| **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** |
|
||||
| **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** |
|
||||
| **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** |
|
||||
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | **갱신은 됐는데 36분 39초 반영 안 됨** (훅 3경로 전부 비었음). reload 자체는 **무중단**(8856건 0실패) |
|
||||
| **후속** | 미측정 3항목 채우기 | `...followup-untested-items` | **셋 다 완료.** 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 · **D-4 갱신 36분 39초 미반영** |
|
||||
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | **갱신은 됐는데 38분 25초 반영 안 됨** (훅 3경로 전부 비었음). reload 자체는 **무중단**(8856건 0실패) |
|
||||
| **A-7a** | volatile refresh 500 원인 확정 | `...a7a-volatile-cause` | **가설(`REVOKED_TOKEN`)은 틀렸다** — `CLIENT_SCOPE_CLIENT` 조회다. **A-7 의 표는 캐시 온도에 따라 400/500/200** |
|
||||
| **B-7a** | 고아 세션 정리 | `...b7a-orphan-session` | **지울 수 있다** — 프록시가 못 할 뿐. TTL 역산으로 고아만 **1초 오차**로 골라낸다 |
|
||||
| **D-4a** | deploy 훅 검증 | `...d4a-deploy-hook` | **처방이 듣는다** — 발급→서빙 **1~2초**(D-4 는 38분 25초). test-server 시계가 **106초 빨라** D-4 수치를 정정 |
|
||||
| **후속** | 미측정 3항목 채우기 | `...followup-untested-items` | **셋 다 완료.** 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 · **D-4 갱신 38분 25초 미반영** |
|
||||
|
||||
## 시각 자료
|
||||
|
||||
@@ -38,7 +41,14 @@
|
||||
| `diagrams/*.svg` | 실험별 구조도 12개 |
|
||||
| `evidence/*/*.png` | Grafana · 브라우저 스크린샷 |
|
||||
|
||||
**스크린샷이 없는 실험은 11개다** — `A-3 A-7 B-3 B-4 B-5 B-6 C-2 D-1 D-3 D-4 후속`.
|
||||
**스크린샷이 없는 실험은 14개다** — `A-3 A-7 A-7a B-3 B-4 B-5 B-6 C-2 D-1 D-3 D-4 D-4a 후속 멀티노드`.
|
||||
없는 이유는 **관측 대상에 그 지표가 없기 때문**이지 안 찍어서가 아니다 —
|
||||
Prometheus 는 keycloak·kubelet·node-exporter·prometheus 만 긁는다
|
||||
([`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt)).
|
||||
터미널·DB·로그로 측정한 실험은 브라우저에 띄울 화면 자체가 없다.
|
||||
|
||||
**구조도(SVG)는 28개이고, 실험 문서 29개 중 28개가 하나 이상을 싣는다.**
|
||||
싣지 않는 하나는 후속 요약 문서이며, 세 실험의 구조도를 표로 참조한다.
|
||||
그중 B층은 **Prometheus 가 Redis·BFF·PostgreSQL 을 긁지 않아** 만들 수가 없다 —
|
||||
[`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt) 에 측정해 두었다.
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# 00 — lab host 준비
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.
|
||||
|
||||
## 전제
|
||||
|
||||
물리 기계 한 대. 이 실험대는 Arch Linux 를 썼지만 배포판은 상관없다 —
|
||||
패키지 이름만 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 1. CPU 가상화가 켜져 있는가
|
||||
|
||||
BIOS 에서 꺼져 있으면 아무것도 못 한다. 먼저 본다.
|
||||
|
||||
**확인** — CPU 가 하드웨어 가상화 확장을 내놓고 있는가
|
||||
```bash
|
||||
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD)
|
||||
중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 다음
|
||||
단계로 간다. 빈 출력은 「CPU 가 못 한다」가 아니라 대개 **BIOS 에서 꺼져
|
||||
있다**는 뜻이다 — 재부팅해 Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서
|
||||
막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
|
||||
|
||||
**실측** — 이 실험대의 호스트는 16 코어 전부에서 지원한다.
|
||||
|
||||
## 2. KVM 모듈이 올라와 있는가
|
||||
|
||||
**확인** — 커널이 그 확장을 실제로 잡고 있는가
|
||||
```bash
|
||||
lsmod | grep kvm
|
||||
```
|
||||
|
||||
**형태** (이 실험대에서 캡처해 두지 않았다 — 줄 모양만)
|
||||
```
|
||||
kvm_intel ...
|
||||
kvm ...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈
|
||||
(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm` 이 **둘 다** 있어야 한다. 셋째 열은
|
||||
이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 인 것이 정상이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고
|
||||
`virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 1번의
|
||||
BIOS 설정이 커널까지 안 넘어온 것이다 — `sudo modprobe kvm_intel` 로 직접
|
||||
올려 보면 거부 사유가 그대로 나온다. 아무것도 없으면 1번으로 돌아간다.
|
||||
|
||||
> **왜 이걸 먼저 보나.** KVM 없이도 QEMU 는 돌지만 **소프트웨어 에뮬레이션**이
|
||||
> 되어 수십 배 느리다. VM 두 대가 「뜨긴 뜨는데 느리다」면 대개 여기다.
|
||||
|
||||
## 3. 패키지 설치
|
||||
|
||||
**하기** (Arch)
|
||||
```bash
|
||||
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
|
||||
```
|
||||
|
||||
Debian/Ubuntu 면 이름이 다르다.
|
||||
```bash
|
||||
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
|
||||
```
|
||||
|
||||
| 무엇 | 하는 일 |
|
||||
|---|---|
|
||||
| qemu | 실제로 가상 기계를 돌리는 것 |
|
||||
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
|
||||
| virt-install | VM 을 만드는 명령 |
|
||||
| dnsmasq | 가상 네트워크의 DHCP·DNS |
|
||||
|
||||
**확인** — 두 실행 파일이 PATH 에 들어왔는가
|
||||
```bash
|
||||
virsh --version
|
||||
qemu-system-x86_64 --version
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 판 번호 두 줄. 명령을 못 찾는다(`command not found`)면
|
||||
패키지가 안 깔린 것이고, 번호가 나오면 깔린 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을
|
||||
가른다. 이 실험대는 **libvirt 12.7.0 · QEMU emulator version 11.1.1** 이었다
|
||||
(문서 끝 실측값 블록). 훨씬 낮은 판이면 `virt-install --cloud-init` 같은
|
||||
옵션의 동작이 다를 수 있으니, 01 에서 막힐 때 이 번호를 같이 본다.
|
||||
|
||||
## 4. libvirt 를 띄우고 권한을 받는다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo systemctl enable --now libvirtd.socket
|
||||
sudo usermod -aG libvirt "$USER"
|
||||
```
|
||||
|
||||
그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로
|
||||
`usermod` 만으로는 지금 셸에 반영되지 않는다.
|
||||
|
||||
**확인** — 지금 이 셸이 libvirt 에 sudo 없이 붙는가
|
||||
```bash
|
||||
groups # libvirt 가 보여야 한다
|
||||
virsh list --all # sudo 없이 돌아야 한다
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
donghyeon libvirt wheel
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `groups` 출력에 `libvirt` 가 끼어 있는가, 그리고
|
||||
`virsh list --all` 이 **머리글만 있는 빈 표**라도 오류 없이 끝나는가. 아직
|
||||
VM 을 안 만들었으므로 표가 비어 있는 것이 정상이다 — 봐야 할 것은 표의
|
||||
내용이 아니라 명령이 통과했다는 사실이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있고,
|
||||
01 로 넘어가도 된다. `groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만
|
||||
**지금 로그인 세션이 옛 그룹 목록을 들고 있는** 것이다 — 로그아웃/로그인
|
||||
한다. `groups` 에는 있는데 `virsh` 가 `Permission denied` 면 그룹이 아니라
|
||||
소켓 문제이므로 `systemctl status libvirtd.socket` 을 본다.
|
||||
|
||||
> **`libvirtd.service` 가 아니라 `.socket` 을 켠 이유.** 소켓 활성화라서
|
||||
> 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄운다.
|
||||
> 자원을 아끼고, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
|
||||
|
||||
## 5. 연결 URI 를 고정한다
|
||||
|
||||
`virsh` 는 기본으로 `qemu:///session`(사용자 단위)에 붙는데, VM 은
|
||||
`qemu:///system`(시스템 단위)에 만들어야 한다. **이걸 안 맞추면 만든 VM 이
|
||||
안 보인다.**
|
||||
|
||||
**하기** — 셸 프로필에 넣는다
|
||||
```bash
|
||||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
|
||||
```
|
||||
|
||||
**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
|
||||
```bash
|
||||
virsh uri
|
||||
```
|
||||
```
|
||||
qemu:///system
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 끝의 한 낱말. `system` 인가 `session` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금
|
||||
`virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를
|
||||
보고 있어 **VM 은 만들어졌는데 `virsh list` 에 안 나오는** 상태가 된다.
|
||||
`.bashrc` 에 넣은 것은 **새로 여는 셸에만** 적용되므로, 지금 셸에서는
|
||||
`export LIBVIRT_DEFAULT_URI=qemu:///system` 을 한 번 더 치거나 새 셸을 연다.
|
||||
|
||||
## 6. 기본 네트워크
|
||||
|
||||
**확인** — VM 이 붙을 가상 네트워크가 살아 있는가
|
||||
```bash
|
||||
virsh net-list --all
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
Name State Autostart Persistent
|
||||
--------------------------------------------
|
||||
default active yes yes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `default` 행의 **State 와 Autostart 두 칸**.
|
||||
`--all` 을 준 이유가 여기 있다 — 빼면 `inactive` 인 네트워크는 아예 목록에
|
||||
안 나와서 「없음」과 「꺼짐」을 구분할 수 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — `active` + `yes` 면 지금도, 호스트를 재부팅한
|
||||
뒤에도 `virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도
|
||||
DHCP 가 없어 IP 를 못 받는다. Autostart 가 `no` 면 **지금은 되지만 호스트를
|
||||
재부팅한 다음 01 의 SSH 가 전부 실패**하고, 원인을 게스트에서 찾게 된다.
|
||||
둘 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
|
||||
|
||||
```bash
|
||||
virsh net-start default
|
||||
virsh net-autostart default
|
||||
```
|
||||
|
||||
이 네트워크가 `virbr0` 브리지와 `192.168.122.0/24` 대역을 만든다. VM 들이
|
||||
여기 붙는다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃/로그인 했는가. `groups` 에 libvirt 가 있는가 |
|
||||
| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` |
|
||||
| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS |
|
||||
| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 이 단계의 실측값
|
||||
|
||||
돌고 있는 실험대에서 그대로 읽은 것이다.
|
||||
|
||||
```
|
||||
libvirt 12.7.0
|
||||
qemu QEMU emulator version 11.1.1
|
||||
그룹 donghyeon libvirt wheel
|
||||
네트워크 default / active / autostart yes
|
||||
```
|
||||
@@ -0,0 +1,445 @@
|
||||
# 01 — VM 세 대
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가
|
||||
키로 붙는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다.
|
||||
|
||||
## 왜 VM 세 대인가
|
||||
|
||||
**k3s 노드 두 대** — 이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고
|
||||
요청이 어느 쪽으로 갈지 모른다」** 를 전제한다. 한 대면 세션 공유도 분단도
|
||||
노드 상실도 실험이 되지 않는다.
|
||||
|
||||
**엣지 한 대** — nginx·인증서·certbot 이 사는 곳이다. 이것을 물리 호스트에
|
||||
두면 **되돌릴 수가 없다.** 자주 고치고 자주 갈아엎는 층인데 물리 기계에
|
||||
쌓이기 때문이다. VM 이면 초기화가 `virsh undefine` 한 줄이고, 물리 호스트에는
|
||||
DNAT 규칙 하나와 DHCP 예약만 남는다. 자세한 이유는 [03](../03-nginx/) 의
|
||||
「왜 엣지가 물리 호스트가 아니라 VM 인가」에 있다.
|
||||
|
||||
그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를
|
||||
죽이고 DB 를 crash 시키는 것이 목적이라 망가뜨릴 수 있는 층이 필요하다.
|
||||
|
||||
| 게스트 | IP | MAC 끝 | 메모리 | 무엇이 도나 |
|
||||
|---|---|---|---|---|
|
||||
| `kc-lab-edge` | 192.168.122.10 | `:10` | 1024MB | nginx · certbot |
|
||||
| `kc-lab-1` | 192.168.122.11 | `:11` | 5120MB | k3s server · Traefik |
|
||||
| `kc-lab-2` | 192.168.122.12 | `:12` | 4096MB | k3s agent · Traefik |
|
||||
|
||||
---
|
||||
|
||||
## 1. base 이미지를 받는다
|
||||
|
||||
OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝난 디스크**이고,
|
||||
첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
cd /var/lib/libvirt/images
|
||||
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
|
||||
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||||
```
|
||||
|
||||
**확인** — 받은 파일이 온전한 qcow2 인가
|
||||
|
||||
```bash
|
||||
qemu-img info /var/lib/libvirt/images/base.qcow2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 줄이다. `file format:` 이 `qcow2` 인가(`raw` 로
|
||||
읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다),
|
||||
`virtual size:` 가 `disk size:` 보다 훨씬 큰가(qcow2 는 희소 파일이라 이게
|
||||
정상이다), `backing file:` 줄이 **없는가**. base 는 아무것도 뒤에 두지 않는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 셋이 맞으면 이 파일을 4번에서 오버레이의 바닥으로
|
||||
쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라
|
||||
**게스트가 부팅을 못 하는 것**으로 나타나 원인을 찾기 어려워진다.
|
||||
|
||||
## 2. cloud-init 을 쓴다
|
||||
|
||||
게스트마다 하나씩 만든다. 템플릿은
|
||||
[`deploy/lab/cloud-init/kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example).
|
||||
|
||||
```yaml
|
||||
#cloud-config
|
||||
hostname: kc-lab-1
|
||||
fqdn: kc-lab-1
|
||||
manage_etc_hosts: true
|
||||
|
||||
users:
|
||||
- name: donghyeon
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||
lock_passwd: false
|
||||
plain_text_passwd: __CONSOLE_PW__
|
||||
ssh_authorized_keys:
|
||||
- __LAB_HOST_KEY__
|
||||
- __WORKSTATION_KEY__
|
||||
|
||||
ssh_pwauth: false
|
||||
package_update: true
|
||||
packages: [curl, nftables]
|
||||
```
|
||||
|
||||
### 세 값을 어디서 가져오나
|
||||
|
||||
자리표시자 셋을 실제 값으로 바꿔야 한다. **찾는 명령이 있다.**
|
||||
|
||||
```bash
|
||||
# ① lab host 공개키 — 없으면 만든다
|
||||
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
|
||||
# ② 워크스테이션 공개키 — 워크스테이션에서
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
|
||||
# ③ 콘솔용 비밀번호 — 만들어서 보관한다
|
||||
openssl rand -base64 18
|
||||
```
|
||||
|
||||
셋을 넣는다.
|
||||
|
||||
```bash
|
||||
sed -i \
|
||||
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
|
||||
-e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
|
||||
-e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
|
||||
kc-lab-1.yaml
|
||||
```
|
||||
|
||||
**확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다
|
||||
|
||||
```bash
|
||||
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||||
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||||
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 두 개와 낱말 하나. 첫 줄이 `0` 이 아니면
|
||||
`__LAB_HOST_KEY__` 같은 문자열이 남아 있는 것이고, 둘째 줄이 `2` 가 아니면
|
||||
`sed` 치환 중 하나가 안 먹은 것이다. 셋째 줄은 `YAML OK` 가 찍히는가만 본다 —
|
||||
찍히지 않으면 대신 파이썬 예외 줄이 나오고, 거기 적힌 `line N` 이 문제의
|
||||
줄 번호다. 이 셋은 값을 뽑는 것이 아니라 **찍힌 숫자를 눈으로 비교하는**
|
||||
용도라 이 형태가 맞다.
|
||||
|
||||
**이 결과가 의미하는 것** — `0` / `2` / `YAML OK` 셋이 다 맞아야 시드를 만든다.
|
||||
어긋난 채로 3번을 진행하면 **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
|
||||
않으므로**, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는
|
||||
사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다.
|
||||
|
||||
> **`yamllint` 는 이 실험대에 없다.** 있으면 좋지만 없다고 설치하러 가지
|
||||
> 않는다 — 위 세 줄로 충분하다.
|
||||
|
||||
**게스트가 한 대라도 떠 있으면 한 단계 더 볼 수 있다.** YAML 로 파싱된다는
|
||||
것과 **cloud-config 로 유효하다**는 것은 다르다. 키 이름 오타(`user` vs
|
||||
`users`)는 위 검사를 그냥 통과한다. cloud-init 자신의 스키마 검사기가
|
||||
게스트에 들어 있다 — `kc-lab-2` 용 파일은 `kc-lab-1` 에서 검사할 수 있다.
|
||||
|
||||
```bash
|
||||
# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
|
||||
# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
|
||||
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||||
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||||
```
|
||||
|
||||
**실측** — 통과하면 이 한 줄이다.
|
||||
|
||||
```
|
||||
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고,
|
||||
아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
|
||||
경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다.
|
||||
|
||||
> **★ `sudo` 는 리스트가 아니라 문자열로 쓴다.** 이 실험대의 첫 두 게스트는
|
||||
> 이렇게 되어 있었는데, 게스트의 cloud-init 22.4.2 스키마 검사기가 거부한다.
|
||||
>
|
||||
> ```yaml
|
||||
> sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||||
> sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||||
> ```
|
||||
>
|
||||
> **실측** — 리스트 형태로 검사하면 이렇게 나온다.
|
||||
>
|
||||
> ```
|
||||
> Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
> ['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
> ```
|
||||
>
|
||||
> 어느 키가 문제인지 **안 알려 준다** — `users.0` 전체를 통째로 찍고
|
||||
> 「어느 스키마에도 안 맞는다」고만 한다. 그래서 키를 하나씩 바꿔 가며
|
||||
> 좁혀야 한다. 리스트 형태도 **부팅은 된다**(`kc-lab-1`·`kc-lab-2` 가 그
|
||||
> 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다). 검사기만 거부하는 것이라
|
||||
> 「검사는 실패했는데 왜 되지」로 헷갈리기 쉽다.
|
||||
|
||||
**이 결과가 의미하는 것** — 오류가 나면 그 키는 **조용히 무시된다.**
|
||||
`users` 를 `user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안
|
||||
붙는다」 하나뿐이다. 첫 게스트(`kc-lab-1`)를 만들 때는 검사할 게스트가 아직
|
||||
없으니 위 세 줄로 가고, 둘째부터는 이 검사를 거친다.
|
||||
|
||||
세 가지가 의도적이다.
|
||||
|
||||
| | 왜 |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
|
||||
| `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
|
||||
| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
|
||||
|
||||
> **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도
|
||||
> **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는
|
||||
> 것이 유일한 증상이다.
|
||||
|
||||
## 3. 시드 이미지를 만든다
|
||||
|
||||
cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는 **정확한
|
||||
이름**의 파일이 있는 볼륨을 찾는다.
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||||
|
||||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||||
|
||||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
**확인** — 볼륨이 풀에 올라갔고 비어 있지 않은가
|
||||
|
||||
```bash
|
||||
virsh vol-list default
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `seed-kc-lab-1.iso` 행이 목록에 있는가. 있으면
|
||||
크기까지 본다.
|
||||
|
||||
```bash
|
||||
virsh vol-info --pool default seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
**Capacity** 가 방금 만든 로컬 파일 크기(`stat -c%s seed-kc-lab-1.iso`)와
|
||||
같아야 한다.
|
||||
|
||||
**이 결과가 의미하는 것** — `vol-create-as` 는 **빈 볼륨을 만들 뿐**이고
|
||||
내용은 `vol-upload` 가 채운다. 두 명령 중 뒤엣것을 빠뜨리면 목록에는 이름이
|
||||
보이지만 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아
|
||||
조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. 크기가 맞으면 4번으로 간다.
|
||||
|
||||
`instance-id` 에 타임스탬프를 넣는 이유가 있다. cloud-init 은 **인스턴스마다
|
||||
한 번만** 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로,
|
||||
user-data 를 고쳐도 반영되지 않는다.
|
||||
|
||||
## 4. VM 을 만든다
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
나머지 둘은 **이름·메모리·MAC·시드**만 바꾼다.
|
||||
|
||||
```bash
|
||||
# kc-lab-2 — k3s agent
|
||||
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:12 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
|
||||
# kc-lab-edge — nginx + certbot 만 돌므로 훨씬 작아도 된다
|
||||
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
|
||||
--disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:10 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
**실측** — 엣지 생성 출력이다.
|
||||
|
||||
```
|
||||
Starting install...
|
||||
Allocating 'kc-lab-edge.qcow2' | 10 GB 00:00
|
||||
Creating domain... | 00:00
|
||||
Domain creation completed.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Domain creation completed.` 한 줄. 그 위
|
||||
`Allocating` 이 **즉시(00:00) 끝나는 것이 정상**이다 — 오버레이라 10GB 를
|
||||
실제로 쓰지 않는다.
|
||||
|
||||
**★ 시드를 `--cloud-init` 으로 붙이지 않는다.** 그 옵션은 시드를 SATA CD-ROM
|
||||
으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어
|
||||
드라이버를 뺐다. **AHCI 장치가 보이지 않아** cloud-init 이 데이터소스를
|
||||
못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다.
|
||||
|
||||
`--disk size=20,backing_store=...` 는 복사가 아니라 **오버레이**다. base 는
|
||||
읽기 전용으로 두고 변경분만 새 파일에 쌓이므로, 20GB 를 두 개 만들어도
|
||||
실제 디스크는 몇백 MB 만 쓴다.
|
||||
|
||||
## 5. DHCP 로 IP 를 고정한다
|
||||
|
||||
MAC 을 정해 두었으니 그 MAC 에 IP 를 예약한다.
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
virsh net-update default add ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
|
||||
--live --config
|
||||
```
|
||||
|
||||
`--live --config` 둘 다 준다. `--live` 만 주면 재부팅에 사라지고, `--config`
|
||||
만 주면 지금 반영되지 않는다.
|
||||
|
||||
**확인** — 예약이 실제로 들어갔는가
|
||||
|
||||
```bash
|
||||
virsh net-dumpxml default | grep ip-dhcp-host -A3
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
|
||||
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
|
||||
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
|
||||
```
|
||||
|
||||
**★ 예약을 먼저 넣고 VM 을 띄운다.** 순서가 반대면 게스트가 동적 대역에서
|
||||
아무 주소나 받아 버리고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야
|
||||
한다. 넣을 때 나오는 한 줄은 이것이다.
|
||||
|
||||
```
|
||||
Updated network default persistent config and live state
|
||||
```
|
||||
|
||||
`persistent config` 와 `live state` **두 마디가 다 나와야** `--live --config`
|
||||
가 제대로 먹은 것이다.
|
||||
|
||||
**어디를 봐야 하는가** — `<host>` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
|
||||
짝이 맞는가**(`:11` ↔ `.11`, `:12` ↔ `.12`). MAC 은 4번의 `virt-install --network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
|
||||
아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
|
||||
받는다. 한 줄만 보이면 `--live --config` 중 하나를 빠뜨린 것이다.
|
||||
`net-dumpxml` 은 **지금 돌고 있는 정의**를 보여 주므로 여기 보이는 것은
|
||||
`--live` 가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는
|
||||
`virsh net-dumpxml --inactive default` 로 따로 본다.
|
||||
|
||||
## 6. 붙어 본다
|
||||
|
||||
**확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가
|
||||
|
||||
```bash
|
||||
virsh list --all
|
||||
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
Id Name State
|
||||
-----------------------------
|
||||
2 kc-lab-1 running
|
||||
4 kc-lab-2 running
|
||||
5 kc-lab-edge running
|
||||
|
||||
kc-lab-1
|
||||
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||
```
|
||||
|
||||
엣지도 같은 방법으로 본다. `cloud-init status` 까지 한 번에 친다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 줄이다. 호스트명이 `kc-lab-edge` 인가, IP 가
|
||||
**예약한 `.10`** 인가, `cloud-init status` 가 `done` 인가. `running` 이면
|
||||
아직 패키지를 받는 중이니 기다린다 — 이 실험대에서는 **약 50초** 걸렸다.
|
||||
`error` 면 `cloud-init status --long` 으로 어느 모듈이 실패했는지 본다.
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지다. ① State 가 두 대 다 `running` 인가
|
||||
(`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가
|
||||
**비밀번호를 묻지 않고** 통과했는가. ③ `hostname` 이 `kc-lab-1` 인가
|
||||
`localhost` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다.
|
||||
호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면
|
||||
같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. **`localhost` 가 나오면 SSH
|
||||
설정을 고치지 말고 시드부터 의심한다** — 아래 「막히면」의 화면 캡처로 간다.
|
||||
Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일 뿐이다).
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
여기서 실제로 겪은 것들이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
|
||||
| SSH`Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 |
|
||||
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
|
||||
| IP 가 매번 바뀐다 | DHCP 예약이`--config` 없이 들어감 | `net-dumpxml` |
|
||||
| VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 |
|
||||
|
||||
게스트에 못 들어갈 때는 **화면을 직접 뜬다.**
|
||||
|
||||
```bash
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 이미지를 열어 **로그인 프롬프트 앞의 호스트명 한
|
||||
낱말**만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다.
|
||||
시드를 못 찾은 것(4번의 `bus=virtio`)이거나 YAML 파싱 실패(2번)이므로 SSH
|
||||
쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 **그 안의 사용자·키
|
||||
단계에서 틀린** 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.
|
||||
|
||||
```bash
|
||||
virsh console kc-lab-1 # 빠져나오려면 Ctrl+]
|
||||
# 게스트 안에서
|
||||
sudo cloud-init status --long
|
||||
sudo journalctl -u cloud-init -n 50
|
||||
```
|
||||
|
||||
콘솔 로그인에 쓸 비밀번호가 2번의 `plain_text_passwd` 다. **이 한 장과 이 두
|
||||
줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.**
|
||||
|
||||
---
|
||||
|
||||
## 실측값
|
||||
|
||||
```
|
||||
kc-lab-1 vCPU 2 메모리 5120MB 192.168.122.11
|
||||
kc-lab-2 vCPU 2 메모리 4096MB 192.168.122.12
|
||||
게스트 OS Debian GNU/Linux 12 (bookworm)
|
||||
```
|
||||
|
||||
> 메모리가 처음 만들 때(3584MB)와 다르다. 호스트가 12GB 뿐이라 실험을 늘리며
|
||||
> 재배분했다. VM 을 다시 만들지 않고 바꾸는 방법은
|
||||
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 13층에 있다.
|
||||
@@ -0,0 +1,407 @@
|
||||
# 02 — k3s 두 노드
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다.
|
||||
`sudo` 도 `ssh` 도 붙이지 않는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[01](../01-vms/) 이 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.
|
||||
|
||||
## 어디서 치는가
|
||||
|
||||
**이 단계는 전부 `[lab host]` 에서 친다.** 게스트에 로그인하지 않는다.
|
||||
자세한 이유는 [가이드 공통 규약](../README.md#어느-기계에서-치는가) 에 있고,
|
||||
요점만 옮기면 이렇다.
|
||||
|
||||
- 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다. 게스트 안에서
|
||||
`ssh kc-lab-1` 을 치면 `Host key verification failed` 로 끝난다.
|
||||
- 그 실패를 `TOKEN=$(...)` 로 감싸면 **오류는 화면으로 새고 변수는 빈 채로**
|
||||
남는다. 셸은 불평하지 않는다.
|
||||
- 그래서 3번의 토큰이 비고, 4번의 agent 설치가 `--token is required` 로
|
||||
죽는다. 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나기 때문에
|
||||
**설치 출력만 보면 성공으로 읽힌다.**
|
||||
|
||||
셸을 하나만 쓰면 이 문제가 통째로 없어진다.
|
||||
|
||||
---
|
||||
|
||||
## 1. server 를 깐다 (kc-lab-1)
|
||||
|
||||
**하기** — `[lab host]`
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
|
||||
```
|
||||
|
||||
게스트에 들어가지 않고 원격 실행한다. cloud-init 이 `NOPASSWD:ALL` 을
|
||||
넣어 두었으므로 비대화식 `sudo` 가 멈추지 않는다.
|
||||
|
||||
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
|
||||
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
|
||||
|
||||
**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` 에
|
||||
`kc-lab-1` 한 줄이 `Ready` 로 있는가. **설치 직후 30초 남짓은 `NotReady`
|
||||
이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온
|
||||
시간이다. 한 번 더 친다.
|
||||
|
||||
이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 것은 여기 한 번뿐이다.
|
||||
lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 것이 바로 2번이다.
|
||||
|
||||
**이 결과가 의미하는 것** — `active` + `Ready` 면 API 서버가 살아 있고
|
||||
kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데
|
||||
`get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이
|
||||
`activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니
|
||||
로그를 본다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
|
||||
```
|
||||
|
||||
## 2. lab host 에 kubeconfig 를 둔다
|
||||
|
||||
**왜 여기서 하나** — 이 뒤로 `kubectl` 을 계속 쓴다. 지금 한 번 해두면
|
||||
남은 단계에서 `ssh` 도 `sudo` 도 붙이지 않는다. **그리고 agent 노드에는
|
||||
kubeconfig 가 없으므로**(6번) 클러스터를 볼 자리를 먼저 정해 두는 편이 낫다.
|
||||
|
||||
**하기** — `[lab host]`
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.kube
|
||||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
```
|
||||
|
||||
세 줄 다 필요하다.
|
||||
|
||||
| 줄 | 빠뜨리면 |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
|
||||
| `sed` | k3s 가 쓴 주소는`https://127.0.0.1:6443` 이다. **게스트 안에서만 맞는 주소**라 lab host 에서는 자기 자신의 6443 을 두드리게 된다 |
|
||||
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
|
||||
|
||||
> `sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다. 리다이렉션은 셸이
|
||||
> **명령보다 먼저** 현재 사용자 권한으로 처리하기 때문이다. 그래서 출력
|
||||
> 파일은 홈 아래(`~/.kube/config`)에 둔다.
|
||||
|
||||
**확인** — 주소가 바뀌었고, 밖에서 붙는가
|
||||
|
||||
```bash
|
||||
grep server: ~/.kube/config
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
server: https://192.168.122.11:6443
|
||||
NAME STATUS ROLES AGE VERSION
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `server:` 값에 `127.0.0.1` 이 남아 있으면 `sed` 가
|
||||
안 먹은 것이다. 그다음 `get nodes` 가 **`sudo` 없이** 도는가. 아직 노드는
|
||||
한 줄뿐인 것이 정상이다 — agent 는 4번에서 붙인다.
|
||||
|
||||
**이 결과가 의미하는 것** — 통과하면 이 뒤의 `kubectl` 은 전부 lab host 에서
|
||||
친다. `x509` 오류가 나면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋난
|
||||
것이다 — k3s 를 다시 깔았다면 이 복사도 다시 해야 한다. `connection refused`
|
||||
면 주소는 맞는데 API 서버가 아직 안 뜬 것이다.
|
||||
|
||||
> **인증서 SAN 에 두 주소가 다 들어 있어서** 이 `sed` 가 통한다. 확인하려면
|
||||
> `ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'`
|
||||
> 을 친다. `IP Address:127.0.0.1` 과 `IP Address:192.168.122.11` 이 둘 다
|
||||
> 보인다. 8번의 SSH 터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 이유다.
|
||||
|
||||
## 3. 토큰을 꺼낸다
|
||||
|
||||
**하기** — `[lab host]`. 화면에 찍어 눈으로 옮기지 말고 변수로 받는다
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
**실측** — 이 실험대에서는 108자였다. (`K10<해시>::server:<비밀번호>` 형식이라
|
||||
k3s 판올림에 따라 자릿수가 달라진다. **중요한 것은 값이 아니라 `0` 이 아니라는
|
||||
것이다.**)
|
||||
|
||||
**어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지
|
||||
않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 세 자리 수가 나오면 토큰을 손에 쥔 것이니 4번으로
|
||||
넘어간다. `0` 이면 변수가 비었다는 뜻이고 원인은 셋 중 하나다.
|
||||
|
||||
| `0` 인 이유 | 확인 |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| **게스트 안에서 쳤다** (가장 흔하다) | 프롬프트가`kc-lab-1` 이면 `exit` 로 lab host 로 나온다 |
|
||||
| server 가 아직 안 떠서 파일이 없다 | `ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'` |
|
||||
| 새 셸을 열어 변수가 사라졌다 | `echo "${#TOKEN} 자"` 를 **4번을 칠 바로 그 셸에서** 다시 친다 |
|
||||
|
||||
> **4번을 3번과 같은 셸에서 친다.** 변수는 셸 밖으로 나가지 않는다. 창을
|
||||
> 새로 열거나 `ssh` 로 어딘가 들어갔다 나오면 `TOKEN` 은 없다.
|
||||
|
||||
## 4. agent 를 붙인다 (kc-lab-2)
|
||||
|
||||
**하기** — `[lab host]`. 3번과 **같은 셸**에서 친다
|
||||
|
||||
```bash
|
||||
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||||
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 \
|
||||
--token '$TOKEN' \
|
||||
--node-ip 192.168.122.12"
|
||||
```
|
||||
|
||||
첫 줄의 가드를 빼지 않는다. `$TOKEN` 이 비면 게스트에는
|
||||
`--token '' --node-ip ...` 가 전달되고, agent 는 기동 즉시
|
||||
`level=fatal msg="Error: --token is required"` 로 죽는다. **그런데 설치
|
||||
스크립트는 내려받기·유닛 생성·`enable` 까지 다 성공으로 찍고 끝나고**, 유닛은
|
||||
`Restart=always` 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.
|
||||
|
||||
> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지
|
||||
> 않으므로 3번의 「같은 셸」 제약도 없어진다.
|
||||
>
|
||||
> ```bash
|
||||
> ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||||
> | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||||
> ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
> --server https://192.168.122.11:6443 --token-file /tmp/token \
|
||||
> --node-ip 192.168.122.12; rm -f /tmp/token"
|
||||
> ```
|
||||
|
||||
**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION INTERNAL-IP
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
|
||||
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP
|
||||
두 개가 1번·4번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄
|
||||
`Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 **역할 라벨이
|
||||
없다**는 뜻이다 — agent 는 원래 그렇다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 줄이 `Ready` 이고 IP 가 맞으면 이 단계는 끝났다.
|
||||
IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 잡혀 있으면
|
||||
지금은 아무 증상이 없다가 **03 의 nginx upstream 과 A층의 노드 상실
|
||||
실험에서** 어긋난다 — 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2` 가
|
||||
아예 안 보이면 join 이 실패한 것이니 agent 쪽 로그를 본다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
|
||||
```
|
||||
|
||||
## 5. 유닛 이름이 다르다
|
||||
|
||||
| 노드 | 유닛 |
|
||||
| ------ | --------------------- |
|
||||
| server | `k3s.service` |
|
||||
| agent | `k3s-agent.service` |
|
||||
|
||||
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
|
||||
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
|
||||
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `ExecStart=` 줄의 **부분명령(`server`/`agent`)과
|
||||
그 뒤의 인자**. 설치 스크립트에 준 옵션이 여기 그대로 굳어 있다. 유닛 이름을
|
||||
틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데,
|
||||
그것 자체가 「이 노드는 agent 다」라는 답이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 4번의 `get nodes -o wide` 는 **k3s 가 보고한**
|
||||
IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다.
|
||||
그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을
|
||||
여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도
|
||||
일어나지 않고, 「주입했는데 증상이 없다」로 오독하게 된다.
|
||||
|
||||
> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가
|
||||
> 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
|
||||
|
||||
## 6. agent 노드에서는 `kubectl` 이 안 된다
|
||||
|
||||
`kc-lab-2` 에 들어가 `sudo kubectl get pods -A` 를 치면 이렇게 끝난다.
|
||||
|
||||
```
|
||||
Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused
|
||||
```
|
||||
|
||||
**`kubectl` 명령 자체는 있다.** 설치 스크립트가
|
||||
`/usr/local/bin/kubectl -> k3s` 심볼릭 링크를 만들기 때문이다. 없는 것은
|
||||
**붙을 곳을 알려 주는 파일**, 곧 kubeconfig 다.
|
||||
|
||||
| 어디를 찾나 | kc-lab-1 | kc-lab-2 |
|
||||
| ----------------------------- | -------------- | -------------- |
|
||||
| `$KUBECONFIG` | (비어 있음) | (비어 있음) |
|
||||
| `~/.kube/config` | 없음 | 없음 |
|
||||
| `/etc/rancher/k3s/k3s.yaml` | **있음** | **없음** |
|
||||
|
||||
넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값
|
||||
`http://localhost:8080` 으로 넘어간다. 쿠버네티스 1.20 이전 API 서버가
|
||||
평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다.
|
||||
|
||||
> **`localhost:8080` 이 보이면 네트워크 문제가 아니라 「설정을 하나도 못
|
||||
> 찾았다」는 뜻이다.** 이 주소는 어디에도 적혀 있지 않다. 방화벽이나 k3s 를
|
||||
> 의심하기 전에 kubeconfig 부터 본다.
|
||||
|
||||
**워커라서 파드가 안 보이는 것이 아니다.** kubectl 은 그냥 HTTP 클라이언트라
|
||||
어디서 실행하든 상관없다. `k3s.yaml` 을 kc-lab-2 로 복사해 넣으면 거기서도
|
||||
전부 보인다. **그래도 복사하지 않는다** — 워커 한 대가 털리면 클러스터 전체가
|
||||
털리는 구성이 된다. agent 가 가진 자격증명은 급이 다르다.
|
||||
|
||||
```
|
||||
subject=O = system:nodes, CN = system:node:kc-lab-2
|
||||
```
|
||||
|
||||
이 신원은 Node authorizer 와 NodeRestriction admission 이 **자기 노드에
|
||||
배정된 객체만** 다루도록 제한한다. 게다가 그 자격증명은 kubelet 전용 경로
|
||||
(`/var/lib/rancher/k3s/agent/`)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가
|
||||
「권한 없음(403)」이 아니라 「설정 없음(`localhost:8080`)」으로 나타난다.
|
||||
|
||||
**그래서 클러스터는 2번에서 만든 lab host 의 kubeconfig 로 본다.** 개념
|
||||
설명은 [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
|
||||
「agent 노드에는 kubeconfig 가 없다」에 있다.
|
||||
|
||||
## 7. k3s 가 기본으로 딸려 오는 것
|
||||
|
||||
따로 설치하지 않아도 이미 있다.
|
||||
|
||||
| | 무엇 |
|
||||
| ---------------------- | -------------------------------------------- |
|
||||
| Traefik | 인그레스 컨트롤러.`:80` 을 듣는다 |
|
||||
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
|
||||
| local-path | 기본 StorageClass.**노드 로컬 디스크** |
|
||||
| flannel | 파드 네트워크 (VXLAN) |
|
||||
| kube-router | NetworkPolicy 집행 |
|
||||
|
||||
**확인** — `[lab host]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
|
||||
|
||||
```bash
|
||||
kubectl get pods -A
|
||||
kubectl get storageclass
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
NAMESPACE NAME READY STATUS RESTARTS AGE
|
||||
kube-system coredns-54996dc9b4-x5bsz 1/1 Running 0 49m
|
||||
kube-system helm-install-traefik-cnv8z 0/1 Completed 2 (48m ago) 48m
|
||||
kube-system helm-install-traefik-crd-c6vft 0/1 Completed 0 48m
|
||||
kube-system local-path-provisioner-77b9867795-5k8d2 1/1 Running 0 49m
|
||||
kube-system metrics-server-6dc596dfb8-nzwt2 1/1 Running 0 49m
|
||||
kube-system svclb-traefik-a18ee1fc-2s9rl 2/2 Running 0 48m
|
||||
kube-system svclb-traefik-a18ee1fc-xmrrt 2/2 Running 0 22m
|
||||
kube-system traefik-59b7647586-rsrbc 1/1 Running 0 48m
|
||||
|
||||
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
|
||||
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 49m
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `get pods -A` 에서는 **NAMESPACE 열이 `kube-system`
|
||||
인 줄들의 STATUS**. `Running` 과 `Completed` 가 섞여 있는 것이 정상이다 —
|
||||
`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
|
||||
`svclb-traefik-*` 가 **두 줄**인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩
|
||||
뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다.
|
||||
`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고,
|
||||
뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의
|
||||
답이 전부 이 목록에 있다. `local-path` 에 `(default)` 가 붙어 있으면
|
||||
05 의 PVC 는 StorageClass 를 안 적어도 이것으로 만들어진다. `Pending` 이나
|
||||
`CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
|
||||
|
||||
> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진
|
||||
> 노드에 묶여** 다른 노드로 재배치되지 않는다.
|
||||
|
||||
## 8. 워크스테이션에서 쓰려면
|
||||
|
||||
**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에
|
||||
있어서 워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다.
|
||||
|
||||
```bash
|
||||
[워크스테이션] $ ping -c1 192.168.122.11
|
||||
1 packets transmitted, 0 received, 100% packet loss
|
||||
```
|
||||
|
||||
lab host 를 거치는 터널을 뚫는다.
|
||||
|
||||
**하기** — `[워크스테이션]`
|
||||
|
||||
```bash
|
||||
# 1) 터널. 이 창은 열어 둔다
|
||||
ssh -N -L 6443:192.168.122.11:6443 test-server
|
||||
|
||||
# 2) 다른 창에서 — 게스트 원본을 그대로 가져온다. sed 가 필요 없다
|
||||
mkdir -p ~/.kube
|
||||
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
|
||||
chmod 600 ~/.kube/kc-lab.yaml
|
||||
export KUBECONFIG=~/.kube/kc-lab.yaml
|
||||
```
|
||||
|
||||
**2번과 달리 `sed` 를 치지 않는다.** k3s 원본이 이미
|
||||
`https://127.0.0.1:6443` 이고, 터널 덕에 워크스테이션에서는 그 주소가 맞기
|
||||
때문이다. 2번이 `192.168.122.11` 로 바꿨던 것은 lab host 에서 볼 때
|
||||
`127.0.0.1` 이 lab host 자신을 가리키기 때문이었다. **같은 파일이라도 어느
|
||||
기계에서 읽느냐에 따라 맞는 주소가 다르다.**
|
||||
|
||||
**확인**
|
||||
|
||||
```bash
|
||||
grep server: ~/.kube/kc-lab.yaml
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `server:` 가 `https://127.0.0.1:6443` 인가. 그다음
|
||||
`get nodes` 가 4번과 **같은 두 줄**을 내놓는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 터널 덕에 워크스테이션의 6443 이 `kc-lab-1` 의
|
||||
6443 이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서(2번의 각주)
|
||||
인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고,
|
||||
`connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다.
|
||||
|
||||
> **터널이 닫히면 `kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를
|
||||
> 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기
|
||||
> 쉽다. **A층 실험은 lab host 에서 치는 것을 권한다.**
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `echo "${#TOKEN} 자"` 가 `0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가`kc-lab-1` 이 아닌지. 3번 표 |
|
||||
| agent 설치는 성공했는데 노드가 안 보임 | `--token` 이 빈 문자열 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` 에 `--token is required` |
|
||||
| agent 가`NotReady` | 토큰·주소 오타 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` |
|
||||
| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` |
|
||||
| lab host 에서`kubectl` 이 `No such file` | `mkdir -p ~/.kube` 를 빠뜨렸다 | 위 2번 |
|
||||
| lab host 에서`connection refused` | kubeconfig 의`127.0.0.1` 을 안 바꿨다 | `grep server: ~/.kube/config` |
|
||||
| `kc-lab-2` 에서 `localhost:8080` | agent 에는 kubeconfig 가 없다.**정상이다** | 위 6번 |
|
||||
| 워크스테이션에서 타임아웃 | 터널이 없다 | 위 8번 |
|
||||
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
|
||||
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
|
||||
@@ -0,0 +1,391 @@
|
||||
# 03 — 엣지 nginx 라우팅 (kc-lab-edge)
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
|
||||
아직 TLS 는 없다.
|
||||
|
||||
## 전제
|
||||
|
||||
[02](../02-k3s/) 가 끝나 두 노드가 `Ready` 이고, [01](../01-vms/) 에서
|
||||
`kc-lab-edge`(192.168.122.10) 까지 세 대가 떠 있다.
|
||||
|
||||
## 왜 프록시가 두 겹인가
|
||||
|
||||
nginx 와 Traefik 이 하는 일이 다르다.
|
||||
|
||||
| | 맡는 것 |
|
||||
|---|---|
|
||||
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
|
||||
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
|
||||
|
||||
**이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더
|
||||
실험이 성립하는 이유다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를
|
||||
재려면 두 겹이 있어야 한다.
|
||||
|
||||
## 왜 엣지가 물리 호스트가 아니라 VM 인가
|
||||
|
||||
**L7 홉 수는 그대로 2홉이다.** 늘어난 것은 커널이 하는 L4 전달 한 번뿐이다.
|
||||
|
||||
바뀐 것은 **더러워지는 층이 어디냐**다. nginx 설정 · 인증서 · certbot · deploy
|
||||
훅은 자주 고치고 자주 갈아엎는 것들인데, 그것이 물리 호스트에 있으면
|
||||
「깨끗하게 초기화하고 다시」가 불가능하다. 엣지가 VM 이면 초기화가
|
||||
`virsh undefine kc-lab-edge --remove-all-storage` 한 줄이 된다.
|
||||
|
||||
물리 호스트에 남는 실험대 설정은 **DNAT 규칙 하나와 DHCP 예약 세 줄**뿐이고,
|
||||
둘 다 한 번 쓰고 다시 안 건드린다.
|
||||
|
||||
덤으로 **엣지 장애를 실험할 수 있게 된다.** 엣지가 물리 호스트일 때는
|
||||
`systemctl stop nginx` 가 진입 경로(SSH)까지 위험하게 만들어서 A층 실험 9건
|
||||
어디에도 엣지 장애가 없었다. VM 이면 A-4 와 똑같이 `virsh destroy` 로 뽑는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 설정을 쓴다
|
||||
|
||||
원본은 [`deploy/lab/edge/nginx-keycloak-lab.conf`](../../../deploy/lab/edge/nginx-keycloak-lab.conf).
|
||||
|
||||
**하기** — `[kc-lab-edge]`. lab host 에서 원격 실행해도 된다.
|
||||
```bash
|
||||
sudo tee /etc/nginx/sites-available/keycloak-lab > /dev/null <<'EOF'
|
||||
upstream k3s_traefik {
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://k3s_traefik;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
> **인증서 경로는 아직 없다.** [04](../04-tls/) 에서 만든다. 그전까지는 443
|
||||
> 블록을 주석 처리하고 80 만 `proxy_pass` 로 두면 이 단계를 먼저 확인할 수 있다.
|
||||
|
||||
**엣지가 Debian 이라 `sites-available` 관례가 기본으로 있다.** 물리 호스트
|
||||
(Arch) 였을 때는 디렉터리를 직접 만들고 `nginx.conf` 에 include 를 넣어야
|
||||
했는데, 그 손질이 없어졌다. 운영도 Debian 계열이라 관례가 맞아떨어진다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-edge '
|
||||
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default'
|
||||
```
|
||||
|
||||
`default` 를 지우는 것을 빠뜨리지 않는다. Debian 기본 사이트가 `:80` 에
|
||||
`default_server` 로 붙어 있어서, 우리 설정의 `listen 80 default_server` 와
|
||||
**충돌해 `nginx -t` 가 실패한다.**
|
||||
|
||||
> **★ `http2 on;` 을 쓰지 않는다.** 그 지시어는 nginx 1.25.1 이상이다.
|
||||
>
|
||||
> ```
|
||||
> 엣지 (Debian 12): nginx version: nginx/1.22.1
|
||||
> 물리 호스트 (Arch): nginx version: nginx/1.30.4
|
||||
> ```
|
||||
>
|
||||
> 물리 호스트에서 쓰던 설정을 그대로 옮기면 **실측으로 이렇게 막힌다.**
|
||||
>
|
||||
> ```
|
||||
> [emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
|
||||
> nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
> ```
|
||||
>
|
||||
> `listen 443 ssl http2;` 형태를 쓴다 — 1.22 와 1.30 양쪽에서 다 돈다.
|
||||
|
||||
## 2. 문법을 보고 적용한다
|
||||
|
||||
**하기** — `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
**실측** — 04 를 아직 안 했다면 여기서 이렇게 막히는 것이 **정상**이다.
|
||||
설정은 맞는데 참조하는 파일이 아직 없는 것뿐이다.
|
||||
|
||||
```
|
||||
[emerg] 1431#1431: cannot load certificate
|
||||
"/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem": BIO_new_file() failed
|
||||
(SSL: ... No such file or directory ...)
|
||||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is
|
||||
ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는
|
||||
`[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이
|
||||
경고를 실패로 오독하는 일이 실제로 벌어지므로, 여기서 **경고와 오류를
|
||||
구분하는 눈**을 들여 둔다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍힌다.
|
||||
|
||||
**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고,
|
||||
`systemctl reload` 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면
|
||||
`&&` 가 reload 를 **막아 준 것**이고, 지금 돌고 있는 nginx 는 옛 설정 그대로
|
||||
멀쩡하다. 설정이 깨진 상태에서 reload 하면 새 워커를 못 띄운다 — `-t` 를
|
||||
먼저 통과시키고 그때만 reload 하는 이유다.
|
||||
|
||||
reload 가 정말 반영됐는지는 워커가 갈렸는지로 본다. 같은 판정 방법을
|
||||
04 에서 인증서 갱신에 그대로 쓴다.
|
||||
|
||||
```bash
|
||||
systemctl status nginx --no-pager | head -20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 그
|
||||
아래 프로세스 트리에 `nginx: master process` 하나와 `nginx: worker process`
|
||||
여럿이 붙어 있는가. 워커 줄의 **PID** 를 눈에 담아 둔다.
|
||||
|
||||
**이 결과가 의미하는 것** — reload 는 마스터를 그대로 두고 **워커만** 갈아
|
||||
끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된
|
||||
것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다.
|
||||
04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다.
|
||||
|
||||
## 3. 호스트에서 엣지로 넘긴다 (DNAT)
|
||||
|
||||
여기까지 하면 엣지 nginx 는 살아 있는데 **아무도 거기로 안 보낸다.** tailnet
|
||||
주소 `100.83.212.4:443` 을 받는 것은 여전히 물리 호스트다. 그 트래픽을
|
||||
엣지로 넘기는 것이 이 단계고, **물리 호스트가 실험대를 위해 하는 일의
|
||||
전부**다.
|
||||
|
||||
**하기** — `[lab host]`. 원본은
|
||||
[`deploy/lab/edge/lab-edge-dnat.nft`](../../../deploy/lab/edge/lab-edge-dnat.nft)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/nftables.d
|
||||
sudo cp deploy/lab/edge/lab-edge-dnat.nft /etc/nftables.d/
|
||||
sudo cp deploy/lab/edge/lab-edge-dnat.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lab-edge-dnat.service
|
||||
```
|
||||
|
||||
규칙의 알맹이는 두 줄이다.
|
||||
|
||||
```
|
||||
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||||
ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new accept
|
||||
```
|
||||
|
||||
**★ SNAT 을 걸지 않는다.** 게스트의 기본 게이트웨이가 호스트라 응답은
|
||||
어차피 여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면
|
||||
출발지가 덮여서 **엣지가 모든 클라이언트를 `192.168.122.1` 로 본다** — 이
|
||||
실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
|
||||
|
||||
**두 번째 줄이 왜 필요한가** — libvirt 의 기본 네트워크 규칙은 게스트 대역으로
|
||||
들어가는 `RELATED,ESTABLISHED` 만 허용한다. **밖에서 새로 들어오는 연결은
|
||||
막는다.** 그래서 명시적으로 열어 준다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo nft list table ip lab_edge
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dnat to 192.168.122.10` 한 줄과 `accept` 한 줄이
|
||||
다 있는가. 그리고 **`masquerade` 나 `snat` 이 없는가.**
|
||||
|
||||
**이 결과가 의미하는 것** — PREROUTING nat 은 라우팅 결정보다 먼저 돌기
|
||||
때문에 이 규칙이 **호스트 자신의 `:443` 소켓보다 우선한다.** 그래서 물리
|
||||
호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 간다 — 전환이 원자적이고,
|
||||
되돌리기도 한 줄이다.
|
||||
|
||||
```bash
|
||||
sudo nft delete table ip lab_edge # rollback
|
||||
```
|
||||
|
||||
## 4. 층별로 확인한다 — 아래에서 위로
|
||||
|
||||
한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다.
|
||||
|
||||
> **`curl` 을 두 형태로 쓴다.** 처음 볼 때는 `-I`(헤더까지 읽는 형태)로
|
||||
> **응답을 눈으로 읽고**, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할
|
||||
> 때만 `-w '%{http_code}'`(값만 뽑는 형태)로 바꾼다. 값만 뽑는 형태는
|
||||
> 골라 놓은 한 칸 말고는 전부 버리므로, **무엇이 잘못됐는지 모르는 상태**
|
||||
> 에서는 쓸 것이 못 된다.
|
||||
|
||||
**확인 ①** Traefik 이 듣고 있나 (nginx 를 건너뛴다)
|
||||
```bash
|
||||
curl -I http://192.168.122.11
|
||||
```
|
||||
|
||||
**형태** (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)
|
||||
```
|
||||
HTTP/1.1 404 Not Found
|
||||
...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나. 그리고 그 앞에 **아무 오류도
|
||||
없이** 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가
|
||||
없는 것이라 상태 코드를 볼 일도 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`404` 가 성공 신호다.** 게스트의 80 을 누가
|
||||
듣고 있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고
|
||||
답한 것이다. 이 층은 통과. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는
|
||||
것이고, 연결 거부·타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라
|
||||
**02 로 돌아간다.**
|
||||
|
||||
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로
|
||||
비교하는 것이 목적이기 때문이다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
|
||||
```
|
||||
|
||||
**실측** — `.11` 에서 잰 값이다.
|
||||
```
|
||||
404
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 줄이 **같은 코드**인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은
|
||||
결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그
|
||||
상태에서는 **요청의 절반만 실패**해서 「가끔 안 된다」로 보인다.
|
||||
|
||||
**확인 ②** 엣지 nginx 가 직접 응답하나 (DNAT 을 건너뛴다)
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가. 여기서 막히면
|
||||
문제는 **엣지 안**이다(설정·기동). 통과하는데 아래 ③ 이 안 되면 문제는
|
||||
**DNAT** 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
|
||||
|
||||
**확인 ③** 밖에서, 즉 DNAT 을 거쳐 닿나
|
||||
```bash
|
||||
curl -I http://auth.hyeonworks.com
|
||||
```
|
||||
|
||||
**형태** (봐야 할 두 줄만)
|
||||
```
|
||||
HTTP/1.1 301 Moved Permanently
|
||||
Location: https://auth.hyeonworks.com/
|
||||
...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태 줄과 **`Location:` 헤더 한 줄**. `Location` 이
|
||||
`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신
|
||||
설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
|
||||
|
||||
**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐
|
||||
엣지 nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친
|
||||
것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나
|
||||
80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다.
|
||||
|
||||
값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
|
||||
```
|
||||
```
|
||||
301 https://auth.hyeonworks.com/
|
||||
```
|
||||
|
||||
**확인 ④** 끝까지 닿나 (TLS 이후)
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는
|
||||
이유는, 이 200 이 **04·05 에서 매번 같은 명령으로 다시 잴 기준값**이기
|
||||
때문이다. 처음 한 번은 `curl -I https://auth.hyeonworks.com/realms/master`
|
||||
로 헤더까지 보고, 그다음부터 이 형태로 줄인다.
|
||||
|
||||
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다
|
||||
이어졌다. `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(4번의 로그를
|
||||
본다), `curl: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서
|
||||
막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
|
||||
|
||||
## 5. upstream 이 둘인 이유
|
||||
|
||||
```
|
||||
upstream k3s_traefik {
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
```
|
||||
|
||||
두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도 된다. nginx 는 기본
|
||||
라운드로빈으로 번갈아 보내고, **한쪽이 죽으면 자동으로 뺀다.**
|
||||
|
||||
그 「빼는」 동작이 로그에 이렇게 남는다.
|
||||
|
||||
```
|
||||
connect() failed (113: No route to host) ← 호스트에 못 닿는다
|
||||
connect() failed (111: Connection refused) ← 포트에 아무도 없다
|
||||
no live upstreams ← 둘 다 죽었다고 판단
|
||||
```
|
||||
|
||||
**113 과 111 은 대응이 다르다.** 113 은 네트워크, 111 은 프로세스다.
|
||||
A-4 에서 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 어디서 끊겼나 | 확인 |
|
||||
|---|---|---|
|
||||
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
|
||||
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
|
||||
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
|
||||
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | [04](../04-tls/) · 아래 로그 |
|
||||
|
||||
**로그를 볼 때** — 실무자가 치는 형태다.
|
||||
```bash
|
||||
journalctl -u nginx -p err -n 5 # 최근 에러만
|
||||
journalctl -u nginx -f # 지금 벌어지는 것
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 각 줄의 **괄호 안 errno**(`113`·`111`)와 그 뒤의
|
||||
`upstream: "http://192.168.122.1x:80/..."` 부분. 어느 upstream 이 문제인지가
|
||||
거기 적혀 있다. 그리고 **타임스탬프** — 방금 친 요청 시각과 안 맞으면 지금
|
||||
보고 있는 것은 옛 사고다.
|
||||
|
||||
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는
|
||||
정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 **띄워 놓은
|
||||
채로 다른 창에서 요청을 치는** 용도다 — 요청과 로그 줄을 눈으로 짝지으면
|
||||
「이 요청이 어느 upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.
|
||||
|
||||
**★ nginx 에러 로그는 2048바이트에서 잘린다.** 긴 URL 이 끝에서 단어 중간에
|
||||
끊겨 보이면 그것이다. 되찾으려면 **access 로그를 본다** — 거기엔 제한이 없다.
|
||||
|
||||
```bash
|
||||
grep oauth2/callback /var/log/nginx/access.log | tail -1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 그 한 줄의 **끝**이다. 요청 URL 이 `"` 로 제대로
|
||||
닫혀 있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘린 것이다. 길이가
|
||||
궁금하면 세어 본다.
|
||||
|
||||
```bash
|
||||
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — error 로그와 access 로그의 같은 요청 길이가 다르면
|
||||
**error 쪽이 잘린 것**이지 요청이 잘린 것이 아니다. 이 실험대에서 B-7 의
|
||||
502 원인이 error 로그에 있었는데 잘려 있었고, access 로그에는 3492자로 온전히
|
||||
남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
|
||||
@@ -0,0 +1,449 @@
|
||||
# 04 — TLS
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[03](../03-nginx/) 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
|
||||
|
||||
## 어디서 치는가
|
||||
|
||||
**이 단계는 전부 `[kc-lab-edge]` 에서 친다.** 인증서·certbot·갱신 타이머·
|
||||
deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
|
||||
그래야 `virsh undefine kc-lab-edge` 한 줄로 이 계층을 통째로 되돌릴 수 있다.
|
||||
|
||||
## ★ 검증 방식을 먼저 정한다 — HTTP-01 이냐 DNS-01 이냐
|
||||
|
||||
같은 Let's Encrypt 인증서인데 **「이 도메인이 네 것이냐」를 증명하는 방법**만
|
||||
다르다. 그리고 이 실험대에서는 **선택의 여지가 없다.**
|
||||
|
||||
| | HTTP-01 | DNS-01 |
|
||||
|---|---|---|
|
||||
| 검증 방향 | Let's Encrypt **→ 우리 서버** (인바운드) | certbot **→ DNS 공급자 API** (아웃바운드) |
|
||||
| 공개 인터넷에서 보여야 하나 | **그렇다** | 아니다 |
|
||||
| 와일드카드 | 불가 | 가능 |
|
||||
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
|
||||
|
||||
**이 실험대는 공개 인터넷을 쓰지 않는다.** 도메인 세 개는 tailnet 주소를
|
||||
가리킨다.
|
||||
|
||||
```bash
|
||||
dig +short auth.hyeonworks.com
|
||||
```
|
||||
```
|
||||
100.83.212.4
|
||||
```
|
||||
|
||||
`100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라 **공개 인터넷에서 라우팅
|
||||
자체가 되지 않는다.** 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지
|
||||
않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다. **그래서 HTTP-01 은
|
||||
쓸 수 없고 DNS-01 을 쓴다.**
|
||||
|
||||
> **공개 서버라면 HTTP-01 이 맞다.** 토큰도 DNS 연동도 필요 없어서 관리할
|
||||
> 것이 적다. DNS-01 이 더 좋은 방식이어서 고르는 것이 아니라, HTTP-01 이
|
||||
> 못 쓰이는 환경이라 고르는 것이다. 개념은
|
||||
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
|
||||
> 「DNS-01 은 언제 쓰는가」.
|
||||
|
||||
---
|
||||
|
||||
## 1. certbot 을 깐다
|
||||
|
||||
cloud-init 이 이미 깔았다면 건너뛴다 —
|
||||
[`kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example) 의
|
||||
`packages` 에 들어 있다.
|
||||
|
||||
**하기** — `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo apt install -y certbot python3-certbot-dns-cloudflare
|
||||
```
|
||||
|
||||
**확인** — 쓸 수 있는 검증 방식이 무엇인가
|
||||
```bash
|
||||
certbot plugins 2>/dev/null | grep -E '^\*'
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
* dns-cloudflare
|
||||
* standalone
|
||||
* webroot
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dns-cloudflare` 한 줄이 있는가. 없으면 플러그인
|
||||
패키지가 안 깔린 것이고, `--dns-cloudflare` 를 줘도 `unrecognized arguments`
|
||||
로 끝난다.
|
||||
|
||||
## 2. 인증서를 받는다
|
||||
|
||||
DNS-01 이면 **와일드카드를 받을 수 있다.** 이 실험대는 처음에 이름 셋을
|
||||
따로 받았고, 그 비용이 B-7 에서 청구됐다 — oauth2-proxy 를 올릴 네 번째
|
||||
이름이 없어 Grafana 의 `app2` 를 빌려야 했다.
|
||||
|
||||
**하기** — `[kc-lab-edge]`. 토큰은 **존 하나 + DNS:Edit** 으로 좁힌다.
|
||||
계정 전역 API Key 를 쓰지 않는다.
|
||||
|
||||
```bash
|
||||
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
|
||||
sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null <<'EOF'
|
||||
dns_cloudflare_api_token = <Cloudflare API 토큰>
|
||||
EOF
|
||||
|
||||
sudo certbot certonly --dns-cloudflare \
|
||||
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
|
||||
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
|
||||
```
|
||||
|
||||
**`--dry-run` 을 먼저 붙인다.** Let's Encrypt 는 같은 이름 조합에 대해
|
||||
**주당 중복 인증서 5장** 제한이 있고, `--dry-run` 은 그 한도를 쓰지 않는다.
|
||||
통과하면 `--dry-run` 만 떼고 다시 친다.
|
||||
|
||||
> **DNS-01 은 느리다.** TXT 레코드가 퍼질 때까지 기다려야 해서 발급이 수십
|
||||
> 초 걸린다. certbot 이 기본 대기 시간을 두고 있으니 중간에 끊지 않는다.
|
||||
|
||||
**확인** — 인증서가 실제로 생겼고 이름이 다 들어갔는가
|
||||
```bash
|
||||
sudo certbot certificates
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 줄이다. `Domains:` 에 **이름 세 개가 한 줄에**
|
||||
나열되어 있는가(줄이 두 개로 갈라져 있으면 인증서가 두 장 만들어진 것이다),
|
||||
`Expiry Date:` 가 오늘로부터 90일 뒤이고 `VALID` 인가, `Certificate Path:` 가
|
||||
`/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem` 인가, 그리고
|
||||
`Private Key Path:` 가 그 옆의 `privkey.pem` 인가. **경로 두 개가 03 의
|
||||
nginx 설정에 적은 것과 한 글자도 다르면 안 된다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
|
||||
발급은 성공했는데 `No certificates found` 가 나오면 `sudo` 없이 쳐서 다른
|
||||
사용자의 설정 디렉터리를 본 것이다. 발급 자체가 실패했다면 검증 단계에서
|
||||
막힌 것이므로 DNS 와 80 포트부터 본다(아래 「막히면」).
|
||||
|
||||
> **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.**
|
||||
> B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를
|
||||
> 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를
|
||||
> 붙여야 한다 — 그 절충을 안 한 결과다.
|
||||
|
||||
## 3. nginx 가 `fullchain` 을 보게 한다
|
||||
|
||||
[03](../03-nginx/) 의 설정에 이미 있다.
|
||||
|
||||
```
|
||||
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||
```
|
||||
|
||||
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가
|
||||
빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**,
|
||||
캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 4. 확인 — 열리는가, 체인이 완전한가
|
||||
|
||||
**확인 ①** 열리나 — **처음 한 번은 협상 과정을 읽는다**
|
||||
```bash
|
||||
curl -v https://auth.hyeonworks.com/realms/master -o /dev/null
|
||||
```
|
||||
|
||||
**형태** (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
|
||||
```
|
||||
* SSL connection using TLSv1.3 / ...
|
||||
* subject: CN=auth.hyeonworks.com
|
||||
* issuer: C=US; O=Let's Encrypt; CN=...
|
||||
* SSL certificate verify ok.
|
||||
> GET /realms/master HTTP/1.1
|
||||
< HTTP/1.1 200 OK
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `*` 로 시작하는 줄 넷이다. 어떤 TLS 판으로
|
||||
협상했는가, `subject` 의 CN 이 지금 친 이름과 같은가, `issuer` 가 Let's
|
||||
Encrypt 인가, 그리고 **`SSL certificate verify ok.`** 가 있는가. 그 아래
|
||||
`<` 로 시작하는 첫 줄이 응답 상태다. `-o /dev/null` 은 본문만 버리는 것이라
|
||||
이 줄들은 그대로 남는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
|
||||
클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
|
||||
있으므로, **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
|
||||
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면
|
||||
중간 인증서가 빠진 것이고, 그 원인은 3번의 `cert.pem`/`fullchain.pem`
|
||||
이다 — 확인 ②로 간다.
|
||||
|
||||
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는
|
||||
형태로 줄인다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**확인 ②** 체인 단계와 검증
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||
-servername auth.hyeonworks.com 2>/dev/null \
|
||||
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
0 s:CN = auth.hyeonworks.com
|
||||
i:C = US, O = Let's Encrypt, CN = YE2
|
||||
1 s:C = US, O = Let's Encrypt, CN = YE2
|
||||
i:C = US, O = ISRG, CN = Root YE
|
||||
2 s:C = US, O = ISRG, CN = Root YE
|
||||
i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||||
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||||
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||
Verify return code: 0 (ok)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 왼쪽의 **번호(0·1·2·3)가 몇까지 가는가**, 그리고
|
||||
각 단계의 `i:`(발급자)가 **바로 다음 단계의 `s:`(주체)와 같은가**. 0번이
|
||||
우리 서버 인증서이고, 위 실측에서 0의 `i:` 가 `CN = YE2` 인데 1의 `s:` 가
|
||||
같은 `CN = YE2` 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
|
||||
`Verify return code: 0 (ok)`.
|
||||
|
||||
**이 결과가 의미하는 것** — **단계가 1개면 `cert.pem` 을 쓴 것이다.**
|
||||
서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
|
||||
대개 캐시나 AIA 로 보완해서 **정상으로 보이므로**, 이 명령이 유일하게
|
||||
믿을 수 있는 판정이다. 고치는 곳은 03 의 `ssl_certificate` 한 줄이고,
|
||||
고친 뒤 `nginx -t && systemctl reload nginx` 하고 여기서 다시 잰다.
|
||||
`Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 —
|
||||
`10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
|
||||
|
||||
**확인 ③** 이름 세 개가 한 인증서인가
|
||||
```bash
|
||||
for H in auth app1 app2; do
|
||||
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -serial
|
||||
done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 찍히는 세 줄의 **일련번호가 서로 같은가**. 값 자체는
|
||||
아무 의미가 없고 **셋이 일치하는지만** 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는
|
||||
인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라
|
||||
**갱신 훅도 장마다 따로 돌고**, 한 장만 갱신됐을 때 나머지 이름이 만료되는
|
||||
상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
|
||||
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -ext subjectAltName
|
||||
```
|
||||
|
||||
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
|
||||
|
||||
**타이머가 도는 것만으로는 부족하다.**
|
||||
|
||||
**확인** — 타이머
|
||||
```bash
|
||||
systemctl list-timers certbot-renew.timer
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은
|
||||
시간)가 채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의
|
||||
`ACTIVATES` 가 `certbot-renew.service` 를 가리키는가. **표가 통째로 비어
|
||||
나오면 타이머가 없는 것이다** — 이름이 배포판마다 다르니
|
||||
`systemctl list-timers --all | grep -i certbot` 로 찾는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
|
||||
대부분의 문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가
|
||||
서빙되지는 않는다.** nginx 는 인증서를
|
||||
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
|
||||
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
|
||||
|
||||
배포판 기본 유닛에는 reload 를 부르는 것이 없다.
|
||||
|
||||
```bash
|
||||
systemctl cat certbot-renew.service
|
||||
```
|
||||
```
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/certbot -q renew
|
||||
PrivateTmp=true
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=` 가
|
||||
**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어
|
||||
있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
|
||||
|
||||
**이 결과가 의미하는 것** — `ExecStartPost` 도 `--deploy-hook` 도 없다.
|
||||
즉 이 유닛은 **인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가
|
||||
읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
|
||||
「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
|
||||
배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
|
||||
확인하면 된다.
|
||||
|
||||
**하기** — 훅 하나를 넣는다
|
||||
```bash
|
||||
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
EOF
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
**저장소에 같은 파일이 있다** —
|
||||
[`deploy/lab/edge/reload-nginx.sh`](../../../deploy/lab/edge/reload-nginx.sh).
|
||||
여기 손으로 치지 말고 그걸 밀어 넣는 편이 낫다.
|
||||
|
||||
```bash
|
||||
cat deploy/lab/edge/reload-nginx.sh \
|
||||
| ssh kc-lab-edge 'sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null'
|
||||
ssh kc-lab-edge 'sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
|
||||
```
|
||||
|
||||
> **이 훅은 한동안 저장소에 없었다.** 호스트에만 있어서, 호스트를 초기화하면
|
||||
> **아무 오류 없이 사라지고** D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초
|
||||
> 지연 · 타이머는 `SUCCESS`)로 되돌아갔다. 저장소에 두는 이유가 이것이다.
|
||||
|
||||
`deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
|
||||
갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다.
|
||||
|
||||
**확인** — 실제로 도는지
|
||||
|
||||
이 확인은 **상태를 바꾼다.** `--force-renewal` 은 인증서를 실제로 새로
|
||||
받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 `--dry-run` 으로 훅이
|
||||
호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
|
||||
`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌
|
||||
곳에 있거나 실행 권한이 없는 것이다(`ls -l` 로 `x` 를 본다).
|
||||
|
||||
**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다.
|
||||
호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 —
|
||||
그래서 아래를 한 번 한다.
|
||||
|
||||
```bash
|
||||
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
|
||||
sudo certbot renew --force-renewal
|
||||
|
||||
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
|
||||
시작 시각)**. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를
|
||||
같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터
|
||||
프로세스는 그대로이고 **워커만** 갈리는 것이 정상이다.
|
||||
|
||||
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를
|
||||
읽었다. 안 바뀌었으면 인증서는 갱신됐는데 **서빙되는 것은 옛것**이고,
|
||||
이 상태가 아래 표의 왼쪽 칸이다.
|
||||
|
||||
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
|
||||
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** —
|
||||
nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
|
||||
`test is successful` · `signal process started` 다.
|
||||
|
||||
> **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.**
|
||||
|
||||
**실측** — 이 실험대에서 잰 차이
|
||||
|
||||
| | 훅 없음 | 훅 있음 |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** |
|
||||
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
|
||||
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
|
||||
|
||||
**88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS` 로
|
||||
끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다.
|
||||
발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고
|
||||
적혀 있다.
|
||||
|
||||
원문: [D-4](../../experiment-d4-certificate-renewal.md) ·
|
||||
[D-4a](../../experiment-d4a-deploy-hook.md) ·
|
||||
증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/)
|
||||
|
||||
## 6. reload 는 무중단인가 — 쟀다
|
||||
|
||||
궁금할 것이므로 결과만 적는다. **무중단이다.**
|
||||
|
||||
새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고
|
||||
845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도**
|
||||
845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
|
||||
| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② |
|
||||
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
|
||||
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
|
||||
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
|
||||
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
|
||||
| DNS-01 이 오래 걸림 | TXT 전파 대기 | **정상이다.** 끊지 않는다 |
|
||||
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
|
||||
|
||||
---
|
||||
|
||||
## 근거를 재려면 (선택)
|
||||
|
||||
평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다.
|
||||
|
||||
갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면
|
||||
갱신 중에 나온 실패 한 건을 해석할 수 없다.
|
||||
|
||||
```bash
|
||||
# 대조군 — 0.2초 × 900회 = 180초
|
||||
i=0; while [ $i -lt 900 ]; do
|
||||
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
|
||||
https://auth.hyeonworks.com/realms/master
|
||||
i=$((i+1)); sleep 0.2
|
||||
done > /tmp/control.txt
|
||||
awk '{print $1}' /tmp/control.txt | sort | uniq -c
|
||||
```
|
||||
|
||||
**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고,
|
||||
헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
|
||||
|
||||
**어디를 봐야 하는가** — `uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면
|
||||
900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
|
||||
두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
|
||||
둘째 열을 따로 본다.
|
||||
|
||||
```bash
|
||||
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
|
||||
이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
|
||||
있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도
|
||||
증명하지 못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
|
||||
|
||||
**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
|
||||
|
||||
```bash
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
curl -sI https://www.google.com | grep -i '^date:'
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
|
||||
몇인가**. `A` 와 `C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
|
||||
가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의
|
||||
시각이 둘 중 어느 쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로
|
||||
나란히 놓아도 된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다.
|
||||
이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫
|
||||
계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로
|
||||
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
|
||||
@@ -0,0 +1,514 @@
|
||||
# 05 — Keycloak 2노드 + PostgreSQL
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이
|
||||
하나의 클러스터로 보인다.
|
||||
|
||||
## 전제
|
||||
|
||||
[04](../04-tls/) 까지 끝나 `https://` 가 열린다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 매니페스트를 적용한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
kubectl create namespace keycloak-lab
|
||||
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||||
```
|
||||
|
||||
**확인** — 적용이 끝날 때까지 기다린다
|
||||
```bash
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
```
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 이 명령은 **끝날 때까지 아무것도 안 찍고 멈춰 있다.**
|
||||
그 침묵이 정상이다. 마지막에 나오는 한 줄에서 `complete` 라는 낱말과 파드
|
||||
개수 `2` 를 본다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 —
|
||||
「안 떴다」가 확정된 것이니 3번으로 간다.
|
||||
|
||||
**이 결과가 의미하는 것** — `complete` 면 두 파드가 다 Ready 가 됐다는 뜻이라
|
||||
2번의 층별 확인으로 넘어간다. `rollout status` 를 쓰는 이유는 `get pods` 를
|
||||
반복해서 치는 것보다 나아서만이 아니라, **언제 끝났는지를 사람이 판정하지
|
||||
않아도 되기** 때문이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로
|
||||
중간에 `0/2` 로 한참 멈춰 있는 것은 정상이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 리소스가 제대로 만들어졌는지 — 층별로 본다
|
||||
|
||||
`kubectl get pods` 만 보면 놓치는 것이 많다. **위에서 아래로** 확인한다.
|
||||
|
||||
### 2-1. 무엇이 만들어졌나
|
||||
|
||||
**확인** — 이 네임스페이스에 무엇이 서 있는가
|
||||
```bash
|
||||
kubectl -n keycloak-lab get all
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드
|
||||
줄에서는 **READY 칸의 `1/1`** 과 **RESTARTS 칸**을 본다. RESTARTS 가 0 이
|
||||
아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이라, 3번의
|
||||
`logs --previous` 를 볼 이유가 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기 보이는 것이 워크로드의 전부다. 그런데
|
||||
`all` 은 이름과 달리 전부는 아니다 — **Secret·ConfigMap·PVC·Ingress 는 안
|
||||
나온다.** 이 넷이 빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이
|
||||
흔한 오독이라, 한 번 더 친다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 종류가 **하나씩이라도 있는가**. PVC 줄의
|
||||
STATUS 는 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 매니페스트에 있는데 여기 없는 종류가 있다면
|
||||
`apply` 가 부분적으로만 먹은 것이다. Ingress 의 호스트 이름이 04 에서 발급한
|
||||
인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 404 가 나온다.
|
||||
|
||||
### 2-2. Deployment → ReplicaSet → Pod 사슬
|
||||
|
||||
Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를
|
||||
만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발점이다.
|
||||
|
||||
**확인** — 사슬 어디까지 갔는가
|
||||
```bash
|
||||
kubectl -n keycloak-lab get deploy,rs,pod -l app=bff
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
replicaset.apps/bff-555df79c97 2 2 ← 지금 쓰이는 것
|
||||
replicaset.apps/bff-574c6d658b 0 0 ← 지난 배포
|
||||
replicaset.apps/bff-576d869c6d 0 0
|
||||
... (7개)
|
||||
pod/bff-555df79c97-6j86w 1/1 Running
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — ReplicaSet 줄들 중 **DESIRED 가 0 이 아닌 것 하나**
|
||||
를 찾고, 그 이름의 해시를 파드 이름 가운데 해시와 맞춰 본다. 위 실측에서는
|
||||
`555df79c97` 이 그 짝이다. 나머지 `0 0` 줄들은 읽지 않아도 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — **ReplicaSet 이 여러 개인 것은 정상이다.**
|
||||
배포할 때마다 새로 만들고 옛것은 `0` 으로 남긴다 — 그래서
|
||||
`kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가
|
||||
아직 안 넘어온 것이고, 그 상태로 실험하면 **고친 적 없는 코드를 재게 된다.**
|
||||
사슬이 어디서 끊겼는지는 이렇게 읽는다.
|
||||
|
||||
| 보이는 것 | 뜻 |
|
||||
|---|---|
|
||||
| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 |
|
||||
| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 |
|
||||
| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe |
|
||||
|
||||
> StatefulSet 은 ReplicaSet 을 쓰지 않고 파드를 직접 만든다. 그래서
|
||||
> `keycloak-0`·`keycloak-1` 처럼 **이름이 고정**이고, A-4 에서 `Terminating`
|
||||
> 파드가 안 지워지면 대체 파드가 안 생기는 이유가 이것이다.
|
||||
|
||||
### 2-3. Secret 이 실제로 들어갔나 — 세 층으로 본다
|
||||
|
||||
값이 있는 것과 파드가 그 값을 받은 것은 다르다.
|
||||
|
||||
**확인 ①** Secret 에 무슨 키가, 얼마만큼 들어 있나 — **값은 찍지 않는다**
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe secret keycloak-lab-secrets
|
||||
```
|
||||
|
||||
**실측** — 아래쪽 `Data` 절만 옮긴 것이다
|
||||
```
|
||||
Data
|
||||
====
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes
|
||||
POSTGRES_PASSWORD: 22 bytes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Data` 절의 **키 이름과 그 옆의 바이트 수** 두 칸.
|
||||
`describe` 는 값을 절대 찍지 않고 길이만 보여 준다 — 그래서 키 목록 확인과
|
||||
「비어 있지 않은가」 확인이 **한 명령으로 끝난다.** `0 bytes` 인 키가 있으면
|
||||
그 자리가 비어 있는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 매니페스트가 기대하는 키 이름이 여기 그대로
|
||||
있어야 한다. 이름이 하나라도 다르면 파드는 `CreateContainerConfigError` 로
|
||||
멈추고, 이유는 `describe pod` 의 Events 에 키 이름까지 적혀 나온다.
|
||||
바이트 수가 뜻밖에 크면(예: 20 이어야 할 것이 21) **`echo` 로 만들면서 개행이
|
||||
같이 들어간** 경우다 — 흔한 사고이고, 증상은 「비밀번호가 틀렸다」로 나온다.
|
||||
|
||||
> **`-o yaml` 로 보지 않는다.** base64 는 암호화가 아니라 인코딩이라
|
||||
> 화면·스크롤백·화면 공유·터미널 로그에 값이 그대로 남는다.
|
||||
> D-3 이 잰 것이 이것이다 — [`experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
|
||||
|
||||
**확인 ②** 특정 키 하나를 따져 볼 때 — **길이만**
|
||||
|
||||
`describe` 가 보여 주는 바이트 수는 base64 를 푼 뒤의 길이다. 어떤 키
|
||||
하나가 의심스러워 다시 잴 때만 이 형태를 쓴다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
```
|
||||
22
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나. 그리고 그것이 확인 ①의 `22 bytes` 와
|
||||
같은가.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다.
|
||||
`base64: invalid input` 이 나오면 키 이름을 잘못 쓴 것이다(없는 키는 빈
|
||||
문자열로 나온다). 여기까지는 **Secret 안에 무엇이 있나**이고, 파드가 그것을
|
||||
받았는지는 아직 모른다.
|
||||
|
||||
**확인 ③** 파드 안에 주입됐나 — 여기가 진짜다
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- \
|
||||
sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
|
||||
```
|
||||
```
|
||||
길이=19
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나. `${#VAR}` 는 값이 아니라 **글자 수**만
|
||||
내놓는다. 이것이 확인 ①의 `19 bytes` 와 같은가.
|
||||
|
||||
**이 결과가 의미하는 것** — 같으면 Secret → 파드 환경변수까지 이어졌다.
|
||||
`길이=0` 이면 Secret 에는 있는데 **이 파드가 그것을 안 받은** 것이다 —
|
||||
`envFrom`/`valueFrom` 을 빠뜨렸거나, 파드가 Secret 을 고치기 **전에** 떠서
|
||||
옛 값을 들고 있는 경우다(환경변수로 주입한 Secret 은 값을 바꿔도 파드를
|
||||
다시 만들기 전까지 갱신되지 않는다).
|
||||
|
||||
**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get pod keycloak-0 \
|
||||
-o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
|
||||
```
|
||||
```
|
||||
KC_DB
|
||||
KC_DB_URL
|
||||
KC_DB_USERNAME
|
||||
KC_DB_PASSWORD keycloak-lab-secrets ← Secret 에서 온 것만 오른쪽에 이름이 있다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **오른쪽 칸이 채워진 줄만**. 왼쪽만 있는 줄은
|
||||
매니페스트에 값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을
|
||||
참조하는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 비밀이어야 할 변수의 오른쪽이 비어 있으면
|
||||
**그 값은 매니페스트에 평문으로 적혀 있다는 뜻**이고, 그 파일은 대개 git 에
|
||||
들어간다. 여기서는 `KC_DB_PASSWORD` 만 Secret 에서 온다.
|
||||
|
||||
### 2-4. Service 가 파드를 잡고 있나 — Endpoints
|
||||
|
||||
Service 가 있어도 **셀렉터가 안 맞으면 뒤가 비어 있다.** 이때 증상은
|
||||
「연결은 되는데 응답이 없다」라 원인을 찾기 어렵다.
|
||||
|
||||
**확인** — 실무자가 가장 자주 쓰는 형태
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
|
||||
```
|
||||
```
|
||||
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 쉼표로 갈린 **주소가 몇 개인가**, 그리고 그 IP 들이
|
||||
`kubectl -n keycloak-lab get pods -o wide` 의 파드 IP 와 같은가. 포트 번호가
|
||||
컨테이너가 실제로 듣는 포트인가도 함께 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다.
|
||||
**비어 있으면** Service 는 있는데 뒤가 없는 것이고, 이때 증상은
|
||||
「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것이 잘 안 보인다.
|
||||
하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로
|
||||
A층 실험을 하면 **이미 한쪽으로만 가고 있던 트래픽**을 이중화 실패로
|
||||
오독하게 된다.
|
||||
|
||||
목록으로 보려면 **EndpointSlice** 를 쓴다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
|
||||
```
|
||||
```
|
||||
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
|
||||
keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h
|
||||
```
|
||||
|
||||
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 이고
|
||||
> 실행하면 경고가 나온다.
|
||||
> ```
|
||||
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||
> ```
|
||||
> 옛 문서와 블로그에 이 형태가 많으니 주의한다.
|
||||
|
||||
준비 상태까지 함께 보려면 이렇게 뽑는다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
|
||||
```
|
||||
```
|
||||
10.42.0.67 true
|
||||
10.42.1.155 true
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 오른쪽 칸이 두 줄 다 `true` 인가. 여기서만 값을
|
||||
뽑는 형태를 쓰는 이유는, 이 두 칸이 **A층 실험 전후로 반복해서 비교할
|
||||
값**이기 때문이다. 처음 볼 때는 위의 `describe svc` 로 충분하다.
|
||||
|
||||
**이 결과가 의미하는 것** — `ready` 가 `false` 면 파드는 있는데 **readiness
|
||||
프로브를 통과하지 못한** 것이라, Service 가 그 파드로 트래픽을 보내지 않는다.
|
||||
파드 목록에서는 `Running` 으로 보이므로 `get pods` 만 봐서는 알 수 없다 —
|
||||
`0/1` 인지 `1/1` 인지가 같은 사실을 말해 준다.
|
||||
|
||||
**비어 있으면** 셀렉터와 파드 라벨이 안 맞는 것이다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
|
||||
kubectl -n keycloak-lab get pods --show-labels
|
||||
```
|
||||
|
||||
### 2-5. PVC 가 실제로 붙었나
|
||||
|
||||
**확인** — 볼륨이 실제로 잡혔는가
|
||||
```bash
|
||||
kubectl -n keycloak-lab get pvc
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — STATUS 칸(`Bound`/`Pending`)과 VOLUME 칸(비어 있는지),
|
||||
그리고 STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에
|
||||
파드 번호가 붙어 있어(`...-keycloak-0`) 어느 파드 것인지 바로 보인다.
|
||||
|
||||
**이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면
|
||||
StorageClass 가 없거나 노드에 자리가 없다. **`local-path` 는 파드가 스케줄될
|
||||
때까지 기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 `Pending`
|
||||
인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저
|
||||
본다. 사유는 PVC 의 이벤트에 적혀 있다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe pvc # 이름을 안 주면 전부 나온다
|
||||
```
|
||||
|
||||
각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지
|
||||
`no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 —
|
||||
앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 안 뜰 때 — 순서가 있다
|
||||
|
||||
**① 이벤트부터.** 로그보다 먼저다. 스케줄링·이미지·볼륨 실패가 여기 나온다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 이유가
|
||||
그것이다. TYPE 이 `Warning` 인 줄, REASON 칸(`FailedScheduling`·`Failed`·
|
||||
`BackOff`), 그리고 OBJECT 칸이 어느 파드인가. **이벤트는 기본 한 시간만
|
||||
남는다** — 아무것도 없으면 「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — REASON 하나가 다음 행동을 정한다.
|
||||
`FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를
|
||||
봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없다.
|
||||
`BackOff` 는 컨테이너가 떴다가 죽는 중이라는 뜻이라 ③으로 간다.
|
||||
|
||||
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe pod keycloak-0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가
|
||||
`False` 인가, 컨테이너 절의 `State`/`Last State` 와 그 안의 **`Exit Code`**,
|
||||
그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은
|
||||
OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을
|
||||
못 찾은 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — Exit Code 가 `137` 이면 로그에는 아무 단서도
|
||||
없을 수 있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다.
|
||||
`Ready` 만 `False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로
|
||||
2-4 로 돌아간다.
|
||||
|
||||
**③ 로그.** 컨테이너가 떴는데 죽는 경우다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab logs keycloak-0
|
||||
kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 첫 명령에서는 **마지막 줄들**, 둘째 명령에서는
|
||||
스택 트레이스의 **맨 윗줄**(가장 안쪽 예외가 아니라 최초 원인 줄)이다.
|
||||
Keycloak 은 기동에 성공하면 `Keycloak ... started in` 한 줄을 남기므로,
|
||||
그 줄이 있는지 없는지가 「기동 중」과 「기동 실패」를 가른다.
|
||||
|
||||
**이 결과가 의미하는 것** — `--previous` 가 중요하다. CrashLoopBackOff 면
|
||||
지금 컨테이너는 방금 뜬 것이라 **죽은 이유는 이전 컨테이너 로그에 있다.**
|
||||
`--previous` 가 `not found` 를 내면 아직 한 번도 재시작하지 않은 것이고,
|
||||
그러면 지금 로그가 곧 전부다.
|
||||
|
||||
**④ 그래도 모르면 안에서 본다.**
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec -it keycloak-0 -- sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 클러스터가 형성됐는지 확인한다
|
||||
|
||||
파드가 둘 다 `Running` 인 것과 **하나의 클러스터로 묶인 것**은 다르다.
|
||||
|
||||
**확인 ①** 로그
|
||||
```bash
|
||||
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
```
|
||||
```
|
||||
ISPN000094: Received new cluster view for channel ISPN:
|
||||
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 군데다. 괄호 안의 **`(2)` 가 멤버 수**,
|
||||
대괄호 안의 **이름 목록**, 그리고 `|1` 이 **뷰 번호**(멤버가 들고 날 때마다
|
||||
올라간다). `tail -1` 을 붙였으므로 지금 보고 있는 것은 **가장 최근 뷰** 하나다.
|
||||
|
||||
**이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자
|
||||
있다고 알고 있는 것이다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다
|
||||
떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다.
|
||||
**이 줄은 「그때 그렇게 보였다」는 과거형이다** — 지금 상태는 확인 ③에서 본다.
|
||||
`grep` 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못 한 것이니 로그를
|
||||
통째로 본다.
|
||||
|
||||
**확인 ②** 디스커버리 테이블
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 나온 **행이 몇 개인가**, 그리고 `ip` 칸이 2-4 에서
|
||||
본 파드 IP 와 같은가. 끝의 `(N rows)` 한 줄이 개수를 말해 준다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 표는 「**등록**되어 있다」이지 「서로 말이
|
||||
통한다」가 아니다. 두 행이 다 있는데 확인 ①이 `(1)` 이면, 서로를 찾기는
|
||||
했는데 7800 포트로 메시지가 안 가는 것이다 — A-1 에서 정확히 그 일이
|
||||
벌어졌다. 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
|
||||
|
||||
**확인 ③** 지표
|
||||
|
||||
**★ Keycloak 컨테이너에는 `curl` 이 없다.** 공식 이미지가 최소 구성이라
|
||||
`wget` 도 `nc` 도 없다. 안에서 치면 이렇게 된다.
|
||||
|
||||
```
|
||||
sh: line 1: curl: command not found
|
||||
command terminated with exit code 127
|
||||
```
|
||||
|
||||
그래서 밖에서 물어본다. **Prometheus 에 묻는 것이 가장 짧다.**
|
||||
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**로 온다.
|
||||
`data.result` 배열에서 원소가 **몇 개인가**(노드 수), 각 원소에서 두 군데만
|
||||
읽는다 — `"metric"` 안의 `node` 라벨(어느 Keycloak 인가)과 `"value"` 배열의
|
||||
**둘째 원소**(따옴표에 싸인 값). **이 실험대에는 `jq` 가 없다.** 파서를
|
||||
따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.
|
||||
|
||||
**실측** — 그렇게 읽어낸 값이다
|
||||
```
|
||||
keycloak-1 → 2
|
||||
keycloak-0 → 2
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를
|
||||
보고 있다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라
|
||||
**클러스터 문제가 아니라 관측 문제**일 수 있다 — 06 의 targets 를 본다.
|
||||
|
||||
Prometheus 가 아직 없다면 임시 파드를 띄운다.
|
||||
|
||||
```bash
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run m --rm -i --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 --quiet --command -- \
|
||||
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
|
||||
```
|
||||
|
||||
```
|
||||
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 줄 끝의 **숫자**와, 중괄호 안 `node=` 라벨이 **어느
|
||||
파드인가**. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과
|
||||
`$K0` 로 고른 파드가 반드시 일치한다.
|
||||
|
||||
**이 결과가 의미하는 것** — `--rm` 을 붙였으므로 파드는 끝나면 사라진다.
|
||||
`curlimages/curl` 을 쓰는 이유는 **Keycloak 이미지에 도구가 없기** 때문이고,
|
||||
같은 이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이
|
||||
안 나오고 연결 거부가 나면 9000(관리 포트)이 안 열린 것이다.
|
||||
|
||||
> **두 값이 다를 수 있다.** 각 노드가 자기가 아는 멤버 수를 보고하므로,
|
||||
> 분단되면 한쪽은 2 다른 쪽은 1 이 된다. **한 노드만 보면 분단을 놓친다.**
|
||||
|
||||
> **셋이 다른 것을 본다.** 로그는 「그때 그렇게 보였다」이고, 테이블은
|
||||
> 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다.
|
||||
> A-1 에서 이 셋이 갈렸다 — 테이블에는 둘 다 있는데 메시지는 안 갔다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 밖에서 닿는지
|
||||
|
||||
**확인** — 2홉을 다 지나 파드까지 닿는가
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
|
||||
**03·04 에서 잰 것과 같은 명령으로 같은 값이 나오는지** 비교하는 것이
|
||||
목적이기 때문이다. 이 자리에서 처음 보는 것이 아니다.
|
||||
|
||||
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → Ingress → Service →
|
||||
파드가 전부 이어졌다. `502`·`503` 이면 뒤에서부터 되짚는다 — Ingress 가
|
||||
있는지(2-1), Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다.
|
||||
처음 보는 오류라 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
|
||||
|
||||
```bash
|
||||
curl -I https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다.
|
||||
비밀번호는 위 2-3 의 Secret 에 있다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 어디를 보나 |
|
||||
|---|---|
|
||||
| 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 |
|
||||
| `ImagePullBackOff` | 이미지 이름·태그. 자체 빌드면 두 노드 모두에 반입했는가 |
|
||||
| `CrashLoopBackOff` | `logs --previous` |
|
||||
| `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions |
|
||||
| 밖에서 502 | Ingress → Service → Endpoints 순으로 뒤를 본다 |
|
||||
| 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 위 4번 셋 다 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 근거를 재려면 (선택)
|
||||
|
||||
세션이 실제로 어디 저장되는지는 DB 를 직접 본다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select offline_flag, count(*) from offline_user_session group by 1"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `offline_flag` 가 `0` 인 행의 `count`. **로그인
|
||||
전과 후에 두 번 재서 그 수의 차이**를 본다. 한 번만 재면 아무것도 알 수
|
||||
없다. 행이 아예 없으면(`0 rows`) 표는 있는데 비어 있는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 로그인 뒤 수가 늘면 세션이 DB 에 남는 것
|
||||
(`persistent-user-sessions` 켜짐)이고, 안 늘면 메모리에만 있는 것이다.
|
||||
그 차이가 A층 결론 전체를 뒤집는다 — 메모리에만 있으면 파드를 재시작하는
|
||||
순간 세션이 사라지고, DB 에 있으면 살아남는다.
|
||||
[A-7](../../experiment-a7-volatile-comparison.md)
|
||||
@@ -0,0 +1,198 @@
|
||||
# 06 — 관측
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를
|
||||
밖에서 볼 수 있다.
|
||||
|
||||
## 전제
|
||||
|
||||
[05](../05-keycloak/) 가 끝나 Keycloak 두 노드가 떴다.
|
||||
|
||||
## 왜 필요한가
|
||||
|
||||
실험의 판정을 **밖에서만** 하면 놓친다. A-1 에서 7800 을 끊었는데 외부 응답이
|
||||
전부 200 이었다 — 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다.
|
||||
클러스터 안을 보는 눈이 따로 있어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 적용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
kubectl apply -f deploy/lab/k8s/observability.yaml
|
||||
kubectl -n observability rollout status deploy/prometheus --timeout=180s
|
||||
```
|
||||
|
||||
**확인** — 무엇이 몇 개 떴는가
|
||||
```bash
|
||||
kubectl -n observability get pods
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
grafana-845b5678cf-b6gvc 1/1 Running
|
||||
node-exporter-9qk9w 1/1 Running
|
||||
node-exporter-c2mz4 1/1 Running
|
||||
prometheus-6774f94f7c-pzr2t 1/1 Running
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **줄이 네 개인가**, 그리고 READY 칸이 전부 `1/1`
|
||||
인가. 특히 `node-exporter` 로 시작하는 줄이 **둘**인지 센다.
|
||||
|
||||
**이 결과가 의미하는 것** — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다
|
||||
하나씩 뜨기 때문이다. **하나뿐이면 노드 하나가 빠진 것**이고, 그러면 그
|
||||
노드의 CPU·메모리·디스크 지표가 통째로 없는 채로 실험을 하게 된다 —
|
||||
이때는 관측이 아니라 02 의 노드 상태부터 본다. 어느 노드에 붙었는지는
|
||||
`-o wide` 로 확인한다.
|
||||
|
||||
```bash
|
||||
kubectl -n observability get pods -o wide
|
||||
```
|
||||
|
||||
## 2. 무엇을 긁고 있나 — 여기가 중요하다
|
||||
|
||||
**확인** — Prometheus 가 스스로 밝히는 대상 목록
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
"job":"keycloak"
|
||||
"job":"kubelet"
|
||||
"job":"node-exporter"
|
||||
"job":"prometheus"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **거기 있는 이름이 아니라 없는 이름**이다. 응답은
|
||||
JSON 한 덩어리이고 그대로는 못 읽는다. **이 실험대에는 `jq` 가 없으므로**
|
||||
`grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앤 것이다 — 여기까지가
|
||||
사람이 손으로 치는 선이고, 그 이상 가공해야 한다면 파서를 짜지 말고 화면에
|
||||
나온 JSON 을 그대로 읽는다.
|
||||
|
||||
**이 결과가 의미하는 것** — **★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는
|
||||
그것들을 긁지 않는다. 그래서 B층 실험 대부분에 Grafana 화면이 없는데,
|
||||
**안 찍은 것이 아니라 지표가 없는 것**이다. 어떤 실험에서 지표를 못 찾으면
|
||||
「측정이 실패했다」로 적기 전에 **이 목록에 그 job 이 있었는지부터** 본다.
|
||||
|
||||
> 이것을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다.
|
||||
> [`evidence/followup/04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
|
||||
|
||||
목록에 있는데도 값이 안 나온다면 그다음은 **상태**다. 같은 응답에서
|
||||
`health` 만 훑는다.
|
||||
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과, 그 **바로 뒤에 붙는
|
||||
`lastError`**. `tr ',' '\n'` 으로 쉼표마다 줄을 나눴으므로 필드가 원래
|
||||
순서대로 세로로 늘어선다 — job 줄 아래에 그 대상의 health 가 온다.
|
||||
|
||||
**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 이유를
|
||||
그대로 말해 준다(연결 거부·타임아웃·404). 3번에서 값이 한 노드만 나오는
|
||||
증상의 원인이 대개 여기 있고, 그때 **클러스터가 아니라 스크레이프가 문제**다.
|
||||
|
||||
## 3. 클러스터 상태를 본다
|
||||
|
||||
**확인** — 두 노드가 각각 몇 명을 보고 있는가
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**이다. `data.result`
|
||||
배열의 원소가 **몇 개인가**(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 —
|
||||
`"metric"` 안의 `node` 라벨과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인
|
||||
값). `jq` 가 없으므로 눈으로 읽는다.
|
||||
|
||||
**실측** — 그렇게 읽어낸 값이다
|
||||
```
|
||||
keycloak-1 → 2
|
||||
keycloak-0 → 2
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **두 노드가 각각 자기가 아는 멤버 수를 보고한다.**
|
||||
둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 —
|
||||
**한 노드만 보면 분단을 놓친다.** 원소가 하나뿐이면 분단이 아니라 스크레이프
|
||||
실패일 수 있으므로 2번의 `health` 를 먼저 본다. 값이 아예 안 나오면
|
||||
`"result":[]` 로 빈 배열이 오는데, 이는 「0 이다」가 아니라 **「그런 지표가
|
||||
없다」**는 뜻이다.
|
||||
|
||||
자주 보는 지표들이다.
|
||||
|
||||
| 지표 | 무엇 |
|
||||
|---|---|
|
||||
| `vendor_cluster_size` | 이 노드가 아는 멤버 수 |
|
||||
| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 |
|
||||
| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 |
|
||||
| `agroal_*` | JDBC 커넥션 풀 |
|
||||
| `up` | 스크레이프 성공 여부 |
|
||||
|
||||
## 4. `up` 을 믿지 않는다
|
||||
|
||||
**A-2 에서 503 이 나는 동안에도 `up` 은 1 이었다.** 프로세스가 살아 있고
|
||||
`/metrics` 가 응답하기만 하면 1 이므로 **「살아 있지만 쓸모없는」 상태를
|
||||
보지 못한다.**
|
||||
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=up'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값(`"1"`/`"0"`). 값이 1 이라는
|
||||
것은 **마지막 스크레이프가 성공했다**는 사실 하나만 말한다.
|
||||
|
||||
**이 결과가 의미하는 것** — `up=1` 은 「프로세스가 살아 있고 `/metrics` 가
|
||||
응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 `up == 0`
|
||||
하나로 걸면 **A-2 같은 「살아 있지만 503」 상태를 통째로 놓친다.**
|
||||
기능 지표를 함께 본다 — 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
|
||||
`up` 의 1/0 과 **나란히 놓고 비교하기 위해서**다. 처음 보는 오류를 파고들
|
||||
때는 `curl -I` 나 `curl -v` 로 바꾼다(04 참조).
|
||||
|
||||
**이 결과가 의미하는 것** — `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧
|
||||
「살아 있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것이
|
||||
A-2 의 판정 근거였다.
|
||||
|
||||
## 5. Grafana 를 볼 때
|
||||
|
||||
**하기** — 밖에 열지 않고 포트포워드로 본다
|
||||
```bash
|
||||
kubectl -n observability port-forward svc/grafana 3000:3000
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이
|
||||
찍히고 **명령이 그대로 멈춰 있는가**. 이 명령은 끝나지 않는 것이 정상이라,
|
||||
터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection`
|
||||
줄이 하나씩 붙는다 — 그것이 붙지 않으면 브라우저가 다른 곳을 보고 있는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 터널은 **명령을 실행한 기계에서만** 열린다.
|
||||
워크스테이션에서 쳤으면 워크스테이션 브라우저로 `http://localhost:3000`,
|
||||
lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use`
|
||||
면 3000 을 이미 누가 쓰는 것이니 `3001:3000` 처럼 왼쪽만 바꾼다. Ctrl+C 로
|
||||
끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 **보는 동안만 뚫는
|
||||
것**이라 실험대의 노출면이 늘지 않는다.
|
||||
|
||||
> 실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로
|
||||
> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 위 2번 targets |
|
||||
| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 |
|
||||
| 컨테이너 안에서 curl 실패 | **Keycloak 이미지에 curl 이 없다** | 밖에서 Prometheus 로 묻는다 |
|
||||
| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 |
|
||||
@@ -0,0 +1,113 @@
|
||||
# 실습 가이드 — 직접 쳐보면서 만드는 실험대
|
||||
|
||||
이 문서 묶음은 **읽는 문서가 아니라 따라 치는 문서**다. 기존
|
||||
[`experiment-*.md`](../) 가 「무엇을 발견했나」를 적었다면, 여기는
|
||||
「그 발견을 재현하려면 무엇을 어떤 순서로 치는가」를 적는다.
|
||||
|
||||
## 두 종류의 명령을 구별해 적는다
|
||||
|
||||
실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도
|
||||
다르다. 이 가이드는 둘을 섞지 않는다.
|
||||
|
||||
| 표시 | 무엇인가 |
|
||||
|---|---|
|
||||
| **하기** · **확인** | 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩 |
|
||||
| **근거를 재려면** | 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다 |
|
||||
|
||||
예를 들어 nginx 에러 로그가 잘렸을 때, 실무자는 잘린 걸 보고 access 로그로
|
||||
넘어간다. 길이를 재서 2048인지 확인하는 것은 **몰라서 재는** 것이고, 알면
|
||||
재지 않는다.
|
||||
|
||||
같은 이유로 `curl` 도 두 형태가 있다.
|
||||
|
||||
```bash
|
||||
curl -I https://auth.hyeonworks.com/realms/master # 한 번 볼 때
|
||||
curl -s -o /dev/null -w '%{http_code}\n' <url> # 여러 번 재서 비교할 때
|
||||
```
|
||||
|
||||
이 가이드의 **확인**은 값을 대조해야 해서 두 번째 형태를 자주 쓴다. 실제로
|
||||
터미널에서 눈으로 볼 때는 첫 번째로 충분하다.
|
||||
|
||||
## 자리표시자를 두지 않는다
|
||||
|
||||
`<토큰>` 처럼 적어 두면 그 값을 어디서 가져오는지가 문서 밖으로 나간다.
|
||||
이 가이드는 **값을 찾는 명령을 함께 적는다.**
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 터미널 스크롤백과
|
||||
화면 공유에 남기 때문이다.
|
||||
|
||||
## 어느 기계에서 치는가
|
||||
|
||||
이 실험대에는 셸이 네 개 있고, **같은 명령이 어디서 도느냐에 따라 결과가
|
||||
달라진다.** 그래서 모든 코드 블록 앞에 어디서 치는지를 붙인다.
|
||||
|
||||
| 표시 | 어느 기계 | 어떻게 들어가나 |
|
||||
|---|---|---|
|
||||
| `[워크스테이션]` | 평소 쓰는 개발 머신 | — |
|
||||
| `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` |
|
||||
| `[kc-lab-edge]` | 엣지 게스트 — nginx · certbot | `ssh kc-lab-edge` (**lab host 에서만**) |
|
||||
| `[kc-lab-1]` | k3s server 게스트 | `ssh kc-lab-1` (**lab host 에서만**) |
|
||||
| `[kc-lab-2]` | k3s agent 게스트 | `ssh kc-lab-2` (**lab host 에서만**) |
|
||||
|
||||
**기본은 `[lab host]` 다.** 게스트는 libvirt NAT(`192.168.122.0/24`) 안에
|
||||
있어서 워크스테이션에서 직접 닿지 않는다. `ssh kc-lab-1` 이라는 별칭도
|
||||
lab host 의 `~/.ssh/config` 에만 있다.
|
||||
|
||||
```bash
|
||||
[워크스테이션] $ ping -c1 192.168.122.11
|
||||
1 packets transmitted, 0 received, 100% packet loss # 경로가 없다
|
||||
```
|
||||
|
||||
**게스트 안에 들어가서 다음 단계를 치지 않는다.** 게스트에는 lab host 의
|
||||
개인키도 `~/.ssh/config` 도 없으므로, 게스트 안에서 `ssh kc-lab-1` 을 치면
|
||||
이렇게 끝난다.
|
||||
|
||||
```bash
|
||||
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||
Host key verification failed.
|
||||
```
|
||||
|
||||
**이 실패가 조용한 이유** — 위 명령을 `TOKEN=$(...)` 로 감싸면 오류는
|
||||
stderr 로 흘러가고 `TOKEN` 에는 **빈 문자열**이 담긴다. 셸은 아무 불평도
|
||||
하지 않는다. 그래서 게스트에 로그인한 채 다음 단계를 치면 몇 단계 뒤에
|
||||
가서야 증상이 나타난다.
|
||||
|
||||
그래서 이 가이드는 게스트에 **로그인하지 않고** lab host 에서
|
||||
`ssh kc-lab-1 '...'` 형태로 원격 실행한다. 셸이 하나뿐이면 「지금 어디
|
||||
있더라」가 생기지 않는다.
|
||||
|
||||
## 순서
|
||||
|
||||
앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고,
|
||||
그 상태를 확인하는 명령이 있다. **그것이 통과해야 다음으로 넘어간다.**
|
||||
|
||||
| 단계 | 무엇을 세우나 | 끝나면 확인되는 것 |
|
||||
|---|---|---|
|
||||
| [00](00-lab-host/) | lab host 가상화 준비 | `virsh list` 가 돈다 |
|
||||
| [01](01-vms/) | VM 세 대 (엣지 + k3s 2노드) | 세 게스트에 SSH 가 붙는다 |
|
||||
| [02](02-k3s/) | k3s server + agent | `kubectl get nodes` 에 둘 다 Ready |
|
||||
| [03](03-nginx/) | 엣지 nginx 라우팅 + 호스트 DNAT | 밖에서 요청이 파드까지 닿는다 |
|
||||
| [04](04-tls/) | Let's Encrypt | `https://` 가 열리고 체인이 4단계 |
|
||||
| [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 |
|
||||
| [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 |
|
||||
| [experiments](experiments/) | 실험 26건 | 각 실험의 판정 기준 |
|
||||
|
||||
## 이 가이드가 검증된 방식
|
||||
|
||||
**읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.**
|
||||
버전·IP·메모리 같은 값은 지어내지 않았다.
|
||||
|
||||
**만드는 명령은 다르다.** VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는
|
||||
실험대가 없어지므로, 그 명령들은 **실제로 구축할 때 쓴 것을 그대로 옮겼고**
|
||||
결과 상태를 확인하는 것으로 대신했다. 어느 쪽인지 각 단계에 표시한다.
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
각 단계 끝에 **「막히면」** 표가 있다. 거기 적힌 증상은 전부 이 실험대가
|
||||
실제로 겪은 것이고, 원문은 [`../evidence/`](../evidence/) 에 있다.
|
||||
지어낸 실패 사례는 없다.
|
||||
@@ -0,0 +1,112 @@
|
||||
# 실험 재현 가이드 26편
|
||||
|
||||
각 편은 **직접 쳐서 재현**하는 문서다. 무엇을 발견했는지는
|
||||
[`docs/experiment-*.md`](../../) 가 적고, 여기는 **그것을 다시 만들려면 무엇을
|
||||
어떤 순서로 치는가**를 적는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[기반 7단계](../)가 끝나 있어야 한다. 특히 [05](../05-keycloak/) 까지는
|
||||
모든 실험의 공통 전제이고, 지표를 보는 실험은 [06](../06-observability/) 도
|
||||
필요하다.
|
||||
|
||||
## 각 편의 구조
|
||||
|
||||
```
|
||||
이 가이드가 끝나면 · 전제 · 주의 · 표시 규약
|
||||
0 왜 이 실험인가
|
||||
1 기준선 ← 주입 전에 평시를 잡는다
|
||||
2 주입
|
||||
3 주입 검증 ← 여기가 대부분의 편에서 가장 중요하다
|
||||
4 관찰
|
||||
5 복구
|
||||
막히면 · 다음
|
||||
```
|
||||
|
||||
**3번이 핵심인 편이 많다.** 이 실험대에서 주입은 아홉 번 조용히 실패했고,
|
||||
실패한 주입은 「아무 일도 없었다」로 보여 「영향이 없다」와 구별되지 않는다.
|
||||
그래서 결과를 읽기 전에 대상이 실제로 그 상태인지를 따로 확인한다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 증거 파일에 있는 출력 원문. 그대로 나온다 |
|
||||
| **형태** | 모양만 같고 값은 환경마다 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 고쳐 쓴 형태. 원래 실행에서 그대로 쓰이지는 않았다 |
|
||||
|
||||
## A층 — Keycloak 자체가 깨질 때
|
||||
|
||||
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||
|---|---|---|
|
||||
| A-0 | [세션 공유 경로](a0-session-replication.md) | 세션을 나르는 것이 Infinispan 이 아니라 PostgreSQL 이라는 것 |
|
||||
| A-1 | [7800 차단](a1-jgroups-transport-block.md) | 세션 공유는 안 깨지고 로그아웃 전파만 깨진다 |
|
||||
| A-2 | [DB 정지](a2-database-loss.md) | 503 이 나는 동안에도 `up` 이 1 이다 |
|
||||
| A-3 | [DB 크래시](a3-database-crash.md) | 200 을 받은 로그인 153건 중 4건이 DB 에 없다 |
|
||||
| A-4 | [노드 상실](a4-node-loss.md) | 죽은 노드가 40초 동안 `Ready` 로 읽힌다 |
|
||||
| A-5 | [비대칭 분단](a5-asymmetric-partition.md) | 한 방향만 막으면 열린 쪽으로 재연결한다 |
|
||||
| A-6 | [지연 주입](a6-latency-injection.md) | 200밀리초가 두 단계를 지나 22.2초가 된다 |
|
||||
| A-7 | [volatile 비교](a7-volatile-comparison.md) | 설정 하나로 A층 결론 셋이 뒤집힌다 |
|
||||
| A-7a | [volatile 원인 확정](a7a-volatile-cause.md) | 같은 설정이 캐시 온도만으로 400·500·200 세 답을 낸다 |
|
||||
| A-8 | [롤링 재시작](a8-rolling-restart.md) | 세션은 남고 캐시만 사라진다 |
|
||||
|
||||
## B층 — 애플리케이션 쪽 저장소
|
||||
|
||||
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||
|---|---|---|
|
||||
| B-0 | [자동구성 확인](b0-bff-redis-deploy.md) | 아무것도 안 주면 Spring 이 무엇을 고르는가 |
|
||||
| B-1 | [Redis 세션 저장소](b1-redis-session-store.md) | 세션은 옮겨지는데 토큰은 안 따라온다 |
|
||||
| B-2 | [다중 인스턴스](b2-multi-instance-session.md) | 저장소를 옮겨도 안 고쳐지는 것 — 원인은 기본키다 |
|
||||
| B-3 | [refresh 경쟁](b3-refresh-token-contention.md) | 이긴 요청의 토큰조차 못 쓴다 |
|
||||
| B-4 | [Edge 인가 범위](b4-edge-authorization-scope.md) | 위조 헤더가 그대로 도착한다 |
|
||||
| B-5 | [Redis 상실·영속화](b5-redis-loss-persistence.md) | 볼륨 없는 영속화 설정은 장식이다 |
|
||||
| B-6 | [키 회전](b6-key-rotation.md) | JWKS 캐시에 유예 구간이 없다 |
|
||||
| B-7 | [cookie secret 회전](b7-cookie-secret-rotation.md) | 겹침 구간을 만들 수 없고 서버 세션이 고아로 남는다 |
|
||||
| B-7a | [고아 세션 정리](b7a-orphan-session.md) | TTL 로 생성 시각을 역산해 골라낸다 |
|
||||
|
||||
## C층 — SSO 와 로그아웃
|
||||
|
||||
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||
|---|---|---|
|
||||
| C-1 | [다중 앱 SSO](c1-multi-app-sso.md) | SSO 는 되는데 로그아웃이 안 퍼진다 |
|
||||
| C-2 | [백채널 로그아웃](c2-backchannel-logout.md) | 양쪽 다 없었다 — 한쪽만 고치면 여전히 안 된다 |
|
||||
|
||||
## D층 — 운영
|
||||
|
||||
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||
|---|---|---|
|
||||
| D-1 | [백업·복구](d1-backup-restore.md) | 백업이 진짜 백업인지 스키마를 지워서 확인한다 |
|
||||
| D-2 | [버전 업그레이드](d2-version-upgrade.md) | 롤백이 되는 조건은 스키마가 안 움직였을 때다 |
|
||||
| D-3 | [비밀 관리](d3-secret-management.md) | base64 는 인코딩이지 암호화가 아니다 |
|
||||
| D-4 | [인증서 갱신](d4-certificate-renewal.md) | 갱신은 성공했는데 38분 25초 동안 옛 인증서가 나갔다 |
|
||||
| D-4a | [deploy 훅](d4a-deploy-hook.md) | 훅 하나로 그 공백이 1~2초가 된다 |
|
||||
|
||||
## 순서
|
||||
|
||||
A-0 을 먼저 한다. 나머지 A층 결론이 전부 거기서 확인한 「세션이 어디 있는가」
|
||||
위에 서 있다.
|
||||
|
||||
```
|
||||
A-0 ─┬─ A-1 ─┬─ A-5
|
||||
│ └─ A-6
|
||||
├─ A-2 ── A-3 ── D-1 ── D-2
|
||||
├─ A-4
|
||||
├─ A-8
|
||||
└─ A-7 ── A-7a ← A층을 다 한 뒤 설정 하나만 바꿔 재실행한다
|
||||
|
||||
B-0 ── B-1 ─┬─ B-2 · B-3 · B-4 · B-5 · B-6
|
||||
└─ B-7 ── B-7a
|
||||
|
||||
C-1 ── C-2 D-3 · D-4 ── D-4a (언제든 독립적으로)
|
||||
```
|
||||
|
||||
**A-7 을 A층 마지막에 두는 이유** — 앞의 실험을 다 마친 뒤 설정 하나만 바꿔
|
||||
재실행하면 **같은 주입에 대한 정반대 결과**를 한 벌로 얻는다.
|
||||
|
||||
## 안전
|
||||
|
||||
각 편의 2번(주입)부터 상태가 바뀐다. 모든 편이 **되돌리는 명령을 주입보다
|
||||
먼저** 보여 주고, 5번에서 원상복구를 확인한다.
|
||||
|
||||
호스트(`test-server`)에서 하는 일은 sudo 비밀번호가 필요해 **사람이 직접
|
||||
쳐야** 한다. D-1 과 D-4 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다.
|
||||
@@ -0,0 +1,956 @@
|
||||
# A-2 재현 가이드 — PostgreSQL 을 내리고 살아남는 노드가 있는지 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md) ·
|
||||
증거 원문: [`docs/evidence/a2-database-loss/`](../../evidence/a2-database-loss/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것 | 상주 탐침 파드 |
|
||||
| JWKS 와 `.well-known` 만 `200` 으로 살아 있는 것 | 같은 파드 |
|
||||
| Ready 파드가 **0개**, `ready` 주소가 **빈 목록**인 것 | `endpointslice` |
|
||||
| 정문이 `503` 을 주는 것 | 밖에서 `curl` |
|
||||
| `database connections` 만 DOWN 인 헬스 본문 | `health/ready` |
|
||||
| **`up = 1` 인 채로 전면 장애가 나 있는 것** | Prometheus |
|
||||
| 15초 만에 **재시작 0회**로 스스로 돌아오는 것 | `get pods` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`A-0`](a0-session-replication.md) 을 먼저 한다. 「세션은 DB 가 공유한다」를
|
||||
손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 네임스페이스는 `keycloak-lab`, Prometheus 는 `observability` 다.
|
||||
- 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용.
|
||||
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||
|
||||
## 주의 — 이건 전면 장애를 만드는 실험이다
|
||||
|
||||
**정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다.** 이 실험대를 쓰는
|
||||
다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는
|
||||
명령은 하나뿐이고 [5. 복구](#5-복구) 에 있다. 중간에 그만두려면 그것만 치면 된다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 11:53–11:58 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
|
||||
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
|
||||
실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
[A-1](a1-jgroups-transport-block.md) 에서 **「룩어사이드 캐시는 읽을 때 DB 와
|
||||
대조하지 않는다」**를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도
|
||||
캐시를 가진 노드가 `200` 을 줬다.
|
||||
|
||||
**그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다.** 그 가설을 가른다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 캐시가 DB 를 대신한다면 | 캐시를 가진 노드는 **살아남는다** — 부분 장애 |
|
||||
| 대신하지 못한다면 | **전면 장애** |
|
||||
|
||||
그리고 A-1 과의 대비가 이 실험의 진짜 값이다.
|
||||
|
||||
```
|
||||
A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하)
|
||||
A-2 DB 정지 → ? (여기서 판정)
|
||||
```
|
||||
|
||||
**네 경로를 구분해서 본다.** 하나만 재면 무엇 때문에 죽었는지 모른다.
|
||||
|
||||
| # | 경로 | 무엇을 보는가 |
|
||||
|---|---|---|
|
||||
| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
|
||||
| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
|
||||
| ③ | 새 로그인 | 쓰기 경로 |
|
||||
| ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 |
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — DB 를 내리기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 정지 후에 볼 것을 정지 전에 **똑같은
|
||||
명령으로** 먼저 봐 둔다.
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험
|
||||
```
|
||||
|
||||
## 1-1. 파드와 노드
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
|
||||
```
|
||||
keycloak-0 true 10.42.1.67 kc-lab-2
|
||||
keycloak-1 true 10.42.0.35 kc-lab-1
|
||||
postgres-7b474b88c8-sn9ff true 10.42.1.24 kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- `READY` 가 셋 다 `1/1`, `RESTARTS` 가 `0`
|
||||
- **`postgres` 가 어느 노드에 있는가.** 원래 실행에서는 `kc-lab-2`, 즉
|
||||
`keycloak-0` 과 **같은 노드**다
|
||||
- IP 세 개를 적어 둔다
|
||||
|
||||
**이 결과가 의미하는 것** — `postgres` 와 `keycloak-0` 이 같은 노드에 있다는
|
||||
사실은 이 실험에서는 상관없지만, **A-4(노드 상실)에서는 결정적이다.**
|
||||
그 노드를 죽이면 A-2 가 함께 일어난다.
|
||||
|
||||
IP 는 변수로 잡아 둔다.
|
||||
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||
echo "$K0 $K1"
|
||||
```
|
||||
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||
```
|
||||
keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
|
||||
```
|
||||
|
||||
## 1-2. 클러스터가 정상인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
|
||||
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||
|
||||
읽기 좋게 자른다. **미검증**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
|
||||
```
|
||||
cluster_size keycloak-1 = 2
|
||||
cluster_size keycloak-0 = 2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
|
||||
|
||||
**이 결과가 의미하는 것** — A-1 의 분단이 완전히 회복된 상태에서 시작한다.
|
||||
여기가 `1` 이면 A-1 의 잔재가 남은 것이고, 그 위에서 재면 두 실험이 섞인다.
|
||||
|
||||
## 1-3. 상주 탐침 파드를 띄운다 — 계측 도구를 바꾼다
|
||||
|
||||
**A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다.** `--rm` 파드는
|
||||
매번 만들고 지우므로 느리고 경합이 있고, **토큰을 단계 사이로 넘길 수 없다.**
|
||||
|
||||
이 실험은 **DB 정지 전에 발급한 토큰을 정지 후에 써야** 한다. 그래서 파드를
|
||||
하나 띄워 두고 `exec` 로 단계를 이어간다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="K0=$K0" --env="K1=$K1" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
|
||||
```
|
||||
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||
```
|
||||
pod/a2-probe condition met
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
> **실측** — `19`
|
||||
|
||||
**확인** — 환경변수가 들어갔나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
K0=10.42.1.67 K1=10.42.0.35 PW길이=19
|
||||
```
|
||||
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
|
||||
|
||||
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
|
||||
```
|
||||
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit` — **파드는 안 지워진다**
|
||||
(`--rm` 이 없다).
|
||||
|
||||
## 1-4. 양쪽 노드에 세션을 하나씩 만든다
|
||||
|
||||
**이 실험의 ① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야
|
||||
한다.** A-0 에서 확인한 성질을 그대로 쓴다 — **각 노드는 자기가 로그인시킨
|
||||
세션만 캐시한다.**
|
||||
|
||||
**하기** — 파드 안 셸에서
|
||||
```sh
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
for H in "$K0" "$K1"; do
|
||||
echo -n "$H : "
|
||||
curl -s -X POST "http://$H:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p'
|
||||
done
|
||||
```
|
||||
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||
```
|
||||
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
|
||||
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
|
||||
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — sid 두 개가 나온다. 빈 줄이 나오면 로그인이 실패했거나
|
||||
base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다.
|
||||
|
||||
## 1-5. 세션이 각자 노드에만 캐시되었는가
|
||||
|
||||
**확인** — 밖에서. Keycloak 이미지에는 `curl` 이 없으므로 Prometheus 에 묻는다
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
|
||||
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
|
||||
```
|
||||
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||
```
|
||||
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
|
||||
keycloak-1 = 0 건
|
||||
keycloak-0 = 1 건
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `cache` 가 `sessions` 인 두 줄. 값이 서로 다르다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`keycloak-0` 은 캐시를 가졌고 `keycloak-1` 은 없다.**
|
||||
이제 ① 과 ② 를 구분해서 물을 수 있다.
|
||||
|
||||
> **`keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다.** 방금 로그인했으므로
|
||||
> 다음 15초 스크레이프에서 `1` 이 될 수 있다. 원래 실행 기록에도 그렇게 적혀
|
||||
> 있다 — 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」.
|
||||
> **중요한 것은 「양쪽이 다르다」가 아니라 「`keycloak-0` 이 확실히 가지고
|
||||
> 있다」다.** ① 의 해석에 필요한 것은 그것뿐이다.
|
||||
|
||||
**확인** — DB 에는 몇 건인가
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
|
||||
```
|
||||
=== DB 온라인 세션 ===
|
||||
2
|
||||
```
|
||||
**이 숫자를 적어 둔다.** 복구 후에 세션이 살아남았는지 볼 대조군이다.
|
||||
|
||||
## 1-6. 대조군 — DB 가 살아 있을 때 네 경로가 전부 되는 것을 먼저 본다
|
||||
|
||||
**이 절을 건너뛰면 뒤의 `500` 이 아무 의미가 없다.**
|
||||
|
||||
### ④ 에 쓸 클라이언트 id 를 지금 뽑아 둔다
|
||||
|
||||
**DB 가 죽은 뒤에는 이 조회 자체가 실패한다.** 미리 잡아 놔야 ④ 를 측정할 수
|
||||
있다.
|
||||
|
||||
**확인** — 파드 안에서. 응답을 한 번 그대로 본다
|
||||
```sh
|
||||
AT=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
curl -s -H "Authorization: Bearer $AT" \
|
||||
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
|
||||
```
|
||||
**형태** — 객체 하나짜리 배열. `"id"` 가 맨 앞에 있다
|
||||
```json
|
||||
[{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli", ...}]
|
||||
```
|
||||
|
||||
무엇을 자르는지 눈으로 본 다음 잘라낸다. **미검증**
|
||||
```sh
|
||||
CID=$(curl -s -H "Authorization: Bearer $AT" \
|
||||
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
|
||||
| tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
|
||||
echo "CID=$CID"
|
||||
```
|
||||
|
||||
> `sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 **뒤쪽의 다른 `id` 를 잡을 수
|
||||
> 있다.** `.*` 가 탐욕적이라 줄에서 마지막 `"id":"` 를 고른다.
|
||||
|
||||
### 네 경로를 정상 상태에서 한 번 돌린다
|
||||
|
||||
**하기** — 파드 안에서
|
||||
```sh
|
||||
R0=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
|
||||
RT0=$(echo "$R0" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
R1=$(curl -s -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
|
||||
RT1=$(echo "$R1" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
|
||||
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
|
||||
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
|
||||
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
|
||||
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
|
||||
```
|
||||
**형태** — 정상 상태에서는
|
||||
```
|
||||
① 200
|
||||
② 200
|
||||
③ 200
|
||||
④ 200
|
||||
```
|
||||
|
||||
**★ `-o /dev/null` 을 빼면 안 된다.** 빼면 본문과 상태코드가 한 줄에 섞여
|
||||
나온다. 원래 실행이 정확히 이걸 당했다 — [4-1](#4-1-네-경로) 을 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — 네 경로가 전부 `200` 인 것이 기준선이다. 정지 후에
|
||||
`500` 이면 「내가 깨뜨린 것」이고, 대조군 없이는 이 구별이 안 된다.
|
||||
|
||||
### ⑤ 상태가 필요 없는 경로도 미리 재 둔다
|
||||
|
||||
**하기**
|
||||
```sh
|
||||
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
|
||||
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||
"http://$K0:8080/realms/master/.well-known/openid-configuration"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
JWKS 200
|
||||
well-known 200
|
||||
```
|
||||
|
||||
**확인** — 밖에서 정문도 재 둔다
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
`exit` 으로 파드 셸에서 나온다. **파드는 그대로 둔다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — PostgreSQL 을 0대로 내린다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
```
|
||||
|
||||
## 2-1. 왜 `scale --replicas=0` 인가
|
||||
|
||||
| 방법 | 무엇이 일어나나 |
|
||||
|---|---|
|
||||
| **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** |
|
||||
| `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 |
|
||||
| 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** |
|
||||
|
||||
**`delete pod` 를 쓰면 이 실험이 성립하지 않는다.** DB 가 없는 구간을 원하는
|
||||
만큼 유지할 수 있어야 네 경로를 다 재고 헬스와 엔드포인트까지 볼 수 있다.
|
||||
|
||||
> 이것은 **정상 종료**다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤
|
||||
> 내려간다. **데이터는 하나도 잃지 않는다.** 강제로 죽였을 때 무엇을 잃는지는
|
||||
> [A-3](a3-database-crash.md) 이 잰다.
|
||||
|
||||
## 2-2. 적용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 정지'
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
date '+%H:%M:%S 삭제완료'
|
||||
```
|
||||
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
|
||||
```
|
||||
=== [2] PostgreSQL 정지 ===
|
||||
정지 시각: 11:56:04
|
||||
deployment.apps/postgres scaled
|
||||
pod/postgres-7b474b88c8-sn9ff condition met
|
||||
삭제 완료: 11:56:04
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 시각이 같다.** 즉시 사라진다.
|
||||
|
||||
**시각을 반드시 적어 둔다.** 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면
|
||||
인과를 못 붙인다.
|
||||
|
||||
> **access token 수명이 60초다.** 1-6 에서 발급한 `AT` 로 ④ 를 재려면
|
||||
> **발급 → 정지 → 시험을 60초 안에** 끝내야 한다. 60초를 넘기면 ④ 의 `401`
|
||||
> 이 「DB 때문」인지 「토큰 만료」인지 구별되지 않는다. 시간이 지났으면
|
||||
> 4-1 전에 토큰을 다시 받아 둔다 — 단, **그건 DB 가 있어야 되는 일**이므로
|
||||
> 순서는 「토큰 발급 → 정지」다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
## 3-1. postgres 파드가 정말 없나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `postgres` 로 시작하는 줄이 **한 개도 없다.**
|
||||
`Terminating` 으로 남아 있으면 아직 안 끝난 것이다. `wait` 가 통과했으면 없다.
|
||||
|
||||
**확인** — Deployment 쪽도 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy postgres
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY UP-TO-DATE AVAILABLE AGE
|
||||
postgres 0/0 0 0 5d
|
||||
```
|
||||
|
||||
`0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.
|
||||
|
||||
## 3-2. Keycloak 이 실제로 DB 에 못 붙고 있나
|
||||
|
||||
**확인** — 로그가 원인을 말한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'
|
||||
```
|
||||
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||
```
|
||||
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664)
|
||||
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645)
|
||||
Caused by: java.net.ConnectException: Connection refused
|
||||
at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219)
|
||||
at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Connection refused` 와 `agroal`.
|
||||
|
||||
**이 결과가 의미하는 것** — `agroal` 은 Quarkus 의 커넥션 풀이다. **풀이 새
|
||||
커넥션을 만들지 못한다.** 이 줄이 없으면 Keycloak 은 아직 옛 커넥션으로 버티고
|
||||
있거나, 애초에 DB 가 안 죽은 것이다.
|
||||
|
||||
> `Connection refused` 이지 `timed out` 이 아니다. Service 는 남아 있지만 뒤에
|
||||
> 파드가 없어 **연결이 즉시 거부**된다. 네트워크를 막았다면 timeout 이 나왔을
|
||||
> 것이고 증상이 훨씬 느리게 나타난다 — 그건 다른 실험이다.
|
||||
|
||||
## 3-3. 엉뚱한 것을 죽이지 않았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
|
||||
| grep keycloak
|
||||
```
|
||||
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||
```
|
||||
keycloak-0 false 0
|
||||
keycloak-1 false 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `READY` 가 `false` 인데 **`RESTARTS` 가 여전히 `0`.**
|
||||
|
||||
**이 결과가 의미하는 것** — 파드는 **죽지 않았다.** 트래픽에서 빠졌을 뿐이다.
|
||||
`RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을
|
||||
재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다.
|
||||
|
||||
**이 `restarts=0` 이 8절의 결론(자동 회복)을 가능하게 하는 조건이다.**
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 네 경로
|
||||
|
||||
**하기** — 탐침 파드 안에서. 1-6 과 **똑같은 명령**을 다시 친다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
|
||||
```
|
||||
```sh
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
|
||||
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
|
||||
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
|
||||
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
|
||||
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
|
||||
```
|
||||
|
||||
> 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라진다. **셸을
|
||||
> 붙잡고 있는 편이 낫다.** 그래서 터미널 두 개를 열라고 한 것이다.
|
||||
|
||||
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt) ·
|
||||
④ 는 [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||
```
|
||||
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
|
||||
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
|
||||
③ 새 로그인 HTTP 500
|
||||
④ 관리 API (세션 조회 필요) HTTP 500
|
||||
```
|
||||
|
||||
**하기** — 본문도 한 번 그대로 본다
|
||||
```sh
|
||||
curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 줄 전부 `500`. 그리고 본문이 **아무것도 말해 주지
|
||||
않는다.** 원인은 3-2 의 서버 로그에만 있다.
|
||||
|
||||
### ★ ④ 의 첫 측정은 오염됐다 — 이 함정에 걸리지 않는다
|
||||
|
||||
원래 실행의 증거 파일에는 이렇게 남아 있다.
|
||||
|
||||
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
|
||||
```
|
||||
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
|
||||
```
|
||||
|
||||
**읽어 보면 세 가지가 한 줄에 뭉쳐 있다.**
|
||||
|
||||
```
|
||||
HTTP 000000{"error":"HTTP 401 Unauthorized"}401
|
||||
─┬──── ──────────┬─────────────────── ─┬─
|
||||
│ │ └─ 마지막 시도의 상태코드
|
||||
│ └─ 응답 본문이 그대로 섞였다
|
||||
└─ 재시도가 세 번 "000" 을 찍었다 (연결 실패)
|
||||
```
|
||||
|
||||
`curl -w '%{http_code}'` 를 쓰면서 **`-o /dev/null` 을 빼면** 본문이 표준출력으로
|
||||
같이 나온다. 여기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓였다.
|
||||
|
||||
> **위 표의 ④ `500` 은 5절에서 다시 잰 값이다.** 첫 측정은 그대로 쓰지 않았다.
|
||||
> 오염된 측정은 **버리고 다시 잰다.** 「`401` 인가 `500` 인가」를 추측으로
|
||||
> 메우면 안 된다.
|
||||
|
||||
**당신은 1-6 부터 `-o /dev/null` 을 쓰고 있으므로 이 함정을 지난다.**
|
||||
|
||||
## 4-2. ① 이 `500` 인 것이 이 실험의 핵심이다
|
||||
|
||||
**캐시에 세션을 들고 있어도 refresh 는 실패한다.**
|
||||
|
||||
A-1 에서는 로그아웃되어 DB 행이 사라진 세션에 대해 캐시를 가진 노드가 `200` 을
|
||||
줬다. **왜 여기서는 안 되는가.**
|
||||
|
||||
```
|
||||
refresh 처리
|
||||
├── 세션이 존재하는가 → 캐시로 답할 수 있다
|
||||
└── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
|
||||
```
|
||||
|
||||
[A-0](a0-session-replication.md) 에서 잡은 SQL 그대로다.
|
||||
|
||||
```sql
|
||||
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
|
||||
```
|
||||
|
||||
> **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.**
|
||||
> **refresh 는 이름과 달리 쓰기 연산이다.**
|
||||
|
||||
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|
||||
|---|---|---|
|
||||
| DB | 살아 있다 | **없다** |
|
||||
| 캐시가 답할 수 있는 부분 | 세션 존재 확인 → `200` | 세션 존재 확인 → 거기까지 |
|
||||
| DB 가 필요한 부분 | `UPDATE` 는 성공 | **`UPDATE` 실패 → `500`** |
|
||||
|
||||
## 4-3. 살아남은 것 — 상태가 필요 없는 경로
|
||||
|
||||
**하기** — 파드 안에서, 1-6 의 ⑤ 를 그대로
|
||||
```sh
|
||||
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
|
||||
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||
"http://$K0:8080/realms/master/.well-known/openid-configuration"
|
||||
```
|
||||
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||
```
|
||||
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
|
||||
JWKS 엔드포인트(realm 공개키) HTTP 200
|
||||
realm 메타데이터(.well-known) HTTP 200
|
||||
관리 API(세션 조회 필요) HTTP 500
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 같은 파드, 같은 포트인데 **경로에 따라 `200` 과 `500`
|
||||
이 갈린다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **realm 공개키와 메타데이터는 메모리에 있으므로
|
||||
DB 없이도 응답한다.** 이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰
|
||||
검증을 계속할 수 있다**는 뜻이다.
|
||||
|
||||
> 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가 말할 수
|
||||
> 있는 범위**다. B층에서 확인한다.
|
||||
>
|
||||
> **그리고 정문으로는 이것도 못 쓴다.** 다음 절 때문이다.
|
||||
|
||||
## 4-4. 전면 장애 — 살아남는 노드가 없다
|
||||
|
||||
**확인** — Service 가 어느 파드를 잡고 있나
|
||||
```bash
|
||||
sudo 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
|
||||
```
|
||||
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||
```
|
||||
=== Service 엔드포인트 ===
|
||||
ready : [] ← 비었다
|
||||
notReady: [10.42.0.35 10.42.1.67]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`ready` 가 빈 목록.** 두 IP 가 전부 `notReady` 다.
|
||||
|
||||
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
|
||||
> 뜬다. 원래 실행 기록에도 그 경고가 두 줄 남아 있다.
|
||||
>
|
||||
> **실측**
|
||||
> ```
|
||||
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||
> ```
|
||||
> 사람이 눈으로 볼 때는 이쪽이 더 짧다.
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
|
||||
> ```
|
||||
|
||||
**확인** — 밖에서
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
=== 외부 진입점 ===
|
||||
https://auth.hyeonworks.com/realms/master HTTP 503
|
||||
```
|
||||
|
||||
한 번 눈으로 볼 때는 헤더까지 본다.
|
||||
```bash
|
||||
curl -I https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **`503` 은 Keycloak 이 준 것이 아니다.** Ready 인
|
||||
백엔드가 하나도 없어서 그 앞의 프록시가 준 것이다. 4-3 에서 `200` 이던 JWKS 도
|
||||
정문으로는 닿지 않는다 — **readiness 게이트가 문을 닫았다.**
|
||||
|
||||
### A-1 과의 대비가 이 실험의 결론이다
|
||||
|
||||
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|
||||
|---|---|---|
|
||||
| Ready 인 파드 | `keycloak-1` **1개 생존** | **0개** |
|
||||
| Service `ready` | `[10.42.0.35]` | **`[]`** |
|
||||
| 외부 응답 | **200** | **503** |
|
||||
| 성격 | 용량 저하 | **전면 장애** |
|
||||
|
||||
**노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다.**
|
||||
**Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.**
|
||||
|
||||
> 「Redis 또는 DB 가 죽으면 어떻게 복구하는가」에 대한 첫 번째 답 —
|
||||
> **복구 이전에, DB 이중화가 Keycloak 대수보다 우선한다.**
|
||||
|
||||
## 4-5. 헬스 본문이 이유를 말한다
|
||||
|
||||
**확인** — Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a2-probe -- \
|
||||
curl -s "http://$K0:9000/health/ready"
|
||||
```
|
||||
|
||||
**형태** — 한 줄 JSON 이 나온다. 한 번은 그대로 본다.
|
||||
|
||||
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||
```
|
||||
=== health/ready 상세 ===
|
||||
전체: DOWN
|
||||
Graceful Shutdown UP
|
||||
Keycloak cluster health check UP
|
||||
Keycloak database connections async health check DOWN
|
||||
Keycloak Initialized UP
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **네 항목 중 하나만 DOWN 인데 전체가 DOWN 이다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **헬스체크는 모든 항목이 UP 이어야 UP 이다.**
|
||||
그리고 **`cluster health` 는 UP** 이다 — 클러스터는 멀쩡하다. A-1 에서는 정확히
|
||||
반대였다(cluster DOWN, database UP). **같은 `503` 이라도 어느 체크가 DOWN 인지가
|
||||
장애를 구별한다.**
|
||||
|
||||
**확인** — `describe` 로도 같은 것이 보인다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions
|
||||
```
|
||||
|
||||
## 4-6. 관측의 함정 — `up = 1` 인 채로 전면 장애
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=up' \
|
||||
| tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]'
|
||||
```
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
|
||||
```
|
||||
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
|
||||
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
|
||||
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 둘 다 `1`. **서비스는 `503` 인데.**
|
||||
|
||||
Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 **전 구간 평평하다.**
|
||||
원래 실행의 그림이
|
||||
[`a2-up-stayed-1-during-outage.png`](../../evidence/a2-database-loss/a2-up-stayed-1-during-outage.png)
|
||||
이고, 11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.
|
||||
|
||||
**이 결과가 의미하는 것** — `up` 은 **Prometheus 가 `/metrics` 를 긁는 데
|
||||
성공했는가**만 말한다. 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다.
|
||||
**기능은 전멸했는데.**
|
||||
|
||||
| 지표 | 이 장애에서 |
|
||||
|---|---|
|
||||
| `up` | **1 — 아무것도 알려주지 않는다** |
|
||||
| 파드 `Ready` | **false — 여기서 드러난다** |
|
||||
| 외부 HTTP 코드 | **503 — 사용자가 겪는 것** |
|
||||
|
||||
> **A-0 에서는 `up` 을 「가장 중요한 합성 지표」라고 썼다. 절반만 맞다.**
|
||||
> `up` 은 **대상이 사라진 것**을 잡지만 **대상이 살아서 못 쓰는 것**은 못 잡는다.
|
||||
> 후자가 운영에서 훨씬 흔하다.
|
||||
>
|
||||
> **알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.**
|
||||
|
||||
**확인** — 그럼 readiness 를 지표로 볼 수 있나
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \
|
||||
| head -c 300; echo
|
||||
```
|
||||
**형태** — 결과가 비어 있다
|
||||
```json
|
||||
{"status":"success","data":{"resultType":"vector","result":[]}}
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 이 실험대에는 아직 `kube-state-metrics` 가 없어
|
||||
**파드 readiness 가 지표로 남지 않는다.** 즉 지금 이 장애는 **Prometheus 만
|
||||
보고 있으면 알 수 없다.** **관측 스택에 빠진 것을 이 실험이 찾아냈다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 재기동'
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
```
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
|
||||
```
|
||||
=== 복구 — PostgreSQL 재기동 ===
|
||||
재기동 시각: 11:57:09
|
||||
deployment.apps/postgres scaled
|
||||
Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated...
|
||||
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
|
||||
deployment "postgres" successfully rolled out
|
||||
```
|
||||
|
||||
## 5-2. Keycloak 이 스스로 회복하는가 — 손대지 않고 본다
|
||||
|
||||
**★ 여기서 Keycloak 을 재시작하고 싶어진다. 참는다.** 재시작하면 이 실험이
|
||||
답하려던 질문(「사람 개입이 필요한가」)이 사라진다.
|
||||
|
||||
**확인** — 15초 간격으로 몇 번 친다
|
||||
```bash
|
||||
sudo 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
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
|
||||
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
|
||||
→ 서비스 복귀
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `READY` 가 둘 다 `true`, 정문이 `200`.
|
||||
|
||||
## 5-3. 재시작 없이 회복한 것이 맞나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
|
||||
keycloak-0 0
|
||||
keycloak-1 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`0`.** 3-3 에서 본 값 그대로다.
|
||||
|
||||
**이 결과가 의미하는 것** — **커넥션 풀이 스스로 재연결하고 readiness 가 다시
|
||||
UP 이 되면서 Service 에 복귀했다.** 사람이 한 일은 DB 를 켠 것뿐이다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 회복 시간 | **약 15초** (DB Ready 이후) |
|
||||
| 사람 개입 | **없음** |
|
||||
| Keycloak 재시작 | **불필요** — `restarts=0` |
|
||||
|
||||
### 개념 — readiness 와 liveness 를 가르는 기준
|
||||
|
||||
| | 실패하면 | 언제 쓰나 |
|
||||
|---|---|---|
|
||||
| **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) |
|
||||
| **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) |
|
||||
|
||||
**DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고, DB 가
|
||||
돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다. 게다가 재시작하면
|
||||
**캐시까지 날아간다.**
|
||||
|
||||
> A-1 에서도 같은 결론이 나왔다. 분단된 노드가 **readiness 로** 빠졌기 때문에
|
||||
> 재시작 없이 격리만 되었다. **Keycloak 은 두 종류의 장애를 다 readiness 로
|
||||
> 신고한다.**
|
||||
|
||||
## 5-4. 세션이 살아남았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
=== 정지 전 세션이 살아남았는가 ===
|
||||
online 세션 5
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 1-5 에서 적어 둔 값보다 크거나 같다. 실험 중에 로그인을
|
||||
여러 번 했으므로 늘어나 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — **세션은 DB 에 있으므로 DB 가 돌아오면 같이
|
||||
돌아온다.** 정상 종료였기 때문에 하나도 잃지 않았다.
|
||||
|
||||
> **강제로 죽였다면 어떨까.** `SET LOCAL synchronous_commit TO OFF` 때문에
|
||||
> 마지막 수백 밀리초의 쓰기가 사라져야 한다. **[A-3](a3-database-crash.md) 이
|
||||
> 그 숫자를 잰다.**
|
||||
|
||||
## 5-5. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| DB | `sudo kubectl -n keycloak-lab get deploy postgres` | `1/1` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 헬스 | `exec a2-probe -- curl -s "http://$K0:9000/health/ready"` | 전체 `UP` |
|
||||
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a2-probe` | 지웠으면 `NotFound` |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
**하기** — 탐침 파드를 지운다. `--rm` 이 없으므로 **직접 지워야 한다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
|
||||
```
|
||||
|
||||
`sleep 7200` 이 끝나면 파드는 `Completed` 로 남는다. **자동으로 사라지지
|
||||
않는다.** 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 원인이 이것이다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 4-1 의 ★ 절. 오염된 측정은 버리고 다시 잰다 |
|
||||
| `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 |
|
||||
| DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` — 2-1 |
|
||||
| ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 — 2-2 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 |
|
||||
| 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 — 4-1 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` — 4-4 |
|
||||
| `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 — 4-6 |
|
||||
| `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 — 4-6 |
|
||||
| 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** — 5-2 |
|
||||
| `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` — 5-5 |
|
||||
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 |
|
||||
|
||||
---
|
||||
|
||||
# 왜 이 가이드는 임시 파드를 안 쓰나
|
||||
|
||||
A-1 은 관찰을 `kubectl run --rm` 임시 파드로 했고, **그 계측이 실패했다.**
|
||||
매번 파드를 만들고 지우므로 느리고, 경합이 있고, 빈 출력이 섞였다.
|
||||
|
||||
이 실험은 거기에 더해 **토큰을 단계 사이로 넘겨야 한다.** 임시 파드로는 불가능
|
||||
하다 — 파드가 사라지면 변수도 사라진다.
|
||||
|
||||
```
|
||||
임시 파드 단계마다 새로 뜬다 → 토큰이 안 넘어간다 · 느리다 · 빈 출력
|
||||
상주 파드 한 번 띄워 둔다 → exec 로 이어간다 · 파일에 남길 수 있다
|
||||
```
|
||||
|
||||
**대신 지우는 것을 잊으면 안 된다.** `--rm` 이 없다는 것은 그런 뜻이다.
|
||||
|
||||
> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가
|
||||
> 그러라고 있는 것이고, 사람이 손으로 묻는 것은 상주 파드가 낫다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-2 가 남긴 질문 |
|
||||
|---|---|
|
||||
| [A-3](a3-database-crash.md) DB 강제 종료 | **정상 정지는 하나도 안 잃었다. 강제 종료는?** `synchronous_commit OFF` 의 대가 |
|
||||
| A-4 노드 상실 | `postgres` 가 `kc-lab-2` 에 있다 — **그 노드를 죽이면 A-2 가 함께 일어난다** |
|
||||
| D-1 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** |
|
||||
| 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 |
|
||||
| 전부 | **알림을 `up` 에 걸지 않는다.** readiness 와 외부 응답 코드에 건다 |
|
||||
@@ -0,0 +1,993 @@
|
||||
# A-3 재현 가이드 — DB 를 진짜로 죽여서 몇 건이 사라지는지 센다
|
||||
|
||||
해설 문서: [`docs/experiment-a3-database-crash.md`](../../experiment-a3-database-crash.md) ·
|
||||
증거 원문: [`docs/evidence/a3-database-crash/`](../../evidence/a3-database-crash/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 로그인 트랜잭션에 붙은 `SET LOCAL synchronous_commit TO OFF` | PostgreSQL 문장 로그 |
|
||||
| `--grace-period=0 --force` 가 **크래시가 아니라는 것** | crash recovery 가 없는 재기동 로그 |
|
||||
| 컨테이너 안에서 **PID 1 이 SIGKILL 을 무시하는 것** | 파드 재시작 0, 로그 시각 그대로 |
|
||||
| `not properly shut down` / `redo starts` / `redo done` | 같은 로그 |
|
||||
| **`200` 과 토큰을 받았는데 DB 에 없는 sid** | `comm` 으로 뽑은 차집합 |
|
||||
| `wal_writer_delay = 200ms` 가 기본값이라는 것 | `pg_settings` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`A-0`](a0-session-replication.md) 과 [`A-2`](a2-database-loss.md) 를 먼저 한다.
|
||||
A-0 이 `SET LOCAL synchronous_commit TO OFF` 를 발견했고, 이 실험은 **그
|
||||
대가가 몇 건인지**를 잰다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 네임스페이스는 `keycloak-lab`.
|
||||
- 터미널 **두 개가 반드시 필요하다.** 하나는 로그인 루프를 돌리고(붙잡고 있어야
|
||||
한다), 하나는 그 사이에 DB 를 죽인다.
|
||||
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||
|
||||
## 주의 — 이건 데이터를 잃는 실험이다
|
||||
|
||||
**PostgreSQL 을 강제로 죽이고, 세션 테이블을 두 번 비운다.** 실제로 커밋됐다고
|
||||
응답한 데이터가 사라진다. **실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는
|
||||
방법은 매 단계에 적어 두었다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 11:58–12:05 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
sid 와 건수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
|
||||
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행
|
||||
기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
[A-2](a2-database-loss.md) 는 DB 를 **정상 종료**시켰다. 세션은 하나도 안
|
||||
없어졌다. 당연하다 — PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려간다.
|
||||
|
||||
**그런데 [A-0](a0-session-replication.md) 에서 이 한 줄을 잡았다.**
|
||||
|
||||
```sql
|
||||
SET LOCAL synchronous_commit TO OFF
|
||||
```
|
||||
|
||||
`COMMIT` 직전, **같은 트랜잭션 안에서** 나온다. 뜻은 이렇다.
|
||||
|
||||
```
|
||||
COMMIT
|
||||
│
|
||||
├─ WAL 버퍼(메모리)에 기록 ← 항상 한다
|
||||
│
|
||||
├─ synchronous_commit = on : 디스크 플러시를 기다렸다가 응답
|
||||
└─ synchronous_commit = off : 기다리지 않고 즉시 응답 ← Keycloak
|
||||
│
|
||||
└─ 크래시 시 이 구간이 사라진다
|
||||
```
|
||||
|
||||
**「사라질 수 있다」와 「몇 건 사라졌다」는 다르다.** 이 실험은 뒤쪽이다.
|
||||
RPO(Recovery Point Objective)를 숫자로 만든다.
|
||||
|
||||
**그리고 이 실험의 절반은 「죽이는 데 실패하는 이야기」다.** 세 번 시도해서
|
||||
세 번째에 성공했고, 앞의 둘은 **「손실 0건」으로 보였지만 실제로는 죽인 적이
|
||||
없었다.** A-1 이 남긴 교훈이 그대로 나온다 — **주입이 실제로 걸렸는지 먼저
|
||||
확인하지 않으면 「아무 일도 없었다」를 결과로 착각한다.**
|
||||
|
||||
---
|
||||
|
||||
# 1. 설계 확인 — 재기 전에 세 가지를 확인한다
|
||||
|
||||
**측정 설계가 성립하는지부터 본다.** 여기서 하나라도 어긋나면 뒤의 숫자는
|
||||
아무 의미가 없다.
|
||||
|
||||
## 1-1. 눈금이 맞는가 — `LAST_SESSION_REFRESH` 로는 못 잰다
|
||||
|
||||
처음 계획은 「세션 갱신 시각이 되감기는지」 보는 것이었다. 스키마를 보고 접었다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "\d offline_user_session"
|
||||
```
|
||||
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
|
||||
```
|
||||
LAST_SESSION_REFRESH 는 integer(초) — 200ms 손실은 보이지 않는다
|
||||
created_on | integer | | not null |
|
||||
last_session_refresh | integer | | not null | 0
|
||||
"idx_user_session_expiration_created" btree (realm_id, offline_flag, remember_me, created_on, user_session_id, user_id)
|
||||
"idx_user_session_expiration_last_refresh" btree (realm_id, offline_flag, remember_me, last_session_refresh, user_session_id, user_id)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 시각 컬럼의 타입이 `integer` 다. **초 단위.**
|
||||
|
||||
**이 결과가 의미하는 것** — **손실 창은 수백 밀리초인데 눈금이 1초다.**
|
||||
보일 리가 없다. 이 설계는 버린다.
|
||||
|
||||
### 대신 행 존재 여부로 잰다 — 이진 판정
|
||||
|
||||
```
|
||||
로그인 1회 = OFFLINE_USER_SESSION 행 1개
|
||||
클라이언트가 sid 를 받았다 = 서버가 COMMIT 했다고 응답했다
|
||||
크래시 후 그 sid 가 없다 = 잃은 것
|
||||
```
|
||||
|
||||
**있거나 없거나**이므로 눈금 문제가 없다. **이 실험이 로그인 수백 건을 도는
|
||||
이유가 이것이다** — 이진 판정을 여러 번 해서 비율로 만든다.
|
||||
|
||||
## 1-2. 로그인도 비동기 커밋인가 — **아니면 설계가 무너진다**
|
||||
|
||||
A-0 에서 잡은 것은 **refresh** 트랜잭션이었다. **로그인(INSERT)도 그런지는
|
||||
확인하지 않았다.** 아니라면 로그인은 안 사라지고, 이 측정 설계 자체가 성립하지
|
||||
않는다.
|
||||
|
||||
### 켠다 — 첫 번째 주입
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
**확인** — 실제로 켜졌나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
log_statement
|
||||
---------------
|
||||
all
|
||||
```
|
||||
|
||||
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. `alter system` 은
|
||||
`postgresql.auto.conf` 에 쓸 뿐이고 **reload 를 해야 적용된다.**
|
||||
|
||||
### 로그인 한 번을 보낸다
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
sudo kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="K0=$K0" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
> **실측** — `19`
|
||||
>
|
||||
> **★ 명령줄에 비밀번호를 직접 쓰지 않는다.** 원래 실험의 재현 절차에는
|
||||
> 평문 비밀번호가 그대로 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도
|
||||
> 남는다.** `--env` 로 넘긴 값은 그 파드 안에서만 산다.
|
||||
|
||||
**확인** — 환경변수가 들어갔나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
K0=10.42.1.67 PW길이=19
|
||||
```
|
||||
|
||||
**하기** — 로그인 한 번
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
### 로그에서 로그인 트랜잭션을 찾는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
|
||||
| grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20
|
||||
```
|
||||
|
||||
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/02-design-check.txt)
|
||||
```
|
||||
=== [설계 확인] 로그인 트랜잭션도 synchronous_commit 을 끄는가 ===
|
||||
--- 로그인 트랜잭션 (INSERT 가 있는 것) ---
|
||||
2:BEGIN
|
||||
5:COMMIT
|
||||
6:BEGIN
|
||||
9:insert into OFFLINE_USER_SESSION (BROKER_SESSION_ID,CREATED_ON,DATA,LAST_SESSION_REFRESH,REALM_ID,REMEMBER_ME,USER_ID,VERSION,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
10:insert into OFFLINE_CLIENT_SESSION (DATA,REALM_ID,TIMESTAMP,VERSION,CLIENT_ID,CLIENT_STORAGE_PROVIDER,EXTERNAL_CLIENT_ID,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
11:SET LOCAL synchronous_commit TO OFF
|
||||
12:COMMIT
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `BEGIN` 과 `COMMIT` 사이에 **`insert into
|
||||
OFFLINE_USER_SESSION` 과 `SET LOCAL synchronous_commit TO OFF` 가 같이 들어
|
||||
있는 것.** 앞의 `BEGIN`/`COMMIT`(2·5줄)은 다른 트랜잭션이다.
|
||||
|
||||
**이 결과가 의미하는 것** — **확인됐고, 함의가 refresh 보다 훨씬 무겁다.**
|
||||
|
||||
| | 잃으면 |
|
||||
|---|---|
|
||||
| refresh 갱신 시각 | 세션 수명이 조금 짧아진다. **사용자는 모른다** |
|
||||
| **로그인 자체** | **토큰은 손에 있는데 세션이 없다.** 다음 요청부터 실패 |
|
||||
|
||||
### ★ 곧바로 끈다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
log_statement
|
||||
---------------
|
||||
none
|
||||
```
|
||||
|
||||
**★ 켜 둔 채로 3절에 들어가면 안 된다.** 3절은 수백 건의 로그인을 최대한 빨리
|
||||
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
|
||||
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
|
||||
|
||||
## 1-3. WAL 설정을 지금 재 둔다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, setting, unit, source from pg_settings
|
||||
where name in ('commit_delay','synchronous_commit','wal_writer_delay','wal_writer_flush_after')"
|
||||
```
|
||||
**실측** — [`08-wal-settings.txt`](../../evidence/a3-database-crash/08-wal-settings.txt)
|
||||
```
|
||||
=== A-3 이 가정만 하고 재지 않은 값 ===
|
||||
name | setting | unit | source
|
||||
------------------------+---------+------+---------
|
||||
commit_delay | 0 | | default
|
||||
synchronous_commit | on | | default
|
||||
wal_writer_delay | 200 | ms | default
|
||||
wal_writer_flush_after | 128 | 8kB | default
|
||||
(4 rows)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `source` 열이 전부 `default` 다. 아무도 안 건드렸다.
|
||||
그리고 **전역 `synchronous_commit` 은 `on`.**
|
||||
|
||||
**이 결과가 의미하는 것** — **전역 설정만 보면 「우리는 동기 커밋」이라고 믿게
|
||||
된다.** 그런데 1-2 에서 본 대로 **Keycloak 이 자기 트랜잭션에만 `SET LOCAL` 로
|
||||
뒤집는다.** DBA 가 서버 설정만 보고 판단하면 틀린다.
|
||||
|
||||
> **★ 이 값을 지금 재 두는 것이 이 절의 요점이다.** 원래 실험은 결과를 먼저
|
||||
> 쓰고 「`wal_writer_delay` 기본값(200ms)과 맞는다」고 주장했는데, **그 시점에
|
||||
> 이 값을 조회한 적이 없었다.** 나중에 재서 맞기는 했지만 **그때는 추정이었다.**
|
||||
> 해설 문서 5절이 그 정정 기록이다.
|
||||
>
|
||||
> **가정한 값은 재기 전에 재 둔다.** 결과를 본 뒤에 재면 「맞춰 보는」 것이 된다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 기준선 — 세션 테이블을 비우고 센다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_user_session"
|
||||
```
|
||||
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
|
||||
```
|
||||
=== [정리] 세션 테이블 비우고 루프 잔여 확인 ===
|
||||
DELETE 375
|
||||
남은 세션: 0
|
||||
```
|
||||
|
||||
**되돌리기** — 되돌릴 수 없다. 지운 세션은 돌아오지 않는다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
count
|
||||
-------
|
||||
0
|
||||
```
|
||||
|
||||
**왜 비우나** — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히
|
||||
놓고 볼 것이다. 시작이 0 이어야 그 둘이 읽힌다.
|
||||
|
||||
> **캐시는 안 비워도 된다.** 이 실험의 판정은 **DB 행의 존재 여부**이고, 캐시는
|
||||
> 판정에 안 들어간다. A-0 처럼 파드를 재시작할 필요가 없다.
|
||||
|
||||
**확인** — 파드가 정상인지도 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
`keycloak-0` `keycloak-1` `postgres` 가 전부 `1/1 Running` 이고 `RESTARTS` 가
|
||||
`0` 이어야 한다. **`RESTARTS` 값을 적어 둔다** — 3절에서 이 값이 오르는지가
|
||||
주입 판정의 일부다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입 — 세 번 시도한다. 앞의 둘은 실패한다
|
||||
|
||||
**이 절이 이 실험의 본체다.** 순서대로 따라가면 「죽이는 데 실패하는 두 가지
|
||||
방법」을 직접 보게 된다. 건너뛰고 3-6 만 하면 **왜 그게 유일한 방법인지** 모른다.
|
||||
|
||||
## 3-1. 로그인 루프 — 스크립트 파일로 만든다
|
||||
|
||||
루프는 한 줄로 칠 물건이 아니다. **파일로 만든다.**
|
||||
|
||||
### 왜 파일인가
|
||||
|
||||
원래 실행은 이걸 `kubectl exec ... sh -c "..."` 한 줄에 욱여넣었고, **인용이
|
||||
세 겹이 되어 두 번 깨졌다.**
|
||||
|
||||
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
|
||||
```
|
||||
=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
|
||||
루프 시작
|
||||
6초 경과 — 지금까지 성공한 로그인: 0
|
||||
...
|
||||
클라이언트가 200 을 받은 로그인 수: 0
|
||||
```
|
||||
|
||||
**0건.** 파드 안에서 `( ... ) &` 로 띄운 루프가 **`exec` 세션이 끝날 때 같이
|
||||
죽었다.** 측정 자체가 없었던 것이다.
|
||||
|
||||
**편집기로 파일을 연다.**
|
||||
```bash
|
||||
vim /tmp/a3-login-loop.sh
|
||||
```
|
||||
```sh
|
||||
# file: /tmp/a3-login-loop.sh — 탐침 파드 안에서 돈다
|
||||
#!/bin/sh
|
||||
# K0 · PW 는 파드 환경변수에서 온다. 여기에 비밀번호를 적지 않는다.
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
: > /tmp/sids
|
||||
i=0
|
||||
while [ "$i" -lt 400 ]; do
|
||||
AT=$(curl -s --max-time 5 -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$AT" ]; then
|
||||
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' >> /tmp/sids
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
echo "루프 종료: $(wc -l < /tmp/sids) 건"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `/tmp/sids` 에 **클라이언트가 `200` 과 토큰을 실제로
|
||||
받은 것만** 쌓인다. `AT` 가 비면 아무것도 안 적는다. **이 파일이 「서버가
|
||||
COMMIT 했다고 응답한 것」의 목록**이고, 그게 이 실험의 시험군이다.
|
||||
|
||||
**하기** — 파드 안으로 넣는다. `tar` 가 필요 없는 방법이다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \
|
||||
< /tmp/a3-login-loop.sh
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
17 /tmp/a3-login-loop.sh
|
||||
```
|
||||
|
||||
> `kubectl cp` 도 되지만 컨테이너에 `tar` 가 있어야 한다. `cat >` 로 밀어 넣는
|
||||
> 쪽이 어디서나 통한다.
|
||||
|
||||
**하기** — **터미널 ①** 에서 **앞으로 두고** 돌린다. 이 터미널은 붙잡힌다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh
|
||||
```
|
||||
|
||||
**★ `&` 로 배경에 보내지 않는다.** 그게 원래 실행이 실패한 이유다. 터미널을
|
||||
하나 통째로 이 루프에 쓴다. **미검증** — 원래 실행은 호스트에서 배경 `exec`
|
||||
로 했다.
|
||||
|
||||
**확인** — **터미널 ②** 에서, 얼마나 쌓였는지 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
|
||||
```
|
||||
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
|
||||
```
|
||||
=== 로그인 루프 시작 ===
|
||||
8초 후: 112 건
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **8초에 112건이면 초당 약 14건.** 이 속도를 적어 둔다.
|
||||
4-4 에서 손실 건수를 시간으로 환산할 때 쓴다.
|
||||
|
||||
**0건이면 루프가 안 도는 것이다.** 터미널 ① 을 본다. 거기 에러가 있다.
|
||||
|
||||
## 3-2. 시도 ① — `--grace-period=0 --force`
|
||||
|
||||
**「강제 삭제」라는 이름이 붙어 있으니 크래시일 것 같다.** 확인해 본다.
|
||||
|
||||
**하기** — 터미널 ② 에서
|
||||
```bash
|
||||
date '+%H:%M:%S.%3N 종료'
|
||||
sudo kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force
|
||||
date '+%H:%M:%S.%3N 반환'
|
||||
```
|
||||
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
|
||||
```
|
||||
=== [2] PostgreSQL 강제 종료 (SIGKILL) ===
|
||||
종료 시각: 12:00:26.511
|
||||
pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace
|
||||
삭제 반환: 12:00:26.586
|
||||
```
|
||||
|
||||
터미널 ① 의 루프가 에러를 쏟기 시작한다. 그대로 두거나 `Ctrl-C` 로 멈춘다.
|
||||
|
||||
## 3-3. 주입 검증 ① — **crash recovery 가 돌았는가**
|
||||
|
||||
**★ 여기가 이 실험 전체에서 가장 중요한 절이다.** 결과를 세기 전에 **주입 성공
|
||||
신호**를 본다. 이 실험은 그 신호를 미리 정해 뒀다.
|
||||
|
||||
```
|
||||
PostgreSQL 이 정상 종료했다 → pg_control 에 "깨끗하게 종료됨" 표시
|
||||
→ 다음 기동에 아무 말 없이 뜬다
|
||||
|
||||
PostgreSQL 이 즉사했다 → 표시가 없다
|
||||
→ "database system was not properly shut down"
|
||||
→ "redo starts at ..." / "redo done at ..."
|
||||
```
|
||||
|
||||
**확인** — DB 가 다시 뜰 때까지 기다렸다가 로그를 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept'
|
||||
```
|
||||
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/02-design-check.txt)
|
||||
```
|
||||
=== crash recovery 가 실행되었는가 (강제 종료의 흔적) ===
|
||||
2026-09-04 02:58:41.036 UTC [1] LOG: database system is ready to accept connections
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`ready to accept connections` 한 줄뿐이다.**
|
||||
`not properly shut down` 도 `redo` 도 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — **crash recovery 가 돌지 않았다 = 깨끗하게 내려갔다.**
|
||||
|
||||
**하기** — 그런데도 손실을 세어 보면 이렇게 나온다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
**실측** — [`04-comparison.txt`](../../evidence/a3-database-crash/04-comparison.txt)
|
||||
```
|
||||
=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
|
||||
클라이언트 성공: 291 건
|
||||
DB 에 존재: 291 건
|
||||
★ 유실: 0 건
|
||||
```
|
||||
|
||||
**0건.** 그런데 이건 **「안 잃었다」가 아니라 「죽인 적이 없는 것」이다.**
|
||||
|
||||
### 왜 안 죽었나 — 시그널 세 가지
|
||||
|
||||
| 신호 | PostgreSQL 의 반응 |
|
||||
|---|---|
|
||||
| **SIGTERM** | **fast shutdown** — 진행 중 트랜잭션을 롤백하고 **WAL 을 플러시**한 뒤 종료 |
|
||||
| SIGINT | smart shutdown — 연결이 끊기길 기다린다 |
|
||||
| **SIGKILL** | **즉사** — 플러시 없음. 다음 기동에 crash recovery |
|
||||
|
||||
`--force --grace-period=0` 는 **API 오브젝트를 즉시 지운다.** 그것뿐이다.
|
||||
컨테이너 런타임은 여전히 정상 종료 절차를 밟고, **PostgreSQL 은 SIGTERM 을
|
||||
받고 얌전히 플러시했다.**
|
||||
|
||||
> **운영에 주는 것 — 장애 훈련이 훈련이 안 될 수 있다.**
|
||||
> 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 **아무것도 죽이지
|
||||
> 않은 것**일 수 있다. 훈련에는 **주입 성공 신호**가 있어야 한다.
|
||||
|
||||
## 3-4. 시도 ② — 컨테이너 안에서 `kill -9 1`
|
||||
|
||||
**postmaster 는 컨테이너의 PID 1 이다.** 직접 SIGKILL 을 보내면 될 것 같다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S.%3N SIGKILL'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1
|
||||
```
|
||||
|
||||
## 3-5. 주입 검증 ② — **아무 일도 일어나지 않았다**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=postgres
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3
|
||||
```
|
||||
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
|
||||
```
|
||||
=== [재주입] postmaster(PID 1)에 SIGKILL — 진짜 크래시 ===
|
||||
8초 후 성공 로그인: 110 건
|
||||
SIGKILL: 12:03:21.441
|
||||
최종 성공 로그인: 139 건
|
||||
|
||||
=== [검증] 이번엔 crash recovery 가 돌았는가 ===
|
||||
2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 가지를 같이 본다.**
|
||||
|
||||
1. **`RESTARTS` 가 안 올랐다.** 파드는 재시작하지 않았다
|
||||
2. **로그의 마지막 줄 시각이 `02:59:48` 이다** — 시도 ① 때 뜬 그 시각 그대로다
|
||||
|
||||
**★ 「`ready to accept connections` 줄이 있다」로 판정하면 안 된다.**
|
||||
그 줄은 **아까 뜰 때 찍힌 것**이고 새로 찍힌 게 아니다. **줄의 존재가 아니라
|
||||
시각을 본다.** 원래 실행의 검증 출력이 정확히 이 함정을 보여 준다.
|
||||
|
||||
### 개념 — PID 1 의 시그널 보호
|
||||
|
||||
리눅스 커널은 **PID 1 을 특별 취급한다.** 자기 PID 네임스페이스 안에서 온
|
||||
시그널은 **핸들러가 등록된 것만** 전달된다. **SIGKILL 도 예외가 아니다.**
|
||||
|
||||
```
|
||||
같은 네임스페이스 안에서 → PID 1 은 등록하지 않은 시그널을 무시한다
|
||||
조상 네임스페이스에서 → 전달된다 (노드에서 kill -9 하면 죽는다)
|
||||
```
|
||||
|
||||
부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데,
|
||||
컨테이너에서는 **「안에서는 PID 1 을 못 죽인다」**로 나타난다.
|
||||
|
||||
> **그래서 크래시 재현은 두 갈래다.**
|
||||
> **(a) 자식 프로세스**를 죽인다 — 다음 절
|
||||
> **(b) 노드에서** 죽인다 — `ssh kc-lab-2 'sudo kill -9 <호스트 PID>'`.
|
||||
> 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했다.
|
||||
|
||||
## 3-6. 시도 ③ — 백엔드 프로세스를 죽인다
|
||||
|
||||
PostgreSQL 은 **postmaster(부모) + 연결마다 백엔드(자식)** 구조다. 자식 하나가
|
||||
비정상 종료하면 **postmaster 는 공유 메모리가 오염됐다고 보고 전체를
|
||||
재초기화한다.** 그게 곧 crash recovery 다.
|
||||
|
||||
**확인** — 먼저 무엇을 죽일지 눈으로 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
postgres 1 0 0 02:59 ? 00:00:00 postgres
|
||||
postgres 40 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.1.67(41234) idle
|
||||
postgres 41 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.0.35(52118) idle
|
||||
...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `PID 1` 이 postmaster 이고, `postgres: keycloak
|
||||
keycloak ...` 이 **Keycloak 이 붙어 있는 백엔드**다. 이 중 하나를 죽인다.
|
||||
|
||||
**터미널 ① 에서 루프를 다시 돌리고 있어야 한다.** 8초쯤 쌓이면:
|
||||
|
||||
**하기** — 터미널 ② 에서
|
||||
```bash
|
||||
date '+%H:%M:%S.%3N SIGKILL'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)'
|
||||
```
|
||||
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
|
||||
```
|
||||
=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
|
||||
시각: 12:04:22.063
|
||||
최종 성공 로그인: 153 건
|
||||
```
|
||||
|
||||
터미널 ① 의 루프를 `Ctrl-C` 로 멈춘다.
|
||||
|
||||
## 3-7. 주입 검증 ③ — 이번엔 걸렸다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --since=5m \
|
||||
| grep -E 'terminated by signal|reinitializing|not properly shut down|redo|checkpoint complete|ready to accept'
|
||||
```
|
||||
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
|
||||
```
|
||||
2026-09-04 03:02:35.807 UTC [1] LOG: server process (PID 40) was terminated by signal 9: Killed
|
||||
2026-09-04 03:02:35.807 UTC [1] LOG: terminating any other active server processes
|
||||
2026-09-04 03:02:35.814 UTC [1] LOG: all server processes terminated; reinitializing
|
||||
2026-09-04 03:02:35.896 UTC [2585] LOG: database system was not properly shut down; automatic recovery in progress
|
||||
2026-09-04 03:02:35.899 UTC [2585] LOG: redo starts at 0/23CAB68
|
||||
2026-09-04 03:02:35.904 UTC [2585] LOG: redo done at 0/2529E40 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
|
||||
2026-09-04 03:02:35.923 UTC [2586] LOG: checkpoint complete: wrote 113 buffers (0.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.004 s, sync=0.004 s, total=0.015 s; sync files=27, longest=0.003 s, average=0.001 s; distance=1405 kB, estimate=1405 kB; lsn=0/252A048, redo lsn=0/252A048
|
||||
2026-09-04 03:02:35.926 UTC [1] LOG: database system is ready to accept connections
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 여섯 줄이 순서대로 나온다.
|
||||
|
||||
| 줄 | 읽는 법 |
|
||||
|---|---|
|
||||
| `terminated by signal 9` | 내가 죽인 그 백엔드다 |
|
||||
| `all server processes terminated; reinitializing` | **postmaster 가 전체를 갈아엎기로 했다** |
|
||||
| **`not properly shut down`** | **주입 성공 신호.** 이게 없으면 결과를 해석하지 않는다 |
|
||||
| `redo starts at 0/23CAB68` → `redo done at 0/2529E40` | 재생된 WAL 구간 |
|
||||
| `checkpoint complete` | 재생 결과를 디스크에 고정했다 |
|
||||
| `ready to accept connections` | **시각이 새로 찍혔다** — 3-5 와 대조한다 |
|
||||
|
||||
**확인** — 파드는 재시작하지 않았다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=postgres -o custom-columns=\
|
||||
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME RESTARTS
|
||||
postgres-7b474b88c8-xxxxx 0
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 컨테이너의 PID 1 인 postmaster 는 **살아 있고
|
||||
자식만 갈아치웠다.** 쿠버네티스 관점에서는 아무 일도 없었지만, **데이터
|
||||
관점에서는 전원이 나간 것과 같다.**
|
||||
|
||||
### crash recovery 를 한 줄로
|
||||
|
||||
```
|
||||
기동 시 pg_control 을 읽는다
|
||||
└─ "깨끗하게 종료됨" 표시가 없다
|
||||
└─ "database system was not properly shut down"
|
||||
└─ 마지막 체크포인트부터 WAL 을 재생(redo)
|
||||
└─ 디스크에 안 내려간 커밋은 복구할 수 없다 ← 손실
|
||||
```
|
||||
|
||||
**WAL 에 없는 것은 재생할 수도 없다.** `redo starts` 와 `redo done` 사이가
|
||||
살아 돌아온 구간이고, **그 뒤에 있던 것이 사라진 것**이다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 결과 — 몇 건이 사라졌나
|
||||
|
||||
## 4-1. 클라이언트가 받은 sid 목록을 꺼낸다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt
|
||||
wc -l /tmp/client-sids.txt
|
||||
```
|
||||
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
|
||||
```
|
||||
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 파일 한 줄에 sid 하나. 몇 줄인지 적어 둔다.
|
||||
|
||||
**확인** — 눈으로 한 번 본다
|
||||
```bash
|
||||
head -3 /tmp/client-sids.txt
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
CQUfg9HLH29xvhiu6pVlfWOo
|
||||
5gLP4fqmpZBbjhH_d-0TPMMr
|
||||
hkcOv1QskUFmYveMLB6Hljra
|
||||
```
|
||||
|
||||
빈 줄이 섞여 있으면 sid 추출이 실패한 것이다. 그대로 세면 유실 건수가 부풀려진다.
|
||||
|
||||
## 4-2. DB 에 남아 있는 sid 목록을 꺼낸다
|
||||
|
||||
**확인** — 먼저 총계를 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
DB 전체 온라인 세션 : 150 건
|
||||
```
|
||||
|
||||
**하기** — 목록으로 뽑는다. `-tAc` 는 헤더·정렬 없이 값만 준다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select user_session_id from offline_user_session where offline_flag='0'" \
|
||||
> /tmp/db-sids.txt
|
||||
wc -l /tmp/db-sids.txt
|
||||
```
|
||||
|
||||
> **`psql` 의 두 얼굴.** `-c` 는 표를 그려서 사람이 읽기 좋고, `-tAc` 는 값만
|
||||
> 줘서 파이프에 넣기 좋다. **한 번은 `-c` 로 눈으로 보고**, 셀 때만 `-tAc` 를
|
||||
> 쓴다.
|
||||
|
||||
## 4-3. 차집합 — 무엇이 사라졌나
|
||||
|
||||
`comm` 은 **정렬된 두 파일의 차집합**을 낸다. 정렬부터 한다.
|
||||
|
||||
**하기** **미검증**
|
||||
```bash
|
||||
LC_ALL=C sort -u /tmp/client-sids.txt > /tmp/a.txt
|
||||
LC_ALL=C sort -u /tmp/db-sids.txt > /tmp/b.txt
|
||||
comm -23 /tmp/a.txt /tmp/b.txt
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `comm -23` 은 **왼쪽 파일에만 있는 줄**을 낸다.
|
||||
즉 **클라이언트는 받았는데 DB 에는 없는 sid** 다.
|
||||
|
||||
| 옵션 | 무엇을 감추나 |
|
||||
|---|---|
|
||||
| `-1` | 왼쪽에만 있는 줄을 감춘다 |
|
||||
| `-2` | 오른쪽에만 있는 줄을 감춘다 |
|
||||
| `-3` | 양쪽에 다 있는 줄을 감춘다 |
|
||||
|
||||
`-23` 은 2·3 을 감추므로 **왼쪽 전용만 남는다.**
|
||||
|
||||
> **`LC_ALL=C` 를 빼면 안 된다.** `comm` 은 두 파일이 **같은 정렬 순서**임을
|
||||
> 전제한다. 로케일이 다르면 대소문자·기호 순서가 달라져 **멀쩡한 sid 가
|
||||
> 「없는 것」으로 잡힌다.** sid 는 대소문자와 `-` `_` 가 섞인 base64url 이라
|
||||
> 정확히 이 문제에 걸린다.
|
||||
|
||||
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
|
||||
```
|
||||
=== 크래시 전후 대조 ===
|
||||
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
|
||||
그중 DB 에 실제로 존재 : 149 건
|
||||
★ 유실 : 4 건
|
||||
|
||||
=== 유실된 sid 목록 ===
|
||||
★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다
|
||||
★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다
|
||||
★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다
|
||||
★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다
|
||||
```
|
||||
|
||||
**확인** — 건수만
|
||||
```bash
|
||||
comm -23 /tmp/a.txt /tmp/b.txt | wc -l
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **로그인이 성공했다고 응답받았는데 세션이 존재하지
|
||||
않는다.** 153건 중 4건, **약 2.6%.**
|
||||
|
||||
**확인** — 대조군. 사라지지 **않은** 것도 하나 본다
|
||||
```bash
|
||||
tail -1 /tmp/client-sids.txt
|
||||
```
|
||||
그 sid 로 DB 를 뒤진다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select user_session_id, created_on, last_session_refresh
|
||||
from offline_user_session where user_session_id='8do0Bw6tkVLDVxgxotE7GosH'"
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
|
||||
마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
|
||||
user_session_id | created_on | last_session_refresh
|
||||
--------------------------+------------+----------------------
|
||||
8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**대부분은 멀쩡하다.** 그래서 **손실이 잘 안 보인다.**
|
||||
|
||||
## 4-4. 숫자를 어떻게 읽나
|
||||
|
||||
**★ 여기서 성급하게 결론을 붙이지 않는다.** 원래 문서가 그렇게 했다가 정정했다.
|
||||
|
||||
원래 문서는 *「초당 19건 … `wal_writer_delay` 기본값(200ms)과 맞는다」*고 썼는데,
|
||||
**그 시점에 `wal_writer_delay` 를 조회한 적이 없었다.** 그리고 로그인 속도도
|
||||
틀렸다.
|
||||
|
||||
**증거를 다시 읽는다.** 3-1 에서 본 값이다.
|
||||
|
||||
```
|
||||
8초에 112건 ≈ 초당 14건
|
||||
4건 ≈ 약 0.29초 분량
|
||||
```
|
||||
|
||||
**19건이 아니라 14건이고, 0.2초가 아니라 약 0.29초다.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 측정한 손실 | 4건 ≈ **약 0.29초 분량** |
|
||||
| `wal_writer_delay` (1-3 에서 잰 값) | **200 ms** |
|
||||
| 관계 | **같은 자릿수이되 정확히 일치하지는 않는다** |
|
||||
|
||||
**「같은 자릿수」까지가 이 실험이 말할 수 있는 것이다.** `wal_writer_delay`
|
||||
하나가 손실 창을 정하는 것도 아니다 — `wal_writer_flush_after`(128 × 8kB)와
|
||||
체크포인트 타이밍이 함께 작용한다.
|
||||
|
||||
> **재현하면 당신의 숫자는 다르다.** 로그인 속도, 디스크, 죽인 순간이 전부
|
||||
> 다르기 때문이다. **중요한 것은 「4」가 아니라 「0 이 아니다」이고, 그 크기가
|
||||
> WAL 플러시 주기와 같은 자릿수라는 것이다.**
|
||||
|
||||
## 4-5. 사용자에게 어떻게 보이는가
|
||||
|
||||
```
|
||||
로그인 성공 → access token + refresh token 을 받음
|
||||
│
|
||||
│ (크래시)
|
||||
▼
|
||||
다음 요청 → access token 은 60초간 통한다
|
||||
│ (서명만 보는 경로라면)
|
||||
▼
|
||||
60초 후 refresh → "Session not active" → 다시 로그인
|
||||
```
|
||||
|
||||
**즉시 드러나지 않는다.** access token 수명 동안은 정상으로 보이다가 갱신
|
||||
시점에 끊긴다. **장애와 증상 사이에 최대 60초의 시차가 있다.**
|
||||
|
||||
> **운영적 함의 — 모니터링은 갱신 실패율을 봐야 한다.** 로그인 성공률만 보면
|
||||
> 이 장애는 안 보인다. 로그인은 `200` 을 줬기 때문이다.
|
||||
|
||||
## 4-6. 이 손실이 「허용된」 이유
|
||||
|
||||
Keycloak 의 판단은 이렇게 읽힌다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 세션 쓰기는 **매우 잦다** | 로그인마다, refresh 마다 |
|
||||
| 잃어도 **회복 가능하다** | 사용자가 다시 로그인하면 된다 |
|
||||
| 동기 커밋의 비용은 **모든 요청에 붙는다** | 크래시는 드물다 |
|
||||
|
||||
**드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택**이다. 합리적이지만,
|
||||
**선택했다는 사실을 알고 있어야 한다.**
|
||||
|
||||
### 바꿀 수 있는가 — 못 바꾼다
|
||||
|
||||
```sql
|
||||
-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
|
||||
ALTER DATABASE keycloak SET synchronous_commit = on;
|
||||
```
|
||||
|
||||
**`SET LOCAL` 이 우선하므로 이것으로는 못 막는다.** Keycloak 설정이나 소스
|
||||
수준의 문제다.
|
||||
|
||||
> **RPO 0 이 필요하면 복제(streaming replication)로 푸는 것이 맞다.** 동기
|
||||
> 스탠바이가 있으면 `synchronous_commit` 의 의미가 달라진다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구 · 정리
|
||||
|
||||
## 5-1. 문장 로깅이 꺼져 있는지 확인한다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement" -c "show log_line_prefix"
|
||||
```
|
||||
`none` 이 아니면 1-2 의 reset 을 다시 친다.
|
||||
|
||||
## 5-2. 실험이 만든 세션을 정리한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_user_session"
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
**★ 재시작을 빼면 안 된다.** DB 만 지우면 **캐시 엔트리가 남아** 캐시 합계와
|
||||
DB 총계가 어긋난다. A-0 이 겪은 함정이고, 다음 실험의 기준선을 망친다.
|
||||
|
||||
## 5-3. 탐침 파드를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
`--rm` 이 없으므로 **자동으로 사라지지 않는다.** `sleep 7200` 이 끝나면
|
||||
`Completed` 로 남는다.
|
||||
|
||||
## 5-4. DB 가 건강한지 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=postgres
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select id, dateexecuted from databasechangelog order by dateexecuted desc limit 3"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **질의가 그냥 되고, 마이그레이션 이력 세 줄이 나오는
|
||||
것.** 건수는 Keycloak 버전마다 다르므로 숫자를 외울 필요가 없다. 오류 없이
|
||||
읽히면 그것으로 충분하다.
|
||||
|
||||
**이 결과가 의미하는 것** — crash recovery 는 **커밋되지 않은 것만 버린다.**
|
||||
스키마와 마이그레이션 이력은 멀쩡하다. 이 실험은 **「데이터 일부 손실」이지
|
||||
「DB 파손」이 아니다.**
|
||||
|
||||
## 5-5. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
|
||||
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
|
||||
| WAL 설정 | `psql -c "show synchronous_commit"` | `on` (전역은 원래 on) |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` |
|
||||
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a3-probe` | `NotFound` |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
로컬 임시 파일도 치운다.
|
||||
```bash
|
||||
rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 유실이 `0건` 이다 | **죽인 적이 없다.** 대개 `--force` 나 `kill -9 1` 을 썼다 | `not properly shut down` 이 로그에 있나 — 3-3·3-7 |
|
||||
| `ready to accept connections` 가 있으니 크래시인 줄 알았다 | **아까 뜰 때 찍힌 줄이다** | **줄의 존재가 아니라 시각**을 본다 — 3-5 |
|
||||
| `kill -9 1` 을 했는데 아무 일도 없다 | **컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다** | 백엔드 프로세스를 죽인다 — 3-6 |
|
||||
| 루프가 `0건` 을 모았다 | **파드 안에서 `&` 로 띄우면 `exec` 종료와 같이 죽는다** | 터미널 하나를 루프에 통째로 쓴다 — 3-1 |
|
||||
| 로그가 폭주하고 크래시 타이밍이 이상하다 | **`log_statement='all'` 을 켠 채로 루프를 돌렸다** | `show log_statement` 가 `none` 인지 — 1-2 |
|
||||
| 멀쩡한 sid 가 「없음」으로 잡힌다 | **`comm` 두 파일의 정렬 순서가 다르다** | `LC_ALL=C sort` 를 양쪽에 — 4-3 |
|
||||
| 유실 건수가 부풀려진다 | `/tmp/sids` 에 빈 줄이 섞였다 | `head -3` 으로 눈으로 본다 — 4-1 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 친다 |
|
||||
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a3-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 |
|
||||
| `a3-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a3-probe --ignore-not-found` — 5-3 |
|
||||
| `pgrep` 이 아무것도 못 찾는다 | Keycloak 이 아직 연결을 안 만들었다 | `ps -ef` 로 먼저 본다 — 3-6 |
|
||||
| 손실 건수를 시간으로 환산했더니 문서와 다르다 | **원래 문서가 속도를 잘못 썼다가 정정했다** | 초당 14건이 실측이다 — 4-4 |
|
||||
|
||||
---
|
||||
|
||||
# 왜 이 가이드는 판정 기준을 먼저 적나
|
||||
|
||||
이 실험이 남긴 가장 큰 교훈은 손실 건수가 아니다.
|
||||
|
||||
> **주입 성공 신호를 미리 정한다.**
|
||||
|
||||
세 번의 시도 중 **두 번은 「유실 0건」이라는 깨끗한 결과를 냈다.** 신호를
|
||||
정해 두지 않았다면 첫 번째 결과를 그대로 발표했을 것이고, 결론은
|
||||
**「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」**가 됐을 것이다.
|
||||
정반대의 결론이다.
|
||||
|
||||
| 실험 | 주입 성공 신호 |
|
||||
|---|---|
|
||||
| A-1 7800 차단 | conntrack 에 `SYN_SENT [UNREPLIED]`, `coord = t` 가 둘 |
|
||||
| A-2 DB 정지 | Keycloak 로그의 `Connection refused` + agroal |
|
||||
| **A-3 DB 크래시** | **`database system was not properly shut down` + `redo starts`** |
|
||||
|
||||
**신호가 없으면 결과를 해석하지 않는다.** 그게 이 절의 전부다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-3 가 남긴 것 |
|
||||
|---|---|
|
||||
| D-1 백업·복구 | **진짜 RPO = 백업 주기 + 이 손실.** 둘을 더해야 한다 |
|
||||
| B-6 Redis 영속화 | `appendfsync everysec` 은 **같은 모양의 트레이드오프** |
|
||||
| A-4 노드 상실 | `postgres` 가 `kc-lab-2` 에 있다 — **노드가 죽으면 이것도 함께 일어난다** |
|
||||
| 모니터링 | **로그인 성공률이 아니라 갱신 실패율을 본다** — 4-5 |
|
||||
| 전부 | **주입 성공 신호를 미리 정한다.** 여기서는 crash recovery 로그 |
|
||||
@@ -0,0 +1,978 @@
|
||||
# A-4 재현 가이드 — 기계 전원을 뽑고 쿠버네티스가 언제 알아채는지 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-a4-node-loss.md`](../../experiment-a4-node-loss.md) ·
|
||||
증거 원문: [`docs/evidence/a4-node-loss/`](../../evidence/a4-node-loss/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 기계는 없는데 쿠버네티스가 40초 동안 `Ready` 라고 말하는 것 | `kubectl get node` 와 외부 `curl` 을 나란히 |
|
||||
| **죽은 파드가 `ready=true`, 산 파드가 `ready=false`** 인 것 | `get pods -o custom-columns` |
|
||||
| 그 와중에 `up` 은 정확히 0 인 것 | Prometheus |
|
||||
| 축출이 5분 뒤에야 시작되는 것 | `tolerationSeconds` 와 파드 상태 |
|
||||
| 새 파드가 **영원히 `Pending`** 인 것 | `describe pod` 의 Events |
|
||||
| StatefulSet 이 대체 파드를 **안 만드는** 것 | `get statefulset` 의 `CURRENT` |
|
||||
| `kubectl` 이 죽어도 컨테이너는 도는 것 | `kc-lab-2` 에서 `crictl ps` |
|
||||
| 관측자가 같이 죽으면 **0 이 아니라 구멍**이 남는 것 | Grafana |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- **명령을 치는 곳이 세 군데다.** 이 실험은 그 구별이 곧 내용이다.
|
||||
|
||||
| 터미널 | 어디 | 무엇을 |
|
||||
|---|---|---|
|
||||
| **A** | `test-server` (VM 호스트) | `virsh` — 전원을 뽑고 다시 넣는다 |
|
||||
| **B** | `kc-lab-1` | `sudo kubectl` — 관찰. **4b 에서는 이 터미널이 죽는다** |
|
||||
| **C** | `test-server` | 밖에서 `curl`. 사용자 시점 |
|
||||
|
||||
- 터미널 A 에서 `virsh` 가 시스템 하이퍼바이저를 보고 있어야 한다.
|
||||
```bash
|
||||
export LIBVIRT_DEFAULT_URI=qemu:///system
|
||||
virsh uri
|
||||
```
|
||||
`qemu:///system` 이 아니면 **VM 이 안 보인다.** [`00-lab-host`](../00-lab-host/) 5절.
|
||||
- 4b 에서는 `kc-lab-2` 에도 붙는다. 터미널 A 와 같은 기계에서 `ssh kc-lab-2`.
|
||||
이름이 안 풀리면 `ssh 192.168.122.12`.
|
||||
|
||||
## 주의 — 이건 기계를 끄는 실험이다
|
||||
|
||||
`virsh destroy` 는 **종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같다.**
|
||||
게스트 파일시스템이 더러운 채로 멈춘다. **실험대에서만 한다.**
|
||||
|
||||
전 구간 약 **40분**이다. 4a 에서 축출을 보려면 그것만 7분을 기다려야 한다.
|
||||
어느 시점에서든 그만두려면 터미널 A 에서 한 줄이면 된다.
|
||||
|
||||
```bash
|
||||
virsh start kc-lab-2 ; virsh start kc-lab-1
|
||||
```
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 12:05–12:23 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
파드 이름·IP·시각은 **당신 환경에서 다르다.** 자리표시자(`<...>`)를 쓰지 않는
|
||||
대신 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
|
||||
실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
A-1 과 A-5 는 **네트워크만** 끊었다. 파드는 살아 있었고, 쿠버네티스는 계속
|
||||
정확한 상태를 알고 있었다. 여기서는 **기계 자체를 없앤다.** 그러면 상태를
|
||||
보고할 주체가 사라진다.
|
||||
|
||||
두 판본으로 나눈다. **어느 노드를 죽이느냐가 전부**이기 때문이다.
|
||||
|
||||
| | 죽이는 노드 | 그 노드에 있는 것 | 묻는 것 |
|
||||
|---|---|---|---|
|
||||
| **4a** | `kc-lab-2` (워커) | keycloak-0 · **postgres** · postgres PVC | Keycloak 과 DB 를 **동시에** 잃으면 |
|
||||
| **4b** | `kc-lab-1` (k3s 서버) | keycloak-1 · **Traefik** · 컨트롤 플레인 · 관측 스택 | **들어갈 문**을 잃으면 |
|
||||
|
||||
세 가지를 확인한다.
|
||||
|
||||
```
|
||||
쿠버네티스는 언제 알아채는가 → 40초 (그동안 거짓말을 한다)
|
||||
무엇을 스스로 고치는가 → 축출. 단 5분 뒤
|
||||
무엇을 못 고치는가 → PVC 가 묶인 재배치, StatefulSet 이름
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 전원을 뽑기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 뽑은 뒤에 볼 것을 뽑기 전에 **똑같은
|
||||
명령으로** 먼저 봐 둔다.
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나
|
||||
```
|
||||
|
||||
## 1-1. VM 이 둘 다 살아 있나
|
||||
|
||||
**확인** — 터미널 A
|
||||
```bash
|
||||
virsh list --all
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||
```
|
||||
--------------------------
|
||||
1 kc-lab-1 running
|
||||
2 kc-lab-2 running
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 둘 다 `running`. 앞의 숫자는 **도메인 ID** 이며
|
||||
VM 을 껐다 켜면 바뀐다. 이름으로 다룬다.
|
||||
|
||||
## 1-2. 노드와 파드 배치
|
||||
|
||||
**확인** — 터미널 B
|
||||
```bash
|
||||
sudo kubectl get nodes
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||
```
|
||||
kc-lab-1 Ready true
|
||||
kc-lab-2 Ready <none>
|
||||
|
||||
a2-probe true kc-lab-2
|
||||
keycloak-0 true kc-lab-2
|
||||
keycloak-1 true kc-lab-1
|
||||
postgres-7b474b88c8-2gf27 true kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`NODE` 열.** 이 실험은 배치가 전부다.
|
||||
|
||||
**이 결과가 의미하는 것** — `kc-lab-2` 에 **keycloak-0 과 postgres 가 함께**
|
||||
있다. 그래서 4a 는 「Keycloak 한 대를 잃는 실험」이 아니라 **「Keycloak 한 대와
|
||||
DB 를 동시에 잃는 실험」**이다. 배치가 다르면 결과도 다르다 — 먼저 확인한다.
|
||||
|
||||
> `a2-probe` 는 A-2 에서 띄워 두고 안 지운 상주 파드다. 당신 환경에는 없을 수
|
||||
> 있다. 없어도 이 실험에는 지장이 없다.
|
||||
|
||||
## 1-3. ★ 볼륨이 어느 노드에 못박혀 있나
|
||||
|
||||
**이 한 줄이 뒤의 결과를 이미 결정한다.** 4a 에서 「새 파드가 왜 영원히
|
||||
Pending 인가」의 답이 여기 있다.
|
||||
|
||||
**확인** — 어떤 PVC 가 있나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pvc
|
||||
```
|
||||
|
||||
**확인** — 그 PVC 뒤의 PV 가 어느 노드를 요구하나. 먼저 **읽는 형태**로 한 번 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
|
||||
sudo kubectl describe pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
|
||||
-o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
Node Affinity:
|
||||
Required Terms:
|
||||
Term 0: kubernetes.io/hostname in [kc-lab-2]
|
||||
```
|
||||
|
||||
값만 필요하면 **뽑는 형태**로 줄인다.
|
||||
```bash
|
||||
sudo kubectl get pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
|
||||
-o jsonpath='{.spec.volumeName}') \
|
||||
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||
```
|
||||
=== PVC 가 어느 노드에 묶여 있는가 (재배치 가능성) ===
|
||||
persistentvolumeclaim/postgres-data → kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 오른쪽의 노드 이름. **그것이 `kc-lab-2` 라면 4a 에서
|
||||
postgres 는 갈 곳이 없다.**
|
||||
|
||||
**이 결과가 의미하는 것** — `local-path` PVC 는 **그 노드의 로컬 디렉터리**다
|
||||
(`/var/lib/rancher/k3s/storage/...`). 노드가 죽으면 볼륨도 같이 죽는다.
|
||||
스케줄러는 그 사실을 `nodeAffinity` 로 알고 있어서, 다른 노드에 파드를
|
||||
**만들지 않는다.** 결함이 아니라 이 실험대의 **조건**이다.
|
||||
|
||||
## 1-4. 밖에서 보이는 상태
|
||||
|
||||
**확인** — 터미널 C. 눈으로 한 번 볼 때는 `-I` 로 충분하다
|
||||
```bash
|
||||
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
HTTP/2 200
|
||||
content-type: application/json
|
||||
```
|
||||
|
||||
여러 번 재서 비교할 것이므로, 이제부터는 **코드만** 뽑는다.
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||
```
|
||||
=== 서비스 정상 확인 ===
|
||||
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||
```
|
||||
|
||||
**`--max-time` 을 반드시 준다.** 4b 에서 이 값이 없으면 curl 이 몇 분씩
|
||||
매달린다. 그리고 **타임아웃이 곧 결과**다 — 뒤에서 `000` 이 나오는 이유가
|
||||
그것이다.
|
||||
|
||||
## 1-5. 관측자가 어디 있나 — 미리 알아 둔다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability get pods -o wide
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS NODE
|
||||
grafana-845b5678cf-b6gvc 1/1 Running kc-lab-1
|
||||
prometheus-6774f94f7c-pzr2t 1/1 Running kc-lab-1
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 관측 스택이 `kc-lab-1` 에 있다. **4a(`kc-lab-2`
|
||||
살해)에서는 Prometheus 가 살아남아 관측이 정확하고, 4b 에서는 관측자가 같이
|
||||
죽는다.** 그 차이를 발견 ⑧ 에서 본다. 지금 알아 두지 않으면 나중에 그래프의
|
||||
빈 구간을 「값이 0」으로 잘못 읽는다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 4a — 워커 노드의 전원을 뽑는다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기** — 터미널 A
|
||||
```bash
|
||||
virsh start kc-lab-2
|
||||
```
|
||||
|
||||
## 2-1. `destroy` 와 `shutdown` 의 차이
|
||||
|
||||
| 명령 | 게스트에 무슨 일이 | 이 실험에 |
|
||||
|---|---|---|
|
||||
| `virsh shutdown` | ACPI 종료 신호 → kubelet 이 정상 종료 → 파드가 정리된다 | **쓰면 안 된다** |
|
||||
| **`virsh destroy`** | **전원 차단.** 신호 없음. 마지막 상태가 그대로 얼어붙는다 | 이것이 「노드 상실」이다 |
|
||||
|
||||
`shutdown` 을 쓰면 쿠버네티스가 **정상적인 노드 이탈**로 처리해서
|
||||
이 실험의 발견 ①·② 가 통째로 안 나온다.
|
||||
|
||||
## 2-2. 뽑는다
|
||||
|
||||
**하기** — 터미널 A
|
||||
```bash
|
||||
date '+%H:%M:%S 차단'
|
||||
virsh destroy kc-lab-2
|
||||
```
|
||||
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
|
||||
```
|
||||
차단 시각: 12:07:43
|
||||
Domain 'kc-lab-2' destroyed
|
||||
```
|
||||
|
||||
**시각을 반드시 적어 둔다.** 40초·5분 같은 숫자는 **이 시각에서 뺀 값**이다.
|
||||
기준점이 없으면 뒤의 관찰은 그냥 나열이다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
A-5·A-6 에서는 「규칙을 넣었는데 카운터가 0」이 실패였다. **이 실험의 검증
|
||||
대상은 다르다.** 여기서 믿을 수 있는 것은 **하이퍼바이저**뿐이고, 쿠버네티스가
|
||||
뭐라고 하든 그것은 결과이지 검증이 아니다.
|
||||
|
||||
## 3-1. VM 이 실제로 꺼졌나 — 이것이 유일한 주입 검증이다
|
||||
|
||||
**확인** — 터미널 A
|
||||
```bash
|
||||
virsh list --all
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
1 kc-lab-1 running
|
||||
- kc-lab-2 shut off
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `shut off`. ID 가 `-` 로 바뀐 것도 같은 말이다.
|
||||
|
||||
**확인** — 정말 응답이 없나
|
||||
```bash
|
||||
ping -c 2 -W 2 192.168.122.12
|
||||
```
|
||||
**미검증** — 원 실행에는 이 확인이 없다. `0 received` 가 나오면 꺼진 것이다.
|
||||
|
||||
## 3-2. ★ 그런데 쿠버네티스는 아직 `Ready` 라고 말한다
|
||||
|
||||
**확인** — 터미널 B
|
||||
```bash
|
||||
sudo kubectl get node kc-lab-2
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION
|
||||
kc-lab-2 Ready <none> 12d v1.33.x+k3s1
|
||||
```
|
||||
|
||||
**여기서 「주입이 안 걸렸다」고 결론 내리면 틀린다.** 기계는 3-1 에서 확인한
|
||||
대로 꺼져 있다. 쿠버네티스가 아직 모를 뿐이다.
|
||||
|
||||
노드 상태와 사용자 경험을 **나란히** 봐야 이게 보인다. 터미널 B 에서:
|
||||
|
||||
```bash
|
||||
sudo kubectl get node kc-lab-2
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
두 줄을 15초 간격으로 몇 번 친다. 손이 아프면 한 줄로 묶는다. **미검증**
|
||||
```bash
|
||||
while true; do
|
||||
printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
|
||||
"$(sudo 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)"
|
||||
sleep 15
|
||||
done
|
||||
```
|
||||
`Ctrl-C` 로 멈춘다.
|
||||
|
||||
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
|
||||
```
|
||||
+15초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
|
||||
+30초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
|
||||
+45초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
|
||||
+60초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `+30초` 줄과 `+45초` 줄 사이. **노드 상태가 그때
|
||||
넘어간다.**
|
||||
|
||||
**이 결과가 의미하는 것** — kube-controller-manager 는 kubelet 의 하트비트가
|
||||
`node-monitor-grace-period`(이 실험대에서 **40초**) 동안 없어야 `NotReady` 로
|
||||
바꾼다. 그 40초 동안 **쿠버네티스는 거짓말을 한다.** 그리고 사용자는 그
|
||||
40초에도 이미 장애를 겪고 있다 — `000` 이 그 증거다.
|
||||
|
||||
> **노드 상태를 알림 근거로 삼으면 항상 늦는다.** 사용자가 먼저 안다.
|
||||
|
||||
## 3-3. 왜 처음 40초는 `503` 이 아니라 `000` 인가
|
||||
|
||||
```
|
||||
000 curl 이 응답 자체를 못 받았다 = 타임아웃 또는 연결 실패
|
||||
503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다
|
||||
```
|
||||
|
||||
엣지 nginx(`kc-lab-edge`) 의 upstream 에는 **두 노드가 다 들어 있다**
|
||||
([`03-nginx`](../03-nginx/) 1절).
|
||||
|
||||
```
|
||||
upstream k3s_traefik {
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
```
|
||||
|
||||
죽은 쪽으로 배분된 요청은 **응답도 거절도 못 받고** `--max-time 8` 에 걸린다.
|
||||
|
||||
> **★ 여기는 이 실험이 답을 못 남긴 자리다.** 증거 파일
|
||||
> [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||
> 의 마지막 절 제목이 「진입점이 처음 40초간 000 이었던 이유 — nginx upstream」
|
||||
> 인데 **그 아래가 비어 있다.** 명령이 아무것도 찍지 못했다.
|
||||
> **당신은 지금 직접 볼 수 있다** — 터미널 C 에서. **미검증**
|
||||
> ```bash
|
||||
> sudo tail -f /var/log/nginx/error.log
|
||||
> ```
|
||||
> `upstream timed out` 이 `192.168.122.12` 에 대해 찍히면 그것이 답이다.
|
||||
> nginx 에러 로그는 2048바이트에서 잘리므로, 잘려 보이면 access 로그를 본다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다 (4a)
|
||||
|
||||
## 4-1. ★ 죽은 파드가 산 파드보다 건강해 보인다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||
NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName
|
||||
```
|
||||
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||
```
|
||||
a2-probe Running true kc-lab-2 <none>
|
||||
keycloak-0 Running true kc-lab-2 <none>
|
||||
keycloak-1 Running false kc-lab-1 <none>
|
||||
postgres-7b474b88c8-2gf27 Running true kc-lab-2 <none>
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `keycloak-0` 은 **꺼진 기계 위에서 `ready=true`**,
|
||||
`keycloak-1` 은 **살아 있는데 `ready=false`.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
| 파드 | 왜 |
|
||||
|---|---|
|
||||
| `keycloak-0` | kubelet 이 없어 **상태를 갱신할 수 없다.** 마지막으로 보고한 값이 얼어 있다 |
|
||||
| `keycloak-1` | 살아서 **정직하게 보고한다** — DB 가 없으니 readiness 실패 |
|
||||
|
||||
> **파드 상태는 「지금 어떤가」가 아니라 「마지막으로 그렇게 들었다」이다.**
|
||||
> 노드가 죽으면 그 노드 파드의 상태는 **화석**이 된다.
|
||||
|
||||
이유를 이벤트로 확인한다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
10m Warning Unhealthy pod/keycloak-0 Readiness probe failed: Get "http://10.42.1.67:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
||||
3m15s Warning NodeNotReady pod/postgres-7b474b88c8-2gf27 Node is not ready
|
||||
3m15s Warning NodeNotReady pod/keycloak-0 Node is not ready
|
||||
3m15s Warning NodeNotReady pod/a2-probe Node is not ready
|
||||
2m27s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.35:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
||||
2s Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
|
||||
```
|
||||
|
||||
**`keycloak-1` 의 실패가 두 종류다.** 처음에는 프로브 자체가 타임아웃되고
|
||||
(`context deadline exceeded`), 나중에는 `503` 을 받는다. Keycloak 이 DB 없음을
|
||||
스스로 판단해 답할 수 있게 된 것이다. **같은 「Unhealthy」라도 층이 다르다.**
|
||||
|
||||
**`Age` 를 반드시 같이 본다.** 노드를 뽑은 것은 `3m15s` 전인데 맨 위 줄은
|
||||
`10m` 짜리다 — **주입보다 앞선 사건**이고, 앞 실험의 잔재다. 이벤트 목록은
|
||||
시간대가 섞여 있으므로 **`Age` 로 먼저 걸러야** 내가 만든 일을 고를 수 있다.
|
||||
|
||||
그리고 주입 이후 `keycloak-0` 에 붙은 이벤트는 `NodeNotReady` **하나뿐**이다.
|
||||
그것은 컨트롤러가 쓴 것이지 kubelet 이 쓴 것이 아니다. **kubelet 이 없으니
|
||||
그 파드에 대해 말해 줄 주체가 없다** — 4-1 의 `ready=true` 가 화석인 이유다.
|
||||
|
||||
## 4-2. Prometheus 는 정확했다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=up'
|
||||
```
|
||||
|
||||
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 job 과
|
||||
라벨이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 읽기 좋게 자르려면
|
||||
(`jq` 는 이 실험대에 없다) **미검증**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=up' \
|
||||
| tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'
|
||||
```
|
||||
|
||||
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||
```
|
||||
up{job=keycloak pod=keycloak-1 } = 1
|
||||
up{job=keycloak pod=keycloak-0 } = 0
|
||||
up{job=kubelet pod=- } = 1
|
||||
up{job=kubelet pod=- } = 0
|
||||
up{job=node-exporter pod=kc-lab-1 } = 1
|
||||
up{job=node-exporter pod=kc-lab-2 } = 0
|
||||
up{job=prometheus pod=- } = 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `kc-lab-2` 쪽이 전부 `0`. **`kubelet` job 이 두 줄인
|
||||
것**도 본다 — 노드마다 하나씩이라 하나는 1, 하나는 0 이다.
|
||||
|
||||
**이 결과가 의미하는 것** — `up` 은 **Prometheus 가 그 대상을 긁는 데
|
||||
성공했는가**다. 대상이 사라졌으니 실패했고, 그 0 은 **사실**이다.
|
||||
|
||||
**A-2 와 정반대다.** A-2(DB 상실)에서는 `up=1` 인데 서비스가 죽어 있었다.
|
||||
|
||||
| | `up` 이 잡는가 |
|
||||
|---|---|
|
||||
| **대상이 사라짐** (노드 상실) | **잡는다** |
|
||||
| **대상이 살아서 못 씀** (DB 상실) | **못 잡는다** |
|
||||
|
||||
Grafana 에서 같은 것을 그림으로 본다 —
|
||||
[`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png).
|
||||
|
||||
## 4-3. 쿠버네티스가 노드에 무엇을 붙였나
|
||||
|
||||
**확인** — 읽는 형태
|
||||
```bash
|
||||
sudo kubectl describe node kc-lab-2 | grep -A3 Taints
|
||||
```
|
||||
값만 필요하면
|
||||
```bash
|
||||
sudo kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo
|
||||
```
|
||||
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||
```
|
||||
=== 노드 taint — 쿠버네티스가 붙인 것 ===
|
||||
node.kubernetes.io/unreachable=:NoSchedule
|
||||
node.kubernetes.io/unreachable=:NoExecute
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 종류가 붙어 있다.
|
||||
|
||||
| taint | 뜻 |
|
||||
|---|---|
|
||||
| `NoSchedule` | **새 파드를 여기 보내지 마라** |
|
||||
| `NoExecute` | **이미 있는 파드도 쫓아내라** |
|
||||
|
||||
`NoExecute` 가 붙었는데 왜 아무 일도 안 일어나는가 — 다음 절.
|
||||
|
||||
## 4-4. 축출은 왜 5분 뒤인가
|
||||
|
||||
**확인** — 파드에 붙어 있는 관용을 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations
|
||||
```
|
||||
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
|
||||
```
|
||||
=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
|
||||
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
|
||||
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `tolerationSeconds=300`. **당신이 쓴 적 없는 값**이다.
|
||||
쿠버네티스가 모든 파드에 자동으로 붙인다.
|
||||
|
||||
```
|
||||
기계 정지
|
||||
│
|
||||
│ 40초 node-monitor-grace-period → 노드 NotReady
|
||||
│
|
||||
│ +300초 tolerationSeconds (NoExecute) → 파드 축출 시작
|
||||
▼
|
||||
총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다
|
||||
```
|
||||
|
||||
## 4-5. 그 5분을 실제로 기다린다
|
||||
|
||||
**확인** — 30초 간격으로 본다. 손으로 치기 싫으면 `watch`
|
||||
```bash
|
||||
watch -n 30 'sudo kubectl -n keycloak-lab get pods -o wide'
|
||||
```
|
||||
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
|
||||
```
|
||||
+240초 a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
|
||||
+270초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
|
||||
+300초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `+240초` 와 `+270초` 사이. 두 가지가 동시에 일어난다.
|
||||
|
||||
- `kc-lab-2` 의 파드들이 **`Terminating`** 으로 바뀐다
|
||||
- **새 이름의 postgres 파드**(`...-9cmsv`)가 생기고 **`Pending`** 이다
|
||||
|
||||
**이 결과가 의미하는 것** — 축출이 시작됐다. 그런데 `Terminating` 이 안 끝나고,
|
||||
새 파드는 뜨지 못한다. 두 문제는 원인이 다르다 — 4-6 과 4-7.
|
||||
|
||||
## 4-6. 새 파드는 왜 영원히 `Pending` 인가
|
||||
|
||||
**확인** — 파드에게 직접 물어본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
|
||||
sudo kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events
|
||||
```
|
||||
이름은 매번 다르므로 위 `get` 으로 먼저 확인하고 옮겨 적는다.
|
||||
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
|
||||
```
|
||||
Events:
|
||||
Type Reason Age From Message
|
||||
---- ------ ---- ---- -------
|
||||
Warning FailedScheduling 4m45s default-scheduler 0/2 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`0/2 nodes are available` 뒤에 이유가 노드 수만큼
|
||||
나열된다.** 이 줄 하나에 두 노드의 사연이 다 들어 있다.
|
||||
|
||||
```
|
||||
kc-lab-2 → had untolerated taint(s) (죽은 노드)
|
||||
kc-lab-1 → didn't match PersistentVolume's node affinity
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **1-3 에서 이미 알고 있던 것이 그대로 벌어졌다.**
|
||||
볼륨이 `kc-lab-2` 에 못박혀 있어서 살아 있는 노드로 못 간다. 죽은 노드에는
|
||||
taint 때문에 못 간다. **갈 곳이 없다.**
|
||||
|
||||
> 이건 결함이 아니라 **조건**이다. 이 실험대는 그걸 알고 `local-path` 를
|
||||
> 골랐다. 운영이라면 네트워크 스토리지나 DB 복제가 이 자리를 메워야 한다.
|
||||
> 노드가 영영 안 돌아오면 남는 길은 **백업 복원(D-1)** 뿐이다.
|
||||
|
||||
## 4-7. StatefulSet 은 대체 파드를 만들지 않는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get statefulset keycloak
|
||||
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||
```
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
|
||||
```
|
||||
keycloak 2 <none> 1
|
||||
keycloak-0 1/1 Terminating 0 30m
|
||||
keycloak-1 0/1 Running 0 143m
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `DESIRED=2` 인데 `CURRENT=1`. 그리고 `keycloak-0` 이
|
||||
**30분째 `Terminating`.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| StatefulSet 의 계약 | **같은 이름의 파드는 클러스터에 하나뿐**이어야 한다 |
|
||||
| 컨트롤 플레인이 아는 것 | 노드가 안 보인다 = **파드가 죽었는지 확신할 수 없다** |
|
||||
| 그래서 | 옛 파드를 확실히 지우기 전엔 새 `keycloak-0` 을 못 만든다 |
|
||||
|
||||
`Terminating` 이 안 끝나는 사슬은 이렇다.
|
||||
|
||||
```
|
||||
파드 삭제 요청
|
||||
└─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
|
||||
└─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating
|
||||
```
|
||||
|
||||
**Deployment 였다면 즉시 새 파드를 만든다.** 이름이 아무래도 되기 때문이다
|
||||
(postgres 가 실제로 그랬다 — 4-5 에서 새 이름의 파드가 생겼다. 다만 갈 곳이
|
||||
없었을 뿐이다). **StatefulSet 의 「안정된 이름」이라는 이득의 반대편 비용**이
|
||||
여기다.
|
||||
|
||||
> **강제로 진행시키는 명령이 있지만, 이 가이드에서는 치지 않는다.**
|
||||
> ```
|
||||
> kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
|
||||
> ```
|
||||
> 이것은 **컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것**이다.
|
||||
> 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 **같은 이름의 파드 둘이
|
||||
> 동시에 존재**하게 된다 — 그게 split brain 이고, 이 실험대에서는 5절의
|
||||
> `virsh start` 가 훨씬 안전하고 빠르다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구 (4a)
|
||||
|
||||
## 5-1. 전원을 다시 넣는다
|
||||
|
||||
**하기** — 터미널 A
|
||||
```bash
|
||||
date '+%H:%M:%S 재기동'
|
||||
virsh start kc-lab-2
|
||||
```
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
|
||||
```
|
||||
재기동 시각: 12:16:31
|
||||
Domain 'kc-lab-2' started
|
||||
```
|
||||
|
||||
## 5-2. 얼마나 걸리나
|
||||
|
||||
**확인** — 30초 간격
|
||||
```bash
|
||||
sudo kubectl get nodes
|
||||
sudo kubectl -n keycloak-lab get pods
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
|
||||
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
|
||||
→ 서비스 복귀
|
||||
```
|
||||
|
||||
**60초. 사람 개입 없이 전부 제자리로 돌아왔다.** `Terminating` 이던 파드도,
|
||||
`Pending` 이던 파드도 kubelet 이 돌아오자 정리됐다.
|
||||
|
||||
> **이 60초는 MTTR 이 아니다.** `virsh start` 를 친 **뒤**의 시간이다.
|
||||
> 실제 장애 구간은 **12:07:43(차단) → 12:17:31(서비스 복귀) ≈ 10분**이고,
|
||||
> 그 대부분은 사람이 관찰하고 결정하는 데 쓴 시간이다. **현실의 MTTR 도
|
||||
> 대개 그렇다.**
|
||||
>
|
||||
> 그리고 본문의 `40초`와 `5분`은 **쿠버네티스 기본값을 인용한 것**이며,
|
||||
> 관측된 전이 시점(+45초, +270초)이 그 값과 모순되지 않는다는 것까지가
|
||||
> 이 실험이 말할 수 있는 범위다. 값 자체를 측정한 것은 아니다.
|
||||
|
||||
## 5-3. 4b 로 넘어가기 전 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| VM | `virsh list --all` | 둘 다 `running` |
|
||||
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `1/1 Running`, `Pending` 없음 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
|
||||
```
|
||||
=== 복구 확인 ===
|
||||
keycloak-0 1/1 Running 0 68s
|
||||
keycloak-1 1/1 Running 0 144m
|
||||
postgres-7b474b88c8-9cmsv 1/1 Running 0 4m20s
|
||||
```
|
||||
|
||||
**`postgres` 의 이름이 바뀌어 있다** (`-2gf27` → `-9cmsv`). 4-5 에서 생겼다가
|
||||
`Pending` 이던 그 파드가 노드가 살아나자 그대로 뜬 것이다. **`keycloak-0` 은
|
||||
이름이 그대로** — StatefulSet 이라 그렇다. 두 컨트롤러의 차이가 이름에 남는다.
|
||||
|
||||
여기까지 안 돌아왔으면 **4b 로 넘어가지 않는다.** 두 고장이 겹치면 무엇이
|
||||
원인인지 못 가린다.
|
||||
|
||||
---
|
||||
|
||||
# 6. 4b — 이번엔 컨트롤 플레인을 뽑는다
|
||||
|
||||
## 6-1. 먼저 인벤토리 — 그게 곧 영향 범위다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
|
||||
```
|
||||
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
|
||||
```
|
||||
keycloak-lab keycloak-1
|
||||
kube-system coredns-54996dc9b4-8k8fj
|
||||
kube-system helm-install-traefik-crd-q29b5
|
||||
kube-system local-path-provisioner-77b9867795-g27z8
|
||||
kube-system metrics-server-6dc596dfb8-7xxq4
|
||||
kube-system svclb-traefik-5eb6a9a1-qwwk5
|
||||
kube-system traefik-5d6fcf895-wpfhr
|
||||
observability grafana-845b5678cf-b6gvc
|
||||
observability node-exporter-9qk9w
|
||||
observability prometheus-6774f94f7c-pzr2t
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `traefik`. **진입점이 여기 있다.**
|
||||
|
||||
**확인** — 진입점이 몇 개인가
|
||||
```bash
|
||||
sudo kubectl -n kube-system get deploy traefik
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
traefik 1 1
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **`replicas=1`. 진입점이 단일 장애점이다.**
|
||||
이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 4a 와 결과가 다른 이유가
|
||||
여기서 이미 예측된다.
|
||||
|
||||
## 6-2. 뽑는다
|
||||
|
||||
**되돌리기** — 터미널 A
|
||||
```bash
|
||||
virsh start kc-lab-1
|
||||
```
|
||||
|
||||
**하기** — 터미널 A
|
||||
```bash
|
||||
date '+%H:%M:%S 차단'
|
||||
virsh destroy kc-lab-1
|
||||
```
|
||||
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
|
||||
```
|
||||
차단 시각: 12:18:08
|
||||
Domain 'kc-lab-1' destroyed
|
||||
```
|
||||
|
||||
**터미널 B 가 여기서 죽는다.** SSH 세션이 그대로 끊긴다. 놀랄 일이 아니다.
|
||||
|
||||
## 6-3. 주입 검증 — `kubectl` 이 죽은 것이 곧 증거다
|
||||
|
||||
**확인** — 터미널 A 나 C 에서
|
||||
```bash
|
||||
sudo kubectl get nodes
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
kubectl: Unable to connect to the server: dial tcp
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Unable to connect to the server`. API 서버가
|
||||
`kc-lab-1:6443` 에 있었으므로 **당연한 결과**다.
|
||||
|
||||
**이 결과가 의미하는 것** — 4a 에서는 「쿠버네티스가 뭐라고 하는가」를 물을 수
|
||||
있었다. **여기서는 물어볼 상대 자체가 없다.** 이 실험의 관찰 도구가 통째로
|
||||
바뀐다.
|
||||
|
||||
## 6-4. 밖에서는 어떻게 보이나
|
||||
|
||||
**확인** — 터미널 C. 20초 간격으로 두 주소를 본다
|
||||
```bash
|
||||
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 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/
|
||||
```
|
||||
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
|
||||
```
|
||||
+20초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
|
||||
+60초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
|
||||
+120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server: dial tcp
|
||||
+160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `+120초` 의 **`grafana=502` 한 줄.** 나머지는 전부
|
||||
`000` 인데 여기만 다르다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`000` 과 `503`/`502` 는 서로 다른 층의 고장을
|
||||
가리킨다.**
|
||||
|
||||
| 코드 | 어디까지 살아 있는가 |
|
||||
|---|---|
|
||||
| **`503`** (4a) | nginx·Traefik 은 살아 있고 **뒤에 보낼 파드가 없다** |
|
||||
| **`502`** (4b, 한 번) | nginx 가 **연결 실패를 제때 판정해** 자기 힘으로 502 를 만들었다 |
|
||||
| **`000`** (4b, 대부분) | nginx 가 죽은 주소를 기다리다 **우리 `--max-time 8` 이 먼저 끝났다** |
|
||||
|
||||
`502` 가 한 번이라도 찍혔다는 것이 **nginx 는 살아 있었다**는 증거다.
|
||||
같은 고장인데 코드가 흔들리는 이유는 **타임아웃 경주**다.
|
||||
|
||||
## 6-5. ★ 그런데 워크로드는 살아 있다
|
||||
|
||||
`kubectl` 이 없으니 **노드의 컨테이너 런타임에 직접 묻는다.**
|
||||
|
||||
**확인** — 터미널 A 에서 살아남은 노드로
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo crictl ps --name keycloak'
|
||||
```
|
||||
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
|
||||
```
|
||||
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
|
||||
e5f777900b762 60e153026e8f5 4 minutes ago Running keycloak 0 640d4dafaefb3 keycloak-0 keycloak-lab
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `STATE` 가 `Running`, `ATTEMPT` 가 `0`.
|
||||
**API 서버가 없는데도 컨테이너는 돌고 있다.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
죽은 것: API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
|
||||
산 것: keycloak-0 · postgres · containerd
|
||||
문제: 들어갈 문(Traefik)이 없다
|
||||
```
|
||||
|
||||
> **컨트롤 플레인 상실 ≠ 워크로드 상실.**
|
||||
> 이미 떠 있는 것은 계속 돈다. **새로 뜨거나 옮기거나 고치는 것이 안 될 뿐.**
|
||||
|
||||
전체 목록도 본다.
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo crictl ps'
|
||||
```
|
||||
`crictl` 이 소켓을 못 찾으면 k3s 의 것을 직접 준다. **미검증**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'
|
||||
```
|
||||
|
||||
## 6-6. 관측자가 같이 죽으면 0 이 아니라 구멍이 남는다
|
||||
|
||||
**지금은 확인할 수 없다.** Prometheus 도 Grafana 도 `kc-lab-1` 에 있었다.
|
||||
**그것이 이 발견이다.**
|
||||
|
||||
```
|
||||
대상이 죽음 → up = 0 → "언제 죽었는지" 알 수 있다
|
||||
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
|
||||
```
|
||||
|
||||
복구 뒤에 Grafana 에서 `up` 그래프를 다시 열어 **12:18–12:23 구간이 0 이
|
||||
아니라 빈칸**인 것을 확인한다. 6-8 에서 한다.
|
||||
|
||||
## 6-7. 복구
|
||||
|
||||
**하기** — 터미널 A
|
||||
```bash
|
||||
date '+%H:%M:%S 재기동'
|
||||
virsh start kc-lab-1
|
||||
```
|
||||
**실측** — [`08-control-plane-recovery.txt`](../../evidence/a4-node-loss/08-control-plane-recovery.txt)
|
||||
```
|
||||
재기동: 12:23:39
|
||||
Domain 'kc-lab-1' started
|
||||
|
||||
+30초 외부=502 | kc-lab-1=Ready kc-lab-2=Ready
|
||||
+60초 외부=200 | kc-lab-1=Ready kc-lab-2=Ready
|
||||
→ 서비스 복귀 (총 60초)
|
||||
```
|
||||
|
||||
**여기서도 60초.** `+30초` 의 `502` 는 **nginx 가 먼저 살아나고 Traefik 이
|
||||
아직 안 뜬** 중간 상태다. 4b 내내 보던 `000` 과 층이 다르다.
|
||||
|
||||
## 6-8. 복구 후에 확인할 것
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
keycloak-0 1/1 Running 0 7m57s
|
||||
keycloak-1 1/1 Running 1 (<invalid> ago) 151m
|
||||
postgres-7b474b88c8-9cmsv 1/1 Running 0 11m
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
|
||||
|
||||
- `keycloak-1` 의 `RESTARTS` 가 **1** — `kc-lab-1` 위에 있었으니 당연하다
|
||||
- `AGE` 가 `151m` 인데 재시작은 방금 — **AGE 는 파드가 만들어진 시각**이지
|
||||
컨테이너가 시작한 시각이 아니다
|
||||
- **`(<invalid> ago)`** — 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다.
|
||||
**원인은 이 실험이 확정하지 않았다.** 잠시 뒤 다시 치면 정상 값으로 바뀐다
|
||||
|
||||
**확인** — Grafana. 6-6 에서 예고한 구멍
|
||||
```
|
||||
브라우저로 Grafana 를 열어 up{job="keycloak"} 그래프를 12:15–12:30 으로 본다
|
||||
```
|
||||
**실측** — [`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png)
|
||||
그림에서 12:18–12:23 은 **선이 0 으로 내려간 것이 아니라 아예 끊겨 있다.**
|
||||
|
||||
**Grafana 로그인이 풀려 있다.** Grafana 데이터가 `emptyDir` 이라 파드
|
||||
재시작에 사라진다. Prometheus 는 PVC 라 지표가 남았다 — 다만 관측자가 죽어
|
||||
있던 구간의 데이터는 애초에 수집되지 않았다. **의도한 설계대로 동작했고,
|
||||
그 설계의 한계도 함께 드러났다.**
|
||||
|
||||
Prometheus 를 `port-forward` 로 보고 있었다면 **다시 연결해야 한다** (실측
|
||||
기록의 마지막 줄이 그것이다).
|
||||
|
||||
## 6-9. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| VM | `virsh list --all` | 둘 다 `running` |
|
||||
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | 전부 `1/1 Running` |
|
||||
| 진입점 | `sudo kubectl -n kube-system get deploy traefik` | `1/1` |
|
||||
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||
| 관측 | Prometheus `up` | 전부 1 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
> **이 실험이 재지 않은 것** — 노드가 **영영 안 돌아오는** 경우는 재지 않았다.
|
||||
> `local-path` PVC 가 그 노드와 함께 사라진 상태에서의 복구는 **D-1(백업·복원)**
|
||||
> 의 주제다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `virsh` 가 도메인을 못 찾는다 | `qemu:///session` 을 보고 있다 | `virsh uri` — `system` 이어야 한다 |
|
||||
| VM 을 껐는데 노드가 `Ready` | **정상.** `node-monitor-grace-period` 40초 | `virsh list --all` 로 전원을 먼저 본다 — 3-1 |
|
||||
| 5분이 지나도 축출이 안 온다 | 40초 + `tolerationSeconds=300` = **5분 40초** | `describe pod \| grep -A4 Tolerations` |
|
||||
| 새 파드가 계속 `Pending` | **PVC 가 죽은 노드에 못박혀 있다** | `describe pod` 의 `FailedScheduling` — 4-6 |
|
||||
| `keycloak-0` 이 30분째 `Terminating` | StatefulSet + kubelet 없음. **정상이다** | `get statefulset` 의 `CURRENT` — 4-7 |
|
||||
| `--force` 로 지우고 싶다 | 노드가 살아 있으면 **중복 실행**이 된다 | 치지 말고 `virsh start` — 4-7 |
|
||||
| `kubectl` 이 전혀 안 된다 (4b) | **API 서버가 죽은 노드에 있었다.** 정상 | `ssh kc-lab-2 'sudo crictl ps'` — 6-5 |
|
||||
| `crictl` 이 소켓을 못 찾는다 | k3s 는 자기 containerd 소켓을 쓴다 | `--runtime-endpoint unix:///run/k3s/containerd/containerd.sock` |
|
||||
| `503` 을 기대했는데 `000` | 층이 다르다. nginx 가 죽은 주소를 기다린다 | `--max-time` 을 늘려 보면 `502` 가 나온다 — 6-4 |
|
||||
| `curl` 이 몇 분씩 안 끝난다 | `--max-time` 을 안 줬다 | 모든 외부 확인에 `--max-time 8` |
|
||||
| 그래프의 그 구간이 0 으로 보인다 | **0 이 아니라 데이터 없음이다** | 점 사이가 이어져 있는지 본다 — 6-6 |
|
||||
| Grafana 로그인이 풀렸다 | 데이터가 `emptyDir` | 재시작마다 그렇다. PVC 로 바꾸면 남는다 |
|
||||
| Prometheus 가 갑자기 안 보인다 | `port-forward` 가 끊겼다 | 다시 연다 |
|
||||
| `RESTARTS` 가 `1 (<invalid> ago)` | 재시작 직후에 나온다. **원인 미확정** | 잠시 뒤 다시 친다 — 6-8 |
|
||||
| 4b 결과가 4a 와 섞인다 | 4a 복구를 확인하지 않고 넘어갔다 | 5-3 확인표를 통과한 뒤 시작 |
|
||||
|
||||
---
|
||||
|
||||
# 이 실험이 남기는 구성 숙제
|
||||
|
||||
관찰만 하고 끝내면 아깝다. **두 가지는 지금 고칠 수 있다.**
|
||||
|
||||
| 발견 | 고치는 방향 |
|
||||
|---|---|
|
||||
| Traefik `replicas=1` 이라 진입점이 단일 장애점 | `replicas=2` 로 늘리거나 DaemonSet 으로 |
|
||||
| Grafana 가 `emptyDir` 이라 재시작마다 세션이 사라짐 | PVC 를 붙인다 |
|
||||
| 관측 스택이 실험 대상 노드에 함께 있음 | 노드가 둘뿐이라 완전히는 못 피한다. **아는 것이 먼저** |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-4 가 남긴 질문 |
|
||||
|---|---|
|
||||
| [A-5](../../experiment-a5-asymmetric-partition.md) 비대칭 파티션 | 여기서는 노드가 **완전히** 사라졌다. **부분 단절은 더 고약하다** |
|
||||
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | **PVC 가 노드에 묶여 있다.** 노드가 영영 안 돌아오면 백업이 유일한 길 |
|
||||
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | 여기서는 `up=0` 이 정확했다. **느려짐은 `up` 이 못 잡는다** |
|
||||
| 전부 | **주입 검증의 기준을 먼저 정한다.** 여기서는 쿠버네티스가 아니라 하이퍼바이저가 기준이었다 |
|
||||
@@ -0,0 +1,919 @@
|
||||
# A-5 재현 가이드 — 한 방향만 끊어 보고, 왜 안 갈라지는지 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-a5-asymmetric-partition.md`](../../experiment-a5-asymmetric-partition.md) ·
|
||||
증거 원문: [`docs/evidence/a5-asymmetric-partition/`](../../evidence/a5-asymmetric-partition/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 규칙을 넣었는데 **0 패킷**인 상태 | `iptables -L -n -v` 의 카운터 |
|
||||
| kube-router 가 내 규칙을 **아래로 밀어내는** 것 | `FORWARD` 체인의 줄 번호 |
|
||||
| JGroups 연결 방향이 **A-1 때와 반대**인 것 | `conntrack -L` |
|
||||
| 단방향 차단이 **스스로 낫는** 것 | 연결이 뒤집혀 재연결 |
|
||||
| `coord = t` 가 둘인 split brain | PostgreSQL `JGROUPS_PING` |
|
||||
| 그런데 **한쪽만 DOWN 이고 외부는 200** 인 것 | `health/ready` · `endpointslice` |
|
||||
| `MergeView` 로 50초 만에 합쳐지는 것 | Keycloak 로그 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- [`A-1`](a1-jgroups-transport-block.md) 을 먼저 해 두면 훨씬 이해가 빠르다.
|
||||
**이 실험은 A-1 이 실패한 자리에서 시작한다.**
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. **iptables 는 두 노드에 각각
|
||||
넣어야 하고, 어느 노드에 넣느냐가 이 실험의 핵심이다.**
|
||||
- 터미널 **두 개**를 열어 두면 편하다. 하나는 상주 탐침 파드용, 하나는 관찰용.
|
||||
|
||||
## 주의 — 이건 상태를 부수는 실험이다
|
||||
|
||||
Keycloak 클러스터를 실제로 분단시킨다. **실험대에서만 한다.**
|
||||
전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다.
|
||||
중간에 그만두려면 두 줄이면 된다.
|
||||
|
||||
```bash
|
||||
sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD
|
||||
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD'
|
||||
```
|
||||
|
||||
> **`-F FORWARD` 는 그 체인 전체를 비운다.** 이 실험대의 `FORWARD` 정책은
|
||||
> `ACCEPT` 이고 실제 규칙은 kube-router·kube-proxy 가 **자기 체인에** 두므로
|
||||
> 잠시 뒤 스스로 복구된다. 그래도 지우기 전에 **`sudo iptables -S FORWARD` 로
|
||||
> 무엇이 있었는지 한 번 보고** 지운다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 12:28–12:46 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
IP·파드 이름·포트 번호는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||||
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
|
||||
전부 위 실행 기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
A-1 이 열어 둔 질문이 하나 있었다.
|
||||
|
||||
> *「`keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고,
|
||||
> `keycloak-0` 은 합류 자체를 못 해 DOWN 이 됐다. **양쪽이 동시에 DOWN 이 되는
|
||||
> 경로가 있다면 전면 장애다.**」*
|
||||
|
||||
그 경로를 찾는 것이 이 실험이다. 그리고 A-1 은 도구도 하나 남겼다.
|
||||
|
||||
| | A-1 이 배운 것 |
|
||||
|---|---|
|
||||
| NetworkPolicy | **기존 연결을 못 끊는다.** conntrack 의 `ESTABLISHED` 가 먼저 통과시킨다 |
|
||||
| 그래서 | 이번엔 iptables 로 직접 간다 |
|
||||
|
||||
**그런데 iptables 에도 벽이 세 개 있었다.** 이 가이드의 절반은 그 세 번의
|
||||
실패를 **일부러 다시 밟는 것**이다. 셋 다 화면에는 **「아무 일도 없었다」**로
|
||||
보이기 때문에, 겪어 보지 않으면 다음에도 똑같이 속는다.
|
||||
|
||||
```
|
||||
실패 ① filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다
|
||||
실패 ② raw 로 옮겼는데도 0 패킷 → 연결 방향을 잘못 짚었다
|
||||
성공 수신측 노드의 raw PREROUTING → 19 패킷
|
||||
그런데 그래도 안 갈라진다 → 반대 방향으로 재연결한다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 아무것도 넣기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 특히 이 실험은 **주입이 걸리기 전과
|
||||
후가 화면상 똑같이 보이므로**, 기준선이 없으면 실패를 성공으로 읽는다.
|
||||
|
||||
## 1-1. 파드 IP 와 노드 — 이 값이 곧 규칙의 인자다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||
```
|
||||
keycloak-0=10.42.1.77 (kc-lab-2) keycloak-1=10.42.0.42 (kc-lab-1)
|
||||
keycloak-0 1/1 Running 0 11m
|
||||
keycloak-1 1/1 Running 1 (2m48s ago) 155m
|
||||
postgres-7b474b88c8-9cmsv 1/1 Running 0 14m
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- **`NODE` 와 파드 번호가 어긋난다** — `keycloak-0` 이 `kc-lab-2` 에 있다.
|
||||
iptables 를 **어느 노드에** 넣을지 정할 때 이걸 헷갈리면 규칙은 걸리는데
|
||||
패킷은 안 걸린다
|
||||
- **IP 가 A-1 때와 다르다** (`10.42.1.43` → `10.42.1.77`). 파드가 재시작되면
|
||||
바뀐다. 여기 적힌 값을 그대로 쓰지 말고 **지금 뽑는다**
|
||||
|
||||
변수로 잡아 둔다. **파드가 재시작되면 다시 잡는다.**
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||
echo "K0=$K0 K1=$K1"
|
||||
```
|
||||
|
||||
> `keycloak-1` 의 `RESTARTS` 가 **1** 인 것도 보인다. A-4 에서 노드를 껐다
|
||||
> 켠 흔적이다. **직전 실험의 잔재가 남아 있는지 확인하는 자리**이기도 하다.
|
||||
|
||||
## 1-2. 클러스터가 지금 하나인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, ip, coord from jgroups_ping order by name"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
name | ip | coord
|
||||
------------------+-----------------+-------
|
||||
keycloak-0-24309 | 10.42.1.77:7800 | f
|
||||
keycloak-1-45480 | 10.42.0.42:7800 | t
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`coord` 열에 `t` 가 정확히 하나.**
|
||||
둘이면 이미 갈라져 있는 것이고, 그 상태에서 주입해 봐야 아무것도 판정 못 한다.
|
||||
|
||||
**확인** — 로그가 말하는 뷰
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)]
|
||||
```
|
||||
|
||||
**뷰 ID(`|13`)를 적어 둔다.** 이 실험의 판정 기준이 이 숫자의 변화다.
|
||||
|
||||
## 1-3. 지표 — 그리고 이 자리에서 원 실행이 넘어졌다
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 밖에서 Prometheus 에
|
||||
묻는 것이 가장 짧다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
**형태** — 한 줄 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다
|
||||
```json
|
||||
{"status":"success","data":{"resultType":"vector","result":[
|
||||
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-1","node":"kc-lab-1"},"value":[1757037600.123,"2"]},
|
||||
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-0","node":"kc-lab-2"},"value":[1757037600.123,"2"]}]}}
|
||||
```
|
||||
라벨을 보고 나면 읽기 좋게 자른다 (`jq` 는 이 실험대에 없다). **미검증**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
|
||||
|
||||
> **★ 원 실행의 기준선은 남지 않았다.** 값을 뽑으려고 붙인 파이썬 한 줄이
|
||||
> 죽었기 때문이다. — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||
> ```
|
||||
> Traceback (most recent call last):
|
||||
> File "<string>", line 3, in <module>
|
||||
> for r in json.load(sys.stdin)["data"]["result"]: print(f" cluster_size {r["metric"].get("pod"):12} = {r["value"][1]}")
|
||||
> json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
|
||||
> ```
|
||||
> **입력이 비어 있었다.** 그런데 파서가 죽으면서 **원본도 같이 사라졌다** —
|
||||
> 화면에 남은 것은 파이썬 스택트레이스뿐이고, Prometheus 가 무엇을 돌려줬는지는
|
||||
> 아무도 모른다. **원본을 먼저 보고 나중에 자르면** 이런 일이 없다.
|
||||
> 이 가이드가 `wget` 원문을 먼저 보여 주는 이유다.
|
||||
|
||||
## 1-4. ★ 연결 방향 — 이 실험에서 가장 중요한 기준선
|
||||
|
||||
**어느 쪽이 클라이언트이고 어느 쪽이 서버인가.** 이걸 모르면 규칙을 엉뚱한
|
||||
노드에 넣게 된다.
|
||||
|
||||
**확인** — 두 노드 모두에서 본다
|
||||
```bash
|
||||
sudo conntrack -L 2>/dev/null | grep 7800
|
||||
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
|
||||
```
|
||||
**실측** — 해설 문서 1절 (실패 ② 에서 인용된 원 실행의 연결)
|
||||
```
|
||||
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
|
||||
──────────── ────────────────────
|
||||
keycloak-0 가 클라이언트 keycloak-1 이 서버
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dport=7800` 인 쪽이 **서버**다. `src` 가 클라이언트.
|
||||
|
||||
**이 결과가 의미하는 것** — **A-1 때와 방향이 반대다.** A-1 에서는
|
||||
`10.42.0.35:40023 → 10.42.1.43:7800`, 즉 `keycloak-1` 이 걸었다. 지금은
|
||||
`keycloak-0` 이 건다.
|
||||
|
||||
> **JGroups 의 TCP 연결 방향은 고정이 아니다.** 먼저 뜬 쪽, 먼저 JOIN 을 건
|
||||
> 쪽에 따라 달라진다. 파드가 재시작될 때마다 바뀔 수 있다.
|
||||
> **가정하지 말고 매번 `conntrack -L` 로 본다.**
|
||||
|
||||
`2>/dev/null` 은 `conntrack` 이 stderr 로 찍는 「N flow entries have been shown」
|
||||
요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다.
|
||||
|
||||
## 1-5. 밖에서 보이는 상태
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
여러 번 재서 비교할 것이므로 이제부터는 코드만 뽑는다.
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
`200` 이어야 한다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 시도 ① — `filter` 테이블 최상단 (실패한다)
|
||||
|
||||
**일부러 실패하는 단계다.** 건너뛰지 않는 편이 좋다. 이 실패의 모양을 봐 둬야
|
||||
다음에 자기 규칙을 의심할 수 있다.
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo iptables -F FORWARD'
|
||||
```
|
||||
|
||||
## 2-1. 넣는다
|
||||
|
||||
A-1 의 NetworkPolicy 는 conntrack 에 막혔다. **`FORWARD` 최상단에 넣으면
|
||||
conntrack 승인보다 먼저 평가될 것**이라는 게 이 시도의 가설이다.
|
||||
|
||||
**하기** — `keycloak-0`(수신측이라고 **가정한** 쪽) 이 있는 노드에
|
||||
```bash
|
||||
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800 -j DROP"
|
||||
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP"
|
||||
date '+%H:%M:%S 주입'
|
||||
```
|
||||
|
||||
> **57800 도 같이 막는다.** FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을
|
||||
> 쓴다. 7800 만 막으면 **장애 감지는 계속 통해서** 분단이 어정쩡해진다.
|
||||
|
||||
**확인** — 방금 넣은 것이 실제로 1번인가
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
|
||||
```
|
||||
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||
```
|
||||
Chain FORWARD (policy ACCEPT)
|
||||
num target prot opt source destination
|
||||
1 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:57800
|
||||
2 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:7800
|
||||
주입 시각: 12:28:23
|
||||
```
|
||||
|
||||
**넣은 직후에는 맞게 보인다.** 여기서 만족하고 넘어가면 속는다.
|
||||
|
||||
## 2-2. 잠시 뒤 다시 본다 — ★ 밀려나 있다
|
||||
|
||||
**확인** — 1~2분 뒤 같은 명령을 다시
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
|
||||
```
|
||||
**실측** — 해설 문서 1절 (실패 ①)
|
||||
```
|
||||
num pkts bytes target
|
||||
1 232 377K KUBE-ROUTER-FORWARD /* kube-router netpol */ ← 다시 1번이 되었다
|
||||
2 0 0 DROP tcp dpt:57800
|
||||
3 0 0 DROP tcp dpt:7800 ← 0 패킷
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 가지를 동시에 본다.
|
||||
|
||||
| 열 | 무엇을 말하는가 |
|
||||
|---|---|
|
||||
| `num` | 내 규칙이 **1번이 아니다.** kube-router 체인이 위로 돌아왔다 |
|
||||
| **`pkts`** | **0.** 이 규칙에는 패킷이 단 한 개도 도달하지 않았다 |
|
||||
|
||||
**이 결과가 의미하는 것** — **kube-router 가 주기적으로 자기 체인을 `FORWARD`
|
||||
최상단에 다시 삽입한다.** 내가 1번에 넣어도 곧 2번, 3번으로 밀려나고,
|
||||
kube-router 체인이 패킷을 먼저 처리해 버린다.
|
||||
|
||||
> **직접 넣은 iptables 규칙은 CNI 가 관리하는 체인과 경쟁한다.**
|
||||
> **넣는 것으로 끝이 아니다. 패킷 카운터로 확인해야 한다.**
|
||||
|
||||
> **정직하게** — 이 확인을 담았어야 할 증거 파일
|
||||
> [`02-injection-verify.txt`](../../evidence/a5-asymmetric-partition/02-injection-verify.txt)
|
||||
> 는 **원 실험 시점에 0바이트로 저장됐다.** 리다이렉션이 stdout 만 받았는데
|
||||
> 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 **사후에
|
||||
> 다시 수집한 것**이며, 원 시점의 DROP 규칙은 이미 없어서 재현되지 않는다.
|
||||
> 남아 있는 것은 구조적 사실 하나 — kube-router 체인이 `FORWARD` 1번을
|
||||
> 차지하고 있다는 것뿐이다.
|
||||
> **당신은 지금 실제 카운터를 볼 수 있다.** 이 단계를 건너뛰지 않는 이유다.
|
||||
|
||||
## 2-3. 그래서 아무 일도 안 일어난다
|
||||
|
||||
**확인** — 25초 간격으로 몇 번 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||
```
|
||||
+25초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
+50초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
...
|
||||
+200초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
```
|
||||
|
||||
**★ 여기서 「비대칭 차단은 클러스터를 안 가른다」고 결론 내리면 틀린다.**
|
||||
결론이 우연히 맞더라도 **근거가 없다.** 규칙에 패킷이 0 개 왔으니
|
||||
**이 관찰은 아무것도 측정하지 않았다.**
|
||||
|
||||
## 2-4. 치운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 7800 -j DROP"
|
||||
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 57800 -j DROP"
|
||||
ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5'
|
||||
```
|
||||
`-D` 는 **넣을 때와 똑같은 인자**를 줘야 지워진다. 안 지워지면 줄 번호로:
|
||||
`sudo iptables -D FORWARD 3`.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입 시도 ② — `raw` 테이블로 옮긴다 (그래도 0 패킷)
|
||||
|
||||
## 3-1. 개념 — netfilter 처리 순서
|
||||
|
||||
```
|
||||
패킷 도착
|
||||
│
|
||||
├─▶ raw PREROUTING ← conntrack 보다 먼저. NOTRACK·DROP 용
|
||||
│
|
||||
├─▶ conntrack 조회/생성 ← 여기서 ESTABLISHED 가 결정된다
|
||||
│
|
||||
├─▶ mangle PREROUTING
|
||||
├─▶ nat PREROUTING
|
||||
├─▶ filter FORWARD ← NetworkPolicy·kube-router 가 여기 있다
|
||||
└─▶ 목적지 파드
|
||||
```
|
||||
|
||||
| 어디에 넣는가 | 기존 연결을 끊는가 | CNI 와 경쟁하는가 |
|
||||
|---|---|---|
|
||||
| NetworkPolicy (filter) | **못 끊는다** — conntrack 이 먼저 통과시킨다 (A-1) | 없음 |
|
||||
| filter FORWARD 직접 | 순서에 따라 | **경쟁한다** (kube-router 가 밀어낸다) — 2절 |
|
||||
| **raw PREROUTING** | **끊는다** | **없다** — CNI 가 안 쓰는 테이블 |
|
||||
|
||||
**진짜 네트워크 분단을 흉내내려면 `raw` 테이블이 맞다.**
|
||||
|
||||
## 3-2. 넣는다 — 아직 같은 노드, 같은 목적지
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
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 주입'
|
||||
```
|
||||
|
||||
**확인** — 카운터
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
|
||||
```
|
||||
**실측** — [`03-raw-table-injection.txt`](../../evidence/a5-asymmetric-partition/03-raw-table-injection.txt)
|
||||
```
|
||||
=== [검증] 이번엔 패킷이 걸렸는가 ===
|
||||
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
|
||||
pkts bytes target prot opt in out source destination
|
||||
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:57800
|
||||
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:7800
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`pkts` 가 여전히 0.** 이번에는 CNI 와 경쟁하지도
|
||||
않는데 0 이다.
|
||||
|
||||
## 3-3. 왜 0 인가 — 1-4 를 다시 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
|
||||
```
|
||||
1-4 에서 본 것이 답이다.
|
||||
|
||||
```
|
||||
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
|
||||
└── keycloak-0 ──┘ └── keycloak-1 ──┘
|
||||
(클라이언트) (서버, 7800 을 듣는 쪽)
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — `10.42.1.77`(keycloak-0)은 이 연결의 **출발지**다.
|
||||
`-d 10.42.1.77 --dport 7800` 은 **존재하지 않는 패킷**을 노린 규칙이었다.
|
||||
7800 으로 **들어가는** 패킷은 `10.42.0.42`(keycloak-1) 쪽으로 간다.
|
||||
|
||||
```
|
||||
내가 막은 것: → 10.42.1.77:7800 (그런 패킷이 없다)
|
||||
실제 흐름: → 10.42.0.42:7800 (여기를 막아야 한다)
|
||||
```
|
||||
|
||||
**규칙을 넣은 노드도 틀렸다.** 목적지 파드가 있는 노드에서 잡아야 한다.
|
||||
|
||||
## 3-4. 치운다
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers'
|
||||
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||
```
|
||||
**지우기 전에 `-L` 로 무엇이 있는지 본다.** `-F` 는 체인 전체를 비운다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 주입 성공 — 수신측 노드의 `raw PREROUTING`
|
||||
|
||||
## 4-1. 넣는다
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo iptables -t raw -F PREROUTING
|
||||
```
|
||||
|
||||
**하기** — 이번에는 **`kc-lab-1`(keycloak-1 이 있는 노드)** 에, `keycloak-1` 의
|
||||
IP 를 목적지로
|
||||
```bash
|
||||
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
|
||||
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
|
||||
date '+%H:%M:%S 주입'
|
||||
```
|
||||
|
||||
**실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt)
|
||||
```
|
||||
=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
|
||||
주입: 12:33:58
|
||||
```
|
||||
|
||||
## 4-2. 이번엔 걸리는가 — 카운터가 유일한 판정 기준이다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo iptables -t raw -L PREROUTING -n -v
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
pkts bytes target prot opt in out source destination
|
||||
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
|
||||
19 2938 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **7800 규칙의 `pkts` 가 19.** 드디어 걸린다.
|
||||
|
||||
**57800 은 아직 0 인 것도 정보다.** FD_SOCK2 는 이미 붙어 있는 연결을 쓰고
|
||||
있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다 —
|
||||
|
||||
**실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/05-reconnect-observed.txt)
|
||||
```
|
||||
=== 차단 규칙 누적 카운터 ===
|
||||
19 1096 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
|
||||
21 3058 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
|
||||
```
|
||||
|
||||
> **카운터 판정표**
|
||||
>
|
||||
> | `pkts` | 뜻 | 할 일 |
|
||||
> |---|---|---|
|
||||
> | `0` | **아무것도 측정하지 않았다** | 해석 금지. 방향과 테이블을 다시 본다 |
|
||||
> | 조금씩 는다 | 재연결 시도가 막히고 있다 | 관찰로 넘어간다 |
|
||||
> | 폭증한다 | 대상이 너무 넓다 | `-d`·`--dport` 를 좁힌다 |
|
||||
|
||||
---
|
||||
|
||||
# 5. 효과를 관찰한다 — 단방향은 클러스터를 못 가른다
|
||||
|
||||
## 5-1. 파드와 외부
|
||||
|
||||
**확인** — 25초 간격
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt)
|
||||
```
|
||||
+25초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
+50초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
+75초 - | keycloak-0:1/1 keycloak-1:0/1 | 외부 200
|
||||
+100초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
+125초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `+75초` 에 `keycloak-1` 이 **한 번 `0/1` 로
|
||||
흔들렸다가 `+100초` 에 돌아온다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 주입이 **닿기는 했다**(2절의 아무 일 없음과 다르다).
|
||||
그런데 **스스로 나았다.**
|
||||
|
||||
## 5-2. 뷰가 변했나 — 그리고 로그 시각의 함정
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094
|
||||
```
|
||||
|
||||
**여기서 시각을 비교하려다 대부분 한 번은 틀린다.**
|
||||
|
||||
```
|
||||
당신 셸의 date 12:33:58 KST
|
||||
컨테이너 로그의 시각 03:33:58 ← 같은 순간이다. UTC 다
|
||||
```
|
||||
|
||||
**Keycloak 컨테이너는 UTC 로 찍는다.** KST 는 UTC+9 이므로 **9시간을 빼서**
|
||||
맞춰 본다. 이걸 모르면 「주입 전 로그」와 「주입 후 로그」를 정반대로 가른다.
|
||||
|
||||
**실측** — [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
|
||||
```
|
||||
2026-09-04 03:33:49 | MergeView::[keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)],
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 뷰 `13`, 멤버 `(2)`, 그리고 **`MergeView`**.
|
||||
|
||||
**이 결과가 의미하는 것 — ★ 여기가 이 실험의 가장 미묘한 자리다.**
|
||||
|
||||
해설 문서는 처음에 「주입 이후 뷰 변화가 하나도 없었다」고 썼다. 맞는 말이다.
|
||||
그런데 그 **「주입 전부터 그대로」의 「전」이 9초였다.**
|
||||
|
||||
```
|
||||
03:33:49 MergeView 로 뷰 13 이 만들어짐 ← 그 직전에는 |12] (1), 즉 분단 상태였다
|
||||
03:33:58 내 주입 ← 9초 뒤
|
||||
```
|
||||
|
||||
앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였던 것이다.
|
||||
|
||||
> **로그 한 줄만 보고 「변화 없음」이라고 말하면 안 된다.**
|
||||
> **그 줄이 언제 생겼는지**를 함께 본다. `grep` 에 시각이 같이 나오는 형태를
|
||||
> 쓰는 이유가 이것이다.
|
||||
>
|
||||
> 결론 자체(주입 이후 뷰가 변하지 않았다)는 유지된다. 다만 **기준선이 9초짜리
|
||||
> 였다**는 사실은 함께 적어야 정직하다.
|
||||
|
||||
## 5-3. ★ 왜 안 갈라졌나 — 연결이 뒤집혔다
|
||||
|
||||
**확인** — 1-4 와 **똑같은 명령**을 다시 친다. 그게 대조의 방법이다
|
||||
```bash
|
||||
sudo conntrack -L 2>/dev/null | grep 7800
|
||||
```
|
||||
**실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/05-reconnect-observed.txt)
|
||||
```
|
||||
tcp 6 299 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=48473 dport=7800 src=10.42.1.77 dst=10.42.0.42 sport=7800 dport=48473
|
||||
tcp 6 86232 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=44205 dport=57800 src=10.42.1.77 dst=10.42.0.42 sport=57800 dport=44205
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `src` 와 `dst` 를 1-4 와 나란히 놓는다.
|
||||
|
||||
```
|
||||
차단 전: src=10.42.1.77 → dst=10.42.0.42:7800 ← 내가 막은 방향
|
||||
차단 후: src=10.42.0.42 → dst=10.42.1.77:7800 ← 열린 방향으로 다시 붙었다
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **JGroups 는 막힌 연결이 죽자 반대 방향으로 새로
|
||||
연결했다.** 그리고 FD_SOCK2 가 상대를 의심하기 전에 복구가 끝났다.
|
||||
|
||||
**확인** — 의심 카운터로 뒷받침한다
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members'
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
|
||||
```
|
||||
**실측** — [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
|
||||
```
|
||||
keycloak-0 merge_events=1.0 suspected=0.0
|
||||
keycloak-1 merge_events=1.0 suspected=0.0
|
||||
```
|
||||
```
|
||||
keycloak-0 cluster_size=2.0
|
||||
keycloak-1 cluster_size=2.0
|
||||
```
|
||||
|
||||
**`suspected = 0`.** 아무도 상대를 의심하지 않았다 — **끊긴 적이 없는 것과
|
||||
같다.** (`merge_events = 1` 은 5-2 의 9초 전 병합의 것이다.)
|
||||
|
||||
> **한 방향만 막는 것으로는 JGroups 를 가를 수 없다.**
|
||||
> 두 노드는 서로에게 연결을 걸 수 있으므로, **한쪽 길이 막히면 다른 길로 간다.**
|
||||
>
|
||||
> 운영적으로는 좋은 소식이다 — **단방향 방화벽 오설정은 자가 치유된다.**
|
||||
> 반대로 **분단을 재현하려는 실험자에게는 함정**이다.
|
||||
|
||||
---
|
||||
|
||||
# 6. 양방향 차단 — 갈라지지만 전면 장애는 아니다
|
||||
|
||||
## 6-1. 반대 노드에도 넣는다
|
||||
|
||||
**되돌리기** — 두 줄이다. 이제 양쪽에 있다
|
||||
```bash
|
||||
sudo iptables -t raw -F PREROUTING
|
||||
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||
```
|
||||
|
||||
**하기** — `kc-lab-1` 의 규칙은 그대로 두고, `kc-lab-2` 에 반대 방향을 추가
|
||||
```bash
|
||||
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 주입'
|
||||
```
|
||||
|
||||
**확인** — 양쪽 카운터를 다 본다. **한쪽만 걸리면 그건 여전히 단방향이다**
|
||||
```bash
|
||||
sudo iptables -t raw -L PREROUTING -n -v
|
||||
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
|
||||
```
|
||||
|
||||
**실측** — [`07-bidirectional-block.txt`](../../evidence/a5-asymmetric-partition/07-bidirectional-block.txt)
|
||||
```
|
||||
=== 양방향 차단 — 두 노드 모두에 raw DROP ===
|
||||
주입: 12:40:25
|
||||
```
|
||||
|
||||
## 6-2. 이번에는 갈라진다
|
||||
|
||||
**확인** — 25초 간격
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||
-o custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
+75초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
|
||||
+100초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
|
||||
+125초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
|
||||
...
|
||||
+225초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
|
||||
|
||||
- `keycloak-1` 이 **`0/1` 로 내려가서 안 돌아온다** (5-1 과 다르다)
|
||||
- ready 주소가 **둘에서 하나로** 줄었다
|
||||
- **외부는 계속 `200`**
|
||||
|
||||
> `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜬다.
|
||||
> `endpointslice` 를 본다.
|
||||
|
||||
**확인** — 뷰
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
keycloak-0 [keycloak-0-24309(v=16.0.12)|14] (1) [keycloak-0-24309(v=16.0.12)]
|
||||
keycloak-1 [keycloak-1-45480(v=16.0.12)|14] (1) [keycloak-1-45480(v=16.0.12)]
|
||||
```
|
||||
|
||||
**뷰 ID 는 둘 다 14 인데 멤버는 각자 1 명이다.** 같은 번호의 다른 세계다.
|
||||
|
||||
## 6-3. split brain 을 DB 한 줄로 확인한다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, ip, coord from jgroups_ping order by name"
|
||||
```
|
||||
**실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
|
||||
```
|
||||
name | ip | coord
|
||||
------------------+-----------------+-------
|
||||
keycloak-0-24309 | 10.42.1.77:7800 | t
|
||||
keycloak-1-45480 | 10.42.0.42:7800 | t
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`coord = t` 가 둘.** 1-2 에서 하나였던 것과 대조한다.
|
||||
|
||||
**분단을 확인하는 가장 짧은 명령이 이것이다.** 로그를 두 번 긁는 것보다 빠르고,
|
||||
지표보다 정확하다.
|
||||
|
||||
## 6-4. ★ 그런데 한쪽만 DOWN 이다
|
||||
|
||||
Keycloak 컨테이너에 `curl` 이 없으므로 **임시 파드에서 묻는다.**
|
||||
|
||||
**하기** — 상주 파드를 띄운다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --command -- sleep 1800
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s
|
||||
```
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found
|
||||
```
|
||||
|
||||
> **왜 `--rm -it` 짜리 일회용 파드를 안 쓰나.** 원 실행이 그렇게 했다가
|
||||
> 붙지 못했다. — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
|
||||
> ```
|
||||
> warning: couldn't attach to pod/a5-h, falling back to streaming logs: Internal error occurred: error attaching to container: container is in CONTAINER_EXITED state
|
||||
> ```
|
||||
> 파드가 만들어지고 **명령이 끝나 버리기 전에** 붙어야 하는 경주가 된다.
|
||||
> 관찰을 여러 번 반복할 것이라면 **상주 파드가 항상 낫다.**
|
||||
|
||||
**확인** — 양쪽 헬스체크
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready"
|
||||
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready"
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
--- keycloak-0 ---
|
||||
{"status":"UP","checks":[{"name":"GracefulShutdown","status":"UP"}
|
||||
{"name":"KeycloakInitialized","status":"UP"}
|
||||
{"name":"Keycloakclusterhealthcheck","status":"UP"}
|
||||
|
||||
--- keycloak-1 ---
|
||||
{"status":"DOWN","checks":[{"name":"GracefulShutdown","status":"UP"}
|
||||
{"name":"Keycloakdatabaseconnectionsasynchealthcheck","status":"UP"}
|
||||
{"name":"KeycloakInitialized","status":"UP"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 맨 앞의 `"status"`. **`keycloak-0` 은 UP, `keycloak-1`
|
||||
은 DOWN.** 그리고 `keycloak-1` 쪽에서 **DB 체크는 UP** 인 것도 본다 —
|
||||
DB 때문이 아니라 클러스터 때문이다.
|
||||
|
||||
**이 결과가 의미하는 것 — A-1 의 열린 질문에 대한 답이다.**
|
||||
|
||||
| | keycloak-0 | keycloak-1 |
|
||||
|---|---|---|
|
||||
| 분단 전 역할 | **코디네이터** (뷰 13 의 발행자) | 일반 멤버 |
|
||||
| 분단 후 자기 인식 | 「멤버가 하나 나갔다」 — **정상 사건** | 「코디네이터를 잃었다」 — **비정상** |
|
||||
| 헬스체크 | **UP** | **DOWN** |
|
||||
| Service 엔드포인트 | **남는다** | 빠진다 |
|
||||
|
||||
**Keycloak 의 클러스터 헬스체크는 비대칭이다.** 코디네이터였던 쪽은 자기가
|
||||
정상이라고 보고, 잃은 쪽만 DOWN 이 된다. 그래서 **완전 분단조차 용량 저하로
|
||||
끝나고 전면 장애가 되지 않는다.**
|
||||
|
||||
> **A-2(DB 상실)에서는 양쪽이 동시에 DOWN 이었다.** 차이는 이것이다 —
|
||||
> **DB 는 모두가 의존하는 하나지만, 클러스터 멤버십은 서로 상대적이다.**
|
||||
|
||||
Grafana 에서 같은 것을 그림으로 본다 —
|
||||
[`a5-cluster-size-bidirectional-block.png`](../../evidence/a5-asymmetric-partition/a5-cluster-size-bidirectional-block.png).
|
||||
|
||||
---
|
||||
|
||||
# 7. 복구
|
||||
|
||||
## 7-1. 지운다
|
||||
|
||||
**확인** — 지우기 전에 무엇이 있는지 본다
|
||||
```bash
|
||||
sudo iptables -t raw -L PREROUTING -n -v --line-numbers
|
||||
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 해제'
|
||||
sudo iptables -t raw -F PREROUTING
|
||||
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||
```
|
||||
**실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
|
||||
```
|
||||
=== 차단 해제 ===
|
||||
해제: 12:44:37
|
||||
```
|
||||
|
||||
## 7-2. 자동으로 다시 붙는지 본다
|
||||
|
||||
**확인** — 25초 간격
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
+25초 keycloak-0:1/1 keycloak-1:0/1
|
||||
+50초 keycloak-0:1/1 keycloak-1:1/1
|
||||
→ 복구 완료
|
||||
```
|
||||
|
||||
**50초. 사람 개입 없음.**
|
||||
|
||||
## 7-3. 누가 붙였나 — `MergeView`
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep MergeView | tail -1
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 | grep MergeView | tail -1
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
keycloak-0 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
|
||||
keycloak-1 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 뷰 ID 가 **15**, 멤버 `(2)`, **양쪽이 같은 줄.**
|
||||
|
||||
```
|
||||
[keycloak-0-24309|13] (2) ← 정상
|
||||
[keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행
|
||||
MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다
|
||||
```
|
||||
|
||||
**뷰 ID 는 단조 증가**하므로 「언제 몇 번 갈라졌는지」를 로그만으로 셀 수 있다.
|
||||
|
||||
## 7-4. 캐시별 재분배 로그
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6
|
||||
```
|
||||
**실측** — 해설 문서 4절 ·
|
||||
원문은 [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
|
||||
```
|
||||
[Context=work] ISPN100007: After merge (or coordinator change) ...
|
||||
[Context=clientSessions] ISPN100007: After merge ...
|
||||
[Context=offlineSessions] ISPN100007: After merge ...
|
||||
[Context=loginFailures] ISPN100007: After merge ...
|
||||
[Context=actionTokens] ISPN100007: After merge ...
|
||||
```
|
||||
|
||||
증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 형태도 한 번 본다.
|
||||
```
|
||||
2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo
|
||||
```
|
||||
|
||||
**`ISPN100007` 은 병합(또는 코디네이터 변경) 후 캐시별 토폴로지 재계산**이다.
|
||||
**캐시가 여럿이므로 로그도 캐시 수만큼 나온다.** 한 줄만 보고 「한 번
|
||||
재분배됐다」고 세면 틀린다 — `work`·`clientSessions`·`offlineSessions`·
|
||||
`loginFailures`·`actionTokens` 가 각각 찍는다.
|
||||
|
||||
## 7-5. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| raw 규칙 | `sudo iptables -t raw -S PREROUTING` | `-P PREROUTING ACCEPT` 만 |
|
||||
| filter 규칙 | `sudo iptables -S FORWARD \| head -5` | 내가 넣은 `DROP` 이 없음 |
|
||||
| (반대 노드) | `ssh kc-lab-2 'sudo iptables -t raw -S PREROUTING'` | 같음 |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | `keycloak` 둘 다 `1/1 Running` |
|
||||
| 디스커버리 | 6-3 의 psql | **`coord = t` 가 하나** |
|
||||
| 뷰 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 |
|
||||
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a5-probe` | 지웠으면 `NotFound` |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
conntrack 은 건드리지 않아도 된다. **차단이 풀리면 새 연결이 스스로 성립한다.**
|
||||
|
||||
> **이 실험이 재지 않은 것** — 분단 중에 **세션이 어떻게 되는지**는 재지 않았다
|
||||
> (그건 A-1 4-5 의 주제다). 여기서는 **누가 살아남는가**만 봤다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 규칙을 넣었는데 아무 일도 없다 | **카운터가 0 이면 아무것도 측정 안 된 것** | `iptables -L -n -v` 의 `pkts` — 4-2 |
|
||||
| 내 규칙이 1번이 아니다 | **kube-router 가 자기 체인을 재삽입한다** | `--line-numbers` 로 순서 — 2-2 |
|
||||
| `raw` 인데도 0 패킷 | **연결 방향을 잘못 짚었다** | `conntrack -L \| grep 7800` — 3-3 |
|
||||
| conntrack 에 아무것도 안 보인다 | 반대 노드에서 봤다 | **두 노드 모두에서** 본다 — 1-4 |
|
||||
| 단방향인데 안 갈라진다 | **정상이다. 열린 방향으로 재연결한다** | `conntrack` 의 `src`/`dst` 뒤집힘 — 5-3 |
|
||||
| 로그에 변화가 없어 보인다 | **컨테이너 로그는 UTC.** KST 와 9시간 차 | `logs` 의 시각에서 9를 뺀다 — 5-2 |
|
||||
| 「주입 전부터 그대로」인데 미심쩍다 | 그 「전」이 9초일 수 있다 | 앞 뷰가 **언제** 생겼는지 본다 — 5-2 |
|
||||
| `-D` 로 규칙이 안 지워진다 | 넣을 때와 인자가 다르다 | 줄 번호로 지운다: `-D FORWARD 3` |
|
||||
| 임시 파드에 attach 실패 | `--rm -it` 는 경주가 된다 | **상주 파드**를 쓴다 — 6-4 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **이미지에 curl 도 wget 도 없다** | 탐침 파드나 Prometheus |
|
||||
| 지표를 파이썬으로 자르다 죽었다 | **원본까지 같이 사라진다** | `wget` 원문을 먼저 본다 — 1-3 |
|
||||
| `get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||
| 57800 카운터만 0 이다 | FD_SOCK2 가 아직 재연결을 안 했다 | 조금 기다렸다 다시 본다 — 4-2 |
|
||||
| 해제했는데 2~3분째 안 붙는다 | 반대 노드 규칙이 남아 있다 | **두 노드 모두** `-t raw -S PREROUTING` |
|
||||
|
||||
---
|
||||
|
||||
# 실험자를 위한 한 장 요약
|
||||
|
||||
| 상황 | 확인 방법 |
|
||||
|---|---|
|
||||
| 규칙을 넣었는데 안 걸린다 | `iptables -L -n -v` 의 **패킷 카운터** |
|
||||
| 방향을 모르겠다 | `sudo conntrack -L 2>/dev/null \| grep 7800` — `dport` 쪽이 서버 |
|
||||
| CNI 가 밀어낸다 | **`raw` 테이블**을 쓴다 |
|
||||
| 갈라졌는지 알고 싶다 | `JGROUPS_PING.coord`, `ISPN000094`, `vendor_cluster_size` |
|
||||
| 누가 살아남을지 알고 싶다 | **분단 전 코디네이터가 누구였는가** |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-5 가 남긴 질문 |
|
||||
|---|---|
|
||||
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | **`tc netem` 도 똑같은 함정.** 인터페이스를 잘못 고르면 카운터가 0 이다 |
|
||||
| [A-7](../../experiment-a7-volatile-comparison.md) volatile 비교 | 여기서는 분단에도 서비스가 계속됐다. volatile 이면 **세션이 갈라진다** |
|
||||
| [A-1](a1-jgroups-transport-block.md) 로 되돌아가서 | 분단 중 **로그아웃이 전파되지 않는다.** 그 상태를 여기서 다시 만들 수 있다 |
|
||||
| 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 |
|
||||
@@ -0,0 +1,926 @@
|
||||
# A-6 재현 가이드 — 끊지 않고 200ms 만 넣어 22초를 만든다
|
||||
|
||||
해설 문서: [`docs/experiment-a6-latency-injection.md`](../../experiment-a6-latency-injection.md) ·
|
||||
증거 원문: [`docs/evidence/a6-latency-injection/`](../../evidence/a6-latency-injection/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| `eth0` 이라는 인터페이스가 **없다**는 것 | `ip -brief link` |
|
||||
| 스크립트가 **「적용완료」를 찍었는데 아무것도 안 걸린 것** | `tc -s qdisc` 카운터 |
|
||||
| `enp1s0` 에서는 **파드 IP 가 안 보이는** 것 | VXLAN 캡슐화 |
|
||||
| 200ms 가 **1,872ms** 가 되는 것 | 두 노드 응답 시간 비교 |
|
||||
| 동시 20건이 **22.2초**까지 계단으로 늘어나는 것 | 상주 탐침이 모은 파일 |
|
||||
| 커넥션 획득에 **20초**를 기다린 요청 | `agroal_blocking_time_max_milliseconds` |
|
||||
| **readiness 프로브가 같은 줄에 서서** 타임아웃되는 것 | `kubectl get events` |
|
||||
| 예측했던 낙관적 락 충돌이 **0건**인 것 | Keycloak 로그 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- [`A-5`](a5-asymmetric-partition.md) 를 먼저 해 두면 좋다. **「주입을 넣은 것과
|
||||
걸린 것은 다르다」가 여기서 세 번째로 나온다.**
|
||||
- `kubectl` 은 **`kc-lab-1` 에서 `sudo`** 로 친다.
|
||||
- `tc` 는 **`kc-lab-2` 에서** 친다(`ssh kc-lab-2`). postgres 가 그 노드에 있다.
|
||||
- 터미널 두 개면 편하다. 하나는 부하·측정, 하나는 이벤트 관찰.
|
||||
|
||||
## 주의 — 이건 상태를 부수는 실험이다
|
||||
|
||||
Keycloak 한 대를 **느려지게** 만든다. 파드가 재시작될 수 있고 readiness 가
|
||||
빠진다. **실험대에서만 한다.** 전 구간 약 30분이다.
|
||||
|
||||
중간에 그만두려면 한 줄이면 된다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||
```
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 13:10–13:35 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
IP·파드 이름·인터페이스 이름은 **당신 환경에서 다를 수 있다.** 자리표시자
|
||||
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
A-2 는 DB 를 **완전히** 세웠고, A-4 는 기계를 **통째로** 껐다. 둘 다 즉시
|
||||
드러났다. `503` 이 나오고 `up` 이 0 이 됐다.
|
||||
|
||||
**실제 장애의 대부분은 그렇지 않다. 느려지기만 한다.** 그리고 느려짐은
|
||||
사망보다 **진단하기 어렵다 — 헬스체크가 통과하기 때문이다.**
|
||||
|
||||
이 실험이 묻는 것은 하나다.
|
||||
|
||||
```
|
||||
200밀리초를 넣으면 애플리케이션은 200밀리초 느려지는가?
|
||||
```
|
||||
|
||||
답은 **아니다.** 두 군데에서 곱해진다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 설계 — 왜 이 배치가 그대로 A/B 실험이 되는가
|
||||
|
||||
## 1-1. 무엇이 어느 노드에 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
|
||||
```
|
||||
postgres 10.42.1.76 (kc-lab-2)
|
||||
keycloak-0 10.42.1.77 (kc-lab-2) → DB 와 같은 노드, cni0 로 직행
|
||||
keycloak-1 10.42.0.42 (kc-lab-1) → DB 와 다른 노드, VXLAN 을 건넌다 ← 여기에 지연을 건다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **postgres 와 `keycloak-0` 이 같은 노드**인가.
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
kc-lab-2 kc-lab-1
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ postgres │ │ keycloak-1 │
|
||||
│ keycloak-0 │ │ │
|
||||
│ └─ cni0 로 직행 │◀─ VXLAN ──▶│ └─ 오버레이 경유 │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
지연 없음 여기만 느려진다
|
||||
```
|
||||
|
||||
**postgres 가 보내는 패킷 중 노드를 건너가는 것만** 지연시키면
|
||||
`keycloak-1` 의 DB 접근만 느려지고 `keycloak-0` 은 그대로다.
|
||||
**대조군이 같은 실험 안에 있다.** 파드를 두 개 더 띄울 필요도, 다른 시간대와
|
||||
비교할 필요도 없다.
|
||||
|
||||
> **배치가 다르면 이 실험은 성립하지 않는다.** 두 Keycloak 이 모두 DB 와 다른
|
||||
> 노드에 있으면 대조군이 없고, 모두 같은 노드에 있으면 시험군이 없다.
|
||||
> 먼저 확인한다.
|
||||
|
||||
변수로 잡아 둔다.
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||
PG=$(sudo kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].status.podIP}')
|
||||
echo "K0=$K0 K1=$K1 PG=$PG"
|
||||
```
|
||||
|
||||
## 1-2. 상주 탐침 파드를 먼저 띄운다
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 그리고 이 실험은
|
||||
**같은 요청을 수십 번 반복**해야 하므로 파드를 매번 만들면 안 된다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="K0=$K0" --env="K1=$K1" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 1800
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
|
||||
```
|
||||
|
||||
**확인** — 값이 들어갔나. **비밀번호는 길이만 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW=${#PW}자"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
K0=10.42.1.77 K1=10.42.0.42 PW=32자
|
||||
```
|
||||
|
||||
`PW=0자` 면 시크릿이 안 넘어간 것이다. 그 상태로 재면 **전부 401 을 재게 된다.**
|
||||
|
||||
> **★ `kubectl run --rm -i` 로 부하를 주면 안 된다.**
|
||||
> 원 실행이 그렇게 했다가 **동시 20건의 출력을 잃었다.** 파드가 만들어지고
|
||||
> 지워지는 사이에 stdout 을 붙잡는 경주가 되고, 20줄 중 일부만 도착하거나
|
||||
> 아예 끊긴다. **결과는 파드 안 파일에 모으고 끝나면 한 번에 꺼낸다.**
|
||||
> 이 가이드의 모든 부하 명령이 그 형태다.
|
||||
|
||||
> **탐침의 `K0`/`K1` 은 만들 때 고정된다.** Keycloak 파드가 재시작되면 IP 가
|
||||
> 바뀌고 탐침의 값은 낡는다. 그때는 탐침을 지우고 다시 만든다.
|
||||
> 이걸 놓치면 **「아무 데도 안 닿음」을 「지연」으로 착각한다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 기준선 — 주입 전에 같은 명령으로 먼저 잰다
|
||||
|
||||
## 2-1. 요청 하나를 눈으로 본다
|
||||
|
||||
먼저 **읽는 형태**로 한 번 친다. 시간이 어디서 드는지 봐야 나중에 무엇이
|
||||
변했는지 안다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||
curl -s -o /dev/null \
|
||||
-w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \
|
||||
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
connect 0.001 ttfb 0.065 total 0.066
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `connect` 와 `ttfb` 의 차이.
|
||||
|
||||
| 값 | 무엇의 시간인가 |
|
||||
|---|---|
|
||||
| `time_connect` | 탐침 → Keycloak **TCP 연결**. 이 실험에서 **거의 안 변한다** |
|
||||
| `time_starttransfer` | 첫 바이트까지 = **Keycloak 이 DB 와 대화한 시간**. 여기가 폭발한다 |
|
||||
|
||||
**이 결과가 의미하는 것** — 지연은 **탐침과 Keycloak 사이**가 아니라
|
||||
**Keycloak 과 DB 사이**에 넣는다. 그래서 `connect` 는 그대로고 `ttfb` 만 는다.
|
||||
주입 후에 이 두 값을 다시 보면 **어디에 지연이 걸렸는지 한눈에 판정된다.**
|
||||
|
||||
응답이 `401` 이나 `400` 이면 `-o /dev/null` 을 빼고 본문을 본다.
|
||||
|
||||
## 2-2. 반복해서 평균을 낸다
|
||||
|
||||
**확인** — 20회, 원본을 파일에 모은다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||
rm -f /tmp/base-k1 ; i=0
|
||||
while [ $i -lt 20 ]; do
|
||||
curl -s -o /dev/null -w "%{time_total}\n" \
|
||||
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
|
||||
>> /tmp/base-k1
|
||||
i=$((i+1))
|
||||
done'
|
||||
```
|
||||
**원본을 먼저 본다.** 평균만 보면 한 건이 튄 것을 놓친다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1
|
||||
```
|
||||
그 다음 줄여서 본다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1 \
|
||||
| awk '{s+=$1} END {printf "%d회 평균 %.0f ms\n", NR, s*1000/NR}'
|
||||
```
|
||||
|
||||
`$K1` 을 `$K0` 로 바꿔 **대조군도 똑같이** 잰다.
|
||||
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
|
||||
```
|
||||
=== 기준선 지연 — 각 노드에서 로그인 20회 ===
|
||||
keycloak-0 평균 70 ms
|
||||
keycloak-1 평균 66 ms
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 값이 **비슷한 것**. 지금 `keycloak-1` 이 오히려
|
||||
4ms 빠르다. **VXLAN 을 건너는 쪽이 더 빠를 수도 있는 수준의 차이**이며,
|
||||
그래서 뒤에 나올 28배가 의심의 여지 없이 주입 탓이 된다.
|
||||
|
||||
> **횟수를 주입 전후로 똑같이 맞춘다.** 기준선은 20회로 쟀고, 해설 문서의
|
||||
> 재현 절차에는 15회로 적혀 있다. 횟수가 다르면 평균도 달라진다.
|
||||
> **비교할 두 값은 같은 명령으로 만든다.**
|
||||
|
||||
## 2-3. 커넥션 풀 지표에 무엇이 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c \
|
||||
'curl -s "http://$K1:9000/metrics" | grep "^agroal_"'
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
|
||||
```
|
||||
agroal_acquire_count_total
|
||||
agroal_active_count
|
||||
agroal_available_count
|
||||
agroal_awaiting_count
|
||||
agroal_blocking_time_average_milliseconds
|
||||
agroal_blocking_time_max_milliseconds
|
||||
agroal_blocking_time_total_milliseconds
|
||||
agroal_creation_count_total
|
||||
agroal_creation_time_average_milliseconds
|
||||
agroal_creation_time_max_milliseconds
|
||||
agroal_creation_time_total_milliseconds
|
||||
agroal_destroy_count_total
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `agroal_*` 이 **JDBC 커넥션 풀** 지표다
|
||||
(Agroal 은 Quarkus 의 풀 구현이다). 이 실험의 핵심 증거가 여기서 나온다.
|
||||
|
||||
> **당신 출력은 이보다 길 것이다.** 위 목록은 알파벳순으로 `destroy_count_total`
|
||||
> 에서 끊겨 있다 — 원 실행이 앞부분만 남긴 것이다. 실제로는 뒤에
|
||||
> `agroal_max_used_count` 같은 것이 더 있고, 6-4 에서 그 값을 쓴다.
|
||||
> **증거 파일이 짧다고 지표가 없는 것이 아니다.**
|
||||
|
||||
| 지표 | 무엇을 말하는가 |
|
||||
|---|---|
|
||||
| `blocking_time_max` | **커넥션을 받으려고 가장 오래 기다린 시간** |
|
||||
| `max_used_count` | 풀이 최대 몇 개까지 늘었나 |
|
||||
| `awaiting_count` | **지금** 줄 서 있는 요청 수 |
|
||||
| `active_count` | **지금** 쓰이고 있는 커넥션 수 |
|
||||
|
||||
**`awaiting_count` 와 `active_count` 는 순간값이다.** 부하가 끝나면 0 으로
|
||||
돌아간다 — **부하 중에 읽어야 보인다.** `blocking_time_max` 는 누적이라
|
||||
나중에 읽어도 남아 있다.
|
||||
|
||||
지금 값을 적어 둔다. 나중에 오른 것을 보려면 지금 값이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입 시도 ① — `eth0` (그런 인터페이스가 없다)
|
||||
|
||||
**일부러 실패하는 단계다.** 이 실패의 모양이 이 실험이 남긴 가장 실용적인
|
||||
교훈이다.
|
||||
|
||||
## 3-1. 넣어 본다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc qdisc add dev eth0 root handle 1: prio'
|
||||
```
|
||||
**실측** — [`02-delay-injected.txt`](../../evidence/a6-latency-injection/02-delay-injected.txt)
|
||||
```
|
||||
Cannot find device "eth0"
|
||||
```
|
||||
|
||||
한 줄이면 끝날 일이다. **그런데 원 실행은 이걸 스크립트로 돌렸다.**
|
||||
|
||||
**실측** — 같은 파일, 원문 그대로
|
||||
```
|
||||
=== 주입: postgres(10.42.1.76) 가 보내는 패킷만 200ms 지연 (kc-lab-2 eth0) ===
|
||||
prio qdisc 로 밴드를 나누고, u32 필터로 출발지 IP 가 postgres 인 것만 3번 밴드로 보낸다
|
||||
Cannot find device "eth0"
|
||||
Cannot find device "eth0"
|
||||
적용완료
|
||||
Cannot find device "eth0"
|
||||
Cannot find device "eth0"
|
||||
주입: 13:14:55
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`적용완료` 가 에러 사이에 끼어 있다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **「적용완료」는 스크립트가 찍은 글자이지 커널이
|
||||
한 말이 아니다.** `tc` 는 네 번 다 실패했는데 스크립트는 그대로 다음 절로
|
||||
넘어갔고, 문서에는 시각까지 찍혔다.
|
||||
|
||||
> **명령의 성공을 「에러가 안 보인다」로 판정하면 안 된다.**
|
||||
> 에러는 보였는데 그 사이에 성공 메시지가 있었을 뿐이다.
|
||||
> 손으로 한 줄씩 치면 이 실수를 할 수 없다 — **이 가이드에 스크립트가 없는
|
||||
> 이유다.**
|
||||
|
||||
그리고 그 상태에서 잰 「검증」이 이랬다.
|
||||
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
=== [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 ===
|
||||
keycloak-0 평균 43 ms 최대 64 ms
|
||||
keycloak-1 평균 47 ms 최대 70 ms
|
||||
```
|
||||
|
||||
**두 노드가 여전히 같다. 이것이 「안 걸렸다」는 신호였다.** 검증 절이 값을
|
||||
찍기만 하고 **판정하지 않으면** 이렇게 그냥 지나간다.
|
||||
|
||||
## 3-2. 인터페이스 이름을 확인한다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ssh kc-lab-2 'ip -brief link'
|
||||
```
|
||||
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt)
|
||||
```
|
||||
flannel.1 UNKNOWN a6:b2:62:04:c1:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
|
||||
cni0 UP 5a:77:1a:e2:b0:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
|
||||
```
|
||||
게스트의 물리 인터페이스는 `enp1s0` 이다.
|
||||
|
||||
**어디를 봐야 하는가** — **`eth0` 이 없다.**
|
||||
|
||||
| 이름 | 무엇 |
|
||||
|---|---|
|
||||
| `enp1s0` | **게스트의 물리(가상) NIC.** 노드 간 실제 트래픽이 나가는 곳 |
|
||||
| `flannel.1` | **VXLAN 터널.** 노드를 건너는 파드 트래픽이 여기로 들어간다 |
|
||||
| `cni0` | **노드 안 브리지.** 같은 노드 파드끼리는 여기서 끝난다 |
|
||||
|
||||
Debian 클라우드 이미지는 **예측 가능한 인터페이스 이름**을 쓴다.
|
||||
|
||||
```
|
||||
enp1s0
|
||||
│ │ └─ s0 : slot 0
|
||||
│ └──── p1 : PCI bus 1
|
||||
└────── en : ethernet
|
||||
```
|
||||
|
||||
이름이 **하드웨어 위치에서** 나오므로 NIC 순서가 바뀌어도 이름이 안 바뀐다.
|
||||
그 대신 `eth0` 이라고 적힌 인터넷의 모든 예제가 안 돈다.
|
||||
|
||||
> `flannel.1` 의 상태가 `UNKNOWN` 인 것은 정상이다. 터널 장치는 캐리어 개념이
|
||||
> 없어서 `UP` 대신 `UNKNOWN` 으로 보고한다. **고장이 아니다.**
|
||||
|
||||
---
|
||||
|
||||
# 4. 주입 시도 ② — `enp1s0` (파드 IP 가 안 보인다)
|
||||
|
||||
`eth0` 을 `enp1s0` 으로 고치면 될 것 같다. **안 된다.** 이유가 이 실험의 핵심
|
||||
개념이다.
|
||||
|
||||
## 4-1. 무엇이 문제인가
|
||||
|
||||
노드 간 파드 통신은 **flannel VXLAN 으로 캡슐화**된다.
|
||||
|
||||
```
|
||||
원래 패킷: src=10.42.1.76(postgres) dst=10.42.0.42(keycloak-1)
|
||||
│
|
||||
▼ flannel.1 에서 캡슐화
|
||||
실제 패킷: src=192.168.122.12(노드) dst=192.168.122.11(노드) UDP 8472
|
||||
└─ 안쪽에 원래 패킷이 통째로 들어 있다
|
||||
│
|
||||
▼
|
||||
enp1s0 로 나간다
|
||||
```
|
||||
|
||||
**`enp1s0` 에서 `match ip src 10.42.1.76` 은 절대 일치하지 않는다.**
|
||||
그 IP 는 페이로드 안에 있고, 헤더에는 노드 IP 만 있다.
|
||||
|
||||
## 4-2. 눈으로 확인한다
|
||||
|
||||
**확인** — 실제로 무엇이 나가는지 본다. **미검증**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tcpdump -i enp1s0 -n -c 5 udp port 8472'
|
||||
```
|
||||
노드 IP 사이의 UDP 8472 만 보이고 `10.42.x.x` 는 안 보인다.
|
||||
|
||||
같은 시간에 터널 쪽을 보면 파드 IP 가 보인다. **미검증**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tcpdump -i flannel.1 -n -c 5 host 10.42.1.76'
|
||||
```
|
||||
|
||||
> **원 실행에는 이 확인이 없다.** `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔다.
|
||||
> 그래서 「`enp1s0` 에 걸면 0 패킷」이라는 **출력 원문은 이 실험에 없다** —
|
||||
> 구조에서 나온 결론이다. 당신이 직접 보고 싶으면 위 `tcpdump` 두 줄이면 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — **오버레이 네트워크에서는 「어느 인터페이스에
|
||||
거는가」가 「무엇을 볼 수 있는가」를 정한다.**
|
||||
|
||||
| 인터페이스 | 파드 IP 가 보이나 | 무엇을 지연시키게 되나 |
|
||||
|---|---|---|
|
||||
| `cni0` | 보인다 | **같은 노드 안** 통신만 |
|
||||
| **`flannel.1`** | **보인다 (캡슐화 직전)** | **노드를 건너는** 파드 통신 |
|
||||
| `enp1s0` | **안 보인다** | 노드 간 **모든** 것 (SSH·k3s 포함) |
|
||||
|
||||
`enp1s0` 에 `netem` 을 root 로 걸면 **`kubectl` 도 SSH 도 같이 느려진다.**
|
||||
그러면 무엇이 원인인지 못 가린다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 주입 성공 — `flannel.1` 에 건다
|
||||
|
||||
## 5-1. 거는 순서
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다. 이 한 줄이 세 가지를 다 지운다
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 root handle 1: prio"
|
||||
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 parent 1:3 handle 30: netem delay 200ms"
|
||||
ssh kc-lab-2 "sudo tc filter add dev flannel.1 protocol ip parent 1:0 prio 3 \
|
||||
u32 match ip src $PG/32 flowid 1:3"
|
||||
date '+%H:%M:%S 주입'
|
||||
```
|
||||
|
||||
**한 줄씩 친다.** 앞 줄이 실패하면 뒤 줄은 붙을 곳이 없어서 다른 에러를 낸다.
|
||||
|
||||
## 5-2. 개념 — `tc` 의 계층 구조
|
||||
|
||||
```
|
||||
qdisc (큐 규율) 인터페이스에 붙는 패킷 스케줄러
|
||||
├─ prio 우선순위 밴드 3개로 나눈다
|
||||
│ ├─ 1:1 (기본)
|
||||
│ ├─ 1:2 (기본)
|
||||
│ └─ 1:3 ← 여기에 netem 을 붙인다
|
||||
└─ filter 어떤 패킷을 어느 밴드로 보낼지
|
||||
```
|
||||
|
||||
**`netem` 을 root 에 바로 붙이면 모든 트래픽이 느려진다.**
|
||||
`prio` + `filter` 를 쓰면 **고른 트래픽만** 느려진다. 이 실험은
|
||||
**postgres 가 보내는 것만** 골라야 하므로 세 단계가 필요하다.
|
||||
|
||||
세 줄이 하는 일을 나눠 읽으면 이렇다.
|
||||
|
||||
| 줄 | 하는 일 |
|
||||
|---|---|
|
||||
| `qdisc ... root handle 1: prio` | 밴드 3개짜리 분류기를 만든다 |
|
||||
| `qdisc ... parent 1:3 handle 30: netem delay 200ms` | 3번 밴드에 **200ms 지연**을 붙인다 |
|
||||
| `filter ... match ip src $PG/32 flowid 1:3` | **출발지가 postgres 인 패킷**을 3번 밴드로 보낸다 |
|
||||
|
||||
## 5-3. ★ 걸렸는지 카운터로 확인한다 — 그리고 0 을 오해하지 않는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1'
|
||||
```
|
||||
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt) · **넣은 직후**
|
||||
```
|
||||
qdisc prio 1: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
|
||||
Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
|
||||
backlog 0b 0p requeues 0
|
||||
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
|
||||
Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
|
||||
backlog 0b 0p requeues 0
|
||||
```
|
||||
|
||||
**`Sent 0 pkt` 이다. 그런데 이건 실패가 아니다.**
|
||||
|
||||
A-5 에서 `pkts 0` 은 「규칙이 안 걸렸다」였다. **여기서는 다르다** —
|
||||
아직 **아무 패킷도 지나가지 않았을 뿐**이다. postgres 는 요청이 있어야 답한다.
|
||||
|
||||
**하기** — 트래픽을 한 번 만든다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||
curl -s -o /dev/null -w "%{time_total}\n" \
|
||||
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
|
||||
```
|
||||
|
||||
**확인** — 다시 센다
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
=== [검증] 필터에 패킷이 걸리는가 ===
|
||||
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
|
||||
Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)
|
||||
backlog 0b 0p requeues 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`150 pkt`.** 실제로 지연 밴드를 통과했다.
|
||||
|
||||
> **판정표 — `netem` 의 `Sent`**
|
||||
>
|
||||
> | 상태 | 뜻 | 할 일 |
|
||||
> |---|---|---|
|
||||
> | 부하 전 `0 pkt` | 아직 트래픽이 없다 | 요청을 한 번 보내고 다시 센다 |
|
||||
> | **부하 후에도 `0 pkt`** | **필터가 아무것도 못 잡았다** | IP·인터페이스·방향을 다시 본다 |
|
||||
> | `pkt` 이 는다 | 걸렸다 | 관찰로 넘어간다 |
|
||||
> | `dropped` 가 는다 | `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
|
||||
|
||||
**A-1·A-5 와 같은 교훈이 세 번째로 나왔다. 주입을 넣은 것과 걸린 것은 다르다.**
|
||||
|
||||
필터 자체도 볼 수 있다. **미검증**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc filter show dev flannel.1'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. 효과를 관찰한다
|
||||
|
||||
## 6-1. 단일 요청 — 지연은 곱해진다
|
||||
|
||||
**확인** — 2-1 과 **똑같은 명령**을 다시 친다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||
curl -s -o /dev/null \
|
||||
-w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \
|
||||
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
|
||||
```
|
||||
|
||||
**`connect` 는 그대로인데 `ttfb` 만 폭발**하는지 본다. 그러면 지연이
|
||||
**의도한 구간에** 걸린 것이다.
|
||||
|
||||
그 다음 2-2 와 똑같이 반복해서 두 노드를 잰다.
|
||||
|
||||
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt)
|
||||
```
|
||||
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
|
||||
keycloak-0 평균 41 ms 최대 57 ms
|
||||
keycloak-1 평균 1872 ms 최대 1887 ms
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `keycloak-1` 이 **66 → 1,872 ms, 28배.**
|
||||
|
||||
> **★ 대조군도 변했다.** `keycloak-0` 은 기준선 70ms 에서 41ms 로 **41%
|
||||
> 빨라졌다.** 주입과 무관한 변동(JIT 워밍업, 캐시)이며, 해설 문서가 처음에
|
||||
> 「영향 없음」이라고 쓴 것은 **부정확했다.** 자릿수가 달라 결론은 유지되지만,
|
||||
> **대조군이 안 변한다고 가정하면 안 된다.** 당신 실행에서도 볼 것이다.
|
||||
|
||||
## 6-2. 왜 200ms 가 1,872ms 가 되는가
|
||||
|
||||
A-0 에서 잡은 로그인 트랜잭션의 SQL 이 답이다.
|
||||
|
||||
```
|
||||
BEGIN
|
||||
select ... from OFFLINE_USER_SESSION ...
|
||||
select VERSION ... for no key update skip locked
|
||||
select ... from OFFLINE_CLIENT_SESSION ...
|
||||
select VERSION ... for no key update skip locked
|
||||
insert into OFFLINE_USER_SESSION ...
|
||||
insert into OFFLINE_CLIENT_SESSION ...
|
||||
SET LOCAL synchronous_commit TO OFF
|
||||
COMMIT
|
||||
```
|
||||
|
||||
**왕복이 아홉 번이다.**
|
||||
|
||||
```
|
||||
200 ms × 9 왕복 ≈ 1,800 ms 실측 1,872 ms
|
||||
```
|
||||
|
||||
> **★ `9` 는 SQL 목록을 센 것이고 패킷을 추적한 값이 아니다.** 자릿수가 맞는다는
|
||||
> 것까지가 이 계산이 말할 수 있는 범위이며, **왕복 수를 확정하려면 `tc -s` 의
|
||||
> 패킷 수를 요청 수로 나누거나 패킷 캡처가 필요하다.**
|
||||
|
||||
> **네트워크 지연은 왕복 횟수만큼 증폭된다.**
|
||||
> 「DB 가 200ms 느려졌다」는 「애플리케이션이 200ms 느려졌다」가 아니다.
|
||||
> **쿼리 수를 줄이는 것이 지연 환경에서 결정적인 이유**가 이것이다.
|
||||
|
||||
## 6-3. 동시 부하 — 여기서 진짜 고장이 난다
|
||||
|
||||
**여기가 이 실험의 본 시험이다.** 순차로 20번 돌리면 큐잉이 재현되지 않는다.
|
||||
**동시에** 20건을 보내야 한다.
|
||||
|
||||
**하기** — 백그라운드로 띄우고 `wait`. 결과는 파드 안 파일에 모은다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||
rm -f /tmp/load ; i=0
|
||||
while [ $i -lt 20 ]; do
|
||||
( curl -s -o /dev/null -w "%{http_code} %{time_total}\n" --max-time 60 \
|
||||
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
|
||||
>> /tmp/load ) &
|
||||
i=$((i+1))
|
||||
done
|
||||
wait'
|
||||
```
|
||||
|
||||
**확인** — 다 모였는지부터 센다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt
|
||||
wc -l /tmp/load.txt
|
||||
```
|
||||
|
||||
**`20` 이 아니면 수집이 샌 것이다.** 그 상태의 숫자는 해석하지 않는다.
|
||||
|
||||
**확인** — 원본을 본다
|
||||
```bash
|
||||
cat /tmp/load.txt
|
||||
```
|
||||
그 다음 상태 코드와 시간을 나눠 본다.
|
||||
```bash
|
||||
awk '{print $1}' /tmp/load.txt | sort | uniq -c
|
||||
awk '{print $2}' /tmp/load.txt | sort -g
|
||||
```
|
||||
|
||||
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
|
||||
```
|
||||
=== 동시 부하 20건을 keycloak-1 에 — 커넥션 풀이 견디는가 ===
|
||||
1 200 1.911191
|
||||
1 200 1.913766
|
||||
1 200 1.958374
|
||||
1 200 1.981620
|
||||
1 200 10.539402
|
||||
1 200 11.951943
|
||||
1 200 13.351102
|
||||
1 200 14.785832
|
||||
1 200 16.189533
|
||||
1 200 17.625166
|
||||
1 200 19.053724
|
||||
1 200 20.495883
|
||||
1 200 21.905932
|
||||
1 200 22.228466
|
||||
1 200 22.230871
|
||||
1 200 3.441366
|
||||
1 200 4.841075
|
||||
1 200 6.257489
|
||||
1 200 7.704608
|
||||
1 200 9.104792
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가 — 두 가지다.**
|
||||
|
||||
**① 순서가 이상하다.** `10.5` 가 `3.4` 보다 앞에 있다. 원 실행이 `sort` 를
|
||||
**사전순**으로 썼기 때문이다(맨 앞의 `1` 은 `uniq -c` 가 붙인 개수다).
|
||||
문자열로 정렬하면 `"10.5" < "3.4"` 다.
|
||||
|
||||
```bash
|
||||
sort /tmp/load.txt # 사전순 — 10.5 가 3.4 앞에 온다
|
||||
sort -g /tmp/load.txt # 수치순 — 이걸 써야 한다
|
||||
```
|
||||
|
||||
**시간 값을 정렬할 때는 `sort -g`.** 이걸 놓치면 「최대값」을 잘못 읽는다.
|
||||
|
||||
**② 숫자를 순서대로 놓으면 계단이다.**
|
||||
|
||||
```
|
||||
1.9 → 3.4 → 4.8 → 6.2 → 7.7 → 9.1 → 10.5 → ... → 22.2
|
||||
──── ──── ──── ────
|
||||
약 1.4초 간격 — 앞 요청이 커넥션을 놓아줄 때까지 줄을 선다
|
||||
```
|
||||
|
||||
**전부 성공(200)했지만 응답 시간이 1.9초에서 22.2초까지 늘어난다.**
|
||||
**전형적인 큐잉이다.** 커넥션 수는 유한하고, 각 요청이 커넥션을 1.9초씩
|
||||
붙잡으므로 뒤에 온 요청은 그만큼 기다린다.
|
||||
|
||||
> **`200` 만 보는 감시는 이 장애를 못 본다.** 상태 코드는 전부 정상이다.
|
||||
> **응답 시간 분포를 봐야 한다.**
|
||||
|
||||
## 6-4. 커넥션 풀 지표가 증언한다
|
||||
|
||||
**부하가 끝나자마자** 읽는다. 늦으면 순간값이 0 으로 돌아간다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c \
|
||||
'curl -s "http://$K1:9000/metrics" | grep -E "^agroal_(blocking_time|max_used|acquire|active|available|awaiting)"'
|
||||
```
|
||||
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
|
||||
```
|
||||
=== 부하 직후 커넥션 풀 ===
|
||||
agroal_blocking_time_max_milliseconds 20000.0
|
||||
agroal_max_used_count 19.0
|
||||
agroal_acquire_count_total 672.0
|
||||
agroal_active_count 0.0
|
||||
agroal_awaiting_count 0.0
|
||||
agroal_blocking_time_average_milliseconds 281.0
|
||||
agroal_available_count 19.0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
| 값 | 읽는 법 |
|
||||
|---|---|
|
||||
| `blocking_time_max 20000.0` | **커넥션을 받으려고 20초를 기다린 요청이 있었다** |
|
||||
| `max_used_count 19.0` | 풀이 19개까지 늘어났다 |
|
||||
| `blocking_time_average 281.0` | 평균은 0.3초. **평균만 보면 아무 일도 없어 보인다** |
|
||||
| `active_count 0.0` · `awaiting_count 0.0` | **순간값. 부하가 끝나서 0 이다** |
|
||||
|
||||
**평균과 최대의 간격이 이 장애의 모양이다.** 평균 281ms 짜리 그래프에서는
|
||||
아무도 20초를 보지 못한다.
|
||||
|
||||
Grafana 에서 같은 것을 그림으로 본다 —
|
||||
[`a6-connection-pool-blocking.png`](../../evidence/a6-latency-injection/a6-connection-pool-blocking.png).
|
||||
|
||||
## 6-5. 그리고 헬스체크가 무너진다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||
sudo kubectl -n keycloak-lab get pods
|
||||
```
|
||||
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
|
||||
```
|
||||
keycloak-0 1/1 Running 0 60m
|
||||
keycloak-1 1/1 Running 1 (51m ago) 3h24m
|
||||
52m Normal TaintManagerEviction pod/keycloak-1 Cancelling deletion of Pod keycloak-lab/keycloak-1
|
||||
32m Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
|
||||
89s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.42:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`89s` 짜리 줄.** 그것이 지금 주입의 결과다.
|
||||
`32m`·`52m` 짜리는 **A-4 의 잔재**다(노드를 껐다 켠 흔적).
|
||||
|
||||
> **이벤트를 볼 때는 `Age` 를 먼저 본다.** 이벤트 목록은 한 시간 전 것까지
|
||||
> 섞여 있다. 방금 일어난 일만 골라야 한다.
|
||||
|
||||
**두 실패의 차이가 중요하다.**
|
||||
|
||||
| 메시지 | 무슨 일 |
|
||||
|---|---|
|
||||
| `HTTP probe failed with statuscode: 503` | Keycloak 이 **답은 했다.** 스스로 DOWN 이라고 말했다 |
|
||||
| **`context deadline exceeded`** | **답 자체를 못 했다.** 프로브가 줄에서 기다리다 끝났다 |
|
||||
|
||||
**readiness 프로브 자체가 타임아웃됐다.** 헬스체크도 같은 커넥션 풀 줄에 선다.
|
||||
|
||||
## 6-6. 연쇄 고장의 모양
|
||||
|
||||
```
|
||||
DB 가 느려진다
|
||||
↓
|
||||
요청이 커넥션을 오래 붙잡는다
|
||||
↓
|
||||
커넥션 풀이 고갈된다
|
||||
↓
|
||||
새 요청이 줄을 선다 (최대 20초)
|
||||
↓
|
||||
헬스체크도 줄에 선다 → 타임아웃 → NotReady
|
||||
↓
|
||||
그 노드가 로드밸런서에서 빠진다
|
||||
↓
|
||||
★ 남은 노드로 트래픽이 몰린다 → 그 노드도 같은 길을 간다
|
||||
```
|
||||
|
||||
**마지막 화살표가 무서운 부분이다. 느려짐은 전파된다.**
|
||||
A-2(DB 완전 정지)는 즉시 503 으로 드러나 오히려 명확했지만,
|
||||
**느려짐은 살아 있는 노드를 하나씩 무너뜨린다.**
|
||||
|
||||
## 6-7. 빗나간 예측 — 낙관적 락 충돌은 늘지 않았다
|
||||
|
||||
계획서에는 이렇게 적혀 있었다.
|
||||
|
||||
> **낙관적 락 충돌 증가** — 트랜잭션이 길어져 `VERSION` 충돌이 늘어야 한다
|
||||
|
||||
**확인** — 지연 구간의 로그를 센다. **미검증** (원 실행의 정확한 패턴은 기록에 없다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m \
|
||||
| grep -icE 'optimistic|StaleState|version.*conflict'
|
||||
```
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a6-latency-injection/05-recovery.txt)
|
||||
```
|
||||
=== 낙관적 락 충돌이 늘었는가 — 지연 중 로그 ===
|
||||
관련 로그 줄수: 0
|
||||
```
|
||||
|
||||
**하나도 없었다.** 이유가 명확하다.
|
||||
|
||||
```
|
||||
로그인 → 매번 새 세션 행을 INSERT → 다툴 상대가 없다
|
||||
refresh → 같은 세션 행을 UPDATE → 여기서 다툰다
|
||||
```
|
||||
|
||||
**충돌은 같은 행을 동시에 고칠 때만 일어난다.** 로그인 부하로는 재현되지
|
||||
않는다. 이건 **B-3(refresh 토큰 경쟁)의 영역**이며, 거기서 지연을 함께 주면
|
||||
충돌률이 올라갈 것이다.
|
||||
|
||||
> 예측을 적어 두지 않았다면 「충돌이 없네」 하고 넘어갔을 것이다.
|
||||
> **빗나간 예측이 다음 실험의 설계를 정해 준다.**
|
||||
|
||||
---
|
||||
|
||||
# 7. 복구
|
||||
|
||||
## 7-1. 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 해제'
|
||||
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||
```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'
|
||||
```
|
||||
**실측** — [`05-recovery.txt`](../../evidence/a6-latency-injection/05-recovery.txt)
|
||||
```
|
||||
=== 지연 해제 ===
|
||||
해제완료
|
||||
qdisc noqueue 0: root refcnt 2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`noqueue`.** `prio` 도 `netem` 도 없다.
|
||||
`root` 를 지우면 그 아래 자식 qdisc 와 filter 가 **같이** 사라진다.
|
||||
|
||||
## 7-2. 즉시 회복하는지 본다
|
||||
|
||||
**확인** — **2-2 의 반복 측정 명령을 그대로 다시 친다.** 그 명령의 첫 줄이
|
||||
`rm -f /tmp/base-k1` 이므로 파일은 새로 만들어진다. 두 노드 다 잰다.
|
||||
|
||||
같은 명령이어야 비교가 된다. 다른 명령으로 잰 값은 기준선과 나란히 놓을 수 없다.
|
||||
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
=== 회복 확인 ===
|
||||
keycloak-0 평균 43 ms
|
||||
keycloak-1 평균 51 ms
|
||||
keycloak-0 1/1 Running 0 61m
|
||||
keycloak-1 1/1 Running 1 (52m ago) 3h24m
|
||||
```
|
||||
|
||||
**파드 재시작 없이 즉시 회복.** `RESTARTS` 가 안 늘었다 — 이 실험은
|
||||
readiness 를 흔들었을 뿐 파드를 죽이지는 않았다. **커넥션 풀도 스스로
|
||||
정상화됐다.**
|
||||
|
||||
## 7-3. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| qdisc | `ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'` | `noqueue` |
|
||||
| (물리 쪽도) | `ssh kc-lab-2 'sudo tc qdisc show dev enp1s0'` | 시도 ① 잔재가 없어야 한다 |
|
||||
| 응답 시간 | 2-2 의 반복 측정 | 기준선과 같은 자릿수 |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
|
||||
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 풀 | `agroal_awaiting_count` · `agroal_active_count` | `0` |
|
||||
| 탐침 파드 | `sudo 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` |
|
||||
|
||||
탐침을 지운다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
|
||||
```
|
||||
|
||||
> `agroal_blocking_time_max_milliseconds` 는 **누적이라 20000 인 채로 남는다.**
|
||||
> 파드를 재시작해야 0 이 된다. **그대로 두는 편이 낫다** — 「이 노드가 한 번
|
||||
> 20초를 기다린 적이 있다」는 기록이다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `Cannot find device "eth0"` | **이 게스트의 NIC 는 `enp1s0` 이다** | `ip -brief link` — 3-2 |
|
||||
| 스크립트가 「적용완료」인데 지연이 없다 | **성공 메시지는 스크립트가 찍은 것** | `tc -s qdisc` 카운터 — 3-1 |
|
||||
| `enp1s0` 에 걸었는데 안 걸린다 | **VXLAN 안에 파드 IP 가 숨어 있다** | `flannel.1` 에 건다 — 4절 |
|
||||
| `Sent 0 pkt` | 부하 **전**이면 정상. 부하 **후**면 필터가 틀렸다 | 요청 한 번 보내고 다시 센다 — 5-3 |
|
||||
| 지연이 양쪽 다 늘었다 | `netem` 을 `root` 에 직접 붙였다 | `prio` + `filter` 로 골라 낸다 — 5-2 |
|
||||
| `kubectl` 이나 SSH 까지 느려졌다 | `enp1s0` 에 걸었다 | `tc qdisc del dev enp1s0 root` |
|
||||
| 20줄 중 몇 줄만 온다 | **`kubectl run --rm -i` 로 동시 실행하면 stdout 이 샌다** | 상주 파드 + 파일 — 1-2 |
|
||||
| 최대값이 `9.1` 로 보인다 | `sort` 가 **사전순**이다 | `sort -g` — 6-3 |
|
||||
| `blocking_time` 이 0 이다 | 부하가 끝나고 한참 뒤에 읽었다 | **부하 직후**에 읽는다 — 6-4 |
|
||||
| `awaiting_count` 가 늘 0 이다 | **순간값이다** | 부하가 도는 **중에** 읽는다 |
|
||||
| 로그인이 전부 `401` | `PW` 가 안 넘어갔다 | `exec a6-probe -- sh -c 'echo ${#PW}'` — 1-2 |
|
||||
| 갑자기 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 다시 만든다 — 1-2 |
|
||||
| 이벤트가 과거 것과 섞인다 | 이벤트는 한 시간 전 것도 남는다 | `Age` 를 먼저 본다 — 6-5 |
|
||||
| 대조군도 값이 변했다 | **정상이다.** JIT·캐시 변동 | 자릿수로 판정한다 — 6-1 |
|
||||
| `dropped` 가 늘어난다 | `netem` 의 `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
|
||||
|
||||
---
|
||||
|
||||
# 이 실험이 남기는 관측 숙제
|
||||
|
||||
**지금 관측 스택에는 지연 분포 지표가 없다.** `agroal_blocking_time_*` 은
|
||||
있지만 히스토그램이 아니라 **평균과 최대뿐**이다. 6-4 에서 본 대로
|
||||
평균 281ms 와 최대 20,000ms 사이에 무엇이 있었는지는 알 수 없다.
|
||||
|
||||
```promql
|
||||
# 있으면 좋았을 것
|
||||
histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m]))
|
||||
```
|
||||
|
||||
| 알게 된 것 | 함의 |
|
||||
|---|---|
|
||||
| 지연은 **왕복 횟수만큼 곱해진다** | DB 지연 대책은 「쿼리 수 줄이기」가 먼저다 |
|
||||
| 커넥션 풀에서 **한 번 더 곱해진다** | 풀 크기와 타임아웃이 장애 반경을 정한다 |
|
||||
| **헬스체크도 줄에 선다** | 프로브 타임아웃이 풀 대기보다 짧아야 격리가 제때 된다 |
|
||||
| 느려짐은 **전파된다** | 노드를 빼면 남은 노드가 더 빨리 무너진다 |
|
||||
| `up` 도 readiness 도 **늦게 반응** | **응답 시간 분포(p95/p99)를 봐야 한다** |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-6 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-3](../../experiment-b3-refresh-token-contention.md) refresh 경쟁 | **지연을 함께 주면 낙관적 락 충돌이 재현될 것** — 여기서는 안 됐다 |
|
||||
| [B-1](../../experiment-b1-redis-session-store.md) 저장소 지연 | **같은 기법을 Redis 앞에 쓴다.** `flannel.1` · `prio` · `filter` 그대로 |
|
||||
| [A-4](a4-node-loss.md) 노드 상실 | 거기서는 `up=0` 이 정확했다. **여기서는 `up=1` 인 채로 무너진다** |
|
||||
| 관측 보완 | **응답 시간 히스토그램**이 없다 |
|
||||
| 전부 | **주입이 걸렸는지 카운터로 먼저 확인한다.** 세 실험 연속으로 같은 교훈 |
|
||||
@@ -0,0 +1,891 @@
|
||||
# A-7a 재현 가이드 — DB 에게 직접 물어서 원인을 확정하고, 같은 설정에서 세 가지 답을 본다
|
||||
|
||||
해설 문서: [`docs/experiment-a7a-volatile-cause.md`](../../experiment-a7a-volatile-cause.md) ·
|
||||
증거 원문: [`docs/evidence/a7a-volatile-cause/`](../../evidence/a7a-volatile-cause/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 로그인이 SQL 을 **0개** 쏘는 것 | PostgreSQL 문장 로그 |
|
||||
| refresh 가 쏘는 **딱 한 문장**의 이름 | 같은 로그 — `CLIENT_SCOPE_CLIENT` |
|
||||
| 그 문장이 **첫 refresh 에만** 나오는 것 | 표식 사이 SQL 0건 |
|
||||
| A-7 이 지목한 `REVOKED_TOKEN` 이 **한 번도 안 나오는 것** | 같은 로그 |
|
||||
| 같은 설정에서 **400 · 500 · 200 셋이 다 나오는 것** | 캐시 온도 세 상태 |
|
||||
| 실패한 SQL 을 Keycloak 로그가 **직접 지목하는 것** | `JDBC exception executing SQL [...]` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다.
|
||||
- **[A-7](a7-volatile-comparison.md) 을 먼저 한다.** 이 실험은 A-7 이 남긴
|
||||
가설을 확정하는 것이고, A-7 의 4-4 에서 본 `500` 이 출발점이다.
|
||||
- [A-3](a3-database-crash.md) 의 문장 로깅을 해 봤으면 3절이 익숙할 것이다.
|
||||
같은 기법이다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 터미널 **두 개**를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.
|
||||
|
||||
## 주의 — 주입이 세 개다. 복구도 세 개다
|
||||
|
||||
1. PostgreSQL **문장 로깅**을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
|
||||
2. Keycloak 을 **volatile** 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
|
||||
3. PostgreSQL 을 **여러 번 내렸다 올린다** → 마지막에 올라와 있어야 한다
|
||||
|
||||
**실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적어
|
||||
두었다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 그대로 친다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 11:18–11:24 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
> **시각이 UTC 다.** 증거 파일의 `11:18:49` 는 KST 로 20:18 이다. 문서 상단의
|
||||
> `20:18–20:24 KST` 와 같은 시각이며, **PostgreSQL 컨테이너가 UTC 로 로그를
|
||||
> 찍기 때문**이다. 로그 시각과 `date` 를 비교할 때 이걸 잊으면 9시간을 헤맨다.
|
||||
|
||||
UUID·IP·파드 이름은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
A-7 은 이렇게 끝났다.
|
||||
|
||||
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는 `REVOKED_TOKEN`
|
||||
> 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지** 확인하려면 그 테이블을
|
||||
> 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||
|
||||
**그럴듯하다. 그리고 틀렸다.**
|
||||
|
||||
```
|
||||
가설을 세우는 것 → 괜찮다
|
||||
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
|
||||
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
|
||||
```
|
||||
|
||||
「refresh 가 어느 테이블 때문에 실패하는가」는 **추측으로 답할 문제가 아니다.**
|
||||
Keycloak 소스를 읽는 대신 **DB 가 실제로 받은 문장**을 보면 된다.
|
||||
|
||||
그리고 확정해 보니 원인만 틀린 게 아니었다. **A-7 의 표 자체가 조건부였다.**
|
||||
같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. **한 번 재고 표로 적으면
|
||||
안 되는 종류의 측정**이었던 것이다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 켜기 전에 지금 상태를 본다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
|
||||
```
|
||||
|
||||
## 1-1. 파드
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
|
||||
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
|
||||
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 셋 다 `Running`. **`postgres` 가 있어야 한다** —
|
||||
이 실험은 그것을 내렸다 올렸다 한다.
|
||||
|
||||
## 1-2. 문장 로깅이 꺼져 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
log_statement
|
||||
---------------
|
||||
none
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `none`.
|
||||
|
||||
**이 결과가 의미하는 것** — 앞 실험이 켜 둔 채 끝내지 않았다. `all` 이면
|
||||
**누가 켜 두었는지 모르는 상태**이고, 그대로 진행하면 지금 쌓인 로그가 어느
|
||||
실험 것인지 구별할 수 없다. 그때는 먼저 끄고, 로그가 한 바퀴 돌 때까지 기다린다.
|
||||
|
||||
## 1-3. 지금 args 가 무엇인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
["start"]
|
||||
```
|
||||
|
||||
**이 값을 적어 둔다.** 5-3 에서 이대로 되돌린다.
|
||||
|
||||
## 1-4. 탐침 파드
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
|
||||
**이 실험은 Keycloak 을 여러 번 재시작하므로 탐침은 반드시 StatefulSet 밖에
|
||||
있어야 한다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="K0=$K0" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
|
||||
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
> **실측** — `19`
|
||||
>
|
||||
> **★ 명령줄에 평문 비밀번호를 쓰지 않는다.** 원래 실험의 재현 절차에는 그대로
|
||||
> 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도 남는다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
K0=10.42.1.94 PW길이=19
|
||||
```
|
||||
|
||||
## 1-5. 로그가 지금 무엇으로 차 있나 — **이걸 알아야 걸러 낼 수 있다**
|
||||
|
||||
**확인** — 로깅을 켜기 전에 한 번 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 조용하다. 여기까지는 에러만 찍힌다.
|
||||
|
||||
**이 결과가 의미하는 것** — 로그가 조용한 것이 기준선이다. 다음 절에서 켜면
|
||||
**JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링**이 로그를 계속 채운다. 그것이
|
||||
소음이고, 4절에서 `grep -v JGROUPS_PING` 으로 거른다. **소음을 먼저 봐 두면
|
||||
거르는 이유를 안다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 두 개를 순서대로 넣는다
|
||||
|
||||
## 2-1. 주입 ① PostgreSQL 문장 로깅
|
||||
|
||||
### 개념 — 문장 로깅은 무엇인가
|
||||
|
||||
**무엇인가.** `log_statement = 'all'` 을 켜면 서버가 받은 **모든 SQL** 을 로그에
|
||||
찍는다. 애플리케이션을 고치지 않고 **「이 요청이 DB 를 어떻게 쓰는지」** 를
|
||||
밖에서 볼 수 있다.
|
||||
|
||||
**왜 여기 나오나.** 「refresh 가 어느 테이블 때문에 실패하는가」를 확정하려면
|
||||
DB 가 실제로 받은 문장을 봐야 한다. Keycloak 안을 들여다볼 필요가 없다.
|
||||
|
||||
**없거나 틀리면.** 여기서 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블
|
||||
이름을 골라 가설로 적게 되고, **그게 틀려도 아무도 모른다.**
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
**확인** — 실제로 켜졌나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
log_statement
|
||||
---------------
|
||||
all
|
||||
```
|
||||
|
||||
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. **`alter system` 은
|
||||
`postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.**
|
||||
|
||||
**확인** — 로그가 실제로 차기 시작했나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=10
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`JGROUPS_PING` 이 계속 나온다.** 1-5 에서 예고한 소음이다.
|
||||
이게 안 보이면 로깅이 안 켜진 것이다.
|
||||
|
||||
## 2-2. 주입 ② volatile 전환
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
|
||||
"value":["start","--features-disabled=persistent-user-sessions"]}]'
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
**빌드 옵션이라 기동 시 재빌드가 일어나 오래 걸린다.** `--timeout=500s` 를 주는
|
||||
이유다.
|
||||
|
||||
**★ 파드 IP 가 바뀌었다.** 탐침 파드를 다시 띄운다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
## 3-1. args 와 동작을 둘 다 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW"'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
volatile 전환 확인
|
||||
args: ["start","--features-disabled=persistent-user-sessions"]
|
||||
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 **행이
|
||||
안 생기는 것**이 volatile 의 증거다.
|
||||
|
||||
> 행 수가 0 이 아니면 옛 행이 남아 있는 것이다. A-7 의 2-1 처럼
|
||||
> `delete from offline_user_session` 을 먼저 하고 다시 잰다.
|
||||
|
||||
## 3-2. 문장 로그가 지금 요청을 잡고 있나
|
||||
|
||||
**확인** — 방금 로그인 직후에 친다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `JGROUPS_PING` 말고 다른 것이 섞여 있는지.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 시점에서는 **거의 `JGROUPS_PING` 뿐일 것**이다.
|
||||
그게 이 실험의 첫 발견인데, 지금은 「내 요청이 어디 있는지 모르겠다」로만 보인다.
|
||||
**구간을 나눠야 볼 수 있다.** 그게 다음 절이다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 표식으로 구간을 나눈다
|
||||
|
||||
로그는 `JGROUPS_PING` 폴링으로 계속 채워진다. 어느 문장이 로그인이고 어느 것이
|
||||
refresh 인지 가르려면 **경계를 찍어야 한다.**
|
||||
|
||||
**개념** — `psql` 로 아무 `select` 나 보내면 **그 문장 자체가 로그에 남는다.**
|
||||
그러면 리터럴 문자열이 로그 안의 이정표가 된다.
|
||||
|
||||
**하기** — 표식 하나를 넣어 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_TEST'"
|
||||
```
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `statement: select 'MARK_TEST'` 가 보이는 것.
|
||||
안 보이면 로깅이 안 켜졌다(2-1 로 돌아간다).
|
||||
|
||||
**이 결과가 의미하는 것** — 이제 **표식과 표식 사이만 잘라 볼 수 있다.**
|
||||
|
||||
## 4-2. 로그인이 무슨 SQL 을 쏘는가
|
||||
|
||||
**하기** — 표식 → 로그인 → 표식. **세 명령을 붙여서 친다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_LOGIN_START'"
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" > /tmp/tok
|
||||
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_LOGIN_END'"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
rt 1188 bytes
|
||||
```
|
||||
|
||||
**★ `1 bytes` 면 파싱이 실패한 것이다.** 그 상태로 4-3 을 하면 빈 토큰을 보내고
|
||||
엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
|
||||
|
||||
**확인** — 표식 사이를 잘라 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
```
|
||||
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||
↑ 사이에 아무것도 없다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 줄뿐이다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **로그인은 SQL 을 0개 쏜다.** realm·사용자·클라이언트가
|
||||
전부 Infinispan 캐시에 있고, volatile 이라 세션 쓰기도 없다. DB 없이 완결된다 —
|
||||
A-7 이 적은 그대로다.
|
||||
|
||||
> `awk '/A/,/B/'` 는 **A 가 나온 줄부터 B 가 나온 줄까지** 출력한다. 로그를 구간으로
|
||||
> 자를 때 이보다 짧게 쓰는 방법은 없다. 파일로 먼저 받는 것은 같은 로그를 여러
|
||||
> 구간으로 반복해서 잘라 볼 것이기 때문이다.
|
||||
|
||||
## 4-3. ★ refresh 는 딱 한 문장을 쏜다 — 그리고 가설이 지목한 것이 아니다
|
||||
|
||||
**하기** — 표식 → refresh → 표식
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_REFRESH_START'"
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||
-d "refresh_token=$(cat /tmp/rt)"'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_REFRESH_END'"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
```
|
||||
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||
11:18:52.137 statement: BEGIN
|
||||
11:18:52.137 execute <unnamed>/C_107:
|
||||
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||
11:18:52.148 execute S_2: COMMIT
|
||||
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지다.
|
||||
|
||||
- **문장이 하나뿐이다.** `BEGIN` / `COMMIT` 사이에 `select` 한 개
|
||||
- 테이블 이름이 **`CLIENT_SCOPE_CLIENT`** 다
|
||||
- `parameters` 줄의 **`$2 = 'f'`**
|
||||
|
||||
**확인** — 가설이 지목한 테이블이 정말 없는지 직접 센다
|
||||
```bash
|
||||
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
|
||||
```
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **A-7 의 가설은 틀렸다.** 그럴듯했지만 로그가
|
||||
아니라고 말한다. 그리고 이제 **로그가 지목하는 문장**이 있다.
|
||||
|
||||
### 개념 — `DEFAULT_SCOPE='f'` 가 무슨 뜻인가
|
||||
|
||||
**무엇인가.** Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
|
||||
|
||||
| | 뜻 | `DEFAULT_SCOPE` |
|
||||
|---|---|---|
|
||||
| default scope | 항상 붙는다 | `t` |
|
||||
| **optional scope** | **요청이 `scope=` 로 달라고 해야 붙는다** | **`f`** |
|
||||
|
||||
**왜 여기 나오나.** refresh 는 **새 access token 을 만든다.** 그 토큰에 어떤
|
||||
스코프를 담을지 정하려면 「이 클라이언트가 요청 가능한 optional 스코프가
|
||||
무엇인가」를 알아야 한다. 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다. **로그인
|
||||
때는 이미 결정된 것을 쓰지만, refresh 는 다시 계산한다.**
|
||||
|
||||
**없거나 틀리면.** 이 조회가 실패하면 토큰을 만들 수 없어 **500** 이다.
|
||||
`400 Session not active` 와 달리 **세션 문제가 아니다** — 그래서 A-7 이 세션 계열
|
||||
테이블(`REVOKED_TOKEN`)을 의심한 것이 자연스러웠지만 틀렸다.
|
||||
|
||||
**확인** — 그 UUID 가 어느 클라이언트인지 궁금하면 물어본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select id, client_id from client where id='131a9912-b578-4b9c-b16a-97518704077e'"
|
||||
```
|
||||
|
||||
**당신 환경에서는 UUID 가 다르다.** 위 로그의 `$1` 값을 그대로 넣는다.
|
||||
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
|
||||
|
||||
## 4-4. 그 조회는 한 번뿐이다 — 여기서 표가 흔들리기 시작한다
|
||||
|
||||
**하기** — refresh 를 연속 3회. 사이사이 표식을 넣는다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_R1'"
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||
-d "refresh_token=$(cat /tmp/rt)" > /tmp/tok
|
||||
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_R2'"
|
||||
```
|
||||
|
||||
**★ 매번 `/tmp/rt` 를 다시 채운다.** refresh token 은 회전한다. 옛 것을 계속 쓰면
|
||||
나오는 오류가 **무효화 때문인지 재사용 때문인지 구별되지 않는다.**
|
||||
|
||||
같은 모양으로 `MARK_R3` · `MARK_R_END` 까지 두 번 더 한다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
```
|
||||
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||
statement: select 'MARK_R1'
|
||||
statement: select 'MARK_R2'
|
||||
statement: select 'MARK_R3'
|
||||
statement: select 'MARK_R_END'
|
||||
↑ SQL 0건
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **표식 네 줄만 있고 그 사이에 아무것도 없다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지
|
||||
않는다.** 그러면 이런 질문이 따라온다.
|
||||
|
||||
> **DB 를 언제 내리느냐에 따라 답이 달라지는 것 아닌가?**
|
||||
|
||||
그렇다. 그게 다음 절이다.
|
||||
|
||||
## 4-5. ★ 같은 설정에서 답이 셋으로 갈린다 — 셋 다 재현한다
|
||||
|
||||
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|
||||
|---|---|---|---|
|
||||
| **완전 냉시동** (재시작 직후) | **400** | 400 | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
|
||||
| **CLIENT 만 더움** ← A-7 이 본 것 | 200 | **500** | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
|
||||
| **완전히 더움** | 200 | **200** | 없음 (SQL 0건) |
|
||||
|
||||
**★ 한 번만 재고 넘어가면 반드시 틀린 표를 쓰게 된다.** A-7 이 그렇게 했다.
|
||||
셋 다 재현해야 한다.
|
||||
|
||||
**되돌리기** — 세 재현 모두 공통이다. 어느 단계에서 멈추든 이것부터
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
```
|
||||
|
||||
### 캐시를 식히는 방법 — **Keycloak 재시작이 유일하다**
|
||||
|
||||
```
|
||||
Infinispan 캐시 = 프로세스 메모리
|
||||
│
|
||||
└─ 파드가 살아 있는 한 안 식는다
|
||||
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
|
||||
```
|
||||
|
||||
**이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다.** 이미 더워진 캐시에서 계속
|
||||
재게 되므로 **A·B 를 재도 C 의 답(200/200)이 나오고**, 「A-7 이 틀렸다」는 엉뚱한
|
||||
결론에 도달한다.
|
||||
|
||||
### 재현 A — 완전 냉시동이면 로그인부터 400
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
```
|
||||
|
||||
**★ 재시작과 DB 정지 사이에 아무 요청도 보내지 않는다.** 한 번이라도 로그인하면
|
||||
캐시가 더워져서 이건 재현 B 가 된다.
|
||||
|
||||
파드 IP 가 바뀌었으므로 탐침을 다시 띄운다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
**확인** — 로그인. **본문까지 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -w "\n%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW"'
|
||||
```
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
로그인 400 {"error":"unauthorized_client",
|
||||
"error_description":"Unexpected error when authenticating client"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `unauthorized_client`. **`invalid_grant` 가 아니다.**
|
||||
세션 문제가 아니라 **클라이언트를 못 찾은 것**이다.
|
||||
|
||||
**확인** — 왜인지는 Keycloak 로그가 직접 말한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
|
||||
authenticating client: org.hibernate.exception.GenericJDBCException:
|
||||
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||
administrator command]
|
||||
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 대괄호가 **두 쌍**이다. 앞은 **DB 가 준 오류**,
|
||||
뒤는 **실패한 SQL 원문**. `grep -oE` 로 그 두 쌍만 뽑는 이유가 이것이다.
|
||||
|
||||
> 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저
|
||||
> 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다.
|
||||
|
||||
**이 결과가 의미하는 것** — **A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었다.
|
||||
냉시동에서는 아니다.** 클라이언트 조회조차 캐시에 없기 때문이다.
|
||||
|
||||
### 재현 B — A-7 이 본 그 조건
|
||||
|
||||
**하기** — DB 를 살리고, 재시작하고, **로그인만 한 번** 하고, DB 를 내린다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
탐침을 새 IP 로 다시 띄운 뒤(재현 A 와 같은 명령), **로그인 한 번만** 한다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" > /tmp/tok
|
||||
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||
```
|
||||
|
||||
**★ 여기서 refresh 를 하면 안 된다.** 하는 순간 재현 C 가 된다.
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
```
|
||||
|
||||
**확인** — 이제 refresh
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -w "\n%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||
-d "refresh_token=$(cat /tmp/rt)"'
|
||||
```
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
로그인 200
|
||||
refresh 500 {"error":"unknown_error"}
|
||||
```
|
||||
|
||||
**확인** — 실패한 SQL
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||
administrator command]
|
||||
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **4-3 에서 본 그 문장이다.** 문장 로깅이 「이 문장을
|
||||
쏜다」를 보여줬고, 여기서는 「이 문장이 실패했다」를 보여준다. **두 개가 만나면
|
||||
가설이 아니라 확정이다.**
|
||||
|
||||
**`500 unknown_error` 인 이유도 이제 안다.** 세션은 멀쩡하다. 토큰을 조립하다가
|
||||
DB 가 없어서 못 만든 것이고, Keycloak 은 그걸 사용자 오류로 분류할 방법이 없어서
|
||||
`unknown_error` 를 준다.
|
||||
|
||||
### 재현 C — 완전히 더우면 둘 다 200
|
||||
|
||||
**하기** — DB 를 살리고, 재시작하고, **refresh 를 3회 미리 돌린 뒤** DB 를 내린다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
탐침을 새 IP 로 다시 띄운 뒤, 로그인 1회 + refresh 3회(4-4 와 같은 형태로
|
||||
`/tmp/rt` 를 매번 갱신하며).
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
```
|
||||
|
||||
**확인** — 로그인과 refresh 를 둘 다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
|
||||
curl -s -o /dev/null -w "refresh %{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||
-d "refresh_token=$(cat /tmp/rt)"'
|
||||
```
|
||||
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||
```
|
||||
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
|
||||
로그인 200
|
||||
refresh 200 ← A-7 의 표와 정반대다
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **같은 설정, 같은 명령, 세 개의 답.** 무엇이 다른지는
|
||||
`kubectl get` 어디에도 안 나온다. **캐시 온도는 보이지 않는 상태다.**
|
||||
|
||||
```
|
||||
volatile + DB 정지의 결과
|
||||
= "무엇을 하느냐"가 아니라
|
||||
"그 경로가 이미 캐시를 채웠느냐"
|
||||
```
|
||||
|
||||
> **A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의
|
||||
> 함정이다.** 상태가 결과를 바꾸는데 그 상태가 안 보인다.
|
||||
|
||||
> **persistent(기본값)에는 해당하지 않는다.** 세션 자체를 DB 에 쓰므로 DB 가
|
||||
> 없으면 캐시 온도와 무관하게 실패한다. **이 조건부성은 volatile 고유의
|
||||
> 성질**이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
**세 개를 순서대로 되돌린다.** 순서가 있다 — DB 가 살아 있어야 나머지가 된다.
|
||||
|
||||
## 5-1. PostgreSQL 을 되살린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
|
||||
```
|
||||
|
||||
## 5-2. ★ 문장 로깅을 끈다 — 잊으면 다음 실험이 전부 오염된다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
log_statement
|
||||
---------------
|
||||
none
|
||||
```
|
||||
|
||||
**왜 급한가** — [A-3](a3-database-crash.md) 은 수백 건의 로그인을 최대한 빨리
|
||||
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
|
||||
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
|
||||
즉 **다음 실험의 측정값이 이 설정 때문에 바뀐다.**
|
||||
|
||||
## 5-3. args 를 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
## 5-4. 정말 persistent 로 돌아왔는지 — 동작으로 확인한다
|
||||
|
||||
**args 문자열만 보고 끝내지 않는다.**
|
||||
|
||||
**하기** — 탐침을 새 IP 로 띄우고 로그인 한 번
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 600
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW"'
|
||||
```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **0 이 아니어야 한다.** 로그인 후 행이 생기면 persistent 다.
|
||||
원래 재현 절차가 마지막에 이 한 줄을 두는 이유가 이것이다.
|
||||
|
||||
## 5-5. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| **문장 로깅** | `psql -c "show log_statement"` | **`none`** |
|
||||
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
|
||||
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
|
||||
| **동작** | 위 5-4 | 로그인 후 세션 행이 **생긴다** |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a7a-probe` | `NotFound` |
|
||||
| 임시 파일 | `ls /tmp/pg.log` | 지워도 된다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
rm -f /tmp/pg.log
|
||||
```
|
||||
|
||||
> **이 실험이 재지 않은 것** — 캐시가 「얼마나 오래」 더운지는 재지 않았다.
|
||||
> `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로, **한참 뒤에 다시 재면
|
||||
> 또 다른 답이 나올 수도 있다.** 그것까지 확인하려면 재현 C 뒤에 시간을 두고
|
||||
> 같은 시험을 반복해야 한다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 — 2-1 |
|
||||
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` — 4-2 |
|
||||
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
|
||||
| 로그 시각이 9시간 어긋난다 | **컨테이너 로그가 UTC 다** | `date -u` 와 비교한다 |
|
||||
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 — 4-4 |
|
||||
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 — 4-2 |
|
||||
| 세 재현이 전부 `200/200` | **재시작을 건너뛰어 캐시가 계속 더웠다** | 재현마다 `rollout restart` — 4-5 |
|
||||
| 재현 A 가 `200` 이 나온다 | 재시작 후 요청을 한 번이라도 보냈다 | 재시작 → **바로** DB 정지 |
|
||||
| 재현 B 가 `200/200` | 로그인 뒤 refresh 를 미리 했다 | 로그인 **한 번만** 하고 DB 정지 |
|
||||
| `JDBC exception` grep 이 빈 출력 | `--tail` 이 짧거나 정규식이 안 맞는다 | `grep -i 'JDBC exception'` 으로 먼저 넓게 |
|
||||
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 새 IP 로 다시 띄운다 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
|
||||
| 다음 실험의 postgres 로그가 폭주한다 | **문장 로깅을 끄지 않았다** | `show log_statement` 가 `none` — 5-2 |
|
||||
| 다음 실험의 세션이 안 살아남는다 | **volatile 로 둔 채 끝냈다** | 5-4 의 행 수 확인 |
|
||||
|
||||
---
|
||||
|
||||
# 왜 이 가이드는 표식을 손으로 넣게 하나
|
||||
|
||||
원래 실행은 표식을 셸 함수로 감쌌다.
|
||||
|
||||
```bash
|
||||
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
|
||||
```
|
||||
|
||||
짧고 편하다. 그런데 **출력을 `/dev/null` 로 버린다.** 표식이 실제로 로그에
|
||||
들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이다. 로깅이 안 켜져
|
||||
있었다면 **표식 없는 로그를 한참 뒤에 `awk` 로 자르다가** 알게 된다.
|
||||
|
||||
이 가이드는 표식을 **한 줄씩 손으로** 넣는다. 느리지만 그 자리에서 보이고,
|
||||
안 보이면 그 자리에서 안다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-7a 가 남긴 것 |
|
||||
|---|---|
|
||||
| [A-7](a7-volatile-comparison.md) volatile 비교 | **그 표에 조건을 붙여야 한다.** 「로그인 200 · refresh 500」은 캐시가 반쯤 더울 때만 참이다 |
|
||||
| [A-3](a3-database-crash.md) DB 크래시 | 같은 문장 로깅 기법. **RPO 를 재는 데 쓴다** |
|
||||
| [A-2](a2-database-loss.md) DB 정지 | persistent 에서는 캐시 온도와 무관하게 실패한다 — 대조군 |
|
||||
| 전부 | **한 번 재고 표로 적으면 안 되는 종류가 있다.** 상태가 결과를 바꾸는데 그 상태가 안 보일 때 |
|
||||
@@ -0,0 +1,753 @@
|
||||
# A-8 재현 가이드 — 배포할 때마다 로그아웃되는지 직접 확인한다
|
||||
|
||||
해설 문서: [`docs/experiment-a8-rolling-restart.md`](../../experiment-a8-rolling-restart.md) ·
|
||||
증거 원문: [`docs/evidence/a8-rolling-restart/`](../../evidence/a8-rolling-restart/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 파드가 전부 교체되는 동안 외부가 계속 `200` 인 것 | 5초 간격 `curl` 시계열 |
|
||||
| **재시작 전에 발급한 토큰이 재시작 후에도 통하는 것** | 상주 탐침 파드 |
|
||||
| DB 세션 수가 그대로인 것 | PostgreSQL `OFFLINE_USER_SESSION` |
|
||||
| **캐시만 0 으로 비워지는 것** | Prometheus `approximate_entries_unique` |
|
||||
| 클러스터가 스스로 다시 붙는 것 | `vendor_cluster_size` |
|
||||
| 「무중단」이 **관측 해상도에 달려 있다**는 것 | 표본이 9개뿐인 시계열 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- **[A-0](a0-session-replication.md) 을 먼저 하면 좋다.** 「세션은 DB 에 있고
|
||||
캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 터미널 **두 개**를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다),
|
||||
하나는 재시작·관찰용.
|
||||
|
||||
## 주의 — 이건 파괴적이지 않다. 그래서 더 조심한다
|
||||
|
||||
`rollout restart` 는 **정상 작업**이다. 되돌릴 것이 없고, 잘못돼도 클러스터가
|
||||
스스로 회복한다. 전 구간 약 15~20분.
|
||||
|
||||
**그래서 함정이 다르다.** 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고,
|
||||
**측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다.** 실제로 원래 실행이
|
||||
그랬다 — 1-5 의 파일 이름 함정을 반드시 읽는다.
|
||||
|
||||
**다른 실험과 겹치지 않게 한다.** 롤링 재시작 중에 다른 주입이 들어가 있으면
|
||||
무엇 때문에 무엇이 일어났는지 구별되지 않는다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 13:19–13:20 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
IP·파드 이름·sid·세션 수는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||||
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
**운영에서 가장 자주 겪는 일이다.** 장애가 아니라 정상 작업인데도 사용자가
|
||||
로그아웃되면 그건 사고다.
|
||||
|
||||
```
|
||||
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
|
||||
│
|
||||
└─ 세션이 거기 있었다면?
|
||||
```
|
||||
|
||||
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을
|
||||
세웠다. **그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 한다.**
|
||||
틀리다면 배포가 곧 전원 로그아웃이다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| A-0 모델 (persistent) | 재시작해도 **세션 생존** |
|
||||
| 옛 방식 (volatile) | 재시작하면 **전원 로그아웃** |
|
||||
|
||||
**둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다.**
|
||||
|
||||
그리고 이 실험은 **가용성도 같이 잰다.** 세션이 살아도 재시작 중에 서비스가
|
||||
끊기면 그것대로 문제다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 재시작하기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 재시작 후에 볼 것을 재시작 전에
|
||||
**똑같은 명령으로** 먼저 봐 둔다.
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
|
||||
```
|
||||
|
||||
## 1-1. 파드와 나이 — **나이가 판정 근거다**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
|
||||
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
|
||||
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`
|
||||
- **`AGE`** — 이 값을 적어 둔다. **재시작 후 이 값이 초 단위로 바뀌는 것이
|
||||
「정말 재시작됐다」의 증거다**
|
||||
- **replica 가 2 인 것** — 무중단의 전제다. 1 이면 반드시 끊긴다
|
||||
|
||||
**이 결과가 의미하는 것** — `rollout restart` 는 파드를 **삭제하고 새로 만든다.**
|
||||
그래서 `RESTARTS` 는 **안 오른다.** 재시작 여부를 `RESTARTS` 로 보면 「아무 일도
|
||||
안 일어났다」로 읽는다. **`AGE` 로 본다.**
|
||||
|
||||
IP 를 잡아 둔다. 재시작 후 **반드시 다시 잡는다.**
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||
echo "$K0 $K1"
|
||||
```
|
||||
|
||||
## 1-2. args 가 `["start"]` 인가 — 이 실험의 전제
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
["start"]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 플래그가 없는 것.
|
||||
|
||||
**이 결과가 의미하는 것** — `persistent-user-sessions` 가 기본으로 켜져 있다.
|
||||
**`--features-disabled=persistent-user-sessions` 가 붙어 있으면 이 실험은
|
||||
정반대 결과를 낸다** — 그건 [A-7](a7-volatile-comparison.md) 이다. 앞 실험이
|
||||
되돌리지 않고 끝냈다면 여기서 잡힌다.
|
||||
|
||||
## 1-3. DB 세션 수를 적어 둔다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
offline_flag | count
|
||||
--------------+-------
|
||||
0 | 151
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`offline_flag = '0'` 이 온라인 세션**이다.
|
||||
|
||||
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||
```
|
||||
DB 세션 수: 151
|
||||
```
|
||||
|
||||
**이 숫자를 적어 둔다.** 재시작 후 같은 값이 나오는 것이 4-3 의 판정이다.
|
||||
|
||||
> 숫자는 당신 환경에서 다르다. 관리 API 호출도 세션을 만들기 때문에 **개수에는
|
||||
> 노이즈가 있다.** 그래서 이 실험은 개수 말고 **특정 sid 하나**를 따로 추적한다.
|
||||
|
||||
## 1-4. 상주 탐침 파드 — **StatefulSet 밖에 있어야 한다**
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`).
|
||||
|
||||
**그리고 이 실험은 탐침이 재시작을 넘어 살아 있어야 한다.** 토큰을 재시작 전에
|
||||
받아서 재시작 후에 써야 하기 때문이다.
|
||||
|
||||
```
|
||||
토큰을 어디에 두나
|
||||
├─ Keycloak 파드 안 → 같이 죽는다. 못 쓴다
|
||||
├─ 내 셸 변수 → 되지만 화면·히스토리에 남는다
|
||||
└─ 단독 탐침 파드의 /tmp → StatefulSet 과 무관하게 산다 ★
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="K0=$K0" --env="K1=$K1" \
|
||||
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
|
||||
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
> **실측** — `19`
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
K0=10.42.1.94 K1=10.42.0.45 PW길이=19
|
||||
```
|
||||
|
||||
`PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
|
||||
|
||||
## 1-5. ★ 토큰을 파드 안에 보관한다 — 여기가 이 실험의 함정이다
|
||||
|
||||
**하기** — 로그인해서 응답을 `/tmp/tok` 에, 거기서 뽑은 값을 `/tmp/rt` · `/tmp/sid` 에
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" > /tmp/tok
|
||||
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
|
||||
| cut -d. -f2 | base64 -d 2>/dev/null \
|
||||
| sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
|
||||
echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'
|
||||
```
|
||||
|
||||
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||
```
|
||||
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
|
||||
sid = XLcgQWRiJrTkuNZcJsNeT_2j
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 값이 다 채워졌는지.**
|
||||
|
||||
| 출력 | 뜻 |
|
||||
|---|---|
|
||||
| `rt 1188 bytes / sid XLcg...` | 정상 |
|
||||
| **`rt 1 bytes`** | **빈 문자열 + 개행.** 파싱 실패 |
|
||||
| `sid` 가 비어 있음 | base64 패딩 때문에 잘렸다. sid 없이 진행하고 4-3 은 개수로 본다 |
|
||||
|
||||
### ★ 원래 실행이 실제로 빠진 함정
|
||||
|
||||
> **처음 재현 절차는 `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽었다.** `/tmp/rt` 를 만드는
|
||||
> 줄이 빠져 있었다. 그러면 **빈 문자열이 `refresh_token=` 으로 전송되는데,
|
||||
> 그래도 400 이 아니라 통과한 것처럼 보였다.**
|
||||
|
||||
**왜 위험한가** — 이 실험의 판정이 「재시작 후 refresh 가 `200` 인가」다.
|
||||
**빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로
|
||||
거짓이 된다.** 그리고 그 오류는 **아무 에러도 안 낸다.**
|
||||
|
||||
**그래서 길이를 찍는다.** `wc -c` 한 번이 이 실험 전체를 지킨다.
|
||||
|
||||
**확인** — 못 미더우면 파일을 직접 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
-rw-r--r-- 1 curl_use curl_gro 1188 Sep 4 13:19 /tmp/rt
|
||||
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `/tmp/rt` 의 크기가 네 자리이고 내용이 `eyJ` 로
|
||||
시작하는 것. `eyJ` 는 base64 로 인코딩된 `{"` 다. **JWT 는 전부 이렇게 시작한다.**
|
||||
|
||||
## 1-6. 대조군 — 재시작 전에 refresh 가 되는 것
|
||||
|
||||
**이 절을 건너뛰면 뒤의 200 이 아무 의미가 없다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||
-d "refresh_token=$(cat /tmp/rt)"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**★ 이 refresh 로 토큰이 회전했다.** `/tmp/rt` 의 값은 이제 **쓰인 토큰**이다.
|
||||
다시 채워 둔다. 안 그러면 4-1 의 400 이 「재시작 때문」인지 「재사용 때문」인지
|
||||
구별되지 않는다.
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW" > /tmp/tok
|
||||
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
|
||||
| cut -d. -f2 | base64 -d 2>/dev/null \
|
||||
| sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
|
||||
echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'
|
||||
```
|
||||
|
||||
**이 sid 가 최종 추적 대상이다.** 적어 둔다.
|
||||
|
||||
**확인** — 그 세션이 DB 에 실제로 있는지 지금 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
|
||||
where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
user_session_id | created_on | last_session_refresh
|
||||
--------------------------+------------+----------------------
|
||||
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495513
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 행이 **1개** 있고, `created_on` 과
|
||||
`last_session_refresh` 가 **같다.** 아직 갱신한 적이 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 세션이 DB 에 있다. **재시작 후에 이 행이 그대로
|
||||
있고 `last_session_refresh` 만 올라가는 것**이 4-2 의 판정이다.
|
||||
|
||||
## 1-7. 캐시와 클러스터 크기
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
|
||||
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||
|
||||
**형태**
|
||||
```json
|
||||
{"status":"success","data":{"resultType":"vector","result":[
|
||||
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757040000.1,"2"]},
|
||||
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757040000.1,"2"]}]}}
|
||||
```
|
||||
|
||||
라벨을 보고 나면 읽기 좋게 자른다. **미검증**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 결과가 두 줄이고 값이 둘 다 `2`.
|
||||
|
||||
**확인** — 세션 캐시 엔트리 수도 지금 봐 둔다
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
|
||||
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
|
||||
```
|
||||
|
||||
**0 이 아닌 값**이 나올 것이다. 재시작 후 **0 이 되는 것**이 4-4 의 판정이다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 롤링 재시작
|
||||
|
||||
여기부터 상태가 바뀐다. **다만 되돌릴 것은 없다.**
|
||||
|
||||
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌리는 명령이 없다. 중간에
|
||||
멈추려면 `rollout status` 를 `Ctrl-C` 로 끊으면 되지만 **롤아웃 자체는 계속
|
||||
진행된다.** 끝날 때까지 두는 편이 낫다. 정말 되돌려야 하면:
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout undo statefulset/keycloak
|
||||
```
|
||||
|
||||
## 2-1. 가용성 감시를 먼저 띄운다
|
||||
|
||||
**두 번째 터미널**에서 돌린다. **재시작보다 먼저 시작해야** 끊김 구간을 놓치지
|
||||
않는다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
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)"
|
||||
sleep 5
|
||||
done
|
||||
echo
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자가 5초마다 하나씩 붙는다. `200` 이 아닌 값이 보이면
|
||||
그 자리가 끊김이다.
|
||||
|
||||
> **여기서 `-w '%{http_code}'` 를 쓰는 이유** — 48번 반복해서 **비교할 값**만
|
||||
> 필요하기 때문이다. 무엇이 잘못됐는지 알아보려면 그때 `curl -v` 로 한 번 보면
|
||||
> 된다. 두 형태는 용도가 다르다.
|
||||
|
||||
> `--max-time 4` 는 5초 간격보다 짧게 잡은 것이다. **타임아웃이 간격보다 길면
|
||||
> 요청이 밀려 시계열이 어긋난다.**
|
||||
|
||||
## 2-2. 재시작한다
|
||||
|
||||
**첫 번째 터미널**에서 친다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 재시작'
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
|
||||
```
|
||||
|
||||
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||
```
|
||||
statefulset.apps/keycloak restarted
|
||||
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
|
||||
Waiting for 1 pods to be ready...
|
||||
Waiting for 1 pods to be ready...
|
||||
Waiting for 1 pods to be ready...
|
||||
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
|
||||
Waiting for 1 pods to be ready...
|
||||
Waiting for 1 pods to be ready...
|
||||
Waiting for 1 pods to be ready...
|
||||
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 위 원문은 두 터미널의 출력이 **한 파일에 섞여 기록된**
|
||||
것이다. `200` 이 가용성 루프, `Waiting for...` 가 `rollout status`.
|
||||
|
||||
- **`0 out of 2` → `1 out of 2` → `complete`** — 한 번에 하나씩 간다
|
||||
- 그 사이사이에 **`200` 이 계속 찍힌다**
|
||||
|
||||
**시각을 반드시 적어 둔다.** 뒤에서 지표가 「언제부터 변했나」를 볼 때 필요하다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 정말 재시작됐는지부터 본다.** 「세션이 살아남았다」는
|
||||
결론은 **파드가 진짜 바뀌었을 때만** 의미가 있다.
|
||||
|
||||
## 3-1. 파드가 정말 새것인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||
```
|
||||
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||
```
|
||||
=== [6] 파드 나이 — 정말 재시작되었나 ===
|
||||
keycloak-0 1/1 Running 0 44s
|
||||
keycloak-1 1/1 Running 0 66s
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지다.
|
||||
|
||||
- **`AGE` 가 초 단위다** — 1-1 에서 `2d` 였던 것이 `44s` 다. 진짜 새 파드다
|
||||
- **두 나이가 다르다** (`44s` vs `66s`) — **한 번에 하나씩 내렸다는 증거**다.
|
||||
22초 차이가 롤링의 간격이다. 둘이 같으면 동시에 내려간 것이고 무중단이 아니다
|
||||
- `RESTARTS` 는 **여전히 `0`** — 파드가 재시작된 게 아니라 **교체**됐기 때문이다
|
||||
|
||||
**이 결과가 의미하는 것** — `RESTARTS` 를 판정에 쓰면 안 된다는 것이 여기서
|
||||
보인다. `rollout restart` 는 파드를 지우고 새로 만들므로 재시작 카운터는
|
||||
새 파드에서 0 부터 시작한다.
|
||||
|
||||
**★ 파드 IP 가 바뀌었다.** 다시 잡는다.
|
||||
```bash
|
||||
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||
echo "$K0 $K1"
|
||||
```
|
||||
|
||||
**★ 탐침 파드는 다시 띄우면 안 된다.** `/tmp/rt` 와 `/tmp/sid` 가 같이 사라진다.
|
||||
탐침 안의 `K0` 환경변수는 낡았으므로, **새 IP 를 명령줄에 직접 넘긴다.** 4절의
|
||||
명령이 그렇게 되어 있다.
|
||||
|
||||
## 3-2. 가용성 시계열을 읽는다
|
||||
|
||||
두 번째 터미널의 출력을 본다.
|
||||
|
||||
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||
```
|
||||
(위 숫자열이 재시작 중 외부 응답 코드의 시계열)
|
||||
```
|
||||
```
|
||||
200 200 200 200 200 200 200 200 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`200` 이 9개.** 비200 이 없다.
|
||||
|
||||
### ★ 「무중단」이라고 쓰기 전에 표본 수를 본다
|
||||
|
||||
```
|
||||
9개 표본 × 5초 간격 = 약 45초를 9번 들여다본 것
|
||||
│
|
||||
└─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다
|
||||
```
|
||||
|
||||
**실제로 더 촘촘히 재니 끊김이 나왔다.** 후속 작업에서 **1초 간격·3초 타임아웃**
|
||||
으로 D-2 롤백 전환을 재보니:
|
||||
|
||||
**실측** — [`experiment-followup-untested-items.md`](../../experiment-followup-untested-items.md) 2절
|
||||
```
|
||||
200 ×24 000 200 ×19
|
||||
```
|
||||
|
||||
`000` 은 서버 오류가 아니라 **`--max-time 3` 타임아웃**이다. 파드 전환 순간
|
||||
요청 하나가 3초를 넘겼다.
|
||||
|
||||
**그래서 정확한 서술은 이것이다.**
|
||||
|
||||
| 쓰면 안 되는 문장 | 정확한 문장 |
|
||||
|---|---|
|
||||
| 「무중단이었다」 | 「**5초 해상도에서 끊김이 관측되지 않았다**」 |
|
||||
|
||||
**더 촘촘히 보고 싶으면** 2-1 의 루프를 이렇게 바꾼다. **미검증**
|
||||
```bash
|
||||
for i in $(seq 1 150); do
|
||||
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||
https://auth.hyeonworks.com/realms/master)"
|
||||
sleep 1
|
||||
done
|
||||
echo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. ★ 본 시험 — 재시작 전 토큰이 아직 통하는가
|
||||
|
||||
**하기** — 새 파드 IP 로, 파드 안에 보관해 둔 토큰을 쓴다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||
'curl -s -w "\n%{http_code}\n" -X POST \
|
||||
"http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||
-d "refresh_token=$(cat /tmp/rt)"'
|
||||
```
|
||||
|
||||
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||
```
|
||||
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
|
||||
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
|
||||
keycloak-0 에서 refresh HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `200`, 그리고 **본문에 새 토큰이 들어 있는 것.**
|
||||
|
||||
**이 결과가 의미하는 것** — **파드가 통째로 바뀌었는데 세션이 그대로다.**
|
||||
새로 뜬 프로세스는 이 세션을 **메모리에서 알던 것이 아니다.** DB 에서 읽었다.
|
||||
|
||||
> **400 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다.**
|
||||
> - 1-6 에서 `/tmp/rt` 를 다시 안 채웠다 → 이미 쓴 토큰이다
|
||||
> - `rt 1 bytes` 를 놓쳤다 → 빈 문자열을 보내고 있다
|
||||
> - args 에 `--features-disabled=persistent-user-sessions` 가 있다 → 그건 A-7 이다
|
||||
>
|
||||
> 셋 다 아니면 그때 결론을 의심한다.
|
||||
|
||||
## 4-2. DB 에 그 세션이 남아 있는가 — sid 로 정확히
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
|
||||
where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
|
||||
```
|
||||
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||
```
|
||||
=== [4] DB 에 그 세션이 남아 있는가 ===
|
||||
user_session_id | created_on | last_session_refresh
|
||||
--------------------------+------------+----------------------
|
||||
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 숫자의 차이.**
|
||||
|
||||
```
|
||||
1788495577 - 1788495513 = 64초
|
||||
│ │
|
||||
│ └─ 재시작 전에 세션이 만들어진 시각
|
||||
└─ 재시작 후의 refresh 가 기록된 시각
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **응답 코드만 200 인 게 아니라 쓰기까지 정상이다.**
|
||||
새 파드가 DB 에서 세션을 읽었고, 갱신 시각을 **DB 에 되썼다.**
|
||||
|
||||
`200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다.
|
||||
**A-1 에서 실제로 그런 일이 있었다** — 캐시가 DB 와 무관하게 200 을 준 사례다.
|
||||
여기서는 DB 행이 갱신됐으므로 그 가능성이 없다.
|
||||
|
||||
> **두 값은 유닉스 시각(초)이다.** 사람이 읽는 형태로 보려면:
|
||||
> ```bash
|
||||
> date -d @1788495513 ; date -d @1788495577
|
||||
> ```
|
||||
|
||||
## 4-3. 전체 세션 수는 그대로인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||
```
|
||||
전체 온라인 세션: 151 (재시작 전 151)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 1-3 에서 적어 둔 값과 같은지.
|
||||
|
||||
**이 결과가 의미하는 것** — **한 건도 안 잃었다.** sid 하나가 살아남은 것과
|
||||
전체가 살아남은 것은 다른 주장이고, 둘 다 봐야 한다.
|
||||
|
||||
> 관리 API 호출이 세션을 만들기 때문에 **몇 건 늘어날 수는 있다.** 크게 줄었다면
|
||||
> 그게 문제다.
|
||||
|
||||
## 4-4. 캐시는 사라진다 — 그게 정상이다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
|
||||
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
|
||||
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||
```
|
||||
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||
```
|
||||
=== [5] 캐시는 어떻게 되었는가 ===
|
||||
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
|
||||
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지다.
|
||||
|
||||
- **캐시가 0 이다** — 프로세스 메모리라 재시작에 사라졌다
|
||||
- **`keycloak-1` 의 1건** — 방금 4-1 의 refresh 를 처리하며 새로 담은 것이다.
|
||||
0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다
|
||||
- **`cluster_size` 가 다시 2** — 클러스터가 스스로 재형성됐다
|
||||
|
||||
**이 결과가 의미하는 것** — **A-0 의 모델이 그대로 확인된다.**
|
||||
|
||||
```
|
||||
재시작 전: 캐시 N건 + DB 151건
|
||||
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
|
||||
```
|
||||
|
||||
**캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다.** 룩어사이드
|
||||
캐시의 성질이다.
|
||||
|
||||
> Grafana 로 보면 세션 캐시가 0 으로 떨어지고 `cluster_size` 가 다시 2 가 되는
|
||||
> 구간이 한 화면에 잡힌다 —
|
||||
> [`a8-cache-reset-cluster-reformed.png`](../../evidence/a8-rolling-restart/a8-cache-reset-cluster-reformed.png)
|
||||
|
||||
## 4-5. 왜 무중단이 되는가
|
||||
|
||||
```
|
||||
StatefulSet 롤링 재시작
|
||||
│
|
||||
├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
|
||||
│ └─ 이 동안 keycloak-0 이 전부 받는다
|
||||
├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
|
||||
│
|
||||
└─ keycloak-0 종료 → ... (반복)
|
||||
```
|
||||
|
||||
**확인** — 엔드포인트가 실제로 그렇게 움직였나. 재시작 중에 봐야 보인다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||
```
|
||||
|
||||
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 뜬다.
|
||||
> `endpointslice` 를 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — **한 번에 하나씩** 내리므로 항상 최소 하나는 Ready 다.
|
||||
readiness 프로브가 이 전환을 정확히 맞춰준다. A-2 에서 「장애를 격리하는 장치」로
|
||||
본 그 메커니즘이 여기서는 **정상 작업을 안전하게** 만든다.
|
||||
|
||||
| 무중단의 조건 | 빠지면 |
|
||||
|---|---|
|
||||
| **replica ≥ 2** | 하나뿐이면 내리는 동안 아무도 안 받는다 |
|
||||
| **readiness 프로브** | 아직 기동 중인 파드로 트래픽이 간다 |
|
||||
|
||||
**둘 다 있어야 성립한다.** 이 실험대는 파드가 2개라서 됐다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
**주입이 정상 작업이었으므로 되돌릴 것이 없다.** 정리만 한다.
|
||||
|
||||
## 5-1. 탐침 파드를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
|
||||
```
|
||||
|
||||
**남겨 두면** 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 **네임스페이스에
|
||||
정체 모를 파드가 하나 있는 상태**가 된다. 지운다.
|
||||
|
||||
## 5-2. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| 세션 | `psql -tAc "select count(*) from offline_user_session where offline_flag='0'"` | 1-3 과 비슷한 값 |
|
||||
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a8-probe` | `NotFound` |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
> **이 실험이 재지 않은 것 셋**
|
||||
> - **replica 1 에서 어떻게 되는지** — 반드시 끊긴다고 적었지만 재지 않았다
|
||||
> - **5초보다 짧은 끊김** — 3-2 참조. 후속 작업이 다른 조건에서 `000` 을 잡았다
|
||||
> - **캐시가 0 에서 다시 차는 데 걸리는 시간** — 「첫 접근만 느려진다」고 썼지만
|
||||
> 그 「느림」을 재지 않았다. [A-6](a6-latency-injection.md) 이 인접한 주제다
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| **refresh 가 200 인데 뭔가 이상하다** | **`/tmp/rt` 가 비어 있다.** 빈 토큰인데 통과한 것처럼 보인다 | `wc -c < /tmp/rt` — 1-5 |
|
||||
| refresh 가 `400 Session not active` | 1-6 뒤에 `/tmp/rt` 를 안 채웠다. 이미 쓴 토큰이다 | 새로 로그인해서 다시 담는다 |
|
||||
| refresh 가 `400` 인데 토큰은 맞다 | **args 가 volatile 이다** | `get statefulset ... args` — 1-2. 그건 [A-7](a7-volatile-comparison.md) |
|
||||
| 재시작 후 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 다시 — 3-1 |
|
||||
| 탐침을 다시 띄웠더니 토큰이 없다 | **`/tmp/rt` 가 파드와 함께 사라졌다** | 탐침은 재시작 내내 유지한다 — 3-1 |
|
||||
| `RESTARTS` 가 0 이라 재시작이 안 된 것 같다 | **`rollout restart` 는 파드를 교체한다** | `AGE` 로 본다 — 3-1 |
|
||||
| `rollout status` 가 타임아웃 | 파드가 Ready 를 못 받는다 | `describe pod` 의 Events, `logs --previous` |
|
||||
| 가용성 루프에 `000` 이 섞인다 | `--max-time` 초과. **서버 오류가 아니다** | 간격보다 짧은 타임아웃인지 — 2-1 |
|
||||
| 가용성 루프가 전부 `000` | 루프가 잘못된 URL 을 친다 | `curl -v` 로 한 번 본다 |
|
||||
| 세션 수가 크게 줄었다 | 다른 실험이 세션을 지웠거나 volatile 이다 | 1-2 · 1-3 을 다시 |
|
||||
| DB 행의 `last_session_refresh` 가 안 올랐다 | 4-1 을 하기 전에 조회했다 | 순서: refresh → 조회 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | A-8 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [A-7](a7-volatile-comparison.md) volatile 비교 | **이 실험을 그대로 반복하면 정반대가 나와야 한다.** 그 한 쌍이 「왜 persistent 인가」의 답이다 |
|
||||
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | 롤링 재시작이 안전하다는 것이 업그레이드의 **전제**다 |
|
||||
| [A-2](a2-database-loss.md) DB 정지 | 여기서 「전환을 맞춰준」 readiness 가 거기서는 「장애를 격리」한다 |
|
||||
| 구성 | 무중단은 공짜가 아니라 **replica ≥ 2 + readiness** 의 조합이다 |
|
||||
@@ -0,0 +1,830 @@
|
||||
# B-0 재현 가이드 — 아무것도 주지 않았을 때 Spring 이 무엇을 고르는지 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) ·
|
||||
증거 원문: [`docs/evidence/b0-bff-redis-deploy/`](../../evidence/b0-bff-redis-deploy/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 돌고 있는 인스턴스가 **실제로 고른 구현체 이름** | `/actuator/beans` |
|
||||
| Redis 도 Spring Session 도 **하나도 구성되지 않은 것** | 같은 곳 |
|
||||
| 조회 키에 **session ID 가 없다**는 것 | 빈 이름 하나가 그대로 설명이다 |
|
||||
| **replica 2 에서 로그인 자체가 실패하는 것** | 브라우저 · `/login?error` |
|
||||
| replica 를 1 로 줄이면 되는 것 | 같은 브라우저 |
|
||||
| 브라우저에 토큰이 **0개**인 것 | `/bff/token-boundary` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다. A층 실험은 안 해도 된다.
|
||||
- **브라우저가 필요하다.** 인가 코드 흐름은 **왕복이 두 번**이라 `curl` 로
|
||||
대신할 수 없다. `https://app1.hyeonworks.com/` 이 당신 브라우저에서 열려야 한다.
|
||||
- BFF 이미지는 **워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.**
|
||||
레지스트리가 없으므로 `imagePullPolicy: Never` 다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 으로 읽는다.
|
||||
|
||||
## 주의 — ★ 저장소를 먼저 붙이면 이 실험은 성립하지 않는다
|
||||
|
||||
B-0 의 질문은 **「아무것도 주지 않았을 때 자동구성이 무엇을 고르는가」**다.
|
||||
Redis 를 먼저 연결하면 잴 것이 없어진다. **Redis 는 배포만 하고 BFF 에 연결하지
|
||||
않는다.** 연결은 [B-1](b1-redis-session-store.md) 에서 한다.
|
||||
|
||||
**그리고 이 저장소의 현재 소스는 이미 B-1·B-2 를 거친 뒤 상태다.**
|
||||
`bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고, `SecurityConfig`
|
||||
에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. **그대로 배포하면 B-2 의
|
||||
결과를 재게 된다.** 어느 브랜치에도 B-0 시점의 파일은 남아 있지 않다 —
|
||||
[2-1](#2-1-b-0-상태로-되돌린다--네-파일) 에서 손으로 되돌린다.
|
||||
|
||||
전 구간 약 40~60분(빌드 시간 포함). 배포한 것을 지우는 명령은 5절에 있다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 13:39–13:46 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
|
||||
|
||||
파드 이름·IP·빈 개수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
Q1 이 직접 요구한 확인이다.
|
||||
|
||||
> 코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로
|
||||
> 사용되는지는 **Spring Boot 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다.
|
||||
|
||||
```
|
||||
빈을 직접 만들지 않으면
|
||||
└─ Spring Boot 가 조건에 따라 고른다
|
||||
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
|
||||
└─ 돌아가는 인스턴스에 물어봐야 안다
|
||||
```
|
||||
|
||||
**추측으로도 답은 나온다.** 「저장소를 안 붙였으니 메모리겠지.」 맞다.
|
||||
**그런데 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.**
|
||||
|
||||
빈 이름 하나가 이 층 전체의 문제를 담고 있는데, **그 이름은 추측으로 안 나온다.**
|
||||
찍어 봐야 나온다. 그게 이 실험이다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 배포하기 전에
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
노드 자원 → 네임스페이스에 무엇이 있나 → 이미지가 두 노드에 있나 → Keycloak realm
|
||||
```
|
||||
|
||||
## 1-1. 노드에 자원이 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
free -m
|
||||
sudo kubectl top nodes
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
|
||||
```
|
||||
=== 배포 전 자원 ===
|
||||
Mem: 11648 7329 280 4 4377 4319
|
||||
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
|
||||
kc-lab-1 115m 5% 2192Mi 44%
|
||||
kc-lab-2 121m 6% 1324Mi 33%
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 노드 메모리 사용률. 여기서는 `44%` · `33%` 다.
|
||||
|
||||
**이 결과가 의미하는 것** — BFF 는 JVM 이고 replica 가 2 다. 매니페스트는
|
||||
`requests: 320Mi` · `limits: 512Mi` 로 잡혀 있다. **여유가 없으면 파드가
|
||||
`Pending` 이거나 OOM 으로 죽는데, 그걸 「Spring 설정 문제」로 읽게 된다.**
|
||||
배포 전에 한 번 보고 시작한다.
|
||||
|
||||
## 1-2. 네임스페이스에 무엇이 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get all
|
||||
sudo kubectl -n keycloak-lab get secret,ingress
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `keycloak` StatefulSet 과 `postgres` 가 있고,
|
||||
**`bff` · `redis` 는 없는 것.**
|
||||
|
||||
**이 결과가 의미하는 것** — 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면
|
||||
「내가 만든 것」과 「원래 있던 것」이 섞인다. 있으면 5-1 로 먼저 지운다.
|
||||
|
||||
> `get all` 은 워크로드 계열만 보여준다. **Secret·PVC·Ingress 는 안 나온다.**
|
||||
> 그래서 두 줄로 나눠 친다.
|
||||
|
||||
## 1-3. Keycloak realm 을 준비한다
|
||||
|
||||
BFF 가 붙을 realm 과 클라이언트가 있어야 한다. **없으면 배포는 성공하는데
|
||||
로그인에서 막힌다.**
|
||||
|
||||
**하기** — `kcadm` 에 로그인한다. Keycloak 이미지 안에 있는 도구다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
|
||||
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
> **실측** — `19`
|
||||
|
||||
**하기** — realm 과 클라이언트
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create clients -r keycloak-patterns \
|
||||
-s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
|
||||
-s 'redirectUris=["https://app1.hyeonworks.com/*"]'
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete realms/keycloak-patterns
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `accessTokenLifespan=60`.
|
||||
|
||||
**이 결과가 의미하는 것** — **B-3(refresh 경쟁)을 위해 미리 짧게 잡는 것이다.**
|
||||
만료를 기다리는 시간이 짧아야 재현이 된다. 지금 정해 두면 나중에 realm 을
|
||||
다시 안 만든다.
|
||||
|
||||
**확인** — 만들어졌나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
|
||||
```
|
||||
|
||||
로그인할 사용자도 하나 만든다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create users -r keycloak-patterns -s username=labuser -s enabled=true
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
set-password -r keycloak-patterns --username labuser --new-password 'lab-user-change-me'
|
||||
```
|
||||
|
||||
**★ 이 비밀번호는 브라우저에 직접 칠 것이므로 당신이 정한다.** 위 값은 예시고,
|
||||
**실제로 쓸 값은 셸 히스토리에 남지 않게** 하려면 `kcadm.sh` 를 대화식으로
|
||||
쓰거나 나중에 관리 콘솔에서 바꾼다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 준비 — 배포에서 겪은 문제 다섯 가지를 먼저 읽는다
|
||||
|
||||
**이 절을 건너뛰면 다섯 번 막힌다.** 전부 이 실험대가 실제로 겪은 것이다.
|
||||
|
||||
## 2-1. B-0 상태로 되돌린다 — 네 파일
|
||||
|
||||
현재 소스는 B-1·B-2 의 결과를 담고 있다. **B-0 을 재려면 그 배선을 빼야 한다.**
|
||||
|
||||
**되돌리기** — 실험이 끝나면 원래대로 돌린다
|
||||
```bash
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
**하기 ①** — `bff/pom.xml` 에서 두 블록을 지운다
|
||||
```bash
|
||||
vim bff/pom.xml
|
||||
```
|
||||
| 지울 의존성 | 왜 |
|
||||
|---|---|
|
||||
| `spring-session-data-redis` | 있으면 `SessionRepository` 가 Redis 로 갈린다 (B-1) |
|
||||
| `spring-boot-starter-data-redis` | 있으면 Redis 연결 빈이 잔뜩 생긴다 (B-1) |
|
||||
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | B-2 의 `JdbcOAuth2AuthorizedClientService` 용 |
|
||||
|
||||
**하기 ②** — `SecurityConfig.java` 에서 **빈 두 개**를 지운다
|
||||
```bash
|
||||
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
|
||||
```
|
||||
```java
|
||||
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
|
||||
@Bean
|
||||
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
|
||||
|
||||
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
|
||||
@Bean
|
||||
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
|
||||
```
|
||||
관련 `import` (`JdbcOAuth2AuthorizedClientService`, `JdbcOperations`, 매니저 계열)도
|
||||
같이 지운다. **`bffSecurity` 빈은 남긴다** — `/actuator/**` 를 열어 주는 것이
|
||||
그 안에 있다(문제 ④).
|
||||
|
||||
**하기 ③** — `application.yml` 에서 세 블록을 지운다
|
||||
```bash
|
||||
vim bff/src/main/resources/application.yml
|
||||
```
|
||||
| 지울 블록 | 왜 |
|
||||
|---|---|
|
||||
| `spring.session` | `store-type` 기본값이 **`redis`** 다. 남겨 두면 의존성만 빼도 경고가 난다 |
|
||||
| `spring.data.redis` | Redis 연결 설정 |
|
||||
| `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 |
|
||||
|
||||
**하기 ④** — `deploy/lab/k8s/bff-redis.yaml` 의 `bff` 컨테이너에서 env 를 지운다
|
||||
```bash
|
||||
vim deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
```yaml
|
||||
# 지운다 — B-1 · B-2 가 넣은 것
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
- name: REDIS_HOST
|
||||
- name: REDIS_PORT
|
||||
- name: BFF_DB_URL
|
||||
- name: BFF_DB_USER
|
||||
- name: BFF_DB_PASSWORD
|
||||
```
|
||||
|
||||
**Redis Deployment·Service·PVC 는 그대로 둔다.** 배포는 하되 **연결만 안 한다** —
|
||||
그게 B-0 의 구성이다.
|
||||
|
||||
**확인** — 무엇을 지웠는지 눈으로 본다
|
||||
```bash
|
||||
git diff --stat
|
||||
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
|
||||
```
|
||||
|
||||
## 2-2. 문제 ① — 소스 없이 빌드 산출물만 커밋되어 있었다
|
||||
|
||||
원래 실행은 `bff/` 에 이런 상태를 만났다.
|
||||
|
||||
```
|
||||
bff/target/classes/... 9개 파일
|
||||
bff/src/ 없음
|
||||
```
|
||||
|
||||
`.gitignore` 에 `target/` 이 없어 **클래스 파일만** 커밋되어 있었고 소스는
|
||||
다른 브랜치에 있었다.
|
||||
|
||||
**확인** — 지금 당신의 저장소는 어떤가
|
||||
```bash
|
||||
ls bff/src/main/java/com/example/keycloakpattern/bff/
|
||||
```
|
||||
|
||||
없으면 가져온다.
|
||||
```bash
|
||||
git checkout origin/develop-keycloak-pattern3 -- bff/
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **빌드 산출물이 커밋되어 있으면 「빌드가 되는데
|
||||
바꿔도 안 바뀐다」가 된다.** 소스가 있는지부터 본다.
|
||||
|
||||
## 2-3. 문제 ② — 빌드 실패 원인이 마지막 15줄에 없다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
echo "exit=$?"
|
||||
```
|
||||
|
||||
**확인** — 실패했으면 전체 로그에서 찾는다
|
||||
```bash
|
||||
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
|
||||
```
|
||||
|
||||
**실측** — 원래 실행이 만난 것
|
||||
```
|
||||
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
|
||||
```
|
||||
|
||||
`management:` 아래에 `endpoint:` 블록을 **하나 더** 넣어서 난 오류였다.
|
||||
이미 있는데 또 넣은 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`docker build` 기본 출력은 마지막 몇 줄만 보여준다.**
|
||||
Maven 스택트레이스는 그 위에 있다. `--progress=plain` 으로 전체를 파일로 받고
|
||||
`grep` 으로 찾는다.
|
||||
|
||||
> `yamllint` 는 이 실험대에 깔려 있지 않다. YAML 중복 키는 **빌드가 잡아 준다** —
|
||||
> 다만 그 메시지를 보려면 위처럼 해야 한다.
|
||||
|
||||
## 2-4. 문제 ③ — 환경변수에 기본값이 없으면 테스트가 죽는다
|
||||
|
||||
```yaml
|
||||
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth
|
||||
|
||||
# 기본값을 준다
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
|
||||
```
|
||||
|
||||
**확인** — 지금 파일이 그렇게 되어 있나
|
||||
```bash
|
||||
grep -n 'KC_ISSUER' bff/src/main/resources/application.yml
|
||||
```
|
||||
|
||||
## 2-5. 문제 ④ — actuator 가 인증에 막혀 200 인데 로그인 페이지
|
||||
|
||||
`/actuator/beans` 를 불렀는데 `200` 이 왔다. **내용은 Keycloak 로그인 페이지였다.**
|
||||
`-L` 로 리다이렉트를 따라간 결과다.
|
||||
|
||||
```java
|
||||
// SecurityConfig 의 permitAll 목록
|
||||
"/actuator/health",
|
||||
"/actuator/health/**",
|
||||
// 실험대 전용 — 운영에서는 절대 열지 않는다
|
||||
"/actuator/**"
|
||||
```
|
||||
|
||||
> **`200` 이 곧 성공은 아니다.** 무엇이 왔는지 봐야 한다. 이 함정은
|
||||
> `-o /dev/null -w '%{http_code}'` 만 쓸 때 **절대 안 보인다.**
|
||||
|
||||
## 2-6. 문제 ⑤ — 큰 응답이 프록시에서 `Bad Gateway`
|
||||
|
||||
`/actuator/beans` 는 **117KB** 다. nginx → Traefik 을 거치면서 실패했다.
|
||||
|
||||
**실측** — [`experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) 1절
|
||||
```
|
||||
$ curl https://app1.hyeonworks.com/actuator/beans
|
||||
Bad Gateway
|
||||
```
|
||||
|
||||
**해결** — 파드 안에서 직접 받는다. **alpine 기반 JRE 이미지에는 `wget` 이 있다.**
|
||||
(Keycloak 이미지와 다른 점이다 — 거기엔 curl 도 wget 도 없다.)
|
||||
|
||||
## 2-7. 이미지를 두 노드에 밀어 넣는다
|
||||
|
||||
레지스트리가 없다. `imagePullPolicy: Never` 라서 **두 노드에 각각 있어야 한다.**
|
||||
|
||||
**하기** — 워크스테이션에서
|
||||
```bash
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
```
|
||||
|
||||
**확인** — 두 노드에 들어갔나
|
||||
```bash
|
||||
sudo k3s ctr images ls | grep keycloak-pattern-bff
|
||||
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
|
||||
```
|
||||
|
||||
**한쪽만 있으면** 그 노드에 스케줄된 replica 만 뜬다. `ErrImageNeverPull` 로
|
||||
나타난다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 배포
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
## 3-1. 적용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
|
||||
```
|
||||
=== 배포 ===
|
||||
secret/bff-secrets created
|
||||
deployment.apps/redis created
|
||||
service/redis created
|
||||
deployment.apps/bff created
|
||||
service/bff created
|
||||
ingress.networking.k8s.io/bff created
|
||||
|
||||
deployment "redis" successfully rolled out
|
||||
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
|
||||
deployment "bff" successfully rolled out
|
||||
```
|
||||
|
||||
## 3-2. 배포 구성 — 무엇이 어디에 있나
|
||||
|
||||
```
|
||||
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
|
||||
│
|
||||
├──▶ Keycloak (realm: keycloak-patterns)
|
||||
└──▶ echo (resource server 대역)
|
||||
|
||||
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
|
||||
```
|
||||
|
||||
**Redis 는 배포만 하고 BFF 에 연결하지 않았다.** 이 상태를 먼저 재는 것이 B-0 이다.
|
||||
|
||||
### 브라우저용 URL 과 백채널 URL 을 분리한다
|
||||
|
||||
```yaml
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
|
||||
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
|
||||
```
|
||||
```yaml
|
||||
- name: KC_ISSUER_EXTERNAL
|
||||
value: https://auth.hyeonworks.com/realms/keycloak-patterns
|
||||
- name: KC_ISSUER_INTERNAL
|
||||
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
|
||||
```
|
||||
|
||||
**브라우저가 보는 이름과 서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.**
|
||||
`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다 — 없으면 Spring 이
|
||||
`redirect_uri` 를 `http://` 로 만들어 Keycloak 이 거부한다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 배포가 실제로 걸렸는지 확인한다
|
||||
|
||||
## 4-1. 파드가 두 노드에 하나씩 떴나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||
sudo kubectl -n keycloak-lab get pods -o wide -l app=redis
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
|
||||
```
|
||||
bff-574c6d658b-8cz4x true kc-lab-1
|
||||
bff-574c6d658b-zpkbp true kc-lab-2
|
||||
redis-568bd7c4-5c5vc true kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **BFF 두 개가 서로 다른 노드에 있는 것.**
|
||||
|
||||
**이 결과가 의미하는 것** — `topologySpreadConstraints` 가 일했다. **「다른
|
||||
인스턴스」가 진짜 다른 기계여야** 이 층의 질문이 성립한다. 같은 노드의 다른
|
||||
프로세스면 재는 의미가 절반이다.
|
||||
|
||||
`Pending` 이면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면
|
||||
2-7 로 돌아간다.
|
||||
|
||||
## 4-2. 밖에서 닿나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -I https://app1.hyeonworks.com/
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
HTTP/2 200
|
||||
content-type: text/html
|
||||
```
|
||||
|
||||
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
|
||||
```
|
||||
=== 외부 진입점 ===
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태 줄과 **`content-type`.** 2-5 의 함정 때문이다.
|
||||
`text/html` 이 왔다고 그게 **당신의** HTML 이라는 보장은 없다. 다음 절에서
|
||||
내용까지 본다.
|
||||
|
||||
> `-I` 는 헤더만 본다. 여기서는 **닿는지**를 물어보는 것이라 이 형태가 맞다.
|
||||
> 나중에 여러 번 재서 비교할 때는 `-o /dev/null -w '%{http_code}'` 를 쓴다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 관찰 — 자동구성이 실제로 고른 것
|
||||
|
||||
## 5-1. `/actuator/beans` 를 파드 안에서 받는다
|
||||
|
||||
**하기** — 파드 이름을 먼저 잡는다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||
echo "$BFF"
|
||||
```
|
||||
|
||||
**하기** — 파드 안에서 받아 파일로 저장한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
|
||||
wc -c /tmp/beans.json
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
119552 /tmp/beans.json
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 크기가 **10만 바이트 대**인 것. 원래 실행에서 **117KB**
|
||||
였다. `0` 이면 못 받은 것이고, 몇 백 바이트면 **로그인 페이지나 오류 본문**이다.
|
||||
|
||||
**확인** — 진짜 JSON 인지 앞부분을 본다
|
||||
```bash
|
||||
head -c 200 /tmp/beans.json ; echo
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `{"contexts":{"keycloak-bff"` 로 시작하는 것.
|
||||
`<!DOCTYPE html` 로 시작하면 **2-5 의 함정**이다 — 로그인 페이지를 받았다.
|
||||
|
||||
## 5-2. `jq` 없이 빈 목록을 읽는다
|
||||
|
||||
**빈 하나는 이런 모양이다.**
|
||||
```
|
||||
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
|
||||
```
|
||||
|
||||
**이름과 타입이 이 한 덩어리 안에 같이 있다.** 그러니 그 덩어리만 뽑으면 된다.
|
||||
|
||||
**확인** — 전체 빈 수. **미검증**
|
||||
```bash
|
||||
grep -o '"aliases":\[' /tmp/beans.json | wc -l
|
||||
```
|
||||
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
|
||||
```
|
||||
컨텍스트: keycloak-bff
|
||||
전체 빈 수: 321
|
||||
```
|
||||
|
||||
**확인** — 이름과 타입을 한 줄로. **미검증**
|
||||
```bash
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -i authorizedclient
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 화살표 오른쪽의 **클래스 이름 끝부분.**
|
||||
|
||||
### ★ 여기서 원래 실행이 실제로 넘어졌다
|
||||
|
||||
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
|
||||
```
|
||||
File "<stdin>", line 9
|
||||
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
|
||||
^
|
||||
SyntaxError: unexpected character after line continuation character
|
||||
```
|
||||
|
||||
**JSON 을 파이썬 한 줄로 파싱하려다 따옴표 이스케이프에서 깨졌다.**
|
||||
빈 목록은 결국 다음 시도에서 나왔고, 그 결과가
|
||||
[`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt) 다.
|
||||
|
||||
> **`jq` 가 있으면 그걸 쓴다. 없으면 `grep` 으로 충분하다.**
|
||||
> 이 실험대에는 `jq` 가 없다. 없는 도구를 전제로 한 명령은 **진단 도중에
|
||||
> 패키지를 깔러 나가게 만든다.** 그러지 않으려고 위 형태를 쓴다.
|
||||
|
||||
## 5-3. B-0 의 답
|
||||
|
||||
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
|
||||
```
|
||||
--- 세션 · 토큰 저장소 관련 ---
|
||||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||||
clientRegistrationRepository -> InMemoryClientRegistrationRepository
|
||||
|
||||
--- Redis / Spring Session 이 구성되었는가 ---
|
||||
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
|
||||
```
|
||||
|
||||
**확인** — Redis 와 Spring Session 이 정말 없는지 직접 센다
|
||||
```bash
|
||||
grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `0`.
|
||||
|
||||
**이 결과가 의미하는 것** — 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다.
|
||||
**세션은 서블릿 컨테이너(Tomcat)의 기본 `StandardSession` 에 있다.** 즉 **인스턴스
|
||||
메모리**다.
|
||||
|
||||
| 빈 | 구현체 | 뜻 |
|
||||
|---|---|---|
|
||||
| `authorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **프로세스 메모리.** 재시작하면 사라진다 |
|
||||
| `authorizedClientRepository` | **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** | **principal 기준 조회.** session ID 가 없다 |
|
||||
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | **service**(공유)를 쓴다 |
|
||||
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
|
||||
| SessionRepository | **없음** | Tomcat 의 기본 `StandardSession` |
|
||||
| Redis / Spring Session | **없음** | 의존성 자체가 없다 |
|
||||
|
||||
## 5-4. ★ 이름 하나가 이 층 전체의 문제다
|
||||
|
||||
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` — **이름이 곧 설명이다.**
|
||||
|
||||
```
|
||||
요청이 인증되어 있으면
|
||||
└─▶ OAuth2AuthorizedClientService 에 위임
|
||||
└─▶ 키: (clientRegistrationId, principalName)
|
||||
└─ session ID 가 없다 ★
|
||||
인증되어 있지 않으면
|
||||
└─▶ HttpSession 에 임시 보관
|
||||
```
|
||||
|
||||
**같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을
|
||||
본다.** 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다.
|
||||
|
||||
> **Redis 를 붙여도 이건 안 고쳐진다.** 저장소를 공유해도 **키에 session ID 가
|
||||
> 없기 때문**이다. 「Session Store 를 공유 저장소로 바꾸는 것만으로는 충분하지
|
||||
> 않다」의 기제가 이 빈 하나에 들어 있다.
|
||||
>
|
||||
> **이것이 추측으로는 안 나오는 부분이다.** 「메모리겠지」까지는 맞혔어도
|
||||
> **조회 키가 무엇인지는 빈 이름을 봐야 안다.**
|
||||
|
||||
---
|
||||
|
||||
# 6. ★ 예상 못 한 것 — replica 2개에서 로그인 자체가 안 된다
|
||||
|
||||
**여기부터는 브라우저로 한다.**
|
||||
|
||||
## 6-1. 증상
|
||||
|
||||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다.
|
||||
|
||||
**형태** — 주소창이 이렇게 끝난다
|
||||
```
|
||||
https://app1.hyeonworks.com/login?error
|
||||
```
|
||||
|
||||
**확인** — 로그를 본다. **두 파드를 다 봐야 한다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **아무 오류도 없다.**
|
||||
|
||||
**이 결과가 의미하는 것** — Spring Security 는 **로그인 실패를 DEBUG 로만
|
||||
남긴다.** 「로그에 아무것도 없으니 애플리케이션 문제가 아니다」로 읽으면 틀린다.
|
||||
**증상은 있는데 로그가 없는 상태**이고, 그럴 때는 가설을 세워 시험한다.
|
||||
|
||||
## 6-2. 가설
|
||||
|
||||
```
|
||||
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
|
||||
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
|
||||
```
|
||||
|
||||
**인가 코드 흐름은 왕복이 두 번이고, 두 번 다 같은 인스턴스로 가야 한다.**
|
||||
저장 위치가 `HttpSession` 이고 그게 **인스턴스 메모리**이므로, 콜백이 다른
|
||||
replica 로 가면 저장된 인가 요청이 없어 실패한다.
|
||||
|
||||
**5-3 에서 본 「SessionRepository 없음」이 이 가설의 근거다.**
|
||||
|
||||
## 6-3. 검증 — replica 를 1로 줄인다
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=1
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
**하기** — 브라우저에서 다시 로그인한다. **쿠키를 먼저 지운다** (앞선 실패의
|
||||
세션이 남아 있으면 결과가 섞인다).
|
||||
|
||||
**실측** — [`b0-bff-login-success-single-replica.png`](../../evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png)
|
||||
```
|
||||
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
|
||||
replica 1 → 로그인 성공
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **가설 확정.**
|
||||
|
||||
> **「다중 인스턴스에서 어떻게 운영할 것인가」는 로그인한 뒤의 문제가 아니라
|
||||
> 로그인 자체의 문제다.** [B-2](b2-multi-instance-session.md) 의
|
||||
> 검증 1번(「한쪽에서 로그인한 뒤 다른 인스턴스로 요청」)보다 **앞선 단계**다.
|
||||
> 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
|
||||
|
||||
## 6-4. 토큰 경계 — 브라우저에 무엇이 있나
|
||||
|
||||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary`
|
||||
|
||||
**실측** — [`b0-bff-token-boundary.png`](../../evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png)
|
||||
```json
|
||||
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
|
||||
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
|
||||
"browserTokenCount":0,"csrfProtectionEnabled":true}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 값.
|
||||
|
||||
| 필드 | 값 | 뜻 |
|
||||
|---|---|---|
|
||||
| `accessTokenStoredOnServer` | `true` | 서버가 access token 을 들고 있다 |
|
||||
| `refreshTokenStoredOnServer` | `true` | refresh token 도 서버에 있다 |
|
||||
| **`browserTokenCount`** | **`0`** | **브라우저에는 토큰이 하나도 없다** |
|
||||
|
||||
**이 결과가 의미하는 것** — **BFF 패턴이 성립한다.** 브라우저는 세션 쿠키만
|
||||
들고 있고 토큰은 전부 서버에 있다. 이 세 값이 [B-1](b1-redis-session-store.md)
|
||||
에서 어떻게 바뀌는지가 다음 실험의 요지다. **지금 값을 적어 둔다.**
|
||||
|
||||
---
|
||||
|
||||
# 7. 복구
|
||||
|
||||
## 7-1. replica 를 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
|
||||
```
|
||||
|
||||
**[B-1](b1-redis-session-store.md) 로 이어서 갈 것이라면 배포는 그대로 둔다.**
|
||||
거기서 같은 파드에 Redis 를 붙인다.
|
||||
|
||||
## 7-2. 소스 변경을 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
git status --short
|
||||
```
|
||||
|
||||
**★ 잊으면 다음에 `apply` 할 때 B-0 구성이 다시 배포된다.**
|
||||
|
||||
## 7-3. 전부 지울 때
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete realms/keycloak-patterns
|
||||
```
|
||||
|
||||
**★ PVC 는 `delete -f` 로 같이 지워진다.** Redis 데이터도 사라진다.
|
||||
|
||||
## 7-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| replica | `sudo kubectl -n keycloak-lab get deploy bff` | `2/2` |
|
||||
| 소스 | `git status --short` | 출력 없음 |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 |
|
||||
| Keycloak | `sudo kubectl -n keycloak-lab get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running` |
|
||||
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
|
||||
| 임시 파일 | `rm -f /tmp/beans.json /tmp/build.log` | — |
|
||||
|
||||
> **★ actuator 를 열어 둔 채로 두지 않는다.** `/actuator/beans` 와
|
||||
> `/actuator/env` 는 **내부 구조와 설정값을 그대로 드러낸다.** 실험대라서
|
||||
> 여는 것이고, 운영이라면 `health` 만 남긴다.
|
||||
|
||||
> **이 실험이 재지 않은 것** — 스티키 세션(세션 어피니티)을 켜면 replica 2 에서
|
||||
> 로그인이 되는지는 재지 않았다. 「같은 인스턴스로 보내면 된다」는 추론이지
|
||||
> 측정이 아니다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 빈 목록에 `RedisSessionRepository` 가 있다 | **B-1·B-2 배선이 남아 있다** | 2-1 을 다시. `git diff` 로 확인 |
|
||||
| `authorizedClientService` 가 `Jdbc...` 다 | `SecurityConfig` 의 명시 빈이 남아 있다 | 2-1 하기 ② |
|
||||
| `/actuator/beans` 가 `Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 — 5-1 |
|
||||
| `/actuator/beans` 가 `200` 인데 HTML | **Keycloak 로그인 페이지다** | `head -c 200` 으로 내용 확인 — 5-1 |
|
||||
| `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` |
|
||||
| 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 — 2-3 |
|
||||
| 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | 2-4 |
|
||||
| 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import — 2-7 |
|
||||
| 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` — 1-1 |
|
||||
| 브라우저가 `/login?error` | **replica 2 + 스티키 없음** | replica 1 로 줄여 확인 — 6-3 |
|
||||
| BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 「문제 없음」으로 읽지 않는다 — 6-1 |
|
||||
| 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 — 3-2 |
|
||||
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 5-2 |
|
||||
| `kcadm` 이 `401` | `config credentials` 를 안 했거나 만료됐다 | 1-3 을 다시 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-0 이 남긴 것 |
|
||||
|---|---|
|
||||
| [B-1](b1-redis-session-store.md) 저장소 결정 | **전환 후 이 빈들을 다시 찍는다.** 「Redis 붙였다」고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
|
||||
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **로그인 자체가 실패한다**는 것이 이미 관측됐다. 그게 검증 0번이다 |
|
||||
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | `accessTokenLifespan=60` 으로 realm 을 만들어 뒀다 |
|
||||
| 운영 | actuator `beans`/`env` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
|
||||
@@ -0,0 +1,852 @@
|
||||
# B-1 재현 가이드 — Redis 를 붙이고, 무엇이 옮겨졌고 무엇이 안 옮겨졌는지 찍어서 확인한다
|
||||
|
||||
해설 문서: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) ·
|
||||
증거 원문: [`docs/evidence/b1-redis-session-store/`](../../evidence/b1-redis-session-store/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| **쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것** | `logs` · `printenv` |
|
||||
| 그것이 `enableServiceLinks: false` 로 고쳐지는 것 | 롤아웃 성공 |
|
||||
| 빈이 **321 → 402 (+81)** 로 늘어나는 것 | `/actuator/beans` |
|
||||
| **그런데 authorized client 는 하나도 안 바뀐 것** | 같은 곳 |
|
||||
| Redis 안의 키·필드·TTL, 그리고 **토큰이 없는 것** | `redis-cli` |
|
||||
| 세션이 **Java 네이티브 직렬화**인 것 | `\xac\xed` |
|
||||
| **「로그인은 되어 있는데 아무것도 못 하는」 상태** | 브라우저 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`B-0`](b0-bff-redis-deploy.md) 이 끝나 있다. **B-0 의 답(빈 세 개의 이름)을
|
||||
손에 들고 시작한다** — 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다.
|
||||
- **브라우저가 필요하다.** 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 과 `redis-cli` 로 읽는다.
|
||||
|
||||
## 주의 — 이건 애플리케이션 구성을 바꾸는 실험이다
|
||||
|
||||
의존성과 설정을 바꿔 **다시 빌드하고 다시 배포한다.** 되돌리려면 소스 변경을
|
||||
되돌리고 다시 빌드해야 하므로, **`git status` 가 깨끗한 상태에서 시작한다.**
|
||||
|
||||
전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다.
|
||||
|
||||
**★ 2-3 은 일부러 고장 난 상태로 배포한다.** 함정을 직접 보기 위해서다. 건너뛰고
|
||||
싶으면 [2-4](#2-4-고침--enableservicelinks-false) 부터 시작해도 결과는 같다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 13:59–14:03 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
|
||||
|
||||
파드 이름·세션 ID·Service IP·TTL 은 **당신 환경에서 다르다.** 이 문서는
|
||||
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
B-0 이 답을 냈다. 세션도 토큰도 **인스턴스 메모리**에 있고, 그래서 replica 2 에서는
|
||||
로그인조차 안 된다.
|
||||
|
||||
**처방은 뻔해 보인다 — 공유 저장소를 붙이면 된다.**
|
||||
|
||||
```
|
||||
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
|
||||
↑
|
||||
정말 그런가?
|
||||
```
|
||||
|
||||
이 실험이 재는 것은 **「붙였다」와 「공유된다」 사이의 거리**다.
|
||||
|
||||
| | 물어볼 것 |
|
||||
|---|---|
|
||||
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
|
||||
| **무엇이 안 옮겨졌나** | **같은 곳.** 안 바뀐 것을 확인하는 게 더 중요하다 |
|
||||
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
|
||||
| 사용자에게는 어떻게 보이나 | 브라우저 |
|
||||
|
||||
그리고 배포 첫 시도에서 **쿠버네티스가 내 설정을 덮어쓰는** 함정을 만난다.
|
||||
그게 1절과 2절의 절반이다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 붙이기 전에
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
|
||||
```
|
||||
|
||||
## 1-1. BFF 가 B-0 구성으로 돌고 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||
sudo kubectl -n keycloak-lab get pods -o wide -l app=redis
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
bff-574c6d658b-8cz4x 1/1 Running 0 20m 10.42.0.51 kc-lab-1
|
||||
bff-574c6d658b-zpkbp 1/1 Running 0 20m 10.42.1.52 kc-lab-2
|
||||
redis-568bd7c4-5c5vc 1/1 Running 0 20m 10.42.1.53 kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — BFF 두 개가 **서로 다른 노드**에 있고, Redis 가 떠 있는 것.
|
||||
|
||||
**이 결과가 의미하는 것** — Redis 는 **배포만 되어 있고 아직 연결되지 않았다.**
|
||||
B-0 이 그렇게 만들어 뒀다. 이제 연결한다.
|
||||
|
||||
## 1-2. B-0 의 답을 다시 확인한다 — before 값
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
|
||||
wc -c /tmp/beans-before.json
|
||||
```
|
||||
|
||||
**확인** — 빈 수와 관련 빈 세 개. **미검증**
|
||||
```bash
|
||||
grep -o '"aliases":\[' /tmp/beans-before.json | wc -l
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -iE 'authorizedclient|sessionRepository'
|
||||
```
|
||||
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
|
||||
```
|
||||
빈 수: 321 → 402 (+81)
|
||||
```
|
||||
```
|
||||
authorizedClientService
|
||||
before: InMemoryOAuth2AuthorizedClientService
|
||||
authorizedClientRepository
|
||||
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientManager
|
||||
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 빈 수 **321**, `sessionRepository` 는 **아예 없다.**
|
||||
|
||||
**★ 이 세 줄과 숫자를 적어 둔다.** 4-1 의 비교 대상이 이것이고, **비교 없이는
|
||||
「안 바뀌었다」를 말할 수 없다.**
|
||||
|
||||
## 1-3. Redis 가 비어 있는지 본다
|
||||
|
||||
**확인** — 먼저 살아 있는지
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
PONG
|
||||
# Server
|
||||
redis_version:7.4.x
|
||||
...
|
||||
```
|
||||
|
||||
**확인** — 키가 있나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
(integer) 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`0`.** 비어 있어야 4-2 에서 「내가 만든 것」이라고
|
||||
말할 수 있다.
|
||||
|
||||
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 서버를 블로킹한다. 지금은 키가
|
||||
> 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
|
||||
|
||||
## 1-4. ★ 파드 안 환경변수를 미리 본다 — 함정이 여기 있다
|
||||
|
||||
**아직 아무것도 안 바꿨는데** 파드 안에 Redis 관련 환경변수가 이미 있다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
|
||||
```
|
||||
|
||||
한 번은 통째로 본다. 그다음 걸러 본다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
REDIS_SERVICE_HOST=10.43.57.116
|
||||
REDIS_SERVICE_PORT=6379
|
||||
REDIS_PORT=tcp://10.43.57.116:6379
|
||||
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
|
||||
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
|
||||
REDIS_PORT_6379_TCP_PORT=6379
|
||||
REDIS_PORT_6379_TCP_PROTO=tcp
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`REDIS_PORT` 의 값이 포트 번호가 아니라 URL 이다.**
|
||||
|
||||
### 개념 — Service Links
|
||||
|
||||
**무엇인가.** 쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link
|
||||
시절의 환경변수를 파드에 자동으로 넣는다. 옛 Docker 링크 호환을 위한 기능이고,
|
||||
**기본값이 켜짐**이다.
|
||||
|
||||
**왜 여기 나오나.** Service 이름이 `redis` 이므로 `REDIS_*` 가 들어온다.
|
||||
그리고 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. **이름이 겹친다.**
|
||||
|
||||
```
|
||||
Service 이름이 redis 이면
|
||||
REDIS_SERVICE_HOST=10.43.57.116
|
||||
REDIS_SERVICE_PORT=6379
|
||||
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
|
||||
```
|
||||
|
||||
**`<SVCNAME>_PORT` 는 포트 번호가 아니라 URL 형태다.**
|
||||
|
||||
**없거나 틀리면.** 매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그게 이긴다 —
|
||||
**그런데 명시를 안 하면 자동 주입이 이긴다.** 그리고 오류 메시지는 당신이 쓰지도
|
||||
않은 값을 지목한다.
|
||||
|
||||
**이 결과가 의미하는 것** — **지금은 아무 일도 안 일어난다.** 애플리케이션이
|
||||
그 변수를 안 읽기 때문이다. **다음 절에서 읽기 시작하는 순간 파드가 죽는다.**
|
||||
|
||||
> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 Service 이름일수록 위험하다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — Redis 를 붙인다
|
||||
|
||||
**되돌리기** — 먼저 읽어 둔다
|
||||
```bash
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
## 2-1. 의존성 **두 개**를 함께 넣는다
|
||||
|
||||
```bash
|
||||
vim bff/pom.xml
|
||||
```
|
||||
```xml
|
||||
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
|
||||
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
|
||||
둘 다 있어야 자동구성이 걸린다 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.session</groupId>
|
||||
<artifactId>spring-session-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
**★ 하나만 넣으면 조용히 in-memory 로 남는다.** 오류도 안 난다. 그래서
|
||||
4-1 에서 **찍어서 확인**하는 절차가 필요하다.
|
||||
|
||||
## 2-2. 설정을 넣는다
|
||||
|
||||
```bash
|
||||
vim bff/src/main/resources/application.yml
|
||||
```
|
||||
```yaml
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
session:
|
||||
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
|
||||
timeout: ${SPRING_SESSION_TIMEOUT:30m}
|
||||
redis:
|
||||
namespace: bff:session
|
||||
```
|
||||
|
||||
### 문제 ② — 테스트가 Redis 를 찾다가 죽는다
|
||||
|
||||
`spring-session-data-redis` 를 넣으면 **컨텍스트 기동 시 Redis 에 붙으려 한다.**
|
||||
테스트에는 Redis 가 없다.
|
||||
|
||||
```bash
|
||||
vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
|
||||
```
|
||||
```java
|
||||
@SpringBootTest(properties = {
|
||||
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
|
||||
// 테스트는 Redis 를 띄우지 않는다
|
||||
"spring.session.store-type=none",
|
||||
})
|
||||
```
|
||||
|
||||
**이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다.** 그리고 그 실패 메시지는
|
||||
Redis 연결 오류라서 **「배포 환경 문제」로 읽히기 쉽다.** 실패한 곳은 빌드다.
|
||||
|
||||
## 2-3. ★ 일부러 `enableServiceLinks` 없이 배포한다
|
||||
|
||||
**함정을 직접 본다.** 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서
|
||||
그 오류 메시지를 만나는 것은 다르다.
|
||||
|
||||
```bash
|
||||
vim deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
```yaml
|
||||
spec:
|
||||
# enableServiceLinks: false ← 아직 넣지 않는다
|
||||
containers:
|
||||
- name: bff
|
||||
env:
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis.keycloak-lab.svc
|
||||
# REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
|
||||
```
|
||||
|
||||
**하기** — 빌드하고 두 노드에 밀어 넣고 배포한다
|
||||
```bash
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
echo "exit=$?"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
```
|
||||
|
||||
**되돌리기** — 2-4 가 곧 되돌리기다. 지금 멈추려면:
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout undo deployment/bff
|
||||
```
|
||||
|
||||
### 무엇이 일어나는지 순서대로 본다
|
||||
|
||||
**확인 ①** — 넓게
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
|
||||
```
|
||||
|
||||
**확인 ②** — 왜인지 물어본다. **로그보다 먼저 이벤트를 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab describe pod -l app=bff | tail -20
|
||||
```
|
||||
|
||||
**확인 ③** — 로그. 죽은 뒤라면 `--previous`
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40
|
||||
sudo kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
|
||||
```
|
||||
|
||||
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
|
||||
```
|
||||
Failed to bind properties under 'spring.data.redis.port' to int:
|
||||
Property: spring.data.redis.port
|
||||
Value: "${REDIS_PORT:6379}"
|
||||
Reason: failed to convert java.lang.String to int
|
||||
(caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 마지막 줄의 **`"tcp://10.43.57.116:6379"`.**
|
||||
|
||||
**이 결과가 의미하는 것** — **내가 쓴 적 없는 값이 오류에 나온다.**
|
||||
1-4 에서 미리 본 그 환경변수다. 쿠버네티스가 넣었다.
|
||||
|
||||
> **이 오류를 「Redis 가 안 떠서」로 읽기 쉽다.** 실제로 Redis 는 멀쩡하다.
|
||||
> **파드가 Redis 에 붙어 보지도 못하고 설정 바인딩에서 죽었다.** 메시지가
|
||||
> `Failed to bind properties` 라고 말하고 있다 — 연결 오류가 아니다.
|
||||
|
||||
**확인** — Redis 는 멀쩡한지 확인해 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
PONG
|
||||
```
|
||||
|
||||
## 2-4. 고침 — `enableServiceLinks: false`
|
||||
|
||||
**두 가지 처방이 있다.**
|
||||
|
||||
| 처방 | 문제 |
|
||||
|---|---|
|
||||
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | **다음 사람이 같은 함정에 다시 빠진다** |
|
||||
| **주입 자체를 끈다** | 근본 처방 |
|
||||
|
||||
```bash
|
||||
vim deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
```yaml
|
||||
spec:
|
||||
enableServiceLinks: false # 근본 처방
|
||||
containers:
|
||||
- name: bff
|
||||
env:
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis.keycloak-lab.svc
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
**실측** — [`01-servicelinks-trap.txt`](../../evidence/b1-redis-session-store/01-servicelinks-trap.txt)
|
||||
```
|
||||
deployment.apps/bff configured
|
||||
deployment "bff" successfully rolled out
|
||||
bff-576d869c6d-bshvl true kc-lab-2
|
||||
bff-695646ddb-kzs9k true kc-lab-1
|
||||
bff-695646ddb-vjqzf true kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **세 줄이다.** replica 는 2인데 파드가 3개 보인다.
|
||||
**롤아웃 전환 중에 찍은 것**이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다.
|
||||
잠시 뒤 두 개가 된다.
|
||||
|
||||
**확인** — 주입이 정말 사라졌나
|
||||
```bash
|
||||
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
REDIS_HOST=redis.keycloak-lab.svc
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`REDIS_SERVICE_HOST` 계열이 전부 사라졌고**, 내가 준
|
||||
두 개만 남은 것. `REDIS_PORT` 가 `6379` 다.
|
||||
|
||||
## 2-5. 문제 ③ — 리소스 서버가 아예 없었다
|
||||
|
||||
API 호출이 `500` 이었다. **원인은 토큰이 아니었다.**
|
||||
|
||||
**확인** — 로그를 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
|
||||
```
|
||||
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
|
||||
```
|
||||
java.nio.channels.UnresolvedAddressException
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`UnresolvedAddressException`.** DNS 다.
|
||||
|
||||
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo` 는
|
||||
**`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다.
|
||||
|
||||
```yaml
|
||||
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
|
||||
- name: RESOURCE_API_BASE_URL
|
||||
value: http://echo.header-lab.svc:8081
|
||||
```
|
||||
|
||||
**확인** — 그 서비스가 실제로 있나
|
||||
```bash
|
||||
sudo kubectl -n header-lab get svc echo
|
||||
```
|
||||
|
||||
> **`500` 을 보고 「토큰이 없어서」라고 읽을 뻔했다.** 로그를 보니 DNS 였다.
|
||||
> **증상과 원인을 붙이기 전에 로그를 본다.**
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
## 3-1. 파드가 떴고 Redis 에 붙었나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/health
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`redis` 컴포넌트가 있고 `UP` 인 것.**
|
||||
|
||||
**이 결과가 의미하는 것** — B-0 에서는 이 컴포넌트가 **아예 없었다.**
|
||||
`spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔다.
|
||||
**건강 체크에 새 항목이 생긴 것 자체가 자동구성이 걸렸다는 신호다.**
|
||||
|
||||
## 3-2. 로그인이 되나 — replica 2 에서
|
||||
|
||||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 로 로그인한다.
|
||||
**쿠키를 먼저 지운다.**
|
||||
|
||||
**실측** — [`b1-login-works-two-replicas.png`](../../evidence/b1-redis-session-store/b1-login-works-two-replicas.png)
|
||||
|
||||
**어디를 봐야 하는가** — **로그인이 된다.** B-0 에서 `replica 2` 로는 `/login?error`
|
||||
였던 그 자리다.
|
||||
|
||||
**이 결과가 의미하는 것** — 인가 요청(state·PKCE verifier)이 이제 **Redis** 에
|
||||
있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. **B-0 이 replica 를 1로
|
||||
줄여야 했던 문제는 고쳐졌다.**
|
||||
|
||||
**여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝난다. 그게 이 실험이
|
||||
막으려는 결론이다.**
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰
|
||||
|
||||
## 4-1. ★ 자동구성이 실제로 무엇을 바꿨나 — B-0 의 방법을 그대로
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json
|
||||
grep -o '"aliases":\[' /tmp/beans-after.json | wc -l
|
||||
```
|
||||
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
|
||||
```
|
||||
빈 수: 321 → 402 (+81)
|
||||
```
|
||||
|
||||
**확인** — 세션 저장소 계열. **미검증**
|
||||
```bash
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -iE 'session|redis'
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
--- 세션 저장소 관련 (새로 생긴 것) ---
|
||||
★ cookieSerializer -> DefaultCookieSerializer
|
||||
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
|
||||
★ sessionRepository -> RedisSessionRepository
|
||||
★ springSessionRepositoryFilter -> SessionRepositoryFilter
|
||||
★ redisConnectionFactory -> LettuceConnectionFactory
|
||||
★ redisTemplate -> RedisTemplate
|
||||
```
|
||||
|
||||
**확인** — ★ **안 바뀐 것.** 이쪽이 핵심이다. **미검증**
|
||||
```bash
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -i authorizedclient
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
--- OAuth2 authorized client — 바뀌었는가? ---
|
||||
authorizedClientService
|
||||
before: InMemoryOAuth2AuthorizedClientService
|
||||
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
|
||||
authorizedClientRepository
|
||||
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
|
||||
authorizedClientManager
|
||||
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
|
||||
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
|
||||
```
|
||||
|
||||
**「Redis 를 붙였다」가 「상태가 공유된다」를 뜻하지 않는다.**
|
||||
`spring.session.store-type` 은 **HttpSession** 을 갈아끼우는 설정이고,
|
||||
`OAuth2AuthorizedClient` 는 **그 설정과 무관한 다른 저장소**다.
|
||||
|
||||
> **찍어서 확인하지 않으면 이 사실을 알 방법이 없다.** 로그인은 되고, 화면도
|
||||
> 뜨고, 파드도 건강하다. **B-0 을 실험으로 만든 이유가 이것이다** — before 가
|
||||
> 있어야 after 를 읽는다.
|
||||
|
||||
## 4-2. Redis 안에 무엇이 들어갔나
|
||||
|
||||
**확인** — 키가 생겼나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||
```
|
||||
=== Redis 에 무엇이 들어 있는가 ===
|
||||
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||
총 키 수: 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네임스페이스가 **`bff:session`** 이다. `application.yml`
|
||||
의 `spring.session.redis.namespace` 가 그대로 접두어가 됐다.
|
||||
|
||||
**하기** — 키 이름을 변수로 잡는다
|
||||
```bash
|
||||
KEY=$(sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r')
|
||||
echo "$KEY"
|
||||
```
|
||||
|
||||
**★ `grep -v expires` 가 필요한 이유** — Spring Session 은 만료 추적용 키
|
||||
(`bff:session:expirations:*` · `bff:session:sessions:expires:*`)도 만든다.
|
||||
그것을 잡으면 다음 명령이 빈 결과를 낸다.
|
||||
|
||||
**★ `tr -d '\r'`** — `redis-cli` 출력이 CR 을 달고 올 수 있다. 그대로 쓰면 키가
|
||||
안 맞는데 오류는 안 난다.
|
||||
|
||||
**확인** — 타입과 필드
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
|
||||
```
|
||||
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||
```
|
||||
타입: hash
|
||||
필드: sessionAttr:SPRING_SECURITY_CONTEXT
|
||||
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
|
||||
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
|
||||
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
|
||||
필드: lastAccessedTime
|
||||
필드: maxInactiveInterval
|
||||
필드: creationTime
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **필드 목록에 토큰이 없다.**
|
||||
|
||||
### ★ refresh token 은 Redis 에 **없다**
|
||||
|
||||
「저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다」가 검증 항목이었다.
|
||||
**답은 더 앞에 있었다 — 애초에 들어가지 않는다.**
|
||||
|
||||
**「토큰 암호화를 어떻게 할까」를 고민하기 전에, 토큰이 그 저장소에 가지도
|
||||
않는다는 것을 먼저 알아야 한다.**
|
||||
|
||||
**확인** — TTL
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
|
||||
```
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
=== TTL (Q3 검증 3번 — session TTL) ===
|
||||
TTL: 1772 초
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `1772`. `spring.session.timeout=30m`(1800초)에서 방금
|
||||
지난 만큼 줄어든 값이다.
|
||||
|
||||
**이 결과가 의미하는 것** — **세션 TTL 1772초와 access token 수명 60초가 처음부터
|
||||
어긋나 있다.** 어느 쪽에 맞출지는 선택이 아니라 **이미 어긋나 있고 그 간극을
|
||||
누가 메우는가**의 문제다. B-3 의 주제다.
|
||||
|
||||
## 4-3. 직렬화는 Java 네이티브다
|
||||
|
||||
**확인** — 값의 바이트를 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
|
||||
```
|
||||
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||
```
|
||||
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
|
||||
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`\xac\xed` 로 시작한다.**
|
||||
|
||||
> **`--no-raw` 를 쓰는 이유** — 바이너리를 이스케이프해서 보여준다. 안 쓰면
|
||||
> 터미널이 제어문자를 먹고 화면이 깨진다.
|
||||
|
||||
**이 결과가 의미하는 것** — `\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다.
|
||||
|
||||
| 결과 | |
|
||||
|---|---|
|
||||
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
|
||||
| **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 |
|
||||
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
|
||||
|
||||
**D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이 바뀌면
|
||||
Redis 에 남은 세션이 깨질 수 있다.
|
||||
|
||||
## 4-4. ★ 사용자에게는 어떻게 보이나 — 가장 중요한 부분
|
||||
|
||||
**하기** — 파드를 전부 교체한다. Redis 덕을 보는지 확인하는 것이다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
|
||||
|
||||
**하기** — **로그인은 그대로 둔 채** 브라우저에서
|
||||
`https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
|
||||
|
||||
**실측** — [`b1-token-boundary-after-redis.png`](../../evidence/b1-redis-session-store/b1-token-boundary-after-redis.png)
|
||||
```json
|
||||
{"pattern":"AP3-backend-for-frontend",
|
||||
"principal":"labuser", ← 세션은 Redis 에서 복원되었다
|
||||
"accessTokenStoredOnServer":false, ← 토큰은 사라졌다
|
||||
"refreshTokenStoredOnServer":false,
|
||||
"browserTokenCount":0,
|
||||
"csrfProtectionEnabled":true}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`principal` 은 살아 있는데 두 토큰이 `false` 다.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
사용자 관점: 로그인되어 있다고 나온다
|
||||
실제: BFF 가 사용자를 대신해 아무것도 못 한다
|
||||
```
|
||||
|
||||
**파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다.
|
||||
**그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까.
|
||||
|
||||
> **이것이 「부분적으로만 공유했을 때」의 실패 모양이다.**
|
||||
> **완전히 로그아웃되는 편이 차라리 낫다** — 적어도 사용자가 다시 로그인한다.
|
||||
> 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
|
||||
|
||||
### B-0 과 나란히 놓으면
|
||||
|
||||
| | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** |
|
||||
|---|---|---|
|
||||
| `principal` | labuser | labuser |
|
||||
| `accessTokenStoredOnServer` | **true** | **false** |
|
||||
| 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** |
|
||||
|
||||
> **★ 스크린샷으로 시점을 구별하지 않는다.**
|
||||
> [`README.md`](../../evidence/b1-redis-session-store/README.md) 가 적어 둔 대로,
|
||||
> `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는
|
||||
> **동일 파일**이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은
|
||||
> 화면이었기 때문이다. **시점 구별은 터미널 출력과 Redis/DB 조회가 한다.**
|
||||
|
||||
## 4-5. 그래서 무엇을 해야 하는가
|
||||
|
||||
`OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 **따로** 필요하다.
|
||||
|
||||
| 후보 | |
|
||||
|---|---|
|
||||
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** |
|
||||
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
|
||||
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
|
||||
|
||||
**세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다.
|
||||
세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.**
|
||||
대신 세션이 커진다. **[B-2](b2-multi-instance-session.md) 에서
|
||||
이 선택지를 비교한다.**
|
||||
|
||||
**「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고,
|
||||
나뉜 채로 두면 깨진다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. B-2 로 이어갈 것이면 그대로 둔다
|
||||
|
||||
이 구성이 B-2 의 출발점이다. **아무것도 안 되돌린다.**
|
||||
|
||||
## 5-2. B-0 상태로 되돌릴 때
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
git status --short
|
||||
```
|
||||
|
||||
**★ 되돌린 뒤에는 다시 빌드해서 다시 밀어 넣어야 한다.** 소스만 되돌리면
|
||||
클러스터에는 여전히 옛 이미지가 돈다.
|
||||
```bash
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
## 5-3. Redis 를 비운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
```
|
||||
|
||||
**되돌리기** — **없다.** 지운 세션은 돌아오지 않는다. 로그인한 사용자는 전부
|
||||
로그아웃된다. **실험대라서 하는 일이다.**
|
||||
|
||||
세션 하나만 지우고 싶으면:
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
|
||||
```
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
|
||||
| Redis | `sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
|
||||
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
|
||||
| Keycloak | `sudo kubectl -n keycloak-lab get pods \| grep keycloak` | 둘 다 `1/1 Running` |
|
||||
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
|
||||
| 임시 파일 | `rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log` | — |
|
||||
|
||||
> **이 실험이 재지 않은 것 셋**
|
||||
> - **Redis 를 끊었을 때 무엇이 나는지** — B-5 의 주제다
|
||||
> - **로그아웃 뒤 두 저장소에 무엇이 남는지** — B-2 로 넘긴다
|
||||
> - **저장소 지연이 화면 지연으로 얼마나 번역되는지** — B-2 이후
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| **파드가 `CrashLoopBackOff`, 오류에 `tcp://...:6379`** | **쿠버네티스가 `REDIS_PORT` 를 주입했다** | `printenv \| grep -i redis` — 1-4 · 2-3 |
|
||||
| 위 오류를 「Redis 가 죽어서」로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
|
||||
| `enableServiceLinks` 를 넣었는데 그대로 | 파드가 아직 옛 것이다 | `rollout restart` 후 `printenv` 다시 |
|
||||
| 빌드가 Redis 연결 오류로 죽는다 | **테스트가 Redis 를 찾는다** | `spring.session.store-type=none` — 2-2 |
|
||||
| `sessionRepository` 가 안 생긴다 | **의존성을 하나만 넣었다.** 오류 없이 in-memory 로 남는다 | 두 개 다 있는지 `pom.xml` — 2-1 |
|
||||
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
|
||||
| `hkeys` 가 빈 결과 | **만료 추적 키를 잡았다** | `grep -v expires` — 4-2 |
|
||||
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` — 4-2 |
|
||||
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` — 4-3 |
|
||||
| API 호출이 `500` 인데 토큰은 멀쩡 | **DNS 다.** 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` — 2-5 |
|
||||
| `/actuator/beans` 가 `Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 — 4-1 |
|
||||
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 4-1 |
|
||||
| 로그인은 되는데 API 가 전부 실패 | **이게 이 실험의 결론이다** | `token-boundary` 의 두 `false` — 4-4 |
|
||||
| 스크린샷으로 시점을 구별하려다 헷갈린다 | **두 파일이 동일하다** | 터미널 출력과 Redis 조회로 구별 — 4-4 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-1 이 남긴 것 |
|
||||
|---|---|
|
||||
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 4-5 의 세 후보를 비교한다 |
|
||||
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** |
|
||||
| [B-5](../../experiment-b5-redis-loss-persistence.md) Redis 소실 | 이제 잃을 것이 생겼다. `/data` 가 볼륨인지부터 본다 |
|
||||
| [D-2](d2-version-upgrade.md) 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 |
|
||||
| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |
|
||||
@@ -0,0 +1,869 @@
|
||||
# B-2 재현 가이드 — 저장소를 옮겨도 안 고쳐지는 것을 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md) ·
|
||||
증거 원문: [`docs/evidence/b2-multi-instance-session/`](../../evidence/b2-multi-instance-session/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 파드는 `1/1 Running` 인데 테이블이 없는 상태 | `psql` 의 `Did not find any relation` |
|
||||
| 조회 키에 session id 가 **없다**는 것 | `\d oauth2_authorized_client` 의 `PRIMARY KEY` |
|
||||
| `bytea` 안에 든 **평문 JWT** | `convert_from(refresh_token_value,'UTF8')` |
|
||||
| 두 번째 로그인이 같은 행을 덮어쓰는 것 | 행 수 1 그대로 · `md5` 만 바뀜 |
|
||||
| 로그아웃이 **셋 중 하나만** 지우는 것 | Redis 0 키 · PostgreSQL 1 행 · Keycloak 2 세션 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- [`B-0`](b0-bff-redis-deploy.md) · [`B-1`](b1-redis-session-store.md)
|
||||
이 끝나 **BFF 가 replica 2개**로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||||
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||||
- **브라우저가 필요하다.** BFF 는 authorization code 흐름이라 로그인을
|
||||
`curl` 로 만들 수 없다. `https://app1.hyeonworks.com/` 에 붙어
|
||||
`labuser` / `labpass` 로 들어간다. realm 은 `keycloak-patterns`.
|
||||
- 터미널 하나와 브라우저 창 하나를 나란히 둔다. 브라우저에서 버튼을 누르고
|
||||
터미널에서 저장소를 세는 왕복이 이 실험의 전부다.
|
||||
|
||||
## 주의 — 이건 상태를 바꾸는 실험이다
|
||||
|
||||
DDL 을 태우고, Redis 세션을 지우고, 로그아웃한다. **실험대에서만 한다.**
|
||||
전 구간 약 25분이고, 되돌리는 방법은 매 단계에 적어 두었다.
|
||||
중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:09–14:13 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 UUID·해시는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||
|
||||
세션 UUID·md5·타임스탬프는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||||
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
|
||||
전부 위 실행 기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
B-1 이 **Application Session 만** Redis 로 옮겼다. 그러자 사용자는 로그인
|
||||
상태로 보이는데 **BFF 에는 access token 이 없는** 상태가 만들어졌다.
|
||||
세션과 토큰이 서로 다른 것에 들어 있고, 한쪽만 옮겼기 때문이다.
|
||||
|
||||
토큰도 공유 저장소로 옮기면 그건 고쳐진다. 문제는 **무엇이 같이 고쳐지고
|
||||
무엇이 안 고쳐지는가**다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 통념 | 공유 저장소로 옮기면 **다중 인스턴스 문제가 해결된다** |
|
||||
| B-2 모델 | 인스턴스 간 공유만 해결되고 **브라우저 간 격리와 로그아웃 정리는 그대로** |
|
||||
|
||||
> **개념 — 「어디에 두는가」와 「어떻게 찾는가」는 독립이다.**
|
||||
>
|
||||
> ```
|
||||
> 저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
|
||||
> 조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
|
||||
> ```
|
||||
>
|
||||
> 이 실험이 판정하는 것은 두 번째다. 그리고 **키는 코드가 아니라 스키마에
|
||||
> 박혀 있다** — 그래서 「구현을 바꾸면 되겠지」로 넘어갈 수 없다.
|
||||
> 1-3 에서 그 줄을 직접 본다.
|
||||
|
||||
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데
|
||||
**셋이 서로 다른 시스템에 있다.**
|
||||
|
||||
```
|
||||
① HttpSession Redis Spring Security 가 지운다
|
||||
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
|
||||
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 두 번째 로그인을 만들기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 덮어쓰기를 보려면 **덮어쓰이기 전의
|
||||
행**이 있어야 하고, 로그아웃 정리를 보려면 **로그아웃 전의 세 숫자**가 있어야
|
||||
한다. 넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
|
||||
```
|
||||
|
||||
## 1-1. 파드와 노드
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**형태** — IP 와 해시는 당신 것과 다르다
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
bff-555df79c97-6j86w 1/1 Running 0 44s 10.42.0.52 kc-lab-1
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 22s 10.42.1.124 kc-lab-2
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||||
```
|
||||
`10.42.0.52` 와 `10.42.1.124` 는 [B-5 의 증거](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||
에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가
|
||||
`nodeSelector` 로 **`kc-lab-2` 에 고정**해 둔다.
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- `bff` 가 **두 개**이고 `READY` 가 둘 다 `1/1`
|
||||
- **`NODE` 가 서로 다르다** — 같은 노드에 몰려 있으면 「다른 인스턴스」가
|
||||
같은 커널 위의 다른 프로세스일 뿐이다. 매니페스트의
|
||||
`topologySpreadConstraints` 가 이걸 벌려 놓는다
|
||||
- `RESTARTS` 가 `0` — 뒤에서 이 값이 오르면 내가 건드린 것이 엉뚱한 데 닿은 것이다
|
||||
|
||||
**이 결과가 의미하는 것** — 이 실험의 질문(Q1)은 **요청이 로그인을 처리하지
|
||||
않은 인스턴스에 떨어질 수 있어서** 생긴다. replica 가 하나면 질문 자체가
|
||||
성립하지 않는다.
|
||||
|
||||
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
|
||||
```
|
||||
deployment.apps/bff configured
|
||||
deployment "bff" successfully rolled out
|
||||
bff-555df79c97-6j86w 1/1 Running 0 44s
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 22s
|
||||
```
|
||||
위 두 줄은 배포 명령이 같이 찍은 것이다. 파드 줄만 나오면 정상이다.
|
||||
|
||||
## 1-2. 테이블이 실제로 있는가 — 없으면 여기서 멈춘다
|
||||
|
||||
**이 실험은 원래 여기서 한 번 넘어졌다.** 파드는 떴고 Hikari 도 붙었는데
|
||||
테이블이 없었다. 그리고 **아무도 그것을 신고하지 않았다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||||
```
|
||||
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
|
||||
```
|
||||
=== oauth2_authorized_client 테이블이 생겼는가 ===
|
||||
Did not find any relation named "oauth2_authorized_client".
|
||||
command terminated with exit code 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 이 두 줄이 나오면 **아직 아무것도 저장되지 않는
|
||||
상태**다. 테이블이 있으면 컬럼 표가 나온다(1-3).
|
||||
|
||||
**이 결과가 의미하는 것** — 스키마 초기화가 **조용히 실패**했다.
|
||||
|
||||
> **개념 — `blob` 은 PostgreSQL 에 없는 타입이다.**
|
||||
>
|
||||
> Spring Security 는 DDL 을 **두 벌** 번들한다.
|
||||
>
|
||||
> | 파일 | 토큰 컬럼 타입 |
|
||||
> |---|---|
|
||||
> | `oauth2-client-schema.sql` | `access_token_value blob NOT NULL` |
|
||||
> | `oauth2-client-schema-postgres.sql` | `access_token_value bytea NOT NULL` |
|
||||
>
|
||||
> 기본 판본을 그대로 태우면 `blob` 에서 문법 오류가 난다. 그리고
|
||||
> `spring.sql.init.continue-on-error: true` 가 켜져 있으면 **그 실패가
|
||||
> 삼켜지고 파드는 정상으로 보인다.**
|
||||
>
|
||||
> `continue-on-error` 는 **「없어도 되는 초기화」에만** 쓴다. 여기서는
|
||||
> 없으면 안 되는 초기화였다.
|
||||
>
|
||||
> **정정 노트** — 해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언
|
||||
> 차이" 였다가 정정됐다. **Liquibase 가 아니다.** 여기서 스키마를 태우는
|
||||
> 것은 Spring Boot 의 `spring.sql.init` 이고, DDL 은
|
||||
> `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는
|
||||
> Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
|
||||
|
||||
### 없으면 만든다 — 이건 명령이 아니라 파일이다
|
||||
|
||||
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 **파일로 만든다.** 터미널에
|
||||
붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
vim /tmp/oauth2-pg.sql
|
||||
```
|
||||
```sql
|
||||
-- file: /tmp/oauth2-pg.sql
|
||||
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
|
||||
CREATE TABLE oauth2_authorized_client (
|
||||
client_registration_id varchar(100) NOT NULL,
|
||||
principal_name varchar(200) NOT NULL,
|
||||
access_token_type varchar(100) NOT NULL,
|
||||
access_token_value bytea NOT NULL,
|
||||
access_token_issued_at timestamp NOT NULL,
|
||||
access_token_expires_at timestamp NOT NULL,
|
||||
access_token_scopes varchar(1000) DEFAULT NULL,
|
||||
refresh_token_value bytea DEFAULT NULL,
|
||||
refresh_token_issued_at timestamp DEFAULT NULL,
|
||||
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (client_registration_id, principal_name)
|
||||
);
|
||||
```
|
||||
**실측** — 위 DDL 은 [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||||
의 `=== PostgreSQL 전용 스키마 ===` 절 원문이다.
|
||||
|
||||
**하기** — 태운다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
|
||||
```
|
||||
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||||
```
|
||||
=== 적용 ===
|
||||
CREATE TABLE
|
||||
```
|
||||
|
||||
**되돌리기** — **이 표는 B-3 이후로도 계속 쓰므로 평소에는 지우지 않는다.**
|
||||
정말 처음 상태로 되돌리려면:
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
|
||||
```
|
||||
|
||||
> `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안
|
||||
> 일어나고 오류도 안 난다 — `kubectl exec` 는 stdin 을 기본으로 연결하지
|
||||
> 않는다.
|
||||
|
||||
## 1-3. 기본키를 눈으로 본다 — 이 실험의 답이 여기 박혀 있다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||||
```
|
||||
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||||
```
|
||||
Table "public.oauth2_authorized_client"
|
||||
Column | Type | Collation | Nullable | Default
|
||||
-------------------------+-----------------------------+-----------+----------+-------------------------
|
||||
client_registration_id | character varying(100) | | not null |
|
||||
principal_name | character varying(200) | | not null |
|
||||
access_token_type | character varying(100) | | not null |
|
||||
access_token_value | bytea | | not null |
|
||||
access_token_issued_at | timestamp without time zone | | not null |
|
||||
access_token_expires_at | timestamp without time zone | | not null |
|
||||
access_token_scopes | character varying(1000) | | | NULL::character varying
|
||||
refresh_token_value | bytea | | |
|
||||
refresh_token_issued_at | timestamp without time zone | | |
|
||||
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
|
||||
Indexes:
|
||||
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **맨 아래 `Indexes:` 줄** 하나다.
|
||||
|
||||
```
|
||||
PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||
└── "keycloak" ──┘ └── "labuser" ──┘
|
||||
세션 id 가 없다
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든
|
||||
`(keycloak, labuser)` 라는 **한 행**을 쓴다. B-0 에서 빈 이름
|
||||
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작했던 것이
|
||||
**테이블 정의로 확정된다.**
|
||||
|
||||
**저장소를 Redis 로 바꿔도, 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.**
|
||||
4-1 에서 그것을 눈으로 확인한다.
|
||||
|
||||
## 1-4. 세 저장소를 세는 명령을 정해 둔다
|
||||
|
||||
4절에서 이 세 숫자를 **로그아웃 전후로** 비교한다. 지금 형태를 확정해 두고,
|
||||
매번 같은 명령을 친다. 다른 명령으로 재면 비교가 아니다.
|
||||
|
||||
**확인 ①** — Redis 의 BFF 세션
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
```
|
||||
**형태** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||
의 B-1 측정과 같은 모양이다
|
||||
```
|
||||
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||
```
|
||||
|
||||
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 Redis 를 잡아 두고 전 키를
|
||||
> 훑는다. `--scan` 은 커서로 나눠 돌아 블로킹하지 않는다.
|
||||
>
|
||||
> **`dbsize` 는 이 실험에서 부정확하다.** Redis 하나를 BFF 와 oauth2-proxy
|
||||
> (B-7)가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다.
|
||||
> **접두어로 걸러 세는 것**이 맞다.
|
||||
|
||||
**확인 ②** — PostgreSQL 의 authorized client
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
|
||||
```
|
||||
|
||||
**확인 ③** — Keycloak 의 SSO 세션. 온라인 세션도 `offline_user_session` 에
|
||||
`offline_flag = 0` 으로 들어 있다(B-3 에서 확인된 성질이다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak \
|
||||
-c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
**미검증** — 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다.
|
||||
같은 숫자를 DB 쪽에서 보는 형태다. 관리 API 로 보려면:
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get client-session-stats -r keycloak-patterns
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
|
||||
## 1-5. 브라우저로 로그인하고 대조군 행을 잡는다
|
||||
|
||||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 열고
|
||||
**Keycloak 로그인** 을 눌러 `labuser` / `labpass` 로 들어간다. 그다음
|
||||
**token 경계 확인** 을 누른다.
|
||||
|
||||
**실측** — 해설 문서 3절
|
||||
```json
|
||||
{"principal":"labuser",
|
||||
"accessTokenStoredOnServer":true, ← B-1 에서는 false 였다
|
||||
"refreshTokenStoredOnServer":true,
|
||||
"browserTokenCount":0}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `accessTokenStoredOnServer` 가 `true`.
|
||||
`browserTokenCount` 가 `0` 인 것이 BFF 패턴의 정의다 — **토큰이 브라우저에
|
||||
없다.**
|
||||
|
||||
**이 결과가 의미하는 것** — B-1 에서는 이 값이 `false` 로 나올 수 있었다.
|
||||
authorized client 가 프로세스 메모리에 있어 **로그인을 처리하지 않은 replica**
|
||||
가 답하면 아무것도 못 찾았기 때문이다. 지금은 두 replica 가 같은 PostgreSQL
|
||||
행을 본다.
|
||||
|
||||
> 이 값이 지금도 `false` 로 나온다면 **테이블은 만들었는데 옛 세션을 쓰고
|
||||
> 있는 것**이다. 증거의 [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png)
|
||||
> 가 정확히 그 상태다. 로그아웃하고 다시 로그인한다.
|
||||
|
||||
**확인** — 지금 행을 잡아 둔다. **이 md5 와 `issued_at` 이 대조군이다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select client_registration_id, principal_name, access_token_issued_at,
|
||||
md5(access_token_value) as at_md5
|
||||
from oauth2_authorized_client"
|
||||
```
|
||||
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||
```
|
||||
=== [현재] 같은 사용자의 항목 ===
|
||||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||
------------------------+----------------+----------------------------+----------------------------------
|
||||
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
|
||||
(1 row)
|
||||
|
||||
행 수: 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `(1 row)` 와 `at_md5`. **둘 다 종이에 적어 둔다.**
|
||||
2절 뒤에 이 두 값을 다시 본다.
|
||||
|
||||
> **왜 토큰 값이 아니라 md5 인가.** 값 자체는 **지금 쓸 수 있는 자격증명**
|
||||
> 이라 터미널 스크롤백에 남기면 안 된다. md5 는 「같은가 다른가」만 답하고
|
||||
> 그게 이 절이 물어보는 전부다.
|
||||
|
||||
크기도 같이 봐 둔다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select client_registration_id, principal_name, access_token_type,
|
||||
length(access_token_value) as at_len, length(refresh_token_value) as rt_len
|
||||
from oauth2_authorized_client"
|
||||
```
|
||||
**실측** — 해설 문서 3절. (이 표는 `.txt` 증거에는 없고 문서에만 남아 있다)
|
||||
```
|
||||
client_registration_id | principal_name | access_token_type | at_len | rt_len
|
||||
------------------------+----------------+-------------------+--------+--------
|
||||
keycloak | labuser | Bearer | 1431 | 744
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 「두 번째 브라우저」를 만든다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 방법을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 로그인하면
|
||||
새 세션이 만들어지고 원래 상태로 돌아온다.
|
||||
|
||||
## 2-1. 실제로는 브라우저를 두 개 쓰지 않는다 — 왜 등가인가
|
||||
|
||||
증거 [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||
에는 이렇게 적혀 있다.
|
||||
|
||||
**실측**
|
||||
```
|
||||
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
|
||||
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
|
||||
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
|
||||
```
|
||||
|
||||
**「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」
|
||||
이었다.** 조회 키가 `(clientRegistrationId, principalName)` 이므로
|
||||
**브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가**다.
|
||||
|
||||
> **다만 등가인 이유를 알고 쓰는 것과 모르고 쓰는 것은 다르다.**
|
||||
> 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, **측정하지 않은 것을
|
||||
> 측정한 것처럼 적었다**고 정정했다. 진짜로 두 브라우저를 쓰고 싶으면
|
||||
> 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 된다 — 결과는 같아야
|
||||
> 하고, 다르면 그게 더 중요한 발견이다.
|
||||
|
||||
## 2-2. Redis 의 BFF 세션만 지운다
|
||||
|
||||
**지우기 전에 무엇을 지울지 눈으로 본다.** 이 Redis 는 BFF 혼자 쓰는 것이
|
||||
아니다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
bff:session:sessions:c63c39ee-...
|
||||
bff:session:expires:c63c39ee-...
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `_oauth2_proxy-` 로 시작하는 키가 섞여 있는지.
|
||||
있으면 **`FLUSHALL` 을 치면 안 된다** — B-7 의 oauth2-proxy 세션까지 날아가
|
||||
그쪽 실험이 오염된다. 접두어로 골라 지운다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
|
||||
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||||
date '+%H:%M:%S 세션 삭제'
|
||||
```
|
||||
**미검증** — 원래 실행은 스크립트였다. 이 형태는
|
||||
[후속 문서 §3](../../experiment-followup-untested-items.md) 이 oauth2-proxy
|
||||
세션을 지울 때 쓴 것과 같은 모양이다.
|
||||
|
||||
**형태**
|
||||
```
|
||||
(integer) 2
|
||||
16:21:03 세션 삭제
|
||||
```
|
||||
|
||||
**시각을 적어 둔다.** 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로
|
||||
「새 로그인이 실제로 일어났는가」를 판정한다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
## 3-1. Redis 에서 BFF 세션만 사라졌나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 첫 명령은 **아무것도 안 나와야** 하고, 두 번째는
|
||||
**아까와 같아야** 한다. 두 번째까지 비었으면 `FLUSHALL` 을 친 것이고,
|
||||
B-7 세션을 날린 것이다.
|
||||
|
||||
## 3-2. 파드를 죽이지 않았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** 세션을 지우는 것은
|
||||
BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고,
|
||||
그 상태로 재면 「덮어쓰기」가 아니라 「파드 재시작」을 재게 된다.
|
||||
|
||||
## 3-3. 다음 요청이 정말 새 로그인을 만드는가
|
||||
|
||||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고
|
||||
**token 경계 확인** 을 누른다.
|
||||
|
||||
**어디를 봐야 하는가** — **로그인 화면이 뜨지 않고 그냥 들어가진다.**
|
||||
|
||||
**이 결과가 의미하는 것** — Redis 세션은 지워졌지만 **Keycloak SSO 세션은
|
||||
살아 있다.** 그래서 BFF 가 `/oauth2/authorization/keycloak` 으로 보내면
|
||||
Keycloak 이 화면 없이 즉시 코드를 돌려주고, **새 로그인 한 벌이 조용히
|
||||
만들어진다.** 이것이 「모의 두 번째 브라우저」다.
|
||||
|
||||
> **이 조용한 재인증이 6절에서 다시 나온다.** 여기서는 편리하지만
|
||||
> 로그아웃 뒤에는 「로그아웃했는데 다시 들어가진다」로 보인다.
|
||||
> **같은 성질의 양면**이다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 행이 늘었는가, 덮어써졌는가
|
||||
|
||||
**확인** — 1-5 와 **똑같은 명령**을 친다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select client_registration_id, principal_name, access_token_issued_at,
|
||||
md5(access_token_value) as at_md5
|
||||
from oauth2_authorized_client"
|
||||
```
|
||||
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||
```
|
||||
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
|
||||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||
------------------------+----------------+----------------------------+----------------------------------
|
||||
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
|
||||
(1 row)
|
||||
|
||||
행 수: 1
|
||||
|
||||
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지를 **한꺼번에** 본다.
|
||||
|
||||
| 값 | 대조군(1-5) | 지금 | 읽는 법 |
|
||||
|---|---|---|---|
|
||||
| 행 수 | `(1 row)` | `(1 row)` | **INSERT 가 아니다** |
|
||||
| `at_md5` | `675af228…` | `e19a63fc…` | **내용은 바뀌었다** |
|
||||
| `issued_at` | `05:10:46` | `05:12:13` | 2-2 의 삭제 시각 뒤 = 새 로그인 맞다 |
|
||||
|
||||
셋 중 하나만 보면 틀린다. 행 수만 보면 「아무 일도 없었다」로, md5 만 보면
|
||||
「새 행이 생겼나?」로 읽힌다.
|
||||
|
||||
**이 결과가 의미하는 것** — **UPDATE 다.**
|
||||
|
||||
```
|
||||
브라우저 A 로그인 → (keycloak, labuser) 행 생성
|
||||
브라우저 B 로그인 → 같은 행을 덮어쓴다
|
||||
└─ A 의 토큰은 사라진다
|
||||
```
|
||||
|
||||
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
|
||||
당장은 아무 증상이 없다. 증상은 나중에 나온다.
|
||||
|
||||
| 언제 문제가 되는가 | |
|
||||
|---|---|
|
||||
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
|
||||
| refresh 회전이 켜져 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → [B-3](b3-refresh-token-contention.md) |
|
||||
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
|
||||
|
||||
## 4-2. 저장소를 바꾸면 고쳐지나 — 안 고쳐진다
|
||||
|
||||
**1-3 의 `PRIMARY KEY` 줄을 다시 본다.** 그 줄이 답이다.
|
||||
|
||||
```
|
||||
InMemory → PostgreSQL → Redis → 직접 구현
|
||||
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
|
||||
```
|
||||
|
||||
**고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라
|
||||
`OAuth2AuthorizedClientRepository` 쪽 이야기다.**
|
||||
|
||||
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|
||||
|---|---|---|
|
||||
| `JdbcOAuth2AuthorizedClientService` | **불필요** (같은 인터페이스) | 안 고쳐짐 |
|
||||
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
|
||||
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
|
||||
|
||||
**이 실험이 두 번째를 고르지 않은 이유**는 Q3 가 "Redis 와 JDBC 중 무엇"을
|
||||
물었기 때문이고, 그 대가로 조회 키 문제가 남았다. **선택이 남긴 자국을
|
||||
측정한 것**이지 실수가 아니다.
|
||||
|
||||
## 4-3. 저장된 것이 평문인가
|
||||
|
||||
**먼저 길이만 본다.** 값을 찍기 전에 「무엇을 찍게 될지」를 알아야 한다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select length(refresh_token_value) from oauth2_authorized_client"
|
||||
```
|
||||
**실측** — 해설 문서 3절의 `rt_len`
|
||||
```
|
||||
744
|
||||
```
|
||||
|
||||
**744 바이트다.** 암호화된 덩어리라면 여기서 알 수 없다. 앞 몇 글자만 본다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"
|
||||
```
|
||||
**미검증** — 원래 실행은 앞 200자 남짓을 통째로 찍었다. 이 형태는 **화면에
|
||||
남는 양을 줄인** 것이다.
|
||||
|
||||
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
|
||||
의 앞부분(원래 실행이 찍은 길이 그대로)
|
||||
```
|
||||
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
|
||||
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`eyJ` 로 시작한다.** 그것이 `{"` 의 base64 다.
|
||||
JWT 는 예외 없이 이렇게 시작한다.
|
||||
|
||||
> **`convert_from` 이 성공한다는 것 자체가 답이다.** 암호화된 바이트라면
|
||||
> UTF-8 로 디코드되지 않고 오류가 난다. **읽힌다 = 텍스트다.**
|
||||
|
||||
정말 JWT 인지 헤더를 풀어 본다.
|
||||
|
||||
**확인** — **미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
|
||||
| cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
|
||||
```
|
||||
=== 저장된 바이트를 그대로 디코드한 결과 ===
|
||||
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
|
||||
refresh_token 페이로드(앞부분):
|
||||
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||||
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||
|
||||
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
|
||||
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는
|
||||
토큰을 얻는다.** 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든
|
||||
그대로 쓸 수 있다. `Spring Security 기본 구현은 저장 시 암호화하지 않는다.`
|
||||
암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
|
||||
|
||||
> **원래 실행은 여기서 한 번 넘어졌다.** 증거 파일에 그 실패가 그대로 있다.
|
||||
> ```
|
||||
> === 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
|
||||
> File "<string>", line 3
|
||||
> h=open(/tmp/hdr.txt).read().strip()
|
||||
> ^
|
||||
> SyntaxError: invalid syntax
|
||||
> ```
|
||||
> **파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨린 것**이다. 셸 안에
|
||||
> 프로그램을 밀어 넣으면 이렇게 된다 — 문법 오류가 측정 결과 자리에
|
||||
> 남는다. `cut` 과 `base64 -d` 로 충분하고, 그건 문법이 틀릴 자리가 없다.
|
||||
|
||||
## 4-4. 로그아웃 — 세 저장소를 한 번에 센다
|
||||
|
||||
**로그아웃 전에 세 숫자를 먼저 잡는다.** 1-4 에서 정한 명령 그대로다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
|
||||
```
|
||||
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||
```
|
||||
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
|
||||
로그아웃 전
|
||||
Redis: 1 키
|
||||
PostgreSQL: 1 행
|
||||
```
|
||||
|
||||
**하기** — 로그아웃한다. **화면에 로그아웃 버튼이 없다** —
|
||||
`index.html` 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은
|
||||
CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다
|
||||
(`F12` → Console, 로그인된 app1 탭에서).
|
||||
|
||||
**미검증**
|
||||
```js
|
||||
const csrf = await (await fetch('/bff/csrf')).json();
|
||||
const token = decodeURIComponent(
|
||||
document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
|
||||
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
|
||||
console.log(r.status, r.url);
|
||||
```
|
||||
|
||||
> **왜 셸이 아니라 브라우저인가** — 세션 쿠키가 `HttpOnly` 라 `curl` 로
|
||||
> 로그인 상태를 재현할 수 없다. `XSRF-TOKEN` 쿠키만 JS 가 읽을 수 있게
|
||||
> 되어 있고(`CookieCsrfTokenRepository.withHttpOnlyFalse()`), 그래서 이
|
||||
> 조각이 성립한다. 해설 문서 8절은 같은 일을 **form 파라미터 `_csrf`** 로
|
||||
> 적었다 — 어느 쪽이든 `SpaCsrfTokenRequestHandler` 가 받아 준다.
|
||||
|
||||
**되돌리기** — 브라우저에서 다시 로그인한다.
|
||||
|
||||
**확인** — 로그아웃 후, **같은 세 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select principal_name, access_token_issued_at, access_token_expires_at
|
||||
from oauth2_authorized_client"
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
**실측** — [`05-logout-cleanup.txt`](../../evidence/b2-multi-instance-session/05-logout-cleanup.txt)
|
||||
```
|
||||
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
|
||||
Redis 세션 : 0 키
|
||||
PostgreSQL 토큰 : 1 행
|
||||
|
||||
principal_name | access_token_issued_at | access_token_expires_at
|
||||
----------------+----------------------------+----------------------------
|
||||
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
|
||||
(1 row)
|
||||
|
||||
|
||||
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
|
||||
|
||||
=== Keycloak 쪽 SSO 세션은? ===
|
||||
Keycloak 온라인 세션: 2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 숫자를 나란히 놓는다.
|
||||
|
||||
```
|
||||
로그아웃 후:
|
||||
Redis 세션 : 0 키 ← 정리됨
|
||||
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
|
||||
Keycloak SSO : 2 세션 ← 남아 있다
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **셋 중 하나만 지워졌다.**
|
||||
|
||||
```
|
||||
로그아웃
|
||||
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
|
||||
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
|
||||
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
|
||||
```
|
||||
|
||||
**남은 행의 `access_token_expires_at` 이 `issued_at` 의 60초 뒤**인 것도 같이
|
||||
본다(`accessTokenLifespan=60`). **access token 은 이미 만료됐지만 같은 행의
|
||||
refresh token 은 아직 살아 있다** — 그리고 그건 4-3 에서 본 대로 평문이다.
|
||||
|
||||
## 4-5. 「로그아웃했는데 다시 들어가진다」
|
||||
|
||||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 다시 연다.
|
||||
|
||||
**어디를 봐야 하는가** — **로그인 화면이 안 뜨고 그냥 들어가진다.**
|
||||
3-3 에서 본 것과 같은 조용한 재인증이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 애플리케이션 세션은 지웠는데 **IdP 세션은 그대로**
|
||||
이므로 IdP 가 화면 없이 새 세션을 만들어 준다. 사용자 입장에서는
|
||||
**로그아웃이 안 된 것**이다.
|
||||
|
||||
| 필요한 것 | 방법 |
|
||||
|---|---|
|
||||
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
|
||||
| Keycloak 세션 종료 | **RP-initiated logout** — `OidcClientInitiatedLogoutSuccessHandler` |
|
||||
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
|
||||
|
||||
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
|
||||
바로 이 지점이며, 답은 「지금은 하나도 안 지운다」이다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 남은 행을 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"delete from oauth2_authorized_client where principal_name = 'labuser'"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
DELETE 1
|
||||
```
|
||||
|
||||
**되돌리기** — 브라우저에서 다시 로그인하면 행이 다시 만들어진다.
|
||||
**표 자체는 지우지 않는다** — B-3 이 이 표를 쓴다.
|
||||
|
||||
## 5-2. Keycloak SSO 세션을 끊는다
|
||||
|
||||
**하기** — 브라우저에서 아래 주소를 연다. RP 가 안 보내 주니 사람이 직접 간다.
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||
```
|
||||
**미검증** — 이 실험은 여기까지 재지 않았다. 확인 화면이 뜨면 승인한다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `offline_flag = 0` 의 개수가 줄어드는지.
|
||||
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.**
|
||||
0 이 안 되어도 놀랄 일이 아니다.
|
||||
|
||||
## 5-3. Redis 세션을 되돌린다
|
||||
|
||||
지운 세션은 되돌아오지 않는다. **브라우저에서 다시 로그인하는 것이 복구다.**
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| 표 | `sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'` | 컬럼 표가 나온다 (지우면 안 된다) |
|
||||
| BFF 세션 | `… redis-cli --scan --pattern 'bff:session:*'` | 다시 로그인했으면 키가 있다 |
|
||||
| **B-7 세션** | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | **2절 전과 같아야 한다** |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 **조용히 실패**했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 — 1-2 |
|
||||
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
|
||||
| `token-boundary` 가 계속 `false` | 테이블은 만들었는데 **옛 세션**을 쓰고 있다 | 로그아웃 후 재로그인 — [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png) 가 그 상태다 |
|
||||
| `psql ... < file` 이 아무 일도 안 한다 | `kubectl exec` 에 **`-i` 가 없다** | `exec -i deploy/postgres` |
|
||||
| 행 수가 2 로 늘었다 | principal 이 다르다(다른 사용자로 로그인) | `select principal_name from oauth2_authorized_client` |
|
||||
| md5 가 안 바뀌었다 | 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 | `access_token_issued_at` 이 삭제 시각 뒤인지 |
|
||||
| B-7 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 2-2 |
|
||||
| 파이썬 한 줄로 디코드하다 `SyntaxError` | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)) | `cut -d. -f1 \| base64 -d` 로 충분하다 |
|
||||
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf` 의 `headerName` 을 그대로 쓴다 |
|
||||
| 로그아웃했는데 다시 들어가진다 | **버그가 아니다.** Keycloak SSO 세션이 살아 있다 | 4-5 · 5-2 |
|
||||
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
|
||||
|
||||
---
|
||||
|
||||
# 왜 이 가이드에는 스크린샷 대신 숫자가 있나
|
||||
|
||||
증거의 [`b2-tokens-shared-across-instances.png`](../../evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
|
||||
는 **B-0 의 `b0-bff-token-boundary.png` 와 동일 파일**이다(md5 `9ed00537…`).
|
||||
두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다.
|
||||
|
||||
**그래서 그 png 는 「JDBC 전환으로 토큰이 공유된다」를 단독으로 증명하지
|
||||
못한다.** 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다 —
|
||||
이 가이드가 1-2·1-5 에서 재는 것이 그것이다.
|
||||
|
||||
> **같은 화면은 같은 증거가 아니다.** 화면이 같아도 그 아래 상태는 다를 수
|
||||
> 있고, 다를 수 있다는 것이 곧 「그 화면으로는 판정 못 한다」는 뜻이다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-2 가 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 덮어쓰기 때문에 **두 브라우저가 같은 refresh token 을 다툰다** |
|
||||
| [B-4](b4-edge-authorization-scope.md) Edge 인가 | 헤더로 신원을 넘기는 구조에서는 이 문제가 **다른 얼굴**로 나온다 |
|
||||
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있으므로 **각각 죽여볼 수 있다** |
|
||||
| 코드 | **평문 refresh token** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
|
||||
@@ -0,0 +1,835 @@
|
||||
# B-3 재현 가이드 — 같은 refresh token 을 다섯 번 동시에 던지고 세션이 사라지는 것을 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md) ·
|
||||
증거 원문: [`docs/evidence/b3-refresh-contention/`](../../evidence/b3-refresh-contention/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 다섯 중 하나만 `200` 이고 나머지는 `400` | 파드 안 `curl` · `&` 와 `wait` |
|
||||
| 오류 메시지가 **두 종류**인 것 | `Maximum allowed refresh token reuse exceeded` / `Session doesn't have required client` |
|
||||
| **이긴 요청이 받은 토큰조차 못 쓰는 것** | 그 토큰으로 한 번 더 갱신 → `400` |
|
||||
| user session 은 남고 **client session 만 사라진** 것 | PostgreSQL, 정상 세션과 나란히 |
|
||||
| `refreshTokenMaxReuse` 를 올려도 안 되는 것 | 구성 A/B/C 비교 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- [`B-2`](b2-multi-instance-session.md) 가 끝나 있다. **토큰이 공유되어야
|
||||
경쟁이 성립한다** — 다만 이 실험은 Keycloak 쪽 동작만 분리해 보려고
|
||||
**BFF 를 거치지 않고** 토큰 엔드포인트를 직접 친다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- **브라우저는 필요 없다.** direct grant(`grant_type=password`)로 토큰을
|
||||
만들므로 전 구간을 터미널에서 한다.
|
||||
- realm 은 `keycloak-patterns`, 사용자는 `labuser` / `labpass`,
|
||||
클라이언트는 `bff-confidential`.
|
||||
|
||||
## 주의 — 이건 realm 설정을 바꾸는 실험이다
|
||||
|
||||
`revokeRefreshToken` 을 켠다. **realm 전체에 걸린다** — 그 realm 을 쓰는 다른
|
||||
실험(B-2 의 BFF 로그인 포함)이 이 설정의 영향을 받는다. **실험대에서만 한다.**
|
||||
전 구간 약 20분이고, 되돌리는 명령은 [2-2](#2-2-적용) 와 [5-1](#5-1-realm-설정을-되돌린다)
|
||||
에 있다. 중간에 그만두려면 5-1 의 한 줄이면 된다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:16–14:17 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 sid·길이는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||
|
||||
sid·토큰 길이는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위
|
||||
실행 기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
B-2 가 토큰을 PostgreSQL 로 옮겼다. 그래서 **두 replica 가 같은 행을 본다.**
|
||||
그리고 조회 키에 session id 가 없으니 **같은 사용자의 두 브라우저도 같은 행을
|
||||
본다.** 그 행에는 refresh token 이 하나 들어 있다.
|
||||
|
||||
**둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.**
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 통념 | **하나는 성공하고 하나는 실패한다.** 실패한 쪽이 새 토큰을 다시 읽어 재시도하면 된다 |
|
||||
| B-3 이 재는 것 | 진짜 그런가. **그리고 이긴 쪽은 멀쩡한가** |
|
||||
|
||||
이 구별이 설계를 가른다.
|
||||
|
||||
```
|
||||
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
|
||||
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
|
||||
```
|
||||
|
||||
**재시도로 회복되면 lock 이 필요 없고, 회복 안 되면 lock 말고 답이 없다.**
|
||||
그러니 재야 할 것은 「몇 개가 성공했나」가 아니라 **「이긴 요청의 토큰을 다시
|
||||
쓸 수 있나」**다. 4-4 가 그 자리다.
|
||||
|
||||
> **개념 — 재사용 탐지(reuse detection)란 무엇인가.**
|
||||
>
|
||||
> 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화한다. 그런데
|
||||
> 무효화된 옛 토큰이 **다시 들어오면** 두 가지 중 하나다.
|
||||
>
|
||||
> ```
|
||||
> ① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
|
||||
> ② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
|
||||
> ```
|
||||
>
|
||||
> **서버는 둘을 구별할 수 없다.** 그래서 OAuth 2.0 보안 권고는 **안전한
|
||||
> 쪽으로 가정하고 세션 전체를 무효화**하라고 말한다. 이 실험이 보는
|
||||
> 파괴는 **버그가 아니라 그 규격이 시키는 대로 동작한 결과**다.
|
||||
> 그래서 "고쳐 달라"가 아니라 "겹치지 않게 하라"가 답이 된다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 회전을 켜기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 회전이 꺼진 상태에서 같은 명령을
|
||||
먼저 돌려 두어야, 나중에 나오는 400 이 「원래 그런 것」인지 「내가 켠 것」
|
||||
때문인지 구별된다.
|
||||
|
||||
```
|
||||
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
|
||||
```
|
||||
|
||||
## 1-1. 파드가 정상인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
|
||||
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — Keycloak 이 **둘 다** `1/1`. 하나가 NotReady 면
|
||||
Service 가 전부 한쪽으로 보내고, 그러면 **동시성이 한 노드 안에서만** 생긴다.
|
||||
이 실험은 그래도 재현되지만 「replica 를 넘는 경쟁」이라고 말할 수 없게 된다.
|
||||
|
||||
## 1-2. realm 이 지금 무엇으로 설정되어 있나
|
||||
|
||||
kcadm 은 먼저 로그인해야 쓸 수 있다. **한 번 하면 파드 안에 세션이 남는다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
**실측**
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 값 전부.
|
||||
|
||||
| 값 | 뜻 | 지금 |
|
||||
|---|---|---|
|
||||
| `revokeRefreshToken` | **회전 스위치** | `false` — **꺼져 있다** |
|
||||
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
|
||||
| `accessTokenLifespan` | access token 수명(초) | `60` |
|
||||
|
||||
**이 결과가 의미하는 것** — **기본값은 회전이 꺼져 있다.** Q2 는
|
||||
*"realm 이 refresh token rotation 과 재사용 허용 0회를 쓰게 되어서"* 를
|
||||
전제로 하므로, **그 전제를 만드는 것이 이 실험의 주입**이다. 지금 그대로
|
||||
재면 Q2 와 다른 것을 재게 된다.
|
||||
|
||||
`accessTokenLifespan=60` 은 B-0 에서 **이 실험을 위해** 넣어 둔 값이다.
|
||||
만료를 기다리는 시간이 짧아야 재현이 된다.
|
||||
|
||||
## 1-3. 상주 탐침 파드를 띄운다
|
||||
|
||||
Keycloak 이미지에는 `curl` 도 `wget` 도 없다(`exit 127`). 그리고 이 실험은
|
||||
**토큰을 단계 사이로 넘겨야** 한다 — 발급받은 토큰을 뒤 단계에서 써야 하므로
|
||||
`--rm` 임시 파드로는 안 된다. **파드를 하나 띄워 두고 `exec` 로 이어간다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
|
||||
--env="CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
|
||||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
pod/b3-probe condition met
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
**확인** — 환경변수가 들어갔나. **값이 아니라 길이만 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
|
||||
```
|
||||
`CS길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
|
||||
|
||||
> **왜 Service 로 가는가.** A-1·A-2 는 「어느 노드가 답했나」가 질문이라 파드
|
||||
> IP 로 직접 쳤다. 여기는 반대다 — **replica 를 넘는 경쟁**이 질문이므로
|
||||
> Service 가 요청을 흩는 것이 오히려 필요한 조건이다.
|
||||
|
||||
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -it b3-probe -- sh
|
||||
```
|
||||
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit` — **파드는 안 지워진다**
|
||||
(`--rm` 이 없다).
|
||||
|
||||
## 1-4. 토큰 하나를 발급받고 sid 를 뽑는다
|
||||
|
||||
**하기 ①** — 파드 안에서. **처음 한 번은 응답을 통째로 본다**
|
||||
```sh
|
||||
curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
|
||||
```
|
||||
**형태** — 한 줄 JSON 이 나온다
|
||||
```json
|
||||
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
|
||||
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `expires_in` 이 60. 1-2 에서 본 `accessTokenLifespan`
|
||||
그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct
|
||||
grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름/비밀번호다.
|
||||
|
||||
**하기 ②** — 변수에 담고 sid 를 뽑는다
|
||||
```sh
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 SID=$SID"
|
||||
```
|
||||
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
|
||||
```
|
||||
=== [1] refresh token 하나 확보 ===
|
||||
토큰 길이: 811
|
||||
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
|
||||
sid: BvFiB01Rntz1FcLdf7zG4BNt
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`SID` 를 종이에 적어 둔다.** 4-5 에서 DB 를 뒤질 때
|
||||
이 값이 필요하고, 그때는 **파드 밖**이라 변수가 안 넘어간다.
|
||||
|
||||
> `sid` 가 빈 줄이면 base64 패딩 때문이다. `sed 's/$/==/'` 가 그 보정이고,
|
||||
> 그래도 안 나오면 **base64url 문자(`-` `_`)** 때문일 수 있다. 그때는:
|
||||
> ```sh
|
||||
> echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||
> | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
> ```
|
||||
> **미검증** — 원래 실행은 위쪽 형태를 썼다. 페이로드 전체가 나오면 그 안에서
|
||||
> `"sid"` 를 눈으로 찾는다.
|
||||
|
||||
## 1-5. 대조군 — 지금은 무엇이 되는가
|
||||
|
||||
### ① 순차로 다섯 번 갱신한다
|
||||
|
||||
**하기** — 파드 안에서. `&` 없이, 한 번에 하나씩
|
||||
```sh
|
||||
for i in 1 2 3 4 5; do
|
||||
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$RT")
|
||||
echo "순차 $i: $(echo "$R" | tail -1)"
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
done
|
||||
```
|
||||
**미검증** — 증거 파일에는 순차 실행 기록이 없다. 해설 문서는
|
||||
**"순차 실행이면 재현되지 않는다"** 고 말하며, 이 절은 그것을 당신 손으로
|
||||
확인하는 자리다.
|
||||
|
||||
**어디를 봐야 하는가** — 다섯 줄 전부 `200` 이어야 한다.
|
||||
|
||||
> **회전이 켜지면 `RT` 를 매번 다시 담아야 한다.** 위 루프가 그렇게 되어
|
||||
> 있다. 옛 것을 계속 쓰면 뒤에 나오는 400 이 「경쟁」 때문인지 「내가 옛
|
||||
> 토큰을 썼기」 때문인지 구별이 안 된다. **이 실험에서 가장 흔한 자기오염이다.**
|
||||
|
||||
### ② 경쟁을 겪지 않은 세션은 어떻게 생겼나
|
||||
|
||||
4-5 에서 볼 DB 모양을 **지금 미리 본다.** 이게 없으면 나중에 나오는 `0` 이
|
||||
「경쟁 때문」인지 「원래 그런 표」인지 모른다.
|
||||
|
||||
**확인** — **파드 밖**(kc-lab-1)에서. 아래의 sid 자리에는 **1-4 에서 적어 둔
|
||||
당신의 `SID`** 를 넣는다. 여기 실린 값은 원래 실행의 대조군 세션 것이다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'JT-XuepgutWcE273QwAnIXta'"
|
||||
```
|
||||
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
|
||||
의 대조군 부분
|
||||
```
|
||||
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
|
||||
새 sid: JT-XuepgutWcE273QwAnIXta
|
||||
user_session_id | client_sessions
|
||||
--------------------------+-----------------
|
||||
JT-XuepgutWcE273QwAnIXta | 1
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`client_sessions = 1`.** 정상 세션은 이렇게 생겼다.
|
||||
|
||||
> **개념 — user session 과 client session 은 다른 것이다.**
|
||||
>
|
||||
> ```
|
||||
> user session "이 브라우저는 labuser 로 로그인함"
|
||||
> ├─ client session : bff-confidential
|
||||
> └─ client session : oauth2-proxy
|
||||
> ```
|
||||
>
|
||||
> 사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 **user session 하나
|
||||
> 아래에 client session 이 여럿** 달린다. 그게 SSO 다.
|
||||
> **재사용 탐지는 이 중 client session 만 제거한다** — 4-5 에서 그것을 본다.
|
||||
>
|
||||
> 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데,
|
||||
> `offline_flag` 열이 그것을 가른다. 위 출력의 `offline_flag = 0` 이
|
||||
> 「온라인 세션」이다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 회전을 켠다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||
```
|
||||
|
||||
## 2-1. 무엇을 켜는가 — 이름이 헷갈린다
|
||||
|
||||
> **개념 — `revokeRefreshToken` 이 회전 스위치다.**
|
||||
>
|
||||
> 이름이 「회전(rotation)」이 아니라 **「취소(revoke)」**다. 켜면 새 토큰을
|
||||
> 줄 때 **옛 토큰을 무효화**한다. 그 결과가 회전이다.
|
||||
>
|
||||
> | 설정 | 뜻 |
|
||||
> |---|---|
|
||||
> | `revokeRefreshToken` | **회전 스위치.** 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
|
||||
> | `refreshTokenMaxReuse` | 그 위에서 **몇 번까지 봐줄 것인가** |
|
||||
>
|
||||
> **`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다.**
|
||||
> 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니
|
||||
> 「봐줄 횟수」를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색인 이유다.
|
||||
|
||||
## 2-2. 적용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||
date '+%H:%M:%S 회전 켬'
|
||||
```
|
||||
**형태** — 성공하면 아무 말도 안 한다(무소식이 좋은 소식이다)
|
||||
```
|
||||
14:16:12 회전 켬
|
||||
```
|
||||
|
||||
**시각을 적어 둔다.** 4절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다.
|
||||
**켜기 전에 발급한 토큰으로 재면 안 된다** — 발급 시점의 정책이 아니라 검증
|
||||
시점의 정책이 적용되므로 섞여서 해석이 안 된다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
## 3-1. 설정이 실제로 바뀌었나
|
||||
|
||||
**확인** — 1-2 와 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `revokeRefreshToken` 이 `true`. `false` 그대로면
|
||||
`update` 가 다른 realm 에 갔거나 kcadm 세션이 만료된 것이다. kcadm 은
|
||||
**실패해도 조용할 때가 있다** — 반드시 다시 읽어서 확인한다.
|
||||
|
||||
## 3-2. 엉뚱한 것을 죽이지 않았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=keycloak
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** realm 설정 변경은 재시작을
|
||||
일으키지 않는다. 여기서 재시작이 올랐다면 다른 것을 건드린 것이고, 그 상태로
|
||||
재면 「경쟁」이 아니라 「재시작」을 재게 된다.
|
||||
|
||||
## 3-3. 회전이 실제로 동작하는가 — 한 번만 갱신해 본다
|
||||
|
||||
**동시성을 넣기 전에, 회전 자체가 도는지 확인한다.**
|
||||
|
||||
**하기** — 파드 안에서. 새 토큰을 하나 받고, **한 번 갱신한 뒤 옛 것을 다시 쓴다**
|
||||
```sh
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||
|
||||
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||
```
|
||||
**미검증** — 이 절은 이 가이드가 덧붙인 사전 확인이다. 증거 파일에는 없다.
|
||||
|
||||
**어디를 봐야 하는가** — **1회차 `200`, 2회차 `400`.**
|
||||
|
||||
**이 결과가 의미하는 것** — 옛 토큰이 무효화된다 = 회전이 켜졌다.
|
||||
2회차도 `200` 이면 **회전이 안 켜진 것**이고, 그 상태로 4절을 돌리면 다섯 개가
|
||||
전부 200 으로 나온다 — 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 깨끗한 토큰을 하나 새로 받는다
|
||||
|
||||
3-3 에서 쓴 토큰은 이미 무효다. **새로 시작한다.**
|
||||
|
||||
**하기** — 파드 안에서
|
||||
```sh
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 SID=$SID"
|
||||
```
|
||||
|
||||
**`SID` 를 다시 적어 둔다.** 4-5 에서 쓴다.
|
||||
|
||||
## 4-2. ★ 동시에 다섯 개 — `&` 와 `wait` 이 없으면 재현되지 않는다
|
||||
|
||||
**이 절이 이 실험의 전부다.** 순차로 돌리면 아무 일도 안 일어난다(1-5 에서
|
||||
직접 봤다). 진짜로 겹쳐야 한다.
|
||||
|
||||
**하기** — 파드 안에서
|
||||
```sh
|
||||
i=1
|
||||
while [ $i -le 5 ]; do
|
||||
( curl -s -o /tmp/b$i -w '%{http_code}' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
|
||||
i=$((i+1))
|
||||
done
|
||||
wait
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
|
||||
done
|
||||
```
|
||||
**미검증** — 원래 실행은 스크립트였다. 이 형태는 손으로 치기 좋게 고친 것이고,
|
||||
**본문과 응답 코드를 파일로 갈라 놓아 순서대로 다시 읽을 수 있게** 했다.
|
||||
다섯 개를 동시에 띄우면 출력이 뒤섞여 어느 줄이 어느 요청인지 알 수 없다 —
|
||||
그래서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
|
||||
|
||||
**어디를 봐야 하는가** — 셸 문법 세 조각이 전부다.
|
||||
|
||||
```
|
||||
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
|
||||
wait 띄운 것이 전부 끝날 때까지 기다린다
|
||||
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
|
||||
```
|
||||
|
||||
**`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다.**
|
||||
`wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다.
|
||||
|
||||
## 4-3. 결과 — 오류가 두 종류다
|
||||
|
||||
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
|
||||
```
|
||||
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
|
||||
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
|
||||
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 5: HTTP 200 {"access_token":"...(발급됨)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **성공 개수가 아니라 오류 메시지가 두 종류인 것.**
|
||||
|
||||
| 메시지 | 뜻 |
|
||||
|---|---|
|
||||
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
|
||||
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
|
||||
|
||||
**이 결과가 의미하는 것** — 만약 「하나만 이기고 나머지는 진다」였다면 지는
|
||||
쪽 메시지는 **전부 같아야** 한다. 두 종류라는 것은 **중간에 상태가 바뀌었다**
|
||||
는 뜻이다. 그 바뀐 상태가 무엇인지가 4-5 다.
|
||||
|
||||
> 성공한 번호는 당신 환경에서 다르다. 증거에서는 5번이었지만 순서는
|
||||
> 스케줄링에 달렸다. **몇 번이 이겼는가는 아무 의미가 없다.**
|
||||
|
||||
## 4-4. ★ 이긴 요청의 토큰을 다시 써 본다 — 여기서 진짜 답이 나온다
|
||||
|
||||
**하기** — 파드 안에서. 다섯 응답 중 `refresh_token` 이 들어 있는 것을 꺼낸다
|
||||
```sh
|
||||
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
|
||||
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
|
||||
echo "새 refresh token 길이: ${#NEW}"
|
||||
|
||||
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$NEW"
|
||||
```
|
||||
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||
```
|
||||
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
|
||||
새 refresh token 길이: 810
|
||||
그 토큰으로 다시 갱신: HTTP 400
|
||||
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`400`.** 그리고 메시지가
|
||||
`Session doesn't have required client`.
|
||||
|
||||
**이 결과가 의미하는 것** — **이긴 요청조차 쓸 수 없는 토큰을 받았다.**
|
||||
|
||||
```
|
||||
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
|
||||
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
|
||||
```
|
||||
|
||||
**오류가 지연되어 나타난다.** 200 을 받은 코드는 성공했다고 믿고 토큰을
|
||||
저장한다. 끊긴 것은 **그다음 요청에서** 안다. 로그를 볼 때 원인 시각과 증상
|
||||
시각이 어긋나 보이는 이유가 이것이다.
|
||||
|
||||
> **여기서 「재시도하면 되지 않나」가 무너진다.** 새 토큰을 다시 읽어
|
||||
> 재시도해도 **그 토큰이 이미 무효**다. 재시도할 대상이 없다.
|
||||
|
||||
## 4-5. 기제 확정 — 무엇이 사라졌는가
|
||||
|
||||
**확인** — **파드 밖**에서. `SID` 는 4-1 에서 적어 둔 값이다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag, us.last_session_refresh
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||
```
|
||||
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||
```
|
||||
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
|
||||
user_session_id | offline_flag | last_session_refresh
|
||||
--------------------------+--------------+----------------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **행이 남아 있다.** 세션이 통째로 지워진 것이 아니다.
|
||||
그러면 왜 `Session doesn't have required client` 인가. **client session 을 센다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||
```
|
||||
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
|
||||
```
|
||||
=== user session 과 client session 을 나눠서 본다 ===
|
||||
user_session_id | offline_flag | client_sessions
|
||||
--------------------------+--------------+-----------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`client_sessions = 0`.** 1-5 의 대조군은 `1` 이었다.
|
||||
**같은 명령, 다른 결과 — 그것이 이 실험의 판정이다.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
|
||||
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
|
||||
```
|
||||
|
||||
그래서 오류 문구가 정확히 그 말을 한다 — **세션은 있는데 그 클라이언트 몫이
|
||||
없다.** 메시지를 오해해서 「세션이 만료됐다」로 읽으면 엉뚱한 곳을 고치게 된다.
|
||||
|
||||
## 4-6. 폐기 목록에 실린 것이 아니다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select count(*) as revoked_count from revoked_token"
|
||||
```
|
||||
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||
```
|
||||
=== [5] revoked_token 테이블 ===
|
||||
revoked_count
|
||||
---------------
|
||||
0
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`0`.**
|
||||
|
||||
**이 결과가 의미하는 것** — 「토큰을 블랙리스트에 올려서 막는다」가 아니다.
|
||||
**client session 이 사라져서 검증할 대상이 없어진 것**이다. 토큰을 지우는
|
||||
방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 **그 client 에 대한
|
||||
모든 토큰이 한꺼번에 죽는다.** 4-4 의 결과가 그것이다.
|
||||
|
||||
## 4-7. 시간선 — 왜 이긴 쪽도 죽는가
|
||||
|
||||
```
|
||||
t0 5개가 동시에 도착
|
||||
t1 하나가 처리를 시작 → 새 토큰 발급 준비
|
||||
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
|
||||
t3 ★ client session 제거
|
||||
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
|
||||
t5 그 토큰을 쓰면 → client session 이 없다 → 400
|
||||
```
|
||||
|
||||
**t3 와 t4 의 순서가 전부다.** 응답을 만들던 요청은 이미 「성공」이 확정된
|
||||
상태로 나가고, 그 사이 바닥이 빠진다.
|
||||
|
||||
## 4-8. 정책을 바꿔 비교한다
|
||||
|
||||
**한 번 더 재기 전에 세션을 새로 만든다.** 파괴된 세션으로 재면 전부 400 이다.
|
||||
|
||||
### 구성 B — 회전 OFF
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false
|
||||
```
|
||||
파드 안에서 **4-1 → 4-2 → 4-4 → 4-5 를 그대로 반복**한다.
|
||||
|
||||
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
|
||||
```
|
||||
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
|
||||
sid=iW1CGyO7COdyJLryIrCt3njk
|
||||
1: 200
|
||||
2: 200
|
||||
3: 200
|
||||
4: 200
|
||||
5: 200
|
||||
성공 5 / 5
|
||||
이긴 토큰 재사용: HTTP 200
|
||||
남은 client_session: 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **전부 200 이고 세션도 멀쩡하다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 같은 refresh token 을 계속 쓸 수 있으므로
|
||||
**경쟁 자체가 성립하지 않는다.** 대신 잃는 것 — 토큰이 유출되면 **만료까지
|
||||
계속 쓸 수 있다.** 회전의 목적이 그 창을 좁히는 것이었다.
|
||||
|
||||
### 구성 C — 회전 ON · 재사용 1회 허용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
|
||||
```
|
||||
다시 반복한다.
|
||||
|
||||
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
|
||||
```
|
||||
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
|
||||
sid=72c04JCdr0NpCHGQmXWW2wM8
|
||||
1: 200
|
||||
2: 400 "error_description":"Session doesn't have required client"
|
||||
3: 200
|
||||
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
|
||||
5: 400 "error_description":"Session doesn't have required client"
|
||||
성공 2 / 5
|
||||
이긴 토큰 재사용: HTTP 400
|
||||
남은 client_session: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 성공이 1에서 2로 늘었지만 **`남은 client_session: 0`**
|
||||
은 그대로다.
|
||||
|
||||
### 세 구성을 나란히
|
||||
|
||||
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|
||||
|---|---|---|---|
|
||||
| **A** 회전 ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
|
||||
| **B** 회전 OFF | **5 / 5** | 200 | **1 — 생존** |
|
||||
| **C** 회전 ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
|
||||
|
||||
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
|
||||
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 **회전의 보안
|
||||
> 목적이 사라진다.** 값을 올려 버티려는 시도는 "몇 개까지 동시에 올
|
||||
> 것인가"를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
|
||||
|
||||
**그래서 답은 lock 이다.** 그리고 lock 은 **저장소 쪽**에 있어야 한다 —
|
||||
프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
|
||||
|
||||
| 후보 | |
|
||||
|---|---|
|
||||
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE` — **A-0 에서 Keycloak 자신이 쓰는 방식** |
|
||||
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
|
||||
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
|
||||
|
||||
**잠금의 수명이 연결의 수명과 묶이는 것**이 DB 잠금의 이점이다. 프로세스가
|
||||
죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis lock 은 TTL 이 짧으면
|
||||
**중복 갱신**, 길면 **정지**다 — 그 약점은 [B-5](b5-redis-loss-persistence.md)
|
||||
에서 다시 만난다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. realm 설정을 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||
date '+%H:%M:%S 회전 끔'
|
||||
```
|
||||
|
||||
**확인** — 1-2 와 똑같은 명령으로 다시 읽는다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**1-2 의 실측과 세 값이 전부 같아야 한다.** `accessTokenLifespan` 이 60 이
|
||||
아니면 다른 것도 건드린 것이다.
|
||||
|
||||
## 5-2. 탐침 파드를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
## 5-3. 실험이 만든 세션을 정리한다
|
||||
|
||||
파괴된 세션의 `user_session_id` 행은 그대로 남는다. **TTL 로 스스로 사라지지만**
|
||||
바로 치우고 싶으면 브라우저에서 아래를 연다.
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||
```
|
||||
**미검증** — 이 실험은 여기까지 재지 않았다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.** 0 이 안 되어도
|
||||
놀랄 일이 아니다.
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| realm | 위 5-1 의 `get realms/...` | `revokeRefreshToken : false` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| 탐침 | `sudo kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
|
||||
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| **다섯 개가 전부 `200`** | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &` 와 `wait` 이 있는지 — 4-2 |
|
||||
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 3-1 로 다시 읽는다. 3-3 이 `200/400` 이어야 한다 |
|
||||
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
|
||||
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 — 4-2 |
|
||||
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 — 1-5 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 — 1-3 |
|
||||
| `CS길이=0` | secret 이름/키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
|
||||
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
|
||||
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 — 1-2 |
|
||||
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 — 1-4 |
|
||||
| 4-5 에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
|
||||
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 5-1 |
|
||||
|
||||
---
|
||||
|
||||
# 왜 이 가이드에는 부하 도구가 없나
|
||||
|
||||
동시성 5는 `ab` 도 `k6` 도 필요 없다. **셸의 `&` 와 `wait` 이면 충분하고,
|
||||
그 편이 무엇이 일어났는지 더 잘 보인다** — 요청 다섯 개의 본문을 전부 파일로
|
||||
갖고 있으니 나중에 다시 읽을 수 있다.
|
||||
|
||||
부하 도구는 **개수를 늘려야 할 때** 쓴다. 이 실험이 묻는 것은 개수가 아니라
|
||||
**「겹치면 무엇이 부서지는가」**이고, 그건 둘만 겹쳐도 답이 나온다.
|
||||
|
||||
> **재현에 필요한 최소치를 찾는 것이 실험 설계다.** 다섯 개를 쓴 이유는
|
||||
> 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지, 다섯이 필요해서가
|
||||
> 아니다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-3 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다.** 그 약점을 직접 본다 |
|
||||
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **왜 두 브라우저가 같은 토큰을 다투는가** — 조회 키가 원인이다 |
|
||||
| A-6 지연 주입 | 거기서 낙관적 락 충돌이 0 이었던 이유가 여기서 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **같은 항목을 갱신할 때**다 |
|
||||
| 설계 | **재시도로 회복되지 않는다 → lock.** 그리고 lock 은 저장소 쪽, 가급적 DB 행 잠금 |
|
||||
@@ -0,0 +1,913 @@
|
||||
# B-4 재현 가이드 — 신원 헤더를 위조해 보내고 그대로 도착하는 것을 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md) ·
|
||||
증거 원문: [`docs/evidence/b4-edge-authorization/`](../../evidence/b4-edge-authorization/) ·
|
||||
③ 반영 시점: [후속 문서 §3](../../experiment-followup-untested-items.md) ·
|
||||
[`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 동명 헤더 두 개가 **둘 다 도착**하는 것 | `curl -H … -H …` · echo 응답 |
|
||||
| 값 안의 쉼표를 구분자와 **구별할 수 없는** 것 | 같은 자리 |
|
||||
| 8KB 에서 `400`, 16KB 에서 **응답 자체가 없는** 것 | 크기 훑기 |
|
||||
| 인증 없이 보낸 **위조 신원**이 그대로 도착하는 것 | 같은 자리 |
|
||||
| JWT 를 요구하는 경로는 `401` 인 것 | 대조군 |
|
||||
| IdP 에서 값을 바꿔도 **12회 요청 동안 옛 값**인 것 | 브라우저 콘솔 (쿠키가 HttpOnly 라 curl 로 못 한다) |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`03-nginx`](../03-nginx/) · [`04-tls`](../04-tls/) · [`05-keycloak`](../05-keycloak/)
|
||||
가 끝나 있다.
|
||||
- `app1.hyeonworks.com` 이 **경로에 따라 둘로 갈린다.** `/` 는 BFF,
|
||||
**`/api` 는 `header-lab` 네임스페이스의 echo 앱**이다. 이 실험은 `/api/echo`
|
||||
만 쓴다 — 도착한 헤더를 그대로 되돌려주는 앱이다.
|
||||
- 4절부터는 **`app2.hyeonworks.com` 을 Grafana 에서 잠시 빌린다.** 인증서가
|
||||
`auth` · `app1` · `app2` 만 덮으므로 네 번째 이름을 만들 수 없다.
|
||||
**끝나면 되돌린다** — [5-4](#5-4-grafana-ingress-를-되돌린다).
|
||||
- 4절은 **브라우저가 필요하다.** oauth2-proxy 세션 쿠키가 `HttpOnly` 라
|
||||
`curl` 로 로그인 상태를 재현할 수 없다. 이유는 [4-1](#4-1--왜-curl-로-못-하는가).
|
||||
- `kubectl` 은 **`kc-lab-1` 에서 `sudo`** 로 친다. 5-1 의 nginx 설정만
|
||||
**랩 호스트(`test-server`)** 에서 한다 — 다른 기계다.
|
||||
- 앞의 `curl` 은 어디서 쳐도 된다. 밖에서 치는 편이 「공격자 관점」에 가깝다.
|
||||
|
||||
## 주의 — 앞부분은 안전하고 뒷부분은 상태를 바꾼다
|
||||
|
||||
| 절 | 무엇을 하나 | 되돌릴 것 |
|
||||
|---|---|---|
|
||||
| 1~3 | **요청만 보낸다.** 클러스터 상태가 안 바뀐다 | 없음 |
|
||||
| 4 | Grafana 에서 app2 를 빌리고 **IdP 의 사용자 속성을 바꾼다** | Ingress · email 값 · 세션 |
|
||||
| 5 | **nginx 설정을 바꾼다** (호스트) | 설정 파일 |
|
||||
|
||||
전 구간 약 30분. **1~3 만 하고 멈춰도 이 실험의 결론 대부분이 나온다.**
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:23 KST(①②④) 및 07:51–07:53 UTC(③) 실행 기록의 **출력 원문** |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||
|
||||
증거 파일의 `['admin', 'editor']` 같은 표기는 **스크립트가 정리한 것**이다.
|
||||
`curl` 로 직접 보면 같은 값이 JSON 배열 `["admin","editor"]` 로 온다 —
|
||||
echo 앱이 헤더 이름마다 **값의 목록**을 돌려주기 때문이다. 이 가이드는 둘을
|
||||
구별해 표시한다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
Edge(oauth2-proxy·nginx)가 인증을 끝내고 **신원을 헤더로 뒤에 넘기는** 구조가
|
||||
있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이다. 뒤쪽
|
||||
애플리케이션은 그 헤더를 읽어 사용자를 안다.
|
||||
|
||||
**그러면 그 헤더는 무엇이 보증하는가.**
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| Q4 가 「확인한 사실」로 적어둔 것 | *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"* |
|
||||
| B-4 가 재는 것 | 진짜 그런가. **그리고 upstream 은 무엇을 검증하는가** |
|
||||
|
||||
이 실험은 **네 가지를 따로 잰다.**
|
||||
|
||||
```
|
||||
① 여러 값을 어떻게 넣는가 쉼표? 헤더를 여러 개? → 구별할 수 있나
|
||||
② 커지면 어떻게 되는가 잘리나? 거부되나?
|
||||
③ IdP 에서 바꾸면 언제 반영되나
|
||||
④ 위조하면 통하는가 ★ 여기가 권한의 문제다
|
||||
```
|
||||
|
||||
> **개념 — 왜 ④ 가 「인증 우회」가 아니라 「권한 상승」인가.**
|
||||
>
|
||||
> 헤더가 **누구인지**만 말하면 위조는 인증 우회다. 그런데 헤더가
|
||||
> **무엇을 할 수 있는지**(role)까지 말하면, 위조는 **권한 상승**이 된다.
|
||||
> 로그인한 일반 사용자가 자기 요청에 `X-Auth-Request-Roles: admin` 을
|
||||
> 한 줄 더 붙이는 것으로 끝난다.
|
||||
>
|
||||
> 그래서 이 구조는 **세 곳이 동시에 성립해야만** 안전하다.
|
||||
>
|
||||
> ```
|
||||
> ① 외부 → upstream 직접 경로 차단 (NetworkPolicy)
|
||||
> ② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header)
|
||||
> ③ upstream 에서 내부 credential 검증 (공통 경계)
|
||||
> ```
|
||||
>
|
||||
> **하나라도 빠지면 나머지 둘이 무의미하다.** 이 실험은 ② 가 빠져 있다는
|
||||
> 것을 재고, 그 결과로 ④ 가 성립한다는 것을 재고, ③ 이 한 곳에만 있다는
|
||||
> 것을 확인한다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 위조하기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 「위조 헤더가 도착했다」고 말하려면
|
||||
**아무것도 안 붙였을 때 무엇이 도착하는지**를 먼저 봐야 한다.
|
||||
|
||||
```
|
||||
경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나
|
||||
```
|
||||
|
||||
## 1-1. 어느 이름이 어디로 가는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl get ingress -A
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAMESPACE NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
header-lab echo traefik app1.hyeonworks.com 80 5d
|
||||
keycloak-lab bff traefik app1.hyeonworks.com 80 3d
|
||||
keycloak-lab keycloak traefik auth.hyeonworks.com 80 6d
|
||||
observability grafana traefik app2.hyeonworks.com 80 6d
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `app1` 이 **두 줄**이다. 같은 호스트에 Ingress 가
|
||||
둘이고, 경로로 갈린다.
|
||||
|
||||
**확인** — 어느 경로가 어디로 가는지 눈으로 본다
|
||||
```bash
|
||||
sudo kubectl -n header-lab describe ingress echo | grep -A5 Rules
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
Rules:
|
||||
Host Path Backends
|
||||
---- ---- --------
|
||||
app1.hyeonworks.com
|
||||
/api echo:8081 (10.42.0.61:8081,10.42.1.72:8081)
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — `https://app1.hyeonworks.com/api/echo` 는 **BFF 가
|
||||
아니라 echo 앱**으로 간다. 이 실험이 재는 것은 BFF 가 아니라 **헤더를 그대로
|
||||
받아 쓰는 upstream** 이므로 이쪽이 맞다.
|
||||
|
||||
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
|
||||
> 뜬다. 위처럼 `describe ingress` / `describe svc` 를 보거나
|
||||
> `get endpointslice -l kubernetes.io/service-name=echo` 를 본다.
|
||||
|
||||
## 1-2. echo 응답을 한 번 통째로 본다
|
||||
|
||||
**나중에 걸러 보려면 먼저 통째로 봐야 한다.** 어떤 키가 있는지 알아야 무엇으로
|
||||
거를지 정할 수 있다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s https://app1.hyeonworks.com/api/echo
|
||||
```
|
||||
**형태** — 한 줄 JSON 이 통째로 나온다
|
||||
```json
|
||||
{"headers":{"host":["app1.hyeonworks.com"],"x-forwarded-host":["app1.hyeonworks.com"],
|
||||
"x-forwarded-proto":["https"],"x-forwarded-port":["443"],"x-forwarded-for":["..."],
|
||||
"x-real-ip":["..."],"user-agent":["curl/8.5.0"],"accept":["*/*"]},
|
||||
"remoteAddr":"...","localAddr":"10.42.1.72","scheme":"https","secure":true,
|
||||
"serverName":"app1.hyeonworks.com","serverPort":443,
|
||||
"requestUrl":"https://app1.hyeonworks.com/api/echo"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- `headers` 의 값이 **전부 배열**이다. HTTP 가 같은 이름의 헤더를 여러 번
|
||||
허용하기 때문이고, **2-1 의 결과를 읽을 수 있는 이유**가 이것이다
|
||||
- `x-forwarded-proto` 가 `https` — nginx 가 `proxy_set_header` 로 **설정한**
|
||||
헤더다. 1-4 에서 이 목록을 확인한다
|
||||
- `scheme` / `secure` / `serverName` — Keycloak 이 `iss` 클레임과 리다이렉트를
|
||||
만들 때 쓰는 값들이다. 2홉 실험이 이 세 개를 봤다
|
||||
|
||||
`jq` 는 이 실험대에 **깔려 있지 않다.** 걸러 볼 때는 `grep -o` 를 쓴다.
|
||||
|
||||
**확인** — **미검증**
|
||||
```bash
|
||||
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
"x-forwarded-proto":["https"]
|
||||
```
|
||||
|
||||
> **`tr ',' '\n' | grep` 은 여기서 쓰면 안 된다.** 값 배열이
|
||||
> `["admin","editor"]` 처럼 쉼표를 품고 있어서 **배열이 두 줄로 잘린다.**
|
||||
> 첫 줄만 보고 「하나만 도착했다」로 읽게 된다 — 이 실험이 가장 조심해야 할
|
||||
> 오독이다. `grep -o '…\[[^]]*\]'` 는 대괄호 안을 통째로 뽑는다.
|
||||
|
||||
## 1-3. 대조군 — 아무것도 안 붙였을 때
|
||||
|
||||
**확인** — **미검증**
|
||||
```bash
|
||||
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **아무것도 안 나와야 한다.** `x-auth-request-*` 는
|
||||
edge 가 붙이는 헤더인데, `app1` 앞에는 oauth2-proxy 가 없으므로 지금은 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — **이 자리가 비어 있다는 것이 대조군이다.**
|
||||
2절에서 여기에 값이 나타나면 그건 **내가 보낸 것이 도착한 것**이다. 이 확인을
|
||||
건너뛰면 「원래 있던 것」과 「내가 넣은 것」이 구별되지 않는다.
|
||||
|
||||
## 1-4. nginx 가 지금 무엇을 설정하고 있나
|
||||
|
||||
**확인** — **랩 호스트(`test-server`)** 에서
|
||||
```bash
|
||||
sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab
|
||||
```
|
||||
**형태** — [`03-nginx`](../03-nginx/) 가 세운 설정 그대로다
|
||||
```
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`X-Auth-Request-*` 가 목록에 없다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 그리고 그것이 2절의 결과를 전부 설명한다.
|
||||
|
||||
> **개념 — nginx 의 헤더 처리는 조건부다.**
|
||||
>
|
||||
> ```nginx
|
||||
> proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다
|
||||
> # X-Auth-Request-Roles 설정 없음 # 안 한 것 → 그대로 흘려보낸다
|
||||
> ```
|
||||
>
|
||||
> **nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다.**
|
||||
> 설정하지 않은 헤더는 **손대지 않고 통과**시킨다. 「nginx 가 덮어쓴다」는
|
||||
> 명제는 **조건부**이며, 그 조건이 빠지면 틀린 문장이 된다.
|
||||
>
|
||||
> Q4 가 「확인한 사실」로 적어둔 *"Nginx는 client가 보낸 동명 헤더를
|
||||
> merge하지 않고 덮어쓴다"* 는 **조건이 빠져 있어 어긋난다.**
|
||||
> 2-1 이 그것을 재는 자리다.
|
||||
|
||||
> **`sudo` 가 아무 결과도 안 주면 실패한 것이다.** 랩 호스트의 sudo 는
|
||||
> **비밀번호를 요구한다**(`sudo -n -l` → `sudo: a password is required`).
|
||||
> D-4 후속 작업이 이 사실을 늦게 발견해서 시간을 버렸다. 빈 출력을
|
||||
> 「설정이 없다」로 읽지 말고 **비밀번호를 넣어 다시 친다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 헤더를 위조해서 보낸다
|
||||
|
||||
**이 절은 클러스터 상태를 바꾸지 않는다.** 요청을 보낼 뿐이다. 그래서
|
||||
되돌릴 것이 없다 — 그리고 **그 사실 자체가 이 실험의 무게**다. 아무것도
|
||||
설치하지 않고 아무 권한도 없이, `curl` 한 줄로 여기까지 된다.
|
||||
|
||||
## 2-1. 동명 헤더 두 개 — 덮어쓰는가, 합치는가, 통과시키는가
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
|
||||
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||
```
|
||||
**미검증** — 원래 실행은 스크립트가 응답을 정리했다. 위는 같은 값을 `grep` 으로
|
||||
뽑는 형태다.
|
||||
|
||||
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||
```
|
||||
(b) 동명 헤더 두 개
|
||||
보냄: X-Auth-Request-Roles: admin
|
||||
X-Auth-Request-Roles: editor
|
||||
도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **값이 두 개**다. `curl` 로 직접 보면
|
||||
`"x-auth-request-roles":["admin","editor"]` 로 보인다.
|
||||
|
||||
**이 결과가 의미하는 것** — 셋 중 어느 것도 아니다.
|
||||
|
||||
| 가설 | 도착했을 모양 | 실제 |
|
||||
|---|---|---|
|
||||
| 덮어쓴다 | `["editor"]` 하나 | ✗ |
|
||||
| 합친다 | `["admin, editor"]` 한 문자열 | ✗ |
|
||||
| **통과시킨다** | **`["admin","editor"]`** | **✔** |
|
||||
|
||||
Edge 가 `X-Auth-Request-Roles: viewer` 를 붙여도, 공격자가 같은 헤더를
|
||||
`admin` 으로 함께 보내면 **둘 다 upstream 에 도착한다.**
|
||||
|
||||
```
|
||||
edge 가 붙인 것: X-Auth-Request-Roles: viewer
|
||||
공격자가 보낸 것: X-Auth-Request-Roles: admin
|
||||
upstream 이 받는 것: ["viewer","admin"] 또는 ["admin","viewer"]
|
||||
└─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다
|
||||
```
|
||||
|
||||
**Spring 의 `request.getHeader()` 는 첫 번째를 돌려준다. 그 순서는 프록시가
|
||||
정한다.** 애플리케이션 코드 어디에도 이 결정이 안 적혀 있다.
|
||||
|
||||
## 2-2. 값 안의 쉼표 — 구분자와 구별할 수 있는가
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
curl -s -H 'X-Auth-Request-Roles: admin,editor,viewer' \
|
||||
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||
curl -s -H 'X-Auth-Request-Roles: role-with,comma' \
|
||||
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||
```
|
||||
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||
```
|
||||
(a) 쉼표 구분 한 개 헤더
|
||||
보냄: X-Auth-Request-Roles: admin,editor,viewer
|
||||
도착: ['admin,editor,viewer'] ← 문자열 하나 그대로
|
||||
...
|
||||
(c) 값 안에 구분자가 들어간 경우
|
||||
보냄: X-Auth-Request-Roles: role-with,comma
|
||||
도착: ['role-with,comma'] ← (a) 와 구별 불가
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **(a) 와 (c) 가 도착 시점에 똑같이 생겼다.**
|
||||
둘 다 값이 **하나**인 배열이고, 그 안에 쉼표가 있다.
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
"admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다
|
||||
"role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다
|
||||
```
|
||||
|
||||
**role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다.**
|
||||
Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 아니다** —
|
||||
애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 자리가 아니다.
|
||||
|
||||
| 대안 | |
|
||||
|---|---|
|
||||
| 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. **다만 위조와 구별이 안 된다**(2-1) |
|
||||
| Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다(2-3) |
|
||||
| **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 **위조도 구분자도 해결된다** → BFF 구조 |
|
||||
|
||||
## 2-3. 크기를 키운다 — 자르나, 거부하나
|
||||
|
||||
**먼저 한 번은 읽는 형태로 본다.** 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다.
|
||||
|
||||
**하기** — **미검증**. 원래 실행은 `python3 -c "print('r'*$n)"` 로 값을 만들었다.
|
||||
파이썬 없이 만든다
|
||||
```bash
|
||||
V=$(head -c 8000 /dev/zero | tr '\0' 'r'); echo "만든 길이 ${#V}"
|
||||
curl -i -s -H "X-Auth-Request-Roles: $V" https://app1.hyeonworks.com/api/echo | head -20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태줄과 본문. 8000 에서는 **Tomcat 의 HTML 오류
|
||||
페이지**가 온다. JSON 이 아니라 HTML 이라는 것 자체가 「애플리케이션까지
|
||||
갔는데 파싱 전에 잘렸다」는 신호다.
|
||||
|
||||
이제 여러 크기를 **비교**한다. 비교가 목적이니 여기서는 코드만 뽑는 형태가 맞다.
|
||||
|
||||
**하기** — **미검증**
|
||||
```bash
|
||||
for n in 1000 4000 8000 16000 32000; do
|
||||
V=$(head -c "$n" /dev/zero | tr '\0' 'r')
|
||||
curl -s -o /dev/null -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
|
||||
https://app1.hyeonworks.com/api/echo
|
||||
done
|
||||
```
|
||||
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||
```
|
||||
=== Q4 ② 헤더 크기 상한 ===
|
||||
보낸 길이 1000 → HTTP 200, 도착 길이 1000
|
||||
보낸 길이 4000 → HTTP 200, 도착 길이 4000
|
||||
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
|
||||
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
|
||||
보낸 길이 32000 → HTTP 000
|
||||
|
||||
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`000` 과 `400` 이 다른 것**이다.
|
||||
|
||||
| curl 이 찍는 값 | 뜻 |
|
||||
|---|---|
|
||||
| `400` | 응답을 받았다. **서버가 거부했다** |
|
||||
| `000` | **응답 자체를 못 받았다.** 연결이 끊겼거나 아예 안 열렸다 |
|
||||
|
||||
**이 결과가 의미하는 것** — **자르지 않는다. 거부한다.** 그리고 **거부하는
|
||||
계층이 둘**이다.
|
||||
|
||||
| 크기 | 누가 거부하나 | 클라이언트가 보는 것 |
|
||||
|---|---|---|
|
||||
| ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 |
|
||||
| ~16KB 이상 | **nginx** (`large_client_header_buffers`) | **응답 없음 / 연결 끊김** |
|
||||
|
||||
> **두 실패가 전혀 다르게 보인다.** 앞의 것은 애플리케이션 오류처럼,
|
||||
> 뒤의 것은 네트워크 장애처럼 보인다. **원인은 같은데 진단이 갈린다** —
|
||||
> 앞의 것은 앱 로그를 뒤지게 하고 뒤의 것은 방화벽을 뒤지게 한다.
|
||||
|
||||
```
|
||||
role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400
|
||||
```
|
||||
|
||||
**점진적으로 나빠지지 않고 절벽에서 떨어진다.** 그리고 그 절벽은
|
||||
**사용자마다 다르다** — role 이 많은 사용자만 깨진다. 테스트 계정으로는
|
||||
영원히 안 보인다.
|
||||
|
||||
## 2-4. 신원 자체를 위조한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
curl -s \
|
||||
-H 'X-Auth-Request-User: administrator' \
|
||||
-H 'X-Auth-Request-Email: admin@example.com' \
|
||||
-H 'X-Auth-Request-Roles: realm-admin,superuser' \
|
||||
https://app1.hyeonworks.com/api/echo
|
||||
```
|
||||
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||
```
|
||||
=== Q4 ④ upstream 이 검증하는가 ===
|
||||
아무 인증 없이 보냄:
|
||||
x-auth-request-user ['administrator']
|
||||
x-auth-request-email ['admin@example.com']
|
||||
x-auth-request-roles ['realm-admin,superuser']
|
||||
remoteAddr 100.123.124.30
|
||||
→ 그대로 도착. 검증 없음.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **1-3 에서 비어 있던 자리에 값이 들어와 있다.**
|
||||
그리고 `remoteAddr` 이 **내 주소**다 — 숨지도 않았다.
|
||||
|
||||
**이 결과가 의미하는 것** — **로그인하지 않았다.** 쿠키도 토큰도 없다.
|
||||
헤더 세 줄이 전부다. upstream 은 그것을 그대로 받는다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 「통한 것」인지 확인한다
|
||||
|
||||
**「도착했다」와 「통했다」는 다르다.** 도착해도 아무도 안 읽으면 무해하다.
|
||||
그래서 **읽는 쪽이 검증을 하는지**를 대조군으로 확인한다.
|
||||
|
||||
## 3-1. JWT 를 요구하는 경로는 어떻게 되나
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
for p in /api/echo /api/me /api/protected; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" \
|
||||
-H 'X-Auth-Request-User: administrator' \
|
||||
-H 'X-Auth-Request-Roles: realm-admin' \
|
||||
"https://app1.hyeonworks.com$p"
|
||||
done
|
||||
```
|
||||
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||
```
|
||||
대조 — JWT 를 요구하는 경로:
|
||||
/api/echo HTTP 200 (permitAll)
|
||||
/api/me HTTP 401
|
||||
/api/protected HTTP 401
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **같은 위조 헤더인데 결과가 갈린다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 위조 헤더는 `/api/echo` 를 열어 준 것이 아니다.
|
||||
거기는 원래 `permitAll` 이라 열려 있었다. `/api/me` 는 **401** 이다 —
|
||||
**헤더로는 인증이 안 된다.**
|
||||
|
||||
**실측** — 같은 파일의 SecurityConfig 발췌
|
||||
```
|
||||
backend SecurityConfig:
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
|
||||
```
|
||||
|
||||
## 3-2. 그래서 무엇이 다른가
|
||||
|
||||
```
|
||||
JWT 경로 → 서명이 있다 → 검증할 대상이 있다 → 위조가 안 된다
|
||||
헤더 경로 → 서명이 없다 → 검증할 대상이 없다 → ★ 위조를 구별할 방법이 없다
|
||||
```
|
||||
|
||||
**`request.getHeader("X-Auth-Request-User")` 는 그 값이 어디서 왔는지 모른다.**
|
||||
edge 가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다.
|
||||
|
||||
> Q4 가 「확인한 사실」로 적어둔 *"upstream은 JWT를 입력으로 받지 않아서
|
||||
> 헤더로 넘어온 값을 검증할 방법이 없다"* — **정확하다. 그리고 그것이 이
|
||||
> 구조의 본질적 한계다.**
|
||||
>
|
||||
> 2홉 실험에서 헤더 위조로 `serverName: evil.example.com` 을 만든 것과
|
||||
> **같은 종류**다. 거기서는 쿠키 속성이었지만 **여기서는 신원 그 자체다.**
|
||||
|
||||
**여기까지가 요청만으로 되는 부분이다.** 여기서 멈춰도 ①②④ 는 다 봤다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 두 번째 주입 — IdP 에서 클레임을 바꾼다
|
||||
|
||||
**여기부터 상태가 바뀐다.** ③ 「role 변경은 언제 반영되는가」를 재려면
|
||||
**edge 세션이 실제로 있어야** 하므로 oauth2-proxy 가 필요하고, 그것이
|
||||
app2 를 쓴다.
|
||||
|
||||
## 4-0. app2 를 Grafana 에서 빌린다 — 되돌리는 것을 먼저 만든다
|
||||
|
||||
**하기** — **백업이 먼저다**
|
||||
```bash
|
||||
sudo kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
|
||||
wc -l /tmp/grafana-ingress-backup.yaml
|
||||
sudo kubectl -n observability delete ingress grafana
|
||||
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt) 의 첫 줄
|
||||
```
|
||||
grafana ingress 삭제
|
||||
```
|
||||
|
||||
**되돌리기** — [5-4](#5-4-grafana-ingress-를-되돌린다). **지금 확인해 둔다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||
```
|
||||
|
||||
> **`wc -l` 을 왜 치나** — 백업 파일이 **비어 있는데 삭제부터 하는** 사고를
|
||||
> 막는다. 0 줄이면 그 자리에서 멈춘다. 파일이 생겼는지 확인하지 않고 원본을
|
||||
> 지우는 것이 이런 작업에서 가장 흔한 사고다.
|
||||
|
||||
**하기** — 브라우저에서 `https://app2.hyeonworks.com/` 를 열고
|
||||
`labuser` / `labpass` 로 로그인한다.
|
||||
|
||||
**확인** — 세션이 생겼나. **지우기 전에 항상 목록을 먼저 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
의 기준선
|
||||
```
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
type=string ttl=3568초 크기=3510바이트
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키 이름이 `_oauth2_proxy-` 로 시작한다.**
|
||||
밑줄로 시작하고 안쪽은 밑줄이다. `'oauth2-proxy*'` 같은 패턴은 **하나도 안
|
||||
맞는다** — 그러면 「세션이 없다」로 오독하고, 이어서 지우는 명령이 조용히
|
||||
아무것도 안 지운다. **목록을 먼저 보는 이유가 이것이다.**
|
||||
|
||||
## 4-1. ★ 왜 curl 로 못 하는가
|
||||
|
||||
**확인** — oauth2-proxy 가 쿠키를 어떻게 만드는지 로그가 말한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
|
||||
httponly:true expiry:1h0m0s ... refresh:disabled
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`httponly:true`** 와 **`refresh:disabled`**.
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
- `httponly:true` — **JS 도 못 읽고, 브라우저 밖으로 꺼낼 수도 없다.**
|
||||
그래서 `curl -b` 로 로그인 상태를 흉내 낼 수 없다. **이 측정은 브라우저
|
||||
안에서 해야 한다.** 여기서 「curl 로 하면 되지 않나」를 붙들면 몇 시간이
|
||||
간다 — 원래 실행도 그래서 Playwright 로 연 브라우저를 썼다
|
||||
- `refresh:disabled` — **4-4 의 결과를 미리 말해 준다.** `--cookie-refresh`
|
||||
가 없으면 세션은 토큰을 다시 받지 않는다
|
||||
|
||||
## 4-2. 기준선 — 지금 무슨 값이 나가고 있나
|
||||
|
||||
**`X-Auth-Request-Roles` 대신 `x-forwarded-email` 을 쓴다.** role 을 헤더로
|
||||
내보내려면 추가 설정이 필요한데, **「IdP 의 클레임 변경이 언제 반영되는가」는
|
||||
어느 클레임이든 같은 질문**이다.
|
||||
|
||||
**하기** — 로그인된 app2 탭에서 `F12` → Console
|
||||
```js
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await (await fetch('/api/echo')).text();
|
||||
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
|
||||
}
|
||||
```
|
||||
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||
```
|
||||
=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
|
||||
2026-09-04T07:51:23.862Z req#1 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||
2026-09-04T07:51:24.304Z req#2 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||
2026-09-04T07:51:24.722Z req#3 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `labuser@example.com`. **이 값이 대조군이다.**
|
||||
|
||||
## 4-3. IdP 에서 값을 바꾼다
|
||||
|
||||
**하기** — 셸에서
|
||||
```bash
|
||||
UID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users -r keycloak-patterns -q username=labuser \
|
||||
--fields id --format csv --noquotes | tail -1)
|
||||
echo "uid=$UID"
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'
|
||||
```
|
||||
|
||||
**되돌리기** — [5-3](#5-3-idp-값을-되돌린다). 지금 명령을 확인해 둔다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update users/$UID -r keycloak-patterns -s email=labuser@example.com
|
||||
```
|
||||
|
||||
**확인** — IdP 쪽은 정말 바뀌었나. **바뀌지 않은 것을 「반영 안 됨」으로
|
||||
읽지 않기 위해** 반드시 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users/$UID -r keycloak-patterns --fields email
|
||||
```
|
||||
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||
```
|
||||
=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
|
||||
변경 시각(UTC): 2026-09-04T07:53:32.000Z
|
||||
IdP 의 값:
|
||||
[ {
|
||||
"email" : "changed-labuser@example.com"
|
||||
} ]
|
||||
oauth2-proxy 세션: 1 개 (그대로 살아 있다)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **IdP 값은 바뀌었고 세션은 그대로 1개**다.
|
||||
이 두 줄이 있어야 다음 절의 「옛 값」이 「반영 안 됨」이라고 말할 수 있다.
|
||||
|
||||
## 4-4. 요청을 반복한다 — 몇 번째부터 바뀌나
|
||||
|
||||
**하기** — 브라우저 콘솔에서. 0.5초 간격으로 12번
|
||||
```js
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const r = await (await fetch('/api/echo')).text();
|
||||
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
|
||||
await new Promise(s => setTimeout(s, 500));
|
||||
}
|
||||
```
|
||||
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||
```
|
||||
=== [3] 변경 후 12회 반복 (브라우저 fetch) ===
|
||||
2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:51:57.489Z req#3 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:51:58.018Z req#4 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:51:58.602Z req#5 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:51:59.217Z req#6 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:51:59.743Z req#7 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:52:00.342Z req#8 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:52:00.964Z req#9 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:52:01.574Z req#10 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:52:02.187Z req#11 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com
|
||||
|
||||
→ 12회 · 약 6.4초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **12줄이 전부 같다.**
|
||||
|
||||
**이 결과가 의미하는 것** — Q4 는 「몇 번째 요청부터 반영되는지」를 물었는데,
|
||||
**답은 「요청으로는 안 된다」이다.** 요청 횟수가 아니라 **세션의 나이**가 정한다.
|
||||
|
||||
## 4-5. ★ 두 시계가 어긋나 있다 — 그래서 이 결론이 성립한다
|
||||
|
||||
**브라우저 타임스탬프와 서버 타임스탬프를 그대로 비교하면 안 된다.**
|
||||
|
||||
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||
```
|
||||
=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
|
||||
개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z
|
||||
test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z
|
||||
→ test-server 가 약 107초 앞선다.
|
||||
브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **107초.**
|
||||
|
||||
**이 결과가 의미하는 것** — 보정 전에는 12회의 타임스탬프(`07:51:56~`)가
|
||||
변경 시각(`07:53:32`)보다 **앞서 보인다.** 그대로 읽으면 「변경 전에 잰
|
||||
것」이 되어 **결론이 통째로 무너진다.** 보정하면 12회는 변경 **11초 뒤**이고,
|
||||
그래야 「변경 후에도 옛 값」이라는 결론이 선다.
|
||||
|
||||
**확인** — 당신 환경의 어긋남을 잰다
|
||||
```bash
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||
```
|
||||
그리고 브라우저 콘솔에서
|
||||
```js
|
||||
new Date().toISOString()
|
||||
```
|
||||
두 값의 차가 보정값이다.
|
||||
|
||||
> **두 기계의 로그를 나란히 놓기 전에 시계를 확인한다.** D-4 는 이 확인을
|
||||
> 안 해서 인증서 공백을 처음에 잘못 계산했고, 나중에 **38분 25초**로
|
||||
> 정정했다. 같은 실수가 여기서도 났고, **증거 파일에 보정값을 적어 두는
|
||||
> 것**으로 처리했다.
|
||||
|
||||
## 4-6. 세션을 지우고 재인증시킨다
|
||||
|
||||
**하기** — **지우기 전에 목록을 본다**(4-0 의 이유)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \
|
||||
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||||
```
|
||||
**미검증** — 후속 문서 §3 에 실린 형태를 **실제 키 이름에 맞춰 고친 것**이다.
|
||||
패턴이 안 맞으면 아무것도 안 지워지고 오류도 안 난다 — 앞 명령의 목록과
|
||||
`del` 이 돌려주는 개수를 대조한다.
|
||||
|
||||
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 접근하면 새
|
||||
세션이 만들어진다(그게 이 절의 목적이다).
|
||||
|
||||
**하기** — 브라우저에서 app2 를 새로고침한 뒤 콘솔에서 3회
|
||||
```js
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await (await fetch('/api/echo')).text();
|
||||
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
|
||||
}
|
||||
```
|
||||
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||
```
|
||||
=== [6] 재인증 후 (브라우저 fetch) ===
|
||||
2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||
2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||
2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **새 값이 나온다.** 그리고 **로그인 화면은 안 떴다** —
|
||||
Keycloak SSO 가 살아 있어 조용히 재인증됐다(B-2 4-5 와 같은 성질이다).
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
변경 후 12회 요청(6.4초) → labuser@example.com (옛 값)
|
||||
세션 삭제 후 재인증 → changed-labuser@example.com (새 값)
|
||||
```
|
||||
|
||||
> **개념 — 세션은 로그인 시점의 스냅샷이다.**
|
||||
>
|
||||
> ```
|
||||
> 로그인 → IdP 가 준 클레임을 세션에 담는다
|
||||
> 이후 요청 → 세션에서 읽어 헤더로 내보낸다
|
||||
> └─ IdP 를 다시 부르지 않는다
|
||||
> IdP 에서 변경 → 세션은 모른다
|
||||
> ```
|
||||
>
|
||||
> | 설정 | 반영 시점 |
|
||||
> |---|---|
|
||||
> | 지금 (`--cookie-refresh` 없음) | **쿠키 만료(1시간) 또는 재인증까지 안 됨** |
|
||||
> | `--cookie-refresh=5m` | 최대 5분 |
|
||||
>
|
||||
> **권한을 뺏는 변경이 최대 1시간 늦게 반영된다.** 해고된 사용자의 세션이
|
||||
> 한 시간 더 산다는 뜻이고, 이것이 Q4 의 설계 판단 2번(**즉시 반영이
|
||||
> 필요한가**)에 직접 답한다 — 즉시가 필요하면 헤더 방식은 맞지 않는다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 고치기와 복구
|
||||
|
||||
## 5-1. nginx 에서 동명 헤더를 덮어쓴다 — 먼저 지워야 한다
|
||||
|
||||
**이 실험대는 이 수정을 적용한 적이 없다.** 해설 문서 6절이 「남긴 것」으로
|
||||
분류한 항목이다. 아래는 **미검증**이며, 적용하려면 랩 호스트에서 사람이
|
||||
직접 친다.
|
||||
|
||||
**하기** — **랩 호스트(`test-server`)** 에서. **백업이 먼저다**
|
||||
```bash
|
||||
sudo cp /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-available/keycloak-lab.b4-backup
|
||||
ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup
|
||||
sudo vi /etc/nginx/sites-available/keycloak-lab
|
||||
```
|
||||
`location / { ... }` 안, 기존 `proxy_set_header` 들 옆에 넣는다.
|
||||
```nginx
|
||||
# B-4 — 클라이언트가 보낸 X-Auth-Request-* 를 먼저 지운다.
|
||||
# 빈 값으로 set 해야 "설정한 헤더"가 되어 통과가 아니라 덮어쓰기가 된다.
|
||||
proxy_set_header X-Auth-Request-User "";
|
||||
proxy_set_header X-Auth-Request-Email "";
|
||||
proxy_set_header X-Auth-Request-Roles "";
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`""` 로 먼저 지우는 것**이 핵심이다.
|
||||
|
||||
> **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다**(1-4). 그러니
|
||||
> 「덮어쓰게 하려면 먼저 설정해야」 하고, 붙일 값이 없을 때 설정하는 방법이
|
||||
> **빈 문자열**이다. `proxy_set_header X-Auth-Request-Roles "";` 는
|
||||
> nginx 에서 **그 헤더를 upstream 으로 보내지 않는다**는 뜻이다.
|
||||
>
|
||||
> edge 가 진짜 값을 붙여야 하는 자리라면 **지운 뒤에 다시 설정**한다.
|
||||
> 순서가 반대면 클라이언트 값이 살아남는다.
|
||||
|
||||
**하기** — 문법을 보고 적용한다
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `nginx -t` 의 **마지막 줄**. `syntax is ok` 와
|
||||
`test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 은 통과를
|
||||
막지 않는다. 실패면 `&&` 가 reload 를 **막아 준 것**이고 지금 돌고 있는
|
||||
nginx 는 옛 설정 그대로다.
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 5-2. 고쳐졌는지 같은 명령으로 다시 잰다
|
||||
|
||||
**하기** — 2-1 과 **똑같은 명령**
|
||||
```bash
|
||||
curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
|
||||
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||
```
|
||||
**미검증** — 이 실험대는 여기까지 재지 않았다.
|
||||
|
||||
**어디를 봐야 하는가** — **아무것도 안 나와야 한다**(1-3 의 대조군과 같아진다).
|
||||
값이 그대로 나오면 reload 가 안 갔거나 다른 `server` 블록을 고친 것이다.
|
||||
워커 PID 가 바뀌었는지로 reload 여부를 판정한다.
|
||||
```bash
|
||||
systemctl status nginx --no-pager | head -20
|
||||
```
|
||||
|
||||
## 5-3. IdP 값을 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
UID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users -r keycloak-patterns -q username=labuser \
|
||||
--fields id --format csv --noquotes | tail -1)
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update users/$UID -r keycloak-patterns -s email=labuser@example.com
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users/$UID -r keycloak-patterns --fields email
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `"email" : "labuser@example.com"`.
|
||||
**4-6 에서 배운 대로, 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다.**
|
||||
세션을 한 번 더 지우면 확실하다.
|
||||
|
||||
## 5-4. Grafana Ingress 를 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||
```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl get ingress -A | grep app2
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `app2` 를 잡고 있는 Ingress가 **`observability/grafana`
|
||||
하나**여야 한다. 둘이 남아 있으면 어느 쪽이 이길지는 컨트롤러가 정한다 —
|
||||
**되돌린 것이 아니라 경합을 만든 것**이다.
|
||||
|
||||
> **oauth2-proxy Deployment 자체는 남겨도 된다.** Ingress 만 떼면 app2 로는
|
||||
> 안 들어간다. B-7 을 이어서 할 거라면 그대로 두는 편이 낫다.
|
||||
|
||||
## 5-5. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| app2 | `sudo kubectl get ingress -A \| grep app2` | `observability/grafana` **하나만** |
|
||||
| Grafana | `curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/` | Grafana 가 답한다 (`200` 또는 로그인 `302`) |
|
||||
| app1 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/echo` | `200` |
|
||||
| IdP | `… kcadm.sh get users/$UID -r keycloak-patterns --fields email` | `labuser@example.com` |
|
||||
| nginx | `sudo nginx -t` (호스트) | `test is successful` |
|
||||
| nginx 백업 | `ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup` | 되돌렸으면 지워도 된다 |
|
||||
| Redis | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | 로그아웃했으면 없거나, 새 세션 하나 |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 동명 헤더가 **하나만 도착한 것처럼** 보인다 | **`tr ',' '\n'` 으로 잘랐다.** 값 배열이 두 줄로 쪼개진다 | `grep -o '…\[[^]]*\]'` 로 대괄호째 뽑는다 — 1-2 |
|
||||
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep -o` 로 뽑거나 응답을 통째로 본다 |
|
||||
| `python3 -m json.tool` 을 쓰라고 되어 있다 | 해설 문서 7절의 형태다. 값 생성도 `python3 -c` 였다 | `head -c N /dev/zero \| tr '\0' 'r'` — 2-3 |
|
||||
| 16000 에서 `000` 이 나온다 | **오류가 아니라 측정 결과다.** nginx 가 연결을 끊는다 | `400`(Tomcat)과 `000`(nginx)을 구별한다 — 2-3 |
|
||||
| `sudo grep` 이 빈 결과 | **호스트 sudo 는 비밀번호를 요구한다** | `sudo -n -l` 로 확인. D-4 가 이 조용한 실패에 걸렸다 |
|
||||
| Redis 에서 세션이 안 보인다 | 패턴이 틀렸다. 키는 **`_oauth2_proxy-`** 로 시작한다 | 먼저 `--scan` 만 쳐서 이름을 눈으로 본다 — 4-0 |
|
||||
| `xargs … del` 이 아무것도 안 지운다 | 같은 원인. 패턴이 안 맞으면 **조용히** 0건 | 목록 개수와 `del` 반환 개수를 대조 — 4-6 |
|
||||
| `curl -b` 로 로그인 상태가 재현이 안 된다 | **쿠키가 `HttpOnly` 다.** 꺼낼 수 없다 | 브라우저 콘솔에서 잰다 — 4-1 |
|
||||
| 12회가 **변경 시각보다 앞서** 보인다 | **두 시계가 107초 어긋나 있었다** | 보정값을 먼저 잰다 — 4-5 |
|
||||
| 값이 안 바뀐다 | **버그가 아니다.** 세션이 새로 만들어져야 한다 | 4-6 · `--cookie-refresh` |
|
||||
| app2 가 Grafana 도 프록시도 아닌 것을 준다 | Ingress 가 **둘 다 남아 있다** | `get ingress -A \| grep app2` — 5-4 |
|
||||
| `/api/me` 가 `200` 이다 | 위조가 통한 것이 **아니라** 진짜 JWT 를 보낸 것이다 | 헤더만 보냈는지 다시 본다 — 3-1 |
|
||||
|
||||
---
|
||||
|
||||
# 이 실험이 재지 않은 것
|
||||
|
||||
| 항목 | 왜 |
|
||||
|---|---|
|
||||
| 5-1 의 nginx 수정 효과 | **적용한 적이 없다.** 해설 문서가 「남긴 것」으로 분류했다 |
|
||||
| `X-Auth-Request-Roles` 자체의 반영 시점 | role 을 헤더로 내보내려면 추가 설정이 필요해 **`x-forwarded-email` 로 대체**했다. 「클레임 변경이 언제 반영되는가」는 어느 클레임이든 같다 |
|
||||
| `--cookie-refresh=5m` 을 켠 뒤의 반영 시점 | 표의 「최대 5분」은 설정의 정의이지 **이 실험대에서 잰 값이 아니다** |
|
||||
| upstream 의 내부 credential 검증 | controller 한 곳에만 있다. **공통 경계로 옮기는 것은 코드 변경**이라 이 실험 밖이다 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-4 가 남긴 질문 |
|
||||
|---|---|
|
||||
| B-7 oauth2-proxy | cookie secret 을 회전하면 **저장소에 무엇이 남는가**. app2 를 빌리는 절차가 같다 |
|
||||
| B-7a 고아 세션 | 4-6 에서 지운 그 키들의 **수명과 정리 규칙** |
|
||||
| [B-2](b2-multi-instance-session.md) BFF | 같은 질문을 **서버 보관 토큰**으로 풀면 어떻게 다른가 |
|
||||
| 설계 | **2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 |
|
||||
@@ -0,0 +1,883 @@
|
||||
# B-5 재현 가이드 — Redis 를 내리고 파드가 `Ready` 인 채로 계속 실패하는 것을 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b5-redis-loss-persistence.md`](../../experiment-b5-redis-loss-persistence.md) ·
|
||||
증거 원문: [`docs/evidence/b5-redis-loss/`](../../evidence/b5-redis-loss/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 오류가 아니라 **멈추는** 것 (`HTTP 000`) | 밖에서 `curl` |
|
||||
| `/actuator/health` 는 `503` 인데 **파드는 `1/1 Ready`** | health 그룹별 응답 |
|
||||
| Service 엔드포인트에 **두 파드가 그대로** 남아 있는 것 | `endpointslice` |
|
||||
| 손대지 않아도 **재시작 0회로 회복**하는 것 | `get pods` · Lettuce |
|
||||
| **AOF 를 켰는데 재시작 후 `dbsize 0`** 인 것 | 볼륨 없는 `/data` |
|
||||
| 볼륨 위에서는 **살아남는** 것 | PVC 를 붙인 뒤 같은 시험 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- [`B-1`](b1-redis-session-store.md) · [`B-2`](b2-multi-instance-session.md)
|
||||
가 끝나 **세션은 Redis, 토큰은 PostgreSQL** 로 나뉘어 있다.
|
||||
나뉘어 있어야 **각각 죽여볼 수 있다** — 이 실험은 Redis 만 죽인다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 브라우저로 `https://app1.hyeonworks.com/` 에 **로그인해 둔다**
|
||||
(`labuser` / `labpass`). Redis 에 세션이 하나는 있어야 「잃는 것」이 보인다.
|
||||
- Redis 는 `redis.keycloak-lab.svc:6379`, 파드는 `kc-lab-2` 에 고정되어 있다.
|
||||
|
||||
## 주의 — 이건 저장소를 지우는 실험이다
|
||||
|
||||
Redis 를 0대로 내리고, 나중에 **볼륨 없이 파드를 지운다.** 그 안의 세션은
|
||||
**돌아오지 않는다.** 로그인한 사용자는 전부 로그아웃된다. **실험대에서만 한다.**
|
||||
전 구간 약 30분. 중간에 그만두려면 [5-1](#5-1-되돌린다) 의 한 줄이면 된다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:24–14:28 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||
|
||||
**이 실험대는 그 뒤로 바뀌었다.** 지금 매니페스트
|
||||
([`bff-redis.yaml`](../../../deploy/lab/k8s/bff-redis.yaml))에는 **B-5 의 결론이
|
||||
이미 반영되어** PVC 와 `--appendonly yes` 가 들어 있다. 그래서 6절은
|
||||
「볼륨 없는 상태를 다시 만드는」 단계부터 시작한다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
A-2 에서 Keycloak 의 PostgreSQL 을 내렸다. 그때는 이렇게 됐다.
|
||||
|
||||
```
|
||||
DB 정지 → 헬스체크 실패 → 파드 NotReady → Service 에서 빠짐 → 밖에서 503
|
||||
```
|
||||
|
||||
**명확한 실패였다.** 503 은 「지금 안 된다」고 말하고, 클라이언트는 재시도든
|
||||
포기든 결정할 수 있다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 통념 | 의존 저장소가 죽으면 **헬스체크가 알아서 파드를 빼 준다** |
|
||||
| B-5 가 재는 것 | 진짜 그런가. **그리고 이번에는 무엇을 보고 판단하는가** |
|
||||
|
||||
그리고 두 번째 질문이 붙는다.
|
||||
|
||||
```
|
||||
Redis 를 다시 띄우면 → 세션이 남아 있나?
|
||||
```
|
||||
|
||||
**「영속화를 켜 두면 남는다」가 통념이다.** 이 실험은 그 통념이 쿠버네티스에서
|
||||
어떻게 어긋나는지를 잰다. 그래서 **1-3 이 이 가이드에서 가장 중요한 절**이다 —
|
||||
영속화를 논하기 전에 **`/data` 가 무엇인지부터** 본다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — Redis 를 내리기 전에
|
||||
|
||||
```
|
||||
파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹
|
||||
```
|
||||
|
||||
## 1-1. 파드와 노드
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
bff-555df79c97-6j86w 1/1 Running 0 17m 10.42.0.52 kc-lab-1
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 16m 10.42.1.124 kc-lab-2
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- `bff` 가 **둘 다** `1/1`, `RESTARTS` 가 `0`
|
||||
- **Redis 는 하나다.** replica 가 없다 — 그래서 0으로 내리면 전면 정지다
|
||||
- Redis 와 PostgreSQL 이 **같은 노드**(`kc-lab-2`)다. 매니페스트가
|
||||
`nodeSelector` 로 고정한다 — A-4(노드 상실)에서 **두 저장소가 한꺼번에**
|
||||
없어지게 하려는 배치다
|
||||
|
||||
`10.42.0.52` 와 `10.42.1.124` 는 [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||
에 남은 실제 BFF 파드 IP 다. **4-3 에서 이 두 주소가 다시 나온다.**
|
||||
|
||||
## 1-2. Redis 가 지금 무엇을 들고 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/b5-redis-loss/01-baseline.txt)
|
||||
```
|
||||
=== 기준선 ===
|
||||
Redis 키: 1
|
||||
PostgreSQL 토큰: 1 행
|
||||
Redis 영속화 설정:
|
||||
save = save
|
||||
appendonly no
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지.
|
||||
|
||||
| 값 | 그때 | 뜻 |
|
||||
|---|---|---|
|
||||
| 키 수 | `1` | 로그인 세션 하나 |
|
||||
| `save` | 빈 값 | **RDB 스냅샷이 꺼져 있다** |
|
||||
| `appendonly` | `no` | **AOF 도 꺼져 있다** |
|
||||
|
||||
**이 결과가 의미하는 것** — 그때는 **영속화가 아예 꺼져 있었다.**
|
||||
지금 당신 환경은 다를 것이다 — 매니페스트가 `--appendonly yes` 로 시작하므로
|
||||
`appendonly yes` 가 나온다. **그 차이가 6절의 출발점이다.**
|
||||
|
||||
> `save` 출력의 값이 비어 있는 것과 키가 없는 것은 다르다.
|
||||
> `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면
|
||||
> 「스냅샷 조건 없음」이다. 증거의 `save = save` 는 그 두 줄이 한 줄로
|
||||
> 붙어 찍힌 모양이다.
|
||||
|
||||
## 1-3. ★ `/data` 가 볼륨인가 — 영속화를 말하기 전에 여기부터 본다
|
||||
|
||||
**이 절을 건너뛰면 6절의 결과를 오해한다.** 「AOF 를 켰는데 안 남는다」를
|
||||
「Redis 가 이상하다」로 읽게 된다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||
```
|
||||
**형태** — 지금 매니페스트 기준
|
||||
```json
|
||||
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
|
||||
```
|
||||
|
||||
**확인** — 그 볼륨이 `/data` 에 붙어 있나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
|
||||
sudo kubectl -n keycloak-lab get pvc
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
redis-data Bound pvc-... 1Gi RWO local-path 3d
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지가 **전부** 성립해야 한다.
|
||||
|
||||
```
|
||||
① volumes 에 항목이 있다 ← 없으면 컨테이너 파일시스템이다
|
||||
② volumeMounts 의 mountPath 가 /data ← 다른 데 붙었으면 소용없다
|
||||
③ PVC 가 Bound ← Pending 이면 파드가 안 뜬다
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 셋 중 하나라도 빠지면 **`appendonly yes` 는
|
||||
장식이다.** 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.
|
||||
6절에서 그것을 직접 만든다.
|
||||
|
||||
> **개념 — 컨테이너 파일시스템은 컨테이너와 함께 죽는다.**
|
||||
>
|
||||
> ```
|
||||
> /data 가 볼륨이 아니다 → 이미지 위의 쓰기 가능 레이어에 쓴다
|
||||
> → 컨테이너가 없어지면 그 레이어도 없어진다
|
||||
> ```
|
||||
>
|
||||
> Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에
|
||||
> `appendonlydir` 을 만들고 매 쓰기를 기록한다. **거짓말이 아니라 정말로
|
||||
> 기록한다.** 다만 그 디렉터리가 어디 있는지를 모를 뿐이다.
|
||||
>
|
||||
> `emptyDir` 도 마찬가지다 — 컨테이너 재시작은 견디지만 **파드가 없어지면
|
||||
> 같이 없어진다.** 「볼륨을 붙였다」와 「영속 볼륨을 붙였다」는 다르다.
|
||||
|
||||
## 1-4. 세 경로를 정상 상태에서 잰다
|
||||
|
||||
**주입 후에 볼 것을 주입 전에 똑같은 명령으로 먼저 봐 둔다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
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
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/b5-redis-loss/01-baseline.txt) 는 첫 줄만 남겼다
|
||||
```
|
||||
=== 외부 진입점 정상 확인 ===
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`000` 이 아닌 것.** 그게 판정 기준의 전부다.
|
||||
|
||||
| 경로 | 정상일 때 | 왜 |
|
||||
|---|---|---|
|
||||
| `/` | `200` | `permitAll` 정적 페이지. **Redis 를 안 탄다** |
|
||||
| `/bff/token-boundary` | `200` 또는 로그인으로 보내는 `3xx` | 세션이 필요하다 — **Redis 를 탄다** |
|
||||
| `/actuator/health` | `200` | 모든 지표의 합 |
|
||||
|
||||
**미검증** — 셸의 `curl` 에는 로그인 쿠키가 없으므로 두 번째는 보통 `3xx` 다.
|
||||
**`200` 이든 `3xx` 든 상관없다** — 이 실험이 보는 것은 **응답이 오는가**이고,
|
||||
`3xx` 를 만드는 과정에서도 BFF 는 세션을 만들려고 **Redis 를 건드린다.**
|
||||
|
||||
> **`--max-time` 을 반드시 붙인다.** 4-1 에서 이 요청은 **응답이 안 온다.**
|
||||
> 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 「멈춤」이 아니라
|
||||
> 「내 터미널이 이상함」으로 읽게 된다.
|
||||
|
||||
## 1-5. health 그룹을 미리 본다
|
||||
|
||||
**4-2 의 놀라움은 기준선을 봐 둬야 놀라움이 된다.**
|
||||
|
||||
**확인** — `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다)
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **첫 번째 응답의 본문에 `redis` 항목이 있는지**,
|
||||
그리고 **두 번째 응답에는 없는지.** 세 응답이 서로 다른 것을 본다는 것이
|
||||
이 절의 전부다.
|
||||
|
||||
**실측** — 정지 후의 값은 [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||
에 있고, 그 본문 항목 자리는 **비어 있다**
|
||||
```
|
||||
=== /actuator/health 본문 (Redis 항목이 있는가) ===
|
||||
|
||||
|
||||
=== /actuator/health/readiness 본문 ===
|
||||
{"status":"UP"}
|
||||
```
|
||||
|
||||
**첫 번째 칸이 비어 있는 것은 측정 실패다.** 파드 안에서 본문을 받아오려다
|
||||
못 받았다. **당신은 지금 밖에서 직접 재 두는 것이 낫다** — 뒤에서 이 자리를
|
||||
비교하게 된다.
|
||||
|
||||
> **개념 — Spring Boot 의 health group.**
|
||||
>
|
||||
> ```
|
||||
> /actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
|
||||
> /actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
|
||||
> /actuator/health/liveness liveness 그룹
|
||||
> ```
|
||||
>
|
||||
> **`redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다.**
|
||||
> 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로 —
|
||||
> `readinessProbe.httpGet.path: /actuator/health/readiness` — 다.
|
||||
>
|
||||
> **전체는 DOWN 인데 readiness 는 UP 인 상태**가 성립하고, 4-2 가 그것이다.
|
||||
|
||||
**확인** — kubelet 이 실제로 무엇을 보는지 매니페스트에서 확인한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy bff \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
/actuator/health/readiness
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 ① — Redis 를 0대로 내린다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||
```
|
||||
|
||||
## 2-1. 왜 `scale --replicas=0` 인가
|
||||
|
||||
| 방법 | 만들어지는 상태 |
|
||||
|---|---|
|
||||
| `delete pod` | Deployment 가 **즉시 새로 만든다.** 몇 초짜리 공백이라 관찰할 시간이 없다 |
|
||||
| **`scale --replicas=0`** | **없는 상태가 유지된다.** 내가 되돌릴 때까지 |
|
||||
| NetworkPolicy 로 6379 차단 | 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 **기존 연결은 안 끊긴다** |
|
||||
|
||||
**「저장소가 없어진 상태」를 안정적으로 유지하는 것이 목적**이므로 두 번째다.
|
||||
그리고 이 방법은 **파드가 사라지므로 주입 여부를 눈으로 확인하기 쉽다**(3-1).
|
||||
|
||||
## 2-2. 적용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 정지'
|
||||
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=0
|
||||
```
|
||||
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||
```
|
||||
=== ① Redis 정지 ===
|
||||
정지: 14:26:30
|
||||
deployment.apps/redis scaled
|
||||
삭제 완료
|
||||
```
|
||||
|
||||
**시각을 반드시 적어 둔다.** 5절에서 「언제부터 회복됐나」를 붙일 때 쓴다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
## 3-1. Redis 파드가 정말 없나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=redis
|
||||
sudo kubectl -n keycloak-lab get deploy redis
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
No resources found in keycloak-lab namespace.
|
||||
|
||||
NAME READY UP-TO-DATE AVAILABLE AGE
|
||||
redis 0/0 0 0 3d
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `0/0`. `1/1` 이면 스케일이 안 먹었거나 다른
|
||||
네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.
|
||||
|
||||
## 3-2. BFF 가 정말 못 붙고 있나
|
||||
|
||||
**응답이 없는 것과 붙지 못하는 것은 다르다.** 로그가 이유를 말한다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10
|
||||
```
|
||||
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||
```
|
||||
=== BFF 로그 ===
|
||||
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
|
||||
at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
|
||||
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
|
||||
at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`pollConnect` · `finishConnect`.** 연결을 **맺는
|
||||
중**이라는 뜻이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이미 실패한 것이 아니라 **아직 시도 중**이다.
|
||||
Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다.
|
||||
**4-1 의 `000` 이 여기서 나온다.**
|
||||
|
||||
## 3-3. 엉뚱한 것을 죽이지 않았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`bff` 두 개의 `RESTARTS` 가 여전히 0**, 그리고
|
||||
**postgres 가 살아 있는 것.** postgres 까지 내렸다면 이건 B-5 가 아니라
|
||||
전면 장애를 재는 것이다.
|
||||
|
||||
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||
```
|
||||
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
|
||||
bff-555df79c97-6j86w 1/1 Running 0 17m
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 16m
|
||||
```
|
||||
|
||||
**여기서 이미 답이 절반 나와 있다** — Redis 가 없는데 **`1/1`** 이다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. `000` 은 오류가 아니라 멈춤이다
|
||||
|
||||
**확인** — 1-4 와 **똑같은 명령**
|
||||
```bash
|
||||
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
|
||||
```
|
||||
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||
```
|
||||
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
|
||||
/ HTTP 200
|
||||
/bff/token-boundary HTTP 000
|
||||
/actuator/health HTTP 503
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 값이 **서로 다르다.**
|
||||
|
||||
| 코드 | 뜻 |
|
||||
|---|---|
|
||||
| `200` | 정적 페이지는 산다 — **Redis 를 안 타는 경로** |
|
||||
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
|
||||
| `503` | 헬스 엔드포인트는 **대답은 한다** — 다만 DOWN 이라고 |
|
||||
|
||||
**이 결과가 의미하는 것** — **오류를 돌려주는 것이 아니라 매달려 있다.**
|
||||
|
||||
```
|
||||
빠른 실패: 요청 → 즉시 503 → 사용자는 오류 화면을 본다. 재시도할지 정할 수 있다
|
||||
느린 실패: 요청 → ………… → 사용자는 멈춘 화면을 본다. 아무것도 정할 수 없다
|
||||
```
|
||||
|
||||
**「빨리 실패하기(fail fast)」가 안 되어 있다.** A-6(지연 주입)에서 본 것과
|
||||
같은 문제다 — **느린 실패가 빠른 실패보다 나쁘다.** 브라우저 탭도, 그 앞의
|
||||
로드밸런서도, 그 앞의 사용자도 전부 붙잡힌다.
|
||||
|
||||
**응답 본문도 비어 있다.**
|
||||
|
||||
**실측** — 같은 파일
|
||||
```
|
||||
--- token-boundary 응답 본문 ---
|
||||
|
||||
|
||||
```
|
||||
**본문이 없다는 것은 「오류 페이지조차 못 만들었다」**는 뜻이다.
|
||||
|
||||
> **고치려면 클라이언트에 타임아웃을 건다.** Lettuce 의 연결·명령 타임아웃을
|
||||
> 짧게 잡으면 `000` 이 `500` 이 된다. **500 이 000 보다 낫다** — 적어도
|
||||
> 말은 하기 때문이다.
|
||||
|
||||
## 4-2. ★ 그런데 파드는 `Ready` 를 유지한다
|
||||
|
||||
**이것이 이 실험의 가장 중요한 발견이다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
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
|
||||
```
|
||||
**실측** — [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||
```
|
||||
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
|
||||
/actuator/health HTTP server
|
||||
/actuator/health/readiness HTTP 200
|
||||
/actuator/health/liveness HTTP 200
|
||||
|
||||
=== /actuator/health 본문 (Redis 항목이 있는가) ===
|
||||
|
||||
|
||||
=== /actuator/health/readiness 본문 ===
|
||||
{"status":"UP"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `readiness` 가 **`200` 이고 `{"status":"UP"}`**.
|
||||
|
||||
> **첫 줄의 `HTTP server` 는 상태 코드가 아니다 — 측정이 실패한 것이다.**
|
||||
> 값이 들어와야 할 자리에 엉뚱한 문자열이 들어와 있다. `503` 이라는 값은
|
||||
> [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt) 쪽
|
||||
> 측정에서 나왔다.
|
||||
>
|
||||
> **빈 값이나 이상한 값을 「측정 결과」로 읽지 않는다.** 그건 「측정 실패」다.
|
||||
> A-1 에서도 빈 문자열을 「변화」로 읽어 판정이 틀어진 적이 있다.
|
||||
> 이상하면 그 자리에서 다시 친다 — 손으로 하나씩 치는 이유가 이것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 전체 상태는 DOWN 인데 **kubelet 이 보는 그룹은 UP**
|
||||
이다. 그래서 **파드를 빼지 않는다.**
|
||||
|
||||
```
|
||||
/actuator/health redis: DOWN → 전체 DOWN → 503
|
||||
/actuator/health/readiness readinessState 만 → UP → kubelet: "정상"
|
||||
```
|
||||
|
||||
## 4-3. Service 엔드포인트에 둘 다 남아 있다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=bff \
|
||||
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||
```
|
||||
**실측** — [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||
```
|
||||
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
|
||||
ready: [10.42.0.52 10.42.1.124]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 주소가 그대로 ready 다.** 1-1 에서 본 그 두 IP.
|
||||
|
||||
**이 결과가 의미하는 것** — **두 파드가 계속 트래픽을 받으며 계속 실패한다.**
|
||||
어느 replica 로 가도 결과는 같으므로 **재시도해도 소용없다.**
|
||||
|
||||
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
|
||||
> 뜬다. 해설 문서 5절의 재현 절차에는 옛 형태(`get endpoints bff`)가 실려
|
||||
> 있다 — `endpointslice` 로 본다.
|
||||
|
||||
## 4-4. A-2 와의 대비가 이 실험의 결론이다
|
||||
|
||||
| | A-2 (Keycloak · DB 상실) | **B-5 (BFF · Redis 상실)** |
|
||||
|---|---|---|
|
||||
| 의존 대상 헬스 지표 | **readiness 에 포함** | **포함 안 됨** |
|
||||
| 파드 상태 | **NotReady** | **Ready 유지** |
|
||||
| Service 엔드포인트 | **비었다** | 둘 다 남는다 |
|
||||
| 외부 응답 | **503** (즉시, 명확) | **000** (멈춤) |
|
||||
|
||||
**Keycloak 은 자기 의존성을 readiness 에 넣었고, 이 BFF 는 안 넣었다.**
|
||||
어느 쪽이 옳은지는 상황에 달렸다.
|
||||
|
||||
| readiness 에 넣으면 | 넣지 않으면 |
|
||||
|---|---|
|
||||
| 의존 대상이 죽으면 **전 파드가 빠진다** → 전면 장애 | 파드가 남아 **실패를 계속 서빙한다** |
|
||||
| 부분 기능이라도 살릴 수 없다 | 부분 기능(정적 페이지 등)은 살아 있다 |
|
||||
| A-2 처럼 **명확한 503** | **멈춤** — 진단이 어렵다 |
|
||||
|
||||
**의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다.**
|
||||
넣기로 정했다면 명시한다.
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: readinessState, redis # 넣으려면 명시해야 한다
|
||||
```
|
||||
|
||||
> **liveness 에는 넣지 않는다.** liveness 가 실패하면 kubelet 이 파드를
|
||||
> **죽인다.** Redis 가 없어서 죽인 파드는 다시 떠도 Redis 가 없으므로 또
|
||||
> 죽는다 — **재시작해도 안 나아지는 문제에 재시작을 거는 것**이다.
|
||||
> 5-2 가 그 반대 증거다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구 ① — 되돌리고 자동 회복을 본다
|
||||
|
||||
## 5-1. 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 복구'
|
||||
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||
```
|
||||
=== 복구 ===
|
||||
deployment.apps/redis scaled
|
||||
deployment "redis" successfully rolled out
|
||||
```
|
||||
|
||||
## 5-2. 손대지 않고 회복하는지 본다
|
||||
|
||||
**BFF 를 재시작하고 싶은 충동을 참는다.** 재시작하면 「스스로 회복하는가」를
|
||||
영영 알 수 없다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
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"
|
||||
done
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||
```
|
||||
/actuator/health HTTP 200
|
||||
/bff/token-boundary HTTP 302
|
||||
BFF 재시작 필요했나: 0,0 회 재시작
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`재시작 0,0`.**
|
||||
|
||||
**이 결과가 의미하는 것** — **Lettuce 가 스스로 재연결했다.** A-2 에서
|
||||
Keycloak 의 커넥션 풀이 그랬던 것과 같다. **liveness 를 Redis 에 걸었다면
|
||||
파드가 재시작됐을 것**이고, 회복이 더 늦어졌을 것이다.
|
||||
|
||||
**`302` 는 실패가 아니다.** 세션이 사라졌으므로 로그인으로 보내는 것이다.
|
||||
**Redis 가 비었으므로 로그인 상태가 없다 — 사용자는 로그아웃된다.**
|
||||
|
||||
> **여기가 6절로 넘어가는 다리다.** 「Redis 를 다시 띄웠는데 왜 세션이
|
||||
> 없나」가 다음 질문이고, 답은 「영속화가 없었으니까」다. 그럼 켜면 되나?
|
||||
|
||||
---
|
||||
|
||||
# 6. 주입 ② — 영속화를 켜고 파드를 지운다
|
||||
|
||||
## 6-1. 볼륨이 없던 상태를 다시 만든다
|
||||
|
||||
**지금 실험대에는 이미 PVC 가 붙어 있다**(1-3 에서 확인했다). 원래 측정
|
||||
당시에는 없었다. **볼륨을 떼야 그때를 재현한다.**
|
||||
|
||||
**되돌리기** — **먼저 읽어 둔다**
|
||||
```bash
|
||||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch deployment redis --type=json \
|
||||
-p '[{"op":"remove","path":"/spec/template/spec/containers/0/volumeMounts"},
|
||||
{"op":"remove","path":"/spec/template/spec/volumes"}]'
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
**미검증** — 원래 실행은 **반대 순서**였다(볼륨 없는 상태에서 시작해 PVC 를
|
||||
붙였다). 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다.
|
||||
|
||||
**확인** — 1-3 과 **똑같은 명령**으로 떨어진 것을 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면
|
||||
패치가 안 먹은 것이고, 그 상태로 6-3 을 하면 **당연히 살아남는다** — 그리고
|
||||
그걸 「영속화가 잘 된다」로 오독한다.
|
||||
|
||||
> **PVC 자체는 지우지 않는다.** Deployment 에서 참조만 뗐다. 6-5 에서
|
||||
> `apply` 로 되돌리면 같은 PVC 에 다시 붙는다. **PVC 를 지우면
|
||||
> `local-path` 프로비저너가 노드의 디렉터리까지 지운다.**
|
||||
|
||||
## 6-2. AOF 를 켜고 키를 심는다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- ls -la /data
|
||||
```
|
||||
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||
```
|
||||
--- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
|
||||
appendonly yes
|
||||
total 12
|
||||
drwxr-xr-x 3 redis redis 4096 Sep 4 05:26 .
|
||||
drwxr-xr-x 1 root root 4096 Sep 4 05:26 ..
|
||||
drwx------ 2 redis redis 4096 Sep 4 05:26 appendonlydir
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`appendonlydir` 이 실제로 만들어졌다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **Redis 는 시킨 대로 했다.** 설정도 `yes` 고
|
||||
디렉터리도 있고 파일도 쓰인다. **여기서 「영속화가 켜졌다」고 결론 내리면
|
||||
틀린다** — 어디에 쓰는지를 안 봤기 때문이다. 1-3 에서 이미 본 대로 지금
|
||||
`/data` 는 **컨테이너 파일시스템**이다.
|
||||
|
||||
## 6-3. 파드를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod -l app=redis
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
```
|
||||
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||
```
|
||||
--- 파드를 지운다 ---
|
||||
deployment "redis" successfully rolled out
|
||||
재기동 후:
|
||||
dbsize: 0
|
||||
b5:probe
|
||||
b5:aof
|
||||
appendonly no
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 가지가 같이 사라졌다.**
|
||||
|
||||
| 사라진 것 | 왜 |
|
||||
|---|---|
|
||||
| **데이터** | `/data` 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다 |
|
||||
| **설정** | `CONFIG SET` 은 **런타임 전용**이다. 재기동하면 매니페스트의 `args` 가 이긴다 |
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
> **쿠버네티스에서 영속화 설정만 켜는 것은 장식이다.**
|
||||
> `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — **파일은 만들어지고
|
||||
> 로그도 정상이며, 사라지는 것은 재시작 순간뿐**이다. 그리고 재시작은
|
||||
> 노드 정비·이미지 갱신·OOM 어느 것으로든 일어난다.
|
||||
|
||||
**설정이 되돌아간 것도 따로 중요하다.** `CONFIG SET` 으로 고친 값은
|
||||
`CONFIG REWRITE` 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가
|
||||
안 남는다. **런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.**
|
||||
|
||||
## 6-4. 볼륨을 되돌리고 같은 시험을 다시 한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
**확인** — 1-3 과 **똑같은 명령**으로 볼륨이 돌아온 것을 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
```
|
||||
|
||||
**하기** — 키를 심고 다시 지운다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
|
||||
sudo kubectl -n keycloak-lab delete pod -l app=redis
|
||||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc
|
||||
```
|
||||
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||
```
|
||||
=== 영속 볼륨 위에서 다시 시험 ===
|
||||
appendonly yes
|
||||
키 심음: written-on-pvc
|
||||
sed: -e expression #1, char 8: unknown option to 's'
|
||||
|
||||
--- 파드를 지운다 ---
|
||||
deployment "redis" successfully rolled out
|
||||
재기동 후:
|
||||
dbsize: 1
|
||||
b5:pvc written-on-pvc
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`dbsize: 1` 과 `written-on-pvc`.** 살아남았다.
|
||||
|
||||
> 중간의 `sed: -e expression #1, char 8: unknown option to 's'` 는
|
||||
> **원래 실행의 스크립트가 낸 오류**이고 측정과는 무관하다. 값에 `/` 가
|
||||
> 들어간 문자열을 `sed 's/.../.../'` 에 그대로 넣으면 이렇게 된다.
|
||||
> **증거 파일에 남은 오류를 지우지 않은 것**은, 그것이 「이 줄은 스크립트가
|
||||
> 만든 것」이라는 표시이기 때문이다.
|
||||
|
||||
## 6-5. 순서가 있다
|
||||
|
||||
| 구성 | 파드 삭제 후 |
|
||||
|---|---|
|
||||
| AOF **끔**, 볼륨 없음 | 전부 소실 |
|
||||
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
|
||||
| AOF **켬**, **PVC** | **생존** |
|
||||
|
||||
**볼륨이 먼저고 설정이 나중이다.** 순서를 바꾸면 두 번째 줄이 된다 —
|
||||
그리고 두 번째 줄은 **첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서
|
||||
더 나쁘다.**
|
||||
|
||||
### `appendfsync` 는 여전히 트레이드오프다
|
||||
|
||||
```
|
||||
appendfsync everysec ← 기본값
|
||||
```
|
||||
|
||||
| 설정 | 잃는 양 | 비용 |
|
||||
|---|---|---|
|
||||
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
|
||||
| **`everysec`** | **최대 1초** | 기본값 |
|
||||
| `no` | OS 에 맡김 | 가장 빠름 |
|
||||
|
||||
**세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시
|
||||
로그인해야 한다는 뜻이다.** A-3 에서 본 PostgreSQL 의 `synchronous_commit OFF`
|
||||
와 **같은 모양의 맞바꿈**이고, 거기서 Keycloak 이 같은 판단을 했다.
|
||||
|
||||
### PVC 도 노드에 못박힌다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
local-path
|
||||
```
|
||||
|
||||
**`local-path` 는 노드의 디렉터리다.** A-4 에서 본 것과 같다 — **노드가 죽으면
|
||||
볼륨도 함께 접근 불가**가 되고, 파드는 다른 노드로 못 옮겨간다.
|
||||
|
||||
**영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.**
|
||||
|
||||
---
|
||||
|
||||
# 7. 복구 ② · 원상복구 확인표
|
||||
|
||||
## 7-1. 실험이 심은 키를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `b5:` 로 시작하는 키가 없는 것. **`FLUSHALL` 은
|
||||
치지 않는다** — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.
|
||||
|
||||
## 7-2. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| Redis | `sudo kubectl -n keycloak-lab get deploy redis` | `1/1` |
|
||||
| 볼륨 | `… get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}'` | `persistentVolumeClaim` 이 보인다 |
|
||||
| PVC | `sudo kubectl -n keycloak-lab get pvc` | `redis-data` `Bound` |
|
||||
| 영속화 | `… exec deploy/redis -- redis-cli config get appendonly` | `yes` |
|
||||
| BFF | `sudo 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` 이 안 끝나고 터미널이 붙잡힌다 | **그게 이 실험의 결과다.** `000` 이 되는 과정이다 | `--max-time` 을 붙인다 — 1-4 |
|
||||
| `000` 을 서버 오류로 읽는다 | `000` 은 **응답을 못 받았다**는 curl 의 표기다 | `400`/`503` 과 구별한다 — 4-1 |
|
||||
| **AOF 를 켰는데 안 남는다** | **`/data` 가 볼륨이 아니다** | 결론 내리기 전에 1-3 을 먼저 |
|
||||
| 6-3 에서 데이터가 **살아남는다** | 볼륨 제거 패치가 안 먹었다 | `get pod … spec.volumes` 가 **비어야** 한다 — 6-1 |
|
||||
| `config set` 한 값이 재기동 후 사라진다 | **런타임 전용이다.** 매니페스트 `args` 가 이긴다 | 6-3 |
|
||||
| `/actuator/health` 응답 자리에 이상한 문자열 | **측정 실패다.** 값이 아니다 (`HTTP server`) | 그 자리에서 다시 친다 — 4-2 |
|
||||
| 파드가 `NotReady` 가 되기를 기다린다 | **안 된다.** `redis` 지표가 readiness 그룹에 없다 | 4-2 · 4-4 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=bff` |
|
||||
| 회복 후 로그인이 풀려 있다 | **정상이다.** Redis 가 비었으니 세션이 없다 | `302` 는 실패가 아니다 — 5-2 |
|
||||
| BFF 를 재시작해 버렸다 | 「스스로 회복하는가」를 못 재게 된다 | 다시 2절부터. 손대지 않고 기다린다 |
|
||||
| PVC 가 `Pending` | `local-path` 프로비저너가 없거나 노드가 안 맞는다 | `describe pvc redis-data` 의 Events |
|
||||
| 다른 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 7-1 |
|
||||
|
||||
---
|
||||
|
||||
# 이 실험이 관측에 남긴 숙제
|
||||
|
||||
**Grafana 에 이 실험의 그래프가 없다.** 안 찍은 것이 아니라 **지표가 없다.**
|
||||
|
||||
**실측** — [`04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
|
||||
(후속 조사)
|
||||
```
|
||||
=== B층 구성 요소의 지표가 있는가 ===
|
||||
redis_up 시계열 0개
|
||||
redis_connected_clients 시계열 0개
|
||||
pg_up 시계열 0개
|
||||
pg_stat_database_numbackends 시계열 0개
|
||||
```
|
||||
|
||||
Prometheus 가 긁는 대상에 **Redis·PostgreSQL·BFF 가 애초에 없다.**
|
||||
A층이 Grafana 증거를 남길 수 있었던 것은 Keycloak 이 `/metrics` 를 내놓고
|
||||
그것을 scrape 대상에 넣어 뒀기 때문이다.
|
||||
|
||||
> **관측은 「나중에 붙이는 것」이 아니라 실험 설계에 포함되어야 한다.**
|
||||
> 이 실험에서 「Redis 가 언제 끊겼고 언제 붙었나」를 초 단위로 보고 싶다면
|
||||
> `redis_exporter` 가 먼저 있어야 하고, 그건 실험이 끝난 뒤에는 못 만든다.
|
||||
|
||||
| 대상 | 방법 |
|
||||
|---|---|
|
||||
| Redis | `redis_exporter` 사이드카 또는 Deployment |
|
||||
| PostgreSQL | `postgres_exporter` |
|
||||
| BFF | 이미 actuator 가 있다 — `/actuator/prometheus` 노출 + scrape 추가 |
|
||||
| 파드 readiness | `kube-state-metrics` (A-2 에서 이미 찾은 항목) |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-5 가 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **Redis lock 을 쓴다면 여기서 갱신이 멈춘다.** DB 행 잠금이 유리한 이유가 이 실험으로 보강된다 |
|
||||
| B-6 암호화 key 교체 | Redis 가 이제 영속적이므로 **key 를 바꾸면 옛 데이터가 남아 있다** |
|
||||
| D-1 백업·복구 | `local-path` PVC 는 **노드에 묶여 있다** — 노드가 안 돌아오면 백업뿐 |
|
||||
| 구성 | **readiness 그룹에 무엇을 넣을지 명시적으로 정한다.** 기본값은 결정이 아니다 |
|
||||
| 구성 | Redis 클라이언트에 **타임아웃**을 걸어 `000` 을 `500` 으로 바꾼다 |
|
||||
@@ -0,0 +1,707 @@
|
||||
# B-6 재현 가이드 — 서명 키를 회전하고, 옛 키를 버리는 순간을 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md) ·
|
||||
증거 원문: [`docs/evidence/b6-key-rotation/`](../../evidence/b6-key-rotation/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 토큰 헤더에 어느 키로 서명했는지가 적혀 있는 것 | JWT 첫 토막 (`kid`) |
|
||||
| 키를 「추가」했는데 옛 키가 JWKS 에 그대로 남는 것 | `.../openid-connect/certs` |
|
||||
| 옛 토큰과 새 토큰이 **둘 다 200** 인 무중단 구간 | echo `/api/me` |
|
||||
| 옛 키를 지운 **직후** 옛 토큰이 401 이 되는 것 | 같은 엔드포인트 |
|
||||
| 리소스 서버를 재시작해도 여전히 401 인 것 | `rollout restart deploy/echo` |
|
||||
| `kcadm` 의 필터가 오류 없이 빈 결과를 주는 것 | `get components -q type=...` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns` 에
|
||||
클라이언트 `bff-confidential` 과 사용자 `labuser` 가 있다.
|
||||
- 리소스 서버(`echo`, 네임스페이스 `header-lab`)가 떠 있다. 이 실험의 401/200 은
|
||||
전부 그 앱이 판정한다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||||
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||||
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). 그래서 JWKS 와
|
||||
토큰은 **`kc-lab-1` 호스트에서** 공개 이름으로 친다. `kcadm.sh` 만 파드 안에서
|
||||
돈다 — 항상 `kubectl exec` 로 감싼다.
|
||||
- 이 실험대에는 **`jq` 가 없다.** JSON 은 `tr` 과 `grep` 으로 자른다.
|
||||
|
||||
## 주의 — 이건 되돌릴 수 없는 실험이다
|
||||
|
||||
**서명 키 공급자를 실제로 지운다. 지운 키는 돌아오지 않는다.**
|
||||
같은 이름으로 공급자를 다시 만들어도 **새 키 쌍이 생기고 `kid` 가 다르다.**
|
||||
그러니 옛 키로 서명된 토큰은 **영구히** 검증되지 않는다.
|
||||
|
||||
**실험대에서만 한다.** 전 구간 약 15분이고, 3절까지는 아무것도 안 깨진다.
|
||||
파괴가 시작되는 지점은 [4. 관찰](#4-관찰--옛-키를-제거한다) 이며,
|
||||
그 앞에 경고를 다시 붙여 두었다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:30–14:32 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||
|
||||
> 해설 문서 머리에 적힌 `15:50–16:00 KST` 는 **문서를 쓴 시각**이고,
|
||||
> 증거 파일의 mtime 은 `14:30–14:32 KST` 다. **실측으로 인용하는 것은 뒤쪽**이다.
|
||||
|
||||
`kid`·컴포넌트 id 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
|
||||
기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
Q3 의 미지수 3 은 이렇게 물었다.
|
||||
|
||||
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
|
||||
> 저장된 값은 어떻게 읽는가."*
|
||||
|
||||
**질문이 두 갈래로 갈린다.**
|
||||
|
||||
| | 상태 |
|
||||
|---|---|
|
||||
| ① 토큰 **저장소**의 암호화 key | **존재하지 않는다.** B-2 에서 `bytea` 안이 JWT 문자열 그대로임을 확인했다 |
|
||||
| ② 토큰 **서명** key (Keycloak realm) | 존재하고 회전 가능하다 — **이 실험이 잰다** |
|
||||
|
||||
①이 없으므로 교체할 것도 없다. 그래서 이 가이드는 ②만 친다. 그리고 ②에서
|
||||
본 모양이 나중에 ①을 설계할 때 그대로 쓰인다.
|
||||
|
||||
**그리고 이 실험은 예측이 틀린 실험이다.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 예측 | 리소스 서버가 JWKS 를 캐시하니, 옛 키를 지워도 **한동안은 통할 것** |
|
||||
| **실측** | **유예가 없다. 제거 직후 바로 401 이다** |
|
||||
|
||||
이유는 뒤에서 본다. **캐시를 유예 기간으로 기대하면 안 된다**는 것이 이 실험이
|
||||
남긴 한 줄이고, 그것을 당신 터미널에서 확인하는 것이 이 가이드의 목적이다.
|
||||
|
||||
핵심은 **두 동작을 분리해서 보는 것**이다.
|
||||
|
||||
```
|
||||
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
|
||||
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
|
||||
```
|
||||
|
||||
「교체」라는 한 단어가 실제로는 **서로 성질이 정반대인 두 조작**이다.
|
||||
회전이 위험한 게 아니라 **옛 키를 언제 버리느냐**가 위험하다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 아무것도 바꾸기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 제거 후에 볼 것을 제거 전에 **똑같은
|
||||
명령으로** 먼저 봐 둔다. 그래야 「원래 그랬던 것」과 「내가 바꾼 것」이 구별된다.
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
|
||||
```
|
||||
|
||||
## 1-1. kcadm 을 먼저 로그인시킨다
|
||||
|
||||
`kcadm.sh` 는 **파드 안 파일에 세션을 저장한다.** 파드가 재시작되면 사라지고,
|
||||
그 뒤 모든 명령이 `401` 로 떨어진다. **맨 앞에서 한 번 해 둔다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **아무것도 안 나오면 성공이다.** 실패하면 한 줄 오류가 뜬다.
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||
> 셸 히스토리에도 남지 않는다. 존재만 확인하고 싶으면 길이만 본다.
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
|
||||
## 1-2. 지금 어떤 키 공급자가 있나
|
||||
|
||||
**확인** — 통째로 받아서 눈으로 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns --fields id,name,providerId
|
||||
```
|
||||
|
||||
**형태** — JSON 배열이 여러 줄로 나온다. `providerId` 가 `rsa-generated` 인
|
||||
항목이 서명 키 공급자이고, `hmac-generated`·`aes-generated` 등이 함께 나온다.
|
||||
|
||||
**어디를 봐야 하는가** — `"name" : "rsa-generated"` 인 항목의 `"id"`.
|
||||
**4절에서 지울 대상이 이것이다.** 지금 적어 둔다.
|
||||
|
||||
### ★ 여기서 조용한 실패를 하나 만난다
|
||||
|
||||
「키 공급자만 걸러 보자」는 자연스러운 시도가 **빈 결과**를 준다.
|
||||
|
||||
**미검증** — 원래 실행에서 이렇게 쳤고 아무것도 안 나왔다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
|
||||
```
|
||||
|
||||
**오류도 종료코드도 없이 비어 있다.** 「키 공급자가 하나도 없구나」로 읽으면
|
||||
이 실험 전체가 무너진다. **`-q` 필터를 믿지 말고 `--fields` 로 전체를 받는다.**
|
||||
|
||||
> A층 내내 반복해 만난 유형이다 — **조용한 실패.** 빈 출력은 「없다」가 아니라
|
||||
> 「이 명령으로는 안 보인다」일 수 있다. 다른 명령으로 한 번 더 확인한다.
|
||||
|
||||
## 1-3. JWKS 원문을 한 번 통째로 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
```
|
||||
|
||||
**줄바꿈 없이 한 줄로 길게 나온다. 그래도 처음 한 번은 그대로 본다.**
|
||||
어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||
|
||||
**실측** — 첫머리.
|
||||
[`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||
에 남은 조각 그대로다
|
||||
```
|
||||
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
```
|
||||
|
||||
그 뒤로 `kty`·`alg`·`use`·`n`·`e` 가 이어지고 다음 키가 온다.
|
||||
**`kid` 마다 `alg` 가 따로 붙는다** — 이 사실이 바로 아래에서 쓰인다.
|
||||
|
||||
읽을 만하게 자른다. `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||
```
|
||||
JWKS kid 목록:
|
||||
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`kid` 는 두 개인데 이 실험이 세는 RS256 키는 하나다.**
|
||||
|
||||
같은 파일의 바로 윗줄이 그렇게 말한다.
|
||||
|
||||
**실측**
|
||||
```
|
||||
JWKS 의 RS256 키 수: 1
|
||||
```
|
||||
|
||||
**세는 단위가 다르다.** JWKS 에는 서명 키만 실리는 게 아니다. 이 realm 에서는
|
||||
암호화용 키(`RSA-OAEP` 계열)가 함께 실려 있고, 그것도 `kid` 를 갖는다.
|
||||
**`grep kid | wc -l` 로 세면 서명 키 수를 과다 계산한다.**
|
||||
|
||||
알고리즘까지 보고 세려면 키 단위로 잘라야 한다. JWKS 는 키 하나가 `}` 로
|
||||
끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다. **미검증**
|
||||
```bash
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr '}' '\n' | grep -c RS256
|
||||
```
|
||||
|
||||
Keycloak 자신에게 묻는 편이 더 확실하다 — **이쪽이 1순위 도구다. 미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get keys -r keycloak-patterns
|
||||
```
|
||||
**어디를 봐야 하는가** — 키마다 붙는 `algorithm` 과 `status`.
|
||||
`RS256` 이면서 `ACTIVE` 인 것이 **지금 서명에 쓰이는 키**다.
|
||||
|
||||
> 이 두 명령은 원래 실행 기록에 출력이 없다. **당신 출력에서 필드 이름을 직접
|
||||
> 확인한다.** 위에 인용한 「RS256 키 수: 1」만이 실측이다.
|
||||
|
||||
## 1-4. 토큰을 하나 받고, 그 토큰의 kid 를 본다
|
||||
|
||||
**하기** — direct grant 로 받는다. 클라이언트 비밀은 Secret 에서 꺼내 쓴다
|
||||
```bash
|
||||
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
|
||||
CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
|
||||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
|
||||
OLD=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
|
||||
-d username=labuser -d password=labpass -d scope=openid \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
echo "${#OLD}자"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
2043자
|
||||
```
|
||||
`0자` 로 나오면 토큰을 못 받은 것이다. 변수에 담지 말고 응답을 그대로 찍어
|
||||
본문을 읽는다.
|
||||
|
||||
> **이 토큰이 이 실험의 시험체다.** 변수 이름을 `OLD` 로 둔 이유는, 회전이
|
||||
> 끝난 뒤에도 **이것이 「옛 키로 서명된 토큰」으로 남아야** 하기 때문이다.
|
||||
> 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다.
|
||||
|
||||
**확인** — JWT 의 **첫 토막**이 헤더다. 거기 `kid` 가 있다
|
||||
```bash
|
||||
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||
```
|
||||
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||
```
|
||||
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `kid` 가 1-3 의 JWKS 목록에 있는 값과 같은가.
|
||||
**이것이 이 실험의 뼈대다.** 토큰이 자기 서명 키를 스스로 밝히고 있다.
|
||||
|
||||
> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다).
|
||||
> 헤더는 짧아서 대개 온전히 보인다.
|
||||
|
||||
### 개념 — `kid` 가 있어서 여러 키를 동시에 운용할 수 있다
|
||||
|
||||
**무엇인가.** `kid` 는 key ID 다. 서명한 쪽이 **어느 키를 썼는지**를 토큰 헤더에
|
||||
적어 준다. 검증하는 쪽은 JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다.
|
||||
|
||||
**왜 여기 나오나.** `kid` 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고,
|
||||
키가 바뀌는 순간 옛 토큰은 전부 죽는다. **`kid` 가 겹침 구간을 가능하게 한다.**
|
||||
|
||||
**없거나 틀리면.** 겹침이 불가능해진다 — 그 사례를 B-7 에서 본다.
|
||||
oauth2-proxy 의 쿠키에는 `kid` 에 해당하는 표시가 없고, 그래서
|
||||
`--cookie-secret` 도 단수다.
|
||||
|
||||
## 1-5. 그 토큰이 지금 통하는가 — 대조군
|
||||
|
||||
**이 절을 건너뛰면 뒤의 401 은 아무 의미가 없다.** 「원래 안 됐던 것」과
|
||||
「내가 깨뜨린 것」을 구별할 수단이 이것뿐이다.
|
||||
|
||||
먼저 응답을 통째로 한 번 본다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태줄과 본문. 200 이면 `subject` 같은 클레임이 돌아온다.
|
||||
401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. **이 헤더를 한 번 봐 두면
|
||||
뒤에서 401 이 났을 때 「왜」를 묻는 자리가 생긴다.**
|
||||
|
||||
이제부터는 여러 번 비교해야 하므로 코드만 뽑는다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||
```
|
||||
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
|
||||
/api/me HTTP 200
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 회전 전에는 통한다. **이 200 이 기준선이다.**
|
||||
|
||||
> **원래 실행은 클러스터 안에서 `http://echo.header-lab.svc:8081/api/me` 를 쳤다.**
|
||||
> 이 가이드가 공개 이름을 쓰는 것은 **`kc-lab-1` 에서는 클러스터 DNS 가 안
|
||||
> 풀리기 때문**이다. `app1.hyeonworks.com` 의 `/api` 는 Ingress 가 같은 `echo`
|
||||
> 로 보내므로 **도달하는 앱은 같다.** 인용한 `HTTP 200` 은 원래 실행의 값이다.
|
||||
|
||||
> **access token 은 60초짜리다**(이 realm 은 `accessTokenLifespan=60`).
|
||||
> 1분을 넘기면 회전과 무관하게 401 이 난다. **뒤에서 401 을 만나면 먼저
|
||||
> 「만료인가 키 문제인가」를 갈라야 한다** — 3-3 에 그 방법을 적어 두었다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 우선순위가 더 높은 키 공급자를 추가한다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기** — 방금 만든 공급자를 지우면 원래대로 돌아간다.
|
||||
**id 는 2-2 가 출력하는 값이고, 그 줄을 그대로 옮겨 친다.** 원래 실행에서는
|
||||
`7902af43-a0cc-4ebd-ad25-04d563854d16` 이었다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns
|
||||
```
|
||||
2절만 되돌리는 것은 안전하다. **되돌릴 수 없는 것은 4절이다.**
|
||||
|
||||
## 2-1. 무엇을 하는 것인가 — 먼저 읽는다
|
||||
|
||||
**Keycloak 의 키 회전은 「바꾸기」가 아니라 「더 높은 우선순위로 추가하기」다.**
|
||||
|
||||
기존 공급자는 그대로 두고, `priority` 가 더 큰 공급자를 하나 더 만든다.
|
||||
그러면 **발급은 새 키로 가고, 검증은 둘 다 받는다.** 옛 키는 아무 데도 안 갔다.
|
||||
|
||||
```
|
||||
t0 키 A 만 있다. 발급: A, 검증: A
|
||||
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
|
||||
t2 키 A 제거. 발급: B, 검증: B
|
||||
```
|
||||
|
||||
**이 실험이 재는 것은 t1 이 무중단인가(2~3절)와, t2 가 언제 안전한가(4절)다.**
|
||||
|
||||
## 2-2. 추가한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create components -r keycloak-patterns \
|
||||
-s name=rsa-rotated -s providerId=rsa-generated \
|
||||
-s providerType=org.keycloak.keys.KeyProvider \
|
||||
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
|
||||
date '+%H:%M:%S 추가'
|
||||
```
|
||||
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||
```
|
||||
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
|
||||
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **돌아온 id 를 적어 둔다.** 되돌릴 때 쓴다.
|
||||
그리고 `config.priority` 가 **기존 공급자보다 큰지** — 기본값은 100 이고
|
||||
여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 **발급에 쓰이지 않아**
|
||||
3-2 에서 kid 가 안 바뀐다.
|
||||
|
||||
> `config.*` 값이 **대괄호로 감싼 배열**인 것에 주의한다. `-s config.priority=200`
|
||||
> 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 추가가 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
## 3-1. JWKS 에 두 키가 함께 있는가
|
||||
|
||||
**확인** — 1-3 과 **똑같은 명령**을 다시 친다
|
||||
```bash
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||
```
|
||||
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
|
||||
RS256 키 수: 2
|
||||
kid 목록:
|
||||
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **옛 `kid`(`OY-caYDN…`)가 목록에 그대로 있다.**
|
||||
새 것이 하나 늘었고, 아무것도 사라지지 않았다.
|
||||
|
||||
**이 결과가 의미하는 것** — 「회전」이라는 말과 달리 **아무것도 교체되지 않았다.**
|
||||
JWKS 는 「지금 검증에 쓸 수 있는 키 전부」를 싣는 목록이고, 추가는 그 목록을
|
||||
늘릴 뿐이다.
|
||||
|
||||
## 3-2. 새 토큰은 어느 키로 서명되는가
|
||||
|
||||
**하기** — 지금 새로 하나 받는다. **`OLD` 은 건드리지 않는다**
|
||||
```bash
|
||||
NEW=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
|
||||
-d username=labuser -d password=labpass -d scope=openid \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||
```
|
||||
=== [5] 새 토큰은 어느 키로 서명되는가 ===
|
||||
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `kid` 가 **우선순위 200 짜리 새 키**로 바뀌었다.
|
||||
|
||||
**이 결과가 의미하는 것** — 발급은 우선순위가 가장 높은 키로 간다.
|
||||
**여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.** 2-2 로 돌아간다.
|
||||
|
||||
## 3-3. ★ 둘 다 통하는가 — 무중단 구간의 실측
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" 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
|
||||
```
|
||||
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||
```
|
||||
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
|
||||
옛 토큰 /api/me HTTP 200
|
||||
새 토큰 /api/me HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **둘 다 200.**
|
||||
|
||||
**이 결과가 의미하는 것** — **키 추가는 무중단이다.** 새 토큰은 새 키로 서명되고,
|
||||
옛 토큰은 **JWKS 에 아직 있는 옛 키로 검증된다.** 사용자는 아무것도 못 느낀다.
|
||||
|
||||
> **여기서 `old` 가 401 이면 두 가지 중 하나다.**
|
||||
> ① 토큰이 만료됐다(60초). ② 뭔가 다른 것을 건드렸다.
|
||||
> **가르는 법** — 옛 토큰의 `exp` 를 본다. JWT 의 **가운데 토막**이 클레임이다.
|
||||
> ```bash
|
||||
> echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
> date +%s
|
||||
> ```
|
||||
> `exp` 가 지금보다 작으면 만료다. 1-4 로 돌아가 다시 받되, **이번에는 추가
|
||||
> 전에** 받아야 「옛 키로 서명된 토큰」이 된다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰 — 옛 키를 제거한다
|
||||
|
||||
## ★ 여기서부터 되돌릴 수 없다
|
||||
|
||||
**이 절이 이 실험의 본 시험이다. 그리고 되돌릴 수 없다.**
|
||||
지우는 것은 키 공급자이고, 그 안의 **개인키가 함께 사라진다.**
|
||||
같은 이름으로 다시 만들어도 **다른 키 쌍**이 생긴다.
|
||||
|
||||
계속하기 전에 확인한다.
|
||||
|
||||
- 이 realm 이 **실험대 전용**인가
|
||||
- 지금 살아 있는 세션 중에 **잃으면 곤란한 것**이 있는가
|
||||
- 3-3 의 `old 200` 을 **실제로 봤는가** (안 봤다면 401 이 나와도 원인을 못 가른다)
|
||||
|
||||
## 4-1. 지울 대상을 정확히 고른다
|
||||
|
||||
**확인** — 1-2 와 같은 명령. `-q` 는 여전히 안 먹는다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns --fields id,name,providerId
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `"name" : "rsa-generated"` 인 항목의 id.
|
||||
방금 만든 것은 `"name" : "rsa-rotated"` 다. **둘을 바꿔 지우면 실험이 뒤집힌다.**
|
||||
|
||||
목록이 길면 그 항목 주변만 잘라 본다. `"id"` 는 `"name"` 보다 **위**에 있다.
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns --fields id,name,providerId \
|
||||
| grep -B2 '"name" : "rsa-generated"'
|
||||
```
|
||||
|
||||
원래 실행에서 지운 것은 이것이다.
|
||||
|
||||
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||
```
|
||||
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
|
||||
제거 완료
|
||||
```
|
||||
|
||||
`980ee9b7…` 로 시작하는 것이 옛 공급자의 id 이고, 그것이 `OY-caYDN…` 키를
|
||||
갖고 있었다. **당신 환경의 id 는 다르다.** 증거에 남은 것도 앞 8자뿐이니
|
||||
**전체 id 는 위 명령의 출력에서 그대로 옮겨 온다.**
|
||||
|
||||
## 4-2. 지운다
|
||||
|
||||
**하기** — 위 출력에서 고른 id 를 변수에 넣고 지운다
|
||||
|
||||
```bash
|
||||
OLDID=980ee9b7-... # ← 4-1 의 출력에서 그대로 옮긴다. 환경마다 다르다
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete components/"$OLDID" -r keycloak-patterns
|
||||
date '+%H:%M:%S 제거'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 조용히 끝나면 성공이다. **시각을 적어 둔다.**
|
||||
|
||||
## 4-3. JWKS 에서 사라졌는가
|
||||
|
||||
**확인** — 또 같은 명령이다
|
||||
```bash
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||
```
|
||||
=== [8] JWKS 에서 사라졌는가 ===
|
||||
RS256 키 수: 1
|
||||
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`OY-caYDN…` 이 없다.** RS256 은 다시 1개다.
|
||||
`gokjn0…` 은 처음부터 끝까지 그대로 있다 — 서명 키가 아니기 때문이다(1-3).
|
||||
|
||||
## 4-4. ★ 옛 토큰은 이제 어떻게 되는가
|
||||
|
||||
**확인** — 3-3 과 **똑같은 두 줄**
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" 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
|
||||
```
|
||||
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||
```
|
||||
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
|
||||
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
|
||||
새 토큰 /api/me HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **옛 토큰 401, 새 토큰 200.**
|
||||
|
||||
**이 결과가 의미하는 것** — 제거는 **즉시** 반영된다.
|
||||
괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 **측정하기 전에 적어 둔
|
||||
예상**이고, **옆의 401 이 그 예상을 부정한 값이다.** 증거 파일에 예상과 결과가
|
||||
나란히 남아 있는 셈이다.
|
||||
|
||||
> **새 토큰도 401 이면** 제거를 잘못했다 — 새 공급자를 지운 것이다.
|
||||
> `kid` 를 다시 확인한다(3-2). 아니면 그냥 만료다(3-3 의 박스).
|
||||
|
||||
## 4-5. 캐시가 구해주지 않는다 — 재시작으로 확인한다
|
||||
|
||||
여기까지 보면 「리소스 서버가 아직 JWKS 를 캐시하고 있어서 우연히 401 인가?」
|
||||
라는 의심이 남는다. **캐시를 비워 보면 갈린다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n header-lab rollout restart deploy/echo
|
||||
sudo kubectl -n header-lab rollout status deploy/echo --timeout=180s
|
||||
```
|
||||
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||
```
|
||||
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
|
||||
deployment "echo" successfully rolled out
|
||||
옛 토큰 /api/me HTTP 401
|
||||
새 토큰 /api/me HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **재시작 전과 후가 같다.** 401 / 200.
|
||||
|
||||
**이 결과가 의미하는 것** — 401 은 캐시 상태와 무관하다.
|
||||
**캐시는 유예를 주지 않았다.**
|
||||
|
||||
### 왜 그런가
|
||||
|
||||
Spring 의 `NimbusJwtDecoder` 는 **모르는 `kid` 를 만나면 JWKS 를 다시
|
||||
가져온다.** 캐시는 「이미 아는 키를 다시 안 받으려는」 장치이지 「옛 키를
|
||||
붙잡아 두는」 장치가 아니다.
|
||||
|
||||
```
|
||||
옛 토큰 도착
|
||||
│
|
||||
├─▶ kid = OY-caYDN… → 캐시에 없다
|
||||
│ │
|
||||
│ └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
|
||||
│
|
||||
└─▶ 새로 받은 JWKS 에도 없다 → 401
|
||||
```
|
||||
|
||||
**캐시가 오히려 제거를 빨리 반영시킨다.** 예측이 정확히 반대였던 이유다.
|
||||
|
||||
> **유예는 캐시로 만드는 것이 아니라, 옛 키를 JWKS 에 남겨 두는 기간으로
|
||||
> 만들어야 한다.** 이것이 이 실험의 한 줄이다.
|
||||
|
||||
## 4-6. 그래서 겹침 구간은 얼마나 길어야 하는가
|
||||
|
||||
**겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.**
|
||||
|
||||
| 이 실험대에서 | |
|
||||
|---|---|
|
||||
| access token | 60초 |
|
||||
| refresh token | 1800초 (30분) |
|
||||
| **필요한 겹침** | **최소 30분** |
|
||||
|
||||
**확인** — 이 값들은 realm 설정이다. 직접 본다. **미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 「교체하는 동안」의 길이를 정하는 것은 **key 가
|
||||
아니라 그 key 로 만든 것의 수명**이다. 30분짜리 refresh token 을 발급하면서
|
||||
겹침을 5분만 두면 **25분어치의 토큰을 죽이는 것**이다.
|
||||
|
||||
### ①에 적용하면 — 저장소를 암호화한다면
|
||||
|
||||
```
|
||||
쓰기: 새 key 하나로만
|
||||
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
|
||||
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
|
||||
```
|
||||
|
||||
**저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다.**
|
||||
암호화를 설계할 때 **key 식별자를 값과 함께 저장**해야 하는 이유이고,
|
||||
그것이 없을 때 어떻게 되는지가 다음 실험(B-7)이다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. ★ 옛 키는 돌아오지 않는다
|
||||
|
||||
**이 실험에는 「원상복구」가 없다.** 지운 키 공급자는 개인키와 함께 사라졌다.
|
||||
같은 이름으로 다시 만들면 **새 키 쌍**이 생기고 `kid` 가 다르므로, 옛 토큰은
|
||||
그래도 401 이다.
|
||||
|
||||
**정상 상태는 「새 키 하나만 남은 상태」다.** 4-3 의 출력이 그 상태이고,
|
||||
실험 전과 다르지만 **깨진 상태가 아니다.**
|
||||
|
||||
## 5-2. 실험이 남긴 것을 정리한다
|
||||
|
||||
| 남은 것 | 어떻게 | |
|
||||
|---|---|---|
|
||||
| `rsa-rotated` 공급자 | **그냥 둔다.** 지금 유일한 RS256 서명 키다 | 지우면 realm 이 서명할 키를 잃는다 |
|
||||
| 셸 변수 `OLD` `NEW` `CS` | 터미널을 닫으면 사라진다 | `unset OLD NEW CS` |
|
||||
| 실험 중 발급한 토큰 | 60초 뒤 만료된다 | 별도 조치 없음 |
|
||||
|
||||
**이름이 거슬리면** 새 공급자를 하나 더 만들고(2-2) `rsa-rotated` 를 지우면
|
||||
된다. **다만 그것 역시 또 한 번의 회전이고, 또 하나의 새 키다.**
|
||||
|
||||
## 5-3. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 서명 키 | `curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \| tr ',' '\n' \| grep kid` | RS256 이 **하나** |
|
||||
| 새 토큰 | 1-4 의 발급 + 1-5 의 `/api/me` | `200` |
|
||||
| 리소스 서버 | `sudo kubectl -n header-lab get pods` | `echo` 가 `1/1 Running` |
|
||||
| Keycloak | `sudo kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
|
||||
| 공급자 목록 | `kcadm get components --fields id,name,providerId` | `rsa-generated` 가 없고 `rsa-rotated` 가 있다 |
|
||||
|
||||
> **이 실험이 재지 않은 것** — 겹침 구간을 실제로 30분 유지하며 그 사이에
|
||||
> 발급된 refresh token 이 t2 이후 어떻게 되는지는 측정하지 않았다.
|
||||
> 재려면 2절과 4절 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로
|
||||
> 4절 뒤에 갱신을 시도한다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `kcadm get components -q type=...` 가 빈 결과 | **`-q` 필터가 안 먹는다. 오류도 없다** | `--fields id,name,providerId` 로 전체를 받는다 — 1-2 |
|
||||
| `kcadm` 이 전부 `401`/`Unauthorized` | 파드가 재시작되어 kcadm 세션이 사라졌다 | `config credentials` 를 다시 — 1-1 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | JWKS·토큰은 `kc-lab-1` 호스트에서 친다 |
|
||||
| `jq: command not found` | 이 실험대에는 jq 가 없다 | `tr ',' '\n' \| grep` 로 자른다 — 1-3 |
|
||||
| kid 를 세니 2개인데 문서는 1개라고 한다 | **RS256 이 아닌 암호화 키가 섞여 있다** | `tr '}' '\n' \| grep -c RS256` 또는 `kcadm get keys` — 1-3 |
|
||||
| 공급자를 추가했는데 새 토큰의 kid 가 그대로 | `config.priority` 가 기존보다 낮다 | 값이 `["200"]` 처럼 **배열**인지 — 2-2 |
|
||||
| 추가만 했는데 옛 토큰이 401 | 추가가 아니라 **토큰이 만료**됐다(60초) | 클레임의 `exp` 와 `date +%s` 비교 — 3-3 |
|
||||
| 제거했는데 **새** 토큰이 401 | 지운 것이 새 공급자다 | `kid` 를 다시 확인하고 남은 공급자 목록을 본다 — 4-1 |
|
||||
| 제거했는데 옛 토큰이 **200** | 지운 것이 그 토큰의 키가 아니다 | 토큰 헤더의 `kid` 와 지운 공급자의 키를 대조 — 4-1 |
|
||||
| 「캐시 때문일 것」이라 재시작을 기다린다 | **캐시는 유예를 주지 않는다** | 재시작 전후가 같다 — 4-5 |
|
||||
| 지운 키를 되살리려 한다 | **되살릴 수 없다.** 같은 이름 ≠ 같은 키 | 5-1 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-6 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-7](../../experiment-b7-cookie-secret-rotation.md) cookie secret | **같은 모양의 문제인데 `kid` 가 없다.** 겹침 구간을 만들 수 있는가 — 답은 「없다」 |
|
||||
| [D-2](../../experiment-d2-version-upgrade.md) 버전 업그레이드 | Redis 의 Java 직렬화 세션도 같은 **「옛 형식을 읽을 수 있는가」** 문제다 |
|
||||
| 설계 | 저장소를 암호화한다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
|
||||
| 전부 | **빈 출력은 「없다」가 아니다.** `-q` 필터 하나가 조용히 실패했다 |
|
||||
@@ -0,0 +1,766 @@
|
||||
# B-7 재현 가이드 — cookie secret 을 갈아치우고, 로그인해 있던 사람에게 무슨 일이 나는지 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md) ·
|
||||
증거 원문: [`docs/evidence/b7-cookie-secret/`](../../evidence/b7-cookie-secret/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| replica 두 개가 상태를 안 나누고도 로그인이 되는 것 | oauth2-proxy 로그 (시작한 replica ≠ 콜백 받은 replica) |
|
||||
| 큰 쿠키가 프록시를 못 넘어 502 가 되는 것, 그리고 계층을 갈라 원인을 좁히는 법 | Traefik 직접 vs nginx |
|
||||
| `--cookie-secret` 이 **단수**라는 것 | `oauth2-proxy --help` |
|
||||
| 옛 쿠키가 `session ticket cookie failed validation` 로 죽는 것 | 프록시 로그 |
|
||||
| **로그인 화면 없이 조용히 재로그인**되는 것 | 브라우저 |
|
||||
| ★ 프록시가 **지우지 못한** 서버 세션이 Redis 에 남는 것 | `redis-cli --scan` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns` 에
|
||||
클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
|
||||
- Redis 가 `redis.keycloak-lab.svc:6379` 로 떠 있다.
|
||||
- **브라우저가 필요하다.** 쿠키가 `HttpOnly` 이고 OIDC 흐름을 끝까지 걸어야
|
||||
세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다 — 「막히면」 표에 그 기록이 있다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 이 실험대에는 **`jq` 도 `yamllint` 도 없다.**
|
||||
|
||||
## 주의 — 이건 남의 도메인을 빌리고, 남의 세션을 끊는 실험이다
|
||||
|
||||
**두 가지를 건드린다.**
|
||||
|
||||
1. **`app2.hyeonworks.com` 은 평소 `observability` 네임스페이스의 Grafana 로
|
||||
간다.** 인증서가 `auth`·`app1`·`app2` 세 이름만 덮고 있어서 네 번째 이름을
|
||||
못 만든다. 그래서 **Grafana 의 Ingress 를 잠시 내리고 빌린다.**
|
||||
**반드시 되돌린다** — [5-3](#5-3--grafana-ingress-를-되돌린다) 이 그 절차다.
|
||||
백업을 뜨는 것이 [1-1](#1-1-먼저-grafana-ingress-를-백업한다) 의 첫 명령인 이유다.
|
||||
2. **secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.**
|
||||
실험대에서만 한다.
|
||||
|
||||
전 구간 약 20분이다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 친다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:35–14:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||
|
||||
> **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는
|
||||
> 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과
|
||||
> 수집 시각이 `14:35–14:42 KST` 인 것은 **같은 시간대의 같은 순간**이다(KST = UTC+9).
|
||||
> 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.
|
||||
|
||||
파드 이름·Redis 키·쿠키 값은 **당신 환경에서 다르다.** 이 문서는
|
||||
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
Q1 의 미지수 7 은 이렇게 물었다.
|
||||
|
||||
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
|
||||
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
|
||||
|
||||
B-6 에서 Keycloak 은 **두 키를 동시에 들고** 무중단으로 회전했다. `kid` 가
|
||||
있어서 「읽기는 여러 키, 쓰기는 하나」가 가능했기 때문이다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| B-6 의 모양대로라면 | oauth2-proxy 도 **겹침 구간을 만들 수 있을 것** |
|
||||
| **실측** | **★ 없다.** `--cookie-secret` 은 단수이고 쿠키에 key 식별자가 없다 |
|
||||
|
||||
**그리고 예측하지 않았던 것이 하나 더 나온다** — 사용자는 아무것도 못 느끼는데
|
||||
**서버 쪽에 지워지지 않는 세션이 남는다.** 그 「지우지 못한다」를 이어서 재는
|
||||
것이 [B-7a](b7a-orphan-session.md) 다.
|
||||
|
||||
핵심은 **상태를 어디에 두었는가**다.
|
||||
|
||||
```
|
||||
BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패
|
||||
oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공
|
||||
대신 secret 이 단일 지점
|
||||
```
|
||||
|
||||
**공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.**
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 아무것도 바꾸기 전에
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양
|
||||
```
|
||||
|
||||
## 1-1. 먼저 Grafana Ingress 를 백업한다
|
||||
|
||||
**이것을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다.**
|
||||
|
||||
**확인** — 지금 app2 가 무엇인지 먼저 본다
|
||||
```bash
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
**형태** — Grafana 로 가고 있으면 `302` 로 `/login` 을 가리킨다.
|
||||
|
||||
**하기** — 백업을 뜨고, 파일이 비지 않았는지 확인한다
|
||||
```bash
|
||||
sudo kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
|
||||
wc -l ~/grafana-ingress-backup.yaml
|
||||
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 줄 수가 **0 이 아니고**, `app2.hyeonworks.com` 이
|
||||
**1회 이상** 잡혀야 한다. `0` 이면 백업이 빈 파일이고, 그 상태로 진행하면
|
||||
복구할 것이 없다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n observability delete ingress grafana
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||
```
|
||||
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
|
||||
grafana ingress 삭제
|
||||
```
|
||||
|
||||
**되돌리기** — `sudo kubectl apply -f ~/grafana-ingress-backup.yaml`
|
||||
|
||||
## 1-2. oauth2-proxy 를 배포한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||
```
|
||||
secret/oauth2-proxy-secrets created
|
||||
deployment.apps/oauth2-proxy created
|
||||
service/oauth2-proxy created
|
||||
ingress.networking.k8s.io/oauth2-proxy created
|
||||
deployment "oauth2-proxy" successfully rolled out
|
||||
```
|
||||
|
||||
**되돌리기** — `sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml`
|
||||
|
||||
## 1-3. replica 두 개가 서로 다른 노드에 있는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||
```
|
||||
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
|
||||
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **파드 두 개, 서로 다른 노드.** 그리고 **파드 이름의
|
||||
끝 다섯 글자**를 적어 둔다. 4절에서 로그를 읽을 때 「어느 replica 가 무엇을
|
||||
했는지」를 이 글자로 가른다.
|
||||
|
||||
**이 결과가 의미하는 것** — replica 가 둘이라는 것이 Q1 의 질문 자체다.
|
||||
하나면 「공유」라는 말이 성립하지 않는다.
|
||||
|
||||
## 1-4. 진입점이 살아 있는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
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
|
||||
```
|
||||
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||
```
|
||||
=== 진입점 확인 ===
|
||||
https://app2.hyeonworks.com/ HTTP 302
|
||||
/ping HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
| 경로 | 정상 | 뜻 |
|
||||
|---|---|---|
|
||||
| `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — **프록시가 일하고 있다** |
|
||||
| `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — **프록시 자체는 살아 있다** |
|
||||
|
||||
**두 값이 갈라지는 것이 중요하다.** `/ping` 도 안 되면 프록시가 안 뜬 것이고,
|
||||
`/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있는 것이다.
|
||||
|
||||
## 1-5. ★ 502 가 나면 — 계층을 가른다
|
||||
|
||||
원래 구성에서 **콜백이 계속 502** 였다. 이 절은 그때 무엇을 쳤는지다.
|
||||
**502 를 안 만났으면 읽고 넘어간다.**
|
||||
|
||||
```
|
||||
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
|
||||
```
|
||||
|
||||
**502 는 「누가 냈는지」를 안 알려 준다.** 앞단 nginx 인지, 그 뒤 Traefik 인지,
|
||||
파드인지. **한 겹씩 벗겨서 좁힌다.**
|
||||
|
||||
**확인** — nginx 를 건너뛰고 Traefik 에 직접 묻는다
|
||||
```bash
|
||||
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
|
||||
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
|
||||
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **Traefik 직접은 정상이다.** 그러면 502 를 내는 것은
|
||||
그 앞의 nginx 다. 그리고 502 는 **쿠키를 설정하는 응답에서만** 났다.
|
||||
|
||||
**이 결과가 의미하는 것** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에
|
||||
담는다.** 그 `Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
|
||||
|
||||
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
|
||||
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시
|
||||
> 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
|
||||
|
||||
**해결** — 세션을 Redis 로 옮긴다. 매니페스트에 이미 들어 있다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
"--session-store-type=redis"
|
||||
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"
|
||||
```
|
||||
|
||||
### ★ 그리고 여기서 조용한 실패를 하나 만난다
|
||||
|
||||
nginx 설정을 보려던 시도가 계속 **빈 결과**였다.
|
||||
|
||||
**실측**
|
||||
```
|
||||
$ sudo -n true
|
||||
sudo: a password is required
|
||||
```
|
||||
|
||||
**`test-server`(호스트)의 sudo 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는
|
||||
무암호라 A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
|
||||
|
||||
**앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가
|
||||
조용히 실패한 것이었다.** 호스트에서 무언가가 빈 결과를 주면 **먼저
|
||||
`sudo -n true` 를 쳐 본다.**
|
||||
|
||||
## 1-6. secret 이 두 개 들어 있는가 — 값은 안 찍는다
|
||||
|
||||
**확인** — 키 이름만 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||
-o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
"CLIENT_SECRET"
|
||||
"COOKIE_SECRET_A"
|
||||
"COOKIE_SECRET_B"
|
||||
```
|
||||
|
||||
**확인** — 길이만 본다. **값은 절대 찍지 않는다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
|
||||
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||
-o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **oauth2-proxy 는 정확히 16·24·32 바이트만 받는다.**
|
||||
매니페스트의 값은 32바이트짜리다. 다른 수가 나오면 프록시가 기동에서 죽는다.
|
||||
**미검증** — 원래 실행 기록에 이 명령의 출력은 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — **회전 대상이 미리 두 개 준비되어 있다.**
|
||||
이것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.
|
||||
|
||||
## 1-7. 로그인해서 세션을 하나 만든다
|
||||
|
||||
**하기** — 브라우저에서
|
||||
```
|
||||
https://app2.hyeonworks.com/api/echo → labuser / labpass
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의
|
||||
JSON 이 보인다.
|
||||
|
||||
**이 결과가 의미하는 것** — upstream 이 받은 헤더가 그대로 찍힌다.
|
||||
|
||||
**실측** — [`b7-oauth2proxy-login-success.png`](../../evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
|
||||
```json
|
||||
"x-forwarded-email" : [ "labuser@example.com" ],
|
||||
"x-forwarded-preferred-username" : [ "labuser" ],
|
||||
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
|
||||
"x-forwarded-proto" : [ "https" ]
|
||||
```
|
||||
|
||||
> **B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
|
||||
> Forward-Auth 구조의 신원 전달 방식이고, **B-4 의 결론이 그대로 적용된다** —
|
||||
> edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
|
||||
|
||||
## 1-8. 세션이 Redis 에 들어갔는가
|
||||
|
||||
**확인** — 먼저 통째로 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
=== 세션이 Redis 에 들어갔는가 ===
|
||||
b5:pvc
|
||||
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
|
||||
dbsize: 2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`dbsize` 는 2 인데 세션은 하나다.**
|
||||
`b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`dbsize` 로 세션을 세면 틀린다.**
|
||||
이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
|
||||
> `KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹한다.
|
||||
> 실험대에서는 티가 안 나지만 습관을 여기서 들인다.
|
||||
|
||||
## 1-9. 쿠키가 「티켓」인지 확인한다
|
||||
|
||||
세션 저장소를 Redis 로 옮기면 쿠키에는 **세션 전체가 아니라 티켓**만 담긴다.
|
||||
|
||||
**하기** — 브라우저 개발자 도구 → Application/저장소 → Cookies → `_oauth2_proxy`
|
||||
|
||||
**실측** — 해설 문서에 남은 값
|
||||
```
|
||||
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
|
||||
└─ 세션 전체가 아니라 티켓이다 (약 180자)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `|` 로 나뉜 **세 토막**과 전체 길이.
|
||||
|
||||
```
|
||||
_oauth2_proxy=<ticket>|<timestamp>|<mac>
|
||||
└─ Redis 키를 여기서 계산한다
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 쿠키가 짧아졌고(그래서 502 가 사라졌고),
|
||||
**Redis 키 이름은 이 티켓에서 계산된다.** 4절의 「지우지 못한다」가 여기서
|
||||
결정된다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — secret 을 A 에서 B 로 바꾼다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_A"}]'
|
||||
```
|
||||
|
||||
## 2-1. 먼저 「겹칠 수 있는가」를 묻는다
|
||||
|
||||
**바꾸기 전에 이것부터 확인한다.** B-6 의 무중단 회전이 여기서도 되는지가
|
||||
Q1 의 질문 자체이기 때문이다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
|
||||
/bin/oauth2-proxy --help 2>&1 | grep cookie-secret
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`string`. 복수형이 아니다.**
|
||||
`--cookie-secrets` 도 `--old-cookie-secret` 도 목록에 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — **겹침 구간을 만들 수단이 아예 없다.**
|
||||
B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid` 가
|
||||
있어서였다. **oauth2-proxy 의 쿠키에는 그런 식별자가 없다.**
|
||||
|
||||
```
|
||||
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6)
|
||||
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7)
|
||||
```
|
||||
|
||||
**이 한 줄이 이 실험의 답이다.** 나머지는 「그래서 실제로 무슨 일이 나는가」다.
|
||||
|
||||
## 2-2. env 배열의 어느 칸을 바꾸는지 먼저 확인한다
|
||||
|
||||
아래 patch 는 `env/1` 을 지목한다. **매니페스트의 순서에 달린 값이다.**
|
||||
그대로 믿지 말고 확인한다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `OAUTH2_PROXY_COOKIE_SECRET` 이 **몇 번째인가**
|
||||
(0부터 센다). 위 형태에서는 두 번째이므로 `env/1` 이다. 순서가 다르면
|
||||
patch 의 숫자를 고친다. **틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.**
|
||||
|
||||
## 2-3. 바꾼다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date -u '+%H:%M:%S UTC 회전'
|
||||
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_B"}]'
|
||||
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
=== ★ secret 을 A → B 로 교체한다 ===
|
||||
deployment.apps/oauth2-proxy patched
|
||||
deployment "oauth2-proxy" successfully rolled out
|
||||
```
|
||||
|
||||
**시각을 UTC 로 적어 둔다.** 프록시 로그가 UTC 이고, [B-7a](b7a-orphan-session.md)
|
||||
의 정리 규칙이 **이 시각을 기준으로** 고아를 고른다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 교체가 실제로 걸렸는지 확인한다
|
||||
|
||||
## 3-1. 지금 어느 키를 참조하는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
현재 secret 키: COOKIE_SECRET_B
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `COOKIE_SECRET_B`. **Deployment 의 참조가 바뀐 것이지
|
||||
Secret 의 내용이 바뀐 것이 아니다.** 두 값 다 그대로 있고 어느 쪽을 읽을지만
|
||||
바뀌었다 — 그래서 되돌리기가 한 줄이다.
|
||||
|
||||
## 3-2. ★ 그런데 Redis 는 그대로다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
Redis 세션은 그대로인가: 2 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세션 수가 **회전 전과 같다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **회전 자체는 아무 일도 일으키지 않는다.**
|
||||
여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면
|
||||
**누군가 옛 쿠키를 들고 와야** 한다. 그게 4절이다.
|
||||
|
||||
> 이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이
|
||||
> [B-7a](b7a-orphan-session.md) 의 기준선이다.
|
||||
|
||||
## 3-3. 파드가 실제로 새로 떴는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **파드 이름이 1-3 과 다르다.** 같으면 patch 가 아무
|
||||
필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다). 3-1 로 돌아간다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰 — 옛 쿠키를 들고 가 본다
|
||||
|
||||
## 4-1. 브라우저로 다시 연다
|
||||
|
||||
**하기** — 1-7 에서 로그인한 **그 브라우저 그대로**
|
||||
```
|
||||
https://app2.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||||
|
||||
**실측** — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.
|
||||
|
||||
**이 결과가 의미하는 것** — **Keycloak SSO 세션이 살아 있어서 조용히 재인증이
|
||||
일어났다.** 쿠키는 분명히 무효가 됐는데 **사용자 눈에는 아무 일도 없었다.**
|
||||
|
||||
> **여기가 이 실험에서 가장 오해하기 쉬운 자리다.** 「로그인 화면이 안 떴으니
|
||||
> 교체가 무중단이구나」로 읽으면 정확히 반대로 읽은 것이다. **쿠키는 죽었고,
|
||||
> 사용자는 실제로 재인증을 거쳤다.** SSO 가 그 사실을 가려 준 것뿐이다.
|
||||
> **IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.**
|
||||
|
||||
## 4-2. 로그가 무슨 일이 났는지 말한다
|
||||
|
||||
**확인** — 먼저 최근 로그를 그대로 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix
|
||||
```
|
||||
|
||||
**한 번은 통째로 본다.** 어떤 줄이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||
`--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다 — **replica 가 둘이므로 이게 없으면
|
||||
누가 무엇을 했는지 못 가린다.**
|
||||
|
||||
이제 좁힌다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
|
||||
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 줄이 다른 말을 하고 있다.**
|
||||
|
||||
| 줄 | 뜻 |
|
||||
|---|---|
|
||||
| `stored_session.go:94` | 쿠키를 열 수 없다 → **세션을 지우겠다** |
|
||||
| `stored_session.go:97` | **그 지우기가 실패했다** → `error decoding ticket to clear session` |
|
||||
|
||||
**94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다.**
|
||||
|
||||
이어지는 줄이 사용자 쪽 이야기다.
|
||||
|
||||
**실측**
|
||||
```
|
||||
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Initiating login` 과 `AuthSuccess` 가 **같은 초**에 있다.
|
||||
**로그인 흐름이 실제로 돌았고, 사람 손이 안 들어갔다.** 4-1 에서 화면이
|
||||
깜빡였던 것이 이것이다.
|
||||
|
||||
## 4-3. ★ Redis 에 고아가 남는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
|
||||
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
|
||||
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
|
||||
총: 2 개
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키가 둘이다.** 뒤엣것(`b26111f…`)은 1-8 에서 본
|
||||
회전 전의 세션이고, 앞엣것이 4-1 에서 새로 생긴 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — **사용자는 하나인데 서버 세션이 둘이다.**
|
||||
옛 것은 아무도 쓸 수 없고 아무도 지울 수 없다. **고아다.**
|
||||
|
||||
## 4-4. 왜 못 지우는가 — 티켓과 키의 관계
|
||||
|
||||
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 **티켓**만 담긴다(1-9).
|
||||
티켓은 두 부분이다.
|
||||
|
||||
```
|
||||
티켓 = <세션 ID>.<암호화 키>
|
||||
│ └─ 값을 복호화할 키
|
||||
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
|
||||
```
|
||||
|
||||
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 봉인되어 있다.
|
||||
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
|
||||
|
||||
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 「이 세션은 못 쓴다」까지는
|
||||
알지만 **「그 세션이 Redis 어디에 있다」를 모른다.** 그래서 `removing session`
|
||||
을 시도하고 실패한다(4-2 의 97 번 줄).
|
||||
|
||||
```
|
||||
secret 교체
|
||||
└─ 옛 티켓을 못 푼다
|
||||
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
|
||||
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
|
||||
```
|
||||
|
||||
**로그인한 사용자 수만큼 고아가 생긴다.** 여기서 이 실험은 멈췄다.
|
||||
「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를
|
||||
[B-7a](b7a-orphan-session.md) 가 이어서 잰다. **답은 「지울 수 있다」이고,
|
||||
「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.**
|
||||
|
||||
## 4-5. 덤 — replica 를 넘어도 되는 이유
|
||||
|
||||
로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
|
||||
| grep -E 'Initiating login|AuthSuccess'
|
||||
```
|
||||
**실측** — 해설 문서에 남은 형태
|
||||
```
|
||||
--- replica 8p5hl ---
|
||||
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||
GET "/api/echo" ← 흐름을 시작한 replica
|
||||
|
||||
--- replica b9928 ---
|
||||
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
|
||||
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
| | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 |
|
||||
|---|---|---|
|
||||
| BFF | **서버 메모리(HttpSession)** | 콜백이 다른 인스턴스로 가면 **실패** (B-0) |
|
||||
| oauth2-proxy | **쿠키 (secret 으로 봉인)** | **secret 만 같으면 성공** |
|
||||
|
||||
**Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다** — 공유할 상태가 없고,
|
||||
공유할 것은 **k8s Secret 하나뿐**이다. 대신 그 하나가 단일 지점이 된다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. secret 을 A 로 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date -u '+%H:%M:%S UTC 되돌림'
|
||||
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_A"}]'
|
||||
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **이것도 회전이다.** B 로 만든 세션이 이번에는 고아가
|
||||
된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.
|
||||
|
||||
## 5-2. 고아를 정리한다
|
||||
|
||||
**확인** — 지금 몇 개 남았는지 센다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
|
||||
**세 가지 선택지가 있다.**
|
||||
|
||||
| | 언제 | |
|
||||
|---|---|---|
|
||||
| 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 |
|
||||
| TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | [B-7a](b7a-orphan-session.md) 의 규칙 |
|
||||
| 전부 지운다 | 어차피 다 무효일 때 | 아래 |
|
||||
|
||||
**하기** — 전부 지울 때. **`b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
|
||||
done
|
||||
```
|
||||
|
||||
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인).
|
||||
> 1-8 에서 `dbsize` 가 2 였던 이유를 여기서 다시 쓴다.
|
||||
|
||||
## 5-3. ★ Grafana Ingress 를 되돌린다
|
||||
|
||||
**이것을 빠뜨리면 Grafana 가 안 열린다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
```
|
||||
|
||||
**확인** — 실제로 Grafana 로 돌아갔는지 본다
|
||||
```bash
|
||||
sudo kubectl -n observability get ingress grafana
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — Ingress 가 `observability` 에 다시 있고, `app2` 응답이
|
||||
1-1 에서 본 모양으로 돌아왔는가.
|
||||
|
||||
> **두 Ingress 가 같은 host 를 동시에 들고 있으면 안 된다.** oauth2-proxy 것을
|
||||
> **먼저 지우고** Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가
|
||||
> 컨트롤러 판단에 맡겨진다.
|
||||
|
||||
**oauth2-proxy 전체를 걷어내려면**
|
||||
```bash
|
||||
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||
```
|
||||
**다만 [B-7a](b7a-orphan-session.md) 와 [C-1](c1-multi-app-sso.md) 이 이 배포를
|
||||
그대로 쓴다.** 이어서 할 생각이면 남겨 둔다 — 그때는 Grafana Ingress 복구도
|
||||
그 실험이 끝난 뒤로 미룬다.
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
|
||||
| Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 |
|
||||
| Ingress (빌린 것) | `sudo kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 **없다** (걷어냈다면) |
|
||||
| Ingress (Grafana) | `sudo kubectl -n observability get ingress grafana` | **있다** |
|
||||
| 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 콜백이 `502 Bad Gateway` | **쿠키가 크다.** `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지 — 1-5. Redis 세션 저장소로 옮긴다 |
|
||||
| 호스트에서 nginx 설정·로그가 **빈 결과** | **`sudo` 가 조용히 실패했다** | `sudo -n true` → `sudo: a password is required` — 1-5 |
|
||||
| `--cookie-secrets` 를 찾는데 없다 | **단수다.** 겹침 구간이 애초에 없다 | `--help \| grep cookie-secret` — 2-1 |
|
||||
| patch 뒤 프록시가 기동에서 죽는다 | **env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다** | `env[*].name` 순서 확인 — 2-2 |
|
||||
| secret 을 바꿨는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 3-2 |
|
||||
| 로그인 화면이 안 떠서 「무중단」이라 읽었다 | **SSO 가 재인증을 가렸다.** 쿠키는 죽었다 | 로그의 `Initiating login` + `AuthSuccess` — 4-2 |
|
||||
| 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` — 4-2 |
|
||||
| `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-8 |
|
||||
| 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | **호스트에서 파드 IP 는 안 닿는다** ([`02`](../../evidence/b7-cookie-secret/02-cookie-portability.txt)) | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 |
|
||||
| `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | **브라우저를 쓴다** — 전제 |
|
||||
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약의 박스 |
|
||||
| `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `sudo kubectl -n keycloak-lab get ingress` |
|
||||
| 실험이 끝났는데 Grafana 가 안 열린다 | **Ingress 복구를 안 했다** | 5-3 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-7 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-7a](b7a-orphan-session.md) 고아 세션 | **정말 사라지는가 · 지울 수 있는가 · 어느 것이 고아인지 아는가** — 셋 다 답이 나온다 |
|
||||
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **app1(BFF)과 app2(oauth2-proxy)가 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
|
||||
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
|
||||
| 운영 | secret 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |
|
||||
@@ -0,0 +1,699 @@
|
||||
# B-7a 재현 가이드 — 고아 세션을 TTL 로 골라내 지운다
|
||||
|
||||
해설 문서: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md) ·
|
||||
증거 원문: [`docs/evidence/b7a-orphan-session/`](../../evidence/b7a-orphan-session/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 회전만으로는 Redis 가 **안 변하는** 것 | 회전 직후 `--scan` |
|
||||
| 옛 쿠키를 들고 온 **그 순간** 고아가 생기는 것 | 프록시 로그 + Redis |
|
||||
| 새 세션과 고아가 이름·타입·**크기까지** 같은 것 | `type` · `strlen` |
|
||||
| TTL 이 요청을 보내도 **갱신되지 않는** 것 | 30초 간격 3회 |
|
||||
| TTL 로 역산한 생성 시각이 로그와 **1초** 차이인 것 | `AuthSuccess` 시각과 대조 |
|
||||
| 고아만 지워도 산 세션은 `200` 인 것 | 브라우저 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`B-7`](b7-cookie-secret-rotation.md) 이 끝나 있다. oauth2-proxy 가
|
||||
`app2.hyeonworks.com` 에서 돌고 있고 **세션 저장소가 Redis** 여야 한다.
|
||||
이 실험은 B-7 이 「지우지 못했다」로 멈춘 자리에서 시작한다.
|
||||
- **브라우저가 필요하다.** 고아는 사람이 옛 쿠키를 들고 와야 생긴다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 이 실험대에는 **`jq` 가 없다.** Redis 는 자기 CLI 로 묻는다.
|
||||
- **시각은 전부 UTC 로 다룬다.** 이 실험의 결론이 시각 계산이라 여기서
|
||||
섞이면 전부 틀린다 — [1-4](#1-4-시계를-맞춰-둔다) 에서 확인한다.
|
||||
|
||||
## 주의 — 이건 남의 세션을 실제로 지우는 실험이다
|
||||
|
||||
`redis-cli del` 로 세션 키를 지운다. **산 사람의 세션을 잘못 지우면 그 사람은
|
||||
재로그인해야 한다**(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는
|
||||
측정되지 않았지만, **실험대에서만 한다.**
|
||||
|
||||
**B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다** —
|
||||
[5-3](#5-3-원래-자리로-돌려놓는다) 이 그 절차다.
|
||||
|
||||
전 구간 약 20분이고, 그중 **TTL 을 세 번 재는 데 1분**이 그대로 든다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 11:29–11:34 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||
|
||||
Redis 키 이름과 TTL 은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
|
||||
기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
B-7 은 여기서 멈췄다.
|
||||
|
||||
```
|
||||
[stored_session.go:97] Error removing session:
|
||||
error decoding ticket to clear session: session ticket cookie failed validation
|
||||
```
|
||||
|
||||
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||
|
||||
그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. **그런데 못 지우는
|
||||
주체가 누구인지를 안 갈랐다.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| B-7 이 남긴 말 | **「★ 지우지 못했다」** |
|
||||
| B-7a 가 묻는 것 | 그건 **oauth2-proxy 의 한계인가, Redis 의 한계인가** |
|
||||
|
||||
**답은 oauth2-proxy 의 한계다.** 프록시는 티켓을 못 풀어 키를 계산 못 하지만,
|
||||
**운영자는 키를 직접 안다.** `--scan` 하면 다 보인다.
|
||||
|
||||
그러면 다음 물음이 생긴다 — **보이긴 하는데 어느 것이 고아인가.**
|
||||
이 실험이 실제로 재는 것은 그 판별이고, 답은 **TTL 하나**다.
|
||||
|
||||
세 물음을 차례로 잰다.
|
||||
|
||||
```
|
||||
(1) 고아의 TTL 은 정말 줄어드는가 — 사라지기는 하는가
|
||||
(2) 운영자가 지울 수 있는가 — 지우면 산 세션이 다치는가
|
||||
(3) ★ 어느 키가 고아인지 구분되는가 — 이것이 진짜 질문이다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 회전하기 전에
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계
|
||||
```
|
||||
|
||||
## 1-1. ★ `refresh:disabled` 를 먼저 확인한다
|
||||
|
||||
**이 한 단어가 5절 규칙 전체의 전제다.** 여기가 `disabled` 가 아니면
|
||||
이 가이드의 결론은 당신 환경에서 성립하지 않는다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'
|
||||
```
|
||||
**실측** — [`b7-cookie-secret/03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||
```
|
||||
[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 값이다.
|
||||
|
||||
| 값 | 이 실험에서 | |
|
||||
|---|---|---|
|
||||
| `expiry:1h0m0s` | **3600초** | 5절의 역산식에 그대로 들어간다 |
|
||||
| **`refresh:disabled`** | **TTL 이 요청으로 갱신되지 않는다** | 이게 `enabled` 면 역산이 무너진다 |
|
||||
|
||||
**이 결과가 의미하는 것** — TTL 이 고정이면 **TTL 은 생성 시각의 정확한
|
||||
함수**다. 4-3 에서 그 식을 세우고 5-1 에서 그걸로 고아를 고른다.
|
||||
|
||||
기동 로그가 잘려 나갔으면 인자에서 직접 본다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
"--cookie-secure=true"
|
||||
"--cookie-expire=1h"
|
||||
```
|
||||
**어디를 봐야 하는가** — **`--cookie-refresh` 가 목록에 없어야 한다.**
|
||||
없으면 `refresh:disabled` 다.
|
||||
|
||||
## 1-2. 세션을 하나 만든다
|
||||
|
||||
**하기** — 브라우저에서
|
||||
```
|
||||
https://app2.hyeonworks.com/api/echo → labuser / labpass
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — upstream 의 JSON 이 보이면 세션이 생긴 것이다.
|
||||
|
||||
## 1-3. Redis 를 있는 그대로 본다
|
||||
|
||||
**한 번은 통째로, 필드를 하나씩 본다.** 나중에 루프로 묶더라도 처음에는
|
||||
`type`·`ttl`·`strlen` 이 각각 무엇을 답하는지 봐 두어야 한다.
|
||||
|
||||
**확인** — 무엇이 있나
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
[기준선] 회전 전 — 11:29:42 UTC
|
||||
secret = COOKIE_SECRET_A
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
type=string ttl=3568초 크기=3510바이트
|
||||
dbsize=1
|
||||
```
|
||||
|
||||
**확인** — 그 키 하나에 대해 셋을 묻는다. **키 이름은 위 출력에서 가져온다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
| 명령 | 답하는 질문 | 이 실험에서 |
|
||||
|---|---|---|
|
||||
| `type` | 무슨 자료형인가 | 전부 `string` — **구분에 못 쓴다** |
|
||||
| `strlen` | 몇 바이트인가 | 전부 `3510` — **구분에 못 쓴다** |
|
||||
| **`ttl`** | 몇 초 남았나 | **유일하게 다른 값** |
|
||||
|
||||
**`ttl` 이 `-1` 이면** 만료가 안 걸린 키다(이 실험의 대상이 아니다).
|
||||
**`-2` 면** 키가 없다 — 이름을 잘못 옮긴 것이다.
|
||||
|
||||
키가 여럿이 되면 손으로 세 번씩 치기 번거로우니 짧은 함수를 하나 둔다.
|
||||
**한 줄짜리고, 하는 일이 이름 그대로다.**
|
||||
|
||||
```bash
|
||||
R() { sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
|
||||
done
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
|
||||
```
|
||||
|
||||
> 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. **느리다.** 키가 수백 개면
|
||||
> 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다.
|
||||
|
||||
## 1-4. 시계를 맞춰 둔다
|
||||
|
||||
**이 실험은 시각 계산이 결론이다.** 프록시 로그는 **UTC** 이고, 당신 셸의
|
||||
`date` 는 KST 일 것이다. 섞이면 9시간이 틀어진다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
date; date -u
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NTP=yes
|
||||
NTPSynchronized=yes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `NTPSynchronized=yes`. 그리고 **앞으로 `date` 는
|
||||
전부 `-u` 를 붙여 친다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 로그의 `[2026/09/04 05:42:18]` 과 회전 시각을
|
||||
같은 축에 놓을 수 있게 된다. 4-6 의 「1초 오차」는 이 축이 맞아야 나온다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 1차 회전 A → B. 시각을 반드시 기록한다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_A"}]'
|
||||
```
|
||||
|
||||
## 2-1. 회전 시각을 변수에 담는다
|
||||
|
||||
**★ 이 값이 5절 규칙의 절반이다.** 안 적어 두면 나중에 고아를 못 고른다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"
|
||||
```
|
||||
**실측** — 원래 실행의 1차 회전 시각
|
||||
```
|
||||
11:29:56 UTC
|
||||
```
|
||||
|
||||
## 2-2. 바꾼다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_B"}]'
|
||||
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `successfully rolled out`. 그리고 **env 배열의 인덱스가
|
||||
당신 매니페스트와 맞는지**는 B-7 의 [2-2](b7-cookie-secret-rotation.md#2-2-env-배열의-어느-칸을-바꾸는지-먼저-확인한다) 에서
|
||||
확인했다. 안 했으면 지금 한다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 걸렸는지 확인한다
|
||||
|
||||
## 3-1. 어느 키를 참조하는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
COOKIE_SECRET_B
|
||||
```
|
||||
|
||||
## 3-2. ★ 그런데 Redis 는 그대로다
|
||||
|
||||
**확인** — 1-3 과 **똑같은 명령**
|
||||
```bash
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K ttl=$(R ttl $K)"
|
||||
done
|
||||
R dbsize
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
[주입] 1차 회전 A → B — 11:29:56 UTC
|
||||
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키 수가 회전 전과 같다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 여기서 「실험 실패」라고 결론 내리면 틀린다.
|
||||
**회전은 방아쇠가 아니라 조건이다.** 실제로 벌어지는 것은
|
||||
**누군가 옛 쿠키를 들고 오는 순간**이다.
|
||||
|
||||
> A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 자리다.
|
||||
> **주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.**
|
||||
|
||||
## 3-3. 브라우저로 다시 연다 — 여기서 고아가 생긴다
|
||||
|
||||
**하기** — 1-2 에서 로그인한 **그 브라우저 그대로**
|
||||
```
|
||||
https://app2.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
**확인** — 그 순간의 로그
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
브라우저가 접근한 순간(11:30:27) 로그:
|
||||
[stored_session.go:94] Error loading cookied session:
|
||||
session ticket cookie failed validation: <nil>, removing session
|
||||
[stored_session.go:97] Error removing session:
|
||||
error decoding ticket to clear session: session ticket cookie failed validation
|
||||
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`AuthSuccess` 의 시각을 적어 둔다.**
|
||||
`11:30:27`. **4-6 에서 이 숫자와 역산값을 맞춰 본다.**
|
||||
|
||||
**확인** — Redis
|
||||
```bash
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K ttl=$(R ttl $K)"
|
||||
done
|
||||
R dbsize
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
Redis:
|
||||
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||
dbsize=2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키가 둘.** 그리고 로그인 화면을 안 봤다는 사실.
|
||||
|
||||
**이 결과가 의미하는 것** — Keycloak SSO 가 살아 있어 **조용히 재인증**됐다.
|
||||
B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰 — 어느 것이 고아인가
|
||||
|
||||
## 4-1. ★ Redis 값만 보고는 구분할 수 없다
|
||||
|
||||
**확인** — 두 키를 나란히 놓는다
|
||||
```bash
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)"
|
||||
done
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
[측정 1] ★ Redis 만 보고는 구분할 수 없다
|
||||
키 type strlen ttl
|
||||
_oauth2_proxy-87faa1c9…(새) string 3510 3558
|
||||
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 열을 하나씩 지운다.
|
||||
|
||||
| 신호 | 새 세션 | 고아 | 쓸 수 있나 |
|
||||
|---|---|---|---|
|
||||
| 이름 접두사 | `_oauth2_proxy-` | 같다 | ✗ |
|
||||
| 이름 뒷부분 | 불투명한 32자 hex | 같은 성질 | ✗ — 사용자·시각·상태 어느 것도 안 담긴다 |
|
||||
| `type` | `string` | `string` | ✗ |
|
||||
| **`strlen`** | **3510** | **3510** | ✗ — **바이트 단위로 같다** |
|
||||
| `ttl` | 3558 | 3480 | **✓ 이것뿐이다** |
|
||||
|
||||
값을 직접 봐도 소용없다. **암호화되어 있다.**
|
||||
|
||||
**확인** — 바이너리를 이스케이프해 보여 준다
|
||||
```bash
|
||||
R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
새 "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
|
||||
고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
|
||||
md5 f9ad43cc6bbb2db4 / 9b31f7c4138e6472 (다르지만 뜻을 읽을 수 없다)
|
||||
```
|
||||
|
||||
> **`--no-raw` 를 안 붙이면 터미널이 깨진다.** 세션 값은 바이너리다.
|
||||
> 붙이면 `\xNN` 로 이스케이프해서 보여 준다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 값이 다르다는 것은 알 수 있지만 **어느 쪽이
|
||||
고아인지는 말해 주지 않는다.** 뜻을 읽을 수 없기 때문이다.
|
||||
**다른 것은 TTL 하나뿐이다.**
|
||||
|
||||
## 4-2. TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
|
||||
|
||||
**TTL 을 신호로 쓰려면 그것이 믿을 만한지부터 재야 한다.**
|
||||
두 가지를 확인한다 — ① 실제로 줄어드는가 ② 요청을 보내면 되살아나는가.
|
||||
|
||||
**확인** — 30초 간격으로 세 번. 여기에 1분이 그대로 든다
|
||||
```bash
|
||||
for i in 1 2 3; do
|
||||
date -u '+%H:%M:%S'
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
printf " %s ttl=%s\n" "$K" "$(R ttl $K)"
|
||||
done
|
||||
sleep 30
|
||||
done
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
|
||||
30초 간격 3회:
|
||||
t+00초 새=3557 고아=3479
|
||||
t+30초 새=3526 고아=3448
|
||||
t+60초 새=3494 고아=3417
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **30초에 30초씩 준다.** 그리고 **두 값의 차가 거의
|
||||
고정**되어 있다 — 3557−3479 = 78, 3526−3448 = 78, 3494−3417 = **77**.
|
||||
**차이가 (1초 안에서) 고정이라는 것이 「둘 다 생성 시각에만 달렸다」는 뜻이다.**
|
||||
그 1초의 흔들림은 TTL 이 초 단위 정수라서 생기는 반올림이고, **4-6 에서 나오는
|
||||
「1초 오차」와 같은 것**이다.
|
||||
|
||||
이제 ②를 확인한다. **브라우저로 요청을 몇 번 보낸 뒤** 다시 잰다.
|
||||
|
||||
**실측**
|
||||
```
|
||||
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
|
||||
살아있는 세션 ttl=3464 ← 계속 줄어든다
|
||||
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **쓰고 있어도 TTL 이 안 늘어난다.**
|
||||
1-1 에서 본 `refresh:disabled` 가 여기서 값으로 확인됐다.
|
||||
따라서 **고아는 생성 후 정확히 1시간에 사라진다.** 무한정 쌓이지 않는다.
|
||||
|
||||
## 4-3. 개념 — TTL 갱신 여부가 왜 결정적인가
|
||||
|
||||
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
|
||||
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
|
||||
|
||||
**왜 여기 나오나.** TTL 이 고정이면 이 식이 성립한다.
|
||||
|
||||
```
|
||||
생성시각 = 지금 - (cookie-expire - TTL)
|
||||
```
|
||||
|
||||
**이 한 줄이 5절의 정리 규칙 전체를 만든다.** `cookie-expire` 는 1-1 에서
|
||||
`1h0m0s` = 3600 으로 확인했다.
|
||||
|
||||
**없거나 틀리면.** **`--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.**
|
||||
활발히 쓰는 세션일수록 TTL 이 크게 남아 「방금 만들어진 것」처럼 보이고,
|
||||
오래 안 쓴 산 세션은 TTL 이 작아 **고아로 오판되어 지워진다.**
|
||||
|
||||
> **그때는 회전 후 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이
|
||||
> 오히려 정직하다.** 골라내는 척하면서 산 세션을 죽이는 것보다 낫다.
|
||||
> **이 가이드의 5절은 `refresh:disabled` 일 때만 유효하다.**
|
||||
|
||||
## 4-4. 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
|
||||
|
||||
**되돌리기가 없는 조작이다. 지우기 전에 어느 키인지 두 번 확인한다.**
|
||||
지금은 TTL 이 작은 쪽이 고아다(4-1).
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
R dbsize
|
||||
R --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
|
||||
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
|
||||
dbsize 2 → 1
|
||||
남은 키: _oauth2_proxy-87faa1c9…
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **반환값 `1`.** `0` 이면 그 키가 없었던 것이다
|
||||
(이름을 잘못 옮겼다).
|
||||
|
||||
**확인** — 산 세션이 멀쩡한지. **브라우저로 다시 연다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
삭제 직후 브라우저 요청 (11:32:49):
|
||||
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
|
||||
```
|
||||

|
||||
|
||||
**이 결과가 의미하는 것** — **200. 산 세션은 영향이 없다.**
|
||||
|
||||
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. **운영자는 키를 직접 안다.**
|
||||
> 0절의 물음 (2)에 대한 답이 이것이다.
|
||||
|
||||
## 4-5. 누적한다 — 회전할 때마다
|
||||
|
||||
**한 번 더 회전해 본다.** 고아가 일회성인지 누적인지가 갈린다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)"
|
||||
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_A"}]'
|
||||
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||
```
|
||||
|
||||
그리고 **브라우저로 다시 연다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K ttl=$(R ttl $K)"
|
||||
done
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
[측정 4] ★ 누적한다 — 회전할 때마다
|
||||
2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:
|
||||
|
||||
키 TTL 생성시각(추정) 판정
|
||||
_oauth2_proxy-dad9c9fb… 3581 11:33:54 살아있음
|
||||
_oauth2_proxy-87faa1c9… 3373 11:30:26 ★ 고아
|
||||
dbsize=2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`87faa1c9…` 의 신분이 바뀌었다.**
|
||||
3-3 에서 「새 세션」이던 것이 여기서는 고아다.
|
||||
|
||||
**이 결과가 의미하는 것** — **1차 회전을 살아남았던 세션이 2차 회전에서
|
||||
고아가 됐다.** 회전 1회 = **그 시점 로그인 사용자 수**만큼의 고아.
|
||||
고아는 사건이 아니라 **회전의 고정 비용**이다.
|
||||
|
||||
## 4-6. ★ 역산이 실제로 맞는지 검증한다
|
||||
|
||||
**규칙을 쓰기 전에 규칙 자체를 검증한다.** 위 표의 「생성시각(추정)」은
|
||||
4-3 의 식으로 나온 값이고, 우리에겐 대조할 실측이 하나 있다 —
|
||||
**3-3 의 `AuthSuccess` 로그 시각.**
|
||||
|
||||
**실측**
|
||||
```
|
||||
[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
|
||||
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **1초.** TTL 이 초 단위 정수라 반올림에서 나올 수
|
||||
있는 크기다.
|
||||
|
||||
**이 결과가 의미하는 것** — **TTL 역산은 추정이 아니라 측정에 가깝다.**
|
||||
그래서 다음 규칙을 안심하고 쓸 수 있다.
|
||||
|
||||
```
|
||||
생성시각 < 회전시각 → 그 키는 고아다
|
||||
```
|
||||
|
||||
**왜 성립하는가** — 회전 **이후에** 만들어진 세션은 **새 secret 으로**
|
||||
만들어졌으므로 반드시 유효하다. 그러니 회전 이전 생성분만 고르면 된다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 정리와 복구
|
||||
|
||||
## 5-1. ★ 먼저 눈으로 보고, 그 다음에 지운다
|
||||
|
||||
**`del` 을 바로 붙이지 않는다.** 같은 루프를 `echo` 로 한 번 돌려
|
||||
**무엇이 지워질지 읽는다.**
|
||||
|
||||
**확인** — 지우지 않는 판. `ROT` 은 2-1(또는 4-5의 `ROT2`)에서 담아 둔 값이다
|
||||
```bash
|
||||
NOW=$(date -u +%s); EXP=3600
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||
if [ "$C" -lt "$ROT" ]; then
|
||||
echo "고아 $K (생성 $(date -u -d @$C +%H:%M:%S))"
|
||||
else
|
||||
echo "산것 $K (생성 $(date -u -d @$C +%H:%M:%S))"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **「산것」이 정확히 지금 로그인해 있는 사람 수만큼
|
||||
있는가.** 아니면 `ROT` 이 틀렸거나 `EXP` 가 3600 이 아니다.
|
||||
|
||||
**`NOW` 를 루프 밖에서 한 번만 잡는 것이 중요하다.** 안에서 잡으면 키마다
|
||||
기준 시각이 달라진다.
|
||||
|
||||
**하기** — 확인한 뒤에 지운다
|
||||
```bash
|
||||
NOW=$(date -u +%s); EXP=3600
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||
if [ "$C" -lt "$ROT" ]; then
|
||||
echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"
|
||||
fi
|
||||
done
|
||||
R dbsize
|
||||
```
|
||||
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||
```
|
||||
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
|
||||
산 세션은 남고 고아만 사라졌다.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 지운 뒤 브라우저로 한 번 더 열어 본다.
|
||||
**열리면 산 세션이 안 다친 것이다**(4-4).
|
||||
|
||||
> **`dbsize` 는 이 Redis 전체를 센다.** BFF 세션과 B-5 가 남긴 키도 들어 있다.
|
||||
> 여기서 `dbsize=1` 이 나온 것은 당시 다른 키가 없었기 때문이고,
|
||||
> **당신 환경에서는 다를 수 있다.** 세션만 세려면 `--scan --pattern` 을 쓴다.
|
||||
|
||||
## 5-2. 전제가 깨졌을 때 — 정직한 대안
|
||||
|
||||
`--cookie-refresh` 가 켜져 있으면 5-1 을 **쓰면 안 된다**(4-3).
|
||||
그때는 전부 지우고 모두 재인증시킨다.
|
||||
|
||||
```bash
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done
|
||||
```
|
||||
|
||||
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 에는 BFF 세션도 들어 있다.
|
||||
> 패턴으로 좁히는 것이 이 실험대에서는 필수다.
|
||||
|
||||
## 5-3. 원래 자리로 돌려놓는다
|
||||
|
||||
**하기** — secret 을 A 로
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
|
||||
```
|
||||
`COOKIE_SECRET_B` 로 나오면 A 로 되돌린다(2절의 되돌리기).
|
||||
4-5 에서 이미 A 로 돌아왔다면 그대로 둔다.
|
||||
|
||||
**하기** — B-7 에서 Grafana Ingress 를 빌렸다면 **여기서 돌려준다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||
|
||||
> **[C-1](c1-multi-app-sso.md) 을 이어서 할 생각이면 아직 돌려주지 않는다.**
|
||||
> C-1 이 app2 를 그대로 쓴다. 그 대신 **C-1 이 끝난 뒤에 반드시 복구한다.**
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
|
||||
| 세션 | `R --scan --pattern '_oauth2_proxy-*'` | 지금 로그인한 사람 수만큼만 |
|
||||
| 다른 키 | `R --scan --pattern '*'` | `b5:pvc`·BFF 세션이 **살아 있다** (안 지웠어야 한다) |
|
||||
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
|
||||
| 셸 변수 | `unset ROT ROT2 NOW EXP` | — |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 회전했는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 생긴다 | 브라우저로 접근 — 3-2·3-3 |
|
||||
| 고아와 산 세션이 구분이 안 간다 | **이름·타입·크기가 같다.** 값은 암호화 | **TTL 만이 신호다** — 4-1 |
|
||||
| `get` 했더니 터미널이 깨진다 | 값이 바이너리다 | `redis-cli --no-raw get` — 4-1 |
|
||||
| `ttl` 이 `-1` | 만료가 안 걸린 키다 | 이 실험의 대상이 아니다 |
|
||||
| `ttl` 이 `-2` · `del` 이 `0` | **그 키가 없다** | 키 이름을 `--scan` 출력에서 다시 옮긴다 |
|
||||
| 역산 생성시각이 미래거나 엉뚱하다 | `EXP` 가 3600 이 아니다 | `--cookie-expire` 를 확인 — 1-1 |
|
||||
| 역산이 9시간 어긋난다 | **`date` 를 로컬로 쳤다** | 전부 `date -u` — 1-4 |
|
||||
| **산 세션이 고아로 잡힌다** | **`--cookie-refresh` 가 켜져 있다** | 기동 로그의 `refresh:disabled` 확인. 켜져 있으면 5-2 |
|
||||
| 산 세션을 지워 버렸다 | 되돌릴 수 없다 | 재로그인하면 된다. SSO 가 살아 있으면 조용히 지나간다 |
|
||||
| `dbsize` 와 세션 수가 안 맞는다 | `b5:pvc`·BFF 세션이 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-3 |
|
||||
| `FLUSHDB` 로 지웠더니 app1 도 끊겼다 | **같은 Redis 에 BFF 세션이 있다** | 패턴으로 좁혀 지운다 — 5-2 |
|
||||
| 루프가 너무 느리다 | 키마다 `kubectl exec` 를 한다 | `--scan` 결과를 먼저 받아 두고 필요한 것만 묻는다 |
|
||||
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-7a 가 남긴 것 |
|
||||
|---|---|
|
||||
| [B-7](b7-cookie-secret-rotation.md) cookie secret | **「지우지 못했다」가 정정됐다** — 프록시가 못 하는 것이지 불가능한 것이 아니다 |
|
||||
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **같은 Redis 에 BFF 세션과 프록시 세션이 함께 있다.** 지울 때 패턴을 좁혀야 하는 이유 |
|
||||
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | 회전의 진짜 비용은 **재로그인이 아니라 저장소에 남는 것**이다 |
|
||||
| 운영 | 회전 시각을 **UTC epoch 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |
|
||||
@@ -0,0 +1,716 @@
|
||||
# C-1 재현 가이드 — 앱 둘에 SSO 를 걸고, IdP 세션을 죽여 본다
|
||||
|
||||
해설 문서: [`docs/experiment-c1-multi-app-sso.md`](../../experiment-c1-multi-app-sso.md) ·
|
||||
증거 원문: [`docs/evidence/c1-multi-app-sso/`](../../evidence/c1-multi-app-sso/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 두 번째 앱이 **로그인 화면 없이** 열리는 것 | 브라우저 |
|
||||
| `user session 1` 에 `client session 2` 가 매달린 구조 | PostgreSQL |
|
||||
| 서로 다른 구조의 두 앱이 같은 user session 을 공유하는 것 | `client` 조인 |
|
||||
| **IdP 세션을 죽여도 두 앱이 그대로 열리는 것** | 브라우저 + Redis |
|
||||
| `logout-all` 이 오류 없이 아무것도 안 하는 것 | 세션 수가 안 변한다 |
|
||||
| realm 을 안 보고 세면 `master` 의 admin 세션에 속는 것 | `realm` 조인 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`B-2`](../../experiment-b2-multi-instance-session.md) 의 **app1(BFF)** 과
|
||||
[`B-7`](b7-cookie-secret-rotation.md) 의 **app2(oauth2-proxy)** 가 **둘 다** 떠 있다.
|
||||
이 실험은 그 둘이 있어야 성립한다 — 없으면 SSO 가 아니라 로그인 한 번이다.
|
||||
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다(B-7 의 주의).
|
||||
이 실험이 끝나면 [5-3](#5-3-빌린-것을-돌려준다) 에서 되돌린다.
|
||||
- **브라우저가 필요하다.** SSO 는 브라우저 쿠키가 만드는 현상이고, `curl` 로는
|
||||
「로그인 화면이 안 떴다」를 볼 수 없다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). `kcadm.sh` 는
|
||||
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.**
|
||||
- 이 실험대에는 **`jq` 가 없다.**
|
||||
|
||||
## 주의 — 이건 세션을 전부 지우고 시작하는 실험이다
|
||||
|
||||
기준선을 만들려고 **Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고,
|
||||
Keycloak StatefulSet 을 재시작한다.** 그 순간 **지금 로그인해 있는 모든 사람이
|
||||
끊긴다.** 실험대에서만 한다.
|
||||
|
||||
전 구간 약 20분이고, Keycloak 재시작에 1~2분이 든다.
|
||||
중간에 그만두려면 [5. 복구](#5-복구) 로 간다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:44–14:48 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했고 SQL 원문은 기록에 없다 |
|
||||
|
||||
세션 id·Redis 키·클라이언트 UUID 는 **당신 환경에서 다르다.** 이 문서는
|
||||
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
예시로 실린 값은 전부 위 수집 기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
원래 질문은 한 줄이었다.
|
||||
|
||||
> *"SSO 를 추가하게 되면 어떻게 달라지는지"*
|
||||
|
||||
「달라진다」에는 두 방향이 섞여 있다 — 편해지는 쪽과 위험해지는 쪽.
|
||||
위험 쪽의 통념은 이렇다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 통념 | SSO 를 붙이면 **IdP 가 단일 장애점**이 된다. IdP 가 죽으면 다 죽는다 |
|
||||
| **실측** | **절반만 맞다.** 로그인 **경로**는 그렇고, **이미 로그인한 사용자**는 아니다 |
|
||||
|
||||
**둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 판정된다.** 그게 이 실험이다.
|
||||
|
||||
핵심은 **수명이 세 층으로 나뉘어 있다는 것**이다.
|
||||
|
||||
```
|
||||
① IdP 세션 (Keycloak) ssoSessionIdleTimeout
|
||||
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
|
||||
③ access token 60초
|
||||
|
||||
①을 지워도 ②는 자기 수명을 산다
|
||||
```
|
||||
|
||||
**로그아웃이 지우는 것은 ① 뿐이다.** 이 실험대에는 서로 완전히 다르게
|
||||
세션을 다루는 앱이 둘 있어서, ②가 어떻게 살아남는지를 두 형태로 동시에 볼 수 있다.
|
||||
|
||||
```
|
||||
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
|
||||
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
|
||||
|
||||
둘 다 realm keycloak-patterns
|
||||
```
|
||||
|
||||
**우연히 좋은 실험대가 됐다.** B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이
|
||||
같은 IdP 를 쓰면서 세션을 정반대로 다룬다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 깨끗한 상태를 만든다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
|
||||
```
|
||||
|
||||
## 1-1. 두 앱이 다 떠 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
sudo kubectl -n keycloak-lab get ingress
|
||||
```
|
||||
**어디를 봐야 하는가** — `bff` 와 `oauth2-proxy` 가 **둘 다** `Running` 이고,
|
||||
Ingress 에 `app1.hyeonworks.com` 과 `app2.hyeonworks.com` 이 **둘 다** 있는가.
|
||||
|
||||
**확인** — 밖에서
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||||
curl -s -o /dev/null -w 'app2 %{http_code}\n' https://app2.hyeonworks.com/
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
|
||||
```
|
||||
app1 HTTP 200 / app2 HTTP 200
|
||||
```
|
||||
|
||||
> **`app2` 가 Grafana 로 간다면** B-7 의 Ingress 가 없는 것이다.
|
||||
> B-7 의 [1-1~1-2](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다) 를 먼저 한다.
|
||||
|
||||
## 1-2. 세션을 지우려고 시도한다 — 그리고 실패를 본다
|
||||
|
||||
**kcadm 을 먼저 로그인시킨다.** 파드가 재시작되면 세션이 사라지고 이후 모든
|
||||
명령이 `401` 이 된다. **1-3 에서 실제로 재시작하므로 그때 다시 해야 한다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
**하기** — 가장 자연스러운 방법부터 친다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create realms/keycloak-patterns/logout-all
|
||||
```
|
||||
|
||||
**확인** — 세션이 정말 지워졌는지 센다. **미검증** (증거에는 이 SQL 의 원문이 없다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
|
||||
```
|
||||
=== 깨끗한 상태로 초기화 ===
|
||||
DELETE 1
|
||||
|
||||
=== 기준선 ===
|
||||
Keycloak 온라인 세션: 4
|
||||
Redis 키: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`4`.** 0 이 아니다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`logout-all` 이 안 먹었다.** 오류도 안 났다.
|
||||
세션이 그대로 4개 남아 있다.
|
||||
|
||||
> **★ 해설 문서 정정** — 이 문서는 처음에 이 값을 `0` 으로 인쇄했다.
|
||||
> 증거 [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt) 는 **`4`** 다.
|
||||
> **`0` 은 그 다음 단계(DB 직접 삭제 + 재시작)의 값이었다.**
|
||||
> 이 가이드는 증거를 따른다 — **여기서 4 가 나오는 것이 정상이다.**
|
||||
|
||||
**왜 안 먹었나** — **캐시 때문이다.** A-1 에서 확인했듯 Keycloak 은 세션을
|
||||
DB 에서 읽되 **캐시로 답한다.** 관리 API 가 무효화를 걸어도 각 노드의 캐시가
|
||||
그대로면 세션은 살아 있는 것처럼 보인다.
|
||||
|
||||
## 1-3. 그래서 DB 를 직접 지우고 Keycloak 을 재시작한다
|
||||
|
||||
**하기** — 자식 테이블부터 지운다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
DELETE 2
|
||||
DELETE 4
|
||||
```
|
||||
|
||||
**하기** — 앱 세션도 비운다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||
```
|
||||
|
||||
> **`flushall` 은 이 Redis 전체를 지운다.** BFF 세션·oauth2-proxy 세션·
|
||||
> B-5 가 남긴 `b5:pvc` 까지 전부다. **기준선을 만드는 자리라서 의도한 것**이고,
|
||||
> 실험 도중에는 절대 쓰지 않는다([B-7a](b7a-orphan-session.md) 5-2 참고).
|
||||
|
||||
**하기** — 캐시를 비우려면 프로세스를 새로 띄우는 수밖에 없다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
**되돌리기** — 없다. **지운 세션은 안 돌아온다.** 다시 로그인하면 된다.
|
||||
|
||||
**확인** — 이제 비었는가
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session where offline_flag='0'"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
|
||||
```
|
||||
Redis 키: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — Redis 키 **0**, 세션 수 **0**.
|
||||
여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다.
|
||||
|
||||
**★ kcadm 세션이 날아갔다.** 1-2 의 `config credentials` 를 **다시 친다.**
|
||||
|
||||
## 1-4. ★ 세는 법을 먼저 고친다 — realm 을 본다
|
||||
|
||||
**이 절을 건너뛰면 4절의 결론을 반대로 읽는다.**
|
||||
|
||||
`offline_user_session` 에는 **모든 realm 의 세션**이 들어 있다. 그리고
|
||||
`kcadm` 을 쓰는 순간 **`master` realm 에 admin 세션이 생긴다.**
|
||||
그러니 그냥 세면 **내가 만든 노이즈를 남의 세션으로 읽는다.**
|
||||
|
||||
**확인** — 틀린 방법(전체를 센다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
**확인** — 맞는 방법(realm 을 조인한다). **미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, r.name as realm,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id=us.user_session_id) as clients
|
||||
from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where us.offline_flag='0'"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`realm` 열.** `keycloak-patterns` 만이 이 실험의
|
||||
대상이고, `master` 는 **당신이 방금 `kcadm` 을 쳐서 생긴 것**이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 한 열 때문에 원래 실행은 **「안 지워졌다」로
|
||||
오독할 뻔했다.** 해설 문서가 「세 번째가 특히 위험했다」고 쓴 것이 이 실수다.
|
||||
|
||||
> **여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다.**
|
||||
> 「세션 1개」가 아니라 「`keycloak-patterns` 세션 0개, `master` 1개」다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 두 앱에 차례로 들어간다
|
||||
|
||||
여기서 SSO 상태를 만든다. **되돌리기는 간단하다** — 5절의 초기화를 다시 하면
|
||||
된다. 파괴적인 조작은 4절에 있다.
|
||||
|
||||
## 2-1. app1 에 로그인한다 — 로그인 화면이 나온다
|
||||
|
||||
**하기** — 브라우저에서
|
||||
```
|
||||
https://app1.hyeonworks.com/ → labuser / labpass
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **Keycloak 로그인 화면이 뜨는가.**
|
||||
주소창이 이렇게 바뀐다.
|
||||
|
||||
**실측** — 해설 문서에 남은 형태
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
|
||||
?client_id=bff-confidential&...
|
||||
→ Sign in to keycloak-patterns
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 첫 앱에서는 **당연히 로그인 화면이 나온다.**
|
||||
이것이 2-2 의 대조군이다. **이걸 안 보면 「app2 에서 안 뜬 것」이 특별한
|
||||
일인지 알 수 없다.**
|
||||
|
||||
## 2-2. 로그인 직후 상태를 잰다
|
||||
|
||||
**확인** — 1-4 의 맞는 쿼리를 그대로 쓴다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, r.name as realm,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id=us.user_session_id) as clients
|
||||
from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where us.offline_flag='0' and r.name='keycloak-patterns'"
|
||||
```
|
||||
**실측** — [`02-after-app1-login.txt`](../../evidence/c1-multi-app-sso/02-after-app1-login.txt)
|
||||
```
|
||||
=== app1 로그인 직후 Keycloak 세션 ===
|
||||
user_session_id | client_sessions
|
||||
--------------------------+-----------------
|
||||
oqOjHekin4JU-BZjgQLjUByW | 1
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`user_session_id` 를 적어 둔다.** 2-3 과 3절에서
|
||||
계속 쓴다. 그리고 **`client_sessions` 가 1** 이다.
|
||||
|
||||
**확인** — 저장소 두 곳
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
Redis 키: 1
|
||||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||
PostgreSQL authorized client: 1 행
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — Redis 키 이름의 **접두사 `bff:session:sessions:`**.
|
||||
이 접두사가 「BFF 가 만든 세션」이라는 뜻이고, 3절에서 프록시 것과 갈라진다.
|
||||
|
||||
`authorized client` 는 BFF 가 토큰을 넣어 둔 PostgreSQL 행이다. **미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from oauth2_authorized_client"
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **한 번 로그인했는데 상태가 세 곳에 생겼다.**
|
||||
Keycloak 세션 · Redis 세션 · PostgreSQL 토큰. 4절에서 이 셋의 운명이 갈린다.
|
||||
|
||||
## 2-3. app2 를 방문한다 — 여기가 SSO 다
|
||||
|
||||
**하기** — **같은 브라우저의 새 탭**에서
|
||||
```
|
||||
https://app2.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||||
|
||||
**실측** — 뜨지 않았다.
|
||||

|
||||
|
||||
**이 결과가 의미하는 것** — **SSO 가 동작한다.** app2 는 Keycloak 으로
|
||||
리다이렉트했지만, Keycloak 에 이미 세션이 있어서 **묻지 않고 바로 돌려보냈다.**
|
||||
|
||||
> **다른 브라우저나 시크릿 창에서 열면 안 된다.** SSO 를 만드는 것은
|
||||
> `auth.hyeonworks.com` 에 붙은 **브라우저 쿠키**다. 창이 다르면 쿠키가 없고,
|
||||
> 그러면 로그인 화면이 뜨는 것이 정상이다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 만든 구조를 확인한다
|
||||
|
||||
## 3-1. user session 하나에 client session 둘
|
||||
|
||||
**확인** — 2-2 와 **똑같은 쿼리**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, r.name as realm,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id=us.user_session_id) as clients
|
||||
from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where us.offline_flag='0' and r.name='keycloak-patterns'"
|
||||
```
|
||||
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
|
||||
```
|
||||
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
|
||||
user_session_id | client_sessions
|
||||
--------------------------+-----------------
|
||||
oqOjHekin4JU-BZjgQLjUByW | 2
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`user_session_id` 가 2-2 와 같고, `client_sessions`
|
||||
만 1 → 2 로 늘었다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **두 번째 로그인이 아니라 같은 로그인에 앱이
|
||||
하나 붙은 것이다.** 이것이 SSO 의 데이터 구조다.
|
||||
|
||||
```
|
||||
user session (사용자 · 브라우저 하나당 하나)
|
||||
├─ client session : bff-confidential
|
||||
└─ client session : oauth2-proxy
|
||||
```
|
||||
|
||||
## 3-2. 어느 클라이언트가 붙었는가
|
||||
|
||||
**확인** — **미검증** (증거에는 이 SQL 의 원문이 없다. 출력은 실측이다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select cs.client_id, c.client_id as name
|
||||
from offline_client_session cs join client c on c.id = cs.client_id
|
||||
where cs.user_session_id = 'oqOjHekin4JU-BZjgQLjUByW'"
|
||||
```
|
||||
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
|
||||
```
|
||||
=== 어느 클라이언트가 붙었는가 ===
|
||||
client_id | name
|
||||
--------------------------------------+------------------
|
||||
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
|
||||
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`client_id` 열은 UUID 이고, 사람이 아는 이름은
|
||||
`client` 테이블에 있다.** 조인 없이 보면 UUID 두 개만 나와서 어느 앱인지
|
||||
알 수 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 구조가 완전히 다른 두 앱이 **같은 user session
|
||||
아래에 나란히** 있다. Keycloak 은 앱이 세션을 어떻게 다루는지 모르고,
|
||||
알 필요도 없다.
|
||||
|
||||
## 3-3. 저장소 세 곳이 각자 무엇을 들고 있는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
|
||||
```
|
||||
=== 저장소 상태 ===
|
||||
Redis 키:
|
||||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||
PostgreSQL authorized client: 1 행
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **같은 Redis 에 접두사가 다른 두 세션**이 있다.
|
||||
`bff:session:sessions:` 는 Spring Session 이 쓰는 이름이고,
|
||||
`_oauth2_proxy-` 는 프록시가 쓰는 이름이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 「세션 저장소를 공유한다」는 말이 **「같은 Redis 를
|
||||
쓴다」일 뿐 「같은 세션을 본다」가 아니다.** 둘은 서로의 키를 모른다.
|
||||
[B-7a](b7a-orphan-session.md) 에서 `FLUSHDB` 를 금지한 이유가 이것이다.
|
||||
|
||||
### 개념 — 두 층으로 나뉘어 있는 이유
|
||||
|
||||
**무엇인가.** Keycloak 은 세션을 `user session`(사람 하나)과
|
||||
`client session`(그 사람이 쓰는 앱 하나)으로 나눠 둔다.
|
||||
|
||||
**왜 여기 나오나.** A층·B층에서 본 두 사건이 서로 다른 층을 건드렸다.
|
||||
|
||||
| | 무엇이 사라졌나 | 결과 |
|
||||
|---|---|---|
|
||||
| **A-3** DB 크래시 | `user_session` 행이 통째로 | **모든 앱이 끊긴다** |
|
||||
| **B-3** refresh 재사용 탐지 | **`client_session` 만** | **그 앱만 끊긴다** |
|
||||
|
||||
**두 층이 나뉘어 있는 이유가 SSO 다.** 앱 하나의 사고가 다른 앱으로 번지지
|
||||
않게 하려면 client session 이 따로 있어야 한다.
|
||||
|
||||
**없거나 틀리면.** 한 층뿐이라면 B-3 의 재사용 탐지 한 번이 **모든 앱을**
|
||||
끊었을 것이다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰 — IdP 세션만 죽인다
|
||||
|
||||
## 4-1. 무엇을 지우는지 먼저 정한다
|
||||
|
||||
**지우려는 것은 ①(IdP 세션)뿐이다.** ②(앱 세션)와 ③(토큰)은 손대지 않는다.
|
||||
**그 구분이 이 실험의 전부다.**
|
||||
|
||||
**되돌리기** — 다시 로그인하면 된다. 파괴적이지만 회복은 쉽다.
|
||||
|
||||
## 4-2. 지우는 방법을 고른다 — 두 개는 안 먹는다
|
||||
|
||||
**하기** — 세션 id 를 지목해서 지운다. **미검증 · 안 먹는다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **오류도 안 나고 세션도 안 줄어든다.**
|
||||
1-2 의 `logout-all` 과 같은 유형이다.
|
||||
|
||||
**하기** — 사용자 단위로 끊는다. **이건 먹는다**
|
||||
```bash
|
||||
USERID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users -r keycloak-patterns -q username=labuser --fields id \
|
||||
--format csv --noquotes | tail -1)
|
||||
echo "$USERID"
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create "users/$USERID/logout" -r keycloak-patterns
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `echo "$USERID"` 가 **UUID 한 줄**인가.
|
||||
비어 있거나 여러 줄이면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은
|
||||
것이다. 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다.
|
||||
|
||||
> **자리표시자를 두지 않으려고 두 단계로 나눴다.** 한 줄로 이어 붙일 수도
|
||||
> 있지만, **그러면 UID 가 비었을 때 그 사실이 안 보인다.**
|
||||
|
||||
## 4-3. IdP 쪽은 정말 끊겼는가 — realm 을 보고 센다
|
||||
|
||||
**확인** — 1-4 의 맞는 쿼리
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, r.name as realm,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id=us.user_session_id) as clients
|
||||
from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where us.offline_flag='0'"
|
||||
```
|
||||
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
|
||||
```
|
||||
=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
|
||||
남은 Keycloak 세션: 1
|
||||
|
||||
=== 남은 세션의 realm 과 client ===
|
||||
user_session_id | realm | clients
|
||||
--------------------------+--------+---------
|
||||
E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **「남은 세션 1」과 「그 1의 realm 이 `master`」를
|
||||
같이 본다.**
|
||||
|
||||
**이 결과가 의미하는 것** — `keycloak-patterns` 세션은 **0** 이다.
|
||||
남은 하나는 **당신이 `kcadm` 을 쳐서 생긴 admin 세션**이다.
|
||||
|
||||
> **★ 여기가 이 실험에서 가장 잘 틀리는 자리다.** 「1이 남았네, 로그아웃이
|
||||
> 안 먹었구나」로 읽으면 4-4 의 결론이 통째로 뒤집힌다. **숫자 옆에 realm 을
|
||||
> 붙이지 않으면 그 숫자는 아무 뜻이 없다.**
|
||||
|
||||
## 4-4. ★ 앱 세션은 그대로 남아 있다
|
||||
|
||||
**확인** — 3-3 과 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
|
||||
```
|
||||
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
|
||||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||
PostgreSQL authorized client: 1 행
|
||||
|
||||
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키 이름이 3-3 과 글자 하나까지 같다.**
|
||||
아무것도 안 지워졌다.
|
||||
|
||||
**이 결과가 의미하는 것** — **로그아웃은 ①만 지웠다.** ②도 ③도 아무도 안 건드렸다.
|
||||
|
||||
## 4-5. 브라우저로 두 앱을 다시 연다
|
||||
|
||||
**하기** — 아까 그 브라우저에서
|
||||
```
|
||||
https://app1.hyeonworks.com/
|
||||
https://app2.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||||
|
||||
**실측** — 둘 다 로그인 화면 없이 그대로 열렸다.
|
||||

|
||||
|
||||
> **★ 증거의 정직성에 관한 주의** — 위 스크린샷과 2-3 의 스크린샷은
|
||||
> **바이트 단위로 동일한 파일**이다(md5 `2c703176…`). 두 시점의 화면이 실제로
|
||||
> 같은 내용이었기 때문이며 조작이 아니지만, **그래서 두 시점을 구별하는 증거가
|
||||
> 되지 못한다.** 구별은 [`03-`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt) 과
|
||||
> [`04-`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt) 의 터미널 출력이 한다 —
|
||||
> `client_sessions` 1→2, 그리고 IdP 세션 삭제 후에도 Redis 키가 남아 있는 것.
|
||||
> **화면이 같아 보인다는 것 자체가 이 실험의 결론**이라, 화면만으로는 증명이 안 된다.
|
||||
|
||||
## 4-6. 왜 그런가 — 세 개의 독립된 수명
|
||||
|
||||
```
|
||||
① IdP 세션 (Keycloak) ssoSessionIdleTimeout 1800초
|
||||
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
|
||||
③ access token 60초
|
||||
|
||||
①을 지워도 ②는 자기 수명을 산다
|
||||
```
|
||||
|
||||
**앱은 매 요청마다 IdP 에 물어보지 않는다.** 자기 세션이 살아 있으면 그걸로
|
||||
답한다. **그래서 ①이 사라진 것을 모른다.**
|
||||
|
||||
**그러면 언제 알게 되는가.**
|
||||
|
||||
| | 언제 끊기는가 |
|
||||
|---|---|
|
||||
| BFF | access token 이 만료되어 **refresh 를 시도할 때** → `Session not active` |
|
||||
| oauth2-proxy | 쿠키 만료(1시간) 또는 **토큰 갱신을 시도**할 때 |
|
||||
|
||||
**즉시가 아니라 지연되어 끊긴다.** 최대 지연은 access token 수명(60초)이 아니라
|
||||
**앱이 다음에 IdP 를 부를 때까지**다.
|
||||
|
||||
> **B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다.**
|
||||
> 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고,
|
||||
> 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다.
|
||||
> **두 방향 모두 「한쪽만 지우면 다른 쪽이 남는다」이다.**
|
||||
|
||||
**확인** — 실제로 끊기는 순간을 보고 싶으면 기다린다. **미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout
|
||||
```
|
||||
그 시간이 지난 뒤 app1 을 새로고침하면 로그인 화면으로 떨어진다.
|
||||
|
||||
## 4-7. 그래서 SSO 의 대가는 무엇인가
|
||||
|
||||
원래 질문에 대한 답이다.
|
||||
|
||||
| | 앱이 하나일 때 | **SSO 일 때** |
|
||||
|---|---|---|
|
||||
| 로그인 | 앱마다 | **한 번** |
|
||||
| IdP 가 죽으면 | 그 앱만 로그인 불가 | **모든 앱이 로그인 불가** |
|
||||
| **이미 로그인한 사용자** | — | **★ 영향 없다** (앱 세션이 살아 있으므로) |
|
||||
| 로그아웃 | 그 앱만 | **전 앱을 끊으려면 백채널 로그아웃이 필요** |
|
||||
| 세션 수명 | 하나 | **세 층이 각자** — 어긋나면 예측이 어렵다 |
|
||||
|
||||
**IdP 는 「로그인 경로」의 단일 장애점이지 「이미 로그인한 사용자」의 단일
|
||||
장애점이 아니다.** A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.
|
||||
|
||||
```
|
||||
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
|
||||
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
|
||||
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
|
||||
```
|
||||
|
||||
**장애가 즉시 전면화되지 않고 「앱 세션 수명만큼 지연되어 몰려온다」.**
|
||||
이것이 SSO 구조의 장애 모양이고, **모니터링이 어려운 이유**다.
|
||||
|
||||
**그리고 마지막 줄이 다음 실험을 부른다** — 전 앱을 끊으려면 백채널
|
||||
로그아웃이 필요하다. **그게 되는지는 [C-2](c2-backchannel-logout.md) 가 잰다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 세션을 정리한다
|
||||
|
||||
**하기** — 1-3 과 같은 절차
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
**그냥 둬도 된다.** 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고,
|
||||
IdP 세션은 이미 없다. **정리는 다음 실험을 깨끗하게 시작하려는 것뿐이다.**
|
||||
|
||||
## 5-2. 브라우저 쿠키를 지운다
|
||||
|
||||
**하기** — `auth.hyeonworks.com`·`app1`·`app2` 의 쿠키를 지우거나
|
||||
**시크릿 창을 새로 연다.**
|
||||
|
||||
**왜** — 서버 세션을 다 지워도 **브라우저에 낡은 쿠키가 남는다.**
|
||||
다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 이것이다.
|
||||
|
||||
## 5-3. 빌린 것을 돌려준다
|
||||
|
||||
**app2 는 Grafana 의 이름이다.** C-2 를 이어서 하지 않을 거라면 지금 되돌린다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||
|
||||
> **[C-2](c2-backchannel-logout.md) 를 이어서 할 생각이면 아직 돌려주지 않는다.**
|
||||
> C-2 가 두 앱을 그대로 쓴다. **그 대신 C-2 가 끝난 뒤에 반드시 복구한다.**
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| Keycloak 세션 | 1-4 의 realm 조인 쿼리 | `keycloak-patterns` **0** (`master` 는 있을 수 있다) |
|
||||
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었거나 남기기로 한 것만 |
|
||||
| 토큰 | `select count(*) from oauth2_authorized_client` | 0 |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running`, `keycloak` 둘 다 `1/1` |
|
||||
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||
|
||||
> **이 실험이 재지 않은 것** — 4-6 의 「언제 끊기는가」를 **실제로 기다려서
|
||||
> 확인하지 않았다.** IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고
|
||||
> app1 을 새로고침하면 `Session not active` 가 나와야 한다. 재려면 그렇게 한다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `logout-all` 이 오류 없이 아무 일도 안 한다 | **캐시.** DB 를 지워도 노드 캐시가 답한다 | DB 직접 삭제 + `rollout restart` — 1-3 |
|
||||
| `kcadm delete sessions/<id>` 가 조용히 안 먹는다 | 같은 유형 | `users/<id>/logout` 을 쓴다 — 4-2 |
|
||||
| 기준선 세션이 0 이 아니라 4 다 | **원래 실행도 4 였다.** 해설의 `0` 은 정정됐다 | 1-2 의 정정 박스 |
|
||||
| 로그아웃했는데 세션이 1 남았다 | **`master` 의 admin 세션이다.** 당신이 `kcadm` 을 쳐서 생겼다 | realm 을 조인한다 — 1-4·4-3 |
|
||||
| `kcadm` 이 전부 `401` | **재시작으로 kcadm 세션이 날아갔다** | `config credentials` 를 다시 — 1-2 |
|
||||
| `$USERID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$USERID"` 로 먼저 확인 — 4-2 |
|
||||
| app2 에서 로그인 화면이 뜬다 | **다른 브라우저·시크릿 창이다.** SSO 쿠키가 없다 | 같은 창의 새 탭에서 연다 — 2-3 |
|
||||
| app2 가 Grafana 로 간다 | B-7 의 Ingress 가 없다 | B-7 1-1~1-2 |
|
||||
| `client_id` 가 UUID 뿐이라 어느 앱인지 모른다 | `client` 테이블을 조인해야 이름이 나온다 | 3-2 |
|
||||
| Redis 를 비웠더니 app1 도 끊겼다 | **`flushall` 은 BFF 세션도 지운다** | 기준선에서만 쓴다 — 1-3 |
|
||||
| 스크린샷 두 장이 똑같다 | **실제로 같은 파일이다.** 조작이 아니다 | 구별은 터미널 출력이 한다 — 4-5 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 치거나 임시 curl 파드 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | C-1 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [C-2](c2-backchannel-logout.md) 백채널 로그아웃 | **이 실험이 C-2 가 왜 필요한지 보여준다** — IdP 로그아웃이 앱에 안 퍼진다. **원인은 아무도 구현하지 않았기 때문이다** |
|
||||
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | `user_session` 을 잃으면 **전 앱이 끊긴다.** 백업 범위에 들어간다 |
|
||||
| [A-3](a3-database-crash.md) DB 크래시 | 여기서 본 두 층 구조가 거기서 「전체 소실 vs 일부 소실」로 갈렸다 |
|
||||
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |
|
||||
@@ -0,0 +1,734 @@
|
||||
# C-2 재현 가이드 — 로그아웃이 왜 안 퍼지는지 양쪽에서 확인한다
|
||||
|
||||
해설 문서: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md) ·
|
||||
증거 원문: [`docs/evidence/c2-backchannel-logout/`](../../evidence/c2-backchannel-logout/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 두 클라이언트 어디에도 `backchannel.logout.url` 이 없는 것 | `kcadm get clients` |
|
||||
| BFF 소스에 `oidcLogout` 이 한 줄도 없는 것 | `grep -rn` |
|
||||
| 후보 경로 셋이 전부 `302` 인 것 — **핸들러가 없다는 뜻** | `curl` |
|
||||
| **IdP 쪽만 설정해도 앱 세션이 그대로 남는 것** | Redis |
|
||||
| Keycloak 이 앱 공개 URL 에 `200` 으로 **닿는** 것 | 임시 curl 파드 |
|
||||
| 로그에 `backchannel` 이 **0줄**인 것, 그리고 그것으로 단정하면 안 되는 이유 | Keycloak 로그 |
|
||||
| **끊을 세션이 없는 상태로 시험해 무의미해지는 것** | 로그아웃 전 세션 수 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`C-1`](c1-multi-app-sso.md) 이 끝나 있다. app1(BFF)·app2(oauth2-proxy)가
|
||||
둘 다 살아 있고, **IdP 로그아웃이 앱에 전파되지 않는다**를 이미 관측했다.
|
||||
이 실험은 **그 원인을 찾는** 실험이다.
|
||||
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다. 끝나면 되돌린다 —
|
||||
[5-3](#5-3-빌린-것을-돌려준다).
|
||||
- **BFF 소스 트리**(`bff/src/main/java/`)를 볼 수 있어야 한다. 1-2 가 그것을 읽는다.
|
||||
- **브라우저가 필요하다.** 살아 있는 세션을 만들어야 시험이 성립한다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). `kcadm.sh` 는
|
||||
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.** 도달성 시험은
|
||||
**임시 curl 파드**로 한다.
|
||||
- 이 실험대에는 **`jq` 가 없다.**
|
||||
|
||||
## 주의 — 이건 클라이언트 설정을 바꾸는 실험이다
|
||||
|
||||
`bff-confidential` 클라이언트의 **`attributes` 를 통째로 교체한다.**
|
||||
JSON 으로 주는 방식이라 **기존 속성이 같이 날아갈 수 있다.**
|
||||
그래서 [2-1](#2-1-지금-attributes-를-먼저-저장해-둔다) 의 첫 명령이 백업이다.
|
||||
|
||||
세션도 지운다. 실험대에서만 한다. 전 구간 약 20분.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:50–14:53 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||
|
||||
> **★ 출처 하나에 주의가 붙어 있다.** 해설 문서 2절이 인쇄한 「설정이 들어갔다」
|
||||
> 확인 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
> 에서 나온 것이 **아니다.** 그 파일에는 **`command terminated with exit code 1`**
|
||||
> 이 남아 있다 — 점 표기로 시도한 **실패한 첫 시도**다. 성공 출력은 그 뒤 별도로
|
||||
> 실행한 조회에서 나왔다. **실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은
|
||||
> 잘못이었고**, 이 가이드는 3-1 에서 그 둘을 갈라 적는다.
|
||||
|
||||
클라이언트 UUID·IP 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
[C-1](c1-multi-app-sso.md) 이 이것을 관측했다.
|
||||
|
||||
```
|
||||
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
|
||||
```
|
||||
|
||||
**왜 안 퍼졌는지는 안 물었다.** 후보가 셋 있다.
|
||||
|
||||
| 후보 | 판정하는 법 |
|
||||
|---|---|
|
||||
| ① IdP 에 **보낼 주소**가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
|
||||
| ② 앱에 **받을 엔드포인트**가 없다 | 소스와 실제 경로를 본다 |
|
||||
| ③ IdP 가 앱에 **못 닿는다** (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 예상 | 셋 중 하나가 원인일 것 |
|
||||
| **실측** | **①과 ②가 둘 다 없었다.** ③은 문제가 아니었다(`HTTP 200`) |
|
||||
|
||||
**C-1 이 관측한 「로그아웃이 안 퍼진다」의 원인은 단순했다 — 아무도
|
||||
구현하지 않았다.**
|
||||
|
||||
그리고 이 실험이 실제로 증명하는 것은 그 다음이다.
|
||||
|
||||
```
|
||||
①만 고친다 → 여전히 안 퍼진다
|
||||
```
|
||||
|
||||
**양쪽이 다 있어야 동작한다.** 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는
|
||||
것이 이 주제에서 가장 흔한 실패다. 이 가이드는 **그 실패를 일부러 재현한다.**
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 어느 쪽에도 없다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
|
||||
```
|
||||
|
||||
## 1-1. IdP 쪽 — 클라이언트 속성을 본다
|
||||
|
||||
**kcadm 을 먼저 로그인시킨다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
**확인** — 두 클라이언트를 각각 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
|
||||
```
|
||||
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||
```
|
||||
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
|
||||
--- bff-confidential ---
|
||||
"frontchannelLogout" : false,
|
||||
--- oauth2-proxy ---
|
||||
"frontchannelLogout" : false,
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **있는 것이 아니라 없는 것을 본다.**
|
||||
`backchannel.logout.url` 이 목록에 **없다.** 나온 것은 `frontchannelLogout` 뿐이다.
|
||||
|
||||
> **「없다」를 확인하는 법.** `grep backchannel` 로 걸러서 빈 출력을 보면
|
||||
> 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다 —
|
||||
> B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다.
|
||||
> **`--fields attributes` 로 통째로 받아 눈으로 훑는다.**
|
||||
> 다른 값(`frontchannelLogout`)이 보이는 것이 「명령은 먹었다」의 증거다.
|
||||
|
||||
## 1-2. 앱 쪽 — 소스에 받을 자리가 있는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
|
||||
```
|
||||
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||
```
|
||||
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
|
||||
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **아무것도 안 나온다.** 헤더 아래가 비어 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — Spring Security 6.2+ 는 백채널 로그아웃을
|
||||
**지원하지만 명시적으로 켜야 한다.**
|
||||
|
||||
```java
|
||||
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
|
||||
```
|
||||
|
||||
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
|
||||
**생기지 않는다.** 소스에 없으니 경로도 없다.
|
||||
|
||||
> `grep` 이 빈 출력을 줄 때는 **경로가 맞는지 먼저 의심한다.**
|
||||
> `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를
|
||||
> 뒤져도 `grep` 은 조용히 0건을 준다.
|
||||
|
||||
## 1-3. 소스 말고 **실제로** 그 경로가 있는지 친다
|
||||
|
||||
**소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이다.** 직접 친다.
|
||||
|
||||
**확인** — 먼저 응답을 통째로 한 번 본다
|
||||
```bash
|
||||
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태줄과 `Location` 헤더. **302 라면 어디로 보내는가.**
|
||||
로그인 페이지로 보내면 「인증이 필요한 요청으로 처리됐다」는 뜻이고,
|
||||
**그런 핸들러가 없어서 기본 규칙에 걸린 것**이다.
|
||||
|
||||
이제 후보 셋을 나란히 잰다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
|
||||
curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
|
||||
done
|
||||
```
|
||||
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||
```
|
||||
=== 실제로 그 경로가 있는가 ===
|
||||
/logout/connect/back-channel/keycloak HTTP 302
|
||||
/backchannel-logout HTTP 302
|
||||
/oauth2/sign_out HTTP 302
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **셋 다 302.**
|
||||
|
||||
| 응답 | 뜻 |
|
||||
|---|---|
|
||||
| `302` | **그런 핸들러가 없어서 인증 요구로 떨어졌다** |
|
||||
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
|
||||
| `404` | 라우팅 자체가 없다 |
|
||||
|
||||
**이 결과가 의미하는 것** — 302 는 **「없다」의 증거**다. 엔드포인트가 있었다면
|
||||
POST 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다.
|
||||
|
||||
**후보 ②가 확정됐다.** ①은 1-1 에서 확정됐다.
|
||||
|
||||
## 1-4. ★ 끊을 세션이 있기는 한가 — 이걸 안 보면 실험이 무의미해진다
|
||||
|
||||
**원래 실행이 여기서 한 번 헛돌았다.**
|
||||
|
||||
**확인** — C-1 에서 배운 대로 **realm 을 조인해서** 센다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== 로그아웃 전 상태 ===
|
||||
Redis: 2 키
|
||||
keycloak-patterns 세션: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **IdP 세션이 0 이다.** Redis 에는 키가 2개 있는데
|
||||
Keycloak 쪽은 비어 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — **이 상태에서 로그아웃을 걸면 아무 일도 안 난다.**
|
||||
끊을 대상이 없기 때문이다. 그리고 「앱 세션이 그대로다」를 보고
|
||||
**「전파가 안 되는구나」로 결론지을 뻔했다.**
|
||||
|
||||
> **★ 이것이 이 실험에서 가장 빠지기 쉬운 함정이다.** 주입은 정상적으로
|
||||
> 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. **틀린 것은 전제뿐이다.**
|
||||
> A층 내내 반복한 교훈 — **주입 대상이 실제로 존재하는지 먼저 확인한다.**
|
||||
|
||||
**하기** — 그러니 세션을 만든다. 브라우저에서
|
||||
```
|
||||
https://app1.hyeonworks.com/ → labuser / labpass
|
||||
```
|
||||
|
||||
**확인** — 다시 센다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt) 의 두 번째 시험
|
||||
```
|
||||
=== 로그아웃 전 — 실제 세션이 있는가 ===
|
||||
keycloak-patterns 세션: 1
|
||||
Redis: 1 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **세션 수가 1 이상.** 여기서 0 이면 로그인이 안 된 것이다.
|
||||
**0 인 채로 2절로 넘어가지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — IdP 쪽에만 설정한다
|
||||
|
||||
**의도적으로 한쪽만 고친다.** 「①만 있으면 되는가」가 이 실험의 질문이다.
|
||||
|
||||
## 2-1. 지금 attributes 를 먼저 저장해 둔다
|
||||
|
||||
**되돌리기가 이 백업에 달렸다.** JSON 으로 통째로 넣는 방식이라 기존 속성이
|
||||
덮인다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
CID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
|
||||
--format csv --noquotes | tail -1)
|
||||
echo "$CID"
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get "clients/$CID" -r keycloak-patterns --fields attributes \
|
||||
| tee ~/c2-bff-attributes-backup.json
|
||||
```
|
||||
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
```
|
||||
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
|
||||
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `echo "$CID"` 가 **UUID 한 줄**인가.
|
||||
비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고,
|
||||
그 상태로 다음 명령을 치면 **엉뚱한 클라이언트를 고친다.**
|
||||
|
||||
> 이 UUID 는 [C-1 3-2](c1-multi-app-sso.md#3-2-어느-클라이언트가-붙었는가) 에서
|
||||
> `bff-confidential` 로 확인한 바로 그 값이다(`9055fa46-…`).
|
||||
> **당신 환경의 값은 다르다. 위 명령이 뽑아 준다.**
|
||||
|
||||
## 2-2. ★ 점 표기는 안 먹는다
|
||||
|
||||
**하기** — 원래 실행이 처음에 친 것. **실패한다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns \
|
||||
-s "attributes.backchannel.logout.url=https://app1.hyeonworks.com/logout/connect/back-channel/keycloak"
|
||||
```
|
||||
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
```
|
||||
command terminated with exit code 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **종료코드 1.** 이건 조용한 실패가 **아니다** —
|
||||
실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려
|
||||
**「왜」는 안 보인다.**
|
||||
|
||||
**왜 안 되나** — 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`)
|
||||
`kcadm` 의 점 표기와 충돌한다. **JSON 으로 통째로 준다.**
|
||||
|
||||
## 2-3. JSON 으로 넣는다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns \
|
||||
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
|
||||
"backchannel.logout.session.required":"true"}'
|
||||
```
|
||||
|
||||
**되돌리기** — 2-1 의 백업을 보고 원래 값으로 다시 `update` 한다.
|
||||
백업이 `{ }` 처럼 비어 있었다면
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 걸렸는지 확인한다
|
||||
|
||||
## 3-1. 설정이 실제로 들어갔는가
|
||||
|
||||
**확인** — 1-1 과 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||
```
|
||||
**실측** — 해설 문서 2절이 인쇄한 값. **위 「출처 주의」가 붙는 자리다**
|
||||
```
|
||||
backchannel.logout.session.required = true
|
||||
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 속성이 **둘 다** 있는가.
|
||||
`url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
|
||||
|
||||
> **이 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
> 에 없다.** 그 파일은 2-2 의 실패로 끝나고, 위 값은 **그 뒤 별도로 실행한
|
||||
> 조회**에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 자리이므로
|
||||
> **당신은 지금 직접 재 두는 편이 낫다.**
|
||||
|
||||
## 3-2. 이 시점의 앱 상태를 적어 둔다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
```
|
||||
=== 로그인 상태를 만든다 ===
|
||||
(브라우저에 이미 세션이 있다)
|
||||
Keycloak 세션: 2
|
||||
Redis: 2 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키 이름을 그대로 적어 둔다.** 4-3 에서 **글자 하나까지
|
||||
같은지**를 볼 것이다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰 — 로그아웃을 걸고 앱 세션을 본다
|
||||
|
||||
## 4-1. 시각을 적고 로그아웃한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 로그아웃'
|
||||
USERID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users -r keycloak-patterns -q username=labuser --fields id \
|
||||
--format csv --noquotes | tail -1)
|
||||
echo "$USERID"
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create "users/$USERID/logout" -r keycloak-patterns
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== ★ IdP 로그아웃 → 백채널 알림 ===
|
||||
시각: 14:54:21
|
||||
```
|
||||
|
||||
**시각이 필요한 이유** — 뒤에서 로그를 뒤질 때 **「이 순간 전후」로 좁히기
|
||||
위해서**다. `--since` 만으로는 어느 시도인지 안 갈린다.
|
||||
|
||||
## 4-2. IdP 쪽은 끊겼는가
|
||||
|
||||
**확인** — 1-4 와 같은 쿼리
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||
```
|
||||
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||
```
|
||||
=== IdP 세션은 실제로 끊겼는가 ===
|
||||
keycloak-patterns 세션: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **0.** 로그아웃 자체는 동작했다.
|
||||
|
||||
**이 결과가 의미하는 것** — **주입은 성공했다.** 이제 앱 쪽을 볼 자격이 생겼다.
|
||||
여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 남아 있어도 그건 당연한
|
||||
결과라 아무것도 판정하지 못한다.
|
||||
|
||||
## 4-3. ★ 앱 세션은 그대로다
|
||||
|
||||
**확인** — 3-2 와 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== 앱 세션이 정리되었는가 ===
|
||||
Redis: 2 키
|
||||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||
```
|
||||
그리고 두 번째 시험(세션이 실제로 1개 있던 판)에서도
|
||||
```
|
||||
=== 앱 세션 ===
|
||||
Redis: 1 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **개수도 이름도 그대로다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **IdP 쪽만 설정해도 소용없다.**
|
||||
①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과다.
|
||||
|
||||
## 4-4. 로그에 흔적이 있는가 — 그리고 그것으로 무엇을 말할 수 있는가
|
||||
|
||||
**확인** — Keycloak 양쪽 노드
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
|
||||
```
|
||||
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||
```
|
||||
=== Keycloak 로그 전체에서 backchannel 흔적 ===
|
||||
keycloak-0: 0 줄
|
||||
keycloak-1: 0 줄
|
||||
```
|
||||
|
||||
**확인** — 앱 쪽
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== BFF 로그 — 백채널 요청이 도착했는가 ===
|
||||
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 양쪽 다 비어 있다.
|
||||
|
||||
**★ 그런데 여기서 결론을 넓히면 안 된다.**
|
||||
|
||||
| 이 출력이 말하는 것 | 말하지 않는 것 |
|
||||
|---|---|
|
||||
| 로그에 `backchannel` 문자열이 없다 | **Keycloak 이 요청을 안 보냈다** |
|
||||
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
|
||||
|
||||
**로그 레벨이 DEBUG 였다면 안 찍혔을 수 있다.** 「0줄」은 「안 보냈다」의
|
||||
증거가 아니라 **「기본 로그 레벨에서는 안 보인다」**일 뿐이다.
|
||||
|
||||
> **확실한 것은 앱 세션이 남았다는 관측이다.** 그것은 직접 봤다(4-3).
|
||||
> **관측한 것과 추론한 것을 섞지 않는다.** 로그 0줄을 근거로 「Keycloak 이
|
||||
> 안 보냈다」고 쓰면, 나중에 DEBUG 를 켜서 보냈다는 게 밝혀졌을 때
|
||||
> 결론 전체의 신뢰가 무너진다.
|
||||
|
||||
## 4-5. 네트워크 문제인가 — 후보 ③을 판정한다
|
||||
|
||||
**앱 세션이 안 지워지는 이유가 「요청이 못 닿아서」일 수도 있다.**
|
||||
그러면 구현이 아니라 네트워크를 고쳐야 한다. **갈라야 한다.**
|
||||
|
||||
Keycloak 파드에는 `curl` 이 없으므로 **같은 네임스페이스에 임시 파드**를 띄운다.
|
||||
|
||||
**하기** — **미검증** (원래 실행의 명령 원문은 기록에 없다. 출력은 실측이다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 --command -- sh
|
||||
```
|
||||
|
||||
파드 안에서
|
||||
```sh
|
||||
nslookup app1.hyeonworks.com
|
||||
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||||
```
|
||||
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||
```
|
||||
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
|
||||
DNS 해석:
|
||||
Address: 100.83.212.4
|
||||
|
||||
Non-authoritative answer:
|
||||
|
||||
HTTPS 도달:
|
||||
HTTP 200 (0 이면 못 닿음)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
| 값 | 뜻 |
|
||||
|---|---|
|
||||
| `Address: 100.83.212.4` | 클러스터 안에서 **공개 이름이 풀린다** |
|
||||
| **`HTTP 200`** | **실제로 닿는다** |
|
||||
| `HTTP 000` | curl 이 연결조차 못 했다 = **네트워크가 원인** |
|
||||
|
||||
**이 결과가 의미하는 것** — **후보 ③은 원인이 아니다.**
|
||||
네트워크는 열려 있고, 그래도 세션은 남았다.
|
||||
|
||||
`exit` 으로 파드에서 나온다. `--rm` 이 지워 준다.
|
||||
|
||||
> **임시 파드는 Keycloak 파드의 완전한 대역이 아니다.** 같은 네임스페이스라
|
||||
> DNS 와 대체로 같은 경로를 타지만, **NetworkPolicy 나 사이드카가 걸려 있으면
|
||||
> 결과가 갈릴 수 있다.** 이 실험대에는 그런 것이 없어서 대역이 성립했다.
|
||||
> 확인: `sudo kubectl -n keycloak-lab get networkpolicy` 가 비어 있는가.
|
||||
|
||||
**그리고 이 200 은 이 실험대의 특수 사정이다.**
|
||||
|
||||
이 실험대는 **tailnet + split DNS** 구성이라 클러스터 안에서 공개 이름을
|
||||
불러도 되돌아온다(헤어핀). **운영에서는 안 되는 경우가 흔하다.**
|
||||
|
||||
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
|
||||
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
|
||||
> **설정을 해도 도달하지 못한다. 그때는 로그도 안 남고 조용히 실패한다.**
|
||||
|
||||
## 4-6. 그래서 왜 안 퍼졌는가
|
||||
|
||||
```
|
||||
IdP 로그아웃
|
||||
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (2절에서 설정함)
|
||||
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다 (1-2·1-3)
|
||||
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
|
||||
```
|
||||
|
||||
**②와 ③이 없다. ①만 설정해도 받을 사람이 없다.**
|
||||
|
||||
이것이 이 실험의 결론이고, **「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서
|
||||
확인한 것**이 이 가이드의 값이다.
|
||||
|
||||
## 4-7. 개념 — 백채널 로그아웃의 구조
|
||||
|
||||
```
|
||||
사용자가 어느 앱에서든 로그아웃
|
||||
│
|
||||
▼
|
||||
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
|
||||
│
|
||||
├──POST──▶ app1 의 backchannel.logout.url
|
||||
└──POST──▶ app2 의 backchannel.logout.url
|
||||
본문: logout_token (JWT)
|
||||
{ "sid": "...", "sub": "...", "events": {...} }
|
||||
```
|
||||
|
||||
### `sid` 가 여기서 쓰인다
|
||||
|
||||
**무엇인가.** `sid` 는 Keycloak 의 user session 식별자다.
|
||||
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
|
||||
|
||||
**왜 여기 나오나.** logout token 에 실려 오는 것이 `sid` 이고,
|
||||
**앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.**
|
||||
|
||||
```
|
||||
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
|
||||
```
|
||||
|
||||
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
|
||||
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다.
|
||||
|
||||
**없거나 틀리면.** 엔드포인트가 있어도 **어느 세션을 지울지 모른다.**
|
||||
그리고 **여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다** —
|
||||
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.** BFF 는 replica 2개다.
|
||||
|
||||
### 부분 실패는 어떻게 되는가
|
||||
|
||||
```
|
||||
app1 로그아웃 성공, app2 는 응답 없음
|
||||
└─ Keycloak 은 재시도하는가? 얼마나?
|
||||
└─ 사용자는 app2 에서 여전히 로그인 상태다
|
||||
```
|
||||
|
||||
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 「일부만 로그아웃된 상태」가
|
||||
생길 확률이 올라간다. **이 실험은 그 재시도 동작을 측정하지 않았다.**
|
||||
|
||||
## 4-8. 구현하려면 무엇이 필요한가
|
||||
|
||||
| 계층 | 할 일 | 이 실험대의 상태 |
|
||||
|---|---|---|
|
||||
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 | **완료** (2절) |
|
||||
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
|
||||
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿) | 없음 |
|
||||
| **네트워크** | IdP → 앱 공개 URL 도달 | **됨** (4-5). 운영은 확인 필요 |
|
||||
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 | — |
|
||||
|
||||
**마지막 줄이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
|
||||
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다 다르게
|
||||
동작하게 된다.** C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데,
|
||||
**그 공유가 로그아웃까지는 안 간다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 클라이언트 속성을 되돌린다
|
||||
|
||||
**하기** — 2-1 의 백업을 먼저 읽는다
|
||||
```bash
|
||||
cat ~/c2-bff-attributes-backup.json
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 원래 무엇이 있었는가. 비어 있었으면 빈 객체로,
|
||||
값이 있었으면 그 값으로 되돌린다.
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||||
```
|
||||
|
||||
**확인** — 1-1 과 같은 명령으로 사라졌는지 본다.
|
||||
|
||||
> **그대로 둬도 무방하다.** 받을 엔드포인트가 없으므로 이 설정 하나로는
|
||||
> 아무 일도 안 일어난다 — **그게 이 실험의 결론이었다.**
|
||||
> 다만 나중에 앱을 고쳤을 때 **왜 갑자기 동작하는지 모르게 되므로**,
|
||||
> 실험이 남긴 설정이라는 것을 기억하거나 지운다.
|
||||
|
||||
## 5-2. 세션을 정리한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
브라우저 쿠키(`auth`·`app1`·`app2`)도 지우거나 시크릿 창을 새로 연다.
|
||||
|
||||
## 5-3. 빌린 것을 돌려준다
|
||||
|
||||
**app2 는 Grafana 의 이름이다.** C 층이 끝났으면 여기서 되돌린다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||
백업 파일이 없으면 [B-7 1-1](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다)
|
||||
을 다시 읽는다 — **그때 떠 뒀어야 하는 파일이다.**
|
||||
|
||||
oauth2-proxy 배포까지 걷어내려면
|
||||
```bash
|
||||
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||
```
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 클라이언트 속성 | 1-1 의 `get clients … --fields attributes` | `backchannel.logout.url` 이 **없다** (지웠다면) |
|
||||
| Keycloak 세션 | 1-4 의 realm 조인 카운트 | `0` |
|
||||
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
|
||||
| 임시 파드 | `sudo kubectl -n keycloak-lab get pod c2probe` | `NotFound` (없어야 정상) |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` |
|
||||
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||
|
||||
임시 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
|
||||
```
|
||||
|
||||
> **이 실험이 재지 않은 것**
|
||||
> · ②·③을 실제로 **구현한 뒤** 전파가 되는지 — 코드를 고쳐야 한다
|
||||
> · Keycloak 이 요청을 보내기는 했는지 (DEBUG 로그를 켜지 않았다)
|
||||
> · 부분 실패 시 **재시도 정책** (4-7)
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| **로그아웃했는데 아무 변화가 없다** | **로그아웃 전 세션이 이미 0 이었다** | realm 조인해서 먼저 센다 — 1-4 |
|
||||
| `kcadm -s "attributes.backchannel.logout.url=…"` 이 `exit 1` | **점 표기가 안 먹는다** | JSON 으로 통째로 — 2-2·2-3 |
|
||||
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 **통째로 교체**한다 | 먼저 백업 — 2-1 |
|
||||
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 — 2-1 |
|
||||
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다 — 1-4 (C-1 과 같은 실수) |
|
||||
| `grep -rn … bff/src/main/java/` 가 빈 출력 | **정말 없거나**, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 — 1-2 |
|
||||
| 후보 경로가 `404` 가 아니라 `302` | **핸들러가 없어 인증 요구로 떨어진 것** | 302 도 「없다」의 신호다 — 1-3 |
|
||||
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | **DEBUG 레벨이면 안 찍힌다** | 판정 근거로 쓰지 않는다 — 4-4 |
|
||||
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | **운영에서는 그게 정상일 수 있다** — 4-5 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 — 4-5 |
|
||||
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 — 1-1 |
|
||||
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | **인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다** | 새 탭에서 주소부터 다시 연다 |
|
||||
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 5-3 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| | C-2 가 남긴 것 |
|
||||
|---|---|
|
||||
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` **공유 저장소** (BFF replica 2개) |
|
||||
| **oauth2-proxy** | 백채널 로그아웃 미지원 — **SSO 안에서 앱마다 동작이 갈린다** |
|
||||
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** — 로그도 안 남는다 |
|
||||
| [C-1](c1-multi-app-sso.md) 과 연결 | 두 앱이 user session 을 공유한다. **그 공유가 로그아웃까지는 안 간다** |
|
||||
| [B-2](../../experiment-b2-multi-instance-session.md) 와 연결 | 로그아웃이 지우는 것은 지금도 **세 곳 중 하나뿐**이다 |
|
||||
| 방법론 | **주입 대상이 실제로 존재하는지 먼저 확인한다.** 이 실험이 그걸로 한 번 헛돌았다 |
|
||||
@@ -0,0 +1,831 @@
|
||||
# D-1 재현 가이드 — 스키마를 통째로 지우고 백업이 진짜 백업인지 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-d1-backup-restore.md`](../../experiment-d1-backup-restore.md) ·
|
||||
증거 원문: [`docs/evidence/d1-backup-restore/`](../../evidence/d1-backup-restore/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 덤프 파일 안에 세션 행이 실제로 들어 있는 것 | `grep` |
|
||||
| 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것 | 밖에서 `curl` |
|
||||
| 파드가 `1/1 Running` 인 채로 테이블이 0개인 것 | `get pods` · `psql` |
|
||||
| `certs` 200 · `well-known` 500 · 토큰 400 으로 **부분만** 깨지는 것 | `curl` 세 번 |
|
||||
| 복구가 1초 만에 오류 0건으로 끝나는 것 | `psql < 덤프` |
|
||||
| 세션까지 되살아나는 것 | `offline_user_session` |
|
||||
| **덤프가 DB 와 같은 기계 위에 놓여 있는 것** | `ls -l` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`A-2`](a2-database-loss.md) 를 먼저 하면 좋다. 「DB 프로세스가 죽었을 때」의
|
||||
모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다.
|
||||
- [`A-3`](a3-database-crash.md) 도 먼저다. RPO 의 두 번째 겹이 거기서 나온다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||||
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||||
- 네임스페이스는 `keycloak-lab` 이다.
|
||||
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||
- **덤프를 다른 기계로 옮기는 단계(5-6)만 호스트(`test-server`)가 필요하고,
|
||||
호스트의 `sudo` 는 비밀번호를 묻는다.** 그 부분은 사람이 직접 친다.
|
||||
|
||||
## 주의 — 이건 데이터베이스를 비우는 실험이다
|
||||
|
||||
`DROP SCHEMA public CASCADE` 는 **realm·client·user·세션을 전부 지운다.**
|
||||
되돌리는 수단은 당신이 방금 뜬 덤프 파일 **하나뿐**이다. 그래서 이 가이드는
|
||||
**덤프를 검증하기 전에는 2절로 넘어가지 않는다.** 전 구간 약 20분이고,
|
||||
파괴 구간 자체는 1분 안쪽으로 잡는다. 중간에 그만두려면
|
||||
[5-1. 되돌린다](#5-1-되돌린다) 의 명령 하나면 된다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:57–15:00 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
파드 이름·세션 id·바이트 수는 **당신 환경에서 다르다.** 이 문서는
|
||||
자리표시자(`<...>`)를 쓰지 않는 대신 그 값을 뽑는 명령을 먼저 적는다.
|
||||
예시로 실린 값은 전부 위 실행 기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는
|
||||
것과, 그 파일로 실제로 서비스를 되살릴 수 있는 것 사이에는 시험되지 않은
|
||||
가정이 여러 개 있다.
|
||||
|
||||
이 실험은 그중 둘을 판정한다.
|
||||
|
||||
| # | 질문 | 어떻게 가르나 |
|
||||
|---|---|---|
|
||||
| ① | 덤프에 **필요한 것이 다 들어가는가** | 특히 **세션**. 안 들어가면 복구 후 전원 재로그인이다 |
|
||||
| ② | **복구 절차가 실제로 도는가** | 오류 없이 끝나고 데이터가 일치하는가 |
|
||||
|
||||
그리고 부수 질문이 하나 붙는다 — **DB 가 비면 무엇이 깨지는가.**
|
||||
이게 A-2 와 대비되는 지점이고, 실제로 이 실험에서 가장 놀라운 결과가 나왔다.
|
||||
|
||||
```
|
||||
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
|
||||
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
|
||||
```
|
||||
|
||||
**커넥션은 되는데 테이블이 없는 상태**는 단일 장애 주입으로는 잘 안 만들어진다.
|
||||
그래서 이 실험이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 지우기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 파괴 후에 볼 것을 파괴 전에 **똑같은
|
||||
명령으로** 먼저 봐 둔다. 복구가 「완전 일치」인지 판정하려면 일치시킬 상대가
|
||||
있어야 한다.
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
|
||||
```
|
||||
|
||||
## 1-1. 파드가 다 떠 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt) 의
|
||||
파괴 직후 목록이지만, 파괴 **전후가 같다**는 것이 이 실험의 결과이므로 기준선으로도 읽는다
|
||||
```
|
||||
bff-555df79c97-6j86w 1/1 Running 0 49m
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 49m
|
||||
keycloak-0 1/1 Running 0 4m15s
|
||||
keycloak-1 1/1 Running 0 4m38s
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
- `READY` 가 전부 `1/1`
|
||||
- **`RESTARTS` 가 `0`** — 뒤에서 이 값이 오르면 파괴가 엉뚱한 것을 건드린 것이다
|
||||
- `postgres` 파드가 있는지, 어느 노드에 있는지
|
||||
|
||||
**이 결과가 의미하는 것** — 지금은 전부 정상이다. 이 표의 값을 적어 둔다.
|
||||
**복구 판정에서 「재시작 없이 돌아왔는가」를 볼 때 `RESTARTS` 를 비교한다.**
|
||||
|
||||
## 1-2. 데이터가 얼마나 있나
|
||||
|
||||
**처음 한 번은 읽는 형태로 친다.** 값만 뽑는 형태부터 배우면 psql 이 무엇을
|
||||
돌려주는지 모르게 된다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from realm"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
count
|
||||
-------
|
||||
2
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나와 `(1 row)`. 여기서 오류가 나면 뒤의 모든
|
||||
단계가 무의미하다. `psql: error: connection to server ... failed` 면 DB 가 아직
|
||||
안 붙은 것이고, `relation "realm" does not exist` 면 **이미 스키마가 없는 것**이다.
|
||||
|
||||
이제 다섯 개를 한 줄로 모은다. **비교할 값이 필요할 때만** 이 형태를 쓴다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select (select count(*) from realm), (select count(*) from client),
|
||||
(select count(*) from user_entity),
|
||||
(select count(*) from offline_user_session where offline_flag='0')"
|
||||
```
|
||||
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
|
||||
```
|
||||
realms|clients|users|sessions|authclients = 2|15|2|3|1
|
||||
```
|
||||
|
||||
> **실측 줄에는 값이 다섯이고 위 명령은 넷을 뽑는다.** 원래 실행 스크립트는
|
||||
> 「인가된 클라이언트(authclients)」를 하나 더 셌는데, 해설 문서의 재현 절차에는
|
||||
> 그 쿼리가 남아 있지 않다. **없는 컬럼을 지어내지 않고 넷으로 둔다** — 판정에는
|
||||
> 넷으로 충분하고, 다섯째가 필요하면 당신이 세는 쿼리를 정해서 **양쪽에 같이**
|
||||
> 쓰면 된다.
|
||||
>
|
||||
> 손으로 치면 이름표 없이 `2|15|2|3` 만 나온다. `-tAc` 는 **헤더 없이(`-t`)
|
||||
> 정렬 없이(`-A`) 한 줄만**이라는 뜻이고, 여러 값을 나란히 비교할 때 이 형태가
|
||||
> 편하다.
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 넷. **이 줄을 그대로 복사해 둔다.**
|
||||
복구 후에 같은 명령을 쳐서 **문자 단위로 같은지** 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 값들이 「복구가 성공했다」의 판정 조건이다.
|
||||
하나라도 다르면 복구가 부분적으로만 된 것이다.
|
||||
|
||||
## 1-3. 세션이 DB 에 있나 — 이게 덤프에 들어갈지가 관건이다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select user_session_id, offline_flag, realm_id from offline_user_session"
|
||||
```
|
||||
**형태** — 행이 몇 개 있고 id 가 어떻게 생겼는지만 본다
|
||||
```
|
||||
user_session_id | offline_flag | realm_id
|
||||
--------------------------+--------------+--------------------------------------
|
||||
E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31
|
||||
...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **행이 0개면 안 된다.** 0개면 이 실험의 ①(세션이
|
||||
덤프에 들어가는가)을 판정할 수 없다. 관리 콘솔에 한 번 로그인해서 세션을
|
||||
만들고 다시 본다.
|
||||
|
||||
> **`offline_flag` 를 눈여겨본다.** 1-2 의 개수 쿼리는 `offline_flag='0'` 만
|
||||
> 셌고, 이 쿼리는 전부 나열한다. **세는 쿼리와 나열하는 쿼리가 다른 것을
|
||||
> 세고 있다** — 실제로 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다
|
||||
> ([`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)).
|
||||
> 두 숫자가 다르다고 놀라지 말고, **복구 전후에 같은 쿼리끼리** 비교한다.
|
||||
|
||||
**이 결과가 의미하는 것** — 세션이 DB 테이블에 있다는 것은
|
||||
`persistent-user-sessions` 가 켜져 있다는 뜻이다(A-0). **그래서 세션이 백업
|
||||
대상이 된다.** volatile 이었다면 세션은 애초에 DB 에 없고, 복구해도 전원
|
||||
재로그인이다 — 백업의 가치가 달라진다.
|
||||
|
||||
## 1-4. 밖에서 정상인가
|
||||
|
||||
**처음 한 번은 응답을 읽는다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -I https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다.
|
||||
같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
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/
|
||||
```
|
||||
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||
(이것도 **파괴 직후** 값이다 — 그게 결과다)
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 둘 다 `200`.
|
||||
|
||||
**이 결과가 의미하는 것** — 지금은 당연히 200 이다. **문제는 3절에서도 이
|
||||
값이 200 이라는 것**이고, 그래서 이 두 줄은 「정상 판정에 쓸 수 없는 지표」의
|
||||
예시로 남는다.
|
||||
|
||||
## 1-5. 백업을 뜬다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 백업 시작'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
|
||||
--clean --if-exists > /tmp/keycloak-backup.sql
|
||||
date '+%H:%M:%S 백업 완료'
|
||||
```
|
||||
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
|
||||
```
|
||||
시작: 14:59:30
|
||||
완료: 14:59:30
|
||||
크기: 394945 bytes (6956 줄)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 시각 두 줄과 파일 크기. 이 규모에서는 **1초 미만**이다.
|
||||
|
||||
### 개념 — `--clean --if-exists` 가 없으면 복구가 실패한다
|
||||
|
||||
| 옵션 | 무엇을 하나 | 없으면 |
|
||||
|---|---|---|
|
||||
| `--clean` | 복구 시 기존 객체를 **DROP 하고** 다시 만든다 | `already exists` 오류가 쏟아진다 |
|
||||
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 DB 에 복구할 때 오류가 쏟아진다 |
|
||||
|
||||
**둘은 짝이다.** `--clean` 만 주면 「빈 DB 에 복구」가 깨지고, `--if-exists`
|
||||
만 주면 아무 효과가 없다(DROP 문 자체가 안 만들어진다).
|
||||
|
||||
> **왜 이 실험에서는 어차피 빈 DB 인데 필요한가.** 이 실험은 `DROP SCHEMA` 로
|
||||
> 완전히 비우고 복구하지만, **실제 사고는 대개 그렇지 않다.** 반쯤 남은 DB 에
|
||||
> 덤프를 밀어 넣는 상황이 훨씬 흔하고, 그때 이 두 옵션이 있고 없고가 갈린다.
|
||||
|
||||
**되돌리기** — 이 단계는 읽기만 한다. 파일이 마음에 안 들면 지우고 다시 뜬다.
|
||||
```bash
|
||||
rm -f /tmp/keycloak-backup.sql
|
||||
```
|
||||
|
||||
## 1-6. ★ 덤프를 검증한다 — 여기를 건너뛰면 2절은 자살행위다
|
||||
|
||||
**「파일이 생겼다」는 「복구할 수 있다」가 아니다.** `pg_dump` 가 중간에
|
||||
실패해도 파일은 남고, 크기도 0 이 아니다.
|
||||
|
||||
**확인 ①** 파일이 실제로 있고 크기가 말이 되는가
|
||||
```bash
|
||||
ls -l /tmp/keycloak-backup.sql
|
||||
wc -l /tmp/keycloak-backup.sql
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
크기: 394945 bytes (6956 줄)
|
||||
```
|
||||
|
||||
**확인 ②** 테이블 정의가 다 들어갔는가
|
||||
```bash
|
||||
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
포함된 테이블 수: 101
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 101 이라는 **절대값이 중요한 게 아니라**, 1-2 에서
|
||||
본 DB 와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘린 것이다.
|
||||
|
||||
**확인 ③** 마지막 줄이 정상 종료인가
|
||||
```bash
|
||||
tail -3 /tmp/keycloak-backup.sql
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dump complete`. **이 줄이 없으면 덤프가 중간에
|
||||
끊긴 것이고, 그 파일로는 복구가 안 된다.** 이 한 줄이 「파일이 생겼다」와
|
||||
「덤프가 끝났다」를 가른다.
|
||||
|
||||
**확인 ④** ★ 세션이 들어 있는가 — 이 실험의 질문 ①
|
||||
```bash
|
||||
grep -c 'offline_user_session' /tmp/keycloak-backup.sql
|
||||
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110
|
||||
```
|
||||
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
|
||||
```
|
||||
offline_user_session 언급: 13
|
||||
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre
|
||||
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788500836 0 {"ipAddr
|
||||
2ap3DyRiBF8OdMiqCodsJ0mp 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501263 0 {"ipAddr
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `COPY` 줄 **다음에 실제 데이터 행이 붙어 있는가.**
|
||||
`COPY ... FROM stdin;` 바로 뒤에 `\.` 만 있으면 **테이블 정의만 들어가고 행은
|
||||
비어 있는 것**이다. 그건 세션을 백업하지 못한 덤프다.
|
||||
|
||||
> `cut -c1-110` 은 `data` 열의 JSON 이 화면을 뒤덮는 것을 막으려는 것이다.
|
||||
> 처음 한 번은 `cut` 없이 쳐서 한 행이 얼마나 긴지 봐 둔다.
|
||||
|
||||
**이 결과가 의미하는 것** — **세션이 덤프에 들어간다.** 질문 ①의 답은
|
||||
「들어간다」이며, 그 근거는 이 `COPY` 블록이다. 5-4 에서 이 id 들이 되살아나는
|
||||
것을 확인한다.
|
||||
|
||||
## 1-7. ★ 덤프가 지금 어디에 있는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ls -l /tmp/keycloak-backup.sql
|
||||
df -h /tmp
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 경로. `/tmp` 다. **이 파일은 지금 `kubectl` 을 친
|
||||
그 기계의 디스크에 있다.**
|
||||
|
||||
**이 결과가 의미하는 것** — A-4 에서 **`local-path` PVC 가 노드에 못박혀
|
||||
있는 것**을 봤다. 그 노드가 안 돌아오면 DB 볼륨도 안 돌아온다. 그때 유일한
|
||||
길이 덤프인데, **덤프도 같은 기계에 있으면 같이 사라진다.**
|
||||
|
||||
> **같은 장애 도메인에 있는 백업은 백업이 아니다.**
|
||||
> 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을
|
||||
> **「가장 중요한 미검증 항목」**으로 기록했다. 옮기는 절차는 5-6 에 있다 —
|
||||
> **파괴 전에 읽어만 두고, 실제 이동은 복구가 끝난 뒤에 한다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 스키마를 통째로 지운다
|
||||
|
||||
여기부터 데이터가 사라진다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기** (5절에서 자세히 한다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
< /tmp/keycloak-backup.sql
|
||||
```
|
||||
|
||||
**이 명령이 유일한 되돌리기 수단이다.** 1-6 의 확인 ①~④ 를 통과하지 않았으면
|
||||
지금 돌아가서 한다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 파괴'
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
```
|
||||
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||
```
|
||||
=== ★ 파괴 — 스키마를 통째로 지운다 ===
|
||||
시각: 14:59:47
|
||||
DROP SCHEMA
|
||||
CREATE SCHEMA
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `DROP SCHEMA` 와 `CREATE SCHEMA` 두 줄. `NOTICE:
|
||||
drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다.
|
||||
|
||||
**시각을 반드시 적어 둔다.** 5-5 의 RTO 는 이 시각에서 시작한다.
|
||||
|
||||
> **왜 `CREATE SCHEMA public` 을 붙이나.** `public` 스키마 자체를 지우면
|
||||
> 복구 스크립트가 들어갈 자리가 없다. 지우는 것은 **안의 객체**이고,
|
||||
> 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.**
|
||||
|
||||
## 3-1. 테이블이 0개인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from pg_tables where schemaname='public'"
|
||||
```
|
||||
**미검증** — 원래 실행은 스크립트로 셌다. 그 결과는 이렇다.
|
||||
**실측**
|
||||
```
|
||||
남은 테이블: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `0`. 여기서 101 이 그대로 나오면 `DROP` 이 다른
|
||||
데이터베이스에 걸린 것이다(`-d` 인자를 본다).
|
||||
|
||||
**확인** — 애플리케이션 테이블이 정말 없는지 직접 물어본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from realm"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
ERROR: relation "realm" does not exist
|
||||
LINE 1: select count(*) from realm
|
||||
^
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **커넥션은 성립하고 SQL 도 파싱된다. 테이블만
|
||||
없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가
|
||||
`connection to server ... failed` 였다.
|
||||
|
||||
## 3-2. ★ 그런데 밖은 멀쩡하다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo 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/
|
||||
```
|
||||
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
keycloak-0 1/1 Running 0 4m15s
|
||||
keycloak-1 1/1 Running 0 4m38s
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `1/1`, `RESTARTS 0`, 그리고 **`200`**.
|
||||
|
||||
**이 결과가 의미하는 것** — **데이터베이스가 통째로 비었는데 정문이 200 이다.**
|
||||
|
||||
여기서 「파괴가 실패했다」고 읽으면 틀린다. 3-1 에서 테이블이 0개인 것을
|
||||
이미 봤다. 파괴는 성공했고, **관측 지점이 그것을 못 보는 것**이다.
|
||||
|
||||
Keycloak 이 realm 정보를 **Infinispan `realms` 캐시**에서 서빙하기 때문이다
|
||||
(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다). 캐시는 읽을 때 DB 와
|
||||
대조하지 않는다 — A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과
|
||||
**같은 성질**이다.
|
||||
|
||||
## 3-3. 엉뚱한 것을 죽이지 않았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **ready 주소가 여전히 둘.**
|
||||
|
||||
**이 결과가 의미하는 것** — **아무 파드도 Service 에서 빠지지 않았다.**
|
||||
A-2 에서는 여기가 빈 목록이었다. readiness 프로브가 통과하고 있다는 뜻이고,
|
||||
그 이유는 4-3 에서 본다.
|
||||
|
||||
> `kubectl get endpoints` 는 v1.33+ 에서 deprecated 다. 실제로 이 실험대에서
|
||||
> 그 경고를 봤다 — 4절 「막히면」 표에 있다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 무엇이 깨지고 무엇이 안 깨지나
|
||||
|
||||
**전부 깨지지 않는다.** 세 경로를 나눠서 친다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'certs %{http_code}\n' \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
|
||||
```
|
||||
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||
```
|
||||
/.well-known/openid-configuration HTTP 500
|
||||
/protocol/openid-connect/certs HTTP 200
|
||||
토큰 발급 (DB 쓰기 필요) HTTP 400
|
||||
```
|
||||
|
||||
토큰 발급은 값이 필요하므로 따로 친다. **미검증** — 원래 실행은 스크립트였다
|
||||
```bash
|
||||
curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \
|
||||
https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \
|
||||
-d grant_type=password -d client_id=admin-cli -d username=admin \
|
||||
-d "password=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||
> 셸 히스토리에도 남지 않는다. 길이만 확인하려면:
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
|
||||
**어디를 봐야 하는가** — 세 값이 **다 다르다**는 것.
|
||||
|
||||
| 경로 | 코드 | 왜 |
|
||||
|---|---|---|
|
||||
| `certs` (JWKS) | **200** | realm 키가 캐시에 있다. DB 를 안 본다 |
|
||||
| `.well-known` | **500** | 이 응답을 만들려면 DB 를 본다 |
|
||||
| 토큰 발급 | **400** | 세션을 **써야** 한다 |
|
||||
|
||||
**이 결과가 의미하는 것** — **부분적으로만 깨진다.** 헬스체크는 통과하고,
|
||||
일부 엔드포인트는 정상이며, **로그인만 안 된다.**
|
||||
|
||||
운영에서 이 모양이 왜 고약한가 — 「사이트가 떴는가」를 재는 감시(정문 200,
|
||||
JWKS 200)는 전부 초록이고, **사용자만 못 들어온다.** 이 실험의 감시 항목은
|
||||
`/realms/master` 가 아니라 **토큰 발급**이어야 한다.
|
||||
|
||||
## 4-2. 로그가 이유를 말한다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=50
|
||||
```
|
||||
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||
```
|
||||
2026-09-04 05:58:02,598 WARN [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut
|
||||
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`WARN` 이지 `ERROR` 가 아니다.** 그리고 내용은
|
||||
「클러스터 멤버를 못 가져온다」다 — `JGROUPS_PING` 테이블도 같이 지워졌기
|
||||
때문이다(A-1 에서 그 테이블을 봤다).
|
||||
|
||||
**이 결과가 의미하는 것** — 디스커버리가 깨졌는데도 **로그 레벨이 WARN 이라
|
||||
대시보드의 에러 카운터에 안 잡힐 수 있다.** 3-2 의 `200`, 4-1 의 부분 정상,
|
||||
여기의 `WARN` — **세 관측이 전부 「괜찮다」 쪽으로 기운다.**
|
||||
|
||||
## 4-3. 개념 — 「DB 가 살아 있다」와 「데이터가 있다」는 다르다
|
||||
|
||||
```
|
||||
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다
|
||||
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다
|
||||
```
|
||||
|
||||
**헬스체크는 커넥션만 본다.** 그래서 빈 데이터베이스를 통과시킨다.
|
||||
|
||||
이건 Keycloak 의 버그가 아니다. 「DB 에 붙을 수 있는가」는 프로브가 답할 수
|
||||
있는 질문이고, 「데이터가 온전한가」는 프로브가 답할 수 없는 질문이다.
|
||||
후자를 재려면 **업무 트랜잭션 하나를 실제로 돌리는 감시**(예: 토큰 발급)가
|
||||
따로 있어야 한다.
|
||||
|
||||
| 재는 것 | 이 사고에서 |
|
||||
|---|---|
|
||||
| 파드 `Ready` | 초록 |
|
||||
| 정문 `200` | 초록 |
|
||||
| JWKS `200` | 초록 |
|
||||
| **토큰 발급** | **400** ← 유일하게 정직한 지표 |
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 복구 시작'
|
||||
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
< /tmp/keycloak-backup.sql > /tmp/restore.log 2>&1
|
||||
date '+%H:%M:%S 복구 완료'
|
||||
```
|
||||
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||
```
|
||||
시작: 15:00:12
|
||||
완료: 15:00:13
|
||||
오류 줄: 0
|
||||
```
|
||||
|
||||
### ★ `-i` 를 빠뜨리면 아무 일도 안 일어난다 — 그리고 오류도 안 난다
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘
|
||||
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
|
||||
```
|
||||
|
||||
`-i` 는 **표준입력을 파드 안으로 연결하라**는 뜻이다. 없으면 파드 안의 psql 은
|
||||
빈 입력을 받고 **정상 종료한다.** 셸은 오류를 내지 않고, 종료 코드도 0 이며,
|
||||
`date` 두 줄은 「1초 만에 끝났다」고 찍힌다. **복구된 것과 구별되지 않는다.**
|
||||
|
||||
구별하는 유일한 방법은 5-2 의 데이터 대조다. **그래서 대조는 선택이 아니다.**
|
||||
|
||||
**확인** — 오류 줄을 센다
|
||||
```bash
|
||||
grep -ci '^ERROR' /tmp/restore.log
|
||||
tail -5 /tmp/restore.log
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `0`. 0 이 아니면 어떤 줄이 실패했는지 본다.
|
||||
`--clean --if-exists` 로 뜬 덤프를 빈 DB 에 넣으면 오류가 0 인 것이 정상이다.
|
||||
|
||||
## 5-2. 데이터를 대조한다 — 여기가 진짜 판정이다
|
||||
|
||||
**확인** — 1-2 와 **똑같은 명령**을 친다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select (select count(*) from realm), (select count(*) from client),
|
||||
(select count(*) from user_entity),
|
||||
(select count(*) from offline_user_session where offline_flag='0')"
|
||||
```
|
||||
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||
```
|
||||
복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
|
||||
백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 줄이 문자 단위로 같은가.**
|
||||
|
||||
**이 결과가 의미하는 것** — 완전 일치. 질문 ②(복구 절차가 도는가)의 답이
|
||||
「돈다」인 근거가 이 두 줄이다. **여기가 다르면 그 앞의 모든 「성공」 표시는
|
||||
무의미하다** — 5-1 의 `-i` 를 빠뜨렸는지 먼저 의심한다.
|
||||
|
||||
## 5-3. 서비스가 재시작 없이 돌아오는가
|
||||
|
||||
**손대지 않고 기다린다.** 여기서 파드를 재시작하면 「자가 회복하는가」라는
|
||||
질문 자체가 사라진다.
|
||||
|
||||
**확인** — 15초쯤 뒤
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
|
||||
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||
```
|
||||
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||
```
|
||||
+15초 well-known=200 토큰발급=200
|
||||
→ 재시작 없이 회복
|
||||
|
||||
keycloak-0 restarts=0
|
||||
keycloak-1 restarts=0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 500 이던 `well-known` 이 `200` 이 된 것, 그리고
|
||||
**`RESTARTS` 가 여전히 0** 인 것.
|
||||
|
||||
**이 결과가 의미하는 것** — **커넥션 풀이 이미 붙어 있었으므로 테이블이
|
||||
돌아오자마자 동작했다.** A-2 에서 본 것과 같은 자가 회복이다. 파드를 만질
|
||||
필요가 없다 — 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가
|
||||
문서에 남았을 것이다.
|
||||
|
||||
## 5-4. 세션이 살아났나
|
||||
|
||||
**확인** — 1-3 과 같은 쿼리
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select user_session_id, offline_flag from offline_user_session"
|
||||
```
|
||||
**실측** — 원래 실행은 realm 이름을 함께 뽑았다
|
||||
```
|
||||
user_session_id | realm
|
||||
--------------------------+-------------------
|
||||
E1q5xI7tt4U_WhZpW7rEPIF2 | master
|
||||
2ap3DyRiBF8OdMiqCodsJ0mp | master
|
||||
Zsk4QcgXf_qgyMKzde5AG-Fz | master
|
||||
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
|
||||
(4 rows)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **1-6 확인 ④ 의 덤프 안에서 봤던 id 가 그대로
|
||||
있는가.** `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도, 복구된
|
||||
테이블에도 있다 — **파일에서 DB 로 실제로 넘어온 것을 눈으로 잇는다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 세션이 백업에서 복원된다. 로그인 상태가 유지된다.
|
||||
|
||||
## 5-5. RTO 와 RPO 를 계산한다
|
||||
|
||||
**확인** — 적어 둔 시각 셋을 나란히 놓는다
|
||||
```
|
||||
14:59:47 파괴
|
||||
15:00:12 복구 시작
|
||||
15:00:13 복구 완료
|
||||
~15:00:28 서비스 정상 확인
|
||||
|
||||
RTO = 41초
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 41초 중 **복구 명령 자체는 1초**다. 나머지는
|
||||
「파괴를 알아채고 무엇을 할지 정하는 시간」이며, 이 실험에서는 이미 알고
|
||||
있었으므로 25초였다. **실제 사고에서는 이 부분이 대부분을 차지한다.**
|
||||
|
||||
### RPO 는 두 겹이다
|
||||
|
||||
```
|
||||
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
|
||||
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
|
||||
|
||||
실제 RPO = ① + ②
|
||||
```
|
||||
|
||||
A-3 은 **클라이언트가 200 을 받은 로그인 153건 중 4건이 DB 에 없었다**는 것을
|
||||
측정했다. **백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.**
|
||||
|
||||
### 이 실험대의 규모는 현실적이지 않다
|
||||
|
||||
| | 이 실험대 | 운영 |
|
||||
|---|---|---|
|
||||
| 덤프 크기 | 395KB | GB~TB |
|
||||
| 복구 시간 | 1초 | 분~시간 |
|
||||
| 세션 수 | 3~4 | 수만 |
|
||||
|
||||
**복구가 1초인 것은 데이터가 작기 때문**이고, 이 실험이 확인한 것은
|
||||
**절차가 맞다는 것**뿐이다. 시간은 규모에 따라 완전히 달라진다.
|
||||
|
||||
## 5-6. ★ 덤프를 다른 장애 도메인으로 옮긴다 — 사람이 쳐야 하는 부분
|
||||
|
||||
**여기가 이 실험이 「못 했다」로 남긴 항목이다.** 덤프는 아직 DB 와 같은
|
||||
기계에 있다.
|
||||
|
||||
### 무엇을 사람이 쳐야 하나
|
||||
|
||||
| 하는 일 | 어디서 | sudo |
|
||||
|---|---|---|
|
||||
| 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 **무암호** — 스크립트로도 된다 |
|
||||
| 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 |
|
||||
| **덤프를 root 소유 경로(`/var/backups` 등)에 두기** | `test-server` | **비밀번호를 묻는다 — 사람이 친다** |
|
||||
|
||||
**호스트의 `sudo` 는 비대화 실행이 반드시 실패한다.** 실제로 그 벽에 부딪힌
|
||||
기록이 있다.
|
||||
|
||||
**실측** — [`d4-certificate-renewal/01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
|
||||
```
|
||||
$ sudo -n -l
|
||||
sudo: a password is required
|
||||
```
|
||||
|
||||
`-n` 은 「비밀번호를 물어보지 말라」는 뜻이고, 호스트에서는 그게 곧 실패다.
|
||||
**그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다.**
|
||||
`ssh -t` 로 붙어 사람이 비밀번호를 쳐야 한다(`-t` 가 없으면 sudo 가
|
||||
비밀번호를 읽을 tty 가 없다).
|
||||
|
||||
**하기** — **미검증**. 이 실험대는 여기까지 하지 않았다. 호스트 이름과 경로는
|
||||
당신 배치에 맞춘다
|
||||
```bash
|
||||
# ① kc-lab-1 에서 호스트로 — sudo 없이 사용자 홈에
|
||||
scp /tmp/keycloak-backup.sql test-server:~/keycloak-backup-2026-09-04.sql
|
||||
|
||||
# ② 보호된 경로로 옮기는 것은 호스트에서 사람이 친다 (비밀번호 프롬프트)
|
||||
ssh -t test-server 'sudo install -m600 -o root -g root \
|
||||
~/keycloak-backup-2026-09-04.sql /var/backups/keycloak-backup-2026-09-04.sql'
|
||||
```
|
||||
|
||||
**확인** — 옮긴 파일이 온전한가. **크기를 양쪽에서 세서 비교한다**
|
||||
```bash
|
||||
wc -c /tmp/keycloak-backup.sql
|
||||
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 숫자가 같은가. 다르면 전송이 잘린 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이것으로도 **부족하다.** 호스트는 VM 두 대를
|
||||
품고 있는 기계이므로, 호스트가 죽으면 게스트도 덤프도 같이 간다.
|
||||
**진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다.**
|
||||
|
||||
> **이 실험이 확인하지 않은 것** — 백업 자동화, 보존 주기, 복구 리허설의
|
||||
> 정기 실행. 이번엔 손으로 한 번 떴고, 한 번 되돌렸다. 그것만 참이다.
|
||||
|
||||
## 5-7. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 |
|
||||
| 데이터 | 5-2 의 `-tAc` 한 줄 | 백업 시점과 **문자 단위로 동일** |
|
||||
| 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `1/1 Running`, `RESTARTS 0` |
|
||||
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
| **로그인** | 4-1 의 토큰 발급 | **`200`** ← 이것이 진짜 판정 |
|
||||
| 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. 다음 실험(D-2)의 전제다 |
|
||||
|
||||
**덤프는 지우지 않는다.** [D-2](d2-version-upgrade.md) 가 이 파일을 전제로 한다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이거나, 이 절차에서 실제로 갈리는
|
||||
지점이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 복구가 1초 만에 끝났는데 데이터가 없다 | **`exec` 에 `-i` 가 없다.** 오류도 안 난다 | 5-2 의 대조. `-i` 를 붙여 다시 |
|
||||
| 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것이다 |
|
||||
| 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3` 에 `dump complete` 가 있는가 — 1-6 확인 ③ |
|
||||
| 파괴했는데 정문이 계속 `200` | **정상이다.** realm 캐시가 서빙한다 | 토큰 발급으로 판정 — 4-1 |
|
||||
| `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 3-1 의 기대 출력이다 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 밖에서 `curl` 로 친다 |
|
||||
| 세션 개수가 나열한 행 수와 다르다 | 개수 쿼리에 `offline_flag='0'` 필터가 있다 | **같은 쿼리끼리** 비교 — 1-3 |
|
||||
| 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 |
|
||||
| 호스트에서 `sudo` 가 안 먹는다 | **호스트 sudo 는 비밀번호를 요구한다** | `ssh -t` 로 붙어 사람이 친다 — 5-6 |
|
||||
|
||||
---
|
||||
|
||||
# 이 가이드에 스크립트가 없는 이유
|
||||
|
||||
원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸다. 그래서 증거 파일의
|
||||
줄이 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가
|
||||
붙은 형태다.
|
||||
|
||||
**그 형태는 사람이 치는 형태가 아니다.** 그리고 이 실험에서는 스크립트가
|
||||
특히 위험하다 — **`DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때
|
||||
무엇이 실행됐는지 알 수 없다.** 파괴는 손으로 치고, 그 직후에 눈으로 확인하고,
|
||||
복구도 손으로 친다. 각 단계 사이에 사람이 서 있어야 한다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | D-1 이 남긴 것 |
|
||||
|---|---|
|
||||
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | **백업이 전제다.** 스키마가 바뀐 뒤에는 태그를 되돌려도 안 뜬다 |
|
||||
| [D-3](d3-secret-management.md) 비밀 관리 | **덤프 안에 무엇이 들어 있는지** 생각한다. 백업을 잘 챙길수록 비밀도 잘 복사된다 |
|
||||
| [A-4](a4-node-loss.md) 노드 상실 | PVC 가 노드에 못박혀 있다. **덤프가 같은 노드에 있으면 둘 다 잃는다** |
|
||||
| 관측 | **「DB 가 살아 있다」만 보는 헬스체크는 빈 DB 를 통과시킨다.** 업무 트랜잭션을 재는 감시가 따로 필요하다 |
|
||||
| 전부 | **복구해 보지 않은 백업은 백업이 아니다.** 그리고 복구 판정은 `200` 이 아니라 데이터 대조로 한다 |
|
||||
@@ -0,0 +1,738 @@
|
||||
# D-2 재현 가이드 — 태그를 올리고 내려 보고 롤백이 되는 조건을 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-d2-version-upgrade.md`](../../experiment-d2-version-upgrade.md) ·
|
||||
증거 원문: [`docs/evidence/d2-version-upgrade/`](../../evidence/d2-version-upgrade/) ·
|
||||
[`docs/evidence/followup/`](../../evidence/followup/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 업그레이드 전후로 `databasechangelog` 행 수가 그대로인 것 | `psql -tAc` |
|
||||
| 파드가 하나씩 갈리는 동안 정문이 계속 `200` 인 것 | 1초 폴링 |
|
||||
| 같은 스키마에서는 **롤백이 되는 것** | 태그를 되돌리고 다시 폴링 |
|
||||
| 전환 순간의 `000` 이 서버 오류가 **아닌** 것 | `--max-time` |
|
||||
| 스키마가 바뀐 방향에서 `ValidationFailedException` 으로 기동이 거부되는 것 | `logs keycloak-1` |
|
||||
| 그때도 서비스가 살아 있는 것 — StatefulSet 이 절반에서 멈춘다 | `endpointslice` |
|
||||
| 실패한 기동이 스키마를 **안 건드린** 것 | 다시 `databasechangelog` |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`D-1`](d1-backup-restore.md) 이 끝나 있고 **덤프가 손에 있다.** 이 실험의
|
||||
되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
|
||||
- [`A-8`](../../experiment-a8-rolling-restart.md) — 롤링 재시작이 무중단이라는
|
||||
것이 전제다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- 네임스페이스는 `keycloak-lab` 이다.
|
||||
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||
- 터미널 **두 개**를 열어 두면 편하다. 하나는 가용성 폴링용, 하나는 관찰용.
|
||||
|
||||
## 주의 — 이건 실제로 버전을 바꾸는 실험이다
|
||||
|
||||
이미지 태그를 세 번 바꾼다(정방향 → 롤백 → 그리고 선택적으로 **실패하는**
|
||||
방향). 마지막 것은 파드를 `CrashLoopBackOff` 로 만든다. **되돌리는 명령은
|
||||
각 절 첫머리에 있고, 전부 태그 한 줄이다.** 전 구간 약 20분이며, 중간에
|
||||
그만두려면 [6. 복구](#6-복구) 의 첫 명령 하나면 된다.
|
||||
|
||||
**그리고 이 실험은 백업 없이 시작하지 않는다.** 스키마가 움직이는 방향으로
|
||||
가면 태그로는 못 돌아온다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 15:00–15:26 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||
|
||||
**실측이 두 실행에서 나온다.** 처음 D-2 실행(15:00–15:10, 역방향 26.0)과
|
||||
후속 실행(15:22–15:26, 26.7.3 정방향과 롤백)이다. 어느 쪽인지 매번 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다.
|
||||
**그 계획이 언제 동작하고 언제 동작하지 않는가**를 가른다.
|
||||
|
||||
Keycloak 은 **Liquibase** 로 스키마를 관리한다. 적용한 변경 하나하나가
|
||||
`databasechangelog` 테이블에 행으로 쌓이고, 각 행에는 그 변경 정의의
|
||||
**체크섬(`md5sum`)**이 들어 있다.
|
||||
|
||||
```
|
||||
컨테이너가 뜬다
|
||||
└─▶ Liquibase 가 databasechangelog 를 읽는다
|
||||
└─▶ 자기가 아는 changeset 의 체크섬과 대조한다
|
||||
├─ 같다 → 기동
|
||||
└─ 다르다 → ValidationFailedException. 기동 거부
|
||||
```
|
||||
|
||||
**「모르는 변경이 있다」가 아니라 「아는 변경인데 정의가 다르다」이며, 더
|
||||
엄격한 실패다.** 그래서 판정 기준은 이렇게 된다.
|
||||
|
||||
| 이렇게 묻지 말고 | 이렇게 묻는다 |
|
||||
|---|---|
|
||||
| 「26.7.3 에서 26.7.0 으로 내려도 되나?」 | **「`databasechangelog` 의 행 수가 바뀌었나?」** |
|
||||
|
||||
> **★ 이 가이드는 정정된 결론을 따른다.**
|
||||
> 해설 문서는 처음에 「롤백은 안 된다」고 단정했다가 후속 실험에서 정정했다.
|
||||
>
|
||||
> | 버전 차 | `databasechangelog` | 롤백 |
|
||||
> |---|---|---|
|
||||
> | 26.7.0 → **26.0** | 체크섬 불일치 | **불가** |
|
||||
> | 26.7.0 ↔ **26.7.3** | **210 → 210, 변화 없음** | **가능** |
|
||||
>
|
||||
> **판단 기준은 버전 번호가 아니라 행 수의 변화다.** 이 가이드는 그 숫자를
|
||||
> 재는 법부터 가르친다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 태그를 바꾸기 전에
|
||||
|
||||
**여기서 재 두지 않으면 나중에 다시 잴 수 없는 값이 하나 있다** —
|
||||
업그레이드 **전**의 `databasechangelog` 행 수다. 올린 뒤에는 그 값이 지워지고,
|
||||
「롤백해도 되는가」를 판정할 근거가 사라진다.
|
||||
|
||||
```
|
||||
백업 → 현재 태그 → ★ 마이그레이션 수 → 세션 → 클러스터 뷰 → 가용성 대조군
|
||||
```
|
||||
|
||||
## 1-1. 백업이 먼저다
|
||||
|
||||
**하기** — D-1 의 절차 그대로
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
|
||||
--clean --if-exists > /tmp/pre-upgrade.sql
|
||||
ls -l /tmp/pre-upgrade.sql
|
||||
tail -3 /tmp/pre-upgrade.sql
|
||||
```
|
||||
**실측** — [`01-pre-upgrade.txt`](../../evidence/d2-version-upgrade/01-pre-upgrade.txt)
|
||||
(첫 실행) · [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)(후속 실행)
|
||||
```
|
||||
백업: 396333 bytes
|
||||
백업: 395375 bytes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 크기, 그리고 `tail` 의 `dump complete`.
|
||||
**이 파일이 없으면 이 실험을 하지 않는다.** 5절에서 왜인지 나온다.
|
||||
|
||||
## 1-2. 지금 무엇이 돌고 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
|
||||
```
|
||||
**실측** — [`01-pre-upgrade.txt`](../../evidence/d2-version-upgrade/01-pre-upgrade.txt)
|
||||
```
|
||||
quay.io/keycloak/keycloak:26.7.0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 태그. **`latest` 로 되어 있으면 이 실험이 성립하지
|
||||
않는다** — 무엇에서 무엇으로 가는지 말할 수 없기 때문이다.
|
||||
|
||||
StatefulSet 에 적힌 것과 **파드가 실제로 돌리고 있는 것**은 다를 수 있다
|
||||
(적용 중이거나, 롤아웃이 멈춰 있으면).
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
|
||||
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 파드의 IMAGE 가 **서로 같고** StatefulSet 과도
|
||||
같은가, `READY` 가 둘 다 `true`, `RESTARTS` 가 `0`.
|
||||
|
||||
## 1-3. ★ 마이그레이션 수 — 이 숫자가 이 실험의 전부다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from databasechangelog"
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
count
|
||||
-------
|
||||
210
|
||||
(1 row)
|
||||
```
|
||||
|
||||
비교용으로 값만 뽑는 형태도 익혀 둔다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from databasechangelog"
|
||||
```
|
||||
**실측** — 두 실행 모두
|
||||
```
|
||||
총 마이그레이션 수: 210
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나. **이 값을 화면 밖에 적어 둔다.**
|
||||
|
||||
무엇이 마지막으로 적용됐는지도 한 번 본다. 나중에 「스키마가 언제 움직였나」를
|
||||
물을 때 여기를 본다.
|
||||
|
||||
**확인** — **미검증**(원래 실행은 개수만 셌다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select id, author, orderexecuted, dateexecuted from databasechangelog
|
||||
order by orderexecuted desc limit 5"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dateexecuted` 의 가장 최근 값. **그게 이 DB 의
|
||||
스키마가 마지막으로 움직인 시각이다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 210 은 「이 DB 는 여기까지 올라갔다」는 기록이다.
|
||||
업그레이드 후에 **211 이상이 되면 스키마가 움직인 것이고, 그 순간부터
|
||||
태그만으로는 못 돌아온다.**
|
||||
|
||||
## 1-4. 세션 — 업그레이드가 로그인 상태를 날리는지 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session"
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
현재 세션: 4 (첫 실행)
|
||||
세션 전: 3 (후속 실행)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자. 0 이면 관리 콘솔에 한 번 로그인해서 만든다.
|
||||
**0인 채로 업그레이드하면 「세션이 유지되는가」를 판정할 수 없다.**
|
||||
|
||||
## 1-5. 클러스터 뷰 — Infinispan 판까지 적어 둔다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
```
|
||||
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt) 의
|
||||
**업그레이드 후** 값
|
||||
```
|
||||
cluster: [keycloak-1-11418(v=16.0.14)|47] (2) [keycloak-1-11418(v=16.0.14), keycloak-0-58996(v=16.0.14)]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `(v=16.0.12)` 같은 괄호 안의 판, 그리고 멤버 수 `(2)`.
|
||||
|
||||
**이 결과가 의미하는 것** — Keycloak 태그를 바꾸면 **함께 실린 Infinispan 판도
|
||||
같이 바뀐다.** 후속 실행에서 `16.0.12 → 16.0.14` 로 올라갔다. 클러스터 프로토콜
|
||||
호환성 문제가 있다면 여기서 드러나므로, 업그레이드 후에 **이 줄이 멤버 2로
|
||||
다시 서는지** 보는 것이 판정 항목 하나다.
|
||||
|
||||
## 1-6. 새 태그가 실제로 있는지 확인한다
|
||||
|
||||
**확인** — 레지스트리에 무엇이 있나. **처음 한 번은 그대로 본다**
|
||||
```bash
|
||||
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
|
||||
```
|
||||
|
||||
한 줄짜리 JSON 이 통째로 나온다. 어떤 필드가 있는지 보고 나서 자른다.
|
||||
|
||||
**미검증** — `jq` 가 없으므로 이 실험대에서는 이렇게 읽는다
|
||||
```bash
|
||||
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true" \
|
||||
| tr ',' '\n' | grep '"name"'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `26.7.1` · `26.7.2` · `26.7.3` 이 있는가.
|
||||
**처음 D-2 를 할 때 이걸 안 해서 정방향을 시험하지 못했다** — 「26.7.0 보다
|
||||
새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.
|
||||
|
||||
## 1-7. 가용성 대조군 — 폴링을 먼저 띄운다
|
||||
|
||||
**주입 중에 나온 `000` 한 건을 해석하려면 평시 오류율을 알아야 한다.**
|
||||
|
||||
**하기** — 1초 간격으로 150회, 뒤에서 돌린다
|
||||
```bash
|
||||
( for i in $(seq 1 150); do
|
||||
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||
https://auth.hyeonworks.com/realms/master)"
|
||||
sleep 1
|
||||
done > /tmp/d2-avail.txt ) &
|
||||
```
|
||||
|
||||
**되돌리기** — 그만 재려면
|
||||
```bash
|
||||
kill %1
|
||||
```
|
||||
|
||||
**확인** — 30초쯤 두고 먼저 평시를 센다
|
||||
```bash
|
||||
tr ' ' '\n' < /tmp/d2-avail.txt | grep -c 200
|
||||
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `uniq -c` 의 **줄이 몇 개인가.** 한 줄이면 전부 같은
|
||||
코드였다는 뜻이다. 두 줄 이상이면 **평시에 이미 오류가 있는 것**이고, 그
|
||||
상태로 주입하면 주입 중의 오류를 귀속할 수 없다.
|
||||
|
||||
> **`--max-time 3` 을 기억해 둔다.** 4-4 에서 나오는 `000` 이 이 값 때문이다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 ① — 정방향 업그레이드 (26.7.0 → 26.7.3)
|
||||
|
||||
**되돌리기를 먼저 읽는다.**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||
```
|
||||
|
||||
**단, 이 되돌리기가 유효한 것은 `databasechangelog` 가 안 바뀌었을 때뿐이다.**
|
||||
바뀌었으면 되돌리기는 「덤프 복구 + 태그 되돌리기」다(5절).
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 태그 변경'
|
||||
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||
keycloak=quay.io/keycloak/keycloak:26.7.3
|
||||
```
|
||||
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||
```
|
||||
시작: 15:22:59
|
||||
statefulset.apps/keycloak image updated
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `image updated` 한 줄. **이건 「적용됐다」가 아니라
|
||||
「접수됐다」다.** 실제 교체는 지금부터 일어난다.
|
||||
|
||||
**하기** — 끝날 때까지 블록한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||
date '+%H:%M:%S 롤아웃 완료'
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
완료: 15:24:26
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `2 new pods have been updated`. 87초 걸렸다.
|
||||
|
||||
> **`rollout status` 가 안 끝나고 매달려 있으면 그게 신호다.** StatefulSet 은
|
||||
> 파드 하나가 Ready 가 되기 전에는 다음 파드를 안 건드린다. 즉 **매달림 =
|
||||
> 첫 파드가 안 뜬다.** 다른 터미널에서 `get pods -w` 로 본다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
## 3-1. 파드가 새 이미지로 돌고 있나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
|
||||
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
|
||||
```
|
||||
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||
```
|
||||
quay.io/keycloak/keycloak:26.7.3
|
||||
keycloak-0 1/1 Running restarts=0
|
||||
keycloak-1 1/1 Running restarts=0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`RESTARTS` 가 `0`.** 여기가 0 인 것이 중요하다.
|
||||
교체는 **새 파드를 만드는 것**이지 같은 파드를 재시작하는 것이 아니다.
|
||||
`RESTARTS` 가 올라가 있으면 새 파드가 기동에 실패해 재시작을 반복하는 것이다.
|
||||
|
||||
**확인** — 실제로 새 파드인지는 나이로 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||
```
|
||||
**실측** — 첫 실행의 롤포워드 직후
|
||||
```
|
||||
keycloak-0 1/1 Running 0 10m
|
||||
keycloak-1 1/1 Running 0 28s
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `AGE`. 하나씩 갈리므로 **나이가 다르다.** 둘 다 방금
|
||||
생긴 나이면 동시에 갈린 것이고, 그건 무중단이 아니다.
|
||||
|
||||
## 3-2. 버전이 정말 바뀌었나 — 파드가 자기 입으로 말하게 한다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -i 'Keycloak 26' | tail -1
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
Keycloak 26.7.3
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 로그가 말하는 판. 이미지 태그와 다르면 **태그가
|
||||
재사용된 것**이다(같은 태그가 다른 내용을 가리키는 경우).
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 끊겼나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
|
||||
```
|
||||
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||
```
|
||||
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
|
||||
...
|
||||
200 응답: 87 회
|
||||
비200 : 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 줄이 하나뿐이고 그 값이 `200` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — **정방향 업그레이드는 무중단이었다.** 87회 요청이
|
||||
전부 200 이다. 파드가 하나씩 갈리는 동안 남은 파드가 받았다.
|
||||
|
||||
> **「무중단」은 관측 해상도에 달려 있다.** 이건 1초 간격·3초 타임아웃으로
|
||||
> 잰 결과다. 더 촘촘히 보면 더 보일 수 있다 — 실제로 D-4 에서 0.2초 간격으로
|
||||
> 재니 다른 것이 보였다.
|
||||
|
||||
## 4-2. 그림으로도 남아 있다
|
||||
|
||||
Grafana 스크린샷이 증거에 있다 —
|
||||
[`d2-upgrade-window.png`](../../evidence/d2-version-upgrade/d2-upgrade-window.png).
|
||||
|
||||
**무엇이 보이나** — `cluster_size` 가 **2 → 1 → 2 를 두 번** 반복하고, 파드별
|
||||
`up` 시계열이 끝나고 새 시계열이 시작된다.
|
||||
|
||||
**어디를 봐야 하는가** — **2 → 1 → 2 가 두 번**인 것. 파드가 둘이므로 교체도
|
||||
두 번이고, 그때마다 클러스터가 잠시 한 명이 된다. **한 번만 보이면 두 파드가
|
||||
동시에 갈린 것이다.**
|
||||
|
||||
## 4-3. ★ 스키마가 움직였나 — 이 실험의 판정
|
||||
|
||||
**확인** — 1-3 과 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from databasechangelog"
|
||||
```
|
||||
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||
```
|
||||
마이그레이션 후: 210 (전: 210)
|
||||
세션 후: 3 (전: 3)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **전과 후가 같은가.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
| 결과 | 뜻 | 되돌리는 법 |
|
||||
|---|---|---|
|
||||
| **행 수가 그대로** | 스키마가 안 움직였다 | **태그만 되돌리면 된다** |
|
||||
| 행 수가 늘었다 | 새 changeset 이 적용됐다 | **덤프 복구 + 태그 되돌리기** |
|
||||
|
||||
26.7.0 → 26.7.3 은 **패치 릴리스라 스키마가 그대로**였다. 그래서 롤백이
|
||||
가능하다는 가설이 섰고, 다음 절에서 시험한다.
|
||||
|
||||
## 4-4. 가설 시험 — 같은 스키마에서 롤백해 본다
|
||||
|
||||
**되돌리기** — 이 절 자체가 되돌리기다. 다시 올리려면 태그를 26.7.3 으로.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 롤백'
|
||||
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||
```
|
||||
**실측** — [`followup/02`](../../evidence/followup/02-d2-rollback-same-schema.txt)
|
||||
```
|
||||
시작: 15:25:08
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
완료: 15:25:53
|
||||
|
||||
keycloak-0 1/1 Running restarts=0
|
||||
keycloak-1 1/1 Running restarts=0
|
||||
Keycloak 26.7.0
|
||||
마이그레이션: 210
|
||||
세션: 3
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **파드가 뜬다.** 이게 가설의 답이다.
|
||||
|
||||
**이 결과가 의미하는 것** — **스키마가 안 바뀌었으면 태그를 되돌리는 것으로
|
||||
충분하다.** 마이그레이션 210 그대로, 세션 3 그대로, 재시작 0.
|
||||
|
||||
### 전환 순간의 `000` 한 번을 오해하지 않는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
|
||||
grep -n '000' /tmp/d2-avail.txt
|
||||
```
|
||||
**실측** — [`followup/02`](../../evidence/followup/02-d2-rollback-same-schema.txt)
|
||||
```
|
||||
200 응답: 43 회 / 비200: 1
|
||||
|
||||
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
|
||||
200 200 200 200 000 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
|
||||
200 200 200 200
|
||||
비200 값: 000
|
||||
|
||||
=== 대조: 정방향 업그레이드 때는 ===
|
||||
200: 87 / 비200: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `000` 이다. **`500` 도 `502` 도 `503` 도 아니다.**
|
||||
|
||||
`000` 은 **curl 이 HTTP 상태 코드를 하나도 못 받았다**는 뜻이며, 여기서는
|
||||
`--max-time 3` 을 넘긴 것이다. 서버가 오류를 돌려준 것이 아니라 **3초 안에
|
||||
응답이 안 왔다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 파드 전환 순간 요청 하나가 3초를 넘겼다. 정방향에서
|
||||
0회, 역방향에서 1회다. **끊긴 것과 느린 것은 다르고, 그 구별은 코드가 아니라
|
||||
`--max-time` 을 알고 있어야 된다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. ★ 대조 — 스키마가 움직인 방향에서는 무슨 일이 나는가 (선택)
|
||||
|
||||
**여기부터는 일부러 실패시킨다.** 앞의 4절까지로 이 실험의 판정은 끝났다.
|
||||
이 절은 「행 수가 바뀌었을 때」가 실제로 어떤 모양인지 보려는 것이다.
|
||||
|
||||
**되돌리기 — 먼저 읽는다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||
```
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 26.0 으로 내린다'
|
||||
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||
keycloak=quay.io/keycloak/keycloak:26.0
|
||||
```
|
||||
|
||||
**확인** — 이번에는 `rollout status` 로 기다리지 말고 **눈으로 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -w
|
||||
```
|
||||
**실측** — [`02-rollback-attempt.txt`](../../evidence/d2-version-upgrade/02-rollback-attempt.txt)
|
||||
```
|
||||
시각: 15:02:20
|
||||
statefulset.apps/keycloak image updated
|
||||
+20초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||
+40초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||
+60초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||
+80초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
|
||||
+100초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||
+120초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
|
||||
+140초 keycloak-0:Running(1/1) keycloak-1:CrashLoopBackOff(0/1)
|
||||
+160초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 가지다.
|
||||
|
||||
- `keycloak-1` 이 `Running(0/1) → Error → CrashLoopBackOff` 를 오간다.
|
||||
**`Running` 인데 `0/1` 인 상태를 「떴다」로 읽으면 안 된다** — 컨테이너
|
||||
프로세스는 살아 있지만 readiness 를 통과하지 못한 것이고, 곧 죽는다.
|
||||
- **`keycloak-0` 은 내내 `1/1` 이다.** StatefulSet 이 안 건드렸다.
|
||||
|
||||
`Ctrl-C` 로 빠져나온다.
|
||||
|
||||
## 5-1. 왜 실패했는지 물어본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -iE 'liquibase|changeset|validation'
|
||||
```
|
||||
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
|
||||
```
|
||||
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
|
||||
1 changesets check sum
|
||||
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Validation Failed:
|
||||
1 changesets check sum
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`1 changesets check sum`.** 개수가 1이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 26.7.0 이 적용한 changeset 하나를 26.0 도 알고
|
||||
있는데, **정의가 다르다.** 같은 changeset 이 버전 사이에 수정된 것이다.
|
||||
Liquibase 는 스키마를 반쯤 아는 상태로 서비스하느니 **기동 자체를 거부**한다.
|
||||
|
||||
> 파드가 이미 죽어서 로그가 안 나오면 **직전 컨테이너의 로그**를 본다.
|
||||
> ```bash
|
||||
> sudo kubectl -n keycloak-lab logs keycloak-1 --previous
|
||||
> ```
|
||||
|
||||
## 5-2. 그런데 서비스는 살아 있다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
sudo 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
|
||||
sudo kubectl -n keycloak-lab get statefulset keycloak
|
||||
```
|
||||
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||
ready 주소: [10.42.1.140] ← 한 파드만
|
||||
statefulset desired/ready/updated: 2 / 1 / 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — ready 주소가 **하나**, 그리고 `desired/ready/updated`
|
||||
가 **2 / 1 / 1**.
|
||||
|
||||
**이 결과가 의미하는 것** — **StatefulSet 의 롤링 업데이트가 사고를 절반에서
|
||||
멈춰줬다.**
|
||||
|
||||
```
|
||||
keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
|
||||
└─ StatefulSet 은 keycloak-0 을 건드리지 않는다
|
||||
└─ keycloak-0 (26.7.0) 이 계속 서비스한다
|
||||
```
|
||||
|
||||
| replica 1 이었다면 | |
|
||||
|---|---|
|
||||
| 유일한 파드가 CrashLoopBackOff | **전면 장애** |
|
||||
| 되돌리려면 사람이 개입 | 그동안 계속 다운 |
|
||||
|
||||
**A-8 에서 「무중단은 replica ≥ 2 와 readiness 의 조합」이라고 썼는데, 여기서는
|
||||
그 조합이 잘못된 배포를 절반에서 멈춰줬다.**
|
||||
|
||||
## 5-3. 실패한 기동이 스키마를 건드렸나
|
||||
|
||||
**확인** — 세 번째로 같은 명령
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from databasechangelog"
|
||||
```
|
||||
**실측**
|
||||
```
|
||||
realms|clients|migrations|sessions = 2|15|210|4
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **210 그대로.**
|
||||
|
||||
**이 결과가 의미하는 것** — **Liquibase 가 검증 단계에서 멈췄으므로 스키마를
|
||||
건드리지 못했다.** 그래서 이 사고는 「태그만 되돌리면 되는」 쪽에 남았다.
|
||||
|
||||
**여기가 4번과 5번을 가르는 지점이다.**
|
||||
|
||||
```
|
||||
✔ Liquibase 가 검증에서 멈췄다 → 이미지만 되돌리면 끝
|
||||
✘ 이미 적용한 뒤였다 → DB 복구(D-1)까지 해야 한다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. 복구
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 복귀'
|
||||
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||
```
|
||||
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
|
||||
```
|
||||
statefulset.apps/keycloak image updated
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
keycloak-0 1/1 Running 0 10m
|
||||
keycloak-1 1/1 Running 0 28s
|
||||
|
||||
realms|clients|migrations|sessions = 2|15|210|4
|
||||
외부 진입점 HTTP 200
|
||||
```
|
||||
|
||||
> **`kubectl rollout undo statefulset/keycloak` 도 있다.** 이 실험은 쓰지 않았고
|
||||
> (**미검증**), 쓰더라도 **되돌아가는 것은 이미지뿐이다.** 스키마가 움직였다면
|
||||
> undo 도 같은 벽에 부딪힌다.
|
||||
|
||||
## 6-1. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 태그 | `get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].image}'` | 시작할 때의 태그 |
|
||||
| 파드 | `get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| 클러스터 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||
| **마이그레이션** | `psql -tAc "select count(*) from databasechangelog"` | **210 — 시작할 때와 같다** |
|
||||
| 세션 | `psql -tAc "select count(*) from offline_user_session"` | 시작할 때와 같다 |
|
||||
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
| 폴링 | `jobs` | 남아 있으면 `kill %1` |
|
||||
| 덤프 | `ls -l /tmp/pre-upgrade.sql` | 남겨 둔다 |
|
||||
|
||||
---
|
||||
|
||||
# 7. 그래서 업그레이드 계획은 어떻게 쓰는가
|
||||
|
||||
```
|
||||
✘ "문제가 생기면 이미지 태그를 되돌린다"
|
||||
└─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다
|
||||
|
||||
✔ "업그레이드 전에 databasechangelog 를 세어 두고,
|
||||
바뀌었으면 백업에서 DB 를 되돌린 뒤 태그를 되돌린다"
|
||||
```
|
||||
|
||||
| 단계 | |
|
||||
|---|---|
|
||||
| 1 | **백업**(D-1). 스키마가 움직인 뒤에는 이것만이 되돌리기 수단이다 |
|
||||
| 2 | **`databasechangelog` 행 수를 적어 둔다** — 나중에는 못 잰다 |
|
||||
| 3 | 태그 변경 |
|
||||
| 4 | **첫 파드만 관찰** — StatefulSet 이 멈춰준다 |
|
||||
| 5 | 행 수를 다시 센다. **그대로면** 태그만 되돌려도 된다 |
|
||||
| 6 | **늘었으면** DB 복구 + 태그 되돌리기 |
|
||||
|
||||
## 이 실험이 확인한 것과 못 한 것
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| ✔ 정방향(26.7.0 → 26.7.3) 무중단 | 87회 전부 200 |
|
||||
| ✔ 같은 스키마에서 롤백 가능 | 43/1, 그 1은 `--max-time` 타임아웃 |
|
||||
| ✔ 스키마가 바뀐 방향은 기동 거부 | `1 changesets check sum` |
|
||||
| ✔ 실패가 절반에서 격리된다 | StatefulSet + readiness |
|
||||
| ✔ 실패한 기동은 스키마를 안 건드린다 | 210 그대로 |
|
||||
| ✘ **스키마가 실제로 늘어나는 업그레이드** | **이 실험대에서는 재현하지 못했다.** 26.7.x 사이에는 변경이 없다 |
|
||||
| ✘ 마이그레이션 도중 장애 | 스키마 변경 중에 죽으면? |
|
||||
| ✘ 대규모 마이그레이션 시간 | 데이터가 작아 순식간이다 |
|
||||
|
||||
> **가장 중요한 미검증이 첫 줄이다.** 「행 수가 늘면 태그로 못 돌아온다」는
|
||||
> **역방향(26.0)에서 관측한 실패를 근거로 한 추론**이며, 실제로 행 수가 늘어난
|
||||
> 뒤 되돌려 본 적은 없다. 메이저 업그레이드를 할 때 이 실험을 다시 한다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `rollout status` 가 안 끝난다 | **첫 파드가 안 뜬다.** StatefulSet 이 기다린다 | 다른 터미널에서 `get pods -w` — 5절 |
|
||||
| 파드가 `Running` 인데 `0/1` | 프로세스는 살아 있고 readiness 미통과 | `logs` 를 본다. 「떴다」로 읽지 않는다 |
|
||||
| 로그가 안 나온다 | 파드가 이미 죽었다 | `logs keycloak-1 --previous` |
|
||||
| **업그레이드 전 행 수를 안 적었다** | 그 값은 이제 DB 에 없다 | 덤프에서 복원한다 — 아래 |
|
||||
| 비200 이 `000` 이다 | 서버 오류가 아니라 **`--max-time` 타임아웃** | `--max-time` 값을 늘려 다시 재 본다 — 4-4 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated. **실측으로 이 경고를 봤다** | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||
| 두 파드가 동시에 갈렸다 | `podManagementPolicy: Parallel` | `get statefulset keycloak -o yaml \| grep podManagement` |
|
||||
| 새 태그를 못 찾는다 | 레지스트리에서 확인 안 했다 | 1-6 |
|
||||
|
||||
**업그레이드 전 행 수를 안 적었을 때** — 덤프 안에 그 테이블이 통째로 들어
|
||||
있다. **미검증**
|
||||
```bash
|
||||
sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' /tmp/pre-upgrade.sql | wc -l
|
||||
```
|
||||
**어디를 봐야 하는가** — 나온 수에서 **2를 뺀다**(`COPY` 줄과 `\.` 줄).
|
||||
이게 백업 시점의 행 수다. **D-1 의 덤프가 여기서 한 번 더 값을 한다.**
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | D-2 가 남긴 것 |
|
||||
|---|---|
|
||||
| [D-1](d1-backup-restore.md) 백업·복구 | **롤백 = 백업 복구**인 경우가 있다. 태그만 되돌리는 계획은 반쪽이다 |
|
||||
| [D-3](d3-secret-management.md) 비밀 관리 | 업그레이드할 때 Secret 도 같이 검토된다 |
|
||||
| [A-8](../../experiment-a8-rolling-restart.md) 롤링 재시작 | **replica ≥ 2 가 잘못된 배포를 절반에서 멈춘다** |
|
||||
| 운영 | **판정은 버전 번호가 아니라 `databasechangelog` 의 행 수다** |
|
||||
| 전부 | **재기 전에 못 재는 값을 먼저 적어 둔다.** 업그레이드 전 행 수가 그것이다 |
|
||||
@@ -0,0 +1,562 @@
|
||||
# D-3 재현 가이드 — Secret 이 어디까지 감춰지는지 네 경로로 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md) ·
|
||||
증거 원문: [`docs/evidence/d3-secret-management/`](../../evidence/d3-secret-management/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| `describe` 는 `14 bytes` 만 보여주는 것 | `describe secret` |
|
||||
| 같은 값이 **한 줄로** 평문이 되는 것 | `get -o jsonpath \| base64 -d` |
|
||||
| 저장소 암호화가 **꺼져 있는** 것 | `k3s secrets-encrypt status` |
|
||||
| 노드 디스크의 저장 파일 안에 **평문이 있는** 것 | `grep -c` on `state.db` |
|
||||
| **그 grep 이 `0` 을 돌려주는데도 안전하지 않은** 것 | 같은 명령, 다른 키 |
|
||||
| 파드 안에서는 그냥 **환경변수**인 것 | `env` · `/proc/1/environ` |
|
||||
| RBAC 은 실제로 막는 것 | `auth can-i` |
|
||||
|
||||
## 전제
|
||||
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
**k3s 서버의 저장 파일도 이 노드에 있다** — 그래서 4-2 를 여기서 칠 수 있다.
|
||||
- 게스트(`kc-lab-1`/`kc-lab-2`)의 `sudo` 는 **무암호**다. 호스트와 다르다.
|
||||
- 네임스페이스는 `keycloak-lab` 이다.
|
||||
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||
- [`B-6`](../../experiment-b6-key-rotation.md) ·
|
||||
[`B-7`](../../experiment-b7-cookie-secret-rotation.md) 를 이미 했다면 이 실험의
|
||||
결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이다.
|
||||
|
||||
## 주의 — 이건 비밀을 화면에 띄우는 실험이다
|
||||
|
||||
**이 실험의 명령 몇 개는 비밀번호를 터미널에 그대로 찍는다.** 그게 결론이기
|
||||
때문에 피할 수 없지만, 그 값은 **스크롤백·화면 공유·터미널 로그**에 남는다.
|
||||
|
||||
이 가이드는 그래서 이렇게 한다.
|
||||
|
||||
- **남의 진짜 비밀은 길이(`wc -c`)와 키 이름까지만 본다.**
|
||||
- **값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다**
|
||||
(2절). 지워도 되는 값이므로 찍어도 된다.
|
||||
- 실측으로 실린 값들은 **이 저장소의 매니페스트와 문서에 이미 적혀 있는
|
||||
실험대 전용 값**이다(`change-me` 가 이름에 들어 있는 이유가 그것이다).
|
||||
|
||||
파괴적인 단계는 없다. 만드는 것은 카나리아 Secret 하나뿐이고
|
||||
[5. 복구](#5-복구) 에서 지운다. 전 구간 약 15분.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 15:05–15:06 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태이거나, 이 실험이 하지 않은 확장 |
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이다.
|
||||
**그 문장이 실제로 무엇을 막아 주는지**를 네 경로로 나눠 판정한다.
|
||||
|
||||
| # | 경로 | 누가 쓰나 | 예측 |
|
||||
|---|---|---|---|
|
||||
| ① | 쿠버네티스 API (`get secret`) | 클러스터에 접근하는 사람 | ? |
|
||||
| ② | **노드 디스크의 저장 파일** | 디스크·백업·스냅샷을 얻은 사람 | ? |
|
||||
| ③ | **파드 안의 프로세스** | `exec` 권한이 있는 사람, 크래시 덤프 | ? |
|
||||
| ④ | RBAC | 권한이 없는 주체 | ? |
|
||||
|
||||
**핵심 개념부터 짚는다.**
|
||||
|
||||
| | 목적 | 되돌리기 |
|
||||
|---|---|---|
|
||||
| **인코딩** (base64) | 바이너리를 텍스트로 안전하게 **옮기기** | **키 없이 누구나** |
|
||||
| 암호화 | 키 없이는 못 **읽게** 하기 | 키가 있어야 |
|
||||
|
||||
**Secret 이 base64 를 쓰는 이유는 감추려는 것이 아니라 YAML 에 임의 바이트를
|
||||
담기 위해서다.** 그런데 `kubectl describe` 가 값을 가려서 보여주기 때문에
|
||||
「가려져 있구나」라는 인상이 남는다 — 이 실험은 그 인상과 사실 사이의 거리를
|
||||
잰다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 무엇이 있는지부터 본다
|
||||
|
||||
## 1-1. Secret 목록
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret
|
||||
```
|
||||
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
|
||||
```
|
||||
bff-secrets Opaque keys=1
|
||||
keycloak-lab-secrets Opaque keys=2
|
||||
oauth2-proxy-secrets Opaque keys=3
|
||||
```
|
||||
|
||||
> 실측 줄은 원래 실행 스크립트가 정리해 찍은 것이다. 손으로 치면
|
||||
> `NAME / TYPE / DATA / AGE` 네 칸이 나오고, `DATA` 열이 위의 `keys=` 에 해당한다.
|
||||
|
||||
**어디를 봐야 하는가** — 이름과 `DATA` 열(키 개수). **`TYPE` 이 `Opaque` 인
|
||||
것도 본다** — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는
|
||||
뜻이지 **감춘다는 뜻이 아니다.**
|
||||
|
||||
## 1-2. `describe` 는 값을 감춘다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab describe secret bff-secrets
|
||||
```
|
||||
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
|
||||
```
|
||||
Type: Opaque
|
||||
|
||||
Data
|
||||
====
|
||||
KEYCLOAK_CLIENT_SECRET: 14 bytes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키 이름과 바이트 수만 나온다.** 값이 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 화면이 「Secret 은 감춰진다」는 인상의 출처다.
|
||||
`describe` 는 **일부러** 값을 안 찍는다. 그런데 그건 `describe` 라는 명령의
|
||||
동작이지, **저장이나 전송의 성질이 아니다.**
|
||||
|
||||
## 1-3. 값을 안 보고 확인하는 법 — 평소에는 이렇게 한다
|
||||
|
||||
**남의 비밀을 다룰 때 기본 자세다.** 키 이름과 길이만 본다.
|
||||
|
||||
**확인** — 키 이름만
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
|
||||
| tr ',' '\n' | grep -o '"[A-Z_]*"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
"KC_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
"POSTGRES_PASSWORD"
|
||||
```
|
||||
|
||||
**확인** — 길이만
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
22
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나. **값이 화면에 없다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 「Secret 이 제대로 들어갔는가」를 확인하는 데는
|
||||
길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개
|
||||
**「비었는가 아닌가」**이지 값 자체가 아니다.
|
||||
|
||||
> `wc -c` 는 개행까지 세므로 `base64 -d` 결과에 개행이 없으면 실제 길이와
|
||||
> 같다. 값이 비었으면 `0` 이 나온다 — **`0` 은 「Secret 은 있는데 그 키가
|
||||
> 비었다」는 뜻이고, 배포 실패의 흔한 원인이다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 카나리아 Secret 하나를 만든다
|
||||
|
||||
**여기부터 상태가 바뀐다.** 바뀌는 것은 Secret 하나뿐이다.
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete secret d3-canary
|
||||
```
|
||||
|
||||
## 2-1. 왜 카나리아를 쓰나
|
||||
|
||||
4절에서 **저장 파일 안을 grep 해야 한다.** 그러려면 **찾을 문자열을 알고
|
||||
있어야** 하는데, 진짜 비밀번호를 grep 인자로 쓰면 그 값이 셸 히스토리와
|
||||
프로세스 목록(`ps` 로 다른 사용자에게도 보인다)에 남는다.
|
||||
|
||||
**그래서 「찾아도 아무 피해가 없는 값」을 하나 심는다.** 실험 대상이 값 자체가
|
||||
아니라 **경로**이기 때문에 이렇게 해도 결론은 같다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab create secret generic d3-canary \
|
||||
--from-literal=CANARY=d3-canary-zq7v-do-not-use
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
secret/d3-canary created
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `created`. 이미 있다면 `AlreadyExists` 가 나온다 —
|
||||
그럼 지우고 다시 만든다.
|
||||
|
||||
> **이 값은 아무 데도 쓰이지 않는다.** 어떤 파드도 참조하지 않으므로 지워도
|
||||
> 아무것도 안 깨진다. 값에 `do-not-use` 를 넣어 둔 이유는, 나중에 저장 파일
|
||||
> 어딘가에서 이 문자열을 다시 만났을 때 **무엇인지 알아보기 위해서**다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret d3-canary
|
||||
sudo kubectl -n keycloak-lab describe secret d3-canary
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
Data
|
||||
====
|
||||
CANARY: 26 bytes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 여기서도 `describe` 는 바이트 수만 준다. **1-2 와
|
||||
같은 화면이다.** 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다 — 네 경로를 하나씩 연다
|
||||
|
||||
## 4-1. ① API — 한 줄로 읽힌다
|
||||
|
||||
**하기** — 카나리아로 먼저 해 본다. 값을 알고 있으므로 무엇이 나올지 예측된다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get secret d3-canary \
|
||||
-o jsonpath='{.data.CANARY}' | base64 -d; echo
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
d3-canary-zq7v-do-not-use
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **2-1 에서 심은 값이 그대로 나온다.**
|
||||
|
||||
같은 명령이 실제 비밀에도 그대로 듣는다. 원래 실행이 네 개를 뽑은 결과가 이것이다.
|
||||
|
||||
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
|
||||
```
|
||||
keycloak-lab-secrets/POSTGRES_PASSWORD = lab-postgres-change-me
|
||||
keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD = lab-admin-change-me
|
||||
bff-secrets/KEYCLOAK_CLIENT_SECRET = bff-lab-secret
|
||||
oauth2-proxy-secrets/COOKIE_SECRET_A = lab-cookie-secret-aaaaaaaaaaaaaa
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **실험대의 모든 비밀이 명령 네 줄로 나온다.**
|
||||
`describe` 가 `14 bytes` 라고 했던 그 값이 `bff-lab-secret` (14자)이다.
|
||||
|
||||
**이 결과가 의미하는 것** — ① 은 **막지 않는다.** base64 는 인코딩이고
|
||||
`base64 -d` 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 **누가 이 명령을
|
||||
칠 수 있는가**이며, 그건 ④(RBAC)의 문제로 넘어간다.
|
||||
|
||||
> **이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다.** 카나리아로 한 번
|
||||
> 확인했으면 기제는 같다. 진짜 비밀은 1-3 의 길이 확인으로 충분하다.
|
||||
|
||||
## 4-2. ② 저장소 — 노드 디스크에 평문이 있다
|
||||
|
||||
### 먼저 암호화 설정을 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo k3s secrets-encrypt status
|
||||
```
|
||||
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||
```
|
||||
Encryption Status: Disabled, no configuration file found
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Disabled`, 그리고 **`no configuration file found`**.
|
||||
설정 파일이 아예 없다 — 껐다기보다 **켠 적이 없다**는 뜻이고, 이게 기본값이다.
|
||||
|
||||
### 저장 파일이 어디 있는지 본다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo ls -l /var/lib/rancher/k3s/server/db/
|
||||
```
|
||||
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||
```
|
||||
total 23336
|
||||
drwx------ 2 root root 4096 Sep 2 09:12 .
|
||||
drwx------ 8 root root 4096 Sep 4 03:23 ..
|
||||
-rw-r--r-- 1 root root 13078528 Sep 4 06:05 state.db
|
||||
-rw-r--r-- 1 root root 32768 Sep 4 06:06 state.db-shm
|
||||
-rw-r--r-- 1 root root 10769712 Sep 4 06:06 state.db-wal
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 파일이 **셋**이다.
|
||||
|
||||
| 파일 | 무엇인가 |
|
||||
|---|---|
|
||||
| `state.db` | 본체 |
|
||||
| `state.db-wal` | **아직 본체에 합쳐지지 않은 최근 쓰기** |
|
||||
| `state.db-shm` | 공유 메모리 인덱스 |
|
||||
|
||||
**k3s 는 etcd 대신 SQLite 를 쓴다.** 「저장소(at rest)」의 자리는 같다 —
|
||||
etcd 를 쓰는 클러스터라면 이 자리가 etcd 의 데이터 디렉터리다.
|
||||
|
||||
> **`-wal` 이 10MB 나 되는 것을 봐 둔다.** 방금 만든 카나리아는 **아직 본체에
|
||||
> 없을 가능성이 높다.** 아래에서 이게 함정이 된다.
|
||||
|
||||
### ★ 파일 안을 찾아본다
|
||||
|
||||
**하기** — 카나리아부터
|
||||
```bash
|
||||
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
|
||||
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
|
||||
```
|
||||
**미검증** — 이 실험대는 카나리아 대신 실제 값으로 쟀다. 그 결과가 아래다.
|
||||
|
||||
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||
```
|
||||
=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
|
||||
state.db 안의 평문 일치: 2
|
||||
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
|
||||
client secret 평문 등장 횟수: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 줄의 값이 다르다. `2` 와 `0`.**
|
||||
|
||||
같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다.
|
||||
|
||||
**이 결과가 의미하는 것 — 이 절에서 제일 중요한 문장이다.**
|
||||
|
||||
> **`grep` 이 `0` 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에
|
||||
> 이 형태로는 못 찾았다」이다.**
|
||||
|
||||
`2` 가 나온 순간 ②의 답은 이미 정해졌다 — **저장 파일에 평문이 있다.**
|
||||
`0` 이 나온 키에 대해 「그건 안전한가 보다」라고 읽으면, **같은 파일에 평문이
|
||||
들어 있는 것을 이미 본 뒤에 그러는 것이다.**
|
||||
|
||||
**0 이 나왔을 때 다음에 볼 곳** — **미검증**. 이 실험은 원인을 가리지 않았다.
|
||||
|
||||
```bash
|
||||
sudo grep -c 'bff-lab-secret' /var/lib/rancher/k3s/server/db/state.db-wal
|
||||
sudo strings /var/lib/rancher/k3s/server/db/state.db | grep -c 'bff-lab-secret'
|
||||
```
|
||||
|
||||
| 왜 안 나올 수 있나 | 확인 |
|
||||
|---|---|
|
||||
| 아직 `-wal` 에만 있다 | `-wal` 을 같이 grep |
|
||||
| 값이 페이지 경계를 넘어 잘렸다 | `strings` 로 한 번 더 |
|
||||
| 그 키가 그 시점에 없었다 | `get secret` 으로 존재 확인 |
|
||||
|
||||
> **`grep -c` 는 바이너리 파일에도 듣는다.** 평소의 `grep` 은 바이너리를 만나면
|
||||
> `Binary file ... matches` 한 줄만 찍고 내용을 안 보여주는데, `-c` 는 개수만
|
||||
> 세므로 그대로 숫자가 나온다. **값 자체를 화면에 안 띄운다는 점에서도 이
|
||||
> 형태가 맞다** — 여기서 궁금한 것은 「있는가」이지 「무엇인가」가 아니다.
|
||||
|
||||
**그래서 무엇이 위험한가**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 노드 디스크를 얻으면 | **전 클러스터의 비밀** |
|
||||
| 노드 백업/스냅샷 | 같은 것을 복사한다 |
|
||||
| A-4 에서 본 `local-path` PVC | **같은 디스크에 있다** |
|
||||
| D-1 의 덤프 | 같은 기계에 뒀다면 **거기도 같이** |
|
||||
|
||||
**D-1 에서 「덤프를 같은 장애 도메인에 두면 백업이 아니다」라고 했는데, 여기서는
|
||||
「노드 디스크 하나가 모든 비밀」이다.** 백업을 잘 챙길수록 비밀도 잘 복사된다.
|
||||
|
||||
**k3s 는 `--secrets-encryption` 플래그로 켤 수 있다.** 지금은 안 켜져 있고,
|
||||
**이 가이드는 켜지 않는다** — 켜는 것은 서버 재시작과 기존 Secret 재암호화를
|
||||
수반하고, 이 실험대에서 시험하지 않았다(**미검증**).
|
||||
|
||||
## 4-3. ③ 파드 안 — 평범한 환경변수다
|
||||
|
||||
**확인** — 어느 파드를 볼지 먼저 정한다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
**하기** — **미검증**(원래 실행은 파드 이름을 직접 지정했다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'
|
||||
```
|
||||
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||
```
|
||||
KEYCLOAK_CLIENT_SECRET=bff-lab-secret
|
||||
BFF_DB_PASSWORD=lab-postgres-change-me
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`env` 한 번이면 나온다.** 그리고 `bff-lab-secret`
|
||||
은 4-1 에서 API 로 뽑은 값과 **같다** — 두 경로가 같은 평문에 닿는다.
|
||||
|
||||
`exec` 이 `deploy/bff` 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라
|
||||
친다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo
|
||||
```
|
||||
|
||||
**같은 파드 안의 다른 프로세스도 본다.** 이게 「환경변수」의 진짜 성질이다.
|
||||
|
||||
**하기** — **미검증**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/bff -- \
|
||||
sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 같은 값이 나오는가. `/proc/<pid>/environ` 은 그
|
||||
프로세스의 환경변수를 그대로 담고 있고, **같은 UID 의 아무 프로세스나 읽는다.**
|
||||
|
||||
| 새는 경로 | |
|
||||
|---|---|
|
||||
| `kubectl exec` 권한이 있는 사람 | 바로 본다 |
|
||||
| 같은 파드의 다른 프로세스 | `/proc/<pid>/environ` |
|
||||
| **크래시 덤프 · 오류 리포트** | 환경변수를 함께 담는 도구가 많다 |
|
||||
| 자식 프로세스 | 상속된다 |
|
||||
|
||||
**볼륨으로 마운트하면 이 중 몇 가지가 줄어든다** — 파일 권한으로 제한할 수
|
||||
있고, 환경변수 덤프에 안 들어간다.
|
||||
|
||||
```yaml
|
||||
volumeMounts:
|
||||
- name: secrets
|
||||
mountPath: /etc/secrets
|
||||
readOnly: true
|
||||
```
|
||||
|
||||
**줄어드는 것이지 없어지는 것이 아니다.** `exec` 권한이 있으면 파일도 읽는다.
|
||||
|
||||
## 4-4. ④ RBAC — 유일하게 막는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl auth can-i get secrets -n keycloak-lab \
|
||||
--as=system:serviceaccount:keycloak-lab:default
|
||||
```
|
||||
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||
```
|
||||
default SA: no
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`no`** 한 단어.
|
||||
|
||||
**어떤 권한이 있는지 통째로 보려면** — **미검증**
|
||||
```bash
|
||||
sudo kubectl auth can-i --list -n keycloak-lab \
|
||||
--as=system:serviceaccount:keycloak-lab:default
|
||||
sudo kubectl -n keycloak-lab get role,rolebinding
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 기본 서비스계정은 Secret 을 못 읽는다.
|
||||
**명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문**이다. RBAC 은
|
||||
기본이 거부이고, Role 을 붙여야 할 수 있게 된다.
|
||||
|
||||
> **네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다.** 그러므로 실질적인
|
||||
> 방어선은 「누가 `get secrets` 를 할 수 있는가」이며,
|
||||
> **관리자 권한을 가진 사람에게는 아무 방어가 없다.**
|
||||
>
|
||||
> A-0 의 관측 스택에서 `nodes/proxy` 서브리소스를 따로 줘야 했던 것처럼,
|
||||
> Secret 접근도 **리소스 단위로 나눌 수 있다.**
|
||||
|
||||
## 4-5. 네 경로 정리
|
||||
|
||||
| # | 경로 | 감춰지는가 | 무엇이 뚫나 |
|
||||
|---|---|---|---|
|
||||
| ① | `get -o jsonpath \| base64 -d` | **아니다** | 클러스터 접근 권한 |
|
||||
| — | `describe secret` | 값을 숨긴다 | **그래서 안전하다고 착각한다** |
|
||||
| ② | 저장 파일(`state.db`) | **아니다.** 암호화 꺼짐 | 노드 디스크·백업·스냅샷 |
|
||||
| ③ | 파드 안 | **아니다.** 평범한 환경변수 | `exec` · `/proc` · 크래시 덤프 |
|
||||
| ④ | RBAC | **막는다** | 관리자 권한 |
|
||||
|
||||
**「Secret 이니까 안전하다」는 네 가지 중 하나(RBAC)만 맞다.**
|
||||
그리고 ②·③ 은 **쿠버네티스 API 를 한 번도 거치지 않고** 평문에 닿는다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 카나리아를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete secret d3-canary
|
||||
sudo kubectl -n keycloak-lab get secret
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
secret "d3-canary" deleted
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 1-1 의 목록으로 돌아왔는가. 세 개다.
|
||||
|
||||
## 5-2. ★ 지웠다고 파일에서 없어지지는 않는다
|
||||
|
||||
**확인** — **미검증**. 이 실험은 삭제 후를 재지 않았다
|
||||
```bash
|
||||
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
|
||||
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 0 이 나오면 「이 시점에 이 형태로는 안 보인다」이고,
|
||||
0 이 아니면 **지운 Secret 의 평문이 아직 파일에 남아 있는 것**이다.
|
||||
어느 쪽이든 **4-2 의 결론은 안 바뀐다** — 판정은 이미 `2` 에서 났다.
|
||||
|
||||
**이 결과가 의미하는 것** — 데이터베이스 파일은 지운 행의 자리를 즉시
|
||||
0으로 덮어쓰지 않는다. **「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는
|
||||
다른 사건**이며, 비밀이 유출됐을 때 실제로 해야 하는 일은 삭제가 아니라
|
||||
**회전(rotation)**인 이유가 여기 있다 — B-6·B-7 의 주제다.
|
||||
|
||||
## 5-3. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 카나리아 | `sudo kubectl -n keycloak-lab get secret d3-canary` | `NotFound` |
|
||||
| Secret 목록 | `sudo kubectl -n keycloak-lab get secret` | 세 개 |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` (아무것도 안 건드렸다) |
|
||||
| **터미널** | `history \| tail -40` | **비밀번호가 찍힌 줄이 어디까지 남았는지 본다** |
|
||||
|
||||
> **이 실험의 진짜 뒷정리는 스크롤백이다.** 4-1 을 실제 비밀로 쳤다면 그 값이
|
||||
> 터미널 버퍼와 셸 히스토리에 남아 있다. 실험대 값이라 지금은 상관없지만,
|
||||
> **같은 절차를 운영에서 하면 그게 유출 경로가 된다.**
|
||||
|
||||
---
|
||||
|
||||
# 6. 그래서 무엇을 해야 하는가
|
||||
|
||||
```
|
||||
지금: 매니페스트에 stringData 평문 → git 에 커밋되면 끝
|
||||
k3s 저장소 암호화 꺼짐
|
||||
파드 환경변수
|
||||
```
|
||||
|
||||
| 단계 | 얻는 것 | 이 실험대 |
|
||||
|---|---|---|
|
||||
| ① 매니페스트에서 값을 빼고 **`.example` 만 커밋** | git 유출을 막는다 | 안 함 |
|
||||
| ② **k3s `--secrets-encryption`** 활성화 | 노드 디스크 유출을 막는다 | 안 함 (**미검증**) |
|
||||
| ③ 환경변수 대신 **볼륨 마운트** | 프로세스·덤프 유출을 줄인다 | 안 함 |
|
||||
| ④ **SealedSecret / 외부 KMS** | 매니페스트에 암호문만 남는다 | 안 함 |
|
||||
| ⑤ **RBAC 최소화** | 유일하게 이미 동작하는 방어선을 좁힌다 | 기본값 그대로 |
|
||||
|
||||
**이 실험대는 ①~④ 중 아무것도 안 하고 있다.** 실험 목적으로는 의도적이지만,
|
||||
**그 사실을 기록해두지 않으면 그대로 운영에 옮겨간다.** 값 이름에 `change-me`
|
||||
를 넣어 둔 것이 그 최소한의 표시다.
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `grep` 이 `0` 인데 안전하다고 읽힌다 | **`0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」** | `-wal` 과 `strings` 로 한 번 더 — 4-2 |
|
||||
| `grep` 이 `Binary file matches` 만 찍는다 | 바이너리 파일이다 | `-c` 를 쓴다(개수만). 값을 안 띄우는 이점도 있다 |
|
||||
| `k3s secrets-encrypt` 가 없다 | **서버 노드가 아니다** | `kc-lab-1`(control-plane)에서 친다 |
|
||||
| `state.db` 가 `Permission denied` | root 전용 디렉터리 | 게스트 sudo 는 무암호다. `sudo` 를 붙인다 |
|
||||
| `exec deploy/bff` 가 실패한다 | 파드가 종료 중이거나 여럿이다 | `--field-selector=status.phase=Running` 으로 이름을 고른다 — 4-3 |
|
||||
| `auth can-i` 가 `yes` 라고 한다 | 그 SA 에 Role 이 붙어 있다 | `get rolebinding -o wide` 로 누가 줬는지 본다 |
|
||||
| 값이 `0 bytes` 로 나온다 | Secret 은 있는데 키가 비었다 | `describe` 의 바이트 수를 본다 — 배포 실패의 흔한 원인 |
|
||||
| 비밀번호를 화면에 찍어 버렸다 | 4-1 을 실제 값으로 쳤다 | 스크롤백·히스토리를 지우고, **운영이면 회전한다** |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | D-3 이 남긴 것 |
|
||||
|---|---|
|
||||
| [B-6](../../experiment-b6-key-rotation.md) key 회전 | **key 를 Secret 에 두면 이 실험의 결론이 그대로 적용된다** |
|
||||
| [B-7](../../experiment-b7-cookie-secret-rotation.md) 쿠키 비밀 회전 | 유출 대응은 삭제가 아니라 **회전**이다 — 5-2 |
|
||||
| [D-1](d1-backup-restore.md) 백업 | **덤프에도 같은 문제가 있다.** 백업을 잘 챙길수록 비밀도 잘 복사된다 |
|
||||
| [D-4](d4-certificate-renewal.md) 인증서 | **개인키(`privkey.pem`)도 같은 비밀 관리 문제다** |
|
||||
| 운영 | **RBAC 이 유일하게 동작하는 방어선이다.** 관리자에게는 아무 방어가 없다 |
|
||||
@@ -0,0 +1,627 @@
|
||||
# D-4a 재현 가이드 — 훅 파일 하나가 38분을 1초로 만드는 것을 직접 본다
|
||||
|
||||
해설 문서: [`docs/experiment-d4a-deploy-hook.md`](../../experiment-d4a-deploy-hook.md) ·
|
||||
증거 원문: [`docs/evidence/d4a-deploy-hook/`](../../evidence/d4a-deploy-hook/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 훅 디렉터리가 비어 있는 것 → 파일 하나를 넣는 것 | `ls -l` |
|
||||
| certbot 이 **`ran with error output`** 이라고 찍는데 **실패가 아닌** 것 | certbot 출력 원문 |
|
||||
| 마스터는 그대로고 워커만 **자동으로** 갈리는 것 | `ps -eo lstart` |
|
||||
| 서빙 인증서가 **그 자리에서** 바뀌는 것 | `openssl s_client` |
|
||||
| 발급에서 서빙까지 **1~2초**인 것 | SCT + 보정한 훅 시각 |
|
||||
| 보정하지 않으면 **훅이 발급보다 104초 먼저** 돈 것이 되는 것 | 같은 계산 |
|
||||
| `notBefore` 가 **발급 시각이 아닌** 것 | 인증서 필드 |
|
||||
|
||||
## 전제
|
||||
|
||||
- **[`D-4`](d4-certificate-renewal.md) 를 먼저 한다.** 특히 두 가지가 없으면
|
||||
이 실험은 성립하지 않는다.
|
||||
- **1-6** — 「reload 판정은 워커 PID 로 한다」는 기준
|
||||
- **1-7** — 두 기계 시계의 왜곡을 **미리** 재 둔 값
|
||||
- 관찰은 **dev 에서**, 주입은 **`test-server` 에서 사람이** 친다.
|
||||
- **호스트의 `sudo` 는 비밀번호를 요구한다.** 이 실험의 주입은 전부 그쪽이다.
|
||||
- 이 호스트의 certbot 은 **5.7.0**, **nginx 플러그인은 없다.**
|
||||
|
||||
## 주의 — 인증서를 한 장 더 쓴다
|
||||
|
||||
`certbot renew --force-renewal` 을 **또** 한 번 친다. D-4 에서 한 번 썼다면
|
||||
이번이 두 번째이고, **Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장
|
||||
쓴 셈**이 된다. 세어 두고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다.
|
||||
|
||||
**그리고 이 실험의 주입은 되돌리지 않는 편이 낫다.** 훅은 결함을 고치는
|
||||
파일이다. 지우면 D-4 의 상태로 돌아간다 — 지우는 명령은
|
||||
[5-1. 남기는 이유](#5-1-남기는-이유) 에 있다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 12:27 UTC(실제) 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **실측(호스트)** | 증거 파일이 아니라 **이 호스트에서 확인된 설정값** |
|
||||
| **형태** | 값이 매번 달라지는 출력 |
|
||||
| **미검증** | 손으로 치기 좋게 고친 형태이거나, 이 실험이 하지 않은 확장 |
|
||||
|
||||
## ★ 시각 표기 규약
|
||||
|
||||
**이 실험은 1~2초를 잰다. 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.**
|
||||
|
||||
| 표기 | 뜻 |
|
||||
|---|---|
|
||||
| `12:27:49 (실제)` | 보정한 값. 외부 기준과 일치 |
|
||||
| `21:29:36 KST (ts)` | test-server 시계. **106초 빠르다** |
|
||||
| `12:29:05 (dev)` | 개발 머신 시계. 보정 불필요 |
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
|
||||
|
||||
| D-4 가 남긴 항목 | 상태 |
|
||||
|---|---|
|
||||
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||||
|
||||
**처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것**은, 이 실험대가
|
||||
스물세 번 경계해 온 바로 그 실수다. 그래서 별도 실험으로 분리했다.
|
||||
|
||||
판정할 것은 셋이다.
|
||||
|
||||
| # | 질문 | 무엇으로 가르나 |
|
||||
|---|---|---|
|
||||
| ① | 훅이 **실행되는가** | certbot 출력 |
|
||||
| ② | nginx 가 **정말 reload 되는가** | **워커 PID** (문구가 아니라) |
|
||||
| ③ | **얼마나 빠른가** | SCT ↔ 보정한 훅 시각 |
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 훅을 넣기 전에
|
||||
|
||||
## 1-1. 워커 PID — 판정 기준을 먼저 잡는다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||
```
|
||||
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||||
```
|
||||
585 1 ... Thu Sep 3 19:00:39 nginx: master process
|
||||
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 마스터 PID 와 워커 PID **두 숫자**, 그리고 워커의
|
||||
`lstart`. **이 세 값을 적어 둔다. 4-3 의 판정이 이 값과의 비교다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 워커 28829 는 D-4 에서 **사람이** `nginx -s reload`
|
||||
를 쳐서 생긴 것이다. 마스터는 여전히 585, 어제 19:00:39 에 뜬 그대로다.
|
||||
**마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명**이라는 것을 D-4 에서
|
||||
확인했고, 이 실험은 그 기준을 그대로 쓴다.
|
||||
|
||||
## 1-2. 지금 서빙 중인 인증서
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -serial -dates
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `serial`. **적어 둔다.** 4-4 에서 이 값이 바뀐다.
|
||||
|
||||
**확인** — 발급 시각의 외부 기준도 지금 봐 둔다
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Timestamp` 두 줄. **CT 로그가 자기 시계로 서명한
|
||||
시각**이고, 이 실험대의 두 기계와 무관한 제3의 기준이다. 4-5 에서 이 값이
|
||||
심판이 된다.
|
||||
|
||||
## 1-3. 훅 디렉터리가 비어 있는가 — 사람이 친다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
|
||||
```
|
||||
**실측** — [`d4-certificate-renewal/12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt)
|
||||
```
|
||||
/etc/letsencrypt/renewal-hooks/deploy/:
|
||||
total 8
|
||||
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
|
||||
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`total 8` 과 `.` `..` 뿐.**
|
||||
|
||||
> **`sudo` 없이 치면 `Permission denied` 다.** 그 빈 출력을 「비어 있다」로 읽는
|
||||
> 것이 D-4 에서 실제로 걸렸던 함정이다.
|
||||
|
||||
## 1-4. ★ 시계 왜곡을 먼저 잰다 — 나중에 재면 값을 해석할 수 없다
|
||||
|
||||
**이 실험의 답은 1~2초다.** 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다.
|
||||
**그리고 왜곡은 사후에 되짚을 수 없다** — 지금 재 둔다.
|
||||
|
||||
**확인** — 왕복 사이에 상대 시각을 끼워 세 번 잰다
|
||||
```bash
|
||||
for i in 1 2 3; do
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
echo "A=$A B=$B C=$C"
|
||||
done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 줄 각각에서 `B` 와 `(A+C)/2` 의 차이를 **눈으로**
|
||||
뺀다. 그리고 **세 번의 값이 서로 비슷한가** — 흔들리면 네트워크 지연이 섞인
|
||||
것이고, 안정적이면 진짜 왜곡이다.
|
||||
|
||||
**확인** — 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
```bash
|
||||
date -u
|
||||
curl -sI https://www.google.com | grep -i '^date:'
|
||||
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
|
||||
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
|
||||
```
|
||||
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||||
```
|
||||
dev → Google 차이 +0초
|
||||
dev → Let's Encrypt ACME 차이 +0초
|
||||
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
|
||||
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `NTPSynchronized`. 이 호스트는 **`no`** 다.
|
||||
그리고 세 번 다 `+106.1` 로 흔들리지 않았다는 것.
|
||||
|
||||
**이 결과가 의미하는 것** — **dev 가 정확하고 test-server 가 106초 빠르다.**
|
||||
|
||||
```
|
||||
실제 시각 = test-server 시계 − 106초
|
||||
```
|
||||
|
||||
**왜 Let's Encrypt 의 `Date:` 도 보나** — 이 실험이 재는 사건의 한쪽 끝이
|
||||
**Let's Encrypt 의 발급**이기 때문이다. 그쪽 기준과 dev 가 일치한다는 것을
|
||||
확인해 두면, 4-5 의 비교가 같은 시간축 위에서 성립한다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 파일 하나
|
||||
|
||||
**되돌리기 — 먼저 읽는다**
|
||||
```bash
|
||||
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
|
||||
```
|
||||
|
||||
**단, 지우면 D-4 의 상태로 돌아간다.** 이 파일은 고장이 아니라 고침이다.
|
||||
|
||||
## 2-1. 개념 — `pre/` · `deploy/` · `post/` 는 다르다
|
||||
|
||||
| 디렉터리 | 언제 실행되나 |
|
||||
|---|---|
|
||||
| `pre/` | 갱신 **시도** 전 |
|
||||
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
|
||||
| `post/` | 갱신 여부와 **무관하게** 매번 |
|
||||
|
||||
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 **갱신이
|
||||
없는 날에도 하루 두 번 nginx 를 reload** 하게 된다 — 아무 이득 없이 워커만
|
||||
갈아치우는 셈이다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때,
|
||||
즉 **실제로 갱신했을 때만** 돈다.
|
||||
|
||||
**없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다.
|
||||
그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다.
|
||||
|
||||
## 2-2. 왜 `nginx -t &&` 를 앞에 두는가
|
||||
|
||||
```sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 **새 워커를 못
|
||||
띄운다.** `-t` 로 먼저 검사하고 통과할 때만 reload 한다.
|
||||
|
||||
**실패하면 옛 워커가 그대로 서비스를 계속한다** — 인증서는 안 바뀌지만
|
||||
**서비스는 죽지 않는다.** 이 순서 하나가 「인증서가 안 바뀐다」와
|
||||
「사이트가 내려간다」를 가른다.
|
||||
|
||||
> **`restart` 를 쓰지 않는 이유**도 같다. **실측(호스트)** 로 확인한
|
||||
> `nginx.service` 의 유효 설정은 `Restart=on-failure` · `RestartUSec=100ms` ·
|
||||
> `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 다. 설정이 깨진 채
|
||||
> `restart` 를 걸면 **10초 안에 5번 실패하고 systemd 가 포기한다** — nginx 가
|
||||
> 내려간 채로 멈춘다.
|
||||
|
||||
## 2-3. sudo 없는 곳에 파일을 미리 만들어 둔다
|
||||
|
||||
**사람이 비밀번호를 치며 실행할 명령은 짧을수록 좋다.** 내용 작성은 sudo 가
|
||||
필요 없는 곳에서 미리 해 둔다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
|
||||
ssh test-server 'cat /tmp/reload-nginx.sh'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 줄이 맞게 들어갔는가. **`#!/bin/sh` 가 첫 줄이어야
|
||||
한다.**
|
||||
|
||||
> **`/tmp` 를 여기서 쓰는 것은 괜찮다.** 이건 당신의 대화형 셸이 쓰는 `/tmp`
|
||||
> 이기 때문이다. 다만 **`certbot-renew.service` 는 `PrivateTmp=true`**
|
||||
> (**실측(호스트)**)라 **그 서비스가 보는 `/tmp` 은 다른 곳**이다 — 훅이
|
||||
> 나중에 `/tmp` 에 로그를 남기도록 만들면 **타이머가 돌렸을 때 그 파일을 밖에서
|
||||
> 찾을 수 없다**(**미검증** — 이 실험은 훅에 로그를 넣지 않았다).
|
||||
> 훅의 로그는 `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다.
|
||||
|
||||
## 2-4. 설치 — 여기부터 사람이 친다
|
||||
|
||||
**하기** — 호스트에 붙어서 직접 친다
|
||||
```bash
|
||||
ssh -t test-server
|
||||
```
|
||||
호스트의 셸에서:
|
||||
```bash
|
||||
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
> 원래 실행은 이 전부를 **한 줄**로 쳤다. 사람이 비밀번호를 한 번만 치게
|
||||
> 하려는 것이다. 참고로 적어 둔다.
|
||||
> ```bash
|
||||
> ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \
|
||||
> /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal \
|
||||
> > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
|
||||
> ```
|
||||
> **읽기는 어렵다.** 처음 할 때는 위처럼 한 줄씩 치고, 익숙해지면 합친다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**갱신을 걸기 전에** 훅이 제자리에, 실행 가능한 상태로 있는지 본다.
|
||||
**한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.**
|
||||
|
||||
## 3-1. 파일이 그 자리에 있고 실행 비트가 있는가
|
||||
|
||||
**하기** — 호스트 셸에서
|
||||
```bash
|
||||
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
total 4
|
||||
-rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가 — 세 가지다.**
|
||||
|
||||
- **`x` 비트** (`-rwxr-xr-x`). 없으면 certbot 이 그냥 건너뛴다
|
||||
- **디렉터리가 `deploy/`** 인가. `post/` 에 들어가면 매번 돈다
|
||||
- 소유자가 `root`
|
||||
|
||||
**확인** — 손으로 한 번 돌려 본다. **이게 가장 확실한 사전 점검이다**
|
||||
```bash
|
||||
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `test is successful`. **이때 워커 PID 도 바뀐다**
|
||||
(이 스크립트는 실제로 reload 한다). 1-1 을 다시 재서 새 값을 기준선으로 삼는다.
|
||||
|
||||
## 3-2. certbot 이 훅을 부르는지 먼저 본다
|
||||
|
||||
**하기** — **미검증**. 원래 실행은 곧바로 강제 갱신을 했다
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 출력에 `Running deploy-hook command` 계열의 줄이
|
||||
나오는가, 그리고 `simulated renewals` 요약.
|
||||
|
||||
**이 결과가 의미하는 것** — dry-run 은 **인증서를 발급하지 않고 한도도 안
|
||||
깎는다.** 훅이 **호출되는지**까지만 말해 준다. **호출된 훅이 nginx 를 정말
|
||||
갈아 끼웠는지는 dry-run 으로 알 수 없다** — 그래서 4절이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 강제 갱신 — 사람이 친다
|
||||
|
||||
**되돌리기 — 없다.** 인증서 한 장을 실제로 발급한다.
|
||||
|
||||
**하기** — 호스트 셸에서
|
||||
```bash
|
||||
date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
|
||||
sudo certbot renew --force-renewal
|
||||
```
|
||||
|
||||
**시각을 기록하되 어느 시계인지 반드시 적는다.** 호스트에서 찍은 것은
|
||||
`(ts)` 이고 **106초 빠르다.**
|
||||
|
||||
## 4-2. ★ certbot 출력 — 함정이 여기 있다
|
||||
|
||||
**실측** — [`02-certbot-with-hook.txt`](../../evidence/d4a-deploy-hook/02-certbot-with-hook.txt)
|
||||
```
|
||||
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||
Hook 'deploy-hook' ran with error output:
|
||||
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Congratulations, all renewals succeeded:
|
||||
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가 — 다섯 줄을 하나씩 읽는다.**
|
||||
|
||||
| 줄 | 실제 의미 |
|
||||
|---|---|
|
||||
| `Hook 'deploy-hook' ran with error output:` | **훅이 실행됐고, stderr 에 뭔가 있었다** |
|
||||
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. **갱신과 무관** |
|
||||
| `nginx: … test is successful` | **`nginx -t` 통과** |
|
||||
| `[notice] … signal process started` | **`nginx -s reload` 가 신호를 보냈다** |
|
||||
| `Congratulations, all renewals succeeded` | 갱신 성공 |
|
||||
|
||||
### ★ `ran with error output` 은 실패가 아니다
|
||||
|
||||
**certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙인다.** 종료 코드를
|
||||
말하는 것이 아니다. 여기서 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고,
|
||||
**내용은 전부 성공**이다.
|
||||
|
||||
> **로그에서 `error` 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로
|
||||
> 오독한다.** 그리고 반대 방향도 위험하다 — 이 실험은 **훅이 진짜로 실패했을 때
|
||||
> certbot 이 무엇을 찍는지 재지 않았다**(**미검증**).
|
||||
>
|
||||
> **그래서 판정은 문구가 아니라 다음 절의 워커 PID 로 한다.**
|
||||
|
||||
## 4-3. 판정 — 워커가 교체됐다
|
||||
|
||||
**확인** — 1-1 과 **똑같은 명령**
|
||||
```bash
|
||||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||
```
|
||||
**실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt)
|
||||
```
|
||||
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||||
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
| | 전 | 후 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 마스터 | **585** | **585** | 그대로 |
|
||||
| 워커 | 28829 | **37252** | **바뀌었다** |
|
||||
| 워커 `lstart` | Fri Sep 4 18:00:35 (ts) | **Fri Sep 4 21:29:36 (ts)** | 방금 떴다 |
|
||||
| 워커 `etimes` | — | **74** | 74초 전 |
|
||||
|
||||
**이 결과가 의미하는 것** — **마스터 PID 는 유지되고 워커만 바뀌었다.**
|
||||
D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 **그대로 작동한다.**
|
||||
그리고 이번에는 사람이 아니라 **훅이** 했다.
|
||||
|
||||
> **`etimes` 74 를 같이 보는 이유** — PID 는 우연히 재사용될 수 있다.
|
||||
> `lstart` 와 `etimes` 가 「방금」을 가리켜야 진짜 새 워커다.
|
||||
|
||||
## 4-4. 서빙 인증서가 바뀌었다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -serial -dates -ext subjectAltName
|
||||
```
|
||||
**실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt)
|
||||
```
|
||||
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||
notBefore=Sep 4 11:29:18 2026 GMT
|
||||
notAfter=Dec 3 11:29:17 2026 GMT
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `serial` 이 1-2 에서 적어 둔 값과 **다른가.**
|
||||
D-4 의 인증서(`06C7CB…EA1D`)에서 바뀌었다. SAN 은 세 이름 그대로다.
|
||||
|
||||
**이 결과가 의미하는 것** — **훅 하나로 ①②③ 중 ①②가 끝났다.** 남은 것은
|
||||
「얼마나 빨랐나」다.
|
||||
|
||||
## 4-5. ★ 얼마나 빨랐나 — 시계 보정이 여기서 결과를 정한다
|
||||
|
||||
**가진 시각은 셋이고, 두 개는 다른 시계에서 왔다.**
|
||||
|
||||
| 사건 | 원래 값 | 어느 시계 |
|
||||
|---|---|---|
|
||||
| 인증서 발급 | SCT `Sep 4 12:27:49.054 GMT` | **CT 로그** (독립) |
|
||||
| 훅의 `nginx -t` | 로그 `2026/09/04 21:29:36` | **(ts)** |
|
||||
| 새 워커 기동 | `lstart Fri Sep 4 21:29:36` | **(ts)** |
|
||||
| 훅의 `nginx -s reload` | 로그 `2026/09/04 21:29:37` | **(ts)** |
|
||||
|
||||
**확인** — 새 인증서의 SCT
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||||
```
|
||||
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||||
```
|
||||
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||||
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||||
```
|
||||
|
||||
**보정한다** — `(ts)` 값에서 106초를 뺀다.
|
||||
|
||||
```
|
||||
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
|
||||
12:27:50 훅 nginx -t ← 로그 21:29:36 KST(ts) − 106초
|
||||
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST(ts) − 106초
|
||||
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST(ts) − 106초
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **발급에서 서빙까지 1~2초.**
|
||||
|
||||
### 보정이 자기 검증된다
|
||||
|
||||
**독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 정확히 놓인다.**
|
||||
|
||||
**보정하지 않으면 어떻게 되나** — 훅 로그 `12:29:36 (ts→UTC)` 에서 SCT
|
||||
`12:27:49` 를 빼면 **+107초**, 즉 **훅이 발급보다 104초 먼저 실행된 것**이 된다.
|
||||
**물리적으로 불가능하다.**
|
||||
|
||||
> **음수 지연이 나오면 계산이 아니라 시계를 의심한다.** 그리고 그 의심을
|
||||
> 가르는 것은 **제3의 시계**다 — 여기서는 CT 로그의 SCT 였다.
|
||||
|
||||
### ★ `notBefore` 로는 계산하지 않는다
|
||||
|
||||
인증서에는 `notBefore=Sep 4 11:29:18` 이라고 적혀 있다. **이건 발급 시각이
|
||||
아니다.**
|
||||
|
||||
**Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다** — 클라이언트
|
||||
시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다.
|
||||
|
||||
그리고 한 시간을 더한 값(`12:29:18`)을 발급 시각으로 그대로 쓰지도 않는다.
|
||||
이 실험대의 두 인증서에서 **SCT 는 그보다 일관되게 약 89초 앞섰다.**
|
||||
|
||||
| 인증서 | `notBefore` | `notBefore` + 1시간 | SCT | 차이 |
|
||||
|---|---|---|---|---|
|
||||
| D-4 이전 것 | `Sep 3 00:47:23` | `01:47:23` | `01:45:53.18` | 약 89.8초 |
|
||||
| D-4a 새것 | `Sep 4 11:29:18` | `12:29:18` | `12:27:49.05` | 약 88.9초 |
|
||||
|
||||
**이 차이의 원인은 이 실험이 규명하지 않았다.** 다만 **시각의 기준으로는
|
||||
SCT 를 쓴다** — 그것이 보정을 자기 검증한 값이기 때문이다.
|
||||
|
||||
**`notBefore` 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.**
|
||||
|
||||
## 4-6. D-4 와의 대조
|
||||
|
||||
| | 훅 없음 (D-4) | **훅 있음 (D-4a)** |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** |
|
||||
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | **certbot deploy 훅** |
|
||||
| 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 |
|
||||
| 차이 | | **약 1150배** |
|
||||
|
||||
**바뀐 것은 파일 하나, 두 줄이다.**
|
||||
|
||||
## 4-7. 부수 정정 — D-4 의 2199초는 틀렸다
|
||||
|
||||
**이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다.**
|
||||
|
||||
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||||
(**test-server 시계**)과 일련번호 관측(**dev 시계**)을 **그대로 뺀** 값이었다.
|
||||
|
||||
| | 시각 (실제 UTC) |
|
||||
|---|---|
|
||||
| 새 인증서 디스크 기록 | **08:20:27** ← mtime `17:22:13 KST (ts)` − 106초 |
|
||||
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
|
||||
| **공백** | **2305초 = 38분 25초** |
|
||||
|
||||
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지
|
||||
> 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만,
|
||||
> **1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구 — 훅은 남긴다
|
||||
|
||||
## 5-1. 남기는 이유
|
||||
|
||||
**이 주입은 고장이 아니라 고침이다.** 지우면 D-4 의 상태로 돌아가고,
|
||||
그 결함은 **다음 실제 갱신(약 89일 뒤)에 인증서 만료로** 나타난다.
|
||||
|
||||
정말 지워야 한다면:
|
||||
```bash
|
||||
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
|
||||
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
|
||||
```
|
||||
**어디를 봐야 하는가** — 다시 `total 8`.
|
||||
|
||||
## 5-2. 남은 미검증 — 타이머가 스스로 갱신하는 경로
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 |
|
||||
|
||||
훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는 시간이
|
||||
지나야 시험할 수 있다. 다만 그 경로도 **같은 `certbot renew` 를 부르고 같은
|
||||
`deploy/` 훅을 실행**하므로 남은 미지수는 「타이머가 뜨는가」 하나이고,
|
||||
그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
|
||||
|
||||
**그날이 오면 무엇을 볼 것인가** — 두 줄이면 된다.
|
||||
|
||||
```bash
|
||||
ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -serial -enddate
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 워커 `lstart` 가 **갱신 시각 근처인가**, 그리고
|
||||
`notAfter` 가 밀렸는가. **문구가 아니라 이 둘이다.**
|
||||
|
||||
## 5-3. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 이렇게 되어 있어야 한다 |
|
||||
|---|---|---|
|
||||
| 훅 | `sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/` | `-rwxr-xr-x … reload-nginx.sh` **(남긴다)** |
|
||||
| nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 새것 |
|
||||
| 서빙 인증서 | `openssl … -serial -dates` | 4-4 의 새 일련번호 |
|
||||
| 체인 | D-4 1-2 | 4단계, `Verify return code: 0` |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
| 임시 파일 | `ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt'` | 지워도 된다. 훅은 `/etc` 에 설치됐다 |
|
||||
| 발급 한도 | — | 이번 주에 몇 장 썼는지 세어 둔다 |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `ran with error output` 을 보고 실패로 판단했다 | **stderr 에 뭔가 있으면 무조건 붙는 문구다** | **워커 PID** — 4-3 |
|
||||
| 훅이 아예 안 불렸다 | `x` 비트가 없거나 `deploy/` 가 아니다 | `sudo ls -l …/deploy/` — 3-1 |
|
||||
| 훅은 돌았는데 워커가 그대로 | `nginx -t` 가 실패해 `&&` 뒤가 안 돌았다 | 훅을 손으로 실행 — 3-1 |
|
||||
| 워커도 마스터도 바뀌었다 | reload 가 아니라 **재시작**됐다 | `lstart` 두 줄을 본다 |
|
||||
| 지연이 음수로 나온다 | **두 시계를 그대로 뺐다** | 1-4 로 돌아간다 |
|
||||
| 발급 시각이 한 시간 어긋난다 | **`notBefore` 를 발급 시각으로 읽었다** | SCT 를 본다 — 4-5 |
|
||||
| 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 — 1-4 |
|
||||
| 호스트 명령이 조용히 빈 결과 | **sudo 가 비밀번호를 못 물었다** | `ssh -t` 로 다시 |
|
||||
| 훅 로그를 `/tmp` 에 썼는데 안 보인다 | **`certbot-renew.service` 는 `PrivateTmp=true`** | `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다(**미검증**) |
|
||||
| nginx 경고가 계속 거슬린다 | `types_hash_max_size` 기본값 | 갱신과 무관하다. 고치려면 `nginx.conf` 를 손본다 |
|
||||
|
||||
---
|
||||
|
||||
# 이 실험이 남기는 한 문장
|
||||
|
||||
**처방을 적었으면 시험한다.**
|
||||
|
||||
D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었다. **그 처방이 듣는지
|
||||
확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다.** 그런데 확인하지 않은
|
||||
채로 문서에 남았다면, 「고치는 법」 항목은 **다음 갱신일까지 아무도 시험하지
|
||||
않은 문장**으로 남았을 것이다 — 그리고 그날이 바로 시험할 수 없는 날이다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | D-4a 가 남긴 것 |
|
||||
|---|---|
|
||||
| [D-4](d4-certificate-renewal.md) 인증서 갱신 | **공백 수치가 2305초로 정정됐다** — 4-7 |
|
||||
| [D-3](d3-secret-management.md) 비밀 관리 | 새 `privkey2.pem` 도 같은 문제를 안고 있다 |
|
||||
| [04-TLS](../04-tls/) 구축 단계 | 이 훅은 **구축 절차에 들어가야 한다.** 사후에 붙이는 것이 아니다 |
|
||||
| 관측 | **밖에서 `notAfter` 를 재는 감시**가 로그 감시보다 정직하다 |
|
||||
| 전부 | **판정 기준은 문구가 아니라 상태다.** 여기서는 워커 PID 였다 |
|
||||
| 전부 | **1~2초를 재려면 시계부터 잰다.** 106초는 그 자체로 결론을 뒤집는다 |
|
||||
@@ -0,0 +1,482 @@
|
||||
# 실험대 가상화 계층 — 실측 기록
|
||||
|
||||
## 이 문서가 무엇인가
|
||||
|
||||
[`docs/guides/`](guides/) 가 **「무엇을 어떤 순서로 치는가」** 를 적는다면, 이
|
||||
문서는 **「그때 실제로 어떤 값이 나왔는가」** 를 적는다. 가이드에 이미 있는
|
||||
절차는 반복하지 않는다.
|
||||
|
||||
여기 적힌 숫자는 전부 **2026-09-10 에 `test-server` 에서 실제로 돌려 받은
|
||||
출력**이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||||
|
||||
| 이 문서가 답하는 것 | 가이드가 답하는 것 |
|
||||
|---|---|
|
||||
| 5120MB 를 줬는데 실제로 얼마를 쓰나 | 메모리를 얼마로 주나 |
|
||||
| 20GB 오버레이가 디스크를 얼마나 먹나 | 오버레이를 어떻게 만드나 |
|
||||
| cloud-init 이 몇 초 걸리나 | cloud-init 을 어떻게 쓰나 |
|
||||
| 철거하면 무엇이 남나 | 무엇을 세우나 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 측정 환경
|
||||
|
||||
```bash
|
||||
lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"
|
||||
free -m | head -2
|
||||
df -h /
|
||||
virsh --version; qemu-system-x86_64 --version | head -1; uname -r
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
CPU(s): 8
|
||||
Model name: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
|
||||
Thread(s) per core: 2
|
||||
Core(s) per socket: 4
|
||||
|
||||
total used free shared buff/cache available
|
||||
Mem: 11648 5642 2599 4 3776 6005
|
||||
|
||||
/dev/nvme0n1p3 226G 9.9G 204G 5% /
|
||||
|
||||
12.7.0
|
||||
QEMU emulator version 11.1.1
|
||||
7.2.2-arch1-1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Core(s) per socket` 4 에 `Thread(s) per core` 2 라
|
||||
논리 코어가 8 이다. **VM 에 주는 vCPU 는 물리 코어가 아니라 논리 코어를 나눠
|
||||
쓰는 것**이고, 합이 8 을 넘어도 libvirt 는 막지 않는다(오버커밋). 이 실험대는
|
||||
`2 + 2 + 1 = 5` 로 잡아 여유를 뒀다.
|
||||
|
||||
`free` 의 `available`(6005MB)이 `free`(2599MB)보다 훨씬 큰 것이 정상이다 —
|
||||
`buff/cache` 3776MB 는 필요하면 회수된다. **VM 을 얼마나 더 띄울 수 있는지는
|
||||
`free` 가 아니라 `available` 로 본다.**
|
||||
|
||||
### 중첩 가상화
|
||||
|
||||
```bash
|
||||
lscpu | grep Virtualization
|
||||
cat /sys/module/kvm_intel/parameters/nested
|
||||
```
|
||||
|
||||
```
|
||||
Virtualization: VT-x
|
||||
Y
|
||||
```
|
||||
|
||||
**켜져 있지만 이 실험대는 쓰지 않는다.** 일회용으로 만들려는 층(게스트)은
|
||||
이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다.
|
||||
그리고 A-6 의 지연 주입처럼 **시간을 재는 실험**에서 중첩은 측정값을 왜곡한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 자원 — 할당과 실사용은 다르다
|
||||
|
||||
VM 에 준 메모리는 **상한이지 점유가 아니다.** virtio-balloon 이 안 쓰는 만큼
|
||||
호스트에 돌려준다.
|
||||
|
||||
```bash
|
||||
for v in kc-lab-1 kc-lab-2; do
|
||||
printf "%-10s 할당 %sMB 실사용 %sMB\n" "$v" \
|
||||
"$(( $(virsh dommemstat $v | awk '/actual/{print $2}') / 1024 ))" \
|
||||
"$(( ($(virsh dommemstat $v | awk '/actual/{print $2}') \
|
||||
- $(virsh dommemstat $v | awk '/^unused/{print $2}')) / 1024 ))"
|
||||
done
|
||||
```
|
||||
|
||||
**실측** — k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태
|
||||
|
||||
```
|
||||
kc-lab-1 할당 5120MB 실사용 353MB
|
||||
kc-lab-2 할당 3120MB 실사용 301MB
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 가지다.
|
||||
|
||||
① **실사용이 할당의 7% 밖에 안 된다.** k3s server 가 353MB 다. 「5GB 를 줬으니
|
||||
5GB 를 쓴다」가 아니다.
|
||||
|
||||
② **`kc-lab-2` 의 할당이 4096 이 아니라 3120 이다.** `virt-install --memory 4096`
|
||||
으로 만들었는데 줄어 있다. virtio-balloon 이 회수해 간 것으로 보인다.
|
||||
`dommemstat` 의 `actual` 은 **현재 할당**이지 선언한 상한이 아니다. 상한은
|
||||
`virsh dominfo` 의 `Max memory` 에 있다 — **다만 이 실험대에서 그 값을 나란히
|
||||
찍어 보지는 않았다(미측정).** 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — 합계 8240MB 를 할당했지만 실제 점유는 654MB 다.
|
||||
그래서 11.6GB 짜리 호스트에서 VM 세 대가 무리 없이 돈다. **다만 이것은 지금
|
||||
k3s 만 떠 있어서다** — Keycloak 2 파드 + PostgreSQL + Redis + Prometheus 가
|
||||
올라가면 늘어난다. 그 시점의 값은 **미측정**이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 디스크 — 오버레이는 얼마나 쓰나
|
||||
|
||||
게스트 디스크는 `base.qcow2` 위의 **오버레이**다. 20GB 를 두 개 만들어도
|
||||
바닥 이미지는 한 벌이고 변경분만 쌓인다.
|
||||
|
||||
```bash
|
||||
qemu-img info /var/lib/libvirt/images/base.qcow2 | head -4
|
||||
ls -l /var/lib/libvirt/images/
|
||||
```
|
||||
|
||||
**실측** — 엣지를 만들기 **전**, k3s 2 노드만 있던 시점이다.
|
||||
|
||||
```
|
||||
image: /var/lib/libvirt/images/base.qcow2
|
||||
file format: qcow2
|
||||
virtual size: 3 GiB (3221225472 bytes)
|
||||
disk size: 335 MiB
|
||||
|
||||
-rw-r--r-- base.qcow2 351404032 (335 MiB)
|
||||
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
|
||||
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
|
||||
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
|
||||
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `base.qcow2` 의 `virtual size` 3GiB 와 `disk size`
|
||||
335MiB 의 차이. **그리고 게스트 디스크가 20GB 로 선언됐는데 1.4GB 와 665MB
|
||||
라는 것.** k3s server 가 agent 보다 두 배 이상 쓰는 것은 컨트롤 플레인
|
||||
바이너리와 SQLite 때문이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 20GB × 2 = 40GB 를 선언했지만 실제로는 2.1GB 를
|
||||
썼다. 디스크는 병목이 아니다. **`df` 로 본 사용량과 VM 에 선언한 크기를 같은
|
||||
것으로 보면 안 된다.**
|
||||
|
||||
### 스토리지 풀
|
||||
|
||||
```bash
|
||||
virsh pool-info default
|
||||
```
|
||||
|
||||
```
|
||||
Name: default
|
||||
State: running
|
||||
Persistent: yes Autostart: yes
|
||||
Capacity: 225.31 GiB
|
||||
Allocation: 7.84 GiB
|
||||
Available: 217.46 GiB
|
||||
```
|
||||
|
||||
`Allocation` 은 **풀 전체**(호스트 루트 파일시스템)의 사용량이지 VM 만의
|
||||
사용량이 아니다. VM 이 얼마를 쓰는지는 위 `ls -l` 로 본다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 부팅 — cloud-init 은 얼마나 걸리나
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 30); do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=4 kc-lab-edge 'cloud-init status' 2>/dev/null \
|
||||
| grep -q 'status: done' && { echo "완료 (약 $((i*10))초)"; break; }
|
||||
sleep 10
|
||||
done
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
**실측** — `package_update: true` 에 패키지 5개(`curl` `nftables` `nginx`
|
||||
`certbot` `python3-certbot-dns-cloudflare`)를 받는 게스트
|
||||
|
||||
```
|
||||
완료 (약 50초)
|
||||
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `cloud-init status` 의 세 상태를 구분한다.
|
||||
|
||||
| 값 | 뜻 |
|
||||
|---|---|
|
||||
| `running` | 아직 진행 중. **기다린다** |
|
||||
| `done` | 끝났다 |
|
||||
| `error` | 실패. `cloud-init status --long` 으로 어느 모듈인지 본다 |
|
||||
|
||||
**이 결과가 의미하는 것** — SSH 가 붙는 것과 cloud-init 이 끝난 것은 다르다.
|
||||
SSH 는 먼저 열리고 패키지 설치는 뒤에 이어진다. **`done` 을 안 기다리고 다음
|
||||
단계를 치면 「방금 깐 패키지가 없다」가 나온다.**
|
||||
|
||||
---
|
||||
|
||||
## 5. 네트워크 — DHCP 예약의 실제 동작
|
||||
|
||||
```bash
|
||||
virsh net-update default add ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||||
--live --config
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
Updated network default persistent config and live state
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`persistent config` 와 `live state` 두 마디가 다
|
||||
나오는가.** `--live` 만 주면 앞의 것이, `--config` 만 주면 뒤의 것이 빠진다.
|
||||
한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.
|
||||
|
||||
### 예약을 먼저, VM 을 나중에
|
||||
|
||||
이 실험대는 **예약을 넣고 나서 `virt-install`** 했고, 게스트가 첫 부팅에서
|
||||
바로 `.10` 을 받았다.
|
||||
|
||||
```
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
```
|
||||
|
||||
순서가 반대면 게스트가 동적 대역(`192.168.122.2`–`.254`)에서 아무 주소나 받고,
|
||||
예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.
|
||||
|
||||
### 리스는 예약과 별개로 남는다
|
||||
|
||||
```bash
|
||||
virsh net-dhcp-leases default
|
||||
```
|
||||
|
||||
```
|
||||
Expiry Time MAC address IP address Hostname
|
||||
2026-09-10 09:59:54 52:54:00:aa:bb:11 192.168.122.11/24 kc-lab-1
|
||||
2026-09-10 09:58:46 52:54:00:aa:bb:12 192.168.122.12/24 kc-lab-2
|
||||
```
|
||||
|
||||
`net-dumpxml` 의 예약은 **줄 의도**이고, `net-dhcp-leases` 는 **실제로 준
|
||||
기록**이다. 둘이 다를 수 있다.
|
||||
|
||||
### virbr0 는 게스트가 없으면 내려간다
|
||||
|
||||
```bash
|
||||
ip -br addr show virbr0
|
||||
```
|
||||
|
||||
VM 세 대가 돌 때:
|
||||
```
|
||||
virbr0 UP 192.168.122.1/24
|
||||
```
|
||||
|
||||
전부 철거한 뒤:
|
||||
```
|
||||
virbr0 DOWN 192.168.122.1/24
|
||||
```
|
||||
|
||||
**주소는 그대로 있고 상태만 `DOWN` 이다.** 브리지에 붙은 tap 인터페이스가
|
||||
하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 **VM 을 다시 띄우면
|
||||
자동으로 `UP` 이 된다.** 이걸 고장으로 오독해 `virsh net-start` 를 찾아
|
||||
헤매지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 철거 — 실제 출력 전문
|
||||
|
||||
가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.
|
||||
|
||||
### 게스트
|
||||
|
||||
```bash
|
||||
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
|
||||
virsh destroy "$v"
|
||||
virsh undefine "$v" --remove-all-storage
|
||||
done
|
||||
```
|
||||
|
||||
**실측** (한 대분)
|
||||
|
||||
```
|
||||
Domain 'kc-lab-edge' destroyed
|
||||
Domain 'kc-lab-edge' has been undefined
|
||||
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
|
||||
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Volume` 줄이 **두 개** 나오는가. `vda`(오버레이
|
||||
디스크)와 `vdb`(시드 ISO)다. `--remove-all-storage` 를 빠뜨리면 도메인만
|
||||
사라지고 **디스크 파일이 남아 다음 `virt-install` 이 「이미 있다」로 실패**한다.
|
||||
|
||||
`destroy` 는 종료 신호가 아니라 **전원을 뽑는 것**이다. 정상 종료를 원하면
|
||||
`virsh shutdown` 을 쓰고 꺼질 때까지 기다린다.
|
||||
|
||||
### DHCP 예약
|
||||
|
||||
```bash
|
||||
virsh net-update default delete ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||||
--live --config
|
||||
```
|
||||
|
||||
**★ 삭제할 때도 `mac`·`name`·`ip` 세 속성을 다 준다.** 하나라도 비면 이렇게
|
||||
거부된다.
|
||||
|
||||
```
|
||||
error: Failed to update network default
|
||||
error: XML error: Cannot use host name '' in network 'default'
|
||||
```
|
||||
|
||||
> **zsh 에서 루프로 돌리면 이 오류를 만난다.** zsh 는 따옴표 없는 변수를
|
||||
> **단어 분리하지 않는다.** bash 에서 되던 `set -- $entry` 가 zsh 에서는
|
||||
> `$1` 에 문자열 전체를 넣고 `$2`·`$3` 을 비운다. 그래서 `name=""` 이 된다.
|
||||
> 세 줄을 값 그대로 쓰는 편이 안전하다.
|
||||
|
||||
**철거 후**
|
||||
|
||||
```
|
||||
### 남은 예약
|
||||
(없음)
|
||||
|
||||
### dhcp 블록
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
</dhcp>
|
||||
```
|
||||
|
||||
동적 대역만 남는 것이 정상이다.
|
||||
|
||||
### 철거 전후 비교 — 실측
|
||||
|
||||
| | 철거 전 | 철거 후 |
|
||||
|---|---|---|
|
||||
| `virsh list --all` | 3 대 running | (없음) |
|
||||
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
|
||||
| DHCP 예약 | 3 줄 | 0 줄 |
|
||||
| `df -h /` | 11G | **7.9G** |
|
||||
| `virbr0` | UP | DOWN |
|
||||
|
||||
**3.1GB 가 회수됐다.** 내역은 `kc-lab-1` 1.4GB + `kc-lab-2` 665MB + 시드 ISO
|
||||
3개(각 370KB)이고, **`kc-lab-edge` 의 디스크 크기는 재 두지 않았다(미측정)** —
|
||||
합계에서 역산하면 1GB 안팎이다.
|
||||
|
||||
`base.qcow2` 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 실측으로 드러난 함정 셋
|
||||
|
||||
전부 **아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는** 유형이다.
|
||||
|
||||
### ① cloud-init `sudo` 는 리스트가 아니라 문자열
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'
|
||||
```
|
||||
|
||||
리스트 형태(`sudo: ['ALL=(ALL) NOPASSWD:ALL']`)일 때:
|
||||
|
||||
```
|
||||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
```
|
||||
|
||||
문자열 형태(`sudo: "ALL=(ALL) NOPASSWD:ALL"`)일 때:
|
||||
|
||||
```
|
||||
Valid cloud-config: /home/donghyeon/chk.yaml
|
||||
```
|
||||
|
||||
**어느 키가 문제인지 안 알려 준다.** `users.0` 블록을 통째로 찍고 「어느
|
||||
스키마에도 안 맞는다」고만 한다. 그리고 **리스트 형태도 부팅은 된다** —
|
||||
`kc-lab-1`·`kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만
|
||||
거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.
|
||||
|
||||
게스트 cloud-init 버전: `22.4.2` (Debian 12 genericcloud).
|
||||
|
||||
### ② nginx `http2 on;` 은 배포판에 따라 없다
|
||||
|
||||
```
|
||||
엣지 (Debian 12): nginx version: nginx/1.22.1
|
||||
lab host (Arch): nginx version: nginx/1.30.4
|
||||
```
|
||||
|
||||
`http2 on;` 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로
|
||||
그대로 옮기면:
|
||||
|
||||
```
|
||||
[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
|
||||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
```
|
||||
|
||||
`listen 443 ssl http2;` 형태는 1.22 와 1.30 양쪽에서 다 돈다.
|
||||
|
||||
### ③ Debian 기본 사이트가 `default_server` 를 먹고 있다
|
||||
|
||||
Debian 계열은 `/etc/nginx/sites-enabled/default` 가 처음부터 붙어 있고 `:80`
|
||||
에 `default_server` 로 선언돼 있다. 실험대 설정의 `listen 80 default_server`
|
||||
와 **충돌한다.** 심볼릭 링크를 걸 때 같이 지운다.
|
||||
|
||||
```bash
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
```
|
||||
|
||||
Arch 는 `sites-available` 관례 자체가 없어서 이 함정이 없는 대신, `nginx.conf`
|
||||
에 `include /etc/nginx/sites-enabled/*;` 를 직접 넣어야 한다. **배포판이 바뀌면
|
||||
함정도 바뀐다.**
|
||||
|
||||
---
|
||||
|
||||
## 8. 재구축할 때 무엇이 남아 있나
|
||||
|
||||
철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜
|
||||
이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
|
||||
|
||||
| | 상태 | 왜 |
|
||||
|---|---|---|
|
||||
| `base.qcow2` (335MB) | **남는다** | 다음 오버레이의 바닥 |
|
||||
| 패키지 (libvirt·qemu·nginx·certbot·kubectl) | **남는다** | 재설치가 무의미 |
|
||||
| `~/workspace/cloud/kc-lab-{1,2}.yaml` | **남는다** | 키와 비밀번호가 들어 있다 |
|
||||
| `~/.ssh/config` 의 `kc-lab-*` 항목 | **남는다** | 재구축해도 IP 가 같다 |
|
||||
| libvirt `default` 네트워크 정의 | **남는다** | 예약만 지웠다 |
|
||||
| `/etc/letsencrypt/` | **남긴다** (정책) | 아래 참고 |
|
||||
| 게스트 디스크·시드 ISO | 사라진다 | `--remove-all-storage` |
|
||||
| DHCP 예약 | 사라진다 | `net-update delete` |
|
||||
| k3s·Keycloak·모든 워크로드 | 사라진다 | 게스트와 함께 |
|
||||
|
||||
### 인증서를 지우지 않는 이유
|
||||
|
||||
Let's Encrypt 는 **같은 이름 조합에 대해 주당 중복 인증서 5장** 제한이 있다.
|
||||
그리고 이 실험대의 이름 셋은 tailnet 주소를 가리킨다.
|
||||
|
||||
```bash
|
||||
dig +short auth.hyeonworks.com
|
||||
```
|
||||
```
|
||||
100.83.212.4
|
||||
```
|
||||
|
||||
`100.64.0.0/10` 은 CGNAT 용 예약 대역이라 **공개 인터넷에서 라우팅되지
|
||||
않는다.** HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로,
|
||||
이 주소로는 검증이 성립하지 않는다. **지우면 되살리기 전에 검증 방식 문제부터
|
||||
풀어야 한다.** 백업만 뜨고 파일은 남긴다.
|
||||
|
||||
```bash
|
||||
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
|
||||
```
|
||||
|
||||
복원은 반대로 한 줄이다.
|
||||
|
||||
```bash
|
||||
sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
|
||||
```
|
||||
|
||||
**미측정** — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지
|
||||
않았다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
|
||||
다음 한 줄이 답이다.
|
||||
|
||||
```bash
|
||||
sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf
|
||||
```
|
||||
|
||||
`webroot`·`standalone` 이면 HTTP-01 이라 위 문제가 실재하고, `dns-cloudflare`
|
||||
면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려
|
||||
있다 — `certbot plugins` 에 `dns-cloudflare` 가 보인다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
| 문서 | 무엇 |
|
||||
|---|---|
|
||||
| [`guides/01-vms/`](guides/01-vms/) | VM 세 대를 세우는 절차 |
|
||||
| [`guides/00-lab-host/`](guides/00-lab-host/) | 호스트 가상화 준비 |
|
||||
| [`session-lab-concepts.md`](session-lab-concepts.md) | 여기 나온 개념의 정의 |
|
||||
| [`deploy/lab/host/teardown-host.sh`](../deploy/lab/host/teardown-host.sh) | 호스트 계층 철거 |
|
||||