0. 先聊聊为什么很多人卡在写 Dockerfile 这一步
docker pull一把梭,跑起来确实爽。可一旦接手自己的项目,迟早会撞上那道绕不过去的坎——你必须自己 build 一个 Docker 镜像。我见过太多人停在这一步:网上抄一份 Dockerfile,改个FROM、改个端口,docker build报一串红字,然后就回到"本地跑得好好的,何必折腾容器"的老路上去了。
问题不在容器本身,而在于大多数教程只告诉你"写这几行",不告诉你"为什么是这几行"。Dockerfile这个东西,语法两天就能背完,真正难的是三件事:选对基础镜像、把层缓存用明白、把镜像我瘦身。这三件事决定了你的镜像是 15MB 还是 1.2GB,构建是 8 秒还是 8 分钟,上线是稳稳当当还是天天出事故。
这篇东西就是从这个角度写的。我会带你从一个几十行的真实应用出发,把Dockerfile逐行拆开讲——每一行在干什么、为什么这么写、换成别的写法会踩什么坑;然后再往深走,讲多阶段构建、层缓存命中率、非 root 运行、镜像标签策略这些真正影响交付质量的东西。适合两类人看:一类是完全没写过Dockerfile、想跑通第一个镜像的新手;另一类是写过但总觉得"能跑就行"、想做扎实的开发者。前者可以照着抄,后者建议重点看第 4 章和第 5 章。
我不想把它写成一份命令手册,手册网上到处都是。我更想讲的是那些文档里不会写、只有自己构建崩过几次才会明白的东西。
1. 想清楚一件事:为什么非得自己写 Dockerfile
1.1 从 pull 到 build,中间差的是"可控"
刚接触容器时,我们都是docker run别人做好的镜像,图个方便。但项目一旦要交付,这套玩法立刻失效。我总结下来,自己写Dockerfile的理由基本集中在三点。
第一,环境必须可复现。你本地装的是某个版本的运行时,服务器上装的是另一个版本,某个依赖库的补丁版本还差了一级,结果就是"我这儿没问题"。Dockerfile的本质是一份可执行的、带版本号的环境说明书,它把"需要装什么、装哪个版本、按什么顺序装"全部固化下来,任何人拿到这份文件都能构建出比特级一致的环境。
第二,配置必须能外置。别人的镜像里,配置文件路径、数据目录、日志输出位置都是写死的。你临时用-v挂载去覆盖,挂一两个还行,挂到五六个就开始怀疑人生。自己写的时候,从第一天就把该外置的东西外置出去,后面会舒服很多。
第三,镜像本身应该进版本库。这条经常被忽略。Dockerfile和.dockerignore是代码的一部分,它们应该跟业务代码一起提交、一起 review、一起回滚。兄弟团队问"你们那个服务是怎么部署的",你把仓库地址一甩就完事了,不用截图加语音。
反过来说,如果你只是临时跑个数据库、跑个缓存做实验,那就别折腾,docker run官方镜像足够了。判断标准很简单:这个东西要不要长期交付给别人?要,就自己写。
1.2 镜像分层与写时复制:不理解这个,写再多也是瞎写
这一节是全文的地基,看懂了后面所有优化才有意义。
Docker 镜像不是一个"大文件",而是一摞只读层叠起来的。Dockerfile里每一条会修改文件系统的指令(RUN、COPY、ADD)都会产生一个新的只读层。容器启动时,在最上面再加一层薄薄的可写层。所有写操作都发生在可写层,底层不动——这就是所谓的写时复制(Copy-on-Write)。
用个生活化的类比:把镜像想成一摞透明胶片,每张胶片上画了点东西,叠在一起看才是完整画面。你要改画面,不能擦掉中间那张,只能再盖一张新的上去。所以——你在容器里删了一个 500MB 的文件,镜像并不会变小,因为那个文件还躺在底下的层里,你只是在新层里给它打上了"已删除"的标记。
这个特性直接推导出三条硬规矩,我在实际项目里反复验证过:
- 同一个
RUN里做完安装和清理。apt-get install和rm -rf /var/lib/apt/lists/*必须写在一条RUN里,用&&连起来。分开写的话,清理发生在下一个层,前一个层的缓存文件该占多少空间还是占多少。 COPY要尽量往后放。层是有缓存的,从上往下逐条匹配,一旦某一层的输入变了,它和它之后的所有层全部失效,得重新构建。把变化频繁的业务代码放在最后,能让依赖安装那一层长期命中缓存。- 镜像大小看的是所有层的总和,不是最终文件系统的大小。想知道每一层各占多少,
docker history一看便知。
注意:很多人第一次接触"层"这个概念时会误以为删除文件能瘦身,于是写出
RUN rm -rf xxx单独成行的写法。这样不但没瘦身,反而因为多了一层而让镜像更大。清理动作一定和产生垃圾的动作在同一层。
明白了这两点,你就知道后面所有的"最佳实践"其实都是从镜像分层的物理特性里长出来的,不是谁拍脑袋定的规矩。
2. 动手之前:环境检查与基础镜像选型
2.1 三分钟把环境验一遍
写Dockerfile之前先确认引擎能正常工作,省得后面把环境问题误判成语法问题。我一般跑这几条:
docker version docker info docker run --rm hello-worlddocker version看客户端和服务端是不是都连上了——如果只打印了 Client 部分,说明守护进程没起来或者当前用户没权限。
docker info里我会特别瞄两个字段:Storage Driver和Server Version。存储驱动会影响构建性能和磁盘占用,版本差异会影响一些新语法(比如 BuildKit 相关的特性)能不能用。
最后那条hello-world是最直接的连通性验证。跑通就说明拉取、解压、启动这一整套链路没问题。
在 Linux 上,普通用户直接敲docker大概率会得到一句权限被拒绝的提示。解决办法是把当前用户加进 docker 用户组:
sudo usermod -aG docker $USER执行完需要重新登录(或者newgrp docker)才生效。这个组权限等同于 root,个人开发机无所谓,多人共用的机器上要留意一下。
还有一个容易忽略的点:磁盘空间。构建过程会在本地囤积中间层,几个大镜像下来吃掉几十 GB 很正常。养成定期docker system df看看的习惯,该清的清:
docker image prune -f docker builder prune -fbuilder prune清的是构建缓存,慎用——清完下次构建就得从头来一遍。
2.2 基础镜像怎么挑:三个维度定生死
这一步是整个Dockerfile里最重要的决策,选错了后面全白搭。我把常见的几类基础镜像拉出来对比一下。
| 基础镜像类型 | 典型体积 | C 库 | 优点 | 需要注意的地方 |
|---|---|---|---|---|
| Alpine 系 | 约 7MB | musl libc | 极致轻量,攻击面小 | 部分依赖没有预编译包,需要现场编译;musl 与 glibc 行为有差异 |
| Debian slim 系 | 约 75MB | glibc | 兼容性好,绝大多数依赖开箱即用 | 体积大一圈,需要自己清理包管理器缓存 |
| 语言官方完整镜像 | 300MB ~ 1GB | 视发行版而定 | 工具链齐全,构建时方便 | 作为运行时太重,一般只当构建阶段用 |
| 精简运行时镜像 | 十几 MB | 视情况 | 体积和安全性兼顾 | 没有 shell,排查问题时不能exec进去 |
选型的判断顺序我一般是这样的:
第一步看兼容性,不是看体积。如果你的项目依赖里有需要编译的原生模块(比如图像处理、数据库驱动、加密库),Alpine 的 musl libc 很可能会让预编译二进制直接失效,你得在镜像里装一整套编译工具链现场编译,构建时间暴涨,最后体积也没省下多少。这种情况下果断用 slim 系,别为了几 MB 给自己找事。
第二步看构建期和运行期的分离程度。编译需要 gcc、make、头文件,运行完全不需要。这两个阶段的需求差异巨大,所以只要你的项目有构建步骤,就应该考虑多阶段构建(第 4 章细讲)。构建阶段用完整镜像,运行阶段用最精简的。
第三步看版本锁定。生产环境绝对不要用latest标签。FROM node:latest意味着你今天的构建和三个月后的构建可能不是同一个东西,出了问题根本没法复现。写死大版本加小版本,比如node:20.11-alpine3.19,是基本素养。想更严格的话,用镜像摘要(digest)锁定,格式是镜像名@sha256:xxxx,那个是内容寻址的,绝对唯一。
注意:Alpine 的版本号也要锁。
alpine:3.19和alpine:3.20里的包管理器行为、默认时区、自带的库版本都可能不一样。我吃过一次亏,同一个Dockerfile隔了两个月重新构建,因为基础镜像小版本升了,某个系统库的默认行为变了,服务启动直接失败。从那以后所有基础镜像都写全版本号。
3. 第一个能跑起来的 Dockerfile:逐行拆解
3.1 一个完整的最小示例
光讲理论没用,直接上手。假设我们有个很小的 Node.js 服务,项目结构是这样的:
demo-app/ ├── package.json ├── package-lock.json ├── server.js └── Dockerfileserver.js内容很简单,起一个 HTTP 服务返回一段文本:
const http = require('http'); const port = process.env.PORT || 3000; http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('hello from container\n'); }).listen(port, '0.0.0.0', () => { console.log(`listening on ${port}`); });注意listen的第二个参数写的是0.0.0.0,不是127.0.0.1。这一点极其关键——绑定到回环地址的话,容器外部的请求根本进不来,你会以为是端口映射写错了,排查半天。容器内的服务一律绑定0.0.0.0。
对应的Dockerfile:
FROM node:20.11-alpine3.19 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY server.js ./ ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 USER node CMD ["node", "server.js"]构建和运行:
docker build -t demo-app:1.0.0 . docker run --rm -p 3000:3000 demo-app:1.0.0浏览器或者curl localhost:3000应该能看到返回内容。跑通之后,我们一行一行往回看。
3.2 每条指令到底在干什么
FROM node:20.11-alpine3.19—— 指定基础镜像,同时锁死版本。这里的node是语言运行时的官方镜像,20.11是运行时版本,alpine3.19是底层发行版。三层信息各管一件事,缺一个都会导致构建不可复现。
WORKDIR /app—— 设置后续所有指令的工作目录,目录不存在会自动创建。千万不要用RUN cd /app代替,cd只对当前那条RUN生效,下一条指令又回到根目录了,这是新手最常见的误解之一。
COPY package.json package-lock.json ./—— 只拷贝依赖清单,不拷业务代码。这是为了缓存优化,具体原因第 4 章展开。
RUN npm ci --omit=dev—— 安装生产依赖。这里用ci而不是install:ci严格按 lock 文件安装,保证版本完全一致,而且速度快;--omit=dev跳过开发依赖,生产镜像里不需要测试框架和构建工具。
COPY server.js ./—— 业务代码放在依赖之后拷贝。改代码不会影响依赖层的缓存。
ENV NODE_ENV=production—— 设置环境变量。NODE_ENV在很多库里会影响行为(比如是否输出调试信息、是否启用某些优化),生产环境一定要设。
EXPOSE 3000—— 声明容器打算监听哪个端口。这句纯粹是文档性质的,不会真的开放端口,真正的映射在docker run -p里做。但写上它是好习惯,docker inspect和很多编排工具会读这个信息。
USER node—— 切换到非 root 用户。官方 node 镜像内置了一个node用户,直接用就行。这一行是安全底线,容器默认以 root 运行,一旦服务被攻破,攻击者在容器内就是 root 权限,配合某些配置缺陷甚至可能影响宿主机。非 root 运行能大幅缩小这个面。
CMD ["node", "server.js"]—— 容器启动时执行的命令,用的是 exec 形式(JSON 数组)。
CMD和ENTRYPOINT这对经常让人犯迷糊,我做个对比:
| 对比项 | CMD | ENTRYPOINT |
|---|---|---|
| 作用 | 提供默认命令 | 定义容器的主程序 |
能否被docker run后面的参数覆盖 | 能,直接替换 | 不能,参数会追加在后面 |
| 典型用法 | 提供可变的默认启动参数 | 固定不可变的启动程序 |
| 组合用法 | ENTRYPOINT定程序,CMD定默认参数 | 同左 |
实践中的判断很简单:这个容器就是干一件事的,用ENTRYPOINT;这个镜像可能被拿来干不同的事,用CMD。像我们的 demo 应用,只有一个启动方式,其实写成ENTRYPOINT更严谨。想临时进容器排查又不被启动命令干扰的话,用--entrypoint sh覆盖。
3.3.dockerignore和构建上下文:一个常被忽视的性能黑洞
docker build -t demo-app:1.0.0 .最后那个.是构建上下文。很多人不知道,这个目录里的所有东西(除了被排除的)都会被打包发给构建引擎。如果你在项目根目录直接构建,里面躺着node_modules、.git、构建产物、日志文件,几个 GB 全传一遍,每次都慢得让人想砸键盘。
解决办法就是.dockerignore,语法跟.gitignore差不多:
node_modules npm-debug.log .git .gitignore .env *.md Dockerfile .dockerignore coverage dist我整理过一份几乎每个 Node 项目都能直接用的清单,核心思路是四类:依赖目录(会在镜像里重装)、版本控制目录(无用且大)、本地环境文件(可能含密钥,绝对不能进镜像)、构建产物(会在镜像里重新构建)。
.env这类文件一定要排除。我之前见过有人把带数据库密码的.env打进了镜像,然后推到内部仓库,虽然仓库是私有的,但这属于典型的凭据泄露路径,出事就是大事。
注意:
.dockerignore只影响发送给构建引擎的上下文,不影响COPY指令本身的匹配。如果某个文件被.dockerignore排除了,COPY也拿不到它。反过来,COPY . .会把你上下文里的所有东西都拷进去,所以上下文里有什么,你得心里有数。
验证上下文大小有个小技巧:构建时留意第一行输出,新版本的构建工具会打印transferring context: xxxMB。看到几百 MB 就该去检查.dockerignore了。
4. 把镜像做小、做快:多阶段构建与缓存优化
4.1 多阶段构建:把编译工具链留在构建期
第一个镜像能跑起来之后,接下来该关心体积和构建速度了。多阶段构建是收益最大的一招。
原理很简单:一个Dockerfile里可以写多个FROM,每个FROM开启一个新阶段,用AS 名字给它命名,后面的阶段可以用COPY --from=名字从前面的阶段里拷文件。最终镜像只包含最后一个阶段的内容,前面阶段产生的所有中间文件全部丢弃。
举个 Go 项目的例子。不用多阶段的话,Dockerfile大概是这样:
FROM golang:1.22 WORKDIR /src COPY . . RUN go build -o /app ./cmd/app CMD ["/app"]这样出来的镜像接近 900MB,因为整个 Go 工具链和源码全在里面。但实际上运行时只需要一个静态编译出来的二进制文件,其他都是废的。
改成多阶段:
FROM golang:1.22-alpine3.19 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 adduser -D -u 10001 appuser COPY --from=builder /out/app /usr/local/bin/app USER appuser EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/app"]体积对比:
| 方案 | 镜像大小 | 层数 | 备注 |
|---|---|---|---|
| 单阶段完整镜像 | 约 900MB | 4 | 含完整工具链和源码 |
| 多阶段 + Alpine 运行时 | 约 16MB | 6 | 只含二进制和一个基础系统 |
从 900MB 到 16MB,除了体积,还有两个隐性收益:攻击面大幅缩小(镜像里没有编译器、没有包管理器、没有源码),拉取和分发速度快了一个数量级(对 CI/CD 流水线意义很大)。
这里有几个参数值得单独说:
CGO_ENABLED=0—— 关闭 CGO,编出纯静态二进制。这样运行时镜像里不需要任何 C 库,可以直接扔进最小的基础镜像。如果你的项目必须依赖 CGO(比如用了某些数据库驱动),那就得用带 glibc 的运行时镜像,别硬套。-ldflags="-s -w"—— 去掉符号表和调试信息,二进制能小 25% 左右。代价是出问题时用常规工具看不到完整调用栈,要不要用看你对排障的要求。adduser -D -u 10001 appuser—— Alpine 的adduser语法,-D表示创建系统用户,-u指定 UID。建议显式指定一个大于 10000 的 UID,避免和宿主机上的用户 ID 冲突导致挂载卷权限问题。
前端项目也是同样的思路:构建阶段用完整运行时跑打包,运行阶段只把一个静态文件服务加产物拷进去。构建阶段产生的node_modules、源码、开发依赖统统不进最终镜像。
注意:
COPY --from只拷贝文件,不拷贝元数据里的所有权。想调整文件属主的话,用COPY --chown=appuser:appuser --from=builder ...,否则拷过来的文件属主可能是 root,非 root 用户运行时读不到。
4.2 层缓存命中率:决定构建是 8 秒还是 8 分钟
第二招是缓存。前面提过,层是从上往下逐条匹配的,输入没变就复用缓存。我们来看一段代码改动对构建的影响:
| 改动内容 | 单阶段顺序(正确) | 顺序颠倒(代码在前) |
|---|---|---|
只改server.js | 只有最后几层重建,约 5 秒 | 依赖层全部失效,重新安装,约 90 秒 |
改package.json | 依赖层重建,约 60 秒 | 依赖层重建,约 60 秒 |
| 改基础镜像版本 | 全部重建 | 全部重建 |
关键就是那句COPY package.json package-lock.json ./放在RUN npm ci之前。依赖清单很少变,所以依赖安装层的缓存能长期命中。如果图省事写成COPY . .再RUN npm ci,那每次改一行业务代码,整个依赖都要重装一遍。
还有一个更进阶的玩法:构建缓存挂载。依赖安装除了下载慢,还有个问题是每次缓存失效都要重新下载。新一代构建引擎支持把某个目录挂成持久缓存:
RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev这样即使这一层的缓存失效了,npm 的下载缓存还在,重新安装时直接读本地,省掉网络往返的时间。我用下来在多项目共用同一台构建机的情况下效果明显,第一次 90 秒,后面即使缓存层失效也能压到 15 秒左右。
不同语言对应的缓存目录:
| 生态 | 缓存目录 | 对应包管理器 |
|---|---|---|
| Node.js | /root/.npm或/home/node/.npm | npm |
| Node.js | /usr/local/share/.cache/yarn | yarn |
| Python | /root/.cache/pip | pip |
| Go | /go/pkg/mod和/root/.cache/go-build | go mod |
| Java | /root/.m2 | maven |
用这个特性需要构建引擎支持(新版本默认开启),老版本要手动设置环境变量DOCKER_BUILDKIT=1。另外--mount挂载的目录不会留在最终镜像里,它只在构建期有效,不用担心污染体积。
注意:用了缓存挂载之后,构建结果可能受缓存内容影响,理论上降低了可复现性。如果你的场景对构建一致性要求极高(比如要出合规制品),可以在 CI 里定期清理一次构建缓存做"干净构建",日常开发用缓存提速。这个权衡自己拿捏。
5. 常见问题与排查实录
5.1 报错速查表:八成问题都在这里
构建和运行出问题,第一件事是看报错信息。我整理了一份自己踩过、也帮别人解决过的高频问题清单。
| 报错关键词 | 常见原因 | 处理办法 |
|---|---|---|
exec format error | 构建架构与运行架构不一致 | 用--platform显式指定目标架构 |
no such file or directory(启动时) | 二进制格式不兼容,或解释器路径不存在 | 静态编译,或换成有对应 C 库的基础镜像 |
permission denied | 非 root 用户对文件/目录没有权限 | 用--chown拷文件,或用COPY后chmod |
port is already allocated | 宿主机端口被占用 | 换宿主机端口,或先停掉占用进程 |
no space left on device | 磁盘被中间层撑满 | 清理镜像和构建缓存 |
Cannot find module | 依赖没装全,或.dockerignore把需要的文件排除了 | 检查依赖安装步骤和忽略清单 |
| 安装依赖超时 | 网络波动,或源地址不稳定 | 换源或加重试参数 |
| 容器启动后立刻退出 | 主进程在前台没有持续运行 | 用前台命令启动,别用后台守护模式 |
逐个说几个容易被误判的。
exec format error—— 这个在跨架构构建时特别常见。很多人的开发机是 ARM 架构,服务器是 x86,本地构建镜像直接推上去跑,起不来。解决办法:
docker build --platform linux/amd64 -t demo-app:1.0.0 .推送到仓库时也要带上平台信息。多架构发布的完整做法比较复杂,日常开发至少做到"构建时指定目标平台"。
permission denied—— 出了这个错,先别急着改成 root 运行。八成是文件属主不对。COPY默认以 root 身份拷贝文件,而你切成了非 root 用户运行,读不到或者写不了。正确做法:
COPY --chown=appuser:appuser --from=builder /out/app /usr/local/bin/app如果目录需要在运行时写入(比如日志目录、临时目录),提前建好并改属主:
RUN mkdir -p /var/log/app && chown -R appuser:appuser /var/log/app容器启动后立刻退出—— 这是围观者最多的问题。根本原因是容器的主进程必须在前台持续运行,一旦 PID 1 退出,容器就结束。如果你在CMD里写了service xxx start或者末尾加了个&,主进程启动完瞬间就退了。正确姿势是让程序本身作为前台进程运行,别用守护模式。
5.2 我的排查套路:从外到内四步走
遇到构建或运行失败,我一般按下面这个顺序来,通常三五分钟能定位。
第一步,看完整构建日志。默认输出会折叠细节,加上参数看全量:
docker build --progress=plain -t demo-app:1.0.0 . 2>&1 | tee build.log日志重定向到文件,方便搜索关键字。
第二步,进到失败的中间层里看现场。如果某条RUN挂了,可以临时用那个阶段起个容器进去手动执行:
docker run --rm -it <中间镜像ID> sh中间镜像 ID 在构建日志里能看到,或者docker images -a找悬空镜像。进去之后把失败的那条命令手动敲一遍,报错往往更清晰。
第三步,检查最终镜像的文件系统。怀疑文件没拷进去、路径不对的时候:
docker run --rm -it --entrypoint sh demo-app:1.0.0进去ls一下目标目录,很多"路径写错"的问题当场就现形了。注意如果运行时镜像是精简镜像,可能连sh都没有,那就得在构建阶段加一个临时调试阶段。
第四步,看镜像每一层的体积。排查"为什么这么大":
docker history --no-trunc demo-app:1.0.0输出里能看到每一层的指令和大小,哪个RUN吃了几百 MB 一清二楚。我经常靠这一条发现某个包管理器缓存没清理干净。
5.3 几个只有踩过才知道的坑
换行符问题。在 Windows 上编辑的脚本文件是 CRLF 换行,拷进 Linux 容器里执行会报bad interpreter: no such file or directory,报错信息还特别有误导性——看起来像文件不存在,其实是/bin/sh\r这个解释器不存在。解决办法是在.gitattributes里统一设置,或者在Dockerfile里转一下:
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh时区不对。精简镜像默认是 UTC,日志时间戳跟本地差 8 小时,排查跨时区问题的时候能折磨死人。两个选择:装tzdata并设置TZ环境变量,或者干脆全链路统一用 UTC 记录、展示层再转换。我个人倾向后者,日志里混用本地时间是个长期隐患。
ENV TZ=Asia/Shanghai RUN apk add --no-cache tzdata && \ cp /usr/share/zoneinfo/$TZ /etc/localtime && \ echo $TZ > /etc/timezone注意apk add和后续清理要写在同一个RUN里,前面讲过原因。
健康检查缺失。镜像能起不代表服务可用。加一个HEALTHCHECK,编排工具就能感知服务真实状态:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/health || exit 1--start-period是给应用留的启动宽限期,这段时间内检查失败不计入重试次数。这个参数很多人不写,结果应用启动要 20 秒,健康检查 5 秒就判死,容器反复重启。
日志写文件而不是标准输出。容器的最佳实践是应用把日志打到 stdout/stderr,由容器运行时统一收集。如果应用往文件里写,你就得额外做日志轮转,还容易把磁盘写满。检查一下你的日志配置,把文件输出关掉。
6. 从本地镜像到可交付制品
6.1 标签策略:别让 latest 害了你
镜像构建出来得打标签,这件事看似简单,其实坑很多。
latest不是"最新版本",它只是个默认标签。你不显式指定标签时,构建出来的就是latest。问题在于,它没有任何版本语义,同一个latest今天指向 A,明天指向 B。生产环境用它,等于放弃了回滚能力。
我用的标签规范大致是这样:
| 标签形式 | 用途 | 示例 |
|---|---|---|
| 语义化版本 | 发布制品 | demo-app:1.4.2 |
| 短提交哈希 | 追溯具体代码 | demo-app:a3f9c21 |
| 环境标识 | 区分部署环境 | demo-app:prod-1.4.2 |
| 内容摘要 | 绝对锁定 | demo-app@sha256:... |
一个镜像可以打多个标签,指向同一份内容:
docker build -t demo-app:1.4.2 -t demo-app:a3f9c21 .部署的时候用具体版本号,回滚的时候切回上一个版本号,清清楚楚。想看某个镜像对应哪次提交,用哈希标签查。
摘要(digest)是最严格的锁定方式。它是镜像内容的哈希值,内容变一点摘要就变。在多环境部署、需要绝对一致性的场景下,用摘要引用:
docker pull demo-app@sha256:9f2c8e...代价是可读性差,一般只在自动化流水线的最终部署环节用。
推送之前先登录。私有仓库需要先认证:
docker login <仓库地址> docker tag demo-app:1.4.2 <仓库地址>/<命名空间>/demo-app:1.4.2 docker push <仓库地址>/<命名空间>/demo-app:1.4.2推送前建议扫一遍漏洞,很多仓库自带扫描功能,或者用独立工具在本地扫。基础镜像的漏洞是持续暴露的,定期重建镜像是必要的运维动作,不是一次做完就完事。
6.2 运行时参数:好镜像也要配对参数
镜像做得再干净,跑的时候参数给错一样出问题。几个我每次都会检查的:
| 参数 | 作用 | 我的常用值 | 说明 |
|---|---|---|---|
-p | 端口映射 | -p 3000:3000 | 前面是宿主机,后面是容器 |
-e | 环境变量 | -e NODE_ENV=production | 敏感信息别写在这里,用文件或密钥管理 |
-v | 目录挂载 | -v /data/app:/app/data | 持久化数据必须挂出来 |
--restart | 重启策略 | --restart unless-stopped | 别用always,手工停了还会自己起来 |
--memory | 内存上限 | --memory 512m | 防止单容器吃光宿主机内存 |
--cpus | CPU 限制 | --cpus 1.5 | 按实际负载给 |
--read-only | 只读根文件系统 | 视情况 | 进一步提升安全性,需要配合临时目录挂载 |
几个细节:
--restart always和unless-stopped的区别在于,后者在你手动docker stop之后不会自动拉起。日常运维用后者更符合直觉。
--read-only这个参数很多人不知道。把根文件系统设成只读之后,容器内任何写操作都会被拒绝,攻击者想往容器里落东西就很困难。当然应用需要写的地方得单独挂出来:
docker run -d \ --read-only \ --tmpfs /tmp \ -v /data/app:/app/data \ --restart unless-stopped \ -p 3000:3000 \ demo-app:1.4.2--tmpfs /tmp给临时文件留个内存盘,很多运行时需要往/tmp写东西,不给的话直接崩。
环境变量管理,小规模用--env-file,把变量集中在一个文件里,注意这个文件不能提交到版本库:
docker run -d --env-file ./app.env demo-app:1.4.2到了多服务编排的规模,就该上编排工具了,那个话题展开又是另一篇,这里先按住。
7. 一份可以直接抄的自检清单
写到这里,我把前面散落各处的要点收成一份清单。每次提交Dockerfile之前扫一遍,能省掉大量返工。
7.1 构建前逐条核对
| 检查项 | 为什么重要 |
|---|---|
| 基础镜像是否锁定了完整版本号 | 保证构建可复现 |
| 是否使用了多阶段构建 | 剔除构建期工具链,大幅瘦身 |
依赖清单是否在业务代码之前COPY | 提高缓存命中率 |
安装与清理是否在同一个RUN里 | 避免垃圾文件留在中间层 |
是否有.dockerignore且覆盖了依赖、版本控制、密钥文件 | 加快构建、防止泄露 |
| 是否切换到非 root 用户 | 安全底线 |
| 启动命令是否用 exec 形式 | 保证信号能正确传递给进程 |
| 端口声明是否与实际监听一致 | 编排工具依赖这个信息 |
| 是否配置了健康检查 | 让编排系统能感知真实状态 |
| 日志是否走标准输出 | 便于统一收集 |
7.2 一个我常用的"体积优化前后"对照
以那个 Node.js 示例来说,不同做法的效果差距很直观:
| 写法 | 镜像体积 | 构建时间(改代码后) |
|---|---|---|
完整基础镜像、单阶段、COPY . .在前 | 约 1.1GB | 约 120 秒 |
| 完整基础镜像、依赖后置 | 约 1.0GB | 约 8 秒 |
| Alpine 基础镜像、依赖后置 | 约 180MB | 约 8 秒 |
| Alpine + 依赖后置 + 生产依赖 + 清理缓存 | 约 140MB | 约 6 秒 |
| 以上全部 + 多阶段输出到无依赖运行时 | 约 60MB | 约 6 秒 |
从 1.1GB 压到 60MB,改动无非就是换基础镜像、调COPY顺序、区分构建期和运行期这么几件事。没有黑魔法,全是理解和执行。
7.3 最后几句实在话
我自己刚开始写Dockerfile的时候,是照着网上的示例改的,能跑就行,从来没想过为什么。后来在一次线上问题里被教育了:某个镜像里残留了一份构建期的配置文件,里面写着测试环境的地址,服务启动时读到了它,导致连错环境。排查了两个通宵才发现是镜像层里躺着一份"应该被删掉但没删干净"的文件。那次之后我养成了两个习惯:每写完一个Dockerfile就docker history看一遍每层体积,进到最终镜像里ls一遍确认没有多余的东西。
还有一个我个人坚持的做法:从第一个版本开始就把镜像跑成非 root。等业务跑起来了再改,往往会发现一堆目录权限问题,改起来牵一发动全身,最后就不了了之。一开始就这么做,成本几乎为零。
至于更进一步的东西——镜像签名、供应链安全、多架构发布、构建流水线的缓存共享——等你把上面这些吃透了再看,理解会快得多。容器这条路,真正难的不是语法,是那些看起来"能跑就行"、实际上在暗中积累风险的小决定。