docs(keycloak-session-store): research the 42 concepts the lab used and write them up

Replaces the to-do list with the work itself. Eight layers, 42 concepts,
1200 lines, each with what it is, why it turns up here, how it fails, and
the command to check it.

Most values were read off the running system rather than recalled:

  conntrack ESTABLISHED timeout   86400s — why A-1's injection sat unmatched
                                  for 25 minutes was normal, not a fault
  FORWARD chain position 1        KUBE-ROUTER-FORWARD — why -I FORWARD 1
                                  counted zero packets
  cgroup version                  v2, and the numbers in systemctl status
                                  are read straight out of those files
  nginx restart policy            on-failure, 100ms, and it gives up after
                                  5 failures in 10 seconds
  Type and KillMode               five units on this host, four different
                                  combinations

Two facts could not be read locally and carry sources: Let's Encrypt
backdates notBefore by exactly one hour to tolerate client clock skew, and
Keycloak invalidates the whole SSO session on refresh token reuse. The
second one explains B-3 — the winning request's new token was not itself
rejected, the session it belonged to had just been deleted.

The systemd layer also explains the one CGroup line in systemctl status that
D-4 spent ps commands establishing: master 585 kept, worker replaced.

One item is marked as read rather than measured. Restart=on-failure comes
from the unit file; nginx has not been killed to watch it come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 15:32:54 +09:00
co-authored by Claude Opus 5
parent f6c825e858
commit fc23660871
2 changed files with 77 additions and 78 deletions
+76 -77
View File
@@ -236,7 +236,7 @@ Keycloak 이 트랜잭션마다 `SET LOCAL synchronous_commit TO OFF` 를 걸기
COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그 사이(측정된
`wal_writer_delay` 200ms)에 프로세스가 죽으면 그만큼이 사라진다.
의도된 설계이므로, 이 실험은 그 대가가 얼마인지 숫자로 확인했을 뿐이다.
의도된 설계이므로, 이 실험은 그 대가가 얼마인지 숫자로 확인했다.
![200 과 디스크 사이의 빈 구간](assets/a3-commit-to-disk-gap/a3-commit-to-disk-gap.svg)
@@ -903,12 +903,32 @@ lint 가 잡아낸 것 중 사람이 놓치기 쉬운 것 둘을 적어 둔다.
## 실험대가 쓴 개념 — 조사한 것
26건을 돌리면서 쓴 용어와, 그 용어를 실제로 설명한 곳을 대조했다.
**이름만 쓰고 넘어간 것이 24개**였다. `conntrack` 은 실험 문서에 35번,
**이름만 쓰고 넘어간 것이 24개**나 됐는데, `conntrack` 은 실험 문서에 35번,
`refresh token rotation` 은 17번, `JWKS``Liquibase` 는 각각 11번 나오는데
무엇인지 설명한 곳이 없었다.
여기서 그것들을 조사해 채운다. 시스템에서 확인할 수 있는 것은 확인했고,
확인할 수 없는 외부 사실 출처를 달았다.
여기서 그것들을 조사해 채우되, 시스템에서 확인할 수 있는 것은 확인하고
확인할 수 없는 외부 사실에는 출처를 달았다.
### 여덟 층이 받치는 것
이 프로젝트에는 주제 여섯과 글감 서른셋으로 분해 계약이 서 있다
([`tech-log-studio/tech-log-tree.json`](../tech-log-studio/tech-log-tree.json)).
위 여덟 층은 그것과 나란한 별개 구조가 아니라 **각 주제가 서 있는 바닥**이다.
| 계약의 주제 | 그 아래에 깔린 층 |
|---|---|
| `session-custody-across-nodes` | 5층 — 디스커버리 대 트랜스포트, 세션 두 겹 |
| `losing-a-node-or-the-store` | 1층 전체 · 3층(WAL·`synchronous_commit`) · 4층(축출 타이머) |
| `where-application-state-lives` | 6층 전체 · 5층(refresh token rotation) |
| `trust-handed-over-at-the-edge` | 2층(conntrack·netfilter) · 7층(oauth2-proxy 티켓) |
| `operations-that-report-success` | 1층(systemd 일체) · 7층(SCT·백데이트) · 3층(Liquibase) |
| `when-the-measurement-lies` | 8층 전체 |
`operations-that-report-success` 의 글감 둘(「새 인증서가 디스크에 있고
38분 25초 동안 옛 인증서가 나갔다」, 「deploy 훅 하나가 그 공백을 1~2초로
줄였다」)은 **systemd 를 설명하지 않고는 쓸 수 없는데**, 1층이 그 바닥을 채운다.
---
@@ -936,7 +956,7 @@ lint 가 잡아낸 것 중 사람이 놓치기 쉬운 것 둘을 적어 둔다.
있는지 모르면, D-4 처럼 「타이머는 도는데 아무 일도 안 일어나는」 상태를
88일 동안 못 본다.
**확인.** 두 명령이 다른 것을 보여 준다.
**확인.** 아래 두 명령이 서로 다른 것을 보여 준다.
```bash
systemctl cat nginx # 파일에 적힌 것
@@ -949,8 +969,8 @@ systemctl show nginx # 기본값까지 합쳐 실제로 적용되는 것
#### `Type=` — systemd 가 「떴다」고 판단하는 방식
**무엇인가.** 시작이 끝난 시점을 어떻게 아는지한다. 이 호스트에서
실제로 쓰이는 값이 넷이다.
**무엇인가.** 시작이 끝난 시점을 systemd 가 어떻게 아는지 정하며, 이
호스트에서만 네 가지 값이 쓰인다.
| Type | 언제 「떴다」고 보나 | 이 호스트에서 |
|---|---|---|
@@ -982,7 +1002,9 @@ systemctl show nginx -p Type -p MainPID -p PIDFile --value
#### `Restart=` — 죽으면 어떻게 되는가
호스트 nginx 가 죽으면 어떻게 되는지가 이 한 줄에 있다.
**왜 여기 나오나.** 호스트 nginx 가 죽으면 어떻게 되는지가 이 한 줄
정해진다. 이 실험대에는 진입점이 하나뿐이라, 그것이 스스로 살아나는지가
전체 가용성의 마지막 방어선이다.
```
Restart=on-failure RestartUSec=100ms
@@ -1019,7 +1041,7 @@ systemctl is-failed nginx # failed 면 상한에 걸렸을 수 있다
> **이것은 설정을 읽은 것이지 측정한 것이 아니다.** 이 실험대가 스물여섯 번
> 배운 것이 「설정이 그렇다고 그렇게 동작하지는 않는다」이므로, 실제로
> 죽여 봐야 안다. 아직 하지 않았다.
> 죽여 봐야 아는데, 아직 하지 않았다.
#### `KillMode=` · `KillSignal=` — 멈출 때
@@ -1032,14 +1054,14 @@ systemctl is-failed nginx # failed 면 상한에 걸렸을 수 있다
| `mixed` | 주 프로세스에 먼저, 남으면 그룹 전체에 SIGKILL | **nginx** |
| `process` | 주 프로세스만 | sshd · libvirtd |
nginx 는 `KillSignal=SIGQUIT` 이다. **nginx 에서 SIGQUIT 은 graceful
nginx 는 `KillSignal=SIGQUIT` 을 쓰는데, **nginx 에서 SIGQUIT 은 graceful
shutdown** — 진행 중 요청을 끝내고 종료하라는 뜻이고, SIGTERM(즉시 종료)과
다르다. `mixed` 와 짝이 되어 「마스터에게 곱게 끝내라고 하고, 5초
(`TimeoutStopSec=5`) 안에 안 끝나면 그룹 전체를 SIGKILL」이 된다.
**왜 여기 나오나.** D-4a 에서 잰 reload 무중단(진행 중이던 42초 요청이
845361바이트를 온전히 받았다)과 **같은 성질이 종료에도 걸려 있다.**
다만 종료 쪽은 재보지 않았다.
845361바이트를 온전히 받았다)과 **같은 성질이 종료에도 걸려 있는데**, 종료
쪽은 재보지 않았다.
**확인.**
```bash
@@ -1053,7 +1075,7 @@ systemctl show nginx -p KillMode -p KillSignal -p TimeoutStopUSec --value
#### cgroup v2 — 프로세스를 묶어 재고 제한한다
**무엇인가.** 커널이 프로세스를 계층 구조로 묶어 **자원을 측정하고 제한하는**
기능이다. 이 호스트는 v2(통합 계층)를 쓴다.
기능이며, 이 호스트는 v2(통합 계층)를 쓴다.
```
$ stat -fc %T /sys/fs/cgroup
@@ -1063,7 +1085,7 @@ cpuset cpu io memory hugetlb pids rdma misc dmem
```
**왜 여기 나오나.** systemd 는 서비스마다 cgroup 을 하나 만들고 그 안에
프로세스를 넣는다. 그래서 `systemctl status`이것을 보여 준다.
프로세스를 넣기 때문에, `systemctl status`그 그룹을 그대로 보여 준다.
```
CGroup: /system.slice/nginx.service
@@ -1071,7 +1093,7 @@ CGroup: /system.slice/nginx.service
└─37252 "nginx: worker process"
```
**D-4 와 바로 연결된다.** 그때 「마스터 PID 유지 + 워커 PID 교체 = reload」를
**D-4 와 바로 이어진다.** 그때 「마스터 PID 유지 + 워커 PID 교체 = reload」를
`ps` 로 판정했는데, 이 블록이 같은 것을 바로 보여 준다 — 마스터 585 는
9월 3일 그대로이고 워커만 37252 로 바뀌어 있다.
@@ -1128,8 +1150,8 @@ cat /sys/fs/cgroup/machine.slice/memory.max # VM 들이 받은 상한
```
#### journald — 로그는 어디로 가나
**무엇인가.** systemd 의 로그 수집기다. 서비스의 stdout·stderr 와 syslog 를
한곳에 모으 **어느 유닛에서 나왔는지**를 메타데이터로 붙인다. 그래서
**무엇인가.** systemd 의 로그 수집기로, 서비스의 stdout·stderr 와 syslog 를
한곳에 모으면서 **어느 유닛에서 나왔는지**를 메타데이터로 붙인다. 그 덕분에
`-u` 로 유닛별 조회가 된다.
```bash
@@ -1154,7 +1176,7 @@ B-7 은 계층을 나눠(traefik 을 직접 불러 nginx 를 우회) 원인을
그렇게 보인다.
**없거나 틀리면.** 파드 로그와 클러스터 지표만 보면 **호스트 계층에서 잘린
요청이 안 보인다.** B-7 의 502 가 그랬다.
요청을 놓친다.** B-7 의 502 가 정확히 그 경우였다.
**확인.**
```bash
@@ -1176,8 +1198,8 @@ systemd /usr/lib/systemd/systemd --switched-root --system --deserialize=56
`kill -9 1` 을 보냈는데 아무 일도 없었다. 컨테이너의 PID 1 이 postmaster 였고,
**자기 네임스페이스 안에서 온 SIGKILL 을 무시**했기 때문이다.
**없거나 틀리면.** 「죽였는데 안 죽었다」를 「영향이 없다」로 읽게 된다.
A-3 의 아홉 실패 중 하나가 이것이었다.
**없거나 틀리면.** 「죽였는데 안 죽었다」를 「영향이 없다」로 읽게 되는데,
A-3 의 아홉 실패 중 하나가 그렇게 생겼다.
**확인.** 백엔드 프로세스를 죽여 postmaster 가 `reinitialize` 하게 만들면
비로소 크래시 복구가 일어난다.
@@ -1188,10 +1210,10 @@ kubectl logs deploy/postgres | grep -i 'not properly shut down\|redo starts'
#### `PrivateTmp=true`
**무엇인가.** 서비스에 **자기만의 `/tmp`**준다. 마운트 네임스페이스를
따로 만들어 다른 프로세스의 `/tmp` 와 격리한다.
**무엇인가.** 서비스에 **자기만의 `/tmp`**주는 설정으로, 마운트
네임스페이스를 따로 만들어 다른 프로세스의 `/tmp` 와 격리한다.
**왜 여기 나오나.** nginx 와 `certbot-renew.service` **양쪽 다 켜져 있다.**
**왜 여기 나오나.** nginx 와 `certbot-renew.service` **양쪽 다 켜져 있어서**,
D-4 에서 certbot 출력을 `/tmp` 로 받아 읽으려 했다면 찾지 못했을 텐데,
실제로는 사람이 대화형으로 실행해 파일이 진짜 `/tmp` 에 떨어졌다.
@@ -1213,8 +1235,8 @@ A층 실험의 주입이 전부 이 층에서 이루어졌고, 아홉 번의 조
#### conntrack — 연결을 기억하는 표
**무엇인가.** 커널이 지나간 연결을 기억하는 표다. 패킷 하나하나를 따로 보지
않고 **어느 연결에 속하는지**를 먼저 판정하며, 그 판정 결과가 `ctstate` 다.
**무엇인가.** 커널이 지나간 연결을 기억하는 표로, 패킷 하나하나를 따로 보지
않고 **어느 연결에 속하는지**를 먼저 판정한다. 그 판정 결과가 `ctstate` 다.
| 상태 | 뜻 |
|---|---|
@@ -1223,7 +1245,7 @@ A층 실험의 주입이 전부 이 층에서 이루어졌고, 아홉 번의 조
| `RELATED` | 기존 연결에 딸린 새 연결 (FTP 데이터 채널 등) |
| `INVALID` | 어디에도 속하지 않음 |
kc-lab-1 실물이다.
kc-lab-1 에서 뽑은 실물이다.
```
$ sudo conntrack -L | awk '{print $4}' | sort | uniq -c
@@ -1234,7 +1256,7 @@ $ sudo conntrack -L | awk '{print $4}' | sort | uniq -c
```
**왜 여기 나오나 — A-1 의 주입이 25분 동안 안 먹은 이유가 이것이다.**
쿠버네티스 규칙이 전부 `NEW` 에만 걸려 있다.
쿠버네티스 규칙이 전부 `NEW` 에만 걸려 있기 때문이다.
```
$ sudo iptables -S FORWARD
@@ -1246,7 +1268,7 @@ $ sudo iptables -S FORWARD
이미 성립한 JGroups 연결의 패킷은 `ESTABLISHED` 라서 **NetworkPolicy 평가
자체에 도달하지 않는다.** 규칙은 정확히 걸렸고 패킷이 그 앞에서 지나갔다.
**얼마나 오래 버티나 — 이 숫자가 답이다.**
**그 표가 얼마나 오래 버티는지는 이 값이 정한다.**
```
$ cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established
@@ -1533,7 +1555,7 @@ kubectl get pod <pod> -o jsonpath='{.spec.tolerations}' # tolerationSeconds
DB 를 잃은 파드가 `CrashLoopBackOff` 로 보였다. **화면이 진실의 역순이었다.**
**왜 여기 나오나.** A-4 에서 화면을 그대로 믿었다면 살아 있는 쪽을 장애로,
죽은 쪽을 정상으로 판단했을 것이다.
죽은 쪽을 정상으로 판단했을 뻔했다.
**없거나 틀리면.** `kubectl get pods` 의 STATUS 를 실시간 진실로 읽는다.
그 값은 **마지막으로 보고된 것**이지 지금 상태가 아니다.
@@ -1606,7 +1628,7 @@ Failed to bind properties under 'spring.data.redis.port' to int:
**확인.**
```bash
kubectl exec <pod> -- env | grep '_PORT=' # tcp:// 로 시작하면 주입된 이다
kubectl exec <pod> -- env | grep '_PORT=' # tcp:// 로 시작하면 주입된 이다
```
---
@@ -1661,8 +1683,8 @@ A-3 에서 RPO 를 잴 때 클라이언트가 받은 `sid` 를 DB 의
보지 않고** 통과한 이유가 이 구조다. 애플리케이션 세션(oauth2-proxy 쿠키)은
죽었지만 SSO 세션은 살아 있어 조용히 재인증됐다.
**없거나 틀리면.** 앱 세션을 지우고 「로그아웃됐다」고 판단한다. SSO 세션
남아 있으면 다음 요청에서 조용히 다시 로그인된다.
**없거나 틀리면.** 앱 세션을 지우고 「로그아웃됐다」고 판단한다. SSO 세션
함께 끝내지 않으면 다음 요청에서 조용히 다시 로그인된다.
**확인.**
```bash
@@ -1694,7 +1716,7 @@ refresh 는 새 access token 에 어떤 스코프를 담을지 다시 계산하
**그래서 로그인은 SQL 0개, refresh 는 1개**가 된다.
**없거나 틀리면.** refresh 가 DB 를 본다는 사실을 모르면 A-7a 의 500 을
세션 문제로 오진한다. 실제로는 스코프 조회가 막힌 것이다.
세션 문제로 오진한다. 실제로는 스코프 조회가 막혀 있다.
**확인.**
```sql
@@ -1737,8 +1759,8 @@ curl -s localhost:9000/metrics | grep vendor_cluster_size
남는 이유가 이것이고, Keycloak 파드에서 앱 URL 로 요청하면 200 이 오므로
네트워크 문제와 구별된다.
**없거나 틀리면.** IdP 쪽만 채우고 「설정했다」로 끝낸다. C-2 에서 그렇게 하면
앱 세션이 그대로 남는 것을 확인했다.
**없거나 틀리면.** IdP 쪽만 채우고 「설정했다」로 끝낸다. C-2 에서 그렇게
해 보니 앱 세션을 아무도 지우지 않았다.
**확인.**
```bash
@@ -1902,6 +1924,9 @@ echo | openssl s_client -connect <host>:443 -servername <host> 2>/dev/null \
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
| `post/` | 갱신 여부와 무관하게 매번 |
**무엇인가.** certbot 이 갱신 과정의 세 시점에 사용자가 넣은 스크립트를
실행해 준다. 셋의 차이는 **실행 조건**이다.
**왜 여기 나오나.** 타이머는 하루 두 번 돈다. `post/` 에 reload 를 넣으면
갱신이 없는 날에도 하루 두 번 워커를 갈아치운다. `deploy/`
`RENEWED_LINEAGE` 가 있을 때만 돌므로 그것이 맞다.
@@ -2014,7 +2039,7 @@ B-7a 가 그 앞을 재서, 프록시가 못 지울 뿐 운영자는 지울 수
생성 시각을 역산해 고아만 골라낼 수 있다는 것을 확인했다.
**없거나 틀리면.** secret 회전을 「사용자 재로그인」 비용으로만 계산한다.
서버 쪽에 지울 수 없는 세션이 는 비용 따로 다.
서버 쪽에 지울 수 없는 세션이 쌓이는 비용 따로 계산해야 한다.
**확인.**
```bash
@@ -2072,7 +2097,7 @@ curl -s 'localhost:19090/api/v1/query?query=up' | jq '.data.result[].value'
# up 만 보지 말고 기능 지표를 함께 본다
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/realms/master
```
#### exporter 패턴과 관측 공백
#### exporter 패턴 — 긁어오지 않으면 보이지 않는다
**무엇인가.** Prometheus 는 대상이 보내오는 것을 받지 않고 **자기가 긁어
온다**(pull). 지표를 내보내지 않는 대상은 exporter 를 앞에 두어야 한다.
@@ -2094,46 +2119,20 @@ curl -s localhost:19090/api/v1/targets | jq -r '.data.activeTargets[].labels.job
---
### 분해 계약과의 대응
### 이 조사가 선 근거
이 프로젝트에는 주제 여섯과 글감 서른셋으로 분해 계약이 서 있다
([`tech-log-studio/tech-log-tree.json`](../tech-log-studio/tech-log-tree.json)).
위 여덟 층은 그것과 나란한 별개 구조가 아니라 **각 주제가 서 있는 바닥**이다.
여기 적은 값은 대부분 시스템에서 직접 읽었다. conntrack 의 24시간
타임아웃도, FORWARD 1번에 앉아 있는 `KUBE-ROUTER-FORWARD` 도, nginx 의
재시작 정책도 kc-lab-1 과 test-server 에서 그대로 뽑은 것이다. 유닛 다섯
개의 `Type``KillMode` 를 나란히 놓은 표도 마찬가지다.
| 계약의 주제 | 그 아래에 깔린 층 |
|---|---|
| `session-custody-across-nodes` | 5층 — 디스커버리 대 트랜스포트, 세션 두 겹 |
| `losing-a-node-or-the-store` | 1층 전체 · 3층(WAL·`synchronous_commit`) · 4층(축출 타이머) |
| `where-application-state-lives` | 6층 전체 · 5층(refresh token rotation) |
| `trust-handed-over-at-the-edge` | 2층(conntrack·netfilter) · 7층(oauth2-proxy 티켓) |
| `operations-that-report-success` | 1층(systemd 일체) · 7층(SCT·백데이트) · 3층(Liquibase) |
| `when-the-measurement-lies` | 8층 전체 |
직접 읽을 수 없는 두 가지는 출처를 달았다 — Let's Encrypt 가 `notBefore`
1시간 앞당기는 이유와, Keycloak 이 refresh token 재사용을 감지하면 SSO 세션
전체를 무효화한다는 규격이다. 뒤엣것이 B-3 의 「이긴 요청의 토큰도 못 쓴다」를
설명한다.
`operations-that-report-success` 의 글감 둘(「새 인증서가 디스크에 있고
38분 25초 동안 옛 인증서가 나갔다」, 「deploy 훅 하나가 그 공백을 1~2초로
줄였다」)은 **systemd 를 설명하지 않고는 쓸 수 없다.** 이제 1층이 그것을
채운다.
### 조사하면서 확인한 것과 확인하지 못한 것
시스템에서 직접 확인한 값은 이렇다.
| 확인한 것 | 값 | 어디서 |
|---|---|---|
| conntrack `ESTABLISHED` 타임아웃 | **86400초** | kc-lab-1 `/proc/sys/net/netfilter/` |
| conntrack 현황 | 311건 / 상한 131072 | `conntrack -C` |
| FORWARD 1번 체인 | `KUBE-ROUTER-FORWARD` | `iptables -S FORWARD` |
| cgroup 버전 | v2 (`cgroup2fs`) | `stat -fc %T /sys/fs/cgroup` |
| nginx 재시작 정책 | `on-failure` · 100ms · 5회/10초 | `systemctl show` |
| 호스트별 `Type`·`KillMode` | 넷이 서로 다름 | `systemctl show` 5개 유닛 |
| PID 1 의 정체 | `systemd --switched-root` | `ps -p 1` |
외부 사실은 출처를 달았다 — Let's Encrypt 의 1시간 백데이트와 그 이유,
Keycloak 이 재사용 감지 시 SSO 세션 전체를 무효화한다는 것.
**아직 재지 않은 것이 하나 남는다.** 1층의 `Restart=on-failure` 는 유닛
파일을 읽어 적은 것이지 nginx 를 죽여 확인한 것이 아니다. 이 기록이 스물여섯
번 배운 것이 「설정이 그렇다고 그렇게 동작하지는 않는다」이므로, 이 항목만은
**설정을 읽었다**고 적어 둔다. 재려면 호스트 sudo 로 마스터 프로세스를
죽이고 100ms 안에 살아나는지, 워커 PID 가 어떻게 바뀌는지, 그동안 외부
요청이 몇 건 떨어지는지를 보면 된다.
**한 항목만은 재지 않았다.** 1층의 `Restart=on-failure` 는 유닛 파일을 읽어
적었지 nginx 를 죽여 확인하지 않았다. 이 기록이 스물여섯 번 배운 것이
「설정이 그렇다고 그렇게 동작하지는다」이므로 그대로 적어 둔다. 재려면
호스트 sudo 로 마스터를 죽이고 100ms 안에 살아나는지, 워커 PID 가 어떻게
바뀌는지, 그동안 외부 요청이 몇 건 떨어지는지를 보면 된다.
@@ -7,7 +7,7 @@
"revision": "cdac9b8178391311d8eca1ebc6cac15bb62d79af",
"verified": "이 커밋이 A-1 부터 D-4a 까지 실험 전량을 병합한 tip 이고 저장소 HEAD 다. git branch --contains 가 experiment 브랜치 26 개를 모두 낸다 (2026-09-07 확인)"
},
"ssotSha256": "dc7c9d1bddb61fc7e48069a34fbb62bc543e3de2bbd39e1a016f5f2ef7770222",
"ssotSha256": "ae0aee375daebc00d72590db8745ff9ef1018911785a80e9fd7b62f893dea19d",
"sourceRevision": "keycloak-session-lab@2026-09",
"generatedAt": "2026-09-07",
"candidateScope": {