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

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

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

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

很多团队把 Docker 用起来后,第一阶段通常是“能跑就行”。
但到了第二阶段,问题会非常集中地冒出来:

  • 镜像动不动几百 MB,拉取慢、发布慢
  • CI 构建越来越久,缓存命中率越来越差
  • 镜像里塞了编译工具、包管理器、调试命令,安全面过大
  • 同一个 Dockerfile 本地能构建,到了 CI/CD 就开始玄学失败
  • 明明只改了一行业务代码,却触发整套依赖重装

这些问题,多阶段构建(Multi-stage Build) 往往是最先该上的手段之一。但我要先说一句实话:
它不是“写两个 FROM 就自动瘦身”,而是要和构建上下文控制、缓存设计、基础镜像选择、最小权限运行一起用,效果才明显。

这篇文章我会带你从一个典型 Node.js 服务出发,做一套可运行、可验证的实战,把“镜像更小、构建更快、安全更稳”串成一条完整链路。


背景与问题

先看一个很常见的 Dockerfile 写法:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

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

这个写法的问题不止一个:

  1. 基础镜像偏大
    node:18 往往包含很多运行时并不需要的内容。

  2. 构建依赖和运行依赖混在一起
    TypeScript 编译、构建工具、测试依赖,全都被打进最终镜像。

  3. 缓存利用差
    COPY . . 太早,任何代码改动都会导致 npm install 重新执行。

  4. 安全性一般
    默认 root 用户运行,镜像内工具过多,被利用面更大。

  5. 上下文污染
    如果没有 .dockerignorenode_modules、日志、测试产物、Git 历史都可能被送进构建上下文。

换句话说,慢、大、不安全,常常不是单点问题,而是一套“默认写法”叠加出来的结果。


前置知识与环境准备

为了顺利跟着做,建议你本地具备:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 一个可运行的 Node.js 示例项目
  • 基础命令行能力:docker builddocker rundocker image ls

建议先开启 BuildKit:

export DOCKER_BUILDKIT=1

如果你用的是较新的 Docker Desktop,通常默认已经启用。


核心原理

1. 多阶段构建到底在解决什么

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

  • 前一阶段负责“造产物”
  • 后一阶段只负责“运行产物”

比如:

  • builder 阶段:安装完整依赖、执行编译
  • runner 阶段:只复制编译后的产物和运行所需的最小依赖

这样最终镜像就不会带上 gcc、make、测试框架、源码、缓存文件等“运行时不需要的东西”。

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

2. 缓存为什么总失效

Docker 构建是按层缓存的。
如果某一层输入变了,这一层以及后续层通常都要重新执行。

比如这段:

COPY . .
RUN npm install

只要项目里任何文件变动,COPY . . 的哈希就变,后面的 npm install 也就白缓存了。

更合理的思路是:

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

这样只有依赖清单变化时,才会重新安装依赖。


3. 瘦身不只是“换 alpine”

很多人第一反应是用 alpine。这个方向不算错,但不是万能答案。

  • alpine 镜像小
  • 但它使用 musl libc
  • 某些 Node 原生模块、Python/C 扩展、glibc 相关依赖可能踩坑

所以基础镜像选择要看场景:

  • 追求极致小体积:可优先评估 alpine
  • 追求兼容性与稳定性:可考虑 slim
  • 追求最小攻击面:可考虑 distroless,但调试难度会上升

4. 安全加固的本质

安全不是靠一句“别用 root”就结束。实际至少要看四件事:

  • 最终镜像中有没有多余工具
  • 是否使用非 root 用户运行
  • 是否固定基础镜像版本
  • 是否尽量减少依赖和系统包
flowchart TD
    A[镜像安全] --> B[减少内容]
    A --> C[降低权限]
    A --> D[固定依赖]
    A --> E[缩小攻击面]

    B --> B1[多阶段构建]
    B --> B2[删除缓存/临时文件]
    C --> C1[USER 非root]
    D --> D1[固定基础镜像Tag或Digest]
    E --> E1[选择更小运行时镜像]

实战代码(可运行)

下面我们以一个简单的 Node.js + TypeScript 服务为例。

项目结构

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

第一步:准备示例代码

src/server.ts

import express from "express";

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

app.get("/", (_req, res) => {
  res.json({
    ok: true,
    message: "hello docker multi-stage build"
  });
});

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

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage demo",
  "main": "dist/server.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.7.4",
    "typescript": "^5.6.2"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true
  }
}

第二步:先看一个“能用但不优”的版本

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

构建:

docker build -t demo-app:bad .

这个版本通常能跑,但问题很多。接下来我们一步步优化。


第三步:加上 .dockerignore

这是最容易被忽略、但收益很直接的一步。

.dockerignore

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

它的作用是:

  • 避免把无关文件送进构建上下文
  • 减少构建传输量
  • 减少缓存失效概率

我自己踩过一个坑:本地 node_modules 被复制进构建上下文后,和容器内环境不一致,导致“本地能构建,CI 不能跑”。
所以这一步真的别省。


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

这是本篇核心。

优化版 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-slim AS base
WORKDIR /app

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

FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

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

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

RUN useradd -r -u 1001 -g root appuser && chown -R appuser:root /app
USER appuser

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

这个 Dockerfile 做了哪些事

1)拆分了职责

  • deps:安装完整依赖,供构建用
  • build:编译 TypeScript
  • runner:只装生产依赖,复制编译产物

2)优化了缓存

先复制 package*.json,再安装依赖。
业务代码改了,不会立刻让依赖层失效。

3)使用 BuildKit 缓存挂载

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

这样 npm 缓存可复用,尤其在 CI 中能明显减少重复下载。

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

npm ci --omit=dev

避免把 TypeScript、类型定义、构建工具带进最终镜像。

5)切换为非 root 用户运行

USER appuser

这是非常基础但很重要的加固项。


第五步:构建与运行验证

构建镜像:

docker build -t demo-app:multi .

运行容器:

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

验证接口:

curl http://localhost:3000/

期望输出:

{"ok":true,"message":"hello docker multi-stage build"}

第六步:对比镜像体积与层信息

查看镜像:

docker image ls | grep demo-app

查看镜像层:

docker history demo-app:multi

如果你想更直观地分析,可以用:

docker inspect demo-app:multi

或者第三方工具 dive

dive demo-app:multi

dive 很适合查这类问题:

  • 哪些层体积最大
  • 哪些文件其实没必要进最终镜像
  • 是否有删除文件但体积仍保留在旧层的问题

进一步优化:更激进的瘦身方案

如果你已经完成基础优化,还想继续压缩体积,可以考虑以下变体。

方案一:使用 Alpine

FROM node:18-alpine AS base
WORKDIR /app

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

FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

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

COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist

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

优点:

  • 镜像通常更小

注意:

  • 如果依赖里有原生模块,可能出现编译或运行兼容性问题

方案二:Distroless 运行时镜像

如果你更关注攻击面收缩,可以考虑 distroless。

FROM node:18-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:18-slim AS prod-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=prod-deps /app/package.json ./package.json

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

优点:

  • 没有 shell、没有包管理器,攻击面更小

缺点:

  • 调试难度更高
  • 某些排障命令没法直接在容器里跑

建议边界
生产环境适合,开发环境未必方便。


构建流程全景图

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Deps as deps阶段
    participant Build as build阶段
    participant Run as runner阶段

    Dev->>Docker: docker build
    Docker->>Deps: 复制 package*.json
    Deps->>Deps: npm ci
    Docker->>Build: 复制源码
    Build->>Build: npm run build
    Docker->>Run: 安装生产依赖
    Build->>Run: 复制 dist
    Run-->>Dev: 生成最终镜像

常见坑与排查

这一节我尽量讲“真会遇到的坑”,不是只讲教科书式问题。

1. COPY . . 导致缓存雪崩

现象

只改了一个业务文件,结果 npm ci 又完整执行了一遍。

原因

依赖安装前复制了整个项目,任意文件变化都会让上一层失效。

解决

改成:

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

2. .dockerignore 没写,构建上下文过大

现象

构建开始时卡很久,或者日志里看到上下文传输几十 MB、几百 MB。

排查

构建时看日志:

docker build -t demo-app .

如果看到:

Sending build context to Docker daemon  350.4MB

那就要警惕了。

解决

.dockerignore,排除:

  • node_modules
  • .git
  • 日志
  • 测试产物
  • 本地编译结果

3. Alpine 下原生模块安装失败

现象

构建时报错,类似:

gyp ERR!

或运行时报动态库问题。

原因

某些模块依赖 glibc 或需要本地编译环境,Alpine 的 musl 环境不兼容。

解决

优先尝试:

  • 改用 node:18-slim
  • 如果必须 alpine,补齐构建工具链

例如:

RUN apk add --no-cache python3 make g++

但要注意,这些工具最好只出现在构建阶段,不要留在最终镜像里。


4. 非 root 用户运行后权限报错

现象

应用启动时报:

EACCES: permission denied

原因

复制进容器的文件仍属于 root,应用用户无权访问。

解决

复制后执行 chown,或在 COPY 时指定属主。

例如:

COPY --chown=1001:0 --from=build /app/dist ./dist

或者:

RUN chown -R appuser:root /app

5. 使用 distroless 后无法进入容器调试

现象

你想执行:

docker exec -it <container> sh

但发现根本没有 shell。

原因

distroless 就是故意不带这些工具。

解决思路

  • 开发环境用 slim
  • 生产环境用 distroless
  • 或保留一个 debug 版镜像用于排障

这不是 bug,而是取舍。


6. npm installnpm ci 混用导致结果不稳定

建议

在 CI/CD 和镜像构建里,优先用:

npm ci

原因:

  • 按 lock 文件精确安装
  • 更可重复
  • 更适合自动化流水线

安全/性能最佳实践

这里给一份我比较认可的“够用且不折腾”的清单。

安全最佳实践

1. 使用非 root 用户运行

USER 1001

如果应用不需要特权端口、也不需要操作宿主资源,尽量不要 root。

2. 固定基础镜像版本

不要只写:

FROM node:latest

建议至少固定主版本甚至具体 tag:

FROM node:18-slim

更进一步可使用 digest 锁定。

3. 最终镜像只保留运行时必须内容

不要把下面这些带进生产镜像:

  • 测试工具
  • 编译工具
  • 包管理缓存
  • 源码(如果运行时不需要)
  • 文档和样例数据

4. 定期扫描漏洞

常见方式:

docker scout quickview demo-app:multi

或者使用:

  • Trivy
  • Grype
  • Snyk

5. 减少系统包安装

每多一个包,就多一份维护成本和攻击面。
实战里我会先问自己一句:这个包是构建必须,还是运行必须?


性能最佳实践

1. 把“变化慢”的层放前面

典型顺序:

  1. 基础镜像
  2. 依赖清单
  3. 安装依赖
  4. 源码复制
  5. 编译

这样缓存命中率会更高。

2. 善用 BuildKit 缓存挂载

例如 npm:

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

对于 apt、pip、go mod 等也有类似优化空间。

3. 控制镜像层中无效文件

不要先生成大文件,再在后续层删除。
因为删掉不代表前一层体积不存在

4. 尽量减少构建上下文

上下文越大:

  • 传输越慢
  • 哈希计算越慢
  • 缓存越容易失效

5. 结合 CI 缓存策略

如果你的 CI 平台支持远程缓存,构建速度会再提升一个档次。
尤其是依赖安装和编译阶段,收益非常可观。


逐步验证清单

你可以按下面的顺序做一次自测。

基础验证

  • 容器能正常启动
  • curl 返回预期结果
  • 镜像中不存在源码目录(如果运行时不需要)
  • 镜像中不存在 devDependencies

体积验证

  • 优化后镜像体积明显小于单阶段版本
  • docker history 中没有异常大层
  • .dockerignore 生效,构建上下文明显变小

安全验证

  • 容器不是 root 用户运行
  • 基础镜像不是 latest
  • 最终镜像不包含编译工具链
  • 已完成一次漏洞扫描

一个更实用的生产模板

如果你不想每次都从头组织,下面这份可以作为 Node.js 服务的通用模板:

# syntax=docker/dockerfile:1.4

FROM node:18-slim AS base
WORKDIR /app

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

FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM base AS prod-deps
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./

RUN useradd -r -u 1001 -g root appuser && chown -R appuser:root /app
USER appuser

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

这个模板的特点是:

  • 结构清晰
  • 缓存友好
  • 最终镜像不混入 devDependencies
  • 默认非 root 运行
  • 适合作为中小型 Node 服务的起点

总结

如果只记住一句话,我希望是这句:

多阶段构建不是“高级写法”,而是生产 Dockerfile 的基础能力。

它解决的不是一个问题,而是一串连锁问题:

  • 让镜像更小
  • 让构建更快
  • 让缓存更稳
  • 让运行环境更干净
  • 让安全基线更容易落实

真正落地时,建议按下面顺序推进:

  1. 先补 .dockerignore
  2. 把依赖安装从源码复制中解耦
  3. 改为多阶段构建
  4. 最终镜像只保留生产依赖和产物
  5. 切换非 root 用户
  6. 根据兼容性在 slim / alpine / distroless 之间选择

最后给一个边界建议:

  • 如果你团队当前还在“镜像能跑就行”阶段,优先上 slim + 多阶段 + 非 root
  • 如果你对体积极敏感,再评估 alpine
  • 如果你对攻击面和合规要求更高,再考虑 distroless

别一上来追求“最小”,先追求可维护、可复用、可排障
我自己的经验也是这样:先把构建链路做稳,再把体积做到合理,最后再做极限优化。


分享到:

上一篇
《Node.js 中基于 Worker Threads 与消息队列的 CPU 密集型任务处理实战》
下一篇
《Java 中基于 CompletableFuture 与线程池的异步任务编排实战:性能优化与异常处理策略》