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

《Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全交付》

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

Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全交付

很多团队一开始用 Docker,都会经历一个“能跑就行”的阶段:Dockerfile 先写出来,镜像先打包成功,服务先上线。等项目越来越大,问题就会一起冒出来:

  • 镜像动不动就几百 MB,甚至 1GB+
  • 构建速度越来越慢,CI 等得人发呆
  • 线上镜像里带着编译工具、包管理器、调试命令,安全面很大
  • 同样的代码,只改一行,结果又从头构建一遍
  • 开发环境能跑,生产环境却因为缺依赖、权限、证书、架构差异出问题

这篇文章我会从一个可运行的 Node.js 示例出发,带你一步一步把一个“能跑但粗糙”的镜像,优化成一个更适合生产交付的镜像。重点不是背概念,而是搞清楚:

  1. 为什么多阶段构建能瘦身
  2. 为什么调整 Dockerfile 顺序就能加速
  3. 如何把安全和可运维一起考虑进去

背景与问题

先说一个典型场景。

很多人最早写出来的 Dockerfile,大概是这样的:

FROM node:20

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

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

它的问题很集中:

  • node:20 默认不是最小镜像,基础体积偏大
  • COPY . . 太早,导致任何代码变动都会让依赖层缓存失效
  • 构建依赖和运行依赖混在一起
  • 源码、测试文件、文档、.git,可能全被塞进镜像
  • 默认 root 用户运行,生产风险更高

如果项目中还有 TypeScript、Webpack、pnpm、编译型依赖(比如 sharpbcryptcanvas),镜像只会更臃肿,构建也会更慢。

一个更现实的问题链路

你会发现这不是“镜像大一点”那么简单,而是一串连锁反应:

flowchart TD
    A[Dockerfile 粗放写法] --> B[缓存命中率低]
    A --> C[构建工具进入运行镜像]
    A --> D[上下文过大]
    B --> E[CI 构建慢]
    C --> F[攻击面扩大]
    D --> G[镜像上传下载慢]
    E --> H[交付效率下降]
    F --> I[生产安全风险上升]
    G --> H

所以,多阶段构建不是“花活”,而是生产交付里非常实用的一种基本功。


前置知识与环境准备

本文示例假设你有这些环境:

  • Docker 24+
  • BuildKit 已启用
  • 一台能运行容器的 Linux/macOS/Windows 机器
  • 基本了解:
    • Docker 镜像和容器区别
    • Node.js 项目结构
    • npm cinpm install 的区别

建议开启 BuildKit:

export DOCKER_BUILDKIT=1

或者直接这样构建:

DOCKER_BUILDKIT=1 docker build -t demo-app .

核心原理

1. 多阶段构建的本质

多阶段构建的核心思想很简单:

把“构建环境”和“运行环境”拆开,只把最终运行需要的产物带进生产镜像。

比如:

  • 第一阶段:安装依赖、编译代码
  • 第二阶段:只复制 dist/、必要依赖和配置
  • 最终镜像不包含编译器、缓存、测试工具、源码

这会直接带来两个收益:

  • 镜像更小
  • 攻击面更小

2. 为什么 Dockerfile 顺序会影响构建速度

Docker 的每一步都是一层。只要某层输入变了,后续层大概率都要重建。

所以我们通常会这样做:

  1. 先复制 package.json / package-lock.json
  2. 先安装依赖
  3. 再复制业务源码
  4. 最后执行构建

这样如果你只是改了业务代码,而依赖没变,npm ci 这一层可以直接复用缓存。

3. 瘦身不只是换个小基础镜像

瘦身常见有 4 个维度:

  • 减少无关文件进入镜像
  • 减少运行时不需要的依赖
  • 选择更小的基础镜像
  • 减少层和缓存垃圾

这几个手段往往要一起用,单独做一个,收益有限。


一个完整的构建流程图

flowchart LR
    A[源码目录] --> B[依赖阶段 deps]
    A --> C[构建阶段 builder]
    B --> C
    C --> D[运行阶段 runtime]
    D --> E[生产镜像]
    
    B1[package.json/package-lock.json] --> B
    C1[源码复制] --> C
    C2[npm run build] --> C
    C3[产物 dist] --> D
    B2[node_modules] --> D

实战代码(可运行)

下面我们用一个最小可运行的 Node.js + TypeScript 服务做演示。

第一步:准备项目结构

目录如下:

demo-app/
├── src/
│   └── index.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile

src/index.ts

import http from "http";

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

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
  res.end(
    JSON.stringify({
      message: "hello docker multi-stage build",
      pid: process.pid,
      time: new Date().toISOString()
    })
  );
});

server.listen(port, "0.0.0.0", () => {
  console.log(`server running at http://0.0.0.0:${port}`);
});

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "private": true,
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {},
  "devDependencies": {
    "@types/node": "^20.14.10",
    "typescript": "^5.5.4"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

生成锁文件

npm install

第二步:先看一个“普通写法”的 Dockerfile

FROM node:20

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

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

它能跑,但问题前面已经说过了。


第三步:改造成多阶段构建

推荐版 Dockerfile

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm-slim AS deps
WORKDIR /app

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

FROM node:20-bookworm-slim AS builder
WORKDIR /app

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

FROM node:20-bookworm-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

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
EXPOSE 3000
CMD ["node", "dist/index.js"]

这个版本做了什么优化?

1)拆成三个阶段

  • deps:安装完整依赖
  • builder:执行编译
  • runtime:只安装生产依赖并复制编译产物

2)最大化缓存命中

先复制 package.json 和锁文件,再安装依赖。只要依赖没变,这一层就能复用。

3)运行时只保留生产依赖

npm ci --omit=dev 会把 devDependencies 排除掉。像 TypeScript 这种构建期工具不会进入最终镜像。

4)避免 root 运行

USER node 是一个很实用的生产习惯。不是绝对安全,但比默认 root 好很多。


第四步:加上 .dockerignore

这一步非常容易被忽略,但收益很直接。

.dockerignore

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

为什么它重要?

因为 docker build 会把构建上下文发给 Docker daemon。你不忽略这些文件,就算 Dockerfile 不复制,它们也可能先被打包上传,拖慢构建过程。

我自己以前踩过一个坑:项目目录里有个本地导出的测试数据文件,几百 MB,结果每次 build 都慢得离谱。查了半天,最后发现就是构建上下文太大。


第五步:构建并运行

构建镜像

docker build -t demo-app:multi-stage .

运行容器

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

验证接口

curl http://127.0.0.1:3000

预期输出:

{"message":"hello docker multi-stage build","pid":1,"time":"2026-09-30T13:46:40.000Z"}

第六步:逐步验证清单

如果你想确认“真的变小了、真的更合理了”,可以按这个顺序验证。

1)看镜像大小

docker images | grep demo-app

2)看镜像层历史

docker history demo-app:multi-stage

3)进入容器检查运行内容

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

然后看目录:

ls -la

你应该只看到较少的运行时文件,比如:

  • dist/
  • package.json
  • package-lock.json
  • 生产依赖对应的 node_modules

而不是整份源码仓库。

4)验证非 root 用户

docker run --rm demo-app:multi-stage id

你会看到当前不是 root。


构建阶段与运行阶段的关系

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Deps as deps 阶段
    participant Builder as builder 阶段
    participant Runtime as runtime 阶段

    Dev->>Docker: docker build
    Docker->>Deps: 复制 package*.json
    Deps->>Deps: npm ci
    Docker->>Builder: 复制 node_modules + 源码
    Builder->>Builder: npm run build
    Docker->>Runtime: 安装生产依赖
    Builder-->>Runtime: 复制 dist
    Runtime-->>Dev: 输出最终生产镜像

常见坑与排查

这部分我尽量讲“真会遇到的坑”,不是只列概念。

坑 1:COPY . . 放太前,导致缓存完全失效

现象

你只改了一行业务代码,结果依赖重新安装。

原因

COPY . . 把所有文件都提前带入镜像,只要任意文件变化,这一层 hash 就变了,后续 RUN npm ci 也会失效。

解决

改成:

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

如果是更严谨的生产写法,就按前面的多阶段示例拆分。


坑 2:最终镜像启动时报“找不到模块”

现象

容器启动失败,类似:

Error: Cannot find module 'xxx'

常见原因

  • 运行阶段没装生产依赖
  • 某些包被错误放进 devDependencies
  • 只复制了 dist/,但运行时还依赖额外静态文件

排查方法

进入容器看:

docker run --rm -it demo-app:multi-stage sh
ls -la
ls -la node_modules

检查 package.json 中该依赖是否在 dependencies 而不是 devDependencies


坑 3:用了 Alpine,结果原生依赖出问题

很多教程喜欢上来就说“换 alpine 更小”。这话没错,但不总是合适。

现象

某些 Node 模块在 alpine 上编译失败,或运行时报 libc 相关错误。

原因

Alpine 基于 musl libc,而很多预编译二进制包默认更偏向 glibc 环境。

建议

  • 如果你的依赖里有原生模块,优先试 debian slim 系列
  • 体积和兼容性之间,别只盯着“最小”

比如本文示例使用的是:

FROM node:20-bookworm-slim

它通常是一个更稳妥的折中。


坑 4:镜像里泄露敏感文件

现象

.env、私钥、CI 配置文件被打进镜像

原因

  • .dockerignore 没写
  • 直接 COPY . .
  • 构建时把 secrets 写死在 Dockerfile 中

解决

  • .dockerignore 排除敏感文件
  • 用运行时环境变量或 Secret 管理系统注入配置
  • 不要在 Dockerfile 里写明文凭证

坑 5:权限问题导致容器无法启动

现象

切到 USER node 后,应用启动时报没有权限。

原因

复制进去的文件归属仍是 root,应用又需要写日志、缓存、临时文件。

解决

按需修改所有权:

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

如果应用要写入某个目录,也提前创建并赋权。


安全/性能最佳实践

这一节我不只列“应该做什么”,也说一下适用边界。

1. 优先使用多阶段构建

这是最值得默认启用的做法,尤其适合:

  • Java / Go / Node.js / Rust / 前端构建产物项目
  • 需要编译但运行期很轻的服务

边界条件
如果是非常简单的脚本型项目,多阶段收益可能没那么大,但依然建议保留构建与运行分离的思路。


2. 基础镜像选“小而稳”,不要只盯最小

推荐顺序通常是:

  1. 官方镜像
  2. slim 版本优先
  3. alpine 只在确认兼容时使用

一个简单判断思路

  • 有原生依赖、图像处理、加密库:先用 slim
  • 纯静态二进制或依赖非常简单:可以考虑更极致的小镜像

3. 固定依赖版本,优先用锁文件

在 CI 和生产交付里,可重复构建非常重要。

推荐:

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

而不是:

RUN npm install

因为 npm ci 更适合自动化环境,行为也更稳定。


4. 尽量减少镜像中不必要的工具

运行镜像里通常不需要:

  • gcc / make / python 编译工具链
  • git
  • curl(不是绝对)
  • vim / less / bash 调试工具

这些工具留在构建阶段就好。


5. 使用非 root 用户运行

这不是银弹,但能降低容器被突破后的权限范围。

推荐:

USER node

如果是自定义用户,也可以这样:

RUN addgroup --system app && adduser --system --ingroup app app
USER app

6. 控制层缓存,提升 CI 构建速度

如果你的 CI 平台支持 BuildKit 缓存,可以显著改善依赖安装速度。

例如 npm cache mount:

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

这在依赖很多的项目里很实用。


7. 做镜像扫描,但别迷信“零漏洞”

生产镜像最好接入扫描工具,比如:

  • Trivy
  • Docker Scout
  • Grype

但要注意:

  • 扫描结果要结合实际可利用性判断
  • 不要为了“清零告警”频繁换不稳定基础镜像
  • 优先修复高危、可利用、暴露面的漏洞

8. 控制容器运行时能力

如果部署平台支持,建议进一步限制:

  • 只读根文件系统
  • 限制 Linux capabilities
  • 限制 CPU / 内存
  • 配合 seccomp / AppArmor / SELinux

这已经超出 Dockerfile 本身,但对“安全交付”非常关键。


一个更贴近生产的小改进版本

如果你还想把权限处理做得更细,可以用下面这个版本:

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:20-bookworm-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
    && npm cache clean --force \
    && chown -R node:node /app

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

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

方案取舍:什么时候该继续瘦,什么时候该停

很多人学会瘦身后,容易进入另一个极端:为了多省几十 MB,把 Dockerfile 变得特别复杂。

我的建议是:

值得继续优化的情况

  • CI 构建明显慢
  • 镜像拉取耗时已影响发布
  • 安全扫描暴露大量无关组件
  • 节点磁盘和网络成本明显偏高

可以先停一停的情况

  • 当前镜像已经足够稳定、体积合理
  • 项目依赖复杂,换基础镜像风险高
  • 团队对容器排障经验还不够,过度极限优化反而影响维护

换句话说,生产可维护性优先于“极限最小”


总结

把这篇文章的重点收拢成几条,你在项目里基本就能落地了:

  1. 先分离构建阶段和运行阶段

    • 这是多阶段构建的核心收益点
  2. 把依赖安装层前置,提升缓存命中率

    • 先复制锁文件,再安装依赖,再复制源码
  3. .dockerignore 控制构建上下文

    • 这一步很便宜,但收益很大
  4. 生产镜像只保留运行必需内容

    • 编译工具、源码、测试文件不要带进去
  5. 优先选官方 slim 镜像,谨慎使用 Alpine

    • 兼容性比“最小体积”更重要
  6. 以非 root 用户运行,并配合镜像扫描

    • 做到基础安全收敛

如果你现在就准备改项目,我建议按这个顺序动手:

  • 第一步:补 .dockerignore
  • 第二步:重排 Dockerfile,让依赖层可缓存
  • 第三步:改成多阶段构建
  • 第四步:切非 root 运行
  • 第五步:接入镜像扫描和 CI 缓存

这套优化做完,通常你会同时得到三样东西:更快的构建、更小的镜像、更稳的生产交付。而这三件事,几乎每个团队都会长期受益。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》