ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Dockerfile生产级实践:分层缓存、多阶段构建与镜像瘦身

Dockerfile生产级实践:分层缓存、多阶段构建与镜像瘦身

1. 先搞清楚 Dockerfile 究竟在解决什么问题

我头一回写 Dockerfile 的时候,犯过一个挺低级的错:把整个项目目录COPY进去之后才跑依赖安装,结果改一行业务代码,依赖全部重装,一次构建三分钟起步。当时以为是机器不给力,后来才明白,问题不在硬件,而在我根本没搞懂 Dockerfile 的分层与缓存逻辑。Dockerfile 这东西,指令统共十几个,官方文档一小时能翻完,但真正决定一份 Dockerfile 好坏的东西,全在文档之外:怎么写能让构建缓存老老实实命中,怎么写能把镜像从 1.2G 压到 80M,怎么写能让容器里的进程正常收到退出信号而不是被硬生生打死。

所以这篇东西不讲"什么是容器"这种前置概念,默认你已经能跑docker run hello-world。我想聊的是:一份 Dockerfile 从"能跑"到"能上生产"之间,中间隔着的那些坑、那些取舍、那些踩过一次就不想再踩第二次的细节。内容会从分层原理讲起,然后逐条拆核心指令,再进多阶段构建和镜像瘦身,最后给一个完整可复现的实战和一个排查速查表。

适合谁看?如果你是刚接手容器化任务的开发者,照着抄能少走弯路;如果你是已经写了几十份 Dockerfile 但总觉得构建慢、镜像大、容器退出怪怪的人,中间关于缓存命中和 PID 1 的部分应该能对上你的痛点;如果你是做 CI 流水线维护的,第 5、6 章的实操记录和排查表可以直接拿去用。全文所有例子都可以在你自己的机器上跑通,不需要任何特殊环境。

1.1 从"手工装环境"到"一份可执行的环境说明书"

在没有 Dockerfile 的年代,部署一个应用是这样的:登录服务器,装运行时,配环境变量,建目录,拷文件,写启动脚本,然后祈祷这台机器的状态和你本地一致。这套流程的问题不在于麻烦,而在于它不可复现——三个月后你想在另一台机器上重建同样的环境,中间那些手工步骤早就忘干净了,只能靠运维文档里那几行语焉不详的记录。

Dockerfile 的本质,是把"装环境"这件事从操作序列变成声明式文本。它记录的不是"我点了什么按钮",而是"最终状态应该长什么样":基础镜像是什么、工作目录在哪、依赖怎么装、哪个文件是入口。这份文本进了 Git,就有了版本、有了 diff、有了 code review,环境配置第一次变成了可以被审计的代码。

这个转变带来的直接好处有三个。第一是可重复:同一份 Dockerfile 在任何装了 Docker 的机器上构建,产出的镜像内容哈希一致(前提是基础镜像 digest 固定)。第二是可追溯:线上跑的镜像对应哪个 commit 的 Dockerfile,一查便知。第三是可传递:新人入职不用再花两天配环境,docker build一条命令的事。

注意:可重复的前提是你把基础镜像的 tag 钉死。FROM node:latest这种写法,今天构建和下周构建拿到的可能是两个不同的大版本,出了问题连回滚都无从下手。

1.2 Dockerfile、镜像、容器三者的关系链条

刚接触的时候很容易把这三个概念搅在一起。我用一个类比说清楚:Dockerfile 是菜谱,镜像是按菜谱做好的半成品冷冻包,容器是把冷冻包加热后端上桌的那盘菜。菜谱可以改,改完重新做冷冻包;冷冻包可以复制很多份,每份加热出来都是一盘独立的菜;端上桌的菜吃完就没了,冷冻包还在。

这个链条里有个关键点常被忽略:镜像不是一整块,它是由若干只读层叠起来的。每一条会改动文件系统的指令,就在前一层的基础上加一层。容器启动时,Docker 在所有只读层之上再盖一个可写层,容器里的所有写操作都落在这个可写层里。容器删掉,可写层跟着删,只读层纹丝不动,所以同一个镜像起十个容器,磁盘上只有一份基础层。

理解了这一点,后面很多"为什么"就顺了:为什么RUN rm -rf删文件反而让镜像变大?因为删除动作发生在新的可写层里,被删的文件还老老实实躺在下面那层。为什么指令顺序影响构建速度?因为缓存是按层匹配的,上层一变,下面所有层的缓存全部作废。

概念角色是否可变典型产出方式
Dockerfile构建脚本 / 声明式配置可改,改了要重建手写、代码生成
镜像只读层堆叠的静态产物不可变,带 digestdocker build
容器镜像 + 可写层 + 运行时进程可变,随容器生命周期存在docker run
层镜像的最小组成单位不可变,可被多层复用每条文件系统指令生成一层

2. 分层与缓存:Dockerfile 性能的底层逻辑

把镜像当成"一坨文件"来理解,写出来的 Dockerfile 一定是慢的。必须把镜像当成"一串层"来理解。这是我认为学 Dockerfile 唯一需要建立的心智模型,别的都可以边查边写。

2.1 一条指令一层,缓存到底是怎么命中的

Docker 在构建时维护一个缓存栈。对每一条指令,它会做一次判断:这条指令的指令文本(包括参数)是否和缓存中的某条一致?如果一致,再看父层是否也命中了缓存?两个条件都满足,就直接复用缓存结果,跳过执行。

对RUN指令来说,判断依据就是命令字符串本身。你哪怕只多打一个空格,缓存就废了。对COPY和ADD来说,判断依据是被复制文件的内容校验和——文件内容没变,缓存就命中;内容变了一个字节,这条及以下所有层的缓存全废。

这就解释了一个我见过无数次的写法问题:

# 反面教材 FROM node:20-alpine WORKDIR /app COPY . . # 把源码、配置、依赖清单全拷进去 RUN npm install # 只要改一行业务代码,这行缓存就失效 CMD ["node", "server.js"]

改一行代码,COPY . .的校验和变了,缓存失效,npm install跟着重跑。一个中等规模的项目,装依赖两到五分钟,一天改二十次代码,这时间就白扔了。

正确的顺序是先拷贝变化频率最低的东西:依赖清单变化频率远低于源码,所以先拷清单、装依赖,再拷源码:

FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ # 只在依赖变动时失效 RUN npm ci # 大部分构建都能命中缓存 COPY . . # 源码变动只影响这一层之后 CMD ["node", "server.js"]

这一条原则可以做通用推广:文件系统中变化越少的部分,越要往上层放。系统级依赖、语言运行时、全局工具链,放最上面;项目依赖放中间;业务源码放最下面。这跟盖楼是一个道理,地基不可能天天改,屋顶的瓦片倒是可能月月换。

提示:想确认某次构建到底命中了哪些缓存,用docker build --progress=plain .,每层的CACHED标记一目了然。BuildKit 下这个输出比默认的进度条信息量大得多。

2.2 指令顺序决定构建速度:一组对比实测

光讲道理没感觉,我做过一组对比。同一个 Node 项目,依赖约 600 个包,冷构建(无缓存)都在 2 分 40 秒左右,差别在于热构建。场景是"改一行业务代码后重新构建"。

写法冷构建耗时改源码后热构建原因
COPY . .后装依赖约 160s约 158s依赖层永远失效,等于每次冷构建
先拷清单装依赖,再拷源码约 160s约 9s依赖层命中缓存,只重跑拷贝和构建
加.dockerignore排除 node_modules约 40s约 8s构建上下文从 300MB 降到 2MB
多阶段构建 + 缓存挂载约 45s约 6s依赖包在层之间复用,不重复下载

第三行那个 40 秒和 160 秒的差距,纯粹来自.dockerignore。因为docker build .的时候,Docker 会先把整个当前目录打包成"构建上下文"发给构建引擎,你那 300MB 的node_modules和.git也在里面。加一个.dockerignore,只写三行就够:

node_modules .git *.log

这一条我认为是性价比最高的优化,改一行文件,省几分钟构建时间,没有任何副作用。

2.3 构建上下文与 .dockerignore 的边界意识

.dockerignore除了提速,还有第二个作用常被低估:防止敏感文件被打进镜像。.env、.aws、私钥、测试用的证书,如果没有排除,COPY . .会把它们原封不动塞进镜像层。镜像即使用docker history也未必看得出来,但docker save导出后解包,文件就摆在那里。

更隐蔽的地方在于:即使你在后面某层RUN rm .env删掉,它仍然存在于下层的层里。镜像一旦推送到任何公共或半公共的仓库,就等于泄露了。

我的习惯是,.dockerignore一律采用"白名单思路"的变体:先排除所有常见噪音,再显式声明需要的目录。下面是我常用的模板,可以直接抄:

# 依赖与产物 node_modules dist build target *.pyc __pycache__ # 版本控制与编辑器 .git .gitignore .vscode .idea # 环境与密钥 .env .env.* *.pem *.key secrets/ # 日志与临时文件 *.log tmp/ .DS_Store # 文档与测试(视情况) README.md docs/ tests/

最后那组"文档与测试"要不要排除,得看你的构建流程。如果构建阶段需要跑测试,就不能排除;如果测试在 CI 的另一阶段跑,排除掉能让镜像更干净。

3. 核心指令逐条拆解与踩坑点

Dockerfile 的指令看着简单,但每一条都有"能用"和"用对"两种状态,中间差着一堆细节。

3.1 FROM / RUN / CMD / ENTRYPOINT 的取舍

FROM是地基,选错了上面全白搭。三件事必须做对:钉死版本 tag;优先选-slim或-alpine变体;生产环境考虑用 digest 钉死。FROM node:20和FROM node:20-alpine,体积差着十倍以上,一个 1.1G 一个 130M。alpine 用的是 musl libc,个别依赖 glibc 的二进制(比如某些预编译的原生模块)跑不起来,这时候退回-slim就行——它是 debian 的裁剪版,兼容性没问题,体积在 200M 上下。

RUN有两个反直觉的点。第一,多个RUN能合并就合并。每条RUN是一层,层数多不只是镜像大,构建也慢。第二,同一层里的删除才算真的删除:

# 这样写,最终镜像里仍然带着 apt 的缓存 RUN apt-get update RUN apt-get install -y curl RUN rm -rf /var/lib/apt/lists/* # 这样写,缓存才在同一层里被清掉 RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*

--no-install-recommends这个参数值得养成习惯,它避免装一堆"推荐但非必需"的包,在 debian 系镜像上经常能省出几十兆。

CMD 和 ENTRYPOINT是新手最容易混淆的一对。记住一个粗糙但好用的判断:ENTRYPOINT 是"这个镜像就是干这个的",CMD 是"默认参数"。ENTRYPOINT 定义了容器的可执行主体,docker run后面跟的参数会作为附加参数传给它;CMD 定的是默认值,docker run给了参数就整体覆盖掉。

# 形式一:只用 CMD,docker run image bash 会直接跑 bash CMD ["node", "server.js"] # 形式二:ENTRYPOINT + CMD,命令行参数追加到后面 ENTRYPOINT ["node"] CMD ["server.js"] # docker run image --version 等价于 node --version

两个必须用 exec 数组形式(["a","b"]),别用 shell 形式(a b)。shell 形式会启动一个/bin/sh -c包住你的命令,你的进程变成 sh 的子进程,收到 SIGTERM 的是 sh 而不是你的应用,优雅关停直接失效,最后容器得等超时被 SIGKILL 打死。

3.2 COPY 与 ADD 的边界

官方文档写得很清楚:优先用 COPY,除非你真的需要 ADD 的额外能力。ADD 多的那两个能力是自动解压本地 tar 包、以及从 URL 下载文件。前者几乎用不上,后者在生产里也不该用——你没法验证下载内容的完整性,也没法利用 COPY 的缓存机制。

COPY --chown=app:app ./src ./src这个参数很多人不知道。默认拷贝进来的文件属主是 root,如果你后面切了非 root 用户,应用可能没有读权限,或者写入日志目录失败。要么在 COPY 时指定--chown,要么在最后统一RUN chown -R(但后者会多一层,还慢)。

还有一个坑:COPY . .里的.指的是构建上下文根目录,不是 Dockerfile 所在目录。如果你的 Dockerfile 放在deploy/子目录里,用docker build -f deploy/Dockerfile .构建,那么COPY . .拷的是项目根目录的内容,而不是deploy/的内容。这个错位我见过不止一次,排查起来挺费劲。

3.3 ENV / ARG / WORKDIR / USER 的细节

ARG 和 ENV的区别值得单独说。ARG 只在构建期有效,构建完就没了,不会进入镜像的运行时环境;ENV 会写进镜像配置,容器启动后依然存在。所以密码、token 这类东西绝对不能用 ENV,因为docker inspect一看就露;用 ARG 也不安全,构建历史里能查到。

维度ARGENV
生效范围仅构建期构建期 + 容器运行期
能否被docker run -e覆盖不能能
是否写入镜像元数据否(但可能进 build history)是
典型用途基础镜像版本、构建参数应用配置、PATH、时区

ARG 的另一个用法是做"条件构建"。ARG NODE_ENV=production配合RUN if [ "$NODE_ENV" = "production" ]; then ...; fi,可以在一份 Dockerfile 里区分开发和生产两个构建路径,不用维护两份文件。

WORKDIR用相对路径可以叠加,WORKDIR /app之后再WORKDIR src,实际路径是/app/src。这个特性我一般不用,容易看晕,一律写绝对路径。

USER是最容易被忽略、但在生产环境里最该加的一条。镜像默认以 root 运行容器进程,一旦应用被拿下,攻击者直接拿到容器内的 root。加一行USER app就能把权限降到普通用户:

RUN addgroup -S app && adduser -S -G app app WORKDIR /app COPY --chown=app:app . . USER app

alpine 用addgroup/adduser,debian 系用groupadd/useradd,命令不同但思路一致。注意切 USER 之后,需要写权限的目录(比如日志目录、临时目录)必须提前 chown 好,否则运行时报 Permission denied,而且这种错误在构建期完全看不出来。

3.4 EXPOSE / VOLUME / HEALTHCHECK / LABEL 的实用边界

EXPOSE是纯文档性质的声明,它不会真的发布端口。真正发布端口的是docker run -p。但 EXPOSE 有价值:它让docker run -P能自动映射,也让读 Dockerfile 的人一眼知道这个服务监听哪个端口。写上,成本为零。

VOLUME得小心用。写在 Dockerfile 里的 VOLUME 会在镜像层里创建一个匿名卷挂载点,之后任何对这个目录的写操作都落到卷里,不会进镜像层。这带来一个副作用:如果 VOLUME 之后还有RUN指令要改这个目录,改动会丢失,因为挂载遮蔽了它。更麻烦的是,VOLUME 声明的匿名卷在容器删除时不一定被清理,时间长了磁盘上堆一堆孤儿卷。我的建议是,只在真正需要持久化的目录上声明 VOLUME,而且优先在docker run -v时显式指定,不依赖镜像里的默认声明。

HEALTHCHECK是容器编排的"心跳"。没有它,编排系统只知道进程活着,不知道服务能不能响应请求。一个典型写法:

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ CMD curl -fsS http://localhost:8080/health || exit 1

start-period这个参数很多人漏掉,它的作用是给应用一个启动宽限期,宽限期内探测失败不计入重试次数。没有它,启动慢的应用会被反复判定不健康然后重启,陷入"启动-被杀-重启"的循环。上面例子还有个前提:镜像里得有curl,alpine 默认没有。想省体积可以用wget -q --spider,alpine 自带。

LABEL不影响运行,但影响可维护性。把镜像来源、构建时间、commit 号打进去,日后排查"这个镜像到底是什么版本"的时候能救命:

LABEL org.opencontainers.image.source="repo-url" \ org.opencontainers.image.revision="${GIT_SHA}" \ org.opencontainers.image.created="${BUILD_TIME}"

4. 从单阶段到多阶段:把镜像瘦下来

写完能跑的 Dockerfile,下一步基本都是"为什么镜像这么大"。多阶段构建是这个问题最直接的答案,没有之一。

4.1 单阶段构建的典型臃肿来源

一份"能跑"的单阶段 Dockerfile 长这样:

FROM node:20 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["node", "dist/server.js"]

这个镜像里包含了什么?完整的 Node 运行时、全套 devDependencies(webpack、typescript、eslint、测试框架)、原始源码、构建产物、npm 缓存、可能的.git目录。全部加起来 1.2G 上下,而真正运行时需要的其实只有node、dist/目录和 package.json 里的少数生产依赖。

臃肿不只是占磁盘。每多一兆,推送慢一点,拉取慢一点,节点扩容时冷启动慢一点。在自动扩缩的集群里,镜像大小直接换算成扩容延迟。我见过一个 2.3G 的镜像,新节点拉取要两分多钟,扩缩策略基本失效。

4.2 多阶段构建的三类典型案例

多阶段的思路很朴素:用一个大而全的镜像做构建,把产物拷到一个精简的镜像里运行。构建阶段的垃圾全部留在那一阶段,不进入最终镜像。

前端/Node 场景:

# 阶段一:构建 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二:运行 FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist USER node CMD ["node", "dist/server.js"]

注意--omit=dev,只装生产依赖。npm cache clean --force清掉 npm 的缓存目录,这一步在同一层里做才有效。

Go 场景更极端,因为 Go 能编译成静态二进制,最终镜像可以做到几乎没有依赖:

FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=builder /out/app /app/app USER 10001 ENTRYPOINT ["/app/app"]

-s -w去掉符号表和调试信息,二进制能小 25% 左右。CGO_ENABLED=0关掉 cgo,得到静态链接的二进制,不依赖目标镜像的 libc。ca-certificates和tzdata这两个包必须手动装,否则容器里发 HTTPS 请求会证书校验失败,时间也会是 UTC 而不是本地时区——这两个问题我都遇到过,排查时都是一脸懵。

Java 场景可以用分层 jar 的思路,把依赖 jar 和应用 jar 分成不同层,改代码时只有小层变化:

FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY build/libs/*.jar app.jar ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]

这里-XX:MaxRAMPercentage=75比写死-Xmx更好,容器内存限制变化时 JVM 会自动适配,不会出现"容器限了 512M,JVM 却按宿主机 32G 算堆"最后被 OOM Killer 干掉的经典事故。

4.3 基础镜像选型对照表

选基础镜像这件事,我一般从三个维度权衡:体积、兼容性、维护活跃度。没有绝对最优,只有场景匹配。

基础镜像压缩后体积量级兼容性适用场景需要注意
debian:bookworm-slim约 30M最好,glibc 完整通用服务,需要编译工具链需要自己清 apt 缓存
alpine:3.19约 3M较好,musl libc静态二进制、轻量服务部分预编译原生模块不可用
ubuntu:22.04约 30M最好需要 apt 生态比 debian 稍大,包更新快
distroless/static约 2M仅静态二进制Go/Rust 生产服务无 shell,排查困难
scratch0无完全静态二进制无 CA 证书、无时区、无 shell
语言官方镜像-slim60M~200M好不想自己装运行时体积明显大于 alpine 版

distroless和scratch这类极致精简的镜像,安全性确实好(没有 shell,攻击者进来了也没法执行命令),但代价是排障几乎没法做——docker exec进去什么都干不了。我的做法是生产用精简镜像,同时在 CI 环境保留一份带 shell 的调试版本,出问题时切过去看。硬要在生产里排障,可以用kubectl debug之类的手段挂一个临时容器进去,不必为此牺牲镜像体积。

5. 一个完整可复现的实战:从零写一份生产级 Dockerfile

前面讲的都是零件,这一章拼一台完整的车。我拿一个典型的中小型 Node 服务举例,它的特征很有代表性:TypeScript 源码、需要编译、有生产依赖和测试依赖、跑在 HTTP 端口上、需要优雅关停。

5.1 先把项目和依赖摸清楚

项目结构大致如下:

app/ ├── src/ │ └── index.ts ├── package.json ├── package-lock.json ├── tsconfig.json └── .env.example

package.json里的关键信息:build脚本调tsc,启动脚本是node dist/index.js,typescript在 devDependencies 里,运行时的express在 dependencies 里。应用监听 3000 端口,收到 SIGTERM 时关闭 HTTP server 然后退出。

写 Dockerfile 之前的准备工作有四步,一步都不能省:确认基础镜像的具体版本号而不是latest;确认应用真正需要哪些运行时环境(时区?CA 证书?字体?);确认有没有原生模块需要编译工具链(如果有,构建阶段必须装python3、make、g++);确认是否有静态资源目录需要额外拷贝。

这一步偷懒的后果,通常要等到容器跑起来报错才暴露,而且报错信息往往和真实原因隔了十万八千里——比如缺时区包,表现是日志时间戳错了但业务正常,很容易被忽略到线上才出问题。

5.2 逐行写出 Dockerfile 并说明理由

# ---- 构建阶段 ---- FROM node:20.11-alpine AS builder # 需要在 alpine 上编译原生模块时,临时装工具链 RUN apk add --no-cache python3 make g++ WORKDIR /app # 先只拷依赖清单,最大化缓存命中 COPY package.json package-lock.json ./ # ci 严格按 lock 文件安装,保证可复现 RUN npm ci # 再拷源码和编译配置 COPY tsconfig.json ./ COPY src ./src RUN npm run build # ---- 运行阶段 ---- FROM node:20.11-alpine AS runtime # 时区与证书,缺了会在运行时出问题 RUN apk add --no-cache tzdata ca-certificates \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone WORKDIR /app ENV NODE_ENV=production \ TZ=Asia/Shanghai # 只装生产依赖 COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force # 从构建阶段拿编译产物 COPY --from=builder /app/dist ./dist # 用镜像自带 node 用户,非 root 运行 USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ CMD wget -q --spider http://127.0.0.1:3000/health || exit 1 CMD ["node", "dist/index.js"]

几个关键决策的取舍说明。为什么不用npm install?npm install在 lock 文件存在时会读它,但允许根据 semver 微调,npm ci则严格按 lock 安装,且会先清空node_modules,构建结果更可复现。CI 环境里这个差别很重要。

为什么构建阶段装编译器,运行阶段不装?原生模块编译只需要在构建时跑一次,编译好的.node文件是二进制产物,运行阶段不需要python3和g++。把工具链留在构建阶段,运行镜像能省 150M 以上。

为什么用wget而不是curl?alpine 自带 busybox 的 wget,不需要额外装包。-q --spider只发 HEAD 请求不下载内容,开销最小。

为什么USER node放在COPY之后?如果先切用户再 COPY,COPY 进来的文件属主是 root,node用户可能读不了。放在最后,前面所有需要 root 权限的操作都已经完成。这里还有一个细节:npm ci --omit=dev是在切用户之前跑的,所以node_modules属主是 root,node用户读它没问题(读权限对所有人开放),但如果你的应用需要写node_modules里的东西,就得额外 chown。

5.3 构建、验证与体积对比

构建和验证分四步走,我一般按这个顺序:

# 1. 构建 docker build -t app:1.0.0 . # 2. 看体积和层 docker images app:1.0.0 docker history --no-trunc app:1.0.0 | head -20 # 3. 跑起来,验证健康检查和时区 docker run -d --name app-test -p 3000:3000 app:1.0.0 docker inspect --format='{{.State.Health.Status}}' app-test docker exec app-test date # 确认时区生效 # 4. 验证优雅关停 docker stop -t 10 app-test docker logs app-test | tail -5 # 应该能看到关停日志

同一份应用,几种写法的体积对比大致是这个量级(具体数字跟项目规模相关,看趋势就行):

写法镜像体积改代码后重建安全基线
node:20+COPY . .+npm install约 1.2G约 160sroot 运行,含源码
node:20-alpine单阶段约 280M约 150sroot 运行,含源码
多阶段 + alpine + 生产依赖约 150M约 10s非 root,无源码无工具链
多阶段 + distroless(若可行)约 130M约 10s无 shell,最小攻击面

第 4 步的优雅关停验证特别容易被跳过,但它是生产事故的高发点。如果你的CMD写成了 shell 形式,docker stop会等满 10 秒超时然后 SIGKILL,日志里看不到任何关停信息,正在处理的请求全部中断。这个现象在小流量测试时完全不会暴露,上量之后就是一堆 502。

提示:BuildKit 的缓存挂载能把依赖下载也缓存起来,跨构建复用。写法是RUN --mount=type=cache,target=/root/.npm npm ci,在 CI 里能再省几十秒。前提是构建时启用 BuildKit(较新版本默认开启)。

6. 常见问题与排查技巧实录

这一章是我自己踩过的坑和帮别人排查过的问题的汇总。很多问题看着莫名其妙,但背后的原因都指向同一个地方:容器里的世界和你在宿主机上熟悉的世界不一样。

6.1 构建阶段的问题速查

现象最可能的原因排查动作处理方式
改了代码但构建结果没变缓存命中了不该命中的层--progress=plain看各层状态调整指令顺序,或临时--no-cache验证
COPY报 not found路径相对于构建上下文,不是 Dockerfile 位置确认docker build的最后一个参数统一在项目根目录构建
RUN里命令找不到用了 shell 内建命令或未装的工具进同版本基础镜像手动试显式装包,别假设存在
构建极慢,卡在 "sending build context"上下文里塞了 node_modules、.gitdu -sh看目录大小配.dockerignore
npm install在 alpine 上编译失败musl libc 缺头文件或工具链看报错里的gyp ERR装python3 make g++或换-slim
镜像比预期大很多删除操作在另一层docker history看各层体积把安装和清理合并到同一条RUN
no space left on device悬空镜像和构建缓存堆积docker system dfdocker builder prune定期清理

那个"改代码但构建结果没变"的问题,还有一个隐蔽变体:COPY . .后面跟着.dockerignore把刚改的文件排除了。比如你把源码放在了被忽略的目录里,构建时压根没拷进去,但本地跑是好的。这种问题排查时建议直接用docker run --rm -it <镜像> sh进去看文件到底在不在,比盯着 Dockerfile 猜快得多。

6.2 运行阶段的问题排查

容器启动即退出,日志空白。这是最经典的。多数情况是 PID 1 的问题:你的进程被/bin/sh -c包着,sh 收到信号后没转发。或者应用本身启动失败但错误写到了 stderr,而你看的是 stdout。docker logs两个流都会带出来,所以更可能是进程压根没起来就被杀了。加docker run -it <镜像> sh手动执行CMD里的命令,错误立刻暴露。

容器内的服务外部访问不到。按这个顺序查:应用是否监听在0.0.0.0而不是127.0.0.1(监听回环地址的话,容器外永远访问不到,这个坑极其常见);EXPOSE的端口和实际监听端口是否一致;docker run -p映射有没有写对;容器内wget -qO- localhost:3000能不能通。四步走完,问题基本定位。

时区不对。表现是日志时间戳差 8 小时。原因就是镜像里没有 tzdata,或者TZ环境变量没设。两者都要做:装 tzdata 提供时区数据,设TZ让运行时读得到。Go 和 Java 有些版本还依赖/etc/localtime文件,所以cp那一步也别省。

中文乱码。基础镜像默认没有中文字体,导出 PDF、生成图片里的中文会变方块。这在 alpine 上尤其常见,因为它的 locale 支持本身就精简。要么装字体包,要么在构建阶段就把字体文件塞进去。

容器内存不断增长直到被 OOM Killer 干掉。docker stats能看到内存曲线,但看不到原因。这时候要确认 JVM 或 Node 的堆上限是否感知了容器限制。老版本 JVM 默认按宿主机内存算堆,必须显式设-XX:MaxRAMPercentage或-Xmx。Node 的默认堆上限在新版本里已经能读 cgroup 限制,但老版本要手动--max-old-space-size。

6.3 我的避坑清单

这些是长期积累下来的习惯,写在这里当检查表用。

第一条,基础镜像的版本一定要钉死,而且定期主动升级。钉死是为了可复现,定期升级是为了安全补丁。这两件事不矛盾,做法是用一个变量集中管理版本号,CI 定期检查基础镜像有没有新版。别用latest,那是定时炸弹。

第二条,构建期的任何密钥都不该进 Dockerfile。ARG不行,ENV更不行。需要私有仓库依赖的话,用 BuildKit 的 secret 挂载:RUN --mount=type=secret,id=npmrc npm ci,密钥在构建时以文件形式挂进来,结束后不留痕。

第三条,每份 Dockerfile 都要能回答"这个镜像里最小的攻击面是什么"。有没有 shell?有没有编译工具链?有没有 curl 和 wget 之外的网络工具?跑的是不是 root?如果一个都不满足,说明还有压缩空间。

第四条,本地构建通过不等于生产能跑。本地测试时习惯用docker run -v $(pwd):/app把代码挂进去,这样镜像里缺什么都没关系。上生产不挂载,问题就来了。验证时必须用纯净的docker run,不带任何挂载。

第五条,定期清理构建缓存和悬空镜像。docker builder prune --keep-storage 5GB之类的策略,能避免磁盘被慢慢吃满。CI 机器上尤其要注意,很多流水线卡在"no space left"就是这个原因。

第六条,给 Dockerfile 加注释,写清楚每个非显然决策的理由。半年后回来改的时候,你会感谢自己。比如"这里必须装 ca-certificates,否则调第三方 HTTPS 接口会报证书错误",这种注释的价值远超它占的那一行。

第七条,别在 Dockerfile 里做编排的事。健康检查探活、日志收集、配置注入,这些应该在编排层解决。Dockerfile 里塞一堆启动脚本、环境判断、配置文件生成逻辑,最后的结果是构建逻辑和运行逻辑纠缠在一起,改哪个都怕碰坏另一个。保持 Dockerfile 只做"把应用和它的运行时依赖装好"这一件事,是我这些年最受益的一条原则。


我个人在实际操作中的体会是,Dockerfile 的难点从来不在语法,而在于建立"层"这个心智模型之后再去看待每一条指令。一旦你习惯性地问自己"这条指令会不会让下面的缓存失效""这个文件进镜像后还能不能删掉""这个进程会不会是 PID 1",大部分问题在写的时候就被规避了,根本轮不到排查阶段。刚上手时可以拿一份能跑的旧 Dockerfile 逐条改,每改一条就看一次docker history的体积变化和热构建耗时,这个反馈循环比看十篇文章都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进