쿠버네티스 Admission Control #2 - Open Policy Agent

지난 쿠버네티스 Admission Control #1에서는 Admission Control의 개념에 대해 알아보았고 직접 Custom Admission Controller을 만들어 봤습니다. 이번 시간에는 General Policy Controller인 Open Policy Agent(OPA)에 대해서 알아보고 OPA Policy를 이용하여 사용자의 세부 사용성을 관리해 보겠습니다.

쿠버네티스 Admission Control 시리즈

  1. 사용자 Webhook: 사용자가 직접 webhook을 구현함으로써 세밀한 접근관리를 수행합니다.
  2. Open Policy Agent: OPA의 개념과 그 Query 언어인 Rego에 대해서 살펴 봅니다.
  3. Open Policy Agent 적용: OPA를 직접 쿠버네티스에 적용해 봅니다.

Open Policy Agent란?

OPA는 플랫폼 관리자에게 체계적이고 세밀한(fine-graded) 권한관리를 손쉽게 할 수 있는 툴을 제공하는 오픈소스입니다. OPA는 쿠버네티스 뿐만 아니라 OPA 엔진을 이해하는 어떠한 플랫폼 위에서도 동작합니다. OPA는 실제로 요청을 막는 메커니즘은 구현되어 있지 않습니다. 그것은 각 플랫폼마다 다르기 때문에 그 플랫폼의 메커니즘을 그대로 사용합니다. 대신 OPA는 관리자가 작성한 Policy를 읽고 해당 요청을 허용할지 반려할지만을 판단합니다. 각 플랫폼에서는 OPA의 판단 내용을 기반하여 실제 Admission Control 동작을 수행합니다.

Part#1에서 살펴본 flask webhook을 살펴 보겠습니다. 해당 webhook도 단지 들어 온 요청을 반려할지 말지만을 판단(response.allowed) 하였고 실제 동작은 쿠버네티스에서 수행하였습니다. 이와 마찬가지로 OPA도 관리자가 작성한 Policy를 기반하여 플랫폼에게 Admission Control의 결과를 전달합니다. 해당 포스트에서는 쿠버네티스 플랫폼 위에서의 OPA 사용을 중심으로 설명드립니다.

혹자는 OPA의 역할이 너무 제한적이지 않는가에 대해 의문을 가질 수도 있습니다. 실제 Admission을 수행하는 메커니즘(enforcement)은 없고 정책(Policy)만을 판단하는 엔진은 마치 집행력이 없는 사법부와 같은 느낌을 줍니다. 이에 대한 OPA만의 철학으로 Policy Decoupling의 중요성을 얘기합니다. 간단하게 정리하자면 다음과 같습니다.

Policy를 Admission enforcement로부터 분리(decouping)했을 때의 이점으로,

  • 재컴파일, 재배포 없이 관리자 정책을 변경할 수 있습니다.
  • 공통된 정책을 다양한 플랫폼에 일관되게 적용할 수 있습니다.
  • 정책을 정의하고 유지보수하는 작업을 줄일 수 있습니다.

출처: https://www.openpolicyagent.org/docs/latest

OPA의 전반적인 모습은 위와 같습니다. Service라고 표현되어 있는 부분이 OPA를 사용하는 플랫폼입니다. 여기서는 쿠버네티스가 되겠죠. Service는 OPA 엔진에게 요청에 대해서 허용 여부를 질의합니다. 이때 사용되는 인터페이스는 JSON 객체입니다. (Part#1에서 살펴 본 webhook의 input, output 인터페이스를 생각하면 됩니다.) 질의를 요청 받은 OPA는 저장된 Policy를 불러와서 요청에 대해 평가(evaluate)하고 그 결과를 다시 JSON 형식으로 Service에 반환합니다.

Rego Language

OPA는 Policy를 기반하여 그 정책에 맞게 사용자 접근을 관리합니다. Policy는 OPA에서 핵심적인 역할을 담당합니다. OPA를 어떻게 사용하는가는 이 Policy를 어떻게 작성하느냐에 따라 달려 있습니다. OPA에서는 Policy를 Rego라는 자체적인 질의언어(Query Language)를 이용하여 표현합니다. Rego는 일반적인 imperative 언어와는 조금 다르게 declarative로 동작합니다. 선언적으로 표현되기 때문에 초기 Rego 언어를 이해하는데 조금 시간이 걸립니다. 예를 들어, 필자가 처음 Rego를 접했을 때 당황했던 것은 imperative 언어에서 많이 사용하는 if, while, for와 같은 문법이 Rego 언어에서는 지원하지 않습니다. 물론 조금 다른 방법으로 이러한 기능들을 제공하긴 하지만 처음 그 컨셉을 이해하는데 어색할 수도 있습니다. 저도 아직까지 완벽하게 Rego에 대해 이해한다고 말하긴 어렵지만 OPA 웹페이지에서 제공하는 여러 튜토리얼을 따라하다 보면 쿠버네티스의 간단한 Admission Control 로직 정도는 가볍게 작성할 수 있을 것 같습니다. 또한 OPA에서는 자신이 만든 Policy를 편리하게 검증해 볼 수 있게 Rego Playground라는 온라인 Query 테스트기를 제공합니다. 처음 Rego라는 언어를 이해하기에 매우 좋은 툴입니다. 자신이 만든 Policy를 실제 적용하기 전에 이 툴을 통해 꼭 검증해 보시기 바랍니다.

그럼 이제 본격적으로 Rego 언어에 대해서 이해해 보는 시간을 가져보도록 하겠습니다. 다음 예시들은 OPA 공식 사이트에서 나오는 것들을 조금 쉽게 수정하여 설명한 것입니다. 더욱 자세하고 정확한 내용은 해당 사이트를 참고해 주시기 바랍니다.

Basics

Rego에서 변수를 선언해 봅시다. 다음 구문은 pi라는 변수가 선언되고 3.14159값이 assign 됩니다.

pi := 3.14159

배열과 사전형 자료구조도 지원합니다.

# array
cities := ["seoul", "busan", "naju"]
# map
dict := {
    "val": 1,
    "hello": "world",
    "foo": "bar"
}

여기까지는 여느 언어와 비슷하게 보입니다. 다음으로 Rule이라는 개념에 대해서 소개합니다. Rule은 일반적인 imperative 언어와는 조금 다르기 때문에 처음 봤을 때부터 이해하기에 조금 어려울 수도 있습니다. 필자도 처음 Rule이라는 개념을 처음 접했을 때 이해하는데 시간이 걸렸고 솔직히 지금도 완벽하게 이해하였다고 말하기 힘들 것 같습니다. 그렇다고 너무 겁 먹으실 필요는 없습니다. 기본개념 자체는 간단합니다. 단지 익숙하지 않을 뿐입니다.

Rule

Rule은 Rego 언어에서의 기본 평가 단위입니다. 먼저 아래 표현식을 살펴 봅시다.

v { 
    "hello" == "hello" 
}
# true

v { 
    "hello" == "world"
}
# undefined

괄호 안의 표현식이 true이면 v의 값이 true로 선언되고 그렇지 않으면 선언되지 않습니다.(undefined) (실제로 undefined라는 문자열이 출력되지 않습니다. 이해를 돕기 위해 표시합니다.) 사전형 자료구조와 헷갈리면 안됩니다.(개인적으로 많이 헷갈렸습니다.) 사전형 자료구조와는 다르게 :=가 없어 변수에 특정 값을 assign하는 것이 아니라 v라는 Rule을 선언한 것입니다.

Rule 안에서 변수도 활용할 수 있습니다.

msg := "hello"
v { 
    msg == "hello" 
}
# true

v { 
    msg == "world"
}
# undefined

imperative 언어로 표현하자면 다음과 같습니다.

msg = "hello";
if (msg == "hello") {
    v = true;
}

만약 undefined가 아닌 false가 선언되도록 바꾸고 싶다면 default라는 키워드를 사용하면 됩니다.

default v = false    # 아무런 값이 선언되지 않을 시(undefined), 기본값으로 false를 사용
msg := "hello"

v { 
    msg == "world" 
}
# false

imperative 언어로 표현하자면 다음과 같습니다.

v = false;
msg = "hello";

if (msg == "hello") {
    v = true;
}

만약 rule v에 boolean(true, false) 타입 이외에 다른 값을 넣고 싶다면 다음과 같이 선언할 수 있습니다.

pi := 3.14159

v = 10 {
    pi > 3
}
# 10

imperative 언어로 표현하자면 다음과 같습니다.

pi = 3.14159;

if (pi > 3) {
    v = 10;
}

그러므로 특정값을 assign하지 않는 Rule도 사실은 true라는 값 assign이 생략되어 있다고 볼 수 있습니다.

v {
    msg == "hello"
}
# true

# is same as

v = true { 
    msg == "hello" 
}
# true

더 나아가 변수 assign하는 문법도 Rule로 표현하자면 다음과 같습니다.

pi = 3.14159 {
    true
}

사실 Rego 언어에서는 변수도 전부 Rule로 인식합니다. 다시 말해 Rego에서는 모든 것이 Rule로 표현됩니다.

rule은 여러 expression으로 구성될 수도 있습니다. rule의 body({...} 부분)은 AND 조건으로 동작합니다. 그 뜻은 body안에 한개의 expression이라도 false로 평가되면 rule 전체가 undefined로 평가됩니다.

pi := 3.14159
msg := "hello"

v = 10 {
    pi > 3           # true
    3 > 4            # false
    msg == "hello"   # true
}
# undefined

imperative 언어로 표현하자면 다음과 같습니다.

pi = 3.14159;
msg = "hello";

if (pi > 3 && 3 > 4 && msg == "hello") {
    v = 10;
}

앞에서 배운 default 키워드를 조합하면 이렇게도 사용할 수 있습니다.

default v = 0
msg := "hello"

v = 10 {
    pi > 3           # true
    3 > 4            # false
    msg == "hello"   # true
}
# 0

Rule의 개념을 정리하자면 다음과 같습니다. 우리가 하고 싶은 것은 Rule을 정의하는 것입니다. 이때 Body의 조건에 따라서 Rule이 정의될 수도, undefined될 수도 있습니다. 또한 정의를 할 때, true, false 이외의 사용자가 원하는 값을 선언할 수도 있습니다. 세부적인 Rule의 문법을 이해하기 보다는 전반적인 모양을 살펴 보기를 추천 드립니다.

RULE_NAME = VALUE {
    Expression1
    Expression2
    Expression3
    ...
}
if (Expression1 && Expression2 ...) {
    RULE_NAME = VALUE;
} 

For-each expression

이번에는 Rule 안에서 for 구문 을 어떻게 선언하는지 살펴 보겠습니다. 먼저 다음과 같은 리스트가 있습니다.

sites = ["dev", "stage", "prod"]

이때 아래와 같이 선언하면 for each처럼 동작합니다. _은 for each item를 나타냅니다. sites를 하나씩 순회하며 조건문에 일치하는 item이 있는지 확인하고 있다면 true를 그렇지 않다면 undefined를 선언합니다.

foreach { 
    sites[_] == "demo"
}
# undefined

foreach {
    sites[_] == "prod"
}
# true

이번에는 _ 키워드를 사용하지 않고 some이라는 키워드를 이용하여 동일한 표현을 갖도록 수정해 보겠습니다.

foreach {
    some i
    sites[i] == "prod"
}

some이라는 키워드는 명시적으로 조건에 해당하는 인덱스 값을 찾고자할 때 사용할 수 있습니다. 예를 들어 아래와 같이 some i의 값을 idx에 저장하면 명시적으로 몇번째 인덱스의 값이 prod인지 확인할 수 있습니다.

foreach {
    some i
    sites[i] == "prod"
    idx := i
}
# idx에 인덱스 2가 저장됩니다.

Set

Rego에는 Set 자료구조형도 지원합니다. Set을 정의해 봅시다.

myset[items] {
    items := ["item1", "item2"]
}
# ["item1", "item2"]

Set이 마치 Rule처럼 보이죠? 네, 맞습니다. Set도 Rule의 일종입니다. 위의 표현식으로 myset 안에는 “item1”, “item2” 이라는 item들이 존재하게 됩니다. 약간 괴랄하죠?ㅋㅋ 하지만 신기하게도 이 문법이 OPA에서 가장 많이 사용되는 패턴 중 하나입니다. Set도 Rule의 일종이기 때문에 Condition expression을 넣을 수 있습니다.

pi := 3.14
myset[items] {
    pi > 3
    items := ["item1", "item2"]
}
# ["item1", "item2"]

Set은 중간의 한개 expression이라도 false가 평가되면 undefined으로 나오지 않고 비어있는 Set이 정의됩니다.([])

pi := 3.14
myset[items] {
    pi > 4
    items := ["item1", "item2"]
}
# []

Set을 정의하고 난 뒤 Set을 이용하여 Set 안에 item이 있는지를 확인하는 Rule을 만들어 봅시다.

is_in_myset {
    myset["item1"]
}
# true

is_in_myset {
    myset["item3"]
}
# undefined

imperative 언어로 표현하자면 다음과 같습니다.

if (myset.contains("item1")) {
    is_in_myset = true;
}

Set에 default 값을 설정하여 item이 없으면 false를 평가하도록 수정합니다.

default is_in_myset = false

is_in_myset { 
    myset["item1"]
}
# true

is_in_myset { 
    myset["item3"]
}
# false

여기까지 오셨다면 축하드립니다. 어려운 부분을 거의 다 넘겼습니다. 마지막 키워드 하나만 더 설명 드리고 Playground에서 직접 테스트 해봅시다.

Build-in keyword

Playground에서 직접 테스트를 진행해 보기 전에 한 가지 내용만 더 설명 드리겠습니다. OPA에는 Build-in keyword가 몇 가지 있는데 그 중에 input이라는 녀석이 존재합니다. 해당 키워드는 사용자로부터 들어오는 input JSON 객체를 의미합니다.

# 예를 들어 다음과 같은 JSON 객체가 OPA로 들어 올때,
{
    "message": "world"
}

# hello rule은 true가 평가됩니다.
hello {
    m := input.message
    m == "world"
}
# true

input JSON 객체가 사전형 자료구조로 입력되고 message property를 변수 m에 assign하고 그것을 평가합니다.

이제 Playgroun으로 가서 직접 테스트해 봅시다!

Playground

https://play.openpolicyagent.org/

좌측 패널은 사용자가 직접 Policy를 작성해 볼 수 있는 editor 공간입니다. 우측 상단은 input으로 들어올 JSON 객체를 정의합니다. 마지막으로 우측 하단은 Evaluate이라는 버튼을 눌렀을 때 Policy의 결과가 나오는 output 패널입니다.


맨 처음으로 나오는 예시는 다음과 같습니다. package는 자바와 같이 이름공간을 의미합니다. 그 외에 나머지는 앞에서 살펴 본 내용들입니다. 바로 우측 상단의 Evaluate 버튼을 눌려 봅시다.

package play


default hello = false

hello {
    m := input.message
    m == "world"
}

hello: true라는 결과가 output 패널에 나옵니다. hello라는 Rule의 평가 결과가 true인 것을 의미합니다.

{
    "hello": true
}

이번에는 input 패널의 JSON 객체에서 message의 값을 bob으로 수정해 봅시다. 예상한대로 hello: false라는 결과가 나옵니다.

Example: Hello World

그럼 이제 The Rego Playground 제목 옆에 있는 Examples dropdown 메뉴를 눌러서 Kubernetes > Hello World를 클릭해 봅시다. 다음과 같은 Policy가 나옵니다. 우측 상단 input 패널에 보이는 JSON 객체는 Part#1에서 Webhook으로 들어오는 input JSON 객체와 100% 동일합니다. 이것은 쿠버네티스가 Webhook이든 OPA든 동일한 input JSON을 전달해주기 때문입니다.

package kubernetes.validating.label


deny[msg] {
    value := input.request.object.metadata.labels.costcenter

    not startswith(value, "cccode-")

    msg := sprintf("Costcenter code must start with `cccode-`; found `%v`", [value])
}

예시의 Rego Policy가 달성하고자 하는 정책을 미리 말씀 드리자면, 요청하는 쿠버네티스 리소스 객체에서 costcenter라는 라벨의 값이 cccode-로 시작하지 않으면 반려하라는 의미를 갖습니다.

먼저 deny라는 Set을 정의하고 그 안에 msg라는 item의 값을 assign합니다. 만약 deny Set rule 끝까지 true가 평가되면 msg에 특정 값(여기서는 문자열 값)이 assign되고 중간에 false가 evaluate되면 deny에 빈 Set이 정의됩니다. (deny: []) 첫번째 expression은 요청하는 객체의 label에 costcenter라는 라벨의 값을 value에 assign합니다. 그리고 해당 value의 값이 cccode-로 시작하는지 확인합니다. 만약 cccode-로 시작하지 않는다면 결과적올 true로 선언되어(not startswith) 최종적으로 msg item에 값이 부여됩니다. msg에는 대략 다음과 같은 문자열이 들어갈 것입니다: deny: [Costcenter code must start with 'cccode-'; found fakecode] OPA는 해당 rule(Set)의 결과를 쿠버네티스로 전달하여 최종적으로 쿠버네티스가 사용자의 요청을 반려(deny)하게 됩니다.

이번에 input JSON 객체에서 costcenter의 값을 cccode-fakecode로 수정하고 다시 Evaluate 버튼을 눌러 봅시다.

{
    "deny": []
}

이번에는 전과 다르게 deny의 빈 Set이 정의되었습니다. 중간에 false가 평가되었기 때문입니다.(not startswith 부분)

Example: Image Safety

이번에는 Example에서 Kubernetes > Image Safety를 클릭합니다. 해당 예시에서는 사용자가 요청한 Pod 리소스의 이미지 주소가 hooli.com으로 시작하는지 확인하는 정책입니다. 예를 들어 다음과 같이 동작합니다.

  • Pod image hooli.com/nginx: Allowed
  • Pod image docker.io/nginx: Denied
package kubernetes.validating.images

deny[msg] {
    some i
    input.request.kind.kind == "Pod"
    image := input.request.object.spec.containers[i].image
    not startswith(image, "hooli.com/")
    msg := sprintf("Image '%v' comes from untrusted registry", [image])
}

앞서 살펴 본 some 키워드를 사용하여 모든 이미지를 순회합니다. 이때 사용자의 요청이 Pod인지(input.request.kind.kind)를 확인하여 해당 Pod에 존재하는 모든 컨테이너의 이미지를 image라는 변수에 저장합니다. 그리고 난 뒤 각 이미지가 hooli.com으로 시작하는지를 확인하여 그렇지 않다면 최종적으로 msg에 값이 부여됩니다. 이때 some 키워드를 사용하지 않고 _를 이용하여 표현하자면 다음과 같이 수정할 수 있습니다.

package kubernetes.validating.images

deny[msg] {
    input.request.kind.kind == "Pod"
    image := input.request.object.spec.containers[_].image
    not startswith(image, "hooli.com/")
    msg := sprintf("Image '%v' comes from untrusted registry", [image])
}

우측 상단의 input 패널에서 두번째 mysql-backed 컨테이너의 이미지를 hooli.com/mysql로 수정하여 다시 Evaluate해 봅시다. 이번에는 deny 메세지 없이 정상적으로 통과하는 것을 확인할 수 있습니다.

어떤가요? 쿠버네티스의 기본적인 Authorization으로는 불가능했던 정책들이 Rego를 통해서는 몇줄 되지 않지만 엄청나게 강력하게 사용할 수 있지 않나요? Rego의 input으로 쿠버네티스 리소스의 전체 spec이 넘어오기 때문에 생각할 수 있는 거의 모든 정책들을 세밀하게 설정할 수 있습니다.

마치며

이번 포스트에서는 General Policy 엔진인 Open Policy Agent가 무엇인지 살펴 보고 Policy를 정의할 수 있는 Rego 언어에 대해 알아보았습니다. 처음 Rego 언어를 접하면 생각보다 쉽지 않다고 느낄 수도 있지만 Playground 사이트와 함께 차근차근 실행 결과를 확인해 보면 전반적인 감을 잡을 수 있으리라 생각합니다. 또한 쿠버네티스에서 사용하는 Rego 스크립트들을 계속해서 살펴 보면 전반적으로 비슷한 패턴을 가지는 것을 알 수 있기 때문에 금방 적응할 수 있습니다. 다음 포스트에서는 OPA를 직접 쿠버네티스 Dynamic Admission Controller로 연결하여 관리자가 원하는대로 쿠버네티스 사용자의 세부적인 접근을 관리할 수 있는지 확인해 보겠습니다.