본문 바로가기

Network/Zero Trust

[Zero Trust - IAM] Google Zanzibar: 2조 개의 권한을 일관성 있게 10ms에 처리하는 설계

Alice가 공유 폴더에서 Bob을 제거했다. 그 직후 Alice는 Charlie에게 새 문서를 폴더로 옮겨달라고 요청했다. 새 문서의 권한은 폴더 권한을 상속한다. 이제 Bob이 그 문서를 열었다. Bob은 볼 수 있는가?

 

"취소가 먼저였으니 안 된다"고 말하기 쉽다. 하지만 분산 시스템에서 "먼저"는 보장되지 않는다. 권한 변경이 아직 전파되지 않은 레플리카에서 체크가 실행되면, Bob은 이미 제거됐음에도 접근할 수 있다. Zanzibar 논문은 이것을 New Enemy Problem이라고 부른다.

 

이 문제가 단일 서버라면 해결하기 어렵지 않다. 문제는 Google의 규모다. Calendar, Drive, Maps, Photos, YouTube에 걸쳐 2조 개(2 trillion) 이상의 ACL 레코드, 초당 1,000만 건 이상의 권한 확인 요청, 30개 이상의 전 세계 데이터센터. 이 환경에서 "Alice가 방금 Bob을 제거했으니 즉시 차단된다"는 보장을 만드는 것이 Zanzibar가 해결해야 했던 문제다.

 

Zero Trust의 핵심은 접근 요청마다 신뢰를 재확인하는 것이다. 그 재확인이 의미를 가지려면 권한 결정이 정확하고 실시간이어야 한다. 만일 오래된 캐시나 전파되지 않은 변경이 틈을 만들면 "Always Verify"는 이름뿐이 된다. Zanzibar는 그 조건을 Google 규모에서 달성하기 위해 설계됐다.


Zanzibar가 해결해야 했던 세 가지 문제

Zanzibar 이전에도 ReBAC 개념은 존재했다. 관계 그래프로 접근을 결정한다는 아이디어 자체는 단순하다. 문제는 그 아이디어를 Google 규모에서 동작시키는 것이었다.

  • 표현력
    Gmail, Drive, YouTube는 각자 다른 계층 구조와 상속 규칙을 갖는다. "폴더 viewer는 하위 문서의 viewer이기도 하다", "editor는 viewer이기도 하다", "비디오 채널의 viewer는 채널 내 비디오를 볼 수 있다"
    이 다양한 규칙을 하나의 선언적 언어로 표현해야 한다.
  • 성능
    관계 그래프 탐색은 그룹이 깊게 중첩될수록 느려진다. Zanzibar는 P95 지연을 10ms 미만으로 유지해야 한다. 검색 결과 하나를 보여주기 위해 수십 개의 권한 확인이 필요한 경우도 있기 때문이다.
  • 일관성
    분산 환경에서 권한 변경이 즉시 모든 레플리카에 반영된다는 보장이 없다. New Enemy Problem은 ACL 업데이트 순서가 무시되는 경우, 그리고 새 콘텐츠에 낡은 ACL이 적용되는 경우 두 가지 형태로 나타난다. 

Namespace와 Tuple — Zanzibar의 기본 단위

Zanzibar에서 모든 객체는 namespace에 속한다. doc:readme에서 doc이 namespace고 readme가 객체 ID다. namespace는 접근 제어 스키마의 단위이기도 하며, 이 namespace 안에서 어떤 relation이 존재하는지, 각 relation이 어떻게 상속되는지를 선언한다.

 

Relation Tuple은 Zanzibar에서 object#relation@user 형식으로 표현된다. 논문의 예시를 그대로 가져오면 이렇다.

튜플 의미
doc:readme#owner@10 User 10은 doc:readme의 owner다
group:eng#member@11 User 11은 group:eng의 member다
doc:readme#viewer@group:eng#member group:eng의 member들은 doc:readme의 viewer다
doc:readme#parent@folder:A#... doc:readme는 folder:A 안에 있다

 

세 번째 튜플에서 subject가 단순한 사용자가 아니라 group:eng#member "group:eng의 member relation이 가리키는 사용자 집합"이다. 이것을 userset이라고 부른다. 사용자 개인이 아닌 관계에서 파생된 집합을 subject로 지정할 수 있고, 이것이 관계 상속의 핵심 메커니즘이다.

 

parent처럼 접근 제어에 직접 관여하지 않고 객체 간 추상 관계만 정의하는 relation도 존재한다. 이 relation들은 userset rewrite rules에서 다른 relation을 파생시키는 데 사용된다.


Userset Rewrite Rules — 관계 상속의 선언

namespace 설정에서 각 relation에 대한 rewrite rule을 정의한다. rule은 세 가지 기본 연산으로 구성된다.

  • _this: 직접 부여된 관계. 저장된 tuple에서 직접 읽는다
  • computed_userset: 같은 객체의 다른 relation에서 파생. "editor는 viewer이기도 하다"처럼 동일 객체 내 관계를 확장
  • tuple_to_userset: 다른 객체로 이동한 뒤 그 객체의 relation을 참조. 폴더 계층 상속이 이 방식

논문의 Figure 1이 세 연산을 모두 담고 있다. doc namespace에서 owner, editor, viewer의 동심원(concentric) 구조다.

name: "doc"

relation { name: "owner" }

relation {
  name: "editor"
  userset_rewrite {
    union {
      child { _this {} }
      child { computed_userset { relation: "owner" } }  // owner는 editor이기도 하다
    }
  }
}

relation {
  name: "viewer"
  userset_rewrite {
    union {
      child { _this {} }
      child { computed_userset { relation: "editor" } }  // editor는 viewer이기도 하다
      child { tuple_to_userset {
        tupleset { relation: "parent" }                  // parent relation을 따라 폴더로 이동
        computed_userset { relation: "viewer" }          // 그 폴더의 viewer를 참조
      }}
    }
  }
}

 

이 선언 하나로 세 가지가 동시에 처리된다. owner는 editor이고, editor는 viewer이므로 owner도 결과적으로 viewer다. 직접 editor로 지정된 사람도 viewer다. 그리고 상위 폴더의 viewer도 viewer다. 이 규칙은 전체 doc namespace의 모든 객체에 적용된다.

doc:report의 viewer를 평가할 때 Zanzibar는 이 세 경로를 동시에(concurrently) 탐색한다. union 연산에서 하나의 경로가 true로 결정되면 나머지 경로의 탐색을 취소한다(early cancellation). 최악의 경우를 기다리지 않고 가장 먼저 확정된 결과로 응답하는 것이 지연 시간을 낮추는 핵심이다.


API — Check·Read·Write·Watch·Expand

Zanzibar는 단순 권한 확인 서비스가 아니다. 논문 2.4는 다섯 가지 API를 정의한다.

 

1. Check: "User U가 object O에 relation R을 갖는가?" 주어진 zookie(일관성 토큰, 아래에서 자세히 설명) 이상의 스냅샷에서 평가한다.

여기에 특수한 형태로 content-change check가 있다. 콘텐츠 수정을 승인할 때 사용하며, zookie를 입력받지 않고 항상 최신 스냅샷에서 평가한다. 응답에 새 zookie를 포함해 반환하는데, 이 zookie가 ACL 변경보다 낡은 데이터로 콘텐츠가 평가되는 상황을 방지하기 위해서 이후 ACL 체크의 staleness bound로 사용된다.

 

2. Read: 튜플을 직접 조회한다. tupleset을 입력받아 특정 ACL 항목, 그룹의 전체 멤버 목록, 특정 사용자가 직접 속한 그룹 목록 등을 읽을 수 있다.

Read는 userset rewrite rules를 따르지 않고 저장된 튜플을 그대로 반환한다. 유효한 접근 권한 전체를 이해하려면 Expand를 사용해야 한다.

 

3. Write: 튜플을 추가하거나 삭제한다.

Zanzibar는 낙관적 동시성 제어를 사용한다. 객체당 하나의 "lock tuple"을 두고 read-modify-write 사이클로 동작한다.

  1. lock tuple을 포함한 모든 관련 튜플을 읽는다.
  2. 쓰기 요청을 보내되, lock tuple이 읽은 이후 변경되지 않았을 때만 커밋하도록 조건을 건다.
  3. 조건이 충족되지 않으면 처음부터 재시도한다.

이 방식으로 동시 쓰기 충돌을 처리한다.

 

4. Watch: namespace의 tuple 변경을 구독한다. 시작 zookie와 함께 요청하면, 그 시점부터의 tuple 수정 이벤트를 타임스탬프 오름차순 스트림으로 받는다.

응답에는 heartbeat zookie가 포함돼, 어디까지 수신했는지 추적하고 구독을 재개하는 데 사용할 수 있다. Leopard 인덱싱 시스템의 증분 레이어가 Watch API를 통해 실시간으로 tuple 변경을 수신한다.

 

5. Expand: 가장 독특한 API다. Check가 "Bob이 이 문서에 접근할 수 있는가?"를 묻는다면, Expand는 "이 문서에 접근할 수 있는 사람 전체를 열거하라"는 역방향 질문에 답한다.

userset rewrite rules를 따라 재귀적으로 전개하며, 결과는 userset tree 형태로 반환된다 — 리프 노드는 사용자 ID 또는 다른 object#relation을 가리키는 userset이고, 중간 노드는 union·intersection·exclusion 연산자다. 공유 UI("이 문서에 접근 권한을 가진 사람"), 검색 인덱스의 접근 제어, 이후 살펴볼 Access Review에서 "현재 누가 권한을 가지고 있는가"를 확인하는 기반이 모두 Expand다.

 

이 다섯 가지 API가 어떻게 처리되는지 이해하려면 Zanzibar의 서버 구조를 봐야 한다.


아키텍처 — aclserver, watchserver, Spanner

Zanzibar는 두 종류의 서버 타입으로 구성된다.

 

  • aclserver: 주 처리 서버
    Check, Read, Expand, Write 요청을 받는다. aclserver들은 클러스터를 구성하며, 요청이 들어오면 필요에 따라 클러스터 내 다른 aclserver들에게 작업을 분산한다(fan-out). 그룹이 개인 멤버와 하위 그룹 둘 다 포함하는 경우처럼 복잡한 평가는 여러 서버가 협력해서 처리한다. 요청을 받은 서버가 중간 결과를 모아 최종 응답을 구성한다.

  • watchserver: Watch 요청 전담 서버
    Spanner의 changelog를 tail하며 tuple 변경 스트림을 클라이언트에 전달한다. Leopard 인덱싱 시스템의 incremental layer가 watchserver를 통해 실시간 변경을 수신한다.

스토리지는 Google의 전역 분산 데이터베이스인 Spanner 위에 구성된다. 레이턴시를 낮추기 위해 Zanzibar 서버가 있는 모든 리전에 Spanner와 Leopard replica를 최소 2개씩 배치한다. 스토리지는 용도별로 세 가지 데이터베이스로 분리돼 있다.

https://storage.googleapis.com/gweb-research2023-media/pubtools/5068.pdf

 

namespace당 별도 Spanner 데이터베이스를 두는 이유는 두 가지다.

하나는 특정 namespace의 부하가 다른 namespace에 영향을 주지 않게하기 위해 격리를 하는 것이며, 다른 하나는 샤딩 최적화이다. namespace마다 데이터 패턴이 달라 독립적인 샤딩 전략을 적용할 수 있다.

 

튜플의 primary key는 (shard ID, object ID, relation, user, commit timestamp)로 구성된다. commit timestamp를 포함하기 때문에 같은 튜플의 여러 버전이 공존할 수 있고, 가비지 컬렉션 윈도우 안에서 어느 시점의 스냅샷이든 조회가 가능하다.

 

요청 라우팅은 일관된 해싱(consistent hashing)으로 처리한다. 대부분의 namespace에서 포워딩 키는 object ID다. 같은 객체에 대한 Check와 Read 요청이 동일한 aclserver로 라우팅되기 때문에, 그 서버의 캐시가 최대한 활용된다.


성능 — 그래프 탐색을 어떻게 빠르게 만드는가

병렬 평가와 Leopard

병렬 평가만으로 해결되지 않는 경우가 있다. 그룹이 깊게 중첩되거나 자식 그룹 수가 매우 많을 때다. 이런 경우 재귀적 pointer chasing은 Spanner 읽기 횟수가 기하급수적으로 늘어나 레이턴시를 유지하기 어렵다.

 

Zanzibar는 이런 namespace에 Leopard 인덱싱 시스템(zanzibar 3.2.4절)을 선택적으로 적용한다.

Leopard의 핵심 아이디어는 그룹 멤버십을 두 가지 set type으로 미리 계산해두는 것이다.

  • GROUP2GROUP(s) : 그룹 s에 직/간접적으로 속하는 모든 하위 그룹
  • MEMBER2GROUP(u) : 사용자 u가 direct member로 속한 모든 parent group
"User U가 Group G의 멤버인가?"는 두 집합의 교집합 연산으로 환원된다.
(MEMBER2GROUP(U) ∩ GROUP2GROUP(G)) ≠ ∅

이 교집합은 skip-list 기반 인덱스에서 O(min(|A|, |B|))번의 탐색으로 처리된다. 재귀적으로 그래프를 따라가는 대신, 미리 계산된 인덱스를 조회하는 것으로 대체하는 구조다.

 

이 인덱스를 만들고 유지하기 위해 Leopard는 세 부분으로 구성된다. serving system, offline periodic index builder, 그리고 tuple 변경을 실시간으로 반영하는 incremental layer(논문에서는 online real-time layer로도 표현)다.

 

Offline index builder

relation tuple 스냅샷을 읽어 ACL 그래프의 엣지를 재귀적으로 순회하고, 유효한 모든 관계를 도출해 index shard로 만들어 전역에 복제한다. namespace config에 선언된 userset rewrite rule까지 적용해 도출하기 때문에, 이후 check 시점에는 재귀 탐색 없이 인덱스 조회만으로 처리된다.

 

Online incremental layer

offline snapshot만으로는 최신 변경사항을 반영할 수 없다는 한계를 보완한다. Watch API를 통해 tuple 변경 스트림을 수신하고, 각 변경을 Leopard tuple 추가·수정·삭제 이벤트로 변환해 인덱스에 적용한다. 쿼리 처리 시 쿼리 타임스탬프 이하의 incremental update가 offline index 위에 병합된다. 다만 GROUP2GROUP 비정규화 특성상 tuple 하나의 변경이 수만 개의 index 이벤트를 연쇄적으로 발생시킬 수 있다는 비용이 있다.

 

Serving system

offline index와 incremental layer를 합쳐 aclserver의 요청에 응답한다. index shard는 대부분 메모리에서 서빙되며, 논문 기준 median 150µsec, 99th percentile 1msec 이내로 응답한다.

 

마지막으로 timestamp quantization이 있다. 평가 타임스탬프를 1초 또는 10초 단위로 반올림해 서로 다른 zookie를 가진 요청들이 같은 캐시를 공유하게 한다. zookie의 at-least-as-fresh semantics 덕분에 이 반올림은 일관성을 깨지 않는다.

Hot Spot Mitigation

검색 쿼리 하나가 수십~수백 개의 문서에 대해 권한 확인을 수행하면, 같은 인기 그룹 ACL에 수많은 요청이 동시에 쏠린다. Zanzibar는 이것을 두 가지 방법으로 처리한다.

  • 각 aclserver는 lock table을 유지한다. 같은 캐시 키에 대한 동시 요청이 들어오면 하나만 처리를 시작하고 나머지는 대기한다. 캐시가 채워지면 대기 중인 요청들이 함께 결과를 받는다. 부모 ACL 체크 결과가 먼저 확정되면 통상 자식 체크를 즉시 취소(eager cancellation)하지만, lock table에 대기 중인 요청이 있으면 취소를 늦춘다. 취소하면 캐시가 채워지지 않아 대기 중인 요청들이 결국 더 느려지기 때문이다.
  • 인기 객체(hot object)에는 추가 최적화를 적용한다. 객체에 대한 미처리 읽기 요청 수를 추적해 hot object를 감지하고, 해당 object#relation의 relation tuples 전체를 미리 읽어 캐싱한다.

Request Hedging

Spanner와 Leopard 인덱스로의 느린 요청은 tail latency을 끌어올린다.

Zanzibar는 분산 된 두 백엔드에 request hedging을 적용한다. 단, 처음부터 여러 서버에 동시에 보내는 방식이 아니다. 첫 요청을 먼저 보내고, 각 서버가 최근 측정값 기반의 dynamic delay estimator로 "느리다"고 판단한 시점에만 hedged request를 추가로 전송한다. 이 방식으로 hedging으로 인한 추가 트래픽을 전체의 일부로 제한한다. 

 

단, Zanzibar 서버 간에는 hedging을 하지 않는다. 권한 체크는 연산 비용이 균일하지 않고, hedging이 가장 비싼 워크로드를 중복 실행하는 결과를 낳기 때문이다. 대신 일관된 샤딩과 느린 서버 감지 메커니즘으로 처리한다.


New Enemy Problem과 zookie

캐싱을 도입하면 일관성 문제가 생긴다. Zanzibar 논문은 이것을 두 가지 형태로 구분한다.

 

Example A — ACL 업데이트 순서 무시:

시작에서 본 시나리오가 바로 이 경우다. Alice가 Bob을 폴더에서 제거한 직후 Charlie가 새 문서를 그 폴더로 옮겼다. 두 ACL 변경 사이의 인과 순서를 권한 확인이 무시하면 Bob은 새 문서를 볼 수 있다.

 

Example B — 새 콘텐츠에 낡은 ACL 적용:

Alice가 Bob을 문서 ACL에서 제거했다. 그 직후 문서에 새 콘텐츠가 추가됐다. 문제는 ACL 변경이 아직 전파되지 않은 레플리카에서 권한을 평가하는 경우다. 그 레플리카 입장에서는 Bob이 여전히 접근 권한을 가진 것으로 보이기 때문에, 새 콘텐츠에 대한 접근이 허용된다. ACL 업데이트 순서의 문제가 아니라, ACL 변경과 콘텐츠 변경 사이의 인과 관계를 시스템이 인식하지 못하는 것이 원인이다.

두 경우 모두 "읽기가 관련 쓰기를 관측하지 못하는" 분산 시스템의 고전적 문제다.

 

Zanzibar의 해법이 zookie다. zookie는 Spanner 트랜잭션의 타임스탬프를 인코딩한 불투명한 바이트 시퀀스(opaque byte sequence)다. 단순 타임스탬프를 노출하지 않는 이유는 클라이언트가 임의 타임스탬프를 사용하지 못하게 하고 향후 구현 변경을 가능하게 하기 위해서다.

 

아래 시퀀스 다이어그램은 Example B 방지 흐름이다. Alice가 권한을 취소한 뒤 발급된 zookie를 콘텐츠 서버가 보관하고, 이후 Bob의 접근 시 체크에 포함시켜 취소 이후 스냅샷으로 평가를 강제한다.

zookie의 핵심은 "이 타임스탬프 이상의 데이터로 평가하라"는 staleness bound다. Zanzibar는 두 종류의 요청을 구분한다.

  • Safe 요청은 zookie가 10초 이상 된 경우로, 대부분 로컬 레플리카에서 처리된다.
  • Recent 요청은 zookie가 10초 미만인 경우로, 크로스 리전 라운드트립이 필요할 수 있다.

실제 트래픽 기준으로 Safe 요청이 Recent보다 약 100배 많다. 일관성을 보장하면서도 지연을 낮출 수 있는 이유가 여기 있다.


5년간의 운영 데이터

Zanzibar는 2019년 논문 발표 당시 이미 5년 이상 프로덕션에서 운영 중이었다. 논문 4절은 실제 운영 수치를 공개한다.

 

규모: 1,500개 이상의 namespace, 2조 개 이상의 relation tuples, 약 100TB. namespace 설정 파일 크기는 수십 줄에서 수천 줄까지 다양하며 중앙값은 약 500줄이다.

 

API별 QPS 분포: 2018년 12월 7일 샘플 기준, Read 8.2M, Check 4.2M, Expand 760K, Write 25K. 읽기 작업이 쓰기 작업보다 약 2자리 많다. Expand가 Check의 약 18% 수준인 것은 Expand가 주로 UI 렌더링과 인덱스 구축에 사용되고 빈번한 실시간 체크는 Check로 처리되기 때문이다.

 

레이턴시: Safe 요청 기준 P95는 10ms 미만을 유지한다.

Recent 요청의 P95가 Safe에 비해 크게 높은 이유는 크로스 리전 라운드트립 때문이다. Write가 느린 이유는 항상 Spanner 서버들 간의 분산 코디네이션을 필요로 하기 때문이다.

 

가용성: 3년간 99.999% 이상을 유지했다. 분기당 전체 오류 비율이 10%를 넘는 시간이 13분 미만이라는 뜻이다. 10,000개 이상의 서버가 30개 이상의 클러스터에 분산되어 있다.

 

내부 구조: 피크 기준 aclserver 간 내부 RPC가 초당 2,200만 건. 인메모리 캐싱이 초당 2억 건의 조회를 처리한다(체크 1.5억, 읽기 5,000만).


오픈소스 구현체 — SpiceDB와 OpenFGA

Zanzibar는 Google 내부 시스템으로 외부에 공개되지 않는다. 2019년 논문 공개 이후 이를 기반으로 한 오픈소스 구현체들이 등장했다.

 

SpiceDB(Authzed)는 Zanzibar의 설계를 가장 충실히 구현한 프로젝트다. namespace 설정과 userset rewrite rules 개념이 그대로 반영돼 있고, 스토리지 백엔드로 PostgreSQL, CockroachDB, Spanner를 지원한다. zookie에 해당하는 일관성 토큰도 구현돼 있다.

 

OpenFGA(Auth0/Okta)는 Zanzibar 모델을 기반으로 Fine-Grained Authorization 표준을 정의하려는 시도다. CNCF 샌드박스 프로젝트로, 설정을 JSON과 자체 DSL 두 가지로 작성할 수 있다.

 

두 구현체 모두 Zanzibar의 핵심 아이디어를 구현하지만, 분산 일관성 보장 수준은 Spanner 기반인 Zanzibar 원본에 미치지 못한다. Spanner의 TrueTime API가 외부 일관성의 기반인데, 오픈소스 환경에서 이를 대체하는 것이 근본적으로 어렵기 때문이다.


판단 기준

Zanzibar 기반 시스템이 필요한 시점은 세 조건이 동시에 성립할 때다. 객체 간 관계가 동적으로 변하고, 관계 상속이 여러 계층에 걸쳐 있으며, 권한 변경이 즉시 반영돼야 하는 요구사항이 있을 때다.

 

Google Workspace가 이 세 조건을 모두 갖춘 대표적인 사례다. 파일 공유, 프로젝트 멤버십, 조직 계층처럼 계층 관계가 접근 제어의 핵심인 서비스라면 SpiceDB나 OpenFGA를 검토할 시점이다. 반대로 역할이 단순하고 관계 상속이 없다면  RBAC나 ABAC로 충분하다.

 

API 선택도 설계 시점에 고려해야 한다. Check만 쓴다면 권한 확인에는 충분하지만, "이 리소스에 누가 접근할 수 있는가?"를 묻는 기능(공유 UI, Access Review)은 Expand 없이는 구현하기 어렵다. 초기에 어떤 질문이 필요한지 정리해두지 않으면, 나중에 데이터 모델을 뒤집어야 하는 상황이 생긴다.

 

스키마 설계에도 주의해야 한다. namespace 정의와 userset rewrite rules는 초기에 잘 설계해야 한다. 이후 변경은 기존 tuple과의 호환성 문제로 이어질 수 있다. Zanzibar 논문은 이 점에서 교훈을 하나 남겼다.

 

클라이언트마다 접근 제어 패턴이 크게 달라, computed_userset와 tuple_to_userset는 초기 설계에 없었다가 운영 중에 추가됐다. "이 시스템에서 주로 묻는 권한 질문이 무엇인가"를 먼저 정리하는 것이 출발점이다.

 

 

참고자료: https://storage.googleapis.com/gweb-research2023-media/pubtools/5068.pdf