docs(guides): bring the seven setup guides in line with the practitioner skill

The guides were written before the skill existed and a scored audit found
three gaps. Fixed by a subagent running under the skill, with measured
values, versions, IPs and quoted output declared off limits.

The big one: every step showed a command and its output, and almost none
said which line to look at or what it meant. 64 interpretation pairs added
across the seven files, weighted where the reading is hardest — 20 in the
Keycloak stage, where a Secret existing and a pod having received it are
different facts.

Only the extracting form of curl appeared. Where the reader meets a response
for the first time the guides now open with curl -I or curl -v and name the
lines worth reading; -w '%{http_code}' survives only where the code is a
value being compared — two upstream nodes against each other, or the 900-run
control loop.

Listing Secret keys went from a three-stage pipe to kubectl describe secret,
which prints the key names and their byte counts in one native command
without exposing a value.

And a tool assumption: jq and yamllint are installed on neither the lab host
nor the guests. The guides now say so where JSON is read by eye, rather than
sending the reader to install something mid-diagnosis. cloud-init schema is
on the guests and is now the guest-side check.

Also removes a stray Playwright screenshot committed at the repository root
in 919547a; the evidence copy under docs/evidence/b7a-orphan-session/ is the
one the document references.

Four things the audit left standing are recorded in the agent's report rather
than papered over — notably that 04's reload measurements are stated without
a reproduction procedure, and that 05 and 06 reference each other as
prerequisites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 17:33:31 +09:00
co-authored by Claude Opus 5
parent 88b7bd4bf0
commit 8062cc9a19
8 changed files with 816 additions and 87 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+64 -9
View File
@@ -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
+106 -7
View File
@@ -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
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
```
**어디를 봐야 하는가**`<host>` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
짝이 맞는가**(`:11``.11`, `:12``.12`). MAC 은 4번의 `virt-install
--network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
아니라 동적 대역이라, 예약 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 가 안 되는 이유」를 절반으로 줄인다.**
---
+87 -7
View File
@@ -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 <none> v1.36.4+k3s1 192.168.122.12
```
`<none>` 은 오류가 아니라 **역할 라벨이 없다**는 뜻이다. agent 는 원래 그렇다.
**어디를 봐야 하는가**`-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP
두 개가 1번·3번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄
`Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 **역할 라벨이
없다**는 뜻이다 — 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 <none> 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 도 다시 복사해야 한다.
---
## 막히면
+119 -7
View File
@@ -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자로 온전히
남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
+144 -12
View File
@@ -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 <name>` · 밖에서 `curl http://<name>` |
| 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초 먼저 실행된 것이 되어 물리적으로
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
+217 -33
View File
@@ -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)
+79 -12
View File
@@ -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** 가 편하다. 값을 그대로
> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.