Tools

Docker 镜像瘦身与安全扫描实战指南:Dive、Trivy 与 Hadolint 的完整工具链

✎ -- 字 🕐 -- 分钟
字号

Docker 镜像瘦身与安全扫描实战指南:Dive、Trivy 与 Hadolint 的完整工具链

Docker镜像瘦身与安全扫描工具链封面图

你有没有遇到过这种情况:写完一个简单的 Go HTTP 服务,docker build 完一看镜像 1.2GB。推到 Registry 上传了三分钟,拉取又三分钟。更要命的是安全团队扫了一下,47 个 CVE,其中 12 个 CRITICAL。生产环境谁敢用?

问题不在于 Docker 本身,而在于大多数开发者写 Dockerfile 时根本没有意识到镜像分层、构建上下文和安全基镜像的重要性。今天这篇文章就用三个工具——Dive(分层分析)、Trivy(漏洞扫描)、Hadolint(Dockerfile 静态检查)——从编写到推送构建一条完整的 CI 安全门禁。

Docker镜像优化与安全扫描流水线

一、为什么需要三件套

先来看一个反面教材。这是一个真实项目中常见的"能跑就行"的 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 CVETrivy 检出
没有 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 条:

规则级别含义
DL3006warning必须指定基镜像版本标签,禁止 latest
DL3008warningapt-get install 必须固定版本号
DL3015infoapt-get install 加 --no-install-recommends
DL3059info多个连续 RUN 应合并以减少层数
DL3025warningCMD/ENTRYPOINT 用 JSON 数组格式
DL4006warningset -o pipefail 管道失败处理
DL3018warningapk add 必须固定版本号
DL3013warningpip install 必须固定版本号
DL3042warningCOPY 多个源时用目录而非通配符
SC2086infoShellCheck:变量引用要加双引号

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, dnfubuntu CVE-2024-1234
语言依赖npm, pip, gem, cargo, go modlodash 4.17.4 Prototype Pollution
IaC 配置Dockerfile, K8s YAML, Terraform容器以 root 运行
密钥泄露AWS key, private key, tokenAKIA*** 在环境变量
许可证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 GB89 MB93%
层数23483%
CVE 数量47 (12 CRITICAL)0100%
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

  1. 基镜像固定版本:用 node:20.11-alpine 而非 node:latest
  2. 多阶段构建:构建阶段和运行阶段必须分离
  3. .dockerignore 完整:排除 .git、node_modules、.env、test/、docs/
  4. RUN 指令合并:多个 apt-get install 放一个 RUN 里
  5. 缓存清理:apt/yum/apk 安装后同一 RUN 里清理缓存
  6. 非 root 运行:创建专用用户,USER 指定
  7. Hadolint CI 门禁:PR 阶段拦截 Dockerfile 规范问题
  8. Dive 效率 > 90%:CI 模式效率分低于 0.90 就阻断
  9. Trivy 零 CRITICAL:推送前扫描,有 CRITICAL 就阻断
  10. 镜像签名:用 cosign 签名,部署时验签
  11. SBOM 生成:每次构建产出软件物料清单
  12. 定期重扫:已推送的镜像定期用最新漏洞库重扫

十一、工具对比与选型建议

工具定位何时用替代品
HadolintDockerfile 静态检查写 Dockerfile 时dockle
Dive分层分析与效率构建后、推送前docker history
Trivy漏洞+密钥+IaC推送前、定期重扫Grype, Clair, Snyk
Grype纯漏洞扫描只需 CVE 扫描时Trivy 子集
Dockle最佳实践检查轻量级替代 HadolintHadolint
cosign镜像签名推送后签名notation
SyftSBOM 生成每次构建后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,让工具替你守门,你的镜像自然又小又安全。