Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.
Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.
The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:
A-0 emptying the session table without a restart leaves cache entries
that get counted as replication arriving
A-2 dropping -o /dev/null fuses body and status into one string
A-3 presence of "ready to accept connections" instead of its timestamp
B-2 row count alone reads an UPDATE as nothing having happened
B-4 tr ',' '\n' splits ["admin","editor"] so only admin is seen
B-7 no login screen means the cookie died and SSO re-authenticated
C-1 counting sessions without joining realm counts your own kcadm one
D-1 kubectl exec without -i restores nothing and still exits 0
D-4a "ran with error output" is what success looks like
Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.
Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.
Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
957 lines
40 KiB
Markdown
957 lines
40 KiB
Markdown
# 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 와 외부 응답 코드에 건다 |
|