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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5cc2b55a9
commit
006da7d490
@@ -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/<name>/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`이 필요한 이유 |
|
||||
Reference in New Issue
Block a user