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

《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固》

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

Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固

很多团队第一次把应用容器化时,往往能“跑起来”,但镜像会越来越胖,构建也越来越慢,安全风险还不低。
我自己就踩过类似的坑:一个看起来不复杂的 Node.js 服务,最终镜像接近 1GB,CI 每次构建都像在“搬家”,上线前安全扫描还扫出一堆不该存在的构建工具链。

这篇文章不讲太多概念堆砌,而是从一个中级开发者最常见的场景出发,带你把一个“能用但不优雅”的 Dockerfile,逐步改造成:

  • 构建更快
  • 镜像更小
  • 运行更安全
  • 更适合 CI/CD 缓存复用

重点会放在 Docker 多阶段构建镜像瘦身容器安全加固 的组合实践上。


背景与问题

先看一个常见但不理想的 Dockerfile。

FROM node:18

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

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

它的问题非常典型:

  1. 构建依赖和运行依赖混在一起
    • npm install 时需要的一些工具、缓存、编译依赖,都会留在最终镜像里。
  2. 源码全量复制过早
    • COPY . . 放在前面,任何一个文件变化都会导致依赖层缓存失效。
  3. 镜像体积偏大
    • 基础镜像、依赖缓存、测试文件、源码、构建产物全都打包进去。
  4. 默认 root 用户运行
    • 这在生产环境里并不理想。
  5. 安全面扩大
    • 不必要的软件包越多,漏洞暴露面越大。

如果你的项目还包含前端打包、Go 编译、Java 构建、Python wheel 构建等步骤,这些问题会进一步放大。


前置知识与环境准备

建议你提前准备:

  • Docker 20.10+
  • 开启 BuildKit(推荐)
  • 一个 Node.js 示例项目
  • 基本理解 Docker layer/caching

启用 BuildKit

Linux/macOS:

export DOCKER_BUILDKIT=1

也可以直接这样执行:

DOCKER_BUILDKIT=1 docker build -t demo-app .

核心原理

多阶段构建的核心思路其实很朴素:

把“构建环境”和“运行环境”拆开,只把运行真正需要的内容复制到最终镜像。

1. 构建阶段与运行阶段分离

比如一个 Node.js 应用:

  • 构建阶段
    • 安装完整依赖
    • 执行构建
    • 生成 dist/
  • 运行阶段
    • 只保留 dist/
    • 只安装生产依赖
    • 不带编译工具链、不带源码、不带测试文件

2. 利用层缓存优化构建速度

Docker 的缓存粒度是“层”。
所以 Dockerfile 写法会直接影响构建速度。

典型优化思路:

  • 先复制 package.jsonpackage-lock.json
  • 先安装依赖
  • 再复制业务代码

这样当你只改了业务代码时,依赖层还能复用缓存。

3. 基础镜像越小,攻击面通常越小

不是说越小一定越安全,但通常:

  • 软件包越少
  • 工具链越少
  • shell 越少
  • 包管理器越少

最终暴露的攻击面也就越小。

4. 最终镜像只保留“运行时最小集合”

这通常包括:

  • 二进制文件或构建产物
  • 必需运行时依赖
  • 最小权限用户
  • 必需环境变量和入口命令

flowchart LR
    A[源码与依赖清单] --> B[构建阶段 builder]
    B --> C[安装依赖]
    C --> D[执行编译/打包]
    D --> E[产出 dist 或二进制]
    E --> F[运行阶段 runner]
    F --> G[仅复制运行所需文件]
    G --> H[更小更安全的生产镜像]

一个坏例子到好例子的演进

为了更贴近实际,我们用一个 Node.js 应用来演示。

假设项目结构如下:

.
├── src
│   └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile

示例应用代码

package.json

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

src/index.js

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.json({
    message: 'hello from docker multi-stage build',
    time: new Date().toISOString()
  });
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`server listening on ${port}`);
});

实战代码(可运行)

第一步:先写一个基础版多阶段 Dockerfile

# syntax=docker/dockerfile:1

FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build

FROM node:18-alpine AS runner

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

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

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

这个版本已经比单阶段好在哪?

  • 构建和运行分离
  • 构建产物从 builder 拷贝到 runner
  • 运行镜像不再包含完整源码
  • npm ci 更适合 CI 场景,依赖安装更可控

构建与运行

docker build -t demo-app:basic .
docker run --rm -p 3000:3000 demo-app:basic

验证:

curl http://localhost:3000

第二步:进一步优化缓存命中率

如果构建时经常因为源码小改动导致依赖重装,那基本就是 Dockerfile 层次写得不够讲究。

更推荐的写法:

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app

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

FROM node:18-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY src ./src
RUN npm run build

FROM node:18-alpine AS runner
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

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

这里做了什么?

  • 单独拆出 deps 阶段,依赖层更容易复用
  • 用 BuildKit 的缓存挂载加速 npm 下载
  • 业务代码变化不会影响依赖安装层

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as Layer/BuildKit Cache
    participant Registry as npm Registry

    Dev->>Docker: docker build
    Docker->>Cache: 检查 package-lock 对应缓存
    alt 命中缓存
        Cache-->>Docker: 返回依赖层
    else 未命中
        Docker->>Registry: 下载依赖
        Registry-->>Docker: 返回依赖包
        Docker->>Cache: 保存依赖缓存
    end
    Docker->>Docker: 复制源码并构建 dist
    Docker->>Docker: 组装 runner 最终镜像

第三步:增加镜像瘦身动作

仅做多阶段还不够,很多镜像肥胖来自“上下文污染”和“多余文件被 COPY 进去”。

.dockerignore 非常关键

创建 .dockerignore

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

为什么它这么重要?

因为 docker build 时,Docker 会先把构建上下文发送给守护进程。
如果你把 node_modules.git、测试报告、日志文件都发过去:

  • 传输慢
  • 缓存容易失效
  • 镜像构建上下文变大
  • 可能误带敏感信息

我见过最夸张的情况是:不是镜像本身太大,而是 .git 历史和本地构建垃圾文件把上下文拖爆了。


第四步:做运行时安全加固

现在继续把 Dockerfile 改得更生产化一些。

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY src ./src
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
    && npm cache clean --force \
    && addgroup -S appgroup \
    && adduser -S appuser -G appgroup \
    && chown -R appuser:appgroup /app

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

USER appuser

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

这里重点看三件事

1)非 root 用户运行

USER appuser

这是非常值得保留的安全默认项。

2)生产环境变量明确声明

ENV NODE_ENV=production

这会影响很多框架和依赖的运行行为。

3)不把构建工具链带进最终镜像

最终镜像只包含:

  • 生产依赖
  • dist
  • 运行用户
  • 启动命令

逐步验证清单

每做完一次优化,不要只“感觉更好了”,最好做可验证检查。

1. 看镜像体积

docker images | grep demo-app

2. 看镜像层历史

docker history demo-app:basic

3. 进入容器确认运行内容

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

检查:

ls -lah
whoami

4. 验证是否为非 root 用户

docker run --rm demo-app:basic whoami

5. 检查是否只保留必要文件

docker run --rm -it demo-app:basic sh -c "find /app -maxdepth 2 -type f | sort"

6. 对比构建耗时

第一次构建:

time DOCKER_BUILDKIT=1 docker build -t demo-app:test .

修改 src/index.js 再次构建:

time DOCKER_BUILDKIT=1 docker build -t demo-app:test .

如果依赖层复用了,第二次通常会明显更快。


常见坑与排查

坑 1:多阶段构建后,应用跑不起来

典型现象

容器启动后报错:

Error: Cannot find module ...

常见原因

  • 运行阶段没有安装生产依赖
  • 只复制了 dist,但运行时还依赖配置文件或静态资源
  • 构建产物路径不对

排查方法

docker run --rm -it demo-app:basic sh
find /app -maxdepth 3 -type f | sort

重点确认:

  • dist/index.js 是否存在
  • node_modules 是否存在
  • 静态资源是否被复制到最终镜像

坑 2:缓存一直失效,构建还是很慢

常见原因

  • COPY . . 太早
  • .dockerignore 没写好
  • package-lock.json 经常被无意义改动
  • CI 没有开启 BuildKit 或缓存策略

建议排查顺序

  1. 看 Dockerfile 中 COPY 顺序
  2. 看上下文里是否带了无关文件
  3. 看依赖文件是否稳定
  4. 看 CI 是否启用了 cache-from / BuildKit

坑 3:Alpine 很小,但某些依赖安装失败

为什么会这样?

alpine 使用 musl libc,而有些 Node 原生模块、Python 扩展、系统依赖更偏向 glibc 环境。
这时你可能会遇到:

  • 编译失败
  • 运行时报动态库问题
  • 性能或兼容性异常

怎么选?

  • 追求极致小体积:优先试 alpine
  • 追求兼容性和稳定性:可考虑 debian-slim

不要为了“理论更小”硬上 Alpine,结果在业务里被兼容性反噬。


坑 4:镜像小了,但安全扫描还是一堆漏洞

原因可能是

  • 基础镜像本身就有 CVE
  • 依赖版本老旧
  • 运行镜像仍保留不必要包
  • 镜像没及时更新重建

排查建议

先看基础镜像:

docker pull node:18-alpine

再做漏洞扫描,例如使用 Trivy:

trivy image demo-app:basic

如果漏洞主要来自基础镜像,就不要只盯着业务代码。


坑 5:容器退出太快

现象

docker run 后容器秒退。

常见原因

  • CMD 写错
  • 主进程没常驻
  • 应用启动失败但日志没看到

查看日志

docker logs <container_id>

也可以前台运行:

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

flowchart TD
    A[构建/运行异常] --> B{是构建失败还是运行失败}
    B -->|构建失败| C[检查 Dockerfile COPY 顺序]
    C --> D[检查依赖安装日志]
    D --> E[检查 Alpine 兼容性]
    B -->|运行失败| F[检查 dist 与 node_modules]
    F --> G[查看容器日志]
    G --> H[进入容器确认文件与权限]

安全/性能最佳实践

下面这部分我建议你当成日常 checklist 来用。

1. 优先使用多阶段构建

适用范围很广:

  • Node.js 打包
  • Go 编译
  • Java Maven/Gradle 构建
  • Python wheel 构建
  • 前端静态资源构建

原则只有一句:

能不进最终镜像的东西,就别进去。


2. 固定基础镜像版本,避免“漂移”

不要只写:

FROM node:latest

更推荐:

FROM node:18-alpine

如果你对可重复构建要求更高,可以进一步固定 digest。


3. 使用 npm ci 而不是 npm install

在 CI/CD 和镜像构建场景中:

  • npm ci 更快
  • 更可预测
  • 严格依赖 lock 文件

4. 让依赖安装层尽可能稳定

推荐顺序:

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

而不是:

COPY . .
RUN npm install

后者几乎是缓存杀手。


5. 控制构建上下文

.dockerignore 要当成必须项,不是可选项。

尤其要排除:

  • .git
  • node_modules
  • 本地缓存
  • 测试报告
  • 临时文件
  • 密钥与 .env

6. 用非 root 用户运行

这是容器安全基线之一。

USER appuser

如果应用需要写文件,提前处理好目录权限,而不是上线后临时用 root 顶着跑。


7. 定期扫描镜像漏洞

推荐把镜像扫描放进 CI:

  • Trivy
  • Grype
  • Docker Scout

至少做到:

  • 构建时扫描
  • 发布前扫描
  • 基础镜像定期刷新

8. 不要在镜像里保存敏感信息

不要这样做:

ENV DB_PASSWORD=123456

更好的方式:

  • 运行时注入环境变量
  • 使用 Secret 管理
  • 在编排平台中托管凭据

9. 根据场景选择基础镜像

一个实用判断表:

场景推荐
极致小体积、依赖简单alpine
兼容性优先debian-slim
单二进制程序scratch 或 distroless
安全要求更高distroless

如果你的服务是 Go 静态编译产物,distrolessscratch 往往非常香。
如果是 Node/Python 这种运行时依赖较多的应用,就要兼顾调试成本与兼容性。


10. 把“可观测”和“可调试”留在流程里,而不是留在镜像里

很多人为了方便排障,会想把 curlbashvim 都装进生产镜像。
短期看方便,长期看会让镜像越来越重、风险越来越大。

更好的思路是:

  • 生产镜像保持最小化
  • 调试通过临时 debug 容器完成
  • 日志、指标、trace 走外部体系

一个更接近生产可用的最终版本

下面给出一个比较平衡的版本:兼顾缓存、体积和安全。

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY src ./src
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
    && npm cache clean --force \
    && addgroup -S appgroup \
    && adduser -S appuser -G appgroup

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

RUN chown -R appuser:appgroup /app
USER appuser

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

搭配的 .dockerignore

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

构建:

DOCKER_BUILDKIT=1 docker build -t demo-app:prod .

运行:

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

什么时候不必过度优化?

这点也很重要。不是所有项目都要追求极限瘦身。

以下场景可以先“够用就好”:

  • 内部工具,构建频率低
  • 镜像体积对交付影响不大
  • 团队当前更需要稳定交付而非极致压缩
  • Alpine 兼容性带来的成本大于收益

换句话说:

优化是为业务服务,不是为了把镜像数字卷到最好看。

建议优先级通常是:

  1. 先做多阶段构建
  2. 再做缓存优化
  3. 再做最小权限和漏洞扫描
  4. 最后再考虑更激进的镜像极致瘦身

总结

如果你只记住这篇文章里的三件事,我建议是这三条:

  1. 构建环境和运行环境分离
    • 多阶段构建是镜像瘦身的核心抓手。
  2. Dockerfile 顺序决定缓存效率
    • 先拷依赖清单,再装依赖,最后拷源码。
  3. 瘦身不只是为了体积,更是为了安全和交付效率
    • 更小的镜像通常意味着更少的攻击面、更快的拉取与部署。

对中级开发者来说,最实用的落地路径不是一步到位追求完美,而是按下面顺序逐步演进:

  • 把单阶段改成多阶段
  • .dockerignore
  • 调整 COPY 顺序
  • 用 BuildKit 缓存
  • 改为非 root 运行
  • 加入漏洞扫描

只要这几步走稳,你的 Docker 镜像质量通常就会有一个肉眼可见的提升。


分享到:

上一篇
《Web3 中级实战:基于 Solidity 与 The Graph 构建可查询的链上积分系统》
下一篇
《大模型应用落地指南:从 RAG 知识库搭建到检索效果优化实战》