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

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

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

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

很多团队刚把服务容器化时,最容易忽略的一件事就是:镜像能跑,不代表适合上线

我见过不少项目的 Dockerfile 是这样起步的:

  • 基础镜像直接用 ubuntu:latest
  • 构建工具、源码、缓存、测试文件全打进最终镜像
  • 容器里默认用 root 用户
  • 一次 COPY . . 把整个仓库复制进去
  • 构建慢、镜像大、漏洞多,线上排查还费劲

一开始觉得“先跑起来再说”,等服务一多,问题就一起冒出来了:CI 变慢、镜像仓库膨胀、发布耗时增加、安全扫描一片红。
这篇文章就从这些真实问题出发,带你一步步把 Docker 镜像做小、做快、做得更适合生产环境。


背景与问题

先看几个常见症状:

  1. 镜像过大

    • 一个简单 Go 服务镜像做到几百 MB
    • Node.js 服务动辄 1GB+
    • 拉取镜像慢,发布窗口变长
  2. 构建速度慢

    • 代码改一行,依赖全量重装
    • CI 每次从头构建
    • 没有利用 Docker layer cache
  3. 生产环境暴露面大

    • 镜像里带编译器、包管理器、shell 工具
    • root 权限运行
    • .env、测试数据甚至私钥打进镜像
  4. 难以审计与维护

    • Dockerfile 层次混乱
    • 构建和运行环境混在一起
    • 无法快速定位“这个文件为什么在镜像里”

本质上,这些问题都指向一个核心:没有把“构建阶段”和“运行阶段”分离


前置知识与环境准备

建议你本地准备好以下环境:

  • Docker 20.10+
  • 启用 BuildKit(推荐)
  • 一个简单的示例项目

开启 BuildKit

Linux / macOS 下可以临时这样执行:

export DOCKER_BUILDKIT=1

或者在构建时直接加:

DOCKER_BUILDKIT=1 docker build -t demo-app .

BuildKit 对缓存、并行构建、多阶段体验都更好,后面示例会顺手用到。


核心原理

什么是多阶段构建

多阶段构建(Multi-stage Build)就是在一个 Dockerfile 里定义多个 FROM 阶段:

  • 前面的阶段负责编译、打包、测试
  • 最后的阶段只保留运行所需产物

这样,最终镜像不会带上源码、编译器、缓存目录等“构建垃圾”。

一个直观理解

flowchart LR
    A[源码] --> B[构建阶段<br/>安装依赖/编译/测试]
    B --> C[产物]
    C --> D[运行阶段<br/>仅复制可执行文件]
    D --> E[最终生产镜像]

为什么它能同时解决“瘦身 + 提速 + 安全”

1. 瘦身

构建工具链不进入最终镜像,自然更小。

2. 提速

合理拆分 COPYRUN 顺序后,Docker 层缓存可以复用。

3. 安全

最终镜像里少了 shell、编译器、包管理器,攻击面更小。


一个典型的坏例子

先看一个“能跑,但不太适合上线”的 Node.js Dockerfile:

FROM node:18

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

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

这个写法的问题很集中:

  • COPY . . 太早,导致任意代码变更都让 npm install 缓存失效
  • 构建依赖、源码、测试文件都进入最终镜像
  • npm install 默认可能装上 devDependencies
  • 运行时仍使用较重的基础镜像
  • 默认 root 用户运行

实战代码:从单阶段到多阶段

下面用一个常见的 Node.js Web 服务做演示。目录大致如下:

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

示例应用代码

package.json

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

src/server.js

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

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

app.get('/', (req, res) => {
  res.json({
    message: 'hello docker multi-stage build',
    hostname: os.hostname(),
    node: process.version
  });
});

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

第一步:写一个更合理的 .dockerignore

这一步很多人会漏掉,但它的收益非常直接。

.dockerignore

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

为什么要加它

因为 Docker 构建时会把“上下文”发送给 daemon。
如果你把整个仓库,包括 .git、本地缓存、测试报告、环境变量文件都传过去:

  • 构建会变慢
  • 镜像缓存可能频繁失效
  • 还可能把敏感信息带入构建环境

第二步:使用多阶段构建

生产可用版 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS builder

WORKDIR /app

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

COPY src ./src
RUN npm run build

FROM node:18-alpine AS runner

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

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

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

逐段拆解这份 Dockerfile

1. builder 阶段负责构建

FROM node:18-alpine AS builder

这里用 node:18-alpine,比完整版更轻。
但我先提醒一句:不是所有场景都适合 alpine。后面“常见坑”会讲。

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

这一步是构建提速关键点:

  • 先复制依赖描述文件
  • 再执行 npm ci
  • 如果只是业务代码改了,依赖层缓存还能复用
COPY src ./src
RUN npm run build

只在依赖安装完成后复制源码,避免不必要的缓存失效。


2. runner 阶段只保留运行所需内容

FROM node:18-alpine AS runner

重新起一个干净阶段,和构建环境分离。

ENV NODE_ENV=production

告诉应用当前是生产模式,很多框架会按这个变量做优化。

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

避免 root 运行,这是生产环境非常基础但非常重要的一步。


构建与运行

构建镜像

DOCKER_BUILDKIT=1 docker build -t demo-node-app:1.0 .

运行容器

docker run --rm -p 3000:3000 demo-node-app:1.0

验证服务

curl http://localhost:3000

预期输出类似:

{"message":"hello docker multi-stage build","hostname":"c8d1234abcd","node":"v18.20.0"}

构建流程图

flowchart TD
    A[复制 package.json/package-lock.json] --> B[npm ci 安装依赖]
    B --> C[复制 src 源码]
    C --> D[npm run build]
    D --> E[进入 runner 阶段]
    E --> F[安装生产依赖]
    F --> G[复制 dist 构建产物]
    G --> H[切换非 root 用户]
    H --> I[启动服务]

第三步:观察镜像瘦身效果

你可以分别构建“坏例子”和“多阶段版本”,然后对比:

docker images | grep demo-node-app

进一步看镜像历史:

docker history demo-node-app:1.0

如果想看镜像里到底塞了什么,我常用这两个命令:

docker run --rm -it demo-node-app:1.0 sh
du -sh /app/*

这样能快速判断:

  • 有没有把源码、测试目录带进去
  • 有没有不该存在的缓存
  • 运行阶段是否真的足够干净

进阶:为什么层缓存能显著提升构建速度

Docker 构建并不是“每次都从零开始”,而是按指令一层层生成镜像。

缓存命中示意

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as Layer Cache

    Dev->>Docker: 修改 src/server.js
    Docker->>Cache: 检查 COPY package*.json 及 npm ci 层
    Cache-->>Docker: 命中缓存
    Docker->>Cache: 检查 COPY src 层
    Cache-->>Docker: 缓存失效
    Docker->>Docker: 重新执行 build
    Docker-->>Dev: 更快生成新镜像

一个经验法则

在 Dockerfile 里,把变化频率低的步骤放前面,把变化频率高的步骤放后面。

例如:

  • 依赖文件变化频率低
  • 业务代码变化频率高

所以通常顺序应是:

  1. 复制依赖描述文件
  2. 安装依赖
  3. 复制源码
  4. 构建

这也是为什么不推荐一上来就 COPY . .


常见坑与排查

这一部分我尽量讲得“接地气”一点,很多坑我自己也踩过。

坑 1:多阶段了,但镜像还是很大

常见原因

  • 最终阶段仍然 COPY . .
  • 安装了 devDependencies
  • 基础镜像本身过大
  • 运行阶段仍保留包缓存、临时文件

排查方法

docker history your-image:tag
docker run --rm -it your-image:tag sh

看几个重点目录:

du -sh /app
du -sh /root/.npm
du -sh /usr/local/lib/node_modules

修复建议

  • 只复制运行必需文件
  • 使用 npm ci --omit=dev
  • 清理包缓存
  • 优先选更轻量的运行时镜像

坑 2:用了 Alpine,结果某些依赖编译失败

这是很典型的问题。
alpine 使用的是 musl libc,有些原生依赖、预编译二进制、企业内部库可能和它不兼容。

表现

  • npm install 卡在 native module 编译
  • 运行时报找不到动态库
  • 某些加密、图像、数据库驱动行为异常

排查思路

先别急着改业务代码,优先验证是不是基础镜像兼容性问题:

FROM node:18-bullseye-slim

如果切到 Debian slim 系列问题消失,那大概率就是 Alpine 兼容性导致的。

建议

  • 能用 alpine 再用
  • 如果依赖 native module,优先测试 debian-slim
  • 镜像小不是唯一目标,稳定更重要

坑 3:构建缓存没生效

常见原因

  • COPY . .,导致任何改动都让依赖层失效
  • 锁文件频繁变化
  • CI 每次都在全新环境,没有远程缓存
  • 没启用 BuildKit

排查命令

DOCKER_BUILDKIT=1 docker build --progress=plain -t demo-node-app:1.0 .

观察日志里哪些步骤显示 CACHED

建议

  • 拆开 COPY package*.jsonCOPY src
  • 尽量使用锁文件
  • CI 引入 registry cache 或 buildx cache

坑 4:容器能启动,但权限报错

你切换到非 root 用户后,可能会遇到:

  • 无法写日志目录
  • 无法创建临时文件
  • 某些生成目录权限不足

处理方式

在切换用户前就把目录权限准备好:

RUN mkdir -p /app/logs && chown -R appuser:appgroup /app
USER appuser

如果是挂载卷带来的权限问题,则需要结合宿主机 UID/GID 一起处理。


坑 5:把敏感文件打进镜像

最容易中招的是:

  • .env
  • 私钥
  • 测试数据库配置
  • 内网证书

排查方法

进入镜像后直接搜:

find /app -maxdepth 2 -type f

建议

  • .dockerignore 明确排除
  • 不把密钥写进镜像
  • 配置通过环境变量、Secret、Kubernetes Secret、Vault 等注入

安全/性能最佳实践

这一节给你一份更接近生产落地的清单。

1. 使用明确版本标签,不要依赖 latest

不推荐:

FROM node:latest

推荐:

FROM node:18.20-alpine

这样构建结果更可控,也更容易审计。


2. 运行时镜像尽量最小化

如果你的应用最终只需要一个二进制,比如 Go、Rust,完全可以把运行阶段做到非常小。

例如 Go 服务常见写法:

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .

FROM scratch
COPY --from=builder /src/app /app
ENTRYPOINT ["/app"]

这种场景下,多阶段构建收益尤其明显。


3. 坚持非 root 运行

至少做到:

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

如果业务确实需要绑定低端口、写特定目录,再单独评估权限,不要图省事全程 root。


4. 只安装生产依赖

Node.js 常见做法:

RUN npm ci --omit=dev

Python、Java、Go 也有类似思路:
不要把测试框架、构建插件、调试工具带进生产镜像。


5. 做漏洞扫描,但别只盯 CVE 数量

建议在流水线加入镜像扫描工具,例如:

  • Trivy
  • Grype
  • Docker Scout

但我实际经验是:
不要只看“漏洞个数”,要结合可利用性、运行路径、修复成本判断优先级。

比如:

  • 基础镜像中的某个包有 CVE,但运行时根本不会触发
  • 而你的应用配置错误、root 运行、暴露调试端口,反而更危险

6. 减少无意义层和中间文件

例如包安装与清理尽量放在同一层里完成,避免清理不彻底:

RUN npm ci --omit=dev && npm cache clean --force

如果拆成两层,前一层产生的缓存仍然可能保留在历史层中。


7. 配合健康检查与只读文件系统

如果运行平台支持,建议加健康检查:

HEALTHCHECK --interval=30s --timeout=3s CMD node -e "require('http').get('http://localhost:3000', res => process.exit(res.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"

同时在运行平台层面尽量启用:

  • readOnlyRootFilesystem
  • 限制 Linux Capabilities
  • CPU / 内存限制
  • seccomp / AppArmor 配置

一个更接近生产的 Dockerfile 示例

下面给一份稍完整一点的 Node.js 版本,适合你在项目里直接改造:

# syntax=docker/dockerfile:1.4

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

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

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

RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
    && chown -R appuser:appgroup /app

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

这个版本的思路是:

  • deps:专门处理依赖
  • builder:利用依赖产物做构建
  • runner:只保留运行所需内容

在中大型项目里,这样分层更清晰。


逐步验证清单

如果你打算把现有项目迁移到多阶段构建,我建议按这个顺序验证:

功能正确性

  • 容器能正常启动
  • 核心接口访问正常
  • 配置通过环境变量注入正常

镜像瘦身

  • 最终镜像不包含源码
  • 不包含测试文件和构建缓存
  • 不包含开发依赖

构建性能

  • 修改业务代码时依赖层缓存命中
  • CI 构建耗时下降
  • 镜像推送和拉取耗时下降

安全基线

  • 非 root 用户运行
  • 没有敏感文件进入镜像
  • 基础镜像版本固定
  • 完成基础漏洞扫描

生产落地建议

如果你现在维护的是已有项目,不必一口气“全都重构”。比较稳妥的推进方式是:

  1. 先补 .dockerignore
  2. 再拆分 COPY 顺序,拿到缓存收益
  3. 引入多阶段构建
  4. 切换非 root 用户
  5. 最后做基础镜像和依赖优化

这样改造风险更可控,收益也能一层层看到。

如果你的服务构建特别复杂,比如:

  • 前端 + 后端混合构建
  • 需要私有仓库认证
  • 依赖 native 扩展
  • 构建过程包含代码生成

那就建议先单独梳理“构建产物边界”:
最终镜像到底只需要哪些文件?
这个边界一旦清楚,多阶段构建就不会乱。


总结

Docker 多阶段构建的价值,不只是“镜像变小”这么简单,它同时影响三件关键的事:

  • 构建速度:通过合理利用层缓存减少重复安装
  • 运行效率:镜像更小,分发和启动更快
  • 生产安全:减少工具链、降低攻击面、避免 root 运行

如果你只记住三条,我建议是:

  1. 构建和运行环境一定分离
  2. 先复制依赖描述文件,再安装依赖,最后复制源码
  3. 最终镜像只保留运行所需文件,并用非 root 用户启动

边界条件也要记住:
不是所有项目都适合盲目追求最小镜像,像 Alpine 兼容性、调试便利性、团队维护成本,都要一起评估。小、快、安全 这三件事,最好是平衡,而不是偏执。

当你把 Dockerfile 当成“生产制品定义文件”来写,而不是“能跑就行的脚本”,镜像质量通常就会有明显提升。


分享到:

上一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-226》
下一篇
《自动化测试中的接口回归体系设计:从用例分层、数据构造到 CI 持续校验实战》