Files
document-haness/docs/keycloak-session-store/tech-log-studio/when-the-measurement-lies/case/case-nine-injections-that-silently-did-nothing.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:01:55 +09:00

12 KiB

kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, assets, evidence
kind slug title topic topicName project status lastVerifiedOn sourceRevision source assets evidence
CASE nine-injections-that-silently-did-nothing 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다 when-the-measurement-lies 주입이 걸렸는지 무엇으로 아는가 keycloak-session-store 게시 전 cdac9b8178391311d8eca1ebc6cac15bb62d79af
final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다
key file
injection-verification ../../../final/assets/injection-verification/injection-verification.svg
key file
a5-partition-asymmetry ../../../final/assets/a5-partition-asymmetry/a5-partition-asymmetry.svg
../../../final/evidence/raw/a1-jgroups-transport-block__05-conntrack-problem.txt
../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt

주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다

주입 명령이 아홉 번 걸리지 않았고, 걸리지 않은 주입은 관측에서 영향이 없는 것과 구별되지 않았다. 아홉 건의 원인은 네트워크·시그널·권한·출력 경로로 매번 달랐다. 공통점은 주입과 결과 관측 사이에 대상 상태를 보는 단계가 없었다는 것 하나였다.

관계

  • 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다 그 기준의 두 번째 규칙이 이 아홉 건에서 나왔다.
  • 실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다 주입이 걸린 것을 확인한 다음에도 관측은 틀릴 수 있고, 그 다음 단계를 대조군이 맡는다.
  • up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다 주입이 제대로 걸린 A-2 에서도 이 지표는 1 이었다. 주입 확인과 지표 읽기는 서로를 대신하지 못한다.

문제

A층 여덟 실험은 예측을 먼저 적어 두고 주입한 뒤 관측하는 순서로 돌렸다. 주입 명령이 오류 없이 끝나면 걸린 것으로 보고 결과를 읽었다.

주입이 걸리지 않으면 관측에 아무 변화도 나타나지 않는다. 영향이 없어서 변화가 없는 경우와 같은 모습이므로, 이 실험대에서는 두 경우를 가를 방법이 없었다.

결론

조용히 실패한 주입 : 9건 원인이 겹치는 건 : 없음. 아홉 건이 각각 다른 이유로 걸리지 않았다 한 실험에서 세 번 실패한 곳 : A-5 비대칭 분단 (표의 4·5·6번) 공통 원인 : 주입과 관측 사이에 대상 상태를 보는 단계가 없었다 그 뒤 바꾼 것 : 주입한 다음 결과를 보기 전에 대상 상태를 따로 확인한다 상태를 보는 수단 : cluster_size · 워커 PID · conntrack 표 · 패킷 카운터

검증 환경

실험대 : 베어메탈 한 대 위에 VM 두 대 test-server : Arch Linux, 12GB, WiFi only kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 게스트 sudo : 무암호 호스트 sudo : 비밀번호 요구 주입에 쓴 것 : NetworkPolicy · iptables · tc · conntrack · kubectl delete · kill

재현 조건

  1. 예측을 먼저 문서에 적는다. 무엇이 깨지고 무엇이 안 깨질지를 주입 전에 적어 둔다.

  2. 주입 명령을 넣는다. NetworkPolicy 로 포트를 빼거나, iptables 규칙을 끼워 넣거나, tc 로 지연을 걸거나, 파드를 강제 종료한다.

  3. 결과를 보기 전에 대상 상태를 조회한다. conntrack 표에 그 포트의 ESTABLISHED 항목이 남아 있는지, iptables 규칙의 패킷 카운터가 0 인지, tc 필터에 패킷이 걸렸는지를 본다.

  4. 상태가 바뀌지 않았으면 주입이 걸리지 않은 것으로 보고 결과를 읽지 않는다.

  5. 주입 방법을 바꾸고 3번을 다시 한다.

본문

여덟 실험이 같은 모양으로 돌았다

A층 실험은 예측을 먼저 문서에 적어 두고 주입한 뒤 관측하고 마지막에 그 예측과 대조하는 순서였다. 주입은 상태를 일부러 망가뜨리는 명령이다 — NetworkPolicy 에서 포트를 빼 통신을 막고, tc 로 지연을 걸고, 파드를 강제 종료하고, iptables 규칙을 앞에 끼워 넣는다.

이 순서에는 확인이 하나 빠져 있었다. 주입 명령이 오류 없이 끝나도 대상이 실제로 그 상태가 됐는지는 보지 않고 바로 결과를 읽었기 때문에, 걸리지 않은 주입은 관측에서 「아무 일도 없었다」로 나타났다. 영향이 없어서 아무 일도 없는 것과 화면에서 같아 보인다. 이 실험대가 시간을 가장 많이 쓴 곳이 여기다.

아홉 건이 각각 다른 이유로 걸리지 않았다

# 무엇을 했나 왜 안 먹었나
1 NetworkPolicy 로 7800 차단 conntrack — ESTABLISHED 연결은 규칙 평가를 건너뛴다. cluster_size 가 25분간 2 로 남았다
2 kubectl delete --grace-period=0 --force 크래시가 아니다. 런타임이 SIGTERM 을 보내 PostgreSQL 이 정상 플러시했다
3 kill -9 1 PID 1 은 자기 네임스페이스의 SIGKILL 을 무시한다
4 iptables -I FORWARD 1 kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0)
5 raw 규칙을 한쪽 노드에 방향이 뒤집혀 있었다. JGroups 의 client/server 역할은 재시작마다 바뀐다
6 tc ... dev eth0 Debian 은 enp1s0 이고, flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다
7 spring.sql.init 로 스키마 생성 기본 DDL 이 blob 인데 PostgreSQL 은 bytea 다. continue-on-error: true 가 삼켰다
8 호스트에서 sudo 비밀번호를 요구한다. 빈 출력이 곧 실패였다
9 kubectl run --rm -i 로 동시 20건 일회성 파드의 stdout 이 유실된다. 20줄 중 일부만 도착하거나 아예 끊긴다

아홉 건을 원인으로 묶어 보려 했지만 묶이지 않았다. 네트워크 계층이 명령을 다르게 해석한 것도 있고, 종료 신호가 크래시가 아니었던 것도 있고, 오류를 삼키는 설정과 권한과 출력 경로도 하나씩 있었다. 아홉 건에 같은 대책을 걸 수 없었다.

게스트에서는 되고 호스트에서는 되지 않던 것

kc-lab-1 과 kc-lab-2 는 무암호 sudoconntrack·tc·iptables 를 그대로 썼지만 호스트인 test-server 는 비밀번호를 요구한다. 이 차이를 모르고 한동안 호스트의 nginx 설정을 읽으려 했는데 계속 빈 출력이 돌아왔고, 그 빈 출력은 명령이 낸 답이 아니라 sudo 가 비밀번호를 받지 못해 멈춘 결과였다. 하마터면 빈 로그를 「아무 일도 없음」으로 읽을 뻔했다.

호스트에서 해야 하는 일은 인증서 강제 갱신과 nginx reload 인데 둘 다 결국 사람이 직접 쳐야 했으므로, D-4 에서는 명령 한 줄을 헛되이 쓰지 않는 것이 설계의 일부가 됐다.

한 실험에서 세 번 — A-5 비대칭 분단

표의 4·5·6번은 모두 A-5 한 실험에서 나왔다. 세 번 모두 다른 이유였고, 셋 다 화면에서는 「아무 일도 없었다」로 보였다.

A-5 가 물은 것은 한쪽 방향만 막았을 때 클러스터가 갈라지는가였다. 한 방향만 막으면 JGroups 가 열린 방향으로 재연결하므로 클러스터가 갈라지지 않는다. 양방향을 다 막으면 갈라지기는 하는데 한쪽만 DOWN 이 되어서, 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 로드밸런서에서 빠지며 서비스는 이어진다.

한 방향이 막혀도 반대 방향으로 연결이 성립하고, 양방향을 다 막아야 두 멤버가 분리되는 구성

그래서 이 실험에서는 주입이 걸리지 않은 것과 비대칭 차단이 원래 클러스터를 가르지 못하는 것이 같은 관측으로 나온다. 세 번의 실패를 하나씩 분리하려면 규칙이 실제로 패킷을 잡았는지부터 따로 봐야 했다.

conntrack 표와 패킷 카운터를 직접 조회했다

1번에서 무엇이 걸리지 않았는지는 conntrack 표를 조회해서 확정했다. conntrack 은 리눅스 커널이 진행 중인 연결을 기억해 두는 표이고, 여기 등록된 흐름의 패킷은 방화벽 규칙을 다시 평가하지 않고 통과한다. NetworkPolicy 를 적용한 뒤 두 노드에서 7800 흐름을 뽑았다.

--- kc-lab-1 ---
  tcp      6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1
--- kc-lab-2 ---
  tcp      6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 mark=0 use=1

두 노드 모두 7800 으로 가는 ESTABLISHED 항목을 들고 있었다. 쿠버네티스가 넣어 둔 FORWARD 규칙은 전부 NEW 상태에만 걸려 있어서, 이미 성립한 연결의 패킷은 NetworkPolicy 평가에 도달하지도 않는다. 규칙이 안 걸린 것이 아니라 규칙은 정확히 걸렸고 패킷이 그 앞에서 지나갔다. nf_conntrack_tcp_timeout_established 는 86400 이고 오가는 패킷이 있으면 타이머가 계속 갱신되므로, JGroups 처럼 주기적으로 통신하는 연결은 사실상 영원히 표에 남는다. cluster_size 가 25분간 2 로 남은 것은 이상한 일이 아니라 정상 동작이었다.

항목을 지우는 것도 한 번에 되지 않았다. 두 노드에서 7800 흐름을 지웠는데 돌아온 것은 0 flow entries have been deleted 였고 삭제 뒤에도 항목은 두 건씩 남아 있었으며 cluster_size 도 2 그대로였다. 클러스터가 실제로 갈라진 것은 정책이 걸린 상태에서 파드를 재시작했을 때다. 처음 쓴 A-1 기록은 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸고, 증거와 대조하면서 그 귀속을 고쳤다.

6번은 인터페이스를 바꿔 우회했다. Debian 게스트의 물리 인터페이스는 enp1s0 이고 flannel VXLAN 이 파드 트래픽을 이미 캡슐화하므로 거기서는 파드 IP 가 보이지 않는다. 캡슐화 전 구간인 flannel.1 에 같은 netem 을 걸고, 결과를 읽기 전에 필터에 패킷이 걸렸는지를 카운터로 확인했다.

=== [검증] 필터에 패킷이 걸리는가 ===
  qdisc netem 30: parent 1:3 limit 1000 delay 200ms
   Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)

=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
  keycloak-0  평균 41 ms   최대 57 ms
  keycloak-1  평균 1872 ms   최대 1887 ms

카운터가 150 패킷으로 올라간 것을 보고 나서 응답 시간을 읽었다. 지연을 걸기 전 두 노드의 로그인 응답은 70ms 와 66ms 였는데, 주입한 지연이 200ms 인데도 keycloak-1 의 평균 응답은 1872ms 로 나왔다. 지연을 걸지 않은 keycloak-0 은 41ms 였다.

주입한 다음, 결과가 아니라 상태를 본다

아홉 건은 원인이 제각각이었고 공통점은 한 곳이었다. 주입 명령과 결과 관측 사이에 대상이 실제로 그 상태인지 보는 단계가 없었다. 그래서 그 단계를 넣고 이후 모든 실험에 적용했다. 확인하는 대상은 결과가 아니라 상태다 — cluster_size, 워커 PID, conntrack 표, 패킷 카운터.

주입 명령에서 대상 상태 확인을 거쳐 결과 관측으로 가는 경로

가운데 단계를 건너뛰면 걸리지 않은 주입과 영향이 없는 주입을 결과만 보고 가를 수 없다.

이 아홉 건에서 확인하지 않은 것

아홉 건을 모두 고친 뒤 처음부터 다시 돌리지는 않았다. 일부는 원래 방법을 고치지 않고 다른 주입 방법으로 우회했다 — 6번은 eth0 대신 flannel.1 에 걸었고, 9번은 일회성 파드 대신 상주 탐침으로 바꿨다. 원래 방법이 왜 안 걸렸는지까지만 확정했고, 그 방법 자체를 동작하게 만든 것은 아니다.