diff --git a/docs/keycloak-session-store/final/document.md b/docs/keycloak-session-store/final/document.md index cf6a106..4dd0f8b 100644 --- a/docs/keycloak-session-store/final/document.md +++ b/docs/keycloak-session-store/final/document.md @@ -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 -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 -- env | grep '_PORT=' # tcp:// 로 시작하면 주입된 것이다 +kubectl exec -- 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 :443 -servername 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:///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 가 어떻게 +바뀌는지, 그동안 외부 요청이 몇 건 떨어지는지를 보면 된다. diff --git a/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json b/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json index efce160..3743c76 100644 --- a/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json +++ b/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json @@ -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": {