TT Lab
开始
学习 学习路径 课程

策略即代码

规则被放宽了,那天的测试却依然是绿灯

在 TT Lab 中继续学习

目标

从头开始为一个策略制作测试框架。把输入样本、预期判定、预期消息写在表格文件中,编写读取这张表并用服务端 dry-run 执行准入的运行器,亲手确认消息变更、范围偏差、规则放宽这三种情况,在测试中如何以红灯暴露出来。

为什么重要

策略出故障最常见的方式不是错误,而是沉默。匹配范围一旦出现偏差,策略就会悄无声息地全部放行,仪表板上连一条红线都不会出现。所以运行良好的策略和已经死掉的策略,从表面上无法区分。还有第二种力量叠加在上面——策略每当有人被拦住,就只会朝着宽松的方向被修改。测试是同时堵住这两件事的唯一机制。不过,只收集了通过样本的测试,即使把策略整个删掉也亮绿灯,什么也守护不了。所以本实验要把拒绝样本和预期消息固定为契约,并进一步把测试本身失去意义的状态(没有样本、没有拒绝预期),用一个既不是成功也不是失败的第三个退出码区分出来。

步骤

  1. 在 /root/poltest 中工作(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。在 /root/poltest/policy.yaml 中编写 admissionregistration.k8s.io/v1 的 ValidatingAdmissionPolicy require-min-replicas——matchConstraints.resourceRules 匹配 apps 组 v1 的 deployments 的 CREATE 和 UPDATE,validation 是 object.spec.replicas >= 2,messageExpression 生成包含 below the minimum 2 replicas 这一片段的句子。在 /root/poltest/binding.yaml 中编写绑定 require-min-replicas-bind——policyName 为 require-min-replicas,validationActions 为 ["Deny"],matchResources.namespaceSelector.matchLabels 为 policy-test: "yes"。两者都应用,并创建命名空间 poltest,加上标签 policy-test=yes。再创建两个样本——/root/poltest/cases/ok-two.yaml 是 Deployment ok-two,replicas: 2,/root/poltest/cases/bad-one.yaml 是 Deployment bad-one,replicas: 1。最后只把 ok-two.yaml 用服务端 dry-run 发送,并把输出保存到 /root/poltest/01-allow.txt。
  2. 在 /root/poltest/cases.txt 中编写黄金用例表。一行是一个样本,各栏用 | 分隔——이름 | 매니페스트 경로 | allow 또는 deny | 기대 메시지 조각(韩文,依次意为“名称 | 清单路径 | allow 或 deny | 预期消息片段”)共四栏。清单路径以表文件所在目录为基准书写,以 # 开头的行和空行是注释。预期为 allow 的行,消息栏写 -。现在先放两行——allow-two 把 cases/ok-two.yaml 写为 allow,deny-one 把 cases/bad-one.yaml 写为 deny,消息片段是从实际拒绝消息中原样摘取的 below the minimum 2 replicas。
  3. 创建 /root/poltest/run-cases.sh。它接收表文件作为参数(没有的话,使用脚本旁边的 cases.txt),对表中的每一行,用 kubectl create -n poltest -f <파일> --dry-run=server(占位符为文件)发送该清单。命令成功,则实际判定为 allow,失败则为 deny。与预期相同,就输出一行 PASS <이름>;不同,就输出一行 FAIL <이름> <까닭>(占位符依次为名称与原因)。清单路径以表文件所在目录为基准解析。最后一行输出 total=<수> pass=<수> fail=<수>(占位符均为数量),只要有一个失败,就以非 0 的值结束。创建之后,用 bash run-cases.sh 运行,确认两行都是 PASS。
  4. 再创建两个拒绝样本——/root/poltest/cases/bad-zero.yaml 是 Deployment bad-zero,replicas: 0,/root/poltest/cases/bad-default.yaml 是 Deployment bad-default,完全不写 replicas 字段。把这两个以 deny-zero 和 deny-default 的名称加入 cases.txt,预期消息片段同样是 below the minimum 2 replicas。表现在有四行,其中三行是 deny 预期。bash run-cases.sh 必须再次全部以 PASS 结束。
  5. 修改 run-cases.sh,使预期为 deny 的行,只有拒绝消息中包含预期片段才算 PASS(如果片段是 - 或为空,则不看消息)。然后创建 /root/poltest/policy-msgdrift.yaml——名称和规则与 policy.yaml 相同,只把 messageExpression 改成另一个句子,这个句子中不能包含 below the minimum 2 replicas。应用它,运行 bash run-cases.sh,把全部输出保存到 /root/poltest/05-drift.txt(必须出现三行以上 FAIL)。最后重新应用 policy.yaml,恢复为原来的消息,并确认测试再次全部以 PASS 结束。
  6. 在 /root/poltest/cases/sts-one.yaml 中编写 StatefulSet sts-one——replicas: 1,serviceName: sts-one。在 cases.txt 中以 deny 预期加入 deny-sts-one 这一行(消息片段同样是 below the minimum 2 replicas),运行 bash run-cases.sh,把全部输出保存到 /root/poltest/06-falsepass.txt——那个样本会显示为 FAIL。因为现在的策略只看 deployments,所以根本不判定这个请求,而没有拒绝,看上去就像通过了。现在把 policy.yaml 的 resources 扩大为 ["deployments", "statefulsets"],重新应用,并确认测试五行全部以 PASS 结束。
  7. 创建 /root/poltest/policy-loose.yaml——名称、资源、消息与 policy.yaml 相同,只是把最小值从 2 降为 1(假设有人提出“请让只有一台的也能上线”,把条件放宽了一格)。应用它,运行 bash run-cases.sh,把全部输出保存到 /root/poltest/07-regression.txt——会出现两行以上 FAIL。然后重新应用 policy.yaml 恢复,并确认测试再次全部以 PASS 结束。
  8. 最后修改 run-cases.sh,让它可以用作流水线的门禁。把汇总行扩展为 total=<수> pass=<수> fail=<수> deny=<수> result=<PASS|FAIL|INVALID>(其中 deny 是预期为 deny 的行数;占位符均为数量),并把输出的全部行也原样保存到表文件所在目录的 report.txt 中。退出码约定有三个——全部通过为 0,只要有一个与预期不符为 1,表中一个样本都没有,或者一个 deny 预期都没有,则为 2(result 为 INVALID)。修改之后运行 bash run-cases.sh,留下 /root/poltest/report.txt,再用去掉了拒绝预期的表运行一次,确认得到 2。

参考

只运行了一个通过样本,就相信策略是有效的

在 /root/poltest 中工作(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。在 /root/poltest/policy.yaml 中编写 admissionregistration.k8s.io/v1 的 ValidatingAdmissionPolicy require-min-replicas——matchConstraints.resourceRules 匹配 apps 组 v1 的 deployments 的 CREATE 和 UPDATE,validation 是 object.spec.replicas >= 2,messageExpression 生成包含 below the minimum 2 replicas 这一片段的句子。在 /root/poltest/binding.yaml 中编写绑定 require-min-replicas-bind——policyName 为 require-min-replicas,validationActions 为 ["Deny"],matchResources.namespaceSelector.matchLabels 为 policy-test: "yes"。两者都应用,并创建命名空间 poltest,加上标签 policy-test=yes。再创建两个样本——/root/poltest/cases/ok-two.yaml 是 Deployment ok-two,replicas: 2,/root/poltest/cases/bad-one.yaml 是 Deployment bad-one,replicas: 1。最后只把 ok-two.yaml 用服务端 dry-run 发送,并把输出保存到 /root/poltest/01-allow.txt。

策略只决定判定方法,在哪里适用由绑定决定。所以两者都要上传,拒绝才会发生。messageExpression 是 CEL 表达式,所以要拼接字符串,数字要用 string(...) 转换。这一步想要说明的是最后一句——只运行了应该通过的样本的记录,不能证明策略是有效的。服务端 dry-run 是 kubectl create -n poltest -f <파일> --dry-run=server(占位符为文件)。它只跳过保存,准入原样通过,所以拒绝消息会真实返回,而对象不会留下。拒绝消息是通过标准错误输出的,所以保存到文件时要加上 2>&1。

把预期写成表格,缺少的一侧就暴露出来了

在 /root/poltest/cases.txt 中编写黄金用例表。一行是一个样本,各栏用 | 分隔——이름 | 매니페스트 경로 | allow 또는 deny | 기대 메시지 조각(韩文,依次意为“名称 | 清单路径 | allow 或 deny | 预期消息片段”)共四栏。清单路径以表文件所在目录为基准书写,以 # 开头的行和空行是注释。预期为 allow 的行,消息栏写 -。现在先放两行——allow-two 把 cases/ok-two.yaml 写为 allow,deny-one 把 cases/bad-one.yaml 写为 deny,消息片段是从实际拒绝消息中原样摘取的 below the minimum 2 replicas。

预期消息不要编造,而要从实际的拒绝输出中截取。请先把 bad-one 用服务端 dry-run 发送一次,看看返回的是哪句话。样本名称要起成不用打开文件也能知道哪里不同。如果把它们做成复制通过样本后只修改一处的成对样本,测试失败时从名称上就能读出原因。

制作读取表格并运行的运行器

创建 /root/poltest/run-cases.sh。它接收表文件作为参数(没有的话,使用脚本旁边的 cases.txt),对表中的每一行,用 kubectl create -n poltest -f <파일> --dry-run=server(占位符为文件)发送该清单。命令成功,则实际判定为 allow,失败则为 deny。与预期相同,就输出一行 PASS <이름>;不同,就输出一行 FAIL <이름> <까닭>(占位符依次为名称与原因)。清单路径以表文件所在目录为基准解析。最后一行输出 total=<수> pass=<수> fail=<수>(占位符均为数量),只要有一个失败,就以非 0 的值结束。创建之后,用 bash run-cases.sh 运行,确认两行都是 PASS。

读取表格时使用 while IFS='|' read -r ... done < "$table" 的形式。循环中调用的命令要加上 </dev/null,以免它把表格吸走。各栏前后的空格要去掉,比较才能匹配。退出码会原样使用最后一条命令的,所以最后要明确地 exit。评分器会给这个脚本另一张表来运行——不读取表格、只输出固定答案的运行器,会在这里露馅。

再增加看似合理但违反规则的样本

再创建两个拒绝样本——/root/poltest/cases/bad-zero.yaml 是 Deployment bad-zero,replicas: 0,/root/poltest/cases/bad-default.yaml 是 Deployment bad-default,完全不写 replicas 字段。把这两个以 deny-zero 和 deny-default 的名称加入 cases.txt,预期消息片段同样是 below the minimum 2 replicas。表现在有四行,其中三行是 deny 预期。bash run-cases.sh 必须再次全部以 PASS 结束。

没有写 replicas 的 Deployment,会在准入之前填充默认值。服务端 dry-run 会把经过默认值填充之后的对象展示给策略,所以没写的和写了 1 的,会得到相同的判定。这正是离线引擎容易漏掉的地方。拒绝样本最好是在通过样本的基础上只改一处来创建。

只是润色了消息,测试就亮红灯了

修改 run-cases.sh,使预期为 deny 的行,只有拒绝消息中包含预期片段才算 PASS(如果片段是 - 或为空,则不看消息)。然后创建 /root/poltest/policy-msgdrift.yaml——名称和规则与 policy.yaml 相同,只把 messageExpression 改成另一个句子,这个句子中不能包含 below the minimum 2 replicas。应用它,运行 bash run-cases.sh,把全部输出保存到 /root/poltest/05-drift.txt(必须出现三行以上 FAIL)。最后重新应用 policy.yaml,恢复为原来的消息,并确认测试再次全部以 PASS 结束。

拒绝消息是策略向开发者说话的唯一通道。抓取这句话来生成告警或工单的流水线,在措辞改变的那天会悄无声息地停下。如果把消息固定为预期值,做重构的人当场就会知道。在 shell 中确认子串,case "$out" in *"$frag"*) 最可靠。应用策略之后并不会立刻生效——请等到看到变化后的判定,再运行测试。

策略根本没看的资源,被读成了通过

在 /root/poltest/cases/sts-one.yaml 中编写 StatefulSet sts-one——replicas: 1,serviceName: sts-one。在 cases.txt 中以 deny 预期加入 deny-sts-one 这一行(消息片段同样是 below the minimum 2 replicas),运行 bash run-cases.sh,把全部输出保存到 /root/poltest/06-falsepass.txt——那个样本会显示为 FAIL。因为现在的策略只看 deployments,所以根本不判定这个请求,而没有拒绝,看上去就像通过了。现在把 policy.yaml 的 resources 扩大为 ["deployments", "statefulsets"],重新应用,并确认测试五行全部以 PASS 结束。

如果策略不看那个资源,判定就不是通过,而是“不适用”。可是准入把这两者以同一副面孔返回——都是请求成功了。所以在表中放入一个必须被拒绝的样本,是匹配范围没有出现偏差的唯一证据。StatefulSet 除了 selector 和 template 之外,还需要 serviceName。扩大范围之后,反映也需要一秒左右。

把规则放宽一格,测试先发现了

创建 /root/poltest/policy-loose.yaml——名称、资源、消息与 policy.yaml 相同,只是把最小值从 2 降为 1(假设有人提出“请让只有一台的也能上线”,把条件放宽了一格)。应用它,运行 bash run-cases.sh,把全部输出保存到 /root/poltest/07-regression.txt——会出现两行以上 FAIL。然后重新应用 policy.yaml 恢复,并确认测试再次全部以 PASS 结束。

策略几乎总是只朝着宽松的方向被修改。每次修改在当时看起来都合理,半年过去,没有人知道当初想拦住什么。如果测试中有拒绝样本,规则被放开的那天就会亮红灯。此时消息措辞不变,所以 replicas 为 0 的样本仍然保持 PASS,也请一并留意——这就是规则与句子各行其是的时刻。

确定退出码,让空测试不会亮绿灯

最后修改 run-cases.sh,让它可以用作流水线的门禁。把汇总行扩展为 total=<수> pass=<수> fail=<수> deny=<수> result=<PASS|FAIL|INVALID>(其中 deny 是预期为 deny 的行数;占位符均为数量),并把输出的全部行也原样保存到表文件所在目录的 report.txt 中。退出码约定有三个——全部通过为 0,只要有一个与预期不符为 1,表中一个样本都没有,或者一个 deny 预期都没有,则为 2(result 为 INVALID)。修改之后运行 bash run-cases.sh,留下 /root/poltest/report.txt,再用去掉了拒绝预期的表运行一次,确认得到 2。

只收集了通过样本的测试,即使把策略整个删掉也亮绿灯。所以要把“测试什么也守护不了的状态”,用一个既不是成功也不是失败的第三个退出码区分出来。门禁只把 0 视为通过,1 和 2 都会被拦住。把 report.txt 放在表文件旁边,即使用其他表运行,也不会覆盖原来的结果。汇总只是在前一步的格式上增加了两栏,所以 PASS、FAIL 行的样子保持不变。