跳转到内容
123xiao | 无名键客

《Docker 镜像瘦身与构建加速实战:多阶段构建、缓存优化及安全扫描全流程指南》

字数: 0 阅读时长: 1 分钟

Docker 镜像瘦身与构建加速实战:多阶段构建、缓存优化及安全扫描全流程指南

很多团队在刚开始用 Docker 时,先解决的是“能跑起来”,然后很快就会碰到三个现实问题:

  1. 镜像太大:一次构建几百 MB,拉取半天,部署也慢。
  2. 构建太慢:代码改一行,依赖重新下载一遍,CI 时间直线上升。
  3. 镜像不安全:基础镜像老旧、系统包有漏洞、运行用户还是 root。

我自己第一次帮项目做镜像优化时,原本一个 Node 服务镜像接近 1.2GB,CI 单次构建接近 8 分钟。真正做完一轮梳理后,镜像缩到两百多 MB,构建时间也降了不少。你会发现,Docker 优化不是一招鲜,而是一套组合拳:多阶段构建 + 缓存设计 + 安全扫描 + 运行时收敛

这篇文章我就带你从零到一走一遍,目标不是讲概念,而是让你能直接把 Dockerfile 和流水线改起来。


背景与问题

先看一个很常见、也很“真实”的 Dockerfile:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

这个写法能跑,但问题很多:

  • COPY . . 太早,代码任何一点变化都会导致依赖层缓存失效
  • npm install 会把开发依赖也装进去
  • 构建工具、源码、缓存都留在最终镜像里
  • 基础镜像可能偏大
  • 默认 root 用户运行,安全风险高

典型后果:

  • 镜像体积越来越胖
  • CI/CD 构建速度越来越慢
  • 漏洞扫描一跑全是红
  • 线上镜像内容太多,不利于审计和回滚

前置知识与环境准备

建议你准备以下环境:

  • Docker 20.10+
  • 优先启用 BuildKit
  • 一台可以联网拉镜像的开发机
  • 可选:trivy 做安全扫描

启用 BuildKit:

export DOCKER_BUILDKIT=1

如果你使用 Docker Desktop,通常默认已开启。

验证版本:

docker version
docker buildx version

核心原理

这部分是整篇文章的骨架。理解后,你写 Dockerfile 就不会只靠“试错”。

1. 多阶段构建:把“构建环境”和“运行环境”拆开

很多语言项目在构建时需要:

  • 编译器
  • 包管理器
  • 构建工具
  • 源码
  • 临时缓存

但运行时真正需要的,往往只有:

  • 可执行文件
  • 编译产物
  • 少量运行时依赖

所以最佳实践是:前一阶段负责构建,后一阶段只负责运行

flowchart LR
    A[源码与依赖清单] --> B[构建阶段 Builder]
    B --> C[编译/打包产物]
    C --> D[运行阶段 Runtime]
    D --> E[最终瘦身镜像]

2. Docker 构建缓存:按“变化频率”安排指令顺序

Docker 会按层缓存,每条指令基本都会生成一层。
所以写 Dockerfile 时,一个核心原则是:

把变化少的内容放前面,把变化多的内容放后面。

比如 Node 项目中:

  • package.jsonpackage-lock.json 变化相对少
  • 业务源码变化频繁

所以应该先复制依赖清单,再安装依赖,最后复制源码。


3. 安全扫描:不是最后补救,而是构建流程的一部分

镜像安全问题通常来自三类:

  • 基础镜像已知漏洞
  • 系统包漏洞
  • 应用依赖漏洞

如果只在上线前临时扫一下,往往已经晚了。更合理的方式是把扫描放进 CI,让高危漏洞尽早暴露。


4. 运行时最小化:减的不只是体积,也是攻击面

镜像越大,不只是下载慢,还意味着:

  • 包越多,漏洞面越大
  • 排查时噪音越多
  • 运行环境越复杂

所以“瘦身”不是美化指标,而是性能和安全的共同目标。


实战场景:优化一个 Node.js 服务镜像

下面我们用一个典型的 Node.js Web 服务做演示。目录结构如下:

demo-app/
├── package.json
├── package-lock.json
├── src/
│   └── server.js
└── .dockerignore

示例应用代码

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker optimization demo",
  "main": "dist/server.js",
  "scripts": {
    "build": "mkdir -p dist && cp -r src/* dist/",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

src/server.js

const express = require('express');

const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({
    message: 'hello docker',
    hostname: process.env.HOSTNAME || 'unknown'
  });
});

app.listen(port, () => {
  console.log(`server running on ${port}`);
});

先看一个“低效版本”的 Dockerfile

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

它为什么慢、为什么大?

构建顺序如下:

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Builder
    participant Cache as 构建缓存
    Dev->>Docker: docker build
    Docker->>Docker: COPY . .
    Docker->>Cache: 检查缓存
    Note over Docker,Cache: 任意源码变动导致此层失效
    Docker->>Docker: npm install
    Docker->>Docker: npm run build
    Docker->>Docker: 生成最终镜像

问题非常直观:

  • 代码一改,COPY . . 层失效
  • npm install 重新执行
  • 所有构建工具保留在最终镜像
  • 开发依赖也被带入生产环境

优化一:用 .dockerignore 先挡掉无用内容

先不要急着改 Dockerfile,第一步是减少构建上下文。

.dockerignore

node_modules
npm-debug.log
dist
.git
.gitignore
Dockerfile
README.md
coverage
.env

为什么这一步很重要?

Docker 在构建前会先把上下文打包传给 daemon。
如果你本地 node_modules 很大、.git 很大、测试产物很多,那么还没开始构建,时间已经浪费掉了。

可以这样观察上下文大小:

docker build -t demo-app:raw .

注意看输出中的构建上下文传输大小。


优化二:重排 Dockerfile 指令,吃到缓存红利

先做一个“单阶段但缓存友好”的版本:

FROM node:18-slim

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY src ./src
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/server.js"]

这里做了什么优化?

  • node:18 换成了更轻量的 node:18-slim
  • 先复制 package*.json,保证依赖安装层更容易复用
  • npm ci 替代 npm install
  • 最终启动命令直接运行构建产物

为什么 npm ci 更适合 CI 和生产构建?

因为它:

  • 严格按照锁文件安装
  • 更稳定,可重复构建
  • 通常比 npm install 更适合流水线

不过这个版本还有问题:
如果 build 依赖开发工具,比如 TypeScript、Babel、Vite,那只装生产依赖可能会导致构建失败。所以更稳妥的方式还是多阶段构建。


优化三:多阶段构建,真正把镜像“瘦下来”

这是最推荐的版本。

# syntax=docker/dockerfile:1.4

FROM node:18-slim AS builder

WORKDIR /app

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY src ./src
RUN npm run build

FROM node:18-slim AS runtime

WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force

COPY --from=builder /app/dist ./dist

RUN useradd -r -s /bin/false appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 3000

CMD ["node", "dist/server.js"]

逐步拆解这个 Dockerfile

第 1 阶段:builder

FROM node:18-slim AS builder
  • 专门用于安装完整依赖、执行构建
  • 这个阶段里可以有开发依赖,不怕“脏”
RUN --mount=type=cache,target=/root/.npm npm ci

这一步是 BuildKit 的缓存挂载能力。
它和 Docker 层缓存不同:即使某层失效,也能复用 npm 下载缓存,减少重复拉包时间。


第 2 阶段:runtime

FROM node:18-slim AS runtime

这是最终对外发布的镜像。

RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

这一步只安装生产依赖,避免把开发工具带进去。

COPY --from=builder /app/dist ./dist

只从构建阶段拷贝产物,不拷源码、不拷构建缓存、不拷编译工具。

USER appuser

改为非 root 用户运行,是非常关键的一步。很多团队会做镜像瘦身,却忘了这一点。


构建与运行验证

构建镜像

docker build -t demo-app:optimized .

运行容器

docker run --rm -p 3000:3000 demo-app:optimized

访问接口

curl http://localhost:3000

预期输出:

{"message":"hello docker","hostname":"<container-id>"}

逐步验证清单

你可以按下面顺序验证这次优化是否真的生效:

  • .dockerignore 已过滤无关文件
  • Dockerfile 先复制依赖清单,再复制源码
  • 使用了 npm ci 而不是 npm install
  • 使用了多阶段构建
  • 最终镜像不包含源码与构建工具
  • 容器以非 root 用户运行
  • 已执行安全扫描
  • 已实际对比构建耗时与镜像大小

查看镜像大小:

docker images | grep demo-app

查看镜像层历史:

docker history demo-app:optimized

再进一步:构建缓存优化的几个实用技巧

1. 把依赖层和源码层分开

这是最常见也最有效的优化。

错误示例:

COPY . .
RUN npm ci

推荐示例:

COPY package*.json ./
RUN npm ci
COPY src ./src

2. 利用 BuildKit 缓存挂载

适用于 npm、pip、apt、go mod 等包管理器。

Node 示例:

RUN --mount=type=cache,target=/root/.npm npm ci

Python 示例:

RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

Go 示例:

RUN --mount=type=cache,target=/go/pkg/mod go mod download

3. 基础镜像不要盲目追求最小

很多人一看到“瘦身”就直接上 alpine,这不一定总是对。

alpine 的优点

  • 很小
  • 拉取快

但它的潜在问题

  • 使用 musl,某些二进制依赖兼容性不如 glibc
  • 构建原生模块时可能踩坑
  • 调试体验有时不如 slim

对中级开发者来说,我的建议是:

  • 通用 Web 服务优先尝试 slim
  • 对极致体积敏感、且兼容性已验证时再考虑 alpine
  • 静态编译语言可优先考虑 distroless 或 scratch

安全扫描实战

镜像优化不能只看体积,还要看安全状态。这里用 trivy 演示。

安装 Trivy

macOS:

brew install aquasecurity/trivy/trivy

或直接使用容器方式:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image demo-app:optimized

扫描本地镜像

trivy image demo-app:optimized

只关注高危和严重漏洞

trivy image --severity HIGH,CRITICAL demo-app:optimized

扫描结果怎么看?

重点看三类信息:

  • 漏洞来源是基础镜像还是应用依赖
  • 是否存在可修复版本
  • 严重等级是否达到阻断标准

在 CI 中接入安全扫描

以 GitHub Actions 为例:

name: docker-build-scan

on:
  push:
    branches: [main]

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image
        run: docker build -t demo-app:ci .

      - name: Run Trivy scan
        uses: aquasecurity/[email protected]
        with:
          image-ref: demo-app:ci
          format: table
          severity: HIGH,CRITICAL
          exit-code: 1

这里的关键是:

exit-code: 1

表示一旦扫描到高危或严重漏洞,流水线直接失败。
这一步很“硬”,但很有必要,否则扫描报告只是摆设。


镜像优化与安全流程总览

flowchart TD
    A[准备源码] --> B[配置 .dockerignore]
    B --> C[编写多阶段 Dockerfile]
    C --> D[启用 BuildKit 缓存]
    D --> E[构建镜像]
    E --> F[检查镜像层与体积]
    F --> G[运行功能验证]
    G --> H[Trivy 安全扫描]
    H --> I{是否存在高危漏洞}
    I -- 是 --> J[升级基础镜像或依赖]
    J --> E
    I -- 否 --> K[推送仓库并部署]

常见坑与排查

这一部分我尽量写得“现场一点”,因为这些问题真的是高频。

坑 1:多阶段构建后,应用启动时报找不到模块

现象:

Error: Cannot find module 'xxx'

常见原因:

  • 构建阶段装了完整依赖
  • 运行阶段只装生产依赖
  • 但你的运行代码实际依赖了一个被误判为开发依赖的包

排查方法:

  1. 检查 package.json 中依赖分类是否正确
  2. 确认启动时需要的包都在 dependencies 而不是 devDependencies
  3. 进入容器查看:
docker run --rm -it demo-app:optimized sh
ls -la node_modules

坑 2:缓存没有生效,构建还是很慢

现象:

  • 每次构建都重新下载依赖
  • npm ci 层总是失效

常见原因:

  • COPY . . 放在依赖安装前
  • 锁文件经常变化
  • 没启用 BuildKit
  • CI 机器是全新环境,没有远程缓存

排查方法:

echo $DOCKER_BUILDKIT

查看是否启用 BuildKit。

再检查 Dockerfile 顺序是否合理。

如果在 CI 中,希望跨任务复用缓存,可以进一步使用 buildx 的缓存导入导出能力。

示例:

docker buildx build \
  --cache-from=type=local,src=.buildx-cache \
  --cache-to=type=local,dest=.buildx-cache-new \
  -t demo-app:cache .

坑 3:换成 Alpine 后,构建或运行异常

现象可能包括:

  • 原生模块编译失败
  • 字体、时区、证书、glibc 兼容问题
  • 某些 npm 包行为异常

建议:

  • 优先切回 slim 验证问题是否消失
  • 如果必须使用 Alpine,明确补齐依赖包
  • 不要为了几十 MB 牺牲太多稳定性

我自己踩过这个坑:镜像是小了,但构建脚本和依赖兼容性问题把节省下来的时间全吐回去了。


坑 4:非 root 用户切换后没有权限

现象:

EACCES: permission denied

原因通常是:

  • 文件复制后属主还是 root
  • 应用在写日志、缓存、临时目录时没有权限

解决方式:

RUN useradd -r -s /bin/false appuser && chown -R appuser:appuser /app
USER appuser

必要时给明确的可写目录:

RUN mkdir -p /app/tmp && chown -R appuser:appuser /app/tmp

坑 5:安全扫描结果太多,不知道先修哪个

建议优先级:

  1. CRITICAL 且可修复
  2. HIGH 且可修复
  3. 基础镜像层漏洞
  4. 应用依赖漏洞
  5. 无修复版本的漏洞先评估缓解措施

实操上不要试图一次清零全部漏洞,先建立规则:

  • 新增漏洞不能比当前基线更糟
  • 高危漏洞必须在上线前修复或豁免备案

安全/性能最佳实践

下面这些建议,基本适用于大多数团队。

1. 固定基础镜像版本

不要总写:

FROM node:18-slim

更稳妥的是固定更具体的版本,例如:

FROM node:18.19.0-slim

这样可以减少“同样代码、不同时间构建出来不一样”的问题。


2. 使用最小必要运行时

如果应用只是跑编译后的 JS,就不要把整个源码、测试文件、文档都打进去。


3. 尽量使用非 root 用户

这是低成本高收益的安全动作。
如果你的应用确实需要绑定低端口或访问特殊资源,再单独评估权限策略。


4. 定期刷新基础镜像

即使代码没变,基础镜像里的系统包漏洞也会变化。
建议定期触发重建和扫描,而不是只在业务代码变更时构建。


5. 让缓存为你服务,但不要依赖“脏缓存”

缓存的目标是提速,不是掩盖问题。
如果你发现“只有我电脑能过,CI 过不了”,大概率就是本地缓存掩盖了依赖或构建问题。


6. 在性能、兼容性、安全之间做平衡

常见取舍可以概括为:

stateDiagram-v2
    [*] --> 选择基础镜像
    选择基础镜像 --> slim: 默认稳妥
    选择基础镜像 --> alpine: 极致体积优先
    选择基础镜像 --> distroless: 更小攻击面
    slim --> [*]
    alpine --> [*]
    distroless --> [*]

简单建议:

  • 追求开发效率slim
  • 追求极致体积alpine,但先验证兼容性
  • 追求生产安全distroless,但调试门槛更高

一份更接近生产可用的 Dockerfile 模板

下面给你一份可以直接改造项目的模板:

# syntax=docker/dockerfile:1.4

FROM node:18.19.0-slim AS builder

WORKDIR /app

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY src ./src
RUN npm run build

FROM node:18.19.0-slim AS runtime

WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
    && npm cache clean --force

COPY --from=builder /app/dist ./dist

RUN useradd -r -u 10001 -s /usr/sbin/nologin appuser \
    && chown -R appuser:appuser /app

USER 10001:10001

EXPOSE 3000

CMD ["node", "dist/server.js"]

配套 .dockerignore

node_modules
dist
.git
coverage
.env
npm-debug.log
Dockerfile
README.md

一个实用的排查命令清单

当你怀疑镜像太大、构建太慢、内容不对时,这些命令很有用。

查看镜像大小:

docker images

查看层历史:

docker history demo-app:optimized

进入容器排查:

docker run --rm -it demo-app:optimized sh

查看镜像详细信息:

docker inspect demo-app:optimized

查看构建详细日志:

docker build --progress=plain -t demo-app:optimized .

扫描安全漏洞:

trivy image demo-app:optimized

总结

如果你只记住三件事,我建议是这三条:

  1. 先用多阶段构建,把构建环境和运行环境分开
  2. 按变化频率设计 Dockerfile,最大化利用缓存
  3. 把安全扫描接入 CI,而不是上线前临时补课

一套比较稳妥的落地顺序是:

  • 第一步:补 .dockerignore
  • 第二步:重排 Dockerfile 的 COPYRUN
  • 第三步:引入多阶段构建
  • 第四步:启用 BuildKit 缓存
  • 第五步:改为非 root 用户运行
  • 第六步:接入 Trivy 扫描并设置阻断规则

最后给一个边界条件提醒:
不是所有项目都值得为“极致瘦身”付出很高的复杂度。如果你的应用本来构建就很快、镜像体积也可接受,那优先做缓存顺序优化 + 非 root + 安全扫描,收益通常最大。
而当你进入 CI 排队严重、跨地域部署频繁、镜像漏洞告警多的阶段,再把多阶段构建和更激进的瘦身策略全面铺开,会更划算。

如果你现在手头就有一个 Dockerfile,我建议你别只看文章,直接照着改一版,再跑一次 docker historytrivy image。很多优化效果,真的是一眼就能看出来。


分享到:

上一篇
《从抓包到补环境:中级开发者实战 Web 逆向中的签名参数还原与请求重放》
下一篇
《中级开发者如何用 RAG 构建企业知识库问答系统:从数据清洗、向量检索到效果评估》