--- id: cf2e783c-424b-4167-aa27-3bd6b5f46ee2 kind: SETUP slug: reproduce-a6-latency-injection title: flannel.1 에 200ms 를 넣고 커넥션 풀이 고갈되는 것을 본다 topic: losing-a-node-or-the-store topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/cf2e783c-424b-4167-aa27-3bd6b5f46ee2/edit" pinnedVersions: - name: Keycloak version: 26.7.0 - name: curlimages/curl version: 8.11.1 source: - final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-6 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # flannel.1 에 200ms 를 넣고 커넥션 풀이 고갈되는 것을 본다 postgres 가 보내는 패킷 중 노드를 건너가는 것만 200밀리초 지연시켜 Keycloak 한 대의 JDBC 커넥션 풀이 마르는 것까지 따라가는 절차다. 주입 셋 중 둘은 일부러 실패시키고, 걸렸는지는 `tc -s` 카운터로만 판정한다. 전 구간 약 30분. ## 관계 - **200 밀리초를 넣었더니 응답이 22.2 초가 됐다** 이 절차가 만드는 사건이고, 왜 200밀리초가 22초가 되는지는 그 기록이 결론으로 갖는다. - **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다** 여기서 일부러 밟는 실패 둘이 그 아홉 건에 들어 있다. - **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다** 스크립트가 찍은 「적용완료」를 커널의 답으로 읽은 것이 그 기록이 다루는 실패와 같은 종류다. - **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다** 대조군을 같은 클러스터 안에 두고 `tc -s` 로 주입을 먼저 판정하는 이 순서가 그 기준을 따른다. ## 본문 ## 읽기 전에 — 어디서 치는가 `kubectl` 은 `[lab host]` 에서 친다. `tc` 는 노드 자체를 건드리는 명령이라 `kc-lab-2` 에서 치고, postgres 가 그 노드에 있다. 아래 명령들은 가이드가 실제로 친 한 줄 형태 그대로이므로 `ssh kc-lab-2 '...'` 가 붙어 있다. 코드블록마다 어느 셸인지 붙여 두었다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. 터미널은 둘을 연다. 하나는 부하와 측정, 하나는 이벤트 관찰이다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` | | 주입 지점 | `flannel.1`(VXLAN 터널). `eth0` 은 없고 `enp1s0` 에는 파드 IP 가 안 보인다 | | 주입 값 | `netem delay 200ms` 를 `prio` 의 3번 밴드에 | | 고르는 기준 | `u32 match ip src $PG/32` — postgres 가 보내는 패킷만 | | 탐침 파드 | `a6-probe` — `curlimages/curl:8.11.1`, `sleep 1800`, `--restart=Never` | | 부하 | 동시 20건. 결과는 파드 안 `/tmp/load` 에 모은다 | | 걸리는 시간 | 전 구간 약 30분 | ## 이 실험이 가르는 것 A-2 는 DB 를 완전히 세웠고 A-4 는 기계를 통째로 껐다. 둘 다 즉시 드러났다 — `503` 이 나오고 `up` 이 0 이 됐다. 실제 장애의 대부분은 느려지기만 하고, 느려짐은 헬스체크가 통과하므로 사망보다 진단하기 어렵다. 묻는 것은 하나다. ```text 200밀리초를 넣으면 애플리케이션은 200밀리초 느려지는가? ``` 답은 아니고, 두 군데에서 곱해진다. 끝나면 이것들을 자기 화면에서 본다 — `eth0` 이라는 인터페이스가 없다는 것은 `ip -brief link` 에서 보고, 스크립트가 「적용완료」를 찍었는데 아무것도 안 걸린 것은 `tc -s qdisc` 카운터에서 본다. `enp1s0` 에서 파드 IP(Internet Protocol 주소)가 안 보이는 것은 VXLAN(Virtual Extensible LAN, 가상 확장 랜) 캡슐화에서, 200ms 가 1,872ms 가 되는 것은 두 노드 응답 시간 비교에서 드러난다. 동시 20건이 22.2초까지 계단으로 늘어나는 것은 상주 탐침이 모은 파일에서, 커넥션 획득에 20초를 기다린 요청은 `agroal_blocking_time_max_milliseconds` 에서 나온다. readiness 프로브가 같은 줄에 서서 타임아웃되는 것은 `kubectl get events` 에 찍히고, 예측했던 낙관적 락 충돌이 0건인 것은 Keycloak 로그에서 확인한다. ## 전제와 되돌리기 - `05-keycloak` 과 `06-observability` 가 끝나 있다. - A-5 를 먼저 해 두면 좋다. 「주입을 넣은 것과 걸린 것은 다르다」가 여기서 세 번째로 나온다. - `tc` 는 `kc-lab-2` 에서 친다(`ssh kc-lab-2`). postgres 가 그 노드에 있다. :::warning 이 절차는 Keycloak 한 대를 느려지게 만든다. 파드가 재시작될 수 있고 readiness 가 빠진다. 실험대에서만 한다. ::: ### 1. 중단하는 방법을 먼저 읽어 둔다 **목적** — 어느 단계에서든 `flannel.1` 에 건 것을 한 줄로 전부 걷어낼 수 있게 해 둔다. **지금 치는 명령이 아니다.** 아래 한 줄은 중간에 그만둘 때 치는 것이고, 여기서는 어디 있는지만 봐 둔다. 아직 아무것도 걸지 않았으므로 지금 치면 지울 것이 없다. ```bash label="[lab host] 중간에 그만둘 때 치는 한 줄 — 읽어만 둔다" ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root' ``` 지금 쳐서 확인할 것은 걸린 것이 없다는 쪽이다. ```bash label="[lab host] 지금 flannel.1 에 무엇이 걸려 있는지 본다" ssh kc-lab-2 'sudo tc qdisc show dev flannel.1' ``` **예상 결과** — `qdisc noqueue 0: root refcnt 2` 다. 이 값이 주입 전의 출발점이고, 걷어낸 뒤에 같은 줄이 다시 나오면 원상복구된 것이다. `netem` 이 지금 보이면 앞 실험이 안 걷고 끝낸 것이므로 위의 `del` 을 먼저 친다. 걸린 것이 없는 상태에서 `del` 을 쳤을 때의 출력은 이 실험대에 기록이 없다. **왜 필요한가** — 이 한 줄이 세 가지를 다 지운다. `prio` qdisc 와 그 아래 `netem` 과 filter 이고, `root` 를 지우면 자식이 함께 사라진다. **문제가 생기면** — `tc` 는 노드 자체를 건드리는 명령이라 게스트 셸이 필요하다. 따라 하는 사람은 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 `sudo tc ...` 를 칠 수 있고, 그러면 한 줄에 SSH(Secure Shell, 원격 셸) 접속과 원격 셸의 인용이 겹치지 않는다. 이 두 단계 형태는 이 실험대에서 치지 않았다. ## 주입 전에 같은 명령으로 먼저 본다 배치를 먼저 확인해야 이 절차가 성립한다. 대조군이 같은 클러스터 안에 있는 설계이기 때문이다. 순서는 이렇다. ```text 파드 배치 → 상주 탐침 → 단일 요청 → 20회 반복 → 커넥션 풀 지표 ``` ### 1. postgres 와 두 Keycloak 이 어느 노드에 있나 **무엇을 확인하는가** — postgres 와 `keycloak-0` 이 같은 노드이고 `keycloak-1` 만 노드를 건너는지. ```bash label="[lab host] ① 배치와 IP" kubectl -n keycloak-lab get pods -o wide ``` ```bash label="[lab host] ② 뒤에서 계속 쓸 세 값을 셸 변수에 담는다" 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}') PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].status.podIP}') echo "K0=$K0 K1=$K1 PG=$PG" ``` **출력에서 답이 되는 것** — 세 파드의 `NODE` 열이다. 실측은 이렇다. ```text postgres 10.42.1.76 (kc-lab-2) keycloak-0 10.42.1.77 (kc-lab-2) → DB 와 같은 노드, cni0 로 직행 keycloak-1 10.42.0.42 (kc-lab-1) → DB 와 다른 노드, VXLAN 을 건넌다 ← 여기에 지연을 건다 ``` **이 결과가 뜻하는 것** — postgres 가 보내는 패킷 중 노드를 건너가는 것만 지연시키면 `keycloak-1` 의 DB 접근만 느려지고 `keycloak-0` 은 그대로다. ```text kc-lab-2 kc-lab-1 ┌──────────────────┐ ┌──────────────────┐ │ postgres │ │ keycloak-1 │ │ keycloak-0 │ │ │ │ └─ cni0 로 직행 │◀─ VXLAN ──▶│ └─ 오버레이 경유 │ └──────────────────┘ └──────────────────┘ 지연 없음 여기만 느려진다 ``` 대조군이 같은 실험 안에 있으므로 파드를 두 개 더 띄울 필요도, 다른 시간대와 비교할 필요도 없다. 배치가 다르면 이 절차는 성립하지 않는다 — 두 Keycloak 이 모두 DB 와 다른 노드에 있으면 대조군이 없고, 모두 같은 노드에 있으면 시험군이 없다. ### 2. 상주 탐침을 띄운다 **목적** — 같은 요청을 수십 번 반복할 수 있는 파드를 하나 띄우고, 비밀번호를 값으로 찍지 않고 넘긴다. ```bash label="[lab host] ① 탐침 파드를 띄운다" kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \ --restart=Never \ --env="K0=$K0" --env="K1=$K1" \ --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \ --command -- sleep 1800 ``` ```bash label="[lab host] ② 뜰 때까지 기다린다" kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s ``` ```bash label="[lab host] ③ 값이 아니라 길이만 확인한다" kubectl -n keycloak-lab exec a6-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW=${#PW}자"' ``` **예상 결과** — 모양은 이렇고 값은 환경마다 다르다. ```text K0=10.42.1.77 K1=10.42.0.42 PW=32자 ``` **왜 필요한가** — Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 비밀번호는 명령 치환으로 넘어가므로 화면에 안 나오고, 확인할 때도 길이만 본다. `PW=0자` 면 시크릿이 안 넘어간 것이고 그 상태로 재면 전부 `401` 을 재게 된다. **문제가 생기면** — 탐침의 `K0` 와 `K1` 은 만들 때 고정된다. Keycloak 파드가 재시작되면 IP 가 바뀌고 탐침의 값이 낡으므로, 그때는 탐침을 지우고 다시 만든다. 이걸 놓치면 「아무 데도 안 닿음」을 「지연」으로 읽는다. 그리고 부하를 `kubectl run --rm -i` 로 주면 안 된다 — 원 실행이 그렇게 했다가 동시 20건의 출력을 잃었다. 파드가 만들어지고 지워지는 사이에 stdout 을 붙잡는 경주가 되고, 20줄 중 일부만 도착하거나 아예 끊긴다. ### 3. 요청 하나를 읽는 형태로 먼저 친다 **무엇을 확인하는가** — 시간이 연결에 드는지 첫 바이트까지 드는지. ```bash label="[lab host] ① 구간별 시간을 함께 찍는다" kubectl -n keycloak-lab exec a6-probe -- sh -c ' curl -s -o /dev/null \ -w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \ -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"' ``` **출력에서 답이 되는 것** — `connect` 와 `ttfb` 다. 모양은 이렇고 숫자는 환경마다 다르다. ```text connect 0.001 ttfb 0.065 total 0.066 ``` | 값 | 무엇의 시간인가 | |---|---| | `time_connect` | 탐침에서 Keycloak 까지의 TCP 연결. 이 절차에서 거의 안 변한다 | | `time_starttransfer` | 첫 바이트까지 = Keycloak 이 DB 와 대화한 시간. 여기가 폭발한다 | **이 결과가 뜻하는 것** — 지연은 탐침과 Keycloak 사이가 아니라 Keycloak 과 DB 사이에 넣으므로 `connect` 는 그대로고 `ttfb` 만 는다. 주입 후에 이 두 값을 다시 보면 어디에 지연이 걸렸는지 한눈에 판정된다. 응답이 `401` 이나 `400` 이면 `-o /dev/null` 을 빼고 본문을 본다. ### 4. 20회를 반복해 원본을 파일에 모은다 **무엇을 확인하는가** — 주입 전 두 노드의 응답 시간. ```bash label="[lab host] ① keycloak-1 에 20회" kubectl -n keycloak-lab exec a6-probe -- sh -c ' rm -f /tmp/base-k1 ; i=0 while [ $i -lt 20 ]; do curl -s -o /dev/null -w "%{time_total}\n" \ -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \ >> /tmp/base-k1 i=$((i+1)) done' ``` ```bash label="[lab host] ② 원본을 먼저 본다" kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1 ``` ```bash label="[lab host] ③ 그다음 줄여서 본다" kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1 \ | awk '{s+=$1} END {printf "%d회 평균 %.0f ms\n", NR, s*1000/NR}' ``` `$K1` 을 `$K0` 로 바꿔 대조군도 똑같이 잰다. **`/tmp/base-k1` 이라는 출력 파일 이름도 같이 바꾼다** — ① 은 그 경로에 덮어쓰므로, 변수만 바꾸고 다시 돌리면 `keycloak-1` 에서 잰 20회가 지워지고 `keycloak-0` 의 20회가 그 파일을 차지한다. 화면에는 아무 경고도 안 뜬다. 뒤에서 두 노드의 평균을 견줄 때 `keycloak-1` 쪽이 이미 없다. **출력에서 답이 되는 것** — 두 노드의 평균이다. ```text === 기준선 지연 — 각 노드에서 로그인 20회 === keycloak-0 평균 70 ms keycloak-1 평균 66 ms ``` **이 결과가 뜻하는 것** — 두 값이 비슷하고 지금은 `keycloak-1` 이 오히려 4ms 빠르다. VXLAN 을 건너는 쪽이 더 빠를 수도 있는 수준의 차이이므로, 뒤에 나올 28배가 의심의 여지 없이 주입 탓이 된다. 평균만 보면 한 건이 튄 것을 놓치므로 ② 를 건너뛰지 않는다. 그리고 횟수를 주입 전후로 똑같이 맞춘다 — 이 측정은 20회로 쟀는데 해설 문서의 재현 절차에는 15회로 적혀 있고, 횟수가 다르면 평균도 달라진다. ### 5. 커넥션 풀 지표에 무엇이 있는지 미리 본다 **무엇을 확인하는가** — `agroal_*` 지표의 이름과 지금 값. ```bash label="[lab host] ① 지표 이름 목록" kubectl -n keycloak-lab exec a6-probe -- sh -c \ 'curl -s "http://$K1:9000/metrics" | grep "^agroal_"' ``` ```text agroal_acquire_count_total agroal_active_count agroal_available_count agroal_awaiting_count agroal_blocking_time_average_milliseconds agroal_blocking_time_max_milliseconds agroal_blocking_time_total_milliseconds agroal_creation_count_total agroal_creation_time_average_milliseconds agroal_creation_time_max_milliseconds agroal_creation_time_total_milliseconds agroal_destroy_count_total ``` **출력에서 답이 되는 것** — 열두 줄 가운데 뒤에서 쓰는 넷이다. | 지표 | 무엇을 말하는가 | |---|---| | `blocking_time_max` | 커넥션을 받으려고 가장 오래 기다린 시간 | | `max_used_count` | 풀이 최대 몇 개까지 늘었나 | | `awaiting_count` | 지금 줄 서 있는 요청 수 | | `active_count` | 지금 쓰이고 있는 커넥션 수 | **이 결과가 뜻하는 것** — `agroal_*` 이 JDBC 커넥션 풀 지표다(Agroal 은 Quarkus 의 풀 구현이다). `awaiting_count` 와 `active_count` 는 순간값이라 부하가 끝나면 0 으로 돌아가므로 부하 중에 읽어야 보이고, `blocking_time_max` 는 누적이라 나중에 읽어도 남는다. 위 목록은 알파벳순으로 `destroy_count_total` 에서 끊겨 있는데 원 실행이 앞부분만 남긴 것이고, 실제로는 뒤에 `agroal_max_used_count` 같은 것이 더 있다. 증거 파일이 짧다고 지표가 없는 것은 아니다. ## 주입 주입은 세 번이고 앞의 둘은 일부러 실패한다. ### 1. 시도 ① — eth0 **목적** — 인터넷 예제가 전부 쓰는 이름을 그대로 써 보고 무엇이 나오는지 본다. ```bash label="[lab host] ① 예제 그대로" ssh kc-lab-2 'sudo tc qdisc add dev eth0 root handle 1: prio' ``` **예상 결과** ```text Cannot find device "eth0" ``` **왜 필요한가** — 한 줄이면 끝날 일인데 원 실행은 이것을 스크립트로 돌렸고, 그 출력이 이랬다. ```text === 주입: postgres(10.42.1.76) 가 보내는 패킷만 200ms 지연 (kc-lab-2 eth0) === prio qdisc 로 밴드를 나누고, u32 필터로 출발지 IP 가 postgres 인 것만 3번 밴드로 보낸다 Cannot find device "eth0" Cannot find device "eth0" 적용완료 Cannot find device "eth0" Cannot find device "eth0" 주입: 13:14:55 ``` `적용완료` 가 에러 넷 사이에 끼어 있다. 그 말은 스크립트가 찍은 글자이지 커널이 한 말이 아니다. `tc` 는 네 번 다 실패했는데 스크립트는 그대로 다음 절로 넘어갔고 문서에는 시각까지 찍혔다. 명령의 성공을 「에러가 안 보인다」로 판정하면 안 되고, 손으로 한 줄씩 치면 이 실수를 할 수 없다. **문제가 생기면** — 인터페이스 이름을 확인한다. ```bash label="[lab host] ② 이 게스트에 무엇이 있나" ssh kc-lab-2 'ip -brief link' ``` ```text flannel.1 UNKNOWN a6:b2:62:04:c1:a4 cni0 UP 5a:77:1a:e2:b0:a4 ``` 게스트의 물리 NIC(Network Interface Card, 네트워크 카드) 이름은 `enp1s0` 이고 `eth0` 이 없다. | 이름 | 무엇 | |---|---| | `enp1s0` | 게스트의 물리(가상) NIC(Network Interface Card, 네트워크 카드). 노드 간 실제 트래픽이 나가는 곳 | | `flannel.1` | VXLAN 터널. 노드를 건너는 파드 트래픽이 여기로 들어간다 | | `cni0` | 노드 안 브리지. 같은 노드 파드끼리는 여기서 끝난다 | Debian 클라우드 이미지는 예측 가능한 인터페이스 이름을 쓴다. ```text enp1s0 │ │ └─ s0 : slot 0 │ └──── p1 : PCI bus 1 └────── en : ethernet ``` 이름이 하드웨어 위치에서 나오므로 NIC 순서가 바뀌어도 이름이 안 바뀌고, 그 대신 `eth0` 이라고 적힌 인터넷의 모든 예제가 안 돈다. `flannel.1` 의 상태가 `UNKNOWN` 인 것은 정상이다 — 터널 장치는 캐리어 개념이 없어서 `UP` 대신 `UNKNOWN` 으로 보고한다. ### 2. 시도 ② — enp1s0. 거는 명령이 없다 **목적** — 이름만 고치면 되는지 확인한다. **이 절에는 주입 명령이 없다.** `enp1s0` 로 `tc` 를 거는 줄은 원본 가이드에 없다(unknown) — 원 실행은 `eth0` 이 실패한 뒤 곧바로 `flannel.1` 로 갔다. 여기서 실제로 칠 수 있는 것은 아래 미검증 `tcpdump` 두 줄뿐이고, 왜 `enp1s0` 이 답이 아닌지는 구조에서 나온다. 두 줄을 건너뛰어도 3번으로 넘어가는 데 지장이 없다. 노드 간 파드 통신은 flannel VXLAN 으로 캡슐화된다. ```text 원래 패킷: src=10.42.1.76(postgres) dst=10.42.0.42(keycloak-1) │ ▼ flannel.1 에서 캡슐화 실제 패킷: src=192.168.122.12(노드) dst=192.168.122.11(노드) UDP 8472 └─ 안쪽에 원래 패킷이 통째로 들어 있다 │ ▼ enp1s0 로 나간다 ``` `enp1s0` 에서 `match ip src 10.42.1.76` 은 절대 일치하지 않는다. 그 IP 는 페이로드 안에 있고 헤더에는 노드 IP 만 있다. 눈으로 확인하는 두 줄을 가이드가 미검증으로 표시했다. ```bash label="[lab host] ① 미검증 — 물리 쪽에는 노드 IP 만 보인다" ssh kc-lab-2 'sudo tcpdump -i enp1s0 -n -c 5 udp port 8472' ``` ```bash label="[lab host] ② 미검증 — 터널 쪽에는 파드 IP 가 보인다" ssh kc-lab-2 'sudo tcpdump -i flannel.1 -n -c 5 host 10.42.1.76' ``` **예상 결과** — 앞쪽에서는 노드 IP 사이의 UDP 8472 만 보이고 `10.42.x.x` 는 안 보인다. 뒤쪽 터널에서는 파드 IP 가 보인다. | 인터페이스 | 파드 IP 가 보이나 | 무엇을 지연시키게 되나 | |---|---|---| | `cni0` | 보인다 | 같은 노드 안 통신만 | | `flannel.1` | 보인다 (캡슐화 직전) | 노드를 건너는 파드 통신 | | `enp1s0` | 안 보인다 | 노드 간 모든 것 (SSH·k3s 포함) | **왜 필요한가** — `enp1s0` 에 `netem` 을 root 로 걸면 `kubectl` 도 SSH 도 같이 느려져서 무엇이 원인인지 못 가린다. **문제가 생기면** — 원 실행에는 이 확인이 없다. `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔으므로 「`enp1s0` 에 걸면 0 패킷」이라는 출력 원문은 이 실험에 없고, 구조에서 나온 결론이다. ### 3. 성공한 주입 — flannel.1 에 세 줄 **목적** — postgres 가 보내는 패킷만 골라 200ms 지연시킨다. 2026-09-17 에 새 실험대에서 이 세 줄을 쳐서 같은 수를 받았다(observed). 넣기 전과 뒤를 같은 명령으로 쟀다. ```text 넣기 전 200 0.067946s 200 0.064262s 넣은 뒤 200 1.905504s 200 1.903161s 200 1.860396s 200 1.867929s 200 1.872740s 되돌린 뒤 200 0.067775s 200 0.043802s 200 0.045763s ``` **200ms 를 넣었는데 1.87초가 된다.** 문서가 적은 `1,872ms` 와 28배가 그대로 나왔다. 그 사이 `netem` 카운터는 `83 pkt` 이었고, 되돌리자 `qdisc noqueue` 로 돌아가며 시간도 원래대로였다. 시도 ① 과 ② 의 관측도 다시 나왔다(observed). ```text sudo tc qdisc add dev eth0 root handle 1: prio → Cannot find device "eth0" enp1s0 의 헤더 192.168.122.12.53654 > 192.168.122.11.8472: OTV … ← 노드 IP 뿐 flannel.1 의 헤더 10.42.0.14.52916 > 10.42.1.29.5432 … ← 파드 IP 가 보인다 ``` **문서가 미검증으로 둔 세 줄도 전부 돌았다**(observed) — `tcpdump` 두 줄과 `tc filter show`. 필터 목록은 이렇게 나온다. ```text filter parent 1: protocol ip pref 3 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 *flowid 1:3 not_in_hw match 0a2a011d/ffffffff at 12 ``` `0a2a011d` 가 `10.42.1.29` 를 16진수로 적은 것이고, 그 값이 지금 postgres 의 IP 와 같은지가 필터가 제 대상을 고르고 있다는 증거다. **`tcpdump` 에는 시간 제한을 걸어 둔다.** `-c 5` 는 패킷 다섯 개를 받아야 끝나므로, 그 조건에 맞는 트래픽이 없으면 명령이 안 끝난다. `sudo timeout 12 tcpdump …` 로 감싸면 조용한 구간에서도 프롬프트가 돌아온다. 한 줄씩 친다. 앞 줄이 실패하면 뒤 줄은 붙을 곳이 없어서 다른 에러를 낸다. ```bash label="[lab host] ① 밴드 3개짜리 분류기를 만든다" ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 root handle 1: prio" ``` ```bash label="[lab host] ② 3번 밴드에 200ms 지연을 붙인다" ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 parent 1:3 handle 30: netem delay 200ms" ``` ```bash label="[lab host] ③ 출발지가 postgres 인 패킷을 3번 밴드로 보낸다" ssh kc-lab-2 "sudo tc filter add dev flannel.1 protocol ip parent 1:0 prio 3 \ u32 match ip src $PG/32 flowid 1:3" ``` ```bash label="[lab host] ④ 주입 시각" date '+%H:%M:%S 주입' ``` **예상 결과** — 세 줄 다 아무것도 찍지 않는다. 걸렸는지는 다음 절의 카운터가 답한다. **왜 필요한가** — 세 줄이 나뉘어 있는 까닭은 `tc` 의 계층 구조다. ```text qdisc (큐 규율) 인터페이스에 붙는 패킷 스케줄러 ├─ prio 우선순위 밴드 3개로 나눈다 │ ├─ 1:1 (기본) │ ├─ 1:2 (기본) │ └─ 1:3 ← 여기에 netem 을 붙인다 └─ filter 어떤 패킷을 어느 밴드로 보낼지 ``` | 줄 | 하는 일 | |---|---| | `qdisc ... root handle 1: prio` | 밴드 3개짜리 분류기를 만든다 | | `qdisc ... parent 1:3 handle 30: netem delay 200ms` | 3번 밴드에 200ms 지연을 붙인다 | | `filter ... match ip src $PG/32 flowid 1:3` | 출발지가 postgres 인 패킷을 3번 밴드로 보낸다 | `netem` 을 root 에 바로 붙이면 모든 트래픽이 느려진다. `prio` 와 `filter` 를 쓰면 고른 트래픽만 느려지고, 이 절차는 postgres 가 보내는 것만 골라야 하므로 세 단계가 필요하다. **문제가 생기면** — 지연이 양쪽 다 늘었으면 `netem` 을 `root` 에 직접 붙인 것이다. 중단 명령으로 걷어내고 세 줄을 다시 친다. ## 주입 검증 ### 1. 시도 ① 은 값을 찍기만 하고 판정하지 않아 그냥 지나갔다 시도 ① 은 에러를 냈는데도 그대로 넘어갔고, 그 상태에서 잰 「검증」이 이랬다. ```text === [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 === keycloak-0 평균 43 ms 최대 64 ms keycloak-1 평균 47 ms 최대 70 ms ``` 두 노드가 여전히 같고, 그것이 「안 걸렸다」는 신호였다. 검증 절이 값을 찍기만 하고 판정하지 않으면 이렇게 지나간다. ### 2. 성공한 주입 뒤에는 카운터를 본다 ```bash label="[lab host] ① 넣은 직후의 카운터" ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1' ``` ```text 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 ``` `Sent 0 pkt` 인데 이것은 실패가 아니다. A-5 에서 `pkts 0` 은 규칙이 안 걸렸다는 뜻이었고, 여기서는 아직 아무 패킷도 지나가지 않았을 뿐이다. postgres 는 요청이 있어야 답하므로 트래픽을 한 번 만든다. ```bash label="[lab host] ② 요청을 한 번 보낸다" kubectl -n keycloak-lab exec a6-probe -- sh -c ' curl -s -o /dev/null -w "%{time_total}\n" \ -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"' ``` ```bash label="[lab host] ③ 다시 센다" ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem' ``` ```text === [검증] 필터에 패킷이 걸리는가 === qdisc netem 30: parent 1:3 limit 1000 delay 200ms Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0) backlog 0b 0p requeues 0 ``` `150 pkt` 이므로 실제로 지연 밴드를 통과했다. | 상태 | 뜻 | 할 일 | |---|---|---| | 부하 전 `0 pkt` | 아직 트래픽이 없다 | 요청을 한 번 보내고 다시 센다 | | 부하 후에도 `0 pkt` | 필터가 아무것도 못 잡았다 | IP·인터페이스·방향을 다시 본다 | | `pkt` 이 는다 | 걸렸다 | 관찰로 넘어간다 | | `dropped` 가 는다 | `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 | A-1 과 A-5 에서 나온 것과 같은 교훈이 세 번째로 나왔다 — 주입을 넣은 것과 걸린 것은 다르다. 필터 자체를 보는 줄은 가이드가 미검증으로 표시했다. ```bash label="[lab host] ④ 미검증 — 필터 목록" ssh kc-lab-2 'sudo tc filter show dev flannel.1' ``` ## 관찰 ### 1. 단일 요청 — connect 는 그대로고 ttfb 만 폭발한다 주입 전에 친 것과 똑같은 명령을 다시 친다. ```bash label="[lab host] ① 구간별 시간" kubectl -n keycloak-lab exec a6-probe -- sh -c ' curl -s -o /dev/null \ -w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \ -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"' ``` 그다음 20회 반복으로 두 노드를 잰다. ```text === 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) === keycloak-0 평균 41 ms 최대 57 ms keycloak-1 평균 1872 ms 최대 1887 ms ``` `keycloak-1` 이 66ms 에서 1,872ms 로 28배가 됐다. 대조군도 변했다 — `keycloak-0` 은 70ms 에서 41ms 로 41% 빨라졌다. 주입과 무관한 변동(JIT(Just-In-Time 컴파일) 워밍업, 캐시)이며, 해설 문서가 처음에 「영향 없음」이라고 쓴 것은 부정확했다. 자릿수가 달라 결론은 유지되지만 대조군이 안 변한다고 가정하면 안 된다. 왜 200ms 가 1,872ms 가 되는가는 A-0 에서 잡은 로그인 트랜잭션의 SQL 이 답한다. ```text BEGIN select ... from OFFLINE_USER_SESSION ... select VERSION ... for no key update skip locked select ... from OFFLINE_CLIENT_SESSION ... select VERSION ... for no key update skip locked insert into OFFLINE_USER_SESSION ... insert into OFFLINE_CLIENT_SESSION ... SET LOCAL synchronous_commit TO OFF COMMIT ``` 왕복이 아홉 번이다. ```text 200 ms × 9 왕복 ≈ 1,800 ms 실측 1,872 ms ``` `9` 는 SQL 목록을 센 값이고 패킷을 추적한 값이 아니다. 자릿수가 맞는다는 것까지가 이 계산이 말할 수 있는 범위이며, 왕복 수를 확정하려면 `tc -s` 의 패킷 수를 요청 수로 나누거나 패킷 캡처가 필요하다. 그래도 네트워크 지연이 왕복 횟수만큼 증폭된다는 것까지는 이 측정이 뒷받침한다. 「DB 가 200ms 느려졌다」는 「애플리케이션이 200ms 느려졌다」가 아니고, 쿼리 수를 줄이는 것이 지연 환경에서 결정적인 까닭이 여기 있다. ### 2. 동시 부하가 이 절차의 본 시험이다 순차로 20번 돌리면 큐잉이 재현되지 않는다. 백그라운드로 띄우고 `wait` 하며, 결과는 파드 안 파일에 모은다. ```bash label="[lab host] ① 동시 20건" kubectl -n keycloak-lab exec a6-probe -- sh -c ' rm -f /tmp/load ; i=0 while [ $i -lt 20 ]; do ( curl -s -o /dev/null -w "%{http_code} %{time_total}\n" --max-time 60 \ -X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \ >> /tmp/load ) & i=$((i+1)) done wait' ``` ```bash label="[lab host] ② 다 모였는지부터 센다" kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt wc -l /tmp/load.txt ``` ```bash label="[lab host] ③ 원본을 본다" cat /tmp/load.txt ``` ```bash label="[lab host] ④ 상태 코드와 시간을 나눠 본다" awk '{print $1}' /tmp/load.txt | sort | uniq -c awk '{print $2}' /tmp/load.txt | sort -g ``` `20` 이 아니면 수집이 샌 것이고, 그 상태의 숫자는 해석하지 않는다. ```text === 동시 부하 20건을 keycloak-1 에 — 커넥션 풀이 견디는가 === 1 200 1.911191 1 200 1.913766 1 200 1.958374 1 200 1.981620 1 200 10.539402 1 200 11.951943 1 200 13.351102 1 200 14.785832 1 200 16.189533 1 200 17.625166 1 200 19.053724 1 200 20.495883 1 200 21.905932 1 200 22.228466 1 200 22.230871 1 200 3.441366 1 200 4.841075 1 200 6.257489 1 200 7.704608 1 200 9.104792 ``` 순서가 이상하다. `10.5` 가 `3.4` 보다 앞에 있는데 원 실행이 `sort` 를 사전순으로 썼기 때문이다(맨 앞의 `1` 은 `uniq -c` 가 붙인 개수다). 문자열로 정렬하면 `"10.5" < "3.4"` 다. ```bash label="[lab host] ⑤ 정렬 방식을 갈라 본다" sort /tmp/load.txt # 사전순 — 10.5 가 3.4 앞에 온다 sort -g /tmp/load.txt # 수치순 — 이걸 써야 한다 ``` **원 가이드의 저 처방은 이 파일에서 안 듣는다.** 각 줄이 `200 1.911191` 처럼 전부 같은 `200` 으로 시작해서, `sort -g` 는 줄 앞머리 수치를 비교하다 전부 동률이 되고 마지막에 바이트 순으로 떨어진다. `sort` 와 출력이 똑같다. 정렬할 열을 짚어야 듣는다. ```bash label="[lab host] ⑥ 열을 짚어 다시 정렬한다" sort -g -k2 /tmp/load.txt ``` 위 ③ 의 `awk '{print $2}' /tmp/load.txt | sort -g` 도 제대로 돈다 — `awk` 가 둘째 열만 남기기 때문이다. 최대값 하나만 볼 때는 거기에 `| tail -1` 을 붙인다. 시간 값을 정렬할 때는 **열을 짚는다.** 이걸 놓치면 최대값을 잘못 읽고, `sort -g` 를 붙였다는 것만으로 고쳤다고 믿게 된다. 숫자를 순서대로 놓으면 계단이 된다. ```text 1.9 → 3.4 → 4.8 → 6.2 → 7.7 → 9.1 → 10.5 → ... → 22.2 ──── ──── ──── ──── 약 1.4초 간격 — 앞 요청이 커넥션을 놓아줄 때까지 줄을 선다 ``` 전부 성공(`200`)했는데 응답 시간이 1.9초에서 22.2초까지 늘어난다. 커넥션 수는 유한하고 각 요청이 커넥션을 1.9초씩 붙잡으므로 뒤에 온 요청은 그만큼 기다린다. `200` 만 보는 감시는 이 장애를 못 본다. ### 3. 커넥션 풀 지표는 부하가 끝나자마자 읽는다 ```bash label="[lab host] ① 부하 직후에 읽는다" kubectl -n keycloak-lab exec a6-probe -- sh -c \ 'curl -s "http://$K1:9000/metrics" | grep -E "^agroal_(blocking_time|max_used|acquire|active|available|awaiting)"' ``` ```text === 부하 직후 커넥션 풀 === agroal_blocking_time_max_milliseconds 20000.0 agroal_max_used_count 19.0 agroal_acquire_count_total 672.0 agroal_active_count 0.0 agroal_awaiting_count 0.0 agroal_blocking_time_average_milliseconds 281.0 agroal_available_count 19.0 ``` | 값 | 읽는 법 | |---|---| | `blocking_time_max 20000.0` | 커넥션을 받으려고 20초를 기다린 요청이 있었다 | | `max_used_count 19.0` | 풀이 19개까지 늘어났다 | | `blocking_time_average 281.0` | 평균은 0.3초. 평균만 보면 아무 일도 없어 보인다 | | `active_count 0.0` · `awaiting_count 0.0` | 순간값. 부하가 끝나서 0 이다 | 평균과 최대의 간격이 이 장애의 모양이다. 평균 281ms 짜리 그래프에서는 아무도 20초를 보지 못한다. 이 관측 스택에는 히스토그램 지표가 없어서 그 사이의 분포는 안 나온다. 가이드가 남겨 둔 쿼리는 다음 한 줄이다. ```promql label="[Prometheus 질의] 미검증 — 이 실험대에 이 지표가 없어 치지 않았다" # 있으면 좋았을 것 histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m])) ``` ### 4. 헬스체크가 같은 줄에 선다 ```bash label="[lab host] ① 최근 이벤트" kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20 ``` ```bash label="[lab host] ② 파드 상태" kubectl -n keycloak-lab get pods ``` ```text keycloak-0 1/1 Running 0 60m keycloak-1 1/1 Running 1 (51m ago) 3h24m 52m Normal TaintManagerEviction pod/keycloak-1 Cancelling deletion of Pod keycloak-lab/keycloak-1 32m Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503 89s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.42:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers) ``` `89s` 짜리 줄이 지금 주입의 결과이고 `32m` 과 `52m` 짜리는 A-4 에서 노드를 껐다 켠 흔적이다. 이벤트를 볼 때는 `Age` 를 먼저 본다 — 목록에 한 시간 전 것까지 섞여 있다. | 메시지 | 무슨 일 | |---|---| | `HTTP probe failed with statuscode: 503` | Keycloak 이 답은 했다. 스스로 DOWN 이라고 말했다 | | `context deadline exceeded` | 답 자체를 못 했다. 프로브가 줄에서 기다리다 끝났다 | readiness 프로브 자체가 타임아웃됐다. 헬스체크도 같은 커넥션 풀 줄에 서므로 연쇄가 이렇게 된다. ```text DB 가 느려진다 ↓ 요청이 커넥션을 오래 붙잡는다 ↓ 커넥션 풀이 고갈된다 ↓ 새 요청이 줄을 선다 (최대 20초) ↓ 헬스체크도 줄에 선다 → 타임아웃 → NotReady ↓ 그 노드가 로드밸런서에서 빠진다 ↓ ★ 남은 노드로 트래픽이 몰린다 → 그 노드도 같은 길을 간다 ``` 마지막 화살표에서 느려짐이 전파된다. A-2(DB 완전 정지)는 즉시 `503` 으로 드러나 오히려 명확했고, 느려짐은 살아 있는 노드를 하나씩 무너뜨린다. 해설 문서는 이 연쇄에서 구성 규칙 두 가지를 끌어냈다. | 알게 된 것 | 구성에서 무엇을 정하나 | |---|---| | 커넥션 풀에서 한 번 더 곱해진다 | 풀 크기와 타임아웃이 장애 반경을 정한다 | | 헬스체크도 줄에 선다 | 프로브 타임아웃이 풀 대기보다 짧아야 격리가 제때 된다 | 헬스체크 쪽이 이 절차에서 실제로 일어난 일이다. ### 5. 빗나간 예측도 하나 남았다 계획서에는 이렇게 적혀 있었다. > 낙관적 락 충돌 증가 — 트랜잭션이 길어져 `VERSION` 충돌이 늘어야 한다 지연 구간의 로그를 세는 줄을 가이드가 미검증으로 표시했다. 원 실행의 정확한 패턴이 기록에 없다. ```bash label="[lab host] ① 미검증 — 충돌 로그를 센다" kubectl -n keycloak-lab logs keycloak-1 --since=20m \ | grep -icE 'optimistic|StaleState|version.*conflict' ``` ```text === 낙관적 락 충돌이 늘었는가 — 지연 중 로그 === 관련 로그 줄수: 0 ``` 하나도 없었고 까닭이 명확하다. ```text 로그인 → 매번 새 세션 행을 INSERT → 다툴 상대가 없다 refresh → 같은 세션 행을 UPDATE → 여기서 다툰다 ``` 충돌은 같은 행을 동시에 고칠 때만 일어나므로 로그인 부하로는 재현되지 않는다. 예측이 빗나간 뒤에야 연산이 INSERT 라는 것이 보였고, 틀린 이유가 락 구현이 아니라 연산의 종류에 있었다. B-3(refresh 토큰 경쟁)의 영역이고, 거기서 지연을 함께 주면 충돌률이 올라갈 것이라고 가이드는 적는다. 예측을 적어 두지 않았다면 「충돌이 없네」 하고 넘어갔다. ## 복구와 원상복구 확인표 ### 1. 지연을 걷어낸다 **목적** — `flannel.1` 의 `root` qdisc 를 지워 `prio` 와 `netem` 과 filter 를 한꺼번에 없앤다. ```bash label="[lab host] ① 해제 시각" date '+%H:%M:%S 해제' ``` ```bash label="[lab host] ② root 를 지운다" ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root' ``` ```bash label="[lab host] ③ 무엇이 남았는지 본다" ssh kc-lab-2 'sudo tc qdisc show dev flannel.1' ``` **예상 결과** ```text === 지연 해제 === 해제완료 qdisc noqueue 0: root refcnt 2 ``` `noqueue` 이므로 `prio` 도 `netem` 도 없다. **왜 필요한가** — `root` 를 지우면 그 아래 자식 qdisc 와 filter 가 같이 사라진다. 하나씩 지우면 filter 를 빠뜨리기 쉽다. **문제가 생기면** — 시도 ① 이 `enp1s0` 에 무언가 남겼을 수 있으므로 그쪽도 본다. ### 2. 회복을 같은 명령으로 확인한다 **목적** — 주입 전과 같은 20회 반복 측정을 다시 쳐서 자릿수가 돌아왔는지 본다. 회복은 20회 반복 측정 명령을 그대로 다시 쳐서 본다. 그 명령의 첫 줄이 `rm -f /tmp/base-k1` 이므로 파일은 새로 만들어진다. 두 노드 다 잰다 — 같은 명령이어야 비교가 된다. **예상 결과** ```text === 회복 확인 === keycloak-0 평균 43 ms keycloak-1 평균 51 ms keycloak-0 1/1 Running 0 61m keycloak-1 1/1 Running 1 (52m ago) 3h24m ``` 파드 재시작 없이 즉시 회복했고 `RESTARTS` 가 안 늘었다. 이 절차는 readiness 를 흔들었을 뿐 파드를 죽이지는 않았고, 커넥션 풀도 스스로 정상화됐다. **왜 필요한가** — `agroal_blocking_time_max_milliseconds` 는 누적이라 `20000` 인 채로 남는다. 파드를 재시작해야 0 이 되는데 그대로 두는 편이 낫다 — 이 노드가 한 번 20초를 기다린 적이 있다는 기록이다. **문제가 생기면** — 탐침 파드를 지운다. ```bash label="[lab host] 탐침 파드를 지운다" kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found ``` | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | qdisc | `ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'` | `noqueue` | | (물리 쪽도) | `ssh kc-lab-2 'sudo tc qdisc show dev enp1s0'` | 시도 ① 잔재가 없어야 한다 | | 응답 시간 | 20회 반복 측정 | 주입 전과 같은 자릿수 | | 파드 | `kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` | | 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` | ## 막히면 이 표는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다. | 증상 | 원인 | 확인 | |---|---|---| | `Cannot find device "eth0"` | 이 게스트의 NIC 는 `enp1s0` 이다 | `ip -brief link` | | 스크립트가 「적용완료」인데 지연이 없다 | 성공 메시지는 스크립트가 찍은 것 | `tc -s qdisc` 카운터 | | `enp1s0` 에 걸었는데 안 걸린다 | VXLAN 안에 파드 IP 가 숨어 있다 | `flannel.1` 에 건다 | | `Sent 0 pkt` | 부하 전이면 정상. 부하 후면 필터가 틀렸다 | 요청 한 번 보내고 다시 센다 | | 지연이 양쪽 다 늘었다 | `netem` 을 `root` 에 직접 붙였다 | `prio` + `filter` 로 골라 낸다 | | `kubectl` 이나 SSH 까지 느려졌다 | `enp1s0` 에 걸었다 | `tc qdisc del dev enp1s0 root` | | 20줄 중 몇 줄만 온다 | `kubectl run --rm -i` 로 동시 실행하면 stdout 이 샌다 | 상주 파드 + 파일 | | 최대값이 `9.1` 로 보인다 | `sort` 가 사전순이고, `sort -g` 만 붙여도 줄이 전부 `200` 으로 시작해 안 듣는다 | `sort -g -k2`, 또는 ③ 의 `awk` 형태 | | `blocking_time` 이 0 이다 | 부하가 끝나고 한참 뒤에 읽었다 | 부하 직후에 읽는다 | | `awaiting_count` 가 늘 0 이다 | 순간값이다 | 부하가 도는 중에 읽는다 | | 로그인이 전부 `401` | `PW` 가 안 넘어갔다 | `exec a6-probe -- sh -c 'echo ${#PW}'` | | 갑자기 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | 탐침을 지우고 다시 만든다 | | 이벤트가 과거 것과 섞인다 | 이벤트는 한 시간 전 것도 남는다 | `Age` 를 먼저 본다 | | 대조군도 값이 변했다 | 정상이다. JIT 와 캐시 변동 | 자릿수로 판정한다 | | `dropped` 가 늘어난다 | `netem` 의 `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 | ## 무엇이 관측이고 무엇이 아닌가 - (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` 를 치는 두 단계 형태도 이 실험대에서 치지 않았다. - 구조에서 나온 결론이고 출력이 없는 것 — 「`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` 는 그 목록에 안 보이지만 부하 뒤 출력에는 있다. - 처음 쓴 것이 부정확했던 곳 — 해설 문서의 「대조군 영향 없음」. 대조군은 `70 ms` 에서 `41 ms` 로 41% 빨라졌다. - 이 절차가 재지 않은 것 — 응답 시간 분포. 관측 스택에 히스토그램 지표가 없어 평균 `281ms` 와 최대 `20,000ms` 사이에 무엇이 있었는지는 모른다. 낙관적 락 충돌도 로그인 부하로는 재현되지 않아 B-3 으로 넘겼다.