课程目录(第 22 章 / 共 33 章)
安全加固:从最小权限到镜像供应链
把「跑起来了」变成「跑得安全」:用 Pod Security Admission 和 securityContext 收紧工作负载,用证书与镜像供应链守住入口。
学完这一章,你将能够
- ✓说得出 Kubernetes 安全的四个层次各自管什么
- ✓会用命名空间标签把 Pod Security Admission 调到 restricted 并读懂拦截报错
- ✓写出一份能被 restricted 档接受的 securityContext
- ✓用 cert-manager 自动签发证书,并知道镜像固定 digest 的意义
安全的四层:从集群到镜像
前面二十章里,你关心的主要是「应用能不能跑起来」。到了生产环境,问题会变成「它会不会被人从外面攻破,或者把集群里的东西搞坏」。这两件事的难度完全不在一个量级。
安全不是一个开关,而是四层各自要守的东西。任何一层破口都可能导致整条链路失守:镜像里有后门,再严格的 RBAC 也拦不住;Pod 以 root 跑并且能读宿主机文件,网络策略再细也白搭。
供应链层 ─ 可信仓库 → 扫描 → 签名 → 准入校验
│
▼
工作负载层 ─ securityContext + PSA + NetworkPolicy
│
▼
节点层 ─ 内核与运行时补丁、kubelet 认证、最小化主机
│
▼
集群层 ─ RBAC + ServiceAccount + 审计日志 + Secret 加密| 层次 | 主要威胁 | 本章对应的手段 |
|---|---|---|
| 集群 | 越权访问、密钥泄露 | 最小权限 RBAC、Secret 外部托管、etcd 静态加密 |
| 节点 | 逃逸、提权、主机被入侵 | 容器运行时加固、主机补丁、禁止特权容器 |
| 工作负载 | 容器以 root 运行、可提权、可写根文件系统 | Pod Security Admission、securityContext、NetworkPolicy |
| 供应链 | 恶意镜像、被篡改的依赖 | 可信仓库、固定 digest、扫描、签名与准入 |
这一章按「工作负载 → 集群 → 供应链」的顺序讲,因为工作负载层最容易上手,收益也最直接。
Pod Security Admission:三档标准
它解决的问题是:怎么在集群层面统一约束 Pod 的安全配置,而不是靠每个人自觉。
Pod Security Admission(简称 PSA)是 Kubernetes 内置的准入控制器。你在命名空间上打标签,它就在创建 Pod 时按对应档位校验。它取代了已经移除的 PodSecurityPolicy(PSP):PSP 是「按用户授权」的复杂模型,PSA 是「按命名空间分档」的简单模型。
| 档位 | 允许什么 | 会拦下什么 |
|---|---|---|
| privileged | 几乎不限制,等同于关闭校验 | 基本不拦,仅建议给系统组件用 |
| baseline | 禁止特权容器、hostNetwork、hostPID、hostPath 等危险能力 | 特权容器、挂载宿主机目录的 Pod |
| restricted | 在 baseline 之上,还要求非 root、禁止提权、丢弃全部能力、设置 seccomp | 任何没有写 securityContext 的「裸」Pod |
三档通过命名空间标签控制,可以分开设置执行、审计和告警行为:
| 标签 | 作用 |
|---|---|
pod-security.kubernetes.io/enforce | 真正拦截不合规的 Pod |
pod-security.kubernetes.io/audit | 只写审计日志,不拦 |
pod-security.kubernetes.io/warn | 只给客户端打印警告,不拦 |
pod-security.kubernetes.io/enforce-version | 固定校验用的策略版本,如 latest 或 v1.30 |
推荐的落地顺序是 warn → audit → enforce:先让团队看到警告,把存量工作负载改完,再开拦截,否则会在某天早上突然什么都发布不出去。
securityContext:加固前后的 Pod 对比
securityContext 写在 Pod 或容器里,描述「这个进程以什么身份、拥有什么能力运行」。下面这些字段是加固的核心。
| 字段 | 作用 | 加固建议 |
|---|---|---|
runAsNonRoot | 拒绝以 uid 0 启动 | 设为 true |
runAsUser / runAsGroup | 指定运行用户与组 | 用镜像里存在的非零 uid |
readOnlyRootFilesystem | 容器根文件系统只读 | 设为 true,需要写的地方挂 emptyDir |
allowPrivilegeEscalation | 是否允许通过 setuid 等方式提权 | 设为 false |
capabilities.drop | 丢弃 Linux capabilities | 丢弃 ALL,再按需加回 |
seccompProfile.type | 限制可用的系统调用 | 设为 RuntimeDefault |
加固前,把下面这份清单保存成 loose-pod.yaml,它没有任何安全字段:
apiVersion: v1
kind: Pod
metadata:
name: loose-pod
spec:
containers:
- name: web
image: nginx:1.27它没有写任何安全字段,于是以 root 运行、拥有默认能力、根文件系统可写,在 restricted 档下会被直接拒绝。把下面这份加固版保存成 hardened-pod.yaml:
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: web
image: nginxinc/nginx-unprivileged:1.27
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}两个容易忽略的点:automountServiceAccountToken: false 让这个 Pod 不自动挂载 ServiceAccount 令牌,绝大多数应用用不到它,关掉就少一个可被利用的凭证;nginxinc/nginx-unprivileged:1.27 是官方提供的非 root 版 nginx,监听 8080,普通版 nginx:1.27 需要 root 绑定 80 端口并写 /var/cache/nginx,在 restricted 下改造成本更高。
最小权限:RBAC、网络与 Secret
工作负载加固之后,第二件事是限制它能做什么、能连谁、能拿到什么密钥。这三块在前面章节都讲过机制,这里只说生产上的加固要点。
- RBAC:给应用单独建 ServiceAccount,只授予它真正需要的动词和资源;能用 Role 就别用 ClusterRole,能限定
resourceNames就限定。写权限尤其要克制,create pods在很多场景等价于「可以拿到节点上的任意 Secret」。完整写法见 第 12 章 命名空间与 RBAC。 - 网络:默认拒绝一切入站,再按调用关系放通,让「被打进来」只能沿着你画好的路径走。策略写法见 集群网络。
- Secret:不要把明文 Secret 提交进 Git。生产上常见三条路:用 External Secrets Operator 从 Vault、云厂商密钥服务同步;用 SealedSecrets 把密文安全地放进仓库;或者干脆让应用直接以工作负载身份(如云上的 IRSA、Workload Identity)访问密钥服务,不再落地静态密钥。
Secret 轮转的思路是「定期换 + 不重启也能生效」:挂载为卷的 Secret 会被 kubelet 周期性更新,应用需要自己重读文件;以环境变量注入的 Secret 不会更新,必须重启 Pod。另外,Secret 在 etcd 里默认只是 base64 编码,要开静态加密(EncryptionConfiguration)才有意义,这一点常被忽略。更多细节见 第 9 章 ConfigMap 与 Secret。
证书管理:cert-manager 签发与轮转
手工申请、上传、续期证书是三件事,而且每件都会忘。cert-manager 把这三件事变成一份 YAML:它监听 Certificate 对象,自动向 ACME(比如 Let's Encrypt)或内部 CA 申请,把结果写进 Secret,并在到期前自动续期。
用 Helm 安装,注意 CRD 要一起装:
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true
kubectl -n cert-manager rollout status deploy/cert-manager-webhook
kubectl get crd | grep cert-manager如果 chart 版本较老、提示没有 crds.enabled 这个值,换成 --set installCRDs=true 即可,两种写法对应不同时期的 chart。
Issuer 与 ClusterIssuer:谁来签发
| 对象 | 作用域 | 什么时候用 |
|---|---|---|
ClusterIssuer | 集群级,所有命名空间都能引用 | 公网域名走 Let's Encrypt,全集群共用一份配置 |
Issuer | 只在所在命名空间生效 | 内部 CA,或按团队隔离签发策略 |
签发方式决定验证途径:HTTP01 要求公网能访问到 Controller 的 80 端口,公网域名首选;DNS01 改为在 DNS 服务商那里写一条 TXT 记录,适合泛域名证书和没有公网 80 端口的内网集群。
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
ingressClassName: nginx内网域名用不了 ACME,就走内部 CA:先用 selfSigned 签一张 CA 证书,再建一个 ca 类型的 ClusterIssuer 引用它,业务证书改用它签发,浏览器/客户端把这张 CA 装进信任库即可。
Certificate 与轮转
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: app-tls
namespace: demo
spec:
secretName: app-tls
duration: 2160h # 证书有效期 90 天
renewBefore: 360h # 到期前 15 天自动续期
privateKey:
rotationPolicy: Always # 每次续期重新生成私钥
dnsNames:
- app.example.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuerrenewBefore 要留出足够余量:如果它比「你发现问题到修好所需的时间」还短,一次续期失败就会直接变成线上证书过期。
轮转能自动完成,但应用不一定用得上:证书写进 Secret 后,kubelet 会把以卷挂载的 Secret 更新进 Pod(有延迟,取决于同步周期),应用必须自己重读文件并 reload;以环境变量注入的证书永远不会更新。所以证书要用卷挂载,并给应用加一个「文件变了就重载」的机制。
kubectl -n demo get certificate
kubectl -n demo describe certificate app-tls # Events 里能看到签发与续期记录
kubectl -n demo get secret app-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates
kubectl get certificaterequests,challenges -A # 卡住时看这两类对象的失败原因READY=True、openssl 能打出有效期、describe 的 Events 里没有报错,三者都对才算成功。
与 Ingress、网格 mTLS 的配合
- Ingress:在
metadata.annotations里写cert-manager.io/cluster-issuer: letsencrypt-prod,并让spec.tls[].secretName与Certificate的secretName一致(这里是app-tls),cert-manager 会自动创建并续期这个 Secret,Controller 直接拿它做 HTTPS。写法见 第 8 章 Ingress。 - 服务网格:网格的 mTLS 用的是
istiod自己签发的工作负载证书(SPIFFE 身份),与 cert-manager 无关,别指望用它给网格发证书。cert-manager 管的是边缘服务器证书——用户浏览器到入口网关这一段。入口用 Istio 的Gateway时,把Certificate生成的 Secret 挂到tls.credentialName(Gateway API 则是certificateRefs),见 服务网格。 - 轮转告警:网关和网格会自己重载读到的证书,业务应用读挂载文件时要自己 reload;证书临近过期的告警规则见 可观测性。
镜像供应链:可信来源与准入校验
镜像一旦被打上「可信」的标签,它就拥有了你在集群里给它的全部权限。所以供应链要回答四个问题:从哪拉、内容是什么、有没有被改过、允不允许进来。
- 从哪拉:统一走内网仓库,节点只信任这一个来源,具体搭建见 镜像仓库与加速。
- 内容是什么:用 Trivy 或 Grype 扫描已知漏洞,用 Syft 生成 SBOM 存档。
- 有没有被改过:给镜像签名(cosign),部署时校验签名,避免「同一个 tag 被推了别的代码」。
- 允不允许进来:用准入控制器(Kyverno、OPA Gatekeeper)在 Pod 创建时校验镜像来源和签名。
固定 digest 比固定 tag 更可靠:tag 是可变的,1.2.3 可以被重新推送;digest 是内容的哈希,不可变。
docker buildx imagetools inspect nginx:1.27 | grep -i '^Digest'
trivy image --severity HIGH,CRITICAL harbor.example.com/library/app:1.2.3
cosign sign --key cosign.key harbor.example.com/library/app@sha256:0000000000000000000000000000000000000000000000000000000000000000拿到 digest 之后,把清单里的镜像改成 image: harbor.example.com/library/app@sha256:...。准入校验则交给 Kyverno 或 OPA Gatekeeper:策略的核心是一条 validate 规则——spec.containers[].image 必须匹配 harbor.example.com/*,不匹配就在创建阶段拒绝,必要时再加上「签名校验通过」这一条。
动手练习:把命名空间调成 restricted
这一节我们亲手制造一次「Pod 被拦下来」,再写出合规版本。前提是集群版本不低于 1.25(PSA 已稳定)。
动手练习:从 warn 到 enforce
第一步,建命名空间,先只开「警告」和「审计」,不拦截:
kubectl create namespace secure-demo
kubectl label namespace secure-demo \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted第二步,应用一份没写 securityContext 的 Pod,观察警告:
kubectl -n secure-demo apply -f loose-pod.yaml
kubectl -n secure-demo get pod loose-podapply 会成功,但输出里会带一串 Warning: would violate PodSecurity "restricted:latest",逐条告诉你缺了 allowPrivilegeEscalation=false、capabilities.drop、runAsNonRoot、seccompProfile。Pod 本身是 Running 的,因为这一档还没执行拦截。
第三步,打开执行档,再试一次:
kubectl label namespace secure-demo \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest --overwrite
kubectl -n secure-demo delete pod loose-pod
kubectl -n secure-demo apply -f loose-pod.yaml这次会被服务端拒绝:Error from server (Forbidden): ... violates PodSecurity "restricted:latest"。注意拦截发生在准入阶段,Pod 根本不会创建,所以你在 kubectl get pod 里看不到任何记录。
第四步,换成加固版清单:
kubectl -n secure-demo apply -f hardened-pod.yaml
kubectl -n secure-demo get pod hardened-pod
kubectl -n secure-demo exec hardened-pod -- id
kubectl -n secure-demo exec hardened-pod -- touch /tmp/ok.txt
kubectl -n secure-demo exec hardened-pod -- touch /root-test.txtid 应该显示 uid=101;写 /tmp 成功;写根目录失败并报 Read-only file system。这三个结果分别证明了非 root、可写临时目录、只读根文件系统都生效了。清理用 kubectl delete namespace secure-demo。
迁移存量工作负载的顺序
先给命名空间只打 warn 和 audit,收集一段时间里所有警告,逐个改工作负载;改完再上 enforce。同时用 enforce-version 固定策略版本,避免升级集群后规则悄悄变化导致发布失败。
常见坑与速查表
安全加固里最常踩的三个坑
- restricted 档把存量 Pod 全拦了:
enforce一打开,所有没写 securityContext 的 Deployment 都发不出去。别在生产命名空间直接上enforce,先 warn 后 enforce。 - readOnlyRootFilesystem 让应用起不来:应用要写临时文件、日志、pid 文件时,只读根文件系统会导致启动失败,报错通常是
Read-only file system或Permission denied。给每个需要写的位置挂一个emptyDir,而不是关掉这个开关。 - 证书过期或 Secret 被删,Ingress 报错:
Certificate续期失败、tls.secretName指向的 Secret 不存在时,用户侧会看到证书错误;某些 Ingress 控制器在拿不到 TLS Secret 时会直接返回 502/503。用kubectl -n <ns> get certificate和openssl x509 -noout -dates定期核对。
| 现象 | 原因 | 怎么确认 |
|---|---|---|
创建 Pod 报 violates PodSecurity | 命名空间开了 enforce,Pod 不满足对应档位 | 看报错里列出的字段,逐条补齐 securityContext |
Pod 启动后立刻 CrashLoopBackOff,日志报只读文件系统 | readOnlyRootFilesystem: true 但应用要写盘 | kubectl logs --previous 看路径,给该路径挂 emptyDir |
非 root 运行后报 bind: permission denied | 端口小于 1024 需要特权 | 让应用监听 8080 之类的高端口,Service 负责映射 |
| Ingress 浏览器报证书不受信任 | 证书未签发成功或引用了错误的 Secret | kubectl -n <ns> describe certificate,确认 READY=True |
| 更新 Secret 后应用仍用旧值 | Secret 以环境变量注入,不会热更新 | 检查 Deployment 里是 envFrom 还是卷挂载;env 需重启 Pod |
| 镜像 tag 没变但内容变了 | tag 可变,有人覆盖推送 | 用 image@sha256:... 固定 digest,并开启签名校验 |
自测题
自测:为什么说 PSA 取代了 PSP,两者的区别到底在哪?(点击展开答案)
PSP 是「按用户授权」的模型:它是一类资源,需要靠 RBAC 把「谁能用哪个 PSP」绑到 ServiceAccount 上,规则和权限耦合在一起,很难推理,也容易配置出漏洞,所以在 1.25 被移除。
PSA 把复杂度换成了「按命名空间分档」:档位是内置的三档(privileged、baseline、restricted),你用命名空间标签声明,准入控制器统一校验。它不再需要给每个用户配授权,代价是灵活性下降——需要细粒度的自定义规则时,要交给 Kyverno、Gatekeeper 这类策略引擎。
自测:`runAsNonRoot: true` 加 `runAsUser: 101`,两个都写有必要吗?(点击展开答案)
有必要,它们管的事情不同。runAsNonRoot: true 是一条约束:kubelet 在启动容器前会检查镜像里配置的用户,如果是 root 就直接拒绝启动并报 container has runAsNonRoot and image will run as root。runAsUser: 101 是指定运行用户。
只写 runAsNonRoot 时,运行用户取决于镜像里的 USER 指令,镜像一变行为就变;只写 runAsUser 时,Kubernetes 不会帮你检查这个值是不是 0。两个一起写,既明确了身份,又保证不会被一个写错的镜像绕过去。
自测:为什么固定镜像 digest 比固定 tag 更安全?(点击展开答案)
tag 只是一个可变的标签,同一个 app:1.2.3 今天和明天指向的可能是完全不同的内容,任何人都能覆盖推送;digest 是镜像内容的 SHA256 哈希,内容变一个字节,digest 就变。
所以「固定 tag」防的是无意中升级,「固定 digest」防的是有意的替换。生产清单里用 image@sha256:...,配合准入策略校验签名,才能保证「跑起来的东西」和「审过的东西」是同一个。
自测:为什么不让 cert-manager 给服务网格签发证书?(点击展开答案)
因为两者解决的不是同一个问题。cert-manager 面向服务器证书:客户端(浏览器、外部系统)要验证「我连的这个服务是不是真的」,所以证书必须由客户端信任的 CA 签发,还要能被公开吊销。网格的 mTLS 是工作负载身份:istiod 给每个 ServiceAccount 签一张短生命周期证书,双方互相验证「你是不是那个命名空间里的那个 SA」,证书不需要外部 CA 信任,也由 istiod 自己高频轮转。所以入口网关的证书交给 cert-manager,服务之间的加密交给网格,两者各管一段,混用只会让信任链更难推理。
小结
- Kubernetes 安全分四层:集群、节点、工作负载、供应链,任何一层破口都可能让整条链路失守。
- Pod Security Admission 用命名空间标签把工作负载分成 privileged、baseline、restricted 三档,已取代 PSP;落地顺序是先 warn、再 audit、最后 enforce。
securityContext的核心是:非 root 运行、禁止提权、丢弃全部 capabilities、只读根文件系统、设置 seccomp。- 最小权限贯穿三处:RBAC 给足必要动词、网络默认拒绝再放通、Secret 走外部密钥管理并定期轮转。
- cert-manager 用
Issuer加Certificate把签发与续期自动化;镜像供应链靠可信仓库、固定 digest、扫描、签名与准入校验闭环。
练习
- 给一个用
nginx:1.27的 Deployment 改造成restricted档可用的版本:列出你改了哪几个字段、为什么要改,以及需要挂几个 emptyDir。 - 在
secure-demo命名空间里把enforce-version从latest改成v1.30,观察报错信息有没有变化,说说为什么生产上要固定这个值。 - 用
docker buildx imagetools inspect取一个你常用镜像的 digest,把它写进 Pod 清单,再用kubectl describe pod确认Image ID与 digest 一致。
安全加固解决的是「别被攻破」,接下来看「别被改乱」:用 GitOps 把集群状态交给 Git 管理。