简介:这是一份由个人翻译的 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 --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/还有一个老话题:为什么不用 alpine?Alpine 的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 裁剪:不要一上来就 --privileged
Linux capabilities 机制把 root 权限拆成了几十个小权限,比如CHOWN可以改文件属主,NET_BIND_SERVICE可以绑定低端口,NET_ADMIN可以配置网络。Docker 默认给容器一个 root 用户,但会限制掉一部分 capabilities。问题是——很多应用根本不需要那些默认权限。
最稳妥的做法是先全部丢弃,再按需添加:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE --cap-add=CHOWN myapp:v1.0参数说明:
--cap-drop=ALL丢弃所有 capabilities,容器内用户即使在 UID 0 状态下,也不具备任何特权操作能力。--cap-add=NET_BIND_SERVICE添加绑定低端口的能力。如果应用要监听 80 或 443,需要这个。CHOWN是解压文件或写入挂载卷时需要改属主的时候用的。没有确实需要就不要加。
在 Kubernetes 里对应的是securityContext.capabilities:
securityContext: 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 启动。即使没有 privilege,root 用户也可以读所有文件,改所有配置。一旦应用有任意文件写漏洞,攻击者可以直接写宿主机挂载目录的敏感文件。
解决:在 Dockerfile 里显式创建非 root 用户并切换:
RUN useradd --create-home --uid 10001 appuser USER appuser如果是 distroless 或不需要 home 目录的镜像,应该让应用进程只带着必要的文件权限运行。同时配合第 3 章的--cap-drop=ALL和只读根文件系统。扫描器只看 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 1:
ENTRYPOINT ["python", "app.py"]如果应用需要环境变量展开或后处理,使用tini作为 init:
RUN 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/enforce=restricted生效后,任何不满足 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,但生产环境中常见的是用 Gatekeeper(OPA 的 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(.Severity=="CRITICAL")] | length' "$tmp_report") high_num=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | 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 可以躺在资料库里,但这份脚本必须跑在每一次构建之前。希望帮到你。
本文还有配套的精品资源,点击获取