Workspace Isolation (Multi-Tenant 격리)
GenD 의 Workspace 는 다중 고객사 (테넌트) 격리를 위한 가장 외곽 컨테이너입니다. 기존 ResourceGroup (부서 단위 격리) 의 상위에 위치하며, ADR-006 의 L4 (워크스페이스) 와 정확히 1:1 매핑됩니다.
격리 계층
각 Layer 는 상위가 가시성을 게이트 합니다. 즉 caller 가 workspace A 에 속하지 않으면 그 안의 어떤 ResourceGroup / DataGrant / Query / DataMart 도 보이지 않습니다.
데이터 모델 (#1018 M1 Step 2 시점)
class Workspace(Base):
__tablename__ = "workspaces"
id: UUID # FK 용 안정적 식별자
slug: str # URL-safe, unique (e.g. "samsung-card")
name: str # 표시명
keycloak_group_path: str # /tenants/\{slug\}
status: str # active | suspended | deleted
FK 전파 (M1 Step 2 — 4 모델, NULLABLE):
| 테이블 | workspace_id 컬럼 | 인덱스 | ondelete |
|---|---|---|---|
resource_groups | ✅ | ✅ | RESTRICT |
query_history | ✅ | ✅ | RESTRICT |
saved_queries | ✅ | ✅ | RESTRICT |
data_marts | ✅ | ✅ | RESTRICT |
M2 계획: 나머지 cross-cutting 모델 22+ 곳에 FK 전파 + ABAC workspace 차원 적용 + NOT NULL 전환 (백필 완료 후).
Keycloak Group 계층
/tenants/samsung-card
/tenants/samsung-card/marketing
/tenants/samsung-card/finance
/tenants/kogas
/tenants/kogas/production
JWT claim:
tenant— primary tenant slug (group path prefix)groups— legacy compat, tenant prefix 제거 후 leaf 만 (["marketing", "finance"])workspace_id— UI/API token 발급 시 해석된 UUID
운영 절차
1. Workspace 생성
# 어드민 권한 (require_admin) 으로 호출 — M1 Step 3+ 에서 라우터 노출 예정
POST /api/v1/workspaces
{
"slug": "samsung-card",
"name": "삼성카드",
"keycloak_group_path": "/tenants/samsung-card"
}
Keycloak 측 group 도 자동 생성됩니다 (M1 Step 4 의 keycloak_sync).
2. ResourceGroup 할당
기존 ResourceGroup 에 workspace_id 채우기 (백필):
UPDATE resource_groups
SET workspace_id = (SELECT id FROM workspaces WHERE slug = 'samsung-card')
WHERE name IN ('marketing', 'finance', 'hr');
3. 백필 정책 (legacy NULL row)
M1 Step 2 시점에는 workspace_id NULLABLE — 기존 row 는 NULL 상태입니다. M2 진입 전 다음 정책으로 백필:
- 모든 NULL row →
default-workspace(system-managed) UUID 로 채움 - admin 이 후속에 재할당 (UI 또는 SQL)
- 백필 완료 후
ALTER TABLE … ALTER COLUMN workspace_id SET NOT NULL
M1 Step 3 — apply_workspace_scope + tenant_slug 주입 (#1018)
JWT 디코드 단계 (auth/jwt_bearer.py) 가 Keycloak group path
/tenants/<slug>/... 의 prefix 를 TokenPayload.tenant_slug 로 추출.
caller 컨텍스트가 자동으로 tenant 인지.
apply_workspace_scope(stmt, model, user) 우선순위
| 시나리오 | 동작 |
|---|---|
user is None | filter 없음 (stmt 그대로) |
user.is_admin == True | filter 없음 (모든 workspace 노출) |
user.tenant_slug is None (legacy) | filter 없음 (single-tenant 가정) |
user.tenant_slug == "samsung-card" | workspace_id IN (SELECT id FROM workspaces WHERE slug='samsung-card' AND status='active') OR workspace_id IS NULL |
사용 예
from gend_api.services.group_filter import (
apply_group_filter,
apply_workspace_scope,
)
stmt = select(SavedQuery)
stmt = apply_workspace_scope(stmt, SavedQuery, current_user) # L0 tenant
stmt = apply_group_filter(stmt, SavedQuery, current_user) # L1 부서
rows = (await db.execute(stmt)).scalars().all()
회귀 가드
본 기능은 다음 가드로 보호됩니다:
tests/test_workspace_model.py— Workspace 테이블 자체 (20 cases)tests/test_workspace_fk_propagation.py— 4 모델 FK 전파 (19 cases)tests/test_workspace_scope.py— Step 3 scope 헬퍼 (9 cases)- TokenPayload tenant_slug 필드 + default None
- apply_workspace_scope 4 분기 (admin/legacy/tenant/None)
- Cross-tenant E2E (workspace A token → B row 차단)
- JWT 디코드 단계 slug 추출 (
/tenants/<slug>/...)
PG-only 동작 (실제 IntegrityError) 은 통합 테스트로 분리 (SQLite 단위 테스트는 PRAGMA foreign_keys=OFF 기본).
M1 Step 4 — Keycloak group path 자동 sync (#1042)
Workspace.slug ↔ Keycloak realm 의 /tenants/<slug> group path 를 idempotent 로 동기화.
KeycloakWorkspaceSyncService
| 메서드 | 효과 |
|---|---|
sync_workspace(workspace) | /tenants parent + /tenants/<slug> child 보장 |
sync_all(workspaces) | active workspace 일괄 sync (non-active skip) |
delete_workspace_group(slug) | Workspace.status='deleted' 전환 시 group 제거 |
Idempotency 보장
- 이미 존재 → GET 만, POST 호출 0
- 동시 sync 409 Conflict → graceful 재조회
- non-409 에러는 raise (silent fail 차단)
- empty slug 즉시
ValueError(Keycloak 호출 0)
호출 지점
- Step 5 의
workspacesPOST/PUT 라우터에서 자동 호출 - 부팅 시
init_db다음 (GEND_KEYCLOAK_SYNC_ON_STARTUP=true, graceful) - admin endpoint
POST /api/v1/workspaces/sync-keycloak
회귀 가드 (10 cases)
tests/test_keycloak_workspace_sync.py:
- parent + child 신규 / 양쪽 존재 / parent 만 존재 / 409 race / non-409 propagate
- non-active skip / empty slug 거부 / delete present + absent / repr
M1 Step 5 — Workspace CRUD 라우터 + Keycloak 통합 (#1046)
Admin 전용 /api/v1/workspaces REST API + Step 4 의 KeycloakWorkspaceSyncService 자동 통합. 본 Step 머지로 Workspace Isolation Epic #1018 의 M1 종결.
Endpoints
| Method | Path | 효과 |
|---|---|---|
| GET | /api/v1/workspaces | 모든 workspace 목록 |
| POST | /api/v1/workspaces | 생성 + Keycloak group 자동 sync |
| GET | /api/v1/workspaces/{id} | 단건 조회 |
| PUT | /api/v1/workspaces/{id} | 부분 갱신 (name / status) — slug 변경 미지원 |
| POST | /api/v1/workspaces/{id}/sync-keycloak | 단일 sync 강제 (운영 도구) |
| POST | /api/v1/workspaces/sync-keycloak/all | active 일괄 sync |
보안 정책
- 모두 admin 전용 —
_protected_routers등록 + 핸들러 내부require_admin게이트 keycloak_group_path는 server-side 에서/tenants/<slug>자동 강제 (클라이언트 입력 무시)- slug 변경은 의도적 금지 (Keycloak group + cross-cutting FK 동기 비용 — slug 변경 워크플로우는 M2)
Atomicity
- POST: Keycloak sync 실패 시 DB row rollback → 502 응답
- PUT to
status='deleted': Keycloak group cascade 정리 (delete_workspace_group) 호출
회귀 가드 (17 cases)
tests/test_workspaces_router.py:
require_admin(admin pass / viewer 403)WorkspaceCreatevalidation (slug regex / empty)- DB 통합 (list / create / 409 duplicate / Keycloak rollback / 404 / PUT / cascade delete)
- sync 라우터 2종 (단일 / 일괄, active 필터)
- ADR-004
protected_routers등록 가드
알려진 갭 (M2 에서 닫힘)
| 갭 | 영향 | 해소 시점 |
|---|---|---|
workspace_id NULL row 노출 | legacy 데이터가 모든 caller 에 보임 | M2 백필 완료 + NOT NULL 전환 |
라우터 측 apply_workspace_scope 호출 미적용 | 신규 헬퍼 작성됐지만 50+ 라우터에 점진 적용 필요 | M2 follow-up (라우터별 PR) |
| Audit chain workspace 미분리 | tenant 별 chain 분리 안 됨 | M3 GA |
관련
- Epic: #1018 (Workspace Isolation cross-cutting prereq)
- ADR: ADR-006 Ontology Layered Packaging — L4 정의
- 메모리:
project_data_isolation,project_auth_arch - 부모 Epic: #989 (Phase 4 RFP 갭 개선)
배포 전 영향 측정 — 고아 워크스페이스 게이트
X-Workspace-Slug 반영 범위를 넓히는 변경(#2714 계열)을 배포하기 전, 누가 영향을
받는지 실측한다. 스크립트는 read-only 다.
kubectl --context aks-genos-prod -n gend exec deploy/gend-api -- \
python -m gend_api.scripts.census_ws_catalog_visibility --json > diff.json
코호트
| 코호트 | 의미 | 조치 |
|---|---|---|
ORPHAN_WORKSPACE | Keycloak 멤버십에는 있는데 workspaces 행이 없는 slug 보유 | 배포 차단 — 아래 절차로 해소 |
IMPACTED | 워크스페이스별 가시 카탈로그가 서로 다른 다중 멤버십 사용자 | 필요 시 권한 선발급 |
SAFE | 다중 멤버십이나 모든 워크스페이스에서 가시 집합이 동일 | 조치 불필요 |
NOT_AFFECTED_ADMIN | 플랫폼 관리자 — 가시성 필터를 우회한다 | 조치 불필요 |
NO_ACCESS | viewer/analyst/admin 역할 없음 — 카탈로그 조회 자체가 403 | 조치 불필요 |
SINGLE_MEMBERSHIP | 멤버십 1개 이하 | 조치 불필요 |
deploy_gate_ok: false 일 때
고아 slug 를 가진 사용자는 그 워크스페이스에서 카탈로그가 빈 목록으로 보이고
/query/execute 가 403 이 된다. 둘 중 하나로 해소한 뒤 배포한다.
- 워크스페이스를 만든다 —
POST /api/v1/workspaces로 해당 slug 의 행을 생성. Keycloak 그룹이 정본이고 DB 행이 누락된 경우. - Keycloak 그룹을 정리한다 —
/tenants/<slug>그룹이 오타이거나 폐기된 워크스페이스인 경우, 해당 그룹에서 멤버를 제거하거나 그룹을 삭제.
orphan_users 필드에 영향 사용자 목록이 나온다.
이 게이트가 없던 시절, 고아 slug 사용자는 가시 집합이 비어 "영향 없음"으로 분류돼 거짓 통과했다. 실제 체감은 거부다.
벡터 검색(RAG) 워크스페이스 격리 — 단계 전환
AI 문서 검색이 반환하는 청크를 활성 워크스페이스로 제한하는 기능이다.
GEND_VECTOR_WS_ENFORCEMENT_MODE 로 단계 전환한다.
| 모드 | 동작 |
|---|---|
off | 워크스페이스 축을 적용하지 않는다 |
observe (기본) | 축을 적용하지 않고 불일치 건수만 계측한다 |
enforce | 검색 시 활성 워크스페이스의 문서만 반환한다 |
미태깅 문서가 남은 상태에서 enforce 로 바꾸면 그 문서는 오류가 아니라 빈 결과로
조용히 사라진다. 사용자에게는 "업로드는 됐는데 검색이 안 됨"으로 보이고, 서버 로그에는
경고 한 줄만 남는다.
두 수치를 모두 확인한 뒤에만 전환한다.
1. 태깅률 확인
kubectl --context aks-genos-prod -n gend exec deploy/gend-api -- \
python -m gend_api.scripts.census_weaviate_workspace_tag
phase2_gate_ok: true 여야 한다. 아니면 백필을 먼저 실행한다:
kubectl --context aks-genos-prod -n gend exec deploy/gend-api -- \
python -m gend_api.scripts.backfill_weaviate_workspace_id \
--class DocumentChunks --execute
2. 관측 카운터 확인
gend_vector_ws_mismatch_total{reason="untagged"} 가 0 이어야 한다.
카운터가 0인 이유가 "전부 태깅됨"인지 "아무도 검색을 안 함"인지 구분되지 않는다.
RAG 사용량이 적은 환경에서는 검색을 실제로 한 번 호출해 계측이 발화하는지 확인한 뒤
판단할 것. (mismatch 가 증가하면 계측은 정상이다.)
3. 전환
kubectl --context aks-genos-prod -n gend set env deploy/gend-api \
GEND_VECTOR_WS_ENFORCEMENT_MODE=enforce
롤백
kubectl --context aks-genos-prod -n gend set env deploy/gend-api \
GEND_VECTOR_WS_ENFORCEMENT_MODE=observe
설정값만 되돌리면 즉시 이전 동작으로 돌아간다 — 데이터 변경이 없다.
알아둘 것
- 워크스페이스를 해석할 수 없는 호출(관리자 일부 경로, 단일테넌트 구성)은 축이 적용되지 않는다. 전 결과가 사라지는 것을 막기 위한 의도된 동작이다.
- 오타로 잘못된 모드값을 넣으면
observe로 폴백하고 오류 로그를 남긴다 — 오타가 전면 차단이나 전면 무시로 번지지 않는다.
NULL workspace_id 청산 — 단계 전환 (#2798 PR-18)
레거시 NULL workspace_id 행은 지금까지 "전원 통과(펜스)·전 워크스페이스
가시(스코프)·기본 워크스페이스 grant(COALESCE)" 로 해석됐습니다. 사용자가
체감하는 "워크스페이스를 바꿔도 같은 것이 보인다" 의 상당 부분이 이
경로입니다. PR-18 은 백필로 NULL 을 청산한 뒤 이 시맨틱을 제거합니다.
모드 (env GEND_WS_NULL_SEMANTICS_MODE)
| 모드 | 동작 |
|---|---|
legacy | 종전 그대로 — NULL 행 전원 통과/전체 가시 |
observe (기본) | 동작은 legacy 와 동일, 펜스 표면에서 NULL 행 조우를 계측 |
strict | 펜스가 NULL 행을 403 거부, 스코프/grant 술어에서 NULL 분기 제거 |
오타 값은 observe 폴백 (전면 비가시 사고 방지).
:::warning ⑤ 의도적 NULL 시맨틱 — 백필·NOT NULL·strict 의 예외 (2026-08-04 실측)
NULL 이 "미백필 레거시"가 아니라 설계인 테이블 3계열이 prod --apply 중
발각됐습니다 (UNIQUE 충돌이 오염 전에 차단):
llm_usecase_bindings— NULL = 전역 기본 바인딩 (llm_resolver Tier 3)ontology_classes/properties/relations/class_grants— L3 공용 팩 (linkml_loader 가 "항상 NULL" 로 upsert)ps_*5종 — 정확 파티션 시맨틱 (NULL 파티션 = admin/tenantless 뷰)
이들은 백필·NOT NULL 대상에서 영구 제외(_P4B_DELIBERATE_NULL)입니다.
fence 는 이 테이블들의 행을 __tablename__ 예외로 전 모드에서 통과시키므로
(PR-18c — L3 공용 팩 조회가 strict 에서 403 되지 않음), strict 전환의 게이트가
충족됩니다. ⑤ 행은 관측 카운터에도 섞이지 않아 gend_ws_null_row_total 은
진짜 스탬핑 누락만 셉니다 — strict 에서 이 값이 오르면 새 쓰기 경로가
NULL 행을 만들고 있다는 뜻입니다.
:::
함께 들어간 변경:
Workspace.status 시행 — suspended/deleted 워크스페이스는 모든
resolver(활성 해석·읽기 펜스·스탬핑·기본 ws)에서 해석되지 않습니다
(모드 무관 상시 — 종전엔 정지시켜도 데이터 접근이 유지됐습니다).
strict 전환 절차
- 백업: 대상 테이블 PG 논리 백업 (정비창 권장 — 백필은 비가역 UPDATE)
- 백필:
python -m gend_api.scripts.backfill_workspace_nulls(census dry-run 으로 분류표 확인) →--apply. FK 부모 상속(document_* ← ingested_files) 먼저, 나머지는 finance-invest 귀속 (런북 확정 결정). 예외 2부류는 건드리지 않는다: ④ admin-only 감사로그 3종(영구 nullable) + ⑤ 의도적 NULL 시맨틱 10종(위 경고 박스 — census 가 분류로 표시). 0-NULL 검증 대상은_P4B_NOTNULL_TABLES(33 — P4B 32종 +intel_filings, ADR-0035 D-1) 뿐이다 - 0-NULL 검증:
--apply가 마지막에 자동 검증 (실패 시 exit 1) - NOT NULL: gend-api 재기동 — init_db
_P4B_NOTNULL_TABLESDO-block 이 0-NULL 테이블에SET NOT NULL적용 (NULL 잔존 테이블은 NOTICE 로 skip 후 다음 재기동에서 수렴) - 분포 확인:
gend_ws_null_row_total{mode="observe"}가 실사용 관측창에서 0 유지 — 0 이 아니면 펜스가 아직 NULL 행을 만나고 있다는 뜻 (⑤ 시맨틱 경로 또는 백필 누락 — 감사 로그로 어느 쪽인지 특정) - 전환: 분포 0 확인 후 configmap
GEND_WS_NULL_SEMANTICS_MODE: "strict"- 재시작 (⑤ 시맨틱은 fence 의
__tablename__예외가 상시 보호 — PR-18c)
- 재시작 (⑤ 시맨틱은 fence 의
롤백
kubectl --context aks-genos-prod -n gend set env deploy/gend-api \
GEND_WS_NULL_SEMANTICS_MODE=observe
시맨틱은 설정만으로 즉시 복귀합니다. 백필 자체는 비가역(UPDATE)이므로 §1 의 백업으로만 되돌릴 수 있으나, 귀속 방향이 COALESCE 등가(기본 워크스페이스)라 strict 이전에는 동작 차이가 없습니다.
관측
sum by (surface, mode) (increase(gend_ws_null_row_total[24h]))
surface=fence_read|fence_mutation. strict 에서 이 카운터가 오르면 어떤 경로가
아직 NULL 행을 만들고 있다는 뜻입니다 — 쓰기 경로 스탬핑 누락을 의심하십시오.
워크스페이스 프로비저닝 (#2798 PR-14)
POST /api/v1/workspaces 가 이제 DB 행·Keycloak group 에 더해 물리 자원까지
함께 만듭니다 (실패 시 전체 롤백 + 502):
- 물리 스키마명 —
ws_<slug의 dash→underscore>를Workspace.physical_schema에 저장 (충돌 시 숫자 접미, immutable — 재명명 불가) - Trino 스키마 —
iceberg.<physical_schema>+hive.<physical_schema>_staging(IF NOT EXISTS — 재시도 안전) - 네임스페이스 레지스트리 —
CatalogNamespace(ownership_status=assigned, owner_workspace_id=신규 ws)— 신규 프로비저닝은 소유가 정의상 확정이라 백필의 "추측 금지" 원칙과 별개 - owner 바인딩 —
NamespaceBinding(access_mode=write)— PR-15 쓰기 게이트가 새 워크스페이스의 자기 스키마 쓰기를 인정 - schema-scope group grant —
grantee_name="/tenants/<slug>"(Keycloak group path 원문 — 파싱된 slug 를 넣으면 조용히 미매칭),ALL. ★ catalog-scope USE 는 발급하지 않습니다 — 하위 grant 로 카탈로그가 보이는 파생 가시성이 이번에 함께 들어갔습니다
소급 프로비저닝 / 재시도
기존(레거시) 워크스페이스는 physical_schema 가 NULL 입니다. admin 이:
POST /api/v1/workspaces/{id}/provision
멱등 — 이미 프로비저닝된 워크스페이스에는 사실상 no-op. active 상태에서만 허용(409). 생성 시 프로비저닝이 502 로 실패한 경우의 재시도 경로이기도 합니다.
알아둘 것
- suspend/delete 는 물리 자원을 건드리지 않습니다 (soft-delete 정책 — 스키마 DROP 은 정비창 수동 결정)
- 새 스키마는 Medallion layer 해석(
catalog_layer_resolver)에 잡히지 않습니다 — 워크스페이스 스키마는 레이어 무관 (후속 트랙)
워크스페이스 미배정 호출자의 읽기 — 단계 전환 (신규 가입자 기본 상태 설계)
인증은 됐지만 워크스페이스 멤버십이 없는 호출자(JWT 에 /tenants/<slug> group
이 없음 — 신규 가입 직후가 전형)의 읽기를 GEND_TENANTLESS_READ_MODE 로 단계
전환한다. 종전 동작은 resolve_caller_workspace 가 필터 없음(None)을
반환하는 것이었다 — 인증만 됐을 뿐 워크스페이스에 귀속되지 않은 호출자가
전 워크스페이스의 행을 그대로 봤다.
모드
| 모드 | 동작 |
|---|---|
off | 신규 계측(gend_tenantless_read_denied_total)은 멈춘다 (긴급 롤백용) — 단 gend_workspace_fence_bypass_total{reason="no_slug"} 는 fence 경로에서 이 모드와 무관하게 계속 증가한다 (기존 동작). off 로 내려도 이 카운터가 계속 오르는 것은 정상이다 |
observe (기본) | 동작은 종전과 동일 — 전 워크스페이스 통과 — 하되 조우를 계측한다 |
enforce | 403 워크스페이스가 배정되지 않았습니다 — 관리자에게 워크스페이스 배정을 요청하세요 |
오타 값은 observe 로 폴백한다 (다른 게이트 계열과 같은 원칙 — 오타 하나로
전면 차단이 되지 않게). 기본값이 observe 이므로 이 플래그를 배포한 것만으로는
동작이 바뀌지 않는다.
user is None(인증 비활성화) 경로와 admin 호출자는 이 플래그의 대상이 아닙니다
— 둘 다 tenant_slug 를 읽기도 전에 무조건 통과합니다(별도 계측:
gend_workspace_fence_bypass_total{reason="no_user"|"admin"}).
관측
gend_tenantless_read_denied_total{mode=...} 는
gend_workspace_fence_bypass_total{reason="no_slug"} 의 상위 집합입니다
(≥, 등가 아님) — fence 경로(21개 라우터)의 no_slug 증가분에 더해
services/workspace_resolver 를 쓰는 3곳(workspace_mcp_tool,
intel/sources)의 거부분도 여기 잡힙니다. enforce 로 전환하면 전자의 증가가
멈추고 후자는 그만큼 이상 늘어야 합니다 — "정확히 같은 수"를 기대하면
정상 상태를 이상으로 오판합니다. 둘 다 정체되면 계측이 죽은 것입니다.
2026-08-07 실측: gend_workspace_fence_bypass_total{reason="no_slug"} 가 7일간
80건 — enforce 전환 시 예상 영향 범위는 작습니다(이 수치는 fence 경로만이며,
resolver 경로 3곳의 트래픽은 별도 집계입니다).
enforce 전환 절차
gend_tenantless_read_denied_total{mode="observe"}분포로 영향 대상 규모 확인- configmap
GEND_TENANTLESS_READ_MODE: "enforce"+ 재시작 gend_workspace_fence_bypass_total{reason="no_slug"}증가가 멈추고gend_tenantless_read_denied_total{mode="enforce"}가 그 이상으로 오르는지 확인합니다(등가가 아니라 상위 집합 — 위 관측 절 참고)
롤백
다른 단계 플래그(GEND_WRITE_NAMESPACE_MODE, GEND_WS_NULL_SEMANTICS_MODE 등,
infra/helm/gend-api/configmap.yaml 롤백 주석 참고)와 같은 방식을 씁니다 —
ConfigMap 값을 되돌리고 apply + 재시작합니다. kubectl set env 는 쓰지
않습니다(아래 경고 참고):
# infra/helm/gend-api/configmap.yaml 의 GEND_TENANTLESS_READ_MODE 를
# "observe" 로 되돌린 뒤:
kubectl --context aks-genos-prod -n gend apply -f infra/helm/gend-api/configmap.yaml
kubectl --context aks-genos-prod -n gend rollout restart deploy/gend-api
설정값만 되돌리면 즉시 이전 동작(전 워크스페이스 통과)으로 복귀합니다 — 코드 변경이 필요 없습니다.
kubectl set env 로 롤백하지 말 것 — ConfigMap 이 조용히 죽습니다deployment.yaml 은 envFrom: configMapRef(82-84행)로 gend-api-config 를
통째로 주입합니다. kubectl set env deploy/gend-api GEND_TENANTLESS_READ_MODE=observe 를 실행하면 컨테이너 레벨의 명시적 env
항목이 Deployment 오브젝트에 영구히 추가되고, 이 항목이 envFrom 의 같은
키보다 우선합니다(114-115행 GEND_ARANGODB_PASSWORD 주석이 이 우선순위를
그대로 보여줍니다). 그 뒤로는 ConfigMap 의 GEND_TENANTLESS_READ_MODE 를
아무리 고쳐도 조용히 무시되는 죽은 키가 됩니다.
이미 kubectl set env 로 긴급 롤백했다면, ConfigMap 을 다시 유효하게 만들기
위해 오버라이드를 명시적으로 제거해야 합니다(트레일링 -):
kubectl --context aks-genos-prod -n gend set env deploy/gend-api \
GEND_TENANTLESS_READ_MODE-