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

《Docker 多阶段构建与镜像瘦身实战:为中型项目建立高效、可维护的生产镜像》

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

Docker 多阶段构建与镜像瘦身实战:为中型项目建立高效、可维护的生产镜像

中型项目做容器化,最容易走到一个尴尬阶段:能跑,但镜像又大又慢;能发版,但一查漏洞一堆;开发方便,生产却不够干净。
我自己第一次给团队整理 Dockerfile 的时候,最典型的问题就是:

  • 一个镜像里把编译工具、源码、测试脚本、调试命令全塞进去了
  • 镜像体积从几百 MB 到 1GB+,拉取很慢
  • CI 构建越来越久,缓存命中率也不稳定
  • 线上镜像里居然还有 gccgitcurl,安全面过大
  • 改一行业务代码,就导致整个依赖层失效重建

这篇文章我不讲太“概念化”的东西,而是带你从中型项目的生产视角,把一个“能跑的 Dockerfile”升级为一个高效、可维护、适合上线的生产镜像方案


背景与问题

先说一个很常见的中型项目场景:

  • 后端服务:Node.js / Java / Go / Python 任一种
  • 有前端静态资源构建
  • 依赖较多,构建链复杂
  • 需要在 CI/CD 中频繁构建
  • 生产环境希望镜像尽量小、启动尽量快、风险尽量低

很多团队最开始会写出这样的 Dockerfile:

FROM node:18

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

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

这份配置的问题非常典型:

  1. 源码和依赖一起复制,改任何文件都会导致 npm install 重新执行
  2. 开发依赖和生产依赖都打进镜像
  3. 构建工具链遗留在运行镜像中
  4. 上下文过大.git、测试文件、文档可能都进了镜像
  5. 安全边界模糊,默认 root 运行,基础镜像偏重

如果项目还要跑前端打包,那情况通常更糟:Node、构建工具、产物、源代码、缓存,全部堆在一个镜像里。


前置知识与环境准备

本文默认你已经会:

  • 使用基础 Docker 命令
  • 理解 Dockerfile 常见指令:FROMCOPYRUNCMD
  • 能在本地安装 Docker 20+ 版本

建议环境:

  • Docker Engine >= 20.x
  • 启用 BuildKit(推荐)
  • 一个简单 Node.js 示例项目

启用 BuildKit:

export DOCKER_BUILDKIT=1

如果你在 CI 中使用,也建议明确开启。


核心原理

多阶段构建的本质,是把“构建环境”和“运行环境”拆开。

  • 构建阶段:安装编译工具、下载依赖、执行打包
  • 运行阶段:只复制最终运行所需文件

这样做的价值非常直接:

  • 镜像体积更小
  • 依赖更少,漏洞面更小
  • 结构更清晰,维护更容易
  • 可以针对不同阶段做缓存优化

一张图看懂多阶段构建

flowchart LR
    A[源码目录] --> B[构建阶段 builder]
    B --> C[安装依赖]
    C --> D[执行编译/打包]
    D --> E[产物 dist/ + 生产依赖]
    E --> F[运行阶段 runtime]
    F --> G[生成生产镜像]

为什么单阶段构建容易变胖

flowchart TD
    A[单阶段镜像] --> B[基础运行时]
    A --> C[编译工具 gcc/git]
    A --> D[全部源码]
    A --> E[测试文件]
    A --> F[开发依赖]
    A --> G[构建缓存]
    A --> H[最终产物]

你会发现,真正上线需要的,往往只有:

  • 运行时
  • 编译后的产物
  • 最小化生产依赖
  • 必要配置文件

其余大部分都不该留在最终镜像里。


一个中型项目该怎么拆阶段

这里我给一个适合中型 Node.js 服务的思路。即使你不是 Node 项目,也可以照着这个拆法理解:

  1. base 阶段:统一基础环境
  2. deps 阶段:只安装依赖,最大化缓存
  3. builder 阶段:复制源码并构建产物
  4. runtime 阶段:仅保留运行需要的文件

这样的结构比“只有 builder + runtime”更适合中型项目,因为后续维护成本更低。


实战代码(可运行)

下面我们做一个可运行示例,项目结构大概如下:

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

1)示例应用代码

src/server.js

const http = require("http");

const port = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(
    JSON.stringify({
      message: "hello from multi-stage docker image",
      pid: process.pid,
      env: process.env.NODE_ENV || "development"
    })
  );
});

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

2)package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage build demo",
  "main": "dist/server.js",
  "scripts": {
    "build": "mkdir -p dist && cp src/server.js dist/server.js",
    "start": "node dist/server.js"
  },
  "dependencies": {},
  "devDependencies": {}
}

3).dockerignore

这个文件经常被低估,但它对镜像瘦身和构建速度帮助非常大。

node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
test
tests
*.md

如果没有 .dockerignore,Docker 会把很多无关文件一起发送到构建上下文中。项目一大,这个损耗会非常明显。

4)推荐版 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
RUN npm run build

FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

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

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

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

逐步拆解这份 Dockerfile

第一步:抽出 base 阶段

FROM node:18-alpine AS base
WORKDIR /app

这里统一工作目录和基础运行环境,后面各阶段都复用它。

第二步:单独安装依赖

FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

为什么这样写?

因为 package.json 和业务源码变化频率不同。
只复制依赖描述文件,意味着:

  • 代码改动时,不必重新安装依赖
  • Docker 层缓存命中率更高
  • CI 构建更快

npm cinpm install 更适合生产和 CI,因为它更可预测,严格依赖 lock 文件。

第三步:构建产物

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
RUN npm run build

这一阶段可以做任何“重活”:

  • TS 编译
  • 前端打包
  • 压缩静态资源
  • 代码生成
  • 单元测试(可选,不建议放最终构建链尾部)

第四步:构建最终运行镜像

FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

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

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

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

这里是核心:
最终镜像中没有源码目录,没有构建脚本,没有构建缓存,也没有开发依赖。


构建与运行

构建镜像

docker build -t demo-app:multi .

运行容器

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

验证服务

curl http://localhost:3000

预期输出:

{"message":"hello from multi-stage docker image","pid":1,"env":"production"}

再进一步:为中型项目做缓存优化

如果你的项目依赖安装很慢,可以结合 BuildKit 的缓存挂载。

带缓存的依赖安装示例

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

这类优化在 CI 上很有价值,特别是依赖多、构建频繁的中型项目。


生产镜像设计思路:不是越小越好,而是越“干净”越好

很多人一开始会执着于“把镜像做到最小”,这没错,但我更建议你关注两个维度:

  1. 是否只保留运行所需内容
  2. 是否足够可维护

比如有些团队为了极致小体积,直接换成 scratch,结果:

  • 没有 shell,排障困难
  • 缺少证书,HTTP 请求异常
  • 缺少时区或基础文件,兼容性问题频出

所以对中型项目来说,比较务实的路线通常是:

  • 优先考虑 alpine 或精简运行时镜像
  • 保证构建、调试、运维三者平衡
  • 逐步收缩,而不是一步极限压缩

常见坑与排查

这部分我按“现象 → 原因 → 处理方式”来讲,比较贴近真实排障。

1. 构建成功,运行时报模块缺失

常见报错:

Error: Cannot find module 'xxx'

原因

通常是以下几种:

  • 只复制了 dist,但运行时仍依赖某些 node_modules
  • 构建阶段有 devDependencies,运行阶段没有正确安装 production 依赖
  • 构建工具把依赖外置了,但运行镜像未包含

排查方法

先进入容器看文件:

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

查看目录:

ls -R

检查 package.json 与产物引用关系,确认最终镜像中是否包含实际运行需要的依赖。

建议

  • Node 项目优先使用 npm ci --omit=dev
  • 如果是打包后完全自包含产物,再考虑不带 node_modules
  • 不要盲目只拷贝 dist

2. 镜像没变小,反而构建更慢

原因

常见是 Dockerfile 顺序写反了:

COPY . .
RUN npm ci

这样只要源码有任何变动,依赖层缓存就失效。

正确写法

COPY package.json package-lock.json ./
RUN npm ci
COPY . .

经验建议

先复制“变化少”的文件,再复制“变化快”的文件,这几乎是 Docker 缓存优化的基本法则。


3. Alpine 镜像更小,但某些依赖编译失败

原因

alpine 使用 musl libc,某些原生模块或预编译包兼容性不如 Debian/Ubuntu 系列镜像。

排查方式

看构建日志,关注:

  • node-gyp
  • python
  • make
  • g++
  • 原生扩展模块编译报错

处理方式

  • 如果业务依赖原生扩展较多,优先考虑 node:18-slim
  • 需要构建工具时,只放在 builder 阶段
  • 不要为了解决 builder 的问题,把整套编译工具带进 runtime

4. 容器启动后权限异常

典型报错:

EACCES: permission denied

原因

切换为非 root 用户后,某些文件属主不对。

解决方式

复制文件时指定属主:

COPY --chown=node:node --from=builder /app/dist ./dist

或者在构建阶段处理好目录权限。


5. .dockerignore 没配好,构建上下文巨大

现象

执行构建时会看到类似输出:

Sending build context to Docker daemon  582.3MB

问题

这说明你把很多没必要的文件也打包传给 Docker 了。

处理方式

补充 .dockerignore,重点排除:

  • node_modules
  • .git
  • 日志
  • 测试产物
  • 文档
  • 本地缓存目录

安全/性能最佳实践

这一部分是最值得落地到团队规范里的。

1)使用非 root 用户运行

USER node

如果镜像默认没有合适用户,也可以自行创建:

RUN addgroup -S app && adduser -S app -G app
USER app

这样即便应用被利用,攻击者能直接拿到的权限也更有限。


2)明确区分构建依赖和运行依赖

多阶段构建不是为了“好看”,而是为了把不该进入生产的内容隔离出去,比如:

  • 编译器
  • git
  • curl
  • 测试工具
  • 调试脚本
  • 临时文件

这是镜像瘦身,也是安全加固。


3)固定基础镜像版本

不要只写:

FROM node:18-alpine

更稳妥的做法是固定到更具体的版本。
否则上游基础镜像更新后,可能导致构建结果波动。

例如:

FROM node:18.17-alpine3.18

如果你的供应链要求更严格,还可以固定 digest。


4)减少层数不是第一目标,减少无效内容才是

很多人会执着于把多个 RUN 合并成一行。
这有一定意义,但真正的大头通常在:

  • 依赖是否合理安装
  • 是否把无关文件复制进去了
  • 是否留下了构建产物之外的垃圾

也就是说,先做内容收缩,再做层数优化


5)在 CI 中加入镜像检查

建议至少做两类检查:

  • 漏洞扫描
  • 镜像体积与层分析

常用命令示例:

docker image ls
docker history demo-app:multi

查看镜像层历史:

docker history --no-trunc demo-app:multi

这样你可以很快看出,究竟是哪一层把镜像“吃胖了”。


6)用健康的目录结构约束镜像内容

中型项目里,我很建议统一约定:

  • /app/dist:编译产物
  • /app/config:配置模板
  • /app/scripts:仅构建阶段使用,不进生产
  • /app/tmp:运行时临时目录

目录清晰后,多阶段复制会容易很多,也更不容易把无关内容带进 runtime。


一个更完整的构建流程图

sequenceDiagram
    participant Dev as 开发者
    participant CI as CI系统
    participant Builder as builder阶段
    participant Runtime as runtime阶段
    participant Registry as 镜像仓库

    Dev->>CI: 提交代码
    CI->>Builder: docker build
    Builder->>Builder: 安装依赖
    Builder->>Builder: 编译/打包
    Builder->>Runtime: 复制最小运行产物
    Runtime->>Registry: 推送生产镜像
    Registry->>Dev: 提供部署镜像

逐步验证清单

如果你想确认自己的 Dockerfile 是否真的达到了“生产可用”的标准,可以按下面这份清单检查。

构建层面

  • 依赖安装是否放在源码复制之前
  • 是否使用多阶段构建
  • 是否存在 .dockerignore
  • 构建工具是否只存在于 builder 阶段
  • 是否使用 lock 文件进行确定性构建

运行层面

  • 生产镜像中是否只保留运行所需文件
  • 是否使用非 root 用户
  • 是否设置 NODE_ENV=production 或等价生产环境变量
  • 是否暴露了正确端口
  • 启动命令是否足够直接、可观测

安全层面

  • 基础镜像是否尽量精简
  • 版本是否固定
  • 是否定期做镜像漏洞扫描
  • 是否避免把源码、密钥、调试工具带进生产镜像

什么时候不必过度追求多阶段

也要说一句边界条件,不是所有项目都值得把 Dockerfile 写得很复杂。

以下场景可以适度简化:

  • 很小的内部工具
  • 无构建步骤的简单服务
  • 生命周期很短的临时项目
  • 纯开发环境用途镜像

但只要你满足下面任一条,我都建议认真做多阶段:

  • 项目需要上线生产
  • CI 每天频繁构建
  • 镜像要跨环境分发
  • 团队成员较多,需要统一规范
  • 依赖链复杂,漏洞治理有要求

总结

多阶段构建真正解决的,不只是“镜像大”这个表面问题,而是整个生产镜像的工程质量问题:

  • 更小:减少无关文件和依赖
  • 更快:提高缓存命中,降低构建与拉取时间
  • 更稳:构建与运行环境分离,行为更可预测
  • 更安全:减少工具链、降低攻击面
  • 更易维护:阶段职责清晰,团队更容易协作

如果你准备在中型项目里落地,我建议直接从这三步开始:

  1. 先补 .dockerignore
  2. 再把依赖安装和源码复制拆开
  3. 最后落地 builder/runtime 双阶段,逐步扩展成多阶段

不要一上来就追求“极限最小镜像”,先做到干净、稳定、可复用
对大多数团队来说,这比省下几十 MB 更有长期价值。


分享到:

上一篇
《Web3 实战:用 Solidity 与 Ethers.js 构建并部署一个支持角色权限控制的 DAO 治理合约》
下一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-147》