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** 가 편하다. 값을 그대로 > 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.