LabHub
배우기 러닝패스 코스

Policy as Code

The exit code was 0, so the pipeline stayed green for weeks

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

진짜 OpenTofu 계획을 JSON 으로 뽑아 구조를 읽고, 그 JSON 에 정책을 걸어 클러스터와 클라우드에 닿기 전에 파괴·교체·빠진 태그를 잡습니다. 그리고 위반을 찾고도 0 으로 끝나는 도구 위에서 믿을 수 있는 게이트를 만듭니다.

왜 중요한가

어드미션 컨트롤은 이미 만들어진 요청을 봅니다. 그런데 데이터베이스 교체나 버킷 공개 같은 위험한 변경은 쿠버네티스 API 를 지나지 않고 클라우드로 곧장 갑니다. 그 변경들에는 공통점이 하나 있습니다 — 적용 전에 계획 단계가 있고, 그 계획은 JSON 으로 뽑을 수 있는 구조화된 문서라는 것입니다. 여기에 정책을 걸면 되돌릴 것이 아직 없을 때 막을 수 있습니다. 다만 계획에는 적용해 봐야 정해지는 값이 섞여 있어서, 그 자리를 모르고 규칙을 쓰면 오탐이 쏟아지고 게이트는 며칠 만에 꺼집니다. 그리고 판정을 도구의 종료 코드에 맡기면, 위반을 찾고도 0 으로 끝나는 도구 하나 때문에 파이프라인이 몇 주 동안 조용히 초록불일 수 있습니다. 그래서 이 실습은 규칙을 쓰는 법만큼이나 '무엇을 근거로 판정하는가'를 다룹니다.

단계

  1. /root/tfpolicy/main.tf 에 local·random·null 프로바이더를 요구하고 리소스 넷을 선언하세요. null_resource.api 의 triggers 는 owner = "platform"·env = "dev", null_resource.worker 의 triggers 는 owner = "data"·env = "dev", local_file.config${path.module}/out/config.txtv1 한 줄(끝에 줄바꿈)을 쓰고, terraform_data.release 는 input 이 v1 입니다. tofu inittofu apply -auto-approve 로 적용한 다음, 변경이 하나도 남지 않은 계획을 /root/tfpolicy/base.tfplan 으로 저장하고 tofu show -json 으로 /root/tfpolicy/base.json 을 만드세요.
  2. main.tf 를 이렇게 고치세요. null_resource.worker 블록을 지우고, random_pet.suffix(length = 2)를 더하고, null_resource.cache 를 더합니다(triggers 는 owner = ""·env = "dev"·name = random_pet.suffix.id). local_file.config 의 내용은 v2 한 줄로, terraform_data.release 의 input 은 v2 로 바꿉니다. 적용하지 마세요. 계획을 /root/tfpolicy/change.tfplan 으로 저장하고 /root/tfpolicy/change.json 을 만든 뒤, /root/tfpolicy/changes.txtno-op 이 아닌 자원마다 <주소> <create|update|replace|delete> 를 한 줄씩 적으세요. 삭제와 생성이 한 자원에 함께 들어 있으면 replace 한 줄로 적습니다.
  3. /root/tfpolicy/change.jsonresource_changes[].change.after_unknown 을 읽어 /root/tfpolicy/unknown.txt 를 만드세요. 모르는 잎(값이 true 인 자리)이 하나라도 있는 자원마다 <주소> <모르는 잎 개수> 를 한 줄씩 적습니다. triggers.name 처럼 중첩된 자리도 잎 하나로 셉니다. 모르는 잎이 없는 자원은 적지 않습니다. 줄 순서는 보지 않습니다.
  4. /root/tfpolicy/policies/block.yamlapiVersion: json.kyverno.io/v1alpha1·kind: ValidatingPolicy 인 정책을 쓰세요. 규칙 하나를 두고, change.actionsdelete 가 들어 있는 자원이 하나도 없어야 통과하게 합니다(순수 삭제와 교체가 모두 걸립니다). 그다음 KYVERNO_EXPERIMENTAL=true kyverno json scan --payload /root/tfpolicy/change.json --policy /root/tfpolicy/policies/block.yaml 을 돌리고, 그 출력 전체와 종료 코드를 /root/tfpolicy/scan-exit.txt 에 저장하세요. 종료 코드는 exit=<코드> 꼴로 마지막에 한 줄 덧붙입니다. /root/tfpolicy/base.json 에도 같은 스캔을 돌려 통과하는 것을 눈으로 확인하세요.
  5. /root/tfpolicy/gate.sh <계획JSON> 을 만드세요. policies/block.yaml 로 스캔을 돌려 보고서를 파싱합니다. 위반 줄(FAILED 또는 ERROR: 가 든 줄)마다 BLOCK 을 앞에 붙여 한 줄씩 출력하고, 마지막 줄RESULT block=<위반 줄 수> 를 출력하세요(뒤에 낱말이 더 붙어도 됩니다). 위반이 없으면 0, 하나라도 있으면 0 이 아닌 값으로 끝냅니다. 보고서에 판정 줄(PASSED·FAILED·ERROR:)이 하나도 없으면 도구 출력이 바뀐 것이므로 2 로 끝내고, 인자로 받은 파일이 없을 때도 2 로 끝냅니다. 채점기는 자기가 만든 깨끗한 계획과 위반 계획으로 이 스크립트를 돌립니다.
  6. /root/tfpolicy/policies/block.yaml 에 두 번째 규칙을 더하세요. change.after.triggers 가 있는 자원은 change.after.triggers.owner있고 빈 문자열이 아니어야 통과합니다. 다만 그 값이 계획 단계에 아직 알려지지 않은 자원(change.after_unknown.triggers.ownertrue)은 위반으로 세지 않습니다. 규칙을 더한 뒤 ./gate.sh /root/tfpolicy/change.json 을 다시 돌려 위반이 두 줄이 되는지 확인하세요. 채점기는 owner 가 빈 계획·owner 가 아직 모르는 값인 계획·깨끗한 계획 셋으로 이 규칙을 시험합니다.
  7. /root/tfpolicy/policies/warn.yaml 에 두 번째 정책 파일을 만드세요. 규칙 하나를 두고, 계획 JSON 의 최상위 resource_drift 가 비어 있어야 통과하게 합니다(키 자체가 없는 계획도 통과해야 합니다). 그리고 /root/tfpolicy/gate.sh 를 고쳐 두 정책을 따로 돌리게 하세요. block.yaml 의 위반은 BLOCK 을 붙여 출력하고 종료 코드를 0 이 아니게 만들고, warn.yaml 의 위반은 WARN 을 붙여 출력하되 종료 코드를 바꾸지 않습니다. 마지막 줄은 RESULT block=<차단 위반 수> warn=<경고 위반 수> 로 바꿉니다. 경고 정책 보고서에도 판정 줄이 하나도 없으면 2 로 끝냅니다.
  8. /root/tfpolicy/out/config.txt 를 테라폼 밖에서 직접 고치세요(예: printf 'hand-edited\n' > /root/tfpolicy/out/config.txt). 그다음 계획을 /root/tfpolicy/drift.tfplan 으로 저장하고 /root/tfpolicy/drift.json 을 만드세요 — 최상위에 resource_drift 가 생깁니다. 마지막으로 base.json·change.json·drift.json 세 계획에 ./gate.sh 를 돌려 출력을 각각 /root/tfpolicy/reports/base.txt·/root/tfpolicy/reports/change.txt·/root/tfpolicy/reports/drift.txt 에 저장하고, 각 파일 마지막 줄EXIT <게이트 종료 코드> 를 덧붙이세요. 채점기는 세 계획에 게이트를 다시 돌려 보고서와 대조합니다.

참고

계획을 JSON 으로 뽑아 놓는다

/root/tfpolicy/main.tf 에 local·random·null 프로바이더를 요구하고 리소스 넷을 선언하세요. null_resource.api 의 triggers 는 owner = "platform"·env = "dev", null_resource.worker 의 triggers 는 owner = "data"·env = "dev", local_file.config${path.module}/out/config.txtv1 한 줄(끝에 줄바꿈)을 쓰고, terraform_data.release 는 input 이 v1 입니다. tofu inittofu apply -auto-approve 로 적용한 다음, 변경이 하나도 남지 않은 계획을 /root/tfpolicy/base.tfplan 으로 저장하고 tofu show -json 으로 /root/tfpolicy/base.json 을 만드세요.

local·random·null 은 파드 안 미러에서 받아지므로 인터넷이 없어도 init 이 됩니다. terraform_data 는 프로바이더 없이 쓰는 내장 리소스입니다. 적용을 마친 직후에 세운 계획은 모든 자원의 actions 가 no-op 입니다 — 정책을 시험할 때 쓸 깨끗한 페이로드가 바로 이것입니다. 계획 파일은 -out 으로 저장하고, JSON 은 그 파일을 tofu show -json 에 넣어 얻습니다.

한 계획에 네 가지 동작이 한꺼번에 들어온다

main.tf 를 이렇게 고치세요. null_resource.worker 블록을 지우고, random_pet.suffix(length = 2)를 더하고, null_resource.cache 를 더합니다(triggers 는 owner = ""·env = "dev"·name = random_pet.suffix.id). local_file.config 의 내용은 v2 한 줄로, terraform_data.release 의 input 은 v2 로 바꿉니다. 적용하지 마세요. 계획을 /root/tfpolicy/change.tfplan 으로 저장하고 /root/tfpolicy/change.json 을 만든 뒤, /root/tfpolicy/changes.txtno-op 이 아닌 자원마다 <주소> <create|update|replace|delete> 를 한 줄씩 적으세요. 삭제와 생성이 한 자원에 함께 들어 있으면 replace 한 줄로 적습니다.

tofu show -json 결과의 resource_changes[] 에는 addresschange.actions 가 있습니다. actions 가 두 원소짜리 배열이면 교체입니다 — 추가 하나와 삭제 하나로 두 번 세면 틀립니다. 어떤 속성이 교체를 부르는지는 프로바이더가 정하므로 계획 출력의 # forces replacement 표시로도 확인할 수 있습니다. 줄 순서는 채점에서 보지 않습니다.

계획이 아직 모르는 값을 세어 둔다

/root/tfpolicy/change.jsonresource_changes[].change.after_unknown 을 읽어 /root/tfpolicy/unknown.txt 를 만드세요. 모르는 잎(값이 true 인 자리)이 하나라도 있는 자원마다 <주소> <모르는 잎 개수> 를 한 줄씩 적습니다. triggers.name 처럼 중첩된 자리도 잎 하나로 셉니다. 모르는 잎이 없는 자원은 적지 않습니다. 줄 순서는 보지 않습니다.

after_unknownafter 와 같은 구조를 가지되 모르는 잎만 true 로 남기고 아는 잎은 아예 빠진 객체입니다. jq 의 paths(조건) 은 조건을 만족하는 값의 경로를 모두 냅니다 — [paths(. == true)] | length 로 잎을 셀 수 있습니다. 정책을 쓸 때 이 자리가 중요한 이유는, after 에 값이 없다고 해서 곧바로 위반이라고 단정하면 오탐이 나기 때문입니다.

규칙을 데이터로 적고 종료 코드를 찍어 본다

/root/tfpolicy/policies/block.yamlapiVersion: json.kyverno.io/v1alpha1·kind: ValidatingPolicy 인 정책을 쓰세요. 규칙 하나를 두고, change.actionsdelete 가 들어 있는 자원이 하나도 없어야 통과하게 합니다(순수 삭제와 교체가 모두 걸립니다). 그다음 KYVERNO_EXPERIMENTAL=true kyverno json scan --payload /root/tfpolicy/change.json --policy /root/tfpolicy/policies/block.yaml 을 돌리고, 그 출력 전체와 종료 코드를 /root/tfpolicy/scan-exit.txt 에 저장하세요. 종료 코드는 exit=<코드> 꼴로 마지막에 한 줄 덧붙입니다. /root/tfpolicy/base.json 에도 같은 스캔을 돌려 통과하는 것을 눈으로 확인하세요.

kyverno-json 의 assert.check 는 열쇠가 JMESPath 식이고 값이 기대값입니다 — 괄호로 감싼 열쇠 (식) 에 기대값 0 을 두면 '그 식의 결과가 0 이어야 한다'가 됩니다. 배열 안에 특정 문자열이 있는지는 contains(배열, '값') 으로 묻습니다. 식을 따로 시험해 보고 싶으면 kyverno jp query -i <파일> '<식>' 을 쓰세요. 출력과 종료 코드를 함께 담을 때는 명령 > 파일 2>&1; echo "exit=$?" >> 파일 꼴이 편합니다.

종료 코드를 버리고 보고서를 센다

/root/tfpolicy/gate.sh <계획JSON> 을 만드세요. policies/block.yaml 로 스캔을 돌려 보고서를 파싱합니다. 위반 줄(FAILED 또는 ERROR: 가 든 줄)마다 BLOCK 을 앞에 붙여 한 줄씩 출력하고, 마지막 줄RESULT block=<위반 줄 수> 를 출력하세요(뒤에 낱말이 더 붙어도 됩니다). 위반이 없으면 0, 하나라도 있으면 0 이 아닌 값으로 끝냅니다. 보고서에 판정 줄(PASSED·FAILED·ERROR:)이 하나도 없으면 도구 출력이 바뀐 것이므로 2 로 끝내고, 인자로 받은 파일이 없을 때도 2 로 끝냅니다. 채점기는 자기가 만든 깨끗한 계획과 위반 계획으로 이 스크립트를 돌립니다.

이 단계의 핵심은 if kyverno json scan ...; then 을 쓰지 않는 것입니다 — 그 도구는 위반을 찾아도 0 으로 끝납니다. 출력을 변수에 담아 grep -E 로 세고, 센 값이 0 인지 아닌지로 끝냅니다. 위반이 0 건이라는 판정과 '아무것도 못 읽었다'는 판정을 반드시 갈라야 합니다 — 도구를 판올리면 전자로 보이는 후자가 생깁니다. set -e 는 grep 이 아무것도 못 찾았을 때 스크립트를 먼저 죽이니 쓰지 마세요.

태그를 요구했더니 아직 모르는 값까지 걸렸다

/root/tfpolicy/policies/block.yaml 에 두 번째 규칙을 더하세요. change.after.triggers 가 있는 자원은 change.after.triggers.owner있고 빈 문자열이 아니어야 통과합니다. 다만 그 값이 계획 단계에 아직 알려지지 않은 자원(change.after_unknown.triggers.ownertrue)은 위반으로 세지 않습니다. 규칙을 더한 뒤 ./gate.sh /root/tfpolicy/change.json 을 다시 돌려 위반이 두 줄이 되는지 확인하세요. 채점기는 owner 가 빈 계획·owner 가 아직 모르는 값인 계획·깨끗한 계획 셋으로 이 규칙을 시험합니다.

JMESPath 의 필터에서 JSON 리터럴은 역따옴표로 씁니다 — `true`·`null`. 없는 열쇠를 읽으면 null 이 나오므로 '열쇠가 없다'와 '빈 문자열이다'를 둘 다 물어야 합니다. 그리고 after_unknown 을 보지 않으면, 값을 데이터 소스에서 계산하는 모듈이 통째로 위반으로 떠서 며칠 만에 게이트가 꺼집니다. 규칙 하나를 통째로 시험하려면 kyverno jp query -i <계획JSON> '<식>' 이 가장 빠릅니다.

어떤 규칙은 막고 어떤 규칙은 알리기만 한다

/root/tfpolicy/policies/warn.yaml 에 두 번째 정책 파일을 만드세요. 규칙 하나를 두고, 계획 JSON 의 최상위 resource_drift 가 비어 있어야 통과하게 합니다(키 자체가 없는 계획도 통과해야 합니다). 그리고 /root/tfpolicy/gate.sh 를 고쳐 두 정책을 따로 돌리게 하세요. block.yaml 의 위반은 BLOCK 을 붙여 출력하고 종료 코드를 0 이 아니게 만들고, warn.yaml 의 위반은 WARN 을 붙여 출력하되 종료 코드를 바꾸지 않습니다. 마지막 줄은 RESULT block=<차단 위반 수> warn=<경고 위반 수> 로 바꿉니다. 경고 정책 보고서에도 판정 줄이 하나도 없으면 2 로 끝냅니다.

resource_drift 는 코드 밖에서 누가 손으로 바꾼 것을 계획이 알려 주는 자리입니다. 키가 아예 없는 계획에서 length() 를 부르면 오류가 나므로 resource_drift || []`` 처럼 기본값을 주는 편이 안전합니다. 게이트 쪽은 스캔을 두 번 돌려 센 값을 따로 들고, 종료 코드는 차단 쪽 숫자만 보고 정합니다. 현장에서 새 규칙을 켜는 순서도 이것입니다 — 경고로 며칠 세어 보고, 걸리는 것이 정리된 뒤에 차단으로 옮깁니다.

손으로 고친 파일 하나가 드리프트로 잡힌다

/root/tfpolicy/out/config.txt 를 테라폼 밖에서 직접 고치세요(예: printf 'hand-edited\n' > /root/tfpolicy/out/config.txt). 그다음 계획을 /root/tfpolicy/drift.tfplan 으로 저장하고 /root/tfpolicy/drift.json 을 만드세요 — 최상위에 resource_drift 가 생깁니다. 마지막으로 base.json·change.json·drift.json 세 계획에 ./gate.sh 를 돌려 출력을 각각 /root/tfpolicy/reports/base.txt·/root/tfpolicy/reports/change.txt·/root/tfpolicy/reports/drift.txt 에 저장하고, 각 파일 마지막 줄EXIT <게이트 종료 코드> 를 덧붙이세요. 채점기는 세 계획에 게이트를 다시 돌려 보고서와 대조합니다.

드리프트는 상태 파일이 기억하는 값과 실제 값이 어긋난 것이라, 계획을 세울 때의 새로고침에서 드러납니다. 보고서를 만들 때 게이트의 종료 코드는 리디렉션 뒤에 $? 로 곧바로 받아야 합니다 — 사이에 다른 명령이 하나라도 들어가면 그 명령의 코드가 잡힙니다. 세 보고서의 RESULT 줄이 서로 다르게 나오는 것이 이 실습의 결론입니다: 깨끗함·차단·경고가 한 게이트에서 갈립니다.