From 32e39e20aa2300445e9491f24b24e9e82d40b6b5 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 17 Sep 2026 19:38:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(setup):=20=EC=8B=A4=ED=97=98=EB=8C=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=2035=ED=8E=B8=EC=9D=84=20=EB=81=9D=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=B0=9F=EA=B3=A0=20=EC=96=B4=EA=B8=8B=EB=82=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=C2=B7=EA=B2=B0=EA=B3=BC=2031=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 · keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를 기록과 SSOT 양쪽에 실측과 함께 넣었다. 막히던 것 - 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다. 실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다 - 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은 TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서 두 문단이 서로 어긋나 있었다 - A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다 검사가 실패할 수 없던 자리 - B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도 통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다 - k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다 certbot - renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다. 본문의 renew failure(s) 로만 판정할 수 있다 - --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을 가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다 - 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다. certbot 2.1.0 에는 --run-deploy-hooks 도 없다 - 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1, notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754 독자가 칠 수 있는 형태로 - 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다 - 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로 쳐서 문서가 적은 값과 같은지 확인했다 - 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다 - 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다 관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 · verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건 남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야 해서 안 했다(unknown). Co-Authored-By: Claude Opus 5 (1M context) --- .../console-2026-09-17T02-08-53-583Z.log | 4 + .../page-2026-09-17T02-07-17-719Z.yml | 0 .../page-2026-09-17T02-07-25-462Z.yml | 0 .../page-2026-09-17T02-07-33-406Z.yml | 0 .../page-2026-09-17T02-08-53-718Z.yml | 0 .../page-2026-09-17T02-09-03-163Z.yml | 133 ++ .../page-2026-09-17T02-09-32-856Z.yml | 135 +++ .../page-2026-09-17T02-10-53-331Z.yml | 0 docs/keycloak-session-store/final/document.md | 1073 +++++++++++++++-- .../evidence/raw/relive-2026-09-17/README.txt | 100 ++ .../relive-2026-09-17/a6-05-observe-prep.txt | 88 ++ .../raw/relive-2026-09-17/a6-06-observe.txt | 225 ++++ .../a6-07-revert-checklist.txt | 53 + .../a7-06-prep-and-switch.txt | 172 +++ .../raw/relive-2026-09-17/a7-07-observe.txt | 148 +++ .../a7-08-revert-checklist.txt | 64 + .../relive-2026-09-17/b0-01-current-state.txt | 3 + .../raw/relive-2026-09-17/b0-02-beans.txt | 10 + .../b0-03-beans-doc-form.txt | 22 + .../relive-2026-09-17/b1-01-login-flow.txt | 11 + .../relive-2026-09-17/b1-02-login-flow.txt | 20 + .../raw/relive-2026-09-17/b1-03-redis.txt | 20 + .../b1-04-redis-doc-form.txt | 28 + .../relive-2026-09-17/b1-05-correct-key.txt | 6 + .../raw/relive-2026-09-17/b2-01-xargs-cr.txt | 20 + .../b2-02-session-counts.txt | 21 + .../relive-2026-09-17/b2-03-jdbc-state.txt | 13 + .../relive-2026-09-17/b2-04-token-rows.txt | 18 + .../raw/relive-2026-09-17/b2-05-overwrite.txt | 12 + .../raw/relive-2026-09-17/b5-04-baseline.txt | 43 + .../relive-2026-09-17/b5-05-redis-down.txt | 56 + .../relive-2026-09-17/b5-06-persistence.txt | 74 ++ .../b5-07-revert-checklist.txt | 39 + .../raw/relive-2026-09-17/b6-01-steps-1-2.txt | 25 + .../raw/relive-2026-09-17/b6-02-steps-3-4.txt | 18 + .../b6-03-labhost-cannot-reach.txt | 8 + .../raw/relive-2026-09-17/b6-04-step5.txt | 7 + .../raw/relive-2026-09-17/b6-05-step5-6.txt | 8 + .../relive-2026-09-17/b6-06-steps-7-15.txt | 30 + .../raw/relive-2026-09-17/b6-07-why-401.txt | 47 + .../relive-2026-09-17/b6-08-clock-skew.txt | 18 + .../b6-09-recovery-table.txt | 15 + .../b6-10-control-after-tls.txt | 10 + .../relive-2026-09-17/b6-11-control-dev.txt | 7 + .../relive-2026-09-17/b6-12-rotation-8-11.txt | 18 + .../raw/relive-2026-09-17/b6-13-removal.txt | 14 + .../relive-2026-09-17/b6-14-full-rotation.txt | 19 + .../relive-2026-09-17/b6-15-cache-restart.txt | 14 + .../raw/relive-2026-09-17/b6-16-decisive.txt | 16 + .../relive-2026-09-17/b6-17-grace-window.txt | 12 + .../relive-2026-09-17/b6-18-per-replica.txt | 9 + .../b7-01-oauth2proxy-after-dnsfix.txt | 20 + .../raw/relive-2026-09-17/b7-02-restore.txt | 15 + .../b7-03-deploy-after-tls.txt | 21 + .../raw/relive-2026-09-17/b7-04-login.txt | 18 + .../b7-05-client-missing.txt | 13 + .../relive-2026-09-17/b7-06-create-client.txt | 11 + .../raw/relive-2026-09-17/b7-07-login-ok.txt | 41 + .../raw/relive-2026-09-17/b7-08-rotation.txt | 21 + .../raw/relive-2026-09-17/b7a-01-orphan.txt | 10 + .../raw/relive-2026-09-17/c1-01-sso.txt | 29 + .../relive-2026-09-17/c1-02-user-session.txt | 13 + .../c2-05-probe-reachability-relive.txt | 15 + .../d4-01-before-force-renewal.txt | 11 + .../relive-2026-09-17/d4-01-steps-5-6-7.txt | 34 + .../relive-2026-09-17/d4-02-force-renewal.txt | 16 + .../d4-03-after-force-renewal.txt | 15 + .../relive-2026-09-17/d4a-01-steps-5-8.txt | 26 + .../d4a-02-dryrun-with-cert.txt | 19 + .../d4a-03-dryrun-complete.txt | 15 + .../relive-2026-09-17/d4a-04-two-accounts.txt | 6 + .../relive-2026-09-17/d4a-05-account-ids.txt | 10 + .../d4a-06-staging-accounts.txt | 6 + ...ter-removing-duplicate-staging-account.txt | 13 + .../relive-2026-09-17/d4a-07-dryrun-final.txt | Bin 0 -> 712 bytes .../d4a-09-dryrun-with-deploy-hook.txt | 13 + .../d4a-10-dryrun-dns-propagation-flake.txt | 23 + ...a-11-deploy-hook-not-called-in-dry-run.txt | 9 + .../relive-2026-09-17/ext-01-from-outside.txt | 21 + .../ext-02-forwarded-for.txt | 41 + .../ext-03-traefik-forwarded.txt | 43 + .../ext-04-traefik-after.txt | 10 + .../ext-05-trustedips-test.txt | 12 + .../relive-2026-09-17/ext-06-cert-check.txt | 18 + .../ext-07-cert-preconditions.txt | 8 + .../ext-08-http-only-check.txt | 19 + .../relive-2026-09-17/ext-09-echo-full.txt | 21 + .../ext-10-restore-and-source.txt | 6 + .../ext-11-after-restore.txt | 46 + .../ext-12-who-masquerades.txt | 24 + .../ext-13-http-login-chain.txt | 7 + .../ext-14-incluster-name-resolution.txt | 43 + .../ext-15-coredns-override.txt | 11 + .../relive-2026-09-17/ext-16-coredns-fix.txt | 14 + .../ext-17-incluster-after-fix.txt | 9 + .../relive-2026-09-17/misc-01-unknowns.txt | 14 + .../setup/setup-reproduce-a2-database-loss.md | 13 +- .../setup/setup-reproduce-a4-node-loss.md | 38 +- ...setup-reproduce-a5-asymmetric-partition.md | 14 +- .../setup-reproduce-a6-latency-injection.md | 72 +- .../setup-reproduce-d1-backup-restore.md | 20 +- .../setup-reproduce-d2-version-upgrade.md | 9 +- .../setup-reproduce-d4-certificate-renewal.md | 34 +- .../setup/setup-reproduce-d4a-deploy-hook.md | 226 +++- ...up-reproduce-a1-jgroups-transport-block.md | 15 +- .../setup-reproduce-a7-volatile-comparison.md | 94 +- .../setup-reproduce-a8-rolling-restart.md | 6 +- .../tech-log-studio/tech-log-tree.json | 2 +- ...up-reproduce-b4-forged-identity-headers.md | 5 +- ...tup-reproduce-b7-cookie-secret-rotation.md | 60 +- .../setup-reproduce-b7a-orphan-session.md | 17 +- .../setup/setup-reproduce-c1-multi-app-sso.md | 21 +- .../setup-reproduce-c2-backchannel-logout.md | 23 +- ...etup-reproduce-b0-default-session-store.md | 31 +- .../setup-reproduce-b1-redis-session-store.md | 48 +- .../setup-reproduce-b2-jdbc-token-store.md | 66 +- .../setup/setup-reproduce-b5-redis-loss.md | 96 +- .../setup/setup-reproduce-b6-key-rotation.md | 117 +- docs/virtualization/final/document.md | 612 +++++++++- .../46-layer3-now-reachable.txt | 13 + .../47-layer-codes-by-host-header.txt | 10 + .../48-cloudinit-packages.txt | 40 + .../49-doc01-verification.txt | 19 + .../50-doc08-precheck-and-cert-location.txt | 42 + .../52-doc01-09-unknowns.txt | 55 + .../53-doc03-sudo-answers.txt | 39 + .../54-who-masquerades.txt | 45 + .../55-doc02-san-journal.txt | 19 + .../56-doc04-token-check.txt | 7 + .../57-host-certs.txt | 19 + .../58-authenticator-and-token.txt | 11 + .../59-cert-moved-to-edge.txt | 18 + .../60-doc04-step5-path-defect.txt | 7 + .../lab-state-before-rebuild/61-tls-up.txt | 13 + .../lab-state-before-rebuild/62-https-400.txt | 22 + .../lab-state-before-rebuild/63-layer-400.txt | 15 + .../64-tls-working.txt | 11 + .../lab-state-before-rebuild/65-auth-400.txt | 23 + .../65-edge-missing-pieces.txt | 7 + .../raw/lab-state-before-rebuild/README.txt | 8 + ...tup-create-three-guests-with-cloud-init.md | 60 +- .../setup/setup-edge-nginx-and-host-dnat.md | 234 +++- .../setup-install-k3s-server-and-agent.md | 46 +- ...-keycloak-two-nodes-and-postgres-on-k3s.md | 10 +- ...cle-the-lab-and-reallocate-guest-memory.md | 25 +- ...prepare-the-lab-host-for-virtualization.md | 55 +- ...etup-prometheus-and-grafana-for-the-lab.md | 5 +- ...ear-down-the-lab-and-know-what-survives.md | 49 + ...rtificate-with-dns-01-and-a-deploy-hook.md | 51 +- .../tech-log-studio/tech-log-tree.json | 2 +- 150 files changed, 6066 insertions(+), 269 deletions(-) create mode 100644 .playwright-mcp/console-2026-09-17T02-08-53-583Z.log create mode 100644 .playwright-mcp/page-2026-09-17T02-07-17-719Z.yml create mode 100644 .playwright-mcp/page-2026-09-17T02-07-25-462Z.yml create mode 100644 .playwright-mcp/page-2026-09-17T02-07-33-406Z.yml create mode 100644 .playwright-mcp/page-2026-09-17T02-08-53-718Z.yml create mode 100644 .playwright-mcp/page-2026-09-17T02-09-03-163Z.yml create mode 100644 .playwright-mcp/page-2026-09-17T02-09-32-856Z.yml create mode 100644 .playwright-mcp/page-2026-09-17T02-10-53-331Z.yml create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-05-observe-prep.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-06-observe.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-07-revert-checklist.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-06-prep-and-switch.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-07-observe.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-08-revert-checklist.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-01-current-state.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-02-beans.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-03-beans-doc-form.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-01-login-flow.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-02-login-flow.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-03-redis.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-04-redis-doc-form.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-05-correct-key.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-01-xargs-cr.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-02-session-counts.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-03-jdbc-state.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-04-token-rows.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-05-overwrite.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-04-baseline.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-05-redis-down.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-06-persistence.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-07-revert-checklist.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-01-steps-1-2.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-02-steps-3-4.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-03-labhost-cannot-reach.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-04-step5.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-05-step5-6.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-06-steps-7-15.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-07-why-401.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-08-clock-skew.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-09-recovery-table.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-10-control-after-tls.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-11-control-dev.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-12-rotation-8-11.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-13-removal.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-14-full-rotation.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-15-cache-restart.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-16-decisive.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-17-grace-window.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-18-per-replica.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-01-oauth2proxy-after-dnsfix.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-02-restore.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-03-deploy-after-tls.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-04-login.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-05-client-missing.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-06-create-client.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-07-login-ok.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-08-rotation.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7a-01-orphan.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-01-sso.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-02-user-session.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c2-05-probe-reachability-relive.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-before-force-renewal.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-steps-5-6-7.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-02-force-renewal.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-03-after-force-renewal.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-01-steps-5-8.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-02-dryrun-with-cert.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-03-dryrun-complete.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-04-two-accounts.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-05-account-ids.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-06-staging-accounts.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-after-removing-duplicate-staging-account.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-final.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-09-dryrun-with-deploy-hook.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-10-dryrun-dns-propagation-flake.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-11-deploy-hook-not-called-in-dry-run.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-01-from-outside.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-02-forwarded-for.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-03-traefik-forwarded.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-04-traefik-after.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-05-trustedips-test.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-06-cert-check.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-07-cert-preconditions.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-08-http-only-check.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-09-echo-full.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-10-restore-and-source.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-11-after-restore.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-12-who-masquerades.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-13-http-login-chain.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-14-incluster-name-resolution.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-15-coredns-override.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-16-coredns-fix.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-17-incluster-after-fix.txt create mode 100644 docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/misc-01-unknowns.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/46-layer3-now-reachable.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/47-layer-codes-by-host-header.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/48-cloudinit-packages.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/49-doc01-verification.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/50-doc08-precheck-and-cert-location.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/52-doc01-09-unknowns.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/53-doc03-sudo-answers.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/54-who-masquerades.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/55-doc02-san-journal.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/56-doc04-token-check.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/57-host-certs.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/58-authenticator-and-token.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/59-cert-moved-to-edge.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/60-doc04-step5-path-defect.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/61-tls-up.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/62-https-400.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/63-layer-400.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/64-tls-working.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-auth-400.txt create mode 100644 docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-edge-missing-pieces.txt diff --git a/.playwright-mcp/console-2026-09-17T02-08-53-583Z.log b/.playwright-mcp/console-2026-09-17T02-08-53-583Z.log new file mode 100644 index 0000000..348a15e --- /dev/null +++ b/.playwright-mcp/console-2026-09-17T02-08-53-583Z.log @@ -0,0 +1,4 @@ +[ 60061ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/documents?limit=200:0 +[ 65184ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/documents?limit=200:0 +[ 87640ms] [ERROR] Failed to load resource: the server responded with a status of 405 (Method Not Allowed) @ https://hyeonworks.com/api/v1/studio/documents/7e7fb64d-f3a5-4f1e-8351-04596929aaf0:0 +[ 98850ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/7e7fb64d-f3a5-4f1e-8351-04596929aaf0:0 diff --git a/.playwright-mcp/page-2026-09-17T02-07-17-719Z.yml b/.playwright-mcp/page-2026-09-17T02-07-17-719Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-09-17T02-07-25-462Z.yml b/.playwright-mcp/page-2026-09-17T02-07-25-462Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-09-17T02-07-33-406Z.yml b/.playwright-mcp/page-2026-09-17T02-07-33-406Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-09-17T02-08-53-718Z.yml b/.playwright-mcp/page-2026-09-17T02-08-53-718Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-09-17T02-09-03-163Z.yml b/.playwright-mcp/page-2026-09-17T02-09-03-163Z.yml new file mode 100644 index 0000000..ef88c81 --- /dev/null +++ b/.playwright-mcp/page-2026-09-17T02-09-03-163Z.yml @@ -0,0 +1,133 @@ +- generic [ref=f6e3]: + - link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f6e5]: + - generic [ref=f6e6]: + - link "TechLog Studio" [ref=f6e7] [cursor=pointer]: + - /url: /studio + - text: TechLog + - generic [ref=f6e8]: Studio + - navigation "Studio 주 탐색" [ref=f6e10]: + - generic [ref=f6e11]: + - link "작업" [ref=f6e12] [cursor=pointer]: + - /url: /studio/documents + - link "구조" [ref=f6e13] [cursor=pointer]: + - /url: /studio/taxonomy + - link "자료" [ref=f6e14] [cursor=pointer]: + - /url: /studio/assets + - link "게시" [ref=f6e15] [cursor=pointer]: + - /url: /studio/publications + - generic [ref=f6e16]: + - link "작업본" [ref=f6e17] [cursor=pointer]: + - /url: /studio/documents + - link "새 문서" [ref=f6e18] [cursor=pointer]: + - /url: /studio/documents/new + - generic [ref=f6e19]: + - link "공개 사이트 보기" [ref=f6e20] [cursor=pointer]: + - /url: / + - button "로그아웃" [ref=f6e21] + - main [ref=f6e22]: + - generic [ref=f6e23]: + - generic [ref=f6e24]: + - generic "현재 문서" [ref=f6e25]: + - generic [ref=f6e26]: CASE + - generic [aria-hidden] [ref=f6e27]: · + - generic [ref=f6e28]: v1 + - complementary [ref=f6e29]: + - heading "작업 상태" [level=2] [ref=f6e30] + - status "편집 상태" [ref=f6e31]: 저장됨 + - generic [ref=f6e33]: + - button "저장" [disabled] [ref=f6e34] + - button "게시" [ref=f6e35] + - main "문서 편집" [ref=f6e36]: + - region "문서 기본 정보" [ref=f6e37]: + - generic [ref=f6e39]: 검증 기록 + - heading "제목 없는 작업본" [level=1] [ref=f6e40]: + - generic [ref=f6e41]: + - generic [ref=f6e42]: 제목 + - textbox "제목" [ref=f6e43]: + - /placeholder: 제목을 입력하세요 + - generic [ref=f6e44]: + - generic [ref=f6e45]: 요약 + - textbox "요약" [ref=f6e46]: + - /placeholder: 이 기록에서 다루는 핵심을 한두 문장으로 적어 주세요. + - generic [ref=f6e47]: + - generic [ref=f6e48]: 목록 카드에는 약 90자까지 보입니다. + - generic [aria-hidden] [ref=f6e49]: 0 / 2000 + - group [ref=f6e50]: + - generic "속성 프로젝트 없음 · 주제 없음" [ref=f6e51] [cursor=pointer]: + - generic [ref=f6e52]: 속성 + - generic [ref=f6e53]: 프로젝트 없음 · 주제 없음 + - generic [aria-hidden] [ref=f6e54]: 접기 + - generic [ref=f6e55]: + - generic [ref=f6e56]: + - generic [ref=f6e57]: 프로젝트 + - group [ref=f6e59]: + - generic "Project" [ref=f6e60] [cursor=pointer]: + - generic [ref=f6e61]: 프로젝트 없음 + - generic [aria-hidden] [ref=f6e62]: ⌄ + - generic [ref=f6e63]: + - generic [ref=f6e64]: 주제 + - group [ref=f6e66]: + - generic "Topic" [ref=f6e67] [cursor=pointer]: + - generic [ref=f6e68]: 주제 없음 + - generic [aria-hidden] [ref=f6e69]: ⌄ + - generic [ref=f6e70]: + - generic [ref=f6e71]: URL 이름 + - generic [ref=f6e72]: + - generic [aria-hidden] [ref=f6e73]: / + - textbox "slug" [ref=f6e74]: + - /placeholder: 비우면 제목에서 자동 생성 + - generic [ref=f6e75]: 직접 정하지 않아도 저장할 때 제목을 기준으로 만들어집니다. + - group [ref=f6e76]: + - generic "연결된 기록 필요할 때 추가 +" [ref=f6e77] [cursor=pointer]: + - generic [ref=f6e78]: 연결된 기록 + - generic [ref=f6e79]: 필요할 때 추가 + - text: + + - text: + + - status [ref=f6e80] + - region [ref=f6e81]: + - generic [ref=f6e82]: + - paragraph [ref=f6e83]: CASE + - heading "문제와 검증" [level=2] [ref=f6e84] + - paragraph [ref=f6e85]: 문제와 결론을 먼저 고정하고, 그 사이의 검증 과정을 본문 블록으로 이어서 작성합니다. + - generic [ref=f6e86]: + - generic [ref=f6e87]: + - generic [ref=f6e88]: 문제 + - textbox "문제" [ref=f6e89]: + - /placeholder: 어떤 현상이 문제였는지 한 문단으로 적어 주세요. + - generic [ref=f6e90]: + - generic [ref=f6e91]: 결론 + - textbox "결론" [ref=f6e92]: + - /placeholder: 검증을 통해 무엇을 확인했는지 적어 주세요. + - group [ref=f6e93]: + - generic "검증 세부정보 0개 입력됨" [ref=f6e94] [cursor=pointer]: + - generic [ref=f6e95]: 검증 세부정보 + - generic [ref=f6e96]: 0개 입력됨 + - generic [ref=f6e97]: + - generic [ref=f6e98]: + - generic [ref=f6e99]: + - text: 본문 + - paragraph [ref=f6e100]: 작성 화면이 곧 문서입니다. 빈 블록에서 / 를 입력해 제목·목록·코드 블록을 추가할 수 있습니다. + - generic [ref=f6e101]: + - button "본문에 자료 넣기" [ref=f6e103] + - generic [ref=f6e104]: + - generic [ref=f6e105]: 업로드 종류 + - combobox "업로드 종류" [ref=f6e106]: + - option "이미지" [selected] + - option "다이어그램" + - option "첨부파일" + - button "Asset 업로드" [ref=f6e107]: 업로드 + - region "본문" [ref=f6e108]: + - generic [ref=f6e110]: + - generic "텍스트 블록 작업" [ref=f6e111]: + - button "1번째 블록 메뉴" [ref=f6e112]: ⋮⋮ + - textbox "1번째 텍스트" [ref=f6e114]: + - /placeholder: 내용을 입력하거나 / 를 눌러 블록을 추가하세요 + - generic "1번째 블록 순서와 삭제": + - button "위로 이동" [disabled]: ↑ + - button "아래로 이동" [disabled]: ↓ + - button "블록 삭제": 삭제 + - button "+ 블록 추가" [ref=f6e115] + - paragraph [ref=f6e116]: 빈 블록에서 / 를 입력하면 제목·목록·코드 같은 블록으로 바꿀 수 있습니다. + - paragraph [ref=f6e117]: 검증 기록 작업본을 만들었습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-17T02-09-32-856Z.yml b/.playwright-mcp/page-2026-09-17T02-09-32-856Z.yml new file mode 100644 index 0000000..57c376a --- /dev/null +++ b/.playwright-mcp/page-2026-09-17T02-09-32-856Z.yml @@ -0,0 +1,135 @@ +- generic [ref=f6e3]: + - link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f6e5]: + - generic [ref=f6e6]: + - link "TechLog Studio" [ref=f6e7] [cursor=pointer]: + - /url: /studio + - text: TechLog + - generic [ref=f6e8]: Studio + - navigation "Studio 주 탐색" [ref=f6e10]: + - generic [ref=f6e11]: + - link "작업" [ref=f6e12] [cursor=pointer]: + - /url: /studio/documents + - link "구조" [ref=f6e13] [cursor=pointer]: + - /url: /studio/taxonomy + - link "자료" [ref=f6e14] [cursor=pointer]: + - /url: /studio/assets + - link "게시" [ref=f6e15] [cursor=pointer]: + - /url: /studio/publications + - generic [ref=f6e16]: + - link "작업본" [ref=f6e17] [cursor=pointer]: + - /url: /studio/documents + - link "새 문서" [ref=f6e18] [cursor=pointer]: + - /url: /studio/documents/new + - generic [ref=f6e19]: + - link "공개 사이트 보기" [ref=f6e20] [cursor=pointer]: + - /url: / + - button "로그아웃" [ref=f6e21] + - main [ref=f6e22]: + - generic [ref=f6e23]: + - generic [ref=f6e24]: + - generic "현재 문서" [ref=f6e25]: + - generic [ref=f6e26]: CASE + - generic [aria-hidden] [ref=f6e27]: · + - generic [ref=f6e28]: v2 + - complementary [ref=f6e29]: + - heading "작업 상태" [level=2] [ref=f6e30] + - status "편집 상태" [ref=f6e31]: 저장됨 + - generic [ref=f6e33]: + - button "저장" [disabled] [ref=f6e34] + - button "게시" [ref=f6e35] + - main "문서 편집" [ref=f6e36]: + - region "문서 기본 정보" [ref=f6e37]: + - generic [ref=f6e39]: 검증 기록 + - heading "zz 시험 작업본 — 지울 것" [level=1] [ref=f6e118]: + - generic [ref=f6e41]: + - generic [ref=f6e42]: 제목 + - textbox "제목" [ref=f6e43]: + - /placeholder: 제목을 입력하세요 + - text: zz 시험 작업본 — 지울 것 + - generic [ref=f6e44]: + - generic [ref=f6e45]: 요약 + - textbox "요약" [ref=f6e46]: + - /placeholder: 이 기록에서 다루는 핵심을 한두 문장으로 적어 주세요. + - generic [ref=f6e47]: + - generic [ref=f6e48]: 목록 카드에는 약 90자까지 보입니다. + - generic [aria-hidden] [ref=f6e49]: 0 / 2000 + - group [ref=f6e50]: + - generic "속성 프로젝트 없음 · 주제 없음" [ref=f6e51] [cursor=pointer]: + - generic [ref=f6e52]: 속성 + - generic [ref=f6e53]: 프로젝트 없음 · 주제 없음 + - generic [aria-hidden] [ref=f6e54]: 접기 + - generic [ref=f6e55]: + - generic [ref=f6e56]: + - generic [ref=f6e57]: 프로젝트 + - group [ref=f6e59]: + - generic "Project" [ref=f6e60] [cursor=pointer]: + - generic [ref=f6e61]: 프로젝트 없음 + - generic [aria-hidden] [ref=f6e62]: ⌄ + - generic [ref=f6e63]: + - generic [ref=f6e64]: 주제 + - group [ref=f6e66]: + - generic "Topic" [ref=f6e67] [cursor=pointer]: + - generic [ref=f6e68]: 주제 없음 + - generic [aria-hidden] [ref=f6e69]: ⌄ + - generic [ref=f6e70]: + - generic [ref=f6e71]: URL 이름 + - generic [ref=f6e72]: + - generic [aria-hidden] [ref=f6e73]: / + - textbox "slug" [ref=f6e74]: + - /placeholder: 비우면 제목에서 자동 생성 + - text: zz-siheom-jakeobbon-jiul-geos + - generic [ref=f6e75]: 직접 정하지 않아도 저장할 때 제목을 기준으로 만들어집니다. + - group [ref=f6e76]: + - generic "연결된 기록 필요할 때 추가 +" [ref=f6e77] [cursor=pointer]: + - generic [ref=f6e78]: 연결된 기록 + - generic [ref=f6e79]: 필요할 때 추가 + - text: + + - text: + + - status [ref=f6e80] + - region [ref=f6e81]: + - generic [ref=f6e82]: + - paragraph [ref=f6e83]: CASE + - heading "문제와 검증" [level=2] [ref=f6e84] + - paragraph [ref=f6e85]: 문제와 결론을 먼저 고정하고, 그 사이의 검증 과정을 본문 블록으로 이어서 작성합니다. + - generic [ref=f6e86]: + - generic [ref=f6e87]: + - generic [ref=f6e88]: 문제 + - textbox "문제" [ref=f6e89]: + - /placeholder: 어떤 현상이 문제였는지 한 문단으로 적어 주세요. + - generic [ref=f6e90]: + - generic [ref=f6e91]: 결론 + - textbox "결론" [ref=f6e92]: + - /placeholder: 검증을 통해 무엇을 확인했는지 적어 주세요. + - group [ref=f6e93]: + - generic "검증 세부정보 0개 입력됨" [ref=f6e94] [cursor=pointer]: + - generic [ref=f6e95]: 검증 세부정보 + - generic [ref=f6e96]: 0개 입력됨 + - generic [ref=f6e97]: + - generic [ref=f6e98]: + - generic [ref=f6e99]: + - text: 본문 + - paragraph [ref=f6e100]: 작성 화면이 곧 문서입니다. 빈 블록에서 / 를 입력해 제목·목록·코드 블록을 추가할 수 있습니다. + - generic [ref=f6e101]: + - button "본문에 자료 넣기" [ref=f6e103] + - generic [ref=f6e104]: + - generic [ref=f6e105]: 업로드 종류 + - combobox "업로드 종류" [ref=f6e106]: + - option "이미지" [selected] + - option "다이어그램" + - option "첨부파일" + - button "Asset 업로드" [ref=f6e107]: 업로드 + - region "본문" [ref=f6e108]: + - generic [ref=f6e110]: + - generic "텍스트 블록 작업" [ref=f6e111]: + - button "1번째 블록 메뉴" [ref=f6e112]: ⋮⋮ + - textbox "1번째 텍스트" [ref=f6e114]: + - /placeholder: 내용을 입력하거나 / 를 눌러 블록을 추가하세요 + - generic "1번째 블록 순서와 삭제": + - button "위로 이동" [disabled]: ↑ + - button "아래로 이동" [disabled]: ↓ + - button "블록 삭제": 삭제 + - button "+ 블록 추가" [ref=f6e115] + - paragraph [ref=f6e116]: 빈 블록에서 / 를 입력하면 제목·목록·코드 같은 블록으로 바꿀 수 있습니다. + - paragraph [ref=f6e117]: 버전 2으로 저장했습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-17T02-10-53-331Z.yml b/.playwright-mcp/page-2026-09-17T02-10-53-331Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/docs/keycloak-session-store/final/document.md b/docs/keycloak-session-store/final/document.md index 87f1821..d3a8d15 100644 --- a/docs/keycloak-session-store/final/document.md +++ b/docs/keycloak-session-store/final/document.md @@ -152,6 +152,20 @@ OIDC 디스커버리를 먼저 하는데 그 주소가 `https://auth.hyeonworks. 정상이었다 — `redis-cli dbsize` 가 `0`, `--scan --pattern '_oauth2_proxy-*'` 가 빈 출력이다(observed). +**그런데 위 오류의 주소가 원인을 하나 가리고 있었다**(2026-09-17, observed). +`100.83.212.4` 는 랩 호스트의 tailnet 주소이고, 가상화 쪽 03 이 nginx 를 엣지 게스트로 +옮긴 뒤로 그 주소에는 443 도 80 도 없다. 인증서를 받아 엣지에 얹었더라도 파드는 여전히 +이 주소를 두드렸을 것이다. 클러스터 DNS 가 이 이름을 엣지로 보내게 고친 뒤 같은 배포를 +다시 하니 주소만 바뀌고 증상은 같았다. + +``` +[2026/09/17 07:37:18] [provider.go:55] Performing OIDC Discovery... +[2026/09/17 07:37:18] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: ... dial tcp 192.168.122.10:443: connect: connection refused +``` + +이제 두드리는 곳이 엣지이고, 거기 443 이 안 열린 것은 인증서가 없어서다. **같은 +`connection refused` 인데 앞의 것은 원인이 둘이었고 뒤의 것은 하나다.** + #### 그 12GB 를 어떻게 나눠 썼나 @@ -851,6 +865,17 @@ B-5 에서 `redis-cli config set appendonly yes` 를 켜도 아무것도 달라 B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했는데 **없었다.** `NimbusJwtDecoder` 는 모르는 `kid` 를 만나면 JWKS 를 다시 가져온다. +**★ 2026-09-17 에 다시 재 보니 그 「없었다」는 절반만 맞았다**(observed). 옛 키를 +지운 직후 같은 토큰으로 여덟 번 연속 쳤더니 `401 200 401 200 401 200 401 200` 이 +나왔다. **`echo` 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로여서**, 한쪽은 +목록을 새로 받아 옛 키를 잃었고 다른 쪽은 아직 들고 있다. Traefik 이 번갈아 +보내므로 어느 쪽이 답하느냐로 결과가 갈린다. + +그래서 판정은 **「유예가 없다」가 아니라 「인스턴스마다 다르다」**다. 운영에서는 +더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 +401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「없었다」로 닫은 것은 +한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred). + ![볼륨 없는 영속화와 유예 없는 회전](assets/b5-b6-storage-and-keys/b5-b6-storage-and-keys.svg) 설정과 매체를 나눠서 보아야 한다. 설정만 보면 둘 다 되어 있는 것으로 읽히기 때문이다. @@ -858,6 +883,39 @@ B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했 #### B-7 · B-7a — 쿠키에 담는 세션, 그리고 그 대가 +**★ 이 세 편을 밟으려면 어디에도 없는 한 단계가 먼저다**(2026-09-17, observed). +realm `keycloak-patterns` 에 **`oauth2-proxy` 클라이언트를 만드는 곳이 없다.** 기반 +가이드 05 도, 저장소의 realm 임포트(`keycloak/import/keycloak-patterns-realm.json`)도 +만들지 않는다 — 임포트에 있는 것은 `bff-confidential` · `edge-proxy` · +`mock-google-broker` · `spa-public` · `token-mediating-confidential` 다섯이다. + +파드는 멀쩡히 뜨고 `app2` 도 `302` 를 내므로 배포는 다 된 것처럼 보이고, 로그인 +화면까지 가서야 드러난다. + +```text +We are sorry... +Client not found. +``` + +만드는 한 줄은 이렇다. 값은 매니페스트가 읽는 Secret 에서 그대로 꺼내 넘기고, +`redirectUris` 는 `--redirect-url` 과 한 글자도 달라선 안 된다. + +```bash +CS=$(kubectl -n keycloak-lab get secret oauth2-proxy-secrets -o jsonpath='{.data.CLIENT_SECRET}' | base64 -d) +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create clients -r keycloak-patterns \ + -s clientId=oauth2-proxy -s enabled=true -s protocol=openid-connect \ + -s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false \ + -s "secret=$CS" \ + -s 'redirectUris=["https://app2.hyeonworks.com/oauth2/callback"]' \ + -s 'webOrigins=["https://app2.hyeonworks.com"]' +``` + +**그 단계를 밟고 나니 B-7 과 B-7a 는 문서 그대로 나왔다**(observed). 로그인 뒤 쿠키가 +`_oauth2_proxy` 176자짜리 티켓이고 세션은 Redis 에 들어갔으며, 업스트림이 +`x-forwarded-user` · `x-forwarded-email` · `x-forwarded-preferred-username` 을 받았다. +secret 을 A 에서 B 로 바꾸자 회전 전 `200` 이던 같은 쿠키가 `302` 로 바뀌었고, +**Redis 의 세션 키는 그대로 남아** `ttl` 이 `3551 → 3521 → 3490` 으로 갱신 없이 줄었다. + oauth2-proxy 는 BFF 와 정반대로 **서버 상태를 갖지 않는다.** 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 만 읽으므로, 공유할 것이 없어서 콜백이 다른 replica 로 가도 문제가 없다. @@ -1206,7 +1264,7 @@ CT 로그가 자체 시계로 서명)가 보정한 훅 시각의 정확히 1초 | A-1 로그아웃 전파는 안 깨진다 | 깨졌다 — A-0 의 인과 설명을 고쳐야 했다 | | A-6 낙관적 락 충돌이 보인다 | 0건 — 로그인은 INSERT 라 경합하지 않는다 | | B-4 nginx 가 동명 헤더를 덮어쓴다 | 덮어쓰지 않는다 | -| B-6 JWKS 캐시가 유예를 준다 | 주지 않는다 | +| B-6 JWKS 캐시가 유예를 준다 | 인스턴스마다 다르다 — replica 둘이 `401` 과 `200` 으로 갈렸다 (2026-09-17 재측정) | | A-7 refresh 500 은 `REVOKED_TOKEN` 때문 | `CLIENT_SCOPE_CLIENT` 였다 | **A-2 의 `up = 1` 은 이 표에 넣지 않는다.** 전에는 「`up` 이 장애를 보여준다」를 @@ -2955,13 +3013,17 @@ Deployment 도 성립한다. | 끝까지 | A-0 · A-1 · A-2 · A-3 · A-4 · A-5 · A-6 · A-7 · A-7a · A-8 · B-3 · B-4 · B-5 · C-2 · D-1 · D-2 · D-3 | | 되는 데까지 (각 편의 「이 실험대에서 아직 못 밟은 단계」가 남은 것을 적는다) | B-0 · B-1 · B-2 · B-6 · B-7 · B-7a · C-1 · D-4 · D-4a | -**막은 것은 하나로 모인다 — `https://auth.hyeonworks.com` 이 서지 않는다.** 둘이 -같이 필요하다. +**막은 것은 하나로 모인다 — `https://auth.hyeonworks.com` 이 서지 않는다.** 처음에는 +셋이 필요했고, 같은 날 둘이 풀려 **지금 남은 것은 인증서 하나다.** -| 무엇 | 왜 없나 | +| 무엇 | 지금 | |---|---| -| 와일드카드 인증서 | DNS-01 에 Cloudflare API 토큰이 필요하고, 그 값은 이 저장소에 두지 않는다 | -| 밖에서 실험대에 닿는 길 | 호스트의 libvirt `guest_input` 구멍. A-4 에서 확인했듯 배포된 유닛에 `ExecStartPost` 가 없고, 고치려면 호스트의 `sudo` 비밀번호가 필요하다 | +| 와일드카드 인증서 | **아직 없다.** DNS-01 에 Cloudflare API 토큰이 필요하고, 그 값은 이 저장소에 두지 않는다. 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않으므로 다른 길이 없다 | +| 밖에서 실험대에 닿는 길 | **열렸다.** 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어갔고, 밖에서 친 `http` 가 `200` 이다 | +| 클러스터 안에서 그 이름에 닿는 길 | **놓았다.** 03 이 nginx 를 엣지로 옮기면서 끊겼던 것을 이날 처음 쟀고, CoreDNS 에 서버 블록 하나를 더해 파드에서 엣지로 풀리게 했다 | + +가운데와 아래 둘은 이날 실측으로 드러났다. **아래 것은 인증서를 받아도 따로 놓아야 +하는 단계라, 그것을 모른 채 토큰만 넣었으면 같은 자리에서 다시 막혔다.** 그 둘이 없으면 인가 코드 흐름(브라우저 로그인)이 본체인 구간과 인증서 발급 자체가 본체인 두 편이 성립하지 않는다. **대신 그 구간이 왜 막히는지는 추측이 아니라 실측으로 @@ -6254,7 +6316,8 @@ keycloak-lxk8h [10.42.0.13],[10.42.1.17] true,false 뜬다. ```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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **★ 인증서 단계를 건너뛴 실험대라면** 443 을 듣는 것이 없어 여기는 `000` 이다. 그때는 @@ -6743,7 +6806,8 @@ curl -s -o /dev/null -w 'well-known %{http_code}\n' \ 밖에서 정문도 재 둔다. ```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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` #### 주입 @@ -6971,8 +7035,9 @@ 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 -curl -I https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` **실측**(observed) — `https://auth.hyeonworks.com/realms/master HTTP 503`. @@ -7097,7 +7162,8 @@ deployment "postgres" successfully rolled out ```bash kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \ | grep keycloak -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **실측**(observed) @@ -8156,8 +8222,10 @@ kubectl get pv $(kubectl -n keycloak-lab get pvc postgres-data \ 재서 비교할 것이므로 그다음에는 코드만 뽑는다. ```bash -curl -I --max-time 8 https://auth.hyeonworks.com/realms/master -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **`--max-time` 을 반드시 준다.** 4b 에서 이 값이 없으면 curl 이 몇 분씩 @@ -8262,7 +8330,8 @@ kubectl get node kc-lab-2 ```bash kubectl get node kc-lab-2 -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **손이 아프면 한 줄로 묶는다**고 가이드가 대안을 함께 적는데, 이 루프는 @@ -8273,7 +8342,7 @@ while true; do printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \ "$(kubectl get node kc-lab-2 --no-headers | awk '{print $2}')" \ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 15 done ``` @@ -8617,10 +8686,41 @@ ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containe 밖에서는 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 'auth=%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/ ``` +**★ 이 탐침은 지금 배치에서 `000` 밖에 못 낸다 — 노드가 살아 있어도 +그렇다**(2026-09-17, observed). 원래 실행은 `502` 와 `000` 이 갈렸는데 지금은 +처음부터 `000` 이라 판정에 못 쓴다. 이유는 이름 해석이 아니라 인증서다. + +```bash +curl -s -o /dev/null --max-time 8 --resolve grafana.hyeonworks.com:443:192.168.122.10 https://grafana.hyeonworks.com/ ; echo "curl exit=$?" +``` + +```bash +curl -sk -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 --resolve grafana.hyeonworks.com:443:192.168.122.10 https://grafana.hyeonworks.com/ +``` + +```bash +echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -ext subjectAltName +``` + +```text +curl exit=60 +grafana=404 +X509v3 Subject Alternative Name: + DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com +``` + +`curl exit=60` 은 인증서 검증 실패다. 엣지의 인증서가 덮는 이름이 셋뿐이라 +`grafana.hyeonworks.com` 은 TLS 단계에서 끝나고 HTTP 는 시작도 안 한다. `-k` 로 +검증을 끄면 nginx 가 `404` 를 돌려준다 — 그 이름으로 갈 곳이 없다. 가이드 06 도 +Grafana 는 밖에 열지 않고 `port-forward svc/grafana 3000:3000` 으로 본다고 적어 둔다. +그래서 이 탐침은 A-4 의 신호가 못 된다. 판정은 `auth` 쪽 탐침로 한다. + + **실측**(observed) — `07-control-plane-loss.txt` ```text @@ -8658,7 +8758,8 @@ virsh start kc-lab-2 ```bash kubectl get nodes kubectl -n keycloak-lab get pods -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **실측**(observed) @@ -9055,11 +9156,13 @@ ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800 밖에서 보이는 상태는 읽는 형태로 한 번 보고 나서 코드만 뽑는다. ```bash -curl -I --max-time 8 https://auth.hyeonworks.com/realms/master +curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```bash -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` `200` 이어야 한다. @@ -9303,7 +9406,8 @@ ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' ```bash 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 +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **실측**(observed) — `01-injection.txt` @@ -9431,7 +9535,8 @@ kubectl -n observability exec deploy/prometheus -- \ kubectl -n keycloak-lab get pods | grep keycloak 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 +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **실측**(observed) — `07-bidirectional-block.txt` @@ -9925,6 +10030,30 @@ agroal_destroy_count_total 앞부분만 남겼다. 실제로는 뒤에 `agroal_max_used_count` 같은 것이 더 있고 뒤에서 그 값을 쓴다. **증거 파일이 짧다고 지표가 없는 것이 아니다.** +**★ 같은 줄을 2026-09-17 에 쳤더니 모양이 달랐다**(observed). `grep` 은 이름만 남기지 않는다. 지표 한 줄을 통째로 내보내므로 레이블과 값이 함께 나오고, 순서도 알파벳순이 아니다. 열일곱 줄이 나왔고 그 안에 `agroal_max_used_count` 가 이미 있었다. + +```text +agroal_invalid_count_total{datasource="default"} 0.0 +agroal_flush_count_total{datasource="default"} 0.0 +agroal_leak_detection_count_total{datasource="default"} 0.0 +agroal_reap_count_total{datasource="default"} 3.0 +agroal_blocking_time_max_milliseconds{datasource="default"} 112.0 +agroal_max_used_count{datasource="default"} 3.0 +agroal_acquire_count_total{datasource="default"} 1908.0 +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +agroal_blocking_time_average_milliseconds{datasource="default"} 0.0 +agroal_creation_time_total_milliseconds{datasource="default"} 160.0 +agroal_creation_time_average_milliseconds{datasource="default"} 32.0 +agroal_destroy_count_total{datasource="default"} 3.0 +agroal_available_count{datasource="default"} 2.0 +agroal_creation_count_total{datasource="default"} 5.0 +agroal_creation_time_max_milliseconds{datasource="default"} 105.0 +agroal_blocking_time_total_milliseconds{datasource="default"} 285.0 +``` + +이름만 늘어놓고 보려면 `| awk '{print $1}' | sort` 를 붙인다. 위의 열두 줄이 이름뿐인 것은 원 실행이 그렇게 다듬어 남겼기 때문이다(inferred). + | 지표 | 무엇을 말하는가 | |---|---| | `blocking_time_max` | **커넥션을 받으려고 가장 오래 기다린 시간** | @@ -10342,6 +10471,21 @@ kubectl -n keycloak-lab exec a6-probe -- sh -c \ agroal_available_count 19.0 ``` +**★ 이 정규식은 `blocking_time_total` 도 함께 잡는다**(2026-09-17, observed). 같은 줄을 쳤더니 여덟 줄이 나왔다. 위 일곱 줄에 `agroal_blocking_time_total_milliseconds` 가 하나 더 붙고, 지표마다 `{datasource="default"}` 가 달려 있다. + +```text +agroal_blocking_time_max_milliseconds{datasource="default"} 20000.0 +agroal_max_used_count{datasource="default"} 18.0 +agroal_acquire_count_total{datasource="default"} 2020.0 +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +agroal_blocking_time_average_milliseconds{datasource="default"} 114.0 +agroal_available_count{datasource="default"} 18.0 +agroal_blocking_time_total_milliseconds{datasource="default"} 230680.0 +``` + +`blocking_time_max` 는 여기서도 `20000.0` 이었다. + | 값 | 읽는 법 | |---|---| | `blocking_time_max 20000.0` | **커넥션을 받으려고 20초를 기다린 요청이 있었다** | @@ -10423,6 +10567,8 @@ kubectl -n keycloak-lab logs keycloak-1 --since=20m \ 관련 로그 줄수: 0 ``` +**★ 그 줄을 2026-09-17 에 쳤다**(observed). 출력은 `0` 이고 종료 코드는 `1` 이다 — `grep -c` 는 센 값이 0 이면 1 로 끝난다. 뒤에 `&&` 로 다른 명령을 이어 붙이면 그 명령이 안 돈다. + **하나도 없었다.** 까닭이 명확하다. ```text @@ -10489,6 +10635,29 @@ keycloak-1 1/1 Running 1 (52m ago) 3h24m kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found ``` +**★ 이 표의 두 줄은 이 실험대에서 답이 달랐다**(2026-09-17, observed). + +`enp1s0` 쪽은 비어 있지 않다. 이 게스트의 기본 qdisc 가 `fq_codel` 이라 아무것도 안 걸었을 때도 한 줄이 나온다. 잔재가 없다는 것은 그 줄에 `netem` 이나 `prio` 가 안 보인다는 뜻이다. + +```text +qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +``` + +마지막 줄의 `200` 은 이 실험대에서 안 나온다. `auth.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 같은 줄이 `000` 을 찍고 `curl` 은 `7` 로 끝난다. `kc-lab-edge` 에서 쳐도 같은 값이다. + +```text +curl: (7) Failed to connect to auth.hyeonworks.com:443 after 3 ms: Could not connect to server +000 +``` + +이름 대신 엣지 주소를 짚어 주면 `200` 이 온다. 그래서 이 줄이 `000` 인 것은 Keycloak 이 아니라 이름이 가리키는 곳의 문제이고, 원상복구 판정에는 쓸 수 없다. + +```bash +curl -s -o /dev/null -w '%{http_code}\n' --max-time 20 \ + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master +``` + + `agroal_blocking_time_max_milliseconds` 는 **누적이라 20000 인 채로 남는다.** 파드를 재시작해야 0 이 되고, 가이드는 **그대로 두는 편이 낫다**고 적는다 — 「이 노드가 한 번 20초를 기다린 적이 있다」는 기록이다. @@ -10527,10 +10696,17 @@ kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found `max_used_count 19.0` · `acquire_count_total 672.0` · `blocking_time_average 281.0`, 이벤트 세 줄과 `89s`/`32m`/`52m`, 낙관적 락 로그 `0`, 해제 뒤 `noqueue` 와 `43 ms`/`51 ms`, `agroal_*` 지표 이름 열두 개. -- (unknown) `enp1s0` 과 `flannel.1` 에 각각 거는 `tcpdump` 두 줄, - `tc filter show`, 낙관적 락 로그를 세는 `grep -icE` 줄. 가이드가 셋 다 - **미검증**으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 - 두 단계 형태도 이 실험대에서 치지 않았다. +- (unknown) `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 두 단계 형태. + 이 실험대에서 치지 않았다. 가이드가 **미검증**으로 표시한 넷 — `enp1s0` 과 + `flannel.1` 에 각각 거는 `tcpdump` 두 줄과 `tc filter show` 와 낙관적 락 + 로그를 세는 `grep -icE` — 은 2026-09-17 에 전부 쳤다. +- (observed, 2026-09-17) 관찰 다섯 절과 복구를 다시 밟은 값 — 주입 전 + `44 ms`/`40 ms`, 주입 뒤 `1865 ms`/`40 ms`, `connect 0.000552 ttfb 1.864861`, + 동시 20건이 `1.871238` 에서 `22.245645` 까지 스무 줄 전부 `200`, + `blocking_time_max 20000.0` 과 `max_used_count 18.0`, readiness 프로브가 + `context deadline exceeded` 로 한 줄, 낙관적 락 로그 `0` 과 종료 코드 `1`, + 해제 뒤 `noqueue` 와 `42 ms`/`39 ms`, 엔드포인트 주소 둘. 원문은 + `relive-2026-09-17/a6-05..07`. - **구조에서 나온 결론이고 출력이 없는 것** — 「`enp1s0` 에 걸면 0 패킷」. 원 실행은 `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔다. - **센 것이고 잰 것이 아닌 것** — 왕복 `9` 는 A-0 이 잡은 SQL 목록을 센 값이고 @@ -10835,6 +11011,13 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help- persistent-user-sessions[:v1] ← 목록에 있다 ``` +**★ 2026-09-17 에 쳤더니 두 줄이 나왔다**(observed). 버전 접미사가 붙은 것과 안 붙은 것이 목록에 따로 있다. 하나만 나올 것으로 알고 있으면 두 줄째를 딴 기능으로 읽는다. + +```text + persistent-user-sessions[:v1] + persistent-user-sessions +``` + 이름이 목록에 있으므로 이 버전(`quay.io/keycloak/keycloak:26.7.0`)에서는 아직 끌 수 있다. **목록에 없으면 그 버전에서는 이 실험을 할 수 없고**, 기능이 제거돼 기본 동작으로 고정된 것이며 그 자체가 답이다. `--help-all` 은 출력이 길고 @@ -10900,6 +11083,14 @@ Waiting for 1 pods to be ready... partitioned roll out complete: 2 new pods have been updated... ``` +**★ patch 로 바꾸면 `configured` 가 안 나온다**(2026-09-17, observed). `configured` 는 `kubectl apply` 가 내는 말이고, patch 는 `statefulset.apps/keycloak patched` 를 낸다. 아래 「`configured` 가 나와야 한다」는 매니페스트를 고쳐 `apply` 한 경우에만 맞는 판정이다. patch 로 쳤으면 바뀐 것을 args 문자열로 확인한다 — 9번이 그 일을 한다. + +```text +statefulset.apps/keycloak patched +``` + +이 실험대에서 patch 로 쳤을 때 롤아웃은 `18:02:41` 에 시작해 `18:03:47` 에 끝났다. 재빌드까지 66초다. + `configured` 가 나와야 한다. `unchanged` 면 **args 가 안 바뀐 것**이다. **`--features-disabled` 는 빌드 옵션이라** 기동 시 재빌드가 일어나 평소보다 오래 걸린다. `--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. **시각을 @@ -10925,6 +11116,31 @@ date '+%H:%M:%S 차단' | `kc-lab-1` | `keycloak-1` | `$K1` | | `kc-lab-2` | `keycloak-0` | `$K0` | +**★ 이 블록은 `kc-lab-1` 에서 안 돈다**(2026-09-17, observed). 앞의 「읽기 전에」가 적은 그대로다 — 그 기계에는 kubeconfig 가 없어서 첫 두 줄의 `kubectl` 이 막히고, `$K0` 와 `$K1` 이 빈 문자열이 된다. 그 상태로 `iptables` 가 이어지면 `-d` 가 값을 못 받아 규칙이 하나도 안 들어간다. 그리고 `kc-lab-1` 에는 `kc-lab-2` 의 호스트 키가 없어 뒤의 두 줄도 접속 단계에서 끝난다. 여섯 줄이 전부 실패했는데 마지막 `date` 는 그대로 `차단` 을 찍는다. + +```text +error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied +Bad argument `7800' +Bad argument `57800' +Host key verification failed. +Host key verification failed. +09:04:49 차단 +``` + +찍힌 시각을 주입 시각으로 적으면 A-6 의 `적용완료` 와 같은 함정이다. 그때 두 노드의 `raw PREROUTING` 은 비어 있었다. + +kubeconfig 가 있는 lab host 에서 두 노드 모두 한 줄 `ssh` 로 치면 된다. 큰따옴표가 값을 lab host 에서 펴서 보내는 것은 아래 설명과 같고, 대상만 `kc-lab-1` 이 하나 더 는다. + +```bash +K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') +K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') +ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP" +ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP" +ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP" +ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP" +date '+%H:%M:%S 차단' +``` + NetworkPolicy 가 아니라 iptables 인 까닭은 A-1 에서 배운 것이다. NetworkPolicy 는 **conntrack 의 ESTABLISHED 를 못 뚫는다** — 이미 붙어 있는 7800 연결은 계속 산다. A-5 가 그 벽을 넘는 방법을 확립했다. @@ -11047,6 +11263,19 @@ kubectl -n observability exec deploy/prometheus -- \ keycloak-1 sessions 캐시 0.0 건 ``` +**★ 위 두 줄은 이 명령의 출력이 아니다**(2026-09-17, observed). `tr` 과 `grep` 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 96줄이었다. 위의 요약 두 줄은 원 실행의 스크립트가 만든 모양이다(inferred). 앞의 ① 이 찍는 JSON 도 마찬가지로 `sessions` 만이 아니라 캐시 전부를 담고 있다. + +```text +"cache":"sessions" +"pod":"keycloak-1"} +"82"]} +"cache":"clientSessions" +"pod":"keycloak-1"} +"82"]} +``` + +세 줄이 한 묶음이고 마지막 줄의 숫자가 그 캐시의 엔트리 수다. 세로로 읽으면서 캐시 이름과 파드 이름을 눈으로 짝지어야 한다. + **persistent 였을 때와 똑같은 숫자다.** `approximate_entries_unique` 는 **그 노드가 소유한 엔트리**만 센다. 백업본을 들고 있어도 0 으로 보인다. @@ -11060,6 +11289,13 @@ DB 행이 있느냐이고, 그다음은 7800 을 끊어 보는 것이다. 차단 쪽 검증은 **양쪽 카운터를 둘 다 본다.** +```bash +ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v' +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' +``` + +원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). + ```bash sudo iptables -t raw -L PREROUTING -n -v ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' @@ -11099,6 +11335,8 @@ kubectl -n observability exec deploy/prometheus -- \ `2.0 2.0` 이 `1.0` 으로 떨어지는 데 **50초쯤 걸린다.** +**★ 이 실험대에서는 `+75초` 에 떨어졌고 시계열에 빈 값이 없었다**(2026-09-17, observed). 손으로 25초마다 친 다섯 번은 `2 2` · `2 2` · `1 1` · `1 1` · `1 1` 이었다. 차단을 푼 뒤 양쪽이 `2` 로 돌아오는 데는 `+60초` 가 걸렸다. + **★ `[]` 와 값이 하나뿐인 줄은 측정 실패다.** 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼는데, 파드 생성이 느리고 경합이 있어 **빈 응답이 섞였다.** A-1 가이드가 지적한 그 문제가 여기서도 그대로 보인다. @@ -11407,6 +11645,13 @@ deployment "postgres" successfully rolled out **순서가 있다.** iptables 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, args 를 되돌린다. +```bash +ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n' +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n' +``` + +원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). + ```bash sudo iptables -t raw -L PREROUTING -n ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n' @@ -11479,6 +11724,10 @@ postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found ``` +**★ 마지막 줄의 `200` 은 이 실험대에서 안 나온다**(2026-09-17, observed). `auth.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어 `000` 이 나온다. 엣지 주소를 짚으면 `200` 이고, 까닭은 A-6 의 같은 줄에 적었다. 나머지 여덟 줄은 이 실험대에서 그대로 통과했다 — args `["start"]`, `git diff` 출력 없음, 파드 둘 다 `1/1 Running`, postgres `1/1 Running`, 로그인 뒤 세션 행 `1`, 두 노드 `raw PREROUTING` 비어 있음, `vendor_cluster_size` 양쪽 `2`, 탐침 `NotFound`. + + + #### 막히면 가이드는 이 표를 두고 **전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 @@ -11511,10 +11760,17 @@ kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found 뒤 캐시 `1.0`, 분단 대기 시계열 다섯 줄, 대조군 `200` 과 시험군 `400`, DB 정지 뒤 `① 500` · `② 200`, 원복 뒤 `["start"]` 와 `DB 온라인 세션: 1 건` 과 외부 `200`, 비밀번호 길이 `19`, 기능 목록의 `persistent-user-sessions[:v1]`. -- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력. 가이드가 - **미검증**으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 - 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 - 확인도 이 실험대에서 치지 않았다. +- (observed, 2026-09-17) 가이드가 **미검증**으로 표시한 + `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력은 이날 쳤다. 나오는 모양은 + 위 캐시 엔트리 절의 ★ 에 적었다. +- (unknown) `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 + 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인. 이 실험대에서 + 치지 않았다. +- (observed, 2026-09-17) 1번부터 복구까지 다시 밟은 값 — `DELETE 144`, 로그인 + 5회 `200 200 200 200 200` 뒤 DB `(0 rows)`, 교차 노드 refresh `200`, 재시작 뒤 + `400` 과 `Session not active`, 분단에서 `same-node 200` 과 `cross-node 400`, + `coord = t` 둘, DB 정지 뒤 `500` 과 `200`, 원복 뒤 `["start"]` 와 세션 행 `1`. + 원문은 `relive-2026-09-17/a7-06..08`. - **측정이 샌 곳** — `cluster_size` 시계열의 `[]` 와 값이 하나뿐인 줄. 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다. @@ -12609,7 +12865,7 @@ kubectl -n observability exec deploy/prometheus -- \ ```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)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 5 done echo @@ -12727,7 +12983,7 @@ echo "$K0 $K1" ```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)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 1 done echo @@ -13331,6 +13587,15 @@ docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s c 그 형태는 이 문서에도 없다(unknown). 겹친 인용이 실제로 어떻게 깨지는지는 A-0 의 측정 실패 기록에 남아 있다. +```bash +ssh kc-lab-1 'sudo k3s ctr images ls | grep keycloak-pattern-bff' +ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff' +``` + +**★ 원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 쳤다**(2026-09-17, observed). 그 기계에는 `kc-lab-2` 의 +호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다. 두 줄 다 lab host 에서 보내면 +양쪽이 다 나온다. + ```bash sudo k3s ctr images ls | grep keycloak-pattern-bff ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff' @@ -13414,7 +13679,7 @@ redis-568bd7c4-5c5vc true kc-lab-2 `ErrImageNeverPull` 이면 이미지 import 로 돌아간다. ```bash -curl -I https://app1.hyeonworks.com/ +curl -I --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/ ``` **형태**(모양은 observed) @@ -13448,6 +13713,29 @@ $ curl https://app1.hyeonworks.com/actuator/beans Bad Gateway ``` +**★ 지금은 밖에서도 받아진다**(2026-09-17, observed). TLS 를 세우고 같은 주소를 쳤더니 +`200` 에 `155395` 바이트가 그대로 왔다. 위 `Bad Gateway` 는 엣지 nginx 의 버퍼나 프록시 +체인이 지금과 다른 상태에서 잰 값으로 보인다(inferred). + +```bash +curl -s -o /tmp/beans-out.json -w 'code=%{http_code} size=%{size_download}\n' https://app1.hyeonworks.com/actuator/beans +``` + +```text +code=200 size=155395 +``` + +그래도 아래 「파드 안에서 받는다」를 그대로 쓴다 — 밖에서 받는 것이 목적이 아니고 프록시 +설정이 바뀌면 다시 막힐 수 있다. 다만 `Bad Gateway` 를 「이 주소는 원래 밖에서 안 된다」로 +읽지는 않는다. + +**그리고 B-0 의 주입은 2026-09-17 에도 밟지 않았다.** 막은 것이 인증서가 아니라 소스다 — +주입 1 절이 `keycloak-pattern` 의 파일 넷을 고치라고 하는데 그 저장소에 이 작업과 무관한 +커밋 안 된 변경이 여럿 있어 손대지 않았다. 지금 실험대의 BFF 는 B-0 이 아니라 B-1·B-2 +상태이고(빈 목록에 `RedisSessionRepository` 와 `JdbcOAuth2AuthorizedClientService` 가 다 +있다), 그 상태에서 replica 2 로 로그인하면 **성공한다.** B-0 은 같은 조건에서 실패한다고 +적으므로, 두 편을 가르는 것이 세션 저장소 하나임이 반대편에서 확인된다. + 그래서 파드 안에서 직접 받는다. **alpine 기반 JRE 이미지에는 `wget` 이 있다** — Keycloak 이미지와 다른 점이다. @@ -14315,6 +14603,60 @@ echo "$KEY" 바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬다. **가이드는 그 두 단계 형태를 적어 두지 않았다**(unknown). +**★ 그런데 `head -1` 이 엉뚱한 세션을 집는다**(2026-09-17, observed). 원래 실행은 키가 +하나였지만 둘 이상일 때가 있다. TLS 를 세우고 로그인 한 번을 한 뒤 키가 둘이었고, +`head -1` 이 고른 쪽에는 **`SPRING_SECURITY_CONTEXT` 가 없었다.** + +```text +bff:session:sessions:b66634a2-… SPRING_SECURITY_SAVED_REQUEST · maxInactiveInterval · creationTime · lastAccessedTime +bff:session:sessions:c5f7b0c7-… SPRING_SECURITY_CONTEXT · …AUTHORIZATION_REQUEST · SPRING_SECURITY_LAST_EXCEPTION · lastAccessedTime · maxInactiveInterval · creationTime +``` + +앞엣것은 로그인 화면으로 보내기 전에 만들어진 세션이라 인증 정보가 없다. 그것을 잡으면 +아래 필드 목록에 `SPRING_SECURITY_CONTEXT` 가 안 나오고 `\xac\xed` 도 못 본다 — **이 편이 +보여 주려는 것이 통째로 안 보인다.** 오류는 안 나므로 화면만으로는 「토큰이 없네」와 +「인증 세션이 아니네」가 갈리지 않는다. 키를 내용으로 고르면 몇 개가 있든 맞는 것을 잡는다. + +```bash +KEY= +for K in $(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | tr -d '\r'); do + HAS=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli hexists "$K" 'sessionAttr:SPRING_SECURITY_CONTEXT' | tr -d '\r') + echo "$HAS $K" + if [ "$HAS" = 1 ]; then KEY=$K; fi +done +echo "고른 키: $KEY" +``` + +첫 줄의 `KEY=` 가 없으면 이 검사는 실패할 수가 없다. 앞 단계의 `head -1` 이 이미 +`$KEY` 를 채워 뇌기 때문에, 루프가 한 건도 못 맞혀도 마지막 `echo` 는 잘못 고른 그 +키를 그대로 찍는다 — 화면은 정상인데 고쳐진 것이 없는 상태다. 키마다 `1`/`0` 을 +앞에 찍는 것도 같은 까닭이다. `grep -q` 로 조용히 거르면 어느 키가 왜 떨어졌는지가 +화면에 안 남는다. + +```text +1 bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23 +1 bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688 +고른 키: bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688 +``` + +2026-09-17 에는 두 키가 다 인증 세션이었고(observed) 루프는 마지막 것을 잡았다. +「맞는 키가 하나뿐」 을 전제하는 절차가 아니다. 같은 날 `hkeys` 는 필드 여섯을 내서 +`sessionAttr:SPRING_SECURITY_SAVED_REQUEST` 하나가 없었는데, 토큰이 없다는 결론은 +그대로다. + +**★ 그리고 필드 순서를 기대하면 안 된다**(observed). 아래 `hgetall | head -4` 는 +`SPRING_SECURITY_CONTEXT` 가 첫 필드로 나온다고 보고 자른 것인데 Redis 해시의 필드 +순서는 보장되지 않는다. 다시 쳤을 때는 `lastAccessedTime` 이 먼저 나왔다. + +```text + 1) "lastAccessedTime" + 2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number…" +``` + +**요점은 오히려 더 세게 확인된다.** `\xac\xed` 가 `SPRING_SECURITY_CONTEXT` 에만 붙는 것이 +아니라 `lastAccessedTime` 의 `Long` 하나에도 붙는다 — 이 해시의 값은 전부 Java 직렬화다. +특정 필드를 보려면 `head` 로 자르지 말고 이름을 대서 `hget` 한다. + ```bash kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY" kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY" @@ -14817,6 +15159,36 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 보려면 `base64 -d | wc -c` 로 센다. +**2026-09-17 에 이 셋을 나란히 쳤고, 두 수가 안 맞는다**(observed). + +```text +② select count(*) from oauth2_authorized_client → 0 +③ select offline_flag, count(*) … group by 1 → offline_flag 0 · count 5 +④ kcadm get client-session-stats → clientId bff-confidential · active 4 +``` + +DB 는 `5` 인데 관리 API 는 `4` 다. **둘이 세는 단위가 다르다** — `offline_user_session` +은 사용자 세션의 행이고 `client-session-stats` 는 클라이언트 세션을 클라이언트마다 +센다. 사용자 세션 하나에 클라이언트 세션이 0개일 수도 여럿일 수도 있으므로 두 수는 맞을 +이유가 없다. **대조군으로 쓸 때는 둘 중 하나를 골라 끝까지 그것만 쓴다.** + +**그리고 `--scan | xargs … del` 에 `tr -d '\r'` 이 없어도 된다**(observed). 오래 의심만 +하고 안 가렸던 것인데, 시험용 키를 넣고 그 형태를 그대로 쳤더니 `del` 이 `5` 를 돌려주고 +스캔이 빈손이 됐다. + +```bash +kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' | od -c | head -3 +``` + +```text +0000000 b f f : s e s s i o n : p r o b +0000020 e 3 \n +``` + +줄 끝이 줄바꿈 하나다. **갈리는 것은 `redis-cli` 가 아니라 `kubectl exec` 에 tty 가 붙었는지다** +— 파이프로 받는 이 형태에는 tty 가 없어 CR 도 없다. B-1 은 tty 가 붙는 형태로 쳤고 그쪽에서는 +그 조각이 필요하다. + 브라우저에서 `https://app1.hyeonworks.com/` 를 열고 **Keycloak 로그인** 을 눌러 `labuser` / `labpass` 로 들어간 뒤 **token 경계 확인** 을 누른다. @@ -15068,6 +15440,28 @@ eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU **`convert_from` 이 성공한다는 것 자체가 답이다** — 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 난다. 읽힌다는 것은 텍스트라는 뜻이다. +**★ 2026-09-17 에 이 구간을 처음 밟았고, 값이 문서와 거의 그대로 나온다**(observed). +TLS 를 세우고 로그인한 뒤 7 절과 관찰 1·2 를 전부 쳤다. + +```text + client_registration_id | principal_name | access_token_type | at_len | rt_len +------------------------+----------------+-------------------+--------+-------- + keycloak | labuser | Bearer | 1437 | 744 +``` + +`rt_len` 이 `744` 로 같고 `at_len` 만 `1431` 대신 `1437` 이다 — access token 은 클레임에 +따라 길이가 조금씩 달라진다. 미검증이던 `left(convert_from(...), 40)` 형태도 그대로 돌고, +나온 문자열이 위에 실린 36자와 **한 글자도 다르지 않다.** + +**덮어쓰기도 재현됐다**(observed). 같은 사용자로 새 브라우저에서 한 번 더 로그인했더니 +**행 수는 `1` 그대로인데 `at_md5` 가 바뀌었다** — 앞 로그인의 토큰이 그 순간 사라진다. +`PRIMARY KEY (clientRegistrationId, principalName)` 가 그렇게 만든다. + +```text +전 행 1 · at_md5 722769ac041a42164d08060e5446d02b +뒤 행 1 · at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 · 발급 2026-09-17 08:05:52 +``` + 정말 JWT 인지 헤더를 풀어 본다. 가이드는 이 줄도 **미검증**으로 표시한다(unknown). ```bash @@ -16817,7 +17211,8 @@ kubectl apply -f /tmp/grafana-ingress-backup.yaml ```bash kubectl get ingress -A | grep app2 -curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \ + https://app2.hyeonworks.com/ ``` `app2` 를 잡고 있는 Ingress 가 **`observability/grafana` 하나**여야 한다. 둘이면 어느 쪽이 @@ -16985,7 +17380,18 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly | `save` | 빈 값 | **RDB 스냅샷이 꺼져 있다** | | `appendonly` | `no` | **AOF(append-only file, 쓰기를 순서대로 적어 두는 파일)도 꺼져 있다** | -**그때는 영속화가 아예 꺼져 있었다.** 지금 환경은 다를 것이다 — 매니페스트가 `--appendonly yes` +**그때는 영속화가 아예 꺼져 있었다.** + +**★ 2026-09-17 에 쳤더니 `save` 도 비어 있지 않았다**(observed). 이 이미지의 기본 스냅샷 조건 셋이 그대로 들어 있다. 그래서 둘째 주입의 출발 조건은 「AOF 만 켠 상태」가 아니라 「AOF 와 스냅샷이 둘 다 켜진 상태」이고, 그래도 결과는 같다 — 볼륨이 없으면 둘 다 컨테이너와 함께 사라진다. + +```text +save +3600 1 300 100 60 10000 +appendonly +yes +``` + +같은 날 `redis-cli --scan` 은 아무것도 안 찍었다. 브라우저 로그인을 안 한 실험대라 세션 키가 0개였고, 그 상태에서는 「잃는 것」이 안 보인다. 로그인을 먼저 해 두라는 전제는 이 때문이다. 지금 환경은 다를 것이다 — 매니페스트가 `--appendonly yes` 로 시작하므로 `appendonly yes` 가 나오고, **그 차이가 두 번째 주입의 출발 조건이다.** `save` 출력의 값이 비어 있는 것과 키가 없는 것은 다르다. `config get save` 는 항상 두 @@ -17045,7 +17451,8 @@ Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에 ```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" + curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p" done ``` @@ -17074,9 +17481,12 @@ done health 그룹도 미리 본다. `/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 +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health; echo +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health/readiness; echo +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health/liveness; echo ``` **첫 번째 응답의 본문에 `redis` 항목이 있는지, 두 번째 응답에는 없는지**를 본다. 세 응답이 @@ -17209,6 +17619,15 @@ kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|nett at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final] ``` +**★ 이 실험대의 로그는 다른 줄을 냈다**(2026-09-17, observed). 스택이 아니라 Lettuce 의 재연결 로그다. Service 는 남아 있고 뒤에 파드가 없으므로 `Connection refused` 이고, 위의 「맺는 중」과 달리 여기서는 즉시 거절당한 뒤 다시 시도한다. 그래도 요청 쪽은 똑같이 매달린다. + +```text +i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379 +i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [redis.keycloak-lab.svc/:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379 +``` + +그리고 `grep -iE 'redis|connect|netty'` 는 `connect` 때문에 Hikari 의 PostgreSQL 커넥션 경고까지 잡는다. `tail -10` 의 앞쪽 여섯 줄이 그것이었고 Redis 와 무관하다. + **`pollConnect` · `finishConnect`.** 연결을 **맺는 중**이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. **관찰 절의 `000` 이 여기서 나온다.** @@ -17241,6 +17660,14 @@ kubectl -n keycloak-lab get pod -l app=redis \ **빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 **당연히 살아남는다** — 그리고 그걸 「영속화가 잘 된다」로 오독한다. +**★ 빈 줄은 안 나온다**(2026-09-17, observed). 쿠버네티스가 서비스 계정 토큰을 `kube-api-access-…` 라는 projected 볼륨으로 자동으로 붙이므로, `volumes` 배열을 통째로 지워도 새 파드에는 그 항목 하나가 다시 들어 있다. 길이가 아니라 이름을 본다 — `persistentVolumeClaim` 이 안 보이면 떨어졌다. + +```text +[{"name":"kube-api-access-d7bbm","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}] +``` + +`volumeMounts` 쪽으로 보면 더 짧다. `/data` 가 없고 `/var/run/secrets/kubernetes.io/serviceaccount` 하나만 남는다. 그리고 이 명령이 고르는 `.items[0]` 은 방금 지운 파드일 수 있다 — 같은 라벨에 `Completed` 파드가 남아 있는 것을 이 실험대에서 봤다. + **실측**(observed) — `04-persistence.txt` ```text @@ -17262,7 +17689,8 @@ kubectl -n keycloak-lab get pod -l app=redis \ ```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" + curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p" done ``` @@ -17281,6 +17709,19 @@ done | **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 | | `503` | 헬스 엔드포인트는 **대답은 한다** — 다만 DOWN 이라고 | +**★ 위 블록의 `--max-time 90` 은 원 가이드의 `--max-time 10` 을 고친 것이다**(2026-09-17, observed). 10초에서 끊으면 `/actuator/health` 도 `000` 이 되어 세 줄이 `200`·`000`·`000` 으로 나오고, 바로 위의 실측과 안 맞는다. 이 실험대에서 그 줄은 `60.046452` 초 뒤에 `503` 을 줬고 `/bff/token-boundary` 는 90초까지 기다려도 안 왔다. 그래서 90초까지 기다리게 하고 `%{time_total}` 로 걸린 시간을 같이 찍는다. + +```text +health 503 total 60.046452 +token-boundary 000 total 90.001609 +``` + +그러니까 헬스 엔드포인트도 빨리 실패하지 않는다. 60초는 Redis 명령 타임아웃이고, 그때 돌아온 본문의 `redis` 항목은 「붙지 못했다」가 아니라 「명령이 시간을 넘겼다」였다. + +```text +"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}} +``` + **오류를 돌려주는 것이 아니라 매달려 있다.** ```text @@ -17306,10 +17747,14 @@ done **★ 그런데 파드는 `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 +curl -s -o /dev/null -w 'health %{http_code} total %{time_total}\n' --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health +curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/readiness +curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/liveness +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health/readiness; echo ``` **실측**(observed) — `03-health-groups.txt` @@ -17402,7 +17847,8 @@ kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s ```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" + curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p" done kubectl -n keycloak-lab get pods -l app=bff ``` @@ -17443,6 +17889,17 @@ deployment "redis" successfully rolled out appendonly no ``` +**★ 설정이 되돌아가는 것은 이 실험대에서 안 보인다**(2026-09-17, observed). 데이터는 그대로 사라져 `dbsize` 가 `0` 이고 `b5:aof` 는 빈 값인데, `config get appendonly` 는 `no` 가 아니라 `yes` 다. 지금 매니페스트의 `args` 가 이미 `--appendonly yes` 라서, 「재기동하면 매니페스트가 이긴다」는 규칙이 이번에는 켜진 값을 되살린다. 규칙은 그대로 맞다. 매니페스트가 이미 `yes` 라서 되돌아가도 값이 안 바뀔 뿐이다. + +```text +0 + +appendonly +yes +``` + +볼륨을 되돌리고 같은 시험을 다시 했을 때는 `b5:pvc` 가 `written-on-pvc` 로 살아남았고 `dbsize` 는 `4` 였다. 나머지 셋은 그 사이에 BFF 가 만든 `bff:session:sessions:…` 키다. + **두 가지가 같이 사라졌다.** | 사라진 것 | 왜 | @@ -17575,7 +18032,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan | BFF | `kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1`, `RESTARTS 0` | | 엔드포인트 | `… get endpointslice -l kubernetes.io/service-name=bff` | ready 주소 **둘** | | 실험 키 | `… redis-cli --scan` | `b5:*` 없음 | -| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://app1.hyeonworks.com/` | `200` | +| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/` | `200` | + +**★ 이 편의 `curl` 에 `--resolve` 가 붙어 있는 까닭이다**(2026-09-17, observed). `app1.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 이름만 치면 이 줄도 앞의 세 경로도 전부 `000` 이다. 그러면 주입 뒤와 견줄 값이 없다. 원 가이드가 적은 형태는 아래와 같고, 지우지 않고 남긴다. + +```bash +for p in / /bff/token-boundary /actuator/health; do + curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p" +done +``` + +나머지 일곱 줄은 이 실험대에서 그대로 통과했다 — Redis `1/1`, 볼륨에 `persistentVolumeClaim`, PVC `redis-data` `Bound`, `appendonly yes`, BFF 둘 다 `1/1` 이고 `RESTARTS 0`, 엔드포인트 주소 둘, `--scan` 에 `b5:*` 없음. **로그인 세션은 돌아오지 않는다.** 브라우저에서 다시 로그인하는 것이 복구다. @@ -17629,6 +18096,13 @@ deployment "redis" successfully rolled out 했다** — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 `curl` 에 로그인 쿠키가 없어 `/bff/token-boundary` 가 `3xx` 로 나오는 것도 가이드가 미검증으로 표시했다. +- (observed, 2026-09-17) 1번부터 복구까지 다시 밟은 값 — Redis 를 0대로 내려도 `bff` 둘이 `1/1` + 이고 엔드포인트가 `true,true`, `/actuator/health` 는 `60.046452` 초 뒤 `503` 과 + `QueryTimeoutException: Redis command timed out`, `/bff/token-boundary` 는 90초까지 무응답, + 볼륨 없이 파드를 지운 뒤 `dbsize 0` 과 `appendonly yes`, 볼륨을 되돌린 뒤 `dbsize 4` 와 + `written-on-pvc`, 네 지표는 여전히 시계열 0개. 원문은 `relive-2026-09-17/b5-04..07`. +- (unknown) 브라우저 로그인 전제를 2026-09-17 에는 밟지 않았다. Redis 키가 0개인 채로 쟀으므로 + 「로그인한 사용자가 로그아웃된다」는 그 실행에서 확인하지 않았다. - **이 실험이 재지 않은 것** — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. `readiness` 그룹에 `redis` 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다. @@ -17674,6 +18148,63 @@ Q3 의 미지수 3 은 이렇게 물었다. 「교체」라는 한 단어가 실제로는 **성질이 정반대인 두 조작**이다. 회전이 위험한 것이 아니라 **옛 키를 언제 버리느냐**가 위험하다. +**2026-09-17 에 이 실험을 새 실험대에서 다시 밟았다 — 절반은 재현됐고 절반은 못 쟀다**(observed). + +키 쪽은 그대로 나왔다. `priority` 200 짜리 공급자를 추가하니 JWKS 의 RS256 이 1 에서 2 로 +늘고 새 토큰의 `kid` 가 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 +`kid` 가 목록에서 사라졌다. + +**판정 쪽은 못 쟀다.** 리소스 서버 `echo` 는 JWKS 를 +`SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI` 가 가리키는 +`https://auth.hyeonworks.com/...` 에서 받는데 그 443 에 듣는 것이 없다. 그래서 JWKS 를 아예 +못 받고 **회전과 무관하게 모든 토큰이 `401`** 이다. 로그에 이렇게 남는다. + +```text +sun.security.ssl.SSLSocketImpl.connect … +org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:900) +``` + +대조군인 「회전 전 옛 토큰이 `200`」이 안 나오므로, 제거 뒤의 `401` 을 키 제거에 귀속할 수 +없다. 이 실험의 결론 자체가 그 `401` 이라, 인증서가 설 때까지 이 편의 판정은 못 낸다. + +**막는 것은 두 겹이었다**(observed). 인증서가 없는 것이 하나이고, 그 이름이 클러스터 +안에서 엣지를 안 가리키는 것이 또 하나다. 파드에서 풀면 `auth.hyeonworks.com` 이 +`100.83.212.4` 로 가는데 그 80·443 은 둘 다 닫혀 있고, 실제로 열려 있는 +`192.168.122.10:80` 을 가리키는 것이 클러스터 DNS 어디에도 없다. 가상화 쪽 03 이 nginx 를 +엣지로 옮기면서 끊긴 경로이고, 거기에 적어 둔 CoreDNS 한 단계가 그것을 놓는다. + +**그리고 `echo` 에는 클러스터 안 주소가 아예 없다**(observed). 같은 실험대의 BFF +매니페스트는 밖으로 보이는 주소와 안에서 쓰는 주소를 나눠 갖는다. + +```text +KC_ISSUER_EXTERNAL https://auth.hyeonworks.com/realms/keycloak-patterns +KC_ISSUER_INTERNAL http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns +``` + +`echo.yaml` 에는 그 짝이 없고 `ISSUER_URI` 와 `JWK_SET_URI` 둘 다 바깥 `https` 주소다. +그래서 토큰을 **받는** 쪽은 클러스터 안 길로 돌아가는데 **검증하는** 쪽만 바깥 길에 +묶인다. B-6 이 인증서에 걸리는 진짜 이유가 그 비대칭이고, B-0·B-1·B-2·C-1 도 같은 +리소스 서버를 쓴다. + +**그리고 이 실험대에서는 「만료인지 아닌지」를 가르는 두 줄이 틀린 답을 낸다**(observed). +`exp` 는 Keycloak 이 게스트 시계로 찍고 `date +%s` 는 랩 호스트 시계로 찍는데, 같은 순간에 +재 보니 **93초 어긋나 있다.** + +```text +lab host 1789629566 NTP=no NTPSynchronized=no +kc-lab-1 1789629472 NTP=yes NTPSynchronized=yes +kc-lab-2 1789629473 NTP=yes NTPSynchronized=yes +kc-lab-edge 1789629473 +``` + +게스트 셋은 서로 맞고 랩 호스트만 앞선다. 방금 받은 60초짜리 토큰도 랩 호스트에서 `exp` 를 +재면 이미 33초 전에 만료된 것으로 읽힌다. §D-4·§D-4a 가 같은 호스트에서 +`NTPSynchronized=no` 와 `+106.1` 을 이미 재 두었는데, B-6 은 그것을 모르는 채로 `exp` 비교를 +시킨다. 두 값을 같은 기계에서 뽑아야 갈린다. + +**realm 의 수명 두 값은 이제 실측이 있다**(observed) — `accessTokenLifespan` 은 `60`, +`ssoSessionIdleTimeout` 은 `1800`. `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다. + #### 전제와 되돌리기 - `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `bff-confidential` 과 @@ -17755,6 +18286,14 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ JWKS 원문도 한 번은 통째로 본다. 줄바꿈 없이 한 줄로 길게 나오지만, 어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸를지 안다. +```bash +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` + +**★ `--resolve` 는 2026-09-17 에 붙인 것이다**(observed). 원 가이드는 이름만 쳤는데 랩 호스트에서는 +그 이름이 호스트 자신의 tailnet 주소로 풀리고 거기에는 443 을 듣는 것이 없어 `000` 이 된다. + ```bash curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs ``` @@ -17769,7 +18308,8 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con 따로 붙는다.** 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다. ```bash -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr ',' '\n' | grep kid ``` @@ -17796,7 +18336,8 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con 표시한다(unknown). ```bash -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr '}' '\n' | grep -c RS256 ``` @@ -17809,8 +18350,33 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ ``` 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 -키다. **이 두 명령은 원래 실행 기록에 출력이 없다** — 위에 인용한 「RS256 키 수: 1」만이 -실측이다. +키다. 원래 실행 기록에는 이 두 명령의 출력이 없었는데, **2026-09-17 에 둘 다 쳐 봤고 둘 다 +돈다**(observed). + +```text +1 +``` + +```text +"kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917" "status" : "ACTIVE" "algorithm" : "AES" +"kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c" "status" : "ACTIVE" "algorithm" : "HS512" +"kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" "status" : "ACTIVE" "algorithm" : "RS256" +"kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" "status" : "ACTIVE" "algorithm" : "RSA-OAEP" +``` + +`tr '}'` 쪽이 내는 `1` 과 위의 `kid` 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 +RS256 이 하나, 암호화용 `RSA-OAEP` 가 하나이며 **JWKS 에는 그 둘만 실린다.** `AES` 와 `HS512` +는 JWKS 에 안 나온다. + +**다만 위 네 줄의 `curl` 은 랩 호스트에서 안 돈다**(2026-09-17, observed). 엣지 nginx 가 게스트로 +옮겨 간 뒤로 `auth.hyeonworks.com` 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 +듣는 것이 없다. tailnet 에 붙은 다른 기계에서 치거나, 랩 호스트에서 쳐야 하면 이름은 두고 주소만 +엣지로 못박는다. + +```bash +curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \ + http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` 토큰을 하나 받고 그 토큰의 `kid` 를 본다. direct grant 로 받고, 클라이언트 비밀은 Secret 에서 꺼내 변수로만 넘긴다. @@ -17819,7 +18385,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token CS=$(kubectl -n keycloak-lab get secret bff-secrets \ -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d) -OLD=$(curl -s -X POST "$KC" \ +OLD=$(curl -s -X POST --resolve auth.hyeonworks.com:443:192.168.122.10 "$KC" \ -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \ -d username=labuser -d password=labpass -d scope=openid \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') @@ -17887,7 +18453,8 @@ JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다. `kid` 가 없다면 검 먼저 응답을 통째로 한 번 본다. ```bash -curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me +curl -s -i -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` 상태줄과 본문을 본다. 200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` @@ -17896,7 +18463,8 @@ curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me ```bash curl -s -o /dev/null -w 'old %{http_code}\n' \ - -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` **실측**(observed) — `01-before-rotation.txt` @@ -17956,7 +18524,8 @@ Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16' 다시 친다. ```bash -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr ',' '\n' | grep kid ``` @@ -17999,9 +18568,11 @@ echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo ```bash curl -s -o /dev/null -w 'old %{http_code}\n' \ - -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me curl -s -o /dev/null -w 'new %{http_code}\n' \ - -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` **실측**(observed) — `02-rotation.txt` @@ -18072,7 +18643,8 @@ date '+%H:%M:%S 제거' 조용히 끝나면 성공이고 **시각을 적어 둔다.** JWKS 에서 사라졌는지는 또 같은 명령으로 본다. ```bash -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr ',' '\n' | grep kid ``` @@ -18090,9 +18662,11 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con ```bash curl -s -o /dev/null -w 'old %{http_code}\n' \ - -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me curl -s -o /dev/null -w 'new %{http_code}\n' \ - -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` **실측**(observed) — `03-old-key-removed.txt` @@ -18769,7 +19343,7 @@ B-7 에서 Grafana Ingress 를 빌렸다면 **여기서 돌려준다.** C-1 을 ```bash kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` | 항목 | 명령 | 돌아왔을 때 | @@ -18903,7 +19477,7 @@ Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 먼저 본다. ```bash -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` **형태**(모양은 observed) — Grafana 로 가고 있으면 `302` 로 `/login` 을 가리킨다. @@ -18959,8 +19533,10 @@ oauth2-proxy-c76b49c59-b9928 true kc-lab-2 자체이고, 하나면 「공유」라는 말이 성립하지 않는다. ```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 +curl -s -o /dev/null -w '/ %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \ + https://app2.hyeonworks.com/ +curl -s -o /dev/null -w '/ping %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \ + https://app2.hyeonworks.com/ping ``` **실측**(observed) — `01-deploy.txt` @@ -19387,7 +19963,7 @@ kubectl apply -f ~/grafana-ingress-backup.yaml ```bash kubectl -n observability get ingress grafana -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` Ingress 가 `observability` 에 다시 있고 `app2` 응답이 처음 본 모양으로 돌아왔는지 본다. @@ -19820,6 +20396,31 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c **`user_session_id` 를 적어 둔다.** 뒤에서 계속 쓴다. 그리고 `client_sessions` 가 **1** 이다. +**★ 2026-09-17 에 이 구간을 처음 밟았고 셋이 드러났다**(observed). + +첫째, **이 질의는 미검증이 아니라 돈다.** 다만 **출력의 칸 이름이 질의와 안 맞는다** — +질의는 `as clients` 인데 실린 출력의 머리는 `client_sessions` 다. `client_sessions` 는 +B-3 이 쓰는 다른 질의의 별칭이고 그쪽 출력이 여기로 섞여 들어왔다. C-1 의 질의를 그대로 +치면 머리는 `clients` 로 나온다. + +둘째, **행이 하나만 나오지 않는다.** direct grant 로 토큰을 여러 번 받아 본 실험대에서는 +그만큼 user session 이 쌓인다. 16행이 나왔고 그중 `clients` 가 `2` 인 한 줄이 SSO 세션이다. +「한 줄이 나온다」를 통과 조건으로 삼으면 안 된다. + +```text + 4i6Q7qb3Y_7Ib4mQzzsmTkzQ | keycloak-patterns | 1 + V0mkutuu-0tNdmvHBfWucB9j | keycloak-patterns | 2 +(16 rows) +``` + +셋째, **SSO 자체는 문서 그대로다.** app1 에 로그인한 쿠키로 app2 를 열자 로그인 화면이 +다시 뜨지 않았고(`login-actions/authenticate` 0건) 곧바로 업스트림 응답이 왔다. +`client-session-stats` 에 `bff-confidential` 과 `oauth2-proxy` 가 나란히 잡히고, 저장소 +쪽도 `_oauth2_proxy-*` 와 `bff:session:sessions:*` 가 각각 하나씩 늘었다. + +**다만 이 편도 `oauth2-proxy` 클라이언트가 없으면 로그인 화면에서 `Client not found.` 로 +끝난다** — 만드는 한 줄은 B-7 절에 적어 두었다. + ```bash kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*' ``` @@ -20119,7 +20720,7 @@ kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s ```bash kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` | 항목 | 명령 | 돌아왔을 때 | @@ -20659,6 +21260,29 @@ curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/ | **`HTTP 200`** | **실제로 닿는다** | | `HTTP 000` | curl 이 연결조차 못 했다 = **네트워크가 원인** | +**★ 지금 배치에서는 주소가 다르고, 닿는 까닭도 다르다**(2026-09-17, +observed). 판정(`200`)은 같은데 `nslookup` 이 내놓는 주소가 `100.83.212.4` 가 +아니라 엣지 게스트의 `192.168.122.10` 이다. + +```text +Server: 10.43.0.10 +Address: 10.43.0.10:53 + + +Name: app1.hyeonworks.com +Address: 192.168.122.10 +--- HTTPS --- +app1 200 +curl exit=0 +``` + +「tailnet 과 split DNS 의 헤어핀」은 이제 이 실험대의 사정이 아니다. 가이드 03 이 +CoreDNS 에 `coredns-custom` 항목을 넣어 세 이름을 엣지로 보내기 때문에 풀린다. +저절로 풀리지 않고 넣어야 풀리며, 안 넣으면 이 절이 `000` 으로 끝나 +「네트워크가 원인이 아니다」를 못 보인다. 같은 날 `get networkpolicy` 는 다시 쳐도 +`No resources found in keycloak-lab namespace.` 였다. + + **후보 ③은 원인이 아니다.** 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. `exit` 으로 파드에서 나오면 `--rm` 이 지워 준다. @@ -20770,7 +21394,7 @@ kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s ```bash kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` 백업 파일이 없으면 B-7 의 백업 단계를 다시 읽는다 — **그때 떠 뒀어야 하는 파일이다.** @@ -20829,8 +21453,9 @@ kubectl -n keycloak-lab delete pod c2probe --ignore-not-found - (observed·출처 주의) 설정이 들어간 것을 확인한 두 줄은 **`02-configure-idp.txt` 에 없다.** 그 파일은 점 표기 실패로 끝나고, 그 값은 뒤에 따로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 곳이다. -- (unknown) 임시 curl 파드를 띄우는 `kubectl run c2probe …` 한 줄. 가이드가 - **미검증**으로 표시했고 원래 실행의 명령 원문이 기록에 없다. **설정 JSON 을 파일로 +- (observed, 2026-09-17) 임시 curl 파드를 띄우는 `kubectl run c2probe …` 한 줄을 + 이번에 쳐서 `Address: 192.168.122.10` 와 `app1 200` 을 받았다. 원래 실행의 명령 + 원문은 여전히 기록에 없다(unknown). **설정 JSON 을 파일로 만들어 넣는 형태도 가이드에 없다** — 이 실험대는 명령줄에 직접 줬다. - **로그 0줄로는 아무것도 단정하지 않았다.** 「Keycloak 이 요청을 안 보냈다」는 이 출력으로 나오지 않는다 — 기본 로그 레벨에서 안 보이는 것과 구별되지 않기 때문이고, @@ -20839,8 +21464,9 @@ kubectl -n keycloak-lab delete pod c2probe --ignore-not-found 찍힌다. 브라우저 로그인 줄에서는 계정 이름 `labuser` 만 옮겼다. 클라이언트 UUID 와 Redis 키 이름은 식별자라 그대로 적었고, `backchannel.logout.url` 은 설정값이라 원문대로 적었다. -- **이 실험대의 `HTTP 200` 은 구성 덕이다.** tailnet + split DNS 라 클러스터 안에서 - 공개 이름이 되돌아온다(헤어핀). 운영에서 같은 값이 나온다고 볼 근거는 없다. +- **이 실험대의 `HTTP 200` 은 구성 덕이다.** 원래 실행은 tailnet + split DNS 의 + 헤어핀이었고, 2026-09-17 의 재구성에서는 CoreDNS 의 `coredns-custom` 항목이 그 일을 + 한다. 둘 다 이 실험대의 사정이고, 운영에서 같은 값이 나온다고 볼 근거는 없다. - **이 실험이 재지 않은 것** — ②·③을 실제로 **구현한 뒤** 전파가 되는지(코드를 고쳐야 한다), Keycloak 이 요청을 보내기는 했는지(`DEBUG` 로그를 안 켰다), 부분 실패 시의 **재시도 정책**. @@ -21082,15 +21708,17 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ 밖에서도 본다. **처음 한 번은 응답을 읽는다.** ```bash -curl -I https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` 헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다. ```bash -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master -curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/ ``` **실측**(observed) — `02-destruction.txt` (이것도 **파괴 직후** 값이고, 그게 결과다) @@ -21305,8 +21933,10 @@ A-2 에서는 여기가 `connection to server ... failed` 였다. ```bash kubectl -n keycloak-lab get pods -o wide -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master -curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/ ``` **실측**(observed) — `02-destruction.txt` @@ -21343,8 +21973,10 @@ kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ```bash curl -s -o /dev/null -w 'certs %{http_code}\n' \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs curl -s -o /dev/null -w 'well-known %{http_code}\n' \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration ``` @@ -21361,6 +21993,7 @@ curl -s -o /dev/null -w 'well-known %{http_code}\n' \ ```bash curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \ -d grant_type=password -d client_id=admin-cli -d username=admin \ -d "password=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ @@ -21487,6 +22120,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tA ```bash curl -s -o /dev/null -w 'well-known %{http_code}\n' \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration kubectl -n keycloak-lab get pods -o wide | grep keycloak ``` @@ -21932,7 +22566,7 @@ curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyA ```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)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 1 done > /tmp/d2-avail.txt ) & ``` @@ -22197,7 +22831,8 @@ kubectl -n keycloak-lab logs keycloak-1 --previous **그런데 서비스는 살아 있다.** ```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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ -o "custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready" kubectl -n keycloak-lab get statefulset keycloak @@ -23315,6 +23950,25 @@ ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v gr └─ pid ``` +**이 명령은 지금 배치에서 기계가 틀렸다**(2026-09-17, observed). 03 이 nginx 를 `kc-lab-edge` +로 옮겨서 `test-server` 에서는 한 줄도 안 나온다. 5·6 절이 certbot 을 엣지에서 찾는 것과 같은 +이유이고, 찾는 곳만 바꾸면 된다. + +```bash +ssh kc-lab-edge "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" +``` + +```text + 1065 1 11145 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; + 1106 1065 11135 Thu Sep 17 04:27:49 2026 nginx: worker process +``` + +이 두 줄은 위 예시와 한 군데가 다르고 **그 다름이 판정표를 그대로 보여 준다.** 위 예시는 +마스터와 워커의 `lstart` 와 `etimes` 가 같아서 「기동 이후 reload 가 없었다」였다. 여기서는 +워커가 10초 늦게 떴고 `etimes` 도 10 작다 — 03 을 밟으며 `systemctl reload nginx` 를 친 +흔적이다. 마스터 명령줄의 경로도 `/usr/sbin/nginx` 로 다른데, Arch 호스트와 Debian 게스트의 +패키징 차이다. + **reload 는 마스터를 유지한 채 워커만 새로 띄운다.** | 마스터 PID | 워커 PID | 판정 | @@ -23506,7 +24160,7 @@ vim /tmp/d4-poll.sh while [ ! -f /tmp/d4-stop ]; do echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null \ -w '%{http_code} %{time_total}' --max-time 5 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 0.2 done ``` @@ -24314,6 +24968,271 @@ ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \ 합친다.** 한 줄로 합치면 설치와 강제 갱신이 한 명령 안에 들어가서, 중간에서 멈췄을 때 훅이 깔린 상태인지 아닌지를 따로 봐야 한다. +**5~8 절은 2026-09-17 에 실제로 밟았고, 기계가 `test-server` 가 아니었다**(observed). 03 이 +nginx 를, 04 가 certbot 을 `kc-lab-edge` 로 옮겼으므로 `/etc/letsencrypt/renewal-hooks/deploy/` +도 `nginx` 도 그 게스트 안에 있다. 1 절의 `ps` 줄을 랩 호스트에서 치면 한 줄도 안 나온다. + +```text +total 4 +-rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh +``` + +파일 크기가 위 예시의 `40` 이 아니라 `38` 이다. 훅을 손으로 돌리면 `nginx -t` 의 두 줄 뒤에 +**예상 결과에 없는 셋째 줄**이 따라온다. + +```text +nginx: the configuration file /etc/nginx/nginx.conf syntax is ok +nginx: configuration file /etc/nginx/nginx.conf test is successful +2026/09/17 07:34:08 [notice] 2361#2361: signal process started +``` + +셋째 줄은 `nginx -s reload` 가 신호 보낼 프로세스를 띄웠다는 알림이고 실패가 아니다. + +**그리고 이 편이 세우려는 판정 기준이 여기서 그대로 작동했다**(observed). + +```text +전 1065 1 11186 Thu Sep 17 04:27:39 2026 nginx: master process + 1106 1065 11177 Thu Sep 17 04:27:49 2026 nginx: worker process +뒤 1065 1 11189 Thu Sep 17 04:27:39 2026 nginx: master process + 2362 1065 0 Thu Sep 17 07:34:08 2026 nginx: worker process +``` + +마스터는 `1065` 로 그대로이고 워커가 `1106` 에서 `2362` 로 갈렸으며 새 워커의 `etimes` 가 `0` +이다. 판정표의 「마스터 그대로 · 워커 바뀜 = reload 됐다」 그 칸이고, **인증서가 없어도 이 +판정 기준 자체는 검증된다.** + +**오래 미검증이던 줄 셋도 같은 날 쳤다**(observed). 셋 다 인증서 없이 돌고, 값이 생겼다. + +| 어디 | 명령 | 2026-09-17 의 값 | +|---|---|---| +| D-4a 8 절 | `sudo certbot renew --dry-run` | `No simulated renewals were attempted.` · 종료 `0` | +| B-7 6 절 | 두 cookie secret 의 `wc -c` | `32` · `32` | +| B-6 겹침 절 | realm 의 수명 세 값 | `accessTokenLifespan` `60` · `ssoSessionIdleTimeout` `1800` · `ssoSessionMaxLifespan` 은 안 나온다 | + +```text +Saving debug log to /var/log/letsencrypt/letsencrypt.log +No simulated renewals were attempted. +``` + +**갱신할 것이 없으면 훅도 안 불린다.** `Running deploy-hook command` 줄은 안 나오고 종료 +코드는 그래도 `0` 이다. 그래서 이 명령의 성공은 훅이 도는지에 대해 아무 말도 안 한다. + +**★ 그런데 인증서를 세우고 훅까지 놓아도 dry-run 은 훅을 안 부른다**(2026-09-17, +observed). `/etc/letsencrypt/renewal-hooks/deploy/` 에 실행 권한까지 준 훅을 놓고 +시뮬레이션이 성공한 판에서도 `Running deploy-hook command` 는 안 나온다. + +```bash +sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ +sudo grep -c deploy-hook /tmp/dr.txt +certbot --version +``` + +```text +-rwxr-xr-x 1 root root 38 Sep 17 09:23 reload-nginx.sh +0 +certbot 2.1.0 +``` + +`certbot --help all` 에 `--run-deploy-hooks` 도 없다(2.1.0 기준, 센 값 `0`). 이 판의 +certbot 에서는 dry-run 으로 훅을 확인할 방법이 없다. 훅이 도는 것은 +진짜 갱신에서만 보인다. + +**★ 그리고 같은 명령이 성공하기도 실패하기도 한다**(2026-09-17, observed). 세 번을 +연달아 쳐는데 성공 · 실패 · 성공이었고, 실패한 판은 앞의 둘과 또 다른 사유다. + +```text +Certbot failed to authenticate some domains (authenticator: dns-cloudflare). The Certificate Authority reported these problems: + Domain: auth.hyeonworks.com + Type: dns + Detail: During secondary validation: DNS problem: NXDOMAIN looking up TXT for _acme-challenge.auth.hyeonworks.com - check that a DNS record exists for this domain + +Hint: The Certificate Authority failed to verify the DNS TXT records created by --dns-cloudflare. Ensure the above domains are hosted by this DNS provider, or try increasing --dns-cloudflare-propagation-seconds (currently 30 seconds). +certbot exit=1 +``` + +기본값 30초가 이 도메인에서는 아슬아슬하다. 한 번 실패했다고 설정이 틀린 것이 +아니다. 이 판의 종료 코드는 `1` 이었다. + + +**★ 더 나쁜 것은, 종료 코드가 실패를 일관되게 알려 주지 않는다는 +점이다**(2026-09-17, observed). 같은 「전부 실패」 본문을 두 번 받았는데 한 번은 `0`, +한 번은 `1` 로 끝났다. + +```text +Failed to renew certificate auth.hyeonworks.com with error: You should register before running non-interactively, … +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +1 renew failure(s), 0 parse failure(s) +certbot exit=0 +``` + +조금 뒤에 같은 명령을 다시 치니 실패 사유가 바뀜었고, 종료 코드도 같이 바뀜었다. + +```text +Failed to renew certificate auth.hyeonworks.com with error: Missing command line flag or config entry for this setting: +Please choose an account +Choices: ['test-server@2026-09-03T01:50:44Z (66d5)', 'kc-lab-edge@2026-09-17T08:16:31Z (5df4)'] + +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +1 renew failure(s), 0 parse failure(s) +certbot exit=1 +``` + +두 번 다 `All simulated renewals failed` 이고 `1 renew failure(s)` 인데 종료 코드만 +갈렸다. `0` 을 성공으로 읽으면 첫 번째를 놓치고, 그렇다고 `1` 을 기다려도 +두 번째에서만 맞는다. 이 명령에 대해 종료 코드는 판정 근거가 못 된다. + +종료 코드를 따로 잡아서 보면 이렇다. + +```bash +sudo certbot renew --dry-run >/tmp/dr.txt 2>&1; echo "certbot exit=$?" +``` + +**그러므로 `&&` 로 뒤를 잇거나 `$?` 로 갈라선 안 된다.** 판정은 본문의 `renew failure(s)` +수로 한다. 실패를 실패로 읽는 형태는 이렇다. + +```bash +sudo certbot renew --dry-run >/tmp/dr.txt 2>&1 +grep -E 'Failed to renew|renew failure' /tmp/dr.txt +``` + +두 줄이다. 먼저 파일로 받아 두고, 그 파일에서 판정에 쓰는 줄만 골라 눈으로 읽는다. +`renew failure(s)` 앞의 숫자가 `0` 이고 `Failed to renew` 줄이 없으면 통과고, +위 실측처럼 `1 renew failure(s)` 와 `Failed to renew` 가 같이 나오면 실패다. +갱신할 인증서가 한 장도 없으면 `grep` 은 아무것도 안 찍는다 — 그건 통과가 아니라 +시뮬레이션할 것이 한 장도 없었다는 뜻이라 `/tmp/dr.txt` 를 그대로 열어 본다. +이 판정을 `&&` 나 `||` 로 이어 붙이지 않는 까닭도 같다 — 종료 코드가 +거짓말하는 명령을 살피는 절에서 종료 코드로 갈라지는 문장을 쓰면 같은 함정을 다시 판다. + +D-4a 가 재려는 것이 「성공했다고 보고하는데 실제로는 안 된 일」인데, 사전 점검 +명령 자체가 그 성질을 갖고 있다. + +**★ 그리고 인증서를 옮겨도 갱신 능력은 따라오지 않는다**(observed). 다른 기계에서 +`live/` · `archive/` · `renewal/` 만 가져오면 위 오류가 난다. `renewal/*.conf` 가 가리키는 +ACME 계정(`/etc/letsencrypt/accounts/`)과 DNS 자격증명 파일(`cloudflare.ini`)이 없기 +때문이다. 오류 문구가 +「등록부터 하라」여서 계정을 새로 만들라는 말로 읽히는데, 실제로 빠진 것은 옮겨 오지 않은 +디렉터리다. §철거 절이 `/etc/letsencrypt` 를 통째로 묶으라고 적은 것이 이 점에서 옳다 — +골라 담으면 갱신이 죽는다. + +**★ 그리고 `--dry-run` 이 보는 계정은 `renewal/*.conf` 가 가리키는 계정이 +아니다**(2026-09-17, observed). `--dry-run` 은 Let's Encrypt staging 서버로 붙는데, +`renewal/*.conf` 의 `account =` 은 운영 계정의 id 를 적어 둔다. 그래서 staging 쪽 +계정이 둘 이상이면 certbot 이 고르지 못하고 앞서 본 `Please choose an account` 로 +멈는다. + +```bash +sudo find /etc/letsencrypt/accounts -mindepth 3 -maxdepth 3 -type d +``` + +```bash +sudo grep -E '^(account|server) ' /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +``` + +```text +/etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/66d5d86599378a0b07936733587017a6 +/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272 +account = 8d53f9312e2a4c9cdd13620122cd8272 +server = https://acme-v02.api.letsencrypt.org/directory +``` + +`account =` 이 가리키는 `8d53…` 은 `acme-v02`(운영) 아래에만 있다. `--dry-run` 은 +`acme-staging-v02` 아래를 보고, 거기 계정이 하나면 그것을 쓰고 둘이면 묻는다. +고르라고 나온 `66d5` 와 `5df4` 는 둘 다 staging 계정이었다 — `5df4` 는 실패한 +dry-run 이 그때 새로 만든 것이라, 한 번 실패하고 나면 다음 dry-run 이 계속 +실패한다. + +고르라고 나오는 이름은 계정 폴더의 `meta.json` 에서 온다. + +```bash +sudo sh -c "cat /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/*/meta.json" +``` + +```text +{"creation_dt": "2026-09-03T01:50:44Z", "creation_host": "test-server"} +``` + +`Choices:` 에 찍힌 `test-server@2026-09-03T01:50:44Z (66d5)` 가 바로 이 두 칸과 폴더 +이름 앞 네 글자다. 둘 이상 나오면 나중에 생긴 쪽의 폴더를 `sudo rm -rf` 로 지워 +하나만 남긴다. 이 실험대에서는 `kc-lab-edge` 가 만든 쪽을 지워 `test-server` 가 +만든 하나만 남겨 두었다(observed). 지우는 것은 staging 계정이라 운영 인증서에는 +영향이 없다. + +위 명령에 `sudo sh -c` 가 붙은 까닭은 경로에 `*` 가 들어 있기 때문이다. +`sudo cat …/*/meta.json` 은 셀이 먼저 `*` 를 푸는데, 그 셀은 root 가 아니라 +`accounts/` 안을 못 읽고 `No such file or directory` 로 끝난다. §04 의 `renewal/*.conf` +글로브 함정과 같은 자리다. + +그래서 `--dry-run` 의 실패와 진짜 갱신의 실패는 같은 것이 아니다. +다만 이 실험대에서 진짜 갱신을 다시 치는 데까지 가지는 않았다(unknown). +확인한 것은 운영 계정이 하나뿐이고 그것이 `account =` 이 가리키는 바로 그 +id 라는 것까지다. + +중복 계정을 지우고 다시 치니 dry-run 이 통과했다(2026-09-17, observed). + +```text +certbot exit=0 +Saving debug log to /var/log/letsencrypt/letsencrypt.log +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains +Waiting 30 seconds for DNS changes to propagate +Congratulations, all simulated renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +``` + +**★ 2026-09-17 에 엣지에서 다시 치고 갱신이 서빙까지 닿는 것을 봤다**(observed). 이번엔 `[test-server]` 가 아니라 `[kc-lab-edge]` 에서 쳤다 — 인증서와 nginx 가 거기 있기 때문이다. + +```text +Renewing an existing certificate for auth.hyeonworks.com and 2 more domains +Hook 'deploy-hook' ran with error output: + nginx: the configuration file /etc/nginx/nginx.conf syntax is ok + nginx: configuration file /etc/nginx/nginx.conf test is successful + 2026/09/17 10:36:31 [notice] 4744#4744: signal process started + +Congratulations, all renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +certbot exit=0 +``` + +**판정은 이 출력이 아니라 서빙하는 인증서로 한다.** 같은 소켓을 갱신 전후로 두 번 열어 일련번호와 날짜를 견준다. + +```bash +echo | openssl s_client -connect 127.0.0.1:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -dates -serial +``` + +```text +notBefore=Sep 4 11:29:18 2026 GMT +notAfter=Dec 3 11:29:17 2026 GMT +serial=06F3E0EF4D1BB03DE58130EAAD1176101373 +``` + +```text +notBefore=Sep 17 09:37:58 2026 GMT +notAfter=Dec 16 09:37:57 2026 GMT +serial=065547991777D11A408CEA90D945DDA03DF1 +``` + +일련번호가 바뀌었으므로 **훅이 돌았고 nginx 가 새 파일을 집었다.** 같은 순간 worker 프로세스도 바뀜다 — 갱신 전 `2629 4712`, 뒤 `4745 4754`. 디스크에는 `privkey4.pem` 이 `Sep 17 10:36` 으로 생겼다. + +**원래 실행과 다른 데가 둘 있다**(observed). 첫째, 훅 출력에 `types_hash` 경고 줄이 없다 — 엣지의 nginx 설정이 호스트의 것과 달라서다. 둘째, 원래는 worker 하나가 그대로 남았는데 이번에는 둘 다 교체됐다. **`ran with error output` 이 실패가 아니라는 것은 그대로다** — stderr 로 나간 세 줄이 전부 성공 메시지다. + +새 인증서의 `notBefore` 가 훅이 도는 시각(`10:36:31`)보다 약 한 시간 앞이다. 왜 그런지는 이 실험대에서 가르지 않았다(unknown) — 발급자가 앞당긴 것인지 시계 차인지는 안 재 봤다. + +세 이름 모두 갱신 뒤에도 검증을 통과한다. + +```text +auth 302 verify=0 +app1 200 verify=0 +app2 302 verify=0 +``` + + +그리고 이것이 종료 코드 이야기를 닫는다. 성공도 `0` 이고 첫 번째 실패도 +`0` 이었다. 같은 명령이 돼을 때와 안 돼을 때 같은 값을 내므로 `$?` 로는 +둔 경우를 가를 수 없다. 본문을 읽는 수밖에 없다. + + #### 주입 검증 **갱신을 걸기 전에** 훅이 제자리에, 실행 가능한 상태로 있는지 본다. **한 번뿐인 강제 갱신을 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/README.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/README.txt index ea2bb96..c793c1a 100644 --- a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/README.txt +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/README.txt @@ -165,3 +165,103 @@ SSOT 의 「2026-09-17 재현 — 어디까지 밟았고 무엇이 막았나」 비밀은 어느 파일에도 값으로 들어 있지 않다. 비밀번호는 길이(19·22)만 적었고, 화면에 찍혀 나온 토큰은 로 가렸다. + +--- 2026-09-17 추가 (인증서 대기 중에 잰 것) --- +ext-01..02 밖에서 http 로 닿는지, X-Forwarded-For 가 오는지 +ext-03..05, ext-09,11 traefik-forwarded-headers.yaml 적용 전/후/되돌린 뒤 앱이 받은 헤더 +ext-06..07 인증서 상태와 발급 전제 (플러그인 O, cloudflare.ini X) +ext-08 http 만으로 어디까지 되는지 (well-known 200 · token 401) +ext-10, ext-12 출발지를 덮는 것이 무엇인지 — 호스트 sudo 가 막아 못 가림 +ext-13 BFF 로그인 체인이 https://auth 로 가서 막히는 것 +b6-01..09 B-6 키 회전을 문서대로 밟은 기록. 키 조작은 재현, 401/200 판정은 못 냄 +ext-14 파드에서 공개 이름이 100.83.212.4 로 풀리고 80·443 이 닫혀 있는 것 +ext-15 CoreDNS 를 *.override 로 고치려다 CrashLoopBackOff 난 기록 +ext-16 *.server 로 고쳐 넣어 CoreDNS 가 뜬 기록 +ext-17 고친 뒤 파드에서 엣지 192.168.122.10:80 이 열리는 것 +d4-01 D-4 5·6·7 절. 7 절이 test-server 에서 빈손이고 nginx 는 엣지에 있다 +d4a-01 D-4a 5~8 절. 훅 작성·설치·실행과 워커 PID 가 갈리는 것 +b7-01, b7-02 CoreDNS 를 고친 뒤 oauth2-proxy 가 두드리는 주소가 + 100.83.212.4 에서 192.168.122.10 으로 바뀐 것과, 빌린 Grafana Ingress 복구 +misc-01 오래 미검증이던 명령 넷 — cookie secret 길이 32·32, R 함수가 빈 출력, + certbot renew --dry-run 이 인증서 0장에서 내는 줄 +b2-01 --scan | xargs del 이 tr -d '\r' 없이도 도는 것과, od -c 로 본 줄 끝 +b2-02 b2 ②③④ 의 세션 수 — DB 5 와 관리 API 4 가 다른 것 +b0-01..03 TLS 뒤 b0 상태 확인 — BFF 가 B-1 상태이고 /actuator/beans 가 밖에서 200 +b1-01..05 B-1 을 끝까지 — 로그인, Redis 키 둘, head -1 이 엉뚱한 키를 집는 것 +b6-10..18 B-6 을 끝까지 — 대조군 200, 추가는 무중단, 제거 뒤 401/200 이 번갈아 나오는 것 +b7-03..08 B-7 을 끝까지 — 클라이언트가 없어 Client not found, 만든 뒤 회전까지 +b7a-01 회전 뒤 남은 고아 세션과 TTL 이 갱신 없이 줄어드는 것 +d4a-07-dryrun-after-removing-duplicate-staging-account.txt + kc-lab-edge 에서 `sudo certbot renew --dry-run`. staging ACME 계정이 둘이라 + "Please choose an account" 로 멎던 것을, 나중에 생긴 계정 하나를 지운 뒤 다시 잰 것. + certbot exit=0 · "Congratulations, all simulated renewals succeeded". + 실패했을 때도 0 이 나온 적이 있어 종료 코드로는 성공과 실패가 안 갈린다는 근거. + +--- 2026-09-17 저녁 추가 (A-6 관찰·복구를 마저 밟은 기록) --- +a6-05-observe-prep.txt A-6 준비 1~5 — 배치와 파드 IP, a6-probe 파드, PW 길이 19, + 구간별 시간 한 번, 두 노드 20회씩 기준선(k1 44ms · k0 40ms), + `grep "^agroal_"` 가 레이블과 값까지 열일곱 줄로 나오는 것 +a6-06-observe.txt A-6 주입 재현과 관찰 1~5 — flannel.1 세 줄, 카운터 0 pkt → 18 pkt, + 주입 뒤 k1 1865ms · k0 40ms, 동시 20건 1.87→22.25 초 계단 스무 줄, + sort / sort -g 가 같고 sort -g -k2 만 듣는 것, 부하 직후 풀 지표 + (blocking_time_max 20000.0 · max_used_count 18.0), + readiness 프로브 context deadline exceeded, 낙관적 락 로그 0 +a6-07-revert-checklist.txt A-6 복구와 확인표 여덟 줄 — flannel.1 noqueue, enp1s0 은 + 기본값 fq_codel, 회복 뒤 k1 42ms · k0 39ms, 엔드포인트 주소 둘, + awaiting/active 0, 탐침 NotFound, 밖에서 auth.hyeonworks.com 이 + 100.83.212.4 로 풀려 443 이 닫혀 000 이고 --resolve 로 짚으면 200 + +--- 2026-09-17 저녁 추가 (A-7 을 1번부터 복구까지 다시 밟은 기록) --- +a7-06-prep-and-switch.txt A-7 1~11 — 전환 전 args ["start"], DB 143행, 탐침, 교차 refresh 200, + 기능 목록이 두 줄, DELETE 144, patch 가 "patched" 를 내는 것, + 전환 뒤 로그인 5회 200 과 DB (0 rows), 캐시 지표 96줄 원문 +a7-07-observe.txt A-7 12~15 — 교차 refresh 200, 롤링 재시작 뒤 400 Session not active, + 14 ① 블록을 kc-lab-1 에서 문서 그대로 쳤을 때 kubectl 이 막히고 + iptables 가 Bad argument 를 내며 ssh 가 Host key verification failed 이고 + 그래도 "차단" 시각이 찍히는 것, 규칙이 0개인 것, lab host 에서 다시 친 + 형태와 cluster_size 2→1(+75초)·해제 뒤 1→2(+60초), coord=t 둘, + 대조군 200 · 시험군 400, DB 정지 뒤 500 / 200 +a7-08-revert-checklist.txt A-7 복구와 확인표 아홉 줄 — args ["start"], git diff 빈 출력, + 로그인 뒤 세션 행 1, raw PREROUTING 비어 있음, cluster_size 양쪽 2, + 탐침 NotFound, 밖은 000 이고 --resolve 로 짚으면 200 + +--- 2026-09-17 저녁 추가 (B-5 를 1번부터 복구까지 다시 밟은 기록) --- +b5-04-baseline.txt B-5 준비 1~5 — 파드 배치, --scan 이 빈 출력(키 0), save 가 + 3600 1 300 100 60 10000 이고 appendonly yes, spec.volumes 에 + kube-api-access-* 가 함께 있는 것, PVC 둘 Bound, 공개 이름으로는 + 세 경로가 000 이고 --resolve 로 짚으면 200/302/200, health 그룹 셋 +b5-05-redis-down.txt B-5 주입 1 과 관찰 — Redis 0/0, BFF 로그가 pollConnect 가 아니라 + ConnectionWatchdog "Cannot reconnect ... Connection refused", + bff 둘 1/1 RESTARTS 0, 엔드포인트 true,true, 세 경로 200/000/000, + /actuator/health 는 --max-time 10 이면 000 이고 60.046452 초 뒤 503 + (redis: QueryTimeoutException), token-boundary 는 90초까지 무응답 +b5-06-persistence.txt B-5 주입 2 와 지속성 시험 — 볼륨을 떼도 spec.volumes 가 빈 줄이 + 아니라 kube-api-access-* 하나, appendonlydir 생성, 파드 삭제 뒤 + dbsize 0 인데 appendonly 는 yes(매니페스트 args 가 이미 yes), + PVC 를 되돌린 뒤 dbsize 4 와 written-on-pvc, 네 지표 시계열 0개 +b5-07-revert-checklist.txt B-5 복구와 확인표 여덟 줄 — apply 후 redis 1/1, PVC Bound, + appendonly yes, bff 둘 1/1, 엔드포인트 둘, b5:* 삭제 1건이고 남은 키는 + bff:session:sessions 셋, 밖은 000 이고 --resolve 로 짚으면 200 +d4a-09-dryrun-with-deploy-hook.txt + /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 를 755 로 놓은 뒤의 + `sudo certbot renew --dry-run`. 시뮬레이션은 성공(exit=0)했는데 + "Running deploy-hook command" 줄은 없다 — certbot 2.1.0 의 dry-run 은 deploy 훅을 건너뛴다. +d4a-10-dryrun-dns-propagation-flake.txt + 같은 명령이 바로 앞뒤로는 성공했는데 이 판만 실패한 것. 사유는 DNS 전파로, + "During secondary validation: DNS problem: NXDOMAIN looking up TXT for + _acme-challenge.auth.hyeonworks.com" · propagation-seconds 기본값 30. certbot exit=1. +d4a-11-deploy-hook-not-called-in-dry-run.txt + 위 판정의 근거 넷 — 훅 파일의 권한·크기, 성공한 출력에서 "deploy-hook" 을 센 값 0, + certbot 2.1.0, `--help all` 에 --run-deploy-hooks 가 없다는 것(센 값 0). +c2-05-probe-reachability-relive.txt + C-2 §5 의 임시 탐침 파드(curlimages/curl:8.11.1)를 2026-09-17 재구성 랩에서 다시 띄운 것. + app1.hyeonworks.com 이 192.168.122.10(엣지 게스트)으로 풀리고 HTTPS 200. + 원래 실행의 100.83.212.4(tailnet 헤어핀)와 주소가 다르다 — 지금은 CoreDNS 의 + coredns-custom 항목이 그 일을 한다. networkpolicy 는 여전히 없다. +d4-01-before-force-renewal.txt / d4-02-force-renewal.txt / d4-03-after-force-renewal.txt + D-4·D-4a 의 강제 갱신을 2026-09-17 에 kc-lab-edge 에서 실제로 친 것. + 01 = 치기 전 archive 목록·nginx worker PID·서빙 인증서의 serial/날짜 + 02 = `sudo certbot renew --force-renewal` 전문. 배포 훅이 돌았고 certbot exit=0 + 03 = 친 뒤의 같은 세 가지 + 세 이름의 HTTPS 응답. + serial 06F3E0EF…1373 → 065547991777…3DF1, notAfter Dec 3 → Dec 16, + worker 2629 4712 → 4745 4754. 갱신이 서빙까지 닿았다는 근거. diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-05-observe-prep.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-05-observe-prep.txt new file mode 100644 index 0000000..e7698b6 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-05-observe-prep.txt @@ -0,0 +1,88 @@ +=== a6 준비 1 ① 배치와 IP === +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +bff-5d97897676-nmlqs 1/1 Running 0 150m 10.42.0.22 kc-lab-1 +bff-5d97897676-zvfhp 1/1 Running 0 150m 10.42.1.51 kc-lab-2 +keycloak-0 1/1 Running 0 121m 10.42.1.64 kc-lab-2 +keycloak-1 1/1 Running 0 121m 10.42.0.29 kc-lab-1 +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 55m 10.42.1.78 kc-lab-2 +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 55m 10.42.1.79 kc-lab-2 +postgres-7b474b88c8-48lrr 1/1 Running 0 160m 10.42.1.46 kc-lab-2 +redis-f94bc6dbf-s674z 1/1 Running 0 123m 10.42.1.63 kc-lab-2 +=== a6 준비 1 ② 세 값을 셸 변수에 === +K0=10.42.1.64 K1=10.42.0.29 PG=10.42.1.46 +=== a6 준비 2 ① 탐침 파드를 띄운다 === +pod/a6-probe created +exit=0 +=== a6 준비 2 ② 뜰 때까지 기다린다 === +pod/a6-probe condition met +exit=0 +=== a6 준비 2 ③ 값이 아니라 길이만 확인한다 === +K0=10.42.1.64 K1=10.42.0.29 PW=19자 +=== a6 준비 3 ① 구간별 시간을 함께 찍는다 === +connect 0.000344 ttfb 0.110990 total 0.111040 +=== a6 준비 4 ① keycloak-1 에 20회 === +=== a6 준비 4 ② 원본을 먼저 본다 (k1) === +0.044959 +0.046268 +0.046746 +0.041681 +0.040931 +0.048671 +0.040027 +0.052310 +0.040426 +0.039144 +0.042341 +0.044985 +0.042190 +0.044052 +0.043110 +0.044736 +0.039418 +0.058032 +0.040404 +0.038562 +=== a6 준비 4 ③ 그다음 줄여서 본다 (k1) === +20회 평균 44 ms +=== a6 준비 4 대조군 — $K1 을 $K0 로, 파일 이름도 /tmp/base-k0 으로 === +=== 원본 (k0) === +0.046606 +0.069418 +0.044654 +0.039537 +0.037198 +0.038300 +0.037252 +0.039732 +0.036486 +0.037554 +0.036501 +0.035420 +0.036633 +0.036898 +0.052301 +0.035256 +0.036421 +0.034673 +0.035721 +0.040525 +=== 평균 (k0) === +20회 평균 40 ms +=== a6 준비 5 ① 지표 이름 목록 === +agroal_invalid_count_total{datasource="default"} 0.0 +agroal_flush_count_total{datasource="default"} 0.0 +agroal_leak_detection_count_total{datasource="default"} 0.0 +agroal_reap_count_total{datasource="default"} 3.0 +agroal_blocking_time_max_milliseconds{datasource="default"} 112.0 +agroal_max_used_count{datasource="default"} 3.0 +agroal_acquire_count_total{datasource="default"} 1908.0 +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +agroal_blocking_time_average_milliseconds{datasource="default"} 0.0 +agroal_creation_time_total_milliseconds{datasource="default"} 160.0 +agroal_creation_time_average_milliseconds{datasource="default"} 32.0 +agroal_destroy_count_total{datasource="default"} 3.0 +agroal_available_count{datasource="default"} 2.0 +agroal_creation_count_total{datasource="default"} 5.0 +agroal_creation_time_max_milliseconds{datasource="default"} 105.0 +agroal_blocking_time_total_milliseconds{datasource="default"} 285.0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-06-observe.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-06-observe.txt new file mode 100644 index 0000000..b86de9f --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-06-observe.txt @@ -0,0 +1,225 @@ +K0=10.42.1.64 K1=10.42.0.29 PG=10.42.1.46 +=== 주입 전에 flannel.1 에 무엇이 걸려 있나 === +qdisc noqueue 0: root refcnt 2 +=== 주입 ① 밴드 3개짜리 분류기 === +exit=0 +=== 주입 ② 3번 밴드에 200ms === +exit=0 +=== 주입 ③ 출발지가 postgres 인 패킷을 3번 밴드로 === +exit=0 +=== 주입 ④ 주입 시각 === +17:56:37 주입 +=== 주입 검증 ① 넣은 직후의 카운터 === +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 +=== 주입 검증 ② 요청을 한 번 보낸다 === +1.855960 +=== 주입 검증 ③ 다시 센다 === +qdisc netem 30: parent 1:3 limit 1000 delay 200ms + Sent 1556 bytes 18 pkt (dropped 0, overlimits 0 requeues 0) + backlog 0b 0p requeues 0 +=== 관찰 1 ① 구간별 시간 (keycloak-1) === +connect 0.000552 ttfb 1.864861 total 1.864966 +=== 관찰 1 ② 20회 반복 — keycloak-1 === +1.860758 +1.870170 +1.872980 +1.876086 +1.857782 +1.859351 +1.858796 +1.860100 +1.864847 +1.874546 +1.856623 +1.870452 +1.879456 +1.867865 +1.861147 +1.861699 +1.867978 +1.862271 +1.864539 +1.857025 +20회 평균 1865 ms 최대 1879 ms +=== 관찰 1 ③ 20회 반복 — keycloak-0 (대조군) === +0.039154 +0.043190 +0.052382 +0.037677 +0.036750 +0.037014 +0.038348 +0.035596 +0.036738 +0.038740 +0.036093 +0.040349 +0.038105 +0.040798 +0.035273 +0.042598 +0.045344 +0.038030 +0.046025 +0.034717 +20회 평균 40 ms 최대 52 ms +=== 관찰 2 ① 동시 20건 === +17:57:39 부하 시작 +17:58:02 부하 끝 +=== 관찰 3 ① 부하 직후에 읽는다 === +agroal_blocking_time_max_milliseconds{datasource="default"} 20000.0 +agroal_max_used_count{datasource="default"} 18.0 +agroal_acquire_count_total{datasource="default"} 2020.0 +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +agroal_blocking_time_average_milliseconds{datasource="default"} 114.0 +agroal_available_count{datasource="default"} 18.0 +agroal_blocking_time_total_milliseconds{datasource="default"} 230680.0 +=== 관찰 2 ② 다 모였는지부터 센다 === +20 /tmp/load.txt +=== 관찰 2 ③ 원본을 본다 === +200 1.889034 +200 1.871238 +200 1.967936 +200 3.399275 +200 4.813854 +200 6.247018 +200 7.652515 +200 9.066673 +200 10.495914 +200 11.919137 +200 13.326469 +200 14.736787 +200 16.159992 +200 17.588220 +200 21.854297 +200 22.214107 +200 22.207834 +200 22.216690 +200 22.245645 +200 22.242271 +=== 관찰 2 ④ 상태 코드와 시간을 나눠 본다 === + 20 200 +1.871238 +1.889034 +1.967936 +3.399275 +4.813854 +6.247018 +7.652515 +9.066673 +10.495914 +11.919137 +13.326469 +14.736787 +16.159992 +17.588220 +21.854297 +22.207834 +22.214107 +22.216690 +22.242271 +22.245645 +=== 관찰 2 ⑤ 정렬 방식을 갈라 본다 — sort (사전순) === +200 1.871238 +200 1.889034 +200 1.967936 +200 10.495914 +200 11.919137 +200 13.326469 +200 14.736787 +200 16.159992 +200 17.588220 +200 21.854297 +200 22.207834 +200 22.214107 +200 22.216690 +200 22.242271 +200 22.245645 +200 3.399275 +200 4.813854 +200 6.247018 +200 7.652515 +200 9.066673 +=== 관찰 2 ⑤ sort -g === +200 1.871238 +200 1.889034 +200 1.967936 +200 10.495914 +200 11.919137 +200 13.326469 +200 14.736787 +200 16.159992 +200 17.588220 +200 21.854297 +200 22.207834 +200 22.214107 +200 22.216690 +200 22.242271 +200 22.245645 +200 3.399275 +200 4.813854 +200 6.247018 +200 7.652515 +200 9.066673 +=== 관찰 2 ⑥ sort -g -k2 === +200 1.871238 +200 1.889034 +200 1.967936 +200 3.399275 +200 4.813854 +200 6.247018 +200 7.652515 +200 9.066673 +200 10.495914 +200 11.919137 +200 13.326469 +200 14.736787 +200 16.159992 +200 17.588220 +200 21.854297 +200 22.207834 +200 22.214107 +200 22.216690 +200 22.242271 +200 22.245645 +=== 관찰 4 ① 최근 이벤트 === +57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled up replica set oauth2-proxy-d9b587458 from 0 to 1 +57m Normal Pulled pod/oauth2-proxy-d9b587458-7x9jt Container image "quay.io/oauth2-proxy/oauth2-proxy:v7.7.1" already present on machine and can be accessed by the pod +57m Normal SuccessfulCreate replicaset/oauth2-proxy-d9b587458 Created pod: oauth2-proxy-d9b587458-7x9jt +57m Normal Created pod/oauth2-proxy-d9b587458-7x9jt Container created +57m Normal Started pod/oauth2-proxy-d9b587458-7x9jt Container started +57m Normal Created pod/oauth2-proxy-d9b587458-njxdw Container created +57m Normal Killing pod/oauth2-proxy-6c56c4c94f-gq484 Stopping container oauth2-proxy +57m Normal SuccessfulDelete replicaset/oauth2-proxy-6c56c4c94f Deleted pod: oauth2-proxy-6c56c4c94f-gq484 +57m Normal Pulled pod/oauth2-proxy-d9b587458-njxdw Container image "quay.io/oauth2-proxy/oauth2-proxy:v7.7.1" already present on machine and can be accessed by the pod +57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled up replica set oauth2-proxy-d9b587458 from 1 to 2 +57m Normal Started pod/oauth2-proxy-d9b587458-njxdw Container started +57m Normal SuccessfulCreate replicaset/oauth2-proxy-d9b587458 Created pod: oauth2-proxy-d9b587458-njxdw +57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled down replica set oauth2-proxy-6c56c4c94f from 2 to 1 +57m Normal SuccessfulDelete replicaset/oauth2-proxy-6c56c4c94f Deleted pod: oauth2-proxy-6c56c4c94f-szr6j +57m Normal ScalingReplicaSet deployment/oauth2-proxy Scaled down replica set oauth2-proxy-6c56c4c94f from 1 to 0 +57m Normal Killing pod/oauth2-proxy-6c56c4c94f-szr6j Stopping container oauth2-proxy +115s Normal Created pod/a6-probe Container created +115s Normal Pulled pod/a6-probe Container image "curlimages/curl:8.11.1" already present on machine and can be accessed by the pod +114s Normal Started pod/a6-probe Container started +19s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.29:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers) +=== 관찰 4 ② 파드 상태 === +NAME READY STATUS RESTARTS AGE +a6-probe 1/1 Running 0 115s +bff-5d97897676-nmlqs 1/1 Running 0 152m +bff-5d97897676-zvfhp 1/1 Running 0 152m +keycloak-0 1/1 Running 0 123m +keycloak-1 1/1 Running 0 123m +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 57m +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 57m +postgres-7b474b88c8-48lrr 1/1 Running 0 162m +redis-f94bc6dbf-s674z 1/1 Running 0 125m +=== 관찰 5 ① 미검증 — 충돌 로그를 센다 === +0 +exit=1 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-07-revert-checklist.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-07-revert-checklist.txt new file mode 100644 index 0000000..c7d3e9e --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a6-07-revert-checklist.txt @@ -0,0 +1,53 @@ +=== 복구 1 ① 해제 시각 === +17:58:20 해제 +=== 복구 1 ② root 를 지운다 === +exit=0 +=== 복구 1 ③ 무엇이 남았는지 본다 === +qdisc noqueue 0: root refcnt 2 +=== 복구 2 회복 확인 — 20회 반복 측정을 그대로 다시 (keycloak-1) === +keycloak-1 20회 평균 42 ms +=== 복구 2 회복 확인 — keycloak-0 === +keycloak-0 20회 평균 39 ms +=== 확인표 qdisc (flannel.1) === +qdisc noqueue 0: root refcnt 2 +=== 확인표 (물리 쪽도) enp1s0 === +qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +=== 확인표 파드 === +NAME READY STATUS RESTARTS AGE +a6-probe 1/1 Running 0 2m9s +bff-5d97897676-nmlqs 1/1 Running 0 152m +bff-5d97897676-zvfhp 1/1 Running 0 152m +keycloak-0 1/1 Running 0 123m +keycloak-1 1/1 Running 0 123m +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 57m +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 57m +postgres-7b474b88c8-48lrr 1/1 Running 0 162m +redis-f94bc6dbf-s674z 1/1 Running 0 125m +=== 확인표 Service === +NAME ADDRESSTYPE PORTS ENDPOINTS AGE +keycloak-lxk8h IPv4 8080 10.42.0.29,10.42.1.64 4h22m +=== 확인표 풀 === +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +=== 탐침 파드를 지운다 === +pod "a6-probe" deleted from keycloak-lab namespace +=== 확인표 탐침 파드 === +Error from server (NotFound): pods "a6-probe" not found +exit=1 +=== 확인표 밖 === +000 +=== 확인표 밖 — 같은 줄을 -s 없이 다시 === +curl: (7) Failed to connect to auth.hyeonworks.com:443 after 3 ms: Could not connect to server +000 +exit=7 +=== 이름이 무엇으로 풀리는가 === +100.83.212.4 auth.hyeonworks.com +=== 엣지 쪽에서 같은 줄 === +000 +exit=7 +=== 엣지 주소로 직접 (이름만 헤더로) === +200 +exit=0 +=== http 로 === +301 +exit=0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-06-prep-and-switch.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-06-prep-and-switch.txt new file mode 100644 index 0000000..e698408 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-06-prep-and-switch.txt @@ -0,0 +1,172 @@ +=== a7 1 ① 노드와 파드를 넓게 본다 === +NAME STATUS ROLES AGE VERSION +kc-lab-1 Ready control-plane 4h34m v1.36.4+k3s1 +kc-lab-2 Ready 4h33m v1.36.4+k3s1 +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +bff-5d97897676-nmlqs 1/1 Running 0 155m 10.42.0.22 kc-lab-1 +bff-5d97897676-zvfhp 1/1 Running 0 155m 10.42.1.51 kc-lab-2 +keycloak-0 1/1 Running 0 126m 10.42.1.64 kc-lab-2 +keycloak-1 1/1 Running 0 126m 10.42.0.29 kc-lab-1 +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 60m 10.42.1.78 kc-lab-2 +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 60m 10.42.1.79 kc-lab-2 +postgres-7b474b88c8-48lrr 1/1 Running 0 165m 10.42.1.46 kc-lab-2 +redis-f94bc6dbf-s674z 1/1 Running 0 128m 10.42.1.63 kc-lab-2 +=== a7 1 ② IP 를 변수에 담는다 === +10.42.1.64 10.42.0.29 +=== a7 2 컨테이너 args 를 그대로 찍는다 === +["start"] +=== a7 3 온라인 세션과 offline token 을 나눠 센다 === + offline_flag | count +--------------+------- + 0 | 143 +(1 row) + +=== a7 4 ① 탐침을 띄우고 Ready 를 기다린다 === +pod/a7-probe created +pod/a7-probe condition met +=== a7 4 ② 비밀번호의 길이만 센다 === +19 +=== a7 4 ③ 탐침 안에 값이 들어갔는지 본다 === +K0=10.42.1.64 K1=10.42.0.29 PW길이=19 +=== a7 5 ② 토큰을 파드 안 파일에 담고 길이를 찍는다 === +rt 612 bytes +=== a7 5 ③ 반대 노드에서 그 토큰으로 갱신한다 === +200 +=== a7 6 빌드 기능 목록에서 이름을 찾는다 === + persistent-user-sessions[:v1] + persistent-user-sessions +exit=0 +=== a7 7 온라인·오프라인 세션 행을 전부 지운다 === +DELETE 144 +=== a7 8 파일 대신 patch 로 바꾸는 형태 === +statefulset.apps/keycloak patched +=== a7 8 ③ 전환 시각을 남기고 롤아웃을 기다린다 === +18:02:41 전환 +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... +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... +partitioned roll out complete: 2 new pods have been updated... +exit=0 +18:03:47 롤아웃 끝 +=== a7 9 args 와 파드 나이를 함께 본다 === +["start","--features-disabled=persistent-user-sessions"] +keycloak-0 1/1 Running 0 31s 10.42.1.82 kc-lab-2 +keycloak-1 1/1 Running 0 64s 10.42.0.37 kc-lab-1 +=== a7 9 ① 롤아웃 뒤 IP 를 다시 잡는다 === +10.42.1.82 10.42.0.37 +=== a7 9 ② 탐침을 지우고 새 IP 로 다시 띄운다 === +pod "a7-probe" deleted from keycloak-lab namespace +pod/a7-probe created +pod/a7-probe condition met +=== a7 10 ① 한쪽 노드에만 다섯 번 로그인한다 === +200 200 200 200 200 +=== a7 10 ② 같은 질의로 DB 행을 다시 센다 === + offline_flag | count +--------------+------- +(0 rows) + +=== a7 11 ① 한 줄짜리 JSON 을 통째로 본다 === +{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"users","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"82"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"clientSessions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"82"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"authorization","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"offlineSessions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"realmRevisions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"7"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"authenticationSessions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"userRevisions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"keys","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"work","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"realms","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"7"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"offlineClientSessions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"actionTokens","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"1"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"authorizationRevisions","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"loginFailures","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"crl","cache_manager":"keycloak","exported_node":"keycloak-1-47642","instance":"10.42.0.37:9000","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"users","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"work","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"1"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"loginFailures","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"clientSessions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"62"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"actionTokens","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"offlineClientSessions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"authenticationSessions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"keys","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"crl","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"authorizationRevisions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"offlineSessions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"62"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"authorization","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"realmRevisions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"7"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"realms","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"7"]},{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"userRevisions","cache_manager":"keycloak","exported_node":"keycloak-0-23770","instance":"10.42.1.82:9000","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1789635775.118,"0"]}]}} +=== a7 11 ② 캐시 이름과 파드와 값만 세로로 늘어놓는다 === +"cache":"users" +"pod":"keycloak-1"} +"0"]} +"cache":"sessions" +"pod":"keycloak-1"} +"82"]} +"cache":"clientSessions" +"pod":"keycloak-1"} +"82"]} +"cache":"authorization" +"pod":"keycloak-1"} +"0"]} +"cache":"offlineSessions" +"pod":"keycloak-1"} +"0"]} +"cache":"realmRevisions" +"pod":"keycloak-1"} +"7"]} +"cache":"authenticationSessions" +"pod":"keycloak-1"} +"0"]} +"cache":"userRevisions" +"pod":"keycloak-1"} +"0"]} +"cache":"keys" +"pod":"keycloak-1"} +"0"]} +"cache":"work" +"pod":"keycloak-1"} +"0"]} +"cache":"realms" +"pod":"keycloak-1"} +"7"]} +"cache":"offlineClientSessions" +"pod":"keycloak-1"} +"0"]} +"cache":"actionTokens" +"pod":"keycloak-1"} +"1"]} +"cache":"authorizationRevisions" +"pod":"keycloak-1"} +"0"]} +"cache":"loginFailures" +"pod":"keycloak-1"} +"0"]} +"cache":"crl" +"pod":"keycloak-1"} +"0"]} +"cache":"users" +"pod":"keycloak-0"} +"0"]} +"cache":"work" +"pod":"keycloak-0"} +"1"]} +"cache":"loginFailures" +"pod":"keycloak-0"} +"0"]} +"cache":"clientSessions" +"pod":"keycloak-0"} +"62"]} +"cache":"actionTokens" +"pod":"keycloak-0"} +"0"]} +"cache":"offlineClientSessions" +"pod":"keycloak-0"} +"0"]} +"cache":"authenticationSessions" +"pod":"keycloak-0"} +"0"]} +"cache":"keys" +"pod":"keycloak-0"} +"0"]} +"cache":"crl" +"pod":"keycloak-0"} +"0"]} +"cache":"authorizationRevisions" +"pod":"keycloak-0"} +"0"]} +"cache":"offlineSessions" +"pod":"keycloak-0"} +"0"]} +"cache":"sessions" +"pod":"keycloak-0"} +"62"]} +"cache":"authorization" +"pod":"keycloak-0"} +"0"]} +"cache":"realmRevisions" +"pod":"keycloak-0"} +"7"]} +"cache":"realms" +"pod":"keycloak-0"} +"7"]} +"cache":"userRevisions" +"pod":"keycloak-0"} +"0"]}]}} +exit=0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-07-observe.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-07-observe.txt new file mode 100644 index 0000000..01c4abb --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-07-observe.txt @@ -0,0 +1,148 @@ +=== a7 12 로그인하고 반대 노드에서 갱신한다 === +200 +=== a7 13 ① 토큰을 담고 access token 의 클레임을 편다 === +{"exp":1789635852,"iat":1789635792,"jti":"onltro:81f8f0e3-06aa-890c-3b2b-bc1080d617bd","iss":"https://auth.hyeonworks.com/realms/master","typ":"Bearer","azp":"admin-cli","sid":"7HNHaJ1RxIKwxI-CUn86vUSe","scope":"profile email +=== a7 13 ② 시각을 남기고 롤링 재시작을 건다 === +18:04:47 재시작 +statefulset.apps/keycloak restarted +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... +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... +partitioned roll out complete: 2 new pods have been updated... +=== a7 13 ③ 롤아웃 뒤 IP 를 다시 잡는다 === +10.42.1.84 10.42.0.38 +=== a7 13 ④ 재시작 전 토큰으로 갱신을 시도한다 === +{"error":"invalid_grant","error_description":"Session not active"} +400 +=== (참고) 재시작 뒤 sessions 캐시 === +"pod":"keycloak-1"} +"151"]} +"pod":"keycloak-0"} +"0"]}]}} +######## a7 14 ① 을 kc-lab-1 에서 문서 그대로 친 결과 +time="2026-09-17T09:04:49Z" level=warning msg="Unable to read /etc/rancher/k3s/k3s.yaml, please start server with --write-kubeconfig-mode or --write-kubeconfig-group to modify kube config permissions" +error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied +time="2026-09-17T09:04:49Z" level=warning msg="Unable to read /etc/rancher/k3s/k3s.yaml, please start server with --write-kubeconfig-mode or --write-kubeconfig-group to modify kube config permissions" +error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied +Bad argument `7800' +Try `iptables -h' or 'iptables --help' for more information. +Bad argument `57800' +Try `iptables -h' or 'iptables --help' for more information. +Host key verification failed. +Host key verification failed. +09:04:49 차단 +remote_exit=0 +######## 그 뒤 두 노드의 raw PREROUTING — 규칙이 하나도 안 들어갔다 +Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination +--- kc-lab-2 --- +Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination +K0=10.42.1.84 K1=10.42.0.38 +=== a7 14 ① 양쪽 노드에 raw DROP 을 넣는다 (lab host 에서 두 노드 모두 ssh 한 줄로) === +18:06:40 차단 +=== a7 14 ② 두 노드의 규칙과 카운터를 본다 === +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.0.38 tcp dpt:57800 + 0 0 DROP 6 -- * * 0.0.0.0/0 10.42.0.38 tcp dpt:7800 +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.84 tcp dpt:57800 + 2 116 DROP 6 -- * * 0.0.0.0/0 10.42.1.84 tcp dpt:7800 +=== a7 14 ③ 두 노드가 각각 아는 멤버 수를 본다 === + +25초 +"pod":"keycloak-1"} +"2"]} +"pod":"keycloak-0"} +"2"]}]}} + +50초 +"pod":"keycloak-1"} +"2"]} +"pod":"keycloak-0"} +"2"]}]}} + +75초 +"pod":"keycloak-1"} +"1"]} +"pod":"keycloak-0"} +"1"]}]}} + +100초 +"pod":"keycloak-1"} +"1"]} +"pod":"keycloak-0"} +"1"]}]}} + +125초 +"pod":"keycloak-1"} +"1"]} +"pod":"keycloak-0"} +"1"]}]}} +=== a7 14 ② 다시 — 카운터가 올라갔나 === +Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination + 20 1184 DROP 6 -- * * 0.0.0.0/0 10.42.0.38 tcp dpt:57800 + 2 120 DROP 6 -- * * 0.0.0.0/0 10.42.0.38 tcp dpt:7800 +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.84 tcp dpt:57800 + 22 4002 DROP 6 -- * * 0.0.0.0/0 10.42.1.84 tcp dpt:7800 +=== a7 14 ④ 디스커버리 테이블의 코디네이터를 센다 === + name | ip | coord +------------------+-----------------+------- + keycloak-0-41852 | 10.42.1.84:7800 | t + keycloak-1-65118 | 10.42.0.38:7800 | t +(2 rows) + + +[exited with code 0] +K0=10.42.1.84 K1=10.42.0.38 +=== a7 14 ⑤ 대조군 — 로그인한 노드에서 갱신한다 === +same-node 200 +=== a7 14 ⑥ 시험군 — 새로 로그인해서 반대 노드에서 갱신한다 === +{"error":"invalid_grant","error_description":"Session not active"} +cross-node 400 +=== a7 14 차단을 푼다 (두 노드) === +18:09:09 해제 +Chain PREROUTING (policy ACCEPT) +target prot opt source destination +Chain PREROUTING (policy ACCEPT) +target prot opt source destination +=== 양쪽 cluster_size 가 2 로 돌아오는가 === + +20초 +"pod":"keycloak-1"} +"1"]} +"pod":"keycloak-0"} +"1"]}]}} + +40초 +"pod":"keycloak-1"} +"1"]} +"pod":"keycloak-0"} +"1"]}]}} + +60초 +"pod":"keycloak-1"} +"2"]} +"pod":"keycloak-0"} +"2"]}]}} + 둘 다 2 + +[exited with code 0] +K0=10.42.1.84 K1=10.42.0.38 +=== a7 15 ① 로그인만 해서 /tmp/rt 를 채운다 === +{"exp":1789636190,"iat":1789636130,"jti":"onltro:66c69a56-72aa-998d-3a65-669945a48ac9","iss":"https://auth.hyeonworks.com/realms/master","typ":"Bearer","azp":"admin-cli","sid":"x8EWkde54QSEnlY-lDKizez3","scope":"profile email +=== a7 15 ② 시각을 남기고 DB 를 0 replica 로 내린다 === +18:10:24 정지 +deployment.apps/postgres scaled +pod/postgres-7b474b88c8-48lrr condition met +=== a7 15 ③ 캐시를 가진 노드에서 refresh === +500 +=== a7 15 ④ 새 로그인 === +200 +=== a7 15 ⑤ DB 를 다시 올린다 === +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 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-08-revert-checklist.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-08-revert-checklist.txt new file mode 100644 index 0000000..3890987 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/a7-08-revert-checklist.txt @@ -0,0 +1,64 @@ +=== a7 복구 ① 두 노드의 raw 규칙을 확인한다 === +Chain PREROUTING (policy ACCEPT) +target prot opt source destination +Chain PREROUTING (policy ACCEPT) +target prot opt source destination +=== a7 복구 ② DB 파드를 본다 === +NAME READY STATUS RESTARTS AGE +postgres-7b474b88c8-kvt96 1/1 Running 0 30s +=== a7 복구 ③ args 를 기본값으로 되돌린다 === +statefulset.apps/keycloak patched +18:10:55 원복 +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... +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... +partitioned roll out complete: 2 new pods have been updated... +=== a7 복구 ④ 매니페스트의 변경을 확인한다 (patch 로 바꿨으므로 파일은 안 건드렸다) === +git diff exit=0 (출력 없음이면 변경 없음) +=== a7 복구 ⑤ 새 IP 로 탐침을 다시 띄운다 === +pod "a7-probe" deleted from keycloak-lab namespace +K0=10.42.1.86 K1=10.42.0.39 +pod/a7-probe created +pod/a7-probe condition met +=== a7 복구 ⑥ 로그인을 한 번 한다 (상태 코드만) === +200 +=== a7 복구 ⑦ 로그인 뒤 온라인 세션 행을 센다 === +1 +=== 확인표 args === +["start"] +=== 확인표 파드 === +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +a7-probe 1/1 Running 0 2s 10.42.1.87 kc-lab-2 +bff-5d97897676-nmlqs 1/1 Running 0 166m 10.42.0.22 kc-lab-1 +bff-5d97897676-zvfhp 1/1 Running 0 166m 10.42.1.51 kc-lab-2 +keycloak-0 1/1 Running 0 72s 10.42.1.86 kc-lab-2 +keycloak-1 1/1 Running 0 105s 10.42.0.39 kc-lab-1 +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 72m 10.42.1.78 kc-lab-2 +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 71m 10.42.1.79 kc-lab-2 +postgres-7b474b88c8-kvt96 1/1 Running 0 2m18s 10.42.1.85 kc-lab-2 +redis-f94bc6dbf-s674z 1/1 Running 0 139m 10.42.1.63 kc-lab-2 +=== 확인표 DB === +NAME READY STATUS RESTARTS AGE +postgres-7b474b88c8-kvt96 1/1 Running 0 2m18s +=== 확인표 iptables === +Chain PREROUTING (policy ACCEPT) +target prot opt source destination +Chain PREROUTING (policy ACCEPT) +target prot opt source destination +=== 확인표 클러스터 === +"pod":"keycloak-1"} +"2"]} +"pod":"keycloak-0"} +"2"]}]}} +=== a7 복구 ⑧ 탐침 파드를 지운다 === +pod "a7-probe" deleted from keycloak-lab namespace +=== 확인표 탐침 파드 === +Error from server (NotFound): pods "a7-probe" not found +=== 확인표 밖 === +000 +200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-01-current-state.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-01-current-state.txt new file mode 100644 index 0000000..861fc1e --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-01-current-state.txt @@ -0,0 +1,3 @@ +=== 지금 bff 가 어느 편의 상태인가 === +2 replicas, image=keycloak-pattern-bff:lab +--- SessionRepository 계열 빈이 있나 (b0 2절과 같은 질문) diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-02-beans.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-02-beans.txt new file mode 100644 index 0000000..4eb051f --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-02-beans.txt @@ -0,0 +1,10 @@ +=== actuator/beans 가 응답하나 === +sh: curl: not found +command terminated with exit code 127 +--- 빈 수 +sh: curl: not found +command terminated with exit code 127 +0 +--- session 이 이름에 든 빈 +sh: curl: not found +command terminated with exit code 127 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-03-beans-doc-form.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-03-beans-doc-form.txt new file mode 100644 index 0000000..818ca9a --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b0-03-beans-doc-form.txt @@ -0,0 +1,22 @@ +=== b0 1단계 ①②③ (문서 형태) === +bff-5d97897676-nmlqs +155395 /tmp/beans.json +{"contexts":{"keycloak-bff":{"beans":{"applicationTaskExecutor":{"aliases":["bootstrapExecutor"],"scope":"singleton","type":"org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor","resource + +=== 밖에서 /actuator/beans 는 정말 Bad Gateway 인가 (TLS 가 선 지금) === +code=200 size=155395 +{"contexts":{"keycloak-bff":{"beans":{"applicationTaskExecutor":{"aliases":["bootstrapExecutor"],"scope":"singleton","ty + +=== b0 2단계 저장소 계열 빈 === +236 +"org.springframework.boot.autoconfigure.session.RedisSessionConfiguration" +"org.springframework.boot.autoconfigure.session.RedisSessionProperties" +"org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration" +"org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration" +"org.springframework.session.data.redis.RedisSessionRepository" +"org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration" +"org.springframework.session.web.http.SessionRepositoryFilter" +"sessionRepository" +"sessionRepositoryFilterRegistration" +"springBootSessionRepositoryCustomizer" +"springSessionRepositoryFilter" diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-01-login-flow.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-01-login-flow.txt new file mode 100644 index 0000000..274ca36 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-01-login-flow.txt @@ -0,0 +1,11 @@ +=== ① 로그인 페이지까지 따라간다 === +도착: https://app1.hyeonworks.com/... +상태: 0 개의 form 단서 +form action: ... + +=== ② 자격을 제출한다 === +결과: 000 + +=== ③ 토큰 경계를 읽는다 (b0 5절 · b1 관찰) === + +=== ④ 쿠키 항아리에 무엇이 남았나 (값은 길이만) === diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-02-login-flow.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-02-login-flow.txt new file mode 100644 index 0000000..aafac47 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-02-login-flow.txt @@ -0,0 +1,20 @@ +=== ① /oauth2/authorization/keycloak 로 시작 === +도착: https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response +바이트: 7021 +form action: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_code=ySbuIX9Mo31v8XcKl + +=== ② 자격 제출 === +결과: 200 https://app1.hyeonworks.com/ +본문 앞: + + + + + AP3 + +=== ③ 토큰 경계 === +{"pattern":"AP3-backend-for-frontend","principal":"labuser","accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,"browserTokenCount":0,"csrfProtectionEnabled":true} + +=== ④ 쿠키 (값은 길이만) === +auth.hyeonworks.com KC_AUTH_SESSION_HASH 66자 +auth.hyeonworks.com KEYCLOAK_SESSION 64자 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-03-redis.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-03-redis.txt new file mode 100644 index 0000000..416bc32 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-03-redis.txt @@ -0,0 +1,20 @@ +=== 쿠키 전부 (HttpOnly 포함 · 값은 길이만) === +auth.hyeonworks.com KC_AUTH_SESSION_HASH 66자 +auth.hyeonworks.com AUTH_SESSION_ID 165자 +app1.hyeonworks.com AP3_SESSION 48자 +auth.hyeonworks.com KEYCLOAK_IDENTITY 594자 +auth.hyeonworks.com KEYCLOAK_SESSION 64자 + +=== b1 관찰 2 · Redis 를 직접 연다 === +dbsize=2 +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 type=hash ttl=1783 +bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c type=hash ttl=1790 + +=== b1 관찰 3 · 필드와 바이트 === +키: bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 +--- 필드 이름 (값은 안 본다) +sessionAttr:SPRING_SECURITY_SAVED_REQUEST +maxInactiveInterval +creationTime +lastAccessedTime +--- 전체 바이트: 2224 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-04-redis-doc-form.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-04-redis-doc-form.txt new file mode 100644 index 0000000..183cd91 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-04-redis-doc-form.txt @@ -0,0 +1,28 @@ +=== b1 관찰 2 ① 문서 그대로 === +2 +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 +bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c + +=== b1 관찰 2 ② 문서 그대로 (head -1) === +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 +--- 그 키의 타입과 필드 +hash +sessionAttr:SPRING_SECURITY_SAVED_REQUEST +maxInactiveInterval +creationTime +lastAccessedTime +--- TTL: 1771 + +=== 키가 둘인데 각각 무엇을 담고 있나 === +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 + sessionAttr:SPRING_SECURITY_SAVED_REQUEST + maxInactiveInterval + creationTime + lastAccessedTime +bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c + lastAccessedTime + maxInactiveInterval + sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST + creationTime + sessionAttr:SPRING_SECURITY_LAST_EXCEPTION + sessionAttr:SPRING_SECURITY_CONTEXT diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-05-correct-key.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-05-correct-key.txt new file mode 100644 index 0000000..c3bd59b --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b1-05-correct-key.txt @@ -0,0 +1,6 @@ +=== SPRING_SECURITY_CONTEXT 를 가진 키를 골라서 관찰 3 ② === +인증된 세션 키: bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c +TTL: 1767 +--- --no-raw hgetall 앞 두 줄 + 1) "lastAccessedTime" + 2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\xa0\xaeW\xad\xa3" diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-01-xargs-cr.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-01-xargs-cr.txt new file mode 100644 index 0000000..c1f561e --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-01-xargs-cr.txt @@ -0,0 +1,20 @@ +=== 시험용 키 둘을 넣는다 === +OK +OK +bff:session:sessions:e22840df-61f2-4126-99a9-7f1dd7fddcc1 +bff:session:probe2 +bff:session:sessions:8a132645-4322-4b73-b97a-e3604ad1e766 +bff:session:sessions:503e5c35-4e15-4d59-a034-5b9decc0ec54 +bff:session:probe1 + +=== b2 ② 를 문서 그대로 (tr -d ' ' 없이) === +5 + +=== 정말 지워졌나 === +(위가 비면 지워진 것) + +=== 스캔 출력에 CR 이 실제로 붙어 오나 === +0000000 b f f : s e s s i o n : p r o b +0000020 e 3 \n +0000023 +1 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-02-session-counts.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-02-session-counts.txt new file mode 100644 index 0000000..d023c19 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-02-session-counts.txt @@ -0,0 +1,21 @@ +=== b2 ② 토큰 행 수 === + count +------- + 0 +(1 row) + + +=== b2 ③ Keycloak 세션을 DB 쪽에서 센다 === + offline_flag | count +--------------+------- + 0 | 5 +(1 row) + + +=== b2 ④ 관리 API 로 세는 형태 === +[ { + "offline" : "0", + "clientId" : "bff-confidential", + "active" : "4", + "id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b" +} ] diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-03-jdbc-state.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-03-jdbc-state.txt new file mode 100644 index 0000000..6fd8bb2 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-03-jdbc-state.txt @@ -0,0 +1,13 @@ +=== B-2 ② 로그인 뒤 토큰 행 수 === + count +------- + 1 +(1 row) + + +=== 빈 목록에 JDBC 계열이 있나 === +"authorizedClientRepository" +"authorizedClientService" +"org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager" +"org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService" +"org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository" diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-04-token-rows.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-04-token-rows.txt new file mode 100644 index 0000000..068f94d --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-04-token-rows.txt @@ -0,0 +1,18 @@ +=== B-2 7단계 ① 행 수·해시·발급 시각 === + client_registration_id | principal_name | access_token_issued_at | at_md5 +------------------------+----------------+----------------------------+---------------------------------- + keycloak | labuser | 2026-09-17 08:01:46.958592 | 722769ac041a42164d08060e5446d02b +(1 row) + + +=== B-2 7단계 ② 두 토큰의 바이트 수 === + client_registration_id | principal_name | access_token_type | at_len | rt_len +------------------------+----------------+-------------------+--------+-------- + keycloak | labuser | Bearer | 1437 | 744 +(1 row) + + +=== B-2 관찰 2 ① rt 바이트 수 === +744 +=== B-2 관찰 2 ② 앞 36자만 (자격증명이라 36자에서 끊는다) === +eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-05-overwrite.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-05-overwrite.txt new file mode 100644 index 0000000..e6e5fcc --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b2-05-overwrite.txt @@ -0,0 +1,12 @@ +=== 대조군 (브라우저 A) === + 행 수 1 + at_md5 722769ac041a42164d08060e5446d02b + +=== 주입: 새 브라우저(B)로 같은 사용자 로그인 === + 로그인 폼을 거쳐 200 +{"pattern":"AP3-backend-for-frontend","principal":"labuser","accessTokenStoredOnServer":tr + +=== 관찰 1: 행이 늘었나 덮어써졌나 === + 행 수 1 + at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 + 발급 시각 2026-09-17 08:05:52.156416 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-04-baseline.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-04-baseline.txt new file mode 100644 index 0000000..6a70eaa --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-04-baseline.txt @@ -0,0 +1,43 @@ +=== b5 1 파드 배치를 본다 === +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +bff-5d97897676-nmlqs 1/1 Running 0 168m 10.42.0.22 kc-lab-1 <none> <none> +bff-5d97897676-zvfhp 1/1 Running 0 168m 10.42.1.51 kc-lab-2 <none> <none> +keycloak-0 1/1 Running 0 3m 10.42.1.86 kc-lab-2 <none> <none> +keycloak-1 1/1 Running 0 3m33s 10.42.0.39 kc-lab-1 <none> <none> +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 73m 10.42.1.78 kc-lab-2 <none> <none> +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 73m 10.42.1.79 kc-lab-2 <none> <none> +postgres-7b474b88c8-kvt96 1/1 Running 0 4m6s 10.42.1.85 kc-lab-2 <none> <none> +redis-f94bc6dbf-s674z 1/1 Running 0 141m 10.42.1.63 kc-lab-2 <none> <none> +=== b5 2 Redis 의 내용과 영속화 설정을 본다 === +PONG +save +3600 1 300 100 60 10000 +appendonly +yes +=== b5 3 ① 파드에 볼륨이 붙어 있는가 === +[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}},{"name":"kube-api-access-g4scs","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}] +=== b5 3 ② 어디에 붙었는지와 PVC 상태를 본다 === +[{"mountPath":"/data","name":"data"},{"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount","name":"kube-api-access-g4scs","readOnly":true}] +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +postgres-data Bound pvc-fe269834-3dd1-4b1f-82f6-a53413310a30 5Gi RWO local-path <unset> 4h38m +redis-data Bound pvc-76a6565c-ca60-4a93-b08f-51400c5680ce 1Gi RWO local-path <unset> 168m +=== b5 4 세 경로의 상태 코드를 뽑는다 === +/ 000 +/bff/token-boundary 000 +/actuator/health 000 +=== (진단) 이름이 무엇으로 풀리나 === +100.83.212.4 app1.hyeonworks.com +=== (진단) 엣지 주소를 짚어 같은 셋 === +/ 200 +/bff/token-boundary 302 +/actuator/health 200 +=== b5 5 ① health 그룹 셋의 본문을 받는다 === + + + +=== (진단) 같은 셋을 엣지 주소로 === +{"status":"UP","groups":["liveness","readiness"],"components":{"db":{"status":"UP","details":{"database":"PostgreSQL","validationQuery":"isValid()"}},"diskSpace":{"status":"UP","details":{"total":20922114048,"free":13518041088,"threshold":10485760,"path":"/app/.","exists":true}},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"redis":{"status":"UP","details":{"version":"7.4.11"}},"ssl":{"status":"UP","details":{"validChains":[],"invalidChains":[]}}}} +{"status":"UP"} +{"status":"UP"} +=== b5 5 ② kubelet 이 보는 경로를 확인한다 === +/actuator/health/readiness diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-05-redis-down.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-05-redis-down.txt new file mode 100644 index 0000000..19b1774 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-05-redis-down.txt @@ -0,0 +1,56 @@ +=== b5 주입 1 시각을 남기고 Redis 를 0대로 내린다 === +18:14:47 정지 +deployment.apps/redis scaled +pod/redis-f94bc6dbf-s674z condition met +=== b5 주입 검증 ① Redis 가 0대인가 === +No resources found in keycloak-lab namespace. +NAME READY UP-TO-DATE AVAILABLE AGE +redis 0/0 0 0 168m +=== b5 주입 검증 ② BFF 로그에서 연결 시도를 찾는다 === +2026-09-17T09:09:46.531Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@13f41550 (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-09-17T09:09:54.173Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@75a79757 (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-09-17T09:09:55.362Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@43aef356 (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-09-17T09:09:57.318Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@7be4bd3b (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-09-17T09:09:57.955Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@32b0803f (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-09-17T09:10:19.741Z WARN 1 --- [keycloak-bff] [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@5b2e9f1c (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-09-17T09:13:13.527Z INFO 1 --- [keycloak-bff] [xecutorLoop-1-2] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379 +2026-09-17T09:13:13.528Z INFO 1 --- [keycloak-bff] [xecutorLoop-1-1] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379 +2026-09-17T09:13:13.529Z WARN 1 --- [keycloak-bff] [ioEventLoop-4-2] i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [redis.keycloak-lab.svc/<unresolved>:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379 +2026-09-17T09:13:13.530Z WARN 1 --- [keycloak-bff] [ioEventLoop-4-1] i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [redis.keycloak-lab.svc/<unresolved>:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379 +=== b5 주입 검증 ③ 엉뚱한 것을 죽이지 않았는지 본다 === +NAME READY STATUS RESTARTS AGE +bff-5d97897676-nmlqs 1/1 Running 0 168m +bff-5d97897676-zvfhp 1/1 Running 0 168m +keycloak-0 1/1 Running 0 3m17s +keycloak-1 1/1 Running 0 3m50s +oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 74m +oauth2-proxy-d9b587458-njxdw 1/1 Running 0 74m +postgres-7b474b88c8-kvt96 1/1 Running 0 4m23s +=== b5 관찰 세 경로를 다시 친다 (문서 그대로) === +/ 000 +/bff/token-boundary 000 +/actuator/health 000 +=== (진단) 엣지 주소를 짚어 같은 셋 === +/ 200 +/bff/token-boundary 000 +/actuator/health 000 +=== (진단) token-boundary 응답 본문 === +[본문 끝] +=== b5 관찰 health 그룹 셋을 코드와 본문으로 본다 (엣지 주소를 짚어서) === +health 000 +health/readiness 200 +health/liveness 200 + +{"status":"UP"} +=== b5 관찰 엔드포인트가 아직 ready 인지 본다 === +NAME ADDR READY +bff-ptf2d [10.42.1.51],[10.42.0.22] true,true +=== (진단) /actuator/health 를 더 길게 기다려 본다 === +health 503 total 60.046452 +exit=0 +{"status":"DOWN","groups":["liveness","readiness"],"components":{"db":{"status":"UP","details":{"database":"PostgreSQL","validationQuery":"isValid()"}},"diskSpace":{"status":"UP","details":{"total":20922114048,"free":13516791808,"threshold":10485760,"path":"/app/.","exists":true}},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}},"ssl":{"status":"UP","details":{"validChains":[],"invalidChains":[]}}}} +=== (진단) /bff/token-boundary 도 길게 === +token-boundary 000 total 90.001609 +exit=28 + +[exited with code 0] diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-06-persistence.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-06-persistence.txt new file mode 100644 index 0000000..1a50c99 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-06-persistence.txt @@ -0,0 +1,74 @@ +=== b5 관찰 ① Redis 를 다시 올린다 === +18:18:32 복구 +deployment.apps/redis scaled +Waiting for deployment "redis" rollout to finish: 0 out of 1 new replicas have been updated... +Waiting for deployment "redis" rollout to finish: 0 of 1 updated replicas are available... +deployment "redis" successfully rolled out +=== b5 관찰 ② 회복했는지와 재시작 횟수를 본다 (엣지 주소를 짚어서) === +/actuator/health 200 +/bff/token-boundary 302 +NAME READY STATUS RESTARTS AGE +bff-5d97897676-nmlqs 1/1 Running 0 173m +bff-5d97897676-zvfhp 1/1 Running 0 173m +=== b5 주입 2 ① 볼륨 참조를 떼고 롤아웃을 기다린다 === +deployment.apps/redis patched +Waiting for deployment "redis" rollout to finish: 0 out of 1 new replicas have been updated... +Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination... +deployment "redis" successfully rolled out +=== b5 주입 검증 ④ 볼륨이 정말 떨어졌는가 === +NAME READY STATUS RESTARTS AGE +redis-547fb5fcc7-wcqg8 1/1 Running 0 11s +redis-f94bc6dbf-l9q6x 0/1 Completed 0 47s +[{"name":"kube-api-access-d7bbm","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}] +=== (진단) volumeMounts 쪽도 === +[{"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount","name":"kube-api-access-d7bbm","readOnly":true}] +=== b5 주입 2 ② AOF 를 켜고 키를 심는다 === +OK +appendonly +yes +OK +1 +total 12 +drwxr-xr-x 3 redis redis 4096 Sep 17 09:17 . +drwxr-xr-x 1 root root 4096 Sep 17 09:17 .. +drwx------ 2 redis redis 4096 Sep 17 09:17 appendonlydir +=== b5 관찰 ③ 볼륨 없이 파드를 지우고 남은 것을 센다 === +pod "redis-547fb5fcc7-wcqg8" deleted from keycloak-lab namespace +Waiting for deployment "redis" rollout to finish: 0 of 1 updated replicas are available... +deployment "redis" successfully rolled out +NAME READY STATUS RESTARTS AGE +redis-547fb5fcc7-4xv8m 1/1 Running 0 12s +0 + +appendonly +yes +=== b5 관찰 ④ 볼륨을 되돌리고 같은 시험을 다시 한다 === +secret/bff-secrets configured +persistentvolumeclaim/redis-data unchanged +deployment.apps/redis configured +service/redis unchanged +deployment.apps/bff unchanged +service/bff unchanged +ingress.networking.k8s.io/bff unchanged +Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "redis" rollout to finish: 1 old replicas are pending termination... +deployment "redis" successfully rolled out +appendonly +yes +OK +pod "redis-547fb5fcc7-4xv8m" deleted from keycloak-lab namespace +pod "redis-f94bc6dbf-lqrgp" deleted from keycloak-lab namespace +Waiting for deployment "redis" rollout to finish: 0 of 1 updated replicas are available... +deployment "redis" successfully rolled out +NAME READY STATUS RESTARTS AGE +redis-f94bc6dbf-b7zdk 1/1 Running 0 12s +4 +written-on-pvc +=== b5 PVC 의 스토리지 클래스를 본다 === +local-path +=== b5 B층 구성 요소의 지표가 있는가 === + redis_up 시계열 0개 + redis_connected_clients 시계열 0개 + pg_up 시계열 0개 + pg_stat_database_numbackends 시계열 0개 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-07-revert-checklist.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-07-revert-checklist.txt new file mode 100644 index 0000000..f4c6b5a --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b5-07-revert-checklist.txt @@ -0,0 +1,39 @@ +=== b5 복구 1 매니페스트를 다시 적용한다 === +secret/bff-secrets configured +persistentvolumeclaim/redis-data unchanged +deployment.apps/redis unchanged +service/redis unchanged +deployment.apps/bff unchanged +service/bff unchanged +ingress.networking.k8s.io/bff unchanged +deployment "redis" successfully rolled out +=== b5 복구 2 실험 키만 지우고 남은 키를 본다 === +1 +bff:session:sessions:[REDACTED] +bff:session:sessions:[REDACTED] +bff:session:sessions:[REDACTED] +=== b5 복구 3 확인표 — Redis === +NAME READY UP-TO-DATE AVAILABLE AGE +redis 1/1 1 1 174m +=== 확인표 볼륨 === +[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}},{"name":"kube-api-access-6mxzf","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}] +=== 확인표 PVC === +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +postgres-data Bound pvc-fe269834-3dd1-4b1f-82f6-a53413310a30 5Gi RWO local-path <unset> 4h44m +redis-data Bound pvc-76a6565c-ca60-4a93-b08f-51400c5680ce 1Gi RWO local-path <unset> 174m +=== 확인표 영속화 === +appendonly +yes +=== 확인표 BFF === +NAME READY STATUS RESTARTS AGE +bff-5d97897676-nmlqs 1/1 Running 0 174m +bff-5d97897676-zvfhp 1/1 Running 0 174m +=== 확인표 엔드포인트 === +NAME ADDRESSTYPE PORTS ENDPOINTS AGE +bff-ptf2d IPv4 8083 10.42.1.51,10.42.0.22 174m +=== 확인표 밖 (문서 그대로) === +000 +=== 확인표 밖 (엣지 주소를 짚어서) === +200 +=== (마무리) git 은 안 건드렸다 === +git status exit=0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-01-steps-1-2.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-01-steps-1-2.txt new file mode 100644 index 0000000..d83deaf --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-01-steps-1-2.txt @@ -0,0 +1,25 @@ +=== b6 ① kcadm 로그인 === +Logging into http://localhost:8080 as user admin of realm master +exit=0 + +=== b6 ② 비밀번호 길이만 === +19 + +=== b6 2단계 공급자 목록 === + "id" : "5be506c2-8472-442b-8258-9c6692f45a13", "name" : "Trusted Hosts", "providerId" : "trusted-hosts" + "id" : "e1686b9f-15a2-4979-a302-8fc18a4d6b22", "name" : "Full Scope Disabled", "providerId" : "scope" + "id" : "6a0b8379-b2b1-4d2f-bc3b-fa1673723730", "name" : "Allowed Client Scopes", "providerId" : "allowed-client-templates" + "id" : "7dc6fe06-06c3-4d25-b93a-1fe245e4837c", "name" : "Consent Required", "providerId" : "consent-required" + "id" : "06777327-7c87-4ff4-92da-3335462ccb3b", "name" : "Allowed Client Scopes", "providerId" : "allowed-client-templates" + "id" : "3c9062a0-fc6d-4fef-870e-7bfc10270279", "name" : "Allowed Protocol Mapper Types", "providerId" : "allowed-protocol-mappers" + "id" : "a03ee0cb-765b-4eea-9d6c-e76bcee3adf4", "name" : "Allowed Registration Web Origins", "providerId" : "registration-web-origins" + "id" : "d47b58ee-7c34-4be0-a3f3-30f49b4577c1", "name" : "rsa-enc-generated", "providerId" : "rsa-enc-generated" + "id" : "807e653f-deaf-41cd-bbaa-5b19eaf74a2a", "name" : "hmac-generated-hs512", "providerId" : "hmac-generated" + "id" : "404e38a3-d3c3-4996-9228-200a912de08b", "name" : "Allowed Registration Web Origins", "providerId" : "registration-web-origins" + "id" : "27216d59-950c-4c8c-b0a8-e1d8108e0c0f", "name" : "aes-generated", "providerId" : "aes-generated" + "id" : "142c7871-4556-4faa-b9af-83d2376da5c6", "name" : "Max Clients Limit", "providerId" : "max-clients" + "id" : "a09ec151-6c8c-4e55-84c0-f02781221b83", "name" : "rsa-generated", "providerId" : "rsa-generated" + "id" : "212bde50-f539-4742-85bc-97f4a31a604a", "name" : "Allowed Protocol Mapper Types", "providerId" : "allowed-protocol-mappers" + +=== b6 2단계 -q type=... 형태 === +"name" : "rsa-enc-generated" "name" : "hmac-generated-hs512" "name" : "aes-generated" "name" : "rsa-generated" diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-02-steps-3-4.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-02-steps-3-4.txt new file mode 100644 index 0000000..9eb7f10 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-02-steps-3-4.txt @@ -0,0 +1,18 @@ +=== b6 3단계를 문서 그대로: [lab host] 에서 공개 URL 로 === +https 000 (Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server) +http 000 (Failed to connect to auth.hyeonworks.com:80 after 4 ms: Could not connect to server) + +=== 같은 것을 dev 에서 http 로 === +{"keys":[{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg","kty":"RSA","alg":"RS256","use":"sig","x5c":["MIICsTCCAZkC + +=== 3단계 ② kid 만 === +{"keys":[{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" +{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" + +=== 4단계 ① 키 단위로 잘라 RS256 만 센다 === +1 +=== 4단계 ② kcadm get keys === + "kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917", "status" : "ACTIVE", "algorithm" : "AES", + "kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c", "status" : "ACTIVE", "algorithm" : "HS512", + "kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg", "status" : "ACTIVE", "algorithm" : "RS256", + "kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI", "status" : "ACTIVE", "algorithm" : "RSA-OAEP", diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-03-labhost-cannot-reach.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-03-labhost-cannot-reach.txt new file mode 100644 index 0000000..6f8c2b6 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-03-labhost-cannot-reach.txt @@ -0,0 +1,8 @@ +=== 왜 못 닿나 === +100.83.212.4 auth.hyeonworks.com +--- 호스트가 80/443 을 듣나 +(리스너 없음) + +=== 고쳐 치는 형태: --resolve 로 엣지를 직접 가리킨다 === +{"keys":[{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" +{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-04-step5.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-04-step5.txt new file mode 100644 index 0000000..b912849 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-04-step5.txt @@ -0,0 +1,7 @@ +=== b6 5단계: 문서 그대로 [lab host] 에서 (https 공개 URL) === +0자 + +=== 같은 것을 --resolve 로 고쳐서 === +길이 0자 +응답(토큰 없음): {"error":"invalid_grant","error_description":"Invalid user credentials"} +헤더: diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-05-step5-6.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-05-step5-6.txt new file mode 100644 index 0000000..c784731 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-05-step5-6.txt @@ -0,0 +1,8 @@ +=== 비밀번호를 B-6 이 박아 둔 값으로 맞춘다 === +exit=0 + +=== b6 5·6단계 다시 === +5단계 길이: 1406자 +6단계 헤더: {"alg":"RS256","typ" : "JWT","kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"} +6단계 kid : HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg +(토큰은 랩 호스트의 /tmp/old.jwt 에만 둔다) diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-06-steps-7-15.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-06-steps-7-15.txt new file mode 100644 index 0000000..91f6842 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-06-steps-7-15.txt @@ -0,0 +1,30 @@ +=== 5. 옛 토큰 === +1406자 · kid HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg +=== 7. 지금 통하는가 === +old 401 +=== 8. priority 200 공급자 추가 === +Created new component with id 'a8b5aecc-ffb6-4b17-8d5b-3eef5ecb9b72' +16:19:04 추가 +=== 9. JWKS 에 옛 키가 남아 있는가 === +{"keys":[{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4" +{"kid":"HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" +{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" + RS256 키 수: 2 +=== 10. 새 토큰의 kid === +1406자 · kid iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4 +=== 11. 둘 다 통하는가 === +old 401 +new 401 + exp 1789629509 · now 1789629545 +=== 12. 지울 대상 === + 지울 id: a09ec151-6c8c-4e55-84c0-f02781221b83 +=== 13. 옛 공급자 제거 === +16:19:07 제거 +=== 14. JWKS 에서 사라졌는가 === +{"keys":[{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4" +{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" + RS256 키 수: 1 +=== 15. 두 토큰을 다시 === +old 401 +new 401 + exp 1789629509 · now 1789629547 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-07-why-401.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-07-why-401.txt new file mode 100644 index 0000000..a169b2f --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-07-why-401.txt @@ -0,0 +1,47 @@ +=== echo 가 왜 401 을 내나 === + at java.base/sun.nio.ch.Net.connect0(Native Method) ~[na:na] + at java.base/sun.nio.ch.Net.connect(Unknown Source) ~[na:na] + at java.base/sun.nio.ch.Net.connect(Unknown Source) ~[na:na] + at java.base/sun.nio.ch.NioSocketImpl.connect(Unknown Source) ~[na:na] + at java.base/java.net.SocksSocketImpl.connect(Unknown Source) ~[na:na] + at java.base/java.net.Socket.connect(Unknown Source) ~[na:na] + at java.base/sun.security.ssl.SSLSocketImpl.connect(Unknown Source) ~[na:na] + at java.base/sun.security.ssl.BaseSSLSocketImpl.connect(Unknown Source) ~[na:na] + at java.base/sun.net.NetworkClient.doConnect(Unknown Source) ~[na:na] + at java.base/sun.net.www.http.HttpClient.openServer(Unknown Source) ~[na:na] + at java.base/sun.net.www.http.HttpClient.openServer(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.https.HttpsClient.<init>(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.https.HttpsClient.New(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.http.HttpURLConnection.connect(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(Unknown Source) ~[na:na] + at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.connect(Unknown Source) ~[na:na] + at org.springframework.http.client.SimpleClientHttpRequest.executeInternal(SimpleClientHttpRequest.java:79) ~[spring-web-6.2.19.jar!/:6.2.19] + at org.springframework.http.client.AbstractStreamingClientHttpRequest.executeInternal(AbstractStreamingClientHttpRequest.java:88) ~[spring-web-6.2.19.jar!/:6.2.19] + at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:81) ~[spring-web-6.2.19.jar!/:6.2.19] + at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:900) ~[spring-web-6.2.19.jar!/:6.2.19] + ... 101 common frames omitted + + +=== echo 의 issuer 설정 === +[{"name":"SERVER_PORT","value":"8081"},{"name":"SERVER_FORWARD_HEADERS_STRATEGY","value":"native"},{"name":"JAVA_TOOL_OPTIONS","value":"-XX:MaxRAMPercentage=70"},{"name":"SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI","value":"https://auth.hyeonworks.com/realms/keycloak-patterns"},{"name":"SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI","value":"https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"}] + +=== 401 의 WWW-Authenticate === +HTTP/1.1 401 Unauthorized +Server: nginx/1.22.1 +Date: Thu, 17 Sep 2026 07:17:43 GMT +Content-Length: 0 +Connection: keep-alive +Cache-Control: no-cache, no-store, max-age=0, must-revalidate +Expires: 0 +Pragma: no-cache + +=== realm 의 accessTokenLifespan === +{ + "accessTokenLifespan" : 60, + "ssoSessionIdleTimeout" : 1800 +}=== 시계 차이 === +host 1789629559 +kc-0 1789629465 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-08-clock-skew.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-08-clock-skew.txt new file mode 100644 index 0000000..7979aa4 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-08-clock-skew.txt @@ -0,0 +1,18 @@ +=== 기계마다의 시각 (같은 순간에 친다) === +lab host 1789629566 07:19:26 +kc-lab-1 1789629472 07:17:52 +kc-lab-2 1789629473 07:17:53 +kc-lab-edge 1789629473 07:17:53 +kc-0 pod 1789629473 +--- 시간 동기 상태 +== kc-lab-1 +NTP=yes +NTPSynchronized=yes +TimeUSec=Thu 2026-09-17 07:17:53 UTC +== kc-lab-2 +NTP=yes +NTPSynchronized=yes +TimeUSec=Thu 2026-09-17 07:17:54 UTC +== lab host +NTP=no +NTPSynchronized=no diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-09-recovery-table.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-09-recovery-table.txt new file mode 100644 index 0000000..29ac281 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-09-recovery-table.txt @@ -0,0 +1,15 @@ +=== b6 원상복구 확인표 === +--- 서명 키 +{"keys":[{"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4" +{"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" +--- 파드 +echo-cd47dccc6-nq62j 1/1 Running 0 41m +echo-cd47dccc6-q46lk 1/1 Running 0 41m +keycloak-0 1/1 Running 0 24m +keycloak-1 1/1 Running 0 25m +--- 공급자 목록 +"name" : "rsa-enc-generated" +"name" : "rsa-rotated" +--- 새 토큰 + /api/me +토큰 1406자 +/api/me 401 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-10-control-after-tls.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-10-control-after-tls.txt new file mode 100644 index 0000000..2046696 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-10-control-after-tls.txt @@ -0,0 +1,10 @@ +=== echo 를 다시 띄워 JWKS 를 받게 한다 === +deployment "echo" successfully rolled out + +=== b6 7단계 대조군: 토큰이 지금 통하는가 === +토큰 0자 +old 000 + +=== echo 로그에 JWKS 오류가 남아 있나 === +0 +0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-11-control-dev.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-11-control-dev.txt new file mode 100644 index 0000000..7315bf5 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-11-control-dev.txt @@ -0,0 +1,7 @@ +=== b6 5·7단계 (dev 에서) === +5단계 토큰 1406자 +6단계 kid iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4 +7단계 대조군: + old 200 + (본문과 WWW-Authenticate) +HTTP/2 200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-12-rotation-8-11.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-12-rotation-8-11.txt new file mode 100644 index 0000000..d5cedf8 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-12-rotation-8-11.txt @@ -0,0 +1,18 @@ +=== 출발 상태: 지금 있는 RSA 공급자 === +}, { "id" : "a8b5aecc-ffb6-4b17-8d5b-3eef5ecb9b72", "name" : "rsa-rotated", +=== 5. 옛 토큰 === + 1406자 · kid iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4 +=== 7. 대조군 === + old 200 +=== 8. 더 높은 우선순위 공급자를 더한다 (기존이 200 이라 300 을 준다) === +Created new component with id '958e80bd-3b9f-4b05-9d9a-011b8e4ef54a' +=== 9. JWKS 에 옛 키가 남아 있는가 === + {"keys":[{"kid":"mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc" + {"kid":"iq4fSn_kovyqBsgaemqsVcsRJlWIYO5HTyiTO5WI9T4" + {"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" + RS256 키 수: 2 +=== 10. 새 토큰의 kid === + kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc +=== 11. 둘 다 통하는가 === + old 200 + new 200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-13-removal.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-13-removal.txt new file mode 100644 index 0000000..590f771 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-13-removal.txt @@ -0,0 +1,14 @@ +=== 12. 지울 대상 (rsa-rotated = 옛 키) === + a8b5aecc-ffb6-4b17-8d5b-3eef5ecb9b72 +=== 다시 옛 토큰을 받는다 (수명 안에 끝내려고) === + 옛 kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc + 새 kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc + ※ 둘 다 지금은 새 키로 서명된다 — 옛 키 토큰은 위 8~11 에서 받은 것이 정본이다 +=== 13. 옛 공급자를 지운다 === + 07:52:46 제거 +=== 14. JWKS 에서 사라졌는가 === + {"keys":[{"kid":"mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc" + {"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" + RS256 키 수: 1 +=== 15. 두 토큰을 다시 === + 새키 토큰 200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-14-full-rotation.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-14-full-rotation.txt new file mode 100644 index 0000000..3e89c43 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-14-full-rotation.txt @@ -0,0 +1,19 @@ +=== 5. 옛 키로 서명된 토큰 (이 값을 끝까지 안 건드린다) === + 1406자 · kid mL3CkHOfWatmZa6b3c_aFUWrY35EI1WmjxTc1ydpqGc +=== 7. 대조군 === + old 200 +=== 8. priority 400 공급자를 더한다 === + Created new component with id '7aa3832c-787a-4e9b-9eda-2ec72c4c0b97' +=== 9·10 === + RS256 키 수: 2 + 새 토큰 kid AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM +=== 11. 겹치는 구간 === + old 200 + new 200 +=== 13. 옛 공급자(priority 300) 제거 === + 지울 id: 958e80bd-3b9f-4b05-9d9a-011b8e4ef54a +=== 14·15 === + RS256 키 수: 1 + old 200 + new 200 + 경과 6초 (토큰 수명 60초) diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-15-cache-restart.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-15-cache-restart.txt new file mode 100644 index 0000000..4a947be --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-15-cache-restart.txt @@ -0,0 +1,14 @@ +=== 지금 JWKS 에 있는 kid === + {"keys":[{"kid":"AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM" + {"kid":"5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" + +=== 사라진 키로 서명된 토큰을 다시 만들 수는 없다 — 지금 받는 토큰은 살아 있는 키다 === + 지금 토큰 kid AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM + +=== 16. echo 를 다시 띄워 캐시를 비운다 === + deployment "echo" successfully rolled out + 재시작 34초 + +=== 재시작 뒤 현재 토큰 === + now 200 + fresh 200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-16-decisive.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-16-decisive.txt new file mode 100644 index 0000000..3aaf14d --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-16-decisive.txt @@ -0,0 +1,16 @@ +5. 옛 토큰 kid AS65-ZKZDmxLFCfxk2agmmHQpM2jdT4xjP-To6r5SRM + [+0초] +7. 대조군 old 200 +8. Created new component with id '066d3957-9b0e-411f-a36c-87ed17a18815' +10. 새 토큰 kid yHLkV7okKrLif-ndb01imOfaHze94lrllFgITCtoQmc +11. old 200 new 200 + [+2초] +13. 옛 공급자 제거 (7aa3832c-787a-4e9b-9eda-2ec72c4c0b97) + [+5초] +14. RS256 키 수: 1 +15. old 200 new 200 + [+6초] +16. echo 재시작 + [+39초] +16 뒤. old 401 new 504 + [+70초] diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-17-grace-window.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-17-grace-window.txt new file mode 100644 index 0000000..927452d --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-17-grace-window.txt @@ -0,0 +1,12 @@ +=== echo 가 준비됐는지 먼저 === +echo-85b6ffd88f-grm5g 1/1 Running 0 75s +echo-85b6ffd88f-nnvpt 1/1 Running 0 59s +옛 토큰 kid yHLkV7okKrLif-ndb01imOfaHze94lrllFgITCtoQmc +Created new component with id '9af4a602-1e5c-47d7-8c52-6fd7667a5261' +제거 완료 [+5초] · JWKS RS256 1개 +--- 제거 뒤 옛 토큰을 10초마다 + [+6초] old=401 + [+16초] old=200 + [+26초] old=401 + [+36초] old=200 + [+47초] old=401 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-18-per-replica.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-18-per-replica.txt new file mode 100644 index 0000000..39fdb57 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b6-18-per-replica.txt @@ -0,0 +1,9 @@ +=== 옛 키로 서명된 토큰을 하나 만들고 즉시 회전+제거, 그다음 파드별로 친다 === +옛 kid aJCNYKhd4LWm6fTcmllP3dbq_incT5Jww6CzNj92WmU +Created new component with id '6689ab97-c4de-4b23-8ad7-c66ae4759113' +제거 완료 +--- 파드마다 직접 (클러스터 안에서, 파드 IP 로) + echo-85b6ffd88f-grm5g 10.42.1.76 up + echo-85b6ffd88f-nnvpt 10.42.0.35 up +--- 같은 토큰으로 8번 연속 (라운드로빈) +401 200 401 200 401 200 401 200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-01-oauth2proxy-after-dnsfix.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-01-oauth2proxy-after-dnsfix.txt new file mode 100644 index 0000000..8ffe6bc --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-01-oauth2proxy-after-dnsfix.txt @@ -0,0 +1,20 @@ +=== B-7 1단계: Grafana Ingress 를 백업한다 === +30 /home/donghyeon/grafana-ingress-backup.yaml + +=== B-7 2단계 ①② === +ingress.networking.k8s.io "grafana" deleted from observability namespace +secret/oauth2-proxy-secrets configured +deployment.apps/oauth2-proxy created +service/oauth2-proxy created +ingress.networking.k8s.io/oauth2-proxy created +Waiting for deployment "oauth2-proxy" rollout to finish: 0 of 2 updated replicas are available... +error: timed out waiting for the condition + +=== 파드 상태와 실제 오류 === +oauth2-proxy-6c56c4c94f-vncmx 0/1 CrashLoopBackOff 4 (6s ago) 91s +oauth2-proxy-6c56c4c94f-zxg52 0/1 CrashLoopBackOff 3 (48s ago) 91s +--- +[2026/09/17 07:37:18] [provider.go:55] Performing OIDC Discovery... +[2026/09/17 07:37:18] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: could not create provider data: error building OIDC ProviderVerifier: could not get verifier builder: error while discovery OIDC configuration: failed to discover OIDC configuration: error performing request: Get "https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration": dial tcp 192.168.122.10:443: connect: connection refused +[2026/09/17 07:36:36] [provider.go:55] Performing OIDC Discovery... +[2026/09/17 07:36:36] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: could not create provider data: error building OIDC ProviderVerifier: could not get verifier builder: error while discovery OIDC configuration: failed to discover OIDC configuration: error performing request: Get "https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration": dial tcp 192.168.122.10:443: connect: connection refused diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-02-restore.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-02-restore.txt new file mode 100644 index 0000000..155fc19 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-02-restore.txt @@ -0,0 +1,15 @@ +=== B-7 되돌리기 ② 빌린 Ingress 를 걷고 Grafana 것을 올린다 === +ingress.networking.k8s.io "oauth2-proxy" deleted from keycloak-lab namespace +ingress.networking.k8s.io/grafana created +deployment.apps "oauth2-proxy" deleted from keycloak-lab namespace +Error from server (NotFound): deployments.apps "svc" not found +Error from server (NotFound): deployments.apps "oauth2-proxy" not found + +=== 돌아왔는지 === +header-lab echo traefik app1.hyeonworks.com 192.168.122.11,192.168.122.12 80 61m +keycloak-lab bff traefik app1.hyeonworks.com 192.168.122.11,192.168.122.12 80 73m +keycloak-lab keycloak traefik auth.hyeonworks.com 192.168.122.11,192.168.122.12 80 3h3m +observability grafana traefik app2.hyeonworks.com 192.168.122.11,192.168.122.12 80 1s +--- +0 +oauth2-proxy 파드 0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-03-deploy-after-tls.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-03-deploy-after-tls.txt new file mode 100644 index 0000000..ac3ee95 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-03-deploy-after-tls.txt @@ -0,0 +1,21 @@ +=== 쌓인 RSA 공급자 정리 (가장 높은 것 하나만 남긴다) === + "id" : "6689ab97-c4de-4b23-8ad7-c66ae4759113", "name" : "rsa-rot-6", + "id" : "d47b58ee-7c34-4be0-a3f3-30f49b4577c1", "name" : "rsa-enc-generated", + +=== B-7 1단계: Grafana Ingress 백업 === +백업 30줄 + +=== B-7 2단계: Grafana 를 내리고 oauth2-proxy 배포 === +ingress.networking.k8s.io "grafana" deleted from observability namespace +secret/oauth2-proxy-secrets configured +deployment.apps/oauth2-proxy created +service/oauth2-proxy created +ingress.networking.k8s.io/oauth2-proxy created +Waiting for deployment "oauth2-proxy" rollout to finish: 0 out of 2 new replicas have been updated... +Waiting for deployment "oauth2-proxy" rollout to finish: 0 of 2 updated replicas are available... +Waiting for deployment "oauth2-proxy" rollout to finish: 1 of 2 updated replicas are available... +deployment "oauth2-proxy" successfully rolled out + +=== 3단계: replica 와 배치 === +oauth2-proxy-6c56c4c94f-gq484 1/1 Running 0 12s 10.42.1.77 kc-lab-2 <none> <none> +oauth2-proxy-6c56c4c94f-szr6j 1/1 Running 0 12s 10.42.0.36 kc-lab-1 <none> <none> diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-04-login.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-04-login.txt new file mode 100644 index 0000000..20d084f --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-04-login.txt @@ -0,0 +1,18 @@ +=== B-7 4단계: 진입점 두 곳이 갈라지나 === +https://app1.hyeonworks.com/ 200 +https://app2.hyeonworks.com/ 302 + +=== B-7 7단계: app2 로 로그인한다 === +도착: https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?app +form: +로그인 결과: 000 +head: cannot open '/tmp/p2b.html' for reading: No such file or directory +본문 앞: + +=== 쿠키 (값은 길이만) === +app2.hyeonworks.com _oauth2_proxy_csrf 176자 + +=== B-7 8단계: Redis 에 세션이 들어갔나 === +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 +bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c +dbsize: 2 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-05-client-missing.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-05-client-missing.txt new file mode 100644 index 0000000..427f48c --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-05-client-missing.txt @@ -0,0 +1,13 @@ +=== realm 에 등록된 클라이언트 === +account +account-console +admin-cli +bff-confidential +broker +realm-management +security-admin-console + +=== oauth2-proxy 가 쓰는 client_id === + +=== 이 클라이언트를 만드는 setup 문서가 있나 === +docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-06-create-client.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-06-create-client.txt new file mode 100644 index 0000000..c7bf294 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-06-create-client.txt @@ -0,0 +1,11 @@ +=== 어느 문서에도 없는 한 단계: oauth2-proxy 클라이언트를 만든다 === +클라이언트 비밀 길이: 16자 +Created new client with id 'f7bed9b9-8fb5-4b3d-9253-c574c0b09739' + +=== 만들어졌나 === +[ { + "clientId" : "oauth2-proxy", + "enabled" : true, + "redirectUris" : [ "https://app2.hyeonworks.com/oauth2/callback" ], + "publicClient" : false +} ] diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-07-login-ok.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-07-login-ok.txt new file mode 100644 index 0000000..50036ff --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-07-login-ok.txt @@ -0,0 +1,41 @@ +=== B-7 7단계: app2 로 로그인 === +로그인 폼: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_co +로그인 결과: 401 · 본문 0바이트 + +=== 업스트림이 받은 헤더 (echo 가 그대로 돌려준다) === +{ + "headers" : { + "host" : [ "app2.hyeonworks.com" ], + "user-agent" : [ "curl/8.5.0" ], + "accept" : [ "*/*" ], + "accept-encoding" : [ "gzip" ], + "cookie" : [ "_oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDFaR05rTURjNVpUQmlaVE5sWWpoaE9UVXdZelptWm1FMVpHTTVZamhpTVEuWDhMSFFnVnYtenRXV0hzTFMzVVN0QQ==|1789631924|yGT15uSP74BddTXAVgSvVGaz65hygKtaju9CDM3lHlc=" ], + "x-forwarded-email" : [ "labuser@example.invalid" ], + "x-forwarded-host" : [ "app2.hyeonworks.com" ], + "x-forwarded-port" : [ "443" ], + "x-forwarded-preferred-username" : [ "labuser" ], + "x-forwarded-proto" : [ "https" ], + "x-forwarded-server" : [ "traefik-5d6fcf895-xmqp6" ], + "x-forwarded-user" : [ "07d3be2a-9f22-4779-a211-e530c8b7ff3b" ], + "x-real-ip" : [ "192.168.122.1" ] + }, + "remoteAddr" : "192.168.122.1", + "localAddr" : "10.42.0.35", + "scheme" : "https", + "secure" : true, + "serverName" : "app2.hyeonworks.com", + "serverPort" : 443, + "requestUrl" : "https://app2.hyeonworks.com/api/echo" +} +=== 쿠키 (값은 길이만) === +auth.hyeonworks.com KC_AUTH_SESSION_HASH 64자 +auth.hyeonworks.com AUTH_SESSION_ID 165자 +auth.hyeonworks.com KEYCLOAK_IDENTITY 594자 +auth.hyeonworks.com KEYCLOAK_SESSION 64자 +app2.hyeonworks.com _oauth2_proxy 176자 + +=== B-7 8단계: Redis === +_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 +bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c +dbsize=3 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-08-rotation.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-08-rotation.txt new file mode 100644 index 0000000..5fcaf82 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7-08-rotation.txt @@ -0,0 +1,21 @@ +=== 주입 2 ① env 이름 순서 === +OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET + +=== 회전 전 대조군: 지금 쿠키가 통하는가 === + 회전 전 200 + +=== 주입 2 ② A → B 로 바꾼다 === + 07:58:58 UTC 회전 +deployment.apps/oauth2-proxy patched +deployment "oauth2-proxy" successfully rolled out + +=== 주입 검증 1·2·3 === + 참조: COOKIE_SECRET_B + Redis 세션: _oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 + 파드: + oauth2-proxy-d9b587458-7x9jt 1/1 Running 0 25s + oauth2-proxy-d9b587458-njxdw 1/1 Running 0 12s + +=== 관찰: 회전 전 쿠키가 아직 통하는가 === + 회전 뒤 302 + (리다이렉트 목적지) https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?approval_prompt=force&client_id=oauth2-proxy&redirect_uri=https%3A%2F%2Fapp2.hyeonworks.com%2Foauth2%2Fcallback&response_type=code&scope=openid+email+profile&state=5V_qNK4dsuw6h4jJTooqFYlaVJ5WarqCuDpsGlpHgAw%3A%2Fapi%2Fecho diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7a-01-orphan.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7a-01-orphan.txt new file mode 100644 index 0000000..6716757 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/b7a-01-orphan.txt @@ -0,0 +1,10 @@ +=== B-7a 6단계: R 함수와 스캔 루프 (오래 미검증이던 블록) === +_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 type=string ttl=3551 len=3457 + +=== B-7a: TTL 이 줄어드는가 · 갱신되는가 (30초 간격 세 번) === +08:01:07 + _oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 ttl=3551 +08:01:37 + _oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 ttl=3521 +08:02:07 + _oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 ttl=3490 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-01-sso.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-01-sso.txt new file mode 100644 index 0000000..2e68b41 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-01-sso.txt @@ -0,0 +1,29 @@ +=== C-1 주입 1: app1 에 로그인한다 === + app1 로그인 200 +{"pattern":"AP3-backend-for-frontend","principal":"labuser","accessTokenStoredOnServer":true,"refreshTokenStoredOnServer + +=== C-1 주입 3: 같은 창의 새 탭에서 app2 를 연다 (재인증이 뜨는가) === + 도착: https://app2.hyeonworks.com/api/echo + 로그인 폼이 떴나: 0 (0 이면 재인증 없이 통과) + 본문 앞: { "headers" : { "host" : [ "app2.hyeonworks.com" ], "user-agent" : [ "curl/8.5.0 + +=== C-1 주입검증 2: 어느 클라이언트가 붙었나 === +[ { + "offline" : "0", + "clientId" : "bff-confidential", + "active" : "15", + "id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b" +}, { + "offline" : "0", + "clientId" : "oauth2-proxy", + "active" : "2", + "id" : "f7bed9b9-8fb5-4b3d-9253-c574c0b09739" +} ] + +=== C-1 주입검증 3: 저장소에 무엇이 늘었나 === +_oauth2_proxy-9aa2915c715c48b09a8916015a6f3801 +bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23 +bff:session:sessions:b66634a2-1381-4071-8ceb-0a006fbe1327 +_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 +bff:session:sessions:c5f7b0c7-cd17-4749-bf3e-6d42c814bb1c +dbsize=5 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-02-user-session.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-02-user-session.txt new file mode 100644 index 0000000..e15fbd2 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c1-02-user-session.txt @@ -0,0 +1,13 @@ +=== C-1 주입검증 1: user session 과 client session 수 === + YwQBgnWh4dmkiknXlBVdbL6f | keycloak-patterns | 1 + 1O7AtenktEMp_0MFgwFtev8o | keycloak-patterns | 1 + c4GCKTLjyfC52_JjLJuUf2GR | keycloak-patterns | 1 + Ur1NWoD6WiHMBX-oi1DvDrEe | keycloak-patterns | 1 + q2LCg3GRdG8acMlSYRXdJ-XF | keycloak-patterns | 1 + rjpU8hkBoDdgWG6duNzadrgn | keycloak-patterns | 1 + tIbyDHzlcDUxD6Ke65juXHlE | keycloak-patterns | 1 + zOVsxAt2PbCKF2fIOD8_YMNe | keycloak-patterns | 1 + 4i6Q7qb3Y_7Ib4mQzzsmTkzQ | keycloak-patterns | 1 + V0mkutuu-0tNdmvHBfWucB9j | keycloak-patterns | 2 +(16 rows) + diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c2-05-probe-reachability-relive.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c2-05-probe-reachability-relive.txt new file mode 100644 index 0000000..915860a --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/c2-05-probe-reachability-relive.txt @@ -0,0 +1,15 @@ +Server: 10.43.0.10 +Address: 10.43.0.10:53 + + +Name: app1.hyeonworks.com +Address: 192.168.122.10 + +--- HTTPS --- +All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt. +If you don't see a command prompt, try pressing enter. +app1 200 +curl exit=0 +pod "c2probe" deleted from keycloak-lab namespace +=== NetworkPolicy 가 있나 === +No resources found in keycloak-lab namespace. diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-before-force-renewal.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-before-force-renewal.txt new file mode 100644 index 0000000..9c1a863 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-before-force-renewal.txt @@ -0,0 +1,11 @@ +-rw-r--r-- 1 root root 4882 Sep 4 08:22 fullchain2.pem +-rw-r--r-- 1 root root 4882 Sep 4 12:29 fullchain3.pem +-rw------- 1 root root 241 Sep 3 01:47 privkey1.pem +-rw------- 1 root root 241 Sep 4 08:22 privkey2.pem +-rw------- 1 root root 241 Sep 4 12:29 privkey3.pem +--- nginx worker PID --- +2629 4712 +--- 지금 서빙하는 인증서의 notBefore --- +notBefore=Sep 4 11:29:18 2026 GMT +notAfter=Dec 3 11:29:17 2026 GMT +serial=06F3E0EF4D1BB03DE58130EAAD1176101373 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-steps-5-6-7.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-steps-5-6-7.txt new file mode 100644 index 0000000..12834c5 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-01-steps-5-6-7.txt @@ -0,0 +1,34 @@ +=== D-4 5단계: 타이머 === + +0 timers listed. +Pass --all to see loaded but inactive timers, too. +--- 엣지 쪽 +Thu 2026-09-17 17:06:05 UTC 9h left - - certbot.timer certbot.service + +=== D-4 6단계 ① ② ③ === +[Unit] +Description=Certbot +Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html +Documentation=https://certbot.eff.org/docs +[Service] +Type=oneshot +ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew +PrivateTmp=true +--- +[Unit] +Description=Run certbot twice daily +[Timer] +OnCalendar=*-*-* 00,12:00:00 +RandomizedDelaySec=43200 +Persistent=true +[Install] +WantedBy=timers.target +--- +total 0 + +=== D-4 7단계를 문서 그대로: [test-server] 에서 === +(줄 수: 3) + +=== 같은 것을 엣지에서 === + 1065 1 11145 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; + 1106 1065 11135 Thu Sep 17 04:27:49 2026 nginx: worker process diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-02-force-renewal.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-02-force-renewal.txt new file mode 100644 index 0000000..e9aa500 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-02-force-renewal.txt @@ -0,0 +1,16 @@ +certbot exit=0 +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 domains +Hook 'deploy-hook' ran with error output: + nginx: the configuration file /etc/nginx/nginx.conf syntax is ok + nginx: configuration file /etc/nginx/nginx.conf test is successful + 2026/09/17 10:36:31 [notice] 4744#4744: signal process started + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Congratulations, all renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-03-after-force-renewal.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-03-after-force-renewal.txt new file mode 100644 index 0000000..3ebcfaf --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4-03-after-force-renewal.txt @@ -0,0 +1,15 @@ +=== 디스크의 새 파일 === +-rw------- 1 root root 241 Sep 4 08:22 privkey2.pem +-rw------- 1 root root 241 Sep 4 12:29 privkey3.pem +-rw------- 1 root root 241 Sep 17 10:36 privkey4.pem + +=== 지금 서빙하는 인증서 === +notBefore=Sep 17 09:37:58 2026 GMT +notAfter=Dec 16 09:37:57 2026 GMT +serial=065547991777D11A408CEA90D945DDA03DF1 + +=== nginx worker PID (갱신 전 2629 4712) === +4745 4754 +auth 302 verify=0 +app1 200 verify=0 +app2 302 verify=0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-01-steps-5-8.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-01-steps-5-8.txt new file mode 100644 index 0000000..b049919 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-01-steps-5-8.txt @@ -0,0 +1,26 @@ +=== 1단계 출발점 워커 PID (문서대로 test-server) === +(랩 호스트에서는 빈손이다) +--- 엣지에서 + 1065 1 11186 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; + 1106 1065 11177 Thu Sep 17 04:27:49 2026 nginx: worker process + +=== 5단계 훅 파일을 만든다 === +#!/bin/sh +nginx -t && nginx -s reload + +=== 6단계 설치 === +exit=0 + +=== 7단계 제자리에 있나 === +total 4 +-rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh + +=== 8단계 손으로 한 번 돌린다 === +exit=0 +nginx: the configuration file /etc/nginx/nginx.conf syntax is ok +nginx: configuration file /etc/nginx/nginx.conf test is successful +2026/09/17 07:34:08 [notice] 2361#2361: signal process started + +=== 8단계 뒤 워커 PID 재측정 === + 1065 1 11189 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; + 2362 1065 0 Thu Sep 17 07:34:08 2026 nginx: worker process diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-02-dryrun-with-cert.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-02-dryrun-with-cert.txt new file mode 100644 index 0000000..ab9f6ae --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-02-dryrun-with-cert.txt @@ -0,0 +1,19 @@ +=== 엣지에 인증서가 생긴 뒤 dry-run 이 무엇을 하나 === +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Failed to renew certificate auth.hyeonworks.com with error: You should register before running non-interactively, or provide --agree-tos and --email <email_address> flags. + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +1 renew failure(s), 0 parse failure(s) +Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details. +exit=0 + +=== 엣지에 자격증명 파일이 있나 === +ls: cannot access '/etc/letsencrypt/cloudflare.ini': No such file or directory +1 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-03-dryrun-complete.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-03-dryrun-complete.txt new file mode 100644 index 0000000..ba345b3 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-03-dryrun-complete.txt @@ -0,0 +1,15 @@ +certbot exit=1 + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Failed to renew certificate auth.hyeonworks.com with error: Missing command line flag or config entry for this setting: +Please choose an account +Choices: ['test-server@2026-09-03T01:50:44Z (66d5)', 'kc-lab-edge@2026-09-17T08:16:31Z (5df4)'] + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +1 renew failure(s), 0 parse failure(s) +Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details. diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-04-two-accounts.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-04-two-accounts.txt new file mode 100644 index 0000000..cd518a5 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-04-two-accounts.txt @@ -0,0 +1,6 @@ +=== 엣지의 ACME 계정이 왜 둘인가 === +* +stat: cannot statx '/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/*/': No such file or directory + +=== renewal 설정이 계정을 지목하나 === +8:account = 8d53f9312e2a4c9cdd13620122cd8272 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-05-account-ids.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-05-account-ids.txt new file mode 100644 index 0000000..dd2bc2c --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-05-account-ids.txt @@ -0,0 +1,10 @@ +=== 운영 디렉터리의 계정들 === +stat: cannot statx '$d': No such file or directory +/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272/ + +=== renewal 이 지목한 계정이 그중에 있나 === +/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272 + +=== 각 계정의 regr.json 이 누구인가 === +/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272/regr.json +acct/3702274776 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-06-staging-accounts.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-06-staging-accounts.txt new file mode 100644 index 0000000..6112eb5 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-06-staging-accounts.txt @@ -0,0 +1,6 @@ +=== staging 디렉터리의 계정들 === +5df435a72a89118f1699212efd5fe2fe +66d5d86599378a0b07936733587017a6 + +=== 운영 디렉터리의 계정 수 === +1 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-after-removing-duplicate-staging-account.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-after-removing-duplicate-staging-account.txt new file mode 100644 index 0000000..f04ef6b --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-after-removing-duplicate-staging-account.txt @@ -0,0 +1,13 @@ +certbot exit=0 +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains +Waiting 30 seconds for DNS changes to propagate + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Congratulations, all simulated renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-final.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-07-dryrun-final.txt new file mode 100644 index 0000000000000000000000000000000000000000..4a91fcad583e22203aca3c2a2eb5362bb9b7ae1c GIT binary patch literal 712 zcmb`Eu}%Xq42GHW6dpn1l8PcBRbr^h%+i55<a~EgllT<-TCPt|E(OHEMw20mW&8g> z+hLY>62K2H&nvY}Bm-vfiNz*|KppJ9QapnV8Erd5o>KCu*J{fL=qSU0VTAHB+U$lJ zVhWRZN~KC*5E*%6jH4K*<Ym?Yg)zB2kD@8}8uo}#v>{O6`G8;~j>cWIn-XlE&PNv? z&uddYi&n;_`1rAR@-jektlE(>LW^R3V6ATcefB<1!Ae0cstHdvEQ7IW85`H|V$^ru zS;6YSPBQ3WMoK5j2i66=<hSqu7jG18nX!7azLu|7Wps{G%eDIDbq{W^sYeDO7&DEw zFRhho;Hp>cDD~M&r7OF-FDnkf7hG(=X&HEO4za~{0}8;PIP+_RCio+oKbTE7g-}R* P(j!K=t!<d^uFmlZpjF6C literal 0 HcmV?d00001 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-09-dryrun-with-deploy-hook.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-09-dryrun-with-deploy-hook.txt new file mode 100644 index 0000000..f04ef6b --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-09-dryrun-with-deploy-hook.txt @@ -0,0 +1,13 @@ +certbot exit=0 +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains +Waiting 30 seconds for DNS changes to propagate + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Congratulations, all simulated renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-10-dryrun-dns-propagation-flake.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-10-dryrun-dns-propagation-flake.txt new file mode 100644 index 0000000..b842ca9 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-10-dryrun-dns-propagation-flake.txt @@ -0,0 +1,23 @@ +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains +Waiting 30 seconds for DNS changes to propagate + +Certbot failed to authenticate some domains (authenticator: dns-cloudflare). The Certificate Authority reported these problems: + Domain: auth.hyeonworks.com + Type: dns + Detail: During secondary validation: DNS problem: NXDOMAIN looking up TXT for _acme-challenge.auth.hyeonworks.com - check that a DNS record exists for this domain + +Hint: The Certificate Authority failed to verify the DNS TXT records created by --dns-cloudflare. Ensure the above domains are hosted by this DNS provider, or try increasing --dns-cloudflare-propagation-seconds (currently 30 seconds). + +Failed to renew certificate auth.hyeonworks.com with error: Some challenges have failed. + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +1 renew failure(s), 0 parse failure(s) +Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details. diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-11-deploy-hook-not-called-in-dry-run.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-11-deploy-hook-not-called-in-dry-run.txt new file mode 100644 index 0000000..013118c --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/d4a-11-deploy-hook-not-called-in-dry-run.txt @@ -0,0 +1,9 @@ +=== 훅 파일 === +total 4 +-rwxr-xr-x 1 root root 38 Sep 17 09:23 reload-nginx.sh +=== 성공한 dry-run 출력에 deploy-hook 이 몇 번 === +0 +=== certbot 판 === +certbot 2.1.0 +=== --run-deploy-hooks 플래그가 있나 === +0 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-01-from-outside.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-01-from-outside.txt new file mode 100644 index 0000000..071bb61 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-01-from-outside.txt @@ -0,0 +1,21 @@ +=== 밖에서 도메인으로 — 앞서 Host 헤더로 대신했던 것을 진짜 경로로 다시 잰다 === +--- B-4 주입 (문서의 명령에서 https 만 http 로) --- +대조군 : (비면 정상) +동명 헤더 둘 : "x-auth-request-roles" : [ "admin", "editor" ] +값 안의 쉼표 : "x-auth-request-roles" : [ "role-with,comma" ] +신원 위조 : +"x-auth-request-email" : [ "attacker@evil.invalid" ] +"x-auth-request-user" : [ "attacker" ] +--- x-forwarded-* 가 밖에서 오면 무엇이 되나 --- +"x-forwarded-host" : [ "app1.hyeonworks.com" ] +"x-forwarded-port" : [ "80" ] +"x-forwarded-proto" : [ "http" ] +"x-forwarded-server" : [ "traefik-59b7647586-t8fc7" ] +"x-real-ip" : [ "10.42.0.1" ] + +--- C-2 후보 셋 (밖에서) --- +/logout/connect/back-channel/keycloak 302 +/backchannel-logout 302 +/oauth2/sign_out 302 +--- app2 (Grafana) 도 닿나 --- +app2 302 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-02-forwarded-for.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-02-forwarded-for.txt new file mode 100644 index 0000000..4cc74ee --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-02-forwarded-for.txt @@ -0,0 +1,41 @@ +=== 엣지 nginx 는 무엇을 설정하나 === + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Port 80; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + +=== 그런데 앱에 도착하는 것 === +"x-forwarded-host" : [ "app1.hyeonworks.com" ] +"x-forwarded-port" : [ "80" ] +"x-forwarded-proto" : [ "http" ] +"x-forwarded-server" : [ "traefik-59b7647586-t8fc7" ] +"x-real-ip" : [ "10.42.1.0" ] + +=== 내 주소는 무엇인가 === + inet 100.83.212.4/32 scope global tailscale0 + +=== Traefik 이 신뢰하는 출발지 설정이 있나 === +(위가 비면 기본값) + +=== 저장소에 그 설정 파일이 있다 — 적용돼 있나 === +# Make Traefik trust the X-Forwarded-* headers that the host nginx sets. +# +# Without this, Traefik rewrites every forwarded header from its own connection, +# which is plain HTTP on port 80. The application then sees scheme=http even +# though the browser connected over TLS. See docs/two-hop-proxy-header-contract.md. +# +# k3s installs Traefik through its bundled HelmChart, so values are overridden +# with a HelmChartConfig rather than by editing the deployment. k3s reconciles +# the chart and recreates the Traefik pod. +# +# kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml +# kubectl -n kube-system rollout status deploy/traefik --timeout=180s +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + ports: diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-03-traefik-forwarded.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-03-traefik-forwarded.txt new file mode 100644 index 0000000..e4b7e2a --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-03-traefik-forwarded.txt @@ -0,0 +1,43 @@ +=== 적용 전 — 앱이 보는 것 === +"x-forwarded-host" : [ "app1.hyeonworks.com" ] +"x-forwarded-port" : [ "80" ] +"x-forwarded-proto" : [ "http" ] +"x-forwarded-server" : [ "traefik-59b7647586-t8fc7" ] +"x-real-ip" : [ "10.42.0.1" ] +(x-forwarded-for 가 없다. x-real-ip 는 flannel 게이트웨이다) + +=== 저장소의 설정 전문 === +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + ports: + web: + forwardedHeaders: + # Requests arriving from these sources keep their existing + # X-Forwarded-* values instead of having them rewritten. + # + # 10.42.0.0/16 is the pod CIDR. It is required because the traefik + # Service uses externalTrafficPolicy: Cluster, so svclb SNATs the + # traffic and Traefik sees a pod-network address rather than the + # host nginx address. + # + # The node/host range is deliberately absent. Because svclb SNATs, + # the host nginx address never reaches Traefik — measured, not assumed. + # Trusting a range that cannot appear only widens the surface. + # + # Trusting the whole pod CIDR still means any pod in the cluster could + # forge these headers, which is why echo-network-policy.yaml restricts + # who may reach the application at all. + trustedIPs: + - 10.42.0.0/16 + websecure: + +=== 적용한다 (파일 주석이 적어 둔 두 줄) === +helmchartconfig.helm.cattle.io/traefik created +Waiting for deployment "traefik" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "traefik" rollout to finish: 1 old replicas are pending termination... +deployment "traefik" successfully rolled out diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-04-traefik-after.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-04-traefik-after.txt new file mode 100644 index 0000000..e32180a --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-04-traefik-after.txt @@ -0,0 +1,10 @@ +=== 적용 뒤 — 앱이 보는 것 === +"x-forwarded-host" : [ "app1.hyeonworks.com" ] +"x-forwarded-port" : [ "80" ] +"x-forwarded-proto" : [ "http" ] +"x-forwarded-server" : [ "traefik-5d6fcf895-n8p4k" ] +"x-real-ip" : [ "192.168.122.1" ] + +=== 내 tailnet 주소와 견준다 === +"remoteAddr" : "192.168.122.1" +호스트의 tailscale0 = 100.83.212.4 / 엣지 게스트 = 192.168.122.10 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-05-trustedips-test.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-05-trustedips-test.txt new file mode 100644 index 0000000..4946826 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-05-trustedips-test.txt @@ -0,0 +1,12 @@ +=== 가설: Traefik 이 보는 출발지가 trustedIPs 밖이다 === +앱이 본 remoteAddr = 192.168.122.1 (호스트의 virbr0) +설정의 trustedIPs = 10.42.0.0/16 (파드 대역) + +=== 게스트 대역을 더해서 시험한다 (저장소 파일은 안 고친다) === +helmchartconfig.helm.cattle.io/traefik configured +deployment "traefik" successfully rolled out + +=== 다시 잰다 === +"x-forwarded-proto" : [ "http" ] +"x-real-ip" : [ "192.168.122.1" ] +"remoteAddr" : "192.168.122.1" diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-06-cert-check.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-06-cert-check.txt new file mode 100644 index 0000000..4e5166f --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-06-cert-check.txt @@ -0,0 +1,18 @@ +=== kc-lab-edge 의 인증서 === +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +No certificates found. +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +--- +ls: cannot access '/etc/letsencrypt/live/': No such file or directory + +=== 엣지 nginx 가 443 을 듣나 === +LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1106,fd=5),("nginx",pid=1065,fd=5)) +--- +keycloak-lab + +=== 밖에서 === +https://auth.hyeonworks.com/realms/master 000 +https://app1.hyeonworks.com/ 000 +http://auth.hyeonworks.com/realms/master 200 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-07-cert-preconditions.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-07-cert-preconditions.txt new file mode 100644 index 0000000..afbc07b --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-07-cert-preconditions.txt @@ -0,0 +1,8 @@ +=== 04번 1단계: 플러그인이 깔렸나 === +* dns-cloudflare +* standalone +* webroot + +=== 04번 3단계: 자격증명 파일이 있나 (값은 안 본다) === +ls: cannot access '/etc/letsencrypt/cloudflare.ini': No such file or directory +wc: /etc/letsencrypt/cloudflare.ini: No such file or directory diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-08-http-only-check.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-08-http-only-check.txt new file mode 100644 index 0000000..e7f08e9 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-08-http-only-check.txt @@ -0,0 +1,19 @@ +=== 엣지 nginx 가 X-Forwarded-For 를 세우나 === +8: listen 80 default_server; +9: server_name _; +12: proxy_pass http://k3s_traefik; +14: proxy_set_header Host $host; +15: proxy_set_header X-Forwarded-Host $host; +16: proxy_set_header X-Forwarded-Proto http; +17: proxy_set_header X-Forwarded-Port 80; +18: proxy_set_header X-Forwarded-For $remote_addr; +19: proxy_set_header X-Real-IP $remote_addr; + +=== app1 은 엣지를 거치나 — 엣지 access log 마지막 3줄 === +192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0" +192.168.122.1 - - [17/Sep/2026:07:11:33 +0000] "GET /realms/master HTTP/1.1" 200 602 "-" "curl/8.5.0" +192.168.122.1 - - [17/Sep/2026:07:12:01 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0" + +=== http 로 토큰 엔드포인트가 도나 (이게 되면 b·c 편은 TLS 없이 돈다) === +well-known 200 +token 401 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-09-echo-full.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-09-echo-full.txt new file mode 100644 index 0000000..fd7dc66 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-09-echo-full.txt @@ -0,0 +1,21 @@ +=== 앱이 실제로 받은 헤더 전부 (trustedIPs 에 게스트 대역을 더한 상태) === +{ + "headers" : { + "host" : [ "app1.hyeonworks.com" ], + "user-agent" : [ "curl/8.5.0" ], + "accept" : [ "*/*" ], + "x-forwarded-host" : [ "app1.hyeonworks.com" ], + "x-forwarded-port" : [ "80" ], + "x-forwarded-proto" : [ "http" ], + "x-forwarded-server" : [ "traefik-697889c85-jqcjn" ], + "x-real-ip" : [ "192.168.122.1" ], + "accept-encoding" : [ "gzip" ] + }, + "remoteAddr" : "192.168.122.1", + "localAddr" : "10.42.0.27", + "scheme" : "http", + "secure" : false, + "serverName" : "app1.hyeonworks.com", + "serverPort" : 80, + "requestUrl" : "http://app1.hyeonworks.com/api/echo" +} \ No newline at end of file diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-10-restore-and-source.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-10-restore-and-source.txt new file mode 100644 index 0000000..b42b2d7 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-10-restore-and-source.txt @@ -0,0 +1,6 @@ +=== 저장소 판으로 되돌린다 === +helmchartconfig.helm.cattle.io/traefik configured +deployment "traefik" successfully rolled out + +=== 호스트에서 출발지를 바꾸는 규칙이 있나 === +--- libvirt diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-11-after-restore.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-11-after-restore.txt new file mode 100644 index 0000000..7b607ae --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-11-after-restore.txt @@ -0,0 +1,46 @@ +=== 저장소 판만 적용한 상태에서 다시 잰다 === +ports: + web: + forwardedHeaders: + # Requests arriving from these sources keep their existing + # X-Forwarded-* values instead of having them rewritten. + # + # 10.42.0.0/16 is the pod CIDR. It is required because the traefik + # Service uses externalTrafficPolicy: Cluster, so svclb SNATs the + # traffic and Traefik sees a pod-network address rather than the + # host nginx address. + # + # The node/host range is deliberately absent. Because svclb SNATs, + # the host nginx address never reaches Traefik — measured, not assumed. + # Trusting a range that cannot appear only widens the surface. + # + # Trusting the whole pod CIDR still means any pod in the cluster could + # forge these headers, which is why echo-network-policy.yaml restricts + # who may reach the application at all. + trustedIPs: + - 10.42.0.0/16 + websecure: + forwardedHeaders: + trustedIPs: + - 10.42.0.0/16 +--- 앱이 받은 것 +{ + "headers" : { + "host" : [ "app1.hyeonworks.com" ], + "user-agent" : [ "curl/8.5.0" ], + "accept" : [ "*/*" ], + "x-forwarded-host" : [ "app1.hyeonworks.com" ], + "x-forwarded-port" : [ "80" ], + "x-forwarded-proto" : [ "http" ], + "x-forwarded-server" : [ "traefik-5d6fcf895-xmqp6" ], + "x-real-ip" : [ "192.168.122.1" ], + "accept-encoding" : [ "gzip" ] + }, + "remoteAddr" : "192.168.122.1", + "localAddr" : "10.42.0.27", + "scheme" : "http", + "secure" : false, + "serverName" : "app1.hyeonworks.com", + "serverPort" : 80, + "requestUrl" : "http://app1.hyeonworks.com/api/echo" +} \ No newline at end of file diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-12-who-masquerades.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-12-who-masquerades.txt new file mode 100644 index 0000000..3545468 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-12-who-masquerades.txt @@ -0,0 +1,24 @@ +=== 이 curl 은 어디서 어디로 가나 === +100.83.212.4 app1.hyeonworks.com +100.83.212.4 auth.hyeonworks.com + +=== 호스트의 DNAT 파일 (sudo 없이 읽히나) === +#!/usr/sbin/nft -f +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 + } + + 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 + } +} + +=== lab_edge 테이블 실물 === +sudo: a password is required +exit=1 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-13-http-login-chain.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-13-http-login-chain.txt new file mode 100644 index 0000000..cc75da2 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-13-http-login-chain.txt @@ -0,0 +1,7 @@ +=== http 로 로그인 체인이 도나 === +oauth2 진입 302 -> https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=Vt28FHS-cuqr7NcdUaR6xjPOs2zreT0crThNPaGjDuQ%3D&redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=s14SFB1opH_jb0_IQKSx65X6FrtD4-8svAS8qfTQQwU&code_challenge=iv0wxqu3JsnSsT-f07hRh1UCwei4WHBZiDNGw-TATvA&code_challenge_method=S256 + +=== 그 리다이렉트를 따라가 본다 (3홉) === +final 302 https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=ATjUt4POkZvWTWfeYJHZxH4uYbJGdIcMDjw0yYmXTW8%3D&redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=R7GTKPxRn-hNvjvcsYTH_Zpw32wrlD7h376XQ1d5I2I&code_challenge=C9PP9r8Oze2mzwC6AusVE19CKx6-xHoQ1fxZwHO7PRM&code_challenge_method=S256 + +=== 등록된 redirectUris === diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-14-incluster-name-resolution.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-14-incluster-name-resolution.txt new file mode 100644 index 0000000..ba0622b --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-14-incluster-name-resolution.txt @@ -0,0 +1,43 @@ +=== 파드 안에서 공개 이름이 무엇으로 풀리나 === +100.83.212.4 auth.hyeonworks.com +100.83.212.4 app1.hyeonworks.com + +=== 파드에서 그 주소의 80·443 에 닿나 === +100.83.212.4:80 닫힘 +100.83.212.4:443 닫힘 +192.168.122.10(엣지):80 열림 +192.168.122.10(엣지):443 닫힘 + +=== CoreDNS 에 이 이름을 돌리는 설정이 있나 === +# File managed by k3s. DO NOT EDIT. +# See: https://docs.k3s.io/advanced#coredns-custom-configuration-imports +.:53 { + errors + health + ready + kubernetes cluster.local in-addr.arpa ip6.arpa { + pods insecure + fallthrough in-addr.arpa ip6.arpa + } + hosts /etc/coredns/NodeHosts { + ttl 60 + reload 15s + fallthrough + } + prometheus :9153 + cache 30 + loop + reload + loadbalance + import /etc/coredns/custom/*.override + forward . /etc/resolv.conf +} +import /etc/coredns/custom/*.server + +--- NodeHosts +192.168.122.11 kc-lab-1 +192.168.122.12 kc-lab-2 + + +=== 노드의 /etc/hosts 에 있나 === +(없음) diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-15-coredns-override.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-15-coredns-override.txt new file mode 100644 index 0000000..189b795 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-15-coredns-override.txt @@ -0,0 +1,11 @@ +=== CoreDNS 에 엣지로 보내는 항목을 더한다 (k3s 의 custom override) === +configmap/coredns-custom created +deployment.apps/coredns restarted +Waiting for deployment "coredns" rollout to finish: 0 of 1 updated replicas are available... +error: timed out waiting for the condition + +=== 파드에서 다시 === +80 닫힘 + +=== echo 를 다시 띄워 JWKS 를 받게 해 본다 (여전히 https 라 실패해야 정상) === +deployment "echo" successfully rolled out diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-16-coredns-fix.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-16-coredns-fix.txt new file mode 100644 index 0000000..9e2034b --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-16-coredns-fix.txt @@ -0,0 +1,14 @@ +=== 실패 원인 === +plugin/hosts: this plugin can only be used once per Server Block +k3s Corefile 이 이미 hosts /etc/coredns/NodeHosts 를 쓴다. override 는 같은 블록에 들어간다. + +=== 별도 서버 블록(.server)으로 고쳐 넣는다 === +configmap/coredns-custom configured +coredns-747f7ff557-c8f9q 1/1 Running 0 30s +--- 로그 +.:53 +hyeonworks.com.:53 +[WARNING] No files matching import glob pattern: /etc/coredns/custom/*.override +[INFO] plugin/reload: Running configuration SHA512 = bbef792a18068d14383e2aae31d25d75edca67dfea52ee28e28117264f318981634c941f3626af4eed0f5f0d9866ddac82f13668a8f19b5a353fae28e695f24d +CoreDNS-1.14.6 +linux/amd64, go1.26.5, 424d125 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-17-incluster-after-fix.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-17-incluster-after-fix.txt new file mode 100644 index 0000000..3697628 --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/ext-17-incluster-after-fix.txt @@ -0,0 +1,9 @@ +=== 파드에서 다시 푼다 === +192.168.122.10 auth.hyeonworks.com +192.168.122.10 app1.hyeonworks.com +192.168.122.10:80 열림 +192.168.122.10:443 닫힘 + +=== 클러스터 안에서 realm 을 받아 본다 (http) === +sh: line 1: curl: command not found +command terminated with exit code 127 diff --git a/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/misc-01-unknowns.txt b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/misc-01-unknowns.txt new file mode 100644 index 0000000..57a0eab --- /dev/null +++ b/docs/keycloak-session-store/final/evidence/raw/relive-2026-09-17/misc-01-unknowns.txt @@ -0,0 +1,14 @@ +=== B-7 6단계 ② 두 cookie secret 의 길이만 (값은 안 본다) === +32 +32 + +=== B-7a 6단계 R 함수와 스캔 루프 === +(위가 비면 세션 키가 없는 것이다) dbsize=3 + +=== D-4a 8단계 dry-run 으로 훅 호출만 본다 === +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +No simulated renewals were attempted. +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +exit=0 diff --git a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a2-database-loss.md b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a2-database-loss.md index 75c4c5b..fe583e1 100644 --- a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a2-database-loss.md +++ b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a2-database-loss.md @@ -57,6 +57,8 @@ PostgreSQL 을 정상 종료시키고 refresh 와 새 로그인과 관리 API | 정지 구간 | 1분 남짓. 그동안 정문이 실제로 `503` 이 된다 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 A-1 에서 룩어사이드 캐시는 읽을 때 데이터베이스와 대조하지 않는다는 것을 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 `200` 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다. @@ -294,7 +296,8 @@ curl -s -o /dev/null -w 'well-known %{http_code}\n' \ 밖에서 정문도 재 둔다. ```bash label="[lab host] 밖에서 본 정문" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ## 주입 @@ -518,8 +521,9 @@ kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints 밖에서 본다. 한 번 눈으로 볼 때는 헤더까지 본다. ```bash label="[lab host] 정문을 코드로 한 번, 헤더로 한 번" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master -curl -I https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` 실측은 `https://auth.hyeonworks.com/realms/master HTTP 503` 이다(observed). 이 `503` 은 Keycloak 이 준 것이 아니다. Ready 인 백엔드가 하나도 없어서 그 앞의 프록시가 준 것이고, `200` 이던 JWKS 도 정문으로는 닿지 않는다. @@ -640,7 +644,8 @@ deployment "postgres" successfully rolled out ```bash label="[lab host] ① Ready 와 정문을 함께 친다" kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \ | grep keycloak -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```bash label="[lab host] ② 재시작 횟수만 따로 센다" diff --git a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a4-node-loss.md b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a4-node-loss.md index ea7c23d..f858d76 100644 --- a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a4-node-loss.md +++ b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a4-node-loss.md @@ -56,6 +56,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af | 걸리는 시간 | 전 구간 약 40분. 4a 의 축출을 보는 데만 7분 | | 되돌리기 | `virsh start`. `--grace-period=0 --force` 로 파드를 지우지 않는다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 A-1 과 A-5 는 네트워크만 끊는다. 파드는 살아 있고 쿠버네티스는 계속 정확한 상태를 안다. 여기서는 기계 자체를 없애므로 상태를 보고할 주체가 사라진다. @@ -197,11 +199,13 @@ kubectl get pv $(kubectl -n keycloak-lab get pvc postgres-data \ **무엇을 확인하는가** — 정문이 지금 무엇을 답하는지, 그리고 Prometheus 와 Grafana 가 어느 노드에 있는지. ```bash label="[터미널 C] ① 응답을 통째로 읽는 형태" -curl -I --max-time 8 https://auth.hyeonworks.com/realms/master +curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```bash label="[터미널 C] ② 여러 번 비교할 것이므로 코드만 뽑는 형태" -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```bash label="[터미널 B] ③ 관측 스택이 어느 노드에 있나" @@ -323,7 +327,8 @@ kubectl get node kc-lab-2 ``` ```bash label="[터미널 C] ② 같은 간격으로 밖에서" -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` 손이 아프면 한 줄로 묶는 형태가 있고, 그 루프는 미검증이다. `Ctrl-C` 로 멈춘다. @@ -333,7 +338,7 @@ while true; do printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \ "$(kubectl get node kc-lab-2 --no-headers | awk '{print $2}')" \ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 15 done ``` @@ -610,13 +615,33 @@ ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containe ### 9. 4b — 밖에서는 두 주소를 함께 본다 ```bash label="[터미널 C] ① 20초 간격으로, 인증 서버" -curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```bash label="[터미널 C] ② 같은 간격으로, Grafana" curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/ ``` +**★ ② 는 이 배치에서 `000` 밖에 못 낸다 — 노드가 살아 있어도 그렇다**(2026-09-17, observed). 원래 실행은 여기서 `502` 와 `000` 이 갈렸는데 지금은 처음부터 `000` 이라 판정에 못 쓴다. 이유가 이름 해석이 아니라 **인증서**다. + +```bash label="[lab host] 왜 000 인지 세 단계로 가른다" +curl -s -o /dev/null --max-time 8 --resolve grafana.hyeonworks.com:443:192.168.122.10 https://grafana.hyeonworks.com/ ; echo "curl exit=$?" +curl -sk -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 --resolve grafana.hyeonworks.com:443:192.168.122.10 https://grafana.hyeonworks.com/ +echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -ext subjectAltName +``` + +```text label="이 실험대의 값" +curl exit=60 +grafana=404 +X509v3 Subject Alternative Name: + DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com +``` + +`curl exit=60` 은 인증서 검증 실패다. 엣지의 인증서가 덮는 이름은 셋뿐이라 `grafana.hyeonworks.com` 은 TLS 단계에서 끝나고 HTTP 는 시작도 안 한다. `-k` 로 검증을 끄면 그제서야 nginx 가 답하는데 `404` 다 — 그 이름으로 갈 곳이 없다. 가이드 06 도 **Grafana 는 밖에 열지 않고 `port-forward svc/grafana 3000:3000` 으로 본다**고 적어 둔다. + +**그래서 ② 는 이 절의 신호가 못 된다.** 판정은 `auth` 쪽 ① 로 한다. 관측 스택이 살아 있는지를 밖에서 보려면 `port-forward` 를 띄운 터미널이 끊기는지로 보거나, 04 의 인증서에 이름을 더하고 엣지에 서버 블록을 올려야 한다(unknown — 이 실험대는 안 했다). + ```text +20초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp +60초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp @@ -656,7 +681,8 @@ kubectl -n keycloak-lab get pods ``` ```bash label="[터미널 C] ⑤ 같은 간격으로 밖에서" -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **예상 결과** diff --git a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a5-asymmetric-partition.md b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a5-asymmetric-partition.md index 9851503..577fb9d 100644 --- a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a5-asymmetric-partition.md +++ b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a5-asymmetric-partition.md @@ -59,6 +59,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af | 걸리는 시간 | 전 구간 약 30분 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 A-1 이 답하지 못하고 넘긴 물음에서 출발한다. 가이드는 그 물음을 그대로 인용한다. @@ -276,11 +278,13 @@ ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800 **무엇을 확인하는가** — 정문이 지금 무엇을 답하는지. ```bash label="[lab host] ① 응답을 통째로 읽는 형태" -curl -I --max-time 8 https://auth.hyeonworks.com/realms/master +curl -I --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```bash label="[lab host] ② 여러 번 비교할 것이므로 코드만 뽑는 형태" -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **출력에서 답이 되는 것** — `200` 이어야 한다. @@ -584,7 +588,8 @@ kubectl -n keycloak-lab get pods | grep keycloak ``` ```bash label="[lab host] ② 같은 간격으로 밖에서" -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```text @@ -690,7 +695,8 @@ kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ``` ```bash label="[lab host] ③ 같은 간격으로 밖에서" -curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```text diff --git a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a6-latency-injection.md b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a6-latency-injection.md index 7c33614..64a87f0 100644 --- a/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a6-latency-injection.md +++ b/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a6-latency-injection.md @@ -263,6 +263,30 @@ agroal_creation_time_total_milliseconds agroal_destroy_count_total ``` +**★ 같은 줄을 2026-09-17 에 쳤더니 모양이 달랐다**(observed). `grep` 은 이름만 남기지 않는다. 지표 한 줄을 통째로 내보내므로 레이블과 값이 함께 나오고, 순서도 알파벳순이 아니다. 열일곱 줄이 나왔고 그 안에 `agroal_max_used_count` 가 이미 있었다. + +```text +agroal_invalid_count_total{datasource="default"} 0.0 +agroal_flush_count_total{datasource="default"} 0.0 +agroal_leak_detection_count_total{datasource="default"} 0.0 +agroal_reap_count_total{datasource="default"} 3.0 +agroal_blocking_time_max_milliseconds{datasource="default"} 112.0 +agroal_max_used_count{datasource="default"} 3.0 +agroal_acquire_count_total{datasource="default"} 1908.0 +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +agroal_blocking_time_average_milliseconds{datasource="default"} 0.0 +agroal_creation_time_total_milliseconds{datasource="default"} 160.0 +agroal_creation_time_average_milliseconds{datasource="default"} 32.0 +agroal_destroy_count_total{datasource="default"} 3.0 +agroal_available_count{datasource="default"} 2.0 +agroal_creation_count_total{datasource="default"} 5.0 +agroal_creation_time_max_milliseconds{datasource="default"} 105.0 +agroal_blocking_time_total_milliseconds{datasource="default"} 285.0 +``` + +이름만 늘어놓고 보려면 `| awk '{print $1}' | sort` 를 붙인다. 위의 열두 줄이 이름뿐인 것은 원 실행이 그렇게 다듬어 남겼기 때문이다(inferred). + **출력에서 답이 되는 것** — 열두 줄 가운데 뒤에서 쓰는 넷이다. | 지표 | 무엇을 말하는가 | @@ -663,6 +687,21 @@ kubectl -n keycloak-lab exec a6-probe -- sh -c \ agroal_available_count 19.0 ``` +**★ 이 정규식은 `blocking_time_total` 도 함께 잡는다**(2026-09-17, observed). 같은 줄을 쳤더니 여덟 줄이 나왔다. 위 일곱 줄에 `agroal_blocking_time_total_milliseconds` 가 하나 더 붙고, 지표마다 `{datasource="default"}` 가 달려 있다. + +```text +agroal_blocking_time_max_milliseconds{datasource="default"} 20000.0 +agroal_max_used_count{datasource="default"} 18.0 +agroal_acquire_count_total{datasource="default"} 2020.0 +agroal_active_count{datasource="default"} 0.0 +agroal_awaiting_count{datasource="default"} 0.0 +agroal_blocking_time_average_milliseconds{datasource="default"} 114.0 +agroal_available_count{datasource="default"} 18.0 +agroal_blocking_time_total_milliseconds{datasource="default"} 230680.0 +``` + +`blocking_time_max` 는 여기서도 `20000.0` 이었다. + | 값 | 읽는 법 | |---|---| | `blocking_time_max 20000.0` | 커넥션을 받으려고 20초를 기다린 요청이 있었다 | @@ -751,6 +790,8 @@ kubectl -n keycloak-lab logs keycloak-1 --since=20m \ 관련 로그 줄수: 0 ``` +**★ 그 줄을 2026-09-17 에 쳤다**(observed). 출력은 `0` 이고 종료 코드는 `1` 이다 — `grep -c` 는 센 값이 0 이면 1 로 끝난다. 뒤에 `&&` 로 다른 명령을 이어 붙이면 그 명령이 안 돈다. + 하나도 없었고 까닭이 명확하다. ```text @@ -827,7 +868,33 @@ kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found | Service | `kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 | | 풀 | `agroal_awaiting_count` · `agroal_active_count` | `0` | | 탐침 파드 | `kubectl -n keycloak-lab get pod a6-probe` | 지웠으면 `NotFound` | -| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | +| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master` | `200` | + +**★ 이 표의 두 줄은 이 실험대에서 답이 달랐다**(2026-09-17, observed). + +`enp1s0` 쪽은 비어 있지 않다. 이 게스트의 기본 qdisc 가 `fq_codel` 이라 아무것도 안 걸었을 때도 한 줄이 나온다. 잔재가 없다는 것은 그 줄에 `netem` 이나 `prio` 가 안 보인다는 뜻이다. + +```text +qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +``` + +마지막 줄에 `--resolve` 가 붙어 있는 까닭이다. 원 가이드는 이름만 쳤는데 `auth.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 그 형태는 `000` 을 찍고 `curl` 은 `7` 로 끝난다. `kc-lab-edge` 에서 쳐도 같은 값이다. + +```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 000 이다" +curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +``` + +```text +curl: (7) Failed to connect to auth.hyeonworks.com:443 after 3 ms: Could not connect to server +000 +``` + +엣지 주소를 짚어 주면 `200` 이 온다. 이름만 친 `000` 은 Keycloak 이 아니라 이름이 가리키는 곳의 문제라 원상복구 판정에는 쓸 수 없다. 지연 주입을 되돌리는 절이라 응답이 늦을 수 있어 시간을 넉넉히 준 형태로 친다. + +```bash label="[lab host] 표의 마지막 줄 — 시간을 넉넉히 준 형태" +curl -s -o /dev/null -w '%{http_code}\n' --max-time 20 \ + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master +``` ## 막히면 @@ -854,7 +921,8 @@ kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found ## 무엇이 관측이고 무엇이 아닌가 - (observed) 파드와 postgres 의 노드 배치, 주입 전 평균 `70 ms` 와 `66 ms`, `Cannot find device "eth0"` 네 줄 사이에 낀 `적용완료` 와 주입 시각 `13:14:55`, 그 상태의 「검증」 값 `43 ms` 와 `47 ms`, `ip -brief link` 의 `flannel.1` 과 `cni0`, 넣은 직후의 `Sent 0 bytes 0 pkt` 와 부하 뒤의 `Sent 18388 bytes 150 pkt`, 주입 뒤 `41 ms` 와 `1872 ms`, 동시 20건의 스무 줄 전부와 `22.230871` 까지의 계단, `blocking_time_max 20000.0` 과 `max_used_count 19.0` 과 `acquire_count_total 672.0` 과 `blocking_time_average 281.0`, 이벤트 세 줄과 `89s`·`32m`·`52m`, 낙관적 락 로그 `0`, 해제 뒤 `noqueue` 와 `43 ms` 와 `51 ms`, `agroal_*` 지표 이름 열두 개. -- (unknown) `enp1s0` 과 `flannel.1` 에 각각 거는 `tcpdump` 두 줄, `tc filter show`, 낙관적 락 로그를 세는 `grep -icE` 줄. 가이드가 셋 다 미검증으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 두 단계 형태도 이 실험대에서 치지 않았다. +- (unknown) `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 두 단계 형태. 이 실험대에서 치지 않았다. 가이드가 미검증으로 표시한 넷 — `tcpdump` 두 줄과 `tc filter show` 와 낙관적 락 로그를 세는 `grep -icE` — 은 2026-09-17 에 전부 쳤다. +- (observed, 2026-09-17) 관찰 다섯 절과 복구를 다시 밟은 값 — 주입 전 `44 ms` 와 `40 ms`, 주입 뒤 `1865 ms` 와 `40 ms`, `connect 0.000552 ttfb 1.864861`, 동시 20건이 `1.871238` 에서 `22.245645` 까지 스무 줄 전부 `200`, `blocking_time_max 20000.0` 과 `max_used_count 18.0`, readiness 프로브가 `context deadline exceeded` 로 한 줄, 낙관적 락 로그 `0`, 해제 뒤 `noqueue` 와 `42 ms` 와 `39 ms`, 엔드포인트 주소 둘. - 구조에서 나온 결론이고 출력이 없는 것 — 「`enp1s0` 에 걸면 0 패킷」. 원 실행은 `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔다. - 센 것이고 잰 것이 아닌 것 — 왕복 `9` 는 A-0 이 잡은 SQL 목록을 센 값이고 패킷을 추적한 값이 아니다. `200 ms × 9 ≈ 1,800 ms` 와 실측 `1,872 ms` 의 자릿수가 맞는다는 것까지가 이 계산의 범위다. - 증거 파일이 잘려 있는 것 — `agroal_*` 목록이 알파벳순으로 `destroy_count_total` 에서 끊겨 있다. 뒤에 쓰는 `agroal_max_used_count` 는 그 목록에 안 보이지만 부하 뒤 출력에는 있다. diff --git a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d1-backup-restore.md b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d1-backup-restore.md index 78d6742..44f73a4 100644 --- a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d1-backup-restore.md +++ b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d1-backup-restore.md @@ -59,6 +59,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af **이 절차에는 스크립트가 없다.** 원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸고, 그래서 증거 파일의 줄에는 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가 붙어 있다. 사람이 치는 형태가 아니다. 그리고 이 실험에서 스크립트는 특히 위험하다 — `DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때 무엇이 실행됐는지 알 수 없다. 파괴를 손으로 치고, 눈으로 확인하고, 복구도 손으로 친다. +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과 그 파일로 실제로 서비스를 되살리는 것 사이에는 시험되지 않은 가정이 여러 개 있고, 이 절차는 그중 둘을 판정한다. @@ -194,14 +196,16 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ **무엇을 보는가** — 정문과 app1 의 응답. 처음 한 번은 응답을 읽는다. ```bash label="[lab host] ① 헤더를 통째로 본다" -curl -I https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` 헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다. ```bash label="[lab host] ② 코드만 뽑아 둘을 잰다" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master -curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/ ``` **어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 이것도 파괴 직후 값이고, 그게 결과다. @@ -400,8 +404,10 @@ LINE 1: select count(*) from realm ```bash label="[lab host] ③ 파드와 밖에서 본 상태를 다시 잰다" kubectl -n keycloak-lab get pods -o wide -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master -curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/ ``` 실측은 이렇다(observed, `02-destruction.txt`). @@ -441,8 +447,10 @@ kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔ ```bash label="[lab host] ① 두 경로를 잰다" curl -s -o /dev/null -w 'certs %{http_code}\n' \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs curl -s -o /dev/null -w 'well-known %{http_code}\n' \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration ``` @@ -458,6 +466,7 @@ curl -s -o /dev/null -w 'well-known %{http_code}\n' \ ```bash label="[lab host] ② 토큰 발급을 잰다" curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \ -d grant_type=password -d client_id=admin-cli -d username=admin \ -d "password=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ @@ -594,6 +603,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tA ```bash label="[lab host] ① 15초 뒤에 다시 잰다" curl -s -o /dev/null -w 'well-known %{http_code}\n' \ + --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration kubectl -n keycloak-lab get pods -o wide | grep keycloak ``` diff --git a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d2-version-upgrade.md b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d2-version-upgrade.md index 0bdf4bf..7b96713 100644 --- a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d2-version-upgrade.md +++ b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d2-version-upgrade.md @@ -60,6 +60,8 @@ databasechangelog 행 수를 먼저 세고 이미지 태그를 정방향·롤백 **실측이 두 실행에서 나온다**(observed). 처음 실행은 `15:00–15:10` 에 역방향 `26.0` 을 쳤고, 후속 실행은 `15:22–15:26` 에 `26.7.3` 정방향과 롤백을 쳤다. 아래에서도 어느 쪽인지 매번 적는다. +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다. 그 계획이 언제 동작하고 언제 동작하지 않는지를 가른다. @@ -278,7 +280,7 @@ curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyA ```bash label="[lab host] ① 폴링을 뒤에서 돌린다" ( for i in $(seq 1 150); do printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 1 done > /tmp/d2-avail.txt ) & ``` @@ -462,7 +464,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tA ```bash label="[lab host] ⓪ 폴링 창에서 다시 띄운다" ( for i in $(seq 1 150); do printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 1 done > /tmp/d2-avail.txt ) & ``` @@ -582,7 +584,8 @@ kubectl -n keycloak-lab logs keycloak-1 --previous ### 7. 그런데 서비스는 살아 있다 ```bash label="[lab host] 밖과 Service 와 StatefulSet 을 함께 본다" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ -o "custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready" kubectl -n keycloak-lab get statefulset keycloak diff --git a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4-certificate-renewal.md b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4-certificate-renewal.md index 6e1f9d0..59b1c9d 100644 --- a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4-certificate-renewal.md +++ b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4-certificate-renewal.md @@ -407,6 +407,21 @@ ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v gr **문제가 생기면** — 출력이 비면 `grep 'nginx:'` 의 콜론을 빠뜨렸거나 nginx 가 떠 있지 않다. `systemctl status nginx` 부터 본다. +**이 단계도 기계가 틀렸다**(2026-09-17, observed). 위 명령은 `test-server` 에서 nginx 를 찾는데, 기반 가이드 03 이 nginx 를 `kc-lab-edge` 로 옮겼다. 그래서 랩 호스트에서는 **한 줄도 안 나온다** — 5·6 절이 certbot 을 엣지에서 찾는 것과 같은 이유다. 찾는 곳을 엣지로 바꾼다. + +```bash label="[kc-lab-edge] nginx 프로세스 두 줄을 본다" +ssh kc-lab-edge "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" +``` + +```text label="2026-09-17 의 두 줄" + 1065 1 11145 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; + 1106 1065 11135 Thu Sep 17 04:27:49 2026 nginx: worker process +``` + +**이 두 줄은 위 예시와 한 군데가 다르고, 그 다름이 판정표를 그대로 보여 준다.** 위 예시는 마스터와 워커의 `lstart` 와 `etimes` 가 같아서 「기동 이후 reload 가 없었다」였다. 여기서는 워커가 10초 늦게 떴고 `etimes` 도 10 작다 — 03 을 밟으면서 `systemctl reload nginx` 를 친 흔적이다. 마스터는 그대로 두고 워커만 갈아 끼운 것이 숫자로 남는다. + +또 하나. 엣지의 마스터 명령줄은 `/usr/sbin/nginx -g daemon on; master_process on;` 이고 위 예시의 `/usr/bin/nginx` 와 경로가 다르다. Arch 호스트와 Debian 게스트의 패키징 차이다. + ### 8. 두 기계의 시계 차를 지금 잰다 **목적** — 주입 후에는 되짚을 수 없는 값을 확보한다. SSH(Secure Shell, 원격 셸 접속) 왕복에 걸리는 시간까지 함께 본다. 이 실험은 이걸 나중에 하는 바람에 공백 수치를 한 번 틀렸다. @@ -670,11 +685,11 @@ sudo: a password is required ssh -t test-server 'sudo certbot renew --dry-run' ``` -**예상 결과** — 끝의 `simulated renewals` 요약이 나온다. 훅을 넣었다면 `Running deploy-hook command` 줄도 나오는데, 이 실험대는 훅이 없는 상태에서 쟀으므로 그 줄은 미검증이다(unknown). +**예상 결과** — 끝의 `simulated renewals` 요약이 나온다. **훅을 넣어도 `Running deploy-hook command` 줄은 안 나온다**(2026-09-17, observed) — certbot 2.1.0 의 dry-run 은 deploy 훅을 건너뛴다. 근거는 D-4a 의 「인증서를 세우고 훅까지 놓아도」 절에 있다. 그리고 같은 명령이 DNS 전파 때문에 한 번 실패하고 다음 번에 성공하기도 한다. **왜 필요한가** — dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 절차가 도는지, 검증이 통과하는지까지만 말해 준다. 파일이 실제로 바뀌었을 때 nginx 가 그것을 집는지는 dry-run 으로 알 수 없다. -**문제가 생기면** — 여기서 실패하면 강제 갱신도 실패한다. 오류 문구를 읽고 고친 뒤 다시 친다. +**문제가 생기면** — **여기서 실패했다고 강제 갱신도 실패하는 것은 아니다**(2026-09-17, observed). `--dry-run` 은 Let's Encrypt 의 **staging 서버**로 붙는데 `renewal/*.conf` 의 `account =` 은 **운영 계정**을 가리킨다. 그래서 staging 쪽 ACME 계정이 둘 이상이면 dry-run 만 `Please choose an account` 로 멈는다 — 운영 경로에는 그 계정이 하나뿐이라 같은 일이 안 일어난다. 오류 문구에 `account` 가 들어 있으면 D-4a 의 「`--dry-run` 이 보는 계정」 절을 먼저 읽는다. 자격증명 파일이 없거나 DNS-01 검증이 안 되는 경우는 두 경로가 같은 것을 쓰므로 강제 갱신에서도 그대로 날 것으로 보는데, 이 실험대에서 그렇게 재 보지는 않았다(unknown). ### 13. 강제 갱신을 한 번 친다 @@ -698,6 +713,15 @@ ssh -t test-server 'sudo certbot renew --force-renewal' **문제가 생기면** — 발급 한도에 걸렸으면 주당 중복 인증서 5장을 이미 썼다는 뜻이다. 다음 주까지 기다린다. +**★ 2026-09-17 에 다시 치는 곳은 `test-server` 가 아니라 `kc-lab-edge` 다**(observed). 인증서와 nginx 가 엣지 게스트로 옵기면서 명령을 치는 기계도 바뀜었고, 거기는 NOPASSWD sudo 라 `-t` 가 필요 없다. + +```bash label="[kc-lab-edge] 이 실험대가 2026-09-17 에 친 형태" +sudo certbot renew --force-renewal +``` + +결과는 `Congratulations, all renewals succeeded:` 였고 `certbot exit=0` 이었다. 배포 훅이 돌아 서빙하는 인증서까지 바뀌었는데, 그 대조는 D-4a 의 「엣지에서 다시 치고」 절에 있다 — 일련번호가 `06F3E0EF4D1BB03DE58130EAAD1176101373` 에서 `065547991777D11A408CEA90D945DDA03DF1` 로, `notAfter` 가 `Dec 3` 에서 `Dec 16` 으로 바뀜다. + + ## 주입 검증 「갱신 실패」와 「갱신은 됐는데 안 집었다」를 가르는 절이다. 이 실험은 처음에 이 둘을 구별하지 못해 두 갈래로 적어 뒀었다. @@ -1106,8 +1130,8 @@ crt.sh 에 관한 곁다리도 실측이다. 발급 사실은 Certificate Transp 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 인증서 발급과 갱신·reload 판정 전부. Cloudflare API 토큰이 있어야 DNS-01 이 돈다. 지금까지 밟은 것 — 기계·유닛 이름 대조, 기본 유닛에 훅이 없다는 확인. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **남은 것** — 인증서 발급과 갱신·reload 판정 전부(9~22 절). Cloudflare API 토큰이 있어야 DNS-01 이 돈다. 지금까지 밟은 것 — 기계·유닛 이름 대조(5 절), 기본 유닛에 훅이 없다는 확인(6 절 셋 다), nginx 워커 PID 판정 기준(7 절, 기계를 엣지로 고쳐서), 두 기계의 시계 차(8 절). +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 @@ -1116,7 +1140,7 @@ crt.sh 에 관한 곁다리도 실측이다. 발급 사실은 Certificate Transp - (observed, 호스트 확인) `nginx.service` 의 유효 설정 여덟 값. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트)으로 따로 표시했다. - **이 편이 잰 reload 는 사람이 쳤다** — `08:58:52` 의 `nginx -s reload` 는 복구 절에서 사람이 `ssh -t` 로 붙어 친 한 줄이다. 훅이 부르는 자동 reload 는 이 실험대에 아직 없었고(그것이 이 편의 진단이다) D-4a 에서 넣어 따로 쟀다. 무중단 판정의 `8856건` 과 `845361` 바이트는 사람이 건 reload 를 잰 값이다. - **`2199초` 는 이 실험이 스스로 정정했다** — 처음에 `archive/cert2.pem` 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서 `2199초` 로 적었고, 시계 왜곡 106초를 보정한 뒤 `2305초` 로 고쳤다. 틀린 값과 맞는 값을 둘 다 적어 둔 까닭은 어느 쪽이 왜 틀렸는지가 이 편의 교훈이기 때문이다. 보정을 자기 검증한 것은 D-4a 이고, 거기서는 같은 106초가 결과를 뒤집는다. -- (unknown) `certbot renew --dry-run` 에서 `Running deploy-hook command` 줄이 나오는지 — 이 실험대는 훅이 없는 상태에서 쟀다. `openssl … -checkend 2592000` 감시 한 줄, `systemctl reload nginx` 형태. 가이드가 전부 미검증으로 표시했다. +- (observed, 2026-09-17) `certbot renew --dry-run` 은 훅을 놓아도 `Running deploy-hook command` 를 안 낸다 — certbot 2.1.0 의 dry-run 이 deploy 훅을 건너뛰고 `--run-deploy-hooks` 플래그도 없다. `openssl … -checkend 2592000` 감시 한 줄, `systemctl reload nginx` 형태는 여전히 미검증이다(unknown). - **비밀은 옮기지 않았다** — 이 편이 다루는 파일 중 비밀인 것은 `privkey2.pem` 하나이고 크기(`241`)와 권한(`-rw-------`)만 적었다. 내용은 열지 않았고 가이드도 열지 않는다. 일련번호·`Log ID`·호스트명은 식별자라 그대로 적었다. - **이 실험이 재지 않은 것** — 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 전송 중 아티팩트 76건의 원인, 타이머가 스스로 갱신하는 경로(만료 30일 전에야 조건이 성립한다). 전송 중 감시는 전체 50건이었고 그 이상 반복하지 않았다. diff --git a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4a-deploy-hook.md b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4a-deploy-hook.md index 1bc57a8..bc114a0 100644 --- a/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4a-deploy-hook.md +++ b/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4a-deploy-hook.md @@ -394,14 +394,232 @@ nginx: configuration file /etc/nginx/nginx.conf test is successful **문제가 생기면** — `nginx -t` 가 실패하면 `&&` 뒤가 안 돌고 워커도 안 바뀐다. `nginx.conf` 를 고친 뒤 다시 친다. -certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래는 가이드가 미검증으로 표시한 줄이다(unknown). +**5~8 절은 2026-09-17 에 실제로 밟았다. 다만 기계가 `test-server` 가 아니다**(observed). 기반 가이드 03 이 nginx 를, 04 가 certbot 을 `kc-lab-edge` 로 옮겼으므로 `/etc/letsencrypt/renewal-hooks/deploy/` 도 `nginx` 도 그 게스트 안에 있다. 1 절의 `ps` 줄을 랩 호스트에서 치면 **한 줄도 안 나온다.** 아래는 엣지에서 친 것이다 — **따라 하는 순서가 아니라 그날의 기록이다.** 첫 줄의 훅 파일은 5 절 ②의 `nano /tmp/reload-nginx.sh` 로 연다. `printf` 로 찍으면 `\n` 과 `>` 를 먼저 해독한 뒤에야 정작 읽어야 할 두 줄에 닿는데, 그 두 줄은 나중에 열어서 고칠 파일이다. -```bash label="[test-server] dry-run 으로 훅 호출만 본다 (unknown)" +```bash label="[kc-lab-edge] 5~8 절을 엣지에서 이어 친 형태 (observed)" +printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh +sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ +sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ +sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +``` + +```text label="7 절의 실측 — 파일 크기가 40 이 아니라 38 이다" +total 4 +-rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh +``` + +```text label="8 절의 실측 — 위 예상 결과에 없는 셋째 줄이 나온다" +nginx: the configuration file /etc/nginx/nginx.conf syntax is ok +nginx: configuration file /etc/nginx/nginx.conf test is successful +2026/09/17 07:34:08 [notice] 2361#2361: signal process started +``` + +셋째 줄은 `nginx -s reload` 가 신호를 보내는 프로세스를 띄웠다고 알리는 것이고, 실패가 아니다. 위 예상 결과는 `nginx -t` 의 두 줄만 실어서 이 줄을 보면 잠깐 멈추게 된다. + +**그리고 이 절이 세우려는 판정 기준이 여기서 그대로 작동했다**(observed). 훅을 손으로 돌리기 전과 뒤의 두 줄이 이렇다. + +```text label="훅 실행 전후" +전 1065 1 11186 Thu Sep 17 04:27:39 2026 nginx: master process + 1106 1065 11177 Thu Sep 17 04:27:49 2026 nginx: worker process +뒤 1065 1 11189 Thu Sep 17 04:27:39 2026 nginx: master process + 2362 1065 0 Thu Sep 17 07:34:08 2026 nginx: worker process +``` + +마스터는 `1065` 로 그대로이고 워커가 `1106` 에서 `2362` 로 갈렸으며 새 워커의 `etimes` 가 `0` 이다. 판정표의 「마스터 그대로 · 워커 바뀜 = reload 됐다」 그 칸이다. 10 절이 이 방법으로 훅의 효과를 판정하는데, **인증서가 없어도 이 판정 기준 자체는 여기서 검증된다.** + +certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래 줄은 오래 미검증이었는데 **2026-09-17 에 쳤다**(observed). 인증서가 한 장도 없는 상태에서도 돌고, 종료 코드는 `0` 이다. + +```bash label="[kc-lab-edge] dry-run 으로 훅 호출만 본다" sudo certbot renew --dry-run ``` +```text label="인증서가 없을 때의 출력" +Saving debug log to /var/log/letsencrypt/letsencrypt.log +No simulated renewals were attempted. +``` + +**갱신할 것이 없으면 훅도 안 불린다.** `Running deploy-hook command` 줄은 안 나오고 `No simulated renewals were attempted.` 한 줄로 끝나며, 그래도 종료 코드는 `0` 이다. 그래서 **이 명령의 성공은 훅이 도는지에 대해 아무 말도 안 한다**. + +**★ 그런데 인증서를 세우고 훅까지 놓아도 dry-run 은 훅을 안 부른다**(2026-09-17, observed). 오래 미검증으로 남겨 둔 줄이라 이번에 재 봤다. `/etc/letsencrypt/renewal-hooks/deploy/` 에 실행 권한까지 준 훅을 놓고, 시뮬레이션이 **성공한** 판에서도 `Running deploy-hook command` 는 안 나온다. + +```bash label="[kc-lab-edge] 훅을 놓고, 성공한 dry-run 출력에서 그 낱말을 센다" +sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ +sudo grep -c deploy-hook /tmp/dr.txt +certbot --version +``` + +```text label="이 실험대의 값" +-rwxr-xr-x 1 root root 38 Sep 17 09:23 reload-nginx.sh +0 +certbot 2.1.0 +``` + +`certbot --help all` 에 `--run-deploy-hooks` 도 없다(2.1.0 기준, 센 값 `0`). **그러므로 이 판의 certbot 에서는 dry-run 으로 훅을 확인할 방법이 없다.** 이 절의 사전 점검은 「갱신 절차가 도는가」까지만 말한다. 훅이 도는 것은 다음 절의 진짜 갱신에서만 보인다. + +**★ 그리고 같은 명령이 성공하기도 실패하기도 한다**(2026-09-17, observed). 세 번을 연달아 쳐는데 성공 · 실패 · 성공이었다. 실패한 판은 앞의 둘과 또 다른 사유다. + +```text label="세 번째 실패 사유 — DNS 전파" +Certbot failed to authenticate some domains (authenticator: dns-cloudflare). The Certificate Authority reported these problems: + Domain: auth.hyeonworks.com + Type: dns + Detail: During secondary validation: DNS problem: NXDOMAIN looking up TXT for _acme-challenge.auth.hyeonworks.com - check that a DNS record exists for this domain + +Hint: The Certificate Authority failed to verify the DNS TXT records created by --dns-cloudflare. Ensure the above domains are hosted by this DNS provider, or try increasing --dns-cloudflare-propagation-seconds (currently 30 seconds). +certbot exit=1 +``` + +기본값 30초가 이 도메인에서는 아슬아슬하다. **한 번 실패했다고 설정이 틀린 것이 아니다** — 다시 쳐 보고, 계속 실패하면 `--dns-cloudflare-propagation-seconds` 를 올린다. 이 판의 종료 코드는 `1` 이었다 — 앞에서 본 대로 종료 코드는 사유마다 다르다. + + +**★ 더 나쁜 것은, 종료 코드가 실패를 일관되게 알려 주지 않는다는 점이다**(2026-09-17, observed). 같은 「전부 실패」 본문을 두 번 받았는데 한 번은 `0`, 한 번은 `1` 로 끝났다. + +```bash label="[kc-lab-edge] 종료 코드를 따로 잡아서 본다" +sudo certbot renew --dry-run >/tmp/dr.txt 2>&1; echo "certbot exit=$?" +``` + +```text label="첫 번째 — 본문은 실패, 종료 코드는 0" +Failed to renew certificate auth.hyeonworks.com with error: You should register before running non-interactively, … +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +1 renew failure(s), 0 parse failure(s) +certbot exit=0 +``` + +조금 뒤에 같은 명령을 다시 치니 실패 사유가 바뀜었고, 종료 코드도 같이 바뀜었다. + +```text label="두 번째 — 본문은 같은 실패, 종료 코드는 1" +Failed to renew certificate auth.hyeonworks.com with error: Missing command line flag or config entry for this setting: +Please choose an account +Choices: ['test-server@2026-09-03T01:50:44Z (66d5)', 'kc-lab-edge@2026-09-17T08:16:31Z (5df4)'] + +All simulated renewals failed. The following certificates could not be renewed: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) +1 renew failure(s), 0 parse failure(s) +certbot exit=1 +``` + +**두 번 다 `All simulated renewals failed` 이고 `1 renew failure(s)` 인데 종료 코드만 갈렸다.** `0` 을 성공으로 읽으면 첫 번째를 놓치고, 그렇다고 `1` 을 기다려도 두 번째에서만 맞는다. 이 명령에 대해 종료 코드는 판정 근거가 못 된다. + +**그러므로 `&&` 로 뒤를 잇거나 `$?` 로 갈라선 안 된다.** 판정은 본문의 `renew failure(s)` 수와 `All simulated renewals failed` 줄로 한다. 이 편이 재려는 것이 「성공했다고 보고하는데 실제로는 안 된 일」인데, 사전 점검 명령 자체가 그 성질을 갖고 있다. + +```bash label="[kc-lab-edge] 실패를 실패로 읽는 형태" +sudo certbot renew --dry-run >/tmp/dr.txt 2>&1 +grep -E 'Failed to renew|renew failure' /tmp/dr.txt +``` + +두 줄이다. 먼저 파일로 받아 두고, 그 파일에서 판정에 쓰는 줄만 골라 눈으로 읽는다. `renew failure(s)` 앞의 숫자가 `0` 이고 `Failed to renew` 줄이 없으면 통과고, 위 실측처럼 `1 renew failure(s)` 와 `Failed to renew` 가 같이 나오면 실패다. 갱신할 인증서가 한 장도 없으면 `grep` 은 아무것도 안 찍는다 — 그건 통과가 아니라 시뮬레이션할 것이 한 장도 없었다는 뜻이라 `/tmp/dr.txt` 를 그대로 열어 본다. + +판정을 `&&` 나 `||` 로 이어 붙이지 않는 까닭은 바로 위 문단과 같다. 종료 코드가 거짓말하는 명령을 살피는 절에서 분기를 셸에 맡기면 같은 함정을 다시 판다. + +**★ 그리고 인증서를 옮겨도 갱신 능력은 따라오지 않는다**(observed). 다른 기계에서 `live/` · `archive/` · `renewal/` 만 가져오면 위 오류가 난다. `renewal/*.conf` 가 가리키는 **ACME(Automatic Certificate Management Environment, 인증서 발급을 주고받는 프로토콜) 계정**(`/etc/letsencrypt/accounts/`)과 **DNS 자격증명 파일**이 없기 때문이다. 오류 문구가 「등록부터 하라」여서 계정을 새로 만들라는 말로 읽히는데, 실제로 빠진 것은 옮겨 오지 않은 디렉터리다. + +```text label="/etc/letsencrypt/ 아래에서 함께 와야 하는 것" +live/ 인증서 심볼릭 링크 +archive/ 실제 파일 +renewal/ 갱신 설정 — authenticator 와 자격증명 경로를 적는다 +accounts/ ★ ACME 계정 키. 없으면 "You should register…" +cloudflare.ini ★ renewal 이 가리키는 자격증명. 없으면 DNS-01 이 못 돈다 +``` + 출력에 `Running deploy-hook command` 계열의 줄이 나오는가, 그리고 `simulated renewals` 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다. 그래서 관찰 절이 필요하다. +**★ 그리고 `--dry-run` 이 보는 계정은 `renewal/*.conf` 가 가리키는 계정이 아니다**(2026-09-17, observed). `--dry-run` 은 Let's Encrypt **staging 서버**로 붙는데, `renewal/*.conf` 의 `account =` 은 **운영 계정**의 id 를 적어 둔다. 그래서 staging 쪽 계정이 둘 이상이면 certbot 이 고르지 못하고 앞서 본 `Please choose an account` 로 멈는다. + +```bash label="[kc-lab-edge] 두 서버의 계정을 각각 센다" +sudo find /etc/letsencrypt/accounts -mindepth 3 -maxdepth 3 -type d +``` + +```bash label="[kc-lab-edge] renewal 이 어느 계정을 박아 뇌는가" +sudo grep -E '^(account|server) ' /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +``` + +```text label="이 실험대의 값" +/etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/66d5d86599378a0b07936733587017a6 +/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272 +account = 8d53f9312e2a4c9cdd13620122cd8272 +server = https://acme-v02.api.letsencrypt.org/directory +``` + +`account =` 이 가리키는 `8d53…` 은 `acme-v02`(운영) 아래에만 있다. `--dry-run` 은 `acme-staging-v02` 아래를 보고, 거기 계정이 하나면 그것을 쓰고 둘이면 묻는다. 앞서 고르라고 나온 `66d5` 와 `5df4` 는 **둘 다 staging 계정**이었다 — `5df4` 는 실패한 dry-run 이 그때 새로 만든 것이라, **한 번 실패하고 나면 다음 dry-run 이 계속 실패한다.** + +고르라고 나오는 이름은 계정 폴더의 `meta.json` 에서 온다. + +```bash label="[kc-lab-edge] staging 계정을 누가 언제 만들었나" +sudo sh -c "cat /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/*/meta.json" +``` + +```text label="이 실험대의 값" +{"creation_dt": "2026-09-03T01:50:44Z", "creation_host": "test-server"} +``` + +`Choices:` 에 찍힌 `test-server@2026-09-03T01:50:44Z (66d5)` 가 바로 이 두 칸과 폴더 이름 앞 네 글자다. 둘 이상 나오면 나중에 생긴 쪽의 폴더를 `sudo rm -rf` 로 지워 하나만 남긴다. 이 실험대에서는 `kc-lab-edge` 가 만든 쪽을 지워 `test-server` 가 만든 하나만 남겨 두었다(observed). **지우는 것은 staging 계정이라 운영 인증서에는 영향이 없다.** + +위 명령에 `sudo sh -c` 가 붙은 까닭은 경로에 `*` 가 들어 있기 때문이다. `sudo cat …/*/meta.json` 은 셀이 먼저 `*` 를 푸는데, 그 셀은 root 가 아니라 `accounts/` 안을 못 읽고 `No such file or directory` 로 끝난다. + +**그래서 `--dry-run` 의 실패와 진짜 갱신의 실패는 같은 것이 아니다.** 다만 이 실험대에서 진짜 갱신을 다시 치는 데까지 가지는 않았다(unknown). 확인한 것은 운영 계정이 하나뿐이고 그것이 `account =` 이 가리키는 바로 그 id 라는 것까지다. + +**중복 계정을 지우고 다시 치니 dry-run 이 통과했다**(2026-09-17, observed). + +```text label="staging 계정을 하나만 남긴 뒤" +certbot exit=0 +Saving debug log to /var/log/letsencrypt/letsencrypt.log +Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf +Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains +Waiting 30 seconds for DNS changes to propagate +Congratulations, all simulated renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +``` + +**★ 2026-09-17 에 엣지에서 다시 치고 갱신이 서빙까지 닿는 것을 봤다**(observed). 이번엔 `[test-server]` 가 아니라 `[kc-lab-edge]` 에서 쳤다 — 인증서와 nginx 가 거기 있기 때문이다. + +```text label="[kc-lab-edge] sudo certbot renew --force-renewal" +Renewing an existing certificate for auth.hyeonworks.com and 2 more domains +Hook 'deploy-hook' ran with error output: + nginx: the configuration file /etc/nginx/nginx.conf syntax is ok + nginx: configuration file /etc/nginx/nginx.conf test is successful + 2026/09/17 10:36:31 [notice] 4744#4744: signal process started + +Congratulations, all renewals succeeded: + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) +certbot exit=0 +``` + +**판정은 이 출력이 아니라 서빙하는 인증서로 한다.** 같은 소켓을 갱신 전후로 두 번 열어 일련번호와 날짜를 견준다. + +```bash label="[kc-lab-edge] 서빙하는 인증서가 바뀜는가" +echo | openssl s_client -connect 127.0.0.1:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -dates -serial +``` + +```text label="갱신 전" +notBefore=Sep 4 11:29:18 2026 GMT +notAfter=Dec 3 11:29:17 2026 GMT +serial=06F3E0EF4D1BB03DE58130EAAD1176101373 +``` + +```text label="갱신 후" +notBefore=Sep 17 09:37:58 2026 GMT +notAfter=Dec 16 09:37:57 2026 GMT +serial=065547991777D11A408CEA90D945DDA03DF1 +``` + +일련번호가 바뀌었으므로 **훅이 돌았고 nginx 가 새 파일을 집었다.** 같은 순간 worker 프로세스도 바뀜다 — 갱신 전 `2629 4712`, 뒤 `4745 4754`. 디스크에는 `privkey4.pem` 이 `Sep 17 10:36` 으로 생겼다. + +**원래 실행과 다른 데가 둘 있다**(observed). 첫째, 훅 출력에 `types_hash` 경고 줄이 없다 — 엣지의 nginx 설정이 호스트의 것과 달라서다. 둘째, 원래는 worker 하나가 그대로 남았는데 이번에는 둘 다 교체됐다. **`ran with error output` 이 실패가 아니라는 것은 그대로다** — stderr 로 나간 세 줄이 전부 성공 메시지다. + +새 인증서의 `notBefore` 가 훅이 도는 시각(`10:36:31`)보다 약 한 시간 앞이다. 왜 그런지는 이 실험대에서 가르지 않았다(unknown) — 발급자가 앞당긴 것인지 시계 차인지는 안 재 봤다. + +세 이름 모두 갱신 뒤에도 검증을 통과한다. + +```text label="[lab host] 갱신 뒤 세 이름" +auth 302 verify=0 +app1 200 verify=0 +app2 302 verify=0 +``` + + +**그리고 이것이 종료 코드 이야기를 닫는다.** 성공도 `0` 이고 앞의 첫 번째 실패도 `0` 이었다. 같은 명령이 돼을 때와 안 돼을 때 같은 값을 내므로, `$?` 로는 둔 경우를 가를 수 없다. 본문을 읽는 수밖에 없다. + + ## 관찰 ### 9. 강제 갱신을 친다 @@ -650,8 +868,8 @@ echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonw 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 훅을 설치하고 강제 갱신으로 워커 PID 가 바뀌는지 보는 구간 전부. 인증서가 있어야 갱신할 것이 생긴다. 지금까지 밟은 것 — 훅 디렉터리가 빈 것, 두 기계 시계 차 94초. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **남은 것** — 강제 갱신부터(9~13 절). 인증서가 있어야 갱신할 것이 생긴다. 지금까지 밟은 것 — 훅 디렉터리가 빈 것(3 절), 두 기계 시계 차 94초(4 절), **훅 파일 작성·설치·확인·손으로 실행(5~8 절)과 그 실행이 워커 PID 를 갈아 끼우는 것(10 절의 판정 기준)**. 기계는 전부 `kc-lab-edge` 로 바꿔서 쳤다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a1-jgroups-transport-block.md b/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a1-jgroups-transport-block.md index 8100136..cd30605 100644 --- a/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a1-jgroups-transport-block.md +++ b/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a1-jgroups-transport-block.md @@ -61,6 +61,8 @@ NetworkPolicy 로 8080 과 9000 만 열어 JGroups 트랜스포트인 TCP 7800 | 걸리는 시간 | 전 구간 약 30분 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 A-0 은 세션이 Infinispan 복제가 아니라 PostgreSQL 로 공유된다고 측정했다. Keycloak 24 이전 자료는 세션이 7800 으로 복제된다고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고 A-0 모델은 안 깨진다고 예측하므로, 7800 만 끊어 보면 둘 중 어느 쪽이 틀렸는지 판정된다. @@ -684,13 +686,16 @@ kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ```text NAME ADDR READY keycloak-lxk8h [10.42.0.13],[10.42.1.17] true,false -``` `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다. - -```bash label="[lab host] 밖에서 정문을 친다" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` -인증서 단계를 건너뛴 실험대라면 443 을 듣는 것이 없어 여기는 `000` 이다. 그때는 TLS 를 빼고 `Host` 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 `200` 을 받았다(observed). +`kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다. + +```bash label="[lab host] 밖에서 정문을 친다" +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master +``` + +`--resolve` 는 위 「읽기 전에」가 적은 까닭으로 붙였다. 인증서 단계를 건너뛴 실험대라면 엣지가 443 을 안 들으므로 그래도 `000` 이고, 그때는 TLS 를 빼고 `Host` 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 `200` 을 받았다(observed). ```bash label="[lab host] TLS 없이 같은 것을 잰다" curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: auth.hyeonworks.com' http://192.168.122.10/realms/master diff --git a/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md b/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md index 24cbf6f..e7a76f1 100644 --- a/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md +++ b/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md @@ -311,6 +311,13 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help- persistent-user-sessions[:v1] ← 목록에 있다 ``` +**★ 2026-09-17 에 쳤더니 두 줄이 나왔다**(observed). 버전 접미사가 붙은 것과 안 붙은 것이 목록에 따로 있다. 하나만 나올 것으로 알고 있으면 두 줄째를 딴 기능으로 읽는다. + +```text + persistent-user-sessions[:v1] + persistent-user-sessions +``` + `--help-all` 은 출력이 길고 기능 목록이 한 줄에 쉼표로 이어 붙어 나온다. `tr ',' '\n'` 이 그것을 줄로 쪼갠다. 처음 한 번은 `grep` 없이 쳐서 어떤 기능들이 있는지 통째로 본다. **왜 필요한가** — 목록에 없으면 그 버전에서는 이 절차를 할 수 없다. 기능이 제거돼 기본 동작으로 고정된 것이고, 그 자체가 답이다. @@ -388,6 +395,14 @@ Waiting for 1 pods to be ready... partitioned roll out complete: 2 new pods have been updated... ``` +**★ patch 로 바꾸면 `configured` 가 안 나온다**(2026-09-17, observed). `configured` 는 `kubectl apply` 가 내는 말이고, patch 는 `statefulset.apps/keycloak patched` 를 낸다. 아래 「`configured` 가 나와야 한다」는 매니페스트를 고쳐 `apply` 한 경우에만 맞는 판정이다. patch 로 쳤으면 바뀐 것을 args 문자열로 확인한다 — 9번이 그 일을 한다. + +```text +statefulset.apps/keycloak patched +``` + +이 실험대에서 patch 로 쳤을 때 롤아웃은 `18:02:41` 에 시작해 `18:03:47` 에 끝났다. 재빌드까지 66초다. + **왜 필요한가** — `configured` 가 나와야 한다. `unchanged` 면 args 가 안 바뀌었다. 빌드 옵션이라 기동할 때 재빌드가 일어나 평소보다 오래 걸리므로 `--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 전환 시각이 없으면 뒤에서 지표가 언제부터 변했는지 볼 때 인과를 못 붙인다. **문제가 생기면** — 타임아웃이 나면 `--timeout=500s` 로 다시 치고, `logs keycloak-0` 에 빌드 진행이 보이는지 확인한다. @@ -503,6 +518,19 @@ kubectl -n observability exec deploy/prometheus -- \ keycloak-1 sessions 캐시 0.0 건 ``` +**★ 위 두 줄은 이 명령의 출력이 아니다**(2026-09-17, observed). `tr` 과 `grep` 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 96줄이었다. 위의 요약 두 줄은 원 실행의 스크립트가 만든 모양이다(inferred). 앞의 ① 이 찍는 JSON 도 마찬가지로 `sessions` 만이 아니라 캐시 전부를 담고 있다. + +```text +"cache":"sessions" +"pod":"keycloak-1"} +"82"]} +"cache":"clientSessions" +"pod":"keycloak-1"} +"82"]} +``` + +세 줄이 한 묶음이고 마지막 줄의 숫자가 그 캐시의 엔트리 수다. 세로로 읽으면서 캐시 이름과 파드 이름을 눈으로 짝지어야 한다. + **왜 필요한가** — persistent 였을 때와 똑같은 숫자다. `approximate_entries_unique` 는 그 노드가 소유한 엔트리만 세고 백업본을 들고 있어도 0 으로 보인다. 이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 10번의 DB 행 수다. **문제가 생기면** — 빈 결과가 오면 0건이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다. @@ -637,14 +665,13 @@ kubectl -n keycloak-lab exec a7-probe -- sh -c \ **목적** — 7800 과 57800 을 양방향으로 버리고 교차 노드가 끊기는지 본다. -두 노드에 각각 규칙을 넣는다. 규칙의 `-d` 는 그 노드에 있는 파드의 IP 다. +두 노드에 각각 규칙을 넣는다. 규칙의 `-d` 는 그 노드에 있는 파드의 IP 다. kubeconfig 가 lab host 에만 있으므로 두 노드 모두 거기서 한 줄 `ssh` 로 친다. -```bash label="[kc-lab-1] ① 양쪽 노드에 raw DROP 을 넣는다" +```bash label="[lab host] ① 양쪽 노드에 raw DROP 을 넣는다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') - -sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP -sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP +ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP" +ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP" ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP" ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP" date '+%H:%M:%S 차단' @@ -655,7 +682,33 @@ date '+%H:%M:%S 차단' | `kc-lab-1` | `keycloak-1` | `$K1` | | `kc-lab-2` | `keycloak-0` | `$K0` | -**뒤의 두 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 가 들어간다. `$K0` 는 `[lab host]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[lab host]` 에서 펴서 보내므로 이 두 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다. +**네 `ssh` 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 와 `$K1` 이 들어간다. 둘 다 `[lab host]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[lab host]` 에서 펴서 보내므로 이 네 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 와 `echo "$K1"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다. + +**원 가이드는 앞 두 줄을 `kc-lab-1` 안에서 직접 쳤다.** 지우지 않고 남기는 까닭은 아래 실패 출력이 이 실험대에서 실제로 받은 것이기 때문이다. 따라 칠 형태는 위의 ① 이다. + +```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 이 실험대에서는 안 돈다" +K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') +K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') + +sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP +sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP +ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP" +ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP" +date '+%H:%M:%S 차단' +``` + +**★ 이 블록은 `kc-lab-1` 에서 안 돈다**(2026-09-17, observed). 앞의 「읽기 전에」가 적은 그대로다 — 그 기계에는 kubeconfig 가 없어서 첫 두 줄의 `kubectl` 이 막히고, `$K0` 와 `$K1` 이 빈 문자열이 된다. 그 상태로 `iptables` 가 이어지면 `-d` 가 값을 못 받아 규칙이 하나도 안 들어간다. 그리고 `kc-lab-1` 에는 `kc-lab-2` 의 호스트 키가 없어 뒤의 두 줄도 접속 단계에서 끝난다. 여섯 줄이 전부 실패했는데 마지막 `date` 는 그대로 `차단` 을 찍는다. + +```text +error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied +Bad argument `7800' +Bad argument `57800' +Host key verification failed. +Host key verification failed. +09:04:49 차단 +``` + +찍힌 시각을 주입 시각으로 적으면 A-6 의 `적용완료` 와 같은 함정이다. 그때 두 노드의 `raw PREROUTING` 은 비어 있었다. NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫어서 이미 붙어 있는 7800 연결이 계속 산다. @@ -670,7 +723,14 @@ NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. Netwo 주입이 걸렸는지 양쪽 카운터를 둘 다 본다. -```bash label="[kc-lab-1] ② 두 노드의 규칙과 카운터를 본다" +```bash label="[lab host] ② 두 노드의 규칙과 카운터를 본다" +ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v' +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' +``` + +원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). + +```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다" sudo iptables -t raw -L PREROUTING -n -v ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' ``` @@ -704,7 +764,9 @@ kubectl -n observability exec deploy/prometheus -- \ +125초 cluster_size(k0 k1) = [1.0 ] ``` -`2.0 2.0` 이 `1.0` 으로 떨어지는 데 50초쯤 걸린다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다. +`2.0 2.0` 이 `1.0` 으로 떨어지는 데 50초쯤 걸린다. + +**★ 이 실험대에서는 `+75초` 에 떨어졌고 시계열에 빈 값이 없었다**(2026-09-17, observed). 손으로 25초마다 친 다섯 번은 `2 2` · `2 2` · `1 1` · `1 1` · `1 1` 이었다. 차단을 푼 뒤 양쪽이 `2` 로 돌아오는 데는 `+60초` 가 걸렸다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다. split brain 은 DB 한 줄로 확인한다. @@ -856,7 +918,14 @@ volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm 과 사용 순서가 있다. `iptables` 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, `args` 를 되돌린다. -```bash label="[kc-lab-1] ① 두 노드의 raw 규칙을 확인한다" +```bash label="[lab host] ① 두 노드의 raw 규칙을 확인한다" +ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n' +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n' +``` + +원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). + +```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다" sudo iptables -t raw -L PREROUTING -n ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n' ``` @@ -937,6 +1006,9 @@ postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s | 탐침 파드 | `kubectl -n keycloak-lab get pod a7-probe` | `NotFound` | | 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | +**★ 마지막 줄의 `200` 은 이 실험대에서 안 나온다**(2026-09-17, observed). `auth.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어 `000` 이 나온다. 엣지 주소를 짚으면 `200` 이고, 까닭은 A-6 의 같은 줄에 적었다. 나머지 여덟 줄은 이 실험대에서 그대로 통과했다 — args `["start"]`, `git diff` 출력 없음, 파드 둘 다 `1/1 Running`, postgres `1/1 Running`, 로그인 뒤 세션 행 `1`, 두 노드 `raw PREROUTING` 비어 있음, `vendor_cluster_size` 양쪽 `2`, 탐침 `NotFound`. + + ```bash label="[lab host] ⑧ 탐침 파드를 지운다" kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found ``` @@ -965,7 +1037,7 @@ kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found 이 실험대가 실제로 본 것(observed)은 전환 전 `args` 가 `["start"]` 이고 파드 IP 가 `10.42.1.94` 와 `10.42.0.45` 였던 것, `DELETE 151`, 전환 뒤 `args` 가 `["start","--features-disabled=persistent-user-sessions"]` 인 것, 로그인 5회 뒤 `(0 rows)` 와 캐시 `5.0`/`0.0`, 교차 노드 refresh `HTTP 200`, 재시작 전 `sid = aVwYnzKZFFvMqD3bpSeiILuM` 와 재시작 뒤 `HTTP 400` 및 `Session not active`, 재시작 뒤 캐시 `1.0`, 분단 대기 시계열 다섯 줄, 대조군 `200` 과 시험군 `400`, DB 정지 뒤 `① 500` 과 `② 200`, 원복 뒤 `["start"]` 와 `DB 온라인 세션: 1 건` 과 외부 `200`, 비밀번호 길이 `19`, 기능 목록의 `persistent-user-sessions[:v1]` 이다. -가이드가 미검증으로 표시한 것(unknown)은 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력이다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다. +가이드가 미검증으로 표시한 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력은 2026-09-17 에 쳤다(observed). 나오는 모양은 위 11번의 ★ 에 적었다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다. 측정이 샌 곳이 하나 있다. `cluster_size` 시계열의 빈 값과 값이 하나뿐인 줄인데, 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다. @@ -973,4 +1045,6 @@ DB 정지 뒤의 `200 / 500` 은 조건부다. 캐시 온도에 따라 `400 / 40 이 절차가 재지 않은 것은 volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지다. 파드가 둘뿐이라 그것을 볼 수 없다. +2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정값은 전부 같은 답이 나왔다 — 전환 전 args `["start"]` 와 `DELETE 144`, 전환 뒤 args `["start","--features-disabled=persistent-user-sessions"]`, 로그인 5회 `200 200 200 200 200` 뒤 DB `(0 rows)`, 교차 노드 refresh `200`, 재시작 전 `sid` 를 담고 재시작 뒤 `400` 과 `Session not active`, 분단에서 대조군 `same-node 200` 과 시험군 `cross-node 400`, `coord = t` 둘, DB 정지 뒤 `500` 과 `200`, 원복 뒤 `["start"]` 와 세션 행 `1`. 달랐던 것은 명령이 아니라 화면의 모양이고 위의 ★ 다섯이 그것이다. 원문은 `relive-2026-09-17/a7-06..08` 에 있다. + <!-- body:end --> diff --git a/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a8-rolling-restart.md b/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a8-rolling-restart.md index ea17257..1d3e191 100644 --- a/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a8-rolling-restart.md +++ b/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a8-rolling-restart.md @@ -57,6 +57,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af | 무중단의 전제 | replica 2 와 readiness 프로브 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 운영에서 가장 자주 겪는 작업이다. 장애가 아니라 정상 배포인데도 사용자가 로그아웃되면 그건 사고다. @@ -373,7 +375,7 @@ kubectl -n observability exec deploy/prometheus -- \ ```bash label="[kc-lab-1 · 두 번째 터미널] ① 5초 간격으로 48번 외부를 친다" for i in $(seq 1 48); do printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 5 done echo @@ -493,7 +495,7 @@ echo "$K0 $K1" ```bash label="[kc-lab-1 · 두 번째 터미널] 1초 간격 150회로 더 촘촘히 잰다" for i in $(seq 1 150); do printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ - https://auth.hyeonworks.com/realms/master)" + --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)" sleep 1 done echo 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 b2d33b4..40009f1 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": "9465582b5d1630eb4ae7c4e078021486919bf6b6", "verified": "반입할 때 적어 둔 source/.source-revision 이 이 커밋이고 저장소에 실재한다 — 「chore: 실행 환경 구성 문서 추가 및 수정」, 2026-09-10. **전에 이 칸은 cdac9b81 이었고 그것은 틀렸다** (2026-09-17 대조) — 그 커밋은 2026-09-04 이고 반입본 306개 가운데 docs/guides/** 28개가 거기에 아예 없다. 가이드는 그 엿새 뒤 6f6ab86 에서 들어왔다. **그런데 반입한 바이트는 이 커밋과도 같지 않다** — 9465582b 와 같은 것은 276개이고 29개가 다르다. 같은 306개를 저장소의 **작업 트리**와 견주면 297개가 같다. HEAD 에서 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 6f6ab86 도 28개가 어긋났다. **맞는 커밋은 없다** — 반입은 커밋이 아니라 **그 시점의 작업 트리**(미커밋 수정이 있던 상태)에서 떠 온 것이다. 지금도 저장소는 그 파일들을 M 으로 낸다. 작업 트리와 남은 차이 8개 가운데 5개가 그 M 목록에 있고(반입 뒤 저장소가 더 고쳤다), deploy/lab/host/nginx-keycloak-lab.conf 는 저장소에서 deploy/lab/edge/ 로 옮겨져 반입본에만 남았다. **이 커밋은 「반입 시점의 HEAD」라는 뜻이지 「반입한 바이트가 이것이다」가 아니다**" }, - "ssotSha256": "23a3efedf685f954fd69d6c90f1468e258457f7dd61142ef7652c8e8c6fa27e0", + "ssotSha256": "fdb69c6e6e80bb74ee1e066523a799be7c8a0320f81f3d3799a12909762d6387", "sourceRevision": "keycloak-session-lab@2026-09", "generatedAt": "2026-09-17", "candidateScope": { diff --git a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b4-forged-identity-headers.md b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b4-forged-identity-headers.md index 7e2f9c3..4d423b9 100644 --- a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b4-forged-identity-headers.md +++ b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b4-forged-identity-headers.md @@ -53,6 +53,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af **무엇을 재는 경로인지 먼저 못박는다.** 위조 헤더를 보내는 `https://app1.hyeonworks.com/api/echo` 는 `header-lab` 네임스페이스의 echo 앱으로 가고, 그 경로는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. 이 절차가 재는 것은 엣지가 인증을 끝낸 뒤의 인가가 아니라, 헤더를 받아 쓰는 업스트림이 그 값을 검증하는가다. 같은 위조 헤더를 토큰이 필요한 경로에 보내면 거기서 막히고, 그 대조를 주입 검증 절이 같이 친다. +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 엣지(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이고, 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다. 그러면 그 헤더는 무엇을 보증하는가. Q4 는 확인한 사실로 이렇게 적어 두었다. @@ -763,7 +765,8 @@ kubectl apply -f /tmp/grafana-ingress-backup.yaml ```bash label="[lab host] ② app2 를 잡고 있는 Ingress 를 센다" kubectl get ingress -A | grep app2 -curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \ + https://app2.hyeonworks.com/ ``` **예상 결과** — `app2` 를 잡고 있는 Ingress 가 `observability/grafana` 하나여야 한다. diff --git a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7-cookie-secret-rotation.md b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7-cookie-secret-rotation.md index 52cf2ea..fadeace 100644 --- a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7-cookie-secret-rotation.md +++ b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7-cookie-secret-rotation.md @@ -58,6 +58,8 @@ oauth2-proxy 의 cookie secret 을 A 에서 B 로 바꿨을 때 로그인해 있 | 전 구간 | 약 20분 | | 도구 | `jq` 도 `yamllint` 도 이 실험대에 없다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 앞선 작업이 남긴 열린 질문 Q1 의 미지수 7 이 이렇게 물었다. @@ -86,6 +88,27 @@ B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다 ## 전제와 되돌리기 - `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다. + +**★ 그 클라이언트를 만드는 단계가 어디에도 없다**(2026-09-17, observed). 기반 가이드 05 도, 저장소의 realm 임포트(`keycloak/import/keycloak-patterns-realm.json`)도 `oauth2-proxy` 를 만들지 않는다. 임포트에 있는 것은 `bff-confidential` · `edge-proxy` · `mock-google-broker` · `spa-public` · `token-mediating-confidential` 다섯이다. 새로 세운 실험대에서 이 편을 밟으면 로그인 화면 대신 이 문장을 만난다. + +```text label="클라이언트가 없을 때 Keycloak 이 내는 화면" +We are sorry... +Client not found. +``` + +`oauth2-proxy` 파드는 멀쩡히 뜨고 `app2` 도 `302` 를 내므로 **배포는 다 된 것처럼 보이고**, 로그인 화면까지 가서야 드러난다. 만드는 한 줄은 이렇다 — 값은 매니페스트가 읽는 Secret 에서 그대로 꺼내 넘긴다. + +```bash label="[lab host] 이 편을 밟기 전에 클라이언트를 만든다" +CS=$(kubectl -n keycloak-lab get secret oauth2-proxy-secrets -o jsonpath='{.data.CLIENT_SECRET}' | base64 -d) +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create clients -r keycloak-patterns \ + -s clientId=oauth2-proxy -s enabled=true -s protocol=openid-connect \ + -s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false \ + -s "secret=$CS" \ + -s 'redirectUris=["https://app2.hyeonworks.com/oauth2/callback"]' \ + -s 'webOrigins=["https://app2.hyeonworks.com"]' +``` + +`redirectUris` 는 매니페스트의 `--redirect-url` 과 한 글자도 달라선 안 된다. **B-7a 와 C-2 도 같은 클라이언트를 쓰므로 똑같이 막힌다.** - B-0 이 끝나 있어야 한다. Redis 를 거기서 띄우고 `redis.keycloak-lab.svc:6379` 로 떠 있다. - 브라우저가 있어야 한다. @@ -100,6 +123,15 @@ B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다 `oauth2-proxy-secrets` 가 있고 이미지도 받아지는데 기동 자체가 안 된다 — **이 편의 전제는 배포가 아니라 TLS 다.** +**그런데 위 오류의 주소가 원인을 하나 가리고 있었다**(2026-09-17, observed). `100.83.212.4` 는 랩 호스트의 tailnet 주소이고, 기반 가이드 03 이 nginx 를 엣지 게스트로 옮긴 뒤로 **그 주소에는 443 도 80 도 없다.** 그러니 인증서를 받아 엣지에 얹었더라도 파드는 여전히 이 주소를 두드렸을 것이다. 클러스터 DNS 가 그 이름을 엣지로 보내게 고친 뒤 같은 배포를 다시 했더니 주소만 바뀌고 증상은 같았다. + +```text label="CoreDNS 를 고친 뒤 같은 오류" +[2026/09/17 07:37:18] [provider.go:55] Performing OIDC Discovery... +[2026/09/17 07:37:18] [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: ... dial tcp 192.168.122.10:443: connect: connection refused +``` + +이제 두드리는 곳이 엣지(`192.168.122.10`)이고, 거기 443 이 안 열린 것은 인증서가 없어서다. **같은 `connection refused` 인데 앞의 것은 원인이 둘이었고 뒤의 것은 하나다.** 03 의 「클러스터 안에서도 공개 이름에 못 닿는다」를 먼저 밟고 와야 이 편의 전제가 TLS 하나로 좁혀진다. + **이건 남의 도메인을 빌리고 남의 세션을 끊는 실험이다.** 둘을 건드린다. 인증서가 `auth` · `app1` · `app2` 세 이름만 덮어서 네 번째 이름을 못 만들기 때문에 Grafana 의 Ingress 를 잠시 내리고 `app2` 를 빌린다. 그리고 secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다. 되돌리기는 둘이고 먼저 읽어 둔다. @@ -133,7 +165,7 @@ Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 **행동** — 먼저 지금 상태를 보고, 백업을 뜨고, 그 백업이 비어 있지 않은지 확인한다. ```bash label="[test-server] ① 지금 app2 가 어디로 가는지 본다" -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` **②를 치기 전에 읽는다.** 셸은 `>` 를 kubectl 보다 먼저 처리한다. `~/grafana-ingress-backup.yaml` 은 kubectl 이 돌기도 전에 0바이트가 되고, `get` 이 실패하면 앞서 떠 둔 백업이 그때 없어진다. 뒤따르는 `wc -l` 과 `grep -c` 는 이미 비어 버린 파일을 센다. 그래서 이 절을 두 번째로 치는 사람은 — B-4 로 app2 를 먼저 빌렸거나 실험을 중간에 다시 시작했다면 — `wc -l ~/grafana-ingress-backup.yaml` 을 먼저 쳐서 쓸 만한 백업을 이미 갖고 있는지 보고, 갖고 있으면 ②를 건너뛴다. @@ -206,8 +238,10 @@ oauth2-proxy-c76b49c59-b9928 true kc-lab-2 **무엇을 보는가** — 인증을 거치는 경로와 안 거치는 경로. ```bash label="[test-server] 두 경로의 상태 코드를 뽑는다" -curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/ -curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping +curl -s -o /dev/null -w '/ %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \ + https://app2.hyeonworks.com/ +curl -s -o /dev/null -w '/ping %{http_code}\n' --resolve app2.hyeonworks.com:443:192.168.122.10 \ + https://app2.hyeonworks.com/ping ``` **어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`). @@ -287,15 +321,20 @@ kubectl -n keycloak-lab get secret oauth2-proxy-secrets \ "COOKIE_SECRET_B" ``` -길이도 본다. 가이드가 아래 두 줄을 미검증으로 표시했다(unknown) — 원래 실행 기록에 이 명령의 출력이 없다. +길이도 본다. 원래 실행 기록에는 이 명령의 출력이 없어 미검증이었는데, **2026-09-17 에 쳐서 값이 생겼다**(observed). 둘 다 `32` 다 — 프록시가 안 떠 있어도 Secret 만 읽으면 되므로 인증서 없이 칠 수 있다. -```bash label="[lab host] ② 두 cookie secret 의 길이만 센다 (unknown)" +```bash label="[lab host] ② 두 cookie secret 의 길이만 센다" kubectl -n keycloak-lab get secret oauth2-proxy-secrets \ -o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c kubectl -n keycloak-lab get secret oauth2-proxy-secrets \ -o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c ``` +```text label="② 의 출력" +32 +32 +``` + **어디를 보나** — oauth2-proxy 는 정확히 16 · 24 · 32 바이트만 받는다. 매니페스트의 값은 32바이트짜리이고, 다른 수가 나오면 프록시가 기동에서 죽는다. **이 값이 뜻하는 것** — 회전 대상이 미리 두 개 준비되어 있고, 그래서 이 실험이 한 번 바꾸고 되돌릴 수 있는 형태가 된다. 16 · 24 · 32 라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다. @@ -634,12 +673,12 @@ kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml ``` -```bash label="[kc-lab-1 → test-server] ② Ingress 와 밖에서 본 응답을 함께 본다" +```bash label="[lab host] ② Ingress 와 밖에서 본 응답을 함께 본다" kubectl -n observability get ingress grafana -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` -첫 줄은 `kc-lab-1` 에서 치고, `curl` 은 1 절 ①과 같게 `test-server` 에서 친다. 기계가 다르면 1 절에서 본 응답과 견줄 수 없다. +원 가이드는 첫 줄을 `kc-lab-1` 에서 치라고 적었는데 그 기계에는 kubeconfig 가 없어 `kubectl` 이 막힌다(2026-09-17, observed). 두 줄 다 lab host 에서 친다 — `curl` 도 1 절 ①과 같은 기계라야 그때 본 응답과 견줄 수 있다. **예상 결과** — Ingress 가 `observability` 에 다시 있고 `app2` 응답이 1 절에서 처음 본 모양으로 돌아온다. @@ -680,8 +719,9 @@ curl -sI https://app2.hyeonworks.com/ | head -3 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 쿠키 비밀 회전 전부. `oauth2-proxy` 가 기동 시 OIDC 디스커버리를 `https` 로 하므로 인증서가 서야 뜬다. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 위 클라이언트를 만든 뒤 전 구간을 밟았다. 결과는 문서 그대로다 — 로그인 뒤 쿠키가 `_oauth2_proxy` 176자짜리 티켓이고 세션은 Redis 에 `_oauth2_proxy-<32자>` 로 들어갔으며, 업스트림이 `x-forwarded-user` · `x-forwarded-email` · `x-forwarded-preferred-username` 을 받았다. secret 을 A 에서 B 로 바꾸자 **회전 전 200 이던 같은 쿠키가 302 로 바뀌어** 로그인 시작점으로 되돌아갔다. 겹침 구간이 없다는 이 편의 결론이 그대로 성립한다. +- **그리고 Redis 의 세션 키는 회전 뒤에도 그대로 남았다**(observed). 가리키던 쿠키가 못 쓰게 됐을 뿐 값은 살아 있다 — B-7a 가 다루는 고아가 여기서 생긴다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7a-orphan-session.md b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7a-orphan-session.md index 3fdc982..c31b975 100644 --- a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7a-orphan-session.md +++ b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b7a-orphan-session.md @@ -55,6 +55,8 @@ sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af | 전 구간 | 약 20분. 그중 TTL 을 세 번 재는 데 1분이 그대로 든다 | | 도구 | `jq` 가 이 실험대에 없다. Redis 는 자기 CLI 로 묻는다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 B-7 은 여기서 멈췄다. @@ -220,6 +222,8 @@ done _oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510 ``` +**2026-09-17 에 이 블록을 그대로 쳤고 오류 없이 돌았다**(observed). 다만 출력이 비었다 — `oauth2-proxy` 가 안 떠서 세션 키가 하나도 없기 때문이고, 같은 순간 `R dbsize` 는 `3` 이었다(BFF 가 쓰는 키다). **그래서 이 절에서 확인된 것은 「명령이 맞다」까지이고 「값이 이렇게 나온다」는 아직 아니다.** 빈 출력을 보고 함수가 틀렸다고 되짚지 않도록 여기 적어 둔다 — `R: command not found` 가 아니라 **아무것도 안 나오는 것**이 이 상태의 정상이다. + **이 값이 뜻하는 것** — 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. 느리다. 키가 수백 개면 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다. ### 4. 시계를 맞춘다 @@ -623,7 +627,7 @@ grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml ```bash label="[lab host] ③ 빌린 Ingress 를 걷고 백업을 올린 뒤 밖에서 본다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` **예상 결과** — `app2` 응답이 B-7 을 시작하기 전 모양으로 돌아온다. 첫 줄의 상태 코드와 이어지는 `location` 헤더를 본다 — 여기서 갈라야 하는 것은 app2 가 아직 oauth2-proxy 로 가는지 Grafana 로 넘어갔는지다. Grafana 자체가 섰는지는 아래 표의 `get ingress grafana` 가 답한다. @@ -667,8 +671,15 @@ curl -sI https://app2.hyeonworks.com/ | head -3 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 고아 세션 관찰 전부. 같은 이유로 `oauth2-proxy` 가 안 뜬다. 지금까지 밟은 것 — Redis 쪽 명령(`dbsize`·`--scan`)이 도는 것까지. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **고아 세션 관찰까지 밟았다**(2026-09-17). TLS 를 세우고 B-7 의 전제에 빠져 있던 `oauth2-proxy` 클라이언트를 만든 뒤, B-7 의 회전을 실제로 걸어 고아를 만들었다. 6 절의 `R` 함수와 스캔 루프가 값을 냈다. + +```text label="회전 뒤 남은 고아 하나" +_oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 type=string ttl=3551 len=3457 +``` + + **TTL 이 정직하게 줄고 갱신되지 않는 것도 확인했다**(observed). 30초 간격으로 세 번 재니 `3551 → 3521 → 3490` 이었다. 가리키던 쿠키가 못 쓰게 된 뒤에도 값은 남은 수명만큼 그대로 산다. +- **남은 것** — 12 절의 회전 시각 기준 정리와 14·15 절의 판정·삭제 루프. 고아가 하나뿐이라 「여럿 가운데 고르는」 형태는 아직 시험하지 못했다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c1-multi-app-sso.md b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c1-multi-app-sso.md index 01e2eb4..e735ce1 100644 --- a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c1-multi-app-sso.md +++ b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c1-multi-app-sso.md @@ -65,6 +65,8 @@ app1 과 app2 를 한 번의 로그인으로 묶은 뒤 IdP 세션만 끊고, | 도구 | `jq` 는 이 실험대에 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없다 | | 전 구간 | 약 20분. Keycloak 재시작에만 1~2분 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 원래 질문은 한 줄이었다. @@ -435,6 +437,18 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c `user_session_id` 가 앞과 같고 `client_sessions` 만 1 에서 2 로 늘었다. SSO 의 데이터 구조가 이렇게 생겼다. +**★ 위 출력의 칸 이름이 질의와 다르다**(2026-09-17, observed). 질의는 `as clients` 로 별칭을 주는데 실린 출력의 머리는 `client_sessions` 다. **질의 쪽이 C-1 원본 가이드 그대로이고 틀린 것은 출력 쪽이다** — `client_sessions` 라는 별칭은 B-3 이 쓰는 다른 질의의 것이고, 그쪽 출력이 여기로 섞여 들어왔다. 위 질의를 그대로 치면 머리는 `clients` 로 나온다. + +**★ 그리고 행이 하나만 나오지 않는다.** 위 출력은 한 줄인데, direct grant 로 토큰을 여러 번 받아 본 실험대에서는 그만큼 user session 이 쌓여 있다. 2026-09-17 에는 16행이 나왔고 **그중 `clients` 가 `2` 인 한 줄이 이 편이 찾는 세션**이다. + +```text label="2026-09-17 의 끝 두 줄" + 4i6Q7qb3Y_7Ib4mQzzsmTkzQ | keycloak-patterns | 1 + V0mkutuu-0tNdmvHBfWucB9j | keycloak-patterns | 2 +(16 rows) +``` + +**`clients` 가 `2` 인 한 줄이 이 편이 찾는 세션이고 나머지는 direct grant 로 받은 토큰들이 남긴 것이다.** 행이 많으면 그 줄을 눈으로 고른다 — 세션 수가 실험 순서에 따라 달라지므로 「한 줄이 나온다」를 통과 조건으로 삼지 않는다. + ```text user session (사용자 · 브라우저 하나당 하나) ├─ client session : bff-confidential @@ -682,7 +696,7 @@ grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml ```bash label="[lab host] C-2 를 이어서 하지 않을 때만 친다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` **예상 결과** — `app2` 응답이 Grafana 로 돌아간다. @@ -723,8 +737,9 @@ curl -sI https://app2.hyeonworks.com/ | head -3 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — app1·app2 로그인과 두 앱의 Redis 키 대조. 지금까지 밟은 것 — 세션 정리 구간 전부(`logout-all` 이 안 듣는 것, 자식 1633·부모 6, 롤아웃이 kcadm 세션을 날리는 것). -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 B-7 의 전제에 빠져 있던 `oauth2-proxy` 클라이언트를 만든 뒤 app1 에 로그인하고 같은 쿠키로 app2 를 열었다. **로그인 화면이 다시 뜨지 않았다** — 응답이 곧바로 업스트림의 것이었고 `login-actions/authenticate` 는 0건이다. `client-session-stats` 에 `bff-confidential` 과 `oauth2-proxy` 가 나란히 잡히고, user session 한 줄의 `clients` 가 `2` 다. 저장소 쪽도 `_oauth2_proxy-*` 와 `bff:session:sessions:*` 가 각각 하나씩 늘었다. +- **이 편도 `oauth2-proxy` 클라이언트가 없으면 로그인 화면에서 `Client not found.` 로 끝난다.** 만드는 한 줄은 B-7 의 전제 절에 적어 두었다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md index eda3aea..ad8b05e 100644 --- a/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md +++ b/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md @@ -60,6 +60,8 @@ C-1 이 세운 두 앱을 그대로 쓴다. app1 은 BFF(Backend for Frontend, | 임시 파드 | `curlimages/curl:8.11.1` · 이름 `c2probe` · `--rm` 으로 띄운다 | | 전 구간 | 약 20분 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 C-1 이 관측한 것에서 출발한다. @@ -574,6 +576,25 @@ curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/ | `HTTP 200` | 실제로 닿는다 | | `HTTP 000` | curl 이 연결조차 못 했다 = 네트워크가 원인 | + +**★ 지금 배치에서는 주소가 다르고, 닿는 까닭도 다르다**(2026-09-17, observed). 판정(`200`)은 같은데 `nslookup` 이 내놓는 주소가 `100.83.212.4` 가 아니라 엣지 게스트의 `192.168.122.10` 이다. + +```text label="탐침 파드에서 받은 그대로" +Server: 10.43.0.10 +Address: 10.43.0.10:53 + + +Name: app1.hyeonworks.com +Address: 192.168.122.10 +--- HTTPS --- +app1 200 +curl exit=0 +``` + +아래 :::warning 이 적은 「tailnet 과 split DNS 의 헤어핀」은 이제 이 실험대의 사정이 아니다. [03] 이 CoreDNS 에 `coredns-custom` 항목을 넣어 세 이름을 엣지로 보내기 때문에 풀린다. **저절로 풀리지 않는다. 넣어야 풀린다** — 그 항목을 안 넣으면 클러스터 안에서 이 이름이 안 풀리고, 이 절은 `000` 으로 끝난다. 그러면 「네트워크가 원인이 아니다」를 못 보이고 후보 ③을 배제하지 못한다. + +`kubectl -n keycloak-lab get networkpolicy` 도 같은 날 다시 쳐 `No resources found in keycloak-lab namespace.` 를 받았다(observed) — 대역이 성립한다는 근거는 그대로다. + 후보 ③은 원인이 아니다. 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. 두 줄을 읽었으면 파드에서 나온다 — `--rm` 이 나가는 순간 파드를 지운다. ```sh label="[탐침 파드] ③ 파드에서 나온다" @@ -706,7 +727,7 @@ grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml ```bash label="[lab host] ① 빌린 것을 걷고 백업을 올린다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml -curl -sI https://app2.hyeonworks.com/ | head -3 +curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` **예상 결과** — `app2` 응답이 Grafana 로 돌아간다. diff --git a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b0-default-session-store.md b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b0-default-session-store.md index 607cf6d..3252d06 100644 --- a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b0-default-session-store.md +++ b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b0-default-session-store.md @@ -79,6 +79,8 @@ error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/ | 도구 | `jq` 가 이 실험대에 없다. 빈 목록은 `grep` 으로 읽는다 | | 걸리는 시간 | 약 40~60분. 빌드 시간이 들어 있다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 코드에 저장소를 직접 만드는 빈이 없으면 무엇이 실제로 쓰이는지는 Spring Boot 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다. @@ -390,7 +392,14 @@ docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s c 두 노드에 들어갔는지 확인한다. -```bash label="[kc-lab-1] ④ 두 노드의 이미지 목록을 본다" +```bash label="[lab host] ④ 두 노드의 이미지 목록을 본다" +ssh kc-lab-1 'sudo k3s ctr images ls | grep keycloak-pattern-bff' +ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff' +``` + +원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). 두 줄 다 lab host 에서 보내면 둘 다 나온다. + +```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다" sudo k3s ctr images ls | grep keycloak-pattern-bff ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff' ``` @@ -488,7 +497,7 @@ BFF 두 개가 서로 다른 노드에 있어야 한다. `topologySpreadConstrai ### 외부 진입점이 이 애플리케이션의 HTML 을 주는가 ```bash label="[lab host] 상태 줄과 헤더를 읽는 형태로 친다" -curl -I https://app1.hyeonworks.com/ +curl -I --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/ ``` 실측은 `https://app1.hyeonworks.com/ HTTP 200` 이고(observed, `02-autoconfiguration.txt`), 응답 머리는 이렇게 생겼다. @@ -508,6 +517,18 @@ content-type: text/html `/actuator/beans` 는 117KB 이고 nginx 와 Traefik 을 거치면서 실패한다(observed, 해설 문서 1절). +**★ 지금은 밖에서도 받아진다**(2026-09-17, observed). TLS 를 세우고 같은 주소를 쳤더니 `200` 에 `155395` 바이트가 그대로 왔다. 위 `Bad Gateway` 는 엣지 nginx 의 버퍼나 프록시 체인이 지금과 다른 상태에서 잰 값으로 보인다(inferred). + +```bash label="[dev] 밖에서 받아 크기를 센다" +curl -s -o /tmp/beans-out.json -w 'code=%{http_code} size=%{size_download}\n' https://app1.hyeonworks.com/actuator/beans +``` + +```text label="그 출력" +code=200 size=155395 +``` + +**그래도 아래 「파드 안에서 받는다」를 그대로 쓴다.** 밖에서 받는 것이 이 편의 목적이 아니고, 프록시 설정이 바뀌면 다시 막힐 수 있다. 다만 `Bad Gateway` 를 「이 주소는 원래 밖에서 안 된다」로 읽지는 않는다. + ```text $ curl https://app1.hyeonworks.com/actuator/beans Bad Gateway @@ -808,8 +829,10 @@ authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-pa 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 브라우저 로그인(3절 뒤 전부)과 그 로그인이 만드는 `/actuator/beans` 재측정. 지금까지 밟은 것 — 이미지 빌드·반입, realm·클라이언트·사용자 생성, 매니페스트 적용, 빈 437개 확인. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **이 편의 주입은 2026-09-17 에도 밟지 않았다.** 막은 것이 인증서가 아니라 **소스**다 — 주입 1 절이 `keycloak-pattern` 의 파일 넷을 고치라고 하는데, 그 저장소에 이 작업과 무관한 커밋 안 된 변경이 이미 여러 개 있어 손대지 않았다. 이미지를 다시 만들지 않으면 3~5 절의 판정이 성립하지 않는다. +- **그래서 지금 실험대의 BFF 는 B-0 이 아니라 B-1·B-2 상태다**(observed). 빈 목록에 `RedisSessionRepository` · `RedisHttpSessionConfiguration` · `JdbcOAuth2AuthorizedClientService` 가 다 있다. 이 상태에서 replica 2 로 로그인하면 **성공한다** — B-0 은 같은 조건에서 실패한다고 적는다. 두 편을 가르는 것이 세션 저장소 하나임을 반대편에서 확인한 셈이다. +- **밟은 것** — 관찰 1·2(파드 안에서 빈 목록 받기, 저장소 계열 빈 뽑기)와 5 절의 토큰 경계. `bff/token-boundary` 가 `accessTokenStoredOnServer=true` · `refreshTokenStoredOnServer=true` · `browserTokenCount=0` 을 내는 것은 이 상태에서도 같다(observed). +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b1-redis-session-store.md b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b1-redis-session-store.md index c465cdc..a2916a2 100644 --- a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b1-redis-session-store.md +++ b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b1-redis-session-store.md @@ -579,6 +579,39 @@ echo "$KEY" 바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬으므로 그 출력에서 키 하나를 눈으로 골라 쳐도 되는데, 원 가이드가 그 두 단계 형태를 적어 두지 않았다(unknown). +**★ `head -1` 이 엉뚱한 세션을 집는다**(2026-09-17, observed). 원래 실행은 키가 하나였지만 둘 이상일 때가 있다. 2026-09-17 에 로그인 한 번 뒤 키가 둘이었고, `head -1` 이 고른 쪽에는 **`SPRING_SECURITY_CONTEXT` 가 없었다.** + +```text label="키 둘이 담고 있는 것" +bff:session:sessions:b66634a2-… sessionAttr:SPRING_SECURITY_SAVED_REQUEST · maxInactiveInterval · creationTime · lastAccessedTime +bff:session:sessions:c5f7b0c7-… sessionAttr:SPRING_SECURITY_CONTEXT · …AUTHORIZATION_REQUEST · SPRING_SECURITY_LAST_EXCEPTION · lastAccessedTime · maxInactiveInterval · creationTime +``` + +앞엣것은 로그인 화면으로 보내기 전에 만들어진 세션이고 인증 정보가 없다. 그것을 잡으면 아래 ③ 의 필드 목록에 `SPRING_SECURITY_CONTEXT` 가 안 나오고, 관찰 3 의 `\xac\xed` 도 못 본다 — **이 편이 보여 주려는 것이 통째로 안 보인다.** 오류는 안 나므로 「토큰이 없네」가 아니라 「인증 세션이 아니네」를 먼저 의심해야 하는데, 화면만 봐서는 갈리지 않는다. + +키를 내용으로 고르면 몇 개가 있든 맞는 것을 잡는다. + +```bash label="[lab host] ②-a 인증된 세션을 내용으로 고른다" +KEY= +for K in $(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | tr -d '\r'); do + HAS=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli hexists "$K" 'sessionAttr:SPRING_SECURITY_CONTEXT' | tr -d '\r') + echo "$HAS $K" + if [ "$HAS" = 1 ]; then KEY=$K; fi +done +echo "고른 키: $KEY" +``` + +**첫 줄의 `KEY=` 를 뺀 채로 치면 이 절이 통째 무의미해진다.** ② 가 이미 `$KEY` 를 채워 뇌기 때문에, 루프가 한 건도 못 맞혀도 마지막 `echo` 는 **② 가 잘못 고른 그 키를 그대로 찍는다.** 화면은 정상으로 보이는데 고쳐진 것이 없다. 비우고 시작해야 「못 찾았다」 가 빈 줄로 보인다. + +키마다 `1` 과 `0` 을 앞에 찍는 것도 같은 까닭이다. 걸러 내는 일을 `grep -q` 에 맡기면 어느 키가 왜 떨어졌는지가 화면에 안 남는다. + +```text label="2026-09-17 의 출력 (observed)" +1 bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23 +1 bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688 +고른 키: bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688 +``` + +**둘 다 `1` 이면 루프는 마지막 것을 잡는다.** 어느 쪽을 잡아도 `SPRING_SECURITY_CONTEXT` 는 있으니 이 날은 상관이 없었지만, 「맞는 키가 하나뿐」 을 전제하고 읽으면 안 된다. + ```bash label="[lab host] ③ 타입과 필드 이름을 본다" kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY" kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY" @@ -597,6 +630,8 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY" 필드: creationTime ``` +**2026-09-17 에 다시 치니 필드가 여섯이었다**(observed) — `sessionAttr:SPRING_SECURITY_SAVED_REQUEST` 하나가 없었고 나머지 여섯은 같았다. 로그인 전에 뭐를 요청했는지에 따라 붙었다 말았다 하는 칸이라, 이 절이 보려는 것—토큰이 없다—은 그대로다. + **이 값이 뜻하는 것** — 필드 목록에 토큰이 없다. 저장소를 직접 열어 refresh token 이 평문으로 남는지 확인하는 것이 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다. ### 3. TTL 과 값의 바이트를 본다 @@ -621,6 +656,15 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | 2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken... ``` +**★ 필드 순서를 기대하면 안 된다**(2026-09-17, observed). 위 `head -4` 는 `SPRING_SECURITY_CONTEXT` 가 첫 필드로 나온다고 보고 자른 것인데, Redis 해시의 필드 순서는 보장되지 않는다. 같은 명령을 다시 쳤을 때는 이렇게 나왔다. + +```text label="2026-09-17 의 앞 두 줄" + 1) "lastAccessedTime" + 2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number…" +``` + +**그래도 요점은 더 세게 확인된다.** `\xac\xed` 가 `SPRING_SECURITY_CONTEXT` 에만 붙는 것이 아니라 `lastAccessedTime` 의 `Long` 하나에도 붙는다. **이 해시의 값은 전부 Java 직렬화다.** 특정 필드를 보려면 `head` 로 자르지 말고 `hget "$KEY" 'sessionAttr:SPRING_SECURITY_CONTEXT'` 로 이름을 대서 꺼낸다. + `\xac\xed` 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. `--no-raw` 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다. 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다. | 결과 | | @@ -786,8 +830,8 @@ kubectl -n header-lab get svc echo 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 브라우저 로그인 뒤의 Redis 키·필드·TTL 관찰. 지금까지 밟은 것 — 이미지 빌드·반입, 배포, `/actuator/beans` 로 `RedisSessionConfiguration` 확인. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 로그인해서 Redis 키·필드·TTL 까지 봤다. 로그인은 브라우저 대신 쿠키 항아리를 쓴 `curl` 로 인가 코드 흐름을 돌렸고, 왕복 두 번과 쿠키가 그대로 재현된다. replica 2 에서 로그인이 됐고(B-0 은 여기서 실패한다) `bff/token-boundary` 가 `accessTokenStoredOnServer=true` · `browserTokenCount=0` 을 냈다. 브라우저 쪽 쿠키는 `AP3_SESSION` 48자 하나뿐이다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b2-jdbc-token-store.md b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b2-jdbc-token-store.md index 3d49d92..3aa9247 100644 --- a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b2-jdbc-token-store.md +++ b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b2-jdbc-token-store.md @@ -348,7 +348,30 @@ kubectl -n keycloak-lab exec deploy/postgres -- \ -c 'select offline_flag, count(*) from offline_user_session group by 1' ``` -온라인 세션도 `offline_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 원 가이드가 미검증으로 표시했다(unknown). 관리 API 로 보려면 이쪽이다. +온라인 세션도 `offline_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 오래 미검증이었는데, **2026-09-17 에 ②③④ 를 나란히 쳤다**(observed). + +```text label="② 의 출력 — 브라우저 로그인 전이라 0 이다" + count +------- + 0 +``` + +```text label="③ 의 출력 — 온라인 세션이 offline_flag 0 으로 들어 있다" + offline_flag | count +--------------+------- + 0 | 5 +``` + +```text label="④ 의 출력" +[ { + "offline" : "0", + "clientId" : "bff-confidential", + "active" : "4", + "id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b" +} ] +``` + +**③ 과 ④ 는 같은 숫자가 아니다.** DB 는 `5` 인데 관리 API 는 `4` 라고 답했다. 둘이 세는 단위가 다르기 때문이다 — `offline_user_session` 은 **사용자 세션**의 행이고, `client-session-stats` 는 **클라이언트 세션**을 클라이언트마다 센다. 사용자 세션 하나에 클라이언트 세션이 0개일 수도 여럿일 수도 있으므로 두 수는 맞을 이유가 없다. 「③ 은 같은 숫자를 DB 쪽에서 보는 형태」라는 위 문장은 그래서 정확하지 않다. **대조군으로 쓸 때는 둘 중 하나를 골라 끝까지 그것만 쓴다.** 관리 API 로 보려면 이쪽이다. ```bash label="[lab host] ④ 관리 API 로 세는 형태" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ @@ -460,7 +483,20 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:ses date '+%H:%M:%S 세션 삭제' ``` -이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. `redis-cli` 출력은 CR 을 달고 오므로 `xargs` 가 넘기는 키 이름이 실제 키와 안 맞을 수 있고, 그때 `del` 은 오류 없이 `(integer) 0` 을 돌려준 뒤 `date` 줄은 그대로 「세션 삭제」를 찍는다. 원 가이드의 이 줄에 그 조각이 없어 여기에도 안 넣었다(unknown). +이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. 그것이 문제가 되는지를 오래 미검증으로 두었는데, **2026-09-17 에 가렸다 — 문제가 안 된다**(observed). + +시험용 키 둘을 넣고 위 블록을 한 글자도 안 바꾸고 쳤더니 `del` 이 `5` 를 돌려주고 스캔이 빈손이 됐다. 키가 실제로 지워졌다. 출력에 CR 이 붙는지를 `od -c` 로 직접 봤더니 줄 끝이 `\n` 하나다. + +```bash label="[lab host] 스캔 출력의 줄 끝을 바이트로 본다" +kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' | od -c | head -3 +``` + +```text label="그 출력 — 줄바꿈 하나로 끝난다" +0000000 b f f : s e s s i o n : p r o b +0000020 e 3 \n +``` + +**갈리는 것은 `redis-cli` 가 아니라 `kubectl exec` 에 tty 가 붙었는지다.** 위 블록은 `-it` 없이 파이프로 받으므로 tty 가 없고 CR 도 없다. B-1 은 tty 가 붙는 형태로 쳤고 그쪽에서는 `tr -d '\r'` 이 필요하다. **그러니 이 줄은 그대로 두는 것이 맞다.** **예상 결과** — 모양은 이렇다(observed). @@ -784,8 +820,30 @@ JDBC 배선까지 걷어낼 것이면 전제와 되돌리기 절의 두 블록 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 브라우저 로그인 뒤의 `oauth2_authorized_client` 행 관찰과 `accessTokenStoredOnServer` 판정. 지금까지 밟은 것 — 표가 생겨 있고 행이 `0` 인 것까지. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. +- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 로그인한 뒤 7 절과 관찰 1·2 를 전부 밟았다. **문서의 값과 거의 그대로 나온다.** + +```text label="7 절 ② 의 실측" + client_registration_id | principal_name | access_token_type | at_len | rt_len +------------------------+----------------+-------------------+--------+-------- + keycloak | labuser | Bearer | 1437 | 744 +``` + + `rt_len` 이 `744` 로 문서와 같고 `at_len` 만 `1431` 대신 `1437` 이다 — access token 은 클레임에 따라 길이가 조금씩 달라진다. +- **오래 미검증이던 두 줄도 값을 냈다**(observed). 관찰 2 의 `left(convert_from(...), 40)` 형태가 그대로 돌고, 나온 문자열이 문서에 실린 36자와 **한 글자도 다르지 않다.** + +```text label="관찰 2 ② 의 실측 (앞 36자에서 끊는다)" +eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU +``` + +- **덮어쓰기도 재현됐다**(observed). 같은 사용자로 새 브라우저에서 한 번 더 로그인했더니 **행 수는 `1` 그대로인데 `at_md5` 가 바뀌었다.** 앞 로그인의 토큰은 그 순간 사라졌다. + +```text label="덮어쓰기 전후" +전 행 1 · at_md5 722769ac041a42164d08060e5446d02b +뒤 행 1 · at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 · 발급 2026-09-17 08:05:52 +``` + +- **남은 것** — 관찰 3(로그아웃하면 세 저장소가 다 정리되는가). 브라우저 없이 `POST /logout` 을 치려면 CSRF 토큰이 필요한데 그 구간은 밟지 않았다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b5-redis-loss.md b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b5-redis-loss.md index 4af56fb..8e8f709 100644 --- a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b5-redis-loss.md +++ b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b5-redis-loss.md @@ -150,6 +150,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly **이 값이 뜻하는 것** — 그때는 영속화가 아예 꺼져 있었다. 지금 환경은 아마 다르다 — 매니페스트가 `--appendonly yes` 로 시작하므로 `appendonly yes` 가 나오고, 그 차이가 둘째 주입의 출발 조건이다. +**★ 2026-09-17 에 쳤더니 `save` 도 비어 있지 않았다**(observed). 이 이미지의 기본 스냅샷 조건 셋이 그대로 들어 있다. 그래서 둘째 주입의 출발 조건은 「AOF 만 켠 상태」가 아니라 「AOF 와 스냅샷이 둘 다 켜진 상태」이고, 그래도 결과는 같다 — 볼륨이 없으면 둘 다 컨테이너와 함께 사라진다. + +```text +save +3600 1 300 100 60 10000 +appendonly +yes +``` + +같은 날 `redis-cli --scan` 은 아무것도 안 찍었다. 브라우저 로그인을 안 한 실험대라 세션 키가 0개였고, 그 상태에서는 「잃는 것」이 안 보인다. 로그인을 먼저 해 두라는 전제는 이 때문이다. + `save` 출력의 값이 비어 있는 것과 그 설정 자체가 없는 것은 다르다. `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 스냅샷 조건이 없다는 뜻이다. 증거의 `save = save` 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다. ### 3. `/data` 가 볼륨인가 @@ -201,7 +212,8 @@ Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에 ```bash label="[lab host] 세 경로의 상태 코드를 뽑는다" for p in / /bff/token-boundary /actuator/health; do - curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p" + curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p" done ``` @@ -229,9 +241,12 @@ done **무엇을 보는가** — 세 응답의 본문. `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다). ```bash label="[lab host] ① health 그룹 셋의 본문을 받는다" -curl -s https://app1.hyeonworks.com/actuator/health; echo -curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo -curl -s https://app1.hyeonworks.com/actuator/health/liveness; echo +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health; echo +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health/readiness; echo +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health/liveness; echo ``` **어디를 보나** — 첫 번째 응답의 본문에 `redis` 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다. @@ -392,6 +407,15 @@ kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|nett at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final] ``` +**★ 이 실험대의 로그는 다른 줄을 냈다**(2026-09-17, observed). 스택이 아니라 Lettuce 의 재연결 로그다. Service 는 남아 있고 뒤에 파드가 없으므로 `Connection refused` 이고, 위의 「맺는 중」과 달리 여기서는 즉시 거절당한 뒤 다시 시도한다. 그래도 요청 쪽은 똑같이 매달린다. + +```text +i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379 +i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [redis.keycloak-lab.svc/<unresolved>:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379 +``` + +그리고 `grep -iE 'redis|connect|netty'` 는 `connect` 때문에 Hikari 의 PostgreSQL 커넥션 경고까지 잡는다. `tail -10` 의 앞쪽 여섯 줄이 그것이었고 Redis 와 무관하다. + `pollConnect` 와 `finishConnect` 는 연결을 맺는 중이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 `000` 이 여기서 나온다. ```bash label="[lab host] ③ 엉뚱한 것을 죽이지 않았는지 본다" @@ -415,6 +439,14 @@ kubectl -n keycloak-lab get pod -l app=redis \ **빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 영속화가 잘 된다고 오독한다. +**★ 빈 줄은 안 나온다**(2026-09-17, observed). 쿠버네티스가 서비스 계정 토큰을 `kube-api-access-…` 라는 projected 볼륨으로 자동으로 붙이므로, `volumes` 배열을 통째로 지워도 새 파드에는 그 항목 하나가 다시 들어 있다. 길이가 아니라 이름을 본다 — `persistentVolumeClaim` 이 안 보이면 떨어졌다. + +```text +[{"name":"kube-api-access-d7bbm","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}] +``` + +`volumeMounts` 쪽으로 보면 더 짧다. `/data` 가 없고 `/var/run/secrets/kubernetes.io/serviceaccount` 하나만 남는다. 그리고 이 명령이 고르는 `.items[0]` 은 방금 지운 파드일 수 있다 — 같은 라벨에 `Completed` 파드가 남아 있는 것을 이 실험대에서 봤다. + **빈 줄에는 뜻이 둘이다.** 이 명령은 `app=redis` 라벨이 붙은 파드 중 첫째를 골라 그 파드의 `spec.volumes` 를 찍는다. Redis 가 0대면 고를 파드가 없어 아무것도 안 나오고, 그 화면은 볼륨을 뗐을 때와 구별되지 않는다. 그래서 이 줄을 읽기 전에 위 ① 의 `get pods -l app=redis` 로 Redis 파드가 하나 `Running` 인지부터 본다. 파드가 있는데 빈 줄이면 볼륨이 떨어졌고, 파드가 없으면 이 명령은 아직 아무 말도 하지 않았다. ```text @@ -434,7 +466,8 @@ kubectl -n keycloak-lab get pod -l app=redis \ ```bash label="[lab host] 세 경로를 다시 친다" for p in / /bff/token-boundary /actuator/health; do - curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p" + curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p" done ``` @@ -453,6 +486,19 @@ done | **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 | | `503` | 헬스 엔드포인트는 대답은 한다 — 다만 DOWN 이라고 | +**★ 위 블록의 `--max-time 90` 은 원 가이드의 `--max-time 10` 을 고친 것이다**(2026-09-17, observed). 10초에서 끊으면 `/actuator/health` 도 `000` 이 되어 세 줄이 `200`·`000`·`000` 으로 나오고, 바로 위의 실측과 안 맞는다. 이 실험대에서 그 줄은 `60.046452` 초 뒤에 `503` 을 줬고 `/bff/token-boundary` 는 90초까지 기다려도 안 왔다. 그래서 90초까지 기다리게 하고 `%{time_total}` 로 걸린 시간을 같이 찍는다. + +```text +health 503 total 60.046452 +token-boundary 000 total 90.001609 +``` + +그러니까 헬스 엔드포인트도 빨리 실패하지 않는다. 60초는 Redis 명령 타임아웃이고, 그때 돌아온 본문의 `redis` 항목은 「붙지 못했다」가 아니라 「명령이 시간을 넘겼다」였다. + +```text +"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}} +``` + 오류를 돌려주는 것이 아니라 매달려 있다. ```text @@ -473,10 +519,14 @@ done **그런데 파드는 `Ready` 를 유지한다.** 이 절차의 가장 중요한 발견이다. ```bash label="[lab host] health 그룹 셋을 코드와 본문으로 본다" -curl -s -o /dev/null -w 'health %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health -curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/readiness -curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/liveness -curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo +curl -s -o /dev/null -w 'health %{http_code} total %{time_total}\n' --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health +curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/readiness +curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/liveness +curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/actuator/health/readiness; echo ``` 실측은 이렇다(observed, `03-health-groups.txt`). @@ -565,7 +615,8 @@ deployment "redis" successfully rolled out ```bash label="[lab host] ② 회복했는지와 재시작 횟수를 본다" for p in /actuator/health /bff/token-boundary; do - curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p" + curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \ + --resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p" done kubectl -n keycloak-lab get pods -l app=bff ``` @@ -602,6 +653,17 @@ deployment "redis" successfully rolled out appendonly no ``` +**★ 설정이 되돌아가는 것은 이 실험대에서 안 보인다**(2026-09-17, observed). 데이터는 그대로 사라져 `dbsize` 가 `0` 이고 `b5:aof` 는 빈 값인데, `config get appendonly` 는 `no` 가 아니라 `yes` 다. 지금 매니페스트의 `args` 가 이미 `--appendonly yes` 라서, 「재기동하면 매니페스트가 이긴다」는 규칙이 이번에는 켜진 값을 되살린다. 규칙은 그대로 맞다. 매니페스트가 이미 `yes` 라서 되돌아가도 값이 안 바뀔 뿐이다. + +```text +0 + +appendonly +yes +``` + +볼륨을 되돌리고 같은 시험을 다시 했을 때는 `b5:pvc` 가 `written-on-pvc` 로 살아남았고 `dbsize` 는 `4` 였다. 나머지 셋은 그 사이에 BFF 가 만든 `bff:session:sessions:…` 키다. + 두 가지가 같이 사라졌다(observed, 같은 파일). | 사라진 것 | 왜 | @@ -738,7 +800,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan | BFF | `kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1`, `RESTARTS 0` | | 엔드포인트 | `… get endpointslice -l kubernetes.io/service-name=bff` | ready 주소 **둘** | | 실험 키 | `… redis-cli --scan` | `b5:*` 없음 | -| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://app1.hyeonworks.com/` | `200` | +| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/` | `200` | + +**★ 이 편의 `curl` 에 `--resolve` 가 붙어 있는 까닭이다**(2026-09-17, observed). `app1.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 이름만 치면 이 줄도 앞의 세 경로도 전부 `000` 이다. 그러면 주입 뒤와 견줄 값이 없다. 원 가이드가 적은 형태는 아래와 같고, 지우지 않고 남긴다. + +```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 세 줄이 다 000 이다" +for p in / /bff/token-boundary /actuator/health; do + curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p" +done +``` + +나머지 일곱 줄은 이 실험대에서 그대로 통과했다 — Redis `1/1`, 볼륨에 `persistentVolumeClaim`, PVC `redis-data` `Bound`, `appendonly yes`, BFF 둘 다 `1/1` 이고 `RESTARTS 0`, 엔드포인트 주소 둘, `--scan` 에 `b5:*` 없음. **로그인 세션은 돌아오지 않는다.** 브라우저에서 다시 로그인하는 것이 복구다. @@ -771,4 +843,6 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan - (unknown) 볼륨을 떼는 `patch deployment redis --type=json` 줄. **원래 실행은 반대 순서로 했다** — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 `curl` 에 로그인 쿠키가 없어 `/bff/token-boundary` 가 `3xx` 로 나오는 것도 가이드가 미검증으로 표시했다. - 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. `readiness` 그룹에 `redis` 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다. +2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정은 전부 같았다 — Redis 를 0대로 내려도 `bff` 둘이 `1/1` 이고 엔드포인트 주소 둘이 그대로 `true,true` 였고, 볼륨 없이 파드를 지우면 `dbsize 0`, 볼륨을 되돌리면 `written-on-pvc` 가 살아남았고, 네 지표는 여전히 시계열 0개였다. 화면이 달랐던 다섯 자리는 위의 ★ 다섯이다. 원문은 `relive-2026-09-17/b5-04..07` 에 있다. 브라우저 로그인 전제는 이날 밟지 않아 세션 키가 0개인 채로 쟀다(unknown) — 「로그인한 사용자가 로그아웃된다」는 이 실행에서 확인하지 않았다. + <!-- body:end --> diff --git a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md index 296de81..ede42e9 100644 --- a/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md +++ b/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md @@ -53,6 +53,8 @@ realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공 | 전 구간 | 약 15분. 주입 검증까지는 아무것도 안 깨진다 | | 도구 | `jq` 가 이 실험대에 없다. JSON 은 `tr` 과 `grep` 으로 자른다 | +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 실험이 가르는 것 암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다. @@ -172,9 +174,23 @@ No server specified. Use --server, or 'kcadm.sh config credentials'. **무엇을 보는가** — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다. ```bash label="[lab host] ① JWKS 를 자르지 않고 본다" +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` + +**`--resolve` 가 붙은 까닭이다**(2026-09-17, observed). 원 가이드는 이름만 쳤는데 `auth.hyeonworks.com` 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 듣는 것이 없다. 엣지 nginx 가 게스트로 옮겨 간 뒤로 그렇다. + +```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 000 이다" curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs ``` +```text label="원 가이드 형태를 랩 호스트에서 쳤을 때" +000 +Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server +``` + +tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿는다. 랩 호스트에서 쳐야 해서 `--resolve` 를 붙였고, 이 편의 `[lab host]` 블록 열두 줄에 전부 같은 것이 걸린다. + 줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, `01-before-rotation.txt`). ```text @@ -184,7 +200,8 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con 그 뒤로 `kty` · `alg` · `use` · `n` · `e` 가 이어지고 다음 키가 온다. `kid` 마다 `alg` 가 따로 붙는다. 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다. ```bash label="[lab host] ② kid 만 뽑아 본다" -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr ',' '\n' | grep kid ``` @@ -210,21 +227,35 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con JWKS 는 키 하나가 `}` 로 끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다. -```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다 (unknown)" -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다" +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr '}' '\n' | grep -c RS256 ``` Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다. -```bash label="[lab host] ② Keycloak 에 직접 묻는다 (unknown)" +```bash label="[lab host] ② Keycloak 에 직접 묻는다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get keys -r keycloak-patterns ``` **어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다. -**이 값이 뜻하는 것** — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다. +**2026-09-17 에 둘 다 쳐 봤고 둘 다 돈다**(observed). 원래 실행 기록에는 출력이 없어 미검증으로 두었던 것인데, 이제 값이 있다. + +```text label="① 의 출력" +1 +``` + +```text label="② 의 출력에서 kid·status·algorithm 만 뽑은 것" +"kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917" "status" : "ACTIVE" "algorithm" : "AES" +"kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c" "status" : "ACTIVE" "algorithm" : "HS512" +"kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" "status" : "ACTIVE" "algorithm" : "RS256" +"kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" "status" : "ACTIVE" "algorithm" : "RSA-OAEP" +``` + +① 이 내는 `1` 과 3단계의 `kid` 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 RS256 이 하나, 암호화용 `RSA-OAEP` 가 하나이며 JWKS 에는 그 둘만 실린다. `AES` 와 `HS512` 는 JWKS 에 안 나온다. ### 5. 시험체가 될 옛 토큰을 하나 받아 둔다 @@ -236,7 +267,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token CS=$(kubectl -n keycloak-lab get secret bff-secrets \ -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d) -OLD=$(curl -s -X POST "$KC" \ +OLD=$(curl -s -X POST --resolve auth.hyeonworks.com:443:192.168.122.10 "$KC" \ -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \ -d username=labuser -d password=labpass -d scope=openid \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') @@ -295,14 +326,16 @@ echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \ **무엇을 보는가** — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다. ```bash label="[lab host] ① 상태줄과 본문을 함께 본다" -curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me +curl -s -i -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` 200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다. ```bash label="[lab host] ② 상태 코드만 뽑는다" curl -s -o /dev/null -w 'old %{http_code}\n' \ - -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` **어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`). @@ -359,7 +392,8 @@ Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16' ### 9. JWKS 에 옛 키가 남아 있는가 ```bash label="[lab host] 3 절과 똑같은 줄을 다시 친다" -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr ',' '\n' | grep kid ``` @@ -401,9 +435,11 @@ echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo ```bash label="[lab host] 두 토큰을 같은 두 줄로 친다" curl -s -o /dev/null -w 'old %{http_code}\n' \ - -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me curl -s -o /dev/null -w 'new %{http_code}\n' \ - -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` 실측은 이렇다(observed, `02-rotation.txt`). @@ -480,7 +516,8 @@ date '+%H:%M:%S 제거' ### 14. JWKS 에서 사라졌는지 본다 ```bash label="[lab host] 또 같은 줄을 친다" -curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ +curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \ | tr ',' '\n' | grep kid ``` @@ -501,9 +538,11 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con ```bash label="[lab host] 11 절과 똑같은 두 줄" curl -s -o /dev/null -w 'old %{http_code}\n' \ - -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me curl -s -o /dev/null -w 'new %{http_code}\n' \ - -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me + -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \ + https://app1.hyeonworks.com/api/me ``` 실측은 이렇다(observed, `03-old-key-removed.txt`). @@ -516,6 +555,27 @@ curl -s -o /dev/null -w 'new %{http_code}\n' \ 제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다. +**★ 2026-09-17 에 다시 재 보니 그 401 은 절반만 맞았다**(observed). 같은 토큰으로 여덟 번 연속 쳤더니 이렇게 나왔다. + +```text label="제거 직후, 같은 토큰으로 여덟 번" +401 200 401 200 401 200 401 200 +``` + +**`echo` 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로이기 때문이다.** 한쪽은 목록을 새로 받아 옛 키를 잃었고(401) 다른 쪽은 아직 들고 있다(200). Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐에 따라 결과가 갈린다. 파드가 둘 다 `1/1 Running` 인 것은 같은 순간에 확인했다. + +한 번만 쳐서는 이것이 안 보인다. 처음 두 번을 쳤을 때 이렇게 갈렸다. + +```text label="같은 상태를 한 번씩 쳤을 때" +제거 6초 뒤 처음 친 것 old 200 ← 캐시가 살아 있는 replica +제거 6초 뒤 다시 친 것 old 401 ← 목록을 새로 받은 replica +``` + +**그래서 이 절의 판정은 「즉시 401」이 아니라 「인스턴스마다 다르다」다.** 운영에서 더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「유예가 없다」로 닫은 것은 한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred). + +**replica 를 1 로 줄이면 이 흔들림이 사라진다.** 무엇을 재려는지에 따라 고른다 — 「제거가 반영되는가」를 보려면 1 로, 「운영에서 무엇이 보이는가」를 보려면 2 로 둔다. + +**아래 16 절이 이것을 가르는 단계인데, 재시작이 34초 걸려 60초짜리 토큰으로는 전후를 같은 토큰으로 못 견준다**(observed). 재시작 뒤의 `old 401` 은 캐시가 비워져서인지 토큰이 만료돼서인지 갈리지 않는다. 가르려면 `accessTokenLifespan` 을 늘리거나, 위처럼 **재시작 없이 연속으로 쳐서** 두 replica 의 답이 갈리는 것을 보는 편이 빠르다. + **`$OLD` 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다.** 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown). ### 16. 리소스 서버를 재시작해 캐시를 비운다 @@ -566,12 +626,12 @@ deployment "echo" successfully rolled out 수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown). -```bash label="[lab host] realm 의 수명 세 값을 받는다 (unknown)" +```bash label="[lab host] realm 의 수명 세 값을 받는다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan ``` -겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다. +**2026-09-17 에 쳤더니 두 값만 돌아왔다**(observed) — `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이고 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다. 겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다. ```text 쓰기: 새 key 하나로만 @@ -623,9 +683,28 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** -- **남은 것** — 키 공급자 추가·삭제와 `echo` 가 내는 `401`/`200` 판정 전부. `echo` 가 JWKS 를 `https://auth.hyeonworks.com` 에서 받으므로 인증서가 서야 한다. 지금까지 밟은 것 — realm·사용자·`echo` 배포, JWKS 와 `kid` 읽기, 공급자 목록. -- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. -- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. +- **밟았다** — 키 공급자 추가와 삭제, 그리고 그 둘이 JWKS 와 토큰 `kid` 를 어떻게 바꾸는지 전부. 8·9·10·12·13·14 절이 적어 둔 대로 나왔다(observed). 공급자를 추가하니 RS256 이 1 에서 2 로 늘고 새 토큰의 `kid` 가 `priority` 200 짜리 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 `kid` 가 목록에서 사라졌다. +- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 `echo` 가 JWKS 를 받게 되어 7 절의 대조군 `old 200` 이 나왔고, 8~15 절을 토큰 수명 안(6초)에 끝냈다. 결과는 **11 절까지 문서 그대로**(추가는 무중단, `old 200` · `new 200`)이고 **15 절에서 갈렸다** — 위 ★ 를 본다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)가 있어야 한다. 밖에서 닿는 길은 열렸다 — 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 2026-09-17 에 들어갔고 `http` 는 밖에서 `200` 이다(observed). + +**11·15 절이 만료를 가르라고 준 두 줄은 이 실험대에서 틀린 답을 낸다**(2026-09-17, observed). `exp` 는 Keycloak 이 게스트 시계로 찍고 `date +%s` 는 랩 호스트 시계로 찍는데, **그 둘이 93초 어긋나 있다.** + +```bash label="[lab host] 기계마다 같은 순간에 친다" +date +%s +timedatectl show -p NTP -p NTPSynchronized +``` + +```text label="같은 순간의 값" +lab host 1789629566 NTP=no NTPSynchronized=no +kc-lab-1 1789629472 NTP=yes NTPSynchronized=yes +kc-lab-2 1789629473 NTP=yes NTPSynchronized=yes +kc-lab-edge 1789629473 +``` + +게스트 셋은 서로 맞고 랩 호스트만 93초 앞선다. 그래서 방금 받은 60초짜리 토큰도 랩 호스트에서 `exp` 를 재면 **이미 33초 전에 만료된 것으로 읽힌다.** D-4 와 D-4a 가 같은 호스트에서 `NTPSynchronized=no` 와 `+106.1` 을 이미 재 두었는데, 이 편은 그것을 모르는 채로 `exp` 비교를 시킨다. 가르려면 두 값을 같은 기계에서 뽑는다 — `kubectl -n keycloak-lab exec keycloak-0 -- date +%s` 로 Keycloak 쪽 시각을 받아 견준다. +- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. +- **realm 의 수명 세 값은 이제 실측이 있다**(observed). 위에서 미검증으로 표시한 줄을 쳐 보니 `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이다. 세 번째 값 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다. +- **그때까지 이 편의 `(observed)` 2026-09-17 값은 위 「밟았다」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 diff --git a/docs/virtualization/final/document.md b/docs/virtualization/final/document.md index 7c172dc..ffc0601 100644 --- a/docs/virtualization/final/document.md +++ b/docs/virtualization/final/document.md @@ -8325,6 +8325,21 @@ source ~/.bashrc virsh uri ``` +libvirt 쪽 설정도 같다. 디렉터리는 명령으로 만들고 파일은 편집기로 연다. + +```bash +mkdir -p ~/.config/libvirt +nano ~/.config/libvirt/libvirt.conf +``` + +그 파일에 이 줄을 더한다. + +```text +uri_default = "qemu:///system" +``` + +이 나눈 형태는 이 실험대에서 치지 않았다(unknown). + **확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가 ```bash @@ -8414,16 +8429,68 @@ virsh list --all - (observed) libvirt `12.7.0` · `QEMU emulator version 11.1.1` · 그룹 세 개 · `default` 네트워크 `active`/autostart `yes` · `virsh uri` 가 `qemu:///system` -- (unknown) `lsmod | grep kvm` 의 출력은 캡처해 두지 않았다. 가이드도 줄 모양만 - 적고 값을 싣지 않았다 -- (unknown) 가이드의 실측 줄은 「이 실험대의 호스트는 16 코어 전부에서 - 지원한다」인데 §178 의 대상 환경은 **논리 코어 8**(i5-1135G7)이다. 두 값이 - 어긋나고, 어느 쪽이 이 호스트의 값인지는 재지 않았다 +- (observed) `lsmod | grep kvm` 의 출력을 2026-09-17 에 받았다. 세 줄이고 마지막 + 칸이 그 모듈을 쓰는 수다 + +```text +kvm_intel 524288 11 +kvm 1490944 6 kvm_intel +irqbypass 16384 1 kvm +``` + +- (observed) **이 호스트는 논리 코어 8 이다.** 가이드의 「16 코어 전부」가 아니라 + §178 의 값이 맞다. 2026-09-17 에 `nproc` 이 `8`, `lscpu` 가 + `11th Gen Intel(R) Core(TM) i5-1135G7`, 물리 4 코어에 코어당 스레드 2 라고 답했다 + +```bash +nproc +lscpu | grep -E "^Model name|^CPU\(s\):|^Core\(s\)|^Thread\(s\)" +``` + +- (observed) `systemctl status libvirtd.socket` 도 같은 날 쳤다. 보는 줄은 `Active` + 와 `Listen` 과 `Triggers` 셋이고, `Triggers` 가 `libvirtd.service` 를 가리키는 + 것이 소켓 활성화가 걸렸다는 뜻이다 + +```text +● libvirtd.socket - libvirt legacy monolithic daemon socket + Loaded: loaded (/usr/lib/systemd/system/libvirtd.socket; enabled; preset: disabled) + Active: active (running) since Thu 2026-09-03 19:00:35 KST; 1 week 6 days ago + Triggers: ● libvirtd.service + Listen: /run/libvirt/libvirt-sock (Stream) +``` + +- (observed) 볼륨 일곱이 실제로 먹는 양도 그날 쟀다. **선언한 크기와 실제 할당이 + 크게 다르다** — 오버레이라 쓴 만큼만 먹는다. 철거 절의 「엣지 디스크는 재 두지 + 않았다」와 거기서 역산한 「1GB 안팎」이 이 값으로 다시 보인다 + +```bash +virsh vol-info --pool default kc-lab-edge.qcow2 +``` + +```text +base.qcow2 Capacity=3.00 GiB Allocation=323.25 MiB +kc-lab-1.qcow2 Capacity=20.00 GiB Allocation=5.40 GiB +kc-lab-2.qcow2 Capacity=20.00 GiB Allocation=3.85 GiB +kc-lab-edge.qcow2 Capacity=10.00 GiB Allocation=316.26 MiB +seed-kc-lab-{1,2,edge}.iso Capacity=370.00 KiB Allocation=372.00 KiB +``` + +- (observed) `cloud-init status --long` 의 마지막 줄이 시드를 `bus=virtio` 로 붙이는 + 이유를 그대로 뒷받침한다. `seed=/dev/vdb` 이므로 CD-ROM(`sr0`)이 아니라 디스크로 + 잡혔고, cloud-init 이 거기서 데이터소스를 찾았다 + +```text +status: done +boot_status_code: enabled-by-generator +last_update: Thu, 17 Sep 2026 04:24:22 +0000 +detail: +DataSourceNoCloud [seed=/dev/vdb][dsmode=net] +``` + - (unknown) **원본 가이드에 되돌리는 절차가 없다.** 패키지·그룹·유닛·`.bashrc`· 네트워크 autostart 를 걷어내 본 적이 없다 -- (unknown) `sudo modprobe kvm_intel` 과 `systemctl status libvirtd.socket` 은 - 막혔을 때 치라고 가이드가 적어 둔 것이고, 이 실험대에서는 막히지 않아 치지 - 않았다 +- (unknown) `sudo modprobe kvm_intel` 은 막혔을 때 치라고 가이드가 적어 둔 + 것이고, 이 실험대에서는 막히지 않아 치지 않았다 - (external) Debian/Ubuntu 패키지 이름은 가이드가 참고로 적어 둔 것이고 이 실험대는 Arch 로 세웠다 @@ -8548,6 +8615,24 @@ cat ~/.ssh/id_ed25519.pub openssl rand -base64 18 ``` +**따라 하는 사람은 ① 을 둘로 나눈다.** `[ -f … ] || …` 는 「키가 있으면 두고 +없으면 만든다」를 한 줄에 접어 넣은 형태라, 배울 것보다 단축 평가를 먼저 읽게 +된다. 먼저 꺼내 보고, 없을 때만 만든다. + +```bash +cat ~/.ssh/id_ed25519.pub +``` + +`ssh-ed25519 …` 한 줄이 나오면 키가 이미 있는 것이고 아래를 건너뛴다. +`No such file or directory` 면 키가 없다. + +```bash +ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 +``` + +만든 뒤 다시 `cat` 해서 공개키 줄을 꺼낸다. 이 나눈 형태는 이 실험대에서 치지 +않았다(unknown). + **이 실험대는 이렇게 했다**(원본 01 본문) — 자리표시자 셋을 `sed` 로 한 번에 바꿨다. @@ -9023,9 +9108,20 @@ sudo journalctl -u cloud-init -n 50 제7부 §198 이 따로 잰다 - (unknown) `kc-lab-1.yaml` 을 템플릿에서 어떻게 만드는지가 원본에도 없다. `kc-lab.yaml.example` 이 `source/` 에 없어 대조하지 못했다 -- (unknown) cloud-init 의 `packages` 에 certbot 이 들어 있었는지가 가이드 안에서 - 갈린다. 01 의 예시와 03 의 본문은 `curl`·`nftables` 뿐이라 적고, 04 는 - `kc-lab.yaml.example` 의 `packages` 에 certbot 이 있다고 적는다 +- (observed) **cloud-init 의 `packages` 에 certbot 은 없다** — 2026-09-17 에 + 갈렸다. 뜬 게스트가 실제로 받은 user-data 가 `packages: [curl, nftables]` 한 + 줄이고, `kc-lab.yaml.example` 에는 `nginx`·`certbot`· + `python3-certbot-dns-cloudflare` 셋이 **주석으로 막혀** 있다. 04 의 표에 + `kc-lab-edge` 가 `nginx · certbot` 으로 적힌 것은 그 게스트가 끝나면 맡을 + 역할이지 cloud-init 이 깔아 준 것이 아니다 + +```bash +sudo grep -n "packages" /var/lib/cloud/instance/user-data.txt +``` + +```text +19:packages: [curl, nftables] +``` - (unknown) `scp` 다섯 줄 형태, `virsh vol-list`·`vol-info`·`net-dumpxml --inactive`·`cloud-init status --long`·`virsh console` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다 @@ -9230,7 +9326,31 @@ ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube ``` `IP Address:127.0.0.1` 과 `IP Address:192.168.122.11` 이 둘 다 보인다. 8번의 SSH -터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다. +터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다. **2026-09-17 에 그 출력을 실제로 +받았다**(observed). + +```text +X509v3 Subject Alternative Name: + DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, + DNS:kubernetes.default.svc.cluster.local, DNS:localhost, DNS:kc-lab-1, + IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, + IP Address:192.168.122.11, IP Address:10.43.0.1, IP Address:192.168.122.11 +``` + +`192.168.122.11` 이 두 번 들어 있는데 중복이고 동작에는 영향이 없다. `10.43.0.1` 은 +클러스터 안에서 API 서버를 부르는 Service 주소다. + +**그리고 `journalctl -u k3s-agent` 는 정상일 때도 `E` 로 시작하는 줄이 가득하다**(observed). +이것을 모르면 「막혔을 때 여기를 보라」는 안내대로 열었다가 멀쩡한 노드를 고장으로 읽는다. +같은 순간 `kubectl get nodes` 는 둘 다 `Ready` 였다. + +```text +E0917 07:42:16.919786 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55428: use of closed network connection" +``` + +`kubectl exec` 나 `logs` 로 연결했다가 끊을 때마다 한 줄씩 남는다. **봐야 할 것은 `E` 인지가 +아니라 문구다** — 토큰이나 주소가 틀렸으면 `failed to get CA certs` 나 `401 Unauthorized` 가 +나온다. **3. 토큰을 꺼낸다** @@ -9360,6 +9480,32 @@ rm ~/node-token 이 여덟 줄 형태는 2026-09-17 에 쳤다 — 설치 자체는 이 판으로 다시 돌리지 않았고, 유닛을 고쳐 다시 시작하는 쪽만 확인했다(observed). +**따라 하는 사람은 유닛을 편집기로 연다.** `sed -i` 는 패턴이 안 맞아도 조용히 성공하고, +여기 패턴에는 `$HOME` 이 들어 있어서 설치할 때와 다른 계정으로 치면 아무 줄도 안 바뀐 +채 `daemon-reload` 와 `restart` 가 이어서 돈다. 그 뒤 노드는 지금 떠 있으니 정상으로 +보이고, 다음 재부팅에서야 `Waiting for file` 로 멎는다. + +```bash +sudo nano /etc/systemd/system/k3s-agent.service +``` + +`ExecStart=` 아래에서 `'--token-file'` 다음 줄의 경로를 `/etc/rancher/node-token` 으로 +고친다. 고친 뒤 그 두 줄이 이렇게 보인다. + +``` + '--token-file' \ + '/etc/rancher/node-token' \ +``` + +저장하고 나와서 유닛을 다시 읽힌다. + +```bash +sudo systemctl daemon-reload +sudo systemctl restart k3s-agent +``` + +이 편집기 형태는 이 실험대에서 치지 않았다(unknown). + **확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가 ```bash @@ -10186,11 +10332,34 @@ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks. 301 https://auth.hyeonworks.com/ ``` -**★ 이 `301` 도 04 이후의 값이다.** 03 까지만 한 상태라면 ② 와 같은 `404` 가 -정상이고, 이 층이 묻는 것은 코드값이 아니라 **「밖에서 친 것이 엣지까지 -닿았는가」**다 — ② 와 같은 응답이 밖에서도 오면 DNAT 과 구멍이 살아 있다는 뜻이다. +**★ 이 `301` 은 04 이후의 값이다.** 03 까지만 했을 때 나오는 것은 `302` 이고 보내는 +곳도 다르다. 2026-09-17 에 구멍을 뚫고 밖에서 재 보니 이랬다(observed). -2026-09-17 에 밖에서 재 보니 **닿지 않았다**(observed). +``` +302 https://auth.hyeonworks.com/admin/ +``` + +**리다이렉트를 내는 것이 둘이다.** 04 를 끝내면 엣지 nginx 의 +`return 301 https://$host$request_uri` 가 먼저 답해 `301 https://auth.hyeonworks.com/` +이 되고, 03 까지만 했으면 그 블록이 없어 요청이 Keycloak 까지 가서 **Keycloak 이** `/` +를 `/admin/` 으로 보낸다. 코드도 `301` 이 아니라 `302` 다. `Location` 이 `https://` 인 +것도 Keycloak 이 정한다 — `KC_HOSTNAME=https://auth.hyeonworks.com` 과 +`KC_HOSTNAME_STRICT=true` 가 그렇게 시키고, **요청이 `http` 로 들어와도 자기가 아는 +주소로 답한다.** + +**같은 엣지에 어떻게 치느냐가 코드를 가른다**(observed). + +| 어떻게 치나 | 코드 | +|---|---| +| Host 없이 엣지 IP 로 (층 ②) | `404` — Traefik 에 매칭되는 Ingress 가 없다 | +| `Host: auth.hyeonworks.com` 으로 엣지 IP 에 | `302 https://auth.hyeonworks.com/admin/` | +| 밖에서 도메인으로 (층 ③) | `302 https://auth.hyeonworks.com/admin/` | +| 밖에서 도메인 + `/realms/master` | **`200`** | + +마지막 줄이 중요하다 — **TLS 를 얹기 전에도 `/realms/master` 는 `200` 이다.** 층 ④ 의 +`200` 은 「TLS 가 섰다」가 아니라 「끝까지 이어졌다」를 재는 값이다. + +구멍을 뚫기 전에는 같은 `curl` 이 이렇게 끝났다(observed). ``` curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms @@ -10204,7 +10373,8 @@ curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms **확인 ④ 끝까지 닿나** (TLS 이후) ```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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ``` @@ -10216,7 +10386,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast 번은 헤더까지 보고, 그다음부터 이 형태로 줄인다. ```bash -curl -I https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` **이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다 @@ -10309,6 +10479,10 @@ error 로그에 있었는데 잘려 있었고, access 로그에는 **3492자** 구멍이 필요한지는 재지 않았다(§183) - **되돌리기는 가이드 7편 가운데 이 편에만 있다.** 위 네 줄이 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown) +- (unknown) 2026-09-17 에 더한 CoreDNS 매니페스트(`coredns-custom`)는 가이드의 + 되돌리기 표에 없다. 걷어내려면 `kubectl -n kube-system delete configmap coredns-custom` + 하고 `kubectl -n kube-system delete pod -l k8s-app=kube-dns` 인데 이 실험대에서 + 치지는 않았다 **배포된 것과 적어 둔 것이 다르다** @@ -10341,11 +10515,250 @@ grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service 써서 `sudo systemctl daemon-reload && sudo systemctl restart lab-edge-dnat.service` 로 올린다. -**그런데 고쳤는지는 이번에 확인하지 못했다**(unknown). 이 호스트의 `sudo` 는 -비밀번호를 묻고 이번 작업에는 그 비밀번호가 없었다. 그래서 `nft list table ip -lab_edge` 도 `guest_input` 체인 조회도 못 돌렸고, 두 파일을 다시 배포하지도 -않았다. 위 표는 **파일 내용과 타임스탬프만으로** 적은 것이고, `reject` 줄의 -카운터가 실제로 올라갔는지는 안 봤다. +**2026-09-17 늦게 그 `sudo` 가 열려 셋을 마저 봤다**(observed). 결론은 **절반만 +고쳐져 있다**이다. + +```text +grep -c 'chain forward' /etc/nftables.d/lab-edge-dnat.nft → 1 (0 이어야 한다) +grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service → 1 (맞다) +``` + +유닛에는 `ExecStartPost` 가 들어갔는데 `.nft` 는 아직 옛 판이다 — 효과 없는 +`forward` 체인을 아무도 걷어내지 않았다. `.nft` 를 다시 쓰고 +`sudo systemctl restart lab-edge-dnat.service` 로 올려야 §180 이 싣는 판이 된다. + +```bash +sudo nft list chain ip libvirt_network guest_input +``` + +```text +oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 44 bytes 2640 accept +oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 0 bytes 0 accept +oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 141705 bytes 2375116597 accept +oif "virbr0" counter packets 5 bytes 300 reject +oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 10 bytes 600 accept +``` + +**구멍이 하나가 아니라 셋이다.** `ExecStartPost` 가 `nft insert` 로 맨 앞에 끼워 +넣으므로 유닛을 다시 시작할 때마다 같은 규칙이 한 줄씩 는다 — **여러 번 쳐도 안전한 +형태가 아니다.** 기능은 멀쩡하다. 맨 앞 하나가 통과시키고 나머지는 카운터가 안 +오르거나(둘째 `0`) 아예 닿지 않는다(다섯째는 `reject` 뒤다). `reject` 의 `5` 는 +구멍이 들어가기 전에 막힌 것이고 그 뒤로는 안 오른다. + +**그리고 출발지를 덮는 것이 무엇인지도 그때 갈렸다 — Tailscale 이다**(observed). + +```bash +sudo nft list ruleset | grep -nE 'masquerade|snat' +sudo iptables -t nat -S | grep ts-postrouting +sudo tailscale debug prefs | grep -i snat +``` + +```text +chain ts-forward { iifname "tailscale0" counter ... xt target "MARK" } +-A ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE +"NoSNAT": false +``` + +`tailscale0` 으로 들어와 전달되는 패킷에 Tailscale 이 표식 `0x40000` 을 찍고, +`ts-postrouting` 이 그 표식이 붙은 것을 전부 `MASQUERADE` 한다. 나가는 인터페이스가 +`virbr0` 이라 출발지가 `192.168.122.1` 이 된다. `NoSNAT` 이 `false` 인 것이 그 동작이 +켜져 있다는 뜻이고 그것이 Tailscale 의 기본값이다. + +**그래서 §180 의 경고는 옳았는데 엉뚱한 테이블을 지켰다.** 「masquerade 를 붙이면 +엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 된다」는 그대로 일어났고, 다만 그 +`masquerade` 를 붙인 것이 우리 테이블이 아니라 Tailscale 이다. libvirt 의 마스커레이드는 +무관하다 — `ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24` 이라 게스트에서 밖으로 +나가는 것만 고르고, 밖에서 들어오는 이 패킷은 목적지가 그 대역 안이라 안 걸린다. + +**클러스터 안에서도 공개 이름에 못 닿는다**(2026-09-17, observed). + +랩 호스트만의 일이 아니다. 파드 안에서도 같은 이름이 같은 이유로 막힌다. + +```bash +kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com" +``` + +```text +100.83.212.4 auth.hyeonworks.com +``` + +같은 파드에서 `100.83.212.4` 의 80 과 443 을 두드리면 둘 다 닫혀 있고, 엣지 게스트의 +`192.168.122.10:80` 은 열려 있다. **길은 있는데 이름이 그 길을 안 가리킨다.** 클러스터 +DNS 어디에도 그 매핑이 없다 — CoreDNS 의 `NodeHosts` 에는 노드 둘뿐이고 노드의 +`/etc/hosts` 에도 없으며, `deploy/lab/k8s/` 의 매니페스트 여덟 장에도 없다. + +nginx 가 호스트에 있던 동안에는 파드가 `100.83.212.4:443` 을 치면 그 nginx 에 닿았다. +03 이 옮기면서 그 경로가 끊겼고, 대신 놓아 줄 것을 어느 문서도 안 놓는다. + +고치는 한 단계는 CoreDNS 에 서버 블록을 하나 더 주는 것이다. **이 실험대는 매니페스트를 +힙독으로 흘려 넣었다**(observed). + +```bash +kubectl apply -f - <<'YAML' +apiVersion: v1 +kind: ConfigMap +metadata: + name: coredns-custom + namespace: kube-system +data: + hyeonworks.server: | + hyeonworks.com:53 { + hosts { + 192.168.122.10 auth.hyeonworks.com + 192.168.122.10 app1.hyeonworks.com + 192.168.122.10 app2.hyeonworks.com + fallthrough + } + forward . /etc/resolv.conf + } +YAML +kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false +``` + +**따라 하는 사람은 매니페스트를 파일로 쓴다.** 힙독은 이 ConfigMap 을 터미널 명령 안에 +묻어 버려서, 나중에 서버 블록을 고치려면 같은 힙독을 처음부터 다시 친다. 파일로 두면 +`kubectl apply` 를 다시 쳐서 고칠 수 있고 무엇이 들어갔는지도 열어서 본다. + +```bash +nano coredns-custom.yaml +``` + +파일에 위 힙독 안의 `apiVersion:` 부터 마지막 `}` 까지를 그대로 쓴다. 그리고 적용한 뒤 +CoreDNS 파드를 다시 띄운다. + +```bash +kubectl apply -f coredns-custom.yaml +kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false +``` + +이 나눈 형태는 이 실험대에서 치지 않았다(unknown). + +**파일 이름이 `.server` 인 것이 핵심이다.** k3s 는 `*.override` 를 기본 서버 블록 안에 +끼워 넣는데 그 블록은 이미 `hosts /etc/coredns/NodeHosts` 로 `hosts` 를 한 번 쓴다. +`.override` 에 `hosts` 를 또 쓰면 CoreDNS 가 `CrashLoopBackOff` 로 안 뜬다. + +```text +plugin/hosts: this plugin can only be used once per Server Block +``` + +`.server` 는 별개의 서버 블록으로 들어가므로 그 안에서 `hosts` 를 처음 쓰는 것이 된다. +뜨고 나면 로그 머리에 듣는 영역이 둘로 찍히고, 파드에서 이름이 엣지로 풀린다. + +```text +.:53 +hyeonworks.com.:53 +``` + +```text +192.168.122.10 auth.hyeonworks.com +``` + +`192.168.122.10:80` 이 파드에서 열린다. **443 은 여전히 닫혀 있다** — 그것은 04 가 +인증서를 얹어야 열린다. + +**03 을 밟고 나면 랩 호스트에서 공개 URL 을 못 친다**(2026-09-17, observed). + +nginx 가 호스트에 있던 동안에는 호스트에서 `curl https://auth.hyeonworks.com` 이 +그냥 됐다. 03 이 nginx 를 엣지 게스트로 옮기면서 그것이 끊긴다. 80 도 443 도 +안 열린다. + +```bash +curl -s -m 10 -o /dev/null -w "%{http_code}\n" https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` + +```text +000 +Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server +``` + +이유는 두 줄이다. + +```bash +getent hosts auth.hyeonworks.com +ss -lnt | grep -E ":(80|443) " +``` + +```text +100.83.212.4 auth.hyeonworks.com +``` + +두 번째 줄은 아무것도 안 낸다. `auth.hyeonworks.com` 은 호스트 자신의 tailnet +주소로 풀리는데 호스트에는 그 포트를 듣는 것이 없고, 03 의 DNAT 은 `iifname +"tailscale0"` 만 매칭하므로 호스트가 스스로 낸 패킷은 그 규칙을 안 탄다. §190 이 +자기 4번 확인을 엣지가 아닌 다른 머신으로 보낸 것과 같은 이유다. + +**이 전제가 실험 기록 14편 · 명령 55줄에 걸려 있다**(observed). `[lab host]` 라벨이 +붙은 코드블록 안에서 `auth`·`app1`·`app2` 의 공개 URL 을 치는 줄을 센 것이고, +많은 쪽부터 B-6(12) · B-5(10) · D-1(9) 순이다. 그 줄들은 nginx 가 호스트에 있던 +때의 배치로 쓰였고 지금 배치에서는 `000` 을 낸다. + +랩 호스트에서 꼭 쳐야 하면 이름은 그대로 두고 주소만 엣지로 못박는다. + +```bash +curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \ + http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` + +이 형태는 응답을 받는다(observed). 다만 DNAT 을 안 거치므로 **층 ③ 을 재는 것이 +아니다.** 층 ② 를 도메인 이름으로 재는 것이고, 4·5 번이 제대로 들어갔는지는 +여전히 밖에서 쳐야 갈린다. + +**`X-Forwarded-For` 계약은 이 실험대에서 성립하지 않는다**(2026-09-17, observed). + +위 4번이 SNAT 을 안 거는 이유로 「masquerade 를 붙이면 엣지가 모든 클라이언트를 +`192.168.122.1` 로 보게 된다」고 적었고, §215 의 표도 그 줄을 감수한 비용으로 +싣는다. 그런데 밖에서 한 번 치고 엣지의 로그를 열었더니 **이미 그 상태였다.** + +```bash +curl -s -o /dev/null http://app1.hyeonworks.com/api/echo +``` + +```bash +sudo tail -3 /var/log/nginx/access.log +``` + +```text +192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0" +``` + +`lab_edge` 테이블에는 `masquerade` 도 `snat` 도 없다 — 위에 실은 `.nft` 파일 +그대로다. 그러므로 출발지를 덮는 것은 이 테이블이 아니고, **무엇이 덮는지는 가리지 +못했다**(unknown). 그것을 보려면 호스트에서 `nft list ruleset` 을 쳐야 하는데 그 +`sudo` 가 비밀번호를 묻는다. 결과만 놓고 보면 `proxy_set_header X-Forwarded-For +$remote_addr` 는 시키는 대로 동작하고 있고 `$remote_addr` 가 이미 호스트의 브리지 +주소다. §259 가 적은 신뢰 경계 논의는 그대로 유효하지만, **이 실험대에서 그 헤더로 +클라이언트를 가릴 수는 없다.** + +**Traefik 이 그 헤더를 한 번 더 덮는다 — 03 에 없던 한 단계**(2026-09-17, observed). + +2번이 세운 헤더가 앱까지 가는지를 처음으로 쟀다. 안 간다. Traefik 은 자기가 +신뢰하지 않는 곳에서 온 `X-Forwarded-*` 를 자기 연결 기준으로 다시 쓰고, 기본값의 +신뢰 목록은 비어 있다. + +```bash +curl -s http://app1.hyeonworks.com/api/echo +``` + +| 헤더 | 이 한 단계를 안 밟았을 때 | 밟은 뒤 | +|---|---|---| +| `x-real-ip` | `10.42.0.1` — flannel 게이트웨이. 엣지가 보낸 값이 사라졌다 | `192.168.122.1` — 엣지가 보낸 값 그대로 | +| `remoteAddr` | `10.42.0.1` | `192.168.122.1` | +| `x-forwarded-for` | 없다 | 없다 | + +빠진 한 단계는 두 줄이고, 저장소에 파일(`deploy/lab/k8s/traefik-forwarded-headers.yaml`) +이 있는데 **setup 아홉 편 어디에도 이 명령이 없었다.** + +```bash +kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml +kubectl -n kube-system rollout status deploy/traefik --timeout=180s +``` + +신뢰 목록에는 파드 대역 `10.42.0.0/16` 하나만 담는다. Traefik 의 Service 가 +`externalTrafficPolicy: Cluster` 라 svclb 가 출발지를 덮고, 그래서 Traefik 에 닿는 +주소는 엣지가 아니라 파드 대역에서 온다. 노드·호스트 대역을 같이 넣어서도 재 +봤는데 위 표의 값이 하나도 안 바뀌었다 — 나타날 수 없는 대역이라 넣어도 소용이 +없다(observed). `x-forwarded-for` 는 이 단계를 밟아도 앱까지 안 오고, **왜 +사라지는지는 안 가렸다**(unknown). ## 190. 단계 04 — Let's Encrypt 와 인증서 갱신 @@ -10749,6 +11162,49 @@ server { | 443 블록 | 없었다 | 인증서와 함께 새로 | | `X-Forwarded-Proto` | `http` | **`https`** | +**★ 위 블록의 인증서 경로가 틀렸다**(2026-09-17, observed). `live/hyeonworks.com/` 이라고 +적혀 있는데 certbot 이 만드는 디렉터리는 **`live/auth.hyeonworks.com/`** 이다. 이름을 +여럿 담은 인증서라도 디렉터리 이름은 `-d` 로 처음 준 이름 하나를 쓴다. 그대로 치면 +`nginx -t` 가 막힌다. + +```text +[emerg] cannot load certificate "/etc/letsencrypt/live/hyeonworks.com/fullchain.pem": + BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory +nginx: configuration file /etc/nginx/nginx.conf test failed +``` + +```bash +sudo ls /etc/letsencrypt/live/ +sudo certbot certificates | grep 'Certificate Path' +``` + +```text +README +auth.hyeonworks.com + Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem +``` + +경로를 그 이름으로 고치자 `syntax is ok` · `test is successful` 로 통과하고 엣지가 443 을 +듣기 시작했다. **§204 의 「문제가 생기면」에 적힌 진단은 방향이 거꾸로다** — +`live/auth.hyeonworks.com/` 이 맞는 경로이고 없는 것은 `live/hyeonworks.com/` 쪽이다. + +**그리고 이 실험대의 certbot 은 DNS-01 로 등록되어 있다**(observed). 두 문서가 갈렸던 +대목이 닫혔다. + +```bash +sudo sh -c 'grep -H "authenticator\|dns_cloudflare_credentials\|server =" /etc/letsencrypt/renewal/*.conf' +``` + +```text +authenticator = dns-cloudflare +dns_cloudflare_credentials = /etc/letsencrypt/cloudflare.ini +server = https://acme-v02.api.letsencrypt.org/directory +``` + +**`sudo grep ... /etc/letsencrypt/renewal/*.conf` 로 치면 안 된다**(observed). 글로브를 일반 +사용자 셸이 먼저 펼치는데 그 디렉터리를 못 읽어서 `no matches found` 로 끝나고 `grep` 은 +시작도 안 한다. `sudo sh -c '...'` 로 sudo 안에서 펼쳐야 한다. + **`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**, 캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. 2번에서 certbot 이 찍어 준 경로와 @@ -10944,6 +11400,31 @@ curl -v https://auth.hyeonworks.com/ -o /dev/null **`subject` 가 `hyeonworks.com` 인 것이 맞다** — 와일드카드 인증서라 CN 은 apex 이름이고 `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다. +**★ 그런데 이 실험대에서는 CN 이 `auth.hyeonworks.com` 이다**(2026-09-17, +observed). 가이드대로 와일드카드를 받으면 위 문단처럼 보인다. 이 실험대는 +와일드카드를 안 받았고 계보도 `auth.hyeonworks.com` 하나뿐이라, 앞에서 적은 +「이 실험대는 처음에 와일드카드를 안 쉈고」와 같은 사실을 가리킨다. 그 둘이 +같은 문서 안에서 서로 어긋나 있었다. + +```bash +echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -dates +``` + +```text +subject=CN=auth.hyeonworks.com +X509v3 Subject Alternative Name: + DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com +notBefore=Sep 4 11:29:18 2026 GMT +notAfter=Dec 3 11:29:17 2026 GMT +``` + +그래서 CN 을 합격 조건으로 읽으면 안 된다. 와일드카드를 받았으면 +`hyeonworks.com`, 이름을 나열해 받았으면 첫 번째 `-d` 의 이름이 CN 이 된다. +보는 것은 `SSL certificate verify ok.` 한 줄과, 지금 친 이름이 SAN 에 있는가다. +이름 셋 밖은 TLS 단계에서 끝나 `curl` 이 종료 코드 `60` 을 내고 `%{http_code}` 가 +`000` 이 되는데, 화면에서는 「서버가 죽었다」와 같아 보인다. A-4 의 Grafana +탐침이 그렇게 쓸모없어졌다. + **이 결과가 의미하는 것** — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면 중간 @@ -10955,6 +11436,14 @@ curl -v https://auth.hyeonworks.com/ -o /dev/null curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/ ``` +**랩 호스트에서 칠 때만 `--resolve` 를 붙인다**(2026-09-17, observed). 거기서는 이 이름이 호스트 +자신의 tailnet 주소로 풀리고 그 주소에는 443 을 듣는 것이 없다. + +```bash +curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/ +``` + **실측**(observed) — 2026-09-11, tailnet 클라이언트에서 ``` @@ -11204,7 +11693,8 @@ ls deploy/lab/k8s/ [04] 의 확인 ① 을 그대로 다시 친다. ```bash -curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/ ``` **어디를 봐야 하는가** — 04 에서 잰 `404 tls=0` 이 그대로인가. **앞의 코드를 같이 @@ -11692,7 +12182,8 @@ vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0 **확인 ④ 밖에서 닿나** — 2홉을 다 지나 파드까지 ```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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ``` @@ -11709,7 +12200,7 @@ Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다. 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다. ```bash -curl -I https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` **★ 이 `200` 은 04 를 끝냈을 때의 값이다.** 04 를 건너뛰고 05 만 했다면 443 을 듣는 @@ -12103,7 +12594,8 @@ kubectl -n observability exec deploy/prometheus -- \ 응답을 받아 보는 것이 가장 짧다. ```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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` **어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 `up` 의 1/0 과 @@ -12219,10 +12711,9 @@ kubectl -n observability port-forward svc/grafana 3000:3000 돌고 있는 실험대가 없어지기 때문이다 — **가이드대로 쳐서 이 상태가 다시 서는지** 는 확인된 적이 없다 (unknown) - 호스트의 코어 수가 가이드의 「16 코어 전부」와 §178 의 「논리 코어 8」로 - 갈린다. 어느 쪽이 이 실험대인지 (unknown) -- cloud-init 의 `packages` 에 certbot 이 있었는가. 01·03 은 `curl`·`nftables` - 뿐이라 적고 04 는 들어 있다고 적는다. `kc-lab.yaml.example` 이 `source/` 에 - 없어 대조하지 못했다 (unknown) + 갈렸다 → **8 이다.** 2026-09-17 에 `nproc` 과 `lscpu` 로 갈랐다 (observed) +- cloud-init 의 `packages` 에 certbot 이 있었는가 → **없다.** 2026-09-17 에 + 게스트의 user-data 와 저장소 템플릿을 둘 다 열어서 갈랐다 (observed) - `lsmod | grep kvm` 의 실제 출력, 03 의 `curl -I http://192.168.122.11` 출력, 04 의 `curl -v` 협상 출력 — 셋 다 캡처해 두지 않았다 (unknown) - k3s 토큰 108자는 판올림에 따라 달라진다. 다른 판에서 몇 자인지는 재지 않았다 @@ -12759,6 +13250,39 @@ tar 하나에 30초고, 답을 알고 나면 지우면 된다. sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt ``` +**그런데 이 정책이 지키는 디렉터리가 03·04 뒤로 바뀌었다**(2026-09-17, observed). +위 명령은 랩 호스트의 `/etc/letsencrypt/` 를 묶는데, 03 이 nginx 를 +`kc-lab-edge` 로 옮겼고 04 가 certbot 을 거기 깔았다. **지금 서빙하는 인증서는 +그 게스트 안에 산다.** 그리고 철거 1번은 게스트를 `--remove-all-storage` 로 +지운다 — 인증서는 그 안에서 같이 사라지고, 위 백업에는 안 들어간다. + +```bash +ls -la /etc/letsencrypt/ +ssh kc-lab-edge 'sudo ls -la /etc/letsencrypt/' +``` + +| 기계 | `/etc/letsencrypt/` | certbot | 타이머 | 서빙 | +|---|---|---|---|---| +| 랩 호스트 | 있다 (읽으려면 `sudo`) | `/usr/bin/certbot` | 없다 | 안 한다 — 80·443 을 안 듣는다 | +| `kc-lab-edge` | 있다 — `cli.ini` · `renewal-hooks` | `/usr/bin/certbot` | `certbot.timer` | **한다** | + +묶어야 할 것은 게스트 쪽이다. + +```bash +ssh kc-lab-edge "sudo tar czf /tmp/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt" +scp kc-lab-edge:/tmp/letsencrypt-backup-*.tgz ~/ +``` + +**「철거해도 남는 것」 표의 패키지 줄도 같은 이유로 틀렸다.** 그 줄은 `nginx` 와 +`certbot` 을 남는 쪽에 적어 두었는데 둘 다 게스트 안에 있으므로 게스트와 함께 +사라진다. 랩 호스트에 남는 것은 `libvirt`·`qemu`·`kubectl` 과, 쓰이지 않는 호스트 +쪽 `certbot` 이다. + +**철거 전 네 값은 2026-09-17 에 다시 받았다**(observed). 도메인 3 대에 볼륨 7 개, +예약 3 줄까지는 2026-09-10 과 같고 **디스크만 11G 가 아니라 18G 다** — 그사이에 +k3s 와 컨테이너 이미지가 쌓였다. 이 네 값은 이 호스트의 정답이 아니라 매 실행에서 +받아 두는 대조군이다. + 복원은 반대로 한 줄이다. ```bash @@ -17514,6 +18038,20 @@ k3s가 컨테이너에 SIGTERM을 보낸다. 유예 시간이 짧으면 **Postgr 강제 종료되어 다음 기동에 crash recovery가 돈다.** 미리 내려두면 그 위험이 없다. +**따라 하는 사람은 3번의 두 게스트를 `&&` 로 잇지 않고 한 줄씩 친다.** + +```bash +virsh shutdown kc-lab-1 +virsh shutdown kc-lab-2 +``` + +두 게스트는 서로 앞뒤가 없고, `A && B` 는 A 가 성공했을 때만 B 를 실행한다. +`kc-lab-1` 이 이미 `shut off` 면 `virsh shutdown` 은 `domain is not running` 으로 +실패하므로 `kc-lab-2` 는 켜진 채로 남고, 바로 다음 줄의 `systemctl poweroff` 가 +그 게스트를 강제로 끈다 — 이 절차가 막으려던 바로 그 상태다. §226 의 +`net-start && net-autostart` 와 같은 자리다. 이 나눈 형태는 이 실험대에서 치지 +않았다(unknown). + **clean shutdown 확인** ```bash @@ -17537,6 +18075,18 @@ kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2 **스케일을 0으로 내려두면 자동으로 복구되지 않는다.** 명시적으로 올려야 한다. +여기 첫 줄도 `&&` 로 잇지 않고 한 줄씩 친다. + +```bash +virsh start kc-lab-1 +virsh start kc-lab-2 +``` + +`kc-lab-1` 이 이미 `running` 이면 `virsh start` 가 `domain is already active` 로 +실패하고 `kc-lab-2` 는 안 뜬다. 그러면 `kubectl get nodes` 에 노드가 하나만 나오는데, +증상이 게스트를 안 띄운 것과 노드가 안 붙은 것 사이에서 갈리지 않는다. +이 나눈 형태는 이 실험대에서 치지 않았다(unknown). + ## 335. qcow2 파일을 다른 물리 서버로 옮기면 무엇이 따라가나 1층의 「qcow2와 backing store」가 **오버레이 구조**를, 「qcow2 파일 내부는 diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/46-layer3-now-reachable.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/46-layer3-now-reachable.txt new file mode 100644 index 0000000..c5d3b0f --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/46-layer3-now-reachable.txt @@ -0,0 +1,13 @@ +=== 03 층별 확인 — 이제 밖에서 닿는다 === +--- 층 ③ 밖에서 도메인으로 (문서: 04 이후엔 301, 이 단계에선 404) --- +HTTP/1.1 302 Found +Server: nginx/1.22.1 +Date: Thu, 17 Sep 2026 07:07:31 GMT +Connection: keep-alive +--- 값만 뽑는 형태 --- +302 https://auth.hyeonworks.com/admin/ +--- 층 ④ TLS 이후 (아직 인증서가 없다) --- +000 +=== app1 도 닿나 (B층·C층이 쓸 경로) === +app1 200 +app1 root 200 diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/47-layer-codes-by-host-header.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/47-layer-codes-by-host-header.txt new file mode 100644 index 0000000..12c904d --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/47-layer-codes-by-host-header.txt @@ -0,0 +1,10 @@ +=== 같은 엣지에 세 가지로 친다 — 무엇이 코드를 가르나 === +① Host 없이 엣지 IP 로 : 000 +404 +② Host: auth 로 엣지 IP 에 : 302 https://auth.hyeonworks.com/admin/ +③ 밖에서 도메인으로 : 302 https://auth.hyeonworks.com/admin/ +④ 밖에서 도메인 + /realms/master: 200 + +=== Keycloak 이 무엇을 보고 그 주소를 만드나 === +KC_HOSTNAME=https://auth.hyeonworks.com +KC_HOSTNAME_STRICT=true diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/48-cloudinit-packages.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/48-cloudinit-packages.txt new file mode 100644 index 0000000..d37cdd1 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/48-cloudinit-packages.txt @@ -0,0 +1,40 @@ +=== 01 의 미검증: cloud-init packages 에 certbot 이 있었나 === +--- 저장소 템플릿 +10:hostname: kc-lab-__NODE__ +14:users: +15: - name: donghyeon +22: # (22.4.2 on the guests) rejects it and prints the whole users.0 block with +40:packages: + +--- 엣지 게스트가 실제로 받은 user-data +19:packages: [curl, nftables] +--- +#cloud-config +hostname: kc-lab-edge +fqdn: kc-lab-edge +manage_etc_hosts: true + +users: + - name: donghyeon + groups: [sudo] + shell: /bin/bash + sudo: ['ALL=(ALL) NOPASSWD:ALL'] + ssh_authorized_keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINW0f8garKmfO93vd66yl1t1JtTKN68eRh8K6NuMpBzk test-server -> kc-lab + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhGllPm3iLvB93ITk36Ep0TtAXgBqZZtzvhIwLK8T4j donghyeon@donghyeon-960XGK + +ssh_pwauth: false +package_update: true +packages: [curl, nftables] +=== 저장소 템플릿의 packages 절 === +ssh_pwauth: false +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 diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/49-doc01-verification.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/49-doc01-verification.txt new file mode 100644 index 0000000..836040b --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/49-doc01-verification.txt @@ -0,0 +1,19 @@ +=== 01 확인 ① === + Id Name State +----------------------------- + 13 kc-lab-1 running + 15 kc-lab-edge running + 16 kc-lab-2 running + + +kc-lab-1 +PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" + +=== 01 확인 ② === +kc-lab-edge +enp1s0 UP 192.168.122.10/24 metric 100 +status: done + +=== 01 확인 ③ 스크린샷 === +Screenshot saved to /tmp/kc1.ppm, with type of image/png +/tmp/kc1.ppm: PNG image data, 720 x 400, 8-bit/color RGB, non-interlaced diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/50-doc08-precheck-and-cert-location.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/50-doc08-precheck-and-cert-location.txt new file mode 100644 index 0000000..af90d91 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/50-doc08-precheck-and-cert-location.txt @@ -0,0 +1,42 @@ +=== 08 「지우기 전에 먼저 본다」 네 값 === + Id Name State +----------------------------- + 13 kc-lab-1 running + 15 kc-lab-edge running + 16 kc-lab-2 running + +--- 볼륨 + Name Path +---------------------------------------------------------------------- + base.qcow2 /var/lib/libvirt/images/base.qcow2 + kc-lab-1.qcow2 /var/lib/libvirt/images/kc-lab-1.qcow2 + kc-lab-2.qcow2 /var/lib/libvirt/images/kc-lab-2.qcow2 + kc-lab-edge.qcow2 /var/lib/libvirt/images/kc-lab-edge.qcow2 + seed-kc-lab-1.iso /var/lib/libvirt/images/seed-kc-lab-1.iso + seed-kc-lab-2.iso /var/lib/libvirt/images/seed-kc-lab-2.iso + seed-kc-lab-edge.iso /var/lib/libvirt/images/seed-kc-lab-edge.iso + +--- 예약 + <dhcp> + <range start='192.168.122.2' end='192.168.122.254'/> + <host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/> + <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'/> + </dhcp> + </ip> +</network> + +--- 디스크 +/dev/nvme0n1p3 226G 18G 197G 9% / + +=== 08 이 지키려는 /etc/letsencrypt 가 어느 기계에 있나 === +--- lab host +ls: cannot open directory '/etc/letsencrypt/': Permission denied +certbot: /usr/bin/certbot +--- kc-lab-edge (03·04 가 nginx·certbot 을 옮겨 둔 곳) +total 16 +drwxr-xr-x 3 root root 4096 Sep 17 07:11 . +drwxr-xr-x 66 root root 4096 Sep 17 04:33 .. +-rw-r--r-- 1 root root 207 Nov 12 2021 cli.ini +drwxr-xr-x 5 root root 4096 Sep 17 04:33 renewal-hooks +certbot: /usr/bin/certbot diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/52-doc01-09-unknowns.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/52-doc01-09-unknowns.txt new file mode 100644 index 0000000..b31497a --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/52-doc01-09-unknowns.txt @@ -0,0 +1,55 @@ +=== 09 · lsmod | grep kvm === +kvm_intel 524288 11 +kvm 1490944 6 kvm_intel +irqbypass 16384 1 kvm + +=== 09 · 코어 수가 16 인가 8 인가 === +8 +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 + +=== 09 · systemctl status libvirtd.socket === +● libvirtd.socket - libvirt legacy monolithic daemon socket + Loaded: loaded (/usr/lib/systemd/system/libvirtd.socket; enabled; preset: disabled) + Active: active (running) since Thu 2026-09-03 19:00:35 KST; 1 week 6 days ago + Invocation: e330532b350d4d79ad7f31f7c6512a6c + Triggers: ● libvirtd.service + Listen: /run/libvirt/libvirt-sock (Stream) + +Sep 03 19:00:35 test-server systemd[1]: Listening on libvirt legacy monolithic daemon socket. + +=== 01 · virsh vol-info 와 net-dumpxml --inactive === +Name: kc-lab-1.qcow2 +Type: file +Capacity: 20.00 GiB +Allocation: 5.40 GiB + +--- +Name: seed-kc-lab-1.iso +Type: file +Capacity: 370.00 KiB +Allocation: 372.00 KiB + +--- + <bridge name='virbr0' stp='on' delay='0'/> + <range start='192.168.122.2' end='192.168.122.254'/> + <host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/> + <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'/> + +=== 01 · cloud-init status --long === +status: done +boot_status_code: enabled-by-generator +last_update: Thu, 17 Sep 2026 04:24:22 +0000 +detail: +DataSourceNoCloud [seed=/dev/vdb][dsmode=net] +=== 볼륨 일곱의 용량·할당 === +base.qcow2 Capacity=3.00 GiB Allocation=323.25 MiB +kc-lab-1.qcow2 Capacity=20.00 GiB Allocation=5.40 GiB +kc-lab-2.qcow2 Capacity=20.00 GiB Allocation=3.85 GiB +kc-lab-edge.qcow2 Capacity=10.00 GiB Allocation=316.26 MiB +seed-kc-lab-1.iso Capacity=370.00 KiB Allocation=372.00 KiB +seed-kc-lab-2.iso Capacity=370.00 KiB Allocation=372.00 KiB +seed-kc-lab-edge.iso Capacity=370.00 KiB Allocation=372.00 KiB diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/53-doc03-sudo-answers.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/53-doc03-sudo-answers.txt new file mode 100644 index 0000000..0b25fd8 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/53-doc03-sudo-answers.txt @@ -0,0 +1,39 @@ +=== 03 ① 배포된 두 파일이 문서 판인가 === +1 +1 +(문서는 0 과 1 이 나와야 맞다고 적는다) + +=== 03 ② 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 + } + + 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 + } +} + +=== 03 ③ guest_input 체인 — 구멍이 들어가 있나 === +table ip libvirt_network { + chain guest_input { + oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 44 bytes 2640 accept + oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 0 bytes 0 accept + oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 141705 bytes 2375116597 accept + oif "virbr0" counter packets 5 bytes 300 reject + oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 10 bytes 600 accept + } +} + +=== 03 ④ 출발지를 덮는 것이 무엇인가 === +# Warning: table ip filter is managed by iptables-nft, do not touch! +# Warning: table ip nat is managed by iptables-nft, do not touch! +# Warning: table ip6 filter is managed by iptables-nft, do not touch! +# Warning: table ip6 nat is managed by iptables-nft, do not touch! +# Warning: table ip mangle is managed by iptables-nft, do not touch! +# Warning: table ip6 mangle is managed by iptables-nft, do not touch! +122: meta l4proto tcp ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 308 bytes 18480 masquerade to :1024-65535 +123: meta l4proto udp ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 39 bytes 2964 masquerade to :1024-65535 +124: ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 0 bytes 0 masquerade diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/54-who-masquerades.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/54-who-masquerades.txt new file mode 100644 index 0000000..d246a83 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/54-who-masquerades.txt @@ -0,0 +1,45 @@ +=== tailscale 이 만든 체인 === +# Warning: table ip filter is managed by iptables-nft, do not touch! +# Warning: table ip nat is managed by iptables-nft, do not touch! +# Warning: table ip6 filter is managed by iptables-nft, do not touch! +# Warning: table ip6 nat is managed by iptables-nft, do not touch! +# Warning: table ip mangle is managed by iptables-nft, do not touch! +# Warning: table ip6 mangle is managed by iptables-nft, do not touch! +2: chain ts-input { +4: iifname "tailscale0" counter packets 1168848 bytes 1390274528 accept +6: ip saddr 100.115.92.0/23 iifname != "tailscale0" counter packets 0 bytes 0 return +7: ip saddr 100.64.0.0/10 iifname != "tailscale0" counter packets 530 bytes 29680 drop +10: chain ts-forward { +11: iifname "tailscale0" counter packets 360 bytes 33127 xt target "MARK" +13: ip saddr 100.64.0.0/10 oifname "tailscale0" counter packets 0 bytes 0 drop +14: oifname "tailscale0" counter packets 265 bytes 95483 accept +19: counter packets 3492404 bytes 4065830972 jump ts-input +24: counter packets 590543 bytes 5494319628 jump ts-forward +28: chain ts-postrouting { +34: counter packets 332016 bytes 24132162 jump ts-postrouting +38: chain ts-input { +40: iifname "tailscale0" counter packets 1 bytes 104 accept +44: chain ts-forward { +45: iifname "tailscale0" counter packets 0 bytes 0 xt target "MARK" +47: oifname "tailscale0" counter packets 0 bytes 0 accept +52: counter packets 435 bytes 159766 jump ts-input +57: counter packets 0 bytes 0 jump ts-forward +61: chain ts-postrouting { +67: counter packets 81 bytes 6580 jump ts-postrouting +152: iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10 + +=== iptables-nft 쪽 nat POSTROUTING === +-P PREROUTING ACCEPT +-P INPUT ACCEPT +-P OUTPUT ACCEPT +-P POSTROUTING ACCEPT +-N ts-postrouting +-A POSTROUTING -j ts-postrouting +-A ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE + +=== tailscale 의 SNAT 설정 === + "AdvertiseTags": null, + "AdvertiseRoutes": null, + "AdvertiseServices": null, + "NoSNAT": false, + "Advertise": false diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/55-doc02-san-journal.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/55-doc02-san-journal.txt new file mode 100644 index 0000000..b7aaa0f --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/55-doc02-san-journal.txt @@ -0,0 +1,19 @@ +=== 02 · API 서버 인증서 SAN === +X509v3 Subject Alternative Name: + DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:localhost, DNS:kc-lab-1, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, IP Address:192.168.122.11, IP Address:10.43.0.1, IP Address:192.168.122.11 + +=== 02 · journalctl -u k3s (서버) 끝 6줄 === +Sep 17 07:37:32 kc-lab-1 k3s[1024]: I0917 07:37:32.358989 1024 reconciler_common.go:299] "Volume detached for volume \"kube-api-access-49x4f\" (UniqueName: \"kubernetes.io/projected/b2a26eb4-c671-4681-b67f-77e47966b53b-kube-api-access-49x4f\") on node \"kc-lab-1\" DevicePath \"\"" +Sep 17 07:37:32 kc-lab-1 k3s[1024]: I0917 07:37:32.501306 1024 scope.go:122] "RemoveContainer" containerID="21a239d442f2d69328ebf52e5b29e6706a93f62c622ae201284313a983839424" +Sep 17 07:37:32 kc-lab-1 k3s[1024]: I0917 07:37:32.962363 1024 kubelet_volumes.go:161] "Cleaned up orphaned pod volumes dir" podUID="b2a26eb4-c671-4681-b67f-77e47966b53b" path="/var/lib/kubelet/pods/b2a26eb4-c671-4681-b67f-77e47966b53b/volumes" +Sep 17 07:40:08 kc-lab-1 k3s[1024]: time="2026-09-17T07:40:08Z" level=info msg="COMPACT compactRev=9376 targetCompactRev=9685 currentRev=10685" +Sep 17 07:40:08 kc-lab-1 k3s[1024]: time="2026-09-17T07:40:08Z" level=info msg="COMPACT deleted 380 rows from 309 revisions in 9.152746ms - compacted to 9685/10685" +Sep 17 07:40:08 kc-lab-1 k3s[1024]: time="2026-09-17T07:40:08Z" level=info msg="COMPACT compacted from 9376 to 9685 in 1 transactions over 9ms" + +=== 02 · journalctl -u k3s-agent (에이전트) 끝 6줄 === +Sep 17 07:42:16 kc-lab-2 k3s[2644]: E0917 07:42:16.919786 2644 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55428: use of closed network connection" +Sep 17 07:42:17 kc-lab-2 k3s[2644]: E0917 07:42:17.005337 2644 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55436: use of closed network connection" +Sep 17 07:42:17 kc-lab-2 k3s[2644]: E0917 07:42:17.092765 2644 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55450: use of closed network connection" +Sep 17 07:42:24 kc-lab-2 k3s[2644]: E0917 07:42:24.627959 2644 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:40132: use of closed network connection" +Sep 17 07:42:25 kc-lab-2 k3s[2644]: E0917 07:42:25.072360 2644 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:40148: use of closed network connection" +Sep 17 07:42:26 kc-lab-2 k3s[2644]: E0917 07:42:26.574554 2644 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:40158: use of closed network connection" diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/56-doc04-token-check.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/56-doc04-token-check.txt new file mode 100644 index 0000000..68c37a7 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/56-doc04-token-check.txt @@ -0,0 +1,7 @@ +=== 04 3단계 ④ 권한과 바이트 수 (값은 안 본다) === +ls: cannot access '/etc/letsencrypt/cloudflare.ini': No such file or directory +wc: /etc/letsencrypt/cloudflare.ini: No such file or directory + +=== 04 3단계 ⑤ 토큰이 살아 있고 권한 범위가 맞는지 (응답의 상태 문자열만) === +awk: cannot open /etc/letsencrypt/cloudflare.ini (No such file or directory) +{"success":false,"errors":[{"code":6003,"message":"Invalid request headers","error_chain":[{"code":6111,"message":"Invalid format for Authorization header"}]}],"messages":[],"result":null} \ No newline at end of file diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/57-host-certs.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/57-host-certs.txt new file mode 100644 index 0000000..b42e2e3 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/57-host-certs.txt @@ -0,0 +1,19 @@ +=== 랩 호스트의 인증서 === +Saving debug log to /var/log/letsencrypt/letsencrypt.log + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Found the following certs: + Certificate Name: auth.hyeonworks.com + Serial Number: 6f3e0ef4d1bb03de58130eaad1176101373 + Key Type: ECDSA + Identifiers: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com + Expiry Date: 2026-12-03 11:29:17+00:00 (VALID: 77 days) + Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem + Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +=== renewal 설정의 authenticator (04 의 오랜 미검증) === +zsh:1: no matches found: /etc/letsencrypt/renewal/*.conf + +=== 토큰이 살아 있나 (상태 문자열만) === +{"success":false,"errors":[{"code":6003,"message":"Invalid request headers","error_chain":[{"code":6111,"message":"Invalid format for Authorization header"}]}],"messages":[],"result":null} \ No newline at end of file diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/58-authenticator-and-token.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/58-authenticator-and-token.txt new file mode 100644 index 0000000..b25febb --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/58-authenticator-and-token.txt @@ -0,0 +1,11 @@ +=== 04 의 오랜 미검증: authenticator 가 무엇인가 === +--- 문서 형태 (글로브가 sudo 앞에서 펼쳐진다) +zsh:1: no matches found: /etc/letsencrypt/renewal/*.conf +--- sudo 안에서 펼치게 고친 형태 +/etc/letsencrypt/renewal/auth.hyeonworks.com.conf:authenticator = dns-cloudflare +/etc/letsencrypt/renewal/auth.hyeonworks.com.conf:dns_cloudflare_credentials = /etc/letsencrypt/cloudflare.ini +/etc/letsencrypt/renewal/auth.hyeonworks.com.conf:server = https://acme-v02.api.letsencrypt.org/directory + +=== 토큰 검증 (값은 안 찍는다) === +토큰 길이 0자 +{"success":false,"errors":[{"code":6003,"message":"Invalid request headers","error_chain":[{"code":6111,"message":"Invalid format for Authorization header"}]}],"messages":[],"result":null} \ No newline at end of file diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/59-cert-moved-to-edge.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/59-cert-moved-to-edge.txt new file mode 100644 index 0000000..0a00d5c --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/59-cert-moved-to-edge.txt @@ -0,0 +1,18 @@ +=== 호스트 → 엣지로 인증서를 옮긴다 (값은 화면에 안 나온다) === +Found the following certs: + Certificate Name: auth.hyeonworks.com + Serial Number: 6f3e0ef4d1bb03de58130eaad1176101373 + Key Type: ECDSA + Domains: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com + Expiry Date: 2026-12-03 11:29:17+00:00 (VALID: 77 days) + Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem + Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +=== 엣지에서 파일 권한 확인 === +total 4 +-rw-r--r-- 1 root root 692 Sep 3 01:47 README +lrwxrwxrwx 1 root root 43 Sep 4 12:29 cert.pem -> ../../archive/auth.hyeonworks.com/cert3.pem +lrwxrwxrwx 1 root root 44 Sep 4 12:29 chain.pem -> ../../archive/auth.hyeonworks.com/chain3.pem +lrwxrwxrwx 1 root root 48 Sep 4 12:29 fullchain.pem -> ../../archive/auth.hyeonworks.com/fullchain3.pem +lrwxrwxrwx 1 root root 46 Sep 4 12:29 privkey.pem -> ../../archive/auth.hyeonworks.com/privkey3.pem diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/60-doc04-step5-path-defect.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/60-doc04-step5-path-defect.txt new file mode 100644 index 0000000..5c1452f --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/60-doc04-step5-path-defect.txt @@ -0,0 +1,7 @@ +=== live 아래에 무엇이 있나 === +README +auth.hyeonworks.com + +=== 04 5단계를 문서 그대로 (live/hyeonworks.com) 쳤을 때 === +2026/09/17 07:47:15 [emerg] 2530#2530: cannot load certificate "/etc/letsencrypt/live/hyeonworks.com/fullchain.pem": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/hyeonworks.com/fullchain.pem, r) error:10000080:BIO routines::no such file) +nginx: configuration file /etc/nginx/nginx.conf test failed diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/61-tls-up.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/61-tls-up.txt new file mode 100644 index 0000000..4f6d197 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/61-tls-up.txt @@ -0,0 +1,13 @@ +=== 경로를 live/auth.hyeonworks.com 으로 고친다 === +nginx: the configuration file /etc/nginx/nginx.conf syntax is ok +nginx: configuration file /etc/nginx/nginx.conf test is successful +RELOADED + +=== 엣지가 443 을 듣나 === +LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=2547,fd=5),("nginx",pid=1065,fd=5)) +LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=2547,fd=10),("nginx",pid=1065,fd=10)) + +=== 밖에서 === +https://auth.hyeonworks.com/realms/master 400 +https://app1.hyeonworks.com/ 400 +http://auth.hyeonworks.com/ 301 diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/62-https-400.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/62-https-400.txt new file mode 100644 index 0000000..d50b09f --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/62-https-400.txt @@ -0,0 +1,22 @@ +=== 400 의 본문과 머리 === +HTTP/2 400 +server: nginx/1.22.1 +date: Thu, 17 Sep 2026 07:47:28 GMT +content-type: text/plain; charset=utf-8 + +400 Bad Request: malformed Host header +=== TLS 협상 자체는 되나 === +http_version=2 ssl_verify=0 code=400 +--- HTTP/1.1 로 강제하면 +code=400 + +=== 엣지 로그 === +192.168.122.1 - - [17/Sep/2026:07:47:28 +0000] "GET /realms/master HTTP/2.0" 400 38 "-" "curl/8.5.0" +192.168.122.1 - - [17/Sep/2026:07:47:28 +0000] "GET /realms/master HTTP/2.0" 400 38 "-" "curl/8.5.0" +192.168.122.1 - - [17/Sep/2026:07:47:28 +0000] "GET /realms/master HTTP/1.1" 400 49 "-" "curl/8.5.0" +--- +2026/09/17 04:27:39 [notice] 1065#1065: using inherited sockets from "5;6;" +2026/09/17 05:39:03 [error] 1106#1106: *31 connect() failed (113: No route to host) while connecting to upstream, client: 192.168.122.1, server: _, request: "GET /realms/master HTTP/1.1", upstream: "http://192.168.122.12:80/realms/master", host: "auth.hyeonworks.com" +2026/09/17 05:40:37 [error] 1106#1106: *38 connect() failed (113: No route to host) while connecting to upstream, client: 192.168.122.1, server: _, request: "GET /realms/master HTTP/1.1", upstream: "http://192.168.122.12:80/realms/master", host: "auth.hyeonworks.com" +2026/09/17 05:46:21 [error] 1106#1106: *47 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.122.1, server: _, request: "GET /realms/master HTTP/1.1", upstream: "http://192.168.122.12:80/realms/master", host: "auth.hyeonworks.com" +2026/09/17 05:48:45 [error] 1106#1106: *54 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.122.1, server: _, request: "GET /realms/master HTTP/1.1", upstream: "http://192.168.122.12:80/realms/master", host: "auth.hyeonworks.com" diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/63-layer-400.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/63-layer-400.txt new file mode 100644 index 0000000..6970470 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/63-layer-400.txt @@ -0,0 +1,15 @@ +=== 층 ① 엣지에서 Traefik 에 직접 (Host 헤더를 손으로) === +200 + +=== 층 ② 엣지의 443 에 엣지 자신이 (localhost) === +400 + +=== 층 ② 엣지의 80 (리다이렉트 확인) === +301 -> https://\auth.hyeonworks.com\/realms/master + +=== 지금 깔린 443 블록의 Host 관련 줄 === +7: listen 80 default_server; +13: listen 443 ssl http2 default_server; +21: proxy_pass http://k3s_traefik; +23: proxy_set_header Host \$host; +24: proxy_set_header X-Forwarded-Host \$host; diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/64-tls-working.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/64-tls-working.txt new file mode 100644 index 0000000..92f74b5 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/64-tls-working.txt @@ -0,0 +1,11 @@ +23: proxy_set_header Host $host; +24: proxy_set_header X-Forwarded-Host $host; +nginx: the configuration file /etc/nginx/nginx.conf syntax is ok +nginx: configuration file /etc/nginx/nginx.conf test is successful +RELOADED + +=== 밖에서 === +https://auth.hyeonworks.com/realms/master 400 +https://app1.hyeonworks.com/ 200 +https://app2.hyeonworks.com/ 302 +http://auth.hyeonworks.com/ 301 diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-auth-400.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-auth-400.txt new file mode 100644 index 0000000..8bbe75d --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-auth-400.txt @@ -0,0 +1,23 @@ +=== auth 의 400 본문 === +HTTP/2 200 +server: nginx/1.22.1 +date: Thu, 17 Sep 2026 07:47:56 GMT +content-type: application/json;charset=UTF-8 +content-length: 602 +cache-control: no-cache +referrer-policy: no-referrer +strict-transport-security: max-age=31536000; includeSubDomains +x-content-type-options: nosniff +x-frame-options: SAMEORIGIN +x-robots-tag: none + + +=== 다른 경로도 400 인가 === +/ 302 +/realms/master 200 +/realms/keycloak-patterns 200 +/admin/ 302 + +=== 엣지에서 Traefik 에 Host 를 주고 직접 (대조군) === +http 200 +http+XFP-https 200 diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-edge-missing-pieces.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-edge-missing-pieces.txt new file mode 100644 index 0000000..fd4e374 --- /dev/null +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/65-edge-missing-pieces.txt @@ -0,0 +1,7 @@ +=== certbot 의 진짜 종료 코드 === +certbot exit=0 +11 + +=== 인증서만 옮기면 따라오지 않는 둘을 마저 옮긴다 === +ls: cannot access '/etc/letsencrypt/accounts/*/*/*': No such file or directory +81 /etc/letsencrypt/cloudflare.ini diff --git a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/README.txt b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/README.txt index 49f341f..4b503e0 100644 --- a/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/README.txt +++ b/docs/virtualization/final/evidence/raw/lab-state-before-rebuild/README.txt @@ -49,3 +49,11 @@ 43-dominfo-vs-config.txt 같은 순간의 dominfo(5242880) 와 dumpxml --inactive(4194304). 바꾼 것이 들어갔는지는 --inactive 가 답한다 44-host-prep-checks.txt 00 의 확인 명령 전부 — vmx, kvm 모듈 셋, 저장소, virsh 12.7.0, libvirtd.socket, libvirt 그룹, default 네트워크, sudo 없는 virsh list 45-libvirt-uri-mechanism.txt rc 파일은 비대화형 ssh 에서 안 읽힌다. 그런데도 되는 까닭은 ~/.config/libvirt/libvirt.conf 의 uri_default +48 cloud-init 의 packages 에 certbot 이 없다는 것 (게스트 user-data + 저장소 템플릿) +49 01 의 확인 ①②③ 을 다시 친 결과 +50 08 의 철거 전 네 값과, 인증서가 어느 기계에 있는지 +51 (철거는 실행하지 않았다 — 파일 없음) +52 09 의 lsmod·코어 수·libvirtd.socket, 01 의 vol-info·net-dumpxml·cloud-init status +53 sudo 가 열린 뒤 doc 03 의 미검증 셋 — 배포 파일 상태·guest_input 중복·masquerade 찾기 +54 출발지를 덮는 것이 Tailscale 이라는 근거 (ts-forward MARK → ts-postrouting MASQUERADE) +55 doc 02 의 API 서버 SAN 과 두 journalctl diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-create-three-guests-with-cloud-init.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-create-three-guests-with-cloud-init.md index 888df9d..ffefa32 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-create-three-guests-with-cloud-init.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-create-three-guests-with-cloud-init.md @@ -127,24 +127,31 @@ qemu-img info /var/lib/libvirt/images/base.qcow2 **목적** — 다음 단계의 파일에 넣을 공개키 둘과 콘솔 비밀번호 하나를 손에 든다. -```bash label="[lab host] ① 자기 공개키를 만들거나 꺼낸다" -[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 +```bash label="[lab host] ① 자기 공개키를 꺼내 본다" cat ~/.ssh/id_ed25519.pub ``` -```bash label="[워크스테이션] ② 워크스테이션 공개키를 꺼낸다" +`ssh-ed25519 ...` 로 시작하는 줄이 나오면 키가 이미 있으니 ② 를 건너뛴다. `No such file or directory` 면 키가 없는 것이고, ② 로 만든 뒤 ① 을 다시 친다. + +```bash label="[lab host] ② 키가 없을 때만 만든다" +ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 +``` + +```bash label="[워크스테이션] ③ 워크스테이션 공개키를 꺼낸다" cat ~/.ssh/id_ed25519.pub ``` -```bash label="[lab host] ③ 콘솔용 비밀번호를 만든다" +```bash label="[lab host] ④ 콘솔용 비밀번호를 만든다" openssl rand -base64 18 ``` **예상 결과** — `ssh-ed25519 ...` 로 시작하는 줄 둘과 base64 한 줄이 화면에 나온다. 셋 다 다음 단계에서 붙여 넣으므로 창을 닫지 않는다. -**왜 필요한가** — 자리표시자를 문서에 남기지 않고 값을 찾는 명령을 함께 둔다. ③ 의 비밀번호는 cloud-init 의 `plain_text_passwd` 로 들어가고, 키가 안 들어갔을 때 게스트로 들어가는 유일한 통로가 된다. 이 문서에는 그 값을 싣지 않는다. +**왜 필요한가** — 자리표시자를 문서에 남기지 않고 값을 찾는 명령을 함께 둔다. ④ 의 비밀번호는 cloud-init 의 `plain_text_passwd` 로 들어가고, 키가 안 들어갔을 때 게스트로 들어가는 유일한 통로가 된다. 이 문서에는 그 값을 싣지 않는다. -**문제가 생기면** — ② 만 워크스테이션에서 친다. lab host 에서 두 번 쳐서 같은 키를 두 줄 넣으면 워크스테이션에서는 게스트에 못 붙는다. +**①②를 나눠 둔 까닭** — 이 실험대는 `[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen …` 한 줄로 쳤다(observed). 그 형태는 「있으면 두고 없으면 만든다」를 단축 평가에 접어 넣어서, 배울 것보다 `[ -f … ]` 와 `||` 를 먼저 읽게 만든다. 나눠 두면 키가 이미 있는지가 ① 의 출력에 그대로 보이고, 만드는 명령은 그때만 친다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown). + +**문제가 생기면** — ③ 만 워크스테이션에서 친다. lab host 에서 두 번 쳐서 같은 키를 두 줄 넣으면 워크스테이션에서는 게스트에 못 붙는다. ### 3. 게스트마다 cloud-init 파일을 쓴다 @@ -156,7 +163,7 @@ openssl rand -base64 18 nano kc-lab-1.yaml ``` -② `__` 로 둘러싼 세 곳에 2번의 ①②③ 출력을 넣는다. +② `__` 로 둘러싼 세 곳에 2번의 ①③④ 출력을 넣는다. ```yaml label="kc-lab-1.yaml 에 쓸 내용" #cloud-config @@ -547,7 +554,7 @@ sudo cloud-init status --long sudo journalctl -u cloud-init -n 50 ``` -콘솔 로그인에 쓰는 비밀번호가 2번 ③ 으로 만든 값이다. 이 한 장과 이 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. +콘솔 로그인에 쓰는 비밀번호가 2번 ④ 로 만든 값이다. 이 한 장과 이 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. ## 통과 조건을 한 번에 다시 본다 @@ -579,8 +586,41 @@ SSH 가 안 붙는 증상 넷을 갈라 보는 방법은 이 표보다 자세한 - (observed) 게스트 세 대의 IP·MAC·vCPU·메모리, `Debian GNU/Linux 12 (bookworm)`, `cloud-init status: done`, cloud-init 대기 약 50초, 스키마 검사기의 거부 문구, `Valid cloud-config` 한 줄, 예약을 넣을 때와 다시 넣을 때의 문구, `virt-install` 의 네 줄 출력. - (observed) 메모리는 처음 만들 때 3584MB 였고 실험을 늘리며 5120 과 4096 으로 재배분했다. 세 게스트의 배정 합 10,240MB 는 호스트 RAM 11,648MiB 보다 작고, 배정하지 않고 남은 것이 1,408MiB 다. 「Guest configured memory 총량이 Host physical RAM보다 크다」는 Memory Overcommit 에 해당하지 않는 배치다. - (unknown) `kc-lab-1.yaml` 을 템플릿에서 어떻게 만드는지가 원본에도 없다. `kc-lab.yaml.example` 이 반입되지 않아 대조하지 못했다. -- (unknown) cloud-init 의 `packages` 에 certbot 이 들어 있었는지가 가이드 안에서 갈린다. 01 의 예시와 03 의 본문은 `curl` 과 `nftables` 뿐이라 적고, 04 는 템플릿의 `packages` 에 certbot 이 있다고 적는다. -- (unknown) 파일을 옮겨 검사하는 다섯 줄 형태, `virsh vol-list` 와 `vol-info`, `net-dumpxml --inactive`, `cloud-init status --long`, `virsh console` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다. +- (observed) 2026-09-17 에 확인 ①·②·③ 을 그대로 쳤고 셋 다 위에 실은 대로 나왔다. 세 도메인이 `running`, `192.168.122.11` 의 `hostname` 이 `kc-lab-1` 이고 `PRETTY_NAME` 이 Debian 12, 엣지가 `192.168.122.10/24` 와 `status: done`, `virsh screenshot` 이 `.ppm` 이름으로 저장하면서 `with type of image/png` 라고 답하고 `file` 도 PNG 라고 읽는다. `virsh list` 의 Id 는 `13`·`15`·`16` 이고 줄 순서도 위와 다른데, 본문이 적은 대로 그 번호에는 뜻이 없다. +- (observed) **cloud-init 의 `packages` 에 certbot 은 없다.** 2026-09-17 에 갈렸다. 뜬 게스트가 실제로 받은 user-data 를 열어 보니 `packages: [curl, nftables]` 한 줄이고, 저장소 템플릿에는 `nginx` · `certbot` · `python3-certbot-dns-cloudflare` 셋이 **주석으로 막혀** 있다. 넷 중 `curl` 과 `nftables` 만 산다. 04 의 표에 `kc-lab-edge` 가 `nginx · certbot` 으로 적힌 것은 그 게스트가 **끝나면 맡을 역할**이지 cloud-init 이 깔아 준 것이 아니다 — nginx 는 03 의 1번이, certbot 은 04 의 1번이 직접 깐다. + +```bash label="[kc-lab-edge] 실제로 받은 user-data 를 연다" +sudo grep -n "packages" /var/lib/cloud/instance/user-data.txt +``` + +```text label="그 출력" +19:packages: [curl, nftables] +``` +- (observed) `virsh vol-info` · `net-dumpxml --inactive` · `cloud-init status --long` 은 2026-09-17 에 쳤다. 볼륨 일곱의 용량과 실제 할당은 이렇다 — **선언한 크기와 디스크가 실제로 먹는 양이 크게 다르다.** 오버레이라 쓴 만큼만 먹는다. + +```text +base.qcow2 Capacity=3.00 GiB Allocation=323.25 MiB +kc-lab-1.qcow2 Capacity=20.00 GiB Allocation=5.40 GiB +kc-lab-2.qcow2 Capacity=20.00 GiB Allocation=3.85 GiB +kc-lab-edge.qcow2 Capacity=10.00 GiB Allocation=316.26 MiB +seed-kc-lab-{1,2,edge}.iso Capacity=370.00 KiB Allocation=372.00 KiB +``` + + `net-dumpxml default --inactive` 는 `virbr0` 과 동적 대역 한 줄과 예약 세 줄을 낸다 — `--inactive` 를 붙여도 예약이 그대로 보이는 것이 `--config` 로 넣었다는 증거다. + +- (observed) `cloud-init status --long` 의 마지막 줄이 이 문서의 7번을 그대로 뒷받침한다. + +```text +status: done +boot_status_code: enabled-by-generator +last_update: Thu, 17 Sep 2026 04:24:22 +0000 +detail: +DataSourceNoCloud [seed=/dev/vdb][dsmode=net] +``` + + `seed=/dev/vdb` 가 핵심이다. 시드를 `--cloud-init` 으로 붙였다면 SATA CD-ROM(`sr0`)이 됐을 텐데, `bus=virtio` 로 디스크로 붙였기 때문에 `vdb` 로 잡혔고 cloud-init 이 거기서 데이터소스를 찾았다. 7번이 적은 이유가 이 한 줄로 확인된다. + +- (unknown) 파일을 옮겨 검사하는 다섯 줄 형태와 `virsh console` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다. `virsh console` 은 대화형이라 비대화식으로 밟지 않았다. - (unknown) 편집기로 `kc-lab-1.yaml` 과 `meta-kc-lab-1` 을 여는 형태도 이 실험대가 치지 않았다. 이 실험대는 치환 명령과 서식 출력으로 만들었고, 같은 상태에 닿는지는 다시 재지 않았다. - (unknown) 원본 가이드에 되돌리는 절차가 없다. 단계 03 이 지나가며 적은 한 줄 말고는 게스트와 시드 볼륨과 DHCP 예약을 걷어내는 순서가 어디에도 없다. - (inferred) `virt-install` 세 줄은 구축할 때 친 것을 옮겼고 재실행으로 검증되지 않았다. diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-edge-nginx-and-host-dnat.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-edge-nginx-and-host-dnat.md index c376b85..3568918 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-edge-nginx-and-host-dnat.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-edge-nginx-and-host-dnat.md @@ -62,7 +62,7 @@ sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 **4번과 5번만 호스트에서 치는 까닭이 둘이다.** DNAT 규칙의 첫 줄이 `iifname "tailscale0"` 인데 VM 에는 Tailscale 을 넣지 않기로 했으므로 엣지에는 그 인터페이스 자체가 없고, 넘기는 대상이 `192.168.122.10` 으로 가는 트래픽이라 넘기는 주체는 그 앞에 있는 호스트다. 엣지에 들어가서 치면 `tailscale0` 이 없어 규칙이 의미가 없다. -여는 파일은 셋이다 — nginx 라우팅 설정, nftables DNAT 파일, systemd 유닛. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. `nano` 는 저장이 `Ctrl+O` 다음 `Enter`, 나가기가 `Ctrl+X` 다. 설치와 링크와 문법 검사와 reload 는 운영자가 그대로 치는 명령을 쓴다. +여는 파일은 넷이다 — nginx 라우팅 설정, nftables DNAT 파일, systemd 유닛, CoreDNS 매니페스트. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. `nano` 는 저장이 `Ctrl+O` 다음 `Enter`, 나가기가 `Ctrl+X` 다. 설치와 링크와 문법 검사와 reload 는 운영자가 그대로 치는 명령을 쓴다. ## 이 단계가 세우는 것 @@ -108,8 +108,9 @@ sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 | 규칙만 즉시 걷어낸다 | `[lab host]` | `sudo nft delete table ip lab_edge` | | libvirt 에 뚫은 구멍을 막는다 | `[lab host]` | `sudo nft -a list chain ip libvirt_network guest_input` 로 handle 을 보고 `sudo nft delete rule ip libvirt_network guest_input handle <번호>` | | 엣지 라우팅을 끈다 | `[kc-lab-edge]` | `sudo rm /etc/nginx/sites-enabled/keycloak-lab && sudo nginx -t && sudo systemctl reload nginx` | +| 클러스터 안에 더한 이름을 걷어낸다 | `[lab host]` | `kubectl -n kube-system delete configmap coredns-custom` 하고 `kubectl -n kube-system delete pod -l k8s-app=kube-dns` | -`sites-available` 의 원본은 남으므로 `ln -sf` 로 다시 걸면 복구된다. 네 줄이 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown). +`sites-available` 의 원본은 남으므로 `ln -sf` 로 다시 걸면 복구된다. **앞 네 줄이 가이드 원문 그대로이고, 마지막 줄은 이 기록이 더한 것이다** — CoreDNS 매니페스트를 이 기록이 처음 넣었는데 걷어내는 명령이 빠져 있었다. 다섯 줄 어느 것도 실제로 쳐서 걷어내 본 기록은 없다(unknown). 마지막 줄을 치면 클러스터 안에서 `auth.hyeonworks.com` 이 다시 안 풀리므로, 그 이름에 의존하는 실험을 끝낸 뒤에 친다. **`.nft` 파일 맨 위의 `delete` 는 이것과 다르다.** 그 두 줄은 파일을 적용할 때마다 자동으로 도는 재적용 안전장치이고, 위 표의 삭제 명령은 사람이 끄는 버튼이다. @@ -491,15 +492,34 @@ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks. 301 https://auth.hyeonworks.com/ ``` -**그 `301` 도 다음 단계 이후의 값이다.** 여기까지만 했다면 ② 와 같은 `404` 가 정상이고, 이 층이 묻는 것은 코드값이 아니라 밖에서 친 것이 엣지까지 닿았는가다. +**그 `301` 은 다음 단계 이후의 값이다.** 여기까지만 했을 때 나오는 것은 `302` 이고 보내는 곳도 다르다. 2026-09-17 에 밖에서 재 보니 이랬다(observed). -2026-09-17 에 밖에서 재 보니 닿지 않았다(observed). +```text +302 https://auth.hyeonworks.com/admin/ +``` + +**리다이렉트를 내는 것이 둘이고, 어느 쪽이 답하느냐가 단계에 따라 바뀐다.** 04 를 끝내면 엣지 nginx 의 `return 301 https://$host$request_uri` 가 먼저 답해 `301 https://auth.hyeonworks.com/` 이 되고, 03 까지만 했으면 그 블록이 없으므로 요청이 Keycloak 까지 가서 **Keycloak 이** `/` 를 `/admin/` 으로 보낸다. 코드도 `301` 이 아니라 `302` 다. + +`Location` 이 `https://` 인 것도 엣지가 아니라 Keycloak 이 정한다 — `KC_HOSTNAME=https://auth.hyeonworks.com` 과 `KC_HOSTNAME_STRICT=true` 가 그렇게 시킨다. **요청이 `http` 로 들어와도 Keycloak 은 자기가 아는 주소로 답한다.** + +**같은 엣지에 어떻게 치느냐가 코드를 가른다**(2026-09-17, observed). 이 표를 먼저 보고 층 ②③ 을 읽는다. + +| 어떻게 치나 | 코드 | +|---|---| +| Host 없이 엣지 IP 로 (층 ②) | `404` — Traefik 에 매칭되는 Ingress 가 없다 | +| `Host: auth.hyeonworks.com` 으로 엣지 IP 에 | `302 https://auth.hyeonworks.com/admin/` | +| 밖에서 도메인으로 (층 ③) | `302 https://auth.hyeonworks.com/admin/` | +| 밖에서 도메인 + `/realms/master` | **`200`** | + +마지막 줄이 중요하다 — **TLS 를 얹기 전에도 `/realms/master` 는 `200` 이다.** 층 ④ 의 `200` 은 「TLS 가 섰다」가 아니라 「끝까지 이어졌다」를 재는 값이고, `https` 로 그 값을 받는 것이 04 의 몫이다. + +밖에서 닿지 않을 때의 모양도 적어 둔다. 2026-09-17 에 구멍을 뚫기 전에는 이랬다(observed). ```text curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms ``` -같은 시각에 호스트 안에서 친 `http://192.168.122.10` 은 `404` 로 답했다. 아래 「막히면」 표의 마지막 줄이 가리키는 상태 그대로였고, 원인도 거기 적힌 대로였다 — **그 호스트에 깔려 있는 유닛에 `ExecStartPost` 줄이 없다.** 아래 「배포된 것과 적어 둔 것이 다르다」를 본다. +같은 시각에 호스트 안에서 친 `http://192.168.122.10` 은 `404` 로 답했다. 아래 「막히면」 표의 마지막 줄이 가리키는 상태 그대로였고, 원인도 거기 적힌 대로였다 — **그 호스트에 깔려 있던 유닛에 `ExecStartPost` 줄이 없었다.** 아래 「배포된 것과 적어 둔 것이 다르다」를 본다. 그 줄을 넣자 같은 `curl` 이 `302` 를 받았다(observed). ### 확인 ④ 끝까지 닿나 @@ -566,9 +586,10 @@ grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c | 우리 테이블 | `sudo nft list table ip lab_edge` | `dnat to 192.168.122.10` · 포트 `80, 443` · SNAT 없음 | | 층 ① | `curl -I http://192.168.122.11` | `404` | | 층 ② | `curl -sS -D - -o /dev/null http://192.168.122.10` | `404` · `Server: nginx/1.22.1` | -| 층 ③ | `curl -I http://auth.hyeonworks.com` | ② 와 같은 응답이 밖에서도 온다 | +| 층 ③ | `curl -I http://auth.hyeonworks.com` | `302` · `Location` 이 `https://auth.hyeonworks.com/admin/` | +| 층 ④ 를 `http` 로 미리 | `curl -s -o /dev/null -w '%{http_code}\n' http://auth.hyeonworks.com/realms/master` | **`200`** | -층 ④ 는 다음 단계가 인증서를 얹은 뒤에 통과한다. **층 ②③ 의 `301` 도 그때 나온다** — 여기서는 `404` 가 통과다. +층 ④ 를 `https` 로 받는 것은 다음 단계가 인증서를 얹은 뒤다. **층 ③ 의 `301` 도 그때 나온다** — 여기서는 `302` 가 통과이고, 그 둘을 내는 주체가 다르다(위 표). ## 막히면 @@ -609,14 +630,209 @@ grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service `0` 과 `1` 이 나와야 맞다. 반대로 나오면 고치기 전 판이 깔려 있는 것이고, 다시 써서 `sudo systemctl daemon-reload && sudo systemctl restart lab-edge-dnat.service` 로 올린다. -**고쳤는지는 확인하지 못했다**(unknown). 그 호스트의 `sudo` 는 비밀번호를 묻고 이번 작업에는 그 비밀번호가 없었다. `nft list table ip lab_edge` 도 `guest_input` 체인 조회도 못 돌렸고 두 파일을 다시 배포하지도 않았다. 위 표는 **파일 내용과 타임스탬프만으로** 적은 것이고 `reject` 줄의 카운터가 실제로 올라갔는지는 안 봤다. +**2026-09-17 늦게 그 `sudo` 가 열려 셋을 마저 봤다**(observed). 결론은 **절반만 고쳐져 있다**이다. + +```text label="위 대조 두 줄의 실제 값" +grep -c 'chain forward' /etc/nftables.d/lab-edge-dnat.nft → 1 (0 이어야 한다) +grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service → 1 (맞다) +``` + +유닛에는 `ExecStartPost` 가 들어갔는데 `.nft` 는 아직 옛 판이다 — **효과 없는 `forward` 체인을 아무도 걷어내지 않았다.** 위 표의 두 줄 가운데 아래만 고쳐졌으므로, `.nft` 를 이 문서의 4번 내용으로 다시 쓰고 `sudo systemctl restart lab-edge-dnat.service` 로 올려야 이 문서가 싣는 판이 된다. + +```bash label="[lab host] guest_input 체인을 연다" +sudo nft list chain ip libvirt_network guest_input +``` + +```text label="그 출력 — 같은 규칙이 셋이다" +oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 44 bytes 2640 accept +oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 0 bytes 0 accept +oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 141705 bytes 2375116597 accept +oif "virbr0" counter packets 5 bytes 300 reject +oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 10 bytes 600 accept +``` + +**구멍이 하나가 아니라 셋이다.** `ExecStartPost` 가 `nft insert` 로 맨 앞에 끼워 넣기 때문에, 유닛을 다시 시작할 때마다 같은 규칙이 한 줄씩 늘어난다. **이 명령은 여러 번 쳐도 안전한 형태가 아니다.** 기능은 멀쩡하다 — 맨 앞 하나가 통과시키고 나머지는 카운터가 안 오르거나(둘째 줄 `0`) 아예 닿지 않는다(다섯째 줄은 `reject` 뒤라 새 패킷이 거기까지 못 간다). + +`reject` 줄의 카운터는 `5` 다. 구멍이 들어가기 전에 막힌 것들이고, 들어간 뒤로는 안 오른다. 맨 앞 줄이 `44` 를 세고 있는 것이 지금 밖에서 오는 요청이 그 규칙으로 통과한다는 뜻이다. + +## 클러스터 안에서도 공개 이름에 못 닿는다 — 이 문서에 없던 또 한 단계 + +랩 호스트만의 일이 아니다. **파드 안에서도 같은 이름이 같은 이유로 막힌다**(2026-09-17, observed). 이름은 호스트의 tailnet 주소로 풀리는데 그 포트를 듣는 것이 없다. + +```bash label="[lab host] ① 파드 안에서 이름을 풀고 포트를 두드린다" +kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com" +``` + +```text label="① 의 출력 — 고치기 전" +100.83.212.4 auth.hyeonworks.com +``` + +같은 파드에서 `100.83.212.4` 의 80 과 443 을 두드리면 둘 다 닫혀 있고, 엣지 게스트의 `192.168.122.10:80` 은 열려 있다. 길은 있는데 **이름이 그 길을 안 가리킨다.** 클러스터 DNS 어디에도 그 매핑이 없다 — CoreDNS 의 `NodeHosts` 에는 노드 둘뿐이고 노드의 `/etc/hosts` 에도 없다(observed). + +여기서 걸리는 것은 토큰을 검증하는 앱이다. 토큰을 받는 쪽은 클러스터 안 주소를 따로 갖고 있어서 안 걸리는데, 검증하는 쪽은 공개 이름 하나만 갖고 있어서 걸린다. + +**고치는 한 단계는 CoreDNS 에 서버 블록을 하나 더 주는 것이다.** + +```bash label="[lab host] ② 매니페스트 파일을 연다" +nano coredns-custom.yaml +``` + +```yaml label="③ coredns-custom.yaml 에 쓸 내용" +apiVersion: v1 +kind: ConfigMap +metadata: + name: coredns-custom + namespace: kube-system +data: + hyeonworks.server: | + hyeonworks.com:53 { + hosts { + 192.168.122.10 auth.hyeonworks.com + 192.168.122.10 app1.hyeonworks.com + 192.168.122.10 app2.hyeonworks.com + fallthrough + } + forward . /etc/resolv.conf + } +``` + +```bash label="[lab host] ④ 적용하고 CoreDNS 파드를 다시 띄운다" +kubectl apply -f coredns-custom.yaml +kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false +``` + +이 실험대는 같은 매니페스트를 `kubectl apply -f - <<'YAML'` 힙독으로 흘려 넣었다(observed). 힙독은 ConfigMap 을 명령 안에 묻어 버려서, 서버 블록을 한 줄 고치려면 같은 힙독을 처음부터 다시 친다. 파일로 두면 열어 보고 고쳐서 ④ 를 다시 치면 된다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown). + +**파일 이름이 `.server` 인 것이 핵심이다.** k3s 는 `*.override` 를 기본 서버 블록 안에 끼워 넣는데, 그 블록은 이미 `hosts /etc/coredns/NodeHosts` 로 `hosts` 를 한 번 쓰고 있다. `.override` 에 `hosts` 를 또 쓰면 CoreDNS 가 안 뜬다. + +```text label="`.override` 로 넣었을 때 — CrashLoopBackOff" +plugin/hosts: this plugin can only be used once per Server Block +``` + +`.server` 는 **별개의 서버 블록**으로 들어가므로 그 안에서 `hosts` 를 처음 쓰는 것이 된다. 뜨고 나면 로그 머리에 듣는 영역이 둘로 찍힌다. + +```text label="제대로 들어갔을 때" +.:53 +hyeonworks.com.:53 +``` + +```bash label="[lab host] ⑤ 파드에서 다시 푼다" +kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com" +``` + +```text label="⑤ 의 출력 — 고친 뒤" +192.168.122.10 auth.hyeonworks.com +``` + +이제 `192.168.122.10:80` 이 파드에서 열린다. **443 은 여전히 닫혀 있다** — 그것은 다음 단계가 인증서를 얹어야 열린다. + +## 이 단계를 밟고 나면 랩 호스트에서 공개 URL 을 못 친다 + +nginx 가 호스트에 있던 동안에는 호스트에서 `curl https://auth.hyeonworks.com` 이 그냥 됐다. 이 단계가 nginx 를 엣지 게스트로 옮기면서 그것이 끊긴다. 2026-09-17 에 재 보니 80 도 443 도 안 열린다(observed). + +```bash label="[lab host] ① 문서에 적힌 대로 쳐 본다" +curl -s -m 10 -o /dev/null -w "%{http_code}\n" https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` + +```text label="① 의 출력" +000 +Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server +``` + +이유는 두 줄이다. `auth.hyeonworks.com` 은 호스트 자신의 tailnet 주소로 풀리고, 호스트에는 그 포트를 듣는 것이 없다. + +```bash label="[lab host] ② 왜 그런지 두 줄로 본다" +getent hosts auth.hyeonworks.com +ss -lnt | grep -E ":(80|443) " +``` + +```text label="② 의 출력" +100.83.212.4 auth.hyeonworks.com +``` + +두 번째 줄은 아무것도 안 낸다. 4번의 DNAT 은 `iifname "tailscale0"` 만 매칭하므로 호스트가 스스로 낸 패킷은 그 규칙을 안 탄다. 그래서 **밖에서 치는 확인은 tailnet 에 붙은 다른 기계에서 친다** — 04 가 자기 4번 확인을 엣지가 아닌 다른 머신으로 보낸 것과 같은 이유다. + +랩 호스트에서 꼭 쳐야 하면 이름은 그대로 두고 주소만 엣지로 못박는다. + +```bash label="[lab host] ③ 이름은 그대로, 주소만 엣지로" +curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \ + http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs +``` + +이 형태는 응답을 받는다(observed). 다만 DNAT 을 안 거치므로 **층 ③ 을 재는 것이 아니다.** 층 ② 를 도메인 이름으로 재는 것이고, 4번과 5번이 제대로 들어갔는지는 여전히 밖에서 쳐야 갈린다. + +## `X-Forwarded-For` 계약은 지금 성립하지 않는다 + +4번에서 SNAT 을 안 거는 이유로 「masquerade 를 붙이면 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 된다」고 적었다. 2026-09-17 에 밖에서 한 번 치고 엣지의 로그를 열었더니 **이미 그 상태였다**(observed). + +```bash label="[dev] ① 밖에서 친다" +curl -s -o /dev/null http://app1.hyeonworks.com/api/echo +``` + +```bash label="[kc-lab-edge] ② 엣지가 누구를 클라이언트로 적었나" +sudo tail -3 /var/log/nginx/access.log +``` + +```text label="② 의 출력" +192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0" +``` + +`lab_edge` 테이블에는 `masquerade` 도 `snat` 도 없다. 4번이 싣는 파일 그대로다. 그런데도 출발지가 덮여 있으므로 덮는 것은 이 테이블이 아니다. **덮는 것은 Tailscale 이다**(2026-09-17, observed). 호스트의 규칙을 전부 읽어서 갈랐다. + +```bash label="[lab host] 누가 덮는지 규칙에서 찾는다" +sudo nft list ruleset | grep -nE 'masquerade|snat' +sudo iptables -t nat -S | grep ts-postrouting +sudo tailscale debug prefs | grep -i snat +``` + +```text label="세 줄이 이어진다" +chain ts-forward { iifname "tailscale0" counter ... xt target "MARK" } +-A ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE +"NoSNAT": false +``` + +읽는 순서는 이렇다. `tailscale0` 으로 들어와 전달되는 패킷에 Tailscale 이 표식 `0x40000` 을 찍고, `ts-postrouting` 이 그 표식이 붙은 것을 전부 `MASQUERADE` 한다. 마스커레이드는 나가는 인터페이스의 주소로 출발지를 바꾸는데, 이 패킷이 나가는 곳이 `virbr0` 이라 출발지가 `192.168.122.1` 이 된다. `NoSNAT` 이 `false` 인 것이 그 동작이 켜져 있다는 뜻이고, 그것이 Tailscale 의 기본값이다. + +**그래서 4번의 경고는 옳았는데 엉뚱한 테이블을 지켰다.** 「masquerade 를 붙이면 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 된다」는 그대로 일어났고, 다만 그 `masquerade` 를 붙인 것이 우리 테이블이 아니라 Tailscale 이다. `lab_edge` 만 깨끗하게 지켜도 소용이 없다. + +libvirt 의 마스커레이드는 이 일과 무관하다. 그 규칙은 `ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24` 이라 **게스트에서 밖으로 나가는** 것만 고르고, 밖에서 게스트로 들어오는 이 패킷은 목적지가 그 대역 안이라 안 걸린다. + +범인보다 결과가 먼저다. `proxy_set_header X-Forwarded-For $remote_addr` 는 시키는 대로 동작하고 있고, 다만 `$remote_addr` 가 이미 호스트의 브리지 주소다. 그래서 **이 실험대의 `X-Forwarded-For` 는 진짜 클라이언트를 담지 않는다.** 통과 조건에 「엣지 로그의 출발지가 밖에서 친 기계다」를 넣으면 늘 실패한다. + +## Traefik 이 그 헤더를 다시 덮는다 — 이 문서에 없던 한 단계 + +위 2번이 세운 헤더가 앱까지 가는지를 처음으로 재 봤다. **안 간다.** Traefik 은 자기가 신뢰하지 않는 곳에서 온 `X-Forwarded-*` 를 자기 연결 기준으로 다시 쓰고, 기본값에는 신뢰 목록이 비어 있다. + +```bash label="[dev] ① 앱이 무엇을 받았나" +curl -s http://app1.hyeonworks.com/api/echo +``` + +| 헤더 | 이 한 단계를 안 밟았을 때 | 밟은 뒤 | +|---|---|---| +| `x-real-ip` | `10.42.0.1` — flannel 게이트웨이. 엣지가 보낸 값이 사라졌다 | `192.168.122.1` — 엣지가 보낸 값 그대로 | +| `remoteAddr` | `10.42.0.1` | `192.168.122.1` | +| `x-forwarded-for` | 없다 | 없다 | + +빠진 한 단계는 아래 두 줄이다. 저장소에 파일이 있는데 **이 실험대의 setup 아홉 편 어디에도 이 명령이 없었다**(observed). + +```bash label="[lab host] ② Traefik 에 신뢰 목록을 준다" +kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml +kubectl -n kube-system rollout status deploy/traefik --timeout=180s +``` + +목록에는 파드 대역 `10.42.0.0/16` 하나만 담는다. Traefik 의 Service 가 `externalTrafficPolicy: Cluster` 라 svclb 가 출발지를 덮고, 그래서 Traefik 에 닿는 주소는 엣지가 아니라 파드 대역에서 온다. 노드·호스트 대역을 같이 넣어 봤지만 위 표의 값이 하나도 안 바뀌었다 — 나타날 수 없는 대역이라 넣어도 소용이 없다(observed). + +`x-forwarded-for` 는 이 단계를 밟아도 앱까지 안 온다(observed). **왜 사라지는지는 안 가렸다**(unknown). 앞 절과 합치면 이 실험대에서는 그 헤더로 클라이언트를 가릴 수 없다. 두 번 재서 두 번 다 없었다. ## 무엇이 관측이고 무엇이 아닌가 - (observed) 2026-09-11 새로 만든 엣지에서 `/etc/nginx` 가 없던 것, `nginx -t` 출력 세 줄, 다 끝난 실험대에서 잰 층별 확인 ①~④ 의 코드, `301 https://auth.hyeonworks.com/`, upstream 실패 errno 세 줄, access 로그 3492자. - (observed) 2026-09-17 에 이 절차를 따라 엣지를 다시 세우고 잰 것 — `nginx -t` 가 두 줄인 것, `which nginx` 가 깔린 상태에서도 빈손인 것, 층 ① 이 `.11`·`.12` 둘 다 `404` 인 것, 층 ② 가 `404` · `Server: nginx/1.22.1` 인 것, 층 ③ 이 `curl: (7)` 인 것, 호스트의 두 파일이 위 본문과 다른 것. +- (observed) 2026-09-17 에 밖에서 친 요청이 엣지 access 로그에 `192.168.122.1` 로 적힌 것, 이 한 단계를 밟기 전 앱이 받은 `x-real-ip` 가 `10.42.0.1` 이고 밟은 뒤 `192.168.122.1` 인 것, 두 상태 모두 `x-forwarded-for` 가 없는 것, setup 아홉 편에 `traefik-forwarded-headers.yaml` 을 거는 명령이 없는 것. +- (observed) 엣지에 닿기 전에 출발지를 덮는 것은 **Tailscale** 이다. `ts-forward` 가 표식 `0x40000` 을 찍고 `ts-postrouting` 이 그 표식에 `MASQUERADE` 를 걸며 `NoSNAT` 이 `false` 다. 호스트에 깔린 두 파일의 실제 상태(`.nft` 는 옛 판, 유닛은 새 판)와 `guest_input` 의 중복 규칙 셋도 같이 봤다. +- (unknown) `x-forwarded-for` 가 어디서 사라지는지는 아직 안 가렸다. Traefik 에 신뢰 목록을 주기 전과 뒤 둘 다 앱까지 안 오는 것만 쟀다. - (observed) 위에 실은 `.nft` 와 유닛의 내용은 저장소 원본과 같다. 다만 **호스트에 실제로 깔려 있는 두 파일은 그것이 아니다** — 「배포된 것과 적어 둔 것이 다르다」를 본다. -- (inferred) 이 이동으로 L7 홉 수가 2홉 그대로라는 판정이 이 배치의 전제다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약이 그대로 성립한다. +- (inferred) 이 이동으로 L7 홉 수가 2홉 그대로라는 판정이 이 배치의 전제다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 홉 수 자체는 맞다. 다만 **홉 수가 그대로여도 `X-Forwarded-*` 계약은 성립하지 않았다** — 위 두 절에서 실제로 재 보니 클라이언트 주소가 엣지에 닿기 전에 덮이고, Traefik 이 신뢰 목록 없이 한 번 더 덮었다. - (unknown) `curl -I http://192.168.122.11` 의 전체 출력은 캡처해 두지 않았다. 가이드도 봐야 할 줄만 적었다. - (unknown) `systemctl status nginx` 두 번과 `ls -l /etc/nginx/sites-enabled/`, 파일 찾기, 체인 조회, `journalctl -u nginx`, access 로그 두 줄은 가이드가 적어 둔 명령이고 출력이 남아 있지 않다. - (unknown) libvirt 의 `firewall_backend` 가 iptables 일 때도 `guest_input` 구멍이 필요한지는 재지 않았다. 이 호스트는 nftables 백엔드다. diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-install-k3s-server-and-agent.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-install-k3s-server-and-agent.md index 154d3db..70b66ce 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-install-k3s-server-and-agent.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-install-k3s-server-and-agent.md @@ -58,7 +58,7 @@ sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 | 4 | agent 설치 | 토큰 파일을 옮긴 뒤 `[kc-lab-2]` 안에서 | | 5 | 워크스테이션에서 쓰기 | `[워크스테이션]` | -편집기를 여는 곳은 한 군데다. 2번에서 kubeconfig 의 `server:` 줄 하나를 고친다. +편집기를 여는 곳은 둘이다. 2번에서 kubeconfig 의 `server:` 줄 하나를 고치고, 4번에서 agent 유닛의 토큰 경로 한 줄을 고친다. ## 이 단계가 세우는 것 @@ -266,19 +266,29 @@ sudo install -m 600 -o root -g root ~/node-token /etc/rancher/node-token sudo ls -l /etc/rancher/node-token ``` -```bash label="[kc-lab-2] ⑦ 유닛이 그 자리를 보게 고치고 다시 읽는다" -sudo sed -i "s|$HOME/node-token|/etc/rancher/node-token|" /etc/systemd/system/k3s-agent.service +```bash label="[kc-lab-2] ⑦ agent 유닛을 편집기로 연다" +sudo nano /etc/systemd/system/k3s-agent.service +``` + +⑧ `ExecStart=` 아래에서 `'--token-file'` 다음 줄의 경로를 `/etc/rancher/node-token` 으로 고친다. 고치고 나면 그 두 줄이 이렇게 보인다. + +```text label="⑧ 고치고 난 뒤의 k3s-agent.service 두 줄" + '--token-file' \ + '/etc/rancher/node-token' \ +``` + +```bash label="[kc-lab-2] ⑨ 유닛을 다시 읽히고 agent 를 재시작한다" sudo systemctl daemon-reload sudo systemctl restart k3s-agent ``` -```bash label="[kc-lab-2] ⑧ 이제 홈의 사본을 지운다" +```bash label="[kc-lab-2] ⑩ 이제 홈의 사본을 지운다" rm ~/node-token ``` :::warning -**⑥⑦ 을 건너뛰고 `rm ~/node-token` 만 치면 이 노드는 다시 못 뜬다.** 설치 스크립트가 `--token-file` 에 준 경로를 유닛의 `ExecStart` 에 그대로 굽기 때문에, 파일이 사라지면 agent 가 영원히 그 파일을 기다린다. 2026-09-17 에 A-4 를 돌려 노드 전원을 뽑았다 다시 켜면서 그대로 겪었다(observed) — VM 은 떴는데 노드가 `NotReady` 에서 안 돌아왔다. +**⑥부터 ⑨ 까지를 건너뛰고 `rm ~/node-token` 만 치면 이 노드는 다시 못 뜬다.** 설치 스크립트가 `--token-file` 에 준 경로를 유닛의 `ExecStart` 에 그대로 굽기 때문에, 파일이 사라지면 agent 가 영원히 그 파일을 기다린다. 2026-09-17 에 A-4 를 돌려 노드 전원을 뽑았다 다시 켜면서 그대로 겪었다(observed) — VM 은 떴는데 노드가 `NotReady` 에서 안 돌아왔다. ::: @@ -295,12 +305,14 @@ k3s-agent.service: Scheduled restart job, restart counter is at 1. '/etc/rancher/node-token' \ ``` -```bash label="[kc-lab-2] ⑨ lab host 로 나온다" +```bash label="[kc-lab-2] ⑪ lab host 로 나온다" exit ``` **예상 결과** — 설치가 끝나면 `k3s-agent.service` 가 그 노드에 서고, lab host 의 `kubectl get nodes` 에 `kc-lab-2` 가 한 줄 더 붙는다. +**⑦ 을 편집기로 여는 까닭** — 이 실험대는 `sudo sed -i "s|$HOME/node-token|/etc/rancher/node-token|" /etc/systemd/system/k3s-agent.service` 로 고쳤다(observed). `sed -i` 는 패턴이 안 맞아도 조용히 성공하고, 이 패턴에는 `$HOME` 이 들어 있어 설치할 때와 다른 계정으로 치면 아무 줄도 안 바뀐 채 ⑨ 가 이어서 돈다. 그러면 지금 떠 있는 노드는 멀쩡해 보이고 다음 재부팅에서야 `Waiting for file` 로 멎는다. 유닛을 열면 고칠 줄이 눈에 보이고, 고쳤는지도 저장하기 전에 눈으로 본다. 이 편집기 형태는 이 실험대에서 치지 않았다(unknown). + **왜 필요한가** — 설치 스크립트는 `sudo` 아래 root 로 도니 홈의 `600` 파일도 읽는다. `--token` 대신 `--token-file` 을 쓰면 토큰이 명령줄에 안 들어가므로 프로세스 목록과 셸 히스토리에 남지 않고, 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다. 이 실험대는 두 줄로 했다(observed). @@ -313,7 +325,7 @@ ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \ --node-ip 192.168.122.12; rm -f /tmp/token" ``` -두 줄 안에 원격 셸 둘과 `sudo` 둘, 파이프 하나, 설치 스크립트 하나, 마지막 삭제 하나가 겹쳐 있다. 실패했을 때 어느 쪽이 실패했는지 갈리지 않아 위에서는 ① 부터 ⑦ 까지로 나눴다. 토큰이 `/tmp/token` 대신 자기 홈에 놓이고 `sudo tee` 대신 `scp` 와 `chmod 600` 이 그 파일을 만드는 것도 그래서 달라진다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown). +두 줄 안에 원격 셸 둘과 `sudo` 둘, 파이프 하나, 설치 스크립트 하나, 마지막 삭제 하나가 겹쳐 있다. 실패했을 때 어느 쪽이 실패했는지 갈리지 않아 위에서는 ① 부터 ⑪ 까지로 나눴다. 토큰이 `/tmp/token` 대신 자기 홈에 놓이고 `sudo tee` 대신 `scp` 와 `chmod 600` 이 그 파일을 만드는 것도 그래서 달라진다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown). **문제가 생기면** — agent 설치는 성공했는데 노드가 안 보이면 로그에 `--token is required` 가 있는지 본다. 토큰이 빈 값이었으면 설치 스크립트는 내려받기와 유닛 생성과 활성화까지 다 성공으로 찍고 끝나고, 유닛은 `Restart=always` 라 5초마다 조용히 재시도한다. @@ -522,7 +534,25 @@ subject=O = system:nodes, CN = system:node:kc-lab-2 - (unknown) 3번과 4번의 나눈 형태는 이 실험대에서 치지 않았다. 이 실험대가 친 것은 원격 셸 둘을 파이프로 이은 두 줄이고, 나눈 형태로 같은 클러스터가 서는지는 다시 재지 않았다. - (unknown) 2번의 ②④ 도 이 실험대에서 치지 않았다. 이 실험대가 친 것은 치환 한 줄이고, 받아 놓고 편집기로 고친 kubeconfig 로 같은 클러스터가 보이는지는 다시 재지 않았다. - (unknown) 5번의 ②③도 이 실험대에서 치지 않았다. 이 실험대가 친 것은 원격 셸을 두 겹으로 겹친 한 줄이다. -- (unknown) 인증서 SAN 조회와 두 `journalctl` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다. SAN 에 두 주소가 들어 있다는 것은 주소 치환과 터널이 둘 다 통한다는 사실로 뒷받침된다(inferred). +- (observed) 인증서 SAN 조회와 두 `journalctl` 을 2026-09-17 에 쳤다. **SAN 에 두 주소가 다 들어 있다** — `127.0.0.1` 과 `192.168.122.11` 이라, kubeconfig 의 주소를 어느 쪽으로 고쳐도 검증이 통과한다. + +```text label="SAN 실측" +X509v3 Subject Alternative Name: + DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, + DNS:kubernetes.default.svc.cluster.local, DNS:localhost, DNS:kc-lab-1, + IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, + IP Address:192.168.122.11, IP Address:10.43.0.1, IP Address:192.168.122.11 +``` + + `192.168.122.11` 이 두 번 나오는데 중복이고 동작에는 영향이 없다. `10.43.0.1` 은 클러스터 안에서 API 서버를 부르는 Service 주소다. + +- (observed) **`journalctl -u k3s-agent` 는 정상일 때도 `E` 로 시작하는 줄이 가득하다.** 이것을 모르면 「막혔을 때 여기를 보라」는 안내대로 열었다가 멀쩡한 노드를 고장으로 읽는다. 같은 순간 `kubectl get nodes` 는 둘 다 `Ready` 였다. + +```text label="Ready 인 agent 의 로그" +E0917 07:42:16.919786 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55428: use of closed network connection" +``` + + `kubectl exec` 나 `logs` 로 연결했다가 끊을 때마다 한 줄씩 남는다. **봐야 할 것은 `E` 인지가 아니라 문구다** — 토큰이나 주소가 틀렸으면 `failed to get CA certs` 나 `401 Unauthorized` 가 나온다. - (unknown) 원본 가이드에 되돌리는 절차가 없다. `k3s-uninstall.sh` 라는 이름이 가이드에 한 번도 안 나오고 이 실험대도 부른 적이 없다. - (unknown) 설치 명령 두 줄은 재실행으로 검증되지 않았다. 토큰 108자도 k3s 판올림에 따라 달라진다. diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-keycloak-two-nodes-and-postgres-on-k3s.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-keycloak-two-nodes-and-postgres-on-k3s.md index a2d739c..dcb5d31 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-keycloak-two-nodes-and-postgres-on-k3s.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-keycloak-two-nodes-and-postgres-on-k3s.md @@ -55,6 +55,8 @@ source: 이 단계에는 손으로 쓰는 파일이 없다. 세우는 것이 전부 매니페스트 한 장에 들어 있고, 그 원문은 저장소의 `deploy/lab/k8s/keycloak-cluster.yaml` 이다. +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 단계가 세우는 것 가이드 05 의 「이 단계가 끝나면」은 두 줄이다. @@ -109,7 +111,8 @@ ls deploy/lab/k8s/ ### 확인 ② 이 이름이 아직 Keycloak 이 아니다 ```bash label="[lab host] 앞 단계에서 잰 값을 그대로 다시 잰다" -curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/ +curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/ ``` **어디를 봐야 하는가** — 앞 단계에서 잰 `404 tls=0` 이 그대로인가. **앞의 코드를 같이 본다.** @@ -465,7 +468,8 @@ vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0 **무엇을 확인하는가** — nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌는지. ```bash label="[lab host] 앞 두 단계와 같은 명령" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` ```text @@ -477,7 +481,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast **이 결과가 의미하는 것** — `200` 이면 2홉이 다 이어졌다. `502` 나 `503` 이면 뒤에서부터 되짚는다 — Ingress 가 있는지(확인 ①), Service 뒤에 파드가 있는지(확인 ④), 파드가 Ready 인지(확인 ②) 순서다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다. ```bash label="[lab host] 처음 보는 오류는 헤더까지 읽는다" -curl -I https://auth.hyeonworks.com/realms/master +curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` **이 `200` 은 앞 단계를 끝냈을 때의 값이다.** 인증서 단계를 건너뛰고 이 단계만 했다면 443 을 듣는 것이 없으므로 여기는 `000` 이다. 2026-09-17 에 그 상태에서 재 보니 이렇게 나왔다(observed). diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-power-cycle-the-lab-and-reallocate-guest-memory.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-power-cycle-the-lab-and-reallocate-guest-memory.md index 29706b5..eae80e5 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-power-cycle-the-lab-and-reallocate-guest-memory.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-power-cycle-the-lab-and-reallocate-guest-memory.md @@ -124,8 +124,9 @@ kubectl -n keycloak-lab scale deployment/postgres --replicas=0 kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s ``` -```bash label="[lab host] ③ 게스트를 ACPI 정상 종료한다" -virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2 +```bash label="[lab host] ③ 게스트를 한 대씩 ACPI 정상 종료한다" +virsh shutdown kc-lab-1 +virsh shutdown kc-lab-2 ``` ```bash label="[lab host] ④ 호스트를 끈다" @@ -136,6 +137,8 @@ sudo systemctl poweroff **왜 필요한가** — ③ 의 `virsh shutdown` 은 게스트 systemd 가 k3s 를 멈추고 k3s 가 컨테이너에 SIGTERM 을 보내는 연쇄다. 유예 시간이 짧으면 PostgreSQL 이 강제 종료되어 다음 기동에 crash recovery 가 돈다. ① 과 ② 로 미리 내려 두면 그 연쇄가 데이터베이스까지 닿지 않는다. 순서를 뒤집어 PostgreSQL 을 먼저 내리면 Keycloak 이 데이터베이스 없이 남아 기동 실패와 재시작을 반복한다. +**③ 의 두 줄을 `&&` 로 잇지 않는다.** 두 게스트는 서로 앞뒤가 없고, `A && B` 는 A 가 성공했을 때만 B 를 실행한다. `kc-lab-1` 이 이미 `shut off` 면 `virsh shutdown` 이 `domain is not running` 으로 실패해 `kc-lab-2` 는 켜진 채로 남고, 바로 다음 ④ 가 그 게스트를 강제로 끈다 — 이 단계가 막으려던 바로 그 상태다. 이 실험대는 `virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2` 로 쳤고(observed) 나눈 형태는 치지 않았다(unknown). + **문제가 생기면** — `wait` 가 `120s` 안에 안 끝나면 파드가 종료 중에 걸린 것이다. `kubectl -n keycloak-lab get pods -o wide` 로 어느 파드가 어느 노드에서 `Terminating` 인지 보고, 그 상태로 ③ 을 치지 않는다. ### 3. 종료의 역순으로 되살린다 @@ -144,25 +147,29 @@ sudo systemctl poweroff 게스트를 띄워 노드가 `Ready` 가 되기를 기다린 뒤, PostgreSQL 을 먼저 올리고 Keycloak 을 나중에 올린다. -```bash label="[lab host] ① 게스트를 띄우고 노드가 Ready 가 되기를 기다린다" -virsh start kc-lab-1 && virsh start kc-lab-2 +```bash label="[lab host] ① 게스트를 한 대씩 띄운다" +virsh start kc-lab-1 +virsh start kc-lab-2 +``` + +```bash label="[lab host] ② 노드가 Ready 가 되기를 기다린다" kubectl get nodes ``` -```bash label="[lab host] ② PostgreSQL 을 먼저 올리고 기동을 확인한다" +```bash label="[lab host] ③ PostgreSQL 을 먼저 올리고 기동을 확인한다" kubectl -n keycloak-lab scale deployment/postgres --replicas=1 kubectl -n keycloak-lab rollout status deployment/postgres ``` -```bash label="[lab host] ③ Keycloak 을 두 벌로 올린다" +```bash label="[lab host] ④ Keycloak 을 두 벌로 올린다" kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2 ``` -**예상 결과** — ① 의 `kubectl get nodes` 에 두 노드가 `Ready` 로 나온다. ② 의 `rollout status` 가 배포 완료로 돌아온 뒤에 ③ 을 친다. +**예상 결과** — ② 의 `kubectl get nodes` 에 두 노드가 `Ready` 로 나온다. ③ 의 `rollout status` 가 배포 완료로 돌아온 뒤에 ④ 를 친다. -**왜 필요한가** — PostgreSQL 이 먼저다. Keycloak 이 데이터베이스 없이 뜨면 기동에 실패하기 때문이다. 그리고 스케일을 0 으로 내려 둔 것은 자동으로 복구되지 않는다. 게스트를 켜고 노드가 `Ready` 가 돼도 파드 수는 0 그대로이므로 ② 와 ③ 을 명시적으로 쳐야 한다. +**왜 필요한가** — PostgreSQL 이 먼저다. Keycloak 이 데이터베이스 없이 뜨면 기동에 실패하기 때문이다. 그리고 스케일을 0 으로 내려 둔 것은 자동으로 복구되지 않는다. 게스트를 켜고 노드가 `Ready` 가 돼도 파드 수는 0 그대로이므로 ③ 과 ④ 를 명시적으로 쳐야 한다. ① 을 두 줄로 나눈 까닭은 2번의 ③ 과 같다 — `kc-lab-1` 이 이미 `running` 이면 `virsh start` 가 `domain is already active` 로 실패해 `kc-lab-2` 가 안 뜨고, 그러면 ② 에 노드가 하나만 나와 게스트를 안 띄운 것인지 노드가 안 붙은 것인지 갈리지 않는다. -**문제가 생기면** — ① 에서 노드가 `Ready` 로 안 올라오면 게스트 안의 k3s 유닛부터 본다. ② 의 `rollout status` 가 멈춰 있으면 PostgreSQL 이 crash recovery 중일 수 있으니 아래 확인 방법의 `postmaster.pid` 를 함께 본다. +**문제가 생기면** — ② 에서 노드가 `Ready` 로 안 올라오면 게스트 안의 k3s 유닛부터 본다. ③ 의 `rollout status` 가 멈춰 있으면 PostgreSQL 이 crash recovery 중일 수 있으니 아래 확인 방법의 `postmaster.pid` 를 함께 본다. ## 구성 값 diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prepare-the-lab-host-for-virtualization.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prepare-the-lab-host-for-virtualization.md index c3ccd36..2d22996 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prepare-the-lab-host-for-virtualization.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prepare-the-lab-host-for-virtualization.md @@ -51,7 +51,7 @@ sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 |---|---|---| | `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` | -편집기를 여는 곳은 한 군데다. 4번에서 `~/.bashrc` 를 연다. 나머지는 전부 조회·설치·유닛 조작이라 운영자가 평소에 치는 CLI 를 그대로 쓴다. +편집기를 여는 곳은 4번 한 단계다. 거기서 `~/.bashrc` 와 `~/.config/libvirt/libvirt.conf` 를 차례로 연다. 나머지는 전부 조회·설치·유닛 조작이라 운영자가 평소에 치는 CLI 를 그대로 쓴다. ## 이 단계가 세우는 것 @@ -85,7 +85,7 @@ sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 전제는 가이드가 두 줄로 적었다 — 물리 기계 한 대가 있고, 배포판은 상관없다. 이 실험대는 Arch Linux 로 세웠고 배포판이 다르면 패키지 이름만 달라진다. 앞 단계가 없으므로 이 절차는 다른 무엇도 전제하지 않는다. -**되돌리는 절차는 원본 가이드 00 에 없다**(unknown). 가이드 7편 가운데 되돌리기를 적은 편은 단계 03 하나다. 이 단계가 호스트에 남기는 것은 다섯이다. +**되돌리는 절차는 원본 가이드 00 에 없다**(unknown). 가이드 7편 가운데 되돌리기를 적은 편은 단계 03 하나다. 이 단계가 호스트에 남기는 것은 여섯이다. | 남는 것 | 어디에 | |---|---| @@ -93,6 +93,7 @@ sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 | `libvirt` 보조 그룹 | 사용자 계정 | | `libvirtd.socket` 활성화 | systemd | | `export` 한 줄 | `~/.bashrc` | +| `uri_default` 한 줄 | `~/.config/libvirt/libvirt.conf` | | `default` 네트워크의 autostart | libvirt 설정 | 무엇을 어떤 순서로 걷어내는지는 가이드에 적혀 있지 않고 이 실험대도 걷어내 본 적이 없다. 여기에 되돌리는 명령을 적으려면 지어내야 하므로 적지 않는다. @@ -281,11 +282,21 @@ ssh 로 한 줄 LIBVIRT_DEFAULT_URI=[(비어 있음)] 대화형 셸 LIBVIRT_DEFAULT_URI=qemu:///system ``` -**셸과 무관하게 고정하려면 libvirt 자기 설정에 넣는다.** 이 실험대에는 그 파일도 있었다(observed). +**셸과 무관하게 고정하려면 libvirt 자기 설정에 넣는다.** 이 실험대에는 그 파일도 있었고, 같은 줄을 `echo 'uri_default = "qemu:///system"' >> ~/.config/libvirt/libvirt.conf` 로 넣었다(observed). 여기서도 디렉터리는 명령으로 만들고 파일은 편집기로 연다. -```bash label="[lab host] 셸을 안 타는 자리에 고정한다" +```bash label="[lab host] ④ 셸을 안 타는 자리를 만든다" mkdir -p ~/.config/libvirt -echo 'uri_default = "qemu:///system"' >> ~/.config/libvirt/libvirt.conf +``` + +```bash label="[lab host] ⑤ libvirt 자기 설정을 편집기로 연다" +nano ~/.config/libvirt/libvirt.conf +``` + +```text label="⑥ libvirt.conf 에 더할 줄" +uri_default = "qemu:///system" +``` + +```bash label="[lab host] ⑦ 어느 하이퍼바이저를 보는지 다시 본다" virsh uri ``` @@ -294,6 +305,8 @@ virsh uri qemu:///system ``` +첫 줄은 `virsh` 가 그 파일을 읽었다고 알리는 것이고 둘째 줄이 답이다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown). + **둘 중 하나만 있으면 어디선가 어긋난다.** rc 만 있으면 `ssh` 한 줄에서 `qemu:///session` 을 보게 되고 — 그쪽에는 VM 이 없으므로 **목록이 빈 채로 나와 「VM 이 죽었다」로 읽힌다** — libvirt 설정만 있으면 대화형 셸의 `echo $LIBVIRT_DEFAULT_URI` 가 비어서 「설정이 안 됐다」로 읽힌다. 둘 다 넣어 두는 편이 낫다. 따라 하는 사람에게는 편집기 쪽이 맞다. `echo >>` 는 같은 가이드를 두 번 따라 하면 같은 줄을 한 번 더 붙이고, 파일에 이미 무엇이 들어 있는지도 보여 주지 않는다. 파일을 열면 둘 다 해결된다. @@ -404,11 +417,35 @@ groups ## 무엇이 관측이고 무엇이 아닌가 - (observed) libvirt `12.7.0` 과 `QEMU emulator version 11.1.1`, `groups` 의 세 이름, `default` 네트워크가 `active` 이고 autostart 가 `yes` 인 것, `virsh uri` 가 내놓은 `qemu:///system`. -- (unknown) `lsmod | grep kvm` 의 출력은 캡처해 두지 않았다. 가이드도 줄 모양만 적고 값을 싣지 않았다. -- (unknown) 가이드의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 대상 환경 쪽은 논리 코어 8(i5-1135G7)로 적혀 있다. 두 값이 어긋나고 어느 쪽이 이 호스트의 값인지는 재지 않았다. 세 게스트의 vCPU 합이 5 라 8 에서도 16 에서도 CPU overcommit 이 아니므로 이 단계의 판정은 어느 쪽이어도 바뀌지 않는다. +- (observed) `lsmod | grep kvm` 의 출력을 2026-09-17 에 받았다. 세 줄이고, 마지막 칸이 그 모듈을 쓰는 수다. + +```text +kvm_intel 524288 11 +kvm 1490944 6 kvm_intel +irqbypass 16384 1 kvm +``` + +- (observed) **이 호스트는 논리 코어 8 이다** — 가이드가 적은 「16 코어 전부」가 아니다. 2026-09-17 에 `nproc` 이 `8`, `lscpu` 가 `11th Gen Intel(R) Core(TM) i5-1135G7`, 물리 4 코어에 코어당 스레드 2 라고 답했다. 가이드의 그 줄은 다른 기계의 값이다. + +```bash label="[lab host] 코어 수를 가른다" +nproc +lscpu | grep -E "^Model name|^CPU\(s\):|^Core\(s\)|^Thread\(s\)" +``` + +- (observed) `systemctl status libvirtd.socket` 도 같은 날 쳤다. 보는 줄은 `Active` 와 `Listen` 과 `Triggers` 셋이다. + +```text +● libvirtd.socket - libvirt legacy monolithic daemon socket + Loaded: loaded (/usr/lib/systemd/system/libvirtd.socket; enabled; preset: disabled) + Active: active (running) since Thu 2026-09-03 19:00:35 KST; 1 week 6 days ago + Triggers: ● libvirtd.service + Listen: /run/libvirt/libvirt-sock (Stream) +``` + + `Triggers` 가 `libvirtd.service` 를 가리키는 것이 소켓 활성화가 걸렸다는 뜻이다. 소켓이 먼저 뜨고 첫 접속이 올 때 서비스가 깨어난다 — 그래서 `.service` 가 아니라 `.socket` 을 `enable` 한다. - (unknown) 원본 가이드에 되돌리는 절차가 없다. 패키지와 그룹과 유닛과 `~/.bashrc` 와 네트워크 autostart 를 걷어내 본 적이 없다. -- (unknown) `sudo modprobe kvm_intel` 과 `systemctl status libvirtd.socket` 은 막혔을 때 치라고 가이드가 적어 둔 명령이고, 이 실험대에서는 막히지 않아 치지 않았다. -- (unknown) `nano ~/.bashrc` 로 여는 형태는 이 실험대가 치지 않았다. 이 실험대는 `echo >>` 로 넣었고, 편집기 쪽은 같은 상태에 닿는 형태로 적었다. +- (unknown) `sudo modprobe kvm_intel` 은 막혔을 때 치라고 가이드가 적어 둔 명령이고, 이 실험대에서는 막힌 적이 없어 치지 않았다. +- (unknown) `nano ~/.bashrc` 와 `nano ~/.config/libvirt/libvirt.conf` 로 여는 형태는 이 실험대가 치지 않았다. 이 실험대는 두 파일 다 `echo >>` 로 넣었고, 편집기 쪽은 같은 상태에 닿는 형태로 적었다. - (external) Debian 과 Ubuntu 의 패키지 이름은 가이드가 참고로 적어 둔 것이고 이 실험대는 Arch 로 세웠다. - (external) 보조 그룹이 로그인 시점에 고정된다는 것, `virsh net-start` 가 이미 `active` 면 실패한다는 것, `sudo` 가 환경 변수를 물려주지 않는다는 것은 리눅스와 libvirt 의 동작이다. 이 실험대가 그 세 가지를 따로 재 보지는 않았다. - (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라, 지금 다시 쳐도 같은 상태가 되는지는 확인되지 않았다. diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prometheus-and-grafana-for-the-lab.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prometheus-and-grafana-for-the-lab.md index 73056a2..e2e7440 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prometheus-and-grafana-for-the-lab.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-prometheus-and-grafana-for-the-lab.md @@ -53,6 +53,8 @@ source: 이 단계에도 손으로 쓰는 파일이 없다. 세우는 것이 전부 매니페스트 한 장에 들어 있고, 그 원문은 저장소의 `deploy/lab/k8s/observability.yaml` 이다. +**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. + ## 이 단계가 세우는 것 가이드 06 의 「이 단계가 끝나면」은 두 줄이다. @@ -273,7 +275,8 @@ kubectl -n observability exec deploy/prometheus -- \ **이 결과가 의미하는 것** — 503 이 나는 동안에도 `up` 은 1 이었다(observed). 프로세스가 살아 있고 `/metrics` 가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 이 지표로는 보지 못한다. 경보를 `up == 0` 하나로 걸면 그 상태를 통째로 놓친다. 그래서 기능 지표를 함께 본다. 밖에서 실제 응답을 받아 보는 것이 가장 짧다. ```bash label="[lab host] up 과 나란히 놓고 비교한다" -curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ + https://auth.hyeonworks.com/realms/master ``` 코드 한 칸을 `up` 의 1 과 0 옆에 놓는다. `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧 「살아 있지만 쓸모없는」 상태의 증거가 된다. 처음 보는 오류를 파고들 때는 값만 뽑는 형태를 버리고 헤더까지 읽는 형태로 바꾼다. diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-tear-down-the-lab-and-know-what-survives.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-tear-down-the-lab-and-know-what-survives.md index df17118..8708f3a 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-tear-down-the-lab-and-know-what-survives.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-tear-down-the-lab-and-know-what-survives.md @@ -74,6 +74,15 @@ df -h / **이 결과가 의미하는 것** — 이 네 값이 철거의 성공 판정 기준이 된다. 여기를 건너뛰면 지운 뒤에 「원래 몇 개였지」를 되짚을 방법이 없다. +2026-09-17 에 다시 세운 실험대에서 같은 네 줄을 쳤다(observed). 도메인 3 대에 볼륨 7 개, 예약 3 줄까지는 2026-09-10 과 같고 **디스크만 11G 가 아니라 18G 다.** 그사이에 k3s 와 컨테이너 이미지가 쌓였다. 그래서 이 네 값은 「이 호스트의 정답」이 아니라 **지금 이 실행에서 받아 두는 대조군**이다 — 위에 실은 숫자와 다르다고 잘못된 것이 아니다. + +```text label="2026-09-17 의 네 값" +도메인 kc-lab-1 · kc-lab-edge · kc-lab-2 (셋 다 running) +볼륨 base.qcow2 + kc-lab-{1,2,edge}.qcow2 + seed-kc-lab-{1,2,edge}.iso = 7 +예약 52:54:00:aa:bb:{10,11,12} = 3 줄 +디스크 /dev/nvme0n1p3 226G 18G 197G 9% / +``` + ## 실행 절차 ### 1. 게스트 세 대를 지운다 @@ -150,6 +159,30 @@ zsh 에서 루프로 돌리면 이 오류를 만난다. zsh 는 따옴표 없는 ## 인증서는 건드리지 않는다 +**이 절의 전제가 03·04 뒤로 깨져 있다**(2026-09-17, observed). 아래 본문은 인증서가 랩 호스트의 `/etc/letsencrypt/` 에 있다고 보고 그 디렉터리를 정책으로 남긴다. 그런데 03 이 nginx 를 `kc-lab-edge` 로 옮겼고 04 가 certbot 을 거기 깔았다. **지금 서빙하는 인증서는 그 게스트 안에 산다.** 그리고 이 절차의 1번은 게스트를 `--remove-all-storage` 로 지운다 — 인증서는 그 안에서 같이 사라진다. + +두 기계를 나란히 열어 보면 이렇다. + +```bash label="[lab host] 어느 쪽에 무엇이 있나" +ls -la /etc/letsencrypt/ +ssh kc-lab-edge 'sudo ls -la /etc/letsencrypt/' +``` + +| 기계 | `/etc/letsencrypt/` | certbot | 타이머 | 서빙 | +|---|---|---|---|---| +| 랩 호스트 | 있다 (읽으려면 `sudo`) | `/usr/bin/certbot` | 없다 | 안 한다 — 80·443 을 안 듣는다 | +| `kc-lab-edge` | 있다 — `cli.ini` · `renewal-hooks` | `/usr/bin/certbot` | `certbot.timer` | **한다** | + +그래서 아래 백업 명령은 **틀린 기계를 묶는다.** 묶어야 할 것은 게스트 쪽이다. + +```bash label="[lab host] 실제로 서빙하는 인증서를 묶는다" +ssh kc-lab-edge "sudo tar czf /tmp/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt" +scp kc-lab-edge:/tmp/letsencrypt-backup-*.tgz ~/ +``` + +**「철거해도 남는 것」 표의 패키지 줄도 같은 이유로 틀렸다.** 그 줄은 `nginx` 와 `certbot` 을 남는 쪽에 적어 두었는데, 둘 다 게스트 안에 있으므로 게스트와 함께 사라진다. 랩 호스트에 남는 것은 `libvirt` · `qemu` · `kubectl` 과, 쓰이지 않는 호스트 쪽 `certbot` 이다. + + `/etc/letsencrypt/` 는 정책으로 남긴다. 한도 때문이 아니다 — Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」 제한은 가끔 재구축하는 정도로는 근처에도 못 간다. 남기는 까닭은 지금 재발급이 되는지를 모르기 때문이다. 이 실험대의 이름 셋은 tailnet 주소를 가리키고, `100.64.0.0/10` 은 CGNAT(Carrier-Grade NAT, 통신사 공용 주소 변환)용 예약 대역이라 공개 인터넷에서 라우팅되지 않는다. HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로 그 주소로는 검증이 성립하지 않는다. 지금 설정이 DNS-01 이면 지우고 다시 받으면 끝이고, HTTP-01 이면 검증 방식부터 손봐야 한다. 어느 쪽인지는 certbot 설정을 읽는 열린 물음이 한 줄로 닫는다. @@ -204,6 +237,22 @@ ip -br addr show virbr0 **이 결과가 의미하는 것** — 디스크에서 3.1GB 가 회수됐다. 잰 내역은 `kc-lab-1` 1.4GB 와 `kc-lab-2` 665MB 와 시드 ISO 세 개(각 370KB)이고, `kc-lab-edge` 의 디스크 크기는 재 두지 않았다. 합계에서 빼면 1GB 안팎인데 그것은 잰 값이 아니라 역산한 값이다. +**그 역산은 아마 너무 컸다**(2026-09-17, observed). 같은 호스트에 다시 세운 실험대의 볼륨 일곱을 `virsh vol-info` 로 재 보니 엣지가 가장 작다. 선언한 크기와 실제로 먹는 양이 크게 다르고, 오버레이라 쓴 만큼만 먹는다. + +```bash label="[lab host] 볼륨이 실제로 먹는 양을 본다" +virsh vol-info --pool default kc-lab-edge.qcow2 +``` + +```text +base.qcow2 Capacity=3.00 GiB Allocation=323.25 MiB +kc-lab-1.qcow2 Capacity=20.00 GiB Allocation=5.40 GiB +kc-lab-2.qcow2 Capacity=20.00 GiB Allocation=3.85 GiB +kc-lab-edge.qcow2 Capacity=10.00 GiB Allocation=316.26 MiB +seed-kc-lab-{1,2,edge}.iso Capacity=370.00 KiB Allocation=372.00 KiB +``` + +**이 값을 2026-09-10 의 역산 대신 그대로 끼워 넣지는 않는다** — 다른 날 다른 실험대이고 그 위에 올린 것도 다르다. 다만 엣지가 nginx 하나만 돌리는 게스트라 300MB 대에 머문다는 것은 두 실행에서 같을 가능성이 높고, 그렇다면 그때의 「1GB 안팎」은 과대 추정이다. **다시 철거할 때는 역산하지 말고 지우기 전에 이 명령으로 세 볼륨을 직접 잰다.** + `virbr0` 가 `DOWN` 인 것은 고장이 아니다. 브리지에 붙은 tap 인터페이스가 하나도 없어서 캐리어가 없는 것으로 표시될 뿐이고, 주소 `192.168.122.1/24` 는 그대로 있다. VM 을 다시 띄우면 그 VM 의 `vnetN` 인터페이스가 브리지에 붙으면서 `UP` 이 된다. `virsh net-start` 를 찾아 헤매지 않는다. ## 철거해도 남는 것과 사라지는 것 diff --git a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-wildcard-certificate-with-dns-01-and-a-deploy-hook.md b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-wildcard-certificate-with-dns-01-and-a-deploy-hook.md index 1f1d90b..fdf9abc 100644 --- a/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-wildcard-certificate-with-dns-01-and-a-deploy-hook.md +++ b/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-wildcard-certificate-with-dns-01-and-a-deploy-hook.md @@ -369,7 +369,28 @@ sudo nginx -t && sudo systemctl reload nginx `ssl_certificate` 에 적는 것은 `cert.pem` 이 아니라 `fullchain.pem` 이다. 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊기는데, 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이고 캐시가 없는 클라이언트에서만 깨지므로 발견이 늦다. 4번 ③ 에서 certbot 이 찍어 준 경로와 한 글자도 다르면 안 된다. -**문제가 생기면** — `cannot load certificate` 로 막히면 경로를 본다. `live/auth.hyeonworks.com/` 이라고 적으면 그 디렉터리가 없다. 확인 ② 에서 본 판 번호가 1.25.1 미만인데 `http2` 를 지시어로 썼다면 여기서 `unknown directive "http2"` 가 나온다. +**★ 위 ② 의 인증서 경로가 틀렸다**(2026-09-17, observed). `live/hyeonworks.com/` 이라고 적혀 있는데 certbot 이 만드는 디렉터리는 **`live/auth.hyeonworks.com/`** 이다. 이름을 여럿 담은 인증서라도 디렉터리 이름은 `-d` 로 처음 준 이름 하나를 쓴다. 그대로 치면 3번에서 nginx 가 안 뜬다. + +```text label="문서 그대로 쳤을 때" +[emerg] cannot load certificate "/etc/letsencrypt/live/hyeonworks.com/fullchain.pem": + BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory +nginx: configuration file /etc/nginx/nginx.conf test failed +``` + +```bash label="[kc-lab-edge] 실제 디렉터리 이름을 보고 쓴다" +sudo ls /etc/letsencrypt/live/ +sudo certbot certificates | grep 'Certificate Path' +``` + +```text label="그 출력" +README +auth.hyeonworks.com + Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem +``` + +**아래 「문제가 생기면」의 진단은 방향이 거꾸로다.** `live/auth.hyeonworks.com/` 이 없는 경로가 아니라 **그쪽이 맞는 경로**이고, `live/hyeonworks.com/` 이 디스크에 없다. 4번 ③ 이 찍어 준 경로를 그대로 옮기라는 원칙은 옳고, 위 ② 가 그 원칙을 스스로 어겼다. + +**문제가 생기면** — `cannot load certificate` 로 막히면 경로를 본다. 자기 실험대의 디렉터리 이름은 `sudo ls /etc/letsencrypt/live/` 가 답한다. 확인 ② 에서 본 판 번호가 1.25.1 미만인데 `http2` 를 지시어로 썼다면 여기서 `unknown directive "http2"` 가 나온다. ### 6. 갱신이 서빙까지 닿게 한다 @@ -458,6 +479,24 @@ curl -v https://auth.hyeonworks.com/ -o /dev/null **어디를 봐야 하는가** — 별표로 시작하는 줄 넷이다. 어떤 TLS 판으로 협상했는가, `subject` 의 CN 이 무엇인가, `issuer` 가 Let's Encrypt 인가, 그리고 `SSL certificate verify ok.` 가 있는가. 그 아래 `<` 로 시작하는 첫 줄이 응답 상태다. `subject` 가 `hyeonworks.com` 인 것이 맞다 — 와일드카드 인증서라 CN 은 apex 이름이고 `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다. +**★ 그런데 이 실험대에서는 CN 이 `auth.hyeonworks.com` 이다**(2026-09-17, observed). 가이드대로 와일드카드를 받으면 위 두 문단처럼 보인다. 이 실험대는 와일드카드를 안 받았고(§「가이드는 여기에 값을 하나 더 적어 두었다」), 계보도 `auth.hyeonworks.com` 하나만 있다. + +```bash label="[lab host] 엣지가 내놓는 인증서가 어느 이름을 덮는가" +echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -dates +``` + +```text label="이 실험대의 값" +subject=CN=auth.hyeonworks.com +X509v3 Subject Alternative Name: + DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com +notBefore=Sep 4 11:29:18 2026 GMT +notAfter=Dec 3 11:29:17 2026 GMT +``` + +**CN 은 합격 조건이 아니다.** 와일드카드를 받았으면 `hyeonworks.com`, 이름을 나열해 받았으면 첫 번째 `-d` 의 이름이 CN 이 된다. 보는 것은 `SSL certificate verify ok.` 한 줄과, 지금 친 이름이 SAN 에 있는가다. + +**이름 셋 밖은 TLS 단계에서 끝난다.** `curl` 은 종료 코드 `60` 을 내고 `%{http_code}` 는 `000` 이 되는데, 화면에서는 「서버가 죽었다」와 같아 보인다. A-4 의 Grafana 탐침이 그렇게 쓸모없어졌다. + **이 결과가 의미하는 것** — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다. `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면 중간 인증서가 빠진 것이고, 원인은 5번의 인증서 파일 이름이라 확인 ② 로 간다. 같은 것을 반복해서 재거나 앞뒤 단계의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다. @@ -578,7 +617,15 @@ ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep - (observed) 이름 풀이가 낸 `100.83.212.4`, `certbot plugins` 세 줄, `nginx -v` 두 줄, 체인 4단계와 `Verify return code: 0 (ok)`, `404 tls=0`, 배포판 기본 유닛에 훅이 없다는 것, 갱신에서 서빙까지 2305초 대 1~2초, reload 무중단 측정값. - (observed) 2026-09-17 에 새로 만든 엣지에서 잰 것 — `dig` 가 없다는 것, `getent hosts` 와 `dnsutils` 설치 뒤의 `dig` 가 같은 주소를 낸다는 것, `nginx -v` 가 `sudo` 없이는 `command not found` 라는 것, `certbot plugins` 세 줄이 문서에 적힌 것과 같다는 것. -- (unknown) 2번부터 4번까지(토큰 발급 · 자격증명 파일 · 시험 발급 · 실제 발급)는 2026-09-17 에 다시 밟지 않았다. Cloudflare 토큰이 필요한 구간이라 토큰 없이는 칠 수 없고, 값을 옮겨 오지도 않았다. 그래서 **5번의 443 블록과 6번의 훅도 재확인하지 못했다** — 인증서 파일이 없으면 그 설정은 `nginx -t` 를 못 지난다. +- (observed) **이 실험대의 certbot 은 DNS-01 로 등록되어 있다.** 위에서 「아직 읽지 못했다」고 적은 그 한 줄을 2026-09-17 에 읽었다 — `authenticator = dns-cloudflare` 이고 `dns_cloudflare_credentials = /etc/letsencrypt/cloudflare.ini` 이며 `server` 는 Let's Encrypt 운영 디렉터리다. 두 문서가 갈렸던 대목이 DNS-01 쪽으로 닫혔다. + +```bash label="[lab host] 등록된 인증 방식을 읽는다" +sudo sh -c 'grep -H "authenticator\|dns_cloudflare_credentials\|server =" /etc/letsencrypt/renewal/*.conf' +``` + + **`sudo grep ... /etc/letsencrypt/renewal/*.conf` 로 치면 안 된다**(observed). 글로브를 일반 사용자 셸이 먼저 펼치는데 그 디렉터리를 못 읽어서 `zsh: no matches found` 로 끝나고 `grep` 은 시작도 안 한다. `sudo sh -c '...'` 로 sudo 안에서 펼쳐야 한다. + +- (unknown) 2번부터 4번까지(토큰 발급 · 시험 발급 · 실제 발급)는 2026-09-17 에 다시 밟지 않았다. 5번의 443 블록은 이미 있던 인증서를 엣지로 옮겨서 밟았고, 그때 위 ② 의 경로 결함이 드러났다. - (external) `100.64.0.0/10` 이 CGNAT 예약 대역이라는 것과 Let's Encrypt 의 발급 한도와 유효기간 90일은 규격이고 이 실험대가 잰 값이 아니다. - (external) Cloudflare 계정에 남는 토큰은 엣지를 지운다고 없어지지 않는다. 가이드는 폐기를 적지 않았다. - (inferred) 2305초 측정은 훅이 물리 호스트에만 있던 시절의 기록이라, 엣지 게스트에서 다시 잰 값이 아니다. 지금 배치에서 같은 수가 나오는지는 재지 않았다. diff --git a/docs/virtualization/tech-log-studio/tech-log-tree.json b/docs/virtualization/tech-log-studio/tech-log-tree.json index fe0ffe9..0f39836 100644 --- a/docs/virtualization/tech-log-studio/tech-log-tree.json +++ b/docs/virtualization/tech-log-studio/tech-log-tree.json @@ -2,7 +2,7 @@ "schemaVersion": 4, "project": "virtualization", "ssot": "final/document.md", - "ssotSha256": "2b860da308e69e0803c5bf2a6f911c1bafa01c1ef5e0e121100567d2ffdc353e", + "ssotSha256": "9146d6428ebdf2ab49f0fbd160e9526db93f48ca357bc8d281e753a999771f50", "sourceRevision": "no-commit · 밖에서 반입한 문서 한 편. 고정할 저장소 리비전이 없다", "generatedAt": "2026-09-17", "sourceRepository": [