# 테넌트 온보딩 가이드 (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. 테넌트 추가 시 생성 자원 (체크리스트) 새 테넌트 `` (accountID ``, 네임스페이스 접두 `-*`) 추가 시: | # | 계층 | 자원 | 담당 | 재배포 | |---|------|------|------|--------| | 1 | Keycloak | 데이터소스용 SA client `perses-vmauth-` (+vm-access scope, SA attr accountID=``) | dip-console | - | | 2 | Keycloak | (직접 API 접근 유저용) user attribute `vm_metrics_account_id`/`vm_logs_account_id`=`` | dip-console | - | | 3 | vmagent | remoteWrite `/insert//` + `urlRelabelConfig` + relabel ConfigMap 키 `.yaml`(keep namespace=~`-.*`) | 카탈로그/dip-console | **vmagent 재배포** | | 4 | otelcol | `filter/`(namespace 정규식) + exporter `otlphttp/vlogs-`(AccountID/ProjectID 헤더) + pipeline `logs/` | 카탈로그 | **otelcol 재배포** | | 5 | Perses | Project `` | dip-console | - | | 6 | Perses | Secret `-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 | - | > **같은 테넌트의 새 네임스페이스**(`-*` 추가)는 정규식이 커버 → **무변경**. 변경이 필요한 건 **새 테넌트(새 accountID) 추가** 시뿐이다. ## 3. 계층별 상세 ### 3.1 Keycloak (paasup realm) — 데이터소스 자격증명 Perses 데이터소스는 테넌트별 **service-account client**(client_credentials)로 vmauth에 접근한다. 그 토큰의 `vm_access` 클레임이 accountID를 담는다. ```sh # client perses-vmauth- (serviceAccountsEnabled) + vm-access scope 연결 # + SA user attribute: vm_metrics_account_id=, vm_logs_account_id= # 절차는 victoria-metrics-auth/CUSTOM-README §3.2 (perses-vmauth 패턴) 동일, clientId만 테넌트별. ``` > 검증: 토큰 디코드 시 `vm_access:{metrics_account_id:, logs_account_id:}` 확인. ### 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//prometheus/api/v1/write" urlRelabelConfig: /relabel/.yaml # relabel ConfigMap 키 .yaml - action: keep source_labels: [namespace] regex: "-.*" ``` ⚠️ **`[]`(빈 규칙)은 격리가 아니다** — 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/: logs: { log_record: ['not IsMatch(attributes["namespace"], "^-.*")'] } exporters: otlphttp/vlogs-: endpoint: "http://...vlinsert...:9481/insert/opentelemetry" headers: { AccountID: "", ProjectID: "0" } # ⚠️ VictoriaLogs 인식 헤더는 AccountID/ProjectID service: pipelines: logs/: { receivers: [filelog], processors: [memory_limiter, k8sattributes, filter/, batch], exporters: [otlphttp/vlogs-] } ``` > ⚠️ 헤더는 반드시 `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-`, 라벨 `paasup.io/tenant: `)로 관리한다. 온보딩=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: } --- kind: Secret # 테넌트 OAuth (perses-vmauth-) metadata: { name: -vmauth, project: } spec: { oauth: { clientID: perses-vmauth-, clientSecret: "", tokenURL: "https://keycloak.example.org/realms/paasup/protocol/openid-connect/token", scopes: ["openid"] } } --- kind: Datasource # 메트릭 metadata: { name: victoriametrics, project: } 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: -vmauth } } } } } --- kind: Datasource # 로그 metadata: { name: victorialogs, project: } 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: -vmauth } } } } } --- kind: Role metadata: { name: viewer, project: } spec: { permissions: [{ actions: ["read"], scopes: ["*"] }] } --- kind: RoleBinding metadata: { name: -viewer-binding, project: } spec: { role: viewer, subjects: [{ kind: User, name: "" }] } ``` - **대시보드 (테넌트 적정 정리)**: `monitoring` 프로젝트 대시보드를 `metadata.project`만 ``로 바꿔 복제하되, **테넌트 계정에 데이터가 있는 것만** 넣는다. 데이터소스가 프로젝트 스코프로 해석되어 자동으로 테넌트 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//prometheus/api/v1/admin/tsdb/delete_series?match[]={namespace!~"-.*"}` 로 외부 시리즈 제거(검증됨: 삭제 후 변수가 테넌트 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: "" }` 추가. (바인딩 없는 유저는 `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- -n monitoring` (+ vmagent relabel 키·otelcol pipeline 제거 후 재배포) 2. **Perses DB 삭제(cascade)**: `DELETE /api/v1/projects/` → 프로젝트 + 그 안의 데이터소스·시크릿·롤·롤바인딩·대시보드 **일괄 삭제**. (검증됨: 삭제 후 해당 프로젝트 데이터소스 조회 시 HTTP 400 `metadata.project "" doesn't exist`) 3. **Keycloak**: 테넌트 SA client `perses-vmauth-` 삭제, 유저 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.