课程目录(第 22 章 / 共 33 章)
课程/进阶:生产实践

安全加固:从最小权限到镜像供应链

22 章 / 共 33·22 分钟·进阶安全PodSecuritysecurityContext供应链

把「跑起来了」变成「跑得安全」:用 Pod Security Admission 和 securityContext 收紧工作负载,用证书与镜像供应链守住入口。

学完这一章,你将能够

  • 说得出 Kubernetes 安全的四个层次各自管什么
  • 会用命名空间标签把 Pod Security Admission 调到 restricted 并读懂拦截报错
  • 写出一份能被 restricted 档接受的 securityContext
  • 用 cert-manager 自动签发证书,并知道镜像固定 digest 的意义

安全的四层:从集群到镜像

前面二十章里,你关心的主要是「应用能不能跑起来」。到了生产环境,问题会变成「它会不会被人从外面攻破,或者把集群里的东西搞坏」。这两件事的难度完全不在一个量级。

安全不是一个开关,而是四层各自要守的东西。任何一层破口都可能导致整条链路失守:镜像里有后门,再严格的 RBAC 也拦不住;Pod 以 root 跑并且能读宿主机文件,网络策略再细也白搭。

text
供应链层 ─ 可信仓库 → 扫描 → 签名 → 准入校验


工作负载层 ─ 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固定校验用的策略版本,如 latestv1.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,它没有任何安全字段:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: loose-pod
spec:
  containers:
    - name: web
      image: nginx:1.27

它没有写任何安全字段,于是以 root 运行、拥有默认能力、根文件系统可写,在 restricted 档下会被直接拒绝。把下面这份加固版保存成 hardened-pod.yaml

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 要一起装:

bash
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 端口的内网集群。

yaml
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 与轮转

yaml
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: ClusterIssuer

renewBefore 要留出足够余量:如果它比「你发现问题到修好所需的时间」还短,一次续期失败就会直接变成线上证书过期。

轮转能自动完成,但应用不一定用得上:证书写进 Secret 后,kubelet 会把以卷挂载的 Secret 更新进 Pod(有延迟,取决于同步周期),应用必须自己重读文件并 reload;以环境变量注入的证书永远不会更新。所以证书要用卷挂载,并给应用加一个「文件变了就重载」的机制。

bash
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=Trueopenssl 能打出有效期、describe 的 Events 里没有报错,三者都对才算成功。

与 Ingress、网格 mTLS 的配合

  • Ingress:在 metadata.annotations 里写 cert-manager.io/cluster-issuer: letsencrypt-prod,并让 spec.tls[].secretNameCertificatesecretName 一致(这里是 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 是内容的哈希,不可变。

bash
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

第一步,建命名空间,先只开「警告」和「审计」,不拦截:

bash
kubectl create namespace secure-demo
kubectl label namespace secure-demo \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

第二步,应用一份没写 securityContext 的 Pod,观察警告:

bash
kubectl -n secure-demo apply -f loose-pod.yaml
kubectl -n secure-demo get pod loose-pod

apply 会成功,但输出里会带一串 Warning: would violate PodSecurity "restricted:latest",逐条告诉你缺了 allowPrivilegeEscalation=falsecapabilities.droprunAsNonRootseccompProfile。Pod 本身是 Running 的,因为这一档还没执行拦截。

第三步,打开执行档,再试一次:

bash
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 里看不到任何记录。

第四步,换成加固版清单:

bash
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.txt

id 应该显示 uid=101;写 /tmp 成功;写根目录失败并报 Read-only file system。这三个结果分别证明了非 root、可写临时目录、只读根文件系统都生效了。清理用 kubectl delete namespace secure-demo

迁移存量工作负载的顺序

先给命名空间只打 warnaudit,收集一段时间里所有警告,逐个改工作负载;改完再上 enforce。同时用 enforce-version 固定策略版本,避免升级集群后规则悄悄变化导致发布失败。

常见坑与速查表

安全加固里最常踩的三个坑

  • restricted 档把存量 Pod 全拦了enforce 一打开,所有没写 securityContext 的 Deployment 都发不出去。别在生产命名空间直接上 enforce,先 warn 后 enforce。
  • readOnlyRootFilesystem 让应用起不来:应用要写临时文件、日志、pid 文件时,只读根文件系统会导致启动失败,报错通常是 Read-only file systemPermission denied。给每个需要写的位置挂一个 emptyDir,而不是关掉这个开关。
  • 证书过期或 Secret 被删,Ingress 报错Certificate 续期失败、tls.secretName 指向的 Secret 不存在时,用户侧会看到证书错误;某些 Ingress 控制器在拿不到 TLS Secret 时会直接返回 502/503。用 kubectl -n <ns> get certificateopenssl 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 浏览器报证书不受信任证书未签发成功或引用了错误的 Secretkubectl -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 rootrunAsUser: 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 用 IssuerCertificate 把签发与续期自动化;镜像供应链靠可信仓库、固定 digest、扫描、签名与准入校验闭环。

练习

  1. 给一个用 nginx:1.27 的 Deployment 改造成 restricted 档可用的版本:列出你改了哪几个字段、为什么要改,以及需要挂几个 emptyDir。
  2. secure-demo 命名空间里把 enforce-versionlatest 改成 v1.30,观察报错信息有没有变化,说说为什么生产上要固定这个值。
  3. docker buildx imagetools inspect 取一个你常用镜像的 digest,把它写进 Pod 清单,再用 kubectl describe pod 确认 Image ID 与 digest 一致。

安全加固解决的是「别被攻破」,接下来看「别被改乱」:用 GitOps 把集群状态交给 Git 管理。