Kubernetes 镜像内容最小化落地:以 sha256 摘要固定镜像并验证准入拦截

枫少@KillBoy
枫少@KillBoy
枫少@KillBoy
管理员
366
文章
0
粉丝
云原生安全1 77阅读模式
AI智能摘要
集群里泛滥的可变标签与包含完整工具链的基础镜像,往往让版本审计难以追溯,且极大放大了攻击面。若要彻底斩断供应链隐患,仅仅把镜像做小还远远不够。如何在多阶段构建中收敛运行时环境,并将镜像引用精准锁定到 sha256 摘要以实现准入拦截?两者的有效结合才能构筑可靠的防线。
— AI 生成的文章内容摘要

镜像治理在 Kubernetes 集群里经常被当成两件事:一件是“把镜像做小”,另一件是“把标签换成摘要”。但在实际攻防视角下,它们消除的是两类完全不同的风险,而且只有串起来才能被验证。典型场景是:集群里跑着几十个 Deployment,镜像引用写成 app:1.4.2 这类可变标签,镜像本身是带完整包管理器和 /bin/sh 的通用基础镜像。一旦镜像仓库被写入同名标签,或者容器内被拿到一个 shell,前者让“部署的是什么”变得不可证,后者让“能做什么”变得不可控。本文以 Kubernetes 官方安全清单中的镜像条目为对照基线,围绕内容最小化与引用固定两条线,给出评估、改造、准入拦截验证与回滚的完整链路;镜像签名体系搭建和运行时逃逸检测不在讨论范围内。

臃肿镜像收敛为最小镜像的对比示意图

先把两类风险拆开

把“镜像太大”和“标签可变”混在一起谈,会导致治理动作也混在一起:有人以为瘦身就能防篡改,有人以为固定摘要就不需要关心镜像内容。实际上两者的作用面完全不同,需要分别度量、分别验收。

风险面具体表现主要后果治理手段生效层
镜像内容冗余基础镜像自带 shell、包管理器、编译工具链、curl/wget拿到执行入口后横向移动成本低;漏洞面和 CVE 数量被放大多阶段构建、distroless 或 scratch 基础镜像、非 root 运行构建流水线
引用可变使用 :latest 或可被覆盖的业务标签同名标签被重推后,集群实际运行的字节与审计记录不一致;回滚不可复现改用 image@sha256:... 引用清单与准入控制

判断适用条件:只要工作负载使用可变标签、或镜像内保留交互式 shell,就值得做这次改造。如果集群规模很小、发布频率极低,可以先只做摘要固定,再逐步收敛镜像内容;两件事的推进节奏可以不同,但验收标准必须分开写清楚。

第一步:评估存量

改造前先量化基线,否则无法证明效果。需要两个数字:使用可变标签的工作负载比例,以及镜像中是否含有 shell 与包管理器。

盘点清单中的镜像引用:

kubectl get deploy,statefulset,daemonset -A -o json 
| jq -r '.items[] | .metadata.namespace + "/" + .metadata.name + " " + (.spec.template.spec.containers[].image)' 
| awk '{print} /@sha256:/{d++; next} {t++} END {printf "带摘要: %d  仅标签: %dn", d+0, t+0}'

注意排查范围不止 containers,initContainers、ephemeralContainers 以及 CronJob 的 jobTemplate 同样是镜像引用入口,只统计主容器会低估风险。

获取镜像摘要不要依赖运行时字段。kubectl get pod -o jsonpath='{.status.containerStatuses[0].imageID}' 返回的是镜像配置的 ID,与清单中用于拉取的 manifest 摘要不是同一个值。可复现的做法是直接查询仓库:

crane digest registry.example.com/app:1.4.2
# 或
skopeo inspect --format '{{.Digest}}' docker://registry.example.com/app:1.4.2

多架构镜像需要注意:镜像索引(manifest list)的摘要与单平台 manifest 的摘要不同。固定索引摘要可以在 amd64/arm64 混合节点上正常拉取;固定成某个平台 manifest 的摘要,换架构就会 ImagePullBackOff。团队内应统一约定固定哪一层,并写进发布规范。

检查镜像内容是否包含 shell,可以本地导出文件系统看一眼:

crane export registry.example.com/app:1.4.2 - | tar -tf - | grep -E '^bin/(sh|bash|ash)$|/(apt|apk|yum)$'

有输出说明镜像保留了交互式入口,属于需要收敛的对象。另外可以对照 Kubernetes 官方文档中关于容器镜像与摘要格式的说明,确认引用写法是否合法。

第二步:收敛镜像内容

内容收敛的目标不是“越小越好”,而是“运行时不需要的东西不要留在镜像里”。改造通常在构建流水线完成,不需要改动集群配置,因此影响面可控。

一个多阶段构建的骨架:

FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

几个必须同步处理的连带影响:

  • 编译产物需要静态链接,否则去掉 libc 后启动即失败。
  • 镜像内没有 shell,健康检查只能用二进制自身实现(应用内暴露 health 端点,或用镜像里的专用探针程序),不能写 sh -c "curl ..."。
  • 进程需要自己处理信号,ENTRYPOINT 用 exec 形式而非 shell 形式,避免 PID 1 不转发 SIGTERM 导致优雅退出超时。
  • 时区、CA 证书、临时目录等隐式依赖要显式补进最终镜像,否则上线后才在调用外部 HTTPS 接口时报错。

配合的 Pod 级加固建议一并启用:runAsNonRoot: true、readOnlyRootFilesystem: true、allowPrivilegeEscalation: false、丢弃全部 capability 并按需加回。这些属于内容治理的延伸,与镜像瘦身同向,但不要把它们当作运行时逃逸检测的替代品。

第三步:把引用固定到 sha256 摘要

摘要固定解决的是“部署内容可证”的问题。Kubernetes 文档明确说明,当引用同时包含标签和摘要时,拉取只使用摘要;因此可以写成 app:1.4.2@sha256:...,既保留人类可读的版本信息,又保证不可变。这一点在官方镜像文档中有直接描述,适合写进团队规范。

改造方式有两种,按团队成熟度选择:

  1. 清单里直接写死摘要:可读性差、变更需要改文件,但最直观,适合先在小范围试点。
  2. 在 CI 中自动解析并回填摘要(如 crane digest 或 GKE 文档介绍的 digester 方案,它可作为变更型准入 webhook 或 kpt/kustomize 客户端函数运行):发布流程无需人工介入,推荐作为长期方案。

需要接受的运维代价:镜像更新不再“重推标签即生效”,必须产生一次清单变更。这既是安全性来源,也是发布节奏的约束,需要提前和研发对齐。回滚同样要走清单变更,因此保留原标签引用作为回滚素材是必要的。

第四步:用准入控制兜住漏网引用

清单改造只能覆盖已知工作负载,新提交的 YAML 仍可能写回标签。准入控制的作用是把规范变成强制。Kubernetes 内置的准入机制中,ImagePolicyWebhook 通过 ImageReview 对象评估镜像;实践中更常见的是 Kyverno 或 OPA/Gatekeeper 这类以 validating admission webhook 形式部署的策略引擎。无论选哪种,策略内容都是“镜像引用必须匹配 @sha256: 摘要格式”。

准入控制对摘要引用放行、对标签引用拦截的流程示意

以 Kyverno 为例,先以审计模式观察一段时间再切换为拒绝:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-digest
spec:
  validationFailureAction: Audit   # 观察期用 Audit,确认误报后改为 Enforce
  background: false
  rules:
    - name: pod-require-digest
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "镜像引用必须包含 @sha256: 摘要,不接受可变标签。"
        pattern:
          spec:
            containers:
              - image: "*@sha256:*"
            =(initContainers):
              - image: "*@sha256:*"

这里有三个容易踩的坑:

  • 只匹配 Pod 时,kubectl apply 一个 Deployment 仍会返回成功,实际拦截发生在 ReplicaSet 控制器创建 Pod 阶段,表现为 ReplicaSet 事件里的拒绝信息。排查时容易误判为策略没生效。若希望 apply 阶段就失败,需要额外为 Deployment、StatefulSet、DaemonSet、Job、CronJob 增加匹配 spec.template.spec.containers 的规则。
  • 遗漏 ephemeralContainers。kubectl debug 注入的临时容器同样走镜像拉取,如果策略不覆盖,就等于留了一条绕过路径,需要显式评估是否放行并限定镜像。
  • failurePolicy 的选择。设为 Fail 时,策略引擎不可用会导致符合条件的资源创建全部失败;需要评估 webhook 副本数、超时时间与命名空间排除范围。把 kube-system 整体排除会留下缺口,更稳妥的做法是按具体组件和标签精确排除,并记录豁免原因与有效期。

验证拦截是否真的生效

策略写完不等于生效。验证要覆盖“应拒绝”和“应放行”两个方向,并且尽量用 --dry-run=server 走完整准入链而不真正创建资源。

验证旧标签被拒绝:

kubectl -n demo run probe --image=registry.example.com/app:1.4.2 
  --restart=Never --dry-run=server -o yaml
# 预期输出包含: admission webhook "validate.kyverno.svc-fail" denied the request:
# 镜像引用必须包含 @sha256: 摘要,不接受可变标签。

再验证控制器路径,确认 Deployment 层面是否也被拦住(对应上文的匹配范围问题):

kubectl -n demo apply -f legacy-tag-deploy.yaml
kubectl -n demo describe rs -l app=web | tail -20   # 若 apply 成功,拒绝信息出现在这里

验证摘要引用可正常部署:

kubectl -n demo apply -f digest-deploy.yaml
kubectl -n demo rollout status deploy/web --timeout=120s
kubectl -n demo get pod -o jsonpath='{.items[0].spec.containers[0].image}'

最后一步很关键:确认集群里实际记录的引用是带摘要的形式,而不是被某个变更型 webhook 又改写回标签。若同时启用了自动回填摘要的 webhook,要确认两者顺序与结果一致,避免“策略拦截标签、webhook 改写标签”互相打架导致行为不可预测。

策略审计结果可以从 PolicyReport 读取,用来发现存量遗漏:

kubectl get policyreport -A -o wide

调试需求与回滚路径

去掉 shell 之后最直接的反对意见是“出问题没法进容器看”。这是真实成本,需要给出替代方案而不是让团队自行其是。

首选临时容器,它注入到目标 Pod 的命名空间中,能共享网络与进程视图,但不进入原容器文件系统:

kubectl -n demo debug -it pod/web-7d9c8f6b5-x2v4k 
  --image=registry.example.com/debug-tools@sha256:<digest> 
  --target=app

前提是集群版本支持临时容器(1.23 起稳定),并且该调试镜像本身通过准入策略——所以调试镜像也必须固定摘要,并纳入受控清单,否则会成为绕过策略的口子。临时容器不能用于静态 Pod,节点级排查需要用 kubectl debug node/... 另走一条路径。

拉取失败是最常见的改造副作用,定位顺序建议固定下来:

  1. kubectl describe pod 看事件是 ImagePullBackOff 还是 ErrImagePull,区分“摘要不存在”与“认证失败”。
  2. 用 crane manifest registry.example.com/app@sha256:<digest> 直接向仓库确认该摘要是否存在、是否是索引摘要。
  3. 检查 imagePullSecrets 与仓库权限,摘要固定不会改变认证方式,但更换仓库或命名空间时容易漏配。
  4. 检查节点架构与摘要层级是否匹配;若使用镜像缓存或同步代理,确认代理侧已同步该摘要而非仅同步标签。

回滚方案按影响面从小到大排列:

  • 单个工作负载回滚:kubectl rollout undo,但这只回滚到上一版 ReplicaSet,若上一版同样被策略拦截则无效。真正可靠的是把清单引用改回原来的标签并重新发布,因此改造前必须把原引用完整记录在版本库中。
  • 策略侧临时豁免:给特定命名空间打标签,并在策略中通过 namespaceSelector 排除,或对特定工作负载使用例外规则。豁免必须限时、限范围、留记录,并在到期后复核;长期豁免等于没有策略。
  • 兜底路径:保留策略的 Audit 模式开关,紧急情况下先降级为审计以恢复发布能力,同时记录降级窗口内创建的负载,事后补齐摘要引用。这条路径要写进应急预案,而不是临时决定。

复盘:需要长期观察的指标

改造完成后,把以下几项纳入日常观察,才能判断治理是否持续有效:

  • 策略拒绝次数与拒绝原因分布,突增通常意味着有人沿用了旧模板或 CI 镜像未更新。
  • ImagePullBackOff 事件数量,尤其是改造后一周内的变化,用于发现摘要层级或仓库同步问题。
  • 准入 webhook 的延迟与错误率,以及策略引擎自身的可用副本数;这是 failurePolicy: Fail 下的单点风险。
  • 镜像体积与镜像内是否重新出现 shell 的漂移检测,可在流水线中加一条断言,防止基础镜像被悄悄换回通用镜像。
  • 摘要引用的覆盖率,按命名空间和工作负载类型统计,避免新业务重新退回标签。

这条链路的价值在于可验证:内容最小化把“拿到执行入口之后能做什么”压缩到最小,摘要固定把“部署的到底是什么”变成可复核的事实,准入控制则保证这两条约束不会因为一次匆忙的提交而失效。三者缺一,治理就会退化成一份写完就归档的检查清单。

 
枫少@KillBoy
    • 斜阳巷口
      斜阳巷口 1

      多架构镜像固定摘要确实容易踩坑,多谢提醒

    匿名

    发表评论

    匿名网友

    拖动滑块以完成验证