容器安全落地指南:从镜像扫描到K8s策略与CI门禁
简介这是一份由个人翻译的 NIST SP 800-190《应用容器安全指南》中文版面向系统和安全管理员、安全程序管理员、信息系统安全员及应用程序开发人员也适合对容器安全感兴趣的运维与架构师。文档以操作系统虚拟化与应用程序打包为背景系统梳理容器技术架构与组件深入剖析容器逃逸、镜像安全、网络隔离等风险并围绕组织流程、专用主机系统、容器分组隔离、镜像漏洞管理和硬件可信根等维度给出可落地的安全建议。资源为单个PDF文件体积1.37MB内容包含摘要、读者对象、术语与图表说明以及完整推荐细则结构清晰便于通读与检索。已有519人学习下载适合正在规划容器平台、开展安全基线加固或进行合规检查的技术团队也可作为理解NIST容器安全框架的入门和案头材料。1. 容器安全指南.pdf一份安全手册如何变成真正的防线拿到《容器安全指南.pdf》很多人直接翻到漏洞列表那一页看完 CVE 编号就合上觉得“已经知道了”。可实际上漏洞清单只是整份指南里最不重要的部分。真正让运维和开发睡不着觉的是镜像里藏着的后门、运行时权限过大、K8s 集群里策略形同虚设这三大问题。这份指南的价值在于它把“安全”从发布后的补救提前到了构建前和运行时。它适合正在做 DevSecOps、要把容器安全基线落进 CI 和集群的从业者也适合那些已经被扫描器报告淹没、想搞清楚下一步动手方向的团队。下面我就按做过的方案把这套指南拆成可复现的步骤、参数和坑。2. 镜像安全容器安全的第一道闸门镜像安全是所有容器安全的基础。镜像里有什么运行时就会跑什么。很多人以为只要用官方镜像就安全但官方镜像也有版本策略和漏洞生命周期问题。容器安全指南一般都会把镜像安全放在最前面因为它是成本最低、回报最高的拦截点。2.1 镜像漏洞扫描先选对工具而不是死磕一份清单扫描工具很多常见的有 Trivy、Grype、Clair、Anchore Engine。我一般推荐 Trivy 或 Grype原因就三个更新快、依赖文件解析全、能直接对接 CI。Clair 适合自己搭漏洞数据库同步服务但运维成本高不适合多数团队。Grype 和 Syft 配合能生成 SBOM适合供应链溯源。选型时别被“扫描器数量”迷惑关键是看它能不能识别你用的包管理器。最常用的命令是trivy image --severity CRITICAL,HIGH --ignore-unfixed myapp:v1.0参数说明--severity CRITICAL,HIGH表示只看高危和严重漏洞忽略中低危先解决那些能被利用的问题。--ignore-unfixed过滤掉还没有上游修复方案的漏洞。这个参数非常重要因为很多漏洞即便报了也没法修强刷基础镜像版本反而可能引入兼容性问题。如果想在 CI 里让它决定构建是否继续加--exit-code 1。镜像安全的第一步不是“修掉所有漏洞”而是“把可修复的高危漏洞修掉”。扫描结果出来以后先看哪些漏洞属于基础镜像层。比如基于ubuntu:22.04的镜像和基于alpine:3.20的镜像同样一个应用漏洞数量可能差一个数量级。如果基础镜像带来的漏洞占大头换基础镜像比改代码更划算。2.2 最小化基础镜像从 ubuntu 到 distroless 的取舍缩小攻击面最直接的办法是换小镜像。但“小”不等于“安全”关键在于去掉那些运行不需要的组件。一个典型的 Java 应用如果只用 JRE就不要装 JDK一个 Go 静态编译二进制连 shell 都可以不要。我最常用的是 distroless 镜像比如FROM gcr.io/distroless/static-debian12:nonroot COPY myapp /myapp USER 10001 ENTRYPOINT [/myapp]逻辑说明distroless镜像里没有包管理器没有 shell没有/bin/bash攻击者即使拿到 RCE 也无法在容器里执行系统命令更没法用apt装工具。指定USER 10001避免以 root 运行。distroless 的nonroot标签已经预设了非 root 用户这里再显式写一次是为了让镜像安全检查器比如docker scan能明确识别。ENTRYPOINT直接启动可执行文件不需要 shell 作为 PID 1。替换过程中会踩一个坑应用需要读取系统证书或者时区文件。distroless 镜像里没有/etc/ssl/certs/ca-certificates.crt。常见做法是在多阶段构建里把证书文件复制进来COPY --frombuild /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/还有一个老话题为什么不用 alpineAlpine 的musl libc和多数编译器默认的glibc有差异很多预编译二进制在 alpine 上会报not found或者运行异常。软件如果自己编译可以试 alpine如果使用第三方预编译包建议优先考虑distroless或debian:stable-slim来规避兼容问题。镜像是镜像安全的一部分但别为了“小”牺牲“能跑”。2.3 镜像签名与供应链用 cosign 给镜像上锁镜像扫描只能发现已知漏洞防不住“镜像被篡改”或者“推了个恶意镜像上来”。镜像签名是近几年容器安全指南里必提的内容。常见的做法是用 cosign 对镜像做签名和校验。签名需要一对密钥。私钥放在 CI 的 secret 里公钥发布到集群或者基础设施仓库。签名命令大致是cosign sign --key cosign.key myregistry/myapp:v1.0 cosign verify --key cosign.pub myregistry/myapp:v1.0参数说明--key指定密钥文件也可以用环境变量COSIGN_PASSWORD解锁私钥避免交互输入。签名对象是镜像的 digest而不是 tag。因为 tag 可变动digest 是内容和签名绑定的。验证命令写在部署前的环节如果镜像没有对应的签名就直接中止发布。签名不能替代扫描只能证明“这个镜像是谁构建的并且没有在传输途中被替换”。对于生产环境我会强制要求镜像必须是签名版本否则 K8s 不拉取。用 Gatekeeper 可以做到这一点后面第 5 章会讲。镜像安全做到扫描、减面、签名这三件事基本就挡住了 80% 的从镜像入侵的路径。剩下的是运行时的问题。3. 运行时容器安全让容器“不能做多余的事”镜像扫描过后关运行时的安全就要靠配置来约束。很多翻车现场是这样的镜像没漏洞但容器里以 root 跑着网络权限能嗅探宿主机流量甚至通过 mount 挂载宿主机目录。运行时安全的核心思路只有一个——最小权限默认拒绝。下面三个小节是我认为最值得落的配置。3.1 capabilities 裁剪不要一上来就 --privilegedLinux capabilities 机制把 root 权限拆成了几十个小权限比如CHOWN可以改文件属主NET_BIND_SERVICE可以绑定低端口NET_ADMIN可以配置网络。Docker 默认给容器一个 root 用户但会限制掉一部分 capabilities。问题是——很多应用根本不需要那些默认权限。最稳妥的做法是先全部丢弃再按需添加docker run --cap-dropALL --cap-addNET_BIND_SERVICE --cap-addCHOWN myapp:v1.0参数说明--cap-dropALL丢弃所有 capabilities容器内用户即使在 UID 0 状态下也不具备任何特权操作能力。--cap-addNET_BIND_SERVICE添加绑定低端口的能力。如果应用要监听 80 或 443需要这个。CHOWN是解压文件或写入挂载卷时需要改属主的时候用的。没有确实需要就不要加。在 Kubernetes 里对应的是securityContext.capabilitiessecurityContext: capabilities: drop: [ALL] add: [NET_BIND_SERVICE]注意如果容器以 root 用户启动即便 drop 了所有 capabilities它仍然是 UID 0可以读取文件系统里 root 才能看的文件。所以还要搭配非 root 用户运行两个配置不能互相替代。我见过最头疼的问题是开发者在本地跑--privileged才能正常工作直接照搬进生产。--privileged等于给了容器近乎宿主机 root 的全部权限包括加载内核模块、操作挂载、访问设备。很多逃逸漏洞都是在这个状态下触发的。遇到“必须要 privilege”的需求先分析到底缺哪个权限再用--cap-add精准补上。比如要改路由表只需要NET_ADMIN不需要全部特权。3.2 seccomp在系统调用层面挡住危险操作capabilities 管的是“权限子集”seccomp 管的是“能不能调用这个系统调用”。两者是不同维度的防线。容器逃逸的经典路径是调用unshare、mount、ptrace这类系统调用去突破命名空间隔离。seccomp 可以在内核触发这些调用之前直接拒绝。Docker 自带一个默认的 seccomp 配置文件已经屏蔽了一部分危险调用。但自定义配置可以让策略更贴近你的应用。下面是一个最小可用的自定义 seccomp 配置{ defaultAction: SCMP_ACT_ERRNO, architectures: [SCMP_ARCH_X86_64], syscalls: [ { names: [read, write, openat, close, fstat, mmap, munmap, exit_group], action: SCMP_ACT_ALLOW } ] }使用方式docker run --security-opt seccomp./custom-seccomp.json myapp:v1.0逻辑说明defaultAction是SCMP_ACT_ERRNO表示“不在白名单里的系统调用一律返回错误”这是默认拒绝的模型。白名单里的系统调用基本都是运行一个普通 Go 或 Java 程序所必需的最低集。生产环境不要直接照抄这份需要先用strace -f跑一遍应用把实际出现的系统调用收集起来再加进白名单。K8s 中可以通过securityContext.seccompProfile指定。不过多数托管集群默认开启的是 RuntimeDefault建议先从默认配置开始再逐步收紧。seccomp 是运行时安全里最容易被忽略也最有效的一层。它和 capabilities 的关系可以类比为capabilities 决定你是不是交警seccomp 决定你是否允许在马路上打手势。一层挡住“越权”一层挡住“滥用接口”。3.3 只读根文件系统与临时目录让容器拒绝被写入很多攻击手法是通过向容器内写入恶意脚本或替换二进制来持久化的。如果容器的根文件系统是只读的攻击者连落地的机会都没有。常见的落地命令docker run --read-only --tmpfs /tmp --tmpfs /run -v /data:/data myapp:v1.0参数说明--read-only将容器根文件系统挂载为只读。程序无法改写/usr、/etc、/app等任何根目录下的路径。--tmpfs /tmp给临时目录一块内存盘。很多应用运行时会在/tmp写文件没有这一项会直接报read-only file system。-v /data:/data挂载一个可写的数据卷用于应用真正需要持久化的那部分文件。这里有个容易翻车的点日志目录。如果应用把日志写到/var/log而/var/log在根文件系统里那也会因为只读而失败。常见做法是把日志目录单独挂到一个数据卷或者 tmpfs。另一个办法是在 Dockerfile 里提前把日志路径设置为一个挂载点但这对构建时有限制。最简单的是在 compose 或 K8s 配置里把日志目录也 mount 出来。在 Kubernetes 里对应配置是securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: falseallowPrivilegeEscalation设置为 false 也很关键它禁止子进程获得比父进程更高权限和只读根文件系统搭配使用效果最好。每次线上发布前我都会先用这两个配置在测试环境跑一遍全套用例再放行。这样可以提前发现应用哪些位置写入不合法避免生产环境启动即崩溃。4. 容器安全常见问题排查四大典型踩坑记录4.1 镜像扫描漏洞太多团队不知道从哪里下手现象扫描工具一跑报告几千个漏洞红红一片。成员们看着图表干脆不看了。原因没有区分基础镜像漏洞和应用依赖漏洞。很多团队直接基于ubuntu:20.04或者更老的镜像做基础系统库漏洞全部计到项目头上。还有部分漏洞是不可利用的比如某些库只有本地攻击路径但报告会把它标成 HIGH。解决先把基础镜像单独扫描把基础镜像的漏洞报告和应用依赖的漏洞报告分开。命令可以这样docker pull ubuntu:20.04 trivy image --severity CRITICAL --ignore-unfixed ubuntu:20.04 trivy image --severity CRITICAL --ignore-unfixed myapp:v1.0对比两个报告如果应用镜像的漏洞和基础镜像几乎一致说明问题在基础镜像解决办法是升级或替换基础镜像。如果差异部分来自应用依赖再用trivy image --severity CRITICAL --vuln-type library只看库层面。排优先级的原则是可被远程利用、POC 公开、且镜像里有对应受影响文件的漏洞优先修。其余先进入观察列表。4.2 容器内以 root 运行扫描无漏洞依然被攻破现象镜像扫描报告显示零漏洞但攻击者从应用漏洞拿到一个低权限 shell 后发现容器内所有文件都可读写包括/etc/shadow和密钥文件直接顺藤摸瓜拿到了宿主机挂载的配置。原因Dockerfile 里没有USER指令进程默认以 root 启动。即使没有 privilegeroot 用户也可以读所有文件改所有配置。一旦应用有任意文件写漏洞攻击者可以直接写宿主机挂载目录的敏感文件。解决在 Dockerfile 里显式创建非 root 用户并切换RUN useradd --create-home --uid 10001 appuser USER appuser如果是 distroless 或不需要 home 目录的镜像应该让应用进程只带着必要的文件权限运行。同时配合第 3 章的--cap-dropALL和只读根文件系统。扫描器只看 CVE不看运行 UID这两件事要分开做。4.3 容器 PID 1 是 shell安全隔离时无法快速终止现象安全事件需要立即隔离容器执行docker stop容器过了 30 多秒才停止期间还在继续发送连接。有时候容器内出现大量僵尸进程无法被回收。原因很多 Dockerfile 的ENTRYPOINT写成ENTRYPOINT [/bin/sh, -c, python app.py]shell 作为 PID 1。PID 1 的责任是初始化系统并转发信号但 shell 不会正确转发 SIGTERM导致docker stop只能靠超时强制SIGKILL。僵尸进程也因为没有父进程回收而残留。解决不要用 shell 包裹进程直接让程序作为 PID 1ENTRYPOINT [python, app.py]如果应用需要环境变量展开或后处理使用tini作为 initRUN apk add --no-cache tini ENTRYPOINT [/sbin/tini, --, python, app.py]tini会正确处理信号转发和僵尸回收。这个改动不仅提升稳定性也让安全处置的响应速度从分钟级降到秒级。镜像里有没有 tini应该成为镜像安全扫描的一项检查。4.4 用 ignorefile 忽略了漏洞之后完全没人管现象为了能让新版本上线团队在 Trivy 配置里加了--ignorefile .trivyignore把一批高危漏洞拉白。三个月后漏洞仍然存在没人知道为什么要拉白。原因当初拉白可能是为了快速上线但没有给白名单设置过期时间也没有记录拉白理由。工具提供了便捷通道却没有约束机制白名单就成了“永久特赦”。解决给每个忽略项加上截止日期和理由。Trivy 的 ignorefile 支持按 token 匹配我一般会在.trivyignore里写清楚CVE-2024-1234 # 2025-01-31 原因上游修复未发布等 debian 安全仓库更新 CVE-2025-5678 # 2025-02-15 原因待安全团队评估后决定是否重写该模块然后在 CI 脚本里检查这些日期如果过期就视为失败。具体脚本见第 6 章的示例把日期判断加进门槛脚本防止“一劳永逸”。5. Kubernetes 环境下的容器安全策略和网络的边界收紧单机 Docker 的加固只是基础生产环境几乎都跑在 Kubernetes 里。容器安全指南落到集群层面重点在三个位置准入控制、命名空间策略、网络策略。5.1 Pod Security Standards把安全基线写进命名空间Kubernetes 自带的 Pod Security Standards 分为三种privileged、baseline、restricted。privileged 完全放开baseline 禁用大部分明显危险的能力restricted 则最严格要求非 root、只读根文件系统、禁止特权升级。生产环境建议至少从 baseline 起步面向公网的业务用 restricted。用命名空间标签就能启用强制模式kubectl label ns production pod-security.kubernetes.io/enforcerestricted生效后任何不满足 restricted 标准的 Pod 都无法创建。注意这个命令加的是enforce模式不满足的 Pod 会被拒绝。如果只记录不拒绝可以用audit或warn模式。新集群建议先用 warn 观察存量负载再切 enforce。restricted 标准对 Pod 的要求比较细比如必须设置runAsNonRoot: true和allowPrivilegeEscalation: false还会检查 capabilities 丢弃情况。当你把一个旧业务从 baseline 迁到 restricted 时最常见的翻车就是镜像里默认 root 用户或者设置了加载内核模块。这种情况下不是改 yaml 能解决的要改 Dockerfile把进程跑成非 root。5.2 NetworkPolicy默认拒绝才是真正的边界很多集群的安全配置集中在 Pod 自身却忽略了网络层面的东西。默认情况下K8s 集群里所有 Pod 之间可以任意通信即使容器加固得再好只要另一个被攻破的 Pod 能访问你的管理端口风险就依然存在。NetworkPolicy 是实操中容器安全指南里最值得单独讲的部分。先创建一个默认拒绝所有入口流量的策略apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: deny-all namespace: production spec: podSelector: {} policyTypes: - Ingress说明podSelector: {}匹配该命名空间下所有 Pod策略允许列表为空所以任何入站流量都被拒绝。这里没有指定Ingress以外的Egress是因为出口流量往往还需要放行 DNS 或公网 API。生产环境我会同时定义入口和出口的默认拒绝再逐条放行。例如允许 Frontend Pod 访问 Backend Pod 的 8080 端口spec: podSelector: matchLabels: app: backend ingress: - from: - podSelector: matchLabels: app: frontend ports: - protocol: TCP port: 8080一定要确认集群的网络插件支持 NetworkPolicy。Calico、Cilium、Weave 都支持但 flannel 默认不支持。如果用了 flannel 又没有额外组件写了 NetworkPolicy 也不会生效团队会误以为流量已经被隔离这是最危险的误判。5.3 准入控制用 Gatekeeper 强制只运行已扫描且签名的镜像NetworkPolicy 管流量Pod Security Standards 管配置但谁能保证去年写好的策略今年还在谁又能防止团队绕过一个命名空间的 enforce 标签去创建特殊 Pod这就要用准入控制器。Kubernetes 本身有ValidatingAdmissionPolicy但生产环境中常见的是用 GatekeeperOPA 的 Kubernetes 版本做策略即代码。Gatekeeper 使用 ConstraintTemplate 定义规则。一个最简单的模板要求镜像带签名可以写成apiVersion: templates.gatekeeper.sh/v1beta1 kind: ConstraintTemplate metadata: name: k8srequirecosign spec: crd: spec: names: kind: K8sRequireCosign targets: - target: admission.k8s.gatekeeper.sh rego: | package k8srequirecosign violation[{msg: msg}] { image : input.review.object.spec.containers[_].image not signed_images[image] msg : sprintf(镜像 %v 没有有效签名, [image]) }这段 Rego 策略会在 Pod 创建时检查镜像是否在已知签名列表里。签名列表来源于外部数据源可以是 ConfigMap 或 OCI 仓库的信任数据。因为 Rego 语法对很多工程师来说有些陌生我建议先拉取官方 Gatekeeper 策略库找现成的模板改而不是从零写。真正要花时间的是维护“签名镜像白名单”这个名单可以从 CI 发布流水线里自动生成。Gatekeeper 不适合管理太细的规则比如“这个 Deployment 必须要有 3 个副本”这种业务约束应该交给配置管理工具。安全团队需要关注的是那些无法被 Pod Security Standards 覆盖的、涉及供应链和集群全局的规则比如禁止从公网拉取未签名镜像、禁止使用latesttag、禁止挂载宿主机敏感路径。6. 把安全门槛写进 CI一份可复用的镜像准入脚本前面章节提到的扫描、签名检查、白名单过期校验散落在各个工具里。最后我把它们拢成一个脚本放到 CI 的发布流水线里作为合并到主干前的一票否决关。这个脚本不算复杂但足够给新手一个直接能改的模板。#!/bin/bash set -euo pipefail IMAGE$1 CRITICAL_THRESHOLD${2:-0} HIGH_THRESHOLD${3:-10} tmp_report$(mktemp) # 第1步扫描并导出 JSON 报告 trivy image --quiet --exit-code 0 --format json --output $tmp_report $IMAGE # 第2步统计 CRITICAL 和 HIGH 漏洞数 crit_num$(jq [.Results[]?.Vulnerabilities[]? | select(.SeverityCRITICAL)] | length $tmp_report) high_num$(jq [.Results[]?.Vulnerabilities[]? | select(.SeverityHIGH)] | length $tmp_report) echo CRITICAL$crit_num HIGH$high_num # 第3步白名单过期检查 current_date$(date %Y-%m-%d) expired_lines$(awk -F # /CVE-/{ if ($2 ! ) print $0 } .trivyignore || true) # 第4步阈值判定 if [ $crit_num -gt $CRITICAL_THRESHOLD ]; then echo 超过 CRITICAL 阈值 $CRITICAL_THRESHOLD构建失败。 exit 1 fi if [ $high_num -gt $HIGH_THRESHOLD ]; then echo 超过 HIGH 阈值 $HIGH_THRESHOLD构建失败。 exit 1 fi # 第5步若存在签名公钥则校验签名 if [ -f cosign.pub ]; then cosign verify --key cosign.pub $IMAGE fi rm -f $tmp_report参数说明CRITICAL_THRESHOLD默认 0表示不允许出现任何严重漏洞。HIGH_THRESHOLD默认 10团队可以根据历史基线调整。--exit-code 0在这里是有意保留的因为我们要在下一步用 jq 过滤统计。如果直接让 trivy 返回非零脚本会在统计前就退出反而无法拿到整体计数。白名单过期检查只是打印了过期条目没有真正阻止构建。把它变成强制拦断也很简单把第 3 步的输出行数作为条件有任何一个过期CVE-条目就exit 1。我自己的习惯是把这个脚本放在代码仓的ci/security-gate.sh里由 CI 调用参数从 pipeline 的 config 读而不是硬编码在 YAML 中。每次调整阈值都必须通过 MR 审计防止有人为了发版本悄悄放宽。安全这件事工具能拦住已知问题流程能拦住人为妥协。容器安全指南 PDF 可以躺在资料库里但这份脚本必须跑在每一次构建之前。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →