Docker 镜像瘦身与安全扫描实战指南:Dive、Trivy 与 Hadolint 的完整工具链
你有没有遇到过这种情况:写完一个简单的 Go HTTP 服务,docker build 完一看镜像 1.2GB。推到 Registry 上传了三分钟,拉取又三分钟。更要命的是安全团队扫了一下,47 个 CVE,其中 12 个 CRITICAL。生产环境谁敢用?
问题不在于 Docker 本身,而在于大多数开发者写 Dockerfile 时根本没有意识到镜像分层、构建上下文和安全基镜像的重要性。今天这篇文章就用三个工具——Dive(分层分析)、Trivy(漏洞扫描)、Hadolint(Dockerfile 静态检查)——从编写到推送构建一条完整的 CI 安全门禁。
一、为什么需要三件套
先来看一个反面教材。这是一个真实项目中常见的"能跑就行"的 Dockerfile:
# 反面教材:什么都往里塞
FROM ubuntu:latest
RUN apt-get update && apt-get install -y \
build-essential python3 curl git vim nodejs npm
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/server.js"]
这个 Dockerfile 有多少问题?我列一下:
| 问题 | 影响 | 谁负责 |
|---|---|---|
ubuntu:latest 没有固定版本 | 每次构建结果不同,不可复现 | Hadolint 检出 |
| 装了 vim、git 等构建期才需要的工具 | 镜像多出 200MB+ 无用包 | Dive 检出 |
COPY . . 把 .git、node_modules 都复制进去了 | 镜像多出 68% 浪费空间 | Dive 检出 |
| ubuntu 基础镜像带 47 个已知漏洞 | 12 个 CRITICAL CVE | Trivy 检出 |
| 没有 multi-stage build | 构建工具链残留在运行镜像中 | Hadolint 检出 |
三个工具各管一摊:Hadolint 在你写 Dockerfile 时就拦住规范问题,Dive 在构建完后分析哪些层浪费了空间,Trivy 在推送前扫描安全漏洞。三者串起来就是一条完整的门禁流水线。
二、Hadolint:Dockerfile 的 ESLint
2.1 安装与基本用法
Hadolint 是一个 Haskell 写的 Dockerfile 静态检查器,不需要 Docker 环境,安装即可用:
# macOS
brew install hadolint
# Linux (直接下二进制)
curl -sL https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64 \
-o /usr/local/bin/hadolint && chmod +x /usr/local/bin/hadolint
# Docker 方式(不想装本地)
docker run --rm -i hadolint/hadolint:2.12.0 < Dockerfile
# 基本用法:检查当前目录的 Dockerfile
hadolint Dockerfile
输出长这样:
Dockerfile:3 DL3006 warning: Always tag the version of an image explicitly
Dockerfile:5 DL3008 warning: Pin versions in apt-get install
Dockerfile:5 DL3015 info: Avoid additional packages by specifying --no-install-recommends
Dockerfile:9 DL3059 info: Multiple consecutive RUN instructions. Consider consolidation
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD
2.2 核心规则速览
Hadolint 内置了 50+ 条规则,按严重级别分为 error、warning、info、style。以下是出现频率最高的 10 条:
| 规则 | 级别 | 含义 |
|---|---|---|
| DL3006 | warning | 必须指定基镜像版本标签,禁止 latest |
| DL3008 | warning | apt-get install 必须固定版本号 |
| DL3015 | info | apt-get install 加 --no-install-recommends |
| DL3059 | info | 多个连续 RUN 应合并以减少层数 |
| DL3025 | warning | CMD/ENTRYPOINT 用 JSON 数组格式 |
| DL4006 | warning | set -o pipefail 管道失败处理 |
| DL3018 | warning | apk add 必须固定版本号 |
| DL3013 | warning | pip install 必须固定版本号 |
| DL3042 | warning | COPY 多个源时用目录而非通配符 |
| SC2086 | info | ShellCheck:变量引用要加双引号 |
2.3 配置与忽略
不是所有规则都适用于你的项目。可以在项目根目录放一个 .hadolint.yaml:
# .hadolint.yaml
ignored:
- DL3008 # 不强制 apt 固定版本(内部源无版本标签)
trustedRegistries:
- docker.io
- gcr.io
- registry.internal.com
failure-threshold: warning # warning 及以上才报错退出
这样在 CI 里跑 hadolint --config .hadolint.yaml Dockerfile,只有 warning 级别以上才会返回非零退出码,适合做 CI 门禁。
三、Dive:看见每一层浪费了什么
3.1 安装与交互模式
# macOS
brew install dive
# Linux
curl -sL https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.tar.gz \
| tar xz && mv dive /usr/local/bin/
# 分析一个镜像
dive myapp:latest
Dive 会进入一个交互式 TUI 界面,左边显示每一层的增删改,右边显示文件树。你可以直观看到哪一层加了什么、删了什么、哪些文件是浪费的(白色=保留,红色=被后续层覆盖或删除但仍占空间)。
3.2 CI 模式:效率分数做门禁
Dive 的交互模式很好看,但真正有用的是 CI 模式。它会把分析结果输出为结构化数据,并根据效率分数决定退出码:
# CI 模式:效率低于 90% 就失败
dive myapp:latest --ci \
--efficiency 0.90 \
--wastedSpace 0.20 \
--lowerBound 0.05
# 输出 JSON 给后续脚本处理
dive myapp:latest --ci --json > dive-report.json
关键指标解释:
| 指标 | 含义 | 推荐阈值 |
|---|---|---|
| efficiency | 有效空间占比,1.0 为无浪费 | > 0.90 |
| wastedSpace | 被后续层覆盖但仍占空间的字节比例 | < 0.20 |
| lowerBound | 低于此字节数的层不计算 | 0.05 (5MB) |
3.3 用 Dive 发现真实问题
拿前面那个反面教材跑一下 Dive:
Parsing image...
Analysis complete! Building tree...
inefficient: true, wastedSpace: 68.2%
TOTAL FILE SIZE: 1,234 MB
Wasted Space (this layer and all later layers):
- /var/lib/apt/lists/* 182 MB
- /var/cache/apt/archives/* 95 MB
- /usr/lib/python3/dist-packages/ 210 MB
- ./node_modules/ 340 MB
- ./.git/ 48 MB
Efficiency: 31.8% (BELOW THRESHOLD)
Wasted: 875 MB
一眼就看到了:node_modules 被复制进了镜像 340MB,apt 缓存 277MB 没清,.git 目录 48MB 也跟着进去了。这些都是 Hadolint 看不出来的运行时问题。
四、Trivy:从镜像到 IaC 的全栈扫描
4.1 安装与基本扫描
# macOS
brew install trivy
# Linux
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
# 扫描一个镜像
trivy image myapp:latest
# 只看 CRITICAL 和 HIGH
trivy image --severity CRITICAL,HIGH myapp:latest
# 输出 JSON
trivy image --format json -o trivy-report.json myapp:latest
Trivy 的扫描覆盖面非常广,不只是 OS 包:
| 扫描类型 | 覆盖范围 | 示例 |
|---|---|---|
| OS 包 | apt, apk, yum, dnf | ubuntu CVE-2024-1234 |
| 语言依赖 | npm, pip, gem, cargo, go mod | lodash 4.17.4 Prototype Pollution |
| IaC 配置 | Dockerfile, K8s YAML, Terraform | 容器以 root 运行 |
| 密钥泄露 | AWS key, private key, token | AKIA*** 在环境变量 |
| 许可证 | GPL, AGPL 等合规风险 | 依赖中有 AGPL-3.0 |
4.2 CI 门禁配置
在 CI 里最常用的模式是"零 CRITICAL 才通过":
# .trivy.yaml (Trivy config)
severity:
- CRITICAL
- HIGH
ignore-unfixed: true # 忽略还没有修复方案的漏洞
exit-code: 1 # 有漏洞就退出码 1
# 忽略特定漏洞(有正当理由时)
vulnerabilities:
- CVE-2024-XXXX # 已评估风险可接受,下个迭代修
# 忽略文件
.trivyignore:
CVE-2024-1234
# GitHub Actions 集成
name: container-security
on: [push, pull_request]
jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
format: sarif
output: trivy-results.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
4.3 密钥扫描:容易忽略的红线
Trivy 的密钥扫描经常能抓到一些你意想不到的东西。比如开发者把数据库密码写进了 Dockerfile 的 ENV 里:
# 这个写法 Trivy 会报警
ENV DB_PASSWORD=SuperSecret123
ENV AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
正确做法是用 Docker Secret 或运行时注入:
# 运行时通过 --env-file 或 secret mount 注入
docker run --env-file .env.prod myapp:latest
# 或用 Docker Secret (Swarm)
echo "SuperSecret123" | docker secret create db_password -
五、Multi-stage Build:瘦身的根本手段
说到底,三件套里的 Dive 和 Trivy 是"检查工具",真正让镜像变小的还得靠 multi-stage build。核心思路很简单:构建环境和生产环境分开,最终镜像只保留运行所需的最小内容。
# ---- 构建阶段 ----
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download # 依赖单独一层,利用缓存
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app .
# ---- 运行阶段 ----
FROM gcr.io/distroless/static-debian12
COPY --from=builder /build/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
关键优化点:
go mod download单独一层:代码变了不会重新下载依赖,利用层缓存-ldflags="-s -w":去掉调试符号表和 DWARF 信息,二进制减小 30%- distroless 基镜像:没有 shell、没有包管理器,攻击面几乎为零
USER nonroot:不以 root 运行,Hadolint 和 Trivy 都会检查
优化前后的对比非常明显:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 镜像大小 | 1.2 GB | 89 MB | 93% |
| 层数 | 23 | 4 | 83% |
| CVE 数量 | 47 (12 CRITICAL) | 0 | 100% |
| Dive 效率分 | 31.8% | 96.2% | 3x |
| 构建时间 | 45s (无缓存) | 12s (有缓存) | 73% |
六、.dockerignore:被忽视的第一道防线
很多团队 Dockerfile 写得很精致,却忘了配 .dockerignore。结果 COPY . . 把 .git、node_modules、.env、测试文件、文档全塞进了镜像构建上下文。这不只是镜像变大的问题——.env 文件里可能有数据库密码,Trivy 的密钥扫描会直接报警。
一份生产级 .dockerignore 模板:
# .dockerignore
# VCS
.git
.gitignore
.gitlab-ci.yml
.github/
# 依赖目录(构建阶段会重新安装)
node_modules/
vendor/
# 环境变量文件
.env
.env.*
*.pem
*.key
# 测试和文档
test/
tests/
spec/
docs/
*.md
LICENSE
# 构建产物
dist/
build/
target/
*.o
*.so
# IDE 和系统文件
.idea/
.vscode/
.DS_Store
Thumbs.db
# 日志
*.log
logs/
配好 .dockerignore 之后,构建上下文从 800MB 降到 12MB,docker build 的第一步"Sending build context to Docker daemon"瞬间快了十倍。
七、BuildKit 高级特性:缓存挂载与密钥注入
Docker 18.09+ 引入的 BuildKit 带来了两个非常有用的特性:缓存挂载(cache mount)和密钥挂载(secret mount)。它们能在不增加镜像层数的前提下加速构建和保护敏感信息。
7.1 缓存挂载加速依赖安装
# 启用 BuildKit
# docker build . --progress=plain
# 或 export DOCKER_BUILDKIT=1
# 语法声明必须放第一行
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
# npm 缓存挂载:不会残留在镜像中
RUN --mount=type=cache,target=/root/.npm \
npm install
# pip 缓存挂载
FROM python:3.12-slim
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# apt 缓存挂载
FROM ubuntu:22.04
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y curl
缓存挂载的好处是:第一次构建装依赖可能要 60 秒,第二次构建因为缓存命中只需要 3 秒。而且缓存不会进入最终镜像,不会增加镜像大小。
7.2 密钥注入:不在镜像里留痕迹
# Dockerfile 里用密钥(比如拉私有 npm 包)
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# 方式一:BuildKit secret mount(推荐)
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install
# 构建时传入密钥文件
# docker build --secret id=npmrc,src=$HOME/.npmrc .
这样 .npmrc 里的私钥只在构建时可用,构建完后镜像里完全找不到。Trivy 的密钥扫描也扫不出来,因为确实不存在。
八、完整 CI 流水线
把三件套串到一条流水线里。Hadolint 在 PR 阶段拦 Dockerfile 规范,Dive 在构建后检查效率,Trivy 在推送前做安全门禁:
# .github/workflows/docker-ci.yml
name: Docker CI Pipeline
on:
push:
paths: [Dockerfile, .dockerignore, src/**]
pull_request:
jobs:
lint:
name: Hadolint Dockerfile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
config: .hadolint.yaml
build-and-analyze:
name: Build + Dive + Trivy
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Dive efficiency check
run: |
curl -sL https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.tar.gz | tar xz
./dive myapp:${{ github.sha }} --ci --efficiency 0.90 --wastedSpace 0.20
- name: Trivy security scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
- name: Push (only on main)
if: github.ref == 'refs/heads/main'
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker tag myapp:${{ github.sha }} registry.com/myapp:latest
docker push registry.com/myapp:latest
九、常见陷阱与排错
| 陷阱 | 现象 | 解法 |
|---|---|---|
| .dockerignore 没写 | node_modules/.git 进了镜像 | 加 .dockerignore 排除 |
| RUN apt install 后不清理缓存 | apt 缓存占 200MB | 同一 RUN 里 rm -rf /var/lib/apt/lists/* |
| 多阶段 COPY 整个目录 | 构建产物外的东西也进了运行镜像 | 只 COPY --from=builder 精确路径 |
| Trivy 扫到 distroless 的漏洞 | distroless 不是零漏洞 | 用 --ignore-unfixed 过滤无修复的 |
| Dive CI 模式退出码不对 | --ci 参数没有生效 | 确认 Docker 环境有权限访问 daemon |
| Hadolint SC 规则报错 | ShellCheck 语法问题 | 在 .hadolint.yaml 里 ignored 掉 SC 规则 |
| Trivy 数据库拉不下来 | CI 环境网络受限 | 用 trivy --skip-db-update 或本地缓存 |
| alpine 镜像有 musl 兼容问题 | 某些 C 依赖在 alpine 上跑不了 | 换 debian-slim 或 distroless |
十、生产 Checklist
- 基镜像固定版本:用
node:20.11-alpine而非node:latest - 多阶段构建:构建阶段和运行阶段必须分离
- .dockerignore 完整:排除 .git、node_modules、.env、test/、docs/
- RUN 指令合并:多个 apt-get install 放一个 RUN 里
- 缓存清理:apt/yum/apk 安装后同一 RUN 里清理缓存
- 非 root 运行:创建专用用户,USER 指定
- Hadolint CI 门禁:PR 阶段拦截 Dockerfile 规范问题
- Dive 效率 > 90%:CI 模式效率分低于 0.90 就阻断
- Trivy 零 CRITICAL:推送前扫描,有 CRITICAL 就阻断
- 镜像签名:用 cosign 签名,部署时验签
- SBOM 生成:每次构建产出软件物料清单
- 定期重扫:已推送的镜像定期用最新漏洞库重扫
十一、工具对比与选型建议
| 工具 | 定位 | 何时用 | 替代品 |
|---|---|---|---|
| Hadolint | Dockerfile 静态检查 | 写 Dockerfile 时 | dockle |
| Dive | 分层分析与效率 | 构建后、推送前 | docker history |
| Trivy | 漏洞+密钥+IaC | 推送前、定期重扫 | Grype, Clair, Snyk |
| Grype | 纯漏洞扫描 | 只需 CVE 扫描时 | Trivy 子集 |
| Dockle | 最佳实践检查 | 轻量级替代 Hadolint | Hadolint |
| cosign | 镜像签名 | 推送后签名 | notation |
| Syft | SBOM 生成 | 每次构建后 | Trivy SBOM |
选型建议:Hadolint + Dive + Trivy 是覆盖面最广、维护成本最低的组合。如果你只需要 CVE 扫描,Grype 比 Trivy 更轻量。如果还要镜像签名和 SBOM,cosign + Syft 是 Trivy 的补充而非替代。
总结
Docker 镜像瘦身和安全不是一次性的工作,而是一条持续运行的流水线。Hadolint 从源头拦住 Dockerfile 规范问题,Dive 让你看清每一层浪费了什么,Trivy 在推送前把住安全最后一道关。三者各司其职,没有重叠。把这条流水线接进 CI,以后每次 PR 都自动检查——不需要靠人肉 review,不需要等安全团队事后通报。
记住一个原则:构建环境和运行环境必须隔离。编译器、包管理器、测试框架都不应该出现在生产镜像里。Multi-stage build 不是可选项,是基本功。
十二、真实案例:一次镜像瘦身实战复盘
去年我们团队有一个 Node.js 微服务,Docker 镜像长期维持在 1.4GB。每次部署拉取镜像就要两分钟,CI 流水线光 build 就要五分钟。安全团队也提了工单:12 个 HIGH 漏洞,2 个 CRITICAL。
第一步用 Hadolint 扫了 Dockerfile,报了 6 个 warning:基镜像用的 node:latest 没固定版本、apt 没加 --no-install-recommends、多个连续 RUN 没合并、CMD 用了 shell 格式。修完后 Hadolint 零 warning。
第二步上 multi-stage build。原来构建时需要 gcc 编译 node-gyp 依赖,运行时根本不需要。加了 builder 阶段,运行阶段直接用 node:20-alpine 只装产物。加完 .dockerignore 排除 node_modules 和 .git。
第三步用 Dive 分析。发现 /usr/local/lib/node_modules 下面有 340MB 是 devDependencies 残留——因为 npm install 装了全部依赖而不是只装 production 依赖。改成 npm ci --omit=dev 后这 340MB 就没了。
第四步用 Trivy 扫描。基镜像 node:20-alpine 之前有 2 个 CRITICAL(alpine 内的 libxml2 和 openssl 漏洞),换成最新 digest 的 alpine 3.19 后清零。npm 依赖里 lodash 4.17.4 有原型链污染漏洞,升级到 4.17.21 后解决。
最终结果:镜像从 1.4GB 降到 167MB,构建时间从 5 分钟降到 90 秒(缓存命中后 30 秒),CVE 从 14 个降到 0 个。整个改造花了一个下午,收益是长期的。
所以别等安全团队找上门才动手。把 Hadolint、Dive、Trivy 接进 CI,让工具替你守门,你的镜像自然又小又安全。