diff --git a/docs/guides/00-lab-host/README.md b/docs/guides/00-lab-host/README.md new file mode 100644 index 0000000..b4ed307 --- /dev/null +++ b/docs/guides/00-lab-host/README.md @@ -0,0 +1,154 @@ +# 00 — lab host 준비 + +## 이 단계가 끝나면 + +`virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다. + +## 전제 + +물리 기계 한 대. 이 실험대는 Arch Linux 를 썼지만 배포판은 상관없다 — +패키지 이름만 다르다. + +--- + +## 1. CPU 가상화가 켜져 있는가 + +BIOS 에서 꺼져 있으면 아무것도 못 한다. 먼저 본다. + +**확인** +```bash +grep -Eo 'vmx|svm' /proc/cpuinfo | head -1 +``` + +`vmx`(Intel) 또는 `svm`(AMD) 이 나오면 된다. 아무것도 안 나오면 BIOS 에서 +Intel VT-x / AMD-V 를 켜야 한다. + +**실측** — 이 실험대의 호스트는 16 코어 전부에서 지원한다. + +## 2. KVM 모듈이 올라와 있는가 + +**확인** +```bash +lsmod | grep kvm +``` + +``` +kvm_intel ... +kvm ... +``` + +두 줄이 나오면 커널이 하드웨어 가상화를 쓸 준비가 됐다. + +> **왜 이걸 먼저 보나.** KVM 없이도 QEMU 는 돌지만 **소프트웨어 에뮬레이션**이 +> 되어 수십 배 느리다. VM 두 대가 「뜨긴 뜨는데 느리다」면 대개 여기다. + +## 3. 패키지 설치 + +**하기** (Arch) +```bash +sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq +``` + +Debian/Ubuntu 면 이름이 다르다. +```bash +sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils +``` + +| 무엇 | 하는 일 | +|---|---| +| qemu | 실제로 가상 기계를 돌리는 것 | +| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) | +| virt-install | VM 을 만드는 명령 | +| dnsmasq | 가상 네트워크의 DHCP·DNS | + +## 4. libvirt 를 띄우고 권한을 받는다 + +**하기** +```bash +sudo systemctl enable --now libvirtd.socket +sudo usermod -aG libvirt "$USER" +``` + +그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로 +`usermod` 만으로는 지금 셸에 반영되지 않는다. + +**확인** +```bash +groups # libvirt 가 보여야 한다 +virsh list --all # sudo 없이 돌아야 한다 +``` + +**실측** +``` +donghyeon libvirt wheel +``` + +> **`libvirtd.service` 가 아니라 `.socket` 을 켠 이유.** 소켓 활성화라서 +> 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄운다. +> 자원을 아끼고, 데몬을 재시작해도 클라이언트가 끊기지 않는다. + +## 5. 연결 URI 를 고정한다 + +`virsh` 는 기본으로 `qemu:///session`(사용자 단위)에 붙는데, VM 은 +`qemu:///system`(시스템 단위)에 만들어야 한다. **이걸 안 맞추면 만든 VM 이 +안 보인다.** + +**하기** — 셸 프로필에 넣는다 +```bash +echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc +``` + +**확인** +```bash +virsh uri +``` +``` +qemu:///system +``` + +## 6. 기본 네트워크 + +**확인** +```bash +virsh net-list --all +``` + +**실측** +``` + Name State Autostart Persistent +-------------------------------------------- + default active yes yes +``` + +`inactive` 면 켠다. +```bash +virsh net-start default +virsh net-autostart default +``` + +이 네트워크가 `virbr0` 브리지와 `192.168.122.0/24` 대역을 만든다. VM 들이 +여기 붙는다. + +--- + +## 막히면 + +| 증상 | 원인 | 확인 | +|---|---|---| +| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃/로그인 했는가. `groups` 에 libvirt 가 있는가 | +| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` | +| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS | +| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 | + +--- + +## 이 단계의 실측값 + +돌고 있는 실험대에서 그대로 읽은 것이다. + +``` +libvirt 12.7.0 +qemu QEMU emulator version 11.1.1 +그룹 donghyeon libvirt wheel +네트워크 default / active / autostart yes +``` diff --git a/docs/guides/01-vms/README.md b/docs/guides/01-vms/README.md new file mode 100644 index 0000000..73c1aea --- /dev/null +++ b/docs/guides/01-vms/README.md @@ -0,0 +1,232 @@ +# 01 — VM 두 대 + +## 이 단계가 끝나면 + +`kc-lab-1`·`kc-lab-2` 두 게스트가 뜨고, 호스트에서 SSH 가 키로 붙는다. + +## 전제 + +[00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다. + +## 왜 VM 두 대인가 + +이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 +모른다」** 를 전제한다. 한 대면 세션 공유도 분단도 노드 상실도 실험이 되지 +않는다. 그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를 +죽이고 DB 를 crash 시키는 것이 목적이라 망가뜨릴 수 있는 층이 필요하다. + +--- + +## 1. base 이미지를 받는다 + +OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝난 디스크**이고, +첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다. + +**하기** +```bash +cd /var/lib/libvirt/images +sudo curl -LO https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2 +sudo mv debian-12-genericcloud-amd64.qcow2 base.qcow2 +``` + +**확인** +```bash +qemu-img info /var/lib/libvirt/images/base.qcow2 +``` + +## 2. cloud-init 을 쓴다 + +게스트마다 하나씩 만든다. 템플릿은 +[`deploy/lab/cloud-init/kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example). + +```yaml +#cloud-config +hostname: kc-lab-1 +fqdn: kc-lab-1 +manage_etc_hosts: true + +users: + - name: donghyeon + groups: [sudo] + shell: /bin/bash + sudo: ['ALL=(ALL) NOPASSWD:ALL'] + lock_passwd: false + plain_text_passwd: __CONSOLE_PW__ + ssh_authorized_keys: + - __LAB_HOST_KEY__ + - __WORKSTATION_KEY__ + +ssh_pwauth: false +package_update: true +packages: [curl, nftables] +``` + +### 세 값을 어디서 가져오나 + +자리표시자 셋을 실제 값으로 바꿔야 한다. **찾는 명령이 있다.** + +```bash +# ① lab host 공개키 — 없으면 만든다 +[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 +cat ~/.ssh/id_ed25519.pub + +# ② 워크스테이션 공개키 — 워크스테이션에서 +cat ~/.ssh/id_ed25519.pub + +# ③ 콘솔용 비밀번호 — 만들어서 보관한다 +openssl rand -base64 18 +``` + +셋을 넣는다. +```bash +sed -i \ + -e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \ + -e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \ + -e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \ + kc-lab-1.yaml +``` + +**확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다 +```bash +grep -c '__' kc-lab-1.yaml # 0 이어야 한다 +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 은 파싱에 실패해도 아무 오류를 남기지 +않으므로**, 넣기 전에 여기서 걸러야 한다. + +세 가지가 의도적이다. + +| | 왜 | +|---|---| +| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 | +| `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 | +| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 | + +> **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도 +> **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는 +> 것이 유일한 증상이다. + +## 3. 시드 이미지를 만든다 + +cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는 **정확한 +이름**의 파일이 있는 볼륨을 찾는다. + +**하기** +```bash +printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1 + +xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \ + -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1 + +virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw +virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso +``` + +`instance-id` 에 타임스탬프를 넣는 이유가 있다. cloud-init 은 **인스턴스마다 +한 번만** 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로, +user-data 를 고쳐도 반영되지 않는다. + +## 4. VM 을 만든다 + +**하기** +```bash +virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \ + --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \ + --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \ + --network network=default,mac=52:54:00:aa:bb:11 \ + --import --os-variant debian12 --noautoconsole +``` + +`kc-lab-2` 는 이름·메모리(4096)·MAC(`...:12`)·시드만 바꾼다. + +**★ 시드를 `--cloud-init` 으로 붙이지 않는다.** 그 옵션은 시드를 SATA CD-ROM +으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 +드라이버를 뺐다. **AHCI 장치가 보이지 않아** cloud-init 이 데이터소스를 +못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다. + +`--disk size=20,backing_store=...` 는 복사가 아니라 **오버레이**다. base 는 +읽기 전용으로 두고 변경분만 새 파일에 쌓이므로, 20GB 를 두 개 만들어도 +실제 디스크는 몇백 MB 만 쓴다. + +## 5. DHCP 로 IP 를 고정한다 + +MAC 을 정해 두었으니 그 MAC 에 IP 를 예약한다. + +**하기** +```bash +virsh net-update default add ip-dhcp-host \ + "" \ + --live --config +``` + +`--live --config` 둘 다 준다. `--live` 만 주면 재부팅에 사라지고, `--config` +만 주면 지금 반영되지 않는다. + +**확인** +```bash +virsh net-dumpxml default | grep ip-dhcp-host -A3 +``` + +**실측** +``` + + + +``` + +## 6. 붙어 본다 + +**확인** +```bash +virsh list --all +ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1' +``` + +**실측** +``` + Id Name State +-------------------------- + 3 kc-lab-2 running + 4 kc-lab-1 running + +kc-lab-1 +PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" +``` + +--- + +## 막히면 + +여기서 실제로 겪은 것들이다. + +| 증상 | 원인 | 확인 | +|---|---|---| +| SSH `Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 | +| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 | +| IP 가 매번 바뀐다 | DHCP 예약이 `--config` 없이 들어감 | `net-dumpxml` | +| VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 | + +게스트에 못 들어갈 때는 **화면을 직접 뜬다.** + +```bash +virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다 +``` + +`localhost login:` 이면 cloud-init 미실행, `kc-lab-1 login:` 이면 실행된 것이다. +이 한 장이 「SSH 가 안 되는 이유」를 절반으로 줄인다. + +--- + +## 실측값 + +``` +kc-lab-1 vCPU 2 메모리 5120MB 192.168.122.11 +kc-lab-2 vCPU 2 메모리 4096MB 192.168.122.12 +게스트 OS Debian GNU/Linux 12 (bookworm) +``` + +> 메모리가 처음 만들 때(3584MB)와 다르다. 호스트가 12GB 뿐이라 실험을 늘리며 +> 재배분했다. VM 을 다시 만들지 않고 바꾸는 방법은 +> [`session-lab-concepts.md`](../../session-lab-concepts.md) 13층에 있다. diff --git a/docs/guides/02-k3s/README.md b/docs/guides/02-k3s/README.md new file mode 100644 index 0000000..1f75df7 --- /dev/null +++ b/docs/guides/02-k3s/README.md @@ -0,0 +1,136 @@ +# 02 — k3s 두 노드 + +## 이 단계가 끝나면 + +`kubectl get nodes` 에 두 노드가 `Ready` 로 나온다. + +## 전제 + +[01](../01-vms/) 이 끝나 두 게스트에 SSH 가 붙는다. + +--- + +## 1. server 를 깐다 (kc-lab-1) + +**하기** +```bash +ssh kc-lab-1 +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 +``` + +## 2. 토큰을 꺼낸다 + +**하기** — 화면에 찍어 눈으로 옮기지 말고 변수로 받는다 +```bash +TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') +echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다 +``` + +**실측** — 이 실험대에서는 56자였다. 0 이면 server 가 아직 안 떴거나 경로가 다르다. + +## 3. agent 를 붙인다 (kc-lab-2) + +**하기** — 토큰을 그대로 넘긴다 +```bash +ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \ + --server https://192.168.122.11:6443 \ + --token '$TOKEN' \ + --node-ip 192.168.122.12" +``` + +> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. +> ```bash +> ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \ +> | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null' +> ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \ +> --server https://192.168.122.11:6443 --token-file /tmp/token \ +> --node-ip 192.168.122.12; rm -f /tmp/token" +> ``` + +**확인** — server 쪽에서 +```bash +sudo kubectl get nodes -o wide +``` + +**실측** +``` +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 는 원래 그렇다. + +## 4. 유닛 이름이 다르다 + +| 노드 | 유닛 | +|---|---| +| 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=' +``` + +**실측** +``` +ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11' +ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12' +``` + +> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가 +> 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다. + +## 5. k3s 가 기본으로 딸려 오는 것 + +따로 설치하지 않아도 이미 있다. + +| | 무엇 | +|---|---| +| Traefik | 인그레스 컨트롤러. `:80` 을 듣는다 | +| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 | +| local-path | 기본 StorageClass. **노드 로컬 디스크** | +| flannel | 파드 네트워크 (VXLAN) | +| kube-router | NetworkPolicy 집행 | + +**확인** +```bash +sudo kubectl get pods -A +sudo kubectl get storageclass +``` + +> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진 +> 노드에 묶여** 다른 노드로 재배치되지 않는다. + +## 6. 워크스테이션에서 쓰려면 + +**하기** +```bash +ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/kc-lab.yaml +sed -i 's|127.0.0.1|192.168.122.11|' ~/.kube/kc-lab.yaml +export KUBECONFIG=~/.kube/kc-lab.yaml +``` + +kubeconfig 안의 서버 주소가 `127.0.0.1` 이다. **게스트 안에서만 맞는 주소**라 +밖에서 쓰려면 바꿔야 한다. + +--- + +## 막히면 + +| 증상 | 원인 | 확인 | +|---|---|---| +| agent 가 `NotReady` | 토큰·주소 오타 | `journalctl -u k3s-agent -n 30` | +| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` | +| 밖에서 kubectl 이 안 붙음 | kubeconfig 의 `127.0.0.1` | 위 6번 | +| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 | diff --git a/docs/guides/03-nginx/README.md b/docs/guides/03-nginx/README.md new file mode 100644 index 0000000..f2fbbbc --- /dev/null +++ b/docs/guides/03-nginx/README.md @@ -0,0 +1,170 @@ +# 03 — 호스트 nginx 라우팅 + +## 이 단계가 끝나면 + +밖에서 보낸 요청이 **nginx → Traefik → 파드**로 닿는다. 아직 TLS 는 없다. + +## 전제 + +[02](../02-k3s/) 가 끝나 두 노드가 `Ready`. + +## 왜 프록시가 두 겹인가 + +nginx 와 Traefik 이 하는 일이 다르다. + +| | 맡는 것 | +|---|---| +| 호스트 nginx | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` | +| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 | + +**이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더 +실험이 성립하는 이유다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를 +재려면 두 겹이 있어야 한다. + +--- + +## 1. 설정을 쓴다 + +원본은 [`deploy/lab/host/nginx-keycloak-lab.conf`](../../../deploy/lab/host/nginx-keycloak-lab.conf). + +**하기** +```bash +sudo tee /etc/nginx/sites-available/keycloak-lab > /dev/null <<'EOF' +upstream k3s_traefik { + server 192.168.122.11:80; + server 192.168.122.12:80; +} + +server { + listen 80 default_server; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl default_server; + http2 on; + server_name _; + + ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass http://k3s_traefik; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Port 443; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +EOF +``` + +> **인증서 경로는 아직 없다.** [04](../04-tls/) 에서 만든다. 그전까지는 443 +> 블록을 주석 처리하고 80 만 `proxy_pass` 로 두면 이 단계를 먼저 확인할 수 있다. + +Arch 는 `sites-available` 관례가 없다. 직접 만들고 `nginx.conf` 의 `http` 블록 +안에서 include 한다. + +```bash +sudo mkdir -p /etc/nginx/sites-{available,enabled} +sudo ln -s /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/ +# nginx.conf 의 http { } 안에: include /etc/nginx/sites-enabled/*; +``` + +## 2. 문법을 보고 적용한다 + +**하기** +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +**`&&` 가 중요하다.** 설정이 깨진 상태에서 reload 하면 nginx 가 새 워커를 +띄우지 못한다. `-t` 를 먼저 통과시키고 그때만 reload 한다. + +## 3. 층별로 확인한다 — 아래에서 위로 + +한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다. + +**확인 ①** Traefik 이 듣고 있나 (nginx 를 건너뛴다) +```bash +curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11 +``` +``` +404 +``` + +**`404` 가 성공 신호다.** Traefik 까지 닿았는데 매칭되는 Ingress 규칙이 없다는 +뜻이다. `502` 나 연결 거부면 그 아래에서 끊긴 것이다. + +**확인 ②** nginx 가 80 에서 리다이렉트하나 +```bash +curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com +``` +``` +301 https://auth.hyeonworks.com/ +``` + +**확인 ③** 끝까지 닿나 (TLS 이후) +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +``` +``` +200 +``` + +## 4. upstream 이 둘인 이유 + +``` +upstream k3s_traefik { + server 192.168.122.11:80; + server 192.168.122.12:80; +} +``` + +두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도 된다. nginx 는 기본 +라운드로빈으로 번갈아 보내고, **한쪽이 죽으면 자동으로 뺀다.** + +그 「빼는」 동작이 로그에 이렇게 남는다. + +``` +connect() failed (113: No route to host) ← 호스트에 못 닿는다 +connect() failed (111: Connection refused) ← 포트에 아무도 없다 +no live upstreams ← 둘 다 죽었다고 판단 +``` + +**113 과 111 은 대응이 다르다.** 113 은 네트워크, 111 은 프로세스다. +A-4 에서 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다. + +--- + +## 막히면 + +| 증상 | 어디서 끊겼나 | 확인 | +|---|---|---| +| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` | +| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 | +| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` | +| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | [04](../04-tls/) · 아래 로그 | + +**로그를 볼 때** — 실무자가 치는 형태다. +```bash +journalctl -u nginx -p err -n 5 # 최근 에러만 +journalctl -u nginx -f # 지금 벌어지는 것 +``` + +**★ nginx 에러 로그는 2048바이트에서 잘린다.** 긴 URL 이 끝에서 단어 중간에 +끊겨 보이면 그것이다. 되찾으려면 **access 로그를 본다** — 거기엔 제한이 없다. + +```bash +grep oauth2/callback /var/log/nginx/access.log | tail -1 +``` + +이 실험대에서 B-7 의 502 원인이 error 로그에 있었는데 잘려 있었고, access +로그에는 3492자로 온전히 남아 있었다. diff --git a/docs/guides/04-tls/README.md b/docs/guides/04-tls/README.md new file mode 100644 index 0000000..502bd8c --- /dev/null +++ b/docs/guides/04-tls/README.md @@ -0,0 +1,227 @@ +# 04 — TLS + +## 이 단계가 끝나면 + +`https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다. + +## 전제 + +[03](../03-nginx/) 이 끝나 nginx 가 Traefik 으로 프록시한다. 그리고 **공개 +DNS 에 이름 세 개가 이 호스트를 가리키고 있어야 한다** — Let's Encrypt 가 +HTTP-01 로 검증하러 오기 때문이다. + +--- + +## 1. certbot 을 깐다 + +**하기** +```bash +sudo pacman -S certbot certbot-nginx # Arch +sudo apt install certbot python3-certbot-nginx # Debian/Ubuntu +``` + +## 2. 인증서를 받는다 + +이름 세 개를 **한 인증서**에 넣는다. + +**하기** +```bash +sudo certbot certonly --webroot -w /var/www/html \ + -d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com +``` + +**확인** +```bash +sudo certbot certificates +``` + +> **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.** +> B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를 +> 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를 +> 붙여야 한다 — 그 절충을 안 한 결과다. + +## 3. nginx 가 `fullchain` 을 보게 한다 + +[03](../03-nginx/) 의 설정에 이미 있다. + +``` +ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem; +ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem; +``` + +**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가 +빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**, +캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. + +**하기** +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +## 4. 확인 — 열리는가, 체인이 완전한가 + +**확인 ①** 열리나 +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +``` +``` +200 +``` + +**확인 ②** 체인 단계와 검증 +```bash +echo | openssl s_client -connect auth.hyeonworks.com:443 \ + -servername auth.hyeonworks.com 2>/dev/null \ + | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code' +``` + +**실측** +``` + 0 s:CN = auth.hyeonworks.com + i:C = US, O = Let's Encrypt, CN = YE2 + 1 s:C = US, O = Let's Encrypt, CN = YE2 + i:C = US, O = ISRG, CN = Root YE + 2 s:C = US, O = ISRG, CN = Root YE + i:C = US, O = Internet Security Research Group, CN = ISRG Root X2 + 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2 + i:C = US, O = Internet Security Research Group, CN = ISRG Root X1 +Verify return code: 0 (ok) +``` + +**단계가 1개면 `cert.pem` 을 쓴 것이다.** + +**확인 ③** 이름 세 개가 한 인증서인가 +```bash +for H in auth app1 app2; do + echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \ + | openssl x509 -noout -serial +done +``` + +일련번호가 셋 다 같으면 SAN 하나에 들어 있는 것이다. + +## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다 + +**타이머가 도는 것만으로는 부족하다.** + +**확인** — 타이머 +```bash +systemctl list-timers certbot-renew.timer +``` + +이것이 `active` 여도 갱신된 인증서가 **서빙되지는 않는다.** nginx 는 인증서를 +기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아 +끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.** + +배포판 기본 유닛에는 reload 를 부르는 것이 없다. + +```bash +systemctl cat certbot-renew.service +``` +``` +[Service] +Type=oneshot +ExecStart=/usr/bin/certbot -q renew +PrivateTmp=true +``` + +`ExecStartPost` 도 `--deploy-hook` 도 없다. + +**하기** — 훅 하나를 넣는다 +```bash +sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF' +#!/bin/sh +nginx -t && nginx -s reload +EOF +sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +``` + +`deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 +갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다. + +**확인** — 실제로 도는지 +```bash +# 강제 갱신 전에 워커 PID 를 적어 둔다 +ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep + +sudo certbot renew --force-renewal + +# 워커 PID 가 바뀌었으면 reload 된 것이다 +ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep +``` + +**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이 +`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** — +nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 +`test is successful` · `signal process started` 다. + +> **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.** + +**실측** — 이 실험대에서 잰 차이 + +| | 훅 없음 | 훅 있음 | +|---|---|---| +| 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** | +| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 | +| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — | + +**88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS` 로 +끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. +발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고 +적혀 있다. + +원문: [D-4](../../experiment-d4-certificate-renewal.md) · +[D-4a](../../experiment-d4a-deploy-hook.md) · +증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/) + +## 6. reload 는 무중단인가 — 쟀다 + +궁금할 것이므로 결과만 적는다. **무중단이다.** + +새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고 +845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도** +845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다. + +--- + +## 막히면 + +| 증상 | 원인 | 확인 | +|---|---|---| +| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short ` · 밖에서 `curl http://` | +| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② | +| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` | +| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** | +| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 | + +--- + +## 근거를 재려면 (선택) + +평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다. + +갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면 +갱신 중에 나온 실패 한 건을 해석할 수 없다. + +```bash +# 대조군 — 0.2초 × 900회 = 180초 +i=0; while [ $i -lt 900 ]; do + curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \ + https://auth.hyeonworks.com/realms/master + i=$((i+1)); sleep 0.2 +done > /tmp/control.txt +awk '{print $1}' /tmp/control.txt | sort | uniq -c +``` + +이 실험대의 대조군은 **900건 전부 200, 오류 0** 이었다. 그래서 갱신 중 비200 이 +한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다. + +**그리고 시각을 비교할 때 시계를 확인한다.** 이 실험대는 test-server 가 NTP +미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 +먼저 실행된 것이 되어 물리적으로 불가능했다. + +```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:' +``` diff --git a/docs/guides/05-keycloak/README.md b/docs/guides/05-keycloak/README.md new file mode 100644 index 0000000..1fcf634 --- /dev/null +++ b/docs/guides/05-keycloak/README.md @@ -0,0 +1,330 @@ +# 05 — Keycloak 2노드 + PostgreSQL + +## 이 단계가 끝나면 + +`https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이 +하나의 클러스터로 보인다. + +## 전제 + +[04](../04-tls/) 까지 끝나 `https://` 가 열린다. + +--- + +## 1. 매니페스트를 적용한다 + +**하기** +```bash +kubectl create namespace keycloak-lab +kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml +``` + +**확인** — 적용이 끝날 때까지 기다린다 +```bash +kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s +``` + +``` +partitioned roll out complete: 2 new pods have been updated... +``` + +`rollout status` 는 **끝날 때까지 블록**한다. `get pods` 를 반복해서 보는 것보다 +이게 낫다. + +--- + +## 2. 리소스가 제대로 만들어졌는지 — 층별로 본다 + +`kubectl get pods` 만 보면 놓치는 것이 많다. **위에서 아래로** 확인한다. + +### 2-1. 무엇이 만들어졌나 + +**확인** +```bash +kubectl -n keycloak-lab get all +``` + +`all` 은 이름과 달리 전부는 아니다 — Secret·ConfigMap·PVC·Ingress 는 안 나온다. + +```bash +kubectl -n keycloak-lab get secret,configmap,pvc,ingress +``` + +### 2-2. Deployment → ReplicaSet → Pod 사슬 + +Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를 +만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발점이다. + +**확인** +```bash +kubectl -n keycloak-lab get deploy,rs,pod -l app=bff +``` + +**실측** +``` +replicaset.apps/bff-555df79c97 2 2 ← 지금 쓰이는 것 +replicaset.apps/bff-574c6d658b 0 0 ← 지난 배포 +replicaset.apps/bff-576d869c6d 0 0 +... (7개) +pod/bff-555df79c97-6j86w 1/1 Running +``` + +**ReplicaSet 이 여러 개인 것은 정상이다.** 배포할 때마다 새로 만들고 옛것은 +`0` 으로 남긴다 — 그래서 `kubectl rollout undo` 가 가능하다. 파드 이름의 +가운데 해시(`555df79c97`)가 어느 ReplicaSet 소속인지 말해 준다. + +읽는 법: + +| 보이는 것 | 뜻 | +|---|---| +| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 | +| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 | +| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe | + +> StatefulSet 은 ReplicaSet 을 쓰지 않고 파드를 직접 만든다. 그래서 +> `keycloak-0`·`keycloak-1` 처럼 **이름이 고정**이고, A-4 에서 `Terminating` +> 파드가 안 지워지면 대체 파드가 안 생기는 이유가 이것이다. + +### 2-3. 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 +``` + +**확인 ②** 값이 비어 있지 않나 — **길이만** +```bash +kubectl -n keycloak-lab get secret keycloak-lab-secrets \ + -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c +``` +``` +22 +``` + +**확인 ③** 파드 안에 주입됐나 — 여기가 진짜다 +```bash +kubectl -n keycloak-lab exec keycloak-0 -- \ + sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"' +``` +``` +길이=19 +``` + +**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다. +```bash +kubectl -n keycloak-lab get pod keycloak-0 \ + -o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}' +``` +``` +KC_DB +KC_DB_URL +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) + +### 2-4. Service 가 파드를 잡고 있나 — Endpoints + +Service 가 있어도 **셀렉터가 안 맞으면 뒤가 비어 있다.** 이때 증상은 +「연결은 되는데 응답이 없다」라 원인을 찾기 어렵다. + +**확인** — 실무자가 가장 자주 쓰는 형태 +```bash +kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints +``` +``` +Endpoints: 10.42.0.67:8080,10.42.1.155:8080 +``` + +목록으로 보려면 **EndpointSlice** 를 쓴다. +```bash +kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak +``` +``` +NAME ADDRESSTYPE PORTS ENDPOINTS AGE +keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h +``` + +> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 이고 +> 실행하면 경고가 나온다. +> ``` +> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice +> ``` +> 옛 문서와 블로그에 이 형태가 많으니 주의한다. + +준비 상태까지 함께 보려면 이렇게 뽑는다. +```bash +kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ + -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}' +``` +``` +10.42.0.67 true +10.42.1.155 true +``` + +`ready` 가 `false` 면 파드는 있는데 **readiness 프로브를 통과하지 못한** 것이라, +Service 가 그 파드로 트래픽을 보내지 않는다. + +**비어 있으면** 셀렉터와 파드 라벨이 안 맞는 것이다. +```bash +kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo +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` 인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 +파드 쪽 원인을 먼저 본다. + +--- + +## 3. 안 뜰 때 — 순서가 있다 + +**① 이벤트부터.** 로그보다 먼저다. 스케줄링·이미지·볼륨 실패가 여기 나온다. +```bash +kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20 +``` + +**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다. +```bash +kubectl -n keycloak-lab describe pod keycloak-0 +``` + +**③ 로그.** 컨테이너가 떴는데 죽는 경우다. +```bash +kubectl -n keycloak-lab logs keycloak-0 +kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그 +``` + +`--previous` 가 중요하다. CrashLoopBackOff 면 지금 컨테이너는 방금 뜬 것이라 +**죽은 이유는 이전 컨테이너 로그에 있다.** + +**④ 그래도 모르면 안에서 본다.** +```bash +kubectl -n keycloak-lab exec -it keycloak-0 -- sh +``` + +--- + +## 4. 클러스터가 형성됐는지 확인한다 + +파드가 둘 다 `Running` 인 것과 **하나의 클러스터로 묶인 것**은 다르다. + +**확인 ①** 로그 +```bash +kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 +``` +``` +ISPN000094: Received new cluster view for channel ISPN: + [keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537] +``` + +`(2)` 가 멤버 수다. + +**확인 ②** 디스커버리 테이블 +```bash +kubectl -n keycloak-lab exec deploy/postgres -- \ + psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping' +``` + +**확인 ③** 지표 + +**★ Keycloak 컨테이너에는 `curl` 이 없다.** 공식 이미지가 최소 구성이라 +`wget` 도 `nc` 도 없다. 안에서 치면 이렇게 된다. + +``` +sh: line 1: curl: command not found +command terminated with exit code 127 +``` + +그래서 밖에서 물어본다. **Prometheus 에 묻는 것이 가장 짧다.** + +```bash +kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' +``` + +``` +keycloak-1 → 2 +keycloak-0 → 2 +``` + +Prometheus 가 아직 없다면 임시 파드를 띄운다. + +```bash +K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') +kubectl -n keycloak-lab run m --rm -i --restart=Never \ + --image=curlimages/curl:8.11.1 --quiet --command -- \ + sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'" +``` + +``` +vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0 +``` + +> **두 값이 다를 수 있다.** 각 노드가 자기가 아는 멤버 수를 보고하므로, +> 분단되면 한쪽은 2 다른 쪽은 1 이 된다. **한 노드만 보면 분단을 놓친다.** + +> **셋이 다른 것을 본다.** 로그는 「그때 그렇게 보였다」이고, 테이블은 +> 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다. +> A-1 에서 이 셋이 갈렸다 — 테이블에는 둘 다 있는데 메시지는 안 갔다. + +--- + +## 5. 밖에서 닿는지 + +**확인** +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +``` +``` +200 +``` + +브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다. +비밀번호는 위 2-3 의 Secret 에 있다. + +--- + +## 막히면 + +| 증상 | 어디를 보나 | +|---|---| +| 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 | +| `ImagePullBackOff` | 이미지 이름·태그. 자체 빌드면 두 노드 모두에 반입했는가 | +| `CrashLoopBackOff` | `logs --previous` | +| `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions | +| 밖에서 502 | Ingress → Service → Endpoints 순으로 뒤를 본다 | +| 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 위 4번 셋 다 확인 | + +--- + +## 근거를 재려면 (선택) + +세션이 실제로 어디 저장되는지는 DB 를 직접 본다. + +```bash +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층 결론 전체를 뒤집는다 — +[A-7](../../experiment-a7-volatile-comparison.md) diff --git a/docs/guides/06-observability/README.md b/docs/guides/06-observability/README.md new file mode 100644 index 0000000..70d8375 --- /dev/null +++ b/docs/guides/06-observability/README.md @@ -0,0 +1,131 @@ +# 06 — 관측 + +## 이 단계가 끝나면 + +Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를 +밖에서 볼 수 있다. + +## 전제 + +[05](../05-keycloak/) 가 끝나 Keycloak 두 노드가 떴다. + +## 왜 필요한가 + +실험의 판정을 **밖에서만** 하면 놓친다. A-1 에서 7800 을 끊었는데 외부 응답이 +전부 200 이었다 — 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다. +클러스터 안을 보는 눈이 따로 있어야 한다. + +--- + +## 1. 적용 + +**하기** +```bash +kubectl apply -f deploy/lab/k8s/observability.yaml +kubectl -n observability rollout status deploy/prometheus --timeout=180s +``` + +**확인** +```bash +kubectl -n observability get pods +``` + +**실측** +``` +grafana-845b5678cf-b6gvc 1/1 Running +node-exporter-9qk9w 1/1 Running +node-exporter-c2mz4 1/1 Running +prometheus-6774f94f7c-pzr2t 1/1 Running +``` + +node-exporter 가 **둘**인 것은 DaemonSet 이라 노드마다 하나씩 뜨기 때문이다. + +## 2. 무엇을 긁고 있나 — 여기가 중요하다 + +**확인** +```bash +kubectl -n observability exec deploy/prometheus -- \ + wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u +``` + +**실측** +``` +"job":"keycloak" +"job":"kubelet" +"job":"node-exporter" +"job":"prometheus" +``` + +**★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는 그것들을 긁지 않는다. +그래서 B층 실험 대부분에 Grafana 화면이 없는데, **안 찍은 것이 아니라 지표가 +없는 것**이다. + +> 이것을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다. +> [`evidence/followup/04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt) + +## 3. 클러스터 상태를 본다 + +**하기** +```bash +kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' +``` + +**실측** +``` +keycloak-1 → 2 +keycloak-0 → 2 +``` + +**두 노드가 각각 자기가 아는 멤버 수를 보고한다.** 분단되면 한쪽은 2, +다른 쪽은 1 이 된다 — **한 노드만 보면 분단을 놓친다.** + +자주 보는 지표들이다. + +| 지표 | 무엇 | +|---|---| +| `vendor_cluster_size` | 이 노드가 아는 멤버 수 | +| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 | +| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 | +| `agroal_*` | JDBC 커넥션 풀 | +| `up` | 스크레이프 성공 여부 | + +## 4. `up` 을 믿지 않는다 + +**A-2 에서 503 이 나는 동안에도 `up` 은 1 이었다.** 프로세스가 살아 있고 +`/metrics` 가 응답하기만 하면 1 이므로 **「살아 있지만 쓸모없는」 상태를 +보지 못한다.** + +```bash +kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=up' +``` + +경보를 걸 때는 `up == 0` 만으로 부족하고 **기능 지표**를 함께 본다. + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +``` + +## 5. Grafana 를 볼 때 + +**하기** — 밖에 열지 않고 포트포워드로 본다 +```bash +kubectl -n observability port-forward svc/grafana 3000:3000 +``` + +브라우저에서 `http://localhost:3000`. + +> 실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로 +> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다. + +--- + +## 막히면 + +| 증상 | 원인 | 확인 | +|---|---|---| +| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 위 2번 targets | +| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 | +| 컨테이너 안에서 curl 실패 | **Keycloak 이미지에 curl 이 없다** | 밖에서 Prometheus 로 묻는다 | +| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 | diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 0000000..8b9be8e --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,73 @@ +# 실습 가이드 — 직접 쳐보면서 만드는 실험대 + +이 문서 묶음은 **읽는 문서가 아니라 따라 치는 문서**다. 기존 +[`experiment-*.md`](../) 가 「무엇을 발견했나」를 적었다면, 여기는 +「그 발견을 재현하려면 무엇을 어떤 순서로 치는가」를 적는다. + +## 두 종류의 명령을 구별해 적는다 + +실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도 +다르다. 이 가이드는 둘을 섞지 않는다. + +| 표시 | 무엇인가 | +|---|---| +| **하기** · **확인** | 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩 | +| **근거를 재려면** | 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다 | + +예를 들어 nginx 에러 로그가 잘렸을 때, 실무자는 잘린 걸 보고 access 로그로 +넘어간다. 길이를 재서 2048인지 확인하는 것은 **몰라서 재는** 것이고, 알면 +재지 않는다. + +같은 이유로 `curl` 도 두 형태가 있다. + +```bash +curl -I https://auth.hyeonworks.com/realms/master # 한 번 볼 때 +curl -s -o /dev/null -w '%{http_code}\n' # 여러 번 재서 비교할 때 +``` + +이 가이드의 **확인**은 값을 대조해야 해서 두 번째 형태를 자주 쓴다. 실제로 +터미널에서 눈으로 볼 때는 첫 번째로 충분하다. + +## 자리표시자를 두지 않는다 + +`<토큰>` 처럼 적어 두면 그 값을 어디서 가져오는지가 문서 밖으로 나간다. +이 가이드는 **값을 찾는 명령을 함께 적는다.** + +```bash +TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') +echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다 +``` + +비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 터미널 스크롤백과 +화면 공유에 남기 때문이다. + +## 순서 + +앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고, +그 상태를 확인하는 명령이 있다. **그것이 통과해야 다음으로 넘어간다.** + +| 단계 | 무엇을 세우나 | 끝나면 확인되는 것 | +|---|---|---| +| [00](00-lab-host/) | lab host 가상화 준비 | `virsh list` 가 돈다 | +| [01](01-vms/) | VM 두 대 | 두 게스트에 SSH 가 붙는다 | +| [02](02-k3s/) | k3s server + agent | `kubectl get nodes` 에 둘 다 Ready | +| [03](03-nginx/) | 호스트 nginx 라우팅 | 밖에서 요청이 파드까지 닿는다 | +| [04](04-tls/) | Let's Encrypt | `https://` 가 열리고 체인이 4단계 | +| [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 | +| [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 | +| [experiments](experiments/) | 실험 26건 | 각 실험의 판정 기준 | + +## 이 가이드가 검증된 방식 + +**읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.** +버전·IP·메모리 같은 값은 지어내지 않았다. + +**만드는 명령은 다르다.** VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는 +실험대가 없어지므로, 그 명령들은 **실제로 구축할 때 쓴 것을 그대로 옮겼고** +결과 상태를 확인하는 것으로 대신했다. 어느 쪽인지 각 단계에 표시한다. + +## 막혔을 때 + +각 단계 끝에 **「막히면」** 표가 있다. 거기 적힌 증상은 전부 이 실험대가 +실제로 겪은 것이고, 원문은 [`../evidence/`](../evidence/) 에 있다. +지어낸 실패 사례는 없다.