diff --git a/b7a-live-session-after-orphan-delete.png b/b7a-live-session-after-orphan-delete.png
deleted file mode 100644
index 5750cdd..0000000
Binary files a/b7a-live-session-after-orphan-delete.png and /dev/null differ
diff --git a/docs/guides/00-lab-host/README.md b/docs/guides/00-lab-host/README.md
index b4ed307..f28be5c 100644
--- a/docs/guides/00-lab-host/README.md
+++ b/docs/guides/00-lab-host/README.md
@@ -15,29 +15,42 @@
BIOS 에서 꺼져 있으면 아무것도 못 한다. 먼저 본다.
-**확인**
+**확인** — CPU 가 하드웨어 가상화 확장을 내놓고 있는가
```bash
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
```
-`vmx`(Intel) 또는 `svm`(AMD) 이 나오면 된다. 아무것도 안 나오면 BIOS 에서
-Intel VT-x / AMD-V 를 켜야 한다.
+**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD)
+중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
+
+**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 다음
+단계로 간다. 빈 출력은 「CPU 가 못 한다」가 아니라 대개 **BIOS 에서 꺼져
+있다**는 뜻이다 — 재부팅해 Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서
+막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
**실측** — 이 실험대의 호스트는 16 코어 전부에서 지원한다.
## 2. KVM 모듈이 올라와 있는가
-**확인**
+**확인** — 커널이 그 확장을 실제로 잡고 있는가
```bash
lsmod | grep kvm
```
+**형태** (이 실험대에서 캡처해 두지 않았다 — 줄 모양만)
```
kvm_intel ...
kvm ...
```
-두 줄이 나오면 커널이 하드웨어 가상화를 쓸 준비가 됐다.
+**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈
+(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm` 이 **둘 다** 있어야 한다. 셋째 열은
+이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 인 것이 정상이다.
+
+**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고
+`virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 1번의
+BIOS 설정이 커널까지 안 넘어온 것이다 — `sudo modprobe kvm_intel` 로 직접
+올려 보면 거부 사유가 그대로 나온다. 아무것도 없으면 1번으로 돌아간다.
> **왜 이걸 먼저 보나.** KVM 없이도 QEMU 는 돌지만 **소프트웨어 에뮬레이션**이
> 되어 수십 배 느리다. VM 두 대가 「뜨긴 뜨는데 느리다」면 대개 여기다.
@@ -61,6 +74,20 @@ sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-util
| virt-install | VM 을 만드는 명령 |
| dnsmasq | 가상 네트워크의 DHCP·DNS |
+**확인** — 두 실행 파일이 PATH 에 들어왔는가
+```bash
+virsh --version
+qemu-system-x86_64 --version
+```
+
+**어디를 봐야 하는가** — 판 번호 두 줄. 명령을 못 찾는다(`command not found`)면
+패키지가 안 깔린 것이고, 번호가 나오면 깔린 것이다.
+
+**이 결과가 의미하는 것** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을
+가른다. 이 실험대는 **libvirt 12.7.0 · QEMU emulator version 11.1.1** 이었다
+(문서 끝 실측값 블록). 훨씬 낮은 판이면 `virt-install --cloud-init` 같은
+옵션의 동작이 다를 수 있으니, 01 에서 막힐 때 이 번호를 같이 본다.
+
## 4. libvirt 를 띄우고 권한을 받는다
**하기**
@@ -72,7 +99,7 @@ sudo usermod -aG libvirt "$USER"
그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로
`usermod` 만으로는 지금 셸에 반영되지 않는다.
-**확인**
+**확인** — 지금 이 셸이 libvirt 에 sudo 없이 붙는가
```bash
groups # libvirt 가 보여야 한다
virsh list --all # sudo 없이 돌아야 한다
@@ -83,6 +110,17 @@ virsh list --all # sudo 없이 돌아야 한다
donghyeon libvirt wheel
```
+**어디를 봐야 하는가** — `groups` 출력에 `libvirt` 가 끼어 있는가, 그리고
+`virsh list --all` 이 **머리글만 있는 빈 표**라도 오류 없이 끝나는가. 아직
+VM 을 안 만들었으므로 표가 비어 있는 것이 정상이다 — 봐야 할 것은 표의
+내용이 아니라 명령이 통과했다는 사실이다.
+
+**이 결과가 의미하는 것** — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있고,
+01 로 넘어가도 된다. `groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만
+**지금 로그인 세션이 옛 그룹 목록을 들고 있는** 것이다 — 로그아웃/로그인
+한다. `groups` 에는 있는데 `virsh` 가 `Permission denied` 면 그룹이 아니라
+소켓 문제이므로 `systemctl status libvirtd.socket` 을 본다.
+
> **`libvirtd.service` 가 아니라 `.socket` 을 켠 이유.** 소켓 활성화라서
> 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄운다.
> 자원을 아끼고, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
@@ -98,7 +136,7 @@ donghyeon libvirt wheel
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
```
-**확인**
+**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
```bash
virsh uri
```
@@ -106,9 +144,17 @@ virsh uri
qemu:///system
```
+**어디를 봐야 하는가** — 끝의 한 낱말. `system` 인가 `session` 인가.
+
+**이 결과가 의미하는 것** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금
+`virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를
+보고 있어 **VM 은 만들어졌는데 `virsh list` 에 안 나오는** 상태가 된다.
+`.bashrc` 에 넣은 것은 **새로 여는 셸에만** 적용되므로, 지금 셸에서는
+`export LIBVIRT_DEFAULT_URI=qemu:///system` 을 한 번 더 치거나 새 셸을 연다.
+
## 6. 기본 네트워크
-**확인**
+**확인** — VM 이 붙을 가상 네트워크가 살아 있는가
```bash
virsh net-list --all
```
@@ -120,7 +166,16 @@ virsh net-list --all
default active yes yes
```
-`inactive` 면 켠다.
+**어디를 봐야 하는가** — `default` 행의 **State 와 Autostart 두 칸**.
+`--all` 을 준 이유가 여기 있다 — 빼면 `inactive` 인 네트워크는 아예 목록에
+안 나와서 「없음」과 「꺼짐」을 구분할 수 없다.
+
+**이 결과가 의미하는 것** — `active` + `yes` 면 지금도, 호스트를 재부팅한
+뒤에도 `virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도
+DHCP 가 없어 IP 를 못 받는다. Autostart 가 `no` 면 **지금은 되지만 호스트를
+재부팅한 다음 01 의 SSH 가 전부 실패**하고, 원인을 게스트에서 찾게 된다.
+둘 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
+
```bash
virsh net-start default
virsh net-autostart default
diff --git a/docs/guides/01-vms/README.md b/docs/guides/01-vms/README.md
index 73c1aea..8102527 100644
--- a/docs/guides/01-vms/README.md
+++ b/docs/guides/01-vms/README.md
@@ -29,11 +29,20 @@ sudo curl -LO https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-ge
sudo mv debian-12-genericcloud-amd64.qcow2 base.qcow2
```
-**확인**
+**확인** — 받은 파일이 온전한 qcow2 인가
```bash
qemu-img info /var/lib/libvirt/images/base.qcow2
```
+**어디를 봐야 하는가** — 세 줄이다. `file format:` 이 `qcow2` 인가(`raw` 로
+읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다),
+`virtual size:` 가 `disk size:` 보다 훨씬 큰가(qcow2 는 희소 파일이라 이게
+정상이다), `backing file:` 줄이 **없는가**. base 는 아무것도 뒤에 두지 않는다.
+
+**이 결과가 의미하는 것** — 셋이 맞으면 이 파일을 4번에서 오버레이의 바닥으로
+쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라
+**게스트가 부팅을 못 하는 것**으로 나타나 원인을 찾기 어려워진다.
+
## 2. cloud-init 을 쓴다
게스트마다 하나씩 만든다. 템플릿은
@@ -93,8 +102,41 @@ grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
```
-마지막 줄이 중요하다. **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
-않으므로**, 넣기 전에 여기서 걸러야 한다.
+**어디를 봐야 하는가** — 숫자 두 개와 낱말 하나. 첫 줄이 `0` 이 아니면
+`__LAB_HOST_KEY__` 같은 문자열이 남아 있는 것이고, 둘째 줄이 `2` 가 아니면
+`sed` 치환 중 하나가 안 먹은 것이다. 셋째 줄은 `YAML OK` 가 찍히는가만 본다 —
+찍히지 않으면 대신 파이썬 예외 줄이 나오고, 거기 적힌 `line N` 이 문제의
+줄 번호다. 이 셋은 값을 뽑는 것이 아니라 **찍힌 숫자를 눈으로 비교하는**
+용도라 이 형태가 맞다.
+
+**이 결과가 의미하는 것** — `0` / `2` / `YAML OK` 셋이 다 맞아야 시드를 만든다.
+어긋난 채로 3번을 진행하면 **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
+않으므로**, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는
+사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다.
+
+> **`yamllint` 는 이 실험대에 없다.** 있으면 좋지만 없다고 설치하러 가지
+> 않는다 — 위 세 줄로 충분하다.
+
+**게스트가 한 대라도 떠 있으면 한 단계 더 볼 수 있다.** YAML 로 파싱된다는
+것과 **cloud-config 로 유효하다**는 것은 다르다. 키 이름 오타(`user` vs
+`users`)는 위 검사를 그냥 통과한다. cloud-init 자신의 스키마 검사기가
+게스트에 들어 있다 — `kc-lab-2` 용 파일은 `kc-lab-1` 에서 검사할 수 있다.
+
+```bash
+# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
+# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
+ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
+ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
+```
+
+**어디를 봐야 하는가** — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고,
+아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
+경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다.
+
+**이 결과가 의미하는 것** — 오류가 나면 그 키는 **조용히 무시된다.**
+`users` 를 `user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안
+붙는다」 하나뿐이다. 첫 게스트(`kc-lab-1`)를 만들 때는 검사할 게스트가 아직
+없으니 위 세 줄로 가고, 둘째부터는 이 검사를 거친다.
세 가지가 의도적이다.
@@ -124,6 +166,26 @@ virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" -
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
```
+**확인** — 볼륨이 풀에 올라갔고 비어 있지 않은가
+```bash
+virsh vol-list default
+```
+
+**어디를 봐야 하는가** — `seed-kc-lab-1.iso` 행이 목록에 있는가. 있으면
+크기까지 본다.
+
+```bash
+virsh vol-info --pool default seed-kc-lab-1.iso
+```
+
+**Capacity** 가 방금 만든 로컬 파일 크기(`stat -c%s seed-kc-lab-1.iso`)와
+같아야 한다.
+
+**이 결과가 의미하는 것** — `vol-create-as` 는 **빈 볼륨을 만들 뿐**이고
+내용은 `vol-upload` 가 채운다. 두 명령 중 뒤엣것을 빠뜨리면 목록에는 이름이
+보이지만 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아
+조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. 크기가 맞으면 4번으로 간다.
+
`instance-id` 에 타임스탬프를 넣는 이유가 있다. cloud-init 은 **인스턴스마다
한 번만** 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로,
user-data 를 고쳐도 반영되지 않는다.
@@ -164,7 +226,7 @@ virsh net-update default add ip-dhcp-host \
`--live --config` 둘 다 준다. `--live` 만 주면 재부팅에 사라지고, `--config`
만 주면 지금 반영되지 않는다.
-**확인**
+**확인** — 예약이 실제로 들어갔는가
```bash
virsh net-dumpxml default | grep ip-dhcp-host -A3
```
@@ -176,9 +238,20 @@ virsh net-dumpxml default | grep ip-dhcp-host -A3
```
+**어디를 봐야 하는가** — `` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
+짝이 맞는가**(`:11` ↔ `.11`, `:12` ↔ `.12`). MAC 은 4번의 `virt-install
+--network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `` 줄은 예약이
+아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.
+
+**이 결과가 의미하는 것** — 두 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
+받는다. 한 줄만 보이면 `--live --config` 중 하나를 빠뜨린 것이다.
+`net-dumpxml` 은 **지금 돌고 있는 정의**를 보여 주므로 여기 보이는 것은
+`--live` 가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는
+`virsh net-dumpxml --inactive default` 로 따로 본다.
+
## 6. 붙어 본다
-**확인**
+**확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가
```bash
virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
@@ -195,6 +268,17 @@ kc-lab-1
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
```
+**어디를 봐야 하는가** — 세 가지다. ① State 가 두 대 다 `running` 인가
+(`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가
+**비밀번호를 묻지 않고** 통과했는가. ③ `hostname` 이 `kc-lab-1` 인가
+`localhost` 인가.
+
+**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다.
+호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면
+같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. **`localhost` 가 나오면 SSH
+설정을 고치지 말고 시드부터 의심한다** — 아래 「막히면」의 화면 캡처로 간다.
+Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일 뿐이다).
+
---
## 막히면
@@ -214,8 +298,23 @@ PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
```
-`localhost login:` 이면 cloud-init 미실행, `kc-lab-1 login:` 이면 실행된 것이다.
-이 한 장이 「SSH 가 안 되는 이유」를 절반으로 줄인다.
+**어디를 봐야 하는가** — 이미지를 열어 **로그인 프롬프트 앞의 호스트명 한
+낱말**만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
+
+**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다.
+시드를 못 찾은 것(4번의 `bus=virtio`)이거나 YAML 파싱 실패(2번)이므로 SSH
+쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 **그 안의 사용자·키
+단계에서 틀린** 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.
+
+```bash
+virsh console kc-lab-1 # 빠져나오려면 Ctrl+]
+# 게스트 안에서
+sudo cloud-init status --long
+sudo journalctl -u cloud-init -n 50
+```
+
+콘솔 로그인에 쓸 비밀번호가 2번의 `plain_text_passwd` 다. **이 한 장과 이 두
+줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.**
---
diff --git a/docs/guides/02-k3s/README.md b/docs/guides/02-k3s/README.md
index 1f75df7..1cb2d69 100644
--- a/docs/guides/02-k3s/README.md
+++ b/docs/guides/02-k3s/README.md
@@ -21,10 +21,25 @@ curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
-**확인**
+**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
```bash
-sudo kubectl get nodes
sudo systemctl is-active k3s
+sudo kubectl get nodes
+```
+
+**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` 에
+`kc-lab-1` 한 줄이 `Ready` 로 있는가. **설치 직후 30초 남짓은 `NotReady`
+이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온
+시간이다. 한 번 더 친다.
+
+**이 결과가 의미하는 것** — `active` + `Ready` 면 API 서버가 살아 있고
+kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데
+`get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이
+`activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니
+로그를 본다.
+
+```bash
+sudo journalctl -u k3s -n 50 --no-pager
```
## 2. 토큰을 꺼낸다
@@ -35,7 +50,19 @@ TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
```
-**실측** — 이 실험대에서는 56자였다. 0 이면 server 가 아직 안 떴거나 경로가 다르다.
+**실측** — 이 실험대에서는 56자였다.
+
+**어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지
+않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.
+
+**이 결과가 의미하는 것** — 두 자리 수가 나오면 토큰을 손에 쥔 것이니 3번으로
+넘어간다. `0` 이면 변수가 비었다는
+뜻이고 원인은 둘 중 하나다: server 가 아직 안 떠서 파일이 없거나, `sudo` 가
+비대화식 SSH 에서 비밀번호를 물어 실패했거나. 어느 쪽인지는 파일부터 본다.
+
+```bash
+ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'
+```
## 3. agent 를 붙인다 (kc-lab-2)
@@ -56,7 +83,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"
> ```
-**확인** — server 쪽에서
+**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
```bash
sudo kubectl get nodes -o wide
```
@@ -67,7 +94,20 @@ kc-lab-1 Ready control-plane v1.36.4+k3s1 192.168.122.11
kc-lab-2 Ready v1.36.4+k3s1 192.168.122.12
```
-`` 은 오류가 아니라 **역할 라벨이 없다**는 뜻이다. agent 는 원래 그렇다.
+**어디를 봐야 하는가** — `-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP
+두 개가 1번·3번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄
+`Ready`, 그다음이 ROLES 열이다. `` 은 오류가 아니라 **역할 라벨이
+없다**는 뜻이다 — agent 는 원래 그렇다.
+
+**이 결과가 의미하는 것** — 두 줄이 `Ready` 이고 IP 가 맞으면 이 단계는 끝났다.
+IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 잡혀 있으면
+지금은 아무 증상이 없다가 **03 의 nginx upstream 과 A층의 노드 상실
+실험에서** 어긋난다 — 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2` 가
+아예 안 보이면 join 이 실패한 것이니 agent 쪽 로그를 본다.
+
+```bash
+ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
+```
## 4. 유닛 이름이 다르다
@@ -76,7 +116,7 @@ kc-lab-2 Ready v1.36.4+k3s1 192.168.122.12
| server | `k3s.service` |
| agent | `k3s-agent.service` |
-**확인**
+**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
```bash
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
@@ -88,6 +128,17 @@ ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
```
+**어디를 봐야 하는가** — `ExecStart=` 줄의 **부분명령(`server`/`agent`)과
+그 뒤의 인자**. 설치 스크립트에 준 옵션이 여기 그대로 굳어 있다. 유닛 이름을
+틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데,
+그것 자체가 「이 노드는 agent 다」라는 답이다.
+
+**이 결과가 의미하는 것** — 3번의 `get nodes -o wide` 는 **k3s 가 보고한**
+IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다.
+그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을
+여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도
+일어나지 않고, 「주입했는데 증상이 없다」로 오독하게 된다.
+
> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가
> 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
@@ -103,12 +154,23 @@ ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
| flannel | 파드 네트워크 (VXLAN) |
| kube-router | NetworkPolicy 집행 |
-**확인**
+**확인** — 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
```bash
sudo kubectl get pods -A
sudo kubectl get storageclass
```
+**어디를 봐야 하는가** — `get pods -A` 에서는 **NAMESPACE 열이 `kube-system`
+인 줄들의 STATUS**. `Running` 과 `Completed` 가 섞여 있는 것이 정상이다 —
+`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
+`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
+
+**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고,
+뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의
+답이 전부 이 목록에 있다. `local-path` 에 `(default)` 가 붙어 있으면
+05 의 PVC 는 StorageClass 를 안 적어도 이것으로 만들어진다. `Pending` 이나
+`CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
+
> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진
> 노드에 묶여** 다른 노드로 재배치되지 않는다.
@@ -124,6 +186,24 @@ export KUBECONFIG=~/.kube/kc-lab.yaml
kubeconfig 안의 서버 주소가 `127.0.0.1` 이다. **게스트 안에서만 맞는 주소**라
밖에서 쓰려면 바꿔야 한다.
+**확인** — 밖에서 붙는가
+```bash
+grep server: ~/.kube/kc-lab.yaml
+kubectl get nodes
+```
+
+**어디를 봐야 하는가** — 첫 줄의 `server:` 값이 `https://192.168.122.11:6443`
+인가(`127.0.0.1` 이 남아 있으면 `sed` 가 안 먹은 것이다). 그다음 `get nodes`
+가 3번과 **같은 두 줄**을 내놓는가. 이제부터는 `sudo` 도 `ssh` 도 붙이지
+않는다.
+
+**이 결과가 의미하는 것** — 통과하면 워크스테이션에서 곧장 클러스터를 볼 수
+있고, 05 이후의 `kubectl` 은 전부 여기서 친다. `connection refused` 면 주소는
+맞는데 API 서버가 안 뜬 것이고, 타임아웃이면 워크스테이션에서
+`192.168.122.0/24` 로 가는 경로가 없는 것이다(호스트를 거치는 SSH 터널이나
+ProxyJump 가 필요하다). `x509` 오류면 파일 안의 CA 와 서버가 지금 쓰는 CA 가
+어긋난 것이다 — k3s 를 다시 깔았다면 kubeconfig 도 다시 복사해야 한다.
+
---
## 막히면
diff --git a/docs/guides/03-nginx/README.md b/docs/guides/03-nginx/README.md
index f2fbbbc..2bb3c17 100644
--- a/docs/guides/03-nginx/README.md
+++ b/docs/guides/03-nginx/README.md
@@ -85,25 +85,107 @@ sudo ln -s /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
-**`&&` 가 중요하다.** 설정이 깨진 상태에서 reload 하면 nginx 가 새 워커를
-띄우지 못한다. `-t` 를 먼저 통과시키고 그때만 reload 한다.
+**어디를 봐야 하는가** — `nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is
+ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는
+`[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이
+경고를 실패로 오독하는 일이 실제로 벌어지므로, 여기서 **경고와 오류를
+구분하는 눈**을 들여 둔다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍힌다.
+
+**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고,
+`systemctl reload` 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면
+`&&` 가 reload 를 **막아 준 것**이고, 지금 돌고 있는 nginx 는 옛 설정 그대로
+멀쩡하다. 설정이 깨진 상태에서 reload 하면 새 워커를 못 띄운다 — `-t` 를
+먼저 통과시키고 그때만 reload 하는 이유다.
+
+reload 가 정말 반영됐는지는 워커가 갈렸는지로 본다. 같은 판정 방법을
+04 에서 인증서 갱신에 그대로 쓴다.
+
+```bash
+systemctl status nginx --no-pager | head -20
+```
+
+**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 그
+아래 프로세스 트리에 `nginx: master process` 하나와 `nginx: worker process`
+여럿이 붙어 있는가. 워커 줄의 **PID** 를 눈에 담아 둔다.
+
+**이 결과가 의미하는 것** — reload 는 마스터를 그대로 두고 **워커만** 갈아
+끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된
+것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다.
+04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다.
## 3. 층별로 확인한다 — 아래에서 위로
한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다.
+> **`curl` 을 두 형태로 쓴다.** 처음 볼 때는 `-I`(헤더까지 읽는 형태)로
+> **응답을 눈으로 읽고**, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할
+> 때만 `-w '%{http_code}'`(값만 뽑는 형태)로 바꾼다. 값만 뽑는 형태는
+> 골라 놓은 한 칸 말고는 전부 버리므로, **무엇이 잘못됐는지 모르는 상태**
+> 에서는 쓸 것이 못 된다.
+
**확인 ①** Traefik 이 듣고 있나 (nginx 를 건너뛴다)
```bash
-curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
+curl -I http://192.168.122.11
```
+
+**형태** (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)
+```
+HTTP/1.1 404 Not Found
+...
+```
+
+**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나. 그리고 그 앞에 **아무 오류도
+없이** 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가
+없는 것이라 상태 코드를 볼 일도 없다.
+
+**이 결과가 의미하는 것** — **`404` 가 성공 신호다.** 게스트의 80 을 누가
+듣고 있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고
+답한 것이다. 이 층은 통과. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는
+것이고, 연결 거부·타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라
+**02 로 돌아간다.**
+
+두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로
+비교하는 것이 목적이기 때문이다.
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
+curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
+```
+
+**실측** — `.11` 에서 잰 값이다.
```
404
```
-**`404` 가 성공 신호다.** Traefik 까지 닿았는데 매칭되는 Ingress 규칙이 없다는
-뜻이다. `502` 나 연결 거부면 그 아래에서 끊긴 것이다.
+**어디를 봐야 하는가** — 두 줄이 **같은 코드**인가.
+
+**이 결과가 의미하는 것** — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은
+결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그
+상태에서는 **요청의 절반만 실패**해서 「가끔 안 된다」로 보인다.
**확인 ②** nginx 가 80 에서 리다이렉트하나
+```bash
+curl -I http://auth.hyeonworks.com
+```
+
+**형태** (봐야 할 두 줄만)
+```
+HTTP/1.1 301 Moved Permanently
+Location: https://auth.hyeonworks.com/
+...
+```
+
+**어디를 봐야 하는가** — 상태 줄과 **`Location:` 헤더 한 줄**. `Location` 이
+`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신
+설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
+
+**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 호스트 nginx
+까지 닿았다**는 뜻이다(①은 게스트에 직접 친 것이므로 nginx 를 안 거쳤다).
+DNS·방화벽·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나
+80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다.
+
+값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다.
+
```bash
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
```
@@ -119,6 +201,16 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast
200
```
+**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는
+이유는, 이 200 이 **04·05 에서 매번 같은 명령으로 다시 잴 기준값**이기
+때문이다. 처음 한 번은 `curl -I https://auth.hyeonworks.com/realms/master`
+로 헤더까지 보고, 그다음부터 이 형태로 줄인다.
+
+**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다
+이어졌다. `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(4번의 로그를
+본다), `curl: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서
+막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
+
## 4. upstream 이 둘인 이유
```
@@ -159,6 +251,16 @@ journalctl -u nginx -p err -n 5 # 최근 에러만
journalctl -u nginx -f # 지금 벌어지는 것
```
+**어디를 봐야 하는가** — 각 줄의 **괄호 안 errno**(`113`·`111`)와 그 뒤의
+`upstream: "http://192.168.122.1x:80/..."` 부분. 어느 upstream 이 문제인지가
+거기 적혀 있다. 그리고 **타임스탬프** — 방금 친 요청 시각과 안 맞으면 지금
+보고 있는 것은 옛 사고다.
+
+**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는
+정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 **띄워 놓은
+채로 다른 창에서 요청을 치는** 용도다 — 요청과 로그 줄을 눈으로 짝지으면
+「이 요청이 어느 upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.
+
**★ nginx 에러 로그는 2048바이트에서 잘린다.** 긴 URL 이 끝에서 단어 중간에
끊겨 보이면 그것이다. 되찾으려면 **access 로그를 본다** — 거기엔 제한이 없다.
@@ -166,5 +268,15 @@ journalctl -u nginx -f # 지금 벌어지는 것
grep oauth2/callback /var/log/nginx/access.log | tail -1
```
-이 실험대에서 B-7 의 502 원인이 error 로그에 있었는데 잘려 있었고, access
-로그에는 3492자로 온전히 남아 있었다.
+**어디를 봐야 하는가** — 그 한 줄의 **끝**이다. 요청 URL 이 `"` 로 제대로
+닫혀 있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘린 것이다. 길이가
+궁금하면 세어 본다.
+
+```bash
+grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
+```
+
+**이 결과가 의미하는 것** — error 로그와 access 로그의 같은 요청 길이가 다르면
+**error 쪽이 잘린 것**이지 요청이 잘린 것이 아니다. 이 실험대에서 B-7 의
+502 원인이 error 로그에 있었는데 잘려 있었고, access 로그에는 3492자로 온전히
+남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
diff --git a/docs/guides/04-tls/README.md b/docs/guides/04-tls/README.md
index 502bd8c..479b80d 100644
--- a/docs/guides/04-tls/README.md
+++ b/docs/guides/04-tls/README.md
@@ -30,11 +30,23 @@ sudo certbot certonly --webroot -w /var/www/html \
-d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com
```
-**확인**
+**확인** — 인증서가 실제로 생겼고 이름 셋이 다 들어갔는가
```bash
sudo certbot certificates
```
+**어디를 봐야 하는가** — 네 줄이다. `Domains:` 에 **이름 세 개가 한 줄에**
+나열되어 있는가(줄이 두 개로 갈라져 있으면 인증서가 두 장 만들어진 것이다),
+`Expiry Date:` 가 오늘로부터 90일 뒤이고 `VALID` 인가, `Certificate Path:` 가
+`/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem` 인가, 그리고
+`Private Key Path:` 가 그 옆의 `privkey.pem` 인가. **경로 두 개가 03 의
+nginx 설정에 적은 것과 한 글자도 다르면 안 된다.**
+
+**이 결과가 의미하는 것** — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
+발급은 성공했는데 `No certificates found` 가 나오면 `sudo` 없이 쳐서 다른
+사용자의 설정 디렉터리를 본 것이다. 발급 자체가 실패했다면 검증 단계에서
+막힌 것이므로 DNS 와 80 포트부터 본다(아래 「막히면」).
+
> **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.**
> B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를
> 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를
@@ -60,7 +72,37 @@ sudo nginx -t && sudo systemctl reload nginx
## 4. 확인 — 열리는가, 체인이 완전한가
-**확인 ①** 열리나
+**확인 ①** 열리나 — **처음 한 번은 협상 과정을 읽는다**
+```bash
+curl -v https://auth.hyeonworks.com/realms/master -o /dev/null
+```
+
+**형태** (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
+```
+* SSL connection using TLSv1.3 / ...
+* subject: CN=auth.hyeonworks.com
+* issuer: C=US; O=Let's Encrypt; CN=...
+* SSL certificate verify ok.
+> GET /realms/master HTTP/1.1
+< HTTP/1.1 200 OK
+```
+
+**어디를 봐야 하는가** — `*` 로 시작하는 줄 넷이다. 어떤 TLS 판으로
+협상했는가, `subject` 의 CN 이 지금 친 이름과 같은가, `issuer` 가 Let's
+Encrypt 인가, 그리고 **`SSL certificate verify ok.`** 가 있는가. 그 아래
+`<` 로 시작하는 첫 줄이 응답 상태다. `-o /dev/null` 은 본문만 버리는 것이라
+이 줄들은 그대로 남는다.
+
+**이 결과가 의미하는 것** — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
+클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
+있으므로, **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
+본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면
+중간 인증서가 빠진 것이고, 그 원인은 3번의 `cert.pem`/`fullchain.pem`
+이다 — 확인 ②로 간다.
+
+같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는
+형태로 줄인다.
+
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
@@ -88,7 +130,19 @@ echo | openssl s_client -connect auth.hyeonworks.com:443 \
Verify return code: 0 (ok)
```
-**단계가 1개면 `cert.pem` 을 쓴 것이다.**
+**어디를 봐야 하는가** — 왼쪽의 **번호(0·1·2·3)가 몇까지 가는가**, 그리고
+각 단계의 `i:`(발급자)가 **바로 다음 단계의 `s:`(주체)와 같은가**. 0번이
+우리 서버 인증서이고, 위 실측에서 0의 `i:` 가 `CN = YE2` 인데 1의 `s:` 가
+같은 `CN = YE2` 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
+`Verify return code: 0 (ok)`.
+
+**이 결과가 의미하는 것** — **단계가 1개면 `cert.pem` 을 쓴 것이다.**
+서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
+대개 캐시나 AIA 로 보완해서 **정상으로 보이므로**, 이 명령이 유일하게
+믿을 수 있는 판정이다. 고치는 곳은 03 의 `ssl_certificate` 한 줄이고,
+고친 뒤 `nginx -t && systemctl reload nginx` 하고 여기서 다시 잰다.
+`Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 —
+`10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
**확인 ③** 이름 세 개가 한 인증서인가
```bash
@@ -98,7 +152,18 @@ for H in auth app1 app2; do
done
```
-일련번호가 셋 다 같으면 SAN 하나에 들어 있는 것이다.
+**어디를 봐야 하는가** — 찍히는 세 줄의 **일련번호가 서로 같은가**. 값 자체는
+아무 의미가 없고 **셋이 일치하는지만** 본다.
+
+**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는
+인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라
+**갱신 훅도 장마다 따로 돌고**, 한 장만 갱신됐을 때 나머지 이름이 만료되는
+상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
+
+```bash
+echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
+ | openssl x509 -noout -ext subjectAltName
+```
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
@@ -109,7 +174,15 @@ done
systemctl list-timers certbot-renew.timer
```
-이것이 `active` 여도 갱신된 인증서가 **서빙되지는 않는다.** nginx 는 인증서를
+**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은
+시간)가 채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의
+`ACTIVATES` 가 `certbot-renew.service` 를 가리키는가. **표가 통째로 비어
+나오면 타이머가 없는 것이다** — 이름이 배포판마다 다르니
+`systemctl list-timers --all | grep -i certbot` 로 찾는다.
+
+**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
+대부분의 문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가
+서빙되지는 않는다.** nginx 는 인증서를
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
@@ -125,7 +198,16 @@ ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
-`ExecStartPost` 도 `--deploy-hook` 도 없다.
+**어디를 봐야 하는가** — `ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=` 가
+**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어
+있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
+
+**이 결과가 의미하는 것** — `ExecStartPost` 도 `--deploy-hook` 도 없다.
+즉 이 유닛은 **인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가
+읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
+「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
+배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
+확인하면 된다.
**하기** — 훅 하나를 넣는다
```bash
@@ -140,6 +222,23 @@ sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다.
**확인** — 실제로 도는지
+
+이 확인은 **상태를 바꾼다.** `--force-renewal` 은 인증서를 실제로 새로
+받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 `--dry-run` 으로 훅이
+호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
+
+```bash
+sudo certbot renew --dry-run
+```
+
+**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
+`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌
+곳에 있거나 실행 권한이 없는 것이다(`ls -l` 로 `x` 를 본다).
+
+**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다.
+호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 —
+그래서 아래를 한 번 한다.
+
```bash
# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
@@ -150,6 +249,15 @@ sudo certbot renew --force-renewal
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
```
+**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
+시작 시각)**. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를
+같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터
+프로세스는 그대로이고 **워커만** 갈리는 것이 정상이다.
+
+**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를
+읽었다. 안 바뀌었으면 인증서는 갱신됐는데 **서빙되는 것은 옛것**이고,
+이 상태가 아래 표의 왼쪽 칸이다.
+
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** —
nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
@@ -188,7 +296,7 @@ nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
| 증상 | 원인 | 확인 |
|---|---|---|
-| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short ` · 밖에서 `curl http://` |
+| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② |
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
@@ -213,15 +321,39 @@ done > /tmp/control.txt
awk '{print $1}' /tmp/control.txt | sort | uniq -c
```
-이 실험대의 대조군은 **900건 전부 200, 오류 0** 이었다. 그래서 갱신 중 비200 이
-한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다.
+**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고,
+헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
-**그리고 시각을 비교할 때 시계를 확인한다.** 이 실험대는 test-server 가 NTP
-미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초
-먼저 실행된 것이 되어 물리적으로 불가능했다.
+**어디를 봐야 하는가** — `uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면
+900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
+두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
+둘째 열을 따로 본다.
+
+```bash
+awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
+```
+
+**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
+이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
+있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도
+증명하지 못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
+
+**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
```bash
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
+timedatectl show -p NTP -p NTPSynchronized
```
+
+**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
+몇인가**. `A` 와 `C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
+가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의
+시각이 둘 중 어느 쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
+
+**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로
+나란히 놓아도 된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다.
+이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫
+계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로
+불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
diff --git a/docs/guides/05-keycloak/README.md b/docs/guides/05-keycloak/README.md
index 1fcf634..7eaba45 100644
--- a/docs/guides/05-keycloak/README.md
+++ b/docs/guides/05-keycloak/README.md
@@ -28,8 +28,16 @@ kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
partitioned roll out complete: 2 new pods have been updated...
```
-`rollout status` 는 **끝날 때까지 블록**한다. `get pods` 를 반복해서 보는 것보다
-이게 낫다.
+**어디를 봐야 하는가** — 이 명령은 **끝날 때까지 아무것도 안 찍고 멈춰 있다.**
+그 침묵이 정상이다. 마지막에 나오는 한 줄에서 `complete` 라는 낱말과 파드
+개수 `2` 를 본다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 —
+「안 떴다」가 확정된 것이니 3번으로 간다.
+
+**이 결과가 의미하는 것** — `complete` 면 두 파드가 다 Ready 가 됐다는 뜻이라
+2번의 층별 확인으로 넘어간다. `rollout status` 를 쓰는 이유는 `get pods` 를
+반복해서 치는 것보다 나아서만이 아니라, **언제 끝났는지를 사람이 판정하지
+않아도 되기** 때문이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로
+중간에 `0/2` 로 한참 멈춰 있는 것은 정상이다.
---
@@ -39,23 +47,38 @@ partitioned roll out complete: 2 new pods have been updated...
### 2-1. 무엇이 만들어졌나
-**확인**
+**확인** — 이 네임스페이스에 무엇이 서 있는가
```bash
kubectl -n keycloak-lab get all
```
-`all` 은 이름과 달리 전부는 아니다 — Secret·ConfigMap·PVC·Ingress 는 안 나온다.
+**어디를 봐야 하는가** — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드
+줄에서는 **READY 칸의 `1/1`** 과 **RESTARTS 칸**을 본다. RESTARTS 가 0 이
+아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이라, 3번의
+`logs --previous` 를 볼 이유가 된다.
+
+**이 결과가 의미하는 것** — 여기 보이는 것이 워크로드의 전부다. 그런데
+`all` 은 이름과 달리 전부는 아니다 — **Secret·ConfigMap·PVC·Ingress 는 안
+나온다.** 이 넷이 빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이
+흔한 오독이라, 한 번 더 친다.
```bash
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
```
+**어디를 봐야 하는가** — 네 종류가 **하나씩이라도 있는가**. PVC 줄의
+STATUS 는 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
+
+**이 결과가 의미하는 것** — 매니페스트에 있는데 여기 없는 종류가 있다면
+`apply` 가 부분적으로만 먹은 것이다. Ingress 의 호스트 이름이 04 에서 발급한
+인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 404 가 나온다.
+
### 2-2. Deployment → ReplicaSet → Pod 사슬
Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를
만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발점이다.
-**확인**
+**확인** — 사슬 어디까지 갔는가
```bash
kubectl -n keycloak-lab get deploy,rs,pod -l app=bff
```
@@ -69,11 +92,15 @@ replicaset.apps/bff-576d869c6d 0 0
pod/bff-555df79c97-6j86w 1/1 Running
```
-**ReplicaSet 이 여러 개인 것은 정상이다.** 배포할 때마다 새로 만들고 옛것은
-`0` 으로 남긴다 — 그래서 `kubectl rollout undo` 가 가능하다. 파드 이름의
-가운데 해시(`555df79c97`)가 어느 ReplicaSet 소속인지 말해 준다.
+**어디를 봐야 하는가** — ReplicaSet 줄들 중 **DESIRED 가 0 이 아닌 것 하나**
+를 찾고, 그 이름의 해시를 파드 이름 가운데 해시와 맞춰 본다. 위 실측에서는
+`555df79c97` 이 그 짝이다. 나머지 `0 0` 줄들은 읽지 않아도 된다.
-읽는 법:
+**이 결과가 의미하는 것** — **ReplicaSet 이 여러 개인 것은 정상이다.**
+배포할 때마다 새로 만들고 옛것은 `0` 으로 남긴다 — 그래서
+`kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가
+아직 안 넘어온 것이고, 그 상태로 실험하면 **고친 적 없는 코드를 재게 된다.**
+사슬이 어디서 끊겼는지는 이렇게 읽는다.
| 보이는 것 | 뜻 |
|---|---|
@@ -89,17 +116,39 @@ pod/bff-555df79c97-6j86w 1/1 Running
값이 있는 것과 파드가 그 값을 받은 것은 다르다.
-**확인 ①** Secret 에 키가 있나 — **값은 찍지 않는다**
+**확인 ①** Secret 에 무슨 키가, 얼마만큼 들어 있나 — **값은 찍지 않는다**
```bash
-kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
- | tr ',' '\n' | grep -o '"[A-Z_]*"' | tr -d '"'
-```
-```
-KC_BOOTSTRAP_ADMIN_PASSWORD
-POSTGRES_PASSWORD
+kubectl -n keycloak-lab describe secret keycloak-lab-secrets
```
-**확인 ②** 값이 비어 있지 않나 — **길이만**
+**실측** — 아래쪽 `Data` 절만 옮긴 것이다
+```
+Data
+====
+KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes
+POSTGRES_PASSWORD: 22 bytes
+```
+
+**어디를 봐야 하는가** — `Data` 절의 **키 이름과 그 옆의 바이트 수** 두 칸.
+`describe` 는 값을 절대 찍지 않고 길이만 보여 준다 — 그래서 키 목록 확인과
+「비어 있지 않은가」 확인이 **한 명령으로 끝난다.** `0 bytes` 인 키가 있으면
+그 자리가 비어 있는 것이다.
+
+**이 결과가 의미하는 것** — 매니페스트가 기대하는 키 이름이 여기 그대로
+있어야 한다. 이름이 하나라도 다르면 파드는 `CreateContainerConfigError` 로
+멈추고, 이유는 `describe pod` 의 Events 에 키 이름까지 적혀 나온다.
+바이트 수가 뜻밖에 크면(예: 20 이어야 할 것이 21) **`echo` 로 만들면서 개행이
+같이 들어간** 경우다 — 흔한 사고이고, 증상은 「비밀번호가 틀렸다」로 나온다.
+
+> **`-o yaml` 로 보지 않는다.** base64 는 암호화가 아니라 인코딩이라
+> 화면·스크롤백·화면 공유·터미널 로그에 값이 그대로 남는다.
+> D-3 이 잰 것이 이것이다 — [`experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
+
+**확인 ②** 특정 키 하나를 따져 볼 때 — **길이만**
+
+`describe` 가 보여 주는 바이트 수는 base64 를 푼 뒤의 길이다. 어떤 키
+하나가 의심스러워 다시 잴 때만 이 형태를 쓴다.
+
```bash
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
@@ -108,6 +157,14 @@ kubectl -n keycloak-lab get secret keycloak-lab-secrets \
22
```
+**어디를 봐야 하는가** — 숫자 하나. 그리고 그것이 확인 ①의 `22 bytes` 와
+같은가.
+
+**이 결과가 의미하는 것** — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다.
+`base64: invalid input` 이 나오면 키 이름을 잘못 쓴 것이다(없는 키는 빈
+문자열로 나온다). 여기까지는 **Secret 안에 무엇이 있나**이고, 파드가 그것을
+받았는지는 아직 모른다.
+
**확인 ③** 파드 안에 주입됐나 — 여기가 진짜다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- \
@@ -117,6 +174,15 @@ kubectl -n keycloak-lab exec keycloak-0 -- \
길이=19
```
+**어디를 봐야 하는가** — 숫자 하나. `${#VAR}` 는 값이 아니라 **글자 수**만
+내놓는다. 이것이 확인 ①의 `19 bytes` 와 같은가.
+
+**이 결과가 의미하는 것** — 같으면 Secret → 파드 환경변수까지 이어졌다.
+`길이=0` 이면 Secret 에는 있는데 **이 파드가 그것을 안 받은** 것이다 —
+`envFrom`/`valueFrom` 을 빠뜨렸거나, 파드가 Secret 을 고치기 **전에** 떠서
+옛 값을 들고 있는 경우다(환경변수로 주입한 Secret 은 값을 바꿔도 파드를
+다시 만들기 전까지 갱신되지 않는다).
+
**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다.
```bash
kubectl -n keycloak-lab get pod keycloak-0 \
@@ -129,9 +195,13 @@ KC_DB_USERNAME
KC_DB_PASSWORD keycloak-lab-secrets ← Secret 에서 온 것만 오른쪽에 이름이 있다
```
-> **값을 그대로 찍지 않는 습관.** `-o yaml` 은 base64 를 그대로 보여 주고
-> 그건 암호화가 아니다. 터미널 스크롤백·화면 공유·로그에 남는다.
-> D-3 이 잰 것이 이것이다 — [`experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
+**어디를 봐야 하는가** — **오른쪽 칸이 채워진 줄만**. 왼쪽만 있는 줄은
+매니페스트에 값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을
+참조하는 것이다.
+
+**이 결과가 의미하는 것** — 비밀이어야 할 변수의 오른쪽이 비어 있으면
+**그 값은 매니페스트에 평문으로 적혀 있다는 뜻**이고, 그 파일은 대개 git 에
+들어간다. 여기서는 `KC_DB_PASSWORD` 만 Secret 에서 온다.
### 2-4. Service 가 파드를 잡고 있나 — Endpoints
@@ -146,6 +216,17 @@ kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
```
+**어디를 봐야 하는가** — 쉼표로 갈린 **주소가 몇 개인가**, 그리고 그 IP 들이
+`kubectl -n keycloak-lab get pods -o wide` 의 파드 IP 와 같은가. 포트 번호가
+컨테이너가 실제로 듣는 포트인가도 함께 본다.
+
+**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다.
+**비어 있으면** Service 는 있는데 뒤가 없는 것이고, 이때 증상은
+「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것이 잘 안 보인다.
+하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로
+A층 실험을 하면 **이미 한쪽으로만 가고 있던 트래픽**을 이중화 실패로
+오독하게 된다.
+
목록으로 보려면 **EndpointSlice** 를 쓴다.
```bash
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
@@ -172,8 +253,14 @@ kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
10.42.1.155 true
```
-`ready` 가 `false` 면 파드는 있는데 **readiness 프로브를 통과하지 못한** 것이라,
-Service 가 그 파드로 트래픽을 보내지 않는다.
+**어디를 봐야 하는가** — 오른쪽 칸이 두 줄 다 `true` 인가. 여기서만 값을
+뽑는 형태를 쓰는 이유는, 이 두 칸이 **A층 실험 전후로 반복해서 비교할
+값**이기 때문이다. 처음 볼 때는 위의 `describe svc` 로 충분하다.
+
+**이 결과가 의미하는 것** — `ready` 가 `false` 면 파드는 있는데 **readiness
+프로브를 통과하지 못한** 것이라, Service 가 그 파드로 트래픽을 보내지 않는다.
+파드 목록에서는 `Running` 으로 보이므로 `get pods` 만 봐서는 알 수 없다 —
+`0/1` 인지 `1/1` 인지가 같은 사실을 말해 준다.
**비어 있으면** 셀렉터와 파드 라벨이 안 맞는 것이다.
```bash
@@ -183,14 +270,28 @@ kubectl -n keycloak-lab get pods --show-labels
### 2-5. PVC 가 실제로 붙었나
+**확인** — 볼륨이 실제로 잡혔는가
```bash
kubectl -n keycloak-lab get pvc
```
-`Pending` 이면 StorageClass 가 없거나 노드에 자리가 없다. **`local-path` 는
-파드가 스케줄될 때까지 기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면
-PVC 도 `Pending` 인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만
-파드 쪽 원인을 먼저 본다.
+**어디를 봐야 하는가** — STATUS 칸(`Bound`/`Pending`)과 VOLUME 칸(비어 있는지),
+그리고 STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에
+파드 번호가 붙어 있어(`...-keycloak-0`) 어느 파드 것인지 바로 보인다.
+
+**이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면
+StorageClass 가 없거나 노드에 자리가 없다. **`local-path` 는 파드가 스케줄될
+때까지 기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 `Pending`
+인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저
+본다. 사유는 PVC 의 이벤트에 적혀 있다.
+
+```bash
+kubectl -n keycloak-lab describe pvc # 이름을 안 주면 전부 나온다
+```
+
+각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지
+`no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 —
+앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.
---
@@ -201,19 +302,47 @@ PVC 도 `Pending` 인 것이 정상이다. 둘이 서로를 기다리는 것처
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
+**어디를 봐야 하는가** — 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 이유가
+그것이다. TYPE 이 `Warning` 인 줄, REASON 칸(`FailedScheduling`·`Failed`·
+`BackOff`), 그리고 OBJECT 칸이 어느 파드인가. **이벤트는 기본 한 시간만
+남는다** — 아무것도 없으면 「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.
+
+**이 결과가 의미하는 것** — REASON 하나가 다음 행동을 정한다.
+`FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를
+봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없다.
+`BackOff` 는 컨테이너가 떴다가 죽는 중이라는 뜻이라 ③으로 간다.
+
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
```bash
kubectl -n keycloak-lab describe pod keycloak-0
```
+**어디를 봐야 하는가** — 위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가
+`False` 인가, 컨테이너 절의 `State`/`Last State` 와 그 안의 **`Exit Code`**,
+그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은
+OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을
+못 찾은 것이다.
+
+**이 결과가 의미하는 것** — Exit Code 가 `137` 이면 로그에는 아무 단서도
+없을 수 있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다.
+`Ready` 만 `False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로
+2-4 로 돌아간다.
+
**③ 로그.** 컨테이너가 떴는데 죽는 경우다.
```bash
kubectl -n keycloak-lab logs keycloak-0
kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그
```
-`--previous` 가 중요하다. CrashLoopBackOff 면 지금 컨테이너는 방금 뜬 것이라
-**죽은 이유는 이전 컨테이너 로그에 있다.**
+**어디를 봐야 하는가** — 첫 명령에서는 **마지막 줄들**, 둘째 명령에서는
+스택 트레이스의 **맨 윗줄**(가장 안쪽 예외가 아니라 최초 원인 줄)이다.
+Keycloak 은 기동에 성공하면 `Keycloak ... started in` 한 줄을 남기므로,
+그 줄이 있는지 없는지가 「기동 중」과 「기동 실패」를 가른다.
+
+**이 결과가 의미하는 것** — `--previous` 가 중요하다. CrashLoopBackOff 면
+지금 컨테이너는 방금 뜬 것이라 **죽은 이유는 이전 컨테이너 로그에 있다.**
+`--previous` 가 `not found` 를 내면 아직 한 번도 재시작하지 않은 것이고,
+그러면 지금 로그가 곧 전부다.
**④ 그래도 모르면 안에서 본다.**
```bash
@@ -235,7 +364,16 @@ ISPN000094: Received new cluster view for channel ISPN:
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
```
-`(2)` 가 멤버 수다.
+**어디를 봐야 하는가** — 세 군데다. 괄호 안의 **`(2)` 가 멤버 수**,
+대괄호 안의 **이름 목록**, 그리고 `|1` 이 **뷰 번호**(멤버가 들고 날 때마다
+올라간다). `tail -1` 을 붙였으므로 지금 보고 있는 것은 **가장 최근 뷰** 하나다.
+
+**이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자
+있다고 알고 있는 것이다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다
+떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다.
+**이 줄은 「그때 그렇게 보였다」는 과거형이다** — 지금 상태는 확인 ③에서 본다.
+`grep` 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못 한 것이니 로그를
+통째로 본다.
**확인 ②** 디스커버리 테이블
```bash
@@ -243,6 +381,14 @@ kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
```
+**어디를 봐야 하는가** — 나온 **행이 몇 개인가**, 그리고 `ip` 칸이 2-4 에서
+본 파드 IP 와 같은가. 끝의 `(N rows)` 한 줄이 개수를 말해 준다.
+
+**이 결과가 의미하는 것** — 이 표는 「**등록**되어 있다」이지 「서로 말이
+통한다」가 아니다. 두 행이 다 있는데 확인 ①이 `(1)` 이면, 서로를 찾기는
+했는데 7800 포트로 메시지가 안 가는 것이다 — A-1 에서 정확히 그 일이
+벌어졌다. 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
+
**확인 ③** 지표
**★ Keycloak 컨테이너에는 `curl` 이 없다.** 공식 이미지가 최소 구성이라
@@ -260,11 +406,22 @@ kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
+**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**로 온다.
+`data.result` 배열에서 원소가 **몇 개인가**(노드 수), 각 원소에서 두 군데만
+읽는다 — `"metric"` 안의 `node` 라벨(어느 Keycloak 인가)과 `"value"` 배열의
+**둘째 원소**(따옴표에 싸인 값). **이 실험대에는 `jq` 가 없다.** 파서를
+따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.
+
+**실측** — 그렇게 읽어낸 값이다
```
keycloak-1 → 2
keycloak-0 → 2
```
+**이 결과가 의미하는 것** — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를
+보고 있다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라
+**클러스터 문제가 아니라 관측 문제**일 수 있다 — 06 의 targets 를 본다.
+
Prometheus 가 아직 없다면 임시 파드를 띄운다.
```bash
@@ -278,6 +435,15 @@ kubectl -n keycloak-lab run m --rm -i --restart=Never \
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
```
+**어디를 봐야 하는가** — 줄 끝의 **숫자**와, 중괄호 안 `node=` 라벨이 **어느
+파드인가**. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과
+`$K0` 로 고른 파드가 반드시 일치한다.
+
+**이 결과가 의미하는 것** — `--rm` 을 붙였으므로 파드는 끝나면 사라진다.
+`curlimages/curl` 을 쓰는 이유는 **Keycloak 이미지에 도구가 없기** 때문이고,
+같은 이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이
+안 나오고 연결 거부가 나면 9000(관리 포트)이 안 열린 것이다.
+
> **두 값이 다를 수 있다.** 각 노드가 자기가 아는 멤버 수를 보고하므로,
> 분단되면 한쪽은 2 다른 쪽은 1 이 된다. **한 노드만 보면 분단을 놓친다.**
@@ -289,7 +455,7 @@ vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
## 5. 밖에서 닿는지
-**확인**
+**확인** — 2홉을 다 지나 파드까지 닿는가
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
@@ -297,6 +463,19 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast
200
```
+**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
+**03·04 에서 잰 것과 같은 명령으로 같은 값이 나오는지** 비교하는 것이
+목적이기 때문이다. 이 자리에서 처음 보는 것이 아니다.
+
+**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → Ingress → Service →
+파드가 전부 이어졌다. `502`·`503` 이면 뒤에서부터 되짚는다 — Ingress 가
+있는지(2-1), Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다.
+처음 보는 오류라 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
+
+```bash
+curl -I https://auth.hyeonworks.com/realms/master
+```
+
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다.
비밀번호는 위 2-3 의 Secret 에 있다.
@@ -324,7 +503,12 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by 1"
```
-`offline_flag='0'` 이 온라인 세션이다. 로그인하고 이 수가 늘면
-`persistent-user-sessions` 가 켜져 있는 것이고, 안 늘면 메모리에만 있는 것이다.
-그 차이가 A층 결론 전체를 뒤집는다 —
+**어디를 봐야 하는가** — `offline_flag` 가 `0` 인 행의 `count`. **로그인
+전과 후에 두 번 재서 그 수의 차이**를 본다. 한 번만 재면 아무것도 알 수
+없다. 행이 아예 없으면(`0 rows`) 표는 있는데 비어 있는 것이다.
+
+**이 결과가 의미하는 것** — 로그인 뒤 수가 늘면 세션이 DB 에 남는 것
+(`persistent-user-sessions` 켜짐)이고, 안 늘면 메모리에만 있는 것이다.
+그 차이가 A층 결론 전체를 뒤집는다 — 메모리에만 있으면 파드를 재시작하는
+순간 세션이 사라지고, DB 에 있으면 살아남는다.
[A-7](../../experiment-a7-volatile-comparison.md)
diff --git a/docs/guides/06-observability/README.md b/docs/guides/06-observability/README.md
index 70d8375..f8255a4 100644
--- a/docs/guides/06-observability/README.md
+++ b/docs/guides/06-observability/README.md
@@ -25,7 +25,7 @@ kubectl apply -f deploy/lab/k8s/observability.yaml
kubectl -n observability rollout status deploy/prometheus --timeout=180s
```
-**확인**
+**확인** — 무엇이 몇 개 떴는가
```bash
kubectl -n observability get pods
```
@@ -38,11 +38,22 @@ node-exporter-c2mz4 1/1 Running
prometheus-6774f94f7c-pzr2t 1/1 Running
```
-node-exporter 가 **둘**인 것은 DaemonSet 이라 노드마다 하나씩 뜨기 때문이다.
+**어디를 봐야 하는가** — **줄이 네 개인가**, 그리고 READY 칸이 전부 `1/1`
+인가. 특히 `node-exporter` 로 시작하는 줄이 **둘**인지 센다.
+
+**이 결과가 의미하는 것** — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다
+하나씩 뜨기 때문이다. **하나뿐이면 노드 하나가 빠진 것**이고, 그러면 그
+노드의 CPU·메모리·디스크 지표가 통째로 없는 채로 실험을 하게 된다 —
+이때는 관측이 아니라 02 의 노드 상태부터 본다. 어느 노드에 붙었는지는
+`-o wide` 로 확인한다.
+
+```bash
+kubectl -n observability get pods -o wide
+```
## 2. 무엇을 긁고 있나 — 여기가 중요하다
-**확인**
+**확인** — Prometheus 가 스스로 밝히는 대상 목록
```bash
kubectl -n observability exec deploy/prometheus -- \
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
@@ -56,29 +67,61 @@ kubectl -n observability exec deploy/prometheus -- \
"job":"prometheus"
```
-**★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는 그것들을 긁지 않는다.
-그래서 B층 실험 대부분에 Grafana 화면이 없는데, **안 찍은 것이 아니라 지표가
-없는 것**이다.
+**어디를 봐야 하는가** — **거기 있는 이름이 아니라 없는 이름**이다. 응답은
+JSON 한 덩어리이고 그대로는 못 읽는다. **이 실험대에는 `jq` 가 없으므로**
+`grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앤 것이다 — 여기까지가
+사람이 손으로 치는 선이고, 그 이상 가공해야 한다면 파서를 짜지 말고 화면에
+나온 JSON 을 그대로 읽는다.
+
+**이 결과가 의미하는 것** — **★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는
+그것들을 긁지 않는다. 그래서 B층 실험 대부분에 Grafana 화면이 없는데,
+**안 찍은 것이 아니라 지표가 없는 것**이다. 어떤 실험에서 지표를 못 찾으면
+「측정이 실패했다」로 적기 전에 **이 목록에 그 job 이 있었는지부터** 본다.
> 이것을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다.
> [`evidence/followup/04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
+목록에 있는데도 값이 안 나온다면 그다음은 **상태**다. 같은 응답에서
+`health` 만 훑는다.
+
+```bash
+kubectl -n observability exec deploy/prometheus -- \
+ wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
+```
+
+**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과, 그 **바로 뒤에 붙는
+`lastError`**. `tr ',' '\n'` 으로 쉼표마다 줄을 나눴으므로 필드가 원래
+순서대로 세로로 늘어선다 — job 줄 아래에 그 대상의 health 가 온다.
+
+**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 이유를
+그대로 말해 준다(연결 거부·타임아웃·404). 3번에서 값이 한 노드만 나오는
+증상의 원인이 대개 여기 있고, 그때 **클러스터가 아니라 스크레이프가 문제**다.
+
## 3. 클러스터 상태를 본다
-**하기**
+**확인** — 두 노드가 각각 몇 명을 보고 있는가
```bash
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
-**실측**
+**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**이다. `data.result`
+배열의 원소가 **몇 개인가**(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 —
+`"metric"` 안의 `node` 라벨과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인
+값). `jq` 가 없으므로 눈으로 읽는다.
+
+**실측** — 그렇게 읽어낸 값이다
```
keycloak-1 → 2
keycloak-0 → 2
```
-**두 노드가 각각 자기가 아는 멤버 수를 보고한다.** 분단되면 한쪽은 2,
-다른 쪽은 1 이 된다 — **한 노드만 보면 분단을 놓친다.**
+**이 결과가 의미하는 것** — **두 노드가 각각 자기가 아는 멤버 수를 보고한다.**
+둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 —
+**한 노드만 보면 분단을 놓친다.** 원소가 하나뿐이면 분단이 아니라 스크레이프
+실패일 수 있으므로 2번의 `health` 를 먼저 본다. 값이 아예 안 나오면
+`"result":[]` 로 빈 배열이 오는데, 이는 「0 이다」가 아니라 **「그런 지표가
+없다」**는 뜻이다.
자주 보는 지표들이다.
@@ -101,12 +144,26 @@ kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
-경보를 걸 때는 `up == 0` 만으로 부족하고 **기능 지표**를 함께 본다.
+**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값(`"1"`/`"0"`). 값이 1 이라는
+것은 **마지막 스크레이프가 성공했다**는 사실 하나만 말한다.
+
+**이 결과가 의미하는 것** — `up=1` 은 「프로세스가 살아 있고 `/metrics` 가
+응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 `up == 0`
+하나로 걸면 **A-2 같은 「살아 있지만 503」 상태를 통째로 놓친다.**
+기능 지표를 함께 본다 — 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
+**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
+`up` 의 1/0 과 **나란히 놓고 비교하기 위해서**다. 처음 보는 오류를 파고들
+때는 `curl -I` 나 `curl -v` 로 바꾼다(04 참조).
+
+**이 결과가 의미하는 것** — `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧
+「살아 있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것이
+A-2 의 판정 근거였다.
+
## 5. Grafana 를 볼 때
**하기** — 밖에 열지 않고 포트포워드로 본다
@@ -114,7 +171,17 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast
kubectl -n observability port-forward svc/grafana 3000:3000
```
-브라우저에서 `http://localhost:3000`.
+**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이
+찍히고 **명령이 그대로 멈춰 있는가**. 이 명령은 끝나지 않는 것이 정상이라,
+터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection`
+줄이 하나씩 붙는다 — 그것이 붙지 않으면 브라우저가 다른 곳을 보고 있는 것이다.
+
+**이 결과가 의미하는 것** — 이 터널은 **명령을 실행한 기계에서만** 열린다.
+워크스테이션에서 쳤으면 워크스테이션 브라우저로 `http://localhost:3000`,
+lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use`
+면 3000 을 이미 누가 쓰는 것이니 `3001:3000` 처럼 왼쪽만 바꾼다. Ctrl+C 로
+끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 **보는 동안만 뚫는
+것**이라 실험대의 노출면이 늘지 않는다.
> 실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로
> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.