docker-compose build实战指南:构建、参数配置与避坑
发布时间:2026/10/2 9:13:49 锦皓数字建站

docker-compose 系列写到第14篇后台和评论区问得最多的就是 build 这个属性。前 13 篇把镜像、容器、网络、卷、环境变量这些高频属性都过了一遍唯独 build平时用得少真到要用的时候才发现里面细节多到能专门写一篇文章。其实 build 解决的就一件事你服务里的镜像到底从哪来——是从仓库直接拉还是自己基于 Dockerfile 现场构建出来。这篇就把 build 的写法、参数、和 image 的配合关系以及我在真实项目里踩过的坑全部拆开讲透适合所有在 docker-compose.yml 里写过、或者正准备写 build 的开发者。1. 先搞清楚build 和 image 到底是什么分工1.1 一条服务定义里镜像只能有一个来源写 docker-compose 的人都知道services 下面每个服务必须有镜像才能跑起来。而这个镜像无非两个来源一个是image属性指定的仓库镜像比如image: nginx:1.25docker 会把镜像从 Docker Hub 或者你配置的私有仓库拉下来另一个就是build属性从本地的 Dockerfile 现场构建出一个新镜像然后再启动容器。这两个办法没有谁更高级只有合不合适。image 的好处是镜像已经存在哪儿都能跑部署机上不需要任何源代码拉下来就完事build 的好处是镜像完全由你掌控代码在哪、依赖怎么装、环境变量怎么设全在 Dockerfile 里写死了。我经常看到有人纠结build 和 image 是不是二选一其实它俩的关系更像是一个服务必须有一个可用的镜像image 是给你一个现成的build 是帮你现场造一个。至于现场造完之后能不能给它起个名字这就涉及后面要讲的 build image 同时写的情况。1.2 哪些场景必须自己构建镜像如果你还在犹豫要不要用 build可以对照一下这几种情况命中任何一条你大概率就需要它项目代码在公司内部仓库不想对外发布公开镜像又需要统一部署那就得每次用代码构建出镜像。需要在基础镜像上做定制比如装了内部证书、改了时区、预置了初始化脚本这种定制 Dockerfile 就是唯一的载体。本地开发想直接用工作区里的代码跑服务改完代码重新 build 就能生效而不是每次手动 commit 再等 CI 出镜像。中间件或数据服务需要预置配置、插件、数据初始化文件通过 COPY 命令打进镜像里比挂载卷更省事。不过也要泼一盆冷水build 是本地行为它不产生任何共享产物。你在一台机器上 build 出来的镜像换一台机器就没了那边还得重新拉代码、重新 build。所以项目一旦上了多机部署build 通常是放在 CI 里做的服务器上只负责拉镜像这个后面第 6 章详细说。2. build 的几种写法从简到繁2.1 最短形态只给一个路径最简单的 build 就是直接在服务下面写一个字符串。比如services: web: build: .这个.是构建上下文build context的路径docker-compose 会把这个目录下的所有文件打包发给 Docker 守护进程然后在这个目录里找名为Dockerfile的文件开始构建。如果你只有一个 Dockerfile而且它就在项目根目录这种写法完全够用。很多人刚上手时会把build: .和image: xxx当成同一个东西其实完全不同。build: .之后你还需要docker compose build或者docker compose up --build才会真正执行构建镜像并不会凭空出现。而image: nginx:1.25只要 up 就会去拉取。2.2 标准写法context dockerfile 分开指定当 Dockerfile 的名字不叫Dockerfile或者不想把 Dockerfile 直接放在代码根目录时字符串写法就不好使了得换成对象写法services: backend: build: context: ./backend dockerfile: Dockerfile.dev这里context还是构建上下文dockerfile是相对于 context 的路径。比如你上一个项目把所有 Dockerfile 集中在docker/目录里可以写成build: context: . dockerfile: docker/Dockerfile注意这里的dockerfile路径是相对于context的不是相对于 compose 文件的。上面例子中 context 是项目根目录所以docker/Dockerfile指的是./docker/Dockerfile。如果你把 context 写到./docker那 dockerfile 就只能在./docker里面找想 COPY 项目根目录的代码就做不到了——这个坑我后面专门用一节讲。2.3 构建参数args、labels、target对象写法最大的价值是能传构建参数。举个实际案例一个前端项目需要区分开发和生产依赖可以这样传参services: web: build: context: ./web args: NODE_ENV: production GIT_COMMIT: ${GIT_COMMIT:-unknown}args支持两种写法上面这种是键值对也可以写成列表形式- NODE_ENVproduction效果一样。但这些参数要在 Dockerfile 里生效必须先用ARG声明FROM node:20 ARG NODE_ENVdevelopment ARG GIT_COMMITunknown WORKDIR /app COPY package.json ./ RUN if [ $NODE_ENV production ]; then \ npm ci --onlyproduction; \ else \ npm install; \ fi如果不声明compose 里的 args 就传了个寂寞构建过程不会有任何反应。这一点在 Dockerfile 比较旧的项目里特别容易忽略。labels参数可以给构建出来的镜像打标签主要用于镜像资产管理比如记录构建时间、团队信息。target是针对多阶段构建的用来指定构建到哪个阶段为止这个在开发/生产不同目标阶段时非常好用第 5 章会详细讲。2.4 构建环境network、shm_size、extra_hosts、cache_from除了构建参数还有一批影响构建过程的属性我用一张表列出来方便查参数默认值作用context无必填构建上下文目录dockerfileDockerfileDockerfile 路径相对 contextargs无构建参数需在 Dockerfile 中用 ARG 声明labels无写入镜像的标签target最后一个阶段多阶段构建的目标阶段network默认网络构建过程使用的网络模式shm_size64MB/dev/shm 大小前端编译不够用时可调大extra_hosts无构建时追加 hosts 映射cache_from无指定缓存来源镜像CI 常用pullfalse构建前强制拉取最新基础镜像privilegedfalse构建容器特权模式慎用这里面最实用的两个一个是shm_size一个是cache_from。前者我经常在前端项目里遇到webpack 或 vite 构建时如果报 shm 内存不足、或者进程莫名其妙被杀多半是默认的 64MB 不够用改成shm_size: 1gb往往就好了。后者在 CI 里很常见先拉一个上次构建的镜像作为缓存来源能让重复构建快很多。比如build: context: . cache_from: - registry.internal/web:latest3. build 和 image 同时写compose 到底在干嘛3.1 构建产物如何打标签我见过不少人在 services 里同时写build和image然后一脸疑惑地问这不冲突吗。实际上不冲突反而是个很实用的组合。当 build 和 image 同时出现时compose 会在构建完成后把构建出来的镜像打上 image 属性指定的标签。services: app: build: . image: registry.internal/app:1.2.0执行docker compose build后本地会多出一个叫registry.internal/app:1.2.0的镜像。这个模式的价值在于同一份 compose 文件在 CI 里构建并 push 到私有仓库在部署机上直接用同一个文件docker compose up拉镜像启动。两边配置完全一致行为也完全一致。3.2 up、build、--build 到底什么关系新手最容易搞混的就是这几个命令的触发时机我直接说结论docker compose build只构建镜像不启动容器。docker compose up如果本地没有对应镜像且服务里配置了 build会先构建再启动如果本地已经有这个镜像就直接用不会重新构建。docker compose up --build不管本地有没有镜像都强制重新构建再启动。所以在本地开发时改完代码我通常直接docker compose up --build一条命令完成构建和启动省得先 build 再 up 两步走。如果只想清掉构建缓存可以用docker compose build --no-cache。新版 docker-compose 还支持在服务里配置pull_policy可以精确控制是拉取还是构建默认行为跟不上需求时可以考虑它不过大多数项目用不到这么细。4. 构建上下文与 .dockerignore这里翻车最频繁4.1 上下文范围决定一切先记住一句话Dockerfile 里所有的 COPY、ADD都只能发生在构建上下文范围内。构建上下文就是你写给 build 的那个路径它下辖的所有文件都会被发送给 Docker 守护进程。Dockerfile 里写COPY ../something /app这种跨出上下文的路径构建会直接报错。这个问题的常见翻车姿势是项目结构明明是这样的但 compose 里 context 配错了。比如project/ ├── docker/ │ └── Dockerfile └── src/ └── main.py如果你写build: context: ./docker dockerfile: Dockerfile那构建上下文就变成了./docker里面没有srcDockerfile 里想COPY ../src /app就会失败。正确做法是 context 指向项目根目录dockerfile 指向 docker 目录里的文件build: context: . dockerfile: docker/Dockerfile还有一个容易被忽视的点上下文越大发送给守护进程的数据越多。如果 context 指到/或者包含大量数据的目录构建前的发送上下文这一步就能卡半天。这个体感在 CI 上尤其明显。4.2 .dockerignore 不写会怎样dockerignore 对构建的影响很多人要到第一次在 CI 上构建超时才感受到。它的作用和 .gitignore 类似告诉 docker 哪些文件不要发送给构建进程。一个典型的 Node 项目 .dockerignore 长这样node_modules .git dist *.log .env .gitignore没写它的后果是什么node_modules随便几百 MB.git目录里全是历史对象可能几十 MB这些全都老老实实打包发送给守护进程。我在一个项目里见过没写 .dockerignore 时构建上下文 1.5GB写了之后降到 50MB构建速度提升肉眼可见。内网环境还好CI 在公网上跑时这种差距就是几分钟和几秒钟的区别。还要提醒一句.dockerignore 只影响构建上下文的发送范围不影响最终镜像里的内容。最终镜像里放了什么还是 Dockerfile 里 COPY 了什么决定的。4.3 缓存失效的顺序陷阱Docker 构建是有层缓存的每一行指令如果没变化构建时可以复用旧层。但缓存能不能命中跟 Dockerfile 里指令的顺序关系极大。看一个经典例子FROM node:20 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . .这个顺序是先锁依赖再拷代码。因为npm ci只需要 package.json 和 lock 文件只要这两个文件没变这一层就永远命中缓存哪怕后面代码改了一百遍也不需要重新安装依赖。反过来如果你把COPY . .放在RUN npm ci前面那代码一改整个缓存链就断了每次都要重新 npm ci慢到怀疑人生。同样的道理适用于所有语言先拷贝依赖清单文件安装依赖再拷贝源码。另外还有个隐蔽的缓存杀手是 ARG——只要构建参数的值变了它之后的所有层都会失效。所以像 GIT_COMMIT 这种每次构建都变的参数建议放在 Dockerfile 靠后的位置再用否则前面的依赖缓存全白搭。5. 真实项目里 build 的几个坑5.1 多阶段构建没用好镜像体积直接失控多阶段构建是控制镜像体积的利器但用不好反而坑自己。最常见的场景是一个前端工程Dockerfile 长这样FROM node:20 AS builder WORKDIR /app COPY package.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --frombuilder /app/dist /usr/share/nginx/html如果 compose 里不指定 target默认用最后一个阶段也就是 nginx 那个体积很小没问题。但如果你的 Dockerfile 里有多条线比如开发和生产各有一条构建链而你希望开发环境用带调试工具的节点镜像、生产环境用精简的运行时镜像那就必须在 compose 里指定 target# docker-compose.override.yml开发环境 services: web: build: context: ./web target: dev# docker-compose.prod.yml生产环境 services: web: build: context: ./web target: prod这里最容易出的错是多阶段里某一步装了一大堆编译工具比如 gcc、python后面阶段没用COPY --from去取产物而是把整个上下文 COPY 进来了镜像里全是垃圾。镜像体积到了几个 GB 才反应过来排查起来很痛苦。5.2 BuildKit 开启后的行为差异新版 Docker 默认启用了 BuildKit构建日志、缓存策略和以前都不一样。最直接的体感是以前构建时的输出是每行Step 1/5 : FROM ...BuildKit 下变成了进度条和并行阶段展示很多人第一次看还以为是卡住了。BuildKit 真正带来的是新语法能力比如缓存挂载FROM node:20 WORKDIR /app RUN --mounttypecache,target/root/.npm \ npm ci COPY . .这段在传统模式下会直接报语法错误因为--mounttypecache是 BuildKit 的语法。这里要提醒的是如果你在公司用旧版 Docker 或者 CI 环境里显式把DOCKER_BUILDKIT0关掉了Dockerfile 里就不能出现这种新语法。反过来代码里出现了这种语法也不要慌先确认一下构建环境是否支持。另外 BuildKit 下构建拉取私有仓库基础镜像时认证走的是docker login后的凭据。在 CI 里如果构建突然拉不动私有基础镜像多半是没提前 login而不是网络问题。5.3 私有仓库基础镜像拉不下来前面热门词里有 nexus 3.28.1这里顺带多说一句。很多公司会在内网搭 Nexus 或者 Harbor 当私有镜像仓库用来代理 Docker Hub 并缓存基础镜像。配置好之后Docker daemon 的 registry-mirror 指向内网 NexusFROM node:20这种基础镜像就会优先从内网拉速度飞快还不会因为外网故障导致构建失败。但 Nexus 或者仓库刚搭好的时候有个坑代理仓库没有缓存过某个 tag第一次拉还是要去上游拿时间同样很长。所以在当中转的 registry 上提前把常用基础镜像 trigger 一遍缓存比盲等第一遍构建要靠谱得多。如果你在构建时总卡在FROM那一步先检查 registry mirror 配没配好再检查基础镜像 tag 是否真的存在。5.4 前端构建内存不足与 shm_sizeNode 项目在容器里构建最容易报两类错一类是JavaScript heap out of memory另一类是编译进程直接被 OOM Killed。前者通常是 Node 默认堆内存太小后者可能是容器 shm 不够或者机器内存不足。对应解法有两种。第一种是传环境变量在 args 里带上build: context: ./web args: NODE_OPTIONS: --max-old-space-size2048Dockerfile 里记得声明 ARG并且在 RUN 之前把它导成环境变量FROM node:20 ARG NODE_OPTIONS ENV NODE_OPTIONS$NODE_OPTIONS第二种是调大构建时的共享内存前端工具链很多依赖 /dev/shm默认 64MB 实在太紧张build: context: ./web shm_size: 1gb这两个配置配合使用基本能把容器里前端构建的内存问题解决掉。如果再不行那就不是配置问题了是物理内存真不够该加机器了。5.5 改代码没生效的幽灵缓存还有一个特别折腾人的现象明明改了代码docker compose up --build跑完容器里跑的还是旧代码。这种幽灵缓存多半是层缓存命中导致的——比如 Dockerfile 里先COPY . .再 RUN 什么而你以为改了文件就能触发重跑实际 COPY 层判断的是文件内容快照某些文件被 .dockerignore 排除、或者文件权限/owner 变化不会触发内容变化判断层缓存就直接复用了。遇到这种情况最省事的排查手段是docker compose build --no-cache如果禁用缓存后是新代码那就是层缓存的问题。再深一层可以把 Dockerfile 里最不希望缓存的 COPY 层之后加一个无关紧要的 ARG或者接受现实做一次全量构建。缓存这东西用好了是加速器用不好就是改代码不生效的背锅侠。6. 多环境与 CI/CD 下的 build 策略6.1 开发环境构建、生产环境拉镜像说一个我在多个团队里反复强调的结论生产环境尽量不要直接 build。构建需要源码、需要上下文、需要网络而且构建结果不可复现的风险很高。更合理的流程是CI 里用 compose 的 build 构建镜像打上版本 tagpush 到私有仓库生产服务器上用同一份 compose 文件但只执行 pull 和 up。具体到 compose 文件怎么组织我推荐用 override 的方式。基础文件只写 image# docker-compose.yml services: web: image: registry.internal/web:${TAG:-latest}本地开发可以用 override 文件加一个 build# docker-compose.override.yml services: web: build: ./webdocker compose 默认会同时读docker-compose.yml和docker-compose.override.yml所以本地开发时docker compose up --build走的是本地构建生产环境用docker compose -f docker-compose.yml upoverride 文件不参与走的是拉取镜像。一套文件两种行为改动面最小也不容易互相污染。6.2 构建参数当配置项管理args 是 build 和外部环境之间唯一的桥建议把所有可变的东西都收敛到 args 上比如版本号、环境名、基础镜像 tag。可以从 .env 文件里注入也可以直接从 CI 的环境变量注入compose 会自动做插值build: context: . args: BASE_IMAGE: ${BASE_IMAGE:-node:20} APP_ENV: ${APP_ENV:-production}Dockerfile 里对应ARG BASE_IMAGEnode:20 FROM ${BASE_IMAGE}这样做的最大好处是环境差异全部收敛在 compose 配置层Dockerfile 不需要为每个环境维护一份。这里要特别提醒一个安全细节构建参数不要放密钥。args 会出现在镜像历史里别人docker history一下就能看到。证书、密码、token 这类东西要么用 secret mountBuildKit 下RUN --mounttypesecret要么在运行时通过环境变量注入千万别塞进 args。6.3 固定基础镜像版本别用 latest最后说一个所有 build 项目的通病FROM node:latest、FROM nginx:latest这种写法。当时用着没问题三个月后再构建拉到的最新版基础镜像可能已经把依赖、行为都改了构建可能过可能直接挂而且不好排查。正确的做法是固定一个明确的版本甚至固定到 digestFROM node:20.12.0-slimsha256:xxxxxxxx固定版本之后构建结果才可复现出问题也知道去哪查。想要安全更新就主动去改版本并走一遍回归而不是让 latest 哪天出事哪天算。这个内容后续还可以这样扩展把 compose 的 build 和 Registry 的镜像清理策略配合起来构建产物打上短 commit hash 作为 tag部署时精确对应代码版本回滚也方便。我自己的习惯是固定 tag 前缀加短 hash比如web-7f3a2b1镜像一目了然排查问题的时候省很多时间。写 build 之前先想清楚这三件事上下文要多大、目标阶段是哪个、参数里会不会带敏感信息。想清楚了再写比写完再排坑省事得多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。