上一章结束时,TaskBoard Deployment 有两个 Ready 副本,Redis StatefulSet 有一个 Ready 实例,PVC 已绑定,初始化 Job 已完成,TaskBoard 也把 Redis 纳入了 readiness。这些工作负载都能按预期运行,taskboard Namespace 和其中的数据仍然保留,供本章继续检查。
接下来要回答的不是“Pod 能不能运行”,而是“它最多能做什么”。如果 TaskBoard 进程存在应用漏洞,攻击者得到的首先是容器内进程权限;容器里是否挂载 API 凭据、ServiceAccount 能调用哪些 API、Pod 是否允许特权模式、网络能访问哪里,会继续决定影响范围。安全设计的目标不是假设第一层永远不会失守,而是让一次失守难以越过下一层。
本章从三条可实际验证的边界入手:RBAC 限制 ServiceAccount 能调用哪些 API,securityContext 限制容器进程在节点内获得的 Linux 权限,Pod Security Admission 在 API 请求进入持久化之前拒绝不符合标准的 Pod。然后我们会说明 Secret 和 NetworkPolicy 的真实边界,避免把“对象已经创建”误当成“防护已经生效”。
完成后,项目应保持业务可用,并新增以下证据:
taskboard ServiceAccount,但容器内没有自动挂载 API token;yes、删除 Pod no、读取 Secret no;restricted:v1.36 Pod Security 标准;安全策略本身也是高影响变更。新终端不会保留上一章的工具路径、独立 kubeconfig 和项目工作目录;在 RBAC、准入或网络策略写入前,必须先证明命令会发往课程 context,清单也来自课程项目。
下面只恢复当前 Shell 并读取状态,不会授予权限或应用策略。目录标记、context 和项目路径任一不匹配都会在变更前停止:
COURSE_ROOT="$HOME/welearn-kubernetes-course"
if ! { [ -d "$COURSE_ROOT" ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - "$COURSE_ROOT/.course-owned"; }; then
echo '停止:课程目录不存在或归属标记不匹配' >&2
exit 1
fi
case ":$PATH:" in
*":$COURSE_ROOT/bin:"*) ;;
*) export PATH="$COURSE_ROOT/bin:$PATH" ;;
esac
export KUBECONFIG="$COURSE_ROOT/welearn-course.kubeconfig"
COURSE_CONTEXT="$(kubectl config current-context 2>/dev/null)" || { echo '停止:无法读取课程 context' >&2; exit 1; }
if [ "$COURSE_CONTEXT" != 'kind-welearn-course' ]; then
echo "停止:当前 context 不是 kind-welearn-course,而是 $COURSE_CONTEXT" >&2
exit 1
fi
CLUSTER_NAME='welearn-course'
NODE_NAME='welearn-course-control-plane'
CLUSTER_LABEL='io.x-k8s.kind.cluster=welearn-course'
CLUSTER_MARKER="$COURSE_ROOT/.kind-cluster-owned"
KIND_CLUSTERS="$(kind get clusters 2>&1)" || { echo '停止:无法读取 kind 集群列表' >&2; exit 1; }
printf '%s\n' "$KIND_CLUSTERS" | grep --fixed-strings --line-regexp --quiet "$CLUSTER_NAME" || { echo '停止:课程 kind 集群不存在' >&2; exit 1; }
LABEL_IDS="$(docker ps -aq --no-trunc --filter "label=$CLUSTER_LABEL" 2>&1)" || { echo '停止:无法按 kind 标签读取节点容器' >&2; exit 1; }
NAME_IDS="$(docker ps -aq --no-trunc --filter "name=$NODE_NAME" 2>&1)" || { echo '停止:无法按名称读取节点容器' >&2; exit 1; }
LABEL_COUNT="$(printf '%s\n' "$LABEL_IDS" | awk 'NF { count += 1 } END { print count + 0 }')"
NAME_COUNT="$(printf '%s\n' "$NAME_IDS" | awk 'NF { count += 1 } END { print count + 0 }')"
if [ "$LABEL_COUNT" -ne 1 ] || [ "$NAME_COUNT" -ne 1 ] || [ "$LABEL_IDS" != "$NAME_IDS" ]; then
echo '停止:课程节点容器的名称、标签或数量不一致' >&2
exit 1
fi
COURSE_NODE_ID="$LABEL_IDS"
case "$COURSE_NODE_ID" in
*[!0-9a-f]*|'') echo '停止:课程节点容器 ID 格式无效' >&2; exit 1 ;;
esac
[ "${#COURSE_NODE_ID}" -eq 64 ] || { echo '停止:课程节点容器 ID 不是完整 64 位' >&2; exit 1; }
printf 'cluster=%s\nnode=%s\nnode_id=%s\nlabel=%s\n' "$CLUSTER_NAME" "$NODE_NAME" "$COURSE_NODE_ID" "$CLUSTER_LABEL" | \
cmp -s - "$CLUSTER_MARKER" || { echo '停止:当前节点与集群归属账本不匹配' >&2; exit 1; }
NODE_FACTS="$(docker inspect --format '{{.Id}}|{{.Name}}|{{index .Config.Labels "io.x-k8s.kind.cluster"}}' "$COURSE_NODE_ID" 2>&1)" || { echo '停止:无法检查课程节点身份' >&2; exit 1; }
[ "$NODE_FACTS" = "$COURSE_NODE_ID|/$NODE_NAME|$CLUSTER_NAME" ] || { echo '停止:课程节点不可变身份校验失败' >&2; exit 1; }
cd "$COURSE_ROOT/taskboard" || { echo '停止:TaskBoard 项目目录不存在' >&2; exit 1; }
printf 'context=%s\nproject=%s\n' "$COURSE_CONTEXT" "$(pwd -P)"
printf 'cluster_identity=verified node_id_prefix=%.12s\n' "$COURSE_NODE_ID"context=kind-welearn-course
project=<用户目录>/welearn-kubernetes-course/taskboard
cluster_identity=verified node_id_prefix=<动态 12 位>这三行不是装饰性输出:它们把随后每个授权与策略变更绑定到明确 API 目标、不可变节点身份和文件根目录。失败时先恢复状态,不要绕过检查。
容器不是虚拟机式的独立内核。普通 Linux 容器通过 namespace 隔离进程、网络和挂载视图,通过 cgroup 限制与统计资源,但多个容器仍共享宿主机内核。容器镜像再小,也不会自动消除内核漏洞、过宽 capabilities、宿主路径挂载或特权模式带来的风险。
Kubernetes 又在容器之上增加了 API。只要某个进程拿到有效凭据,它的影响就不再局限于当前容器:它可能读取 Secret、创建工作负载、修改 Service,甚至通过创建 privileged Pod 接触节点。因此集群安全至少要同时回答四个问题:
securityContext 与 NetworkPolicy。这四层发生在不同时间。RBAC 可以允许某人创建 Pod,但不知道这个 Pod 是否设置 privileged: true;Pod Security Admission 可以拒绝危险字段,但不会决定调用者是否有 create pods 权限;securityContext 约束已经启动的进程,却不替 API Server 管理 Secret 读取;NetworkPolicy 处理三、四层网络流量,不处理 API 授权。
“最小权限”不是把所有能力都关闭,而是只保留完成任务所需的权限,并为每一项保留证据。TaskBoard 不调用 Kubernetes API,所以最小凭据范围是根本不挂载 token;课程额外创建一份只读 Role,是为了学习授权计算,而不是暗示应用必须拿到这份凭据。
Kubernetes 没有一个可以包办所有安全问题的开关。TaskBoard 至少涉及下面四层:
它们互相补充,但不能互相替代。容器以非 root 运行,不代表它不能读取 Kubernetes Secret;RBAC 禁止读取 Secret,也不代表容器进程不能利用节点内核漏洞;NetworkPolicy 对 API 权限也没有影响。
还要区分“策略声明”“准入判断”和“运行时执行”。Pod Security Standards 是社区维护的标准,描述 privileged、baseline 和 restricted 三档 Pod 字段要求;Pod Security Admission 读取 Namespace 标签,在 Pod 创建或更新请求到达时比较字段并执行 enforce、warn 或 audit;Pod 中真正被接受的 runAsNonRoot、seccomp、capabilities 等设置,最终由 kubelet、容器运行时和 Linux 内核落实。
因此 PSA 拒绝一个 Pod,说明危险规格没有进入运行阶段;PSA 接受一个 Pod,则说明它满足所选标准的字段约束,不表示应用没有漏洞,也不表示运行时和节点配置永远正确。安全验收必须针对每一层收集各自证据。
本章用一个具体假设来避免空谈:假设 TaskBoard API 的某个输入处理存在漏洞,攻击者能够在应用进程权限范围内执行命令,但尚未取得集群管理员凭据。我们希望限制以下扩散路径:
这个模型没有覆盖恶意集群管理员、容器运行时零日漏洞、软件供应链和云账号泄露。安全边界必须对着威胁说话;把所有风险都写成“已解决”,反而会让读者忽略真正未覆盖的部分。
ServiceAccount 是 Pod 在 Kubernetes API 中的身份。Role 描述某个 Namespace 内允许的动作,RoleBinding 再把 Role 绑定给具体身份。
RBAC 是 Role-Based Access Control,也就是基于角色的访问控制。它不把权限直接写在每一个 Pod 上,而是拆成“规则集合”和“谁获得规则集合”两部分。这样同一 Role 可以绑定给多个主体,也可以在不改工作负载模板的情况下撤销绑定。
第一次接触时,可以先把四个对象按范围记住:
一条 RBAC 规则的核心是 apiGroups、resources 和 verbs。核心 API 组用空字符串 "" 表示,Pod 与 Secret 都属于它;Deployment 属于 apps。动词不是 shell 命令,而是 API 操作,例如 get 读取一个对象、list 读取集合、watch 持续接收变化、create 新建、patch 部分修改、delete 删除。pods/log 不是普通 Pod,而是 Pod 的子资源,因此需要单独列出。
ServiceAccount 是面向工作负载的、属于 Namespace 的非人身份。Pod 未显式指定时会使用该 Namespace 的 default ServiceAccount。现代 Kubernetes 通常通过 TokenRequest 和 projected volume 提供有期限、可轮换的 token;但对不调用 API 的应用,最小权限仍然是根本不挂载。token 短期化降低泄漏后的持续时间,不会让过宽 RBAC 变安全。
我们给 taskboard ServiceAccount 一个只读观察角色:允许 get、list Pod,允许 get 单个 Pod 的日志,但不允许对 pods/log 使用没有必要的 list,也不允许删除 Pod 或读取 Secret。pods/log 是 Pod 的日志子资源,要与 pods 拆成两条规则,否则把同一 verbs 数组复用给两个资源时,很容易顺带授予多余动作。
为什么课程 Role 允许看 Pod,而应用又不挂 token?这是为了把“身份拥有怎样的授权”和“容器是否持有凭据”分开演示。RoleBinding 说明如果该身份完成认证,API Server 会怎样授权;automountServiceAccountToken: false 则让当前应用进程拿不到默认凭据。生产中若应用完全不需要这些只读权限,可以连 Role 与 RoleBinding 一起删除。

在项目工作目录创建 k8s/rbac.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: taskboard
namespace: taskboard
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: taskboard-observer
namespace: taskboard
rules:
- apiGroups: [""]
automountServiceAccountToken: false 适合当前 TaskBoard,因为应用不调用 Kubernetes API。只有明确选用 taskboard ServiceAccount 的 Pod 才会继承这项默认值;创建一个同名 ServiceAccount 不会自动改变现有 Deployment。RBAC 仍然定义这个身份理论上可获得的权限;如果以后某个容器确实需要调用 API,应在 Pod 层有意识地开启 token,并重新审查 Role,而不是默认给所有 Pod 挂载凭据。
创建前还要理解 RoleBinding 的 roleRef。它引用 taskboard-observer Role,绑定创建后不能随意把 roleRef 改到另一个角色;需要更换时通常删除并重建绑定。subjects 精确写出 ServiceAccount 的名称和 Namespace,避免把权限误授给同名主体。清单没有使用通配符 *,因为资源、子资源和动词会随 API 演进,通配符可能在升级后自动包含新能力。
应用三个对象后读取 Role。kubectl apply 会创建 ServiceAccount、Role 和 RoleBinding;第二条命令从 API Server 回读 Role 的实际对象。输出应只包含 get 和 list,资源范围也只包含 Pod 与日志。此时还没有修改 Deployment,所以不会触发 Pod 替换:
kubectl apply -n taskboard -f k8s/rbac.yaml
kubectl get role taskboard-observer -n taskboard -o yamlserviceaccount/taskboard created
role.rbac.authorization.k8s.io/taskboard-observer created
rolebinding.rbac.authorization.k8s.io/taskboard-observer createdrules:
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get服务端会返回动态元数据,这里只摘出 rules。第一条规则允许 Pod 的 get/list,第二条只允许日志子资源的 get。Role 没有 secrets,也没有 delete、create 或 patch。
如果输出出现预期外的动词,不要直接假设是 kubectl 格式问题。先检查 k8s/rbac.yaml 和 kubectl get role taskboard-observer -n taskboard -o yaml 的完整内容;如果 Role 正确但实际授权仍过宽,还要找同一主体是否绑定了其他 Role 或 ClusterRole。RBAC 权限是所有适用允许规则的并集,没有“显式 deny”来抵消另一条允许。
现在让 TaskBoard 真正使用这个身份。在 k8s/deployment.yaml 的 spec.template.spec 下加入 serviceAccountName: taskboard 和显式的 automountServiceAccountToken: false;二者都会改变 Pod 模板并触发滚动替换。ServiceAccount 已声明不自动挂载,Pod 模板再写一次能让凭据边界直接出现在工作负载源码中,也避免以后更换 ServiceAccount 时悄悄恢复默认挂载。
手工修改长 YAML 时,最危险的不是缩进报错,而是只粘贴一个短片段后覆盖同级的容器、卷或安全上下文。下面给出本阶段 k8s/deployment.yaml 的完整关键内容:镜像已经是 2.0.0,配置来自 ConfigMap/Secret/Downward API,资源、探针、只读根文件系统和 /tmp 卷全部保留。请用它逐项合并并核对,不要删减成只有 ServiceAccount 的 Pod 模板。
apiVersion: apps/v1
kind: Deployment
metadata:
name: taskboard
namespace: taskboard
labels:
app: taskboard
spec:
replicas: 2
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge
serviceAccountName 必须位于 Pod spec,也就是 spec.template.spec;放到 Deployment 顶层不会生效。automountServiceAccountToken 同样位于 Pod spec。保存后可以先运行 kubectl diff -n taskboard -f k8s/deployment.yaml 阅读变更;它是服务端差异预览,不会修改对象。
保存完整的 k8s/deployment.yaml 后重新应用。修改 Pod 模板会生成新 ReplicaSet,所以先等待新 Pod 全部就绪。第一条 JSONPath 回读 Deployment 模板中的 ServiceAccount 与 token 开关,避免只凭文件判断;随后用 custom-columns 读取每个实际 Pod 的 .spec.serviceAccountName;最后进入其中一个容器检查 token 路径不存在。这里的 test ! -e 只读文件元数据,不会修改容器。
同一原则也必须覆盖长期运行的 Redis 和已经完成的初始化 Job。第 9 章已在两份 Pod 模板中显式关闭 token;本节会回读 StatefulSet、Job 与实际完成 Pod 的字段。Redis 仍在运行,可以进入容器验证 token 文件不存在;Job 容器已经退出,不能用 exec,所以改为确认实际 Pod 的 automountServiceAccountToken=false,并断言卷列表里没有自动生成的 kube-api-access-* 投影卷。
这些检查要分别证明模板已发布、身份已选用、凭据未挂载,不能只看 Deployment 的 configured:
kubectl apply -n taskboard -f k8s/deployment.yaml
kubectl rollout status deployment/taskboard -n taskboard --timeout=90s
kubectl get deployment taskboard -n taskboard \
-o jsonpath='template_serviceaccount={.spec.template.spec.serviceAccountName}{"\n"}template_automount={.spec.template.spec.automountServiceAccountToken}{"\n"}'
kubectl get pods -n taskboard -l app=taskboard \
-o custom-columns='NAME:.metadata.name,SERVICEACCOUNT:.spec.serviceAccountName'
TASKBOARD_POD="$(kubectl get pods
deployment.apps/taskboard configured
deployment "taskboard" successfully rolled out
template_serviceaccount=taskboard
template_automount=false
NAME SERVICEACCOUNT
taskboard-<模板哈希>-<后缀 A> taskboard
taskboard-<模板哈希>-<后缀 B> taskboard
token-not-mounted
redis-token-not-mounted
init-job-token-not-mounted这组输出把身份与凭据分开验证:服务端 Deployment 模板保存了身份与显式 token 开关,新 Pod 已经替换完成,每个 TaskBoard Pod 选用 taskboard 身份,而且 TaskBoard、Redis 与初始化 Job 都没有默认 API token。Job 的成功文本是在模板、实际 Pod 字段和实际卷名三项断言之后才打印,不是根据 YAML 猜测。RoleBinding 的授权还需要下一节用 API Server 实际计算。
Pod 名、模板哈希和后缀会变化;稳定部分是两行 SERVICEACCOUNT 都为 taskboard,以及三个 *-token-not-mounted 结果。若 TaskBoard 或 Redis 检查失败,读取对应 Pod spec 与 /var/run/secrets/kubernetes.io/serviceaccount/;若 Job 检查失败,读取 Job 模板和完成 Pod 的卷列表。不要为了得到预期文本而直接删除 token 文件或投影卷;挂载行为应通过声明修正。
RBAC 清单表达的是期望权限,kubectl auth can-i 会让 API Server 按实际授权规则计算答案。使用 --as 模拟 taskboard ServiceAccount,可以发现其他 RoleBinding 带来的权限,也能避免只凭肉眼漏看规则。
API Server 的请求路径可以简化成认证、授权、准入三个阶段。--as=system:serviceaccount:taskboard:taskboard 让本次检查以标准 ServiceAccount 用户名进行授权计算;前提是当前操作者自己被允许 impersonate。-n taskboard 限定目标 Namespace。命令没有使用 Pod 内 token,也不会给 ServiceAccount 新权限。
为什么不只检查 Role?因为权限是多个来源的并集。同一个 ServiceAccount 可能同时出现在 RoleBinding、另一个 RoleBinding 和 ClusterRoleBinding 中。你眼前这份 Role 很小,不代表实际答案一定小。auth can-i 请求 API Server 的授权评审,更接近调用真正发生时的决定。
我们预计:读取 Pod 和读取 Pod 日志为 yes,删除 Pod 和读取 Secret 都是 no。
下面四个检查使用同一个 ServiceAccount 身份和同一个 Namespace,只改变动词、资源或子资源。它们都是读取授权结论,不创建或修改资源。--subresource=log 让第二条精确评估 pods/log,而不是再次评估普通 Pod:
kubectl auth can-i get pods \
-n taskboard \
--as=system:serviceaccount:taskboard:taskboard
kubectl auth can-i get pods \
--subresource=log \
-n taskboard \
--as=system:serviceaccount:taskboard:taskboard
kubectl auth can-i delete pods \
-n taskboard \
--as=system:serviceaccount:taskboard:taskboard
kubectl auth can-i get secrets
yes
yes
no
no这四个结果比“Role 看起来很小”更有说服力。前两个 yes 分别对应普通 Pod 与日志子资源,后两个 no 对应删除 Pod 与读取 Secret。特别是 get secrets=no:能创建 Pod 的身份仍可能通过把 Secret 挂进新 Pod 间接读取它,因此生产授权还要一起审查 create pods、create deployments 等写权限。这里的观察角色没有这些权限。
这一点叫间接提权路径。RBAC 好实践文档明确提醒,能在某个 Namespace 创建 Pod 或管理 Pod 的工作负载对象,通常就能让新 Pod 挂载该 Namespace 中的 Secret、ConfigMap 和 PVC,也可能选择该 Namespace 中权限更高的 ServiceAccount。于是“不能直接 get secrets”并不等于“无法得到 Secret 内容”。同样需要谨慎的还有:
list 或 watch secrets 会返回 Secret 数据,不能当成低风险只读权限;bind 可绕过常规限制绑定更高角色,escalate 可创建超出自身权限的角色;impersonate 可借用其他身份,正是本节管理员检查使用的能力;create serviceaccounts/token 可请求其他 ServiceAccount token;get nodes/proxy 可能访问 kubelet API,不是普通意义的只读节点信息;生产审核不能只搜 cluster-admin。更可靠的办法是从可达路径出发:这个身份能创建什么对象、能让对象以谁的身份运行、能挂载哪些数据、能修改哪些策略入口。
输出中的四个词应该逐行对应四条命令。如果 delete pods 或 get secrets 返回 yes,下一步不是修改本 Role 来“加一个 no”——RBAC 没有拒绝规则。应执行 kubectl get rolebinding,clusterrolebinding -A -o yaml 并按主体检索所有绑定,再移除不该存在的允许来源。如果四条都报 Forbidden,则当前操作者可能没有 impersonate 权限,不能把命令失败误读成被测身份的 no。
执行 --as 的操作者自己需要 impersonate 权限。普通应用账号通常不能随意模拟其他身份;这里由课程管理员身份发起授权检查。
TaskBoard Deployment 已经设置 Pod 级和容器级安全上下文。Pod 级设置提供共同默认值:
securityContext 是 Pod 规格的一部分,不是另一个独立控制器。API Server 接受 Pod 后,kubelet 把这些设置交给容器运行时,运行时再配置进程用户、Linux capabilities、seccomp 和文件系统挂载等。Pod Security Admission 可以要求这些字段必须写成安全值;真正启动进程时执行值的是节点侧组件。PSP 被移除不影响 securityContext,因为二者从来就不是同一层。
下面第一个片段位于 spec.template.spec.securityContext,会给 Pod 中容器提供共同默认值。它只用于复习前面已存在的配置,不需要再次单独 apply:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault容器级设置进一步收紧进程:
下面第二个片段位于 spec.template.spec.containers[*].securityContext。容器级字段可以覆盖适用的 Pod 级值,也包含只能在容器级表达的选项:
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]这些字段分别限制不同能力:
runAsNonRoot: true 拒绝以 UID 0 启动容器。TaskBoard 镜像中的用户是 UID 10001。seccompProfile.type: RuntimeDefault 使用容器运行时的默认系统调用过滤配置。allowPrivilegeEscalation: false 阻止进程通过 setuid、setgid 等方式获得更高权限。readOnlyRootFilesystem: true 禁止写入镜像根文件系统。capabilities.drop: ["ALL"] 移除默认附带的 Linux capabilities,需要某项能力时再逐项评估。这些名字很像“绝对安全开关”,实际都有边界:
runAsNonRoot 根据镜像用户或显式 UID 阻止 UID 0,不检查应用是否拥有危险文件,也不阻止内核漏洞。RuntimeDefault 的具体配置由容器运行时提供;它不是网络访问控制,也不是文件权限模型。allowPrivilegeEscalation: false 主要落实 Linux 的 no_new_privs,限制当前进程通过 setuid 等方式取得新权限;它不撤销进程启动时已经拥有的所有权限。readOnlyRootFilesystem 让镜像根文件系统只读,但挂载的 Secret、ConfigMap、emptyDir 或 PVC 各有自己的读写属性。TaskBoard 仍需要写 /tmp/not-ready,所以 Deployment 给 /tmp 挂载了 emptyDir。只读根文件系统并不等于应用完全不能写文件,而是把可写位置收敛到明确声明的卷。
这是一种很实用的设计方法:先把根文件系统设为只读,让应用暴露出真实写入需求,再为必要目录声明最小卷。日志最好写到标准输出,由日志系统收集;缓存和临时文件放到有容量约束的临时卷;业务数据交给专用持久系统。不要因为一次 Permission denied 就取消只读设置,先判断这个写入是否必要、是否应持久。
生产环境还要考虑镜像层与节点层:镜像应固定不可变 digest、减少软件和 setuid 文件、持续扫描并及时更新;节点需要受支持的内核和运行时、限制管理入口,并有独立的检测与响应。securityContext 能压缩影响范围,却无法把一个长期未修补的应用或节点变成安全系统。
安全上下文不是应用漏洞的修复方案。它的价值在于压缩容器被利用后的权限和可写范围,应用本身仍需要依赖更新、输入校验和安全的凭据处理。
Pod Security Standards 定义 privileged、baseline 和 restricted 三档策略。Pod Security Admission 是内置准入控制器,它通过 Namespace 标签决定创建或更新 Pod 时采用哪档标准。
PodSecurityPolicy 已在 Kubernetes v1.25 移除,不能再使用旧的 PSP 清单。现在应使用 Pod Security Admission,或使用 Kyverno、Gatekeeper 等策略引擎处理更细的组织规则。
PodSecurityPolicy,简称 PSP,曾是 Kubernetes 内置的 Pod 安全字段策略。它从 Kubernetes 早期就承担一个现实问题:RBAC 只知道调用者有没有 create pods,无法分辨提交的是普通 Web Pod,还是拥有宿主机和特权访问的 Pod。PSP 因此在准入阶段检查 Pod 规格,并且在部分字段上还能修改或补默认值。
问题出在“哪一条 PSP 会作用于谁”以及“策略是否会悄悄修改对象”过于复杂。管理员需要创建多个 PSP,再通过 RBAC 的 use 权限决定不同主体可用哪些策略。一个 Pod 可能满足多条策略,策略选择与授权关系不容易从工作负载清单直接看出;某些 PSP 字段会变更默认值,另一些不会,最终运行规格可能与提交者写下的内容不同。官方回顾还指出,PSP 缺少适合既有集群渐进迁移的 dry-run 或 audit 模式,很难先观察影响再安全启用。
Kubernetes 项目在 v1.21 弃用 PSP,并在 v1.25 移除。替代路径吸收了一个经验:大多数集群首先需要少数一致、可理解的安全档位,更复杂的组织规则可以交给通用策略引擎。因此社区维护 Pod Security Standards 三档标准,内置 PSA 按 Namespace 标签执行,提供 warn、audit 和 enforce 三种可并行模式。PSA 在 v1.25 达到 stable。
这不是功能一比一替换:
use 关系选择策略,PSA 主要通过 Namespace 标签选择模式和版本。迁移时不能删掉 PSP 后直接打开 restricted。合理顺序是先盘点 PSP 的变更与授权语义,使用 warn、audit 或 dry-run 发现差异,修改工作负载显式写出安全上下文,再把 enforce 固定到经过验证的版本。对组织特有规则,要先选择新的策略落点。
我们给 taskboard Namespace 设置 restricted:v1.36:
enforce 拒绝新的违规 Pod。warn 把违规原因返回给提交者。audit 把违规信息写入审计注解。v1.36 版本,避免集群升级后策略含义未经评估就改变。固定版本并不表示永远停在 v1.36。它表示升级策略是一项显式变更:先阅读新版本 Pod Security Standards 差异,在非生产 Namespace 用 warn、audit 和服务器端 dry-run 检查,再更新标签。若使用 latest,集群升级可能让同一份工作负载在下一次滚动发布时突然被拒绝。
更新 Namespace 标签会改变该 Namespace 后续 Pod 准入规则。六个键分别设置三种模式及其策略版本,--overwrite 允许重复执行时把旧值收敛到这里定义的值。命令会读取并修改 Namespace 标签,不会修改 Pod 模板;准入控制器可能检查当前已有 Pod 并给出聚合警告,但不会追溯驱逐它们。
执行前确认当前 TaskBoard、Redis、Job 和 toolbox-safe 已按前文设置非 root、seccomp、禁止提权和 capabilities。否则直接启用 enforce 可能让下一次滚动发布无法创建替代 Pod。确认后再提交标签:
kubectl label namespace taskboard \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.36 \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.36 \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/audit-version=v1.36 \
--overwritenamespace/taskboard labeled前文创建的 TaskBoard、Redis、Job 和 toolbox-safe 都具备 restricted 要求的关键安全上下文,因此这条从头执行的路径只返回 Namespace 已标记。
namespace/taskboard labeled 只证明标签修改被 API Server 接受。它没有主动重新扫描并修复运行中 Pod,也没有证明拒绝路径真正工作;下一节会提交一份已知违规清单,观察 API Server 返回具体字段并确认对象不存在。如果标签命令出现存量 warning,应把 Pod 名和违规字段记录下来,在开启下一次发布前修改模板,而不是忽略警告。
如果在其他 Namespace 中给已有违规 Pod 开启 enforce,标签命令可能会附加存量警告,但不会追溯驱逐那些 Pod。Pod Security Admission 检查新建或更新请求;迁移时应先用 warn 和 audit 发现存量问题,修复后再启用 enforce。
现在提交一个明确要求 privileged: true 的 Pod。清单还缺少 restricted 要求的非 root、seccomp、禁止提权和 capabilities 限制,因此 API Server 应一次返回全部相关字段,而不是创建后再让 Pod 启动失败。
privileged: true 会让容器获得接近宿主机进程的广泛能力,并取消多项普通容器隔离限制。在没有额外边界的环境中,能够创建这种 Pod 可能演变为节点控制权。本节使用它不是为了启动危险进程,而是为了验证准入控制器在对象持久化之前阻断请求。
清单特意保持最小且明显违规:没有挂宿主目录,也不执行节点命令。只要 restricted:v1.36 正确生效,容器不会被创建,sleep 3600 更不会运行。不要在未确认 PSA 的其他 Namespace 中照搬这份清单。
在项目工作目录创建 k8s/insecure-pod.yaml:
apiVersion: v1
kind: Pod
metadata:
name: privileged-demo
namespace: taskboard
spec:
containers:
- name: shell
image: busybox:1.37.0
command: ["sh", "-c", "sleep 3600"]
securityContext:
privileged: true应用清单后再执行一次 get。第一次请求预期以非零退出码返回 Forbidden,且消息明确来自 PodSecurity restricted;第二次按名称读取预期返回 NotFound,证明对象没有持久化。只看“两条命令都失败”仍不足够:认证过期也会让两者失败,所以 shell 必须同时校验退出状态与错误语义。
确认这两种预期后再执行:
if PSA_APPLY_OUTPUT="$(
kubectl apply -n taskboard -f k8s/insecure-pod.yaml 2>&1
)"; then
PSA_APPLY_RC=0
else
PSA_APPLY_RC=$?
fi
printf '%s\n' "$PSA_APPLY_OUTPUT"
if [ "$PSA_APPLY_RC" -eq 0 ]; then
echo '停止:不安全 Pod 意外通过准入' >&2
exit 1
Error from server (Forbidden): error when creating "k8s/insecure-pod.yaml": pods "privileged-demo" is forbidden: violates PodSecurity "restricted:v1.36": privileged (container "shell" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "shell" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "shell" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "shell" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "shell" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
Error from server (NotFound): pods "privileged-demo" not found
psa_rejection=verified apply_rc=1 get_rc=1拒绝信息列出了五类真实问题。修复时应该逐项满足策略,而不是删掉 Namespace 的 enforce 标签。准入发生在对象持久化之前,因此集群里不会出现短暂运行的 privileged-demo。
逐项看错误:privileged 直接拒绝特权模式;allowPrivilegeEscalation != false 表示字段缺失也不满足 restricted;unrestricted capabilities 要求移除 ALL;runAsNonRoot != true 要求明确声明非 root;seccompProfile 要求 RuntimeDefault 或允许的 Localhost 配置。拒绝信息把“哪个容器、哪个字段、需要什么值”都带回来了,最可靠的修复是改源清单并重新评审。
如果第一条意外创建成功,先不要进入或运行该 Pod。立即删除 privileged-demo,再执行 kubectl get namespace taskboard --show-labels 检查标签拼写、enforce 值和版本,并确认集群启用了 Pod Security Admission。若第一条被拒绝但第二条能读到对象,可能是同名旧 Pod 早已存在;比较 .metadata.creationTimestamp 和 UID,并清理旧对象后重新验证。
PSA 的边界也要讲清:它在准入时根据 Pod 规格做判断,不持续监控运行中进程,不扫描镜像漏洞,不检查应用输入,也不会因为后来修改 Namespace 标签就驱逐已有 Pod。运行时约束仍依赖实际 Pod 的 securityContext 和节点实现;更细的镜像仓库、标签、团队例外或自定义字段规则需要其他准入策略。
第 7 节已经避免在输出中回显 Secret,但日志纪律只是第一层。生产设计还需要考虑:
Secret 是为敏感配置提供单独 API、分发方式和权限边界的对象,不是自动完成加密生命周期的保险箱。YAML 中常见的 data 值只是 base64 编码,任何拿到内容的人都能还原。Kubernetes 官方安全实践明确说明,Secret 默认在 etcd 中并未加密,需要集群管理员显式配置静态加密。
get、list、watch secrets,也审查能创建 Pod 的间接读取路径。RBAC 检查得到 get secrets=no 是正确起点,但不能单独证明 Secret 已经安全。
Secret 通过环境变量注入还有更新语义:进程在启动时得到值,Secret 后续变更不会自动修改已存在进程的环境。课程前面使用滚动重启让配置生效;生产轮换要设计新旧凭据并存窗口,先让服务接受新凭据,再替换消费者,最后撤销旧凭据。直接覆盖口令而没有协调依赖,可能让所有副本同时认证失败。
卷形式的 Secret 可以由 kubelet更新投影内容,但应用是否重新读取文件仍取决于实现;使用 subPath 挂载还有不同更新行为。无论哪种分发方式,Secret 一旦进入进程,它可能出现在内存、崩溃转储、调试输出或子进程环境中。需要把日志脱敏、调试权限、节点访问和进程隔离一起考虑。
外部 Secret 系统也不是自动终点。控制器通常仍会把值同步为 Kubernetes Secret,或者通过 CSI 挂载到 Pod;你要追踪明文在哪些边界出现、谁能读取外部系统、同步失败时应用怎样表现、审计记录在哪里。目标是缩短凭据寿命、缩小分发范围并让轮换可验证,而不是仅仅换一个产品名。
审核时可以按下面顺序追问:
get、list、watch Secret?list 和 watch 同样可能返回数据。这套检查解释了为什么 get secrets=no 只是一个证据,而不是完整结论。
NetworkPolicy 是 Kubernetes API 对象,真正的数据包过滤由集群网络实现。这里必须纠正一个常见的旧结论:kind 在 v0.24.0 起默认提供基于 sigs.k8s.io/kube-network-policies 的开箱即用 NetworkPolicy 支持;本课程固定使用 kind v0.32.0,因此可以并且应该做真实正反向验证。旧版 kind 或显式关闭默认网络的集群不能直接套用这个结论。
Kubernetes 网络模型默认让 Pod 之间可以直接通信。NetworkPolicy 通过 podSelector 选中要隔离的 Pod,再用 ingress 和 egress 规则描述允许的来源、目的、端口和协议。规则采用“允许集合相加”语义,不按 YAML 顺序覆盖:某方向一旦被任一策略选中,就只允许所有适用策略并集中的流量。
本章只保留项目已经实际需要的三条路径:
所有 taskboard Namespace Pod ──UDP/TCP 53──▶ kube-system/CoreDNS
toolbox-safe ──TCP 8080──▶ TaskBoard Pod
TaskBoard Pod ──TCP 6379──▶ Redis Pod调用方访问 http://taskboard 时使用 Service 端口 80,但 Service 把流量转到 Pod 的 8080;NetworkPolicy 的端口规则约束目标 Pod 端口,所以放行的是 8080。TaskBoard 访问 Redis Service 后,实际目标是 Redis Pod 的 6379。Redis ingress 只允许标签为 app=taskboard 的来源,普通诊断 Pod不能直连数据库。
我们先对整个 taskboard Namespace 同时默认拒绝 ingress 与 egress,再逐条添加 DNS、toolbox 到 API、API 到 Redis 以及 Redis 接收 API 的允许策略。一条连接同时受“源 Pod 的 egress”和“目标 Pod 的 ingress”约束;两端都被默认拒绝选中时,只放行一端仍然不通。因此 toolbox→TaskBoard 既有 toolbox egress 策略,也有 TaskBoard ingress 策略;TaskBoard→Redis 同理。
镜像拉取由节点侧容器运行时完成,不是 Pod egress;kubelet 对本节点 Pod 的探针也有网络策略的节点流量边界。生产系统还要把监控、遥测、外部 API 和运维入口加入通信清单,不能照搬这个最小集合。

在项目目录创建 k8s/network-policy.yaml:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: taskboard
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace:
空的 podSelector: {} 在 namespaced NetworkPolicy 中选择该 Namespace 的所有 Pod。DNS peer 同时包含 Namespace 和 Pod selector,表示二者的交集;若拆成两个数组项,会变成“整个 kube-system Namespace 或任意 Namespace 的 kube-dns 标签”,范围更宽。其余 peer 没写 namespaceSelector,因此只匹配 taskboard 内的 Pod。
kubectl apply 只证明 API 对象保存成功,所以紧接着从已有 toolbox-safe 请求 /api/info 与 /api/tasks。前者验证 toolbox 到 TaskBoard 8080,后者还会迫使 TaskBoard 访问 Redis 6379。shell 分别捕获 kubectl 的退出码;任何 context、认证、exec 或业务失败都会保留非零并停止,不会把错误无条件转为成功。
if [ "$(kubectl config current-context)" != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
if ! kubectl apply -n taskboard -f k8s/network-policy.yaml; then
echo '停止:NetworkPolicy 清单未成功保存' >&2
exit 1
fi
if ! kubectl get networkpolicy -n taskboard; then
下面展示这一段需要核对的稳定验收字段。NetworkPolicy 的 AGE、TaskBoard Pod 名和 JSON 字段顺序都属于动态值,不应写进断言:
networkpolicy.networking.k8s.io/default-deny-all created
networkpolicy.networking.k8s.io/allow-dns-egress created
networkpolicy.networking.k8s.io/allow-toolbox-egress-to-taskboard created
networkpolicy.networking.k8s.io/allow-taskboard-paths created
networkpolicy.networking.k8s.io/allow-redis-from-taskboard created
NAME POD-SELECTOR AGE
default-deny-all <none> <动态时间>
allow-dns-egress <none> <动态时间>
allow-toolbox-egress-to-taskboard app=toolbox-safe <动态时间>
allow-taskboard-paths app=taskboard <动态时间>
allow-redis-from-taskboard app=redis <动态时间>
allowed_info={"service":"taskboard","version":"2.0.0","message":"你好,配置已经更新","pod":"taskboard-<动态后缀>","namespace":"taskboard","redisConfigured":true}
allowed_tasks={"items":[{"title":"由 Job 初始化","done":false}],"count":1}
allowed_info_exit=0
allowed_tasks_exit=0两个退出码为 0 是正向验收核心,任务 count 的断言进一步证明 Redis 依赖可达。若 info 失败,先核对 toolbox 与 TaskBoard 标签、8080 规则和 EndpointSlice;若 info 成功而 tasks 失败,问题集中在 TaskBoard egress、Redis ingress、6379 端口或 Redis 本身。不要在失败时立即放开整个 Namespace。
只测允许流量无法证明 default-deny 生效。下面的 blocked-client 使用与 toolbox 相同的 BusyBox 工具,却只有 app=blocked-client 标签,因此它能使用全 Pod DNS 规则,却不满足 toolbox 或 TaskBoard 来源选择器。Pod 显式满足 restricted:非 root、RuntimeDefault seccomp、禁止提权、只读根文件系统、移除所有 capabilities,并且不挂载 ServiceAccount token。
在项目目录创建 k8s/blocked-client.yaml:
apiVersion: v1
kind: Pod
metadata:
name: blocked-client
namespace: taskboard
labels:
app: blocked-client
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
先创建并等待 Pod Ready,再用 nslookup taskboard 验证 DNS 允许规则。之后的远端 shell 把真实 wget 或 nc 退出码打印为 probe_exit,并在观察到网络失败时刻意以 42 或 43 退出。外层 shell 捕获 kubectl 退出码,只接受这两个专用值;认证失败、context 错误、Pod 不存在会返回其他值并使实验失败,而不会被误判成策略拦截。
cleanup_blocked_client() {
kubectl delete pod blocked-client -n taskboard \
--ignore-not-found --wait=true
}
trap cleanup_blocked_client EXIT
if ! kubectl apply -n taskboard -f k8s/blocked-client.yaml; then
echo '停止:受限客户端未成功创建' >&2
exit 1
fi
if ! kubectl wait -n taskboard --for=condition=Ready \
下面同样使用动态验收字段;wget/nc 的错误文字可能随 BusyBox 和网络数据面变化,因此断言依赖容器内探针标记与专用退出码,不逐字匹配错误句子:
pod/blocked-client created
pod/blocked-client condition met
wget: download timed out
probe_exit=1
command terminated with exit code 42
probe_exit=1
command terminated with exit code 43
dns_exit=0
blocked_http_remote_exit=42
blocked_redis_remote_exit=43
pod "blocked-client" deleteddns_exit=0 排除了“因为 DNS 被误拦才连接失败”;42 证明 HTTP 探针确实在容器内运行且 wget 非零,43 对 Redis 的 nc 做同样证明。在 kind v0.32.0 的验证记录中,策略生效前两个客户端都能访问 TaskBoard;生效后允许客户端仍成功,受限客户端的原始 wget 退出码为 1,并输出 wget: download timed out。课程 shell 再把这个已验证的远端失败映射为专用码 42,以便与 kubectl 认证或 context 错误区分。
前面的允许请求同时为 0,所以失败不是 TaskBoard 或 Redis 整体宕机。正向与负向证据合在一起,才说明策略选择器和端口路径按预期工作。nc 的错误句子可能因 BusyBox 与网络实现不同而变化,所以只断言容器内非零的 probe_exit 和映射后的 43。
若负向请求返回 0,先读取 kubectl get networkpolicy -n taskboard -o yaml、Pod 标签和 kind 版本;若外层退出码为 1、126、127 或其他非专用值,应把它当成 kubectl、认证、命令或容器问题,不得写成“已拦截”。trap 会在正常结束、中断或任一断言 exit 时按精确 Pod 名清理客户端;成功路径显式清理后撤销 trap,避免 shell 后续操作重复执行。
NetworkPolicy 仍不是应用层授权。允许 TaskBoard 连 Redis 6379,不代表 Redis 认证、命令权限和数据隔离已经正确;拒绝一条连接也不修复镜像漏洞。标准 NetworkPolicy 主要按 IP、Pod/Namespace 选择器、端口和协议工作,对域名、HTTP 路径、用户身份或内容的控制需要其他机制。生产迁移还要从允许与禁止来源做持续连通性测试,并核对所选 CNI 的实现边界。
本节既读取了 NetworkPolicy 对象,也取得了允许路径成功、DNS 成功以及两个禁止路径失败的运行证据。对象存在回答“声明是什么”,正反向请求回答“数据面是否执行”,两者不能互相替代。
进入最终排障与清理前,项目业务资源仍应保持运行,本章新增的是安全控制与验证结果:
serviceAccountName 都是 taskboard;token-not-mounted、redis-token-not-mounted、init-job-token-not-mounted;auth can-i 依次得到 yes、yes、no、no,并能解释 Pod、日志子资源、权限并集与间接提权;RuntimeDefault、禁止提权、只读根文件系统和移除 capabilities;restricted:v1.36 的 enforce、warn 与 audit;/api/info 和 /api/tasks 成功,受限客户端 DNS 成功、HTTP 和 Redis 连接分别以专用退出码 42/43 证明被拦截;若 TaskBoard 因模板更新未 Ready,应在进入下一章前沿 Deployment、Pod 条件、事件和日志查清。最终章节会主动制造故障;它要求开始时系统已有健康基线,否则会把旧故障和新故障混在一起。
本章的历史和安全边界来自 Kubernetes 项目维护的一手资料:
automountServiceAccountToken。bind、escalate、impersonate 等间接提权路径。阅读安全资料时,持续问三个问题:控制发生在请求前还是运行时,谁真正执行,什么结果能证明它生效。这样才能把 YAML、控制器和数据面放回各自边界。