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

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

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

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

很多团队刚开始用 Docker 时,镜像能跑就算成功。可项目一旦进入持续集成、灰度发布、跨环境交付,你很快会遇到几个典型问题:

  • 镜像越来越大:几百 MB 到几个 GB,拉取慢、分发慢、部署慢
  • 构建越来越慢:代码改一行也要重新装一遍依赖
  • 镜像里东西太多:编译工具、缓存、测试文件、密钥、包管理器都打进去了
  • 安全基线薄弱:直接 root 运行、基础镜像过大、攻击面不清晰

这篇文章我不打算只讲概念,而是带你从“一个普通可运行 Dockerfile”,一步步优化到更适合生产环境的版本。重点放在三件事:

  1. 用多阶段构建拆分构建期与运行期
  2. 用缓存和层设计提升构建速度
  3. 建立最基本可执行的镜像安全基线

本文面向有一定 Docker 使用经验的开发者。如果你已经会写基本的 Dockerfile、知道 docker builddocker run,那就可以直接往下看。


背景与问题

先说一个很常见的真实场景。一个 Node.js 服务最开始的 Dockerfile 往往长这样:

FROM node:18

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

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

它的问题其实不少:

  • COPY . . 太早,导致代码一变,依赖缓存失效
  • npm install 会安装开发依赖,运行时其实不需要
  • 构建工具链和源码都保留在最终镜像中
  • 基础镜像较大
  • 默认以 root 身份运行
  • .git、测试文件、文档、构建缓存等可能都被打进镜像

结果就是:构建慢、镜像大、风险高

我们先看一下优化目标。

flowchart TD
    A[原始 Dockerfile] --> B[分离依赖与源码复制顺序]
    B --> C[启用多阶段构建]
    C --> D[仅复制运行产物]
    D --> E[使用更小基础镜像]
    E --> F[非 root 运行]
    F --> G[减少攻击面与体积]

对中级开发者来说,真正重要的不是“记住几条优化口诀”,而是理解背后的构建原理。理解了之后,不只是 Node.js,Go、Java、Python、前端静态站点都能套用。


前置知识与环境准备

建议你的环境至少满足以下条件:

  • Docker 20.10+
  • 建议启用 BuildKit
  • 一台可联网机器,能拉取基础镜像
  • 示例项目使用 Node.js 18

启用 BuildKit 的方式:

export DOCKER_BUILDKIT=1

或者临时构建时加:

DOCKER_BUILDKIT=1 docker build -t demo-app .

为什么建议开启 BuildKit?因为它在缓存复用、挂载缓存目录、多阶段构建体验上都更好,实际构建速度通常能明显提升。


核心原理

1. Docker 镜像为什么会变大

Docker 镜像本质上是分层文件系统。每一条 RUNCOPYADD 大概率都会生成一层。层不是简单覆盖,而是叠加。

这意味着:

  • 你安装了很多工具,后面删掉,也不一定真正“变小”
  • 如果把大量无关文件 COPY 进去,即使后面删除,它们也可能已经进入镜像层历史
  • Dockerfile 指令顺序直接影响缓存命中率

2. 多阶段构建解决什么问题

多阶段构建的核心思想是:

在前面的阶段完成编译、打包、测试;在最后的阶段只保留运行所需的最小产物。

比如:

  • builder 阶段:安装依赖、编译源码
  • runtime 阶段:只复制 dist/ 和生产依赖,甚至只复制单个二进制文件

这样能带来三个直接收益:

  • 镜像更小
  • 攻击面更小
  • 构建职责更清晰

3. 缓存命中为什么重要

Docker 构建不是每次都从零开始。它会根据指令和上下文判断能否复用缓存。

最典型的优化是:

  1. 先复制依赖清单
  2. 安装依赖
  3. 再复制业务代码

因为日常开发中,代码变化频率远高于依赖变化频率。

如果顺序写反了,缓存就很难命中。

4. 运行时镜像不该承担构建职责

这是我自己早期最容易忽略的一点:运行镜像只应该负责运行

也就是说,最终镜像中通常不应该存在:

  • gcc、make、git、curl 之类构建工具
  • 测试文件
  • 源码(如果运行只需要编译产物)
  • 包管理器缓存
  • 调试工具
  • 明文密钥

一张图看懂多阶段构建

sequenceDiagram
    participant Dev as 开发者
    participant Builder as builder 阶段
    participant Runtime as runtime 阶段

    Dev->>Builder: 复制 package*.json
    Builder->>Builder: 安装依赖
    Dev->>Builder: 复制源码
    Builder->>Builder: 执行构建 npm run build
    Builder->>Runtime: 复制 dist/ 与生产依赖
    Runtime->>Runtime: 以非 root 用户启动

实战代码(可运行)

下面我们用一个简单的 Node.js Web 服务做演示。你可以直接照着创建文件并运行。

目录结构

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

第一步:准备一个最小可运行服务

package.json

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

src/index.js

const express = require('express');

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

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

app.get('/health', (req, res) => {
  res.status(200).send('ok');
});

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

先在本地生成锁文件:

npm install

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

FROM node:18

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

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

构建:

docker build -t demo-app:bad .

运行:

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

访问:

curl http://127.0.0.1:3000/

虽然能跑,但这个版本适合作为对照组,不适合作为生产基线。


第三步:改成多阶段构建

下面是一个更推荐的版本。

Dockerfile

# syntax=docker/dockerfile:1.4

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

ENV NODE_ENV=production
WORKDIR /app

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 addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

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

这个版本做了几件事:

  • 使用 builder 阶段构建产物
  • 最终镜像只复制 dist
  • npm ci 保证依赖安装更稳定
  • 使用缓存挂载加速 npm 包下载
  • 设置 NODE_ENV=production
  • 创建非 root 用户运行服务

第四步:编写 .dockerignore

这是很多人会漏掉的关键文件。它对构建速度和体积都很有帮助。

.dockerignore

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

为什么重要?

因为 Docker 构建时会先把构建上下文发送给 Docker daemon。你目录里文件越多,构建上下文越大,上传越慢,也更容易把不该打包的内容带进去。


第五步:构建并验证

构建镜像:

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

运行镜像:

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

验证接口:

curl http://127.0.0.1:3000/
curl http://127.0.0.1:3000/health

查看镜像大小:

docker images | grep demo-app

查看镜像层历史:

docker history demo-app:latest

如果你同时构建 demo-app:baddemo-app:latest,通常能明显看到优化版镜像更小、层更干净。


逐步验证清单

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

  1. 服务能正常启动
  2. 接口返回正常
  3. 镜像大小下降
  4. 修改源码后重建,依赖层是否命中缓存
  5. 容器内是否不存在构建源码或无关文件
  6. 容器是否以非 root 运行
  7. 健康检查接口是否可用于编排系统探活

例如检查运行用户:

docker run --rm demo-app:latest id

如果输出的不是 root,说明配置生效。

也可以进入容器查看内容:

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

然后检查 /app 下只有运行必需文件。


核心优化点拆解

1. npm ci 优于 npm install

在 CI/CD 或镜像构建场景里,优先考虑:

npm ci

原因:

  • 更适合基于锁文件的可重复构建
  • 行为更稳定
  • 通常更快

2. 先复制依赖清单,再复制源码

推荐:

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

不推荐:

COPY . .
RUN npm ci

前者只要 package-lock.json 没变,依赖安装层就可以复用缓存。

3. builder 和 runtime 分离

如果你在 builder 阶段用了很多工具,没关系。只要最终阶段不复制进去,它们就不会进入最终运行镜像。

4. 尽量选择合适的基础镜像

常见选择:

  • node:18:通用,体积偏大
  • node:18-alpine:更小,适合大部分场景
  • distroless:更小更安全,但调试成本更高

边界条件也要说清楚:
不是所有应用都适合 Alpine。某些依赖会遇到 musl 与 glibc 兼容问题,尤其是原生扩展或特定二进制依赖。如果你碰到诡异运行错误,先排查基础镜像兼容性。


常见坑与排查

这部分我尽量写得“像在旁边陪你排查”,因为多阶段构建第一次上手时,坑点还挺集中。

坑 1:构建阶段能成功,运行阶段启动失败

典型现象:

  • Error: Cannot find module ...
  • 启动时找不到编译产物

排查思路:

  1. 检查 builder 阶段产物路径是否正确
  2. 检查 COPY --from=builder 路径是否写对
  3. 检查最终 CMD 指向的文件是否存在

例如:

COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

可以进入最终镜像验证:

docker run --rm -it demo-app:latest sh
ls -R /app

坑 2:缓存完全没命中,构建总是很慢

最常见原因:

  • 提前 COPY . .
  • .dockerignore 没写,导致上下文频繁变化
  • 锁文件经常变动
  • BuildKit 没启用

可以看构建日志里每一层是否 CACHED。如果依赖层反复重建,先检查 Dockerfile 指令顺序。


坑 3:镜像已经用了多阶段,为什么还是很大

常见原因:

  • 运行阶段又重新安装了很多不必要依赖
  • 最终阶段仍然基于很大的基础镜像
  • 静态资源、模型文件、日志文件被复制进来了
  • .dockerignore 缺失

可以用下面命令观察:

docker history demo-app:latest

如果某一层异常大,基本就能定位到问题指令。


坑 4:容器里执行权限异常

如果你切换到非 root 用户,可能遇到:

  • 无法读取某些文件
  • 无法写日志目录
  • 端口绑定失败

排查建议:

  1. 检查文件所有权
  2. 检查应用是否尝试写入只读目录
  3. 避免绑定 1024 以下端口
  4. 必要时在构建阶段调整权限

例如:

RUN chown -R appuser:appgroup /app
USER appuser

不过也别一上来就全量 chmod 777,那只是把问题藏起来。


坑 5:Alpine 很小,但某些依赖装不上

这是多阶段构建中非常现实的问题。你会看到:

  • 原生模块编译失败
  • 二进制库缺失
  • 运行时报动态链接错误

解决思路:

  • 构建阶段用 Debian/Ubuntu 系镜像,运行阶段再选更小镜像
  • 如果兼容性差,别强行 Alpine,直接用 slim
  • 对原生依赖明确安装所需系统库

有时候,“稍大一点但稳定”比“极限压缩但脆弱”更适合生产。


安全/性能最佳实践

这一节给你一套比较务实的基线,不追求教科书式完美,但足够大部分项目落地。

1. 使用非 root 用户运行

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

收益:

  • 降低容器逃逸或应用漏洞带来的破坏面
  • 满足很多安全审计的基础要求

2. 固定基础镜像版本

不建议:

FROM node:latest

建议:

FROM node:18-alpine

更进一步可以固定到更具体版本,减少不可预期变更。


3. 减少镜像中无关内容

  • .dockerignore
  • 只复制必要目录
  • 不要把 .env、私钥、Git 历史带进去

如果有敏感信息,应该通过运行时注入,而不是写进镜像。


4. 只安装生产依赖

RUN npm ci --omit=dev

如果运行时不需要开发依赖,就不要带进去。


5. 合理合并命令,但不要为“层数洁癖”牺牲可维护性

很多人喜欢把所有命令写成一行。确实能少几层,但可读性会下降。我的建议是:

  • 对清理缓存、安装系统包这类强耦合操作可合并
  • 对业务逻辑步骤尽量保持清晰

比如:

RUN apk add --no-cache curl

就比“安装后再删缓存”的写法更自然。


6. 增加健康检查

可以在编排系统外,也做一层容器自检。

HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/health || exit 1

不过注意:
如果你的基础镜像里没有 wgetcurl,这条命令会失败。要么安装工具,要么使用应用内部机制配合平台探活。


7. 用扫描工具做镜像安全检查

常见工具如:

  • Trivy
  • Docker Scout

例如用 Trivy 扫描:

trivy image demo-app:latest

这一步很重要。因为“镜像小”不等于“镜像安全”。


8. 利用缓存挂载加速构建

BuildKit 下可以这样写:

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

适合频繁构建的 CI 环境,尤其是依赖下载成本高时。


进阶:构建流程推荐模板

如果你想把这套方法推广到团队,我建议按下面思路统一:

flowchart LR
    A[源码提交] --> B[CI 构建]
    B --> C[builder 阶段编译]
    C --> D[runtime 阶段最小化打包]
    D --> E[镜像扫描]
    E --> F[推送仓库]
    F --> G[部署]

比较实用的一条流水线顺序是:

  1. 拉取代码
  2. 构建多阶段镜像
  3. 执行单元测试/构建校验
  4. 扫描镜像漏洞
  5. 打 tag
  6. 推送仓库
  7. 部署

这样镜像优化和安全基线就不再依赖“某个同事比较认真”,而是被流程固化。


一个更适合生产的示例 Dockerfile

如果你想直接拿去做项目基线,可以参考下面这个版本:

# syntax=docker/dockerfile:1.4

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

ENV NODE_ENV=production
WORKDIR /app

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 addgroup -S appgroup && adduser -S appuser -G appgroup \
    && chown -R appuser:appgroup /app

USER appuser

EXPOSE 3000

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

如果你的服务只输出静态文件,比如 React/Vue 打包产物,还可以把运行阶段换成 Nginx 或更轻量的静态文件服务器镜像,体积通常还能继续下降。


方案取舍:什么时候不必极限瘦身

这点我想特别提醒一下。镜像优化不是越狠越好,要看场景。

适合极致瘦身的场景

  • Serverless / 弹性伸缩频繁
  • 边缘节点分发
  • 带宽敏感环境
  • 大量微服务并行部署

不必过度追求极限的场景

  • 内部低频部署系统
  • 调试优先的开发环境
  • 依赖复杂、兼容性要求高的业务

比如你为了省几十 MB,换到一个调试极难的基础镜像,结果线上定位问题成本大增,这就未必划算。

我的经验是:
先做到“结构正确”,再追求“极限压缩”
也就是先完成多阶段、缓存优化、非 root、依赖最小化,然后再考虑 distroless、scratch 之类更激进的方案。


总结

把 Docker 镜像做小、做快、做安全,核心不是堆技巧,而是建立一套稳定思路:

  • 多阶段构建:构建和运行分离
  • 缓存友好:先复制依赖清单,再复制源码
  • 最小运行时:只保留运行所需内容
  • 安全基线:非 root、固定版本、避免敏感文件入镜像
  • 持续验证:用 docker history、容器内检查、漏洞扫描工具确认结果

如果你现在手里有一个“能跑但很胖”的 Dockerfile,我建议你按下面顺序改:

  1. .dockerignore
  2. 调整 COPY 顺序
  3. 改成多阶段构建
  4. 最终镜像只保留运行产物
  5. 加非 root 用户
  6. 引入镜像扫描

做到这一步,通常已经能解决大多数团队在镜像体积、构建速度和基础安全上的问题。

别一上来追求“最小镜像宇宙冠军”,先把工程化基线落地。很多时候,这比再省 20MB 更有价值。


分享到:

上一篇
《大模型应用落地实战:基于 RAG 构建企业知识库问答系统的关键技术与性能优化》
下一篇
《区块链钱包安全实战:从私钥管理到多签方案的架构设计与落地实践》