From 006da7d490a2b4ce308224faf132a218e59a580b Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 09:21:45 +0900 Subject: [PATCH] docs: record observability setup and add concept layers 10-13 Adds Kubernetes resources (StatefulSet, PVC, Secret, RBAC, placement), Keycloak clustering internals (Infinispan, JGroups), Prometheus concepts and virtualization operations. Co-Authored-By: Claude Opus 5 --- docs/lab-observability.md | 429 +++++++++++++++++++++++++ docs/session-lab-concepts.md | 584 ++++++++++++++++++++++++++++++++++- 2 files changed, 1005 insertions(+), 8 deletions(-) create mode 100644 docs/lab-observability.md diff --git a/docs/lab-observability.md b/docs/lab-observability.md new file mode 100644 index 0000000..bab64b6 --- /dev/null +++ b/docs/lab-observability.md @@ -0,0 +1,429 @@ +# 관측성 — Prometheus · node-exporter · Grafana + +로드맵 10번. 장애 주입 실험보다 **먼저** 세운다. + +**왜 먼저인가** — 나중에 세우면 이미 지나간 장애의 지표를 볼 수 없다. +"클러스터가 1분쯤 뒤에 복구됐다"는 측정이 아니라 인상이다. +로드맵에 *"장애 주입 중에 어떤 지표가 먼저 움직이는지 기록한다"*고 적어둔 항목은 +관측이 먼저 서 있어야만 가능하다. + +메모리를 8GB → 12GB로 증설한 뒤에야 올릴 수 있게 됐다. + +--- + +## 1. 무엇을 세웠나 + +``` + ┌─ Grafana ──────────┐ + 브라우저 ──────▶│ app2.hyeonworks.com│ 대시보드 + └─────────┬──────────┘ + │ PromQL + ┌─────────▼──────────┐ + │ Prometheus │ 수집·저장 (TSDB, 7일) + └─────────┬──────────┘ + │ scrape (15초) + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + Keycloak :9000 node-exporter :9100 kubelet + (앱 지표) (머신 지표) (컨테이너 지표) +``` + +매니페스트: [`deploy/lab/k8s/observability.yaml`](../deploy/lab/k8s/observability.yaml) + +| 구성요소 | 역할 | 실측 메모리 | +|---|---|---| +| Prometheus | 수집·저장·질의 | 164Mi | +| node-exporter (DaemonSet) | 노드당 하나, 머신 지표 | 8Mi × 2 | +| Grafana | 시각화 | 65Mi | +| **합계** | | **약 245Mi** | + +예상(550Mi)보다 훨씬 적다. 실험대 규모에서는 관측성 비용이 거의 무시할 수준이다. + +--- + +## 2. 왜 kube-prometheus-stack을 쓰지 않았나 + +Helm 차트 하나로 끝내는 방법이 있지만 **평범한 매니페스트를 직접 썼다.** + +| | kube-prometheus-stack | 직접 작성 | +|---|---|---| +| 설치 | Helm 한 줄 | 매니페스트 400줄 | +| 메모리 | 1.5GB 이상 | **245Mi** | +| 포함 | Operator, Alertmanager, 대시보드 다수, kube-state-metrics | 필요한 것만 | +| **보이는 것** | 추상화 뒤에 숨음 | **스크레이프 설정·RBAC·relabel 이 눈에 보임** | + +세 번째 줄이 결정적이다. 이 실험대의 목적은 **인과를 직접 확인하는 것**이므로, +"어떻게 타깃을 찾는가"가 YAML에 드러나 있어야 한다. Operator를 쓰면 +`ServiceMonitor` 하나만 보이고 그 아래는 감춰진다. + +--- + +## 3. 구성 결정과 근거 + +### 3-1. 관측 스택의 배치 — 장애 도메인 분리 + +```yaml +nodeSelector: + node-role.kubernetes.io/control-plane: "true" +``` + +**관측 시스템은 관측 대상과 같은 장애 도메인에 있으면 안 된다.** 죽는 순간을 +기록해야 하는데 같이 죽으면 기록이 남지 않는다. + +노드가 둘뿐이라 완전히 피할 수는 없다. 그래서 규칙을 정했다. + +| 노드 | 역할 | 실험에서 | +|---|---|---| +| **kc-lab-1** (k3s **server**) | control plane · Traefik · coredns · metrics-server · local-path-provisioner | **관측 스택을 여기 둔다. 죽이지 않는다** | +| **kc-lab-2** (k3s **agent**) | keycloak-0 · postgres | **장애 주입 대상** | + +`kubernetes.io/hostname`으로 못박지 않고 **`node-role.kubernetes.io/control-plane` +라벨**을 쓴 이유는 의미가 드러나기 때문이다 — "컨트롤 플레인 노드에 둔다"는 +의도가 호스트 이름보다 오래간다. + +### 3-2. 앞선 판단을 정정했다 + +배치를 조사하기 전에는 **"노드 상실 실험은 `kc-lab-1`을 죽여서 하자"**고 +적었다. 그 노드에 Keycloak 하나만 있다고 생각했기 때문이다. **틀렸다.** + +``` +kc-lab-1 (server) keycloak-1, traefik, coredns, metrics-server, local-path-provisioner +kc-lab-2 (agent) keycloak-0, postgres +``` + +`kc-lab-1`을 죽이면 **API 서버·DNS·인그레스가 한꺼번에 사라진다.** 노드 상실이 +아니라 **컨트롤 플레인 상실**이며, `kubectl`조차 동작하지 않는다. + +**깨끗한 워커 노드 상실 실험은 `kc-lab-2`를 죽이는 것이다.** 그때도 변수가 +둘(keycloak-0 + postgres)이지만, 클러스터 제어는 살아 있고 관측도 계속된다. + +### 3-3. 스크레이프 주기 15초 + +```yaml +global: + scrape_interval: 15s +``` + +운영에서는 30~60초가 흔하지만 여기서는 짧게 잡았다. **노드가 죽는 순간을 +두어 샘플 안에 잡아야** "무엇이 먼저 움직였나"를 말할 수 있다. +60초면 장애와 복구가 같은 샘플에 뭉개진다. + +### 3-4. 타깃을 정적 목록으로 두지 않는다 + +```yaml +kubernetes_sd_configs: + - role: endpoints + namespaces: { names: [keycloak-lab] } +``` + +**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켰을 때 모든 파드가 +새 주소를 받는 것을 직접 확인했다(`10.42.1.22` → `10.42.1.25`). +정적 목록을 적어두면 그때마다 깨진다. + +쿠버네티스 API에 물어보는 방식(service discovery)이므로 **파드가 옮겨다녀도 +따라간다.** Traefik의 `trustedIPs`에 개별 IP를 적을 수 없었던 것과 같은 이유다. + +### 3-5. relabel — 발견한 것을 걸러내고 이름을 붙인다 + +```yaml +relabel_configs: + - source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name] + action: keep + regex: keycloak-headless;management + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - source_labels: [__meta_kubernetes_pod_node_name] + target_label: node +``` + +service discovery는 네임스페이스의 **모든 엔드포인트**를 가져온다. 그중 +필요한 것만 남기고 나머지는 버리는 것이 `keep`이다. + +- 첫 규칙 — `keycloak-headless` 서비스의 `management` 포트만 남긴다. + 8080(http)까지 긁으면 애플리케이션 트래픽 포트에 헛되이 요청이 간다 +- 나머지 두 규칙 — **`pod`과 `node` 라벨을 붙인다.** 이것이 없으면 + "어느 파드가, 어느 노드에서" 라는 질문에 답할 수 없다. + 노드 상실 실험에서 결정적이다 + +### 3-6. Keycloak 지표는 9000 포트다 + +헬스체크와 같은 관리 포트다. `KC_METRICS_ENABLED=true`가 이미 StatefulSet에 +설정돼 있다. **8080을 긁으면 지표가 나오지 않는다.** + +### 3-7. node-exporter는 DaemonSet + 호스트 네임스페이스 + +```yaml +kind: DaemonSet +spec: + template: + spec: + hostNetwork: true + hostPID: true + tolerations: + - operator: Exists +``` + +- **DaemonSet** — 노드마다 정확히 하나. 죽을 노드에도 있어야 **꺼지기 직전의 + 마지막 샘플**이 남는다 +- **`hostNetwork`/`hostPID`** — 측정 대상이 컨테이너가 아니라 **머신**이다. + 컨테이너 네임스페이스 안에서 보면 자기 자신만 보인다 +- **`tolerations: operator: Exists`** — 어떤 taint가 걸린 노드에도 뜬다. + 관측이 빠지는 노드가 있으면 안 된다 + +### 3-8. Prometheus 저장소는 PVC + +```yaml +storageClassName: local-path +--storage.tsdb.retention.time=7d +``` + +`emptyDir`로 두면 파드가 재시작될 때 **장애 실험의 기록이 통째로 사라진다.** +사후 추적이 목적이므로 영속 저장이 필요하다. + +`local-path`는 노드에 고정되므로 Prometheus도 `kc-lab-1`에 묶인다. +`nodeSelector`와 방향이 같아 문제가 되지 않는다. + +보존 7일은 실험 기간보다 넉넉하면서 **볼륨이 노드를 채우는 원인이 되지 않을** +크기다. + +```yaml +securityContext: + fsGroup: 65534 +``` + +`prom/prometheus` 이미지는 `nobody`(65534)로 실행된다. `fsGroup`이 없으면 +새로 만들어진 볼륨의 소유자가 root라 **쓰기 권한이 없어 기동에 실패한다.** + +### 3-9. Grafana에도 외부 URL을 알려줘야 한다 + +```yaml +- name: GF_SERVER_ROOT_URL + value: https://app2.hyeonworks.com +``` + +**Keycloak의 `KC_HOSTNAME`과 정확히 같은 성격의 설정이다.** Grafana도 +리다이렉트와 자산 경로에 절대 URL을 만든다. 이 값이 없으면 로그인 리다이렉트가 +`http://<파드IP>:3000`으로 나간다. + +2홉 헤더 계약에서 확인한 원리가 여기서도 그대로 적용된다 — +**프록시 뒤의 애플리케이션은 자기가 외부에서 어떤 주소로 보이는지 모른다.** + +### 3-10. 데이터소스는 파일로 프로비저닝 + +```yaml +volumeMounts: + - name: datasources + mountPath: /etc/grafana/provisioning/datasources +``` + +UI에서 클릭으로 추가하면 Grafana 자체 DB에만 남는다. 그 DB는 여기서 +`emptyDir`이므로 **파드가 재시작되면 사라진다.** 파일로 두면 항상 같은 상태로 +뜬다. + +### 3-11. Grafana를 `app2`에 붙인 이유 + +인증서에 들어 있는 이름이 `auth` / `app1` / `app2` 셋뿐이고 `app2`가 비어 +있었다. **SSO 실험에서 `app2`가 필요해지면 옮긴다.** + +--- + +## 4. 실행한 명령 + +```bash +# 워크스테이션 — 매니페스트 작성 후 +git add deploy/lab/k8s/observability.yaml +git commit -m "feat: add Prometheus, node-exporter and Grafana" +git push origin feature/keycloak-multinode-cluster-jdbc-ping + +# lab host +cd ~/workspace/keycloak-pattern && git pull +kubectl apply -f deploy/lab/k8s/observability.yaml + +kubectl -n observability rollout status deployment/prometheus --timeout=300s +kubectl -n observability rollout status daemonset/node-exporter --timeout=180s +kubectl -n observability rollout status deployment/grafana --timeout=300s +``` + +**검증 — 배포 성공과 타깃 수집은 다른 문제다.** + +```bash +kubectl -n observability run q --rm -i --restart=Never \ + --image=curlimages/curl:8.11.1 --quiet --command -- \ + curl -s "http://prometheus.observability.svc:9090/api/v1/targets?state=any" > /tmp/targets.json + +python3 -c " +import json +d,_ = json.JSONDecoder().raw_decode(open('/tmp/targets.json').read()) +ts = d['data']['activeTargets'] +print(f\"{sum(1 for t in ts if t['health']=='up')}/{len(ts)} up\") +for t in ts: + if t['health'] != 'up': print(t['labels'], t.get('lastError')) +" +``` + +--- + +## 5. 겪은 함정 + +### kubelet 타깃이 403 Forbidden + +첫 배포에서 **7개 중 5개만 up**이었다. + +``` +DOWN kubelet kc-lab-1 server returned HTTP status 403 Forbidden +DOWN kubelet kc-lab-2 server returned HTTP status 403 Forbidden +``` + +원인은 RBAC였다. kubelet 지표는 **API 서버의 proxy 서브리소스**를 통해 +가져온다. + +``` +/api/v1/nodes//proxy/metrics + ───── +``` + +이 경로에는 `nodes`나 `nodes/metrics`가 아니라 **`nodes/proxy`** 권한이 +필요하다. + +```diff +- resources: [nodes, nodes/metrics, services, endpoints, pods] ++ resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods] +``` + +**다른 잡은 전부 정상이었다.** 이런 부분 실패는 타깃 목록을 직접 확인하지 +않으면 드러나지 않는다. `rollout status`는 "성공"이라고 말한다. + +### `kubectl run --rm -i`의 출력에 종료 메시지가 섞인다 + +``` +json.decoder.JSONDecodeError: Extra data: line 1 column 54973 +``` + +`kubectl run --rm`은 컨테이너 출력 뒤에 `pod "q" deleted`를 덧붙인다. +JSON 파서가 그 뒤를 만나면 실패한다. + +**해결** — `raw_decode`로 앞쪽의 완전한 JSON만 읽는다. + +```python +d, _ = json.JSONDecoder().raw_decode(raw) +``` + +--- + +## 6. 실험에 쓸 지표 + +메트릭 이름이 **1506개** 수집된다. 그중 장애 실험에서 볼 것들이다. + +### 가장 중요한 것 — `up` + +```promql +up +up{job="keycloak"} +``` + +Prometheus가 타깃을 긁는 데 성공했는가를 0/1로 알려주는 **합성 지표**다. +타깃이 응답하지 않으면 0이 된다. + +**노드나 파드가 죽는 순간 가장 먼저 움직이는 신호**이며, 다른 모든 지표가 +사라지는 것과 달리 `up`은 **0이라는 값으로 남는다.** 그래서 "언제부터 죽었나"를 +사후에 알 수 있다. + +### JGroups — 7800 차단 실험의 핵심 + +```promql +vendor_jgroups_fd_sock2_get_num_suspected_members +vendor_jgroups_merge3_get_views +vendor_jgroups_tcp_get_different_cluster_messages +``` + +| 지표 | 무엇을 말하는가 | +|---|---| +| `fd_sock2_..._suspected_members` | **FD_SOCK2가 의심하는 멤버 수.** 현재 두 파드 모두 `0`. 7800이 막히면 상대를 suspect 하기 시작한다 | +| `merge3_get_views` | **MERGE3가 처리한 뷰 수.** split brain 후 다시 합칠 때 움직인다 | +| `tcp_get_different_cluster_messages` | 다른 클러스터로부터 온 메시지 | + +**7800 차단 실험의 가설** — `JGROUPS_PING` 테이블은 그대로 채워진 채 +`suspected_members`가 0에서 1로 오르고, 클러스터 뷰가 각각 1로 쪼개진다. + +### 노드 지표 + +```promql +node_memory_MemAvailable_bytes +node_load1 +node_network_receive_bytes_total +node_filesystem_avail_bytes +``` + +**"머신이 죽었나 프로세스가 죽었나"** 를 가르는 데 쓴다. 파드는 사라졌는데 +node-exporter가 살아 있으면 프로세스 문제이고, 둘 다 사라지면 머신 문제다. + +### Keycloak 애플리케이션 지표 + +```promql +keycloak_session_expiration_task_seconds_count +``` + +`keycloak_` 접두 지표는 아직 적다. 세션 관련 지표는 **실제 로그인이 발생해야** +나타나므로, 세션 복제 실험 이후 다시 조사한다. + +--- + +## 7. 접근 + +| | 주소 | 계정 | +|---|---|---| +| Grafana | `https://app2.hyeonworks.com` | `admin` / `lab-grafana-change-me` | +| Prometheus | 클러스터 내부 `prometheus.observability.svc:9090` | — | + +Prometheus UI를 직접 보려면 포트포워딩한다. + +```bash +kubectl -n observability port-forward svc/prometheus 9090:9090 +# http://localhost:9090/targets +``` + +**Grafana 비밀번호가 매니페스트에 평문이다.** 로드맵 11번(비밀 관리)에서 +정리한다. 지금 드러내 두는 것은 의도이며, 감춰두면 잊어버린다. + +--- + +## 8. 자원 실측 + +``` +grafana 65Mi +prometheus 164Mi +node-exporter 8Mi × 2 +──────────────────────── +합계 약 245Mi + +kc-lab-1 2045Mi (41%) +kc-lab-2 1131Mi (28%) +호스트 여유 3957MB +``` + +메모리 증설(8GB → 12GB) 전이었다면 kc-lab-1이 60%를 넘겼을 것이다. +증설이 이 항목을 가능하게 했다. + +--- + +## 9. 다음 + +관측이 서 있으므로 이제 고장을 주입하면 **무엇이 먼저 움직였는지**가 기록된다. + +``` +0. 세션 복제 확인 ← 로그인 세션을 만들어 두 노드에 복제되는지 +1. TCP 7800 차단 ← suspected_members 와 JGROUPS_PING 대조 +2. DB 상실 ← postgres 파드 정지 +3. 노드 상실 ← kc-lab-2 (agent) 를 죽인다. kc-lab-1 이 아니다 +``` + +각 실험 전후로 같은 PromQL을 실행해 대조한다. + +## 참고 + +| 문서 | 관계 | +|---|---| +| [`keycloak-multinode-cluster.md`](keycloak-multinode-cluster.md) | 관측 대상의 구성 | +| [`session-lab-concepts.md`](session-lab-concepts.md) | Prometheus·RBAC·DaemonSet 등 개념 | +| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 로드맵 10번 | +| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | `GF_SERVER_ROOT_URL`이 필요한 이유 | diff --git a/docs/session-lab-concepts.md b/docs/session-lab-concepts.md index 2f568b4..3cf98e6 100644 --- a/docs/session-lab-concepts.md +++ b/docs/session-lab-concepts.md @@ -2826,17 +2826,585 @@ SSH 공개키 두 줄이다. 공개키 자체는 비밀이 아니지만, **저 --- +## 10층. 쿠버네티스 리소스 — 이 실험대에서 실제로 쓴 것들 + +5층이 k3s 자체라면 여기는 그 위에 올린 리소스들이다. + +### 워크로드 세 종류 — 무엇을 언제 쓰는가 + +| | 보장하는 것 | 이 실험대에서 | +|---|---|---| +| **Deployment** | 파드 N개를 유지. 이름은 매번 바뀐다 | postgres, grafana, prometheus, echo | +| **StatefulSet** | **안정된 이름**(`-0`, `-1`)과 순서 | **keycloak** | +| **DaemonSet** | **노드마다 정확히 하나** | node-exporter, svclb | + +**StatefulSet을 Keycloak에 쓴 이유** — Infinispan이 **파드 이름 + 랜덤 접미사**를 +클러스터 노드 식별자로 쓴다(`keycloak-0-49501`). Deployment면 이름이 +`keycloak-7d9f8b-x4k2p`처럼 매번 달라져서, 로그와 `JGROUPS_PING` 테이블을 +대조하기가 어려워진다. + +**`podManagementPolicy`** + +| 값 | 동작 | +|---|---| +| `OrderedReady` (기본) | `-0`이 Ready가 된 뒤에야 `-1`을 만든다 | +| **`Parallel`** | **동시에 시작한다** | + +이 실험대는 `Parallel`을 쓴다. 두 파드가 **동시에 클러스터 등록을 시도하는 것**이 +운영에서 실제로 일어나는 상황이기 때문이다. + +**DaemonSet을 node-exporter에 쓴 이유** — replica 수를 지정하지 않는다. +노드가 늘면 자동으로 늘고, 줄면 준다. **죽을 노드에도 반드시 있어야** +꺼지기 직전의 마지막 샘플이 남는다. + +```bash +kubectl get deploy,sts,ds -A +``` + +### 저장소 — PVC · PV · StorageClass + +``` + PersistentVolumeClaim (PVC) "5Gi 짜리 읽기쓰기 볼륨을 주세요" ← 요청 + │ storageClassName: local-path + ▼ + StorageClass 어떻게 만들지 아는 프로비저너 + │ + ▼ + PersistentVolume (PV) 실제로 만들어진 볼륨 ← 결과 +``` + +**PVC는 요청서, PV는 실물이다.** 파드는 PVC 이름만 알면 되고, 그 뒤가 +로컬 디스크인지 NFS인지 클라우드 블록 스토리지인지 몰라도 된다. + +**`accessModes`** + +| 값 | 의미 | +|---|---| +| **`ReadWriteOnce` (RWO)** | **한 노드에서만** 읽기/쓰기 | +| `ReadOnlyMany` | 여러 노드에서 읽기만 | +| `ReadWriteMany` | 여러 노드에서 읽기/쓰기 (NFS 등) | + +**RWO가 `strategy: Recreate`를 강제한다.** 기본값 `RollingUpdate`는 새 파드를 +띄운 뒤 옛 파드를 내리는데, RWO 볼륨은 **두 파드가 동시에 마운트할 수 없어서** +새 파드가 영원히 Pending에 머문다. + +```yaml +strategy: + type: Recreate # 옛 파드를 먼저 내리고 새 파드를 띄운다 +``` + +**k3s의 `local-path` 프로비저너 — 볼륨이 노드에 못박힌다** + +```json +"nodeAffinity": { + "required": { "nodeSelectorTerms": [{ + "matchExpressions": [{ "key": "kubernetes.io/hostname", "values": ["kc-lab-2"] }] + }]} +} +경로: /var/lib/rancher/k3s/storage/pvc-__ +``` + +**그 노드의 로컬 디스크에 디렉터리를 만드는 것이 전부**다. 따라서 +**PVC를 쓰는 파드는 그 노드를 벗어날 수 없다.** + +| 결과 | | +|---|---| +| 노드가 죽으면 | **파드가 다른 노드로 재배치되지 못한다** | +| 실험 관점 | **결함이 아니라 조건이다.** "DB가 있는 노드가 죽으면"이 의미를 갖는다 | + +```bash +kubectl get pvc -A +kubectl get pv +kubectl get pv -o jsonpath='{.spec.nodeAffinity}' | python3 -m json.tool +``` + +### Secret — 감춰지지 않는다 + +```yaml +kind: Secret +type: Opaque +stringData: + POSTGRES_PASSWORD: lab-postgres-change-me +``` + +`stringData`는 평문으로 쓰고 쿠버네티스가 base64로 인코딩해 저장한다. +`data`는 직접 base64로 넣는다. + +**base64는 암호화가 아니라 인코딩이다.** + +```bash +kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d +``` + +한 줄로 읽힌다. etcd에도 그대로 들어 있다. + +| 그래도 Secret을 쓰는 이유 | | +|---|---| +| RBAC로 접근을 나눌 수 있다 | ConfigMap과 별도로 권한 관리 | +| 로그·`describe`에 값이 안 찍힌다 | 사고로 노출될 확률이 준다 | +| 볼륨·env 주입 방식이 표준화된다 | | + +**진짜 보호는 별도 계층이다** — SealedSecret, 외부 KMS, 또는 클라우드 +시크릿 매니저. 로드맵 11번의 주제다. + +### RBAC — ServiceAccount · ClusterRole · Binding + +Prometheus가 쿠버네티스 API에 물어서 타깃을 찾으려면 **읽기 권한**이 필요하다. + +``` + ServiceAccount 파드가 쓰는 신원 (누구인가) + │ + ClusterRoleBinding 신원과 권한을 잇는다 + │ + ClusterRole 무엇을 할 수 있는가 (리소스 × 동사) +``` + +```yaml +rules: + - apiGroups: [""] + resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods] + verbs: [get, list, watch] +``` + +**`Role`과 `ClusterRole`의 차이** — `Role`은 한 네임스페이스 안에서만, +`ClusterRole`은 클러스터 전체에서 유효하다. 노드는 네임스페이스에 속하지 +않으므로 **노드를 읽으려면 반드시 `ClusterRole`**이다. + +**서브리소스가 따로 있다 — 실제로 걸린 함정** + +`nodes`, `nodes/metrics`, `nodes/proxy`는 **서로 다른 권한**이다. + +``` +/api/v1/nodes//proxy/metrics + ───── + 이 경로에는 nodes/proxy 가 필요 +``` + +`nodes/proxy`를 빠뜨렸을 때 kubelet 타깃만 **403 Forbidden**으로 실패하고 +나머지 잡은 전부 정상이었다. **부분 실패라 `rollout status`는 성공이라고 +말한다.** 타깃 목록을 직접 봐야 드러난다. + +```bash +kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus +kubectl describe clusterrole prometheus +``` + +### 배치 제어 — nodeSelector · 라벨 · taint + +```yaml +nodeSelector: + node-role.kubernetes.io/control-plane: "true" +``` + +**호스트 이름 대신 역할 라벨을 쓴다.** `kubernetes.io/hostname: kc-lab-1`로 +못박으면 노드 이름이 바뀔 때 깨지고, **왜 거기 두는지가 드러나지 않는다.** + +k3s는 server 노드에 `node-role.kubernetes.io/control-plane=true`를 붙인다. + +```bash +kubectl get nodes --show-labels +kubectl get nodes -l node-role.kubernetes.io/control-plane=true +``` + +**taint와 toleration** + +| | | +|---|---| +| **taint** | 노드에 붙는 "여기 오지 마" 표시 | +| **toleration** | 파드가 갖는 "그래도 갈 수 있음" 면제권 | + +```yaml +tolerations: + - operator: Exists # 어떤 taint 든 무시한다 +``` + +node-exporter에 이걸 주는 이유는 **관측이 빠지는 노드가 있으면 안 되기** +때문이다. taint가 걸린 노드에서도 떠야 한다. + +**배치를 정하는 세 수단의 차이** + +| 수단 | 성격 | +|---|---| +| `nodeSelector` | **반드시** 그 라벨의 노드에 | +| `topologySpreadConstraints` | **골고루** 퍼뜨린다 | +| taint / toleration | 노드가 **거부**하고 파드가 **면제**받는다 | + +### k3s server와 agent — 죽였을 때가 다르다 + +```bash +kubectl get nodes -o custom-columns=\ +'NODE:.metadata.name,CP:.metadata.labels.node-role\.kubernetes\.io/control-plane' +``` + +| | kc-lab-1 (**server**) | kc-lab-2 (**agent**) | +|---|---|---| +| 실행 | API 서버 · 스케줄러 · etcd(SQLite) | kubelet · containerd | +| 이 실험대에서 | keycloak-1 · traefik · **coredns** · metrics-server · local-path-provisioner | keycloak-0 · postgres | +| 죽이면 | **`kubectl`이 안 된다. DNS·인그레스도 사라진다** | 클러스터 제어는 살아 있다 | + +**노드 상실 실험은 agent를 죽이는 것이다.** server를 죽이는 것은 노드 상실이 +아니라 **컨트롤 플레인 상실**이며 성격이 완전히 다르다. + +이 사실을 모르고 "keycloak 하나만 있는 노드를 죽이자"고 계획했다가 +실제 배치를 조회한 뒤 정정했다. + +--- + +## 11층. Keycloak 클러스터링 내부 — Infinispan과 JGroups + +### 두 층으로 되어 있다 + +``` + Infinispan 분산 캐시. "세션을 어디에 두고 어떻게 복제할까" + │ + JGroups 그룹 통신. "누가 멤버이고 어떻게 메시지를 주고받을까" + │ + TCP 7800 실제 소켓 +``` + +Keycloak은 Infinispan을 쓰고, Infinispan은 JGroups 위에서 돈다. +로그의 `org.infinispan.CLUSTER`와 `vendor_jgroups_*` 지표가 각각 이 두 층이다. + +### 디스커버리와 트랜스포트는 다른 경로다 + +**이것이 이 실험대를 2노드로 만든 이유다.** + +| 단계 | 경로 | 끊기면 | +|---|---|---| +| **디스커버리** — 서로를 찾는다 | PostgreSQL `JGROUPS_PING` 테이블 | 상대의 존재를 모른다 | +| **트랜스포트** — 실제로 대화한다 | **TCP 7800** | **DB엔 등록되는데 클러스터가 안 붙는다** | + +`JGROUPS_PING` 한 테이블에 두 메커니즘이 다 보인다. + +``` + name | cluster_name | ip | coord +------------------+--------------+-----------------+------- + keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f + keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t + ───────────────────────────── ──── ─ + 디스커버리 결과 트랜스포트 경로 코디네이터 +``` + +전체 스키마는 `address / name / cluster_name / ip / coord / last_update / +coordinated_by`이고 기본키는 `address`다. + +> 오래된 자료에는 `own_addr`, `ping_data` 같은 컬럼명이 나오지만 Keycloak 26의 +> 실제 스키마는 위와 같다. 쿼리 전에 `\d jgroups_ping`으로 확인한다. + +**`jdbc-ping`을 쓰는 이유** — 예전에는 UDP 멀티캐스트로 서로를 찾았다. +쿠버네티스나 클라우드에서는 멀티캐스트가 막혀 있는 경우가 많아, +**이미 있는 데이터베이스를 게시판처럼 쓰는** 방식으로 바뀌었다. +Keycloak 26의 기본값이다. + +### 코디네이터 + +`coord = t` 인 노드가 **코디네이터**다. 뷰 변경을 확정하고 리밸런싱을 +주도한다. 특별한 권한이 아니라 **역할**이며, 그 노드가 사라지면 남은 멤버가 +인계받는다. + +실험대를 전원 종료했다 켰을 때 코디네이터가 `keycloak-1` → `keycloak-0`으로 +바뀌는 것을 관찰했다. **먼저 뜬 쪽이 맡는다.** + +### 클러스터 뷰 + +``` +ISPN000094: Received new cluster view for channel ISPN: + [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938, keycloak-0-49501] + ─────────────────────────── ─ ─ ──────────────────────────────────── + 뷰를 만든 코디네이터 뷰 ID 멤버 수 멤버 목록 +``` + +**뷰(view)는 "지금 이 순간의 멤버 명단"** 이다. 멤버가 들어오거나 나가면 +새 뷰가 발행되고 뷰 ID가 올라간다. + +| 로그 코드 | 의미 | +|---|---| +| `ISPN000094` | 새 클러스터 뷰를 받았다 | +| `ISPN000079` | 자기 주소와 물리 주소(7800) | +| `ISPN100000` | 노드가 합류했다 | + +```bash +kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000' +``` + +### 주요 JGroups 프로토콜 — 지표 이름에 그대로 나온다 + +| 프로토콜 | 하는 일 | 관련 지표 | +|---|---|---| +| **GMS** (Group Membership Service) | 멤버십 관리, 뷰 발행 | `vendor_jgroups_gms_*` | +| **FD_SOCK2** (Failure Detection) | **TCP 소켓으로 상대 생존 감시** | `..._get_num_suspected_members` | +| **MERGE3** | **split brain 후 다시 합치기** | `..._merge3_get_views` | +| **NAKACK2** | 신뢰성 있는 메시지 전달, 재전송 | `..._nakack2_*` | +| **TCP** | 트랜스포트 | `..._tcp_*` | + +**7800을 막으면 FD_SOCK2가 먼저 반응한다.** 소켓 연결이 끊기면 상대를 +suspect 하고, GMS가 그 멤버를 뷰에서 제외한다. 각자 자기만 있는 뷰가 되면 +**split brain**이고, 통신이 복구되면 MERGE3가 합친다. + +### 세션은 어디에 있는가 — 두 곳 다 + +Keycloak 26의 기본값 `persistent-user-sessions`에서는 + +| 저장소 | 역할 | +|---|---| +| **PostgreSQL** | **진실의 원천.** 재시작에도 살아남는다 | +| **Infinispan** | 캐시 + 노드 간 실시간 전파 | + +`--features-disabled=persistent-user-sessions`로 끄면 Infinispan만 남는 +**volatile** 모드가 되고, 그때는 캐시가 곧 진실의 원천이다. +이 둘의 차이가 로드맵 2번의 주제다. + +--- + +## 12층. 관측성 — Prometheus의 구조 + +### 세 부분으로 되어 있다 + +``` + 수집(scrape) ──▶ 저장(TSDB) ──▶ 질의(PromQL) + 15초마다 로컬 디스크 Grafana 또는 API + HTTP GET /metrics 시계열 +``` + +**Prometheus는 pull 방식이다.** 대상이 보내주는 것이 아니라 Prometheus가 +주기적으로 `/metrics`를 긁어간다. + +| 결과 | | +|---|---| +| 대상이 죽으면 | 긁기가 실패하고 **`up`이 0이 된다** — 죽은 사실 자체가 데이터가 된다 | +| 방화벽 방향 | Prometheus → 대상. 대상이 Prometheus 주소를 알 필요가 없다 | +| 짧은 작업 | 긁히기 전에 끝나면 잡히지 않는다 (Pushgateway가 필요한 경우) | + +### exporter 패턴 + +애플리케이션이 Prometheus 형식을 모를 때, **번역기**를 옆에 둔다. + +| exporter | 무엇을 노출하는가 | +|---|---| +| **node-exporter** | 머신 — CPU, 메모리, 디스크, 네트워크 | +| kube-state-metrics | 쿠버네티스 오브젝트 상태 | +| postgres-exporter | PostgreSQL 내부 통계 | + +**Keycloak과 Traefik은 exporter가 필요 없다.** 자체적으로 Prometheus 형식 +엔드포인트를 제공한다(`KC_METRICS_ENABLED=true`). + +### 서비스 디스커버리 — 타깃을 적어두지 않는다 + +```yaml +kubernetes_sd_configs: + - role: endpoints + namespaces: { names: [keycloak-lab] } +``` + +**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켜니 모든 파드가 +새 주소를 받았다(`10.42.1.22` → `10.42.1.25`). 정적 목록은 그때마다 깨진다. + +`role`에 따라 무엇을 찾을지가 달라진다. + +| role | 찾는 것 | +|---|---| +| `endpoints` | 서비스 뒤의 실제 파드들 ← 애플리케이션 지표 | +| `node` | 노드 | +| `pod` | 파드 직접 | +| `service` | 서비스 | + +### relabel — 걸러내고 이름을 붙인다 + +디스커버리는 **전부 다** 가져온다. 그중 필요한 것만 남기는 것이 relabel이다. + +```yaml +relabel_configs: + - source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name] + action: keep + regex: keycloak-headless;management + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod +``` + +| `action` | 하는 일 | +|---|---| +| `keep` | regex에 맞는 것만 남긴다 | +| `drop` | 맞는 것을 버린다 | +| `replace` (기본) | 라벨 값을 만든다 | +| `labelmap` | 메타 라벨을 일반 라벨로 복사 | + +**`__`로 시작하는 라벨은 내부용**이며 저장되지 않는다. `__meta_*`는 +디스커버리가 붙여준 정보이고, 필요하면 `target_label`로 옮겨야 남는다. + +**`pod`과 `node` 라벨을 붙이는 것이 실험에서 결정적이다.** 없으면 +"어느 파드가, 어느 노드에서"에 답할 수 없다. + +### 메트릭 타입 + +| 타입 | 성질 | 예 | +|---|---|---| +| **counter** | **누적. 줄지 않는다** (재시작 시 0으로) | `..._requests_total` | +| **gauge** | 오르내린다 | `node_memory_MemAvailable_bytes` | +| **histogram** | 구간별 분포 + 합계 + 개수 | `..._seconds_bucket/_sum/_count` | +| summary | 분위수를 클라이언트가 계산 | | + +**counter는 그대로 보면 의미가 없다.** 변화율을 봐야 한다. + +```promql +rate(http_requests_total[5m]) +``` + +**histogram은 세 지표가 한 벌**이다. `_bucket`으로 분위수를 계산한다. + +```promql +histogram_quantile(0.95, rate(keycloak_session_expiration_task_seconds_bucket[5m])) +``` + +### `up` — 가장 중요한 합성 지표 + +```promql +up +up{job="keycloak"} +``` + +Prometheus가 **직접 만드는** 지표다. 긁기에 성공하면 1, 실패하면 0. + +**장애 실험에서 이것이 핵심인 이유** — 다른 지표는 대상이 죽으면 **사라진다.** +사라진 데이터로는 "언제부터 죽었나"를 알 수 없다. `up`은 **0이라는 값으로 +남기 때문에** 사후에 시각을 특정할 수 있다. + +```promql +up == 0 # 지금 죽은 타깃 +changes(up[1h]) # 1시간 동안 몇 번 오르내렸나 +min_over_time(up[10m]) # 10분 중 한 번이라도 죽었나 +``` + +### TSDB와 보존 기간 + +```yaml +--storage.tsdb.path=/prometheus +--storage.tsdb.retention.time=7d +``` + +로컬 디스크에 시계열로 저장한다. **보존 기간이 지나면 삭제**되므로 볼륨이 +무한히 커지지 않는다. + +`emptyDir`에 두면 파드 재시작 시 **실험 기록이 통째로 사라진다.** +사후 추적이 목적이면 PVC여야 한다. + +### 관측 시스템의 장애 도메인 + +**관측 시스템은 관측 대상과 같이 죽으면 안 된다.** 죽는 순간을 기록해야 +하는데 같이 죽으면 기록이 없다. + +노드가 둘뿐인 실험대에서는 완전히 피할 수 없으므로 **규칙으로 정한다.** + +``` +kc-lab-1 (server) 관측 스택을 둔다. 죽이지 않는다 +kc-lab-2 (agent) 장애 주입 대상 +``` + +`nodeSelector`로 못박아 실험이 재현 가능하게 만든다. + +--- + +## 13층. 가상화 운영 — 실행 중 바꾸는 것들 + +### VM 메모리 재배분 — 게스트를 다시 만들지 않는다 + +```bash +virsh setmaxmem kc-lab-1 5120M --config +virsh setmem kc-lab-1 5120M --config +``` + +| 명령 | 바꾸는 것 | +|---|---| +| `setmaxmem` | **상한**. 부팅 시 게스트가 보는 총량 | +| `setmem` | **현재 할당**. 상한 이하여야 한다 | + +**순서가 중요하다.** 현재값을 상한보다 크게 줄 수 없으므로 `setmaxmem`이 +먼저다. + +| 플래그 | 적용 범위 | +|---|---| +| `--config` | 영구 정의. **다음 부팅부터** | +| `--live` | 실행 중인 도메인에 즉시 | +| 둘 다 | 지금과 앞으로 | + +`setmaxmem --live`는 대개 거부된다 — 게스트가 부팅 시 메모리 맵을 정하기 +때문이다. **상한을 바꾸려면 게스트를 껐다 켜야 한다.** + +```bash +virsh dominfo kc-lab-1 | grep -i memory +ssh kc-lab-1 free -m # 게스트가 실제로 인식한 값 +``` + +호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다. +**게스트 재생성이나 디스크 조작은 전혀 필요 없었다.** + +### 안전한 종료 순서 + +전원을 내리기 전에 **위에서부터** 정리한다. + +```bash +# 1. 애플리케이션 — 클러스터에서 정상 탈퇴 +kubectl -n keycloak-lab scale statefulset/keycloak --replicas=0 +kubectl -n keycloak-lab wait --for=delete pod -l app=keycloak --timeout=120s + +# 2. 데이터베이스 — 마지막에, 충분한 시간을 주고 +kubectl -n keycloak-lab scale deployment/postgres --replicas=0 +kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s + +# 3. 게스트 — ACPI 정상 종료 +virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2 + +# 4. 호스트 +sudo systemctl poweroff +``` + +**왜 순서가 중요한가** — `virsh shutdown`은 게스트 systemd가 k3s를 멈추고, +k3s가 컨테이너에 SIGTERM을 보낸다. 유예 시간이 짧으면 **PostgreSQL이 +강제 종료되어 다음 기동에 crash recovery가 돈다.** 미리 내려두면 그 위험이 +없다. + +**clean shutdown 확인** + +```bash +ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid' +``` + +**`postmaster.pid`가 남아 있지 않아야 정상**이다. 남아 있으면 비정상 종료였고 +다음 기동에 복구 절차가 실행된다. + +### 복구 순서 — 종료의 역순 + +```bash +virsh start kc-lab-1 && virsh start kc-lab-2 +kubectl get nodes # Ready 2개 대기 +kubectl -n keycloak-lab scale deployment/postgres --replicas=1 +kubectl -n keycloak-lab rollout status deployment/postgres +kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2 +``` + +**PostgreSQL이 먼저다.** Keycloak이 DB 없이 뜨면 기동에 실패한다. + +**스케일을 0으로 내려두면 자동으로 복구되지 않는다.** 명시적으로 올려야 한다. + +--- + ## 아직 기록하지 않은 개념 -실험 설계 단계에서 아래 항목을 이 문서에 추가한다. +실험을 진행하면서 이 문서에 추가한다. -- Infinispan, `DIST_SYNC`, `numOwners`, 캐시별 설정 -- JGroups, `JDBC_PING`, 디스커버리와 트랜스포트의 분리, TCP 7800 -- `persistent-user-sessions` / `volatile-user-sessions` -- 원격 Infinispan(Hot Rod)과 multi-site -- refresh token rotation, revoke, max reuse, 동시 갱신 경쟁 +- `persistent-user-sessions` / `volatile-user-sessions` 의 실제 차이 (로드맵 2번) +- refresh token rotation·revoke·max reuse 와 동시 갱신 경쟁 (로드맵 5번) - SSO 세션 vs 애플리케이션 세션, `KEYCLOAK_IDENTITY`, `AUTH_SESSION_ID` - 백채널 로그아웃과 `sid` 역인덱스 -- 쿠키 `Secure` / `SameSite` / `HttpOnly` - Redis 영속화(RDB/AOF)와 세션 복구 -- `tc netem`, OOM killer와 `oom_score`, fsync와 페이지 캐시 +- Spring Session / `OAuth2AuthorizedClientService` 의 저장 구조 +- `tc netem` 지연 주입 +- OOM killer 와 `oom_score` +- fsync 와 페이지 캐시, EBS IOPS + +### 이번에 채운 것 (2026-09-04) + +10~13층으로 기록 완료 — StatefulSet·DaemonSet, PVC/PV/StorageClass, +Secret, RBAC 와 서브리소스, nodeSelector·taint, k3s server/agent 차이, +Infinispan·JGroups(디스커버리 vs 트랜스포트, GMS/FD_SOCK2/MERGE3), +Prometheus(pull·SD·relabel·메트릭 타입·`up`·TSDB), VM 메모리 재배분, +안전한 종료·복구 순서.