Docker 镜像从 1.2GB 瘦到 80MB:我们做的 7 步优化和 10 条黄金法则

Node.js 服务镜像 1.2GB,CI 4 分钟,Pod 启动 3 分钟。本文复盘 7 步优化:alpine 基础、多阶段构建、.dockerignore、缓存友好 COPY、distroless / scratch、BuildKit cache mount、镜像扫描。最终 78MB,CI 12 秒。附 10 条 Dockerfile 黄金法则。

我们的 Node.js 服务 Docker 镜像 1.2GB,每次 CI 推到镜像仓库 4 分钟,Pod 拉镜像 + 启动 3 分钟。优化两轮后降到 78MB,推送 + 启动加起来 40 秒。这篇文章把整个优化过程拆开讲,每一步省了多少、原理是什么、潜在风险是什么。

优化前 Dockerfile

FROM node:20

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

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

这是新人最常见的写法。问题一堆:

  • 基础镜像太大:node:20 有 1.1GB(基于 Debian)
  • COPY 顺序错:任何代码改动都让 npm install 缓存失效
  • 装了所有依赖:devDependencies 在生产里没用
  • 源码、测试、文档全打进去:几十 MB 浪费
  • 用 root 跑:安全隐患

构建出来 docker images 看一下:

$ docker build -t my-app:before .
$ docker images my-app
REPOSITORY   TAG       SIZE
my-app       before    1.24GB

优化 1:基础镜像换 alpine

node:20 → node:20-alpine,基础镜像从 1.1GB 缩到 180MB。但 alpine 用 musl libc,有些 native 模块(node-gyp 编译的 C++ 扩展)需要装 build 工具:

FROM node:20-alpine
WORKDIR /app

# 如果你的依赖里有 sharp / sqlite3 / bcrypt 这种 native 模块
# 装 build 依赖,装完再删
RUN apk add --no-cache --virtual .build-deps \
        python3 make g++ \
    && apk add --no-cache \
        ca-certificates tini

COPY . .
RUN npm install && npm run build
# 删 build 依赖
RUN apk del .build-deps

EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

这一步:1.24GB → 480MB。

关键技巧:

  • --no-cache:不缓存 apk 包索引,省 ~50MB
  • --virtual .build-deps:把 build 依赖打成虚拟包,装完一键 apk del 删干净
  • tini:Alpine 默认没有 init,容器内 PID 1 是你的 node 进程,信号处理有坑(SIGTERM 不会被处理)。tini 当 PID 1 转发信号

优化 2:多阶段构建

构建阶段需要 npm、devDeps、build 工具,但运行时不需要。把这些分离:

# ============ 阶段 1: 安装依赖 ============
FROM node:20-alpine AS deps
WORKDIR /app

# 只复制 package.json,缓存友好
COPY package.json package-lock.json ./

# ci 比 install 严格:必须有 lockfile,版本完全一致
RUN npm ci --only=production --no-audit --no-fund && \
    npm cache clean --force

# ============ 阶段 2: 构建 ============
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund   # 这里装全部依赖(包括 devDeps)

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# ============ 阶段 3: 运行时 ============
FROM node:20-alpine AS runtime
WORKDIR /app

RUN apk add --no-cache tini && \
    addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# 只 copy 必要文件
COPY --from=deps   --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json ./

USER nodejs
EXPOSE 3000

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

这一步:480MB → 195MB。

多阶段构建的精髓:每个阶段独立,只把最终产物 COPY 到最后阶段。中间阶段的 build 工具 / source 代码 / devDependencies 全部丢弃。

优化 3:.dockerignore 大扫除

有没有发现 COPY . . 把整个工程目录都复制进去了?node_modules、.git、tests、docs、IDE 配置全在里面。

# .dockerignore
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Git
.git
.gitignore
.gitattributes

# IDE
.vscode
.idea
*.swp
*.swo

# Tests & docs
test
tests
__tests__
*.test.ts
*.spec.ts
docs
README.md
LICENSE
CHANGELOG.md

# Build artifacts (我们要自己重新 build)
dist
build
.cache
coverage

# Env files
.env
.env.local
.env.*.local

# CI / 配置
.github
.gitlab-ci.yml
Dockerfile*
docker-compose*.yml

这一步 build context 从 380MB 降到 4.2MB,docker build 速度大幅提升(主要是 COPY 阶段)。

优化 4:层缓存友好的 COPY 顺序

Docker 每条 RUN / COPY 都生成一层。如果某层变了,后面所有层的缓存都失效。把"变化频率低"的放前面、"变化频率高"的放后面:

# 错:任何代码改动都让 npm ci 缓存失效
FROM node:20-alpine
WORKDIR /app
COPY . .                            # ← 任何文件改了,这层就变
RUN npm ci                          # ← 重新装依赖

# 对:依赖文件先 copy 单独装
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./    # ← 这两个文件不常变
RUN npm ci                                # ← 缓存命中率高
COPY src ./src                            # ← 业务代码改了只重 build,不重装

实测:改一行业务代码后重新 build,优化前 4 分 30 秒,优化后 18 秒。

优化 5:用 distroless 或 scratch

更激进:用 Google 的 distroless 镜像(只有 glibc / libssl 等运行时,没有 shell、包管理器、cron 这些):

# 最终阶段用 distroless
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
COPY --from=deps    /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]    # distroless 镜像默认 ENTRYPOINT 已经是 node

distroless 镜像:

  • 体积:Node.js 版约 130MB
  • 没有 shell —— 攻击者拿到 RCE 也没法直接执行命令
  • USER nonroot 默认非 root
  • 但 debug 麻烦:进不去容器执行命令

对 Go / Rust 这种编译型语言,可以更进一步用 scratch(完全空镜像):

# Go 示例:scratch 最终镜像可能小到 8MB
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静态链接编译
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /app/server ./cmd/server

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
USER 1000:1000
ENTRYPOINT ["/server"]

用 scratch 注意:不能 shell 进容器、glibc 没了所以 native 库可能跑不起来、没有 ca-certificates 要自己 COPY。

优化 6:BuildKit + 缓存挂载

Docker BuildKit 默认在新版 Docker 已启用。用 cache mount 把 npm / pip / go mod 缓存放到独立的可复用 volume,避免每次重新下载:

# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./

# --mount=type=cache 把 npm cache 放到 buildkit 缓存目录,跨 build 复用
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production --no-audit --no-fund
DOCKER_BUILDKIT=1 docker build -t my-app:cached .

第二次 build 时,npm cache 命中率 95%+,下载 100 个包变成几乎不下载。

优化 7:别忘了 squash(慎用)

多层 RUN 会留下大量小层,可以用 --squash 合并:

docker build --squash -t my-app:squashed .

但 squash 会丢失中间层缓存优势(下次完全重新 build)。我们后来没用这个,因为多阶段 + .dockerignore 已经把体积压下来了,没必要损失缓存换体积。

最终对比

优化阶段                                镜像大小   build (无缓存)   build (有缓存)
原版                                     1.24GB    4m 30s          4m 30s
+ alpine                                  480MB    3m 50s          3m 50s
+ 多阶段                                  195MB    3m 20s          3m 20s
+ .dockerignore                          195MB    2m 40s          2m 40s
+ 缓存友好 COPY 顺序                      195MB    2m 40s          18s (业务改)
+ distroless                             130MB    2m 50s          22s
+ BuildKit cache mount                   130MB    2m 50s          12s
+ Go scratch (改成 Go 服务)               8MB     45s             8s

核心收益不是体积本身,是CI / CD 时间。从一次 build 4 分钟降到 12 秒,团队迭代速度直接翻倍。

常见误区

  1. "alpine 一定更快":错。alpine 用 musl libc,对某些 Python / Node 包不友好。node:slim(基于 Debian 但裁剪)有时更好
  2. "层越少越好":错。层多但每层小,缓存命中率高;层少但每层大,任何改动都失效
  3. "FROM scratch 适合所有":错。只适合静态链接 + 不需要 DNS/cert/shell 的服务
  4. "用 docker-slim 自动瘦身":工具确实存在,但它通过 strace 分析运行时实际用到的文件来裁剪,误删风险高,生产慎用

容器安全顺手做的事

# 必须非 root
USER 1001

# 只读根文件系统(运行时配合 K8s readOnlyRootFilesystem: true)
# 写入需要的目录用 volume / emptyDir

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost:3000/healthz || exit 1

# 元数据(给运维 / 安全扫描器看的)
LABEL org.opencontainers.image.source="https://github.com/myorg/my-app" \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.revision="${GIT_SHA}"

镜像扫描必装

# Trivy:开源,扫 CVE
trivy image my-app:latest

# Grype:类似 Trivy
grype my-app:latest

# CI 集成,发现 critical 漏洞直接挂掉
trivy image --severity CRITICAL --exit-code 1 my-app:latest

# 看具体某个包为啥被引入
trivy image --show-suppressed my-app:latest

我们 CI pipeline 加了一个 trivy step,发现 CRITICAL 直接拒绝合并 PR。一年下来挡掉了 23 个高危漏洞 —— 全是某个依赖的传递依赖里悄悄藏的。

10 条 Dockerfile 黄金法则

  1. 基础镜像选 alpine / slim / distroless,不用 latest 用具体版本号
  2. 多阶段构建分离 build 和 runtime
  3. .dockerignore 必须有,排除 node_modules / .git / tests
  4. COPY 顺序:依赖文件 → 装依赖 → 业务代码
  5. RUN 合并:apt-get update && apt-get install -y x y z && rm -rf /var/lib/apt/lists/*
  6. 非 root 用户:USER 1001 或 USER nonroot
  7. tini 当 PID 1,正确处理信号
  8. HEALTHCHECK 给 K8s 用
  9. BuildKit cache mount 加速 CI
  10. CI 跑 trivy 扫漏洞

把这 10 条做齐,你的镜像基本是行业一线水平。我们厂里 80+ Go / Node / Python 服务,所有镜像都遵循这套规则,运维 / 安全 / 上云成本大幅下降。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

HTTPS 握手慢 380ms 排查:OCSP Stapling 救场的全过程

2026-5-19 10:35:24

技术教程

Nginx upstream keepalive 漏一行配置,QPS 直接砍 6 倍

2026-5-19 10:44:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索