容器镜像安全实战——Trivy 扫描、基础镜像选型、最小权限原则
容器镜像安全实战——Trivy 扫描、基础镜像选型、最小权限原则
适读人群:使用 Docker/K8s 的工程师 | 阅读时长:约19分钟 | 核心价值:从镜像扫描到 Dockerfile 最佳实践,把容器的安全基线做扎实
我有一次在做容器安全扫描的时候,扫出了一个让我很震惊的结果。
那是一个在生产环境运行了一年的 Java 微服务镜像,基础镜像是 openjdk:8-jdk。Trivy 扫描结果:306 个漏洞,其中 HIGH 79 个,CRITICAL 18 个。
这个镜像在生产运行了一年,我们从来不知道它有这么多漏洞。
当时我用 openjdk:8-jdk 是因为文档里的示例就是这个,我没有深想过基础镜像的安全问题。
那次之后,我把所有项目的基础镜像做了一次审查,建立了容器安全规范。这篇文章把这些内容整理出来。
基础镜像选型
为什么 openjdk:8-jdk 有那么多漏洞
openjdk:8-jdk 基于 Debian Stretch(Debian 9),Stretch 已经 EOL(End of Life),不再接受安全更新。镜像里包含了大量 OS 级别的工具(bash、curl、apt 等),这些工具本身也有漏洞。
更糟糕的是,jdk 变体包含完整的 Java 开发工具(javac、javadoc 等),而运行时根本不需要这些,它们只是额外的攻击面。
基础镜像选型原则
原则一:用最小化的镜像
按体积和包含工具从多到少:ubuntu/debian > slim > alpine > distroless
对于 Java 应用,推荐顺序:
eclipse-temurin:17-jre-jammy(Ubuntu 22.04 LTS + JRE):安全更新及时,包管理器齐全,调试方便,适合大多数团队eclipse-temurin:17-jre-alpine(Alpine + JRE):镜像最小,但 Alpine 用 musl libc,部分 native 库可能有兼容性问题gcr.io/distroless/java17(Distroless + JRE):没有 shell,没有包管理器,攻击面最小,适合安全要求极高的场景
# 不推荐:包含大量不必要的工具
FROM openjdk:8-jdk
# 推荐:只有 JRE,OS 有安全更新
FROM eclipse-temurin:17-jre-jammy
# 更好:进一步减少攻击面
FROM gcr.io/distroless/java17原则二:用 JRE 而不是 JDK
生产环境运行 Java 应用只需要 JRE(Java Runtime Environment),JDK(Java Development Kit)包含了编译器等开发工具,不应该存在于生产镜像里。
从 JDK 换到 JRE,镜像体积可以减少 200-300MB,漏洞数量也会大幅降低。
原则三:定期更新基础镜像
使用固定 tag(如 eclipse-temurin:17.0.10-jre)还是浮动 tag(如 eclipse-temurin:17-jre)是一个权衡:
- 固定 tag:可重现,但不会自动获得安全更新
- 浮动 tag:总是最新的安全版本,但可能有 breaking change
我的建议:CI 里用浮动 tag 触发定期重建(每周),测试通过后再 promote 到生产。这样既有安全更新,又有测试保障。
基础镜像漏洞的现实
有一个容易被忽视的事实:即使你今天用了扫描通过的基础镜像,三个月后这个镜像可能会有新的 HIGH 漏洞,因为新的 CVE 被发现了。
这意味着容器安全不是"做一次扫描就完了",而是需要持续的过程。定期重新扫描已部署的镜像(不只是构建时扫描),及时发现运行中镜像的新漏洞。很多镜像仓库(Harbor、ECR、Artifact Registry)支持持续扫描功能,自动对存储的镜像重新扫描,发现新漏洞时告警。
对于生产环境中已经在运行的容器,也需要有镜像更新的机制。K8s 的 Deployment 滚动更新让这个过程可以不停服:CI 产生新镜像后,自动触发 K8s 更新,旧 Pod 逐步被新 Pod 替换。
Dockerfile 最佳实践
多阶段构建:分离构建环境和运行环境
# 构建阶段:用完整的 JDK
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
# 先下载依赖(利用 Docker 层缓存)
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
# 运行阶段:只用 JRE
FROM eclipse-temurin:17-jre-jammy AS runtime
# 创建非 root 用户
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# 只复制 jar,不复制源代码
COPY --from=build /build/target/*.jar app.jar
# 设置文件所有者
RUN chown appuser:appuser app.jar
# 切换到非 root 用户运行
USER appuser
EXPOSE 8080
# JVM 参数优化
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]重点:USER appuser 切换到非 root 用户运行应用。默认情况下 Docker 容器以 root 运行,一旦容器被攻破,攻击者直接拥有 root 权限,可以影响宿主机(如果容器配置不当)。
避免危险的 Dockerfile 写法
# 危险:把 SSH 密钥复制进镜像
COPY id_rsa /root/.ssh/id_rsa # 密钥会永久留在镜像层里
# 安全:用 BuildKit 的 secret 特性
RUN --mount=type=secret,id=ssh_key \
git clone --key /run/secrets/ssh_key https://git.company.com/repo.git
# 危险:运行时以 root 运行
# 不写 USER 指令,默认就是 root
# 安全:明确指定非 root 用户
USER 1001 # 用 UID 而不是用户名,在某些场景更可靠
# 危险:安装了大量调试工具
RUN apt-get install -y vim curl netcat strace # 攻击者可以用这些工具进行侦察
# 安全:最小化工具集
# 如果需要调试,用 kubectl debug 或临时容器Trivy 扫描实践
扫描镜像
# 基本扫描
trivy image eclipse-temurin:17-jre-jammy
# 只显示 HIGH 和 CRITICAL
trivy image --severity HIGH,CRITICAL eclipse-temurin:17-jre-jammy
# 输出 JSON 格式
trivy image --format json --output trivy-report.json myapp:latest
# 扫描本地未推送的镜像
trivy image --input myapp.tar扫描 Dockerfile 最佳实践(Trivy Config Scanner)
# 扫描 Dockerfile 的配置问题
trivy config ./Dockerfile
# 示例输出
# HIGH Running as root user
# MEDIUM No HEALTHCHECK defined
# INFO Add HEALTHCHECK to ensure the service is healthyCI 集成
# GitHub Actions
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # 发现 CRITICAL/HIGH 则 job 失败
- name: Upload Trivy scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always() # 即使上一步失败也上传报告
with:
sarif_file: 'trivy-results.sarif'K8s 层面的容器安全
光有安全的镜像还不够,K8s 的 Pod Security 配置也很关键:
Security Context
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
# Pod 级别的安全配置
securityContext:
runAsNonRoot: true # 禁止以 root 运行
runAsUser: 1001 # 指定 UID
runAsGroup: 1001 # 指定 GID
fsGroup: 1001 # 挂载卷的 GID
seccompProfile:
type: RuntimeDefault # 使用默认 seccomp profile,限制系统调用
containers:
- name: app
image: myapp:latest
# 容器级别的安全配置
securityContext:
allowPrivilegeEscalation: false # 禁止提权
capabilities:
drop:
- ALL # 删除所有 Linux capabilities
add:
- NET_BIND_SERVICE # 只保留绑定 <1024 端口的能力(如果需要)
readOnlyRootFilesystem: true # 根文件系统只读
volumeMounts:
- name: tmp
mountPath: /tmp # tmp 目录可写(很多应用需要)
- name: heapdump
mountPath: /heapdump # heap dump 目录可写
volumes:
- name: tmp
emptyDir: {}
- name: heapdump
emptyDir: {}readOnlyRootFilesystem: true 是一个很强的安全配置——容器的文件系统完全只读,攻击者即使进入容器,也无法写入任何恶意文件。代价是你需要明确挂载所有需要写入的目录(如 /tmp、/logs)。
踩坑实录
关于漏洞"接受"与"修复"的权衡
Trivy 扫出漏洞之后,并不是所有漏洞都需要立刻修复。合理的处理策略是:
CRITICAL 和 HIGH 漏洞:优先修复,通常需要在 48 小时到一周内处理完毕。尤其是那些有已知公开 exploit 的漏洞,更需要紧急响应。
MEDIUM 漏洞:纳入常规 backlog,按照业务价值和安全风险综合排期。
LOW 漏洞:可以接受在受控条件下暂时存在,但要文档化说明原因。比如某个 LOW 漏洞只影响一个不会被外部访问的内部工具镜像,风险可以接受。
重要的是,对于每个接受的漏洞,要记录:这个漏洞的 CVE 编号是什么?为什么接受而不是修复?下次复查的时间?谁审批了这个决定?这些记录既是合规要求,也是技术债管理的一部分。
踩坑实录
踩坑一:Distroless 镜像没有 shell,无法调试
我们把一个服务切换到了 distroless 镜像,测试通过了,上了生产。结果有一次需要进容器里查一个问题,发现 kubectl exec ... -- bash 报错:bash: no such file or directory。
没有 shell、没有 ps、没有 netstat,调试工具全没了。
解决方案:用 K8s 的 ephemeral container(临时容器):
kubectl debug -it POD_NAME \
--image=busybox \
--target=app \
-- sh这会在 Pod 里临时启动一个带 shell 的调试容器,可以查看运行中应用的文件系统和进程,调试完成后自动清理。
踩坑二:多阶段构建的 Maven 层缓存失效
多阶段构建中,第一次执行时:
COPY pom.xml .
RUN mvn dependency:go-offline -B # 下载依赖
COPY src ./src
RUN mvn package -DskipTests # 构建pom.xml 不变时,mvn dependency:go-offline 这层会命中缓存,不重新下载依赖,速度很快。
但如果你先 COPY src 再 COPY pom.xml,只要任何源文件改变,后面的所有层缓存都失效,每次都要重新下载依赖。
Dockerfile 层顺序原则:改变频率越低的内容放越前面,改变频率越高的内容放越后面。这个原则不只适用于 Maven 依赖,对于任何 Dockerfile 都是通用的。配置文件比代码变更频率低,代码比依赖变更频率高——所以顺序应该是:基础镜像 → 系统依赖 → 应用依赖 → 配置文件 → 应用代码。理解了层缓存机制,就能显著加快 CI 里的镜像构建速度。
踩坑三:非 root 用户导致端口绑定失败
应用需要绑定 8080 端口,Dockerfile 里设置了 USER 1001,结果应用报错 java.net.BindException: Permission denied。
原因:Linux 下绑定 1024 以下的端口需要 root 权限(或者 NET_BIND_SERVICE capability)。8080 端口是大于 1024 的,按理说不需要特权。
真正的原因:应用配置里 server.port=80(不知道谁改的),80 端口需要 root 权限。
解决方案:应用端口配置为 8080(大于 1024),K8s Service 层面做 80 → 8080 的映射,或者在 securityContext 里保留 NET_BIND_SERVICE capability。
深度解析:容器逃逸与纵深防御
容器安全的终极威胁是"容器逃逸"——攻击者从容器内部突破隔离,获取宿主机权限。理解逃逸的原理,才能明白为什么要做那些安全配置。
容器逃逸的常见路径
第一类:内核漏洞利用。容器和宿主机共享同一个 Linux 内核。如果内核有安全漏洞(比如历史上著名的 Dirty COW、runc 漏洞),攻击者可以从容器内利用内核漏洞逃出来。
防御:及时更新内核和容器运行时(containerd、runc)。这是 OS 层面的事情,但很重要。
第二类:特权容器滥用。运行特权容器(privileged: true)相当于把宿主机的所有权限给了容器,等于没有隔离。一行配置错误,容器安全就完全失效。
防御:绝对不要在生产环境用特权容器。如果某个工具声称需要特权容器才能运行,仔细研究是否真的需要,通常可以用更细粒度的 capabilities 替代。
第三类:挂载主机目录。如果把宿主机的敏感目录挂载进容器(比如 /var/run/docker.sock、/etc、/root),攻击者在容器内就能操作这些目录,等同于访问宿主机。
/var/run/docker.sock 是特别危险的挂载——它是 Docker 的控制接口,挂载进容器意味着容器内的进程可以控制宿主机上的所有容器,可以创建特权容器,然后通过那个特权容器访问宿主机文件系统。很多 CI/CD 工具(Jenkins agent、某些监控工具)需要这个挂载,要特别谨慎。
第四类:capabilities 滥用。Linux capabilities 把传统 root 权限拆成了更细粒度的能力。如果容器保留了太多 capabilities,攻击者可以利用这些能力做恶意操作。比如 CAP_SYS_ADMIN 接近于完整 root 权限,CAP_NET_ADMIN 可以修改网络配置。
我们配置的 drop: ALL 就是从最小权限出发,只加回真正需要的 capabilities。
纵深防御的层次
容器安全要在多个层次上同时做防御,不能依赖单一的防线:
第一层:镜像安全(本文重点)。基础镜像选型、扫描、非 root 用户。
第二层:运行时安全策略。K8s Security Context、Pod Security Admission,在 Pod 调度时就拒绝不安全的配置。
第三层:运行时行为监控。Falco 这类工具可以在容器运行时监控系统调用,发现异常行为(比如容器里突然出现了 curl 下载脚本、修改了 /etc/passwd 文件)实时告警。
第四层:网络策略。K8s Network Policy 限制 Pod 间的网络访问,即使某个容器被攻破,也无法访问不应该访问的其他服务。
这四层防御,每一层都有可能被绕过,但要同时绕过四层的难度呈指数级增长。这就是纵深防御的价值。
深度解析:镜像仓库安全与供应链攻击
容器安全不只是"自己的代码是否安全",还包括"你使用的镜像从哪里来,是否可信"。2020 年以来,供应链攻击成为了攻击者的重要手段。
Docker Hub 上的恶意镜像
Docker Hub 是公开镜像仓库,任何人都可以发布镜像。已经有多起事件:攻击者发布了伪装成知名镜像的恶意镜像(名字相似、拼写错误),镜像里内置了挖矿程序、后门。
防御措施:
一是只使用官方镜像(Docker Official Images 标志)或知名发布者的 Verified Publisher 镜像。不要使用来历不明的第三方镜像,尤其是低 pull 数量的镜像。
二是在公司内部建立私有镜像仓库(Harbor、AWS ECR、阿里云容器镜像服务),所有镜像先经过扫描验证,再推送到内部仓库。业务使用时从内部仓库拉取,而不是直接从 Docker Hub。
三是使用镜像摘要(digest)而不是 tag。Tag 是可以被覆盖的(今天的 myapp:latest 和明天的 myapp:latest 可能完全不同),而 digest(SHA256 哈希)是内容固定的。在生产部署里,用 digest 引用镜像,确保运行的是预期版本。
# 用 digest 而不是 tag(digest 是内容的唯一标识符)
image: eclipse-temurin@sha256:1234567890abcdef...镜像签名与验证
更进一步的安全实践是镜像签名:构建时用私钥给镜像签名,部署时验证签名。确保镜像从构建到部署的整个过程没有被篡改。
Cosign(Sigstore 项目的一部分)是目前主流的镜像签名工具:
# 签名镜像
cosign sign --key cosign.key myregistry.com/myapp:v1.0
# 验证签名
cosign verify --key cosign.pub myregistry.com/myapp:v1.0在 K8s 里,可以配置 Policy(比如 Kyverno 的策略):所有 Pod 只允许运行有有效签名的镜像,未签名的镜像会被拒绝调度。
这样,即使有人设法把一个未签名的恶意镜像推入了仓库,也无法在 K8s 里运行。
总结
容器安全不是一次性的工作,是一个持续的过程:
- 选合适的基础镜像:最小化、有 LTS 支持、定期更新
- Dockerfile 遵循最佳实践:非 root 用户、多阶段构建、最小化安装
- CI 里扫描每个构建的镜像:Trivy + 高危漏洞阻塞合并
- K8s 里配置 Security Context:readOnlyRootFilesystem、drop ALL capabilities
把这四步做到位,你的容器安全基线就已经比大多数团队好了。
容器安全有时候让人觉得"这些配置这么多,是不是过度设计了"。但容器安全的价值在于限制爆炸半径——当某个漏洞真的被利用时,攻击者能做的事情被严格限制了。一个以非 root 身份运行、文件系统只读、没有任何特权能力的容器,即使被攻破,攻击者也很难扩大攻击范围。这些配置不是杜绝攻击,而是让攻击代价变高。就像家门锁不能杜绝入室盗窃,但让小偷需要更多时间和风险,多数情况下他会放弃这个目标转而去找没锁的门。
