我猜不少人都遇到过这种情况:代码改了一堆,信心满满地敲下 docker compose up -d,结果浏览器一刷新,页面纹丝不动,老毛病一个没少。翻日志也看不出问题,因为容器起来的瞬间,用的根本就是旧镜像里的旧代码。这不是 Compose 坏了,也不是你的代码没保存,而是默认情况下,docker compose up 压根没打算帮你重新打包镜像。
想让 Compose 用最新代码重新构建并启动服务,正确姿势是加一个 --build 参数:docker compose up -d --build。这条命令看起来不起眼,却是“改完代码以后重跑服务”这个高频场景里最容易踩坑、也最值得搞清楚的一条命令。这篇博客我就围绕它,把背后镜像构建、容器重建、缓存命中的逻辑一次讲透,并附上我平时排查问题的完整思路。无论你是刚接触 Docker 的新手,还是已经用了很久但偶尔被缓存坑一把的老手,应该都能从里面拿走点能直接用的东西。
1. 先搞清楚:为什么改了代码,服务却还是“旧版本”
1.1 镜像、容器、Compose 服务三者的关系
要理解 --build 干了什么,先得把三个概念理清楚:镜像、容器、Compose 服务。
镜像可以理解成一份“打包好的程序快照”,里面包含了代码、运行时、依赖、系统库,以及启动命令。容器则是这份快照跑起来之后的实例。你可以有很多个容器同时跑同一个镜像,就像同一个安装包装到两台电脑上。Compose 服务是在 compose.yaml 里定义的一个个应用单元,它描述了“用哪个镜像、映射哪个端口、挂载哪些卷、依赖哪些服务”。
这里有一个很多新手忽略的关键点:你改的是宿主机上的源代码,真正跑起来的是容器里的代码。 如果容器里的代码是从镜像 COPY 进去的,那么源码头改完,必须重新把代码打进镜像,再基于新镜像创建新容器,改动才会生效。这一步不会自动发生,需要一个明确的动作去触发。docker compose up -d --build 就是那个动作。
1.2 不带 --build 时,Compose 到底做了什么
我见过太多人以为 docker compose up -d 是“重新部署”,其实它的真实行为比这保守得多。官方文档对 up 的描述是“创建并启动容器”,而判断“需不需要重建”的主要依据是:本地镜像是否存在、服务的配置是否发生变化。
具体来说,当你运行 docker compose up -d 时:
- 如果服务定义里有
build:,但本地还没有这个镜像,Compose 会按 Dockerfile 构建一次。 - 如果本地已经存在这个镜像,Compose 默认不会重新构建,直接拿旧镜像创建或启动容器。
- 如果容器的配置和 Compose 文件里定义的不一致(比如端口变了、环境变量变了),Compose 会重建容器,但用的还是旧镜像。
所以结论很直接:本地镜像还在的情况下,不带 --build 的 up -d 永远不会把新代码放进镜像,容器里跑的自然还是旧代码。 这就是“改了代码却不生效”最常见的根源。你以为是重新部署,其实只是把旧容器停掉,换了个名字一样的旧姿势重新跑起来。
1.3 --build 参数的真实作用
--build 的存在,就是用来打破上面那种保守行为的。它告诉 Compose:在启动容器之前,不管本地镜像存不存在,先把 build: 段落里的镜像重新构建一遍,然后再创建容器。
完整的命令是这样:
bash复制docker compose up -d --build
拆开看:
up:创建并启动服务。-d:daemon 模式,命令执行后回到终端,容器在后台运行。--build:启动前强制重新构建镜像。
这条命令的实际执行顺序是:build → create → start。也就是说,构建成功之后,Compose 会拿新镜像去对比当前运行的容器。如果镜像 ID 变了,就重建容器;如果镜像没变(构建出来和原来一模一样,比如你只是敲了个命令但代码没改),容器可能不会重建,或者只是按配置重新创建。这一点也会在后面引出“明明 build 了却没变化”的疑惑,等会儿细说。
注意:
--build只确保“重新构建”,不保证“不使用缓存”。它默认会走 Docker 的层缓存,所以构建速度通常很快,但也意味着有时候它不是你想的“完全重建”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. docker compose up -d --build 的完整执行流程
2.1 一条命令背后发生了什么
把 docker compose up -d --build 拆成底层动作后,大致是下面几条:
- Compose 读取
compose.yaml(或docker-compose.yml),解析出服务列表。 - 找出所有带
build:字段的服务,确定哪些需要构建。注意,如果某个服务只有image:没有build:,那--build对它没有任何作用,它只会去拉镜像或复用本地镜像。 - 对每个需要构建的服务,把构建上下文(默认是
build字段指定的目录)发给 Docker 构建引擎,按 Dockerfile 逐条指令执行构建,生成新镜像。 - 构建完成后,Compose 比较新镜像和当前运行容器的镜像 ID、环境变量、挂载卷等配置,判断是否需要重建容器。
- 如果需要重建,就创建新的容器并删除旧的;然后按依赖顺序启动服务。
- 由于加了
-d,全部启动完成后命令立即返回,后续日志交给docker compose logs查看。
这里有个容易忽略的细节:--build 只构建“服务定义里有 build: 字段”的服务。如果你的环境里有 A 服务用 Dockerfile 构建、B 服务直接拉官方镜像,那么跑 up -d --build 时,A 会重新构建,B 不会。B 的更新只能靠手动 docker compose pull 或删除本地镜像来触发。
2.2 构建缓存机制:哪些层会重用,哪些层会重做
Docker 镜像是一层一层的,Dockerfile 里的每条指令基本都会生成一个新层。构建时,Docker 会检查每一层对应的指令和输入内容有没有变化:如果没变,就直接复用本地缓存的层;如果变了,这一层以及它后面的所有层全部重新执行。
这句话值得划重点,因为它解释了很多“灵异事件”。
- 你改了
index.js,但 Dockerfile 里有一行COPY . /app。由于 COPY 会检查源文件内容,Docker 发现代码文件确实变了,于是从这一层开始重做,后面的RUN npm run build、CMD等层也会跟着重做。 - 你没改
package.json,只改了几行业务代码。那么RUN npm install这一层会命中缓存,安装依赖不会重新执行,只有代码相关的层重做。 - 如果你什么都没改,只是跑了一遍
docker compose up -d --build,构建会全部命中缓存,生成的镜像内容几乎不变,Compose 可能连容器都不重建,直接返回“Running/Started”。
所以,--build 其实是一个“用缓存加速、只重做该重做的部分”的智能过程。它非常适合日常开发场景:改了业务代码,重新打包并重启容器,几秒钟搞定;依赖没动,就不用傻傻地重新装一遍。
2.3 和 docker compose build 的区别与配合
很多人会把 docker compose build 和 docker compose up --build 混在一起,其实分工不同。
docker compose build:只负责构建镜像,不创建容器、不启动服务。适合在 CI 流水线里单独构建镜像,或者你想在启动之前把所有镜像都构建好。docker compose up --build:把构建和启动合并成一条命令,日常本地开发最顺手。- 两者组合起来用也行:
docker compose build && docker compose up -d。效果和一条up --build基本等价,只是分成了两步。
我个人习惯是:日常开发用一条 docker compose up -d --build;只有在需要强制无缓存构建、或者需要先拉最新基础镜像时,才拆成 docker compose build --no-cache --pull 和 docker compose up -d 两步走。原因后面会讲,因为 up 这一层有些选项并不支持 --no-cache。
顺带提一句命令演进的坑:老项目里常见的 docker-compose(带横线)是 V1 的 Python 实现,现在已经慢慢被 docker compose(空格,V2 的 Go 插件)取代。新版 Docker Desktop 以及较新版本的 Docker Engine 通常直接自带 docker compose 插件,不用单独安装。如果你执行 docker-compose 报“命令不存在”,可以先看下 docker compose version 能不能用,再决定是否补装。两种命令的常见参数基本一致,但新项目我强烈建议统一用 docker compose。
3. “完全使用新代码”的标准操作清单
3.1 实操场景:改完代码后该跑哪些命令
假设你有一个很常见的 Node.js + Redis 项目,Compose 文件长这样:
yaml复制services:
web:
build: .
image: myapp-web:latest
ports:
- "8080:8080"
depends_on:
- redis
redis:
image: redis:7-alpine
你改了 src/index.js 里的逻辑,想重新部署。正确命令就是:
bash复制docker compose up -d --build
这个命令会触发 web 服务的重新构建(因为 build: . 指向当前目录的 Dockerfile),构建完成后用新镜像重建 web 容器。redis 服务没有 build 字段,直接使用本地已有的 redis:7-alpine 镜像,不会重新拉取,也不会重建(除非配置变了)。
有一种常见误解是“那我是不是每次都该加 --build?会不会很慢?”答案是不会太慢,尤其是依赖层能命中缓存的情况下。这也是我敢于把它当成日常默认命令的原因:它既保证了新代码确实被打进镜像,又不会无脑全量重装依赖。
3.2 如何确认新代码真的生效了
构建完不等于万事大吉,我建议养成“验证一下”的习惯。下面这一套是我平时排查时必走的流程:
bash复制# 1. 看服务状态和启动时间,确认容器确实重建过
docker compose ps
# 2. 看镜像ID、创建时间,确认镜像是刚构建的
docker compose images
# 3. 直接进容器里看代码文件,确认内容是最新的
docker compose exec web cat /app/index.js
# 4. 看启动日志,确认有没有新输出
docker compose logs --tail=50 web
很多时候,你确实跑了 up -d --build,但容器里文件还是旧的。这时候先别怀疑命令,优先检查两件事:一是你的构建上下文目录对不对,二是是不是有卷把宿主机目录挂载进了容器,把镜像里的代码“覆盖”了。后者在开发环境非常常见,volumes 里如果写了类似 - ./src:/app/src 这样挂载,那容器里的 /app/src 永远来自宿主机,跟镜像构建没有关系。这时候问题就不是“代码没有打包”,而是“镜像打包了,但容器没在用它”。
还有一个小技巧:如果你在代码里打印一个明确的版本号或编译时间戳,验证起来会方便很多。比如页脚显示 BUILD_TIME=2025-01-20T15:30:00,日志里输出一行 Started at 15:30,构建成没成功、代码生效没生效,一眼就能看出来。
3.3 强制无缓存构建与拉取最新基础镜像
--build 虽好,但有个软肋:默认走缓存。当你遇到下面这些情况时,普通 --build 可能救不了你:
- 改了
package.json里的依赖版本,或者把 npm 源从官方换成了镜像源,但package-lock.json没变化,缓存层依然命中,依赖没装上新的。 - 基础镜像用的是
ubuntu:latest、node:latest这类 tag,远程已经更新了,本地却一直拿旧的基础镜像在构建。 - Dockerfile 本身没问题,但你怀疑某些层被缓存污染了,就是想彻底重来一次。
这时候需要的是强制无缓存构建:
bash复制docker compose build --no-cache --pull
docker compose up -d
注意两个点:
--no-cache 是 docker compose build 的参数,不是 up 的参数。虽然有部分版本的 docker-compose up --build --no-cache 也能用,但为了兼容性和可读性,我建议把“强制重建镜像”和“启动服务”分开做。
--pull 的作用是构建前尝试从远程仓库拉取最新版本的基础镜像,解决“latest tag 但本地还是旧镜像”的问题。它不会强制重建所有层,只是让基础镜像更新。
下面这个表基本概括了我平时对“各种重启场景”的选择:
| 场景 | 推荐命令 | 说明 |
|---|---|---|
| 只改了业务代码 | docker compose up -d --build |
增量构建,快,依赖层走缓存 |
| 改了依赖或怀疑缓存 | docker compose build --no-cache && docker compose up -d |
无缓存完整重建 |
| 基础镜像可能要更新 | docker compose build --pull && docker compose up -d |
拉最新基础镜像,再增量构建 |
| 代码通过卷挂载进容器 | docker compose restart 或热重载 |
不需要重新 build,宿主机文件即真身 |
3.4 结合数据卷使用时的一个新坑
再补充一个很容易被忽略的坑:数据卷的生命周期和容器、镜像都不一样。docker compose up -d --build 重建的是镜像和容器,命名卷(named volume)里的数据不会丢。数据库容器跑 up --build 完全不会影响 MySQL、Redis 里的数据,这一点可以放心。
但反过来也要小心:如果你用 docker compose down -v,卷会被一并删掉。很多人在折腾“让新代码生效”的时候,会顺手来一句 docker compose down -v && docker compose up -d --build,结果代码是生效了,数据库表也空了,一朝回到解放前。所以请记住:强制删卷是清理数据的手段,不是更新代码的手段。
4. 实战中容易踩的坑与排查经验
4.1 常见问题速查表
把我在实际使用中遇到的典型问题整理成了一张表,排查的时候对着看就行。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 代码改了,页面没变化 | 用的 up -d 没加 --build;浏览器缓存 |
改用 up -d --build;强刷页面 |
| 镜像构建了,容器里还是旧代码 | 宿主机目录通过卷挂载覆盖了镜像内路径 | 检查 volumes,确认挂载路径 |
| 依赖版本更新不生效 | RUN npm install 命中缓存 |
docker compose build --no-cache |
up --build 报错找不到镜像 |
服务没有 build 字段 |
检查 Compose 文件的服务定义 |
docker-compose 命令不存在 |
新版 Docker 用 docker compose 插件 |
改用 docker compose 或者补装 |
| 构建时下载基础镜像很慢 | 网络问题或镜像仓库源 | 配置镜像加速器,或换用体积小的基础镜像 |
| 容器重启后服务没自动起 | 没有设置重启策略 | Compose 文件加 restart: unless-stopped |
| 构建输出全是 CACHED,没看到新东西 | 层缓存命中了,代码可能真没变 | 检查代码文件修改时间,必要时 --no-cache |
4.2 Dockerfile 分层设计不当导致缓存失效
很多人刚写 Dockerfile 时会把 COPY 和 RUN npm install 的顺序搞反,典型的错误写法是这样:
dockerfile复制FROM node:18
COPY . /app
WORKDIR /app
RUN npm install
CMD ["npm", "start"]
这种写法有什么问题?你改了任意一行源码,COPY . /app 这一层的输入就变了,导致 RUN npm install 这一层也失效。明明依赖一个都没改,却每次都要重新下载安装,白白浪费几分钟。
正确的“缓存友好”写法是把依赖安装放在前面,代码复制放在后面:
dockerfile复制FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]
这样当你只改业务代码时,COPY package*.json 和 RUN npm install 都命中缓存,只有 COPY . . 及之后的层会重新构建。构建速度快得不止一点。
同样的道理也适用于 Python、Go、Java 等项目,原则是一致的:把“经常变化的文件”放在 Dockerfile 后面,把“恒定依赖”放在前面。 这是 Docker 分层缓存体系里最重要的一条经验。
4.3 BuildKit 行为差异带来的“诡异”现象
较新版本的 Docker 默认使用 BuildKit 构建引擎,输出风格和旧版 builder 完全不同,缓存命中判定也略有差异。你可能会看到构建时一条 [builder 2/2] RUN ... 或 CACHED 的提示,心里犯嘀咕“是不是没构建成功”。其实只要看到 CACHED 字样就说明这一层复用了缓存,这是正常行为。
BuildKit 下有几个值得注意的细节:
ARG参与层哈希计算,如果构建参数变了,受影响层的缓存就会失效。这可以用来主动“打断”缓存,比如故意传一个版本号ARG APP_VERSION=20250120,每次改版本号就会触发相关层重建。RUN --mount=type=cache可以把npm、pip、go mod的缓存目录挂载为持久缓存,进一步加速构建。不过这个属于进阶技巧,新手可以先不碰。- BuildKit 对基础镜像的缓存判断比较严格,如果你改了 Dockerfile 里的
FROM node:18为FROM node:20,那几乎所有的层都会重建,这是正常的。
有时候你改了 Dockerfile 里的环境变量,但构建时发现“没变化”,多半是环境变量符号写错了,或者改动发生在后面不影响构建输出的位置。多留意构建日志里的层哈希变化,比瞎猜靠谱得多。
4.4 从日志到容器内文件:一套排查路径
最后分享一套我在实战中反复使用的排查路径,专门对付“明明应该新但结果旧”的问题:
bash复制# 第一步:先确认容器是什么时候启动的
docker compose ps
# 第二步:确认当前容器里跑的镜像ID,和刚构建出来的镜像ID是否一致
docker compose images
# 第三步:直接进容器,看看代码文件到底是不是新的
docker compose exec web cat /app/index.js
# 第四步:如果文件是新的,但运行结果还是旧的,看日志里有没有加载旧逻辑
docker compose logs --tail=100 web
# 第五步:检查是不是进程缓存或没有重启旧进程导致
docker compose restart web
这套排查路径的核心思路是:先在容器外面确认镜像和容器,再进到容器里面确认文件,最后才怀疑应用进程本身。 大多数问题都出在前两步,因为很多人根本没意识到 up -d 不会重新构建镜像。
如果走到第三步发现文件已经是最新的,那就不是构建问题,而是应用进程的问题。比如 Node.js 启动时已经把旧代码加载进内存了,单纯重启容器才会重新加载;或者热重载没生效,需要强制重启进程。这时候 docker compose restart web 就能解决,不需要重新构建。
我还踩过一个哭笑不得的坑:改完代码跑 up -d --build,镜像确实构建了,容器也确实重建了,但页面还是旧的。排查半天发现是 Nginx 缓存了静态资源,和容器一点关系都没有。所以当你“什么都做对了”,多想想浏览器、CDN、反向代理这些镜像之外的东西,它们也经常是“看起来没生效”的元凶。
一句话收个尾:在我自己的开发日常里,只要动了业务代码,docker compose up -d --build 已经是肌肉记忆级别的命令,又快又稳。它不会傻傻地全量重装依赖,也不会漏掉最新代码。真正需要更重的手段,比如 build --no-cache、--pull 时,反而说明 Dockerfile 分层或者构建策略还有优化空间。希望这篇盘点能帮你少踩几个坑,下次再遇到“明明改了代码却不生效”,先看一眼命令里有没有 --build,再按上面那套排查路径走一遍,问题基本都能水落石出。
