当初情急之下手动加上的例外,两年后仍然留在那里
目标
把策略例外作为带有期限和负责人的登记册来管理,并亲手制作:把登记册转换为集群实际适用范围的脚本、以基准日为参数的到期检查器、按命名空间的违规汇总,以及债务增加时会失败的门禁。
为什么重要
启用策略的团队碰到的第二堵墙,不是规则的质量,而是例外的寿命。规则一启用,就必然出现暂时无法修复的东西,这时选择只有两个——撤下策略,或者创建例外。撤下策略,所有人都被放开,再也不会重新启用。所以要创建例外,但如果例外没有期限和负责人,因为“下个 Sprint 我们就修”而创建的例外,两年后仍然留着,会让整个规则都失去可信度。这就是为什么要先设计删除例外的流程,而不是创建例外的流程。这个设计的核心是把登记册作为原本——手动贴到集群上的标记,没人知道是谁、为什么贴的,而仓库中的登记册要经过评审、带有期限,连被删除的记录也会留下。集群只是这份登记册的反映,不在登记册中的标记无法解释,所以会被删除。而且只有有了数字才能缩减。例外几条、过期几条、还没有人知道的未登记违规几条——把这三个数字放在同一个界面上并设置上限,增加债务的变更就会在流水线中停下,而不是依赖人的记忆。
步骤
- 所有工作都在
/root/polexc中进行(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。首先搭建现场——创建命名空间polexc-pay、polexc-legacy、polexc-sandbox,并用kubectl create deployment部署六个:polexc-pay/pay-api(registry.internal/pay:1.4)、polexc-pay/pay-batch(vendor.example/paybatch:latest)、polexc-legacy/billing-api(vendor.example/billing:latest)、polexc-legacy/report-gen(vendor.example/report:latest)、polexc-legacy/promo-web(vendor.example/promo:latest)、polexc-sandbox/scratch-job(vendor.example/scratch:latest)。然后在/root/polexc/policy.yaml中编写 ValidatingAdmissionPolicyno-latest-tag——匹配apps/v1deployments的CREATE和UPDATE,如果 Pod 模板中的容器镜像以:latest结尾就拦截,但如果该工作负载所在的命名空间带有polexc.io/exempt-<워크로드이름>标签(占位符为工作负载名称),就放行(查看namespaceObject)。在/root/polexc/binding.yaml中编写 ValidatingAdmissionPolicyBindingno-latest-deny——validationActions为["Deny"],matchResources.namespaceSelector.matchLabels为polexc.io/enforce: "on"。两者都应用之后,只给polexc-pay和polexc-legacy添加标签polexc.io/enforce=on(不给polexc-sandbox添加)。现在把kubectl -n polexc-pay patch deployment pay-batch --type merge -p '{"metadata":{"annotations":{"polexc.io/probe":"1"}}}' --dry-run=server的输出连同标准错误一起保存到/root/polexc/01-blocked.txt。最后只删除绑定(kubectl delete -f /root/polexc/binding.yaml),对pay-batch和polexc-legacy/promo-web各发送一次同样的请求,把两份输出汇总到/root/polexc/01-off.txt,并重新应用绑定。 - 在
/root/polexc/exceptions.txt中编写例外登记册。一行是一个条目,恰好有六列,分隔符为|——顺序是namespace|workload|owner|expires|ticket|reason,其中expires为YYYY-MM-DD。第一行放一个以#开头的表头,方便人阅读(以#开头的行和空行,工具会跳过)。共有三个条目——polexc-pay|pay-batch|team-pay|2026-09-10|OPS-2201|근거、polexc-legacy|billing-api|team-billing|2026-10-20|OPS-2188|근거、polexc-legacy|report-gen|team-report|2027-03-31|OPS-2245|근거。每个条目最后一列的内容(韩文,意为“依据”)要替换为自己写的依据,不少于 4 个字符。接着在/root/polexc/scope.txt中每行写一个这个策略的适用范围——polexc-pay、polexc-legacy、polexc-sandbox三行。polexc-sandbox没有强制标签,但属于统计违规的范围。 - 编写
/root/polexc/apply-exceptions.sh。参数有两个——<등록부> <적용범위파일>(占位符依次为登记册与适用范围文件)。它要做两件事。(1) 对登记册中的每个条目,给该命名空间添加标签polexc.io/exempt-<workload>=<ticket>,并输出一行GRANT <namespace>/<workload> <ticket>(按登记册中所写的顺序)。(2) 遍历适用范围文件中的命名空间,删除以polexc.io/exempt-开头的标签中登记册里没有的,并输出REVOKE <namespace>/<workload>(这些行按<namespace>/<workload>字典序汇集后,放在 GRANT 行之后输出)。先重现有人匆忙手动加上的例外——kubectl label ns polexc-legacy polexc.io/exempt-promo-web=MANUAL --overwrite。然后运行bash /root/polexc/apply-exceptions.sh /root/polexc/exceptions.txt /root/polexc/scope.txt,把输出保存到/root/polexc/03-apply.txt。必须出现三行 GRANT 和一行REVOKE polexc-legacy/promo-web。 - 亲自确认例外范围很窄。
polexc-legacy中现在同时有billing-api(有登记的例外)和promo-web(第 3 步中标记被删除)。依次对这两个工作负载发送与第 1 步相同的kubectl -n polexc-legacy patch deployment <이름> --type merge -p '{"metadata":{"annotations":{"polexc.io/probe":"4"}}}' --dry-run=server请求(占位符为工作负载名称),把两份输出连同标准错误汇总到/root/polexc/04-narrow.txt——billing-api必须通过,而promo-web虽然在同一个命名空间,也必须被拒绝。 - 编写
/root/polexc/expiry.sh。参数是<등록부> <기준일 YYYY-MM-DD>两个(占位符依次为登记册与基准日)。按登记册中所写的顺序,每个条目输出一行——<상태> <namespace>/<workload> <expires> <owner> <ticket>(第一个占位符为状态)。状态有三种:到期日早于基准日为EXPIRED,从基准日起到基准日+30天为止(含两端)为DUE,再往后为OK。退出码是:只要有一个EXPIRED就是1,没有则为0——这个退出码就是门禁。运行bash /root/polexc/expiry.sh /root/polexc/exceptions.txt 2026-10-01,把输出保存到/root/polexc/05-expiry.txt(会出现一个EXPIRED、一个DUE、一个OK)。 - 真正收回第 5 步找出的过期条目。先把该条目(
polexc-pay|pay-batch|...)这一行追加到/root/polexc/retired.txt中留存,并从/root/polexc/exceptions.txt中删除——必须留下删除的记录,以后才能回答“这个为什么没了”。然后再次运行bash /root/polexc/apply-exceptions.sh /root/polexc/exceptions.txt /root/polexc/scope.txt,把输出保存到/root/polexc/06-apply.txt(必须出现REVOKE polexc-pay/pay-batch)。最后向polexc-pay/pay-batch发送与第 4 步相同的请求,把输出连同标准错误保存到/root/polexc/06-revoked.txt——现在必须被拒绝。bash /root/polexc/expiry.sh /root/polexc/exceptions.txt 2026-10-01的退出码也必须是0。 - 编写
/root/polexc/scan.sh。参数是<적용범위파일>一个(占位符为适用范围文件)。按文件中所写的顺序,每个命名空间输出一行——<namespace> <위반수> <예외수> <무단수>(占位符依次为违规数、例外数、未登记数)。违规是指容器镜像以:latest结尾的 Deployment,其中该命名空间带有polexc.io/exempt-<이름>标签(占位符为工作负载名称)的,计入例外数,没有的,计入未登记数(没有违规的命名空间也输出为0 0 0)。运行bash /root/polexc/scan.sh /root/polexc/scope.txt,把输出保存到/root/polexc/scan.txt。会出现polexc-pay 1 0 1、polexc-legacy 3 2 1、polexc-sandbox 1 0 1三行。 - 编写
/root/polexc/debt.sh。参数是<등록부> <적용범위파일> <기준일> <부채상한>四个(占位符依次为登记册、适用范围文件、基准日、债务上限),并调用同一目录中的expiry.sh和scan.sh(用$(dirname "$0")查找)。输出恰好是六行——EXCEPTIONS <등록부 항목 수>(登记册条目数)、EXPIRED <만료 수>(过期数)、DUE <임박 수>(临近数)、UNMANAGED <무단 위반 합계>(未登记违规合计)、DEBT <EXCEPTIONS + UNMANAGED>,最后是GATE OK或GATE FAIL。只要有一个EXPIRED,或者DEBT超过上限,就打印GATE FAIL并以退出码1结束。否则是GATE OK和0。运行bash /root/polexc/debt.sh /root/polexc/exceptions.txt /root/polexc/scope.txt 2026-10-01 5,把输出保存到/root/polexc/debt.txt(会出现DEBT 5、GATE OK,退出码为0)。再用上限4运行同一个命令一次,亲眼确认GATE FAIL和退出码1。
参考
- 先执行
export KUBECONFIG=/root/.kube/config和kubectl config use-context kwok-lab。这是 Pod 内 kwok 启动的真实 kube-apiserver v1.30.4,所有产物都放在/root/polexc之下。 - kwok 不会真正运行 Pod。本实验所看的只有准入判定和对象状态,所以用
kubectl patch ... --dry-run=server就足够了——它会照常经过准入,但不会留下对象。 - 在这个 Pod 中无法修改系统时间。处理到期的脚本要把基准日作为参数接收。这样才能同时测试过去和未来,而同一份登记册也不会每天得出不同的判定。
- 例外标记不放在工作负载自身的标签上,是有原因的。摘掉标记的请求本身会被策略拦截并拒绝,导致无法收回已过期的例外。命名空间不是这个策略的匹配对象,所以随时可以添加和摘掉。
- 命名空间标签要经过一两次往返,才会反映到准入评估中。如果结果还是和原来一样,请几秒后再发送一次。
- 常见错误:按命名空间为单位放开例外。这样其中今后新创建的工作负载也会被永久排除在规则之外。
- 常见错误:在汇总中把被例外覆盖的违规和未登记违规合并统计。合并之后两者都看不见了。
- 常见错误:把拒绝消息保存到文件时没有加
2>&1。拒绝和警告是通过标准错误输出的。 - Validating Admission Policy · Admission Controllers Reference · Common Expression Language in Kubernetes · Policy Reports · Labels and Selectors
一启用规则,暂时无法修复的东西就被拦住了
所有工作都在 /root/polexc 中进行(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。首先搭建现场——创建命名空间 polexc-pay、polexc-legacy、polexc-sandbox,并用 kubectl create deployment 部署六个:polexc-pay/pay-api(registry.internal/pay:1.4)、polexc-pay/pay-batch(vendor.example/paybatch:latest)、polexc-legacy/billing-api(vendor.example/billing:latest)、polexc-legacy/report-gen(vendor.example/report:latest)、polexc-legacy/promo-web(vendor.example/promo:latest)、polexc-sandbox/scratch-job(vendor.example/scratch:latest)。然后在 /root/polexc/policy.yaml 中编写 ValidatingAdmissionPolicy no-latest-tag——匹配 apps/v1 deployments 的 CREATE 和 UPDATE,如果 Pod 模板中的容器镜像以 :latest 结尾就拦截,但如果该工作负载所在的命名空间带有 polexc.io/exempt-<워크로드이름> 标签(占位符为工作负载名称),就放行(查看 namespaceObject)。在 /root/polexc/binding.yaml 中编写 ValidatingAdmissionPolicyBinding no-latest-deny——validationActions 为 ["Deny"],matchResources.namespaceSelector.matchLabels 为 polexc.io/enforce: "on"。两者都应用之后,只给 polexc-pay 和 polexc-legacy 添加标签 polexc.io/enforce=on(不给 polexc-sandbox 添加)。现在把 kubectl -n polexc-pay patch deployment pay-batch --type merge -p '{"metadata":{"annotations":{"polexc.io/probe":"1"}}}' --dry-run=server 的输出连同标准错误一起保存到 /root/polexc/01-blocked.txt。最后只删除绑定(kubectl delete -f /root/polexc/binding.yaml),对 pay-batch 和 polexc-legacy/promo-web 各发送一次同样的请求,把两份输出汇总到 /root/polexc/01-off.txt,并重新应用绑定。
策略(判定什么)与绑定(在哪里、以什么强度)是不同的对象。所以只删除绑定,规则还在,却什么都拦不住——这就是“撤下策略,所有人都被放开”的实质,也是需要例外的原因。在 CEL 表达式中,用 object 读取请求对象,用 namespaceObject 读取它的命名空间对象。对可能不存在的字段,要先用 has(...) 检查;通过拼接字符串构造的键是否在标签中,用 in 来判断。--dry-run=server 会照常经过准入,但不会留下对象。拒绝消息是通过标准错误输出的,所以需要 2>&1。
把例外写进文件,而不是记在脑子里
在 /root/polexc/exceptions.txt 中编写例外登记册。一行是一个条目,恰好有六列,分隔符为 |——顺序是 namespace|workload|owner|expires|ticket|reason,其中 expires 为 YYYY-MM-DD。第一行放一个以 # 开头的表头,方便人阅读(以 # 开头的行和空行,工具会跳过)。共有三个条目——polexc-pay|pay-batch|team-pay|2026-09-10|OPS-2201|근거、polexc-legacy|billing-api|team-billing|2026-10-20|OPS-2188|근거、polexc-legacy|report-gen|team-report|2027-03-31|OPS-2245|근거。每个条目最后一列的内容(韩文,意为“依据”)要替换为自己写的依据,不少于 4 个字符。接着在 /root/polexc/scope.txt 中每行写一个这个策略的适用范围——polexc-pay、polexc-legacy、polexc-sandbox 三行。polexc-sandbox 没有强制标签,但属于统计违规的范围。
例外登记册需要具备的,不只是“放开什么”。没有负责人,就没有可以询问的人;没有到期日,就没有删除的依据;没有追踪编号,就无法回溯为什么放开。有了这四项,例外才不是开关,而是债务清单。格式要方便人阅读,同时又能被 awk -F'|' 或 while IFS='|' read 一次性拆开。之所以另外写适用范围,后面会看到原因——要删除登记册中没有的标记,就必须先确定“遍历到哪里为止”。
手动加上的例外,在登记册面前被删除了
编写 /root/polexc/apply-exceptions.sh。参数有两个——<등록부> <적용범위파일>(占位符依次为登记册与适用范围文件)。它要做两件事。(1) 对登记册中的每个条目,给该命名空间添加标签 polexc.io/exempt-<workload>=<ticket>,并输出一行 GRANT <namespace>/<workload> <ticket>(按登记册中所写的顺序)。(2) 遍历适用范围文件中的命名空间,删除以 polexc.io/exempt- 开头的标签中登记册里没有的,并输出 REVOKE <namespace>/<workload>(这些行按 <namespace>/<workload> 字典序汇集后,放在 GRANT 行之后输出)。先重现有人匆忙手动加上的例外——kubectl label ns polexc-legacy polexc.io/exempt-promo-web=MANUAL --overwrite。然后运行 bash /root/polexc/apply-exceptions.sh /root/polexc/exceptions.txt /root/polexc/scope.txt,把输出保存到 /root/polexc/03-apply.txt。必须出现三行 GRANT 和一行 REVOKE polexc-legacy/promo-web。
这里要决定的不是技术,而是哪一方是原本。如果登记册是原本,集群就只是它的反映,不在登记册中的标记无法解释,所以必须删除。反过来,如果以集群为原本,就没有人知道是谁、何时、为什么放开的。如果把追踪编号放进标签的值中,仅凭集群就能回溯依据。删除标签时,在键后面加上 -。命名空间的标签键列表,可以用 jq 遍历 kubectl get ns <n> -o json 得到。脚本运行两次,结果必须相同。
确认例外没有放开整个命名空间
亲自确认例外范围很窄。polexc-legacy 中现在同时有 billing-api(有登记的例外)和 promo-web(第 3 步中标记被删除)。依次对这两个工作负载发送与第 1 步相同的 kubectl -n polexc-legacy patch deployment <이름> --type merge -p '{"metadata":{"annotations":{"polexc.io/probe":"4"}}}' --dry-run=server 请求(占位符为工作负载名称),把两份输出连同标准错误汇总到 /root/polexc/04-narrow.txt——billing-api 必须通过,而 promo-web 虽然在同一个命名空间,也必须被拒绝。
例外范围变宽的事故,大多是悄无声息的。按命名空间为单位放开的例外,会把其中今后新创建的工作负载也永久排除在规则之外,而且不会报任何错误,几个月之后才会暴露。所以创建例外之后,立刻去试探一下不应该被放开的邻居,应该成为流程的一部分。两份输出必须放进同一个文件,所以要打包传递,或者把第二个追加写入。
写下到期日却没人去读,就等于没有
编写 /root/polexc/expiry.sh。参数是 <등록부> <기준일 YYYY-MM-DD> 两个(占位符依次为登记册与基准日)。按登记册中所写的顺序,每个条目输出一行——<상태> <namespace>/<workload> <expires> <owner> <ticket>(第一个占位符为状态)。状态有三种:到期日早于基准日为 EXPIRED,从基准日起到基准日 +30 天为止(含两端)为 DUE,再往后为 OK。退出码是:只要有一个 EXPIRED 就是 1,没有则为 0——这个退出码就是门禁。运行 bash /root/polexc/expiry.sh /root/polexc/exceptions.txt 2026-10-01,把输出保存到 /root/polexc/05-expiry.txt(会出现一个 EXPIRED、一个 DUE、一个 OK)。
把基准日作为参数接收,是这一步的核心。如果在脚本里读取今天的日期,同一份登记册会在某一天突然得出不同的判定,既无法回溯过去的状态,也无法预先测试下个季度。把基准日从外部传入,“以上个月为基准是几条”这个问题也能回答。日期比较时,用 date -d '<날짜>' +%s 换算成秒(占位符为日期),再按整数比较,位数和月末的问题就消失了。30 天就是 30*86400。在这个 Pod 中无法修改系统时间(date -s 没有权限,会失败)。所以基准日参数是唯一的测试方法。
真正收回过期的例外之后,该工作负载又被拦住了
真正收回第 5 步找出的过期条目。先把该条目(polexc-pay|pay-batch|...)这一行追加到 /root/polexc/retired.txt 中留存,并从 /root/polexc/exceptions.txt 中删除——必须留下删除的记录,以后才能回答“这个为什么没了”。然后再次运行 bash /root/polexc/apply-exceptions.sh /root/polexc/exceptions.txt /root/polexc/scope.txt,把输出保存到 /root/polexc/06-apply.txt(必须出现 REVOKE polexc-pay/pay-batch)。最后向 polexc-pay/pay-batch 发送与第 4 步相同的请求,把输出连同标准错误保存到 /root/polexc/06-revoked.txt——现在必须被拒绝。bash /root/polexc/expiry.sh /root/polexc/exceptions.txt 2026-10-01 的退出码也必须是 0。
比起创建例外的流程,删除例外的流程更难,也更重要。如果删除的一方不能自动运行,例外就会悄悄变成永久,从那一刻起,这条规则就成了“开着却没有人相信的规则”。多亏在第 3 步中把登记册作为原本,这里要做的只是从登记册中删掉一行,再运行同一个脚本——不用另写收回逻辑。就地修改登记册时,先写入临时文件再移动过去。这一步运行两次,retired.txt 中也不能出现同一行两次。
统计违规,发现未登记的比例外还多
编写 /root/polexc/scan.sh。参数是 <적용범위파일> 一个(占位符为适用范围文件)。按文件中所写的顺序,每个命名空间输出一行——<namespace> <위반수> <예외수> <무단수>(占位符依次为违规数、例外数、未登记数)。违规是指容器镜像以 :latest 结尾的 Deployment,其中该命名空间带有 polexc.io/exempt-<이름> 标签(占位符为工作负载名称)的,计入例外数,没有的,计入未登记数(没有违规的命名空间也输出为 0 0 0)。运行 bash /root/polexc/scan.sh /root/polexc/scope.txt,把输出保存到 /root/polexc/scan.txt。会出现 polexc-pay 1 0 1、polexc-legacy 3 2 1、polexc-sandbox 1 0 1 三行。
准入只看今后要进入的内容。已经在集群中的内容,不会有人再次检查,所以启用规则之后,“现在有多少个在违规”也必须另外遍历才能知道。而且这个数字必须拆成两个才有意义——被例外覆盖的,是有期限的债务,而未登记违规则是还没有人知道的漏洞。合并在一起,两者都会看不见。像 polexc-sandbox 这样没有强制标签的命名空间也必须统计——强制关闭,并不意味着没有违规。镜像列表可以用 jq 遍历 kubectl get deploy -o json 一次性得到。
把债务摆在同一个界面上,增加时就让它失败
编写 /root/polexc/debt.sh。参数是 <등록부> <적용범위파일> <기준일> <부채상한> 四个(占位符依次为登记册、适用范围文件、基准日、债务上限),并调用同一目录中的 expiry.sh 和 scan.sh(用 $(dirname "$0") 查找)。输出恰好是六行——EXCEPTIONS <등록부 항목 수>(登记册条目数)、EXPIRED <만료 수>(过期数)、DUE <임박 수>(临近数)、UNMANAGED <무단 위반 합계>(未登记违规合计)、DEBT <EXCEPTIONS + UNMANAGED>,最后是 GATE OK 或 GATE FAIL。只要有一个 EXPIRED,或者 DEBT 超过上限,就打印 GATE FAIL 并以退出码 1 结束。否则是 GATE OK 和 0。运行 bash /root/polexc/debt.sh /root/polexc/exceptions.txt /root/polexc/scope.txt 2026-10-01 5,把输出保存到 /root/polexc/debt.txt(会出现 DEBT 5、GATE OK,退出码为 0)。再用上限 4 运行同一个命令一次,亲眼确认 GATE FAIL 和退出码 1。
这一个界面就是会议上要回答的全部问题——例外有几条,现在有没有超期的,马上要超期的有几条,以及还没有人知道的违规有几条。数字分散的话没人会看,合并成一行的话又不知道该修复什么。设置上限,是因为“我们来缩减吧”这句话不会自己得到遵守。有了上限,增加债务的变更就会在流水线中停下。重要的是再次使用前面步骤的两个脚本——统计规则如果写在两处,必然有一处先过时。expiry.sh 在有过期项时会以退出码 1 结束。要让接收它输出的一方,不会被这个退出码拖垮。