Docker 用久了你会发现一个规律真正让人头疼的从来不是敲docker run那一行命令而是背后那个 Dockerfile 写得够不够干净。我见过太多团队镜像动辄两三个 G构建一次十几分钟改一行代码要重新拉一遍依赖排查问题时进容器一看里面塞满了编译工具、缓存包、临时文件活像个没打扫过的仓库。这些问题九成都能追溯到 Dockerfile 的编写方式上。这篇内容就围绕 Linux 环境下编写 Dockerfile 这件事展开把我在实际项目里反复验证过的思路、参数选择、分层技巧和踩坑记录整理出来。不管你是刚接触容器、能跑通docker build就行的新手还是已经在维护多阶段构建、想进一步压缩镜像体积的老手下面这些内容应该都能对上你的某些实际场景。核心关键词 Dockerfile、镜像分层、多阶段构建、构建缓存、基础镜像选型会自然穿插在各个章节里不刻意堆砌读到哪算哪。1. 先想清楚Dockerfile 到底在解决什么问题1.1 从“能跑”到“跑得好”的认知转变很多人写第一个 Dockerfile 的心态是“能构建成功就行”于是上来就是FROM ubuntu然后apt-get install一大堆COPY . .RUN npm install最后CMD一挂收工。这个写法在本地验证阶段确实没问题但只要进了 CI 流水线、上了多环境部署毛病就全冒出来了。Dockerfile 的本质不是一份“安装脚本”而是一份镜像构建的声明式配方。它描述的是“从哪个基础环境出发经过哪些确定性步骤最终得到一个可复现的运行态”。注意“确定性”和“可复现”这两个词这是它和普通 shell 脚本最大的区别。shell 脚本你写apt-get update apt-get install -y curl今天跑和明天跑结果可能不一样因为软件源在变而 Dockerfile 的每一层都会被缓存、被复用、被打上内容哈希你写的每一行都在决定这个镜像的最终形态和构建效率。所以我在动手写任何 Dockerfile 之前会先问自己三个问题这个镜像的运行时到底需要哪些东西哪些是构建期才需要的、运行期完全用不上的基础镜像选哪个发行版和哪个 tag 最合适这三个问题答清楚了Dockerfile 的结构基本就定了剩下的只是语法细节。1.2 镜像分层机制决定了你该怎么写要写好 Dockerfile绕不开对镜像分层layer的理解。Docker 镜像是由一层层的只读层叠加而成的Dockerfile 里每一条RUN、COPY、ADD指令都会产生一个新层。层一旦生成就是只读的后续层只能在其之上追加不能修改。这个机制带来两个直接后果也是所有编写技巧的根源。第一个后果是层数越多、体积越大。你在某一层里apt-get install装了一堆包又在下一层里apt-get purge删掉看起来删干净了但被删掉的文件仍然存在于上一层里镜像体积一点没减。这就是为什么老手都强调“安装和清理要放在同一条 RUN 里用串起来”因为同一条 RUN 只产生一层层内的删除才是真删除。第二个后果是缓存按层命中。Docker 构建时会从上到下逐层比对只要某一层的内容没变就直接复用缓存跳过执行。一旦某一层变了它之后的所有层缓存全部失效。这个特性直接决定了指令的排列顺序变化频率低的东西放前面变化频率高的放后面。最典型的就是先COPY package.json再RUN npm install最后才COPY . .这样你改业务代码时依赖层还能命中缓存不用重装。提示理解分层是写好 Dockerfile 的地基。后面讲的多阶段构建、缓存优化、体积压缩本质上都是在跟这个分层机制打交道。1.3 基础镜像选型别一上来就用大而全的发行版基础镜像的选择是影响镜像体积和攻击面的第一道关口。我见过不少项目默认FROM ubuntu:latest一个基础层就七八十兆起步再装点东西轻松破百。实际上大多数场景根本用不上完整的 Ubuntu 用户空间。我的选型习惯大致是这样需要 glibc 兼容、生态成熟的优先考虑debian:bookworm-slim这类 slim 版本比完整版小一大截纯静态编译的 Go、Rust 程序直接上scratch或alpineNode、Python 这类有官方镜像的用官方提供的-slim或-alpine变体。alpine用的是 musl libc体积小但某些依赖 glibc 的二进制可能跑不起来这点要提前验证别等上线才发现。还有一个细节是 tag 的选择。latest看着省事但它意味着不可复现——今天构建和下周构建可能拿到完全不同的基础环境。生产镜像我一律要求锁定具体版本号比如node:20.11.1-bookworm-slim把版本钉死构建结果才稳定。2. 核心指令的实操要点与常见误区2.1 FROM、WORKDIR、ENV地基三件套FROM决定了起点前面已经聊过选型。这里补一个多阶段构建里常见的写法给每个阶段起名字比如FROM node:20-slim AS builder后面就能用COPY --frombuilder精确引用比用数字索引清晰得多阶段一多也不容易搞混。WORKDIR看似简单但很多人习惯用RUN cd /app ...来切换目录这是错的。cd只在当前那条 RUN 里生效下一条指令又回到默认目录了。正确做法是用WORKDIR /app它会持久影响后续所有指令的工作目录而且目录不存在时会自动创建。我一般会在 WORKDIR 之后紧接着安排依赖安装相关的指令让整个构建过程有个稳定的落脚点。ENV用来设置环境变量构建期和运行期都生效。这里有个容易忽略的点ENV设置的值会写进镜像元数据如果塞了密钥、token 这类敏感信息等于直接暴露在镜像里谁docker inspect一下都能看到。敏感配置一律走运行时注入别用 ENV 硬编码。另外多个环境变量建议合并成一条ENV减少层数ENV NODE_ENVproduction \ APP_PORT8080 \ TZAsia/Shanghai2.2 RUN合并、清理、非交互三原则RUN是 Dockerfile 里出现频率最高、也最容易写臃肿的指令。我总结下来就三条原则。第一条能合并的合并。前面说过层内删除才是真删除所以apt-get update、apt-get install、清理缓存必须写在同一条 RUN 里RUN apt-get update \ apt-get install -y --no-install-recommends curl ca-certificates \ rm -rf /var/lib/apt/lists/*注意--no-install-recommends它会跳过那些“推荐但非必需”的包能省下不少体积。rm -rf /var/lib/apt/lists/*清掉 apt 的索引缓存这一步不做光索引就能占几十兆。第二条非交互式安装。在容器里跑apt-get install如果遇到需要交互确认的包构建会直接卡死。所以要加-yDebian 系还可以配合DEBIAN_FRONTENDnoninteractive环境变量彻底关掉交互提示。第三条别在 RUN 里干需要人工介入的事。比如启动一个需要手动配置的服务、等待用户输入的命令这些在构建期都会挂起。构建过程必须是全自动、无交互的。2.3 COPY 与 ADD优先用 COPYCOPY和ADD功能有重叠但我的原则很明确除非你确实需要 ADD 的自动解压或远程 URL 下载能力否则一律用 COPY。ADD 的行为更“聪明”但聪明往往意味着不可预期——它会自动解压 tar 包、会去拉远程文件这些隐式行为在排查问题时很烦人。COPY 就是老老实实复制语义清晰。还有一个高频坑是COPY . .把不该进镜像的东西也带进去了比如node_modules、.git、本地日志、测试数据。解决办法是配一个.dockerignore文件把构建上下文里不需要的内容排除掉。这个文件不仅影响 COPY 的结果还能显著加快构建上下文的传输速度——构建时 Docker 会先把整个上下文目录打包发给守护进程目录里塞了几百兆的node_modules光传输就够你等的。node_modules .git *.log .env dist coverage2.4 CMD 与 ENTRYPOINT搞清楚谁覆盖谁这两个指令经常被混用。简单说ENTRYPOINT定义的是容器的“可执行主体”CMD提供的是默认参数。docker run后面跟的命令会覆盖 CMD但不会覆盖 ENTRYPOINT除非用--entrypoint显式指定。实际项目里我常用的组合是ENTRYPOINT固定启动脚本、CMD给默认参数这样既保证了容器启动行为一致又留了运行时传参的口子。另外要注意CMD 和 ENTRYPOINT 都有 exec 和 shell 两种写法优先用 exec 形式JSON 数组比如CMD [node, server.js]。shell 形式CMD node server.js会额外套一层/bin/sh -c导致进程 PID 不是 1信号传递会出问题容器优雅退出、健康检查这些都会受影响。3. 多阶段构建把构建环境和运行环境彻底分开3.1 为什么单阶段构建总是臃肿假设你写一个 Go 服务单阶段 Dockerfile 大概是FROM golang:1.22拷代码go build然后CMD跑起来。问题在于最终镜像里不仅有编译好的二进制还有整个 Go 工具链、源码、模块缓存体积轻松上 G。可运行时真正需要的只是那个几十兆的二进制文件而已。多阶段构建就是来解决这个矛盾的。它允许你在一个 Dockerfile 里定义多个FROM阶段前面的阶段负责编译、打包后面的阶段只从前面阶段拷贝产物。最终镜像只包含最后一个阶段的内容前面那些构建工具统统不会带进去。3.2 一个可复用的多阶段模板以 Node 项目为例我常用的结构是这样# 阶段一依赖安装 FROM node:20.11.1-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omitdev # 阶段二构建 FROM node:20.11.1-bookworm-slim AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # 阶段三运行 FROM node:20.11.1-bookworm-slim AS runner WORKDIR /app ENV NODE_ENVproduction COPY --fromdeps /app/node_modules ./node_modules COPY --frombuilder /app/dist ./dist COPY package.json ./ USER node EXPOSE 8080 CMD [node, dist/main.js]这个结构有几个讲究。deps阶段只装生产依赖builder阶段装全量依赖并构建runner阶段只拿最终产物。三个阶段都基于同一个 slim 基础镜像保证运行时环境一致。USER node是安全实践用非 root 用户跑应用降低容器被攻破后的影响面。npm ci而不是npm install是因为ci严格按 lock 文件安装结果可复现而且速度更快。生产依赖用--omitdev跳过开发依赖进一步瘦身。3.3 静态编译语言可以做到极致Go、Rust 这类能静态编译的语言多阶段构建的效果更夸张。编译阶段用完整工具链运行阶段直接上scratch或alpineFROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o /out/app ./cmd/app FROM scratch COPY --frombuilder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --frombuilder /out/app /app ENTRYPOINT [/app]CGO_ENABLED0关掉 cgo保证二进制是纯静态的才能在 scratch 里跑。-ldflags-s -w去掉符号表和调试信息二进制能小一截。scratch 是空镜像连 shell 都没有所以要把 CA 证书拷进去否则 HTTPS 请求会失败。这种镜像做出来往往只有十几兆启动快、攻击面极小。注意scratch 镜像里没有 shelldocker exec进去是没法调试的。如果项目还在频繁排障阶段可以先退一步用 alpine等稳定了再切 scratch。4. 构建缓存优化与体积压缩实战4.1 指令顺序就是缓存策略缓存优化的核心思想前面提过把变化频率低的指令放前面。但实际操作中有个细节值得展开——依赖清单文件和源码要分开拷贝。以 Python 为例FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [python, main.py]requirements.txt不常变所以pip install这一层能长期命中缓存。如果你写成COPY . .再pip install那改任何一行源码都会导致依赖重装构建时间直接翻几倍。这个顺序调整是零成本的但收益巨大属于必做项。pip install加--no-cache-dir是为了不保留 pip 的下载缓存否则缓存文件会留在镜像层里白白占空间。npm 对应的是npm ci本身就不留多余缓存apt 则是前面说的清/var/lib/apt/lists/*。4.2 构建参数与缓存失效的边界ARG定义的构建参数会影响缓存。如果你在依赖安装之前声明了一个经常变化的 ARG比如构建时间戳、git commit hash那这一层缓存就永远命中不了。所以 ARG 的声明位置也要讲究只在真正需要它的阶段和位置声明别一股脑堆在文件开头。另外docker build时可以用--cache-from指定外部缓存源在 CI 环境里配合镜像仓库做缓存复用能大幅缩短流水线构建时间。这个属于进阶用法团队规模上来之后值得配置。4.3 体积压缩的几个实用手段除了多阶段构建和清理缓存还有几个立竿见影的手段。一是选对基础镜像变体前面说过 slim 和 alpine 的区别。二是合并 RUN 减少层数层数少了元数据开销也小。三是用.dockerignore挡住无用文件。四是对二进制做 strip去掉调试符号。我做过一个对比同一个 Node 服务从最初的FROM node:20单阶段写法到换成 slim 基础镜像、多阶段构建、依赖分层、清理缓存之后镜像从 1.2G 降到了 180M 左右构建时间从 8 分钟降到 2 分钟出头。这个差距在频繁发版的团队里累积起来是相当可观的时间成本。优化手段体积影响构建速度影响实施成本换 slim/alpine 基础镜像显著减小略快低多阶段构建显著减小略慢多一阶段中依赖清单分层拷贝无显著加快低合并 RUN 并清理缓存中等减小略快低.dockerignore 排除无用文件中等减小加快上下文传输低二进制 strip小幅减小无低5. 常见问题排查与避坑实录5.1 构建失败类问题速查实际写 Dockerfile 的过程中报错五花八门但高频的就那么几类。我整理了一张速查表遇到问题先对号入座。现象常见原因排查与解决构建卡在 apt 安装不动交互式提示等待输入加-y和DEBIAN_FRONTENDnoninteractiveCOPY 报文件不存在构建上下文路径不对或被 .dockerignore 排除检查 build 命令的上下文目录核对 ignore 规则依赖安装层缓存频繁失效COPY 顺序错误源码先于依赖清单被拷贝调整为先拷清单文件再装依赖容器启动即退出CMD 用了 shell 形式或主进程前台运行问题改 exec 形式确保主进程是 PID 1时区不对、中文乱码基础镜像缺时区和 locale 配置设置 TZ 环境变量必要时装 tzdata镜像体积异常大单阶段构建、缓存未清理、上下文带入大文件多阶段构建 清理 .dockerignore5.2 几个我踩过的真实坑坑一alpine 里跑 glibc 二进制。有次把一个预编译的第三方工具塞进 alpine 镜像构建成功运行时报not found排查半天才发现是 musl 和 glibc 不兼容。后来要么换 debian-slim要么找 musl 版本要么自己编译。这个坑的教训是基础镜像选型要在项目早期就验证清楚别等镜像都写完了才发现跑不起来。坑二USER 切换后权限不足。为了安全加了USER node结果应用要写日志目录权限不够直接崩。解决办法是在切换用户之前把目录属主改好RUN chown -R node:node /app/logs或者干脆让应用往标准输出打日志交给容器运行时收集这也是更推荐的做法。坑三多阶段构建里 COPY --from 路径写错。阶段名和路径都要对COPY --frombuilder /app/dist ./dist里/app/dist是 builder 阶段里的绝对路径./dist是当前阶段 WORKDIR 下的相对路径。写错一个就是文件找不到而且报错信息不够直观容易懵。坑四构建上下文过大导致构建极慢。有个项目目录里堆了几个 G 的测试数据和历史日志每次 build 光传上下文就要好几分钟。加了.dockerignore之后立竿见影。这个问题的隐蔽性在于它不报错就是慢很多人以为是机器性能问题其实是上下文没清理。5.3 上线前的自检清单Dockerfile 写完别急着提交我习惯过一遍这个清单基础镜像 tag 是否锁定是否用了多阶段构建依赖安装和源码拷贝的顺序对不对有没有清理包管理器缓存敏感信息有没有硬编码进 ENV 或 COPY是否用了非 root 用户CMD 是不是 exec 形式.dockerignore配了没有镜像体积和构建时间是否在可接受范围这几条过一遍基本能挡掉八成以上的常见问题。剩下的就是结合具体业务做针对性优化了比如健康检查HEALTHCHECK、启动脚本的信号处理、日志输出规范这些属于锦上添花的部分按项目需要加就行。我个人在实际操作中的体会是Dockerfile 这东西写一次容易写好难难在持续维护。项目依赖在变、基础镜像在更新、业务在迭代Dockerfile 也得跟着调整。所以与其追求一次写到完美不如把上面这些原则内化成习惯每次改动都顺手检查一下分层和缓存长期下来镜像质量和构建效率自然就稳住了。
阅读完成 · 觉得有帮助?