Merge pull request #4 from paasup/docs/tenant-onboarding-guide

docs: 테넌트 온보딩 가이드 (VictoriaMetrics + Perses 멀티테넌시)
This commit is contained in:
wbsong111
2026-07-09 16:14:04 +09:00
committed by GitHub
+176
View File
@@ -0,0 +1,176 @@
# 테넌트 온보딩 가이드 (VictoriaMetrics + Perses 멀티테넌시)
새 테넌트를 모니터링 스택에 추가할 때 **생성해야 하는 자원**과 **유저 권한 설정 방법**을 정리한다. dev 클러스터에서 demo01(accountID 1)·demo02(accountID 2)로 실측 검증한 결과를 기반으로 한다.
> 관련 문서: [victoria-metrics-architecture.md](victoria-metrics-architecture.md)(구조·멀티테넌시), [monitoring-deploy-guide.md](monitoring-deploy-guide.md)(배포 런북).
## 1. 멀티테넌시 모델 요약
| 축 | 격리 방식 |
|---|---|
| 메트릭 | vmauth가 URL 경로 `/select/{accountID}/` 로 라우팅 (쓰기: vmagent per-URL `/insert/{accountID}/`) |
| 로그 | vmauth가 `AccountID` 헤더 주입 (쓰기: otelcol이 `AccountID`/`ProjectID` 헤더로 전송) |
| 유저별 구분 | **Perses RBAC(프로젝트 접근) + 프로젝트별 데이터소스(테넌트 자격증명)** |
핵심: **테넌트 = accountID**. Perses는 로그인 유저의 토큰을 데이터소스로 전달하지 않고 **프로젝트 데이터소스에 박힌 고정 자격증명(테넌트 SA)** 으로 조회한다. 따라서 유저가 어떤 테넌트를 보는지는 **그 유저가 접근 가능한 프로젝트(RBAC)** 가 결정한다.
```
유저 로그인(OIDC) → Perses RBAC(RoleBinding) → 접근 가능한 프로젝트
→ 프로젝트 데이터소스(테넌트 accountID 고정)
→ vmauth → 해당 테넌트 메트릭/로그만
```
## 2. 테넌트 추가 시 생성 자원 (체크리스트)
새 테넌트 `<T>` (accountID `<N>`, 네임스페이스 접두 `<T>-*`) 추가 시:
| # | 계층 | 자원 | 담당 | 재배포 |
|---|------|------|------|--------|
| 1 | Keycloak | 데이터소스용 SA client `perses-vmauth-<T>` (+vm-access scope, SA attr accountID=`<N>`) | dip-console | - |
| 2 | Keycloak | (직접 API 접근 유저용) user attribute `vm_metrics_account_id`/`vm_logs_account_id`=`<N>` | dip-console | - |
| 3 | vmagent | remoteWrite `/insert/<N>/` + `urlRelabelConfig` + relabel ConfigMap 키 `<T>.yaml`(keep namespace=~`<T>-.*`) | 카탈로그/dip-console | **vmagent 재배포** |
| 4 | otelcol | `filter/<T>`(namespace 정규식) + exporter `otlphttp/vlogs-<N>`(AccountID/ProjectID 헤더) + pipeline `logs/<T>` | 카탈로그 | **otelcol 재배포** |
| 5 | Perses | Project `<T>` | dip-console | - |
| 6 | Perses | Secret `<T>-vmauth`(OAuth → SA client) | dip-console | - |
| 7 | Perses | Datasource `victoriametrics`(메트릭) + `victorialogs`(로그) | dip-console | - |
| 8 | Perses | Role `viewer` + RoleBinding(테넌트 유저) | dip-console | - |
| 9 | Perses | Dashboard N종(프로젝트별 복제) | dip-console | - |
> **같은 테넌트의 새 네임스페이스**(`<T>-*` 추가)는 정규식이 커버 → **무변경**. 변경이 필요한 건 **새 테넌트(새 accountID) 추가** 시뿐이다.
## 3. 계층별 상세
### 3.1 Keycloak (paasup realm) — 데이터소스 자격증명
Perses 데이터소스는 테넌트별 **service-account client**(client_credentials)로 vmauth에 접근한다. 그 토큰의 `vm_access` 클레임이 accountID를 담는다.
```sh
# client perses-vmauth-<T> (serviceAccountsEnabled) + vm-access scope 연결
# + SA user attribute: vm_metrics_account_id=<N>, vm_logs_account_id=<N>
# 절차는 victoria-metrics-auth/CUSTOM-README §3.2 (perses-vmauth 패턴) 동일, clientId만 테넌트별.
```
> 검증: 토큰 디코드 시 `vm_access:{metrics_account_id:<N>, logs_account_id:<N>}` 확인.
### 3.2 vmagent — 메트릭 쓰기 경로 (per-URL)
[victoria-metrics-agent/custom-values.yaml](../manifests/helm/victoria-metrics-agent/0.40.0/custom-values.yaml) `remoteWrite`에 테넌트 URL 추가 + `vmagent-relabel-configs` ConfigMap에 keep 규칙:
```yaml
# remoteWrite 추가
- url: "http://...vminsert...:8480/insert/<N>/prometheus/api/v1/write"
urlRelabelConfig: /relabel/<T>.yaml
# relabel ConfigMap 키 <T>.yaml
- action: keep
source_labels: [namespace]
regex: "<T>-.*"
```
⚠️ **`[]`(빈 규칙)은 격리가 아니다** — keep 규칙 없으면 전 accountID에 기록됨. 새 accountID 추가 시 **vmagent 재배포** 필요.
### 3.3 otelcol — 로그 쓰기 경로
[opentelemetry-collector/custom-values.yaml](../manifests/helm/opentelemetry-collector/0.156.2/custom-values.yaml)에 테넌트 파이프라인 추가:
```yaml
processors:
filter/<T>:
logs: { log_record: ['not IsMatch(attributes["namespace"], "^<T>-.*")'] }
exporters:
otlphttp/vlogs-<N>:
endpoint: "http://...vlinsert...:9481/insert/opentelemetry"
headers: { AccountID: "<N>", ProjectID: "0" } # ⚠️ VictoriaLogs 인식 헤더는 AccountID/ProjectID
service:
pipelines:
logs/<T>: { receivers: [filelog], processors: [memory_limiter, k8sattributes, filter/<T>, batch], exporters: [otlphttp/vlogs-<N>] }
```
> ⚠️ 헤더는 반드시 `AccountID`/`ProjectID`. `VictoriaLogs-AccountID`는 무시되어 전량 account 0으로 적재된다(실측 확인).
### 3.4 Perses — 프로젝트/데이터소스/RBAC/대시보드
> ⚠️ **`Datasource`/`Project`/`Secret`/`Role`/`RoleBinding`/`Dashboard`는 Kubernetes CRD가 아니라 Perses 자체 리소스**다(Perses DB에 저장). `kubectl apply` 대상이 아니다. 배포 경로:
> - **사이드카 provisioning(권장)**: 아래 리소스들을 **K8s ConfigMap의 `data:` 값**으로 넣고 ConfigMap에 라벨 `perses.dev/resource: "true"` → `perses-provisioning-sidecar`가 `/etc/perses/provisioning/`에 기록 → Perses가 DB로 로드(10분 간격 upsert, **prune 없음**).
> - **Perses REST API**: `POST /api/v1/projects/{project}/datasources` 등. / **percli**: `percli apply -f`.
> - (대안) perses-operator를 쓰면 `PersesDatasource` 등 진짜 CRD가 생기나, 본 스택은 Helm 차트 + 사이드카 방식이라 **CRD 없음**.
> **ConfigMap 구성 (테넌트당 분리 권장)**: 전역 리소스(GlobalRole·globalAdmin)는 **bootstrap ConfigMap**, 테넌트별 리소스는 **테넌트당 ConfigMap 1개**(`perses-tenant-<T>`, 라벨 `paasup.io/tenant: <T>`)로 관리한다. 온보딩=ConfigMap 생성, 오프보딩=ConfigMap 삭제(§5). 테넌트 변경이 다른 테넌트/전역 설정을 건드리지 않는다.
데이터소스 이름은 대시보드가 참조하는 **`victoriametrics`/`victorialogs`** 로 맞춘다. 아래는 ConfigMap `data` 값(또는 API 바디)에 넣는 **Perses 리소스**다(전체 예시는 검증 매니페스트 `tenant-verification/manifests/perses-tenant-demo01.yaml`의 ConfigMap 래핑 참조):
```yaml
# ↓ 아래는 K8s 매니페스트가 아니라, perses.dev/resource:true ConfigMap 의 data 값 (또는 API 바디)
kind: Project
metadata: { name: <T> }
---
kind: Secret # 테넌트 OAuth (perses-vmauth-<T>)
metadata: { name: <T>-vmauth, project: <T> }
spec: { oauth: { clientID: perses-vmauth-<T>, clientSecret: "<from-infisical>",
tokenURL: "https://keycloak.example.org/realms/paasup/protocol/openid-connect/token", scopes: ["openid"] } }
---
kind: Datasource # 메트릭
metadata: { name: victoriametrics, project: <T> }
spec: { default: true, plugin: { kind: PrometheusDatasource, spec: { proxy: { kind: HTTPProxy, spec: {
url: "http://...vmauth...:8427", allowedEndpoints: [{endpointPattern: "/api/v1/.*", method: GET},{endpointPattern: "/api/v1/.*", method: POST}], secret: <T>-vmauth } } } } }
---
kind: Datasource # 로그
metadata: { name: victorialogs, project: <T> }
spec: { default: false, plugin: { kind: VictoriaLogsDatasource, spec: { proxy: { kind: HTTPProxy, spec: {
url: "http://...vmauth...:8427", allowedEndpoints: [{endpointPattern: "/select/logsql/.*", method: GET},{endpointPattern: "/select/logsql/.*", method: POST}], secret: <T>-vmauth } } } } }
---
kind: Role
metadata: { name: viewer, project: <T> }
spec: { permissions: [{ actions: ["read"], scopes: ["*"] }] }
---
kind: RoleBinding
metadata: { name: <T>-viewer-binding, project: <T> }
spec: { role: viewer, subjects: [{ kind: User, name: "<preferred_username>" }] }
```
- **대시보드 (테넌트 적정 정리)**: `monitoring` 프로젝트 대시보드를 `metadata.project``<T>`로 바꿔 복제하되, **테넌트 계정에 데이터가 있는 것만** 넣는다. 데이터소스가 프로젝트 스코프로 해석되어 자동으로 테넌트 accountID를 쓰므로, 내용 수정 없이 복제하면 테넌트 데이터만 렌더된다.
-**테넌트에 복제**: `k8s-workloads`, `k8s-pod-diagnostics`, `k8s-pod-history` (namespace/pod/workload 스코프 — 테넌트 계정에 존재. 검증: `kube_pod_info`/`container_*`/`kube_deployment_*` > 0)
-**테넌트에서 제외**: `k8s-node-overview` (node-exporter 메트릭은 **namespace가 없어** 테넌트 계정이 아니라 platform 계정(9000/0)에 적재 → 테넌트에선 빈 화면. 검증: acct1에서 `node_uname_info`=0). 노드/클러스터 레벨 대시보드는 **platform(`monitoring`) 프로젝트에만** 둔다.
- **namespace 변수(권한 있는 네임스페이스만 보이기)**: 대시보드의 `namespace` 변수(`label_values(namespace)`)는 **데이터소스 계정에 존재하는 namespace만** 반환한다 → 테넌트 데이터소스(accountID 격리)를 쓰면 **자동으로 그 테넌트의 namespace만** 표시된다(별도 필터 불필요). 검증: 정리된 acct1에서 `label_values(namespace)` = `['demo01-nginx']`.
- ⚠️ **인덱스 오염 주의**: 어느 시점에 keep 규칙 없이(`[]`) 메트릭이 그 계정에 적재된 적이 있으면, **label 인덱스에 외부 namespace가 남아** 변수 드롭다운에 전부 표시된다(데이터 쿼리는 5분 lookback이라 안 보여도 인덱스는 남음). retention까지 유지되거나 수동 삭제 필요.
- **정리**: vmselect에 `POST /delete/<N>/prometheus/api/v1/admin/tsdb/delete_series?match[]={namespace!~"<T>-.*"}` 로 외부 시리즈 제거(검증됨: 삭제 후 변수가 테넌트 namespace만 표시). 애초에 keep 규칙이 처음부터 올바르면 오염이 없어 정리도 불필요.
- **provisioning 특성**: 사이드카 ConfigMap → 10분 간격 upsert. **prune 안 함**(ConfigMap 삭제해도 DB 잔존 → 삭제는 API DELETE).
## 4. 유저 권한 부여 (핵심)
Perses는 **OIDC로 인증만** 받고 **권한은 OIDC group/claim에서 자동 동기화하지 않는다.** 권한은 Perses 내부 RBAC로만 부여한다.
- **로그인 식별자 = OIDC `preferred_username`** (v0.53.1 실측).
- **부여 방법**: 테넌트 프로젝트의 `RoleBinding.spec.subjects``{ kind: User, name: "<preferred_username>" }` 추가. (바인딩 없는 유저는 `guest_permissions` 미설정 → **fail-closed, 아무것도 못 봄**.)
- **global admin**: `sidecar.globalAdminUsers`(예: `paasup`) → GlobalRoleBinding으로 전 프로젝트 접근.
- 유저는 최초 OIDC 로그인 시 Perses에 생성되며, RoleBinding은 로그인 전에 미리 만들어 둬도 로그인 시점에 매칭된다.
예시 — 기존 테넌트에 OIDC 유저 `alice` 추가:
```sh
# demo01 프로젝트 viewer 바인딩에 subject 추가 (Perses API 또는 provisioning ConfigMap)
# subjects: [..., { kind: User, name: "alice" }]
```
> 검증: native 유저로 RBAC 강제 확인됨(테넌트 유저는 자기 프로젝트만, 타 프로젝트 403). OIDC 유저도 동일 메커니즘 — 식별자만 preferred_username.
## 5. 테넌트 오프보딩 (삭제)
Perses provisioning은 **prune를 하지 않으므로** ConfigMap 삭제만으론 Perses DB에서 안 지워진다. 2단계로 정리한다.
1. **재프로비저닝 중단**: `kubectl delete configmap perses-tenant-<T> -n monitoring` (+ vmagent relabel 키·otelcol pipeline 제거 후 재배포)
2. **Perses DB 삭제(cascade)**: `DELETE /api/v1/projects/<T>` → 프로젝트 + 그 안의 데이터소스·시크릿·롤·롤바인딩·대시보드 **일괄 삭제**. (검증됨: 삭제 후 해당 프로젝트 데이터소스 조회 시 HTTP 400 `metadata.project "<T>" doesn't exist`)
3. **Keycloak**: 테넌트 SA client `perses-vmauth-<T>` 삭제, 유저 attribute/그룹 정리.
> 유저만 제거(테넌트는 유지)하려면 프로젝트 RoleBinding의 `subjects`에서 해당 `preferred_username`만 제거.
## 6. 검증 방법
`tenant-verification/`(로컬, gitignore)의 스크립트로 재현:
- `03-verify.sh` — vmauth JWT 경로 메트릭·로그 격리(B1~B7)
- `04-perses-verify.sh` — Perses RBAC(유저별 프로젝트 가시성, 타 프로젝트 403)
- `05-perses-complete.sh` — 프로젝트별 victoriametrics/victorialogs 데이터소스 + 대시보드 복제
**검증된 결과(2026-07-01, demo01/demo02)**:
- 메트릭: acct1↔demo01, 교차 0. 로그: acct1 demo01=50건, demo02=0.
- Perses: demo01-user는 demo01 프로젝트만, demo02 접근 403. demo01 데이터소스→demo01 데이터만(demo02=0).
- 테넌트 유저가 메트릭+로그+대시보드 4종 모두 자기 테넌트로 조회.
## 7. 요약 — "무엇을 바꿔야 하나"
| 시나리오 | Keycloak | vmagent | otelcol | Perses | 재배포 |
|---|---|---|---|---|---|
| 같은 테넌트 새 네임스페이스 | - | - | - | - | 없음 |
| **새 테넌트(새 accountID)** | SA client + attr | remoteWrite+relabel | filter+exporter+pipeline | project+secret+DS×2+role+binding+dashboards | vmagent·otelcol |
| 기존 테넌트에 유저 추가 | (그룹/attr, 선택) | - | - | **RoleBinding subject 추가(preferred_username)** | 없음 |
> 이 자원 생성·유저 바인딩 일체는 **dip-console 온보딩 자동화 대상**이다. 정적 대시보드/데이터소스의 GitOps 편입(ArgoCD directory 소스 또는 차트 `extraObjects`)은 별도 backlog.