只开着警告就切到 enforce,结果部署全停了
目标
只用命名空间标签来运用 Kubernetes 内置的 Pod 安全准入(PSA)。亲自走一遍从观测(warn、audit)提升到拦截(enforce)的顺序,并在提升之前预先找出会被拦住的内容,整理成报告。
为什么重要
PSA 不需要安装任何东西——API 服务器中已经启用,只需在命名空间上挂三个标签。所以它看起来很简单,但事故总是出在跳过顺序的时候。直接启用 enforce 的团队,当天部署就全被拦住;只开启 warn 的团队,没有人阅读警告,半年后又站到同一个地方。处于这两种失败之间的,就是本实验要讲的顺序——通过观测收集现状,固定等级的版本,把集群升级与策略变更分开,在切换之前预先找出现有 Pod 的违规,列出要修复的清单,然后再提升。而且要知道,提升之后,已经在运行的 Pod 是拦不住的,这样才不会把修改标签的那天误当成已经完成。
步骤
- 在
/root/psa/plain.yaml中写一个 Pod——名称web,标签app: web,容器名称web,镜像nginx:1.27,securityContext 一行也不写。创建不带 PSA 标签的命名空间psa-legacy,并应用这个 Pod。然后在/root/psa/default.txt中用一个词写出适用于没有任何标签的命名空间的 enforce 等级的名称。 - 创建命名空间
psa-observe,只加上pod-security.kubernetes.io/warn=restricted和pod-security.kubernetes.io/audit=restricted两个标签(不设置 enforce)。用kubectl create -f plain.yaml -n psa-observe放入同一个plain.yaml,并把标准输出和标准错误一起保存到/root/psa/warn.txt。该文件中必须同时有“Pod 已创建”这一行以及全部四条违规原因。 - 创建命名空间
psa-strict,并加上pod-security.kubernetes.io/enforce=restricted。用kubectl create -f plain.yaml -n psa-strict --dry-run=server放入同一个plain.yaml,把拒绝消息连同标准错误保存到/root/psa/denied.txt。把该消息中写的四项全部修复后的 Pod 写入/root/psa/hardened.yaml(名称web-hardened,镜像仍为nginx:1.27),并真正应用到psa-strict。 - 创建命名空间
psa-baseline,并加上pod-security.kubernetes.io/enforce=baseline。在/root/psa/privileged.yaml中写一个连 baseline 也违反的 Pod(名称breaker,容器名称breaker,镜像busybox:1.36)。然后在/root/psa/gap.txt中,把三个清单(plain.yaml、hardened.yaml、privileged.yaml)分别放入两个命名空间的结果,以<파일이름> baseline=<allow|deny> restricted=<allow|deny>(占位符为文件名)的形式每行一条写出。判定用--dry-run=server进行,不真正创建 Pod。 - 给三个命名空间的等级标签,分别加上与之配对的版本标签,值为
v1.30——psa-strict和psa-baseline是pod-security.kubernetes.io/enforce-version,psa-observe是pod-security.kubernetes.io/warn-version和pod-security.kubernetes.io/audit-version。然后创建/root/psa/pinned.sh——不带参数运行时,只输出设置了 enforce、audit、warn 标签、却没有与之配对的-version标签的命名空间的名称,每行一个(排序后)。 - 先往
psa-legacy里再放入两个 Pod——把plain.yaml只改名为api的,以及把hardened.yaml只改名为batch的。然后创建专门用于判定的命名空间psa-canary,加上pod-security.kubernetes.io/enforce=restricted和pod-security.kubernetes.io/enforce-version=v1.30。把kubectl label ns psa-legacy pod-security.kubernetes.io/enforce=restricted --overwrite --dry-run=server的输出连同标准错误保存到/root/psa/preview.txt——标签不能真的被加上。最后创建/root/psa/violators.sh <네임스페이스>(占位符为命名空间):把该命名空间的 Pod 逐个重新提交到psa-canary(--dry-run=server),只把违反 restricted 的名称,每行一个、排序后输出。把./violators.sh psa-legacy的结果保存到/root/psa/violators.txt。 - 这次真正给
psa-legacy加上pod-security.kubernetes.io/enforce=restricted和pod-security.kubernetes.io/enforce-version=v1.30。然后把hardened.yaml应用到psa-legacy(必须通过),用kubectl create -n psa-legacy --dry-run=server放入plain.yaml,把拒绝消息连同标准错误保存到/root/psa/enforced.txt,再把kubectl get pod -n psa-legacy的输出接在该文件之后。已经在运行的web和api不要删除。 - 在
/root/psa/targets.txt中每行写一个要检查的命名空间名称——psa-legacy、psa-observe、psa-strict、psa-baseline四个。创建/root/psa/readiness.sh [목록파일](占位符为列表文件;不带参数时使用/root/psa/targets.txt)。对列表中的每个命名空间输出一行判定:如果已经是enforce=restricted,就是<이름> ENFORCED;如果没有任何违规的 Pod,就是<이름> READY;如果有,就是<이름> BLOCKED <개수>;如果没有这样的命名空间,就是<이름> MISSING(占位符依次为命名空间名称与数量)。不能真的修改标签。把运行结果保存到/root/psa/report.txt。
参考
- 实验 Pod 中有 kwok 启动的真实 kube-apiserver v1.30.4。请先执行
export KUBECONFIG=/root/.kube/config。容器不会真正运行,但准入是真实生效的。 - 标签格式:
pod-security.kubernetes.io/<enforce|audit|warn>=<privileged|baseline|restricted>,以及与之配对的pod-security.kubernetes.io/<모드>-version=<latest|v1.x>(占位符为模式)。 - 如果想只经过准入而不留下对象,使用
kubectl create -f - --dry-run=server。kubectl label ns ... --dry-run=server会以警告的形式预先告知该命名空间中已有 Pod 的违规。 - 常见错误:不知道警告是通过标准错误输出的,只加了
> 파일(韩文,意为“文件”),结果留下一个空文件。 - 常见错误:已经存在同名的 Pod,没有走到准入,就以 AlreadyExists 结束——用于判定的提交,要改名后再发送。
- 常见错误:看到提升标签那天什么都没发生,就判断是安全的。已有的 Pod 要等到重新部署时才会被拦住。
- Pod Security Admission · Pod Security Standards · Enforce Pod Security Standards with Namespace Labels · Admission Controllers Reference · Migrate from PodSecurityPolicy to PSA
没有任何标签的命名空间,什么都不会拦
在 /root/psa/plain.yaml 中写一个 Pod——名称 web,标签 app: web,容器名称 web,镜像 nginx:1.27,securityContext 一行也不写。创建不带 PSA 标签的命名空间 psa-legacy,并应用这个 Pod。然后在 /root/psa/default.txt 中用一个词写出适用于没有任何标签的命名空间的 enforce 等级的名称。
PodSecurity 是 API 服务器中已经启用的准入控制器,按哪个等级来看,只由命名空间标签决定。没有标签时使用集群默认值,而这个默认值是什么都不拦的一侧。Pod Security Standards 有三个等级——其中最宽松的那个的名称。
只开启警告,同一个 Pod 在带有警告的情况下被创建了
创建命名空间 psa-observe,只加上 pod-security.kubernetes.io/warn=restricted 和 pod-security.kubernetes.io/audit=restricted 两个标签(不设置 enforce)。用 kubectl create -f plain.yaml -n psa-observe 放入同一个 plain.yaml,并把标准输出和标准错误一起保存到 /root/psa/warn.txt。该文件中必须同时有“Pod 已创建”这一行以及全部四条违规原因。
PSA 的三个模式彼此独立——只有 enforce 会拦截请求,warn 会把警告返回给发起请求的人,audit 则在审计日志中留下注解。警告是通过标准错误输出的,所以要像 > 파일 2>&1(韩文,意为“文件”)这样,把两者都接收下来。这一步的价值在于回答“既然什么都拦不住,为什么还要开启”。
读完四条原因,把 Pod 改成符合 restricted
创建命名空间 psa-strict,并加上 pod-security.kubernetes.io/enforce=restricted。用 kubectl create -f plain.yaml -n psa-strict --dry-run=server 放入同一个 plain.yaml,把拒绝消息连同标准错误保存到 /root/psa/denied.txt。把该消息中写的四项全部修复后的 Pod 写入 /root/psa/hardened.yaml(名称 web-hardened,镜像仍为 nginx:1.27),并真正应用到 psa-strict。
拒绝消息会原样告诉你要修复的位置——只需分清哪些属于 Pod 级别的 securityContext,哪些属于容器级别,再放进去即可。把 runAsNonRoot 设为 true,意味着镜像不以 root 运行,所以最好同时指定要运行的 UID。capabilities 的顺序是先全部丢弃,再只把需要的重新加回来。
baseline 能通过,只有 restricted 会拦住的 Pod
创建命名空间 psa-baseline,并加上 pod-security.kubernetes.io/enforce=baseline。在 /root/psa/privileged.yaml 中写一个连 baseline 也违反的 Pod(名称 breaker,容器名称 breaker,镜像 busybox:1.36)。然后在 /root/psa/gap.txt 中,把三个清单(plain.yaml、hardened.yaml、privileged.yaml)分别放入两个命名空间的结果,以 <파일이름> baseline=<allow|deny> restricted=<allow|deny>(占位符为文件名)的形式每行一条写出。判定用 --dry-run=server 进行,不真正创建 Pod。
baseline 只拦截广为人知的权限提升,restricted 则在此基础上再加上强化规则。两者都不能使用的是宿主机命名空间和特权容器。如果已经存在同名的 Pod,会在到达准入之前就以 AlreadyExists 结束,所以提交时改掉名称和命名空间再发送更安全(可以用 kubectl create -f 파일 --dry-run=client -o json(占位符为文件)取出,用 jq 修改后再传递)。
设为 latest 的命名空间,会在升级集群那天停摆
给三个命名空间的等级标签,分别加上与之配对的版本标签,值为 v1.30——psa-strict 和 psa-baseline 是 pod-security.kubernetes.io/enforce-version,psa-observe 是 pod-security.kubernetes.io/warn-version 和 pod-security.kubernetes.io/audit-version。然后创建 /root/psa/pinned.sh——不带参数运行时,只输出设置了 enforce、audit、warn 标签、却没有与之配对的 -version 标签的命名空间的名称,每行一个(排序后)。
如果不加版本标签,那个位置就按 latest 运行——意思是等级的“当前版本”,所以升级集群时规则也会一起升级,昨天还能通过的 Pod,今天就被拦住了。标签全都包含在 kubectl get ns -o json 的 .items[].metadata.labels 中,而对于没有任何标签的命名空间,那个位置根本不存在。这是一个一边遍历三个模式,一边查看配对标签是否存在的问题。
提升之前,预先找出会被拦住的内容
先往 psa-legacy 里再放入两个 Pod——把 plain.yaml 只改名为 api 的,以及把 hardened.yaml 只改名为 batch 的。然后创建专门用于判定的命名空间 psa-canary,加上 pod-security.kubernetes.io/enforce=restricted 和 pod-security.kubernetes.io/enforce-version=v1.30。把 kubectl label ns psa-legacy pod-security.kubernetes.io/enforce=restricted --overwrite --dry-run=server 的输出连同标准错误保存到 /root/psa/preview.txt——标签不能真的被加上。最后创建 /root/psa/violators.sh <네임스페이스>(占位符为命名空间):把该命名空间的 Pod 逐个重新提交到 psa-canary(--dry-run=server),只把违反 restricted 的名称,每行一个、排序后输出。把 ./violators.sh psa-legacy 的结果保存到 /root/psa/violators.txt。
预警在 Pod 很多时,会缩写成 (and N other pods)——要知道全部名称,就得逐个 Pod 分别判定。已经在运行的 Pod 不会再次经过准入,所以办法是把同样的 spec 重新提交到强制该等级的命名空间中。从 kubectl get pod <이름> -n <ns> -o json(占位符为 Pod 名称)中只提取 apiVersion、kind、spec,metadata 则重新构造即可。
提升之后,已经在运行的 Pod 仍然照常运行
这次真正给 psa-legacy 加上 pod-security.kubernetes.io/enforce=restricted 和 pod-security.kubernetes.io/enforce-version=v1.30。然后把 hardened.yaml 应用到 psa-legacy(必须通过),用 kubectl create -n psa-legacy --dry-run=server 放入 plain.yaml,把拒绝消息连同标准错误保存到 /root/psa/enforced.txt,再把 kubectl get pod -n psa-legacy 的输出接在该文件之后。已经在运行的 web 和 api 不要删除。
PSA 是准入控制器,只在请求进来时才做判断——已经保存的对象不会再看,所以即使有违规的 Pod 在运行,也不会把它赶走。因此刚提升到 enforce 的集群,是一种“只有新进来的才是干净的”状态,已有的 Pod 要等到重新部署时才会被拦住。如果不了解这个时间差,看到提升那天什么都没发生,就会错误地判断它是安全的。
整理成报告,说明可以先提升哪个命名空间
在 /root/psa/targets.txt 中每行写一个要检查的命名空间名称——psa-legacy、psa-observe、psa-strict、psa-baseline 四个。创建 /root/psa/readiness.sh [목록파일](占位符为列表文件;不带参数时使用 /root/psa/targets.txt)。对列表中的每个命名空间输出一行判定:如果已经是 enforce=restricted,就是 <이름> ENFORCED;如果没有任何违规的 Pod,就是 <이름> READY;如果有,就是 <이름> BLOCKED <개수>;如果没有这样的命名空间,就是 <이름> MISSING(占位符依次为命名空间名称与数量)。不能真的修改标签。把运行结果保存到 /root/psa/report.txt。
如果直接调用前一步的 violators.sh,数量就只需统计行数。之所以要先筛掉已经是 restricted 的地方,是因为对这样的命名空间以相同的值再次贴标签,值不会发生变化,预警根本不会出现。如果让列表文件作为参数传入,同一个脚本也可以用在其他批次上。