The exit code was 0, so the pipeline stayed green for weeks
한국어 원문으로 표시합니다.
목표
진짜 OpenTofu 계획을 JSON 으로 뽑아 구조를 읽고, 그 JSON 에 정책을 걸어 클러스터와 클라우드에 닿기 전에 파괴·교체·빠진 태그를 잡습니다. 그리고 위반을 찾고도 0 으로 끝나는 도구 위에서 믿을 수 있는 게이트를 만듭니다.
왜 중요한가
어드미션 컨트롤은 이미 만들어진 요청을 봅니다. 그런데 데이터베이스 교체나 버킷 공개 같은 위험한 변경은 쿠버네티스 API 를 지나지 않고 클라우드로 곧장 갑니다. 그 변경들에는 공통점이 하나 있습니다 — 적용 전에 계획 단계가 있고, 그 계획은 JSON 으로 뽑을 수 있는 구조화된 문서라는 것입니다. 여기에 정책을 걸면 되돌릴 것이 아직 없을 때 막을 수 있습니다. 다만 계획에는 적용해 봐야 정해지는 값이 섞여 있어서, 그 자리를 모르고 규칙을 쓰면 오탐이 쏟아지고 게이트는 며칠 만에 꺼집니다. 그리고 판정을 도구의 종료 코드에 맡기면, 위반을 찾고도 0 으로 끝나는 도구 하나 때문에 파이프라인이 몇 주 동안 조용히 초록불일 수 있습니다. 그래서 이 실습은 규칙을 쓰는 법만큼이나 '무엇을 근거로 판정하는가'를 다룹니다.
단계
/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.txt에v1한 줄(끝에 줄바꿈)을 쓰고,terraform_data.release는 input 이v1입니다.tofu init과tofu apply -auto-approve로 적용한 다음, 변경이 하나도 남지 않은 계획을/root/tfpolicy/base.tfplan으로 저장하고tofu show -json으로/root/tfpolicy/base.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.txt에no-op이 아닌 자원마다<주소> <create|update|replace|delete>를 한 줄씩 적으세요. 삭제와 생성이 한 자원에 함께 들어 있으면replace한 줄로 적습니다./root/tfpolicy/change.json의resource_changes[].change.after_unknown을 읽어/root/tfpolicy/unknown.txt를 만드세요. 모르는 잎(값이true인 자리)이 하나라도 있는 자원마다<주소> <모르는 잎 개수>를 한 줄씩 적습니다.triggers.name처럼 중첩된 자리도 잎 하나로 셉니다. 모르는 잎이 없는 자원은 적지 않습니다. 줄 순서는 보지 않습니다./root/tfpolicy/policies/block.yaml에apiVersion: json.kyverno.io/v1alpha1·kind: ValidatingPolicy인 정책을 쓰세요. 규칙 하나를 두고,change.actions에delete가 들어 있는 자원이 하나도 없어야 통과하게 합니다(순수 삭제와 교체가 모두 걸립니다). 그다음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에도 같은 스캔을 돌려 통과하는 것을 눈으로 확인하세요./root/tfpolicy/gate.sh <계획JSON>을 만드세요.policies/block.yaml로 스캔을 돌려 보고서를 파싱합니다. 위반 줄(FAILED또는ERROR:가 든 줄)마다BLOCK을 앞에 붙여 한 줄씩 출력하고, 마지막 줄에RESULT block=<위반 줄 수>를 출력하세요(뒤에 낱말이 더 붙어도 됩니다). 위반이 없으면 0, 하나라도 있으면 0 이 아닌 값으로 끝냅니다. 보고서에 판정 줄(PASSED·FAILED·ERROR:)이 하나도 없으면 도구 출력이 바뀐 것이므로2로 끝내고, 인자로 받은 파일이 없을 때도2로 끝냅니다. 채점기는 자기가 만든 깨끗한 계획과 위반 계획으로 이 스크립트를 돌립니다./root/tfpolicy/policies/block.yaml에 두 번째 규칙을 더하세요.change.after.triggers가 있는 자원은change.after.triggers.owner가 있고 빈 문자열이 아니어야 통과합니다. 다만 그 값이 계획 단계에 아직 알려지지 않은 자원(change.after_unknown.triggers.owner가true)은 위반으로 세지 않습니다. 규칙을 더한 뒤./gate.sh /root/tfpolicy/change.json을 다시 돌려 위반이 두 줄이 되는지 확인하세요. 채점기는 owner 가 빈 계획·owner 가 아직 모르는 값인 계획·깨끗한 계획 셋으로 이 규칙을 시험합니다./root/tfpolicy/policies/warn.yaml에 두 번째 정책 파일을 만드세요. 규칙 하나를 두고, 계획 JSON 의 최상위resource_drift가 비어 있어야 통과하게 합니다(키 자체가 없는 계획도 통과해야 합니다). 그리고/root/tfpolicy/gate.sh를 고쳐 두 정책을 따로 돌리게 하세요.block.yaml의 위반은BLOCK을 붙여 출력하고 종료 코드를 0 이 아니게 만들고,warn.yaml의 위반은WARN을 붙여 출력하되 종료 코드를 바꾸지 않습니다. 마지막 줄은RESULT block=<차단 위반 수> warn=<경고 위반 수>로 바꿉니다. 경고 정책 보고서에도 판정 줄이 하나도 없으면2로 끝냅니다./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 <게이트 종료 코드>를 덧붙이세요. 채점기는 세 계획에 게이트를 다시 돌려 보고서와 대조합니다.
참고
- 현장에서는 Conftest 나 OPA(Rego)로 계획 JSON 을 검사하는 곳이 많지만 이 파드에는 conftest·opa 가 없습니다. 대신 kyverno CLI 1.13.2 의
kyverno json scan으로 같은 일을 합니다 — 임의 JSON 을 페이로드로 받아 정책으로 판정하는 구조는 같고, 배우는 것(계획 JSON 의 모양, 모르는 값, 판정의 근거)도 같습니다. - 스캔:
KYVERNO_EXPERIMENTAL=true kyverno json scan --payload <계획JSON> --policy <정책YAML>— 환경변수를 빠뜨리면 실험 기능이라 명령 자체가 거부됩니다. - 정책 형식:
apiVersion: json.kyverno.io/v1alpha1·kind: ValidatingPolicy·spec.rules[].assert.all[].check— check 의 열쇠는 괄호로 감싼 JMESPath 식이고 값이 기대값입니다. - 식만 따로 시험하려면
kyverno jp query -i <파일> '<식>'을 쓰세요. JMESPath 의 JSON 리터럴은 역따옴표입니다. - 파드에는 OpenTofu 1.9.0 이
tofu로 들어 있고 미러에는 local·random·null·tls 프로바이더만 있습니다. 클라우드 프로바이더는 없으니 태그에 해당하는 자리는null_resource의triggers맵으로 대신합니다. - 흔한 실수:
if kyverno json scan ...; then으로 판정하는 것. 이 도구는 위반이 있어도 0 으로 끝납니다 — 4단계에서 직접 찍어 확인합니다. - 흔한 실수: 위반이 0 건인 것과 보고서를 한 줄도 못 읽은 것을 구분하지 않는 것. 도구를 판올린 날 게이트가 조용히 꺼집니다.
- 채점기가 읽지 않는 중간 산출물:
/root/tfpolicy/base.tfplan·change.tfplan·drift.tfplan(저장된 계획 파일)과/root/tfpolicy/out/config.txt. - tofu show · tofu plan · 계획 JSON 형식 · kyverno-json · kyverno-json assert · kyverno CLI 의 json 명령 · Conftest · JMESPath 명세
계획을 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.txt 에 v1 한 줄(끝에 줄바꿈)을 쓰고, terraform_data.release 는 input 이 v1 입니다. tofu init 과 tofu 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.txt 에 no-op 이 아닌 자원마다 <주소> <create|update|replace|delete> 를 한 줄씩 적으세요. 삭제와 생성이 한 자원에 함께 들어 있으면 replace 한 줄로 적습니다.
tofu show -json 결과의 resource_changes[] 에는 address 와 change.actions 가 있습니다. actions 가 두 원소짜리 배열이면 교체입니다 — 추가 하나와 삭제 하나로 두 번 세면 틀립니다. 어떤 속성이 교체를 부르는지는 프로바이더가 정하므로 계획 출력의 # forces replacement 표시로도 확인할 수 있습니다. 줄 순서는 채점에서 보지 않습니다.
계획이 아직 모르는 값을 세어 둔다
/root/tfpolicy/change.json 의 resource_changes[].change.after_unknown 을 읽어 /root/tfpolicy/unknown.txt 를 만드세요. 모르는 잎(값이 true 인 자리)이 하나라도 있는 자원마다 <주소> <모르는 잎 개수> 를 한 줄씩 적습니다. triggers.name 처럼 중첩된 자리도 잎 하나로 셉니다. 모르는 잎이 없는 자원은 적지 않습니다. 줄 순서는 보지 않습니다.
after_unknown 은 after 와 같은 구조를 가지되 모르는 잎만 true 로 남기고 아는 잎은 아예 빠진 객체입니다. jq 의 paths(조건) 은 조건을 만족하는 값의 경로를 모두 냅니다 — [paths(. == true)] | length 로 잎을 셀 수 있습니다. 정책을 쓸 때 이 자리가 중요한 이유는, after 에 값이 없다고 해서 곧바로 위반이라고 단정하면 오탐이 나기 때문입니다.
규칙을 데이터로 적고 종료 코드를 찍어 본다
/root/tfpolicy/policies/block.yaml 에 apiVersion: json.kyverno.io/v1alpha1·kind: ValidatingPolicy 인 정책을 쓰세요. 규칙 하나를 두고, change.actions 에 delete 가 들어 있는 자원이 하나도 없어야 통과하게 합니다(순수 삭제와 교체가 모두 걸립니다). 그다음 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.owner 가 true)은 위반으로 세지 않습니다. 규칙을 더한 뒤 ./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 줄이 서로 다르게 나오는 것이 이 실습의 결론입니다: 깨끗함·차단·경고가 한 게이트에서 갈립니다.